mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-03-30 12:25:40 +00:00
Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28e3d8319b | ||
|
|
3155504d70 | ||
|
|
dda69e7e1e | ||
|
|
ca3ba6d04e | ||
|
|
5149bb3295 | ||
|
|
961ac7b68c | ||
|
|
dbbe280f17 | ||
|
|
c267e58418 | ||
|
|
19c60e6872 | ||
|
|
f4002def4c | ||
|
|
5f7626ab3c | ||
|
|
55ff793e37 | ||
|
|
646afa2cf9 | ||
|
|
3c44b4638d | ||
|
|
398363cc9d | ||
|
|
632e684029 | ||
|
|
70db798aed | ||
|
|
28475722d7 | ||
|
|
e732d52e0e | ||
|
|
3491fdabef | ||
|
|
501d685003 | ||
|
|
f0243ff332 | ||
|
|
46868b7dcd | ||
|
|
537e04d0ad | ||
|
|
5273d1fd01 | ||
|
|
5ad498a662 | ||
|
|
0974e7b15b | ||
|
|
b11722d854 | ||
|
|
25c85aeeb2 | ||
|
|
a2dd96812d | ||
|
|
13460cfc93 | ||
|
|
935ed6ef85 | ||
|
|
70c67d8551 | ||
|
|
c1d789b5d7 | ||
|
|
4cc1ad7b34 | ||
|
|
b7a29d4849 | ||
|
|
d474d0b427 | ||
|
|
2609a26605 | ||
|
|
0934d8bbb6 | ||
|
|
2fd0d3e07b | ||
|
|
7c1132b7cf | ||
|
|
f523d4f3c4 | ||
|
|
edbaddbd37 | ||
|
|
d5e6481d9b | ||
|
|
1450bc928b | ||
|
|
c7f12c72b9 | ||
|
|
2688f3e63a | ||
|
|
90bd9e12e5 | ||
|
|
4e8b1b2584 | ||
|
|
288c1b048b | ||
|
|
85ecddd92c | ||
|
|
d0b02b7070 | ||
|
|
08255aeba5 | ||
|
|
51df14521b | ||
|
|
f3572c646a | ||
|
|
0dbe5fd229 | ||
|
|
f206ae48ef | ||
|
|
ff0f26293e | ||
|
|
d0de0770ec | ||
|
|
6b8e4447c0 | ||
|
|
a7e8a70c2f | ||
|
|
ddc86a2574 | ||
|
|
3f8b8aec79 | ||
|
|
73dd0d34d2 | ||
|
|
fbf1648ae3 | ||
|
|
36eb04c016 | ||
|
|
7739b7ef71 | ||
|
|
7fbbea11a4 | ||
|
|
8dfb5c39f7 | ||
|
|
0116cd38ac | ||
|
|
0255e10746 | ||
|
|
3d7c087025 | ||
|
|
54d453d034 | ||
|
|
ca46cc6959 | ||
|
|
a01999c743 | ||
|
|
a295e5eb9c | ||
|
|
c3e97e6768 | ||
|
|
1ba33d5d04 | ||
|
|
4b0cc38adb | ||
|
|
26fca2677b | ||
|
|
3f4077c8e0 | ||
|
|
261bb54c38 | ||
|
|
bbfaded9fb | ||
|
|
051d351a01 | ||
|
|
786237e461 | ||
|
|
68d2fba54e | ||
|
|
bbaecd664a | ||
|
|
aa8feb3912 | ||
|
|
967f4def7e | ||
|
|
76ad318b15 | ||
|
|
e501b63362 | ||
|
|
1ea2152418 | ||
|
|
a9d5d2450c | ||
|
|
6f8cd2eac0 | ||
|
|
13d781fcd9 | ||
|
|
0f8e886984 | ||
|
|
9cfd452910 | ||
|
|
95ce48543c | ||
|
|
d93ff1a1e7 | ||
|
|
3102d15e45 | ||
|
|
556359e9db | ||
|
|
c47e8947c6 | ||
|
|
e76e63b80d | ||
|
|
cf3a8fe2f4 | ||
|
|
620458be8b |
7
.github/workflows/deploy.yml
vendored
7
.github/workflows/deploy.yml
vendored
@@ -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
146
AUDIO-PLAN.md
Normal 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
175
AUDIO-WORKBENCH.md
Normal 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)
|
||||
47
CHANGELOG.md
47
CHANGELOG.md
@@ -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
64
RELEASE-v2.6.0.md
Normal 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
90
iata-coords.js
Normal 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 };
|
||||
@@ -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
562
public/audio-lab.js
Normal 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 });
|
||||
})();
|
||||
139
public/audio-v1-constellation.js
Normal file
139
public/audio-v1-constellation.js
Normal 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
214
public/audio.js
Normal 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
121
public/hop-display.js
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// 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 };
|
||||
})();
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
436
public/live.js
436
public/live.js
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>';
|
||||
|
||||
175
public/roles.js
175
public/roles.js
@@ -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'
|
||||
};
|
||||
})();
|
||||
|
||||
161
public/style.css
161
public/style.css
@@ -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
166
server.js
@@ -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
135
test-regional-filter.js
Normal 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);
|
||||
96
test-regional-integration.js
Normal file
96
test-regional-integration.js
Normal 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); });
|
||||
Reference in New Issue
Block a user