Raffael-Kultyshev's picture
Show Wrist and Tips by default in graphs
c1961f5
"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";
// Use the same delimiter as the data processing
const SERIES_NAME_DELIMITER = " | ";
export const DataRecharts = React.memo(
({ data, onChartsReady }: DataGraphProps) => {
// Shared hoveredTime for all graphs
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)) {
// Special case: if this is a group value that is a primitive, assign to prefix.key
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)) {
// If it's an object, recurse
Object.assign(result, flattenRow(value, prefix ? `${prefix}${SERIES_NAME_DELIMITER}${key}` : key));
}
}
// Always keep timestamp at top level if present
if ("timestamp" in row) {
result["timestamp"] = row["timestamp"];
}
return result;
}
// Flatten all rows for recharts
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;
// Get all keys except timestamp from the first row
const keys = Object.keys(chartData[0]).filter((k) => k !== "timestamp");
setDataKeys(keys);
// Show WRIST and TIPS by default for a good overview without performance lag
const defaultVisible = keys.filter(k =>
k.toLowerCase().includes("wrist") ||
k.toLowerCase().includes("tip")
);
setVisibleKeys(defaultVisible);
}, [chartData]);
// Parse dataKeys into groups (dot notation)
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);
}
});
// Assign a color per group (and for singles)
const allGroups = [...Object.keys(groups), ...singles];
const groupColorMap: Record<string, string> = {};
allGroups.forEach((group, idx) => {
groupColorMap[group] = `hsl(${idx * (360 / allGroups.length)}, 100%, 50%)`;
});
// Find the closest data point to the current time for highlighting
const findClosestDataIndex = (time: number) => {
if (!chartData.length) return 0;
// Find the index of the first data point whose timestamp is >= time (ceiling)
const idx = chartData.findIndex((point) => point.timestamp >= time);
if (idx !== -1) return idx;
// If all timestamps are less than time, return the last index
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);
}
};
// Custom legend to show current value next to each series
const CustomLegend = () => {
const closestIndex = findClosestDataIndex(
hoveredTime != null ? hoveredTime : currentTime,
);
const currentData = chartData[closestIndex] || {};
// Parse dataKeys into groups (dot notation)
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);
}
});
// Assign a color per group (and for singles)
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)) {
// Uncheck all children
setVisibleKeys((prev) => prev.filter(k => !groups[group].includes(k)));
} else {
// Check all children
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>
);
},
); // End React.memo
SingleDataGraph.displayName = "SingleDataGraph";
DataRecharts.displayName = "DataGraph";
export default DataRecharts;