| "use client"; |
|
|
| import { useEffect, useState } from "react"; |
| import { useTime } from "../context/time-context"; |
| import { |
| LineChart, |
| Line, |
| XAxis, |
| YAxis, |
| CartesianGrid, |
| ResponsiveContainer, |
| Tooltip, |
| } from "recharts"; |
|
|
| type DataGraphProps = { |
| data: Array<Array<Record<string, number>>>; |
| onChartsReady?: () => void; |
| }; |
|
|
| import React, { useMemo } from "react"; |
|
|
| |
| const SERIES_NAME_DELIMITER = " | "; |
|
|
| export const DataRecharts = React.memo( |
| ({ data, onChartsReady }: DataGraphProps) => { |
| |
| const [hoveredTime, setHoveredTime] = useState<number | null>(null); |
|
|
| if (!Array.isArray(data) || data.length === 0) return null; |
|
|
| useEffect(() => { |
| if (typeof onChartsReady === "function") { |
| onChartsReady(); |
| } |
| }, [onChartsReady]); |
|
|
| return ( |
| <div className="grid md:grid-cols-2 grid-cols-1 gap-4"> |
| {data.map((group, idx) => ( |
| <SingleDataGraph |
| key={idx} |
| data={group} |
| hoveredTime={hoveredTime} |
| setHoveredTime={setHoveredTime} |
| /> |
| ))} |
| </div> |
| ); |
| }, |
| ); |
|
|
|
|
| const SingleDataGraph = React.memo( |
| ({ |
| data, |
| hoveredTime, |
| setHoveredTime, |
| }: { |
| data: Array<Record<string, number>>; |
| hoveredTime: number | null; |
| setHoveredTime: (t: number | null) => void; |
| }) => { |
| const { currentTime, setCurrentTime } = useTime(); |
| function flattenRow(row: Record<string, any>, prefix = ""): Record<string, number> { |
| const result: Record<string, number> = {}; |
| for (const [key, value] of Object.entries(row)) { |
| |
| if (typeof value === "number") { |
| if (prefix) { |
| result[`${prefix}${SERIES_NAME_DELIMITER}${key}`] = value; |
| } else { |
| result[key] = value; |
| } |
| } else if (value !== null && typeof value === "object" && !Array.isArray(value)) { |
| |
| Object.assign(result, flattenRow(value, prefix ? `${prefix}${SERIES_NAME_DELIMITER}${key}` : key)); |
| } |
| } |
| |
| if ("timestamp" in row) { |
| result["timestamp"] = row["timestamp"]; |
| } |
| return result; |
| } |
|
|
| |
| const chartData = useMemo(() => data.map(row => flattenRow(row)), [data]); |
| const [dataKeys, setDataKeys] = useState<string[]>([]); |
| const [visibleKeys, setVisibleKeys] = useState<string[]>([]); |
|
|
| useEffect(() => { |
| if (!chartData || chartData.length === 0) return; |
| |
| const keys = Object.keys(chartData[0]).filter((k) => k !== "timestamp"); |
| setDataKeys(keys); |
| |
| |
| const defaultVisible = keys.filter(k => |
| k.toLowerCase().includes("wrist") || |
| k.toLowerCase().includes("tip") |
| ); |
| setVisibleKeys(defaultVisible); |
| }, [chartData]); |
|
|
| |
| const groups: Record<string, string[]> = {}; |
| const singles: string[] = []; |
| dataKeys.forEach((key) => { |
| const parts = key.split(SERIES_NAME_DELIMITER); |
| if (parts.length > 1) { |
| const group = parts[0]; |
| if (!groups[group]) groups[group] = []; |
| groups[group].push(key); |
| } else { |
| singles.push(key); |
| } |
| }); |
|
|
| |
| const allGroups = [...Object.keys(groups), ...singles]; |
| const groupColorMap: Record<string, string> = {}; |
| allGroups.forEach((group, idx) => { |
| groupColorMap[group] = `hsl(${idx * (360 / allGroups.length)}, 100%, 50%)`; |
| }); |
|
|
| |
| const findClosestDataIndex = (time: number) => { |
| if (!chartData.length) return 0; |
| |
| const idx = chartData.findIndex((point) => point.timestamp >= time); |
| if (idx !== -1) return idx; |
| |
| return chartData.length - 1; |
| }; |
|
|
| const handleMouseLeave = () => { |
| setHoveredTime(null); |
| }; |
|
|
| const handleClick = (data: any) => { |
| if (data && data.activePayload && data.activePayload.length) { |
| const timeValue = data.activePayload[0].payload.timestamp; |
| setCurrentTime(timeValue); |
| } |
| }; |
|
|
| |
| const CustomLegend = () => { |
| const closestIndex = findClosestDataIndex( |
| hoveredTime != null ? hoveredTime : currentTime, |
| ); |
| const currentData = chartData[closestIndex] || {}; |
|
|
| |
| const groups: Record<string, string[]> = {}; |
| const singles: string[] = []; |
| dataKeys.forEach((key) => { |
| const parts = key.split(SERIES_NAME_DELIMITER); |
| if (parts.length > 1) { |
| const group = parts[0]; |
| if (!groups[group]) groups[group] = []; |
| groups[group].push(key); |
| } else { |
| singles.push(key); |
| } |
| }); |
|
|
| |
| const allGroups = [...Object.keys(groups), ...singles]; |
| const groupColorMap: Record<string, string> = {}; |
| allGroups.forEach((group, idx) => { |
| groupColorMap[group] = `hsl(${idx * (360 / allGroups.length)}, 100%, 50%)`; |
| }); |
|
|
| const isGroupChecked = (group: string) => groups[group].every(k => visibleKeys.includes(k)); |
| const isGroupIndeterminate = (group: string) => groups[group].some(k => visibleKeys.includes(k)) && !isGroupChecked(group); |
|
|
| const handleGroupCheckboxChange = (group: string) => { |
| if (isGroupChecked(group)) { |
| |
| setVisibleKeys((prev) => prev.filter(k => !groups[group].includes(k))); |
| } else { |
| |
| setVisibleKeys((prev) => Array.from(new Set([...prev, ...groups[group]]))); |
| } |
| }; |
|
|
| const handleCheckboxChange = (key: string) => { |
| setVisibleKeys((prev) => |
| prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key] |
| ); |
| }; |
|
|
| return ( |
| <div className="grid grid-cols-[repeat(auto-fit,250px)] gap-4 mx-8"> |
| {/* Grouped keys */} |
| {Object.entries(groups).map(([group, children]) => { |
| const color = groupColorMap[group]; |
| return ( |
| <div key={group} className="mb-2"> |
| <label className="flex gap-2 cursor-pointer select-none font-semibold"> |
| <input |
| type="checkbox" |
| checked={isGroupChecked(group)} |
| ref={el => { if (el) el.indeterminate = isGroupIndeterminate(group); }} |
| onChange={() => handleGroupCheckboxChange(group)} |
| className="size-3.5 mt-1" |
| style={{ accentColor: color }} |
| /> |
| <span className="text-sm w-40 text-white">{group}</span> |
| </label> |
| <div className="pl-7 flex flex-col gap-1 mt-1"> |
| {children.map((key) => ( |
| <label key={key} className="flex gap-2 cursor-pointer select-none"> |
| <input |
| type="checkbox" |
| checked={visibleKeys.includes(key)} |
| onChange={() => handleCheckboxChange(key)} |
| className="size-3.5 mt-1" |
| style={{ accentColor: color }} |
| /> |
| <span className={`text-xs break-all w-36 ${visibleKeys.includes(key) ? "text-white" : "text-gray-400"}`}>{key.slice(group.length + 1)}</span> |
| <span className={`text-xs font-mono ml-auto ${visibleKeys.includes(key) ? "text-orange-300" : "text-gray-500"}`}> |
| {typeof currentData[key] === "number" ? currentData[key].toFixed(2) : "--"} |
| </span> |
| </label> |
| ))} |
| </div> |
| </div> |
| ); |
| })} |
| {/* Singles (non-grouped) */} |
| {singles.map((key) => { |
| const color = groupColorMap[key]; |
| return ( |
| <label key={key} className="flex gap-2 cursor-pointer select-none"> |
| <input |
| type="checkbox" |
| checked={visibleKeys.includes(key)} |
| onChange={() => handleCheckboxChange(key)} |
| className="size-3.5 mt-1" |
| style={{ accentColor: color }} |
| /> |
| <span className={`text-sm break-all w-40 ${visibleKeys.includes(key) ? "text-white" : "text-gray-400"}`}>{key}</span> |
| <span className={`text-sm font-mono ml-auto ${visibleKeys.includes(key) ? "text-orange-300" : "text-gray-500"}`}> |
| {typeof currentData[key] === "number" ? currentData[key].toFixed(2) : "--"} |
| </span> |
| </label> |
| ); |
| })} |
| </div> |
| ); |
| }; |
|
|
| return ( |
| <div className="w-full"> |
| <div className="w-full h-80" onMouseLeave={handleMouseLeave}> |
| <ResponsiveContainer width="100%" height="100%"> |
| <LineChart |
| data={chartData} |
| syncId="episode-sync" |
| margin={{ top: 24, right: 16, left: 0, bottom: 16 }} |
| onClick={handleClick} |
| onMouseMove={(state: any) => { |
| setHoveredTime( |
| state?.activePayload?.[0]?.payload?.timestamp ?? |
| state?.activeLabel ?? |
| null, |
| ); |
| }} |
| onMouseLeave={handleMouseLeave} |
| > |
| <CartesianGrid strokeDasharray="3 3" stroke="#444" /> |
| <XAxis |
| dataKey="timestamp" |
| label={{ |
| value: "time", |
| position: "insideBottomLeft", |
| fill: "#cbd5e1", |
| }} |
| domain={[ |
| chartData.at(0)?.timestamp ?? 0, |
| chartData.at(-1)?.timestamp ?? 0, |
| ]} |
| ticks={useMemo( |
| () => |
| Array.from( |
| new Set(chartData.map((d) => Math.ceil(d.timestamp))), |
| ), |
| [chartData], |
| )} |
| stroke="#cbd5e1" |
| minTickGap={20} // Increased for fewer ticks |
| allowDataOverflow={true} |
| /> |
| <YAxis |
| domain={["auto", "auto"]} |
| stroke="#cbd5e1" |
| interval={0} |
| allowDataOverflow={true} |
| /> |
| |
| <Tooltip |
| content={() => null} |
| active={true} |
| isAnimationActive={false} |
| defaultIndex={ |
| !hoveredTime ? findClosestDataIndex(currentTime) : undefined |
| } |
| /> |
| |
| {/* Render lines for visible dataKeys only */} |
| {dataKeys.map((key) => { |
| // Use group color for all keys in a group |
| const group = key.includes(SERIES_NAME_DELIMITER) ? key.split(SERIES_NAME_DELIMITER)[0] : key; |
| const color = groupColorMap[group]; |
| let strokeDasharray: string | undefined = undefined; |
| if (groups[group] && groups[group].length > 1) { |
| const idxInGroup = groups[group].indexOf(key); |
| if (idxInGroup > 0) strokeDasharray = "5 5"; |
| } |
| return ( |
| visibleKeys.includes(key) && ( |
| <Line |
| key={key} |
| type="monotone" |
| dataKey={key} |
| name={key} |
| stroke={color} |
| strokeDasharray={strokeDasharray} |
| dot={false} |
| activeDot={false} |
| strokeWidth={1.5} |
| isAnimationActive={false} |
| /> |
| ) |
| ); |
| })} |
| </LineChart> |
| </ResponsiveContainer> |
| </div> |
| <CustomLegend /> |
| </div> |
| ); |
| }, |
| ); |
|
|
| SingleDataGraph.displayName = "SingleDataGraph"; |
| DataRecharts.displayName = "DataGraph"; |
| export default DataRecharts; |
|
|