mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-09 10:22:52 +02:00
web: distinguish meshcore from meshtastic in frontend (#688)
* web: distinguish meshcore from meshtastic in frontend * fix mark_packet_seen bug * web: distinguish meshcore from meshtastic in frontend * address review comments * address review comments * address review comments
This commit is contained in:
@@ -34,6 +34,7 @@ from __future__ import annotations
|
||||
|
||||
from .. import queue as _queue
|
||||
from ._state import (
|
||||
_mark_packet_seen,
|
||||
host_node_id,
|
||||
last_packet_monotonic,
|
||||
register_host_node_id,
|
||||
@@ -85,6 +86,7 @@ __all__ = [
|
||||
"_radio_metadata_fields",
|
||||
"_record_ignored_packet",
|
||||
"base64_payload",
|
||||
"_mark_packet_seen",
|
||||
"host_node_id",
|
||||
"last_packet_monotonic",
|
||||
"on_receive",
|
||||
|
||||
@@ -219,6 +219,7 @@ def _contact_to_node_dict(contact: dict) -> dict:
|
||||
role = _meshcore_adv_type_to_role(contact.get("type"))
|
||||
node: dict = {
|
||||
"lastHeard": contact.get("last_advert"),
|
||||
"protocol": "meshcore",
|
||||
"user": {
|
||||
"longName": name,
|
||||
"shortName": _meshcore_short_name(pub_key),
|
||||
@@ -249,6 +250,7 @@ def _self_info_to_node_dict(self_info: dict) -> dict:
|
||||
role = _meshcore_adv_type_to_role(self_info.get("adv_type"))
|
||||
node: dict = {
|
||||
"lastHeard": int(time.time()),
|
||||
"protocol": "meshcore",
|
||||
"user": {
|
||||
"longName": name,
|
||||
"shortName": _meshcore_short_name(pub_key),
|
||||
|
||||
@@ -100,6 +100,13 @@ class TestLastPacketMonotonic:
|
||||
assert ts is not None
|
||||
assert isinstance(ts, float)
|
||||
|
||||
def test_mark_packet_seen_exported_from_handlers(self):
|
||||
"""handlers._mark_packet_seen must be accessible via the package."""
|
||||
assert callable(handlers._mark_packet_seen)
|
||||
handlers._mark_packet_seen()
|
||||
ts = handlers.last_packet_monotonic()
|
||||
assert ts is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _state: _host_telemetry_suppressed
|
||||
|
||||
@@ -668,6 +668,12 @@ def test_contact_to_node_dict_omits_position_at_origin():
|
||||
assert "position" not in node
|
||||
|
||||
|
||||
def test_contact_to_node_dict_sets_protocol_meshcore():
|
||||
"""_contact_to_node_dict must set protocol='meshcore' on every node dict."""
|
||||
contact = {"public_key": "aa" * 32, "adv_name": "Node"}
|
||||
assert _contact_to_node_dict(contact)["protocol"] == "meshcore"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _self_info_to_node_dict
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -709,6 +715,12 @@ def test_self_info_to_node_dict_includes_position():
|
||||
assert node["position"]["longitude"] == pytest.approx(2.35)
|
||||
|
||||
|
||||
def test_self_info_to_node_dict_sets_protocol_meshcore():
|
||||
"""_self_info_to_node_dict must set protocol='meshcore' on the node dict."""
|
||||
self_info = {"name": "MyNode", "public_key": "bb" * 32}
|
||||
assert _self_info_to_node_dict(self_info)["protocol"] == "meshcore"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _MeshcoreInterface contact management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
/*
|
||||
* 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 test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createDomEnvironment } from './dom-environment.js';
|
||||
import { initializeApp } from '../main.js';
|
||||
|
||||
const MINIMAL_CONFIG = Object.freeze({
|
||||
channel: 'Primary',
|
||||
frequency: '915MHz',
|
||||
refreshMs: 0,
|
||||
refreshIntervalSeconds: 30,
|
||||
chatEnabled: true,
|
||||
mapCenter: { lat: 0, lon: 0 },
|
||||
mapZoom: null,
|
||||
maxDistanceKm: 0,
|
||||
tileFilters: { light: '', dark: '' },
|
||||
instancesFeatureEnabled: false,
|
||||
instanceDomain: null,
|
||||
snapshotWindowSeconds: 3600,
|
||||
});
|
||||
|
||||
/**
|
||||
* Spin up a minimal app and return test utilities with a cleanup handle.
|
||||
*
|
||||
* @returns {{ testUtils: Object, cleanup: Function }}
|
||||
*/
|
||||
function setupApp() {
|
||||
const env = createDomEnvironment({ includeBody: true });
|
||||
env.createElement('button', 'themeToggle');
|
||||
const { _testUtils } = initializeApp(MINIMAL_CONFIG);
|
||||
return { testUtils: _testUtils, cleanup: env.cleanup.bind(env) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a test body with a fresh app instance, ensuring cleanup regardless of outcome.
|
||||
*
|
||||
* @param {function(Object): void} fn Receives the _testUtils object.
|
||||
*/
|
||||
function withApp(fn) {
|
||||
const { testUtils, cleanup } = setupApp();
|
||||
try {
|
||||
fn(testUtils);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// makeRoleFilterKey
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('makeRoleFilterKey produces compound key for meshtastic protocol', () => {
|
||||
withApp((t) => {
|
||||
assert.equal(t.makeRoleFilterKey('SENSOR', 'meshtastic'), 'meshtastic:SENSOR');
|
||||
assert.equal(t.makeRoleFilterKey('ROUTER', 'meshtastic'), 'meshtastic:ROUTER');
|
||||
});
|
||||
});
|
||||
|
||||
test('makeRoleFilterKey produces compound key for meshcore protocol', () => {
|
||||
withApp((t) => {
|
||||
assert.equal(t.makeRoleFilterKey('SENSOR', 'meshcore'), 'meshcore:SENSOR');
|
||||
assert.equal(t.makeRoleFilterKey('REPEATER', 'meshcore'), 'meshcore:REPEATER');
|
||||
});
|
||||
});
|
||||
|
||||
test('makeRoleFilterKey defaults null protocol to meshtastic bucket', () => {
|
||||
withApp((t) => {
|
||||
assert.equal(t.makeRoleFilterKey('SENSOR', null), 'meshtastic:SENSOR');
|
||||
assert.equal(t.makeRoleFilterKey('ROUTER', null), 'meshtastic:ROUTER');
|
||||
});
|
||||
});
|
||||
|
||||
test('makeRoleFilterKey defaults absent protocol to meshtastic bucket', () => {
|
||||
withApp((t) => {
|
||||
assert.equal(t.makeRoleFilterKey('CLIENT', undefined), 'meshtastic:CLIENT');
|
||||
});
|
||||
});
|
||||
|
||||
test('makeRoleFilterKey SENSOR and REPEATER produce distinct keys across protocols', () => {
|
||||
withApp((t) => {
|
||||
const meshtasticSensor = t.makeRoleFilterKey('SENSOR', 'meshtastic');
|
||||
const meshcoreSensor = t.makeRoleFilterKey('SENSOR', 'meshcore');
|
||||
assert.notEqual(meshtasticSensor, meshcoreSensor);
|
||||
|
||||
const meshtasticRepeater = t.makeRoleFilterKey('REPEATER', 'meshtastic');
|
||||
const meshcoreRepeater = t.makeRoleFilterKey('REPEATER', 'meshcore');
|
||||
assert.notEqual(meshtasticRepeater, meshcoreRepeater);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// matchesRoleFilter — no active filters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('matchesRoleFilter returns true when no filters are active', () => {
|
||||
withApp((t) => {
|
||||
t.activeRoleFilters.clear();
|
||||
assert.equal(t.matchesRoleFilter({ role: 'ROUTER', protocol: 'meshtastic' }), true);
|
||||
assert.equal(t.matchesRoleFilter({ role: 'SENSOR', protocol: 'meshcore' }), true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// matchesRoleFilter — protocol-aware compound key matching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('matchesRoleFilter matches meshtastic SENSOR filter for meshtastic node', () => {
|
||||
withApp((t) => {
|
||||
t.activeRoleFilters.clear();
|
||||
t.activeRoleFilters.add('meshtastic:SENSOR');
|
||||
assert.equal(t.matchesRoleFilter({ role: 'SENSOR', protocol: 'meshtastic' }), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('matchesRoleFilter does not match meshtastic SENSOR filter for meshcore node', () => {
|
||||
withApp((t) => {
|
||||
t.activeRoleFilters.clear();
|
||||
t.activeRoleFilters.add('meshtastic:SENSOR');
|
||||
assert.equal(t.matchesRoleFilter({ role: 'SENSOR', protocol: 'meshcore' }), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('matchesRoleFilter matches meshcore SENSOR filter for meshcore node', () => {
|
||||
withApp((t) => {
|
||||
t.activeRoleFilters.clear();
|
||||
t.activeRoleFilters.add('meshcore:SENSOR');
|
||||
assert.equal(t.matchesRoleFilter({ role: 'SENSOR', protocol: 'meshcore' }), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('matchesRoleFilter does not match meshcore SENSOR filter for meshtastic node', () => {
|
||||
withApp((t) => {
|
||||
t.activeRoleFilters.clear();
|
||||
t.activeRoleFilters.add('meshcore:SENSOR');
|
||||
assert.equal(t.matchesRoleFilter({ role: 'SENSOR', protocol: 'meshtastic' }), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('matchesRoleFilter matches meshtastic REPEATER filter for meshtastic node', () => {
|
||||
withApp((t) => {
|
||||
t.activeRoleFilters.clear();
|
||||
t.activeRoleFilters.add('meshtastic:REPEATER');
|
||||
assert.equal(t.matchesRoleFilter({ role: 'REPEATER', protocol: 'meshtastic' }), true);
|
||||
assert.equal(t.matchesRoleFilter({ role: 'REPEATER', protocol: 'meshcore' }), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('matchesRoleFilter matches meshcore REPEATER filter for meshcore node', () => {
|
||||
withApp((t) => {
|
||||
t.activeRoleFilters.clear();
|
||||
t.activeRoleFilters.add('meshcore:REPEATER');
|
||||
assert.equal(t.matchesRoleFilter({ role: 'REPEATER', protocol: 'meshcore' }), true);
|
||||
assert.equal(t.matchesRoleFilter({ role: 'REPEATER', protocol: 'meshtastic' }), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// matchesRoleFilter — null/absent protocol treated as meshtastic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('matchesRoleFilter treats null protocol as meshtastic for filter matching', () => {
|
||||
withApp((t) => {
|
||||
t.activeRoleFilters.clear();
|
||||
t.activeRoleFilters.add('meshtastic:SENSOR');
|
||||
// null-protocol node should match the meshtastic SENSOR filter
|
||||
assert.equal(t.matchesRoleFilter({ role: 'SENSOR', protocol: null }), true);
|
||||
// but not the meshcore one
|
||||
t.activeRoleFilters.clear();
|
||||
t.activeRoleFilters.add('meshcore:SENSOR');
|
||||
assert.equal(t.matchesRoleFilter({ role: 'SENSOR', protocol: null }), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('matchesRoleFilter with multiple active filters returns true when any matches', () => {
|
||||
withApp((t) => {
|
||||
t.activeRoleFilters.clear();
|
||||
t.activeRoleFilters.add('meshtastic:SENSOR');
|
||||
t.activeRoleFilters.add('meshcore:REPEATER');
|
||||
assert.equal(t.matchesRoleFilter({ role: 'SENSOR', protocol: 'meshtastic' }), true);
|
||||
assert.equal(t.matchesRoleFilter({ role: 'REPEATER', protocol: 'meshcore' }), true);
|
||||
assert.equal(t.matchesRoleFilter({ role: 'ROUTER', protocol: 'meshtastic' }), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// matchesProtocolFilter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('matchesProtocolFilter returns true when no protocols are hidden', () => {
|
||||
withApp((t) => {
|
||||
t.hiddenProtocols.clear();
|
||||
assert.equal(t.matchesProtocolFilter({ protocol: 'meshtastic' }), true);
|
||||
assert.equal(t.matchesProtocolFilter({ protocol: 'meshcore' }), true);
|
||||
assert.equal(t.matchesProtocolFilter({ protocol: null }), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('matchesProtocolFilter hides meshtastic nodes when meshtastic is hidden', () => {
|
||||
withApp((t) => {
|
||||
t.hiddenProtocols.clear();
|
||||
t.hiddenProtocols.add('meshtastic');
|
||||
assert.equal(t.matchesProtocolFilter({ protocol: 'meshtastic' }), false);
|
||||
assert.equal(t.matchesProtocolFilter({ protocol: 'meshcore' }), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('matchesProtocolFilter hides meshcore nodes when meshcore is hidden', () => {
|
||||
withApp((t) => {
|
||||
t.hiddenProtocols.clear();
|
||||
t.hiddenProtocols.add('meshcore');
|
||||
assert.equal(t.matchesProtocolFilter({ protocol: 'meshcore' }), false);
|
||||
assert.equal(t.matchesProtocolFilter({ protocol: 'meshtastic' }), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('matchesProtocolFilter always shows null-protocol nodes even when meshtastic is hidden', () => {
|
||||
withApp((t) => {
|
||||
t.hiddenProtocols.clear();
|
||||
t.hiddenProtocols.add('meshtastic');
|
||||
// null/absent protocol nodes are NOT hidden — they predate the protocol field
|
||||
assert.equal(t.matchesProtocolFilter({ protocol: null }), true);
|
||||
assert.equal(t.matchesProtocolFilter({}), true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Role filter key independence across protocols
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('SENSOR filter keys for meshtastic and meshcore are distinct strings', () => {
|
||||
withApp((t) => {
|
||||
const m = t.makeRoleFilterKey('SENSOR', 'meshtastic');
|
||||
const mc = t.makeRoleFilterKey('SENSOR', 'meshcore');
|
||||
// They must be different keys so they can live independently in the Set
|
||||
assert.notEqual(m, mc);
|
||||
// Adding one to the filter set must not affect the other
|
||||
t.activeRoleFilters.clear();
|
||||
t.activeRoleFilters.add(m);
|
||||
assert.equal(t.activeRoleFilters.has(m), true);
|
||||
assert.equal(t.activeRoleFilters.has(mc), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('REPEATER filter keys for meshtastic and meshcore are distinct strings', () => {
|
||||
withApp((t) => {
|
||||
const m = t.makeRoleFilterKey('REPEATER', 'meshtastic');
|
||||
const mc = t.makeRoleFilterKey('REPEATER', 'meshcore');
|
||||
assert.notEqual(m, mc);
|
||||
t.activeRoleFilters.clear();
|
||||
t.activeRoleFilters.add(mc);
|
||||
assert.equal(t.activeRoleFilters.has(mc), true);
|
||||
assert.equal(t.activeRoleFilters.has(m), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// normalizeFilterProtocol
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('normalizeFilterProtocol returns meshcore for explicit meshcore', () => {
|
||||
withApp((t) => {
|
||||
assert.equal(t.normalizeFilterProtocol('meshcore'), 'meshcore');
|
||||
});
|
||||
});
|
||||
|
||||
test('normalizeFilterProtocol returns meshtastic for explicit meshtastic', () => {
|
||||
withApp((t) => {
|
||||
assert.equal(t.normalizeFilterProtocol('meshtastic'), 'meshtastic');
|
||||
});
|
||||
});
|
||||
|
||||
test('normalizeFilterProtocol returns meshtastic for null', () => {
|
||||
withApp((t) => {
|
||||
assert.equal(t.normalizeFilterProtocol(null), 'meshtastic');
|
||||
});
|
||||
});
|
||||
|
||||
test('normalizeFilterProtocol returns meshtastic for undefined', () => {
|
||||
withApp((t) => {
|
||||
assert.equal(t.normalizeFilterProtocol(undefined), 'meshtastic');
|
||||
});
|
||||
});
|
||||
|
||||
test('normalizeFilterProtocol returns meshtastic for unknown protocol', () => {
|
||||
withApp((t) => {
|
||||
assert.equal(t.normalizeFilterProtocol('reticulum'), 'meshtastic');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildProtocolIconImg / buildMeshtasticIconImg / buildMeshcoreIconImg
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('buildProtocolIconImg returns an img element with the correct src and class', () => {
|
||||
withApp((t) => {
|
||||
const img = t.buildProtocolIconImg('/assets/img/test.svg', 'protocol-icon--test');
|
||||
assert.equal(img.tagName.toLowerCase(), 'img');
|
||||
assert.equal(img.getAttribute('src'), '/assets/img/test.svg');
|
||||
assert.ok(img.className.includes('protocol-icon'));
|
||||
assert.ok(img.className.includes('protocol-icon--test'));
|
||||
assert.equal(img.getAttribute('aria-hidden'), 'true');
|
||||
assert.equal(img.getAttribute('alt'), '');
|
||||
assert.equal(img.getAttribute('width'), '12');
|
||||
assert.equal(img.getAttribute('height'), '12');
|
||||
});
|
||||
});
|
||||
|
||||
test('buildMeshtasticIconImg references meshtastic.svg and carries the meshtastic class', () => {
|
||||
withApp((t) => {
|
||||
const img = t.buildMeshtasticIconImg();
|
||||
assert.ok(img.getAttribute('src').includes('meshtastic.svg'));
|
||||
assert.ok(img.className.includes('protocol-icon--meshtastic'));
|
||||
assert.equal(img.getAttribute('aria-hidden'), 'true');
|
||||
});
|
||||
});
|
||||
|
||||
test('buildMeshcoreIconImg references meshcore.svg and carries the meshcore class', () => {
|
||||
withApp((t) => {
|
||||
const img = t.buildMeshcoreIconImg();
|
||||
assert.ok(img.getAttribute('src').includes('meshcore.svg'));
|
||||
assert.ok(img.className.includes('protocol-icon--meshcore'));
|
||||
assert.equal(img.getAttribute('aria-hidden'), 'true');
|
||||
});
|
||||
});
|
||||
|
||||
test('buildMeshtasticIconImg and buildMeshcoreIconImg return different src values', () => {
|
||||
withApp((t) => {
|
||||
const mt = t.buildMeshtasticIconImg();
|
||||
const mc = t.buildMeshcoreIconImg();
|
||||
assert.notEqual(mt.getAttribute('src'), mc.getAttribute('src'));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// legendClickHandler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('legendClickHandler calls preventDefault and stopPropagation before fn', () => {
|
||||
withApp((t) => {
|
||||
let fnCalled = false;
|
||||
let preventDefaultCalled = false;
|
||||
let stopPropagationCalled = false;
|
||||
const handler = t.legendClickHandler(() => { fnCalled = true; });
|
||||
const fakeEvent = {
|
||||
preventDefault: () => { preventDefaultCalled = true; },
|
||||
stopPropagation: () => { stopPropagationCalled = true; },
|
||||
};
|
||||
handler(fakeEvent);
|
||||
assert.equal(preventDefaultCalled, true);
|
||||
assert.equal(stopPropagationCalled, true);
|
||||
assert.equal(fnCalled, true);
|
||||
});
|
||||
});
|
||||
|
||||
test('legendClickHandler passes the event to fn', () => {
|
||||
withApp((t) => {
|
||||
let received = null;
|
||||
const handler = t.legendClickHandler(ev => { received = ev; });
|
||||
const fakeEvent = {
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
detail: 'test',
|
||||
};
|
||||
handler(fakeEvent);
|
||||
assert.equal(received, fakeEvent);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildRoleButtons
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('buildRoleButtons appends one child per palette entry', () => {
|
||||
withApp((t) => {
|
||||
t.legendRoleButtons.clear();
|
||||
const col = document.createElement('div');
|
||||
t.buildRoleButtons(col, { SENSOR: '#4A7EB4', REPEATER: '#C8D0DC' }, 'meshcore');
|
||||
assert.equal(col.childNodes.length, 2);
|
||||
});
|
||||
});
|
||||
|
||||
test('buildRoleButtons sets dataset.role and dataset.protocol on each button', () => {
|
||||
withApp((t) => {
|
||||
t.legendRoleButtons.clear();
|
||||
const col = document.createElement('div');
|
||||
t.buildRoleButtons(col, { SENSOR: '#4A7EB4' }, 'meshcore');
|
||||
const btn = t.legendRoleButtons.get('meshcore:SENSOR');
|
||||
assert.ok(btn, 'button should be in legendRoleButtons');
|
||||
assert.equal(btn.dataset.role, 'SENSOR');
|
||||
assert.equal(btn.dataset.protocol, 'meshcore');
|
||||
});
|
||||
});
|
||||
|
||||
test('buildRoleButtons registers compound keys in legendRoleButtons', () => {
|
||||
withApp((t) => {
|
||||
t.legendRoleButtons.clear();
|
||||
const col = document.createElement('div');
|
||||
t.buildRoleButtons(col, { SENSOR: '#4A7EB4', REPEATER: '#C8D0DC' }, 'meshcore');
|
||||
assert.ok(t.legendRoleButtons.has('meshcore:SENSOR'));
|
||||
assert.ok(t.legendRoleButtons.has('meshcore:REPEATER'));
|
||||
});
|
||||
});
|
||||
|
||||
test('buildRoleButtons keeps meshtastic and meshcore SENSOR keys distinct', () => {
|
||||
withApp((t) => {
|
||||
t.legendRoleButtons.clear();
|
||||
const colMc = document.createElement('div');
|
||||
const colMt = document.createElement('div');
|
||||
t.buildRoleButtons(colMc, { SENSOR: '#4A7EB4' }, 'meshcore');
|
||||
t.buildRoleButtons(colMt, { SENSOR: '#B2D880' }, 'meshtastic');
|
||||
assert.ok(t.legendRoleButtons.has('meshcore:SENSOR'));
|
||||
assert.ok(t.legendRoleButtons.has('meshtastic:SENSOR'));
|
||||
assert.notEqual(
|
||||
t.legendRoleButtons.get('meshcore:SENSOR'),
|
||||
t.legendRoleButtons.get('meshtastic:SENSOR'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('buildRoleButtons sets aria-pressed to false initially', () => {
|
||||
withApp((t) => {
|
||||
t.legendRoleButtons.clear();
|
||||
const col = document.createElement('div');
|
||||
t.buildRoleButtons(col, { ROUTER: '#D44E14' }, 'meshtastic');
|
||||
const btn = t.legendRoleButtons.get('meshtastic:ROUTER');
|
||||
assert.ok(btn, 'button should be in legendRoleButtons');
|
||||
assert.equal(btn.getAttribute('aria-pressed'), 'false');
|
||||
});
|
||||
});
|
||||
|
||||
test('buildRoleButtons creates swatch child with background color', () => {
|
||||
withApp((t) => {
|
||||
t.legendRoleButtons.clear();
|
||||
const col = document.createElement('div');
|
||||
t.buildRoleButtons(col, { ROUTER: '#D44E14' }, 'meshtastic');
|
||||
const btn = t.legendRoleButtons.get('meshtastic:ROUTER');
|
||||
// swatch is the first child of the button
|
||||
const swatch = btn.childNodes[0];
|
||||
assert.ok(swatch, 'swatch element should exist');
|
||||
assert.ok(swatch.style.background, 'swatch should have background color');
|
||||
});
|
||||
});
|
||||
|
||||
test('buildRoleButtons creates label child with role text', () => {
|
||||
withApp((t) => {
|
||||
t.legendRoleButtons.clear();
|
||||
const col = document.createElement('div');
|
||||
t.buildRoleButtons(col, { ROUTER: '#D44E14' }, 'meshtastic');
|
||||
const btn = t.legendRoleButtons.get('meshtastic:ROUTER');
|
||||
// label is the second child of the button
|
||||
const label = btn.childNodes[1];
|
||||
assert.ok(label, 'label element should exist');
|
||||
assert.equal(label.textContent, 'ROUTER');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateLegendRoleFiltersUI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('updateLegendRoleFiltersUI sets aria-pressed true on active role buttons', () => {
|
||||
withApp((t) => {
|
||||
t.legendRoleButtons.clear();
|
||||
const col = document.createElement('div');
|
||||
t.buildRoleButtons(col, { SENSOR: '#4A7EB4' }, 'meshcore');
|
||||
const btn = t.legendRoleButtons.get('meshcore:SENSOR');
|
||||
t.activeRoleFilters.clear();
|
||||
t.activeRoleFilters.add('meshcore:SENSOR');
|
||||
t.updateLegendRoleFiltersUI();
|
||||
assert.equal(btn.getAttribute('aria-pressed'), 'true');
|
||||
});
|
||||
});
|
||||
|
||||
test('updateLegendRoleFiltersUI sets aria-pressed false on inactive role buttons', () => {
|
||||
withApp((t) => {
|
||||
t.legendRoleButtons.clear();
|
||||
const col = document.createElement('div');
|
||||
t.buildRoleButtons(col, { SENSOR: '#4A7EB4' }, 'meshcore');
|
||||
const btn = t.legendRoleButtons.get('meshcore:SENSOR');
|
||||
t.activeRoleFilters.clear();
|
||||
t.updateLegendRoleFiltersUI();
|
||||
assert.equal(btn.getAttribute('aria-pressed'), 'false');
|
||||
});
|
||||
});
|
||||
|
||||
test('updateLegendRoleFiltersUI updates protocol button text to Show when hidden', () => {
|
||||
withApp((t) => {
|
||||
t.legendProtocolButtons.clear();
|
||||
const fakeBtn = document.createElement('button');
|
||||
fakeBtn.setAttribute('aria-pressed', 'false');
|
||||
t.legendProtocolButtons.set('meshtastic', fakeBtn);
|
||||
t.hiddenProtocols.clear();
|
||||
t.hiddenProtocols.add('meshtastic');
|
||||
t.updateLegendRoleFiltersUI();
|
||||
assert.equal(fakeBtn.getAttribute('aria-pressed'), 'true');
|
||||
assert.ok(fakeBtn.textContent.startsWith('Show'));
|
||||
});
|
||||
});
|
||||
|
||||
test('updateLegendRoleFiltersUI updates protocol button text to Hide when visible', () => {
|
||||
withApp((t) => {
|
||||
t.legendProtocolButtons.clear();
|
||||
const fakeBtn = document.createElement('button');
|
||||
fakeBtn.setAttribute('aria-pressed', 'true');
|
||||
t.legendProtocolButtons.set('meshcore', fakeBtn);
|
||||
t.hiddenProtocols.clear();
|
||||
t.updateLegendRoleFiltersUI();
|
||||
assert.equal(fakeBtn.getAttribute('aria-pressed'), 'false');
|
||||
assert.ok(fakeBtn.textContent.startsWith('Hide'));
|
||||
});
|
||||
});
|
||||
|
||||
test('updateLegendRoleFiltersUI is safe when legendContainer is null', () => {
|
||||
withApp((t) => {
|
||||
// legendContainer starts null in tests (no map); should not throw
|
||||
assert.doesNotThrow(() => t.updateLegendRoleFiltersUI());
|
||||
});
|
||||
});
|
||||
@@ -140,17 +140,26 @@ test('normalizeOverlaySource omits protocol when value is not a string', () => {
|
||||
|
||||
// --- buildMapPopupHtml ---
|
||||
|
||||
test('buildMapPopupHtml includes meshtastic icon for null protocol', () => {
|
||||
test('buildMapPopupHtml shows no icon for null protocol', () => {
|
||||
withApp((t) => {
|
||||
const html = t.buildMapPopupHtml({ long_name: 'Alice', node_id: '!abc123', protocol: null }, 0);
|
||||
assert.ok(html.includes('meshtastic.svg'), 'popup should show meshtastic icon for null protocol');
|
||||
assert.ok(!html.includes('meshtastic.svg'), 'popup should not show meshtastic icon when protocol is null');
|
||||
assert.ok(!html.includes('meshcore.svg'), 'popup should not show meshcore icon when protocol is null');
|
||||
});
|
||||
});
|
||||
|
||||
test('buildMapPopupHtml includes meshtastic icon for absent protocol', () => {
|
||||
test('buildMapPopupHtml shows no icon when protocol is absent', () => {
|
||||
withApp((t) => {
|
||||
const html = t.buildMapPopupHtml({ long_name: 'Bob', node_id: '!abc456' }, 0);
|
||||
assert.ok(html.includes('meshtastic.svg'), 'popup should show meshtastic icon when protocol absent');
|
||||
assert.ok(!html.includes('meshtastic.svg'), 'popup should not show any icon when protocol is absent');
|
||||
assert.ok(!html.includes('meshcore.svg'), 'popup should not show any icon when protocol is absent');
|
||||
});
|
||||
});
|
||||
|
||||
test('buildMapPopupHtml shows meshtastic icon for explicit meshtastic protocol', () => {
|
||||
withApp((t) => {
|
||||
const html = t.buildMapPopupHtml({ long_name: 'Alice', node_id: '!abc123', protocol: 'meshtastic' }, 0);
|
||||
assert.ok(html.includes('meshtastic.svg'), 'popup should show meshtastic icon for explicit meshtastic protocol');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,7 +187,7 @@ test('createAnnouncementEntry prefixes meshtastic icon when protocol is meshtast
|
||||
});
|
||||
});
|
||||
|
||||
test('createAnnouncementEntry prefixes meshtastic icon when protocol is absent', () => {
|
||||
test('createAnnouncementEntry shows no icon when protocol is absent', () => {
|
||||
withApp((t) => {
|
||||
const div = t.createAnnouncementEntry({
|
||||
timestampSeconds: 1000,
|
||||
@@ -189,7 +198,8 @@ test('createAnnouncementEntry prefixes meshtastic icon when protocol is absent',
|
||||
nodeData: null,
|
||||
messageHtml: 'detected',
|
||||
});
|
||||
assert.ok(innerHtml(div).includes('meshtastic.svg'), 'announcement without protocol should show meshtastic icon');
|
||||
assert.ok(!innerHtml(div).includes('meshtastic.svg'), 'no meshtastic icon when protocol is absent');
|
||||
assert.ok(!innerHtml(div).includes('meshcore.svg'), 'no meshcore icon when protocol is absent');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -237,14 +247,15 @@ test('createMessageChatEntry prefixes meshtastic icon when node protocol is mesh
|
||||
});
|
||||
});
|
||||
|
||||
test('createMessageChatEntry prefixes meshtastic icon when node protocol is absent', () => {
|
||||
test('createMessageChatEntry shows no icon when node protocol is absent', () => {
|
||||
withApp((t) => {
|
||||
const div = t.createMessageChatEntry({
|
||||
text: 'hi',
|
||||
rx_time: 2000,
|
||||
node: { short_name: 'BOB', role: 'ROUTER' },
|
||||
});
|
||||
assert.ok(innerHtml(div).includes('meshtastic.svg'), 'chat entry without protocol should show meshtastic icon');
|
||||
assert.ok(!innerHtml(div).includes('meshtastic.svg'), 'no meshtastic icon when protocol is absent');
|
||||
assert.ok(!innerHtml(div).includes('meshcore.svg'), 'no meshcore icon when protocol is absent');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -336,6 +336,7 @@ test('merge helpers combine node, telemetry, and position data', () => {
|
||||
assert.equal(node.nodeId, '!node');
|
||||
assert.equal(node.nodeNum, 55);
|
||||
assert.equal(node.shortName, 'NODE');
|
||||
assert.equal(node.protocol, undefined); // no protocol in this fixture
|
||||
assert.equal(node.battery, 50);
|
||||
assert.equal(node.voltage, 3.8);
|
||||
assert.equal(node.lastHeard, 1_200);
|
||||
@@ -350,6 +351,31 @@ test('merge helpers combine node, telemetry, and position data', () => {
|
||||
assert.ok(node.position);
|
||||
});
|
||||
|
||||
test('mergeNodeFields propagates the protocol field', () => {
|
||||
const node = {};
|
||||
mergeNodeFields(node, { node_id: '!abc', protocol: 'meshcore' });
|
||||
assert.equal(node.protocol, 'meshcore');
|
||||
});
|
||||
|
||||
test('mergeNodeFields does not overwrite an existing protocol with absent value', () => {
|
||||
const node = { protocol: 'meshcore' };
|
||||
mergeNodeFields(node, { node_id: '!abc' });
|
||||
assert.equal(node.protocol, 'meshcore');
|
||||
});
|
||||
|
||||
test('refreshNodeInformation surfaces protocol from the node API record', async () => {
|
||||
const responses = new Map([
|
||||
['/api/nodes/!proto?limit=7', createResponse(200, [{ node_id: '!proto', short_name: 'PT', protocol: 'meshcore' }])],
|
||||
['/api/telemetry/!proto?limit=1000', createResponse(404, {})],
|
||||
['/api/positions/!proto?limit=7', createResponse(404, {})],
|
||||
['/api/neighbors/!proto?limit=1000', createResponse(404, {})],
|
||||
]);
|
||||
const fetchImpl = async url => responses.get(url) ?? createResponse(404, {});
|
||||
const node = await refreshNodeInformation('!proto', { fetchImpl });
|
||||
assert.equal(node.protocol, 'meshcore');
|
||||
assert.equal(node.rawSources.node.protocol, 'meshcore');
|
||||
});
|
||||
|
||||
test('normalizeReference extracts identifiers and tolerates malformed fallback payloads', () => {
|
||||
const originalWarn = console.warn;
|
||||
const warnings = [];
|
||||
|
||||
@@ -97,9 +97,10 @@ test('renderNodeLongNameLink returns empty string for null/empty longName', () =
|
||||
assert.equal(renderNodeLongNameLink(' ', '!abc123'), '');
|
||||
});
|
||||
|
||||
test('renderNodeLongNameLink prepends Meshtastic icon for null protocol', () => {
|
||||
test('renderNodeLongNameLink shows no icon for null protocol', () => {
|
||||
const html = renderNodeLongNameLink('Alice', '!abc123', { protocol: null });
|
||||
assert.ok(html.includes('meshtastic.svg'), 'should include meshtastic icon');
|
||||
assert.ok(!html.includes('meshtastic.svg'), 'no meshtastic icon for null protocol');
|
||||
assert.ok(!html.includes('meshcore.svg'), 'no meshcore icon for null protocol');
|
||||
assert.ok(html.includes('Alice'), 'should include the name');
|
||||
});
|
||||
|
||||
@@ -108,9 +109,10 @@ test('renderNodeLongNameLink prepends Meshtastic icon for "meshtastic" protocol'
|
||||
assert.ok(html.includes('meshtastic.svg'), 'should include meshtastic icon');
|
||||
});
|
||||
|
||||
test('renderNodeLongNameLink prepends Meshtastic icon for undefined protocol (default)', () => {
|
||||
test('renderNodeLongNameLink shows no icon for absent protocol (default)', () => {
|
||||
const html = renderNodeLongNameLink('Alice', '!abc123');
|
||||
assert.ok(html.includes('meshtastic.svg'), 'default protocol should show meshtastic icon');
|
||||
assert.ok(!html.includes('meshtastic.svg'), 'no meshtastic icon when protocol is absent');
|
||||
assert.ok(!html.includes('meshcore.svg'), 'no meshcore icon when protocol is absent');
|
||||
});
|
||||
|
||||
test('renderNodeLongNameLink does not prepend icon for "meshcore" protocol', () => {
|
||||
@@ -133,11 +135,12 @@ test('renderNodeLongNameLink renders anchor with href when identifier is present
|
||||
assert.ok(html.includes('data-node-id="!abc123"'), 'should include node id attribute');
|
||||
});
|
||||
|
||||
test('renderNodeLongNameLink renders plain text (with icon) when no identifier', () => {
|
||||
test('renderNodeLongNameLink renders plain text (no icon) when no identifier and null protocol', () => {
|
||||
const html = renderNodeLongNameLink('Alice', null, { protocol: null });
|
||||
assert.ok(!html.startsWith('<a '), 'should not be an anchor');
|
||||
assert.ok(html.includes('Alice'), 'should include the name');
|
||||
assert.ok(html.includes('meshtastic.svg'), 'should still show meshtastic icon');
|
||||
assert.ok(!html.includes('meshtastic.svg'), 'no meshtastic icon for null protocol');
|
||||
assert.ok(!html.includes('meshcore.svg'), 'no meshcore icon for null protocol');
|
||||
});
|
||||
|
||||
test('renderNodeLongNameLink escapes HTML in long name', () => {
|
||||
|
||||
@@ -360,7 +360,7 @@ test('renderSingleNodeTable renders a condensed table for the node', () => {
|
||||
10_000,
|
||||
);
|
||||
assert.equal(html.includes('<table'), true);
|
||||
assert.ok(html.includes('meshtastic.svg'), 'default protocol should show meshtastic icon in long name link');
|
||||
assert.ok(!html.includes('meshtastic.svg'), 'absent protocol should show no meshtastic icon in long name link');
|
||||
assert.match(html, /<a class="node-long-link" href="\/nodes\/!abcd" data-node-detail-link="true" data-node-id="!abcd">.*Example Node<\/a>/s);
|
||||
assert.equal(html.includes('66.0%'), true);
|
||||
assert.equal(html.includes('1.230%'), true);
|
||||
@@ -604,7 +604,7 @@ test('renderNodeDetailHtml composes the table, neighbors, and messages', () => {
|
||||
assert.equal(html.includes('Heard by'), true);
|
||||
assert.equal(html.includes('We hear'), true);
|
||||
assert.equal(html.includes('Messages'), true);
|
||||
assert.ok(html.includes('meshtastic.svg'), 'default protocol should show meshtastic icon in heading and table');
|
||||
assert.ok(!html.includes('meshtastic.svg'), 'absent protocol should show no meshtastic icon in heading and table');
|
||||
assert.match(html, /<a class="node-long-link" href="\/nodes\/!abcd" data-node-detail-link="true" data-node-id="!abcd">.*Example Node<\/a>/s);
|
||||
assert.equal(html.includes('PEER'), true);
|
||||
assert.equal(html.includes('ALLY'), true);
|
||||
@@ -665,7 +665,7 @@ test('renderSingleNodeTable shows meshtastic icon for meshtastic protocol in lon
|
||||
assert.ok(html.includes('meshtastic.svg'), 'meshtastic protocol should show icon in long name link');
|
||||
});
|
||||
|
||||
test('renderSingleNodeTable shows meshtastic icon when protocol is absent in long name link', () => {
|
||||
test('renderSingleNodeTable shows no protocol icon when protocol is absent in long name link', () => {
|
||||
const node = {
|
||||
shortName: 'A',
|
||||
longName: 'Alice',
|
||||
@@ -674,7 +674,8 @@ test('renderSingleNodeTable shows meshtastic icon when protocol is absent in lon
|
||||
rawSources: { node: { node_id: '!aa', role: 'CLIENT' } },
|
||||
};
|
||||
const html = renderSingleNodeTable(node, (short, role) => `<span data-role="${role}">${short}</span>`, 0);
|
||||
assert.ok(html.includes('meshtastic.svg'), 'absent protocol should show meshtastic icon in long name link');
|
||||
assert.ok(!html.includes('meshtastic.svg'), 'absent protocol should show no meshtastic icon in long name link');
|
||||
assert.ok(!html.includes('meshcore.svg'), 'absent protocol should show no meshcore icon in long name link');
|
||||
});
|
||||
|
||||
test('renderSingleNodeTable omits meshtastic icon for meshcore protocol in long name link', () => {
|
||||
@@ -700,12 +701,13 @@ test('renderNodeDetailHtml shows meshtastic icon in heading for meshtastic proto
|
||||
assert.ok(html.includes('meshtastic.svg'), 'meshtastic protocol should show icon in heading');
|
||||
});
|
||||
|
||||
test('renderNodeDetailHtml shows meshtastic icon in heading when protocol is absent', () => {
|
||||
test('renderNodeDetailHtml shows no protocol icon in heading when protocol is absent', () => {
|
||||
const html = renderNodeDetailHtml(
|
||||
{ shortName: 'A', longName: 'Alice', nodeId: '!aa', role: 'CLIENT' },
|
||||
{ renderShortHtml: short => `<span>${short}</span>` },
|
||||
);
|
||||
assert.ok(html.includes('meshtastic.svg'), 'absent protocol should show meshtastic icon in heading');
|
||||
assert.ok(!html.includes('meshtastic.svg'), 'absent protocol should show no meshtastic icon in heading');
|
||||
assert.ok(!html.includes('meshcore.svg'), 'absent protocol should show no meshcore icon in heading');
|
||||
});
|
||||
|
||||
test('renderNodeDetailHtml omits meshtastic icon in heading for meshcore protocol', () => {
|
||||
@@ -736,7 +738,7 @@ test('renderMessages prefixes meshtastic icon for meshtastic node protocol', ()
|
||||
assert.ok(html.includes('meshtastic.svg'), 'meshtastic node chat entry should show icon');
|
||||
});
|
||||
|
||||
test('renderMessages prefixes meshtastic icon when node protocol is absent', () => {
|
||||
test('renderMessages shows no protocol icon when node protocol is absent', () => {
|
||||
const nodeContext = {
|
||||
shortName: 'SRC',
|
||||
longName: 'Source',
|
||||
@@ -750,7 +752,8 @@ test('renderMessages prefixes meshtastic icon when node protocol is absent', ()
|
||||
(short, role) => `<span data-role="${role}">${short}</span>`,
|
||||
nodeContext,
|
||||
);
|
||||
assert.ok(html.includes('meshtastic.svg'), 'absent node protocol chat entry should show meshtastic icon');
|
||||
assert.ok(!html.includes('meshtastic.svg'), 'absent node protocol chat entry should show no meshtastic icon');
|
||||
assert.ok(!html.includes('meshcore.svg'), 'absent node protocol chat entry should show no meshcore icon');
|
||||
});
|
||||
|
||||
test('renderMessages omits meshtastic icon for meshcore node protocol', () => {
|
||||
|
||||
@@ -122,14 +122,21 @@ test('renderNodeLongNameLink renders anchor when identifier is present', () => {
|
||||
assert.ok(html.includes('Alice'), 'long name should appear');
|
||||
});
|
||||
|
||||
test('renderNodeLongNameLink renders meshtastic icon for null protocol', () => {
|
||||
test('renderNodeLongNameLink shows no icon for null protocol', () => {
|
||||
const html = renderNodeLongNameLink('Alice', '!aabbccdd', { protocol: null });
|
||||
assert.ok(html.includes('meshtastic.svg'), 'meshtastic icon should be shown for null protocol');
|
||||
assert.ok(!html.includes('meshtastic.svg'), 'no icon when protocol is null');
|
||||
assert.ok(!html.includes('meshcore.svg'), 'no icon when protocol is null');
|
||||
});
|
||||
|
||||
test('renderNodeLongNameLink renders meshtastic icon when protocol is absent', () => {
|
||||
test('renderNodeLongNameLink shows no icon when protocol is absent', () => {
|
||||
const html = renderNodeLongNameLink('Alice', '!aabbccdd');
|
||||
assert.ok(html.includes('meshtastic.svg'));
|
||||
assert.ok(!html.includes('meshtastic.svg'), 'no icon when protocol is absent');
|
||||
assert.ok(!html.includes('meshcore.svg'), 'no icon when protocol is absent');
|
||||
});
|
||||
|
||||
test('renderNodeLongNameLink renders meshtastic icon for explicit meshtastic protocol', () => {
|
||||
const html = renderNodeLongNameLink('Alice', '!aabbccdd', { protocol: 'meshtastic' });
|
||||
assert.ok(html.includes('meshtastic.svg'), 'meshtastic icon shown for explicit meshtastic protocol');
|
||||
});
|
||||
|
||||
test('renderNodeLongNameLink uses meshcore icon for meshcore protocol', () => {
|
||||
@@ -138,6 +145,21 @@ test('renderNodeLongNameLink uses meshcore icon for meshcore protocol', () => {
|
||||
assert.ok(html.includes('meshcore.svg'), 'meshcore icon should be shown');
|
||||
});
|
||||
|
||||
test('renderNodeLongNameLink renders meshcore icon for meshcore protocol', () => {
|
||||
const html = renderNodeLongNameLink('Eve', '!aabbccdd', { protocol: 'meshcore' });
|
||||
assert.ok(html.includes('meshcore.svg'), 'meshcore icon should be shown for meshcore protocol');
|
||||
});
|
||||
|
||||
test('renderNodeLongNameLink omits meshcore icon for meshtastic protocol', () => {
|
||||
const html = renderNodeLongNameLink('Alice', '!aabbccdd', { protocol: 'meshtastic' });
|
||||
assert.ok(!html.includes('meshcore.svg'), 'no meshcore icon for meshtastic protocol');
|
||||
});
|
||||
|
||||
test('renderNodeLongNameLink omits meshcore icon for null protocol', () => {
|
||||
const html = renderNodeLongNameLink('Alice', '!aabbccdd', { protocol: null });
|
||||
assert.ok(!html.includes('meshcore.svg'), 'no meshcore icon for null protocol');
|
||||
});
|
||||
|
||||
test('renderNodeLongNameLink renders plain text when identifier is null', () => {
|
||||
const html = renderNodeLongNameLink('Alice', null);
|
||||
assert.ok(!html.includes('<a'), 'should not produce anchor without identifier');
|
||||
|
||||
@@ -27,26 +27,30 @@ import {
|
||||
protocolIconPrefixHtml,
|
||||
} from '../protocol-helpers.js';
|
||||
|
||||
test('isMeshtasticProtocol — null is Meshtastic (default)', () => {
|
||||
assert.equal(isMeshtasticProtocol(null), true);
|
||||
});
|
||||
|
||||
test('isMeshtasticProtocol — undefined is Meshtastic (default)', () => {
|
||||
assert.equal(isMeshtasticProtocol(undefined), true);
|
||||
});
|
||||
|
||||
test('isMeshtasticProtocol — empty string is Meshtastic', () => {
|
||||
assert.equal(isMeshtasticProtocol(''), true);
|
||||
});
|
||||
|
||||
test('isMeshtasticProtocol — whitespace-only string is Meshtastic', () => {
|
||||
assert.equal(isMeshtasticProtocol(' '), true);
|
||||
});
|
||||
// ---------------------------------------------------------------------------
|
||||
// isMeshtasticProtocol — only matches the explicit string "meshtastic"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('isMeshtasticProtocol — "meshtastic" is Meshtastic', () => {
|
||||
assert.equal(isMeshtasticProtocol('meshtastic'), true);
|
||||
});
|
||||
|
||||
test('isMeshtasticProtocol — null is not Meshtastic (no default)', () => {
|
||||
assert.equal(isMeshtasticProtocol(null), false);
|
||||
});
|
||||
|
||||
test('isMeshtasticProtocol — undefined is not Meshtastic (no default)', () => {
|
||||
assert.equal(isMeshtasticProtocol(undefined), false);
|
||||
});
|
||||
|
||||
test('isMeshtasticProtocol — empty string is not Meshtastic', () => {
|
||||
assert.equal(isMeshtasticProtocol(''), false);
|
||||
});
|
||||
|
||||
test('isMeshtasticProtocol — whitespace-only string is not Meshtastic', () => {
|
||||
assert.equal(isMeshtasticProtocol(' '), false);
|
||||
});
|
||||
|
||||
test('isMeshtasticProtocol — "meshcore" is not Meshtastic', () => {
|
||||
assert.equal(isMeshtasticProtocol('meshcore'), false);
|
||||
});
|
||||
@@ -107,26 +111,23 @@ test('MESHCORE_ICON_SRC is referenced by meshcoreIconHtml', () => {
|
||||
// protocolIconPrefixHtml
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('protocolIconPrefixHtml — null yields meshtastic icon prefix', () => {
|
||||
const result = protocolIconPrefixHtml(null);
|
||||
assert.ok(result.includes('meshtastic.svg'), 'null should produce the meshtastic icon');
|
||||
assert.ok(result.endsWith(' '), 'prefix must end with a trailing space');
|
||||
test('protocolIconPrefixHtml — null yields empty string (no default)', () => {
|
||||
assert.equal(protocolIconPrefixHtml(null), '');
|
||||
});
|
||||
|
||||
test('protocolIconPrefixHtml — undefined yields meshtastic icon prefix', () => {
|
||||
const result = protocolIconPrefixHtml(undefined);
|
||||
assert.ok(result.includes('meshtastic.svg'), 'undefined should produce the meshtastic icon');
|
||||
test('protocolIconPrefixHtml — undefined yields empty string (no default)', () => {
|
||||
assert.equal(protocolIconPrefixHtml(undefined), '');
|
||||
});
|
||||
|
||||
test('protocolIconPrefixHtml — empty string yields meshtastic icon prefix', () => {
|
||||
const result = protocolIconPrefixHtml('');
|
||||
assert.ok(result.includes('meshtastic.svg'), 'empty string should produce the meshtastic icon');
|
||||
test('protocolIconPrefixHtml — empty string yields empty string', () => {
|
||||
assert.equal(protocolIconPrefixHtml(''), '');
|
||||
});
|
||||
|
||||
test('protocolIconPrefixHtml — "meshtastic" yields meshtastic icon prefix', () => {
|
||||
const result = protocolIconPrefixHtml('meshtastic');
|
||||
assert.ok(result.includes('meshtastic.svg'), '"meshtastic" should produce the meshtastic icon');
|
||||
assert.ok(!result.includes('meshcore.svg'), '"meshtastic" must not produce the meshcore icon');
|
||||
assert.ok(result.endsWith(' '), 'prefix must end with a trailing space');
|
||||
});
|
||||
|
||||
test('protocolIconPrefixHtml — "meshcore" yields meshcore icon prefix', () => {
|
||||
|
||||
@@ -22,7 +22,11 @@ import {
|
||||
getRoleKey,
|
||||
getRoleRenderPriority,
|
||||
getRoleColors,
|
||||
getRoleTextColor,
|
||||
meshcoreRoleColors,
|
||||
meshcoreRoleTextColors,
|
||||
meshcoreRoleRenderOrder,
|
||||
meshtasticRoleRenderOrder,
|
||||
roleColors,
|
||||
normalizeRole,
|
||||
translateRoleId,
|
||||
@@ -52,10 +56,53 @@ test('role key and color lookups prefer known values with uppercase fallback', (
|
||||
});
|
||||
|
||||
test('render priority uses canonical role keys and defaults to zero for unknowns', () => {
|
||||
// translateRoleId(2) → 'ROUTER', so both should resolve to the same priority
|
||||
assert.equal(getRoleRenderPriority('ROUTER'), getRoleRenderPriority(2));
|
||||
assert.equal(getRoleRenderPriority('custom-role'), 0);
|
||||
});
|
||||
|
||||
test('render priority is protocol-aware for shared roles', () => {
|
||||
// SENSOR: meshtastic=2, meshcore=3
|
||||
assert.equal(getRoleRenderPriority('SENSOR', 'meshtastic'), 2);
|
||||
assert.equal(getRoleRenderPriority('SENSOR', 'meshcore'), 3);
|
||||
assert.ok(getRoleRenderPriority('SENSOR', 'meshcore') > getRoleRenderPriority('SENSOR', 'meshtastic'));
|
||||
// REPEATER: meshtastic=11, meshcore=12
|
||||
assert.equal(getRoleRenderPriority('REPEATER', 'meshtastic'), 11);
|
||||
assert.equal(getRoleRenderPriority('REPEATER', 'meshcore'), 12);
|
||||
assert.ok(getRoleRenderPriority('REPEATER', 'meshcore') > getRoleRenderPriority('REPEATER', 'meshtastic'));
|
||||
});
|
||||
|
||||
test('render priority meshcore-exclusive roles have defined priorities', () => {
|
||||
assert.equal(getRoleRenderPriority('COMPANION', 'meshcore'), 7);
|
||||
assert.equal(getRoleRenderPriority('ROOM_SERVER', 'meshcore'), 9);
|
||||
});
|
||||
|
||||
test('render priority respects the full bottom-to-top order', () => {
|
||||
const order = [
|
||||
['CLIENT_HIDDEN', null],
|
||||
['SENSOR', 'meshtastic'],
|
||||
['SENSOR', 'meshcore'],
|
||||
['TRACKER', null],
|
||||
['CLIENT_MUTE', null],
|
||||
['CLIENT', null],
|
||||
['COMPANION', 'meshcore'],
|
||||
['CLIENT_BASE', null],
|
||||
['ROOM_SERVER', 'meshcore'],
|
||||
['ROUTER_LATE', null],
|
||||
['REPEATER', 'meshtastic'],
|
||||
['REPEATER', 'meshcore'],
|
||||
['ROUTER', null],
|
||||
['LOST_AND_FOUND', null],
|
||||
];
|
||||
for (let i = 1; i < order.length; i++) {
|
||||
const [roleA, protoA] = order[i - 1];
|
||||
const [roleB, protoB] = order[i];
|
||||
const pA = getRoleRenderPriority(roleA, protoA);
|
||||
const pB = getRoleRenderPriority(roleB, protoB);
|
||||
assert.ok(pA < pB, `Expected ${roleA}/${protoA} (${pA}) < ${roleB}/${protoB} (${pB})`);
|
||||
}
|
||||
});
|
||||
|
||||
test('getRoleColors returns Meshtastic palette for null/undefined/meshtastic', () => {
|
||||
assert.equal(getRoleColors(null), roleColors);
|
||||
assert.equal(getRoleColors(undefined), roleColors);
|
||||
@@ -70,3 +117,34 @@ test('getRoleColors returns MeshCore palette for meshcore protocol', () => {
|
||||
test('getRoleColors returns Meshtastic palette for unknown protocols', () => {
|
||||
assert.equal(getRoleColors('reticulum'), roleColors);
|
||||
});
|
||||
|
||||
test('getRoleColor uses meshcore palette when protocol is meshcore', () => {
|
||||
assert.equal(getRoleColor('COMPANION', 'meshcore'), meshcoreRoleColors.COMPANION);
|
||||
assert.equal(getRoleColor('REPEATER', 'meshcore'), meshcoreRoleColors.REPEATER);
|
||||
assert.equal(getRoleColor('ROOM_SERVER', 'meshcore'), meshcoreRoleColors.ROOM_SERVER);
|
||||
assert.equal(getRoleColor('SENSOR', 'meshcore'), meshcoreRoleColors.SENSOR);
|
||||
});
|
||||
|
||||
test('getRoleColor uses meshtastic palette when protocol is null', () => {
|
||||
assert.equal(getRoleColor('ROUTER', null), roleColors.ROUTER);
|
||||
assert.equal(getRoleColor('CLIENT', null), roleColors.CLIENT);
|
||||
});
|
||||
|
||||
test('getRoleColor falls back to CLIENT color for unknown meshcore role', () => {
|
||||
assert.equal(getRoleColor('UNKNOWN_ROLE', 'meshcore'), roleColors.CLIENT);
|
||||
});
|
||||
|
||||
test('getRoleTextColor returns light grey for meshcore COMPANION', () => {
|
||||
assert.equal(getRoleTextColor('COMPANION', 'meshcore'), meshcoreRoleTextColors.COMPANION);
|
||||
});
|
||||
|
||||
test('getRoleTextColor returns null for meshcore roles without override', () => {
|
||||
assert.equal(getRoleTextColor('REPEATER', 'meshcore'), null);
|
||||
assert.equal(getRoleTextColor('ROOM_SERVER', 'meshcore'), null);
|
||||
assert.equal(getRoleTextColor('SENSOR', 'meshcore'), null);
|
||||
});
|
||||
|
||||
test('getRoleTextColor returns null for meshtastic roles', () => {
|
||||
assert.equal(getRoleTextColor('CLIENT', 'meshtastic'), null);
|
||||
assert.equal(getRoleTextColor('ROUTER', null), null);
|
||||
});
|
||||
|
||||
@@ -95,9 +95,10 @@ import {
|
||||
getRoleColor,
|
||||
getRoleKey,
|
||||
getRoleRenderPriority,
|
||||
getRoleTextColor,
|
||||
meshcoreRoleColors,
|
||||
normalizeRole,
|
||||
roleColors,
|
||||
roleRenderOrder,
|
||||
} from './role-helpers.js';
|
||||
import {
|
||||
isMeshtasticProtocol,
|
||||
@@ -835,8 +836,63 @@ export function initializeApp(config) {
|
||||
|
||||
syncInfoOverlayHost();
|
||||
|
||||
/** @type {Set<string>} Active compound role-filter keys, each ``"<protocol>:<roleKey>"``. */
|
||||
const activeRoleFilters = new Set();
|
||||
/** @type {Map<string, HTMLElement>} Compound key → legend button element. */
|
||||
const legendRoleButtons = new Map();
|
||||
/** @type {Set<string>} Protocols hidden by the user via legend toggles. */
|
||||
const hiddenProtocols = new Set();
|
||||
const legendProtocolButtons = new Map();
|
||||
|
||||
/**
|
||||
* Wrap a legend button click handler so it always calls
|
||||
* ``preventDefault`` and ``stopPropagation`` before running the body.
|
||||
*
|
||||
* Centralising this prevents the two-line boilerplate from repeating in
|
||||
* every legend button handler, reducing token-level duplication.
|
||||
*
|
||||
* @param {function(Event): void} fn Handler body.
|
||||
* @returns {function(Event): void} Full click listener.
|
||||
*/
|
||||
function legendClickHandler(fn) {
|
||||
return (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
fn(event);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical protocol token for use in compound filter keys.
|
||||
*
|
||||
* Collapses null/absent/unknown protocol values to ``'meshtastic'`` so that
|
||||
* pre-protocol legacy records land in the Meshtastic filter bucket.
|
||||
*
|
||||
* @param {string|null|undefined} protocol Raw protocol value.
|
||||
* @returns {'meshtastic'|'meshcore'} Normalised protocol token.
|
||||
*/
|
||||
function normalizeFilterProtocol(protocol) {
|
||||
return isMeshcoreProtocol(protocol) ? 'meshcore' : 'meshtastic';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a compound filter key that encodes both protocol and role.
|
||||
*
|
||||
* Using compound keys avoids collisions between role names that appear in
|
||||
* both Meshtastic and MeshCore (e.g. ``SENSOR``, ``REPEATER``). The filter
|
||||
* set stores these keys so that clicking the MeshCore SENSOR button only
|
||||
* includes MeshCore SENSOR nodes, not Meshtastic ones.
|
||||
*
|
||||
* @param {*} role Raw role value from the API.
|
||||
* @param {string|null|undefined} protocol Protocol string from the API.
|
||||
* @returns {string} Compound key in the form ``"<protocol>:<roleKey>"``.
|
||||
*/
|
||||
function makeRoleFilterKey(role, protocol) {
|
||||
return `${normalizeFilterProtocol(protocol)}:${getRoleKey(role)}`;
|
||||
}
|
||||
|
||||
/** @type {Readonly<Record<string,string>>} Display names for protocol tokens. */
|
||||
const PROTOCOL_DISPLAY_NAMES = Object.freeze({ meshtastic: 'Meshtastic', meshcore: 'MeshCore' });
|
||||
|
||||
/**
|
||||
* Lazily create the floating map status element used for progress messages.
|
||||
@@ -1366,31 +1422,44 @@ export function initializeApp(config) {
|
||||
* @returns {void}
|
||||
*/
|
||||
/**
|
||||
* Build a Meshtastic protocol icon ``<img>`` element via DOM APIs.
|
||||
* Build a protocol icon ``<img>`` element via DOM APIs.
|
||||
*
|
||||
* Used wherever an icon node must be appended rather than injected via
|
||||
* ``innerHTML``. Mirrors the attribute set used by {@link meshtasticIconHtml}
|
||||
* so the rendered output is identical.
|
||||
* Shared implementation used by {@link buildMeshtasticIconImg} and
|
||||
* {@link buildMeshcoreIconImg}. Mirrors the attribute set produced by
|
||||
* the HTML-string helpers in ``protocol-helpers.js`` so the rendered
|
||||
* output is identical regardless of insertion method.
|
||||
*
|
||||
* @param {string} src Absolute path to the SVG asset.
|
||||
* @param {string} variantClass BEM modifier class, e.g. ``protocol-icon--meshtastic``.
|
||||
* @returns {HTMLImageElement} Icon element ready to append.
|
||||
*/
|
||||
function buildMeshtasticIconImg() {
|
||||
function buildProtocolIconImg(src, variantClass) {
|
||||
const img = document.createElement('img');
|
||||
img.setAttribute('src', MESHTASTIC_ICON_SRC);
|
||||
img.setAttribute('src', src);
|
||||
img.setAttribute('alt', '');
|
||||
img.setAttribute('width', '12');
|
||||
img.setAttribute('height', '12');
|
||||
img.setAttribute('aria-hidden', 'true');
|
||||
img.setAttribute('loading', 'lazy');
|
||||
img.setAttribute('decoding', 'async');
|
||||
img.className = 'protocol-icon protocol-icon--meshtastic';
|
||||
img.className = `protocol-icon ${variantClass}`;
|
||||
return img;
|
||||
}
|
||||
|
||||
/** @returns {HTMLImageElement} Meshtastic protocol icon element. */
|
||||
function buildMeshtasticIconImg() {
|
||||
return buildProtocolIconImg(MESHTASTIC_ICON_SRC, 'protocol-icon--meshtastic');
|
||||
}
|
||||
|
||||
/** @returns {HTMLImageElement} MeshCore protocol icon element. */
|
||||
function buildMeshcoreIconImg() {
|
||||
return buildProtocolIconImg(MESHCORE_ICON_SRC, 'protocol-icon--meshcore');
|
||||
}
|
||||
|
||||
function updateNeighborLinesToggleState() {
|
||||
if (!neighborLinesToggleButton) return;
|
||||
const label = neighborLinesVisible ? 'Hide neighbor lines' : 'Show neighbor lines';
|
||||
neighborLinesToggleButton.replaceChildren(buildMeshtasticIconImg(), document.createTextNode(` ${label}`));
|
||||
neighborLinesToggleButton.textContent = label;
|
||||
neighborLinesToggleButton.setAttribute('aria-pressed', neighborLinesVisible ? 'true' : 'false');
|
||||
neighborLinesToggleButton.setAttribute('aria-label', label);
|
||||
}
|
||||
@@ -1422,7 +1491,7 @@ export function initializeApp(config) {
|
||||
function updateTraceLinesToggleState() {
|
||||
if (!traceLinesToggleButton) return;
|
||||
const label = traceLinesVisible ? 'Hide trace lines' : 'Show trace lines';
|
||||
traceLinesToggleButton.replaceChildren(buildMeshtasticIconImg(), document.createTextNode(` ${label}`));
|
||||
traceLinesToggleButton.textContent = label;
|
||||
traceLinesToggleButton.setAttribute('aria-pressed', traceLinesVisible ? 'true' : 'false');
|
||||
traceLinesToggleButton.setAttribute('aria-label', label);
|
||||
}
|
||||
@@ -1453,13 +1522,21 @@ export function initializeApp(config) {
|
||||
*/
|
||||
function updateLegendRoleFiltersUI() {
|
||||
const hasFilters = activeRoleFilters.size > 0;
|
||||
legendRoleButtons.forEach((button, role) => {
|
||||
// legendRoleButtons is keyed by compound key ("protocol:roleKey")
|
||||
legendRoleButtons.forEach((button, compoundKey) => {
|
||||
if (!button) return;
|
||||
const isActive = activeRoleFilters.has(role);
|
||||
const isActive = activeRoleFilters.has(compoundKey);
|
||||
button.setAttribute('aria-pressed', isActive ? 'true' : 'false');
|
||||
});
|
||||
legendProtocolButtons.forEach((button, protocol) => {
|
||||
if (!button) return;
|
||||
const isHidden = hiddenProtocols.has(protocol);
|
||||
const displayName = PROTOCOL_DISPLAY_NAMES[protocol] ?? protocol;
|
||||
button.setAttribute('aria-pressed', isHidden ? 'true' : 'false');
|
||||
button.textContent = isHidden ? `Show ${displayName}` : `Hide ${displayName}`;
|
||||
});
|
||||
if (legendContainer) {
|
||||
if (hasFilters) {
|
||||
if (hasFilters || hiddenProtocols.size > 0) {
|
||||
legendContainer.setAttribute('data-has-active-filters', 'true');
|
||||
} else {
|
||||
legendContainer.removeAttribute('data-has-active-filters');
|
||||
@@ -1469,22 +1546,69 @@ export function initializeApp(config) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the visibility filter for a given role.
|
||||
* Toggle the visibility filter for a role+protocol combination.
|
||||
*
|
||||
* @param {string} role Role identifier.
|
||||
* @param {string} compoundKey Compound key in the form ``"<protocol>:<roleKey>"``.
|
||||
* @returns {void}
|
||||
*/
|
||||
function toggleRoleFilter(role) {
|
||||
if (!role) return;
|
||||
if (activeRoleFilters.has(role)) {
|
||||
activeRoleFilters.delete(role);
|
||||
function toggleRoleFilter(compoundKey) {
|
||||
if (!compoundKey) return;
|
||||
if (activeRoleFilters.has(compoundKey)) {
|
||||
activeRoleFilters.delete(compoundKey);
|
||||
} else {
|
||||
activeRoleFilters.add(role);
|
||||
activeRoleFilters.add(compoundKey);
|
||||
}
|
||||
updateLegendRoleFiltersUI();
|
||||
applyFilter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build role filter buttons for a given palette and append them to a column.
|
||||
*
|
||||
* Each button is keyed by a compound ``"<protocol>:<roleKey>"`` string so
|
||||
* that roles sharing a name across protocols (e.g. ``SENSOR``, ``REPEATER``)
|
||||
* produce independent buttons without colliding in {@link legendRoleButtons}.
|
||||
*
|
||||
* @param {HTMLElement} colEl Column container element.
|
||||
* @param {Record<string,string>} palette Role→colour map to render.
|
||||
* @param {'meshtastic'|'meshcore'} protocol Protocol token for this column.
|
||||
* @returns {void}
|
||||
*/
|
||||
function buildRoleButtons(colEl, palette, protocol) {
|
||||
for (const [role, color] of Object.entries(palette)) {
|
||||
if (!CHAT_ENABLED && role === 'CLIENT_HIDDEN') continue;
|
||||
const compoundKey = makeRoleFilterKey(role, protocol);
|
||||
const item = document.createElement('button');
|
||||
item.className = 'legend-item';
|
||||
colEl.appendChild(item);
|
||||
item.type = 'button';
|
||||
item.setAttribute('aria-pressed', 'false');
|
||||
item.dataset.role = role;
|
||||
item.dataset.protocol = protocol;
|
||||
const swatch = document.createElement('span');
|
||||
swatch.className = 'legend-swatch';
|
||||
item.appendChild(swatch);
|
||||
swatch.style.background = color;
|
||||
swatch.setAttribute('aria-hidden', 'true');
|
||||
const label = document.createElement('span');
|
||||
label.className = 'legend-label';
|
||||
item.appendChild(label);
|
||||
label.textContent = role;
|
||||
item.addEventListener('click', legendClickHandler(event => {
|
||||
const exclusive = event.metaKey || event.ctrlKey;
|
||||
if (exclusive) {
|
||||
activeRoleFilters.clear();
|
||||
activeRoleFilters.add(compoundKey);
|
||||
updateLegendRoleFiltersUI();
|
||||
applyFilter();
|
||||
} else {
|
||||
toggleRoleFilter(compoundKey);
|
||||
}
|
||||
}));
|
||||
legendRoleButtons.set(compoundKey, item);
|
||||
}
|
||||
}
|
||||
|
||||
if (map && hasLeaflet) {
|
||||
const legend = L.control({ position: 'bottomright' });
|
||||
/**
|
||||
@@ -1503,66 +1627,81 @@ export function initializeApp(config) {
|
||||
const title = L.DomUtil.create('span', 'legend-title', header);
|
||||
title.textContent = 'Legend';
|
||||
|
||||
const itemsContainer = L.DomUtil.create('div', 'legend-items', div);
|
||||
legendRoleButtons.clear();
|
||||
for (const [role, color] of Object.entries(roleColors)) {
|
||||
if (!CHAT_ENABLED && role === 'CLIENT_HIDDEN') continue;
|
||||
const item = L.DomUtil.create('button', 'legend-item', itemsContainer);
|
||||
item.type = 'button';
|
||||
item.setAttribute('aria-pressed', 'false');
|
||||
item.dataset.role = role;
|
||||
item.appendChild(buildMeshtasticIconImg());
|
||||
const swatch = L.DomUtil.create('span', 'legend-swatch', item);
|
||||
swatch.style.background = color;
|
||||
swatch.setAttribute('aria-hidden', 'true');
|
||||
const label = L.DomUtil.create('span', 'legend-label', item);
|
||||
label.textContent = role;
|
||||
item.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const exclusive = event.metaKey || event.ctrlKey;
|
||||
if (exclusive) {
|
||||
activeRoleFilters.clear();
|
||||
activeRoleFilters.add(role);
|
||||
updateLegendRoleFiltersUI();
|
||||
applyFilter();
|
||||
} else {
|
||||
toggleRoleFilter(role);
|
||||
}
|
||||
});
|
||||
legendRoleButtons.set(role, item);
|
||||
}
|
||||
updateLegendRoleFiltersUI();
|
||||
const itemsContainer = L.DomUtil.create('div', 'legend-items legend-items--columns', div);
|
||||
|
||||
const toggle = L.DomUtil.create('div', 'legend-toggle', div);
|
||||
neighborLinesToggleButton = L.DomUtil.create('button', 'legend-item legend-toggle-neighbors', toggle);
|
||||
// --- MeshCore column (left) ---
|
||||
const meshcoreCol = L.DomUtil.create('div', 'legend-column', itemsContainer);
|
||||
const meshcoreColHeader = L.DomUtil.create('div', 'legend-column-header', meshcoreCol);
|
||||
meshcoreColHeader.appendChild(buildMeshcoreIconImg());
|
||||
const meshcoreColTitle = document.createElement('span');
|
||||
meshcoreColTitle.textContent = 'MeshCore';
|
||||
meshcoreColHeader.appendChild(meshcoreColTitle);
|
||||
|
||||
// --- Meshtastic column (right) ---
|
||||
const meshtasticCol = L.DomUtil.create('div', 'legend-column', itemsContainer);
|
||||
const meshtasticColHeader = L.DomUtil.create('div', 'legend-column-header', meshtasticCol);
|
||||
meshtasticColHeader.appendChild(buildMeshtasticIconImg());
|
||||
const meshtasticColTitle = document.createElement('span');
|
||||
meshtasticColTitle.textContent = 'Meshtastic';
|
||||
meshtasticColHeader.appendChild(meshtasticColTitle);
|
||||
|
||||
legendRoleButtons.clear();
|
||||
buildRoleButtons(meshcoreCol, meshcoreRoleColors, 'meshcore');
|
||||
buildRoleButtons(meshtasticCol, roleColors, 'meshtastic');
|
||||
|
||||
// --- Protocol hide toggles — one per column footer ---
|
||||
legendProtocolButtons.clear();
|
||||
const protocolColDefs = [
|
||||
{ protocol: 'meshcore', col: meshcoreCol },
|
||||
{ protocol: 'meshtastic', col: meshtasticCol },
|
||||
];
|
||||
for (const { protocol, col } of protocolColDefs) {
|
||||
const displayName = PROTOCOL_DISPLAY_NAMES[protocol] ?? protocol;
|
||||
const btn = L.DomUtil.create('button', 'legend-item legend-protocol-toggle', col);
|
||||
btn.type = 'button';
|
||||
btn.setAttribute('aria-pressed', 'false');
|
||||
btn.textContent = `Hide ${displayName}`;
|
||||
btn.addEventListener('click', legendClickHandler(() => {
|
||||
if (hiddenProtocols.has(protocol)) {
|
||||
hiddenProtocols.delete(protocol);
|
||||
} else {
|
||||
hiddenProtocols.add(protocol);
|
||||
}
|
||||
updateLegendRoleFiltersUI();
|
||||
applyFilter();
|
||||
}));
|
||||
legendProtocolButtons.set(protocol, btn);
|
||||
}
|
||||
|
||||
// --- Line toggles in the Meshtastic column ---
|
||||
neighborLinesToggleButton = L.DomUtil.create('button', 'legend-item legend-toggle-neighbors', meshtasticCol);
|
||||
neighborLinesToggleButton.type = 'button';
|
||||
neighborLinesToggleButton.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
neighborLinesToggleButton.addEventListener('click', legendClickHandler(() => {
|
||||
setNeighborLinesVisibility(!neighborLinesVisible);
|
||||
});
|
||||
}));
|
||||
updateNeighborLinesToggleState();
|
||||
|
||||
traceLinesToggleButton = L.DomUtil.create('button', 'legend-item legend-toggle-traces', toggle);
|
||||
traceLinesToggleButton = L.DomUtil.create('button', 'legend-item legend-toggle-traces', meshtasticCol);
|
||||
traceLinesToggleButton.type = 'button';
|
||||
traceLinesToggleButton.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
traceLinesToggleButton.addEventListener('click', legendClickHandler(() => {
|
||||
setTraceLinesVisibility(!traceLinesVisible);
|
||||
});
|
||||
}));
|
||||
updateTraceLinesToggleState();
|
||||
|
||||
updateLegendRoleFiltersUI();
|
||||
|
||||
// --- Clear filters — full-width below the two columns ---
|
||||
const toggle = L.DomUtil.create('div', 'legend-toggle', div);
|
||||
|
||||
const resetButton = L.DomUtil.create('button', 'legend-item legend-reset', toggle);
|
||||
resetButton.type = 'button';
|
||||
resetButton.textContent = 'Clear filters';
|
||||
resetButton.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
resetButton.addEventListener('click', legendClickHandler(() => {
|
||||
activeRoleFilters.clear();
|
||||
hiddenProtocols.clear();
|
||||
updateLegendRoleFiltersUI();
|
||||
applyFilter();
|
||||
});
|
||||
}));
|
||||
|
||||
L.DomEvent.disableClickPropagation(div);
|
||||
L.DomEvent.disableScrollPropagation(div);
|
||||
@@ -1586,11 +1725,9 @@ export function initializeApp(config) {
|
||||
button.setAttribute('aria-pressed', 'true');
|
||||
button.setAttribute('aria-label', 'Hide map legend');
|
||||
button.setAttribute('aria-controls', 'mapLegend');
|
||||
button.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
button.addEventListener('click', legendClickHandler(() => {
|
||||
setLegendVisibility(!legendVisible);
|
||||
});
|
||||
}));
|
||||
legendToggleButton = button;
|
||||
updateLegendToggleState();
|
||||
L.DomEvent.disableClickPropagation(container);
|
||||
@@ -1848,8 +1985,11 @@ export function initializeApp(config) {
|
||||
return `<span class="short-name" style="background:#ccc"${titleAttr}${infoAttr}>? </span>`;
|
||||
}
|
||||
const padded = escapeHtml(String(short).padStart(4, ' ')).replace(/ /g, ' ');
|
||||
const color = getRoleColor(roleValue);
|
||||
return `<span class="short-name" style="background:${color}"${titleAttr}${infoAttr}>${padded}</span>`;
|
||||
const protocol = nodeData?.protocol ?? null;
|
||||
const color = getRoleColor(roleValue, protocol);
|
||||
const textColor = getRoleTextColor(roleValue, protocol);
|
||||
const styleAttr = textColor ? `background:${color};color:${textColor}` : `background:${color}`;
|
||||
return `<span class="short-name" style="${styleAttr}"${titleAttr}${infoAttr}>${padded}</span>`;
|
||||
}
|
||||
|
||||
const potatoMeshNamespace = globalThis.PotatoMesh || (globalThis.PotatoMesh = {});
|
||||
@@ -2266,7 +2406,7 @@ export function initializeApp(config) {
|
||||
}
|
||||
}
|
||||
const shortParts = [];
|
||||
const shortHtml = renderShortHtml(overlayInfo.shortName, overlayInfo.role, overlayInfo.longName);
|
||||
const shortHtml = renderShortHtml(overlayInfo.shortName, overlayInfo.role, overlayInfo.longName, overlayInfo);
|
||||
if (shortHtml) {
|
||||
shortParts.push(shortHtml);
|
||||
}
|
||||
@@ -3852,8 +3992,10 @@ export function initializeApp(config) {
|
||||
const loraFrequencyText = formatLoraFrequencyMHz(modemMetadata.loraFreq);
|
||||
const loraFrequencyDisplay = loraFrequencyText ? escapeHtml(loraFrequencyText) : '';
|
||||
const modemPresetDisplay = modemMetadata.modemPreset ? escapeHtml(modemMetadata.modemPreset) : '';
|
||||
const longNameHtml = renderNodeLongNameLink(n.long_name, n.node_id, { protocol: n.protocol });
|
||||
const longNameHtml = renderNodeLongNameLink(n.long_name, n.node_id);
|
||||
const protocolIconCell = protocolIconPrefixHtml(n.protocol);
|
||||
tr.innerHTML = `
|
||||
<td class="nodes-col nodes-col--protocol">${protocolIconCell}</td>
|
||||
<td class="mono nodes-col nodes-col--node-id">${n.node_id || ""}</td>
|
||||
<td class="nodes-col nodes-col--short-name">${renderShortHtml(n.short_name, n.role, n.long_name, n)}</td>
|
||||
<td class="nodes-col nodes-col--long-name">${longNameHtml}</td>
|
||||
@@ -3933,7 +4075,7 @@ export function initializeApp(config) {
|
||||
? buildTraceSegments(allTraces, nodes, {
|
||||
limitDistance: LIMIT_DISTANCE,
|
||||
maxDistanceKm: MAX_DISTANCE_KM,
|
||||
colorForNode: node => getRoleColor(node.role)
|
||||
colorForNode: node => getRoleColor(node.role, node.protocol)
|
||||
})
|
||||
: [];
|
||||
|
||||
@@ -3973,7 +4115,7 @@ export function initializeApp(config) {
|
||||
if (LIMIT_DISTANCE && sourceNode.distance_km != null && sourceNode.distance_km > MAX_DISTANCE_KM) continue;
|
||||
if (LIMIT_DISTANCE && targetNode.distance_km != null && targetNode.distance_km > MAX_DISTANCE_KM) continue;
|
||||
|
||||
const priority = getRoleRenderPriority(sourceNode.role);
|
||||
const priority = getRoleRenderPriority(sourceNode.role, sourceNode.protocol);
|
||||
const rxTimeRaw = entry.rx_time;
|
||||
let rxTime = 0;
|
||||
if (typeof rxTimeRaw === 'number' && Number.isFinite(rxTimeRaw)) {
|
||||
@@ -3991,7 +4133,7 @@ export function initializeApp(config) {
|
||||
|
||||
neighborSegments.push({
|
||||
latlngs: [[srcLat, srcLon], [tgtLat, tgtLon]],
|
||||
color: getRoleColor(sourceNode.role),
|
||||
color: getRoleColor(sourceNode.role, sourceNode.protocol),
|
||||
priority,
|
||||
rxTime,
|
||||
sourceId,
|
||||
@@ -4123,8 +4265,8 @@ export function initializeApp(config) {
|
||||
const nodesByRenderOrder = nodes
|
||||
.map((node, index) => ({ node, index }))
|
||||
.sort((a, b) => {
|
||||
const orderA = getRoleRenderPriority(a.node && a.node.role);
|
||||
const orderB = getRoleRenderPriority(b.node && b.node.role);
|
||||
const orderA = getRoleRenderPriority(a.node && a.node.role, a.node && a.node.protocol);
|
||||
const orderB = getRoleRenderPriority(b.node && b.node.role, b.node && b.node.protocol);
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return a.index - b.index;
|
||||
})
|
||||
@@ -4137,7 +4279,7 @@ export function initializeApp(config) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
if (LIMIT_DISTANCE && n.distance_km != null && n.distance_km > MAX_DISTANCE_KM) continue;
|
||||
|
||||
const color = getRoleColor(n.role);
|
||||
const color = getRoleColor(n.role, n.protocol);
|
||||
const marker = L.circleMarker([lat, lon], {
|
||||
radius: 9,
|
||||
color: '#000',
|
||||
@@ -4222,13 +4364,36 @@ export function initializeApp(config) {
|
||||
/**
|
||||
* Test whether a node matches the active role filters.
|
||||
*
|
||||
* Filters use compound ``"<protocol>:<roleKey>"`` keys so that shared role
|
||||
* names (e.g. ``SENSOR``, ``REPEATER``) can be toggled independently per
|
||||
* protocol. Nodes whose protocol is null/absent are treated as Meshtastic
|
||||
* (via {@link normalizeFilterProtocol}) to keep legacy records visible when
|
||||
* the Meshtastic SENSOR filter is active.
|
||||
*
|
||||
* @param {Object} node Node payload.
|
||||
* @returns {boolean} True when the node should be visible.
|
||||
*/
|
||||
function matchesRoleFilter(node) {
|
||||
if (!activeRoleFilters.size) return true;
|
||||
const roleKey = getRoleKey(node && node.role);
|
||||
return activeRoleFilters.has(roleKey);
|
||||
const compoundKey = makeRoleFilterKey(node && node.role, node && node.protocol);
|
||||
return activeRoleFilters.has(compoundKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a node passes the active protocol visibility filters.
|
||||
*
|
||||
* Nodes with a null/absent protocol are always shown — hiding
|
||||
* ``'meshtastic'`` hides only nodes that explicitly carry that protocol
|
||||
* value. Pre-protocol legacy records remain visible regardless.
|
||||
*
|
||||
* @param {Object} node Node payload.
|
||||
* @returns {boolean} True when the node should be visible.
|
||||
*/
|
||||
function matchesProtocolFilter(node) {
|
||||
if (!hiddenProtocols.size) return true;
|
||||
const protocol = (node && node.protocol) || null;
|
||||
if (protocol && hiddenProtocols.has(protocol)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4256,7 +4421,7 @@ export function initializeApp(config) {
|
||||
// Text and role filters apply only to the node table and map; the chat log
|
||||
// always receives the full node collection so reply-thread lookups succeed
|
||||
// even for nodes that are currently hidden by the active filter.
|
||||
const filteredNodes = allNodes.filter(n => matchesTextFilter(n, q) && matchesRoleFilter(n));
|
||||
const filteredNodes = allNodes.filter(n => matchesTextFilter(n, q) && matchesRoleFilter(n) && matchesProtocolFilter(n));
|
||||
const sortedNodes = sortNodes(filteredNodes);
|
||||
const nowSec = Date.now()/1000;
|
||||
renderTable(sortedNodes, nowSec);
|
||||
@@ -4475,6 +4640,20 @@ export function initializeApp(config) {
|
||||
createAnnouncementEntry,
|
||||
createMessageChatEntry,
|
||||
buildDisplayContext,
|
||||
makeRoleFilterKey,
|
||||
normalizeFilterProtocol,
|
||||
matchesRoleFilter,
|
||||
matchesProtocolFilter,
|
||||
buildProtocolIconImg,
|
||||
buildMeshtasticIconImg,
|
||||
buildMeshcoreIconImg,
|
||||
buildRoleButtons,
|
||||
updateLegendRoleFiltersUI,
|
||||
legendClickHandler,
|
||||
activeRoleFilters,
|
||||
hiddenProtocols,
|
||||
legendRoleButtons,
|
||||
legendProtocolButtons,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -178,6 +178,7 @@ function mergeNodeFields(target, record) {
|
||||
assignString(target, 'shortName', extractString(record, ['shortName', 'short_name']));
|
||||
assignString(target, 'longName', extractString(record, ['longName', 'long_name']));
|
||||
assignString(target, 'role', extractString(record, ['role']));
|
||||
assignString(target, 'protocol', extractString(record, ['protocol']));
|
||||
assignString(target, 'hwModel', extractString(record, ['hwModel', 'hw_model']));
|
||||
mergeModemMetadata(target, record);
|
||||
assignNumber(target, 'snr', extractNumber(record, ['snr']));
|
||||
|
||||
@@ -79,9 +79,9 @@ export function canonicalNodeIdentifier(identifier) {
|
||||
/**
|
||||
* Render a linked long name pointing to the node detail view.
|
||||
*
|
||||
* When ``protocol`` is Meshtastic (including null/empty per
|
||||
* {@link module:protocol-helpers~isMeshtasticProtocol}) or ``"meshcore"``, the
|
||||
* matching protocol icon is prepended. An anchor element is only emitted when
|
||||
* When ``protocol`` is a known value (``"meshtastic"`` or ``"meshcore"``),
|
||||
* the matching protocol icon is prepended. Absent or unknown protocol strings
|
||||
* produce no icon prefix. An anchor element is only emitted when
|
||||
* ``identifier`` resolves to a non-null detail path.
|
||||
*
|
||||
* @param {string|null} longName Display name.
|
||||
|
||||
@@ -21,11 +21,10 @@ export const MESHTASTIC_ICON_SRC = '/assets/img/meshtastic.svg';
|
||||
export const MESHCORE_ICON_SRC = '/assets/img/meshcore.svg';
|
||||
|
||||
/**
|
||||
* Return true when the protocol value represents Meshtastic or is absent.
|
||||
* Return true when the protocol value is explicitly ``"meshtastic"``.
|
||||
*
|
||||
* A null/undefined/empty string is treated as Meshtastic because the backend
|
||||
* defaults all records to ``"meshtastic"`` and pre-existing records that
|
||||
* predate the protocol column carry an implicit Meshtastic origin.
|
||||
* Absent, null, or empty values return ``false`` — no default is applied.
|
||||
* An icon is only shown when the protocol is positively known.
|
||||
*
|
||||
* Comparison is case-sensitive: only the lowercase value ``"meshtastic"``
|
||||
* matches — mixed-case strings such as ``"Meshtastic"`` return ``false``.
|
||||
@@ -33,12 +32,11 @@ export const MESHCORE_ICON_SRC = '/assets/img/meshcore.svg';
|
||||
* intentional.
|
||||
*
|
||||
* @param {string|null|undefined} protocol Protocol string from the API.
|
||||
* @returns {boolean} Whether the protocol is (or defaults to) Meshtastic.
|
||||
* @returns {boolean} Whether the protocol is explicitly Meshtastic.
|
||||
*/
|
||||
export function isMeshtasticProtocol(protocol) {
|
||||
if (protocol == null) return true;
|
||||
const trimmed = String(protocol).trim();
|
||||
return trimmed.length === 0 || trimmed === 'meshtastic';
|
||||
if (protocol == null) return false;
|
||||
return String(protocol).trim() === 'meshtastic';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,10 +81,10 @@ export function meshcoreIconHtml() {
|
||||
/**
|
||||
* Build an HTML prefix (protocol icon plus a trailing space) for inline UI.
|
||||
*
|
||||
* Meshtastic — including null, undefined, empty, or whitespace-only values per
|
||||
* {@link isMeshtasticProtocol} — uses the Meshtastic icon. The literal
|
||||
* ``"meshcore"`` uses the MeshCore icon. Any other protocol string yields an
|
||||
* empty prefix (same as the pre-MeshCore behaviour for unknown stacks).
|
||||
* Returns the matching icon only when the protocol is positively known:
|
||||
* ``"meshtastic"`` → Meshtastic icon, ``"meshcore"`` → MeshCore icon.
|
||||
* Absent, null, or unrecognised protocol strings yield an empty string —
|
||||
* no default icon is assumed.
|
||||
*
|
||||
* @param {string|null|undefined} protocol Protocol string from the API.
|
||||
* @returns {string} HTML fragment safe to concatenate before visible text.
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { isMeshcoreProtocol } from './protocol-helpers.js';
|
||||
|
||||
/**
|
||||
* Mapping of numeric Meshtastic role identifiers to their canonical names.
|
||||
*
|
||||
@@ -81,6 +83,33 @@ export const meshcoreRoleColors = Object.freeze({
|
||||
COMPANION: '#1A5498',
|
||||
});
|
||||
|
||||
/**
|
||||
* MeshCore role text colour overrides — only populated for roles whose
|
||||
* background is dark enough that the default (near-black) text becomes
|
||||
* illegible. Roles absent from this map inherit the page default.
|
||||
*
|
||||
* @type {Readonly<Record<string, string>>}
|
||||
*/
|
||||
export const meshcoreRoleTextColors = Object.freeze({
|
||||
COMPANION: '#e0e0e0',
|
||||
});
|
||||
|
||||
/**
|
||||
* Return the foreground text colour for a role badge, or ``null`` when the
|
||||
* page default is acceptable.
|
||||
*
|
||||
* @param {*} role Raw role value from the API.
|
||||
* @param {string|null|undefined} [protocol] Protocol string from the API.
|
||||
* @returns {string|null} CSS colour string, or ``null`` to inherit.
|
||||
*/
|
||||
export function getRoleTextColor(role, protocol = null) {
|
||||
if (isMeshcoreProtocol(protocol)) {
|
||||
const key = getRoleKey(role);
|
||||
return meshcoreRoleTextColors[key] ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the role colour palette appropriate for the given protocol.
|
||||
*
|
||||
@@ -92,25 +121,51 @@ export const meshcoreRoleColors = Object.freeze({
|
||||
* @returns {Readonly<Record<string, string>>} Role colour map.
|
||||
*/
|
||||
export function getRoleColors(protocol) {
|
||||
if (protocol != null && String(protocol).trim() === 'meshcore') {
|
||||
return meshcoreRoleColors;
|
||||
}
|
||||
return roleColors;
|
||||
return isMeshcoreProtocol(protocol) ? meshcoreRoleColors : roleColors;
|
||||
}
|
||||
|
||||
export const roleRenderOrder = Object.freeze({
|
||||
/**
|
||||
* 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: 3,
|
||||
CLIENT_MUTE: 4,
|
||||
CLIENT: 5,
|
||||
CLIENT_BASE: 6,
|
||||
REPEATER: 7,
|
||||
ROUTER_LATE: 8,
|
||||
ROUTER: 9,
|
||||
LOST_AND_FOUND: 10
|
||||
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. Only roles whose stacking
|
||||
* order differs from the Meshtastic palette need to appear here — any role
|
||||
* absent from this map falls through to {@link meshtasticRoleRenderOrder}.
|
||||
*
|
||||
* @type {Readonly<Record<string, number>>}
|
||||
*/
|
||||
export const meshcoreRoleRenderOrder = Object.freeze({
|
||||
SENSOR: 3,
|
||||
COMPANION: 7,
|
||||
ROOM_SERVER: 9,
|
||||
REPEATER: 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.
|
||||
*
|
||||
@@ -156,22 +211,37 @@ export function getRoleKey(role) {
|
||||
/**
|
||||
* 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}; 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) {
|
||||
export function getRoleColor(role, protocol = null) {
|
||||
const colors = getRoleColors(protocol);
|
||||
const key = getRoleKey(role);
|
||||
return roleColors[key] || roleColors.CLIENT || '#3388ff';
|
||||
return colors[key] || 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}.
|
||||
*
|
||||
* @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) {
|
||||
export function getRoleRenderPriority(role, protocol = null) {
|
||||
const key = getRoleKey(role);
|
||||
const priority = roleRenderOrder[key];
|
||||
if (isMeshcoreProtocol(protocol)) {
|
||||
const mc = meshcoreRoleRenderOrder[key];
|
||||
if (typeof mc === 'number') return mc;
|
||||
}
|
||||
const priority = meshtasticRoleRenderOrder[key];
|
||||
return typeof priority === 'number' ? priority : 0;
|
||||
}
|
||||
|
||||
@@ -1709,6 +1709,29 @@ input[type="radio"] {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.legend-items--columns {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.legend-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.legend-column-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
padding: 2px 6px 4px;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1735,8 +1758,16 @@ input[type="radio"] {
|
||||
}
|
||||
|
||||
.legend-item[aria-pressed="true"] {
|
||||
border-color: rgba(0, 0, 0, 0.2);
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
border-color: #3a7bd5;
|
||||
background: rgba(58, 123, 213, 0.18);
|
||||
color: #1a4a9e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.legend-protocol-toggle {
|
||||
margin-top: 4px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
.legend-swatch {
|
||||
@@ -1784,6 +1815,13 @@ input[type="radio"] {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.nodes-col--protocol {
|
||||
width: 20px;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
padding-inline: 4px;
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
position: fixed;
|
||||
inset-block-end: 0;
|
||||
@@ -2150,8 +2188,14 @@ body.dark .legend-item:hover {
|
||||
}
|
||||
|
||||
body.dark .legend-item[aria-pressed="true"] {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border-color: #5b9bd5;
|
||||
background: rgba(91, 155, 213, 0.28);
|
||||
color: #a8d4ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
body.dark .legend-protocol-toggle {
|
||||
border-top-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
body.dark .leaflet-popup-content-wrapper,
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<table id="nodes">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="nodes-col nodes-col--protocol" aria-label="Protocol"></th>
|
||||
<th class="nodes-col nodes-col--node-id"><button type="button" class="sort-button" data-sort-key="node_id" data-sort-label="Node ID">Node ID <span class="sort-indicator" aria-hidden="true"></span></button></th>
|
||||
<th class="nodes-col nodes-col--short-name"><button type="button" class="sort-button" data-sort-key="short_name" data-sort-label="Short Name">Short <span class="sort-indicator" aria-hidden="true"></span></button></th>
|
||||
<th class="nodes-col nodes-col--long-name"><button type="button" class="sort-button" data-sort-key="long_name" data-sort-label="Long Name">Long Name <span class="sort-indicator" aria-hidden="true"></span></button></th>
|
||||
|
||||
Reference in New Issue
Block a user