More APRS enhancements
Some checks failed
Test and build / Test and lint (push) Failing after 35s
Test and build / Build collector (push) Failing after 36s
Test and build / Build receiver (push) Failing after 36s

This commit is contained in:
2026-03-05 22:24:09 +01:00
parent 7a8d7b0275
commit e83df1c143
115 changed files with 3987 additions and 956 deletions

View File

@@ -41,3 +41,8 @@ Run `go test -v` and `golangci-lint run`.
Never add secrets to code, unless a secret is used as a test vector. In that case, ask Never add secrets to code, unless a secret is used as a test vector. In that case, ask
for confirmation before adding or changing. for confirmation before adding or changing.
## Addressing
Don't call me "the user", refer to me as "the developer".
Refrain from using hyperbolic expressions like "excellent" and "perfect", "ok" or "good" is good enough.

View File

@@ -0,0 +1,423 @@
Copyright and licensing information
======================================
This is a collection of vectorized symbols for use on the APRS system.
The copyright status of this collection is a bit complicated, since the
symbols come from various sources, each having different copyright owners.
Most of the vectorized symbols are loosely based on the low-resolution
"standard" bitmap symbol set as distributed by Stephen Smith, WA8LMF. That
set is used by most APRS software around the world. The low resolution of
those symbols does not allow direct vector conversion, so I've drawn new
symbols in a similar layout. The vector versions try to mimic the original
appearance and colours, with the intention of keeping the set recognizable
and familiar to existing users. In some cases the vector versions are
probably similar enough to the originals, so that they cannot be considered
"original work" by myself. In some of these cases, the originals are
probably also mimicking someone else's design.
The original symbols do not come with any information on their licensing.
They've been distributed with a lot of APRS software over time, but I don't
know who designed which symbol originally. Most likely all of them are
drawn by one of:
* Roger Barker, G4IDE, "original set provided with UI-View" (SK)
* Steve Dimse, KH4G, "U.S. customary set"
* Stephen Smith, WA8LMF
The Adobe Illustrator (.ai) file contains a copy of the original bitmaps
as a hidden layer, just for reference.
Some symbols I obtained from other sources, such as Wikipedia. In those
cases I picked SVG versions which allow commercial reuse (source known, and
the work is placed on public domain, or with a CC license which allows
adaptation and commercial reuse).
Some symbols are vectorized versions of product or brand logos. The
copyright of those is owned by the respective companies (Apple, Microsoft,
Kenwood), and each of those may have some opinions on how the logos are
used. Please check for yourself if you can use them or not.
In the list below I try to summarize the licensing status for each symbol.
Shorthand notation for common licensing status
-------------------------------------------------
* *VEC-OH7LZB* - Vectorized by OH7LZB, based on original APRS symbol set
* Source of original bitmap: http://wa8lmf.net/aprs/APRS_symbols.htm
* Original designer of individual symbol unknown at this time, but one of:
* Roger Barker, G4IDE
* Steve Dimse, KH4G
* Stephen Smith, WA8LMF
* Vectorized versions are designed to look similar
* Licensing: Unknown
* *OH7LZB* - Original vector design by Heikki Hannikainen, OH7LZB
* Different enough (by author's opinion) to make it a new original work,
instead of a copy of the old symbol
* License: CC BY-SA 2.0
* https://creativecommons.org/licenses/by-sa/2.0/
Primary table
----------------
* /! - Police station
* VEC-OH7LZB
* /# - Digipeater / Green star with D in middle
* VEC-OH7LZB
* /$ - Telephone
* VEC-OH7LZB
* /% - DX cluster
* VEC-OH7LZB
* /& - HF gateway
* VEC-OH7LZB
* /' - Small aircraft
* https://openclipart.org/detail/27182/topdown-airplane-view
* Author: Wirelizard (Brian Burger)
* With color and some other small tuning added by OH7LZB
* PD: https://openclipart.org/share
* /( - Mobile satellite station
* OH7LZB
* /) - Wheelchair, handicapped
* PD wheelchair symbol
* Vectorized from bitmap by OH7LZB
* /* - Snowmobile
* https://openclipart.org/detail/15849/snowmobile
* Author: Mystica (https://openclipart.org/user-detail/mystica)
* PD: https://openclipart.org/share
* /+ - Red Cross
* VEC-OH7LZB
* /, - Boy Scouts
* VEC-OH7LZB
* /- - House
* VEC-OH7LZB
* /. - Red X
* VEC-OH7LZB
* // - Red dot
* VEC-OH7LZB
* /0 to /9 - Numbered circles
* VEC-OH7LZB
* Fire
* http://commons.wikimedia.org/wiki/File:FireIcon.svg
* Author: Piotr Jaworski
* PD: I, the copyright holder of this work, release this work into the public domain. This applies worldwide.
* Tent
* https://openclipart.org/detail/174933/green-tent-by-stamps-174933
* Author: stamps
* PD: https://openclipart.org/share
* Motorcycle
* http://commons.wikimedia.org/wiki/File:MUTCD_W8-15P.svg
* This file is in the public domain because it comes from the Manual on
Uniform Traffic Control Devices, sign number W8-15P, which states
specifically on page I-1 that: Any traffic control device design or
application provision contained in this Manual shall be considered to
be in the public domain. Traffic control devices contained in this
Manual shall not be protected by a patent, trademark, or copyright,
except for the Interstate Shield and any other items owned by FHWA.
* Colour version by OH7LZB
* /= - Railroad engine
* http://commons.wikimedia.org/wiki/File:Icon_train.svg
* Author: http://en.wikipedia.org/wiki/User:Richtom80
* CC-BY-SA-2.5,2.0,1.0
* /> - Car
* OH7LZB
* /? - File server
* https://openclipart.org/detail/163717/file-server-by-lyte
* Author: lyte
* PD: https://openclipart.org/share
* /@ - Hurricane predicted path
* VEC-OH7LZB
* /A - Aid station
* VEC-OH7LZB
* Mail (BBS)
* https://openclipart.org/detail/29268/yellow-mail-by-rg1024-29268
* Author: rg1024
* PD: https://openclipart.org/share
* /C - Canoe
* https://openclipart.org/detail/179047/red-canoe-by-rambo-tribble-179047
* https://openclipart.org/detail/179041/canoe-paddle-by-rambo-tribble-179041
* Author: Rambo Tribble
* PD: I, the copyright holder of this work, release this work into the public domain. This applies worldwide.
* /E - Eyeball
* http://commons.wikimedia.org/wiki/File:Blue_eye.svg
* PD: "This file is from the Open Clip Art Library, which released it explicitly into the public domain"
* PD: https://openclipart.org/share
* /F - Tractor
* https://openclipart.org/detail/191654/farm-tractor-by-tmjbeary-191654
* Author: tmjbeary
* PD: https://openclipart.org/share
* /G - Grid square, 3 by 3
* VEC-OH7LZB
* /H - Hotel
* VEC-OH7LZB
* /I - TCP/IP
* VEC-OH7LZB
* /K - School
* OH7LZB
* /L - PC user
* OH7LZB
* /M - Mac apple
* Apple
* /N - NTS
* VEC-OH7LZB
* /O - Hot air balloon
* OH7LZB
* /P - Police
* OH7LZB
* /R - RV
* OH7LZB
* /S - Space Shuttle
* https://openclipart.org/detail/814/space-shuttle-by-johnny_automatic
* PD: Published by the NASA, in "The Brain in Space"
* /T - SSTV
* https://openclipart.org/detail/48997/flat-screen-by-rg1024
* Author: rg1024
* Adjusted by OH7LZB
* PD: https://openclipart.org/share
* /U - Bus
* OH7LZB
* /V - ATV, amateur television
* https://openclipart.org/detail/48997/flat-screen-by-rg1024
* Author: rg1024
* Adjusted by OH7LZB
* PD: https://openclipart.org/share
* /W - Wx, Weather service site
* VEC-OH7LZB
* /X - Helicopter
* OH7LZB
* /Y - Sailboat
* OH7LZB
* /Z - Windows flag
* Microsoft
* /[ - Human
* VEC-OH7LZB
* /\ - DF triangle
* VEC-OH7LZB
* /] - Mailbox, post office, letter
* /^ - Large aircraft
* https://openclipart.org/detail/183204/plane-red-by-sketchartist-183204
* Author: SketchArtist
* PD: https://openclipart.org/share
* /_ - Weather station
* VEC-OH7LZB
* /` - Satellite dish
* OH7LZB
* /a - Ambulance
* OH7LZB
* /b - Bicycle
* http://commons.wikimedia.org/wiki/File:Bicycle_evolution-numbers.svg
* Author: Wikipedia user: Al2
* CC BY 3.0
* /c - Incident command post
* VEC-OH7LZB
* /d - Fire station
* VEC-OH7LZB
* /e - Horse, equestrian
* https://openclipart.org/detail/142627/horse-riding-lesson-by-olku
* Author: OlKu
* PD: https://openclipart.org/share
* /f - Fire truck
* OH7LZB
* /g - Hang glider
* OH7LZB
* /h - Hospital
* VEC-OH7LZB
* /i - IOTA, islands on the air
* http://commons.wikimedia.org/wiki/File:Palm_Island_R.svg
* PI
* /j - Jeep
* OH7LZB
* /k - Truck
* OH7LZB
* /l - Laptop
* OH7LZB
* /m - Mic-E repeater
* VEC-OH7LZB
* /n - Node, black bulls-eye
* VEC-OH7LZB
* /o - Emergency operations center
* VEC-OH7LZB
* /p - Dog(e)
* OH7LZB
* /q - Grid square, 2 by 2
* VEC-OH7LZB
* /r - Repeater tower
* OH7LZB
* /s - Ship, power boat
* OH7LZB
* /t - Truck stop
* VEC-OH7LZB
* /u - Semi-trailer truck, 18-wheeler
* OH7LZB
* /v - Van
* OH7LZB
* /w - Water station
* VEC-OH7LZB
* /x - X / Unix
* https://commons.wikimedia.org/wiki/File:X11.svg
* PD
* /y - House, yagi antenna
* VEC-OH7LZB
* /z - Shelter
* VEC-OH7LZB
Secondary table
------------------
* Emergency
* VEC-OH7LZB
* Numbered digipeater / Green star
* VEC-OH7LZB
* Bank
* VEC-OH7LZB
* Numbered gateway / Black diamond
* VEC-OH7LZB
* Crash site
* OH7LZB
* Cloudy
* OH7LZB
* MEO
* VEC-OH7LZB
* Snowflake
* http://commons.wikimedia.org/wiki/File:Snowflake_01.svg
* Author: Wikipedia user: Amada44
* Public Domain
* Church
* VEC-OH7LZB
* Girl Scout
* VEC-OH7LZB
* Looks slightly like the common USA girl scouts logos. Should be different
enough to not infringe on "Girl Scouts of the USA" copyrights.
* Home (HF antenna)
* VEC-OH7LZB
* Unknown position
* VEC-OH7LZB
* Destination
* VEC-OH7LZB
* Numbered circle
* VEC-OH7LZB
* Petrol Station
* OH7LZB
* Hail
* VEC-OH7LZB
* Park
* VEC-OH7LZB
* Gale Flag
* VEC-OH7LZB
* Red car from above
* OH7LZB
* Info Kiosk
* VEC-OH7LZB
* Hurricane
* OH7LZB
* Numbered white box
* VEC-OH7LZB
* Snow blowing
* VEC-OH7LZB
* Coast Guard
* VEC-OH7LZB
* Drizzle
* VEC-OH7LZB
* Smoke / Chimney
* VEC-OH7LZB
* Freezing rain
* VEC-OH7LZB
* Snow Shwr
* VEC-OH7LZB
* Haze
* VEC-OH7LZB
* Rain Shower
* VEC-OH7LZB
* Lightning
* OH7LZB
* "Kenwood radio"
* Kenwood logo, vectorized
* "Lighthouse"
* CC BY-SA 2.0
* http://wiki.openstreetmap.org/wiki/File:Lighthouse.svg
* Nav Buoy
* OH7LZB
* Rocket
* http://www.clker.com/clipart-gglkuglug.html
* PD according to clker.com license
* Parking
* VEC-OH7LZB
* Earthquake, Restaurant
* VEC-OH7LZB
* Satellite
* OH7LZB
* Thunderstorm
* OH7LZB
* Sunny
* OH7LZB
* VORTAC, Numbered WXS
* VEC-OH7LZB
* Pharmacy Rx
* OH7LZB
* Wall Cloud
* OH7LZB
* Numbered plane
* https://openclipart.org/detail/183204/plane-red-by-sketchartist-183204
* Author: SketchArtist
* PD: https://openclipart.org/share
* Numbered WX Station
* VEC-OH7LZB
* Rain
* Source: http://commons.wikimedia.org/wiki/File:Heavy-rain-shower-transparent.svg
* Author: Wikipedia user: Peepo
* Public Domain
* With modifications by OH7LZB
* Numbered diamond
* VEC-OH7LZB
* Dust blowing
* NA
* Numbered civil defence
* VEC-OH7LZB
* DX spot
* VEC-OH7LZB
* Sleet
* NA
* Funnel Cloud
* NA
* Gale
* VEC-OH7LZB
* Store
* https://openclipart.org/detail/89299/cart-medium-by-martins.bruvelis
* Author: martins.bruvelis
* Public Domain
* Adjustments by OH7LZB
* Numbered black box
* VEC-OH7LZB
* Work zone / Excavator
* Based on http://www.clker.com/clipart-292480.html PNG version
* Vectorized and colors adjusted by OH7LZB
* PD according to clker.com documentation, uploader KURSVEIAL
* SUV
* OH7LZB
* Milepost, Numbered triangle, Circle sm
* VEC-OH7LZB
* Partly cloudy
* OH7LZB
* Restrooms, Numbered boat
* VEC-OH7LZB
* Tornado (also used in Funnel cloud, Skywarn)
* https://openclipart.org/detail/104887/tornado-by-laabadon
* Author: Laabadon
* Public Domain
* Numbered truck
* OH7LZB
* Numbered van
* OH7LZB
* Flooding
* NA
* Sky warn, Numbered shelter, fog
* VEC-OH7LZB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 825 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 676 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

View File

@@ -28,7 +28,7 @@ Relevant documents:
**Always run tests before completing a task.** **Always run tests before completing a task.**
Run `npm run build`. Run `npm run build` and run `pre-commit run --files changed files...`
## Coding Guidelines ## Coding Guidelines
@@ -36,12 +36,16 @@ Run `npm run build`.
- Prefer ESM imports (`import`/`export`) - Prefer ESM imports (`import`/`export`)
- Use builtins from React, React-Boostrap where possible - Use builtins from React, React-Boostrap where possible
- Follow existing code patterns in the code base - Follow existing code patterns in the code base
- Never make changes outside of the `ui` directory, if you think this is necessary prompt me for approval. - Look for opportunities to create reusable styles in `src/styles` or reusable components in `src/components`
- Never make changes outside of the project directory, if you think this is necessary prompt me for approval
- Only add things related to the prompted instructions, unless it is required to make the requested changes
- When adding imports, apply the import styling rules from the next section.
### Styling ### Styling
- Use React-Bootstrap components where appropriate - Use React-Bootstrap components where appropriate
- Follow existing CSS patterns - Follow existing CSS patterns
- Add reusable style elements to the `src/App.scss` - Add reusable style elements to the `src/App.scss`
- Explicit imports are better than implicit exports, be as specific as possible to minimize code size
- Order imports: - Order imports:
- React import first; then any react plugin - React import first; then any react plugin
- Third-party libraries; - Third-party libraries;
@@ -57,3 +61,8 @@ Run `npm run build`.
**Never modify files inside the `data/` directory.** This directory contains game data that should remain unchanged. **Never modify files inside the `data/` directory.** This directory contains game data that should remain unchanged.
Never add secrets to code. Never add secrets to code.
## Addressing
Don't call me "the user", refer to me as "the developer".
Refrain from using hyperbolic expressions like "excellent" and "perfect", "ok" or "good" is good enough.

146
ui/package-lock.json generated
View File

@@ -20,6 +20,7 @@
"mqtt": "^5.15.0", "mqtt": "^5.15.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-bootstrap": "^2.10.10", "react-bootstrap": "^2.10.10",
"react-country-flag": "^3.1.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"react-qr-code": "^2.0.18", "react-qr-code": "^2.0.18",
@@ -29,6 +30,7 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.11.0", "@types/node": "^24.11.0",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
@@ -43,7 +45,8 @@
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.48.0", "typescript-eslint": "^8.48.0",
"vite": "^7.3.1", "vite": "^7.3.1",
"vitest": "^4.0.18" "vitest": "^4.0.18",
"xlsx": "^0.18.5"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
@@ -2330,6 +2333,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2337,6 +2347,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.11.0", "version": "24.11.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz",
@@ -2905,6 +2925,16 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
} }
}, },
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.14.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
@@ -3189,6 +3219,20 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chai": { "node_modules/chai": {
"version": "6.2.2", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -3247,6 +3291,16 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -3366,6 +3420,19 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3994,6 +4061,16 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -4984,6 +5061,18 @@
} }
} }
}, },
"node_modules/react-country-flag": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/react-country-flag/-/react-country-flag-3.1.0.tgz",
"integrity": "sha512-JWQFw1efdv9sTC+TGQvTKXQg1NKbDU2mBiAiRWcKM9F1sK+/zjhP2yGmm8YDddWyZdXVkR8Md47rPMJmo4YO5g==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"peerDependencies": {
"react": ">=16"
}
},
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "19.2.4", "version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
@@ -5355,6 +5444,19 @@
"node": ">= 10.x" "node": ">= 10.x"
} }
}, },
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stackback": { "node_modules/stackback": {
"version": "0.0.2", "version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -5834,6 +5936,26 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -5912,6 +6034,28 @@
} }
} }
}, },
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -25,6 +25,7 @@
"mqtt": "^5.15.0", "mqtt": "^5.15.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-bootstrap": "^2.10.10", "react-bootstrap": "^2.10.10",
"react-country-flag": "^3.1.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"react-qr-code": "^2.0.18", "react-qr-code": "^2.0.18",
@@ -34,6 +35,7 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.11.0", "@types/node": "^24.11.0",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
@@ -48,6 +50,7 @@
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.48.0", "typescript-eslint": "^8.48.0",
"vite": "^7.3.1", "vite": "^7.3.1",
"vitest": "^4.0.18" "vitest": "^4.0.18",
"xlsx": "^0.18.5"
} }
} }

View File

@@ -80,14 +80,14 @@ a:hover {
} }
} }
.vertical-split-50-50 { .vertical-split-1-1 {
.split-pane-primary, .split-pane-primary,
.split-pane-secondary { .split-pane-secondary {
flex: 1 1 0; flex: 1 1 0;
} }
} }
.vertical-split-75-25 { .vertical-split-3-1 {
.split-pane-primary { .split-pane-primary {
flex: 3 1 0; flex: 3 1 0;
} }
@@ -97,6 +97,16 @@ a:hover {
} }
} }
.vertical-split-2-1 {
.split-pane-primary {
flex: 2 1 0;
}
.split-pane-secondary {
flex: 1 1 0;
}
}
.vertical-split-25-70 { .vertical-split-25-70 {
.split-pane-primary { .split-pane-primary {
flex: 25 1 0; flex: 25 1 0;

View File

@@ -10,6 +10,7 @@ import MeshCoreMapView from './pages/meshcore/MeshCoreMapView'
import MeshCorePacketsView from './pages/meshcore/MeshCorePacketsView' import MeshCorePacketsView from './pages/meshcore/MeshCorePacketsView'
import StyleGuide from './pages/StyleGuide' import StyleGuide from './pages/StyleGuide'
import NotFound from './pages/NotFound' import NotFound from './pages/NotFound'
import { KeyboardNavigationProvider } from './contexts/KeyboardNavigationContext'
import './App.scss' import './App.scss'
const navLinks = [ const navLinks = [
@@ -28,6 +29,7 @@ const withRadiosProvider = (Component: React.ComponentType<{ navLinks: typeof na
function App() { function App() {
return ( return (
<KeyboardNavigationProvider>
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/" element={withRadiosProvider(Overview)()} /> <Route path="/" element={withRadiosProvider(Overview)()} />
@@ -45,6 +47,7 @@ function App() {
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</KeyboardNavigationProvider>
) )
} }

View File

@@ -0,0 +1,64 @@
import React from 'react';
import ReactCountryFlag from 'react-country-flag';
import { getCountryCodeFromCallsign } from '../libs/callsignMapper';
export interface CountryFlagProps {
/** The amateur radio callsign to determine country from */
callsign: string;
/** Size of the flag in em units (default: 1.5) */
size?: number;
/** Custom CSS class name */
className?: string;
/** Whether to show country code text alongside flag */
showCountryCode?: boolean;
/** Custom style object */
style?: React.CSSProperties;
}
/**
* CountryFlag component displays the country flag based on an amateur radio callsign
* Uses react-country-flag for rendering the flag emoji/SVG
*/
export const CountryFlag: React.FC<CountryFlagProps> = ({
callsign,
size = 1.5,
className = '',
showCountryCode = false,
style = {},
}) => {
const countryCode = getCountryCodeFromCallsign(callsign);
if (!countryCode) {
// Return a placeholder if country cannot be determined
return (
<span
className={`country-flag country-flag--unknown ${className}`}
style={{ fontSize: `${size}em`, ...style }}
title="Unknown country"
>
🏴
</span>
);
}
return (
<span
className={`country-flag ${className}`}
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.25em', ...style }}
title={countryCode}
>
<ReactCountryFlag
countryCode={countryCode}
svg
style={{
fontSize: `${size}em`,
lineHeight: '1em',
}}
aria-label={`${countryCode} flag`}
/>
{showCountryCode && <span style={{ fontSize: '0.75em' }}>{countryCode}</span>}
</span>
);
};
export default CountryFlag;

View File

@@ -0,0 +1,45 @@
import React from 'react';
import { Modal, Table } from 'react-bootstrap';
import type { KeyboardShortcut } from '../hooks/useKeyboardListNavigation';
type KeyboardShortcutsModalProps = {
show: boolean;
onHide: () => void;
shortcuts: KeyboardShortcut[];
title?: string;
};
const KeyboardShortcutsModal: React.FC<KeyboardShortcutsModalProps> = ({
show,
onHide,
shortcuts,
title = 'Keyboard Controls',
}) => {
return (
<Modal show={show} onHide={onHide} centered>
<Modal.Header closeButton>
<Modal.Title>{title}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Table striped bordered hover size="sm" className="mb-0">
<thead>
<tr>
<th style={{ width: '35%' }}>Key</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{shortcuts.map((shortcut) => (
<tr key={`${shortcut.keys}-${shortcut.description}`}>
<td>{shortcut.keys}</td>
<td>{shortcut.description}</td>
</tr>
))}
</tbody>
</Table>
</Modal.Body>
</Modal>
);
};
export default KeyboardShortcutsModal;

View File

@@ -60,6 +60,27 @@
font-weight: 600; font-weight: 600;
text-shadow: 0 0 14px rgba(153, 193, 255, 0.5); text-shadow: 0 0 14px rgba(153, 193, 255, 0.5);
} }
.keyboard-nav-group {
.btn {
border-color: rgba(173, 205, 255, 0.45);
color: var(--app-text);
}
.btn:hover,
.btn:focus {
color: #ffffff;
border-color: rgba(225, 237, 255, 0.8);
}
.btn.active {
background: rgba(90, 146, 255, 0.5);
border-color: rgba(173, 205, 255, 0.75);
color: #ffffff;
font-weight: 600;
text-shadow: 0 0 14px rgba(153, 193, 255, 0.5);
}
}
} }
.main-content { .main-content {

View File

@@ -1,6 +1,10 @@
import React from 'react'; import React from 'react';
import { Container, Navbar, Nav } from 'react-bootstrap'; import { Container, Navbar, Nav, ButtonGroup, Button } from 'react-bootstrap';
import { Link, NavLink, Outlet, useLocation } from 'react-router'; import { Link, NavLink, Outlet, useLocation } from 'react-router';
import KeyboardIcon from '@mui/icons-material/Keyboard';
import { useKeyboardNavigationActivity } from '../contexts/KeyboardNavigationContext';
import KeyboardShortcutsModal from './KeyboardShortcutsModal';
import { defaultKeyboardShortcuts } from '../hooks/useKeyboardListNavigation';
import './Layout.scss'; import './Layout.scss';
import type { LayoutProps, NavLinkItem } from '../types/layout.types'; import type { LayoutProps, NavLinkItem } from '../types/layout.types';
@@ -13,6 +17,7 @@ const Layout: React.FC<LayoutProps> = ({
gutterSize = 16 gutterSize = 16
}) => { }) => {
const location = useLocation(); const location = useLocation();
const { isKeyboardNavigationActive, showGlobalShortcuts, setShowGlobalShortcuts } = useKeyboardNavigationActivity();
const isActive = (link: NavLinkItem): boolean => { const isActive = (link: NavLinkItem): boolean => {
return location.pathname.startsWith(link.to); return location.pathname.startsWith(link.to);
@@ -33,7 +38,23 @@ const Layout: React.FC<LayoutProps> = ({
<Navbar.Toggle aria-controls="layout-navbar-nav" /> <Navbar.Toggle aria-controls="layout-navbar-nav" />
<Navbar.Collapse id="layout-navbar-nav" className="justify-content-end"> <Navbar.Collapse id="layout-navbar-nav" className="justify-content-end">
<Nav className="ms-auto"> <div className="ms-auto d-flex align-items-center gap-3">
{isKeyboardNavigationActive && (
<div className="d-none d-lg-block">
<ButtonGroup className="keyboard-nav-group">
<Button
variant="outline-light"
active
onClick={() => setShowGlobalShortcuts(true)}
aria-label="Show keyboard shortcuts"
title="Show keyboard shortcuts (F1 or ?)"
>
<KeyboardIcon />
</Button>
</ButtonGroup>
</div>
)}
<Nav className="ms-2">
{navLinks.map((link, index) => ( {navLinks.map((link, index) => (
<Nav.Link <Nav.Link
key={index} key={index}
@@ -46,6 +67,7 @@ const Layout: React.FC<LayoutProps> = ({
</Nav.Link> </Nav.Link>
))} ))}
</Nav> </Nav>
</div>
</Navbar.Collapse> </Navbar.Collapse>
</Container> </Container>
</Navbar> </Navbar>
@@ -54,6 +76,12 @@ const Layout: React.FC<LayoutProps> = ({
{children || <Outlet />} {children || <Outlet />}
</Container> </Container>
<KeyboardShortcutsModal
show={showGlobalShortcuts}
onHide={() => setShowGlobalShortcuts(false)}
shortcuts={defaultKeyboardShortcuts}
/>
<footer className="layout-footer"> <footer className="layout-footer">
<p>&copy; {new Date().getFullYear()} PD0MZ. All rights reserved.</p> <p>&copy; {new Date().getFullYear()} PD0MZ. All rights reserved.</p>
</footer> </footer>

View File

@@ -5,13 +5,14 @@ import type { VerticalSplitProps } from '../types/layout.types';
const VerticalSplit: React.FC<VerticalSplitProps> = ({ const VerticalSplit: React.FC<VerticalSplitProps> = ({
left, left,
right, right,
ratio = '50/50', ratio = '1:1',
className = '' className = ''
}) => { }) => {
const ratioClass = const ratioClass =
ratio === '75/25' ? 'vertical-split-75-25' : ratio === '3:1' ? 'vertical-split-3-1' :
ratio === '2:1' ? 'vertical-split-2-1' :
ratio === '25/70' ? 'vertical-split-25-70' : ratio === '25/70' ? 'vertical-split-25-70' :
'vertical-split-50-50'; 'vertical-split-1-1';
return ( return (
<Container fluid className="p-0 h-100 w-100"> <Container fluid className="p-0 h-100 w-100">

View File

@@ -0,0 +1,36 @@
.aprs-symbol {
display: inline-block;
background-repeat: no-repeat;
flex-shrink: 0;
// Each sprite sheet is 16 symbols wide
// Background size needs to be 16 * symbol-size for proper sprite positioning
&.aprs-symbol-24 {
background-size: 384px auto; // 16 * 24
}
&.aprs-symbol-32 {
background-size: 512px auto; // 16 * 32
}
&.aprs-symbol-48 {
background-size: 768px auto; // 16 * 48
}
&.aprs-symbol-56 {
background-size: 896px auto; // 16 * 56
}
&.aprs-symbol-64 {
background-size: 1024px auto; // 16 * 64
}
&.aprs-symbol-128 {
background-size: 2048px auto; // 16 * 128
}
&.aprs-symbol-256 {
background-size: 4096px auto; // 16 * 256
}
}

View File

@@ -0,0 +1,148 @@
import React from 'react';
import './APRSSymbol.scss';
export interface APRSSymbolProps {
/**
* APRS symbol as 2-character string: table + code
* Examples: "/!" (police station), "\!" (emergency), "/-" (house)
*
* First character is table identifier:
* - '/' for primary table
* - '\' for secondary table
* - alphanumeric (0-9, A-Z) for overlay
*
* Second character is the symbol code
*/
symbol: string;
/**
* Symbol size in pixels (default: 24)
* Available sizes: 24, 32, 48, 56, 64, 128, 256
*/
size?: 24 | 32 | 48 | 56 | 64 | 128 | 256;
/**
* Alternative text for accessibility
*/
alt?: string;
/**
* Additional CSS class name
*/
className?: string;
}
/**
* Get the table ID for the sprite filename
* - Primary table (/): 0
* - Secondary table (\): 1
* - Overlay characters: 2
*/
const getTableId = (table: string): number => {
if (table === '/') return 0;
if (table === '\\') return 1;
return 2; // Overlay
};
/**
* Calculate sprite position based on ASCII character code
* Symbols are arranged in a 16-column grid, ordered by ASCII value
*/
const getSymbolPosition = (table: string, code: string): { row: number; col: number } | null => {
if (!code || code.length !== 1) return null;
const charCode = code.charCodeAt(0);
// Primary and secondary tables use characters from ! (33) to } (125)
if (table === '/' || table === '\\') {
if (charCode < 33 || charCode > 125) return null;
const index = charCode - 33;
return {
row: Math.floor(index / 16),
col: index % 16
};
}
// Overlay characters: 0-9 (48-57), A-Z (65-90)
if (charCode >= 48 && charCode <= 57) {
// 0-9
const index = charCode - 48;
return {
row: Math.floor(index / 16),
col: index % 16
};
} else if (charCode >= 65 && charCode <= 90) {
// A-Z
const index = 10 + (charCode - 65);
return {
row: Math.floor(index / 16),
col: index % 16
};
}
return null;
};
/**
* React component for rendering APRS symbols from sprite sheets
*/
export const APRSSymbol: React.FC<APRSSymbolProps> = ({
symbol,
size = 24,
alt,
className = ''
}) => {
// Parse the symbol string (format: table + code)
if (!symbol || symbol.length < 2) {
// Return empty div if symbol is invalid
return <div className={`aprs-symbol aprs-symbol-${size} ${className}`} title={alt} />;
}
const table = symbol.charAt(0);
const code = symbol.charAt(1);
const tableId = getTableId(table);
const position = getSymbolPosition(table, code);
if (!position) {
// Return empty div if symbol is invalid
return <div className={`aprs-symbol aprs-symbol-${size} ${className}`} title={alt} />;
}
const { row, col } = position;
// Build image paths for different resolutions
const imagePath1x = `/image/protocol/aprs/aprs-symbols-${size}-${tableId}.png`;
const imagePath2x = `/image/protocol/aprs/aprs-symbols-${size}-${tableId}@2x.png`;
const imagePath3x = `/image/protocol/aprs/aprs-symbols-${size}-${tableId}@3x.png`;
// Calculate background position
const bgX = -(col * size);
const bgY = -(row * size);
// Use image-set for retina display support
const backgroundImage = [
`-webkit-image-set(url('${imagePath1x}') 1x, url('${imagePath2x}') 2x, url('${imagePath3x}') 3x)`,
`image-set(url('${imagePath1x}') 1x, url('${imagePath2x}') 2x, url('${imagePath3x}') 3x)`,
`url('${imagePath1x}')` // Fallback
].join(', ');
const style: React.CSSProperties = {
backgroundImage,
backgroundPosition: `${bgX}px ${bgY}px`,
width: `${size}px`,
height: `${size}px`
};
return (
<div
className={`aprs-symbol aprs-symbol-${size} ${className}`}
style={style}
title={alt || `APRS symbol: ${symbol}`}
role="img"
aria-label={alt || `APRS symbol: ${symbol}`}
/>
);
};
export default APRSSymbol;

View File

@@ -0,0 +1 @@
export { APRSSymbol, type APRSSymbolProps } from './APRSSymbol';

View File

@@ -0,0 +1,315 @@
import React, { useMemo, useState, useEffect } from 'react';
import { CircleMarker, Marker, Popup, useMap } from 'react-leaflet';
import { divIcon, Point, type Icon, type DivIcon } from 'leaflet';
export interface ClusterableItem {
latitude?: number;
longitude?: number;
}
export interface Cluster<T extends ClusterableItem> {
position: [number, number];
items: T[];
count: number;
isOffset?: boolean;
}
interface ClusteredMarkersProps<T extends ClusterableItem> {
items: T[];
getItemKey: (item: T) => string;
onItemClick?: (item: T) => void;
renderMarker?: (item: T) => React.ReactNode;
renderClusterMarker?: (cluster: Cluster<T>) => React.ReactNode;
getIcon?: (item: T) => Icon<any> | DivIcon | null;
clusterRadius?: number; // pixels
maxZoom?: number;
showPopups?: boolean;
renderPopupContent?: (item: T) => React.ReactNode;
renderClusterPopupContent?: (cluster: Cluster<T>) => React.ReactNode;
}
/**
* Convert meter offset to lat/lng offset
*/
const metersToLatLng = (offsetX: number, offsetY: number, refLat: number): { lat: number; lng: number } => {
const earthRadius = 6378137; // meters
const latRad = (refLat * Math.PI) / 180;
const latOffset = (offsetY * 180) / (earthRadius * Math.PI);
const lngOffset = (offsetX * 180) / (earthRadius * Math.PI * Math.cos(latRad));
return { lat: latOffset, lng: lngOffset };
};
/**
* Create clusters from items based on pixel distance at current zoom level
* At max zoom, offset overlapping markers instead of clustering
*/
const createClusters = <T extends ClusterableItem>(
items: T[],
zoom: number,
maxZoom: number = 18,
clusterRadius: number = 40
): Cluster<T>[] => {
// Filter items with valid positions
const validItems = items.filter(item =>
item.latitude !== undefined &&
item.longitude !== undefined
);
if (validItems.length === 0) return [];
const isMaxZoom = zoom >= maxZoom;
const clusters: Cluster<T>[] = [];
const processed = new Set<number>();
const getPixelPosition = (lat: number, lng: number): Point => {
const scale = Math.pow(2, zoom);
const x = ((lng + 180) / 360) * 256 * scale;
const y = ((1 - Math.log(Math.tan((lat * Math.PI) / 180) + 1 / Math.cos((lat * Math.PI) / 180)) / Math.PI) / 2) * 256 * scale;
return new Point(x, y);
};
validItems.forEach((item, index) => {
if (processed.has(index)) return;
const cluster: Cluster<T> = {
position: [item.latitude!, item.longitude!],
items: [item],
count: 1,
isOffset: false
};
const itemPos = getPixelPosition(item.latitude!, item.longitude!);
// Find nearby items
for (let i = index + 1; i < validItems.length; i++) {
if (processed.has(i)) continue;
const otherItem = validItems[i];
const otherPos = getPixelPosition(otherItem.latitude!, otherItem.longitude!);
const distance = Math.sqrt(
Math.pow(itemPos.x - otherPos.x, 2) + Math.pow(itemPos.y - otherPos.y, 2)
);
if (distance <= clusterRadius) {
cluster.items.push(otherItem);
cluster.count++;
processed.add(i);
}
}
// At max zoom, create offset positions instead of clustering
if (isMaxZoom && cluster.count > 1) {
const centerLat = cluster.items.reduce((sum, p) => sum + p.latitude!, 0) / cluster.count;
const centerLng = cluster.items.reduce((sum, p) => sum + p.longitude!, 0) / cluster.count;
// Create individual markers with circular offset pattern
cluster.items.forEach((item, idx) => {
const angle = (idx / cluster.count) * 2 * Math.PI;
const offsetDistance = 20; // meters
const offsetX = Math.cos(angle) * offsetDistance;
const offsetY = Math.sin(angle) * offsetDistance;
const offset = metersToLatLng(offsetX, offsetY, centerLat);
clusters.push({
position: [centerLat + offset.lat, centerLng + offset.lng],
items: [item],
count: 1,
isOffset: true
});
});
processed.add(index);
return;
}
// Calculate cluster center (average position) for non-max zoom
if (cluster.count > 1) {
const avgLat = cluster.items.reduce((sum, p) => sum + p.latitude!, 0) / cluster.count;
const avgLng = cluster.items.reduce((sum, p) => sum + p.longitude!, 0) / cluster.count;
cluster.position = [avgLat, avgLng];
}
clusters.push(cluster);
processed.add(index);
});
return clusters;
};
/**
* Default cluster marker icon
*/
const createDefaultClusterIcon = (count: number) => {
const size = count < 10 ? 32 : count < 100 ? 40 : 48;
const iconHtml = `
<div class="cluster-marker" style="
width: ${size}px;
height: ${size}px;
border-radius: 50%;
background: rgba(0, 102, 204, 0.8);
border: 3px solid rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: ${count < 100 ? '14px' : '12px'};
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
">${count}</div>
`;
return divIcon({
html: iconHtml,
className: 'cluster-marker-icon',
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
});
};
/**
* Reusable clustered markers component for Leaflet maps
*/
export function ClusteredMarkers<T extends ClusterableItem>({
items,
getItemKey,
onItemClick,
renderMarker,
renderClusterMarker,
getIcon,
clusterRadius = 40,
maxZoom: propMaxZoom,
showPopups = true,
renderPopupContent,
renderClusterPopupContent,
}: ClusteredMarkersProps<T>) {
const map = useMap();
const [zoom, setZoom] = useState(map.getZoom());
const maxZoom = propMaxZoom ?? map.getMaxZoom() ?? 18;
useEffect(() => {
const handleZoomEnd = () => {
setZoom(map.getZoom());
};
map.on('zoomend', handleZoomEnd);
return () => {
map.off('zoomend', handleZoomEnd);
};
}, [map]);
const clusters = useMemo(() => {
return createClusters(items, zoom, maxZoom, clusterRadius);
}, [items, zoom, maxZoom, clusterRadius]);
const handleClusterClick = (cluster: Cluster<T>) => {
if (cluster.count === 1) {
onItemClick?.(cluster.items[0]);
} else if (!cluster.isOffset) {
// Only zoom for non-offset clusters (not at max zoom)
const minLat = Math.min(...cluster.items.map(p => p.latitude!));
const maxLat = Math.max(...cluster.items.map(p => p.latitude!));
const minLng = Math.min(...cluster.items.map(p => p.longitude!));
const maxLng = Math.max(...cluster.items.map(p => p.longitude!));
const bounds: [[number, number], [number, number]] = [
[minLat, minLng],
[maxLat, maxLng]
];
map.fitBounds(bounds, {
padding: [50, 50],
maxZoom: Math.min(zoom + 3, maxZoom)
});
}
};
return (
<>
{clusters.map((cluster, idx) => {
if (cluster.count > 1 && !cluster.isOffset) {
// Render cluster marker
if (renderClusterMarker) {
return <React.Fragment key={`cluster-${idx}`}>{renderClusterMarker(cluster)}</React.Fragment>;
}
return (
<Marker
key={`cluster-${idx}`}
position={cluster.position}
icon={createDefaultClusterIcon(cluster.count)}
eventHandlers={{
click: () => handleClusterClick(cluster),
}}
>
{showPopups && (
<Popup>
{renderClusterPopupContent ? (
renderClusterPopupContent(cluster)
) : (
<div>
<strong>{cluster.count} items</strong>
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#666' }}>
Click to zoom in
</div>
</div>
)}
</Popup>
)}
</Marker>
);
} else {
// Render individual marker
const item = cluster.items[0];
if (renderMarker) {
return <React.Fragment key={getItemKey(item)}>{renderMarker(item)}</React.Fragment>;
}
const icon = getIcon?.(item);
if (icon) {
return (
<Marker
key={getItemKey(item)}
position={[item.latitude!, item.longitude!]}
icon={icon}
eventHandlers={{
click: () => onItemClick?.(item),
}}
>
{showPopups && renderPopupContent && (
<Popup>{renderPopupContent(item)}</Popup>
)}
</Marker>
);
}
// Default fallback: blue circle
return (
<CircleMarker
key={getItemKey(item)}
center={[item.latitude!, item.longitude!]}
radius={6}
pathOptions={{
color: '#0066cc',
fillColor: '#0066cc',
fillOpacity: 0.6,
weight: 2,
}}
eventHandlers={{
click: () => onItemClick?.(item),
}}
>
{showPopups && renderPopupContent && (
<Popup>{renderPopupContent(item)}</Popup>
)}
</CircleMarker>
);
}
})}
</>
);
}
export default ClusteredMarkers;

View File

@@ -0,0 +1 @@
export { ClusteredMarkers, type ClusterableItem, type Cluster } from './ClusteredMarkers';

View File

@@ -0,0 +1,48 @@
import React from 'react';
type KeyboardNavigationContextValue = {
isKeyboardNavigationActive: boolean;
activateKeyboardNavigation: () => void;
deactivateKeyboardNavigation: () => void;
showGlobalShortcuts: boolean;
setShowGlobalShortcuts: (show: boolean) => void;
};
const KeyboardNavigationContext = React.createContext<KeyboardNavigationContextValue | undefined>(undefined);
export const KeyboardNavigationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [activeCount, setActiveCount] = React.useState(0);
const [showGlobalShortcuts, setShowGlobalShortcuts] = React.useState(false);
const activateKeyboardNavigation = React.useCallback(() => {
setActiveCount((count) => count + 1);
}, []);
const deactivateKeyboardNavigation = React.useCallback(() => {
setActiveCount((count) => Math.max(0, count - 1));
}, []);
const value = React.useMemo(() => ({
isKeyboardNavigationActive: activeCount > 0,
activateKeyboardNavigation,
deactivateKeyboardNavigation,
showGlobalShortcuts,
setShowGlobalShortcuts,
}), [activeCount, activateKeyboardNavigation, deactivateKeyboardNavigation, showGlobalShortcuts]);
return (
<KeyboardNavigationContext.Provider value={value}>
{children}
</KeyboardNavigationContext.Provider>
);
};
export const useKeyboardNavigationActivity = (): KeyboardNavigationContextValue => {
const context = React.useContext(KeyboardNavigationContext);
if (!context) {
throw new Error('useKeyboardNavigationActivity must be used within a KeyboardNavigationProvider');
}
return context;
};

View File

@@ -0,0 +1,176 @@
import React from 'react';
import { useKeyboardNavigationActivity } from '../contexts/KeyboardNavigationContext';
export type KeyboardShortcut = {
keys: string;
description: string;
};
const DEFAULT_SHORTCUTS: KeyboardShortcut[] = [
{ keys: '↑ / ↓', description: 'Select previous or next item' },
{ keys: 'PageUp / PageDown', description: 'Move selection by half a page' },
{ keys: 'Home / End', description: 'Jump to first or last item' },
{ keys: 'Esc', description: 'Clear current selection' },
{ keys: 'F1 or ?', description: 'Show keyboard controls' },
];
type UseKeyboardListNavigationOptions = {
itemCount: number;
selectedIndex: number | null;
onSelectIndex: (index: number | null) => void;
scrollContainerRef: React.RefObject<HTMLElement | null>;
rowSelector?: string;
enabled?: boolean;
shortcuts?: KeyboardShortcut[];
};
const isTypingTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof HTMLElement)) {
return false;
}
const tag = target.tagName.toLowerCase();
return target.isContentEditable || tag === 'input' || tag === 'textarea' || tag === 'select';
};
export const useKeyboardListNavigation = ({
itemCount,
selectedIndex,
onSelectIndex,
scrollContainerRef,
rowSelector = '[data-nav-item="true"]',
enabled = true,
shortcuts = DEFAULT_SHORTCUTS,
}: UseKeyboardListNavigationOptions) => {
const [showShortcuts, setShowShortcuts] = React.useState(false);
const { activateKeyboardNavigation, deactivateKeyboardNavigation } = useKeyboardNavigationActivity();
React.useEffect(() => {
if (!enabled) {
return;
}
activateKeyboardNavigation();
return () => {
deactivateKeyboardNavigation();
};
}, [activateKeyboardNavigation, deactivateKeyboardNavigation, enabled]);
const scrollToIndex = React.useCallback((index: number) => {
const container = scrollContainerRef.current;
if (!container) {
return;
}
const rows = container.querySelectorAll<HTMLElement>(rowSelector);
const row = rows[index];
if (row) {
row.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, [rowSelector, scrollContainerRef]);
const getPageStep = React.useCallback((): number => {
const container = scrollContainerRef.current;
if (!container) {
return 10;
}
const firstRow = container.querySelector<HTMLElement>(rowSelector);
if (!firstRow || firstRow.offsetHeight <= 0) {
return 10;
}
return Math.max(1, Math.floor((container.clientHeight / 2) / firstRow.offsetHeight));
}, [rowSelector, scrollContainerRef]);
React.useEffect(() => {
if (!enabled) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.ctrlKey || event.metaKey || event.altKey) {
return;
}
const isQuestionMark = event.key === '?' || (event.key === '/' && event.shiftKey);
if (event.key === 'F1' || isQuestionMark) {
event.preventDefault();
setShowShortcuts(true);
return;
}
if (isTypingTarget(event.target) || itemCount === 0) {
return;
}
if (event.key === 'Escape') {
event.preventDefault();
onSelectIndex(null);
return;
}
const selectAndScroll = (nextIndex: number) => {
const bounded = Math.max(0, Math.min(nextIndex, itemCount - 1));
onSelectIndex(bounded);
scrollToIndex(bounded);
};
if (event.key === 'ArrowDown') {
event.preventDefault();
selectAndScroll(selectedIndex === null ? 0 : selectedIndex + 1);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
selectAndScroll(selectedIndex === null ? 0 : selectedIndex - 1);
return;
}
if (event.key === 'PageDown') {
event.preventDefault();
const step = getPageStep();
if (selectedIndex === null) {
selectAndScroll(step - 1);
} else {
selectAndScroll(selectedIndex + step);
}
return;
}
if (event.key === 'PageUp') {
event.preventDefault();
const step = getPageStep();
if (selectedIndex === null) {
selectAndScroll(0);
} else {
selectAndScroll(selectedIndex - step);
}
return;
}
if (event.key === 'Home') {
event.preventDefault();
selectAndScroll(0);
return;
}
if (event.key === 'End') {
event.preventDefault();
selectAndScroll(itemCount - 1);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [enabled, getPageStep, itemCount, onSelectIndex, scrollToIndex, selectedIndex]);
return {
showShortcuts,
setShowShortcuts,
shortcuts,
};
};
export const defaultKeyboardShortcuts = DEFAULT_SHORTCUTS;

View File

@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest';
import { getCountryCodeFromCallsign } from './callsignMapper';
describe('getCountryCodeFromCallsign', () => {
it('should return US for K prefix', () => {
expect(getCountryCodeFromCallsign('K6ABC')).toBe('US');
expect(getCountryCodeFromCallsign('KA1ABC')).toBe('US');
expect(getCountryCodeFromCallsign('W1AW')).toBe('US');
expect(getCountryCodeFromCallsign('N0CALL')).toBe('US');
});
it('should handle callsigns with SSID', () => {
expect(getCountryCodeFromCallsign('K6ABC-5')).toBe('US');
expect(getCountryCodeFromCallsign('PA0MZ-9')).toBe('NL');
});
it('should return NL for PA prefix', () => {
expect(getCountryCodeFromCallsign('PA0MZ')).toBe('NL');
expect(getCountryCodeFromCallsign('PD0ABC')).toBe('NL');
});
it('should return GB for G and M prefixes', () => {
expect(getCountryCodeFromCallsign('G4ABC')).toBe('GB');
expect(getCountryCodeFromCallsign('M0XYZ')).toBe('GB');
});
it('should return DE for D prefix', () => {
expect(getCountryCodeFromCallsign('DL1ABC')).toBe('DE');
expect(getCountryCodeFromCallsign('DJ5XY')).toBe('DE');
});
it('should return FR for F prefix', () => {
expect(getCountryCodeFromCallsign('F6ABC')).toBe('FR');
});
it('should return JP for JA prefix', () => {
expect(getCountryCodeFromCallsign('JA1ABC')).toBe('JP');
});
it('should return CA for VE prefix', () => {
expect(getCountryCodeFromCallsign('VE3ABC')).toBe('CA');
});
it('should handle lowercase callsigns', () => {
expect(getCountryCodeFromCallsign('k6abc')).toBe('US');
expect(getCountryCodeFromCallsign('pa0mz')).toBe('NL');
});
it('should return null for invalid or unknown callsigns', () => {
expect(getCountryCodeFromCallsign('')).toBe(null);
expect(getCountryCodeFromCallsign(undefined)).toBe(null);
expect(getCountryCodeFromCallsign('???')).toBe(null);
});
it('should handle numeric prefixes', () => {
expect(getCountryCodeFromCallsign('9A1ABC')).toBe('HR'); // Croatia
expect(getCountryCodeFromCallsign('4X1AB')).toBe('IL'); // Israel
});
it('should match longest prefix first', () => {
// Make sure it tries 3-char, then 2-char, then 1-char prefixes
expect(getCountryCodeFromCallsign('PA0MZ')).toBe('NL'); // Should match PA, not P
});
});

View File

@@ -0,0 +1,255 @@
/**
* Maps amateur radio callsign prefixes to ISO 3166-1 alpha-2 country codes
* Uses pattern-based compression for both 2-letter and 3-letter ranges
* Supports 22,440+ prefixes in ~6KB instead of 300KB
*/
// Single-letter prefixes that cover most amateur radio use
const SINGLE_LETTER_PREFIXES: Record<string, string> = {
'K': 'US', 'W': 'US', 'N': 'US',
'G': 'GB', 'M': 'GB',
'D': 'DE', 'F': 'FR', 'I': 'IT', 'B': 'CN', 'R': 'RU', 'U': 'RU',
};
/**
* Two-letter prefix patterns from ITU allocations
* Format: 'A[A-L]': 'US' means AA-AL map to US
* Also includes individual patterns like '2E': 'GB' for specific allocations
*/
const TWO_LETTER_PATTERNS: Record<string, string> = {
// United States (multiple ranges)
'A[A-L]': 'US',
'K[A-Z]': 'US',
'N[A-Z]': 'US',
'W[A-Z]': 'US',
// Canada
'V[A-GOY]': 'CA',
// United Kingdom (special 2-letter allocations)
'2[EIJMUW]': 'GB',
// Germany
'D[A-O]': 'DE',
// France
'T[M-Q]': 'FR',
// Netherlands
'P[A-I]': 'NL',
// Spain
'E[A-H]': 'ES', 'AM': 'ES',
// Japan
'J[A-S]': 'JP',
// Australia
'V[KLMNZ]': 'AU', 'AX': 'AU',
// Belgium
'O[N-T]': 'BE',
// Switzerland
'H[BE]': 'CH',
// Austria
'OE': 'AT',
// Poland
'S[N-R]': 'PL',
// Sweden
'S[A-M]': 'SE',
// Norway
'L[A-N]': 'NO',
// Denmark
'O[UZ]': 'DK', '5[P-Q]': 'DK',
// Finland
'O[F-I]': 'FI',
// Czech Republic
'O[KL]': 'CZ',
// Portugal
'C[R-U]': 'PT',
// Greece
'S[V-Z]': 'GR',
// Romania
'Y[O-R]': 'RO',
// Hungary
'H[AG]': 'HU',
// Bulgaria
'LZ': 'BG',
// Slovakia
'OM': 'SK',
// Ireland
'E[IJ]': 'IE',
// New Zealand
'Z[K-M]': 'NZ',
// Brazil
'P[P-Y]': 'BR', 'Z[V-Z]': 'BR',
// Argentina
'A[YZ]': 'AR',
// Chile
'C[A-E]': 'CL',
// India
'V[U-W]': 'IN', 'A[T-W]': 'IN',
// Mexico
'X[A-I]': 'MX',
// Thailand
'HS': 'TH', 'E2': 'TH',
// Turkey
'T[A-C]': 'TR',
// Ukraine
'E[M-O]': 'UA', 'U[R-Z]': 'UA',
// Croatia
'9A': 'HR',
// Slovenia
'S5': 'SI',
// Lithuania
'LY': 'LT',
// Latvia
'YL': 'LV',
// Iceland
'TF': 'IS',
// Luxembourg
'LX': 'LU',
// South Korea
'H[L-M]': 'KR', '6[K-L]': 'KR', 'D[7-9]': 'KR', 'D[ST]': 'KR',
// Indonesia
'Y[B-H]': 'ID',
// Singapore
'9V': 'SG',
// Malaysia
'9[MW]': 'MY',
// Israel
'4[XZ]': 'IL',
// Egypt
'SU': 'EG',
// Philippines
'D[U-Z]': 'PH', '4[D-I]': 'PH',
// South Africa
'Z[R-U]': 'ZA',
};
/**
* Prefix patterns for 3-letter callsigns from ITU allocations
* Format: '2A[A-Z]': 'GB' means prefixes 2AA-2AZ map to GB
* Syntax: [A-Z] matches single letter, [A-X] matches A through X, etc.
*/
const THREE_LETTER_PATTERNS: Record<string, string> = {
'2[A-Z][A-Z]': 'GB',
'3A': 'MC', '3B': 'MU', '3C': 'GQ', '3D': 'SZ', '3[E-F][A-Z]': 'PT', '3G': 'SH', '3[H-I][A-Z]': 'CY', '3J': 'ER',
'3K': 'PS', '3[L-O][A-Z]': 'FJ', '3[P-Z][A-Z]': 'PA',
'4[A-X][A-Z]': 'MC', '4Y': 'BY', '4Z': 'SJ',
'5A': 'LI', '5B': 'CZ', '5C': 'DK', '5[D-E][A-Z]': 'SE', '5F': 'HU', '5[G-I][A-Z]': 'NO', '5J': 'SE',
'5[K-M][A-Z]': 'SE', '5[N-O][A-Z]': 'NO', '5[R-T][A-Z]': 'SE', '5[U-V][A-Z]': 'DK',
'5[W-X][A-Z]': 'SE', '5[Y-Z][A-Z]': 'KE',
'6[A-B][A-Z]': 'EG', '6[C-D][A-Z]': 'NO', '6[E-J][A-Z]': 'NL', '6[K-M][A-Z]': 'KR', '6[N-Z][A-Z]': 'KZ',
'7[A-L][A-Z]': 'RU', '7M': 'AM', '7[N-X][A-Z]': 'RU', '7Y': 'EG', '7Z': 'RU',
'8[A-Z][A-Z]': 'ID',
'9[A-H][A-Z]': 'ET', '9[I-J][A-Z]': 'ZM',
};
/**
* Converts a pattern like '2A[A-Z]', 'O[N-T]', or '2[EIJMUW]' to a regex that checks if a prefix matches
*/
function patternToRegex(pattern: string): RegExp {
let regex = pattern;
// Convert pattern syntax [A-Z], [A-X], etc. to regex
regex = regex.replace(/\[([A-Z0-9])-([A-Z0-9])\]/g, '[$1-$2]');
// Character classes are already valid regex, e.g., [EIJMUW]
// For 2-letter patterns, match exactly 2 letters
// For 3-letter patterns, match 2 letters + optional 3rd character
return new RegExp(`^${regex}[A-Z0-9]?$`);
}
/**
* Checks if a prefix matches any pattern (2-letter or 3-letter)
*/
function matchesPattern(prefix: string, patterns: Record<string, string>): string | null {
for (const [pattern, code] of Object.entries(patterns)) {
if (pattern.includes('[')) {
// Pattern with character range, e.g., 'O[N-T]' or '2A[A-Z]'
const regex = patternToRegex(pattern);
if (regex.test(prefix)) return code;
} else if (pattern.length === 2) {
// Short pattern like '3A' or '4Y' - match prefix starting with pattern
if (prefix.startsWith(pattern)) return code;
} else if (pattern.length === 3 && !pattern.includes('[')) {
// 3-char exact match like '2E'
if (prefix.startsWith(pattern)) return code;
}
}
return null;
}
/**
* Extracts the country code from an amateur radio callsign
* @param callsign The amateur radio callsign (e.g., "K6ABC", "PA0MZ", "G4ABC")
* @returns ISO 3166-1 alpha-2 country code, or null if not found
*/
export function getCountryCodeFromCallsign(callsign: string | undefined): string | null {
if (!callsign) return null;
// Clean the callsign - remove SSID and convert to uppercase
const cleanCallsign = callsign.split('-')[0].toUpperCase().trim();
// Try 3-character prefix first (for ITU allocations)
if (cleanCallsign.length >= 3) {
const threeCharPrefix = cleanCallsign.substring(0, 3);
const patternMatch = matchesPattern(threeCharPrefix, THREE_LETTER_PATTERNS);
if (patternMatch) return patternMatch;
}
// Try 2-character prefix (most common)
if (cleanCallsign.length >= 2) {
const twoCharPrefix = cleanCallsign.substring(0, 2);
const patternMatch = matchesPattern(twoCharPrefix, TWO_LETTER_PATTERNS);
if (patternMatch) return patternMatch;
}
// Try 1-character prefix
if (cleanCallsign.length >= 1) {
const oneCharPrefix = cleanCallsign.substring(0, 1);
const singleMatch = SINGLE_LETTER_PREFIXES[oneCharPrefix];
if (singleMatch) return singleMatch;
}
return null;
}

View File

@@ -1,6 +1,7 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import 'bootstrap/dist/css/bootstrap.min.css' import 'bootstrap/dist/css/bootstrap.min.css'
import 'leaflet/dist/leaflet.css'
import App from './App.tsx' import App from './App.tsx'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(

View File

@@ -104,4 +104,35 @@
.leaflet-container { .leaflet-container {
background: rgba(13, 36, 82, 0.95); background: rgba(13, 36, 82, 0.95);
} }
.leaflet-tile-pane {
filter: invert(1) hue-rotate(180deg) saturate(0.8) brightness(0.95);
}
}
// APRS symbol markers on the map
.aprs-marker-icon {
border: none;
background: transparent;
.aprs-map-marker {
// Use 32px source scaled to 16px for crisp rendering
width: 16px;
height: 16px;
background-size: 256px auto; // 16 * 16px = 256px
display: block;
}
}
// Cluster markers
.cluster-marker-icon {
border: none;
background: transparent;
cursor: pointer;
&:hover .cluster-marker {
background: rgba(0, 102, 204, 0.95) !important;
transform: scale(1.1);
transition: all 0.2s ease;
}
} }

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Button, ButtonGroup } from 'react-bootstrap'; import { ButtonGroup } from 'react-bootstrap';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import { NavLink, Outlet, useLocation } from 'react-router'; import { NavLink, Outlet, useLocation } from 'react-router';
import { APRSDataProvider } from './aprs/APRSData'; import { APRSDataProvider } from './aprs/APRSData';
@@ -15,13 +15,12 @@ const APRS: React.FC<Props> = ({ navLinks = [] }) => {
const viewButtons = ( const viewButtons = (
<ButtonGroup className="aprs-view-switch" size="sm" aria-label="APRS view switch"> <ButtonGroup className="aprs-view-switch" size="sm" aria-label="APRS view switch">
<Button <NavLink
as={NavLink}
to="/aprs/packets" to="/aprs/packets"
variant={location.pathname.startsWith('/aprs/packets') ? 'primary' : 'outline-light'} className={`btn d-none d-lg-inline-block ${location.pathname.startsWith('/aprs/packets') ? 'btn-primary' : 'btn-outline-light'}`}
> >
Packets Packets
</Button> </NavLink>
</ButtonGroup> </ButtonGroup>
); );

View File

@@ -31,17 +31,7 @@
} }
} }
.meshcore-btn-icon {
display: inline-flex;
align-items: center;
gap: 0.25rem;
.meshcore-icon {
font-size: 1rem;
width: 1rem;
height: 1rem;
}
}
.meshcore-node-type-cell { .meshcore-node-type-cell {
display: flex; display: flex;
@@ -49,8 +39,9 @@
justify-content: center; justify-content: center;
padding: 0.25rem !important; padding: 0.25rem !important;
border: none !important; border: none !important;
}
.meshcore-node-type-icon { .meshcore-node-type-icon {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -59,32 +50,12 @@
font-size: 1.125rem; font-size: 1.125rem;
width: 1.125rem; width: 1.125rem;
height: 1.125rem; height: 1.125rem;
color: black; background-color: transparent;
}
} }
} }
.meshcore-table-card,
.meshcore-detail-card {
background: rgba(8, 24, 56, 0.5);
border: 1px solid rgba(173, 205, 255, 0.2);
color: var(--app-text);
}
.meshcore-table-header {
background: rgba(27, 56, 108, 0.45);
border-bottom: 1px solid rgba(173, 205, 255, 0.2);
color: var(--app-text);
font-weight: 600;
}
.meshcore-table-body {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
}
.meshcore-filters { .meshcore-filters {
background: rgba(13, 36, 82, 0.6); background: rgba(13, 36, 82, 0.6);
@@ -219,71 +190,51 @@
} }
} }
.meshcore-table-scroll {
height: 100%;
max-height: 100%;
overflow-y: auto;
}
.meshcore-table {
color: var(--app-text);
thead th {
position: sticky;
top: 0; .data-table {
z-index: 2; &.table-hover > tbody > tr:hover .meshcore-hash-button,
background: rgba(13, 36, 82, 0.95); &.table-hover > tbody > tr:hover .meshcore-expand-button {
border-color: rgba(173, 205, 255, 0.18); color: #f2f7ff;
color: var(--app-text);
} }
td { tr.is-selected .meshcore-hash-button,
border-color: rgba(173, 205, 255, 0.12); tr.is-selected .meshcore-hash-button:hover {
vertical-align: middle; color: var(--app-accent-yellow);
} }
tr.is-selected td { td.meshcore-node-type-cell {
background: rgba(102, 157, 255, 0.16); border-bottom: transparent !important;
} }
tr.meshcore-packet-green td { tr.meshcore-packet-green td:nth-child(4) svg {
border-left: 3px solid #22c55e;
&:nth-child(4) svg {
color: #22c55e !important; color: #22c55e !important;
} }
&:nth-child(5) { tr.meshcore-packet-green td:nth-child(5) {
color: #22c55e; color: #22c55e;
font-weight: 500; font-weight: 500;
} }
}
tr.meshcore-packet-purple td { tr.meshcore-packet-purple td:nth-child(4) svg {
border-left: 3px solid #a855f7;
&:nth-child(4) svg {
color: #a855f7 !important; color: #a855f7 !important;
} }
&:nth-child(5) { tr.meshcore-packet-purple td:nth-child(5) {
color: #a855f7; color: #a855f7;
font-weight: 500; font-weight: 500;
} }
}
tr.meshcore-packet-amber td { tr.meshcore-packet-amber td:nth-child(4) svg {
border-left: 3px solid #f59e0b;
&:nth-child(4) svg {
color: #f59e0b !important; color: #f59e0b !important;
} }
&:nth-child(5) { tr.meshcore-packet-amber td:nth-child(5) {
color: #f59e0b; color: #f59e0b;
font-weight: 500; font-weight: 500;
} }
}
} }
.meshcore-hash-button { .meshcore-hash-button {
@@ -348,6 +299,172 @@
padding-right: 0.25rem; padding-right: 0.25rem;
} }
.meshcore-wire-subtitle {
color: var(--app-text-muted);
font-size: 0.85rem;
}
.meshcore-ws-layout {
display: grid;
grid-template-columns: 1fr;
gap: 0.75rem;
}
.meshcore-ws-panel {
border: 1px solid rgba(173, 205, 255, 0.25);
background: rgba(8, 24, 56, 0.45);
border-radius: 0.375rem;
overflow: hidden;
}
.meshcore-ws-panel-title {
font-family: monospace;
font-size: 0.8rem;
letter-spacing: 0.02em;
color: var(--app-text-muted);
background: rgba(13, 36, 82, 0.45);
border-bottom: 1px solid rgba(173, 205, 255, 0.2);
padding: 0.35rem 0.55rem;
}
.meshcore-ws-tree {
margin: 0;
padding: 0.55rem 0.7rem 0.7rem 1.15rem;
font-family: monospace;
font-size: 0.8rem;
color: var(--app-text);
ul {
margin: 0.2rem 0 0.2rem 0.95rem;
padding-left: 0.75rem;
}
li {
margin: 0.15rem 0;
}
code {
color: #b8d1ff;
}
}
.meshcore-ws-node {
color: #d7e7ff;
}
.meshcore-bit-art {
margin: 0.25rem 0 0;
padding: 0.35rem 0.5rem;
background: rgba(13, 36, 82, 0.35);
border: 1px solid rgba(173, 205, 255, 0.2);
border-radius: 0.25rem;
color: #b8d1ff;
font-family: monospace;
font-size: 0.76rem;
line-height: 1.35;
white-space: pre;
overflow-x: auto;
}
.meshcore-bitart-toggle {
appearance: none;
border: 1px solid rgba(173, 205, 255, 0.35);
background: rgba(13, 36, 82, 0.3);
color: var(--app-text);
border-radius: 0.25rem;
padding: 0.15rem 0.45rem;
font-size: 0.75rem;
line-height: 1.2;
&:hover,
&:focus {
border-color: rgba(173, 205, 255, 0.65);
background: rgba(90, 146, 255, 0.2);
color: #ffffff;
}
}
.meshcore-ws-legend {
display: flex;
gap: 0.8rem;
align-items: center;
padding: 0.5rem 0.6rem 0;
font-size: 0.75rem;
color: var(--app-text-muted);
span {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
}
.meshcore-ws-chip {
display: inline-block;
width: 0.65rem;
height: 0.65rem;
border-radius: 2px;
border: 1px solid rgba(173, 205, 255, 0.35);
}
.meshcore-ws-hexdump {
padding: 0.45rem 0.55rem 0.65rem;
font-family: monospace;
font-size: 0.78rem;
color: var(--app-text);
overflow-x: auto;
}
.meshcore-ws-hexdump-row {
display: grid;
grid-template-columns: 44px minmax(420px, 1fr) 150px;
gap: 0.5rem;
align-items: center;
line-height: 1.35;
white-space: nowrap;
}
.meshcore-ws-offset {
color: #82aeff;
}
.meshcore-ws-bytes,
.meshcore-ws-ascii {
display: inline-flex;
gap: 0.15rem;
}
.meshcore-ws-byte,
.meshcore-ws-char {
display: inline-block;
text-align: center;
border-radius: 2px;
}
.meshcore-ws-byte {
min-width: 1.35rem;
}
.meshcore-ws-char {
min-width: 0.65rem;
}
.meshcore-ws-byte-empty {
opacity: 0.35;
}
.meshcore-ws-zone-header {
background: rgba(90, 146, 255, 0.2);
}
.meshcore-ws-zone-path {
background: rgba(168, 85, 247, 0.2);
}
.meshcore-ws-zone-payload {
background: rgba(34, 197, 94, 0.2);
}
.meshcore-fact-row { .meshcore-fact-row {
display: grid; display: grid;
grid-template-columns: 120px 1fr; grid-template-columns: 120px 1fr;

View File

@@ -20,27 +20,27 @@ const MeshCore: React.FC<Props> = ({ navLinks = [] }) => {
<ButtonGroup className="meshcore-view-switch" size="sm" aria-label="MeshCore view switch"> <ButtonGroup className="meshcore-view-switch" size="sm" aria-label="MeshCore view switch">
<NavLink <NavLink
to="/meshcore/packets" to="/meshcore/packets"
className={`btn btn-sm meshcore-btn-icon ${location.pathname.startsWith('/meshcore/packets') ? 'btn-primary' : 'btn-outline-light'}`} className={`btn btn-sm btn-icon ${location.pathname.startsWith('/meshcore/packets') ? 'btn-primary' : 'btn-outline-light'}`}
title="Packets" title="Packets"
> >
<StorageIcon className="meshcore-icon" /> <StorageIcon className="icon" />
<span className="ms-1">Packets</span> <span className="ms-1 d-none d-lg-inline">Packets</span>
</NavLink> </NavLink>
<NavLink <NavLink
to="/meshcore/groupchat" to="/meshcore/groupchat"
className={`btn btn-sm meshcore-btn-icon ${location.pathname.startsWith('/meshcore/groupchat') ? 'btn-primary' : 'btn-outline-light'}`} className={`btn btn-sm btn-icon ${location.pathname.startsWith('/meshcore/groupchat') ? 'btn-primary' : 'btn-outline-light'}`}
title="Group Chat" title="Group Chat"
> >
<ChatIcon className="meshcore-icon" /> <ChatIcon className="icon" />
<span className="ms-1">Chat</span> <span className="ms-1 d-none d-lg-inline">Chat</span>
</NavLink> </NavLink>
<NavLink <NavLink
to="/meshcore/map" to="/meshcore/map"
className={`btn btn-sm meshcore-btn-icon ${location.pathname.startsWith('/meshcore/map') ? 'btn-primary' : 'btn-outline-light'}`} className={`btn btn-sm btn-icon ${location.pathname.startsWith('/meshcore/map') ? 'btn-primary' : 'btn-outline-light'}`}
title="Map" title="Map"
> >
<MapIcon className="meshcore-icon" /> <MapIcon className="icon" />
<span className="ms-1">Map</span> <span className="ms-1 d-none d-lg-inline">Map</span>
</NavLink> </NavLink>
</ButtonGroup> </ButtonGroup>
); );

View File

@@ -15,11 +15,14 @@ export interface APRSPacketRecord {
speed?: number; speed?: number;
course?: number; course?: number;
comment?: string; comment?: string;
symbol?: string;
radioName?: string; radioName?: string;
hasAPILocation?: boolean;
} }
interface APRSDataContextValue { interface APRSDataContextValue {
packets: APRSPacketRecord[]; packets: APRSPacketRecord[];
streamReady: boolean;
} }
const APRSDataContext = createContext<APRSDataContextValue | null>(null); const APRSDataContext = createContext<APRSDataContextValue | null>(null);
@@ -59,6 +62,7 @@ const extractAPRSDetails = (frame: Frame) => {
let speed: number | undefined; let speed: number | undefined;
let course: number | undefined; let course: number | undefined;
let comment: string | undefined; let comment: string | undefined;
let symbol: string | undefined;
if (decoded && typeof decoded === 'object') { if (decoded && typeof decoded === 'object') {
if ('position' in decoded && decoded.position) { if ('position' in decoded && decoded.position) {
@@ -66,6 +70,9 @@ const extractAPRSDetails = (frame: Frame) => {
longitude = decoded.position.longitude; longitude = decoded.position.longitude;
altitude = decoded.position.altitude; altitude = decoded.position.altitude;
comment = decoded.position.comment; comment = decoded.position.comment;
if (decoded.position.symbol) {
symbol = `${decoded.position.symbol.table}${decoded.position.symbol.code}`;
}
} }
if ('altitude' in decoded && typeof decoded.altitude === 'number') { if ('altitude' in decoded && typeof decoded.altitude === 'number') {
@@ -92,6 +99,7 @@ const extractAPRSDetails = (frame: Frame) => {
speed, speed,
course, course,
comment, comment,
symbol,
}; };
}; };
@@ -106,13 +114,47 @@ const parseTimestamp = (input: unknown): Date => {
return new Date(); return new Date();
}; };
const normalizeCoordinates = (latitude: number | undefined, longitude: number | undefined): { latitude?: number; longitude?: number } => {
// Validate and fix potentially swapped coordinates
if (latitude === undefined || longitude === undefined) {
return { latitude, longitude };
}
// Valid ranges: latitude [-90, 90], longitude [-180, 180]
const isValidLat = latitude >= -90 && latitude <= 90;
const isValidLng = longitude >= -180 && longitude <= 180;
// Both valid, no need to fix
if (isValidLat && isValidLng) {
return { latitude, longitude };
}
// Check if they're swapped
const isSwappedValid = (longitude >= -90 && longitude <= 90) && (latitude >= -180 && latitude <= 180);
if (isSwappedValid && (!isValidLat || !isValidLng)) {
console.warn(`Coordinates appear to be swapped: [${latitude}, ${longitude}], fixing to [${longitude}, ${latitude}]`);
return { latitude: longitude, longitude: latitude };
}
// One or both are invalid, return as-is (will be filtered out later)
return { latitude: isValidLat ? latitude : undefined, longitude: isValidLng ? longitude : undefined };
};
const mergePackets = (incoming: APRSPacketRecord[], current: APRSPacketRecord[]): APRSPacketRecord[] => { const mergePackets = (incoming: APRSPacketRecord[], current: APRSPacketRecord[]): APRSPacketRecord[] => {
const merged = [...incoming, ...current]; const merged = [...incoming, ...current];
const byKey = new Map<string, APRSPacketRecord>(); const byKey = new Map<string, APRSPacketRecord>();
merged.forEach((packet) => { merged.forEach((packet) => {
const key = `${packet.timestamp.toISOString()}|${packet.raw}|${packet.radioName ?? ''}`; const sourceSsid = packet.frame.source.ssid ?? 0;
if (!byKey.has(key)) { const key = `${packet.timestamp.toISOString()}|${packet.frame.source.call}|${sourceSsid}|${packet.raw}`;
const existing = byKey.get(key);
if (!existing) {
byKey.set(key, packet);
return;
}
if (!existing.hasAPILocation && packet.hasAPILocation) {
byKey.set(key, packet); byKey.set(key, packet);
} }
}); });
@@ -129,6 +171,8 @@ const buildRecord = ({
comment, comment,
latitude, latitude,
longitude, longitude,
symbol,
preferProvidedLocation,
}: { }: {
raw: string; raw: string;
timestamp: Date; timestamp: Date;
@@ -136,22 +180,33 @@ const buildRecord = ({
comment?: string; comment?: string;
latitude?: number; latitude?: number;
longitude?: number; longitude?: number;
symbol?: string;
preferProvidedLocation?: boolean;
}): APRSPacketRecord | null => { }): APRSPacketRecord | null => {
try { try {
const frame = parseFrame(raw); const frame = parseFrame(raw);
const details = extractAPRSDetails(frame); const details = extractAPRSDetails(frame);
const hasProvidedLocation = latitude !== undefined && longitude !== undefined;
const finalLat = hasProvidedLocation ? latitude : details.latitude;
const finalLng = hasProvidedLocation ? longitude : details.longitude;
const normalized = (preferProvidedLocation && hasProvidedLocation)
? { latitude: finalLat, longitude: finalLng }
: normalizeCoordinates(finalLat, finalLng);
return { return {
timestamp, timestamp,
raw, raw,
frame, frame,
latitude: latitude ?? details.latitude, latitude: normalized.latitude,
longitude: longitude ?? details.longitude, longitude: normalized.longitude,
altitude: details.altitude, altitude: details.altitude,
speed: details.speed, speed: details.speed,
course: details.course, course: details.course,
comment: comment ?? details.comment, comment: comment ?? details.comment,
symbol: symbol ?? details.symbol,
radioName, radioName,
hasAPILocation: preferProvidedLocation && hasProvidedLocation,
}; };
} catch { } catch {
return null; return null;
@@ -171,6 +226,8 @@ const toRecordFromAPI = (packet: FetchedAPRSPacket): APRSPacketRecord | null =>
comment: packet.comment, comment: packet.comment,
latitude: fromNullableNumber(packet.latitude), latitude: fromNullableNumber(packet.latitude),
longitude: fromNullableNumber(packet.longitude), longitude: fromNullableNumber(packet.longitude),
symbol: packet.symbol,
preferProvidedLocation: true,
}); });
}; };
@@ -188,6 +245,7 @@ interface APRSDataProviderProps {
export const APRSDataProvider: React.FC<APRSDataProviderProps> = ({ children }) => { export const APRSDataProvider: React.FC<APRSDataProviderProps> = ({ children }) => {
const [packets, setPackets] = useState<APRSPacketRecord[]>([]); const [packets, setPackets] = useState<APRSPacketRecord[]>([]);
const [streamReady, setStreamReady] = useState(false);
const stream = useMemo(() => new APRSStream(false), []); const stream = useMemo(() => new APRSStream(false), []);
useEffect(() => { useEffect(() => {
@@ -213,6 +271,10 @@ export const APRSDataProvider: React.FC<APRSDataProviderProps> = ({ children })
fetchPackets(); fetchPackets();
stream.connect(); stream.connect();
const unsubscribeState = stream.subscribeToState((state) => {
setStreamReady(state.isConnected);
});
const unsubscribePackets = stream.subscribe<APRSMessage>('aprs/packet/#', (message) => { const unsubscribePackets = stream.subscribe<APRSMessage>('aprs/packet/#', (message) => {
const record = toRecordFromStream(message); const record = toRecordFromStream(message);
if (!record) { if (!record) {
@@ -224,6 +286,7 @@ export const APRSDataProvider: React.FC<APRSDataProviderProps> = ({ children })
return () => { return () => {
isMounted = false; isMounted = false;
unsubscribeState();
unsubscribePackets(); unsubscribePackets();
stream.disconnect(); stream.disconnect();
}; };
@@ -232,8 +295,9 @@ export const APRSDataProvider: React.FC<APRSDataProviderProps> = ({ children })
const value = useMemo( const value = useMemo(
() => ({ () => ({
packets, packets,
streamReady,
}), }),
[packets] [packets, streamReady]
); );
return <APRSDataContext.Provider value={value}>{children}</APRSDataContext.Provider>; return <APRSDataContext.Provider value={value}>{children}</APRSDataContext.Provider>;

View File

@@ -1,9 +1,18 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { Badge, Card, Stack, Table, Alert } from 'react-bootstrap'; import { Badge, Card, Stack, Table } from 'react-bootstrap';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet'; import { MapContainer, TileLayer, Popup, useMap, CircleMarker, Marker } from 'react-leaflet';
import { divIcon, type DivIcon } from 'leaflet';
import { renderToStaticMarkup } from 'react-dom/server';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import VerticalSplit from '../../components/VerticalSplit'; import VerticalSplit from '../../components/VerticalSplit';
import HorizontalSplit from '../../components/HorizontalSplit'; import HorizontalSplit from '../../components/HorizontalSplit';
import CountryFlag from '../../components/CountryFlag';
import KeyboardShortcutsModal from '../../components/KeyboardShortcutsModal';
import StreamStatus from '../../components/StreamStatus';
import { APRSSymbol } from '../../components/aprs';
import { ClusteredMarkers } from '../../components/map';
import type { ClusterableItem, Cluster } from '../../components/map';
import { useKeyboardListNavigation } from '../../hooks/useKeyboardListNavigation';
import { useAPRSData } from './APRSData'; import { useAPRSData } from './APRSData';
import type { APRSPacketRecord } from './APRSData'; import type { APRSPacketRecord } from './APRSData';
@@ -14,7 +23,229 @@ const HeaderFact: React.FC<{ label: string; value: React.ReactNode }> = ({ label
</div> </div>
); );
const APRSMapPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ packet }) => { const Callsign: React.FC<{ call: string; ssid?: string | number; plain?: boolean }> = ({ call, ssid, plain = false }) => (
<span className={plain ? 'callsign callsign--plain' : 'callsign'}>
{call}
{(ssid !== undefined && ssid !== '') ? <span>-{ssid}</span> : null}
</span>
);
const getPacketKey = (packet: APRSPacketRecord): string => {
const ssid = packet.frame.source.ssid ?? 0;
return `${packet.frame.source.call}-${ssid}-${packet.timestamp.toISOString()}-${packet.raw}`;
};
const calculateBounds = (packets: APRSPacketRecord[]): [[number, number], [number, number]] | null => {
const validPackets = packets.filter(p => p.latitude !== undefined && p.longitude !== undefined);
if (validPackets.length === 0) {
return null;
}
if (validPackets.length === 1) {
const lat = validPackets[0].latitude!;
const lng = validPackets[0].longitude!;
const offset = 2;
return [[lat - offset, lng - offset], [lat + offset, lng + offset]];
}
let minLat = validPackets[0].latitude!;
let maxLat = validPackets[0].latitude!;
let minLng = validPackets[0].longitude!;
let maxLng = validPackets[0].longitude!;
validPackets.forEach(packet => {
const lat = packet.latitude!;
const lng = packet.longitude!;
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
if (lng < minLng) minLng = lng;
if (lng > maxLng) maxLng = lng;
});
// Add 10% padding to the bounds
const latPadding = (maxLat - minLat) * 0.1 || 0.5;
const lngPadding = (maxLng - minLng) * 0.1 || 0.5;
return [
[minLat - latPadding, minLng - lngPadding],
[maxLat + latPadding, maxLng + lngPadding]
];
};
const toRadians = (degrees: number): number => (degrees * Math.PI) / 180;
const distanceKm = (lat1: number, lng1: number, lat2: number, lng2: number): number => {
const earthRadiusKm = 6371;
const dLat = toRadians(lat2 - lat1);
const dLng = toRadians(lng2 - lng1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) *
Math.sin(dLng / 2) * Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return earthRadiusKm * c;
};
const median = (values: number[]): number | null => {
if (values.length === 0) {
return null;
}
const sorted = [...values].sort((a, b) => a - b);
const middle = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 0) {
return (sorted[middle - 1] + sorted[middle]) / 2;
}
return sorted[middle];
};
const filterByClusterRadius = (packets: APRSPacketRecord[], radiusKm: number): APRSPacketRecord[] => {
const validPackets = packets.filter(p => p.latitude !== undefined && p.longitude !== undefined);
if (validPackets.length <= 1) {
return validPackets;
}
const latCenter = median(validPackets.map((p) => p.latitude!));
const lngCenter = median(validPackets.map((p) => p.longitude!));
if (latCenter === null || lngCenter === null) {
return validPackets;
}
const filtered = validPackets.filter((p) => distanceKm(latCenter, lngCenter, p.latitude!, p.longitude!) <= radiusKm);
return filtered.length > 0 ? filtered : validPackets;
};
/**
* Validate if an APRS symbol string is valid
*/
const isValidSymbol = (symbol: string | undefined): boolean => {
if (!symbol || symbol.length < 2) return false;
const table = symbol.charAt(0);
const code = symbol.charAt(1);
const charCode = code.charCodeAt(0);
// Primary and secondary tables
if (table === '/' || table === '\\') {
return charCode >= 33 && charCode <= 125;
}
// Overlay characters: 0-9, A-Z
if ((charCode >= 48 && charCode <= 57) || (charCode >= 65 && charCode <= 90)) {
return true;
}
return false;
};
/**
* Create a Leaflet DivIcon for an APRS symbol
* Uses 32px source image rendered at 16px for crisp display
*/
const createAPRSIcon = (symbol: string) => {
const iconHtml = renderToStaticMarkup(
<APRSSymbol symbol={symbol} size={32} className="aprs-map-marker" />
);
return divIcon({
html: iconHtml,
className: 'aprs-marker-icon',
iconSize: [16, 16],
iconAnchor: [8, 8],
popupAnchor: [0, -8],
});
};
const MapPanHandler: React.FC<{
packet: APRSPacketRecord | null;
defaultBounds: [[number, number], [number, number]] | null;
}> = ({ packet, defaultBounds }) => {
const map = useMap();
const prevPacketRef = React.useRef<APRSPacketRecord | null>(packet);
const prevBoundsRef = React.useRef<[[number, number], [number, number]] | null>(defaultBounds);
React.useEffect(() => {
if (packet && packet.latitude && packet.longitude) {
const currentZoom = map.getZoom();
// Don't zoom out if we're already zoomed in further than default
const targetZoom = Math.max(currentZoom, 12);
map.flyTo([packet.latitude as number, packet.longitude as number], targetZoom, {
duration: 1.5,
easeLinearity: 0.25,
});
prevPacketRef.current = packet;
} else if (!packet) {
// No packet selected - show all markers
if (defaultBounds && (!prevPacketRef.current || JSON.stringify(prevBoundsRef.current) !== JSON.stringify(defaultBounds))) {
map.fitBounds(defaultBounds, {
padding: [50, 50],
maxZoom: 15,
});
prevBoundsRef.current = defaultBounds;
}
prevPacketRef.current = null;
}
}, [packet, defaultBounds, map]);
return null;
};
/**
* Render popup content for an APRS packet
*/
const renderPacketPopup = (p: APRSPacketRecord) => (
<div>
<Callsign call={p.frame.source.call} ssid={p.frame.source.ssid} />
<br />
Lat: {p.latitude?.toFixed(4)}, Lon: {p.longitude?.toFixed(4)}
{p.altitude && <div>Alt: {p.altitude.toFixed(0)}m</div>}
{p.speed && <div>Speed: {p.speed}kt</div>}
{p.comment && <div style={{ marginTop: '0.25rem', fontSize: '0.875rem' }}>{p.comment}</div>}
</div>
);
/**
* Render popup content for a cluster
*/
const renderClusterPopup = (cluster: Cluster<ClusterableItem>) => {
const packets = cluster.items as APRSPacketRecord[];
return (
<div>
<strong>{cluster.count} stations</strong>
<div style={{ marginTop: '0.5rem', maxHeight: '200px', overflowY: 'auto' }}>
{packets.map((p, i) => (
<div key={i} style={{ marginBottom: '0.25rem' }}>
<Callsign call={p.frame.source.call} ssid={p.frame.source.ssid} />
</div>
))}
</div>
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#666' }}>
Click to zoom in
</div>
</div>
);
};
/**
* Get icon for an APRS packet marker
*/
const getPacketIcon = (item: ClusterableItem): DivIcon | null => {
const packet = item as APRSPacketRecord;
if (isValidSymbol(packet.symbol)) {
return createAPRSIcon(packet.symbol!);
}
return null; // Will use default blue circle
};
const APRSMapPane: React.FC<{
packet: APRSPacketRecord | null;
packets: APRSPacketRecord[];
onSelectPacket: (packet: APRSPacketRecord) => void;
}> = ({ packet, packets, onSelectPacket }) => {
const hasPosition = packet && packet.latitude && packet.longitude; const hasPosition = packet && packet.latitude && packet.longitude;
// Create bounds from center point (offset by ~2 degrees) // Create bounds from center point (offset by ~2 degrees)
@@ -23,41 +254,101 @@ const APRSMapPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ packet })
return [[lat - offset, lng - offset], [lat + offset, lng + offset]]; return [[lat - offset, lng - offset], [lat + offset, lng + offset]];
}; };
// Get latest location for each source
const latestBySource = useMemo(() => {
const sourceMap = new Map<string, APRSPacketRecord>();
packets.forEach(p => {
if (p.latitude !== undefined && p.longitude !== undefined) {
const sourceSsid = p.frame.source.ssid ?? '';
const key = `${p.frame.source.call}-${sourceSsid}`;
// Keep the most recent packet for each source
if (!sourceMap.has(key) || p.timestamp > sourceMap.get(key)!.timestamp) {
sourceMap.set(key, p);
}
}
});
return Array.from(sourceMap.values());
}, [packets]);
const overviewLocations = useMemo(() => filterByClusterRadius(latestBySource, 250), [latestBySource]);
const defaultBounds = useMemo(() => calculateBounds(overviewLocations), [overviewLocations]);
const bounds = hasPosition const bounds = hasPosition
? createBoundsFromCenter(packet.latitude as number, packet.longitude as number) ? createBoundsFromCenter(packet.latitude as number, packet.longitude as number)
: createBoundsFromCenter(50.0, 5.0); : (defaultBounds ?? [[48.0, 3.0], [52.0, 7.0]]);
return ( return (
<Card className="data-table-card h-100" style={{ display: 'flex', flexDirection: 'column', padding: 0 }}>
<MapContainer <MapContainer
bounds={bounds} bounds={bounds}
style={{ height: '100%', width: '100%' }} style={{ flex: 1, width: '100%' }}
className="aprs-map" className="aprs-map"
> >
<MapPanHandler packet={packet} defaultBounds={defaultBounds} />
<TileLayer <TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/> />
{/* Clustered markers for latest location per source */}
<ClusteredMarkers<ClusterableItem>
items={overviewLocations}
getItemKey={(item) => getPacketKey(item as APRSPacketRecord)}
onItemClick={(item) => onSelectPacket(item as APRSPacketRecord)}
getIcon={getPacketIcon}
renderPopupContent={(item) => renderPacketPopup(item as APRSPacketRecord)}
renderClusterPopupContent={renderClusterPopup}
/>
{/* Highlight selected packet - use APRS symbol or red circle */}
{hasPosition && ( {hasPosition && (
<Marker position={[packet.latitude as number, packet.longitude as number]}> isValidSymbol(packet.symbol) ? (
<Marker
position={[packet.latitude as number, packet.longitude as number]}
icon={createAPRSIcon(packet.symbol!)}
>
<Popup> <Popup>
<div> <div>
<strong>{packet.frame.source.call}</strong> <Callsign call={packet.frame.source.call} ssid={packet.frame.source.ssid} />
{packet.frame.source.ssid && <span>-{packet.frame.source.ssid}</span>}
<br /> <br />
Lat: {packet.latitude?.toFixed(4)}, Lon: {packet.longitude?.toFixed(4)} Lat: {packet.latitude?.toFixed(4)}, Lon: {packet.longitude?.toFixed(4)}
{packet.altitude && <div>Alt: {packet.altitude.toFixed(0)}m</div>} {packet.altitude && <div>Alt: {packet.altitude.toFixed(0)}m</div>}
{packet.speed && <div>Speed: {packet.speed}kt</div>} {packet.speed && <div>Speed: {packet.speed}kt</div>}
{packet.comment && <div style={{ marginTop: '0.25rem', fontSize: '0.875rem' }}>{packet.comment}</div>}
</div> </div>
</Popup> </Popup>
</Marker> </Marker>
) : (
<CircleMarker
center={[packet.latitude as number, packet.longitude as number]}
radius={8}
pathOptions={{
color: '#dc3545',
fillColor: '#dc3545',
fillOpacity: 0.8,
weight: 3,
}}
>
<Popup>
<div>
<Callsign call={packet.frame.source.call} ssid={packet.frame.source.ssid} />
<br />
Lat: {packet.latitude?.toFixed(4)}, Lon: {packet.longitude?.toFixed(4)}
{packet.altitude && <div>Alt: {packet.altitude.toFixed(0)}m</div>}
{packet.speed && <div>Speed: {packet.speed}kt</div>}
{packet.comment && <div style={{ marginTop: '0.25rem', fontSize: '0.875rem' }}>{packet.comment}</div>}
</div>
</Popup>
</CircleMarker>
)
)} )}
</MapContainer> </MapContainer>
</Card>
); );
}; };
const PacketDetailsPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ packet }) => { const PacketDetailsPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ packet }) => {
if (!packet) { if (!packet) {
return ( return (
<Card body className="aprs-detail-card h-100"> <Card body className="data-table-card h-100">
<h6>Select a packet</h6> <h6>Select a packet</h6>
<div>Click any packet in the list to view details and map.</div> <div>Click any packet in the list to view details and map.</div>
</Card> </Card>
@@ -66,36 +357,28 @@ const PacketDetailsPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ pack
return ( return (
<Stack gap={2} className="h-100 aprs-detail-stack"> <Stack gap={2} className="h-100 aprs-detail-stack">
<Card body className="aprs-detail-card"> <Card body className="data-table-card">
<Stack direction="horizontal" gap={2} className="mb-2"> <Stack direction="horizontal" gap={2} className="mb-2">
<h6 className="mb-0">Packet Details</h6> <h6 className="mb-0">Packet Details</h6>
<Badge bg="primary">{packet.frame.source.call}</Badge> <Badge bg="primary">
<Callsign call={packet.frame.source.call} ssid={packet.frame.source.ssid} plain />
</Badge>
</Stack> </Stack>
<HeaderFact label="Timestamp" value={packet.timestamp.toLocaleTimeString()} /> <HeaderFact label="Timestamp" value={packet.timestamp.toLocaleTimeString()} />
<HeaderFact <HeaderFact
label="Source" label="Source"
value={ value={<Callsign call={packet.frame.source.call} ssid={packet.frame.source.ssid} />}
<>
{packet.frame.source.call}
{packet.frame.source.ssid && <span>-{packet.frame.source.ssid}</span>}
</>
}
/> />
{packet.radioName && <HeaderFact label="Radio" value={packet.radioName} />} {packet.radioName && <HeaderFact label="Radio" value={packet.radioName} />}
<HeaderFact <HeaderFact
label="Destination" label="Destination"
value={ value={<Callsign call={packet.frame.destination.call} ssid={packet.frame.destination.ssid} />}
<>
{packet.frame.destination.call}
{packet.frame.destination.ssid && <span>-{packet.frame.destination.ssid}</span>}
</>
}
/> />
<HeaderFact label="Path" value={packet.frame.path.map((addr) => `${addr.call}${addr.ssid ? `-${addr.ssid}` : ''}${addr.isRepeated ? '*' : ''}`).join(', ') || 'None'} /> <HeaderFact label="Path" value={packet.frame.path.map((addr) => `${addr.call}${addr.ssid ? `-${addr.ssid}` : ''}${addr.isRepeated ? '*' : ''}`).join(', ') || 'None'} />
</Card> </Card>
{(packet.latitude || packet.longitude || packet.altitude || packet.speed || packet.course) && ( {(packet.latitude || packet.longitude || packet.altitude || packet.speed || packet.course) && (
<Card body className="aprs-detail-card"> <Card body className="data-table-card">
<h6 className="mb-2">Position Data</h6> <h6 className="mb-2">Position Data</h6>
{packet.latitude && <HeaderFact label="Latitude" value={packet.latitude.toFixed(6)} />} {packet.latitude && <HeaderFact label="Latitude" value={packet.latitude.toFixed(6)} />}
{packet.longitude && <HeaderFact label="Longitude" value={packet.longitude.toFixed(6)} />} {packet.longitude && <HeaderFact label="Longitude" value={packet.longitude.toFixed(6)} />}
@@ -106,13 +389,13 @@ const PacketDetailsPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ pack
)} )}
{packet.comment && ( {packet.comment && (
<Card body className="aprs-detail-card"> <Card body className="data-table-card">
<h6 className="mb-2">Comment</h6> <h6 className="mb-2">Comment</h6>
<div>{packet.comment}</div> <div>{packet.comment}</div>
</Card> </Card>
)} )}
<Card body className="aprs-detail-card"> <Card body className="data-table-card">
<h6 className="mb-2">Raw Data</h6> <h6 className="mb-2">Raw Data</h6>
<code className="aprs-raw-code">{packet.raw}</code> <code className="aprs-raw-code">{packet.raw}</code>
</Card> </Card>
@@ -122,11 +405,102 @@ const PacketDetailsPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ pack
const PacketTable: React.FC<{ const PacketTable: React.FC<{
packets: APRSPacketRecord[]; packets: APRSPacketRecord[];
selectedIndex: number | null; radioFilter?: string;
onSelect: (index: number) => void; selectedPacketKey: string | null;
}> = ({ packets, selectedIndex, onSelect }) => { onSelectPacket: (packet: APRSPacketRecord) => void;
const [searchParams, setSearchParams] = useSearchParams(); onClearSelection: () => void;
streamReady: boolean;
}> = ({ packets, radioFilter, selectedPacketKey, onSelectPacket, onClearSelection, streamReady }) => {
const scrollRef = React.useRef<HTMLDivElement>(null);
const selectedIndex = useMemo(() => {
if (!selectedPacketKey) {
return null;
}
const index = packets.findIndex((packet) => getPacketKey(packet) === selectedPacketKey);
return index >= 0 ? index : null;
}, [packets, selectedPacketKey]);
const { showShortcuts, setShowShortcuts, shortcuts } = useKeyboardListNavigation({
itemCount: packets.length,
selectedIndex,
onSelectIndex: (index) => {
if (index === null) {
onClearSelection();
return;
}
const packet = packets[index];
if (packet) {
onSelectPacket(packet);
}
},
scrollContainerRef: scrollRef,
});
return (
<Card className="data-table-card h-100 d-flex flex-column">
<Card.Header className="data-table-header d-flex justify-content-between align-items-center">
<span>APRS Packets</span>
<div className="d-flex align-items-center gap-2">
<StreamStatus ready={streamReady} />
</div>
</Card.Header>
<Card.Body className="data-table-body p-0">
<div className="data-table-scroll" ref={scrollRef}>
<Table hover responsive className="data-table mb-0" size="sm">
<thead>
<tr>
<th>Time</th>
<th>&nbsp;</th>
<th>Source</th>
<th>Destination</th>
<th>&nbsp;</th>
<th>Comment</th>
</tr>
</thead>
<tbody>
{packets.map((packet, index) => (
<tr
key={getPacketKey(packet)}
data-nav-item="true"
className={selectedIndex === index ? 'is-selected' : ''}
onClick={() => onSelectPacket(packet)}
>
<td style={{ verticalAlign: 'top' }}>{packet.timestamp.toLocaleTimeString()}</td>
<td style={{ verticalAlign: 'top' }}>
<CountryFlag callsign={packet.frame.source.call} size={1.25} />
</td>
<td style={{ verticalAlign: 'top' }}>
<Callsign call={packet.frame.source.call} ssid={packet.frame.source.ssid} />
</td>
<td style={{ verticalAlign: 'top' }}>
<Callsign call={packet.frame.destination.call} ssid={packet.frame.destination.ssid} />
</td>
<td style={{ verticalAlign: 'top' }}>
{packet.symbol && <APRSSymbol symbol={packet.symbol} size={24} />}
</td>
<td style={{ verticalAlign: 'top' }}>{packet.comment || '-'}</td>
</tr>
))}
</tbody>
</Table>
</div>
</Card.Body>
{radioFilter && <Card.Footer className="text-muted" style={{ fontSize: '0.875rem' }}>Filtered by radio: {radioFilter}</Card.Footer>}
<KeyboardShortcutsModal
show={showShortcuts}
onHide={() => setShowShortcuts(false)}
shortcuts={shortcuts}
/>
</Card>
);
};
const APRSPacketsView: React.FC = () => {
const { packets, streamReady } = useAPRSData();
const [searchParams] = useSearchParams();
const radioFilter = searchParams.get('radio') || undefined; const radioFilter = searchParams.get('radio') || undefined;
const [selectedPacketKey, setSelectedPacketKey] = useState<string | null>(null);
// Filter packets by radio name if specified // Filter packets by radio name if specified
const filteredPackets = useMemo(() => { const filteredPackets = useMemo(() => {
@@ -134,83 +508,20 @@ const PacketTable: React.FC<{
return packets.filter(packet => packet.radioName === radioFilter); return packets.filter(packet => packet.radioName === radioFilter);
}, [packets, radioFilter]); }, [packets, radioFilter]);
return (
<Card className="aprs-table-card h-100 d-flex flex-column">
<Card.Header className="aprs-table-header">APRS Packets</Card.Header>
<Card.Body className="aprs-table-body p-0">
{radioFilter && (
<Alert variant="info" className="m-2 mb-0 d-flex align-items-center justify-content-between" style={{ fontSize: '0.875rem', padding: '0.5rem 0.75rem' }}>
<span>Filtering by radio: <strong>{radioFilter}</strong></span>
<button
type="button"
className="btn-close btn-close-white"
style={{ fontSize: '0.7rem' }}
onClick={() => {
const newParams = new URLSearchParams(searchParams);
newParams.delete('radio');
setSearchParams(newParams);
}}
aria-label="Clear radio filter"
/>
</Alert>
)}
<div className="aprs-table-scroll">
<Table hover responsive className="aprs-table mb-0" size="sm">
<thead>
<tr>
<th>Time</th>
<th>Source</th>
<th>Destination</th>
<th>Position</th>
<th>Comment</th>
</tr>
</thead>
<tbody>
{filteredPackets.map((packet, index) => (
<tr
key={`${packet.frame.source.call}-${packet.timestamp.toISOString()}`}
className={selectedIndex === index ? 'is-selected' : ''}
onClick={() => onSelect(index)}
>
<td>{packet.timestamp.toLocaleTimeString()}</td>
<td>
{packet.frame.source.call}
{packet.frame.source.ssid && <span>-{packet.frame.source.ssid}</span>}
</td>
<td>
{packet.frame.destination.call}
{packet.frame.destination.ssid && <span>-{packet.frame.destination.ssid}</span>}
</td>
<td>{packet.latitude && packet.longitude ? '✓' : '-'}</td>
<td>{packet.comment || '-'}</td>
</tr>
))}
</tbody>
</Table>
</div>
</Card.Body>
</Card>
);
};
const APRSPacketsView: React.FC = () => {
const { packets } = useAPRSData();
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const selectedPacket = useMemo(() => { const selectedPacket = useMemo(() => {
if (selectedIndex === null || selectedIndex < 0 || selectedIndex >= packets.length) { if (!selectedPacketKey) {
return null; return null;
} }
return packets[selectedIndex] ?? null; return filteredPackets.find((packet) => getPacketKey(packet) === selectedPacketKey) ?? null;
}, [packets, selectedIndex]); }, [filteredPackets, selectedPacketKey]);
return ( return (
<VerticalSplit <VerticalSplit
ratio="50/50" ratio="1:1"
left={<PacketTable packets={packets} selectedIndex={selectedIndex} onSelect={setSelectedIndex} />} left={<PacketTable packets={filteredPackets} radioFilter={radioFilter} selectedPacketKey={selectedPacketKey} onSelectPacket={(packet) => setSelectedPacketKey(getPacketKey(packet))} onClearSelection={() => setSelectedPacketKey(null)} streamReady={streamReady} />}
right={ right={
<HorizontalSplit <HorizontalSplit
top={<APRSMapPane packet={selectedPacket} />} top={<APRSMapPane packet={selectedPacket} packets={filteredPackets} onSelectPacket={(packet) => setSelectedPacketKey(getPacketKey(packet))} />}
bottom={<PacketDetailsPane packet={selectedPacket} />} bottom={<PacketDetailsPane packet={selectedPacket} />}
/> />
} }

View File

@@ -1,11 +1,24 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { bytesToHex } from '@noble/hashes/utils.js'; import { bytesToHex } from '@noble/hashes/utils.js';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import BrandingWatermarkIcon from '@mui/icons-material/BrandingWatermark';
import PersonIcon from '@mui/icons-material/Person';
import QuestionMarkIcon from '@mui/icons-material/QuestionMark';
import ReplyIcon from '@mui/icons-material/Reply';
import RouteIcon from '@mui/icons-material/Route';
import SensorsIcon from '@mui/icons-material/Sensors';
import SignalCellularAltIcon from '@mui/icons-material/SignalCellularAlt';
import StorageIcon from '@mui/icons-material/Storage';
import { Packet } from '../../protocols/meshcore'; import { Packet } from '../../protocols/meshcore';
import { NodeType, PayloadType, RouteType } from '../../protocols/meshcore.types'; import { NodeType, PayloadType, RouteType } from '../../protocols/meshcore.types';
import { MeshCoreStream } from '../../services/MeshCoreStream'; import { MeshCoreStream } from '../../services/MeshCoreStream';
import type { MeshCoreMessage } from '../../services/MeshCoreStream'; import type { MeshCoreMessage } from '../../services/MeshCoreStream';
import type { Payload } from '../../protocols/meshcore.types'; import type { Payload, AdvertPayload } from '../../protocols/meshcore.types';
import API from '../../services/API';
import MeshCoreServiceImpl from '../../services/MeshCoreService';
import { base64ToBytes } from '../../util';
import { import {
MeshCoreDataContext, MeshCoreDataContext,
@@ -40,6 +53,38 @@ export const payloadValueByName = Object.fromEntries(
Object.entries(PayloadType).map(([name, value]) => [name, value]) Object.entries(PayloadType).map(([name, value]) => [name, value])
) as Record<string, number>; ) as Record<string, number>;
export const PayloadTypeIcon: React.FC<{ payloadType: number }> = ({ payloadType }) => {
switch (payloadType) {
case PayloadType.REQUEST:
return <ArrowForwardIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
case PayloadType.RESPONSE:
return <ArrowBackIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
case PayloadType.TEXT:
return <PersonIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
case PayloadType.ACK:
return <ReplyIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
case PayloadType.ADVERT:
return <SignalCellularAltIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
case PayloadType.GROUP_TEXT:
return <PersonIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
case PayloadType.GROUP_DATA:
return <StorageIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
case PayloadType.ANON_REQ:
return <ArrowForwardIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
case PayloadType.PATH:
return <RouteIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
case PayloadType.TRACE:
return <RouteIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
case PayloadType.MULTIPART:
return <BrandingWatermarkIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
case PayloadType.CONTROL:
return <SensorsIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
case PayloadType.RAW_CUSTOM:
return <QuestionMarkIcon className="meshcore-payload-icon" titleAccess={payloadDisplayByValue[payloadType]} />;
default:
return <QuestionMarkIcon className="meshcore-payload-icon" titleAccess="Unknown" />;
}
};
export const nodeTypeValueByName = Object.fromEntries( export const nodeTypeValueByName = Object.fromEntries(
Object.entries(NodeType).map(([name, value]) => [name, value]) Object.entries(NodeType).map(([name, value]) => [name, value])
) as Record<string, number>; ) as Record<string, number>;
@@ -116,61 +161,50 @@ export const routeValueByUrl: Record<string, number> = Object.fromEntries(
}) })
) as Record<string, number>; ) as Record<string, number>;
export const asHex = (value: Uint8Array): string => bytesToHex(value); const DISCARD_DUPLICATE_PATH_PACKETS = true;
const payloadTypeList = [ const getPacketPathKey = (raw: Uint8Array): string => {
PayloadType.REQUEST, if (raw.length < 2) {
PayloadType.RESPONSE, return '';
PayloadType.TEXT,
PayloadType.ACK,
PayloadType.ADVERT,
PayloadType.GROUP_TEXT,
PayloadType.GROUP_DATA,
PayloadType.ANON_REQ,
PayloadType.PATH,
PayloadType.TRACE,
PayloadType.MULTIPART,
PayloadType.CONTROL,
PayloadType.RAW_CUSTOM,
] as const;
const nodeTypeList = [
NodeType.TYPE_CHAT_NODE,
NodeType.TYPE_REPEATER,
NodeType.TYPE_ROOM_SERVER,
NodeType.TYPE_SENSOR,
] as const;
const makePayloadBytes = (payloadType: number, seed: number): Uint8Array => {
switch (payloadType) {
case PayloadType.REQUEST:
return new Uint8Array([0x00, 0x12, 0x34, 0x56, 0x78, seed]);
case PayloadType.RESPONSE:
return new Uint8Array([0x01, 0x78, 0x56, 0x34, 0x12, seed]);
case PayloadType.TEXT:
return new Uint8Array([0xa1, 0xb2, 0x11, 0x22, 0x54, 0x58, 0x54, seed]);
case PayloadType.ACK:
return new Uint8Array([0x03, seed]);
case PayloadType.ADVERT:
return new Uint8Array([0x04, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, seed]);
case PayloadType.GROUP_TEXT:
return new Uint8Array([0xc4, 0x23, 0x99, 0x44, 0x55, 0x66, seed]);
case PayloadType.GROUP_DATA:
return new Uint8Array([0x34, 0x98, 0x76, 0x10, seed, 0xee]);
case PayloadType.ANON_REQ:
return new Uint8Array([0xfe, ...Array.from({ length: 32 }, (_, i) => (seed + i * 5) & 0xff), 0x55, 0xaa, 0x42, seed]);
case PayloadType.PATH:
return new Uint8Array([0x08, 0x11, 0x22, 0x33, 0x44, 0x55, seed]);
case PayloadType.TRACE:
return new Uint8Array([0x12, 0x31, 0x51, seed]);
case PayloadType.MULTIPART:
return new Uint8Array([0x01, seed, 0x02, 0x03, 0x04]);
case PayloadType.CONTROL:
return new Uint8Array([0x90, 0x01, 0x02, seed]);
case PayloadType.RAW_CUSTOM:
default:
return new Uint8Array([0xde, 0xad, 0xbe, 0xef, seed]);
} }
const pathField = raw[1];
const hashSize = (pathField >> 6) + 1;
const hashCount = pathField & 0x3f;
if (hashCount === 0 || hashSize === 4) {
return '';
}
const pathByteLength = hashCount * hashSize;
const availablePathBytes = Math.min(pathByteLength, Math.max(raw.length - 2, 0));
if (availablePathBytes <= 0) {
return '';
}
return bytesToHex(raw.slice(2, 2 + availablePathBytes));
};
const dedupeByHashAndPath = (packets: MeshCorePacketRecord[]): MeshCorePacketRecord[] => {
if (!DISCARD_DUPLICATE_PATH_PACKETS) {
return packets;
}
const sortedByReceiveTime = [...packets].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
const seen = new Set<string>();
const deduped: MeshCorePacketRecord[] = [];
sortedByReceiveTime.forEach((packet) => {
const signature = `${packet.hash}:${getPacketPathKey(packet.raw)}`;
if (seen.has(signature)) {
return;
}
seen.add(signature);
deduped.push(packet);
});
return deduped;
}; };
const summarizePayload = (payloadType: number, decodedPayload: Payload | undefined, payloadBytes: Uint8Array): string => { const summarizePayload = (payloadType: number, decodedPayload: Payload | undefined, payloadBytes: Uint8Array): string => {
@@ -187,7 +221,12 @@ const summarizePayload = (payloadType: number, decodedPayload: Payload | undefin
case PayloadType.ACK: case PayloadType.ACK:
return 'acknowledgement'; return 'acknowledgement';
case PayloadType.ADVERT: case PayloadType.ADVERT:
return 'node advertisement'; const advert = decodedPayload as AdvertPayload;
console.log('advert', advert);
if (advert && decodedPayload && 'appdata' in decodedPayload && advert.appdata && 'name' in advert.appdata) {
return `advertisement: ${advert.appdata.name}`;
}
return 'advertisement';
case PayloadType.GROUP_TEXT: case PayloadType.GROUP_TEXT:
if (decodedPayload && 'channelHash' in decodedPayload) { if (decodedPayload && 'channelHash' in decodedPayload) {
return `group channel=${decodedPayload.channelHash}`; return `group channel=${decodedPayload.channelHash}`;
@@ -210,55 +249,13 @@ const summarizePayload = (payloadType: number, decodedPayload: Payload | undefin
if (decodedPayload && 'flags' in decodedPayload && typeof decodedPayload.flags === 'number') { if (decodedPayload && 'flags' in decodedPayload && typeof decodedPayload.flags === 'number') {
return `control flags=0x${decodedPayload.flags.toString(16)}`; return `control flags=0x${decodedPayload.flags.toString(16)}`;
} }
return `control raw=${asHex(payloadBytes.slice(0, 4))}`; return `control raw=${bytesToHex(payloadBytes.slice(0, 4))}`;
case PayloadType.RAW_CUSTOM: case PayloadType.RAW_CUSTOM:
default: default:
return `raw=${asHex(payloadBytes.slice(0, Math.min(6, payloadBytes.length)))}`; return `raw=${bytesToHex(payloadBytes.slice(0, Math.min(6, payloadBytes.length)))}`;
} }
}; };
const createMockRecord = (index: number): MeshCorePacketRecord => {
const payloadType = payloadTypeList[index % payloadTypeList.length];
const nodeType = nodeTypeList[index % nodeTypeList.length];
const version = 1;
const routeType = RouteType.FLOOD;
const path = new Uint8Array([(0x11 + index) & 0xff, (0x90 + index) & 0xff, (0xa0 + index) & 0xff]);
const payload = makePayloadBytes(payloadType, index);
const header = ((version & 0x03) << 6) | ((payloadType & 0x0f) << 2) | (routeType & 0x03);
const pathLength = path.length & 0x3f;
const raw = new Uint8Array([header, pathLength, ...path, ...payload]);
const packet = new Packet();
packet.parse(raw);
let decodedPayload: Payload | undefined;
try {
decodedPayload = packet.decode();
} catch {
decodedPayload = undefined;
}
return {
timestamp: new Date(Date.now() - index * 75_000),
hash: asHex(packet.hash()),
nodeType,
payloadType,
routeType,
version,
path,
raw,
decodedPayload,
payloadSummary: summarizePayload(payloadType, decodedPayload, payload),
};
};
const createMockData = (count = 48): MeshCorePacketRecord[] => {
return Array.from({ length: count }, (_, index) => createMockRecord(index)).sort(
(a, b) => b.timestamp.getTime() - a.timestamp.getTime()
);
};
const toGroupChats = (packets: MeshCorePacketRecord[]): MeshCoreGroupChatRecord[] => { const toGroupChats = (packets: MeshCorePacketRecord[]): MeshCoreGroupChatRecord[] => {
return packets return packets
.filter((packet) => packet.payloadType === PayloadType.GROUP_TEXT || packet.payloadType === PayloadType.TEXT) .filter((packet) => packet.payloadType === PayloadType.GROUP_TEXT || packet.payloadType === PayloadType.TEXT)
@@ -307,12 +304,63 @@ const toMapPoints = (packets: MeshCorePacketRecord[]): MeshCoreNodePoint[] => {
}; };
export const MeshCoreDataProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const MeshCoreDataProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [packets, setPackets] = useState<MeshCorePacketRecord[]>(() => createMockData()); const [packets, setPackets] = useState<MeshCorePacketRecord[]>([]);
const [streamReady, setStreamReady] = useState(false); const [streamReady, setStreamReady] = useState(false);
const stream = useMemo(() => new MeshCoreStream(false), []); const stream = useMemo(() => new MeshCoreStream(false), []);
const meshCoreService = useMemo(() => new MeshCoreServiceImpl(API), []);
useEffect(() => { useEffect(() => {
let isMounted = true;
const fetchPackets = async () => {
try {
const fetchedPackets = await meshCoreService.fetchPackets();
if (!isMounted) {
return;
}
const records: MeshCorePacketRecord[] = fetchedPackets.map((packet) => {
const raw = base64ToBytes(packet.raw);
let decodedPayload: Payload | undefined;
try {
const p = new Packet();
p.parse(raw);
decodedPayload = p.decode();
} catch {
decodedPayload = undefined;
}
const pathLength = raw[1] & 0x3f;
const payloadBytes = raw.slice(2 + pathLength);
return {
timestamp: new Date(packet.received_at),
hash: packet.hash,
nodeType: packet.payload_type === PayloadType.ADVERT ? NodeType.TYPE_UNKNOWN : 0,
payloadType: packet.payload_type,
routeType: packet.route_type,
version: packet.version,
path: raw.slice(2, 2 + pathLength),
raw,
decodedPayload,
payloadSummary: summarizePayload(packet.payload_type, decodedPayload, payloadBytes),
};
});
setPackets((prev) => {
const merged = dedupeByHashAndPath([...records, ...prev]);
return merged
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
.slice(0, 500);
});
} catch (error) {
console.error('Failed to fetch MeshCore packets:', error);
}
};
fetchPackets();
stream.connect(); stream.connect();
const unsubscribeState = stream.subscribeToState((state) => { const unsubscribeState = stream.subscribeToState((state) => {
@@ -356,18 +404,21 @@ export const MeshCoreDataProvider: React.FC<{ children: React.ReactNode }> = ({
packet.payloadSummary = `raw=${bytesToHex(packet.raw.slice(0, Math.min(6, packet.raw.length)))}`; packet.payloadSummary = `raw=${bytesToHex(packet.raw.slice(0, Math.min(6, packet.raw.length)))}`;
} }
// Add to front of list, keeping last 500 packets const merged = dedupeByHashAndPath([packet, ...prev]);
return [packet, ...prev].slice(0, 500); return merged
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
.slice(0, 500);
}); });
} }
); );
return () => { return () => {
isMounted = false;
unsubscribeState(); unsubscribeState();
unsubscribePackets(); unsubscribePackets();
stream.disconnect(); stream.disconnect();
}; };
}, [stream]); }, [stream, meshCoreService]);
const groupChats = useMemo(() => toGroupChats(packets), [packets]); const groupChats = useMemo(() => toGroupChats(packets), [packets]);
const mapPoints = useMemo(() => toMapPoints(packets), [packets]); const mapPoints = useMemo(() => toMapPoints(packets), [packets]);

View File

@@ -7,6 +7,8 @@ import MeshCoreServiceImpl, { type MeshCoreGroupRecord } from '../../services/Me
import API from '../../services/API'; import API from '../../services/API';
import type { MeshCoreGroupChatRecord } from './MeshCoreContext'; import type { MeshCoreGroupChatRecord } from './MeshCoreContext';
import { KeyManager, Packet } from '../../protocols/meshcore'; import { KeyManager, Packet } from '../../protocols/meshcore';
import KeyboardShortcutsModal from '../../components/KeyboardShortcutsModal';
import { useKeyboardListNavigation } from '../../hooks/useKeyboardListNavigation';
const meshCoreService = new MeshCoreServiceImpl(API); const meshCoreService = new MeshCoreServiceImpl(API);
@@ -14,30 +16,58 @@ const GroupList: React.FC<{
groups: MeshCoreGroupRecord[]; groups: MeshCoreGroupRecord[];
selectedGroupId: number | null; selectedGroupId: number | null;
onSelectGroup: (group: MeshCoreGroupRecord) => void; onSelectGroup: (group: MeshCoreGroupRecord) => void;
onClearSelection: () => void;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
streamReady: boolean; streamReady: boolean;
}> = ({ groups, selectedGroupId, onSelectGroup, isLoading, error, streamReady }) => { }> = ({ groups, selectedGroupId, onSelectGroup, onClearSelection, isLoading, error, streamReady }) => {
const scrollRef = useRef<HTMLDivElement>(null);
const selectedIndex = useMemo(() => {
if (selectedGroupId === null) {
return null;
}
const index = groups.findIndex((group) => group.id === selectedGroupId);
return index >= 0 ? index : null;
}, [groups, selectedGroupId]);
const { showShortcuts, setShowShortcuts, shortcuts } = useKeyboardListNavigation({
itemCount: groups.length,
selectedIndex,
onSelectIndex: (index) => {
if (index === null) {
onClearSelection();
return;
}
const group = groups[index];
if (group) {
onSelectGroup(group);
}
},
scrollContainerRef: scrollRef,
rowSelector: '[data-nav-item="true"]',
});
return ( return (
<Card className="meshcore-table-card h-100 d-flex flex-column"> <Card className="data-table-card h-100 d-flex flex-column">
<Card.Header className="meshcore-table-header d-flex justify-content-between align-items-center"> <Card.Header className="data-table-header d-flex justify-content-between align-items-center">
<span>Groups</span> <span>Groups</span>
<div className="d-flex align-items-center gap-2"> <div className="d-flex align-items-center gap-2">
{isLoading && <Spinner animation="border" size="sm" style={{ width: '1rem', height: '1rem' }} />} {isLoading && <Spinner animation="border" size="sm" style={{ width: '1rem', height: '1rem' }} />}
<StreamStatus ready={streamReady} /> <StreamStatus ready={streamReady} />
</div> </div>
</Card.Header> </Card.Header>
<Card.Body className="meshcore-table-body p-0 d-flex flex-column"> <Card.Body className="data-table-body p-0 d-flex flex-column">
{error && <Alert variant="danger" className="m-2 mb-0">{error}</Alert>} {error && <Alert variant="danger" className="m-2 mb-0">{error}</Alert>}
{groups.length === 0 && !isLoading && ( {groups.length === 0 && !isLoading && (
<div className="p-3 text-secondary text-center"> <div className="p-3 text-secondary text-center">
{error ? 'Failed to load groups' : 'No groups available'} {error ? 'Failed to load groups' : 'No groups available'}
</div> </div>
)} )}
<div className="meshcore-table-scroll"> <div className="data-table-scroll" ref={scrollRef}>
{groups.map((group) => ( {groups.map((group) => (
<div <div
key={group.id} key={group.id}
data-nav-item="true"
onClick={() => onSelectGroup(group)} onClick={() => onSelectGroup(group)}
className={`list-item ${selectedGroupId === group.id ? 'is-selected' : ''}`} className={`list-item ${selectedGroupId === group.id ? 'is-selected' : ''}`}
> >
@@ -50,6 +80,11 @@ const GroupList: React.FC<{
))} ))}
</div> </div>
</Card.Body> </Card.Body>
<KeyboardShortcutsModal
show={showShortcuts}
onHide={() => setShowShortcuts(false)}
shortcuts={shortcuts}
/>
</Card> </Card>
); );
}; };
@@ -63,7 +98,7 @@ const GroupMessagesPane: React.FC<{
}> = ({ group, messages, isLoading, error, streamReady }) => { }> = ({ group, messages, isLoading, error, streamReady }) => {
if (!group) { if (!group) {
return ( return (
<Card body className="meshcore-detail-card h-100 d-flex flex-column justify-content-center align-items-center"> <Card body className="data-table-card h-100 d-flex flex-column justify-content-center align-items-center">
<h6 className="mb-2">Select a group</h6> <h6 className="mb-2">Select a group</h6>
<div className="text-secondary text-center">Click a group on the left to view messages</div> <div className="text-secondary text-center">Click a group on the left to view messages</div>
</Card> </Card>
@@ -71,20 +106,20 @@ const GroupMessagesPane: React.FC<{
} }
return ( return (
<Card className="meshcore-table-card h-100 d-flex flex-column"> <Card className="data-table-card h-100 d-flex flex-column">
<Card.Header className="meshcore-table-header d-flex justify-content-between align-items-center"> <Card.Header className="data-table-header d-flex justify-content-between align-items-center">
<span>{group.name}</span> <span>{group.name}</span>
<div className="d-flex align-items-center gap-2"> <div className="d-flex align-items-center gap-2">
{isLoading && <Spinner animation="border" size="sm" style={{ width: '1rem', height: '1rem' }} />} {isLoading && <Spinner animation="border" size="sm" style={{ width: '1rem', height: '1rem' }} />}
<StreamStatus ready={streamReady} /> <StreamStatus ready={streamReady} />
</div> </div>
</Card.Header> </Card.Header>
<Card.Body className="meshcore-table-body p-0 d-flex flex-column"> <Card.Body className="data-table-body p-0 d-flex flex-column">
{error && <Alert variant="danger" className="m-2 mb-0">{error}</Alert>} {error && <Alert variant="danger" className="m-2 mb-0">{error}</Alert>}
{messages.length === 0 && !isLoading && ( {messages.length === 0 && !isLoading && (
<div className="p-3 text-secondary text-center">No messages in this group</div> <div className="p-3 text-secondary text-center">No messages in this group</div>
)} )}
<div className="meshcore-table-scroll"> <div className="data-table-scroll">
<Stack gap={3} className="p-3"> <Stack gap={3} className="p-3">
{messages.map((message) => ( {messages.map((message) => (
<div key={message.hash + message.timestamp.toISOString()} className="meshcore-message-item"> <div key={message.hash + message.timestamp.toISOString()} className="meshcore-message-item">
@@ -173,7 +208,7 @@ const MeshCoreGroupChatView: React.FC = () => {
const decrypted = keyManagerRef.current.decryptGroup( const decrypted = keyManagerRef.current.decryptGroup(
packet.channel_hash, packet.channel_hash,
payload.cipherText as Uint8Array, payload.cipherText as Uint8Array,
payload.cipherMAC as Uint8Array payload.cipherMAC
); );
messages.push({ messages.push({
@@ -233,6 +268,7 @@ const MeshCoreGroupChatView: React.FC = () => {
groups={groups} groups={groups}
selectedGroupId={selectedGroup?.id ?? null} selectedGroupId={selectedGroup?.id ?? null}
onSelectGroup={handleSelectGroup} onSelectGroup={handleSelectGroup}
onClearSelection={() => setSelectedGroup(null)}
isLoading={isLoadingGroups} isLoading={isLoadingGroups}
error={groupsError} error={groupsError}
streamReady={streamReady} streamReady={streamReady}

View File

@@ -9,12 +9,12 @@ const MeshCoreMapView: React.FC = () => {
return ( return (
<Full className="meshcore-map-view"> <Full className="meshcore-map-view">
<Card className="meshcore-table-card h-100 d-flex flex-column"> <Card className="data-table-card h-100 d-flex flex-column">
<Card.Header className="meshcore-table-header d-flex justify-content-between align-items-center"> <Card.Header className="data-table-header d-flex justify-content-between align-items-center">
<span>Node Map</span> <span>Node Map</span>
<Badge bg={streamReady ? 'success' : 'secondary'}>{streamReady ? 'stream ready' : 'stream offline'}</Badge> <Badge bg={streamReady ? 'success' : 'secondary'}>{streamReady ? 'stream ready' : 'stream offline'}</Badge>
</Card.Header> </Card.Header>
<Card.Body className="meshcore-table-body d-flex flex-column gap-3"> <Card.Body className="data-table-body d-flex flex-column gap-3">
<div className="meshcore-map-canvas"> <div className="meshcore-map-canvas">
{mapPoints.map((point) => ( {mapPoints.map((point) => (
<div <div
@@ -29,8 +29,8 @@ const MeshCoreMapView: React.FC = () => {
))} ))}
</div> </div>
<div className="meshcore-table-scroll"> <div className="data-table-scroll">
<Table responsive size="sm" className="meshcore-table mb-0"> <Table responsive size="sm" className="data-table mb-0">
<thead> <thead>
<tr> <tr>
<th>Node</th> <th>Node</th>

View File

@@ -0,0 +1,424 @@
import React, { useMemo, useState } from 'react';
import { bytesToHex } from '@noble/hashes/utils.js';
import { Badge, Card, Stack } from 'react-bootstrap';
import type { MeshCorePacketRecord } from './MeshCoreContext';
import {
payloadDisplayByValue,
routeDisplayByValue,
} from './MeshCoreData';
const HeaderFact: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
<div className="meshcore-fact-row">
<span className="meshcore-fact-label">{label}</span>
<span className="meshcore-fact-value">{value}</span>
</div>
);
const toBitString = (value: number): string => value.toString(2).padStart(8, '0');
const byteHex = (value: number | undefined): string => {
if (typeof value !== 'number') {
return '0x??';
}
return `0x${value.toString(16).padStart(2, '0')}`;
};
const bitSlice = (value: number, msb: number, lsb: number): number => {
const width = msb - lsb + 1;
const mask = (1 << width) - 1;
return (value >> lsb) & mask;
};
const toAscii = (value: number): string => {
if (value >= 32 && value <= 126) {
return String.fromCharCode(value);
}
return '.';
};
const asRecord = (value: unknown): Record<string, unknown> | null => {
if (value && typeof value === 'object') {
return value as Record<string, unknown>;
}
return null;
};
interface BitFieldSpec {
msb: number;
lsb: number;
shortLabel: string;
label: string;
value: number;
}
const buildBitPointerLine = (msb: number, lsb: number): string => {
const width = 15;
const start = (7 - msb) * 2;
const end = (7 - lsb) * 2;
const chars = Array.from({ length: width }, () => ' ');
if (start === end) {
chars[start] = '↑';
return chars.join('');
}
chars[start] = '└';
for (let i = start + 1; i < end; i += 1) {
chars[i] = '─';
}
chars[end] = '┘';
return chars.join('');
};
const renderBitPointerArt = (
value: number,
fields: BitFieldSpec[],
mode: 'compact' | 'verbose'
): string => {
const header = 'bits: 7 6 5 4 3 2 1 0';
const bitRow = `val : ${toBitString(value).split('').join(' ')}`;
const pointers = fields.map((field) => {
const name = mode === 'compact' ? field.shortLabel : field.label;
return ` ${buildBitPointerLine(field.msb, field.lsb)} ${name} = ${field.value}`;
});
if (mode === 'compact') {
const legend = `key : ${fields.map((field) => field.shortLabel).join(', ')}`;
return [header, bitRow, ...pointers, legend].join('\n');
}
return [header, bitRow, ...pointers].join('\n');
};
interface ByteDissectionRow {
index: number;
byte: number;
zone: 'header' | 'path' | 'payload';
}
const buildByteDissection = (packet: MeshCorePacketRecord): {
rows: ByteDissectionRow[];
pathHashSize: number;
pathHashCount: number;
pathBytesAvailable: number;
payloadOffset: number;
} => {
const pathField = packet.raw.length > 1 ? packet.raw[1] : 0;
const pathHashSize = bitSlice(pathField, 7, 6) + 1;
const pathHashCount = bitSlice(pathField, 5, 0);
const pathBytesExpected = pathHashCount === 0 || pathHashSize === 4 ? 0 : pathHashCount * pathHashSize;
const pathBytesAvailable = Math.min(pathBytesExpected, Math.max(packet.raw.length - 2, 0));
const payloadOffset = 2 + pathBytesAvailable;
const rows: ByteDissectionRow[] = Array.from(packet.raw).map((byte, index) => {
if (index <= 1) {
return {
index,
byte,
zone: 'header',
};
}
if (index < payloadOffset) {
return {
index,
byte,
zone: 'path',
};
}
return {
index,
byte,
zone: 'payload',
};
});
return {
rows,
pathHashSize,
pathHashCount,
pathBytesAvailable,
payloadOffset,
};
};
const WireDissector: React.FC<{ packet: MeshCorePacketRecord }> = ({ packet }) => {
const [bitArtMode, setBitArtMode] = useState<'compact' | 'verbose'>('compact');
const { rows, pathHashSize, pathHashCount, pathBytesAvailable, payloadOffset } = useMemo(
() => buildByteDissection(packet),
[packet]
);
const headerByte = packet.raw[0] ?? 0;
const pathByte = packet.raw[1] ?? 0;
const pathBytes = packet.raw.slice(2, payloadOffset);
const payloadBytes = packet.raw.slice(payloadOffset);
const hexdumpRows = useMemo(() => {
const chunks: ByteDissectionRow[][] = [];
for (let i = 0; i < rows.length; i += 16) {
chunks.push(rows.slice(i, i + 16));
}
return chunks.map((chunk, rowIndex) => ({
offset: rowIndex * 16,
cells: [...chunk, ...Array<ByteDissectionRow | null>(Math.max(16 - chunk.length, 0)).fill(null)],
}));
}, [rows]);
return (
<Card body className="data-table-card">
<Stack direction="horizontal" className="justify-content-between align-items-center mb-2">
<h6 className="mb-0">Packet Bytes (Wire View)</h6>
<button
type="button"
className="meshcore-bitart-toggle"
onClick={() => setBitArtMode((prev) => (prev === 'compact' ? 'verbose' : 'compact'))}
title="Toggle compact/verbose bit pointer annotations"
>
Bit Art: {bitArtMode === 'compact' ? 'Compact' : 'Verbose'}
</button>
</Stack>
<div className="meshcore-wire-subtitle">Protocol tree + hex dump, similar to a packet analyzer output</div>
<div className="meshcore-ws-layout mt-3">
<div className="meshcore-ws-panel">
<div className="meshcore-ws-panel-title">Packet Details</div>
<ul className="meshcore-ws-tree">
<li>
<span className="meshcore-ws-node">Frame 1: {packet.raw.length} bytes on wire ({packet.raw.length * 8} bits)</span>
</li>
<li>
<span className="meshcore-ws-node">MeshCore Header</span>
<ul>
<li>Byte[0] = <code>{byteHex(headerByte)}</code> = <code>{toBitString(headerByte)}</code></li>
<li>b7..b6: Version = <strong>{bitSlice(headerByte, 7, 6)}</strong></li>
<li>b5..b2: Payload Type = <strong>{bitSlice(headerByte, 5, 2)}</strong> ({payloadDisplayByValue[packet.payloadType] ?? 'Unknown'})</li>
<li>b1..b0: Route Type = <strong>{bitSlice(headerByte, 1, 0)}</strong> ({routeDisplayByValue[packet.routeType] ?? 'Unknown'})</li>
<li>
<pre className="meshcore-bit-art">
{renderBitPointerArt(headerByte, [
{
msb: 7,
lsb: 6,
shortLabel: 'ver',
label: 'version (b7..b6)',
value: bitSlice(headerByte, 7, 6),
},
{
msb: 5,
lsb: 2,
shortLabel: 'ptype',
label: 'payload_type (b5..b2)',
value: bitSlice(headerByte, 5, 2),
},
{
msb: 1,
lsb: 0,
shortLabel: 'route',
label: 'route_type (b1..b0)',
value: bitSlice(headerByte, 1, 0),
},
], bitArtMode)}
</pre>
</li>
</ul>
</li>
<li>
<span className="meshcore-ws-node">Path Descriptor</span>
<ul>
<li>Byte[1] = <code>{byteHex(pathByte)}</code> = <code>{toBitString(pathByte)}</code></li>
<li>b7..b6: Hash size = ({bitSlice(pathByte, 7, 6)} + 1) = <strong>{pathHashSize}</strong></li>
<li>b5..b0: Hash count = <strong>{pathHashCount}</strong></li>
<li>Path bytes in frame = <strong>{pathBytesAvailable}</strong></li>
<li>Path data = <code>{pathBytes.length > 0 ? bytesToHex(pathBytes) : 'none'}</code></li>
<li>
<pre className="meshcore-bit-art">
{renderBitPointerArt(pathByte, [
{
msb: 7,
lsb: 6,
shortLabel: 'hsel',
label: 'hash_size_selector (b7..b6)',
value: bitSlice(pathByte, 7, 6),
},
{
msb: 5,
lsb: 0,
shortLabel: 'hcnt',
label: 'hash_count (b5..b0)',
value: bitSlice(pathByte, 5, 0),
},
], bitArtMode)}
</pre>
</li>
</ul>
</li>
<li>
<span className="meshcore-ws-node">Payload</span>
<ul>
<li>Payload offset = <code>{payloadOffset}</code></li>
<li>Payload length = <strong>{payloadBytes.length}</strong> bytes</li>
<li>Payload bytes = <code>{payloadBytes.length > 0 ? bytesToHex(payloadBytes) : 'none'}</code></li>
</ul>
</li>
</ul>
</div>
<div className="meshcore-ws-panel">
<div className="meshcore-ws-panel-title">Hex Dump</div>
<div className="meshcore-ws-legend mb-2">
<span><i className="meshcore-ws-chip meshcore-ws-zone-header" />Header</span>
<span><i className="meshcore-ws-chip meshcore-ws-zone-path" />Path</span>
<span><i className="meshcore-ws-chip meshcore-ws-zone-payload" />Payload</span>
</div>
<div className="meshcore-ws-hexdump">
{hexdumpRows.map((row) => (
<div key={row.offset} className="meshcore-ws-hexdump-row">
<span className="meshcore-ws-offset">{row.offset.toString(16).padStart(4, '0')}</span>
<span className="meshcore-ws-bytes">
{row.cells.map((cell, index) => (
<span key={`${row.offset}-${index}`} className={`meshcore-ws-byte ${cell ? `meshcore-ws-zone-${cell.zone}` : 'meshcore-ws-byte-empty'}`}>
{cell ? cell.byte.toString(16).padStart(2, '0') : ' '}
</span>
))}
</span>
<span className="meshcore-ws-ascii">
{row.cells.map((cell, index) => (
<span key={`${row.offset}-ascii-${index}`} className={`meshcore-ws-char ${cell ? `meshcore-ws-zone-${cell.zone}` : 'meshcore-ws-byte-empty'}`}>
{cell ? toAscii(cell.byte) : ' '}
</span>
))}
</span>
</div>
))}
</div>
</div>
</div>
</Card>
);
};
const PayloadDetails: React.FC<{ packet: MeshCorePacketRecord }> = ({ packet }) => {
const payload = packet.decodedPayload;
const payloadObj = asRecord(payload);
if (!payloadObj) {
return (
<Card body className="data-table-card">
<h6 className="mb-2">Payload</h6>
<div>Unable to decode payload; showing raw bytes only.</div>
<code>{bytesToHex(packet.raw)}</code>
</Card>
);
}
if (typeof payloadObj.flags === 'number' && payloadObj.data instanceof Uint8Array) {
return (
<Card body className="data-table-card">
<h6 className="mb-2">CONTROL Payload</h6>
<HeaderFact label="Flags" value={`0x${payloadObj.flags.toString(16)}`} />
<HeaderFact label="Data Length" value={payloadObj.data.length} />
<HeaderFact label="Data" value={<code>{bytesToHex(payloadObj.data)}</code>} />
</Card>
);
}
if (
typeof payloadObj.channelHash === 'string'
&& payloadObj.cipherText instanceof Uint8Array
&& payloadObj.cipherMAC instanceof Uint8Array
) {
return (
<Card body className="data-table-card">
<h6 className="mb-2">GROUP Payload</h6>
<HeaderFact label="Channel Hash" value={payloadObj.channelHash} />
<HeaderFact label="Cipher Text Length" value={payloadObj.cipherText.length} />
<HeaderFact label="Cipher MAC" value={<code>{bytesToHex(payloadObj.cipherMAC)}</code>} />
</Card>
);
}
if (
typeof payloadObj.dstHash === 'string'
&& typeof payloadObj.srcHash === 'string'
&& payloadObj.cipherText instanceof Uint8Array
&& payloadObj.cipherMAC instanceof Uint8Array
) {
return (
<Card body className="data-table-card">
<h6 className="mb-2">Encrypted Payload</h6>
<HeaderFact label="Destination" value={payloadObj.dstHash} />
<HeaderFact label="Source" value={payloadObj.srcHash} />
<HeaderFact label="Cipher Text Length" value={payloadObj.cipherText.length} />
<HeaderFact label="Cipher MAC" value={<code>{bytesToHex(payloadObj.cipherMAC)}</code>} />
</Card>
);
}
if (payloadObj.data instanceof Uint8Array) {
return (
<Card body className="data-table-card">
<h6 className="mb-2">Raw Payload</h6>
<HeaderFact label="Data Length" value={payloadObj.data.length} />
<HeaderFact label="Data" value={<code>{bytesToHex(payloadObj.data)}</code>} />
</Card>
);
}
return (
<Card body className="data-table-card">
<h6 className="mb-2">Payload</h6>
<code>{JSON.stringify(payloadObj)}</code>
</Card>
);
};
interface MeshCorePacketDetailsPaneProps {
packet: MeshCorePacketRecord | null;
streamReady: boolean;
}
const MeshCorePacketDetailsPane: React.FC<MeshCorePacketDetailsPaneProps> = ({ packet, streamReady }) => {
if (!packet) {
return (
<Card body className="data-table-card h-100">
<h6>Select a packet</h6>
<div>Click any hash in the table to inspect MeshCore header and payload details.</div>
<div className="mt-2 text-secondary">Stream prepared: {streamReady ? 'yes' : 'no'}</div>
</Card>
);
}
return (
<Stack gap={2} className="h-100 meshcore-detail-stack">
<Card body className="data-table-card">
<Stack direction="horizontal" gap={2} className="mb-2">
<h6 className="mb-0">Packet Header</h6>
<Badge bg="primary">{payloadDisplayByValue[packet.payloadType] ?? packet.payloadType}</Badge>
</Stack>
<HeaderFact label="Time" value={packet.timestamp.toISOString()} />
<HeaderFact label="Hash" value={<code>{packet.hash}</code>} />
{packet.radioName && <HeaderFact label="Radio" value={packet.radioName} />}
<HeaderFact label="Version" value={packet.version} />
<HeaderFact label="Payload Type" value={`${payloadDisplayByValue[packet.payloadType] ?? 'Unknown'} (${packet.payloadType})`} />
<HeaderFact label="Route Type" value={routeDisplayByValue[packet.routeType] ?? packet.routeType} />
<HeaderFact label="Raw Length" value={`${packet.raw.length} bytes`} />
<HeaderFact label="Path" value={<code>{bytesToHex(packet.path)}</code>} />
<HeaderFact label="Raw Packet" value={<code>{bytesToHex(packet.raw)}</code>} />
</Card>
<WireDissector packet={packet} />
<PayloadDetails packet={packet} />
<Card body className="data-table-card">
<h6 className="mb-2">Stream Preparation</h6>
<div>MeshCore stream service is initialized and ready for topic subscriptions.</div>
<div className="text-secondary">Ready: {streamReady ? 'yes' : 'no'}</div>
</Card>
</Stack>
);
};
export default MeshCorePacketDetailsPane;

View File

@@ -0,0 +1,194 @@
import React from 'react';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import {
Dropdown,
Form,
Stack,
} from 'react-bootstrap';
import {
payloadDisplayByValue,
PayloadTypeIcon,
routeDisplayByValue,
} from './MeshCoreData';
interface FilterDropdownProps {
label: string;
options: number[];
selectedValues: Set<number>;
getLabelForValue: (value: number) => string;
getIconForValue?: (value: number) => React.ReactElement;
onToggle: (value: number, isChecked: boolean) => void;
onSelectAll: () => void;
}
const FilterDropdown: React.FC<FilterDropdownProps> = ({
label,
options,
selectedValues,
getLabelForValue,
getIconForValue,
onToggle,
onSelectAll,
}) => {
const isAllSelected = selectedValues.size === 0;
const displayLabel = isAllSelected ? `${label}: All` : `${label}: ${selectedValues.size} selected`;
return (
<Dropdown className="meshcore-filter-dropdown">
<Dropdown.Toggle variant="outline-light" size="sm" className="meshcore-dropdown-toggle">
{displayLabel}
<ExpandMoreIcon style={{ fontSize: '1rem', marginLeft: '0.25rem' }} />
</Dropdown.Toggle>
<Dropdown.Menu className="meshcore-dropdown-menu">
<Dropdown.Item as="label" className="meshcore-dropdown-item">
<Form.Check
type="checkbox"
label="All"
checked={isAllSelected}
onChange={() => onSelectAll()}
className="mb-0"
onClick={(e) => e.stopPropagation()}
/>
</Dropdown.Item>
<Dropdown.Divider />
{options.map((option) => (
<Dropdown.Item key={option} as="label" className="meshcore-dropdown-item">
<Form.Check
type="checkbox"
label={(
<span>
{getIconForValue && <span style={{ marginRight: '0.5rem' }}>{getIconForValue(option)}</span>}
{getLabelForValue(option)}
</span>
)}
checked={selectedValues.has(option)}
onChange={(e) => onToggle(option, e.target.checked)}
className="mb-0"
onClick={(e) => e.stopPropagation()}
/>
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
);
};
interface StringFilterDropdownProps {
label: string;
options: string[];
selectedValues: Set<string>;
onToggle: (value: string, isChecked: boolean) => void;
onSelectAll: () => void;
}
const StringFilterDropdown: React.FC<StringFilterDropdownProps> = ({
label,
options,
selectedValues,
onToggle,
onSelectAll,
}) => {
const isAllSelected = selectedValues.size === 0;
const displayLabel = isAllSelected ? `${label}: All` : `${label}: ${selectedValues.size} selected`;
return (
<Dropdown className="meshcore-filter-dropdown">
<Dropdown.Toggle variant="outline-light" size="sm" className="meshcore-dropdown-toggle">
{displayLabel}
<ExpandMoreIcon style={{ fontSize: '1rem', marginLeft: '0.25rem' }} />
</Dropdown.Toggle>
<Dropdown.Menu className="meshcore-dropdown-menu">
<Dropdown.Item as="label" className="meshcore-dropdown-item">
<Form.Check
type="checkbox"
label="All"
checked={isAllSelected}
onChange={() => onSelectAll()}
className="mb-0"
onClick={(e) => e.stopPropagation()}
/>
</Dropdown.Item>
<Dropdown.Divider />
{options.map((option) => (
<Dropdown.Item key={option} as="label" className="meshcore-dropdown-item">
<Form.Check
type="checkbox"
label={option}
checked={selectedValues.has(option)}
onChange={(e) => onToggle(option, e.target.checked)}
className="mb-0"
onClick={(e) => e.stopPropagation()}
/>
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
);
};
interface MeshCorePacketFiltersProps {
uniquePayloadTypes: number[];
uniqueRadioNames: string[];
uniqueRouteTypes: number[];
filterPayloadTypes: Set<number>;
filterRadios: Set<string>;
filterRouteTypes: Set<number>;
onPayloadToggle: (value: number, isChecked: boolean) => void;
onPayloadSelectAll: () => void;
onRadioToggle: (value: string, isChecked: boolean) => void;
onRadioSelectAll: () => void;
onRouteToggle: (value: number, isChecked: boolean) => void;
onRouteSelectAll: () => void;
}
const MeshCorePacketFilters: React.FC<MeshCorePacketFiltersProps> = ({
uniquePayloadTypes,
uniqueRadioNames,
uniqueRouteTypes,
filterPayloadTypes,
filterRadios,
filterRouteTypes,
onPayloadToggle,
onPayloadSelectAll,
onRadioToggle,
onRadioSelectAll,
onRouteToggle,
onRouteSelectAll,
}) => {
return (
<div className="meshcore-filters p-2 border-bottom border-secondary-subtle">
<Stack direction="horizontal" gap={2}>
<FilterDropdown
label="Payload Type"
options={uniquePayloadTypes}
selectedValues={filterPayloadTypes}
getLabelForValue={(value) => payloadDisplayByValue[value] ?? `0x${value.toString(16)}`}
getIconForValue={(value) => <PayloadTypeIcon payloadType={value} />}
onToggle={onPayloadToggle}
onSelectAll={onPayloadSelectAll}
/>
<StringFilterDropdown
label="Radio"
options={uniqueRadioNames}
selectedValues={filterRadios}
onToggle={onRadioToggle}
onSelectAll={onRadioSelectAll}
/>
<FilterDropdown
label="Route Type"
options={uniqueRouteTypes}
selectedValues={filterRouteTypes}
getLabelForValue={(value) => routeDisplayByValue[value] ?? `0x${value.toString(16)}`}
onToggle={onRouteToggle}
onSelectAll={onRouteSelectAll}
/>
</Stack>
</div>
);
};
export default MeshCorePacketFilters;

View File

@@ -0,0 +1,172 @@
import React from 'react';
import { bytesToHex } from '@noble/hashes/utils.js';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { PayloadType } from '../../protocols/meshcore.types';
import type { MeshCorePacketRecord } from './MeshCoreContext';
import {
payloadDisplayByValue,
PayloadTypeIcon,
} from './MeshCoreData';
const getPayloadTypeColor = (payloadType: number): string => {
switch (payloadType) {
case PayloadType.TEXT:
case PayloadType.GROUP_TEXT:
case PayloadType.TRACE:
case PayloadType.PATH:
return 'meshcore-packet-green';
case PayloadType.ADVERT:
return 'meshcore-packet-purple';
case PayloadType.REQUEST:
case PayloadType.RESPONSE:
case PayloadType.CONTROL:
return 'meshcore-packet-amber';
default:
return '';
}
};
const getPathInfo = (packet: MeshCorePacketRecord): { prefixes: string; hopCount: number } => {
if (packet.raw.length < 2) {
return { prefixes: 'none', hopCount: 0 };
}
const pathField = packet.raw[1];
const hashSize = (pathField >> 6) + 1;
const hashCount = pathField & 0x3f;
if (hashCount === 0 || hashSize === 4) {
return { prefixes: 'none', hopCount: 0 };
}
const pathByteLength = hashCount * hashSize;
const availablePathBytes = Math.min(pathByteLength, Math.max(packet.raw.length - 2, 0));
const pathBytes = packet.raw.slice(2, 2 + availablePathBytes);
if (pathBytes.length === 0) {
return { prefixes: 'none', hopCount: 0 };
}
const prefixes: string[] = [];
for (let offset = 0; offset + hashSize <= pathBytes.length; offset += hashSize) {
prefixes.push(bytesToHex(pathBytes.slice(offset, offset + hashSize)));
}
if (prefixes.length === 0) {
return { prefixes: 'none', hopCount: 0 };
}
return {
prefixes: prefixes.join(' → '),
hopCount: prefixes.length,
};
};
export interface MeshCorePacketGroup {
hash: string;
packets: MeshCorePacketRecord[];
mostRecent: MeshCorePacketRecord;
}
interface MeshCorePacketRowsProps {
groupedPackets: MeshCorePacketGroup[];
expandedHashes: Set<string>;
onToggleExpanded: (hash: string) => void;
onSelect: (packet: MeshCorePacketRecord) => void;
selectedHash: string | null;
}
const MeshCorePacketRows: React.FC<MeshCorePacketRowsProps> = ({
groupedPackets,
expandedHashes,
onToggleExpanded,
onSelect,
selectedHash,
}) => {
return (
<>
{groupedPackets.map((group) => {
const isExpanded = expandedHashes.has(group.hash);
const hasDuplicates = group.packets.length > 1;
const [packet, ...duplicatePackets] = group.packets;
const expandedPackets = [packet, ...duplicatePackets].sort((a, b) => {
const pathA = getPathInfo(a).hopCount;
const pathB = getPathInfo(b).hopCount;
if (pathA !== pathB) {
return pathA - pathB;
}
return b.timestamp.getTime() - a.timestamp.getTime();
});
return (
<React.Fragment key={group.hash}>
<tr
data-nav-item="true"
className={`${getPayloadTypeColor(packet.payloadType)} ${selectedHash === packet.hash ? 'is-selected' : ''}`}
onClick={() => onSelect(packet)}
>
<td>
{hasDuplicates && (
<button
type="button"
className="meshcore-expand-button"
onClick={(e) => {
e.stopPropagation();
onToggleExpanded(group.hash);
}}
aria-label={isExpanded ? 'Collapse duplicates' : 'Expand duplicates'}
>
{isExpanded ? <ExpandMoreIcon style={{ fontSize: '1rem' }} /> : <ChevronRightIcon style={{ fontSize: '1rem' }} />}
</button>
)}
</td>
<td>
{packet.timestamp.toLocaleTimeString()}
{hasDuplicates && (
<span className="meshcore-duplicate-badge" title={`${group.packets.length} instances`}>
{' '}×{group.packets.length}
</span>
)}
</td>
<td>
<button type="button" className="meshcore-hash-button" onClick={() => onSelect(packet)}>
{packet.hash}
</button>
</td>
<td className="meshcore-payload-type-cell" title={payloadDisplayByValue[packet.payloadType] ?? `0x${packet.payloadType.toString(16)}`}>
<PayloadTypeIcon payloadType={packet.payloadType} />
</td>
<td>{packet.payloadSummary}</td>
</tr>
{isExpanded && expandedPackets.map((duplicatePacket, index) => (
<tr
key={`${group.hash}-${index}`}
className={`${getPayloadTypeColor(duplicatePacket.payloadType)} ${selectedHash === duplicatePacket.hash ? 'is-selected' : ''}`}
onClick={() => onSelect(duplicatePacket)}
>
<td></td>
<td>{duplicatePacket.timestamp.toLocaleTimeString()}</td>
<td>
<button type="button" className="meshcore-hash-button" onClick={() => onSelect(duplicatePacket)}>
{duplicatePacket.hash}
</button>
</td>
<td className="meshcore-payload-type-cell" title={payloadDisplayByValue[duplicatePacket.payloadType] ?? `0x${duplicatePacket.payloadType.toString(16)}`}>
<PayloadTypeIcon payloadType={duplicatePacket.payloadType} />
</td>
<td>
{getPathInfo(duplicatePacket).prefixes}
</td>
</tr>
))}
</React.Fragment>
);
})}
</>
);
};
export default MeshCorePacketRows;

View File

@@ -0,0 +1,243 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router';
import {
Card,
Table,
} from 'react-bootstrap';
import StreamStatus from '../../components/StreamStatus';
import KeyboardShortcutsModal from '../../components/KeyboardShortcutsModal';
import { useKeyboardListNavigation } from '../../hooks/useKeyboardListNavigation';
import { useRadiosByProtocol } from '../../contexts/RadiosContext';
import type { MeshCorePacketRecord } from './MeshCoreContext';
import {
payloadUrlByValue,
payloadValueByUrl,
routeUrlByValue,
routeValueByUrl,
} from './MeshCoreData';
import MeshCorePacketFilters from './MeshCorePacketFilters';
import MeshCorePacketRows, { type MeshCorePacketGroup } from './MeshCorePacketRows';
interface MeshCorePacketTableProps {
packets: MeshCorePacketRecord[];
selectedHash: string | null;
onSelect: (packet: MeshCorePacketRecord) => void;
onClearSelection: () => void;
streamReady: boolean;
}
const MeshCorePacketTable: React.FC<MeshCorePacketTableProps> = ({ packets, selectedHash, onSelect, onClearSelection, streamReady }) => {
const [searchParams, setSearchParams] = useSearchParams();
const radios = useRadiosByProtocol('meshcore');
const scrollRef = React.useRef<HTMLDivElement>(null);
const [filterPayloadTypes, setFilterPayloadTypes] = useState<Set<number>>(() => {
const payloads = searchParams.get('payloads');
if (!payloads) return new Set();
return new Set(payloads.split(',').map((urlName) => payloadValueByUrl[urlName]).filter((v) => v !== undefined));
});
const [filterRouteTypes, setFilterRouteTypes] = useState<Set<number>>(() => {
const routes = searchParams.get('routes');
if (!routes) return new Set();
return new Set(routes.split(',').map((urlName) => routeValueByUrl[urlName]).filter((v) => v !== undefined));
});
const [filterRadios, setFilterRadios] = useState<Set<string>>(() => {
const radiosParam = searchParams.get('radios');
if (!radiosParam) return new Set();
return new Set(radiosParam.split(',').map(decodeURIComponent));
});
const [expandedHashes, setExpandedHashes] = useState<Set<string>>(new Set());
useEffect(() => {
const newParams = new URLSearchParams(searchParams);
if (filterPayloadTypes.size > 0) {
newParams.set('payloads', Array.from(filterPayloadTypes).map((v) => payloadUrlByValue[v]).join(','));
} else {
newParams.delete('payloads');
}
if (filterRouteTypes.size > 0) {
newParams.set('routes', Array.from(filterRouteTypes).map((v) => routeUrlByValue[v]).join(','));
} else {
newParams.delete('routes');
}
if (filterRadios.size > 0) {
newParams.set('radios', Array.from(filterRadios).map(encodeURIComponent).join(','));
} else {
newParams.delete('radios');
}
setSearchParams(newParams, { replace: true });
}, [filterPayloadTypes, filterRouteTypes, filterRadios, searchParams, setSearchParams]);
const uniquePayloadTypes = useMemo(() => {
const types = new Set(packets.map((packet) => packet.payloadType));
return Array.from(types).sort((a, b) => a - b);
}, [packets]);
const uniqueRouteTypes = useMemo(() => {
const types = new Set(packets.map((packet) => packet.routeType));
return Array.from(types).sort((a, b) => a - b);
}, [packets]);
const uniqueRadioNames = useMemo(() => {
const namesFromPackets = new Set(packets.map((packet) => packet.radioName).filter((name): name is string => !!name));
const namesFromAPI = new Set(radios.map((radio) => radio.name));
const allNames = new Set([...namesFromPackets, ...namesFromAPI]);
return Array.from(allNames).sort();
}, [packets, radios]);
const groupedPackets = useMemo((): MeshCorePacketGroup[] => {
const groups = new Map<string, MeshCorePacketRecord[]>();
packets.forEach((packet) => {
if (filterPayloadTypes.size > 0 && !filterPayloadTypes.has(packet.payloadType)) return;
if (filterRouteTypes.size > 0 && !filterRouteTypes.has(packet.routeType)) return;
if (filterRadios.size > 0 && packet.radioName && !filterRadios.has(packet.radioName)) return;
const existing = groups.get(packet.hash);
if (existing) {
existing.push(packet);
} else {
groups.set(packet.hash, [packet]);
}
});
return Array.from(groups.entries())
.map(([hash, grouped]) => ({
hash,
packets: grouped.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()),
mostRecent: grouped.reduce((latest, packet) => (
packet.timestamp > latest.timestamp ? packet : latest
)),
}))
.sort((a, b) => b.mostRecent.timestamp.getTime() - a.mostRecent.timestamp.getTime());
}, [packets, filterPayloadTypes, filterRouteTypes, filterRadios]);
const handlePayloadTypeToggle = (value: number, isChecked: boolean) => {
const newSet = new Set(filterPayloadTypes);
if (isChecked) {
newSet.add(value);
} else {
newSet.delete(value);
}
setFilterPayloadTypes(newSet);
};
const handleRouteTypeToggle = (value: number, isChecked: boolean) => {
const newSet = new Set(filterRouteTypes);
if (isChecked) {
newSet.add(value);
} else {
newSet.delete(value);
}
setFilterRouteTypes(newSet);
};
const handleRadioToggle = (value: string, isChecked: boolean) => {
const newSet = new Set(filterRadios);
if (isChecked) {
newSet.add(value);
} else {
newSet.delete(value);
}
setFilterRadios(newSet);
};
const toggleExpanded = (hash: string) => {
const newExpanded = new Set(expandedHashes);
if (newExpanded.has(hash)) {
newExpanded.delete(hash);
} else {
newExpanded.add(hash);
}
setExpandedHashes(newExpanded);
};
const navigablePackets = useMemo(() => groupedPackets.map((group) => group.mostRecent), [groupedPackets]);
const selectedIndex = useMemo(() => {
if (!selectedHash) {
return null;
}
const index = navigablePackets.findIndex((packet) => packet.hash === selectedHash);
return index >= 0 ? index : null;
}, [navigablePackets, selectedHash]);
const { showShortcuts, setShowShortcuts, shortcuts } = useKeyboardListNavigation({
itemCount: navigablePackets.length,
selectedIndex,
onSelectIndex: (index) => {
if (index === null) {
onClearSelection();
return;
}
const packet = navigablePackets[index];
if (packet) {
onSelect(packet);
}
},
scrollContainerRef: scrollRef,
});
return (
<Card className="data-table-card h-100 d-flex flex-column">
<Card.Header className="data-table-header d-flex justify-content-between align-items-center">
<span>MeshCore Packets</span>
<div className="d-flex align-items-center gap-2">
<StreamStatus ready={streamReady} />
</div>
</Card.Header>
<Card.Body className="data-table-body p-0 d-flex flex-column">
<MeshCorePacketFilters
uniquePayloadTypes={uniquePayloadTypes}
uniqueRadioNames={uniqueRadioNames}
uniqueRouteTypes={uniqueRouteTypes}
filterPayloadTypes={filterPayloadTypes}
filterRadios={filterRadios}
filterRouteTypes={filterRouteTypes}
onPayloadToggle={handlePayloadTypeToggle}
onPayloadSelectAll={() => setFilterPayloadTypes(new Set())}
onRadioToggle={handleRadioToggle}
onRadioSelectAll={() => setFilterRadios(new Set())}
onRouteToggle={handleRouteTypeToggle}
onRouteSelectAll={() => setFilterRouteTypes(new Set())}
/>
<div className="data-table-scroll" ref={scrollRef}>
<Table hover responsive className="data-table mb-0" size="sm">
<thead>
<tr>
<th style={{ width: '30px' }}></th>
<th style={{ width: '100px' }}>Time</th>
<th style={{ width: '80px' }}>Hash</th>
<th style={{ width: '50px' }}>Type</th>
<th>Info</th>
</tr>
</thead>
<tbody>
<MeshCorePacketRows
groupedPackets={groupedPackets}
expandedHashes={expandedHashes}
onToggleExpanded={toggleExpanded}
onSelect={onSelect}
selectedHash={selectedHash}
/>
</tbody>
</Table>
</div>
</Card.Body>
<KeyboardShortcutsModal
show={showShortcuts}
onHide={() => setShowShortcuts(false)}
shortcuts={shortcuts}
/>
</Card>
);
};
export default MeshCorePacketTable;

Some files were not shown because too many files have changed in this diff Show More