feat: y-axis SI prefix, hide tick from legend, fix table sort

This commit is contained in:
Sebastian Seedorf
2026-06-03 12:52:45 +02:00
parent 8c83e8b8e8
commit 3506d1f6c5
7 changed files with 59 additions and 8 deletions

View File

@@ -46,7 +46,7 @@ export function CardShell({ title, onEdit, onDelete, empty, children, legendCont
{/* Legend scrolls independently, capped at 25% card height */} {/* Legend scrolls independently, capped at 25% card height */}
<div <div
ref={legendContainerRef} 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" 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 [&_.u-series:first-child]:hidden"
/> />
</> </>
)} )}

View File

@@ -23,6 +23,7 @@ interface Props {
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 { localeMap } = useApp();
const locale = typeof navigator !== 'undefined' ? navigator.language : 'de-DE';
const { containerRef, legendRef } = usePlot( const { containerRef, legendRef } = usePlot(
(el, w, h, lRef) => { (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)), series: makeSignalsSeries(keys, timeMode, key => resolveName(key, localeMap)),
axes: makeSignalsAxes(timeMode), axes: makeSignalsAxes(timeMode, locale),
scales: { scales: {
x: { time: false }, x: { time: false },
y: makeYScale(config.y_min ?? null, config.y_max ?? null, config.y_scale), y: makeYScale(config.y_min ?? null, config.y_max ?? null, config.y_scale),

View File

@@ -1,5 +1,6 @@
import { useApp } from '@/lib/context'; import { useApp } from '@/lib/context';
import { resolveName } from '@/lib/localization'; import { resolveName } from '@/lib/localization';
import { formatSI } from '@/lib/formatNumber';
import { CardShell } from './CardShell'; import { CardShell } from './CardShell';
import type { ChartConfig, SignalRow } from '@/lib/types'; import type { ChartConfig, SignalRow } from '@/lib/types';
@@ -13,11 +14,27 @@ interface Props {
export default function TableViz({ config, rows, onEdit, onDelete }: Props) { export default function TableViz({ config, rows, onEdit, onDelete }: Props) {
const { localeMap } = useApp(); const { localeMap } = useApp();
const sortCol = config.signal_type === 'red' ? 'red' : 'green';
const latest = new Map<string, { green?: number; red?: number }>(); const latest = new Map<string, { green?: number; red?: number }>();
for (const row of rows) { for (const row of rows) {
latest.set(`${row.combinator}::${row.item_key}`, { green: row.green, red: row.red }); 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 ( return (
<CardShell title={config.title} onEdit={onEdit} onDelete={onDelete} empty={rows.length === 0}> <CardShell title={config.title} onEdit={onEdit} onDelete={onDelete} empty={rows.length === 0}>
@@ -40,12 +57,12 @@ export default function TableViz({ config, rows, onEdit, onDelete }: Props) {
<td className="px-2 py-0.5 text-gray-500">{combinator}</td> <td className="px-2 py-0.5 text-gray-500">{combinator}</td>
{config.signal_type !== 'red' && ( {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?.toLocaleString() ?? '--'} {vals.green != null ? formatSI(vals.green) : '--'}
</td> </td>
)} )}
{config.signal_type !== 'green' && ( {config.signal_type !== 'green' && (
<td className="px-2 py-0.5 text-right font-mono text-orange-400"> <td className="px-2 py-0.5 text-right font-mono text-orange-400">
{vals.red?.toLocaleString() ?? '--'} {vals.red != null ? formatSI(vals.red) : '--'}
</td> </td>
)} )}
</tr> </tr>

View File

@@ -4,6 +4,7 @@ import 'uplot/dist/uPlot.min.css';
import uPlot from 'uplot'; import uPlot from 'uplot';
import { CardShell } from './CardShell'; import { CardShell } from './CardShell';
import { AXIS_BASE, CURSOR_NO_DRAG, makeYScale } from './plotHelpers'; import { AXIS_BASE, CURSOR_NO_DRAG, makeYScale } from './plotHelpers';
import { formatSI } from '@/lib/formatNumber';
import { usePlot } from './usePlot'; import { usePlot } from './usePlot';
import type { ChartConfig, UpsRow } from '@/lib/types'; import type { ChartConfig, UpsRow } from '@/lib/types';
import type { TimeMode } 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: timeMode === 'tick' ? 'Tick' : 'Time' },
{ label: 'UPS', stroke: '#4ade80', width: 1.5 }, { 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: { scales: {
x: { time: false }, x: { time: false },
y: makeYScale(config.y_min ?? null, config.y_max ?? null, config.y_scale), y: makeYScale(config.y_min ?? null, config.y_max ?? null, config.y_scale),

View File

@@ -1,5 +1,6 @@
import uPlot from 'uplot'; import uPlot from 'uplot';
import type { ChartConfig } from '@/lib/types'; import type { ChartConfig } from '@/lib/types';
import { formatSI } from '@/lib/formatNumber';
// --- Color helpers --- // --- 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 [ return [
{ {
...AXIS_BASE, ...AXIS_BASE,
@@ -173,6 +174,10 @@ export function makeSignalsAxes(timeMode: 'real' | 'tick'): uPlot.Axis[] {
vals.map(v => v == null ? '' : new Date(v * 1000).toLocaleTimeString()), 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)),
},
]; ];
} }

22
web/lib/formatNumber.ts Normal file
View File

@@ -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);
}

5
web/package-lock.json generated
View File

@@ -986,6 +986,7 @@
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/node": "*", "@types/node": "*",
"pg-protocol": "*", "pg-protocol": "*",
@@ -998,6 +999,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -1795,6 +1797,7 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"pg-connection-string": "^2.12.0", "pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0", "pg-pool": "^3.13.0",
@@ -1968,6 +1971,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -1977,6 +1981,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },