feat: View Path adds show/hide toggles for area boundaries and approximate positions

Two more filter checkboxes alongside the existing declutter toggle:

- "Show area boundaries" -- starts checked (shapes visible by
  default, matching current behavior); unchecking removes them.
- "Show only approximate positions" -- appears only when at least
  one marker is approximate; filters down to just the
  neighbor-estimated stations, useful for sanity-checking how many
  (and which) positions on a packet are actually estimates rather
  than real fixes.

Both only appear when there's something to toggle (no area boundaries
= no checkbox, no approx markers = no checkbox), matching the
existing declutter toggle's convention.

Refactored the layer-visibility bookkeeping to support this: the
declutter and approx-only filters are independent and combinable, so
naively having each checkbox add/remove only its own layers breaks as
soon as both are active and one gets unchecked -- a layer that's
still excluded by the OTHER filter would incorrectly reappear.
Replaced with markerEntries/polylineEntries (tagged primary/approx)
and a single applyMarkerFilters() that recomputes every layer's
visibility from both checkboxes' current state together. Area
boundary visibility stays a separate, independent toggle since it's
an unrelated layer type.
This commit is contained in:
dborup
2026-07-26 08:59:19 +02:00
parent fe68991102
commit 51b76979ca
2 changed files with 258 additions and 27 deletions
+82 -27
View File
@@ -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 =
'<label style="display:flex;align-items:center;gap:6px;font-size:12px;margin:0 0 8px;cursor:pointer;color:var(--text-muted)">' +
'<input type="checkbox" id="packetPathPrimaryOnly">' +
'Show only the farthest-traveled route (' + hiddenCount + ' other station' + (hiddenCount === 1 ? '' : 's') + ' hidden when checked)' +
'</label>';
}
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 +=
'<label style="display:flex;align-items:center;gap:6px;font-size:12px;margin:0 0 8px;cursor:pointer;color:var(--text-muted)">' +
'<input type="checkbox" id="packetPathPrimaryOnly">' +
'Show only the farthest-traveled route (' + hiddenCount + ' other station' + (hiddenCount === 1 ? '' : 's') + ' hidden when checked)' +
'</label>';
}
if (approxTotal > 0) {
controlsHtml +=
'<label style="display:flex;align-items:center;gap:6px;font-size:12px;margin:0 0 8px;cursor:pointer;color:var(--text-muted)">' +
'<input type="checkbox" id="packetPathApproxOnly">' +
'Show only approximate positions (' + approxTotal + ' estimated from neighbors)' +
'</label>';
}
if (data.touchedAreas && data.touchedAreas.length > 0) {
controlsHtml +=
'<label style="display:flex;align-items:center;gap:6px;font-size:12px;margin:0 0 8px;cursor:pointer;color:var(--text-muted)">' +
'<input type="checkbox" id="packetPathShowAreas">' +
'Show area boundaries on the map' +
'</label>';
}
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);
});
});
}
+176
View File
@@ -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,