Fix theme-aware chart colour selection (#147)

This commit is contained in:
Jesper B
2026-10-10 10:10:26 -04:00
committed by GitHub
parent 330015ea68
commit b453b8da8e
10 changed files with 210 additions and 47 deletions
@@ -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 ? <p role="alert">{t("common.loadFailed")}</p> : !overlap.data ? <p role="status">{t("common.loading")}</p> : <>
<p className="mb-3 text-lg font-semibold">{t("observerCompare.total", { count: overlap.data.totalPackets })}</p>
{overlap.data.totalPackets === 0 ? <p>{t("observerCompare.empty")}</p> : <div aria-hidden className="mb-3 flex h-5 overflow-hidden rounded">{groups.map(g => <div key={g.label} style={{ width: `${g.count / overlap.data!.totalPackets * 100}%`, background: g.color }} />)}</div>}
<dl className="grid grid-cols-3 gap-2 text-sm">{groups.map(g => <div key={g.label}><dt className="text-text-muted">{g.label}</dt><dd className={`text-lg font-semibold ${g.textClass}`}>{g.count.toLocaleString(i18n.resolvedLanguage)}</dd></div>)}</dl>
<dl className="grid grid-cols-3 gap-2 text-sm">{groups.map(g => <div key={g.label}><dt className="text-text-muted">{g.label}</dt><dd className="text-lg font-semibold" style={{ color: g.color }}>{g.count.toLocaleString(i18n.resolvedLanguage)}</dd></div>)}</dl>
</>}
</Card>}
</>}
+4 -4
View File
@@ -126,10 +126,10 @@ export function MeshTab({ range, onSelectObserver }: MeshTabProps) {
return (
<div className="mx-auto flex w-full min-w-0 max-w-[1200px] flex-col gap-3.5 p-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label={t("mesh.totalPackets")} sublabel={ovWindow} accent="var(--color-primary)" value={formatCount(kpis?.uniquePackets)} spark={sparks.packets} />
<StatCard label={t("mesh.observations")} sublabel={ovWindow} accent="var(--color-green)" value={formatCount(kpis?.observations)} spark={sparks.observations} />
<StatCard label={t("mesh.activeObservers")} sublabel={ovWindow} accent="var(--color-secondary)" value={kpis?.activeObservers ?? "—"} spark={sparks.observers} />
<StatCard label={t("mesh.activeIatas")} sublabel={ovWindow} accent="var(--color-warn)" value={kpis?.activeIatas ?? "—"} spark={sparks.iatas} />
<StatCard label={t("mesh.totalPackets")} sublabel={ovWindow} accent={colors.series[0]!} value={formatCount(kpis?.uniquePackets)} spark={sparks.packets} />
<StatCard label={t("mesh.observations")} sublabel={ovWindow} accent={colors.series[1]!} value={formatCount(kpis?.observations)} spark={sparks.observations} />
<StatCard label={t("mesh.activeObservers")} sublabel={ovWindow} accent={colors.series[2]!} value={kpis?.activeObservers ?? "—"} spark={sparks.observers} />
<StatCard label={t("mesh.activeIatas")} sublabel={ovWindow} accent={colors.series[3]!} value={kpis?.activeIatas ?? "—"} spark={sparks.iatas} />
</div>
<ChartCard
+7 -7
View File
@@ -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.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 }) {
<SectionInfo text={[t("paths.subtitle"), t("paths.measurement"), data && t("paths.window", { since: formatUtc(data.since), until: formatUtc(data.until) })]} />
{query.isError && <p role="alert" className="text-sm text-danger">{t("paths.error")}</p>}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard label={t("paths.receptions")} value={data ? formatCount(data.receptions) : "—"} accent={c.primary} sublabel={t(`stats.ranges.${range}`)} spark={sparks.receptions} />
<StatCard label={t("paths.withHashPaths")} value={data ? formatCount(data.hashed) : "—"} accent={c.green} spark={sparks.hashed} />
<StatCard label={t("paths.multiByteShare")} value={data?.hashed ? `${(100 * multi / data.hashed).toFixed(1)}%` : "—"} accent={c.secondary} spark={sparks.multiShare} />
<StatCard label={t("paths.mostEntries")} value={largest ?? "—"} accent={c.warn} spark={sparks.maxEntries} />
<StatCard label={t("paths.receptions")} value={data ? formatCount(data.receptions) : "—"} accent={c.series[0]!} sublabel={t(`stats.ranges.${range}`)} spark={sparks.receptions} />
<StatCard label={t("paths.withHashPaths")} value={data ? formatCount(data.hashed) : "—"} accent={c.series[1]!} spark={sparks.hashed} />
<StatCard label={t("paths.multiByteShare")} value={data?.hashed ? `${(100 * multi / data.hashed).toFixed(1)}%` : "—"} accent={c.series[2]!} spark={sparks.multiShare} />
<StatCard label={t("paths.mostEntries")} value={largest ?? "—"} accent={c.series[3]!} spark={sparks.maxEntries} />
</div>
<div className="grid min-w-0 grid-cols-1 gap-3.5 lg:grid-cols-2">
<ChartCard title={t("paths.widthTitle")} option={charts.width} height={260} isEmpty={!data?.hashed} {...state} />
+4 -4
View File
@@ -41,10 +41,10 @@ export function ScopesTab({ range }: { range: StatsRange }) {
</label>
{query.isError && <p role="alert" className="text-sm text-danger">{t("scopes.error")}</p>}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard label={t("scopes.active")} value={value(totals.active)} accent={colors.secondary} spark={sparks?.active} />
<StatCard label={t("scopes.scopedPackets")} value={packetsUnknown ? "—" : value(totals.packets)} accent={colors.primary} spark={sparks?.packets} />
<StatCard label={t("scopes.memberships")} value={value(totals.memberships)} accent={colors.green} spark={sparks?.observers} sublabel={sparks?.observers && t("scopes.observersLine")} />
<StatCard label={t("scopes.defaultNodes")} value={value(totals.nodes)} accent={colors.warn} spark={sparks?.nodes} sublabel={sparks?.nodes && t("scopes.nodesLine")} />
<StatCard label={t("scopes.active")} value={value(totals.active)} accent={colors.series[2]!} spark={sparks?.active} />
<StatCard label={t("scopes.scopedPackets")} value={packetsUnknown ? "—" : value(totals.packets)} accent={colors.series[0]!} spark={sparks?.packets} />
<StatCard label={t("scopes.memberships")} value={value(totals.memberships)} accent={colors.series[1]!} spark={sparks?.observers} sublabel={sparks?.observers && t("scopes.observersLine")} />
<StatCard label={t("scopes.defaultNodes")} value={value(totals.nodes)} accent={colors.series[3]!} spark={sparks?.nodes} sublabel={sparks?.nodes && t("scopes.nodesLine")} />
</div>
<div className="grid grid-cols-1 gap-3.5 lg:grid-cols-2">
<ChartCard title={t("scopes.packetsTitle")} option={packets} height={height} isLoading={loading} isError={query.isError} isEmpty={!totals.packets} />
+4 -4
View File
@@ -35,10 +35,10 @@ export function SignalTab({ range }: { range: StatsRange }) {
<SectionInfo text={[t("signal.subtitle"), t("signal.measurement"), data && t("signal.window", { since: formatUtc(data.since), until: formatUtc(data.until) })]} />
{query.isError && <p role="alert" className="text-sm text-danger">{t("signal.error")}</p>}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard label={t("signal.receptions")} value={data ? formatCount(data.receptions) : "—"} accent={c.primary} sublabel={t(`stats.ranges.${range}`)} spark={sparks?.receptions} />
<StatCard label={t("signal.meanSnr")} value={average(data?.snr.average, "dB")} accent={c.secondary} spark={sparks?.snr} />
<StatCard label={t("signal.meanRssi")} value={average(data?.rssi.average, "dBm")} accent={c.green} spark={sparks?.rssi} />
<StatCard label={t("signal.hoursWithRecords")} value={data ? `${data.hourly.length}/${hours.length}` : "—"} accent={c.warn} presence={sparks?.reported} />
<StatCard label={t("signal.receptions")} value={data ? formatCount(data.receptions) : "—"} accent={c.series[0]!} sublabel={t(`stats.ranges.${range}`)} spark={sparks?.receptions} />
<StatCard label={t("signal.meanSnr")} value={average(data?.snr.average, "dB")} accent={c.series[2]!} spark={sparks?.snr} />
<StatCard label={t("signal.meanRssi")} value={average(data?.rssi.average, "dBm")} accent={c.series[1]!} spark={sparks?.rssi} />
<StatCard label={t("signal.hoursWithRecords")} value={data ? `${data.hourly.length}/${hours.length}` : "—"} accent={c.series[3]!} presence={sparks?.reported} />
</div>
<div className="grid min-w-0 grid-cols-1 gap-3.5 lg:grid-cols-2">
<ChartCard title={t("signal.histogramTitle", { metric: "SNR", unit: "dB" })} option={charts.snr} height={280} isEmpty={!data?.snr.samples} {...state} />
+4 -4
View File
@@ -35,10 +35,10 @@ export function TrafficTab({ range }: { range: StatsRange }) {
<SectionInfo text={[t("traffic.subtitle"), t("traffic.measurement")]} />
{query.isError && <p role="alert" className="text-sm text-danger">{t("traffic.error")}</p>}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard label={t("traffic.receptions")} value={value(model.total)} accent={colors.primary} sublabel={t(`stats.ranges.${range}`)} spark={sparks?.totals} />
<StatCard label={t("traffic.reportingIatas")} value={value(model.areas.filter((area) => area.total > 0).length)} accent={colors.green} spark={sparks?.areas} />
<StatCard label={t("traffic.busiestHour")} value={unavailable || !model.peak ? "—" : formatCount(model.peak.total)} accent={colors.secondary} spark={sparks?.totals} markPeak />
<StatCard label={t("traffic.hoursWithRecords")} value={unavailable ? "—" : `${model.reportedHours}/${model.hours.length}`} accent={colors.warn} presence={sparks?.reported} />
<StatCard label={t("traffic.receptions")} value={value(model.total)} accent={colors.series[0]!} sublabel={t(`stats.ranges.${range}`)} spark={sparks?.totals} />
<StatCard label={t("traffic.reportingIatas")} value={value(model.areas.filter((area) => area.total > 0).length)} accent={colors.series[1]!} spark={sparks?.areas} />
<StatCard label={t("traffic.busiestHour")} value={unavailable || !model.peak ? "—" : formatCount(model.peak.total)} accent={colors.series[2]!} spark={sparks?.totals} markPeak />
<StatCard label={t("traffic.hoursWithRecords")} value={unavailable ? "—" : `${model.reportedHours}/${model.hours.length}`} accent={colors.series[3]!} presence={sparks?.reported} />
</div>
<ChartCard title={t("traffic.trendTitle")} right={<span className="text-[10px] text-text-muted">{t("traffic.hourlyRecords")}</span>} option={trend} height={260} isLoading={loading} isError={query.isError} isEmpty={!model.total} />
<ChartCard title={t("traffic.hourlyTitle", { range: t(`stats.ranges.${range}`) })} option={heatmap} height={Math.max(150, model.days.length * 15 + 72)} isLoading={loading} isError={query.isError} isEmpty={!model.total} />
+10 -10
View File
@@ -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] } },
],
};
}
+129 -8
View File
@@ -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]!;
}
}
+1 -1
View File
@@ -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 } } }],
};
}
+42
View File
@@ -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<string, string>;
}
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);
}
});
});