switch timeline to use tremor scatter chart

This commit is contained in:
Daniel Roth 2026-02-02 18:15:31 +00:00
parent 244053bea8
commit b91640d168
2 changed files with 427 additions and 286 deletions

View file

@ -1,17 +1,231 @@
// "use client";
// import React, { useRef, useState } from "react";
// import {
// Table,
// TableHeader,
// TableBody,
// TableRow,
// TableHead,
// TableCell,
// } from "@/app/shadcn_components/ui/table";
// import { Element } from "../types/element";
// import { ASPECT_TYPE_LABELS, ELEMENT_TYPE_LABELS } from "../constants";
// export type ElementGroup = {
// elementType: string;
// elementInstance: number;
// elements: Element[];
// };
// const thStyle: React.CSSProperties = {
// borderBottom: "1px solid #ccc",
// textAlign: "left",
// padding: "4px",
// };
// const tdStyle: React.CSSProperties = {
// borderBottom: "1px solid #eee",
// padding: "4px",
// };
// type Props = {
// groupedElements: ElementGroup[];
// };
// export default function ElementTable({ groupedElements }: Props) {
// const [selectedTypes, setSelectedTypes] = useState<Set<string>>(new Set());
// const [expandedParents, setExpandedParents] = useState<Set<string>>(new Set());
// const [showDropdown, setShowDropdown] = useState(false);
// const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
// const buttonRef = useRef<HTMLButtonElement | null>(null);
// const uniqueElementTypes = Array.from(new Set(groupedElements.map(g => g.elementType))).sort((a, b) =>
// a.localeCompare(b)
// );
// const toggleTypeFilter = (type: string) => {
// const newSet = new Set(selectedTypes);
// if (newSet.has(type)) newSet.delete(type);
// else newSet.add(type);
// setSelectedTypes(newSet);
// };
// const selectAll = () => setSelectedTypes(new Set(uniqueElementTypes));
// const deselectAll = () => setSelectedTypes(new Set());
// const filteredGroups =
// selectedTypes.size === 0
// ? groupedElements
// : groupedElements.filter(g => selectedTypes.has(g.elementType));
// const sortedGroups = [...filteredGroups].sort((a, b) =>
// a.elementType.localeCompare(b.elementType)
// );
// const toggleParent = (key: string) => {
// const newSet = new Set(expandedParents);
// if (newSet.has(key)) newSet.delete(key);
// else newSet.add(key);
// setExpandedParents(newSet);
// };
// // Handle dropdown toggle and position
// const handleDropdownToggle = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
// const rect = e.currentTarget.getBoundingClientRect();
// const parentRect = (e.currentTarget.offsetParent as HTMLElement)?.getBoundingClientRect();
// if (parentRect) {
// setDropdownPos({
// top: rect.bottom - parentRect.top,
// left: rect.left - parentRect.left,
// });
// }
// setShowDropdown(prev => !prev);
// };
// return (
// <div className="relative">
// <div className="rounded-md border">
// <Table>
// <TableHeader>
// <TableRow>
// <TableHead>
// Element
// {/* Filter button stays in header */}
// <button
// ref={buttonRef}
// onClick={handleDropdownToggle}
// className="ml-2 btn btn-sm"
// >
// ▼
// </button>
// </TableHead>
// <TableHead>No. Aspects</TableHead>
// </TableRow>
// </TableHeader>
// <TableBody>
// {sortedGroups.map(group => {
// const parentKey = `${group.elementType}__${group.elementInstance}`;
// const isExpanded = expandedParents.has(parentKey);
// const totalAspects = group.elements.reduce((acc, el) => acc + el.aspects.length, 0);
// return (
// <React.Fragment key={parentKey}>
// {/* Parent Row */}
// <TableRow
// className="bg-gray-100 font-bold cursor-pointer"
// onClick={() => toggleParent(parentKey)}
// >
// <TableCell>
// {`${ELEMENT_TYPE_LABELS[group.elementType] ?? group.elementType.replaceAll("_", " ")}${group.elementInstance > 1 ? ` (${group.elementInstance})` : ""}`}
// </TableCell>
// <TableCell>{totalAspects}</TableCell>
// </TableRow>
// {/* Child Table */}
// {isExpanded && (
// <TableRow>
// <TableCell colSpan={2} className="p-0">
// <Table className="m-0 w-full border-t">
// <TableHeader>
// <TableRow>
// <TableHead>Aspect Type</TableHead>
// <TableHead>Value</TableHead>
// <TableHead>Quantity</TableHead>
// <TableHead>Install Date</TableHead>
// <TableHead>Renewal Year</TableHead>
// <TableHead>Comments</TableHead>
// </TableRow>
// </TableHeader>
// <TableBody>
// {group.elements.flatMap(el =>
// el.aspects
// .sort((a, b) => {
// const labelA = ASPECT_TYPE_LABELS[a.aspectType] ?? a.aspectType;
// const labelB = ASPECT_TYPE_LABELS[b.aspectType] ?? b.aspectType;
// return labelA.localeCompare(labelB);
// })
// .map(aspect => (
// <TableRow key={aspect.id}>
// <TableCell>{ASPECT_TYPE_LABELS[aspect.aspectType] ?? aspect.aspectType}</TableCell>
// <TableCell>{aspect.value ?? "-"}</TableCell>
// <TableCell>{aspect.quantity ?? "-"}</TableCell>
// <TableCell>{aspect.installDate ?? "-"}</TableCell>
// <TableCell>{aspect.renewalYear ?? "-"}</TableCell>
// <TableCell>{aspect.comments ?? "-"}</TableCell>
// </TableRow>
// ))
// )}
// </TableBody>
// </Table>
// </TableCell>
// </TableRow>
// )}
// </React.Fragment>
// );
// })}
// </TableBody>
// </Table>
// </div>
// {/* Floating Dropdown */}
// {showDropdown && (
// <div
// className="absolute z-20 max-h-64 w-72 overflow-y-auto rounded border bg-white p-2 shadow"
// style={{ top: dropdownPos.top, left: dropdownPos.left }}
// >
// <div className="mb-2 flex gap-2">
// <button onClick={selectAll} className="btn btn-xs">
// Select All
// </button>
// <button onClick={deselectAll} className="btn btn-xs">
// Deselect All
// </button>
// </div>
// {uniqueElementTypes
// .sort((a, b) => {
// const labelA = ELEMENT_TYPE_LABELS[a] ?? a.replaceAll("_", " ");
// const labelB = ELEMENT_TYPE_LABELS[b] ?? b.replaceAll("_", " ");
// return labelA.localeCompare(labelB);
// })
// .map(type => (
// <label key={type} className="block cursor-pointer">
// <input
// type="checkbox"
// checked={selectedTypes.has(type)}
// onChange={() => toggleTypeFilter(type)}
// className="mr-2"
// />
// {ELEMENT_TYPE_LABELS[type] ?? type.replaceAll("_", " ")}
// </label>
// ))}
// <button
// onClick={() => setShowDropdown(false)}
// className="mt-2 btn btn-xs"
// >
// Close
// </button>
// </div>
// )}
// </div>
// );
// }
"use client";
import React, { useRef, useState } from "react";
import React, { useState, useEffect } from "react";
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from "@/app/shadcn_components/ui/table";
useReactTable,
getCoreRowModel,
getFilteredRowModel,
ColumnDef,
flexRender,
} from "@tanstack/react-table";
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/app/shadcn_components/ui/table";
import { Element } from "../types/element";
import { ASPECT_TYPE_LABELS, ELEMENT_TYPE_LABELS } from "../constants";
import { ELEMENT_TYPE_LABELS, ASPECT_TYPE_LABELS } from "../constants";
export type ElementGroup = {
elementType: string;
@ -19,196 +233,168 @@ export type ElementGroup = {
elements: Element[];
};
const thStyle: React.CSSProperties = {
borderBottom: "1px solid #ccc",
textAlign: "left",
padding: "4px",
};
const tdStyle: React.CSSProperties = {
borderBottom: "1px solid #eee",
padding: "4px",
};
type Props = {
groupedElements: ElementGroup[];
};
export default function ElementTable({ groupedElements }: Props) {
const [selectedTypes, setSelectedTypes] = useState<Set<string>>(new Set());
const [expandedParents, setExpandedParents] = useState<Set<string>>(new Set());
const [showDropdown, setShowDropdown] = useState(false);
const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
const buttonRef = useRef<HTMLButtonElement | null>(null);
// -----------------------
// Hooks
// -----------------------
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
const [filterText, setFilterText] = useState("");
const [isMounted, setIsMounted] = useState(false);
const uniqueElementTypes = Array.from(new Set(groupedElements.map(g => g.elementType))).sort((a, b) =>
a.localeCompare(b)
);
useEffect(() => setIsMounted(true), []);
const toggleTypeFilter = (type: string) => {
const newSet = new Set(selectedTypes);
if (newSet.has(type)) newSet.delete(type);
else newSet.add(type);
setSelectedTypes(newSet);
// -----------------------
// Columns
// -----------------------
const columns: ColumnDef<ElementGroup, any>[] = [
{
accessorKey: "elementType",
header: "Element",
cell: info => {
const value = info.getValue() as string;
const label = ELEMENT_TYPE_LABELS[value] ?? value.replaceAll("_", " ");
const instance = info.row.original.elementInstance;
return instance > 1 ? `${label} (${instance})` : label;
},
},
{
accessorFn: row => row.elements.reduce((acc, el) => acc + el.aspects.length, 0),
id: "noAspects",
header: "No. Aspects",
cell: info => info.getValue(),
enableColumnFilter: false, // disable column filtering
},
];
// -----------------------
// Table instance
// -----------------------
const table = useReactTable({
data: groupedElements,
columns,
state: { globalFilter: filterText },
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getRowId: row => `${row.elementType}__${row.elementInstance}`,
globalFilterFn: (row, _columnId, filterValue) => {
// filter only elementType
return row.original.elementType
.toLowerCase()
.includes((filterValue as string).toLowerCase());
},
onGlobalFilterChange: setFilterText,
});
const toggleRow = (rowId: string) => {
setExpandedRows(prev => {
const newSet = new Set(prev);
if (newSet.has(rowId)) newSet.delete(rowId);
else newSet.add(rowId);
return newSet;
});
};
const selectAll = () => setSelectedTypes(new Set(uniqueElementTypes));
const deselectAll = () => setSelectedTypes(new Set());
const filteredGroups =
selectedTypes.size === 0
? groupedElements
: groupedElements.filter(g => selectedTypes.has(g.elementType));
const sortedGroups = [...filteredGroups].sort((a, b) =>
a.elementType.localeCompare(b.elementType)
);
const toggleParent = (key: string) => {
const newSet = new Set(expandedParents);
if (newSet.has(key)) newSet.delete(key);
else newSet.add(key);
setExpandedParents(newSet);
};
// Handle dropdown toggle and position
const handleDropdownToggle = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
const rect = e.currentTarget.getBoundingClientRect();
const parentRect = (e.currentTarget.offsetParent as HTMLElement)?.getBoundingClientRect();
if (parentRect) {
setDropdownPos({
top: rect.bottom - parentRect.top,
left: rect.left - parentRect.left,
});
}
setShowDropdown(prev => !prev);
};
// -----------------------
// Prevent SSR hydration errors
// -----------------------
if (!isMounted) return null;
return (
<div className="relative">
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>
Element
{/* Filter button stays in header */}
<button
ref={buttonRef}
onClick={handleDropdownToggle}
className="ml-2 btn btn-sm"
>
</button>
</TableHead>
<TableHead>No. Aspects</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedGroups.map(group => {
const parentKey = `${group.elementType}__${group.elementInstance}`;
const isExpanded = expandedParents.has(parentKey);
const totalAspects = group.elements.reduce((acc, el) => acc + el.aspects.length, 0);
return (
<React.Fragment key={parentKey}>
{/* Parent Row */}
<TableRow
className="bg-gray-100 font-bold cursor-pointer"
onClick={() => toggleParent(parentKey)}
>
<TableCell>
{`${ELEMENT_TYPE_LABELS[group.elementType] ?? group.elementType.replaceAll("_", " ")}${group.elementInstance > 1 ? ` (${group.elementInstance})` : ""}`}
</TableCell>
<TableCell>{totalAspects}</TableCell>
</TableRow>
{/* Child Table */}
{isExpanded && (
<TableRow>
<TableCell colSpan={2} className="p-0">
<Table className="m-0 w-full border-t">
<TableHeader>
<TableRow>
<TableHead>Aspect Type</TableHead>
<TableHead>Value</TableHead>
<TableHead>Quantity</TableHead>
<TableHead>Install Date</TableHead>
<TableHead>Renewal Year</TableHead>
<TableHead>Comments</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{group.elements.flatMap(el =>
el.aspects
.sort((a, b) => {
const labelA = ASPECT_TYPE_LABELS[a.aspectType] ?? a.aspectType;
const labelB = ASPECT_TYPE_LABELS[b.aspectType] ?? b.aspectType;
return labelA.localeCompare(labelB);
})
.map(aspect => (
<TableRow key={aspect.id}>
<TableCell>{ASPECT_TYPE_LABELS[aspect.aspectType] ?? aspect.aspectType}</TableCell>
<TableCell>{aspect.value ?? "-"}</TableCell>
<TableCell>{aspect.quantity ?? "-"}</TableCell>
<TableCell>{aspect.installDate ?? "-"}</TableCell>
<TableCell>{aspect.renewalYear ?? "-"}</TableCell>
<TableCell>{aspect.comments ?? "-"}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})}
</TableBody>
</Table>
<div>
{/* Filter input above the table */}
<div className="mb-2">
<input
type="text"
placeholder="Filter elements..."
value={filterText}
onChange={e => setFilterText(e.target.value)}
className="border rounded p-2 max-w-sm"
/>
</div>
{/* Floating Dropdown */}
{showDropdown && (
<div
className="absolute z-20 max-h-64 w-72 overflow-y-auto rounded border bg-white p-2 shadow"
style={{ top: dropdownPos.top, left: dropdownPos.left }}
>
<div className="mb-2 flex gap-2">
<button onClick={selectAll} className="btn btn-xs">
Select All
</button>
<button onClick={deselectAll} className="btn btn-xs">
Deselect All
</button>
</div>
{uniqueElementTypes
.sort((a, b) => {
const labelA = ELEMENT_TYPE_LABELS[a] ?? a.replaceAll("_", " ");
const labelB = ELEMENT_TYPE_LABELS[b] ?? b.replaceAll("_", " ");
return labelA.localeCompare(labelB);
})
.map(type => (
<label key={type} className="block cursor-pointer">
<input
type="checkbox"
checked={selectedTypes.has(type)}
onChange={() => toggleTypeFilter(type)}
className="mr-2"
/>
{ELEMENT_TYPE_LABELS[type] ?? type.replaceAll("_", " ")}
</label>
))}
<button
onClick={() => setShowDropdown(false)}
className="mt-2 btn btn-xs"
>
Close
</button>
</div>
)}
<Table>
<TableHeader>
{table.getHeaderGroups().map(headerGroup => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map(header => (
<TableHead key={header.id}>
{flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map(row => {
const rowId = row.id;
const isExpanded = expandedRows.has(rowId);
return (
<React.Fragment key={rowId}>
{/* Parent row */}
<TableRow
className="bg-gray-100 font-bold cursor-pointer"
onClick={() => toggleRow(rowId)}
>
{row.getVisibleCells().map(cell => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
{/* Child row / expanded table */}
{isExpanded && (
<TableRow>
<TableCell colSpan={row.getVisibleCells().length} className="p-0">
<ChildTable elements={row.original.elements} />
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})}
</TableBody>
</Table>
</div>
);
}
}
// -----------------------
// Child table (ShadCN table)
// -----------------------
function ChildTable({ elements }: { elements: Element[] }) {
return (
<Table className="m-0 w-full border-t">
<TableHeader>
<TableRow>
<TableHead>Aspect Type</TableHead>
<TableHead>Value</TableHead>
<TableHead>Quantity</TableHead>
<TableHead>Install Date</TableHead>
<TableHead>Renewal Year</TableHead>
<TableHead>Comments</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{elements.flatMap(el =>
el.aspects.map(aspect => (
<TableRow key={aspect.id}>
<TableCell>{ASPECT_TYPE_LABELS[aspect.aspectType] ?? aspect.aspectType}</TableCell>
<TableCell>{aspect.value ?? "-"}</TableCell>
<TableCell>{aspect.quantity ?? "-"}</TableCell>
<TableCell>{aspect.installDate ?? "-"}</TableCell>
<TableCell>{aspect.renewalYear ?? "-"}</TableCell>
<TableCell>{aspect.comments ?? "-"}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
);
}

View file

@ -1,18 +1,20 @@
"use client";
import React from "react";
import {
ScatterChart,
Scatter,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
Label,
} from "recharts";
import { ScatterChart } from "@tremor/react";
// import {
// ScatterChart,
// Scatter,
// XAxis,
// YAxis,
// CartesianGrid,
// Tooltip,
// ResponsiveContainer,
// ReferenceLine,
// Label,
// } from "recharts";
import { Renewal } from "../viewModels/renewal";
import { Tooltip } from "@/app/shadcn_components/ui/tooltip";
type RenewalsTimelineProps = {
renewals: Renewal[];
@ -21,97 +23,50 @@ type RenewalsTimelineProps = {
export default function RenewalsTimeline({ renewals }: RenewalsTimelineProps) {
const today = new Date().getFullYear();
const elementTypes = Array.from(
new Set(renewals.map(r => (r.elementType ?? "").trim()))
);
const elementTypeToY = Object.fromEntries(elementTypes.map((type, i) => [type, i]));
const renewalYears = renewals.map(r => r.renewalYear);
const elementIds = renewals.map(r => parseInt(r.elementId));
const data = renewals.map(r => {
let color = "#10b981";
if (r.renewalYear < today) color = "#ef4444";
else if (r.renewalYear - today <= 3) color = "#f59e0b";
const minXValue = Math.min(...renewalYears);
const maxXValue = Math.max(...renewalYears);
const minYValue = Math.min(...elementIds);
const maxYValue = Math.max(...elementIds);
return {
id: r.id,
elementType: r.elementType,
x: r.renewalYear,
y: elementTypeToY[r.elementType ?? ""],
comments: r.comments ?? "",
color,
};
const colouredData = renewals.map(r => ({
...r,
colourCategory:
r.renewalYear < today
? "past"
: r.renewalYear <= today + 3
? "soon"
: "future",
}));
const orderedData = colouredData.sort((a, b) => {
const order: Record<'past' | 'soon' | 'future', number> = { past: 0, soon: 1, future: 2 };
return order[a.colourCategory as 'past' | 'soon' | 'future'] - order[b.colourCategory as 'past' | 'soon' | 'future'];
});
// 3⃣ Click handler
const handleClick = (payload: any) => {
if (!payload) return;
alert(`Element Type: ${payload.elementType}\nYear: ${payload.x}\nComments: ${payload.comments}`);
};
const todayPosition =
((today - (minXValue - 1)) / ((maxXValue + 1) - (minXValue - 1))) * 100;
return (
<div style={{ width: "100%", height: 400 }}>
<ResponsiveContainer>
<ScatterChart
margin={{ top: 20, right: 40, bottom: 20, left: 200 }}
>
<CartesianGrid />
<XAxis
type="number"
dataKey="x"
domain={["dataMin - 1", "dataMax + 1"]}
allowDecimals={false}
tickFormatter={tick => tick.toString()}
/>
<YAxis
type="number"
dataKey="y"
width={200}
ticks={elementTypes.map((_, i) => i)}
tickFormatter={index => elementTypes[index]}
interval={0} // force all element labels to render
/>
<Tooltip
cursor={{ strokeDasharray: "3 3" }}
content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div style={{
background: "white",
padding: "8px",
border: "1px solid #ccc",
borderRadius: "4px",
fontSize: "0.875rem"
}}>
<div><strong>{data.elementType}</strong></div>
<div>Renewal year: {data.x}</div>
</div>
);
}
return null;
}}
/>
<ReferenceLine x={today} stroke="#000" strokeDasharray="3 3">
<Label value="Today" position="top" />
</ReferenceLine>
<Scatter
data={data}
shape={(props: any) => {
const { cx, cy, payload } = props;
return (
<circle
cx={cx}
cy={cy}
r={6}
fill={payload.color}
onClick={() => handleClick(payload)}
style={{ cursor: "pointer" }}
/>
);
}}
/>
</ScatterChart>
</ResponsiveContainer>
</div>
<ScatterChart
className="h-80"
data={orderedData}
x="renewalYear"
y="elementId"
category="colorCategory"
colors={['red', 'orange', 'green']}
showLegend={false}
xAxisLabel="Year"
yAxisLabel="" // removes axis title
showXAxis={true}
showYAxis={false}
minXValue={minXValue - 1}
maxXValue={maxXValue + 1}
minYValue={minYValue - 1}
maxYValue={maxYValue + 1}
showGridLines={false}
/>
);
}
};