Files
factorio-signal-exporter/web/components/ChartCard/plotHelpers.ts

184 lines
5.0 KiB
TypeScript

import uPlot from 'uplot';
import type { ChartConfig } from '@/lib/types';
import { formatSI } from '@/lib/formatNumber';
// --- 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', 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)),
},
];
}