Skip to content

feat(web): export what the map's filters select, heard and not heard - #729

Open
khagele wants to merge 2 commits into
efiten:masterfrom
khagele:feat/728-export-selection
Open

khagele wants to merge 2 commits into
efiten:masterfrom
khagele:feat/728-export-selection

Conversation

@khagele

@khagele khagele commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Closes #728

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

What it is

A third item in the Export sheet, Heard with these filters, draws what the map's filters select in view as a 1200×1200 PNG. It works for any filter: targets, packet types, id sizes, time and hunters.

  • Heard cells in the tier of the strongest selected reception, finer than the map's cells on purpose: the sizes the app's zoom bands gave, kept as sizes (selectionCellSize), 180 Mercator units at z11 and 90 at z14.
  • Grey cells where the same hunters drove in the window without a selected reception. That query leaves out the senders, packet types, id sizes and hops.
  • The nodes it was heard from, ▲ with names: 1-3 byte relay hashes placed by reach (app,web: a 1-byte last hop gets no position, no reach star and no name #661), keys and prefixes on the node they alone start (app,web: a repeater heard by Discover hangs its reach star from an estimate, beside its own ▲ #723), plus the picked targets.
  • The relief, and a band with a title from the filter and three numbers: receptions, cells heard, and the share of the cells driven.
  • The map's view decides which receptions count: zoom in and the picture zooms with it.

The rules and what was left out: docs/2026-09-27-export-selection.md.

Measured on real data

Channel messages with a 1-byte sender, Nijmegen and Arnhem, 28 August to 27 September 2026:

View Receptions Cells heard Share of the cells driven
z11 344 96 28%
z14, centre of Nijmegen 238 68 47%

Code

  • web/exportselection.js: the model, the heard nodes and the title, in small pure functions.
  • web/exportrender.js: the reach export's cell and dot layers are now shared helpers.
  • web/map.js: the glue, and a __lastSelection test hook.

Verification

  • vitest 850 passed, eslint clean.
  • Playwright 305 passed. The new e2e test checks the file name and the size, and that a reception out of view is left out. It also checks that the drive cells come from a query without the id-size filter, and that a 1-byte id is placed on its node. Each of those is mutation-checked.

Not in it

  • No fill and no lines: there is no single origin.
  • With nothing narrowing the selection, the share is 100%; the export does not refuse that.

🤖 Generated with Claude Code

@khagele

khagele commented Sep 28, 2026

Copy link
Copy Markdown
Contributor Author

Rebased on #726 (7864ccf) and amended: a picked target without a name and a node without a registry name read through idLabel (#725), and the white strokes take tokens.bg. Web: 832 tests, eslint, Playwright 305 passed.

@efiten

efiten commented Sep 30, 2026

Copy link
Copy Markdown
Owner

Review of this PR's own commit (7864ccf): four points to change, small ones below. Points 1 and 2 checked in the code at 7864ccf; the rest found by reading the code, not run.

1. The export's cells are not the cells the map draws (change requested)
web/map.js:1194 uses hexResForZoom(leafletZoom(wm.getZoom())) from web/hexgrid.js, the app's band table. The website's hex layer comes from /api/heatmap (api.go:268, geo.ResForZoom), about 28 px per cell at every zoom. At z11 an export cell has about 1/6 the radius of a map hex, at z14 about 2/3. So "cells heard" and "share of the cells driven" are not what the hex layer shows, while the comment says "as it bins them". After #734, hexResForZoom already adds 1, so the leafletZoom wrap would add it twice.
Question: (a) bin with the server's ResForZoom and size (clamp(z,3,18), 14·156543.03392/2^res), or (b) keep the finer cells on purpose and correct the comment, the doc and the PR text?

2. Picked targets are placed by key prefix at any length (change requested)
targetPositions (web/exportselection.js:64-70) places any picked id on the one node in the padded slice whose pubkey starts with it. A picked 1-byte relay or channel hash like db can match exactly one node in view; that node is drawn as the target with n: Infinity, replaces its heardNodes entry (map.js:1191) and names the picture. AGENTS.md §7 places a hash by reach, and registryMatcher pairs a prefix only from 2 bytes.
Proposed: skip hash-length picks (the idLabel test) and let heardNodes' reach result carry them; use the registryMatcher rules for longer picks. Plus a test with a 1-byte pick against one matching node.

3. A nameless picked node draws the word "undefined" (change requested)
targetPositions copies hits[0].name with no fallback, and targets replace the heardNodes entry, so ctx.fillText(undefined, ...) prints "undefined". Proposed: name: hits[0].name || idLabel(key) plus a test.

4. The mapped-cells query can hit the cap silently (change requested)
windowPoints(everything) has no sender or type filter and reaches the 25,000 cap well before the selected query. drivenCells is then undercounted and the share inflated, but only sel.capped reaches the subline. Proposed: report sel.capped || all.capped, and say which count is cut.

5. Small ones

  • Under the ticker's "all" stand the sender filter is dropped, but the title still names the picked targets; a typed prefix or "No path" narrows the points but not the title.
  • The header comment of exportselection.js still says "on the res-8 grid".
  • The legend shows ▲ when model.nodes.length > 0, also when every ▲ is outside the frame.

@khagele

khagele commented Sep 30, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 55cb517 for the review, rebuilt on #726 (b22cbd1). Per point:

  1. Cells. (b): the finer cells are on purpose (Kasper). They no longer come from hexResForZoom: selectionCellSize keeps the sizes the app's bands gave by Leaflet zoom (180 Mercator units at z11, 90 at z14), and selectionRes bins at the resolution closest to that size. So feat(app,web): ease what the map shows by zoom, on one hex system #734 changes neither the picture nor the zoom it reads, and the leafletZoom wrap stays single. Tests pin the sizes and that each is finer than the server's cell at every zoom from 3 to 18. The comment, the decision log and the PR text now say finer, not "as it bins them".
  2. Picked targets by prefix. targetPositions(ids, registryNodeOf) places a pick by registryMatcher's rules, and skips a relay id of 1 to 3 bytes; heardNodes' reach result carries those. Test: ee77cc against the one node it starts is not placed. Red without the skip.
  3. "undefined". A nameless node reads as its key's prefix (idLabel). Tested, red without the fallback.
  4. Cap. When the cells-driven query reaches the cap and the selection does not, the band says "the cells driven from the first 25000 receptions". When the selection is capped, "the first 25000 receptions" covers both. Glue, not tested.
  5. Small ones.
    • The title reads what the stand applies: under "all" no targets; a typed prefix reads "Starts with db1", and No path is named. selectionTitle, tested.
    • The header comment of exportselection.js no longer says res-8.
    • Only nodes inside the frame reach the model, so the legend's ▲ follows what is drawn. Glue.

Web: 850 tests, eslint, Playwright 305 passed.

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, #725 and #734 are merged. This one follows #726: once #726 is rebased onto master, this needs the same rebase. Its fixes passed the check; see #726 for the exportreach fixture question, which may touch exportselection.test.js too (2 of its tests hard-code res-8/9 cells).

khagele and others added 2 commits October 1, 2026 08:03
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>
The Export sheet drew every repeater in view (efiten#666) or one repeater's reach
(efiten#720), not what the filters select: channel messages with a 1-byte sender,
say, or two hunted nodes together. A third item, Heard with these filters,
draws the selection in view as a 1200×1200 PNG. Heard cells in the tier of
the strongest reception, at the cell size the map draws at that zoom; grey
where the same hunters drove without it; the nodes it was heard from, placed
by reach or by key as the map's layers place them; the relief; and a band
titled after the filter, with receptions, cells heard and the share of the
cells driven.

It does not depend on repeaters, and nothing is filled in: there is no one
origin to think from. The reach export's cell and dot layers are shared with
it. Decided with Kasper on renders from real data; recorded in
docs/2026-09-27-export-selection.md.

Closes efiten#728

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@khagele
khagele force-pushed the feat/728-export-selection branch from 55cb517 to a9935fb Compare October 1, 2026 06:05
@khagele

khagele commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto #726 (a9935fb). The two selectionModel tests that hard-coded res 8 now bin at CELL_RES; nothing else changed. The selection cells come from selectionCellSize, so #734 does not move them. Web: 926 tests, eslint, Playwright 329 passed. App: 1707 tests, eslint, build.

@efiten

efiten commented Oct 2, 2026

Copy link
Copy Markdown
Owner

Review of a9935fb (base 957b144, #726's commit). The five points of the earlier review are in the code and tested. Run on a9935fb: web 926 tests, eslint, e2e/export.spec.js 8 passed. Two points to change, one of them a question, plus small ones. Checked in the code at a9935fb, not run in a browser.

1. Question: the band's numbers cover the map view, but the picture only covers the selection
mapped is filtered on the whole map view (web/map.js:1270), so the cells driven and the share count every grey cell in view. The picture is fitted to the selected dots (padding 80, maxZoom 14; doc line 24, "the picture is fitted to them"). With the selection in a corner of the view, the band says for example "28% of the cells driven", while most of the grey cells it counts fall outside the picture.
Question: (a) fit the frame to the dots and the grey cells, so every counted cell is drawn; (b) count only cells whose centre falls inside the fitted frame; or (c) keep it, and have the doc and the band say the numbers cover the map view, not the picture?

2. One merged target row gives a title and file name with several ids (change requested)
rowIds (web/targetpicker.js:306) puts every prefix of a merged row into the selection, and names (map.js:1288) maps each picked id separately. One picked node then gets for example #4a4a + #4a4abe + NL-NIJ-Dikkeboom as title and file name. targetChipLabel (#268) already counts nodes instead of id variants.
Proposed: fold the picks per node before naming them, for example by dropping a pick that is a prefix of another pick or of a placed target's key, with a unit test on a pure helper.

3. Small ones

  • The legend ▲ follows model.nodes.length > 0 (web/exportrender.js:152), but drawn is filtered on the view (map.js:1281) and drawNodes only draws inside the fitted frame. The doc's "Only nodes in the frame are drawn and in the legend" says more than the code does. Proposed: drawNodes returns how many it drew, and the legend reads that.
  • exportselection.test.js:411-414 "leaves out a target that starts no node, or more than one": place(['db1']) is dropped by the isHashId length rule, so registryMatcher's ambiguity branch never runs. A pick of 8+ hex characters that starts two keys tests it.
  • everything (map.js:1265) keeps ignorePairs, so a cell where only an ignored node was heard does not count as driven. Possibly intended, but the doc only names senders, types, id sizes and hops as left out.
  • map.js:1259-1260: "the id sizes included" reads as if they are kept, while idclass: '' drops them. "id sizes too".
  • web: what the map's filters select, heard and not heard, cannot be passed on as a picture #728's text still says "at the cell size the map draws at that zoom". One line on the issue keeps it in line with your decision for finer cells.

This PR follows #726. Once #726 is merged, it needs the rebase that drops 957b144.

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: what the map's filters select, heard and not heard, cannot be passed on as a picture

2 participants