Compare commits

...

105 Commits

Author SHA1 Message Date
you
28e3d8319b v2.6.0 — Audio Sonification, Regional Hop Filtering, Audio Lab 2026-03-23 00:19:45 +00:00
you
3155504d70 Fix CI: use docker rm -f to avoid stale container conflicts 2026-03-23 00:18:19 +00:00
you
dda69e7e1e Fix: revert WS handler to sync — async resolve was blocking rendering
Per-observer resolve in the WS handler made it async, which
broke the debounce callback (unhandled promise + race conditions).
Live packets now render immediately with global cache. Per-observer
resolution happens on initial load and packet detail only.
2026-03-23 00:16:44 +00:00
you
ca3ba6d04e Fix: per-observer hop resolution for WS live packets too
New packets arriving via WebSocket were only getting global
resolution. Now ambiguous hops in WS batches also get per-observer
server-side resolution before rendering.
2026-03-23 00:11:01 +00:00
you
5149bb3295 Fix: per-observer hop resolution for packet list
Ambiguous hops in the list now get resolved per-observer via
batch server API calls. Cache uses observer-scoped keys
(hop:observerId) so the same 1-byte prefix shows different
names depending on which observer saw the packet.

Flow: global resolve first (fast, covers unambiguous hops),
then batch per-observer resolve for ambiguous ones only.
2026-03-23 00:08:41 +00:00
you
961ac7b68c Fix: resolve sender GPS from DB for channel texts and all packet types
When packet doesn't have lat/lon directly (channel messages, DMs),
look up sender node from DB by pubkey or name. Use that GPS as
the origin anchor for hop disambiguation. We've seen ADVERTs from
these senders — use that known location.
2026-03-23 00:05:27 +00:00
you
dbbe280f17 Fix: don't apply time window when navigating to specific packet hash
Direct links like #/packets/HASH should always show the packet
regardless of the time window filter.
2026-03-23 00:04:03 +00:00
you
c267e58418 Fix: sort regional candidates by distance to IATA center
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.
2026-03-22 23:59:33 +00:00
you
19c60e6872 Fix: packet detail uses server-side resolve-hops with GPS anchor
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.
2026-03-22 23:52:44 +00:00
you
f4002def4c Debug: log renderDetail GPS anchor and resolved hops 2026-03-22 23:50:07 +00:00
you
5f7626ab3c Fix: detail view always re-resolves hops fresh with sender GPS
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.
2026-03-22 23:47:34 +00:00
you
55ff793e37 Debug: trace renderDetail re-resolve inputs 2026-03-22 23:46:47 +00:00
you
646afa2cf9 Debug: log HopResolver.resolve inputs to trace disambiguation 2026-03-22 23:44:53 +00:00
you
3c44b4638d Fix: pass sender lat/lon as origin anchor for hop disambiguation
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.
2026-03-22 23:35:10 +00:00
you
398363cc9d Packet detail: re-resolve hops with observer for regional conflicts
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.
2026-03-22 23:29:46 +00:00
you
632e684029 Conflict badge: bigger clickable button with popover pane
⚠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.
2026-03-22 23:25:32 +00:00
you
70db798aed Packet detail byte breakdown uses shared HopDisplay for path hops
Replaces inline conflict rendering with HopDisplay.renderHop() —
consistent regional-only tooltips everywhere.
2026-03-22 23:24:02 +00:00
you
28475722d7 Conflict tooltips show only regional candidates, ignore global noise 2026-03-22 23:22:16 +00:00
you
e732d52e0e Hex Paths mode still shows conflict tooltips
hexMode flag shows raw hex prefix as display text but keeps
the full conflict tooltip, link, and warning badges.
2026-03-22 23:20:28 +00:00
you
3491fdabef Shared HopDisplay module for consistent conflict tooltips
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.
2026-03-22 23:12:11 +00:00
you
501d685003 Fix: hoist targetNodeKey to module scope so loadNodes can access it 2026-03-22 23:05:56 +00:00
you
f0243ff332 Fix: delay popup open after setView, add debug logging
Leaflet needs map to settle after setView before popup can open.
Added 500ms delay + console.warn if target marker not found.
2026-03-22 23:01:18 +00:00
you
46868b7dcd Packet detail: resolve location for channel texts + all node types
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.
2026-03-22 23:00:09 +00:00
you
537e04d0ad Fix: map node highlight uses _nodeKey instead of alt text matching
Store public_key on each Leaflet marker as _nodeKey. Match by
exact pubkey instead of fragile alt text substring search.
2026-03-22 22:55:46 +00:00
you
5273d1fd01 Map: navigate to node by pubkey from packet detail
📍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.
2026-03-22 22:51:15 +00:00
you
5ad498a662 Map: highlight pin when navigated from packet detail
Red circle marker with tooltip at the target coordinates,
fades out after 10 seconds. Makes it obvious where the
packet location is on the map.
2026-03-22 22:47:01 +00:00
you
0974e7b15b Fix: observer detail packet links use hash, not ID
Was linking to #/packet/ID (wrong route + observation ID).
Now links to #/packets/HASH (correct route + packet hash).
2026-03-22 22:44:00 +00:00
you
b11722d854 Location link points to our own map, not Google Maps
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.
2026-03-22 22:43:29 +00:00
you
25c85aeeb2 Fix: packet-detail page loads observers before rendering
Direct navigation to #/packet/ID skipped loadObservers(), so
obsName() fell through to raw hex pubkey. Now loads observers
first.
2026-03-22 22:42:38 +00:00
you
a2dd96812d Packet detail: show location for ADVERTs and nodes with lat/lon
Shows coordinates with Google Maps link for packets that have
lat/lon in decoded payload (ADVERTs, known nodes). Includes
node name when available.
2026-03-22 22:41:35 +00:00
you
13460cfc93 Fix: move /api/iata-coords route after app initialization
Route was at line 16 before express app was created — caused
'Cannot access app before initialization' crash on startup.
2026-03-22 22:26:15 +00:00
you
935ed6ef85 Show observer IATA regions in packet, node, and live detail views
- 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
2026-03-22 22:21:01 +00:00
you
70c67d8551 Client-side regional hop filtering (#117)
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.
2026-03-22 22:18:01 +00:00
you
c1d789b5d7 Regional hop filtering: layered geo + observer approach (#117)
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).
2026-03-22 22:09:43 +00:00
you
4cc1ad7b34 Fix #117: Regional filtering for repeater ID resolution
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
2026-03-22 21:38:24 +00:00
you
b7a29d4849 feat: add built-in IATA-to-city mapping for region dropdown (#116)
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
2026-03-22 21:22:23 +00:00
you
d474d0b427 fix: region dropdown layout for longer city name labels (#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
2026-03-22 21:21:39 +00:00
you
2609a26605 Audio Lab: click any note row to play it individually
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.
2026-03-22 19:41:00 +00:00
you
0934d8bbb6 Audio Lab: fix highlight timing vs speed setting
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.
2026-03-22 19:39:18 +00:00
you
2fd0d3e07b Audio Lab: show WHY each parameter has its value
Sound Mapping: 3-column table (Parameter | Value | Formula/Reason)
Note Sequence: payload index + duration/gap derivation formulas
2026-03-22 19:36:07 +00:00
you
7c1132b7cf Audio Lab: real-time playback highlighting
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.
2026-03-22 19:27:35 +00:00
you
f523d4f3c4 Audio Lab page (Milestone 1): Packet Jukebox
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.
2026-03-22 19:19:45 +00:00
you
edbaddbd37 Audio Workbench: expand M2 parameter overrides — envelope, filter, limiter, timing 2026-03-22 19:00:48 +00:00
you
d5e6481d9b Audio: eliminate pops — proper envelope + per-packet limiter
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.
2026-03-22 18:58:33 +00:00
you
1450bc928b Packets page: O(1) hash dedup via Map index
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.
2026-03-22 18:55:22 +00:00
you
c7f12c72b9 Audio: log2 voice scaling (up to 8), wider detune spread
1 obs = solo, 3 = duo, 8 = trio, 15 = quartet, 30 = quintet, 60 = sextet.
Wider detune (±8, ±13, ±18...) so stacked voices shimmer instead
of sounding like the same oscillator copied.
2026-03-22 18:45:47 +00:00
you
2688f3e63a Audio: pass consolidated observation_count to sonifyPacket
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.
2026-03-22 18:44:45 +00:00
you
90bd9e12e5 Fix realistic mode: all WS broadcasts include hash + raw_hex
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.
2026-03-22 18:37:53 +00:00
you
4e8b1b2584 Audio: fix pop/crackle — exponential ramps + longer release tail
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.
2026-03-22 18:31:36 +00:00
you
288c1b048b CI: skip deploy on markdown, docs, LICENSE, gitignore changes 2026-03-22 18:28:56 +00:00
you
85ecddd92c Audio Workbench plan — packet jukebox, parameter overrides, A/B comparison 2026-03-22 18:26:49 +00:00
you
d0b02b7070 Remove legacy playSound/🔇 button — MeshAudio is the only audio system now 2026-03-22 18:11:55 +00:00
you
08255aeba5 Audio: "Tap to enable audio" overlay when context is suspended
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.
2026-03-22 17:31:23 +00:00
you
51df14521b Audio: create context eagerly on restore, gesture listener as fallback 2026-03-22 17:27:54 +00:00
you
f3572c646a Audio: unlock on first user gesture after restore
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.
2026-03-22 17:27:00 +00:00
you
0dbe5fd229 Audio: fix inconsistent init — lazy AudioContext, no premature creation
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.
2026-03-22 17:25:42 +00:00
you
f206ae48ef Modularize audio: engine + swappable voice modules
audio.js is now the core engine (context, routing, voice mgmt).
Voice modules register via MeshAudio.registerVoice(name, module).
Each module exports { name, play(ctx, master, parsed, opts) }.

Voice selector dropdown appears in audio controls.
Voices persist in localStorage. Adding a new voice = new file +
script tag. Previous voices are never lost.

v1 "constellation" extracted as audio-v1-constellation.js.
2026-03-22 17:06:18 +00:00
you
ff0f26293e Audio plan: add percussion layer — kick/hat/snare/rim/crash from packet types 2026-03-22 09:41:04 +00:00
you
d0de0770ec Revert audio fixes — restore to initial working audio commit (ddc86a2) 2026-03-22 09:35:18 +00:00
you
6b8e4447c0 Audio: debug logs to trace why packets aren't playing 2026-03-22 09:33:47 +00:00
you
a7e8a70c2f Audio: resume suspended AudioContext + sonify realistic propagation path
AudioContext starts suspended until user gesture — now resumes on
setEnabled(). Also added sonifyPacket to animateRealisticPropagation
which is the main code path when Realistic mode is on.
2026-03-22 09:29:54 +00:00
you
ddc86a2574 Add mesh audio sonification — raw packet bytes become music
Per AUDIO-PLAN.md:
- payload_type selects instrument (bell/marimba/piano/ethereal),
  scale (major penta/minor penta/natural minor/whole tone), and root key
- sqrt(payload_length) bytes sampled evenly across payload for melody
- byte value → pitch (quantized to scale) + note duration (50-400ms)
- byte-to-byte delta → note spacing (30-300ms)
- hop_count → low-pass filter cutoff (bright nearby, muffled far)
- observation_count → volume + chord voicing (detuned stacked voices)
- origin longitude → stereo pan
- BPM tempo slider scales all timings
- Volume slider for master gain
- ADSR envelopes per instrument type
- Max 12 simultaneous voices with voice stealing
- Pure Web Audio API, no dependencies
2026-03-22 09:19:36 +00:00
you
3f8b8aec79 Audio plan: final — byte-driven timing, BPM tempo control, no fixed spacing 2026-03-22 09:15:14 +00:00
you
73dd0d34d2 Audio plan: header configures voice, payload sings the melody 2026-03-22 09:11:02 +00:00
you
fbf1648ae3 Audio plan: payload type as root key, document guaranteed fields 2026-03-22 09:06:15 +00:00
you
36eb04c016 Audio plan: drop SNR/RSSI, use obs count + hops for dynamics 2026-03-22 08:58:05 +00:00
you
7739b7ef71 Add mesh audio sonification plan (AUDIO-PLAN.md) 2026-03-22 08:56:51 +00:00
you
7fbbea11a4 Remove scanline flicker animation — was causing visible pulsing 2026-03-22 08:39:47 +00:00
you
8dfb5c39f7 v2.5.0 "Digital Rain" — Matrix mode, hex flight, matrix rain 2026-03-22 08:38:28 +00:00
you
0116cd38ac Fix replay missing raw_hex — no rain on replayed packets
Replay button builds packets without raw/raw_hex field.
Now includes raw: o.raw_hex || pkt.raw_hex for both
single and multi-observation replays.
2026-03-22 08:19:21 +00:00
you
0255e10746 Rain: vary hop count per observation column (±1 hop)
Each observer sees a different path length in reality. Extra
rain columns now randomly vary ±1 hop from the base, giving
different fall distances for visual variety.
2026-03-22 08:17:18 +00:00
you
3d7c087025 Rain: 4 hops = full screen (was 8), matches median of 3 hops 2026-03-22 08:15:15 +00:00
you
54d453d034 Rain: spawn column per observation for denser rain
Each observation of a packet spawns its own rain column,
staggered 150ms apart. More observers = more rain.
2026-03-22 08:13:22 +00:00
you
ca46cc6959 Rain: show all packet bytes, no cap 2026-03-22 08:12:14 +00:00
you
a01999c743 Rain: show up to 20 bytes trailing, scroll through all packet bytes
Trail was limited to hops*30px which meant 1-hop packets showed
1 character. Now shows up to 20 visible chars at once, scrolling
through the entire packet byte array as the drop falls.
2026-03-22 08:11:23 +00:00
you
a295e5eb9c Rain: fix missing raw_hex in VCR/timeline packets
dbPacketToLive() wasn't including raw_hex from API data.
VCR replay and timeline scrub packets had no raw bytes,
so rain silently dropped them all. Now includes pkt.raw_hex
as 'raw' field. Removed debug log.
2026-03-22 08:09:26 +00:00
you
c3e97e6768 Rain: add debug log to diagnose missing drops 2026-03-22 08:06:33 +00:00
you
1ba33d5d04 Rain: only show drops with real raw packet bytes, no faking
Packets from companion bridge (Format 2) have no raw hex —
they arrive as pre-decoded JSON. Skip them entirely instead
of showing fake/random bytes.
2026-03-22 08:03:52 +00:00
you
4b0cc38adb Rain: use packet hash + decoded payload as hex source fallback
Format 2 MQTT packets (companion bridge) have no raw hex field.
Now falls back to pkt.hash, then extracts hex from decoded payload
JSON. Random bytes only as absolute last resort.
2026-03-22 08:02:53 +00:00
you
26fca2677b Rain: fix hex bytes source — check pkt.raw, pkt.raw_hex, pkt.packet.raw_hex 2026-03-22 07:58:52 +00:00
you
3f4077c8e0 Matrix Rain: canvas overlay with falling hex byte columns
New 'Rain' toggle on live map. Each incoming packet spawns a
falling column of hex bytes from its raw data:

- Fall distance proportional to hop count (8+ hops = full screen)
- 5 second fall time for full-height drops, proportional for shorter
- Leading char: bright white with green glow
- Trail chars: green, progressively fading
- Entire column fades out in last 30% of life
- Random x position across screen width
- Canvas-rendered at 60fps (no DOM overhead)
- Works independently of Matrix mode (can combine both)
2026-03-22 07:53:50 +00:00
you
261bb54c38 Matrix: faster trail fadeout (500ms->300ms, delay 300->150ms) 2026-03-22 07:41:39 +00:00
you
bbfaded9fb Matrix: 1.1s per hop 2026-03-22 07:38:47 +00:00
you
051d351a01 Matrix: speed up to 1s per hop (was 1.4s) 2026-03-22 07:36:43 +00:00
you
786237e461 Revert "Matrix: CSS transition pooled markers for smoother animation"
This reverts commit 68d2fba54e.
2026-03-22 07:36:10 +00:00
you
68d2fba54e Matrix: CSS transition pooled markers for smoother animation
- Pre-create pool of 6 reusable markers (no create/destroy per frame)
- CSS transition: transform 80ms linear for position, opacity 200ms ease
- will-change: transform, opacity for GPU compositing
- Styles moved from inline to .matrix-char span class
- Marker positions updated via setLatLng, browser interpolates between
- Fade-out via CSS transition instead of rAF opacity loop

Revert to bbaecd6 if this doesn't feel better.
2026-03-22 07:34:39 +00:00
you
bbaecd664a Matrix: requestAnimationFrame for smooth 60fps animation
Replaced setInterval(40ms) with rAF + time-based interpolation.
Same 1.4s duration per hop, but buttery smooth movement.
Fade-out also uses rAF instead of setInterval.
2026-03-22 07:31:23 +00:00
you
aa8feb3912 Matrix: markers 10% brighter (#008a22, 50% opacity), map 10% darker (1.1) 2026-03-22 07:29:35 +00:00
you
967f4def7e Matrix: speed up animation (35 steps @ 40ms = ~1.4s per hop) 2026-03-22 07:29:06 +00:00
you
76ad318b15 Matrix: brighter hex, more spacing, slower animation, darker map
- Hex chars: 16px white text with triple green glow (was 12px green)
- Only render every 2nd step for wider spacing between bytes
- Animation speed: 45 steps @ 50ms (was 30 @ 33ms) — ~2.3s per hop
- Trail length reduced to 6 (less clutter)
- Map brightness down 10% (1.4 -> 1.25)
2026-03-22 07:27:51 +00:00
you
e501b63362 Matrix: tint new markers on creation during matrix mode
Timeline scrub clears and recreates markers — now addNodeMarker()
applies matrix tinting inline if matrixMode is active.
2026-03-22 07:24:09 +00:00
you
1ea2152418 Matrix: fix invisible map — brighten dark tiles instead of dimming
Dark mode tiles are already dark; previous filter was making them
invisible. Now brightens 1.4x + green tint via sepia+hue-rotate.
Also fixed ::before/::after selectors (same element, not descendant).
2026-03-22 07:23:23 +00:00
you
a9d5d2450c Matrix mode forces dark mode, restores on toggle off
- Saves previous theme, switches to dark, disables theme toggle
- On Matrix off: restores original theme + re-enables toggle
- Dark mode tiles + green filter = actually visible map
2026-03-22 07:20:59 +00:00
you
6f8cd2eac0 Matrix: reworked map visibility + dimmer markers
- Replaced sepia+hue-rotate chain with grayscale+brightness+contrast
- Green tint via ::before (multiply) + ::after (screen) overlays
- Much brighter base map — roads/coastlines/land clearly visible
- Markers dimmed to #005f15 at 40% opacity
- DivIcon markers at 35% brightness
2026-03-22 07:20:06 +00:00
you
13d781fcd9 Matrix: significantly brighter map tiles (0.35->0.55, contrast 1.5) 2026-03-22 07:18:39 +00:00
you
0f8e886984 Matrix mode disables heat map (incompatible combo)
Unchecks and greys out Heat toggle when Matrix is on. Restores on off.
2026-03-22 07:17:27 +00:00
you
9cfd452910 Matrix: higher contrast for land/ocean distinction 2026-03-22 07:16:51 +00:00
you
95ce48543c Matrix: green-tinted map tiles via sepia+hue-rotate filter chain
Roads, coastlines, terrain features now have faint green outlines
instead of just being dimmed to grey.
2026-03-22 07:16:32 +00:00
you
d93ff1a1e7 Matrix: dim node markers to let hex bytes stand out
Markers #00aa2a (darker green), DivIcon filter brightness 0.6
2026-03-22 07:15:54 +00:00
you
3102d15e45 Matrix theme: brighten map tiles (0.15 -> 0.3), slight saturation 2026-03-22 07:15:07 +00:00
you
556359e9db Matrix theme: full map visual overhaul when Matrix mode enabled
- Map tiles desaturated + darkened to near-black with green tint
- CRT scanline overlay with subtle flicker animation
- All node markers re-tinted to Matrix green (#00ff41)
- Feed panel: dark green background, monospace font, green text
- Controls/VCR bar: green-on-black theme
- Node detail panel: green themed
- Zoom controls, attribution: themed
- Node labels glow green
- Markers get hue-rotate filter (except matrix hex chars)
- Restores all original colors when toggled off
2026-03-22 07:13:30 +00:00
you
c47e8947c6 Fix Matrix mode null element errors
getElement() returns null when DivIcon not yet rendered to DOM.
Null-guard all element access in drawMatrixLine interval and fadeout.
2026-03-22 07:10:40 +00:00
you
e76e63b80d Add Matrix visualization mode for live map
New toggle in live map controls: 'Matrix' - animates packet hex bytes
flowing along paths in green Matrix-style rain effect.

- Hex bytes from actual packet raw_hex data flow along each hop
- Green (#00ff41) monospace characters with neon glow/text-shadow
- Trail of 8 characters with progressive fade
- Dim green trail line underneath
- Falls back to random hex if no raw data available
- Persists toggle state to localStorage
- Works alongside existing Realistic mode
2026-03-22 07:07:21 +00:00
you
cf3a8fe2f4 fix: null-guard all animation entry points (pulseNode, animatePath, drawAnimatedLine)
All animation functions now bail early if animLayer/pathsLayer are null,
preventing cascading errors from setInterval callbacks after navigation.
2026-03-22 05:08:06 +00:00
you
620458be8b docs: update v2.4.1 notes with pause fix 2026-03-22 05:01:38 +00:00
24 changed files with 2948 additions and 138 deletions

View File

@@ -3,6 +3,11 @@ name: Deploy
on:
push:
branches: [master]
paths-ignore:
- '**.md'
- 'LICENSE'
- '.gitignore'
- 'docs/**'
concurrency:
group: deploy
@@ -25,7 +30,7 @@ jobs:
run: |
set -e
docker build -t meshcore-analyzer .
docker stop meshcore-analyzer 2>/dev/null && docker rm meshcore-analyzer 2>/dev/null || true
docker rm -f meshcore-analyzer 2>/dev/null || true
docker run -d \
--name meshcore-analyzer \
--restart unless-stopped \

146
AUDIO-PLAN.md Normal file
View File

@@ -0,0 +1,146 @@
# Mesh Audio — Sonification Plan
*Turn raw packet bytes into generative music.*
## What Every Packet Has (guaranteed)
- `raw_hex` — melody source
- `hop_count` — note duration + filter cutoff
- `observation_count` — volume + chord voicing
- `payload_type` — instrument + scale + root key
- `node_lat/lon` — stereo pan
- `timestamp` — arrival timing
## Final Mapping
| Data | Musical Role |
|------|-------------|
| **payload_type** | Instrument + scale + root key |
| **payload bytes** (evenly sampled, sqrt(len) count) | Melody notes (pitch) |
| **byte value** | Note length (higher = longer sustain, lower = staccato) |
| **byte-to-byte delta** | Note spacing (big jump = longer gap, small = rapid) |
| **hop_count** | Low-pass filter cutoff (more hops = more muffled) |
| **observation_count** | Volume + chord voicing (more observers = louder + stacked detuned voices) |
| **node longitude** | Stereo pan (west = left, east = right) |
| **BPM tempo** (user control) | Master time multiplier on all durations |
## Instruments & Scales by Type
| Type | Instrument | Scale | Root |
|------|-----------|-------|------|
| ADVERT | Bell / pad | C major pentatonic | C |
| GRP_TXT | Marimba / pluck | A minor pentatonic | A |
| TXT_MSG | Piano | E natural minor | E |
| TRACE | Ethereal synth | D whole tone | D |
## How a Packet Plays
1. **Header configures the voice** — payload type selects instrument, scale, root key. Flags/transport codes select envelope shape. Header bytes are NOT played as notes.
2. **Sample payload bytes** — pick `sqrt(payload_length)` bytes, evenly spaced across payload:
- 16-byte payload → 4 notes
- 36-byte payload → 6 notes
- 64-byte payload → 8 notes
3. **Each sampled byte → a note:**
- **Pitch**: byte value (0-255) quantized to selected scale across 2-3 octaves
- **Length**: byte value maps to sustain duration (low byte = short staccato ~50ms, high byte = sustained ~400ms)
- **Spacing**: delta between current and next sampled byte determines gap to next note (small delta = rapid fire, large delta = pause). Scaled by BPM tempo multiplier.
4. **Filter**: low-pass cutoff from hop_count — few hops = bright/clear, many hops = muffled (signal traveled far)
5. **Volume**: observation_count — more observers = louder
6. **Chord voicing**: if observations > 1, stack slightly detuned voices (±5-15 cents per voice, chorus effect)
7. **Pan**: origin node longitude mapped to stereo field
8. **All timings scaled by BPM tempo control**
## UI Controls
- **Audio toggle** — on/off (next to Matrix / Rain)
- **BPM tempo slider** — master time multiplier (slow = ambient, fast = techno)
- **Volume slider** — master gain
- **Mute button** — pause audio without losing toggle state
## Implementation
### Library: Tone.js (~150KB)
- `Tone.Synth` / `Tone.PolySynth` for melody + chords
- `Tone.Sampler` for realistic instruments
- `Tone.Filter` for hop-based cutoff
- `Tone.Chorus` for observation detuning
- `Tone.Panner` for geographic stereo
- `Tone.Reverb` for spatial depth
### Integration
- `animatePacket(pkt)` also calls `sonifyPacket(pkt)`
- Optional "Sonify" button on packet detail page
- Web Audio runs on separate thread — won't block UI/animations
- Polyphony capped at 8-12 voices to prevent mudding
- Voice stealing when busy
### Core Function
```
sonifyPacket(pkt):
1. Extract raw_hex → byte array
2. Separate header (first ~3 bytes) from payload
3. Header → select instrument, scale, root key, envelope
4. Sample sqrt(payload.length) bytes evenly across payload
5. For each sampled byte:
- pitch = quantize(byte, scale, rootKey)
- duration = map(byte, 50ms, 400ms) × tempoMultiplier
- gap to next = map(abs(nextByte - byte), 30ms, 300ms) × tempoMultiplier
6. Set filter cutoff from hop_count
7. Set gain from observation_count
8. Set pan from origin longitude
9. If observation_count > 1: detune +/- cents per voice
10. Schedule note sequence via Tone.js
```
## Percussion Layer
Percussion fires **instantly** on packet arrival — gives you the rhythmic pulse while the melodic notes unfold underneath.
### Drum Kit Mapping
| Packet Type | Drum Sound | Why |
|-------------|-----------|-----|
| **Any packet** | Kick drum | Network heartbeat. Every arrival = one kick. Busier network = faster kicks. |
| **ADVERT** | Hi-hat | Most frequent, repetitive — the timekeeper tick. |
| **GRP_TXT / TXT_MSG** | Snare | Human-initiated messages are accent hits. |
| **TRACE** | Rim click | Sparse, searching — light metallic tick. |
| **8+ hops OR 10+ observations** | Cymbal crash | Big network events get a crash. Rare = special. |
### Sound Design (all synthesized, no samples)
**Kick:** Sine oscillator, frequency ramp 150Hz → 40Hz in ~50ms, short gain envelope.
**Hi-hat:** White noise through highpass filter (7-10kHz).
- **Closed** (1-2 hops): 30ms decay — tight tick
- **Open** (3+ hops): 150ms decay — sizzle
**Snare:** White noise burst (bandpass ~200-1000Hz) + sine tone body (~180Hz). Observation count scales intensity (more observers = louder crack, longer decay).
**Rim click:** Short sine pulse at ~800Hz with fast decay (20ms). Dry, metallic.
**Cymbal crash:** White noise through bandpass (3-8kHz), long decay (500ms-1s). Only triggers on exceptional packets.
### Byte-Driven Variation
First payload byte mod 4 selects between variations of each percussion sound:
- Slightly different pitch (±10-20%)
- Different decay length
- Different filter frequency
Prevents machine-gun effect of identical repeated hits.
### Timing
- Percussion: fires immediately on packet arrival (t=0)
- Melody: unfolds over 0.6-1.6s starting at t=0
- Result: rhythmic hit gives you the pulse, melody gives you the data underneath
## The Full Experience
Matrix mode + Rain + Audio: green hex bytes flow across the map, columns of raw data rain down, and each packet plays its own unique melody derived from its actual bytes. Quiet periods are sparse atmospheric ambience; traffic bursts become dense polyrhythmic cascades. Crank the BPM for techno, slow it down for ambient.
## Future Ideas
- "Record" button → export MIDI or WAV
- Per-type mute toggles (silence ADVERTs, only hear messages)
- "DJ mode" — crossfade between regions
- Historical playback at accelerated speed = mesh network symphony
- Presets (ambient, techno, classical, minimal)
- ADVERT ambient drone layer (single modulated oscillator, not per-packet)

175
AUDIO-WORKBENCH.md Normal file
View File

@@ -0,0 +1,175 @@
# AUDIO-WORKBENCH.md — Sound Shaping & Debug Interface
## Problem
Live packets arrive randomly and animate too fast to understand what's happening musically. You hear sound, but can't connect it to what the data is doing — which bytes become which notes, why this packet sounds different from that one.
## Milestone 1: Packet Jukebox
A standalone page (`#/audio-lab`) that lets you trigger packets manually and understand the data→sound mapping.
### Packet Buckets
Pre-load representative packets from the database, bucketed by type:
| Type ID | Name | Typical Size | Notes |
|---------|------|-------------|-------|
| 0x04 | ADVERT | 109-177 bytes | Node advertisements, most musical (long payload) |
| 0x05 | GRP_TXT | 18-173 bytes | Group messages, wide size range |
| 0x01 | TXT_MSG | 22-118 bytes | Direct messages |
| 0x02 | ACK/REQ | 22-57 bytes | Short acknowledgments |
| 0x09 | TRACE | 11-13 bytes | Very short, sparse |
| 0x00 | RAW | 22-33 bytes | Raw packets |
For each type, pull 5-10 representative packets spanning the size range (smallest, median, largest) and observation count range (1 obs, 10+ obs, 50+ obs).
### API
New endpoint: `GET /api/audio-lab/buckets`
Returns pre-selected packets grouped by type with decoded data and raw_hex. Server picks representatives so the client doesn't need to sift through hundreds.
### UI Layout
```
┌─────────────────────────────────────────────────────┐
│ 🎵 Audio Lab │
├──────────┬──────────────────────────────────────────┤
│ │ │
│ ADVERT │ [▶ Play] [🔁 Loop] [⏱ Slow 0.5x] │
│ ▸ #1 │ │
│ ▸ #2 │ ┌─ Packet Data ──────────────────────┐ │
│ ▸ #3 │ │ Type: ADVERT │ │
│ │ │ Size: 141 bytes (payload: 138) │ │
│ GRP_TXT │ │ Hops: 3 Observations: 12 │ │
│ ▸ #1 │ │ Raw: 04 8b 33 87 e9 c5 cd ea ... │ │
│ ▸ #2 │ └────────────────────────────────────┘ │
│ │ │
│ TXT_MSG │ ┌─ Sound Mapping ────────────────────┐ │
│ ▸ #1 │ │ Instrument: Bell (triangle) │ │
│ │ │ Scale: C major pentatonic │ │
│ TRACE │ │ Notes: 12 (√138 ≈ 11.7) │ │
│ ▸ #1 │ │ Filter: 4200 Hz (3 hops) │ │
│ │ │ Volume: 0.48 (12 obs) │ │
│ │ │ Voices: 4 (12 obs, capped) │ │
│ │ │ Pan: -0.3 (lon: -105.2) │ │
│ │ └────────────────────────────────────┘ │
│ │ │
│ │ ┌─ Note Sequence ────────────────────┐ │
│ │ │ #1: byte 0x8B → C4 (880Hz) 310ms │ │
│ │ │ gap: 82ms (Δ=0x58) │ │
│ │ │ #2: byte 0x33 → G3 (392Hz) 120ms │ │
│ │ │ gap: 210ms (Δ=0xB4) │ │
│ │ │ ... │ │
│ │ └────────────────────────────────────┘ │
│ │ │
│ │ ┌─ Byte Visualizer ──────────────────┐ │
│ │ │ ████░░██████░░░████████░░██░░░░████ │ │
│ │ │ ↑ ↑ ↑ ↑ │ │
│ │ │ sampled bytes highlighted in payload │ │
│ │ └────────────────────────────────────┘ │
├──────────┴──────────────────────────────────────────┤
│ BPM [====●========] 120 Vol [==●===========] 30 │
│ Voice: [constellation ▾] │
└─────────────────────────────────────────────────────┘
```
### Key Features
1. **Play button** — triggers `sonifyPacket()` with the selected packet
2. **Loop** — retrigger every N seconds (configurable)
3. **Slow mode** — 0.25x / 0.5x / 1x / 2x tempo override (separate from BPM, multiplies it)
4. **Note sequence breakdown** — shows every sampled byte, its MIDI note, frequency, duration, gap to next. Highlights each note in real-time as it plays.
5. **Byte visualizer** — hex dump of payload with sampled bytes highlighted. Shows which bytes the voice module chose and what they became.
6. **Sound mapping panel** — shows computed parameters (instrument, scale, filter, pan, volume, voice count) so you can see exactly why it sounds the way it does.
### Playback Highlighting
As each note plays, highlight:
- The corresponding byte in the hex dump
- The note row in the sequence table
- A playhead marker on the byte visualizer bar
This connects the visual and auditory — you SEE which byte is playing RIGHT NOW.
---
## Milestone 2: Parameter Overrides
Once you can hear individual packets clearly, add override sliders to shape the sound:
### Envelope & Tone
- **Oscillator type** — sine / triangle / square / sawtooth
- **ADSR sliders** — attack, decay, sustain, release (with real-time envelope visualizer curve)
- **Scale override** — force any scale regardless of packet type (C maj pent, A min pent, E nat minor, D whole tone, chromatic, etc.)
- **Root note** — base MIDI note for the scale
### Spatial & Filter
- **Filter type** — lowpass / highpass / bandpass
- **Filter cutoff** — manual override of hop-based cutoff (Hz slider + "data-driven" toggle)
- **Filter Q/resonance** — 0.1 to 20
- **Pan lock** — force stereo position (-1 to +1)
### Voicing & Dynamics
- **Voice count** — force 1-8 voices regardless of observation count
- **Detune spread** — cents per voice (0-50)
- **Volume** — manual override of observation-based volume
- **Limiter threshold** — per-packet compressor threshold (dB)
- **Limiter ratio** — 1:1 to 20:1
### Note Timing
- **Note duration range** — min/max duration mapped from byte value
- **Note gap range** — min/max gap mapped from byte delta
- **Lookahead** — scheduling buffer (ms)
Each override has a "lock 🔒" toggle — locked = your value, unlocked = data-driven. Unlocked shows the computed value in real-time so you can see what the data would produce.
The voice module's `play()` accepts an `overrides` object from the workbench. Locked parameters override computed values; unlocked ones pass through.
---
## Milestone 3: A/B Voice Comparison
- Split-screen: two voice modules side by side
- Same packet, different voices
- "Play Both" button with configurable delay between them
- Good for iterating on v2/v3 voices against v1 constellation
---
## Milestone 4: Sequence Editor
- Drag packets into a timeline to create a sequence
- Adjust timing between packets manually
- Play the sequence as a composition
- Export as audio (MediaRecorder API → WAV/WebM)
- Useful for demoing "this is what the mesh sounds like" without waiting for live traffic
---
## Milestone 5: Live Annotation Mode
- Toggle on live map that shows the sound mapping panel for each packet as it plays
- Small floating card near the animated path showing: type, notes, instrument
- Fades out after the notes finish
- Connects the live visualization with the audio in real-time
---
## Architecture Notes
- Audio Lab is a new SPA page like packets/nodes/analytics
- Reuses existing `MeshAudio.sonifyPacket()` and voice modules
- Voice modules need a small extension: `play()` should return a `NoteSequence` object describing what it will play, not just play it. This enables the visualizer.
- Or: add a `describe(parsed, opts)` method that returns the mapping without playing
- BPM/volume/voice selection shared with live map via `MeshAudio.*`
## Implementation Order
1. API endpoint for bucketed representative packets
2. Basic page layout with packet list and play button
3. Sound mapping panel (computed parameters display)
4. Note sequence breakdown
5. Playback highlighting
6. Byte visualizer
7. Override sliders (M2)

View File

@@ -1,5 +1,51 @@
# Changelog
## [2.5.0] "Digital Rain" — 2026-03-22
### ✨ Matrix Mode — Full Cyberpunk Map Theme
Toggle **Matrix** on the live map to transform the entire visualization:
- **Green phosphor CRT aesthetic** — map tiles are desaturated and re-tinted through a `sepia → hue-rotate(70°) → saturate` filter chain, giving roads, coastlines, and terrain a faint green wireframe look against a dark background
- **CRT scanline overlay** — subtle horizontal lines with a gentle flicker animation across the entire map
- **Node markers dim to dark green** (#008a22 at 50% opacity) so they don't compete with packet animations
- **Forces dark mode** while active (saves and restores your previous theme on toggle off)
- **Disables heat map** automatically (incompatible visual combo)
- **All UI panels themed** — feed panel, VCR controls, node detail all go green-on-black with monospace font
- New markers created during Matrix mode (e.g. VCR timeline scrub) are automatically tinted
### ✨ Matrix Hex Flight — Packet Bytes on the Wire
When Matrix mode is enabled, packet animations between nodes show the **actual hex bytes from the raw packet data** flowing along the path:
- **Real packet data** — bytes come from the packet's `raw_hex` field, not random/generated
- **White leading byte** with triple-layer green neon glow (`text-shadow: 0 0 8px, 0 0 16px, 0 0 24px`)
- **Trailing bytes fade** from bright to dim green, shrinking in size with distance from the head
- **Scrolls through all bytes** in the packet as it travels each hop
- **60fps animation** via `requestAnimationFrame` with time-based interpolation (1.1s per hop)
- **300ms fade-out** after reaching the destination node
- Replaces the standard contrail animation; toggle off to restore normal mode
### ✨ Matrix Rain — Falling Packet Columns
A separate **Rain** toggle adds a canvas-rendered overlay of falling hex byte columns, Matrix-style:
- **Each incoming packet** spawns a column of its actual raw hex bytes falling from the top of the screen
- **Fall distance proportional to hop count** — 4+ hops reach the bottom of the screen; a 1-hop packet barely drops. Matches the real mesh network: more hops = more propagation = longer rain trail
- **Fall duration scales with distance** — 5 seconds for a full-screen drop, proportional for shorter
- **Multiple observations = more rain** — each observation of a packet spawns its own column, staggered 150ms apart. A packet seen by 8 observers creates 8 simultaneous falling columns with ±1 hop variation for visual variety
- **Leading byte is bright white** with green glow; trailing bytes progressively fade to green
- **Entire column fades out** in the last 30% of its lifetime
- **Canvas-rendered at 60fps** — no DOM overhead, handles hundreds of simultaneous drops
- **Works independently or with Matrix mode** — combine both for the full effect
- **Replay support** — the ▶ Replay button on packet detail pages now includes raw hex data so replayed packets produce rain
### 🐛 Bug Fixes
- **Fixed null element errors in Matrix hex flight** — `getElement()` returns null when DivIcon hasn't been rendered to DOM yet during fast VCR replay
- **Fixed animation null-guard cascade** — `pulseNode`, `animatePath`, and `drawAnimatedLine` now bail early if map layers are null (stale `setInterval` callbacks after page navigation)
- **Fixed WS broadcast with null packet** — deduplicated observations caused `fullPacket` to be null in WebSocket broadcasts
- **Fixed pause button crash** — was killing WS handler registration
- **Fixed multi-select menu close handler** — null-guard for missing elements
### ⚡ Technical Notes
- Matrix hex flight uses Leaflet `L.divIcon` markers for each character — the smoothness ceiling is Leaflet's DOM repositioning speed. CSS transitions were tested but caused stutter due to conflicts with Leaflet's internal transform updates.
- Matrix Rain uses a raw `<canvas>` overlay at z-index 9998 for zero-DOM-overhead rendering. Each drop is a simple `{x, maxY, duration, bytes, startTime}` struct rendered in a single `requestAnimationFrame` loop.
- Map tile tinting applies CSS filters to `.leaflet-tile-pane` and green overlays via `::before`/`::after` pseudo-elements on the map container (same element as `.leaflet-container`, so selectors use `.matrix-theme.leaflet-container` not descendant `.matrix-theme .leaflet-container`).
## [2.4.1] — 2026-03-22
Hotfix release for regressions introduced in v2.4.0.
@@ -7,6 +53,7 @@ Hotfix release for regressions introduced in v2.4.0.
### Fixed
- Packet ingestion broken: `insert()` returned undefined after legacy table removal, causing all MQTT packets to fail silently
- Live packet updates not working: pause button `addEventListener` on null element crashed `init()`, preventing WS handler registration
- Pause button not toggling: event delegation was on `app` variable not in IIFE scope; moved to `document`
- WS broadcast had null packet data when observation was deduped (2nd+ observer of same packet)
- Multi-select filter menu close handler crashed on null `observerFilterWrap`/`typeFilterWrap` elements
- Live map animation cleanup crashed with null `animLayer`/`pathsLayer` after navigating away (setInterval kept firing)

64
RELEASE-v2.6.0.md Normal file
View File

@@ -0,0 +1,64 @@
# v2.6.0 — Audio Sonification, Regional Hop Filtering, Audio Lab
## 🔊 Mesh Audio Sonification
Packets now have sound. Each packet's raw bytes become music through a modular voice engine.
- **Payload type → instrument + scale**: ADVERTs play triangle waves on C major pentatonic, GRP_TXT uses sine on A minor pentatonic, TXT_MSG on E natural minor, TRACE on D whole tone
- **Payload bytes → melody**: √(payload_length) bytes sampled evenly, quantized to scale
- **Byte value → note duration**: low bytes = staccato, high = sustained
- **Byte delta → note spacing**: small deltas = rapid fire, large = pauses
- **Observation count → volume + chord voicing**: more observers = louder + richer (up to 8 detuned voices via log₂ scaling)
- **Hop count → filter cutoff**: more hops = more muffled (lowpass 800-8000Hz)
- **Node longitude → stereo pan**
- **BPM tempo slider** for ambient ↔ techno feel
- **Per-packet limiter** prevents amplitude spikes from overlapping notes
- **Exponential envelopes** eliminate click/pop artifacts
- **"Tap to enable audio" overlay** handles browser autoplay policy
- **Modular voice architecture**: engine (`audio.js`) + swappable voice modules. New voices = new file + script tag.
## 🎵 Audio Lab (Packet Jukebox)
New `#/audio-lab` page for understanding and debugging the audio:
- **Packet buckets by type** — representative packets spanning size/observation ranges
- **Play/Loop/Speed controls** — trigger individual packets, 0.25x to 4x speed
- **Sound Mapping panel** — shows WHY each parameter has its value (formulas + computed results)
- **Note Sequence table** — every sampled byte → MIDI note → frequency → duration → gap, with derivation formulas
- **Real-time playback highlighting** — hex dump, note rows, and byte visualizer highlight in sync as each note plays
- **Click individual notes** — play any single note from the sequence
- **Byte Visualizer** — bar chart of payload bytes, sampled bytes colored by type
## 🗺️ Regional Hop Filtering (#117)
1-byte repeater IDs (0-255) collide globally. Previously, resolve-hops picked candidates from anywhere, causing false cross-regional paths (e.g., Eugene packet showing Vancouver repeaters).
- **Layered filtering**: GPS distance to IATA center (bridge-proof) → observer-based fallback → global fallback
- **60+ IATA airport coordinates** built in for geographic distance calculations
- **Regional candidates sorted by distance** — closest to region center wins when no sender GPS available
- **Sender GPS as origin anchor** — ADVERTs use their own coordinates; channel messages look up sender node GPS from previous ADVERTs in the database
- **Per-observer resolution** — packet list batch-resolves ambiguous hops per observer via server API
- **Conflict popover** — clickable ⚠ badges show all regional candidates with distances, each linking to node detail
- **Shared HopDisplay module** — consistent conflict display across packets, nodes, and detail views
## 🏷️ Region Dropdown Improvements (#116)
- **150+ built-in IATA-to-city mappings** — dropdown shows `SEA - Seattle, WA` automatically, no config needed
- **Layout fixes** — dropdown auto-sizes for longer labels, checkbox alignment, ellipsis overflow
## 📍 Location & Navigation
- **Packet detail shows location** for ADVERTs (direct GPS), channel texts (sender node lookup), and all resolvable senders
- **📍 Map link** navigates to `#/map?node=PUBKEY` — centers on the actual node and opens its popup
- **Observer IATA regions** shown in packet detail, node detail, and live map node panels
## 🔧 Fixes
- **Realistic mode fixed** — secondary WS broadcast paths (ADVERT, GRP_TXT, TXT_MSG, TRACE) were missing `hash` field, bypassing the 5-second grouping buffer entirely
- **Observation count passed to sonification** — realistic mode now provides actual observer count for volume/chord voicing
- **Packet list dedup** — O(1) hash index via Map prevents duplicate rows
- **Observer names in packet detail** — direct navigation to `#/packets/HASH` now loads observers first
- **Observer detail packet links** — fixed to use hash (not ID) and correct route
- **Time window bypassed for direct links** — `#/packets/HASH` always shows the packet regardless of time filter
- **CI: `docker rm -f`** — prevents stale container conflicts during deploy
- **CI: `paths-ignore`** — skips deploy on markdown/docs/license changes

90
iata-coords.js Normal file
View File

@@ -0,0 +1,90 @@
// IATA airport coordinates for regional node filtering
// Used by resolve-hops to determine if a node is geographically near an observer's region
const IATA_COORDS = {
// US West Coast
SJC: { lat: 37.3626, lon: -121.9290 },
SFO: { lat: 37.6213, lon: -122.3790 },
OAK: { lat: 37.7213, lon: -122.2208 },
SEA: { lat: 47.4502, lon: -122.3088 },
PDX: { lat: 45.5898, lon: -122.5951 },
LAX: { lat: 33.9425, lon: -118.4081 },
SAN: { lat: 32.7338, lon: -117.1933 },
SMF: { lat: 38.6954, lon: -121.5908 },
MRY: { lat: 36.5870, lon: -121.8430 },
EUG: { lat: 44.1246, lon: -123.2119 },
RDD: { lat: 40.5090, lon: -122.2934 },
MFR: { lat: 42.3742, lon: -122.8735 },
FAT: { lat: 36.7762, lon: -119.7181 },
SBA: { lat: 34.4262, lon: -119.8405 },
RNO: { lat: 39.4991, lon: -119.7681 },
BOI: { lat: 43.5644, lon: -116.2228 },
LAS: { lat: 36.0840, lon: -115.1537 },
PHX: { lat: 33.4373, lon: -112.0078 },
SLC: { lat: 40.7884, lon: -111.9778 },
// US Mountain/Central
DEN: { lat: 39.8561, lon: -104.6737 },
DFW: { lat: 32.8998, lon: -97.0403 },
IAH: { lat: 29.9844, lon: -95.3414 },
AUS: { lat: 30.1975, lon: -97.6664 },
MSP: { lat: 44.8848, lon: -93.2223 },
// US East Coast
ATL: { lat: 33.6407, lon: -84.4277 },
ORD: { lat: 41.9742, lon: -87.9073 },
JFK: { lat: 40.6413, lon: -73.7781 },
EWR: { lat: 40.6895, lon: -74.1745 },
BOS: { lat: 42.3656, lon: -71.0096 },
MIA: { lat: 25.7959, lon: -80.2870 },
IAD: { lat: 38.9531, lon: -77.4565 },
CLT: { lat: 35.2144, lon: -80.9473 },
DTW: { lat: 42.2124, lon: -83.3534 },
MCO: { lat: 28.4312, lon: -81.3081 },
BNA: { lat: 36.1263, lon: -86.6774 },
RDU: { lat: 35.8801, lon: -78.7880 },
// Canada
YVR: { lat: 49.1967, lon: -123.1815 },
YYZ: { lat: 43.6777, lon: -79.6248 },
YYC: { lat: 51.1215, lon: -114.0076 },
YEG: { lat: 53.3097, lon: -113.5800 },
YOW: { lat: 45.3225, lon: -75.6692 },
// Europe
LHR: { lat: 51.4700, lon: -0.4543 },
CDG: { lat: 49.0097, lon: 2.5479 },
FRA: { lat: 50.0379, lon: 8.5622 },
AMS: { lat: 52.3105, lon: 4.7683 },
MUC: { lat: 48.3537, lon: 11.7750 },
SOF: { lat: 42.6952, lon: 23.4062 },
// Asia/Pacific
NRT: { lat: 35.7720, lon: 140.3929 },
HND: { lat: 35.5494, lon: 139.7798 },
ICN: { lat: 37.4602, lon: 126.4407 },
SYD: { lat: -33.9461, lon: 151.1772 },
MEL: { lat: -37.6690, lon: 144.8410 },
};
// Haversine distance in km
function haversineKm(lat1, lon1, lat2, lon2) {
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
// Default radius for "near region" — LoRa max realistic range ~300km
const DEFAULT_REGION_RADIUS_KM = 300;
/**
* Check if a node is geographically within radius of an IATA region center.
* Returns { near: boolean, distKm: number } or null if can't determine.
*/
function nodeNearRegion(nodeLat, nodeLon, iata, radiusKm = DEFAULT_REGION_RADIUS_KM) {
const center = IATA_COORDS[iata];
if (!center) return null;
if (nodeLat == null || nodeLon == null || (nodeLat === 0 && nodeLon === 0)) return null;
const distKm = haversineKm(nodeLat, nodeLon, center.lat, center.lon);
return { near: distKm <= radiusKm, distKm: Math.round(distKm) };
}
module.exports = { IATA_COORDS, haversineKm, nodeNearRegion, DEFAULT_REGION_RADIUS_KM };

View File

@@ -1,6 +1,6 @@
{
"name": "meshcore-analyzer",
"version": "2.4.1",
"version": "2.6.0",
"description": "Community-run alternative to the closed-source `analyzer.letsmesh.net`. MQTT packet collection + open-source web analyzer for the Bay Area MeshCore mesh.",
"main": "index.js",
"scripts": {

562
public/audio-lab.js Normal file
View File

@@ -0,0 +1,562 @@
/* === MeshCore Analyzer — audio-lab.js === */
/* Audio Lab: Packet Jukebox for sound debugging & understanding */
'use strict';
(function () {
let styleEl = null;
let loopTimer = null;
let selectedPacket = null;
let baseBPM = 120;
let speedMult = 1;
let highlightTimers = [];
const TYPE_COLORS = {
ADVERT: '#f59e0b', GRP_TXT: '#10b981', TXT_MSG: '#6366f1',
TRACE: '#8b5cf6', REQ: '#ef4444', RESPONSE: '#3b82f6',
ACK: '#6b7280', PATH: '#ec4899', ANON_REQ: '#f97316', UNKNOWN: '#6b7280'
};
const SCALE_NAMES = {
ADVERT: 'C major pentatonic', GRP_TXT: 'A minor pentatonic',
TXT_MSG: 'E natural minor', TRACE: 'D whole tone'
};
const SYNTH_TYPES = {
ADVERT: 'triangle', GRP_TXT: 'sine', TXT_MSG: 'triangle', TRACE: 'sine'
};
const SCALE_INTERVALS = {
ADVERT: { intervals: [0,2,4,7,9], root: 48 },
GRP_TXT: { intervals: [0,3,5,7,10], root: 45 },
TXT_MSG: { intervals: [0,2,3,5,7,8,10], root: 40 },
TRACE: { intervals: [0,2,4,6,8,10], root: 50 },
};
function injectStyles() {
if (styleEl) return;
styleEl = document.createElement('style');
styleEl.textContent = `
.alab { display: flex; height: 100%; overflow: hidden; }
.alab-sidebar { width: 280px; min-width: 200px; border-right: 1px solid var(--border);
overflow-y: auto; padding: 12px; background: var(--surface-1); }
.alab-main { flex: 1; overflow-y: auto; padding: 16px 24px; }
.alab-type-hdr { font-weight: 700; font-size: 13px; padding: 6px 8px; margin-top: 8px;
border-radius: 6px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; }
.alab-type-hdr:hover { opacity: 0.8; }
.alab-type-list { padding: 0; }
.alab-pkt { padding: 5px 8px 5px 16px; font-size: 12px; font-family: var(--mono);
cursor: pointer; border-radius: 4px; color: var(--text-muted); }
.alab-pkt:hover { background: var(--hover-bg); }
.alab-pkt.selected { background: var(--selected-bg); color: var(--text); font-weight: 600; }
.alab-controls { display: flex; flex-wrap: wrap; gap: 12px; align-items: center;
padding: 12px 16px; background: var(--surface-1); border-radius: 8px; margin-bottom: 16px; border: 1px solid var(--border); }
.alab-btn { padding: 6px 14px; border: 1px solid var(--border); border-radius: 6px;
background: var(--surface-1); color: var(--text); cursor: pointer; font-size: 13px; }
.alab-btn:hover { background: var(--hover-bg); }
.alab-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.alab-speed { padding: 4px 8px; font-size: 12px; border-radius: 4px; border: 1px solid var(--border);
background: var(--surface-1); color: var(--text-muted); cursor: pointer; }
.alab-speed.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.alab-section { background: var(--surface-1); border: 1px solid var(--border);
border-radius: 8px; padding: 16px; margin-bottom: 16px; }
.alab-section h3 { margin: 0 0 12px 0; font-size: 14px; color: var(--text-muted); font-weight: 600; }
.alab-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px; }
.alab-stat { font-size: 12px; }
.alab-stat .label { color: var(--text-muted); }
.alab-stat .value { font-weight: 600; font-family: var(--mono); }
.alab-hex { font-family: var(--mono); font-size: 11px; word-break: break-all; line-height: 1.6;
max-height: 80px; overflow: hidden; transition: max-height 0.3s; }
.alab-hex.expanded { max-height: none; }
.alab-hex .sampled { background: var(--accent); color: #fff; border-radius: 2px; padding: 0 1px; }
.alab-note-table { width: 100%; font-size: 12px; border-collapse: collapse; }
.alab-note-table th { text-align: left; font-weight: 600; color: var(--text-muted);
padding: 4px 8px; border-bottom: 1px solid var(--border); font-size: 11px; }
.alab-note-table td { padding: 4px 8px; border-bottom: 1px solid var(--border); font-family: var(--mono); }
.alab-byte-viz { display: flex; align-items: flex-end; height: 60px; gap: 1px; margin-top: 8px; }
.alab-byte-bar { flex: 1; min-width: 2px; border-radius: 1px 1px 0 0; transition: box-shadow 0.1s; }
.alab-byte-bar.playing { box-shadow: 0 0 8px 2px currentColor; transform: scaleY(1.15); }
.alab-hex .playing { background: #ff6b6b !important; color: #fff !important; border-radius: 2px; padding: 0 2px; transition: background 0.1s; }
.alab-note-table tr.playing { background: var(--accent) !important; color: #fff; }
.alab-note-table tr.playing td { color: #fff; }
.alab-map-table { width: 100%; font-size: 13px; border-collapse: collapse; }
.alab-map-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
.alab-map-table .map-param { font-weight: 600; white-space: nowrap; width: 110px; }
.alab-map-table .map-value { font-family: var(--mono); font-weight: 700; white-space: nowrap; width: 120px; }
.alab-map-table .map-why { font-size: 11px; color: var(--text-muted); font-family: var(--mono); }
.map-why-inline { display: block; font-size: 10px; color: var(--text-muted); font-family: var(--mono); margin-top: 2px; }
.alab-note-play { background: none; border: 1px solid var(--border); border-radius: 4px; cursor: pointer;
font-size: 10px; padding: 2px 6px; color: var(--text-muted); }
.alab-note-play:hover { background: var(--accent); color: #fff; border-color: var(--accent); }
.alab-note-clickable { cursor: pointer; }
.alab-note-clickable:hover { background: var(--hover-bg); }
.alab-empty { text-align: center; padding: 60px 20px; color: var(--text-muted); font-size: 15px; }
.alab-slider-group { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-muted); }
.alab-slider-group input[type=range] { width: 80px; }
.alab-slider-group select { font-size: 12px; padding: 2px 4px; background: var(--input-bg); color: var(--text); border: 1px solid var(--border); border-radius: 4px; }
@media (max-width: 768px) {
.alab { flex-direction: column; }
.alab-sidebar { width: 100%; max-height: 200px; border-right: none; border-bottom: 1px solid var(--border); }
.alab-main { padding: 12px; }
}
`;
document.head.appendChild(styleEl);
}
function parseHex(hex) {
const bytes = [];
for (let i = 0; i < hex.length; i += 2) {
const b = parseInt(hex.slice(i, i + 2), 16);
if (!isNaN(b)) bytes.push(b);
}
return bytes;
}
function computeMapping(pkt) {
const { buildScale, midiToFreq, mapRange, quantizeToScale } = MeshAudio.helpers;
const rawHex = pkt.raw_hex || '';
const allBytes = parseHex(rawHex);
if (allBytes.length < 3) return null;
const payloadBytes = allBytes.slice(3);
let typeName = 'UNKNOWN';
try { const d = JSON.parse(pkt.decoded_json || '{}'); typeName = d.type || 'UNKNOWN'; } catch {}
const hops = [];
try { const p = JSON.parse(pkt.path_json || '[]'); if (Array.isArray(p)) hops.push(...p); } catch {}
const hopCount = Math.max(1, hops.length);
const obsCount = pkt.observation_count || 1;
const si = SCALE_INTERVALS[typeName] || SCALE_INTERVALS.ADVERT;
const scale = buildScale(si.intervals, si.root);
const scaleName = SCALE_NAMES[typeName] || 'C major pentatonic';
const oscType = SYNTH_TYPES[typeName] || 'triangle';
const noteCount = Math.max(2, Math.min(10, Math.ceil(Math.sqrt(payloadBytes.length))));
const sampledIndices = [];
const sampledBytes = [];
for (let i = 0; i < noteCount; i++) {
const idx = Math.floor((i / noteCount) * payloadBytes.length);
sampledIndices.push(idx);
sampledBytes.push(payloadBytes[idx]);
}
const filterHz = Math.round(mapRange(Math.min(hopCount, 10), 1, 10, 8000, 800));
const volume = Math.min(0.6, 0.15 + (obsCount - 1) * 0.02);
const voiceCount = Math.min(Math.max(1, Math.ceil(Math.log2(obsCount + 1))), 8);
let panValue = 0;
let panSource = 'no location data → center';
try {
const d = JSON.parse(pkt.decoded_json || '{}');
if (d.lon != null) {
panValue = Math.max(-1, Math.min(1, mapRange(d.lon, -125, -65, -1, 1)));
panSource = `lon ${d.lon.toFixed(1)}° → map(-125...-65) → ${panValue.toFixed(2)}`;
}
} catch {}
// Detune description
const detuneDesc = [];
for (let v = 0; v < voiceCount; v++) {
const d = v === 0 ? 0 : (v % 2 === 0 ? 1 : -1) * (v * 5 + 3);
detuneDesc.push((d >= 0 ? '+' : '') + d + '¢');
}
const bpm = MeshAudio.getBPM ? MeshAudio.getBPM() : 120;
const tm = 60 / bpm; // BPM already includes speed multiplier
const notes = sampledBytes.map((byte, i) => {
const midi = quantizeToScale(byte, scale);
const freq = midiToFreq(midi);
const duration = mapRange(byte, 0, 255, 0.05, 0.4) * tm * 1000;
let gap = 0.05 * tm * 1000;
if (i < sampledBytes.length - 1) {
const delta = Math.abs(sampledBytes[i + 1] - byte);
gap = mapRange(delta, 0, 255, 0.03, 0.3) * tm * 1000;
}
return { index: sampledIndices[i], byte, midi, freq: Math.round(freq), duration: Math.round(duration), gap: Math.round(gap) };
});
return {
typeName, allBytes, payloadBytes, sampledIndices, sampledBytes, notes,
noteCount, filterHz, volume: volume.toFixed(3), voiceCount, panValue: panValue.toFixed(2),
oscType, scaleName, hopCount, obsCount,
totalSize: allBytes.length, payloadSize: payloadBytes.length,
color: TYPE_COLORS[typeName] || TYPE_COLORS.UNKNOWN,
panSource, detuneDesc,
};
}
function renderDetail(pkt, app) {
const m = computeMapping(pkt);
if (!m) { document.getElementById('alabDetail').innerHTML = '<div class="alab-empty">No raw hex data for this packet</div>'; return; }
// Hex dump with sampled bytes highlighted
const sampledSet = new Set(m.sampledIndices);
let hexHtml = '';
for (let i = 0; i < m.payloadBytes.length; i++) {
const h = m.payloadBytes[i].toString(16).padStart(2, '0').toUpperCase();
if (sampledSet.has(i)) hexHtml += `<span class="sampled" id="hexByte${i}">${h}</span> `;
else hexHtml += `<span id="hexByte${i}">${h}</span> `;
}
document.getElementById('alabDetail').innerHTML = `
<div class="alab-section">
<h3>📦 Packet Data</h3>
<div class="alab-grid">
<div class="alab-stat"><span class="label">Type</span><br><span class="value" style="color:${m.color}">${m.typeName}</span></div>
<div class="alab-stat"><span class="label">Total Size</span><br><span class="value">${m.totalSize} bytes</span></div>
<div class="alab-stat"><span class="label">Payload Size</span><br><span class="value">${m.payloadSize} bytes</span></div>
<div class="alab-stat"><span class="label">Hops</span><br><span class="value">${m.hopCount}</span></div>
<div class="alab-stat"><span class="label">Observations</span><br><span class="value">${m.obsCount}</span></div>
<div class="alab-stat"><span class="label">Hash</span><br><span class="value">${pkt.hash || '—'}</span></div>
</div>
<div style="margin-top:10px">
<div class="alab-hex" id="alabHex" onclick="this.classList.toggle('expanded')" title="Click to expand">${hexHtml}</div>
</div>
</div>
<div class="alab-section">
<h3>🎵 Sound Mapping</h3>
<table class="alab-map-table">
<tr>
<td class="map-param">Instrument</td>
<td class="map-value">${m.oscType}</td>
<td class="map-why">payload_type = ${m.typeName}${m.oscType} oscillator</td>
</tr>
<tr>
<td class="map-param">Scale</td>
<td class="map-value">${m.scaleName}</td>
<td class="map-why">payload_type = ${m.typeName}${m.scaleName} (root MIDI ${SCALE_INTERVALS[m.typeName]?.root || 48})</td>
</tr>
<tr>
<td class="map-param">Notes</td>
<td class="map-value">${m.noteCount}</td>
<td class="map-why">⌈√${m.payloadSize}⌉ = ⌈${Math.sqrt(m.payloadSize).toFixed(1)}⌉ = ${m.noteCount} bytes sampled evenly across payload</td>
</tr>
<tr>
<td class="map-param">Filter Cutoff</td>
<td class="map-value">${m.filterHz} Hz</td>
<td class="map-why">${m.hopCount} hops → map(1...10 → 8000...800 Hz) = ${m.filterHz} Hz lowpass — more hops = more muffled</td>
</tr>
<tr>
<td class="map-param">Volume</td>
<td class="map-value">${m.volume}</td>
<td class="map-why">min(0.6, 0.15 + (${m.obsCount} obs 1) × 0.02) = ${m.volume} — more observers = louder</td>
</tr>
<tr>
<td class="map-param">Voices</td>
<td class="map-value">${m.voiceCount}</td>
<td class="map-why">min(⌈log₂(${m.obsCount} + 1)⌉, 8) = ${m.voiceCount} — more observers = richer chord</td>
</tr>
<tr>
<td class="map-param">Detune</td>
<td class="map-value">${m.detuneDesc.join(', ')}</td>
<td class="map-why">${m.voiceCount} voices detuned for shimmer — wider spread with more voices</td>
</tr>
<tr>
<td class="map-param">Pan</td>
<td class="map-value">${m.panValue}</td>
<td class="map-why">${m.panSource}</td>
</tr>
</table>
</div>
<div class="alab-section">
<h3>🎹 Note Sequence</h3>
<table class="alab-note-table">
<tr><th></th><th>#</th><th>Payload Index</th><th>Byte</th><th>→ MIDI</th><th>→ Freq</th><th>Duration (why)</th><th>Gap (why)</th></tr>
${m.notes.map((n, i) => {
const durWhy = `byte ${n.byte} → map(0...255 → 50...400ms) × tempo`;
const gapWhy = i < m.notes.length - 1
? `|${n.byte} ${m.notes[i+1].byte}| = ${Math.abs(m.notes[i+1].byte - n.byte)} → map(0...255 → 30...300ms) × tempo`
: '';
return `<tr id="noteRow${i}" class="alab-note-clickable" data-note-idx="${i}">
<td><button class="alab-note-play" data-note-idx="${i}" title="Play this note">▶</button></td>
<td>${i + 1}</td>
<td>[${n.index}]</td>
<td>0x${n.byte.toString(16).padStart(2, '0').toUpperCase()} (${n.byte})</td>
<td>${n.midi}</td>
<td>${n.freq} Hz</td>
<td>${n.duration} ms <span class="map-why-inline">${durWhy}</span></td>
<td>${i < m.notes.length - 1 ? n.gap + ' ms <span class="map-why-inline">' + gapWhy + '</span>' : '—'}</td>
</tr>`;}).join('')}
</table>
</div>
<div class="alab-section">
<h3>📊 Byte Visualizer</h3>
<div class="alab-byte-viz" id="alabByteViz"></div>
</div>
`;
// Render byte visualizer
const viz = document.getElementById('alabByteViz');
if (viz) {
for (let i = 0; i < m.payloadBytes.length; i++) {
const bar = document.createElement('div');
bar.className = 'alab-byte-bar';
bar.id = 'byteBar' + i;
const h = Math.max(2, (m.payloadBytes[i] / 255) * 60);
bar.style.height = h + 'px';
bar.style.background = sampledSet.has(i) ? m.color : '#555';
bar.style.opacity = sampledSet.has(i) ? '1' : '0.3';
bar.title = `[${i}] 0x${m.payloadBytes[i].toString(16).padStart(2, '0')} = ${m.payloadBytes[i]}`;
viz.appendChild(bar);
}
}
// Wire up individual note play buttons
document.querySelectorAll('.alab-note-play').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
playOneNote(parseInt(btn.dataset.noteIdx));
});
});
// Also allow clicking anywhere on the row
document.querySelectorAll('.alab-note-clickable').forEach(row => {
row.addEventListener('click', () => playOneNote(parseInt(row.dataset.noteIdx)));
});
}
function clearHighlights() {
highlightTimers.forEach(t => clearTimeout(t));
highlightTimers = [];
document.querySelectorAll('.alab-hex .playing, .alab-note-table .playing, .alab-byte-bar.playing').forEach(el => el.classList.remove('playing'));
}
function highlightPlayback(mapping) {
clearHighlights();
let timeOffset = 0;
mapping.notes.forEach((note, i) => {
// Highlight ON
highlightTimers.push(setTimeout(() => {
// Clear previous note highlights
document.querySelectorAll('.alab-hex .playing, .alab-note-table .playing, .alab-byte-bar.playing').forEach(el => el.classList.remove('playing'));
// Hex byte
const hexEl = document.getElementById('hexByte' + note.index);
if (hexEl) { hexEl.classList.add('playing'); hexEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }
// Note row
const rowEl = document.getElementById('noteRow' + i);
if (rowEl) { rowEl.classList.add('playing'); rowEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }
// Byte bar
const barEl = document.getElementById('byteBar' + note.index);
if (barEl) barEl.classList.add('playing');
}, timeOffset));
timeOffset += note.duration + (i < mapping.notes.length - 1 ? note.gap : 0);
});
// Clear all at end
highlightTimers.push(setTimeout(clearHighlights, timeOffset + 200));
}
function playOneNote(noteIdx) {
if (!selectedPacket) return;
const m = computeMapping(selectedPacket);
if (!m || !m.notes[noteIdx]) return;
if (window.MeshAudio && !MeshAudio.isEnabled()) MeshAudio.setEnabled(true);
const audioCtx = MeshAudio.getContext();
if (!audioCtx) return;
if (audioCtx.state === 'suspended') audioCtx.resume();
const note = m.notes[noteIdx];
const oscType = SYNTH_TYPES[m.typeName] || 'triangle';
const ADSR = { ADVERT: { a: 0.02, d: 0.3, s: 0.4, r: 0.5 }, GRP_TXT: { a: 0.005, d: 0.15, s: 0.1, r: 0.2 },
TXT_MSG: { a: 0.01, d: 0.2, s: 0.3, r: 0.4 }, TRACE: { a: 0.05, d: 0.4, s: 0.5, r: 0.8 } };
const env = ADSR[m.typeName] || ADSR.ADVERT;
const vol = parseFloat(m.volume) || 0.3;
const dur = note.duration / 1000;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
const filter = audioCtx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = m.filterHz;
osc.type = oscType;
osc.frequency.value = note.freq;
const now = audioCtx.currentTime + 0.02;
const sustainVol = Math.max(vol * env.s, 0.0001);
gain.gain.setValueAtTime(0.0001, now);
gain.gain.exponentialRampToValueAtTime(Math.max(vol, 0.0001), now + env.a);
gain.gain.exponentialRampToValueAtTime(sustainVol, now + env.a + env.d);
gain.gain.setTargetAtTime(0.0001, now + dur, env.r / 5);
osc.connect(gain);
gain.connect(filter);
filter.connect(audioCtx.destination);
osc.start(now);
osc.stop(now + dur + env.r + 0.1);
osc.onended = () => { osc.disconnect(); gain.disconnect(); filter.disconnect(); };
// Highlight this note
clearHighlights();
const hexEl = document.getElementById('hexByte' + note.index);
const rowEl = document.getElementById('noteRow' + noteIdx);
const barEl = document.getElementById('byteBar' + note.index);
if (hexEl) hexEl.classList.add('playing');
if (rowEl) rowEl.classList.add('playing');
if (barEl) barEl.classList.add('playing');
highlightTimers.push(setTimeout(clearHighlights, note.duration + 200));
}
function playSelected() {
if (!selectedPacket) return;
if (window.MeshAudio) {
if (!MeshAudio.isEnabled()) MeshAudio.setEnabled(true);
// Build a packet object that sonifyPacket expects
const pkt = {
raw_hex: selectedPacket.raw_hex,
raw: selectedPacket.raw_hex,
observation_count: selectedPacket.observation_count || 1,
decoded: {}
};
try {
const d = JSON.parse(selectedPacket.decoded_json || '{}');
const typeName = d.type || 'UNKNOWN';
pkt.decoded = {
header: { payloadTypeName: typeName },
payload: d,
path: { hops: JSON.parse(selectedPacket.path_json || '[]') }
};
} catch {}
MeshAudio.sonifyPacket(pkt);
// Sync highlights with audio
const m = computeMapping(selectedPacket);
if (m) highlightPlayback(m);
}
}
async function init(app) {
injectStyles();
baseBPM = (MeshAudio && MeshAudio.getBPM) ? MeshAudio.getBPM() : 120;
speedMult = 1;
app.innerHTML = `
<div class="alab">
<div class="alab-sidebar" id="alabSidebar"><div style="color:var(--text-muted);font-size:13px;padding:8px">Loading packets...</div></div>
<div class="alab-main">
<div class="alab-controls" id="alabControls">
<button class="alab-btn" id="alabPlay" title="Play selected packet">▶ Play</button>
<button class="alab-btn" id="alabLoop" title="Loop playback">🔁 Loop</button>
<span style="font-size:12px;color:var(--text-muted)">Speed:</span>
<button class="alab-speed" data-speed="0.25">0.25x</button>
<button class="alab-speed active" data-speed="1">1x</button>
<button class="alab-speed" data-speed="2">2x</button>
<button class="alab-speed" data-speed="4">4x</button>
<div class="alab-slider-group">
<span>BPM</span>
<input type="range" id="alabBPM" min="30" max="300" value="${baseBPM}">
<span id="alabBPMVal">${baseBPM}</span>
</div>
<div class="alab-slider-group">
<span>Vol</span>
<input type="range" id="alabVol" min="0" max="100" value="${MeshAudio && MeshAudio.getVolume ? Math.round(MeshAudio.getVolume() * 100) : 30}">
<span id="alabVolVal">${MeshAudio && MeshAudio.getVolume ? Math.round(MeshAudio.getVolume() * 100) : 30}%</span>
</div>
<div class="alab-slider-group">
<span>Voice</span>
<select id="alabVoice">${(MeshAudio && MeshAudio.getVoiceNames ? MeshAudio.getVoiceNames() : ['constellation']).map(v =>
`<option value="${v}" ${(MeshAudio && MeshAudio.getVoiceName && MeshAudio.getVoiceName() === v) ? 'selected' : ''}>${v}</option>`
).join('')}</select>
</div>
</div>
<div id="alabDetail"><div class="alab-empty">← Select a packet from the sidebar to explore its sound</div></div>
</div>
</div>
`;
// Controls
document.getElementById('alabPlay').addEventListener('click', playSelected);
document.getElementById('alabLoop').addEventListener('click', function () {
if (loopTimer) { clearInterval(loopTimer); loopTimer = null; this.classList.remove('active'); return; }
this.classList.add('active');
playSelected();
loopTimer = setInterval(playSelected, 3000);
});
document.querySelectorAll('.alab-speed').forEach(btn => {
btn.addEventListener('click', function () {
document.querySelectorAll('.alab-speed').forEach(b => b.classList.remove('active'));
this.classList.add('active');
speedMult = parseFloat(this.dataset.speed);
if (MeshAudio && MeshAudio.setBPM) MeshAudio.setBPM(baseBPM * speedMult);
if (selectedPacket) renderDetail(selectedPacket, app);
});
});
document.getElementById('alabBPM').addEventListener('input', function () {
baseBPM = parseInt(this.value);
document.getElementById('alabBPMVal').textContent = baseBPM;
if (MeshAudio && MeshAudio.setBPM) MeshAudio.setBPM(baseBPM * speedMult);
if (selectedPacket) renderDetail(selectedPacket, app);
});
document.getElementById('alabVol').addEventListener('input', function () {
const v = parseInt(this.value) / 100;
document.getElementById('alabVolVal').textContent = Math.round(v * 100) + '%';
if (MeshAudio && MeshAudio.setVolume) MeshAudio.setVolume(v);
});
document.getElementById('alabVoice').addEventListener('change', function () {
if (MeshAudio && MeshAudio.setVoice) MeshAudio.setVoice(this.value);
});
// Load buckets
try {
const data = await api('/audio-lab/buckets');
const sidebar = document.getElementById('alabSidebar');
if (!data.buckets || Object.keys(data.buckets).length === 0) {
sidebar.innerHTML = '<div style="color:var(--text-muted);font-size:13px;padding:8px">No packets in memory yet</div>';
return;
}
let html = '';
for (const [type, pkts] of Object.entries(data.buckets)) {
const color = TYPE_COLORS[type] || TYPE_COLORS.UNKNOWN;
html += `<div class="alab-type-hdr" style="background:${color}22;color:${color}" data-type="${type}">
<span>${type}</span><span style="font-size:11px;opacity:0.7">${pkts.length}</span></div>`;
html += `<div class="alab-type-list" data-type-list="${type}">`;
pkts.forEach((p, i) => {
const size = p.raw_hex ? p.raw_hex.length / 2 : 0;
html += `<div class="alab-pkt" data-type="${type}" data-idx="${i}">#${i + 1}${size}B — ${p.observation_count || 1} obs</div>`;
});
html += '</div>';
}
sidebar.innerHTML = html;
// Store buckets for selection
sidebar._buckets = data.buckets;
// Click handlers
sidebar.addEventListener('click', function (e) {
const typeHdr = e.target.closest('.alab-type-hdr');
if (typeHdr) {
const list = sidebar.querySelector(`[data-type-list="${typeHdr.dataset.type}"]`);
if (list) list.style.display = list.style.display === 'none' ? '' : 'none';
return;
}
const pktEl = e.target.closest('.alab-pkt');
if (pktEl) {
sidebar.querySelectorAll('.alab-pkt').forEach(el => el.classList.remove('selected'));
pktEl.classList.add('selected');
const type = pktEl.dataset.type;
const idx = parseInt(pktEl.dataset.idx);
selectedPacket = sidebar._buckets[type][idx];
renderDetail(selectedPacket, app);
}
});
} catch (err) {
document.getElementById('alabSidebar').innerHTML = `<div style="color:var(--text-muted);padding:8px">Error loading packets: ${err.message}</div>`;
}
}
function destroy() {
clearHighlights();
if (loopTimer) { clearInterval(loopTimer); loopTimer = null; }
if (styleEl) { styleEl.remove(); styleEl = null; }
selectedPacket = null;
}
registerPage('audio-lab', { init, destroy });
})();

View File

@@ -0,0 +1,139 @@
// Voice v1: "Constellation" — melodic packet sonification
// Original voice: type-based instruments, scale-quantized melody from payload bytes,
// byte-driven note duration and spacing, hop-based filter, observation chord voicing.
(function () {
'use strict';
const { buildScale, midiToFreq, mapRange, quantizeToScale } = MeshAudio.helpers;
// Scales per payload type
const SCALES = {
ADVERT: buildScale([0, 2, 4, 7, 9], 48), // C major pentatonic
GRP_TXT: buildScale([0, 3, 5, 7, 10], 45), // A minor pentatonic
TXT_MSG: buildScale([0, 2, 3, 5, 7, 8, 10], 40),// E natural minor
TRACE: buildScale([0, 2, 4, 6, 8, 10], 50), // D whole tone
};
const DEFAULT_SCALE = SCALES.ADVERT;
// Synth ADSR envelopes per type
const SYNTHS = {
ADVERT: { type: 'triangle', attack: 0.02, decay: 0.3, sustain: 0.4, release: 0.5 },
GRP_TXT: { type: 'sine', attack: 0.005, decay: 0.15, sustain: 0.1, release: 0.2 },
TXT_MSG: { type: 'triangle', attack: 0.01, decay: 0.2, sustain: 0.3, release: 0.4 },
TRACE: { type: 'sine', attack: 0.05, decay: 0.4, sustain: 0.5, release: 0.8 },
};
const DEFAULT_SYNTH = SYNTHS.ADVERT;
function play(audioCtx, masterGain, parsed, opts) {
const { payloadBytes, typeName, hopCount, obsCount, payload, hops } = parsed;
const tm = opts.tempoMultiplier;
const scale = SCALES[typeName] || DEFAULT_SCALE;
const synthConfig = SYNTHS[typeName] || DEFAULT_SYNTH;
// Sample sqrt(len) bytes evenly
const noteCount = Math.max(2, Math.min(10, Math.ceil(Math.sqrt(payloadBytes.length))));
const sampledBytes = [];
for (let i = 0; i < noteCount; i++) {
const idx = Math.floor((i / noteCount) * payloadBytes.length);
sampledBytes.push(payloadBytes[idx]);
}
// Pan from longitude
let panValue = 0;
if (payload.lat !== undefined && payload.lon !== undefined) {
panValue = Math.max(-1, Math.min(1, mapRange(payload.lon, -125, -65, -1, 1)));
} else if (hops.length > 0) {
panValue = (Math.random() - 0.5) * 0.6;
}
// Filter from hops
const filterFreq = mapRange(Math.min(hopCount, 10), 1, 10, 8000, 800);
// Volume from observations
const volume = Math.min(0.6, 0.15 + (obsCount - 1) * 0.02);
// More observers = richer chord: 1→1, 3→2, 8→3, 15→4, 30→5, 60→6
const voiceCount = Math.min(Math.max(1, Math.ceil(Math.log2(obsCount + 1))), 8);
// Audio chain: filter → limiter → panner → master
const filter = audioCtx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = filterFreq;
filter.Q.value = 1;
const limiter = audioCtx.createDynamicsCompressor();
limiter.threshold.value = -6;
limiter.knee.value = 6;
limiter.ratio.value = 12;
limiter.attack.value = 0.001;
limiter.release.value = 0.05;
const panner = audioCtx.createStereoPanner();
panner.pan.value = panValue;
filter.connect(limiter);
limiter.connect(panner);
panner.connect(masterGain);
let timeOffset = audioCtx.currentTime + 0.02; // small lookahead avoids scheduling on "now"
let lastNoteEnd = timeOffset;
for (let i = 0; i < sampledBytes.length; i++) {
const byte = sampledBytes[i];
const freq = midiToFreq(quantizeToScale(byte, scale));
const duration = mapRange(byte, 0, 255, 0.05, 0.4) * tm;
let gap = 0.05 * tm;
if (i < sampledBytes.length - 1) {
const delta = Math.abs(sampledBytes[i + 1] - byte);
gap = mapRange(delta, 0, 255, 0.03, 0.3) * tm;
}
const noteStart = timeOffset;
const noteEnd = noteStart + duration;
const { attack: a, decay: d, sustain: s, release: r } = synthConfig;
for (let v = 0; v < voiceCount; v++) {
const detune = v === 0 ? 0 : (v % 2 === 0 ? 1 : -1) * (v * 5 + 3);
const osc = audioCtx.createOscillator();
const envGain = audioCtx.createGain();
osc.type = synthConfig.type;
osc.frequency.value = freq;
osc.detune.value = detune;
const voiceVol = volume / voiceCount;
const sustainVol = Math.max(voiceVol * s, 0.0001);
// Envelope: start silent, ramp up, decay to sustain, hold, release to silence
// Use exponentialRamp throughout to avoid discontinuities
envGain.gain.setValueAtTime(0.0001, noteStart);
envGain.gain.exponentialRampToValueAtTime(Math.max(voiceVol, 0.0001), noteStart + a);
envGain.gain.exponentialRampToValueAtTime(sustainVol, noteStart + a + d);
// Hold sustain — cancelAndHoldAtTime not universal, so just let it ride
// Release: ramp down from wherever we are
envGain.gain.setTargetAtTime(0.0001, noteEnd, r / 5); // smooth exponential decay
osc.connect(envGain);
envGain.connect(filter);
osc.start(noteStart);
osc.stop(noteEnd + r + 0.1);
osc.onended = () => { osc.disconnect(); envGain.disconnect(); };
}
timeOffset = noteEnd + gap;
lastNoteEnd = noteEnd + (synthConfig.release || 0.2);
}
// Cleanup shared nodes
const cleanupMs = (lastNoteEnd - audioCtx.currentTime + 1) * 1000;
setTimeout(() => {
try { filter.disconnect(); limiter.disconnect(); panner.disconnect(); } catch (e) {}
}, cleanupMs);
return lastNoteEnd - audioCtx.currentTime;
}
MeshAudio.registerVoice('constellation', { name: 'constellation', play });
})();

214
public/audio.js Normal file
View File

@@ -0,0 +1,214 @@
// Mesh Audio Engine — public/audio.js
// Core audio infrastructure + swappable voice modules
// Each voice module is a separate file (audio-v1.js, audio-v2.js, etc.)
(function () {
'use strict';
// === Engine State ===
let audioEnabled = false;
let audioCtx = null;
let masterGain = null;
let bpm = 120;
let activeVoices = 0;
const MAX_VOICES = 12;
let currentVoice = null;
let _pendingVolume = 0.3; // active voice module
// === Shared Helpers (available to voice modules) ===
function buildScale(intervals, rootMidi) {
const notes = [];
for (let oct = 0; oct < 3; oct++) {
for (const interval of intervals) {
notes.push(rootMidi + oct * 12 + interval);
}
}
return notes;
}
function midiToFreq(midi) {
return 440 * Math.pow(2, (midi - 69) / 12);
}
function mapRange(value, inMin, inMax, outMin, outMax) {
return outMin + ((value - inMin) / (inMax - inMin)) * (outMax - outMin);
}
function quantizeToScale(byteVal, scale) {
const idx = Math.floor((byteVal / 256) * scale.length);
return scale[Math.min(idx, scale.length - 1)];
}
function tempoMultiplier() {
return 120 / bpm;
}
function parsePacketBytes(pkt) {
const rawHex = pkt.raw || pkt.raw_hex || (pkt.packet && pkt.packet.raw_hex) || '';
if (!rawHex || rawHex.length < 6) return null;
const allBytes = [];
for (let i = 0; i < rawHex.length; i += 2) {
const b = parseInt(rawHex.slice(i, i + 2), 16);
if (!isNaN(b)) allBytes.push(b);
}
if (allBytes.length < 3) return null;
const decoded = pkt.decoded || {};
const header = decoded.header || {};
const payload = decoded.payload || {};
const hops = decoded.path?.hops || [];
return {
allBytes,
headerBytes: allBytes.slice(0, 3),
payloadBytes: allBytes.slice(3),
typeName: header.payloadTypeName || 'UNKNOWN',
hopCount: Math.max(1, hops.length),
obsCount: pkt.observation_count || (pkt.packet && pkt.packet.observation_count) || 1,
payload,
hops,
};
}
// === Engine: Init ===
function initAudio() {
if (audioCtx) {
if (audioCtx.state === 'suspended') audioCtx.resume();
return;
}
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
masterGain = audioCtx.createGain();
masterGain.gain.value = _pendingVolume;
masterGain.connect(audioCtx.destination);
}
// === Engine: Sonify ===
function sonifyPacket(pkt) {
if (!audioEnabled || !currentVoice) return;
if (!audioCtx) initAudio();
if (!audioCtx) return;
if (audioCtx.state === 'suspended') {
// Show unlock overlay if not already showing
_showUnlockOverlay();
return; // don't schedule notes on suspended context
}
if (activeVoices >= MAX_VOICES) return;
const parsed = parsePacketBytes(pkt);
if (!parsed || parsed.payloadBytes.length === 0) return;
activeVoices++;
try {
const duration = currentVoice.play(audioCtx, masterGain, parsed, {
bpm, tempoMultiplier: tempoMultiplier(),
});
// Release voice slot after estimated duration
const releaseMs = (duration || 3) * 1000 + 500;
setTimeout(() => { activeVoices = Math.max(0, activeVoices - 1); }, releaseMs);
} catch (e) {
activeVoices = Math.max(0, activeVoices - 1);
console.error('[audio] voice error:', e);
}
}
// === Voice Registration ===
function registerVoice(name, voiceModule) {
// voiceModule must have: { name, play(audioCtx, masterGain, parsed, opts) → durationSec }
if (!window._meshAudioVoices) window._meshAudioVoices = {};
window._meshAudioVoices[name] = voiceModule;
// Auto-select first registered voice if none active
if (!currentVoice) currentVoice = voiceModule;
}
function setVoice(name) {
if (window._meshAudioVoices && window._meshAudioVoices[name]) {
currentVoice = window._meshAudioVoices[name];
localStorage.setItem('live-audio-voice', name);
return true;
}
return false;
}
function getVoiceName() {
return currentVoice ? currentVoice.name : null;
}
function getVoiceNames() {
return Object.keys(window._meshAudioVoices || {});
}
// === Public API ===
function setEnabled(on) {
audioEnabled = on;
if (on) initAudio();
localStorage.setItem('live-audio-enabled', on);
}
function isEnabled() { return audioEnabled; }
function setBPM(val) {
bpm = Math.max(40, Math.min(300, val));
localStorage.setItem('live-audio-bpm', bpm);
}
function getBPM() { return bpm; }
function setVolume(val) {
if (masterGain) masterGain.gain.value = Math.max(0, Math.min(1, val));
localStorage.setItem('live-audio-volume', val);
}
function getVolume() { return masterGain ? masterGain.gain.value : 0.3; }
function restore() {
const saved = localStorage.getItem('live-audio-enabled');
if (saved === 'true') audioEnabled = true;
const savedBpm = localStorage.getItem('live-audio-bpm');
if (savedBpm) bpm = parseInt(savedBpm, 10) || 120;
const savedVol = localStorage.getItem('live-audio-volume');
if (savedVol) _pendingVolume = parseFloat(savedVol) || 0.3;
const savedVoice = localStorage.getItem('live-audio-voice');
if (savedVoice) setVoice(savedVoice);
// If audio was enabled, create context eagerly. If browser suspends it,
// the unlock overlay will appear when the first packet arrives.
if (audioEnabled) {
initAudio();
}
}
let _overlayShown = false;
function _showUnlockOverlay() {
if (_overlayShown) return;
_overlayShown = true;
const overlay = document.createElement('div');
overlay.className = 'audio-unlock-overlay';
overlay.innerHTML = '<div class="audio-unlock-prompt">🔊 Tap to enable audio</div>';
overlay.addEventListener('click', () => {
if (audioCtx) audioCtx.resume();
overlay.remove();
}, { once: true });
document.body.appendChild(overlay);
}
// Export engine + helpers for voice modules
window.MeshAudio = {
sonifyPacket,
setEnabled, isEnabled,
setBPM, getBPM,
setVolume, getVolume,
registerVoice, setVoice, getVoiceName, getVoiceNames,
restore,
getContext() { return audioCtx; },
// Helpers for voice modules
helpers: { buildScale, midiToFreq, mapRange, quantizeToScale },
};
})();

121
public/hop-display.js Normal file
View File

@@ -0,0 +1,121 @@
/* === MeshCore Analyzer — hop-display.js === */
/* Shared hop rendering with conflict info for all pages */
'use strict';
window.HopDisplay = (function() {
function escapeHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Dismiss any open conflict popover
function dismissPopover() {
const old = document.querySelector('.hop-conflict-popover');
if (old) old.remove();
}
// Global click handler to dismiss popovers
let _listenerAttached = false;
function ensureGlobalListener() {
if (_listenerAttached) return;
_listenerAttached = true;
document.addEventListener('click', (e) => {
if (!e.target.closest('.hop-conflict-popover') && !e.target.closest('.hop-conflict-btn')) {
dismissPopover();
}
});
}
function showConflictPopover(btn, h, conflicts, globalFallback) {
dismissPopover();
ensureGlobalListener();
const regional = conflicts.filter(c => c.regional);
const shown = regional.length > 0 ? regional : conflicts;
let html = `<div class="hop-conflict-header">${escapeHtml(h)}${shown.length} candidate${shown.length > 1 ? 's' : ''}${regional.length > 0 ? ' in region' : ' (global fallback)'}</div>`;
html += '<div class="hop-conflict-list">';
for (const c of shown) {
const name = escapeHtml(c.name || c.pubkey?.slice(0, 16) || '?');
const dist = c.distKm != null ? `<span class="hop-conflict-dist">${c.distKm}km</span>` : '';
const pk = c.pubkey ? c.pubkey.slice(0, 12) + '…' : '';
html += `<a href="#/nodes/${encodeURIComponent(c.pubkey || '')}" class="hop-conflict-item">
<span class="hop-conflict-name">${name}</span>
${dist}
<span class="hop-conflict-pk">${pk}</span>
</a>`;
}
html += '</div>';
const popover = document.createElement('div');
popover.className = 'hop-conflict-popover';
popover.innerHTML = html;
document.body.appendChild(popover);
// Position near the button
const rect = btn.getBoundingClientRect();
popover.style.top = (rect.bottom + window.scrollY + 4) + 'px';
popover.style.left = Math.max(8, Math.min(rect.left + window.scrollX - 60, window.innerWidth - 280)) + 'px';
}
/**
* Render a hop prefix as HTML with conflict info.
*/
function renderHop(h, entry, opts) {
opts = opts || {};
if (!entry) entry = {};
if (typeof entry === 'string') entry = { name: entry };
const name = entry.name || null;
const pubkey = entry.pubkey || h;
const ambiguous = entry.ambiguous || false;
const conflicts = entry.conflicts || [];
const globalFallback = entry.globalFallback || false;
const unreliable = entry.unreliable || false;
const display = opts.hexMode ? h : (name ? escapeHtml(opts.truncate ? name.slice(0, opts.truncate) : name) : h);
// Simple title for the hop link itself
let title = h;
if (unreliable) title += ' — unreliable';
// Badge — only count regional conflicts
const regionalConflicts = conflicts.filter(c => c.regional);
const badgeCount = regionalConflicts.length > 0 ? regionalConflicts.length : (globalFallback ? conflicts.length : 0);
const conflictData = escapeHtml(JSON.stringify({ h, conflicts, globalFallback }));
const warnBadge = badgeCount > 1
? ` <button class="hop-conflict-btn" data-conflict='${conflictData}' onclick="event.preventDefault();event.stopPropagation();HopDisplay._showFromBtn(this)" title="${badgeCount} candidates — click for details">⚠${badgeCount}</button>`
: '';
const cls = [
'hop',
name ? 'hop-named' : '',
ambiguous ? 'hop-ambiguous' : '',
unreliable ? 'hop-unreliable' : '',
globalFallback ? 'hop-global-fallback' : '',
].filter(Boolean).join(' ');
if (opts.link !== false) {
return `<a class="${cls} hop-link" href="#/nodes/${encodeURIComponent(pubkey)}" title="${escapeHtml(title)}" data-hop-link="true">${display}</a>${warnBadge}`;
}
return `<span class="${cls}" title="${escapeHtml(title)}">${display}</span>${warnBadge}`;
}
/**
* Render a full path as HTML.
*/
function renderPath(hops, cache, opts) {
opts = opts || {};
const sep = opts.separator || ' → ';
if (!hops || !hops.length) return '—';
return hops.filter(Boolean).map(h => renderHop(h, cache[h], opts)).join(sep);
}
// Called from inline onclick
function _showFromBtn(btn) {
try {
const data = JSON.parse(btn.dataset.conflict);
showConflictPopover(btn, data.h, data.conflicts, data.globalFallback);
} catch (e) { console.error('Conflict popover error:', e); }
}
return { renderHop, renderPath, _showFromBtn };
})();

View File

@@ -6,18 +6,32 @@ window.HopResolver = (function() {
'use strict';
const MAX_HOP_DIST = 1.8; // ~200km in degrees
const REGION_RADIUS_KM = 300;
let prefixIdx = {}; // lowercase hex prefix → [node, ...]
let nodesList = [];
let observerIataMap = {}; // observer_id → iata
let iataCoords = {}; // iata → {lat, lon}
function dist(lat1, lon1, lat2, lon2) {
return Math.sqrt((lat1 - lat2) ** 2 + (lon1 - lon2) ** 2);
}
function haversineKm(lat1, lon1, lat2, lon2) {
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
/**
* Initialize (or rebuild) the prefix index from the full nodes list.
* @param {Array} nodes - Array of {public_key, name, lat, lon, ...}
* @param {Object} [opts] - Optional: { observers: [{id, iata}], iataCoords: {code: {lat,lon}} }
*/
function init(nodes) {
function init(nodes, opts) {
nodesList = nodes || [];
prefixIdx = {};
for (const n of nodesList) {
@@ -29,6 +43,28 @@ window.HopResolver = (function() {
prefixIdx[p].push(n);
}
}
// Store observer IATA mapping and coords if provided
observerIataMap = {};
if (opts && opts.observers) {
for (const o of opts.observers) {
if (o.id && o.iata) observerIataMap[o.id] = o.iata;
}
}
iataCoords = (opts && opts.iataCoords) || (window.IATA_COORDS_GEO) || {};
}
/**
* Check if a node is near an IATA region center.
* Returns { near, method, distKm } or null.
*/
function nodeInRegion(candidate, iata) {
const center = iataCoords[iata];
if (!center) return null;
if (candidate.lat && candidate.lon && !(candidate.lat === 0 && candidate.lon === 0)) {
const d = haversineKm(candidate.lat, candidate.lon, center.lat, center.lon);
return { near: d <= REGION_RADIUS_KM, method: 'geo', distKm: Math.round(d) };
}
return null; // no GPS — can't geo-filter client-side
}
/**
@@ -42,22 +78,51 @@ window.HopResolver = (function() {
* @param {number|null} observerLon - Observer longitude (backward anchor)
* @returns {Object} resolved map keyed by hop prefix
*/
function resolve(hops, originLat, originLon, observerLat, observerLon) {
function resolve(hops, originLat, originLon, observerLat, observerLon, observerId) {
if (!hops || !hops.length) return {};
// Determine observer's IATA for regional filtering
const packetIata = observerId ? observerIataMap[observerId] : null;
const resolved = {};
const hopPositions = {};
// First pass: find candidates
// First pass: find candidates with regional filtering
for (const hop of hops) {
const h = hop.toLowerCase();
const candidates = prefixIdx[h] || [];
if (candidates.length === 0) {
resolved[hop] = { name: null, candidates: [] };
} else if (candidates.length === 1) {
resolved[hop] = { name: candidates[0].name, pubkey: candidates[0].public_key, candidates: [{ name: candidates[0].name, pubkey: candidates[0].public_key }] };
const allCandidates = prefixIdx[h] || [];
if (allCandidates.length === 0) {
resolved[hop] = { name: null, candidates: [], conflicts: [] };
} else if (allCandidates.length === 1) {
const c = allCandidates[0];
const regionCheck = packetIata ? nodeInRegion(c, packetIata) : null;
resolved[hop] = { name: c.name, pubkey: c.public_key,
candidates: [{ name: c.name, pubkey: c.public_key, lat: c.lat, lon: c.lon, regional: regionCheck ? regionCheck.near : false, filterMethod: regionCheck ? regionCheck.method : 'none', distKm: regionCheck ? regionCheck.distKm : undefined }],
conflicts: [] };
} else {
resolved[hop] = { name: candidates[0].name, pubkey: candidates[0].public_key, ambiguous: true, candidates: candidates.map(c => ({ name: c.name, pubkey: c.public_key, lat: c.lat, lon: c.lon })) };
// Multiple candidates — apply geo regional filtering
const checked = allCandidates.map(c => {
const r = packetIata ? nodeInRegion(c, packetIata) : null;
return { ...c, regional: r ? r.near : false, filterMethod: r ? r.method : 'none', distKm: r ? r.distKm : undefined };
});
const regional = checked.filter(c => c.regional);
regional.sort((a, b) => (a.distKm || 9999) - (b.distKm || 9999));
const candidates = regional.length > 0 ? regional : checked;
const globalFallback = regional.length === 0 && checked.length > 0 && packetIata != null;
const conflicts = candidates.map(c => ({
name: c.name, pubkey: c.public_key, lat: c.lat, lon: c.lon,
regional: c.regional, filterMethod: c.filterMethod, distKm: c.distKm
}));
if (candidates.length === 1) {
resolved[hop] = { name: candidates[0].name, pubkey: candidates[0].public_key,
candidates: conflicts, conflicts, globalFallback };
} else {
resolved[hop] = { name: candidates[0].name, pubkey: candidates[0].public_key,
ambiguous: true, candidates: conflicts, conflicts, globalFallback,
hopBytes: Math.ceil(hop.length / 2), totalGlobal: allCandidates.length, totalRegional: regional.length };
}
}
}

View File

@@ -22,7 +22,7 @@
<meta name="twitter:title" content="MeshCore Analyzer">
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/meshcore-analyzer/master/public/og-image.png">
<link rel="stylesheet" href="style.css?v=1774138896">
<link rel="stylesheet" href="style.css?v=1774221932">
<link rel="stylesheet" href="home.css">
<link rel="stylesheet" href="live.css?v=1774058575">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
@@ -54,6 +54,7 @@
<a href="#/observers" class="nav-link" data-route="observers">Observers</a>
<a href="#/analytics" class="nav-link" data-route="analytics">Analytics</a>
<a href="#/perf" class="nav-link" data-route="perf">⚡ Perf</a>
<a href="#/audio-lab" class="nav-link" data-route="audio-lab">🎵 Lab</a>
</div>
</div>
<div class="nav-right">
@@ -79,20 +80,24 @@
<main id="app" role="main"></main>
<script src="vendor/qrcode.js"></script>
<script src="roles.js?v=1774290000"></script>
<script src="region-filter.js?v=1774136865"></script>
<script src="hop-resolver.js?v=1774126708"></script>
<script src="roles.js?v=1774325000"></script>
<script src="region-filter.js?v=1774325000"></script>
<script src="hop-resolver.js?v=1774223973"></script>
<script src="hop-display.js?v=1774221932"></script>
<script src="app.js?v=1774126708"></script>
<script src="home.js?v=1774042199"></script>
<script src="packets.js?v=1774155585"></script>
<script src="map.js?v=1774126708" onerror="console.error('Failed to load:', this.src)"></script>
<script src="packets.js?v=1774225004"></script>
<script src="map.js?v=1774220756" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774331200" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774126708" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774221131" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774135052" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774126708" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774155165" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774208460" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774207165" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1774208460" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774218049" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774290000" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774028201" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774219440" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774126708" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1773985649" onerror="console.error('Failed to load:', this.src)"></script>
</body>

View File

@@ -8,11 +8,12 @@
let activeAnims = 0;
let nodeActivity = {};
let recentPaths = [];
let audioCtx = null;
let soundEnabled = false;
let showGhostHops = localStorage.getItem('live-ghost-hops') !== 'false';
let realisticPropagation = localStorage.getItem('live-realistic-propagation') === 'true';
let showOnlyFavorites = localStorage.getItem('live-favorites-only') === 'true';
let matrixMode = localStorage.getItem('live-matrix-mode') === 'true';
let matrixRain = localStorage.getItem('live-matrix-rain') === 'true';
let rainCanvas = null, rainCtx = null, rainDrops = [], rainRAF = null;
const propagationBuffer = new Map(); // hash -> {timer, packets[]}
let _onResize = null;
let _navCleanup = null;
@@ -45,21 +46,6 @@
REQUEST: '❓', RESPONSE: '📨', TRACE: '🔍', PATH: '🛤️'
};
function playSound(typeName) {
if (!soundEnabled || !audioCtx) return;
try {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain); gain.connect(audioCtx.destination);
const freqs = { ADVERT: 880, GRP_TXT: 523, TXT_MSG: 659, ACK: 330, REQUEST: 740, TRACE: 987 };
osc.frequency.value = freqs[typeName] || 440;
osc.type = typeName === 'GRP_TXT' ? 'sine' : typeName === 'ADVERT' ? 'triangle' : 'square';
gain.gain.setValueAtTime(0.03, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.15);
osc.start(audioCtx.currentTime); osc.stop(audioCtx.currentTime + 0.15);
} catch {}
}
function initResizeHandler() {
let resizeTimer = null;
_onResize = function() {
@@ -419,6 +405,7 @@
const typeName = raw.type || pkt.payload_type_name || 'UNKNOWN';
return {
id: pkt.id, hash: pkt.hash,
raw: pkt.raw_hex,
_ts: new Date(pkt.timestamp || pkt.created_at).getTime(),
decoded: { header: { payloadTypeName: typeName }, payload: raw, path: { hops } },
snr: pkt.snr, rssi: pkt.rssi, observer: pkt.observer_name
@@ -623,7 +610,6 @@
<div class="live-stat-pill anim-pill"><span id="liveAnimCount">0</span> active</div>
<div class="live-stat-pill rate-pill"><span id="livePktRate">0</span>/min</div>
</div>
<button class="live-sound-btn" id="liveSoundBtn" title="Toggle sound">🔇</button>
<div class="live-toggles">
<label><input type="checkbox" id="liveHeatToggle" checked aria-describedby="heatDesc"> Heat</label>
<span id="heatDesc" class="sr-only">Overlay a density heat map on the mesh nodes</span>
@@ -631,9 +617,20 @@
<span id="ghostDesc" class="sr-only">Show interpolated ghost markers for unknown hops</span>
<label><input type="checkbox" id="liveRealisticToggle" aria-describedby="realisticDesc"> Realistic</label>
<span id="realisticDesc" class="sr-only">Buffer packets by hash and animate all paths simultaneously</span>
<label><input type="checkbox" id="liveMatrixToggle" aria-describedby="matrixDesc"> Matrix</label>
<span id="matrixDesc" class="sr-only">Animate packet hex bytes flowing along paths like the Matrix</span>
<label><input type="checkbox" id="liveMatrixRainToggle" aria-describedby="rainDesc"> Rain</label>
<span id="rainDesc" class="sr-only">Matrix rain overlay — packets fall as hex columns</span>
<label><input type="checkbox" id="liveAudioToggle" aria-describedby="audioDesc"> 🎵 Audio</label>
<span id="audioDesc" class="sr-only">Sonify packets — turn raw bytes into generative music</span>
<label><input type="checkbox" id="liveFavoritesToggle" aria-describedby="favDesc"> ⭐ Favorites</label>
<span id="favDesc" class="sr-only">Show only favorited and claimed nodes</span>
</div>
<div class="audio-controls hidden" id="audioControls">
<label class="audio-slider-label">Voice <select id="audioVoiceSelect" class="audio-voice-select"></select></label>
<label class="audio-slider-label">BPM <input type="range" id="audioBpmSlider" min="40" max="300" value="120" class="audio-slider"><span id="audioBpmVal">120</span></label>
<label class="audio-slider-label">Vol <input type="range" id="audioVolSlider" min="0" max="100" value="30" class="audio-slider"><span id="audioVolVal">30</span></label>
</div>
</div>
<div class="live-overlay live-feed" id="liveFeed">
<button class="feed-hide-btn" id="feedHideBtn" title="Hide feed">✕</button>
@@ -752,13 +749,6 @@
map.on('zoomend', rescaleMarkers);
// Sound toggle
document.getElementById('liveSoundBtn').addEventListener('click', () => {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
soundEnabled = !soundEnabled;
document.getElementById('liveSoundBtn').textContent = soundEnabled ? '🔊' : '🔇';
});
// Heat map toggle
document.getElementById('liveHeatToggle').addEventListener('change', (e) => {
if (e.target.checked) showHeatMap(); else hideHeatMap();
@@ -786,6 +776,83 @@
applyFavoritesFilter();
});
const matrixToggle = document.getElementById('liveMatrixToggle');
matrixToggle.checked = matrixMode;
matrixToggle.addEventListener('change', (e) => {
matrixMode = e.target.checked;
localStorage.setItem('live-matrix-mode', matrixMode);
applyMatrixTheme(matrixMode);
if (matrixMode) {
hideHeatMap();
const ht = document.getElementById('liveHeatToggle');
if (ht) { ht.checked = false; ht.disabled = true; }
} else {
const ht = document.getElementById('liveHeatToggle');
if (ht) { ht.disabled = false; }
}
});
applyMatrixTheme(matrixMode);
if (matrixMode) {
hideHeatMap();
const ht = document.getElementById('liveHeatToggle');
if (ht) { ht.checked = false; ht.disabled = true; }
}
const rainToggle = document.getElementById('liveMatrixRainToggle');
rainToggle.checked = matrixRain;
rainToggle.addEventListener('change', (e) => {
matrixRain = e.target.checked;
localStorage.setItem('live-matrix-rain', matrixRain);
if (matrixRain) startMatrixRain(); else stopMatrixRain();
});
if (matrixRain) startMatrixRain();
// Audio toggle
const audioToggle = document.getElementById('liveAudioToggle');
const audioControls = document.getElementById('audioControls');
const bpmSlider = document.getElementById('audioBpmSlider');
const bpmVal = document.getElementById('audioBpmVal');
const volSlider = document.getElementById('audioVolSlider');
const volVal = document.getElementById('audioVolVal');
if (window.MeshAudio) {
MeshAudio.restore();
audioToggle.checked = MeshAudio.isEnabled();
if (MeshAudio.isEnabled()) audioControls.classList.remove('hidden');
bpmSlider.value = MeshAudio.getBPM();
bpmVal.textContent = MeshAudio.getBPM();
volSlider.value = Math.round(MeshAudio.getVolume() * 100);
volVal.textContent = Math.round(MeshAudio.getVolume() * 100);
// Populate voice selector
const voiceSelect = document.getElementById('audioVoiceSelect');
const voices = MeshAudio.getVoiceNames();
voices.forEach(v => {
const opt = document.createElement('option');
opt.value = v; opt.textContent = v;
voiceSelect.appendChild(opt);
});
voiceSelect.value = MeshAudio.getVoiceName() || voices[0] || '';
voiceSelect.addEventListener('change', (e) => MeshAudio.setVoice(e.target.value));
}
audioToggle.addEventListener('change', (e) => {
if (window.MeshAudio) {
MeshAudio.setEnabled(e.target.checked);
audioControls.classList.toggle('hidden', !e.target.checked);
}
});
bpmSlider.addEventListener('input', (e) => {
const v = parseInt(e.target.value, 10);
bpmVal.textContent = v;
if (window.MeshAudio) MeshAudio.setBPM(v);
});
volSlider.addEventListener('input', (e) => {
const v = parseInt(e.target.value, 10);
volVal.textContent = v;
if (window.MeshAudio) MeshAudio.setVolume(v / 100);
});
// Feed show/hide
const feedEl = document.getElementById('liveFeed');
// Keyboard support for feed items (event delegation)
@@ -1102,9 +1169,10 @@
</table>`;
if (observers.length) {
html += `<h4 style="font-size:12px;margin:12px 0 6px;color:var(--text-muted);">Heard By</h4>
const regions = [...new Set(observers.map(o => o.iata).filter(Boolean))];
html += `<h4 style="font-size:12px;margin:12px 0 6px;color:var(--text-muted);">Heard By${regions.length ? ' — Regions: ' + regions.join(', ') : ''}</h4>
<div style="font-size:11px;">` +
observers.map(o => `<div style="padding:2px 0;"><a href="#/observers/${encodeURIComponent(o.observer_id)}" style="color:var(--accent);text-decoration:none;">${escapeHtml(o.observer_name || o.observer_id.slice(0, 12))}</a> — ${o.packetCount || o.count || 0} pkts</div>`).join('') +
observers.map(o => `<div style="padding:2px 0;"><a href="#/observers/${encodeURIComponent(o.observer_id)}" style="color:var(--accent);text-decoration:none;">${escapeHtml(o.observer_name || o.observer_id.slice(0, 12))}${o.iata ? ' (' + escapeHtml(o.iata) + ')' : ''}</a> — ${o.packetCount || o.count || 0} pkts</div>`).join('') +
'</div>';
}
@@ -1287,6 +1355,15 @@
marker._baseColor = color;
marker._baseSize = size;
nodeMarkers[n.public_key] = marker;
// Apply matrix tint if active
if (matrixMode) {
marker._matrixPrevColor = color;
marker._baseColor = '#008a22';
marker.setStyle({ fillColor: '#008a22', color: '#008a22', fillOpacity: 0.5, opacity: 0.5 });
glow.setStyle({ fillColor: '#008a22', fillOpacity: 0.15 });
}
return marker;
}
@@ -1345,8 +1422,16 @@
const hops = decoded.path?.hops || [];
const color = TYPE_COLORS[typeName] || '#6b7280';
playSound(typeName);
if (window.MeshAudio) MeshAudio.sonifyPacket(pkt);
addFeedItem(icon, typeName, payload, hops, color, pkt);
addRainDrop(pkt);
// Spawn extra rain columns for multiple observations with varied hop counts
const obsCount = pkt.observation_count || (pkt.packet && pkt.packet.observation_count) || 1;
const baseHops = (pkt.decoded?.path?.hops || []).length || 1;
for (let i = 1; i < obsCount; i++) {
const variedHops = Math.max(1, baseHops + Math.floor(Math.random() * 3) - 1); // ±1 hop
setTimeout(() => addRainDrop(pkt, variedHops), i * 150);
}
// Favorites filter: skip animation if packet doesn't involve a favorited node
if (showOnlyFavorites && !packetInvolvesFavorite(pkt)) return;
@@ -1365,7 +1450,7 @@
const hopPositions = resolveHopPositions(hops, payload);
if (hopPositions.length === 0) return;
if (hopPositions.length === 1) { pulseNode(hopPositions[0].key, hopPositions[0].pos, typeName); return; }
animatePath(hopPositions, typeName, color);
animatePath(hopPositions, typeName, color, pkt.raw);
}
function animateRealisticPropagation(packets) {
@@ -1385,7 +1470,13 @@
// Favorites filter: skip if none of the packets involve a favorite
if (showOnlyFavorites && !packets.some(p => packetInvolvesFavorite(p))) return;
playSound(typeName);
const consolidated = Object.assign({}, first, { observation_count: packets.length });
if (window.MeshAudio) MeshAudio.sonifyPacket(consolidated);
// Add single consolidated feed item for the group
const allHops = (decoded.path?.hops) || [];
addFeedItem(icon, typeName, payload, allHops, color, consolidated);
// Rain drop per observation in the group
packets.forEach((p, i) => setTimeout(() => addRainDrop(p), i * 150));
// Ensure ADVERT nodes appear
for (const pkt of packets) {
@@ -1452,7 +1543,7 @@
// Animate all paths simultaneously
for (const hopPositions of allPaths) {
animatePath(hopPositions, typeName, color);
animatePath(hopPositions, typeName, color, first.raw);
}
}
@@ -1554,7 +1645,8 @@
return raw.filter(h => h.pos != null);
}
function animatePath(hopPositions, typeName, color) {
function animatePath(hopPositions, typeName, color, rawHex) {
if (!animLayer || !pathsLayer) return;
activeAnims++;
document.getElementById('liveAnimCount').textContent = activeAnims;
let hopIndex = 0;
@@ -1590,7 +1682,7 @@
const nextGhost = hopPositions[hopIndex + 1].ghost;
const lineColor = (isGhost || nextGhost) ? '#94a3b8' : color;
const lineOpacity = (isGhost || nextGhost) ? 0.3 : undefined;
drawAnimatedLine(hp.pos, nextPos, lineColor, () => { hopIndex++; nextHop(); }, lineOpacity);
drawAnimatedLine(hp.pos, nextPos, lineColor, () => { hopIndex++; nextHop(); }, lineOpacity, rawHex);
} else {
if (!isGhost) pulseNode(hp.key, hp.pos, typeName);
hopIndex++; nextHop();
@@ -1600,6 +1692,7 @@
}
function pulseNode(key, pos, typeName) {
if (!animLayer || !nodesLayer) return;
if (!nodeMarkers[key]) {
const ghost = L.circleMarker(pos, {
radius: 5, fillColor: '#6b7280', fillOpacity: 0.3, color: '#fff', weight: 0.5, opacity: 0.2
@@ -1652,7 +1745,281 @@
nodeActivity[key] = (nodeActivity[key] || 0) + 1;
}
function drawAnimatedLine(from, to, color, onComplete, overrideOpacity) {
// === Matrix Rain System ===
function startMatrixRain() {
const container = document.getElementById('liveMap');
if (!container || rainCanvas) return;
rainCanvas = document.createElement('canvas');
rainCanvas.id = 'matrixRainCanvas';
rainCanvas.style.cssText = 'position:absolute;inset:0;z-index:9998;pointer-events:none;';
rainCanvas.width = container.clientWidth;
rainCanvas.height = container.clientHeight;
container.appendChild(rainCanvas);
rainCtx = rainCanvas.getContext('2d');
rainDrops = [];
// Resize handler
rainCanvas._resizeHandler = () => {
if (rainCanvas) {
rainCanvas.width = container.clientWidth;
rainCanvas.height = container.clientHeight;
}
};
window.addEventListener('resize', rainCanvas._resizeHandler);
function renderRain(now) {
if (!rainCanvas || !rainCtx) return;
const W = rainCanvas.width, H = rainCanvas.height;
rainCtx.clearRect(0, 0, W, H);
for (let i = rainDrops.length - 1; i >= 0; i--) {
const drop = rainDrops[i];
const elapsed = now - drop.startTime;
const progress = Math.min(1, elapsed / drop.duration);
// Head position
const headY = progress * drop.maxY;
// Trail shows all packet bytes, scrolling through them
const CHAR_H = 18;
const VISIBLE_CHARS = drop.bytes.length; // show all bytes
const trailPx = VISIBLE_CHARS * CHAR_H;
// Scroll offset — cycles through all bytes over the drop lifetime
const scrollOffset = Math.floor(progress * drop.bytes.length);
for (let c = 0; c < VISIBLE_CHARS; c++) {
const charY = headY - c * CHAR_H;
if (charY < -CHAR_H || charY > H) continue;
const byteIdx = (scrollOffset + c) % drop.bytes.length;
// Fade: head is bright, tail fades
const fadeFactor = 1 - (c / VISIBLE_CHARS);
// Also fade entire drop near end of life
const lifeFade = progress > 0.7 ? 1 - (progress - 0.7) / 0.3 : 1;
const alpha = Math.max(0, fadeFactor * lifeFade);
if (c === 0) {
rainCtx.font = 'bold 16px "Courier New", monospace';
rainCtx.fillStyle = `rgba(255, 255, 255, ${alpha})`;
rainCtx.shadowColor = '#00ff41';
rainCtx.shadowBlur = 12;
} else {
rainCtx.font = '14px "Courier New", monospace';
rainCtx.fillStyle = `rgba(0, 255, 65, ${alpha * 0.8})`;
rainCtx.shadowColor = '#00ff41';
rainCtx.shadowBlur = 4;
}
rainCtx.fillText(drop.bytes[byteIdx], drop.x, charY);
}
// Remove finished drops
if (progress >= 1) {
rainDrops.splice(i, 1);
}
}
rainCtx.shadowBlur = 0; // reset
rainRAF = requestAnimationFrame(renderRain);
}
rainRAF = requestAnimationFrame(renderRain);
}
function stopMatrixRain() {
if (rainRAF) { cancelAnimationFrame(rainRAF); rainRAF = null; }
if (rainCanvas) {
window.removeEventListener('resize', rainCanvas._resizeHandler);
rainCanvas.remove();
rainCanvas = null;
rainCtx = null;
}
rainDrops = [];
}
function addRainDrop(pkt, hopOverride) {
if (!rainCanvas || !matrixRain) return;
const rawHex = pkt.raw || pkt.raw_hex || (pkt.packet && pkt.packet.raw_hex) || '';
if (!rawHex) return;
const decoded = pkt.decoded || {};
const hops = decoded.path?.hops || [];
const hopCount = hopOverride || Math.max(1, hops.length);
const bytes = [];
for (let i = 0; i < rawHex.length; i += 2) {
bytes.push(rawHex.slice(i, i + 2).toUpperCase());
}
if (bytes.length === 0) return;
const W = rainCanvas.width;
const H = rainCanvas.height;
// Fall distance proportional to hops: 8+ hops = full height
const maxY = H * Math.min(1, hopCount / 4);
// Duration: 5s for full height, proportional for shorter
const duration = 5000 * (maxY / H);
// Random x position, avoid edges
const x = 20 + Math.random() * (W - 40);
rainDrops.push({
x,
maxY,
duration,
bytes,
hops: hopCount,
startTime: performance.now()
});
}
function applyMatrixTheme(on) {
const container = document.getElementById('liveMap');
if (!container) return;
if (on) {
// Force dark mode, save previous theme to restore later
const currentTheme = document.documentElement.getAttribute('data-theme');
if (currentTheme !== 'dark') {
container.dataset.matrixPrevTheme = currentTheme || 'light';
document.documentElement.setAttribute('data-theme', 'dark');
const dt = document.getElementById('darkModeToggle');
if (dt) { dt.textContent = '🌙'; dt.disabled = true; }
} else {
const dt = document.getElementById('darkModeToggle');
if (dt) dt.disabled = true;
}
container.classList.add('matrix-theme');
if (!document.getElementById('matrixScanlines')) {
const scanlines = document.createElement('div');
scanlines.id = 'matrixScanlines';
scanlines.className = 'matrix-scanlines';
container.appendChild(scanlines);
}
for (const [key, marker] of Object.entries(nodeMarkers)) {
marker._matrixPrevColor = marker._baseColor;
marker._baseColor = '#008a22';
marker.setStyle({ fillColor: '#008a22', color: '#008a22', fillOpacity: 0.5, opacity: 0.5 });
if (marker._glowMarker) marker._glowMarker.setStyle({ fillColor: '#008a22', fillOpacity: 0.15 });
}
} else {
container.classList.remove('matrix-theme');
const scanlines = document.getElementById('matrixScanlines');
if (scanlines) scanlines.remove();
// Restore previous theme
const prevTheme = container.dataset.matrixPrevTheme;
if (prevTheme) {
document.documentElement.setAttribute('data-theme', prevTheme);
localStorage.setItem('meshcore-theme', prevTheme);
const dt = document.getElementById('darkModeToggle');
if (dt) { dt.textContent = prevTheme === 'dark' ? '🌙' : '☀️'; dt.disabled = false; }
delete container.dataset.matrixPrevTheme;
} else {
const dt = document.getElementById('darkModeToggle');
if (dt) dt.disabled = false;
}
for (const [key, marker] of Object.entries(nodeMarkers)) {
if (marker._matrixPrevColor) {
marker._baseColor = marker._matrixPrevColor;
marker.setStyle({ fillColor: marker._matrixPrevColor, color: '#fff', fillOpacity: 0.85, opacity: 1 });
if (marker._glowMarker) marker._glowMarker.setStyle({ fillColor: marker._matrixPrevColor });
delete marker._matrixPrevColor;
}
}
}
}
function drawMatrixLine(from, to, color, onComplete, rawHex) {
if (!animLayer || !pathsLayer) { if (onComplete) onComplete(); return; }
const hexStr = rawHex || '';
const bytes = [];
for (let i = 0; i < hexStr.length; i += 2) {
bytes.push(hexStr.slice(i, i + 2).toUpperCase());
}
if (bytes.length === 0) {
for (let i = 0; i < 16; i++) bytes.push(((Math.random() * 256) | 0).toString(16).padStart(2, '0').toUpperCase());
}
const matrixGreen = '#00ff41';
const TRAIL_LEN = Math.min(6, bytes.length);
const DURATION_MS = 1100; // total hop duration
const CHAR_INTERVAL = 0.06; // spawn a char every 6% of progress
const charMarkers = [];
let nextCharAt = CHAR_INTERVAL;
let byteIdx = 0;
const trail = L.polyline([from], {
color: matrixGreen, weight: 1.5, opacity: 0.2, lineCap: 'round'
}).addTo(pathsLayer);
const trailCoords = [from];
const startTime = performance.now();
function tick(now) {
const elapsed = now - startTime;
const t = Math.min(1, elapsed / DURATION_MS);
const lat = from[0] + (to[0] - from[0]) * t;
const lon = from[1] + (to[1] - from[1]) * t;
trailCoords.push([lat, lon]);
trail.setLatLngs(trailCoords);
// Remove old chars beyond trail length
while (charMarkers.length > TRAIL_LEN) {
const old = charMarkers.shift();
try { animLayer.removeLayer(old.marker); } catch {}
}
// Fade existing chars
for (let i = 0; i < charMarkers.length; i++) {
const age = charMarkers.length - i;
const op = Math.max(0.15, 1 - (age / TRAIL_LEN) * 0.7);
const size = Math.max(10, 16 - age * 1.5);
const el = charMarkers[i].marker.getElement();
if (el) { el.style.opacity = op; el.style.fontSize = size + 'px'; }
}
// Spawn new char at intervals
if (t >= nextCharAt && t < 1) {
nextCharAt += CHAR_INTERVAL;
const charEl = L.marker([lat, lon], {
icon: L.divIcon({
className: 'matrix-char',
html: `<span style="color:#fff;font-family:'Courier New',monospace;font-size:16px;font-weight:bold;text-shadow:0 0 8px ${matrixGreen},0 0 16px ${matrixGreen},0 0 24px ${matrixGreen}60;pointer-events:none">${bytes[byteIdx % bytes.length]}</span>`,
iconSize: [24, 18],
iconAnchor: [12, 9]
}),
interactive: false
}).addTo(animLayer);
charMarkers.push({ marker: charEl });
byteIdx++;
}
if (t < 1) {
requestAnimationFrame(tick);
} else {
// Fade out
const fadeStart = performance.now();
function fadeOut(now) {
const ft = Math.min(1, (now - fadeStart) / 300);
if (ft >= 1) {
for (const cm of charMarkers) try { animLayer.removeLayer(cm.marker); } catch {}
try { pathsLayer.removeLayer(trail); } catch {}
charMarkers.length = 0;
} else {
const op = 1 - ft;
for (const cm of charMarkers) {
const el = cm.marker.getElement(); if (el) el.style.opacity = op * 0.5;
}
trail.setStyle({ opacity: op * 0.15 });
requestAnimationFrame(fadeOut);
}
}
setTimeout(() => requestAnimationFrame(fadeOut), 150);
if (onComplete) onComplete();
}
}
requestAnimationFrame(tick);
}
function drawAnimatedLine(from, to, color, onComplete, overrideOpacity, rawHex) {
if (!animLayer || !pathsLayer) { if (onComplete) onComplete(); return; }
if (matrixMode) return drawMatrixLine(from, to, color, onComplete, rawHex);
const steps = 20;
const latStep = (to[0] - from[0]) / steps;
const lonStep = (to[1] - from[1]) / steps;
@@ -1862,6 +2229,7 @@
_navCleanup = null;
}
nodesLayer = pathsLayer = animLayer = heatLayer = null;
stopMatrixRain();
nodeMarkers = {}; nodeData = {};
recentPaths = [];
packetCount = 0; activeAnims = 0;

View File

@@ -7,6 +7,7 @@
let markerLayer = null;
let clusterGroup = null;
let nodes = [];
let targetNodeKey = null;
let observers = [];
let filters = { repeater: true, companion: true, room: true, sensor: true, observer: true, lastHeard: '30d', neighbors: false, clusters: false, hashLabels: localStorage.getItem('meshcore-map-hash-labels') !== 'false' };
let wsHandler = null;
@@ -126,12 +127,22 @@
} catch {}
let initCenter = defaultCenter;
let initZoom = defaultZoom;
const savedView = localStorage.getItem('map-view');
if (savedView) {
try { const v = JSON.parse(savedView); initCenter = [v.lat, v.lng]; initZoom = v.zoom; } catch {}
// Check URL query params first (from packet detail links)
const urlParams = new URLSearchParams(location.hash.split('?')[1] || '');
if (urlParams.get('lat') && urlParams.get('lon')) {
initCenter = [parseFloat(urlParams.get('lat')), parseFloat(urlParams.get('lon'))];
initZoom = parseInt(urlParams.get('zoom')) || 12;
} else {
const savedView = localStorage.getItem('map-view');
if (savedView) {
try { const v = JSON.parse(savedView); initCenter = [v.lat, v.lng]; initZoom = v.zoom; } catch {}
}
}
map = L.map('leaflet-map', { zoomControl: true }).setView(initCenter, initZoom);
// If navigated with ?node=PUBKEY, highlight that node after markers load
targetNodeKey = urlParams.get('node') || null;
const isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const tileLayer = L.tileLayer(isDark ? TILE_DARK : TILE_LIGHT, {
@@ -368,6 +379,27 @@
buildJumpButtons();
renderMarkers();
// If navigated with ?node=PUBKEY, center on and highlight that node
if (targetNodeKey) {
const targetNode = nodes.find(n => n.public_key === targetNodeKey);
if (targetNode && targetNode.lat && targetNode.lon) {
map.setView([targetNode.lat, targetNode.lon], 14);
// Delay popup open slightly — Leaflet needs the map to settle after setView
setTimeout(() => {
let found = false;
markerLayer.eachLayer(m => {
if (found) return;
if (m._nodeKey === targetNodeKey && m.openPopup) {
m.openPopup();
found = true;
}
});
if (!found) console.warn('[map] Target node marker not found:', targetNodeKey);
}, 500);
}
}
// Don't fitBounds on initial load — respect the Bay Area default or saved view
// Only fitBounds on subsequent data refreshes if user hasn't manually panned
} catch (e) {
@@ -537,6 +569,7 @@
for (const m of allMarkers) {
const pos = m.adjustedLatLng || m.latLng;
const marker = L.marker(pos, { icon: m.icon, alt: m.alt });
marker._nodeKey = m.node.public_key || m.node.id || null;
marker.bindPopup(m.popupFn(), { maxWidth: 280 });
markerLayer.addLayer(marker);

View File

@@ -143,12 +143,14 @@
</div>
${observers.length ? `<div class="node-full-card">
${(() => { const regions = [...new Set(observers.map(o => o.iata).filter(Boolean))]; return regions.length ? `<div style="margin-bottom:8px"><strong>Regions:</strong> ${regions.map(r => '<span class="badge" style="margin:0 2px">' + escapeHtml(r) + '</span>').join(' ')}</div>` : ''; })()}
<h4>Heard By (${observers.length} observer${observers.length > 1 ? 's' : ''})</h4>
<table class="data-table" style="font-size:12px">
<thead><tr><th>Observer</th><th>Packets</th><th>Avg SNR</th><th>Avg RSSI</th></tr></thead>
<thead><tr><th>Observer</th><th>Region</th><th>Packets</th><th>Avg SNR</th><th>Avg RSSI</th></tr></thead>
<tbody>
${observers.map(o => `<tr>
<td style="font-weight:600">${escapeHtml(o.observer_name || o.observer_id)}</td>
<td>${o.iata ? escapeHtml(o.iata) : '—'}</td>
<td>${o.packetCount}</td>
<td>${o.avgSnr != null ? o.avgSnr.toFixed(1) + ' dB' : '—'}</td>
<td>${o.avgRssi != null ? o.avgRssi.toFixed(0) + ' dBm' : '—'}</td>
@@ -233,6 +235,11 @@
return paths.map(p => {
const chain = p.hops.map(h => {
const isThis = h.pubkey === n.public_key;
if (window.HopDisplay) {
const entry = { name: h.name, pubkey: h.pubkey, ambiguous: h.ambiguous, conflicts: h.conflicts, totalGlobal: h.totalGlobal, totalRegional: h.totalRegional, globalFallback: h.globalFallback, unreliable: h.unreliable };
const html = HopDisplay.renderHop(h.prefix, entry);
return isThis ? html.replace('class="', 'class="hop-current ') : html;
}
const name = escapeHtml(h.name || h.prefix);
const link = h.pubkey ? `<a href="#/nodes/${encodeURIComponent(h.pubkey)}" style="${isThis ? 'font-weight:700;color:var(--accent, #3b82f6)' : ''}">${name}</a>` : `<span>${name}</span>`;
return link;
@@ -518,10 +525,11 @@
</div>
${observers.length ? `<div class="node-detail-section">
${(() => { const regions = [...new Set(observers.map(o => o.iata).filter(Boolean))]; return regions.length ? `<div style="margin-bottom:6px;font-size:12px"><strong>Regions:</strong> ${regions.join(', ')}</div>` : ''; })()}
<h4>Heard By (${observers.length} observer${observers.length > 1 ? 's' : ''})</h4>
<div class="observer-list">
${observers.map(o => `<div class="observer-row" style="display:flex;justify-content:space-between;align-items:center;padding:4px 0;border-bottom:1px solid var(--border);font-size:12px">
<span style="font-weight:600">${escapeHtml(o.observer_name || o.observer_id)}</span>
<span style="font-weight:600">${escapeHtml(o.observer_name || o.observer_id)}${o.iata ? ' <span class="badge" style="font-size:10px">' + escapeHtml(o.iata) + '</span>' : ''}</span>
<span style="color:var(--text-muted)">${o.packetCount} pkts · ${o.avgSnr != null ? 'SNR ' + o.avgSnr.toFixed(1) + 'dB' : ''}${o.avgRssi != null ? ' · RSSI ' + o.avgRssi.toFixed(0) : ''}</span>
</div>`).join('')}
</div>

View File

@@ -304,7 +304,7 @@
const decoded = typeof p.decoded_json === 'string' ? JSON.parse(p.decoded_json) : (p.decoded_json || {});
const hops = typeof p.path_json === 'string' ? JSON.parse(p.path_json) : (p.path_json || []);
const typeName = PAYLOAD_LABELS[p.payload_type] || 'Type ' + p.payload_type;
return `<tr style="cursor:pointer" onclick="location.hash='#/packet/${p.id}'">
return `<tr style="cursor:pointer" onclick="location.hash='#/packets/${p.hash || p.id}'">
<td>${timeAgo(p.timestamp)}</td>
<td>${typeName}</td>
<td class="mono" style="font-size:0.85em">${(p.hash || '').substring(0, 10)}</td>

View File

@@ -3,12 +3,14 @@
(function () {
let packets = [];
let hashIndex = new Map(); // hash → packet group for O(1) dedup
// Resolve observer_id to friendly name from loaded observers list
function obsName(id) {
if (!id) return '—';
const o = observers.find(ob => ob.id === id);
return o?.name || id;
if (!o) return id;
return o.iata ? `${o.name} (${o.iata})` : o.name;
}
let selectedId = null;
let groupByHash = true;
@@ -98,12 +100,19 @@
}, { passive: false });
}
// Ensure HopResolver is initialized with the nodes list
// Ensure HopResolver is initialized with the nodes list + observer IATA data
async function ensureHopResolver() {
if (!HopResolver.ready()) {
try {
const data = await api('/nodes?limit=2000', { ttl: 60000 });
HopResolver.init(data.nodes || []);
const [nodeData, obsData, coordData] = await Promise.all([
api('/nodes?limit=2000', { ttl: 60000 }),
api('/observers', { ttl: 60000 }),
api('/iata-coords', { ttl: 300000 }).catch(() => ({ coords: {} })),
]);
HopResolver.init(nodeData.nodes || [], {
observers: obsData.observers || obsData || [],
iataCoords: coordData.coords || {},
});
} catch {}
}
}
@@ -120,24 +129,16 @@
}
}
function renderHop(h) {
if (showHexHashes) {
return `<span class="hop">${escapeHtml(h)}</span>`;
}
const entry = hopNameCache[h];
const name = entry ? (typeof entry === 'string' ? entry : entry.name) : null;
const pubkey = entry?.pubkey || h;
const ambiguous = entry?.ambiguous || false;
const display = name ? escapeHtml(name) : h;
const title = ambiguous
? `${h} — ⚠ ${entry.candidates.length} matches: ${entry.candidates.map(c => c.name).join(', ')}`
: h;
return `<a class="hop hop-link ${name ? 'hop-named' : ''} ${ambiguous ? 'hop-ambiguous' : ''}" href="#/nodes/${encodeURIComponent(pubkey)}" title="${title}" data-hop-link="true">${display}${ambiguous ? '<span class="hop-warn">⚠</span>' : ''}</a>`;
function renderHop(h, observerId) {
// Use per-packet cache key if observer context available (ambiguous hops differ by region)
const cacheKey = observerId ? h + ':' + observerId : h;
const entry = hopNameCache[cacheKey] || hopNameCache[h];
return HopDisplay.renderHop(h, entry, { hexMode: showHexHashes });
}
function renderPath(hops) {
function renderPath(hops, observerId) {
if (!hops || !hops.length) return '—';
return hops.map(renderHop).join('<span class="arrow">→</span>');
return hops.map(h => renderHop(h, observerId)).join('<span class="arrow">→</span>');
}
let directPacketId = null;
@@ -255,7 +256,7 @@
const newHops = hops.filter(h => !(h in hopNameCache));
if (newHops.length) await resolveHops(newHops);
} catch {}
renderDetail(content, data);
await renderDetail(content, data);
initPanelResize();
}
} catch {}
@@ -297,7 +298,7 @@
// Update existing groups or create new ones
for (const p of filtered) {
const h = p.hash;
const existing = packets.find(g => g.hash === h);
const existing = hashIndex.get(h);
if (existing) {
existing.count = (existing.count || 1) + 1;
existing.observation_count = (existing.observation_count || 1) + 1;
@@ -316,7 +317,7 @@
}
} else {
// New group
packets.unshift({
const newGroup = {
hash: h,
count: 1,
observer_count: 1,
@@ -327,7 +328,9 @@
payload_type: p.payload_type,
raw_hex: p.raw_hex,
decoded_json: p.decoded_json,
});
};
packets.unshift(newGroup);
if (h) hashIndex.set(h, newGroup);
}
}
// Re-sort by latest DESC, cap size
@@ -347,7 +350,7 @@
if (wsHandler) offWS(wsHandler);
wsHandler = null;
packets = [];
selectedId = null;
hashIndex = new Map(); selectedId = null;
filtersBuilt = false;
delete filters.node;
expandedHashes = new Set();
@@ -372,7 +375,7 @@
try {
const params = new URLSearchParams();
const windowMin = Number(document.getElementById('fTimeWindow')?.value || 15);
if (windowMin > 0) {
if (windowMin > 0 && !filters.hash) {
const since = new Date(Date.now() - windowMin * 60000).toISOString();
params.set('since', since);
}
@@ -385,6 +388,8 @@
const data = await api('/packets?' + params.toString());
packets = data.packets || [];
hashIndex = new Map();
for (const p of packets) { if (p.hash) hashIndex.set(p.hash, p); }
totalCount = data.total || packets.length;
// When ungrouped, fetch observations for all multi-obs packets and flatten
@@ -416,6 +421,32 @@
}
if (allHops.size) await resolveHops([...allHops]);
// Per-observer batch resolve for ambiguous hops (context-aware disambiguation)
const hopsByObserver = {};
for (const p of packets) {
if (!p.observer_id) continue;
try {
const path = JSON.parse(p.path_json || '[]');
const ambiguous = path.filter(h => hopNameCache[h]?.ambiguous);
if (ambiguous.length) {
if (!hopsByObserver[p.observer_id]) hopsByObserver[p.observer_id] = new Set();
ambiguous.forEach(h => hopsByObserver[p.observer_id].add(h));
}
} catch {}
}
// Batch resolve — one API call per observer (typically 4-5 observers)
await Promise.all(Object.entries(hopsByObserver).map(async ([obsId, hopsSet]) => {
try {
const params = new URLSearchParams({ hops: [...hopsSet].join(','), observer: obsId });
const result = await api(`/resolve-hops?${params}`);
if (result?.resolved) {
for (const [k, v] of Object.entries(result.resolved)) {
hopNameCache[k + ':' + obsId] = v;
}
}
} catch {}
}));
// Restore expanded group children
if (groupByHash && expandedHashes.size > 0) {
for (const hash of expandedHashes) {
@@ -925,7 +956,7 @@
const groupRegion = headerObserverId ? (observers.find(o => o.id === headerObserverId)?.iata || '') : '';
let groupPath = [];
try { groupPath = JSON.parse(headerPathJson || '[]'); } catch {}
const groupPathStr = renderPath(groupPath);
const groupPathStr = renderPath(groupPath, headerObserverId);
const groupTypeName = payloadTypeName(p.payload_type);
const groupTypeClass = payloadTypeColor(p.payload_type);
const groupSize = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0;
@@ -957,7 +988,7 @@
const childRegion = c.observer_id ? (observers.find(o => o.id === c.observer_id)?.iata || '') : '';
let childPath = [];
try { childPath = JSON.parse(c.path_json || '[]'); } catch {}
const childPathStr = renderPath(childPath);
const childPathStr = renderPath(childPath, child.observer_id);
html += `<tr class="group-child" data-id="${c.id}" data-hash="${c.hash || ''}" data-action="select-observation" data-value="${c.id}" data-parent-hash="${p.hash}" tabindex="0" role="row">
<td></td><td class="col-region">${childRegion ? `<span class="badge-region">${childRegion}</span>` : '—'}</td>
<td class="col-time">${timeAgo(c.timestamp)}</td>
@@ -985,8 +1016,7 @@
const typeName = payloadTypeName(p.payload_type);
const typeClass = payloadTypeColor(p.payload_type);
const size = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0;
const pathStr = renderPath(pathHops);
const detail = getDetailPreview(decoded);
const pathStr = renderPath(pathHops, p.observer_id); const detail = getDetailPreview(decoded);
return `<tr data-id="${p.id}" data-hash="${p.hash || ''}" data-action="select-hash" data-value="${p.hash || p.id}" tabindex="0" role="row" class="${selectedId === p.id ? 'selected' : ''}">
<td></td><td class="col-region">${region ? `<span class="badge-region">${region}</span>` : '—'}</td>
@@ -1082,14 +1112,14 @@
panel.innerHTML = isMobileNow ? '' : '<div class="panel-resize-handle" id="pktResizeHandle"></div>';
const content = document.createElement('div');
panel.appendChild(content);
renderDetail(content, data);
await renderDetail(content, data);
if (!isMobileNow) initPanelResize();
} catch (e) {
panel.innerHTML = `<div class="text-muted">Error: ${e.message}</div>`;
}
}
function renderDetail(panel, data) {
async function renderDetail(panel, data) {
const pkt = data.packet;
const breakdown = data.breakdown || {};
const ranges = breakdown.ranges || [];
@@ -1098,6 +1128,44 @@
let pathHops;
try { pathHops = JSON.parse(pkt.path_json || '[]'); } catch { pathHops = []; }
// Resolve sender GPS — from packet directly, or from known node in DB
let senderLat = decoded.lat != null ? decoded.lat : (decoded.latitude || null);
let senderLon = decoded.lon != null ? decoded.lon : (decoded.longitude || null);
if (senderLat == null) {
// Try to find sender node GPS from DB
const senderKey = decoded.pubKey || decoded.srcPubKey;
const senderName = decoded.sender || decoded.name;
try {
if (senderKey) {
const nd = await api(`/nodes/${senderKey}`, { ttl: 30000 }).catch(() => null);
if (nd?.node?.lat && nd.node.lon) { senderLat = nd.node.lat; senderLon = nd.node.lon; }
}
if (senderLat == null && senderName) {
const sd = await api(`/nodes/search?q=${encodeURIComponent(senderName)}`, { ttl: 30000 }).catch(() => null);
const match = sd?.nodes?.[0];
if (match?.lat && match.lon) { senderLat = match.lat; senderLon = match.lon; }
}
} catch {}
}
// Re-resolve hops using SERVER-SIDE API with sender GPS + observer
if (pathHops.length) {
try {
const params = new URLSearchParams({ hops: pathHops.join(',') });
if (pkt.observer_id) params.set('observer', pkt.observer_id);
if (senderLat != null) params.set('originLat', senderLat);
if (senderLon != null) params.set('originLon', senderLon);
const serverResolved = await api(`/resolve-hops?${params}`);
if (serverResolved?.resolved) {
for (const [k, v] of Object.entries(serverResolved.resolved)) {
hopNameCache[k] = v;
// Also store observer-scoped key for list view
if (pkt.observer_id) hopNameCache[k + ':' + pkt.observer_id] = v;
}
}
} catch {}
}
// Parse hash size from path byte
const rawPathByte = pkt.raw_hex ? parseInt(pkt.raw_hex.slice(2, 4), 16) : NaN;
const hashSize = isNaN(rawPathByte) ? null : ((rawPathByte >> 6) + 1);
@@ -1145,19 +1213,56 @@
}
}
// Location: from ADVERT lat/lon, or from known node via pubkey/sender name
let locationHtml = '—';
let locationNodeKey = null;
if (decoded.lat != null && decoded.lon != null && !(decoded.lat === 0 && decoded.lon === 0)) {
locationNodeKey = decoded.pubKey || decoded.srcPubKey || '';
const nodeName = decoded.name || '';
locationHtml = `${decoded.lat.toFixed(5)}, ${decoded.lon.toFixed(5)}`;
if (nodeName) locationHtml = `${escapeHtml(nodeName)}${locationHtml}`;
if (locationNodeKey) locationHtml += ` <a href="#/map?node=${encodeURIComponent(locationNodeKey)}" style="font-size:0.85em">📍map</a>`;
} else {
// Try to resolve sender node location from nodes list
const senderKey = decoded.pubKey || decoded.srcPubKey;
const senderName = decoded.sender || decoded.name;
if (senderKey || senderName) {
try {
const nodeData = senderKey ? await api(`/nodes/${senderKey}`, { ttl: 30000 }).catch(() => null) : null;
if (nodeData && nodeData.node && nodeData.node.lat && nodeData.node.lon) {
locationNodeKey = nodeData.node.public_key;
locationHtml = `${nodeData.node.lat.toFixed(5)}, ${nodeData.node.lon.toFixed(5)}`;
if (nodeData.node.name) locationHtml = `${escapeHtml(nodeData.node.name)}${locationHtml}`;
locationHtml += ` <a href="#/map?node=${encodeURIComponent(locationNodeKey)}" style="font-size:0.85em">📍map</a>`;
} else if (senderName && !senderKey) {
// Search by name
const searchData = await api(`/nodes/search?q=${encodeURIComponent(senderName)}`, { ttl: 30000 }).catch(() => null);
const match = searchData && searchData.nodes && searchData.nodes[0];
if (match && match.lat && match.lon) {
locationNodeKey = match.public_key;
locationHtml = `${match.lat.toFixed(5)}, ${match.lon.toFixed(5)}`;
locationHtml = `${escapeHtml(match.name)}${locationHtml}`;
locationHtml += ` <a href="#/map?node=${encodeURIComponent(locationNodeKey)}" style="font-size:0.85em">📍map</a>`;
}
}
} catch {}
}
}
panel.innerHTML = `
<div class="detail-title">${hasRawHex ? `Packet Byte Breakdown (${size} bytes)` : typeName + ' Packet'}</div>
<div class="detail-hash">${pkt.hash || 'Packet #' + pkt.id}</div>
${messageHtml}
<dl class="detail-meta">
<dt>Observer</dt><dd>${obsName(pkt.observer_id)}</dd>
<dt>Location</dt><dd>${locationHtml}</dd>
<dt>SNR / RSSI</dt><dd>${snr != null ? snr + ' dB' : '—'} / ${rssi != null ? rssi + ' dBm' : '—'}</dd>
<dt>Route Type</dt><dd>${routeTypeName(pkt.route_type)}</dd>
<dt>Payload Type</dt><dd><span class="badge badge-${payloadTypeColor(pkt.payload_type)}">${typeName}</span></dd>
${hashSize ? `<dt>Hash Size</dt><dd>${hashSize} byte${hashSize !== 1 ? 's' : ''}</dd>` : ''}
<dt>Timestamp</dt><dd>${pkt.timestamp}</dd>
<dt>Propagation</dt><dd>${propagationHtml}</dd>
<dt>Path</dt><dd>${pathHops.length ? renderPath(pathHops) : '—'}</dd>
<dt>Path</dt><dd>${pathHops.length ? renderPath(pathHops, pkt.observer_id) : '—'}</dd>
</dl>
<div class="detail-actions">
<button class="copy-link-btn" data-packet-hash="${pkt.hash || ''}" data-packet-id="${pkt.id}" title="Copy link to this packet">🔗 Copy Link</button>
@@ -1202,7 +1307,7 @@
let oDec;
try { oDec = JSON.parse(o.decoded_json || '{}'); } catch { oDec = decoded; }
replayPackets.push({
id: o.id, hash: pkt.hash,
id: o.id, hash: pkt.hash, raw: o.raw_hex || pkt.raw_hex,
_ts: new Date(o.timestamp).getTime(),
decoded: { header: { payloadTypeName: typeName }, payload: oDec, path: { hops: oPath } },
snr: o.snr, rssi: o.rssi, observer: obsName(o.observer_id)
@@ -1210,7 +1315,7 @@
}
} else {
replayPackets.push({
id: pkt.id, hash: pkt.hash,
id: pkt.id, hash: pkt.hash, raw: pkt.raw_hex,
_ts: new Date(pkt.timestamp).getTime(),
decoded: { header: { payloadTypeName: typeName }, payload: decoded, path: { hops: pathHops } },
snr: pkt.snr, rssi: pkt.rssi, observer: obsName(pkt.observer_id)
@@ -1236,7 +1341,7 @@
// Try to find observer in nodes list by name — best effort
}
await ensureHopResolver();
const data = { resolved: HopResolver.resolve(pathHops, senderLat || null, senderLon || null, obsLat, obsLon) };
const data = { resolved: HopResolver.resolve(pathHops, senderLat || null, senderLon || null, obsLat, obsLon, pkt.observer_id) };
// Pass full pubkeys (client-disambiguated) to map, falling back to short prefix
const resolvedKeys = pathHops.map(h => {
const r = data.resolved?.[h];
@@ -1297,13 +1402,8 @@
const pathByte = parseInt(buf.slice(2, 4), 16);
const hashSize = (pathByte >> 6) + 1;
for (let i = 0; i < pathHops.length; i++) {
const hopEntry = hopNameCache[pathHops[i]];
const hopName = hopEntry ? (typeof hopEntry === 'string' ? hopEntry : hopEntry.name) : null;
const hopPubkey = hopEntry?.pubkey || pathHops[i];
const nameHtml = hopName
? `<a href="#/nodes/${encodeURIComponent(hopPubkey)}" class="hop-link hop-named" data-hop-link="true">${escapeHtml(hopName)}</a>${hopEntry?.ambiguous ? ' ⚠' : ''}`
: '';
const label = hopName ? `Hop ${i}${nameHtml}` : `Hop ${i}`;
const hopHtml = HopDisplay.renderHop(pathHops[i], hopNameCache[pathHops[i]]);
const label = `Hop ${i}${hopHtml}`;
rows += fieldRow(off + i * hashSize, label, pathHops[i], '');
}
off += hashSize * pathHops.length;
@@ -1603,6 +1703,7 @@
const param = routeParam;
app.innerHTML = `<div style="max-width:800px;margin:0 auto;padding:20px"><div class="text-center text-muted" style="padding:40px">Loading packet…</div></div>`;
try {
await loadObservers();
const data = await api(`/packets/${param}`);
if (!data?.packet) { app.innerHTML = `<div style="max-width:800px;margin:0 auto;padding:40px;text-align:center"><h2>Packet not found</h2><p>Packet ${param} doesn't exist.</p><a href="#/packets">← Back to packets</a></div>`; return; }
const hops = [];
@@ -1614,7 +1715,7 @@
container.innerHTML = `<div style="margin-bottom:16px"><a href="#/packets" style="color:var(--primary);text-decoration:none">← Back to packets</a></div>`;
const detail = document.createElement('div');
container.appendChild(detail);
renderDetail(detail, data);
await renderDetail(detail, data);
app.innerHTML = '';
app.appendChild(container);
} catch (e) {

View File

@@ -127,7 +127,9 @@
html += '<label class="region-dropdown-item"><input type="checkbox" data-region="__all__"' +
(allSelected ? ' checked' : '') + '> <strong>All</strong></label>';
codes.forEach(function (code) {
var label = _regions[code] ? (code + ' - ' + _regions[code]) : code;
var configLabel = _regions[code];
var cityName = configLabel || (window.IATA_CITIES && window.IATA_CITIES[code]);
var label = cityName ? (code + ' - ' + cityName) : code;
var active = allSelected || (_selected && _selected.has(code));
html += '<label class="region-dropdown-item"><input type="checkbox" data-region="' + code + '"' +
(active ? ' checked' : '') + '> ' + label + '</label>';

View File

@@ -128,4 +128,179 @@
if (ROLE_COLORS[role]) ROLE_STYLE[role].color = ROLE_COLORS[role];
}
}).catch(function () { /* use defaults */ });
// ─── Built-in IATA airport code → city name mapping ───
window.IATA_CITIES = {
// United States
'SEA': 'Seattle, WA',
'SFO': 'San Francisco, CA',
'PDX': 'Portland, OR',
'LAX': 'Los Angeles, CA',
'DEN': 'Denver, CO',
'SLC': 'Salt Lake City, UT',
'PHX': 'Phoenix, AZ',
'DFW': 'Dallas, TX',
'ATL': 'Atlanta, GA',
'ORD': 'Chicago, IL',
'JFK': 'New York, NY',
'LGA': 'New York, NY',
'BOS': 'Boston, MA',
'MIA': 'Miami, FL',
'FLL': 'Fort Lauderdale, FL',
'IAH': 'Houston, TX',
'HOU': 'Houston, TX',
'MSP': 'Minneapolis, MN',
'DTW': 'Detroit, MI',
'CLT': 'Charlotte, NC',
'EWR': 'Newark, NJ',
'IAD': 'Washington, DC',
'DCA': 'Washington, DC',
'BWI': 'Baltimore, MD',
'LAS': 'Las Vegas, NV',
'MCO': 'Orlando, FL',
'TPA': 'Tampa, FL',
'BNA': 'Nashville, TN',
'AUS': 'Austin, TX',
'SAT': 'San Antonio, TX',
'RDU': 'Raleigh, NC',
'SAN': 'San Diego, CA',
'OAK': 'Oakland, CA',
'SJC': 'San Jose, CA',
'SMF': 'Sacramento, CA',
'PHL': 'Philadelphia, PA',
'PIT': 'Pittsburgh, PA',
'CLE': 'Cleveland, OH',
'CMH': 'Columbus, OH',
'CVG': 'Cincinnati, OH',
'IND': 'Indianapolis, IN',
'MCI': 'Kansas City, MO',
'STL': 'St. Louis, MO',
'MSY': 'New Orleans, LA',
'MEM': 'Memphis, TN',
'SDF': 'Louisville, KY',
'JAX': 'Jacksonville, FL',
'RIC': 'Richmond, VA',
'ORF': 'Norfolk, VA',
'BDL': 'Hartford, CT',
'PVD': 'Providence, RI',
'ABQ': 'Albuquerque, NM',
'OKC': 'Oklahoma City, OK',
'TUL': 'Tulsa, OK',
'OMA': 'Omaha, NE',
'BOI': 'Boise, ID',
'GEG': 'Spokane, WA',
'ANC': 'Anchorage, AK',
'HNL': 'Honolulu, HI',
'OGG': 'Maui, HI',
'BUF': 'Buffalo, NY',
'SYR': 'Syracuse, NY',
'ROC': 'Rochester, NY',
'ALB': 'Albany, NY',
'BTV': 'Burlington, VT',
'PWM': 'Portland, ME',
'MKE': 'Milwaukee, WI',
'DSM': 'Des Moines, IA',
'LIT': 'Little Rock, AR',
'BHM': 'Birmingham, AL',
'CHS': 'Charleston, SC',
'SAV': 'Savannah, GA',
// Canada
'YVR': 'Vancouver, BC',
'YYZ': 'Toronto, ON',
'YUL': 'Montreal, QC',
'YOW': 'Ottawa, ON',
'YYC': 'Calgary, AB',
'YEG': 'Edmonton, AB',
'YWG': 'Winnipeg, MB',
'YHZ': 'Halifax, NS',
'YQB': 'Quebec City, QC',
// Europe
'LHR': 'London, UK',
'LGW': 'London, UK',
'STN': 'London, UK',
'CDG': 'Paris, FR',
'ORY': 'Paris, FR',
'FRA': 'Frankfurt, DE',
'MUC': 'Munich, DE',
'BER': 'Berlin, DE',
'AMS': 'Amsterdam, NL',
'MAD': 'Madrid, ES',
'BCN': 'Barcelona, ES',
'FCO': 'Rome, IT',
'MXP': 'Milan, IT',
'ZRH': 'Zurich, CH',
'GVA': 'Geneva, CH',
'VIE': 'Vienna, AT',
'CPH': 'Copenhagen, DK',
'ARN': 'Stockholm, SE',
'OSL': 'Oslo, NO',
'HEL': 'Helsinki, FI',
'DUB': 'Dublin, IE',
'LIS': 'Lisbon, PT',
'ATH': 'Athens, GR',
'IST': 'Istanbul, TR',
'WAW': 'Warsaw, PL',
'PRG': 'Prague, CZ',
'BUD': 'Budapest, HU',
'OTP': 'Bucharest, RO',
'SOF': 'Sofia, BG',
'ZAG': 'Zagreb, HR',
'BEG': 'Belgrade, RS',
'KBP': 'Kyiv, UA',
'LED': 'St. Petersburg, RU',
'SVO': 'Moscow, RU',
'BRU': 'Brussels, BE',
'EDI': 'Edinburgh, UK',
'MAN': 'Manchester, UK',
// Asia
'NRT': 'Tokyo, JP',
'HND': 'Tokyo, JP',
'KIX': 'Osaka, JP',
'ICN': 'Seoul, KR',
'PEK': 'Beijing, CN',
'PVG': 'Shanghai, CN',
'HKG': 'Hong Kong',
'TPE': 'Taipei, TW',
'SIN': 'Singapore',
'BKK': 'Bangkok, TH',
'KUL': 'Kuala Lumpur, MY',
'CGK': 'Jakarta, ID',
'MNL': 'Manila, PH',
'DEL': 'New Delhi, IN',
'BOM': 'Mumbai, IN',
'BLR': 'Bangalore, IN',
'CCU': 'Kolkata, IN',
'SGN': 'Ho Chi Minh City, VN',
'HAN': 'Hanoi, VN',
'DOH': 'Doha, QA',
'DXB': 'Dubai, AE',
'AUH': 'Abu Dhabi, AE',
'TLV': 'Tel Aviv, IL',
// Oceania
'SYD': 'Sydney, AU',
'MEL': 'Melbourne, AU',
'BNE': 'Brisbane, AU',
'PER': 'Perth, AU',
'AKL': 'Auckland, NZ',
'WLG': 'Wellington, NZ',
'CHC': 'Christchurch, NZ',
// South America
'GRU': 'São Paulo, BR',
'GIG': 'Rio de Janeiro, BR',
'EZE': 'Buenos Aires, AR',
'SCL': 'Santiago, CL',
'BOG': 'Bogota, CO',
'LIM': 'Lima, PE',
'UIO': 'Quito, EC',
'CCS': 'Caracas, VE',
'MVD': 'Montevideo, UY',
// Africa
'JNB': 'Johannesburg, ZA',
'CPT': 'Cape Town, ZA',
'CAI': 'Cairo, EG',
'NBO': 'Nairobi, KE',
'ADD': 'Addis Ababa, ET',
'CMN': 'Casablanca, MA',
'LOS': 'Lagos, NG'
};
})();

View File

@@ -1224,7 +1224,25 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
/* Ambiguous hop indicator */
.hop-ambiguous { border-bottom: 1px dashed #f59e0b; }
.hop-warn { font-size: 0.7em; margin-left: 2px; vertical-align: super; }
.hop-warn { font-size: 0.7em; margin-left: 2px; vertical-align: super; color: #f59e0b; }
.hop-conflict-btn { background: #f59e0b; color: #000; border: none; border-radius: 4px; font-size: 11px;
font-weight: 700; padding: 1px 5px; cursor: pointer; vertical-align: middle; margin-left: 3px; line-height: 1.2; }
.hop-conflict-btn:hover { background: #d97706; }
.hop-conflict-popover { position: absolute; z-index: 9999; background: var(--surface-1); border: 1px solid var(--border);
border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.25); width: 260px; max-height: 300px; overflow-y: auto; }
.hop-conflict-header { padding: 10px 12px; font-size: 12px; font-weight: 700; border-bottom: 1px solid var(--border);
color: var(--text-muted); }
.hop-conflict-list { padding: 4px 0; }
.hop-conflict-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; text-decoration: none;
color: var(--text); font-size: 13px; border-bottom: 1px solid var(--border); }
.hop-conflict-item:last-child { border-bottom: none; }
.hop-conflict-item:hover { background: var(--hover-bg); }
.hop-conflict-name { font-weight: 600; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hop-conflict-dist { font-size: 11px; color: var(--text-muted); font-family: var(--mono); white-space: nowrap; }
.hop-conflict-pk { font-size: 10px; color: var(--text-muted); font-family: var(--mono); }
.hop-unreliable { opacity: 0.5; text-decoration: line-through; }
.hop-global-fallback { border-bottom: 1px dashed #ef4444; }
.hop-current { font-weight: 700 !important; color: var(--accent) !important; }
/* Self-loop subpath rows */
.subpath-selfloop { opacity: 0.6; }
@@ -1491,13 +1509,14 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
.region-dropdown-trigger:hover { border-color: var(--accent); color: var(--accent); }
.region-dropdown-menu {
position: absolute; top: 100%; left: 0; z-index: 90;
min-width: 220px; max-height: 260px; overflow-y: auto;
min-width: 220px; width: max-content; max-height: 260px; overflow-y: auto;
background: var(--card-bg, #fff); border: 1px solid var(--border); border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.12); padding: 4px 0;
}
.region-dropdown-item {
display: flex; align-items: center; gap: 6px; padding: 6px 12px;
font-size: 13px; cursor: pointer; color: var(--text); white-space: nowrap;
overflow: hidden; text-overflow: ellipsis; max-width: 320px;
}
.region-dropdown-item input[type="checkbox"] {
width: 14px; height: 14px; margin: 0; flex-shrink: 0;
@@ -1530,3 +1549,141 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
.multi-select-item:hover { background: var(--row-hover, #f5f5f5); }
.chan-tag { background: var(--accent, #3b82f6); color: #fff; padding: 2px 8px; border-radius: 4px; font-size: 0.9em; font-weight: 600; }
/* Matrix mode hex animation */
.matrix-char { background: none !important; border: none !important; }
.matrix-char span { display: block; text-align: center; white-space: nowrap; line-height: 1; }
/* === Matrix Theme === */
.matrix-theme .leaflet-tile-pane {
filter: brightness(1.1) contrast(1.2) sepia(0.6) hue-rotate(70deg) saturate(2);
}
.matrix-theme.leaflet-container::before {
content: ''; position: absolute; inset: 0; z-index: 401;
background: rgba(0, 60, 10, 0.35); mix-blend-mode: multiply; pointer-events: none;
}
.matrix-theme.leaflet-container::after {
content: ''; position: absolute; inset: 0; z-index: 402;
background: rgba(0, 255, 65, 0.06); mix-blend-mode: screen; pointer-events: none;
}
.matrix-theme { background: #000 !important; }
.matrix-theme .leaflet-control-zoom a { background: #0a0a0a !important; color: #00ff41 !important; border-color: #00ff4130 !important; }
.matrix-theme .leaflet-control-attribution { background: rgba(0,0,0,0.8) !important; color: #00ff4180 !important; }
.matrix-theme .leaflet-control-attribution a { color: #00ff4160 !important; }
/* Scanline overlay */
.matrix-scanlines {
position: absolute; inset: 0; z-index: 9999; pointer-events: none;
background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,255,65,0.02) 2px, rgba(0,255,65,0.02) 4px);
}
/* Feed panel in matrix mode */
.matrix-theme .live-feed {
background: rgba(0, 10, 0, 0.92) !important;
border-color: #00ff4130 !important;
font-family: 'Courier New', monospace !important;
}
.matrix-theme .live-feed .live-feed-item { color: #00ff41 !important; border-color: #00ff4115 !important; }
.matrix-theme .live-feed .live-feed-item:hover { background: rgba(0,255,65,0.08) !important; }
.matrix-theme .live-feed .feed-hide-btn { color: #00ff41 !important; }
/* Controls in matrix mode */
.matrix-theme .live-controls {
background: rgba(0, 10, 0, 0.9) !important;
border-color: #00ff4130 !important;
color: #00ff41 !important;
}
.matrix-theme .live-controls label,
.matrix-theme .live-controls span,
.matrix-theme .live-controls .lcd-display { color: #00ff41 !important; }
.matrix-theme .live-controls button { color: #00ff41 !important; border-color: #00ff4130 !important; }
.matrix-theme .live-controls input[type="range"] { accent-color: #00ff41; }
/* Node detail panel in matrix mode */
.matrix-theme .live-node-detail {
background: rgba(0, 10, 0, 0.95) !important;
border-color: #00ff4130 !important;
color: #00ff41 !important;
}
.matrix-theme .live-node-detail a { color: #00ff41 !important; }
.matrix-theme .live-node-detail .feed-hide-btn { color: #00ff41 !important; }
/* Node labels on map */
.matrix-theme .node-label { color: #00ff41 !important; text-shadow: 0 0 4px #00ff41 !important; }
.matrix-theme .leaflet-marker-icon:not(.matrix-char) { filter: hue-rotate(90deg) saturate(1) brightness(0.35) opacity(0.5); }
/* Audio controls */
.audio-controls {
display: flex;
gap: 12px;
align-items: center;
padding: 4px 8px;
font-size: 12px;
}
.audio-controls.hidden { display: none; }
.audio-slider-label {
display: flex;
align-items: center;
gap: 4px;
color: var(--text-secondary, #6b7280);
font-size: 11px;
white-space: nowrap;
}
.audio-slider {
width: 80px;
height: 4px;
cursor: pointer;
accent-color: #8b5cf6;
}
.audio-slider-label span {
min-width: 24px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.matrix-theme .audio-controls label,
.matrix-theme .audio-controls span { color: #00ff41 !important; }
.matrix-theme .audio-slider { accent-color: #00ff41; }
/* Audio voice selector */
.audio-voice-select {
background: var(--bg-secondary, #1f2937);
color: var(--text-primary, #e5e7eb);
border: 1px solid var(--border, #374151);
border-radius: 4px;
padding: 2px 4px;
font-size: 11px;
cursor: pointer;
}
.matrix-theme .audio-voice-select {
background: #001a00 !important;
color: #00ff41 !important;
border-color: #00ff4130 !important;
}
/* Audio unlock overlay */
.audio-unlock-overlay {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.6);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.audio-unlock-prompt {
background: #1f2937;
color: #e5e7eb;
padding: 24px 40px;
border-radius: 12px;
font-size: 20px;
font-weight: 600;
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
user-select: none;
}
.matrix-theme .audio-unlock-prompt {
background: #001a00;
color: #00ff41;
box-shadow: 0 0 30px rgba(0,255,65,0.2);
}

166
server.js
View File

@@ -9,6 +9,8 @@ const path = require('path');
const fs = require('fs');
const config = require('./config.json');
const decoder = require('./decoder');
const PAYLOAD_TYPES = decoder.PAYLOAD_TYPES;
const { nodeNearRegion, IATA_COORDS } = require('./iata-coords');
// Health thresholds — configurable with sensible defaults
const _ht = config.healthThresholds || {};
@@ -796,7 +798,7 @@ for (const source of mqttSources) {
};
const packetId = pktStore.insert(advertPktData); _updateHashSizeForPacket(advertPktData);
try { db.insertTransmission(advertPktData); } catch (e) { console.error('[dual-write] transmission insert error:', e.message); }
broadcast({ type: 'packet', data: { id: packetId, decoded: { header: { payloadTypeName: 'ADVERT' }, payload: advert } } });
broadcast({ type: 'packet', data: { id: packetId, hash: advertPktData.hash, raw: advertPktData.raw_hex, decoded: { header: { payloadTypeName: 'ADVERT' }, payload: advert } } });
}
return;
}
@@ -829,8 +831,8 @@ for (const source of mqttSources) {
};
const packetId = pktStore.insert(chPktData); _updateHashSizeForPacket(chPktData);
try { db.insertTransmission(chPktData); } catch (e) { console.error('[dual-write] transmission insert error:', e.message); }
broadcast({ type: 'packet', data: { id: packetId, decoded: { header: { payloadTypeName: 'GRP_TXT' }, payload: channelMsg } } });
broadcast({ type: 'message', data: { id: packetId, decoded: { header: { payloadTypeName: 'GRP_TXT' }, payload: channelMsg } } });
broadcast({ type: 'packet', data: { id: packetId, hash: chPktData.hash, raw: chPktData.raw_hex, decoded: { header: { payloadTypeName: 'GRP_TXT' }, payload: channelMsg } } });
broadcast({ type: 'message', data: { id: packetId, hash: chPktData.hash, decoded: { header: { payloadTypeName: 'GRP_TXT' }, payload: channelMsg } } });
return;
}
@@ -852,7 +854,7 @@ for (const source of mqttSources) {
};
const packetId = pktStore.insert(dmPktData); _updateHashSizeForPacket(dmPktData);
try { db.insertTransmission(dmPktData); } catch (e) { console.error('[dual-write] transmission insert error:', e.message); }
broadcast({ type: 'packet', data: { id: packetId, decoded: { header: { payloadTypeName: 'TXT_MSG' }, payload: dm } } });
broadcast({ type: 'packet', data: { id: packetId, hash: dmPktData.hash, raw: dmPktData.raw_hex, decoded: { header: { payloadTypeName: 'TXT_MSG' }, payload: dm } } });
return;
}
@@ -874,7 +876,7 @@ for (const source of mqttSources) {
};
const packetId = pktStore.insert(tracePktData); _updateHashSizeForPacket(tracePktData);
try { db.insertTransmission(tracePktData); } catch (e) { console.error('[dual-write] transmission insert error:', e.message); }
broadcast({ type: 'packet', data: { id: packetId, decoded: { header: { payloadTypeName: 'TRACE' }, payload: trace } } });
broadcast({ type: 'packet', data: { id: packetId, hash: tracePktData.hash, raw: tracePktData.raw_hex, decoded: { header: { payloadTypeName: 'TRACE' }, payload: trace } } });
return;
}
@@ -1112,7 +1114,7 @@ app.post('/api/packets', requireApiKey, (req, res) => {
// Invalidate caches on new data
cache.debouncedInvalidateAll();
broadcast({ type: 'packet', data: { id: packetId, decoded } });
broadcast({ type: 'packet', data: { id: packetId, hash: apiPktData.hash, raw: apiPktData.raw_hex, decoded } });
res.json({ id: packetId, decoded });
} catch (e) {
@@ -2002,25 +2004,60 @@ app.get('/api/analytics/hash-sizes', (req, res) => {
app.get('/api/resolve-hops', (req, res) => {
const hops = (req.query.hops || '').split(',').filter(Boolean);
const observerId = req.query.observer || null;
// Origin anchor: sender's lat/lon for forward-pass disambiguation.
// Without this, the first ambiguous hop falls through to the backward pass
// which anchors from the observer — wrong when sender and observer are far apart.
const originLat = req.query.originLat ? parseFloat(req.query.originLat) : null;
const originLon = req.query.originLon ? parseFloat(req.query.originLon) : null;
if (!hops.length) return res.json({ resolved: {} });
const allNodes = getCachedNodes(false);
const allObservers = db.getObservers();
// Build observer IATA lookup and regional observer sets
const observerIataMap = {}; // observer_id → iata
const observersByIata = {}; // iata → Set<observer_id>
for (const obs of allObservers) {
if (obs.iata) {
observerIataMap[obs.id] = obs.iata;
if (!observersByIata[obs.iata]) observersByIata[obs.iata] = new Set();
observersByIata[obs.iata].add(obs.id);
}
}
// Determine this packet's region from its observer
const packetIata = observerId ? observerIataMap[observerId] : null;
const regionalObserverIds = packetIata ? observersByIata[packetIata] : null;
// Helper: check if a node is near the packet's region using layered filtering
// Layer 1: Node has lat/lon → geographic distance to IATA center (bridge-proof)
// Layer 2: Node has no lat/lon → observer-based (was ADVERT seen by regional observer)
// Returns: { near: boolean, method: 'geo'|'observer'|'none', distKm?: number }
const nodeInRegion = (candidate) => {
// Layer 1: Geographic check (ground truth, bridge-proof)
if (packetIata && candidate.lat && candidate.lon && !(candidate.lat === 0 && candidate.lon === 0)) {
const geoCheck = nodeNearRegion(candidate.lat, candidate.lon, packetIata);
if (geoCheck) return { near: geoCheck.near, method: 'geo', distKm: geoCheck.distKm };
}
// Layer 2: Observer-based check (fallback for nodes without GPS)
if (regionalObserverIds) {
const nodeObservers = pktStore._advertByObserver.get(candidate.public_key);
if (nodeObservers) {
for (const obsId of nodeObservers) {
if (regionalObserverIds.has(obsId)) return { near: true, method: 'observer' };
}
}
return { near: false, method: 'observer' };
}
// No region info available
return { near: false, method: 'none' };
};
// Build observer geographic position
let observerLat = null, observerLon = null;
if (observerId) {
// Try exact name match first
const obsNode = allNodes.find(n => n.name === observerId);
if (obsNode && obsNode.lat && obsNode.lon && !(obsNode.lat === 0 && obsNode.lon === 0)) {
observerLat = obsNode.lat;
observerLon = obsNode.lon;
} else {
// Fall back to averaging nearby nodes from adverts this observer received
const obsNodes = db.db.prepare(`
SELECT n.lat, n.lon FROM packets_v p
JOIN nodes n ON n.public_key = json_extract(p.decoded_json, '$.pubKey')
@@ -2039,25 +2076,55 @@ app.get('/api/resolve-hops', (req, res) => {
}
const resolved = {};
// First pass: find all candidates for each hop
// First pass: find all candidates for each hop, split into regional and global
for (const hop of hops) {
const hopLower = hop.toLowerCase();
const candidates = allNodes.filter(n => n.public_key.toLowerCase().startsWith(hopLower));
if (candidates.length === 0) {
resolved[hop] = { name: null, candidates: [] };
} else if (candidates.length === 1) {
resolved[hop] = { name: candidates[0].name, pubkey: candidates[0].public_key, candidates: [{ name: candidates[0].name, pubkey: candidates[0].public_key }] };
const hopByteLen = Math.ceil(hop.length / 2); // 2 hex chars = 1 byte
const allCandidates = allNodes.filter(n => n.public_key.toLowerCase().startsWith(hopLower));
if (allCandidates.length === 0) {
resolved[hop] = { name: null, candidates: [], conflicts: [] };
} else if (allCandidates.length === 1) {
const c = allCandidates[0];
const regionCheck = nodeInRegion(c);
resolved[hop] = { name: c.name, pubkey: c.public_key,
candidates: [{ name: c.name, pubkey: c.public_key, lat: c.lat, lon: c.lon, regional: regionCheck.near, filterMethod: regionCheck.method, distKm: regionCheck.distKm }],
conflicts: [] };
} else {
resolved[hop] = { name: candidates[0].name, pubkey: candidates[0].public_key, ambiguous: true, candidates: candidates.map(c => ({ name: c.name, pubkey: c.public_key, lat: c.lat, lon: c.lon })) };
// Multiple candidates — apply layered regional filtering
const checked = allCandidates.map(c => {
const r = nodeInRegion(c);
return { ...c, regional: r.near, filterMethod: r.method, distKm: r.distKm };
});
const regional = checked.filter(c => c.regional);
// Sort by distance to region center — closest first
regional.sort((a, b) => (a.distKm || 9999) - (b.distKm || 9999));
const candidates = regional.length > 0 ? regional : checked;
const globalFallback = regional.length === 0 && checked.length > 0;
const conflicts = candidates.map(c => ({
name: c.name, pubkey: c.public_key, lat: c.lat, lon: c.lon,
regional: c.regional, filterMethod: c.filterMethod, distKm: c.distKm
}));
if (candidates.length === 1) {
resolved[hop] = { name: candidates[0].name, pubkey: candidates[0].public_key,
candidates: conflicts, conflicts, globalFallback,
filterMethod: candidates[0].filterMethod };
} else {
resolved[hop] = { name: candidates[0].name, pubkey: candidates[0].public_key,
ambiguous: true, candidates: conflicts, conflicts, globalFallback,
hopBytes: hopByteLen, totalGlobal: allCandidates.length, totalRegional: regional.length,
filterMethods: [...new Set(candidates.map(c => c.filterMethod))] };
}
}
}
// Sequential disambiguation: each hop must be near the previous one
// Walk the path forward, resolving ambiguous hops by distance to last known position
// Start from first unambiguous hop (or observer position as anchor for last hop)
// Build initial resolved positions map
const hopPositions = {}; // hop -> {lat, lon}
const dist = (lat1, lon1, lat2, lon2) => Math.sqrt((lat1 - lat2) ** 2 + (lon1 - lon2) ** 2);
// Forward pass: resolve each ambiguous hop using previous hop's position
const hopPositions = {};
// Seed unambiguous positions
for (const hop of hops) {
const r = resolved[hop];
if (r && !r.ambiguous && r.pubkey) {
@@ -2068,9 +2135,6 @@ app.get('/api/resolve-hops', (req, res) => {
}
}
const dist = (lat1, lon1, lat2, lon2) => Math.sqrt((lat1 - lat2) ** 2 + (lon1 - lon2) ** 2);
// Forward pass: resolve each ambiguous hop using previous hop's position
let lastPos = (originLat != null && originLon != null) ? { lat: originLat, lon: originLon } : null;
for (let hi = 0; hi < hops.length; hi++) {
const hop = hops[hi];
@@ -2083,7 +2147,6 @@ app.get('/api/resolve-hops', (req, res) => {
const withLoc = r.candidates.filter(c => c.lat && c.lon && !(c.lat === 0 && c.lon === 0));
if (!withLoc.length) continue;
// Use previous hop position, or observer position for last hop, or skip
let anchor = lastPos;
if (!anchor && hi === hops.length - 1 && observerLat != null) {
anchor = { lat: observerLat, lon: observerLon };
@@ -2116,7 +2179,7 @@ app.get('/api/resolve-hops', (req, res) => {
nextPos = hopPositions[hop];
}
// Sanity check: drop hops impossibly far from both neighbors (>200km ≈ 1.8°)
// Sanity check: drop hops impossibly far from both neighbors
const MAX_HOP_DIST = MAX_HOP_DIST_SERVER;
for (let i = 0; i < hops.length; i++) {
const pos = hopPositions[hops[i]];
@@ -2129,14 +2192,13 @@ app.get('/api/resolve-hops', (req, res) => {
const tooFarPrev = prev && dPrev > MAX_HOP_DIST;
const tooFarNext = next && dNext > MAX_HOP_DIST;
if ((tooFarPrev && tooFarNext) || (tooFarPrev && !next) || (tooFarNext && !prev)) {
// Mark as unreliable — likely prefix collision with distant node
const r = resolved[hops[i]];
if (r) { r.unreliable = true; }
delete hopPositions[hops[i]];
}
}
res.json({ resolved });
res.json({ resolved, region: packetIata || null });
});
// channelHashNames removed — we only use decoded channel names now
@@ -2405,9 +2467,15 @@ app.get('/api/nodes/:pubkey/health', (req, res) => {
}
}
// Build observer iata lookup
const allObservers = db.getObservers();
const obsIataMap = {};
for (const obs of allObservers) { if (obs.iata) obsIataMap[obs.id] = obs.iata; }
const observers = Object.entries(obsMap).map(([observer_id, o]) => ({
observer_id, observer_name: o.observer_name, packetCount: o.packetCount,
avgSnr: o.snrN ? o.snrSum / o.snrN : null, avgRssi: o.rssiN ? o.rssiSum / o.rssiN : null
avgSnr: o.snrN ? o.snrSum / o.snrN : null, avgRssi: o.rssiN ? o.rssiSum / o.rssiN : null,
iata: obsIataMap[observer_id] || null
})).sort((a, b) => b.packetCount - a.packetCount);
const recentPackets = packets.slice(0, 20);
@@ -2848,6 +2916,40 @@ app.get('/api/analytics/subpath-detail', (req, res) => {
res.json(_sdResult);
});
// IATA coordinates for client-side regional filtering
app.get('/api/iata-coords', (req, res) => {
res.json({ coords: IATA_COORDS });
});
// Audio Lab: representative packets bucketed by type
app.get('/api/audio-lab/buckets', (req, res) => {
const buckets = {};
const byType = {};
for (const tx of pktStore.packets) {
if (!tx.raw_hex) continue;
let typeName = 'UNKNOWN';
try { const d = JSON.parse(tx.decoded_json || '{}'); typeName = d.type || (PAYLOAD_TYPES[tx.payload_type] || 'UNKNOWN'); } catch {}
if (!byType[typeName]) byType[typeName] = [];
byType[typeName].push(tx);
}
for (const [type, pkts] of Object.entries(byType)) {
const sorted = pkts.sort((a, b) => (a.raw_hex || '').length - (b.raw_hex || '').length);
const count = Math.min(8, sorted.length);
const picked = [];
for (let i = 0; i < count; i++) {
const idx = Math.floor((i / count) * sorted.length);
const tx = sorted[idx];
picked.push({
hash: tx.hash, raw_hex: tx.raw_hex, decoded_json: tx.decoded_json,
observation_count: tx.observation_count || 1, payload_type: tx.payload_type,
path_json: tx.path_json, observer_id: tx.observer_id, timestamp: tx.timestamp,
});
}
buckets[type] = picked;
}
res.json({ buckets });
});
// Static files + SPA fallback
app.use(express.static(path.join(__dirname, 'public'), {
etag: false,

135
test-regional-filter.js Normal file
View File

@@ -0,0 +1,135 @@
#!/usr/bin/env node
// Test: Regional hop resolution filtering
// Validates that resolve-hops correctly filters candidates by geography and observer region
const { IATA_COORDS, haversineKm, nodeNearRegion } = require('./iata-coords');
let pass = 0, fail = 0;
function assert(condition, msg) {
if (condition) { pass++; console.log(`${msg}`); }
else { fail++; console.error(` ❌ FAIL: ${msg}`); }
}
// === 1. Haversine distance tests ===
console.log('\n=== Haversine Distance ===');
const sjcToSea = haversineKm(37.3626, -121.9290, 47.4502, -122.3088);
assert(sjcToSea > 1100 && sjcToSea < 1150, `SJC→SEA = ${Math.round(sjcToSea)}km (expect ~1125km)`);
const sjcToOak = haversineKm(37.3626, -121.9290, 37.7213, -122.2208);
assert(sjcToOak > 40 && sjcToOak < 55, `SJC→OAK = ${Math.round(sjcToOak)}km (expect ~48km)`);
const sjcToSjc = haversineKm(37.3626, -121.9290, 37.3626, -121.9290);
assert(sjcToSjc === 0, `SJC→SJC = ${sjcToSjc}km (expect 0)`);
const sjcToEug = haversineKm(37.3626, -121.9290, 44.1246, -123.2119);
assert(sjcToEug > 750 && sjcToEug < 780, `SJC→EUG = ${Math.round(sjcToEug)}km (expect ~762km)`);
// === 2. nodeNearRegion tests ===
console.log('\n=== Node Near Region ===');
// Node in San Jose, check against SJC region
const sjNode = nodeNearRegion(37.35, -121.95, 'SJC');
assert(sjNode && sjNode.near, `San Jose node near SJC: ${sjNode.distKm}km`);
// Node in Seattle, check against SJC region — should NOT be near
const seaNode = nodeNearRegion(47.45, -122.30, 'SJC');
assert(seaNode && !seaNode.near, `Seattle node NOT near SJC: ${seaNode.distKm}km`);
// Node in Seattle, check against SEA region — should be near
const seaNodeSea = nodeNearRegion(47.45, -122.30, 'SEA');
assert(seaNodeSea && seaNodeSea.near, `Seattle node near SEA: ${seaNodeSea.distKm}km`);
// Node in Eugene, check against EUG — should be near
const eugNode = nodeNearRegion(44.05, -123.10, 'EUG');
assert(eugNode && eugNode.near, `Eugene node near EUG: ${eugNode.distKm}km`);
// Eugene node should NOT be near SJC (~762km)
const eugNodeSjc = nodeNearRegion(44.05, -123.10, 'SJC');
assert(eugNodeSjc && !eugNodeSjc.near, `Eugene node NOT near SJC: ${eugNodeSjc.distKm}km`);
// Node with no location — returns null
const noLoc = nodeNearRegion(null, null, 'SJC');
assert(noLoc === null, 'Null lat/lon returns null');
// Node at 0,0 — returns null
const zeroLoc = nodeNearRegion(0, 0, 'SJC');
assert(zeroLoc === null, 'Zero lat/lon returns null');
// Unknown IATA — returns null
const unkIata = nodeNearRegion(37.35, -121.95, 'ZZZ');
assert(unkIata === null, 'Unknown IATA returns null');
// === 3. Edge cases: nodes just inside/outside 300km radius ===
console.log('\n=== Boundary Tests (300km radius) ===');
// Sacramento is ~145km from SJC — inside
const smfNode = nodeNearRegion(38.58, -121.49, 'SJC');
assert(smfNode && smfNode.near, `Sacramento near SJC: ${smfNode.distKm}km (expect ~145)`);
// Fresno is ~235km from SJC — inside
const fatNode = nodeNearRegion(36.74, -119.79, 'SJC');
assert(fatNode && fatNode.near, `Fresno near SJC: ${fatNode.distKm}km (expect ~235)`);
// Redding is ~400km from SJC — outside
const rddNode = nodeNearRegion(40.59, -122.39, 'SJC');
assert(rddNode && !rddNode.near, `Redding NOT near SJC: ${rddNode.distKm}km (expect ~400)`);
// === 4. Simulate the core issue: 1-byte hop with cross-regional collision ===
console.log('\n=== Cross-Regional Collision Simulation ===');
// Two nodes with pubkeys starting with "D6": one in SJC area, one in SEA area
const candidates = [
{ name: 'Redwood Mt. Tam', pubkey: 'D6...sjc', lat: 37.92, lon: -122.60 }, // Marin County, CA
{ name: 'VE7RSC North Repeater', pubkey: 'D6...sea', lat: 49.28, lon: -123.12 }, // Vancouver, BC
{ name: 'KK7RXY Lynden', pubkey: 'D6...bel', lat: 48.94, lon: -122.47 }, // Bellingham, WA
];
// Packet observed in SJC region
const packetIata = 'SJC';
const geoFiltered = candidates.filter(c => {
const check = nodeNearRegion(c.lat, c.lon, packetIata);
return check && check.near;
});
assert(geoFiltered.length === 1, `Geo filter SJC: ${geoFiltered.length} candidates (expect 1)`);
assert(geoFiltered[0].name === 'Redwood Mt. Tam', `Winner: ${geoFiltered[0].name} (expect Redwood Mt. Tam)`);
// Packet observed in SEA region
const seaFiltered = candidates.filter(c => {
const check = nodeNearRegion(c.lat, c.lon, 'SEA');
return check && check.near;
});
assert(seaFiltered.length === 2, `Geo filter SEA: ${seaFiltered.length} candidates (expect 2 — Vancouver + Bellingham)`);
// Packet observed in EUG region — Eugene is ~300km from SEA nodes
const eugFiltered = candidates.filter(c => {
const check = nodeNearRegion(c.lat, c.lon, 'EUG');
return check && check.near;
});
assert(eugFiltered.length === 0, `Geo filter EUG: ${eugFiltered.length} candidates (expect 0 — all too far)`);
// === 5. Layered fallback logic ===
console.log('\n=== Layered Fallback ===');
const nodeWithGps = { lat: 37.92, lon: -122.60 }; // has GPS
const nodeNoGps = { lat: null, lon: null }; // no GPS
const observerSawNode = true; // observer-based filter says yes
// Layer 1: GPS check
const gpsCheck = nodeNearRegion(nodeWithGps.lat, nodeWithGps.lon, 'SJC');
assert(gpsCheck && gpsCheck.near, 'Layer 1 (GPS): node with GPS near SJC');
// Layer 2: No GPS, fall back to observer
const gpsCheckNoLoc = nodeNearRegion(nodeNoGps.lat, nodeNoGps.lon, 'SJC');
assert(gpsCheckNoLoc === null, 'Layer 2: no GPS returns null → use observer-based fallback');
// Bridged WA node with GPS — should be REJECTED by SJC even though observer saw it
const bridgedWaNode = { lat: 47.45, lon: -122.30 }; // Seattle
const bridgedCheck = nodeNearRegion(bridgedWaNode.lat, bridgedWaNode.lon, 'SJC');
assert(bridgedCheck && !bridgedCheck.near, `Bridge test: WA node rejected by SJC geo filter (${bridgedCheck.distKm}km)`);
// === Summary ===
console.log(`\n${'='.repeat(40)}`);
console.log(`Results: ${pass} passed, ${fail} failed`);
process.exit(fail > 0 ? 1 : 0);

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env node
// Integration test: Verify layered filtering works against live prod API
// Tests that resolve-hops returns regional metadata and correct filtering
const https = require('https');
const BASE = 'https://analyzer.00id.net';
function apiGet(path) {
return new Promise((resolve, reject) => {
https.get(BASE + path, { timeout: 10000 }, (res) => {
let data = '';
res.on('data', d => data += d);
res.on('end', () => { try { resolve(JSON.parse(data)); } catch (e) { reject(e); } });
}).on('error', reject);
});
}
let pass = 0, fail = 0;
function assert(condition, msg) {
if (condition) { pass++; console.log(`${msg}`); }
else { fail++; console.error(` ❌ FAIL: ${msg}`); }
}
async function run() {
console.log('\n=== Integration: resolve-hops API with regional filtering ===\n');
// 1. Get a packet with short hops and a known observer
const packets = await apiGet('/api/packets?limit=100&groupByHash=true');
const pkt = packets.packets.find(p => {
const path = JSON.parse(p.path_json || '[]');
return path.length > 0 && path.some(h => h.length <= 2) && p.observer_id;
});
if (!pkt) {
console.log(' ⚠ No packets with short hops found — skipping API tests');
return;
}
const path = JSON.parse(pkt.path_json);
const shortHops = path.filter(h => h.length <= 2);
console.log(` Using packet ${pkt.hash.slice(0,12)} observed by ${pkt.observer_name || pkt.observer_id.slice(0,12)}`);
console.log(` Path: ${path.join(' → ')} (${shortHops.length} short hops)`);
// 2. Resolve WITH observer (should get regional filtering)
const withObs = await apiGet(`/api/resolve-hops?hops=${path.join(',')}&observer=${pkt.observer_id}`);
assert(withObs.region != null, `Response includes region: ${withObs.region}`);
// 3. Check that conflicts have filterMethod field
let hasFilterMethod = false;
let hasDistKm = false;
for (const [hop, info] of Object.entries(withObs.resolved)) {
if (info.conflicts && info.conflicts.length > 0) {
for (const c of info.conflicts) {
if (c.filterMethod) hasFilterMethod = true;
if (c.distKm != null) hasDistKm = true;
}
}
if (info.filterMethods) {
assert(Array.isArray(info.filterMethods), `Hop ${hop}: filterMethods is array: ${JSON.stringify(info.filterMethods)}`);
}
}
assert(hasFilterMethod, 'At least one conflict has filterMethod');
// 4. Resolve WITHOUT observer (no regional filtering)
const withoutObs = await apiGet(`/api/resolve-hops?hops=${path.join(',')}`);
assert(withoutObs.region === null, `Without observer: region is null`);
// 5. Compare: with observer should have same or fewer candidates per ambiguous hop
for (const hop of shortHops) {
const withInfo = withObs.resolved[hop];
const withoutInfo = withoutObs.resolved[hop];
if (withInfo && withoutInfo && withInfo.conflicts && withoutInfo.conflicts) {
const withCount = withInfo.totalRegional || withInfo.conflicts.length;
const withoutCount = withoutInfo.totalGlobal || withoutInfo.conflicts.length;
assert(withCount <= withoutCount + 1,
`Hop ${hop}: regional(${withCount}) <= global(${withoutCount}) — ${withInfo.name || '?'}`);
}
}
// 6. Check that geo-filtered candidates have distKm
for (const [hop, info] of Object.entries(withObs.resolved)) {
if (info.conflicts) {
const geoFiltered = info.conflicts.filter(c => c.filterMethod === 'geo');
for (const c of geoFiltered) {
assert(c.distKm != null, `Hop ${hop} candidate ${c.name}: has distKm=${c.distKm}km (geo filter)`);
}
}
}
console.log(`\n${'='.repeat(40)}`);
console.log(`Results: ${pass} passed, ${fail} failed`);
process.exit(fail > 0 ? 1 : 0);
}
run().catch(e => { console.error('Test error:', e); process.exit(1); });