Files
meshcore-analyzer/test-map-clustering.js
T
Kpa-clawbot 2f0c97604b feat(map): cluster markers with Leaflet.markercluster (#1036) (#1038)
## Summary
Implements map marker clustering for large meshes (500+ nodes) using
vendored `Leaflet.markercluster@1.5.3`. Closes the long-standing no-op
`Show clusters` checkbox.

## What changed
**Vendored library** — `public/vendor/leaflet.markercluster.js` +
`MarkerCluster.css` + `MarkerCluster.Default.css`. No CDN: this runs
offline on mesh-operator deployments.

**`map.js`**
- `createClusterGroup()` instantiates `L.markerClusterGroup` with:
  - `chunkedLoading: true` (no frame drops on initial render)
- `removeOutsideVisibleBounds: true` (viewport culling — key win at 2k+
nodes)
  - `disableClusteringAtZoom: 16` (fully expanded at high zoom)
  - `spiderfyOnMaxZoom: true` (fan out at max zoom)
  - `showCoverageOnHover: false`
  - `animate` disabled on mobile UA for perf
- `makeClusterIcon(cluster)` produces a CoreScope-themed `L.divIcon`:
  - Bold total count, centered
- Up to 4 role-color mini-pills (repeater / companion / room / sensor /
observer) using `ROLE_COLORS`
- Bucketed `mc-sm` / `mc-md` / `mc-lg` background (info / warning /
accent CSS vars)
- `#mcClusters` checkbox repurposed from no-op `Show clusters` →
`Cluster markers`, default **ON**, persisted to
`localStorage['meshcore-map-clustering']`
- Render branches at the marker-add step: clustering ON → `addLayers()`
to `clusterGroup`, skip `deconflictLabels` + `_updateOffsetIndicator`
polylines + `_repositionMarkers` on zoom/resize. Clustering OFF →
original flow unchanged.
- Route polylines (`drawPacketRoute`) already removed both layers — no
change needed beyond actually instantiating `clusterGroup`.
- `?node=PUBKEY` deep-link lookup now searches both `markerLayer` and
`clusterGroup` so it works in either mode.

**`style.css`** — cluster bubble + role-pill styles using `--info` /
`--warning` / `--accent` CSS variables; hover scale.

**`index.html`** — vendor CSS + JS tags after the Leaflet bundle
(cache-busted via `__BUST__`).

## TDD
- **Red commit** `e10af23` — `test-map-clustering.js` + stub
`createClusterGroup`/`makeClusterIcon` returning null/empty divIcon.
Compiles, runs, fails 4/5 on assertions.
- **Green commit** `482ea2e` — real implementation. 5/5 pass.

```
=== map.js: clustering ===
   exposes test hooks (__meshcoreMapInternals)
   createClusterGroup returns an L.MarkerClusterGroup with required options
   cluster group accepts markers via addLayer
   makeClusterIcon: includes total count and role-pill counts
   makeClusterIcon: bucket sm/md/lg by total
```

## Behavior preserved
- Clustering OFF (existing checkbox unchecked) → all original behavior
intact: deconfliction spiral, offset-indicator polylines, per-zoom
reposition.
- Default ON. Operators with small meshes can disable via the checkbox;
choice persists.
- Spiderfying enabled at max zoom (built-in markercluster behavior).

## Performance target
Smooth pan/zoom at 2000 nodes — `chunkedLoading` keeps the main thread
responsive during initial add, `removeOutsideVisibleBounds` keeps DOM
bounded to the viewport. Per AGENTS.md rule 0: complexity is O(n) for
the initial add (chunked across frames), per-zoom re-cluster is internal
to markercluster (well-tested at 10k+ scale).

## Out of scope (filed as follow-ups in spec)
- Canvas marker renderer — only if 5k+ nodes per viewport materializes
- Server-side viewport culling (`/api/nodes?bbox=`)
- Cluster-by-role split groups
- 2k-node fixture + Playwright DOM assertions — repo doesn't currently
ship a `fixture=` query param; the unit test exercises the integration
deterministically.

Fixes #1036

---------

Co-authored-by: corescope-bot <bot@corescope>
2026-05-04 18:29:42 -07:00

143 lines
7.5 KiB
JavaScript

/* Unit tests for map.js clustering integration (issue #1036)
*
* Verifies:
* - makeClusterIcon produces a divIcon HTML containing the total + per-role pills
* - createClusterGroup instantiates an L.MarkerClusterGroup with the required options
* - The cluster group accepts markers via addLayer
*
* Tests run in a jsdom-free vm sandbox with a tiny Leaflet/Leaflet.markercluster
* shim so we exercise our integration code (not the library itself).
*/
'use strict';
const vm = require('vm');
const fs = require('fs');
const assert = require('assert');
let passed = 0, failed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(`${name}`); }
catch (e) { failed++; console.log(`${name}: ${e.message}`); }
}
// ---- Tiny Leaflet shim ----
function makeLeafletShim() {
const L = {};
L.point = (x, y) => ({ x, y });
L.latLng = (a, b) => ({ lat: a, lng: b });
L.divIcon = (opts) => ({ _isDivIcon: true, options: opts, html: opts.html, className: opts.className });
L.layerGroup = () => {
const g = { _layers: [], addLayer(m){ this._layers.push(m); return this; }, removeLayer(m){ const i=this._layers.indexOf(m); if(i>=0) this._layers.splice(i,1); return this; }, clearLayers(){ this._layers=[]; return this; }, eachLayer(fn){ this._layers.forEach(fn); }, addTo(){ return this; }, hasLayer(m){ return this._layers.includes(m); } };
return g;
};
L.marker = (latlng, opts) => ({ _isMarker: true, _latlng: latlng, options: opts || {}, getLatLng(){ return this._latlng; }, bindPopup(){ return this; }, bindTooltip(){ return this; } });
// markercluster shim
function MarkerClusterGroup(opts) {
this.options = opts || {};
this._layers = [];
this._isClusterGroup = true;
}
MarkerClusterGroup.prototype.addLayer = function (m) { this._layers.push(m); return this; };
MarkerClusterGroup.prototype.addLayers = function (ms) { ms.forEach(m => this._layers.push(m)); return this; };
MarkerClusterGroup.prototype.removeLayer = function (m) { const i=this._layers.indexOf(m); if(i>=0) this._layers.splice(i,1); return this; };
MarkerClusterGroup.prototype.clearLayers = function () { this._layers = []; return this; };
MarkerClusterGroup.prototype.eachLayer = function (fn) { this._layers.forEach(fn); };
MarkerClusterGroup.prototype.hasLayer = function (m) { return this._layers.includes(m); };
MarkerClusterGroup.prototype.addTo = function () { return this; };
MarkerClusterGroup.prototype.getLayers = function () { return this._layers.slice(); };
L.MarkerClusterGroup = MarkerClusterGroup;
L.markerClusterGroup = (opts) => new MarkerClusterGroup(opts);
return L;
}
function makeSandbox() {
const ctx = {
window: {},
document: { addEventListener(){}, getElementById(){ return null; }, querySelector(){ return null; }, querySelectorAll(){ return []; }, createElement(){ return { id:'', textContent:'', innerHTML:'', appendChild(){}, addEventListener(){}, setAttribute(){}, classList:{add(){},remove(){},toggle(){}} }; }, head: { appendChild(){} }, body: { appendChild(){} } },
console, Date, Math, Array, Object, String, Number, JSON, RegExp, Error,
parseInt, parseFloat, isFinite, isNaN, Map, Set, Promise,
setTimeout: ()=>{}, clearTimeout: ()=>{}, setInterval: ()=>{}, clearInterval: ()=>{},
registerPage: () => {}, esc: (s) => s, onWS: () => {}, offWS: () => {},
localStorage: (() => { const s={}; return { getItem:k=>s[k]||null, setItem:(k,v)=>{s[k]=String(v);}, removeItem:k=>{delete s[k];} }; })(),
fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
addEventListener(){}, dispatchEvent(){},
L: makeLeafletShim(),
};
ctx.window.L = ctx.L;
vm.createContext(ctx);
// Load roles for ROLE_COLORS palette
vm.runInContext(fs.readFileSync('public/roles.js','utf8'), ctx);
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
// Load map.js (IIFE — exposes test hooks via window.__meshcoreMapInternals)
vm.runInContext(fs.readFileSync('public/map.js','utf8'), ctx);
for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k];
return ctx;
}
console.log('\n=== map.js: clustering ===');
{
const ctx = makeSandbox();
const internals = ctx.window.__meshcoreMapInternals;
test('exposes test hooks (__meshcoreMapInternals)', () => {
assert.ok(internals, 'window.__meshcoreMapInternals not exposed by map.js');
assert.ok(typeof internals.makeClusterIcon === 'function', 'makeClusterIcon not exported');
assert.ok(typeof internals.createClusterGroup === 'function', 'createClusterGroup not exported');
});
test('createClusterGroup returns an L.MarkerClusterGroup with required options', () => {
const g = internals.createClusterGroup();
assert.ok(g, 'createClusterGroup returned falsy');
assert.ok(g instanceof ctx.L.MarkerClusterGroup, 'expected L.MarkerClusterGroup instance');
assert.strictEqual(g.options.chunkedLoading, true, 'chunkedLoading should be true');
assert.strictEqual(g.options.removeOutsideVisibleBounds, true, 'removeOutsideVisibleBounds should be true');
assert.strictEqual(g.options.disableClusteringAtZoom, 16, 'disableClusteringAtZoom should be 16');
assert.strictEqual(g.options.spiderfyOnMaxZoom, true, 'spiderfyOnMaxZoom should be true');
assert.strictEqual(typeof g.options.iconCreateFunction, 'function', 'iconCreateFunction should be set');
});
test('cluster group accepts markers via addLayer', () => {
const g = internals.createClusterGroup();
const m1 = ctx.L.marker(ctx.L.latLng(37.7, -122.4));
const m2 = ctx.L.marker(ctx.L.latLng(37.8, -122.5));
g.addLayer(m1);
g.addLayer(m2);
assert.strictEqual(g.getLayers().length, 2, 'cluster group should hold added markers');
});
test('makeClusterIcon: includes total count and role-pill counts', () => {
const markers = [
{ _role: 'repeater' }, { _role: 'repeater' }, { _role: 'repeater' },
{ _role: 'companion' }, { _role: 'companion' },
{ _role: 'room' },
];
const cluster = { getAllChildMarkers: () => markers, getChildCount: () => markers.length };
const icon = internals.makeClusterIcon(cluster);
assert.ok(icon && icon._isDivIcon, 'expected an L.divIcon');
const html = icon.html || '';
assert.ok(/>6</.test(html) || html.indexOf('>6<') >= 0, `total count 6 not in html: ${html}`);
// Role pill counts should appear
assert.ok(html.indexOf('>3<') >= 0, `repeater pill (3) not in html: ${html}`);
assert.ok(html.indexOf('>2<') >= 0, `companion pill (2) not in html: ${html}`);
assert.ok(html.indexOf('>1<') >= 0, `room pill (1) not in html: ${html}`);
// CoreScope-themed wrapper class
assert.ok((icon.className || '').indexOf('mc-cluster') >= 0, `expected mc-cluster class, got: ${icon.className}`);
});
test('makeClusterIcon: bucket sm/md/lg by total', () => {
const mk = (n, role='companion') => Array.from({length:n}, () => ({ _role: role }));
function clusterOf(n) { const ms = mk(n); return { getAllChildMarkers: () => ms, getChildCount: () => n }; }
const small = internals.makeClusterIcon(clusterOf(5));
const med = internals.makeClusterIcon(clusterOf(40));
const large = internals.makeClusterIcon(clusterOf(150));
assert.ok(/mc-sm/.test(small.html || small.className || ''), 'small bucket missing');
assert.ok(/mc-md/.test(med.html || med.className || ''), 'medium bucket missing');
assert.ok(/mc-lg/.test(large.html || large.className || ''), 'large bucket missing');
});
}
if (failed > 0) {
console.log(`\n${failed} test(s) failed, ${passed} passed`);
process.exit(1);
}
console.log(`\nAll ${passed} test(s) passed`);