mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-16 18:00:02 +00:00
Fix theme-aware chart colour selection (#147)
This commit is contained in:
@@ -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>}
|
||||
</>}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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] } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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]!;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } } }],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user