More APRS enhancements
@@ -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
|
||||
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.
|
||||
|
||||
423
asset/image/protocol/aprs/COPYRIGHT.md
Normal 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
|
||||
BIN
asset/image/protocol/aprs/aprs-symbols-128-0.png
Normal file
|
After Width: | Height: | Size: 308 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-128-0@2x.png
Normal file
|
After Width: | Height: | Size: 521 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-128-0@3x.png
Normal file
|
After Width: | Height: | Size: 825 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-128-1.png
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-128-1@2x.png
Normal file
|
After Width: | Height: | Size: 449 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-128-1@3x.png
Normal file
|
After Width: | Height: | Size: 676 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-128-2.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-128-2@2x.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-128-2@3x.png
Normal file
|
After Width: | Height: | Size: 156 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-24-0.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-24-0@2x.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-24-0@3x.png
Normal file
|
After Width: | Height: | Size: 162 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-24-1.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-24-1@2x.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-24-1@3x.png
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-24-2.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-24-2@2x.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-24-2@3x.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-256-0.png
Normal file
|
After Width: | Height: | Size: 521 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-256-0@2x.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
asset/image/protocol/aprs/aprs-symbols-256-0@3x.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
asset/image/protocol/aprs/aprs-symbols-256-1.png
Normal file
|
After Width: | Height: | Size: 449 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-256-1@2x.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
asset/image/protocol/aprs/aprs-symbols-256-1@3x.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
asset/image/protocol/aprs/aprs-symbols-256-2.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-256-2@2x.png
Normal file
|
After Width: | Height: | Size: 251 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-256-2@3x.png
Normal file
|
After Width: | Height: | Size: 416 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-32-0.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-32-0@2x.png
Normal file
|
After Width: | Height: | Size: 144 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-32-0@3x.png
Normal file
|
After Width: | Height: | Size: 224 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-32-1.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-32-1@2x.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-32-1@3x.png
Normal file
|
After Width: | Height: | Size: 188 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-32-2.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-32-2@2x.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-32-2@3x.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-48-0.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-48-0@2x.png
Normal file
|
After Width: | Height: | Size: 224 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-48-0@3x.png
Normal file
|
After Width: | Height: | Size: 350 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-48-1.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-48-1@2x.png
Normal file
|
After Width: | Height: | Size: 188 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-48-1@3x.png
Normal file
|
After Width: | Height: | Size: 301 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-48-2.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-48-2@2x.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-48-2@3x.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-56-0.png
Normal file
|
After Width: | Height: | Size: 125 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-56-0@2x.png
Normal file
|
After Width: | Height: | Size: 265 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-56-0@3x.png
Normal file
|
After Width: | Height: | Size: 414 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-56-1.png
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-56-1@2x.png
Normal file
|
After Width: | Height: | Size: 241 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-56-1@3x.png
Normal file
|
After Width: | Height: | Size: 352 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-56-2.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-56-2@2x.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-56-2@3x.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-64-0.png
Normal file
|
After Width: | Height: | Size: 144 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-64-0@2x.png
Normal file
|
After Width: | Height: | Size: 308 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-64-0@3x.png
Normal file
|
After Width: | Height: | Size: 489 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-64-1.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-64-1@2x.png
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-64-1@3x.png
Normal file
|
After Width: | Height: | Size: 401 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-64-2.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-64-2@2x.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-64-2@3x.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
asset/image/protocol/aprs/aprs-symbols-64-droid.png
Normal file
|
After Width: | Height: | Size: 320 KiB |
13
ui/AGENTS.md
@@ -28,7 +28,7 @@ Relevant documents:
|
||||
|
||||
**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
|
||||
|
||||
@@ -36,12 +36,16 @@ Run `npm run build`.
|
||||
- Prefer ESM imports (`import`/`export`)
|
||||
- Use builtins from React, React-Boostrap where possible
|
||||
- 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
|
||||
- Use React-Bootstrap components where appropriate
|
||||
- Follow existing CSS patterns
|
||||
- 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:
|
||||
- React import first; then any react plugin
|
||||
- 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 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
@@ -20,6 +20,7 @@
|
||||
"mqtt": "^5.15.0",
|
||||
"react": "^19.2.0",
|
||||
"react-bootstrap": "^2.10.10",
|
||||
"react-country-flag": "^3.1.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-qr-code": "^2.0.18",
|
||||
@@ -29,6 +30,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^24.11.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -43,7 +45,8 @@
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.18"
|
||||
"vitest": "^4.0.18",
|
||||
"xlsx": "^0.18.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
@@ -2330,6 +2333,13 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -2337,6 +2347,16 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "24.11.0",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||
@@ -3189,6 +3219,20 @@
|
||||
],
|
||||
"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": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
@@ -3247,6 +3291,16 @@
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -3366,6 +3420,19 @@
|
||||
"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": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -3994,6 +4061,16 @@
|
||||
"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": {
|
||||
"version": "2.3.3",
|
||||
"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": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
@@ -5355,6 +5444,19 @@
|
||||
"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": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
@@ -5834,6 +5936,26 @@
|
||||
"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": {
|
||||
"version": "1.2.5",
|
||||
"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": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"mqtt": "^5.15.0",
|
||||
"react": "^19.2.0",
|
||||
"react-bootstrap": "^2.10.10",
|
||||
"react-country-flag": "^3.1.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-qr-code": "^2.0.18",
|
||||
@@ -34,6 +35,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^24.11.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -48,6 +50,7 @@
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.18"
|
||||
"vitest": "^4.0.18",
|
||||
"xlsx": "^0.18.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,14 +80,14 @@ a:hover {
|
||||
}
|
||||
}
|
||||
|
||||
.vertical-split-50-50 {
|
||||
.vertical-split-1-1 {
|
||||
.split-pane-primary,
|
||||
.split-pane-secondary {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
}
|
||||
|
||||
.vertical-split-75-25 {
|
||||
.vertical-split-3-1 {
|
||||
.split-pane-primary {
|
||||
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 {
|
||||
.split-pane-primary {
|
||||
flex: 25 1 0;
|
||||
|
||||
@@ -10,6 +10,7 @@ import MeshCoreMapView from './pages/meshcore/MeshCoreMapView'
|
||||
import MeshCorePacketsView from './pages/meshcore/MeshCorePacketsView'
|
||||
import StyleGuide from './pages/StyleGuide'
|
||||
import NotFound from './pages/NotFound'
|
||||
import { KeyboardNavigationProvider } from './contexts/KeyboardNavigationContext'
|
||||
import './App.scss'
|
||||
|
||||
const navLinks = [
|
||||
@@ -28,23 +29,25 @@ const withRadiosProvider = (Component: React.ComponentType<{ navLinks: typeof na
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={withRadiosProvider(Overview)()} />
|
||||
<Route path="/aprs" element={withRadiosProvider(APRS)()}>
|
||||
<Route index element={<Navigate to="packets" replace />} />
|
||||
<Route path="packets" element={<APRSPacketsView />} />
|
||||
</Route>
|
||||
<Route path="/meshcore" element={withRadiosProvider(MeshCore)()}>
|
||||
<Route index element={<Navigate to="packets" replace />} />
|
||||
<Route path="packets" element={<MeshCorePacketsView />} />
|
||||
<Route path="groupchat" element={<MeshCoreGroupChatView />} />
|
||||
<Route path="map" element={<MeshCoreMapView />} />
|
||||
</Route>
|
||||
<Route path="/style-guide" element={<StyleGuide />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<KeyboardNavigationProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={withRadiosProvider(Overview)()} />
|
||||
<Route path="/aprs" element={withRadiosProvider(APRS)()}>
|
||||
<Route index element={<Navigate to="packets" replace />} />
|
||||
<Route path="packets" element={<APRSPacketsView />} />
|
||||
</Route>
|
||||
<Route path="/meshcore" element={withRadiosProvider(MeshCore)()}>
|
||||
<Route index element={<Navigate to="packets" replace />} />
|
||||
<Route path="packets" element={<MeshCorePacketsView />} />
|
||||
<Route path="groupchat" element={<MeshCoreGroupChatView />} />
|
||||
<Route path="map" element={<MeshCoreMapView />} />
|
||||
</Route>
|
||||
<Route path="/style-guide" element={<StyleGuide />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</KeyboardNavigationProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
64
ui/src/components/CountryFlag.tsx
Normal 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;
|
||||
45
ui/src/components/KeyboardShortcutsModal.tsx
Normal 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;
|
||||
@@ -60,6 +60,27 @@
|
||||
font-weight: 600;
|
||||
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 {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
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 KeyboardIcon from '@mui/icons-material/Keyboard';
|
||||
import { useKeyboardNavigationActivity } from '../contexts/KeyboardNavigationContext';
|
||||
import KeyboardShortcutsModal from './KeyboardShortcutsModal';
|
||||
import { defaultKeyboardShortcuts } from '../hooks/useKeyboardListNavigation';
|
||||
import './Layout.scss';
|
||||
import type { LayoutProps, NavLinkItem } from '../types/layout.types';
|
||||
|
||||
@@ -13,6 +17,7 @@ const Layout: React.FC<LayoutProps> = ({
|
||||
gutterSize = 16
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const { isKeyboardNavigationActive, showGlobalShortcuts, setShowGlobalShortcuts } = useKeyboardNavigationActivity();
|
||||
|
||||
const isActive = (link: NavLinkItem): boolean => {
|
||||
return location.pathname.startsWith(link.to);
|
||||
@@ -33,7 +38,23 @@ const Layout: React.FC<LayoutProps> = ({
|
||||
|
||||
<Navbar.Toggle aria-controls="layout-navbar-nav" />
|
||||
<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) => (
|
||||
<Nav.Link
|
||||
key={index}
|
||||
@@ -45,7 +66,8 @@ const Layout: React.FC<LayoutProps> = ({
|
||||
{link.label}
|
||||
</Nav.Link>
|
||||
))}
|
||||
</Nav>
|
||||
</Nav>
|
||||
</div>
|
||||
</Navbar.Collapse>
|
||||
</Container>
|
||||
</Navbar>
|
||||
@@ -54,6 +76,12 @@ const Layout: React.FC<LayoutProps> = ({
|
||||
{children || <Outlet />}
|
||||
</Container>
|
||||
|
||||
<KeyboardShortcutsModal
|
||||
show={showGlobalShortcuts}
|
||||
onHide={() => setShowGlobalShortcuts(false)}
|
||||
shortcuts={defaultKeyboardShortcuts}
|
||||
/>
|
||||
|
||||
<footer className="layout-footer">
|
||||
<p>© {new Date().getFullYear()} PD0MZ. All rights reserved.</p>
|
||||
</footer>
|
||||
|
||||
@@ -5,13 +5,14 @@ import type { VerticalSplitProps } from '../types/layout.types';
|
||||
const VerticalSplit: React.FC<VerticalSplitProps> = ({
|
||||
left,
|
||||
right,
|
||||
ratio = '50/50',
|
||||
ratio = '1:1',
|
||||
className = ''
|
||||
}) => {
|
||||
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' :
|
||||
'vertical-split-50-50';
|
||||
'vertical-split-1-1';
|
||||
|
||||
return (
|
||||
<Container fluid className="p-0 h-100 w-100">
|
||||
|
||||
36
ui/src/components/aprs/APRSSymbol.scss
Normal 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
|
||||
}
|
||||
}
|
||||
148
ui/src/components/aprs/APRSSymbol.tsx
Normal 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;
|
||||
1
ui/src/components/aprs/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { APRSSymbol, type APRSSymbolProps } from './APRSSymbol';
|
||||
315
ui/src/components/map/ClusteredMarkers.tsx
Normal 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;
|
||||
1
ui/src/components/map/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ClusteredMarkers, type ClusterableItem, type Cluster } from './ClusteredMarkers';
|
||||
48
ui/src/contexts/KeyboardNavigationContext.tsx
Normal 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;
|
||||
};
|
||||
176
ui/src/hooks/useKeyboardListNavigation.ts
Normal 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;
|
||||
64
ui/src/libs/callsignMapper.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
255
ui/src/libs/callsignMapper.ts
Normal 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;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import 'bootstrap/dist/css/bootstrap.min.css'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
|
||||
@@ -104,4 +104,35 @@
|
||||
.leaflet-container {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Button, ButtonGroup } from 'react-bootstrap';
|
||||
import { ButtonGroup } from 'react-bootstrap';
|
||||
import Layout from '../components/Layout';
|
||||
import { NavLink, Outlet, useLocation } from 'react-router';
|
||||
import { APRSDataProvider } from './aprs/APRSData';
|
||||
@@ -15,13 +15,12 @@ const APRS: React.FC<Props> = ({ navLinks = [] }) => {
|
||||
|
||||
const viewButtons = (
|
||||
<ButtonGroup className="aprs-view-switch" size="sm" aria-label="APRS view switch">
|
||||
<Button
|
||||
as={NavLink}
|
||||
<NavLink
|
||||
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
|
||||
</Button>
|
||||
</NavLink>
|
||||
</ButtonGroup>
|
||||
);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
display: flex;
|
||||
@@ -49,42 +39,23 @@
|
||||
justify-content: center;
|
||||
padding: 0.25rem !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.meshcore-node-type-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.meshcore-node-type-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
font-size: 1.125rem;
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
color: black;
|
||||
}
|
||||
svg {
|
||||
font-size: 1.125rem;
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
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 {
|
||||
background: rgba(13, 36, 82, 0.6);
|
||||
@@ -219,70 +190,50 @@
|
||||
}
|
||||
}
|
||||
|
||||
.meshcore-table-scroll {
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.meshcore-table {
|
||||
color: var(--app-text);
|
||||
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: rgba(13, 36, 82, 0.95);
|
||||
border-color: rgba(173, 205, 255, 0.18);
|
||||
color: var(--app-text);
|
||||
|
||||
|
||||
.data-table {
|
||||
&.table-hover > tbody > tr:hover .meshcore-hash-button,
|
||||
&.table-hover > tbody > tr:hover .meshcore-expand-button {
|
||||
color: #f2f7ff;
|
||||
}
|
||||
|
||||
td {
|
||||
border-color: rgba(173, 205, 255, 0.12);
|
||||
vertical-align: middle;
|
||||
tr.is-selected .meshcore-hash-button,
|
||||
tr.is-selected .meshcore-hash-button:hover {
|
||||
color: var(--app-accent-yellow);
|
||||
}
|
||||
|
||||
tr.is-selected td {
|
||||
background: rgba(102, 157, 255, 0.16);
|
||||
td.meshcore-node-type-cell {
|
||||
border-bottom: transparent !important;
|
||||
}
|
||||
|
||||
tr.meshcore-packet-green td {
|
||||
border-left: 3px solid #22c55e;
|
||||
|
||||
&:nth-child(4) svg {
|
||||
color: #22c55e !important;
|
||||
}
|
||||
|
||||
&:nth-child(5) {
|
||||
color: #22c55e;
|
||||
font-weight: 500;
|
||||
}
|
||||
tr.meshcore-packet-green td:nth-child(4) svg {
|
||||
color: #22c55e !important;
|
||||
}
|
||||
|
||||
tr.meshcore-packet-purple td {
|
||||
border-left: 3px solid #a855f7;
|
||||
|
||||
&:nth-child(4) svg {
|
||||
color: #a855f7 !important;
|
||||
}
|
||||
|
||||
&:nth-child(5) {
|
||||
color: #a855f7;
|
||||
font-weight: 500;
|
||||
}
|
||||
tr.meshcore-packet-green td:nth-child(5) {
|
||||
color: #22c55e;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
tr.meshcore-packet-amber td {
|
||||
border-left: 3px solid #f59e0b;
|
||||
tr.meshcore-packet-purple td:nth-child(4) svg {
|
||||
color: #a855f7 !important;
|
||||
}
|
||||
|
||||
&:nth-child(4) svg {
|
||||
color: #f59e0b !important;
|
||||
}
|
||||
tr.meshcore-packet-purple td:nth-child(5) {
|
||||
color: #a855f7;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&:nth-child(5) {
|
||||
color: #f59e0b;
|
||||
font-weight: 500;
|
||||
}
|
||||
tr.meshcore-packet-amber td:nth-child(4) svg {
|
||||
color: #f59e0b !important;
|
||||
}
|
||||
|
||||
tr.meshcore-packet-amber td:nth-child(5) {
|
||||
color: #f59e0b;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,6 +299,172 @@
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
|
||||
@@ -20,27 +20,27 @@ const MeshCore: React.FC<Props> = ({ navLinks = [] }) => {
|
||||
<ButtonGroup className="meshcore-view-switch" size="sm" aria-label="MeshCore view switch">
|
||||
<NavLink
|
||||
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"
|
||||
>
|
||||
<StorageIcon className="meshcore-icon" />
|
||||
<span className="ms-1">Packets</span>
|
||||
<StorageIcon className="icon" />
|
||||
<span className="ms-1 d-none d-lg-inline">Packets</span>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
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"
|
||||
>
|
||||
<ChatIcon className="meshcore-icon" />
|
||||
<span className="ms-1">Chat</span>
|
||||
<ChatIcon className="icon" />
|
||||
<span className="ms-1 d-none d-lg-inline">Chat</span>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
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"
|
||||
>
|
||||
<MapIcon className="meshcore-icon" />
|
||||
<span className="ms-1">Map</span>
|
||||
<MapIcon className="icon" />
|
||||
<span className="ms-1 d-none d-lg-inline">Map</span>
|
||||
</NavLink>
|
||||
</ButtonGroup>
|
||||
);
|
||||
|
||||
@@ -15,11 +15,14 @@ export interface APRSPacketRecord {
|
||||
speed?: number;
|
||||
course?: number;
|
||||
comment?: string;
|
||||
symbol?: string;
|
||||
radioName?: string;
|
||||
hasAPILocation?: boolean;
|
||||
}
|
||||
|
||||
interface APRSDataContextValue {
|
||||
packets: APRSPacketRecord[];
|
||||
streamReady: boolean;
|
||||
}
|
||||
|
||||
const APRSDataContext = createContext<APRSDataContextValue | null>(null);
|
||||
@@ -59,6 +62,7 @@ const extractAPRSDetails = (frame: Frame) => {
|
||||
let speed: number | undefined;
|
||||
let course: number | undefined;
|
||||
let comment: string | undefined;
|
||||
let symbol: string | undefined;
|
||||
|
||||
if (decoded && typeof decoded === 'object') {
|
||||
if ('position' in decoded && decoded.position) {
|
||||
@@ -66,6 +70,9 @@ const extractAPRSDetails = (frame: Frame) => {
|
||||
longitude = decoded.position.longitude;
|
||||
altitude = decoded.position.altitude;
|
||||
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') {
|
||||
@@ -92,6 +99,7 @@ const extractAPRSDetails = (frame: Frame) => {
|
||||
speed,
|
||||
course,
|
||||
comment,
|
||||
symbol,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -106,13 +114,47 @@ const parseTimestamp = (input: unknown): 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 merged = [...incoming, ...current];
|
||||
const byKey = new Map<string, APRSPacketRecord>();
|
||||
|
||||
merged.forEach((packet) => {
|
||||
const key = `${packet.timestamp.toISOString()}|${packet.raw}|${packet.radioName ?? ''}`;
|
||||
if (!byKey.has(key)) {
|
||||
const sourceSsid = packet.frame.source.ssid ?? 0;
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -129,6 +171,8 @@ const buildRecord = ({
|
||||
comment,
|
||||
latitude,
|
||||
longitude,
|
||||
symbol,
|
||||
preferProvidedLocation,
|
||||
}: {
|
||||
raw: string;
|
||||
timestamp: Date;
|
||||
@@ -136,22 +180,33 @@ const buildRecord = ({
|
||||
comment?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
symbol?: string;
|
||||
preferProvidedLocation?: boolean;
|
||||
}): APRSPacketRecord | null => {
|
||||
try {
|
||||
const frame = parseFrame(raw);
|
||||
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 {
|
||||
timestamp,
|
||||
raw,
|
||||
frame,
|
||||
latitude: latitude ?? details.latitude,
|
||||
longitude: longitude ?? details.longitude,
|
||||
latitude: normalized.latitude,
|
||||
longitude: normalized.longitude,
|
||||
altitude: details.altitude,
|
||||
speed: details.speed,
|
||||
course: details.course,
|
||||
comment: comment ?? details.comment,
|
||||
symbol: symbol ?? details.symbol,
|
||||
radioName,
|
||||
hasAPILocation: preferProvidedLocation && hasProvidedLocation,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -171,6 +226,8 @@ const toRecordFromAPI = (packet: FetchedAPRSPacket): APRSPacketRecord | null =>
|
||||
comment: packet.comment,
|
||||
latitude: fromNullableNumber(packet.latitude),
|
||||
longitude: fromNullableNumber(packet.longitude),
|
||||
symbol: packet.symbol,
|
||||
preferProvidedLocation: true,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -188,6 +245,7 @@ interface APRSDataProviderProps {
|
||||
|
||||
export const APRSDataProvider: React.FC<APRSDataProviderProps> = ({ children }) => {
|
||||
const [packets, setPackets] = useState<APRSPacketRecord[]>([]);
|
||||
const [streamReady, setStreamReady] = useState(false);
|
||||
const stream = useMemo(() => new APRSStream(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -213,6 +271,10 @@ export const APRSDataProvider: React.FC<APRSDataProviderProps> = ({ children })
|
||||
fetchPackets();
|
||||
stream.connect();
|
||||
|
||||
const unsubscribeState = stream.subscribeToState((state) => {
|
||||
setStreamReady(state.isConnected);
|
||||
});
|
||||
|
||||
const unsubscribePackets = stream.subscribe<APRSMessage>('aprs/packet/#', (message) => {
|
||||
const record = toRecordFromStream(message);
|
||||
if (!record) {
|
||||
@@ -224,6 +286,7 @@ export const APRSDataProvider: React.FC<APRSDataProviderProps> = ({ children })
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
unsubscribeState();
|
||||
unsubscribePackets();
|
||||
stream.disconnect();
|
||||
};
|
||||
@@ -232,8 +295,9 @@ export const APRSDataProvider: React.FC<APRSDataProviderProps> = ({ children })
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
packets,
|
||||
streamReady,
|
||||
}),
|
||||
[packets]
|
||||
[packets, streamReady]
|
||||
);
|
||||
|
||||
return <APRSDataContext.Provider value={value}>{children}</APRSDataContext.Provider>;
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Badge, Card, Stack, Table, Alert } from 'react-bootstrap';
|
||||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
||||
import { Badge, Card, Stack, Table } from 'react-bootstrap';
|
||||
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 VerticalSplit from '../../components/VerticalSplit';
|
||||
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 type { APRSPacketRecord } from './APRSData';
|
||||
|
||||
@@ -14,7 +23,229 @@ const HeaderFact: React.FC<{ label: string; value: React.ReactNode }> = ({ label
|
||||
</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;
|
||||
|
||||
// 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]];
|
||||
};
|
||||
|
||||
// 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
|
||||
? createBoundsFromCenter(packet.latitude as number, packet.longitude as number)
|
||||
: createBoundsFromCenter(50.0, 5.0);
|
||||
: (defaultBounds ?? [[48.0, 3.0], [52.0, 7.0]]);
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
bounds={bounds}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
className="aprs-map"
|
||||
>
|
||||
<TileLayer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
{hasPosition && (
|
||||
<Marker position={[packet.latitude as number, packet.longitude as number]}>
|
||||
<Popup>
|
||||
<div>
|
||||
<strong>{packet.frame.source.call}</strong>
|
||||
{packet.frame.source.ssid && <span>-{packet.frame.source.ssid}</span>}
|
||||
<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>}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
)}
|
||||
<Card className="data-table-card h-100" style={{ display: 'flex', flexDirection: 'column', padding: 0 }}>
|
||||
<MapContainer
|
||||
bounds={bounds}
|
||||
style={{ flex: 1, width: '100%' }}
|
||||
className="aprs-map"
|
||||
>
|
||||
<MapPanHandler packet={packet} defaultBounds={defaultBounds} />
|
||||
<TileLayer
|
||||
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 && (
|
||||
isValidSymbol(packet.symbol) ? (
|
||||
<Marker
|
||||
position={[packet.latitude as number, packet.longitude as number]}
|
||||
icon={createAPRSIcon(packet.symbol!)}
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const PacketDetailsPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ packet }) => {
|
||||
if (!packet) {
|
||||
return (
|
||||
<Card body className="aprs-detail-card h-100">
|
||||
<Card body className="data-table-card h-100">
|
||||
<h6>Select a packet</h6>
|
||||
<div>Click any packet in the list to view details and map.</div>
|
||||
</Card>
|
||||
@@ -66,36 +357,28 @@ const PacketDetailsPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ pack
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
<HeaderFact label="Timestamp" value={packet.timestamp.toLocaleTimeString()} />
|
||||
<HeaderFact
|
||||
label="Source"
|
||||
value={
|
||||
<>
|
||||
{packet.frame.source.call}
|
||||
{packet.frame.source.ssid && <span>-{packet.frame.source.ssid}</span>}
|
||||
</>
|
||||
}
|
||||
value={<Callsign call={packet.frame.source.call} ssid={packet.frame.source.ssid} />}
|
||||
/>
|
||||
{packet.radioName && <HeaderFact label="Radio" value={packet.radioName} />}
|
||||
<HeaderFact
|
||||
label="Destination"
|
||||
value={
|
||||
<>
|
||||
{packet.frame.destination.call}
|
||||
{packet.frame.destination.ssid && <span>-{packet.frame.destination.ssid}</span>}
|
||||
</>
|
||||
}
|
||||
value={<Callsign call={packet.frame.destination.call} ssid={packet.frame.destination.ssid} />}
|
||||
/>
|
||||
<HeaderFact label="Path" value={packet.frame.path.map((addr) => `${addr.call}${addr.ssid ? `-${addr.ssid}` : ''}${addr.isRepeated ? '*' : ''}`).join(', ') || 'None'} />
|
||||
</Card>
|
||||
|
||||
{(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>
|
||||
{packet.latitude && <HeaderFact label="Latitude" value={packet.latitude.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 && (
|
||||
<Card body className="aprs-detail-card">
|
||||
<Card body className="data-table-card">
|
||||
<h6 className="mb-2">Comment</h6>
|
||||
<div>{packet.comment}</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card body className="aprs-detail-card">
|
||||
<Card body className="data-table-card">
|
||||
<h6 className="mb-2">Raw Data</h6>
|
||||
<code className="aprs-raw-code">{packet.raw}</code>
|
||||
</Card>
|
||||
@@ -122,11 +405,102 @@ const PacketDetailsPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ pack
|
||||
|
||||
const PacketTable: React.FC<{
|
||||
packets: APRSPacketRecord[];
|
||||
selectedIndex: number | null;
|
||||
onSelect: (index: number) => void;
|
||||
}> = ({ packets, selectedIndex, onSelect }) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
radioFilter?: string;
|
||||
selectedPacketKey: string | null;
|
||||
onSelectPacket: (packet: APRSPacketRecord) => void;
|
||||
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> </th>
|
||||
<th>Source</th>
|
||||
<th>Destination</th>
|
||||
<th> </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 [selectedPacketKey, setSelectedPacketKey] = useState<string | null>(null);
|
||||
|
||||
// Filter packets by radio name if specified
|
||||
const filteredPackets = useMemo(() => {
|
||||
@@ -134,83 +508,20 @@ const PacketTable: React.FC<{
|
||||
return packets.filter(packet => packet.radioName === 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(() => {
|
||||
if (selectedIndex === null || selectedIndex < 0 || selectedIndex >= packets.length) {
|
||||
if (!selectedPacketKey) {
|
||||
return null;
|
||||
}
|
||||
return packets[selectedIndex] ?? null;
|
||||
}, [packets, selectedIndex]);
|
||||
return filteredPackets.find((packet) => getPacketKey(packet) === selectedPacketKey) ?? null;
|
||||
}, [filteredPackets, selectedPacketKey]);
|
||||
|
||||
return (
|
||||
<VerticalSplit
|
||||
ratio="50/50"
|
||||
left={<PacketTable packets={packets} selectedIndex={selectedIndex} onSelect={setSelectedIndex} />}
|
||||
ratio="1:1"
|
||||
left={<PacketTable packets={filteredPackets} radioFilter={radioFilter} selectedPacketKey={selectedPacketKey} onSelectPacket={(packet) => setSelectedPacketKey(getPacketKey(packet))} onClearSelection={() => setSelectedPacketKey(null)} streamReady={streamReady} />}
|
||||
right={
|
||||
<HorizontalSplit
|
||||
top={<APRSMapPane packet={selectedPacket} />}
|
||||
top={<APRSMapPane packet={selectedPacket} packets={filteredPackets} onSelectPacket={(packet) => setSelectedPacketKey(getPacketKey(packet))} />}
|
||||
bottom={<PacketDetailsPane packet={selectedPacket} />}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
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 { NodeType, PayloadType, RouteType } from '../../protocols/meshcore.types';
|
||||
import { MeshCoreStream } 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 {
|
||||
MeshCoreDataContext,
|
||||
@@ -40,6 +53,38 @@ export const payloadValueByName = Object.fromEntries(
|
||||
Object.entries(PayloadType).map(([name, value]) => [name, value])
|
||||
) 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(
|
||||
Object.entries(NodeType).map(([name, value]) => [name, value])
|
||||
) as Record<string, number>;
|
||||
@@ -116,61 +161,50 @@ export const routeValueByUrl: Record<string, number> = Object.fromEntries(
|
||||
})
|
||||
) as Record<string, number>;
|
||||
|
||||
export const asHex = (value: Uint8Array): string => bytesToHex(value);
|
||||
const DISCARD_DUPLICATE_PATH_PACKETS = true;
|
||||
|
||||
const payloadTypeList = [
|
||||
PayloadType.REQUEST,
|
||||
PayloadType.RESPONSE,
|
||||
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 getPacketPathKey = (raw: Uint8Array): string => {
|
||||
if (raw.length < 2) {
|
||||
return '';
|
||||
}
|
||||
|
||||
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 => {
|
||||
@@ -187,7 +221,12 @@ const summarizePayload = (payloadType: number, decodedPayload: Payload | undefin
|
||||
case PayloadType.ACK:
|
||||
return 'acknowledgement';
|
||||
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:
|
||||
if (decodedPayload && 'channelHash' in decodedPayload) {
|
||||
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') {
|
||||
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:
|
||||
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[] => {
|
||||
return packets
|
||||
.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 }) => {
|
||||
const [packets, setPackets] = useState<MeshCorePacketRecord[]>(() => createMockData());
|
||||
const [packets, setPackets] = useState<MeshCorePacketRecord[]>([]);
|
||||
const [streamReady, setStreamReady] = useState(false);
|
||||
|
||||
const stream = useMemo(() => new MeshCoreStream(false), []);
|
||||
const meshCoreService = useMemo(() => new MeshCoreServiceImpl(API), []);
|
||||
|
||||
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();
|
||||
|
||||
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)))}`;
|
||||
}
|
||||
|
||||
// Add to front of list, keeping last 500 packets
|
||||
return [packet, ...prev].slice(0, 500);
|
||||
const merged = dedupeByHashAndPath([packet, ...prev]);
|
||||
return merged
|
||||
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
|
||||
.slice(0, 500);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
unsubscribeState();
|
||||
unsubscribePackets();
|
||||
stream.disconnect();
|
||||
};
|
||||
}, [stream]);
|
||||
}, [stream, meshCoreService]);
|
||||
|
||||
const groupChats = useMemo(() => toGroupChats(packets), [packets]);
|
||||
const mapPoints = useMemo(() => toMapPoints(packets), [packets]);
|
||||
|
||||
@@ -7,6 +7,8 @@ import MeshCoreServiceImpl, { type MeshCoreGroupRecord } from '../../services/Me
|
||||
import API from '../../services/API';
|
||||
import type { MeshCoreGroupChatRecord } from './MeshCoreContext';
|
||||
import { KeyManager, Packet } from '../../protocols/meshcore';
|
||||
import KeyboardShortcutsModal from '../../components/KeyboardShortcutsModal';
|
||||
import { useKeyboardListNavigation } from '../../hooks/useKeyboardListNavigation';
|
||||
|
||||
const meshCoreService = new MeshCoreServiceImpl(API);
|
||||
|
||||
@@ -14,30 +16,58 @@ const GroupList: React.FC<{
|
||||
groups: MeshCoreGroupRecord[];
|
||||
selectedGroupId: number | null;
|
||||
onSelectGroup: (group: MeshCoreGroupRecord) => void;
|
||||
onClearSelection: () => void;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
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 (
|
||||
<Card className="meshcore-table-card h-100 d-flex flex-column">
|
||||
<Card.Header className="meshcore-table-header d-flex justify-content-between align-items-center">
|
||||
<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>Groups</span>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
{isLoading && <Spinner animation="border" size="sm" style={{ width: '1rem', height: '1rem' }} />}
|
||||
<StreamStatus ready={streamReady} />
|
||||
</div>
|
||||
</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>}
|
||||
{groups.length === 0 && !isLoading && (
|
||||
<div className="p-3 text-secondary text-center">
|
||||
{error ? 'Failed to load groups' : 'No groups available'}
|
||||
</div>
|
||||
)}
|
||||
<div className="meshcore-table-scroll">
|
||||
<div className="data-table-scroll" ref={scrollRef}>
|
||||
{groups.map((group) => (
|
||||
<div
|
||||
key={group.id}
|
||||
data-nav-item="true"
|
||||
onClick={() => onSelectGroup(group)}
|
||||
className={`list-item ${selectedGroupId === group.id ? 'is-selected' : ''}`}
|
||||
>
|
||||
@@ -50,6 +80,11 @@ const GroupList: React.FC<{
|
||||
))}
|
||||
</div>
|
||||
</Card.Body>
|
||||
<KeyboardShortcutsModal
|
||||
show={showShortcuts}
|
||||
onHide={() => setShowShortcuts(false)}
|
||||
shortcuts={shortcuts}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -63,7 +98,7 @@ const GroupMessagesPane: React.FC<{
|
||||
}> = ({ group, messages, isLoading, error, streamReady }) => {
|
||||
if (!group) {
|
||||
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>
|
||||
<div className="text-secondary text-center">Click a group on the left to view messages</div>
|
||||
</Card>
|
||||
@@ -71,20 +106,20 @@ const GroupMessagesPane: React.FC<{
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="meshcore-table-card h-100 d-flex flex-column">
|
||||
<Card.Header className="meshcore-table-header d-flex justify-content-between align-items-center">
|
||||
<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>{group.name}</span>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
{isLoading && <Spinner animation="border" size="sm" style={{ width: '1rem', height: '1rem' }} />}
|
||||
<StreamStatus ready={streamReady} />
|
||||
</div>
|
||||
</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>}
|
||||
{messages.length === 0 && !isLoading && (
|
||||
<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">
|
||||
{messages.map((message) => (
|
||||
<div key={message.hash + message.timestamp.toISOString()} className="meshcore-message-item">
|
||||
@@ -173,7 +208,7 @@ const MeshCoreGroupChatView: React.FC = () => {
|
||||
const decrypted = keyManagerRef.current.decryptGroup(
|
||||
packet.channel_hash,
|
||||
payload.cipherText as Uint8Array,
|
||||
payload.cipherMAC as Uint8Array
|
||||
payload.cipherMAC
|
||||
);
|
||||
|
||||
messages.push({
|
||||
@@ -233,6 +268,7 @@ const MeshCoreGroupChatView: React.FC = () => {
|
||||
groups={groups}
|
||||
selectedGroupId={selectedGroup?.id ?? null}
|
||||
onSelectGroup={handleSelectGroup}
|
||||
onClearSelection={() => setSelectedGroup(null)}
|
||||
isLoading={isLoadingGroups}
|
||||
error={groupsError}
|
||||
streamReady={streamReady}
|
||||
|
||||
@@ -9,12 +9,12 @@ const MeshCoreMapView: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Full className="meshcore-map-view">
|
||||
<Card className="meshcore-table-card h-100 d-flex flex-column">
|
||||
<Card.Header className="meshcore-table-header d-flex justify-content-between align-items-center">
|
||||
<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>Node Map</span>
|
||||
<Badge bg={streamReady ? 'success' : 'secondary'}>{streamReady ? 'stream ready' : 'stream offline'}</Badge>
|
||||
</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">
|
||||
{mapPoints.map((point) => (
|
||||
<div
|
||||
@@ -29,8 +29,8 @@ const MeshCoreMapView: React.FC = () => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="meshcore-table-scroll">
|
||||
<Table responsive size="sm" className="meshcore-table mb-0">
|
||||
<div className="data-table-scroll">
|
||||
<Table responsive size="sm" className="data-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
|
||||
424
ui/src/pages/meshcore/MeshCorePacketDetailsPane.tsx
Normal 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;
|
||||
194
ui/src/pages/meshcore/MeshCorePacketFilters.tsx
Normal 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;
|
||||
172
ui/src/pages/meshcore/MeshCorePacketRows.tsx
Normal 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;
|
||||
243
ui/src/pages/meshcore/MeshCorePacketTable.tsx
Normal 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;
|
||||