diff --git a/public/packet-path-map.js b/public/packet-path-map.js index 53a3b81c..23c51f1d 100644 --- a/public/packet-path-map.js +++ b/public/packet-path-map.js @@ -181,19 +181,6 @@ return; } - // A packet heard by many stations draws a lot of secondary routes on - // top of each other -- only worth offering the declutter toggle once - // there's more than one branch to hide. - var controlsEl = document.getElementById('packetPathControls'); - if (controlsEl && plotted.length > 1) { - var hiddenCount = plotted.length - 1; - controlsEl.innerHTML = - ''; - } - var primaryChain = plotted[0].chain; var center = primaryChain[Math.floor(primaryChain.length / 2)]; var map = L.map('packetPathMapContainer', { zoomControl: true, attributionControl: false }) @@ -215,23 +202,36 @@ // markers, and deliberately excluded from the fitBounds calculation // below: a huge area (e.g. a whole region) would zoom the map out // past the point where individual branches are still readable. + // Tracked separately from marker/polyline layers below -- toggled by + // its own "show area boundaries" checkbox, independent of the + // branch/approx filters. + var areaShapeLayers = []; (data.touchedAreas || []).forEach(function (area) { var shapeOpts = { color: accent, weight: 1, opacity: 0.35, fillColor: accent, fillOpacity: 0.06, interactive: false }; + var shape = null; if (area.polygon && area.polygon.length > 0) { - L.polygon(area.polygon, shapeOpts).addTo(map); + shape = L.polygon(area.polygon, shapeOpts).addTo(map); } else if (area.latMin != null && area.latMax != null && area.lonMin != null && area.lonMax != null) { - L.rectangle([[area.latMin, area.lonMin], [area.latMax, area.lonMax]], shapeOpts).addTo(map); + shape = L.rectangle([[area.latMin, area.lonMin], [area.latMax, area.lonMax]], shapeOpts).addTo(map); } + if (shape) areaShapeLayers.push(shape); }); var bounds = []; var missingTotal = 0; var approxTotal = 0; - // Markers/lines belonging to a non-primary branch -- toggled off by - // the "show only farthest route" checkbox above. The landmark "first - // to hear it" ring (drawn separately below) is never included: it's - // not a branch, it stays visible regardless of the toggle. - var secondaryLayers = []; + // Every branch marker/polyline, tagged with which filters apply to it + // -- the "show only farthest route" and "show only approximate" + // checkboxes are independent and combinable (e.g. both checked at + // once), so visibility is recomputed from BOTH current checkbox + // states together (applyMarkerFilters below) rather than each + // checkbox naively add/removing its own layers -- that naive + // approach breaks as soon as two filters overlap on the same layer + // and get toggled in different orders. The landmark "first to hear + // it" ring (drawn separately below) is never tracked here: it's not + // a branch, it stays visible regardless of either filter. + var markerEntries = []; + var polylineEntries = []; // The same physical node (e.g. a shared entry-point repeater near the // sender) commonly appears in many branches' chains -- dedupe by // identity so "N approximate" counts distinct stations, not chain @@ -286,11 +286,11 @@ window.location.hash = '#/nodes/' + encodeURIComponent(pt.publicKey); }); } - if (!p.primary) secondaryLayers.push(marker); + markerEntries.push({ layer: marker, primary: p.primary, approx: !!pt.approx }); }); if (line.length > 1) { var polyline = L.polyline(line, { color: lineColor, weight: p.primary ? 2.5 : 1.5, opacity: p.primary ? 0.85 : 0.5 }).addTo(map); - if (!p.primary) secondaryLayers.push(polyline); + polylineEntries.push({ layer: polyline, primary: p.primary }); } }); // The earliest-arriving observation, drawn last so its landmark ring @@ -315,12 +315,67 @@ setTimeout(function () { map.invalidateSize(); }, 120); activeMap = map; + // Build whichever filter checkboxes are actually relevant for this + // packet -- a single-branch packet gets no declutter toggle, one + // with no approximate positions gets no approx-only toggle, etc. + var controlsEl = document.getElementById('packetPathControls'); + var controlsHtml = ''; + var hiddenCount = plotted.length - 1; + if (plotted.length > 1) { + controlsHtml += + ''; + } + if (approxTotal > 0) { + controlsHtml += + ''; + } + if (data.touchedAreas && data.touchedAreas.length > 0) { + controlsHtml += + ''; + } + if (controlsEl && controlsHtml) controlsEl.innerHTML = controlsHtml; + var primaryOnlyCheckbox = document.getElementById('packetPathPrimaryOnly'); - if (primaryOnlyCheckbox) { - primaryOnlyCheckbox.addEventListener('change', function () { - secondaryLayers.forEach(function (layer) { - if (primaryOnlyCheckbox.checked) map.removeLayer(layer); - else layer.addTo(map); + var approxOnlyCheckbox = document.getElementById('packetPathApproxOnly'); + // Recomputes every marker/polyline's visibility from BOTH checkboxes' + // CURRENT state together, rather than each checkbox naively add/ + // removing only its own layers -- the two filters are independent + // and combinable (e.g. both checked at once), and a naive per- + // checkbox toggle breaks as soon as they overlap on the same layer + // and get unchecked in a different order than they were checked. + function applyMarkerFilters() { + var primaryOnly = !!(primaryOnlyCheckbox && primaryOnlyCheckbox.checked); + var approxOnly = !!(approxOnlyCheckbox && approxOnlyCheckbox.checked); + markerEntries.forEach(function (m) { + var visible = (!primaryOnly || m.primary) && (!approxOnly || m.approx); + if (visible) m.layer.addTo(map); else map.removeLayer(m.layer); + }); + polylineEntries.forEach(function (p) { + var visible = !primaryOnly || p.primary; + if (visible) p.layer.addTo(map); else map.removeLayer(p.layer); + }); + } + if (primaryOnlyCheckbox) primaryOnlyCheckbox.addEventListener('change', applyMarkerFilters); + if (approxOnlyCheckbox) approxOnlyCheckbox.addEventListener('change', applyMarkerFilters); + + // Area-boundary visibility is a separate, independent toggle -- not + // part of applyMarkerFilters since it's a different layer type + // entirely and never interacts with the branch/approx filters above. + var showAreasCheckbox = document.getElementById('packetPathShowAreas'); + if (showAreasCheckbox) { + showAreasCheckbox.checked = true; // shapes are drawn (visible) by default; unchecking hides them + showAreasCheckbox.addEventListener('change', function () { + areaShapeLayers.forEach(function (layer) { + if (showAreasCheckbox.checked) layer.addTo(map); else map.removeLayer(layer); }); }); } diff --git a/test-packet-path-map.js b/test-packet-path-map.js index 592e1152..28037aec 100644 --- a/test-packet-path-map.js +++ b/test-packet-path-map.js @@ -330,6 +330,182 @@ function makeSandbox(apiImpl) { } catch (e) { failed++; console.log(' ❌ declutter toggle is absent for a single-branch packet: ' + e.message); } })(); + await (async () => { + try { + // "Show only approximate positions" should only appear when at + // least one marker is approx, and checking it should hide exactly + // the non-approx markers while leaving the polyline (route context) + // and the approx marker itself untouched. + const ctx = makeSandbox(() => Promise.resolve({ + hash: 'deadbeef', + branches: [ + { + hops: 2, + points: [ + { publicKey: 'pk1', name: 'RealFix', lat: 56.0, lon: 10.0 }, + { publicKey: 'pk2', name: 'GhostRepeater', lat: 56.1, lon: 10.1, approx: true }, + ], + observer: { name: 'FarObserver', lat: 56.2, lon: 10.2 }, + }, + ], + })); + const removedLayers = []; + const mapObj = { + setView() { return this; }, fitBounds() {}, invalidateSize() {}, remove() {}, + removeLayer(layer) { removedLayers.push(layer); }, + }; + ctx.L = { + map: () => mapObj, + tileLayer: () => ({ addTo() { return this; } }), + circleMarker: () => ({ addTo() { return this; }, bindTooltip() { return this; }, on() { return this; } }), + polyline: () => ({ addTo() { return this; } }), + }; + + await ctx.window.PacketPathMap.open('deadbeef'); + const checkbox = ctx.document.getElementById('packetPathApproxOnly'); + assert.ok(checkbox, 'expected the approx-only checkbox to exist when at least one marker is approximate'); + const controlsEl = ctx.document.getElementById('packetPathControls'); + assert.ok(controlsEl.innerHTML.includes('1 estimated from neighbors'), 'label should count the approximate markers, got: ' + controlsEl.innerHTML); + + checkbox.checked = true; + (checkbox._listeners.change || []).forEach((fn) => fn()); + assert.strictEqual(removedLayers.length, 2, 'expected 2 removeLayer calls: RealFix and FarObserver (both non-approx) -- GhostRepeater (approx) and the polyline (not approx-filtered) must stay, got ' + removedLayers.length); + passed++; + console.log(' ✅ "show only approximate positions" toggle hides exactly the non-approx markers, leaves the route line'); + } catch (e) { failed++; console.log(' ❌ "show only approximate positions" toggle hides exactly the non-approx markers, leaves the route line: ' + e.message); } + })(); + + await (async () => { + try { + // No approximate positions at all -- the approx-only checkbox must + // not appear (nothing to filter to). + const ctx = makeSandbox(() => Promise.resolve({ + hash: 'deadbeef', + branches: [{ hops: 1, points: [], observer: { name: 'OnlyObserver', lat: 56.0, lon: 10.0 } }], + })); + ctx.L = { + map: () => ({ setView() { return this; }, fitBounds() {}, invalidateSize() {}, remove() {} }), + tileLayer: () => ({ addTo() { return this; } }), + circleMarker: () => ({ addTo() { return this; }, bindTooltip() { return this; }, on() { return this; } }), + polyline: () => ({ addTo() { return this; } }), + }; + + await ctx.window.PacketPathMap.open('deadbeef'); + const checkbox = ctx.document.getElementById('packetPathApproxOnly'); + assert.ok(!checkbox, 'expected no approx-only checkbox when nothing is approximate, found one'); + passed++; + console.log(' ✅ approx-only toggle is absent when no marker is approximate'); + } catch (e) { failed++; console.log(' ❌ approx-only toggle is absent when no marker is approximate: ' + e.message); } + })(); + + await (async () => { + try { + // "Show area boundaries" starts checked (shapes visible by + // default, matching prior always-on behavior) and unchecking it + // removes exactly the area shape layers -- markers/polylines must + // be untouched. + const ctx = makeSandbox(() => Promise.resolve({ + hash: 'deadbeef', + branches: [{ hops: 0, points: [], observer: { name: 'Obs', lat: 56.0, lon: 10.0 } }], + touchedAreas: [{ label: 'Aarhus by', latMin: 56.05, latMax: 56.25, lonMin: 9.95, lonMax: 10.35 }], + })); + const removedLayers = []; + const mapObj = { + setView() { return this; }, fitBounds() {}, invalidateSize() {}, remove() {}, + removeLayer(layer) { removedLayers.push(layer); }, + }; + ctx.L = { + map: () => mapObj, + tileLayer: () => ({ addTo() { return this; } }), + circleMarker: () => ({ addTo() { return this; }, bindTooltip() { return this; }, on() { return this; } }), + polyline: () => ({ addTo() { return this; } }), + rectangle: () => ({ addTo() { return this; } }), + }; + + await ctx.window.PacketPathMap.open('deadbeef'); + const checkbox = ctx.document.getElementById('packetPathShowAreas'); + assert.ok(checkbox, 'expected the show-areas checkbox to exist when touchedAreas is present'); + assert.strictEqual(checkbox.checked, true, 'show-areas checkbox should start checked (shapes visible by default)'); + + checkbox.checked = false; + (checkbox._listeners.change || []).forEach((fn) => fn()); + assert.strictEqual(removedLayers.length, 1, 'expected exactly 1 removeLayer call for the single area shape, got ' + removedLayers.length); + passed++; + console.log(' ✅ "show area boundaries" toggle starts checked and hides exactly the area shapes when unchecked'); + } catch (e) { failed++; console.log(' ❌ "show area boundaries" toggle starts checked and hides exactly the area shapes when unchecked: ' + e.message); } + })(); + + await (async () => { + try { + // The declutter and approx-only filters are independent and + // combinable -- checking BOTH, then unchecking just one, must + // recompute visibility from the OTHER filter's still-active state + // rather than blindly re-adding everything. A naive + // "each checkbox owns its own add/remove" implementation breaks + // exactly this case. + const ctx = makeSandbox(() => Promise.resolve({ + hash: 'deadbeef', + branches: [ + { + hops: 1, + points: [], + observer: { name: 'PrimaryObserver', lat: 56.0, lon: 10.0 }, + }, + { + hops: 1, + points: [], + observer: { name: 'SecondaryApprox', lat: 56.1, lon: 10.1, approx: true }, + }, + ], + })); + let secondaryApproxOnMap = true; // starts added via .addTo() at draw time + const secondaryApproxMarker = { + addTo() { secondaryApproxOnMap = true; return this; }, + bindTooltip() { return this; }, + on() { return this; }, + }; + let callIndex = 0; + const mapObj = { + setView() { return this; }, fitBounds() {}, invalidateSize() {}, remove() {}, + removeLayer(layer) { if (layer === secondaryApproxMarker) secondaryApproxOnMap = false; }, + }; + ctx.L = { + map: () => mapObj, + tileLayer: () => ({ addTo() { return this; } }), + circleMarker: () => { + callIndex++; + // Secondary branches are drawn FIRST (so the primary ends up + // drawn last, on top) -- the 1st circleMarker created is + // SecondaryApprox's observer marker, not the 2nd. + if (callIndex === 1) return secondaryApproxMarker; + return { addTo() { return this; }, bindTooltip() { return this; }, on() { return this; } }; + }, + polyline: () => ({ addTo() { return this; } }), + }; + + await ctx.window.PacketPathMap.open('deadbeef'); + const primaryOnly = ctx.document.getElementById('packetPathPrimaryOnly'); + const approxOnly = ctx.document.getElementById('packetPathApproxOnly'); + assert.ok(primaryOnly && approxOnly, 'expected both filter checkboxes to exist'); + + // Check both -- the secondary+approx marker is hidden by primaryOnly. + primaryOnly.checked = true; + (primaryOnly._listeners.change || []).forEach((fn) => fn()); + approxOnly.checked = true; + (approxOnly._listeners.change || []).forEach((fn) => fn()); + assert.strictEqual(secondaryApproxOnMap, false, 'secondary+approx marker should be hidden once primaryOnly is checked'); + + // Uncheck primaryOnly -- approxOnly is STILL checked, and this + // marker IS approx, so it must reappear (approxOnly alone doesn't + // exclude it). + primaryOnly.checked = false; + (primaryOnly._listeners.change || []).forEach((fn) => fn()); + assert.strictEqual(secondaryApproxOnMap, true, 'secondary+approx marker should reappear once primaryOnly is unchecked -- approxOnly alone does not hide an approx marker'); + passed++; + console.log(' ✅ declutter and approx-only filters combine correctly instead of fighting over the same layer'); + } catch (e) { failed++; console.log(' ❌ declutter and approx-only filters combine correctly instead of fighting over the same layer: ' + e.message); } + })(); + await (async () => { try { // `first` is the earliest-arriving observation (usually 0 hops,