Skip to content

feat(web): export the reach of one repeater as a 1200×1200 PNG - #726

Open
khagele wants to merge 1 commit into
efiten:masterfrom
khagele:feat/720-export-reach
Open

khagele wants to merge 1 commit into
efiten:masterfrom
khagele:feat/720-export-reach

Conversation

@khagele

@khagele khagele commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Closes #720

Stacked on #724 and #725: the first two commits are those PRs, the last one is this PR.

What it is

A second item in the Export sheet, Reach of one repeater, draws the selected star as a 1200×1200 PNG. It shows where the repeater was heard (the export's cell of #725, res 8 today, in the RSSI tiers), where the same hunters drove without hearing it (grey), and where it likely reaches in between (lighter cells). You select a star by tapping its ▲ or picking it as target. Without a selection the item is disabled and says what to do. A merged target row whose hearings form two stars is one pick: the star with the most hearings is drawn. The cells follow the map's type and hop filters, so with a type filter on a grey cell means not heard as that packet type (both Kasper, 30 September).

What is filled in

Thought from the repeater, decided with Kasper on renders from real data:

  • One line from the repeater to the strongest hearing in each heard cell.
  • A line is reached up to the next heard cell, not across a silent one. Heard at 1, 2 and 3, not at 4 and 5, and again at 6, 7 and 8 fills 1 to 3 and 6 to 8.
  • A line to a cell that a longer line passes through is part of that longer line.
  • Between neighbouring lines, the area where both are reached is filled when their ends are at most 2 km apart and no silent cell lies in it.
  • Terrain: the Terrarium tiles of the 3D view at zoom 10. A cell is filled only where a 30 m mast (RAY_ALT_M) sees 1.5 m above it, with the earth's radius times 4/3 and no Fresnel zone. When the tiles cannot all be read, the lines alone decide.

The rules, the numbers and what was tried: docs/2026-09-27-export-reach.md.

Relief in both exports

Both exports shade the relief from the same tiles, at the exaggeration from Settings, as the 3D view does. Beside the scale bar they say "relief 7× exaggerated", and nothing at 1×, where the relief is true to scale. The tiles' attribution joins the map's. This touches the renderer from #725, so it lives here, with the terrain.

Measured on real data

Receptions around Nijmegen and Arnhem, 8 August to 8 September 2026:

Repeater Heard cells Filled, flat ground Filled, with terrain
NL-NIJ-Dikkeboom 85 45 40
Stollenberg 23 83 77

Of the 29 stars with at least 20 hearings, the terrain removes cells for 4. The decoded ground matches known heights: the Waal at Nijmegen 6 m, the Valkhof 28 m, Berg en Dal 96 m.

Code

  • web/exportreach.js: the model, in small pure functions.
  • web/exportterrain.js: fetches and reads the tiles. It returns nothing when a tile is missing, or when the browser alters what a canvas reads back (the check MapLibre does in isOffscreenCanvasDistorted).
  • web/exportrender.js, web/exportsheet.js, web/map.js: the two exports share the offscreen map, the band and the sheet.

Verification

  • vitest 832 passed, eslint clean. App (signal.js): 1637 passed, eslint, build.
  • Playwright 304 passed. The new e2e test checks that the lines fill without the tiles, and that a repeater in a pit of 500 m ground fills fewer cells.
  • Every fill rule is mutation-checked: without it, a test goes red.
  • Rendered from real receptions for both repeaters above.

Not in it

🤖 Generated with Claude Code

@khagele

khagele commented Sep 28, 2026

Copy link
Copy Markdown
Contributor Author

Rebased on #725 (af6aad6) and amended:

  • The 30 km rule of Repeaters heard applies here too: a repeater whose nearest hearing is farther than FAR_KM from its position gets a message instead of a picture fitted to a position that cannot be right. nearestKm in exportheard.js, shared by both exports.
  • The legend's tiers come from TIER_BANDS in signal.js (both copies), which rssiTier now reads too, so the words on the picture cannot drift from the map's colours. tierLegend in exportheard.js, tested against rssiTier.
  • lineOfSight treats ground it could not read (null, off the tiles) as open rather than as sea level.
  • A relay-hash star is titled and named #id, as in feat(web): export the repeaters heard in view as a 1200×1200 PNG #725.

Each new test red with its line mutated. Web: 820 tests, eslint, e2e/export.spec.js 7 passed. App (signal.js is a copied file): 1630 tests, eslint, build.

@efiten

efiten commented Sep 30, 2026

Copy link
Copy Markdown
Owner

Review of this PR's own commit (af6aad6): one point to change, the #734 dependency, one question, small ones. Found by reading the code, not run.

1. The title and file name can show a 2/3-byte relay id as a bare resolver name (change requested)
reachModel takes model.name from nameOf (web/map.js:1111), which falls back to cachedName(id) and accepts ids of 4 hex characters and up. A relay-hash star then gets a 36 px title and a file name with the resolver's name, without ~ and without the AGENTS.md §7 rule-2 check. The unit test "names a relay-hash star with a #" covers 1-byte ids only. The fix for #725 point 1 covers this one too.

2. CELL_RES and #734
Same as #725 point 2: reachModel defaults to CELL_RES, and walk's 80 m sampling and the area sampler's 0.0011°/0.0017° steps assume a cell of today's res-8 size. Deriving the steps from the cell size keeps them right when the grid changes.

3. Question: active type and hop filters
coveragePoints drops the sender filter for the mapped cells, but types and hops stay. With a type filter on, "silent" means "not heard as that packet type". Intended, or should the mapped cells drop those filters too?

4. Small ones

  • loadTerrain (web/exportterrain.js) fetches tiles without a timeout, so a hanging DEM host keeps the export on "Drawing the map…". AbortSignal.timeout(...) would fall through to the lines-only path that already exists.
  • TIER_BANDS sits between rssiTier's doc comment and the function (both signal.js copies), so that comment now reads as if it describes the constant.
  • picked.length !== 1 counts stars, so a merged target row whose prefix variants form two stars gets "More than one repeater is selected".

@khagele

khagele commented Sep 30, 2026

Copy link
Copy Markdown
Contributor Author

Pushed b22cbd1 for the review, rebuilt on #725 (94c0bd9). Per point:

  1. Relay-hash title and file name. starName in exportheard.js now names a star for both exports: the registry's name, the resolver's for a key only, #id for a hash. reachModel takes it, so the title and the file name follow. Test: a 2-byte path_hash star with a cached name is titled #db11, a Discover star takes its cached name. Red when the resolver is asked for every star.
  2. CELL_RES and feat(app,web): ease what the map shows by zoom, on one hex system #734. The cell comes from feat(web): export the repeaters heard in view as a 1200×1200 PNG #725's CELL_SIZE. sampleSteps(res) scales today's steps (80 m along a line, 0.0011° by 0.0017° over an area) by the cell's size, and a test keeps each step inside the cell's inner radius at 52°N for res 6 to 12. The steps hold up to about 65°N.
  3. Type and hop filters. Intended (Kasper): with a type filter on, a grey cell means not heard as that packet type. The decision log says so.
  4. Small ones.
    • loadTerrain: each tile fetch has an 8 s AbortSignal.timeout; a late tile counts as missing and the export fills along the lines alone. Glue, not tested.
    • TIER_BANDS sits above rssiTier's comment in both signal.js copies.
    • One merged target row whose hearings form two stars is one pick: pickReachStar draws the star with the most hearings (Kasper). Two rows still get "More than one repeater is selected". Tested, red with the grouping removed.

Web: 832 tests, eslint, Playwright 304 passed. App (signal.js): 1637 tests, eslint, build.

efiten added a commit that referenced this pull request Sep 30, 2026
What the map shows at a zoom is now a share the style eases, where it
was a step in JS. App and website draw their cells by one rule, the
server's.

## What was there

Five rules changed the map in one frame when a zoom threshold was
crossed: the hex resolution bands, the outlines of earlier rides, the
cell names, and on the website the server's resolution per zoom level.
Hex and points together was a manual view, `both`. The app binned in ten
bands of a fixed size, the server in one size per zoom level.

## What this PR does

The numbers are Kasper's. He picked them on 29 September 2026 in a
mockup with 4559 receptions between Nijmegen and Arnhem, by pinching
through it on a phone, in 2D and in 3D. They are recorded on #634 and in
`docs/2026-09-29-zoom-transitions.md`.

| | 2D | 3D |
|---|---|---|
| Points of this ride | fade in from zoom 13.25 to 18.25 | one step at
zoom 19 |
| Hex | stays at 100% | 95% of the bar height from zoom 19 |
| Earlier rides (app) | fade in from zoom 11.25 to 18.75 | with the
points, at zoom 19 |
| Cell names (app) | fade in from zoom 15.5 to 16.5 | not drawn in 3D,
as before |
| Two cell sizes | blend over 0.95 zoom level | blend over 0.75 zoom
level |

- **One hex system.** `app/src/hexgrid.js` takes the server's sizes: one
per zoom level, about 28 px tall. The ten bands in metres are gone.
- **Two cell sizes at a time.** A size hands over to its neighbour
around every change, and the two shares add up to 1. Two hex layers hold
them, the even resolutions in one and the odd in the other. The pair
moves up at a whole zoom, where the size that stays is at 100% and the
other two are at 0.
- **The website asks for two sizes**, one heatmap request each. Where
the server answers both with the same size, that size is drawn once and
stays in full.
- **Auto replaces Both**, in the app's view cycle and in the website's
segmented control. A stored view or a link that says `both` reads as
auto.
- **In 3D the pillars wait for zoom 19.** Up to there the hex is drawn
as bars, also in auto. #266 drew it flat under the pillars.
- **Nothing is built for a share of 0.** In auto the points are not
built below their zoom, and the website does not fetch them.
- **A cell names one node.** A cell is 28 px tall and about 24 wide at
every zoom, which holds four characters. The label is the prefix of the
node heard last, with the count of the others under it. It was three
prefixes on one line.

`zoomfade.js` holds the numbers and builds the expressions. It is one
file on both surfaces, pinned in `web/parity.test.js`.

## Measured in MapLibre 4.7.1

- `['zoom']` is only accepted as the input of a top-level `interpolate`
or `step`. The per-feature value (`['get', 'op']`, `['get', 'height']`)
rides in each stop's output.
- A `fill-extrusion` of height 0 still draws an opaque polygon on the
ground. With several cell sizes stacked, the ones with nothing to show
covered the one that had. A layer with nothing to show is switched off
by a `step` on `fill-extrusion-opacity`.
- In 3D a share is spent on the bar's height. Alpha dissolves toward
black, as `docs/2026-09-04-new-versus-old-on-the-map.md` records.

## Verification

- app vitest 1661 of 1661, web vitest 810 of 810, eslint clean in both,
the app builds.
- `zoomfade.test.js`: the shares at their ends and midpoints, the pair
adding up to 1 at every 0.05 zoom for both widths, no third size
anywhere, and stops that rise in every expression for every resolution
and view.
- `web/e2e/zoomfade.spec.js`: five cases. Two fail when the rule is
taken out: asking for one size only, and fetching the points at every
zoom.
- The app's map, fed the 4559 receptions through a throwaway page: at
zoom 13.3 two sizes are up (646 and 1042 cells), no points are built
below 13.25, in 3D the pillars are built from zoom 19 (1882 after the
collapse). No map errors.
- Full e2e locally: 300 of 302 at a load average between 26 and 48. The
two that failed, `targetpicker.spec.js:198` and `ui.spec.js:502`, depend
on timing. The second passed 4 of 4 when run alone.
- Two existing e2e cases changed with the rules: the click on a point
inside a cell runs at a zoom where auto shows points, and the 3D case
asserts bars and no pillars below zoom 19.

Not verified: the motion itself. The browser pane was in the background,
so the checks are still frames and measurements.

## Not in this PR

- **The server is unchanged.** It stops at resolution 18. The app goes
to 21, so a cell stays 28 px up to zoom 20 for the walk-in. On the
website a cell grows past 28 px above zoom 17. Kasper chose this on 29
September 2026.
- **The noise layer (#410) draws one size**, the one in full at the
zoom, without a blend.
- **The exports (#725, #726, #729) add `web/hexgrid.js`.** This PR does
not touch that file. Whichever merges second has to check the cell sizes
agree.

Closes #634

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: efiten <erwin.fiten@gmail.com>
@efiten

efiten commented Sep 30, 2026

Copy link
Copy Markdown
Owner

#724 and #725 are merged (15a6ada, d6186bc), and so is #734 (4c2a57b). This one needs a rebase onto master before it can go in.

What happens when b22cbd1 is applied to master
Only the two changelog copies conflict. But with #734's hexSizeForRes, CELL_RES resolves to 13 (267.5 Mercator units) instead of 8, and 12 tests in exportreach.test.js fail: they call hexCellAt(..., 8) and res: 9 literally, for example expected [ '13:-7032:16923' ] to deeply equal [ '8:-220:529' ], and the lattice cases ("fills the wedge…", "leaves the gap…") no longer hold at the new cell size. Web vitest on that tree: 896 passed, 12 failed, all in exportreach.test.js. App: 1707 passed.

How #725 was merged, in case the same overlap shows up here (merge commit 4a661d6 on your branch):

Question: should the reach fixtures follow CELL_RES (and CELL_RES + 1 for the lattice), or do the fill rules need a second look at 267.5 units first?

A hunter who wants to show how far one repeater reaches has only the map,
which draws every repeater at once. A second item in the Export sheet,
Reach of one repeater, draws the selected star on the res-8 grid: where it
was heard, in the RSSI tiers, where the same hunters drove without hearing
it, and where it likely reaches in between.

What is filled in is thought from the repeater. Each heard cell gets a line
from the repeater. A line is reached up to the next heard cell and not
across a silent one: a shadow darkens it until it is heard again. Between
neighbouring lines the area where both are reached is filled, when their
ends are at most 2 km apart and no silent cell lies in it. The ground comes
from the Terrarium tiles the 3D view draws, at zoom 10: a cell is filled
only where a 30 m mast sees it over the ground and the earth's bulge. When
the tiles cannot all be read, the lines alone decide.

Both exports now shade the relief from the same tiles, at the exaggeration
from Settings as the 3D view does, and say "relief 7× exaggerated" beside
the scale bar. The Export sheet takes a list of items, and the two exports
share the offscreen map and the band. Decided with Kasper on renders from real data;
recorded in docs/2026-09-27-export-reach.md.

Closes efiten#720

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@khagele
khagele force-pushed the feat/720-export-reach branch from b22cbd1 to 957b144 Compare October 1, 2026 06:03
@khagele

khagele commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto master (957b144), as one commit on top of #725's merge. On the question: the fixtures follow CELL_RES. The fill rules work on distances and lines (the 2 km edge, the stretches, the terrain), not on a cell size, so res 13 draws the same rules finer.

  • exportreach.test.js uses CELL_RES where it said 8, CELL_RES + 1 for the lattice where it said 9, and cellsAlong steps by sampleSteps. 11 of the 12 failures were only that.
  • The twelfth was the wedge fixture: two hearings meant to share a cell sat 25 m apart and fall in two cells at res 13. They now share a position.
  • Checked that the lattice cases still measure at the smaller cell: with the shadow break removed 3 tests go red, with the 2 km edge removed 1.
  • The comments and both decision logs now say res 13 since feat(app,web): ease what the map shows by zoom, on one hex system #734, about 330 m point to point, where they said res 8. exportheard.js was merged with the old text, so that fix rides here.

Web: 908 tests, eslint, Playwright 328 passed. App: 1707 tests, build.

@efiten

efiten commented Oct 2, 2026

Copy link
Copy Markdown
Owner

Review of 957b144 (base 77d4e5a). The four points of the earlier review are resolved and tested. The res-13 rebase holds: the wedge fixture change is explained, and the lattice tests still discriminate. Run on 957b144: web 908 tests, eslint, e2e/export.spec.js 7 passed, app 1707 tests. The branch merges cleanly onto master c6fc9a9. One point to change before merge, plus small ones.

1. With a target picked, the subtitle does not report a cap or a failure of the fetch the picture is built from (change requested)
exportStars (web/map.js:1195) returns capped: pointsRes.capped, the sender-filtered fetch. With a target picked, the stars and the mapped cells come from coveragePoints' unfiltered fetch (map.js:1257-1261). That fetch reaches the 25,000 cap much sooner, and its capped is dropped. When it throws, coveragePoints silently falls back to the target's own hearings. The picture then has no grey cells, the shadow rule never applies, and the band says nothing. #720 asks for this: "When that fetch is truncated, the subtitle says so."
Proposed: let coveragePoints (or an export-only variant) return { points, capped, failed }. The subline then reports the cap of the fetch the picture uses, and says so when the mapped cells are missing. heardExport has the same capped mismatch since #666. Fix it here or in a separate issue, your call.

2. Small ones (found by reading; the title width was measured by the reviewer in Chromium)

  • drawBand (web/exportrender.js:333-334) draws the title without fitText, while the numbers start at about x=727. A 31-character name of wide letters ends at about x=1112 and runs into the numbers. fitText(title, numbersLeft - L - gap, ...).
  • exportFileName (web/exportheard.js:123) turns "Zürich-Ost" into z-rich-ost, and a name in a non-Latin script into an empty subject. normalize('NFD').replace(/\p{M}/gu, '') before the ASCII filter.
  • The sampleSteps test "at every resolution the grid has" loops over [6, 8, 10, 12] and skips CELL_RES and CELL_RES + 1, the two the export uses.
  • web/exportreach.js:369: the reachModel docstring has a badly reflowed line ("named by starName (nameOf, cachedNameOf); elevationAt(lat,").

Measured, for information only: reachModel at res 13 with synthetic data takes 95 ms for 400 hearings and 20k mapped points, and 1.96 s with 17k grey cells for 1500 hearings and 25k points.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

web: one repeater's reach, heard and not heard, cannot be passed on as a picture

2 participants