| | "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; |
| |
|