From 3506d1f6c581348a71d9ed854d741ac65efd31b3 Mon Sep 17 00:00:00 2001 From: Sebastian Seedorf Date: Wed, 3 Jun 2026 12:52:45 +0200 Subject: [PATCH] feat: y-axis SI prefix, hide tick from legend, fix table sort --- web/components/ChartCard/CardShell.tsx | 2 +- web/components/ChartCard/SignalsChart.tsx | 3 ++- web/components/ChartCard/TableViz.tsx | 23 ++++++++++++++++++++--- web/components/ChartCard/UpsChart.tsx | 3 ++- web/components/ChartCard/plotHelpers.ts | 9 +++++++-- web/lib/formatNumber.ts | 22 ++++++++++++++++++++++ web/package-lock.json | 5 +++++ 7 files changed, 59 insertions(+), 8 deletions(-) create mode 100644 web/lib/formatNumber.ts diff --git a/web/components/ChartCard/CardShell.tsx b/web/components/ChartCard/CardShell.tsx index 5de90db..485dead 100644 --- a/web/components/ChartCard/CardShell.tsx +++ b/web/components/ChartCard/CardShell.tsx @@ -46,7 +46,7 @@ export function CardShell({ title, onEdit, onDelete, empty, children, legendCont {/* Legend scrolls independently, capped at 25% card height */}
)} diff --git a/web/components/ChartCard/SignalsChart.tsx b/web/components/ChartCard/SignalsChart.tsx index ed1e3a7..c287261 100644 --- a/web/components/ChartCard/SignalsChart.tsx +++ b/web/components/ChartCard/SignalsChart.tsx @@ -23,6 +23,7 @@ interface 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 { containerRef, legendRef } = usePlot( (el, w, h, lRef) => { @@ -45,7 +46,7 @@ export default function SignalsChart({ config, rows, sessions, alerts, timeMode, }, }, series: makeSignalsSeries(keys, timeMode, key => resolveName(key, localeMap)), - axes: makeSignalsAxes(timeMode), + axes: makeSignalsAxes(timeMode, locale), scales: { x: { time: false }, y: makeYScale(config.y_min ?? null, config.y_max ?? null, config.y_scale), diff --git a/web/components/ChartCard/TableViz.tsx b/web/components/ChartCard/TableViz.tsx index 14875ba..d719a64 100644 --- a/web/components/ChartCard/TableViz.tsx +++ b/web/components/ChartCard/TableViz.tsx @@ -1,5 +1,6 @@ import { useApp } from '@/lib/context'; import { resolveName } from '@/lib/localization'; +import { formatSI } from '@/lib/formatNumber'; import { CardShell } from './CardShell'; import type { ChartConfig, SignalRow } from '@/lib/types'; @@ -13,11 +14,27 @@ interface Props { export default function TableViz({ config, rows, onEdit, onDelete }: Props) { const { localeMap } = useApp(); + const sortCol = config.signal_type === 'red' ? 'red' : 'green'; + const latest = new Map(); 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)); + const tableRows = [...latest.entries()] + .sort((a, b) => { + const va = a[1][sortCol] ?? 0; + const vb = b[1][sortCol] ?? 0; + switch (config.order_by) { + case 'value_asc': + case 'delta_asc': + return va - vb; + case 'abs_desc': + return Math.abs(vb) - Math.abs(va); + default: + return vb - va; + } + }) + .slice(0, config.series_limit); return ( @@ -40,12 +57,12 @@ export default function TableViz({ config, rows, onEdit, onDelete }: Props) { {combinator} {config.signal_type !== 'red' && ( - {vals.green?.toLocaleString() ?? '--'} + {vals.green != null ? formatSI(vals.green) : '--'} )} {config.signal_type !== 'green' && ( - {vals.red?.toLocaleString() ?? '--'} + {vals.red != null ? formatSI(vals.red) : '--'} )} diff --git a/web/components/ChartCard/UpsChart.tsx b/web/components/ChartCard/UpsChart.tsx index e2d4ad0..f19887a 100644 --- a/web/components/ChartCard/UpsChart.tsx +++ b/web/components/ChartCard/UpsChart.tsx @@ -4,6 +4,7 @@ import 'uplot/dist/uPlot.min.css'; import uPlot from 'uplot'; import { CardShell } from './CardShell'; import { AXIS_BASE, CURSOR_NO_DRAG, makeYScale } from './plotHelpers'; +import { formatSI } from '@/lib/formatNumber'; import { usePlot } from './usePlot'; import type { ChartConfig, UpsRow } from '@/lib/types'; import type { TimeMode } from '@/lib/types'; @@ -49,7 +50,7 @@ export default function UpsChart({ config, upsRows, timeMode, onEdit, onDelete } { label: timeMode === 'tick' ? 'Tick' : 'Time' }, { label: 'UPS', stroke: '#4ade80', width: 1.5 }, ], - axes: [xAxis, { ...AXIS_BASE }], + 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), diff --git a/web/components/ChartCard/plotHelpers.ts b/web/components/ChartCard/plotHelpers.ts index 84d4bd0..c7e1512 100644 --- a/web/components/ChartCard/plotHelpers.ts +++ b/web/components/ChartCard/plotHelpers.ts @@ -1,5 +1,6 @@ import uPlot from 'uplot'; import type { ChartConfig } from '@/lib/types'; +import { formatSI } from '@/lib/formatNumber'; // --- Color helpers --- @@ -164,7 +165,7 @@ export function makeSignalsSeries( ]; } -export function makeSignalsAxes(timeMode: 'real' | 'tick'): uPlot.Axis[] { +export function makeSignalsAxes(timeMode: 'real' | 'tick', locale?: string): uPlot.Axis[] { return [ { ...AXIS_BASE, @@ -173,6 +174,10 @@ export function makeSignalsAxes(timeMode: 'real' | 'tick'): uPlot.Axis[] { vals.map(v => v == null ? '' : new Date(v * 1000).toLocaleTimeString()), }), }, - { ...AXIS_BASE }, + { + ...AXIS_BASE, + values: (_u: uPlot, vals: (number | null)[]) => + vals.map(v => v == null ? '' : formatSI(v, locale)), + }, ]; } \ No newline at end of file diff --git a/web/lib/formatNumber.ts b/web/lib/formatNumber.ts new file mode 100644 index 0000000..5eda4e0 --- /dev/null +++ b/web/lib/formatNumber.ts @@ -0,0 +1,22 @@ +const SI_THRESHOLDS = [ + { limit: 1_000_000_000, divisor: 1_000_000_000, suffix: 'G' }, + { limit: 1_000_000, divisor: 1_000_000, suffix: 'M' }, + { limit: 1_000, divisor: 1_000, suffix: 'K' }, +] as const; + +export function formatSI(v: number, locale?: string): string { + const abs = Math.abs(v); + for (const { limit, divisor, suffix } of SI_THRESHOLDS) { + if (abs >= limit) { + const formatted = new Intl.NumberFormat(locale, { + maximumFractionDigits: 3, + minimumFractionDigits: 0, + }).format(v / divisor); + return `${formatted}${suffix}`; + } + } + return new Intl.NumberFormat(locale, { + maximumFractionDigits: 0, + minimumFractionDigits: 0, + }).format(v); +} diff --git a/web/package-lock.json b/web/package-lock.json index 36ebd5c..dbb6334 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -986,6 +986,7 @@ "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -998,6 +999,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1795,6 +1797,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -1968,6 +1971,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -1977,6 +1981,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" },