mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-03-30 13:35:42 +00:00
Compare commits
328 Commits
fix/remove
...
v2.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
301f04e3de | ||
|
|
0b14da8f4f | ||
|
|
5ff9ba7a53 | ||
|
|
8770d2b3e0 | ||
|
|
ae40726c71 | ||
|
|
d3c4fdc6d6 | ||
|
|
cb3ce5e764 | ||
|
|
044ffd34e2 | ||
|
|
28b2756f40 | ||
|
|
4fc12383fa | ||
|
|
f2c7c48eed | ||
|
|
e027beeb38 | ||
|
|
748862db9c | ||
|
|
036078e1ce | ||
|
|
60a20d4190 | ||
|
|
9aa185ef09 | ||
|
|
89b4ee817e | ||
|
|
48de8f99b3 | ||
|
|
c13de6f7d7 | ||
|
|
e04324a4c9 | ||
|
|
871d6953ed | ||
|
|
e5f808b078 | ||
|
|
3650007f06 | ||
|
|
eca41c466f | ||
|
|
074dd736d9 | ||
|
|
bfc1acbbe6 | ||
|
|
f16fce8b7f | ||
|
|
a892582821 | ||
|
|
3e2f7a9afe | ||
|
|
56a09d180d | ||
|
|
f1bcb95ee5 | ||
|
|
011294c0fa | ||
|
|
b97212087d | ||
|
|
68f36d9ecf | ||
|
|
5d20269d05 | ||
|
|
918589fc8c | ||
|
|
f2c6186d8c | ||
|
|
6a0c0770b4 | ||
|
|
c4c06e7fb8 | ||
|
|
502244fc38 | ||
|
|
0073504657 | ||
|
|
b4ce4ede42 | ||
|
|
6362c4338a | ||
|
|
fb57670f74 | ||
|
|
1666f7c5d7 | ||
|
|
feceadf432 | ||
|
|
5e81ad6c87 | ||
|
|
f1cf759ebd | ||
|
|
da19ddef51 | ||
|
|
b461a05b6d | ||
|
|
9916a9d59f | ||
|
|
f979743727 | ||
|
|
056410a850 | ||
|
|
142bbabcc3 | ||
|
|
da315aac94 | ||
|
|
db9219319d | ||
|
|
db7f394a6a | ||
|
|
e267a99274 | ||
|
|
e36c6cca49 | ||
|
|
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 | ||
|
|
1050aa52d9 | ||
|
|
e8d9aa6839 | ||
|
|
daa84fdd73 | ||
|
|
01df3c70d8 | ||
|
|
368fef8713 | ||
|
|
657653dd3b | ||
|
|
11828a321a | ||
|
|
f09e338dfa | ||
|
|
87672797f9 | ||
|
|
b337da6461 | ||
|
|
bd32aa9565 | ||
|
|
60219ae02a | ||
|
|
5e28fb15c9 | ||
|
|
98f958641a | ||
|
|
3a8d92e39b | ||
|
|
0f8d63636b | ||
|
|
132ab60e06 | ||
|
|
c123ee731d | ||
|
|
0fb586730b | ||
|
|
7e304e60d5 | ||
|
|
9171eecc69 | ||
|
|
552ba2f970 | ||
|
|
3ff29519b4 | ||
|
|
39c1782881 | ||
|
|
cbd13c00d6 | ||
|
|
4b1c1c3f22 | ||
|
|
0d3b12d40a | ||
|
|
e000db438c | ||
|
|
09f6f106ba | ||
|
|
a299798b31 | ||
|
|
181fddf196 | ||
|
|
2ae467bc72 | ||
|
|
a11ace77ac | ||
|
|
b1c2e817fa | ||
|
|
3d31afd0ec | ||
|
|
28ac094d83 | ||
|
|
8c4b3f029f | ||
|
|
3b1e16a1d6 | ||
|
|
fa7f1cf76a | ||
|
|
b800d77570 | ||
|
|
7e0cd455ae | ||
|
|
4f66e377d1 | ||
|
|
1877c49adc | ||
|
|
461fb7ee68 | ||
|
|
679f9a552f | ||
|
|
f1e3a57fcf | ||
|
|
1dc5daab67 | ||
|
|
8892fb0f66 | ||
|
|
98a6cbd3b4 | ||
|
|
ad6a796b35 | ||
|
|
1b2f28cb5f | ||
|
|
2894c38435 | ||
|
|
97be64353a | ||
|
|
8ae7d7710b | ||
|
|
b61b71635b | ||
|
|
04a138eba3 | ||
|
|
f51db66775 | ||
|
|
75d76cf68e | ||
|
|
1e61e021ec | ||
|
|
c4d6fb7cd3 | ||
|
|
1158161e1a | ||
|
|
81f284e952 | ||
|
|
6f64ed6b9b | ||
|
|
d51c7a780c | ||
|
|
4a909fbd0b | ||
|
|
105f8546b1 | ||
|
|
eca0c9bd61 | ||
|
|
3c12690ccb | ||
|
|
ddb6dcb113 | ||
|
|
746f5cf3b1 | ||
|
|
1320e33bd6 | ||
|
|
00ce8de7bc | ||
|
|
32b897d8f3 | ||
|
|
ebc72fa364 | ||
|
|
6f350bb785 | ||
|
|
775a45f9eb | ||
|
|
9eb4bcc088 | ||
|
|
e1590c6242 | ||
|
|
7d164f4a67 | ||
|
|
e70dd8b2fa | ||
|
|
a66cc8f126 | ||
|
|
7a3a3a5ea0 | ||
|
|
cf14701592 | ||
|
|
7e841d89c1 | ||
|
|
795be6996f | ||
|
|
187a2ac536 | ||
|
|
2c38d3c7d6 | ||
|
|
9120985ab1 | ||
|
|
cc55e5733d | ||
|
|
6b78b3c5e4 | ||
|
|
75af7c3094 | ||
|
|
e087156d90 | ||
|
|
fe552c8a4b | ||
|
|
3edb02a829 | ||
|
|
7f1c735981 | ||
|
|
5bb17bbe60 | ||
|
|
a892691dd3 | ||
|
|
fd57790d36 | ||
|
|
7d46d96563 | ||
|
|
47fa32f982 | ||
|
|
b3599694c6 | ||
|
|
2f50bc0c89 | ||
|
|
77fe834eb6 | ||
|
|
9f4a9c6506 | ||
|
|
8bf5e64b1d | ||
|
|
f1fd34f47a | ||
|
|
013e33253d | ||
|
|
bbf17b69ef | ||
|
|
3270e389c5 | ||
|
|
95443abd3e | ||
|
|
35313c57d4 | ||
|
|
013cbaf5c4 | ||
|
|
49d4841862 | ||
|
|
eaf0e621af | ||
|
|
99dde1fc31 | ||
|
|
cff995e00c | ||
|
|
ab163227f8 | ||
|
|
9664d6089c | ||
|
|
606d4e134f | ||
|
|
da475e9c13 | ||
|
|
739d4480a1 | ||
|
|
212990a295 | ||
|
|
ce190886ff | ||
|
|
9f53a059c7 | ||
|
|
c6d72e828d | ||
|
|
2834cfccba | ||
|
|
d555ea26be | ||
|
|
133f267c4c | ||
|
|
bd5171cf95 | ||
|
|
dc6df38c9a | ||
|
|
0ef1eb2595 | ||
|
|
4bfe1ec363 | ||
|
|
290508d67c | ||
|
|
36ad6c8f75 | ||
|
|
071acd1561 | ||
|
|
c72f014f99 | ||
|
|
558687051e | ||
|
|
5f291bdaa7 | ||
|
|
31a8f707b6 | ||
|
|
78ea581fc5 | ||
|
|
1b737519bc | ||
|
|
0848a6c634 | ||
|
|
926a68959b | ||
|
|
67a90a3a33 | ||
|
|
be1bcbf733 | ||
|
|
f6fb024a20 | ||
|
|
4395ff348c | ||
|
|
0c97e4e980 | ||
|
|
78cc5edbb4 | ||
|
|
c7e528331c | ||
|
|
e9b2dc7c00 | ||
|
|
5a36b8bf2e | ||
|
|
6bff9ce5e7 | ||
|
|
92c258dabc | ||
|
|
5eacce1b40 | ||
|
|
5f3e5a6ad1 | ||
|
|
975abade32 | ||
|
|
cf9d5e3325 | ||
|
|
003f5b1477 | ||
|
|
1adc3ca41d | ||
|
|
ab22b98f48 | ||
|
|
607eef2d06 | ||
|
|
ac8a6a4dc3 | ||
|
|
209e17fcd4 |
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 \
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@ data/
|
||||
config.json
|
||||
data-lincomatic/
|
||||
config-lincomatic.json
|
||||
theme.json
|
||||
|
||||
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)
|
||||
187
CHANGELOG.md
187
CHANGELOG.md
@@ -1,4 +1,147 @@
|
||||
# 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.
|
||||
|
||||
### 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)
|
||||
|
||||
## [2.4.0] — 2026-03-22
|
||||
|
||||
UI polish, client-side filtering, time window selector, DB cleanup, and bug fixes.
|
||||
|
||||
### Added
|
||||
- Observation-level deeplinks (`#/packets/HASH?obs=OBSERVER_ID`)
|
||||
- Observation detail pane (click any child row for its specific data)
|
||||
- Observation sort: Observer / Path ↑↓ / Time ↑↓ with persistent preference
|
||||
- Ungrouped mode flattens all observations into individual rows
|
||||
- Sort help tooltip (ⓘ) explaining each mode
|
||||
- Distance/Range analytics tab with haversine calculations
|
||||
- View on Map buttons for distance leaderboard entries
|
||||
- Realistic packet propagation mode on live map
|
||||
- Packet propagation time in detail pane
|
||||
- Replay sends all observations with realistic animation
|
||||
- Paths-through section on node detail (desktop + mobile)
|
||||
- Regional filters on all tabs (shared RegionFilter component)
|
||||
- Favorites filter on live map (packet-level, not node markers)
|
||||
- Configurable map defaults via `config.json`
|
||||
- Hash prefix labels on map with spiral deconfliction + callout lines
|
||||
- Channel rainbow table (pre-computed keys for common names)
|
||||
- Zero-API live channel updates via WebSocket
|
||||
- Channel message dedup by packet hash
|
||||
- Channel name tags (blue pill) in packet detail column
|
||||
- Shareable channel URLs (`#/channels/HASH`)
|
||||
- API key required for POST endpoints
|
||||
- HTTPS support (lincomatic PR #105)
|
||||
- Graceful shutdown (lincomatic PR #109)
|
||||
- Filter bar: logical grouping, consistent 34px height, help tooltips
|
||||
- Multi-select Observer and Type filters (checkbox dropdowns, OR logic)
|
||||
- Hex Paths toggle: show raw hex hash prefixes vs resolved node names
|
||||
- Time window selector (15min/30min/1h/3h/6h/12h/24h/All) replaces fixed packet count limit
|
||||
- Pause/resume button (⏸/▶) for live WebSocket updates with buffered packet count
|
||||
- localStorage persistence for all filter/view preferences
|
||||
|
||||
### Changed
|
||||
- Channel keys: plain `String(channelHash)`, `hashChannels` for auto-derived SHA256
|
||||
- Node region filtering uses ADVERT-based index (accurate local presence vs mesh-wide routing)
|
||||
- Header row reflects first sorted observation's data
|
||||
- Max hop distance filter: 1000km → 300km (LoRa record ~250km)
|
||||
- Route view labels use deconflicted divIcons
|
||||
- Channels page hides encrypted messages, shows only decrypted
|
||||
- Dark mode: active filter buttons retain accent styling
|
||||
- Region dropdown: `IATA - Friendly Name` format, proper sizing
|
||||
- Observer/Type filters are pure client-side (no API calls on filter change)
|
||||
- Packet loading: time-window based (`since`) instead of fixed count limit
|
||||
- Header row shows matching observer when observer filter is active
|
||||
|
||||
### Removed
|
||||
- Legacy `packets` and `paths` database tables (auto-migrated on startup)
|
||||
- Redundant server-side type/observer filtering (client filters in-memory)
|
||||
|
||||
### Fixed
|
||||
- Header row showed longest path instead of first observer's path
|
||||
- Observer/path mismatch when earlier observation arrives later
|
||||
- Auto-seeding fake data on empty DB (now requires `--seed` flag)
|
||||
- Channel "10h ago" bug (used stale `first_seen` instead of current time)
|
||||
- Stale UI: wrong ID type for packet lookup after insert
|
||||
- ADVERT timestamp validation rejecting valid nodes
|
||||
- Channels page API spam on every WS update
|
||||
- Duplicate observations in expanded view
|
||||
- Analytics RF 500 error (stack overflow with 193K observations)
|
||||
- Region filter SQL using non-existent column
|
||||
- Channel hash: decimal→hex, keyed by decrypted name
|
||||
- Corrupted repeater entries (ADVERT validation at ingestion)
|
||||
- Hash_size: uses newest ADVERT, precomputed at startup
|
||||
- Tab backgrounding: skip animations, resume cleanly
|
||||
- Feed panel position (obscured by VCR bar)
|
||||
- Hop disambiguation anchored from sender origin
|
||||
- Packet hash case normalization for deeplinks
|
||||
- Critical: packet ingestion broken after legacy table removal (`insert()` returned undefined)
|
||||
- Sort help tooltip rendering (CSS pseudo-elements don't support newlines)
|
||||
|
||||
### Performance
|
||||
- `/api/analytics/distance`: 3s → 630ms
|
||||
- `/api/analytics/topology`: 289ms → 193ms
|
||||
- `/api/observers`: 3s → 130ms
|
||||
- `/api/nodes`: 50ms → 2ms (precomputed hash_size)
|
||||
- Event loop max: 3.2s → 903ms (startup only)
|
||||
- Pre-warm yields event loop via `setImmediate`
|
||||
- Client-side hop resolution
|
||||
- SQLite manual PASSIVE checkpointing
|
||||
- Single API call for packet expand (was 3)
|
||||
|
||||
## [2.3.0] - 2026-03-20
|
||||
|
||||
### Added
|
||||
@@ -27,47 +170,3 @@
|
||||
### Performance
|
||||
- **8.19× dedup ratio on production** (117K observations → 14K transmissions)
|
||||
- RAM usage reduced proportionally — store loads transmissions, not inflated observations
|
||||
|
||||
## v2.1.1 — Multi-Broker MQTT & Observer Detail (2026-03-20)
|
||||
|
||||
### 🆕 New Features
|
||||
|
||||
- **Multi-Broker MQTT** — Connect to multiple MQTT brokers simultaneously via `mqttSources` config array. Each source gets its own connection, topics, credentials, TLS settings, and optional IATA region filter. Legacy `mqtt` config still works.
|
||||
- **IATA Region Filtering** — `mqttSources[].iataFilter` restricts accepted regions per source (e.g. only accept SJC/SFO/OAK packets from a shared feed).
|
||||
- **Observer Detail Pages** — Click any observer row for a full detail page with status, radio info, battery/uptime/noise floor, packet type donut chart, timeline, unique nodes chart, SNR distribution, and recent packets table.
|
||||
- **Observer Status Topic Parsing** — `meshcore/<region>/<id>/status` messages populate model, firmware, client_version, radio config, battery, uptime, and noise floor. 7 new columns in the observers table with auto-migration.
|
||||
- **Channel Key Auto-Derivation** — Hashtag channel keys (`#channel`) are automatically derived as `SHA256("#channelname")` first 16 bytes on startup. Only non-hashtag keys (like `public`) need manual config.
|
||||
- **Map Dark/Light Mode** — Map page now uses CartoDB dark/light tiles that swap automatically with the theme toggle (same as live page).
|
||||
- **Shareable URLs** — Copy Link button on packet detail, standalone packet page at `#/packet/ID`, deep links to channels and observer detail pages.
|
||||
- **Multi-Node Packet Filter** — "My Nodes" toggle in packets view now uses server-side `findPacketsForNode()` to find ALL packet types (messages, acks, traces), not just ADVERTs.
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **Observer name resolution** — MQTT packets now pass `msg.origin` (friendly name) to both packet records and observer upserts. Previously only the status handler used it.
|
||||
- **Observer analytics ordering** — Fixed `recentPackets` returning oldest instead of newest (wrong slice direction). Sorted observer analytics packets explicitly.
|
||||
- **Spark bars visible** — Fixed `.data-table td { max-width: 0 }` crushing spark bar cells to zero width with inline style override.
|
||||
- **My Nodes filter field names** — Fixed `pubkey` → `pubKey`, `to`/`from` → `srcPubKey`/`destPubKey`/`srcHash`/`destHash`.
|
||||
- **Duplicate pin buttons** — Live page destroy now removes the nav pin button; init guards against duplicates.
|
||||
- **Packets page crash** — Fixed non-async `renderTableRows` using `await` (syntax error prevented entire page from loading).
|
||||
- **Node search all packet types** — Search by node name now returns messages, acks, and traces — not just ADVERTs.
|
||||
- **Node packet count accuracy** — `findPacketsForNode()` is now single source of truth for all node packet lookups.
|
||||
- **Health endpoint recentPackets** — Changed from `slice(-10).reverse()` to `slice(0, 20)` — 20 newest DESC instead of 10 oldest.
|
||||
- **RF analytics total packets** — Added `totalAllPackets` field so frontend shows both total and signal-filtered counts.
|
||||
- **Duplicate `const crypto` crash** — Removed duplicate `require('crypto')` that crashed prod for ~2 minutes.
|
||||
- **PII scrubbed from git history** — Removed real names and coordinates from seed data across all commits.
|
||||
|
||||
### 🏗️ Infrastructure
|
||||
|
||||
- **Docker container deployed to Azure VM** — Live at `https://analyzer.00id.net` with automatic Let's Encrypt TLS via Caddy.
|
||||
- **`deploy.sh` fixed** — Config mount (`-v config.json:/app/config.json:ro`) was missing, causing every deploy to fall back to placeholder credentials. Added `|| true` to stop/rm to prevent chain failures.
|
||||
- **CI/CD via GitHub Actions** — Self-hosted runner on VM, auto-deploys on push to master.
|
||||
|
||||
---
|
||||
|
||||
## v2.0.1 — Mobile Packets (2026-03-18)
|
||||
|
||||
See [v2.0.1 release](https://github.com/Kpa-clawbot/meshcore-analyzer/releases/tag/v2.0.1).
|
||||
|
||||
## v2.0.0 — Live Trace Map & VCR Playback (2026-03-17)
|
||||
|
||||
See [v2.0.0 release](https://github.com/Kpa-clawbot/meshcore-analyzer/releases/tag/v2.0.0).
|
||||
|
||||
152
CUSTOMIZATION-PLAN.md
Normal file
152
CUSTOMIZATION-PLAN.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# CUSTOMIZATION-PLAN.md — White-Label / Multi-Instance Theming
|
||||
|
||||
## Status: Phase 1 Complete (v2.6.0+)
|
||||
|
||||
### What's Built
|
||||
- Floating draggable customizer panel (🎨 in nav)
|
||||
- Basic (7 colors) + Advanced (12 colors + fonts) with light/dark mode
|
||||
- Node role colors + packet type colors
|
||||
- Branding (site name, logo, favicon)
|
||||
- Home page content editor with markdown support
|
||||
- Auto-save to localStorage + admin JSON export
|
||||
- Colors restore on page load before any rendering
|
||||
|
||||
### Known Bugs to Fix
|
||||
- Nav background sometimes doesn't repaint (gradient caching)
|
||||
- Some pages may flash default colors before customization applies
|
||||
- Color picker dragging can still feel sluggish on complex pages
|
||||
- Reset preview may not fully restore all derived variables
|
||||
|
||||
### Next Round: Phase 2
|
||||
- **Click-to-identify**: Click any UI element → customizer scrolls to the setting that controls it (like DevTools inspect but for theme colors)
|
||||
- **Theme presets**: Built-in themes (Default, Cascadia Navy, Forest Green, Midnight) — one-click switch
|
||||
- **Import config**: Paste JSON to load a theme (reverse of export)
|
||||
- **Preview home page changes live** without navigating away
|
||||
- Fix remaining 8 hardcoded colors from audit (nav stats, trace labels, rec-dot)
|
||||
- Hex viewer color customization (Advanced section)
|
||||
|
||||
### Architecture Notes
|
||||
- `customize.js` MUST load right after `roles.js`, before `app.js` — color restore timing is critical
|
||||
- `syncBadgeColors()` in roles.js is the single source for badge CSS
|
||||
- `ROLE_STYLE[role].color` must be updated alongside `ROLE_COLORS[role]`
|
||||
- Auto-save debounced 500ms, theme-refresh debounced 300ms
|
||||
|
||||
## Problem
|
||||
|
||||
Regional mesh admins (e.g. CascadiaMesh) fork the analyzer and manually edit CSS/HTML to customize branding, colors, and content. This is fragile — every upstream update requires re-applying customizations.
|
||||
|
||||
## Goal
|
||||
|
||||
A `config.json`-driven customization system where admins configure branding, colors, labels, and home page content without touching source code. Accessible via a **Tools → Customization** UI that outputs the config.
|
||||
|
||||
## Direct Feedback (CascadiaMesh Admin)
|
||||
|
||||
Customizations they made manually:
|
||||
- **Branding**: Custom logo, favicon, site title ("CascadiaMesh Analyzer")
|
||||
- **Colors**: Node type colors (repeaters blue instead of red, companions red)
|
||||
- **UI styling**: Custom color scheme (deep navy theme — "Cascadia" theme)
|
||||
- **Home page**: Intro section emojis, steps, checklist content
|
||||
|
||||
Requested config options:
|
||||
- Configurable branding assets (logo, favicon, site name)
|
||||
- Configurable UI colors/text labels
|
||||
- Configurable node type colors
|
||||
- Everything in the intro/home section should be configurable
|
||||
|
||||
## Config Schema (proposed)
|
||||
|
||||
```json
|
||||
{
|
||||
"branding": {
|
||||
"siteName": "CascadiaMesh Analyzer",
|
||||
"logoUrl": "/assets/logo.png",
|
||||
"faviconUrl": "/assets/favicon.ico",
|
||||
"tagline": "Pacific Northwest Mesh Network Monitor"
|
||||
},
|
||||
"theme": {
|
||||
"accent": "#20468b",
|
||||
"accentHover": "#2d5bb0",
|
||||
"navBg": "#111c36",
|
||||
"navBg2": "#060a13",
|
||||
"statusGreen": "#45644c",
|
||||
"statusYellow": "#b08b2d",
|
||||
"statusRed": "#b54a4a"
|
||||
},
|
||||
"nodeColors": {
|
||||
"repeater": "#3b82f6",
|
||||
"companion": "#ef4444",
|
||||
"room": "#8b5cf6",
|
||||
"sensor": "#10b981",
|
||||
"observer": "#f59e0b"
|
||||
},
|
||||
"home": {
|
||||
"heroTitle": "CascadiaMesh Network Monitor",
|
||||
"heroSubtitle": "Real-time packet analysis for the Pacific Northwest mesh",
|
||||
"steps": [
|
||||
{ "emoji": "📡", "title": "Connect", "description": "Link your node to the mesh" },
|
||||
{ "emoji": "🔍", "title": "Monitor", "description": "Watch packets flow in real-time" },
|
||||
{ "emoji": "📊", "title": "Analyze", "description": "Understand your network's health" }
|
||||
],
|
||||
"checklist": [
|
||||
{ "question": "How do I add my node?", "answer": "..." },
|
||||
{ "question": "What regions are covered?", "answer": "..." }
|
||||
],
|
||||
"footerLinks": [
|
||||
{ "label": "Discord", "url": "https://discord.gg/..." },
|
||||
{ "label": "GitHub", "url": "https://github.com/..." }
|
||||
]
|
||||
},
|
||||
"labels": {
|
||||
"latestPackets": "Latest Packets",
|
||||
"liveMap": "Live Map"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Config Loading + CSS Variables (Server)
|
||||
- Server reads `config.json` theme section
|
||||
- New endpoint: `GET /api/config/theme` returns merged theme config
|
||||
- Client injects CSS variables from theme config on page load
|
||||
- Node type colors configurable via `window.TYPE_COLORS` override
|
||||
|
||||
### Phase 2: Branding
|
||||
- Config drives nav bar title, logo, favicon
|
||||
- `index.html` rendered server-side with branding placeholders OR
|
||||
- Client JS replaces branding elements on load from `/api/config/theme`
|
||||
|
||||
### Phase 3: Home Page Content
|
||||
- Home page sections (hero, steps, checklist, footer) driven by config
|
||||
- Default content baked in; config overrides specific sections
|
||||
- Emoji + text for each step configurable
|
||||
|
||||
### Phase 4: Tools → Customization UI
|
||||
- New page `#/customize` (admin only?)
|
||||
- Color pickers for theme variables
|
||||
- Live preview
|
||||
- Branding upload (logo, favicon)
|
||||
- Export as JSON config
|
||||
- Home page content editor (WYSIWYG-lite)
|
||||
|
||||
### Phase 5: CSS Theme Presets
|
||||
- Built-in themes: Default (blue), Cascadia (navy), Forest (green), Midnight (dark)
|
||||
- One-click theme switching
|
||||
- Custom theme = override any variable
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- Theme CSS variables are already in `:root {}` — just need to override from config
|
||||
- Node type colors used in `roles.js` via `TYPE_COLORS` — make configurable
|
||||
- Home page content is in `home.js` — extract to template driven by config
|
||||
- Logo/favicon: serve from config-specified path, default to built-in
|
||||
- No build step — pure runtime configuration
|
||||
- Config changes take effect on page reload (no server restart needed for theme)
|
||||
|
||||
## Priority
|
||||
|
||||
1. Theme colors (CSS variables from config) — highest impact, lowest effort
|
||||
2. Branding (site name, logo) — visible, requested
|
||||
3. Node type colors — requested specifically
|
||||
4. Home page content — requested
|
||||
5. Customization UI — nice to have, lower priority
|
||||
93
DEDUP-DESIGN.md
Normal file
93
DEDUP-DESIGN.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Packet Deduplication Design
|
||||
|
||||
## The Problem
|
||||
|
||||
A single physical RF transmission gets recorded as N rows in the DB, where N = number of observers that heard it. Each row has the same `hash` but different `path_json` and `observer_id`.
|
||||
|
||||
### Example
|
||||
```
|
||||
Pkt 1 repeat 1: Path: A→B→C→D→E (observer E)
|
||||
Pkt 1 repeat 2: Path: A→B→F→G (observer G)
|
||||
Pkt 1 repeat 3: Path: A→C→H→J→K (observer K)
|
||||
```
|
||||
|
||||
- Repeater A sent 1 packet, not 3
|
||||
- Repeater B sent 1 packet, not 2 (C and F both heard the same broadcast)
|
||||
- The hash is identical across all 3 rows
|
||||
|
||||
### Why the hash works
|
||||
|
||||
`computeContentHash()` = `SHA256(header_byte + payload)`, skipping path hops. Two observations of the same original packet through different paths produce the same hash. This is the dedup key.
|
||||
|
||||
## What's inflated (and what's not)
|
||||
|
||||
| Context | Current (inflated?) | Correct behavior |
|
||||
|---------|-------------------|------------------|
|
||||
| Node "total packets" | COUNT(*) — inflated | COUNT(DISTINCT hash) for transmissions |
|
||||
| Packets/hour on observer page | Raw count | Correct — each observer DID receive it |
|
||||
| Node analytics throughput | Inflated | DISTINCT hash |
|
||||
| Live map animations | N animations per physical packet | 1 animation? Or 1 per path? TBD |
|
||||
| "Heard By" table | Observations per observer | Correct as-is |
|
||||
| RF analytics (SNR/RSSI) | Mixes observations | Each observation has its own SNR — all valid |
|
||||
| Topology/path analysis | All paths shown | All paths are valuable — don't discard |
|
||||
| Packet list (grouped mode) | Groups by hash already | Probably fine |
|
||||
| Packet list (ungrouped) | Shows every observation | Maybe show distinct, expand for repeats? |
|
||||
|
||||
## Key Principle
|
||||
|
||||
**Observations are valuable data — never discard them.** The paths tell you about mesh topology, coverage, and redundancy. But **counts displayed to users should reflect reality** (1 transmission = 1 count).
|
||||
|
||||
## Design Decisions Needed
|
||||
|
||||
1. **What does "packets" mean in node detail?** Unique transmissions? Total observations? Both?
|
||||
2. **Live map**: 1 animation with multiple path lines? Or 1 per observation?
|
||||
3. **Analytics charts**: Should throughput charts show transmissions or observations?
|
||||
4. **Packet list default view**: Group by hash by default?
|
||||
5. **New metric: "observation ratio"?** — avg observations per transmission tells you about mesh redundancy/coverage
|
||||
|
||||
## Work Items
|
||||
|
||||
- [ ] **DB/API: Add distinct counts** — `findPacketsForNode()` and health endpoint should return both `totalTransmissions` (DISTINCT hash) and `totalObservations` (COUNT(*))
|
||||
- [ ] **Node detail UI** — show "X transmissions seen Y times" or similar
|
||||
- [ ] **Bulk health / network status** — use distinct hash counts
|
||||
- [ ] **Node analytics charts** — throughput should use distinct hashes
|
||||
- [ ] **Packets page default** — consider grouping by hash by default
|
||||
- [ ] **Live map** — decide on animation strategy for repeated observations
|
||||
- [ ] **Observer page** — observation count is correct, but could add "unique packets" column
|
||||
- [ ] **In-memory store** — add hash→[packets] index if not already there (check `pktStore.byHash`)
|
||||
- [ ] **API: packet siblings** — `/api/packets/:id/siblings` or `?groupByHash=true` (may already exist)
|
||||
- [ ] **RF analytics** — keep all observations for SNR/RSSI (each is a real measurement) but label counts correctly
|
||||
- [ ] **"Coverage ratio" metric** — avg(observations per unique hash) per node/observer — measures mesh redundancy
|
||||
|
||||
## Live Map Animation Design
|
||||
|
||||
### Current behavior
|
||||
Every observation triggers a separate animation. Same packet heard by 3 observers = 3 independent route animations. Looks like 3 packets when it was 1.
|
||||
|
||||
### Options considered
|
||||
|
||||
**Option A: Single animation, all paths simultaneously (PREFERRED)**
|
||||
When a hash first arrives, buffer briefly (500ms-2s) for sibling observations, then animate all paths at once. One pulse from origin, multiple route lines fanning out simultaneously. Most accurate — this IS what physically happened: one RF burst propagating through the mesh along multiple paths at once.
|
||||
|
||||
Timing challenge: observations don't arrive simultaneously (seconds apart). Need to buffer the first observation, wait for siblings, then render all together. Adds slight latency to "live" feel.
|
||||
|
||||
**Option B: Single animation, "best" path only** — REJECTED
|
||||
Pick shortest/highest-SNR path, animate only that. Clean but loses coverage/redundancy info.
|
||||
|
||||
**Option C: Single origin pulse, staggered path reveals** — REJECTED
|
||||
Origin pulses once, paths draw in sequence with delay. Dramatic but busy, and doesn't reflect reality (the propagation is simultaneous).
|
||||
|
||||
**Option D: Animate first, suppress siblings** — REJECTED (pragmatic but inaccurate)
|
||||
First observation gets animation, subsequent same-hash observations silently logged. Simple but you never see alternate paths on the live map.
|
||||
|
||||
### Implementation notes (for when we build this)
|
||||
- Need a client-side hash buffer: `Map<hash, {timer, packets[]}>`
|
||||
- On first WS packet with new hash: start timer (configurable, ~1-2s)
|
||||
- On subsequent packets with same hash: add to buffer, reset/extend timer
|
||||
- On timer expiry: animate all buffered paths for that hash simultaneously
|
||||
- Feed sidebar could show consolidated entry: "1 packet, 3 paths" with expand
|
||||
- Buffer window should be configurable (config.json)
|
||||
|
||||
## Status
|
||||
|
||||
**Discussion phase** — no code changes yet. Iavor wants to finalize design before implementation. Live map changes tabled for later.
|
||||
236
DEDUP-MIGRATION-PLAN.md
Normal file
236
DEDUP-MIGRATION-PLAN.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# Packet Deduplication — Normalized Schema Migration Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Split the monolithic `packets` table into two tables:
|
||||
- **`packets`** — one row per unique physical transmission (keyed by content hash)
|
||||
- **`observations`** — one row per observer sighting (SNR, RSSI, path, observer, timestamp)
|
||||
|
||||
This fixes inflated packet counts across the entire app and enables proper "1 transmission seen N times" semantics.
|
||||
|
||||
## Current State
|
||||
|
||||
**`packets` table**: 1 row per observation. ~61MB, ~30K+ rows. Same hash appears N times (once per observer). Fields mix transmission data (raw_hex, payload_type, decoded_json, hash) with observation data (observer_id, snr, rssi, path_json).
|
||||
|
||||
**`packet-store.js`**: In-memory mirror of packets table. Indexes: `byId`, `byHash` (hash → [packets]), `byObserver`, `byNode`. All reads served from RAM. SQLite is write-only for packets.
|
||||
|
||||
**Touch surface**: ~66 SQL queries across db.js/server.js/packet-store.js. ~12 frontend files consume packet data.
|
||||
|
||||
---
|
||||
|
||||
## Milestone 1: Schema Migration (Backend Only)
|
||||
|
||||
**Goal**: New tables exist, data migrated, old table preserved as backup. No behavioral changes yet.
|
||||
|
||||
### Tasks
|
||||
1. **Create new schema** in `db.js` init:
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS transmissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
raw_hex TEXT NOT NULL,
|
||||
hash TEXT NOT NULL UNIQUE,
|
||||
first_seen TEXT NOT NULL,
|
||||
route_type INTEGER,
|
||||
payload_type INTEGER,
|
||||
payload_version INTEGER,
|
||||
decoded_json TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS observations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transmission_id INTEGER NOT NULL REFERENCES transmissions(id),
|
||||
hash TEXT NOT NULL,
|
||||
observer_id TEXT,
|
||||
observer_name TEXT,
|
||||
direction TEXT,
|
||||
snr REAL,
|
||||
rssi REAL,
|
||||
score INTEGER,
|
||||
path_json TEXT,
|
||||
timestamp TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_transmissions_hash ON transmissions(hash);
|
||||
CREATE INDEX idx_transmissions_first_seen ON transmissions(first_seen);
|
||||
CREATE INDEX idx_transmissions_payload_type ON transmissions(payload_type);
|
||||
CREATE INDEX idx_observations_hash ON observations(hash);
|
||||
CREATE INDEX idx_observations_transmission_id ON observations(transmission_id);
|
||||
CREATE INDEX idx_observations_observer_id ON observations(observer_id);
|
||||
CREATE INDEX idx_observations_timestamp ON observations(timestamp);
|
||||
```
|
||||
|
||||
2. **Write migration script** (`scripts/migrate-dedup.js`):
|
||||
- Read all rows from `packets` ordered by timestamp
|
||||
- Group by hash
|
||||
- For each unique hash: INSERT into `transmissions` (use first observation's raw_hex, decoded_json, etc.)
|
||||
- For each row: INSERT into `observations` with foreign key to transmission
|
||||
- Verify counts: `SELECT COUNT(*) FROM observations` = old packets count
|
||||
- Verify: `SELECT COUNT(*) FROM transmissions` < observations count
|
||||
- **Do NOT drop old `packets` table** — rename to `packets_backup`
|
||||
|
||||
3. **Print migration stats**: total packets, unique transmissions, dedup ratio, time taken
|
||||
|
||||
### Validation
|
||||
- `COUNT(*) FROM observations` = `COUNT(*) FROM packets_backup`
|
||||
- `COUNT(*) FROM transmissions` = `COUNT(DISTINCT hash) FROM packets_backup`
|
||||
- Spot-check: pick 5 known multi-observer packets, verify transmission + observations match
|
||||
|
||||
### Risk: LOW — additive only, old data preserved
|
||||
|
||||
---
|
||||
|
||||
## Milestone 2: Dual-Write Ingest
|
||||
|
||||
**Goal**: New packets written to both old and new tables. Read path unchanged. Zero downtime.
|
||||
|
||||
### Tasks
|
||||
1. **Update `db.js` `insertPacket()`**:
|
||||
- On new packet: check if `transmissions` row exists for hash
|
||||
- If not: INSERT into `transmissions`, get id
|
||||
- If yes: UPDATE `first_seen` if this timestamp is earlier
|
||||
- INSERT into `observations` with transmission_id
|
||||
- **Still also write to old `packets` table** (dual-write for safety)
|
||||
|
||||
2. **Update `packet-store.js` `insert()`**: Mirror the dual-write in memory model
|
||||
- Maintain both old flat array AND new `byTransmission` Map
|
||||
|
||||
### Validation
|
||||
- Send test packets, verify they appear in both old and new tables
|
||||
- Verify multi-observer packet creates 1 transmission + N observations
|
||||
|
||||
### Risk: LOW — old read path still works as fallback
|
||||
|
||||
---
|
||||
|
||||
## Milestone 3: In-Memory Store Restructure
|
||||
|
||||
**Goal**: `packet-store.js` switches from flat packet array to transmission-centric model.
|
||||
|
||||
### Tasks
|
||||
1. **New in-memory data model**:
|
||||
```
|
||||
transmissions: Map<hash, {id, raw_hex, hash, first_seen, payload_type, decoded_json, observations: []}>
|
||||
```
|
||||
Each observation: `{id, observer_id, observer_name, snr, rssi, path_json, timestamp}`
|
||||
|
||||
2. **Update indexes**:
|
||||
- `byHash`: hash → transmission object (1:1 instead of 1:N)
|
||||
- `byObserver`: observer_id → [observation references]
|
||||
- `byNode`: pubkey → [transmission references] (deduped!)
|
||||
- `byId`: observation.id → observation (for backward compat with packet detail links)
|
||||
|
||||
3. **Update `load()`**: Read from `transmissions` JOIN `observations` instead of `packets`
|
||||
|
||||
4. **Update query methods**:
|
||||
- `findPackets()` — returns transmissions by default, with `.observations` attached
|
||||
- `findPacketsForNode()` — returns transmissions where node appears in ANY observation's path/decoded_json
|
||||
- `getSiblings()` — becomes `getObservations(hash)` — trivial, just return `transmission.observations`
|
||||
- `countForNode()` — returns `{transmissions: N, observations: M}`
|
||||
|
||||
### Validation
|
||||
- All existing API endpoints return valid data
|
||||
- Packet counts decrease (correctly!) for multi-observer nodes
|
||||
- `/api/perf` shows no regression
|
||||
|
||||
### Risk: MEDIUM — core read path changes. Test thoroughly.
|
||||
|
||||
---
|
||||
|
||||
## Milestone 4: API Response Changes
|
||||
|
||||
**Goal**: APIs return deduped data with observation counts.
|
||||
|
||||
### Tasks
|
||||
1. **`GET /api/packets`**:
|
||||
- Default: return transmissions (1 row per unique packet)
|
||||
- Each transmission includes `observation_count` and optionally `observations[]`
|
||||
- `?expand=observations` to include full observation list
|
||||
- `?groupByHash` becomes the default behavior (deprecate param)
|
||||
- Preserve `observer` filter: return transmissions where at least one observation matches
|
||||
|
||||
2. **`GET /api/nodes/:pubkey/health`**:
|
||||
- `stats.totalPackets` → `stats.totalTransmissions` (distinct hashes)
|
||||
- Add `stats.totalObservations` (old count, for reference)
|
||||
- `recentPackets` → returns transmissions with observation_count
|
||||
|
||||
3. **`GET /api/nodes/bulk-health`**: Same changes as health
|
||||
|
||||
4. **`GET /api/nodes/network-status`**: Use transmission counts
|
||||
|
||||
5. **`GET /api/nodes/:pubkey/analytics`**: All throughput charts use transmission counts
|
||||
|
||||
6. **WebSocket broadcast**: Include `observation_count` when sibling observations exist for same hash
|
||||
|
||||
### Backward Compatibility
|
||||
- Add `?legacy=1` param that returns old-style flat observations (for any external consumers)
|
||||
- Include both `totalTransmissions` and `totalObservations` in health responses during transition
|
||||
|
||||
### Risk: MEDIUM — frontend expects certain shapes. May need coordinated deploy with Milestone 5.
|
||||
|
||||
---
|
||||
|
||||
## Milestone 5: Frontend Updates
|
||||
|
||||
**Goal**: UI shows correct counts and leverages observation data.
|
||||
|
||||
### Tasks
|
||||
1. **Packets page**:
|
||||
- Default view shows transmissions (already has groupByHash mode — make it default)
|
||||
- Expand row to see individual observations with their paths/SNR/RSSI
|
||||
- Badge: "×3 observers" on grouped rows
|
||||
|
||||
2. **Node detail panel** (nodes.js + live.js):
|
||||
- Show "X transmissions" not "X packets"
|
||||
- Or "X packets (seen Y times)" to show both
|
||||
|
||||
3. **Home page**: Network stats use transmission counts
|
||||
|
||||
4. **Node analytics**: Throughput charts use transmissions
|
||||
|
||||
5. **Observer detail**: Keep observation counts (correct metric for observers)
|
||||
|
||||
6. **Analytics page**: Topology/RF analysis uses all observations (SNR per observation is valid data)
|
||||
|
||||
### Risk: LOW-MEDIUM — mostly display changes
|
||||
|
||||
---
|
||||
|
||||
## Milestone 6: Cleanup
|
||||
|
||||
**Goal**: Remove dual-write, drop old table, clean up.
|
||||
|
||||
### Tasks
|
||||
1. Remove dual-write from `insertPacket()`
|
||||
2. Drop `packets_backup` table (after confirming everything works for 1+ week)
|
||||
3. Remove `?legacy=1` support if unused
|
||||
4. Update DEDUP-DESIGN.md → mark as complete
|
||||
5. VACUUM the database
|
||||
6. Tag release (v2.3.0?)
|
||||
|
||||
### Risk: LOW — cleanup only, all functional changes already proven
|
||||
|
||||
---
|
||||
|
||||
## Estimated Scope
|
||||
|
||||
| Milestone | Files Modified | Complexity | Can Deploy Independently? |
|
||||
|-----------|---------------|------------|--------------------------|
|
||||
| 1. Schema Migration | db.js, new script | Low | Yes — additive only |
|
||||
| 2. Dual-Write | db.js, packet-store.js | Low | Yes — old reads unchanged |
|
||||
| 3. Memory Store | packet-store.js | Medium | No — must deploy with M4 |
|
||||
| 4. API Changes | server.js, db.js | Medium | No — must deploy with M5 |
|
||||
| 5. Frontend | 8+ public/*.js files | Medium | No — must deploy with M4 |
|
||||
| 6. Cleanup | db.js, server.js | Low | Yes — after bake period |
|
||||
|
||||
**Milestones 1-2**: Safe to deploy independently, no user-visible changes.
|
||||
**Milestones 3-5**: Must ship together (API shape changes + frontend expects new shape).
|
||||
**Milestone 6**: Ship after 1 week bake.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Table naming**: `transmissions` + `observations`? Or keep `packets` + add `observations`? The word "transmission" is more accurate but "packet" is what the whole UI calls them.
|
||||
2. **Packet detail URLs**: Currently `#/packet/123` uses the observation ID. Keep observation IDs as the URL key? Or switch to hash?
|
||||
3. **Path dedup in paths table**: The `paths` table also has per-observation entries. Normalize that too, or leave as-is?
|
||||
4. **Migration on prod**: Run migration script before deploying new code, or make new code handle both old and new schema?
|
||||
@@ -9,7 +9,7 @@ COPY package.json package-lock.json ./
|
||||
RUN npm ci --production
|
||||
|
||||
# Copy application
|
||||
COPY *.js config.example.json ./
|
||||
COPY *.js config.example.json channel-rainbow.json ./
|
||||
COPY public/ ./public/
|
||||
|
||||
# Supervisor + Mosquitto + Caddy config
|
||||
|
||||
24
README.md
24
README.md
@@ -120,11 +120,26 @@ docker run -d \
|
||||
-p 3000:3000 \
|
||||
-p 1883:1883 \
|
||||
-v meshcore-data:/app/data \
|
||||
-v $(pwd)/config.json:/app/config.json \
|
||||
meshcore-analyzer
|
||||
```
|
||||
|
||||
**Persist your database** across container rebuilds by using a named volume (`meshcore-data`) or bind mount (`-v ./data:/app/data`).
|
||||
Config lives in the data volume at `/app/data/config.json` — a default is created on first run. To edit it:
|
||||
```bash
|
||||
docker exec -it meshcore-analyzer vi /app/data/config.json
|
||||
```
|
||||
|
||||
Or use a bind mount for the data directory:
|
||||
```bash
|
||||
docker run -d \
|
||||
--name meshcore-analyzer \
|
||||
-p 3000:3000 \
|
||||
-p 1883:1883 \
|
||||
-v ./data:/app/data \
|
||||
meshcore-analyzer
|
||||
# Now edit ./data/config.json directly on the host
|
||||
```
|
||||
|
||||
**Theme customization:** Put `theme.json` next to `config.json` — wherever your config lives, that's where the theme goes. Use the built-in customizer (Tools → Customize) to design your theme, download the file, and drop it in. Changes are picked up on page refresh — no restart needed. The server logs where it's looking on startup.
|
||||
|
||||
### Manual Install
|
||||
|
||||
@@ -148,6 +163,10 @@ Edit `config.json`:
|
||||
```json
|
||||
{
|
||||
"port": 3000,
|
||||
"https": {
|
||||
"cert": "/path/to/cert.pem",
|
||||
"key": "/path/to/key.pem"
|
||||
},
|
||||
"mqtt": {
|
||||
"broker": "mqtt://localhost:1883",
|
||||
"topic": "meshcore/+/+/packets"
|
||||
@@ -178,6 +197,7 @@ Edit `config.json`:
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `port` | HTTP server port (default: 3000) |
|
||||
| `https.cert` / `https.key` | Optional PEM cert/key paths to enable native HTTPS (falls back to HTTP if omitted or unreadable) |
|
||||
| `mqtt.broker` | Local MQTT broker URL. Set to `""` to disable |
|
||||
| `mqtt.topic` | MQTT topic pattern for packet ingestion |
|
||||
| `mqttSources` | Array of external MQTT broker connections (optional) |
|
||||
|
||||
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
|
||||
298
channel-rainbow.json
Normal file
298
channel-rainbow.json
Normal file
@@ -0,0 +1,298 @@
|
||||
{
|
||||
"#LongFast": "2cc3d22840e086105ad73443da2cacb8",
|
||||
"#MediumSlow": "99aa7084b6312841eb9b79b3a146bea4",
|
||||
"#ShortFast": "18267412b697cb98344c4a44b044d04d",
|
||||
"#ShortSlow": "8dffe23f9ed28b7d617fc587bdb19ec0",
|
||||
"#LongSlow": "7f8722cce459fc6d452db4f5be59ba5e",
|
||||
"#MediumFast": "7a5d6b6c3977df0e9a0929cd6fe98f5f",
|
||||
"#LongModerate": "ca954bbfd33831fa3a2bb7018d3ab654",
|
||||
"#ShortTurbo": "efe09f21c232292838d5c657a0ecb814",
|
||||
"#test": "9cd8fcf22a47333b591d96a2b848b73f",
|
||||
"#public": "8b4b705b080c0d943b1c80f6b3ef6b6d",
|
||||
"#general": "4c49f3f24629f5ee4ad5b3965db47985",
|
||||
"#chat": "d0bdd6d71538138ed979eec00d98ad97",
|
||||
"#local": "d2d35fa76be9875ed254db80397483a5",
|
||||
"#emergency": "e1ad578d25108e344808f30dfdaaf926",
|
||||
"#help": "dcc67fae2067046832af7b2b0b743165",
|
||||
"#info": "ce51a275a0a0507c43d1651d78292320",
|
||||
"#news": "ecadb1a7d803db8958bea1302ca6e8be",
|
||||
"#weather": "88f502554fee92a1625cfb311546e7cb",
|
||||
"#admin": "889334b7e486938c776dbdde120da9de",
|
||||
"#mod": "8e238c8f71e508c849fa1743783359c9",
|
||||
"#ops": "3b644de377c32c78793605a25aa915bf",
|
||||
"#dev": "d41bcba61e9dca7177c7b8533d23a0bc",
|
||||
"#debug": "fd7a60ed4796efcd1965c2de466105cb",
|
||||
"#bot": "eb50a1bcb3e4e5d7bf69a57c9dada211",
|
||||
"#bots": "0d24f5830b449668b8c221759b6c50d2",
|
||||
"#sf": "a32c1fcfda0def959c305e4cd803def1",
|
||||
"#bayarea": "7f9a5fd3070ad14e337ba100ca53a89b",
|
||||
"#sfo": "1ecce5970716c9415b0411bf190944b1",
|
||||
"#oakland": "c5a2f1d9f4433d041881447bf443084b",
|
||||
"#sanjose": "ce964c28170b1c043d06073e6fcd83a7",
|
||||
"#socal": "f4018307615ac79d2e5ef17bb44654d4",
|
||||
"#la": "21349a74e68588be435f33abed117d84",
|
||||
"#losangeles": "3dd9373dcea0294bd05ab067cc58e9d4",
|
||||
"#sandiego": "08623bbd90a96ecdc1f5c34e7292b35f",
|
||||
"#sacramento": "a6d6927f0b48762cf1346e2ae95cec14",
|
||||
"#nyc": "6e6554655f84ca26fbc09d81d15d6b96",
|
||||
"#newyork": "82a78024dd7edf6c9be298c919632e25",
|
||||
"#brooklyn": "bcc6e13acd87570dcee4d5d87ac711e6",
|
||||
"#manhattan": "56c90afc93b5bc55d1e3bdb1003dc2ef",
|
||||
"#queens": "55ff7df317b63d269d878e51d125ced3",
|
||||
"#seattle": "ef627a9bbbb549347fdb76bf0cd3bc14",
|
||||
"#portland": "45c6bc719c15b9fb809f48f594359877",
|
||||
"#pdx": "e75d6c892ff4d085e66701548c97acec",
|
||||
"#denver": "b24355a0d22ed2bf393ec530d75810b4",
|
||||
"#boulder": "eaa379d95ac9bbf857f499019ed0e8a5",
|
||||
"#colorado": "ef61c9e5a3286053746f7603044bcb08",
|
||||
"#austin": "b2e6f9af95d959734d71cdf90ca62533",
|
||||
"#dallas": "5b2efa4e2ad0a2b83c5486ca4dd244de",
|
||||
"#houston": "c001fbacad2d97676395ca37e2576345",
|
||||
"#texas": "c4a214e133de5e9ad276f99fdbd7216f",
|
||||
"#dfw": "5b7dc809ba579affccab5462c537244e",
|
||||
"#chicago": "c1c289b131e5222370cbc2048445844b",
|
||||
"#detroit": "bd01f26bf7d8c90952753157a41c61ac",
|
||||
"#minneapolis": "3283cca82b7b0ac50e8014c344cb8a86",
|
||||
"#stlouis": "f366422991a19e745f65096e59b43d51",
|
||||
"#boston": "9587d847a7208da684c89cc1f525bc03",
|
||||
"#philly": "9ff9182dd800e0be620dead724cbdf88",
|
||||
"#philadelphia": "963ad5382e8d910ce0872958c5b36e6a",
|
||||
"#dc": "0f3aa71fed514f5c16ebaf265ef05b2e",
|
||||
"#washingtondc": "0067d451cc79f26bc9924b3fc53f28d7",
|
||||
"#baltimore": "c5e649cacdc8fe661d5910d00c7c95ac",
|
||||
"#atlanta": "3c8f15665f99a349a97427e7c312ee0e",
|
||||
"#miami": "d81c566a5d337d588ebd250df6fc1b63",
|
||||
"#tampa": "8d10508c39d5d8e6c5e3e8fab41d1c09",
|
||||
"#orlando": "3553bdbd9b3a54da760624a27dcda156",
|
||||
"#florida": "c44bd74eac2c81dcb6dfb217727b05cd",
|
||||
"#phoenix": "027850d9410fa98809819c96644ec04c",
|
||||
"#tucson": "cf989ccea881cf5ddcf40d87bbfc441e",
|
||||
"#arizona": "8017183d8f9c01b660cd3663b8972e9d",
|
||||
"#vegas": "d45435b6467e7ded33c28f6796bf5183",
|
||||
"#lasvegas": "b82b823d6dcc845ead47cee8cfa758b3",
|
||||
"#nevada": "9dc06c13ed0875b4a1acd545653fef33",
|
||||
"#hawaii": "3fd57495820328594e1641d14583faa6",
|
||||
"#honolulu": "81a4f1ae399448b8c10f8e761ba4e216",
|
||||
"#maui": "cd08692b0cfbecfc06248bf8b8f10463",
|
||||
"#alaska": "2a5841192b151422baec71537b0b5238",
|
||||
"#anchorage": "2d87885e753b3231d33fd57dc53b5d69",
|
||||
"#london": "9881d2b7ab9105a41a8d0f6ba449447e",
|
||||
"#uk": "22b2eed34b5cc429ce1dc5e88635ff84",
|
||||
"#berlin": "8bffd7b0bf481d92afc625e409b88a16",
|
||||
"#germany": "0a834b4687fd4e09f72f6eeb3191ee25",
|
||||
"#paris": "d0fc2b1ec400eb8669010ce0311a00fb",
|
||||
"#france": "edcb362b38e74b99025a7e551d925d20",
|
||||
"#amsterdam": "d768f5a0aa65f8c54e4ea521bd49eb4f",
|
||||
"#netherlands": "cfc0a6c4004324e8adc99dfec1501943",
|
||||
"#tokyo": "c574cca64e441dc3a414ef8047e8054d",
|
||||
"#japan": "6c3db58db1c49d7e974971f675a66c89",
|
||||
"#sydney": "57fe5284a5b905835193868d9bdbe1e9",
|
||||
"#australia": "eadb84fa1da64c44b77c40fd11b9d78e",
|
||||
"#melbourne": "4d73731d9450ccb9673eb923c0f40af2",
|
||||
"#brisbane": "e4bd09784621decac600d0d4d857e3b2",
|
||||
"#toronto": "e0d3774dd1da4dc5c55d8bd731555334",
|
||||
"#vancouver": "16d6034be448ab86d11858cfa4c57c9c",
|
||||
"#canada": "8373bb1055f34164c6dd2663927cb6d9",
|
||||
"#montreal": "0c4c03b5fbea5b80f89e2a2a16ed3f40",
|
||||
"#calgary": "bbbb1ad23fe1cdd7739667418204a57d",
|
||||
"#mexico": "1e3806b6eccf8114ff7fc27ed8f84b0b",
|
||||
"#cdmx": "042268f5d791d342d8c50c065ef0c50b",
|
||||
"#brasil": "47d6242d2c1dd0f1e0eee4f0df64b2c1",
|
||||
"#brazil": "aac6e31471f27feac8da78793bed9690",
|
||||
"#ham": "5270db3979da687fa133fec6684cd952",
|
||||
"#radio": "266f225baced1b2a868dcc8e9c69a304",
|
||||
"#mesh": "5b664cde0b08b220612113db980650f3",
|
||||
"#lora": "0749ea267c6be7b54ca1dbbae7dba0aa",
|
||||
"#meshtastic": "73a2e13ff0dea9ed19b24b2ab753650f",
|
||||
"#meshcore": "2fa78a5aef618e7c2a78f0ab5c8869b3",
|
||||
"#offgrid": "aaa26662bebf122262692d0bca61dcef",
|
||||
"#prepper": "1eec1a7df7080a392cb490473c4a9920",
|
||||
"#preppers": "4877173813de9668b9ef33adbc1b8f37",
|
||||
"#survival": "e1f465ea51df09fc901389758f1c5f01",
|
||||
"#shtf": "9321638017bd7f42ca4468726cd06893",
|
||||
"#emcomm": "a9a49340642dfc4c562d7849b7c8a258",
|
||||
"#ares": "44419e394cde859e45710f288db939a8",
|
||||
"#cert": "828d37872695b8b47e537164fb1570cf",
|
||||
"#skywarn": "46e8175d5a3b373985eb471a3ad479f8",
|
||||
"#hike": "a8f9964431372ed34db9088d03362f6f",
|
||||
"#hiking": "2370a013053e384e5f18918bc2b26baf",
|
||||
"#camping": "c011b7c2abf33748cd9bd5a78c2b4955",
|
||||
"#outdoors": "b4c10e8ecfe10ef66cae6299ae29d488",
|
||||
"#trail": "9b7f5ade7e2bff2eaaeebfd0333401a5",
|
||||
"#bike": "22a682f2c0f3011aab4510c533278413",
|
||||
"#cycling": "795980fbfe059133a2fc47c2c210f127",
|
||||
"#wardrive": "4076c315c1ef385fa93f066027320fe5",
|
||||
"#wardriving": "e3c26491e9cd321e3a6be50d57d54acf",
|
||||
"#security": "b7123ea6c2dcbf7332a198ddd6612da9",
|
||||
"#infosec": "364aa6797508df1cf248c8983ec968bd",
|
||||
"#cyber": "bc435860170598cb9b1c7cc6938c8be5",
|
||||
"#hacker": "98010d08107a5bc0b7f41ce3c93cba99",
|
||||
"#hackers": "bb6c2edeb9a25b4f77398a687acfdb85",
|
||||
"#maker": "04cf78295deab8c76fa3236f8a87612e",
|
||||
"#makers": "4d8febe1979910912d7f8d11ca8ce689",
|
||||
"#diy": "2947bea0668269732f29808d2b9c8fed",
|
||||
"#electronics": "34d8645bda0ba7e4a7c78a1f9f3ed1ac",
|
||||
"#arduino": "ce8c596922eff9274f3b1e19ad296754",
|
||||
"#raspberry": "469bc476d8263b64b6f90ca868b04b91",
|
||||
"#esp32": "9f8aac4c48973b07bbdc47acbaea3e8e",
|
||||
"#linux": "02c74eda5d8ec8b9bb945049fb2f55c6",
|
||||
"#unix": "a4c8cd0f130f2f9801d1afa2d0a52a2f",
|
||||
"#tech": "5177b749eefbfab3c90a21b4e2518c5f",
|
||||
"#code": "2a8a567349cce15a48fd5d81709424f4",
|
||||
"#coding": "becdff6841b6f36610e97ac89c3a40ea",
|
||||
"#programming": "bce55c792ec60d79530a2eec9a8ede14",
|
||||
"#yo": "51f93a1e79f96333fe5d0c8eb3bed7c3",
|
||||
"#sup": "153ec8634ac55727e006e37c39b29f16",
|
||||
"#hello": "dc9fe3652402447479779b06609a22a5",
|
||||
"#hi": "e411034bd14b17b5e39a76b5a5b4f348",
|
||||
"#hey": "3972b5f0fe9a438f260ffc1d125eefdb",
|
||||
"#random": "bda30db2910b90b1bc5a608a1b5f0ee4",
|
||||
"#offtopic": "4bfd513d655d6e435bd8f4d6b863e500",
|
||||
"#memes": "5b36bb8722a8c1741127266299439cdd",
|
||||
"#fun": "ecfceab52a3d730051d2ce6adea30f78",
|
||||
"#music": "b025ab29b0a5f56fb68a474741d7a2cc",
|
||||
"#gaming": "3802c79121f195ea50ad9ab2aa2c402a",
|
||||
"#games": "ddffe2cfbf037caed279d02c41b74f5f",
|
||||
"#sports": "e8ee81f3aabf105d9ba2d2d4bd94fe4a",
|
||||
"#food": "7b4f27d6b5bbf5f8eb3bfc6f43770fdd",
|
||||
"#beer": "8fbe47b032102949554ac78fcd583560",
|
||||
"#coffee": "82cd4bd9e7dda8cae0854281246cc64f",
|
||||
"#queer": "5754476f162d93bbee3de0efba136860",
|
||||
"#lgbtq": "7d71d54a2bb4bbd7352322a59126d7fb",
|
||||
"#pride": "c732b12b15a5bca3cff2e39b7480baca",
|
||||
"#bookclub": "b803ab3fbb867737ab5b7d32914d7e67",
|
||||
"#books": "b2fdc9c9313dad4515527cade7d67021",
|
||||
"#reading": "46d531186b6148726a97a62324f8a356",
|
||||
"#film": "35ed875bfa83f29298e81f83c3c56c1e",
|
||||
"#movies": "15cea29e9e62887c767cabec8c601ebe",
|
||||
"#dogs": "e956e2b054b795c129a5daab4aad0be1",
|
||||
"#cats": "4aeaf541d243ea9f84abc406b7eba360",
|
||||
"#pets": "6f36ecba946c2eba3a7a9c694c53f23a",
|
||||
"#1": "0b0fa0b2280a09639e2059e56c8fa932",
|
||||
"#2": "b6b52c0e41dd6e18a31636deb586175b",
|
||||
"#3": "ea46599a238699be9409316928670559",
|
||||
"#4": "7e3cfd9c828a75671a34898112caa743",
|
||||
"#5": "7aff87c72ca0f13d288b3418c89f67e9",
|
||||
"#42": "2bccaf40951009e4203e2065b2a4bde0",
|
||||
"#69": "0285b036b9837b5babac54668e623ce0",
|
||||
"#420": "d4346dd20f9a85256cff48f33f46de0a",
|
||||
"#1337": "817d01d43c5960c1ceaf9a2467182675",
|
||||
"#a": "302d59c4f9e75750166105ea1d8b3673",
|
||||
"#b": "dd883973c3c017ed51c9e10fba7bca0b",
|
||||
"#c": "3732c64f873466d50e0badb3f8d79faf",
|
||||
"#aa": "e147f36926b7b509af9b41b65304dc30",
|
||||
"#ab": "7ee8192c80507041253e255dcc7e6f87",
|
||||
"#abc": "00e16e1d31c0ba2b3b3d17583bb2ac3b",
|
||||
"#norcal": "f2438510715f5d9d55eb4370664330f5",
|
||||
"#nocal": "819836f0049f5dca9596cd681d0cbab8",
|
||||
"#bay": "80e5fadb907564764eb09d2667a02638",
|
||||
"#eastbay": "4c2e48f600e4952346441278ac363432",
|
||||
"#southbay": "3c9e372b38917334d1091419f32bed8a",
|
||||
"#northbay": "8dfb7427cc1e5abab25bc16e8ae4373c",
|
||||
"#peninsula": "7f2ce5480359431d2f3ca259bf8bef68",
|
||||
"#marin": "a084727e9d2d2afcc73f49055c6f7764",
|
||||
"#pnw": "98059014c708581fbf0a398cfe8a486d",
|
||||
"#pacnw": "49cc714305cf0a61817a01114414d490",
|
||||
"#cascadia": "1313c4078af5c36040bec10115c04806",
|
||||
"#midwest": "cf7910dacee35da8b90da21a0e37fea7",
|
||||
"#northeast": "3b1ca0ae6003193eb9f91984eefef5dc",
|
||||
"#southeast": "7b4392ed3c3fdde98cdb167c2f0f2c8b",
|
||||
"#southwest": "e17c45726653035f7a90a6b57b5a0d57",
|
||||
"#socalmesh": "df9e74198a7c334964f18f200a065e33",
|
||||
"#sfmesh": "89454fcff893b5a2ecc16d886e9cf3b3",
|
||||
"#nycmesh": "021be2e194650cc5d3ea77618eea817f",
|
||||
"#atlmesh": "50b8266c71b3d3ee0253d462b34f6b2b",
|
||||
"#wx": "472dd8595b8fd0ab542b3e86a379a620",
|
||||
"#fire": "3d74a070077293ab66baf3aa724349ff",
|
||||
"#earthquake": "0c7082c04a1a90502a5f32fc6a9f6524",
|
||||
"#flood": "f2a0fd0abe4c9fc6865b5f8eebf319d6",
|
||||
"#tsunami": "153ddc83452935d8486ab3d34dd6d313",
|
||||
"#storm": "113761b9e31a5c30e0ee4fc78dc7310b",
|
||||
"#alert": "678c7d2e08c019e113ace03eaaa128ad",
|
||||
"#alerts": "b8212240d8b433b54db46906738e2094",
|
||||
"#sos": "9ed2c78bcd68ac7ce2a2fc3bb4045114",
|
||||
"#rescue": "8fefdc46995fd86cf1265a96e25e9be1",
|
||||
"#missing": "bb627c3e98fb103e54b203a11d2c1a8c",
|
||||
"#evac": "c20a9bf0eabefaf3dd3553bb5df19b61",
|
||||
"#shelter": "b11ebee14de380147b2f0b613c82ee84",
|
||||
"#default": "66e7fd8b7b4caa5dc98e752d43044d30",
|
||||
"#main": "512ba51f98c27b93cd2ff6fbc2c0fad1",
|
||||
"#primary": "3e417c7ce555fa7dcb705a94cbb358e4",
|
||||
"#secondary": "69abb13534f87ebfedc0d92797da1fbe",
|
||||
"#backup": "1fa50c46f15c60363567cae7982b8394",
|
||||
"#private": "bd072d2fbd62a89db08b2a9e6976cc36",
|
||||
"#secret": "be657c0527e122bf93bc735999cd7e0d",
|
||||
"#hidden": "f78143af9f1168c243729b1bf6bb3235",
|
||||
"#invite": "d46e531ee7d3b591fcf2dfc9e23e63e1",
|
||||
"#repeater": "289991a3077903263f2d31493887c651",
|
||||
"#repeaters": "89db441e2814dccf0dbd2e8cc5f501a3",
|
||||
"#node": "85cdc068443a7bf5b9435423c40dbefd",
|
||||
"#nodes": "d2b5d06216710d4dbef3de9d08168a53",
|
||||
"#gateway": "73feacb0c27f83b3d2db143823efb891",
|
||||
"#server": "11c4e843fc066c8bfe01719cfca1fb1e",
|
||||
"#gisborne": "d8b45a1eb52d0bd45655d5f2a72d571f",
|
||||
"#wellington": "c2da57d74f78996ad71bbe3e22446f16",
|
||||
"#auckland": "7c6f1c71a5a3d6823a1d7bfd2a349ac8",
|
||||
"#nz": "eb87ee8817ba71315ac7be9c733b523a",
|
||||
"#newzealand": "1870a961e4aa06f62b02b835efcacb71",
|
||||
"#spain": "49217e19fa0d5c28bd02fd6b688dd11d",
|
||||
"#madrid": "8886480d4b99f6882328d9068d0c6235",
|
||||
"#barcelona": "919a4a5d9522320ca9c95dceb92a5544",
|
||||
"#italy": "f8d4dd36f6b9476eb4cec18a1536b17d",
|
||||
"#rome": "4c3729aca56d948088bd25750e8d7d33",
|
||||
"#milan": "71cd9f729336b7a1110f77bb93c43e9e",
|
||||
"#sweden": "d6bcdf00baf5d2d981846d2b47ba5b42",
|
||||
"#stockholm": "889483e6b54ca5bf8cbb2af23e1ab7f0",
|
||||
"#norway": "0ed3e16f327a787d5ff4c4496bb4c4a9",
|
||||
"#oslo": "1a06f287bc45a8cf14a304f898cc1fea",
|
||||
"#finland": "cc012bb6718f447824c8ba7cd81b7fbd",
|
||||
"#helsinki": "6bd215dc2f6f8833a309eba8d4ba57d7",
|
||||
"#denmark": "7bb81ff9d3eb29f091d1d64e044b2a79",
|
||||
"#copenhagen": "76c1331a0d13d081988a379c60ad59bb",
|
||||
"#poland": "e63d27631c0f74aca88a6b91efcc7067",
|
||||
"#warsaw": "86afa156c7f0567c97ea03df07482888",
|
||||
"#czech": "0a15067478a2b8e74177c8fc68e4001e",
|
||||
"#prague": "564cb31b3895393f22f0f50354389334",
|
||||
"#austria": "faaa5ef01081222e319a8205357321f4",
|
||||
"#vienna": "8e52cd2b9ff13fb0030fd41714edc95e",
|
||||
"#switzerland": "8ad1ce57ad257627090ed28413c1f0b7",
|
||||
"#zurich": "95a7261009cdcb13d22e8f8d532f3ba5",
|
||||
"#portugal": "11f13d9d06c892574a277337967a7267",
|
||||
"#lisbon": "6c49a07fd953c5856df538d5dce0b19e",
|
||||
"#ireland": "1b2a12acc5db1517d9d407946756b1da",
|
||||
"#dublin": "8792638977132bc05a1f72d6bb913694",
|
||||
"#scotland": "f4fce403cfd56f7089920d065718f29c",
|
||||
"#edinburgh": "72d70a8e87b0e7f8072239d68ffccc9e",
|
||||
"#wales": "809573d8134fa262d284400a788f63d9",
|
||||
"#india": "bedb569c4d55038e801985e87bc311cb",
|
||||
"#mumbai": "408b4cecfb253c8150cadd5da8925b1f",
|
||||
"#delhi": "4974162f580211f17187d1a16cab2514",
|
||||
"#bangalore": "f87f1fcf72618fbb5d36642847859df0",
|
||||
"#korea": "38ea37fe7de4691145f8e200a3fc6976",
|
||||
"#seoul": "d6e3685ad1ce9d943c34594321eb3d75",
|
||||
"#taiwan": "489ff602625eb18ae5f457fb70e149cd",
|
||||
"#taipei": "257f2ea07b93df20b8ef8a69459cc541",
|
||||
"#singapore": "524771d953b40e8880e00d7250f02c42",
|
||||
"#malaysia": "a51ef9684d30551eb7fe4faa00c4dd64",
|
||||
"#thailand": "6ddc1b15fd53b35a8c1a7e8bb720d5ac",
|
||||
"#bangkok": "68b4467f7e1a99705143a6a5ebcfb8e8",
|
||||
"#vietnam": "9a6514b712cbc8a1be0663591c6a6e13",
|
||||
"#indonesia": "2be2d4470d8cc641ce69dfe6497a2842",
|
||||
"#jakarta": "5b931a38c29a24ec8395c23421248138",
|
||||
"#philippines": "74d160fb0ad7867295730d41351dd21a",
|
||||
"#manila": "48a840132b292a2dcde0ef0d10c3149b",
|
||||
"#southafrica": "35bb5bcec03c0c7ba256bdc948108a1c",
|
||||
"#capetown": "a16101e1fa482e43dcb4b35ab836b3f3",
|
||||
"#nairobi": "44cf6c94cdcb61d576c8855935997260",
|
||||
"#kenya": "5bde28964c3008a6741f593d3b70c78e",
|
||||
"#nigeria": "9a20897b3b223ee02ba9eebc43ac2300",
|
||||
"#lagos": "c1d000aa764a45ba0d992d0289137991",
|
||||
"#argentina": "22304ee269f9623972776a4d1d306afd",
|
||||
"#buenosaires": "43ece797139ce4051cf62568a6a28c2b",
|
||||
"#chile": "15f352f255947e485b845652791f3354",
|
||||
"#santiago": "9d7f9df716281124aa16d27a45b2ff5f",
|
||||
"#colombia": "bea223a8c1d13ed9638ee000ea3a6aca",
|
||||
"#bogota": "6d0864985b64350ce4cbfebf4979e970",
|
||||
"#peru": "7e6fc347bf29a4c128ac3156865bd521",
|
||||
"#lima": "5f167ce354eca08ab742463df10ef255"
|
||||
}
|
||||
@@ -1,5 +1,52 @@
|
||||
{
|
||||
"port": 3000,
|
||||
"apiKey": "your-secret-api-key-here",
|
||||
"https": {
|
||||
"cert": "/path/to/cert.pem",
|
||||
"key": "/path/to/key.pem"
|
||||
},
|
||||
"branding": {
|
||||
"siteName": "MeshCore Analyzer",
|
||||
"tagline": "Real-time MeshCore LoRa mesh network analyzer",
|
||||
"logoUrl": null,
|
||||
"faviconUrl": null
|
||||
},
|
||||
"theme": {
|
||||
"accent": "#4a9eff",
|
||||
"accentHover": "#6db3ff",
|
||||
"navBg": "#0f0f23",
|
||||
"navBg2": "#1a1a2e",
|
||||
"statusGreen": "#45644c",
|
||||
"statusYellow": "#b08b2d",
|
||||
"statusRed": "#b54a4a"
|
||||
},
|
||||
"nodeColors": {
|
||||
"repeater": "#dc2626",
|
||||
"companion": "#2563eb",
|
||||
"room": "#16a34a",
|
||||
"sensor": "#d97706",
|
||||
"observer": "#8b5cf6"
|
||||
},
|
||||
"home": {
|
||||
"heroTitle": "MeshCore Analyzer",
|
||||
"heroSubtitle": "Find your nodes to start monitoring them.",
|
||||
"steps": [
|
||||
{ "emoji": "📡", "title": "Connect", "description": "Link your node to the mesh" },
|
||||
{ "emoji": "🔍", "title": "Monitor", "description": "Watch packets flow in real-time" },
|
||||
{ "emoji": "📊", "title": "Analyze", "description": "Understand your network's health" }
|
||||
],
|
||||
"checklist": [
|
||||
{ "question": "How do I add my node?", "answer": "Search for your node name or paste your public key." },
|
||||
{ "question": "What regions are covered?", "answer": "Check the map page to see active observers and nodes." }
|
||||
],
|
||||
"footerLinks": [
|
||||
{ "label": "📦 Packets", "url": "#/packets" },
|
||||
{ "label": "🗺️ Network Map", "url": "#/map" },
|
||||
{ "label": "🔴 Live", "url": "#/live" },
|
||||
{ "label": "📡 All Nodes", "url": "#/nodes" },
|
||||
{ "label": "💬 Channels", "url": "#/channels" }
|
||||
]
|
||||
},
|
||||
"mqtt": {
|
||||
"broker": "mqtt://localhost:1883",
|
||||
"topic": "meshcore/+/+/packets"
|
||||
@@ -34,17 +81,27 @@
|
||||
}
|
||||
],
|
||||
"channelKeys": {
|
||||
"public": "8b3387e9c5cdea6ac9e5edbaa115cd72",
|
||||
"#test": "9cd8fcf22a47333b591d96a2b848b73f",
|
||||
"#sf": "a32c1fcfda0def959c305e4cd803def1",
|
||||
"#wardrive": "4076c315c1ef385fa93f066027320fe5",
|
||||
"#yo": "51f93a1e79f96333fe5d0c8eb3bed7c3",
|
||||
"#bot": "eb50a1bcb3e4e5d7bf69a57c9dada211",
|
||||
"#queer": "5754476f162d93bbee3de0efba136860",
|
||||
"#bookclub": "b803ab3fbb867737ab5b7d32914d7e67",
|
||||
"#shtf": "9321638017bd7f42ca4468726cd06893"
|
||||
"public": "8b3387e9c5cdea6ac9e5edbaa115cd72"
|
||||
},
|
||||
"hashChannels": [
|
||||
"#LongFast",
|
||||
"#test",
|
||||
"#sf",
|
||||
"#wardrive",
|
||||
"#yo",
|
||||
"#bot",
|
||||
"#queer",
|
||||
"#bookclub",
|
||||
"#shtf"
|
||||
],
|
||||
"defaultRegion": "SJC",
|
||||
"mapDefaults": {
|
||||
"center": [
|
||||
37.45,
|
||||
-122.0
|
||||
],
|
||||
"zoom": 9
|
||||
},
|
||||
"regions": {
|
||||
"SJC": "San Jose, US",
|
||||
"SFO": "San Francisco, US",
|
||||
@@ -72,6 +129,10 @@
|
||||
"invalidationDebounce": 30,
|
||||
"_comment": "All values in seconds. Server uses these directly. Client fetches via /api/config/cache."
|
||||
},
|
||||
"liveMap": {
|
||||
"propagationBufferMs": 5000,
|
||||
"_comment": "How long (ms) to buffer incoming observations of the same packet before animating. Mesh packets propagate through multiple paths and arrive at different observers over several seconds. This window collects all observations of a single transmission so the live map can animate them simultaneously as one realistic propagation event. Set higher for wide meshes with many observers, lower for snappier animations. 5000ms captures ~95% of observations for a typical mesh."
|
||||
},
|
||||
"packetStore": {
|
||||
"maxMemoryMB": 1024,
|
||||
"estimatedPacketBytes": 450,
|
||||
|
||||
167
db.js
167
db.js
@@ -10,28 +10,21 @@ if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.pragma('wal_autocheckpoint = 0'); // Disable auto-checkpoint — manual checkpoint on timer to avoid random event loop spikes
|
||||
|
||||
// --- Migration: drop legacy tables (replaced by transmissions + observations in v2.3.0) ---
|
||||
// Drop paths first (has FK to packets)
|
||||
const legacyTables = ['paths', 'packets'];
|
||||
for (const t of legacyTables) {
|
||||
const exists = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`).get(t);
|
||||
if (exists) {
|
||||
console.log(`[migration] Dropping legacy table: ${t}`);
|
||||
db.exec(`DROP TABLE IF EXISTS ${t}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Schema ---
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS packets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
raw_hex TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
observer_id TEXT,
|
||||
observer_name TEXT,
|
||||
direction TEXT,
|
||||
snr REAL,
|
||||
rssi REAL,
|
||||
score INTEGER,
|
||||
hash TEXT,
|
||||
route_type INTEGER,
|
||||
payload_type INTEGER,
|
||||
payload_version INTEGER,
|
||||
path_json TEXT,
|
||||
decoded_json TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
public_key TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
@@ -59,16 +52,6 @@ db.exec(`
|
||||
noise_floor INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paths (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
packet_id INTEGER REFERENCES packets(id),
|
||||
hop_index INTEGER,
|
||||
node_hash TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_packets_timestamp ON packets(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_packets_hash ON packets(hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_packets_payload_type ON packets(payload_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_last_seen ON nodes(last_seen);
|
||||
CREATE INDEX IF NOT EXISTS idx_observers_last_seen ON observers(last_seen);
|
||||
|
||||
@@ -106,6 +89,21 @@ db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_observations_transmission_id ON observations(transmission_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_observations_observer_id ON observations(observer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_observations_timestamp ON observations(timestamp);
|
||||
DROP INDEX IF EXISTS idx_observations_dedup;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_observations_dedup ON observations(hash, observer_id, COALESCE(path_json, ''));
|
||||
|
||||
-- Clean up legacy duplicates (same hash+observer+path, keep lowest id)
|
||||
DELETE FROM observations WHERE id NOT IN (
|
||||
SELECT MIN(id) FROM observations GROUP BY hash, observer_id, COALESCE(path_json, '')
|
||||
);
|
||||
|
||||
CREATE VIEW IF NOT EXISTS packets_v AS
|
||||
SELECT o.id, t.raw_hex, o.timestamp, o.observer_id, o.observer_name,
|
||||
o.direction, o.snr, o.rssi, o.score, t.hash, t.route_type,
|
||||
t.payload_type, t.payload_version, o.path_json, t.decoded_json,
|
||||
t.created_at
|
||||
FROM observations o
|
||||
JOIN transmissions t ON t.id = o.transmission_id;
|
||||
`);
|
||||
|
||||
// --- Migrations for existing DBs ---
|
||||
@@ -118,13 +116,21 @@ for (const col of ['model', 'firmware', 'client_version', 'radio', 'battery_mv',
|
||||
}
|
||||
}
|
||||
|
||||
// --- Cleanup corrupted nodes on startup ---
|
||||
// Remove nodes with obviously invalid data (short pubkeys, control chars in names, etc.)
|
||||
{
|
||||
const cleaned = db.prepare(`
|
||||
DELETE FROM nodes WHERE
|
||||
length(public_key) < 16
|
||||
OR public_key GLOB '*[^0-9a-fA-F]*'
|
||||
OR (lat IS NOT NULL AND (lat < -90 OR lat > 90))
|
||||
OR (lon IS NOT NULL AND (lon < -180 OR lon > 180))
|
||||
`).run();
|
||||
if (cleaned.changes > 0) console.log(`[cleanup] Removed ${cleaned.changes} corrupted node(s) from DB`);
|
||||
}
|
||||
|
||||
// --- Prepared statements ---
|
||||
const stmts = {
|
||||
insertPacket: db.prepare(`
|
||||
INSERT INTO packets (raw_hex, timestamp, observer_id, observer_name, direction, snr, rssi, score, hash, route_type, payload_type, payload_version, path_json, decoded_json)
|
||||
VALUES (@raw_hex, @timestamp, @observer_id, @observer_name, @direction, @snr, @rssi, @score, @hash, @route_type, @payload_type, @payload_version, @path_json, @decoded_json)
|
||||
`),
|
||||
insertPath: db.prepare(`INSERT INTO paths (packet_id, hop_index, node_hash) VALUES (?, ?, ?)`),
|
||||
upsertNode: db.prepare(`
|
||||
INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
||||
VALUES (@public_key, @name, @role, @lat, @lon, @last_seen, @first_seen, 1)
|
||||
@@ -167,18 +173,17 @@ const stmts = {
|
||||
uptime_secs = COALESCE(@uptime_secs, uptime_secs),
|
||||
noise_floor = COALESCE(@noise_floor, noise_floor)
|
||||
`),
|
||||
getPacket: db.prepare(`SELECT * FROM packets WHERE id = ?`),
|
||||
getPathsForPacket: db.prepare(`SELECT * FROM paths WHERE packet_id = ? ORDER BY hop_index`),
|
||||
getPacket: db.prepare(`SELECT * FROM packets_v WHERE id = ?`),
|
||||
getNode: db.prepare(`SELECT * FROM nodes WHERE public_key = ?`),
|
||||
getRecentPacketsForNode: db.prepare(`
|
||||
SELECT * FROM packets WHERE decoded_json LIKE ? OR decoded_json LIKE ? OR decoded_json LIKE ? OR decoded_json LIKE ?
|
||||
SELECT * FROM packets_v WHERE decoded_json LIKE ? OR decoded_json LIKE ? OR decoded_json LIKE ? OR decoded_json LIKE ?
|
||||
ORDER BY timestamp DESC LIMIT 20
|
||||
`),
|
||||
getObservers: db.prepare(`SELECT * FROM observers ORDER BY last_seen DESC`),
|
||||
countPackets: db.prepare(`SELECT COUNT(*) as count FROM packets`),
|
||||
countPackets: db.prepare(`SELECT COUNT(*) as count FROM observations`),
|
||||
countNodes: db.prepare(`SELECT COUNT(*) as count FROM nodes`),
|
||||
countObservers: db.prepare(`SELECT COUNT(*) as count FROM observers`),
|
||||
countRecentPackets: db.prepare(`SELECT COUNT(*) as count FROM packets WHERE timestamp > ?`),
|
||||
countRecentPackets: db.prepare(`SELECT COUNT(*) as count FROM observations WHERE timestamp > ?`),
|
||||
getTransmissionByHash: db.prepare(`SELECT id, first_seen FROM transmissions WHERE hash = ?`),
|
||||
insertTransmission: db.prepare(`
|
||||
INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json)
|
||||
@@ -186,33 +191,13 @@ const stmts = {
|
||||
`),
|
||||
updateTransmissionFirstSeen: db.prepare(`UPDATE transmissions SET first_seen = @first_seen WHERE id = @id`),
|
||||
insertObservation: db.prepare(`
|
||||
INSERT INTO observations (transmission_id, hash, observer_id, observer_name, direction, snr, rssi, score, path_json, timestamp)
|
||||
INSERT OR IGNORE INTO observations (transmission_id, hash, observer_id, observer_name, direction, snr, rssi, score, path_json, timestamp)
|
||||
VALUES (@transmission_id, @hash, @observer_id, @observer_name, @direction, @snr, @rssi, @score, @path_json, @timestamp)
|
||||
`),
|
||||
};
|
||||
|
||||
// --- Helper functions ---
|
||||
|
||||
function insertPacket(data) {
|
||||
const d = {
|
||||
raw_hex: data.raw_hex,
|
||||
timestamp: data.timestamp || new Date().toISOString(),
|
||||
observer_id: data.observer_id || null,
|
||||
observer_name: data.observer_name || null,
|
||||
direction: data.direction || null,
|
||||
snr: data.snr ?? null,
|
||||
rssi: data.rssi ?? null,
|
||||
score: data.score ?? null,
|
||||
hash: data.hash || null,
|
||||
route_type: data.route_type ?? null,
|
||||
payload_type: data.payload_type ?? null,
|
||||
payload_version: data.payload_version ?? null,
|
||||
path_json: data.path_json || null,
|
||||
decoded_json: data.decoded_json || null,
|
||||
};
|
||||
return stmts.insertPacket.run(d).lastInsertRowid;
|
||||
}
|
||||
|
||||
function insertTransmission(data) {
|
||||
const hash = data.hash;
|
||||
if (!hash) return null; // Can't deduplicate without a hash
|
||||
@@ -256,15 +241,6 @@ function insertTransmission(data) {
|
||||
return { transmissionId, observationId: obsResult.lastInsertRowid };
|
||||
}
|
||||
|
||||
function insertPath(packetId, hops) {
|
||||
const tx = db.transaction((hops) => {
|
||||
for (let i = 0; i < hops.length; i++) {
|
||||
stmts.insertPath.run(packetId, i, hops[i]);
|
||||
}
|
||||
});
|
||||
tx(hops);
|
||||
}
|
||||
|
||||
function upsertNode(data) {
|
||||
const now = new Date().toISOString();
|
||||
stmts.upsertNode.run({
|
||||
@@ -322,15 +298,20 @@ function getPackets({ limit = 50, offset = 0, type, route, hash, since } = {}) {
|
||||
if (hash) { where.push('hash = @hash'); params.hash = hash; }
|
||||
if (since) { where.push('timestamp > @since'); params.since = since; }
|
||||
const clause = where.length ? 'WHERE ' + where.join(' AND ') : '';
|
||||
const rows = db.prepare(`SELECT * FROM packets ${clause} ORDER BY timestamp DESC LIMIT @limit OFFSET @offset`).all({ ...params, limit, offset });
|
||||
const total = db.prepare(`SELECT COUNT(*) as count FROM packets ${clause}`).get(params).count;
|
||||
const rows = db.prepare(`SELECT * FROM packets_v ${clause} ORDER BY timestamp DESC LIMIT @limit OFFSET @offset`).all({ ...params, limit, offset });
|
||||
const total = db.prepare(`SELECT COUNT(*) as count FROM packets_v ${clause}`).get(params).count;
|
||||
return { rows, total };
|
||||
}
|
||||
|
||||
function getTransmission(id) {
|
||||
try {
|
||||
return db.prepare('SELECT * FROM transmissions WHERE id = ?').get(id) || null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function getPacket(id) {
|
||||
const packet = stmts.getPacket.get(id);
|
||||
if (!packet) return null;
|
||||
packet.paths = stmts.getPathsForPacket.all(id);
|
||||
return packet;
|
||||
}
|
||||
|
||||
@@ -370,9 +351,9 @@ function getStats() {
|
||||
totalTransmissions = db.prepare('SELECT COUNT(*) as count FROM transmissions').get().count;
|
||||
} catch {}
|
||||
return {
|
||||
totalPackets: stmts.countPackets.get().count,
|
||||
totalPackets: totalTransmissions || stmts.countPackets.get().count,
|
||||
totalTransmissions,
|
||||
totalObservations: stmts.countPackets.get().count, // legacy packets = observations
|
||||
totalObservations: stmts.countPackets.get().count,
|
||||
totalNodes: stmts.countNodes.get().count,
|
||||
totalObservers: stmts.countObservers.get().count,
|
||||
packetsLastHour: stmts.countRecentPackets.get(oneHourAgo).count,
|
||||
@@ -386,7 +367,7 @@ function seed() {
|
||||
|
||||
upsertObserver({ id: 'obs-seed-001', name: 'Seed Observer', iata: 'UNK', last_seen: now, first_seen: now });
|
||||
|
||||
const pktId = insertPacket({
|
||||
insertTransmission({
|
||||
raw_hex: rawHex,
|
||||
timestamp: now,
|
||||
observer_id: 'obs-seed-001',
|
||||
@@ -403,8 +384,6 @@ function seed() {
|
||||
decoded_json: JSON.stringify({ type: 'ADVERT', name: 'Test Repeater', role: 'repeater', lat: 0, lon: 0 }),
|
||||
});
|
||||
|
||||
insertPath(pktId, ['A1B2', 'C3D4']);
|
||||
|
||||
upsertNode({
|
||||
public_key: 'seed-test-pubkey',
|
||||
name: 'Test Repeater',
|
||||
@@ -454,7 +433,7 @@ function getNodeHealth(pubkey) {
|
||||
const observers = db.prepare(`
|
||||
SELECT observer_id, observer_name,
|
||||
AVG(snr) as avgSnr, AVG(rssi) as avgRssi, COUNT(*) as packetCount
|
||||
FROM packets
|
||||
FROM packets_v
|
||||
WHERE ${whereClause} AND observer_id IS NOT NULL
|
||||
GROUP BY observer_id
|
||||
ORDER BY packetCount DESC
|
||||
@@ -462,20 +441,20 @@ function getNodeHealth(pubkey) {
|
||||
|
||||
// Stats
|
||||
const packetsToday = db.prepare(`
|
||||
SELECT COUNT(*) as count FROM packets WHERE ${whereClause} AND timestamp > @since
|
||||
SELECT COUNT(*) as count FROM packets_v WHERE ${whereClause} AND timestamp > @since
|
||||
`).get({ ...params, since: todayISO }).count;
|
||||
|
||||
const avgStats = db.prepare(`
|
||||
SELECT AVG(snr) as avgSnr FROM packets WHERE ${whereClause}
|
||||
SELECT AVG(snr) as avgSnr FROM packets_v WHERE ${whereClause}
|
||||
`).get(params);
|
||||
|
||||
const lastHeard = db.prepare(`
|
||||
SELECT MAX(timestamp) as lastHeard FROM packets WHERE ${whereClause}
|
||||
SELECT MAX(timestamp) as lastHeard FROM packets_v WHERE ${whereClause}
|
||||
`).get(params).lastHeard;
|
||||
|
||||
// Avg hops from path_json
|
||||
const pathRows = db.prepare(`
|
||||
SELECT path_json FROM packets WHERE ${whereClause} AND path_json IS NOT NULL
|
||||
SELECT path_json FROM packets_v WHERE ${whereClause} AND path_json IS NOT NULL
|
||||
`).all(params);
|
||||
|
||||
let totalHops = 0, hopCount = 0;
|
||||
@@ -488,12 +467,12 @@ function getNodeHealth(pubkey) {
|
||||
const avgHops = hopCount > 0 ? Math.round(totalHops / hopCount) : 0;
|
||||
|
||||
const totalPackets = db.prepare(`
|
||||
SELECT COUNT(*) as count FROM packets WHERE ${whereClause}
|
||||
SELECT COUNT(*) as count FROM packets_v WHERE ${whereClause}
|
||||
`).get(params).count;
|
||||
|
||||
// Recent 10 packets
|
||||
const recentPackets = db.prepare(`
|
||||
SELECT * FROM packets WHERE ${whereClause} ORDER BY timestamp DESC LIMIT 10
|
||||
SELECT * FROM packets_v WHERE ${whereClause} ORDER BY timestamp DESC LIMIT 10
|
||||
`).all(params);
|
||||
|
||||
return {
|
||||
@@ -524,31 +503,31 @@ function getNodeAnalytics(pubkey, days) {
|
||||
// Activity timeline
|
||||
const activityTimeline = db.prepare(`
|
||||
SELECT strftime('%Y-%m-%dT%H:00:00Z', timestamp) as bucket, COUNT(*) as count
|
||||
FROM packets WHERE ${timeWhere} GROUP BY bucket ORDER BY bucket
|
||||
FROM packets_v WHERE ${timeWhere} GROUP BY bucket ORDER BY bucket
|
||||
`).all(params);
|
||||
|
||||
// SNR trend
|
||||
const snrTrend = db.prepare(`
|
||||
SELECT timestamp, snr, rssi, observer_id, observer_name
|
||||
FROM packets WHERE ${timeWhere} AND snr IS NOT NULL ORDER BY timestamp
|
||||
FROM packets_v WHERE ${timeWhere} AND snr IS NOT NULL ORDER BY timestamp
|
||||
`).all(params);
|
||||
|
||||
// Packet type breakdown
|
||||
const packetTypeBreakdown = db.prepare(`
|
||||
SELECT payload_type, COUNT(*) as count FROM packets WHERE ${timeWhere} GROUP BY payload_type
|
||||
SELECT payload_type, COUNT(*) as count FROM packets_v WHERE ${timeWhere} GROUP BY payload_type
|
||||
`).all(params);
|
||||
|
||||
// Observer coverage
|
||||
const observerCoverage = db.prepare(`
|
||||
SELECT observer_id, observer_name, COUNT(*) as packetCount,
|
||||
AVG(snr) as avgSnr, AVG(rssi) as avgRssi, MIN(timestamp) as firstSeen, MAX(timestamp) as lastSeen
|
||||
FROM packets WHERE ${timeWhere} AND observer_id IS NOT NULL
|
||||
FROM packets_v WHERE ${timeWhere} AND observer_id IS NOT NULL
|
||||
GROUP BY observer_id ORDER BY packetCount DESC
|
||||
`).all(params);
|
||||
|
||||
// Hop distribution
|
||||
const pathRows = db.prepare(`
|
||||
SELECT path_json FROM packets WHERE ${timeWhere} AND path_json IS NOT NULL
|
||||
SELECT path_json FROM packets_v WHERE ${timeWhere} AND path_json IS NOT NULL
|
||||
`).all(params);
|
||||
|
||||
const hopCounts = {};
|
||||
@@ -570,7 +549,7 @@ function getNodeAnalytics(pubkey, days) {
|
||||
|
||||
// Peer interactions from decoded_json
|
||||
const decodedRows = db.prepare(`
|
||||
SELECT decoded_json, timestamp FROM packets WHERE ${timeWhere} AND decoded_json IS NOT NULL
|
||||
SELECT decoded_json, timestamp FROM packets_v WHERE ${timeWhere} AND decoded_json IS NOT NULL
|
||||
`).all(params);
|
||||
|
||||
const peerMap = {};
|
||||
@@ -596,11 +575,11 @@ function getNodeAnalytics(pubkey, days) {
|
||||
const uptimeHeatmap = db.prepare(`
|
||||
SELECT CAST(strftime('%w', timestamp) AS INTEGER) as dayOfWeek,
|
||||
CAST(strftime('%H', timestamp) AS INTEGER) as hour, COUNT(*) as count
|
||||
FROM packets WHERE ${timeWhere} GROUP BY dayOfWeek, hour
|
||||
FROM packets_v WHERE ${timeWhere} GROUP BY dayOfWeek, hour
|
||||
`).all(params);
|
||||
|
||||
// Computed stats
|
||||
const totalPackets = db.prepare(`SELECT COUNT(*) as count FROM packets WHERE ${timeWhere}`).get(params).count;
|
||||
const totalPackets = db.prepare(`SELECT COUNT(*) as count FROM packets_v WHERE ${timeWhere}`).get(params).count;
|
||||
const uniqueObservers = observerCoverage.length;
|
||||
const uniquePeers = peerInteractions.length;
|
||||
const avgPacketsPerDay = days > 0 ? Math.round(totalPackets / days * 10) / 10 : totalPackets;
|
||||
@@ -612,7 +591,7 @@ function getNodeAnalytics(pubkey, days) {
|
||||
|
||||
// Longest silence
|
||||
const timestamps = db.prepare(`
|
||||
SELECT timestamp FROM packets WHERE ${timeWhere} ORDER BY timestamp
|
||||
SELECT timestamp FROM packets_v WHERE ${timeWhere} ORDER BY timestamp
|
||||
`).all(params).map(r => new Date(r.timestamp).getTime());
|
||||
|
||||
let longestSilenceMs = 0, longestSilenceStart = null;
|
||||
@@ -652,4 +631,4 @@ function getNodeAnalytics(pubkey, days) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { db, insertPacket, insertTransmission, insertPath, upsertNode, upsertObserver, updateObserverStatus, getPackets, getPacket, getNodes, getNode, getObservers, getStats, seed, searchNodes, getNodeHealth, getNodeAnalytics };
|
||||
module.exports = { db, insertTransmission, upsertNode, upsertObserver, updateObserverStatus, getPackets, getPacket, getTransmission, getNodes, getNode, getObservers, getStats, seed, searchNodes, getNodeHealth, getNodeAnalytics };
|
||||
|
||||
53
decoder.js
53
decoder.js
@@ -265,7 +265,58 @@ function decodePacket(hexString, channelKeys) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { decodePacket, ROUTE_TYPES, PAYLOAD_TYPES };
|
||||
// --- ADVERT validation ---
|
||||
|
||||
const VALID_ROLES = new Set(['repeater', 'companion', 'room', 'sensor']);
|
||||
|
||||
/**
|
||||
* Validate decoded ADVERT data before upserting into the DB.
|
||||
* Returns { valid: true } or { valid: false, reason: string }.
|
||||
*/
|
||||
function validateAdvert(advert) {
|
||||
if (!advert || advert.error) return { valid: false, reason: advert?.error || 'null advert' };
|
||||
|
||||
// pubkey must be at least 16 hex chars (8 bytes) and not all zeros
|
||||
const pk = advert.pubKey || '';
|
||||
if (pk.length < 16) return { valid: false, reason: `pubkey too short (${pk.length} hex chars)` };
|
||||
if (/^0+$/.test(pk)) return { valid: false, reason: 'pubkey is all zeros' };
|
||||
|
||||
// lat/lon must be in valid ranges if present
|
||||
if (advert.lat != null) {
|
||||
if (!Number.isFinite(advert.lat) || advert.lat < -90 || advert.lat > 90) {
|
||||
return { valid: false, reason: `invalid lat: ${advert.lat}` };
|
||||
}
|
||||
}
|
||||
if (advert.lon != null) {
|
||||
if (!Number.isFinite(advert.lon) || advert.lon < -180 || advert.lon > 180) {
|
||||
return { valid: false, reason: `invalid lon: ${advert.lon}` };
|
||||
}
|
||||
}
|
||||
|
||||
// name must not contain control chars (except space) or be garbage
|
||||
if (advert.name != null) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(advert.name)) {
|
||||
return { valid: false, reason: 'name contains control characters' };
|
||||
}
|
||||
// Reject names that are mostly non-printable or suspiciously long
|
||||
if (advert.name.length > 64) {
|
||||
return { valid: false, reason: `name too long (${advert.name.length} chars)` };
|
||||
}
|
||||
}
|
||||
|
||||
// role derivation check — flags byte should produce a known role
|
||||
if (advert.flags) {
|
||||
const role = advert.flags.repeater ? 'repeater' : advert.flags.room ? 'room' : advert.flags.sensor ? 'sensor' : 'companion';
|
||||
if (!VALID_ROLES.has(role)) return { valid: false, reason: `unknown role: ${role}` };
|
||||
}
|
||||
|
||||
// timestamp: decoded but not currently used for node storage — skip validation
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
module.exports = { decodePacket, validateAdvert, ROUTE_TYPES, PAYLOAD_TYPES, VALID_ROLES };
|
||||
|
||||
// --- Tests ---
|
||||
if (require.main === module) {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Copy example config if no config.json exists
|
||||
# Copy example config if no config.json exists at app root (not bind-mounted)
|
||||
if [ ! -f /app/config.json ]; then
|
||||
echo "[entrypoint] No config.json found, copying from config.example.json"
|
||||
cp /app/config.example.json /app/config.json
|
||||
fi
|
||||
|
||||
# theme.json: check data/ volume (admin-editable on host)
|
||||
if [ -f /app/data/theme.json ]; then
|
||||
ln -sf /app/data/theme.json /app/theme.json
|
||||
fi
|
||||
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
|
||||
|
||||
215
docs/CUSTOMIZATION.md
Normal file
215
docs/CUSTOMIZATION.md
Normal file
@@ -0,0 +1,215 @@
|
||||
# Customizing Your Instance
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Open your analyzer in a browser
|
||||
2. Go to **Tools → Customize**
|
||||
3. Change colors, branding, home page content
|
||||
4. Click **💾 Download theme.json**
|
||||
5. Put the file next to your `config.json` on the server
|
||||
6. Refresh the page — done
|
||||
|
||||
No restart needed. The server picks up changes to `theme.json` on every page load.
|
||||
|
||||
## Where Does theme.json Go?
|
||||
|
||||
**Next to config.json.** However you deployed, put them side by side.
|
||||
|
||||
**Docker:**
|
||||
```bash
|
||||
# Add to your docker run command:
|
||||
-v /path/to/theme.json:/app/theme.json:ro
|
||||
|
||||
# Or if you bind-mount the data directory:
|
||||
# Just put theme.json in that directory
|
||||
```
|
||||
|
||||
**Bare metal / PM2 / systemd:**
|
||||
```bash
|
||||
# Same directory as server.js and config.json
|
||||
cp theme.json /path/to/meshcore-analyzer/
|
||||
```
|
||||
|
||||
Check the server logs on startup — it tells you where it's looking:
|
||||
```
|
||||
[theme] Loaded from /app/theme.json
|
||||
```
|
||||
or:
|
||||
```
|
||||
[theme] No theme.json found. Place it next to config.json or in data/ to customize.
|
||||
```
|
||||
|
||||
## What Can You Customize?
|
||||
|
||||
### Branding
|
||||
```json
|
||||
{
|
||||
"branding": {
|
||||
"siteName": "Bay Area Mesh",
|
||||
"tagline": "Community LoRa mesh network",
|
||||
"logoUrl": "/my-logo.svg",
|
||||
"faviconUrl": "/my-favicon.png"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Logo replaces the 🍄 emoji in the nav bar (renders at 24px height). Favicon replaces the browser tab icon. Use a URL path for files in the `public/` folder, or a full URL for external images.
|
||||
|
||||
### Theme Colors (Light Mode)
|
||||
```json
|
||||
{
|
||||
"theme": {
|
||||
"accent": "#ff6b6b",
|
||||
"navBg": "#1a1a2e",
|
||||
"navText": "#ffffff",
|
||||
"background": "#f4f5f7",
|
||||
"text": "#1a1a2e",
|
||||
"statusGreen": "#22c55e",
|
||||
"statusYellow": "#eab308",
|
||||
"statusRed": "#ef4444"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Theme Colors (Dark Mode)
|
||||
```json
|
||||
{
|
||||
"themeDark": {
|
||||
"accent": "#57f2a5",
|
||||
"navBg": "#0a0a1a",
|
||||
"background": "#0f0f23",
|
||||
"text": "#e2e8f0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Only include colors you want to change — everything else stays default.
|
||||
|
||||
### All Available Theme Keys
|
||||
|
||||
| Key | What It Controls |
|
||||
|-----|-----------------|
|
||||
| `accent` | Buttons, links, active tabs, badges, charts |
|
||||
| `accentHover` | Hover state for accent elements |
|
||||
| `navBg` | Nav bar background (gradient start) |
|
||||
| `navBg2` | Nav bar gradient end |
|
||||
| `navText` | Nav bar text and links |
|
||||
| `navTextMuted` | Inactive nav links, stats |
|
||||
| `background` | Main page background |
|
||||
| `text` | Primary text color |
|
||||
| `textMuted` | Labels, timestamps, secondary text |
|
||||
| `statusGreen` | Healthy/online indicators |
|
||||
| `statusYellow` | Warning/degraded indicators |
|
||||
| `statusRed` | Error/offline indicators |
|
||||
| `border` | Dividers, table borders |
|
||||
| `surface1` | Card backgrounds |
|
||||
| `surface2` | Nested panels |
|
||||
| `cardBg` | Detail panels, modals |
|
||||
| `contentBg` | Content area behind cards |
|
||||
| `detailBg` | Side panels, packet detail |
|
||||
| `inputBg` | Text inputs, dropdowns |
|
||||
| `rowStripe` | Alternating table rows |
|
||||
| `rowHover` | Table row hover |
|
||||
| `selectedBg` | Selected/active rows |
|
||||
| `font` | Body font stack |
|
||||
| `mono` | Monospace font (hex, hashes, code) |
|
||||
|
||||
### Node Role Colors
|
||||
```json
|
||||
{
|
||||
"nodeColors": {
|
||||
"repeater": "#dc2626",
|
||||
"companion": "#2563eb",
|
||||
"room": "#16a34a",
|
||||
"sensor": "#d97706",
|
||||
"observer": "#8b5cf6"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Affects map markers, packet path badges, node lists, and legends.
|
||||
|
||||
### Packet Type Colors
|
||||
```json
|
||||
{
|
||||
"typeColors": {
|
||||
"ADVERT": "#22c55e",
|
||||
"GRP_TXT": "#3b82f6",
|
||||
"TXT_MSG": "#f59e0b",
|
||||
"ACK": "#6b7280",
|
||||
"REQUEST": "#a855f7",
|
||||
"RESPONSE": "#06b6d4",
|
||||
"TRACE": "#ec4899",
|
||||
"PATH": "#14b8a6",
|
||||
"ANON_REQ": "#f43f5e"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Affects packet badges, feed dots, map markers, and chart colors.
|
||||
|
||||
### Home Page Content
|
||||
```json
|
||||
{
|
||||
"home": {
|
||||
"heroTitle": "Welcome to Bay Area Mesh",
|
||||
"heroSubtitle": "Find your nodes to start monitoring them.",
|
||||
"steps": [
|
||||
{ "emoji": "📡", "title": "Connect", "description": "Link your node to the mesh" },
|
||||
{ "emoji": "🔍", "title": "Monitor", "description": "Watch packets flow in real-time" }
|
||||
],
|
||||
"checklist": [
|
||||
{ "question": "How do I add my node?", "answer": "Search by name or paste your public key." }
|
||||
],
|
||||
"footerLinks": [
|
||||
{ "label": "📦 Packets", "url": "#/packets" },
|
||||
{ "label": "🗺️ Map", "url": "#/map" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Step descriptions and checklist answers support Markdown (`**bold**`, `*italic*`, `` `code` ``, `[links](url)`).
|
||||
|
||||
## User vs Admin Themes
|
||||
|
||||
- **Admin theme** (`theme.json`): Default for all users. Edit the file, refresh.
|
||||
- **User theme** (browser): Each user can override the admin theme via Tools → Customize → "Save as my theme". Stored in localStorage, only affects that browser.
|
||||
|
||||
User themes take priority over admin themes. Users can reset their personal theme to go back to the admin default.
|
||||
|
||||
## Full Example
|
||||
|
||||
```json
|
||||
{
|
||||
"branding": {
|
||||
"siteName": "Bay Area MeshCore",
|
||||
"tagline": "Community mesh monitoring for the Bay Area",
|
||||
"logoUrl": "https://example.com/logo.svg"
|
||||
},
|
||||
"theme": {
|
||||
"accent": "#2563eb",
|
||||
"statusGreen": "#16a34a",
|
||||
"statusYellow": "#ca8a04",
|
||||
"statusRed": "#dc2626"
|
||||
},
|
||||
"themeDark": {
|
||||
"accent": "#60a5fa",
|
||||
"navBg": "#0a0a1a",
|
||||
"background": "#111827"
|
||||
},
|
||||
"nodeColors": {
|
||||
"repeater": "#ef4444",
|
||||
"observer": "#a855f7"
|
||||
},
|
||||
"home": {
|
||||
"heroTitle": "Bay Area MeshCore",
|
||||
"heroSubtitle": "Real-time monitoring for our community mesh network.",
|
||||
"steps": [
|
||||
{ "emoji": "💬", "title": "Join our Discord", "description": "Get help and connect with local operators." },
|
||||
{ "emoji": "📡", "title": "Advertise your node", "description": "Send an ADVERT so the network can see you." },
|
||||
{ "emoji": "🗺️", "title": "Check the map", "description": "Find repeaters near you." }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
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.1.0",
|
||||
"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": {
|
||||
|
||||
175
packet-store.js
175
packet-store.js
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
class PacketStore {
|
||||
constructor(dbModule, config = {}) {
|
||||
this.dbModule = dbModule; // The full db module (has .db, .insertPacket, .getPacket)
|
||||
this.dbModule = dbModule; // The full db module (has .db, .insertTransmission, .getPacket)
|
||||
this.db = dbModule.db; // Raw better-sqlite3 instance for queries
|
||||
this.maxBytes = (config.maxMemoryMB || 1024) * 1024 * 1024;
|
||||
this.estPacketBytes = config.estimatedPacketBytes || 450;
|
||||
@@ -27,10 +27,10 @@ class PacketStore {
|
||||
this.byHash = new Map(); // hash → transmission object (1:1)
|
||||
this.byObserver = new Map(); // observer_id → [observation objects]
|
||||
this.byNode = new Map(); // pubkey → [transmission objects] (deduped)
|
||||
this.byTransmission = new Map(); // hash → transmission object (same refs as byHash)
|
||||
|
||||
// Track which hashes are indexed per node pubkey (avoid dupes in byNode)
|
||||
this._nodeHashIndex = new Map(); // pubkey → Set<hash>
|
||||
this._advertByObserver = new Map(); // pubkey → Set<observer_id> (ADVERT-only, for region filtering)
|
||||
|
||||
this.loaded = false;
|
||||
this.stats = { totalLoaded: 0, totalObservations: 0, evicted: 0, inserts: 0, queries: 0 };
|
||||
@@ -77,9 +77,9 @@ class PacketStore {
|
||||
`).all();
|
||||
|
||||
for (const row of rows) {
|
||||
if (this.packets.length >= this.maxPackets && !this.byTransmission.has(row.hash)) break;
|
||||
if (this.packets.length >= this.maxPackets && !this.byHash.has(row.hash)) break;
|
||||
|
||||
let tx = this.byTransmission.get(row.hash);
|
||||
let tx = this.byHash.get(row.hash);
|
||||
if (!tx) {
|
||||
tx = {
|
||||
id: row.transmission_id,
|
||||
@@ -100,7 +100,7 @@ class PacketStore {
|
||||
path_json: null,
|
||||
direction: null,
|
||||
};
|
||||
this.byTransmission.set(row.hash, tx);
|
||||
this.byHash.set(row.hash, tx);
|
||||
this.byHash.set(row.hash, tx);
|
||||
this.packets.push(tx);
|
||||
this.byTxId.set(tx.id, tx);
|
||||
@@ -126,6 +126,10 @@ class PacketStore {
|
||||
route_type: row.route_type,
|
||||
};
|
||||
|
||||
// Dedup: skip if same observer + same path already loaded
|
||||
const isDupeLoad = tx.observations.some(o => o.observer_id === obs.observer_id && (o.path_json || '') === (obs.path_json || ''));
|
||||
if (isDupeLoad) continue;
|
||||
|
||||
tx.observations.push(obs);
|
||||
tx.observation_count++;
|
||||
|
||||
@@ -151,12 +155,39 @@ class PacketStore {
|
||||
this.stats.totalObservations++;
|
||||
}
|
||||
}
|
||||
|
||||
// Post-load: set each transmission's observer/path to the EARLIEST observation
|
||||
for (const tx of this.packets) {
|
||||
if (tx.observations.length > 0) {
|
||||
let earliest = tx.observations[0];
|
||||
for (let i = 1; i < tx.observations.length; i++) {
|
||||
if (tx.observations[i].timestamp < earliest.timestamp) earliest = tx.observations[i];
|
||||
}
|
||||
tx.observer_id = earliest.observer_id;
|
||||
tx.observer_name = earliest.observer_name;
|
||||
tx.snr = earliest.snr;
|
||||
tx.rssi = earliest.rssi;
|
||||
tx.path_json = earliest.path_json;
|
||||
tx.direction = earliest.direction;
|
||||
}
|
||||
}
|
||||
|
||||
// Post-load: build ADVERT-by-observer index (needs all observations loaded first)
|
||||
for (const tx of this.packets) {
|
||||
if (tx.payload_type === 4 && tx.decoded_json) {
|
||||
try {
|
||||
const d = JSON.parse(tx.decoded_json);
|
||||
if (d.pubKey) this._indexAdvertObservers(d.pubKey, tx);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
console.log(`[PacketStore] ADVERT observer index: ${this._advertByObserver.size} nodes tracked`);
|
||||
}
|
||||
|
||||
/** Fallback: load from legacy packets table */
|
||||
_loadLegacy() {
|
||||
const rows = this.db.prepare(
|
||||
'SELECT * FROM packets ORDER BY timestamp DESC'
|
||||
'SELECT * FROM packets_v ORDER BY timestamp DESC'
|
||||
).all();
|
||||
|
||||
for (const row of rows) {
|
||||
@@ -167,7 +198,7 @@ class PacketStore {
|
||||
|
||||
/** Index a legacy packet row (old flat structure) — builds transmission + observation */
|
||||
_indexLegacy(pkt) {
|
||||
let tx = this.byTransmission.get(pkt.hash);
|
||||
let tx = this.byHash.get(pkt.hash);
|
||||
if (!tx) {
|
||||
tx = {
|
||||
id: pkt.id,
|
||||
@@ -187,7 +218,7 @@ class PacketStore {
|
||||
path_json: pkt.path_json,
|
||||
direction: pkt.direction,
|
||||
};
|
||||
this.byTransmission.set(pkt.hash, tx);
|
||||
this.byHash.set(pkt.hash, tx);
|
||||
this.byHash.set(pkt.hash, tx);
|
||||
this.packets.push(tx);
|
||||
this.byTxId.set(tx.id, tx);
|
||||
@@ -197,6 +228,9 @@ class PacketStore {
|
||||
if (pkt.timestamp < tx.first_seen) {
|
||||
tx.first_seen = pkt.timestamp;
|
||||
tx.timestamp = pkt.timestamp;
|
||||
tx.observer_id = pkt.observer_id;
|
||||
tx.observer_name = pkt.observer_name;
|
||||
tx.path_json = pkt.path_json;
|
||||
}
|
||||
|
||||
const obs = {
|
||||
@@ -215,6 +249,10 @@ class PacketStore {
|
||||
decoded_json: pkt.decoded_json,
|
||||
route_type: pkt.route_type,
|
||||
};
|
||||
// Dedup: skip if same observer + same path already recorded for this transmission
|
||||
const isDupe = tx.observations.some(o => o.observer_id === obs.observer_id && (o.path_json || '') === (obs.path_json || ''));
|
||||
if (isDupe) return tx;
|
||||
|
||||
tx.observations.push(obs);
|
||||
tx.observation_count++;
|
||||
|
||||
@@ -239,7 +277,7 @@ class PacketStore {
|
||||
if (decoded.srcPubKey) keys.add(decoded.srcPubKey);
|
||||
for (const k of keys) {
|
||||
if (!this._nodeHashIndex.has(k)) this._nodeHashIndex.set(k, new Set());
|
||||
if (this._nodeHashIndex.get(k).has(tx.hash)) continue; // already indexed
|
||||
if (this._nodeHashIndex.get(k).has(tx.hash)) continue;
|
||||
this._nodeHashIndex.get(k).add(tx.hash);
|
||||
if (!this.byNode.has(k)) this.byNode.set(k, []);
|
||||
this.byNode.get(k).push(tx);
|
||||
@@ -247,12 +285,32 @@ class PacketStore {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** Track which observers saw an ADVERT from a given pubkey */
|
||||
_indexAdvertObservers(pubkey, tx) {
|
||||
if (!this._advertByObserver.has(pubkey)) this._advertByObserver.set(pubkey, new Set());
|
||||
const s = this._advertByObserver.get(pubkey);
|
||||
for (const obs of tx.observations) {
|
||||
if (obs.observer_id) s.add(obs.observer_id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Get node pubkeys whose ADVERTs were seen by any of the given observer IDs */
|
||||
getNodesByAdvertObservers(observerIds) {
|
||||
const result = new Set();
|
||||
for (const [pubkey, observers] of this._advertByObserver) {
|
||||
for (const obsId of observerIds) {
|
||||
if (observers.has(obsId)) { result.add(pubkey); break; }
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Remove oldest transmissions when over memory limit */
|
||||
_evict() {
|
||||
while (this.packets.length > this.maxPackets) {
|
||||
const old = this.packets.pop();
|
||||
this.byHash.delete(old.hash);
|
||||
this.byTransmission.delete(old.hash);
|
||||
this.byHash.delete(old.hash);
|
||||
this.byTxId.delete(old.id);
|
||||
// Remove observations from byId and byObserver
|
||||
for (const obs of old.observations) {
|
||||
@@ -269,14 +327,34 @@ class PacketStore {
|
||||
|
||||
/** Insert a new packet (to both memory and SQLite) */
|
||||
insert(packetData) {
|
||||
const id = this.dbModule.insertPacket(packetData);
|
||||
const row = this.dbModule.getPacket(id);
|
||||
if (row && !this.sqliteOnly) {
|
||||
// Write to normalized tables and get the transmission ID
|
||||
const txResult = this.dbModule.insertTransmission ? this.dbModule.insertTransmission(packetData) : null;
|
||||
const transmissionId = txResult ? txResult.transmissionId : null;
|
||||
const observationId = txResult ? txResult.observationId : null;
|
||||
|
||||
// Build row directly from packetData — avoids view ID mismatch issues
|
||||
const row = {
|
||||
id: observationId,
|
||||
raw_hex: packetData.raw_hex,
|
||||
hash: packetData.hash,
|
||||
timestamp: packetData.timestamp,
|
||||
route_type: packetData.route_type,
|
||||
payload_type: packetData.payload_type,
|
||||
payload_version: packetData.payload_version,
|
||||
decoded_json: packetData.decoded_json,
|
||||
observer_id: packetData.observer_id,
|
||||
observer_name: packetData.observer_name,
|
||||
snr: packetData.snr,
|
||||
rssi: packetData.rssi,
|
||||
path_json: packetData.path_json,
|
||||
direction: packetData.direction,
|
||||
};
|
||||
if (!this.sqliteOnly) {
|
||||
// Update or create transmission in memory
|
||||
let tx = this.byTransmission.get(row.hash);
|
||||
let tx = this.byHash.get(row.hash);
|
||||
if (!tx) {
|
||||
tx = {
|
||||
id: row.id,
|
||||
id: transmissionId || row.id,
|
||||
raw_hex: row.raw_hex,
|
||||
hash: row.hash,
|
||||
first_seen: row.timestamp,
|
||||
@@ -293,16 +371,19 @@ class PacketStore {
|
||||
path_json: row.path_json,
|
||||
direction: row.direction,
|
||||
};
|
||||
this.byTransmission.set(row.hash, tx);
|
||||
this.byHash.set(row.hash, tx);
|
||||
this.byHash.set(row.hash, tx);
|
||||
this.packets.unshift(tx); // newest first
|
||||
this.byTxId.set(tx.id, tx);
|
||||
this._indexByNode(tx);
|
||||
} else {
|
||||
// Update first_seen if earlier
|
||||
// Update first_seen if earlier — also update observer + path to match
|
||||
if (row.timestamp < tx.first_seen) {
|
||||
tx.first_seen = row.timestamp;
|
||||
tx.timestamp = row.timestamp;
|
||||
tx.observer_id = row.observer_id;
|
||||
tx.observer_name = row.observer_name;
|
||||
tx.path_json = row.path_json;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,8 +404,12 @@ class PacketStore {
|
||||
decoded_json: row.decoded_json,
|
||||
route_type: row.route_type,
|
||||
};
|
||||
tx.observations.push(obs);
|
||||
tx.observation_count++;
|
||||
// Dedup: skip if same observer + same path already recorded for this transmission
|
||||
const isDupe = tx.observations.some(o => o.observer_id === obs.observer_id && (o.path_json || '') === (obs.path_json || ''));
|
||||
if (!isDupe) {
|
||||
tx.observations.push(obs);
|
||||
tx.observation_count++;
|
||||
}
|
||||
|
||||
// Update transmission's display fields if this is first observation
|
||||
if (tx.observations.length === 1) {
|
||||
@@ -342,10 +427,22 @@ class PacketStore {
|
||||
}
|
||||
|
||||
this.stats.totalObservations++;
|
||||
|
||||
// Update ADVERT observer index for live ingestion
|
||||
if (tx.payload_type === 4 && obs.observer_id && tx.decoded_json) {
|
||||
try {
|
||||
const d = JSON.parse(tx.decoded_json);
|
||||
if (d.pubKey) {
|
||||
if (!this._advertByObserver.has(d.pubKey)) this._advertByObserver.set(d.pubKey, new Set());
|
||||
this._advertByObserver.get(d.pubKey).add(obs.observer_id);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
this._evict();
|
||||
this.stats.inserts++;
|
||||
}
|
||||
return id;
|
||||
return observationId || transmissionId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -414,8 +511,9 @@ class PacketStore {
|
||||
}
|
||||
if (observer) results = this._transmissionsForObserver(observer, results);
|
||||
if (hash) {
|
||||
const tx = this.byHash.get(hash);
|
||||
results = tx ? results.filter(p => p.hash === hash) : [];
|
||||
const h = hash.toLowerCase();
|
||||
const tx = this.byHash.get(h);
|
||||
results = tx ? results.filter(p => p.hash === h) : [];
|
||||
}
|
||||
if (since) results = results.filter(p => p.timestamp > since);
|
||||
if (until) results = results.filter(p => p.timestamp < until);
|
||||
@@ -465,7 +563,7 @@ class PacketStore {
|
||||
for (const o of obs) {
|
||||
if (!seen.has(o.hash)) {
|
||||
seen.add(o.hash);
|
||||
const tx = this.byTransmission.get(o.hash);
|
||||
const tx = this.byHash.get(o.hash);
|
||||
if (tx) result.push(tx);
|
||||
}
|
||||
}
|
||||
@@ -506,7 +604,7 @@ class PacketStore {
|
||||
/** Get timestamps for sparkline */
|
||||
getTimestamps(since) {
|
||||
if (this.sqliteOnly) {
|
||||
return this.db.prepare('SELECT timestamp FROM packets WHERE timestamp > ? ORDER BY timestamp ASC').all(since).map(r => r.timestamp);
|
||||
return this.db.prepare('SELECT timestamp FROM packets_v WHERE timestamp > ? ORDER BY timestamp ASC').all(since).map(r => r.timestamp);
|
||||
}
|
||||
const results = [];
|
||||
for (const p of this.packets) {
|
||||
@@ -518,7 +616,7 @@ class PacketStore {
|
||||
|
||||
/** Get a single packet by ID — checks observation IDs first (backward compat) */
|
||||
getById(id) {
|
||||
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets WHERE id = ?').get(id) || null;
|
||||
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets_v WHERE id = ?').get(id) || null;
|
||||
return this.byId.get(id) || null;
|
||||
}
|
||||
|
||||
@@ -530,20 +628,21 @@ class PacketStore {
|
||||
|
||||
/** Get all siblings of a packet (same hash) — returns observations array */
|
||||
getSiblings(hash) {
|
||||
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets WHERE hash = ? ORDER BY timestamp DESC').all(hash);
|
||||
const tx = this.byTransmission.get(hash);
|
||||
const h = hash.toLowerCase();
|
||||
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets_v WHERE hash = ? ORDER BY timestamp DESC').all(h);
|
||||
const tx = this.byHash.get(h);
|
||||
return tx ? tx.observations : [];
|
||||
}
|
||||
|
||||
/** Get all transmissions (backward compat — returns packets array) */
|
||||
all() {
|
||||
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets ORDER BY timestamp DESC').all();
|
||||
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets_v ORDER BY timestamp DESC').all();
|
||||
return this.packets;
|
||||
}
|
||||
|
||||
/** Get all transmissions matching a filter function */
|
||||
filter(fn) {
|
||||
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets ORDER BY timestamp DESC').all().filter(fn);
|
||||
if (this.sqliteOnly) return this.db.prepare('SELECT * FROM packets_v ORDER BY timestamp DESC').all().filter(fn);
|
||||
return this.packets.filter(fn);
|
||||
}
|
||||
|
||||
@@ -560,7 +659,7 @@ class PacketStore {
|
||||
byHash: this.byHash.size,
|
||||
byObserver: this.byObserver.size,
|
||||
byNode: this.byNode.size,
|
||||
byTransmission: this.byTransmission.size,
|
||||
advertByObserver: this._advertByObserver.size,
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -571,14 +670,14 @@ class PacketStore {
|
||||
if (type !== undefined) { where.push('payload_type = ?'); params.push(Number(type)); }
|
||||
if (route !== undefined) { where.push('route_type = ?'); params.push(Number(route)); }
|
||||
if (observer) { where.push('observer_id = ?'); params.push(observer); }
|
||||
if (hash) { where.push('hash = ?'); params.push(hash); }
|
||||
if (hash) { where.push('hash = ?'); params.push(hash.toLowerCase()); }
|
||||
if (since) { where.push('timestamp > ?'); params.push(since); }
|
||||
if (until) { where.push('timestamp < ?'); params.push(until); }
|
||||
if (region) { where.push('observer_id IN (SELECT id FROM observers WHERE iata = ?)'); params.push(region); }
|
||||
if (node) { try { const nr = this.db.prepare('SELECT public_key FROM nodes WHERE public_key = ? OR name = ? LIMIT 1').get(node, node); const pk = nr ? nr.public_key : node; where.push('(decoded_json LIKE ? OR id IN (SELECT packet_id FROM paths WHERE node_hash = ?))'); params.push('%' + pk + '%', pk.substring(0, 8)); } catch(e) { where.push('decoded_json LIKE ?'); params.push('%' + node + '%'); } }
|
||||
if (node) { try { const nr = this.db.prepare('SELECT public_key FROM nodes WHERE public_key = ? OR name = ? LIMIT 1').get(node, node); const pk = nr ? nr.public_key : node; where.push('decoded_json LIKE ?'); params.push('%' + pk + '%'); } catch(e) { where.push('decoded_json LIKE ?'); params.push('%' + node + '%'); } }
|
||||
const w = where.length ? 'WHERE ' + where.join(' AND ') : '';
|
||||
const total = this.db.prepare(`SELECT COUNT(*) as c FROM packets ${w}`).get(...params).c;
|
||||
const packets = this.db.prepare(`SELECT * FROM packets ${w} ORDER BY timestamp ${order === 'ASC' ? 'ASC' : 'DESC'} LIMIT ? OFFSET ?`).all(...params, limit, offset);
|
||||
const total = this.db.prepare(`SELECT COUNT(*) as c FROM packets_v ${w}`).get(...params).c;
|
||||
const packets = this.db.prepare(`SELECT * FROM packets_v ${w} ORDER BY timestamp ${order === 'ASC' ? 'ASC' : 'DESC'} LIMIT ? OFFSET ?`).all(...params, limit, offset);
|
||||
return { packets, total };
|
||||
}
|
||||
|
||||
@@ -588,21 +687,21 @@ class PacketStore {
|
||||
if (type !== undefined) { where.push('payload_type = ?'); params.push(Number(type)); }
|
||||
if (route !== undefined) { where.push('route_type = ?'); params.push(Number(route)); }
|
||||
if (observer) { where.push('observer_id = ?'); params.push(observer); }
|
||||
if (hash) { where.push('hash = ?'); params.push(hash); }
|
||||
if (hash) { where.push('hash = ?'); params.push(hash.toLowerCase()); }
|
||||
if (since) { where.push('timestamp > ?'); params.push(since); }
|
||||
if (until) { where.push('timestamp < ?'); params.push(until); }
|
||||
if (region) { where.push('observer_id IN (SELECT id FROM observers WHERE iata = ?)'); params.push(region); }
|
||||
if (node) { try { const nr = this.db.prepare('SELECT public_key FROM nodes WHERE public_key = ? OR name = ? LIMIT 1').get(node, node); const pk = nr ? nr.public_key : node; where.push('(decoded_json LIKE ? OR id IN (SELECT packet_id FROM paths WHERE node_hash = ?))'); params.push('%' + pk + '%', pk.substring(0, 8)); } catch(e) { where.push('decoded_json LIKE ?'); params.push('%' + node + '%'); } }
|
||||
if (node) { try { const nr = this.db.prepare('SELECT public_key FROM nodes WHERE public_key = ? OR name = ? LIMIT 1').get(node, node); const pk = nr ? nr.public_key : node; where.push('decoded_json LIKE ?'); params.push('%' + pk + '%'); } catch(e) { where.push('decoded_json LIKE ?'); params.push('%' + node + '%'); } }
|
||||
const w = where.length ? 'WHERE ' + where.join(' AND ') : '';
|
||||
|
||||
const sql = `SELECT hash, COUNT(*) as count, COUNT(DISTINCT observer_id) as observer_count,
|
||||
MAX(timestamp) as latest, MIN(observer_id) as observer_id, MIN(observer_name) as observer_name,
|
||||
MIN(path_json) as path_json, MIN(payload_type) as payload_type, MIN(raw_hex) as raw_hex,
|
||||
MIN(decoded_json) as decoded_json
|
||||
FROM packets ${w} GROUP BY hash ORDER BY latest DESC LIMIT ? OFFSET ?`;
|
||||
FROM packets_v ${w} GROUP BY hash ORDER BY latest DESC LIMIT ? OFFSET ?`;
|
||||
const packets = this.db.prepare(sql).all(...params, limit, offset);
|
||||
|
||||
const countSql = `SELECT COUNT(DISTINCT hash) as c FROM packets ${w}`;
|
||||
const countSql = `SELECT COUNT(DISTINCT hash) as c FROM packets_v ${w}`;
|
||||
const total = this.db.prepare(countSql).get(...params).c;
|
||||
return { packets, total };
|
||||
}
|
||||
|
||||
@@ -3,8 +3,17 @@
|
||||
|
||||
(function () {
|
||||
let _analyticsData = {};
|
||||
const sf = (v, d) => (v != null ? v.toFixed(d) : '–'); // safe toFixed
|
||||
function esc(s) { return s ? String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"') : ''; }
|
||||
|
||||
// --- Status color helpers (read from CSS variables for theme support) ---
|
||||
function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
|
||||
function statusGreen() { return cssVar('--status-green') || '#22c55e'; }
|
||||
function statusYellow() { return cssVar('--status-yellow') || '#eab308'; }
|
||||
function statusRed() { return cssVar('--status-red') || '#ef4444'; }
|
||||
function accentColor() { return cssVar('--accent') || '#4a9eff'; }
|
||||
function snrColor(snr) { return snr > 6 ? statusGreen() : snr > 0 ? statusYellow() : statusRed(); }
|
||||
|
||||
// --- SVG helpers ---
|
||||
function sparkSvg(data, color, w = 120, h = 32) {
|
||||
if (!data.length) return '';
|
||||
@@ -65,6 +74,7 @@
|
||||
<div class="analytics-header">
|
||||
<h2>📊 Mesh Analytics</h2>
|
||||
<p class="text-muted">Deep dive into your mesh network data</p>
|
||||
<div id="analyticsRegionFilter" class="region-filter-container"></div>
|
||||
<div class="analytics-tabs" id="analyticsTabs">
|
||||
<button class="tab-btn active" data-tab="overview">Overview</button>
|
||||
<button class="tab-btn" data-tab="rf">RF / Signal</button>
|
||||
@@ -74,6 +84,7 @@
|
||||
<button class="tab-btn" data-tab="collisions">Hash Collisions</button>
|
||||
<button class="tab-btn" data-tab="subpaths">Route Patterns</button>
|
||||
<button class="tab-btn" data-tab="nodes">Nodes</button>
|
||||
<button class="tab-btn" data-tab="distance">Distance</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="analyticsContent" class="analytics-content">
|
||||
@@ -89,9 +100,13 @@
|
||||
if (!btn) return;
|
||||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
renderTab(btn.dataset.tab);
|
||||
_currentTab = btn.dataset.tab;
|
||||
renderTab(_currentTab);
|
||||
});
|
||||
|
||||
RegionFilter.init(document.getElementById('analyticsRegionFilter'));
|
||||
RegionFilter.onChange(function () { loadAnalytics(); });
|
||||
|
||||
// Delegated click/keyboard handler for clickable table rows
|
||||
const analyticsContent = document.getElementById('analyticsContent');
|
||||
if (analyticsContent) {
|
||||
@@ -106,16 +121,24 @@
|
||||
analyticsContent.addEventListener('keydown', handler);
|
||||
}
|
||||
|
||||
loadAnalytics();
|
||||
}
|
||||
|
||||
let _currentTab = 'overview';
|
||||
|
||||
async function loadAnalytics() {
|
||||
try {
|
||||
_analyticsData = {};
|
||||
const rqs = RegionFilter.regionQueryString();
|
||||
const sep = rqs ? '?' + rqs.slice(1) : '';
|
||||
const [hashData, rfData, topoData, chanData] = await Promise.all([
|
||||
api('/analytics/hash-sizes', { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/rf', { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/topology', { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/channels', { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/hash-sizes' + sep, { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/rf' + sep, { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/topology' + sep, { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/channels' + sep, { ttl: CLIENT_TTL.analyticsRF }),
|
||||
]);
|
||||
_analyticsData = { hashData, rfData, topoData, chanData };
|
||||
renderTab('overview');
|
||||
renderTab(_currentTab);
|
||||
} catch (e) {
|
||||
document.getElementById('analyticsContent').innerHTML =
|
||||
`<div class="text-muted" role="alert" aria-live="polite" style="padding:40px">Failed to load: ${e.message}</div>`;
|
||||
@@ -134,6 +157,7 @@
|
||||
case 'collisions': await renderCollisionTab(el, d.hashData); break;
|
||||
case 'subpaths': await renderSubpaths(el); break;
|
||||
case 'nodes': await renderNodesTab(el); break;
|
||||
case 'distance': await renderDistanceTab(el); break;
|
||||
}
|
||||
// Auto-apply column resizing to all analytics tables
|
||||
requestAnimationFrame(() => {
|
||||
@@ -163,17 +187,17 @@
|
||||
<div class="stat-label">Unique Nodes</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${rf.snr.avg.toFixed(1)} dB</div>
|
||||
<div class="stat-value">${sf(rf.snr.avg, 1)} dB</div>
|
||||
<div class="stat-label">Avg SNR</div>
|
||||
<div class="stat-detail">${rf.snr.min.toFixed(1)} to ${rf.snr.max.toFixed(1)}</div>
|
||||
<div class="stat-detail">${sf(rf.snr.min, 1)} to ${sf(rf.snr.max, 1)}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${rf.rssi.avg.toFixed(0)} dBm</div>
|
||||
<div class="stat-value">${sf(rf.rssi.avg, 0)} dBm</div>
|
||||
<div class="stat-label">Avg RSSI</div>
|
||||
<div class="stat-detail">${rf.rssi.min} to ${rf.rssi.max}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${topo.avgHops.toFixed(1)}</div>
|
||||
<div class="stat-value">${sf(topo.avgHops, 1)}</div>
|
||||
<div class="stat-label">Avg Hops</div>
|
||||
<div class="stat-detail">max ${topo.maxHops}</div>
|
||||
</div>
|
||||
@@ -231,8 +255,8 @@
|
||||
|
||||
// ===================== RF / SIGNAL =====================
|
||||
function renderRF(el, rf) {
|
||||
const snrHist = histogram(rf.snrValues, 20, '#22c55e');
|
||||
const rssiHist = histogram(rf.rssiValues, 20, '#3b82f6');
|
||||
const snrHist = histogram(rf.snrValues, 20, statusGreen());
|
||||
const rssiHist = histogram(rf.rssiValues, 20, accentColor());
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="analytics-row">
|
||||
@@ -241,11 +265,11 @@
|
||||
<p class="text-muted">Signal-to-Noise Ratio (higher = cleaner signal)</p>
|
||||
${snrHist.svg}
|
||||
<div class="rf-stats">
|
||||
<span>Min: <strong>${rf.snr.min.toFixed(1)} dB</strong></span>
|
||||
<span>Mean: <strong>${rf.snr.avg.toFixed(1)} dB</strong></span>
|
||||
<span>Median: <strong>${rf.snr.median.toFixed(1)} dB</strong></span>
|
||||
<span>Max: <strong>${rf.snr.max.toFixed(1)} dB</strong></span>
|
||||
<span>σ: <strong>${rf.snr.stddev.toFixed(1)} dB</strong></span>
|
||||
<span>Min: <strong>${sf(rf.snr.min, 1)} dB</strong></span>
|
||||
<span>Mean: <strong>${sf(rf.snr.avg, 1)} dB</strong></span>
|
||||
<span>Median: <strong>${sf(rf.snr.median, 1)} dB</strong></span>
|
||||
<span>Max: <strong>${sf(rf.snr.max, 1)} dB</strong></span>
|
||||
<span>σ: <strong>${sf(rf.snr.stddev, 1)} dB</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="analytics-card flex-1">
|
||||
@@ -254,10 +278,10 @@
|
||||
${rssiHist.svg}
|
||||
<div class="rf-stats">
|
||||
<span>Min: <strong>${rf.rssi.min} dBm</strong></span>
|
||||
<span>Mean: <strong>${rf.rssi.avg.toFixed(0)} dBm</strong></span>
|
||||
<span>Mean: <strong>${sf(rf.rssi.avg, 0)} dBm</strong></span>
|
||||
<span>Median: <strong>${rf.rssi.median} dBm</strong></span>
|
||||
<span>Max: <strong>${rf.rssi.max} dBm</strong></span>
|
||||
<span>σ: <strong>${rf.rssi.stddev.toFixed(1)} dBm</strong></span>
|
||||
<span>σ: <strong>${sf(rf.rssi.stddev, 1)} dBm</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -313,20 +337,21 @@
|
||||
svg += `<text x="${pad-4}" y="${y+3}" text-anchor="end" font-size="9" fill="var(--text-muted)">${rssi}</text>`;
|
||||
}
|
||||
// Quality zones
|
||||
const _sg = statusGreen(), _sy = statusYellow(), _sr = statusRed();
|
||||
const zones = [
|
||||
{ label: 'Excellent', snr: [6, 15], rssi: [-80, -5], color: '#22c55e20' },
|
||||
{ label: 'Good', snr: [0, 6], rssi: [-100, -80], color: '#f59e0b15' },
|
||||
{ label: 'Weak', snr: [-12, 0], rssi: [-130, -100], color: '#ef444410' },
|
||||
{ label: 'Excellent', snr: [6, 15], rssi: [-80, -5], color: _sg + '20' },
|
||||
{ label: 'Good', snr: [0, 6], rssi: [-100, -80], color: _sy + '15' },
|
||||
{ label: 'Weak', snr: [-12, 0], rssi: [-130, -100], color: _sr + '10' },
|
||||
];
|
||||
// Define patterns for color-blind accessibility
|
||||
svg += `<defs>`;
|
||||
svg += `<pattern id="pat-excellent" patternUnits="userSpaceOnUse" width="8" height="8"><line x1="0" y1="8" x2="8" y2="0" stroke="#22c55e" stroke-width="0.5" opacity="0.4"/></pattern>`;
|
||||
svg += `<pattern id="pat-good" patternUnits="userSpaceOnUse" width="6" height="6"><circle cx="3" cy="3" r="1" fill="#f59e0b" opacity="0.4"/></pattern>`;
|
||||
svg += `<pattern id="pat-weak" patternUnits="userSpaceOnUse" width="8" height="8"><line x1="0" y1="0" x2="8" y2="8" stroke="#ef4444" stroke-width="0.5" opacity="0.4"/><line x1="0" y1="8" x2="8" y2="0" stroke="#ef4444" stroke-width="0.5" opacity="0.4"/></pattern>`;
|
||||
svg += `<pattern id="pat-excellent" patternUnits="userSpaceOnUse" width="8" height="8"><line x1="0" y1="8" x2="8" y2="0" stroke="${_sg}" stroke-width="0.5" opacity="0.4"/></pattern>`;
|
||||
svg += `<pattern id="pat-good" patternUnits="userSpaceOnUse" width="6" height="6"><circle cx="3" cy="3" r="1" fill="${_sy}" opacity="0.4"/></pattern>`;
|
||||
svg += `<pattern id="pat-weak" patternUnits="userSpaceOnUse" width="8" height="8"><line x1="0" y1="0" x2="8" y2="8" stroke="${_sr}" stroke-width="0.5" opacity="0.4"/><line x1="0" y1="8" x2="8" y2="0" stroke="${_sr}" stroke-width="0.5" opacity="0.4"/></pattern>`;
|
||||
svg += `</defs>`;
|
||||
const zonePatterns = { 'Excellent': 'pat-excellent', 'Good': 'pat-good', 'Weak': 'pat-weak' };
|
||||
const zoneDash = { 'Excellent': '4,2', 'Good': '6,3', 'Weak': '2,2' };
|
||||
const zoneBorder = { 'Excellent': '#22c55e', 'Good': '#f59e0b', 'Weak': '#ef4444' };
|
||||
const zoneBorder = { 'Excellent': _sg, 'Good': _sy, 'Weak': _sr };
|
||||
zones.forEach(z => {
|
||||
const x1 = pad + (z.snr[0] - snrMin) / (snrMax - snrMin) * (w - pad * 2);
|
||||
const x2 = pad + (z.snr[1] - snrMin) / (snrMax - snrMin) * (w - pad * 2);
|
||||
@@ -353,13 +378,13 @@
|
||||
let html = '<table class="analytics-table"><thead><tr><th>Type</th><th>Packets</th><th>Avg SNR</th><th>Min</th><th>Max</th><th>Distribution</th></tr></thead><tbody>';
|
||||
snrByType.forEach(t => {
|
||||
const barPct = Math.max(((t.avg - (-12)) / 27) * 100, 2);
|
||||
const color = t.avg > 6 ? '#22c55e' : t.avg > 0 ? '#f59e0b' : '#ef4444';
|
||||
const color = t.avg > 6 ? statusGreen() : t.avg > 0 ? statusYellow() : statusRed();
|
||||
html += `<tr>
|
||||
<td><strong>${t.name}</strong></td>
|
||||
<td>${t.count}</td>
|
||||
<td><strong>${t.avg.toFixed(1)} dB</strong></td>
|
||||
<td>${t.min.toFixed(1)}</td>
|
||||
<td>${t.max.toFixed(1)}</td>
|
||||
<td><strong>${sf(t.avg, 1)} dB</strong></td>
|
||||
<td>${sf(t.min, 1)}</td>
|
||||
<td>${sf(t.max, 1)}</td>
|
||||
<td><div class="hash-bar-track" style="height:14px"><div class="hash-bar-fill" style="width:${barPct}%;background:${color};height:100%"></div></div></td>
|
||||
</tr>`;
|
||||
});
|
||||
@@ -376,7 +401,7 @@
|
||||
const y = h - pad - ((d.avgSnr + 12) / 27) * (h - pad * 2);
|
||||
return `${x},${y}`;
|
||||
}).join(' ');
|
||||
svg += `<polyline points="${snrPts}" fill="none" stroke="#22c55e" stroke-width="2"/>`;
|
||||
svg += `<polyline points="${snrPts}" fill="none" stroke="${statusGreen()}" stroke-width="2"/>`;
|
||||
// Packet count as area
|
||||
const areaPts = data.map((d, i) => {
|
||||
const x = pad + i * ((w - pad * 2) / Math.max(data.length - 1, 1));
|
||||
@@ -395,7 +420,7 @@
|
||||
svg += `<text x="${x}" y="${h-pad+14}" text-anchor="middle" font-size="9" fill="var(--text-muted)">${data[i].hour.slice(11)}h</text>`;
|
||||
}
|
||||
svg += '</svg>';
|
||||
svg += `<div class="timeline-legend"><span><span class="legend-dot" style="background:#22c55e"></span>Avg SNR</span><span><span class="legend-dot" style="background:var(--accent);opacity:0.3"></span>Volume</span></div>`;
|
||||
svg += `<div class="timeline-legend"><span><span class="legend-dot" style="background:${statusGreen()}"></span>Avg SNR</span><span><span class="legend-dot" style="background:var(--accent);opacity:0.3"></span>Volume</span></div>`;
|
||||
return svg;
|
||||
}
|
||||
|
||||
@@ -408,7 +433,7 @@
|
||||
<p class="text-muted">Number of repeater hops per packet</p>
|
||||
${barChart(topo.hopDistribution.map(h=>h.count), topo.hopDistribution.map(h=>h.hops), ['#3b82f6'])}
|
||||
<div class="rf-stats">
|
||||
<span>Avg: <strong>${topo.avgHops.toFixed(1)} hops</strong></span>
|
||||
<span>Avg: <strong>${sf(topo.avgHops, 1)} hops</strong></span>
|
||||
<span>Median: <strong>${topo.medianHops}</strong></span>
|
||||
<span>Max: <strong>${topo.maxHops}</strong></span>
|
||||
<span>1-hop direct: <strong>${topo.hopDistribution[0]?.count || 0}</strong></span>
|
||||
@@ -510,7 +535,7 @@
|
||||
const x = pad + (d.hops / maxHop) * (w - pad * 2);
|
||||
const y = h - pad - ((d.avgSnr + 12) / 27) * (h - pad * 2);
|
||||
const r = Math.min(Math.sqrt(d.count) * 1.5, 12);
|
||||
const color = d.avgSnr > 6 ? '#22c55e' : d.avgSnr > 0 ? '#f59e0b' : '#ef4444';
|
||||
const color = d.avgSnr > 6 ? statusGreen() : d.avgSnr > 0 ? statusYellow() : statusRed();
|
||||
svg += `<circle cx="${x}" cy="${y}" r="${r}" fill="${color}" opacity="0.6"/>`;
|
||||
svg += `<text x="${x}" y="${y-r-3}" text-anchor="middle" font-size="9" fill="var(--text-muted)">${d.hops}h</text>`;
|
||||
});
|
||||
@@ -604,7 +629,7 @@
|
||||
<tbody>
|
||||
${ch.channels.map(c => `<tr class="clickable-row" data-action="navigate" data-value="#/channels?ch=${c.hash}" tabindex="0" role="row">
|
||||
<td><strong>${esc(c.name || 'Unknown')}</strong></td>
|
||||
<td class="mono">${c.hash}</td>
|
||||
<td class="mono">${typeof c.hash === 'number' ? '0x' + c.hash.toString(16).toUpperCase().padStart(2, '0') : c.hash}</td>
|
||||
<td>${c.messages}</td>
|
||||
<td>${c.senders}</td>
|
||||
<td>${timeAgo(c.lastActivity)}</td>
|
||||
@@ -759,7 +784,7 @@
|
||||
</div>
|
||||
`;
|
||||
let allNodes = [];
|
||||
try { const nd = await api('/nodes?limit=2000', { ttl: CLIENT_TTL.nodeList }); allNodes = nd.nodes || []; } catch {}
|
||||
try { const nd = await api('/nodes?limit=2000' + RegionFilter.regionQueryString(), { ttl: CLIENT_TTL.nodeList }); allNodes = nd.nodes || []; } catch {}
|
||||
renderHashMatrix(data.topHops, allNodes);
|
||||
renderCollisions(data.topHops, allNodes);
|
||||
}
|
||||
@@ -911,13 +936,13 @@
|
||||
<tbody>${collisions.map(c => {
|
||||
let badge, tooltip;
|
||||
if (c.classification === 'local') {
|
||||
badge = '<span class="badge" style="background:#22c55e;color:#fff" title="All nodes within 50km — likely true collision, same RF neighborhood">🏘️ Local</span>';
|
||||
badge = '<span class="badge" style="background:var(--status-green);color:#fff" title="All nodes within 50km — likely true collision, same RF neighborhood">🏘️ Local</span>';
|
||||
tooltip = 'Nodes close enough for direct RF — probably genuine prefix collision';
|
||||
} else if (c.classification === 'regional') {
|
||||
badge = '<span class="badge" style="background:#f59e0b;color:#fff" title="Nodes 50–200km apart — edge of LoRa range, could be atmospheric">⚡ Regional</span>';
|
||||
badge = '<span class="badge" style="background:var(--status-yellow);color:#fff" title="Nodes 50–200km apart — edge of LoRa range, could be atmospheric">⚡ Regional</span>';
|
||||
tooltip = 'At edge of 915MHz range — could indicate atmospheric ducting or hilltop-to-hilltop links';
|
||||
} else if (c.classification === 'distant') {
|
||||
badge = '<span class="badge" style="background:#ef4444;color:#fff" title="Nodes >200km apart — beyond typical 915MHz range">🌐 Distant</span>';
|
||||
badge = '<span class="badge" style="background:var(--status-red);color:#fff" title="Nodes >200km apart — beyond typical 915MHz range">🌐 Distant</span>';
|
||||
tooltip = 'Beyond typical LoRa range — likely internet bridging, MQTT gateway, or separate mesh networks sharing prefix';
|
||||
} else {
|
||||
badge = '<span class="badge" style="background:#6b7280;color:#fff">❓ Unknown</span>';
|
||||
@@ -949,11 +974,12 @@
|
||||
async function renderSubpaths(el) {
|
||||
el.innerHTML = '<div class="text-center text-muted" style="padding:40px">Analyzing route patterns…</div>';
|
||||
try {
|
||||
const rq = RegionFilter.regionQueryString();
|
||||
const [d2, d3, d4, d5] = await Promise.all([
|
||||
api('/analytics/subpaths?minLen=2&maxLen=2&limit=50', { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/subpaths?minLen=3&maxLen=3&limit=30', { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/subpaths?minLen=4&maxLen=4&limit=20', { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/subpaths?minLen=5&maxLen=8&limit=15', { ttl: CLIENT_TTL.analyticsRF })
|
||||
api('/analytics/subpaths?minLen=2&maxLen=2&limit=50' + rq, { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/subpaths?minLen=3&maxLen=3&limit=30' + rq, { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/subpaths?minLen=4&maxLen=4&limit=20' + rq, { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/analytics/subpaths?minLen=5&maxLen=8&limit=15' + rq, { ttl: CLIENT_TTL.analyticsRF })
|
||||
]);
|
||||
|
||||
function renderTable(data, title) {
|
||||
@@ -976,7 +1002,7 @@
|
||||
<td>${routeDisplay}${hasSelfLoop ? ' <span title="Contains self-loop — likely 1-byte prefix collision" style="cursor:help">🔄</span>' : ''}<br><span class="hop-prefix mono">${esc(prefixDisplay)}</span></td>
|
||||
<td>${s.count.toLocaleString()}</td>
|
||||
<td>${s.pct}%</td>
|
||||
<td><div style="background:${hasSelfLoop ? '#f59e0b' : 'var(--accent,#3b82f6)'};height:14px;border-radius:3px;width:${barW}%;opacity:0.7"></div></td>
|
||||
<td><div style="background:${hasSelfLoop ? 'var(--status-yellow)' : 'var(--accent)'};height:14px;border-radius:3px;width:${barW}%;opacity:0.7"></div></td>
|
||||
</tr>`;
|
||||
}).join('')}
|
||||
</tbody></table>`;
|
||||
@@ -1076,7 +1102,7 @@
|
||||
const dLon = (a.lon - b.lon) * 85;
|
||||
const km = Math.sqrt(dLat*dLat + dLon*dLon);
|
||||
total += km;
|
||||
const cls = km > 200 ? 'color:#ef4444;font-weight:bold' : km > 50 ? 'color:#f59e0b' : 'color:#22c55e';
|
||||
const cls = km > 200 ? 'color:var(--status-red);font-weight:bold' : km > 50 ? 'color:var(--status-yellow)' : 'color:var(--status-green)';
|
||||
dists.push(`<div style="padding:2px 0"><span style="${cls}">${km < 1 ? (km*1000).toFixed(0)+'m' : km.toFixed(1)+'km'}</span> <span class="text-muted">${esc(a.name)} → ${esc(b.name)}</span></div>`);
|
||||
} else {
|
||||
dists.push(`<div style="padding:2px 0"><span class="text-muted">? ${esc(a.name)} → ${esc(b.name)} (no coords)</span></div>`);
|
||||
@@ -1138,13 +1164,13 @@
|
||||
const isEnd = i === 0 || i === nodesWithLoc.length - 1;
|
||||
L.circleMarker(ll, {
|
||||
radius: isEnd ? 8 : 5,
|
||||
color: isEnd ? (i === 0 ? '#22c55e' : '#ef4444') : '#f59e0b',
|
||||
fillColor: isEnd ? (i === 0 ? '#22c55e' : '#ef4444') : '#f59e0b',
|
||||
color: isEnd ? (i === 0 ? statusGreen() : statusRed()) : statusYellow(),
|
||||
fillColor: isEnd ? (i === 0 ? statusGreen() : statusRed()) : statusYellow(),
|
||||
fillOpacity: 0.9, weight: 2
|
||||
}).bindTooltip(n.name, { permanent: false }).addTo(map);
|
||||
});
|
||||
|
||||
L.polyline(latlngs, { color: '#f59e0b', weight: 3, dashArray: '8,6', opacity: 0.8 }).addTo(map);
|
||||
L.polyline(latlngs, { color: statusYellow(), weight: 3, dashArray: '8,6', opacity: 0.8 }).addTo(map);
|
||||
map.fitBounds(L.latLngBounds(latlngs).pad(0.3));
|
||||
}
|
||||
}
|
||||
@@ -1152,10 +1178,11 @@
|
||||
async function renderNodesTab(el) {
|
||||
el.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-muted)">Loading node analytics…</div>';
|
||||
try {
|
||||
const rq = RegionFilter.regionQueryString();
|
||||
const [nodesResp, bulkHealth, netStatus] = await Promise.all([
|
||||
api('/nodes?limit=200&sortBy=lastSeen', { ttl: CLIENT_TTL.nodeList }),
|
||||
api('/nodes/bulk-health?limit=50', { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/nodes/network-status', { ttl: CLIENT_TTL.analyticsRF })
|
||||
api('/nodes?limit=200&sortBy=lastSeen' + rq, { ttl: CLIENT_TTL.nodeList }),
|
||||
api('/nodes/bulk-health?limit=50' + rq, { ttl: CLIENT_TTL.analyticsRF }),
|
||||
api('/nodes/network-status' + (rq ? '?' + rq.slice(1) : ''), { ttl: CLIENT_TTL.analyticsRF })
|
||||
]);
|
||||
const nodes = nodesResp.nodes || nodesResp;
|
||||
const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]');
|
||||
@@ -1189,15 +1216,15 @@
|
||||
<h3>🔍 Network Status</h3>
|
||||
<div style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:20px">
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:120px;text-align:center;padding:16px;background:var(--card-bg);border:1px solid var(--border);border-radius:8px">
|
||||
<div style="font-size:28px;font-weight:700;color:#22c55e">${active}</div>
|
||||
<div style="font-size:28px;font-weight:700;color:var(--status-green)">${active}</div>
|
||||
<div style="font-size:11px;text-transform:uppercase;color:var(--text-muted)">🟢 Active</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:120px;text-align:center;padding:16px;background:var(--card-bg);border:1px solid var(--border);border-radius:8px">
|
||||
<div style="font-size:28px;font-weight:700;color:#eab308">${degraded}</div>
|
||||
<div style="font-size:28px;font-weight:700;color:var(--status-yellow)">${degraded}</div>
|
||||
<div style="font-size:11px;text-transform:uppercase;color:var(--text-muted)">🟡 Degraded</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:120px;text-align:center;padding:16px;background:var(--card-bg);border:1px solid var(--border);border-radius:8px">
|
||||
<div style="font-size:28px;font-weight:700;color:#ef4444">${silent}</div>
|
||||
<div style="font-size:28px;font-weight:700;color:var(--status-red)">${silent}</div>
|
||||
<div style="font-size:11px;text-transform:uppercase;color:var(--text-muted)">🔴 Silent</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card" style="flex:1;min-width:120px;text-align:center;padding:16px;background:var(--card-bg);border:1px solid var(--border);border-radius:8px">
|
||||
@@ -1296,6 +1323,92 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function renderDistanceTab(el) {
|
||||
try {
|
||||
const rqs = RegionFilter.regionQueryString();
|
||||
const sep = rqs ? '?' + rqs.slice(1) : '';
|
||||
const data = await api('/analytics/distance' + sep, { ttl: CLIENT_TTL.analyticsRF });
|
||||
const s = data.summary;
|
||||
let html = `<div class="analytics-grid">
|
||||
<div class="stat-card"><div class="stat-value">${s.totalHops.toLocaleString()}</div><div class="stat-label">Total Hops Analyzed</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${s.totalPaths.toLocaleString()}</div><div class="stat-label">Paths Analyzed</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${s.avgDist} km</div><div class="stat-label">Avg Hop Distance</div></div>
|
||||
<div class="stat-card"><div class="stat-value">${s.maxDist} km</div><div class="stat-label">Max Hop Distance</div></div>
|
||||
</div>`;
|
||||
|
||||
// Category stats
|
||||
const cats = data.catStats;
|
||||
html += `<div class="analytics-section"><h3>Distance by Link Type</h3><table class="data-table"><thead><tr><th>Type</th><th>Count</th><th>Avg (km)</th><th>Median (km)</th><th>Min (km)</th><th>Max (km)</th></tr></thead><tbody>`;
|
||||
for (const [cat, st] of Object.entries(cats)) {
|
||||
if (!st.count) continue;
|
||||
html += `<tr><td><strong>${esc(cat)}</strong></td><td>${st.count.toLocaleString()}</td><td>${st.avg}</td><td>${st.median}</td><td>${st.min}</td><td>${st.max}</td></tr>`;
|
||||
}
|
||||
html += `</tbody></table></div>`;
|
||||
|
||||
// Histogram
|
||||
if (data.distHistogram && data.distHistogram.bins) {
|
||||
const buckets = data.distHistogram.bins.map(b => b.count);
|
||||
const labels = data.distHistogram.bins.map(b => b.x.toFixed(1));
|
||||
html += `<div class="analytics-section"><h3>Hop Distance Distribution</h3>${barChart(buckets, labels, statusGreen())}</div>`;
|
||||
}
|
||||
|
||||
// Distance over time
|
||||
if (data.distOverTime && data.distOverTime.length > 1) {
|
||||
html += `<div class="analytics-section"><h3>Average Distance Over Time</h3>${sparkSvg(data.distOverTime.map(d => d.avg), 'var(--accent)', 800, 120)}</div>`;
|
||||
}
|
||||
|
||||
// Top hops leaderboard
|
||||
html += `<div class="analytics-section"><h3>🏆 Top 20 Longest Hops</h3><table class="data-table"><thead><tr><th>#</th><th>From</th><th>To</th><th>Distance (km)</th><th>Type</th><th>SNR</th><th>Packet</th><th></th></tr></thead><tbody>`;
|
||||
const top20 = data.topHops.slice(0, 20);
|
||||
top20.forEach((h, i) => {
|
||||
const fromLink = h.fromPk ? `<a href="#/nodes/${encodeURIComponent(h.fromPk)}" class="analytics-link">${esc(h.fromName)}</a>` : esc(h.fromName || '?');
|
||||
const toLink = h.toPk ? `<a href="#/nodes/${encodeURIComponent(h.toPk)}" class="analytics-link">${esc(h.toName)}</a>` : esc(h.toName || '?');
|
||||
const snr = h.snr != null ? h.snr + ' dB' : '<span class="text-muted">—</span>';
|
||||
const pktLink = h.hash ? `<a href="#/packet/${encodeURIComponent(h.hash)}" class="analytics-link mono" style="font-size:0.85em">${esc(h.hash.slice(0, 12))}…</a>` : '—';
|
||||
const mapBtn = h.fromPk && h.toPk ? `<button class="btn-icon dist-map-hop" data-from="${esc(h.fromPk)}" data-to="${esc(h.toPk)}" title="View on map">🗺️</button>` : '';
|
||||
html += `<tr><td>${i+1}</td><td>${fromLink}</td><td>${toLink}</td><td><strong>${h.dist}</strong></td><td>${esc(h.type)}</td><td>${snr}</td><td>${pktLink}</td><td>${mapBtn}</td></tr>`;
|
||||
});
|
||||
html += `</tbody></table></div>`;
|
||||
|
||||
// Top paths
|
||||
if (data.topPaths.length) {
|
||||
html += `<div class="analytics-section"><h3>🛤️ Top 10 Longest Multi-Hop Paths</h3><table class="data-table"><thead><tr><th>#</th><th>Total Distance (km)</th><th>Hops</th><th>Route</th><th>Packet</th><th></th></tr></thead><tbody>`;
|
||||
data.topPaths.slice(0, 10).forEach((p, i) => {
|
||||
const route = p.hops.map(h => esc(h.fromName)).concat(esc(p.hops[p.hops.length-1].toName)).join(' → ');
|
||||
const pktLink = p.hash ? `<a href="#/packet/${encodeURIComponent(p.hash)}" class="analytics-link mono" style="font-size:0.85em">${esc(p.hash.slice(0, 12))}…</a>` : '—';
|
||||
// Collect all unique pubkeys in path order
|
||||
const pathPks = [];
|
||||
p.hops.forEach(h => { if (h.fromPk && !pathPks.includes(h.fromPk)) pathPks.push(h.fromPk); });
|
||||
if (p.hops.length && p.hops[p.hops.length-1].toPk) { const last = p.hops[p.hops.length-1].toPk; if (!pathPks.includes(last)) pathPks.push(last); }
|
||||
const mapBtn = pathPks.length >= 2 ? `<button class="btn-icon dist-map-path" data-hops='${JSON.stringify(pathPks)}' title="View on map">🗺️</button>` : '';
|
||||
html += `<tr><td>${i+1}</td><td><strong>${p.totalDist}</strong></td><td>${p.hopCount}</td><td style="font-size:0.9em">${route}</td><td>${pktLink}</td><td>${mapBtn}</td></tr>`;
|
||||
});
|
||||
html += `</tbody></table></div>`;
|
||||
}
|
||||
|
||||
el.innerHTML = html;
|
||||
|
||||
// Wire up map buttons
|
||||
el.querySelectorAll('.dist-map-hop').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
sessionStorage.setItem('map-route-hops', JSON.stringify({ hops: [btn.dataset.from, btn.dataset.to] }));
|
||||
window.location.hash = '#/map?route=1';
|
||||
});
|
||||
});
|
||||
el.querySelectorAll('.dist-map-path').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
try {
|
||||
const hops = JSON.parse(btn.dataset.hops);
|
||||
sessionStorage.setItem('map-route-hops', JSON.stringify({ hops }));
|
||||
window.location.hash = '#/map?route=1';
|
||||
} catch {}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div style="padding:40px;text-align:center;color:#ff6b6b">Failed to load distance analytics: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() { _analyticsData = {}; }
|
||||
|
||||
registerPage('analytics', { init, destroy });
|
||||
|
||||
131
public/app.js
131
public/app.js
@@ -215,7 +215,6 @@ function connectWS() {
|
||||
api._invalidateTimer = null;
|
||||
invalidateApiCache('/stats');
|
||||
invalidateApiCache('/nodes');
|
||||
invalidateApiCache('/channels');
|
||||
}, 5000);
|
||||
}
|
||||
wsListeners.forEach(fn => fn(msg));
|
||||
@@ -316,6 +315,14 @@ function navigate() {
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', navigate);
|
||||
let _themeRefreshTimer = null;
|
||||
window.addEventListener('theme-changed', () => {
|
||||
if (_themeRefreshTimer) clearTimeout(_themeRefreshTimer);
|
||||
_themeRefreshTimer = setTimeout(() => {
|
||||
_themeRefreshTimer = null;
|
||||
window.dispatchEvent(new CustomEvent('theme-refresh'));
|
||||
}, 300);
|
||||
});
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
connectWS();
|
||||
|
||||
@@ -326,6 +333,43 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
darkToggle.textContent = theme === 'dark' ? '🌙' : '☀️';
|
||||
localStorage.setItem('meshcore-theme', theme);
|
||||
// Re-apply user theme CSS vars for the correct mode (light/dark)
|
||||
reapplyUserThemeVars(theme === 'dark');
|
||||
}
|
||||
function reapplyUserThemeVars(dark) {
|
||||
try {
|
||||
var userTheme = JSON.parse(localStorage.getItem('meshcore-user-theme') || '{}');
|
||||
if (!userTheme.theme && !userTheme.themeDark) {
|
||||
// Fall back to server config
|
||||
var cfg = window.SITE_CONFIG || {};
|
||||
if (!cfg.theme && !cfg.themeDark) return;
|
||||
userTheme = cfg;
|
||||
}
|
||||
var themeData = dark ? Object.assign({}, userTheme.theme || {}, userTheme.themeDark || {}) : (userTheme.theme || {});
|
||||
if (!Object.keys(themeData).length) return;
|
||||
var varMap = {
|
||||
accent: '--accent', accentHover: '--accent-hover',
|
||||
navBg: '--nav-bg', navBg2: '--nav-bg2', navText: '--nav-text', navTextMuted: '--nav-text-muted',
|
||||
background: '--surface-0', text: '--text', textMuted: '--text-muted', border: '--border',
|
||||
statusGreen: '--status-green', statusYellow: '--status-yellow', statusRed: '--status-red',
|
||||
surface1: '--surface-1', surface2: '--surface-2', surface3: '--surface-3',
|
||||
cardBg: '--card-bg', contentBg: '--content-bg', inputBg: '--input-bg',
|
||||
rowStripe: '--row-stripe', rowHover: '--row-hover', detailBg: '--detail-bg',
|
||||
selectedBg: '--selected-bg', sectionBg: '--section-bg',
|
||||
font: '--font', mono: '--mono'
|
||||
};
|
||||
var root = document.documentElement.style;
|
||||
for (var key in varMap) {
|
||||
if (themeData[key]) root.setProperty(varMap[key], themeData[key]);
|
||||
}
|
||||
if (themeData.background) root.setProperty('--content-bg', themeData.contentBg || themeData.background);
|
||||
if (themeData.surface1) root.setProperty('--card-bg', themeData.cardBg || themeData.surface1);
|
||||
// Nav gradient
|
||||
if (themeData.navBg) {
|
||||
var nav = document.querySelector('.top-nav');
|
||||
if (nav) { nav.style.background = ''; void nav.offsetHeight; }
|
||||
}
|
||||
} catch (e) { console.error('[theme] reapply error:', e); }
|
||||
}
|
||||
// On load: respect saved pref, else OS pref, else light
|
||||
if (savedTheme) {
|
||||
@@ -455,7 +499,7 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
const pktList = packets.packets || packets;
|
||||
if (Array.isArray(pktList)) {
|
||||
for (const p of pktList.slice(0, 5)) {
|
||||
html += `<div class="search-result-item" onclick="location.hash='#/packets?id=${p.id}';document.getElementById('searchOverlay').classList.add('hidden')">
|
||||
html += `<div class="search-result-item" onclick="location.hash='#/packets/${p.packet_hash || p.hash || p.id}';document.getElementById('searchOverlay').classList.add('hidden')">
|
||||
<span class="search-result-type">Packet</span>${truncate(p.packet_hash || '', 16)} — ${payloadTypeName(p.payload_type)}</div>`;
|
||||
}
|
||||
}
|
||||
@@ -498,8 +542,87 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
setInterval(updateNavStats, 15000);
|
||||
debouncedOnWS(function () { updateNavStats(); });
|
||||
|
||||
if (!location.hash || location.hash === '#/') location.hash = '#/home';
|
||||
else navigate();
|
||||
// --- Theme Customization ---
|
||||
// Fetch theme config and apply branding/colors before first render
|
||||
fetch('/api/config/theme', { cache: 'no-store' }).then(r => r.json()).then(cfg => {
|
||||
window.SITE_CONFIG = cfg;
|
||||
|
||||
// User's localStorage preferences take priority over server config
|
||||
const userTheme = (() => { try { return JSON.parse(localStorage.getItem('meshcore-user-theme') || '{}'); } catch { return {}; } })();
|
||||
|
||||
// Apply CSS variable overrides from theme config (skipped if user has local overrides)
|
||||
if (!userTheme.theme && !userTheme.themeDark) {
|
||||
const dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const themeData = dark ? { ...(cfg.theme || {}), ...(cfg.themeDark || {}) } : (cfg.theme || {});
|
||||
const root = document.documentElement.style;
|
||||
const varMap = {
|
||||
accent: '--accent', accentHover: '--accent-hover',
|
||||
navBg: '--nav-bg', navBg2: '--nav-bg2', navText: '--nav-text', navTextMuted: '--nav-text-muted',
|
||||
background: '--surface-0', text: '--text', textMuted: '--text-muted', border: '--border',
|
||||
statusGreen: '--status-green', statusYellow: '--status-yellow', statusRed: '--status-red',
|
||||
surface1: '--surface-1', surface2: '--surface-2', surface3: '--surface-3',
|
||||
cardBg: '--card-bg', contentBg: '--content-bg', inputBg: '--input-bg',
|
||||
rowStripe: '--row-stripe', rowHover: '--row-hover', detailBg: '--detail-bg',
|
||||
selectedBg: '--selected-bg', sectionBg: '--section-bg',
|
||||
font: '--font', mono: '--mono'
|
||||
};
|
||||
for (const [key, cssVar] of Object.entries(varMap)) {
|
||||
if (themeData[key]) root.setProperty(cssVar, themeData[key]);
|
||||
}
|
||||
// Derived vars
|
||||
if (themeData.background) root.setProperty('--content-bg', themeData.contentBg || themeData.background);
|
||||
if (themeData.surface1) root.setProperty('--card-bg', themeData.cardBg || themeData.surface1);
|
||||
// Nav gradient
|
||||
if (themeData.navBg) {
|
||||
const nav = document.querySelector('.top-nav');
|
||||
if (nav) nav.style.background = `linear-gradient(135deg, ${themeData.navBg} 0%, ${themeData.navBg2 || themeData.navBg} 50%, ${themeData.navBg} 100%)`;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply node color overrides (skip if user has local preferences)
|
||||
if (cfg.nodeColors && !userTheme.nodeColors) {
|
||||
for (const [role, color] of Object.entries(cfg.nodeColors)) {
|
||||
if (window.ROLE_COLORS && role in window.ROLE_COLORS) window.ROLE_COLORS[role] = color;
|
||||
if (window.ROLE_STYLE && window.ROLE_STYLE[role]) window.ROLE_STYLE[role].color = color;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply type color overrides (skip if user has local preferences)
|
||||
if (cfg.typeColors && !userTheme.typeColors) {
|
||||
for (const [type, color] of Object.entries(cfg.typeColors)) {
|
||||
if (window.TYPE_COLORS && type in window.TYPE_COLORS) window.TYPE_COLORS[type] = color;
|
||||
}
|
||||
if (window.syncBadgeColors) window.syncBadgeColors();
|
||||
}
|
||||
|
||||
// Apply branding (skip if user has local preferences)
|
||||
if (cfg.branding && !userTheme.branding) {
|
||||
if (cfg.branding.siteName) {
|
||||
document.title = cfg.branding.siteName;
|
||||
const brandText = document.querySelector('.brand-text');
|
||||
if (brandText) brandText.textContent = cfg.branding.siteName;
|
||||
}
|
||||
if (cfg.branding.logoUrl) {
|
||||
const brandIcon = document.querySelector('.brand-icon');
|
||||
if (brandIcon) {
|
||||
const img = document.createElement('img');
|
||||
img.src = cfg.branding.logoUrl;
|
||||
img.alt = cfg.branding.siteName || 'Logo';
|
||||
img.style.height = '24px';
|
||||
img.style.width = 'auto';
|
||||
brandIcon.replaceWith(img);
|
||||
}
|
||||
}
|
||||
if (cfg.branding.faviconUrl) {
|
||||
const favicon = document.querySelector('link[rel="icon"]');
|
||||
if (favicon) favicon.href = cfg.branding.faviconUrl;
|
||||
}
|
||||
}
|
||||
}).catch(() => { window.SITE_CONFIG = null; }).finally(() => {
|
||||
if (!location.hash || location.hash === '#/') location.hash = '#/home';
|
||||
else navigate();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
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 = window.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 },
|
||||
};
|
||||
})();
|
||||
@@ -205,6 +205,14 @@
|
||||
return str.length > len ? str.slice(0, len) + '…' : str;
|
||||
}
|
||||
|
||||
function formatSecondsAgo(sec) {
|
||||
if (sec < 0) sec = 0;
|
||||
if (sec < 60) return sec + 's ago';
|
||||
if (sec < 3600) return Math.floor(sec / 60) + 'm ago';
|
||||
if (sec < 86400) return Math.floor(sec / 3600) + 'h ago';
|
||||
return Math.floor(sec / 86400) + 'd ago';
|
||||
}
|
||||
|
||||
function highlightMentions(text) {
|
||||
if (!text) return '';
|
||||
return escapeHtml(text).replace(/@\[([^\]]+)\]/g, function(_, name) {
|
||||
@@ -213,12 +221,15 @@
|
||||
});
|
||||
}
|
||||
|
||||
let regionChangeHandler = null;
|
||||
|
||||
function init(app, routeParam) {
|
||||
app.innerHTML = `<div class="ch-layout">
|
||||
<div class="ch-sidebar" aria-label="Channel list">
|
||||
<div class="ch-sidebar-header">
|
||||
<div class="ch-sidebar-title"><span class="ch-icon">💬</span> Channels</div>
|
||||
</div>
|
||||
<div id="chRegionFilter" class="region-filter-container" style="padding:0 8px"></div>
|
||||
<div class="ch-channel-list" id="chList" role="listbox" aria-label="Channels">
|
||||
<div class="ch-loading">Loading channels…</div>
|
||||
</div>
|
||||
@@ -237,6 +248,9 @@
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
RegionFilter.init(document.getElementById('chRegionFilter'));
|
||||
regionChangeHandler = RegionFilter.onChange(function () { loadChannels(); });
|
||||
|
||||
loadChannels().then(() => {
|
||||
if (routeParam) selectChannel(routeParam);
|
||||
});
|
||||
@@ -367,21 +381,140 @@
|
||||
});
|
||||
|
||||
wsHandler = debouncedOnWS(function (msgs) {
|
||||
var dominated = msgs.some(function (m) {
|
||||
var dominated = msgs.filter(function (m) {
|
||||
return m.type === 'message' || (m.type === 'packet' && m.data?.decoded?.header?.payloadTypeName === 'GRP_TXT');
|
||||
});
|
||||
if (dominated) {
|
||||
loadChannels(true);
|
||||
if (selectedHash) {
|
||||
refreshMessages();
|
||||
if (!dominated.length) return;
|
||||
|
||||
var channelListDirty = false;
|
||||
var messagesDirty = false;
|
||||
var seenHashes = new Set();
|
||||
|
||||
for (var i = 0; i < dominated.length; i++) {
|
||||
var m = dominated[i];
|
||||
var payload = m.data?.decoded?.payload;
|
||||
if (!payload) continue;
|
||||
|
||||
var channelName = payload.channel || 'unknown';
|
||||
var rawText = payload.text || '';
|
||||
var sender = payload.sender || null;
|
||||
var displayText = rawText;
|
||||
|
||||
// Parse "sender: message" format
|
||||
if (rawText && !sender) {
|
||||
var colonIdx = rawText.indexOf(': ');
|
||||
if (colonIdx > 0 && colonIdx < 50) {
|
||||
sender = rawText.slice(0, colonIdx);
|
||||
displayText = rawText.slice(colonIdx + 2);
|
||||
}
|
||||
} else if (rawText && sender) {
|
||||
var colonIdx2 = rawText.indexOf(': ');
|
||||
if (colonIdx2 > 0 && colonIdx2 < 50) {
|
||||
displayText = rawText.slice(colonIdx2 + 2);
|
||||
}
|
||||
}
|
||||
if (!sender) sender = 'Unknown';
|
||||
|
||||
var ts = new Date().toISOString();
|
||||
var pktHash = m.data?.hash || m.data?.packet?.hash || null;
|
||||
var pktId = m.data?.id || null;
|
||||
var snr = m.data?.snr ?? m.data?.packet?.snr ?? payload.SNR ?? null;
|
||||
var observer = m.data?.packet?.observer_name || m.data?.observer || null;
|
||||
|
||||
// Update channel list entry — only once per unique packet hash
|
||||
var isFirstObservation = pktHash && !seenHashes.has(pktHash + ':' + channelName);
|
||||
if (pktHash) seenHashes.add(pktHash + ':' + channelName);
|
||||
|
||||
var ch = channels.find(function (c) { return c.hash === channelName; });
|
||||
if (ch) {
|
||||
if (isFirstObservation) ch.messageCount = (ch.messageCount || 0) + 1;
|
||||
ch.lastActivityMs = Date.now();
|
||||
ch.lastSender = sender;
|
||||
ch.lastMessage = truncate(displayText, 100);
|
||||
channelListDirty = true;
|
||||
} else if (isFirstObservation) {
|
||||
// New channel we haven't seen
|
||||
channels.push({
|
||||
hash: channelName,
|
||||
name: channelName,
|
||||
messageCount: 1,
|
||||
lastActivityMs: Date.now(),
|
||||
lastSender: sender,
|
||||
lastMessage: truncate(displayText, 100),
|
||||
});
|
||||
channelListDirty = true;
|
||||
}
|
||||
|
||||
// If this message is for the selected channel, append to messages
|
||||
if (selectedHash && channelName === selectedHash) {
|
||||
// Deduplicate by packet hash — same message seen by multiple observers
|
||||
var existing = pktHash ? messages.find(function (msg) { return msg.packetHash === pktHash; }) : null;
|
||||
if (existing) {
|
||||
existing.repeats = (existing.repeats || 1) + 1;
|
||||
if (observer && existing.observers && existing.observers.indexOf(observer) === -1) {
|
||||
existing.observers.push(observer);
|
||||
}
|
||||
} else {
|
||||
messages.push({
|
||||
sender: sender,
|
||||
text: displayText,
|
||||
timestamp: ts,
|
||||
sender_timestamp: payload.sender_timestamp || null,
|
||||
packetId: pktId,
|
||||
packetHash: pktHash,
|
||||
repeats: 1,
|
||||
observers: observer ? [observer] : [],
|
||||
hops: payload.path_len || 0,
|
||||
snr: snr,
|
||||
});
|
||||
}
|
||||
messagesDirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (channelListDirty) {
|
||||
channels.sort(function (a, b) { return (b.lastActivityMs || 0) - (a.lastActivityMs || 0); });
|
||||
renderChannelList();
|
||||
}
|
||||
if (messagesDirty) {
|
||||
renderMessages();
|
||||
// Update header count
|
||||
var ch2 = channels.find(function (c) { return c.hash === selectedHash; });
|
||||
var header = document.getElementById('chHeader');
|
||||
if (header && ch2) {
|
||||
header.querySelector('.ch-header-text').textContent = (ch2.name || 'Channel ' + selectedHash) + ' — ' + messages.length + ' messages';
|
||||
}
|
||||
var msgEl = document.getElementById('chMessages');
|
||||
if (msgEl && autoScroll) scrollToBottom();
|
||||
else {
|
||||
document.getElementById('chScrollBtn')?.classList.remove('hidden');
|
||||
var liveEl = document.getElementById('chAriaLive');
|
||||
if (liveEl) liveEl.textContent = 'New message received';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Tick relative timestamps every 1s — iterates channels array, updates DOM text only
|
||||
timeAgoTimer = setInterval(function () {
|
||||
var now = Date.now();
|
||||
for (var i = 0; i < channels.length; i++) {
|
||||
var ch = channels[i];
|
||||
if (!ch.lastActivityMs) continue;
|
||||
var el = document.querySelector('.ch-item-time[data-channel-hash="' + ch.hash + '"]');
|
||||
if (el) el.textContent = formatSecondsAgo(Math.floor((now - ch.lastActivityMs) / 1000));
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
var timeAgoTimer = null;
|
||||
|
||||
function destroy() {
|
||||
if (wsHandler) offWS(wsHandler);
|
||||
wsHandler = null;
|
||||
if (timeAgoTimer) clearInterval(timeAgoTimer);
|
||||
timeAgoTimer = null;
|
||||
if (regionChangeHandler) RegionFilter.offChange(regionChangeHandler);
|
||||
regionChangeHandler = null;
|
||||
channels = [];
|
||||
messages = [];
|
||||
selectedHash = null;
|
||||
@@ -393,8 +526,13 @@
|
||||
|
||||
async function loadChannels(silent) {
|
||||
try {
|
||||
const data = await api('/channels', { ttl: CLIENT_TTL.channels });
|
||||
channels = (data.channels || []).sort((a, b) => (b.lastActivity || '').localeCompare(a.lastActivity || ''));
|
||||
const rp = RegionFilter.getRegionParam();
|
||||
const qs = rp ? '?region=' + encodeURIComponent(rp) : '';
|
||||
const data = await api('/channels' + qs, { ttl: CLIENT_TTL.channels });
|
||||
channels = (data.channels || []).map(ch => {
|
||||
ch.lastActivityMs = ch.lastActivity ? new Date(ch.lastActivity).getTime() : 0;
|
||||
return ch;
|
||||
}).sort((a, b) => (b.lastActivityMs || 0) - (a.lastActivityMs || 0));
|
||||
renderChannelList();
|
||||
} catch (e) {
|
||||
if (!silent) {
|
||||
@@ -409,30 +547,27 @@
|
||||
if (!el) return;
|
||||
if (channels.length === 0) { el.innerHTML = '<div class="ch-empty">No channels found</div>'; return; }
|
||||
|
||||
// Sort: decrypted first (by message count desc), then encrypted (by message count desc)
|
||||
// Sort by message count desc
|
||||
const sorted = [...channels].sort((a, b) => {
|
||||
if (a.encrypted !== b.encrypted) return a.encrypted ? 1 : -1;
|
||||
return (b.messageCount || 0) - (a.messageCount || 0);
|
||||
});
|
||||
|
||||
el.innerHTML = sorted.map(ch => {
|
||||
const name = ch.name || `Channel ${ch.hash}`;
|
||||
const color = getChannelColor(ch.hash);
|
||||
const time = ch.lastActivity ? timeAgo(ch.lastActivity) : '';
|
||||
const time = ch.lastActivityMs ? formatSecondsAgo(Math.floor((Date.now() - ch.lastActivityMs) / 1000)) : '';
|
||||
const preview = ch.lastSender && ch.lastMessage
|
||||
? `${ch.lastSender}: ${truncate(ch.lastMessage, 28)}`
|
||||
: ch.encrypted ? `🔒 ${ch.messageCount} encrypted` : `${ch.messageCount} messages`;
|
||||
: `${ch.messageCount} messages`;
|
||||
const sel = selectedHash === ch.hash ? ' selected' : '';
|
||||
const lockIcon = ch.encrypted ? ' 🔒' : '';
|
||||
const encClass = ch.encrypted ? ' ch-item-encrypted' : '';
|
||||
const abbr = name.startsWith('#') ? name.slice(0, 3) : name.slice(0, 2).toUpperCase();
|
||||
|
||||
return `<button class="ch-item${sel}${encClass}" data-hash="${ch.hash}" type="button" role="option" aria-selected="${selectedHash === ch.hash ? 'true' : 'false'}" aria-label="${escapeHtml(name)}">
|
||||
return `<button class="ch-item${sel}" data-hash="${ch.hash}" type="button" role="option" aria-selected="${selectedHash === ch.hash ? 'true' : 'false'}" aria-label="${escapeHtml(name)}">
|
||||
<div class="ch-badge" style="background:${color}" aria-hidden="true">${escapeHtml(abbr)}</div>
|
||||
<div class="ch-item-body">
|
||||
<div class="ch-item-top">
|
||||
<span class="ch-item-name">${escapeHtml(name)}${lockIcon}</span>
|
||||
<span class="ch-item-time">${time}</span>
|
||||
<span class="ch-item-name">${escapeHtml(name)}</span>
|
||||
<span class="ch-item-time" data-channel-hash="${ch.hash}">${time}</span>
|
||||
</div>
|
||||
<div class="ch-item-preview">${escapeHtml(preview)}</div>
|
||||
</div>
|
||||
@@ -442,7 +577,7 @@
|
||||
|
||||
async function selectChannel(hash) {
|
||||
selectedHash = hash;
|
||||
history.replaceState(null, '', `#/channels/${hash}`);
|
||||
history.replaceState(null, '', `#/channels/${encodeURIComponent(hash)}`);
|
||||
renderChannelList();
|
||||
const ch = channels.find(c => c.hash === hash);
|
||||
const name = ch?.name || `Channel ${hash}`;
|
||||
@@ -456,7 +591,7 @@
|
||||
msgEl.innerHTML = '<div class="ch-loading">Loading messages…</div>';
|
||||
|
||||
try {
|
||||
const data = await api(`/channels/${hash}/messages?limit=200`, { ttl: CLIENT_TTL.channelMessages });
|
||||
const data = await api(`/channels/${encodeURIComponent(hash)}/messages?limit=200`, { ttl: CLIENT_TTL.channelMessages });
|
||||
messages = data.messages || [];
|
||||
renderMessages();
|
||||
scrollToBottom();
|
||||
@@ -471,7 +606,7 @@
|
||||
if (!msgEl) return;
|
||||
const wasAtBottom = msgEl.scrollHeight - msgEl.scrollTop - msgEl.clientHeight < 60;
|
||||
try {
|
||||
const data = await api(`/channels/${selectedHash}/messages?limit=200`, { ttl: CLIENT_TTL.channelMessages });
|
||||
const data = await api(`/channels/${encodeURIComponent(selectedHash)}/messages?limit=200`, { ttl: CLIENT_TTL.channelMessages });
|
||||
const newMsgs = data.messages || [];
|
||||
// #92: Use message ID/hash for change detection instead of count + timestamp
|
||||
var _getLastId = function (arr) { var m = arr.length ? arr[arr.length - 1] : null; return m ? (m.id || m.packetId || m.timestamp || '') : ''; };
|
||||
@@ -499,11 +634,7 @@
|
||||
const senderLetter = sender.replace(/[^\w]/g, '').charAt(0).toUpperCase() || '?';
|
||||
|
||||
let displayText;
|
||||
if (msg.encrypted) {
|
||||
displayText = '<span class="mono ch-encrypted-text">🔒 encrypted</span>';
|
||||
} else {
|
||||
displayText = highlightMentions(msg.text || '');
|
||||
}
|
||||
displayText = highlightMentions(msg.text || '');
|
||||
|
||||
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
|
||||
const date = msg.timestamp ? new Date(msg.timestamp).toLocaleDateString() : '';
|
||||
|
||||
1270
public/customize.js
Normal file
1270
public/customize.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -25,12 +25,6 @@
|
||||
.chooser-btn span:last-child { font-size: .8rem; color: var(--text-muted); }
|
||||
.home-level-toggle { margin-top: 16px; }
|
||||
|
||||
:root {
|
||||
--status-green: #22c55e;
|
||||
--status-yellow: #eab308;
|
||||
--status-red: #ef4444;
|
||||
}
|
||||
|
||||
/* Hero */
|
||||
.home-hero {
|
||||
text-align: center;
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
function showChooser(container) {
|
||||
container.innerHTML = `
|
||||
<section class="home-chooser">
|
||||
<h1>Welcome to Bay Area MeshCore Analyzer</h1>
|
||||
<h1>Welcome to ${escapeHtml(window.SITE_CONFIG?.branding?.siteName || 'MeshCore Analyzer')}</h1>
|
||||
<p>How familiar are you with MeshCore?</p>
|
||||
<div class="chooser-options">
|
||||
<button class="chooser-btn new" id="chooseNew">
|
||||
@@ -62,11 +62,13 @@
|
||||
const exp = isExperienced();
|
||||
const myNodes = getMyNodes();
|
||||
const hasNodes = myNodes.length > 0;
|
||||
const homeCfg = window.SITE_CONFIG?.home || null;
|
||||
const siteName = window.SITE_CONFIG?.branding?.siteName || 'MeshCore Analyzer';
|
||||
|
||||
container.innerHTML = `
|
||||
<section class="home-hero">
|
||||
<h1>${hasNodes ? 'My Mesh' : 'MeshCore Analyzer'}</h1>
|
||||
<p>${hasNodes ? 'Your nodes at a glance. Add more by searching below.' : 'Find your nodes to start monitoring them.'}</p>
|
||||
<h1>${hasNodes ? 'My Mesh' : escapeHtml(homeCfg?.heroTitle || siteName)}</h1>
|
||||
<p>${hasNodes ? 'Your nodes at a glance. Add more by searching below.' : escapeHtml(homeCfg?.heroSubtitle || 'Find your nodes to start monitoring them.')}</p>
|
||||
<div class="home-search-wrap">
|
||||
<input type="text" id="homeSearch" placeholder="Search by node name or public key…" autocomplete="off" aria-label="Search nodes" role="combobox" aria-expanded="false" aria-owns="homeSuggest" aria-autocomplete="list" aria-activedescendant="">
|
||||
<div class="home-suggest" id="homeSuggest" role="listbox"></div>
|
||||
@@ -92,17 +94,18 @@
|
||||
|
||||
${exp ? '' : `
|
||||
<section class="home-checklist">
|
||||
<h2>🚀 Getting on the mesh — SF Bay Area</h2>
|
||||
${checklist()}
|
||||
<h2>🚀 Getting on the mesh${homeCfg?.steps ? '' : ' — SF Bay Area'}</h2>
|
||||
${checklist(homeCfg)}
|
||||
</section>`}
|
||||
|
||||
<section class="home-footer">
|
||||
<div class="home-footer-links">
|
||||
${homeCfg?.footerLinks ? homeCfg.footerLinks.map(l => `<a href="${escapeAttr(l.url)}" class="home-footer-link" target="_blank" rel="noopener">${escapeHtml(l.label)}</a>`).join('') : `
|
||||
<a href="#/packets" class="home-footer-link">📦 Packets</a>
|
||||
<a href="#/map" class="home-footer-link">🗺️ Network Map</a>
|
||||
<a href="#/live" class="home-footer-link">🔴 Live</a>
|
||||
<a href="#/nodes" class="home-footer-link">📡 All Nodes</a>
|
||||
<a href="#/channels" class="home-footer-link">💬 Channels</a>
|
||||
<a href="#/channels" class="home-footer-link">💬 Channels</a>`}
|
||||
</div>
|
||||
<div class="home-level-toggle">
|
||||
<small>${exp ? 'Want setup guides? ' : 'Already know MeshCore? '}
|
||||
@@ -261,7 +264,7 @@
|
||||
// SNR quality label
|
||||
const snrVal = stats.avgSnr;
|
||||
const snrLabel = snrVal != null ? (snrVal > 10 ? 'Excellent' : snrVal > 0 ? 'Good' : snrVal > -5 ? 'Marginal' : 'Poor') : null;
|
||||
const snrColor = snrVal != null ? (snrVal > 10 ? '#22c55e' : snrVal > 0 ? '#3b82f6' : snrVal > -5 ? '#f59e0b' : '#ef4444') : '#6b7280';
|
||||
const snrColor = snrVal != null ? (snrVal > 10 ? 'var(--status-green)' : snrVal > 0 ? 'var(--accent)' : snrVal > -5 ? 'var(--status-yellow)' : 'var(--status-red)') : '#6b7280';
|
||||
|
||||
// Build sparkline from recent packets (packet timestamps → hourly buckets)
|
||||
const sparkHtml = buildSparkline(h.recentPackets || []);
|
||||
@@ -507,7 +510,13 @@
|
||||
function escapeAttr(s) { return String(s).replace(/"/g,'"').replace(/'/g,'''); }
|
||||
function timeSinceMs(d) { return Date.now() - d.getTime(); }
|
||||
|
||||
function checklist() {
|
||||
function checklist(homeCfg) {
|
||||
if (homeCfg?.checklist) {
|
||||
return homeCfg.checklist.map(i => `<div class="checklist-item"><div class="checklist-q" role="button" tabindex="0" aria-expanded="false">${escapeHtml(i.question)}</div><div class="checklist-a">${window.miniMarkdown ? miniMarkdown(i.answer) : escapeHtml(i.answer)}</div></div>`).join('');
|
||||
}
|
||||
if (homeCfg?.steps) {
|
||||
return homeCfg.steps.map(s => `<div class="checklist-item"><div class="checklist-q" role="button" tabindex="0" aria-expanded="false">${escapeHtml(s.emoji || '')} ${escapeHtml(s.title)}</div><div class="checklist-a">${window.miniMarkdown ? miniMarkdown(s.description) : escapeHtml(s.description)}</div></div>`).join('');
|
||||
}
|
||||
const items = [
|
||||
{ q: '💬 First: Join the Bay Area MeshCore Discord',
|
||||
a: '<p>The community Discord is the best place to get help and find local mesh enthusiasts.</p><p><a href="https://discord.gg/q59JzsYTst" target="_blank" rel="noopener" style="color:var(--accent);font-weight:600">Join the Discord ↗</a></p><p>Start with <strong>#intro-to-meshcore</strong> — it has detailed setup instructions.</p>' },
|
||||
|
||||
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 };
|
||||
})();
|
||||
207
public/hop-resolver.js
Normal file
207
public/hop-resolver.js
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Client-side hop resolver — eliminates /api/resolve-hops HTTP requests.
|
||||
* Mirrors the server's disambiguateHops() logic from server.js.
|
||||
*/
|
||||
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, opts) {
|
||||
nodesList = nodes || [];
|
||||
prefixIdx = {};
|
||||
for (const n of nodesList) {
|
||||
if (!n.public_key) continue;
|
||||
const pk = n.public_key.toLowerCase();
|
||||
for (let len = 1; len <= 3; len++) {
|
||||
const p = pk.slice(0, len * 2);
|
||||
if (!prefixIdx[p]) prefixIdx[p] = [];
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an array of hex hop prefixes to node info.
|
||||
* Returns a map: { hop: {name, pubkey, lat, lon, ambiguous, unreliable} }
|
||||
*
|
||||
* @param {string[]} hops - Hex prefixes
|
||||
* @param {number|null} originLat - Sender latitude (forward anchor)
|
||||
* @param {number|null} originLon - Sender longitude (forward anchor)
|
||||
* @param {number|null} observerLat - Observer latitude (backward anchor)
|
||||
* @param {number|null} observerLon - Observer longitude (backward anchor)
|
||||
* @returns {Object} resolved map keyed by hop prefix
|
||||
*/
|
||||
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 with regional filtering
|
||||
for (const hop of hops) {
|
||||
const h = hop.toLowerCase();
|
||||
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 {
|
||||
// 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build initial positions for unambiguous hops
|
||||
for (const hop of hops) {
|
||||
const r = resolved[hop];
|
||||
if (r && !r.ambiguous && r.pubkey) {
|
||||
const node = nodesList.find(n => n.public_key === r.pubkey);
|
||||
if (node && node.lat && node.lon && !(node.lat === 0 && node.lon === 0)) {
|
||||
hopPositions[hop] = { lat: node.lat, lon: node.lon };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Forward pass
|
||||
let lastPos = (originLat != null && originLon != null) ? { lat: originLat, lon: originLon } : null;
|
||||
for (let i = 0; i < hops.length; i++) {
|
||||
const hop = hops[i];
|
||||
if (hopPositions[hop]) { lastPos = hopPositions[hop]; continue; }
|
||||
const r = resolved[hop];
|
||||
if (!r || !r.ambiguous) continue;
|
||||
const withLoc = r.candidates.filter(c => c.lat && c.lon && !(c.lat === 0 && c.lon === 0));
|
||||
if (!withLoc.length) continue;
|
||||
let anchor = lastPos;
|
||||
if (!anchor && i === hops.length - 1 && observerLat != null) {
|
||||
anchor = { lat: observerLat, lon: observerLon };
|
||||
}
|
||||
if (anchor) {
|
||||
withLoc.sort((a, b) => dist(a.lat, a.lon, anchor.lat, anchor.lon) - dist(b.lat, b.lon, anchor.lat, anchor.lon));
|
||||
}
|
||||
r.name = withLoc[0].name;
|
||||
r.pubkey = withLoc[0].pubkey;
|
||||
hopPositions[hop] = { lat: withLoc[0].lat, lon: withLoc[0].lon };
|
||||
lastPos = hopPositions[hop];
|
||||
}
|
||||
|
||||
// Backward pass
|
||||
let nextPos = (observerLat != null && observerLon != null) ? { lat: observerLat, lon: observerLon } : null;
|
||||
for (let i = hops.length - 1; i >= 0; i--) {
|
||||
const hop = hops[i];
|
||||
if (hopPositions[hop]) { nextPos = hopPositions[hop]; continue; }
|
||||
const r = resolved[hop];
|
||||
if (!r || !r.ambiguous) continue;
|
||||
const withLoc = r.candidates.filter(c => c.lat && c.lon && !(c.lat === 0 && c.lon === 0));
|
||||
if (!withLoc.length || !nextPos) continue;
|
||||
withLoc.sort((a, b) => dist(a.lat, a.lon, nextPos.lat, nextPos.lon) - dist(b.lat, b.lon, nextPos.lat, nextPos.lon));
|
||||
r.name = withLoc[0].name;
|
||||
r.pubkey = withLoc[0].pubkey;
|
||||
hopPositions[hop] = { lat: withLoc[0].lat, lon: withLoc[0].lon };
|
||||
nextPos = hopPositions[hop];
|
||||
}
|
||||
|
||||
// Sanity check: drop hops impossibly far from neighbors
|
||||
for (let i = 0; i < hops.length; i++) {
|
||||
const pos = hopPositions[hops[i]];
|
||||
if (!pos) continue;
|
||||
const prev = i > 0 ? hopPositions[hops[i - 1]] : null;
|
||||
const next = i < hops.length - 1 ? hopPositions[hops[i + 1]] : null;
|
||||
if (!prev && !next) continue;
|
||||
const dPrev = prev ? dist(pos.lat, pos.lon, prev.lat, prev.lon) : 0;
|
||||
const dNext = next ? dist(pos.lat, pos.lon, next.lat, next.lon) : 0;
|
||||
const tooFarPrev = prev && dPrev > MAX_HOP_DIST;
|
||||
const tooFarNext = next && dNext > MAX_HOP_DIST;
|
||||
if ((tooFarPrev && tooFarNext) || (tooFarPrev && !next) || (tooFarNext && !prev)) {
|
||||
const r = resolved[hops[i]];
|
||||
if (r) r.unreliable = true;
|
||||
delete hopPositions[hops[i]];
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the resolver has been initialized with nodes.
|
||||
*/
|
||||
function ready() {
|
||||
return nodesList.length > 0;
|
||||
}
|
||||
|
||||
return { init: init, resolve: resolve, ready: ready };
|
||||
})();
|
||||
@@ -22,9 +22,9 @@
|
||||
<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=1774042199">
|
||||
<link rel="stylesheet" href="home.css">
|
||||
<link rel="stylesheet" href="live.css?v=1774034490">
|
||||
<link rel="stylesheet" href="style.css?v=1774237752">
|
||||
<link rel="stylesheet" href="home.css?v=1774236560">
|
||||
<link rel="stylesheet" href="live.css?v=1774236560">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="anonymous">
|
||||
@@ -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">
|
||||
@@ -63,6 +64,7 @@
|
||||
<div class="nav-fav-dropdown" id="favDropdown"></div>
|
||||
</div>
|
||||
<button class="nav-btn" id="searchToggle" title="Search (Ctrl+K)">🔍</button>
|
||||
<button class="nav-btn" id="customizeToggle" title="Customize theme & branding">🎨</button>
|
||||
<button class="nav-btn" id="darkModeToggle" title="Toggle dark mode">☀️</button>
|
||||
<button class="nav-btn hamburger" id="hamburger" title="Menu" aria-label="Toggle navigation menu">☰</button>
|
||||
</div>
|
||||
@@ -79,19 +81,26 @@
|
||||
<main id="app" role="main"></main>
|
||||
|
||||
<script src="vendor/qrcode.js"></script>
|
||||
<script src="roles.js?v=1774028201"></script>
|
||||
<script src="app.js?v=1774034748"></script>
|
||||
<script src="home.js?v=1774042199"></script>
|
||||
<script src="packets.js?v=1774051434"></script>
|
||||
<script src="map.js?v=1774028201" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1774050030" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1774050030" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1774048777" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1774042199" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1774050030" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1774018095" 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="node-analytics.js?v=1774042199" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="perf.js?v=1773985649" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="roles.js?v=1774236560"></script>
|
||||
<script src="customize.js?v=1774238281" onerror="console.error('Failed to load:', this.src)"></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=1774238281"></script>
|
||||
<script src="home.js?v=1774236560"></script>
|
||||
<script src="packets.js?v=1774236560"></script>
|
||||
<script src="map.js?v=1774236560" 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=1774236560" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1774236560" 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="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=1774229396" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1774236560" 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=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=1774236560" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
.live-beacon {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #ef4444;
|
||||
background: var(--status-red);
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
animation: beaconPulse 1.5s ease-in-out infinite;
|
||||
@@ -80,11 +80,11 @@
|
||||
.live-stat-pill span {
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #60a5fa;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.live-stat-pill.anim-pill span { color: #f59e0b; }
|
||||
.live-stat-pill.rate-pill span { color: #22c55e; }
|
||||
.live-stat-pill.anim-pill span { color: var(--status-yellow); }
|
||||
.live-stat-pill.rate-pill span { color: var(--status-green); }
|
||||
|
||||
.live-sound-btn {
|
||||
background: color-mix(in srgb, var(--text) 8%, transparent);
|
||||
@@ -375,7 +375,7 @@
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
background: rgba(59,130,246,0.15);
|
||||
color: #60a5fa;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-size: .75rem;
|
||||
font-weight: 600;
|
||||
@@ -486,7 +486,7 @@
|
||||
|
||||
.vcr-live-btn {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #f87171;
|
||||
color: var(--status-red);
|
||||
font-weight: 700;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.05em;
|
||||
@@ -501,15 +501,15 @@
|
||||
border-radius: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.vcr-mode-live { color: #22c55e; }
|
||||
.vcr-mode-paused { color: #fbbf24; background: rgba(251,191,36,0.1); }
|
||||
.vcr-mode-replay { color: #60a5fa; background: rgba(96,165,250,0.1); }
|
||||
.vcr-mode-live { color: var(--status-green); }
|
||||
.vcr-mode-paused { color: var(--status-yellow); background: rgba(251,191,36,0.1); }
|
||||
.vcr-mode-replay { color: var(--accent); background: rgba(96,165,250,0.1); }
|
||||
|
||||
.vcr-live-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #22c55e;
|
||||
background: var(--status-green);
|
||||
border-radius: 50%;
|
||||
margin-right: 4px;
|
||||
animation: vcr-pulse 1.5s ease-in-out infinite;
|
||||
@@ -541,7 +541,7 @@
|
||||
}
|
||||
.vcr-lcd-mode {
|
||||
font-size: 0.65rem;
|
||||
color: #4ade80;
|
||||
color: var(--status-green);
|
||||
text-shadow: 0 0 6px rgba(74, 222, 128, 0.6);
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -551,7 +551,7 @@
|
||||
}
|
||||
.vcr-lcd-pkts {
|
||||
font-size: 0.6rem;
|
||||
color: #fbbf24;
|
||||
color: var(--status-yellow);
|
||||
text-shadow: 0 0 4px rgba(251, 191, 36, 0.5);
|
||||
font-weight: 700;
|
||||
min-height: 0.7rem;
|
||||
@@ -559,7 +559,7 @@
|
||||
.vcr-missed {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
color: #fbbf24;
|
||||
color: var(--status-yellow);
|
||||
background: rgba(251,191,36,0.15);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
@@ -587,7 +587,7 @@
|
||||
}
|
||||
.vcr-scope-btn.active {
|
||||
background: rgba(59,130,246,0.2);
|
||||
color: #60a5fa;
|
||||
color: var(--accent);
|
||||
border-color: rgba(59,130,246,0.3);
|
||||
}
|
||||
|
||||
@@ -613,7 +613,7 @@
|
||||
top: 0;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
background: #f87171;
|
||||
background: var(--status-red);
|
||||
border-radius: 1px;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 0 4px rgba(248,113,113,0.5);
|
||||
@@ -631,7 +631,7 @@
|
||||
.vcr-prompt-btn {
|
||||
background: rgba(59,130,246,0.15);
|
||||
border: 1px solid rgba(59,130,246,0.25);
|
||||
color: #60a5fa;
|
||||
color: var(--accent);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 4px 12px;
|
||||
@@ -642,7 +642,7 @@
|
||||
.vcr-prompt-btn:hover { background: rgba(59,130,246,0.3); }
|
||||
|
||||
/* Adjust feed position to not overlap VCR bar */
|
||||
.live-feed { bottom: 58px; }
|
||||
.live-feed { bottom: 68px; }
|
||||
.feed-show-btn { bottom: 68px !important; }
|
||||
|
||||
/* Mobile VCR */
|
||||
|
||||
781
public/live.js
781
public/live.js
@@ -1,6 +1,10 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Status color helpers (read from CSS variables for theme support)
|
||||
function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
|
||||
function statusGreen() { return cssVar('--status-green') || '#22c55e'; }
|
||||
|
||||
let map, ws, nodesLayer, pathsLayer, animLayer, heatLayer;
|
||||
let nodeMarkers = {};
|
||||
let nodeData = {};
|
||||
@@ -8,9 +12,13 @@
|
||||
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;
|
||||
let _timelineRefreshInterval = null;
|
||||
@@ -32,7 +40,7 @@
|
||||
|
||||
// ROLE_COLORS loaded from shared roles.js (includes 'unknown')
|
||||
|
||||
const TYPE_COLORS = {
|
||||
const TYPE_COLORS = window.TYPE_COLORS || {
|
||||
ADVERT: '#22c55e', GRP_TXT: '#3b82f6', TXT_MSG: '#f59e0b', ACK: '#6b7280',
|
||||
REQUEST: '#a855f7', RESPONSE: '#06b6d4', TRACE: '#ec4899', PATH: '#14b8a6'
|
||||
};
|
||||
@@ -42,21 +50,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() {
|
||||
@@ -359,7 +352,7 @@
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
drawLcdText(`${hh}:${mm}:${ss}`, '#4ade80');
|
||||
drawLcdText(`${hh}:${mm}:${ss}`, statusGreen());
|
||||
}
|
||||
|
||||
function updateVCRLcd() {
|
||||
@@ -416,6 +409,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
|
||||
@@ -423,6 +417,21 @@
|
||||
}
|
||||
|
||||
// Buffer a packet from WS
|
||||
let _tabHidden = false;
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
_tabHidden = true;
|
||||
} else {
|
||||
// Tab restored — skip animating anything that queued while away
|
||||
_tabHidden = false;
|
||||
// Clear any pending propagation buffers so they don't all fire at once
|
||||
for (const [hash, entry] of propagationBuffer) {
|
||||
clearTimeout(entry.timer);
|
||||
}
|
||||
propagationBuffer.clear();
|
||||
}
|
||||
});
|
||||
|
||||
function bufferPacket(pkt) {
|
||||
pkt._ts = Date.now();
|
||||
const entry = { ts: pkt._ts, pkt };
|
||||
@@ -437,7 +446,26 @@
|
||||
}
|
||||
|
||||
if (VCR.mode === 'LIVE') {
|
||||
animatePacket(pkt);
|
||||
// Skip animations when tab is backgrounded — just buffer for VCR timeline
|
||||
if (_tabHidden) {
|
||||
updateTimeline();
|
||||
return;
|
||||
}
|
||||
if (realisticPropagation && pkt.hash) {
|
||||
const hash = pkt.hash;
|
||||
if (propagationBuffer.has(hash)) {
|
||||
propagationBuffer.get(hash).packets.push(pkt);
|
||||
} else {
|
||||
const entry = { packets: [pkt], timer: setTimeout(() => {
|
||||
const buffered = propagationBuffer.get(hash);
|
||||
propagationBuffer.delete(hash);
|
||||
if (buffered) animateRealisticPropagation(buffered.packets);
|
||||
}, PROPAGATION_BUFFER_MS) };
|
||||
propagationBuffer.set(hash, entry);
|
||||
}
|
||||
} else {
|
||||
animatePacket(pkt);
|
||||
}
|
||||
updateTimeline();
|
||||
} else if (VCR.mode === 'PAUSED') {
|
||||
VCR.missedCount++;
|
||||
@@ -586,12 +614,26 @@
|
||||
<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>
|
||||
<label><input type="checkbox" id="liveGhostToggle" checked aria-describedby="ghostDesc"> Ghosts</label>
|
||||
<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">
|
||||
@@ -606,11 +648,11 @@
|
||||
<div class="live-overlay live-legend" id="liveLegend" role="region" aria-label="Map legend">
|
||||
<h3 class="legend-title">PACKET TYPES</h3>
|
||||
<ul class="legend-list">
|
||||
<li><span class="live-dot" style="background:#22c55e" aria-hidden="true"></span> Advert — Node advertisement</li>
|
||||
<li><span class="live-dot" style="background:#3b82f6" aria-hidden="true"></span> Message — Group text</li>
|
||||
<li><span class="live-dot" style="background:#f59e0b" aria-hidden="true"></span> Direct — Direct message</li>
|
||||
<li><span class="live-dot" style="background:#a855f7" aria-hidden="true"></span> Request — Data request</li>
|
||||
<li><span class="live-dot" style="background:#ec4899" aria-hidden="true"></span> Trace — Route trace</li>
|
||||
<li><span class="live-dot" style="background:${TYPE_COLORS.ADVERT}" aria-hidden="true"></span> Advert — Node advertisement</li>
|
||||
<li><span class="live-dot" style="background:${TYPE_COLORS.GRP_TXT}" aria-hidden="true"></span> Message — Group text</li>
|
||||
<li><span class="live-dot" style="background:${TYPE_COLORS.TXT_MSG}" aria-hidden="true"></span> Direct — Direct message</li>
|
||||
<li><span class="live-dot" style="background:${TYPE_COLORS.REQUEST}" aria-hidden="true"></span> Request — Data request</li>
|
||||
<li><span class="live-dot" style="background:${TYPE_COLORS.TRACE}" aria-hidden="true"></span> Trace — Route trace</li>
|
||||
</ul>
|
||||
<h3 class="legend-title" style="margin-top:8px">NODE ROLES</h3>
|
||||
<ul class="legend-list" id="roleLegendList"></ul>
|
||||
@@ -645,10 +687,19 @@
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Fetch configurable map defaults (#115)
|
||||
let mapCenter = [37.45, -122.0];
|
||||
let mapZoom = 9;
|
||||
try {
|
||||
const mapCfg = await (await fetch('/api/config/map')).json();
|
||||
if (Array.isArray(mapCfg.center) && mapCfg.center.length === 2) mapCenter = mapCfg.center;
|
||||
if (typeof mapCfg.zoom === 'number') mapZoom = mapCfg.zoom;
|
||||
} catch {}
|
||||
|
||||
map = L.map('liveMap', {
|
||||
zoomControl: false, attributionControl: false,
|
||||
zoomAnimation: true, markerZoomAnimation: true
|
||||
}).setView([37.45, -122.0], 9);
|
||||
}).setView(mapCenter, mapZoom);
|
||||
|
||||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
(document.documentElement.getAttribute('data-theme') !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
@@ -674,14 +725,27 @@
|
||||
initResizeHandler();
|
||||
startRateCounter();
|
||||
|
||||
// Check for single packet replay from packets page
|
||||
// Check for packet replay from packets page (single or array of observations)
|
||||
const replayData = sessionStorage.getItem('replay-packet');
|
||||
if (replayData) {
|
||||
sessionStorage.removeItem('replay-packet');
|
||||
try {
|
||||
const pkt = JSON.parse(replayData);
|
||||
const parsed = JSON.parse(replayData);
|
||||
const packets = Array.isArray(parsed) ? parsed : [parsed];
|
||||
vcrPause(); // suppress live packets
|
||||
setTimeout(() => animatePacket(pkt), 1500);
|
||||
if (packets.length > 1 && packets[0].hash) {
|
||||
// Multiple observations — use realistic propagation (animate all paths at once)
|
||||
setTimeout(() => {
|
||||
if (typeof animateRealisticPropagation === 'function') {
|
||||
animateRealisticPropagation(packets);
|
||||
} else {
|
||||
// Fallback: stagger animations
|
||||
packets.forEach((p, i) => setTimeout(() => animatePacket(p), i * 400));
|
||||
}
|
||||
}, 1500);
|
||||
} else {
|
||||
setTimeout(() => animatePacket(packets[0]), 1500);
|
||||
}
|
||||
} catch {}
|
||||
} else {
|
||||
replayRecent();
|
||||
@@ -689,13 +753,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();
|
||||
@@ -708,6 +765,98 @@
|
||||
localStorage.setItem('live-ghost-hops', showGhostHops);
|
||||
});
|
||||
|
||||
const realisticToggle = document.getElementById('liveRealisticToggle');
|
||||
realisticToggle.checked = realisticPropagation;
|
||||
realisticToggle.addEventListener('change', (e) => {
|
||||
realisticPropagation = e.target.checked;
|
||||
localStorage.setItem('live-realistic-propagation', realisticPropagation);
|
||||
});
|
||||
|
||||
const favoritesToggle = document.getElementById('liveFavoritesToggle');
|
||||
favoritesToggle.checked = showOnlyFavorites;
|
||||
favoritesToggle.addEventListener('change', (e) => {
|
||||
showOnlyFavorites = e.target.checked;
|
||||
localStorage.setItem('live-favorites-only', showOnlyFavorites);
|
||||
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)
|
||||
@@ -1024,9 +1173,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>';
|
||||
}
|
||||
|
||||
@@ -1040,12 +1190,48 @@
|
||||
'</div>';
|
||||
}
|
||||
|
||||
html += `<div id="liveNodePaths" style="margin-top:8px;"><div style="font-size:11px;color:var(--text-muted);padding:4px 0;"><span class="spinner" style="font-size:10px"></span> Loading paths…</div></div>`;
|
||||
|
||||
html += `<div style="margin-top:12px;display:flex;gap:8px;">
|
||||
<a href="#/nodes/${encodeURIComponent(n.public_key)}" style="font-size:12px;color:var(--accent);">Full Detail →</a>
|
||||
<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" style="font-size:12px;color:var(--accent);">📊 Analytics</a>
|
||||
</div></div>`;
|
||||
|
||||
content.innerHTML = html;
|
||||
|
||||
// Fetch paths asynchronously
|
||||
api('/nodes/' + encodeURIComponent(n.public_key) + '/paths', { ttl: 300 }).then(pathData => {
|
||||
const pathEl = document.getElementById('liveNodePaths');
|
||||
if (!pathEl) return;
|
||||
if (!pathData || !pathData.paths || !pathData.paths.length) {
|
||||
pathEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const COLLAPSE = 5;
|
||||
function renderPathList(paths) {
|
||||
return paths.map(p => {
|
||||
const chain = p.hops.map(h => {
|
||||
const isThis = h.pubkey === n.public_key || (h.prefix && n.public_key.toLowerCase().startsWith(h.prefix.toLowerCase()));
|
||||
const name = escapeHtml(h.name || h.prefix);
|
||||
if (isThis) return `<strong style="color:var(--accent)">${name}</strong>`;
|
||||
return h.pubkey ? `<a href="#/nodes/${h.pubkey}" style="color:var(--text);text-decoration:none">${name}</a>` : name;
|
||||
}).join(' → ');
|
||||
return `<div style="padding:3px 0;font-size:11px;line-height:1.4">${chain} <span style="color:var(--text-muted)">(${p.count}×)</span></div>`;
|
||||
}).join('');
|
||||
}
|
||||
pathEl.innerHTML = `<h4 style="font-size:12px;margin:8px 0 4px;color:var(--text-muted);">Paths Through (${pathData.totalPaths})</h4>` +
|
||||
`<div id="livePathsList" style="max-height:200px;overflow-y:auto;">` +
|
||||
renderPathList(pathData.paths.slice(0, COLLAPSE)) +
|
||||
(pathData.paths.length > COLLAPSE ? `<button id="showMorePaths" style="font-size:11px;color:var(--accent);background:none;border:none;cursor:pointer;padding:4px 0;">Show all ${pathData.paths.length} paths</button>` : '') +
|
||||
'</div>';
|
||||
const moreBtn = document.getElementById('showMorePaths');
|
||||
if (moreBtn) moreBtn.addEventListener('click', () => {
|
||||
document.getElementById('livePathsList').innerHTML = renderPathList(pathData.paths);
|
||||
});
|
||||
}).catch(() => {
|
||||
const pathEl = document.getElementById('liveNodePaths');
|
||||
if (pathEl) pathEl.innerHTML = '';
|
||||
});
|
||||
} catch (e) {
|
||||
content.innerHTML = `<div style="padding:20px;color:var(--text-muted);">Error: ${e.message}</div>`;
|
||||
}
|
||||
@@ -1078,6 +1264,74 @@
|
||||
if (heatLayer) { map.removeLayer(heatLayer); heatLayer = null; }
|
||||
}
|
||||
|
||||
function getFavoritePubkeys() {
|
||||
let favs = [];
|
||||
try { favs = favs.concat(JSON.parse(localStorage.getItem('meshcore-favorites') || '[]')); } catch {}
|
||||
try { favs = favs.concat(JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]').map(n => n.pubkey)); } catch {}
|
||||
return favs.filter(Boolean);
|
||||
}
|
||||
|
||||
function packetInvolvesFavorite(pkt) {
|
||||
const favs = getFavoritePubkeys();
|
||||
if (favs.length === 0) return false;
|
||||
const decoded = pkt.decoded || {};
|
||||
const payload = decoded.payload || {};
|
||||
const hops = decoded.path?.hops || [];
|
||||
|
||||
// Full pubkeys: sender
|
||||
if (payload.pubKey && favs.some(f => f === payload.pubKey)) return true;
|
||||
|
||||
// Observer: may be name or pubkey
|
||||
const obs = pkt.observer_name || pkt.observer || '';
|
||||
if (obs) {
|
||||
if (favs.some(f => f === obs)) return true;
|
||||
for (const nd of Object.values(nodeData)) {
|
||||
if ((nd.name === obs || nd.public_key === obs) && favs.some(f => f === nd.public_key)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Hops are truncated hex prefixes — match by prefix in either direction
|
||||
for (const hop of hops) {
|
||||
const h = (hop.id || hop.public_key || hop).toString().toLowerCase();
|
||||
if (favs.some(f => f.toLowerCase().startsWith(h) || h.startsWith(f.toLowerCase()))) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isNodeFavorited(pubkey) {
|
||||
return getFavoritePubkeys().some(f => f === pubkey);
|
||||
}
|
||||
|
||||
function rebuildFeedList() {
|
||||
const feed = document.getElementById('liveFeed');
|
||||
if (!feed) return;
|
||||
// Remove all feed items but keep the hide button and resize handle
|
||||
feed.querySelectorAll('.live-feed-item').forEach(el => el.remove());
|
||||
// Re-add from VCR buffer (most recent first, up to 25)
|
||||
const entries = VCR.buffer.slice(-100).reverse();
|
||||
let count = 0;
|
||||
for (const entry of entries) {
|
||||
if (count >= 25) break;
|
||||
const pkt = entry.pkt;
|
||||
if (showOnlyFavorites && !packetInvolvesFavorite(pkt)) continue;
|
||||
const decoded = pkt.decoded || {};
|
||||
const header = decoded.header || {};
|
||||
const payload = decoded.payload || {};
|
||||
const typeName = header.payloadTypeName || 'UNKNOWN';
|
||||
const icon = PAYLOAD_ICONS[typeName] || '📦';
|
||||
const hops = decoded.path?.hops || [];
|
||||
const color = TYPE_COLORS[typeName] || '#6b7280';
|
||||
addFeedItemDOM(icon, typeName, payload, hops, color, pkt, feed);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
function applyFavoritesFilter() {
|
||||
// Node markers always stay visible — only rebuild the feed list
|
||||
rebuildFeedList();
|
||||
}
|
||||
|
||||
function addNodeMarker(n) {
|
||||
if (nodeMarkers[n.public_key]) return nodeMarkers[n.public_key];
|
||||
const color = ROLE_COLORS[n.role] || ROLE_COLORS.unknown;
|
||||
@@ -1105,6 +1359,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;
|
||||
}
|
||||
|
||||
@@ -1163,8 +1426,19 @@
|
||||
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;
|
||||
|
||||
// If ADVERT, ensure node appears on map
|
||||
if (typeName === 'ADVERT' && payload.pubKey) {
|
||||
@@ -1180,7 +1454,101 @@
|
||||
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) {
|
||||
if (!packets.length) return;
|
||||
const first = packets[0];
|
||||
const decoded = first.decoded || {};
|
||||
const header = decoded.header || {};
|
||||
const typeName = header.payloadTypeName || 'UNKNOWN';
|
||||
const color = TYPE_COLORS[typeName] || '#6b7280';
|
||||
const icon = PAYLOAD_ICONS[typeName] || '📦';
|
||||
const payload = decoded.payload || {};
|
||||
|
||||
packetCount += packets.length;
|
||||
pktTimestamps.push(Date.now());
|
||||
const _el = document.getElementById('livePktCount'); if (_el) _el.textContent = packetCount;
|
||||
|
||||
// Favorites filter: skip if none of the packets involve a favorite
|
||||
if (showOnlyFavorites && !packets.some(p => packetInvolvesFavorite(p))) return;
|
||||
|
||||
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) {
|
||||
const d = pkt.decoded || {};
|
||||
const h = d.header || {};
|
||||
const p = d.payload || {};
|
||||
if (h.payloadTypeName === 'ADVERT' && p.pubKey) {
|
||||
const key = p.pubKey;
|
||||
if (!nodeMarkers[key] && p.lat != null && p.lon != null && !(p.lat === 0 && p.lon === 0)) {
|
||||
const n = { public_key: key, name: p.name || key.slice(0,8), role: p.role || 'unknown', lat: p.lat, lon: p.lon };
|
||||
nodeData[key] = n;
|
||||
addNodeMarker(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
const _el2 = document.getElementById('liveNodeCount'); if (_el2) _el2.textContent = Object.keys(nodeMarkers).length;
|
||||
|
||||
// Resolve all unique paths
|
||||
const allPaths = [];
|
||||
const seenPathKeys = new Set();
|
||||
const observers = new Set();
|
||||
for (const pkt of packets) {
|
||||
const d = pkt.decoded || {};
|
||||
const p = d.payload || {};
|
||||
const hops = d.path?.hops || [];
|
||||
if (pkt.observer) observers.add(pkt.observer);
|
||||
const pathKey = hops.join(',');
|
||||
if (seenPathKeys.has(pathKey)) continue;
|
||||
seenPathKeys.add(pathKey);
|
||||
const hopPositions = resolveHopPositions(hops, p);
|
||||
if (hopPositions.length >= 2) allPaths.push(hopPositions);
|
||||
}
|
||||
|
||||
// Consolidated feed item
|
||||
const hops0 = decoded.path?.hops || [];
|
||||
const text = payload.text || payload.name || '';
|
||||
const preview = text ? ' ' + (text.length > 35 ? text.slice(0, 35) + '…' : text) : '';
|
||||
const feed = document.getElementById('liveFeed');
|
||||
if (feed) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'live-feed-item live-feed-enter';
|
||||
item.setAttribute('tabindex', '0');
|
||||
item.setAttribute('role', 'button');
|
||||
item.style.cursor = 'pointer';
|
||||
item.innerHTML = `
|
||||
<span class="feed-icon" style="color:${color}">${icon}</span>
|
||||
<span class="feed-type" style="color:${color}">${typeName}</span>
|
||||
<span class="feed-hops">${allPaths.length}⇢ ${observers.size}👁</span>
|
||||
<span class="feed-text">${escapeHtml(preview)}</span>
|
||||
<span class="feed-time">${new Date(first._ts || Date.now()).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})}</span>
|
||||
`;
|
||||
item.addEventListener('click', () => showFeedCard(item, first, color));
|
||||
feed.prepend(item);
|
||||
requestAnimationFrame(() => { requestAnimationFrame(() => item.classList.remove('live-feed-enter')); });
|
||||
while (feed.children.length > 25) feed.removeChild(feed.lastChild);
|
||||
}
|
||||
|
||||
if (allPaths.length === 0) {
|
||||
// Single hop or unresolvable — just pulse origin if possible
|
||||
const hp0 = resolveHopPositions(decoded.path?.hops || [], payload);
|
||||
if (hp0.length >= 1) pulseNode(hp0[0].key, hp0[0].pos, typeName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Animate all paths simultaneously
|
||||
for (const hopPositions of allPaths) {
|
||||
animatePath(hopPositions, typeName, color, first.raw);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveHopPositions(hops, payload) {
|
||||
@@ -1281,7 +1649,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;
|
||||
@@ -1317,7 +1686,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();
|
||||
@@ -1327,6 +1696,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
|
||||
@@ -1379,7 +1749,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;
|
||||
@@ -1412,13 +2056,12 @@
|
||||
|
||||
if (step >= steps) {
|
||||
clearInterval(interval);
|
||||
animLayer.removeLayer(dot);
|
||||
if (animLayer) animLayer.removeLayer(dot);
|
||||
|
||||
recentPaths.push({ line, glowLine: contrail, time: Date.now() });
|
||||
while (recentPaths.length > 5) {
|
||||
const old = recentPaths.shift();
|
||||
pathsLayer.removeLayer(old.line);
|
||||
pathsLayer.removeLayer(old.glowLine);
|
||||
if (pathsLayer) { pathsLayer.removeLayer(old.line); pathsLayer.removeLayer(old.glowLine); }
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -1427,8 +2070,7 @@
|
||||
fadeOp -= 0.1;
|
||||
if (fadeOp <= 0) {
|
||||
clearInterval(fi);
|
||||
pathsLayer.removeLayer(line);
|
||||
pathsLayer.removeLayer(contrail);
|
||||
if (pathsLayer) { pathsLayer.removeLayer(line); pathsLayer.removeLayer(contrail); }
|
||||
recentPaths = recentPaths.filter(p => p.line !== line);
|
||||
} else {
|
||||
line.setStyle({ opacity: fadeOp });
|
||||
@@ -1467,10 +2109,34 @@
|
||||
if (heatLayer) { map.removeLayer(heatLayer); heatLayer = null; }
|
||||
}
|
||||
|
||||
function addFeedItemDOM(icon, typeName, payload, hops, color, pkt, feed) {
|
||||
const text = payload.text || payload.name || '';
|
||||
const preview = text ? ' ' + (text.length > 35 ? text.slice(0, 35) + '…' : text) : '';
|
||||
const hopStr = hops.length ? `<span class="feed-hops">${hops.length}⇢</span>` : '';
|
||||
const obsBadge = pkt.observation_count > 1 ? `<span class="badge badge-obs" style="font-size:10px;margin-left:4px">👁 ${pkt.observation_count}</span>` : '';
|
||||
const item = document.createElement('div');
|
||||
item.className = 'live-feed-item';
|
||||
item.setAttribute('tabindex', '0');
|
||||
item.setAttribute('role', 'button');
|
||||
item.style.cursor = 'pointer';
|
||||
item.innerHTML = `
|
||||
<span class="feed-icon" style="color:${color}">${icon}</span>
|
||||
<span class="feed-type" style="color:${color}">${typeName}</span>
|
||||
${hopStr}${obsBadge}
|
||||
<span class="feed-text">${escapeHtml(preview)}</span>
|
||||
<span class="feed-time">${new Date(pkt._ts || Date.now()).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})}</span>
|
||||
`;
|
||||
item.addEventListener('click', () => showFeedCard(item, pkt, color));
|
||||
feed.appendChild(item);
|
||||
}
|
||||
|
||||
function addFeedItem(icon, typeName, payload, hops, color, pkt) {
|
||||
const feed = document.getElementById('liveFeed');
|
||||
if (!feed) return;
|
||||
|
||||
// Favorites filter: skip feed item if packet doesn't involve a favorite
|
||||
if (showOnlyFavorites && !packetInvolvesFavorite(pkt)) return;
|
||||
|
||||
const text = payload.text || payload.name || '';
|
||||
const preview = text ? ' ' + (text.length > 35 ? text.slice(0, 35) + '…' : text) : '';
|
||||
const hopStr = hops.length ? `<span class="feed-hops">${hops.length}⇢</span>` : '';
|
||||
@@ -1525,7 +2191,7 @@
|
||||
${rssi != null ? `<span>📡 ${rssi} dBm</span>` : ''}
|
||||
${observer ? `<span>👁 ${escapeHtml(observer)}</span>` : ''}
|
||||
</div>
|
||||
${pkt.hash ? `<a class="fdc-link" href="#/packets/${pkt.hash}">View in packets →</a>` : ''}
|
||||
${pkt.hash ? `<a class="fdc-link" href="#/packets/${pkt.hash.toLowerCase()}">View in packets →</a>` : ''}
|
||||
<button class="fdc-replay">↻ Replay</button>
|
||||
`;
|
||||
card.querySelector('.fdc-close').addEventListener('click', (e) => { e.stopPropagation(); card.remove(); });
|
||||
@@ -1567,6 +2233,7 @@
|
||||
_navCleanup = null;
|
||||
}
|
||||
nodesLayer = pathsLayer = animLayer = heatLayer = null;
|
||||
stopMatrixRain();
|
||||
nodeMarkers = {}; nodeData = {};
|
||||
recentPaths = [];
|
||||
packetCount = 0; activeAnims = 0;
|
||||
@@ -1574,5 +2241,17 @@
|
||||
VCR.buffer = []; VCR.playhead = -1; VCR.mode = 'LIVE'; VCR.missedCount = 0; VCR.speed = 1;
|
||||
}
|
||||
|
||||
registerPage('live', { init, destroy });
|
||||
let _themeRefreshHandler = null;
|
||||
|
||||
registerPage('live', {
|
||||
init: function(app, routeParam) {
|
||||
_themeRefreshHandler = () => { /* live map rebuilds on next packet */ };
|
||||
window.addEventListener('theme-refresh', _themeRefreshHandler);
|
||||
return init(app, routeParam);
|
||||
},
|
||||
destroy: function() {
|
||||
if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; }
|
||||
return destroy();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
290
public/map.js
290
public/map.js
@@ -7,8 +7,9 @@
|
||||
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 };
|
||||
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;
|
||||
let heatLayer = null;
|
||||
let userHasMoved = false;
|
||||
@@ -60,7 +61,24 @@
|
||||
});
|
||||
}
|
||||
|
||||
function init(container) {
|
||||
function makeRepeaterLabelIcon(node) {
|
||||
var s = ROLE_STYLE['repeater'] || ROLE_STYLE.companion;
|
||||
var hs = node.hash_size || 1;
|
||||
// Show the short mesh hash ID (first N bytes of pubkey, uppercased)
|
||||
var shortHash = node.public_key ? node.public_key.slice(0, hs * 2).toUpperCase() : '??';
|
||||
var bgColor = node.hash_size ? s.color : '#888';
|
||||
var html = '<div style="background:' + bgColor + ';color:#fff;font-weight:bold;font-size:11px;padding:2px 5px;border-radius:3px;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,0.4);text-align:center;line-height:1.2;white-space:nowrap;">' +
|
||||
shortHash + '</div>';
|
||||
return L.divIcon({
|
||||
html: html,
|
||||
className: 'meshcore-marker meshcore-label-marker',
|
||||
iconSize: null,
|
||||
iconAnchor: [14, 12],
|
||||
popupAnchor: [0, -12],
|
||||
});
|
||||
}
|
||||
|
||||
async function init(container) {
|
||||
container.innerHTML = `
|
||||
<div id="map-wrap" style="position:relative;width:100%;height:100%;">
|
||||
<div id="leaflet-map" style="width:100%;height:100%;"></div>
|
||||
@@ -75,6 +93,7 @@
|
||||
<legend class="mc-label">Display</legend>
|
||||
<label for="mcClusters"><input type="checkbox" id="mcClusters"> Show clusters</label>
|
||||
<label for="mcHeatmap"><input type="checkbox" id="mcHeatmap"> Heat map</label>
|
||||
<label for="mcHashLabels"><input type="checkbox" id="mcHashLabels"> Hash prefix labels</label>
|
||||
</fieldset>
|
||||
<fieldset class="mc-section">
|
||||
<legend class="mc-label">Filters</legend>
|
||||
@@ -98,17 +117,32 @@
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Init Leaflet — restore saved position or default to Bay Area
|
||||
const defaultCenter = [37.6, -122.1];
|
||||
const defaultZoom = 9;
|
||||
// Init Leaflet — restore saved position or use configurable defaults (#115)
|
||||
let defaultCenter = [37.6, -122.1];
|
||||
let defaultZoom = 9;
|
||||
try {
|
||||
const mapCfg = await (await fetch('/api/config/map')).json();
|
||||
if (Array.isArray(mapCfg.center) && mapCfg.center.length === 2) defaultCenter = mapCfg.center;
|
||||
if (typeof mapCfg.zoom === 'number') defaultZoom = mapCfg.zoom;
|
||||
} 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, {
|
||||
@@ -129,6 +163,10 @@
|
||||
userHasMoved = true;
|
||||
});
|
||||
|
||||
map.on('zoomend', () => {
|
||||
if (filters.hashLabels && !_renderingMarkers) renderMarkers();
|
||||
});
|
||||
|
||||
markerLayer = L.layerGroup().addTo(map);
|
||||
routeLayer = L.layerGroup().addTo(map);
|
||||
|
||||
@@ -154,6 +192,13 @@
|
||||
document.getElementById('mcClusters').addEventListener('change', e => { filters.clusters = e.target.checked; renderMarkers(); });
|
||||
document.getElementById('mcHeatmap').addEventListener('change', e => { toggleHeatmap(e.target.checked); });
|
||||
document.getElementById('mcNeighbors').addEventListener('change', e => { filters.neighbors = e.target.checked; renderMarkers(); });
|
||||
|
||||
// Hash Labels toggle
|
||||
const hashLabelEl = document.getElementById('mcHashLabels');
|
||||
if (hashLabelEl) {
|
||||
hashLabelEl.checked = filters.hashLabels;
|
||||
hashLabelEl.addEventListener('change', e => { filters.hashLabels = e.target.checked; localStorage.setItem('meshcore-map-hash-labels', filters.hashLabels); renderMarkers(); });
|
||||
}
|
||||
document.getElementById('mcLastHeard').addEventListener('change', e => { filters.lastHeard = e.target.value; loadNodes(); });
|
||||
|
||||
// WS for live advert updates
|
||||
@@ -169,14 +214,42 @@
|
||||
if (routeHopsJson) {
|
||||
sessionStorage.removeItem('map-route-hops');
|
||||
try {
|
||||
const hopKeys = JSON.parse(routeHopsJson);
|
||||
drawPacketRoute(hopKeys);
|
||||
const parsed = JSON.parse(routeHopsJson);
|
||||
// Support new format {origin, hops} and legacy plain array
|
||||
if (Array.isArray(parsed)) {
|
||||
drawPacketRoute(parsed, null);
|
||||
} else {
|
||||
drawPacketRoute(parsed.hops || [], parsed.origin || null);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function drawPacketRoute(hopKeys) {
|
||||
function drawPacketRoute(hopKeys, origin) {
|
||||
// Hide default markers so only the route is visible
|
||||
if (markerLayer) map.removeLayer(markerLayer);
|
||||
if (clusterGroup) map.removeLayer(clusterGroup);
|
||||
if (heatLayer) map.removeLayer(heatLayer);
|
||||
|
||||
routeLayer.clearLayers();
|
||||
|
||||
// Add close route button
|
||||
const closeBtn = L.control({ position: 'topright' });
|
||||
closeBtn.onAdd = function () {
|
||||
const div = L.DomUtil.create('div', 'leaflet-bar');
|
||||
div.innerHTML = '<a href="#" title="Close route" style="font-size:18px;font-weight:bold;text-decoration:none;display:block;width:36px;height:36px;line-height:36px;text-align:center;background:var(--input-bg,#1e293b);color:var(--text,#e2e8f0);border-radius:4px">✕</a>';
|
||||
L.DomEvent.on(div, 'click', function (e) {
|
||||
L.DomEvent.preventDefault(e);
|
||||
routeLayer.clearLayers();
|
||||
if (markerLayer) map.addLayer(markerLayer);
|
||||
if (clusterGroup) map.addLayer(clusterGroup);
|
||||
map.removeControl(closeBtn);
|
||||
});
|
||||
return div;
|
||||
};
|
||||
closeBtn.addTo(map);
|
||||
|
||||
// Resolve hop short hashes to node positions with geographic disambiguation
|
||||
const raw = hopKeys.map(hop => {
|
||||
const hopLower = hop.toLowerCase();
|
||||
@@ -213,29 +286,52 @@
|
||||
}
|
||||
|
||||
const positions = raw.filter(h => h && h.resolved);
|
||||
|
||||
// Resolve and prepend origin node
|
||||
if (origin) {
|
||||
let originPos = null;
|
||||
if (origin.lat != null && origin.lon != null) {
|
||||
originPos = { lat: origin.lat, lon: origin.lon, name: origin.name || 'Sender', pubkey: origin.pubkey, isOrigin: true };
|
||||
} else if (origin.pubkey) {
|
||||
const pk = origin.pubkey.toLowerCase();
|
||||
const match = nodes.find(n => n.public_key.toLowerCase() === pk || n.public_key.toLowerCase().startsWith(pk));
|
||||
if (match && match.lat != null && match.lon != null) {
|
||||
originPos = { lat: match.lat, lon: match.lon, name: origin.name || match.name || 'Sender', pubkey: match.public_key, role: match.role, isOrigin: true };
|
||||
}
|
||||
}
|
||||
if (originPos) positions.unshift(originPos);
|
||||
}
|
||||
|
||||
if (positions.length < 1) return;
|
||||
|
||||
// Even a single node is worth showing (zoom to it)
|
||||
const coords = positions.map(p => [p.lat, p.lon]);
|
||||
|
||||
if (positions.length >= 2) {
|
||||
// Draw route polyline
|
||||
L.polyline(coords, {
|
||||
color: '#f59e0b', weight: 3, opacity: 0.8, dashArray: '8 4'
|
||||
}).addTo(routeLayer);
|
||||
}
|
||||
|
||||
// Add numbered markers at each hop
|
||||
var labelItems = [];
|
||||
positions.forEach((p, i) => {
|
||||
const color = i === 0 ? '#22c55e' : i === positions.length - 1 ? '#ef4444' : '#f59e0b';
|
||||
const label = i === 0 ? 'Origin' : i === positions.length - 1 ? 'Destination' : `Hop ${i}`;
|
||||
const isOrigin = i === 0 && p.isOrigin;
|
||||
const isLast = i === positions.length - 1 && positions.length > 1;
|
||||
const color = isOrigin ? '#06b6d4' : isLast ? (getComputedStyle(document.documentElement).getPropertyValue('--status-red').trim() || '#ef4444') : i === 0 ? (getComputedStyle(document.documentElement).getPropertyValue('--status-green').trim() || '#22c55e') : '#f59e0b';
|
||||
const radius = isOrigin ? 14 : 10;
|
||||
const label = isOrigin ? 'Sender' : isLast ? 'Last Hop' : `Hop ${isOrigin ? i : i}`;
|
||||
|
||||
if (isOrigin) {
|
||||
L.circleMarker([p.lat, p.lon], {
|
||||
radius: radius + 4, fillColor: 'transparent', fillOpacity: 0, color: '#06b6d4', weight: 2, opacity: 0.6
|
||||
}).addTo(routeLayer);
|
||||
}
|
||||
|
||||
const marker = L.circleMarker([p.lat, p.lon], {
|
||||
radius: 10, fillColor: color,
|
||||
radius: radius, fillColor: color,
|
||||
fillOpacity: 0.9, color: '#fff', weight: 2
|
||||
}).addTo(routeLayer);
|
||||
|
||||
marker.bindTooltip(`${i + 1}. ${p.name}`, { permanent: true, direction: 'top', className: 'route-tooltip' });
|
||||
|
||||
|
||||
const popupHtml = `<div style="font-size:12px;min-width:160px">
|
||||
<div style="font-weight:700;margin-bottom:4px">${label}: ${safeEsc(p.name)}</div>
|
||||
<div style="color:#9ca3af;font-size:11px;margin-bottom:4px">${p.role || 'unknown'}</div>
|
||||
@@ -244,6 +340,19 @@
|
||||
${p.pubkey ? `<div style="margin-top:6px"><a href="#/nodes/${p.pubkey}" style="color:var(--accent);font-size:11px">View Node →</a></div>` : ''}
|
||||
</div>`;
|
||||
marker.bindPopup(popupHtml, { className: 'route-popup' });
|
||||
|
||||
labelItems.push({ latLng: L.latLng(p.lat, p.lon), isLabel: true, text: `${i + 1}. ${p.name}` });
|
||||
});
|
||||
|
||||
// Deconflict labels so overlapping hop names spread out
|
||||
deconflictLabels(labelItems, map);
|
||||
labelItems.forEach(function (m) {
|
||||
var pos = m.adjustedLatLng || m.latLng;
|
||||
var icon = L.divIcon({ className: 'route-tooltip', html: m.text, iconSize: [null, null], iconAnchor: [0, 0] });
|
||||
L.marker(pos, { icon: icon, interactive: false }).addTo(routeLayer);
|
||||
if (m.offset > 2) {
|
||||
L.polyline([m.latLng, pos], { weight: 1, color: '#475569', opacity: 0.5, dashArray: '3 3' }).addTo(routeLayer);
|
||||
}
|
||||
});
|
||||
|
||||
// Fit map to route
|
||||
@@ -270,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) {
|
||||
@@ -345,7 +475,65 @@
|
||||
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 12 });
|
||||
}
|
||||
|
||||
var _renderingMarkers = false;
|
||||
var _lastDeconflictZoom = null;
|
||||
|
||||
function deconflictLabels(markers, mapRef) {
|
||||
const placed = [];
|
||||
const PAD = 4;
|
||||
|
||||
var overlaps = function(b) {
|
||||
for (var k = 0; k < placed.length; k++) {
|
||||
var p = placed[k];
|
||||
if (b.x < p.x + p.w + PAD && b.x + b.w + PAD > p.x &&
|
||||
b.y < p.y + p.h + PAD && b.y + b.h + PAD > p.y) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Spiral offsets — 6 rings, 8 directions, up to ~132px
|
||||
var offsets = [];
|
||||
for (var ring = 1; ring <= 6; ring++) {
|
||||
var dist = ring * 22;
|
||||
for (var angle = 0; angle < 360; angle += 45) {
|
||||
var rad = angle * Math.PI / 180;
|
||||
offsets.push([Math.round(Math.cos(rad) * dist), Math.round(Math.sin(rad) * dist)]);
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < markers.length; i++) {
|
||||
var m = markers[i];
|
||||
var w = m.isLabel ? 38 : 20;
|
||||
var h = m.isLabel ? 24 : 20;
|
||||
var pt = mapRef.latLngToLayerPoint(m.latLng);
|
||||
var bestPt = pt;
|
||||
var box = { x: pt.x - w / 2, y: pt.y - h / 2, w: w, h: h };
|
||||
|
||||
if (overlaps(box)) {
|
||||
for (var j = 0; j < offsets.length; j++) {
|
||||
var tryPt = L.point(pt.x + offsets[j][0], pt.y + offsets[j][1]);
|
||||
var tryBox = { x: tryPt.x - w / 2, y: tryPt.y - h / 2, w: w, h: h };
|
||||
if (!overlaps(tryBox)) {
|
||||
bestPt = tryPt;
|
||||
box = tryBox;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
placed.push(box);
|
||||
m.adjustedLatLng = mapRef.layerPointToLatLng(bestPt);
|
||||
m.offset = Math.sqrt(Math.pow(bestPt.x - pt.x, 2) + Math.pow(bestPt.y - pt.y, 2));
|
||||
}
|
||||
}
|
||||
|
||||
function renderMarkers() {
|
||||
if (_renderingMarkers) return;
|
||||
_renderingMarkers = true;
|
||||
try { _renderMarkersInner(); } finally { _renderingMarkers = false; }
|
||||
}
|
||||
|
||||
function _renderMarkersInner() {
|
||||
markerLayer.clearLayers();
|
||||
|
||||
const filtered = nodes.filter(n => {
|
||||
@@ -354,15 +542,13 @@
|
||||
return true;
|
||||
});
|
||||
|
||||
for (const node of filtered) {
|
||||
const icon = makeMarkerIcon(node.role || 'companion');
|
||||
const marker = L.marker([node.lat, node.lon], {
|
||||
icon,
|
||||
alt: `${node.name || 'Unknown'} (${node.role || 'node'})`,
|
||||
});
|
||||
const allMarkers = [];
|
||||
|
||||
marker.bindPopup(buildPopup(node), { maxWidth: 280 });
|
||||
markerLayer.addLayer(marker);
|
||||
for (const node of filtered) {
|
||||
const useLabel = node.role === 'repeater' && filters.hashLabels;
|
||||
const icon = useLabel ? makeRepeaterLabelIcon(node) : makeMarkerIcon(node.role || 'companion');
|
||||
const latLng = L.latLng(node.lat, node.lon);
|
||||
allMarkers.push({ latLng, node, icon, isLabel: useLabel, popupFn: function() { return buildPopup(node); }, alt: (node.name || 'Unknown') + ' (' + (node.role || 'node') + ')' });
|
||||
}
|
||||
|
||||
// Add observer markers
|
||||
@@ -370,12 +556,33 @@
|
||||
for (const obs of observers) {
|
||||
if (!obs.lat || !obs.lon) continue;
|
||||
const icon = makeMarkerIcon('observer');
|
||||
const marker = L.marker([obs.lat, obs.lon], {
|
||||
icon,
|
||||
alt: `${obs.name || obs.id} (observer)`,
|
||||
const latLng = L.latLng(obs.lat, obs.lon);
|
||||
allMarkers.push({ latLng, node: obs, icon, isLabel: false, popupFn: function() { return buildObserverPopup(obs); }, alt: (obs.name || obs.id || 'Unknown') + ' (observer)' });
|
||||
}
|
||||
}
|
||||
|
||||
// Deconflict ALL markers
|
||||
if (allMarkers.length > 0) {
|
||||
deconflictLabels(allMarkers, map);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (m.offset > 10) {
|
||||
const line = L.polyline([m.latLng, pos], {
|
||||
color: getComputedStyle(document.documentElement).getPropertyValue('--status-red').trim() || '#ef4444', weight: 2, dashArray: '6,4', opacity: 0.85
|
||||
});
|
||||
marker.bindPopup(buildObserverPopup(obs), { maxWidth: 280 });
|
||||
markerLayer.addLayer(marker);
|
||||
markerLayer.addLayer(line);
|
||||
// Small dot at true GPS position
|
||||
const dot = L.circleMarker(m.latLng, {
|
||||
radius: 3, fillColor: getComputedStyle(document.documentElement).getPropertyValue('--status-red').trim() || '#ef4444', fillOpacity: 0.9, stroke: true, color: '#fff', weight: 1
|
||||
});
|
||||
markerLayer.addLayer(dot);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -409,12 +616,17 @@
|
||||
const loc = (node.lat && node.lon) ? `${node.lat.toFixed(5)}, ${node.lon.toFixed(5)}` : '—';
|
||||
const lastAdvert = node.last_seen ? timeAgo(node.last_seen) : '—';
|
||||
const roleBadge = `<span style="display:inline-block;padding:2px 8px;border-radius:12px;font-size:11px;font-weight:600;background:${ROLE_COLORS[node.role] || '#4b5563'};color:#fff;">${(node.role || 'unknown').toUpperCase()}</span>`;
|
||||
const hs = node.hash_size || 1;
|
||||
const hashPrefix = node.public_key ? node.public_key.slice(0, hs * 2).toUpperCase() : '—';
|
||||
const hashPrefixRow = `<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Hash Prefix</dt>
|
||||
<dd style="font-family:var(--mono);font-size:11px;font-weight:700;margin-left:88px;padding:2px 0;">${safeEsc(hashPrefix)} <span style="font-weight:400;color:var(--text-muted);">(${hs}B)</span></dd>`;
|
||||
|
||||
return `
|
||||
<div class="map-popup" style="font-family:var(--font);min-width:180px;">
|
||||
<h3 style="font-weight:700;font-size:14px;margin:0 0 4px;">${safeEsc(node.name || 'Unknown')}</h3>
|
||||
${roleBadge}
|
||||
<dl style="margin-top:8px;font-size:12px;">
|
||||
${hashPrefixRow}
|
||||
<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Key</dt>
|
||||
<dd style="font-family:var(--mono);font-size:11px;margin-left:88px;padding:2px 0;">${safeEsc(key)}</dd>
|
||||
<dt style="color:var(--text-muted);float:left;clear:left;width:80px;padding:2px 0;">Location</dt>
|
||||
@@ -468,5 +680,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
registerPage('map', { init, destroy });
|
||||
let _themeRefreshHandler = null;
|
||||
|
||||
registerPage('map', {
|
||||
init: function(app, routeParam) {
|
||||
_themeRefreshHandler = () => { if (markerLayer) renderMarkers(); };
|
||||
window.addEventListener('theme-refresh', _themeRefreshHandler);
|
||||
return init(app, routeParam);
|
||||
},
|
||||
destroy: function() {
|
||||
if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; }
|
||||
return destroy();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
127
public/nodes.js
127
public/nodes.js
@@ -35,6 +35,8 @@
|
||||
|
||||
let directNode = null; // set when navigating directly to #/nodes/:pubkey
|
||||
|
||||
let regionChangeHandler = null;
|
||||
|
||||
function init(app, routeParam) {
|
||||
directNode = routeParam || null;
|
||||
|
||||
@@ -66,12 +68,16 @@
|
||||
<input type="text" class="nodes-search" id="nodeSearch" placeholder="Search nodes by name…" aria-label="Search nodes by name">
|
||||
<div class="nodes-counts" id="nodeCounts"></div>
|
||||
</div>
|
||||
<div id="nodesRegionFilter" class="region-filter-container"></div>
|
||||
<div class="split-layout">
|
||||
<div class="panel-left" id="nodesLeft"></div>
|
||||
<div class="panel-right empty" id="nodesRight"><span>Select a node to view details</span></div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
RegionFilter.init(document.getElementById('nodesRegionFilter'));
|
||||
regionChangeHandler = RegionFilter.onChange(function () { loadNodes(); });
|
||||
|
||||
document.getElementById('nodeSearch').addEventListener('input', debounce(e => {
|
||||
search = e.target.value;
|
||||
loadNodes();
|
||||
@@ -137,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>
|
||||
@@ -151,6 +159,11 @@
|
||||
</table>
|
||||
</div>` : ''}
|
||||
|
||||
<div class="node-full-card" id="fullPathsSection">
|
||||
<h4>Paths Through This Node</h4>
|
||||
<div id="fullPathsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading paths…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="node-full-card">
|
||||
<h4>Recent Packets (${adverts.length})</h4>
|
||||
<div class="node-activity-list">
|
||||
@@ -208,15 +221,60 @@
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Fetch paths through this node (full-screen view)
|
||||
api('/nodes/' + encodeURIComponent(n.public_key) + '/paths', { ttl: CLIENT_TTL.nodeDetail }).then(pathData => {
|
||||
const el = document.getElementById('fullPathsContent');
|
||||
if (!el) return;
|
||||
if (!pathData || !pathData.paths || !pathData.paths.length) {
|
||||
el.innerHTML = '<div class="text-muted" style="padding:8px">No paths observed through this node</div>';
|
||||
return;
|
||||
}
|
||||
document.querySelector('#fullPathsSection h4').textContent = `Paths Through This Node (${pathData.totalPaths} unique, ${pathData.totalTransmissions} transmissions)`;
|
||||
const COLLAPSE_LIMIT = 10;
|
||||
function renderPaths(paths) {
|
||||
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;
|
||||
}).join(' → ');
|
||||
return `<div style="padding:6px 0;border-bottom:1px solid var(--border);font-size:12px">
|
||||
<div>${chain}</div>
|
||||
<div style="color:var(--text-muted);margin-top:2px">${p.count}× · last ${timeAgo(p.lastSeen)} · <a href="#/packets/${p.sampleHash}" class="ch-analyze-link">Analyze →</a></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
if (pathData.paths.length <= COLLAPSE_LIMIT) {
|
||||
el.innerHTML = renderPaths(pathData.paths);
|
||||
} else {
|
||||
el.innerHTML = renderPaths(pathData.paths.slice(0, COLLAPSE_LIMIT)) +
|
||||
`<button id="showAllFullPaths" class="btn-primary" style="margin-top:8px;font-size:11px;padding:4px 12px">Show all ${pathData.paths.length} paths</button>`;
|
||||
document.getElementById('showAllFullPaths').addEventListener('click', function() {
|
||||
el.innerHTML = renderPaths(pathData.paths);
|
||||
});
|
||||
}
|
||||
}).catch(() => {
|
||||
const el = document.getElementById('fullPathsContent');
|
||||
if (el) el.innerHTML = '<div class="text-muted" style="padding:8px">Failed to load paths</div>';
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
body.innerHTML = `<div class="text-muted" style="padding:40px">Failed to load node: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
function destroy() {
|
||||
if (wsHandler) offWS(wsHandler);
|
||||
wsHandler = null;
|
||||
if (detailMap) { detailMap.remove(); detailMap = null; }
|
||||
if (regionChangeHandler) RegionFilter.offChange(regionChangeHandler);
|
||||
regionChangeHandler = null;
|
||||
nodes = [];
|
||||
selectedKey = null;
|
||||
}
|
||||
@@ -227,10 +285,19 @@
|
||||
if (activeTab !== 'all') params.set('role', activeTab);
|
||||
if (search) params.set('search', search);
|
||||
if (lastHeard) params.set('lastHeard', lastHeard);
|
||||
const rp = RegionFilter.getRegionParam();
|
||||
if (rp) params.set('region', rp);
|
||||
const data = await api('/nodes?' + params, { ttl: CLIENT_TTL.nodeList });
|
||||
nodes = data.nodes || [];
|
||||
counts = data.counts || {};
|
||||
|
||||
// Defensive filter: hide nodes with obviously corrupted data
|
||||
nodes = nodes.filter(n => {
|
||||
if (n.public_key && n.public_key.length < 16) return false;
|
||||
if (!n.name && !n.advert_count) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Ensure claimed nodes are always present even if not in current page
|
||||
const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]');
|
||||
const existingKeys = new Set(nodes.map(n => n.public_key));
|
||||
@@ -260,10 +327,10 @@
|
||||
const el = document.getElementById('nodeCounts');
|
||||
if (!el) return;
|
||||
el.innerHTML = [
|
||||
{ k: 'repeaters', l: 'Repeaters', c: '#3b82f6' },
|
||||
{ k: 'rooms', l: 'Rooms', c: '#6b7280' },
|
||||
{ k: 'companions', l: 'Companions', c: '#22c55e' },
|
||||
{ k: 'sensors', l: 'Sensors', c: '#f59e0b' },
|
||||
{ k: 'repeaters', l: 'Repeaters', c: ROLE_COLORS.repeater },
|
||||
{ k: 'rooms', l: 'Rooms', c: ROLE_COLORS.room || '#6b7280' },
|
||||
{ k: 'companions', l: 'Companions', c: ROLE_COLORS.companion },
|
||||
{ k: 'sensors', l: 'Sensors', c: ROLE_COLORS.sensor },
|
||||
].map(r => `<span class="node-count-pill" style="background:${r.c}">${counts[r.k] || 0} ${r.l}</span>`).join('');
|
||||
}
|
||||
|
||||
@@ -458,15 +525,21 @@
|
||||
</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>
|
||||
</div>` : ''}
|
||||
|
||||
<div class="node-detail-section" id="pathsSection">
|
||||
<h4>Paths Through This Node</h4>
|
||||
<div id="pathsContent"><div class="text-muted" style="padding:8px"><span class="spinner"></span> Loading paths…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="node-detail-section">
|
||||
<h4>Recent Packets (${adverts.length})</h4>
|
||||
<div id="advertTimeline">
|
||||
@@ -528,6 +601,46 @@
|
||||
setTimeout(() => btn.textContent = '📋 Copy URL', 2000);
|
||||
}).catch(() => {});
|
||||
});
|
||||
|
||||
// Fetch paths through this node
|
||||
api('/nodes/' + encodeURIComponent(n.public_key) + '/paths', { ttl: CLIENT_TTL.nodeDetail }).then(pathData => {
|
||||
const el = document.getElementById('pathsContent');
|
||||
if (!el) return;
|
||||
if (!pathData || !pathData.paths || !pathData.paths.length) {
|
||||
el.innerHTML = '<div class="text-muted" style="padding:8px">No paths observed through this node</div>';
|
||||
document.querySelector('#pathsSection h4').textContent = 'Paths Through This Node';
|
||||
return;
|
||||
}
|
||||
document.querySelector('#pathsSection h4').textContent = `Paths Through This Node (${pathData.totalPaths} unique path${pathData.totalPaths !== 1 ? 's' : ''}, ${pathData.totalTransmissions} transmissions)`;
|
||||
const COLLAPSE_LIMIT = 10;
|
||||
const showAll = pathData.paths.length <= COLLAPSE_LIMIT;
|
||||
function renderPaths(paths) {
|
||||
return paths.map(p => {
|
||||
const chain = p.hops.map(h => {
|
||||
const isThis = h.pubkey === n.public_key;
|
||||
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;
|
||||
}).join(' → ');
|
||||
return `<div style="padding:6px 0;border-bottom:1px solid var(--border);font-size:12px">
|
||||
<div>${chain}</div>
|
||||
<div style="color:var(--text-muted);margin-top:2px">${p.count}× · last ${timeAgo(p.lastSeen)} · <a href="#/packets/${p.sampleHash}" class="ch-analyze-link">Analyze →</a></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
if (showAll) {
|
||||
el.innerHTML = renderPaths(pathData.paths);
|
||||
} else {
|
||||
el.innerHTML = renderPaths(pathData.paths.slice(0, COLLAPSE_LIMIT)) +
|
||||
`<button id="showAllPaths" class="btn-primary" style="margin-top:8px;font-size:11px;padding:4px 12px">Show all ${pathData.paths.length} paths</button>`;
|
||||
document.getElementById('showAllPaths').addEventListener('click', function() {
|
||||
el.innerHTML = renderPaths(pathData.paths);
|
||||
});
|
||||
}
|
||||
}).catch(() => {
|
||||
const el = document.getElementById('pathsContent');
|
||||
if (el) el.innerHTML = '<div class="text-muted" style="padding:8px">Failed to load paths</div>';
|
||||
});
|
||||
}
|
||||
|
||||
registerPage('nodes', { init, destroy });
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
let observers = [];
|
||||
let wsHandler = null;
|
||||
let refreshTimer = null;
|
||||
let regionChangeHandler = null;
|
||||
|
||||
function init(app) {
|
||||
app.innerHTML = `
|
||||
@@ -13,8 +14,11 @@
|
||||
<h2>Observer Status</h2>
|
||||
<button class="btn-icon" data-action="obs-refresh" title="Refresh" aria-label="Refresh observers">🔄</button>
|
||||
</div>
|
||||
<div id="obsRegionFilter" class="region-filter-container"></div>
|
||||
<div id="obsContent"><div class="text-center text-muted" style="padding:40px">Loading…</div></div>
|
||||
</div>`;
|
||||
RegionFilter.init(document.getElementById('obsRegionFilter'));
|
||||
regionChangeHandler = RegionFilter.onChange(function () { render(); });
|
||||
loadObservers();
|
||||
// Event delegation for data-action buttons
|
||||
app.addEventListener('click', function (e) {
|
||||
@@ -33,6 +37,8 @@
|
||||
wsHandler = null;
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
if (regionChangeHandler) RegionFilter.offChange(regionChangeHandler);
|
||||
regionChangeHandler = null;
|
||||
observers = [];
|
||||
}
|
||||
|
||||
@@ -78,24 +84,30 @@
|
||||
const el = document.getElementById('obsContent');
|
||||
if (!el) return;
|
||||
|
||||
if (observers.length === 0) {
|
||||
// Apply region filter
|
||||
const selectedRegions = RegionFilter.getSelected();
|
||||
const filtered = selectedRegions
|
||||
? observers.filter(o => o.iata && selectedRegions.includes(o.iata))
|
||||
: observers;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
el.innerHTML = '<div class="text-center text-muted" style="padding:40px">No observers found.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const maxPktsHr = Math.max(1, ...observers.map(o => o.packetsLastHour || 0));
|
||||
const maxPktsHr = Math.max(1, ...filtered.map(o => o.packetsLastHour || 0));
|
||||
|
||||
// Summary counts
|
||||
const online = observers.filter(o => healthStatus(o.last_seen).cls === 'health-green').length;
|
||||
const stale = observers.filter(o => healthStatus(o.last_seen).cls === 'health-yellow').length;
|
||||
const offline = observers.filter(o => healthStatus(o.last_seen).cls === 'health-red').length;
|
||||
const online = filtered.filter(o => healthStatus(o.last_seen).cls === 'health-green').length;
|
||||
const stale = filtered.filter(o => healthStatus(o.last_seen).cls === 'health-yellow').length;
|
||||
const offline = filtered.filter(o => healthStatus(o.last_seen).cls === 'health-red').length;
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="obs-summary">
|
||||
<span class="obs-stat"><span class="health-dot health-green">●</span> ${online} Online</span>
|
||||
<span class="obs-stat"><span class="health-dot health-yellow">▲</span> ${stale} Stale</span>
|
||||
<span class="obs-stat"><span class="health-dot health-red">✕</span> ${offline} Offline</span>
|
||||
<span class="obs-stat">📡 ${observers.length} Total</span>
|
||||
<span class="obs-stat">📡 ${filtered.length} Total</span>
|
||||
</div>
|
||||
<div class="obs-table-scroll"><table class="data-table obs-table" id="obsTable">
|
||||
<caption class="sr-only">Observer status and statistics</caption>
|
||||
@@ -103,7 +115,7 @@
|
||||
<th>Status</th><th>Name</th><th>Region</th><th>Last Seen</th>
|
||||
<th>Packets</th><th>Packets/Hour</th><th>Uptime</th>
|
||||
</tr></thead>
|
||||
<tbody>${observers.map(o => {
|
||||
<tbody>${filtered.map(o => {
|
||||
const h = healthStatus(o.last_seen);
|
||||
const shape = h.cls === 'health-green' ? '●' : h.cls === 'health-yellow' ? '▲' : '✕';
|
||||
return `<tr style="cursor:pointer" onclick="location.hash='#/observers/${encodeURIComponent(o.id)}'">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,8 +34,8 @@
|
||||
// System health (memory, event loop, WS)
|
||||
if (health) {
|
||||
const m = health.memory, el = health.eventLoop;
|
||||
const elColor = el.p95Ms > 500 ? '#ef4444' : el.p95Ms > 100 ? '#f59e0b' : '#22c55e';
|
||||
const memColor = m.heapUsed > m.heapTotal * 0.85 ? '#ef4444' : m.heapUsed > m.heapTotal * 0.7 ? '#f59e0b' : '#22c55e';
|
||||
const elColor = el.p95Ms > 500 ? 'var(--status-red)' : el.p95Ms > 100 ? 'var(--status-yellow)' : 'var(--status-green)';
|
||||
const memColor = m.heapUsed > m.heapTotal * 0.85 ? 'var(--status-red)' : m.heapUsed > m.heapTotal * 0.7 ? 'var(--status-yellow)' : 'var(--status-green)';
|
||||
html += `<h3>System Health</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
|
||||
<div class="perf-card"><div class="perf-num" style="color:${memColor}">${m.heapUsed}MB</div><div class="perf-label">Heap Used / ${m.heapTotal}MB</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${m.rss}MB</div><div class="perf-label">RSS</div></div>
|
||||
@@ -54,7 +54,7 @@
|
||||
<div class="perf-card"><div class="perf-num">${c.size}</div><div class="perf-label">Server Entries</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${c.hits}</div><div class="perf-label">Server Hits</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${c.misses}</div><div class="perf-label">Server Misses</div></div>
|
||||
<div class="perf-card"><div class="perf-num" style="color:${c.hitRate > 50 ? '#22c55e' : c.hitRate > 20 ? '#f59e0b' : '#ef4444'}">${c.hitRate}%</div><div class="perf-label">Server Hit Rate</div></div>
|
||||
<div class="perf-card"><div class="perf-num" style="color:${c.hitRate > 50 ? 'var(--status-green)' : c.hitRate > 20 ? 'var(--status-yellow)' : 'var(--status-red)'}">${c.hitRate}%</div><div class="perf-label">Server Hit Rate</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${c.staleHits || 0}</div><div class="perf-label">Stale Hits (SWR)</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${c.recomputes || 0}</div><div class="perf-label">Recomputes</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${clientCache}</div><div class="perf-label">Client Entries</div></div>
|
||||
@@ -63,7 +63,7 @@
|
||||
html += `<div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
|
||||
<div class="perf-card"><div class="perf-num">${client.cacheHits || 0}</div><div class="perf-label">Client Hits</div></div>
|
||||
<div class="perf-card"><div class="perf-num">${client.cacheMisses || 0}</div><div class="perf-label">Client Misses</div></div>
|
||||
<div class="perf-card"><div class="perf-num" style="color:${(client.cacheHitRate||0) > 50 ? '#22c55e' : '#f59e0b'}">${client.cacheHitRate || 0}%</div><div class="perf-label">Client Hit Rate</div></div>
|
||||
<div class="perf-card"><div class="perf-num" style="color:${(client.cacheHitRate||0) > 50 ? 'var(--status-green)' : 'var(--status-yellow)'}">${client.cacheHitRate || 0}%</div><div class="perf-label">Client Hit Rate</div></div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
218
public/region-filter.js
Normal file
218
public/region-filter.js
Normal file
@@ -0,0 +1,218 @@
|
||||
/* === MeshCore Analyzer — region-filter.js (shared region filter component) === */
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
var LS_KEY = 'meshcore-region-filter';
|
||||
var _regions = {}; // { code: label }
|
||||
var _selected = null; // Set of selected region codes, null = all
|
||||
var _listeners = [];
|
||||
var _loaded = false;
|
||||
|
||||
function loadFromStorage() {
|
||||
try {
|
||||
var stored = JSON.parse(localStorage.getItem(LS_KEY));
|
||||
if (Array.isArray(stored) && stored.length > 0) return new Set(stored);
|
||||
} catch (e) { /* ignore */ }
|
||||
return null; // null = all selected
|
||||
}
|
||||
|
||||
function saveToStorage() {
|
||||
if (!_selected) {
|
||||
localStorage.removeItem(LS_KEY);
|
||||
} else {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(Array.from(_selected)));
|
||||
}
|
||||
}
|
||||
|
||||
_selected = loadFromStorage();
|
||||
|
||||
/** Fetch regions from server */
|
||||
async function fetchRegions() {
|
||||
if (_loaded) return _regions;
|
||||
try {
|
||||
var data = await fetch('/api/config/regions').then(function (r) { return r.json(); });
|
||||
_regions = data || {};
|
||||
_loaded = true;
|
||||
// If stored selection has codes no longer valid, clean up
|
||||
if (_selected) {
|
||||
var codes = Object.keys(_regions);
|
||||
var cleaned = new Set();
|
||||
_selected.forEach(function (c) { if (codes.includes(c)) cleaned.add(c); });
|
||||
_selected = cleaned.size > 0 ? cleaned : null;
|
||||
saveToStorage();
|
||||
}
|
||||
} catch (e) {
|
||||
_regions = {};
|
||||
}
|
||||
return _regions;
|
||||
}
|
||||
|
||||
/** Get selected regions as array, or null if all */
|
||||
function getSelected() {
|
||||
if (!_selected || _selected.size === 0) return null;
|
||||
return Array.from(_selected);
|
||||
}
|
||||
|
||||
/** Get region query param string for API calls: "SJC,SFO" or empty */
|
||||
function getRegionParam() {
|
||||
var sel = getSelected();
|
||||
return sel ? sel.join(',') : '';
|
||||
}
|
||||
|
||||
/** Build query string fragment: "®ion=SJC,SFO" or "" */
|
||||
function regionQueryString() {
|
||||
var p = getRegionParam();
|
||||
return p ? '®ion=' + encodeURIComponent(p) : '';
|
||||
}
|
||||
|
||||
/** Handle a region toggle (shared logic for both pill and dropdown modes) */
|
||||
function toggleRegion(region, codes, container) {
|
||||
if (region === '__all__') {
|
||||
_selected = null;
|
||||
} else {
|
||||
if (!_selected) {
|
||||
_selected = new Set([region]);
|
||||
} else if (_selected.has(region)) {
|
||||
_selected.delete(region);
|
||||
if (_selected.size === 0) _selected = null;
|
||||
} else {
|
||||
_selected.add(region);
|
||||
}
|
||||
if (_selected && _selected.size === codes.length) _selected = null;
|
||||
}
|
||||
saveToStorage();
|
||||
render(container);
|
||||
_listeners.forEach(function (fn) { fn(getSelected()); });
|
||||
}
|
||||
|
||||
/** Build summary label for dropdown trigger */
|
||||
function dropdownLabel(codes) {
|
||||
if (!_selected) return 'All Regions';
|
||||
var sel = Array.from(_selected);
|
||||
if (sel.length === 0) return 'All Regions';
|
||||
if (sel.length <= 2) return sel.join(', ');
|
||||
return sel.length + ' Regions';
|
||||
}
|
||||
|
||||
/** Render pill bar mode (≤4 regions) */
|
||||
function renderPills(container, codes) {
|
||||
var allSelected = !_selected;
|
||||
var html = '<div class="region-filter-bar" role="group" aria-label="Region filter">';
|
||||
html += '<span class="region-filter-label" id="region-filter-label">Region:</span>';
|
||||
html += '<button class="region-pill' + (allSelected ? ' region-pill-active' : '') +
|
||||
'" data-region="__all__" role="checkbox" aria-checked="' + allSelected + '">All</button>';
|
||||
codes.forEach(function (code) {
|
||||
var label = _regions[code] || code;
|
||||
var active = allSelected || (_selected && _selected.has(code));
|
||||
html += '<button class="region-pill' + (active ? ' region-pill-active' : '') +
|
||||
'" data-region="' + code + '" role="checkbox" aria-checked="' + !!active + '">' + label + '</button>';
|
||||
});
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
|
||||
container.onclick = function (e) {
|
||||
var btn = e.target.closest('[data-region]');
|
||||
if (!btn) return;
|
||||
toggleRegion(btn.dataset.region, codes, container);
|
||||
};
|
||||
}
|
||||
|
||||
/** Render dropdown mode (>4 regions) */
|
||||
function renderDropdown(container, codes) {
|
||||
var allSelected = !_selected;
|
||||
var html = '<div class="region-dropdown-wrap" role="group" aria-label="Region filter">';
|
||||
html += '<button class="region-dropdown-trigger" aria-haspopup="listbox" aria-expanded="false">' +
|
||||
dropdownLabel(codes) + ' ▾</button>';
|
||||
html += '<div class="region-dropdown-menu" role="listbox" aria-label="Select regions" hidden>';
|
||||
html += '<label class="region-dropdown-item"><input type="checkbox" data-region="__all__"' +
|
||||
(allSelected ? ' checked' : '') + '> <strong>All</strong></label>';
|
||||
codes.forEach(function (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>';
|
||||
});
|
||||
html += '</div></div>';
|
||||
container.innerHTML = html;
|
||||
|
||||
var trigger = container.querySelector('.region-dropdown-trigger');
|
||||
var menu = container.querySelector('.region-dropdown-menu');
|
||||
|
||||
trigger.onclick = function () {
|
||||
var open = !menu.hidden;
|
||||
menu.hidden = open;
|
||||
trigger.setAttribute('aria-expanded', String(!open));
|
||||
};
|
||||
|
||||
menu.onchange = function (e) {
|
||||
var input = e.target;
|
||||
if (!input.dataset.region) return;
|
||||
toggleRegion(input.dataset.region, codes, container);
|
||||
};
|
||||
|
||||
// Close on outside click
|
||||
function onDocClick(e) {
|
||||
if (!container.contains(e.target)) {
|
||||
menu.hidden = true;
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', onDocClick, true);
|
||||
container._regionCleanup = function () {
|
||||
document.removeEventListener('click', onDocClick, true);
|
||||
};
|
||||
}
|
||||
|
||||
/** Render the filter bar into a container element */
|
||||
function render(container) {
|
||||
// Clean up previous outside-click listener if any
|
||||
if (container._regionCleanup) { container._regionCleanup(); container._regionCleanup = null; }
|
||||
|
||||
var codes = Object.keys(_regions);
|
||||
if (codes.length < 2) {
|
||||
container.innerHTML = '';
|
||||
container.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
container.style.display = '';
|
||||
|
||||
if (codes.length > 4 || container._forceDropdown) {
|
||||
renderDropdown(container, codes);
|
||||
} else {
|
||||
renderPills(container, codes);
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscribe to selection changes. Callback receives selected array or null */
|
||||
function onChange(fn) {
|
||||
_listeners.push(fn);
|
||||
return fn;
|
||||
}
|
||||
|
||||
/** Unsubscribe */
|
||||
function offChange(fn) {
|
||||
_listeners = _listeners.filter(function (f) { return f !== fn; });
|
||||
}
|
||||
|
||||
/** Initialize filter in a container, fetch regions, render, return promise.
|
||||
* Options: { dropdown: true } to force dropdown mode regardless of region count */
|
||||
async function initFilter(container, opts) {
|
||||
if (opts && opts.dropdown) container._forceDropdown = true;
|
||||
await fetchRegions();
|
||||
render(container);
|
||||
}
|
||||
|
||||
// Expose globally
|
||||
window.RegionFilter = {
|
||||
init: initFilter,
|
||||
render: render,
|
||||
getSelected: getSelected,
|
||||
getRegionParam: getRegionParam,
|
||||
regionQueryString: regionQueryString,
|
||||
onChange: onChange,
|
||||
offChange: offChange,
|
||||
fetchRegions: fetchRegions
|
||||
};
|
||||
})();
|
||||
231
public/roles.js
231
public/roles.js
@@ -14,6 +14,40 @@
|
||||
sensor: '#d97706', observer: '#8b5cf6', unknown: '#6b7280'
|
||||
};
|
||||
|
||||
window.TYPE_COLORS = {
|
||||
ADVERT: '#22c55e', GRP_TXT: '#3b82f6', TXT_MSG: '#f59e0b', ACK: '#6b7280',
|
||||
REQUEST: '#a855f7', RESPONSE: '#06b6d4', TRACE: '#ec4899', PATH: '#14b8a6',
|
||||
ANON_REQ: '#f43f5e', UNKNOWN: '#6b7280'
|
||||
};
|
||||
|
||||
// Badge CSS class name mapping
|
||||
const TYPE_BADGE_MAP = {
|
||||
ADVERT: 'advert', GRP_TXT: 'grp-txt', TXT_MSG: 'txt-msg', ACK: 'ack',
|
||||
REQUEST: 'req', RESPONSE: 'response', TRACE: 'trace', PATH: 'path',
|
||||
ANON_REQ: 'anon-req', UNKNOWN: 'unknown'
|
||||
};
|
||||
|
||||
// Generate badge CSS from TYPE_COLORS — single source of truth
|
||||
window.syncBadgeColors = function() {
|
||||
var el = document.getElementById('type-color-badges');
|
||||
if (!el) { el = document.createElement('style'); el.id = 'type-color-badges'; document.head.appendChild(el); }
|
||||
var css = '';
|
||||
for (var type in TYPE_BADGE_MAP) {
|
||||
var color = window.TYPE_COLORS[type];
|
||||
if (!color) continue;
|
||||
var cls = TYPE_BADGE_MAP[type];
|
||||
css += '.badge-' + cls + ' { background: ' + color + '20; color: ' + color + '; }\n';
|
||||
}
|
||||
el.textContent = css;
|
||||
};
|
||||
|
||||
// Auto-sync on load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', window.syncBadgeColors);
|
||||
} else {
|
||||
window.syncBadgeColors();
|
||||
}
|
||||
|
||||
window.ROLE_LABELS = {
|
||||
repeater: 'Repeaters', companion: 'Companions', room: 'Room Servers',
|
||||
sensor: 'Sensors', observer: 'Observers'
|
||||
@@ -87,6 +121,9 @@
|
||||
// ─── WebSocket reconnect delay (ms) ───
|
||||
window.WS_RECONNECT_MS = 3000;
|
||||
|
||||
// ─── Propagation buffer (ms) for realistic mode ───
|
||||
window.PROPAGATION_BUFFER_MS = 5000;
|
||||
|
||||
// ─── Cache invalidation debounce (ms) ───
|
||||
window.CACHE_INVALIDATE_MS = 5000;
|
||||
|
||||
@@ -119,9 +156,203 @@
|
||||
if (cfg.wsReconnectMs != null) window.WS_RECONNECT_MS = cfg.wsReconnectMs;
|
||||
if (cfg.cacheInvalidateMs != null) window.CACHE_INVALIDATE_MS = cfg.cacheInvalidateMs;
|
||||
if (cfg.externalUrls) Object.assign(EXTERNAL_URLS, cfg.externalUrls);
|
||||
if (cfg.propagationBufferMs != null) window.PROPAGATION_BUFFER_MS = cfg.propagationBufferMs;
|
||||
// Sync ROLE_STYLE colors with ROLE_COLORS
|
||||
for (var role in ROLE_STYLE) {
|
||||
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'
|
||||
};
|
||||
|
||||
// Simple markdown → HTML (bold, italic, links, code, lists, line breaks)
|
||||
window.miniMarkdown = function(text) {
|
||||
if (!text) return '';
|
||||
var html = text
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/`(.+?)`/g, '<code>$1</code>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener" style="color:var(--accent)">$1</a>')
|
||||
.replace(/^- (.+)/gm, '<li>$1</li>')
|
||||
.replace(/\n/g, '<br>');
|
||||
// Wrap consecutive <li> in <ul>
|
||||
html = html.replace(/((?:<li>.*?<\/li><br>?)+)/g, function(m) {
|
||||
return '<ul>' + m.replace(/<br>/g, '') + '</ul>';
|
||||
});
|
||||
return html;
|
||||
};
|
||||
})();
|
||||
|
||||
366
public/style.css
366
public/style.css
@@ -3,7 +3,12 @@
|
||||
:root {
|
||||
--nav-bg: #0f0f23;
|
||||
--nav-bg2: #1a1a2e;
|
||||
--nav-text: #ffffff;
|
||||
--nav-text-muted: #cbd5e1;
|
||||
--accent: #4a9eff;
|
||||
--status-green: #22c55e;
|
||||
--status-yellow: #eab308;
|
||||
--status-red: #ef4444;
|
||||
--accent-hover: #6db3ff;
|
||||
--text: #1a1a2e;
|
||||
--text-muted: #5b6370;
|
||||
@@ -30,6 +35,9 @@
|
||||
When changing dark theme variables, update BOTH blocks below. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--status-green: #22c55e;
|
||||
--status-yellow: #eab308;
|
||||
--status-red: #ef4444;
|
||||
--surface-0: #0f0f23;
|
||||
--surface-1: #1a1a2e;
|
||||
--surface-2: #232340;
|
||||
@@ -50,6 +58,9 @@
|
||||
}
|
||||
/* ⚠️ DARK THEME VARIABLES — KEEP IN SYNC with @media block above */
|
||||
[data-theme="dark"] {
|
||||
--status-green: #22c55e;
|
||||
--status-yellow: #eab308;
|
||||
--status-red: #ef4444;
|
||||
--surface-0: #0f0f23;
|
||||
--surface-1: #1a1a2e;
|
||||
--surface-2: #232340;
|
||||
@@ -87,15 +98,15 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
/* === Nav === */
|
||||
.top-nav {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
background: linear-gradient(135deg, #0f0f23 0%, #151532 50%, #1a1035 100%); color: #fff; padding: 0 20px; height: 52px;
|
||||
background: linear-gradient(135deg, var(--nav-bg) 0%, var(--nav-bg2) 100%); color: var(--nav-text); padding: 0 20px; height: 52px;
|
||||
position: sticky; top: 0; z-index: 1100;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.3);
|
||||
}
|
||||
.nav-left { display: flex; align-items: center; gap: 24px; }
|
||||
.nav-brand { display: flex; align-items: center; gap: 8px; text-decoration: none; color: #fff; font-weight: 700; font-size: 16px; }
|
||||
.nav-brand { display: flex; align-items: center; gap: 8px; text-decoration: none; color: var(--nav-text); font-weight: 700; font-size: 16px; }
|
||||
.brand-icon { font-size: 20px; }
|
||||
.live-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%; background: #555;
|
||||
width: 8px; height: 8px; border-radius: 50%; background: var(--text-muted);
|
||||
display: inline-block; margin-left: 4px; transition: background .3s;
|
||||
}
|
||||
@keyframes pulse-ring {
|
||||
@@ -103,18 +114,18 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
70% { box-shadow: 0 0 0 6px rgba(34, 197, 94, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); }
|
||||
}
|
||||
.live-dot.connected { background: #22c55e; animation: pulse-ring 2s ease-out infinite; }
|
||||
.live-dot.connected { background: var(--status-green); animation: pulse-ring 2s ease-out infinite; }
|
||||
|
||||
.nav-links { display: flex; align-items: center; gap: 4px; }
|
||||
.nav-link {
|
||||
color: #cbd5e1; text-decoration: none; padding: 14px 12px; font-size: 14px;
|
||||
color: var(--nav-text-muted); text-decoration: none; padding: 14px 12px; font-size: 14px;
|
||||
border-bottom: 2px solid transparent; transition: all .15s;
|
||||
background: none; border-top: none; border-left: none; border-right: none;
|
||||
cursor: pointer; font-family: var(--font);
|
||||
}
|
||||
.nav-link:hover { color: #fff; }
|
||||
.nav-link:hover { color: var(--nav-text); }
|
||||
.nav-link.active {
|
||||
color: #fff;
|
||||
color: var(--nav-text);
|
||||
border-bottom-color: transparent;
|
||||
background: rgba(74, 158, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
@@ -125,28 +136,28 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
.nav-dropdown { position: relative; }
|
||||
.dropdown-menu {
|
||||
display: none; position: absolute; top: 100%; left: 0;
|
||||
background: var(--nav-bg2); border: 1px solid #333; border-radius: 6px;
|
||||
background: var(--nav-bg2); border: 1px solid var(--border); border-radius: 6px;
|
||||
min-width: 140px; padding: 4px 0; box-shadow: 0 8px 24px rgba(0,0,0,.4);
|
||||
}
|
||||
.nav-dropdown:hover .dropdown-menu { display: block; }
|
||||
.dropdown-item {
|
||||
display: block; padding: 8px 16px; color: #cbd5e1; text-decoration: none; font-size: 13px;
|
||||
display: block; padding: 8px 16px; color: var(--text-muted); text-decoration: none; font-size: 13px;
|
||||
}
|
||||
.dropdown-item:hover { background: var(--accent); color: #fff; }
|
||||
|
||||
.nav-right { display: flex; align-items: center; gap: 8px; }
|
||||
.nav-btn {
|
||||
background: none; border: 1px solid #444; color: #cbd5e1; padding: 6px 12px;
|
||||
background: none; border: 1px solid var(--border); color: var(--nav-text-muted); padding: 6px 12px;
|
||||
border-radius: 6px; cursor: pointer; font-size: 14px; transition: all .15s;
|
||||
min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.nav-btn:hover { background: #333; color: #fff; }
|
||||
.nav-btn:hover { background: var(--nav-bg2); color: var(--nav-text); }
|
||||
/* === Nav Stats === */
|
||||
.nav-stats {
|
||||
display: flex; gap: 12px; align-items: center; font-size: 12px; color: #94a3b8;
|
||||
display: flex; gap: 12px; align-items: center; font-size: 12px; color: var(--nav-text-muted);
|
||||
font-family: var(--mono); margin-right: 4px;
|
||||
}
|
||||
.nav-stats .stat-val { color: #e2e8f0; font-weight: 600; transition: color 0.3s ease; }
|
||||
.nav-stats .stat-val { color: var(--nav-text); font-weight: 600; transition: color 0.3s ease; }
|
||||
.nav-stats .stat-val.updated { color: var(--accent); }
|
||||
|
||||
/* === Layout === */
|
||||
@@ -188,22 +199,34 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; align-items: center;
|
||||
}
|
||||
.filter-bar input, .filter-bar select {
|
||||
padding: 4px 8px; border: 1px solid var(--border); border-radius: 4px;
|
||||
font-size: 12px; background: var(--input-bg); color: var(--text); font-family: var(--font);
|
||||
padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px;
|
||||
font-size: 13px; background: var(--input-bg); color: var(--text); font-family: var(--font);
|
||||
height: 34px; box-sizing: border-box; line-height: 1;
|
||||
}
|
||||
.filter-bar input { width: 120px; }
|
||||
.filter-bar select { min-width: 90px; }
|
||||
.filter-bar .btn {
|
||||
padding: 6px 14px; border: 1px solid var(--border); border-radius: 6px;
|
||||
background: var(--input-bg); cursor: pointer; font-size: 13px; transition: all .15s;
|
||||
font-family: var(--font); color: var(--text);
|
||||
font-family: var(--font); color: var(--text); height: 34px; box-sizing: border-box; line-height: 1;
|
||||
}
|
||||
.filter-group { display: flex; gap: 6px; align-items: center; }
|
||||
.filter-group + .filter-group { border-left: 1px solid var(--border); padding-left: 12px; margin-left: 6px; }
|
||||
.sort-help { cursor: help; font-size: 14px; color: var(--text-muted, #888); position: relative; display: inline-block; }
|
||||
.sort-help-tip {
|
||||
display: none; position: absolute; top: 130%; left: 50%; transform: translateX(-50%);
|
||||
background: var(--card-bg, #222); color: var(--text, #eee); border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 8px 12px; font-size: 12px; line-height: 1.5;
|
||||
white-space: pre-line; width: 260px; z-index: 100;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.3); pointer-events: none;
|
||||
}
|
||||
.sort-help:hover .sort-help-tip { display: block; }
|
||||
.filter-bar .btn:hover { background: var(--row-hover); }
|
||||
.filter-bar .btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
|
||||
.btn-icon {
|
||||
background: none; border: 1px solid var(--border); border-radius: 6px;
|
||||
padding: 6px 10px; cursor: pointer; font-size: 14px; transition: all .15s;
|
||||
color: var(--text); padding: 6px 10px; cursor: pointer; font-size: 14px; transition: all .15s;
|
||||
}
|
||||
.btn-icon:hover { background: var(--row-hover); }
|
||||
|
||||
@@ -236,32 +259,11 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
display: inline-block; padding: 2px 8px; border-radius: var(--badge-radius);
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .3px;
|
||||
}
|
||||
.badge-advert { background: #dcfce7; color: #166534; }
|
||||
.badge-grp-txt { background: #dbeafe; color: #1e40af; }
|
||||
.badge-ack { background: #f3f4f6; color: var(--text-muted); }
|
||||
.badge-req { background: #ffedd5; color: #9a3412; }
|
||||
.badge-txt-msg { background: #f3e8ff; color: #7e22ce; }
|
||||
.badge-trace { background: #cffafe; color: #0e7490; }
|
||||
.badge-path { background: #fef9c3; color: #a16207; }
|
||||
.badge-response { background: #e0e7ff; color: #3730a3; }
|
||||
.badge-anon-req { background: #fce7f3; color: #9d174d; }
|
||||
.badge-unknown { background: #f3f4f6; color: var(--text-muted); }
|
||||
|
||||
[data-theme="dark"] .badge-advert { background: #166534; color: #86efac; }
|
||||
[data-theme="dark"] .badge-grp-txt { background: #1e3a5f; color: #93c5fd; }
|
||||
[data-theme="dark"] .badge-ack { background: #374151; color: #d1d5db; }
|
||||
[data-theme="dark"] .badge-req { background: #7c2d12; color: #fdba74; }
|
||||
[data-theme="dark"] .badge-txt-msg { background: #581c87; color: #d8b4fe; }
|
||||
[data-theme="dark"] .badge-trace { background: #164e63; color: #67e8f9; }
|
||||
[data-theme="dark"] .badge-path { background: #713f12; color: #fde68a; }
|
||||
[data-theme="dark"] .badge-response { background: #312e81; color: #a5b4fc; }
|
||||
[data-theme="dark"] .badge-anon-req { background: #831843; color: #f9a8d4; }
|
||||
[data-theme="dark"] .badge-unknown { background: #374151; color: #d1d5db; }
|
||||
|
||||
.badge-region {
|
||||
display: inline-block; padding: 2px 6px; border-radius: 4px;
|
||||
font-size: 10px; font-weight: 700; font-family: var(--mono);
|
||||
background: var(--nav-bg); color: #fff; letter-spacing: .5px;
|
||||
background: var(--nav-bg); color: var(--nav-text); letter-spacing: .5px;
|
||||
}
|
||||
.badge-obs {
|
||||
display: inline-block; padding: 1px 6px; border-radius: 10px;
|
||||
@@ -322,7 +324,7 @@ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible
|
||||
width: 100%; border-collapse: collapse; font-size: 11px; margin-bottom: 12px;
|
||||
}
|
||||
.field-table th {
|
||||
text-align: left; padding: 6px 8px; background: var(--nav-bg); color: #fff;
|
||||
text-align: left; padding: 6px 8px; background: var(--nav-bg); color: var(--nav-text);
|
||||
font-size: 11px; text-transform: uppercase; letter-spacing: .3px;
|
||||
}
|
||||
.field-table td {
|
||||
@@ -649,7 +651,7 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
border-radius: 4px; font-family: var(--mono); font-size: 12px; font-weight: 600;
|
||||
}
|
||||
.trace-path-arrow { color: var(--text-muted); font-size: 16px; }
|
||||
.trace-path-label { color: #94a3b8; font-size: 12px; font-style: italic; }
|
||||
.trace-path-label { color: var(--text-muted); font-size: 12px; font-style: italic; }
|
||||
.trace-path-info { font-size: 12px; color: var(--text-muted); }
|
||||
|
||||
/* Timeline */
|
||||
@@ -675,31 +677,31 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
}
|
||||
.tl-delta { font-size: 11px; color: var(--text-muted); text-align: right; }
|
||||
.tl-snr { font-size: 12px; font-weight: 600; text-align: right; }
|
||||
.tl-snr.good { color: #16a34a; }
|
||||
.tl-snr.ok { color: #ca8a04; }
|
||||
.tl-snr.bad { color: #dc2626; }
|
||||
.tl-snr.good { color: var(--status-green); }
|
||||
.tl-snr.ok { color: var(--status-yellow); }
|
||||
.tl-snr.bad { color: var(--status-red); }
|
||||
.tl-rssi { font-size: 12px; color: var(--text-muted); text-align: right; }
|
||||
|
||||
/* === Scrollbar === */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||||
|
||||
/* === Observers Page === */
|
||||
.observers-page { padding: 20px; max-width: 1200px; margin: 0 auto; overflow-y: auto; height: calc(100vh - 56px); }
|
||||
.obs-summary { display: flex; gap: 20px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.obs-stat { display: flex; align-items: center; gap: 6px; font-size: 14px; color: var(--text-muted); }
|
||||
.health-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||||
.health-dot.health-green { background: #22c55e; box-shadow: 0 0 6px #22c55e80; }
|
||||
.health-dot.health-yellow { background: #eab308; box-shadow: 0 0 6px #eab30880; }
|
||||
.health-dot.health-red { background: #ef4444; box-shadow: 0 0 6px #ef444480; }
|
||||
.health-dot.health-green { background: var(--status-green); box-shadow: 0 0 6px #22c55e80; }
|
||||
.health-dot.health-yellow { background: var(--status-yellow); box-shadow: 0 0 6px #eab30880; }
|
||||
.health-dot.health-red { background: var(--status-red); box-shadow: 0 0 6px #ef444480; }
|
||||
.obs-table td:first-child { white-space: nowrap; }
|
||||
.obs-table td:nth-child(6) { max-width: none; overflow: visible; }
|
||||
.col-observer { min-width: 70px; max-width: none; }
|
||||
.spark-bar { position: relative; min-width: 60px; max-width: 100px; flex: 1; height: 18px; background: var(--border); border-radius: 4px; overflow: hidden; display: inline-block; vertical-align: middle; }
|
||||
@media (max-width: 640px) { .spark-bar { max-width: 60px; } }
|
||||
.spark-fill { height: 100%; background: linear-gradient(90deg, #3b82f6, #60a5fa); border-radius: 4px; transition: width 0.3s; }
|
||||
.spark-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent-hover, #60a5fa)); border-radius: 4px; transition: width 0.3s; }
|
||||
.spark-label { position: absolute; right: 4px; top: 0; line-height: 18px; font-size: 11px; color: var(--text); font-weight: 500; }
|
||||
|
||||
/* === Dark mode input overrides === */
|
||||
@@ -711,6 +713,7 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
[data-theme="dark"] .trace-search input,
|
||||
[data-theme="dark"] .mc-jump-btn,
|
||||
[data-theme="dark"] .filter-bar .btn { background: var(--input-bg); color: var(--text); border-color: var(--border); }
|
||||
[data-theme="dark"] .filter-bar .btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
[data-theme="dark"] .ch-item.selected,
|
||||
[data-theme="dark"] .data-table tbody tr.selected { background: var(--selected-bg); }
|
||||
[data-theme="dark"] .tl-bar-container { background: #334155; }
|
||||
@@ -852,6 +855,8 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
.filter-bar.filters-expanded > .col-toggle-wrap { display: inline-block; }
|
||||
.filter-bar.filters-expanded input { width: 100%; }
|
||||
.filter-bar.filters-expanded select { width: 100%; }
|
||||
.filter-group { flex-wrap: wrap; }
|
||||
.filter-group + .filter-group { border-left: none; padding-left: 0; margin-left: 0; }
|
||||
.filter-bar .btn { min-height: 36px; }
|
||||
.node-filter-wrap { width: 100%; }
|
||||
|
||||
@@ -906,7 +911,7 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
transition: color .15s, transform .15s;
|
||||
}
|
||||
.fav-star:hover { transform: scale(1.2); }
|
||||
.fav-star.on { color: #f5a623; }
|
||||
.fav-star.on { color: var(--status-yellow); }
|
||||
|
||||
/* BYOP Decode Modal */
|
||||
.byop-modal { max-width: 560px; }
|
||||
@@ -919,8 +924,8 @@ button.ch-item.selected { background: var(--selected-bg); }
|
||||
border: 1px solid var(--border); border-radius: 6px; background: var(--surface-1);
|
||||
color: var(--text);
|
||||
}
|
||||
.byop-input:focus { border-color: var(--accent); outline: none; }
|
||||
.byop-err { color: #ef4444; font-size: .85rem; }
|
||||
.byop-input:focus { border-color: var(--accent); outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.byop-err { color: var(--status-red); font-size: .85rem; }
|
||||
.byop-decoded { margin-top: 8px; }
|
||||
.byop-section { margin-bottom: 14px; }
|
||||
.byop-section-title {
|
||||
@@ -1054,9 +1059,9 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
.hash-cell.hash-active:hover { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
.hash-cell.hash-selected { outline: 2px solid var(--accent); outline-offset: -2px; box-shadow: 0 0 6px var(--accent); }
|
||||
.hash-bar-value { min-width: 120px; text-align: right; font-size: 13px; font-weight: 600; }
|
||||
.badge-hash-1 { background: #ef444420; color: #ef4444; }
|
||||
.badge-hash-2 { background: #22c55e20; color: #22c55e; }
|
||||
.badge-hash-3 { background: #3b82f620; color: #3b82f6; }
|
||||
.badge-hash-1 { background: #ef444420; color: var(--status-red); }
|
||||
.badge-hash-2 { background: #22c55e20; color: var(--status-green); }
|
||||
.badge-hash-3 { background: #3b82f620; color: var(--accent); }
|
||||
.timeline-legend { display: flex; gap: 16px; justify-content: center; margin-top: 8px; font-size: 12px; }
|
||||
.legend-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 4px; vertical-align: middle; }
|
||||
.timeline-chart svg { display: block; }
|
||||
@@ -1163,12 +1168,12 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
|
||||
/* Clickable hop links */
|
||||
.hop-link {
|
||||
color: var(--primary, #3b82f6);
|
||||
color: var(--accent, #3b82f6);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.hop-link:hover { color: var(--accent, #60a5fa); text-decoration: underline; }
|
||||
.hop-link:hover { color: var(--accent-hover, #60a5fa); text-decoration: underline; }
|
||||
|
||||
/* Detail map link */
|
||||
.detail-map-link {
|
||||
@@ -1189,7 +1194,7 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
padding: 5px 12px;
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
border: 1px solid rgba(59, 130, 246, 0.25);
|
||||
color: var(--primary, #3b82f6);
|
||||
color: var(--accent, #3b82f6);
|
||||
border-radius: 6px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
@@ -1208,15 +1213,33 @@ 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-ambiguous { border-bottom: 1px dashed var(--status-yellow, #f59e0b); }
|
||||
.hop-warn { font-size: 0.7em; margin-left: 2px; vertical-align: super; color: var(--status-yellow, #f59e0b); }
|
||||
.hop-conflict-btn { background: var(--status-yellow, #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: var(--status-yellow, #d97706); filter: brightness(0.85); }
|
||||
.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 var(--status-red); }
|
||||
.hop-current { font-weight: 700 !important; color: var(--accent) !important; }
|
||||
|
||||
/* Self-loop subpath rows */
|
||||
.subpath-selfloop { opacity: 0.6; }
|
||||
.subpath-selfloop td:first-child::after { content: ''; }
|
||||
|
||||
/* Hop prefix in subpath routes */
|
||||
.hop-prefix { color: #9ca3af; font-size: 0.8em; }
|
||||
.hop-prefix { color: var(--text-muted); font-size: 0.8em; }
|
||||
|
||||
/* Subpath split layout */
|
||||
.subpath-layout { display: flex; gap: 0; flex: 1; min-height: 0; overflow: auto; position: relative; }
|
||||
@@ -1224,7 +1247,7 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
.subpath-detail { width: 420px; min-width: 360px; max-width: 50vw; border-left: 1px solid var(--border, #e5e7eb); overflow-y: auto; padding: 16px; transition: width 0.2s; }
|
||||
.subpath-detail.collapsed { width: 0; min-width: 0; padding: 0; overflow: hidden; border: none; }
|
||||
.subpath-detail-inner h4 { margin: 0 0 4px; word-break: break-word; }
|
||||
.subpath-meta { display: flex; flex-direction: column; gap: 2px; margin-bottom: 12px; color: #9ca3af; font-size: 0.9em; }
|
||||
.subpath-meta { display: flex; flex-direction: column; gap: 2px; margin-bottom: 12px; color: var(--text-muted); font-size: 0.9em; }
|
||||
.subpath-section { margin: 16px 0; }
|
||||
.subpath-section h5 { margin: 0 0 6px; font-size: 0.9em; }
|
||||
.subpath-selected { background: var(--accent, #3b82f6) !important; color: #fff; }
|
||||
@@ -1234,7 +1257,7 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
|
||||
/* Hour distribution chart */
|
||||
.hour-chart { display: flex; align-items: flex-end; gap: 2px; height: 60px; }
|
||||
.hour-bar { flex: 1; background: var(--accent, #3b82f6); border-radius: 2px 2px 0 0; min-width: 4px; }
|
||||
.hour-labels { display: flex; justify-content: space-between; font-size: 0.7em; color: #9ca3af; }
|
||||
.hour-labels { display: flex; justify-content: space-between; font-size: 0.7em; color: var(--text-muted); }
|
||||
|
||||
/* Parent paths */
|
||||
.parent-path { padding: 3px 0; border-bottom: 1px solid var(--border, #e5e7eb); }
|
||||
@@ -1254,7 +1277,7 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
|
||||
|
||||
/* Subpath jump nav */
|
||||
.subpath-jump-nav { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; font-size: 0.9em; flex-wrap: wrap; }
|
||||
.subpath-jump-nav span { color: #9ca3af; }
|
||||
.subpath-jump-nav span { color: var(--text-muted); }
|
||||
.subpath-jump-nav a { padding: 4px 12px; border-radius: 4px; background: var(--accent, #3b82f6); color: #fff; text-decoration: none; font-size: 0.85em; }
|
||||
.subpath-jump-nav a:hover { opacity: 0.8; }
|
||||
|
||||
@@ -1270,10 +1293,11 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
|
||||
|
||||
/* #71 — Column visibility toggle */
|
||||
.col-toggle-wrap { position: relative; display: inline-block; }
|
||||
.col-toggle-btn { font-size: .8rem; padding: 4px 8px; cursor: pointer; background: var(--input-bg); border: 1px solid var(--border); border-radius: 4px; color: var(--text); }
|
||||
.col-toggle-btn { font-size: 13px; padding: 6px 10px; cursor: pointer; background: var(--input-bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); height: 34px; box-sizing: border-box; line-height: 1; }
|
||||
.col-toggle-menu { display: none; position: absolute; top: 100%; left: 0; z-index: 50; background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; padding: 6px 0; min-width: 150px; box-shadow: 0 4px 12px rgba(0,0,0,.15); }
|
||||
.col-toggle-menu.open { display: block; }
|
||||
.col-toggle-menu label { display: flex; align-items: center; gap: 6px; padding: 4px 12px; font-size: .82rem; cursor: pointer; color: var(--text); }
|
||||
.col-toggle-menu label input[type="checkbox"] { width: 14px; height: 14px; margin: 0; flex-shrink: 0; }
|
||||
.col-toggle-menu label:hover { background: var(--row-hover); }
|
||||
|
||||
/* Column hide classes */
|
||||
@@ -1346,7 +1370,7 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border, #333);
|
||||
background: var(--bg-card, #1e1e1e);
|
||||
background: var(--card-bg, #1e1e1e);
|
||||
color: var(--text, #fff);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
@@ -1444,6 +1468,212 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); }
|
||||
.perf-table td { padding: 5px 10px; border-bottom: 1px solid var(--border); font-variant-numeric: tabular-nums; }
|
||||
.perf-table code { font-size: 12px; color: var(--text); }
|
||||
.perf-table .perf-slow { background: rgba(239, 68, 68, 0.08); }
|
||||
.perf-table .perf-slow td { color: #ef4444; }
|
||||
.perf-table .perf-slow td { color: var(--status-red); }
|
||||
.perf-table .perf-warn { background: rgba(251, 191, 36, 0.06); }
|
||||
.perf-table .perf-warn td { color: #f59e0b; }
|
||||
.perf-table .perf-warn td { color: var(--status-yellow); }
|
||||
|
||||
/* ─── Region filter bar ─── */
|
||||
.region-filter-bar { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 0; }
|
||||
.region-filter-container { margin: 0; padding: 0; display: inline-flex; align-items: center; }
|
||||
.region-pill {
|
||||
display: inline-flex; align-items: center; padding: 4px 12px; border-radius: 16px;
|
||||
font-size: 12px; font-weight: 500; cursor: pointer; border: 1.5px solid var(--border);
|
||||
background: transparent; color: var(--text-muted); transition: all 0.15s;
|
||||
}
|
||||
.region-pill:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.region-pill-active {
|
||||
background: var(--accent); color: #fff; border-color: var(--accent);
|
||||
}
|
||||
.region-pill-active:hover { opacity: 0.85; }
|
||||
.region-filter-label {
|
||||
font-size: 12px; font-weight: 600; color: var(--text-muted); align-self: center;
|
||||
margin-right: 2px; user-select: none;
|
||||
}
|
||||
.region-dropdown-wrap { position: relative; display: inline-flex; align-items: center; }
|
||||
.region-dropdown-trigger {
|
||||
display: inline-flex; align-items: center; padding: 6px 10px; border-radius: 6px;
|
||||
font-size: 13px; font-weight: 500; cursor: pointer; border: 1px solid var(--border);
|
||||
background: var(--input-bg); color: var(--text); transition: all 0.15s;
|
||||
height: 34px; box-sizing: border-box; white-space: nowrap; line-height: 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; 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;
|
||||
}
|
||||
.region-dropdown-item:hover { background: var(--row-hover, #f5f5f5); }
|
||||
|
||||
/* Generic multi-select dropdown (Observer, Type filters) */
|
||||
.multi-select-wrap { position: relative; display: inline-flex; align-items: center; }
|
||||
.multi-select-trigger {
|
||||
display: inline-flex; align-items: center; padding: 6px 10px; border-radius: 6px;
|
||||
font-size: 13px; font-weight: 500; cursor: pointer; border: 1px solid var(--border);
|
||||
background: var(--input-bg); color: var(--text); transition: all 0.15s;
|
||||
height: 34px; box-sizing: border-box; white-space: nowrap; line-height: 1;
|
||||
}
|
||||
.multi-select-trigger:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.multi-select-menu {
|
||||
position: absolute; top: 100%; left: 0; z-index: 90;
|
||||
min-width: 220px; 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; display: none;
|
||||
}
|
||||
.multi-select-menu.open { display: block; }
|
||||
.multi-select-item {
|
||||
display: flex; align-items: center; gap: 6px; padding: 6px 12px;
|
||||
font-size: 13px; cursor: pointer; color: var(--text); white-space: nowrap;
|
||||
}
|
||||
.multi-select-item input[type="checkbox"] {
|
||||
width: 14px; height: 14px; margin: 0; flex-shrink: 0;
|
||||
}
|
||||
.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-muted, #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(--input-bg, #1f2937);
|
||||
color: var(--text, #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);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,10 @@
|
||||
let currentHash = null;
|
||||
let traceData = [];
|
||||
let packetMeta = null;
|
||||
|
||||
function init(app) {
|
||||
// Check URL for pre-filled hash
|
||||
function init(app, routeParam) {
|
||||
// Check URL for pre-filled hash — support both route param and query param
|
||||
const params = new URLSearchParams(location.hash.split('?')[1] || '');
|
||||
const urlHash = params.get('hash') || '';
|
||||
const urlHash = routeParam || params.get('hash') || '';
|
||||
|
||||
app.innerHTML = `
|
||||
<div class="traces-page">
|
||||
@@ -240,8 +239,8 @@
|
||||
for (const [node, pos] of nodePos) {
|
||||
const isEndpoint = node === 'Origin' || node === 'Dest';
|
||||
const r = isEndpoint ? 18 : 14;
|
||||
const fill = isEndpoint ? 'var(--primary, #3b82f6)' : 'var(--surface-2, #374151)';
|
||||
const stroke = isEndpoint ? 'var(--primary, #3b82f6)' : 'var(--border, #4b5563)';
|
||||
const fill = isEndpoint ? 'var(--accent, #3b82f6)' : 'var(--surface-2, #374151)';
|
||||
const stroke = isEndpoint ? 'var(--accent, #3b82f6)' : 'var(--border, #4b5563)';
|
||||
const label = isEndpoint ? node : node;
|
||||
nodesSvg += `<circle cx="${pos.x}" cy="${pos.y}" r="${r}" fill="${fill}" stroke="${stroke}" stroke-width="2"/>`;
|
||||
nodesSvg += `<text x="${pos.x}" y="${pos.y + 4}" text-anchor="middle" fill="white" font-size="${isEndpoint ? 10 : 9}" font-weight="${isEndpoint ? 700 : 500}">${escapeHtml(label)}</text>`;
|
||||
|
||||
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