mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-11 17:24:42 +00:00
2f0c97604b
## 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>
143 lines
7.5 KiB
JavaScript
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`);
|