import uPlot from 'uplot'; import type { ChartConfig } from '@/lib/types'; import { formatSI } from '@/lib/formatNumber'; import type { ColorMap } from '@/lib/colors'; import { getItemColor } from '@/lib/colors'; 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, colorMap: ColorMap = new Map(), ): 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: 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, }; } /** * 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, 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 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, colorMap); return { label: getSeriesLabel(k, uCombs, uItems, uSigs, resolveName(item_key)), stroke: color, width: 1.5, dash, }; }), ]; } export function makeSignalsAxes(timeMode: 'real' | 'tick', locale?: string): uPlot.Axis[] { 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))), }, { ...AXIS_BASE, values: (_u: uPlot, vals: (number | null)[]) => vals.map((v) => (v == null ? '' : formatSI(v, locale))), }, ]; }