mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-07-05 09:21:42 +02:00
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:
@@ -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');
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user