Initial web
This commit is contained in:
55
web/components/ChartCard/CardShell.tsx
Normal file
55
web/components/ChartCard/CardShell.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
|
||||
interface HeaderProps {
|
||||
title: string;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function Header({ title, onEdit, onDelete }: HeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-gray-700 shrink-0">
|
||||
<span className="text-sm font-medium text-gray-200 truncate">{title}</span>
|
||||
<div className="flex gap-1 ml-2 shrink-0">
|
||||
<button onClick={onEdit} className="text-xs text-gray-400 hover:text-white px-1.5 py-0.5 rounded hover:bg-gray-700">✏️</button>
|
||||
<button onClick={onDelete} className="text-xs text-gray-400 hover:text-red-400 px-1.5 py-0.5 rounded hover:bg-gray-700">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState() {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-500 text-sm">No data</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardShellProps extends HeaderProps {
|
||||
empty: boolean;
|
||||
children: React.ReactNode;
|
||||
/** Ref to the div where the uPlot legend will be mounted */
|
||||
legendContainerRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export function CardShell({ title, onEdit, onDelete, empty, children, legendContainerRef }: CardShellProps) {
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-gray-900 rounded border border-gray-700 overflow-hidden">
|
||||
<Header title={title} onEdit={onEdit} onDelete={onDelete} />
|
||||
{empty ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<>
|
||||
{/* Plot fills remaining space */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{children}
|
||||
</div>
|
||||
{/* Legend scrolls independently, capped at 25% card height */}
|
||||
<div
|
||||
ref={legendContainerRef}
|
||||
className="shrink-0 max-h-[25%] overflow-y-auto border-t border-gray-800 px-2 py-1 text-[10px] text-gray-400 [&_.u-legend]:w-full [&_.u-series]:flex [&_.u-series]:items-center [&_.u-series_th]:flex [&_.u-series_th]:items-center [&_.u-series_th]:gap-1 [&_.u-marker]:w-3 [&_.u-marker]:h-0.5 [&_.u-marker]:shrink-0 [&_.u-label]:truncate [&_.u-value]:ml-auto [&_.u-value]:font-mono [&_.u-value]:shrink-0"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
web/components/ChartCard/DividerCard.tsx
Normal file
18
web/components/ChartCard/DividerCard.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
interface Props {
|
||||
title: string;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export default function DividerCard({ title, onEdit, onDelete }: Props) {
|
||||
return (
|
||||
<div className="h-full flex items-center bg-gray-900/30 rounded border border-gray-600/40 px-4 gap-4 overflow-hidden">
|
||||
<span className="text-sm font-bold text-gray-200 uppercase tracking-widest shrink-0">{title}</span>
|
||||
<div className="flex-1 h-px bg-gray-600" />
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<button onClick={onEdit} className="text-xs text-gray-500 hover:text-white px-1.5 py-0.5 rounded hover:bg-gray-700">✏️</button>
|
||||
<button onClick={onDelete} className="text-xs text-gray-500 hover:text-red-400 px-1.5 py-0.5 rounded hover:bg-gray-700">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
web/components/ChartCard/SignalsChart.tsx
Normal file
64
web/components/ChartCard/SignalsChart.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import 'uplot/dist/uPlot.min.css';
|
||||
import uPlot from 'uplot';
|
||||
import { useApp } from '@/lib/context';
|
||||
import { resolveName } from '@/lib/localization';
|
||||
import { CardShell } from './CardShell';
|
||||
import { makeYScale, makeAnnotationHooks, makeSignalsSeries, makeSignalsAxes, CURSOR_NO_DRAG } from './plotHelpers';
|
||||
import { buildSeriesData } from './seriesData';
|
||||
import { usePlot } from './usePlot';
|
||||
import type { AlertConfig, ChartConfig, SessionBoundary, SignalRow } from '@/lib/types';
|
||||
import type { TimeMode } from '@/lib/types';
|
||||
|
||||
interface Props {
|
||||
config: ChartConfig;
|
||||
rows: SignalRow[];
|
||||
sessions: SessionBoundary[];
|
||||
alerts: AlertConfig[];
|
||||
timeMode: TimeMode;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export default function SignalsChart({ config, rows, sessions, alerts, timeMode, onEdit, onDelete }: Props) {
|
||||
const { localeMap } = useApp();
|
||||
|
||||
const { containerRef, legendRef } = usePlot(
|
||||
(el, w, h, lRef) => {
|
||||
const data = buildSeriesData(rows, config.signal_type, timeMode);
|
||||
if (!data) return null;
|
||||
|
||||
const { keys, allXs, data: seriesData } = data;
|
||||
const sessionXs = sessions.map(s => timeMode === 'tick' ? s.game_tick : new Date(s.real_time).getTime() / 1000);
|
||||
const alertThresholds = alerts
|
||||
.filter(a => config.signal_type === 'both' || config.signal_type === a.signal_type)
|
||||
.map(a => a.threshold);
|
||||
|
||||
return new uPlot({
|
||||
width: w,
|
||||
height: h,
|
||||
cursor: CURSOR_NO_DRAG,
|
||||
legend: {
|
||||
mount: (_u, legendEl) => {
|
||||
if (lRef.current) lRef.current.appendChild(legendEl);
|
||||
},
|
||||
},
|
||||
series: makeSignalsSeries(keys, timeMode, key => resolveName(key, localeMap)),
|
||||
axes: makeSignalsAxes(timeMode),
|
||||
scales: {
|
||||
x: { time: false },
|
||||
y: makeYScale(config.y_min ?? null, config.y_max ?? null, config.y_scale),
|
||||
},
|
||||
hooks: makeAnnotationHooks(sessionXs, alertThresholds),
|
||||
}, [allXs, ...seriesData], el);
|
||||
},
|
||||
[rows, sessions, alerts, config, timeMode, localeMap],
|
||||
);
|
||||
|
||||
return (
|
||||
<CardShell title={config.title} onEdit={onEdit} onDelete={onDelete} empty={rows.length === 0} legendContainerRef={legendRef}>
|
||||
<div ref={containerRef as React.RefObject<HTMLDivElement>} className="w-full h-full" />
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
59
web/components/ChartCard/TableViz.tsx
Normal file
59
web/components/ChartCard/TableViz.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useApp } from '@/lib/context';
|
||||
import { resolveName } from '@/lib/localization';
|
||||
import { CardShell } from './CardShell';
|
||||
import type { ChartConfig, SignalRow } from '@/lib/types';
|
||||
|
||||
interface Props {
|
||||
config: ChartConfig;
|
||||
rows: SignalRow[];
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export default function TableViz({ config, rows, onEdit, onDelete }: Props) {
|
||||
const { localeMap } = useApp();
|
||||
|
||||
const latest = new Map<string, { green?: number; red?: number }>();
|
||||
for (const row of rows) {
|
||||
latest.set(`${row.combinator}::${row.item_key}`, { green: row.green, red: row.red });
|
||||
}
|
||||
const tableRows = [...latest.entries()].sort((a, b) => (a[1].green ?? 0) - (b[1].green ?? 0));
|
||||
|
||||
return (
|
||||
<CardShell title={config.title} onEdit={onEdit} onDelete={onDelete} empty={rows.length === 0}>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<table className="w-full text-xs text-gray-300">
|
||||
<thead className="sticky top-0 bg-gray-800">
|
||||
<tr>
|
||||
<th className="text-left px-2 py-1">Item</th>
|
||||
<th className="text-left px-2 py-1">Combinator</th>
|
||||
{config.signal_type !== 'red' && <th className="text-right px-2 py-1 text-green-400">Green</th>}
|
||||
{config.signal_type !== 'green' && <th className="text-right px-2 py-1 text-red-400">Red (NP)</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tableRows.map(([key, vals]) => {
|
||||
const [combinator, item_key] = key.split('::');
|
||||
return (
|
||||
<tr key={key} className="border-t border-gray-800 hover:bg-gray-800/50">
|
||||
<td className="px-2 py-0.5">{resolveName(item_key, localeMap)}</td>
|
||||
<td className="px-2 py-0.5 text-gray-500">{combinator}</td>
|
||||
{config.signal_type !== 'red' && (
|
||||
<td className={`px-2 py-0.5 text-right font-mono ${(vals.green ?? 0) < 0 ? 'text-red-400' : 'text-green-400'}`}>
|
||||
{vals.green?.toLocaleString() ?? '--'}
|
||||
</td>
|
||||
)}
|
||||
{config.signal_type !== 'green' && (
|
||||
<td className="px-2 py-0.5 text-right font-mono text-orange-400">
|
||||
{vals.red?.toLocaleString() ?? '--'}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
67
web/components/ChartCard/UpsChart.tsx
Normal file
67
web/components/ChartCard/UpsChart.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import 'uplot/dist/uPlot.min.css';
|
||||
import uPlot from 'uplot';
|
||||
import { CardShell } from './CardShell';
|
||||
import { AXIS_BASE, CURSOR_NO_DRAG, makeYScale } from './plotHelpers';
|
||||
import { usePlot } from './usePlot';
|
||||
import type { ChartConfig, UpsRow } from '@/lib/types';
|
||||
import type { TimeMode } from '@/lib/types';
|
||||
|
||||
interface Props {
|
||||
config: ChartConfig;
|
||||
upsRows: UpsRow[];
|
||||
timeMode: TimeMode;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export default function UpsChart({ config, upsRows, timeMode, onEdit, onDelete }: Props) {
|
||||
const { containerRef, legendRef } = usePlot(
|
||||
(el, w, h, lRef) => {
|
||||
if (upsRows.length < 2) return null;
|
||||
|
||||
const sorted = [...upsRows].sort((a, b) =>
|
||||
timeMode === 'tick'
|
||||
? a.game_tick - b.game_tick
|
||||
: new Date(a.real_time).getTime() - new Date(b.real_time).getTime(),
|
||||
);
|
||||
const xs = sorted.map(r => timeMode === 'tick' ? r.game_tick : new Date(r.real_time).getTime() / 1000);
|
||||
const ys = sorted.map(r => r.ups);
|
||||
|
||||
const xAxis: uPlot.Axis = {
|
||||
...AXIS_BASE,
|
||||
...(timeMode === 'real' && {
|
||||
values: (_u, vals) => vals.map(v => v == null ? '' : new Date(v * 1000).toLocaleTimeString()),
|
||||
}),
|
||||
};
|
||||
|
||||
return new uPlot({
|
||||
width: w,
|
||||
height: h,
|
||||
cursor: CURSOR_NO_DRAG,
|
||||
legend: {
|
||||
mount: (_u, legendEl) => {
|
||||
if (lRef.current) lRef.current.appendChild(legendEl);
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{ label: timeMode === 'tick' ? 'Tick' : 'Time' },
|
||||
{ label: 'UPS', stroke: '#4ade80', width: 1.5 },
|
||||
],
|
||||
axes: [xAxis, { ...AXIS_BASE }],
|
||||
scales: {
|
||||
x: { time: false },
|
||||
y: makeYScale(config.y_min ?? null, config.y_max ?? null, config.y_scale),
|
||||
},
|
||||
}, [xs, ys], el);
|
||||
},
|
||||
[upsRows, config.y_min, config.y_max, config.y_scale, timeMode],
|
||||
);
|
||||
|
||||
return (
|
||||
<CardShell title={config.title} onEdit={onEdit} onDelete={onDelete} empty={upsRows.length === 0} legendContainerRef={legendRef}>
|
||||
<div ref={containerRef as React.RefObject<HTMLDivElement>} className="w-full h-full" />
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
25
web/components/ChartCard/index.tsx
Normal file
25
web/components/ChartCard/index.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { AlertConfig, ChartConfig, SessionBoundary, SignalRow, UpsRow } from '@/lib/types';
|
||||
import type { TimeMode } from '@/lib/types';
|
||||
import UpsChart from './UpsChart';
|
||||
import SignalsChart from './SignalsChart';
|
||||
import TableViz from './TableViz';
|
||||
import DividerCard from './DividerCard';
|
||||
|
||||
export interface ChartCardProps {
|
||||
config: ChartConfig;
|
||||
rows: SignalRow[];
|
||||
upsRows: UpsRow[];
|
||||
sessions: SessionBoundary[];
|
||||
alerts: AlertConfig[];
|
||||
timeMode: TimeMode;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export default function ChartCard(props: ChartCardProps) {
|
||||
const { config } = props;
|
||||
if (config.chart_type === 'divider') return <DividerCard title={config.title} onEdit={props.onEdit} onDelete={props.onDelete} />;
|
||||
if (config.chart_type === 'ups') return <UpsChart {...props} />;
|
||||
if (config.viz_type === 'table') return <TableViz config={props.config} rows={props.rows} onEdit={props.onEdit} onDelete={props.onDelete} />;
|
||||
return <SignalsChart {...props} />;
|
||||
}
|
||||
178
web/components/ChartCard/plotHelpers.ts
Normal file
178
web/components/ChartCard/plotHelpers.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import uPlot from 'uplot';
|
||||
import type { ChartConfig } from '@/lib/types';
|
||||
|
||||
// --- Color helpers ---
|
||||
|
||||
function djb2(s: string): number {
|
||||
let h = 5381;
|
||||
for (let i = 0; i < s.length; i++) h = (h * 33) ^ s.charCodeAt(i);
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
function hslColor(key: string): string {
|
||||
const hue = djb2(key) % 360;
|
||||
return `hsl(${hue},70%,60%)`;
|
||||
}
|
||||
|
||||
const SEMANTIC_GREEN = '#4ade80';
|
||||
const SEMANTIC_RED = '#f87171';
|
||||
|
||||
export interface SeriesStyle {
|
||||
color: string;
|
||||
dash: number[] | undefined;
|
||||
}
|
||||
|
||||
export function getSeriesStyle(
|
||||
key: string,
|
||||
uCombs: number,
|
||||
uItems: number,
|
||||
uSigs: number,
|
||||
): SeriesStyle {
|
||||
const [combinator, item_key, sig] = key.split('::');
|
||||
|
||||
if (uCombs === 1 && uItems === 1) {
|
||||
return { color: sig === 'green' ? SEMANTIC_GREEN : SEMANTIC_RED, dash: undefined };
|
||||
}
|
||||
if (uItems > 1) {
|
||||
return { color: hslColor(item_key), dash: uSigs > 1 && sig === 'red' ? [4, 2] : undefined };
|
||||
}
|
||||
return { color: hslColor(combinator), dash: uSigs > 1 && sig === 'red' ? [4, 2] : undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a human-readable series label.
|
||||
* @param displayName Pre-resolved localized name for the item.
|
||||
*/
|
||||
export function getSeriesLabel(
|
||||
key: string,
|
||||
uCombs: number,
|
||||
uItems: number,
|
||||
uSigs: number,
|
||||
displayName: string,
|
||||
): string {
|
||||
const [combinator, , sig] = key.split('::');
|
||||
if (uCombs === 1 && uItems === 1) return sig;
|
||||
const parts: string[] = [];
|
||||
if (uItems > 1) parts.push(displayName);
|
||||
if (uCombs > 1) parts.push(`(${combinator})`);
|
||||
if (uSigs > 1) parts.push(`[${sig}]`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
// --- Axis / scale helpers ---
|
||||
|
||||
export const AXIS_BASE: uPlot.Axis = {
|
||||
stroke: '#9ca3af',
|
||||
ticks: { stroke: '#374151' },
|
||||
grid: { stroke: '#1f2937' },
|
||||
};
|
||||
|
||||
export const CURSOR_NO_DRAG: uPlot.Cursor = {
|
||||
drag: { x: false, y: false },
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a uPlot y-scale.
|
||||
* 'log' uses arcsinh distribution (distr:4) — handles negatives, zero, positives.
|
||||
*/
|
||||
export function makeYScale(
|
||||
yMin: number | null,
|
||||
yMax: number | null,
|
||||
yScale: ChartConfig['y_scale'] = 'linear',
|
||||
): uPlot.Scale {
|
||||
if (yScale === 'log') {
|
||||
return {
|
||||
distr: 4,
|
||||
asinh: 1,
|
||||
...(yMin !== null || yMax !== null ? {
|
||||
range: (_u, dataMin, dataMax) => [yMin ?? dataMin, yMax ?? dataMax],
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (yMin === null && yMax === null) return { dir: 1 };
|
||||
return {
|
||||
dir: 1,
|
||||
range: (_u, dataMin, dataMax) => {
|
||||
const lo = yMin ?? (dataMin ?? 0);
|
||||
const hi = yMax ?? (dataMax ?? 1);
|
||||
if (lo === hi) return [lo - 1, hi + 1];
|
||||
const pad = (yMin == null || yMax == null) ? Math.abs(hi - lo) * 0.05 : 0;
|
||||
return [lo - pad, hi + pad];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function makeAnnotationHooks(
|
||||
sessionXs: number[],
|
||||
alertThresholds: number[],
|
||||
): uPlot.Options['hooks'] {
|
||||
return {
|
||||
draw: [(u) => {
|
||||
const { ctx, bbox } = u;
|
||||
ctx.save();
|
||||
|
||||
ctx.strokeStyle = 'rgba(251,191,36,0.6)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 4]);
|
||||
for (const sx of sessionXs) {
|
||||
const cx = Math.round(u.valToPos(sx, 'x', true));
|
||||
ctx.beginPath(); ctx.moveTo(cx, bbox.top); ctx.lineTo(cx, bbox.top + bbox.height); ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.strokeStyle = 'rgba(248,113,113,0.7)';
|
||||
ctx.setLineDash([6, 3]);
|
||||
for (const t of alertThresholds) {
|
||||
const cy = Math.round(u.valToPos(t, 'y', true));
|
||||
ctx.beginPath(); ctx.moveTo(bbox.left, cy); ctx.lineTo(bbox.left + bbox.width, cy); ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
export function makeSignalsSeries(
|
||||
keys: string[],
|
||||
timeMode: 'real' | 'tick',
|
||||
resolveName: (key: string) => string,
|
||||
): uPlot.Series[] {
|
||||
const uCombs = new Set(keys.map(k => k.split('::')[0])).size;
|
||||
const uItems = new Set(keys.map(k => k.split('::')[1])).size;
|
||||
const uSigs = new Set(keys.map(k => k.split('::')[2])).size;
|
||||
|
||||
const xSeries: uPlot.Series = {
|
||||
label: timeMode === 'tick' ? 'Tick' : 'Time',
|
||||
...(timeMode === 'real' && {
|
||||
value: (_u: uPlot, v: number | null) =>
|
||||
v == null ? '--' : new Date(v * 1000).toLocaleTimeString(),
|
||||
}),
|
||||
};
|
||||
|
||||
return [
|
||||
xSeries,
|
||||
...keys.map(k => {
|
||||
const [, item_key] = k.split('::');
|
||||
const { color, dash } = getSeriesStyle(k, uCombs, uItems, uSigs);
|
||||
return {
|
||||
label: getSeriesLabel(k, uCombs, uItems, uSigs, resolveName(item_key)),
|
||||
stroke: color,
|
||||
width: 1.5,
|
||||
dash,
|
||||
};
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
export function makeSignalsAxes(timeMode: 'real' | 'tick'): uPlot.Axis[] {
|
||||
return [
|
||||
{
|
||||
...AXIS_BASE,
|
||||
...(timeMode === 'real' && {
|
||||
values: (_u: uPlot, vals: (number | null)[]) =>
|
||||
vals.map(v => v == null ? '' : new Date(v * 1000).toLocaleTimeString()),
|
||||
}),
|
||||
},
|
||||
{ ...AXIS_BASE },
|
||||
];
|
||||
}
|
||||
43
web/components/ChartCard/seriesData.ts
Normal file
43
web/components/ChartCard/seriesData.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { SignalRow, ChartConfig } from '@/lib/types';
|
||||
import type { TimeMode } from '@/lib/types';
|
||||
|
||||
const MAX_SERIES = 80;
|
||||
|
||||
export interface SeriesData {
|
||||
keys: string[];
|
||||
allXs: number[];
|
||||
data: (number | undefined)[][];
|
||||
}
|
||||
|
||||
export function buildSeriesData(
|
||||
rows: SignalRow[],
|
||||
signalType: ChartConfig['signal_type'],
|
||||
timeMode: TimeMode,
|
||||
): SeriesData | null {
|
||||
const seriesMap = new Map<string, Map<number, number>>();
|
||||
|
||||
for (const row of rows) {
|
||||
for (const [sig, val] of [['green', row.green], ['red', row.red]] as ['green' | 'red', number | undefined][]) {
|
||||
if (signalType !== 'both' && signalType !== sig) continue;
|
||||
if (val === undefined) continue;
|
||||
const key = `${row.combinator}::${row.item_key}::${sig}`;
|
||||
const x = timeMode === 'tick'
|
||||
? parseInt(row.game_tick, 10)
|
||||
: new Date(row.real_time).getTime() / 1000;
|
||||
if (!seriesMap.has(key)) seriesMap.set(key, new Map());
|
||||
seriesMap.get(key)!.set(x, val);
|
||||
}
|
||||
}
|
||||
|
||||
if (seriesMap.size === 0) return null;
|
||||
|
||||
const keys = [...seriesMap.keys()].slice(0, MAX_SERIES);
|
||||
const allXs = [...new Set(keys.flatMap(k => [...seriesMap.get(k)!.keys()]))].sort((a, b) => a - b);
|
||||
|
||||
const data = keys.map(k => {
|
||||
const m = seriesMap.get(k)!;
|
||||
return allXs.map(x => m.get(x)); // undefined = gap
|
||||
});
|
||||
|
||||
return { keys, allXs, data };
|
||||
}
|
||||
80
web/components/ChartCard/usePlot.ts
Normal file
80
web/components/ChartCard/usePlot.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
export type BuildFn = (
|
||||
el: HTMLDivElement,
|
||||
w: number,
|
||||
h: number,
|
||||
legendRef: React.RefObject<HTMLDivElement>,
|
||||
) => uPlot | null;
|
||||
|
||||
/** Converts a data index to the pixel x position uPlot expects for setCursor */
|
||||
function idxToPixel(plot: uPlot, idx: number): number {
|
||||
const x = plot.data[0]?.[idx];
|
||||
if (x == null) return -10;
|
||||
return plot.valToPos(x, 'x');
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages full uPlot lifecycle:
|
||||
* - builds/rebuilds on dep change or resize
|
||||
* - mounts legend into legendRef container
|
||||
* - pins cursor to last data point on init (legend shows latest values)
|
||||
* - restores last-point legend on mouseleave
|
||||
*/
|
||||
export function usePlot(
|
||||
build: BuildFn,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
deps: any[],
|
||||
): { containerRef: React.RefObject<HTMLDivElement | null>; legendRef: React.RefObject<HTMLDivElement> } {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const legendRef = useRef<HTMLDivElement>(null!);
|
||||
const plotRef = useRef<uPlot | null>(null);
|
||||
const lastIdxRef = useRef<number>(0);
|
||||
|
||||
const rebuild = useCallback(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const w = el.clientWidth, h = el.clientHeight;
|
||||
if (w < 10 || h < 10) return;
|
||||
|
||||
plotRef.current?.destroy();
|
||||
if (legendRef.current) legendRef.current.innerHTML = '';
|
||||
|
||||
const plot = build(el, w, h, legendRef);
|
||||
plotRef.current = plot;
|
||||
|
||||
if (plot) {
|
||||
const lastIdx = Math.max(0, (plot.data[0]?.length ?? 1) - 1);
|
||||
lastIdxRef.current = lastIdx;
|
||||
|
||||
// Pin legend to latest data point
|
||||
plot.setCursor({ left: idxToPixel(plot, lastIdx), top: -10 });
|
||||
|
||||
// Defer mouseleave — prevents React hydration events firing before lastIdxRef set
|
||||
requestAnimationFrame(() => {
|
||||
plot.over.addEventListener('mouseleave', () => {
|
||||
const p = plotRef.current;
|
||||
if (!p) return;
|
||||
p.setCursor({ left: idxToPixel(p, lastIdxRef.current), top: -10 });
|
||||
});
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
|
||||
useEffect(() => {
|
||||
rebuild();
|
||||
return () => { plotRef.current?.destroy(); plotRef.current = null; };
|
||||
}, [rebuild]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(rebuild);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [rebuild]);
|
||||
|
||||
return { containerRef, legendRef };
|
||||
}
|
||||
Reference in New Issue
Block a user