Skip to content

feat(app,web): ease what the map shows by zoom, on one hex system - #734

Merged
efiten merged 2 commits into
efiten:masterfrom
khagele:feat/634-zoom-transitions
Sep 30, 2026
Merged

efiten merged 2 commits into
efiten:masterfrom
khagele:feat/634-zoom-transitions

Conversation

@khagele

@khagele khagele commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

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. feat(app): render points in 3D mode as raised pillar markers (#250) #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

Closes #634

🤖 Generated with Claude Code

@efiten

efiten commented Sep 30, 2026

Copy link
Copy Markdown
Owner

Review before merge: three points to change, one question, merge-order notes. Point 2 checked in the code at 2104e11; the rest found by reading the code, not run.

1. The website map fades to nearly empty during a zoom of more than about one level (change requested)
web/map.js:694 refetches only on moveend (250 ms debounce plus two fetches). Until then both slots keep the expressions for the pair fetched at the old zoom, which fall to 0 outside roughly [z0-1, z0+2). The e2e pins this: after loading at 13, hex is 0 below 12.025 and hex-b from 14.975. From 13.9, a +1 zoom ends at 14.9 with res 15 at 0.079 and res 14 at 0. Before this PR the old cells stayed until they were replaced.
Proposed: in applyFades (mapcore.js:369) hold the coarse slot's lower side and the fine slot's upper side at 100%, as maxRes already does, plus an e2e: load at z, jumpTo(z+1.9) before the refetch, assert a non-zero share.

2. A stored 3D view comes back as 2D (change requested)
loadViewIndex (app/src/settings.js:91-95) compares the stored string with viewKey(s), which is mode plus 2d/3d. A stored both3d matches nothing and falls back to index 1 (auto/2D); both2d falls back to the same state. Proposed: readStored(...)?.replace(/^both/, 'auto') before the lookup, plus a test for both old keys.

3. Hover and click can hit the invisible slot (change requested)
Both sizes are loaded, and queryRenderedFeatures and layer events ignore paint opacity. hex-b sits above hex in LAYER_ORDER (mapcore.js:52), so the hover text (map.js:420) and onHexClick (map.js:426) can read the hidden cell: "best RSSI · N pts" of a smaller cell, and a ticker jump to the wrong area. Points at about 1% opacity just past 13.25 (map.js:659) take clicks over cells the same way. Proposed: drop hits whose slot is not the size drawn in full at the current zoom.

4. Question: two heatmap requests per refresh
map.js:460 sends one /api/heatmap per size, and each runs QueryPoints up to 50,000 rows, so every pan, filter change and 10 s live tick now costs two queries. For guests above zoom 12 both are answered at res 12 (degrade.go:15), so the second is identical. Options: (a) ask one size once an answer showed the cap, (b) let the server take z=a,b and aggregate both from one query, as a follow-up issue, (c) keep it as is.

5. Merge order with the exports, #711 and #740

6. Small ones (app)

  • drawHexLabels and the noise layer take hexResForZoom, which changes at every half zoom, while zoomDrawKey changes only at whole zooms, so labels lag one size mid-pinch.
  • map.on('zoom') runs a full draw() on every whole-zoom crossing during a pinch, where only the hex slots change.

Everything the map showed or hid by zoom was a step: a threshold
crossed, the data rebuilt, the thing there or gone in one frame. A
share between 0 and 1 is now handed to the style as a zoom expression,
so the map eases it while the fingers move.

The app takes the server's cell sizes, one per zoom level at about
28 px, and gives up its ten bands in metres. Two sizes are on the map
while one takes over from the other. The points and the earlier rides
fade in over a hex that stays at full strength, the cell names fade in,
and Auto takes the place of Both. In 3D the pillars wait for zoom 19.

The numbers are Kasper's, picked in a mockup on 29 September 2026, and
live in zoomfade.js, one file on both surfaces.

Closes efiten#634

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@khagele
khagele force-pushed the feat/634-zoom-transitions branch from 2104e11 to c96eb0e Compare September 30, 2026 17:21
@khagele

khagele commented Sep 30, 2026

Copy link
Copy Markdown
Contributor Author

Pushed c96eb0e for the review, rebased on master (#710, #712, #715). Per point:

  1. Zoom past the pair. applyFades takes the two sizes held as the whole range: the coarser stays in full below its level, the finer above it (resShare's new minRes, both zoomfade.js copies). An e2e loads at 13, holds the refetch and jumps to 14.9 and 11.5: the shares add up to 1 at both. Before the fix it read 0.079 at 14.9. setHexSizes no longer takes maxRes, since the held pair is the range.
  2. Stored both3d. Left as is: the app is in development and does not migrate stored state. A stored both3d opens in 2D once. The decision log now says so instead of "a stored view reads as auto".
  3. Hits on the hidden slot. takesPointer in mapcore.js gates hover, click and the empty click: a cell answers only from the size drawn in full (the one the status line counts), a point from half its strength on (zoom 15.75 in 2D auto). Two e2e tests, each red without the gate: the hover line reads cell 14 at zoom 13 (it read 15), and a point at 15% gives the cell the click.
  4. Two heatmap requests. Which would you prefer? (a) ask one size once an answer shows the cap, in this PR; (b) a follow-up issue where the server takes z=a,b and aggregates both from one query; (c) keep two queries.
  5. Merge order. The exports define their cell by size in their own fixes (feat(web): export the repeaters heard in view as a 1200×1200 PNG #725 point 2), so they no longer depend on what res 8 means. feat(app): show the measured noise floor in the cells and the HUD #711 and perf(app): hand a map collection to MapLibre only when it changed #740 rebase onto this one after it merges.
  6. Small ones. zoomKeys in huntmap.js splits a zoom step in two: a layer whose share leaves 0 takes the full draw; a size change (the pair at whole zooms, labels and noise at every half) rebuilds only the cell layers, the noise and the labels, with the selection of the last full draw. Unit-tested; the draw split itself is glue and not run on a device.

App: 1671 tests, eslint, build. Web: 811 tests, eslint, Playwright 305 passed.

@efiten
efiten merged commit 4c2a57b into efiten:master Sep 30, 2026
6 checks passed
@github-actions github-actions Bot mentioned this pull request Sep 30, 2026
efiten added a commit to khagele/core-hunter that referenced this pull request Sep 30, 2026
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 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.

app,web: every zoom transition on the map is a hard step, and hex never fades into points

2 participants