Without sender GPS (channel texts etc), the forward pass had no
anchor and just took candidates[0] — random order. Now regional
candidates are sorted by distKm to observer IATA center before
disambiguation. Closest to region center = default pick.
Client-side HopResolver wasn't properly disambiguating despite
correct data. Switched detail view to use the server API directly:
/api/resolve-hops?hops=...&observer=...&originLat=...&originLon=...
Server-side resolution is battle-tested and handles regional
filtering + GPS-anchored disambiguation correctly.
List view resolves hops without anchor (no per-packet context).
Detail view now always re-resolves with the packet's actual GPS
coordinates + observer, overwriting stale cache entries.
Removed debug logging.
ADVERT packets have GPS coordinates — use them as the forward
pass anchor so the first hop resolves to the nearest candidate
to the sender, not random pick order.
The general hop cache was populated without observer context,
so all conflicts showed filterMethod=none. Now renderDetail()
re-resolves hops with pkt.observer_id, getting proper regional
filtering with distances and conflict flags.
⚠3 is now a yellow button (not tiny superscript). Clicking it
opens a popover listing all regional candidates with:
- Node name (clickable → node detail page)
- Distance from observer region center
- Truncated pubkey
Popover dismisses on outside click. Each candidate is a link
to #/nodes/PUBKEY for full details.
New hop-display.js: shared renderHop() and renderPath() with
full conflict info — candidate count, regional/global flags,
distance, filter method. Tooltip shows all candidates with
details on hover.
packets.js: uses HopDisplay.renderHop() (was inline)
nodes.js: path rendering uses HopDisplay when available
style.css: .hop-current for highlighting the viewed node in paths
Consistent conflict display across packets + node detail pages.
For packets without direct lat/lon (GRP_TXT, TXT_MSG):
- Look up sender by pubKey via /api/nodes/:key
- Look up sender by name via /api/nodes/search?q=name
- Show location + 📍map link when node has coordinates
Works for decrypted channel messages (sender field), direct
messages (srcPubKey), and any packet type with a resolvable sender.
📍map link now uses #/map?node=PUBKEY. Map centers on the node
at zoom 14 and opens its popup. No fake markers — uses the
existing node marker already on the map.
Packet detail 📍map link now navigates to #/map?lat=X&lon=Y&zoom=12.
Map page reads lat/lon/zoom from URL query params to center on
the linked location.
- packets.js: obsName() now shows IATA code next to observer name, e.g. 'EW-SFC-DR01 (SFO)'
- packets.js: hop conflicts in field table show distance (e.g. '37km')
- nodes.js: both full and sidebar detail views show 'Regions: SJC, OAK, SFO' badges and per-observer IATA
- live.js: node detail panel shows regions in 'Heard By' heading and per-observer IATA
- server.js: /api/nodes/:pubkey/health now returns iata field for each observer
- Bump cache busters
HopResolver now mirrors server-side layered regional filtering:
- init() accepts observers list + IATA coords
- resolve() accepts observerId, looks up IATA, filters candidates
by haversine distance (300km radius) to IATA center
- Candidates include regional, filterMethod, distKm fields
- Packet detail view passes observer_id to resolve()
New endpoint: GET /api/iata-coords returns airport coordinates
for client-side use.
Fixes: conflict badges showing "0 conflicts" in packet detail
because client-side resolver had no regional filtering.
Layer 1 (GPS, bridge-proof): Nodes with lat/lon are checked via
haversine distance to the observer IATA center. Only nodes within
300km are considered regional. Bridged WA nodes appearing in SJC
MQTT feeds are correctly rejected because their GPS coords are
1100km+ from SJC.
Layer 2 (observer-based, fallback): Nodes without GPS fall back to
_advertByObserver index — were they seen by a regional observer?
Less precise but still useful for nodes that never sent ADVERTs
with coordinates.
Layer 3: Global fallback, flagged.
New module: iata-coords.js with 60+ IATA airport coordinates +
haversine distance function.
API response now includes filterMethod (geo/observer/none) and
distKm per conflict candidate.
Tests: 22 unit tests (haversine, boundaries, cross-regional
collision sim, layered fallback, bridge rejection).
1-byte (and 2-byte) hop IDs match many nodes globally. Previously
resolve-hops picked candidates from anywhere, causing cross-regional
false paths (e.g. Eugene packet showing Vancouver repeaters).
Fix: Use observer IATA to determine packet region. Filter candidates
to nodes seen by observers in the same IATA region via the existing
_advertByObserver index. Fall back to global only if zero regional
candidates exist (flagged as globalFallback).
API changes to /api/resolve-hops response:
- conflicts[]: all candidates with regional flag per hop
- totalGlobal/totalRegional: candidate counts
- globalFallback: true when no regional candidates found
- region: packet IATA region in top-level response
UI changes:
- Conflict count badge (⚠3) instead of bare ⚠
- Tooltip shows regional vs global candidates
- Unreliable hops shown with strikethrough + opacity
- Global fallback hops shown with red dashed underline
Add window.IATA_CITIES with ~150 common airport codes covering US, Canada,
Europe, Asia, Oceania, South America, and Africa. The region filter now
falls back to this mapping when no user-configured label exists, so region
dropdowns show friendly city names out of the box.
Closes#116
- Add width: max-content to dropdown menu for auto-sizing
- Add overflow ellipsis + max-width on dropdown items for very long labels
- Checkboxes already flex-shrink: 0, no text wrapping with white-space: nowrap
Each note in the sequence table has a ▶ button and the whole row
is clickable. Plays a single oscillator with the correct envelope,
filter, and frequency for that note. Highlights the corresponding
hex byte, table row, and byte bar while it plays.
Also added MeshAudio.getContext() accessor for audio lab to create
individual notes without duplicating AudioContext.
computeMapping was applying speedMult on top of BPM that already
included it (setBPM(baseBPM * speedMult)). Double-multiplication
made highlights run at wrong speed. BPM already encodes speed.
As each note plays, highlights sync across all three views:
- Hex dump: current byte pulses red
- Note table: current row highlights blue
- Byte visualizer: current bar glows and scales up
Timing derived from note duration + gap (same values the voice
module uses), scheduled via setTimeout in parallel with audio.
Clears previous note before highlighting next. Auto-clears at end.
New #/audio-lab page for understanding and debugging audio sonification.
Server: GET /api/audio-lab/buckets — returns representative packets
bucketed by type (up to 8 per type spanning size range).
Client: Left sidebar with collapsible type sections, right panel with:
- Controls: Play, Loop, Speed (0.25x-4x), BPM, Volume, Voice select
- Packet Data: type, sizes, hops, obs count, hex dump with sampled
bytes highlighted
- Sound Mapping: computed instrument, scale, filter, volume, voices,
pan — shows exactly why it sounds the way it does
- Note Sequence: table of sampled bytes → MIDI → freq → duration → gap
- Byte Visualizer: bar chart of payload bytes, sampled ones colored
Enables MeshAudio automatically on first play. Mobile responsive.
Three pop sources fixed:
1. setValueAtTime(0) at note start — oscillator starting at exact zero
causes click. Now starts at 0.0001 with exponentialRamp up.
2. setValueAtTime at noteEnd jumping to sustain level — removed.
Decay ramp flows naturally into setTargetAtTime release (smooth
exponential decay, no discontinuities).
3. No amplitude limiting — multiple overlapping packets could spike.
Added DynamicsCompressor as limiter per packet chain (-6dB
threshold, 12:1 ratio, 1ms attack).
Also: 20ms lookahead (was 10ms) gives scheduler more headroom.
packets.find(g => g.hash === h) was O(n) and could race with
loadPackets replacing the array. hashIndex Map stays in sync —
rebuilt on API fetch, updated on WS insert. Prevents duplicate
rows for same hash in grouped mode.
Realistic mode buffers observations then fires once — but was
passing the first packet (obs_count=1). Now passes consolidated
packet with obs_count=packets.length so the voice module gets
the real count for volume + chord voicing.
Secondary broadcast paths (ADVERT, GRP_TXT, TXT_MSG, TRACE, API)
were missing hash field. Without hash, realistic mode's buffer
check (if pkt.hash) failed and packets fell through to
animatePacket individually — causing duplicate feed items and
duplicate sonification.
Also added missing addFeedItem call in animateRealisticPropagation
so the feed shows consolidated entries in realistic mode.
Linear gain ramps + osc.stop() too close to release end caused
waveform discontinuities. Switched to exponentialRamp (natural
decay curve), 0.0001 floor (-80dB), 50ms extra headroom before
oscillator stop.
Instead of silently dropping notes or hoping gesture listeners fire,
show a clear overlay on first packet if AudioContext is suspended.
One tap resumes context and removes overlay. Standard pattern used
by every browser game/music site.
When audio was previously enabled, registers one-shot click/touch/key
listener to init AudioContext on first interaction. Any tap on the
page is enough — no need to toggle the checkbox.
restore() was creating AudioContext without user gesture (on page load
when volume was saved), causing browser to permanently suspend it.
Now restore() only sets flags; AudioContext created lazily on first
sonifyPacket() call or setEnabled() click. Pending volume applied
when context is finally created.