- SessionTimelineChart: adds explicit gap between the tooltip's device label and its value via a custom formatter, instead of relying on justify-between's leftover-space spacing, which collapsed to zero in the narrow single-item tooltip Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BuWtppPBizG2NQRa31FCRa
104 lines
4.0 KiB
TypeScript
104 lines
4.0 KiB
TypeScript
"use client";
|
|
|
|
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
|
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ui/chart";
|
|
|
|
export interface TimelineRow {
|
|
bucket: number;
|
|
sessionDeviceId: number;
|
|
slotLabel: string;
|
|
avgValue: number;
|
|
maxValue: number;
|
|
}
|
|
|
|
const SERIES_COLORS = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)"];
|
|
|
|
// Chart config keys become raw CSS custom-property names (--color-<key>), so
|
|
// they must be safe identifiers - a user-chosen device display name isn't.
|
|
// Use the numeric session_device_id as the key, slotLabel only as display text.
|
|
function seriesKey(sessionDeviceId: number): string {
|
|
return `device_${sessionDeviceId}`;
|
|
}
|
|
|
|
export function SessionTimelineChart({ timeline }: { timeline: TimelineRow[] }) {
|
|
const series = [...new Map(timeline.map((r) => [r.sessionDeviceId, r.slotLabel])).entries()];
|
|
const buckets = [...new Set(timeline.map((r) => r.bucket))].sort((a, b) => a - b);
|
|
|
|
const data = buckets.map((bucket) => {
|
|
const row: Record<string, number> = { bucket };
|
|
for (const [sessionDeviceId] of series) {
|
|
const match = timeline.find((r) => r.bucket === bucket && r.sessionDeviceId === sessionDeviceId);
|
|
row[seriesKey(sessionDeviceId)] = match ? Math.round(match.avgValue * 100) : 0;
|
|
}
|
|
return row;
|
|
});
|
|
|
|
const config: ChartConfig = Object.fromEntries(
|
|
series.map(([sessionDeviceId, slotLabel], i) => [
|
|
seriesKey(sessionDeviceId),
|
|
{ label: slotLabel, color: SERIES_COLORS[i % SERIES_COLORS.length] },
|
|
]),
|
|
);
|
|
|
|
if (timeline.length === 0) {
|
|
return <p className="text-sm text-muted-foreground">No device activity recorded for this session.</p>;
|
|
}
|
|
|
|
return (
|
|
<ChartContainer config={config} className="h-64 w-full">
|
|
<AreaChart data={data}>
|
|
<defs>
|
|
{series.map(([sessionDeviceId]) => (
|
|
<linearGradient key={sessionDeviceId} id={`fill-${seriesKey(sessionDeviceId)}`} x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor={`var(--color-${seriesKey(sessionDeviceId)})`} stopOpacity={0.5} />
|
|
<stop offset="95%" stopColor={`var(--color-${seriesKey(sessionDeviceId)})`} stopOpacity={0.03} />
|
|
</linearGradient>
|
|
))}
|
|
</defs>
|
|
<CartesianGrid vertical={false} stroke="var(--border)" />
|
|
<XAxis
|
|
dataKey="bucket"
|
|
tickFormatter={(v: number) => `${Math.round(v / 1000)}s`}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
className="bp-readout text-xs"
|
|
/>
|
|
<YAxis domain={[0, 100]} tickLine={false} axisLine={false} width={32} className="bp-readout text-xs" />
|
|
<ChartTooltip
|
|
content={
|
|
<ChartTooltipContent
|
|
formatter={(value, name) => {
|
|
const itemConfig = config[name as string];
|
|
return (
|
|
<>
|
|
<div
|
|
className="h-2.5 w-2.5 shrink-0 rounded-[2px]"
|
|
style={{ backgroundColor: itemConfig?.color }}
|
|
/>
|
|
<div className="flex flex-1 items-center justify-between gap-4 leading-none">
|
|
<span className="text-muted-foreground">{itemConfig?.label ?? name}</span>
|
|
<span className="bp-readout font-medium text-foreground">
|
|
{typeof value === "number" ? value.toLocaleString() : String(value)}
|
|
</span>
|
|
</div>
|
|
</>
|
|
);
|
|
}}
|
|
/>
|
|
}
|
|
/>
|
|
{series.map(([sessionDeviceId]) => (
|
|
<Area
|
|
key={sessionDeviceId}
|
|
type="monotone"
|
|
dataKey={seriesKey(sessionDeviceId)}
|
|
stroke={`var(--color-${seriesKey(sessionDeviceId)})`}
|
|
strokeWidth={2}
|
|
fill={`url(#fill-${seriesKey(sessionDeviceId)})`}
|
|
/>
|
|
))}
|
|
</AreaChart>
|
|
</ChartContainer>
|
|
);
|
|
}
|