Compare commits

...
Author SHA1 Message Date
sjg 23cc0db1fa [fix](trx-frontend-http): drop the tabs to icons when the bar runs out of room
CI / test (push) Successful in 8m15s
CI / frontend (push) Failing after 31s
CI / reuse (push) Successful in 2s
CI / lint (push) Successful in 2m17s
CI put the tab strip 9px into the controls at 1440px on a run that
passed locally: its system font is wider, and the bar had no move left
to make.  Controls are moved into the overflow menu until the bar fits,
but once all of them were in the menu nothing else gave — the nav may
shrink below its content, so the tabs kept full width and ran under the
controls, leaving the destinations nearest them unclickable.  Labels
dropping to icons was the other half of the answer, but it hung off a
max-width:1360px media query and so was unavailable at 1440px.

That class now goes on by measurement, as the last step after the menu
is exhausted, which is the same reasoning the controls' own fit test
already uses: how much fits depends on the rig name and on how wide the
platform draws the labels, not on the viewport.  The class is cleared
before measuring so the decision cannot ratchet, and icon widths are
fixed, so it always buys back the labels' width.

Labels now stay put between 1100px and 1360px while they fit, with the
style picker and theme toggle behind the overflow menu instead.

The suite could not have caught this: it passed on the fonts of the
machine that wrote it.  The layout section now repeats its fit check
with the bar's text scaled up, which reproduces a wider system font
anywhere — with this fix reverted it fails on macOS too.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 21:36:55 +02:00
sjg 657f952a2c [fix](trx-frontend-http): hold the strips' place above the spectrum
Tuning across a band edge moved the whole page under the cursor: the
band plan strip is in flow, so a range with no allocations collapsed it
from 18px to nothing and dragged every element below it up (measured at
1600x950: overview top 118 to 100, footer 1026 to 1008).  It now keeps
its height whenever a band plan could be drawn at all, and only gives it
back when the feature is off, has no data, or there is no spectrum.

The bookmark rail gets the same treatment for consistency, though it
never moved anything — it is absolutely positioned over the top of the
overview.  It stays up and blank rather than vanishing.

Which exposes something the rail was already doing wrong: it covers the
top of the plot, and a bare div still takes pointer events, so whenever
bookmarks were in range that band of the overview could not be dragged
or scrolled.  Only the chips are targets now.

tests/spectrum-layout.mjs covers this: it streams spectrum frames, tunes
between a band with bookmarks and allocations and one with neither, and
asserts nothing moves and that the rail lets clicks through.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 21:26:19 +02:00
sjg 1f256cbb68 [refactor](trx-frontend-http): extract the browser test fixture
browser-smoke.mjs carried its static server inline, which made it the
only browser test that could exist: a second one would have had to copy
180 lines of routes to change a single capability flag.  The server
moves to tests/web-fixture.mjs behind startWebFixture(), with the rig's
spectrum support, bookmarks and band plan as options.

Serving a rig with a spectrum matters because that is where the layout
actually lives — the panel, the strips above it and the waterfall are
all gated on filter_controls, and the existing fixture reports a
CAT-only rig, so none of it has ever been rendered under test.

No change to what the smoke test checks.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 21:26:06 +02:00
sjg 92fbdb692c [feat](trx-frontend-http): lay the scheduler controls out in one row
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 8m12s
CI / frontend (push) Failing after 29s
CI / reuse (push) Successful in 3s
The controls were a column — release, then the step buttons, then the
status line, then the entry on air last — which read bottom-up and left
the entry that is actually transmitting furthest from the buttons that
change it.  They now run left to right: step through the entries, hand
the rig back, then the current entry behind a separator.

The separator is a pseudo-element on the current-entry block rather than
an element of its own, because that block is display-toggled whenever
fewer than two entries are active; a standalone rule would be left
hanging with nothing after it.

No ids move, so the enable/disable logic in the scheduler plugin and the
release polling in vchan bind exactly as before.  The smoke test asserts
the row's order, which is also what keeps the separator in place.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 20:58:31 +02:00
sjg b2fbcb318d [fix](trx-frontend-http): stop Tools lighting up on every refresh
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 8m21s
CI / frontend (push) Failing after 30s
CI / reuse (push) Successful in 3s
navigateToTab marked the Tools button by asking whether the destination
tab was displayed, which is the right question at the wrong moment: the
first route navigation runs while the card is still behind the loading
state, where every tab computes to display:none.  Refreshing or deep
linking to any page therefore lit Tools alongside the real destination,
and nothing re-evaluated it once the page appeared.

Membership of the Tools menu answers the same question without needing
anything laid out, and still reads the grouping ui-core installs rather
than a second copy of it.

The smoke fixture now serves the SPA shell for route paths the way the
server's per-tab index handlers do, so a deep link no longer 404s and
the case is testable at all; two of them are asserted.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 20:51:04 +02:00
sjg 44721b579c [fix](trx-frontend-http): fill the map column down to the footer
The windowed map was capped at 75% of the viewport height and at a
width-derived aspect ratio, which left a dead band under it: 69px at
1600x950, and on a 420px-wide phone a 270px map on an 800px screen.
Neither cap was doing useful work now that the stage spans the full
width, so the map fills the column down to the footer instead.

Growing into the footer needs a bound: once the column is tall enough to
push the footer below the fold, using its position would push it further
on every pass, so the bottom edge is clamped to the viewport.  Growth
then consumes the column's spare height and settles in one pass.

Also drops three mapIsFullscreen() branches in the windowed path that
could never be taken — the fullscreen case returns above them.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 20:50:48 +02:00
sjg 889d00007d [feat](trx-frontend-http): box the selected tab instead of underlining it
CI / lint (push) Successful in 2m23s
CI / test (push) Successful in 8m19s
CI / frontend (push) Successful in 3m10s
CI / reuse (push) Successful in 3s
The desktop strip marked the current page with a 2px underline while the
mobile bottom nav already boxed it, so one navigation model looked like
two.  The box now sits on both: a transparent 1px border on the base
reserves it, so switching pages moves no neighbours, and hover fills a
fainter version of the same shape.  Tools carries it too — that button
is marked active for the destinations the strip hides.

Dropping the mobile rule's border-bottom:none, which only existed to
cancel the old desktop underline, closes the bottom edge its active box
had been missing.  The smoke test checks all four edges.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 20:37:31 +02:00
sjg 6f53592041 [feat](trx-frontend-http): rework the page footer
The footer floated in space below the content with no rule to close the
page, its two clusters sat on a text baseline that left the source pill
hanging, and the status hint was a plain line of text a size larger than
the attribution beside it.

Now a hairline closes the page the way .tab-bar opens it, the clusters
centre on one line, and the attribution drops the opacity it stacked on
top of --text-muted, which had put it below a readable contrast ratio.

The status hint becomes a pill with a state dot: green when ready, amber
while a command is in flight, red on connection loss.  The colour comes
from a data-state attribute, so every hint now goes through setPowerHint
instead of assigning textContent directly.  --status-ok carries the
indicator green; .about-status-on picks it up too, which darkens it on
light themes where the old value was barely legible.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 20:37:12 +02:00
sjg 709939f60b [feat](trx-frontend-http): span the map across the full viewport width
The map stage broke out of the centred .card column: negative inline
margins cancel the card's centring offset and its side padding, so the
stage reaches both viewport edges at every width without hardcoding
either value.  Its rounded corners and left/right borders go with it —
edge to edge, the panel reads as a band rather than a floating card.

The browser smoke test now measures the stage against the viewport, and
checks that the full-bleed width does not push the page sideways.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 20:36:50 +02:00
sjgandClaude Opus 5 5b7dd493d4 [feat](trx-frontend-http): draw APRS symbols from the sprite sheets
CI / lint (pull_request) Successful in 2m16s
CI / test (pull_request) Successful in 8m12s
CI / frontend (pull_request) Successful in 3m2s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m24s
CI / test (push) Successful in 7m28s
CI / frontend (push) Successful in 2m11s
CI / reuse (push) Successful in 3s
Resolve a table/code pair to a sprite cell in aprs-shared, and use it
from both the packet lists and the map markers, which had each been
printing the raw symbol character in a bordered box.

A table identifier of / or \ selects the primary or alternate sheet
directly.  Anything else is an overlay character, which the APRS spec
draws on top of the alternate symbol -- so those stack the overlay sheet
over the alternate one rather than picking a sheet.  Codes outside
0x21..0x7E have no cell and keep the old character box.

The sheet URLs stay in the stylesheet so a min-resolution query can swap
in the retina sheets; only the cell offset is computed and set inline.
Map markers share the helper through the plugin chunk, so the map stays
free of any remote symbol fetch.

Verified in a browser against the real stylesheet and sheets: /> is a
car, /_ a WX circle, /& an igate diamond, \n a red triangle, and the
overlays S> and 7# carry their character on the alternate symbol.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018huL1ELyr86yVqfAabtioA
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 19:55:50 +02:00
sjgandClaude Opus 5 02fe492dbf [feat](trx-frontend-http): serve the vendored APRS symbol sprites
Embed the six sheets alongside the other vendored assets and serve them
under /vendor with the same immutable cache headers.

The browser computes a symbol's cell from a 16x6 grid of 24px cells, so
a re-vendored sheet at any other size would shift every station onto a
neighbouring icon -- wrong on every packet, and invisible unless you
know which glyph to expect.  Pin the geometry by parsing each embedded
PNG's IHDR in a test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018huL1ELyr86yVqfAabtioA
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 19:55:50 +02:00
sjgandClaude Opus 5 1bc2c88e2f [chore](trx-rs): vendor the APRS symbol sprite sheets
The web UI had no symbol graphics at all, so a position report rendered
its raw symbol character in a bordered box.  Vendor rev H of the
hessu/aprs-symbols set: three 24px sheets (primary, alternate, and the
overlay characters) plus their retina variants.

The set carries no single license.  Individual symbols are variously
vectorizations of the original WA8LMF bitmaps with unknown terms, new
CC BY-SA work by OH7LZB, public-domain or CC sources, and a handful of
brand logos owned by their companies.  Record that as
LicenseRef-APRS-Symbols with the upstream per-symbol catalogue copied
verbatim, and carry the attribution pointer upstream asks for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018huL1ELyr86yVqfAabtioA
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 19:36:27 +02:00
sjgandClaude Opus 5 863a6d8fd4 [fix](trx-frontend-http): restore decode history from the stored records
CI / lint (pull_request) Successful in 2m21s
CI / test (pull_request) Successful in 8m19s
CI / frontend (pull_request) Successful in 3m1s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m16s
CI / test (push) Successful in 7m26s
CI / frontend (push) Successful in 2m11s
CI / reuse (push) Successful in 2s
Replay required every restored record to carry a string `type`, and
stored records do not have one: an AIS entry holds mmsi, lat, lon,
crc_ok and its decoder's own fields, nothing more.  The filter therefore
discarded all of them, and did it silently — the fetch returned its full
payload, the worker decoded it, and no error was logged, so the history
simply never appeared.

That field identifies live SSE frames, which do carry it, which is why
only replay was affected.  History arrives already grouped and the
group's kind is delivered alongside the messages, so `type` was never
needed to route them.  Require only that a record is an object.

Confirmed against a live server: the first restored group is AIS, and
its records expose their decoder fields with `type` undefined.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 18:39:13 +02:00
sjgandClaude Opus 5 b252717f6b [test](trx-frontend-http): serve a realistic decoder registry to the smoke test
CI / lint (pull_request) Successful in 2m18s
CI / test (pull_request) Successful in 7m25s
CI / frontend (pull_request) Successful in 2m10s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 7m26s
CI / frontend (push) Successful in 2m12s
CI / reuse (push) Successful in 3s
The fixture answered /decoders with an empty list, which hid most of the
application from the only test that runs it in a browser.  The decoder
sub-tabs, their panels, the decode toggles and the bookmark decoder
checkboxes are all built from that registry, so the run exercised three
of thirteen sub-tabs and none of the decoder UI.  Finding this needed
route interception, because nothing in the suite could see it.

Serve eleven decoders covering the modes the real registry spans.  The
run now builds 13 sub-tabs and 11 bookmark decoder checkboxes — the same
checkboxes whose construction a recent fix changed without any test
reaching them — and still reports no runtime errors.

It also makes an existing fault observable: at 1100px the decoder
sub-tab bar hides 195px of itself with no scrollbar or fade, the same
silent truncation the top strip had.  No assertion for it here, since
that would fail until the truncation is fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 17:46:52 +02:00
sjgandClaude Opus 5 92697b11c5 [feat](trx-frontend-http): mark Tools active for its destinations
CI / test (pull_request) Successful in 8m9s
CI / test (push) Successful in 7m22s
CI / lint (pull_request) Successful in 2m15s
CI / frontend (pull_request) Successful in 3m1s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m16s
CI / frontend (push) Successful in 2m10s
CI / reuse (push) Successful in 2s
Grouping Statistics, Recorder, Settings and About behind Tools left the
tab strip looking identical on all four: the destination's own button
carries the active class, but the strip hides that button, so nothing
was marked.  The page titles named the page without saying how you got
there.

Mark the Tools button when the active destination is one the strip hides.
That state is read from the button's computed display rather than from a
second copy of the grouping, so the two cannot drift: whatever ui-core
puts in the menu lights up Tools, and a destination promoted back into
the strip stops doing so with no further change.

Tools already carries the tab class, so the existing active styling
applies unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 00:23:17 +02:00
sjgandClaude Opus 5 b409c57296 [test](trx-frontend-http): assert header geometry in the browser smoke test
CI / test (push) Successful in 7m27s
CI / frontend (push) Successful in 3m1s
CI / reuse (push) Successful in 3s
CI / lint (pull_request) Successful in 2m16s
CI / test (pull_request) Successful in 7m24s
CI / frontend (pull_request) Successful in 2m11s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m15s
Several layout faults shipped while every gate passed, because nothing
looked at geometry: a header whose height tracked the viewport, controls
at four different heights, a tab strip that ran under the controls, and a
dropdown that opened underneath the page.

Assert the invariants behind those at four widths — the header stays one
row, the tabs do not reach the controls, the controls share a height, the
page does not scroll sideways — and that the menu renders with real
dimensions and wins a hit test at its own centre.

The overlap check measures the tabs rather than the strip: with the strip
allowed to overflow, its box shrinks while its content paints across the
controls, so the container's own rect never registers the collision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 00:18:16 +02:00
sjgandClaude Opus 5 00191c8d7a [fix](trx-frontend-http): serve the Statistics and Bookmarks routes
CI / lint (pull_request) Successful in 2m18s
CI / frontend (pull_request) Successful in 3m1s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m17s
CI / test (pull_request) Successful in 8m23s
CI / test (push) Successful in 7m28s
CI / frontend (push) Successful in 2m9s
CI / reuse (push) Successful in 2s
The server answers /, /map, /digital-modes, /recorder, /settings and
/about with the application shell, but never had a route for /statistics
or /bookmarks.  Both fell through to the catch-all asset handler, so
reloading on either one downloaded a file instead of reopening the page.
Only in-app navigation worked, which is why it went unnoticed until
Statistics was reachable from the Tools menu.

Add the two missing shell routes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 00:03:48 +02:00
sjgandClaude Opus 5 56c363a517 [fix](trx-frontend-http): align the Statistics page and unblock the tab strip
Two faults, both mine, both visible in one screenshot of that page.

#tab-statistics was the only panel with padding of its own, so its title
and content sat 16px inside where every other page begins.  Remove it and
the page lines up with the header and with its siblings.

Removing the tab strip's `overflow-x` left it unable to shrink below its
content, so at around 1280px it ran under the controls: the Map tab sat
beneath the audio button and Tools beneath REC.  Clipping is safe again —
the menus it anchors are reparented to the body when they open — so the
strip can shrink, and the labels now give way to icons at 1360px rather
than 1180px, before it has to clip anything.

Measured at 1280px: 321px of clearance between the strip and the
controls, and the page title at the same left edge as the header.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 23:59:58 +02:00
sjgandClaude Opus 5 5ad91b4ab6 [fix](trx-frontend-http): stop doubling the space under the Statistics title
CI / lint (pull_request) Successful in 2m17s
CI / test (pull_request) Successful in 8m13s
CI / frontend (pull_request) Successful in 3m1s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m20s
CI / test (push) Successful in 7m33s
CI / frontend (push) Successful in 2m12s
CI / reuse (push) Successful in 3s
The page titles carry a bottom margin, which is what spaces them from the
content on the plain block panels.  #tab-statistics is not one: it is a
flex column with `gap: 1rem`, so the margin landed on top of that gap and
left 28px under the title where every other page had 12px.

Drop the margin on that panel and let its own gap do the spacing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 23:50:03 +02:00
29 changed files with 1651 additions and 266 deletions
+434
View File
@@ -0,0 +1,434 @@
APRS symbol set (https://github.com/hessu/aprs-symbols)
=========================================================
Verbatim copy of the upstream COPYRIGHT.md, retrieved 2026-08-03 from
https://github.com/hessu/aprs-symbols. The set has no single SPDX license:
individual symbols carry different terms, summarized below. Attribution
requirement from the upstream README: "If you use this symbol set, please
provide a pointer to the source (http://github.com/hessu/aprs-symbols/)."
---
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
+4 -1
View File
@@ -144,4 +144,7 @@ a unified set of frontends.
GPL-2.0-or-later. See [`LICENSES`](LICENSES) for the full license text and
bundled third-party license files. Bundled third-party components retain their
original licenses: Leaflet is BSD-2-Clause, DSEG is OFL-1.1, and opus-decoder
is MIT.
is MIT. The APRS symbol sprites come from
[hessu/aprs-symbols](https://github.com/hessu/aprs-symbols); their per-symbol
copyright status is catalogued in
[`LICENSES/LicenseRef-APRS-Symbols.txt`](LICENSES/LicenseRef-APRS-Symbols.txt).
+16
View File
@@ -56,3 +56,19 @@ SPDX-License-Identifier = "OFL-1.1"
path = ["src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/opus-decoder-0.7.11.min.js"]
SPDX-FileCopyrightText = "2021-2025 Ethan Halsall"
SPDX-License-Identifier = "MIT"
# Vendored APRS symbol sprites (https://github.com/hessu/aprs-symbols), rev H.
# The set has no single upstream license -- individual symbols carry different
# terms, catalogued in LICENSES/LicenseRef-APRS-Symbols.txt. Upstream asks that
# users point back to https://github.com/hessu/aprs-symbols/.
[[annotations]]
path = [
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-0.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-1.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-2.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-0-2x.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-1-2x.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-2-2x.png",
]
SPDX-FileCopyrightText = "Heikki Hannikainen OH7LZB and the APRS symbol set authors (https://github.com/hessu/aprs-symbols)"
SPDX-License-Identifier = "LicenseRef-APRS-Symbols"
@@ -818,6 +818,8 @@ function elementById(id) {
return needed <= bar.clientWidth;
};
const reflowOverflow = () => {
const nav = document.querySelector(".tab-bar-nav");
nav?.classList.remove("nav-icons-only");
overflowOrder.forEach((selector) => {
const element = menu.querySelector(selector);
if (element) actions.insertBefore(element, wrap);
@@ -830,6 +832,7 @@ function elementById(id) {
wrap.hidden = false;
menu.appendChild(element);
}
if (nav && !barFits()) nav.classList.add("nav-icons-only");
wrap.hidden = menu.children.length === 0;
if (wrap.hidden) closeMenu();
};
@@ -2724,6 +2727,17 @@ if (headerStylePickSelect) {
function readyText() {
return lastClientCount !== null ? `Ready · ${lastClientCount} user${lastClientCount !== 1 ? "s" : ""}` : "Ready";
}
var HINT_ERROR_RE = /failed|missing|unavailable|unknown|lost|required/i;
var HINT_BUSY_RE = /initializ|connecting|retrying|scanning|shifting|waiting|sending|switching|toggling|setting|not fully/i;
function hintState(msg) {
if (HINT_ERROR_RE.test(msg)) return "error";
if (HINT_BUSY_RE.test(msg) || /[\u2026]$|\.\.\.$/.test(msg)) return "busy";
return "ok";
}
function setPowerHint(msg) {
powerHint.textContent = msg;
powerHint.dataset.state = hintState(msg);
}
function rigBadgeColor(rigId) {
const text = (rigId || "rx").toString();
let hash = 0;
@@ -2837,12 +2851,12 @@ function refreshOperatorLayoutCapabilities() {
});
}
function showHint(msg, duration) {
powerHint.textContent = msg;
setPowerHint(msg);
if (hintTimer) clearTimeout(hintTimer);
if (duration) hintTimer = setTimeout(() => {
powerHint.textContent = readyText();
setPowerHint(readyText());
}, duration);
if (/failed|missing|unavailable|unknown|lost|required/i.test(msg)) {
if (HINT_ERROR_RE.test(msg)) {
window.trxUi?.notify(msg, { kind: "error" });
}
}
@@ -4405,13 +4419,13 @@ function render(update) {
console.info("Rig initializing:", { manufacturer: manu, model, revision: rev });
loadingEl.style.display = "";
if (contentEl) contentEl.style.display = "none";
powerHint.textContent = "Initializing rig…";
setPowerHint("Initializing rig…");
setDisabled(true);
return;
}
loadingEl.style.display = "none";
if (contentEl) contentEl.style.display = "";
powerHint.textContent = "Rig not fully initialized yet";
setPowerHint("Rig not fully initialized yet");
} else {
loadingEl.style.display = "none";
if (contentEl) contentEl.style.display = "";
@@ -4730,7 +4744,7 @@ function render(update) {
powerBtn.disabled = true;
powerBtn.textContent = "Power unavailable";
powerBtn.setAttribute("aria-pressed", "false");
powerHint.textContent = "State unknown";
setPowerHint("State unknown");
}
if (update.status && update.status.tx && typeof update.status.tx.limit === "number") {
txLimitInput.value = String(update.status.tx.limit);
@@ -4827,7 +4841,7 @@ function render(update) {
if (Array.isArray(update.remotes)) {
applyRigList(typeof update.active_remote === "string" ? update.active_remote : null, update.remotes);
}
powerHint.textContent = readyText();
setPowerHint(readyText());
lastLocked = update.status?.lock === true;
window.trxUi?.setButtonState(lockBtn, {
active: lastLocked,
@@ -4905,11 +4919,11 @@ function connect() {
render(data);
lastEventAt = Date.now();
if (data.server_connected === false) {
powerHint.textContent = "trx-server connection lost";
setPowerHint("trx-server connection lost");
if (tabMainEl) tabMainEl.classList.add("server-disconnected");
} else {
if (tabMainEl) tabMainEl.classList.remove("server-disconnected");
if (data.initialized) powerHint.textContent = readyText();
if (data.initialized) setPowerHint(readyText());
}
} catch (e) {
console.error("Bad event data", e);
@@ -4932,7 +4946,7 @@ function connect() {
});
source.onerror = () => {
if (source.readyState === EventSource.CLOSED) {
powerHint.textContent = "trx-client connection lost, retrying…";
setPowerHint("trx-client connection lost, retrying…");
setConnLostOverlay(true, "trx-client connection lost", "Retrying…", true);
source.close();
void pollFreshSnapshot();
@@ -4942,7 +4956,7 @@ function connect() {
esHeartbeat = setInterval(() => {
const now = Date.now();
if (now - lastEventAt > 15e3) {
powerHint.textContent = "trx-client connection lost, retrying…";
setPowerHint("trx-client connection lost, retrying…");
setConnLostOverlay(true, "trx-client connection lost", "Retrying…", true);
source.close();
void pollFreshSnapshot();
@@ -5592,6 +5606,11 @@ function navigateToTab(name, options = {}) {
_activeTab = name;
document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active"));
btn.classList.add("active");
const toolsBtn = document.getElementById("mobile-more-btn");
if (toolsBtn) {
const inToolsMenu = !!document.querySelector(`#mobile-more-menu [data-navigate-tab="${name}"]`);
toolsBtn.classList.toggle("active", inToolsMenu);
}
window.trxUi?.syncSelectedTab(document.querySelector(".tab-bar-nav"), btn);
document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none");
const panel = document.getElementById(`tab-${name}`);
@@ -7422,7 +7441,7 @@ function connectDecode() {
return;
}
if (data.type === "group") {
const messages = Array.isArray(data.messages) ? data.messages.filter((message) => isRecord2(message) && typeof message.type === "string") : [];
const messages = Array.isArray(data.messages) ? data.messages.filter((message) => isRecord2(message)) : [];
enqueueDecodeHistoryGroup(typeof data.kind === "string" ? data.kind : "", messages);
return;
}
@@ -8572,7 +8591,8 @@ function updateBookmarkAxis(range) {
updateSideBookmarkStack(leftSideEl, leftBookmarks, colorMap);
updateSideBookmarkStack(rightSideEl, rightBookmarks, colorMap);
const hasVisible = visBookmarks.length > 0;
axisEl.classList.toggle("bm-axis-visible", hasVisible);
axisEl.classList.add("bm-axis-visible");
axisEl.classList.toggle("bm-axis-empty", !hasVisible);
if (!hasVisible) {
if (axisEl.dataset.bmKey) {
axisEl.replaceChildren();
@@ -9367,24 +9387,28 @@ function bandplanVisibleSegments(region, loHz, hiHz) {
}
return result;
}
function _hideBandplanStrip() {
function _clearBandplanStrip(reserveSpace) {
if (!bandplanStripEl) return;
bandplanStripEl.classList.remove("bp-visible");
bandplanStripEl.replaceChildren();
bandplanCacheKey = "";
if (bandplanCacheKey) {
bandplanStripEl.replaceChildren();
bandplanCacheKey = "";
}
bandplanStripEl.classList.toggle("bp-visible", reserveSpace);
bandplanStripEl.classList.toggle("bp-empty", reserveSpace);
}
function updateBandplanStrip(range) {
if (!bandplanStripEl) return;
if (!range || bandplanRegion === "off" || !bandplanData) {
if (bandplanStripEl.classList.contains("bp-visible")) _hideBandplanStrip();
_clearBandplanStrip(false);
return;
}
const segments = bandplanVisibleSegments(bandplanRegion, range.visLoHz, range.visHiHz);
if (segments.length === 0) {
if (bandplanStripEl.classList.contains("bp-visible")) _hideBandplanStrip();
_clearBandplanStrip(true);
return;
}
bandplanStripEl.classList.add("bp-visible");
bandplanStripEl.classList.remove("bp-empty");
const newKey = bandplanRegion + ":" + (bandplanShowLabels ? "L" : "N") + ":" + segments.map((s) => s.low_hz + "-" + s.high_hz).join(",");
const stripW = bandplanStripEl.clientWidth || 1;
if (bandplanCacheKey !== newKey) {
@@ -7,7 +7,7 @@ import {
normalizeAprsPacket,
renderAprsInfo,
renderLocalAprsSymbol
} from "./chunk-M2I6DH4X.js";
} from "./chunk-ROTCLFXS.js";
import {
hostCore,
hostState
@@ -75,8 +75,51 @@ function escapeAprsCharacter(character) {
if (character === '"') return "&quot;";
return character;
}
var APRS_SPRITE_COLUMNS = 16;
var APRS_SPRITE_CELL_PX = 24;
var APRS_SPRITE_FIRST_CODE = 33;
var APRS_SPRITE_LAST_CODE = 126;
function aprsSpriteOffset(code) {
if (code.length !== 1) return null;
const point = code.charCodeAt(0);
if (point < APRS_SPRITE_FIRST_CODE || point > APRS_SPRITE_LAST_CODE) return null;
const index = point - APRS_SPRITE_FIRST_CODE;
const column = index % APRS_SPRITE_COLUMNS;
const row = Math.floor(index / APRS_SPRITE_COLUMNS);
return `${String(-column * APRS_SPRITE_CELL_PX)}px ${String(-row * APRS_SPRITE_CELL_PX)}px`;
}
function aprsSymbolSprite(symbolTable, symbolCode) {
if (!symbolTable || !symbolCode) return null;
const symbolOffset = aprsSpriteOffset(symbolCode);
if (!symbolOffset) return null;
if (symbolTable === "/") {
return {
className: "aprs-symbol-primary",
backgroundPosition: symbolOffset,
label: `Primary APRS symbol ${symbolTable}${symbolCode}`
};
}
if (symbolTable === "\\") {
return {
className: "aprs-symbol-alternate",
backgroundPosition: symbolOffset,
label: `Alternate APRS symbol ${symbolTable}${symbolCode}`
};
}
const overlayOffset = aprsSpriteOffset(symbolTable);
if (!overlayOffset) return null;
return {
className: "aprs-symbol-overlaid",
backgroundPosition: `${overlayOffset}, ${symbolOffset}`,
label: `Alternate APRS symbol \\${symbolCode} with overlay ${symbolTable}`
};
}
function renderLocalAprsSymbol(packet, escapeHtml) {
if (!packet.symbolTable || !packet.symbolCode) return "";
const sprite = aprsSymbolSprite(packet.symbolTable, packet.symbolCode);
if (sprite) {
return `<span class="aprs-symbol ${sprite.className}" role="img" style="background-position:${sprite.backgroundPosition}" title="${escapeHtml(sprite.label)}" aria-label="${escapeHtml(sprite.label)}"></span>`;
}
const symbol = escapeHtml(packet.symbolCode);
const table = packet.symbolTable === "/" ? "Primary" : "Alternate";
return `<span class="aprs-symbol aprs-symbol-local" title="${table} APRS symbol ${symbol}">${symbol}</span>`;
@@ -107,6 +150,7 @@ export {
collapseAprsDuplicates,
aprsHexBytes,
renderAprsInfo,
aprsSymbolSprite,
renderLocalAprsSymbol,
normalizeAprsPacket
};
@@ -7,7 +7,7 @@ import {
normalizeAprsPacket,
renderAprsInfo,
renderLocalAprsSymbol
} from "./chunk-M2I6DH4X.js";
} from "./chunk-ROTCLFXS.js";
import {
hostCore,
hostState
@@ -1,3 +1,7 @@
import {
aprsSymbolSprite
} from "./chunk-ROTCLFXS.js";
// src/map-core.ts
function mapEl(id) {
const element = document.querySelector(`#${CSS.escape(id)}`);
@@ -1643,27 +1647,23 @@ var mapWindow = window;
return;
}
const mapRect = mapContainer.getBoundingClientRect();
const width = mapContainer.clientWidth || mapRect.width;
const footer = document.querySelector(".footer");
let bottom = mapIsFullscreen() && stage ? stage.getBoundingClientRect().bottom : window.innerHeight;
if (!mapIsFullscreen() && footer) {
let bottom = window.innerHeight;
if (footer) {
const fr = footer.getBoundingClientRect();
if (fr.top > mapRect.top + 50) bottom = fr.top;
if (fr.top > mapRect.top + 50) bottom = Math.min(fr.top, bottom);
}
const available = Math.max(0, Math.floor(bottom - mapRect.top - 8));
const widthDriven = width > 0 ? Math.floor(width / 1.55) : available;
const viewportCap = mapIsFullscreen() ? Math.floor(window.innerHeight * 0.9) : Math.floor(window.innerHeight * 0.75);
const minHeight = Math.min(260, available);
const target = Math.max(minHeight, Math.min(available, viewportCap, widthDriven));
const target = Math.max(0, Math.floor(bottom - mapRect.top - 8));
mapContainer.style.height = `${target}px`;
if (aprsMap) aprsMap.invalidateSize();
}
function aprsSymbolIcon(symbolTable, symbolCode) {
if (!symbolTable || !symbolCode) return null;
const table = symbolTable === "/" ? "primary" : "alternate";
const sprite = aprsSymbolSprite(symbolTable, symbolCode);
const html = sprite ? `<div class="aprs-symbol aprs-symbol-marker ${sprite.className}" role="img" style="background-position:${sprite.backgroundPosition}" title="${escapeMapHtml(sprite.label)}" aria-label="${escapeMapHtml(sprite.label)}"></div>` : `<div class="aprs-symbol aprs-symbol-marker aprs-symbol-local" title="${symbolTable === "/" ? "primary" : "alternate"} APRS symbol ${escapeMapHtml(symbolCode)}">${escapeMapHtml(symbolCode)}</div>`;
return L.divIcon({
className: "",
html: `<div class="aprs-symbol-local" title="${table} APRS symbol ${escapeMapHtml(symbolCode)}">${escapeMapHtml(symbolCode)}</div>`,
html,
iconSize: [24, 24],
iconAnchor: [12, 12],
popupAnchor: [0, -12]
@@ -367,23 +367,25 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="advanced-radio-body">
<div class="scheduler-control-row" style="display:none">
<div class="scheduler-release-wrap">
<button id="scheduler-release-btn" type="button">Release to Scheduler</button>
<div class="scheduler-step-controls">
<button id="scheduler-prev-btn" type="button">Previous Entry</button>
<button id="scheduler-next-btn" type="button">Next Entry</button>
</div>
<div id="scheduler-release-status" class="scheduler-release-status">Scheduler is controlling the rig.</div>
<div id="scheduler-cycle-status" class="interleave-ring-wrap" style="display:none;">
<svg class="interleave-ring" viewBox="0 0 36 36">
<circle class="interleave-ring-bg" cx="18" cy="18" r="15.915" />
<circle class="interleave-ring-fill" id="interleave-ring-fill" cx="18" cy="18" r="15.915"
stroke-dasharray="100" stroke-dashoffset="100" />
</svg>
<div class="interleave-ring-text">
<div class="interleave-ring-label" id="interleave-active-name">--</div>
<div class="interleave-ring-sub" id="interleave-countdown">--</div>
<div class="scheduler-action-row">
<div class="scheduler-step-controls">
<button id="scheduler-prev-btn" type="button">Previous Entry</button>
<button id="scheduler-next-btn" type="button">Next Entry</button>
</div>
<button id="scheduler-release-btn" type="button">Release to Scheduler</button>
<div id="scheduler-cycle-status" class="interleave-ring-wrap" style="display:none;">
<svg class="interleave-ring" viewBox="0 0 36 36">
<circle class="interleave-ring-bg" cx="18" cy="18" r="15.915" />
<circle class="interleave-ring-fill" id="interleave-ring-fill" cx="18" cy="18" r="15.915"
stroke-dasharray="100" stroke-dashoffset="100" />
</svg>
<div class="interleave-ring-text">
<div class="interleave-ring-label" id="interleave-active-name">--</div>
<div class="interleave-ring-sub" id="interleave-countdown">--</div>
</div>
</div>
</div>
<div id="scheduler-release-status" class="scheduler-release-status">Scheduler is controlling the rig.</div>
</div>
</div>
</div>
@@ -1594,10 +1596,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
</template>
</div>
<div class="footer">
<div class="copyright">
Built by <a href="https://www.qrzcq.com/call/SP2SJG" target="_blank" rel="noopener">SP2SJG</a> from <a href="https://haxx.space" target="_blank" rel="noopener">haxx.space</a> · <span class="gh-link-wrap"><a class="gh-link" href="https://git.haxx.space/sjg/trx-rs" target="_blank" rel="noopener" aria-label="Open trx-rs source repository"><svg class="gh-link-icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"></path></svg><span>trx-rs source</span></a></span><span id="copyright-year"></span>
<div class="footer-meta">
<span class="copyright">
Built by <a href="https://www.qrzcq.com/call/SP2SJG" target="_blank" rel="noopener">SP2SJG</a> from <a href="https://haxx.space" target="_blank" rel="noopener">haxx.space</a> · &copy; <span id="copyright-year"></span>
</span>
<span class="gh-link-wrap"><a class="gh-link" href="https://git.haxx.space/sjg/trx-rs" target="_blank" rel="noopener" aria-label="Open trx-rs source repository"><svg class="gh-link-icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"></path></svg><span>trx-rs source</span></a></span>
</div>
<div class="hint" id="power-hint" aria-live="polite">Connecting…</div>
<div class="hint" id="power-hint" data-state="busy" aria-live="polite">Connecting…</div>
</div>
<div id="conn-lost-overlay" class="decode-history-overlay content-overlay is-hidden" aria-live="assertive" aria-atomic="true">
<div class="decode-history-overlay-card">
@@ -18,6 +18,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
--accent-green: #c24b1a;
--accent-yellow: #f0ad4e;
--accent-red: #e55353;
/* Healthy/on indicator. Distinct from --accent-green, which this theme
family uses as the (orange) brand accent rather than a status colour. */
--status-ok: #00d17f;
--vchan-color: #38bdf8;
--control-height: 2.6rem;
--jog-hi: #243a5b;
@@ -107,6 +110,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
--accent-green: #b04317;
--accent-yellow: #b57600;
--accent-red: #cf3f3f;
/* Darker on light backgrounds: #00d17f as text/dots is barely legible there. */
--status-ok: #0a9d63;
--jog-hi: #e6edf8;
--jog-lo: #cdd9eb;
--jog-shadow: rgba(58, 79, 110, 0.18);
@@ -570,6 +575,24 @@ input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem;
border-color: var(--accent-yellow);
color: var(--accent-yellow);
}
/* One row, read left to right: step through the entries, hand the rig back,
then — fenced off — the entry actually on air. */
.scheduler-action-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
}
/* The rule belongs to the current-entry block, so it leaves with it: the
block is display-toggled whenever fewer than two entries are active. */
.scheduler-action-row #scheduler-cycle-status::before {
content: "";
align-self: stretch;
width: 1px;
min-height: 1.7rem;
margin-inline: 0.3rem;
background: color-mix(in srgb, var(--border-light) 70%, transparent);
}
.scheduler-step-controls {
display: flex;
flex-wrap: wrap;
@@ -1531,32 +1554,54 @@ small { color: var(--text-muted); }
align-items: center;
gap: 0.2rem;
min-width: 0;
/* Never wraps and never scrolls: the occasional destinations live behind
* More, so four tabs plus More always fit. overflow-x here would clip the
* dropdown this element anchors. */
/* Never wraps. It must still be able to shrink below its content, or it
* overlaps the controls; the menus it anchors are reparented to the body on
* open, so clipping here no longer reaches them. */
flex-wrap: nowrap;
overflow: hidden;
}
.tab-bar-nav .tab { flex: 0 0 auto; }
/* Icons before scrolling: every tab already carries one, and four icons plus
* More always fit, so the strip never has to hide a destination. */
@media (max-width: 1180px) and (min-width: 701px) {
.tab-bar-nav .tab .tab-label { display: none; }
.tab-bar-nav .tab .tab-icon, .tab-bar-nav .tab .tab-more-icon { display: block; }
.tab-bar-nav .tab { padding: 0.5rem 0.6rem; }
* More always fit, so the strip never has to hide a destination. Applied by
* measurement (ui-core's reflowOverflow) rather than at a viewport width: how
* much fits depends on the rig name and on how wide the platform renders the
* labels, so the same width fits on one machine and clips on another.
* Bounded below 761px, where the strip becomes the bottom nav and keeps its
* labels under the icons. */
@media (min-width: 761px) {
.tab-bar-nav.nav-icons-only .tab .tab-label { display: none; }
.tab-bar-nav.nav-icons-only .tab .tab-icon,
.tab-bar-nav.nav-icons-only .tab .tab-more-icon { display: block; }
.tab-bar-nav.nav-icons-only .tab { padding: 0.45rem 0.55rem; }
}
/* The selected destination is boxed, not underlined — the same treatment the
mobile bottom nav already used, so one navigation model reads the same at
every width. The transparent border is on the base so switching tabs moves
no neighbours. */
.tab {
background: transparent;
border: none;
border-bottom: 2px solid transparent;
border-radius: 0;
padding: 0.5rem 0.95rem;
border: 1px solid transparent;
border-radius: var(--radius-md);
padding: 0.45rem 0.9rem;
color: var(--text-muted);
cursor: pointer;
font-size: 0.95rem;
height: auto;
transition: color var(--dur-fast) var(--ease-standard),
background-color var(--dur-fast) var(--ease-standard),
border-color var(--dur-fast) var(--ease-standard);
}
.tab.active {
border-color: color-mix(in srgb, var(--accent-green) 50%, var(--border-light));
background: color-mix(in srgb, var(--accent-green) 12%, transparent);
color: var(--accent-text);
font-weight: 600;
}
.tab:hover:not(.active) {
color: var(--text);
background: color-mix(in srgb, var(--btn-bg) 55%, transparent);
border-color: color-mix(in srgb, var(--border-light) 40%, transparent);
}
.tab.active { border-bottom-color: var(--accent-green); color: var(--accent-text); font-weight: 600; }
.tab:hover:not(.active) { color: var(--text); }
/* Tab icons — hidden on desktop, shown on mobile bottom nav */
.tab-icon {
display: none;
@@ -1574,15 +1619,88 @@ small { color: var(--text-muted); }
.about-table td { padding: 0.4rem 0.75rem; border-bottom: 1px solid color-mix(in srgb, var(--border) 50%, transparent); font-size: 0.85rem; }
.about-table tr:last-child td { border-bottom: none; }
.about-table td:first-child { color: var(--text-muted); width: 40%; }
.about-status-on { color: #00d17f; }
.about-status-on { color: var(--status-ok); }
.about-status-off { color: var(--text-muted); }
.plugin-item { padding: 0.5rem 0.6rem; border-bottom: 1px solid var(--border); color: var(--text); }
.plugin-item:last-child { border-bottom: none; }
.footer { display: flex; justify-content: space-between; align-items: baseline; margin-top: auto; padding-top: 1rem; flex-shrink: 0; }
/* ── Footer ───────────────────────────────────────────────────────────
A hairline rule closes the page the way .tab-bar opens it; the two
clusters (attribution + source pill, live status chip) sit on one
baseline-free centre line so the pills don't hang off the text. */
.footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
flex-wrap: wrap;
margin-top: auto;
padding: var(--space-3) 0 var(--space-1);
border-top: 1px solid color-mix(in srgb, var(--border-light) 45%, transparent);
flex-shrink: 0;
}
.footer-meta {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
min-width: 0;
}
.full-row { grid-column: 1 / -1; }
.copyright { color: var(--text-muted); font-size: 0.75rem; opacity: 0.7; }
/* No opacity: --text-muted is already the muted step, and stacking the
two put the attribution below a readable contrast ratio. */
.copyright { color: var(--text-muted); font-size: var(--fs-xs); }
.copyright a { color: var(--accent-text); text-decoration: none; }
.copyright a:hover { text-decoration: underline; }
/* Live status: dot + text, colour driven by the data-state app.ts sets. */
.footer .hint {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: var(--fs-xs);
color: var(--text-heading);
padding: 0.2rem 0.6rem;
border-radius: var(--radius-pill);
border: 1px solid color-mix(in srgb, var(--border-light) 60%, transparent);
background: color-mix(in srgb, var(--btn-bg) 55%, transparent);
font-variant-numeric: tabular-nums;
}
.footer .hint::before {
content: "";
width: 0.45rem;
height: 0.45rem;
flex-shrink: 0;
border-radius: 50%;
background: var(--text-muted);
box-shadow: 0 0 0 0.16rem color-mix(in srgb, var(--text-muted) 22%, transparent);
transition: background var(--dur-base) var(--ease-standard),
box-shadow var(--dur-base) var(--ease-standard);
}
.footer .hint[data-state="ok"]::before {
background: var(--status-ok);
box-shadow: 0 0 0 0.16rem color-mix(in srgb, var(--status-ok) 24%, transparent);
}
.footer .hint[data-state="busy"]::before {
background: var(--accent-yellow);
box-shadow: 0 0 0 0.16rem color-mix(in srgb, var(--accent-yellow) 24%, transparent);
}
.footer .hint[data-state="error"] {
color: var(--accent-red);
border-color: color-mix(in srgb, var(--accent-red) 45%, var(--border-light));
}
.footer .hint[data-state="error"]::before {
background: var(--accent-red);
box-shadow: 0 0 0 0.16rem color-mix(in srgb, var(--accent-red) 24%, transparent);
}
@media (prefers-reduced-motion: no-preference) {
.footer .hint[data-state="busy"]::before,
.footer .hint[data-state="error"]::before {
animation: trx-status-pulse 1.8s var(--ease-standard) infinite;
}
}
@keyframes trx-status-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
}
.gh-link-wrap {
display: inline-flex;
vertical-align: middle;
@@ -1591,6 +1709,8 @@ small { color: var(--text-muted); }
.gh-link {
display: inline-flex;
align-items: center;
text-decoration: none;
font-size: var(--fs-xs);
gap: 0.34rem;
padding: 0.18rem 0.5rem;
border-radius: 999px;
@@ -1838,11 +1958,17 @@ small { color: var(--text-muted); }
font-size: 0.82rem;
color: var(--text-muted);
}
/* Full-bleed: break the map panel out of the centred .card column so the
stage spans the whole viewport width. The negative inline margins cancel
the card's centring offset plus its side padding, whatever they are. */
#tab-map {
display: flex;
flex-direction: column;
min-height: 0;
gap: 0.85rem;
width: 100vw;
max-width: 100vw;
margin-inline: calc(50% - 50vw);
}
/* map-loading uses the shared .decode-history-overlay .content-overlay classes */
#map-stage {
@@ -1850,8 +1976,9 @@ small { color: var(--text-muted); }
flex: 0 1 auto;
min-height: 0;
overflow: hidden;
border-radius: 6px;
border-radius: 0;
border: 1px solid color-mix(in srgb, var(--border-light) 78%, transparent);
border-inline: 0;
background:
linear-gradient(180deg, color-mix(in srgb, var(--card-bg) 86%, transparent), color-mix(in srgb, var(--card-bg) 64%, transparent)),
color-mix(in srgb, var(--input-bg) 88%, transparent);
@@ -2532,7 +2659,21 @@ body.map-fake-fullscreen-active {
font-size: 0.75rem;
word-break: break-all;
}
.aprs-symbol { display: inline-block; width: 24px; height: 24px; background-size: 384px 192px; vertical-align: middle; margin-right: 0.3rem; }
/* APRS symbols are drawn from the vendored hessu/aprs-symbols sprite sheets:
16x6 grids of 24px cells, indexed by `symbol code - 0x21`. The cell offset
is set inline by the APRS/map bundles; the sheet URL stays here so retina
displays can swap in the @2x variants. */
.aprs-symbol { display: inline-block; width: 24px; height: 24px; background-repeat: no-repeat; background-size: 384px 144px; vertical-align: middle; margin-right: 0.3rem; }
.aprs-symbol-marker { margin-right: 0; }
.aprs-symbol-primary { background-image: url('/vendor/aprs-symbols-24-0.png'); }
.aprs-symbol-alternate { background-image: url('/vendor/aprs-symbols-24-1.png'); }
.aprs-symbol-overlaid { background-image: url('/vendor/aprs-symbols-24-2.png'), url('/vendor/aprs-symbols-24-1.png'); }
@media (min-resolution: 2dppx) {
.aprs-symbol-primary { background-image: url('/vendor/aprs-symbols-24-0-2x.png'); }
.aprs-symbol-alternate { background-image: url('/vendor/aprs-symbols-24-1-2x.png'); }
.aprs-symbol-overlaid { background-image: url('/vendor/aprs-symbols-24-2-2x.png'), url('/vendor/aprs-symbols-24-1-2x.png'); }
}
/* Fallback for symbol codes outside the sprite sheets: show the raw character. */
.aprs-symbol-local { border: 1px solid var(--border); border-radius: 4px; background: var(--surface-raised); color: var(--text); font: 700 0.8rem/22px ui-monospace, monospace; text-align: center; }
.aprs-pos { color: var(--accent-green); text-decoration: none; margin-left: 0.3rem; font-size: 0.8rem; }
.aprs-pos:hover { text-decoration: underline; }
@@ -3259,7 +3400,6 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
min-height: 3.2rem;
padding: 0.35rem 0.1rem 0.3rem;
border: 1px solid transparent;
border-bottom: none;
border-radius: 0.75rem;
white-space: nowrap;
text-align: center;
@@ -3833,6 +3973,11 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
overflow: hidden;
border-bottom-color: var(--border-light, rgba(255,255,255,0.06));
}
/* No allocations in range. The height stays reserved — collapsing it moved
every element below by 18px each time tuning left a band. */
#spectrum-bandplan-strip.bp-empty {
background: color-mix(in srgb, var(--border) 22%, transparent);
}
.bp-segment {
position: absolute;
top: 0;
@@ -3923,6 +4068,12 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
height: 38px;
overflow: visible;
}
/* The rail sits over the top of the overview plot, so it must not swallow
drags and clicks meant for it — only the chips themselves are targets. */
#spectrum-bookmark-axis { pointer-events: none; }
#spectrum-bookmark-axis .spectrum-bookmark-chip { pointer-events: auto; }
/* Nothing bookmarked in range: the rail keeps its place but stays blank —
no caption over the plot. */
.spectrum-bookmark-chip {
position: absolute;
top: 2px;
@@ -5187,7 +5338,6 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.stats-controls {
display: flex;
@@ -5447,7 +5597,11 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
/* Page title for destinations reached through the Tools menu. The tab strip
* cannot show which of them is active, so each states its own name. */
* cannot show which of them is active, so each states its own name.
*
* The margin is the spacing on the plain block panels; #tab-statistics is a
* flex column with its own `gap`, where the margin lands on top of that gap
* and doubles the space under the title. */
.section-heading {
margin: 0 0 var(--space-3);
font-size: 1.15rem;
@@ -5455,3 +5609,5 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
line-height: 1.2;
color: var(--text);
}
#tab-statistics > .section-heading { margin-bottom: 0; }
Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -12,7 +12,7 @@
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
"test": "node --test tests/*.test.mjs",
"test:browser": "node tests/browser-smoke.mjs",
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs",
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
},
"devDependencies": {
@@ -1332,6 +1332,22 @@ function readyText() {
return lastClientCount !== null ? `Ready \u00b7 ${lastClientCount} user${lastClientCount !== 1 ? "s" : ""}` : "Ready";
}
// The footer hint is a status pill whose dot is coloured from data-state,
// so the text has to be written through setPowerHint rather than assigned.
const HINT_ERROR_RE = /failed|missing|unavailable|unknown|lost|required/i;
const HINT_BUSY_RE = /initializ|connecting|retrying|scanning|shifting|waiting|sending|switching|toggling|setting|not fully/i;
function hintState(msg: string): "ok" | "busy" | "error" {
if (HINT_ERROR_RE.test(msg)) return "error";
if (HINT_BUSY_RE.test(msg) || /[\u2026]$|\.\.\.$/.test(msg)) return "busy";
return "ok";
}
function setPowerHint(msg: string) {
powerHint.textContent = msg;
powerHint.dataset.state = hintState(msg);
}
function rigBadgeColor(rigId: string) {
const text = (rigId || "rx").toString();
let hash = 0;
@@ -1461,10 +1477,10 @@ function refreshOperatorLayoutCapabilities() {
}
function showHint(msg: string, duration?: number) {
powerHint.textContent = msg;
setPowerHint(msg);
if (hintTimer) clearTimeout(hintTimer);
if (duration) hintTimer = setTimeout(() => { powerHint.textContent = readyText(); }, duration);
if (/failed|missing|unavailable|unknown|lost|required/i.test(msg)) {
if (duration) hintTimer = setTimeout(() => { setPowerHint(readyText()); }, duration);
if (HINT_ERROR_RE.test(msg)) {
window.trxUi?.notify(msg, { kind: "error" });
}
}
@@ -3292,13 +3308,13 @@ function render(update: AppUpdate) {
console.info("Rig initializing:", { manufacturer: manu, model, revision: rev });
loadingEl.style.display = "";
if (contentEl) contentEl.style.display = "none";
powerHint.textContent = "Initializing rig…";
setPowerHint("Initializing rig…");
setDisabled(true);
return;
}
loadingEl.style.display = "none";
if (contentEl) contentEl.style.display = "";
powerHint.textContent = "Rig not fully initialized yet";
setPowerHint("Rig not fully initialized yet");
} else {
loadingEl.style.display = "none";
if (contentEl) contentEl.style.display = "";
@@ -3649,7 +3665,7 @@ function render(update: AppUpdate) {
powerBtn.disabled = true;
powerBtn.textContent = "Power unavailable";
powerBtn.setAttribute("aria-pressed", "false");
powerHint.textContent = "State unknown";
setPowerHint("State unknown");
}
if (update.status && update.status.tx && typeof update.status.tx.limit === "number") {
@@ -3760,7 +3776,7 @@ function render(update: AppUpdate) {
if (Array.isArray(update.remotes)) {
applyRigList(typeof update.active_remote === "string" ? update.active_remote : null, update.remotes);
}
powerHint.textContent = readyText();
setPowerHint(readyText());
lastLocked = update.status?.lock === true;
window.trxUi?.setButtonState(lockBtn, {
active: lastLocked,
@@ -3847,11 +3863,11 @@ function connect() {
render(data);
lastEventAt = Date.now();
if (data.server_connected === false) {
powerHint.textContent = "trx-server connection lost";
setPowerHint("trx-server connection lost");
if (tabMainEl) tabMainEl.classList.add("server-disconnected");
} else {
if (tabMainEl) tabMainEl.classList.remove("server-disconnected");
if (data.initialized) powerHint.textContent = readyText();
if (data.initialized) setPowerHint(readyText());
}
} catch (e) {
console.error("Bad event data", e);
@@ -3874,7 +3890,7 @@ function connect() {
source.onerror = () => {
// Check if this is an auth error by looking at readyState
if (source.readyState === EventSource.CLOSED) {
powerHint.textContent = "trx-client connection lost, retrying\u2026";
setPowerHint("trx-client connection lost, retrying\u2026");
setConnLostOverlay(true, "trx-client connection lost", "Retrying\u2026", true);
source.close();
void pollFreshSnapshot();
@@ -3885,7 +3901,7 @@ function connect() {
esHeartbeat = setInterval(() => {
const now = Date.now();
if (now - lastEventAt > 15000) {
powerHint.textContent = "trx-client connection lost, retrying\u2026";
setPowerHint("trx-client connection lost, retrying\u2026");
setConnLostOverlay(true, "trx-client connection lost", "Retrying\u2026", true);
source.close();
void pollFreshSnapshot();
@@ -4600,6 +4616,19 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
_activeTab = name;
document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active"));
btn.classList.add("active");
// A destination the strip hides is reached through Tools, so mark that
// button instead — otherwise the strip looks identical on all four of them.
// Membership of the Tools menu is the test: it still reads from the grouping
// ui-core installs rather than a second copy of it, but unlike the tab's
// computed display it does not depend on anything being laid out. The first
// route navigation runs while the card is still behind the loading state,
// where every tab computes to display:none — which lit Tools up on every
// refresh of every page.
const toolsBtn = document.getElementById("mobile-more-btn");
if (toolsBtn) {
const inToolsMenu = !!document.querySelector(`#mobile-more-menu [data-navigate-tab="${name}"]`);
toolsBtn.classList.toggle("active", inToolsMenu);
}
window.trxUi?.syncSelectedTab(document.querySelector(".tab-bar-nav"), btn);
document.querySelectorAll<HTMLElement>(".tab-panel").forEach((p) => p.style.display = "none");
const panel = document.getElementById(`tab-${name}`);
@@ -6360,7 +6389,12 @@ function connectDecode() {
}
if (data.type === "group") {
const messages = Array.isArray(data.messages)
? data.messages.filter((message): message is DecodeMessage => isRecord(message) && typeof message.type === "string")
// Stored records carry only decoder fields — an AIS entry has mmsi,
// lat, lon and so on, and no `type`. That field identifies live SSE
// frames; history is already grouped, and the group's kind is
// delivered alongside these messages, so requiring it here discarded
// every restored record silently.
? data.messages.filter((message): message is DecodeMessage => isRecord(message))
: [];
enqueueDecodeHistoryGroup(typeof data.kind === "string" ? data.kind : "", messages);
return;
@@ -7727,7 +7761,12 @@ function updateBookmarkAxis(range: SpectrumRange) {
updateSideBookmarkStack(rightSideEl, rightBookmarks, colorMap);
const hasVisible = visBookmarks.length > 0;
axisEl.classList.toggle("bm-axis-visible", hasVisible);
// The rail is kept up with nothing in range — blank, no caption — so tuning
// across bands only swaps its contents rather than taking the strip itself
// away. This function only runs with a spectrum range in hand, so rigs
// without a spectrum never get it.
axisEl.classList.add("bm-axis-visible");
axisEl.classList.toggle("bm-axis-empty", !hasVisible);
if (!hasVisible) {
if (axisEl.dataset.bmKey) { axisEl.replaceChildren(); axisEl.dataset.bmKey = ""; }
@@ -8606,27 +8645,36 @@ function bandplanVisibleSegments(region: string, loHz: number, hiHz: number): Vi
return result;
}
function _hideBandplanStrip() {
// Empties the strip. `reserveSpace` keeps its height: the strip is in flow, so
// collapsing it on a range with no allocations shifted the whole page down by
// its height every time tuning crossed out of a band. Space stays reserved
// whenever a band plan could be drawn at all, and is only given back when the
// feature is off, has no data, or there is no spectrum to annotate.
function _clearBandplanStrip(reserveSpace: boolean) {
if (!bandplanStripEl) return;
bandplanStripEl.classList.remove("bp-visible");
bandplanStripEl.replaceChildren();
bandplanCacheKey = "";
if (bandplanCacheKey) {
bandplanStripEl.replaceChildren();
bandplanCacheKey = "";
}
bandplanStripEl.classList.toggle("bp-visible", reserveSpace);
bandplanStripEl.classList.toggle("bp-empty", reserveSpace);
}
function updateBandplanStrip(range: SpectrumRange | null) {
if (!bandplanStripEl) return;
if (!range || bandplanRegion === "off" || !bandplanData) {
if (bandplanStripEl.classList.contains("bp-visible")) _hideBandplanStrip();
_clearBandplanStrip(false);
return;
}
const segments = bandplanVisibleSegments(bandplanRegion, range.visLoHz, range.visHiHz);
if (segments.length === 0) {
if (bandplanStripEl.classList.contains("bp-visible")) _hideBandplanStrip();
_clearBandplanStrip(true);
return;
}
bandplanStripEl.classList.add("bp-visible");
bandplanStripEl.classList.remove("bp-empty");
const newKey = bandplanRegion + ":" + (bandplanShowLabels ? "L" : "N") + ":" +
segments.map((s) => s.low_hz + "-" + s.high_hz).join(",");
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import type * as Leaflet from "leaflet";
import { aprsSymbolSprite } from "./plugins/aprs-shared";
export {};
@@ -1979,33 +1980,36 @@ const mapWindow = window as unknown as MapWindow;
if (aprsMap) aprsMap.invalidateSize();
return;
}
// Everything below is the windowed path — the fullscreen branch returned.
// The map tab is a whole page, so the stage fills the column down to the
// footer. Capping it at a fraction of the viewport, or at a width-derived
// aspect ratio, left a dead band under the map that grew with the window
// (and on narrow screens made the map barely a third of the page).
const mapRect = mapContainer.getBoundingClientRect();
const width = mapContainer.clientWidth || mapRect.width;
const footer = document.querySelector(".footer");
let bottom = mapIsFullscreen() && stage
? stage.getBoundingClientRect().bottom
: window.innerHeight;
if (!mapIsFullscreen() && footer) {
let bottom = window.innerHeight;
if (footer) {
const fr = footer.getBoundingClientRect();
if (fr.top > mapRect.top + 50) bottom = fr.top;
// Clamped to the viewport: once the column is tall enough to push the
// footer below the fold, growing into it would push it further still.
if (fr.top > mapRect.top + 50) bottom = Math.min(fr.top, bottom);
}
const available = Math.max(0, Math.floor(bottom - mapRect.top - 8));
const widthDriven = width > 0 ? Math.floor(width / 1.55) : available;
const viewportCap = mapIsFullscreen()
? Math.floor(window.innerHeight * 0.9)
: Math.floor(window.innerHeight * 0.75);
const minHeight = Math.min(260, available);
const target = Math.max(minHeight, Math.min(available, viewportCap, widthDriven));
const target = Math.max(0, Math.floor(bottom - mapRect.top - 8));
mapContainer.style.height = `${target}px`;
if (aprsMap) aprsMap.invalidateSize();
}
function aprsSymbolIcon(symbolTable: string, symbolCode: string): Leaflet.DivIcon | null {
if (!symbolTable || !symbolCode) return null;
const table = symbolTable === "/" ? "primary" : "alternate";
const sprite = aprsSymbolSprite(symbolTable, symbolCode);
const html = sprite
? `<div class="aprs-symbol aprs-symbol-marker ${sprite.className}" role="img"` +
` style="background-position:${sprite.backgroundPosition}"` +
` title="${escapeMapHtml(sprite.label)}" aria-label="${escapeMapHtml(sprite.label)}"></div>`
: `<div class="aprs-symbol aprs-symbol-marker aprs-symbol-local" title="${symbolTable === "/" ? "primary" : "alternate"} APRS symbol ${escapeMapHtml(symbolCode)}">${escapeMapHtml(symbolCode)}</div>`;
return L.divIcon({
className: "",
html: `<div class="aprs-symbol-local" title="${table} APRS symbol ${escapeMapHtml(symbolCode)}">${escapeMapHtml(symbolCode)}</div>`,
html,
iconSize: [24, 24],
iconAnchor: [12, 12],
popupAnchor: [0, -12]
@@ -108,8 +108,80 @@ function escapeAprsCharacter(character: string): string {
return character;
}
// The vendored sprite sheets (assets/web/vendor/aprs-symbols-24-*.png) are
// 16x6 grids of 24px cells covering the printable codes 0x21..0x7E, so a
// symbol's cell index is simply `code - 0x21`. The sheet URLs live in CSS so
// the retina variants can be picked up by a media query; only the cell offsets
// are computed here.
const APRS_SPRITE_COLUMNS = 16;
const APRS_SPRITE_CELL_PX = 24;
const APRS_SPRITE_FIRST_CODE = 0x21;
const APRS_SPRITE_LAST_CODE = 0x7e;
export interface AprsSymbolSprite {
/** Sheet modifier class appended to `.aprs-symbol`. */
className: string;
/** `background-position` covering the overlay layer first, if any. */
backgroundPosition: string;
/** Human-readable description for the tooltip. */
label: string;
}
function aprsSpriteOffset(code: string): string | null {
if (code.length !== 1) return null;
const point = code.charCodeAt(0);
if (point < APRS_SPRITE_FIRST_CODE || point > APRS_SPRITE_LAST_CODE) return null;
const index = point - APRS_SPRITE_FIRST_CODE;
const column = index % APRS_SPRITE_COLUMNS;
const row = Math.floor(index / APRS_SPRITE_COLUMNS);
return `${String(-column * APRS_SPRITE_CELL_PX)}px ${String(-row * APRS_SPRITE_CELL_PX)}px`;
}
/**
* Resolve an APRS table/code pair to a sprite cell. A table identifier of `/`
* selects the primary sheet and `\` the alternate one; any other character is
* an overlay, which draws that character from the overlay sheet on top of the
* alternate symbol. Returns null when the pair is outside the sprite sheets,
* leaving callers to fall back to the raw character.
*/
export function aprsSymbolSprite(
symbolTable: string | null | undefined,
symbolCode: string | null | undefined,
): AprsSymbolSprite | null {
if (!symbolTable || !symbolCode) return null;
const symbolOffset = aprsSpriteOffset(symbolCode);
if (!symbolOffset) return null;
if (symbolTable === "/") {
return {
className: "aprs-symbol-primary",
backgroundPosition: symbolOffset,
label: `Primary APRS symbol ${symbolTable}${symbolCode}`,
};
}
if (symbolTable === "\\") {
return {
className: "aprs-symbol-alternate",
backgroundPosition: symbolOffset,
label: `Alternate APRS symbol ${symbolTable}${symbolCode}`,
};
}
const overlayOffset = aprsSpriteOffset(symbolTable);
if (!overlayOffset) return null;
return {
className: "aprs-symbol-overlaid",
backgroundPosition: `${overlayOffset}, ${symbolOffset}`,
label: `Alternate APRS symbol \\${symbolCode} with overlay ${symbolTable}`,
};
}
export function renderLocalAprsSymbol(packet: AprsPacket, escapeHtml: (value: string) => string): string {
if (!packet.symbolTable || !packet.symbolCode) return "";
const sprite = aprsSymbolSprite(packet.symbolTable, packet.symbolCode);
if (sprite) {
return `<span class="aprs-symbol ${sprite.className}" role="img"` +
` style="background-position:${sprite.backgroundPosition}"` +
` title="${escapeHtml(sprite.label)}" aria-label="${escapeHtml(sprite.label)}"></span>`;
}
const symbol = escapeHtml(packet.symbolCode);
const table = packet.symbolTable === "/" ? "Primary" : "Alternate";
return `<span class="aprs-symbol aprs-symbol-local" title="${table} APRS symbol ${symbol}">${symbol}</span>`;
@@ -422,6 +422,10 @@ function elementById<T extends HTMLElement>(id: string): T {
return needed <= bar.clientWidth;
};
const reflowOverflow = () => {
const nav = document.querySelector<HTMLElement>(".tab-bar-nav");
// Measure from the roomiest state every time, so the decision is a
// function of the current widths alone and cannot ratchet.
nav?.classList.remove("nav-icons-only");
overflowOrder.forEach((selector) => {
const element = menu.querySelector<HTMLElement>(selector);
if (element) actions.insertBefore(element, wrap);
@@ -434,6 +438,12 @@ function elementById<T extends HTMLElement>(id: string): T {
wrap.hidden = false;
menu.appendChild(element);
}
// Last resort, once every movable control is already in the menu: drop
// the tabs to their icons. Without it the nav — which may shrink below
// its content — keeps its tabs at full width and runs them under the
// controls, so the destinations nearest the controls become unclickable.
// Icon widths are fixed, so this always buys back the labels' width.
if (nav && !barFits()) nav.classList.add("nav-icons-only");
wrap.hidden = menu.children.length === 0;
if (wrap.hidden) closeMenu();
};
@@ -0,0 +1,85 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import test from "node:test";
import vm from "node:vm";
import { readFile } from "node:fs/promises";
import { bundleEntry } from "./bundle-entry.mjs";
const sharedUrl = new URL("../src/plugins/aprs-shared.ts", import.meta.url);
const stylePath = new URL("../../assets/web/style.css", import.meta.url);
async function loadShared() {
const source = await bundleEntry(sharedUrl, "trxAprsShared");
const context = vm.createContext({ Math, String, Number, Array, Date, Set, console });
new vm.Script(source).runInContext(context);
return context.trxAprsShared;
}
// The sheets are 16x6 grids of 24px cells covering 0x21..0x7E, so cell index
// is `code - 0x21`. Getting this wrong shifts every station to a neighbouring
// icon, which is invisible in a screenshot but wrong on every packet.
test("sprite cells are indexed from the first printable symbol code", async () => {
const { aprsSymbolSprite } = await loadShared();
const first = aprsSymbolSprite("/", "!");
assert.equal(first.className, "aprs-symbol-primary");
assert.equal(first.backgroundPosition, "0px 0px");
assert.equal(first.label, "Primary APRS symbol /!");
// '>' is 0x3E -> index 29 -> column 13, row 1.
assert.equal(aprsSymbolSprite("/", ">").backgroundPosition, "-312px -24px");
// '~' is 0x7E -> index 93 -> the last cell of the last row.
assert.equal(aprsSymbolSprite("/", "~").backgroundPosition, "-312px -120px");
});
test("the table identifier selects the primary, alternate, or overlay sheet", async () => {
const { aprsSymbolSprite } = await loadShared();
assert.equal(aprsSymbolSprite("/", "_").className, "aprs-symbol-primary");
assert.equal(aprsSymbolSprite("\\", "_").className, "aprs-symbol-alternate");
// An alphanumeric table identifier is an overlay character drawn on top of
// the alternate symbol: overlay cell first, then the symbol cell.
const overlaid = aprsSymbolSprite("S", ">");
assert.equal(overlaid.className, "aprs-symbol-overlaid");
assert.equal(overlaid.backgroundPosition, "-48px -72px, -312px -24px");
assert.equal(overlaid.label, "Alternate APRS symbol \\> with overlay S");
});
test("codes outside the sprite sheets fall back to the raw character", async () => {
const { aprsSymbolSprite, renderLocalAprsSymbol } = await loadShared();
const escape = (value) => value;
assert.equal(aprsSymbolSprite("/", " "), null);
assert.equal(aprsSymbolSprite("/", ""), null);
assert.equal(aprsSymbolSprite("/", "ab"), null);
assert.equal(aprsSymbolSprite(null, ">"), null);
assert.equal(renderLocalAprsSymbol({ symbolTable: "/", symbolCode: " " }, escape),
'<span class="aprs-symbol aprs-symbol-local" title="Primary APRS symbol "> </span>');
assert.equal(renderLocalAprsSymbol({}, escape), "");
});
test("rendered symbols carry a sprite class and an inline cell offset", async () => {
const { renderLocalAprsSymbol } = await loadShared();
const html = renderLocalAprsSymbol({ symbolTable: "/", symbolCode: ">" }, (value) => value);
assert.match(html, /class="aprs-symbol aprs-symbol-primary"/);
assert.match(html, /style="background-position:-312px -24px"/);
assert.match(html, /aria-label="Primary APRS symbol \/>"/);
assert.equal(html.includes("http"), false);
});
// The cell offsets are computed in the bundles, so the sheet URLs and the grid
// geometry have to stay in lockstep with them here.
test("the stylesheet serves every sheet locally at the sprite geometry", async () => {
const css = await readFile(stylePath, "utf8");
assert.match(css, /\.aprs-symbol\s*\{[^}]*background-size:\s*384px 144px/);
for (const sheet of ["24-0", "24-1", "24-2", "24-0-2x", "24-1-2x", "24-2-2x"]) {
assert.ok(css.includes(`url('/vendor/aprs-symbols-${sheet}.png')`), `missing sheet ${sheet}`);
}
assert.match(css, /\.aprs-symbol-overlaid\s*\{[^}]*aprs-symbols-24-2\.png'\), url\('\/vendor\/aprs-symbols-24-1\.png'\)/);
});
@@ -3,167 +3,18 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import http from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
const frontendDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const webDir = path.resolve(frontendDir, "../assets/web");
const generatedDir = path.join(webDir, "generated");
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
remote,
display_name: remote === "rig-a" ? "Primary fixture" : "Secondary fixture",
manufacturer: "Smoke",
model: "Fixture",
supported_modes: ["FM"],
tx: false,
filter_controls: false,
initialized: true,
latitude: null,
longitude: null,
}));
const rigsResponse = { rigs: rigItems, active_remote: "rig-a" };
const selectedRigs = [];
// page.evaluate callbacks run in the browser, not in this Node process.
/* global document, getComputedStyle */
const jsonRoutes = new Map([
["/auth/session", { authenticated: true, role: "control", auth_disabled: true }],
["/decoders", []],
["/rigs", rigsResponse],
["/status", {
info: {
manufacturer: "Smoke",
model: "Fixture",
revision: "1",
access: { Tcp: { addr: "127.0.0.1:0" } },
capabilities: {
min_freq_step_hz: 1,
supported_bands: [],
supported_modes: ["FM"],
num_vfos: 1,
lock: false,
lockable: false,
attenuator: false,
preamp: false,
rit: false,
rpt: false,
split: false,
tx: false,
tx_limit: false,
vfo_switch: false,
filter_controls: false,
signal_meter: false,
},
},
status: { freq: { hz: 100_000_000 }, mode: "FM", tx_en: false, vfo: null, tx: null, rx: null, lock: null },
band: null,
enabled: true,
initialized: true,
cw_auto: false,
cw_wpm: 20,
cw_tone_hz: 700,
aprs_decode_enabled: false,
hf_aprs_decode_enabled: false,
cw_decode_enabled: false,
ft8_decode_enabled: false,
ft4_decode_enabled: false,
ft2_decode_enabled: false,
wspr_decode_enabled: false,
lrpt_decode_enabled: false,
wefax_decode_enabled: false,
recorder_enabled: false,
clients: 1,
rigctl_clients: 0,
audio_clients: 0,
active_remote: "rig-a",
remotes: ["rig-a", "rig-b"],
show_sdr_gain_control: false,
initial_map_zoom: 10,
spectrum_coverage_margin_hz: 50_000,
spectrum_usable_span_ratio: 0.92,
bandplan_enabled: false,
bandplan_region: "iaru1",
decode_history_retention_min: 1440,
server_connected: true,
}],
["/bandplan.json", {}],
["/api/recorder/status", []],
["/api/recorder/files", []],
]);
const contentTypes = new Map([
[".css", "text/css; charset=utf-8"],
[".html", "text/html; charset=utf-8"],
[".js", "application/javascript; charset=utf-8"],
[".json", "application/json; charset=utf-8"],
[".png", "image/png"],
[".woff2", "font/woff2"],
]);
function assetPath(urlPath) {
if (urlPath === "/") return path.join(webDir, "index.html");
if (urlPath.startsWith("/vendor/")) return path.join(webDir, urlPath);
const generated = path.join(generatedDir, path.basename(urlPath));
if (urlPath.endsWith(".js")) return generated;
return path.join(webDir, urlPath);
}
const server = http.createServer(async (request, response) => {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
if (url.pathname === "/select_rig" && request.method === "POST") {
const remote = url.searchParams.get("remote");
if (remote) {
rigsResponse.active_remote = remote;
jsonRoutes.get("/status").active_remote = remote;
selectedRigs.push(remote);
}
response.writeHead(200).end();
return;
}
if (jsonRoutes.has(url.pathname)) {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify(jsonRoutes.get(url.pathname)));
return;
}
if (url.pathname === "/audio") {
response.writeHead(404).end();
return;
}
if (["/events", "/decode", "/spectrum", "/meter"].includes(url.pathname)) {
response.writeHead(200, {
"cache-control": "no-cache",
connection: "keep-alive",
"content-type": "text/event-stream",
});
response.write(": browser smoke stream\n\n");
return;
}
try {
const file = assetPath(url.pathname);
const bytes = await readFile(file);
response.writeHead(200, {
"content-type": contentTypes.get(path.extname(file)) ?? "application/octet-stream",
});
response.end(bytes);
} catch {
response.writeHead(404).end();
}
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
assert(address && typeof address === "object");
const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
?? "/usr/bin/chromium";
const browser = await chromium.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
const page = await browser.newPage();
const runtimeErrors = [];
page.on("pageerror", (error) => runtimeErrors.push(error.stack ?? error.message));
const fixture = await startWebFixture();
const { selectedRigs } = fixture;
const { browser, page, runtimeErrors } = await startBrowser(chromium);
try {
await page.goto(`http://127.0.0.1:${address.port}/`, { waitUntil: "domcontentloaded" });
await page.goto(`${fixture.origin}/`, { waitUntil: "domcontentloaded" });
await page.waitForTimeout(500);
assert.deepEqual(runtimeErrors, []);
await page.locator("#content").waitFor({ state: "visible" });
@@ -172,6 +23,24 @@ try {
await page.locator("summary", { hasText: "Audio controls" }).click();
assert.equal(await page.locator("#rx-audio-btn").count(), 1);
// Scheduler controls read left to right: step, hand back, then the entry on
// air. The separator is drawn by the current-entry block, so it can only sit
// in the right place if that block is last.
const schedulerRow = await page.evaluate(() => [...document.querySelectorAll(".scheduler-action-row > *")]
.map((el) => el.id || [...el.children].map((c) => c.id).join("+")));
assert.deepEqual(schedulerRow,
["scheduler-prev-btn+scheduler-next-btn", "scheduler-release-btn", "scheduler-cycle-status"],
`scheduler control order is ${JSON.stringify(schedulerRow)}`);
// The footer status pill colours its dot from data-state, so a hint written
// straight to textContent would leave the dot stuck on the previous state.
const hint = await page.evaluate(() => {
const element = document.getElementById("power-hint");
return { state: element.dataset.state, text: element.textContent.trim() };
});
assert.ok(["ok", "busy", "error"].includes(hint.state), `status pill state is ${hint.state}`);
assert.equal(hint.state, "ok", `fixture reports "${hint.text}" but the pill is ${hint.state}`);
const rigPicker = page.locator("#header-rig-switch-select");
await rigPicker.locator("option").nth(1).waitFor({ state: "attached" });
await rigPicker.selectOption("rig-b");
@@ -181,6 +50,44 @@ try {
await page.locator('.tab[data-tab="map"]').click();
await page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
assert.equal(new URL(page.url()).pathname, "/map");
// The selected destination is marked by a box on all four sides, so a rule
// that drops one edge (the mobile nav used to lose its bottom border) is a
// regression even though the tab still reads as "active".
const activeTab = await page.evaluate(() => {
const style = getComputedStyle(document.querySelector(".tab-bar-nav .tab.active"));
return ["Top", "Right", "Bottom", "Left"].map((side) => ({
width: style.getPropertyValue(`border-${side.toLowerCase()}-width`),
color: style.getPropertyValue(`border-${side.toLowerCase()}-color`),
}));
});
for (const edge of activeTab) {
assert.notEqual(edge.width, "0px", `active tab border: ${JSON.stringify(activeTab)}`);
assert.ok(!/rgba\(0, 0, 0, 0\)|transparent/.test(edge.color), `active tab border: ${JSON.stringify(activeTab)}`);
}
// The map is full-bleed: it breaks out of the centred .card column and
// reaches both viewport edges, without pushing the page sideways.
const stage = await page.evaluate(() => {
const rect = document.getElementById("map-stage").getBoundingClientRect();
return {
left: Math.round(rect.left),
right: Math.round(rect.right),
viewport: document.documentElement.clientWidth,
sideways: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
gapToFooter: Math.round(document.querySelector(".footer").getBoundingClientRect().top - rect.bottom),
pageScrolls: document.documentElement.scrollHeight > document.documentElement.clientHeight + 1,
};
});
assert.equal(stage.left, 0, `map stage starts at ${stage.left}px, not the viewport edge`);
assert.equal(stage.right, stage.viewport, `map stage ends at ${stage.right}px, not ${stage.viewport}px`);
assert.equal(stage.sideways, false, "full-bleed map makes the page scroll sideways");
// ...and fills the column down to the footer. Capping the height at a
// fraction of the viewport left a dead band that grew with the window.
assert.ok(stage.gapToFooter <= 16,
`${stage.gapToFooter}px of dead space between the map and the footer`);
assert.equal(stage.pageScrolls, false, "the map grew past the viewport");
await page.locator('.tab[data-tab="main"]').click();
assert.equal(new URL(page.url()).pathname, "/");
@@ -195,7 +102,102 @@ try {
await page.locator("#tab-main").waitFor({ state: "visible" });
assert.equal(new URL(page.url()).pathname, "/");
assert.deepEqual(runtimeErrors, []);
// Refreshing or deep-linking must mark the destination, not Tools. The first
// route navigation runs while the card is still behind the loading state, so
// a test that asked whether the tab was displayed saw "none" for every tab
// and lit Tools up on every refresh of every page.
for (const [route, tab, toolsLit] of [["/map", "map", false], ["/about", "about", true]]) {
await page.goto(`${fixture.origin}${route}`, { waitUntil: "domcontentloaded" });
await page.locator(`#tab-${tab}`).waitFor({ state: "visible" });
const marked = await page.evaluate(() => ({
actives: [...document.querySelectorAll(".tab-bar .tab.active")].map((t) => t.dataset.tab || t.id),
tools: document.getElementById("mobile-more-btn").classList.contains("active"),
}));
assert.ok(marked.actives.includes(tab), `${route} marks ${JSON.stringify(marked.actives)}`);
assert.equal(marked.tools, toolsLit, `${route}: Tools active is ${marked.tools}`);
}
await page.goto(`${fixture.origin}/`, { waitUntil: "domcontentloaded" });
await page.locator("#tab-main").waitFor({ state: "visible" });
assert.deepEqual(runtimeErrors, []);
// --- Layout regressions -------------------------------------------------
// Every fault below shipped at some point while the rest of this file
// passed, because nothing here looked at geometry: a header whose height
// tracked the viewport, controls that stretched, a tab strip that ran under
// the controls, and a dropdown that opened underneath the spectrum.
for (const width of [1440, 1280, 1100, 900]) {
await page.setViewportSize({ width, height: 900 });
await page.waitForTimeout(250);
const header = await page.evaluate(() => {
const bar = document.querySelector(".tab-bar");
const nav = document.querySelector(".tab-bar-nav");
const actions = document.querySelector(".top-bar-actions");
const controls = [...actions.children]
.filter((el) => !el.hidden && el.getBoundingClientRect().height > 0)
.map((el) => Math.round(el.getBoundingClientRect().height));
return {
barHeight: Math.round(bar.getBoundingClientRect().height),
// The tabs, not the strip: with the strip allowed to overflow its box
// shrinks while its content paints across the controls, so the
// container's own rect never registers the collision.
overlap: Math.round(Math.max(...[...nav.querySelectorAll(".tab")]
.filter((tab) => tab.offsetParent !== null)
.map((tab) => tab.getBoundingClientRect().right))
- actions.getBoundingClientRect().left),
heights: [...new Set(controls)],
pageScrollsSideways: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
};
});
assert.ok(header.barHeight <= 96, `header is ${header.barHeight}px at ${width}px; it should stay one row`);
assert.ok(header.overlap <= 0, `tab strip overlaps the controls by ${header.overlap}px at ${width}px`);
assert.ok(header.heights.length <= 2, `controls have heights ${header.heights.join(", ")} at ${width}px`);
assert.equal(header.pageScrollsSideways, false, `page scrolls sideways at ${width}px`);
}
// A dropdown must paint over the page, not inside the header: fixed
// positioning escapes clipping but not the header's stacking context.
await page.setViewportSize({ width: 1280, height: 900 });
await page.waitForTimeout(250);
const menu = await page.evaluate(() => {
document.getElementById("mobile-more-btn").click();
const element = document.getElementById("mobile-more-menu");
const rect = element.getBoundingClientRect();
const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
return { width: Math.round(rect.width), height: Math.round(rect.height), onTop: element.contains(hit) };
});
assert.ok(menu.height > 40 && menu.width > 80, `menu rendered ${menu.width}x${menu.height}`);
assert.ok(menu.onTop, "menu is painted underneath the page");
// Wider text than this machine renders. The checks above passed on macOS
// while CI, whose system font is wider, put the tab strip 9px into the
// controls: the bar had run out of controls to move into the overflow menu
// and the tabs kept their full width anyway. Scaling the bar's own text
// reproduces that on any machine. The tabs carry their font size themselves,
// so the parent size alone does not cascade to them.
await page.addStyleTag({ content: `
.tab-bar { font-size: 160%; }
.tab-bar .tab, .tab-bar select, .tab-bar button { font-size: 1.52rem; }
` });
for (const width of [1440, 1280, 1100]) {
await page.setViewportSize({ width, height: 900 });
await page.waitForTimeout(250);
const crowded = await page.evaluate(() => {
const nav = document.querySelector(".tab-bar-nav");
const actions = document.querySelector(".top-bar-actions");
const tabs = [...nav.querySelectorAll(".tab")].filter((tab) => tab.offsetParent !== null);
return {
overlap: Math.round(Math.max(...tabs.map((tab) => tab.getBoundingClientRect().right))
- actions.getBoundingClientRect().left),
iconsOnly: nav.classList.contains("nav-icons-only"),
};
});
assert.ok(crowded.overlap <= 0,
`with wider text the tab strip overlaps the controls by ${crowded.overlap}px at ${width}px`);
assert.equal(crowded.iconsOnly, true, `the strip kept its labels at ${width}px with no room for them`);
}
} finally {
await browser.close();
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
await fixture.close();
}
@@ -4,7 +4,7 @@
import { build } from "esbuild";
export async function bundleEntry(entryUrl) {
export async function bundleEntry(entryUrl, globalName) {
const result = await build({
entryPoints: [entryUrl.pathname],
bundle: true,
@@ -12,6 +12,7 @@ export async function bundleEntry(entryUrl) {
platform: "browser",
target: "es2022",
write: false,
...(globalName ? { globalName } : {}),
});
const output = result.outputFiles[0];
if (!output) throw new Error(`No bundle output for ${entryUrl.pathname}`);
@@ -0,0 +1,127 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// Geometry of the spectrum area while tuning. Everything above the spectrum is
// driven by what happens to be in the visible range — band plan allocations,
// bookmarks — and the strips that show them used to appear and disappear with
// it, so tuning across a band edge moved the whole page under the operator's
// cursor. Nothing else in the suite serves a rig with a spectrum.
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
/* global document */
const BOOKMARKS = [
{ id: "b1", name: "40m FT8", freq_hz: 7074000, mode: "DIG", category: "Digital", comment: "", locator: "" },
{ id: "b2", name: "40m CW", freq_hz: 7030000, mode: "CW", category: "", comment: "", locator: "" },
];
const BANDPLAN = {
iaru1: {
bands: [{
name: "40m",
low_hz: 7000000,
high_hz: 7200000,
segments: [
{ low_hz: 7000000, high_hz: 7040000, mode: "CW", label: "CW" },
{ low_hz: 7040000, high_hz: 7200000, mode: "All", label: "All modes" },
],
}],
},
};
// 40m has both bookmarks and allocations; 20m has neither.
const BAND_WITH_CONTENT = 7074000;
const BAND_WITHOUT_CONTENT = 14074000;
const fixture = await startWebFixture({
spectrum: true,
bookmarks: BOOKMARKS,
bandplan: BANDPLAN,
bandplanEnabled: true,
});
const { browser, page, runtimeErrors } = await startBrowser(chromium);
function readGeometry() {
const top = (selector) => {
const el = document.querySelector(selector);
return el ? Math.round(el.getBoundingClientRect().top) : null;
};
const axis = document.getElementById("spectrum-bookmark-axis");
const strip = document.getElementById("spectrum-bandplan-strip");
return {
chips: axis.querySelectorAll(".spectrum-bookmark-chip").length,
axisEmpty: axis.classList.contains("bm-axis-empty"),
stripReserved: strip.classList.contains("bp-visible"),
stripEmpty: strip.classList.contains("bp-empty"),
overviewTop: top(".overview-strip"),
spectrumTop: top("#spectrum-panel"),
controlsTop: top(".controls-row"),
footerTop: top(".footer"),
docHeight: document.documentElement.scrollHeight,
};
}
const layoutOf = (geometry) => ({
overviewTop: geometry.overviewTop,
spectrumTop: geometry.spectrumTop,
controlsTop: geometry.controlsTop,
footerTop: geometry.footerTop,
docHeight: geometry.docHeight,
});
async function tuneTo(hz) {
fixture.setCenterHz(hz);
await page.waitForTimeout(900);
return page.evaluate(readGeometry);
}
try {
await page.setViewportSize({ width: 1600, height: 950 });
await page.goto(fixture.origin, { waitUntil: "domcontentloaded" });
await page.locator("#content").waitFor({ state: "visible" });
await page.locator("#spectrum-panel").waitFor({ state: "visible" });
await page.waitForTimeout(1500);
const populated = await tuneTo(BAND_WITH_CONTENT);
assert.equal(populated.chips, BOOKMARKS.length, `expected both bookmarks, saw ${populated.chips}`);
assert.equal(populated.axisEmpty, false, "bookmark rail claims to be empty with chips in it");
assert.equal(populated.stripReserved, true, "band plan strip is missing on a band with allocations");
assert.equal(populated.stripEmpty, false, "band plan strip claims to be empty with segments in it");
const bare = await tuneTo(BAND_WITHOUT_CONTENT);
assert.equal(bare.chips, 0, `expected no bookmarks on ${BAND_WITHOUT_CONTENT}Hz, saw ${bare.chips}`);
assert.equal(bare.axisEmpty, true, "bookmark rail should show its placeholder");
assert.equal(bare.stripReserved, true, "band plan strip gave its height back");
assert.equal(bare.stripEmpty, true, "band plan strip should be marked empty");
// The point of both placeholders: tuning must not move anything.
assert.deepEqual(layoutOf(bare), layoutOf(populated),
`tuning off the band moved the page: ${JSON.stringify(populated)} -> ${JSON.stringify(bare)}`);
const back = await tuneTo(BAND_WITH_CONTENT);
assert.equal(back.chips, BOOKMARKS.length, "bookmarks did not come back");
assert.deepEqual(layoutOf(back), layoutOf(populated), "tuning back moved the page");
// The rail covers the top of the overview, so only the chips may take
// pointer events — the rest has to fall through to the plot behind it.
const hits = await page.evaluate(() => {
const chip = document.querySelector("#spectrum-bookmark-axis .spectrum-bookmark-chip");
const chipRect = chip.getBoundingClientRect();
const axisRect = document.getElementById("spectrum-bookmark-axis").getBoundingClientRect();
const onChip = document.elementFromPoint(chipRect.left + chipRect.width / 2, chipRect.top + chipRect.height / 2);
const besideChip = document.elementFromPoint(axisRect.right - 30, axisRect.top + 10);
return {
chip: onChip?.closest(".spectrum-bookmark-chip") ? "chip" : (onChip?.id || onChip?.tagName),
besideChip: besideChip?.id || besideChip?.tagName,
};
});
assert.equal(hits.chip, "chip", `chip is not clickable, hit ${hits.chip}`);
assert.equal(hits.besideChip, "overview-canvas", `rail swallows events, hit ${hits.besideChip}`);
assert.deepEqual(runtimeErrors, []);
} finally {
await browser.close();
await fixture.close();
}
@@ -0,0 +1,238 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// The static server the browser tests load the real web assets from. It was
// inline in browser-smoke.mjs until a second browser test needed a rig with a
// spectrum: the interesting layout lives in the spectrum area, and none of it
// could be exercised while the only fixture served a CAT-only rig.
import { readFile } from "node:fs/promises";
import http from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
const frontendDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const webDir = path.resolve(frontendDir, "../assets/web");
const generatedDir = path.join(webDir, "generated");
// A realistic decoder registry. Serving an empty one hid most of the
// application from this test: the decoder sub-tabs, their panels, the decode
// toggles and the bookmark decoder checkboxes are all built from it, so with
// no decoders only three of thirteen sub-tabs existed and none of the decoder
// UI was ever constructed.
const DECODER_REGISTRY = [
{ id: "ft8", label: "FT8", activation: "toggle", active_modes: ["USB"] },
{ id: "ft4", label: "FT4", activation: "toggle", active_modes: ["USB"] },
{ id: "ft2", label: "FT2", activation: "toggle", active_modes: ["USB"] },
{ id: "wspr", label: "WSPR", activation: "toggle", active_modes: ["USB"] },
{ id: "cw", label: "CW", activation: "toggle", active_modes: ["CW", "CWR"] },
{ id: "sat", label: "SAT", activation: "toggle", active_modes: ["FM"] },
{ id: "wefax", label: "WEFAX", activation: "toggle", active_modes: ["USB"] },
{ id: "ais", label: "AIS", activation: "toggle", active_modes: ["FM"] },
{ id: "aprs", label: "APRS", activation: "toggle", active_modes: ["FM"] },
{ id: "hf-aprs", label: "HF APRS", activation: "toggle", active_modes: ["USB"] },
{ id: "vdes", label: "VDES", activation: "toggle", active_modes: ["FM"] },
].map((decoder) => ({ ...decoder, background_decode: false, bookmark_selectable: true }));
const CONTENT_TYPES = new Map([
[".css", "text/css; charset=utf-8"],
[".html", "text/html; charset=utf-8"],
[".js", "application/javascript; charset=utf-8"],
[".json", "application/json; charset=utf-8"],
[".png", "image/png"],
[".woff2", "font/woff2"],
]);
function assetPath(urlPath) {
// Every tab route has its own index handler on the server (see api/assets.rs),
// so a deep link or a refresh serves the SPA shell, not a 404.
if (!path.extname(urlPath)) return path.join(webDir, "index.html");
if (urlPath.startsWith("/vendor/")) return path.join(webDir, urlPath);
const generated = path.join(generatedDir, path.basename(urlPath));
if (urlPath.endsWith(".js")) return generated;
return path.join(webDir, urlPath);
}
/**
* Starts the fixture server.
*
* `spectrum` turns the rig into an SDR: `filter_controls` is what gates the
* spectrum panel, and frames are pushed on the /spectrum stream from a centre
* frequency the test moves with `setCenterHz` to simulate tuning across bands.
*/
export async function startWebFixture({
spectrum = false,
bookmarks = [],
bandplan = {},
bandplanEnabled = false,
} = {}) {
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
remote,
display_name: remote === "rig-a" ? "Primary fixture" : "Secondary fixture",
manufacturer: "Smoke",
model: "Fixture",
supported_modes: ["FM"],
tx: false,
filter_controls: spectrum,
initialized: true,
latitude: null,
longitude: null,
}));
const rigsResponse = { rigs: rigItems, active_remote: "rig-a" };
const selectedRigs = [];
const state = { centerHz: 7074000 };
const status = {
info: {
manufacturer: "Smoke",
model: "Fixture",
revision: "1",
access: { Tcp: { addr: "127.0.0.1:0" } },
capabilities: {
min_freq_step_hz: 1,
supported_bands: [],
supported_modes: ["FM"],
num_vfos: 1,
lock: false,
lockable: false,
attenuator: false,
preamp: false,
rit: false,
rpt: false,
split: false,
tx: false,
tx_limit: false,
vfo_switch: false,
filter_controls: spectrum,
signal_meter: spectrum,
},
},
status: { freq: { hz: 100_000_000 }, mode: "FM", tx_en: false, vfo: null, tx: null, rx: null, lock: null },
band: null,
enabled: true,
initialized: true,
cw_auto: false,
cw_wpm: 20,
cw_tone_hz: 700,
aprs_decode_enabled: false,
hf_aprs_decode_enabled: false,
cw_decode_enabled: false,
ft8_decode_enabled: false,
ft4_decode_enabled: false,
ft2_decode_enabled: false,
wspr_decode_enabled: false,
lrpt_decode_enabled: false,
wefax_decode_enabled: false,
recorder_enabled: false,
clients: 1,
rigctl_clients: 0,
audio_clients: 0,
active_remote: "rig-a",
remotes: ["rig-a", "rig-b"],
show_sdr_gain_control: false,
initial_map_zoom: 10,
spectrum_coverage_margin_hz: 50_000,
spectrum_usable_span_ratio: 0.92,
bandplan_enabled: bandplanEnabled,
bandplan_region: "iaru1",
decode_history_retention_min: 1440,
server_connected: true,
};
const jsonRoutes = new Map([
["/auth/session", { authenticated: true, role: "control", auth_disabled: true }],
["/decoders", DECODER_REGISTRY],
["/rigs", rigsResponse],
["/status", status],
["/bookmarks", bookmarks],
["/bandplan.json", bandplan],
["/api/recorder/status", []],
["/api/recorder/files", []],
]);
// Flat i8 bins: the shape does not matter, only that frames arrive so the
// page has a spectrum range to place bookmarks and allocations against.
const spectrumBins = Buffer.alloc(512, 200);
const spectrumB64 = spectrumBins.toString("base64");
const server = http.createServer(async (request, response) => {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
if (url.pathname === "/select_rig" && request.method === "POST") {
const remote = url.searchParams.get("remote");
if (remote) {
rigsResponse.active_remote = remote;
status.active_remote = remote;
selectedRigs.push(remote);
}
response.writeHead(200).end();
return;
}
if (jsonRoutes.has(url.pathname)) {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify(jsonRoutes.get(url.pathname)));
return;
}
if (url.pathname === "/audio") {
response.writeHead(404).end();
return;
}
if (spectrum && url.pathname === "/spectrum") {
response.writeHead(200, {
"cache-control": "no-cache",
connection: "keep-alive",
"content-type": "text/event-stream",
});
const timer = setInterval(() => {
response.write(`event: b\ndata: ${state.centerHz},192000,${spectrumB64}\n\n`);
}, 100);
request.on("close", () => clearInterval(timer));
return;
}
if (["/events", "/decode", "/spectrum", "/meter"].includes(url.pathname)) {
response.writeHead(200, {
"cache-control": "no-cache",
connection: "keep-alive",
"content-type": "text/event-stream",
});
response.write(": browser smoke stream\n\n");
return;
}
try {
const file = assetPath(url.pathname);
const bytes = await readFile(file);
response.writeHead(200, {
"content-type": CONTENT_TYPES.get(path.extname(file)) ?? "application/octet-stream",
});
response.end(bytes);
} catch {
response.writeHead(404).end();
}
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address !== "object") throw new Error("fixture server has no port");
return {
origin: `http://127.0.0.1:${address.port}`,
selectedRigs,
rigsResponse,
/** Moves the spectrum centre, i.e. tunes the fixture rig to another band. */
setCenterHz(hz) { state.centerHz = hz; },
close() {
return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
},
};
}
/** Launches headless Chromium and records any uncaught page error. */
export async function startBrowser(chromium) {
const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
?? "/usr/bin/chromium";
const browser = await chromium.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
const page = await browser.newPage();
const runtimeErrors = [];
page.on("pageerror", (error) => runtimeErrors.push(error.stack ?? error.message));
return { browser, page, runtimeErrors };
}
@@ -95,6 +95,18 @@ pub(crate) async fn about_index(req: HttpRequest) -> impl Responder {
static_asset_response(&req, "text/html; charset=utf-8", c)
}
#[get("/statistics")]
pub(crate) async fn statistics_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(&req, "text/html; charset=utf-8", c)
}
#[get("/bookmarks")]
pub(crate) async fn bookmarks_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(&req, "text/html; charset=utf-8", c)
}
// ---------------------------------------------------------------------------
// Favicon & logo
// ---------------------------------------------------------------------------
@@ -267,6 +279,47 @@ pub(crate) async fn leaflet_layers_2x() -> impl Responder {
.body(status::LEAFLET_LAYERS_2X)
}
// ---------------------------------------------------------------------------
// Vendored APRS symbol sprites
// ---------------------------------------------------------------------------
fn embedded_png(bytes: &'static [u8]) -> HttpResponse {
HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "image/png"))
.insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
.body(bytes)
}
#[get("/vendor/aprs-symbols-24-0.png")]
pub(crate) async fn aprs_symbols_primary() -> impl Responder {
embedded_png(status::APRS_SYMBOLS_PRIMARY)
}
#[get("/vendor/aprs-symbols-24-0-2x.png")]
pub(crate) async fn aprs_symbols_primary_2x() -> impl Responder {
embedded_png(status::APRS_SYMBOLS_PRIMARY_2X)
}
#[get("/vendor/aprs-symbols-24-1.png")]
pub(crate) async fn aprs_symbols_alternate() -> impl Responder {
embedded_png(status::APRS_SYMBOLS_ALTERNATE)
}
#[get("/vendor/aprs-symbols-24-1-2x.png")]
pub(crate) async fn aprs_symbols_alternate_2x() -> impl Responder {
embedded_png(status::APRS_SYMBOLS_ALTERNATE_2X)
}
#[get("/vendor/aprs-symbols-24-2.png")]
pub(crate) async fn aprs_symbols_overlay() -> impl Responder {
embedded_png(status::APRS_SYMBOLS_OVERLAY)
}
#[get("/vendor/aprs-symbols-24-2-2x.png")]
pub(crate) async fn aprs_symbols_overlay_2x() -> impl Responder {
embedded_png(status::APRS_SYMBOLS_OVERLAY_2X)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -295,4 +348,41 @@ mod tests {
);
assert_eq!(generated_content_type("secret.txt"), None);
}
/// Reads the width and height out of a PNG IHDR chunk.
fn png_dimensions(bytes: &[u8]) -> (u32, u32) {
assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n", "not a PNG");
assert_eq!(&bytes[12..16], b"IHDR", "first chunk is not IHDR");
let width = u32::from_be_bytes(bytes[16..20].try_into().expect("IHDR width"));
let height = u32::from_be_bytes(bytes[20..24].try_into().expect("IHDR height"));
(width, height)
}
/// The browser computes sprite cell offsets from a 16x6 grid of 24px cells,
/// so a sheet at any other size would silently shift every APRS symbol.
#[test]
fn aprs_symbol_sheets_match_the_sprite_grid_the_frontend_assumes() {
for (name, bytes) in [
("aprs-symbols-24-0", status::APRS_SYMBOLS_PRIMARY),
("aprs-symbols-24-1", status::APRS_SYMBOLS_ALTERNATE),
("aprs-symbols-24-2", status::APRS_SYMBOLS_OVERLAY),
] {
assert_eq!(
png_dimensions(bytes),
(384, 144),
"{name} is not a 16x6 grid"
);
}
for (name, bytes) in [
("aprs-symbols-24-0-2x", status::APRS_SYMBOLS_PRIMARY_2X),
("aprs-symbols-24-1-2x", status::APRS_SYMBOLS_ALTERNATE_2X),
("aprs-symbols-24-2-2x", status::APRS_SYMBOLS_OVERLAY_2X),
] {
assert_eq!(
png_dimensions(bytes),
(768, 288),
"{name} is not a 2x sheet"
);
}
}
}
@@ -637,6 +637,8 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(assets::recorder_index)
.service(assets::settings_index)
.service(assets::about_index)
.service(assets::statistics_index)
.service(assets::bookmarks_index)
.service(assets::favicon)
.service(assets::favicon_png)
.service(assets::logo)
@@ -656,6 +658,13 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(assets::leaflet_marker_shadow)
.service(assets::leaflet_layers)
.service(assets::leaflet_layers_2x)
// Vendored APRS symbol sprites
.service(assets::aprs_symbols_primary)
.service(assets::aprs_symbols_primary_2x)
.service(assets::aprs_symbols_alternate)
.service(assets::aprs_symbols_alternate_2x)
.service(assets::aprs_symbols_overlay)
.service(assets::aprs_symbols_overlay_2x)
.service(assets::generated_asset)
// Virtual channels
.service(vchan::list_channels)
@@ -37,6 +37,23 @@ pub const LEAFLET_MARKER_SHADOW: &[u8] = include_bytes!("../assets/web/vendor/ma
pub const LEAFLET_LAYERS: &[u8] = include_bytes!("../assets/web/vendor/layers.png");
pub const LEAFLET_LAYERS_2X: &[u8] = include_bytes!("../assets/web/vendor/layers-2x.png");
// Vendored APRS symbol sprites (https://github.com/hessu/aprs-symbols).
// Each sheet is a 16x6 grid of 24px cells indexed by `symbol code - 0x21`:
// table 0 is the primary ('/') set, table 1 the alternate ('\') set, and
// table 2 the overlay characters drawn on top of an alternate symbol.
pub const APRS_SYMBOLS_PRIMARY: &[u8] =
include_bytes!("../assets/web/vendor/aprs-symbols-24-0.png");
pub const APRS_SYMBOLS_PRIMARY_2X: &[u8] =
include_bytes!("../assets/web/vendor/aprs-symbols-24-0-2x.png");
pub const APRS_SYMBOLS_ALTERNATE: &[u8] =
include_bytes!("../assets/web/vendor/aprs-symbols-24-1.png");
pub const APRS_SYMBOLS_ALTERNATE_2X: &[u8] =
include_bytes!("../assets/web/vendor/aprs-symbols-24-1-2x.png");
pub const APRS_SYMBOLS_OVERLAY: &[u8] =
include_bytes!("../assets/web/vendor/aprs-symbols-24-2.png");
pub const APRS_SYMBOLS_OVERLAY_2X: &[u8] =
include_bytes!("../assets/web/vendor/aprs-symbols-24-2-2x.png");
/// Build version tag used for cache-busting asset URLs and ETag headers.
/// Computed once from `PKG_VERSION` + `CLIENT_BUILD_DATE`.
pub fn build_version_tag() -> &'static str {