diff --git a/AGENTS.md b/AGENTS.md index d965fe8..ff8393f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/asset/image/protocol/aprs/COPYRIGHT.md b/asset/image/protocol/aprs/COPYRIGHT.md new file mode 100644 index 0000000..8c5dd3a --- /dev/null +++ b/asset/image/protocol/aprs/COPYRIGHT.md @@ -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 diff --git a/asset/image/protocol/aprs/aprs-symbols-128-0.png b/asset/image/protocol/aprs/aprs-symbols-128-0.png new file mode 100644 index 0000000..0601d63 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-128-0.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-128-0@2x.png b/asset/image/protocol/aprs/aprs-symbols-128-0@2x.png new file mode 100644 index 0000000..0b20f56 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-128-0@2x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-128-0@3x.png b/asset/image/protocol/aprs/aprs-symbols-128-0@3x.png new file mode 100644 index 0000000..28d0309 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-128-0@3x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-128-1.png b/asset/image/protocol/aprs/aprs-symbols-128-1.png new file mode 100644 index 0000000..a0ca0a7 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-128-1.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-128-1@2x.png b/asset/image/protocol/aprs/aprs-symbols-128-1@2x.png new file mode 100644 index 0000000..758a3a0 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-128-1@2x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-128-1@3x.png b/asset/image/protocol/aprs/aprs-symbols-128-1@3x.png new file mode 100644 index 0000000..b254a22 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-128-1@3x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-128-2.png b/asset/image/protocol/aprs/aprs-symbols-128-2.png new file mode 100644 index 0000000..867cbd9 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-128-2.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-128-2@2x.png b/asset/image/protocol/aprs/aprs-symbols-128-2@2x.png new file mode 100644 index 0000000..9a4084a Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-128-2@2x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-128-2@3x.png b/asset/image/protocol/aprs/aprs-symbols-128-2@3x.png new file mode 100644 index 0000000..c53e5ce Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-128-2@3x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-24-0.png b/asset/image/protocol/aprs/aprs-symbols-24-0.png new file mode 100644 index 0000000..8a2713f Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-24-0.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-24-0@2x.png b/asset/image/protocol/aprs/aprs-symbols-24-0@2x.png new file mode 100644 index 0000000..d6c15bb Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-24-0@2x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-24-0@3x.png b/asset/image/protocol/aprs/aprs-symbols-24-0@3x.png new file mode 100644 index 0000000..9743f6c Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-24-0@3x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-24-1.png b/asset/image/protocol/aprs/aprs-symbols-24-1.png new file mode 100644 index 0000000..10126ef Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-24-1.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-24-1@2x.png b/asset/image/protocol/aprs/aprs-symbols-24-1@2x.png new file mode 100644 index 0000000..f7e1a78 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-24-1@2x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-24-1@3x.png b/asset/image/protocol/aprs/aprs-symbols-24-1@3x.png new file mode 100644 index 0000000..3b63473 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-24-1@3x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-24-2.png b/asset/image/protocol/aprs/aprs-symbols-24-2.png new file mode 100644 index 0000000..c1dfa98 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-24-2.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-24-2@2x.png b/asset/image/protocol/aprs/aprs-symbols-24-2@2x.png new file mode 100644 index 0000000..ac8918a Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-24-2@2x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-24-2@3x.png b/asset/image/protocol/aprs/aprs-symbols-24-2@3x.png new file mode 100644 index 0000000..a87f13e Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-24-2@3x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-256-0.png b/asset/image/protocol/aprs/aprs-symbols-256-0.png new file mode 100644 index 0000000..f87a2c2 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-256-0.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-256-0@2x.png b/asset/image/protocol/aprs/aprs-symbols-256-0@2x.png new file mode 100644 index 0000000..ee424c7 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-256-0@2x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-256-0@3x.png b/asset/image/protocol/aprs/aprs-symbols-256-0@3x.png new file mode 100644 index 0000000..e78425d Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-256-0@3x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-256-1.png b/asset/image/protocol/aprs/aprs-symbols-256-1.png new file mode 100644 index 0000000..758a3a0 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-256-1.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-256-1@2x.png b/asset/image/protocol/aprs/aprs-symbols-256-1@2x.png new file mode 100644 index 0000000..64adb6e Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-256-1@2x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-256-1@3x.png b/asset/image/protocol/aprs/aprs-symbols-256-1@3x.png new file mode 100644 index 0000000..c3f77f5 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-256-1@3x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-256-2.png b/asset/image/protocol/aprs/aprs-symbols-256-2.png new file mode 100644 index 0000000..9a4084a Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-256-2.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-256-2@2x.png b/asset/image/protocol/aprs/aprs-symbols-256-2@2x.png new file mode 100644 index 0000000..579b962 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-256-2@2x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-256-2@3x.png b/asset/image/protocol/aprs/aprs-symbols-256-2@3x.png new file mode 100644 index 0000000..66ecd2e Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-256-2@3x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-32-0.png b/asset/image/protocol/aprs/aprs-symbols-32-0.png new file mode 100644 index 0000000..130fd06 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-32-0.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-32-0@2x.png b/asset/image/protocol/aprs/aprs-symbols-32-0@2x.png new file mode 100644 index 0000000..a47d80e Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-32-0@2x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-32-0@3x.png b/asset/image/protocol/aprs/aprs-symbols-32-0@3x.png new file mode 100644 index 0000000..8f1c952 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-32-0@3x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-32-1.png b/asset/image/protocol/aprs/aprs-symbols-32-1.png new file mode 100644 index 0000000..06f5cb4 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-32-1.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-32-1@2x.png b/asset/image/protocol/aprs/aprs-symbols-32-1@2x.png new file mode 100644 index 0000000..fc3f08a Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-32-1@2x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-32-1@3x.png b/asset/image/protocol/aprs/aprs-symbols-32-1@3x.png new file mode 100644 index 0000000..f904b1b Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-32-1@3x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-32-2.png b/asset/image/protocol/aprs/aprs-symbols-32-2.png new file mode 100644 index 0000000..8b9cab9 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-32-2.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-32-2@2x.png b/asset/image/protocol/aprs/aprs-symbols-32-2@2x.png new file mode 100644 index 0000000..422aa54 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-32-2@2x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-32-2@3x.png b/asset/image/protocol/aprs/aprs-symbols-32-2@3x.png new file mode 100644 index 0000000..c3849ee Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-32-2@3x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-48-0.png b/asset/image/protocol/aprs/aprs-symbols-48-0.png new file mode 100644 index 0000000..79e6ef1 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-48-0.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-48-0@2x.png b/asset/image/protocol/aprs/aprs-symbols-48-0@2x.png new file mode 100644 index 0000000..7a73045 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-48-0@2x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-48-0@3x.png b/asset/image/protocol/aprs/aprs-symbols-48-0@3x.png new file mode 100644 index 0000000..f1c46df Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-48-0@3x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-48-1.png b/asset/image/protocol/aprs/aprs-symbols-48-1.png new file mode 100644 index 0000000..f7e1a78 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-48-1.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-48-1@2x.png b/asset/image/protocol/aprs/aprs-symbols-48-1@2x.png new file mode 100644 index 0000000..f904b1b Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-48-1@2x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-48-1@3x.png b/asset/image/protocol/aprs/aprs-symbols-48-1@3x.png new file mode 100644 index 0000000..7c4be2d Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-48-1@3x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-48-2.png b/asset/image/protocol/aprs/aprs-symbols-48-2.png new file mode 100644 index 0000000..ac8918a Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-48-2.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-48-2@2x.png b/asset/image/protocol/aprs/aprs-symbols-48-2@2x.png new file mode 100644 index 0000000..c3849ee Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-48-2@2x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-48-2@3x.png b/asset/image/protocol/aprs/aprs-symbols-48-2@3x.png new file mode 100644 index 0000000..90e3f85 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-48-2@3x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-56-0.png b/asset/image/protocol/aprs/aprs-symbols-56-0.png new file mode 100644 index 0000000..ad6d4e4 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-56-0.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-56-0@2x.png b/asset/image/protocol/aprs/aprs-symbols-56-0@2x.png new file mode 100644 index 0000000..793c880 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-56-0@2x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-56-0@3x.png b/asset/image/protocol/aprs/aprs-symbols-56-0@3x.png new file mode 100644 index 0000000..ffcf17e Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-56-0@3x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-56-1.png b/asset/image/protocol/aprs/aprs-symbols-56-1.png new file mode 100644 index 0000000..1503e81 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-56-1.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-56-1@2x.png b/asset/image/protocol/aprs/aprs-symbols-56-1@2x.png new file mode 100644 index 0000000..a9e0392 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-56-1@2x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-56-1@3x.png b/asset/image/protocol/aprs/aprs-symbols-56-1@3x.png new file mode 100644 index 0000000..5b9bb26 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-56-1@3x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-56-2.png b/asset/image/protocol/aprs/aprs-symbols-56-2.png new file mode 100644 index 0000000..a9f08f3 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-56-2.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-56-2@2x.png b/asset/image/protocol/aprs/aprs-symbols-56-2@2x.png new file mode 100644 index 0000000..bb1aa48 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-56-2@2x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-56-2@3x.png b/asset/image/protocol/aprs/aprs-symbols-56-2@3x.png new file mode 100644 index 0000000..9574a58 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-56-2@3x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-64-0.png b/asset/image/protocol/aprs/aprs-symbols-64-0.png new file mode 100644 index 0000000..b8278b3 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-64-0.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-64-0@2x.png b/asset/image/protocol/aprs/aprs-symbols-64-0@2x.png new file mode 100644 index 0000000..e314445 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-64-0@2x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-64-0@3x.png b/asset/image/protocol/aprs/aprs-symbols-64-0@3x.png new file mode 100644 index 0000000..d9a2513 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-64-0@3x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-64-1.png b/asset/image/protocol/aprs/aprs-symbols-64-1.png new file mode 100644 index 0000000..fc3f08a Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-64-1.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-64-1@2x.png b/asset/image/protocol/aprs/aprs-symbols-64-1@2x.png new file mode 100644 index 0000000..a0ca0a7 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-64-1@2x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-64-1@3x.png b/asset/image/protocol/aprs/aprs-symbols-64-1@3x.png new file mode 100644 index 0000000..28736eb Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-64-1@3x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-64-2.png b/asset/image/protocol/aprs/aprs-symbols-64-2.png new file mode 100644 index 0000000..422aa54 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-64-2.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-64-2@2x.png b/asset/image/protocol/aprs/aprs-symbols-64-2@2x.png new file mode 100644 index 0000000..867cbd9 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-64-2@2x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-64-2@3x.png b/asset/image/protocol/aprs/aprs-symbols-64-2@3x.png new file mode 100644 index 0000000..883f1da Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-64-2@3x.png differ diff --git a/asset/image/protocol/aprs/aprs-symbols-64-droid.png b/asset/image/protocol/aprs/aprs-symbols-64-droid.png new file mode 100644 index 0000000..269d689 Binary files /dev/null and b/asset/image/protocol/aprs/aprs-symbols-64-droid.png differ diff --git a/docs/CallSignSeriesRanges-72c24bb4-4f0a-4c57-84e9-0670e5b03428.xlsx b/docs/CallSignSeriesRanges-72c24bb4-4f0a-4c57-84e9-0670e5b03428.xlsx new file mode 100644 index 0000000..05d0177 Binary files /dev/null and b/docs/CallSignSeriesRanges-72c24bb4-4f0a-4c57-84e9-0670e5b03428.xlsx differ diff --git a/ui/AGENTS.md b/ui/AGENTS.md index 39a7a49..9792f40 100644 --- a/ui/AGENTS.md +++ b/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. diff --git a/ui/package-lock.json b/ui/package-lock.json index 99d2113..2fbb9ed 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -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", diff --git a/ui/package.json b/ui/package.json index 6f14dbb..92b0a55 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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" } } diff --git a/ui/src/App.scss b/ui/src/App.scss index e2fb01c..a4081af 100644 --- a/ui/src/App.scss +++ b/ui/src/App.scss @@ -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; diff --git a/ui/src/App.tsx b/ui/src/App.tsx index d9237cb..85ea28e 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -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 ( - - - - - } /> - } /> - - - } /> - } /> - } /> - } /> - - } /> - } /> - - + + + + + + } /> + } /> + + + } /> + } /> + } /> + } /> + + } /> + } /> + + + ) } diff --git a/ui/src/components/CountryFlag.tsx b/ui/src/components/CountryFlag.tsx new file mode 100644 index 0000000..23f86a9 --- /dev/null +++ b/ui/src/components/CountryFlag.tsx @@ -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 = ({ + callsign, + size = 1.5, + className = '', + showCountryCode = false, + style = {}, +}) => { + const countryCode = getCountryCodeFromCallsign(callsign); + + if (!countryCode) { + // Return a placeholder if country cannot be determined + return ( + + 🏴 + + ); + } + + return ( + + + {showCountryCode && {countryCode}} + + ); +}; + +export default CountryFlag; diff --git a/ui/src/components/KeyboardShortcutsModal.tsx b/ui/src/components/KeyboardShortcutsModal.tsx new file mode 100644 index 0000000..89af712 --- /dev/null +++ b/ui/src/components/KeyboardShortcutsModal.tsx @@ -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 = ({ + show, + onHide, + shortcuts, + title = 'Keyboard Controls', +}) => { + return ( + + + {title} + + + + + + + + + + + {shortcuts.map((shortcut) => ( + + + + + ))} + +
KeyAction
{shortcut.keys}{shortcut.description}
+
+
+ ); +}; + +export default KeyboardShortcutsModal; diff --git a/ui/src/components/Layout.scss b/ui/src/components/Layout.scss index 1f43bfe..b57b883 100644 --- a/ui/src/components/Layout.scss +++ b/ui/src/components/Layout.scss @@ -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 { diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index b5efba8..2e11400 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -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 = ({ 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 = ({ - + @@ -54,6 +76,12 @@ const Layout: React.FC = ({ {children || } + setShowGlobalShortcuts(false)} + shortcuts={defaultKeyboardShortcuts} + /> +

© {new Date().getFullYear()} PD0MZ. All rights reserved.

diff --git a/ui/src/components/VerticalSplit.tsx b/ui/src/components/VerticalSplit.tsx index 98b5b02..ae48025 100644 --- a/ui/src/components/VerticalSplit.tsx +++ b/ui/src/components/VerticalSplit.tsx @@ -5,13 +5,14 @@ import type { VerticalSplitProps } from '../types/layout.types'; const VerticalSplit: React.FC = ({ 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 ( diff --git a/ui/src/components/aprs/APRSSymbol.scss b/ui/src/components/aprs/APRSSymbol.scss new file mode 100644 index 0000000..b4e66ef --- /dev/null +++ b/ui/src/components/aprs/APRSSymbol.scss @@ -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 + } +} diff --git a/ui/src/components/aprs/APRSSymbol.tsx b/ui/src/components/aprs/APRSSymbol.tsx new file mode 100644 index 0000000..5b446c7 --- /dev/null +++ b/ui/src/components/aprs/APRSSymbol.tsx @@ -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 = ({ + 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
; + } + + 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
; + } + + 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 ( +
+ ); +}; + +export default APRSSymbol; diff --git a/ui/src/components/aprs/index.ts b/ui/src/components/aprs/index.ts new file mode 100644 index 0000000..c6a389f --- /dev/null +++ b/ui/src/components/aprs/index.ts @@ -0,0 +1 @@ +export { APRSSymbol, type APRSSymbolProps } from './APRSSymbol'; diff --git a/ui/src/components/map/ClusteredMarkers.tsx b/ui/src/components/map/ClusteredMarkers.tsx new file mode 100644 index 0000000..30867a0 --- /dev/null +++ b/ui/src/components/map/ClusteredMarkers.tsx @@ -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 { + position: [number, number]; + items: T[]; + count: number; + isOffset?: boolean; +} + +interface ClusteredMarkersProps { + items: T[]; + getItemKey: (item: T) => string; + onItemClick?: (item: T) => void; + renderMarker?: (item: T) => React.ReactNode; + renderClusterMarker?: (cluster: Cluster) => React.ReactNode; + getIcon?: (item: T) => Icon | DivIcon | null; + clusterRadius?: number; // pixels + maxZoom?: number; + showPopups?: boolean; + renderPopupContent?: (item: T) => React.ReactNode; + renderClusterPopupContent?: (cluster: Cluster) => 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 = ( + items: T[], + zoom: number, + maxZoom: number = 18, + clusterRadius: number = 40 +): Cluster[] => { + // 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[] = []; + const processed = new Set(); + + 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 = { + 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 = ` +
${count}
+ `; + + 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({ + items, + getItemKey, + onItemClick, + renderMarker, + renderClusterMarker, + getIcon, + clusterRadius = 40, + maxZoom: propMaxZoom, + showPopups = true, + renderPopupContent, + renderClusterPopupContent, +}: ClusteredMarkersProps) { + 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) => { + 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 {renderClusterMarker(cluster)}; + } + + return ( + handleClusterClick(cluster), + }} + > + {showPopups && ( + + {renderClusterPopupContent ? ( + renderClusterPopupContent(cluster) + ) : ( +
+ {cluster.count} items +
+ Click to zoom in +
+
+ )} +
+ )} +
+ ); + } else { + // Render individual marker + const item = cluster.items[0]; + + if (renderMarker) { + return {renderMarker(item)}; + } + + const icon = getIcon?.(item); + + if (icon) { + return ( + onItemClick?.(item), + }} + > + {showPopups && renderPopupContent && ( + {renderPopupContent(item)} + )} + + ); + } + + // Default fallback: blue circle + return ( + onItemClick?.(item), + }} + > + {showPopups && renderPopupContent && ( + {renderPopupContent(item)} + )} + + ); + } + })} + + ); +} + +export default ClusteredMarkers; diff --git a/ui/src/components/map/index.ts b/ui/src/components/map/index.ts new file mode 100644 index 0000000..2d01282 --- /dev/null +++ b/ui/src/components/map/index.ts @@ -0,0 +1 @@ +export { ClusteredMarkers, type ClusterableItem, type Cluster } from './ClusteredMarkers'; diff --git a/ui/src/contexts/KeyboardNavigationContext.tsx b/ui/src/contexts/KeyboardNavigationContext.tsx new file mode 100644 index 0000000..7f82102 --- /dev/null +++ b/ui/src/contexts/KeyboardNavigationContext.tsx @@ -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(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 ( + + {children} + + ); +}; + +export const useKeyboardNavigationActivity = (): KeyboardNavigationContextValue => { + const context = React.useContext(KeyboardNavigationContext); + + if (!context) { + throw new Error('useKeyboardNavigationActivity must be used within a KeyboardNavigationProvider'); + } + + return context; +}; diff --git a/ui/src/hooks/useKeyboardListNavigation.ts b/ui/src/hooks/useKeyboardListNavigation.ts new file mode 100644 index 0000000..47591fa --- /dev/null +++ b/ui/src/hooks/useKeyboardListNavigation.ts @@ -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; + 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(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(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; diff --git a/ui/src/libs/callsignMapper.test.ts b/ui/src/libs/callsignMapper.test.ts new file mode 100644 index 0000000..28cef19 --- /dev/null +++ b/ui/src/libs/callsignMapper.test.ts @@ -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 + }); +}); diff --git a/ui/src/libs/callsignMapper.ts b/ui/src/libs/callsignMapper.ts new file mode 100644 index 0000000..ae82a24 --- /dev/null +++ b/ui/src/libs/callsignMapper.ts @@ -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 = { + '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 = { + // 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 = { + '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 | 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; +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 6f6f799..5716bf2 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -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( diff --git a/ui/src/pages/APRS.scss b/ui/src/pages/APRS.scss index d23b06b..898baf5 100644 --- a/ui/src/pages/APRS.scss +++ b/ui/src/pages/APRS.scss @@ -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; + } } diff --git a/ui/src/pages/APRS.tsx b/ui/src/pages/APRS.tsx index 9c5738a..6a0ff96 100644 --- a/ui/src/pages/APRS.tsx +++ b/ui/src/pages/APRS.tsx @@ -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 = ({ navLinks = [] }) => { const viewButtons = ( - + ); diff --git a/ui/src/pages/MeshCore.scss b/ui/src/pages/MeshCore.scss index 93b905d..772a895 100644 --- a/ui/src/pages/MeshCore.scss +++ b/ui/src/pages/MeshCore.scss @@ -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; diff --git a/ui/src/pages/MeshCore.tsx b/ui/src/pages/MeshCore.tsx index b438410..2e6852a 100644 --- a/ui/src/pages/MeshCore.tsx +++ b/ui/src/pages/MeshCore.tsx @@ -20,27 +20,27 @@ const MeshCore: React.FC = ({ navLinks = [] }) => { - - Packets + + Packets - - Chat + + Chat - - Map + + Map ); diff --git a/ui/src/pages/aprs/APRSData.tsx b/ui/src/pages/aprs/APRSData.tsx index f20300d..c128e3a 100644 --- a/ui/src/pages/aprs/APRSData.tsx +++ b/ui/src/pages/aprs/APRSData.tsx @@ -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(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(); 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 = ({ children }) => { const [packets, setPackets] = useState([]); + const [streamReady, setStreamReady] = useState(false); const stream = useMemo(() => new APRSStream(false), []); useEffect(() => { @@ -213,6 +271,10 @@ export const APRSDataProvider: React.FC = ({ children }) fetchPackets(); stream.connect(); + const unsubscribeState = stream.subscribeToState((state) => { + setStreamReady(state.isConnected); + }); + const unsubscribePackets = stream.subscribe('aprs/packet/#', (message) => { const record = toRecordFromStream(message); if (!record) { @@ -224,6 +286,7 @@ export const APRSDataProvider: React.FC = ({ children }) return () => { isMounted = false; + unsubscribeState(); unsubscribePackets(); stream.disconnect(); }; @@ -232,8 +295,9 @@ export const APRSDataProvider: React.FC = ({ children }) const value = useMemo( () => ({ packets, + streamReady, }), - [packets] + [packets, streamReady] ); return {children}; diff --git a/ui/src/pages/aprs/APRSPacketsView.tsx b/ui/src/pages/aprs/APRSPacketsView.tsx index 870b000..9c35894 100644 --- a/ui/src/pages/aprs/APRSPacketsView.tsx +++ b/ui/src/pages/aprs/APRSPacketsView.tsx @@ -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
); -const APRSMapPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ packet }) => { +const Callsign: React.FC<{ call: string; ssid?: string | number; plain?: boolean }> = ({ call, ssid, plain = false }) => ( + + {call} + {(ssid !== undefined && ssid !== '') ? -{ssid} : null} + +); + +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( + + ); + + 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(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) => ( +
+ +
+ Lat: {p.latitude?.toFixed(4)}, Lon: {p.longitude?.toFixed(4)} + {p.altitude &&
Alt: {p.altitude.toFixed(0)}m
} + {p.speed &&
Speed: {p.speed}kt
} + {p.comment &&
{p.comment}
} +
+); + +/** + * Render popup content for a cluster + */ +const renderClusterPopup = (cluster: Cluster) => { + const packets = cluster.items as APRSPacketRecord[]; + return ( +
+ {cluster.count} stations +
+ {packets.map((p, i) => ( +
+ +
+ ))} +
+
+ Click to zoom in +
+
+ ); +}; + +/** + * 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(); + 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 ( - - - {hasPosition && ( - - -
- {packet.frame.source.call} - {packet.frame.source.ssid && -{packet.frame.source.ssid}} -
- Lat: {packet.latitude?.toFixed(4)}, Lon: {packet.longitude?.toFixed(4)} - {packet.altitude &&
Alt: {packet.altitude.toFixed(0)}m
} - {packet.speed &&
Speed: {packet.speed}kt
} -
-
-
- )} + + + + + {/* Clustered markers for latest location per source */} + + 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) ? ( + + +
+ +
+ Lat: {packet.latitude?.toFixed(4)}, Lon: {packet.longitude?.toFixed(4)} + {packet.altitude &&
Alt: {packet.altitude.toFixed(0)}m
} + {packet.speed &&
Speed: {packet.speed}kt
} + {packet.comment &&
{packet.comment}
} +
+
+
+ ) : ( + + +
+ +
+ Lat: {packet.latitude?.toFixed(4)}, Lon: {packet.longitude?.toFixed(4)} + {packet.altitude &&
Alt: {packet.altitude.toFixed(0)}m
} + {packet.speed &&
Speed: {packet.speed}kt
} + {packet.comment &&
{packet.comment}
} +
+
+
+ ) + )}
+
); }; const PacketDetailsPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ packet }) => { if (!packet) { return ( - +
Select a packet
Click any packet in the list to view details and map.
@@ -66,36 +357,28 @@ const PacketDetailsPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ pack return ( - +
Packet Details
- {packet.frame.source.call} + + +
- {packet.frame.source.call} - {packet.frame.source.ssid && -{packet.frame.source.ssid}} - - } + value={} /> {packet.radioName && } - {packet.frame.destination.call} - {packet.frame.destination.ssid && -{packet.frame.destination.ssid}} - - } + value={} /> `${addr.call}${addr.ssid ? `-${addr.ssid}` : ''}${addr.isRepeated ? '*' : ''}`).join(', ') || 'None'} />
{(packet.latitude || packet.longitude || packet.altitude || packet.speed || packet.course) && ( - +
Position Data
{packet.latitude && } {packet.longitude && } @@ -106,13 +389,13 @@ const PacketDetailsPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ pack )} {packet.comment && ( - +
Comment
{packet.comment}
)} - +
Raw Data
{packet.raw}
@@ -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(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 ( + + + APRS Packets +
+ +
+
+ +
+ + + + + + + + + + + + + {packets.map((packet, index) => ( + onSelectPacket(packet)} + > + + + + + + + + ))} + +
Time SourceDestination Comment
{packet.timestamp.toLocaleTimeString()} + + + + + + + {packet.symbol && } + {packet.comment || '-'}
+
+
+ {radioFilter && Filtered by radio: {radioFilter}} + setShowShortcuts(false)} + shortcuts={shortcuts} + /> +
+ ); +}; + +const APRSPacketsView: React.FC = () => { + const { packets, streamReady } = useAPRSData(); + const [searchParams] = useSearchParams(); const radioFilter = searchParams.get('radio') || undefined; + const [selectedPacketKey, setSelectedPacketKey] = useState(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 ( - - APRS Packets - - {radioFilter && ( - - Filtering by radio: {radioFilter} -