diff --git a/package-lock.json b/package-lock.json index 1d4378d..71aa4a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "beacon-web", - "version": "2.0.5", + "version": "2.0.5-dev1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "beacon-web", - "version": "2.0.5", + "version": "2.0.5-dev1.1", "license": "AGPL-3.0-or-later", "dependencies": { "@nazka/map-gl-js-spiderfy": "^2.0.0", @@ -4094,9 +4094,9 @@ "license": "ISC" }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { diff --git a/package.json b/package.json index c983950..de590fc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "beacon-web", "private": true, - "version": "2.0.5", + "version": "2.0.5-dev1.1", "license": "AGPL-3.0-or-later", "type": "module", "scripts": { diff --git a/src/api/client.ts b/src/api/client.ts index 4a7691c..2f0da47 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -13,6 +13,7 @@ import type { PayloadBreakdownItem, TopNode, TopObserver, + AdvertiserSort, TopAdvertiser, TopTalker, RadioPreset, @@ -422,8 +423,8 @@ export function getTopObservers(iatas?: StatsRegion, since?: number, limit = 10) return request("/stats/top-observers", { ...statsRegionParams(iatas), since, limit }); } -export function getTopAdvertisers(iatas?: StatsRegion, since?: number, limit = 10): Promise { - return request("/stats/top-advertisers", { ...statsRegionParams(iatas), since, limit }); +export function getTopAdvertisers(iatas: StatsRegion | undefined, since: number | undefined, sort: AdvertiserSort, limit = 10): Promise { + return request("/stats/top-advertisers", { ...statsRegionParams(iatas), since, sort, limit }); } export function getTopTalkers(iatas?: StatsRegion, since?: number, limit = 10): Promise { diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx index 1505510..fe4092c 100644 --- a/src/components/Badge.tsx +++ b/src/components/Badge.tsx @@ -1,10 +1,10 @@ import type { ReactNode } from "react"; import { VARIANT_CLASSES, type BadgeVariant } from "./badge-utils"; -export function Badge({ variant, children }: { variant: BadgeVariant; children: ReactNode }) { +export function Badge({ variant, children, normalCase = false }: { variant: BadgeVariant; children: ReactNode; normalCase?: boolean }) { return ( {children} diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx index 267f38b..c64f94d 100644 --- a/src/components/Tooltip.tsx +++ b/src/components/Tooltip.tsx @@ -1,11 +1,13 @@ -import { useState, useRef, useEffect, useLayoutEffect, type MouseEvent as ReactMouseEvent, type ReactNode } from "react"; +import { useState, useRef, useEffect, useId, useLayoutEffect, type MouseEvent as ReactMouseEvent, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { useHasHover } from "../hooks/useMediaQuery"; // Portals to with fixed positioning so overflow parents (the data tables) can't clip it. // Hover reveals with a mouse; on touch it toggles on tap and dismisses on an outside tap. -export function Tooltip({ label, children, className = "", wrap = false }: { label: ReactNode; children: ReactNode; className?: string; wrap?: boolean }) { +// `focusable` makes a non-interactive trigger a tab stop whose label is exposed to screen readers. +export function Tooltip({ label, children, className = "", wrap = false, focusable = false }: { label: ReactNode; children: ReactNode; className?: string; wrap?: boolean; focusable?: boolean }) { const hasHover = useHasHover(); + const descId = useId(); const ref = useRef(null); const tipRef = useRef(null); const [anchor, setAnchor] = useState(null); @@ -62,9 +64,12 @@ export function Tooltip({ label, children, className = "", wrap = false }: { lab onClick={hasHover ? undefined : toggle} onFocus={hasHover ? show : undefined} onBlur={hasHover ? hide : undefined} + tabIndex={focusable ? 0 : undefined} + aria-describedby={focusable ? descId : undefined} className={`inline-flex ${className}`} > {children} + {focusable && } {anchor && createPortal( + {t("nodes.possiblyForeign")} ); diff --git a/src/features/nodes/MeshMapperSection.tsx b/src/features/nodes/MeshMapperSection.tsx new file mode 100644 index 0000000..fa030b0 --- /dev/null +++ b/src/features/nodes/MeshMapperSection.tsx @@ -0,0 +1,91 @@ +import { useTranslation } from "react-i18next"; +import { Section, Field } from "../../components/DetailPanel"; +import { IataChip } from "../../components/IataChip"; +import { Timestamp } from "../../components/Timestamp"; +import type { MeshMapperRepeater, MeshMapperScope } from "./types"; + +function ScopeRow({ scope }: { scope: MeshMapperScope }) { + const { t } = useTranslation(); + const how = [ + scope.observedPackets != null ? t("nodeDetail.observedPackets", { count: scope.observedPackets }) : scope.observedAt != null ? t("nodeDetail.observed") : null, + scope.reportedAt != null ? t("nodeDetail.reported") : null, + scope.uploadedAt != null ? t("nodeDetail.uploaded") : null, + ].filter(Boolean).join(" · "); + const latest = Math.max(scope.observedAt ?? 0, scope.reportedAt ?? 0, scope.uploadedAt ?? 0); + return ( +
+ {scope.scope} + {how && {how}} + {latest > 0 && } +
+ ); +} + +export function MeshMapperSection({ mm }: { mm: MeshMapperRepeater }) { + const { t, i18n } = useTranslation(); + const { site, defaultScope } = mm; + const hasSite = Boolean(site.hardware || site.power || site.powerSource || site.antenna || site.heightM != null || site.notes); + const hasScopes = mm.scopes.length > 0 || mm.scopesCheckedAt != null; + // carpeater alone is covered by the badge next to the node name + if (!hasSite && !hasScopes && mm.admins.length === 0 && !defaultScope) return null; + + const num = new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 1 }); + const height = site.heightM != null + ? [t("nodeDetail.heightAgl", { value: num.format(site.heightM) }), site.heightAslM != null ? t("nodeDetail.heightAsl", { value: num.format(site.heightAslM) }) : null].filter(Boolean).join(" · ") + : null; + + return ( +
+
+ {(site.hardware || site.power || site.powerSource) && ( +
+ {site.hardware && } + {site.power && } + {site.powerSource && } +
+ )} + {site.antenna && } + {height && } + {mm.admins.length > 0 && } + {defaultScope && ( + + {defaultScope.state === "named" && defaultScope.name ? defaultScope.name : t(`nodeDetail.scopeState.${defaultScope.state === "none" ? "none" : "unknown"}`)} + {(defaultScope.source || defaultScope.updatedAt != null) && ( + + {" "}({defaultScope.source && t(`nodeDetail.scopeSource.${defaultScope.source}`, { defaultValue: defaultScope.source })} + {defaultScope.source && defaultScope.updatedAt != null && ", "} + {defaultScope.updatedAt != null && }) + + )} + + } + /> + )} + {hasScopes && ( +
+ {t("nodeDetail.confirmedScopes")} +
+ {mm.scopes.length > 0 ? ( + mm.scopes.map((s, i) => ) + ) : ( +
+ {t("nodeDetail.noConfirmedScopes")} + +
+ )} +
+
+ )} + {site.notes &&

{site.notes}

} +
+
+ {t("nodeDetail.fromMeshMapper")} + {mm.iata && {mm.iata}} + {t("nodeDetail.updated")} +
+
+ ); +} diff --git a/src/features/nodes/NodeDetailPanel.tsx b/src/features/nodes/NodeDetailPanel.tsx index dedc183..7941dd2 100644 --- a/src/features/nodes/NodeDetailPanel.tsx +++ b/src/features/nodes/NodeDetailPanel.tsx @@ -11,6 +11,8 @@ import { Timestamp } from "../../components/Timestamp"; import { hasMapLocation } from "../map/location"; import type { NodeObservation, NodeNeighbor } from "./types"; import { ForeignNodeBadge } from "./ForeignNodeBadge"; +import { MeshMapperSection } from "./MeshMapperSection"; +import { Tooltip } from "../../components/Tooltip"; function NodeNeighborRow({ neighbor, onClick }: { neighbor: NodeNeighbor; onClick?: () => void }) { const { t } = useTranslation(); @@ -126,6 +128,11 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o
{node.nodeTypeName} + {node.meshmapper?.carpeater && ( + + {t("nodeDetail.carpeater")} + + )}
@@ -163,6 +170,8 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o )} + {node.meshmapper && } +
{node.minFirmwareVersion && } diff --git a/src/features/nodes/types.ts b/src/features/nodes/types.ts index d95da86..2568a31 100644 --- a/src/features/nodes/types.ts +++ b/src/features/nodes/types.ts @@ -40,6 +40,46 @@ export interface Node extends NodeSummary { clockDriftSeconds?: number; clockOutOfSync?: boolean; clockCheckedAt?: number; // epoch ms + // MeshMapper's record for this repeater; node detail only, absent when the import is off or unmatched. + meshmapper?: MeshMapperRepeater; +} + +// Imported from MeshMapper. Times are epoch ms; every string is admin-entered free text. +export interface MeshMapperRepeater { + iata?: string; + fetchedAt: number; + site: MeshMapperSite; + carpeater: boolean; + admins: string[]; + scopes: MeshMapperScope[]; + scopesCheckedAt?: number; // set with empty scopes = it answered with none; absent = never asked + defaultScope?: MeshMapperDefaultScope; // separate from Node.defaultScope (Beacon's own match) +} + +export interface MeshMapperSite { + power?: string; + hardware?: string; + antenna?: string; + heightM?: number; // above ground + heightAslM?: number; + powerSource?: "mains" | "solar" | "battery" | "poe"; + notes?: string; +} + +export interface MeshMapperScope { + scope: string; + observedAt?: number; + observedPackets?: number; + proof?: "multibyte" | "floor"; + reportedAt?: number; + uploadedAt?: number; +} + +export interface MeshMapperDefaultScope { + name?: string; // only when state is "named" + state: "named" | "unknown" | "none"; + updatedAt?: number; + source?: "advert" | "reported"; } // First-hop neighbor of a node, from GET /nodes/{id}/neighbors (bare array, no pagination). diff --git a/src/features/observers/ObserverComparison.tsx b/src/features/observers/ObserverComparison.tsx index 7ccdf9a..eda617a 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 51b47f5..c1d548a 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 f2e733d..fc5478d 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 96774bb..917e5f8 100644 --- a/src/features/stats/SignalTab.tsx +++ b/src/features/stats/SignalTab.tsx @@ -33,10 +33,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 f60b5a3..ff5f031 100644 --- a/src/features/stats/TalkersTab.tsx +++ b/src/features/stats/TalkersTab.tsx @@ -1,15 +1,16 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useChartColors } from "./chartTheme"; import { rolledWindow, useTopAdvertisers, useTopTalkers } from "./useStats"; import { leaderboardOption } from "./chartOptions"; import { Card, ChartCard } from "./cards"; +import { Segmented } from "./Segmented"; import { DataTable, type Column } from "../../components/DataTable"; import { Badge } from "../../components/Badge"; import { IataChip } from "../../components/IataChip"; import { formatCount, formatRatePerDay } from "../../lib/formatters"; import { RANGE_MS } from "./types"; -import type { TopAdvertiser, StatsRange } from "./types"; +import type { AdvertiserSort, TopAdvertiser, StatsRange } from "./types"; interface TalkersTabProps { range: StatsRange; @@ -32,7 +33,8 @@ function coveredMs(range: StatsRange, fetchedAt: number) { export function TalkersTab({ range, onViewNode }: TalkersTabProps) { const { t } = useTranslation(); const colors = useChartColors(); - const topAdvertisers = useTopAdvertisers(range, 20); + const [advertSort, setAdvertSort] = useState("flood"); + const topAdvertisers = useTopAdvertisers(range, advertSort, 20); const topTalkers = useTopTalkers(range, 20); const advertisersLoading = topAdvertisers.isPending || topAdvertisers.isPlaceholderData; @@ -76,7 +78,15 @@ export function TalkersTab({ range, onViewNode }: TalkersTabProps) { const rangeLabel = t(`stats.ranges.${range}`); return (
- {t("talkers.floodDirect")}}> + setAdvertSort(v as AdvertiserSort)} + options={[{ value: "flood", label: t("talkers.flood") }, { value: "direct", label: t("talkers.direct") }]} + /> + }>
{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 42a3f0c..7606c0a 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 745c9e5..0eac028 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 3b24501..80861d6 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/src/features/stats/types.ts b/src/features/stats/types.ts index 965a64c..dae5186 100644 --- a/src/features/stats/types.ts +++ b/src/features/stats/types.ts @@ -77,6 +77,8 @@ export interface TopObserver { observationCount: number; } +export type AdvertiserSort = "flood" | "direct"; + export interface TopAdvertiser { nodeId: string | null; // null once the node row is deleted; key rows by publicKey publicKey: string; diff --git a/src/features/stats/useStats.ts b/src/features/stats/useStats.ts index f5680fb..1e2b8da 100644 --- a/src/features/stats/useStats.ts +++ b/src/features/stats/useStats.ts @@ -14,7 +14,7 @@ import { getClockDrift, type StatsRegion, } from "../../api/client"; -import { RANGE_MS, type StatsRange } from "./types"; +import { RANGE_MS, type AdvertiserSort, type StatsRange } from "./types"; // Shared query options: cache for 30s; previous data stays as placeholder so tabs can decide whether to show it during a switch. const common = { @@ -102,11 +102,11 @@ export function useTopObservers(range: StatsRange, limit = 10) { }); } -export function useTopAdvertisers(range: StatsRange, limit = 10) { +export function useTopAdvertisers(range: StatsRange, sort: AdvertiserSort, limit = 10) { const { where, regionKey, isResolved } = useStatsRegion(); return useQuery({ - queryKey: ["stats-top-advertisers", regionKey, range, limit], - ...gatedOn(isResolved, () => getTopAdvertisers(where, sinceFor(range), limit)), + queryKey: ["stats-top-advertisers", regionKey, range, sort, limit], + ...gatedOn(isResolved, () => getTopAdvertisers(where, sinceFor(range), sort, limit)), ...common, }); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 1f7f897..84263a1 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -283,7 +283,42 @@ "noRecentPackets": "No recent packets", "observations_one": "{{formatted}} obs", "observations_other": "{{formatted}} obs", - "hops": "Hops" + "hops": "Hops", + "hardware": "Hardware", + "power": "Power", + "antenna": "Antenna", + "powerSource": "Power source", + "powerSources": { + "mains": "Mains", + "solar": "Solar", + "battery": "Battery", + "poe": "PoE" + }, + "height": "Height", + "heightAgl": "{{value}} m AGL", + "heightAsl": "{{value}} m ASL", + "admins": "Admins", + "mmDefaultScope": "Default scope", + "scopeState": { + "unknown": "Unknown", + "none": "None" + }, + "scopeSource": { + "advert": "advert", + "reported": "reported" + }, + "confirmedScopes": "Confirmed scopes", + "noConfirmedScopes": "None confirmed", + "observedPackets_one": "observed {{count}} packet", + "observedPackets_other": "observed {{count}} packets", + "observed": "observed", + "reported": "reported", + "uploaded": "uploaded", + "meshmapper": "MeshMapper", + "fromMeshMapper": "From MeshMapper", + "updated": "updated", + "carpeater": "CARpeater", + "carpeaterHint": "Mobile repeater in a vehicle: its position moves." }, "nodes": { "lastHeard": "last heard {{ago}}", @@ -816,7 +851,7 @@ "flood": "Flood", "direct": "Direct", "topAdvertisers": "Top advertisers · {{range}}", - "floodDirect": "flood · direct", + "sortBy": "Sort advertisers by", "noAdvertisers": "No advertisers", "topTalkers": "Top talkers · {{range}}", "byName": "by name" diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index e6c5b30..38a3152 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -848,7 +848,7 @@ "flood": "Flood", "direct": "Direct", "topAdvertisers": "Principaux annonceurs · {{range}}", - "floodDirect": "flood · direct", + "sortBy": "Trier les annonceurs par", "noAdvertisers": "Aucun annonceur", "topTalkers": "Principaux émetteurs · {{range}}", "byName": "par nom" diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json index 197255b..d455120 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -815,7 +815,7 @@ "flood": "Flood", "direct": "Direkt", "topAdvertisers": "Mest aktiva annonsörer · {{range}}", - "floodDirect": "flood · direkt", + "sortBy": "Sortera annonsörer efter", "noAdvertisers": "Inga annonsörer", "topTalkers": "Mest aktiva sändare · {{range}}", "byName": "efter namn" diff --git a/tests/api/client.test.ts b/tests/api/client.test.ts index df55de4..a637077 100644 --- a/tests/api/client.test.ts +++ b/tests/api/client.test.ts @@ -431,15 +431,16 @@ describe("stats endpoints", () => { expect(url.searchParams.get("limit")).toBe("15"); }); - it("hits /stats/top-advertisers with iatas/since/limit", async () => { + it("hits /stats/top-advertisers with iatas/since/sort/limit", async () => { const getUrl = mockFetchOnce([]); - await getTopAdvertisers(["YOW", "YYZ"], 1700000000000, 10); + await getTopAdvertisers(["YOW", "YYZ"], 1700000000000, "direct", 10); const url = new URL(getUrl()); expect(url.pathname).toContain("/stats/top-advertisers"); expect(url.searchParams.get("iatas")).toBe("YOW,YYZ"); expect(url.searchParams.get("since")).toBe("1700000000000"); + expect(url.searchParams.get("sort")).toBe("direct"); expect(url.searchParams.get("limit")).toBe("10"); }); diff --git a/tests/components/Tooltip.test.tsx b/tests/components/Tooltip.test.tsx index e9b26d3..8951990 100644 --- a/tests/components/Tooltip.test.tsx +++ b/tests/components/Tooltip.test.tsx @@ -72,3 +72,19 @@ describe("Tooltip (mobile)", () => { expect(onParentClick).not.toHaveBeenCalled(); }); }); + +describe("Tooltip focusable", () => { + it("is a tab stop described by its label for keyboard and screen-reader users", () => { + setMobile(false); + render(target); + const trigger = screen.getByText("target").parentElement!; + expect(trigger).toHaveAttribute("tabindex", "0"); + expect(trigger).toHaveAccessibleDescription("tip text"); + }); + + it("is not a tab stop by default", () => { + setMobile(false); + render(target); + expect(screen.getByText("target").parentElement).not.toHaveAttribute("tabindex"); + }); +}); diff --git a/tests/features/nodes/NodeDetailPanel.test.tsx b/tests/features/nodes/NodeDetailPanel.test.tsx index 6c9424a..664e773 100644 --- a/tests/features/nodes/NodeDetailPanel.test.tsx +++ b/tests/features/nodes/NodeDetailPanel.test.tsx @@ -5,7 +5,7 @@ import type { ReactNode } from "react"; import { NodeDetailPanel } from "../../../src/features/nodes/NodeDetailPanel"; import i18n from "../../../src/i18n"; import { getNode, getNodeObservations, getNodeNeighbors } from "../../../src/api/client"; -import type { Node, NodeNeighbor } from "../../../src/features/nodes/types"; +import type { MeshMapperRepeater, Node, NodeNeighbor } from "../../../src/features/nodes/types"; vi.mock("../../../src/api/client", () => ({ getNode: vi.fn(), @@ -45,8 +45,8 @@ function renderPanel(onViewNode = vi.fn()) { const wrapper = ({ children }: { children: ReactNode }) => ( {children} ); - render(, { wrapper }); - return { onViewNode }; + const result = render(, { wrapper }); + return { ...result, onViewNode }; } beforeEach(() => { @@ -227,3 +227,117 @@ describe("NodeDetailPanel in French", () => { expect(await screen.findByText("Possiblement étranger")).toBeInTheDocument(); }); }); + +describe("NodeDetailPanel MeshMapper", () => { + const meshmapper: MeshMapperRepeater = { + iata: "YOW", + fetchedAt: Date.now() - 60_000, + site: { + power: "1W", hardware: "RAK4631", antenna: "5.8 dBi fibreglass", + heightM: 12, heightAslM: 341.6, powerSource: "solar", + notes: "On the fire tower, east face", + }, + carpeater: false, + admins: ["MrAlders0n"], + scopes: [ + { scope: "yow", observedAt: Date.now() - 3_600_000, observedPackets: 37, proof: "multibyte", reportedAt: Date.now() - 7_200_000 }, + ], + scopesCheckedAt: Date.now() - 7_200_000, + defaultScope: { name: "#yow", state: "named", updatedAt: Date.now() - 180_000, source: "advert" }, + }; + const empty: MeshMapperRepeater = { fetchedAt: 1, site: {}, carpeater: false, admins: [], scopes: [] }; + + it("shows MeshMapper's site details, admins and scopes", async () => { + mockGetNode.mockResolvedValue({ ...node, meshmapper }); + renderPanel(); + + expect(await screen.findByText("MeshMapper")).toBeInTheDocument(); + expect(screen.getByText("RAK4631")).toBeInTheDocument(); + expect(screen.getByText("1W")).toBeInTheDocument(); + expect(screen.getByText("5.8 dBi fibreglass")).toBeInTheDocument(); + expect(screen.getByText("Solar")).toBeInTheDocument(); + expect(screen.getByText("12 m AGL · 341.6 m ASL")).toBeInTheDocument(); + expect(screen.getByText("MrAlders0n")).toBeInTheDocument(); + expect(screen.getByText("#yow")).toBeInTheDocument(); + expect(screen.getByText("yow")).toBeInTheDocument(); + expect(screen.getByText("observed 37 packets · reported")).toBeInTheDocument(); + expect(screen.getByText("On the fire tower, east face")).toBeInTheDocument(); + expect(screen.getByText(/From MeshMapper/)).toBeInTheDocument(); + expect(screen.queryByText("CARpeater")).not.toBeInTheDocument(); + }); + + it("never shows MeshMapper's proof type", async () => { + mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...empty, scopes: [{ scope: "yow", observedPackets: 3, proof: "floor" }] } }); + renderPanel(); + expect(await screen.findByText("observed 3 packets")).toBeInTheDocument(); + expect(screen.queryByText(/floor/)).not.toBeInTheDocument(); + }); + + it("describes a scope confirmed only by report or upload", async () => { + mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...empty, scopes: [{ scope: "yow", reportedAt: 1, uploadedAt: 2 }] } }); + renderPanel(); + expect(await screen.findByText("reported · uploaded")).toBeInTheDocument(); + }); + + it("shows height above ground alone when sea level is missing", async () => { + mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...empty, site: { heightM: 12 } } }); + renderPanel(); + expect(await screen.findByText("12 m AGL")).toBeInTheDocument(); + }); + + it("formats heights in the reader's locale", async () => { + mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...empty, site: { heightM: 12.25, heightAslM: 341.6 } } }); + await act(() => i18n.changeLanguage("fr")); + renderPanel(); + expect(await screen.findByText("12,3 m AGL · 341,6 m ASL")).toBeInTheDocument(); + await act(() => i18n.changeLanguage("en")); + }); + + it("is hidden when the node has no MeshMapper record", async () => { + renderPanel(); + await screen.findByText("Self Node"); + expect(screen.queryByText("MeshMapper")).not.toBeInTheDocument(); + expect(screen.queryByText(/From MeshMapper/)).not.toBeInTheDocument(); + }); + + it.each([ + ["nothing set", empty], + ["only a CARpeater flag", { ...empty, carpeater: true }], + ["only a sea-level height", { ...empty, site: { heightAslM: 341.6 } }], + ])("is hidden when the record has %s", async (_, mm) => { + mockGetNode.mockResolvedValue({ ...node, meshmapper: mm }); + renderPanel(); + await screen.findByText("Self Node"); + expect(screen.queryByText(/From MeshMapper/)).not.toBeInTheDocument(); + }); + + it("shows an unknown default scope and an answered-but-empty scope list", async () => { + mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...empty, scopesCheckedAt: 1, defaultScope: { state: "unknown" } } }); + renderPanel(); + expect(await screen.findByText("Unknown")).toBeInTheDocument(); + expect(screen.getByText("None confirmed")).toBeInTheDocument(); + }); + + it("shows a default scope of none", async () => { + mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...empty, defaultScope: { state: "none" } } }); + renderPanel(); + expect(await screen.findByText("None")).toBeInTheDocument(); + }); + + it("badges a CARpeater next to the name, reachable by keyboard", async () => { + mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...meshmapper, carpeater: true } }); + renderPanel(); + const badge = await screen.findByText("CARpeater"); + expect(badge.className).not.toContain("uppercase"); + const trigger = badge.parentElement!; + expect(trigger).toHaveAttribute("tabindex", "0"); + expect(trigger).toHaveAccessibleDescription("Mobile repeater in a vehicle: its position moves."); + }); + + it("renders admin-entered markup as plain text", async () => { + mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...meshmapper, site: { notes: "bold" } } }); + const { container } = renderPanel(); + expect(await screen.findByText("bold")).toBeInTheDocument(); + expect(container.querySelector("b")).toBeNull(); + }); +}); diff --git a/tests/features/stats/TalkersTab.test.tsx b/tests/features/stats/TalkersTab.test.tsx index de7f887..5880b07 100644 --- a/tests/features/stats/TalkersTab.test.tsx +++ b/tests/features/stats/TalkersTab.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, expect, it, onTestFinished, vi } from "vitest"; -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { TalkersTab } from "../../../src/features/stats/TalkersTab"; import { getTopAdvertisers, getTopTalkers } from "../../../src/api/client"; @@ -59,7 +59,8 @@ it.each(["region", "range"] as const)("hides previous results while the new %s i await screen.findByText("New sender"); const request = vi.mocked(getTopAdvertisers).mock.calls[1]!; expect(request[0]).toEqual(filter === "region" ? ["YOW"] : ["YVR"]); - expect(request[2]).toBe(20); + expect(request[2]).toBe("flood"); + expect(request[3]).toBe(20); const windowMs = (filter === "range" ? 7 : 1) * 86_400_000; expect(request[1]).toBeGreaterThanOrEqual(requestedAt - windowMs); expect(request[1]).toBeLessThanOrEqual(Date.now() - windowMs); @@ -73,7 +74,7 @@ it("shows an advertiser refetch error instead of cached rows, keeps the sender p await screen.findByText("Failed to load"); expect(screen.queryByText("Old advertiser")).not.toBeInTheDocument(); expect(screen.getByText("Old sender")).toBeInTheDocument(); - expect(client.getQueryData(["stats-top-advertisers", "YVR", "24h", 20])).toEqual(advertisers); + expect(client.getQueryData(["stats-top-advertisers", "YVR", "24h", "flood", 20])).toEqual(advertisers); vi.mocked(getTopAdvertisers).mockResolvedValue([{ ...advertisers[0]!, nodeName: "Recovered advertiser" }]); await act(() => client.refetchQueries({ queryKey: ["stats-top-advertisers"] })); await screen.findByText("Recovered advertiser"); @@ -103,7 +104,7 @@ it("retains valid rows and rates during an ordinary background refresh", async ( vi.mocked(getTopAdvertisers).mockReturnValue(next.promise); let refresh!: Promise; await act(async () => { refresh = client.refetchQueries({ queryKey: ["stats-top-advertisers"] }); }); - expect(client.getQueryState(["stats-top-advertisers", "YVR", "24h", 20])?.fetchStatus).toBe("fetching"); + expect(client.getQueryState(["stats-top-advertisers", "YVR", "24h", "flood", 20])?.fetchStatus).toBe("fetching"); expect(screen.getByText("Old advertiser")).toBeInTheDocument(); expect(screen.getByText("10/d")).toBeInTheDocument(); expect(screen.getByText("4/d")).toBeInTheDocument(); @@ -158,3 +159,16 @@ it("divides advert rates by the hours the rollup actually covers", async () => { expect(await screen.findByText("21/d")).toBeInTheDocument(); expect(screen.getByText("4.2/d")).toBeInTheDocument(); }); + +it("asks the server for flood-ranked advertisers by default and refetches direct-ranked from the toggle", async () => { + mount(); + await screen.findByText("Old advertiser"); + expect(vi.mocked(getTopAdvertisers).mock.calls[0]!.slice(2)).toEqual(["flood", 20]); + const sort = screen.getByRole("group", { name: "Sort advertisers by" }); + expect(within(sort).getByRole("button", { name: "Flood" })).toHaveAttribute("aria-pressed", "true"); + vi.mocked(getTopAdvertisers).mockResolvedValue([{ ...advertisers[0]!, nodeName: "Direct advertiser" }]); + fireEvent.click(within(sort).getByRole("button", { name: "Direct" })); + await screen.findByText("Direct advertiser"); + expect(vi.mocked(getTopAdvertisers).mock.calls[1]!.slice(2)).toEqual(["direct", 20]); + expect(within(sort).getByRole("button", { name: "Direct" })).toHaveAttribute("aria-pressed", "true"); +}); diff --git a/tests/features/stats/chart-theme.test.ts b/tests/features/stats/chart-theme.test.ts new file mode 100644 index 0000000..1784deb --- /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); + } + }); +}); diff --git a/tests/features/stats/region-gate.test.tsx b/tests/features/stats/region-gate.test.tsx index 4956933..d8f2ede 100644 --- a/tests/features/stats/region-gate.test.tsx +++ b/tests/features/stats/region-gate.test.tsx @@ -22,7 +22,7 @@ const hooks = [ ["usePayloadBreakdown", () => usePayloadBreakdown("24h"), client.getPayloadBreakdown], ["useTopNodes", () => useTopNodes("24h"), client.getTopNodes], ["useTopObservers", () => useTopObservers("24h"), client.getTopObservers], - ["useTopAdvertisers", () => useTopAdvertisers("24h"), client.getTopAdvertisers], + ["useTopAdvertisers", () => useTopAdvertisers("24h", "flood"), client.getTopAdvertisers], ["useTopTalkers", () => useTopTalkers("24h"), client.getTopTalkers], ["useRadioPresets", () => useRadioPresets(), client.getRadioPresets], ["useNodeTypes", () => useNodeTypes(), client.getStatsNodeTypes],