Skip to content

feat(web): export the repeaters heard in view as a 1200×1200 PNG - #725

Merged
efiten merged 3 commits into
efiten:masterfrom
khagele:feat/666-export-repeaters-heard
Sep 30, 2026
Merged

efiten merged 3 commits into
efiten:masterfrom
khagele:feat/666-export-repeaters-heard

Conversation

@khagele

@khagele khagele commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Closes #666

Builds on #724: the first commit is that PR (a Discover-heard repeater's star under its own ▲). Review the second; it rebases to one commit once #724 is in.

What

An Export button in #bar, next to Start mapping, opens a sheet with the first export of #665: Repeaters heard, the map as it stands (filters, window, hunters) as a 1200×1200 PNG, saved as mesh-hunter-repeaters-heard-YYYY-MM-DD.png. Below 640px the button moves into the menu with Start mapping and Log in (barnarrow.js, #561).

The picture shows the stars the reach layer would draw for the view, from the same registry slice, window receptions and attribution, whether the layer is on or not:

  • every repeater in its own hue (assignHues), each ray's strength as a gradient within it: strong full and thick, weak lighter and thinner, with the map's width and opacity (rayStyle);
  • a dot per hearing, the ▲ or ● the star hangs from, and the names that fit (most-heard first, dropped on overlap, never the ▲);
  • the mapped cells (the resolution closest to 360 Mercator units, res 8 today) and the route in grey; a star whose nearest hearing is more than 30 km off is left out and named;
  • a band with the mark, the title, the window as dates, up to three hunters, three numbers (repeaters, receptions, farthest) and a legend;
  • always the light theme.

Decided with Kasper on mockups rendered from a real /api/points export; what was tried and not chosen (signal-tier colours, #720's hex fill over every repeater) is in docs/2026-09-27-export-repeaters-heard.md.

How

  • exportheard.js: the rules (which stars, the numbers, the route, the cells, label placement, the texts), unit-tested.
  • exportrender.js: a second MapLibre instance offscreen at 1200×1000 with preserveDrawingBuffer (the live map is untouched), then a canvas for the glyphs, names, scale bar and band. The bare background stands in when the hosted style does not load.
  • exportsheet.js: the sheet, on the .lc-modal shell with the settings sheet's focus rules.
  • Members only, like the node positions it is made of; anyone else sees the item disabled with the reason.
  • hexgrid.js is copied whole from the app and added to the parity list, which web: one repeater's reach, heard and not heard, cannot be passed on as a picture #720 needs too.

Verification

  • exportheard.test.js (18): what is drawn, the 30 km rule, the numbers, the route breaks, the cells, label placement, tint, window text, hunters, file name, scale bar. Five rules mutated, each caught.
  • e2e/export.spec.js: a member downloads a PNG of 1200×1200 with the right name; a guest sees the reason and a disabled button; Escape closes and hands focus back. Red with the sheet unwired.
  • Rendered in Chromium with the real export of 8 Aug to 7 Sep around Nijmegen: 87 repeaters, 4501 receptions.
  • web: 789 tests green, eslint clean, full e2e 301 passed

🤖 Generated with Claude Code

@khagele

khagele commented Sep 28, 2026

Copy link
Copy Markdown
Contributor Author

Amended (6b1de2d) after a self-review:

  • A star without a registry name reads as an id, as the ticker reads it (AGENTS.md §5.4 item 6): a relay hash as #db, a key as its 8-hex prefix. idLabel in exportheard.js, tested; the picture and the band use it.
  • HUE_COUNT comes from coverage.js, the light style from mapcore.js (STYLES, now exported), and kmBetween is locate.js's haversineM in km.
  • The halos and glyph strokes take the paper's colour (tokens.bg) instead of #ffffff.
  • Closing the sheet below 900px hands the focus to the settings button, since the Export button sits in the menu that closed. The e2e test at 768px checks it.

Web: 783 tests, eslint, e2e/export.spec.js 4 passed.

@efiten

efiten commented Sep 30, 2026

Copy link
Copy Markdown
Owner

Review of this PR's own commit (6b1de2d): two points to change, small ones below. Found by reading the code, not run.

1. A relay-hash star can be printed with a bare resolver name (change requested)
nameOf in heardExport (web/map.js:1109) falls back to cachedName(id). A rule-2 star (no candidate in reach) is keyed by its raw relay id, and RESOLVABLE (names.js:24) accepts 2-byte ids, which the points layer resolves. The picture then prints that name without ~ and without the prefixKnown check that AGENTS.md §7 rule 2 asks for. The result also depends on what the points layer resolved earlier in the session.
Proposed: for hash-kind stars use idLabel (or the name with ~, only when prefixKnown is false); keep cachedName for discover and advert keys. Plus a test with a 2-byte path_hash star and a cached name.

2. CELL_RES = 8 depends on what res 8 means (change requested)
web/exportheard.js:30. web/hexgrid.js is pinned byte-identical to app/src/hexgrid.js, and #734 changes hexSizeForRes to the server formula, where res 8 is about 8561 Mercator units instead of 360. After #734 the mapped cells become about 24× larger unless CELL_RES changes with it (res 12 is the closest to today's size). The comment's "about 600 m across" is in Mercator metres; on the ground at 52°N res 8 is about 445 m point to point.
Proposed: define the export cell by size (the res whose hexSizeForRes is closest to a target) and pin that size in a test, so a grid change turns red.

3. Small ones

  • idLabel (exportheard.js:41-44): a star keyed by a nameless attributed node's full pubkey can take sender_kind path_hash from its first point and print # plus 64 hex (§5.4 item 6).
  • A failed registry fetch leaves nodes = [], and the picture draws every star as an estimate with no names; the band does not say so.
  • The band's off line (all off-map names joined) is not measured and runs past 1200 px with several far stars.
  • web/index.html:287-289 still says "below 640px", while the item moves at 900 px.

…tion

A Discover reply carries a prefix of the responder's key. The node layer
paired it with the registry node whose key it alone starts (efiten#661); the reach
stars did not, and looked the prefix up as a whole key, which it never is.
So the repeater drew its ▲ and, beside it, a star hung from its RSSI estimate
with the prefix as its name.

The pairing is now one function, registryMatcher in nodelayer.js, which
groupSenderPointsForNodes and coverageStars both use; AGENTS.md §7 names
both. On the data of 8 Aug to 7 Sep around Nijmegen, 63 of 71 stars now hang
from their ▲, where 5 of 66 did.

Closes efiten#723

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@khagele
khagele force-pushed the feat/666-export-repeaters-heard branch from 6b1de2d to 81324ca Compare September 30, 2026 17:34
A hunter could not pass on what a drive heard: the reach layer draws every
repeater's star, but the only thing to share was a URL that needs an account
and the same filters. An Export button in the top bar opens a sheet with
the first export, Repeaters heard. Below 900px the button is in the menu:
between 641 and 900px its label wrapped the bar into another row.

It draws the stars the reach layer would draw for the view, from the same
registry slice, window and attribution, on the light theme: every repeater
in its own hue with each ray's strength as a gradient within it, a dot per
hearing, the ▲ or ● it hangs from, the names that fit, the mapped cells and
the route in grey, and a band with the window, the hunters and three
numbers. A second MapLibre instance renders offscreen, so the live map is
untouched. hexgrid.js is copied whole from the app and pinned.

Decided with Kasper on mockups from real data; recorded in
docs/2026-09-27-export-repeaters-heard.md.

Closes efiten#666

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@khagele
khagele force-pushed the feat/666-export-repeaters-heard branch from 81324ca to 94c0bd9 Compare September 30, 2026 17:34
@khagele

khagele commented Sep 30, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 94c0bd9 for the review, rebuilt on #724 (82588a0). Per point:

  1. Relay-hash star named by the resolver. heardModel takes the registry name (nameOf) and the resolver's (cachedNameOf) apart, and asks the resolver for a key only; a hash reads #id. Test: a 2-byte path_hash star with a cached name reads #db11, while a Discover key next to it takes its cached name. Red when the resolver is asked for every star.
  2. CELL_RES. The export cell is now a size: CELL_SIZE 360 Mercator units, and CELL_RES is the resolution whose hexSizeForRes is closest to it (res 8 today). A test pins both, plus a factor of √2 around the size, so a grid change moves the resolution rather than the cell. With feat(app,web): ease what the map shows by zoom, on one hex system #734's formula that is res 13 (268 units, 535 point to point), not res 12 (535, 1070 point to point): 13 is the closer one. The comment and the decision log now say "about 445 m point to point at 52°N".
  3. Small ones.
    • idLabel: a star reads as a hash only when its kind is a hash kind and its id is 3 bytes at most, so a nameless node's full key reads as its prefix. Tested with a path_hash first hearing.
    • A failed registry fetch adds "node list unavailable, every position estimated" to the band. Glue in map.js, not tested.
    • The "left off" line is cut with an ellipsis at the band's width (fitText, tested).
    • web/index.html says 900px.

Web: 789 tests, eslint, Playwright 301 passed. #726 and #729 are rebuilt on this one next.

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>
Resolves the overlap with efiten#732 (narrow line 640 -> 767) and efiten#734 (hexgrid
sizes, zoomfade parity):

- barnarrow.js keeps efiten#725's per-control breakpoint (Export leaves below
  900px) with NARROW_PX = 767; NARROW_MEDIA is built from it.
- barnarrow.test.js: the stylesheet-width test accepts NARROW_MEDIA's width
  or a width a control leaves earlier at (Export's 900).
- parity.test.js lists both zoomfade.js and hexgrid.js; web/hexgrid.js is
  recopied from app/src/hexgrid.js after efiten#734.
- style.css and export.spec.js comments say 768 instead of 640.
@efiten
efiten merged commit d6186bc into efiten:master Sep 30, 2026
6 checks passed
@github-actions github-actions Bot mentioned this pull request Sep 30, 2026
efiten pushed a commit that referenced this pull request Sep 30, 2026
🤖 I have created a release *beep* *boop*
---


<details><summary>app: 1.30.0</summary>

##
[1.30.0](app-v1.29.1...app-v1.30.0)
(2026-09-30)


### Features

* **app,web:** ease what the map shows by zoom, on one hex system
([#734](#734))
([4c2a57b](4c2a57b))
* **app,web:** name a relay on the map the way the app does
([#733](#733))
([a9aa01a](a9aa01a))
* **app:** send one broker to several collectors under one switch
([#722](#722))
([8c6da30](8c6da30))
* **web:** export the repeaters heard in view as a 1200×1200 PNG
([#725](#725))
([d6186bc](d6186bc))


### Bug Fixes

* **app,web:** hang a Discover-heard repeater's star from its own
position ([#724](#724))
([15a6ada](15a6ada))
* **app,web:** put the caret in the target picker's search when it opens
([#721](#721))
([6bd899c](6bd899c))
* **app:** drop the float window's previous and next buttons
([#717](#717))
([c957ff3](c957ff3))
* **app:** give the sound the playback buffer instead of the smallest
([#712](#712))
([dee5484](dee5484))
* **app:** hide Chrome's pause on the fullscreen readout
([#715](#715))
([836d91e](836d91e))
* **app:** keep a space under the HUD on Android
([#710](#710))
([2754716](2754716))
* **app:** let the fullscreen readout turn with the phone
([#718](#718))
([fae3749](fae3749))
* **app:** put the transmit pop under the receptions it sat over
([#738](#738))
([2bfc5fa](2bfc5fa))
* **server:** keep the node registry in memory on a loop of its own
([#736](#736))
([4f3e988](4f3e988))
* **web:** keep the map bar on one row between 641 and 767 px
([#732](#732))
([c44dc1d](c44dc1d))
</details>

<details><summary>server: 1.10.1</summary>

##
[1.10.1](server-v1.10.0...server-v1.10.1)
(2026-09-30)


### Bug Fixes

* **server:** keep the node registry in memory on a loop of its own
([#736](#736))
([4f3e988](4f3e988))
* **server:** read a refused frame again when the message-id decoder
changes ([#737](#737))
([8586c71](8586c71))
</details>

<details><summary>web: 1.26.0</summary>

##
[1.26.0](web-v1.25.1...web-v1.26.0)
(2026-09-30)


### Features

* **app,web:** ease what the map shows by zoom, on one hex system
([#734](#734))
([4c2a57b](4c2a57b))
* **app,web:** name a relay on the map the way the app does
([#733](#733))
([a9aa01a](a9aa01a))
* **app:** send one broker to several collectors under one switch
([#722](#722))
([8c6da30](8c6da30))
* **web:** export the repeaters heard in view as a 1200×1200 PNG
([#725](#725))
([d6186bc](d6186bc))


### Bug Fixes

* **app,web:** hang a Discover-heard repeater's star from its own
position ([#724](#724))
([15a6ada](15a6ada))
* **app,web:** put the caret in the target picker's search when it opens
([#721](#721))
([6bd899c](6bd899c))
* **app:** drop the float window's previous and next buttons
([#717](#717))
([c957ff3](c957ff3))
* **app:** give the sound the playback buffer instead of the smallest
([#712](#712))
([dee5484](dee5484))
* **app:** hide Chrome's pause on the fullscreen readout
([#715](#715))
([836d91e](836d91e))
* **app:** keep a space under the HUD on Android
([#710](#710))
([2754716](2754716))
* **app:** let the fullscreen readout turn with the phone
([#718](#718))
([fae3749](fae3749))
* **app:** put the transmit pop under the receptions it sat over
([#738](#738))
([2bfc5fa](2bfc5fa))
* **server:** keep the node registry in memory on a loop of its own
([#736](#736))
([4f3e988](4f3e988))
* **web:** keep the map bar on one row between 641 and 767 px
([#732](#732))
([c44dc1d](c44dc1d))
</details>

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
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: an Export sheet with the first export, Repeaters heard — the map as it stands as a 1200×1200 PNG

2 participants