mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-30 10:37:58 +00:00
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:
+82
-27
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user