Files
meshcore-analyzer/public/compare.js
T
Kpa-clawbot 167af54eb8 polish(#1645): tighten observer compare — checkboxes, hierarchy, selector strip (#1647)
## Summary

Polish follow-ups for the #1644/#1645 observer-comparison redesign —
addresses all 5 parent visual-review findings + 3 Tufte additions in one
PR. Fixes #1646.

## Coalesced fix list (status:  all landed)

| # | Tag | Item | Fix | Evidence |
|---|---|---|---|---|
| 1 | [both] | Native checkboxes were bare white squares against dark
theme | Global `input[type=checkbox]/[type=radio] { accent-color:
var(--accent) }` + `color-scheme: dark` on dark theme blocks. UA renders
themed checkboxes everywhere now. |
`screenshots-1646/after-observers-selected-dark.jpg` vs
`before-observers-selected-dark.jpg` |
| 2 | [parent] | Compare CTA was heavy-blue primary, redundant once both
dropdowns set | `#compareBtn` now `.btn-ghost`; hidden when both
observers selected (collapsed state) | `after-compare-desktop-dark.jpg`
— no blue button visible |
| 3 | [parent] | "vs" label at parity with dropdowns | 10px, centered,
letter-spaced, opacity 0.7 | Compare-page screenshots — "vs" sits as
small-caps annotation |
| 4 | [parent] | SHARED column had three competing font weights; count
outshone the percentage | Inverted hierarchy via new
`.compare-strip-mid-pct` + `.compare-strip-mid-pct-unit`. 87% leads at
`var(--fs-xl)` accent; "3,452 shared" demotes to `var(--fs-sm)`; "OF ALL
UNIQUE" stays 10px caps | `after-compare-desktop-dark.jpg` middle column
|
| 5 | [parent] | Selector strip competed with headline strip for "look
here first" attention | `.compare-controls.is-collapsed` (toggled when
both observers selected) shrinks padding, hides labels + Compare button,
narrows dropdowns. A/B swap still reachable |
`after-compare-desktop-light.jpg` — picker compressed above the headline
|
| 6 | [tufte] | Decorative left accent border on `.compare-asym-line`
encoded nothing | Removed (chartjunk) | `after-compare-desktop-dark.jpg`
— squared cards |
| 7 | [tufte] | Decorative left green border on `.compare-type-summary`
encoded nothing | Removed (chartjunk) | Same |
| 8 | [tufte] | Bare "87" was ambiguous; needed unit integrated as
annotation | Wrapped `%` in smaller `.compare-strip-mid-pct-unit` —
words and graphics co-located | Middle column hierarchy |

## Push-backs / scope discipline

- **Did NOT remove the selector strip entirely.** Parent rule + operator
UX: A/B swap must remain reachable. Collapsing > removing.
- **Did NOT introduce a custom checkbox widget.** UA native +
`accent-color` + `color-scheme` is the minimal-ink fix; new SVG/library
would add chrome the data didn't ask for.
- **Did NOT add new color tokens.** All restyling uses existing
`--accent`, `--text`, `--text-muted`, `--surface-1/2`, `--border`.

## TDD

- Red commit: `2863cfb3 test(#1646): RED — assertions for compare-polish
...` — `test-issue-1646-compare-polish.js` 9 assertions, all FAIL on
master.
- Green commits (3 logical groups):
1. `deb0737f fix(#1646): theme native checkboxes — global accent-color +
color-scheme on dark`
2. `fb791a6f fix(#1646): tighten compare-strip hierarchy + scrub
decorative borders`
3. `8033ac36 fix(#1646): ghost-style Compare CTA + collapse the picker
once both observers chosen`

Final: `node test-issue-1646-compare-polish.js` → 9/9 pass; `node
test-issue-1644-redesign.js` → 13/13 pass (no regression); `node
test-compare-overlap.js` → 6/6 pass; `node test-frontend-helpers.js` →
611/611 pass.

## Visual verification

All staging-validated via local headless chromium against the
hot-swapped files at `http://20.x.y.z` (staging). Surface matrix
covered:

- Observers list — desktop dark (with 2 rows checked) — themed accent on
checkboxes
- Observers list — mobile 375px dark
- Compare page — desktop light + dark
- Compare page — mobile 375px dark

**Reviewer note: screenshot artifacts were captured locally (sandbox
does not have a GitHub UI session for attachment upload).** Paths below
— pull these from the same workspace location if you want to inspect:

```
screenshots-1646/before-observers-desktop-dark.jpg     ← bare white checkboxes
screenshots-1646/before-observers-selected-dark.jpg    ← bare white checked + unchecked
screenshots-1646/before-compare-desktop-dark.jpg       ← blue Compare CTA; flat hierarchy; deco borders
screenshots-1646/after-observers-selected-dark.jpg     ← themed checkboxes
screenshots-1646/after-observers-mobile-dark.jpg
screenshots-1646/after-compare-desktop-light.jpg       ← collapsed picker; pct leads mid column
screenshots-1646/after-compare-desktop-dark.jpg
screenshots-1646/after-compare-mobile-dark.jpg
```

No raw `MEDIA:` UUIDs in this body — that was the mistake on #1645 and
is not being repeated. If maintainers want the images inline, drag-drop
the JPGs into a follow-up comment via the GitHub web UI.

## Risk

Low. Pure CSS + one class-toggle in `compare.js`'s `updateBtn`
(idempotent, no race, no event loop change). `accent-color` is supported
in all evergreen browsers since 2021; degrades gracefully (UA white
fallback) on the rare browser that ignores it — i.e. exactly the
current-master state.

---------

Co-authored-by: openclaw-bot <openclaw-bot@users.noreply.github.com>
Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: clawbot <clawbot@users.noreply.github.com>
2026-06-11 05:44:00 +00:00

542 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* === CoreScope — compare.js === */
/* Observer packet comparison — Fixes #129 */
'use strict';
/**
* Compare two sets of packet hashes using Set operations.
* Returns { onlyA, onlyB, both } as arrays of hashes.
* O(n) via Set lookups — no nested loops.
*/
function comparePacketSets(hashesA, hashesB) {
var setA = hashesA instanceof Set ? hashesA : new Set(hashesA || []);
var setB = hashesB instanceof Set ? hashesB : new Set(hashesB || []);
var onlyA = [];
var onlyB = [];
var both = [];
setA.forEach(function (h) {
if (setB.has(h)) both.push(h);
else onlyA.push(h);
});
setB.forEach(function (h) {
if (!setA.has(h)) onlyB.push(h);
});
return { onlyA: onlyA, onlyB: onlyB, both: both };
}
/**
* Filter packets by route type.
* mode: 'all' | 'flood' | 'direct'
* Flood = route_type 0 (TransportFlood) or 1 (Flood)
* Direct = route_type 2 (Direct) or 3 (TransportDirect)
*/
function filterPacketsByRoute(packets, mode) {
if (!packets || mode === 'all') return packets || [];
if (mode === 'flood') {
return packets.filter(function (p) { return p.route_type === 0 || p.route_type === 1; });
}
if (mode === 'direct') {
return packets.filter(function (p) { return p.route_type === 2 || p.route_type === 3; });
}
return packets;
}
/**
* Compute asymmetric overlap statistics between two observer packet sets.
* Given a comparePacketSets() result, returns:
* - totalA / totalB: unique packet count for each observer
* - shared: packets seen by both
* - onlyA / onlyB: exclusive packet counts
* - aSeesOfB: percentage of B's packets that A also saw (rounded to 0.1%)
* - bSeesOfA: percentage of A's packets that B also saw (rounded to 0.1%)
* Returns 0% (not NaN) when a denominator is zero.
*/
function computeOverlapStats(cmp) {
var onlyA = (cmp && cmp.onlyA && cmp.onlyA.length) || 0;
var onlyB = (cmp && cmp.onlyB && cmp.onlyB.length) || 0;
var shared = (cmp && cmp.both && cmp.both.length) || 0;
var totalA = onlyA + shared;
var totalB = onlyB + shared;
var aSeesOfB = totalB > 0 ? Math.round((shared / totalB) * 1000) / 10 : 0;
var bSeesOfA = totalA > 0 ? Math.round((shared / totalA) * 1000) / 10 : 0;
return {
totalA: totalA,
totalB: totalB,
shared: shared,
onlyA: onlyA,
onlyB: onlyB,
aSeesOfB: aSeesOfB,
bSeesOfA: bSeesOfA,
};
}
// Expose for testing
if (typeof window !== 'undefined') {
window.comparePacketSets = comparePacketSets;
window.filterPacketsByRoute = filterPacketsByRoute;
window.computeOverlapStats = computeOverlapStats;
}
(function () {
var PAYLOAD_LABELS = { 0: 'Request', 1: 'Response', 2: 'Direct Msg', 3: 'ACK', 4: 'Advert', 5: 'Channel Msg', 7: 'Anon Req', 8: 'Path', 9: 'Trace', 11: 'Control' };
var MAX_PACKETS = 10000;
var observers = [];
var selA = null;
var selB = null;
var comparisonResult = null;
var packetsA = [];
var packetsB = [];
var currentView = 'summary';
var routeFilter = 'all';
function init(app, routeParam) {
// Parse preselected observers from URL: #/compare?a=ID1&b=ID2
var hashParams = location.hash.split('?')[1] || '';
var params = new URLSearchParams(hashParams);
selA = params.get('a') || null;
selB = params.get('b') || null;
comparisonResult = null;
packetsA = [];
packetsB = [];
currentView = 'summary';
routeFilter = 'all';
app.innerHTML = '<div class="compare-page">' +
'<div class="page-header">' +
'<a href="#/observers" class="btn-icon" title="Back to Observers" aria-label="Back">\u2190</a>' +
'<h2>\uD83D\uDD0D Observer Comparison</h2>' +
'</div>' +
'<nav data-role="compare-breadcrumbs" aria-label="Compare breadcrumbs" class="compare-breadcrumbs"></nav>' +
'<div id="compareControls" class="compare-controls"><div class="text-center text-muted" style="padding:20px">Loading observers\u2026</div></div>' +
'<div id="compareContent"></div>' +
'</div>';
// #209 — Keyboard accessibility for compare table rows
app.addEventListener('keydown', function (e) {
var row = e.target.closest('tr[data-action="navigate"]');
if (!row) return;
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
location.hash = row.dataset.value;
});
loadObservers();
}
function destroy() {
observers = [];
selA = null;
selB = null;
comparisonResult = null;
packetsA = [];
packetsB = [];
routeFilter = 'all';
}
// #1646 round-2 — single shared "should we run a comparison?" predicate
// used by every auto-run call site so guards cannot drift apart.
// URL-prepopulated ?a=X&b=X (same observer in both slots) returns false.
function isComparisonReady() {
return !!(selA && selB && selA !== selB);
}
async function loadObservers() {
try {
var data = await api('/observers', { ttl: CLIENT_TTL.observers });
observers = (data.observers || []).sort(function (a, b) {
return (a.name || a.id).localeCompare(b.name || b.id);
});
renderControls();
if (isComparisonReady()) runComparison();
} catch (e) {
document.getElementById('compareControls').innerHTML =
'<div class="text-muted" style="padding:20px">Error loading observers: ' + escapeHtml(e.message) + '</div>';
}
}
function renderControls() {
var el = document.getElementById('compareControls');
if (!el) return;
var optionsHtml = '<option value="">Select observer\u2026</option>' +
observers.map(function (o) {
var label = escapeHtml(o.name || o.id);
var region = o.iata ? ' (' + escapeHtml(o.iata) + ')' : '';
return '<option value="' + escapeHtml(o.id) + '">' + label + region + '</option>';
}).join('');
el.innerHTML =
'<div class="compare-selector">' +
'<div class="compare-select-group">' +
'<label for="compareObsA">Observer A</label>' +
'<span class="compare-select-id" aria-hidden="true">A</span>' +
'<select id="compareObsA" class="compare-select">' + optionsHtml + '</select>' +
'</div>' +
'<span class="compare-vs">vs</span>' +
'<div class="compare-select-group">' +
'<label for="compareObsB">Observer B</label>' +
'<span class="compare-select-id" aria-hidden="true">B</span>' +
'<select id="compareObsB" class="compare-select">' + optionsHtml + '</select>' +
'</div>' +
'<div class="compare-select-group">' +
'<label for="compareRouteFilter">Packet Type</label>' +
'<select id="compareRouteFilter" class="compare-select">' +
'<option value="all">All packets</option>' +
'<option value="flood">Flood only</option>' +
'<option value="direct">Direct only</option>' +
'</select>' +
'</div>' +
'</div>';
var ddA = document.getElementById('compareObsA');
var ddB = document.getElementById('compareObsB');
if (selA) ddA.value = selA;
if (selB) ddB.value = selB;
var ddRoute = document.getElementById('compareRouteFilter');
ddRoute.value = routeFilter;
ddRoute.addEventListener('change', function () {
routeFilter = ddRoute.value;
if (comparisonResult) runComparison();
});
// #1646 — single source of truth for "should we run a comparison?".
// Called from change handlers and from the initial pre-populated
// path in loadObservers(). The picker collapse rule (.is-collapsed)
// is the ONLY DOM hook for state — no parallel data-collapsed attr.
function updateBtn() {
selA = ddA.value || null;
selB = ddB.value || null;
var wrap = document.getElementById('compareControls');
var ready = isComparisonReady();
if (wrap) wrap.classList.toggle('is-collapsed', ready);
renderBreadcrumbs();
}
function onChange() {
updateBtn();
// change events only fire when value actually changes, so any
// ready transition that lands here came from a real user action.
if (isComparisonReady()) runComparison();
}
ddA.addEventListener('change', onChange);
ddB.addEventListener('change', onChange);
updateBtn();
}
// #1640 — render breadcrumbs linking back to each observer's detail page.
// Hidden when neither observer is picked; otherwise:
// "Observers <A name> ⇆ <B name>"
// The "&lrm;" entities keep punctuation LTR in RTL contexts.
function renderBreadcrumbs() {
var el = document.querySelector('[data-role="compare-breadcrumbs"]');
if (!el) return;
function linkFor(id) {
if (!id) return null;
var match = null;
for (var i = 0; i < observers.length; i++) {
if (String(observers[i].id) === String(id)) { match = observers[i]; break; }
}
var label = match ? (match.name || match.id) : id;
return '<a href="#/observers/' + encodeURIComponent(id) + '">' + escapeHtml(label) + '</a>';
}
var parts = ['<a href="#/observers">Observers</a>'];
var aLink = linkFor(selA);
var bLink = linkFor(selB);
if (aLink || bLink) {
var pair = [];
if (aLink) pair.push(aLink);
if (bLink) pair.push(bLink);
parts.push(pair.join(' <span aria-hidden="true">\u21C6</span> '));
}
el.innerHTML = parts.join(' <span aria-hidden="true">\u203A</span> ');
}
function sinceISO(hours) {
return new Date(Date.now() - hours * 3600000).toISOString();
}
async function runComparison() {
if (!selA || !selB || selA === selB) return;
var content = document.getElementById('compareContent');
if (!content) return;
content.innerHTML = '<div class="text-center text-muted" style="padding:40px">Fetching packets\u2026</div>';
// Update URL for shareability
var base = '#/compare?a=' + encodeURIComponent(selA) + '&b=' + encodeURIComponent(selB);
if (location.hash.split('?')[0] === '#/compare') {
history.replaceState(null, '', base);
}
try {
var since24h = sinceISO(24);
var results = await Promise.all([
api('/packets?observer=' + encodeURIComponent(selA) + '&limit=' + MAX_PACKETS + '&since=' + encodeURIComponent(since24h)),
api('/packets?observer=' + encodeURIComponent(selB) + '&limit=' + MAX_PACKETS + '&since=' + encodeURIComponent(since24h))
]);
packetsA = results[0].packets || [];
packetsB = results[1].packets || [];
// Apply flood/direct filter (#928)
var filteredA = filterPacketsByRoute(packetsA, routeFilter);
var filteredB = filterPacketsByRoute(packetsB, routeFilter);
var hashesA = new Set(filteredA.map(function (p) { return p.hash; }));
var hashesB = new Set(filteredB.map(function (p) { return p.hash; }));
comparisonResult = comparePacketSets(hashesA, hashesB);
// Build hash→packet lookups for detail rendering
comparisonResult.packetMapA = new Map();
comparisonResult.packetMapB = new Map();
filteredA.forEach(function (p) { comparisonResult.packetMapA.set(p.hash, p); });
filteredB.forEach(function (p) { comparisonResult.packetMapB.set(p.hash, p); });
currentView = 'summary';
renderComparison();
} catch (e) {
content.innerHTML = '<div class="text-muted" style="padding:40px">Error: ' + escapeHtml(e.message) + '</div>';
}
}
function obsName(id) {
for (var i = 0; i < observers.length; i++) {
if (observers[i].id === id) return observers[i].name || id;
}
return id ? id.substring(0, 12) : 'Unknown';
}
function renderComparison() {
var content = document.getElementById('compareContent');
if (!content || !comparisonResult) return;
var r = comparisonResult;
var nameA = escapeHtml(obsName(selA));
var nameB = escapeHtml(obsName(selB));
var total = r.onlyA.length + r.onlyB.length + r.both.length;
var pctBoth = total > 0 ? Math.round(r.both.length / total * 100) : 0;
var pctA = total > 0 ? Math.round(r.onlyA.length / total * 100) : 0;
var pctB = total > 0 ? Math.round(r.onlyB.length / total * 100) : 0;
// Type breakdown for "both" packets
var typeBreakdown = {};
r.both.forEach(function (h) {
var p = r.packetMapA.get(h) || r.packetMapB.get(h);
if (p) {
var t = p.payload_type;
typeBreakdown[t] = (typeBreakdown[t] || 0) + 1;
}
});
var typeHtml = Object.keys(typeBreakdown).map(function (t) {
return '<span class="compare-type-badge">' +
escapeHtml(PAYLOAD_LABELS[t] || 'Type ' + t) + ' <b>' + typeBreakdown[t] + '</b>' +
'</span>';
}).join('');
var stats = computeOverlapStats(r);
content.innerHTML =
'<div class="compare-results">' +
// Headline strip — A | shared | B above a single proportional bar.
// All three cells lead with their percentage so the row reads in
// one unit (Tufte: show data variation, not design variation).
// #1646
'<section class="compare-strip" aria-label="Packet overlap summary">' +
'<div class="compare-strip-row">' +
'<div class="compare-strip-side" data-view="onlyA" role="button" tabindex="0" aria-label="Show only ' + nameA + ' packets">' +
'<div class="compare-strip-name">' + nameA + '</div>' +
'<div class="compare-strip-side-pct">' + pctA + '<span class="compare-strip-side-pct-unit">%</span></div>' +
'<div class="compare-strip-sub">' + r.onlyA.length.toLocaleString() + ' only here</div>' +
'</div>' +
'<div class="compare-strip-mid" data-view="both" role="button" tabindex="0" aria-label="Show shared packets">' +
'<div class="compare-strip-mid-pct">' + pctBoth + '<span class="compare-strip-mid-pct-unit">%</span></div>' +
'<div class="compare-strip-mid-count">' + r.both.length.toLocaleString() + '</div>' +
'<div class="compare-strip-mid-label">of all unique</div>' +
'</div>' +
'<div class="compare-strip-side compare-strip-side-b" data-view="onlyB" role="button" tabindex="0" aria-label="Show only ' + nameB + ' packets">' +
'<div class="compare-strip-name">' + nameB + '</div>' +
'<div class="compare-strip-side-pct">' + pctB + '<span class="compare-strip-side-pct-unit">%</span></div>' +
'<div class="compare-strip-sub">' + r.onlyB.length.toLocaleString() + ' only here</div>' +
'</div>' +
'</div>' +
// Single shared-axis diff bar. Width is exact proportion.
'<div class="compare-bar-container">' +
'<div class="compare-bar" role="img"' +
' aria-label="' + nameA + ' only ' + pctA + '%, both ' + pctBoth + '%, ' + nameB + ' only ' + pctB + '%">' +
(pctA > 0 ? '<div class="compare-bar-seg compare-bar-a" style="width:' + pctA + '%" title="Only ' + nameA + ': ' + r.onlyA.length + '"></div>' : '') +
(pctBoth > 0 ? '<div class="compare-bar-seg compare-bar-both" style="width:' + pctBoth + '%" title="Both: ' + r.both.length + '"></div>' : '') +
(pctB > 0 ? '<div class="compare-bar-seg compare-bar-b" style="width:' + pctB + '%" title="Only ' + nameB + ': ' + r.onlyB.length + '"></div>' : '') +
'</div>' +
'<div class="compare-bar-legend">' +
'<span class="compare-legend-item"><span class="compare-dot compare-dot-a"></span> ' + nameA + ' only</span>' +
'<span class="compare-legend-item"><span class="compare-dot compare-dot-both"></span> Both</span>' +
'<span class="compare-legend-item"><span class="compare-dot compare-dot-b"></span> ' + nameB + ' only</span>' +
'</div>' +
'</div>' +
'</section>' +
// Asymmetric reach — two compact sentences instead of two big cards
'<section class="compare-asym" aria-label="Directional reach">' +
'<div class="compare-asym-line">' +
'<span class="compare-asym-pct">' + stats.aSeesOfB.toFixed(1) + '%</span>' +
nameA + ' saw <b>' + stats.shared.toLocaleString() + '</b> of ' + nameB +
'\u2019s <b>' + stats.totalB.toLocaleString() + '</b> packets' +
'</div>' +
'<div class="compare-asym-line">' +
'<span class="compare-asym-pct">' + stats.bSeesOfA.toFixed(1) + '%</span>' +
nameB + ' saw <b>' + stats.shared.toLocaleString() + '</b> of ' + nameA +
'\u2019s <b>' + stats.totalA.toLocaleString() + '</b> packets' +
'</div>' +
'</section>' +
// Shared packet types — pills, mono-numeric
(typeHtml ? '<section class="compare-type-summary" aria-label="Shared packet types">' +
'<span class="compare-type-summary-label">Shared types</span>' + typeHtml +
'</section>' : '') +
// Detail tabs
'<div class="compare-tabs" role="tablist">' +
'<button class="tab-btn' + (currentView === 'summary' ? ' active' : '') + '" data-cview="summary" role="tab" aria-controls="compareDetail" aria-selected="' + (currentView === 'summary' ? 'true' : 'false') + '" tabindex="' + (currentView === 'summary' ? '0' : '-1') + '">Summary</button>' +
'<button class="tab-btn' + (currentView === 'both' ? ' active' : '') + '" data-cview="both" role="tab" aria-controls="compareDetail" aria-selected="' + (currentView === 'both' ? 'true' : 'false') + '" tabindex="' + (currentView === 'both' ? '0' : '-1') + '">Both (' + r.both.length + ')</button>' +
'<button class="tab-btn' + (currentView === 'onlyA' ? ' active' : '') + '" data-cview="onlyA" role="tab" aria-controls="compareDetail" aria-selected="' + (currentView === 'onlyA' ? 'true' : 'false') + '" tabindex="' + (currentView === 'onlyA' ? '0' : '-1') + '">Only ' + nameA + ' (' + r.onlyA.length + ')</button>' +
'<button class="tab-btn' + (currentView === 'onlyB' ? ' active' : '') + '" data-cview="onlyB" role="tab" aria-controls="compareDetail" aria-selected="' + (currentView === 'onlyB' ? 'true' : 'false') + '" tabindex="' + (currentView === 'onlyB' ? '0' : '-1') + '">Only ' + nameB + ' (' + r.onlyB.length + ')</button>' +
'</div>' +
'<div id="compareDetail"></div>' +
'</div>';
// Sync the tablist's active/aria-selected/tabindex to currentView.
function syncTabState() {
content.querySelectorAll('.tab-btn').forEach(function (b) {
var on = b.dataset.cview === currentView;
b.classList.toggle('active', on);
b.setAttribute('aria-selected', on ? 'true' : 'false');
b.setAttribute('tabindex', on ? '0' : '-1');
});
}
// Activate a [data-view] strip segment OR a [data-cview] tab.
function activate(el) {
if (!el) return;
if (el.dataset.cview) {
currentView = el.dataset.cview;
} else if (el.dataset.view) {
currentView = el.dataset.view;
} else {
return;
}
syncTabState();
renderDetail();
}
// Bind tab clicks + strip clicks
content.addEventListener('click', function handler(e) {
var btn = e.target.closest('[data-cview]');
if (btn) { activate(btn); return; }
var seg = e.target.closest('[data-view]');
if (seg) { activate(seg); }
});
// Keyboard activation for tabs (arrow nav) + strip segments (Enter/Space).
content.addEventListener('keydown', function (e) {
var tab = e.target.closest('[data-cview]');
if (tab) {
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
e.preventDefault();
var tabs = Array.prototype.slice.call(content.querySelectorAll('.tab-btn'));
var idx = tabs.indexOf(tab);
if (idx < 0) return;
var next = e.key === 'ArrowRight'
? tabs[(idx + 1) % tabs.length]
: tabs[(idx - 1 + tabs.length) % tabs.length];
activate(next);
next.focus();
return;
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
activate(tab);
return;
}
}
var seg = e.target.closest('[data-view]');
if (seg && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
activate(seg);
}
});
renderDetail();
}
function renderDetail() {
var el = document.getElementById('compareDetail');
if (!el || !comparisonResult) return;
var r = comparisonResult;
var nameA = escapeHtml(obsName(selA));
var nameB = escapeHtml(obsName(selB));
if (currentView === 'summary') {
// Textual summary — the headline strip + asym lines already cover
// the quantitative story; this paragraph adds context and surfaces
// edge cases (no shared packets, perfect overlap).
var total = r.onlyA.length + r.onlyB.length + r.both.length;
var overlap = total > 0 ? (r.both.length / total * 100).toFixed(1) : '0.0';
el.innerHTML =
'<div class="compare-summary-text">' +
'<p>In the last 24 hours, <strong>' + nameA + '</strong> saw <strong>' +
(r.onlyA.length + r.both.length).toLocaleString() + '</strong> unique packets ' +
'and <strong>' + nameB + '</strong> saw <strong>' +
(r.onlyB.length + r.both.length).toLocaleString() + '</strong> unique packets. ' +
'<strong>' + r.both.length.toLocaleString() + '</strong> (' + overlap + '%) were seen by both observers.</p>' +
(r.both.length === 0 && total > 0 ? '<p class="compare-warning">\u26A0\uFE0F These observers share no packets \u2014 they may be on different frequencies or too far apart.</p>' : '') +
(r.onlyA.length === 0 && r.onlyB.length === 0 && r.both.length > 0 ? '<p class="compare-good">\u2705 Perfect overlap \u2014 both observers see the same packets.</p>' : '') +
'</div>';
return;
}
var hashes = r[currentView] || [];
if (hashes.length === 0) {
el.innerHTML = '<div class="text-muted" style="padding:20px">No packets in this category.</div>';
return;
}
// Show up to 200 packets in the table
var displayLimit = 200;
var displayed = hashes.slice(0, displayLimit);
var mapA = r.packetMapA;
var mapB = r.packetMapB;
el.innerHTML =
(hashes.length > displayLimit ? '<div class="text-muted" style="margin-bottom:8px">Showing first ' + displayLimit + ' of ' + hashes.length.toLocaleString() + ' packets.</div>' : '') +
'<div class="analytics-table-scroll"><table class="data-table compare-table">' +
'<thead><tr>' +
'<th scope="col">Hash</th><th scope="col">Time</th><th scope="col">Type</th><th scope="col">Observer</th>' +
'</tr></thead>' +
'<tbody>' + displayed.map(function (h) {
var p = mapA.get(h) || mapB.get(h);
if (!p) return '';
var typeName = PAYLOAD_LABELS[p.payload_type] || 'Type ' + p.payload_type;
var obsLabel = '';
if (currentView === 'both') {
obsLabel = nameA + ', ' + nameB;
} else if (currentView === 'onlyA') {
obsLabel = nameA;
} else {
obsLabel = nameB;
}
return '<tr style="cursor:pointer" tabindex="0" role="row" data-action="navigate" data-value="#/packets/' + escapeHtml(h) + '" onclick="location.hash=\'#/packets/' + escapeHtml(h) + '\'">' +
'<td class="mono" style="font-size:0.85em">' + escapeHtml(h.substring(0, 12)) + '</td>' +
'<td>' + timeAgo(p.timestamp || p.first_seen) + '</td>' +
'<td><span class="payload-badge badge-' + payloadTypeColor(p.payload_type) + '">' + escapeHtml(typeName) + '</span></td>' +
'<td>' + obsLabel + '</td>' +
'</tr>';
}).join('') +
'</tbody>' +
'</table></div>';
}
registerPage('compare', { init: init, destroy: destroy });
})();