Initial implementation of Bluetooth toy control app
Next.js app with a browser-side buttplug/buttplug-wasm control layer (server never touches real-time device commands), SQLite storage via Drizzle, single-secret auth, recordings/replay with device remapping, a usage stats dashboard, Docker deployment, and Gitea CI. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W8WkFF5ppURBAB918593Eb
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { CartesianGrid, Line, LineChart, 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">
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="bucket"
|
||||
tickFormatter={(v: number) => `${Math.round(v / 1000)}s`}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis domain={[0, 100]} tickLine={false} axisLine={false} width={32} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
{series.map(([sessionDeviceId]) => (
|
||||
<Line
|
||||
key={sessionDeviceId}
|
||||
type="monotone"
|
||||
dataKey={seriesKey(sessionDeviceId)}
|
||||
stroke={`var(--color-${seriesKey(sessionDeviceId)})`}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user