Files
potato-mesh/web/public/assets/js/app/role-helpers.js
T

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);
}