From 88900e668961dc78fc40bb5a3684430f4c180e79 Mon Sep 17 00:00:00 2001 From: Afri <220195275+l5yth@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:36:52 +0200 Subject: [PATCH] web: list only protocols in view in the map legend (#983) --- CHANGELOG.md | 1 + .../__tests__/main-legend-protocols.test.js | 414 ++++++++++++++++++ .../app/__tests__/main-update-counts.test.js | 92 ++++ .../__tests__/map-legend-protocols.test.js | 128 ++++++ web/public/assets/js/app/main.js | 43 +- .../assets/js/app/map-legend-protocols.js | 93 ++++ 6 files changed, 760 insertions(+), 11 deletions(-) create mode 100644 web/public/assets/js/app/__tests__/main-legend-protocols.test.js create mode 100644 web/public/assets/js/app/__tests__/map-legend-protocols.test.js create mode 100644 web/public/assets/js/app/map-legend-protocols.js diff --git a/CHANGELOG.md b/CHANGELOG.md index f8937a2d..4c15c179 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/web/public/assets/js/app/__tests__/main-legend-protocols.test.js b/web/public/assets/js/app/__tests__/main-legend-protocols.test.js new file mode 100644 index 00000000..e1ed13dc --- /dev/null +++ b/web/public/assets/js/app/__tests__/main-legend-protocols.test.js @@ -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} 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}} 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, 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} 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} created Elements `L.DomUtil.create` built. + * @returns {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, + * input: ?Object, + * clearButton: ?Object, + * typeFilter: function(string): Promise, + * clickClear: function(): Promise, + * clickClearFilters: function(): Promise, + * clickToggle: function(string): Promise, + * clickRoleChip: function(string, string): Promise, + * markersOnMap: function(): number, + * shown: function(): Array, + * cleanup: function(): Promise + * }>} 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} 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(); + } +}); diff --git a/web/public/assets/js/app/__tests__/main-update-counts.test.js b/web/public/assets/js/app/__tests__/main-update-counts.test.js index 8061df65..b0a21062 100644 --- a/web/public/assets/js/app/__tests__/main-update-counts.test.js +++ b/web/public/assets/js/app/__tests__/main-update-counts.test.js @@ -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 // --------------------------------------------------------------------------- diff --git a/web/public/assets/js/app/__tests__/map-legend-protocols.test.js b/web/public/assets/js/app/__tests__/map-legend-protocols.test.js new file mode 100644 index 00000000..a93e07b4 --- /dev/null +++ b/web/public/assets/js/app/__tests__/map-legend-protocols.test.js @@ -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} 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']) }), []); +}); diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index b86f6a16..6300bee8 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -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} Compound key → legend button element. */ const legendRoleButtons = new Map(); - /** @type {Set} Protocols hidden by the user via legend toggles. */ + /** @type {Set} 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, diff --git a/web/public/assets/js/app/map-legend-protocols.js b/web/public/assets/js/app/map-legend-protocols.js new file mode 100644 index 00000000..269d337f --- /dev/null +++ b/web/public/assets/js/app/map-legend-protocols.js @@ -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} + */ +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|undefined} nodes Loaded node records. + * @param {function(Object): boolean} passes Predicate a node must pass. + * @returns {Set} 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} params.hiddenProtocols Protocols hidden with the + * meta-row toggles. + * @param {Array} [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} 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)), + ), + ); +}