mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-10-31 03:51:58 +01:00
431 lines
15 KiB
JavaScript
431 lines
15 KiB
JavaScript
/*
|
|
* Copyright © 2025-26 l5yth & contributors
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
import { isMeshcoreProtocol, isReticulumProtocol } from './protocol-helpers.js';
|
|
|
|
/**
|
|
* Mapping of numeric Meshtastic role identifiers to their canonical names.
|
|
*
|
|
* The mapping mirrors ``Config.DeviceConfig.Role`` enumeration values from
|
|
* Meshtastic firmware and CLI tooling to ensure consistent colour and legend
|
|
* lookups throughout the dashboard.
|
|
*
|
|
* @type {Readonly<Record<number, string>>}
|
|
*/
|
|
export const roleIdToName = Object.freeze({
|
|
0: 'CLIENT',
|
|
1: 'CLIENT_MUTE',
|
|
2: 'ROUTER',
|
|
3: 'ROUTER_CLIENT',
|
|
4: 'REPEATER',
|
|
5: 'TRACKER',
|
|
6: 'SENSOR',
|
|
7: 'TAK',
|
|
8: 'CLIENT_HIDDEN',
|
|
9: 'LOST_AND_FOUND',
|
|
10: 'TAK_TRACKER',
|
|
11: 'ROUTER_LATE',
|
|
12: 'CLIENT_BASE',
|
|
});
|
|
|
|
/**
|
|
* Meshtastic role colour palette — broad-spectrum blue-green-yellow-red gradient
|
|
* with a distinctive lavender accent for {@link LOST_AND_FOUND}. Adopted from
|
|
* the meshenvy fork to keep visual consistency across instances.
|
|
*
|
|
* The cool-blue low end is differentiated from the MeshCore steel-grey palette
|
|
* by saturation (51 %+ here vs 18 % for MeshCore) and an 8-degree hue offset.
|
|
*
|
|
* Firmware 2.7.10 / Android 2.7.0 roles (see issue #177).
|
|
*
|
|
* @type {Readonly<Record<string, string>>}
|
|
*/
|
|
export const roleColors = Object.freeze({
|
|
CLIENT_HIDDEN: '#A9CBE8',
|
|
SENSOR: '#A8D5BA',
|
|
TRACKER: '#99e67f',
|
|
CLIENT_MUTE: '#bcef75',
|
|
CLIENT: '#f3ef74',
|
|
CLIENT_BASE: '#fdbf79',
|
|
REPEATER: '#fa997b',
|
|
ROUTER_LATE: '#ff5061',
|
|
ROUTER: '#ff0019',
|
|
LOST_AND_FOUND: '#C3A8E8',
|
|
});
|
|
|
|
/**
|
|
* MeshCore role colour palette — cool grey-blue gradient used to distinguish
|
|
* MeshCore nodes from Meshtastic nodes in future protocol-aware views.
|
|
*
|
|
* These colours are defined now for completeness but are not yet applied to
|
|
* live UI surfaces — see the protocol-aware legend work that will follow once
|
|
* MeshCore ingest is implemented.
|
|
*
|
|
* @type {Readonly<Record<string, string>>}
|
|
*/
|
|
export const meshcoreRoleColors = Object.freeze({
|
|
REPEATER: '#B8C4D4',
|
|
ROOM_SERVER: '#7A9EBC',
|
|
SENSOR: '#40749E',
|
|
COMPANION: '#164A88',
|
|
});
|
|
|
|
/**
|
|
* Reticulum role colour palette — a four-step violet ramp, light to dark,
|
|
* tying the role badges to the protocol's `#7b61ff` tile (SPEC RD5).
|
|
*
|
|
* Measured against {@link getContrastTextColor}, every step clears the UX2
|
|
* 4.5:1 badge floor: PEER 10.86, NODE 6.71, TRANSPORT 5.31, PROPAGATION 8.61.
|
|
* The tile's own `#7b61ff` measures **4.39:1** and would fail, which is why
|
|
* `TRANSPORT` is one step lighter than the tile rather than equal to it.
|
|
*
|
|
* `TRANSPORT` is populated for the **ingestor's own host only** (SPEC RE8), and
|
|
* only when its stack reports `transport_enabled`. No announce exposes
|
|
* transport status, so it stays absent for every remote peer: deriving it from
|
|
* our own path table would make it a property of this ingestor's vantage point
|
|
* rather than of the node, which is the CONTRACTS sender-side determinism rule
|
|
* (SPEC RD4).
|
|
*
|
|
* @type {Readonly<Record<string, string>>}
|
|
*/
|
|
export const reticulumRoleColors = Object.freeze({
|
|
PEER: '#cabffa',
|
|
NODE: '#a08bff',
|
|
TRANSPORT: '#8b74ff',
|
|
PROPAGATION: '#4a32b8',
|
|
});
|
|
|
|
/** Dark text candidate for light badge backgrounds (WCAG pick, SPEC UX2). */
|
|
const BADGE_TEXT_DARK = '#111418';
|
|
|
|
/** Light text candidate for dark badge backgrounds (WCAG pick, SPEC UX2). */
|
|
const BADGE_TEXT_LIGHT = '#ffffff';
|
|
|
|
/**
|
|
* Convert one sRGB channel (0-255) to its linear-light value (WCAG 2.x).
|
|
*
|
|
* @param {number} channel Channel value in the 0-255 range.
|
|
* @returns {number} Linearised channel.
|
|
*/
|
|
function linearChannel(channel) {
|
|
const c = channel / 255;
|
|
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
}
|
|
|
|
/**
|
|
* Compute WCAG relative luminance for a ``#rgb``/``#rrggbb`` colour.
|
|
*
|
|
* @param {string} hex CSS hex colour.
|
|
* @returns {number} Relative luminance in [0, 1].
|
|
*/
|
|
function relativeLuminance(hex) {
|
|
let h = String(hex).replace('#', '');
|
|
if (h.length === 3) h = [...h].map(ch => ch + ch).join('');
|
|
const r = parseInt(h.slice(0, 2), 16);
|
|
const g = parseInt(h.slice(2, 4), 16);
|
|
const b = parseInt(h.slice(4, 6), 16);
|
|
return 0.2126 * linearChannel(r) + 0.7152 * linearChannel(g) + 0.0722 * linearChannel(b);
|
|
}
|
|
|
|
/**
|
|
* Compute the WCAG contrast ratio between two hex colours.
|
|
*
|
|
* @param {string} a First colour.
|
|
* @param {string} b Second colour.
|
|
* @returns {number} Contrast ratio (>= 1).
|
|
*/
|
|
function contrastRatio(a, b) {
|
|
const la = relativeLuminance(a);
|
|
const lb = relativeLuminance(b);
|
|
const [hi, lo] = la >= lb ? [la, lb] : [lb, la];
|
|
return (hi + 0.05) / (lo + 0.05);
|
|
}
|
|
|
|
/**
|
|
* Pick the badge text colour with the higher WCAG contrast against a
|
|
* background — near-black on light fills, white on dark fills (SPEC UX2;
|
|
* replaces the one-role ``meshcoreRoleTextColors`` special case, audit D-008).
|
|
*
|
|
* @param {string} backgroundHex Badge background colour.
|
|
* @returns {string} CSS colour string for the badge text.
|
|
*/
|
|
export function getContrastTextColor(backgroundHex) {
|
|
return contrastRatio(BADGE_TEXT_DARK, backgroundHex) >=
|
|
contrastRatio(BADGE_TEXT_LIGHT, backgroundHex)
|
|
? BADGE_TEXT_DARK
|
|
: BADGE_TEXT_LIGHT;
|
|
}
|
|
|
|
/**
|
|
* Return the foreground text colour for a role badge.
|
|
*
|
|
* Computed from the badge background's luminance so every role of every
|
|
* protocol palette clears the 4.5:1 AA floor (SPEC UX2).
|
|
*
|
|
* @param {*} role Raw role value from the API.
|
|
* @param {string|null|undefined} [protocol] Protocol string from the API.
|
|
* @returns {string} CSS colour string for the badge text.
|
|
*/
|
|
export function getRoleTextColor(role, protocol = null) {
|
|
return getContrastTextColor(getRoleColor(role, protocol));
|
|
}
|
|
|
|
/**
|
|
* Return the role colour palette appropriate for the given protocol.
|
|
*
|
|
* Defaults to {@link roleColors} (Meshtastic) for absent or unrecognised
|
|
* protocol values so existing callers are unaffected when MeshCore ingest
|
|
* is not yet active. Reticulum resolves to {@link reticulumRoleColors} (SPEC
|
|
* RD5); before that ramp existed, Reticulum nodes inherited this default.
|
|
*
|
|
* @param {string|null|undefined} protocol Protocol string from the API.
|
|
* @returns {Readonly<Record<string, string>>} Role colour map.
|
|
*/
|
|
export function getRoleColors(protocol) {
|
|
if (isMeshcoreProtocol(protocol)) return meshcoreRoleColors;
|
|
if (isReticulumProtocol(protocol)) return reticulumRoleColors;
|
|
return roleColors;
|
|
}
|
|
|
|
/**
|
|
* Meshtastic-specific render priority order for map marker stacking.
|
|
* Higher numbers render above lower ones (LOST_AND_FOUND on top).
|
|
*
|
|
* @type {Readonly<Record<string, number>>}
|
|
*/
|
|
export const meshtasticRoleRenderOrder = Object.freeze({
|
|
CLIENT_HIDDEN: 1,
|
|
SENSOR: 2,
|
|
TRACKER: 4,
|
|
CLIENT_MUTE: 5,
|
|
CLIENT: 6,
|
|
CLIENT_BASE: 8,
|
|
ROUTER_LATE: 10,
|
|
REPEATER: 11,
|
|
ROUTER: 13,
|
|
LOST_AND_FOUND: 14,
|
|
});
|
|
|
|
/**
|
|
* MeshCore-specific render priority overrides. Bottom-up stacking order:
|
|
* REPEATER → ROOM_SERVER → SENSOR → COMPANION (top), so companion nodes
|
|
* are always visible above infrastructure roles.
|
|
*
|
|
* @type {Readonly<Record<string, number>>}
|
|
*/
|
|
export const meshcoreRoleRenderOrder = Object.freeze({
|
|
REPEATER: 3,
|
|
ROOM_SERVER: 7,
|
|
SENSOR: 9,
|
|
COMPANION: 12,
|
|
});
|
|
|
|
/**
|
|
* Reticulum marker stacking order (SPEC RD5/RD6).
|
|
*
|
|
* Interleaved with the Meshtastic scale like MeshCore's, so a mixed map stacks
|
|
* by significance rather than by protocol: the more infrastructural a node is,
|
|
* the higher it draws. Mirrors the role ramp's own ordering
|
|
* (`PEER < NODE < TRANSPORT < PROPAGATION`).
|
|
*
|
|
* Reticulum announces carry no position, so only an ingestor's own host,
|
|
* positioned from its RNS config (SPEC RP7), reaches this table. Without it
|
|
* every RNS role would fall through to `0` and draw beneath every Meshtastic
|
|
* node — `CLIENT_HIDDEN` included.
|
|
*
|
|
* @type {Readonly<Record<string, number>>}
|
|
*/
|
|
export const reticulumRoleRenderOrder = Object.freeze({
|
|
PEER: 6,
|
|
NODE: 9,
|
|
TRANSPORT: 11,
|
|
PROPAGATION: 12,
|
|
});
|
|
|
|
/**
|
|
* Backward-compatible alias kept for any code that still imports
|
|
* ``roleRenderOrder`` by name.
|
|
*
|
|
* @deprecated Use {@link meshtasticRoleRenderOrder} directly.
|
|
* @type {Readonly<Record<string, number>>}
|
|
*/
|
|
export const roleRenderOrder = meshtasticRoleRenderOrder;
|
|
|
|
/**
|
|
* Translate numeric identifiers or numeric strings into canonical role names.
|
|
*
|
|
* @param {*} role Raw role value, potentially numeric.
|
|
* @returns {*} Canonical role name when recognised, otherwise the original value.
|
|
*/
|
|
export function translateRoleId(role) {
|
|
const trimmed = typeof role === 'string' ? role.trim() : null;
|
|
const numericCandidate = typeof role === 'number' ? role : (trimmed && trimmed !== '' ? Number(trimmed) : null);
|
|
if (Number.isInteger(numericCandidate) && roleIdToName[numericCandidate] !== undefined) {
|
|
return roleIdToName[numericCandidate];
|
|
}
|
|
return role;
|
|
}
|
|
|
|
export const DEFAULT_ROLES = Object.freeze({
|
|
meshtastic: 'CLIENT',
|
|
meshcore: 'COMPANION',
|
|
reticulum: 'PEER',
|
|
});
|
|
|
|
/**
|
|
* The role a node of *protocol* falls back to when it reports none.
|
|
*
|
|
* `CLIENT` is a **Meshtastic** role, so using it everywhere labelled a Meshcore
|
|
* node as something Meshcore has no concept of, and a Reticulum node likewise.
|
|
* Each protocol falls back to its own base role instead: the plain participant
|
|
* that carries no additional function.
|
|
*
|
|
* @param {string|null|undefined} protocol Protocol string from the API.
|
|
* @returns {string} Default role identifier for that protocol.
|
|
*/
|
|
export function defaultRoleFor(protocol) {
|
|
if (isMeshcoreProtocol(protocol)) return DEFAULT_ROLES.meshcore;
|
|
if (isReticulumProtocol(protocol)) return DEFAULT_ROLES.reticulum;
|
|
return DEFAULT_ROLES.meshtastic;
|
|
}
|
|
|
|
/**
|
|
* Normalise role strings so lookups remain consistent.
|
|
*
|
|
* @param {*} role Raw role value from the API.
|
|
* @param {string|null|undefined} [protocol] Protocol whose base role applies
|
|
* when the value is absent; omitted keeps the Meshtastic default.
|
|
* @returns {string} Uppercase role identifier with a protocol-aware fallback.
|
|
*/
|
|
export function normalizeRole(role, protocol = null) {
|
|
const fallback = defaultRoleFor(protocol);
|
|
const translated = translateRoleId(role);
|
|
if (translated == null) return fallback;
|
|
const str = String(translated).trim();
|
|
return str.length ? str : fallback;
|
|
}
|
|
|
|
/**
|
|
* Resolve the canonical role key used for colour lookup tables.
|
|
*
|
|
* Only the palette's own keys count: a role named after an `Object.prototype`
|
|
* member (`constructor`, `toString`) is not a palette key (SPEC RA9).
|
|
*
|
|
* @param {*} role Raw role value from the API.
|
|
* @param {string|null|undefined} [protocol] Protocol whose base role applies
|
|
* when the value is absent.
|
|
* @returns {string} Canonical role identifier.
|
|
*/
|
|
export function getRoleKey(role, protocol = null) {
|
|
const normalized = normalizeRole(role, protocol);
|
|
if (Object.hasOwn(roleColors, normalized)) return normalized;
|
|
const upper = normalized.toUpperCase();
|
|
if (Object.hasOwn(roleColors, upper)) return upper;
|
|
return normalized;
|
|
}
|
|
|
|
/**
|
|
* Determine the colour assigned to a role for legend badges.
|
|
*
|
|
* Pass the node's ``protocol`` field to select the correct palette: MeshCore
|
|
* roles are looked up in {@link meshcoreRoleColors}, Reticulum roles in
|
|
* {@link reticulumRoleColors}; everything else falls
|
|
* back to the Meshtastic {@link roleColors} palette.
|
|
*
|
|
* @param {*} role Raw role value.
|
|
* @param {string|null|undefined} [protocol] Protocol string from the API.
|
|
* @returns {string} CSS colour string.
|
|
*/
|
|
export function getRoleColor(role, protocol = null) {
|
|
const colors = getRoleColors(protocol);
|
|
const key = getRoleKey(role, protocol);
|
|
// Only the palette's own keys are colours: a bare `colors[key]` also finds
|
|
// inherited `Object.prototype` members (a function for `constructor`).
|
|
const own = Object.hasOwn(colors, key) ? colors[key] : null;
|
|
// Fall back within the node's own palette before the Meshtastic one, so a
|
|
// role-less Meshcore or Reticulum node takes its protocol's base colour
|
|
// rather than Meshtastic blue.
|
|
return own || colors[defaultRoleFor(protocol)] || roleColors.CLIENT || '#3388ff';
|
|
}
|
|
|
|
/**
|
|
* Determine the render priority that decides marker stacking order.
|
|
*
|
|
* MeshCore nodes use {@link meshcoreRoleRenderOrder} for roles that differ
|
|
* from Meshtastic; everything else falls back to
|
|
* {@link meshtasticRoleRenderOrder}. Reticulum resolves through
|
|
* {@link reticulumRoleRenderOrder} on the same fall-through rule.
|
|
*
|
|
* @param {*} role Raw role value.
|
|
* @param {string|null|undefined} [protocol] Protocol string from the API.
|
|
* @returns {number} Higher numbers render above lower ones.
|
|
*/
|
|
export function getRoleRenderPriority(role, protocol = null) {
|
|
const key = getRoleKey(role);
|
|
if (isMeshcoreProtocol(protocol)) {
|
|
const mc = meshcoreRoleRenderOrder[key];
|
|
if (typeof mc === 'number') return mc;
|
|
}
|
|
if (isReticulumProtocol(protocol)) {
|
|
const rt = reticulumRoleRenderOrder[key];
|
|
if (typeof rt === 'number') return rt;
|
|
}
|
|
const priority = meshtasticRoleRenderOrder[key];
|
|
return typeof priority === 'number' ? priority : 0;
|
|
}
|
|
|
|
|
|
/**
|
|
* Convert a ``#rrggbb`` (or ``#rgb``) hex colour to an ``rgba(r, g, b, a)``
|
|
* string. Used by {@link getRoleFlashColor} to express a role colour at a fixed
|
|
* alpha for the live-update fade keyframe (LV3).
|
|
*
|
|
* @param {*} hex Hex colour such as ``#f3ef74`` or ``#abc``.
|
|
* @param {number} alpha Alpha channel in the inclusive range 0..1.
|
|
* @returns {?string} An ``rgba(...)`` string, or ``null`` when ``hex`` is not a
|
|
* parseable 3/6-digit hex colour.
|
|
*/
|
|
export function hexToRgba(hex, alpha) {
|
|
if (typeof hex !== 'string') return null;
|
|
let h = hex.trim();
|
|
if (h[0] === '#') h = h.slice(1);
|
|
if (h.length === 3) {
|
|
h = h.split('').map(c => c + c).join('');
|
|
}
|
|
if (!/^[0-9a-fA-F]{6}$/.test(h)) return null;
|
|
const r = parseInt(h.slice(0, 2), 16);
|
|
const g = parseInt(h.slice(2, 4), 16);
|
|
const b = parseInt(h.slice(4, 6), 16);
|
|
const a = typeof alpha === 'number' && alpha >= 0 && alpha <= 1 ? alpha : 1;
|
|
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
|
}
|
|
|
|
/**
|
|
* Role colour as a translucent ``rgba()`` string for the live-update highlight
|
|
* fade (SPEC LV1/LV3). Stamped onto a flashable element as the
|
|
* ``--flash-role-color`` custom property so the CSS keyframe can fade white
|
|
* through the element's role colour without any per-element JS.
|
|
*
|
|
* @param {*} role Raw role value.
|
|
* @param {string|null|undefined} [protocol] Protocol string from the API.
|
|
* @param {number} [alpha=0.55] Alpha for the mid-fade role-colour stop.
|
|
* @returns {string} The role colour as an ``rgba(...)`` string. {@link getRoleColor}
|
|
* always resolves to a parseable hex, so a real ``rgba(...)`` is always returned.
|
|
*/
|
|
export function getRoleFlashColor(role, protocol = null, alpha = 0.55) {
|
|
return hexToRgba(getRoleColor(role, protocol), alpha);
|
|
}
|