web: list only protocols in view in the map legend (#983)

This commit is contained in:
Afri authored and GitHub committed 2026-10-06 18:36:52 +02:00
1 parent 4b318129e9
commit 88900e6689
6 files changed
+760 -11

No files matched your search

+1
View File
@@ -29,6 +29,7 @@
* Web: Reticulum rows match every other row's height; one dash glyph throughout the nodes table (SPEC RA11)
* Data/Web: Reticulum nodes are keyed on the announcing identity, not on each destination - one node, several destinations (SPEC RE7/RE10)
* Data: the ingestor's own node id is its primary announced identity (SPEC RE8)
* Web: the map legend lists only protocols in view: a protocol toggled off, or with no node matching the filter, leaves it; Clear filters also clears the filter text (SPEC LP1)
* Data: the host's own transport aspect is ingested when the stack has transport enabled (SPEC RE9)
* Data: the Reticulum ingestor re-posts its host's own destinations every hour, so `rns.transport` and the host aspects stay fresh on a connection that never reconnects (SPEC RE8)
* Data: `PROTOCOL=reticulum` needs `INGESTOR_NODE_ID` in Docker, when nothing on the ingestor's RNS stack announces, or when two local identities tie; otherwise the node id is derived once a local app announces (SPEC RE5/RE8)
@@ -0,0 +1,414 @@
/*
* 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.
*/
/**
* The map legend follows the search text and Clear filters (SPEC LP1).
*
* Boots the dashboard over a stub fetch that serves one node per protocol,
* each active this week, then drives the real search input and its clear
* button. By default the three legend columns are swapped for stubs after
* boot, since the legend panel needs Leaflet. With `leaflet: true` a stub
* Leaflet mounts the real panel, so its columns, role chips and Clear filters
* button are the ones the app built. Everything between the user event and a
* column's `display` runs as in the browser (listener, `applyFilter`, the
* `/api/stats` callback, `applyProtocolVisibility`).
*
* @module app/__tests__/main-legend-protocols
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { createDomEnvironment } from './dom-environment.js';
import { makeLeafletStub } from './main-app-leaflet-stub.js';
import { MINIMAL_CONFIG, setupAppWithOptions } from './main-app-test-helpers.js';
import { initializeApp } from '../main.js';
const NOW = Math.floor(Date.now() / 1000);
/** Column order of the map legend, left to right. */
const LEGEND_ORDER = Object.freeze(['meshcore', 'meshtastic', 'reticulum']);
/**
* One loaded node per protocol, all heard within the past 7 days. The
* Meshtastic and MeshCore nodes have a position, so they render a marker;
* a Reticulum announce carries none.
*/
const NODES = Object.freeze([
{
node_id: '!aa000001', short_name: 'ALFA', long_name: 'Alpha Tower',
role: 'ROUTER', protocol: 'meshtastic', last_heard: NOW - 60,
latitude: 52.52, longitude: 13.4,
},
{
node_id: '!bb000002', short_name: 'BRAV', long_name: 'Bravo Repeater',
role: 'REPEATER', protocol: 'meshcore', last_heard: NOW - 120,
latitude: 52.53, longitude: 13.42,
},
{
node_id: '!cc000003', short_name: 'CHRL', long_name: 'Charlie Peer',
role: 'PEER', protocol: 'reticulum', last_heard: NOW - 180,
},
]);
/**
* Build one `/api/stats` window bucket.
*
* @param {number} week Active nodes in the past 7 days.
* @returns {{hour: number, day: number, week: number, month: number}} Bucket.
*/
const bucket = week => ({ hour: week, day: week, week, month: week });
/** `/api/stats` payload: every protocol has one node active this week. */
const STATS_PAYLOAD = Object.freeze({
total: { nodes: bucket(3), packets: { hour: 12 } },
meshtastic: { nodes: bucket(1) },
meshcore: { nodes: bucket(1) },
reticulum: { nodes: bucket(1) },
});
/** Click event stub for the handlers `legendClickHandler` wraps. */
const CLICK = Object.freeze({ preventDefault() {}, stopPropagation() {} });
/**
* Let a user event's `applyFilter` and its `/api/stats` callback settle.
*
* @returns {Promise<void>} Resolves after a short timer.
*/
const settle = () => new Promise(resolve => setTimeout(resolve, 20));
/**
* Answer the dashboard's GETs: the node list, the stats snapshot, nothing else.
*
* @param {string} url Requested URL.
* @returns {Promise<{ok: boolean, status: number, json: Function}>} Response stub.
*/
function stubFetch(url) {
const path = String(url);
let body = [];
if (path.startsWith('/api/stats/')) body = [];
else if (path.startsWith('/api/stats')) body = STATS_PAYLOAD;
else if (path.startsWith('/api/nodes/')) body = null;
else if (path.startsWith('/api/nodes')) body = NODES;
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body) });
}
/**
* Stub Leaflet whose controls mount: `addTo` runs the control's `onAdd`, and
* `L.DomUtil.create` builds mock elements, so the real legend panel exists.
*
* @returns {{leaflet: Object, created: Array<Object>}} The stub and every
* element `L.DomUtil.create` built, in creation order.
*/
function makeMountingLeafletStub() {
const leaflet = makeLeafletStub();
const created = [];
leaflet.control = () => ({
addTo(map) {
this.container = typeof this.onAdd === 'function' ? this.onAdd(map) : null;
return this;
},
});
leaflet.DomUtil = {
create(tagName, className, parent) {
const element = document.createElement(tagName);
element.className = className;
className.split(' ').forEach(name => element.classList.add(name));
if (parent) parent.appendChild(element);
created.push(element);
return element;
},
};
leaflet.DomEvent = { ...leaflet.DomEvent, disableClickPropagation() {}, disableScrollPropagation() {} };
return { leaflet, created };
}
/**
* Initialise the app over the mounting Leaflet stub, with a `#map` container,
* the three meta-row protocol toggles and, unless `searchBox` is false, the
* search input and its clear button.
*
* @param {{searchBox: boolean}} options Whether the view has a search box.
* @returns {{testUtils: Object, env: Object, leaflet: Object, created: Array<Object>, cleanup: Function}}
* App handles; `cleanup` restores the globals.
*/
function setupAppWithLegendPanel({ searchBox }) {
const env = createDomEnvironment({ includeBody: true });
env.registerElement('map', env.createElement('div', 'map'));
const ids = ['protocolToggleMeshcore', 'protocolToggleMeshtastic', 'protocolToggleReticulum'];
if (searchBox) ids.push('filterInput', 'filterClear');
for (const id of ids) env.registerElement(id, env.createElement(id === 'filterInput' ? 'input' : 'button', id));
// initializeApp watches a media query to collapse the legend; the DOM stub has none.
env.window.matchMedia = () => ({ matches: false, media: '', addEventListener() {}, removeEventListener() {} });
const { leaflet, created } = makeMountingLeafletStub();
const previousL = globalThis.L;
globalThis.window.L = leaflet;
globalThis.L = leaflet;
const cleanup = () => {
globalThis.L = previousL;
env.cleanup();
};
try {
const { _testUtils } = initializeApp(MINIMAL_CONFIG);
return { testUtils: _testUtils, env, leaflet, created, cleanup };
} catch (error) {
cleanup();
throw error;
}
}
/**
* Swap the legend columns for stubs (the panel is not mounted without Leaflet).
*
* @param {Object} testUtils App test utilities.
* @returns {Object<string, {style: {display: string}}>} Column stubs by protocol.
*/
function injectColumnStubs(testUtils) {
const columns = {
meshcore: { style: { display: '' } },
meshtastic: { style: { display: '' } },
reticulum: { style: { display: '' } },
};
testUtils._setProtocolColElements(columns.meshcore, columns.meshtastic, columns.reticulum);
return columns;
}
/**
* Map the mounted legend columns to their protocols; the panel builds them
* left to right.
*
* @param {Array<Object>} created Elements `L.DomUtil.create` built.
* @returns {Object<string, Object>} Column elements by protocol.
*/
function mountedColumns(created) {
const [meshcore, meshtastic, reticulum] = created.filter(element => element.classList.contains('legend-column'));
return { meshcore, meshtastic, reticulum };
}
/**
* Boot the app with {@link NODES} loaded.
*
* @param {{leaflet?: boolean, searchBox?: boolean}} [options] `leaflet` mounts
* the real legend panel; `searchBox: false` (with `leaflet`) boots a view
* without the search input.
* @returns {Promise<{
* testUtils: Object,
* columns: Object<string, Object>,
* input: ?Object,
* clearButton: ?Object,
* typeFilter: function(string): Promise<void>,
* clickClear: function(): Promise<void>,
* clickClearFilters: function(): Promise<void>,
* clickToggle: function(string): Promise<void>,
* clickRoleChip: function(string, string): Promise<void>,
* markersOnMap: function(): number,
* shown: function(): Array<string>,
* cleanup: function(): Promise<void>
* }>} Harness for one test. The click helpers past `clickClear` and
* `markersOnMap` need `leaflet`.
*/
async function bootLegendApp({ leaflet = false, searchBox = true } = {}) {
const originalFetch = globalThis.fetch;
globalThis.fetch = stubFetch;
let harness;
try {
harness = leaflet
? setupAppWithLegendPanel({ searchBox })
: setupAppWithOptions({ extraElements: ['filterInput', 'filterClear'] });
await harness.testUtils.initialLoad;
await harness.testUtils.flushCollectionBackfills();
await settle();
assert.equal(harness.testUtils.getLoadedNodeCount(), NODES.length, 'every stub node is loaded');
} catch (error) {
if (harness) harness.testUtils.stopAutoRefresh();
globalThis.fetch = originalFetch;
if (harness) harness.cleanup();
throw error;
}
const { testUtils, env } = harness;
const columns = leaflet ? mountedColumns(harness.created) : injectColumnStubs(testUtils);
const input = env.document.getElementById('filterInput');
// The clear button refocuses the input; the DOM stub has no focus().
if (input) input.focus = () => {};
const clearButton = env.document.getElementById('filterClear');
/**
* Run an element's click listeners, then let the refilter settle.
*
* @param {Object} element Mock element.
* @param {Object} [event] Event passed to the listeners.
* @returns {Promise<void>} Resolves once settled.
*/
const click = async (element, event) => {
element._listeners.get('click').forEach(handler => handler(event));
await settle();
};
return {
testUtils,
columns,
input,
clearButton,
async typeFilter(text) {
input.value = text;
input._listeners.get('input').forEach(handler => handler());
await settle();
},
clickClear: () => click(clearButton),
clickClearFilters: () => click(harness.created.find(element => element.classList.contains('legend-reset')), CLICK),
clickToggle: name => click(env.document.getElementById(`protocolToggle${name}`)),
clickRoleChip: (protocol, role) => click(columns[protocol].childNodes.find(node => node.dataset?.role === role), CLICK),
markersOnMap() {
const { circleMarkers, markers, layerGroups } = harness.leaflet._recorded;
const nodeMarkers = new Set([...circleMarkers, ...markers]);
return layerGroups.reduce((count, group) => count + group._layers.filter(layer => nodeMarkers.has(layer)).length, 0);
},
shown: () => LEGEND_ORDER.filter(protocol => columns[protocol].style.display !== 'none'),
async cleanup() {
await settle();
testUtils.stopAutoRefresh();
globalThis.fetch = originalFetch;
harness.cleanup();
},
};
}
test('a protocol whose nodes all fail the text filter leaves the map legend (LP1)', async () => {
const app = await bootLegendApp();
try {
// Only the Meshtastic node matches. MeshCore and Reticulum keep their
// 7-day activity and stay toggled on: the filter alone takes them out.
await app.typeFilter('alpha');
assert.deepEqual(app.shown(), ['meshtastic'], 'only the protocol with a matching node keeps its column');
assert.equal(app.testUtils.hiddenProtocols.size, 0, 'the text filter toggles no protocol off');
// Same normalisation as the table: trimmed and case-insensitive, across
// the node id, short name and long name.
await app.typeFilter(' BRAVO ');
assert.deepEqual(app.shown(), ['meshcore'], 'the column set follows the filter text as it changes');
await app.typeFilter('!cc0000');
assert.deepEqual(app.shown(), ['reticulum'], 'a node id match keeps its protocol listed');
} finally {
await app.cleanup();
}
});
test('clearing the text filter brings the legend columns back (LP1)', async () => {
const app = await bootLegendApp();
try {
await app.typeFilter('zz-no-such-node-zz');
assert.deepEqual(app.shown(), [], 'a filter that matches no node leaves no protocol column');
await app.typeFilter('');
assert.deepEqual(app.shown(), LEGEND_ORDER, 'deleting the text returns every column');
await app.typeFilter('charlie');
assert.deepEqual(app.shown(), ['reticulum']);
await app.clickClear();
assert.deepEqual(app.shown(), LEGEND_ORDER, 'the clear button returns every column');
} finally {
await app.cleanup();
}
});
test('role filters never take a protocol column out of the map legend (LP1)', async () => {
const app = await bootLegendApp();
try {
const { testUtils } = app;
// Switch off the role of the only MeshCore node: it leaves the table and
// the map, but its column must stay so its role chips can bring it back.
testUtils.activeRoleFilters.add(testUtils.makeRoleFilterKey('REPEATER', 'meshcore'));
assert.equal(testUtils.matchesRoleFilter(NODES[1]), false, 'the MeshCore node is filtered out by role');
await app.typeFilter('');
assert.deepEqual(app.shown(), LEGEND_ORDER, 'role filters alone hide no column');
// The text matches only that role-filtered node: MeshCore keeps its
// column, Meshtastic and Reticulum (no matching node) leave.
await app.typeFilter('bravo');
assert.deepEqual(app.shown(), ['meshcore'], 'the text filter decides, the role filter does not');
} finally {
await app.cleanup();
}
});
test('Clear filters also empties the search box and brings the legend columns back (LP1)', async () => {
const app = await bootLegendApp({ leaflet: true });
try {
const { testUtils } = app;
assert.deepEqual(app.shown(), LEGEND_ORDER, 'the mounted legend lists all three protocols');
assert.equal(app.markersOnMap(), 2, 'the Meshtastic and MeshCore nodes are on the map');
// A role chip, a protocol toggle and a search that matches nothing.
await app.clickRoleChip('meshcore', 'REPEATER');
await app.clickToggle('Reticulum');
await app.typeFilter('zz-no-such-node-zz');
assert.equal(testUtils.activeRoleFilters.size, 1);
assert.ok(testUtils.hiddenProtocols.has('reticulum'));
assert.deepEqual(app.shown(), [], 'a search that matches nothing leaves no column');
assert.equal(app.markersOnMap(), 0);
const repaints = testUtils.getRenderCount();
await app.clickClearFilters();
assert.equal(app.input.value, '', 'Clear filters empties the search box');
assert.equal(app.clearButton.hidden, true, "the search box's clear button hides with the text");
assert.equal(testUtils.activeRoleFilters.size, 0, 'the role filters are reset');
assert.equal(testUtils.hiddenProtocols.size, 0, 'the protocol toggles are reset');
assert.deepEqual(app.shown(), LEGEND_ORDER, 'every active column is back');
assert.ok(testUtils.getRenderCount() > repaints, 'the table, map and chat refilter');
assert.equal(app.markersOnMap(), 2, 'both nodes are back on the map');
} finally {
await app.cleanup();
}
});
test("the search box's clear button still clears only the search text (LP1)", async () => {
const app = await bootLegendApp({ leaflet: true });
try {
const { testUtils } = app;
await app.clickRoleChip('meshcore', 'REPEATER');
await app.clickToggle('Reticulum');
await app.typeFilter('zz-no-such-node-zz');
assert.deepEqual(app.shown(), []);
await app.clickClear();
assert.equal(app.input.value, '', 'the search box is empty');
assert.equal(testUtils.activeRoleFilters.size, 1, 'the role filter stays');
assert.ok(testUtils.hiddenProtocols.has('reticulum'), 'Reticulum stays toggled off');
assert.deepEqual(app.shown(), ['meshcore', 'meshtastic'], 'the columns the search removed are back');
assert.equal(app.markersOnMap(), 1, 'the MeshCore node stays role-filtered off the map');
} finally {
await app.cleanup();
}
});
test('Clear filters still resets the role filters and toggles on a view without a search box (LP1)', async () => {
const app = await bootLegendApp({ leaflet: true, searchBox: false });
try {
const { testUtils } = app;
assert.equal(app.input, null, 'this view has no search box');
await app.clickRoleChip('meshcore', 'REPEATER');
await app.clickToggle('Reticulum');
assert.deepEqual(app.shown(), ['meshcore', 'meshtastic']);
await app.clickClearFilters();
assert.equal(testUtils.activeRoleFilters.size, 0);
assert.equal(testUtils.hiddenProtocols.size, 0);
assert.deepEqual(app.shown(), LEGEND_ORDER);
} finally {
await app.cleanup();
}
});
@@ -604,6 +604,98 @@ test('applyProtocolVisibility handles missing per-protocol data gracefully', ()
}
});
// ---------------------------------------------------------------------------
// applyProtocolVisibility x meta-row protocol toggles (map-legend regression)
// ---------------------------------------------------------------------------
/**
* Stats with 7-day activity on all three protocols, so every meta-row toggle
* stays visible and the un-strand rule never fires.
*/
const ALL_ACTIVE_STATS = Object.freeze({
meshcore: { hour: 1, day: 2, week: 5, month: 10 },
meshtastic: { hour: 2, day: 3, week: 8, month: 15 },
reticulum: { hour: 1, day: 3, week: 3, month: 3 },
});
/**
* Inject three visible legend column stubs.
*
* @param {Object} testUtils App test utilities.
* @returns {{mcCol: Object, mtCol: Object, rtCol: Object}} Column stubs.
*/
function injectVisibleColumns(testUtils) {
const mcCol = { style: { display: '' } };
const mtCol = { style: { display: '' } };
const rtCol = { style: { display: '' } };
testUtils._setProtocolColElements(mcCol, mtCol, rtCol);
return { mcCol, mtCol, rtCol };
}
test('applyProtocolVisibility drops the legend column of a protocol hidden with its toggle', () => {
const { testUtils, cleanup } = setupApp();
try {
const { mcCol, mtCol, rtCol } = injectVisibleColumns(testUtils);
// The user turned MeshCore off with its meta-row toggle. Every protocol
// keeps 7-day activity, so the toggle (the way back) stays on screen.
testUtils.hiddenProtocols.add('meshcore');
testUtils.applyProtocolVisibility(ALL_ACTIVE_STATS);
assert.equal(mcCol.style.display, 'none', 'a protocol hidden from view leaves the map legend');
assert.equal(mtCol.style.display, '', 'a protocol in view keeps its legend column');
assert.equal(rtCol.style.display, '', 'a protocol in view keeps its legend column');
assert.ok(testUtils.hiddenProtocols.has('meshcore'), 'the explicit hide is preserved');
} finally {
cleanup();
}
});
test('applyProtocolVisibility returns a legend column when its protocol is shown again', () => {
const { testUtils, cleanup } = setupApp();
try {
const { mcCol, mtCol, rtCol } = injectVisibleColumns(testUtils);
testUtils.hiddenProtocols.add('meshcore');
testUtils.hiddenProtocols.add('reticulum');
testUtils.applyProtocolVisibility(ALL_ACTIVE_STATS);
assert.equal(mcCol.style.display, 'none', 'hidden MeshCore leaves the legend');
assert.equal(rtCol.style.display, 'none', 'hidden Reticulum leaves the legend');
testUtils.hiddenProtocols.delete('reticulum');
testUtils.applyProtocolVisibility(ALL_ACTIVE_STATS);
assert.equal(rtCol.style.display, '', 'Reticulum shown again: its column returns');
assert.equal(mcCol.style.display, 'none', 'MeshCore still hidden: its column stays out');
assert.equal(mtCol.style.display, '');
} finally {
cleanup();
}
});
test('applyProtocolVisibility gives an un-stranded protocol its column back in the same pass', async () => {
const { testUtils, cleanup } = setupApp();
// Let the un-strand's applyFilter() async fallout settle before cleanup.
const settle = () => new Promise(resolve => setTimeout(resolve, 20));
try {
const { mcCol } = injectVisibleColumns(testUtils);
testUtils.hiddenProtocols.add('meshcore');
// MeshCore is now the only active protocol: its toggle disappears, the
// un-strand rule drops it from hiddenProtocols, and its nodes return.
// Its column must return with them, not one stats round-trip later.
testUtils.applyProtocolVisibility({
meshcore: { hour: 1, day: 2, week: 5, month: 10 },
meshtastic: { hour: 0, day: 0, week: 0, month: 0 },
reticulum: { hour: 0, day: 0, week: 0, month: 0 },
});
assert.ok(!testUtils.hiddenProtocols.has('meshcore'), 'the chipless protocol is un-stranded');
assert.equal(mcCol.style.display, '', 'the un-stranded protocol is back in view, so is its column');
await settle();
} finally {
cleanup();
}
});
// ---------------------------------------------------------------------------
// restartAutoRefresh
// ---------------------------------------------------------------------------
@@ -0,0 +1,128 @@
/*
* 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.
*/
/**
* Unit tests for {@link legendProtocolsInView} (SPEC LP1).
*
* @module app/__tests__/map-legend-protocols
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { legendProtocolsInView } from '../map-legend-protocols.js';
/** Stats with 7-day activity on all three protocols. */
const ALL_ACTIVE = Object.freeze({
meshcore: { week: 4 },
meshtastic: { week: 12 },
reticulum: { week: 3 },
});
/** One node per protocol, matched by long name. */
const NODES = Object.freeze([
{ node_id: '!aa000001', long_name: 'Alpha Tower', protocol: 'meshtastic', role: 'ROUTER' },
{ node_id: '!bb000002', long_name: 'Bravo Repeater', protocol: 'meshcore', role: 'REPEATER' },
{ node_id: '!cc000003', long_name: 'Charlie Peer', protocol: 'reticulum', role: 'PEER' },
]);
/**
* Text filter stub: case-insensitive substring on the long name.
*
* @param {Object} node Node record.
* @param {string} query Normalised query.
* @returns {boolean} Whether the node matches.
*/
const matchesText = (node, query) => String(node.long_name || '').toLowerCase().includes(query);
/**
* Protocol filter stub that shows every node.
*
* @returns {boolean} Always true.
*/
const showAll = () => true;
/**
* Run {@link legendProtocolsInView} with defaults and return the listed
* protocols in column order.
*
* @param {Object} overrides Parameters to replace.
* @returns {Array<string>} Listed protocols.
*/
function listed(overrides = {}) {
return [
...legendProtocolsInView({
stats: ALL_ACTIVE,
hiddenProtocols: new Set(),
nodes: NODES,
query: '',
matchesText,
matchesProtocol: showAll,
...overrides,
}),
];
}
test('legendProtocolsInView lists every active protocol, in column order, with nothing hidden or filtered (LP1)', () => {
assert.deepEqual(listed(), ['meshcore', 'meshtastic', 'reticulum']);
// Without a text filter the loaded nodes are not consulted at all.
assert.deepEqual(listed({ nodes: [] }), ['meshcore', 'meshtastic', 'reticulum']);
assert.deepEqual(listed({ query: undefined, nodes: undefined }), ['meshcore', 'meshtastic', 'reticulum']);
});
test('legendProtocolsInView drops a protocol without 7-day activity (LP1)', () => {
assert.deepEqual(listed({ stats: { meshcore: { week: 0 }, meshtastic: { week: 12 } } }), ['meshtastic']);
assert.deepEqual(listed({ stats: { week: 5 } }), [], 'no per-protocol data reads as no activity');
assert.deepEqual(listed({ stats: null }), []);
});
test('legendProtocolsInView drops a protocol hidden with its meta-row toggle (LP1)', () => {
assert.deepEqual(listed({ hiddenProtocols: new Set(['meshcore']) }), ['meshtastic', 'reticulum']);
assert.deepEqual(listed({ hiddenProtocols: new Set(['meshcore', 'meshtastic', 'reticulum']) }), []);
});
test('legendProtocolsInView keeps only protocols with a node passing the text filter while one is set (LP1)', () => {
const calls = [];
const recordingText = (node, query) => {
calls.push(query);
return matchesText(node, query);
};
assert.deepEqual(listed({ query: 'alpha', matchesText: recordingText }), ['meshtastic']);
assert.ok(calls.length > 0 && calls.every(query => query === 'alpha'), 'the text filter sees the query');
assert.deepEqual(listed({ query: 'r' }), ['meshcore', 'meshtastic', 'reticulum'], 'every protocol has a match');
assert.deepEqual(listed({ query: 'zz-no-such-node-zz' }), [], 'no match lists no protocol');
});
test('legendProtocolsInView also requires the node to pass the protocol filter (LP1)', () => {
const hideMeshcoreNodes = node => node.protocol !== 'meshcore';
assert.deepEqual(listed({ query: 'bravo', matchesProtocol: hideMeshcoreNodes }), []);
assert.deepEqual(listed({ query: 'e', matchesProtocol: hideMeshcoreNodes }), ['meshtastic', 'reticulum']);
});
test('legendProtocolsInView counts a node without a protocol as Meshtastic (LP1)', () => {
const legacy = [{ node_id: '!dd000004', long_name: 'Legacy Box', protocol: null }];
assert.deepEqual(listed({ query: 'legacy', nodes: legacy }), ['meshtastic']);
});
test('legendProtocolsInView skips empty node slots and a missing node list (LP1)', () => {
assert.deepEqual(listed({ query: 'charlie', nodes: [null, undefined, NODES[2]] }), ['reticulum']);
assert.deepEqual(listed({ query: 'charlie', nodes: undefined }), [], 'nothing loaded, nothing matches');
});
test('legendProtocolsInView never lists a match whose protocol is inactive or toggled off (LP1)', () => {
assert.deepEqual(listed({ query: 'bravo', stats: { meshcore: { week: 0 }, meshtastic: { week: 12 } } }), []);
assert.deepEqual(listed({ query: 'bravo', hiddenProtocols: new Set(['meshcore']) }), []);
});
+32 -11
View File
@@ -61,6 +61,7 @@ import { createMapAutoFitController } from './map-auto-fit-controller.js';
import { resolveAutoFitBoundsConfig } from './map-auto-fit-settings.js';
import { attachNodeInfoRefreshToMarker, overlayToPopupNode } from './map-marker-node-info.js';
import { resolveLegendVisibility, legendToggleLabel } from './map-legend-visibility.js';
import { legendProtocolsInView } from './map-legend-protocols.js';
import { createMapFocusHandler, DEFAULT_NODE_FOCUS_ZOOM } from './nodes-map-focus.js';
import { createMapCenterResetHandler } from './map-center-reset.js';
import { enhanceCoordinateCell } from './nodes-coordinate-links.js';
@@ -1495,9 +1496,8 @@ export function initializeApp(config) {
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. */
/** @type {Set<string>} Protocols hidden by the user with the meta-row protocol toggles. */
const hiddenProtocols = new Set();
const legendProtocolButtons = new Map();
/**
* Wrap a legend button click handler so it always calls
@@ -2218,6 +2218,10 @@ export function initializeApp(config) {
resetButton.addEventListener('click', legendClickHandler(() => {
activeRoleFilters.clear();
hiddenProtocols.clear();
// The search text is a filter too (SPEC LP1): applyFilter then hides
// the box's own clear button and refilters the table, map and chat,
// as clearing the box does.
if (filterInput) filterInput.value = '';
updateLegendRoleFiltersUI();
applyFilter();
}));
@@ -5849,10 +5853,14 @@ export function initializeApp(config) {
}
/**
* Hide/show UI elements based on per-protocol activity in the past 7 days.
* Hide/show UI elements based on per-protocol activity in the past 7 days
* and on which protocols are in view (SPEC LP1).
*
* Hides the Charts nav link when meshtastic has no active nodes, and hides
* legend columns for protocols with zero weekly activity.
* Hides the Charts nav link when meshtastic has no active nodes, shows a
* protocol's meta-row toggle only while two or more protocols are active,
* and lists in the map legend only the protocols in view (SPEC LP1, decided
* by {@link legendProtocolsInView}). Runs from {@link applyFilter}'s stats
* callback, so the legend follows the toggles and the filter text.
*
* @param {{meshcore?: {week: number}, meshtastic?: {week: number}, reticulum?: {week: number}}} stats Stats from /api/stats.
* @returns {void}
@@ -5862,11 +5870,6 @@ export function initializeApp(config) {
const meshtasticWeek = stats?.meshtastic?.week ?? 0;
const reticulumWeek = stats?.reticulum?.week ?? 0;
// Hide legend columns for protocols with no activity in the past 7 days.
if (meshcoreColEl) meshcoreColEl.style.display = meshcoreWeek === 0 ? 'none' : '';
if (meshtasticColEl) meshtasticColEl.style.display = meshtasticWeek === 0 ? 'none' : '';
if (reticulumColEl) reticulumColEl.style.display = reticulumWeek === 0 ? 'none' : '';
// Show a protocol's toggle button only when that protocol has weekly
// activity and at least one other protocol does too — filtering is
// pointless when only one protocol is present. (Generalises the original
@@ -5895,6 +5898,25 @@ export function initializeApp(config) {
unstranded = true;
}
}
// List in the legend only the protocols in view (SPEC LP1): 7-day
// activity, not toggled off, and, while a text filter is set, a node
// passing the text and protocol filters. After the un-strand pass, so a
// protocol it brought back regains its column in the same pass.
const legendProtocols = legendProtocolsInView({
stats,
hiddenProtocols,
nodes: allNodes,
query: normaliseChatFilterQuery(filterInput ? filterInput.value : ''),
matchesText: matchesTextFilter,
matchesProtocol: matchesProtocolFilter,
});
for (const [col, protocol] of [
[meshcoreColEl, 'meshcore'],
[meshtasticColEl, 'meshtastic'],
[reticulumColEl, 'reticulum'],
]) {
if (col) col.style.display = legendProtocols.has(protocol) ? '' : 'none';
}
if (unstranded) {
// Re-run the same sync path a chip click uses so the nodes reappear.
// Bounded: the dropped protocols are no longer in the set, so the
@@ -5977,7 +5999,6 @@ export function initializeApp(config) {
activeRoleFilters,
hiddenProtocols,
legendRoleButtons,
legendProtocolButtons,
updateTitleCount,
updateLegendProtocolCounts,
updateProtocolToggleCounts,
@@ -0,0 +1,93 @@
/*
* 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.
*/
/**
* Which protocols the map legend lists (SPEC LP1).
*
* The legend has one column per protocol. A column keys the markers of its
* protocol, so it is listed only while that protocol is in view.
*
* @module app/map-legend-protocols
*/
import { normalizeFilterProtocol } from './main/filter-helpers.js';
/**
* Protocols that own a map-legend column, in column order.
*
* @type {ReadonlyArray<string>}
*/
const LEGEND_PROTOCOLS = Object.freeze(['meshcore', 'meshtastic', 'reticulum']);
/**
* Collect the protocols that have at least one node passing a predicate.
*
* Nodes are bucketed with {@link normalizeFilterProtocol}, the mapping the
* role filters use, so a legacy node without a protocol counts as Meshtastic.
*
* @param {Array<Object>|undefined} nodes Loaded node records.
* @param {function(Object): boolean} passes Predicate a node must pass.
* @returns {Set<string>} Protocol tokens with at least one passing node.
*/
function protocolsWithPassingNodes(nodes, passes) {
const found = new Set();
for (const node of Array.isArray(nodes) ? nodes : []) {
if (node && passes(node)) found.add(normalizeFilterProtocol(node.protocol));
}
return found;
}
/**
* Decide which protocols the map legend lists, one column each (SPEC LP1).
*
* A protocol is listed while all three hold:
*
* 1. it had activity in the past 7 days (`stats[protocol].week` above 0);
* 2. it is not hidden with its meta-row toggle (`hiddenProtocols`);
* 3. while a text filter is set, at least one loaded node of that protocol
* passes the text and protocol filters.
*
* Role filters are not part of the third condition: a protocol whose roles
* are all switched off keeps its column, so its role chips stay reachable.
* With no text filter set, the third condition does not apply.
*
* @param {Object} params Inputs.
* @param {?Object} params.stats Normalised `/api/stats` snapshot; a missing
* per-protocol `week` count reads as 0.
* @param {Set<string>} params.hiddenProtocols Protocols hidden with the
* meta-row toggles.
* @param {Array<Object>} [params.nodes] Loaded node records.
* @param {string} [params.query] Normalised text filter; empty when none is set.
* @param {function(Object, string): boolean} [params.matchesText] Text filter:
* whether a node matches the query.
* @param {function(Object): boolean} [params.matchesProtocol] Protocol filter:
* whether a node's protocol is shown.
* @returns {Set<string>} Protocols whose legend column shows.
*/
export function legendProtocolsInView({ stats, hiddenProtocols, nodes, query, matchesText, matchesProtocol }) {
// Only a set text filter can empty a column of nodes; skip the scan otherwise.
const matched = query
? protocolsWithPassingNodes(nodes, node => matchesText(node, query) && matchesProtocol(node))
: null;
return new Set(
LEGEND_PROTOCOLS.filter(
protocol =>
(stats?.[protocol]?.week ?? 0) > 0 &&
!hiddenProtocols.has(protocol) &&
(matched === null || matched.has(protocol)),
),
);
}