diff --git a/src/features/observers/ObserverComparison.tsx b/src/features/observers/ObserverComparison.tsx index 7ccdf9a2..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.primary], ["B", bPoints, colors.secondary]].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.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.series[1]! }, + { label: t("observerCompare.onlyB"), count: overlap.data.onlyB, color: colors.series[2]! }, ] : []; 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..c1d548a1 100644 --- a/src/features/stats/MeshTab.tsx +++ b/src/features/stats/MeshTab.tsx @@ -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[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")); @@ -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..fc5478df 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..9a63f483 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/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..7606c0a1 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[2], width: 1.3, type: "dashed" }, + itemStyle: { color: c.series[2] }, }, ], }; @@ -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[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.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[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] } }, ], }; } diff --git a/src/features/stats/chartTheme.ts b/src/features/stats/chartTheme.ts index 745c9e57..0eac0288 100644 --- a/src/features/stats/chartTheme.ts +++ b/src/features/stats/chartTheme.ts @@ -31,6 +31,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 +50,125 @@ 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 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); + 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); +} + +// 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)))) { + 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]); +} + +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; +} + +// Below this OKLab distance two series read as one colour (Phosphor's primary and green are identical). +const MIN_SERIES_DISTANCE = 0.05; + +// 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; + } + } + out.push(best); + }); + return out; +} + export function withAlpha(color: string, a: number): string { const [r, g, b] = parseColor(color); return `rgba(${r}, ${g}, ${b}, ${a})`; @@ -78,8 +199,8 @@ 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 = [ + // token order matters: series[0..5] stand in for primary, green, secondary, warn, danger, primaryDim + const series = distinctColors([ c.primary, c.green, c.secondary, @@ -88,7 +209,7 @@ export function readChartColors(): ChartColors { c.primaryDim, blend(c.primary, c.secondary), blend(c.green, c.warn), - ]; + ], c.bgSurface); return { ...c, series }; } @@ -104,11 +225,11 @@ export function useChartColors(): ChartColors { // 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.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[5]!; } } diff --git a/src/features/stats/traffic.ts b/src/features/stats/traffic.ts index 3b245010..80861d63 100644 --- a/src/features/stats/traffic.ts +++ b/src/features/stats/traffic.ts @@ -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: [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 new file mode 100644 index 00000000..1784debb --- /dev/null +++ b/tests/features/stats/chart-theme.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { blend, colorContrast, colorDistance, distinctColors } from "../../../src/features/stats/chartTheme"; + +interface ThemeFixture { + id: string; + vars: Record; +} + +const themes = JSON.parse(readFileSync("public/themes.json", "utf8")) as ThemeFixture[]; + +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"))]; +} + +describe("distinctColors", () => { + it("keeps a theme's colours when none repeat", () => { + const colors = ["#3B82F6", "#22C55E", "#A78BFA", "#EAB308"]; + expect(distinctColors(colors, "#111114")).toEqual(colors); + }); + + 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("gives every bundled theme eight separable series and leaves clash-free themes untouched", () => { + for (const theme of themes) { + 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); + } + }); +});