chore: add prettier with config and format all files

This commit is contained in:
Sebastian Seedorf
2026-06-04 11:44:20 +02:00
parent d212ae3f30
commit cf9bb33ecb
50 changed files with 1290 additions and 714 deletions

View File

@@ -1,8 +1,8 @@
import React, { useRef, useState, useCallback } from 'react';
interface HeaderProps {
title: string;
onEdit: () => void;
title: string;
onEdit: () => void;
onDelete: () => void;
}
@@ -11,8 +11,18 @@ export function Header({ title, onEdit, onDelete }: HeaderProps) {
<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>
<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>
);
@@ -25,64 +35,81 @@ export function EmptyState() {
}
interface CardShellProps extends HeaderProps {
empty: boolean;
children: React.ReactNode;
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) {
export function CardShell({
title,
onEdit,
onDelete,
empty,
children,
legendContainerRef,
}: CardShellProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [legendHeight, setLegendHeight] = useState<number | null>(null);
const dragRef = useRef<{ startY: number; startH: number } | null>(null);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const el = legendContainerRef?.current;
if (!el) return;
dragRef.current = { startY: e.clientY, startH: el.offsetHeight };
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const el = legendContainerRef?.current;
if (!el) return;
dragRef.current = { startY: e.clientY, startH: el.offsetHeight };
function onMove(ev: MouseEvent) {
if (!dragRef.current) return;
const delta = dragRef.current.startY - ev.clientY;
const containerH = containerRef.current?.offsetHeight ?? 400;
const newH = Math.max(32, Math.min(containerH - 64, dragRef.current.startH + delta));
setLegendHeight(newH);
}
function onUp() {
dragRef.current = null;
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}, [legendContainerRef]);
function onMove(ev: MouseEvent) {
if (!dragRef.current) return;
const delta = dragRef.current.startY - ev.clientY;
const containerH = containerRef.current?.offsetHeight ?? 400;
const newH = Math.max(32, Math.min(containerH - 64, dragRef.current.startH + delta));
setLegendHeight(newH);
}
function onUp() {
dragRef.current = null;
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
},
[legendContainerRef],
);
return (
<div ref={containerRef} className="h-full flex flex-col bg-gray-900 rounded border border-gray-700 overflow-hidden">
<div
ref={containerRef}
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 />
) : (
<>
<div className="flex-1 min-h-0">
{children}
</div>
{legendContainerRef && <>
<div
onMouseDown={handleMouseDown}
className="shrink-0 h-1.5 cursor-row-resize bg-gray-800 hover:bg-gray-700 active:bg-gray-600"
/>
<div
ref={legendContainerRef}
className="shrink-0 overflow-y-auto 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"
style={legendHeight != null ? { height: legendHeight, maxHeight: legendHeight } : { maxHeight: '25%' }}
/>
<style>{'.u-legend .u-series:first-child { display: none; }'}</style>
</>}
<div className="flex-1 min-h-0">{children}</div>
{legendContainerRef && (
<>
<div
onMouseDown={handleMouseDown}
className="shrink-0 h-1.5 cursor-row-resize bg-gray-800 hover:bg-gray-700 active:bg-gray-600"
/>
<div
ref={legendContainerRef}
className="shrink-0 overflow-y-auto 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"
style={
legendHeight != null
? { height: legendHeight, maxHeight: legendHeight }
: { maxHeight: '25%' }
}
/>
<style>{'.u-legend .u-series:first-child { display: none; }'}</style>
</>
)}
</>
)}
</div>
);
}
}

View File

@@ -1,18 +1,30 @@
interface Props {
title: string;
onEdit: () => void;
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>
<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>
<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>
);
}
}

View File

@@ -8,27 +8,43 @@ import { resolveName } from '@/lib/localization';
import { getColorMap } from '@/lib/colors';
import type { ColorMap } from '@/lib/colors';
import { CardShell } from './CardShell';
import { makeYScale, makeAnnotationHooks, makeSignalsSeries, makeSignalsAxes, CURSOR_NO_DRAG } from './plotHelpers';
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[];
config: ChartConfig;
rows: SignalRow[];
sessions: SessionBoundary[];
alerts: AlertConfig[];
alerts: AlertConfig[];
timeMode: TimeMode;
onEdit: () => void;
onEdit: () => void;
onDelete: () => void;
}
export default function SignalsChart({ config, rows, sessions, alerts, timeMode, onEdit, onDelete }: Props) {
export default function SignalsChart({
config,
rows,
sessions,
alerts,
timeMode,
onEdit,
onDelete,
}: Props) {
const { localeMap } = useApp();
const locale = typeof navigator !== 'undefined' ? navigator.language : 'de-DE';
const [colorMap, setColorMap] = useState<ColorMap>(new Map());
useEffect(() => { getColorMap().then(setColorMap); }, []);
useEffect(() => {
getColorMap().then(setColorMap);
}, []);
const { containerRef, legendRef } = usePlot(
(el, w, h, lRef) => {
@@ -36,35 +52,47 @@ export default function SignalsChart({ config, rows, sessions, alerts, 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 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);
.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);
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), colorMap),
axes: makeSignalsAxes(timeMode, locale),
scales: {
x: { time: false },
y: makeYScale(config.y_min ?? null, config.y_max ?? null, config.y_scale),
},
hooks: makeAnnotationHooks(sessionXs, alertThresholds),
},
series: makeSignalsSeries(keys, timeMode, key => resolveName(key, localeMap), colorMap),
axes: makeSignalsAxes(timeMode, locale),
scales: {
x: { time: false },
y: makeYScale(config.y_min ?? null, config.y_max ?? null, config.y_scale),
},
hooks: makeAnnotationHooks(sessionXs, alertThresholds),
}, [allXs, ...seriesData], el);
[allXs, ...seriesData],
el,
);
},
[rows, sessions, alerts, config, timeMode, localeMap],
);
return (
<CardShell title={config.title} onEdit={onEdit} onDelete={onDelete} empty={rows.length === 0} legendContainerRef={legendRef}>
<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>
);
}
}

View File

@@ -8,16 +8,18 @@ import { CardShell } from './CardShell';
import type { ChartConfig, SignalRow } from '@/lib/types';
interface Props {
config: ChartConfig;
rows: SignalRow[];
onEdit: () => void;
config: ChartConfig;
rows: SignalRow[];
onEdit: () => void;
onDelete: () => void;
}
export default function TableViz({ config, rows, onEdit, onDelete }: Props) {
const { localeMap } = useApp();
const [colorMap, setColorMap] = useState<ColorMap>(new Map());
useEffect(() => { getColorMap().then(setColorMap); }, []);
useEffect(() => {
getColorMap().then(setColorMap);
}, []);
const latest = new Map<string, { green?: number; red?: number }>();
for (const row of rows) {
@@ -33,8 +35,12 @@ export default function TableViz({ config, rows, onEdit, onDelete }: Props) {
<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>}
{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>
@@ -43,13 +49,17 @@ export default function TableViz({ config, rows, onEdit, onDelete }: Props) {
return (
<tr key={key} className="border-t border-gray-800 hover:bg-gray-800/50">
<td className="px-2 py-0.5 flex items-center gap-1.5">
<span className="inline-block w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: getItemColor(item_key, colorMap) }} />
<span
className="inline-block w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: getItemColor(item_key, colorMap) }}
/>
{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'}`}>
<td
className={`px-2 py-0.5 text-right font-mono ${(vals.green ?? 0) < 0 ? 'text-red-400' : 'text-green-400'}`}
>
{vals.green != null ? formatSI(vals.green, undefined, 0) : '--'}
</td>
)}
@@ -66,4 +76,4 @@ export default function TableViz({ config, rows, onEdit, onDelete }: Props) {
</div>
</CardShell>
);
}
}

View File

@@ -10,10 +10,10 @@ import type { ChartConfig, UpsRow } from '@/lib/types';
import type { TimeMode } from '@/lib/types';
interface Props {
config: ChartConfig;
upsRows: UpsRow[];
config: ChartConfig;
upsRows: UpsRow[];
timeMode: TimeMode;
onEdit: () => void;
onEdit: () => void;
onDelete: () => void;
}
@@ -27,42 +27,59 @@ export default function UpsChart({ config, upsRows, timeMode, onEdit, onDelete }
? 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 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,
values: timeMode === 'real'
? (_u, vals) => vals.map(v => v == null ? '' : new Date(v * 1000).toLocaleTimeString())
: (_u, vals) => vals.map(v => v == null ? '' : formatSI(v)),
values:
timeMode === 'real'
? (_u, vals) =>
vals.map((v) => (v == null ? '' : new Date(v * 1000).toLocaleTimeString()))
: (_u, vals) => vals.map((v) => (v == null ? '' : formatSI(v))),
};
return new uPlot({
width: w,
height: h,
cursor: CURSOR_NO_DRAG,
legend: {
mount: (_u, legendEl) => {
if (lRef.current) lRef.current.appendChild(legendEl);
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, values: (_u, vals) => vals.map((v) => (v == null ? '' : formatSI(v))) },
],
scales: {
x: { time: false },
y: makeYScale(config.y_min ?? null, config.y_max ?? null, config.y_scale),
},
},
series: [
{ label: timeMode === 'tick' ? 'Tick' : 'Time' },
{ label: 'UPS', stroke: '#4ade80', width: 1.5 },
],
axes: [xAxis, { ...AXIS_BASE, values: (_u, vals) => vals.map(v => v == null ? '' : formatSI(v)) }],
scales: {
x: { time: false },
y: makeYScale(config.y_min ?? null, config.y_max ?? null, config.y_scale),
},
}, [xs, ys], el);
[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}>
<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>
);
}
}

View File

@@ -6,20 +6,29 @@ import TableViz from './TableViz';
import DividerCard from './DividerCard';
export interface ChartCardProps {
config: ChartConfig;
rows: SignalRow[];
upsRows: UpsRow[];
config: ChartConfig;
rows: SignalRow[];
upsRows: UpsRow[];
sessions: SessionBoundary[];
alerts: AlertConfig[];
alerts: AlertConfig[];
timeMode: TimeMode;
onEdit: () => void;
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} />;
}
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} />;
}

View File

@@ -5,18 +5,18 @@ import type { ColorMap } from '@/lib/colors';
import { getItemColor } from '@/lib/colors';
const SEMANTIC_GREEN = '#4ade80';
const SEMANTIC_RED = '#f87171';
const SEMANTIC_RED = '#f87171';
export interface SeriesStyle {
color: string;
dash: number[] | undefined;
dash: number[] | undefined;
}
export function getSeriesStyle(
key: string,
uCombs: number,
uItems: number,
uSigs: number,
key: string,
uCombs: number,
uItems: number,
uSigs: number,
colorMap: ColorMap = new Map(),
): SeriesStyle {
const [combinator, item_key, sig] = key.split('::');
@@ -25,9 +25,15 @@ export function getSeriesStyle(
return { color: sig === 'green' ? SEMANTIC_GREEN : SEMANTIC_RED, dash: undefined };
}
if (uItems > 1) {
return { color: getItemColor(item_key, colorMap), dash: uSigs > 1 && sig === 'red' ? [4, 2] : undefined };
return {
color: getItemColor(item_key, colorMap),
dash: uSigs > 1 && sig === 'red' ? [4, 2] : undefined,
};
}
return { color: getItemColor(combinator, colorMap), dash: uSigs > 1 && sig === 'red' ? [4, 2] : undefined };
return {
color: getItemColor(combinator, colorMap),
dash: uSigs > 1 && sig === 'red' ? [4, 2] : undefined,
};
}
/**
@@ -35,10 +41,10 @@ export function getSeriesStyle(
* @param displayName Pre-resolved localized name for the item.
*/
export function getSeriesLabel(
key: string,
uCombs: number,
uItems: number,
uSigs: number,
key: string,
uCombs: number,
uItems: number,
uSigs: number,
displayName: string,
): string {
const [combinator, , sig] = key.split('::');
@@ -46,7 +52,7 @@ export function getSeriesLabel(
const parts: string[] = [];
if (uItems > 1) parts.push(displayName);
if (uCombs > 1) parts.push(`(${combinator})`);
if (uSigs > 1) parts.push(`[${sig}]`);
if (uSigs > 1) parts.push(`[${sig}]`);
return parts.join(' ');
}
@@ -54,8 +60,8 @@ export function getSeriesLabel(
export const AXIS_BASE: uPlot.Axis = {
stroke: '#9ca3af',
ticks: { stroke: '#374151' },
grid: { stroke: '#1f2937' },
ticks: { stroke: '#374151' },
grid: { stroke: '#1f2937' },
};
export const CURSOR_NO_DRAG: uPlot.Cursor = {
@@ -67,17 +73,19 @@ export const CURSOR_NO_DRAG: uPlot.Cursor = {
* 'log' uses arcsinh distribution (distr:4) — handles negatives, zero, positives.
*/
export function makeYScale(
yMin: number | null,
yMax: number | null,
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],
} : {}),
...(yMin !== null || yMax !== null
? {
range: (_u, dataMin, dataMax) => [yMin ?? dataMin, yMax ?? dataMax],
}
: {}),
};
}
@@ -85,53 +93,61 @@ export function makeYScale(
return {
dir: 1,
range: (_u, dataMin, dataMax) => {
const lo = yMin ?? (dataMin ?? 0);
const hi = yMax ?? (dataMax ?? 1);
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;
const pad = yMin == null || yMax == null ? Math.abs(hi - lo) * 0.05 : 0;
return [lo - pad, hi + pad];
},
};
}
export function makeAnnotationHooks(
sessionXs: number[],
sessionXs: number[],
alertThresholds: number[],
): uPlot.Options['hooks'] {
return {
draw: [(u) => {
const { ctx, bbox } = u;
ctx.save();
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(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.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();
}],
ctx.restore();
},
],
};
}
export function makeSignalsSeries(
keys: string[],
timeMode: 'real' | 'tick',
keys: string[],
timeMode: 'real' | 'tick',
resolveName: (key: string) => string,
colorMap: ColorMap = new Map(),
colorMap: ColorMap = new Map(),
): 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 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',
@@ -143,13 +159,13 @@ export function makeSignalsSeries(
return [
xSeries,
...keys.map(k => {
...keys.map((k) => {
const [, item_key] = k.split('::');
const { color, dash } = getSeriesStyle(k, uCombs, uItems, uSigs, colorMap);
return {
label: getSeriesLabel(k, uCombs, uItems, uSigs, resolveName(item_key)),
label: getSeriesLabel(k, uCombs, uItems, uSigs, resolveName(item_key)),
stroke: color,
width: 1.5,
width: 1.5,
dash,
};
}),
@@ -160,16 +176,17 @@ export function makeSignalsAxes(timeMode: 'real' | 'tick', locale?: string): uPl
return [
{
...AXIS_BASE,
values: timeMode === 'real'
? (_u: uPlot, vals: (number | null)[]) =>
vals.map(v => v == null ? '' : new Date(v * 1000).toLocaleTimeString())
: (_u: uPlot, vals: (number | null)[]) =>
vals.map(v => v == null ? '' : formatSI(v, locale)),
values:
timeMode === 'real'
? (_u: uPlot, vals: (number | null)[]) =>
vals.map((v) => (v == null ? '' : new Date(v * 1000).toLocaleTimeString()))
: (_u: uPlot, vals: (number | null)[]) =>
vals.map((v) => (v == null ? '' : formatSI(v, locale))),
},
{
...AXIS_BASE,
values: (_u: uPlot, vals: (number | null)[]) =>
vals.map(v => v == null ? '' : formatSI(v, locale)),
vals.map((v) => (v == null ? '' : formatSI(v, locale))),
},
];
}
}

View File

@@ -4,26 +4,30 @@ import type { TimeMode } from '@/lib/types';
const MAX_SERIES = 80;
export interface SeriesData {
keys: string[];
keys: string[];
allXs: number[];
data: (number | undefined)[][];
data: (number | undefined)[][];
}
export function buildSeriesData(
rows: SignalRow[],
rows: SignalRow[],
signalType: ChartConfig['signal_type'],
timeMode: TimeMode,
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][]) {
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;
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);
}
@@ -31,13 +35,15 @@ export function buildSeriesData(
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 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 data = keys.map((k) => {
const m = seriesMap.get(k)!;
return allXs.map(x => m.get(x)); // undefined = gap
return allXs.map((x) => m.get(x)); // undefined = gap
});
return { keys, allXs, data };
}
}

View File

@@ -2,9 +2,9 @@ import { useCallback, useEffect, useRef } from 'react';
import uPlot from 'uplot';
export type BuildFn = (
el: HTMLDivElement,
w: number,
h: number,
el: HTMLDivElement,
w: number,
h: number,
legendRef: React.RefObject<HTMLDivElement>,
) => uPlot | null;
@@ -25,17 +25,21 @@ function idxToPixel(plot: uPlot, idx: number): number {
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> } {
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 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;
const w = el.clientWidth,
h = el.clientHeight;
if (w < 10 || h < 10) return;
plotRef.current?.destroy();
@@ -60,12 +64,15 @@ export function usePlot(
});
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
useEffect(() => {
rebuild();
return () => { plotRef.current?.destroy(); plotRef.current = null; };
return () => {
plotRef.current?.destroy();
plotRef.current = null;
};
}, [rebuild]);
useEffect(() => {
@@ -77,4 +84,4 @@ export function usePlot(
}, [rebuild]);
return { containerRef, legendRef };
}
}