Skip to content

feat(app,web): every repeater's reach at once, in its own hue, as the third stop of Node positions - #593

Open
khagele wants to merge 11 commits into
efiten:masterfrom
khagele:feat/549-reach
Open

feat(app,web): every repeater's reach at once, in its own hue, as the third stop of Node positions#593
khagele wants to merge 11 commits into
efiten:masterfrom
khagele:feat/549-reach

Conversation

@khagele

@khagele khagele commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Closes #549
Closes #603

Stacked on #592 (feat/465-maplibre-map) and #596 (feat/595-web-3d): the rays are a MapLibre line source in 2D and ride the map's 3D view. Merge those two first; this one then rebases to its own diff.

The problem

The map answers "where did I hear something" in one palette: every cell and point takes the tier colour of its best RSSI, whoever was heard there. With five repeaters along one road that is one red band, and the question at the end of a drive, which repeater covers what and where one hands over to the next, has no view. #549 asked for the reach of one node; #603 asks for all of them at once. The decisions are in the two issue threads and in docs/2026-09-08-coverage-overview.md.

My changes in this PR

  • A third stop on Node positions, on both surfaces: off / positions / positions + reach. The app's FAB cycles it with the ring showing the stop (nodeposmode.js); the map's filter panel has a segmented control, and ?nodepos=reach travels in the URL next to the ?nodepos=1 links already out there.
  • The stars (coverage.js, copied whole between app and web): one ray per hearing attributed to a repeater (classifyReception's rule: a Repeater role, a relay hash, a Discover or trace reply), from its advertised position (▲) or its RSSI estimate (●). Signal strength runs in the ray's width and opacity over -120 to -60 dBm. The dots of the hearings take the repeater's hue while the reach is on.
  • A hue per repeater id, fixed: twelve --ch-hue-* tokens in both sheets, the slot a hash of the id, so a repeater keeps its colour across sessions and between the app and the map. Two repeaters within 2 km never share a slot.
  • Two-way by opacity: a Discover or trace reply to our own ask draws at full opacity, a one-way hearing at 40%.
  • One selected: a tap on a repeater's ▲ or ●, or picking it as a target, keeps its star as it is with its name in a pill and drops the others to 25% in their own hue. A second tap, or a tap on bare map, clears it. With a target picked the stars still come from the whole window, so the neighbours stay up.
  • 3D from the mast (raylayer.js, copied whole): each ray starts 30 m above the repeater and lands at ground level at the hearing, on the terrain when the mesh is on. A MapLibre line lies on the ground, so this is a custom WebGL layer of 150 lines: each ray a screen-space quad between two mercator points with altitude. No deck.gl.
  • The popup "Show reach" buttons of the first cut of this PR are gone: the layer draws every repeater, and a companion has no star (it is not a repeater).
  • One changelog entry for both surfaces.

Verification

Suite Result
app vitest 939 passed, eslint clean, npm run build clean
web vitest 601 passed, eslint clean
web e2e, all 211, no retries 211 passed

New model tests, each mutation-checked: coverage.test.js (19: attribution, two-way, the hash and its neighbour rule, the strength ramp, the origin, the grouping, the dimming), raylayer.test.js (6: the vertex layout, the premultiplied colour, the terrain lift), nodeposmode.test.js (4). Five e2e tests in reach.spec.js: the rays per repeater and their hues, the two-way opacity, the dot colours, the selection by tap and by target, the 2D/3D hand-over of the rays, the three stops in the URL. nodepos.spec.js drives the new control. parity.test.js pins coverage.js and raylayer.js byte for byte.

In the browser: the app with 33 seeded receptions over three repeaters and a companion, in 2D, with a selection, and in 3D with the pillars; the map in 2D and 3D over the same set.

Not in this PR

🤖 Generated with Claude Code

khagele and others added 7 commits September 6, 2026 08:59
…D parity

The website drew its map with Leaflet on Carto raster tiles while the app
had moved to MapLibre on OpenFreeMap's vector styles (efiten#147): two engines,
two basemaps, two treatments of the same colour tokens, and no road to the
3D views the app has. map.js now builds its map from mapcore.js, a port of
the app's huntmap.js shape (the same styles, the bare fallback, the sky,
overlays re-added on every style load), with the collections the layers
read built in mapmodel.js, which the unit suite covers.

Points, hex cells, the CoreScope layers, Locate's cloud and dots, the node
positions and the ticker's ring are GeoJSON sources swapped whole; markers
and popups are MapLibre's. Zoom keeps Leaflet's numbers in shared links and
in the server's hex binning, converted at the edge, so no link and no cell
changes. The e2e fixtures answer the hosted style inline so load fires
offline, and the specs read sources through page hooks where they counted
Leaflet's SVG paths.

Closes efiten#465

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…exaggeration setting

Terrain was pulled in efiten#247 because the DEM source kept the map loading and
froze weak GPUs. efiten#394 decided it comes back on the AWS terrarium tiles,
capped at z10, exaggeration 7 by default because the Low Countries show no
relief otherwise. This adds the source and a hillshade layer under the
signal overlays, a sixth FAB that turns terrain on and off, and the
exaggeration steps in Settings with the slope caveat on the control.

The mesh waits: hillshade follows the button, setTerrain needs the button,
DEM tiles for the view and a 3D view, so a slow host leaves the map flat
rather than stalled. Leaving 3D drops the mesh before the tilt back, and
entering 3D sets it once the tilt has settled, since easeTo({pitch}) is a
no-op under a mesh.

Closes efiten#396

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Kasper (2026-09-06): the button is the opt-in, not the opt-out. The
loader answers on only for a stored '1'; the hint, the decision log and
the release note say so.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e light

The flat cell is the tier colour at the tier's opacity over the basemap, a
19% tint for faint; the bar drew the token opaque at 0.85 for every tier,
so a faint bar stood as solid purple on a pale patch, worse the weaker the
signal. On top, MapLibre's default light darkened every face by 7 to 17%.

The bar now takes the cell's tint pre-mixed over the theme background,
opaque, so shared walls keep depth-testing, and the style light sits at
0.15 on every style load. Measured pixel for pixel in both themes: the bar
is within a few levels of its cell at hot and at faint. The theme joins the
hex cache key so a switch rebuilds the colours.

Closes efiten#412

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Kasper (2026-09-06): 3D takes the terrain and the exaggeration along at
once, the same rule the map follows in efiten#595. The sixth FAB goes, with
its loader, its CSS and its place in the onboarding ring; huntmap's plan
reads mode3D as the switch and the map only takes the exaggeration.
Settings keeps the exaggeration. Decision log and release note follow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… buildings, terrain and rotation

Closes efiten#595. The map could pitch since efiten#465 and nothing reached it.

A view button under the zoom and the compass eases to the app's pitch;
hex cells stand as bars in their own tint (efiten#412), receptions as octagon
pillars collapsed where they coincide (efiten#402), buildings rise from the
hosted style's source. 3D carries the terrain on the site (Kasper,
2026-09-06): hillshade and the mesh come with the view at the Settings
exaggeration. Rotation and tilt on; MapLibre's compass in the house
colours. ?view=3d, ?pitch=, ?bearing= and ?exag= travel in the URL.

signal.js, maplayers.js, pointmarker.js and terrain.js are the app's
files whole in web/, pinned byte for byte. --ch-building added to both
themes: undefined it was '', which MapLibre rejects and then skips the
layer silently.

Two e2e races fixed: popover's on-screen read polls, onboarding:233
reads one snapshot.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A guest deep-linking ?nodepos=1 got the "needs a verified member
account" key from the role gate, which also unchecks the box; the
refresh the gate then asks for redrew from the box and took the key
back 250 ms later. The ask is now held below member until the role can
see the layer. The efiten#376 test arms an observer and forces a draw, so a
flash no longer passes it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
khagele and others added 4 commits September 8, 2026 16:06
Filtering to one repeater showed where it was heard and nothing about how
far it reaches. The map now draws one line from the node's position to
every reception attributed to it, in that reception's tier colour: strong
near the node, weak at the edge, with the gaps where nobody drove. The hub
is the registry's advertised position when the resolver has one and the
RSSI estimate otherwise, marked so the two never look alike. On for every
picked node, and toggled from a point, sighting or registry popup for any
node heard, companions included. A lower bound from where hunters drove,
on an unauthenticated identity; both caveats stay on the hub and the
button. Decisions after design rounds R13 and R14.

Closes efiten#549

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e third stop of Node positions

The star of one node (efiten#549) becomes the coverage overview (efiten#603): one ray
per hearing attributed to a repeater, from its advertised position or its
RSSI estimate, in a hue fixed per id, the signal strength in the ray's
width and opacity. A two-way hearing (a Discover or trace reply) draws at
full opacity, a one-way one at 40%. A tap on a repeater's marker, or a
picked target, selects that star and drops the others to 25% in their own
hue. In 3D the rays leave the mast 30 m up, drawn by a custom WebGL layer
(raylayer.js), since a MapLibre line lies on the ground.

Node positions in the filter panel is a three-stop segmented control:
Off / Positions / Positions + reach. The popup Show reach buttons go.

coverage.js and raylayer.js are copied whole between app and web
(parity.test.js), with the twelve --ch-hue-* tokens in both sheets.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… reach in its own hue, on the ground and from the mast in 3D

The app draws the same coverage as the map (efiten#603): the FAB cycles off /
positions / positions + reach with the ring showing the stop, the stars are
built from the plotted window (the sender-free window while a target is
picked, so the other stars stay up at a quarter), a tap on a ▲ or ● selects
one, and in 3D the rays leave the mast through the same custom layer.

One changelog entry for both surfaces replaces the map-only reach entry of
efiten#549, and docs/2026-09-08-coverage-overview.md records the decisions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The pill read the checkbox that efiten#603 replaced, so the stop never counted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@khagele khagele changed the title feat(web): draw a node's reach as the star of its direct hearings feat(app,web): every repeater's reach at once, in its own hue, as the third stop of Node positions Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant