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,