Compare commits

..
Author SHA1 Message Date
sjg 09634eb851 [fix](trx-frontend-http): tidy up the map's filter bar
CI / lint (pull_request) Successful in 2m21s
CI / test (pull_request) Successful in 8m7s
CI / frontend (push) Successful in 2m57s
CI / reuse (push) Successful in 3s
CI / frontend (pull_request) Successful in 3m47s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m16s
CI / test (push) Successful in 7m19s
The bar explained itself in prose: "All bands visible by default" sat
between the chips and the next group, taking width the bar could not
spare and reading as a stray line of text. An "All" chip says the same
thing in a chip's width and gives the selection somewhere to be undone.

Band chips also came up dimmed at the very moment every band was on the
map -- an empty selection is no filter at all, so nothing is dimmed
until something is picked. The path toggles drop their "On"/"Off"
suffix, which cost most of a row and only repeated what their own
highlight already said; state moves to aria-pressed and the tooltip.

The rest is alignment. The rule dividing the buttons from the filters
is drawn on the button block's edge, and a centred block left it
floating as a stub beside a two-row bar; stacked, it lay down the left
of a block that sits underneath. The labels sat at their natural
widths, so each row's first control started somewhere different, and
the two pairs of phase buttons differed in width, so the groups after
them missed each other by four pixels. One gutter for every label, one
width for both pairs, and the search field moved last where it can take
the room the fixed-width groups leave.

The map layout test now covers the chips, the divider's height and the
rows' shared start.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 21:43:57 +02:00
sjg 90ab7781ad [fix](trx-frontend-http): stop the browser offering saved values for frequency
CI / lint (pull_request) Successful in 2m16s
CI / test (pull_request) Successful in 8m11s
CI / frontend (pull_request) Successful in 3m48s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m24s
CI / test (push) Successful in 7m23s
CI / frontend (push) Successful in 2m54s
CI / reuse (push) Successful in 3s
The tuned and centre frequency readouts are text inputs, so the browser
keeps what has been typed into them and offers it back in a dropdown --
Edge does this out of the box, dropping stale frequencies from other
sessions over the reading.

Turn autofill off on both, along with autocorrect and spellcheck, which
have no business near a number either.

Fixes #39

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 19:36:22 +02:00
sjg 88d04253ca [fix](trx-frontend-http): move the map's fullscreen and filter toggles into the bar
Fullscreen and Hide Filters floated in their own block over the map's
top-right corner, separate from the filter bar they sit beside.

Put them at the right-hand end of the bar, behind a separator. What made
this awkward before is that Hide Filters cannot live inside the thing it
hides, so the collapse now applies to the filters alone: the bar keeps its
two controls and shrinks to them at the map's right edge, leaving the whole
map visible and the way back one click away.

Fixes #38

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 19:34:09 +02:00
sjg 08005c5c07 [feat](trx-frontend-http): lay the map filters out as a bar across the top
CI / test (push) Successful in 8m6s
CI / frontend (push) Successful in 3m45s
CI / reuse (push) Successful in 3s
CI / lint (push) Successful in 2m15s
The filters were a 30rem column parked in the bottom-right corner, covering
a third of the map they filter. Lay them out horizontally instead: one row
per group -- label beside its control, thin rules between -- across the top
of the map, spanning ~87% of its width at 1600px and wrapping to a second
row as it narrows.

It starts clear of Leaflet's zoom buttons and stops short of the corner
controls, which stay outside it: the button that hides the filters cannot
live inside the thing it hides. The bottom-left band legend keeps its place.
The sentence explaining the two path toggles would have swallowed the bar,
so it moves to their tooltips and is shown inline only in the stacked
narrow-screen layout.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 07:54:21 +02:00
sjg 026f816ddb [fix](trx-frontend-http): replay stored decodes onto the map when it loads
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 8m12s
CI / frontend (push) Successful in 3m40s
CI / reuse (push) Successful in 2s
The map module is lazy: it arrives when the Map tab is first opened, which
is normally long after startup restored the decode history. Until then
aprsMapAddStation, aisMapAddVessel and vdesMapAddPoint are undefined, and
the decoders' `if (lat != null && ... && fn)` guards quietly dropped every
restored position. Nothing replayed them once the module did arrive, so the
map came up empty and filled in only from decodes heard afterwards -- a
station heard once was never plotted at all. A second reload appeared to
fix it because the cached module then loaded early enough to win the race
against the history fetch.

Give DecoderPlugin an optional syncMap(), implement it for APRS, AIS and
VDES over the history each already retains, and have map-core call
trxPluginRuntime.syncMapAll() as it attaches. The add functions are keyed
by callsign, MMSI and point, so replaying updates in place and cannot
duplicate a marker; the replay runs oldest-first so tracks are rebuilt in
the order they happened.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 07:34:45 +02:00
sjg d31b6f545f [test](trx-frontend-http): watch the history progress from inside the page
CI / lint (push) Successful in 2m16s
CI / test (push) Successful in 8m18s
CI / frontend (push) Successful in 3m38s
CI / reuse (push) Successful in 2s
The replay-progress check polled the overlay from the test every 100ms.
A replay that starts and finishes between two polls is never sampled, and
the test then reports that no progress was shown at all -- the source of
the intermittent "no progress was shown while the history loaded" failure.

Record the samples from a MutationObserver installed before the page's own
scripts run, so a fast replay is observed rather than missed.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 07:10:57 +02:00
sjg 39f551c914 [fix](trx-frontend-http): hold the APRS symbol column open for frames without one
renderLocalAprsSymbol() returns nothing when a packet carries no symbol
table or code, so those rows lost the icon's 24px slot and every column
after it -- callsign, type badge, summary -- slid left against the rows
around them. Frames that do carry a symbol then read as indented.

Render an empty slot of the same size instead, so a list mixing position
reports with messages and telemetry still lines up.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 07:10:33 +02:00
sjg 6d25ecdc11 [feat](trx-frontend-http): give the AIS list the same log shape
CI / lint (push) Successful in 2m15s
CI / test (push) Successful in 8m8s
CI / frontend (push) Successful in 3m41s
CI / reuse (push) Successful in 2s
A message was three stacked lines — time and name, then MMSI and route,
then motion, distance, position and age — so a screen held eight of
them.  It is one line now: time, vessel, message type, and what the
message says, opening in place for the MMSI, the channel frequency, the
route, the age, the fix and a jump to the map.  Twenty-two fit where
eight did.

What a message says depends on what it is.  Position reports give the
fix and the motion; the static and voyage reports that carry no fix give
the callsign and where the vessel is bound.  Both fall back to whatever
fields are present rather than showing nothing.

The row vocabulary the APRS list introduced is no longer APRS-specific —
the classes are decode-line and decode-expanded now, shared by both, and
identity sits in fixed columns so the summaries line up down the list
instead of starting wherever the callsign happens to end.  The three
summary cards above the list go the way of the APRS ones.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 23:24:09 +02:00
sjg 37987b2779 [feat](trx-frontend-http): make the APRS list a log, and read the payloads
CI / lint (push) Successful in 2m24s
CI / test (push) Successful in 8m12s
CI / frontend (push) Successful in 3m41s
CI / reuse (push) Successful in 2s
A frame was a card five lines tall — timestamp, a meta line, the
information field as it arrived on the air, three buttons, and a Details
panel repeating the four things already on the row — so five frames
filled the panel and the payload was left to be decoded by eye.

A frame is one line now: time, station, type, and what the frame says.
It opens in place for the path, the CRC, the raw field, its bytes and
the actions.  Twenty-one frames fit where five did.

And the information field is read rather than echoed.  Weather reports
give temperature, wind, humidity and pressure; telemetry gives its
sequence and channels; a message gives its addressee and text; a
position gives the fix, course and speed, and the comment the station
wrote.  Anything that cannot be summarised falls back to the raw field,
which is in the expanded view either way.

HF APRS had a copy of the same forty lines of markup, differing by one
badge, so both now build their rows from one function in the shared
module — the CSS is shared between them and this would have broken it
otherwise.  Both headers lose their three summary cards for a line of
counts beside the filters, which frees another fifth of the panel.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 23:12:02 +02:00
sjg 67a7bace4e [fix](trx-frontend-http): let the decode lists fill their panel
CI / test (push) Successful in 8m16s
CI / lint (push) Successful in 2m18s
CI / frontend (push) Successful in 3m40s
CI / reuse (push) Successful in 3s
FT8, FT4, FT2 and WSPR size their list against the panel with flex, and
the sidebar layout made the panel a grid item aligned to the start of
its row — sized to its own content.  The lists collapsed to their 120px
minimum with several hundred pixels of the page empty underneath.  The
panel stretches to the row now and the sidebar keeps its own height.

The marine lists were sized a different way, by formula: 100vh minus a
guess at everything above them.  That guess stopped matching the moment
the panel changed shape, so they left a few hundred pixels unused as
well.  They fill the panel like the rest now, and so does CW, which had
a 360px ceiling.

HF APRS had no container styling at all — no scroller, no frame, no
height — so its packets ran down the page.  It gets what the other
packet lists have.

The smoke test measures each list against its panel and requires it to
scroll on its own.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 22:49:28 +02:00
sjg b48cc23d6e [fix](trx-frontend-http): keep the rig names through the state stream
CI / lint (push) Successful in 2m19s
CI / test (push) Successful in 8m14s
CI / frontend (push) Successful in 3m47s
CI / reuse (push) Successful in 3s
The picker and the header showed each rig's lowercase id instead of its
configured name.  applyRigList takes the names as a parameter defaulted
to an empty map, and the state-update path passes only the rig ids —
names come from /rigs, not from a state frame — so that call landed on
the default and the body, which treats "an object" as "here are the
names", cleared them.  One frame after load the names were gone for the
rest of the session.  Omitted now means no news rather than no names.

The fixture is why this was invisible: it pushed an identical status
payload every tick and the client skips a frame equal to the last, so
render never ran and neither did the call that did the damage.  Its
event stream varies between frames now, as a real one does.

Which immediately caught a second fault: state frames arrive
continuously, and one sent before the server applied a new squelch
threshold snapped the line back to where it had just been dragged from.
A local change outranks the echo for two seconds, the same idea as the
optimistic frequency guard beside it.  The fixture also records what
/set_sdr_squelch sets and reports it back afterwards — the drag test had
been passing against a server that ignored the write.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 22:38:21 +02:00
sjg c1899229a0 [fix](trx-frontend-http): stop the decode history replay giving up at 20s
CI / lint (push) Successful in 2m19s
CI / test (push) Successful in 8m15s
CI / frontend (push) Successful in 3m37s
CI / reuse (push) Successful in 3s
Reloading a second time sometimes showed history the first load did not,
and the safety valve is why: it called one function that both released
the buffered live decodes and tore the history worker down, so any load
where the replay had not finished inside twenty seconds — a large
backlog, a cold cache, a slow link — dropped whatever had not arrived,
without a word.  A reload got another go at it, and the second one is
faster because everything is cached by then.

Those are two separate things now.  At the timeout the live decodes are
released so the panels are not held back, the replay carries on, and the
progress says so.  The fallback's error path retries once and then says
"Decode history unavailable" rather than leaving the operator to guess
whether there was anything to see.

The progress is no longer a scrim.  It was fixed to the whole viewport
with a wash over the page — the waterfall, the decode panels, all of it —
for the length of the replay, which is exactly when there is something
worth watching.  It is a corner card with a bar: indeterminate while the
payload is on the wire, then filling as N of M messages replay.

None of this was reachable from a test.  /decode/history answers in CBOR
and the worker reads the body as CBOR unconditionally, but the fixture
served JSON, so every browser run had been exercising the client's retry
path and never its history path.  It encodes CBOR now, including the
64-bit form the millisecond timestamps need, and decode-flow serves 1200
records and holds the client to restoring all of them on the first load,
showing progress while it does, and never covering the page with it.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 22:14:13 +02:00
sjg 84a99a3636 [fix](trx-frontend-http): load the decoders that own the digital modes panels
AIS, VDES and both APRS decoders were listed under the map plugin group
alone, so opening Digital modes and clicking AIS or APRS gave an empty
panel reading "Connected, listening for packets" while the decodes piled
up unprocessed in the plugin runtime.  They appeared only if something
had opened the Map tab first, which flushed the queue.  There is also a
map-data group naming exactly those four that nothing loads: the loader
is called with tab names and no tab is called map-data.

They load with the tab whose panels they fill now.  map-core stays lazy,
since their calls into it are optional and the Map tab can go on paying
for Leaflet by itself.

tests/decode-flow.mjs follows a decode from the wire to the map: an AIS
vessel and an APRS beacon arrive on /decode, and it asserts both panels
fill with the map module confirmed absent, the mini view names the
vessel and offers a pin, following that pin lands on /map centred on the
vessel, and both decoders leave a marker.  Nothing exercised any of this
before — the fixture served an empty decode stream, which is how the map
links came to be broken for every decoder at once.

The fixture stamps decodes as it sends them, since the client prunes
anything outside the retention window, and repeats them, since the views
collapse by vessel and need more than one frame to behave.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 21:31:30 +02:00
sjg 84bdf2593c [fix](trx-frontend-http): measure whether the tab bar fits, and watch for it
CI / lint (push) Successful in 2m20s
CI / test (push) Successful in 8m25s
CI / frontend (push) Successful in 3m36s
CI / reuse (push) Successful in 3s
CI put the tab strip 9px into the controls at 1440px with no scaling at
all, on a bar that had every degradation step available to it and used
none of them.  It used none because nothing thought anything was wrong:
the fit test was an arithmetic estimate — identity + nav.scrollWidth +
actions.scrollWidth + a 48px allowance for the gaps — and on a platform
whose fonts run wider than the one it was written on, that allowance no
longer covered what it stands for.  An estimate that says "fits" stops
the ladder before its first rung.

It reads the geometry now: the controls have to stay inside the bar, and
no tab may reach them.  That is the same measurement the test makes, so
the two cannot disagree about any platform's metrics.  The tabs are the
subject rather than the nav's box because the nav shrinks below its
content — the box gets smaller while the tabs keep their width and slide
underneath the controls.

A second fault turned up while probing this: the strip only reflowed on
window resize.  The rig name arriving from the server, the style picker
filling in, a font swapping in wider metrics — each changes what fits
without touching the window, and the bar sat there as it was through all
of them.  A ResizeObserver on the bar and the controls covers those, and
document.fonts.ready covers the swap.

The guard sweeps text scales and adds a station name too long for the
bar, but it should be said plainly: it passes against the old code too.
Nothing here reproduces on this machine — a 4px viewport sweep from 1080
to 1500, three wide font stacks and scales from 1.0 to 3.0 all failed to
make the old estimate lie.  What is fixed is the mechanism that could.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 20:32:38 +02:00
sjg c527f20a88 [feat](trx-frontend-http): give digital modes a decoder sidebar
CI / lint (push) Successful in 2m18s
CI / test (push) Successful in 8m15s
CI / frontend (push) Failing after 32s
CI / reuse (push) Successful in 3s
Thirteen decoders in a horizontal strip needed a scroller on anything
but a wide window, and the open one was marked by a single underline
among thirteen.  They are a list down the left now, all visible at once,
each keeping the state dot it already carried, with the panel for the
selected one filling the rest of the width.

No script changed: the sub-tab wiring, the aria roles, the decoder
picker and the state-dot observers all work on the same markup, so this
is layout only.

Below 760px the sidebar gives way to the picker that already existed
there.  That path needed align-content: the tab panel fills the page
height and a grid stretches its rows to match, which handed the picker a
218px row and left a 189px gap under it.

The tab icon was the signal-strength bars, which is what the S-meter
shows two rows above it; a pulse train says digital modes instead.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 08:01:22 +02:00
sjg 9f495021f0 [style](trx-frontend-http): name and fence the three audio groups
CI / lint (push) Successful in 2m19s
CI / test (push) Successful in 8m23s
CI / frontend (push) Successful in 3m18s
CI / reuse (push) Successful in 3s
The row carries three unrelated things — how loud it is, whether there
is any audio at all, and how much is arriving — and only the middle one
was named.  Each is a group now: VOLUME over the two sliders, SQL on its
own switch, LEVEL over the meter, with a hairline between each.

The rules are drawn only while the row is one line, measured against the
row rather than the viewport: what fits depends on whether the rig
transmits and whether it has a squelch at all.  A rule divides what sits
either side of it, so once a group wraps the wrap is the division and
the rule would just be a mark at the start of a line — which is what it
was at 900px before this.  The labels carry the grouping on their own
below that width, and the groups stack whole on a phone.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 07:46:56 +02:00
sjg 33aa7b807c [style](trx-frontend-http): fence the squelch off from the volume controls
CI / lint (push) Successful in 2m18s
CI / test (push) Successful in 8m18s
CI / frontend (push) Failing after 31s
CI / reuse (push) Successful in 3s
The squelch sat in the audio row on the same gap as everything else, so
it read as a continuation of the volume sliders.  It decides whether
there is audio at all, which is not the same kind of control as how loud
it is, and a hairline says so.

The rule belongs to the squelch block, so it leaves with it on a rig
that has none, and it stands down where the row stacks: there the line
break separates them already and a leading rule would just start a line.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 07:36:53 +02:00
sjg 2c56d82a81 [feat](trx-frontend-http): make the SQL label the squelch switch
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 8m13s
CI / frontend (push) Failing after 30s
CI / reuse (push) Successful in 3s
Clicking SQL turns the squelch on and off.  The label and the button
beside it said the same thing twice — one naming the control, the other
reading "On" or "Off" — where the name itself is the obvious target, and
the dot already carries the state: grey when off, green while the gate
passes, amber while it holds.

The pressed state is on the label, so the switch reads the same to a
screen reader as it looks.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 23:41:52 +02:00
sjg 0fc2115973 [fix](trx-frontend-http): make map links work before the map has loaded
The AIS and APRS mini views link each position to the map, and neither
did anything: the map module installs itself lazily, and it was the one
defining window.navigateToAprsMap, so until something had opened the Map
tab the global did not exist.  AIS calls it inline from onclick and
threw "not a function"; APRS guards the call and so failed silently.
The grid links on FT8, FT4, FT2 and WSPR rows went the same way through
navigateToMapLocator.

The app owns both globals now, installed at startup.  They record the
target, switch tabs through navigateToTab — the only path that
materialises the panel from its template, loads the module and updates
the history entry, none of which the module's own hand-rolled tab switch
did — and the target is applied once the module reports ready.

The module keeps the focusing, which is its job, and exposes it as
focusMapPosition and focusMapLocator.

The smoke test now calls the link from a cold page, asserting the map
module is not loaded first so the check cannot pass by accident.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 23:41:09 +02:00
sjg 2c1df75d19 [fix](trx-frontend-http): measure auto squelch from the meter
CI / frontend (push) Successful in 3m16s
CI / reuse (push) Successful in 2s
CI / lint (push) Successful in 2m18s
CI / test (push) Successful in 8m14s
Auto took the spectrum's noise floor and added 6 dB, but the threshold
is compared against the channel level the meter reports, and the two sit
a long way apart: the gap is set by the FFT size and window, the channel
bandwidth, the decimation, and peak-versus-mean statistics.  Measured on
white noise it runs +22.1 dB at 48k/8k/3k, +18.7 dB at 240k/24k/12k and
-1.2 dB at 1.92M/24k/12k — a 23 dB swing across ordinary configurations.
Only the last of those is anywhere near right, so on a narrow span Auto
set the gate some 20 dB below the noise and it never closed.

It now reads the same number the DSP compares: the 20th percentile of
the meter over the last ten seconds, plus 5 dB.  The percentile keeps a
burst of traffic inside the window from dragging the estimate up, and
5 dB clears the meter's own jitter, which measured 0.9-1.6 dB.  Nothing
in it converts between scales, so no part of the signal chain can put it
out again.  With no history yet — a fresh connection, a rig switch — it
listens for a moment rather than refusing.

The fixture gained a streaming /meter, without which there is nothing to
measure, and the spectrum test pins auto to the meter it serves.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 23:32:18 +02:00
sjg aefd36c4b1 [feat](trx-frontend-http): set the squelch on the spectrum, in dB
CI / lint (push) Successful in 2m19s
CI / test (push) Successful in 8m41s
CI / frontend (push) Failing after 31s
CI / reuse (push) Successful in 2s
The threshold is in dB, and since the squelch fix that is the scale the
spectrum axis and the S-meter are labelled in — so the control belongs
on the plot, at the level it gates.  A dashed line spans the spectrum at
its threshold with a grip that reads it out, dragged like the bandwidth
edges, green while the signal is above it and amber while it gates.
Arrow keys move it a dB at a time for anyone not using a mouse.

The audio row keeps a compact version: the dB, an indicator lit from the
same meter the DSP compares against, Auto, and an enable toggle that no
longer doubles as the level.  The slider ran 0-100% over that dB range,
which gave the operator a number with nothing on screen to relate it to,
and zero meant "disabled", so turning the squelch off to listen threw
the threshold away.  Auto now says which level it picked.

Two things the browser could only show once it was on the plot: the grip
landed underneath the split control at the right edge, which swallowed
its pointer, and dragging to the foot of the axis hid the line — and the
grip with it — instead of pinning it where it could be dragged back.

The fixture could not exercise any of this: /audio answered 404, which
hides the audio row and the control inside it, and the status carried no
filter block, which is what tells the client the rig has a squelch at
all.  Both now look like an SDR, and the spectrum test drives the line.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 23:20:19 +02:00
sjg 25b9c31c9b [fix](trx-backend-soapysdr): squelch against the meter, not the post-AGC level
The threshold was compared against the block level measured after the IQ
AGC.  Holding that level at a setpoint is the AGC's entire purpose, so
for every mode that has one — FM, PKT, AIS, AM, SAM, which is to say the
modes anyone squelches — the comparison was against a near-constant.
With FM's 12 dB of gain a weak signal reads some 12 dB hotter than it
is, and the value never had the decimation correction the meter applies
on top of that: around 20 dB adrift at 48k/8k, more as decimation grows.

The threshold arrives in the other scale entirely.  The slider maps its
percentage onto -120..-30 dB and Auto takes the spectrum noise floor
plus 6, both of which are what the meter and the spectrum display show.
So a gate set just above the noise sat open on it.

It now reads last_signal_db, which is already computed each block before
the AGC and corrected for decimation — the same number the meter shows.
The post-AGC measurement had no other consumer.

The test feeds one signal twice and takes the threshold from the
channel's own meter: 6 dB above must gate it, 6 dB below must pass it.
Nothing there depends on the absolute scale, only on the two agreeing.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 23:03:46 +02:00
sjg d68a84f7f9 [chore](trx-client): apply cargo fmt
CI / lint (push) Successful in 2m16s
CI / test (push) Successful in 7m33s
CI / frontend (push) Successful in 3m18s
CI / reuse (push) Successful in 3s
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 22:57:33 +02:00
sjg 4fa191e9b6 [fix](trx-frontend-http): serve the band plan to every session
CI / lint (push) Failing after 1s
CI / test (push) Successful in 8m20s
CI / frontend (push) Failing after 31s
CI / reuse (push) Successful in 3s
/bandplan.json needed the control role.  Route access is decided by
suffix for static assets — .js, .css, .png and so on — and ".json" is
not among them, so the band plan matched nothing and fell through to the
catch-all.  It is compiled into the binary and identical for every user,
so it is public now, like the rest of them.

Two things followed from that.  Read-only sessions never saw a band plan
at all.  And since the page asks for it during startup, the request can
land before the session is established: that 401 was swallowed by an
empty catch and never retried, which is why the allocations sometimes
only appeared after a manual reload.

So the client no longer hides the failure, retries once the auth gate
clears — which is exactly when a startup 401 becomes fixable — and
schedules a draw when the data lands, since the strip is painted from
the spectrum draw and a rig sitting between frames would stay blank.

The fixture can now refuse the first request the way the server did, and
the spectrum layout test holds the client to recovering from it.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 22:50:49 +02:00
sjg c073d03ffb [feat](trx-frontend-http): reorder the controls tray sections
CI / lint (push) Successful in 2m23s
CI / frontend (push) Failing after 31s
CI / reuse (push) Successful in 3s
CI / test (push) Successful in 8m14s
The radio's own settings now come first, then audio, then the scheduler:
Advanced radio controls, Audio controls, Scheduler controls.

The advanced section is not in the markup — ui-core builds it at runtime
and gathers the SDR settings, virtual channel and TX limit rows into it,
appending the result, which put it last however the markup was ordered.
It is inserted ahead of the audio section instead.

The signal readout and the TX meters stay where they are, between the
controls and the sections: they are readouts rather than a section, and
on an SDR the spectrum covers them anyway.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 22:41:08 +02:00
sjg 730f129404 [fix](trx-frontend-http): let the header identity give way before the tabs
CI still put the tab strip into the controls, now at 1100px — the
narrowest bar in the app, since the bookmark gutters take 9.5rem a side
above that width and leave 756px against 871px at 900px.  With the
controls already in the overflow menu and the tabs already down to
icons, nothing else could give, and what gives by default is the strip:
it is the one item allowed to shrink below its content, so its tabs keep
full width and slide under the controls, out of reach.

The identity block takes the squeeze instead, ellipsised.  A clipped
station name is still readable; a destination hidden underneath the
controls is not.

The guard that was supposed to catch this scaled only the tabs and the
controls, not the title and subtitles — which is exactly what runs out
of room — and skipped 900px.  It now scales every piece of text in the
bar and checks all four widths.  Measured across text scales from 1.0 to
3.0 at each width, the bar keeps its 16px allowance everywhere; before
this, 1.6 and above overlapped at 1100px.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 22:40:36 +02:00
sjg f95f3d0104 [feat](trx-frontend-http): centre the radio controls, split off the mode row
CI / lint (push) Successful in 2m19s
CI / test (push) Successful in 8m13s
CI / frontend (push) Failing after 32s
CI / reuse (push) Successful in 3s
The controls every rig has — mode, wheel, tune step, transmit — now sit
as a centred block rather than packed against the left edge.

What the current mode adds moves out from among them: WFM's six controls
stretched the row sideways whenever it was active, pushing the wheel and
the step pickers off centre, and SAM did the same on a smaller scale.
They get a row of their own below a divider, which appears and leaves
with them — an empty one would still take a track and a gap in the tray
and draw its divider under controls it has nothing to do with.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 22:29:31 +02:00
sjg e70e82c8c0 [feat](trx-frontend-http): rebuild the general radio controls row
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 8m8s
CI / frontend (push) Failing after 36s
CI / reuse (push) Successful in 4s
Mode was a full-width select: 483px of the row to display "FM".  The
modes are three or four characters and there are at most twelve, so they
become a segmented group like the Unit and Step Scale pickers beside
them — a third of the width, and one click instead of two.

The <select> stays as the mode's value.  A dozen call sites and several
plugins read #mode.value, so replacing it outright would have reached
much further than a layout change should; it is hidden from sight and
from assistive tech, the buttons write to it, and everything downstream
runs unchanged.  Every writer re-syncs the buttons, the plugins through
a new trxCore.syncModePicker.

The row itself was a grid with a track per column, but the WFM, SAM and
transmit columns are hidden on most rigs, so it ended in some 500px of
hole.  It packs left now.  Same fault one level down: the power buttons
sat in three fixed tracks, so a rig with neither transmit nor lock kept
two empty ones and left its label chip stranded at the far edge.

Unit and Step Scale move out of the frequency row and in beside the
wheel and the +/- they modify, which were some 600px away.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 22:09:08 +02:00
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
49 changed files with 5027 additions and 1306 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"
@@ -160,8 +160,18 @@ function updateAisSummary() {
}
}
}
function aisSummaryText(msg) {
const parts = [];
if (msg.lat != null && msg.lon != null) parts.push(`${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}`);
const motion = aisMotionText(msg);
if (motion) parts.push(motion);
const route = aisRouteText(msg);
if (route && parts.length < 2) parts.push(route);
if (!parts.length) return route || "no position reported";
return parts.join(" · ");
}
function renderAisRow(msg) {
const row = document.createElement("div");
const row = document.createElement("details");
row.className = "ais-message";
const ts = msg._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], {
hour: "2-digit",
@@ -174,7 +184,8 @@ function renderAisRow(msg) {
const motion = aisMotionText(msg);
const route = aisRouteText(msg);
const distance = aisDistanceText(msg);
const pos = msg.lat != null && msg.lon != null ? `<a class="ais-pos-link" href="javascript:void(0)" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}</a>` : "";
const pos = msg.lat != null && msg.lon != null ? `<a class="ais-pos-link" href="javascript:void(0)" data-ais-map="${msg.lat},${msg.lon}">${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}</a>` : "";
const vesselUrl = aisWindow.buildAisVesselUrl?.(msg.mmsi) ?? null;
row.dataset.filterText = [
name,
msg.mmsi,
@@ -185,7 +196,16 @@ function renderAisRow(msg) {
msg.destination,
aisTypeLabel(msg.message_type)
].filter(Boolean).join(" ").toUpperCase();
row.innerHTML = `<div class="ais-row-head"><span class="ais-time">${ts}</span><span class="ais-call">${nameHtml}</span><span class="${channel.badgeClass}">${escapeAisHtml(channel.label)}</span><span class="ais-badge ais-badge-type">${escapeAisHtml(aisTypeLabel(msg.message_type))}</span></div><div class="ais-row-meta"><span>MMSI ${escapeAisHtml(String(msg.mmsi))}</span>` + (route ? `<span class="ais-meta-text">${escapeAisHtml(route)}</span>` : "") + `<span class="ais-meta-text">${escapeAisHtml(channel.freqText)}</span></div><div class="ais-row-detail">` + (motion ? `<span>${escapeAisHtml(motion)}</span>` : `<span>No motion data</span>`) + (distance ? `<span>${escapeAisHtml(distance)}</span>` : "") + (pos ? `<span>${pos}</span>` : "") + `<span>${escapeAisHtml(aisAgeText(msg._tsMs))}</span></div>`;
row.innerHTML = `<summary class="decode-line"><span class="ais-time">${escapeAisHtml(ts)}</span><span class="ais-call">${nameHtml}</span><span class="ais-badge ais-badge-type">${escapeAisHtml(aisTypeLabel(msg.message_type))}</span><span class="decode-line-summary">${escapeAisHtml(aisSummaryText(msg))}</span><span class="${channel.badgeClass}">${escapeAisHtml(channel.label)}</span>` + (distance ? `<span class="decode-line-distance">${escapeAisHtml(distance)}</span>` : "") + `</summary><div class="decode-expanded"><div class="decode-expanded-meta"><span>MMSI ${escapeAisHtml(String(msg.mmsi))}</span><span>${escapeAisHtml(channel.freqText)}</span>` + (route ? `<span>${escapeAisHtml(route)}</span>` : "") + (motion ? `<span>${escapeAisHtml(motion)}</span>` : "") + `<span>${escapeAisHtml(aisAgeText(msg._tsMs))}</span>` + (pos ? `<span>${pos}</span>` : "") + `</div><div class="aprs-row-actions">` + (msg.lat != null && msg.lon != null ? `<button class="aprs-inline-btn" type="button" data-ais-map="${msg.lat},${msg.lon}">Map</button>` : "") + (vesselUrl ? `<a class="aprs-inline-btn" href="${escapeAisHtml(vesselUrl)}" target="_blank" rel="noopener">Vessel</a>` : "") + `</div></div>`;
row.querySelectorAll("[data-ais-map]").forEach((element) => {
element.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const [lat, lon] = (element.dataset.aisMap ?? "").split(",").map(Number);
if (lat == null || lon == null || !Number.isFinite(lat) || !Number.isFinite(lon)) return;
aisWindow.navigateToAprsMap?.(lat, lon);
});
});
applyAisFilterToRow(row);
return row;
}
@@ -265,9 +285,11 @@ function addAisMessage(msg) {
pruneAisMessageHistory();
scheduleAisBarUpdate();
scheduleAisHistoryRender();
if (msg.lat != null && msg.lon != null && aisWindow.aisMapAddVessel) {
aisWindow.aisMapAddVessel(msg);
plotAisMessage(msg);
}
function plotAisMessage(msg) {
if (msg.lat == null || msg.lon == null || !aisWindow.aisMapAddVessel) return;
aisWindow.aisMapAddVessel(msg);
}
function normalizeServerAisMessage(msg) {
return {
@@ -288,9 +310,7 @@ function onServerAisBatch(messages) {
minute: "2-digit",
second: "2-digit"
});
if (next.lat != null && next.lon != null && aisWindow.aisMapAddVessel) {
aisWindow.aisMapAddVessel(next);
}
plotAisMessage(next);
normalized.push(next);
}
normalized.reverse();
@@ -332,5 +352,9 @@ window.trxPluginRuntime.registerDecoder({
onBatch: onServerAisBatch,
restore: onServerAisBatch,
reset: resetAisHistoryView,
prune: pruneAisHistoryView
prune: pruneAisHistoryView,
// Oldest first, so vessel tracks are rebuilt in the order they happened.
syncMap: () => {
for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry);
}
});
@@ -743,7 +743,7 @@ function elementById(id) {
const element = document.getElementById(id);
if (element) body.appendChild(element);
});
tray.appendChild(details);
tray.insertBefore(details, document.getElementById("audio-controls"));
api.applyLayout(savedLayoutName(), { persist: false });
}
}
@@ -810,14 +810,21 @@ function elementById(id) {
});
const barFits = () => {
const bar = actions.closest(".tab-bar");
if (!bar) return true;
const identity = bar.querySelector(".header-main");
const nav = bar.querySelector(".tab-bar-nav");
const gutters = 48;
const needed = (identity?.offsetWidth ?? 0) + (nav?.scrollWidth ?? 0) + actions.scrollWidth + gutters;
return needed <= bar.clientWidth;
const nav = bar?.querySelector(".tab-bar-nav");
if (!bar || !nav) return true;
const barRect = bar.getBoundingClientRect();
const actionsRect = actions.getBoundingClientRect();
if (actionsRect.right > barRect.right + 1) return false;
const tabs = Array.from(nav.querySelectorAll(".tab")).filter((tab) => tab.offsetParent !== null);
if (!tabs.length) return true;
const tabsRight = Math.max(...tabs.map((tab) => tab.getBoundingClientRect().right));
return tabsRight <= actionsRect.left - 1;
};
const reflowOverflow = () => {
const nav = document.querySelector(".tab-bar-nav");
const bar = actions.closest(".tab-bar");
nav?.classList.remove("nav-icons-only");
bar?.classList.remove("bar-tight");
overflowOrder.forEach((selector) => {
const element = menu.querySelector(selector);
if (element) actions.insertBefore(element, wrap);
@@ -830,11 +837,25 @@ function elementById(id) {
wrap.hidden = false;
menu.appendChild(element);
}
if (nav && !barFits()) nav.classList.add("nav-icons-only");
if (bar && !barFits()) bar.classList.add("bar-tight");
wrap.hidden = menu.children.length === 0;
if (wrap.hidden) closeMenu();
};
reflowOverflow();
window.addEventListener("resize", reflowOverflow);
if (typeof ResizeObserver !== "undefined") {
const bar = actions.closest(".tab-bar");
const observer = new ResizeObserver(() => {
reflowOverflow();
});
if (bar) observer.observe(bar);
observer.observe(actions);
}
document.fonts?.ready.then(() => {
reflowOverflow();
}).catch(() => {
});
}
function installMobileMore() {
const nav = document.querySelector(".tab-bar-nav");
@@ -1039,6 +1060,9 @@ var runtime = {
plugin.prune();
return true;
},
syncMapAll() {
for (const plugin of decoders.values()) plugin.syncMap?.();
},
clearQueued() {
queued.clear();
},
@@ -1668,7 +1692,24 @@ function estimateNoiseFloorDb(bins) {
// src/plugin-loader.ts
var pluginGroups = {
"digital-modes": ["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js", "/sat.js", "/wefax.js"],
// AIS, VDES and the two APRS decoders have panels on this tab, so they load
// with it. They used to come only with the map group, which left their
// sub-tabs empty — decodes queueing in the runtime — until something opened
// the Map tab. Their map calls are optional, so map-core stays lazy.
"digital-modes": [
"/ft8.js",
"/ft4.js",
"/ft2.js",
"/wspr.js",
"/cw.js",
"/background-decode.js",
"/sat.js",
"/wefax.js",
"/ais.js",
"/vdes.js",
"/aprs.js",
"/hf-aprs.js"
],
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
statistics: ["/map-core.js"],
@@ -1833,6 +1874,7 @@ function hideAuthGate() {
});
navigateToTab(tabFromPath2(), { updateHistory: false, replaceHistory: true });
syncTopBarAccess();
if (!bandplanData) void loadBandplanJson();
}
function showAuthError(msg) {
const el = requiredElement("auth-error");
@@ -2026,6 +2068,7 @@ var loadingSub = requiredElement("loading-sub");
var decodeHistoryOverlayEl = document.getElementById("decode-history-overlay");
var decodeHistoryOverlayTitleEl = document.getElementById("decode-history-overlay-title");
var decodeHistoryOverlaySubEl = document.getElementById("decode-history-overlay-sub");
var decodeHistoryProgressBarEl = document.getElementById("decode-history-progress-bar");
var connLostOverlayEl = document.getElementById("conn-lost-overlay");
var connLostOverlayTitleEl = document.getElementById("conn-lost-overlay-title");
var connLostOverlaySubEl = document.getElementById("conn-lost-overlay-sub");
@@ -2175,10 +2218,19 @@ function syncTopBarAccess() {
}
}
var overviewDrawPending = false;
function setDecodeHistoryOverlayVisible(visible, title = "", sub = "") {
function setDecodeHistoryOverlayVisible(visible, title = "", sub = "", fraction = null) {
if (!decodeHistoryOverlayEl) return;
if (title && decodeHistoryOverlayTitleEl) decodeHistoryOverlayTitleEl.textContent = title;
if (decodeHistoryOverlaySubEl) decodeHistoryOverlaySubEl.textContent = sub || "";
if (decodeHistoryProgressBarEl) {
if (fraction == null) {
decodeHistoryOverlayEl.dataset.phase = "fetching";
decodeHistoryProgressBarEl.style.width = "";
} else {
delete decodeHistoryOverlayEl.dataset.phase;
decodeHistoryProgressBarEl.style.width = `${Math.round(Math.max(0, Math.min(1, fraction)) * 100)}%`;
}
}
decodeHistoryOverlayEl.classList.toggle("is-hidden", !visible);
}
function setConnLostOverlay(visible, title = "Connection lost", sub = "Retrying…", fullscreen = false) {
@@ -2237,6 +2289,7 @@ function formatSigStrength(dbm) {
return `${dbfs.toFixed(1)} ${sigUnit("dBFS")}`;
}
function refreshSigStrengthDisplay() {
renderSdrSquelch();
if (!sigStrengthEl) return;
sigStrengthEl.innerHTML = formatSigStrength(sigLastDbm);
}
@@ -2290,6 +2343,7 @@ async function restorePreviousTuneState() {
savePreviousTuneState();
if (saved.mode && modeEl && modeEl.value !== saved.mode) {
modeEl.value = saved.mode;
syncModePicker();
await postPath(`/set_mode?mode=${encodeURIComponent(saved.mode)}`);
updateWfmControls();
}
@@ -2724,6 +2778,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;
@@ -2763,7 +2828,7 @@ function updateRigSubtitle(activeRigId) {
rigSubtitle.textContent = `Rig: ${name}`;
updateDocumentTitle(activeChannelRds());
}
function applyRigList(activeRigId, rigIds, displayNames = {}) {
function applyRigList(activeRigId, rigIds, displayNames) {
if (!Array.isArray(rigIds)) return;
const nextIds = rigIds.filter((id) => typeof id === "string" && id.length > 0);
const prevKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
@@ -2837,12 +2902,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" });
}
}
@@ -4113,6 +4178,7 @@ function setDisabled(disabled) {
[freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => {
if (el) el.disabled = disabled;
});
syncModePicker();
}
var serverVersion = null;
var serverBuildDate = null;
@@ -4405,13 +4471,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 = "";
@@ -4444,6 +4510,7 @@ function render(update) {
opt.textContent = m;
modeEl.appendChild(opt);
});
renderModePicker();
}
}
if (update.info && update.info.capabilities) {
@@ -4570,6 +4637,7 @@ function render(update) {
const onVirtual = window.trx.modules.vchan?.isOnVirtual() === true;
if (!onVirtual) {
modeEl.value = modeUpper2;
syncModePicker();
if (modeUpper2 === "WFM" && lastModeName !== "WFM") {
setJogDivisor(10);
resetRdsDisplay();
@@ -4704,6 +4772,7 @@ function render(update) {
const sUnits = dbmToSUnits(update.status.rx.sig);
sigLastSUnits = sUnits;
sigLastDbm = update.status.rx.sig;
recordSquelchMeterSample(update.status.rx.sig);
const pct = sUnits <= 9 ? Math.max(0, Math.min(100, sUnits / 9 * 100)) : 100;
signalBar.style.width = `${pct}%`;
signalValue.innerHTML = formatSignal(sUnits);
@@ -4730,7 +4799,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 +4896,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 +4974,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 +5001,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 +5011,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();
@@ -5327,6 +5396,33 @@ if (jogMultEl) {
}
jogStep = Math.max(Math.round(jogUnit / jogMult), minFreqStepHz);
}
var modePickerEl = requiredElement("mode-picker");
function renderModePicker() {
modePickerEl.replaceChildren();
for (const option of Array.from(modeEl.options)) {
const btn = document.createElement("button");
btn.type = "button";
btn.dataset.mode = option.value;
btn.textContent = option.textContent;
btn.addEventListener("click", () => {
if (btn.disabled || modeEl.value === option.value) return;
modeEl.value = option.value;
syncModePicker();
void applyModeFromPicker();
});
modePickerEl.appendChild(btn);
}
syncModePicker();
}
function syncModePicker() {
const active = (modeEl.value || "").toUpperCase();
modePickerEl.querySelectorAll("button").forEach((btn) => {
const selected = (btn.dataset.mode || "").toUpperCase() === active;
btn.classList.toggle("active", selected);
btn.disabled = modeEl.disabled;
btn.setAttribute("aria-pressed", String(selected));
});
}
async function applyModeFromPicker() {
const mode = modeEl.value || "";
if (!mode) {
@@ -5335,6 +5431,7 @@ async function applyModeFromPicker() {
}
updateWfmControls();
setControlPending(modeEl, true);
syncModePicker();
showHint("Setting mode…");
try {
if (await window.trx.modules.vchan?.interceptMode(mode)) {
@@ -5352,6 +5449,7 @@ async function applyModeFromPicker() {
console.error(err);
} finally {
setControlPending(modeEl, false);
syncModePicker();
}
}
modeEl.addEventListener("change", applyModeFromPicker);
@@ -5548,6 +5646,39 @@ var _activeTab = "main";
function tabFromPath2(pathname = window.location.pathname) {
return tabFromPath(pathname);
}
var pendingMapTarget = null;
function applyMapTarget(target) {
const map = window.trx.modules.map;
if (!map) return false;
if (target.kind === "position") {
map.focusMapPosition?.(target.lat, target.lon);
} else {
map.focusMapLocator?.(target.grid, target.preferredType);
}
return true;
}
function requestMapTarget(target) {
pendingMapTarget = target;
navigateToTab("map");
if (window.trx.modules.map) {
requestAnimationFrame(() => {
drainPendingMapTarget();
});
}
}
function drainPendingMapTarget() {
const target = pendingMapTarget;
if (!target) return;
if (applyMapTarget(target)) pendingMapTarget = null;
}
window.navigateToAprsMap = (lat, lon) => {
if (!isFiniteNumber(lat) || !isFiniteNumber(lon)) return;
requestMapTarget({ kind: "position", lat, lon });
};
window.navigateToMapLocator = (grid, preferredType = null) => {
if (!grid) return;
requestMapTarget({ kind: "locator", grid, preferredType });
};
var _mapInitTimer = null;
function _initMapWhenReady() {
const loadingEl2 = document.getElementById("map-loading");
@@ -5564,6 +5695,7 @@ function _initMapWhenReady() {
requestAnimationFrame(() => {
map.sizeAprsMapToViewport();
map.aprsMap?.invalidateSize();
drainPendingMapTarget();
});
});
return;
@@ -5593,7 +5725,10 @@ function navigateToTab(name, options = {}) {
document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active"));
btn.classList.add("active");
const toolsBtn = document.getElementById("mobile-more-btn");
if (toolsBtn) toolsBtn.classList.toggle("active", getComputedStyle(btn).display === "none");
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}`);
@@ -5938,6 +6073,7 @@ var trxCore = Object.freeze({
syncBandwidthInput,
scheduleSpectrumDraw,
onDecoderRegistryReady,
syncModePicker,
formatFreqForStep: formatFrequencyForStep,
refreshFreqDisplay,
setJogDivisor,
@@ -6150,14 +6286,18 @@ var wfmCciValEl = document.getElementById("wfm-cci-val");
var wfmAciFillEl = document.getElementById("wfm-aci-fill");
var wfmAciValEl = document.getElementById("wfm-aci-val");
var samControlsCol = document.getElementById("sam-controls-col");
var modeControlsRow = document.getElementById("mode-controls-row");
var samStereoWidthEl = document.getElementById("sam-stereo-width");
var samCarrierSyncEl = document.getElementById("sam-carrier-sync");
var sdrSquelchWrapEl = document.getElementById("sdr-squelch-wrap");
var sdrSquelchEl = document.getElementById("sdr-squelch");
var sdrSquelchPctEl = document.getElementById("sdr-squelch-pct");
var sdrSquelchDbEl = document.getElementById("sdr-squelch-db");
var sdrSquelchStateEl = document.getElementById("sdr-squelch-state");
var sdrSquelchToggleBtn = document.getElementById("sdr-squelch-toggle");
var squelchLineEl = document.getElementById("spectrum-squelch-line");
var squelchGripEl = document.getElementById("spectrum-squelch-grip");
var squelchLabelEl = document.getElementById("spectrum-squelch-label");
var SDR_SQUELCH_MIN_DB = -120;
var SDR_SQUELCH_MAX_DB = -30;
var syncFromServerSdrSquelch = false;
var sdrNbWrapEl = document.getElementById("sdr-nb-wrap");
var sdrNbEnabledEl = document.getElementById("sdr-nb-enabled");
var sdrNbThresholdControlsEl = document.getElementById("sdr-nb-threshold-controls");
@@ -6246,89 +6386,195 @@ function normalizeWfmDenoiseLevel(value) {
if (next === "off" || next === "auto" || next === "low" || next === "medium" || next === "high") return next;
return "auto";
}
function clampSdrSquelchPercent(value) {
if (!isFiniteNumber(value)) return 0;
return Math.max(0, Math.min(100, Math.round(value)));
var sdrSquelchEnabled = loadSetting("sdrSquelchEnabled", false);
var sdrSquelchThresholdDb = clampSdrSquelchDb(Number(loadSetting("sdrSquelchThresholdDb", -95)));
function clampSdrSquelchDb(value) {
if (!isFiniteNumber(value)) return SDR_SQUELCH_MIN_DB;
return Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, Math.round(value)));
}
function sdrSquelchPercentToServer(percent) {
const pct = clampSdrSquelchPercent(percent);
if (pct <= 0) {
return { enabled: false, thresholdDb: SDR_SQUELCH_MIN_DB };
function sdrSquelchIsPassing() {
if (!sdrSquelchEnabled) return true;
if (!isFiniteNumber(sigLastDbm)) return false;
return sigLastDbm >= sdrSquelchThresholdDb;
}
const ratio = pct / 100;
const thresholdDb = SDR_SQUELCH_MIN_DB + ratio * (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB);
return { enabled: true, thresholdDb };
function renderSdrSquelch() {
if (sdrSquelchDbEl && document.activeElement !== sdrSquelchDbEl) {
sdrSquelchDbEl.value = String(sdrSquelchThresholdDb);
}
function sdrSquelchServerToPercent(enabled, thresholdDb) {
if (!enabled) return 0;
if (!isFiniteNumber(thresholdDb)) return 0;
const ratio = (thresholdDb - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB);
return clampSdrSquelchPercent(ratio * 100);
if (sdrSquelchDbEl) sdrSquelchDbEl.disabled = !sdrSquelchSupported;
if (sdrSquelchToggleBtn) {
sdrSquelchToggleBtn.setAttribute("aria-pressed", String(sdrSquelchEnabled));
sdrSquelchToggleBtn.title = sdrSquelchEnabled ? "Turn the squelch off" : "Turn the squelch on";
}
function updateSdrSquelchPctLabel() {
if (!sdrSquelchEl || !sdrSquelchPctEl) return;
const pct = clampSdrSquelchPercent(Number(sdrSquelchEl.value));
sdrSquelchPctEl.textContent = pct <= 0 ? "Open" : `${pct}%`;
const state = !sdrSquelchEnabled ? "off" : sdrSquelchIsPassing() ? "open" : "closed";
if (sdrSquelchStateEl) {
sdrSquelchStateEl.dataset.state = state;
sdrSquelchStateEl.parentElement?.setAttribute(
"aria-label",
state === "off" ? "Squelch off" : state === "open" ? "Squelch on, open" : "Squelch on, closed"
);
}
if (squelchLabelEl) squelchLabelEl.textContent = String(sdrSquelchThresholdDb);
if (squelchGripEl) squelchGripEl.setAttribute("aria-valuenow", String(sdrSquelchThresholdDb));
if (squelchLineEl) squelchLineEl.dataset.state = state === "open" ? "open" : "closed";
positionSquelchLine();
}
function positionSquelchLine() {
if (!squelchLineEl) return;
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
const canvas = document.getElementById("spectrum-canvas");
const visible = sdrSquelchSupported && sdrSquelchEnabled && mode !== "WFM" && !!canvas && canvas.clientHeight > 0 && getComputedStyle(requiredElement("spectrum-panel")).display !== "none";
if (!visible) {
squelchLineEl.style.display = "none";
return;
}
const dbMin = spectrumFloor;
const dbMax = spectrumFloor + spectrumRange;
const frac = Math.max(0, Math.min(1, (sdrSquelchThresholdDb - dbMin) / Math.max(1, dbMax - dbMin)));
squelchLineEl.style.display = "";
squelchLineEl.style.top = `${canvas.offsetTop + canvas.clientHeight * (1 - frac)}px`;
}
function submitSdrSquelch() {
if (!sdrSquelchSupported) return;
sdrSquelchLocalAt = Date.now();
saveSetting("sdrSquelchEnabled", sdrSquelchEnabled);
saveSetting("sdrSquelchThresholdDb", sdrSquelchThresholdDb);
postPath(
`/set_sdr_squelch?enabled=${sdrSquelchEnabled ? "true" : "false"}&threshold_db=${encodeURIComponent(sdrSquelchThresholdDb.toFixed(2))}`
).catch(() => {
});
}
function setSdrSquelch(thresholdDb, enabled, options = {}) {
sdrSquelchThresholdDb = clampSdrSquelchDb(thresholdDb);
sdrSquelchEnabled = enabled;
renderSdrSquelch();
if (options.submit !== false) submitSdrSquelch();
}
var SDR_SQUELCH_HOLD_MS = 2e3;
var sdrSquelchLocalAt = 0;
var SQUELCH_NOISE_WINDOW_MS = 1e4;
var SQUELCH_NOISE_MARGIN_DB = 5;
var SQUELCH_MEASURE_MS = 1500;
var squelchMeterSamples = [];
function recordSquelchMeterSample(db) {
if (!isFiniteNumber(db)) return;
const now = Date.now();
squelchMeterSamples.push({ t: now, v: db });
while (squelchMeterSamples[0] && now - squelchMeterSamples[0].t > SQUELCH_NOISE_WINDOW_MS) {
squelchMeterSamples.shift();
}
}
function squelchNoiseFloorDb() {
const now = Date.now();
const values = squelchMeterSamples.filter((sample) => now - sample.t <= SQUELCH_NOISE_WINDOW_MS).map((sample) => sample.v).sort((a, b) => a - b);
if (values.length < 4) return null;
return values[Math.floor((values.length - 1) * 0.2)] ?? null;
}
function autoSquelchThresholdDb() {
const noiseDb = squelchNoiseFloorDb();
if (noiseDb == null) return null;
return clampSdrSquelchDb(noiseDb + SQUELCH_NOISE_MARGIN_DB);
}
function updateSdrSquelchControlVisibility() {
if (!sdrSquelchWrapEl) return;
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
sdrSquelchWrapEl.style.display = sdrSquelchSupported && mode !== "WFM" ? "" : "none";
renderSdrSquelch();
}
function syncSdrSquelchFromServer(enabled, thresholdDb) {
if (!sdrSquelchEl) return;
if (document.activeElement === sdrSquelchEl) return;
const pct = sdrSquelchServerToPercent(enabled, thresholdDb);
syncFromServerSdrSquelch = true;
sdrSquelchEl.value = String(pct);
updateSdrSquelchPctLabel();
syncFromServerSdrSquelch = false;
saveSetting("sdrSquelchPct", pct);
if (squelchDragPointerId !== null) return;
if (sdrSquelchDbEl && document.activeElement === sdrSquelchDbEl) return;
if (Date.now() - sdrSquelchLocalAt < SDR_SQUELCH_HOLD_MS) return;
sdrSquelchEnabled = enabled;
if (isFiniteNumber(thresholdDb)) sdrSquelchThresholdDb = clampSdrSquelchDb(thresholdDb);
saveSetting("sdrSquelchEnabled", sdrSquelchEnabled);
saveSetting("sdrSquelchThresholdDb", sdrSquelchThresholdDb);
renderSdrSquelch();
}
function submitSdrSquelchPercent(percent) {
var squelchDragPointerId = null;
var squelchDragSubmitAt = 0;
if (sdrSquelchDbEl) {
sdrSquelchDbEl.addEventListener("change", () => {
setSdrSquelch(Number(sdrSquelchDbEl.value), sdrSquelchEnabled);
});
sdrSquelchDbEl.addEventListener("blur", () => {
renderSdrSquelch();
});
}
if (sdrSquelchToggleBtn) {
sdrSquelchToggleBtn.addEventListener("click", () => {
if (!sdrSquelchSupported) return;
const { enabled, thresholdDb } = sdrSquelchPercentToServer(percent);
postPath(
`/set_sdr_squelch?enabled=${enabled ? "true" : "false"}&threshold_db=${encodeURIComponent(thresholdDb.toFixed(2))}`
).catch(() => {
setSdrSquelch(sdrSquelchThresholdDb, !sdrSquelchEnabled);
});
}
if (sdrSquelchEl) {
const savedPct = clampSdrSquelchPercent(Number(loadSetting("sdrSquelchPct", 0)));
sdrSquelchEl.value = String(savedPct);
updateSdrSquelchPctLabel();
sdrSquelchEl.addEventListener("input", () => {
const pct = clampSdrSquelchPercent(Number(sdrSquelchEl.value));
sdrSquelchEl.value = String(pct);
updateSdrSquelchPctLabel();
saveSetting("sdrSquelchPct", pct);
if (!syncFromServerSdrSquelch) {
submitSdrSquelchPercent(pct);
}
});
function applyAutoSquelch(threshold) {
setSdrSquelch(threshold, true);
showHint(`Squelch ${threshold} dB`, 1500);
}
var sdrSquelchAutoBtn = document.getElementById("sdr-squelch-auto");
if (sdrSquelchAutoBtn) {
sdrSquelchAutoBtn.addEventListener("click", () => {
if (!sdrSquelchSupported) return;
let pct = 0;
const data = lastSpectrumData || window.lastSpectrumData;
if (data && isNumericBins(data.bins) && data.bins.length > 0) {
const noiseDb = estimateNoiseFloorDb(data.bins);
if (noiseDb != null && isFiniteNumber(noiseDb)) {
const thresholdDb = noiseDb + 6;
const clamped = Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, thresholdDb));
pct = clampSdrSquelchPercent(
(clamped - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB) * 100
);
const threshold = autoSquelchThresholdDb();
if (threshold != null) {
applyAutoSquelch(threshold);
return;
}
sdrSquelchAutoBtn.disabled = true;
showHint("Measuring the noise…");
setTimeout(() => {
sdrSquelchAutoBtn.disabled = false;
const measured = autoSquelchThresholdDb();
if (measured == null) {
showHint("No meter to measure the noise from", 1800);
return;
}
if (sdrSquelchEl) {
sdrSquelchEl.value = String(pct);
updateSdrSquelchPctLabel();
saveSetting("sdrSquelchPct", pct);
applyAutoSquelch(measured);
}, SQUELCH_MEASURE_MS);
});
}
submitSdrSquelchPercent(pct);
if (squelchGripEl) {
const dbFromClientY = (clientY) => {
const canvas = document.getElementById("spectrum-canvas");
if (!canvas || !canvas.clientHeight) return sdrSquelchThresholdDb;
const rect = canvas.getBoundingClientRect();
const frac = 1 - (clientY - rect.top) / rect.height;
return clampSdrSquelchDb(spectrumFloor + frac * spectrumRange);
};
squelchGripEl.addEventListener("pointerdown", (event) => {
if (!sdrSquelchSupported) return;
squelchDragPointerId = event.pointerId;
squelchGripEl.setPointerCapture(event.pointerId);
event.preventDefault();
event.stopPropagation();
});
squelchGripEl.addEventListener("pointermove", (event) => {
if (squelchDragPointerId !== event.pointerId) return;
sdrSquelchThresholdDb = dbFromClientY(event.clientY);
renderSdrSquelch();
const now = Date.now();
if (now - squelchDragSubmitAt > 200) {
squelchDragSubmitAt = now;
submitSdrSquelch();
}
});
const endDrag = (event) => {
if (squelchDragPointerId !== event.pointerId) return;
squelchDragPointerId = null;
submitSdrSquelch();
};
squelchGripEl.addEventListener("pointerup", endDrag);
squelchGripEl.addEventListener("pointercancel", endDrag);
squelchGripEl.addEventListener("keydown", (event) => {
const step = event.shiftKey ? 10 : 1;
if (event.key === "ArrowUp" || event.key === "ArrowRight") {
setSdrSquelch(sdrSquelchThresholdDb + step, sdrSquelchEnabled);
} else if (event.key === "ArrowDown" || event.key === "ArrowLeft") {
setSdrSquelch(sdrSquelchThresholdDb - step, sdrSquelchEnabled);
} else {
return;
}
event.preventDefault();
});
}
if (wfmAudioModeEl) {
@@ -6457,6 +6703,7 @@ function updateWfmControls() {
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
if (wfmControlsCol) wfmControlsCol.style.display = mode === "WFM" ? "" : "none";
if (samControlsCol) samControlsCol.style.display = mode === "SAM" ? "" : "none";
if (modeControlsRow) modeControlsRow.style.display = mode === "WFM" || mode === "SAM" ? "" : "none";
}
if (!hasWebCodecs) {
rxAudioBtn.disabled = true;
@@ -7187,15 +7434,10 @@ function volWheel(slider, pctEl, getGain, storageKey) {
}
volWheel(rxVolSlider, rxVolPct, () => rxGainNode, "rxVol");
volWheel(txVolSlider, txVolPct, () => txGainNode, "txVol");
if (sdrSquelchEl) {
sdrSquelchEl.addEventListener("wheel", (e) => {
e.preventDefault();
const step = e.deltaY < 0 ? 2 : -2;
const next = clampSdrSquelchPercent(Number(sdrSquelchEl.value) + step);
sdrSquelchEl.value = String(next);
updateSdrSquelchPctLabel();
saveSetting("sdrSquelchPct", next);
submitSdrSquelchPercent(next);
if (sdrSquelchDbEl) {
sdrSquelchDbEl.addEventListener("wheel", (event) => {
event.preventDefault();
setSdrSquelch(sdrSquelchThresholdDb + (event.deltaY < 0 ? 1 : -1), sdrSquelchEnabled);
}, { passive: false });
}
requiredElement("copyright-year").textContent = String((/* @__PURE__ */ new Date()).getFullYear());
@@ -7294,16 +7536,15 @@ function connectDecode() {
let historySettled = false;
let historyWorkerDone = false;
let historyFallbackStarted = false;
let historyRetried = false;
let historyBatchDrainScheduled = false;
let historyTotal = 0;
let historyProcessed = 0;
const historyGroupQueue = [];
const liveBuffer = [];
function flushLiveBuffer() {
function releaseLiveBuffer() {
if (historySettled) return;
historySettled = true;
terminateDecodeHistoryWorker();
setDecodeHistoryReplayActive(false);
setDecodeHistoryOverlayVisible(false);
for (const msg of liveBuffer) {
try {
dispatchDecodeMessage(msg);
@@ -7312,19 +7553,23 @@ function connectDecode() {
}
liveBuffer.length = 0;
}
function finishHistoryReplay() {
clearTimeout(historyTimeout);
releaseLiveBuffer();
terminateDecodeHistoryWorker();
setDecodeHistoryReplayActive(false);
setDecodeHistoryOverlayVisible(false);
}
function updateHistoryReplayOverlay() {
setDecodeHistoryOverlayVisible(
true,
"Loading decode history…",
`Replaying ${historyProcessed} / ${historyTotal} decoded messages`
`Replaying ${historyProcessed} / ${historyTotal} decoded messages`,
historyTotal > 0 ? historyProcessed / historyTotal : null
);
}
function maybeFinishHistoryReplay() {
if (historySettled) return;
if (historyWorkerDone && historyGroupQueue.length === 0) {
clearTimeout(historyTimeout);
flushLiveBuffer();
}
if (historyWorkerDone && historyGroupQueue.length === 0) finishHistoryReplay();
}
function pumpDecodeHistoryGroupQueue() {
historyBatchDrainScheduled = false;
@@ -7378,17 +7623,25 @@ function connectDecode() {
if (historyFallbackStarted || historySettled) return;
historyFallbackStarted = true;
loadDecodeHistoryOnMainThread((groups) => {
clearTimeout(historyTimeout);
const total = totalDecodeHistoryMessages(groups);
if (total > 0) {
enqueueDecodeHistoryGroups(groups);
} else {
flushLiveBuffer();
finishHistoryReplay();
}
}, (err) => {
console.error("Decode history fallback failed", err);
clearTimeout(historyTimeout);
flushLiveBuffer();
if (historyRetried) {
showHint("Decode history unavailable", 3e3);
finishHistoryReplay();
return;
}
historyRetried = true;
historyFallbackStarted = false;
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Retrying");
setTimeout(() => {
startDecodeHistoryFallback();
}, 2e3);
});
}
function startDecodeHistoryWorkerReplay() {
@@ -7424,7 +7677,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;
}
@@ -7449,10 +7702,9 @@ function connectDecode() {
return true;
}
const historyTimeout = setTimeout(() => {
if (!historySettled) {
terminateDecodeHistoryWorker();
flushLiveBuffer();
}
if (historySettled) return;
releaseLiveBuffer();
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Still loading — live decodes are showing");
}, 2e4);
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer");
decodeSource = new EventSource("/decode");
@@ -7474,7 +7726,7 @@ function connectDecode() {
const wasClosed = source.readyState === 2;
source.close();
terminateDecodeHistoryWorker();
if (!historySettled) flushLiveBuffer();
if (!historySettled) releaseLiveBuffer();
if (wasClosed) {
updateDecodeStatus("Decode not available (check client audio config)");
setTimeout(connectDecode, 1e4);
@@ -7938,6 +8190,7 @@ function flushMeterDom() {
const sUnits = dbmToSUnits(dbm);
sigLastSUnits = sUnits;
sigLastDbm = dbm;
recordSquelchMeterSample(dbm);
const pct = sUnits <= 9 ? Math.max(0, Math.min(100, sUnits / 9 * 100)) : 100;
if (signalBar) signalBar.style.width = `${pct}%`;
if (signalValue) signalValue.innerHTML = formatSignal(sUnits);
@@ -8242,6 +8495,7 @@ function drawSpectrum(data) {
}
spectrumGl.drawSegments(spectrumTmpGridSegments, cssColorToRgba(pal.spectrumGrid), 1);
updateSpectrumDbAxis(DB_MIN, DB_MAX, gridStep, H, dpr);
positionSquelchLine();
function hzToX(hz) {
return (hz - range.visLoHz) / range.visSpanHz * W;
}
@@ -8574,7 +8828,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();
@@ -8869,31 +9124,15 @@ window.addEventListener("keydown", (event) => {
}
if (key === "q") {
event.preventDefault();
if (sdrSquelchSupported && sdrSquelchEl) {
const current = clampSdrSquelchPercent(Number(sdrSquelchEl.value));
let nextPct;
if (current > 0) {
nextPct = 0;
if (sdrSquelchSupported) {
if (sdrSquelchEnabled) {
setSdrSquelch(sdrSquelchThresholdDb, false);
showHint("Squelch off", 1200);
} else {
let auto = 30;
const data = lastSpectrumData || window.lastSpectrumData;
if (data && isNumericBins(data.bins) && data.bins.length > 0) {
const noiseDb = estimateNoiseFloorDb(data.bins);
if (noiseDb != null && isFiniteNumber(noiseDb)) {
const thresholdDb = noiseDb + 6;
const clamped = Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, thresholdDb));
auto = clampSdrSquelchPercent(
(clamped - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB) * 100
);
const threshold = sdrSquelchThresholdDb > SDR_SQUELCH_MIN_DB ? sdrSquelchThresholdDb : autoSquelchThresholdDb() ?? SDR_SQUELCH_MIN_DB + 25;
setSdrSquelch(threshold, true);
showHint(`Squelch ${clampSdrSquelchDb(threshold)} dB`, 1200);
}
}
nextPct = auto;
}
sdrSquelchEl.value = String(nextPct);
updateSdrSquelchPctLabel();
saveSetting("sdrSquelchPct", nextPct);
submitSdrSquelchPercent(nextPct);
showHint(nextPct > 0 ? `Squelch ${nextPct}%` : "Squelch Off", 1200);
} else {
showHint("Squelch N/A", 1200);
}
@@ -9296,8 +9535,8 @@ var bandplanCacheKey = "";
var bandplanStripEl = document.getElementById("spectrum-bandplan-strip");
var bandplanRegionSelect = document.getElementById("bandplan-region-select");
var bandplanLabelsCheck = document.getElementById("bandplan-labels-check");
(function loadBandplanJson() {
fetch("/bandplan.json").then(async (response) => {
function loadBandplanJson() {
return fetch("/bandplan.json").then(async (response) => {
if (!response.ok) throw new Error(String(response.status));
return await responseJsonUnknown(response);
}).then((data) => {
@@ -9305,9 +9544,12 @@ var bandplanLabelsCheck = document.getElementById("bandplan-labels-check");
bandplanData = data;
bandplanSegmentsCache = null;
bandplanCacheKey = "";
}).catch(() => {
if (lastSpectrumData) scheduleSpectrumDraw();
}).catch((err) => {
console.warn("Band plan unavailable", err);
});
})();
}
void loadBandplanJson();
if (bandplanRegionSelect) {
bandplanRegionSelect.value = bandplanRegion;
bandplanRegionSelect.addEventListener("change", () => {
@@ -9369,24 +9611,28 @@ function bandplanVisibleSegments(region, loHz, hiHz) {
}
return result;
}
function _hideBandplanStrip() {
function _clearBandplanStrip(reserveSpace) {
if (!bandplanStripEl) return;
bandplanStripEl.classList.remove("bp-visible");
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) {
@@ -1,13 +1,10 @@
import {
aprsAgeText,
aprsCategoryLabel,
aprsHexBytes,
aprsPacketCategory,
collapseAprsDuplicates,
normalizeAprsPacket,
renderAprsInfo,
renderLocalAprsSymbol
} from "./chunk-M2I6DH4X.js";
renderAprsPacketRow
} from "./chunk-OPEIVJGD.js";
import {
hostCore,
hostState
@@ -114,51 +111,27 @@ function updateAprsChipState() {
aprsHideCrcBtn?.classList.toggle("active", aprsHideCrc);
aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
}
function renderAprsRow(pkt, isFresh) {
const row = document.createElement("div");
row.className = "aprs-packet";
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
if (isFresh) row.classList.add("aprs-packet-new");
const ts = pkt._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
const age = aprsAgeText(pkt._tsMs);
const category = aprsPacketCategory(pkt);
const categoryLabel = aprsCategoryLabel(category);
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeAprsHtml(pkt.path)}</span>` : "";
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
const symbolHtml = renderLocalAprsSymbol(pkt, escapeAprsHtml);
const posLink = pkt.lat != null && pkt.lon != null ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>` : "";
const distance = aprsDistanceText(pkt);
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
row.innerHTML = `<div class="aprs-row-head"><span class="aprs-time">${ts}</span>` + symbolHtml + `<span class="aprs-call">${escapeAprsHtml(pkt.srcCall ?? "")}</span><span>&gt;${escapeAprsHtml(pkt.destCall || "")}</span><span class="${categoryClass}">${escapeAprsHtml(categoryLabel)}</span>` + pathBadge + crcBadge + `</div><div class="aprs-row-meta"><span class="aprs-meta-text">${escapeAprsHtml(age)}</span>` + (distance ? `<span class="aprs-meta-text">${escapeAprsHtml(distance)}</span>` : "") + `<span class="aprs-meta-text">${escapeAprsHtml(pkt.type || "--")}</span></div><div class="aprs-row-detail"><span title="${escapeAprsHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` + (posLink ? `<span>${posLink}</span>` : "") + `</div><div class="aprs-row-actions">` + (pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") + (pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a></div><details class="aprs-details"><summary>Details</summary><div class="aprs-details-grid"><span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.srcCall || "--")}</span><span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.destCall || "--")}</span><span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.type || "--")}</span><span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.path || "--")}</span><span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeAprsHtml(age)}</span><span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span><span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span><span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.info || "--")}</span><span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeAprsHtml(aprsHexBytes(pkt.info_bytes))}</span></div></details>`;
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
el.addEventListener("click", (evt) => {
evt.preventDefault();
const raw = el.dataset.aprsMap ?? "";
const [lat, lon] = raw.split(",").map(Number);
if (aprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) {
aprsWindow.navigateToAprsMap(lat, lon);
}
});
});
const copyBtn = row.querySelector("[data-aprs-copy]");
if (copyBtn) {
copyBtn.addEventListener("click", () => {
void (async () => {
const raw = copyBtn.dataset.aprsCopy ?? "";
async function copyAprsCoords(text) {
try {
const clipboard = Reflect.get(navigator, "clipboard");
if (clipboard) {
await clipboard.writeText(raw);
if (!clipboard) return;
await clipboard.writeText(text);
showAprsHint("Coordinates copied", 1200);
}
} catch {
showAprsHint("Copy failed", 1500);
}
})();
});
}
return row;
function renderAprsRow(pkt, isFresh) {
return renderAprsPacketRow(pkt, {
fresh: isFresh,
distance: aprsDistanceText(pkt),
onMap: (lat, lon) => {
aprsWindow.navigateToAprsMap?.(lat, lon);
},
onCopy: (text) => {
void copyAprsCoords(text);
}
});
}
function renderAprsHistory() {
pruneAprsPacketHistory();
@@ -223,15 +196,17 @@ function pruneAprsHistoryView() {
updateAprsBar();
renderAprsHistory();
}
function plotAprsPacket(pkt) {
if (pkt.lat == null || pkt.lon == null || !aprsWindow.aprsMapAddStation) return;
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
}
function addAprsPacket(pkt) {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs;
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
aprsPacketHistory.unshift(pkt);
pruneAprsPacketHistory();
if (pkt.lat != null && pkt.lon != null && aprsWindow.aprsMapAddStation) {
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
}
plotAprsPacket(pkt);
if (pkt.crcOk) scheduleAprsBarUpdate();
scheduleAprsHistoryRender();
}
@@ -248,9 +223,7 @@ function onServerAprsBatch(packets) {
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
if (next.lat != null && next.lon != null && aprsWindow.aprsMapAddStation) {
aprsWindow.aprsMapAddStation(next.srcCall ?? "", next.lat, next.lon, next.info ?? "", next.symbolTable, next.symbolCode, next);
}
plotAprsPacket(next);
if (next.crcOk) hasCrcOk = true;
normalized.push(next);
}
@@ -314,5 +287,9 @@ window.trxPluginRuntime.registerDecoder({
onBatch: onServerAprsBatch,
restore: onServerAprsBatch,
reset: resetAprsHistoryView,
prune: pruneAprsHistoryView
prune: pruneAprsHistoryView,
// Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => {
for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry);
}
});
@@ -328,6 +328,7 @@ function bmApply(bm) {
const modeEl = document.getElementById("mode");
if (modeEl) {
modeEl.value = (bm.mode || "").toUpperCase();
hostCore.syncModePicker();
}
if (bm.bandwidth_hz) {
hostState.currentBandwidthHz = bm.bandwidth_hz;
@@ -1,112 +0,0 @@
// src/plugins/aprs-shared.ts
function aprsPacketCategory(packet) {
const type = (packet.type ?? "").toLowerCase();
const info = (packet.info ?? "").toLowerCase();
if (packet.lat != null && packet.lon != null || type.includes("position")) return "position";
if (type.includes("message") || info.startsWith(":")) return "message";
if (type.includes("weather") || info.startsWith("_")) return "weather";
if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
return "other";
}
function aprsCategoryLabel(category) {
switch (category) {
case "position":
return "Position";
case "message":
return "Message";
case "weather":
return "Weather";
case "telemetry":
return "Telemetry";
default:
return "Other";
}
}
function aprsAgeText(timestampMs) {
if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return "just now";
const seconds = Math.round(Math.max(0, Date.now() - timestampMs) / 1e3);
if (seconds < 5) return "just now";
if (seconds < 60) return `${String(seconds)}s ago`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${String(minutes)}m ago`;
return `${String(Math.round(minutes / 60))}h ago`;
}
function aprsPacketSignature(packet) {
return [
packet.srcCall ?? "",
packet.destCall ?? "",
packet.path ?? "",
packet.info ?? "",
packet.type ?? "",
packet.lat?.toFixed(4) ?? "",
packet.lon?.toFixed(4) ?? ""
].join("|");
}
function collapseAprsDuplicates(packets) {
const seen = /* @__PURE__ */ new Set();
return packets.filter((packet) => {
const signature = aprsPacketSignature(packet);
if (seen.has(signature)) return false;
seen.add(signature);
return true;
});
}
function aprsHexBytes(bytes) {
if (!bytes?.length) return "--";
return bytes.map((byte) => byte.toString(16).toUpperCase().padStart(2, "0")).join(" ");
}
function renderAprsInfo(packet) {
if (packet.info_bytes?.length) {
return packet.info_bytes.map((byte) => renderAprsByte(byte)).join("");
}
return Array.from(packet.info ?? "", (character) => renderAprsCharacter(character)).join("");
}
function renderAprsByte(byte) {
return byte >= 32 && byte <= 126 ? escapeAprsCharacter(String.fromCharCode(byte)) : `<span class="aprs-byte">0x${byte.toString(16).toUpperCase().padStart(2, "0")}</span>`;
}
function renderAprsCharacter(character) {
const code = character.charCodeAt(0);
return code >= 32 && code <= 126 ? escapeAprsCharacter(character) : `<span class="aprs-byte">0x${code.toString(16).toUpperCase().padStart(2, "0")}</span>`;
}
function escapeAprsCharacter(character) {
if (character === "<") return "&lt;";
if (character === ">") return "&gt;";
if (character === "&") return "&amp;";
if (character === '"') return "&quot;";
return character;
}
function renderLocalAprsSymbol(packet, escapeHtml) {
if (!packet.symbolTable || !packet.symbolCode) return "";
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>`;
}
function normalizeAprsPacket(packet, receiver) {
return {
rig_id: packet.rig_id || null,
receiver,
srcCall: packet.src_call ?? "",
destCall: packet.dest_call ?? "",
path: packet.path ?? "",
info: packet.info ?? "",
info_bytes: packet.info_bytes ?? [],
type: packet.packet_type ?? "",
crcOk: packet.crc_ok ?? false,
ts_ms: packet.ts_ms ?? null,
lat: packet.lat ?? null,
lon: packet.lon ?? null,
symbolTable: packet.symbol_table ?? null,
symbolCode: packet.symbol_code ?? null
};
}
export {
aprsPacketCategory,
aprsCategoryLabel,
aprsAgeText,
collapseAprsDuplicates,
aprsHexBytes,
renderAprsInfo,
renderLocalAprsSymbol,
normalizeAprsPacket
};
@@ -0,0 +1,254 @@
// src/plugins/aprs-shared.ts
function escapeAprsHtml(value) {
return String(value ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
}
function aprsPacketCategory(packet) {
const type = (packet.type ?? "").toLowerCase();
const info = (packet.info ?? "").toLowerCase();
if (packet.lat != null && packet.lon != null || type.includes("position")) return "position";
if (type.includes("message") || info.startsWith(":")) return "message";
if (type.includes("weather") || info.startsWith("_")) return "weather";
if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
return "other";
}
function aprsCategoryLabel(category) {
switch (category) {
case "position":
return "Position";
case "message":
return "Message";
case "weather":
return "Weather";
case "telemetry":
return "Telemetry";
default:
return "Other";
}
}
function aprsAgeText(timestampMs) {
if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return "just now";
const seconds = Math.round(Math.max(0, Date.now() - timestampMs) / 1e3);
if (seconds < 5) return "just now";
if (seconds < 60) return `${String(seconds)}s ago`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${String(minutes)}m ago`;
return `${String(Math.round(minutes / 60))}h ago`;
}
function aprsPacketSignature(packet) {
return [
packet.srcCall ?? "",
packet.destCall ?? "",
packet.path ?? "",
packet.info ?? "",
packet.type ?? "",
packet.lat?.toFixed(4) ?? "",
packet.lon?.toFixed(4) ?? ""
].join("|");
}
function collapseAprsDuplicates(packets) {
const seen = /* @__PURE__ */ new Set();
return packets.filter((packet) => {
const signature = aprsPacketSignature(packet);
if (seen.has(signature)) return false;
seen.add(signature);
return true;
});
}
function aprsHexBytes(bytes) {
if (!bytes?.length) return "--";
return bytes.map((byte) => byte.toString(16).toUpperCase().padStart(2, "0")).join(" ");
}
function renderAprsInfo(packet) {
if (packet.info_bytes?.length) {
return packet.info_bytes.map((byte) => renderAprsByte(byte)).join("");
}
return Array.from(packet.info ?? "", (character) => renderAprsCharacter(character)).join("");
}
function renderAprsByte(byte) {
return byte >= 32 && byte <= 126 ? escapeAprsCharacter(String.fromCharCode(byte)) : `<span class="aprs-byte">0x${byte.toString(16).toUpperCase().padStart(2, "0")}</span>`;
}
function renderAprsCharacter(character) {
const code = character.charCodeAt(0);
return code >= 32 && code <= 126 ? escapeAprsCharacter(character) : `<span class="aprs-byte">0x${code.toString(16).toUpperCase().padStart(2, "0")}</span>`;
}
function escapeAprsCharacter(character) {
if (character === "<") return "&lt;";
if (character === ">") return "&gt;";
if (character === "&") return "&amp;";
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 renderAprsSymbolSlot(packet, escapeHtml) {
return renderLocalAprsSymbol(packet, escapeHtml) || '<span class="aprs-symbol aprs-symbol-empty" aria-hidden="true"></span>';
}
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>`;
}
function normalizeAprsPacket(packet, receiver) {
return {
rig_id: packet.rig_id || null,
receiver,
srcCall: packet.src_call ?? "",
destCall: packet.dest_call ?? "",
path: packet.path ?? "",
info: packet.info ?? "",
info_bytes: packet.info_bytes ?? [],
type: packet.packet_type ?? "",
crcOk: packet.crc_ok ?? false,
ts_ms: packet.ts_ms ?? null,
lat: packet.lat ?? null,
lon: packet.lon ?? null,
symbolTable: packet.symbol_table ?? null,
symbolCode: packet.symbol_code ?? null
};
}
function fahrenheitToCelsius(fahrenheit) {
return Math.round((fahrenheit - 32) * 5 / 9 * 10) / 10;
}
function summarizeAprsWeather(info) {
const parts = [];
const temperature = /t(-?\d{2,3})/.exec(info);
if (temperature) parts.push(`${fahrenheitToCelsius(Number(temperature[1]))} °C`);
const wind = /(\d{3})\/(\d{3})/.exec(info) ?? /c(\d{3}).*?s(\d{3})/.exec(info);
if (wind) {
const gust = /g(\d{3})/.exec(info);
const knots = Number(wind[2]);
parts.push(`wind ${Number(wind[1])}° ${knots} kt${gust ? ` gust ${Number(gust[1])}` : ""}`);
}
const humidity = /h(\d{2})/.exec(info);
if (humidity) {
const value = Number(humidity[1]);
parts.push(`${value === 0 ? 100 : value}% RH`);
}
const pressure = /b(\d{5})/.exec(info);
if (pressure) parts.push(`${(Number(pressure[1]) / 10).toFixed(1)} hPa`);
const rain = /r(\d{3})/.exec(info);
if (rain && Number(rain[1]) > 0) parts.push(`rain ${(Number(rain[1]) / 100).toFixed(2)}"`);
return parts.length ? parts.join(" · ") : null;
}
function summarizeAprsTelemetry(info) {
const match = /^T#(\d+|MIC)((?:,-?[\d.]*)+)(?:,([01]{8}))?/.exec(info.trim());
if (!match?.[2]) return null;
const channels = match[2].split(",").filter((value) => value.length > 0);
const bits = match[3] ? ` · bits ${match[3]}` : "";
return `#${match[1]} · ${channels.join(" ")}${bits}`;
}
function summarizeAprsMessage(info) {
const match = /^:([^:]{9}):(.*)$/.exec(info);
if (!match?.[1] || match[2] == null) return null;
const addressee = match[1].trim();
const text = match[2].replace(/\{\d+\s*$/, "").trim();
return `${addressee}: ${text}`;
}
function summarizeAprsCourseSpeed(info) {
const match = /(\d{3})\/(\d{3})/.exec(info);
if (!match) return null;
const knots = Number(match[2]);
if (knots === 0) return null;
return `${Number(match[1])}° ${knots} kt`;
}
function summarizeAprsPayload(packet) {
const info = packet.info ?? "";
if (!info) return null;
const category = aprsPacketCategory(packet);
if (category === "message") return summarizeAprsMessage(info);
if (category === "weather") return summarizeAprsWeather(info);
if (category === "telemetry") return summarizeAprsTelemetry(info);
if (category === "position") {
const parts = [];
if (packet.lat != null && packet.lon != null) {
parts.push(`${packet.lat.toFixed(4)}, ${packet.lon.toFixed(4)}`);
}
const courseSpeed = summarizeAprsCourseSpeed(info);
if (courseSpeed) parts.push(courseSpeed);
const comment = info.replace(/^[!=@/][^>]*[>_]?/, "").replace(/\d{3}\/\d{3}/, "").trim();
if (comment && comment.length <= 60) parts.push(comment);
return parts.length ? parts.join(" · ") : null;
}
const text = info.replace(/^[>;<?]/, "").trim();
return text.length ? text : null;
}
function renderAprsPacketRow(packet, options = {}) {
const row = document.createElement("details");
row.className = "aprs-packet";
if (!packet.crcOk) row.classList.add("aprs-packet-crc");
if (options.fresh) row.classList.add("aprs-packet-new");
const time = packet._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
const category = aprsPacketCategory(packet);
const summary = summarizeAprsPayload(packet);
const hasPosition = packet.lat != null && packet.lon != null;
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(packet.srcCall || "")}`;
row.innerHTML = `<summary class="decode-line"><span class="aprs-time">${escapeAprsHtml(time)}</span>` + (options.badge ? `<span class="aprs-badge aprs-badge-band">${escapeAprsHtml(options.badge)}</span>` : "") + renderAprsSymbolSlot(packet, escapeAprsHtml) + `<span class="aprs-call">${escapeAprsHtml(packet.srcCall ?? "")}</span><span class="aprs-badge aprs-badge-type aprs-badge-type-${category}">${escapeAprsHtml(aprsCategoryLabel(category))}</span><span class="decode-line-summary">${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}</span>` + (packet.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC</span>') + (options.distance ? `<span class="decode-line-distance">${escapeAprsHtml(options.distance)}</span>` : "") + `</summary><div class="decode-expanded"><div class="decode-expanded-meta"><span>&gt;${escapeAprsHtml(packet.destCall || "--")}</span><span>${escapeAprsHtml(packet.path || "no path")}</span><span>${escapeAprsHtml(aprsAgeText(packet._tsMs))}</span><span>CRC ${packet.crcOk ? "ok" : "failed"}</span>` + (hasPosition ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${packet.lat},${packet.lon}">${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}</a>` : "") + `</div><div class="decode-expanded-raw">${renderAprsInfo(packet)}</div>` + (packet.info_bytes?.length ? `<div class="decode-expanded-bytes">${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}</div>` : "") + `<div class="aprs-row-actions">` + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${packet.lat},${packet.lon}">Map</button>` : "") + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${packet.lat},${packet.lon}">Copy Coords</button>` : "") + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a></div></div>`;
row.querySelectorAll("[data-aprs-map]").forEach((element) => {
element.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const [lat, lon] = (element.dataset.aprsMap ?? "").split(",").map(Number);
if (Number.isFinite(lat) && Number.isFinite(lon)) options.onMap?.(lat, lon);
});
});
const copyButton = row.querySelector("[data-aprs-copy]");
if (copyButton) {
copyButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
options.onCopy?.(copyButton.dataset.aprsCopy ?? "", copyButton);
});
}
return row;
}
export {
aprsPacketCategory,
aprsAgeText,
collapseAprsDuplicates,
aprsSymbolSprite,
normalizeAprsPacket,
renderAprsPacketRow
};
@@ -1,13 +1,10 @@
import {
aprsAgeText,
aprsCategoryLabel,
aprsHexBytes,
aprsPacketCategory,
collapseAprsDuplicates,
normalizeAprsPacket,
renderAprsInfo,
renderLocalAprsSymbol
} from "./chunk-M2I6DH4X.js";
renderAprsPacketRow
} from "./chunk-OPEIVJGD.js";
import {
hostCore,
hostState
@@ -15,7 +12,6 @@ import {
// src/plugins/hf-aprs.ts
var hfAprsWindow = window;
var escapeHfAprsHtml = (input) => hostCore.escapeMapHtml(input);
var hfAprsStatus = document.getElementById("hf-aprs-status");
var hfAprsPacketsEl = document.getElementById("hf-aprs-packets");
var hfAprsFilterInput = document.getElementById("hf-aprs-filter");
@@ -102,51 +98,27 @@ function updateHfAprsChipState() {
hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup);
}
function renderHfAprsRow(pkt, isFresh) {
const row = document.createElement("div");
row.className = "aprs-packet";
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
if (isFresh) row.classList.add("aprs-packet-new");
const ts = pkt._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
const age = aprsAgeText(pkt._tsMs);
const category = aprsPacketCategory(pkt);
const categoryLabel = aprsCategoryLabel(category);
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeHfAprsHtml(pkt.path)}</span>` : "";
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
const hfBadge = '<span class="aprs-badge" style="background:var(--accent-alt,#f59e0b);color:#000">HF</span>';
const symbolHtml = renderLocalAprsSymbol(pkt, escapeHfAprsHtml);
const posLink = pkt.lat != null && pkt.lon != null ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>` : "";
const distance = hfAprsDistanceText(pkt);
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
row.innerHTML = `<div class="aprs-row-head"><span class="aprs-time">${ts}</span>` + hfBadge + symbolHtml + `<span class="aprs-call">${escapeHfAprsHtml(pkt.srcCall ?? "")}</span><span>&gt;${escapeHfAprsHtml(pkt.destCall || "")}</span><span class="${categoryClass}">${escapeHfAprsHtml(categoryLabel)}</span>` + pathBadge + crcBadge + `</div><div class="aprs-row-meta"><span class="aprs-meta-text">${escapeHfAprsHtml(age)}</span>` + (distance ? `<span class="aprs-meta-text">${escapeHfAprsHtml(distance)}</span>` : "") + `<span class="aprs-meta-text">${escapeHfAprsHtml(pkt.type || "--")}</span></div><div class="aprs-row-detail"><span title="${escapeHfAprsHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` + (posLink ? `<span>${posLink}</span>` : "") + `</div><div class="aprs-row-actions">` + (pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") + (pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a></div><details class="aprs-details"><summary>Details</summary><div class="aprs-details-grid"><span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.srcCall || "--")}</span><span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.destCall || "--")}</span><span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.type || "--")}</span><span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.path || "--")}</span><span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeHfAprsHtml(age)}</span><span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span><span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span><span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.info || "--")}</span><span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeHfAprsHtml(aprsHexBytes(pkt.info_bytes))}</span></div></details>`;
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
el.addEventListener("click", (evt) => {
evt.preventDefault();
const raw = el.dataset.aprsMap ?? "";
const [lat, lon] = raw.split(",").map(Number);
if (hfAprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) {
hfAprsWindow.navigateToAprsMap(lat, lon);
return renderAprsPacketRow(pkt, {
fresh: isFresh,
badge: "HF",
distance: hfAprsDistanceText(pkt),
onMap: (lat, lon) => {
hfAprsWindow.navigateToAprsMap?.(lat, lon);
},
onCopy: (text) => {
void copyHfAprsCoords(text);
}
});
});
const copyBtn = row.querySelector("[data-aprs-copy]");
if (copyBtn) {
copyBtn.addEventListener("click", () => {
void (async () => {
const raw = copyBtn.dataset.aprsCopy ?? "";
}
async function copyHfAprsCoords(text) {
try {
const clipboard = Reflect.get(navigator, "clipboard");
if (clipboard) {
await clipboard.writeText(raw);
if (!clipboard) return;
await clipboard.writeText(text);
hostCore.showHint("Coordinates copied", 1200);
}
} catch {
hostCore.showHint("Copy failed", 1500);
}
})();
});
}
return row;
}
function renderHfAprsHistory() {
pruneHfAprsPacketHistory();
@@ -1,3 +1,7 @@
import {
aprsSymbolSprite
} from "./chunk-OPEIVJGD.js";
// src/map-core.ts
function mapEl(id) {
const element = document.querySelector(`#${CSS.escape(id)}`);
@@ -58,6 +62,7 @@ var mapWindow = window;
const mapMarkers = /* @__PURE__ */ new Set();
const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER };
const MAP_FILTER_ALL_KEY = "__all";
const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() };
let mapSearchFilter = "";
let mapRigFilter = "";
@@ -834,38 +839,36 @@ var mapWindow = window;
container.innerHTML = `<span class="map-locator-empty">No ${kind === "band" ? "bands" : "sources"} available</span>`;
return;
}
let helperText = "";
const noun = kind === "band" ? "bands" : "sources";
const sourceKeys = kind === "source" ? Object.keys(DEFAULT_MAP_SOURCE_FILTER) : [];
const noneSelected = kind === "source" && sourceKeys.every((k) => !mapFilter[k]);
if (kind === "source") {
if (noneSelected) {
helperText = "All sources visible — click to filter";
}
} else if (!(selectedSet instanceof Set) || selectedSet.size === 0) {
helperText = `All ${kind === "band" ? "bands" : "sources"} visible by default`;
}
const showingAll = kind === "source" ? sourceKeys.every((k) => !mapFilter[k]) : !(selectedSet instanceof Set) || selectedSet.size === 0;
const allChip = document.createElement("button");
allChip.type = "button";
allChip.className = "map-locator-chip map-locator-chip-all";
if (showingAll) allChip.classList.add("is-active");
allChip.dataset.filterKind = kind;
allChip.dataset.filterKey = MAP_FILTER_ALL_KEY;
allChip.setAttribute("aria-pressed", showingAll ? "true" : "false");
allChip.title = showingAll ? `All ${noun} shown` : `Show all ${noun}`;
allChip.innerHTML = `<span class="map-locator-chip-text">All</span>`;
container.appendChild(allChip);
for (const item of items) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "map-locator-chip";
const isActive = kind === "source" ? !!mapFilter[item.key] : !!selectedSet?.has(item.key);
if (kind === "source" && noneSelected) {
if (showingAll) {
btn.classList.add("is-default");
} else if (!isActive) {
btn.classList.add("is-inactive");
}
btn.setAttribute("aria-pressed", !showingAll && isActive ? "true" : "false");
btn.dataset.filterKind = kind;
btn.dataset.filterKey = item.key;
btn.style.setProperty("--chip-color", item.color);
btn.innerHTML = `<span class="map-locator-chip-text">${escapeMapHtml(item.label)}</span>`;
container.appendChild(btn);
}
if (helperText) {
const hint = document.createElement("span");
hint.className = "map-locator-empty";
hint.textContent = helperText;
container.appendChild(hint);
}
}
function renderMapLocatorPhaseRow(container, phase) {
if (!container) return;
@@ -973,11 +976,10 @@ var mapWindow = window;
renderMapLocatorLegend(mapLocatorFilter.phase, sourceItems, bandItems);
if (!phaseEl || !choiceEl || !choiceLabelEl) return;
renderMapLocatorPhaseRow(phaseEl, mapLocatorFilter.phase);
choiceLabelEl.textContent = "Show";
if (mapLocatorFilter.phase === "band") {
choiceLabelEl.textContent = "Visible Bands";
renderMapLocatorChipRow(choiceEl, bandItems, mapLocatorFilter.bands, "band");
} else {
choiceLabelEl.textContent = "Visible Sources";
renderMapLocatorChipRow(choiceEl, sourceItems, null, "source");
}
syncLocatorMarkerStyles();
@@ -1331,7 +1333,8 @@ var mapWindow = window;
function applyMapOverlayPanelVisibility() {
const panel = document.querySelector("#map-stage .map-overlay-panel");
if (!panel) return;
panel.classList.toggle("is-hidden", !mapOverlayPanelVisible);
panel.classList.toggle("filters-hidden", !mapOverlayPanelVisible);
panel.querySelector(".map-overlay-filters")?.classList.toggle("is-hidden", !mapOverlayPanelVisible);
}
function updateMapOverlayToggleButton() {
const btn = mapEl("map-overlay-toggle-btn");
@@ -1528,7 +1531,13 @@ var mapWindow = window;
const kind = String(chip.dataset.filterKind || "");
const key = String(chip.dataset.filterKey || "");
if (!key) return;
if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) {
if (key === MAP_FILTER_ALL_KEY) {
if (kind === "source") {
for (const srcKey of Object.keys(DEFAULT_MAP_SOURCE_FILTER)) mapFilter[srcKey] = false;
} else {
mapLocatorFilter.bands.clear();
}
} else if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) {
const sourceKey = key;
mapFilter[sourceKey] = !mapFilter[sourceKey];
const srcKeys = Object.keys(DEFAULT_MAP_SOURCE_FILTER);
@@ -1643,42 +1652,29 @@ 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]
});
}
mapWindow.navigateToAprsMap = function(lat, lon) {
T._activeTab = "map";
document.querySelectorAll(".tab-bar .tab").forEach((t) => {
t.classList.remove("active");
});
const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']");
if (mapTabBtn) mapTabBtn.classList.add("active");
document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none");
const mapPanel = mapEl("tab-map");
if (mapPanel) mapPanel.style.display = "";
function focusMapPosition(lat, lon) {
initAprsMap();
sizeAprsMapToViewport();
if (aprsMap) {
@@ -1690,19 +1686,10 @@ var mapWindow = window;
});
});
}
};
mapWindow.navigateToMapLocator = function(grid, preferredType = null) {
}
function focusMapLocator(grid, preferredType = null) {
const normalizedGrid = String(grid || "").trim().toUpperCase();
if (!/^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalizedGrid)) return false;
T._activeTab = "map";
document.querySelectorAll(".tab-bar .tab").forEach((t) => {
t.classList.remove("active");
});
const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']");
if (mapTabBtn) mapTabBtn.classList.add("active");
document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none");
const mapPanel = mapEl("tab-map");
if (mapPanel) mapPanel.style.display = "";
initAprsMap();
sizeAprsMapToViewport();
if (!aprsMap) return false;
@@ -1747,7 +1734,7 @@ var mapWindow = window;
requestAnimationFrame(focusMarker);
});
return true;
};
}
function buildReceiverPopupHtml(rigIds) {
const call = T.serverCallsign || T.ownerCallsign || "Receiver";
let meta = "";
@@ -2165,14 +2152,16 @@ var mapWindow = window;
function updateMapContactPathsToggle() {
const btn = mapEl("map-contact-paths-toggle");
if (!btn) return;
btn.textContent = mapDecodeContactPathsEnabled ? "Contact Paths On" : "Contact Paths Off";
btn.classList.toggle("is-active", mapDecodeContactPathsEnabled);
btn.setAttribute("aria-pressed", mapDecodeContactPathsEnabled ? "true" : "false");
btn.title = mapDecodeContactPathsEnabled ? "Directed decode paths are drawn when the target locator is known" : "Directed decode paths are hidden";
}
function updateMapP2pPathsToggle() {
const btn = mapEl("map-p2p-paths-toggle");
if (!btn) return;
btn.textContent = mapP2pRadioPathsEnabled ? "TRX Paths On" : "TRX Paths Off";
btn.classList.toggle("is-active", mapP2pRadioPathsEnabled);
btn.setAttribute("aria-pressed", mapP2pRadioPathsEnabled ? "true" : "false");
btn.title = mapP2pRadioPathsEnabled ? "TRX paths are drawn from a station popup" : "TRX paths are hidden";
}
function scheduleDecodeMapMaintenance() {
if (C.decodeHistoryMapRenderingDeferred()) {
@@ -2332,7 +2321,7 @@ var mapWindow = window;
selectedMapQsoKey = selectedMapQsoKey === entry.pathKey ? null : entry.pathKey ?? null;
syncDecodeContactPathVisibility();
if (selectedMapQsoKey && entry.sourceGrid) {
mapWindow.navigateToMapLocator?.(entry.sourceGrid, entry.sourceType);
focusMapLocator(entry.sourceGrid, entry.sourceType);
}
});
const head = document.createElement("div");
@@ -2438,7 +2427,7 @@ var mapWindow = window;
card.className = "map-qso-card";
if (entry.grid) {
card.addEventListener("click", () => {
mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType);
focusMapLocator(entry.grid ?? "", entry.sourceType);
});
}
const head = document.createElement("div");
@@ -2544,7 +2533,7 @@ var mapWindow = window;
card.className = "map-qso-card";
if (entry.grid) {
card.addEventListener("click", () => {
mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType);
focusMapLocator(entry.grid ?? "", entry.sourceType);
});
}
const head = document.createElement("div");
@@ -3049,6 +3038,8 @@ var mapWindow = window;
}
modules.map = {
initAprsMap,
focusMapPosition,
focusMapLocator,
sizeAprsMapToViewport,
syncAprsReceiverMarker,
updateMapRigFilter,
@@ -3109,5 +3100,6 @@ var mapWindow = window;
bandForHz,
reverseGeocodeLocation
};
window.trxPluginRuntime.syncMapAll();
autoInitIfVisible();
})();
@@ -286,7 +286,10 @@ function vchanSyncModeDisplay() {
if (!modeEl) return;
if (vchanIsOnVirtual()) {
const ch = vchanActiveChannel();
if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase();
if (ch && ch.mode) {
modeEl.value = ch.mode.toUpperCase();
hostCore.syncModePicker();
}
}
const modeUpper = (modeEl.value || "").toUpperCase();
if (typeof hostState.lastModeName === "string") {
@@ -220,9 +220,7 @@ function onServerVdesBatch(messages) {
minute: "2-digit",
second: "2-digit"
});
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
vdesWindow.vdesMapAddPoint(next);
}
plotVdesMessage(next);
normalized.push(next);
}
normalized.reverse();
@@ -248,13 +246,15 @@ if (vdesFilterInput) {
renderVdesHistory();
});
}
function plotVdesMessage(msg) {
if (msg.lat == null || msg.lon == null || !vdesWindow.vdesMapAddPoint) return;
vdesWindow.vdesMapAddPoint(msg);
}
function onServerVdes(msg) {
if (vdesStatus) vdesStatus.textContent = "Receiving";
const next = normalizeServerVdesMessage(msg);
addVdesMessage(next);
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
vdesWindow.vdesMapAddPoint(next);
}
plotVdesMessage(next);
}
function pruneVdesHistoryView() {
pruneVdesMessageHistory();
@@ -268,5 +268,9 @@ window.trxPluginRuntime.registerDecoder({
onBatch: onServerVdesBatch,
restore: onServerVdesBatch,
reset: resetVdesHistoryView,
prune: pruneVdesHistoryView
prune: pruneVdesHistoryView,
// Oldest first, so tracks are rebuilt in the order they happened.
syncMap: () => {
for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry);
}
});
@@ -22,7 +22,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
<symbol id="icon-home" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.8 7 8 2.9 13.2 7"/><path d="M4.3 5.9V13h7.4V5.9"/><path d="M6.8 13V9.3h2.4V13"/></symbol>
<symbol id="icon-bookmark" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2h8v12l-4-2.5L4 14V2z"/></symbol>
<symbol id="icon-signal" viewBox="0 0 16 16" fill="currentColor"><rect x="1" y="11" width="2.5" height="4" rx="0.5"/><rect x="4.75" y="8" width="2.5" height="7" rx="0.5"/><rect x="8.5" y="5" width="2.5" height="10" rx="0.5"/><rect x="12.25" y="2" width="2.5" height="13" rx="0.5"/></symbol>
<symbol id="icon-digital" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 11.5h2.6V4.5h3.2v7h3.2v-7h3.2v7H15"/></symbol>
<symbol id="icon-map" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2a4 4 0 0 1 4 4c0 3-4 8-4 8S4 9 4 6a4 4 0 0 1 4-4z"/><circle cx="8" cy="6" r="1.2" fill="currentColor" stroke="none"/></symbol>
<symbol id="icon-stats" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 14h12"/><rect x="3" y="8" width="2" height="6" rx="0.4" fill="currentColor" stroke="none" opacity="0.6"/><rect x="7" y="5" width="2" height="9" rx="0.4" fill="currentColor" stroke="none" opacity="0.75"/><rect x="11" y="2" width="2" height="12" rx="0.4" fill="currentColor" stroke="none" opacity="0.9"/></symbol>
<symbol id="icon-record" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><circle cx="8" cy="8" r="2.5" fill="currentColor" stroke="none"/></symbol>
@@ -53,7 +53,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<span class="tab-label">Bookmarks</span>
</button>
<button class="tab" data-tab="digital-modes">
<svg class="tab-icon" aria-hidden="true"><use href="#icon-signal"/></svg>
<svg class="tab-icon" aria-hidden="true"><use href="#icon-digital"/></svg>
<span class="tab-label">Digital modes</span>
</button>
<button class="tab" data-tab="map">
@@ -142,6 +142,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div id="spectrum-bookmark-side-left" class="spectrum-bookmark-side spectrum-bookmark-side-left" aria-hidden="true"></div>
<canvas id="spectrum-canvas" tabindex="0" role="img" aria-label="Spectrum display" aria-describedby="spectrum-text-summary"></canvas>
<p id="spectrum-text-summary" class="visually-hidden">Spectrum data is waiting for the receiver.</p>
<div id="spectrum-squelch-line" class="spectrum-squelch-line" style="display:none;" data-state="closed">
<span class="spectrum-squelch-grip" id="spectrum-squelch-grip" role="slider" tabindex="0"
aria-label="Squelch threshold" aria-valuemin="-120" aria-valuemax="-30" aria-valuenow="-95">SQL <span id="spectrum-squelch-label">-95</span> dB</span>
</div>
<div id="spectrum-zoom-indicator" aria-hidden="true"></div>
<div id="spectrum-minimap" aria-hidden="true"><div class="minimap-view"></div></div>
<div id="spectrum-db-axis" aria-hidden="true"></div>
@@ -205,13 +209,40 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="label"><span>Signal strength</span></div>
</div>
<div class="freq-field frequency-col">
<input class="status-input" id="freq" type="text" value="--" aria-describedby="freq-label" aria-label="Tuned frequency" />
<input class="status-input" id="freq" type="text" value="--" aria-describedby="freq-label" aria-label="Tuned frequency" autocomplete="off" autocorrect="off" spellcheck="false" />
<div class="label" id="freq-label"><span>Frequency</span></div>
</div>
<div class="freq-field frequency-col center-frequency-col" id="center-freq-field" style="display:none;">
<input class="status-input" id="center-freq" type="text" value="--" aria-describedby="center-freq-label" aria-label="SDR center frequency" />
<input class="status-input" id="center-freq" type="text" value="--" aria-describedby="center-freq-label" aria-label="SDR center frequency" autocomplete="off" autocorrect="off" spellcheck="false" />
<div class="label" id="center-freq-label"><span>Center Frequency</span></div>
</div>
</div>
</div>
<div class="full-row controls-tray-shell">
<div class="controls-tray-scroll">
<div class="controls-tray">
<div class="controls-row full-row">
<div class="controls-col controls-col-mode label-below-col">
<div class="label"><span>Mode</span></div>
<div class="inline">
<!-- The select stays as the mode's value: a dozen call sites and
several plugins read #mode.value. It is hidden from sight and
from assistive tech; the buttons beside it are the control. -->
<select class="visually-hidden" id="mode" tabindex="-1" aria-hidden="true"></select>
<div id="mode-picker" class="mode-picker" role="group" aria-label="Demodulation mode"></div>
</div>
</div>
<div class="controls-col controls-col-center">
<div class="jog-container">
<button id="jog-down" type="button" class="jog-btn">&minus;</button>
<div class="jog-wheel" id="jog-wheel">
<div class="jog-indicator" id="jog-indicator"></div>
</div>
<button id="jog-up" type="button" class="jog-btn">+</button>
</div>
</div>
<div class="controls-col controls-col-step">
<div class="inline step-controls-inline">
<div class="freq-field unit-col">
<div class="jog-step" id="jog-step">
<button type="button" data-step="1000000">MHz</button>
@@ -229,25 +260,16 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div>
</div>
</div>
<div class="full-row controls-tray-shell">
<div class="controls-tray-scroll">
<div class="controls-tray">
<div class="controls-row full-row">
<div class="controls-col label-below-col">
<div class="label"><span>Mode</span></div>
<div class="inline">
<select class="status-input" id="mode" aria-label="Demodulation mode"></select>
<div class="controls-col controls-col-power label-below-col" id="tx-power-col">
<div class="label"><span>Transmit / Power</span></div>
<div class="btn-grid">
<button id="ptt-btn" type="button" aria-pressed="false">Start TX</button>
<button id="power-btn" type="button" aria-pressed="false">Power On</button>
<button id="lock-btn" type="button" aria-pressed="false">Lock Tuning</button>
</div>
</div>
<div class="controls-col controls-col-center">
<div class="jog-container">
<button id="jog-down" type="button" class="jog-btn">&minus;</button>
<div class="jog-wheel" id="jog-wheel">
<div class="jog-indicator" id="jog-indicator"></div>
</div>
<button id="jog-up" type="button" class="jog-btn">+</button>
</div>
</div>
<div class="controls-row controls-row-mode full-row" id="mode-controls-row" style="display:none">
<div class="controls-col controls-col-wfm label-below-col" id="wfm-controls-col" style="display:none;">
<div class="inline wfm-controls-inline">
<label class="wfm-control">
@@ -305,14 +327,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div>
<div class="label"><span>SAM</span></div>
</div>
<div class="controls-col controls-col-power label-below-col" id="tx-power-col">
<div class="label"><span>Transmit / Power</span></div>
<div class="btn-grid">
<button id="ptt-btn" type="button" aria-pressed="false">Start TX</button>
<button id="power-btn" type="button" aria-pressed="false">Power On</button>
<button id="lock-btn" type="button" aria-pressed="false">Lock Tuning</button>
</div>
</div>
</div>
<div class="full-row label-below-row" id="vfo-row">
<div class="label"><span>VFO</span></div>
@@ -362,32 +376,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="vchan-picker" id="vchan-picker"></div>
</div>
</div>
<details id="scheduler-controls" class="advanced-radio-controls scheduler-controls-section">
<summary>Scheduler controls</summary>
<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>
</div>
</div>
</div>
</div>
</details>
<div class="full-row label-below-row">
<div class="label"><span>Signal</span></div>
<div class="signal" style="gap: 1rem;">
@@ -425,13 +413,51 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="inline" style="gap: 0.6rem; flex-wrap: wrap; align-items: center;">
<button id="rx-audio-btn" type="button">Play Audio</button>
<button id="tx-audio-btn" type="button">Transmit Audio</button>
<span class="audio-group audio-volume-group">
<span class="audio-group-label">Volume</span>
<label class="vol-label">RX<input type="range" id="rx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="rx-vol-pct">80%</small></label>
<label class="vol-label">TX<input type="range" id="tx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="tx-vol-pct">80%</small></label>
<label class="vol-label" id="sdr-squelch-wrap" style="display:none;">SQL<input type="range" id="sdr-squelch" min="0" max="100" value="0" class="vol-slider" /><small class="vol-pct" id="sdr-squelch-pct">Open</small><button id="sdr-squelch-auto" type="button" class="sql-auto-btn" title="Set squelch to current noise level">Auto</button></label>
</span>
<span class="sql-control" id="sdr-squelch-wrap" style="display:none;">
<button id="sdr-squelch-toggle" type="button" class="sql-toggle" aria-pressed="false" title="Turn the squelch on or off"><span class="sql-state" id="sdr-squelch-state" data-state="off" aria-hidden="true"></span>SQL</button>
<label class="sql-db"><input type="number" id="sdr-squelch-db" min="-120" max="-30" step="1" value="-95" inputmode="numeric" aria-label="Squelch threshold in dB" /><span class="sql-db-unit">dB</span></label>
<button id="sdr-squelch-auto" type="button" class="sql-auto-btn" title="Set the threshold just above the noise floor">Auto</button>
</span>
<span class="audio-group audio-level-group">
<span class="audio-group-label">Level</span>
<div id="audio-level">
<div id="audio-level-fill"></div>
</div>
<small id="audio-status" style="min-width: 60px;">Off</small>
<small id="audio-status">Off</small>
</span>
</div>
</div>
</div>
</details>
<details id="scheduler-controls" class="advanced-radio-controls scheduler-controls-section">
<summary>Scheduler controls</summary>
<div class="advanced-radio-body">
<div class="scheduler-control-row" style="display:none">
<div class="scheduler-release-wrap">
<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>
@@ -654,19 +680,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
<input id="ais-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. MMSI, vessel, A)" />
<small id="ais-status" style="color:var(--text-muted);">Waiting for server decode</small>
</div>
<div class="ais-summary">
<div class="ais-summary-card">
<span class="ais-summary-label">Channels</span>
<span id="ais-channel-summary" class="ais-summary-value">A 161.975 MHz · B 162.025 MHz</span>
</div>
<div class="ais-summary-card">
<span class="ais-summary-label">Tracked</span>
<span id="ais-vessel-count" class="ais-summary-value">0 vessels</span>
</div>
<div class="ais-summary-card">
<span class="ais-summary-label">Latest</span>
<span id="ais-latest-seen" class="ais-summary-value">No traffic yet</span>
</div>
<div class="aprs-filter-row">
<span class="aprs-counts">
<span id="ais-vessel-count" class="aprs-counts-value">0 vessels</span>
<span id="ais-latest-seen" class="aprs-counts-value">No traffic yet</span>
<span id="ais-channel-summary" class="aprs-counts-value">A 161.975 MHz · B 162.025 MHz</span>
</span>
</div>
<div id="ais-messages"></div>
</div>
@@ -696,20 +715,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<input id="aprs-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. SP2, beacon)" />
<small id="aprs-status" style="color:var(--text-muted);">Waiting for server decode</small>
</div>
<div class="aprs-summary">
<div class="aprs-summary-card">
<span class="aprs-summary-label">Frames</span>
<span id="aprs-total-count" class="aprs-summary-value">0 total</span>
</div>
<div class="aprs-summary-card">
<span class="aprs-summary-label">Visible</span>
<span id="aprs-visible-count" class="aprs-summary-value">0 shown</span>
</div>
<div class="aprs-summary-card">
<span class="aprs-summary-label">Latest</span>
<span id="aprs-latest-seen" class="aprs-summary-value">No packets yet</span>
</div>
</div>
<div class="aprs-filter-row">
<button id="aprs-type-all" class="aprs-chip active" type="button">All</button>
<button id="aprs-type-position" class="aprs-chip" type="button">Pos</button>
@@ -717,11 +722,15 @@ SPDX-License-Identifier: GPL-2.0-or-later
<button id="aprs-type-weather" class="aprs-chip" type="button">Wx</button>
<button id="aprs-type-telemetry" class="aprs-chip" type="button">Tlm</button>
<button id="aprs-type-other" class="aprs-chip" type="button">Other</button>
</div>
<div class="aprs-filter-row">
<span class="aprs-filter-sep" aria-hidden="true"></span>
<button id="aprs-only-pos-btn" class="aprs-chip" type="button">Pos Only</button>
<button id="aprs-hide-crc-btn" class="aprs-chip" type="button">No CRC</button>
<button id="aprs-collapse-dup-btn" class="aprs-chip" type="button">Dupes</button>
<span class="aprs-counts">
<span id="aprs-visible-count" class="aprs-counts-value">0 shown</span>
<span id="aprs-total-count" class="aprs-counts-value">0 total</span>
<span id="aprs-latest-seen" class="aprs-counts-value">No packets yet</span>
</span>
</div>
<div id="aprs-packets"></div>
</div>
@@ -731,20 +740,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<input id="hf-aprs-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. SP2, beacon)" />
<small id="hf-aprs-status" style="color:var(--text-muted);">Waiting for server decode</small>
</div>
<div class="aprs-summary">
<div class="aprs-summary-card">
<span class="aprs-summary-label">Frames</span>
<span id="hf-aprs-total-count" class="aprs-summary-value">0 total</span>
</div>
<div class="aprs-summary-card">
<span class="aprs-summary-label">Visible</span>
<span id="hf-aprs-visible-count" class="aprs-summary-value">0 shown</span>
</div>
<div class="aprs-summary-card">
<span class="aprs-summary-label">Latest</span>
<span id="hf-aprs-latest-seen" class="aprs-summary-value">No packets yet</span>
</div>
</div>
<div class="aprs-filter-row">
<button id="hf-aprs-type-all" class="aprs-chip active" type="button">All</button>
<button id="hf-aprs-type-position" class="aprs-chip" type="button">Pos</button>
@@ -752,11 +747,15 @@ SPDX-License-Identifier: GPL-2.0-or-later
<button id="hf-aprs-type-weather" class="aprs-chip" type="button">Wx</button>
<button id="hf-aprs-type-telemetry" class="aprs-chip" type="button">Tlm</button>
<button id="hf-aprs-type-other" class="aprs-chip" type="button">Other</button>
</div>
<div class="aprs-filter-row">
<span class="aprs-filter-sep" aria-hidden="true"></span>
<button id="hf-aprs-only-pos-btn" class="aprs-chip" type="button">Pos Only</button>
<button id="hf-aprs-hide-crc-btn" class="aprs-chip" type="button">No CRC</button>
<button id="hf-aprs-collapse-dup-btn" class="aprs-chip" type="button">Dupes</button>
<span class="aprs-counts">
<span id="hf-aprs-visible-count" class="aprs-counts-value">0 shown</span>
<span id="hf-aprs-total-count" class="aprs-counts-value">0 total</span>
<span id="hf-aprs-latest-seen" class="aprs-counts-value">No packets yet</span>
</span>
</div>
<div id="hf-aprs-packets"></div>
</div>
@@ -1002,8 +1001,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
<template id="tmpl-map">
<div id="map-stage">
<div class="map-overlay-panel">
<div class="map-overlay-filters">
<div class="map-locator-filter-group">
<span class="map-locator-filter-label">Filter by</span>
<span class="map-locator-filter-label">Filter</span>
<div id="map-locator-phase" class="map-locator-phase-row"></div>
</div>
<div class="map-locator-filter-group">
@@ -1016,10 +1016,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<option value="">All</option>
</select>
</div>
<div class="map-locator-filter-group">
<span class="map-locator-filter-label">Search</span>
<input type="text" id="map-search-filter" class="map-search-input" placeholder="Callsign, MMSI, locator, message..." />
</div>
<div class="map-locator-filter-group">
<span class="map-locator-filter-label">History</span>
<select id="map-history-limit" class="map-history-select" aria-label="Map history limit">
@@ -1035,16 +1031,23 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="map-locator-filter-group">
<span class="map-locator-filter-label">Paths</span>
<div class="map-locator-phase-row">
<button type="button" id="map-p2p-paths-toggle" class="map-locator-phase-btn">TRX Paths On</button>
<button type="button" id="map-contact-paths-toggle" class="map-locator-phase-btn">Contact Paths On</button>
<span class="map-locator-empty">TRX paths on popup, directed decode paths when target locator is known</span>
<button type="button" id="map-p2p-paths-toggle" class="map-locator-phase-btn" aria-pressed="true" title="TRX paths are drawn from a station popup">TRX</button>
<button type="button" id="map-contact-paths-toggle" class="map-locator-phase-btn" aria-pressed="true" title="Directed decode paths are drawn when the target locator is known">Contact</button>
<span class="map-paths-hint">TRX paths on popup, directed decode paths when target locator is known</span>
</div>
</div>
<!-- Last, so the search field takes whatever the fixed-width
groups leave on the bar's final row rather than a sliver. -->
<div class="map-locator-filter-group map-filter-grow">
<span class="map-locator-filter-label">Search</span>
<input type="text" id="map-search-filter" class="map-search-input" placeholder="Callsign, MMSI, locator, message..." />
</div>
<div class="map-corner-controls">
</div>
<div class="map-overlay-actions">
<button type="button" id="map-fullscreen-btn" class="map-fullscreen-btn">Fullscreen</button>
<button type="button" id="map-overlay-toggle-btn" class="map-overlay-toggle-btn">Hide Filters</button>
</div>
</div>
<div id="map-band-legend" class="map-band-legend" aria-label="Band color legend"></div>
<div id="aprs-map"></div>
</div>
@@ -1594,10 +1597,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">
@@ -1631,11 +1637,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="shortcut-overlay-hint">Press <kbd>F1</kbd> or <kbd>Esc</kbd> to close</div>
</div>
</div>
<div id="decode-history-overlay" class="decode-history-overlay is-hidden" aria-live="polite" aria-atomic="true">
<div class="decode-history-overlay-card">
<div id="decode-history-overlay-title" class="decode-history-overlay-title">Loading decode history…</div>
<div id="decode-history-overlay-sub" class="decode-history-overlay-sub">Preparing recent decodes for the UI</div>
<div id="decode-history-overlay" class="history-progress is-hidden" role="status" aria-live="polite" aria-atomic="true">
<div class="history-progress-text">
<span id="decode-history-overlay-title" class="history-progress-title">Loading decode history…</span>
<span id="decode-history-overlay-sub" class="history-progress-sub">Preparing recent decodes for the UI</span>
</div>
<span class="history-progress-track"><span id="decode-history-progress-bar" class="history-progress-bar"></span></span>
</div>
<script defer src="/vendor/opus-decoder-0.7.11.min.js" charset="UTF-8"></script>
<script defer src="/vendor/leaflet.js"></script>
File diff suppressed because it is too large Load Diff
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 && node tests/decode-flow.mjs",
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
},
"devDependencies": {
@@ -172,6 +172,8 @@ interface TrxModules {
map?: {
aprsMap?: { invalidateSize(): void } | null;
initAprsMap(): void;
focusMapPosition?(lat: number, lon: number): void;
focusMapLocator?(grid: string, preferredType?: string | null): boolean;
sizeAprsMapToViewport(): void;
pruneMapHistory(): void;
updateMapBaseLayerForTheme(theme: string): void;
@@ -359,6 +361,10 @@ declare global {
trx: { state: TrxState; core: Readonly<Record<string, unknown>>; modules: TrxModules };
trxUi: TrxUi;
trxPluginRuntime: TrxPluginRuntime;
// Owned here, not by the lazy map module: decode rows link to the map long
// before it has been loaded.
navigateToAprsMap(lat: number, lon: number): void;
navigateToMapLocator(grid: string, preferredType?: string | null): void;
lastSpectrumData: SpectrumFrame | null;
lastFreqHz: number | null;
currentBandwidthHz: number;
@@ -477,6 +483,8 @@ function hideAuthGate() {
});
navigateToTab(tabFromPath(), { updateHistory: false, replaceHistory: true });
syncTopBarAccess();
// The startup fetch may have run before there was a session to authorise it.
if (!bandplanData) void loadBandplanJson();
}
function showAuthError(msg: string) {
@@ -704,6 +712,7 @@ const loadingSub = requiredElement("loading-sub");
const decodeHistoryOverlayEl = document.getElementById("decode-history-overlay");
const decodeHistoryOverlayTitleEl = document.getElementById("decode-history-overlay-title");
const decodeHistoryOverlaySubEl = document.getElementById("decode-history-overlay-sub");
const decodeHistoryProgressBarEl = document.getElementById("decode-history-progress-bar");
const connLostOverlayEl = document.getElementById("conn-lost-overlay");
const connLostOverlayTitleEl = document.getElementById("conn-lost-overlay-title");
const connLostOverlaySubEl = document.getElementById("conn-lost-overlay-sub");
@@ -872,10 +881,29 @@ function syncTopBarAccess() {
}
let overviewDrawPending = false;
function setDecodeHistoryOverlayVisible(visible: boolean, title = "", sub = "") {
// Progress, in the corner. This used to dim the whole page behind a scrim,
// which hid the waterfall and the decode panels for as long as the replay ran
// — and a replay only happens when there is a backlog worth watching arrive.
// `fraction` null leaves the bar indeterminate, for the part where the payload
// is still on the wire and there is nothing to count yet.
function setDecodeHistoryOverlayVisible(
visible: boolean,
title = "",
sub = "",
fraction: number | null = null,
) {
if (!decodeHistoryOverlayEl) return;
if (title && decodeHistoryOverlayTitleEl) decodeHistoryOverlayTitleEl.textContent = title;
if (decodeHistoryOverlaySubEl) decodeHistoryOverlaySubEl.textContent = sub || "";
if (decodeHistoryProgressBarEl) {
if (fraction == null) {
decodeHistoryOverlayEl.dataset.phase = "fetching";
decodeHistoryProgressBarEl.style.width = "";
} else {
delete decodeHistoryOverlayEl.dataset.phase;
decodeHistoryProgressBarEl.style.width = `${Math.round(Math.max(0, Math.min(1, fraction)) * 100)}%`;
}
}
decodeHistoryOverlayEl.classList.toggle("is-hidden", !visible);
}
@@ -944,6 +972,8 @@ function formatSigStrength(dbm: number | null) {
}
function refreshSigStrengthDisplay() {
// The squelch indicator reads the same meter, so it follows it here.
renderSdrSquelch();
if (!sigStrengthEl) return;
sigStrengthEl.innerHTML = formatSigStrength(sigLastDbm);
}
@@ -1003,6 +1033,7 @@ async function restorePreviousTuneState() {
savePreviousTuneState(); // save current as previous so B toggles back
if (saved.mode && modeEl && modeEl.value !== saved.mode) {
modeEl.value = saved.mode;
syncModePicker();
await postPath(`/set_mode?mode=${encodeURIComponent(saved.mode)}`);
updateWfmControls();
}
@@ -1332,6 +1363,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;
@@ -1377,7 +1424,11 @@ function updateRigSubtitle(activeRigId: string | null) {
updateDocumentTitle(activeChannelRds());
}
function applyRigList(activeRigId: string | null, rigIds: string[], displayNames: Record<string, string> = {}) {
// `displayNames` omitted means "no news", not "no names". The state updates
// carry only the rig ids — /rigs is what knows the names — and this defaulted
// to an empty map, so the first state frame after load wiped the names and the
// picker and header fell back to the lowercase ids for the rest of the session.
function applyRigList(activeRigId: string | null, rigIds: string[], displayNames?: Record<string, string>) {
if (!Array.isArray(rigIds)) return;
const nextIds = rigIds.filter((id) => typeof id === "string" && id.length > 0);
// Detect whether the rig list or active rig actually changed so we can
@@ -1461,10 +1512,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" });
}
}
@@ -2947,6 +2998,7 @@ function setDisabled(disabled: boolean) {
[freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => {
if (el) el.disabled = disabled;
});
syncModePicker();
}
let serverVersion: string | null = null;
@@ -3292,13 +3344,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 = "";
@@ -3336,6 +3388,7 @@ function render(update: AppUpdate) {
opt.textContent = m;
modeEl.appendChild(opt);
});
renderModePicker();
}
}
if (update.info && update.info.capabilities) {
@@ -3481,6 +3534,7 @@ function render(update: AppUpdate) {
// vchan.js will apply the correct mode via vchanSyncModeDisplay().
if (!onVirtual) {
modeEl.value = modeUpper;
syncModePicker();
if (modeUpper === "WFM" && lastModeName !== "WFM") {
setJogDivisor(10);
resetRdsDisplay();
@@ -3623,6 +3677,7 @@ function render(update: AppUpdate) {
const sUnits = dbmToSUnits(update.status.rx.sig);
sigLastSUnits = sUnits;
sigLastDbm = update.status.rx.sig;
recordSquelchMeterSample(update.status.rx.sig);
const pct = sUnits <= 9 ? Math.max(0, Math.min(100, (sUnits / 9) * 100)) : 100;
signalBar.style.width = `${pct}%`;
signalValue.innerHTML = formatSignal(sUnits);
@@ -3649,7 +3704,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 +3815,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 +3902,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 +3929,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 +3940,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();
@@ -4315,6 +4370,39 @@ if (jogMultEl) {
jogStep = Math.max(Math.round(jogUnit / jogMult), minFreqStepHz);
}
// The mode buttons are a view of the <select>, which stays the value everything
// else reads. Rebuilt when the rig's mode list changes, re-synced whenever
// anything writes to the select — including the plugins, via trxCore.
const modePickerEl = requiredElement("mode-picker");
function renderModePicker() {
modePickerEl.replaceChildren();
for (const option of Array.from(modeEl.options)) {
const btn = document.createElement("button");
btn.type = "button";
btn.dataset.mode = option.value;
btn.textContent = option.textContent;
btn.addEventListener("click", () => {
if (btn.disabled || modeEl.value === option.value) return;
modeEl.value = option.value;
syncModePicker();
void applyModeFromPicker();
});
modePickerEl.appendChild(btn);
}
syncModePicker();
}
function syncModePicker() {
const active = (modeEl.value || "").toUpperCase();
modePickerEl.querySelectorAll<HTMLButtonElement>("button").forEach((btn) => {
const selected = (btn.dataset.mode || "").toUpperCase() === active;
btn.classList.toggle("active", selected);
btn.disabled = modeEl.disabled;
btn.setAttribute("aria-pressed", String(selected));
});
}
async function applyModeFromPicker() {
const mode = modeEl.value || "";
if (!mode) {
@@ -4323,6 +4411,7 @@ async function applyModeFromPicker() {
}
updateWfmControls();
setControlPending(modeEl, true);
syncModePicker();
showHint("Setting mode…");
try {
if (await window.trx.modules.vchan?.interceptMode(mode)) {
@@ -4341,6 +4430,7 @@ async function applyModeFromPicker() {
console.error(err);
} finally {
setControlPending(modeEl, false);
syncModePicker();
}
}
@@ -4551,6 +4641,54 @@ function tabFromPath(pathname = window.location.pathname) {
return tabFromPathname(pathname);
}
// Map links fire from decode rows — an AIS position, an APRS frame, an FT8
// grid — that exist long before the Map tab has ever been opened, and the map
// module is lazy. It used to install these two globals itself, so until
// something had opened the tab the AIS links threw ("not a function") and the
// APRS ones silently did nothing. The app owns them instead: it is the only
// place that can materialise the panel, load the module and update history,
// and the target waits here until the module is up.
type PendingMapTarget =
| { kind: "position"; lat: number; lon: number }
| { kind: "locator"; grid: string; preferredType: string | null };
let pendingMapTarget: PendingMapTarget | null = null;
function applyMapTarget(target: PendingMapTarget): boolean {
const map = window.trx.modules.map;
if (!map) return false;
if (target.kind === "position") {
map.focusMapPosition?.(target.lat, target.lon);
} else {
map.focusMapLocator?.(target.grid, target.preferredType);
}
return true;
}
function requestMapTarget(target: PendingMapTarget) {
pendingMapTarget = target;
navigateToTab("map");
// Already loaded: the tab switch has shown the panel, so focus can happen
// now. Otherwise _initMapWhenReady drains it once the module arrives.
if (window.trx.modules.map) {
requestAnimationFrame(() => { drainPendingMapTarget(); });
}
}
function drainPendingMapTarget() {
const target = pendingMapTarget;
if (!target) return;
if (applyMapTarget(target)) pendingMapTarget = null;
}
window.navigateToAprsMap = (lat: number, lon: number) => {
if (!isFiniteNumber(lat) || !isFiniteNumber(lon)) return;
requestMapTarget({ kind: "position", lat, lon });
};
window.navigateToMapLocator = (grid: string, preferredType: string | null = null) => {
if (!grid) return;
requestMapTarget({ kind: "locator", grid, preferredType });
};
// Initialise the Leaflet map, waiting for both Leaflet (L) and map-core.js
// (window.trx.modules.map) if they haven't loaded yet.
let _mapInitTimer: ReturnType<typeof setInterval> | null = null;
@@ -4570,6 +4708,7 @@ function _initMapWhenReady() {
requestAnimationFrame(() => {
map.sizeAprsMapToViewport();
map.aprsMap?.invalidateSize();
drainPendingMapTarget();
});
});
return;
@@ -4602,10 +4741,17 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
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.
// Derived from what is actually hidden rather than from a second copy of the
// grouping, which would drift from the one ui-core installs.
// 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) toolsBtn.classList.toggle("active", getComputedStyle(btn).display === "none");
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}`);
@@ -4872,7 +5018,7 @@ const trxCore = Object.freeze({
formatWavelength, formatBwLabel, formatUptime, formatSigStrength, formatSignal,
postPath, scheduleUiFrameJob, navigateToTab, rigBadgeColor,
setRigFrequency, applyLocalTunedFrequency, armOptimisticFrequency,
syncBandwidthInput, scheduleSpectrumDraw, onDecoderRegistryReady,
syncBandwidthInput, scheduleSpectrumDraw, onDecoderRegistryReady, syncModePicker,
formatFreqForStep, refreshFreqDisplay, setJogDivisor, mwDefaultsForMode,
resetRdsDisplay, positionRdsPsOverlay, updateWfmControls,
updateSdrSquelchControlVisibility, startRxAudio, stopRxAudio,
@@ -5083,14 +5229,18 @@ const wfmCciValEl = document.getElementById("wfm-cci-val");
const wfmAciFillEl = document.getElementById("wfm-aci-fill");
const wfmAciValEl = document.getElementById("wfm-aci-val");
const samControlsCol = document.getElementById("sam-controls-col");
const modeControlsRow = document.getElementById("mode-controls-row");
const samStereoWidthEl = document.getElementById("sam-stereo-width") as HTMLInputElement | null;
const samCarrierSyncEl = document.getElementById("sam-carrier-sync") as HTMLSelectElement | null;
const sdrSquelchWrapEl = document.getElementById("sdr-squelch-wrap");
const sdrSquelchEl = document.getElementById("sdr-squelch") as HTMLInputElement | null;
const sdrSquelchPctEl = document.getElementById("sdr-squelch-pct");
const sdrSquelchDbEl = document.getElementById("sdr-squelch-db") as HTMLInputElement | null;
const sdrSquelchStateEl = document.getElementById("sdr-squelch-state");
const sdrSquelchToggleBtn = document.getElementById("sdr-squelch-toggle") as HTMLButtonElement | null;
const squelchLineEl = document.getElementById("spectrum-squelch-line");
const squelchGripEl = document.getElementById("spectrum-squelch-grip");
const squelchLabelEl = document.getElementById("spectrum-squelch-label");
const SDR_SQUELCH_MIN_DB = -120;
const SDR_SQUELCH_MAX_DB = -30;
let syncFromServerSdrSquelch = false;
const sdrNbWrapEl = document.getElementById("sdr-nb-wrap");
const sdrNbEnabledEl = document.getElementById("sdr-nb-enabled") as HTMLInputElement | null;
const sdrNbThresholdControlsEl = document.getElementById("sdr-nb-threshold-controls");
@@ -5190,97 +5340,248 @@ function normalizeWfmDenoiseLevel(value: unknown) {
return "auto";
}
function clampSdrSquelchPercent(value: number) {
if (!isFiniteNumber(value)) return 0;
return Math.max(0, Math.min(100, Math.round(value)));
// The threshold is held in dB, the scale the spectrum axis and the S-meter are
// labelled in and the one the server compares against. It used to be a
// percentage over that range, which left the operator no way to relate the
// control to anything on screen — and 0% doubled as "disabled", so turning the
// squelch off to listen threw the setting away.
let sdrSquelchEnabled: boolean = loadSetting("sdrSquelchEnabled", false);
let sdrSquelchThresholdDb = clampSdrSquelchDb(Number(loadSetting("sdrSquelchThresholdDb", -95)));
function clampSdrSquelchDb(value: number) {
if (!isFiniteNumber(value)) return SDR_SQUELCH_MIN_DB;
return Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, Math.round(value)));
}
function sdrSquelchPercentToServer(percent: number) {
const pct = clampSdrSquelchPercent(percent);
if (pct <= 0) {
return { enabled: false, thresholdDb: SDR_SQUELCH_MIN_DB };
}
const ratio = pct / 100;
const thresholdDb = SDR_SQUELCH_MIN_DB + ratio * (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB);
return { enabled: true, thresholdDb };
/** Passing right now, as far as the meter can tell: the same comparison the
* DSP makes, against the same number it reports. */
function sdrSquelchIsPassing() {
if (!sdrSquelchEnabled) return true;
if (!isFiniteNumber(sigLastDbm)) return false;
return sigLastDbm >= sdrSquelchThresholdDb;
}
function sdrSquelchServerToPercent(enabled: boolean, thresholdDb: number | null) {
if (!enabled) return 0;
if (!isFiniteNumber(thresholdDb)) return 0;
const ratio = (thresholdDb - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB);
return clampSdrSquelchPercent(ratio * 100);
function renderSdrSquelch() {
if (sdrSquelchDbEl && document.activeElement !== sdrSquelchDbEl) {
sdrSquelchDbEl.value = String(sdrSquelchThresholdDb);
}
if (sdrSquelchDbEl) sdrSquelchDbEl.disabled = !sdrSquelchSupported;
if (sdrSquelchToggleBtn) {
sdrSquelchToggleBtn.setAttribute("aria-pressed", String(sdrSquelchEnabled));
sdrSquelchToggleBtn.title = sdrSquelchEnabled ? "Turn the squelch off" : "Turn the squelch on";
}
const state = !sdrSquelchEnabled ? "off" : (sdrSquelchIsPassing() ? "open" : "closed");
if (sdrSquelchStateEl) {
sdrSquelchStateEl.dataset.state = state;
sdrSquelchStateEl.parentElement?.setAttribute(
"aria-label",
state === "off" ? "Squelch off" : (state === "open" ? "Squelch on, open" : "Squelch on, closed"),
);
}
if (squelchLabelEl) squelchLabelEl.textContent = String(sdrSquelchThresholdDb);
if (squelchGripEl) squelchGripEl.setAttribute("aria-valuenow", String(sdrSquelchThresholdDb));
if (squelchLineEl) squelchLineEl.dataset.state = state === "open" ? "open" : "closed";
positionSquelchLine();
}
function updateSdrSquelchPctLabel() {
if (!sdrSquelchEl || !sdrSquelchPctEl) return;
const pct = clampSdrSquelchPercent(Number(sdrSquelchEl.value));
sdrSquelchPctEl.textContent = pct <= 0 ? "Open" : `${pct}%`;
/** Places the line at its threshold on the spectrum's dB axis. */
function positionSquelchLine() {
if (!squelchLineEl) return;
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
const canvas = document.getElementById("spectrum-canvas");
const visible = sdrSquelchSupported && sdrSquelchEnabled && mode !== "WFM"
&& !!canvas && canvas.clientHeight > 0
&& getComputedStyle(requiredElement("spectrum-panel")).display !== "none";
if (!visible) {
squelchLineEl.style.display = "none";
return;
}
const dbMin = spectrumFloor;
const dbMax = spectrumFloor + spectrumRange;
// Pinned to the plot edge when the threshold sits outside the visible dB
// window rather than hidden: the grip is how it gets dragged back, and the
// label still reads the real value.
const frac = Math.max(0, Math.min(1, (sdrSquelchThresholdDb - dbMin) / Math.max(1, dbMax - dbMin)));
squelchLineEl.style.display = "";
squelchLineEl.style.top = `${canvas.offsetTop + canvas.clientHeight * (1 - frac)}px`;
}
function submitSdrSquelch() {
if (!sdrSquelchSupported) return;
sdrSquelchLocalAt = Date.now();
saveSetting("sdrSquelchEnabled", sdrSquelchEnabled);
saveSetting("sdrSquelchThresholdDb", sdrSquelchThresholdDb);
postPath(
`/set_sdr_squelch?enabled=${sdrSquelchEnabled ? "true" : "false"}`
+ `&threshold_db=${encodeURIComponent(sdrSquelchThresholdDb.toFixed(2))}`,
).catch(() => {});
}
function setSdrSquelch(thresholdDb: number, enabled: boolean, options: { submit?: boolean } = {}) {
sdrSquelchThresholdDb = clampSdrSquelchDb(thresholdDb);
sdrSquelchEnabled = enabled;
renderSdrSquelch();
if (options.submit !== false) submitSdrSquelch();
}
// Auto reads the meter, not the spectrum. The threshold is compared against
// the channel level the meter reports, and that sits a long way from the
// spectrum's per-bin noise floor — the gap is set by the FFT size and window,
// the channel bandwidth, the decimation and peak-versus-mean statistics.
// Measured across ordinary configurations it ranges from -1 dB to +22 dB, so
// the old "noise floor + 6 dB" left the gate 16-22 dB below the noise on a
// narrow span and it simply never closed. Reading the same number the DSP
// compares needs no conversion at all.
// How long a local squelch change outranks the server's echo of the old one.
const SDR_SQUELCH_HOLD_MS = 2_000;
let sdrSquelchLocalAt = 0;
const SQUELCH_NOISE_WINDOW_MS = 10_000;
const SQUELCH_NOISE_MARGIN_DB = 5;
const SQUELCH_MEASURE_MS = 1_500;
const squelchMeterSamples: SignalSample[] = [];
function recordSquelchMeterSample(db: number) {
if (!isFiniteNumber(db)) return;
const now = Date.now();
squelchMeterSamples.push({ t: now, v: db });
while (squelchMeterSamples[0] && now - squelchMeterSamples[0].t > SQUELCH_NOISE_WINDOW_MS) {
squelchMeterSamples.shift();
}
}
/** The level the channel rests at, taken low enough down the distribution that
* a burst of traffic inside the window cannot drag it up. */
function squelchNoiseFloorDb(): number | null {
const now = Date.now();
const values = squelchMeterSamples
.filter((sample) => now - sample.t <= SQUELCH_NOISE_WINDOW_MS)
.map((sample) => sample.v)
.sort((a, b) => a - b);
if (values.length < 4) return null;
return values[Math.floor((values.length - 1) * 0.2)] ?? null;
}
/** Just clear of the noise the meter is actually reading. */
function autoSquelchThresholdDb(): number | null {
const noiseDb = squelchNoiseFloorDb();
if (noiseDb == null) return null;
return clampSdrSquelchDb(noiseDb + SQUELCH_NOISE_MARGIN_DB);
}
function updateSdrSquelchControlVisibility() {
if (!sdrSquelchWrapEl) return;
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
sdrSquelchWrapEl.style.display = sdrSquelchSupported && mode !== "WFM" ? "" : "none";
renderSdrSquelch();
}
function syncSdrSquelchFromServer(enabled: boolean, thresholdDb: number | null) {
if (!sdrSquelchEl) return;
if (document.activeElement === sdrSquelchEl) return;
const pct = sdrSquelchServerToPercent(enabled, thresholdDb);
syncFromServerSdrSquelch = true;
sdrSquelchEl.value = String(pct);
updateSdrSquelchPctLabel();
syncFromServerSdrSquelch = false;
saveSetting("sdrSquelchPct", pct);
// Not while the operator is on the control: dragging the line or typing a
// level would fight the echo of the value the server last confirmed.
if (squelchDragPointerId !== null) return;
if (sdrSquelchDbEl && document.activeElement === sdrSquelchDbEl) return;
// Nor just after letting go: state frames are in flight continuously, and
// one sent before the new threshold was applied would snap the line back to
// where it was dragged from.
if (Date.now() - sdrSquelchLocalAt < SDR_SQUELCH_HOLD_MS) return;
sdrSquelchEnabled = enabled;
if (isFiniteNumber(thresholdDb)) sdrSquelchThresholdDb = clampSdrSquelchDb(thresholdDb);
saveSetting("sdrSquelchEnabled", sdrSquelchEnabled);
saveSetting("sdrSquelchThresholdDb", sdrSquelchThresholdDb);
renderSdrSquelch();
}
function submitSdrSquelchPercent(percent: number) {
if (!sdrSquelchSupported) return;
const { enabled, thresholdDb } = sdrSquelchPercentToServer(percent);
postPath(
`/set_sdr_squelch?enabled=${enabled ? "true" : "false"}&threshold_db=${encodeURIComponent(thresholdDb.toFixed(2))}`,
).catch(() => {});
}
let squelchDragPointerId: number | null = null;
let squelchDragSubmitAt = 0;
if (sdrSquelchEl) {
const savedPct = clampSdrSquelchPercent(Number(loadSetting("sdrSquelchPct", 0)));
sdrSquelchEl.value = String(savedPct);
updateSdrSquelchPctLabel();
sdrSquelchEl.addEventListener("input", () => {
const pct = clampSdrSquelchPercent(Number(sdrSquelchEl.value));
sdrSquelchEl.value = String(pct);
updateSdrSquelchPctLabel();
saveSetting("sdrSquelchPct", pct);
if (!syncFromServerSdrSquelch) {
submitSdrSquelchPercent(pct);
}
if (sdrSquelchDbEl) {
sdrSquelchDbEl.addEventListener("change", () => {
setSdrSquelch(Number(sdrSquelchDbEl.value), sdrSquelchEnabled);
});
sdrSquelchDbEl.addEventListener("blur", () => { renderSdrSquelch(); });
}
if (sdrSquelchToggleBtn) {
sdrSquelchToggleBtn.addEventListener("click", () => {
if (!sdrSquelchSupported) return;
setSdrSquelch(sdrSquelchThresholdDb, !sdrSquelchEnabled);
});
}
function applyAutoSquelch(threshold: number) {
setSdrSquelch(threshold, true);
showHint(`Squelch ${threshold} dB`, 1500);
}
const sdrSquelchAutoBtn = document.getElementById("sdr-squelch-auto") as HTMLButtonElement | null;
if (sdrSquelchAutoBtn) {
sdrSquelchAutoBtn.addEventListener("click", () => {
if (!sdrSquelchSupported) return;
let pct = 0; // default: Off
const data = lastSpectrumData || window.lastSpectrumData;
if (data && isBinsArray(data.bins) && data.bins.length > 0) {
const noiseDb = estimateNoiseFloorDb(data.bins);
if (noiseDb != null && isFiniteNumber(noiseDb)) {
// Set threshold slightly above noise floor so squelch closes on noise
const thresholdDb = noiseDb + 6;
const clamped = Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, thresholdDb));
pct = clampSdrSquelchPercent(
((clamped - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB)) * 100,
);
const threshold = autoSquelchThresholdDb();
if (threshold != null) {
applyAutoSquelch(threshold);
return;
}
// Nothing recorded yet — right after a connection or a rig switch. Listen
// for a moment rather than refusing, which is what a radio does.
sdrSquelchAutoBtn.disabled = true;
showHint("Measuring the noise…");
setTimeout(() => {
sdrSquelchAutoBtn.disabled = false;
const measured = autoSquelchThresholdDb();
if (measured == null) {
showHint("No meter to measure the noise from", 1800);
return;
}
if (sdrSquelchEl) {
sdrSquelchEl.value = String(pct);
updateSdrSquelchPctLabel();
saveSetting("sdrSquelchPct", pct);
applyAutoSquelch(measured);
}, SQUELCH_MEASURE_MS);
});
}
submitSdrSquelchPercent(pct);
// Dragging the line, the same gesture as the bandwidth edges. Submits are
// throttled so the gate follows the drag by ear without a request per pixel.
if (squelchGripEl) {
const dbFromClientY = (clientY: number) => {
const canvas = document.getElementById("spectrum-canvas");
if (!canvas || !canvas.clientHeight) return sdrSquelchThresholdDb;
const rect = canvas.getBoundingClientRect();
const frac = 1 - (clientY - rect.top) / rect.height;
return clampSdrSquelchDb(spectrumFloor + frac * spectrumRange);
};
squelchGripEl.addEventListener("pointerdown", (event) => {
if (!sdrSquelchSupported) return;
squelchDragPointerId = event.pointerId;
squelchGripEl.setPointerCapture(event.pointerId);
event.preventDefault();
event.stopPropagation();
});
squelchGripEl.addEventListener("pointermove", (event) => {
if (squelchDragPointerId !== event.pointerId) return;
sdrSquelchThresholdDb = dbFromClientY(event.clientY);
renderSdrSquelch();
const now = Date.now();
if (now - squelchDragSubmitAt > 200) {
squelchDragSubmitAt = now;
submitSdrSquelch();
}
});
const endDrag = (event: PointerEvent) => {
if (squelchDragPointerId !== event.pointerId) return;
squelchDragPointerId = null;
submitSdrSquelch();
};
squelchGripEl.addEventListener("pointerup", endDrag);
squelchGripEl.addEventListener("pointercancel", endDrag);
squelchGripEl.addEventListener("keydown", (event) => {
const step = event.shiftKey ? 10 : 1;
if (event.key === "ArrowUp" || event.key === "ArrowRight") {
setSdrSquelch(sdrSquelchThresholdDb + step, sdrSquelchEnabled);
} else if (event.key === "ArrowDown" || event.key === "ArrowLeft") {
setSdrSquelch(sdrSquelchThresholdDb - step, sdrSquelchEnabled);
} else {
return;
}
event.preventDefault();
});
}
@@ -5401,6 +5702,9 @@ function updateWfmControls() {
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
if (wfmControlsCol) wfmControlsCol.style.display = mode === "WFM" ? "" : "none";
if (samControlsCol) samControlsCol.style.display = mode === "SAM" ? "" : "none";
// The row holds only these two, so it goes with them — an empty one would
// still take a track and a gap in the tray, and draw its divider.
if (modeControlsRow) modeControlsRow.style.display = (mode === "WFM" || mode === "SAM") ? "" : "none";
}
// Show compatibility warning for non-Chromium browsers
@@ -6108,15 +6412,10 @@ function volWheel(slider: HTMLInputElement, pctEl: HTMLElement, getGain: () => G
}
volWheel(rxVolSlider, rxVolPct, () => rxGainNode, "rxVol");
volWheel(txVolSlider, txVolPct, () => txGainNode, "txVol");
if (sdrSquelchEl) {
sdrSquelchEl.addEventListener("wheel", (e) => {
e.preventDefault();
const step = e.deltaY < 0 ? 2 : -2;
const next = clampSdrSquelchPercent(Number(sdrSquelchEl.value) + step);
sdrSquelchEl.value = String(next);
updateSdrSquelchPctLabel();
saveSetting("sdrSquelchPct", next);
submitSdrSquelchPercent(next);
if (sdrSquelchDbEl) {
sdrSquelchDbEl.addEventListener("wheel", (event) => {
event.preventDefault();
setSdrSquelch(sdrSquelchThresholdDb + (event.deltaY < 0 ? 1 : -1), sdrSquelchEnabled);
}, { passive: false });
}
@@ -6227,36 +6526,44 @@ function connectDecode() {
let historySettled = false;
let historyWorkerDone = false;
let historyFallbackStarted = false;
let historyRetried = false;
let historyBatchDrainScheduled = false;
let historyTotal = 0;
let historyProcessed = 0;
const historyGroupQueue: DecodeHistoryGroup[] = [];
const liveBuffer: DecodeMessage[] = [];
function flushLiveBuffer() {
// Live decodes wait behind the history so the panels stay in order. Letting
// them through is not the same as being finished, and conflating the two is
// what made a slow history disappear: the safety valve released the buffer
// and tore the worker down with it, so whatever had not arrived never did.
function releaseLiveBuffer() {
if (historySettled) return;
historySettled = true;
terminateDecodeHistoryWorker();
setDecodeHistoryReplayActive(false);
setDecodeHistoryOverlayVisible(false);
for (const msg of liveBuffer) {
try { dispatchDecodeMessage(msg); } catch (_) {}
}
liveBuffer.length = 0;
}
function finishHistoryReplay() {
clearTimeout(historyTimeout);
releaseLiveBuffer();
terminateDecodeHistoryWorker();
setDecodeHistoryReplayActive(false);
setDecodeHistoryOverlayVisible(false);
}
function updateHistoryReplayOverlay() {
setDecodeHistoryOverlayVisible(
true,
"Loading decode history…",
`Replaying ${historyProcessed} / ${historyTotal} decoded messages`
`Replaying ${historyProcessed} / ${historyTotal} decoded messages`,
historyTotal > 0 ? historyProcessed / historyTotal : null,
);
}
function maybeFinishHistoryReplay() {
if (historySettled) return;
if (historyWorkerDone && historyGroupQueue.length === 0) {
clearTimeout(historyTimeout);
flushLiveBuffer();
}
if (historyWorkerDone && historyGroupQueue.length === 0) finishHistoryReplay();
}
function pumpDecodeHistoryGroupQueue() {
@@ -6318,17 +6625,26 @@ function connectDecode() {
if (historyFallbackStarted || historySettled) return;
historyFallbackStarted = true;
loadDecodeHistoryOnMainThread((groups) => {
clearTimeout(historyTimeout);
const total = totalDecodeHistoryMessages(groups);
if (total > 0) {
enqueueDecodeHistoryGroups(groups);
} else {
flushLiveBuffer();
finishHistoryReplay();
}
}, (err: unknown) => {
console.error("Decode history fallback failed", err);
clearTimeout(historyTimeout);
flushLiveBuffer();
// One retry, then say so. Failing silently here is why the history
// sometimes only turned up on a second reload: nothing asked again and
// nothing said anything was missing.
if (historyRetried) {
showHint("Decode history unavailable", 3000);
finishHistoryReplay();
return;
}
historyRetried = true;
historyFallbackStarted = false;
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Retrying");
setTimeout(() => { startDecodeHistoryFallback(); }, 2000);
});
}
@@ -6366,7 +6682,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;
@@ -6392,12 +6713,13 @@ function connectDecode() {
return true;
}
// Safety valve: if the history fetch hangs, unblock after 20 s.
// Safety valve: after 20 s, stop holding live decodes back — but keep
// loading. The history is what the operator is waiting for, and dropping it
// on the floor at the timeout is not something they can even see happen.
const historyTimeout = setTimeout(() => {
if (!historySettled) {
terminateDecodeHistoryWorker();
flushLiveBuffer();
}
if (historySettled) return;
releaseLiveBuffer();
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Still loading — live decodes are showing");
}, 20000);
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer");
@@ -6420,7 +6742,7 @@ function connectDecode() {
const wasClosed = source.readyState === 2;
source.close();
terminateDecodeHistoryWorker();
if (!historySettled) flushLiveBuffer();
if (!historySettled) releaseLiveBuffer();
if (wasClosed) {
updateDecodeStatus("Decode not available (check client audio config)");
setTimeout(connectDecode, 10000);
@@ -6979,6 +7301,7 @@ function flushMeterDom() {
const sUnits = dbmToSUnits(dbm);
sigLastSUnits = sUnits;
sigLastDbm = dbm;
recordSquelchMeterSample(dbm);
const pct = sUnits <= 9 ? Math.max(0, Math.min(100, (sUnits / 9) * 100)) : 100;
if (signalBar) signalBar.style.width = `${pct}%`;
if (signalValue) signalValue.innerHTML = formatSignal(sUnits);
@@ -7325,6 +7648,9 @@ function drawSpectrum(data: SpectrumFrame) {
}
spectrumGl.drawSegments(spectrumTmpGridSegments, cssColorToRgba(pal.spectrumGrid), 1);
updateSpectrumDbAxis(DB_MIN, DB_MAX, gridStep, H, dpr);
// The squelch line rides the same axis: reposition it whenever the axis or
// the plot geometry moves under it.
positionSquelchLine();
function hzToX(hz: number) {
return ((hz - range.visLoHz) / range.visSpanHz) * W;
@@ -7733,7 +8059,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 = ""; }
@@ -8047,35 +8378,21 @@ window.addEventListener("keydown", (event) => {
return;
}
// Q — toggle squelch (cycle 0 → auto → 0)
// Q — gate on or off, keeping the threshold. Picks one off the noise floor
// the first time, when there is nothing to keep.
if (key === "q") {
event.preventDefault();
if (sdrSquelchSupported && sdrSquelchEl) {
const current = clampSdrSquelchPercent(Number(sdrSquelchEl.value));
let nextPct;
if (current > 0) {
nextPct = 0; // turn off
if (sdrSquelchSupported) {
if (sdrSquelchEnabled) {
setSdrSquelch(sdrSquelchThresholdDb, false);
showHint("Squelch off", 1200);
} else {
// Auto: estimate from noise floor
let auto = 30;
const data = lastSpectrumData || window.lastSpectrumData;
if (data && isBinsArray(data.bins) && data.bins.length > 0) {
const noiseDb = estimateNoiseFloorDb(data.bins);
if (noiseDb != null && isFiniteNumber(noiseDb)) {
const thresholdDb = noiseDb + 6;
const clamped = Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, thresholdDb));
auto = clampSdrSquelchPercent(
((clamped - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB)) * 100,
);
const threshold = sdrSquelchThresholdDb > SDR_SQUELCH_MIN_DB
? sdrSquelchThresholdDb
: (autoSquelchThresholdDb() ?? SDR_SQUELCH_MIN_DB + 25);
setSdrSquelch(threshold, true);
showHint(`Squelch ${clampSdrSquelchDb(threshold)} dB`, 1200);
}
}
nextPct = auto;
}
sdrSquelchEl.value = String(nextPct);
updateSdrSquelchPctLabel();
saveSetting("sdrSquelchPct", nextPct);
submitSdrSquelchPercent(nextPct);
showHint(nextPct > 0 ? `Squelch ${nextPct}%` : "Squelch Off", 1200);
} else {
showHint("Squelch N/A", 1200);
}
@@ -8527,8 +8844,13 @@ const bandplanStripEl = document.getElementById("spectrum-bandplan-strip");
const bandplanRegionSelect = document.getElementById("bandplan-region-select") as HTMLSelectElement | null;
const bandplanLabelsCheck = document.getElementById("bandplan-labels-check") as HTMLInputElement | null;
(function loadBandplanJson() {
fetch("/bandplan.json")
// Fired at startup, and again once the session exists. The first attempt can
// land before the user is authenticated, and it used to fail silently and
// never retry, which is why the band plan sometimes only appeared after a
// manual reload. Redraws on arrival: the strip is painted from the spectrum
// draw, and a rig sitting between frames would otherwise stay blank.
function loadBandplanJson(): Promise<void> {
return fetch("/bandplan.json")
.then(async (response) => {
if (!response.ok) throw new Error(String(response.status));
return await responseJsonUnknown(response);
@@ -8538,9 +8860,13 @@ const bandplanLabelsCheck = document.getElementById("bandplan-labels-check") as
bandplanData = data as BandplanData;
bandplanSegmentsCache = null;
bandplanCacheKey = "";
if (lastSpectrumData) scheduleSpectrumDraw();
})
.catch(() => {});
})();
.catch((err) => {
console.warn("Band plan unavailable", err);
});
}
void loadBandplanJson();
if (bandplanRegionSelect) {
bandplanRegionSelect.value = bandplanRegion;
@@ -8612,27 +8938,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");
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,8 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import type * as Leaflet from "leaflet";
import { aprsSymbolSprite } from "./plugins/aprs-shared";
import type { PluginRuntimeWindow } from "./plugins/runtime-contract";
export {};
@@ -228,6 +230,8 @@ const mapWindow = window as unknown as MapWindow;
const mapMarkers = new Set<TrxLayer>();
const DEFAULT_MAP_SOURCE_FILTER: Record<MapFilterKey, boolean> = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
const mapFilter: Record<MapFilterKey, boolean> = { ...DEFAULT_MAP_SOURCE_FILTER };
/** Chip key that clears a selection rather than naming a band or a source. */
const MAP_FILTER_ALL_KEY = "__all";
const mapLocatorFilter: { phase: "band" | "type"; bands: Set<string> } = { phase: "band", bands: new Set() };
let mapSearchFilter = "";
let mapRigFilter = ""; // "" = all rigs
@@ -1077,38 +1081,42 @@ const mapWindow = window as unknown as MapWindow;
container.innerHTML = `<span class="map-locator-empty">No ${kind === "band" ? "bands" : "sources"} available</span>`;
return;
}
let helperText = "";
const noun = kind === "band" ? "bands" : "sources";
const sourceKeys = kind === "source" ? Object.keys(DEFAULT_MAP_SOURCE_FILTER) as MapFilterKey[] : [];
const noneSelected = kind === "source" && sourceKeys.every((k) => !mapFilter[k]);
if (kind === "source") {
if (noneSelected) {
helperText = "All sources visible \u2014 click to filter";
}
} else if (!(selectedSet instanceof Set) || selectedSet.size === 0) {
helperText = `All ${kind === "band" ? "bands" : "sources"} visible by default`;
}
// Selecting nothing selects everything, for both kinds.
const showingAll = kind === "source"
? sourceKeys.every((k) => !mapFilter[k])
: !(selectedSet instanceof Set) || selectedSet.size === 0;
// An "All" chip carries what a sentence of helper text used to say, in a
// width the bar can afford, and gives the selection somewhere to be undone.
const allChip = document.createElement("button");
allChip.type = "button";
allChip.className = "map-locator-chip map-locator-chip-all";
if (showingAll) allChip.classList.add("is-active");
allChip.dataset.filterKind = kind;
allChip.dataset.filterKey = MAP_FILTER_ALL_KEY;
allChip.setAttribute("aria-pressed", showingAll ? "true" : "false");
allChip.title = showingAll ? `All ${noun} shown` : `Show all ${noun}`;
allChip.innerHTML = `<span class="map-locator-chip-text">All</span>`;
container.appendChild(allChip);
for (const item of items) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "map-locator-chip";
const isActive = kind === "source" ? !!mapFilter[item.key as MapFilterKey] : !!selectedSet?.has(item.key);
if (kind === "source" && noneSelected) {
// Nothing is filtered out yet, so no chip is dimmed as if it were.
if (showingAll) {
btn.classList.add("is-default");
} else if (!isActive) {
btn.classList.add("is-inactive");
}
btn.setAttribute("aria-pressed", !showingAll && isActive ? "true" : "false");
btn.dataset.filterKind = kind;
btn.dataset.filterKey = item.key;
btn.style.setProperty("--chip-color", item.color);
btn.innerHTML = `<span class="map-locator-chip-text">${escapeMapHtml(item.label)}</span>`;
container.appendChild(btn);
}
if (helperText) {
const hint = document.createElement("span");
hint.className = "map-locator-empty";
hint.textContent = helperText;
container.appendChild(hint);
}
}
function renderMapLocatorPhaseRow(container: HTMLElement, phase: "band" | "type"): void {
@@ -1232,11 +1240,12 @@ const mapWindow = window as unknown as MapWindow;
if (!phaseEl || !choiceEl || !choiceLabelEl) return;
renderMapLocatorPhaseRow(phaseEl, mapLocatorFilter.phase);
// The phase buttons next door already name the dimension; "Show" is the
// rest of the sentence, and it keeps the bar's labels a uniform width.
choiceLabelEl.textContent = "Show";
if (mapLocatorFilter.phase === "band") {
choiceLabelEl.textContent = "Visible Bands";
renderMapLocatorChipRow(choiceEl, bandItems, mapLocatorFilter.bands, "band");
} else {
choiceLabelEl.textContent = "Visible Sources";
renderMapLocatorChipRow(choiceEl, sourceItems, null, "source");
}
syncLocatorMarkerStyles();
@@ -1635,7 +1644,10 @@ const mapWindow = window as unknown as MapWindow;
function applyMapOverlayPanelVisibility() {
const panel = document.querySelector("#map-stage .map-overlay-panel");
if (!panel) return;
panel.classList.toggle("is-hidden", !mapOverlayPanelVisible);
// Only the filters collapse. The bar itself stays, because it carries the
// button that brings them back.
panel.classList.toggle("filters-hidden", !mapOverlayPanelVisible);
panel.querySelector(".map-overlay-filters")?.classList.toggle("is-hidden", !mapOverlayPanelVisible);
}
function updateMapOverlayToggleButton() {
@@ -1858,7 +1870,14 @@ const mapWindow = window as unknown as MapWindow;
const kind = String(chip.dataset.filterKind || "");
const key = String(chip.dataset.filterKey || "");
if (!key) return;
if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) {
if (key === MAP_FILTER_ALL_KEY) {
// Back to no selection at all, which is what shows everything.
if (kind === "source") {
for (const srcKey of Object.keys(DEFAULT_MAP_SOURCE_FILTER) as MapFilterKey[]) mapFilter[srcKey] = false;
} else {
mapLocatorFilter.bands.clear();
}
} else if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) {
// toggle the clicked source; when none are selected everything is shown
const sourceKey = key as MapFilterKey;
mapFilter[sourceKey] = !mapFilter[sourceKey];
@@ -1979,48 +1998,43 @@ 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]
});
}
mapWindow.navigateToAprsMap = function(lat, lon) {
// Activate the map tab
T._activeTab = "map";
document.querySelectorAll(".tab-bar .tab").forEach((t) => { t.classList.remove("active"); });
const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']");
if (mapTabBtn) mapTabBtn.classList.add("active");
document.querySelectorAll<HTMLElement>(".tab-panel").forEach((p) => (p.style.display = "none"));
const mapPanel = mapEl("tab-map");
if (mapPanel) mapPanel.style.display = "";
function focusMapPosition(lat: number, lon: number) {
initAprsMap();
sizeAprsMapToViewport();
if (aprsMap) {
@@ -2032,20 +2046,12 @@ const mapWindow = window as unknown as MapWindow;
});
});
}
};
}
mapWindow.navigateToMapLocator = function(grid, preferredType = null) {
function focusMapLocator(grid: string, preferredType: string | null = null) {
const normalizedGrid = String(grid || "").trim().toUpperCase();
if (!/^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalizedGrid)) return false;
T._activeTab = "map";
document.querySelectorAll(".tab-bar .tab").forEach((t) => { t.classList.remove("active"); });
const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']");
if (mapTabBtn) mapTabBtn.classList.add("active");
document.querySelectorAll<HTMLElement>(".tab-panel").forEach((p) => (p.style.display = "none"));
const mapPanel = mapEl("tab-map");
if (mapPanel) mapPanel.style.display = "";
initAprsMap();
sizeAprsMapToViewport();
if (!aprsMap) return false;
@@ -2095,7 +2101,7 @@ const mapWindow = window as unknown as MapWindow;
requestAnimationFrame(focusMarker);
});
return true;
};
}
@@ -2592,18 +2598,26 @@ const mapWindow = window as unknown as MapWindow;
syncDecodeContactPathVisibility();
}
// The buttons light up when they are on, so the label need not repeat it —
// an "On"/"Off" suffix on each cost the bar most of a row.
function updateMapContactPathsToggle() {
const btn = mapEl("map-contact-paths-toggle");
if (!btn) return;
btn.textContent = mapDecodeContactPathsEnabled ? "Contact Paths On" : "Contact Paths Off";
btn.classList.toggle("is-active", mapDecodeContactPathsEnabled);
btn.setAttribute("aria-pressed", mapDecodeContactPathsEnabled ? "true" : "false");
btn.title = mapDecodeContactPathsEnabled
? "Directed decode paths are drawn when the target locator is known"
: "Directed decode paths are hidden";
}
function updateMapP2pPathsToggle() {
const btn = mapEl("map-p2p-paths-toggle");
if (!btn) return;
btn.textContent = mapP2pRadioPathsEnabled ? "TRX Paths On" : "TRX Paths Off";
btn.classList.toggle("is-active", mapP2pRadioPathsEnabled);
btn.setAttribute("aria-pressed", mapP2pRadioPathsEnabled ? "true" : "false");
btn.title = mapP2pRadioPathsEnabled
? "TRX paths are drawn from a station popup"
: "TRX paths are hidden";
}
function scheduleDecodeMapMaintenance() {
@@ -2806,7 +2820,7 @@ const mapWindow = window as unknown as MapWindow;
selectedMapQsoKey = selectedMapQsoKey === entry.pathKey ? null : entry.pathKey ?? null;
syncDecodeContactPathVisibility();
if (selectedMapQsoKey && entry.sourceGrid) {
mapWindow.navigateToMapLocator?.(entry.sourceGrid, entry.sourceType);
focusMapLocator(entry.sourceGrid, entry.sourceType);
}
});
@@ -2933,7 +2947,7 @@ const mapWindow = window as unknown as MapWindow;
card.className = "map-qso-card";
if (entry.grid) {
card.addEventListener("click", () => {
mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType);
focusMapLocator(entry.grid ?? "", entry.sourceType);
});
}
@@ -3059,7 +3073,7 @@ const mapWindow = window as unknown as MapWindow;
card.className = "map-qso-card";
if (entry.grid) {
card.addEventListener("click", () => {
mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType);
focusMapLocator(entry.grid ?? "", entry.sourceType);
});
}
@@ -3623,6 +3637,8 @@ const mapWindow = window as unknown as MapWindow;
// Register module API for core to call
modules.map = {
initAprsMap,
focusMapPosition,
focusMapLocator,
sizeAprsMapToViewport,
syncAprsReceiverMarker,
updateMapRigFilter,
@@ -3672,6 +3688,18 @@ const mapWindow = window as unknown as MapWindow;
reverseGeocodeLocation,
};
// Everything the decoders already hold goes onto the map now. This module is
// lazy -- it arrives when the Map tab is first opened, long after startup
// restored the decode history -- and until it does, aprsMapAddStation and
// friends are undefined, so every restored position was dropped on the floor.
// The map then showed only what arrived live after it loaded, which is why it
// took a second reload (with the module cached, and so loaded early enough to
// win the race against the history fetch) for the stations to appear.
//
// The add functions are keyed by callsign/MMSI/point, so replaying costs
// nothing on a second call and cannot duplicate a marker.
(window as unknown as PluginRuntimeWindow).trxPluginRuntime.syncMapAll();
// If the map tab is already visible (direct /map URL), init immediately.
autoInitIfVisible();
})();
@@ -5,7 +5,14 @@
type PluginGroup = "digital-modes" | "map-data" | "map" | "statistics" | "bookmarks" | "recorder" | "settings";
const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
"digital-modes": ["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js", "/sat.js", "/wefax.js"],
// AIS, VDES and the two APRS decoders have panels on this tab, so they load
// with it. They used to come only with the map group, which left their
// sub-tabs empty — decodes queueing in the runtime — until something opened
// the Map tab. Their map calls are optional, so map-core stays lazy.
"digital-modes": [
"/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js",
"/sat.js", "/wefax.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js",
],
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
statistics: ["/map-core.js"],
@@ -81,6 +81,7 @@ const runtime: TrxPluginRuntime = {
plugin.prune();
return true;
},
syncMapAll() { for (const plugin of decoders.values()) plugin.syncMap?.(); },
clearQueued() { queued.clear(); },
hasDecoder: (id) => decoders.has(id),
};
@@ -27,6 +27,7 @@ interface AisMessage {
}
interface AisChannelInfo { label: string; badgeClass: string; freqText: string }
interface AisBridge {
navigateToAprsMap?: (lat: number, lon: number) => void;
getDecodeHistoryRetentionMs?: () => number;
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
buildAisVesselUrl?: (mmsi: number | null | undefined) => string | null;
@@ -212,8 +213,21 @@ function updateAisSummary() {
}
}
/** What the message says, in one line: where the vessel is and what it is
* doing, or for the static reports that carry no fix where it is going. */
function aisSummaryText(msg: AisMessage): string {
const parts: string[] = [];
if (msg.lat != null && msg.lon != null) parts.push(`${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}`);
const motion = aisMotionText(msg);
if (motion) parts.push(motion);
const route = aisRouteText(msg);
if (route && parts.length < 2) parts.push(route);
if (!parts.length) return route || "no position reported";
return parts.join(" · ");
}
function renderAisRow(msg: AisMessage): HTMLElement {
const row = document.createElement("div");
const row = document.createElement("details");
row.className = "ais-message";
const ts = msg._ts || new Date().toLocaleTimeString([], {
hour: "2-digit",
@@ -227,8 +241,9 @@ function renderAisRow(msg: AisMessage): HTMLElement {
const route = aisRouteText(msg);
const distance = aisDistanceText(msg);
const pos = msg.lat != null && msg.lon != null
? `<a class="ais-pos-link" href="javascript:void(0)" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}</a>`
? `<a class="ais-pos-link" href="javascript:void(0)" data-ais-map="${msg.lat},${msg.lon}">${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}</a>`
: "";
const vesselUrl = aisWindow.buildAisVesselUrl?.(msg.mmsi) ?? null;
row.dataset.filterText = [
name,
msg.mmsi,
@@ -243,23 +258,43 @@ function renderAisRow(msg: AisMessage): HTMLElement {
.join(" ")
.toUpperCase();
row.innerHTML =
`<div class="ais-row-head">` +
`<span class="ais-time">${ts}</span>` +
`<summary class="decode-line">` +
`<span class="ais-time">${escapeAisHtml(ts)}</span>` +
`<span class="ais-call">${nameHtml}</span>` +
`<span class="${channel.badgeClass}">${escapeAisHtml(channel.label)}</span>` +
`<span class="ais-badge ais-badge-type">${escapeAisHtml(aisTypeLabel(msg.message_type))}</span>` +
`</div>` +
`<div class="ais-row-meta">` +
`<span class="decode-line-summary">${escapeAisHtml(aisSummaryText(msg))}</span>` +
`<span class="${channel.badgeClass}">${escapeAisHtml(channel.label)}</span>` +
(distance ? `<span class="decode-line-distance">${escapeAisHtml(distance)}</span>` : "") +
`</summary>` +
`<div class="decode-expanded">` +
`<div class="decode-expanded-meta">` +
`<span>MMSI ${escapeAisHtml(String(msg.mmsi))}</span>` +
(route ? `<span class="ais-meta-text">${escapeAisHtml(route)}</span>` : "") +
`<span class="ais-meta-text">${escapeAisHtml(channel.freqText)}</span>` +
`</div>` +
`<div class="ais-row-detail">` +
(motion ? `<span>${escapeAisHtml(motion)}</span>` : `<span>No motion data</span>`) +
(distance ? `<span>${escapeAisHtml(distance)}</span>` : "") +
(pos ? `<span>${pos}</span>` : "") +
`<span>${escapeAisHtml(channel.freqText)}</span>` +
(route ? `<span>${escapeAisHtml(route)}</span>` : "") +
(motion ? `<span>${escapeAisHtml(motion)}</span>` : "") +
`<span>${escapeAisHtml(aisAgeText(msg._tsMs))}</span>` +
(pos ? `<span>${pos}</span>` : "") +
`</div>` +
`<div class="aprs-row-actions">` +
(msg.lat != null && msg.lon != null
? `<button class="aprs-inline-btn" type="button" data-ais-map="${msg.lat},${msg.lon}">Map</button>`
: "") +
(vesselUrl
? `<a class="aprs-inline-btn" href="${escapeAisHtml(vesselUrl)}" target="_blank" rel="noopener">Vessel</a>`
: "") +
`</div>` +
`</div>`;
row.querySelectorAll<HTMLElement>("[data-ais-map]").forEach((element) => {
element.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const [lat, lon] = (element.dataset.aisMap ?? "").split(",").map(Number);
if (lat == null || lon == null || !Number.isFinite(lat) || !Number.isFinite(lon)) return;
aisWindow.navigateToAprsMap?.(lat, lon);
});
});
applyAisFilterToRow(row);
return row;
}
@@ -354,9 +389,13 @@ function addAisMessage(msg: AisMessage): void {
scheduleAisBarUpdate();
scheduleAisHistoryRender();
if (msg.lat != null && msg.lon != null && aisWindow.aisMapAddVessel) {
aisWindow.aisMapAddVessel(msg);
plotAisMessage(msg);
}
/** Hands a positioned message to the map, if the map module is loaded yet. */
function plotAisMessage(msg: AisMessage): void {
if (msg.lat == null || msg.lon == null || !aisWindow.aisMapAddVessel) return;
aisWindow.aisMapAddVessel(msg);
}
function normalizeServerAisMessage(msg: AisMessage): AisMessage {
@@ -379,9 +418,7 @@ function onServerAisBatch(messages: AisMessage[]): void {
minute: "2-digit",
second: "2-digit",
});
if (next.lat != null && next.lon != null && aisWindow.aisMapAddVessel) {
aisWindow.aisMapAddVessel(next);
}
plotAisMessage(next);
normalized.push(next);
}
normalized.reverse();
@@ -427,4 +464,6 @@ updateAisSummary();
restore: onServerAisBatch,
reset: resetAisHistoryView,
prune: pruneAisHistoryView,
// Oldest first, so vessel tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry); },
});
@@ -30,6 +30,14 @@ export interface AprsPacket {
symbol_code?: string | null;
}
function escapeAprsHtml(value: string): string {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
export function aprsPacketCategory(packet: AprsPacket): AprsCategory {
const type = (packet.type ?? "").toLowerCase();
const info = (packet.info ?? "").toLowerCase();
@@ -108,8 +116,87 @@ 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}`,
};
}
/** An empty slot of the symbol's size, so a frame without one still lines up
* with the frames around it in the list. */
export function renderAprsSymbolSlot(packet: AprsPacket, escapeHtml: (value: string) => string): string {
return renderLocalAprsSymbol(packet, escapeHtml)
|| '<span class="aprs-symbol aprs-symbol-empty" aria-hidden="true"></span>';
}
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>`;
@@ -133,3 +220,174 @@ export function normalizeAprsPacket(packet: AprsPacket, receiver: unknown): Aprs
symbolCode: packet.symbol_code ?? null,
};
}
// ── Payload summaries ──────────────────────────────────────────────────────
// APRS packs its meaning into the information field with a set of one-character
// type identifiers and fixed-width encodings (APRS 1.0.1, chapters 6-15). The
// list showed that field as it arrives on the air, so reading a weather report
// meant decoding "_10090556c220s004g005t077..." by eye. These produce a line
// of plain text for the common types and leave the raw field to the expanded
// view, which is still the authority when a summary cannot be made.
/** `t077` → 25.0 °C. APRS carries temperature in whole degrees Fahrenheit. */
function fahrenheitToCelsius(fahrenheit: number): number {
return Math.round(((fahrenheit - 32) * 5 / 9) * 10) / 10;
}
/** Weather report fields: wind, gust, temperature, rain, humidity, pressure. */
function summarizeAprsWeather(info: string): string | null {
const parts: string[] = [];
const temperature = /t(-?\d{2,3})/.exec(info);
if (temperature) parts.push(`${fahrenheitToCelsius(Number(temperature[1]))} °C`);
const wind = /(\d{3})\/(\d{3})/.exec(info) ?? /c(\d{3}).*?s(\d{3})/.exec(info);
if (wind) {
const gust = /g(\d{3})/.exec(info);
const knots = Number(wind[2]);
parts.push(`wind ${Number(wind[1])}° ${knots} kt${gust ? ` gust ${Number(gust[1])}` : ""}`);
}
const humidity = /h(\d{2})/.exec(info);
if (humidity) {
const value = Number(humidity[1]);
parts.push(`${value === 0 ? 100 : value}% RH`);
}
const pressure = /b(\d{5})/.exec(info);
if (pressure) parts.push(`${(Number(pressure[1]) / 10).toFixed(1)} hPa`);
const rain = /r(\d{3})/.exec(info);
if (rain && Number(rain[1]) > 0) parts.push(`rain ${(Number(rain[1]) / 100).toFixed(2)}"`);
return parts.length ? parts.join(" · ") : null;
}
/** `T#005,199,000,255,073,123,01101001` → sequence, five channels, eight bits. */
function summarizeAprsTelemetry(info: string): string | null {
const match = /^T#(\d+|MIC)((?:,-?[\d.]*)+)(?:,([01]{8}))?/.exec(info.trim());
if (!match?.[2]) return null;
const channels = match[2].split(",").filter((value) => value.length > 0);
const bits = match[3] ? ` · bits ${match[3]}` : "";
return `#${match[1]} · ${channels.join(" ")}${bits}`;
}
/** `:DEST :text{01` → addressed message text. */
function summarizeAprsMessage(info: string): string | null {
const match = /^:([^:]{9}):(.*)$/.exec(info);
if (!match?.[1] || match[2] == null) return null;
const addressee = match[1].trim();
const text = match[2].replace(/\{\d+\s*$/, "").trim();
return `${addressee}: ${text}`;
}
/** Course/speed appended to a position, as `088/036`. */
function summarizeAprsCourseSpeed(info: string): string | null {
const match = /(\d{3})\/(\d{3})/.exec(info);
if (!match) return null;
const knots = Number(match[2]);
if (knots === 0) return null;
return `${Number(match[1])}° ${knots} kt`;
}
/** Whatever a frame is worth saying in one line, or null to fall back to raw. */
export function summarizeAprsPayload(packet: AprsPacket): string | null {
const info = packet.info ?? "";
if (!info) return null;
const category = aprsPacketCategory(packet);
if (category === "message") return summarizeAprsMessage(info);
if (category === "weather") return summarizeAprsWeather(info);
if (category === "telemetry") return summarizeAprsTelemetry(info);
if (category === "position") {
const parts: string[] = [];
if (packet.lat != null && packet.lon != null) {
parts.push(`${packet.lat.toFixed(4)}, ${packet.lon.toFixed(4)}`);
}
const courseSpeed = summarizeAprsCourseSpeed(info);
if (courseSpeed) parts.push(courseSpeed);
// Whatever the station wrote after the position report.
const comment = info.replace(/^[!=@/][^>]*[>_]?/, "").replace(/\d{3}\/\d{3}/, "").trim();
if (comment && comment.length <= 60) parts.push(comment);
return parts.length ? parts.join(" · ") : null;
}
// Status and anything else: the text it carries, minus its type character.
const text = info.replace(/^[>;<?]/, "").trim();
return text.length ? text : null;
}
// ── Frame row ──────────────────────────────────────────────────────────────
// Shared by the APRS and HF APRS lists, which had a copy each of the same
// forty lines of markup and drifted only by one badge.
export interface AprsRowOptions {
/** Marks the newest frame so it can flash on arrival. */
fresh?: boolean;
/** Leading badge, e.g. the band a copy of this list is dedicated to. */
badge?: string;
/** Distance from the receiver, already formatted, or "" to leave it out. */
distance?: string;
/** Opens the map on a frame's position. */
onMap?: (lat: number, lon: number) => void;
/** Puts a frame's coordinates on the clipboard. */
onCopy?: (text: string, button: HTMLElement) => void;
}
export function renderAprsPacketRow(packet: AprsPacket, options: AprsRowOptions = {}): HTMLElement {
const row = document.createElement("details");
row.className = "aprs-packet";
if (!packet.crcOk) row.classList.add("aprs-packet-crc");
if (options.fresh) row.classList.add("aprs-packet-new");
const time = packet._ts
|| new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
const category = aprsPacketCategory(packet);
const summary = summarizeAprsPayload(packet);
const hasPosition = packet.lat != null && packet.lon != null;
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(packet.srcCall || "")}`;
row.innerHTML =
`<summary class="decode-line">` +
`<span class="aprs-time">${escapeAprsHtml(time)}</span>` +
(options.badge ? `<span class="aprs-badge aprs-badge-band">${escapeAprsHtml(options.badge)}</span>` : "") +
renderAprsSymbolSlot(packet, escapeAprsHtml) +
`<span class="aprs-call">${escapeAprsHtml(packet.srcCall ?? "")}</span>` +
`<span class="aprs-badge aprs-badge-type aprs-badge-type-${category}">` +
`${escapeAprsHtml(aprsCategoryLabel(category))}</span>` +
`<span class="decode-line-summary">${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}</span>` +
(packet.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC</span>') +
(options.distance ? `<span class="decode-line-distance">${escapeAprsHtml(options.distance)}</span>` : "") +
`</summary>` +
`<div class="decode-expanded">` +
`<div class="decode-expanded-meta">` +
`<span>&gt;${escapeAprsHtml(packet.destCall || "--")}</span>` +
`<span>${escapeAprsHtml(packet.path || "no path")}</span>` +
`<span>${escapeAprsHtml(aprsAgeText(packet._tsMs))}</span>` +
`<span>CRC ${packet.crcOk ? "ok" : "failed"}</span>` +
(hasPosition
? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${packet.lat},${packet.lon}">`
+ `${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}</a>`
: "") +
`</div>` +
`<div class="decode-expanded-raw">${renderAprsInfo(packet)}</div>` +
(packet.info_bytes?.length
? `<div class="decode-expanded-bytes">${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}</div>`
: "") +
`<div class="aprs-row-actions">` +
(hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${packet.lat},${packet.lon}">Map</button>` : "") +
(hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${packet.lat},${packet.lon}">Copy Coords</button>` : "") +
`<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` +
`</div>` +
`</div>`;
row.querySelectorAll<HTMLElement>("[data-aprs-map]").forEach((element) => {
element.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const [lat, lon] = (element.dataset.aprsMap ?? "").split(",").map(Number);
if (Number.isFinite(lat) && Number.isFinite(lon)) options.onMap?.(lat as number, lon as number);
});
});
const copyButton = row.querySelector<HTMLElement>("[data-aprs-copy]");
if (copyButton) {
copyButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
options.onCopy?.(copyButton.dataset.aprsCopy ?? "", copyButton);
});
}
return row;
}
@@ -6,13 +6,10 @@ import { hostCore, hostState } from "./host.js";
import {
aprsAgeText,
aprsCategoryLabel,
aprsHexBytes,
aprsPacketCategory,
collapseAprsDuplicates,
normalizeAprsPacket,
renderAprsInfo,
renderLocalAprsSymbol,
renderAprsPacketRow,
type AprsPacket,
type AprsTypeFilter,
} from "./aprs-shared";
@@ -141,93 +138,24 @@ function updateAprsChipState() {
aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
}
function renderAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
const row = document.createElement("div");
row.className = "aprs-packet";
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
if (isFresh) row.classList.add("aprs-packet-new");
const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
const age = aprsAgeText(pkt._tsMs);
const category = aprsPacketCategory(pkt);
const categoryLabel = aprsCategoryLabel(category);
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeAprsHtml(pkt.path)}</span>` : "";
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
const symbolHtml = renderLocalAprsSymbol(pkt, escapeAprsHtml);
const posLink = pkt.lat != null && pkt.lon != null
? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>`
: "";
const distance = aprsDistanceText(pkt);
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
row.innerHTML =
`<div class="aprs-row-head">` +
`<span class="aprs-time">${ts}</span>` +
symbolHtml +
`<span class="aprs-call">${escapeAprsHtml(pkt.srcCall ?? "")}</span>` +
`<span>&gt;${escapeAprsHtml(pkt.destCall || "")}</span>` +
`<span class="${categoryClass}">${escapeAprsHtml(categoryLabel)}</span>` +
pathBadge +
crcBadge +
`</div>` +
`<div class="aprs-row-meta">` +
`<span class="aprs-meta-text">${escapeAprsHtml(age)}</span>` +
(distance ? `<span class="aprs-meta-text">${escapeAprsHtml(distance)}</span>` : "") +
`<span class="aprs-meta-text">${escapeAprsHtml(pkt.type || "--")}</span>` +
`</div>` +
`<div class="aprs-row-detail">` +
`<span title="${escapeAprsHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` +
(posLink ? `<span>${posLink}</span>` : "") +
`</div>` +
`<div class="aprs-row-actions">` +
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") +
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") +
`<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` +
`</div>` +
`<details class="aprs-details">` +
`<summary>Details</summary>` +
`<div class="aprs-details-grid">` +
`<span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.srcCall || "--")}</span>` +
`<span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.destCall || "--")}</span>` +
`<span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.type || "--")}</span>` +
`<span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.path || "--")}</span>` +
`<span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeAprsHtml(age)}</span>` +
`<span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span>` +
`<span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span>` +
`<span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.info || "--")}</span>` +
`<span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeAprsHtml(aprsHexBytes(pkt.info_bytes))}</span>` +
`</div>` +
`</details>`;
row.querySelectorAll<HTMLElement>("[data-aprs-map]").forEach((el) => {
el.addEventListener("click", (evt) => {
evt.preventDefault();
const raw = el.dataset.aprsMap ?? "";
const [lat, lon] = raw.split(",").map(Number);
if (aprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) {
aprsWindow.navigateToAprsMap(lat, lon);
}
});
});
const copyBtn = row.querySelector<HTMLElement>("[data-aprs-copy]");
if (copyBtn) {
copyBtn.addEventListener("click", () => { void (async () => {
const raw = copyBtn.dataset.aprsCopy ?? "";
async function copyAprsCoords(text: string): Promise<void> {
try {
const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined;
if (clipboard) {
await clipboard.writeText(raw);
if (!clipboard) return;
await clipboard.writeText(text);
showAprsHint("Coordinates copied", 1200);
}
} catch {
showAprsHint("Copy failed", 1500);
}
})(); });
}
return row;
function renderAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
return renderAprsPacketRow(pkt, {
fresh: isFresh,
distance: aprsDistanceText(pkt),
onMap: (lat, lon) => { aprsWindow.navigateToAprsMap?.(lat, lon); },
onCopy: (text) => { void copyAprsCoords(text); },
});
}
function renderAprsHistory() {
@@ -301,6 +229,12 @@ function pruneAprsHistoryView(): void {
renderAprsHistory();
}
/** Hands a positioned packet to the map, if the map module is loaded yet. */
function plotAprsPacket(pkt: AprsPacket): void {
if (pkt.lat == null || pkt.lon == null || !aprsWindow.aprsMapAddStation) return;
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
}
function addAprsPacket(pkt: AprsPacket): void {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs;
@@ -309,9 +243,7 @@ function addAprsPacket(pkt: AprsPacket): void {
aprsPacketHistory.unshift(pkt);
pruneAprsPacketHistory();
if (pkt.lat != null && pkt.lon != null && aprsWindow.aprsMapAddStation) {
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
}
plotAprsPacket(pkt);
if (pkt.crcOk) scheduleAprsBarUpdate();
@@ -332,9 +264,7 @@ function onServerAprsBatch(packets: AprsPacket[]): void {
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
if (next.lat != null && next.lon != null && aprsWindow.aprsMapAddStation) {
aprsWindow.aprsMapAddStation(next.srcCall ?? "", next.lat, next.lon, next.info ?? "", next.symbolTable, next.symbolCode, next);
}
plotAprsPacket(next);
if (next.crcOk) hasCrcOk = true;
normalized.push(next);
}
@@ -406,4 +336,6 @@ renderAprsHistory();
restore: onServerAprsBatch,
reset: resetAprsHistoryView,
prune: pruneAprsHistoryView,
// Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry); },
});
@@ -456,6 +456,7 @@ function bmApply(bm: Bookmark): void {
const modeEl = document.getElementById("mode") as HTMLSelectElement | null;
if (modeEl) {
modeEl.value = (bm.mode || "").toUpperCase();
hostCore.syncModePicker();
}
if (bm.bandwidth_hz) {
hostState.currentBandwidthHz = bm.bandwidth_hz;
@@ -6,13 +6,10 @@ import { hostCore, hostState } from "./host.js";
import {
aprsAgeText,
aprsCategoryLabel,
aprsHexBytes,
aprsPacketCategory,
collapseAprsDuplicates,
normalizeAprsPacket,
renderAprsInfo,
renderLocalAprsSymbol,
renderAprsPacketRow,
type AprsPacket,
type AprsTypeFilter,
} from "./aprs-shared";
@@ -27,7 +24,6 @@ interface HfAprsBridge {
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
}
const hfAprsWindow = window as unknown as HfAprsBridge;
const escapeHfAprsHtml = (input: string): string => hostCore.escapeMapHtml(input);
// --- HF APRS Decoder Plugin (server-side decode, 300 baud) ---
const hfAprsStatus = document.getElementById("hf-aprs-status");
@@ -128,95 +124,27 @@ function updateHfAprsChipState() {
hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup);
}
// HF traffic goes in the same row as VHF, marked with the band it came in on.
// This was a second copy of the same forty lines of markup.
function renderHfAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
const row = document.createElement("div");
row.className = "aprs-packet";
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
if (isFresh) row.classList.add("aprs-packet-new");
const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
const age = aprsAgeText(pkt._tsMs);
const category = aprsPacketCategory(pkt);
const categoryLabel = aprsCategoryLabel(category);
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeHfAprsHtml(pkt.path)}</span>` : "";
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
const hfBadge = '<span class="aprs-badge" style="background:var(--accent-alt,#f59e0b);color:#000">HF</span>';
const symbolHtml = renderLocalAprsSymbol(pkt, escapeHfAprsHtml);
const posLink = pkt.lat != null && pkt.lon != null
? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>`
: "";
const distance = hfAprsDistanceText(pkt);
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
row.innerHTML =
`<div class="aprs-row-head">` +
`<span class="aprs-time">${ts}</span>` +
hfBadge +
symbolHtml +
`<span class="aprs-call">${escapeHfAprsHtml(pkt.srcCall ?? "")}</span>` +
`<span>&gt;${escapeHfAprsHtml(pkt.destCall || "")}</span>` +
`<span class="${categoryClass}">${escapeHfAprsHtml(categoryLabel)}</span>` +
pathBadge +
crcBadge +
`</div>` +
`<div class="aprs-row-meta">` +
`<span class="aprs-meta-text">${escapeHfAprsHtml(age)}</span>` +
(distance ? `<span class="aprs-meta-text">${escapeHfAprsHtml(distance)}</span>` : "") +
`<span class="aprs-meta-text">${escapeHfAprsHtml(pkt.type || "--")}</span>` +
`</div>` +
`<div class="aprs-row-detail">` +
`<span title="${escapeHfAprsHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` +
(posLink ? `<span>${posLink}</span>` : "") +
`</div>` +
`<div class="aprs-row-actions">` +
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") +
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") +
`<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` +
`</div>` +
`<details class="aprs-details">` +
`<summary>Details</summary>` +
`<div class="aprs-details-grid">` +
`<span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.srcCall || "--")}</span>` +
`<span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.destCall || "--")}</span>` +
`<span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.type || "--")}</span>` +
`<span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.path || "--")}</span>` +
`<span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeHfAprsHtml(age)}</span>` +
`<span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span>` +
`<span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span>` +
`<span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.info || "--")}</span>` +
`<span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeHfAprsHtml(aprsHexBytes(pkt.info_bytes))}</span>` +
`</div>` +
`</details>`;
row.querySelectorAll<HTMLElement>("[data-aprs-map]").forEach((el) => {
el.addEventListener("click", (evt) => {
evt.preventDefault();
const raw = el.dataset.aprsMap ?? "";
const [lat, lon] = raw.split(",").map(Number);
if (hfAprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) {
hfAprsWindow.navigateToAprsMap(lat, lon);
return renderAprsPacketRow(pkt, {
fresh: isFresh,
badge: "HF",
distance: hfAprsDistanceText(pkt),
onMap: (lat: number, lon: number) => { hfAprsWindow.navigateToAprsMap?.(lat, lon); },
onCopy: (text: string) => { void copyHfAprsCoords(text); },
});
}
});
});
const copyBtn = row.querySelector<HTMLElement>("[data-aprs-copy]");
if (copyBtn) {
copyBtn.addEventListener("click", () => { void (async () => {
const raw = copyBtn.dataset.aprsCopy ?? "";
async function copyHfAprsCoords(text: string): Promise<void> {
try {
const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined;
if (clipboard) {
await clipboard.writeText(raw);
if (!clipboard) return;
await clipboard.writeText(text);
hostCore.showHint("Coordinates copied", 1200);
}
} catch {
hostCore.showHint("Copy failed", 1500);
}
})(); });
}
return row;
}
function renderHfAprsHistory() {
@@ -59,6 +59,8 @@ export interface HostCore {
setRigFrequency(frequencyHz: number): void;
syncBandwidthInput(bandwidthHz: number): void;
scheduleSpectrumDraw(): void;
/** Repaints the mode buttons from #mode after writing to it. */
syncModePicker(): void;
onDecoderRegistryReady(callback: () => void): void;
}
@@ -9,6 +9,9 @@ export interface DecoderPlugin<TMessage = unknown> {
restore?(messages: TMessage[]): void;
reset?(): void;
prune?(): void;
/** Replay everything the plugin is holding onto the map. Called when the map
* module attaches, which can happen long after the decodes arrived. */
syncMap?(): void;
}
export interface TrxPluginRuntime {
@@ -19,6 +22,7 @@ export interface TrxPluginRuntime {
reset(id: string): boolean;
resetAll(): void;
prune(id: string): boolean;
syncMapAll(): void;
clearQueued(): void;
hasDecoder(id: string): boolean;
}
@@ -403,7 +403,10 @@ function vchanSyncModeDisplay() {
if (!modeEl) return;
if (vchanIsOnVirtual()) {
const ch = vchanActiveChannel();
if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase();
if (ch && ch.mode) {
modeEl.value = ch.mode.toUpperCase();
hostCore.syncModePicker();
}
}
// When on primary channel, app.js rig-state updates handle the picker.
const modeUpper = (modeEl.value || "").toUpperCase();
@@ -320,9 +320,7 @@ function onServerVdesBatch(messages: VdesMessage[]): void {
minute: "2-digit",
second: "2-digit",
});
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
vdesWindow.vdesMapAddPoint(next);
}
plotVdesMessage(next);
normalized.push(next);
}
normalized.reverse();
@@ -349,13 +347,17 @@ if (vdesFilterInput) {
});
}
/** Hands a positioned message to the map, if the map module is loaded yet. */
function plotVdesMessage(msg: VdesMessage): void {
if (msg.lat == null || msg.lon == null || !vdesWindow.vdesMapAddPoint) return;
vdesWindow.vdesMapAddPoint(msg);
}
function onServerVdes(msg: VdesMessage): void {
if (vdesStatus) vdesStatus.textContent = "Receiving";
const next = normalizeServerVdesMessage(msg);
addVdesMessage(next);
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
vdesWindow.vdesMapAddPoint(next);
}
plotVdesMessage(next);
}
function pruneVdesHistoryView(): void {
@@ -372,4 +374,6 @@ updateVdesSummary();
restore: onServerVdesBatch,
reset: resetVdesHistoryView,
prune: pruneVdesHistoryView,
// Oldest first, so tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry); },
});
@@ -317,7 +317,10 @@ function elementById<T extends HTMLElement>(id: string): T {
const element = document.getElementById(id);
if (element) body.appendChild(element);
});
tray.appendChild(details);
// Ahead of the collapsibles the markup ships: the radio's own settings
// come before audio and the scheduler, and appending would put the
// section built here last whatever the markup says.
tray.insertBefore(details, document.getElementById("audio-controls"));
api.applyLayout(savedLayoutName(), { persist: false });
}
}
@@ -397,31 +400,40 @@ function elementById<T extends HTMLElement>(id: string): T {
});
document.addEventListener("keydown", (event) => { if (event.key === "Escape") closeMenu(); });
// Measured against the bar, not the actions container: the actions are
// sized by their content, so their own scrollWidth never exceeds their
// clientWidth. A viewport-width threshold is not enough either — how much
// fits depends on the rig name and the translated labels, so a bar that is
// wide enough on one rig clips a control on another.
// `bar.scrollWidth > bar.clientWidth` is true even when nothing is clipped,
// so it cannot be the test. What actually matters is that the controls stay
// inside the bar and the page tabs are not squeezed into a scroller: seeing
// every tab beats keeping the style picker inline.
// Compare natural widths against the space available. Rendered widths
// cannot answer this: the nav has min-width 0 and scrolls, so it always
// shrinks to the leftover space and always reports "scrolling", while the
// bar reports overflow even when nothing is clipped. scrollWidth on a
// scroll container is its unconstrained content width, which is what a fit
// test needs.
// What "fits" means, measured rather than predicted. Two things have to
// hold: the controls stay inside the bar, and no tab reaches them. The
// tabs are the test rather than the nav's own box because the nav may
// shrink below its content — its box gets smaller while the tabs inside
// keep their width and slide under the controls, so the container reports
// nothing wrong while destinations become unclickable.
//
// This was an arithmetic estimate — identity + nav.scrollWidth +
// actions.scrollWidth + a 48px allowance for the gaps — which under-counts
// whatever the allowance does not cover. On a platform whose fonts run
// wider than this machine's it declared a fit that overlapped by 9px, and
// nothing degraded because nothing thought anything was wrong. Reading
// the geometry costs a synchronous layout per step, at most five per pass,
// and cannot disagree with what the operator sees.
const barFits = () => {
const bar = actions.closest<HTMLElement>(".tab-bar");
if (!bar) return true;
const identity = bar.querySelector<HTMLElement>(".header-main");
const nav = bar.querySelector<HTMLElement>(".tab-bar-nav");
const gutters = 48;
const needed = (identity?.offsetWidth ?? 0) + (nav?.scrollWidth ?? 0) + actions.scrollWidth + gutters;
return needed <= bar.clientWidth;
const nav = bar?.querySelector<HTMLElement>(".tab-bar-nav");
if (!bar || !nav) return true;
const barRect = bar.getBoundingClientRect();
const actionsRect = actions.getBoundingClientRect();
if (actionsRect.right > barRect.right + 1) return false;
const tabs = Array.from(nav.querySelectorAll<HTMLElement>(".tab"))
.filter((tab) => tab.offsetParent !== null);
if (!tabs.length) return true;
const tabsRight = Math.max(...tabs.map((tab) => tab.getBoundingClientRect().right));
return tabsRight <= actionsRect.left - 1;
};
const reflowOverflow = () => {
const nav = document.querySelector<HTMLElement>(".tab-bar-nav");
const bar = actions.closest<HTMLElement>(".tab-bar");
// 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");
bar?.classList.remove("bar-tight");
overflowOrder.forEach((selector) => {
const element = menu.querySelector<HTMLElement>(selector);
if (element) actions.insertBefore(element, wrap);
@@ -434,11 +446,32 @@ 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");
// Still short with the tabs down to icons: hand the squeeze to the
// identity block, which can ellipsise, rather than to the strip, which
// can only clip destinations out of reach.
if (bar && !barFits()) bar.classList.add("bar-tight");
wrap.hidden = menu.children.length === 0;
if (wrap.hidden) closeMenu();
};
reflowOverflow();
window.addEventListener("resize", reflowOverflow);
// A resize is not the only thing that changes what fits: the rig name
// arrives from the server, the style picker fills in, a web font swaps in
// wider metrics. Each changes the bar's content without touching the
// window, and the strip stayed as it was through all of them.
if (typeof ResizeObserver !== "undefined") {
const bar = actions.closest<HTMLElement>(".tab-bar");
const observer = new ResizeObserver(() => { reflowOverflow(); });
if (bar) observer.observe(bar);
observer.observe(actions);
}
document.fonts?.ready.then(() => { reflowOverflow(); }).catch(() => {});
}
function installMobileMore() {
@@ -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'\)/);
});
@@ -29,7 +29,7 @@ class ElementFixture {
// Mirrors the `window.trx` host contract published by app.ts. The plugin is a
// separate bundle, so every application service it uses arrives this way.
function hostFixture(overrides = {}) {
const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0 };
const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0, syncModePicker: 0 };
const state = {
authEnabled: false,
authRole: "control",
@@ -50,6 +50,7 @@ function hostFixture(overrides = {}) {
armOptimisticFrequency: (hz) => { calls.armOptimisticFrequency.push(hz); },
syncBandwidthInput: (hz) => { calls.syncBandwidthInput.push(hz); },
scheduleSpectrumDraw: () => { calls.scheduleSpectrumDraw += 1; },
syncModePicker: () => { calls.syncModePicker += 1; },
onDecoderRegistryReady: () => {},
};
return { window: { trx: { state, core, modules: {} }, trxUi: { confirm: async () => true } }, calls };
@@ -3,170 +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";
// page.evaluate callbacks run in the browser, not in this Node process.
/* global document */
/* global document, getComputedStyle, window, location */
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 = [];
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" });
@@ -175,6 +23,167 @@ try {
await page.locator("summary", { hasText: "Audio controls" }).click();
assert.equal(await page.locator("#rx-audio-btn").count(), 1);
// Digital modes: the decoders are a list down the side, and the panel for the
// selected one sits beside it. A horizontal strip put thirteen decoders in a
// scroller and marked the open one with a single underline among them.
await page.locator('.tab[data-tab="digital-modes"]').click();
await page.locator("#tab-digital-modes").waitFor({ state: "visible" });
await page.locator('.sub-tab[data-subtab="ft8"]').click();
await page.waitForTimeout(250);
const digital = await page.evaluate(() => {
const bar = document.querySelector("#tab-digital-modes > .sub-tab-bar").getBoundingClientRect();
const panel = document.getElementById("subtab-ft8").getBoundingClientRect();
const shown = [...document.querySelectorAll("#tab-digital-modes > .sub-tab-panel")]
.filter((element) => getComputedStyle(element).display !== "none")
.map((element) => element.id);
return {
sidebarIsColumn: bar.height > bar.width,
panelBesideSidebar: Math.round(panel.left) >= Math.round(bar.right),
panelsShown: shown,
decoders: document.querySelectorAll("#tab-digital-modes > .sub-tab-bar .sub-tab").length,
};
});
assert.ok(digital.sidebarIsColumn, "the decoder list is not a sidebar");
assert.ok(digital.panelBesideSidebar, "the decoder panel does not sit beside the sidebar");
assert.deepEqual(digital.panelsShown, ["subtab-ft8"], `panels shown: ${JSON.stringify(digital.panelsShown)}`);
assert.ok(digital.decoders >= 10, `only ${digital.decoders} decoders in the sidebar`);
// Each decoder's list fills its panel. FT8, FT4, FT2 and WSPR size against
// the panel with flex, so a panel sized to its own content collapsed them to
// their 120px minimum with the rest of the page left empty; the marine lists
// were sized by a viewport formula that stopped matching when the panel
// changed shape.
for (const [subtab, list] of [["ft8", "ft8-messages"], ["wspr", "wspr-messages"],
["ais", "ais-messages"], ["aprs", "aprs-packets"], ["hf-aprs", "hf-aprs-packets"]]) {
await page.locator(`.sub-tab[data-subtab="${subtab}"]`).click();
await page.waitForTimeout(150);
const filled = await page.evaluate((id) => {
const element = document.getElementById(id);
const panel = element.closest(".sub-tab-panel");
return {
list: Math.round(element.getBoundingClientRect().height),
panel: Math.round(panel.getBoundingClientRect().height),
scrolls: getComputedStyle(element).overflowY,
};
}, list);
assert.ok(filled.list > filled.panel * 0.6,
`${subtab}: the list is ${filled.list}px in a ${filled.panel}px panel`);
assert.equal(filled.scrolls, "auto", `${subtab}: the list does not scroll on its own`);
}
await page.locator('.tab[data-tab="main"]').click();
await page.waitForTimeout(200);
// Map links from decode rows, before anything has opened the Map tab. The
// lazy map module used to install these globals itself, so an AIS pin threw
// "not a function" and an APRS link silently did nothing until the tab had
// been visited once.
const mapLinkReady = await page.evaluate(() => ({
position: typeof window.navigateToAprsMap,
locator: typeof window.navigateToMapLocator,
mapModuleLoaded: !!window.trx.modules.map,
}));
assert.equal(mapLinkReady.position, "function", "navigateToAprsMap is missing before the map loads");
assert.equal(mapLinkReady.locator, "function", "navigateToMapLocator is missing before the map loads");
assert.equal(mapLinkReady.mapModuleLoaded, false, "the map module was already loaded, so this proves nothing");
await page.evaluate(() => { window.navigateToAprsMap(52.2, 21.0); });
await page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
await page.waitForTimeout(500);
const followed = await page.evaluate(() => ({
path: location.pathname,
active: [...document.querySelectorAll(".tab-bar .tab.active")].map((tab) => tab.dataset.tab || tab.id),
mapHeight: Math.round(document.getElementById("aprs-map").getBoundingClientRect().height),
}));
assert.equal(followed.path, "/map", `the map link left the page on ${followed.path}`);
assert.ok(followed.active.includes("map"), `the strip marks ${JSON.stringify(followed.active)}`);
assert.ok(followed.mapHeight > 100, `the map came up ${followed.mapHeight}px tall`);
await page.locator('.tab[data-tab="main"]').click();
await page.waitForTimeout(200);
// Section order in the tray. "Advanced radio controls" is built at runtime,
// so it lands wherever ui-core puts it rather than where the markup says —
// appending, as it once did, always left it last.
const sections = await page.evaluate(() =>
[...document.querySelectorAll(".controls-tray > details")].map((section) =>
section.querySelector("summary").textContent.trim()));
assert.deepEqual(sections, ["Advanced radio controls", "Audio controls", "Scheduler controls"],
`tray sections are ${JSON.stringify(sections)}`);
// Mode is a button group over a hidden <select>, which stays the value a
// dozen call sites and several plugins read. The click has to reach it, and
// the select must not take part in layout while it does.
const modeBefore = await page.evaluate(() => ({
buttons: document.querySelectorAll("#mode-picker button").length,
value: document.getElementById("mode").value,
active: document.querySelector("#mode-picker button.active")?.dataset.mode,
}));
assert.ok(modeBefore.buttons > 1, `mode picker rendered ${modeBefore.buttons} buttons`);
assert.equal(modeBefore.active, modeBefore.value, "mode picker disagrees with the select");
const target = await page.evaluate(() => {
const other = [...document.querySelectorAll("#mode-picker button")]
.find((btn) => btn.dataset.mode !== document.getElementById("mode").value);
return other?.dataset.mode;
});
await page.locator(`#mode-picker button[data-mode="${target}"]`).click();
await page.waitForTimeout(200);
const modeAfter = await page.evaluate(() => ({
value: document.getElementById("mode").value,
active: document.querySelector("#mode-picker button.active")?.dataset.mode,
selectWidth: Math.round(document.getElementById("mode").getBoundingClientRect().width),
}));
assert.equal(modeAfter.value, target, `clicking ${target} left the select at ${modeAfter.value}`);
assert.equal(modeAfter.active, target, "the clicked mode is not the marked one");
assert.ok(modeAfter.selectWidth <= 2, `the hidden select still occupies ${modeAfter.selectWidth}px`);
// Mode-specific controls live on their own row, which has to leave with them:
// an empty one would still take a track and a gap in the tray and draw its
// divider under the controls every rig has.
const modeRowState = async () => page.evaluate(() => {
const row = document.getElementById("mode-controls-row");
return { display: getComputedStyle(row).display, height: Math.round(row.getBoundingClientRect().height) };
});
await page.locator('#mode-picker button[data-mode="WFM"]').click();
await page.waitForTimeout(250);
const withWfm = await modeRowState();
assert.notEqual(withWfm.display, "none", "WFM controls did not bring their row up");
assert.ok(withWfm.height > 0, `WFM row has no height (${withWfm.height}px)`);
await page.locator('#mode-picker button[data-mode="FM"]').click();
await page.waitForTimeout(250);
const withoutWfm = await modeRowState();
assert.equal(withoutWfm.display, "none", "the mode row stayed behind with nothing in it");
// 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}`);
// Rig names, and they have to survive the state stream. The updates carry
// only rig ids — /rigs is what knows the names — and applying one used to
// clear the names, so the picker and the header fell back to the lowercase
// ids a second after load and stayed there.
await page.waitForTimeout(1500);
const rigLabels = await page.evaluate(() => ({
options: [...document.getElementById("header-rig-switch-select").options].map((o) => o.textContent),
subtitle: document.getElementById("rig-subtitle").textContent,
}));
assert.deepEqual(rigLabels.options, ["Primary fixture", "Secondary fixture"],
`the picker reads ${JSON.stringify(rigLabels.options)}`);
assert.equal(rigLabels.subtitle, "Rig: Primary fixture",
`the header reads "${rigLabels.subtitle}"`);
const rigPicker = page.locator("#header-rig-switch-select");
await rigPicker.locator("option").nth(1).waitFor({ state: "attached" });
await rigPicker.selectOption("rig-b");
@@ -184,6 +193,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, "/");
@@ -199,6 +246,24 @@ try {
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
@@ -247,7 +312,83 @@ try {
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. This suite passed on macOS twice
// while CI, whose system font is wider, put the tab strip into the controls —
// the second time by 9px at 1440px with no scaling at all, because the fit
// test was an arithmetic estimate whose fixed allowance for the gaps did not
// cover them at those metrics. A single scale factor cannot stand in for
// another platform's fonts, so sweep: somewhere in this range is whatever CI
// renders, and the strip has to hold at every step of it.
const applyTextScale = (scale) => page.addStyleTag({ content: `
.tab-bar .title { font-size: ${1.05 * scale}rem !important; }
.tab-bar .subtitle { font-size: ${0.78 * scale}rem !important; }
.tab-bar .tab { font-size: ${0.95 * scale}rem !important; }
.tab-bar select, .tab-bar button { font-size: ${0.95 * scale}rem !important; }
` });
const measureBar = () => 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"),
};
});
for (const scale of [1.0, 1.15, 1.3, 1.5, 1.75, 2.0]) {
await applyTextScale(scale);
// 1100px is the narrowest bar in the app: the bookmark gutters take 9.5rem
// a side above that width, leaving less room than 900px has.
for (const width of [1440, 1280, 1100, 900]) {
await page.setViewportSize({ width, height: 900 });
await page.waitForTimeout(120);
const crowded = await measureBar();
assert.ok(crowded.overlap <= 0,
`at ${scale}x text the tab strip overlaps the controls by ${crowded.overlap}px at ${width}px`);
}
}
// A station name long enough that the bar cannot hold it, which is the rung
// below icons: the identity has to give, not the strip.
await page.evaluate(() => {
document.getElementById("rig-subtitle").textContent =
"Rig: Shack SDR — RTL-SDR v4 on the attic dipole, north-west";
});
await applyTextScale(1.6);
for (const width of [1440, 1100]) {
await page.setViewportSize({ width, height: 900 });
await page.waitForTimeout(200);
const crowded = await measureBar();
assert.ok(crowded.overlap <= 0,
`with a long station name the tab strip overlaps the controls by ${crowded.overlap}px at ${width}px`);
}
// ...and when the text changes under a bar that is not resized. The rig name
// arrives from the server, a web font swaps in: neither is a window resize,
// and the strip used to sit there as it was.
await page.setViewportSize({ width: 1440, height: 900 });
await page.waitForTimeout(200);
await applyTextScale(2.4);
await page.waitForTimeout(400);
const unresized = await measureBar();
assert.ok(unresized.overlap <= 0,
`text grew without a resize and the strip overlaps by ${unresized.overlap}px`);
assert.equal(unresized.iconsOnly, true, "the strip kept its labels with no room for them");
// The frequency readouts are typed into, so the browser remembers what went
// in and offers it back in a dropdown over the reading — Edge does this by
// default. Nothing here wants to be autofilled from what was tuned last week.
const autofill = await page.evaluate(() => ["freq", "center-freq"].map((id) => {
const input = document.getElementById(id);
return { id, autocomplete: input?.getAttribute("autocomplete") ?? null };
}));
for (const field of autofill) {
assert.equal(field.autocomplete, "off",
`#${field.id} offers autofill (autocomplete=${field.autocomplete})`);
}
} 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,334 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// What happens to a decode after it arrives: the panel on its tab, the mini
// view over the waterfall, the marker on the map, and the link between them.
// Nothing exercised this before — the fixture served an empty decode stream —
// which is how the map links came to be broken for every decoder at once.
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
/* global document, getComputedStyle, window, location, requestAnimationFrame, MutationObserver */
const VESSEL = {
type: "ais", mmsi: 244660000, lat: 52.37, lon: 4.89, vessel_name: "NEDERLAND",
callsign: "PBTX", sog_knots: 8.2, cog_deg: 91, channel: "A", message_type: 1, rig_id: "rig-a",
};
const BEACON = {
type: "aprs", src_call: "SP2SJG-9", dest_call: "APRS", path: "WIDE1-1", info: "Test beacon",
packet_type: "position", crc_ok: true, lat: 54.35, lon: 18.65,
symbol_table: "/", symbol_code: ">", rig_id: "rig-a",
};
// AIS is what the mini view for vessels is gated on; the rig has to be on it.
const fixture = await startWebFixture({ spectrum: true, decodes: [VESSEL, BEACON], mode: "AIS" });
const { browser, page, runtimeErrors } = await startBrowser(chromium);
try {
await page.setViewportSize({ width: 1500, height: 950 });
await page.goto(`${fixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" });
await page.locator("#tab-digital-modes").waitFor({ state: "visible" });
await page.waitForTimeout(2000);
// The decoders with panels on this tab have to load with it. They used to
// come only with the map group, so these panels stayed empty — decodes
// queued in the plugin runtime — until something opened the Map tab.
const panels = await page.evaluate(() => ({
ais: document.getElementById("ais-messages")?.children.length ?? 0,
aprs: document.getElementById("aprs-packets")?.children.length ?? 0,
aisStatus: document.getElementById("ais-status")?.textContent ?? "",
aprsStatus: document.getElementById("aprs-status")?.textContent ?? "",
mapLoaded: !!window.trx.modules.map,
}));
assert.equal(panels.mapLoaded, false, "the map module was loaded, so this proves nothing");
assert.ok(panels.ais > 0, `the AIS panel is empty (status: ${panels.aisStatus})`);
assert.ok(panels.aprs > 0, `the APRS panel is empty (status: ${panels.aprsStatus})`);
// The mini view rides over the waterfall on the radio page.
await page.locator('.tab[data-tab="main"]').click();
await page.waitForTimeout(1500);
const miniView = await page.evaluate(() => {
const bar = document.getElementById("ais-bar-overlay");
return {
shown: getComputedStyle(bar).display !== "none",
pins: bar.querySelectorAll(".aprs-bar-pin").length,
names: bar.textContent.includes("NEDERLAND"),
};
});
assert.equal(miniView.shown, true, "the AIS mini view did not appear");
assert.ok(miniView.pins > 0, "the mini view has no pin to follow");
assert.equal(miniView.names, true, "the mini view does not name the vessel");
// Following the pin: the map opens, on the vessel. This is the path that was
// broken for every decoder — the module that owned the navigation had not
// been loaded, so the pin did nothing at all.
await page.locator("#ais-bar-overlay .aprs-bar-pin").first().click();
await page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
await page.waitForTimeout(800);
const followed = await page.evaluate(() => {
const centre = window.trx.modules.map?.aprsMap?.getCenter?.();
return {
path: location.pathname,
lat: centre ? Number(centre.lat.toFixed(2)) : null,
lon: centre ? Number(centre.lng.toFixed(2)) : null,
};
});
assert.equal(followed.path, "/map", `the pin left the page on ${followed.path}`);
assert.equal(followed.lat, VESSEL.lat, `the map centred on ${followed.lat}, not the vessel`);
assert.equal(followed.lon, VESSEL.lon, `the map centred on ${followed.lon}, not the vessel`);
// Both decoders put their own marker on it.
await page.waitForTimeout(1200);
const markers = await page.evaluate(() => {
const map = window.trx.modules.map;
const size = (collection) => (collection instanceof Map
? collection.size
: Object.keys(collection ?? {}).length);
return { ais: size(map?.aisMarkers), stations: size(map?.stationMarkers) };
});
assert.ok(markers.ais > 0, "the vessel never reached the map");
assert.ok(markers.stations > 0, "the APRS station never reached the map");
assert.deepEqual(runtimeErrors, []);
} finally {
await browser.close();
await fixture.close();
}
// Stored history, which is what is on screen a second after a page load. The
// endpoint answers in CBOR, so the fixture speaks CBOR: serving anything else
// left the client on its retry path and the history path untested.
const HISTORY_AIS = 900;
const HISTORY_APRS = 300;
const historyFixture = await startWebFixture({
spectrum: true,
mode: "AIS",
history: {
ais: Array.from({ length: HISTORY_AIS }, (_, index) => ({
mmsi: 244660000 + index, lat: 52.3 + index * 0.001, lon: 4.8,
vessel_name: `HISTORIC ${index}`, channel: "A", message_type: 1,
rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
})),
aprs: Array.from({ length: HISTORY_APRS }, (_, index) => ({
src_call: `SP2SJG-${index % 15}`, dest_call: "APRS", path: "WIDE1-1",
info: `history ${index}`, packet_type: "position", crc_ok: true,
lat: 54.3 + (index % 15) * 0.01, lon: 18.6, rig_id: "rig-a",
ts_ms: Date.now() - (index + 1) * 1000,
})),
},
});
const replay = await startBrowser(chromium);
// Installed before the page's own scripts, so nothing can be missed: every
// time the progress element becomes visible, its geometry is recorded.
await replay.page.addInitScript(() => {
window.__historyProgressSamples = [];
const watch = () => {
const element = document.getElementById("decode-history-overlay");
if (!element) { requestAnimationFrame(watch); return; }
const sample = () => {
if (element.classList.contains("is-hidden")) return;
const rect = element.getBoundingClientRect();
window.__historyProgressSamples.push({
width: Math.round(rect.width),
coversCentre: document.elementFromPoint(700, 450)?.id === "decode-history-overlay",
});
};
new MutationObserver(sample).observe(element, { attributes: true, attributeFilter: ["class"] });
sample();
};
watch();
});
try {
await replay.page.setViewportSize({ width: 1400, height: 900 });
await replay.page.goto(`${historyFixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" });
await replay.page.locator("#tab-digital-modes").waitFor({ state: "visible" });
// While it loads, the operator can still see the radio. This used to be a
// full-screen scrim over everything for as long as the replay ran.
//
// Watched from inside the page rather than polled from here: a fast replay
// can start and finish between two polls, and then the test reports that no
// progress was ever shown when what happened is that it blinked.
const shown = await replay.page.evaluate(() => window.__historyProgressSamples ?? []);
assert.ok(shown.length > 0, "no progress was shown while the history loaded");
for (const sample of shown) {
assert.ok(sample.width < 700, `the progress covers ${sample.width}px of a 1400px page`);
assert.equal(sample.coversCentre, false, "the progress sits over the page");
}
// And all of it arrives, on the first load.
await replay.page.waitForTimeout(2000);
const restored = await replay.page.evaluate(() => ({
ais: document.getElementById("ais-messages")?.children.length ?? 0,
aprs: document.getElementById("aprs-packets")?.children.length ?? 0,
progressHidden: document.getElementById("decode-history-overlay").classList.contains("is-hidden"),
}));
assert.equal(restored.ais, HISTORY_AIS, `restored ${restored.ais} of ${HISTORY_AIS} AIS records`);
assert.equal(restored.aprs, HISTORY_APRS, `restored ${restored.aprs} of ${HISTORY_APRS} APRS records`);
assert.equal(restored.progressHidden, true, "the progress stayed up after the replay finished");
// Opening the map for the first time has to show the stored history too.
// The map module is lazy, so at the moment the history was restored its
// aprsMapAddStation/aisMapAddVessel hooks did not exist yet and every
// position was dropped. Nothing replayed them when the module finally
// arrived, so the map came up empty and only filled in from decodes heard
// afterwards -- a station heard once was never plotted at all, and it took a
// second reload (module cached, so loaded early enough to beat the history
// fetch) before the map showed anything.
//
// This fixture serves no live decode stream on purpose: with one, fresh
// frames arriving after the module loads would paper over the whole thing.
const mapLoadedDuringReplay = await replay.page.evaluate(() => !!window.trx.modules.map);
assert.equal(mapLoadedDuringReplay, false, "the map was already loaded, so this proves nothing");
await replay.page.locator('.tab[data-tab="map"]').click();
await replay.page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
await replay.page.waitForTimeout(1500);
const plotted = await replay.page.evaluate(() => {
const map = window.trx.modules.map;
const size = (collection) => (collection instanceof Map
? collection.size
: Object.keys(collection ?? {}).length);
return { ais: size(map?.aisMarkers), stations: size(map?.stationMarkers) };
});
assert.equal(plotted.ais, HISTORY_AIS, `${plotted.ais} of ${HISTORY_AIS} vessels reached the map`);
assert.equal(plotted.stations, 15, `${plotted.stations} of 15 stations reached the map`);
assert.deepEqual(replay.runtimeErrors, []);
} finally {
await replay.browser.close();
await historyFixture.close();
}
// The APRS list: one line per frame, with the information field read for the
// operator rather than shown as it arrives on the air.
const APRS_FRAMES = [
{ packet_type: "weather", info: "_10090556c220s004g005t077r000p000P000h50b09900" },
{ packet_type: "message", info: ":SP2SJG-9 :Hello from the field{01" },
{ packet_type: "telemetry", info: "T#005,199,000,255,073,123,01101001" },
{ packet_type: "position", info: "!5421.30N/01839.20E>Test beacon 73", lat: 54.35, lon: 18.65 },
].map((frame, index) => ({
src_call: `SP2SJG-${index}`, dest_call: "APRS", path: "WIDE1-1", crc_ok: true,
// Only position reports carry a symbol here, which is the case the columns
// have to survive: a frame without one used to close the gap and shift
// everything after it left.
...(frame.packet_type === "position" ? { symbol_table: "/", symbol_code: ">" } : {}),
rig_id: "rig-a", ts_ms: Date.now() - index * 1000,
...frame,
}));
// The AIS list is the same shape: a line per message, saying where the vessel
// is and what it is doing, with the identifiers behind it.
const AIS_MESSAGES = [
{ message_type: 1, mmsi: 244660001, vessel_name: "NEDERLAND", channel: "A",
lat: 54.35, lon: 18.65, sog_knots: 8.2, cog_deg: 91.4 },
{ message_type: 5, mmsi: 244660002, vessel_name: "STENA SPIRIT", channel: "B",
callsign: "PBTX", destination: "GDANSK" },
].map((message, index) => ({ rig_id: "rig-a", ts_ms: Date.now() - index * 1000, ...message }));
const aprsFixture = await startWebFixture({
spectrum: true,
mode: "AIS",
history: { aprs: APRS_FRAMES, ais: AIS_MESSAGES },
});
const aprs = await startBrowser(chromium);
try {
await aprs.page.setViewportSize({ width: 1400, height: 900 });
await aprs.page.goto(`${aprsFixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" });
await aprs.page.locator("#tab-digital-modes").waitFor({ state: "visible" });
await aprs.page.waitForTimeout(2000);
await aprs.page.locator('.sub-tab[data-subtab="aprs"]').click();
await aprs.page.waitForTimeout(400);
const rows = await aprs.page.evaluate(() =>
[...document.querySelectorAll("#aprs-packets .aprs-packet")].map((row) => ({
tag: row.tagName,
height: Math.round(row.getBoundingClientRect().height),
type: row.querySelector(".aprs-badge-type")?.textContent?.trim() ?? "",
summary: row.querySelector(".decode-line-summary")?.textContent?.trim() ?? "",
})));
// Newest first, so find each by the type it carries rather than by position.
const summaryOf = (type) => rows.find((row) => row.type === type)?.summary ?? "";
assert.equal(rows.length, APRS_FRAMES.length, `rendered ${rows.length} frames`);
for (const row of rows) {
assert.equal(row.tag, "DETAILS", "a frame is not expandable in place");
assert.ok(row.height < 44, `a frame is ${row.height}px tall; it used to be a card of about 140`);
}
// Each payload read rather than echoed: 25 °C from t077, the addressee from
// a message, the sequence from telemetry, the fix from a position report.
assert.match(summaryOf("Weather"), /25 °C/, `weather summary: ${summaryOf("Weather")}`);
assert.match(summaryOf("Weather"), /990\.0 hPa/, `weather summary: ${summaryOf("Weather")}`);
assert.match(summaryOf("Message"), /→ SP2SJG-9: Hello from the field/, `message summary: ${summaryOf("Message")}`);
assert.match(summaryOf("Telemetry"), /^#005/, `telemetry summary: ${summaryOf("Telemetry")}`);
assert.match(summaryOf("Position"), /54\.3500, 18\.6500/, `position summary: ${summaryOf("Position")}`);
// Frames with and without a symbol line up: the slot is held open either way.
const columns = await aprs.page.evaluate(() =>
[...document.querySelectorAll("#aprs-packets .aprs-packet")].map((row) => ({
call: Math.round(row.querySelector(".aprs-call").getBoundingClientRect().x),
summary: Math.round(row.querySelector(".decode-line-summary").getBoundingClientRect().x),
symbol: !!row.querySelector(".aprs-symbol:not(.aprs-symbol-empty)"),
})));
assert.ok(columns.some((column) => column.symbol) && columns.some((column) => !column.symbol),
"the sample has to mix frames with and without a symbol to test this");
assert.equal(new Set(columns.map((column) => column.call)).size, 1,
`callsigns start at ${JSON.stringify(columns.map((column) => column.call))}`);
assert.equal(new Set(columns.map((column) => column.summary)).size, 1,
`summaries start at ${JSON.stringify(columns.map((column) => column.summary))}`);
// The frame as it arrived is still there, one click away.
await aprs.page.locator("#aprs-packets .aprs-packet", { hasText: "25 °C" })
.locator(".decode-line").first().click();
await aprs.page.waitForTimeout(200);
const expanded = await aprs.page.evaluate(() => {
const row = document.querySelector("#aprs-packets .aprs-packet[open]");
return {
open: row.hasAttribute("open"),
raw: row.querySelector(".decode-expanded-raw")?.textContent?.trim() ?? "",
meta: row.querySelector(".decode-expanded-meta")?.textContent ?? "",
};
});
assert.equal(expanded.open, true, "the frame did not open");
assert.match(expanded.raw, /^_10090556c220s004g005t077/, `raw frame: ${expanded.raw}`);
assert.match(expanded.meta, /WIDE1-1/, `expanded meta: ${expanded.meta}`);
// AIS, on the same row.
await aprs.page.locator('.sub-tab[data-subtab="ais"]').click();
await aprs.page.waitForTimeout(300);
const aisRows = await aprs.page.evaluate(() =>
[...document.querySelectorAll("#ais-messages .ais-message")].map((row) => ({
tag: row.tagName,
height: Math.round(row.getBoundingClientRect().height),
name: row.querySelector(".ais-call")?.textContent?.trim() ?? "",
summary: row.querySelector(".decode-line-summary")?.textContent?.trim() ?? "",
})));
assert.equal(aisRows.length, AIS_MESSAGES.length, `rendered ${aisRows.length} messages`);
for (const row of aisRows) {
assert.equal(row.tag, "DETAILS", "an AIS message is not expandable in place");
assert.ok(row.height < 44, `an AIS message is ${row.height}px tall`);
}
const positionRow = aisRows.find((row) => row.name === "NEDERLAND");
const staticRow = aisRows.find((row) => row.name === "STENA SPIRIT");
assert.match(positionRow?.summary ?? "", /54\.3500, 18\.6500/, `position: ${positionRow?.summary}`);
assert.match(positionRow?.summary ?? "", /8\.2 kn/, `position: ${positionRow?.summary}`);
// A static report carries no fix, so it says where the vessel is going.
assert.match(staticRow?.summary ?? "", /PBTX -> GDANSK/, `static: ${staticRow?.summary}`);
await aprs.page.locator("#ais-messages .ais-message .decode-line").first().click();
await aprs.page.waitForTimeout(200);
const aisExpanded = await aprs.page.evaluate(() =>
document.querySelector("#ais-messages .ais-message[open] .decode-expanded-meta")?.textContent ?? "");
assert.match(aisExpanded, /MMSI 2446600/, `expanded AIS: ${aisExpanded}`);
assert.match(aisExpanded, /MHz/, `expanded AIS: ${aisExpanded}`);
assert.deepEqual(aprs.runtimeErrors, []);
} finally {
await aprs.browser.close();
await aprsFixture.close();
}
@@ -57,6 +57,7 @@ export function createHost({ state = {}, core = {}, modules = {} } = {}) {
setRigFrequency: record("setRigFrequency"),
syncBandwidthInput: record("syncBandwidthInput"),
scheduleSpectrumDraw: record("scheduleSpectrumDraw"),
syncModePicker: record("syncModePicker"),
onDecoderRegistryReady: record("onDecoderRegistryReady"),
...core,
},
@@ -0,0 +1,380 @@
// 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, getComputedStyle, window */
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;
// The meter sits well away from the spectrum's noise floor, so a squelch that
// took its level from the plot would land somewhere else entirely.
const METER_DB = -70;
const fixture = await startWebFixture({
spectrum: true,
bookmarks: BOOKMARKS,
bandplan: BANDPLAN,
bandplanEnabled: true,
meterDb: METER_DB,
});
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}`);
// Squelch: the threshold is in the dB the spectrum axis is labelled in, so the
// line is the control. It used to be a percentage on a slider in the audio
// row, with nothing on screen to relate the number to.
await page.locator("summary", { hasText: "Audio controls" }).click();
await page.locator("#sdr-squelch-toggle").click();
// Auto parks it just above the noise the meter reports — not above the
// spectrum's noise floor, which sits anywhere from 1 dB below to 22 dB above
// the meter depending on span, bandwidth and decimation.
await page.locator("#sdr-squelch-auto").click();
await page.waitForTimeout(400);
const auto = await page.evaluate(() => Number(document.getElementById("sdr-squelch-db").value));
assert.ok(Math.abs(auto - (METER_DB + 5)) <= 1,
`auto put the threshold at ${auto} dB with the meter at ${METER_DB} dB`);
const squelchOn = await page.evaluate(() => {
const line = document.getElementById("spectrum-squelch-line");
return {
shown: getComputedStyle(line).display !== "none",
db: Number(document.getElementById("sdr-squelch-db").value),
label: Number(document.getElementById("spectrum-squelch-label").textContent),
toggle: document.getElementById("sdr-squelch-toggle").getAttribute("aria-pressed"),
top: Math.round(line.getBoundingClientRect().top),
};
});
assert.equal(squelchOn.shown, true, "the threshold line did not appear with the squelch on");
assert.equal(squelchOn.toggle, "true", "the SQL switch did not follow the squelch state");
assert.equal(squelchOn.label, squelchOn.db, "the line and the readout disagree on the threshold");
// Dragging the line down lowers the threshold and tells the server.
const submitted = [];
page.on("request", (request) => {
if (request.url().includes("/set_sdr_squelch")) {
submitted.push(Number(new URL(request.url()).searchParams.get("threshold_db")));
}
});
const grip = await page.locator("#spectrum-squelch-grip").boundingBox();
await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2);
await page.mouse.down();
await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2 + 60, { steps: 8 });
await page.mouse.up();
await page.waitForTimeout(400);
const dragged = await page.evaluate(() => ({
db: Number(document.getElementById("sdr-squelch-db").value),
label: Number(document.getElementById("spectrum-squelch-label").textContent),
top: Math.round(document.getElementById("spectrum-squelch-line").getBoundingClientRect().top),
}));
assert.ok(dragged.db < squelchOn.db,
`dragging down left the threshold at ${dragged.db} dB (was ${squelchOn.db})`);
assert.equal(dragged.label, dragged.db, "the line label did not follow the drag");
assert.ok(dragged.top > squelchOn.top, "the line did not move with the drag");
assert.ok(submitted.includes(dragged.db),
`the server was never told about ${dragged.db} dB (saw ${JSON.stringify(submitted)})`);
// Turning it off leaves the threshold alone — the old control conflated the
// two, so dropping to zero to listen threw the setting away.
await page.locator("#sdr-squelch-toggle").click();
await page.waitForTimeout(300);
const squelchOff = await page.evaluate(() => ({
db: Number(document.getElementById("sdr-squelch-db").value),
shown: getComputedStyle(document.getElementById("spectrum-squelch-line")).display !== "none",
dot: document.getElementById("sdr-squelch-state").dataset.state,
}));
assert.equal(squelchOff.db, dragged.db, "turning the squelch off discarded the threshold");
assert.equal(squelchOff.shown, false, "the line stayed up with the squelch off");
assert.equal(squelchOff.dot, "off", "the indicator did not follow the squelch off");
assert.deepEqual(runtimeErrors, []);
} finally {
await browser.close();
await fixture.close();
}
// The band plan is fetched once at startup, which can land before the session
// exists. It used to fail silently and never retry, so the allocations only
// turned up if the operator reloaded the page by hand.
const retryFixture = await startWebFixture({
spectrum: true,
bandplan: BANDPLAN,
bandplanEnabled: true,
bandplanUnauthorizedFirst: true,
});
const retry = await startBrowser(chromium);
try {
await retry.page.setViewportSize({ width: 1600, height: 950 });
await retry.page.goto(retryFixture.origin, { waitUntil: "domcontentloaded" });
await retry.page.locator("#spectrum-panel").waitFor({ state: "visible" });
await retry.page.waitForTimeout(2000);
const strip = await retry.page.evaluate(() => {
const element = document.getElementById("spectrum-bandplan-strip");
return { segments: element.children.length, empty: element.classList.contains("bp-empty") };
});
assert.ok(strip.segments > 0,
"the band plan never arrived after its first request was refused");
assert.equal(strip.empty, false, "the strip is still showing its placeholder");
} finally {
await retry.browser.close();
await retryFixture.close();
}
// The map's filter panel is a bar across the top of the map, not a window
// sitting on it: it has to stay one or two rows tall, span most of the width,
// and keep clear of what shares the map's corners — Leaflet's zoom buttons and
// the band legend. A panel that grew a column would cover the map it filters.
// Fullscreen and the filter toggle ride at the bar's right-hand end, so only
// the filters collapse: the bar itself has to survive Hide Filters, or there
// is nothing left to click to bring them back.
const mapFixture = await startWebFixture({ spectrum: true });
const mapView = await startBrowser(chromium);
try {
for (const width of [1600, 1200]) {
await mapView.page.setViewportSize({ width, height: 950 });
await mapView.page.goto(`${mapFixture.origin}/map`, { waitUntil: "domcontentloaded" });
await mapView.page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
await mapView.page.waitForTimeout(1200);
const bar = await mapView.page.evaluate(() => {
const box = (element) => (element ? element.getBoundingClientRect() : null);
const panel = document.querySelector(".map-overlay-panel");
const stage = box(document.getElementById("map-stage"));
const zoom = box(document.querySelector("#aprs-map .leaflet-control-zoom"));
const legend = box(document.getElementById("map-band-legend"));
const panelBox = box(panel);
const hits = (a, b) => !!a && !!b
&& a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
return {
widthPct: Math.round((panelBox.width / stage.width) * 100),
height: Math.round(panelBox.height),
outsideStage: panelBox.right > stage.right + 1 || panelBox.bottom > stage.bottom + 1,
hitsZoom: hits(panelBox, zoom),
hitsLegend: hits(panelBox, legend),
// Both controls belong to the bar now, not to a floating corner block.
actionsInBar: [...panel.querySelectorAll(".map-overlay-actions button")]
.map((button) => button.id).join(","),
// The rule dividing them from the filters is drawn on this block's
// edge, so the block has to run the height of the filters beside it —
// centred, it left the rule floating as a stub against a two-row bar.
actionsShort: Math.round(box(panel.querySelector(".map-overlay-filters")).height
- box(panel.querySelector(".map-overlay-actions")).height),
// Every label sits in a gutter of one width, so whichever group leads
// a row leads it from the same place: with the labels at their natural
// widths, a row starting "Search" began 10px off one starting "Filter".
rowStarts: (() => {
const leaders = new Map();
for (const group of panel.querySelectorAll(".map-overlay-filters .map-locator-filter-group")) {
const rect = box(group);
// Groups of a row differ in height, so bucket them by their middle.
const row = Math.round((rect.top + rect.height / 2) / 20);
if (!leaders.has(row) || rect.left < box(leaders.get(row)).left) leaders.set(row, group);
}
return [...leaders.values()].map((group) => Math.round(box(group.children[1]).left));
})(),
clipped: panel.scrollWidth > panel.clientWidth + 1 || panel.scrollHeight > panel.clientHeight + 1,
};
});
assert.ok(bar.widthPct >= 70, `the filter bar covers ${bar.widthPct}% of the map at ${width}px`);
assert.ok(bar.height <= 140, `the filter bar is ${bar.height}px tall at ${width}px, not a bar`);
assert.equal(bar.outsideStage, false, `the filter bar runs off the map at ${width}px`);
assert.equal(bar.hitsZoom, false, `the filter bar covers the zoom buttons at ${width}px`);
assert.equal(bar.actionsInBar, "map-fullscreen-btn,map-overlay-toggle-btn",
`the bar carries "${bar.actionsInBar}" at ${width}px`);
assert.ok(bar.actionsShort <= 1,
`the buttons' divider falls ${bar.actionsShort}px short of the bar at ${width}px`);
assert.equal(new Set(bar.rowStarts).size, 1,
`the bar's rows start at ${bar.rowStarts.join(", ")}px at ${width}px`);
assert.equal(bar.hitsLegend, false, `the filter bar covers the band legend at ${width}px`);
assert.equal(bar.clipped, false, `the filter bar is clipping its own controls at ${width}px`);
}
// Band chips only exist once something has been heard on a band. The bar
// used to explain "all bands visible by default" in a line of prose wedged
// between the chips and the next group, which is neither what a toolbar is
// for nor a width it can spare: an "All" chip says it and undoes a
// selection. Nothing is dimmed while nothing is filtered out, either — every
// chip used to come up greyed at the very moment all of them were showing.
await mapView.page.evaluate(() => {
const ts = Date.now();
for (const [grid, hz] of [["JO94", 14_074_000], ["JN48", 7_074_000], ["FN42", 21_074_000]]) {
window.trxPluginRuntime.dispatch("ft8",
{ message: `CQ TEST ${grid}`, grid, freq_hz: hz, snr_db: -8, ts_ms: ts, rig_id: "rig-a" });
}
});
await mapView.page.waitForTimeout(1500);
const chipRow = () => mapView.page.evaluate(() => {
const row = document.getElementById("map-locator-choice-filter");
const chips = [...row.querySelectorAll(".map-locator-chip")];
const all = row.querySelector(".map-locator-chip-all");
return {
bands: chips.filter((chip) => chip !== all).map((chip) => chip.textContent.trim()),
prose: row.querySelector(".map-locator-empty")?.textContent ?? null,
allActive: all?.classList.contains("is-active") ?? null,
dimmed: chips.filter((chip) => chip.classList.contains("is-inactive"))
.map((chip) => chip.textContent.trim()),
selected: chips.filter((chip) => chip.getAttribute("aria-pressed") === "true"
&& chip !== all).map((chip) => chip.textContent.trim()),
};
});
const unfiltered = await chipRow();
assert.ok(unfiltered.bands.includes("20m") && unfiltered.bands.includes("40m"),
`the chip row is showing ${unfiltered.bands.join(",")}`);
assert.equal(unfiltered.prose, null, `the bar is explaining itself in prose: "${unfiltered.prose}"`);
assert.equal(unfiltered.allActive, true, "All is not lit while every band is on the map");
assert.deepEqual(unfiltered.dimmed, [], `${unfiltered.dimmed.join(",")} came up dimmed with no filter set`);
await mapView.page.locator('#map-locator-choice-filter .map-locator-chip[data-filter-key="20m"]').click();
await mapView.page.waitForTimeout(300);
const filtered = await chipRow();
assert.deepEqual(filtered.selected, ["20m"], `picking 20m selected ${filtered.selected.join(",")}`);
assert.equal(filtered.allActive, false, "All stayed lit with a band picked out");
assert.ok(filtered.dimmed.includes("40m"), "the bands now filtered out are not shown as such");
// And back: All is how a selection is undone without hunting for the chips
// that are in it.
await mapView.page.locator('#map-locator-choice-filter .map-locator-chip-all').click();
await mapView.page.waitForTimeout(300);
const cleared = await chipRow();
assert.equal(cleared.allActive, true, "All did not clear the band selection");
assert.deepEqual(cleared.selected, [], `${cleared.selected.join(",")} survived All`);
assert.deepEqual(cleared.dimmed, [], `${cleared.dimmed.join(",")} stayed dimmed after All`);
// Hiding gives the map back, but leaves the bar itself — collapsed to its
// two controls at the right-hand edge — so the filters can be brought back.
await mapView.page.locator("#map-overlay-toggle-btn").click();
await mapView.page.waitForTimeout(400);
const toggled = await mapView.page.evaluate(() => {
const panel = document.querySelector(".map-overlay-panel");
const stage = document.getElementById("map-stage").getBoundingClientRect();
const panelBox = panel.getBoundingClientRect();
const visible = (id) => document.getElementById(id).getBoundingClientRect().width > 0;
return {
filtersHidden: panel.querySelector(".map-overlay-filters").classList.contains("is-hidden"),
widthPct: Math.round((panelBox.width / stage.width) * 100),
rightGap: Math.round(stage.right - panelBox.right),
label: document.getElementById("map-overlay-toggle-btn").textContent.trim(),
togglesVisible: visible("map-fullscreen-btn") && visible("map-overlay-toggle-btn"),
};
});
assert.equal(toggled.filtersHidden, true, "the filters stayed up after Hide Filters");
assert.equal(toggled.togglesVisible, true, "Hide Filters took its own button down with it");
assert.ok(toggled.widthPct < 30, `the collapsed bar still covers ${toggled.widthPct}% of the map`);
assert.ok(toggled.rightGap < 30, `the collapsed bar sits ${toggled.rightGap}px from the map's edge`);
assert.equal(toggled.label, "Show Filters", `the toggle still reads "${toggled.label}"`);
await mapView.page.locator("#map-overlay-toggle-btn").click();
await mapView.page.waitForTimeout(400);
const restored = await mapView.page.evaluate(() =>
!document.querySelector(".map-overlay-filters").classList.contains("is-hidden"));
assert.equal(restored, true, "Show Filters did not bring the filters back");
assert.deepEqual(mapView.runtimeErrors, []);
} finally {
await mapView.browser.close();
await mapFixture.close();
}
@@ -0,0 +1,407 @@
// 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"],
]);
// The history endpoint answers in CBOR (see api/decoder.rs), and the worker
// that reads it takes the body as CBOR unconditionally. Serving JSON here left
// every run exercising the client's retry path instead of its history path.
function encodeCbor(value) {
const chunks = [];
const head = (major, length) => {
if (length < 24) return Buffer.from([(major << 5) | length]);
if (length < 0x100) return Buffer.from([(major << 5) | 24, length]);
if (length < 0x10000) {
const buffer = Buffer.alloc(3);
buffer[0] = (major << 5) | 25;
buffer.writeUInt16BE(length, 1);
return buffer;
}
if (length < 0x1_0000_0000) {
const buffer = Buffer.alloc(5);
buffer[0] = (major << 5) | 26;
buffer.writeUInt32BE(length, 1);
return buffer;
}
// Timestamps are past 2^32 milliseconds, so the 64-bit form is needed.
const buffer = Buffer.alloc(9);
buffer[0] = (major << 5) | 27;
buffer.writeBigUInt64BE(BigInt(length), 1);
return buffer;
};
const write = (item) => {
if (item === null || item === undefined) { chunks.push(Buffer.from([0xf6])); return; }
if (typeof item === "boolean") { chunks.push(Buffer.from([item ? 0xf5 : 0xf4])); return; }
if (typeof item === "number") {
if (Number.isInteger(item) && item >= 0) { chunks.push(head(0, item)); return; }
if (Number.isInteger(item) && item < 0) { chunks.push(head(1, -item - 1)); return; }
const buffer = Buffer.alloc(9);
buffer[0] = 0xfb;
buffer.writeDoubleBE(item, 1);
chunks.push(buffer);
return;
}
if (typeof item === "string") {
const bytes = Buffer.from(item, "utf8");
chunks.push(head(3, bytes.length), bytes);
return;
}
if (Array.isArray(item)) {
chunks.push(head(4, item.length));
item.forEach(write);
return;
}
const entries = Object.entries(item);
chunks.push(head(5, entries.length));
for (const [key, entryValue] of entries) {
const keyBytes = Buffer.from(key, "utf8");
chunks.push(head(3, keyBytes.length), keyBytes);
write(entryValue);
}
};
write(value);
return Buffer.concat(chunks);
}
const HISTORY_GROUPS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
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,
tx = false,
meterDb = -70,
decodes = [],
mode = "FM",
history = {},
bookmarks = [],
bandplan = {},
bandplanEnabled = false,
bandplanUnauthorizedFirst = 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,
filter_controls: spectrum,
initialized: true,
latitude: null,
longitude: null,
}));
const rigsResponse = { rigs: rigItems, active_remote: "rig-a" };
const selectedRigs = [];
const state = { centerHz: 7074000 };
let bandplanServed = false;
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: ["LSB", "USB", "CW", "CWR", "AM", "SAM", "WFM", "FM", "AIS", "VDES", "DIG", "PKT"],
num_vfos: 1,
lock: false,
lockable: tx,
attenuator: false,
preamp: false,
rit: false,
rpt: false,
split: false,
tx,
tx_limit: tx,
vfo_switch: false,
filter_controls: spectrum,
signal_meter: spectrum,
},
},
status: { freq: { hz: 100_000_000 }, mode, tx_en: false, vfo: null, tx: null, rx: { sig: meterDb }, lock: null },
// Reported only by SDR backends, and what makes the client show the
// squelch control at all.
filter: spectrum
? {
bandwidth_hz: 12_000,
sdr_squelch_enabled: false,
sdr_squelch_threshold_db: -95,
sdr_agc_enabled: false,
}
: 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");
// Setting a control means the next status carries the new value, the way a
// real server echoes what it applied.
if (url.pathname === "/set_sdr_squelch") {
const enabled = url.searchParams.get("enabled") === "true";
const threshold = Number(url.searchParams.get("threshold_db"));
if (status.filter) {
status.filter.sdr_squelch_enabled = enabled;
if (Number.isFinite(threshold)) status.filter.sdr_squelch_threshold_db = threshold;
}
response.writeHead(200).end();
return;
}
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;
}
// Rejects the first band plan request the way the server did before it was
// classified as a public asset: the page asks for it at startup, which can
// land before the session exists.
if (bandplanUnauthorizedFirst && url.pathname === "/bandplan.json" && !bandplanServed) {
bandplanServed = true;
response.writeHead(401).end();
return;
}
if (jsonRoutes.has(url.pathname)) {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify(jsonRoutes.get(url.pathname)));
return;
}
// 200 means "audio is configured": the client hides the whole audio row —
// and the squelch control with it — when this 404s.
if (url.pathname === "/audio") {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ sample_rate: 48_000, channels: 1 }));
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;
}
// The meter streams like the server's does: the squelch reads its noise
// level from here, so a static snapshot would leave it nothing to measure.
if (url.pathname === "/meter") {
response.writeHead(200, {
"cache-control": "no-cache",
connection: "keep-alive",
"content-type": "text/event-stream",
});
const timer = setInterval(() => {
response.write(`data: ${JSON.stringify({ sig: meterDb })}\n\n`);
}, 120);
request.on("close", () => clearInterval(timer));
return;
}
// Decodes arrive on this stream in the server's own shape: a routing
// "type" naming the decoder, snake_case fields inside. The mini views, the
// map markers and the history all hang off it, and serving nothing left
// every one of them untested.
if (url.pathname === "/decode/history") {
const payload = Object.fromEntries(HISTORY_GROUPS.map((group) => [group, history[group] ?? []]));
response.writeHead(200, { "content-type": "application/cbor" });
response.end(encodeCbor(payload));
return;
}
if (url.pathname === "/decode") {
response.writeHead(200, {
"cache-control": "no-cache",
connection: "keep-alive",
"content-type": "text/event-stream",
});
response.write(": decode stream\n\n");
// Repeats: a live decoder keeps producing, and the views that collapse
// by station or vessel need more than one frame to behave like they do
// in front of a radio.
let sent = 0;
const timer = setInterval(() => {
if (!decodes.length) return;
const decode = decodes[sent++ % decodes.length];
// Stamped as they leave: the client prunes anything older than the
// retention window, so a fixed epoch would be dropped on arrival.
response.write(`data: ${JSON.stringify({ ts_ms: Date.now(), ...decode })}\n\n`);
}, 400);
request.on("close", () => clearInterval(timer));
return;
}
// The real server pushes rig state here every second or so; serving an
// open-but-silent stream meant nothing in the client's state-update path
// was ever exercised.
if (url.pathname === "/events") {
response.writeHead(200, {
"cache-control": "no-cache",
connection: "keep-alive",
"content-type": "text/event-stream",
});
// Varying, as a real one is: the client skips a frame identical to the
// last, so a repeated payload exercises none of the state-update path.
const frame = () => JSON.stringify({
...status,
status: { ...status.status, freq: { hz: 100_000_000 + (Date.now() % 1000) } },
});
response.write(`data: ${frame()}\n\n`);
const timer = setInterval(() => {
response.write(`data: ${frame()}\n\n`);
}, 700);
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 };
}
@@ -279,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::*;
@@ -307,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"
);
}
}
}
@@ -658,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)
@@ -504,7 +504,14 @@ impl RouteAccess {
return Self::Public;
}
// Static assets
// Static assets. The band plan is one of them: it is compiled into the
// binary and identical for every user, but ".json" is not an asset
// suffix, so it used to fall through to Control — leaving read-only
// users without a band plan, and everyone else without one whenever the
// page requested it before the session was established.
if path == "/bandplan.json" {
return Self::Public;
}
if path.starts_with("/style.css")
|| path.starts_with("/app.js")
|| path.ends_with(".js")
@@ -696,6 +703,12 @@ mod tests {
assert_eq!(RouteAccess::from_path("/auth/logout"), RouteAccess::Public);
assert_eq!(RouteAccess::from_path("/style.css"), RouteAccess::Public);
assert_eq!(RouteAccess::from_path("/app.js"), RouteAccess::Public);
// Static reference data, served to every role: ".json" is not in the
// asset suffix list, so this one has to be named.
assert_eq!(
RouteAccess::from_path("/bandplan.json"),
RouteAccess::Public
);
}
#[test]
@@ -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 {
@@ -837,12 +837,6 @@ impl ChannelDsp {
}
}
let signal_power = decimated
.iter()
.map(|s| s.re * s.re + s.im * s.im)
.sum::<f32>()
/ decimated.len() as f32;
let signal_db = 10.0 * signal_power.max(1e-12).log10();
const WFM_OUTPUT_GAIN: f32 = 0.50;
let mut audio = if let Some(decoder) = self.wfm_decoder.as_mut() {
let mut out = decoder.process_iq(decimated);
@@ -884,7 +878,14 @@ impl ChannelDsp {
raw
}
};
if !self.squelch.update(&self.mode, signal_db) {
// Against the meter reading, not the block level after the IQ AGC.
// The threshold arrives in the scale the operator sets it from — the
// S-meter and the spectrum — while the level measured here had been
// through the AGC, whose whole job is to hold it at a setpoint. For
// every mode that has one (FM, PKT, AIS, AM, SAM) the squelch was
// therefore comparing against a near-constant, and for the rest it was
// still off by the decimation correction the meter applies.
if !self.squelch.update(&self.mode, self.last_signal_db) {
audio.fill(0.0);
}
@@ -933,6 +934,93 @@ mod tests {
dsp.process_block(&block);
}
/// Feeds one signal twice, with the squelch threshold set from the channel's
/// own meter reading: 6 dB above it must gate the audio, 6 dB below it must
/// pass. FM runs an IQ AGC, so a squelch measured after that stage compares
/// against a level pinned near the AGC setpoint — some 20 dB adrift of the
/// scale the operator reads the threshold off, and open on plain noise.
#[test]
fn squelch_follows_the_meter_the_threshold_is_set_from() {
const AMPLITUDE: f32 = 0.0025;
let (pcm_tx, mut pcm_rx) = broadcast::channel::<Vec<f32>>(4096);
let (iq_tx, _iq_rx) = broadcast::channel::<Vec<Complex<f32>>>(8);
let mut dsp = ChannelDsp::new(
0.0,
&RigMode::FM,
48_000,
8_000,
1,
20,
12_000,
75,
true,
false,
VirtualSquelchConfig::default(),
NoiseBlankerConfig::default(),
pcm_tx,
iq_tx,
);
// A 1 kHz tone on the carrier, so an open gate is audibly non-zero and
// a closed one is unambiguously silent.
let mut phase = 0.0_f32;
let mut mod_phase = 0.0_f32;
let mut feed = |dsp: &mut ChannelDsp, blocks: usize| {
for _ in 0..blocks {
let mut block = Vec::with_capacity(4096);
for _ in 0..4096 {
mod_phase += std::f32::consts::TAU * 1_000.0 / 48_000.0;
phase += std::f32::consts::TAU * (3_000.0 * mod_phase.sin()) / 48_000.0;
block.push(Complex::new(
AMPLITUDE * phase.cos(),
AMPLITUDE * phase.sin(),
));
}
dsp.process_block(&block);
}
};
let drain = |rx: &mut broadcast::Receiver<Vec<f32>>| {
let mut audio = Vec::new();
while let Ok(frame) = rx.try_recv() {
audio.extend_from_slice(&frame);
}
audio
};
let peak = |audio: &[f32]| audio.iter().fold(0.0_f32, |acc, s| acc.max(s.abs()));
// Settle the meter on this signal, then read what the operator would.
feed(&mut dsp, 24);
let meter_db = dsp.signal_db();
assert!(
meter_db > -120.0,
"the meter never moved off its floor ({meter_db} dB)"
);
dsp.set_squelch(true, meter_db + 6.0);
let _ = drain(&mut pcm_rx);
feed(&mut dsp, 24);
let gated = drain(&mut pcm_rx);
assert!(!gated.is_empty(), "no audio frames were produced at all");
// From the second half on: the first frame out still carries the audio
// that was already buffered when the threshold changed.
assert_eq!(
peak(&gated[gated.len() / 2..]),
0.0,
"squelch set 6 dB above the meter ({meter_db} dB) still passed audio"
);
dsp.set_squelch(true, meter_db - 6.0);
let _ = drain(&mut pcm_rx);
feed(&mut dsp, 24);
let passed = drain(&mut pcm_rx);
assert!(!passed.is_empty(), "no audio frames were produced at all");
assert!(
peak(&passed[passed.len() / 2..]) > 0.0,
"squelch set 6 dB below the meter ({meter_db} dB) gated the audio"
);
}
#[test]
fn channel_dsp_set_mode() {
let (pcm_tx, _) = broadcast::channel::<Vec<f32>>(8);