From 4cd828265007ec135d7e94a48e742792ad08d6e9 Mon Sep 17 00:00:00 2001 From: dborup Date: Sun, 26 Jul 2026 09:41:01 +0200 Subject: [PATCH 1/3] fix: View Path highlights the actually-farthest branch, not just the deepest The highlighted "primary" route was always branches[0] (most hops), labeled "farthest-traveled route" -- but more hops doesn't mean more geographic distance. A dense area can take many short hops; a couple of long-range links can cover more real distance in fewer. dborup caught the mislabeling; we now pick the branch with the largest distanceFromFirstKm when any branch has that data (ties break toward more hops, since branches[] stays deepest-first). Falls back to the old hops-based pick only when NO branch has usable distance data (sparse GPS coverage) -- and the legend/checkbox wording switches to an honest "deepest (most hops)" in that case instead of still claiming "farthest-traveled" for a branch nobody actually measured. The "deepest reached N hops" footer stat is unrelated and unchanged -- it was already correctly hop-based. --- public/packet-path-map.js | 42 +++++++++++++++++++--- test-packet-path-map.js | 74 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 4 deletions(-) diff --git a/public/packet-path-map.js b/public/packet-path-map.js index 23c51f1d..e54f2d69 100644 --- a/public/packet-path-map.js +++ b/public/packet-path-map.js @@ -131,7 +131,7 @@ '

Relay Path

' + '

How far and how wide this packet spread. Click a marker to open that node\'s detail page.

' + '
' + - 'farthest-traveled route' + + 'farthest-traveled route' + 'other station' + 'approximate position' + 'first to hear it' + @@ -157,11 +157,35 @@ } var branches = data.branches || []; - var plotted = branches.map(function (b, i) { + var plotted = branches.map(function (b) { var built = chainForBranch(b); - return { branch: b, chain: built.chain, missing: built.missing, primary: i === 0 }; + return { branch: b, chain: built.chain, missing: built.missing, primary: false }; }).filter(function (p) { return p.chain.length > 0; }); + // The "highlighted" branch used to just be branches[0] (most hops) -- + // but more hops doesn't mean more geographic distance (a dense area + // can take many short hops; a couple of long-range links can cover + // more real distance in fewer). Prefer the branch that actually + // traveled farthest by distanceFromFirstKm when any branch has that + // data; among ties, branches[] is already deepest-first so the + // earliest match also has the most hops. Falls back to the old + // hops-based pick (plotted[0]) only when NO branch has usable + // distance data (e.g. sparse GPS coverage) -- there's nothing better + // to compare by in that case. + var hasDistanceData = plotted.some(function (p) { return typeof p.branch.distanceFromFirstKm === 'number'; }); + var primaryIdx = 0; + if (hasDistanceData) { + var farthestKm = -1; + plotted.forEach(function (p, i) { + var d = p.branch.distanceFromFirstKm; + if (typeof d === 'number' && d > farthestKm) { + farthestKm = d; + primaryIdx = i; + } + }); + } + if (plotted[primaryIdx]) plotted[primaryIdx].primary = true; + if (plotted.length === 0) { if (statusEl) { if (branches.length === 0) { @@ -315,6 +339,16 @@ setTimeout(function () { map.invalidateSize(); }, 120); activeMap = map; + // Label the highlighted branch honestly: only call it + // "farthest-traveled" when it was actually picked by real distance + // (see the primaryIdx selection above) -- when no branch has usable + // distance data, what's highlighted is really just the one with the + // most hops, which is a different thing and shouldn't borrow the + // "farthest" word. + var primaryLabel = hasDistanceData ? 'farthest-traveled' : 'deepest (most hops)'; + var primaryLegendLabel = document.getElementById('packetPathPrimaryLegendLabel'); + if (primaryLegendLabel) primaryLegendLabel.textContent = primaryLabel + ' route'; + // 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. @@ -325,7 +359,7 @@ controlsHtml += ''; } if (approxTotal > 0) { diff --git a/test-packet-path-map.js b/test-packet-path-map.js index 28037aec..bc99c40b 100644 --- a/test-packet-path-map.js +++ b/test-packet-path-map.js @@ -687,6 +687,80 @@ function makeSandbox(apiImpl) { } catch (e) { failed++; console.log(' ❌ distanceFromFirstKm renders as a "N km away" label in the tooltip: ' + e.message); } })(); + await (async () => { + try { + // More hops does not mean more geographic distance -- the + // highlighted ("primary") branch must be picked by actual + // distanceFromFirstKm when that data exists, not by branches[0] + // (the deepest-by-hops branch, which is a different thing). Here + // the SHALLOWER branch (2 hops) travels much farther (200km) than + // the DEEPER one (5 hops, only 50km) -- a dense-mesh short-hop + // chain vs. a couple of long-range links. + const ctx = makeSandbox(() => Promise.resolve({ + hash: 'deadbeef', + branches: [ + { hops: 5, points: [], observer: { name: 'DeepButClose', lat: 56.0, lon: 10.0 }, distanceFromFirstKm: 50 }, + { hops: 2, points: [], observer: { name: 'ShallowButFar', lat: 57.0, lon: 11.0 }, distanceFromFirstKm: 200 }, + ], + })); + + const markerCalls = []; + ctx.L = { + map: () => ({ setView() { return this; }, fitBounds() {}, invalidateSize() {}, remove() {} }), + tileLayer: () => ({ addTo() { return this; } }), + circleMarker: (latlng, opts) => { + const entry = { opts, tooltip: null }; + markerCalls.push(entry); + return { addTo() { return this; }, bindTooltip(t) { entry.tooltip = t; return this; }, on() { return this; } }; + }, + polyline: () => ({ addTo() { return this; } }), + }; + + await ctx.window.PacketPathMap.open('deadbeef'); + const deepMarker = markerCalls.find((m) => m.tooltip && m.tooltip.includes('DeepButClose')); + const farMarker = markerCalls.find((m) => m.tooltip && m.tooltip.includes('ShallowButFar')); + assert.ok(deepMarker && farMarker, 'expected markers for both observers, got: ' + JSON.stringify(markerCalls.map((m) => m.tooltip))); + assert.strictEqual(farMarker.opts.weight, 2, 'ShallowButFar (200km, actually farthest) should get primary styling (weight 2), got ' + farMarker.opts.weight); + assert.strictEqual(deepMarker.opts.weight, 1, 'DeepButClose (5 hops but only 50km) should get secondary styling (weight 1) despite having more hops, got ' + deepMarker.opts.weight); + + const legendLabel = ctx.document.getElementById('packetPathPrimaryLegendLabel'); + assert.ok(legendLabel && legendLabel.textContent.includes('farthest-traveled'), 'legend should say "farthest-traveled" when real distance data picked the highlight, got: ' + (legendLabel && legendLabel.textContent)); + passed++; + console.log(' ✅ the highlighted branch is picked by actual distance, not hop count, when distance data exists'); + } catch (e) { failed++; console.log(' ❌ the highlighted branch is picked by actual distance, not hop count, when distance data exists: ' + e.message); } + })(); + + await (async () => { + try { + // No branch has distanceFromFirstKm at all (e.g. sparse GPS + // coverage) -- falls back to the old hops-based pick, and the + // legend/checkbox wording must say so honestly rather than still + // claiming "farthest-traveled" for a branch nobody measured. + const ctx = makeSandbox(() => Promise.resolve({ + hash: 'deadbeef', + branches: [ + { hops: 3, points: [], observer: { name: 'ObsA', lat: 56.0, lon: 10.0 } }, + { hops: 1, points: [], observer: { name: 'ObsB', lat: 56.1, lon: 10.1 } }, + ], + })); + 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 legendLabel = ctx.document.getElementById('packetPathPrimaryLegendLabel'); + assert.ok(legendLabel && legendLabel.textContent.includes('deepest (most hops)'), 'legend should honestly say "deepest (most hops)" when no branch has distance data, got: ' + (legendLabel && legendLabel.textContent)); + assert.ok(!legendLabel.textContent.includes('farthest'), 'legend must not claim "farthest" when nothing was actually measured by distance, got: ' + legendLabel.textContent); + const controlsEl = ctx.document.getElementById('packetPathControls'); + assert.ok(controlsEl.innerHTML.includes('deepest (most hops)'), 'declutter checkbox label should also use the honest wording, got: ' + controlsEl.innerHTML); + passed++; + console.log(' ✅ falls back to hop-based selection with honest "deepest (most hops)" wording when no branch has distance data'); + } catch (e) { failed++; console.log(' ❌ falls back to hop-based selection with honest "deepest (most hops)" wording when no branch has distance data: ' + e.message); } + })(); + await (async () => { try { // A single-neighbor approx point should render with a bigger, From e851a435645ce42764cc77ddecfe92f5b1181c3d Mon Sep 17 00:00:00 2001 From: dborup Date: Sun, 26 Jul 2026 09:56:40 +0200 Subject: [PATCH 2/3] feat: View Path highlights both farthest and deepest routes when they differ Following up on the previous fix (highlight by real distance, not hop count): dborup asked why not show both when they diverge, rather than picking one and letting the other blend into the secondary stations. Now tracks two independent roles per branch -- farthest (by distanceFromFirstKm) and deepest (by hop count) -- and highlights each with its own color (accent for farthest, purple for deepest) plus its own legend entry, when they're genuinely different branches. When they're the same branch (the common case) it's still shown once, combined, exactly as before. When no branch has distance data at all, falls back to the single "deepest (most hops)" highlight, also unchanged. Also fixes a gap this surfaced: an observer-only branch (no relay hops, e.g. a 0-hop direct reception) has no polyline and always FILLS yellow regardless of role, so farthest vs. deepest were previously indistinguishable on such a marker. The ring (stroke) color now carries the role instead, so a highlighted observer reads as an accent- or purple-ringed yellow dot rather than a plain one. --- public/packet-path-map.js | 118 +++++++++++++++++++++++++++----------- test-packet-path-map.js | 71 ++++++++++++++++++----- 2 files changed, 143 insertions(+), 46 deletions(-) diff --git a/public/packet-path-map.js b/public/packet-path-map.js index e54f2d69..dac1372d 100644 --- a/public/packet-path-map.js +++ b/public/packet-path-map.js @@ -132,6 +132,7 @@ '

How far and how wide this packet spread. Click a marker to open that node\'s detail page.

' + '
' + 'farthest-traveled route' + + '' + 'other station' + 'approximate position' + 'first to hear it' + @@ -159,32 +160,50 @@ var branches = data.branches || []; var plotted = branches.map(function (b) { var built = chainForBranch(b); - return { branch: b, chain: built.chain, missing: built.missing, primary: false }; + return { branch: b, chain: built.chain, missing: built.missing, highlightRole: null }; }).filter(function (p) { return p.chain.length > 0; }); - // The "highlighted" branch used to just be branches[0] (most hops) -- - // but more hops doesn't mean more geographic distance (a dense area - // can take many short hops; a couple of long-range links can cover - // more real distance in fewer). Prefer the branch that actually - // traveled farthest by distanceFromFirstKm when any branch has that - // data; among ties, branches[] is already deepest-first so the - // earliest match also has the most hops. Falls back to the old - // hops-based pick (plotted[0]) only when NO branch has usable - // distance data (e.g. sparse GPS coverage) -- there's nothing better - // to compare by in that case. + // Two different questions, not always the same branch: which station + // did the packet reach at the greatest real-world DISTANCE + // (distanceFromFirstKm), and which took the most HOPS to reach it (a + // dense area can take many short hops; a couple of long-range links + // can cover more real distance in fewer -- caught on a real packet: + // 7 hops but 117km vs. 6 hops but 124km). Both get their own + // highlight when they're different branches; when they coincide (the + // common case) it's shown once, combined, exactly as before. var hasDistanceData = plotted.some(function (p) { return typeof p.branch.distanceFromFirstKm === 'number'; }); - var primaryIdx = 0; + var deepestIdx = 0; + var deepestHopsSeen = -1; + plotted.forEach(function (p, i) { + if (p.branch.hops > deepestHopsSeen) { + deepestHopsSeen = p.branch.hops; + deepestIdx = i; + } + }); + var farthestIdx = -1; if (hasDistanceData) { var farthestKm = -1; plotted.forEach(function (p, i) { var d = p.branch.distanceFromFirstKm; if (typeof d === 'number' && d > farthestKm) { farthestKm = d; - primaryIdx = i; + farthestIdx = i; } }); } - if (plotted[primaryIdx]) plotted[primaryIdx].primary = true; + // 'deepestFallback' (no distance data anywhere) reuses the farthest + // slot's accent color -- there's only one highlighted concept in + // that case, same single-highlight look as before this branch/role + // split existed. 'deepest' (purple) only appears when it's actually + // a DIFFERENT branch from farthest -- the genuinely new information. + if (!hasDistanceData) { + if (plotted[deepestIdx]) plotted[deepestIdx].highlightRole = 'deepestFallback'; + } else if (farthestIdx === deepestIdx) { + if (plotted[deepestIdx]) plotted[deepestIdx].highlightRole = 'both'; + } else { + if (plotted[farthestIdx]) plotted[farthestIdx].highlightRole = 'farthest'; + if (plotted[deepestIdx]) plotted[deepestIdx].highlightRole = 'deepest'; + } if (plotted.length === 0) { if (statusEl) { @@ -217,8 +236,16 @@ var outline = cssVar('--surface-0'); var accent = cssVar('--accent'); + var deepestColor = cssVar('--status-purple'); var observerColor = cssVar('--status-yellow'); var muted = cssVar('--text-muted'); + // 'both'/'farthest'/'deepestFallback' all read as the accent color; + // only a genuinely-distinct 'deepest' branch gets the second color. + function colorForRole(role) { + if (role === 'deepest') return deepestColor; + if (role) return accent; + return muted; + } // Shade every touched area's configured boundary as a faint // background layer -- ties the "touched: X, Y" footer text to actual @@ -262,11 +289,13 @@ // appearances (#1... a packet heard by 12 stations through one shared // repeater was showing "11 approximate" for what was really 1 node). var approxSeen = {}; - // Draw secondary branches first so the primary (deepest) one ends up on top. - var ordered = plotted.slice().sort(function (a, b) { return (a.primary ? 1 : 0) - (b.primary ? 1 : 0); }); + // Draw non-highlighted branches first so the highlighted one(s) -- + // farthest and/or deepest -- end up drawn on top. + var ordered = plotted.slice().sort(function (a, b) { return (a.highlightRole ? 1 : 0) - (b.highlightRole ? 1 : 0); }); ordered.forEach(function (p) { missingTotal += p.missing; - var lineColor = p.primary ? accent : muted; + var isHighlighted = !!p.highlightRole; + var lineColor = colorForRole(p.highlightRole); var line = []; p.chain.forEach(function (pt) { if (pt.approx) { @@ -279,7 +308,17 @@ bounds.push([pt.lat, pt.lon]); line.push([pt.lat, pt.lon]); var color = pt.isObserver ? observerColor : lineColor; - var radius = p.primary ? (pt.isObserver ? 7 : 6) : (pt.isObserver ? 5 : 4); + var radius = isHighlighted ? (pt.isObserver ? 7 : 6) : (pt.isObserver ? 5 : 4); + // Observer dots always FILL yellow (the established "this is a + // hearing station" language, kept regardless of role) -- but for + // an observer that's also the branch's only point (a direct/ + // 0-hop reception has no relay hops and thus no polyline + // either), fill color alone can't show whether it's the + // farthest, deepest, or neither. The STROKE carries the role + // color instead when highlighted, so a farthest/deepest observer + // still reads as accent/purple-ringed, not just "a yellow dot" + // indistinguishable from every other observer. + var strokeColor = isHighlighted ? lineColor : outline; var markerOpts = pt.approx // Approximate (borrowed-from-neighbor) position: larger, // thick-dashed ring with a faint fill -- a plain hollow outline @@ -289,10 +328,10 @@ // more agreeing neighbors = tighter, more solid; one neighbor // or a wide spread among several = bigger, fainter. ? { - radius: radius + approxRadiusBonus(pt.approxNeighborCount, pt.approxSpreadKm), color: color, weight: 3, + radius: radius + approxRadiusBonus(pt.approxNeighborCount, pt.approxSpreadKm), color: isHighlighted ? lineColor : color, weight: 3, fillColor: color, fillOpacity: approxFillOpacity(pt.approxNeighborCount), dashArray: '5,4', } - : { radius: radius, color: outline, weight: p.primary ? 2 : 1, fillColor: color, fillOpacity: p.primary ? 1 : 0.8 }; + : { radius: radius, color: strokeColor, weight: isHighlighted ? 2 : 1, fillColor: color, fillOpacity: isHighlighted ? 1 : 0.8 }; var approxNote = ''; if (pt.approx) { approxNote = ', approx. position'; @@ -310,11 +349,11 @@ window.location.hash = '#/nodes/' + encodeURIComponent(pt.publicKey); }); } - markerEntries.push({ layer: marker, primary: p.primary, approx: !!pt.approx }); + markerEntries.push({ layer: marker, primary: isHighlighted, 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); - polylineEntries.push({ layer: polyline, primary: p.primary }); + var polyline = L.polyline(line, { color: lineColor, weight: isHighlighted ? 2.5 : 1.5, opacity: isHighlighted ? 0.85 : 0.5 }).addTo(map); + polylineEntries.push({ layer: polyline, primary: isHighlighted }); } }); // The earliest-arriving observation, drawn last so its landmark ring @@ -339,27 +378,40 @@ setTimeout(function () { map.invalidateSize(); }, 120); activeMap = map; - // Label the highlighted branch honestly: only call it - // "farthest-traveled" when it was actually picked by real distance - // (see the primaryIdx selection above) -- when no branch has usable - // distance data, what's highlighted is really just the one with the - // most hops, which is a different thing and shouldn't borrow the - // "farthest" word. - var primaryLabel = hasDistanceData ? 'farthest-traveled' : 'deepest (most hops)'; + // Label the highlight(s) honestly, matching the role split above: + // no distance data at all -> one accent-colored "deepest (most + // hops)" route (old single-highlight look); farthest and deepest + // are the same branch -> one accent-colored route, combined label; + // genuinely different branches -> two legend entries, accent + // "farthest-traveled" plus a second purple "deepest (most hops)". + var primaryLabel; + var showDeepestLegendItem = false; + if (!hasDistanceData) { + primaryLabel = 'deepest (most hops)'; + } else if (farthestIdx === deepestIdx) { + primaryLabel = 'farthest-traveled & deepest'; + } else { + primaryLabel = 'farthest-traveled'; + showDeepestLegendItem = true; + } var primaryLegendLabel = document.getElementById('packetPathPrimaryLegendLabel'); if (primaryLegendLabel) primaryLegendLabel.textContent = primaryLabel + ' route'; + var deepestLegendItem = document.getElementById('packetPathDeepestLegendItem'); + if (deepestLegendItem && showDeepestLegendItem) deepestLegendItem.style.display = 'inline-flex'; // 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) { + var highlightedCount = plotted.filter(function (p) { return !!p.highlightRole; }).length; + var hiddenCount = plotted.length - highlightedCount; + var toggleRouteLabel = showDeepestLegendItem ? 'farthest-traveled and deepest routes' : (primaryLabel + ' route'); + if (plotted.length > highlightedCount) { controlsHtml += ''; } if (approxTotal > 0) { diff --git a/test-packet-path-map.js b/test-packet-path-map.js index bc99c40b..2e801b6d 100644 --- a/test-packet-path-map.js +++ b/test-packet-path-map.js @@ -51,8 +51,8 @@ test('draws every branch, not just the deepest one', () => { assert.ok(/branches\.map/.test(src), 'should iterate all branches from the response'); }); -test('draws the deepest branch on top of the others (primary drawn last)', () => { - assert.ok(/a\.primary \? 1 : 0/.test(src) || /primary.*sort/.test(src), 'should reorder so the primary branch paints last'); +test('draws highlighted branch(es) on top of the others', () => { + assert.ok(/a\.highlightRole \? 1 : 0/.test(src), 'should reorder so highlighted (farthest/deepest) branches paint last'); }); test('handles Escape key and click-outside to close, matching other CoreScope modals', () => { @@ -689,18 +689,25 @@ function makeSandbox(apiImpl) { await (async () => { try { - // More hops does not mean more geographic distance -- the - // highlighted ("primary") branch must be picked by actual - // distanceFromFirstKm when that data exists, not by branches[0] - // (the deepest-by-hops branch, which is a different thing). Here + // More hops does not mean more geographic distance, and they're not + // always the same branch -- both deserve their own highlight. Here // the SHALLOWER branch (2 hops) travels much farther (200km) than - // the DEEPER one (5 hops, only 50km) -- a dense-mesh short-hop - // chain vs. a couple of long-range links. + // the DEEPER one (5 hops, only 50km): a dense-mesh short-hop chain + // vs. a couple of long-range links. Both get weight-2 (highlighted) + // styling, but in DIFFERENT colors -- farthest keeps the accent + // color, deepest gets the second (purple) color -- and the legend + // shows both. const ctx = makeSandbox(() => Promise.resolve({ hash: 'deadbeef', branches: [ { hops: 5, points: [], observer: { name: 'DeepButClose', lat: 56.0, lon: 10.0 }, distanceFromFirstKm: 50 }, { hops: 2, points: [], observer: { name: 'ShallowButFar', lat: 57.0, lon: 11.0 }, distanceFromFirstKm: 200 }, + // A third, fully-secondary branch -- neither farthest nor + // deepest -- so there's something left for the declutter + // toggle to actually hide (with only the two highlighted + // branches, hiddenCount would be 0 and no toggle appears at + // all, same as the existing single-branch case). + { hops: 3, points: [], observer: { name: 'PlainSecondary', lat: 56.5, lon: 10.5 }, distanceFromFirstKm: 80 }, ], })); @@ -720,14 +727,52 @@ function makeSandbox(apiImpl) { const deepMarker = markerCalls.find((m) => m.tooltip && m.tooltip.includes('DeepButClose')); const farMarker = markerCalls.find((m) => m.tooltip && m.tooltip.includes('ShallowButFar')); assert.ok(deepMarker && farMarker, 'expected markers for both observers, got: ' + JSON.stringify(markerCalls.map((m) => m.tooltip))); - assert.strictEqual(farMarker.opts.weight, 2, 'ShallowButFar (200km, actually farthest) should get primary styling (weight 2), got ' + farMarker.opts.weight); - assert.strictEqual(deepMarker.opts.weight, 1, 'DeepButClose (5 hops but only 50km) should get secondary styling (weight 1) despite having more hops, got ' + deepMarker.opts.weight); + assert.strictEqual(farMarker.opts.weight, 2, 'ShallowButFar (200km, actually farthest) should get highlighted styling (weight 2), got ' + farMarker.opts.weight); + assert.strictEqual(deepMarker.opts.weight, 2, 'DeepButClose (5 hops, most hops) should ALSO get highlighted styling (weight 2) via its own "deepest" role, got ' + deepMarker.opts.weight); + // Both are observer-only (no relay hop) points, so both FILL + // yellow (the constant "this is an observer" color) -- the role + // distinction shows up in the ring (stroke) color instead. + assert.notStrictEqual(farMarker.opts.color, deepMarker.opts.color, 'farthest and deepest are different branches here, so their marker rings must use different colors, got matching color ' + farMarker.opts.color); const legendLabel = ctx.document.getElementById('packetPathPrimaryLegendLabel'); - assert.ok(legendLabel && legendLabel.textContent.includes('farthest-traveled'), 'legend should say "farthest-traveled" when real distance data picked the highlight, got: ' + (legendLabel && legendLabel.textContent)); + assert.ok(legendLabel && legendLabel.textContent.includes('farthest-traveled') && !legendLabel.textContent.includes('deepest'), 'primary legend slot should say just "farthest-traveled" (deepest gets its own slot) when they diverge, got: ' + (legendLabel && legendLabel.textContent)); + const deepestLegendItem = ctx.document.getElementById('packetPathDeepestLegendItem'); + assert.strictEqual(deepestLegendItem.style.display, 'inline-flex', 'the second "deepest" legend swatch should be shown when farthest and deepest are different branches'); + const controlsEl = ctx.document.getElementById('packetPathControls'); + assert.ok(controlsEl.innerHTML.includes('farthest-traveled and deepest routes'), 'declutter checkbox should mention both routes when they diverge, got: ' + controlsEl.innerHTML); passed++; - console.log(' ✅ the highlighted branch is picked by actual distance, not hop count, when distance data exists'); - } catch (e) { failed++; console.log(' ❌ the highlighted branch is picked by actual distance, not hop count, when distance data exists: ' + e.message); } + console.log(' ✅ farthest and deepest get their own distinct highlight (color + legend entry) when they are different branches'); + } catch (e) { failed++; console.log(' ❌ farthest and deepest get their own distinct highlight (color + legend entry) when they are different branches: ' + e.message); } + })(); + + await (async () => { + try { + // The common case: the deepest branch IS also the farthest one. + // Must show a SINGLE combined highlight (not two), still only the + // accent color, with a combined label -- not a second purple + // "deepest" swatch for a branch that's already shown. + const ctx = makeSandbox(() => Promise.resolve({ + hash: 'deadbeef', + branches: [ + { hops: 5, points: [], observer: { name: 'DeepAndFar', lat: 57.0, lon: 11.0 }, distanceFromFirstKm: 200 }, + { hops: 2, points: [], observer: { name: 'ShallowAndClose', lat: 56.0, lon: 10.0 }, distanceFromFirstKm: 20 }, + ], + })); + 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 legendLabel = ctx.document.getElementById('packetPathPrimaryLegendLabel'); + assert.ok(legendLabel && legendLabel.textContent.includes('farthest-traveled & deepest'), 'legend should combine both facts into one label for the same branch, got: ' + (legendLabel && legendLabel.textContent)); + const deepestLegendItem = ctx.document.getElementById('packetPathDeepestLegendItem'); + assert.notStrictEqual(deepestLegendItem.style.display, 'inline-flex', 'the second "deepest" legend swatch must stay hidden when it is the same branch as farthest, got display=' + deepestLegendItem.style.display); + passed++; + console.log(' ✅ shows one combined highlight (not two) when the deepest branch is also the farthest one'); + } catch (e) { failed++; console.log(' ❌ shows one combined highlight (not two) when the deepest branch is also the farthest one: ' + e.message); } })(); await (async () => { From d25d0253d9f42d792e9c40298581bf47669718b0 Mon Sep 17 00:00:00 2001 From: dborup Date: Sun, 26 Jul 2026 10:30:21 +0200 Subject: [PATCH 3/3] feat: View Path status line shows elapsed time and total spread duration Three additions, all using secondsAfterFirst/distanceFromFirstKm data the response already carried -- no backend changes needed: - "deepest reached N hops (Xs)" -- the deepest branch's own elapsed time, appended to the existing stat. - "farthest reached Xkm (Ys)" -- new stat: the actual distance the farthest branch reached, plus its elapsed time. Previously only implied via the "touched" area list or a marker's hover tooltip, never stated as its own number. - "fully spread in Zs" -- the largest secondsAfterFirst across ALL branches, not just farthest/deepest (a middling branch stuck behind a slow relay can still be the last one reached). All three degrade cleanly (omitted, not "NaNs") when a branch's timing is unknown. --- public/packet-path-map.js | 30 +++++++++++++++++++- test-packet-path-map.js | 60 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/public/packet-path-map.js b/public/packet-path-map.js index dac1372d..ceb22bf4 100644 --- a/public/packet-path-map.js +++ b/public/packet-path-map.js @@ -37,6 +37,17 @@ return '+' + m + 'm ' + s + 's'; } + // Plain "Xs"/"Xm Ys" duration, no leading "+" or "first to arrive" -- + // for footer stats standing on their own (unlike formatElapsed's + // tooltip use, these aren't continuing a "this station arrived..." + // sentence). + function formatDuration(seconds) { + if (seconds < 60) return seconds.toFixed(1) + 's'; + var m = Math.floor(seconds / 60); + var s = Math.round(seconds % 60); + return m + 'm ' + s + 's'; + } + // How much bigger/fuzzier an approximate marker's ring should be than // a normal marker, given how many positioned neighbors fed the // estimate (more = tighter) and how much they disagreed (a wide @@ -467,10 +478,27 @@ } var deepestHops = branches[0].hops; + var deepestBranch = plotted[deepestIdx] && plotted[deepestIdx].branch; + var deepestSeconds = deepestBranch && typeof deepestBranch.secondsAfterFirst === 'number' ? deepestBranch.secondsAfterFirst : null; var statusParts = [ plotted.length + ' of ' + branches.length + ' station' + (branches.length === 1 ? '' : 's') + ' shown', - 'deepest reached ' + deepestHops + ' hop' + (deepestHops === 1 ? '' : 's'), + 'deepest reached ' + deepestHops + ' hop' + (deepestHops === 1 ? '' : 's') + (deepestSeconds != null ? ' (' + formatDuration(deepestSeconds) + ')' : ''), ]; + if (hasDistanceData) { + var farthestBranch = plotted[farthestIdx] && plotted[farthestIdx].branch; + var farthestSeconds = farthestBranch && typeof farthestBranch.secondsAfterFirst === 'number' ? farthestBranch.secondsAfterFirst : null; + statusParts.push('farthest reached ' + farthestKm.toFixed(1) + 'km' + (farthestSeconds != null ? ' (' + formatDuration(farthestSeconds) + ')' : '')); + } + // How long the whole flood took to finish reaching every station it + // ever reached -- the largest secondsAfterFirst across ALL branches, + // not just the farthest/deepest ones (a station that's neither can + // still be the last to hear it). + var maxSpreadSeconds = null; + plotted.forEach(function (p) { + var s = p.branch.secondsAfterFirst; + if (typeof s === 'number' && (maxSpreadSeconds === null || s > maxSpreadSeconds)) maxSpreadSeconds = s; + }); + if (maxSpreadSeconds != null && maxSpreadSeconds > 0) statusParts.push('fully spread in ' + formatDuration(maxSpreadSeconds)); if (firstPoint) statusParts.push('entered near ' + firstPoint.name); if (approxTotal > 0) statusParts.push(approxTotal + ' approximate (estimated from neighbors)'); if (missingTotal > 0) statusParts.push(missingTotal + ' hop' + (missingTotal === 1 ? '' : 's') + ' without a known position (not shown)'); diff --git a/test-packet-path-map.js b/test-packet-path-map.js index 2e801b6d..c99bacf8 100644 --- a/test-packet-path-map.js +++ b/test-packet-path-map.js @@ -806,6 +806,66 @@ function makeSandbox(apiImpl) { } catch (e) { failed++; console.log(' ❌ falls back to hop-based selection with honest "deepest (most hops)" wording when no branch has distance data: ' + e.message); } })(); + await (async () => { + try { + // Status line should report how long it took to reach the deepest + // and farthest stations, plus the overall spread duration (the + // largest secondsAfterFirst across ALL branches, not just those + // two -- a station that's neither can still be the last to hear + // it, e.g. a middling branch stuck behind a slow relay). + const ctx = makeSandbox(() => Promise.resolve({ + hash: 'deadbeef', + branches: [ + { hops: 5, points: [], observer: { name: 'DeepButClose', lat: 56.0, lon: 10.0 }, distanceFromFirstKm: 50, secondsAfterFirst: 4.1 }, + { hops: 2, points: [], observer: { name: 'ShallowButFar', lat: 57.0, lon: 11.0 }, distanceFromFirstKm: 200, secondsAfterFirst: 3.2 }, + { hops: 3, points: [], observer: { name: 'SlowestOfAll', lat: 56.5, lon: 10.5 }, distanceFromFirstKm: 80, secondsAfterFirst: 9.7 }, + ], + })); + 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 status = ctx.document.getElementById('packetPathStatus'); + assert.ok(status.textContent.includes('deepest reached 5 hops (4.1s)'), 'status should show the deepest branch\'s own elapsed time, got: ' + status.textContent); + assert.ok(status.textContent.includes('farthest reached 200.0km (3.2s)'), 'status should show the farthest branch\'s distance and its own elapsed time, got: ' + status.textContent); + assert.ok(status.textContent.includes('fully spread in 9.7s'), 'status should report the LARGEST elapsed time across all branches (SlowestOfAll, neither deepest nor farthest), not just the deepest/farthest ones, got: ' + status.textContent); + passed++; + console.log(' ✅ status line reports deepest/farthest elapsed time plus overall spread duration (max across all branches)'); + } catch (e) { failed++; console.log(' ❌ status line reports deepest/farthest elapsed time plus overall spread duration (max across all branches): ' + e.message); } + })(); + + await (async () => { + try { + // No branch has secondsAfterFirst at all -- must omit all three + // timing additions cleanly rather than showing "(NaNs)" or a + // spurious "fully spread in 0.0s". + const ctx = makeSandbox(() => Promise.resolve({ + hash: 'deadbeef', + branches: [ + { hops: 3, points: [], observer: { name: 'ObsA', lat: 56.0, lon: 10.0 }, distanceFromFirstKm: 40 }, + ], + })); + 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 status = ctx.document.getElementById('packetPathStatus'); + assert.ok(status.textContent.includes('deepest reached 3 hops') && !status.textContent.includes('deepest reached 3 hops ('), 'deepest line should have no "(Xs)" suffix when secondsAfterFirst is unknown, got: ' + status.textContent); + assert.ok(status.textContent.includes('farthest reached 40.0km') && !status.textContent.includes('farthest reached 40.0km ('), 'farthest line should have no "(Xs)" suffix when secondsAfterFirst is unknown, got: ' + status.textContent); + assert.ok(!status.textContent.includes('fully spread'), 'should not claim a spread duration when no branch has timing data, got: ' + status.textContent); + passed++; + console.log(' ✅ omits timing suffixes and the spread-duration stat when no branch has secondsAfterFirst'); + } catch (e) { failed++; console.log(' ❌ omits timing suffixes and the spread-duration stat when no branch has secondsAfterFirst: ' + e.message); } + })(); + await (async () => { try { // A single-neighbor approx point should render with a bigger,