web: rework map spider-net feature (#769)

* web: rework map spider-net feature

* web: address review comments

* web: address review comments
This commit is contained in:
l5y
2026-04-29 07:06:18 +02:00
committed by GitHub
parent 521c2f2972
commit ee98efc120
6 changed files with 1357 additions and 42 deletions
@@ -0,0 +1,352 @@
/*
* 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 { createDomEnvironment } from './dom-environment.js';
import { initializeApp } from '../main.js';
import { MINIMAL_CONFIG } from './main-app-test-helpers.js';
/**
* Build a minimal stub of the Leaflet ``L`` global that supports the surface
* area exercised during {@link initializeApp} setup and the subsequent
* {@link renderMap} render path. The stub is deliberately data-only — every
* Leaflet object is a plain ``{}`` shape with the methods the production code
* calls — so tests can introspect counts (e.g. how many markers were added
* to a particular layer) without depending on a real Leaflet runtime.
*
* The returned object also exposes the ``_recorded`` reference which holds
* arrays of created markers / lines / hubs so individual tests can assert on
* what was drawn into each layer. Layers themselves expose their internal
* ``_layers`` array, allowing direct assertions like
* ``stub.markersLayer._layers.length`` after a render. The stub is kept
* intentionally minimal — every method here corresponds to a call site in
* production main.js, so adding a new Leaflet call generally requires a
* matching entry here.
*
* @returns {Object} Stub Leaflet root with helper accessors.
*/
export function makeLeafletStub() {
const recorded = {
circleMarkers: [],
polylines: [],
markers: [],
divIcons: [],
layerGroups: [],
domEventStopPropagation: 0
};
/**
* Build a layer-group stub that records additions into an internal
* ``_layers`` array so tests can introspect what was drawn there.
*
* @returns {Object} Layer group stub.
*/
function makeLayerGroup() {
const group = {
_layers: [],
addTo() {
return group;
},
clearLayers() {
group._layers.length = 0;
return group;
}
};
recorded.layerGroups.push(group);
return group;
}
/**
* Construct a marker-shaped stub with the subset of Leaflet's marker API
* that production code interacts with. Used as the base for both
* ``L.circleMarker`` and ``L.marker`` results so the two share the
* ``addTo`` / ``on`` / ``getElement`` surface.
*
* @param {[number, number]} latLng Initial coordinate pair.
* @param {Object} [options] Marker options forwarded by the caller.
* @returns {Object} Marker stub.
*/
function makeMarker(latLng, options) {
const eventHandlers = new Map();
const marker = {
_latLng: latLng,
_addedTo: null,
options: options || {},
addTo(layer) {
marker._addedTo = layer;
if (layer && Array.isArray(layer._layers)) layer._layers.push(marker);
return marker;
},
on(event, handler) {
if (!eventHandlers.has(event)) eventHandlers.set(event, []);
eventHandlers.get(event).push(handler);
return marker;
},
_eventHandlers: eventHandlers
};
return marker;
}
/**
* Construct a polyline-shaped stub for spider leader / neighbour /
* trace lines. Production code reads ``setLatLngs`` (used by the spider
* refresh helper) but never the getters, so we keep the shape minimal.
*
* @param {Array<[number, number]>} latLngs Initial coordinate list.
* @param {Object} [options] Polyline options.
* @returns {Object} Polyline stub.
*/
function makePolyline(latLngs, options) {
const line = {
_latLngs: latLngs,
_addedTo: null,
options: options || {},
addTo(layer) {
line._addedTo = layer;
if (layer && Array.isArray(layer._layers)) layer._layers.push(line);
return line;
}
};
return line;
}
/**
* Construct a tile-layer stub. ``initializeApp`` registers
* ``tileloadstart`` / ``tileload`` / ``load`` / ``tileerror`` handlers but
* never fires them in the test environment, so the stub just stores the
* registration for completeness.
*
* @param {string} url Tile URL template (ignored).
* @param {Object} [options] Tile options.
* @returns {Object} Tile-layer stub.
*/
function makeTileLayer(url, options) {
const tile = {
_url: url,
_events: new Map(),
options: options || {},
addTo() {
return tile;
},
on(event, handler) {
if (!tile._events.has(event)) tile._events.set(event, []);
tile._events.get(event).push(handler);
return tile;
}
};
return tile;
}
/**
* Construct the map stub returned by ``L.map()``. ``getZoom`` is
* mutable via ``_setZoom`` so individual tests can drive the dispatch
* branches without re-instantiating the entire harness.
*
* @returns {Object} Map stub.
*/
function makeMap() {
let zoom = 14;
const eventHandlers = new Map();
const map = {
_setZoom(value) {
zoom = value;
},
fitBounds() {
return map;
},
getZoom() {
return zoom;
},
latLngToLayerPoint(latLng) {
// Identity-ish: [lat, lon] → {x: lon, y: lat}. Keeps offsets simple
// to reason about in test assertions.
const lat = Array.isArray(latLng) ? latLng[0] : latLng.lat;
const lon = Array.isArray(latLng) ? latLng[1] : latLng.lng;
return { x: lon, y: lat };
},
layerPointToLatLng(point) {
return { lat: point.y, lng: point.x };
},
on(event, handler) {
if (!eventHandlers.has(event)) eventHandlers.set(event, []);
eventHandlers.get(event).push(handler);
return map;
},
whenReady(cb) {
// Fire synchronously so the harness does not have to drive an event
// loop just to thread the ready-callback side effects.
if (typeof cb === 'function') cb();
return map;
},
invalidateSize() {
return map;
}
};
return map;
}
const stub = {
map(_container, _options) {
stub._map = makeMap();
return stub._map;
},
tileLayer: makeTileLayer,
layerGroup: makeLayerGroup,
circleMarker(latLng, options) {
const marker = makeMarker(latLng, options);
recorded.circleMarkers.push(marker);
return marker;
},
polyline(latLngs, options) {
const line = makePolyline(latLngs, options);
recorded.polylines.push(line);
return line;
},
marker(latLng, options) {
const marker = makeMarker(latLng, options);
recorded.markers.push(marker);
return marker;
},
divIcon(options) {
const icon = { options: options || {} };
recorded.divIcons.push(icon);
return icon;
},
point(x, y) {
return { x, y };
},
latLng(lat, lng) {
// ``L.latLng`` is invoked once during ``initializeApp`` to seed the
// initial map centre. The stub returns a plain object since the rest
// of the production code only reads ``.lat`` / ``.lng`` from it.
return { lat, lng };
},
DomEvent: {
stopPropagation() {
recorded.domEventStopPropagation += 1;
}
},
control(_options) {
// ``initializeApp`` calls ``L.control(...)`` to construct the legend
// toggle widget. The stub returns a chainable shape with ``addTo`` so
// the registration path completes without producing a real Leaflet
// control instance.
return {
addTo() {
return this;
}
};
},
_recorded: recorded
};
return stub;
}
/**
* Spin up the application with a Leaflet stub on ``window.L`` and a
* pre-registered ``#map`` element so the map-init branch of
* {@link initializeApp} runs to completion. Network ``fetch`` is replaced
* with a never-resolving promise so the trailing ``refresh()`` cycle does
* not race against the test's cleanup (the same pattern documented in the
* narrower ``stubFetchForApplyFilter`` helper).
*
* @param {Object} [opts]
* @param {Object} [opts.configOverrides] Per-test overrides merged into
* {@link MINIMAL_CONFIG}.
* @returns {{ testUtils: Object, env: Object, leaflet: Object, cleanup: Function }}
*/
export function setupAppWithLeaflet(opts = {}) {
const env = createDomEnvironment({ includeBody: true });
const mapContainer = env.createElement('div', 'map');
env.registerElement('map', mapContainer);
// ``applyFiltersToAllTiles`` writes to ``document.body.style`` via
// ``setProperty``; the bare ``MockElement`` only exposes an empty object,
// so extend it with the method. The ``style.cssText`` accumulator is
// diagnostic-only — production code never reads it back, but having it
// lets tests inspect what filters were applied if needed.
const bodyStyle = (env.window && env.window.document && env.window.document.body)
? env.window.document.body.style
: null;
if (bodyStyle && typeof bodyStyle.setProperty !== 'function') {
bodyStyle._properties = bodyStyle._properties || {};
bodyStyle.setProperty = (name, value) => {
bodyStyle._properties[name] = value;
};
}
// ``initializeApp`` calls ``window.matchMedia`` to set up a responsive
// legend listener. The base DOM mock does not provide it, so we install
// a no-op shim that returns a never-firing ``MediaQueryList`` shape.
if (env.window && typeof env.window.matchMedia !== 'function') {
env.window.matchMedia = () => ({
matches: false,
media: '',
addEventListener() {},
removeEventListener() {}
});
}
const previousWindowL = globalThis.window.L;
const previousGlobalL = globalThis.L;
const previousFetch = globalThis.fetch;
const leaflet = makeLeafletStub();
// Both ``window.L`` and the bare ``L`` global must be set: the
// ``hasLeaflet`` capture reads ``window.L``, while the runtime references
// ``L`` directly via the module's global scope. Mirror the way the
// browser's ``leaflet.js`` exposes the namespace.
globalThis.window.L = leaflet;
globalThis.L = leaflet;
// Pinning fetch to a never-resolving promise keeps any
// ``fetchActiveNodeStats`` / ``refresh`` chains from racing against the
// test cleanup. The promise never settles, so any future ``.then`` /
// ``.catch`` attached downstream simply hangs harmlessly until the next
// microtask cycle is abandoned by the test runner.
globalThis.fetch = () => new Promise(() => {});
const config = { ...MINIMAL_CONFIG, ...(opts.configOverrides || {}) };
const { _testUtils } = initializeApp(config);
return {
testUtils: _testUtils,
env,
leaflet,
cleanup() {
globalThis.fetch = previousFetch;
globalThis.window.L = previousWindowL;
globalThis.L = previousGlobalL;
env.cleanup();
}
};
}
/**
* Mirror of {@link withApp} that uses the Leaflet-aware setup. Ensures the
* cleanup runs regardless of test outcome.
*
* @param {function({ testUtils: Object, leaflet: Object, env: Object }): void} fn
* Test body.
* @param {Object} [opts] Forwarded to {@link setupAppWithLeaflet}.
*/
export function withAppAndLeaflet(fn, opts = {}) {
const harness = setupAppWithLeaflet(opts);
try {
fn({ testUtils: harness.testUtils, leaflet: harness.leaflet, env: harness.env });
} finally {
harness.cleanup();
}
}
@@ -18,6 +18,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { withApp } from './main-app-test-helpers.js';
import { withAppAndLeaflet } from './main-app-leaflet-stub.js';
/**
* Build a stub Leaflet ``L`` that implements ``point({x, y})``. The renderer
@@ -233,3 +234,600 @@ test('_setColocatedSpiderStateForTests returns the previous state and rejects no
assert.deepEqual(t._getColocatedSpiderStateForTests(), []);
});
});
/**
* Build a stub Leaflet map that reports a configurable zoom level. The
* other Leaflet-projection methods are kept identical to ``makeStubMap`` so
* the helper composes with existing harness shapes that exercise both
* ``getZoom`` and the projection.
*
* @param {number} zoom Zoom level to report from ``getZoom()``.
* @returns {Object} Stub map.
*/
function makeStubMapAtZoom(zoom) {
const base = makeStubMap();
base.getZoom = () => zoom;
return base;
}
test('currentZoomBucket returns "low" below the threshold and "high" at/above', () => {
withApp((t) => {
// No map injected → defensive default keeps the feature visible so the
// test harness behaves identically to today's no-Leaflet path.
assert.equal(t._currentZoomBucketForTests(), 'high');
t._setMapForTests(makeStubMapAtZoom(12));
assert.equal(t._currentZoomBucketForTests(), 'low');
t._setMapForTests(makeStubMapAtZoom(13));
assert.equal(t._currentZoomBucketForTests(), 'high');
t._setMapForTests(makeStubMapAtZoom(18));
assert.equal(t._currentZoomBucketForTests(), 'high');
// Non-finite zoom (e.g. before the projection is ready) must not flip
// the user into the low-zoom branch — fall back to 'high' so the
// current rendering remains usable.
t._setMapForTests(makeStubMapAtZoom(Number.NaN));
assert.equal(t._currentZoomBucketForTests(), 'high');
// Map without a getZoom method (e.g. a stub used purely for projection
// round-trips) is also treated as 'high' rather than throwing.
t._setMapForTests({});
assert.equal(t._currentZoomBucketForTests(), 'high');
t._setMapForTests(null);
});
});
test('handleZoomEndForColocatedHubs clears expanded keys when crossing the threshold', () => {
withApp((t) => {
// Pre-stage state as if the previous render was at high zoom with one
// group expanded; a zoomend that drops us below the threshold should
// erase that state. No fetch wrapper is needed because the new
// ``rerenderMapForFiltering`` helper called by the threshold-cross
// handler does not run the stats-fetch pipeline.
t._setLastRenderedZoomBucketForTests('high');
const seeded = new Set(['10.00000,20.00000']);
t._setExpandedColocatedKeysForTests(seeded);
t._setMapForTests(makeStubMapAtZoom(12));
t.handleZoomEndForColocatedHubs();
assert.equal(t._getExpandedColocatedKeysForTests().size, 0);
t._setMapForTests(null);
});
});
test('handleZoomEndForColocatedHubs leaves expanded keys alone when bucket is unchanged', () => {
withApp((t) => {
// Same bucket as the last render → no clear, no applyFilter side effect.
t._setLastRenderedZoomBucketForTests('high');
const seeded = new Set(['1.00000,2.00000']);
t._setExpandedColocatedKeysForTests(seeded);
t._setMapForTests(makeStubMapAtZoom(15));
t.handleZoomEndForColocatedHubs();
assert.equal(t._getExpandedColocatedKeysForTests(), seeded);
assert.ok(seeded.has('1.00000,2.00000'));
t._setMapForTests(null);
});
});
test('handleZoomEndForColocatedHubs handles zooming back up through the threshold', () => {
withApp((t) => {
// Previous render was low; zoom back up to high → expanded keys are
// (already) empty per the prior crossing, but the bucket flip must
// still register so subsequent clicks behave correctly.
t._setLastRenderedZoomBucketForTests('low');
t._setExpandedColocatedKeysForTests(new Set());
t._setMapForTests(makeStubMapAtZoom(14));
assert.doesNotThrow(() => t.handleZoomEndForColocatedHubs());
t._setMapForTests(null);
});
});
test('createColocatedHubMarker emits "*<count>" html and toggles expansion on click', () => {
withApp((t) => {
const previousL = globalThis.L;
const created = [];
let domEventStopCalls = 0;
let lastClickHandler = null;
globalThis.L = {
divIcon(opts) {
return { _kind: 'divIcon', options: opts };
},
marker(latLng, opts) {
const marker = {
latLng,
options: opts,
_addedTo: null,
on(event, handler) {
if (event === 'click') lastClickHandler = handler;
return marker;
},
addTo(layer) {
marker._addedTo = layer;
layer._children.push(marker);
return marker;
}
};
created.push(marker);
return marker;
},
DomEvent: {
stopPropagation() {
domEventStopCalls += 1;
}
}
};
const stubLayer = { _children: [] };
t._setColocatedHubsLayerForTests(stubLayer);
try {
// Reset the icon cache so this test's stub L is the source of every
// divIcon rather than a previous run's plain-object icon.
t._getColocatedHubIconCacheForTests().clear();
const result = t.createColocatedHubMarker('5.12345,6.54321', 4, 5.12345, 6.54321);
assert.equal(created.length, 1);
assert.equal(result, created[0]);
assert.deepEqual(result.latLng, [5.12345, 6.54321]);
// The divIcon receives the asterisk + count html and the spider hub
// class so the CSS rules in base.css can style it as a clickable badge.
const iconOptions = result.options.icon.options;
assert.equal(iconOptions.className, 'colocated-spider-hub');
assert.ok(/\*4</.test(iconOptions.html), `html ${iconOptions.html} should contain *4`);
assert.deepEqual(iconOptions.iconSize, [16, 16]);
assert.deepEqual(iconOptions.iconAnchor, [8, 8]);
// ``bubblingMouseEvents: false`` keeps Leaflet's internal event
// routing from forwarding the click to map-level handlers. The
// ``riseOnHover`` option is intentionally absent because divIcon
// markers handle z-index inconsistently across Leaflet versions.
assert.equal(result.options.bubblingMouseEvents, false);
assert.equal(result.options.riseOnHover, undefined);
// Marker was added to the injected hub layer rather than the global
// markers layer; this keeps hub badges in their own clearable group.
assert.equal(result._addedTo, stubLayer);
assert.equal(stubLayer._children.length, 1);
// Click → expandedColocatedKeys flips, both Leaflet's DomEvent
// helper and the raw DOM stopPropagation are invoked so the click
// is contained at every layer of the event pipeline.
let stopPropagationCalls = 0;
assert.ok(lastClickHandler);
lastClickHandler({
originalEvent: { stopPropagation() { stopPropagationCalls += 1; } }
});
assert.equal(stopPropagationCalls, 1);
assert.equal(domEventStopCalls, 1);
assert.ok(t._getExpandedColocatedKeysForTests().has('5.12345,6.54321'));
// Second click toggles back off.
lastClickHandler({
originalEvent: { stopPropagation() { stopPropagationCalls += 1; } }
});
assert.equal(stopPropagationCalls, 2);
assert.equal(domEventStopCalls, 2);
assert.equal(t._getExpandedColocatedKeysForTests().has('5.12345,6.54321'), false);
// A click without an originalEvent (or without stopPropagation) must
// still toggle without throwing — covers the defensive guard branch.
assert.doesNotThrow(() => lastClickHandler(undefined));
assert.ok(t._getExpandedColocatedKeysForTests().has('5.12345,6.54321'));
assert.doesNotThrow(() => lastClickHandler({ originalEvent: {} }));
} finally {
t._setColocatedHubsLayerForTests(null);
t._setExpandedColocatedKeysForTests(new Set());
t._getColocatedHubIconCacheForTests().clear();
globalThis.L = previousL;
}
});
});
test('_setExpandedColocatedKeysForTests round-trips and rejects non-Set input', () => {
withApp((t) => {
// Initial state from init: empty Set.
const initial = t._setExpandedColocatedKeysForTests(new Set(['a']));
assert.ok(initial instanceof Set);
assert.equal(initial.size, 0);
const live = t._getExpandedColocatedKeysForTests();
assert.ok(live.has('a'));
// Non-Set input replaces the live set with a fresh empty Set, returning
// the previous (now-stale) reference for the test to inspect.
const previous = t._setExpandedColocatedKeysForTests('not-a-set');
assert.equal(previous.size, 1);
assert.equal(t._getExpandedColocatedKeysForTests().size, 0);
});
});
test('_setColocatedHubsLayerForTests round-trips the hub layer reference', () => {
withApp((t) => {
const initial = t._setColocatedHubsLayerForTests('layer-a');
// Initial value is null because the harness never instantiates Leaflet.
assert.equal(initial, null);
assert.equal(t._getColocatedHubsLayerForTests(), 'layer-a');
const previous = t._setColocatedHubsLayerForTests(null);
assert.equal(previous, 'layer-a');
assert.equal(t._getColocatedHubsLayerForTests(), null);
});
});
test('_setLastRenderedZoomBucketForTests round-trips the bucket marker', () => {
withApp((t) => {
const initial = t._setLastRenderedZoomBucketForTests('high');
// Initial value is null because no render has yet captured a bucket.
assert.equal(initial, null);
assert.equal(t._getLastRenderedZoomBucketForTests(), 'high');
const previous = t._setLastRenderedZoomBucketForTests('low');
assert.equal(previous, 'high');
assert.equal(t._getLastRenderedZoomBucketForTests(), 'low');
});
});
/**
* Build a list of nodes that share an identical coordinate so the renderer
* can group them. Each node carries a unique ``node_id`` to satisfy the
* deterministic-slot ordering inside ``computeColocatedOffsets``.
*
* @param {number} count Number of nodes to generate.
* @param {number} [lat=50] Shared latitude.
* @param {number} [lon=10] Shared longitude.
* @param {Object} [extra] Optional extra fields merged into each node.
* @returns {Array<Object>} Nodes ready to feed into ``renderMap``.
*/
function makeColocatedNodes(count, lat = 50, lon = 10, extra = {}) {
const nodes = [];
for (let i = 0; i < count; i += 1) {
nodes.push({
node_id: `node-${i}`,
latitude: lat,
longitude: lon,
role: 'CLIENT',
protocol: 'meshtastic',
...extra
});
}
return nodes;
}
/**
* Count how many drawn objects in ``recorded`` ended up inside a particular
* layer group. ``recorded`` is the running history of every Leaflet object
* the stub created during the test, while ``layer._layers`` reflects only
* the ones still mounted (after ``clearLayers``). Filtering by both keeps
* the assertions stable across re-renders.
*
* @param {Array<Object>} recorded Array such as ``leaflet._recorded.circleMarkers``.
* @param {Object} layer Layer group whose ``_layers`` array tracks current mounts.
* @returns {number} Count of recorded items currently mounted on the layer.
*/
function countLayerMembers(recorded, layer) {
if (!layer || !Array.isArray(layer._layers)) return 0;
return recorded.filter(item => layer._layers.includes(item)).length;
}
test('renderMap renders flat overlap at zoom < COLOCATED_HUB_MIN_ZOOM', () => {
withAppAndLeaflet(({ testUtils, leaflet }) => {
leaflet._map._setZoom(12);
leaflet._recorded.circleMarkers.length = 0;
leaflet._recorded.markers.length = 0;
leaflet._recorded.polylines.length = 0;
const nodes = makeColocatedNodes(3);
testUtils.renderMap(nodes, 0);
const hubLayer = testUtils._getColocatedHubsLayerForTests();
// Below the threshold every node renders as a normal circleMarker at
// its original coordinate; no hub badge is created and no leader lines
// are drawn. This is the "spider disabled" mode that the user asked
// for when the map is fully zoomed out.
assert.equal(hubLayer._layers.length, 0);
assert.equal(leaflet._recorded.markers.length, 0);
assert.equal(leaflet._recorded.circleMarkers.length, 3);
assert.equal(leaflet._recorded.polylines.length, 0);
// Markers stack at exactly the original coords (no projection round-trip).
for (const marker of leaflet._recorded.circleMarkers) {
assert.deepEqual(marker._latLng, [50, 10]);
}
// The cached zoom-bucket reflects what the render targeted, so the
// zoomend handler can detect a future bucket flip.
assert.equal(testUtils._getLastRenderedZoomBucketForTests(), 'low');
});
});
test('renderMap renders a collapsed hub at zoom ≥ COLOCATED_HUB_MIN_ZOOM', () => {
withAppAndLeaflet(({ testUtils, leaflet }) => {
leaflet._map._setZoom(14);
leaflet._recorded.circleMarkers.length = 0;
leaflet._recorded.markers.length = 0;
leaflet._recorded.polylines.length = 0;
const nodes = makeColocatedNodes(3);
testUtils.renderMap(nodes, 0);
const hubLayer = testUtils._getColocatedHubsLayerForTests();
// Default state at high zoom is collapsed: a single hub badge replaces
// the three member markers, no leader lines are drawn, and the badge
// html carries the asterisk + count so the user can read the group
// size at a glance.
assert.equal(hubLayer._layers.length, 1);
assert.equal(leaflet._recorded.markers.length, 1);
assert.equal(leaflet._recorded.circleMarkers.length, 0);
assert.equal(leaflet._recorded.polylines.length, 0);
const hub = leaflet._recorded.markers[0];
assert.deepEqual(hub._latLng, [50, 10]);
assert.ok(/\*3</.test(hub.options.icon.options.html));
assert.equal(testUtils._getLastRenderedZoomBucketForTests(), 'high');
});
});
test('renderMap dedups the hub badge across the slots in a single group', () => {
withAppAndLeaflet(({ testUtils, leaflet }) => {
leaflet._map._setZoom(14);
leaflet._recorded.markers.length = 0;
// Five colocated nodes would yield five offset slots; the renderer must
// still create exactly one hub for the group rather than emitting one
// per slot. This exercises the ``renderedHubKeys`` dedup guard.
const nodes = makeColocatedNodes(5);
testUtils.renderMap(nodes, 0);
const hubLayer = testUtils._getColocatedHubsLayerForTests();
assert.equal(hubLayer._layers.length, 1);
assert.equal(leaflet._recorded.markers.length, 1);
assert.ok(/\*5</.test(leaflet._recorded.markers[0].options.icon.options.html));
});
});
test('renderMap renders a singleton as a normal marker (no hub) at any zoom', () => {
withAppAndLeaflet(({ testUtils, leaflet }) => {
leaflet._map._setZoom(14);
leaflet._recorded.circleMarkers.length = 0;
leaflet._recorded.markers.length = 0;
const nodes = makeColocatedNodes(1, 1, 2);
testUtils.renderMap(nodes, 0);
const hubLayer = testUtils._getColocatedHubsLayerForTests();
assert.equal(hubLayer._layers.length, 0);
assert.equal(leaflet._recorded.markers.length, 0);
assert.equal(leaflet._recorded.circleMarkers.length, 1);
assert.deepEqual(leaflet._recorded.circleMarkers[0]._latLng, [1, 2]);
});
});
test('renderMap fans out members and draws leader lines when a group is expanded', () => {
withAppAndLeaflet(({ testUtils, leaflet }) => {
leaflet._map._setZoom(14);
leaflet._recorded.circleMarkers.length = 0;
leaflet._recorded.markers.length = 0;
leaflet._recorded.polylines.length = 0;
// Pre-stage the group as expanded so the renderer takes the (c) branch
// — the user already clicked the hub. The key matches the format
// ``computeColocatedOffsets`` produces at the default precision.
testUtils._setExpandedColocatedKeysForTests(new Set(['50.00000,10.00000']));
const nodes = makeColocatedNodes(3);
testUtils.renderMap(nodes, 0);
const hubLayer = testUtils._getColocatedHubsLayerForTests();
// Expanded mode: 1 hub still visible (the click affordance) + 3 member
// markers fanned out + 3 leader polylines.
assert.equal(hubLayer._layers.length, 1);
assert.equal(leaflet._recorded.markers.length, 1);
assert.equal(leaflet._recorded.circleMarkers.length, 3);
assert.equal(leaflet._recorded.polylines.length, 3);
// The spider state has one entry per fanned member so the zoomend hook
// can re-project them when the user keeps zooming.
assert.equal(testUtils._getColocatedSpiderStateForTests().length, 3);
});
});
test('renderMap prunes expandedColocatedKeys whose group has shrunk below 2', () => {
withAppAndLeaflet(({ testUtils, leaflet }) => {
leaflet._map._setZoom(14);
// Pre-stage a stale expansion key whose group will not exist in this
// render. After the render the key must be evicted so subsequent
// clicks at the same coordinate start collapsed.
testUtils._setExpandedColocatedKeysForTests(new Set(['99.00000,99.00000', '50.00000,10.00000']));
const nodes = makeColocatedNodes(1);
testUtils.renderMap(nodes, 0);
const live = testUtils._getExpandedColocatedKeysForTests();
assert.equal(live.has('99.00000,99.00000'), false, 'vanished group key was not pruned');
assert.equal(live.has('50.00000,10.00000'), false, 'shrunken group key was not pruned');
});
});
test('renderMap distance-filter regression: hub html reflects visible count', () => {
withAppAndLeaflet(({ testUtils, leaflet }) => {
leaflet._map._setZoom(14);
leaflet._recorded.markers.length = 0;
const nodes = makeColocatedNodes(4);
nodes[0].distance_km = 9999;
testUtils.renderMap(nodes, 0);
assert.equal(leaflet._recorded.markers.length, 1);
assert.ok(/\*3</.test(leaflet._recorded.markers[0].options.icon.options.html));
}, { configOverrides: { maxDistanceKm: 100 } });
});
test('renderMap re-renders preserve expansion across data refreshes', () => {
withAppAndLeaflet(({ testUtils, leaflet }) => {
leaflet._map._setZoom(14);
testUtils._setExpandedColocatedKeysForTests(new Set(['50.00000,10.00000']));
const nodes = makeColocatedNodes(3);
testUtils.renderMap(nodes, 0);
// First render produced 3 fanned markers; a second render with the
// same data must keep the expansion (i.e. re-emit 3 fanned markers
// rather than collapsing back to a hub-only state).
leaflet._recorded.circleMarkers.length = 0;
leaflet._recorded.markers.length = 0;
leaflet._recorded.polylines.length = 0;
testUtils.renderMap(nodes, 0);
assert.equal(leaflet._recorded.circleMarkers.length, 3);
assert.equal(leaflet._recorded.markers.length, 1);
assert.equal(leaflet._recorded.polylines.length, 3);
assert.ok(testUtils._getExpandedColocatedKeysForTests().has('50.00000,10.00000'));
});
});
test('hub click invokes Leaflet stopPropagation through the live harness', () => {
withAppAndLeaflet(({ testUtils, leaflet }) => {
leaflet._map._setZoom(14);
const nodes = makeColocatedNodes(2);
testUtils.renderMap(nodes, 0);
// The hub badge created during renderMap is a regular Leaflet marker;
// its click handler should stop the event at both the Leaflet and DOM
// layers. Firing the registered click handler directly emulates a
// user click without needing a real DOM event.
const hub = leaflet._recorded.markers[0];
const handlers = hub._eventHandlers.get('click') || [];
assert.equal(handlers.length, 1);
const baselineDomEventCount = leaflet._recorded.domEventStopPropagation;
let stopPropagationCalls = 0;
handlers[0]({
originalEvent: { stopPropagation() { stopPropagationCalls += 1; } }
});
// The click handler must contain the event at both pipeline layers so
// the underlying overlayStack / map ``click`` handlers are not also
// notified. ``rerenderMapForFiltering`` then triggers a second
// renderMap cycle that re-evaluates the dispatch — but with the
// harness's empty ``allNodes`` the new render produces zero offsets,
// so the pruning step sees no surviving multi-node groups. We assert
// on the stopPropagation side effects rather than the post-render
// expansion state because the latter is correctly cleaned up by the
// pruning logic.
assert.equal(stopPropagationCalls, 1);
assert.equal(leaflet._recorded.domEventStopPropagation, baselineDomEventCount + 1);
});
});
test('hub click does not trigger an /api/stats fetch (surgical re-render)', () => {
withAppAndLeaflet(({ testUtils, leaflet }) => {
leaflet._map._setZoom(14);
const nodes = makeColocatedNodes(2);
testUtils.renderMap(nodes, 0);
// Replace the harness's never-resolving fetch with a counter so we can
// observe whether the click handler accidentally invokes it via the
// old ``applyFilter`` path. Capture the previous reference so the
// ``cleanup`` from withAppAndLeaflet can still restore it.
let fetchCalls = 0;
const previousFetch = globalThis.fetch;
globalThis.fetch = () => {
fetchCalls += 1;
return new Promise(() => {});
};
try {
const hub = leaflet._recorded.markers[0];
const handler = (hub._eventHandlers.get('click') || [])[0];
assert.ok(handler);
handler({ originalEvent: { stopPropagation() {} } });
// ``rerenderMapForFiltering`` only calls renderMap; the stats fetch
// that ``applyFilter`` used to issue should not have been triggered.
assert.equal(fetchCalls, 0);
} finally {
globalThis.fetch = previousFetch;
}
});
});
test('renderMap reuses a single divIcon instance across same-size groups', () => {
withAppAndLeaflet(({ testUtils, leaflet }) => {
leaflet._map._setZoom(14);
leaflet._recorded.markers.length = 0;
leaflet._recorded.divIcons.length = 0;
testUtils._getColocatedHubIconCacheForTests().clear();
// Two distinct groups of size 3 at different coordinates. The dispatch
// emits one hub per group, so this exercises the icon cache *within*
// a single render: the second hub should pick up the cached icon
// rather than allocating a new ``L.divIcon``.
const nodes = [
...makeColocatedNodes(3, 50, 10),
...makeColocatedNodes(3, 51, 11)
].map((n, i) => ({ ...n, node_id: `dup-${i}` }));
testUtils.renderMap(nodes, 0);
assert.equal(leaflet._recorded.markers.length, 2, 'expected one hub per group');
assert.equal(leaflet._recorded.divIcons.length, 1, 'expected exactly one divIcon allocation across both hubs');
assert.equal(
leaflet._recorded.markers[0].options.icon,
leaflet._recorded.markers[1].options.icon,
'both hubs should share the cached icon instance'
);
const cache = testUtils._getColocatedHubIconCacheForTests();
assert.equal(cache.size, 1);
assert.ok(cache.has(3));
});
});
test('renderMap reuses divIcons across re-renders', () => {
withAppAndLeaflet(({ testUtils, leaflet }) => {
leaflet._map._setZoom(14);
testUtils._getColocatedHubIconCacheForTests().clear();
const nodes = makeColocatedNodes(4);
testUtils.renderMap(nodes, 0);
const firstIcon = leaflet._recorded.markers[0].options.icon;
leaflet._recorded.markers.length = 0;
leaflet._recorded.divIcons.length = 0;
testUtils.renderMap(nodes, 0);
// Second render reuses the cached size-4 icon — no new divIcon
// allocation, and the new hub points at the same instance as before.
assert.equal(leaflet._recorded.divIcons.length, 0);
assert.equal(leaflet._recorded.markers[0].options.icon, firstIcon);
});
});
test('rerenderMapForFiltering refreshes the map without the applyFilter side effects', () => {
withAppAndLeaflet(({ testUtils, leaflet }) => {
leaflet._map._setZoom(14);
leaflet._recorded.markers.length = 0;
leaflet._recorded.divIcons.length = 0;
let fetchCalls = 0;
const previousFetch = globalThis.fetch;
globalThis.fetch = () => {
fetchCalls += 1;
return new Promise(() => {});
};
try {
// ``rerenderMapForFiltering`` reads ``allNodes`` directly; the test
// harness leaves it empty (no /api/nodes resolution), so the call
// exercises the early-return branches inside renderMap rather than
// a full render. The point of this test is the *absence* of side
// effects: no stats fetch, no thrown errors.
assert.doesNotThrow(() => testUtils.rerenderMapForFiltering());
assert.equal(fetchCalls, 0);
} finally {
globalThis.fetch = previousFetch;
}
});
});
test('_getColocatedHubIconCacheForTests exposes the live cache', () => {
// Use the Leaflet-aware harness so ``L.divIcon`` exists when the helper
// is invoked; the bare ``withApp`` harness leaves L undefined.
withAppAndLeaflet(({ testUtils }) => {
const cache = testUtils._getColocatedHubIconCacheForTests();
assert.ok(cache instanceof Map);
cache.clear();
assert.equal(cache.size, 0);
// Populating via ``getColocatedHubIcon`` proves the seam returns the
// same Map instance the production helper writes to.
const icon = testUtils.getColocatedHubIcon(7);
assert.equal(cache.get(7), icon);
assert.equal(testUtils.getColocatedHubIcon(7), icon, 'second lookup must hit the cache');
cache.clear();
});
});
test('renderMap places fanned markers around the shared centre when expanded', () => {
withAppAndLeaflet(({ testUtils, leaflet }) => {
leaflet._map._setZoom(14);
testUtils._setExpandedColocatedKeysForTests(new Set(['50.00000,10.00000']));
leaflet._recorded.circleMarkers.length = 0;
const nodes = makeColocatedNodes(2);
testUtils.renderMap(nodes, 0);
// The two fanned slots sit on opposite sides of the original centre at
// the configured base radius. The stub uses an identity projection
// ([lat, lon] → {x: lon, y: lat}), so the offset markers' coordinates
// differ from the centre by exactly ``baseRadiusPx`` (after the recent
// halving: 7px) along the X axis for the first slot.
assert.equal(leaflet._recorded.circleMarkers.length, 2);
// ``projectColocatedOffsetLatLng`` returns a ``[lat, lng]`` array, so
// each ``_latLng`` here is a tuple rather than a Leaflet LatLng object.
const offsets = leaflet._recorded.circleMarkers.map(m =>
Math.hypot(m._latLng[1] - 10, m._latLng[0] - 50)
);
for (const distance of offsets) {
assert.ok(distance > 0, `offset distance ${distance} should be > 0`);
assert.ok(Math.abs(distance - 7) < 1e-9, `offset distance ${distance} should match the halved base radius`);
}
});
});
@@ -235,6 +235,35 @@ test('entries without node_id still receive deterministic slots', () => {
}
});
test('grouped slots expose groupKey and groupSize', () => {
// Three colocated entries → every slot reports the same coordinate-derived
// bucket key (matching coordinateKey at the default precision) and the full
// membership count. This is what the renderer keys expand/collapse state
// off of, so a regression here would silently desync the hub interaction.
const entries = [
makeEntry('a', 10, 20),
makeEntry('b', 10, 20),
makeEntry('c', 10, 20)
];
const result = computeColocatedOffsets(entries);
const expectedKey = coordinateKey(10, 20, DEFAULT_PRECISION);
for (const slot of result) {
assert.equal(slot.groupKey, expectedKey);
assert.equal(slot.groupSize, 3);
}
});
test('singleton slots still report a stable groupKey and groupSize of 1', () => {
// Singletons need a groupKey too: the renderer treats every result row
// uniformly when pruning expand/collapse state, so even non-grouped points
// must carry a non-empty key matching their rounded coordinate.
const entries = [makeEntry('solo', 12.34567, 89.01234)];
const result = computeColocatedOffsets(entries);
assert.equal(result.length, 1);
assert.equal(result[0].groupSize, 1);
assert.equal(result[0].groupKey, coordinateKey(12.34567, 89.01234, DEFAULT_PRECISION));
});
test('coordinateKey formats lat/lon at requested precision', () => {
assert.equal(coordinateKey(1.234567, 7.654321, 3), '1.235,7.654');
assert.equal(coordinateKey(0, 0, DEFAULT_PRECISION), '0.00000,0.00000');
+324 -26
View File
@@ -509,6 +509,15 @@ export function initializeApp(config) {
const INITIAL_VIEW_PADDING_PX = 12;
const AUTO_FIT_PADDING_PX = 12;
const MAX_INITIAL_ZOOM = 13;
// Below this zoom level the co-located spider feature is disabled
// entirely: markers stack at their shared coordinate (no fan, no leader
// lines, no hub badge). At or above it, multi-node groups collapse into
// a single hub badge that the user can click to expand into the spider.
// Intentionally aligned with ``MAX_INITIAL_ZOOM`` above so the auto-fit
// initial view (which clamps at zoom 13) lands directly on the bucket
// boundary and users see the hub representation as soon as the map is
// ready rather than after their first zoom-in interaction.
const COLOCATED_HUB_MIN_ZOOM = 13;
let neighborLinesLayer = null;
let traceLinesLayer = null;
let neighborLinesVisible = true;
@@ -527,6 +536,27 @@ export function initializeApp(config) {
// into a single refresh; reset to ``null`` once the scheduled callback
// runs so the next frame can schedule again.
let pendingSpiderRefreshHandle = null;
// Leaflet layer that holds the small "asterisk + count" hub badges that
// collapse co-located groups at zoom levels at or above
// ``COLOCATED_HUB_MIN_ZOOM``. Initialised alongside the other map layers
// and cleared on every render before being re-populated.
let colocatedHubsLayer = null;
// Bucket keys (as returned by ``computeColocatedOffsets``) for groups the
// user has explicitly clicked open. Hubs whose key is in the set render
// their members fanned out + leader lines; absent keys render the hub
// alone. Cleared whenever the map crosses the zoom threshold so the
// collapsed default is restored when the visual context changes.
let expandedColocatedKeys = new Set();
// Tracks whether the most recent render was below or at/above the zoom
// threshold so the ``zoomend`` handler can detect threshold crossings and
// trigger a re-render that swaps the hub representation in or out.
let lastRenderedZoomBucket = null;
// Cache of divIcon instances keyed by group size. Building an icon for
// every multi-node group on every render is expensive at scale (hundreds
// of nodes can produce dozens of hubs); since the icon's html only varies
// by groupSize we share a single instance across same-size groups and
// across renders. See ``getColocatedHubIcon`` for the lookup.
const colocatedHubIconCache = new Map();
let tileDomObserver = null;
const fullscreenChangeEvents = [
'fullscreenchange',
@@ -1332,6 +1362,10 @@ export function initializeApp(config) {
// but never sit on top of the marker glyphs themselves.
spiderLinesLayer = L.layerGroup().addTo(map);
markersLayer = L.layerGroup().addTo(map);
// Hub badges render on top of the marker glyphs so the click target is
// always reachable, even when a stale marker happens to share the exact
// pixel coordinate of the hub centre.
colocatedHubsLayer = L.layerGroup().addTo(map);
// Pixel-space offsets are baked into a LatLng at render time, so the
// on-screen spread would otherwise scale with zoom — at extreme zoom-outs
@@ -1341,9 +1375,11 @@ export function initializeApp(config) {
// through `requestAnimationFrame` to coalesce redundant updates into a
// single redraw per frame; `zoomend` snaps to the final position; and
// `viewreset` covers projection resets such as resize / fullscreen /
// dateline wrap.
// dateline wrap. ``zoomend`` additionally watches for crossings of
// ``COLOCATED_HUB_MIN_ZOOM`` and re-runs ``applyFilter`` so the marker
// representation switches between flat / hub modes.
map.on('zoom', scheduleColocatedSpiderRefresh);
map.on('zoomend', refreshColocatedSpiderState);
map.on('zoomend', handleZoomEndForColocatedHubs);
map.on('viewreset', refreshColocatedSpiderState);
if (typeof navigator !== 'undefined' && navigator && navigator.onLine === false) {
@@ -4077,6 +4113,138 @@ export function initializeApp(config) {
});
}
/**
* Classify the current zoom level relative to ``COLOCATED_HUB_MIN_ZOOM``.
*
* Returns ``'low'`` when the user is zoomed out far enough that the
* collapsed-hub representation should not be drawn (markers stack at the
* shared coordinate instead) and ``'high'`` otherwise. Defaults to
* ``'high'`` when the map is missing or its ``getZoom`` returns a
* non-finite value, which preserves the pre-feature behaviour during
* early init / tests where the projection is not yet available.
*
* @returns {'low'|'high'} Bucket name for the current zoom level.
*/
function currentZoomBucket() {
if (!map || typeof map.getZoom !== 'function') return 'high';
const zoom = map.getZoom();
if (!Number.isFinite(zoom)) return 'high';
return zoom < COLOCATED_HUB_MIN_ZOOM ? 'low' : 'high';
}
/**
* Wired to the map's ``zoomend`` event in addition to the spider
* re-projection. When the user crosses the
* ``COLOCATED_HUB_MIN_ZOOM`` threshold in either direction we forget the
* previously-expanded hub state and trigger a full re-render through
* {@link applyFilter}, since the marker representation switches between
* "flat overlap" and "hub badge" modes.
*
* @returns {void}
*/
function handleZoomEndForColocatedHubs() {
refreshColocatedSpiderState();
const bucket = currentZoomBucket();
if (bucket !== lastRenderedZoomBucket) {
expandedColocatedKeys.clear();
// Bucket flips only swap the marker representation; the node table,
// chat log, and active-stats counts are unaffected, so we re-render
// just the map rather than running the full applyFilter pipeline.
rerenderMapForFiltering();
}
}
/**
* Build the small "asterisk + count" hub badge that represents a collapsed
* (or expanded-but-still-visible) co-located group. The badge is a
* Leaflet ``L.marker`` backed by an ``L.divIcon`` so the visual is HTML/CSS
* (themable via ``var(--fg)`` / ``var(--bg)``) rather than the SVG
* ``L.circleMarker`` used for node points.
*
* Clicking the hub toggles ``expandedColocatedKeys`` for ``groupKey`` and
* triggers a full re-render via {@link applyFilter}. The hub deliberately
* does NOT participate in the node-info overlay — it is a control rather
* than a node anchor — so the click handler stops propagation to keep the
* ``overlayStack`` close path from also firing.
*
* @param {string} groupKey Bucket key from {@link computeColocatedOffsets}.
* @param {number} groupSize Number of (visible) nodes in the group.
* @param {number} lat Latitude of the shared centre.
* @param {number} lon Longitude of the shared centre.
* @returns {Object} The created Leaflet marker, already added to the layer.
*/
function createColocatedHubMarker(groupKey, groupSize, lat, lon) {
// ``bubblingMouseEvents: false`` keeps Leaflet's internal event system
// from forwarding the click to the map and any registered map-level
// ``click`` handlers (e.g. overlay close). ``riseOnHover`` is omitted
// intentionally — it is documented for the default raster icon's
// z-index handling and behaves inconsistently with ``divIcon`` across
// Leaflet versions; layer ordering (``colocatedHubsLayer`` is added
// *after* ``markersLayer``) keeps the hub on top reliably.
const marker = L.marker([lat, lon], {
icon: getColocatedHubIcon(groupSize),
keyboard: false,
bubblingMouseEvents: false
});
marker.on('click', event => {
// Stop the Leaflet event from bubbling to the map's own click handlers
// and stop the raw DOM event so the ``overlayStack`` close path does
// not also fire. We use the Leaflet helper rather than only the raw
// DOM stopPropagation because Leaflet routes events through its own
// pipeline before the browser does.
if (event && L && L.DomEvent && typeof L.DomEvent.stopPropagation === 'function') {
L.DomEvent.stopPropagation(event);
}
if (event && event.originalEvent && typeof event.originalEvent.stopPropagation === 'function') {
event.originalEvent.stopPropagation();
}
if (expandedColocatedKeys.has(groupKey)) {
expandedColocatedKeys.delete(groupKey);
} else {
expandedColocatedKeys.add(groupKey);
}
// Surgical re-render: only the map's marker representation changed.
// The table, chat log, and active-stats counts stay valid, so we skip
// the full applyFilter pipeline (and its ``/api/stats`` fetch) — that
// saves a round-trip per click and keeps rapid expand/collapse cheap.
rerenderMapForFiltering();
});
marker.addTo(colocatedHubsLayer);
return marker;
}
/**
* Lookup or lazily create the divIcon used to render a hub badge for a
* group of ``groupSize`` co-located nodes. Icons are cached because
* Leaflet allows the same icon instance to be shared across markers, the
* underlying DOM element is cloned per marker, and ``L.divIcon`` itself
* is non-trivially expensive at the volumes this feature can produce
* (every render creates one icon per multi-node group). The cache is
* keyed by ``groupSize`` because the html string is the only thing that
* varies between groups; it is bounded in practice (typical group sizes
* are small single digits) so it never grows large enough to warrant
* eviction.
*
* Theme changes do not invalidate the cache: the icon's html only carries
* the static text label and class hooks; all colour / border styling
* comes from CSS variables that resolve at paint time.
*
* @param {number} groupSize Number of visible nodes in the group.
* @returns {Object} Cached or freshly-created Leaflet divIcon.
*/
function getColocatedHubIcon(groupSize) {
const cached = colocatedHubIconCache.get(groupSize);
if (cached) return cached;
const icon = L.divIcon({
html: '<span class="colocated-spider-hub__glyph">*' + groupSize + '</span>',
className: 'colocated-spider-hub',
iconSize: [16, 16],
iconAnchor: [8, 8]
});
colocatedHubIconCache.set(groupSize, icon);
return icon;
}
/**
* Render the Leaflet map markers and neighbour connections.
*
@@ -4097,9 +4265,15 @@ export function initializeApp(config) {
if (spiderLinesLayer) {
spiderLinesLayer.clearLayers();
}
if (colocatedHubsLayer) {
colocatedHubsLayer.clearLayers();
}
// Drop the previous render's spider records before populating them again
// so the zoom handler does not try to reposition stale Leaflet objects.
colocatedSpiderState = [];
// Capture the zoom bucket the upcoming render targets so the zoomend
// handler can detect threshold crossings on the next zoom event.
lastRenderedZoomBucket = currentZoomBucket();
markersLayer.clearLayers();
const pts = [];
const nodesById = new Map();
@@ -4320,19 +4494,73 @@ export function initializeApp(config) {
});
const offsets = computeColocatedOffsets(renderableEntries);
for (const { entry, dx, dy } of offsets) {
// Build the set of bucket keys that currently host more than one visible
// node so we can drop stale entries from ``expandedColocatedKeys`` (a
// group that lost members to the distance filter, an upstream delete,
// etc.). Keys whose group has shrunk to a singleton are pruned here so
// the remaining slot renders as a normal marker rather than carrying an
// orphaned "expanded" flag.
const visibleMultiGroupKeys = new Set();
for (const slot of offsets) {
if (slot && slot.groupSize >= 2) visibleMultiGroupKeys.add(slot.groupKey);
}
// Snapshot the keys before mutating the live set: ``Set`` iteration
// during ``delete`` is technically safe per spec, but copying first
// makes the intent explicit and keeps the loop body straightforward
// for future maintainers.
for (const key of Array.from(expandedColocatedKeys)) {
if (!visibleMultiGroupKeys.has(key)) expandedColocatedKeys.delete(key);
}
const zoomBucket = currentZoomBucket();
// Each multi-node group emits a single hub badge. Track which keys we
// have already drawn so we create the hub once even though the offsets
// array yields one slot per member.
const renderedHubKeys = new Set();
for (const slot of offsets) {
const { entry, dx, dy, groupKey, groupSize } = slot;
const n = entry.node;
const { lat, lon } = entry;
const isMulti = groupSize >= 2;
const lowZoom = zoomBucket === 'low';
const isExpanded = isMulti && !lowZoom && expandedColocatedKeys.has(groupKey);
// Hub badges represent multi-node groups at zoom levels where the
// collapsed control is meaningful; below the threshold they would just
// sit in a sea of overlapping markers without conveying useful info.
const showHub = isMulti && !lowZoom;
// Singletons always render their marker; multi-node groups render
// member markers only when the user has expanded the hub (or when the
// zoom is below the threshold and we fall back to flat overlap).
const showMarker = !isMulti || lowZoom || isExpanded;
// Use the helper-level significance test (rather than strict !== 0)
// because trig at angles like π produces values around 1e-15 which
// would otherwise pass the strict check and cause us to draw
// zero-length spider lines.
const useOffset = isExpanded && isOffsetSignificant(dx, dy);
if (showHub && !renderedHubKeys.has(groupKey)) {
createColocatedHubMarker(groupKey, groupSize, lat, lon);
renderedHubKeys.add(groupKey);
}
// Auto-fit bounds always use the original coordinate so the
// collapse/expand state cannot widen or narrow the fit window. Push
// here even when the underlying marker is suppressed so a fully
// collapsed group still contributes to the bounds.
pts.push([lat, lon]);
if (!showMarker) {
continue;
}
// Translate the pixel-space offset into the LatLng to render at. The
// baked-in LatLng is correct for the current zoom only; the zoom event
// handlers re-project on zoom/zoomend/viewreset to keep the gap
// visually constant when the user changes zoom. Use the helper-level
// significance test (rather than strict !== 0) because trig at angles
// like π produces values around 1e-15 which would otherwise pass the
// strict check and cause us to draw zero-length spider lines.
const markerLatLng = projectColocatedOffsetLatLng(lat, lon, dx, dy);
const isOffset = isOffsetSignificant(dx, dy);
// visually constant when the user changes zoom.
const markerLatLng = useOffset ? projectColocatedOffsetLatLng(lat, lon, dx, dy) : [lat, lon];
const color = getRoleColor(n.role, n.protocol);
const marker = L.circleMarker(markerLatLng, {
@@ -4344,14 +4572,14 @@ export function initializeApp(config) {
opacity: 0.7
});
// Draw a faint dotted leader line from each co-located marker back to
// Draw a faint dotted leader line from each fanned-out marker back to
// the shared physical location so the spider hub is visually obvious.
// Singleton markers (no offset) get no line. Stroke colour, dash,
// weight and opacity all live in `.colocated-spider-line` so the line
// can pick up theme-aware tokens (var(--fg)) and stay legible on both
// light and dark basemaps without code changes here.
// Singleton / collapsed / low-zoom markers get no line. Stroke
// colour, dash, weight and opacity all live in `.colocated-spider-line`
// so the line can pick up theme-aware tokens (var(--fg)) and stay
// legible on both light and dark basemaps without code changes here.
let spiderLine = null;
if (isOffset && spiderLinesLayer) {
if (useOffset && spiderLinesLayer) {
spiderLine = L.polyline([[lat, lon], markerLatLng], {
interactive: false,
className: 'colocated-spider-line'
@@ -4362,14 +4590,12 @@ export function initializeApp(config) {
let markerToken = 0;
marker.addTo(markersLayer);
// Track every offset marker so the zoomend handler can reposition the
// marker + leader line in lock-step. Singletons skip the record since
// their position never changes between zooms.
if (isOffset) {
// marker + leader line in lock-step. Markers rendered at the shared
// centre (singletons / low-zoom overlap / collapsed-group fallback)
// skip the record since their position never changes between zooms.
if (useOffset) {
colocatedSpiderState.push({ marker, line: spiderLine, lat, lon, dx, dy });
}
// Use the original coordinates for fitBounds so sub-pixel display
// offsets cannot widen the auto-fit window.
pts.push([lat, lon]);
attachNodeInfoRefreshToMarker({
marker,
@@ -4505,6 +4731,37 @@ export function initializeApp(config) {
return adjusted;
}
/**
* Re-run the active text/role/protocol filter pipeline over ``allNodes``
* and return the nodes that should currently render on the map and table.
* Pulled out of {@link applyFilter} so the colocated-hub click handler and
* the zoom-bucket-crossing handler can call it without paying for the
* table re-render, chat-log re-render, or stats fetch — none of which are
* affected by either of those events.
*
* @returns {Array<Object>} Filtered + sorted node list.
*/
function getFilteredSortedNodes() {
const filterQuery = filterInput ? filterInput.value : '';
const q = normaliseChatFilterQuery(filterQuery);
const filteredNodes = allNodes.filter(n => matchesTextFilter(n, q) && matchesRoleFilter(n) && matchesProtocolFilter(n));
return sortNodes(filteredNodes);
}
/**
* Re-render only the map markers (hub badges, member markers, leader
* lines) without touching the node table, chat log, page title, or the
* ``/api/stats`` fetch. Used for events that only affect the marker
* representation — currently the colocated-hub expand/collapse click and
* the zoom-bucket threshold crossing — so we avoid the full
* {@link applyFilter} pipeline that those events would otherwise trigger.
*
* @returns {void}
*/
function rerenderMapForFiltering() {
renderMap(getFilteredSortedNodes(), Date.now() / 1000);
}
/**
* Apply text and role filters to the node list and re-render outputs.
*
@@ -4513,14 +4770,10 @@ export function initializeApp(config) {
function applyFilter() {
updateFilterClearVisibility();
const filterQuery = filterInput ? filterInput.value : '';
// Normalise query so empty strings and whitespace-only input are treated
// identically and comparisons are case-insensitive.
const q = normaliseChatFilterQuery(filterQuery);
// 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) && matchesProtocolFilter(n));
const sortedNodes = sortNodes(filteredNodes);
const sortedNodes = getFilteredSortedNodes();
const nowSec = Date.now()/1000;
renderTable(sortedNodes, nowSec);
renderMap(sortedNodes, nowSec);
@@ -4910,6 +5163,22 @@ export function initializeApp(config) {
refreshColocatedSpiderState,
/** rAF-throttled wrapper around the spider refresh. */
scheduleColocatedSpiderRefresh,
/** ``zoomend`` handler that also detects co-located zoom-bucket crossings. */
handleZoomEndForColocatedHubs,
/** Build the asterisk + count hub badge for a co-located group. */
createColocatedHubMarker,
/** Lazily look up or create the divIcon for a hub of a given size. */
getColocatedHubIcon,
/** Render the map (test use only). */
renderMap,
/** Re-render only the map (skips the table / chat log / stats pipeline). */
rerenderMapForFiltering,
/** Classify the current zoom level as ``'low'`` or ``'high'``. */
_currentZoomBucketForTests: currentZoomBucket,
/** Inspect the live divIcon cache (test use only). */
_getColocatedHubIconCacheForTests() {
return colocatedHubIconCache;
},
/** Replace the recorded spider state for tests; returns the previous value. */
_setColocatedSpiderStateForTests(next) {
const previous = colocatedSpiderState;
@@ -4920,6 +5189,35 @@ export function initializeApp(config) {
_getColocatedSpiderStateForTests() {
return colocatedSpiderState;
},
/** Replace the expanded-group key set for tests; returns the previous value. */
_setExpandedColocatedKeysForTests(next) {
const previous = expandedColocatedKeys;
expandedColocatedKeys = next instanceof Set ? next : new Set();
return previous;
},
/** Inspect the live expanded-group key set (test use only). */
_getExpandedColocatedKeysForTests() {
return expandedColocatedKeys;
},
/** Inject a stub hub layer for tests; returns the previous value. */
_setColocatedHubsLayerForTests(next) {
const previous = colocatedHubsLayer;
colocatedHubsLayer = next;
return previous;
},
/** Inspect the hub layer (test use only). */
_getColocatedHubsLayerForTests() {
return colocatedHubsLayer;
},
/** Read or override the cached zoom bucket from the previous render. */
_setLastRenderedZoomBucketForTests(next) {
const previous = lastRenderedZoomBucket;
lastRenderedZoomBucket = next;
return previous;
},
_getLastRenderedZoomBucketForTests() {
return lastRenderedZoomBucket;
},
/** Inject a stub Leaflet map for tests that need to drive the projection. */
_setMapForTests(stub) {
const previous = map;
@@ -24,14 +24,15 @@ const DEFAULT_PRECISION = 5;
/**
* Default offset ring radius in pixels for a co-located group of two nodes.
* Chosen slightly larger than the standard map marker radius so the markers
* read as adjacent rather than overlapping. Callers may pass ``0`` to
* intentionally collapse all members of a group back onto the shared centre
* the value is honoured rather than substituted with the default so that
* the offset feature can be disabled without touching the call sites that
* still want grouping for other purposes.
* Sized so the ring sits inside the standard 9px-radius marker glyph rather
* than around it: the spider should read as a tight cluster rather than a
* fan that visually competes with the marker layer. Callers may pass ``0``
* to intentionally collapse all members of a group back onto the shared
* centre the value is honoured rather than substituted with the default
* so that the offset feature can be disabled without touching the call sites
* that still want grouping for other purposes.
*/
const DEFAULT_BASE_RADIUS_PX = 14;
const DEFAULT_BASE_RADIUS_PX = 7;
/**
* Tolerance (in pixels) below which an offset is considered effectively zero.
@@ -46,7 +47,7 @@ const OFFSET_EPSILON_PX = 1e-9;
* second. Keeps groups of five or more visually legible without growing
* unbounded for any single pair.
*/
const DEFAULT_RADIUS_GROWTH_PX = 4;
const DEFAULT_RADIUS_GROWTH_PX = 2;
/**
* Build a string key used to bucket entries that share the same coordinate at
@@ -156,13 +157,16 @@ export function buildRenderableEntries(nodes, options = {}) {
* @param {Object} [options] Optional tuning parameters.
* @param {number} [options.precision=5] Decimal places used to bucket nearby
* coordinates into the same group.
* @param {number} [options.baseRadiusPx=14] Pixel radius applied to a group
* @param {number} [options.baseRadiusPx=7] Pixel radius applied to a group
* of two nodes.
* @param {number} [options.radiusGrowthPx=4] Pixel radius added per extra
* @param {number} [options.radiusGrowthPx=2] Pixel radius added per extra
* node beyond the second.
* @returns {Array<{entry: {node: Object, lat: number, lon: number}, dx: number, dy: number}>}
* @returns {Array<{entry: {node: Object, lat: number, lon: number}, dx: number, dy: number, groupKey: string, groupSize: number}>}
* One result per input entry, in the original input order. Singleton
* groups receive ``{dx: 0, dy: 0}``.
* groups receive ``{dx: 0, dy: 0, groupSize: 1}``. ``groupKey`` is the
* stable bucket identifier returned by {@link coordinateKey} so callers
* can correlate slots that belong to the same co-located group across
* renders without re-running the bucketing themselves.
*/
export function computeColocatedOffsets(entries, options = {}) {
if (!Array.isArray(entries) || entries.length === 0) return [];
@@ -185,10 +189,11 @@ export function computeColocatedOffsets(entries, options = {}) {
});
const results = new Array(entries.length);
for (const bucket of groups.values()) {
if (bucket.length === 1) {
for (const [key, bucket] of groups.entries()) {
const groupSize = bucket.length;
if (groupSize === 1) {
const { entry, index } = bucket[0];
results[index] = { entry, dx: 0, dy: 0 };
results[index] = { entry, dx: 0, dy: 0, groupKey: key, groupSize: 1 };
continue;
}
@@ -212,7 +217,9 @@ export function computeColocatedOffsets(entries, options = {}) {
results[member.index] = {
entry: member.entry,
dx: radius * Math.cos(theta),
dy: radius * Math.sin(theta)
dy: radius * Math.sin(theta),
groupKey: key,
groupSize
};
});
}
+31
View File
@@ -145,6 +145,37 @@ tbody tr:nth-child(even) td {
pointer-events: none;
}
/*
* Asterisk + count badge that represents a collapsed (or expanded-but-still-
* present) co-located node group. The Leaflet ``L.divIcon`` host element
* receives the ``.colocated-spider-hub`` class; the inner ``__glyph`` span
* carries the visible circle so the cursor can still flip back to the
* default state outside the badge. Theming is delegated to the existing
* ``--fg`` / ``--bg`` tokens so the badge blends with both light and dark
* basemaps.
*/
.colocated-spider-hub {
cursor: pointer;
background: transparent;
border: 0;
}
.colocated-spider-hub__glyph {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--fg);
font-size: 10px;
font-weight: 600;
line-height: 1;
user-select: none;
}
.leaflet-tooltip.trace-tooltip {
background: var(--bg2);
color: var(--fg);