From d71cec03b25942d0964eb2f7f4af14af49b5f57c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rkan?= Date: Sun, 4 Oct 2026 10:18:16 +0200 Subject: [PATCH 1/2] fix: derive distinct chart colors from theme --- src/features/observers/ObserverComparison.tsx | 10 +- src/features/stats/MeshTab.tsx | 10 +- src/features/stats/PathsTab.tsx | 14 +- src/features/stats/ScopesTab.tsx | 8 +- src/features/stats/SignalTab.tsx | 8 +- src/features/stats/TalkersTab.tsx | 2 +- src/features/stats/TrafficTab.tsx | 8 +- src/features/stats/chartOptions.ts | 32 +-- src/features/stats/chartTheme.ts | 187 ++++++++++++++++-- src/features/stats/paths.ts | 2 +- src/features/stats/signal.ts | 4 +- src/features/stats/traffic.ts | 4 +- tests/features/stats/chart-theme.test.ts | 60 ++++++ 13 files changed, 281 insertions(+), 68 deletions(-) create mode 100644 tests/features/stats/chart-theme.test.ts diff --git a/src/features/observers/ObserverComparison.tsx b/src/features/observers/ObserverComparison.tsx index 7ccdf9a2..a5e53a05 100644 --- a/src/features/observers/ObserverComparison.tsx +++ b/src/features/observers/ObserverComparison.tsx @@ -48,7 +48,7 @@ export function ObserverComparison({ observerA, activityA, range, observerBId, u const bPoints = dataB && aligned ? fillActivity(dataB.points, intervalMs, window, dataB) : []; return { ...heardOption(aPoints, colors, window), grid: { left: 48, right: 14, top: 32, bottom: 22 }, legend: { data: ["A", "B"], top: 0, textStyle: { color: colors.textNormal } }, - series: [["A", aPoints, colors.primary], ["B", bPoints, colors.secondary]].map(([name, points, color]) => ({ + series: [["A", aPoints, colors.series[0]], ["B", bPoints, colors.series[1]]].map(([name, points, color]) => ({ name: name as string, type: "line" as const, symbol: "none", connectNulls: false, data: (points as typeof aPoints).map(p => [p.t, p.observations]), lineStyle: { width: 2, color: color as string }, itemStyle: { color: color as string }, @@ -57,9 +57,9 @@ export function ObserverComparison({ observerA, activityA, range, observerBId, u }, [activityA, dataB, aligned, intervalMs, start, end, colors]); const date = (value: number | null | undefined) => value == null ? "—" : new Date(value).toLocaleString(i18n.resolvedLanguage, { timeZone: "UTC", dateStyle: "short", timeStyle: "short" }); const groups = overlap.data && !overlap.isError ? [ - { label: t("observerCompare.onlyA"), count: overlap.data.onlyA, color: colors.primary, textClass: "text-primary" }, - { label: t("observerCompare.both"), count: overlap.data.both, color: colors.green, textClass: "text-green" }, - { label: t("observerCompare.onlyB"), count: overlap.data.onlyB, color: colors.secondary, textClass: "text-secondary" }, + { label: t("observerCompare.onlyA"), count: overlap.data.onlyA, color: colors.series[0] }, + { label: t("observerCompare.both"), count: overlap.data.both, color: colors.green }, + { label: t("observerCompare.onlyB"), count: overlap.data.onlyB, color: colors.series[1] }, ] : []; const refresh = () => { const nextUntil = onRefresh(); @@ -92,7 +92,7 @@ export function ObserverComparison({ observerA, activityA, range, observerBId, u {overlap.isError ?

{t("common.loadFailed")}

: !overlap.data ?

{t("common.loading")}

: <>

{t("observerCompare.total", { count: overlap.data.totalPackets })}

{overlap.data.totalPackets === 0 ?

{t("observerCompare.empty")}

:
{groups.map(g =>
)}
} -
{groups.map(g =>
{g.label}
{g.count.toLocaleString(i18n.resolvedLanguage)}
)}
+
{groups.map(g =>
{g.label}
{g.count.toLocaleString(i18n.resolvedLanguage)}
)}
} } } diff --git a/src/features/stats/MeshTab.tsx b/src/features/stats/MeshTab.tsx index 51b47f5d..5614d80c 100644 --- a/src/features/stats/MeshTab.tsx +++ b/src/features/stats/MeshTab.tsx @@ -70,7 +70,7 @@ export function MeshTab({ range, onSelectObserver }: MeshTabProps) { const payloadOption = useMemo(() => typeBarOption(payloadItems, colors), [payloadItems, colors]); const observerRows = useMemo( - () => (topObserversData ?? []).map((o) => ({ name: o.displayName ?? o.observerId.slice(0, 8), value: o.observationCount, color: colors.secondary })), + () => (topObserversData ?? []).map((o) => ({ name: o.displayName ?? o.observerId.slice(0, 8), value: o.observationCount, color: colors.primary })), [topObserversData, colors], ); const observersOption = useMemo(() => leaderboardOption(observerRows, colors), [observerRows, colors]); @@ -126,10 +126,10 @@ export function MeshTab({ range, onSelectObserver }: MeshTabProps) { return (
- - - - + + + +
pathHours(data), [data]); const categories = useMemo(() => [ - { name: t("paths.categories.hashed"), value: data?.hashed ?? 0, color: c.primary }, + { name: t("paths.categories.hashed"), value: data?.hashed ?? 0, color: c.series[0] }, { name: t("paths.categories.empty"), value: data?.empty ?? 0, color: c.textDim }, - { name: t("paths.categories.trace"), value: data?.trace ?? 0, color: c.warn }, - { name: t("paths.categories.unclassified"), value: data?.unclassified ?? 0, color: c.secondary }, + { name: t("paths.categories.trace"), value: data?.trace ?? 0, color: c.series[1] }, + { name: t("paths.categories.unclassified"), value: data?.unclassified ?? 0, color: c.series[2] }, ], [data, c, t]); const charts = useMemo(() => { const width = donutOption((data?.hashWidths ?? []).map((bin, i) => ({ name: t("paths.hashWidth", { count: bin.bytes }), value: bin.receptions, color: c.series[i] })), c, formatCount(data?.hashed ?? 0), t("paths.hashPathsCenter")); @@ -49,10 +49,10 @@ export function PathsTab({ range }: { range: StatsRange }) { {query.isError &&

{t("paths.error")}

}
- - - - + + + +
diff --git a/src/features/stats/ScopesTab.tsx b/src/features/stats/ScopesTab.tsx index f2e733d1..51d5f60f 100644 --- a/src/features/stats/ScopesTab.tsx +++ b/src/features/stats/ScopesTab.tsx @@ -41,10 +41,10 @@ export function ScopesTab({ range }: { range: StatsRange }) { {query.isError &&

{t("scopes.error")}

}
- - - - + + + +
diff --git a/src/features/stats/SignalTab.tsx b/src/features/stats/SignalTab.tsx index 326113e7..d3bf3ec2 100644 --- a/src/features/stats/SignalTab.tsx +++ b/src/features/stats/SignalTab.tsx @@ -35,10 +35,10 @@ export function SignalTab({ range }: { range: StatsRange }) { {query.isError &&

{t("signal.error")}

}
- - - - + + + +
diff --git a/src/features/stats/TalkersTab.tsx b/src/features/stats/TalkersTab.tsx index f60b5a38..9dc123fc 100644 --- a/src/features/stats/TalkersTab.tsx +++ b/src/features/stats/TalkersTab.tsx @@ -68,7 +68,7 @@ export function TalkersTab({ range, onViewNode }: TalkersTabProps) { }, [range, t, topAdvertisers.dataUpdatedAt]); const talkerRows = useMemo( - () => (talkersUnavailable ? [] : (topTalkers.data ?? [])).map((row) => ({ name: row.senderName, value: row.messageCount, color: colors.secondary })), + () => (talkersUnavailable ? [] : (topTalkers.data ?? [])).map((row) => ({ name: row.senderName, value: row.messageCount, color: colors.primary })), [topTalkers.data, colors, talkersUnavailable], ); const talkersOption = useMemo(() => leaderboardOption(talkerRows, colors), [talkerRows, colors]); diff --git a/src/features/stats/TrafficTab.tsx b/src/features/stats/TrafficTab.tsx index f14ee517..ec49334b 100644 --- a/src/features/stats/TrafficTab.tsx +++ b/src/features/stats/TrafficTab.tsx @@ -35,10 +35,10 @@ export function TrafficTab({ range }: { range: StatsRange }) { {query.isError &&

{t("traffic.error")}

}
- - area.total > 0).length)} accent={colors.green} spark={sparks?.areas} /> - - + + area.total > 0).length)} accent={colors.series[1]!} spark={sparks?.areas} /> + +
{t("traffic.hourlyRecords")}} option={trend} height={260} isLoading={loading} isError={query.isError} isEmpty={!model.total} /> diff --git a/src/features/stats/chartOptions.ts b/src/features/stats/chartOptions.ts index 42a3f0c2..3ff06906 100644 --- a/src/features/stats/chartOptions.ts +++ b/src/features/stats/chartOptions.ts @@ -59,15 +59,15 @@ export function observationsAreaOption( smooth: true, symbol: "none", data: points.map((p) => [p.hour, p.observations]), - lineStyle: { color: c.primary, width: 2 }, - itemStyle: { color: c.primary }, + lineStyle: { color: c.series[0], width: 2 }, + itemStyle: { color: c.series[0] }, areaStyle: { color: { type: "linear", x: 0, y: 0, x2: 0, y2: 1, colorStops: [ - { offset: 0, color: withAlpha(c.primary, 0.42) }, - { offset: 1, color: withAlpha(c.primary, 0.01) }, + { offset: 0, color: withAlpha(c.series[0]!, 0.42) }, + { offset: 1, color: withAlpha(c.series[0]!, 0.01) }, ], }, }, @@ -78,8 +78,8 @@ export function observationsAreaOption( smooth: true, symbol: "none", data: points.map((p) => [p.hour, p.uniquePackets]), - lineStyle: { color: c.secondary, width: 1.3, type: "dashed" }, - itemStyle: { color: c.secondary }, + lineStyle: { color: c.series[1], width: 1.3, type: "dashed" }, + itemStyle: { color: c.series[1] }, }, ], }; @@ -198,10 +198,10 @@ export function presetBarsOption( }, }, series: [ - { name: labels.nodes, ...segment(rows.map((r) => r.nodes), c.primary) }, + { name: labels.nodes, ...segment(rows.map((r) => r.nodes), c.series[0]!) }, { name: labels.observers, - ...segment(rows.map((r) => r.observers), c.secondary), + ...segment(rows.map((r) => r.observers), c.series[1]!), // outer segment carries the row total so it sits at the end of the whole stack label: { show: true, @@ -327,8 +327,8 @@ export function airtimeOption(points: TelemetryPoint[], c: ChartColors, bucketMs xAxis: timeAxis(c), yAxis: percentAxis(c), series: [ - { name: "RX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: airtimePctSeries(points, "airtimeRxSecs", bucketMs), lineStyle: { width: 1, color: c.green }, areaStyle: { color: withAlpha(c.green, 0.35) }, itemStyle: { color: c.green } }, - { name: "TX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: airtimePctSeries(points, "airtimeTxSecs", bucketMs), lineStyle: { width: 1, color: c.primary }, areaStyle: { color: withAlpha(c.primary, 0.35) }, itemStyle: { color: c.primary } }, + { name: "RX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: airtimePctSeries(points, "airtimeRxSecs", bucketMs), lineStyle: { width: 1, color: c.series[0] }, areaStyle: { color: withAlpha(c.series[0]!, 0.35) }, itemStyle: { color: c.series[0] } }, + { name: "TX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: airtimePctSeries(points, "airtimeTxSecs", bucketMs), lineStyle: { width: 1, color: c.series[1] }, areaStyle: { color: withAlpha(c.series[1]!, 0.35) }, itemStyle: { color: c.series[1] } }, ], }; } @@ -379,10 +379,10 @@ export const batteryOption = (p: TelemetryPoint[], c: ChartColors, name = "Batte metricLineOption(p, c, { name, color: c.primary, accessor: (x) => (x.batteryMv == null ? null : +(x.batteryMv / 1000).toFixed(3)) }); export const noiseFloorOption = (p: TelemetryPoint[], c: ChartColors, name = "Noise dBm") => - metricLineOption(p, c, { name, color: c.warn, accessor: (x) => x.noiseFloorDb }); + metricLineOption(p, c, { name, color: c.primary, accessor: (x) => x.noiseFloorDb }); export const queueOption = (p: TelemetryPoint[], c: ChartColors, name = "Queue") => - metricLineOption(p, c, { name, color: c.secondary, accessor: (x) => x.queueLength, area: true }); + metricLineOption(p, c, { name, color: c.primary, accessor: (x) => x.queueLength, area: true }); // receiveErrors is a cumulative counter in raw points, a per-bucket delta in bucketed ones export const receiveErrorsOption = (p: TelemetryPoint[], c: ChartColors, bucketed: boolean, name = "Recv errors") => @@ -422,9 +422,9 @@ export function busyOption(points: ActivityPoint[], c: ChartColors, intervalMs: symbol: "none", connectNulls: false, data: points.map((p) => [p.t, pct(p)]), - lineStyle: { width: 1.5, color: c.green }, - areaStyle: { color: withAlpha(c.green, 0.28) }, - itemStyle: { color: c.green }, + lineStyle: { width: 1.5, color: c.primary }, + areaStyle: { color: withAlpha(c.primary, 0.28) }, + itemStyle: { color: c.primary }, }, ], }; @@ -466,7 +466,7 @@ export function snrHeardOption(points: ActivityPoint[], c: ChartColors, w: TimeW xAxis: windowAxis(c, w), yAxis: valueAxis(c, { scale: true, axisLabel: { color: c.textMuted, fontFamily: MONO, fontSize: 10, formatter: "{value} dB" } }), series: [ - { name: labels.average, type: "line", symbol: "none", connectNulls: false, data: points.map((p) => [p.t, p.snrAvg]), lineStyle: { width: 1.8, color: c.secondary }, itemStyle: { color: c.secondary } }, + { name: labels.average, type: "line", symbol: "none", connectNulls: false, data: points.map((p) => [p.t, p.snrAvg]), lineStyle: { width: 1.8, color: c.primary }, itemStyle: { color: c.primary } }, { name: labels.minimum, type: "line", symbol: "none", connectNulls: false, data: points.map((p) => [p.t, p.snrMin]), lineStyle: { width: 1, color: c.textMuted, type: "dashed" }, itemStyle: { color: c.textMuted } }, ], }; diff --git a/src/features/stats/chartTheme.ts b/src/features/stats/chartTheme.ts index 745c9e57..fb2bed54 100644 --- a/src/features/stats/chartTheme.ts +++ b/src/features/stats/chartTheme.ts @@ -10,6 +10,7 @@ export interface ChartColors { primary: string; primaryDim: string; secondary: string; + // Semantic status colours. Do not use these merely to distinguish unrelated series. green: string; warn: string; danger: string; @@ -31,6 +32,8 @@ function readVar(name: string): string { } type RGB = [number, number, number]; +type OKLab = [number, number, number]; +type OKLCH = [number, number, number]; function parseColor(c: string): RGB { const s = c.trim(); @@ -48,6 +51,161 @@ function parseColor(c: string): RGB { return [128, 128, 128]; } +function srgbToLinear(channel: number): number { + const c = channel / 255; + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; +} + +function linearToSrgb(channel: number): number { + const c = channel <= 0.0031308 ? 12.92 * channel : 1.055 * channel ** (1 / 2.4) - 0.055; + return c * 255; +} + +// OKLab is deliberately used here instead of RGB/HSL: equal distances are much closer to equal +// perceived colour differences, which is what matters when adjacent chart series must be told apart. +function rgbToOklab(color: string): OKLab { + const [r8, g8, b8] = parseColor(color); + const r = srgbToLinear(r8), g = srgbToLinear(g8), b = srgbToLinear(b8); + const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); + const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); + const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); + return [ + 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, + 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, + 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s, + ]; +} + +function oklabToRgbRaw([L, a, b]: OKLab): RGB { + const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3; + const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3; + const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3; + return [ + linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), + linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), + linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s), + ]; +} + +function labToLch([L, a, b]: OKLab): OKLCH { + return [L, Math.hypot(a, b), (Math.atan2(b, a) * 180 / Math.PI + 360) % 360]; +} + +function lchToLab([L, C, h]: OKLCH): OKLab { + const radians = h * Math.PI / 180; + return [L, C * Math.cos(radians), C * Math.sin(radians)]; +} + +function inGamut(rgb: RGB): boolean { + return rgb.every((channel) => channel >= 0 && channel <= 255); +} + +// Chroma is reduced only when the requested OKLCH colour falls outside sRGB. This preserves hue and +// lightness instead of independently clipping channels, which can collapse two generated colours. +function lchToColor(lch: OKLCH): string { + let candidate = lch; + if (!inGamut(oklabToRgbRaw(lchToLab(candidate)))) { + let low = 0, high = lch[1]; + for (let i = 0; i < 14; i += 1) { + const chroma = (low + high) / 2; + if (inGamut(oklabToRgbRaw(lchToLab([lch[0], chroma, lch[2]])))) low = chroma; + else high = chroma; + } + candidate = [lch[0], low, lch[2]]; + } + const rgb = oklabToRgbRaw(lchToLab(candidate)).map((channel) => Math.round(Math.max(0, Math.min(255, channel)))) as RGB; + return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`; +} + +function relativeLuminance(color: string): number { + const [r, g, b] = parseColor(color).map(srgbToLinear); + return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!; +} + +export function colorContrast(a: string, b: string): number { + const l1 = relativeLuminance(a), l2 = relativeLuminance(b); + return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); +} + +export function colorDistance(a: string, b: string): number { + const x = rgbToOklab(a), y = rgbToOklab(b); + return Math.hypot(x[0] - y[0], x[1] - y[1], x[2] - y[2]); +} + +export function colorHueDistance(a: string, b: string): number { + const [, chromaA, hueA] = labToLch(rgbToOklab(a)); + const [, chromaB, hueB] = labToLch(rgbToOklab(b)); + if (chromaA < 0.02 || chromaB < 0.02) return 180; + const distance = Math.abs(hueA - hueB); + return Math.min(distance, 360 - distance); +} + +function contrastingColor(lch: OKLCH, background: string): string { + let color = lchToColor(lch); + if (colorContrast(color, background) >= 3) return color; + const lighter = relativeLuminance(background) < 0.4; + let lightness = lch[0]; + for (let i = 0; i < 24; i += 1) { + lightness = Math.max(0.05, Math.min(0.95, lightness + (lighter ? 0.025 : -0.025))); + color = lchToColor([lightness, lch[1], lch[2]]); + if (colorContrast(color, background) >= 3) break; + } + return color; +} + +/** + * Build a categorical chart palette from the active theme's identity colours. + * + * Themes are allowed to reuse a semantic colour (for example Phosphor's primary and green are both + * #4ADE80), so token order alone is not a categorical palette. We make a pool from those tokens and + * theme-relative OKLCH hue rotations, then greedily pick the colour furthest from every colour already + * selected. The result remains derived from the theme while avoiding duplicate/near-duplicate series. + */ +export function categoricalPalette(themeColors: string[], background: string, count = 8, reservedColors: string[] = []): string[] { + if (themeColors.length === 0 || count <= 0) return []; + const labs = themeColors.map(rgbToOklab); + const lchs = labs.map(labToLch); + const chromatic = lchs.filter(([, chroma]) => chroma > 0.02); + const farFromReserved = (color: string) => reservedColors.every((reserved) => + colorDistance(color, reserved) >= 0.1 && colorHueDistance(color, reserved) >= 32); + const anchorIndex = themeColors.findIndex(farFromReserved); + const anchor = lchs[anchorIndex >= 0 ? anchorIndex : 0] ?? chromatic[0] ?? lchs[0]!; + const sortedLightness = lchs.map(([lightness]) => lightness).sort((a, b) => a - b); + const sortedChroma = chromatic.map(([, chroma]) => chroma).sort((a, b) => a - b); + const median = (values: number[], fallback: number) => values[Math.floor(values.length / 2)] ?? fallback; + const baseLightness = median(sortedLightness, anchor[0]); + const baseChroma = Math.max(0.08, Math.min(0.22, median(sortedChroma, anchor[1]))); + + const semantic = lchs.map((lch) => contrastingColor(lch, background)); + const generated = Array.from({ length: Math.max(32, count * 6) }, (_, i) => { + // A golden-angle walk avoids repeatedly landing in the same hue family. Alternating lightness + // gives later colours another perceptual dimension without introducing a fixed colour palette. + const hue = (anchor[2] + i * 137.507764) % 360; + const lightnessOffset = ((i % 5) - 2) * 0.06; + return contrastingColor([Math.max(0.2, Math.min(0.88, baseLightness + lightnessOffset)), baseChroma, hue], background); + }); + // Status colours are intentionally absent from ordinary categories: green/warn/danger must keep + // one meaning throughout Beacon. Perceptual distance also excludes nearby derived shades. + const candidates = [...semantic, ...generated].filter(farFromReserved); + const selected = [candidates.shift() ?? semantic[0]!]; + + while (selected.length < count) { + let best: string | undefined; + let bestScore = -1; + for (const candidate of candidates) { + const score = Math.min(...selected.map((color) => colorDistance(candidate, color))); + if (score > bestScore) { + best = candidate; + bestScore = score; + } + } + if (!best || bestScore < 0.001) break; + selected.push(best); + candidates.splice(candidates.indexOf(best), 1); + } + return selected; +} + export function withAlpha(color: string, a: number): string { const [r, g, b] = parseColor(color); return `rgba(${r}, ${g}, ${b}, ${a})`; @@ -78,17 +236,12 @@ export function readChartColors(): ChartColors { border: readVar("--color-border") || "#27272A", borderSubtle: readVar("--color-border-subtle") || "#1E1E22", }; - // 8 categorical colors blended from the palette so any theme stays cohesive. - const series = [ - c.primary, - c.green, - c.secondary, - c.warn, - c.danger, - c.primaryDim, - blend(c.primary, c.secondary), - blend(c.green, c.warn), - ]; + const series = categoricalPalette( + [c.primary, c.secondary, c.primaryDim], + c.bgSurface, + 8, + [c.green, c.warn, c.danger], + ); return { ...c, series }; } @@ -101,14 +254,14 @@ export function useChartColors(): ChartColors { } // Per-device-type colour, shared by the Mesh "Node types" donut and the neighbour graph so the two -// views stay in sync. Unknown types fall back to a dim primary. +// views stay in sync. These are categories, so none of them consume a semantic status colour. export function nodeTypeColor(typeName: string, c: ChartColors): string { switch (typeName) { - case "companion": return c.primary; - case "repeater": return c.green; - case "room_server": return c.secondary; - case "sensor": return c.warn; - default: return c.primaryDim; + case "companion": return c.series[0]!; + case "repeater": return c.series[1]!; + case "room_server": return c.series[2]!; + case "sensor": return c.series[3]!; + default: return c.series[4]!; } } diff --git a/src/features/stats/paths.ts b/src/features/stats/paths.ts index d583e82d..f1094d94 100644 --- a/src/features/stats/paths.ts +++ b/src/features/stats/paths.ts @@ -25,7 +25,7 @@ export function pathLengthOption(bins: PathLengthBin[], c: ChartColors, t: TFunc tooltip: { trigger: "axis", renderMode: "richText", ...tooltipStyle(c) }, xAxis: { type: "category", data: entries, name: t("paths.entries"), nameLocation: "middle", nameGap: 25, nameTextStyle: { color: c.textMuted }, axisLabel: { color: c.textMuted, fontSize: 10 }, axisLine: { lineStyle: { color: c.border } } }, yAxis: { type: "value", minInterval: 1, axisLabel: { color: c.textMuted, fontSize: 10 }, splitLine: { lineStyle: { color: c.borderSubtle } } }, - series: [{ name: t("paths.receptionsColumn"), type: "bar", barMaxWidth: 26, data: entries.map((i) => ({ value: counts.get(i) ?? 0, itemStyle: { color: i === 0 ? c.textDim : blend(c.primary, c.secondary, i / Math.max(1, max)), borderRadius: [3, 3, 0, 0] } })) }], + series: [{ name: t("paths.receptionsColumn"), type: "bar", barMaxWidth: 26, data: entries.map((i) => ({ value: counts.get(i) ?? 0, itemStyle: { color: i === 0 ? c.textDim : blend(c.primaryDim, c.primary, i / Math.max(1, max)), borderRadius: [3, 3, 0, 0] } })) }], }; } diff --git a/src/features/stats/signal.ts b/src/features/stats/signal.ts index dddad107..b3539ec8 100644 --- a/src/features/stats/signal.ts +++ b/src/features/stats/signal.ts @@ -33,12 +33,12 @@ export function signalHistogramOption(metric: SignalMetric | undefined, name: st tooltip: { trigger: "axis", renderMode: "richText", ...tooltipStyle(c) }, xAxis: { type: "category", data: bins.map((bin) => `${signalBinLabel(bin, t)} ${unit}`), axisLabel: { color: c.textMuted, fontSize: 9, rotate: 35 }, axisLine: { lineStyle: { color: c.border } } }, yAxis: { type: "value", minInterval: 1, axisLabel: { color: c.textMuted, fontSize: 10 }, splitLine: { lineStyle: { color: c.borderSubtle } } }, - series: [{ name: t("signal.metricSamples", { metric: name }), type: "bar", barMaxWidth: 28, data: bins.map((bin, i) => ({ value: bin.count, itemStyle: { color: blend(name === "SNR" ? c.secondary : c.green, c.primary, i / Math.max(1, bins.length - 1)), borderRadius: [3, 3, 0, 0] } })) }], + series: [{ name: t("signal.metricSamples", { metric: name }), type: "bar", barMaxWidth: 28, data: bins.map((bin, i) => ({ value: bin.count, itemStyle: { color: blend(c.bgRaised, c.primary, 0.45 + 0.55 * i / Math.max(1, bins.length - 1)), borderRadius: [3, 3, 0, 0] } })) }], }; } export function signalTrendOption(hours: ReturnType, metric: "snr" | "rssi", c: ChartColors, t: TFunction): EChartsOption { - const color = metric === "snr" ? c.secondary : c.green; + const color = metric === "snr" ? c.series[0]! : c.series[1]!; const unit = metric === "snr" ? "dB" : "dBm"; return { animation: false, useUTC: true, diff --git a/src/features/stats/traffic.ts b/src/features/stats/traffic.ts index 3b245010..94e7f0d6 100644 --- a/src/features/stats/traffic.ts +++ b/src/features/stats/traffic.ts @@ -1,6 +1,6 @@ import type { TFunction } from "i18next"; import type { EChartsOption } from "./echarts-setup"; -import { tooltipStyle, type ChartColors } from "./chartTheme"; +import { blend, tooltipStyle, type ChartColors } from "./chartTheme"; import { RANGE_MS, type ObservationPoint, type StatsRange } from "./types"; const HOUR = 3_600_000; @@ -89,7 +89,7 @@ export function trafficHeatmapOption(model: TrafficModel, c: ChartColors, t: TFu yAxis: { type: "category", data: model.days, inverse: true, axisTick: { show: false }, axisLine: { show: false }, axisLabel: { color: c.textMuted, fontFamily: MONO, fontSize: 9, formatter: (day: string) => day.slice(5) } }, visualMap: { min: 0, max: Math.max(1, model.peak?.total ?? 0), calculable: false, orient: "horizontal", left: "center", bottom: 0, itemWidth: 10, itemHeight: 130, - text: [t("traffic.more"), t("traffic.fewer")], textStyle: { color: c.textMuted, fontSize: 10 }, inRange: { color: [c.primaryDim, c.primary, c.secondary, c.green, c.warn] } }, + text: [t("traffic.more"), t("traffic.fewer")], textStyle: { color: c.textMuted, fontSize: 10 }, inRange: { color: [0.18, 0.38, 0.62, 0.82, 1].map((amount) => blend(c.bgRaised, c.primary, amount)) } }, series: [{ name: t("traffic.receptionsColumn"), type: "heatmap", data: model.heatmap, itemStyle: { borderColor: c.bgSurface, borderWidth: 1 }, emphasis: { itemStyle: { borderColor: c.textBright, borderWidth: 1 } } }], }; } diff --git a/tests/features/stats/chart-theme.test.ts b/tests/features/stats/chart-theme.test.ts new file mode 100644 index 00000000..b2f692ea --- /dev/null +++ b/tests/features/stats/chart-theme.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { categoricalPalette, colorContrast, colorDistance, colorHueDistance } from "../../../src/features/stats/chartTheme"; + +interface ThemeFixture { + id: string; + vars: Record; +} + +const themes = JSON.parse(readFileSync("public/themes.json", "utf8")) as ThemeFixture[]; + +describe("categoricalPalette", () => { + it("separates series when a theme reuses semantic colours", () => { + // Phosphor intentionally uses the same green for primary and success. + const palette = categoricalPalette( + ["#4ADE80", "#22D3EE", "#166534"], + "#0A1628", + 8, + ["#4ADE80", "#FDE047", "#F87171"], + ); + + expect(palette).toHaveLength(8); + expect(new Set(palette).size).toBe(8); + for (let i = 0; i < palette.length; i += 1) { + for (let j = i + 1; j < palette.length; j += 1) { + expect(colorDistance(palette[i]!, palette[j]!)).toBeGreaterThan(0.08); + } + } + }); + + it("keeps generated chart colours visible on dark and light theme surfaces", () => { + const accents = ["#007A94", "#1E7E34", "#0062CC", "#9A6A00", "#C82333", "#005F73"]; + const light = categoricalPalette(accents, "#FFFFFF"); + const dark = categoricalPalette(accents, "#111114"); + + expect(light.every((color) => colorContrast(color, "#FFFFFF") >= 3)).toBe(true); + expect(dark.every((color) => colorContrast(color, "#111114") >= 3)).toBe(true); + }); + + it("produces a distinct, visible palette for every bundled theme", () => { + for (const theme of themes) { + const accents = ["primary", "secondary", "primary-dim"].map((token) => theme.vars[`--palette-${token}`]!); + const reserved = ["green", "warn", "danger"].map((token) => theme.vars[`--palette-${token}`]!); + const background = theme.vars["--palette-bg-surface"]!; + const palette = categoricalPalette(accents, background, 8, reserved); + const pairs = palette.flatMap((color, i) => palette.slice(i + 1).map((other) => colorDistance(color, other))); + + expect(palette, theme.id).toHaveLength(8); + expect(Math.min(...pairs), theme.id).toBeGreaterThan(0.075); + expect(palette.every((color) => colorContrast(color, background) >= 3), theme.id).toBe(true); + expect(palette.every((color) => reserved.every((status) => colorDistance(color, status) >= 0.1)), theme.id).toBe(true); + expect(palette.every((color) => reserved.every((status) => colorHueDistance(color, status) >= 32)), theme.id).toBe(true); + } + }); + + it("returns the requested number of colours without a named/static palette", () => { + const palette = categoricalPalette(["#B8E636", "#4ADE80", "#36C4E6"], "#121410", 12); + expect(palette).toHaveLength(12); + }); +}); From 47f5517cd580144f3cd1cd4046f17b123fa1b9a7 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 10 Oct 2026 10:06:48 -0400 Subject: [PATCH 2/2] Only replace chart colours that repeat within a theme Keeps every theme's own series colours and swaps in a generated hue only where a slot duplicates an earlier one (Phosphor/Radar primary = green, Amber/Earth green = secondary). Multi-colour views use the deduplicated series slots; single-series charts keep their theme tokens. --- src/features/observers/ObserverComparison.tsx | 8 +- src/features/stats/MeshTab.tsx | 2 +- src/features/stats/PathsTab.tsx | 6 +- src/features/stats/ScopesTab.tsx | 6 +- src/features/stats/SignalTab.tsx | 4 +- src/features/stats/TalkersTab.tsx | 2 +- src/features/stats/chartOptions.ts | 22 ++-- src/features/stats/chartTheme.ts | 100 ++++++------------ src/features/stats/paths.ts | 2 +- src/features/stats/signal.ts | 4 +- src/features/stats/traffic.ts | 4 +- tests/features/stats/chart-theme.test.ts | 64 ++++------- 12 files changed, 87 insertions(+), 137 deletions(-) diff --git a/src/features/observers/ObserverComparison.tsx b/src/features/observers/ObserverComparison.tsx index a5e53a05..eda617a2 100644 --- a/src/features/observers/ObserverComparison.tsx +++ b/src/features/observers/ObserverComparison.tsx @@ -48,7 +48,7 @@ export function ObserverComparison({ observerA, activityA, range, observerBId, u const bPoints = dataB && aligned ? fillActivity(dataB.points, intervalMs, window, dataB) : []; return { ...heardOption(aPoints, colors, window), grid: { left: 48, right: 14, top: 32, bottom: 22 }, legend: { data: ["A", "B"], top: 0, textStyle: { color: colors.textNormal } }, - series: [["A", aPoints, colors.series[0]], ["B", bPoints, colors.series[1]]].map(([name, points, color]) => ({ + series: [["A", aPoints, colors.series[0]], ["B", bPoints, colors.series[2]]].map(([name, points, color]) => ({ name: name as string, type: "line" as const, symbol: "none", connectNulls: false, data: (points as typeof aPoints).map(p => [p.t, p.observations]), lineStyle: { width: 2, color: color as string }, itemStyle: { color: color as string }, @@ -57,9 +57,9 @@ export function ObserverComparison({ observerA, activityA, range, observerBId, u }, [activityA, dataB, aligned, intervalMs, start, end, colors]); const date = (value: number | null | undefined) => value == null ? "—" : new Date(value).toLocaleString(i18n.resolvedLanguage, { timeZone: "UTC", dateStyle: "short", timeStyle: "short" }); const groups = overlap.data && !overlap.isError ? [ - { label: t("observerCompare.onlyA"), count: overlap.data.onlyA, color: colors.series[0] }, - { label: t("observerCompare.both"), count: overlap.data.both, color: colors.green }, - { label: t("observerCompare.onlyB"), count: overlap.data.onlyB, color: colors.series[1] }, + { label: t("observerCompare.onlyA"), count: overlap.data.onlyA, color: colors.series[0]! }, + { label: t("observerCompare.both"), count: overlap.data.both, color: colors.series[1]! }, + { label: t("observerCompare.onlyB"), count: overlap.data.onlyB, color: colors.series[2]! }, ] : []; const refresh = () => { const nextUntil = onRefresh(); diff --git a/src/features/stats/MeshTab.tsx b/src/features/stats/MeshTab.tsx index 5614d80c..c1d548a1 100644 --- a/src/features/stats/MeshTab.tsx +++ b/src/features/stats/MeshTab.tsx @@ -70,7 +70,7 @@ export function MeshTab({ range, onSelectObserver }: MeshTabProps) { const payloadOption = useMemo(() => typeBarOption(payloadItems, colors), [payloadItems, colors]); const observerRows = useMemo( - () => (topObserversData ?? []).map((o) => ({ name: o.displayName ?? o.observerId.slice(0, 8), value: o.observationCount, color: colors.primary })), + () => (topObserversData ?? []).map((o) => ({ name: o.displayName ?? o.observerId.slice(0, 8), value: o.observationCount, color: colors.secondary })), [topObserversData, colors], ); const observersOption = useMemo(() => leaderboardOption(observerRows, colors), [observerRows, colors]); diff --git a/src/features/stats/PathsTab.tsx b/src/features/stats/PathsTab.tsx index be8e4d5c..696ad00b 100644 --- a/src/features/stats/PathsTab.tsx +++ b/src/features/stats/PathsTab.tsx @@ -17,10 +17,10 @@ export function PathsTab({ range }: { range: StatsRange }) { const data = loading || query.isError ? undefined : query.data; const hours = useMemo(() => pathHours(data), [data]); const categories = useMemo(() => [ - { name: t("paths.categories.hashed"), value: data?.hashed ?? 0, color: c.series[0] }, + { name: t("paths.categories.hashed"), value: data?.hashed ?? 0, color: c.series[0]! }, { name: t("paths.categories.empty"), value: data?.empty ?? 0, color: c.textDim }, - { name: t("paths.categories.trace"), value: data?.trace ?? 0, color: c.series[1] }, - { name: t("paths.categories.unclassified"), value: data?.unclassified ?? 0, color: c.series[2] }, + { name: t("paths.categories.trace"), value: data?.trace ?? 0, color: c.series[3]! }, + { name: t("paths.categories.unclassified"), value: data?.unclassified ?? 0, color: c.series[2]! }, ], [data, c, t]); const charts = useMemo(() => { const width = donutOption((data?.hashWidths ?? []).map((bin, i) => ({ name: t("paths.hashWidth", { count: bin.bytes }), value: bin.receptions, color: c.series[i] })), c, formatCount(data?.hashed ?? 0), t("paths.hashPathsCenter")); diff --git a/src/features/stats/ScopesTab.tsx b/src/features/stats/ScopesTab.tsx index 51d5f60f..fc5478df 100644 --- a/src/features/stats/ScopesTab.tsx +++ b/src/features/stats/ScopesTab.tsx @@ -41,9 +41,9 @@ export function ScopesTab({ range }: { range: StatsRange }) { {query.isError &&

{t("scopes.error")}

}
- - - + + +
diff --git a/src/features/stats/SignalTab.tsx b/src/features/stats/SignalTab.tsx index d3bf3ec2..9a63f483 100644 --- a/src/features/stats/SignalTab.tsx +++ b/src/features/stats/SignalTab.tsx @@ -36,8 +36,8 @@ export function SignalTab({ range }: { range: StatsRange }) { {query.isError &&

{t("signal.error")}

}
- - + +
diff --git a/src/features/stats/TalkersTab.tsx b/src/features/stats/TalkersTab.tsx index 150ee285..ff5f0312 100644 --- a/src/features/stats/TalkersTab.tsx +++ b/src/features/stats/TalkersTab.tsx @@ -70,7 +70,7 @@ export function TalkersTab({ range, onViewNode }: TalkersTabProps) { }, [range, t, topAdvertisers.dataUpdatedAt]); const talkerRows = useMemo( - () => (talkersUnavailable ? [] : (topTalkers.data ?? [])).map((row) => ({ name: row.senderName, value: row.messageCount, color: colors.primary })), + () => (talkersUnavailable ? [] : (topTalkers.data ?? [])).map((row) => ({ name: row.senderName, value: row.messageCount, color: colors.secondary })), [topTalkers.data, colors, talkersUnavailable], ); const talkersOption = useMemo(() => leaderboardOption(talkerRows, colors), [talkerRows, colors]); diff --git a/src/features/stats/chartOptions.ts b/src/features/stats/chartOptions.ts index 3ff06906..7606c0a1 100644 --- a/src/features/stats/chartOptions.ts +++ b/src/features/stats/chartOptions.ts @@ -78,8 +78,8 @@ export function observationsAreaOption( smooth: true, symbol: "none", data: points.map((p) => [p.hour, p.uniquePackets]), - lineStyle: { color: c.series[1], width: 1.3, type: "dashed" }, - itemStyle: { color: c.series[1] }, + lineStyle: { color: c.series[2], width: 1.3, type: "dashed" }, + itemStyle: { color: c.series[2] }, }, ], }; @@ -201,7 +201,7 @@ export function presetBarsOption( { name: labels.nodes, ...segment(rows.map((r) => r.nodes), c.series[0]!) }, { name: labels.observers, - ...segment(rows.map((r) => r.observers), c.series[1]!), + ...segment(rows.map((r) => r.observers), c.series[2]!), // outer segment carries the row total so it sits at the end of the whole stack label: { show: true, @@ -327,8 +327,8 @@ export function airtimeOption(points: TelemetryPoint[], c: ChartColors, bucketMs xAxis: timeAxis(c), yAxis: percentAxis(c), series: [ - { name: "RX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: airtimePctSeries(points, "airtimeRxSecs", bucketMs), lineStyle: { width: 1, color: c.series[0] }, areaStyle: { color: withAlpha(c.series[0]!, 0.35) }, itemStyle: { color: c.series[0] } }, - { name: "TX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: airtimePctSeries(points, "airtimeTxSecs", bucketMs), lineStyle: { width: 1, color: c.series[1] }, areaStyle: { color: withAlpha(c.series[1]!, 0.35) }, itemStyle: { color: c.series[1] } }, + { name: "RX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: airtimePctSeries(points, "airtimeRxSecs", bucketMs), lineStyle: { width: 1, color: c.series[1] }, areaStyle: { color: withAlpha(c.series[1]!, 0.35) }, itemStyle: { color: c.series[1] } }, + { name: "TX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: airtimePctSeries(points, "airtimeTxSecs", bucketMs), lineStyle: { width: 1, color: c.series[0] }, areaStyle: { color: withAlpha(c.series[0]!, 0.35) }, itemStyle: { color: c.series[0] } }, ], }; } @@ -379,10 +379,10 @@ export const batteryOption = (p: TelemetryPoint[], c: ChartColors, name = "Batte metricLineOption(p, c, { name, color: c.primary, accessor: (x) => (x.batteryMv == null ? null : +(x.batteryMv / 1000).toFixed(3)) }); export const noiseFloorOption = (p: TelemetryPoint[], c: ChartColors, name = "Noise dBm") => - metricLineOption(p, c, { name, color: c.primary, accessor: (x) => x.noiseFloorDb }); + metricLineOption(p, c, { name, color: c.warn, accessor: (x) => x.noiseFloorDb }); export const queueOption = (p: TelemetryPoint[], c: ChartColors, name = "Queue") => - metricLineOption(p, c, { name, color: c.primary, accessor: (x) => x.queueLength, area: true }); + metricLineOption(p, c, { name, color: c.secondary, accessor: (x) => x.queueLength, area: true }); // receiveErrors is a cumulative counter in raw points, a per-bucket delta in bucketed ones export const receiveErrorsOption = (p: TelemetryPoint[], c: ChartColors, bucketed: boolean, name = "Recv errors") => @@ -422,9 +422,9 @@ export function busyOption(points: ActivityPoint[], c: ChartColors, intervalMs: symbol: "none", connectNulls: false, data: points.map((p) => [p.t, pct(p)]), - lineStyle: { width: 1.5, color: c.primary }, - areaStyle: { color: withAlpha(c.primary, 0.28) }, - itemStyle: { color: c.primary }, + lineStyle: { width: 1.5, color: c.green }, + areaStyle: { color: withAlpha(c.green, 0.28) }, + itemStyle: { color: c.green }, }, ], }; @@ -466,7 +466,7 @@ export function snrHeardOption(points: ActivityPoint[], c: ChartColors, w: TimeW xAxis: windowAxis(c, w), yAxis: valueAxis(c, { scale: true, axisLabel: { color: c.textMuted, fontFamily: MONO, fontSize: 10, formatter: "{value} dB" } }), series: [ - { name: labels.average, type: "line", symbol: "none", connectNulls: false, data: points.map((p) => [p.t, p.snrAvg]), lineStyle: { width: 1.8, color: c.primary }, itemStyle: { color: c.primary } }, + { name: labels.average, type: "line", symbol: "none", connectNulls: false, data: points.map((p) => [p.t, p.snrAvg]), lineStyle: { width: 1.8, color: c.secondary }, itemStyle: { color: c.secondary } }, { name: labels.minimum, type: "line", symbol: "none", connectNulls: false, data: points.map((p) => [p.t, p.snrMin]), lineStyle: { width: 1, color: c.textMuted, type: "dashed" }, itemStyle: { color: c.textMuted } }, ], }; diff --git a/src/features/stats/chartTheme.ts b/src/features/stats/chartTheme.ts index fb2bed54..0eac0288 100644 --- a/src/features/stats/chartTheme.ts +++ b/src/features/stats/chartTheme.ts @@ -10,7 +10,6 @@ export interface ChartColors { primary: string; primaryDim: string; secondary: string; - // Semantic status colours. Do not use these merely to distinguish unrelated series. green: string; warn: string; danger: string; @@ -61,8 +60,7 @@ function linearToSrgb(channel: number): number { return c * 255; } -// OKLab is deliberately used here instead of RGB/HSL: equal distances are much closer to equal -// perceived colour differences, which is what matters when adjacent chart series must be told apart. +// OKLab distances track perceived difference, which is what tells two series apart. function rgbToOklab(color: string): OKLab { const [r8, g8, b8] = parseColor(color); const r = srgbToLinear(r8), g = srgbToLinear(g8), b = srgbToLinear(b8); @@ -100,8 +98,7 @@ function inGamut(rgb: RGB): boolean { return rgb.every((channel) => channel >= 0 && channel <= 255); } -// Chroma is reduced only when the requested OKLCH colour falls outside sRGB. This preserves hue and -// lightness instead of independently clipping channels, which can collapse two generated colours. +// Out-of-gamut colours lose chroma rather than having channels clipped, so hue and lightness hold. function lchToColor(lch: OKLCH): string { let candidate = lch; if (!inGamut(oklabToRgbRaw(lchToLab(candidate)))) { @@ -132,14 +129,6 @@ export function colorDistance(a: string, b: string): number { return Math.hypot(x[0] - y[0], x[1] - y[1], x[2] - y[2]); } -export function colorHueDistance(a: string, b: string): number { - const [, chromaA, hueA] = labToLch(rgbToOklab(a)); - const [, chromaB, hueB] = labToLch(rgbToOklab(b)); - if (chromaA < 0.02 || chromaB < 0.02) return 180; - const distance = Math.abs(hueA - hueB); - return Math.min(distance, 360 - distance); -} - function contrastingColor(lch: OKLCH, background: string): string { let color = lchToColor(lch); if (colorContrast(color, background) >= 3) return color; @@ -153,57 +142,31 @@ function contrastingColor(lch: OKLCH, background: string): string { return color; } -/** - * Build a categorical chart palette from the active theme's identity colours. - * - * Themes are allowed to reuse a semantic colour (for example Phosphor's primary and green are both - * #4ADE80), so token order alone is not a categorical palette. We make a pool from those tokens and - * theme-relative OKLCH hue rotations, then greedily pick the colour furthest from every colour already - * selected. The result remains derived from the theme while avoiding duplicate/near-duplicate series. - */ -export function categoricalPalette(themeColors: string[], background: string, count = 8, reservedColors: string[] = []): string[] { - if (themeColors.length === 0 || count <= 0) return []; - const labs = themeColors.map(rgbToOklab); - const lchs = labs.map(labToLch); - const chromatic = lchs.filter(([, chroma]) => chroma > 0.02); - const farFromReserved = (color: string) => reservedColors.every((reserved) => - colorDistance(color, reserved) >= 0.1 && colorHueDistance(color, reserved) >= 32); - const anchorIndex = themeColors.findIndex(farFromReserved); - const anchor = lchs[anchorIndex >= 0 ? anchorIndex : 0] ?? chromatic[0] ?? lchs[0]!; - const sortedLightness = lchs.map(([lightness]) => lightness).sort((a, b) => a - b); - const sortedChroma = chromatic.map(([, chroma]) => chroma).sort((a, b) => a - b); - const median = (values: number[], fallback: number) => values[Math.floor(values.length / 2)] ?? fallback; - const baseLightness = median(sortedLightness, anchor[0]); - const baseChroma = Math.max(0.08, Math.min(0.22, median(sortedChroma, anchor[1]))); - - const semantic = lchs.map((lch) => contrastingColor(lch, background)); - const generated = Array.from({ length: Math.max(32, count * 6) }, (_, i) => { - // A golden-angle walk avoids repeatedly landing in the same hue family. Alternating lightness - // gives later colours another perceptual dimension without introducing a fixed colour palette. - const hue = (anchor[2] + i * 137.507764) % 360; - const lightnessOffset = ((i % 5) - 2) * 0.06; - return contrastingColor([Math.max(0.2, Math.min(0.88, baseLightness + lightnessOffset)), baseChroma, hue], background); - }); - // Status colours are intentionally absent from ordinary categories: green/warn/danger must keep - // one meaning throughout Beacon. Perceptual distance also excludes nearby derived shades. - const candidates = [...semantic, ...generated].filter(farFromReserved); - const selected = [candidates.shift() ?? semantic[0]!]; +// Below this OKLab distance two series read as one colour (Phosphor's primary and green are identical). +const MIN_SERIES_DISTANCE = 0.05; - while (selected.length < count) { - let best: string | undefined; - let bestScore = -1; - for (const candidate of candidates) { - const score = Math.min(...selected.map((color) => colorDistance(candidate, color))); +// Keeps each colour unless it repeats an earlier one; a repeat becomes the hue furthest from the rest. +export function distinctColors(colors: string[], background: string): string[] { + const out: string[] = []; + colors.forEach((color, i) => { + if (out.every((kept) => colorDistance(color, kept) >= MIN_SERIES_DISTANCE)) { + out.push(color); + return; + } + const [lightness, chroma, hue] = labToLch(rgbToOklab(color)); + const others = [...out, ...colors.slice(i + 1)]; + let best = color, bestScore = -1; + for (let step = 1; step < 36; step += 1) { + const candidate = contrastingColor([lightness, Math.max(0.08, chroma), (hue + step * 137.507764) % 360], background); + const score = Math.min(...others.map((other) => colorDistance(candidate, other))); if (score > bestScore) { best = candidate; bestScore = score; } } - if (!best || bestScore < 0.001) break; - selected.push(best); - candidates.splice(candidates.indexOf(best), 1); - } - return selected; + out.push(best); + }); + return out; } export function withAlpha(color: string, a: number): string { @@ -236,12 +199,17 @@ export function readChartColors(): ChartColors { border: readVar("--color-border") || "#27272A", borderSubtle: readVar("--color-border-subtle") || "#1E1E22", }; - const series = categoricalPalette( - [c.primary, c.secondary, c.primaryDim], - c.bgSurface, - 8, - [c.green, c.warn, c.danger], - ); + // token order matters: series[0..5] stand in for primary, green, secondary, warn, danger, primaryDim + const series = distinctColors([ + c.primary, + c.green, + c.secondary, + c.warn, + c.danger, + c.primaryDim, + blend(c.primary, c.secondary), + blend(c.green, c.warn), + ], c.bgSurface); return { ...c, series }; } @@ -254,14 +222,14 @@ export function useChartColors(): ChartColors { } // Per-device-type colour, shared by the Mesh "Node types" donut and the neighbour graph so the two -// views stay in sync. These are categories, so none of them consume a semantic status colour. +// views stay in sync. Unknown types fall back to a dim primary. export function nodeTypeColor(typeName: string, c: ChartColors): string { switch (typeName) { case "companion": return c.series[0]!; case "repeater": return c.series[1]!; case "room_server": return c.series[2]!; case "sensor": return c.series[3]!; - default: return c.series[4]!; + default: return c.series[5]!; } } diff --git a/src/features/stats/paths.ts b/src/features/stats/paths.ts index f1094d94..d583e82d 100644 --- a/src/features/stats/paths.ts +++ b/src/features/stats/paths.ts @@ -25,7 +25,7 @@ export function pathLengthOption(bins: PathLengthBin[], c: ChartColors, t: TFunc tooltip: { trigger: "axis", renderMode: "richText", ...tooltipStyle(c) }, xAxis: { type: "category", data: entries, name: t("paths.entries"), nameLocation: "middle", nameGap: 25, nameTextStyle: { color: c.textMuted }, axisLabel: { color: c.textMuted, fontSize: 10 }, axisLine: { lineStyle: { color: c.border } } }, yAxis: { type: "value", minInterval: 1, axisLabel: { color: c.textMuted, fontSize: 10 }, splitLine: { lineStyle: { color: c.borderSubtle } } }, - series: [{ name: t("paths.receptionsColumn"), type: "bar", barMaxWidth: 26, data: entries.map((i) => ({ value: counts.get(i) ?? 0, itemStyle: { color: i === 0 ? c.textDim : blend(c.primaryDim, c.primary, i / Math.max(1, max)), borderRadius: [3, 3, 0, 0] } })) }], + series: [{ name: t("paths.receptionsColumn"), type: "bar", barMaxWidth: 26, data: entries.map((i) => ({ value: counts.get(i) ?? 0, itemStyle: { color: i === 0 ? c.textDim : blend(c.primary, c.secondary, i / Math.max(1, max)), borderRadius: [3, 3, 0, 0] } })) }], }; } diff --git a/src/features/stats/signal.ts b/src/features/stats/signal.ts index b3539ec8..dddad107 100644 --- a/src/features/stats/signal.ts +++ b/src/features/stats/signal.ts @@ -33,12 +33,12 @@ export function signalHistogramOption(metric: SignalMetric | undefined, name: st tooltip: { trigger: "axis", renderMode: "richText", ...tooltipStyle(c) }, xAxis: { type: "category", data: bins.map((bin) => `${signalBinLabel(bin, t)} ${unit}`), axisLabel: { color: c.textMuted, fontSize: 9, rotate: 35 }, axisLine: { lineStyle: { color: c.border } } }, yAxis: { type: "value", minInterval: 1, axisLabel: { color: c.textMuted, fontSize: 10 }, splitLine: { lineStyle: { color: c.borderSubtle } } }, - series: [{ name: t("signal.metricSamples", { metric: name }), type: "bar", barMaxWidth: 28, data: bins.map((bin, i) => ({ value: bin.count, itemStyle: { color: blend(c.bgRaised, c.primary, 0.45 + 0.55 * i / Math.max(1, bins.length - 1)), borderRadius: [3, 3, 0, 0] } })) }], + series: [{ name: t("signal.metricSamples", { metric: name }), type: "bar", barMaxWidth: 28, data: bins.map((bin, i) => ({ value: bin.count, itemStyle: { color: blend(name === "SNR" ? c.secondary : c.green, c.primary, i / Math.max(1, bins.length - 1)), borderRadius: [3, 3, 0, 0] } })) }], }; } export function signalTrendOption(hours: ReturnType, metric: "snr" | "rssi", c: ChartColors, t: TFunction): EChartsOption { - const color = metric === "snr" ? c.series[0]! : c.series[1]!; + const color = metric === "snr" ? c.secondary : c.green; const unit = metric === "snr" ? "dB" : "dBm"; return { animation: false, useUTC: true, diff --git a/src/features/stats/traffic.ts b/src/features/stats/traffic.ts index 94e7f0d6..80861d63 100644 --- a/src/features/stats/traffic.ts +++ b/src/features/stats/traffic.ts @@ -1,6 +1,6 @@ import type { TFunction } from "i18next"; import type { EChartsOption } from "./echarts-setup"; -import { blend, tooltipStyle, type ChartColors } from "./chartTheme"; +import { tooltipStyle, type ChartColors } from "./chartTheme"; import { RANGE_MS, type ObservationPoint, type StatsRange } from "./types"; const HOUR = 3_600_000; @@ -89,7 +89,7 @@ export function trafficHeatmapOption(model: TrafficModel, c: ChartColors, t: TFu yAxis: { type: "category", data: model.days, inverse: true, axisTick: { show: false }, axisLine: { show: false }, axisLabel: { color: c.textMuted, fontFamily: MONO, fontSize: 9, formatter: (day: string) => day.slice(5) } }, visualMap: { min: 0, max: Math.max(1, model.peak?.total ?? 0), calculable: false, orient: "horizontal", left: "center", bottom: 0, itemWidth: 10, itemHeight: 130, - text: [t("traffic.more"), t("traffic.fewer")], textStyle: { color: c.textMuted, fontSize: 10 }, inRange: { color: [0.18, 0.38, 0.62, 0.82, 1].map((amount) => blend(c.bgRaised, c.primary, amount)) } }, + text: [t("traffic.more"), t("traffic.fewer")], textStyle: { color: c.textMuted, fontSize: 10 }, inRange: { color: [5, 0, 2, 1, 3].map((i) => c.series[i]!) } }, series: [{ name: t("traffic.receptionsColumn"), type: "heatmap", data: model.heatmap, itemStyle: { borderColor: c.bgSurface, borderWidth: 1 }, emphasis: { itemStyle: { borderColor: c.textBright, borderWidth: 1 } } }], }; } diff --git a/tests/features/stats/chart-theme.test.ts b/tests/features/stats/chart-theme.test.ts index b2f692ea..1784debb 100644 --- a/tests/features/stats/chart-theme.test.ts +++ b/tests/features/stats/chart-theme.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; -import { categoricalPalette, colorContrast, colorDistance, colorHueDistance } from "../../../src/features/stats/chartTheme"; +import { blend, colorContrast, colorDistance, distinctColors } from "../../../src/features/stats/chartTheme"; interface ThemeFixture { id: string; @@ -9,52 +9,34 @@ interface ThemeFixture { const themes = JSON.parse(readFileSync("public/themes.json", "utf8")) as ThemeFixture[]; -describe("categoricalPalette", () => { - it("separates series when a theme reuses semantic colours", () => { - // Phosphor intentionally uses the same green for primary and success. - const palette = categoricalPalette( - ["#4ADE80", "#22D3EE", "#166534"], - "#0A1628", - 8, - ["#4ADE80", "#FDE047", "#F87171"], - ); +function seriesFor(theme: ThemeFixture) { + const v = (token: string) => theme.vars[`--palette-${token}`]!; + return [v("primary"), v("green"), v("secondary"), v("warn"), v("danger"), v("primary-dim"), blend(v("primary"), v("secondary")), blend(v("green"), v("warn"))]; +} - expect(palette).toHaveLength(8); - expect(new Set(palette).size).toBe(8); - for (let i = 0; i < palette.length; i += 1) { - for (let j = i + 1; j < palette.length; j += 1) { - expect(colorDistance(palette[i]!, palette[j]!)).toBeGreaterThan(0.08); - } - } +describe("distinctColors", () => { + it("keeps a theme's colours when none repeat", () => { + const colors = ["#3B82F6", "#22C55E", "#A78BFA", "#EAB308"]; + expect(distinctColors(colors, "#111114")).toEqual(colors); }); - it("keeps generated chart colours visible on dark and light theme surfaces", () => { - const accents = ["#007A94", "#1E7E34", "#0062CC", "#9A6A00", "#C82333", "#005F73"]; - const light = categoricalPalette(accents, "#FFFFFF"); - const dark = categoricalPalette(accents, "#111114"); - - expect(light.every((color) => colorContrast(color, "#FFFFFF") >= 3)).toBe(true); - expect(dark.every((color) => colorContrast(color, "#111114") >= 3)).toBe(true); + it("replaces a repeated colour with a distinct, visible one", () => { + // Phosphor uses the same green for primary and success + const [primary, green] = distinctColors(["#4ADE80", "#4ADE80", "#22D3EE"], "#0A1628"); + expect(primary).toBe("#4ADE80"); + expect(colorDistance(green!, "#4ADE80")).toBeGreaterThan(0.1); + expect(colorDistance(green!, "#22D3EE")).toBeGreaterThan(0.1); + expect(colorContrast(green!, "#0A1628")).toBeGreaterThanOrEqual(3); }); - it("produces a distinct, visible palette for every bundled theme", () => { + it("gives every bundled theme eight separable series and leaves clash-free themes untouched", () => { for (const theme of themes) { - const accents = ["primary", "secondary", "primary-dim"].map((token) => theme.vars[`--palette-${token}`]!); - const reserved = ["green", "warn", "danger"].map((token) => theme.vars[`--palette-${token}`]!); - const background = theme.vars["--palette-bg-surface"]!; - const palette = categoricalPalette(accents, background, 8, reserved); - const pairs = palette.flatMap((color, i) => palette.slice(i + 1).map((other) => colorDistance(color, other))); - - expect(palette, theme.id).toHaveLength(8); - expect(Math.min(...pairs), theme.id).toBeGreaterThan(0.075); - expect(palette.every((color) => colorContrast(color, background) >= 3), theme.id).toBe(true); - expect(palette.every((color) => reserved.every((status) => colorDistance(color, status) >= 0.1)), theme.id).toBe(true); - expect(palette.every((color) => reserved.every((status) => colorHueDistance(color, status) >= 32)), theme.id).toBe(true); + const wanted = seriesFor(theme); + const series = distinctColors(wanted, theme.vars["--palette-bg-surface"]!); + const pairs = series.flatMap((color, i) => series.slice(i + 1).map((other) => colorDistance(color, other))); + expect(series, theme.id).toHaveLength(8); + expect(Math.min(...pairs), theme.id).toBeGreaterThanOrEqual(0.05); + if (theme.id === "neutral-blue") expect(series).toEqual(wanted); } }); - - it("returns the requested number of colours without a named/static palette", () => { - const palette = categoricalPalette(["#B8E636", "#4ADE80", "#36C4E6"], "#121410", 12); - expect(palette).toHaveLength(12); - }); });