diff --git a/AGENTS.md b/AGENTS.md
index ce0881a8..21d17fb5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -68,6 +68,7 @@ app/ Mobile hunter PWA (Vite ES-module)
signal.js Thermal tier helpers (snrTier / rssiTier)
huntmap.js Leaflet map: signal points + hex-heat layer
hexgrid.js Hex-grid binning geometry
+ zoomfade.js What the map shows at a zoom, as shares the style eases (#634)
transport.js BLE transport (ported from CoreDrive RX)
frames.js 0x88 RX-log frame decoder
gps.js Phone Geolocation wrapper
diff --git a/app/changelog.json b/app/changelog.json
index a7415395..4cbe791b 100644
--- a/app/changelog.json
+++ b/app/changelog.json
@@ -27,6 +27,13 @@
"title": "The map names a relay like the app",
"body": "A relay's name in the ticker and the popup is marked ~ as a guess. Members get the name of the one known node in reach."
},
+ {
+ "id": "2026-09-29-zoom-eases",
+ "date": "2026-09-29",
+ "where": "both",
+ "title": "Zooming eases instead of jumping",
+ "body": "Cells change size gradually and points fade in as you zoom. Auto replaces Both. In 3D the pillars appear at the closest zoom."
+ },
{
"id": "2026-09-27-hud-bottom-space",
"date": "2026-09-27",
diff --git a/app/src/__tests__/hexgrid.test.js b/app/src/__tests__/hexgrid.test.js
index 963091e6..ed78944e 100644
--- a/app/src/__tests__/hexgrid.test.js
+++ b/app/src/__tests__/hexgrid.test.js
@@ -1,35 +1,39 @@
import { describe, it, expect } from 'vitest'
-import { hexSizeForRes, hexResForZoom } from '../hexgrid.js'
+import { hexSizeForRes, hexResForZoom, HEX_TARGET_PX, HEX_MAX_RES } from '../hexgrid.js'
-describe('hexSizeForRes', () => {
- it('keeps the existing coarse bands', () => {
- expect(hexSizeForRes(11)).toBe(40)
- expect(hexSizeForRes(10)).toBe(90)
- expect(hexSizeForRes(9)).toBe(180)
- expect(hexSizeForRes(7)).toBe(720)
- expect(hexSizeForRes(6)).toBe(1500)
+describe('hexSizeForRes (#634: the server\'s sizes)', () => {
+ it('halves with every resolution', () => {
+ for (let res = 3; res < 21; res++) expect(hexSizeForRes(res) / hexSizeForRes(res + 1)).toBeCloseTo(2, 12)
})
- it('adds finer cells down to 3 m for the zoomed-in resolutions', () => {
- expect(hexSizeForRes(12)).toBe(20)
- expect(hexSizeForRes(13)).toBe(10)
- expect(hexSizeForRes(14)).toBe(5)
- expect(hexSizeForRes(15)).toBe(3)
- expect(hexSizeForRes(16)).toBe(3) // clamps at the finest band
+ it('matches server/internal/geo/hexgrid.go: 14 px of Mercator units at that Leaflet zoom', () => {
+ expect(hexSizeForRes(0)).toBeCloseTo(14 * 156543.03392, 6)
+ expect(hexSizeForRes(14)).toBeCloseTo(133.765, 3)
+ expect(hexSizeForRes(18)).toBeCloseTo(8.3603, 4)
+ })
+ it('draws a cell 28 px tall at the zoom it belongs to', () => {
+ // Point to point is twice the circumradius. MapLibre zoom z shows
+ // 156543.03392 / 2^(z + 1) Mercator units per pixel.
+ for (const z of [5, 11, 14, 19]) {
+ const res = hexResForZoom(z)
+ const unitsPerPx = 156543.03392 / Math.pow(2, z + 1)
+ expect(2 * hexSizeForRes(res) / unitsPerPx, `zoom ${z}`).toBeCloseTo(HEX_TARGET_PX, 9)
+ }
})
})
-describe('hexResForZoom', () => {
- it('keeps the existing coarse mapping', () => {
- expect(hexResForZoom(15)).toBe(11)
- expect(hexResForZoom(14)).toBe(10)
- expect(hexResForZoom(13)).toBe(10)
- expect(hexResForZoom(9)).toBe(8)
- expect(hexResForZoom(6)).toBe(6)
+describe('hexResForZoom (#634: one size per zoom level)', () => {
+ it('is the Leaflet zoom, one above MapLibre\'s, rounded', () => {
+ expect(hexResForZoom(13)).toBe(14)
+ expect(hexResForZoom(13.49)).toBe(14)
+ expect(hexResForZoom(13.5)).toBe(15)
+ expect(hexResForZoom(14)).toBe(15)
+ })
+ it('stops at 3 and at the app\'s finest, 21', () => {
+ expect(hexResForZoom(0)).toBe(3)
+ expect(hexResForZoom(20)).toBe(21)
+ expect(hexResForZoom(22)).toBe(HEX_MAX_RES)
})
- it('goes finer each level past 15, down to 3 m at max zoom', () => {
- expect(hexResForZoom(16)).toBe(12) // 20 m
- expect(hexResForZoom(17)).toBe(13) // 10 m
- expect(hexResForZoom(18)).toBe(14) // 5 m
- expect(hexResForZoom(19)).toBe(15) // 3 m
+ it('ends finer than the 3 units the old bands ended on', () => {
+ expect(hexSizeForRes(HEX_MAX_RES)).toBeLessThan(3)
})
})
diff --git a/app/src/__tests__/hexlabels.test.js b/app/src/__tests__/hexlabels.test.js
index 02eb02e0..20d75d55 100644
--- a/app/src/__tests__/hexlabels.test.js
+++ b/app/src/__tests__/hexlabels.test.js
@@ -1,14 +1,17 @@
import { describe, it, expect } from 'vitest'
-import { HEX_LABEL_MIN_ZOOM, HEX_LABEL_MAX, hexCellLabel, showHexLabels, planHexLabels } from '../hexlabels.js'
+import { HEX_LABEL_MAX, hexCellLabel, planHexLabels } from '../hexlabels.js'
import { hexCellAt } from '../hexgrid.js'
const at = (s) => `2026-09-04T13:${s}:00Z`
const rec = (id, kind, rx_at) => ({ sender_id: id, sender_kind: kind, rx_at })
describe('hexCellLabel', () => {
- it('lists the 4-character prefixes of the nodes heard in the cell, newest first', () => {
+ // One prefix since #634: a cell is 28 px tall at every zoom and about 24
+ // wide, which holds four characters at 10 px and no more. The count of the
+ // others stands on a line of its own under it.
+ it('names the node heard last by its 4-character prefix, and counts the others under it', () => {
const rows = [rec('7e76aa'.padEnd(64, '0'), 'advert_pubkey', at('10')), rec('4ac3bb', 'discover_pubkey', at('20')), rec('a1b2cc', 'relay', at('15'))]
- expect(hexCellLabel(rows)).toBe('4ac3 a1b2 7e76')
+ expect(hexCellLabel(rows)).toBe('4ac3\n+2')
})
it('names a node once, however many receptions and id variants it has', () => {
const rows = [rec('7e76aa'.padEnd(64, '0'), 'advert_pubkey', at('10')), rec('7e76aabb', 'discover_pubkey', at('11')), rec('7E76AA', 'relay', at('12'))]
@@ -21,10 +24,10 @@ describe('hexCellLabel', () => {
expect(hexCellLabel(rows)).toBe('c3d4')
expect(hexCellLabel([rec('4a', 'direct_hash', at('10'))])).toBe('')
})
- it('stops at three, and says how many more there were', () => {
+ it('stops at one, and says how many more there were', () => {
const rows = ['aaaa11', 'bbbb22', 'cccc33', 'dddd44', 'eeee55'].map((id, i) => rec(id, 'advert_pubkey', at(String(10 + i).padStart(2, '0'))))
- expect(HEX_LABEL_MAX).toBe(3)
- expect(hexCellLabel(rows)).toBe('eeee dddd cccc +2')
+ expect(HEX_LABEL_MAX).toBe(1)
+ expect(hexCellLabel(rows)).toBe('eeee\n+4')
})
it('is empty for nothing', () => {
expect(hexCellLabel([])).toBe('')
@@ -32,16 +35,6 @@ describe('hexCellLabel', () => {
})
})
-describe('showHexLabels', () => {
- // Four characters at 10px need a cell wider than the label; at zoom 16 a
- // cell is about 110 m and the label fits with room, at 15 it does not.
- it('shows labels from zoom 16, not below', () => {
- expect(HEX_LABEL_MIN_ZOOM).toBe(16)
- expect(showHexLabels(15.9)).toBe(false)
- expect(showHexLabels(16)).toBe(true)
- })
-})
-
describe('planHexLabels', () => {
// One marker per cell, kept while the cell stays in view. The map used to
// rebuild every label marker when any one text changed, so each reception
diff --git a/app/src/__tests__/huntmap-contract.test.js b/app/src/__tests__/huntmap-contract.test.js
index 49d126b5..00dd7b3b 100644
--- a/app/src/__tests__/huntmap-contract.test.js
+++ b/app/src/__tests__/huntmap-contract.test.js
@@ -52,7 +52,7 @@ describe('applyLayerVisibility covers every layer layerVisibility decides', () =
// one of them names the full set. hex-labels is the single exception: it is
// not a style layer but DOM markers, applied through drawHexLabels().
// The noise layer (#410) is decided on top of it, by withNoise.
- const decided = Object.keys(withNoise(layerVisibility({ mode: 'both', mode3D: true }), true)).filter((id) => id !== 'hex-labels')
+ const decided = Object.keys(withNoise(layerVisibility({ mode: 'auto', mode3D: true }), true)).filter((id) => id !== 'hex-labels')
expect([...applied].sort()).toEqual([...decided].sort())
})
})
diff --git a/app/src/__tests__/huntmap-zoomkeys.test.js b/app/src/__tests__/huntmap-zoomkeys.test.js
new file mode 100644
index 00000000..1db3afb9
--- /dev/null
+++ b/app/src/__tests__/huntmap-zoomkeys.test.js
@@ -0,0 +1,26 @@
+import { describe, it, expect } from 'vitest'
+import { zoomKeys } from '../huntmap.js'
+import { HEX_MAX_RES } from '../hexgrid.js'
+
+// What a zoom step asks of the draw while the fingers move (#634). The data
+// key names what has to be built at all; the cells key what has to be rebuilt
+// at another size. Only the first needs the full draw.
+const view = { mode: 'auto', mode3D: false }
+const keys = (z) => zoomKeys(z, view, HEX_MAX_RES)
+
+describe('zoomKeys', () => {
+ it('moves the cells key at the half zoom where the labels and the noise change size', () => {
+ // 13.4 and 13.6 share a pair of hex layers, but the size drawn in full is
+ // 14 below 13.5 and 15 above it.
+ expect(keys(13.4).cells).not.toBe(keys(13.6).cells)
+ })
+ it('moves the cells key at the whole zoom where the pair of hex layers moves', () => {
+ expect(keys(13.9).cells).not.toBe(keys(14.1).cells)
+ })
+ it('keeps the data key while no layer\'s share leaves nothing', () => {
+ expect(keys(13.6).data).toBe(keys(14.1).data)
+ })
+ it('moves the data key where the points arrive', () => {
+ expect(keys(13.2).data).not.toBe(keys(13.3).data)
+ })
+})
diff --git a/app/src/__tests__/maplayers.test.js b/app/src/__tests__/maplayers.test.js
index c010d7c8..45413300 100644
--- a/app/src/__tests__/maplayers.test.js
+++ b/app/src/__tests__/maplayers.test.js
@@ -9,8 +9,8 @@ import { layerVisibility, pitchFor, pitchTransition, PITCH_3D, VIEW_STATES, VIEW
const vis = (mode, mode3D) => layerVisibility({ mode, mode3D })
describe('layerVisibility in 2D', () => {
- it('both shows the flat hex and the flat points', () => {
- expect(vis('both', false)).toMatchObject({ hex: true, points: true, 'hex-3d': false, 'points-3d': false })
+ it('auto shows the flat hex and the flat points', () => {
+ expect(vis('auto', false)).toMatchObject({ hex: true, points: true, 'hex-3d': false, 'points-3d': false })
})
it('hex shows only the flat hex', () => {
expect(vis('hex', false)).toMatchObject({ hex: true, points: false })
@@ -19,7 +19,7 @@ describe('layerVisibility in 2D', () => {
expect(vis('points', false)).toMatchObject({ hex: false, points: true })
})
it('never shows a 3D layer in 2D', () => {
- for (const m of ['both', 'hex', 'points']) {
+ for (const m of ['auto', 'hex', 'points']) {
expect(vis(m, false)).toMatchObject({ 'hex-3d': false, 'points-3d': false })
}
})
@@ -32,17 +32,16 @@ describe('layerVisibility in 3D', () => {
// write. The pillar's fragments lose the depth test and vanish. Drawing the
// hex FLAT on the ground removes the occluder entirely while keeping the
// coverage context under the pillars.
- it('draws hex FLAT under the pillars when both layers are shown', () => {
- expect(vis('both', true)).toMatchObject({
- hex: true, 'hex-3d': false, 'points-3d': true, points: false,
+ // Since #634 the pillars arrive at zoom 19 only and the bars drop to 95%
+ // from there (zoomfade.js), so auto draws the bars extruded, not flat.
+ it('draws the hex extruded in auto, with the pillar layer on beside it', () => {
+ expect(vis('auto', true)).toMatchObject({
+ hex: false, 'hex-3d': true, 'points-3d': true, points: false,
})
})
- it('never shows extruded hex and pillars at the same time', () => {
- for (const m of ['both', 'hex', 'points']) {
- const v = vis(m, true)
- expect(v['hex-3d'] && v['points-3d']).toBe(false)
- }
+ it('never draws the hex flat in 3D', () => {
+ for (const m of ['auto', 'hex', 'points']) expect(vis(m, true).hex).toBe(false)
})
it('keeps the extruded bars when hex is the only layer — that is what 3D hex is for', () => {
@@ -58,16 +57,16 @@ describe('layerVisibility in 3D', () => {
})
it('never shows a flat point layer in 3D', () => {
- for (const m of ['both', 'hex', 'points']) expect(vis(m, true).points).toBe(false)
+ for (const m of ['auto', 'hex', 'points']) expect(vis(m, true).points).toBe(false)
})
})
describe('layerVisibility is total', () => {
it('always answers for every layer it decides, so a caller cannot read undefined', () => {
- for (const m of ['both', 'hex', 'points']) {
+ for (const m of ['auto', 'hex', 'points']) {
for (const d of [true, false]) {
const v = vis(m, d)
- for (const k of ['hex', 'hex-3d', 'points', 'points-3d', 'hex-labels', 'pulse', 'pulse-3d']) {
+ for (const k of ['hex', 'hex-b', 'hex-3d', 'hex-3d-b', 'points', 'points-3d', 'hex-labels', 'pulse', 'pulse-3d']) {
expect(typeof v[k], `${m}/${d} ${k}`).toBe('boolean')
}
}
@@ -76,6 +75,19 @@ describe('layerVisibility is total', () => {
it('treats an unknown mode as the cold default (hex), not as nothing visible', () => {
expect(vis('', false)).toMatchObject({ hex: true, points: false })
})
+ // Two cell sizes are on the map while one takes over from the other (#634),
+ // so the hex is two layers, and the second follows the first in every view.
+ it('shows the second cell size wherever it shows the first', () => {
+ for (const m of ['auto', 'hex', 'points']) for (const d of [true, false]) {
+ const v = vis(m, d)
+ expect(v['hex-b'], `${m}/${d}`).toBe(v.hex)
+ expect(v['hex-3d-b'], `${m}/${d}`).toBe(v['hex-3d'])
+ }
+ })
+ it('reads both, the name auto had until #634, as auto', () => {
+ expect(vis('both', false)).toEqual(vis('auto', false))
+ expect(vis('both', true)).toEqual(vis('auto', true))
+ })
})
// #648: with the age fade gone, nothing in 3D said "this one just arrived".
@@ -85,18 +97,18 @@ describe('layerVisibility is total', () => {
describe('the arrival pulse has a form per dimension (#648)', () => {
it('rings on the ground in 2D, where the flat dots are', () => {
expect(vis('points', false)).toMatchObject({ pulse: true, 'pulse-3d': false })
- expect(vis('both', false)).toMatchObject({ pulse: true, 'pulse-3d': false })
+ expect(vis('auto', false)).toMatchObject({ pulse: true, 'pulse-3d': false })
})
it('flashes the pillar in 3D, where the dots are pillars', () => {
expect(vis('points', true)).toMatchObject({ pulse: false, 'pulse-3d': true })
- expect(vis('both', true)).toMatchObject({ pulse: false, 'pulse-3d': true })
+ expect(vis('auto', true)).toMatchObject({ pulse: false, 'pulse-3d': true })
})
it('does neither in hex mode, which draws no receptions to mark', () => {
expect(vis('hex', false)).toMatchObject({ pulse: false, 'pulse-3d': false })
expect(vis('hex', true)).toMatchObject({ pulse: false, 'pulse-3d': false })
})
it('never shows both forms at once', () => {
- for (const m of ['both', 'hex', 'points']) {
+ for (const m of ['auto', 'hex', 'points']) {
for (const d of [true, false]) {
const v = vis(m, d)
expect(v.pulse && v['pulse-3d'], `${m}/${d}`).toBe(false)
@@ -112,10 +124,10 @@ describe('VIEW_STATES / nextViewIndex (#258)', () => {
it('has exactly the 5 states in the decided order, dropping 2D hex-only', () => {
expect(VIEW_STATES).toEqual([
{ mode: 'points', mode3D: false },
- { mode: 'both', mode3D: false },
+ { mode: 'auto', mode3D: false },
{ mode: 'hex', mode3D: true },
{ mode: 'points', mode3D: true },
- { mode: 'both', mode3D: true },
+ { mode: 'auto', mode3D: true },
])
})
it('never contains the dropped 2D-hex-only combination', () => {
@@ -153,7 +165,7 @@ describe('VIEW_LABELS covers VIEW_STATES (#258)', () => {
for (const l of Object.values(VIEW_LABELS)) expect(l).not.toContain('·')
})
it('gives 2D and 3D distinct keys for the same layer mode', () => {
- expect(viewKey({ mode: 'both', mode3D: false })).not.toBe(viewKey({ mode: 'both', mode3D: true }))
+ expect(viewKey({ mode: 'auto', mode3D: false })).not.toBe(viewKey({ mode: 'auto', mode3D: true }))
})
})
@@ -220,12 +232,12 @@ describe('pitchTransition — which FAB taps move the camera', () => {
// top would float at ground level under the pillar, so 3D keeps its drawing.
describe('layerVisibility carries the hex labels', () => {
it('shows them exactly when the flat hex layer is on, in 2D', () => {
- expect(vis('both', false)['hex-labels']).toBe(true)
+ expect(vis('auto', false)['hex-labels']).toBe(true)
expect(vis('hex', false)['hex-labels']).toBe(true)
expect(vis('points', false)['hex-labels']).toBe(false)
})
it('never shows them in 3D', () => {
- expect(vis('both', true)['hex-labels']).toBe(false)
+ expect(vis('auto', true)['hex-labels']).toBe(false)
expect(vis('hex', true)['hex-labels']).toBe(false)
})
})
@@ -235,17 +247,17 @@ describe('layerVisibility carries the hex labels', () => {
// reception, and in 3D the flat ring would sit on the ground under its pillar.
describe('layerVisibility carries the pulse', () => {
it('rings where the flat points are drawn', () => {
- expect(vis('both', false).pulse).toBe(true)
+ expect(vis('auto', false).pulse).toBe(true)
expect(vis('points', false).pulse).toBe(true)
})
it('does not ring in hex mode, where no point is drawn', () => {
expect(vis('hex', false).pulse).toBe(false)
})
it('does not ring in 3D, under the pillars', () => {
- for (const m of ['both', 'hex', 'points']) expect(vis(m, true).pulse).toBe(false)
+ for (const m of ['auto', 'hex', 'points']) expect(vis(m, true).pulse).toBe(false)
})
it('never disagrees with the flat point layer', () => {
- for (const m of ['both', 'hex', 'points', '']) {
+ for (const m of ['auto', 'hex', 'points', '']) {
for (const d of [true, false]) expect(vis(m, d).pulse).toBe(vis(m, d).points)
}
})
diff --git a/app/src/__tests__/rides.test.js b/app/src/__tests__/rides.test.js
index bc7d9e11..97fa0038 100644
--- a/app/src/__tests__/rides.test.js
+++ b/app/src/__tests__/rides.test.js
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
-import { RIDE_GAP_MS, splitRides, currentRideStart, isBacklog, showBacklogPoints } from '../rides.js'
+import { RIDE_GAP_MS, splitRides, currentRideStart, isBacklog } from '../rides.js'
const T0 = Date.parse('2026-09-04T13:10:00Z')
const MIN = 60_000
@@ -51,15 +51,3 @@ describe('currentRideStart / isBacklog', () => {
expect(isBacklog(rec(0), null)).toBe(false)
})
})
-
-describe('showBacklogPoints', () => {
- // Kasper, 2026-09-04: outlines come back "reasonably soon" while zooming in.
- // That was zoom 15 until the field said one pinch out already lost them
- // (#668, 2026-09-21); 12 keeps them through town level.
- it('draws backlog points from zoom 12, not below', () => {
- expect(showBacklogPoints(11.9)).toBe(false)
- expect(showBacklogPoints(12)).toBe(true)
- expect(showBacklogPoints(14)).toBe(true)
- expect(showBacklogPoints(18)).toBe(true)
- })
-})
diff --git a/app/src/__tests__/settings.test.js b/app/src/__tests__/settings.test.js
index 17bf46c9..a12bb4ea 100644
--- a/app/src/__tests__/settings.test.js
+++ b/app/src/__tests__/settings.test.js
@@ -149,7 +149,7 @@ describe('loadViewIndex', () => {
vi.stubGlobal('localStorage', storageWith({ 'core-hunter-view': 'points2d' }))
expect(loadViewIndex()).toBe(0)
})
- it('falls back to both/2D (index 1) for a missing or unknown value', () => {
+ it('falls back to auto/2D (index 1) for a missing or unknown value', () => {
vi.stubGlobal('localStorage', storageWith({}))
expect(loadViewIndex()).toBe(1)
vi.stubGlobal('localStorage', storageWith({ 'core-hunter-view': 'hex4d' }))
diff --git a/app/src/__tests__/zoomfade.test.js b/app/src/__tests__/zoomfade.test.js
new file mode 100644
index 00000000..ba896c1b
--- /dev/null
+++ b/app/src/__tests__/zoomfade.test.js
@@ -0,0 +1,278 @@
+import { describe, it, expect } from 'vitest'
+import {
+ ramp, pointShare, hexShare, backlogShare, labelShare, resForZoom, resPair, resShare, resBreaks,
+ zoomRamp, zoomSwitch, stepBreaks, sampledBreaks, hexBlend, STEP_EPS, LAYER_MODES,
+} from '../zoomfade.js'
+
+const MAX = 21
+
+describe('ramp', () => {
+ it('is 0 before, 1 after and a straight line between', () => {
+ const band = { from: 10, to: 12 }
+ expect(ramp(9, band)).toBe(0)
+ expect(ramp(10, band)).toBe(0)
+ expect(ramp(11, band)).toBe(0.5)
+ expect(ramp(12, band)).toBe(1)
+ expect(ramp(20, band)).toBe(1)
+ })
+ it('is a step when the band has no width', () => {
+ expect(ramp(18.99, { from: 19, to: 19 })).toBe(0)
+ expect(ramp(19, { from: 19, to: 19 })).toBe(1)
+ })
+})
+
+describe('the shares of 29 September 2026 (#634)', () => {
+ it('auto in 2D: the points come in from 13.25 to 18.25 over a hex that stays full', () => {
+ const v = { mode: 'auto', mode3D: false }
+ expect(pointShare(13.25, v)).toBe(0)
+ expect(pointShare(15.75, v)).toBe(0.5)
+ expect(pointShare(18.25, v)).toBe(1)
+ for (const z of [5, 13.25, 16, 18.25, 21]) expect(hexShare(z, v), `zoom ${z}`).toBe(1)
+ })
+ it('auto in 3D: the pillars arrive in one step at zoom 19, and the bars drop to 95%', () => {
+ const v = { mode: 'auto', mode3D: true }
+ expect(pointShare(18.99, v)).toBe(0)
+ expect(pointShare(19, v)).toBe(1)
+ expect(hexShare(18.99, v)).toBe(1)
+ expect(hexShare(19, v)).toBe(0.95)
+ })
+ it('hex and points draw one layer at full strength, whatever the zoom', () => {
+ for (const mode3D of [false, true]) for (const z of [5, 14, 19, 21]) {
+ expect(pointShare(z, { mode: 'points', mode3D })).toBe(1)
+ expect(hexShare(z, { mode: 'points', mode3D })).toBe(0)
+ expect(pointShare(z, { mode: 'hex', mode3D })).toBe(0)
+ expect(hexShare(z, { mode: 'hex', mode3D })).toBe(1)
+ }
+ })
+ it('reads an unknown mode as auto, so a stored value from before cannot blank the map', () => {
+ expect(LAYER_MODES).toEqual(['auto', 'hex', 'points'])
+ expect(pointShare(18.25, { mode: 'both', mode3D: false })).toBe(1)
+ expect(hexShare(18.25, { mode: undefined, mode3D: false })).toBe(1)
+ })
+ it('earlier rides come in from 11.25 to 18.75 in 2D, and with the pillars in 3D', () => {
+ expect(backlogShare(11.25, { mode3D: false })).toBe(0)
+ expect(backlogShare(15, { mode3D: false })).toBe(0.5)
+ expect(backlogShare(18.75, { mode3D: false })).toBe(1)
+ expect(backlogShare(5, { mode3D: true })).toBe(1)
+ })
+ it('cell names come in from 15.5 to 16.5', () => {
+ expect(labelShare(15.5)).toBe(0)
+ expect(labelShare(16)).toBe(0.5)
+ expect(labelShare(16.5)).toBe(1)
+ })
+ it('blends two cell sizes over 0.95 of a zoom level in 2D and 0.75 in 3D', () => {
+ expect(hexBlend(false)).toBe(0.95)
+ expect(hexBlend(true)).toBe(0.75)
+ })
+})
+
+describe('cell sizes: one per zoom level, the server\'s rule', () => {
+ it('is the Leaflet zoom, one above MapLibre\'s, rounded and clamped', () => {
+ expect(resForZoom(13, MAX)).toBe(14)
+ expect(resForZoom(13.49, MAX)).toBe(14)
+ expect(resForZoom(13.5, MAX)).toBe(15)
+ expect(resForZoom(0, MAX)).toBe(3)
+ expect(resForZoom(22, MAX)).toBe(21)
+ expect(resForZoom(22, 18)).toBe(18)
+ })
+ it('names the two sizes around the nearest change', () => {
+ expect(resPair(13.2, MAX)).toEqual({ coarse: 14, fine: 15 })
+ expect(resPair(13.9, MAX)).toEqual({ coarse: 14, fine: 15 })
+ expect(resPair(14, MAX)).toEqual({ coarse: 15, fine: 16 })
+ expect(resPair(21.5, MAX)).toEqual({ coarse: 21, fine: 21 })
+ })
+ it('shows a size in full at the middle of its level and half at each end', () => {
+ expect(resShare(14, 13, 0.95, MAX)).toBe(1)
+ expect(resShare(14, 13.5, 0.95, MAX)).toBeCloseTo(0.5, 9)
+ expect(resShare(14, 12.5, 0.95, MAX)).toBeCloseTo(0.5, 9)
+ expect(resShare(14, 14, 0.95, MAX)).toBe(0)
+ })
+ it('adds up to 1 over the pair at every zoom, for both widths', () => {
+ for (const width of [0.95, 0.75]) for (let z = 2; z <= 22; z += 0.05) {
+ const { coarse, fine } = resPair(z, MAX)
+ const sum = coarse === fine ? resShare(coarse, z, width, MAX) : resShare(coarse, z, width, MAX) + resShare(fine, z, width, MAX)
+ expect(sum, `zoom ${z.toFixed(2)} width ${width}`).toBeCloseTo(1, 9)
+ }
+ })
+ it('shows no third size anywhere', () => {
+ for (let z = 2; z <= 22; z += 0.05) {
+ const { coarse, fine } = resPair(z, MAX)
+ for (let res = 3; res <= MAX; res++) {
+ if (res === coarse || res === fine) continue
+ expect(resShare(res, z, 0.95, MAX), `res ${res} at zoom ${z.toFixed(2)}`).toBe(0)
+ }
+ }
+ })
+ it('keeps the coarsest and the finest full past their end of the range', () => {
+ expect(resShare(3, 0, 0.95, MAX)).toBe(1)
+ expect(resShare(MAX, 22, 0.95, MAX)).toBe(1)
+ expect(resShare(18, 22, 0.95, 18)).toBe(1)
+ })
+ it('holds a pair fetched at an earlier zoom in full past both its ends, until the next pair arrives', () => {
+ // The website fetched 14 and 15 at zoom 13; the view zoomed on before the
+ // refetch. Its pair is the whole range there is, so it never falls to 0.
+ for (let z = 10; z <= 17; z += 0.05) {
+ const sum = resShare(14, z, 0.95, 15, 14) + resShare(15, z, 0.95, 15, 14)
+ expect(sum, `zoom ${z.toFixed(2)}`).toBeCloseTo(1, 9)
+ }
+ expect(resShare(14, 11, 0.95, 15, 14)).toBe(1)
+ expect(resShare(15, 14.9, 0.95, 15, 14)).toBe(1)
+ expect(resBreaks(14, 0.5, 15, 14)).toEqual([13.25, 13.75])
+ expect(resBreaks(15, 0.5, 15, 14)).toEqual([13.25, 13.75])
+ })
+ it('is a hard step with no width, which is what it was', () => {
+ expect(resShare(14, 13.49, 0, MAX)).toBe(1)
+ expect(resShare(14, 13.5, 0, MAX)).toBe(0)
+ expect(resShare(15, 13.5, 0, MAX)).toBe(1)
+ })
+ it('breaks where the share bends', () => {
+ expect(resBreaks(14, 0.5, MAX)).toEqual([12.25, 12.75, 13.25, 13.75])
+ expect(resBreaks(3, 0.5, MAX)).toEqual([2.25, 2.75])
+ expect(resBreaks(MAX, 0.5, MAX)).toEqual([19.25, 19.75])
+ })
+})
+
+describe('style expressions', () => {
+ const evalRamp = (expr, z) => {
+ if (!Array.isArray(expr)) return expr
+ const stops = expr.slice(3)
+ if (z <= stops[0]) return stops[1]
+ for (let i = 2; i < stops.length; i += 2) {
+ if (z <= stops[i]) {
+ const t = (z - stops[i - 2]) / (stops[i] - stops[i - 2])
+ return stops[i - 1] + t * (stops[i + 1] - stops[i - 1])
+ }
+ }
+ return stops[stops.length - 1]
+ }
+ const evalStep = (expr, z) => {
+ let v = expr[2]
+ for (let i = 3; i < expr.length; i += 2) if (z >= expr[i]) v = expr[i + 1]
+ return v
+ }
+ it('builds an interpolate on zoom with stops that rise', () => {
+ const expr = zoomRamp([13.25, 18.25, 13.25], (z) => ramp(z, { from: 13.25, to: 18.25 }))
+ expect(expr).toEqual(['interpolate', ['linear'], ['zoom'], 13.25, 0, 18.25, 1])
+ })
+ it('carries the per-feature value in the output, not around the ramp', () => {
+ const expr = zoomRamp([10, 12], (z) => ramp(z, { from: 10, to: 12 }), (v) => ['*', ['get', 'op'], v])
+ expect(expr[0]).toBe('interpolate')
+ expect(expr[4]).toEqual(['*', ['get', 'op'], 0])
+ expect(expr[6]).toEqual(['*', ['get', 'op'], 1])
+ })
+ it('follows a size\'s share through its whole level', () => {
+ const expr = zoomRamp(resBreaks(14, 0.95, MAX), (z) => resShare(14, z, 0.95, MAX))
+ for (let z = 11; z <= 15; z += 0.01) expect(evalRamp(expr, z), `zoom ${z.toFixed(2)}`).toBeCloseTo(resShare(14, z, 0.95, MAX), 2)
+ })
+ it('holds a step inside a ramp, within a thousandth of a zoom level', () => {
+ const share = (z) => resShare(20, z, 0.75, MAX) * hexShare(z, { mode: 'auto', mode3D: true })
+ const expr = zoomRamp([...resBreaks(20, 0.75, MAX), ...stepBreaks(19)], share)
+ expect(evalRamp(expr, 19 - STEP_EPS)).toBeCloseTo(1, 2)
+ expect(evalRamp(expr, 19)).toBeCloseTo(0.95, 2)
+ expect(evalRamp(expr, 19.2)).toBeCloseTo(share(19.2), 2)
+ })
+ it('is a constant when there is nothing to bend', () => {
+ expect(zoomRamp([], () => 1)).toBe(1)
+ expect(zoomRamp([], () => 1, (v) => ['*', ['get', 'op'], v])).toEqual(['*', ['get', 'op'], 1])
+ })
+ it('switches a layer off exactly where its share is nothing', () => {
+ const share = (z) => resShare(14, z, 0.95, MAX)
+ const expr = zoomSwitch(resBreaks(14, 0.95, MAX), share)
+ expect(expr[0]).toBe('step')
+ for (let z = 11; z <= 15; z += 0.01) {
+ // Not on the breaks themselves, where the share is exactly 0 on one side.
+ if (resBreaks(14, 0.95, MAX).some((b) => Math.abs(b - z) < 0.011)) continue
+ expect(evalStep(expr, z), `zoom ${z.toFixed(2)}`).toBe(share(z) > 0 ? 1 : 0)
+ }
+ })
+ it('switches the pillars on at zoom 19 in 3D', () => {
+ const expr = zoomSwitch(stepBreaks(19), (z) => pointShare(z, { mode: 'auto', mode3D: true }))
+ expect(evalStep(expr, 18.9)).toBe(0)
+ expect(evalStep(expr, 19)).toBe(1)
+ expect(evalStep(expr, 21)).toBe(1)
+ })
+ it('samples a curve at every quarter, ends included', () => {
+ expect(sampledBreaks(11.25, 12.1)).toEqual([11.25, 11.5, 11.75, 12, 12.1])
+ })
+})
+
+import { hexSlots, hexFillOpacity, hexBarHeight, hexBarSwitch, pointFillOpacity, pointStrokeOpacity, pillarSwitch } from '../zoomfade.js'
+
+describe('the two hex layers', () => {
+ it('gives the even sizes slot a and the odd ones slot b', () => {
+ expect(hexSlots(13.2, MAX)).toEqual({ a: 14, b: 15 })
+ expect(hexSlots(14.2, MAX)).toEqual({ a: 16, b: 15 })
+ })
+ it('leaves the size that stays in its slot when the pair moves up', () => {
+ for (let n = 3; n < 19; n++) {
+ const before = hexSlots(n - 0.01, MAX), after = hexSlots(n, MAX)
+ const stays = n + 1
+ const slot = stays % 2 === 0 ? 'a' : 'b'
+ expect(before[slot], `zoom ${n}`).toBe(stays)
+ expect(after[slot], `zoom ${n}`).toBe(stays)
+ }
+ })
+ it('swaps the other slot only while both its sizes show nothing', () => {
+ for (const width of [0.95, 0.75]) for (let n = 3; n < 19; n++) {
+ expect(resShare(n, n, width, MAX), `leaving at zoom ${n}`).toBe(0)
+ expect(resShare(n + 2, n, width, MAX), `arriving at zoom ${n}`).toBe(0)
+ expect(resShare(n + 1, n, width, MAX), `staying at zoom ${n}`).toBe(1)
+ }
+ })
+ it('uses one slot at the end of the range', () => {
+ expect(hexSlots(21.5, MAX)).toEqual({ a: null, b: 21 })
+ })
+})
+
+describe('paint', () => {
+ const stopsOf = (expr) => expr.slice(3).filter((_, i) => i % 2 === 0)
+ const rises = (zs) => zs.every((z, i) => i === 0 || z > zs[i - 1])
+ const flat = { mode: 'auto', mode3D: false }, tilted = { mode: 'auto', mode3D: true }
+
+ it('only ever builds stops that rise, which MapLibre refuses otherwise', () => {
+ for (const view of [flat, tilted, { mode: 'hex', mode3D: true }, { mode: 'points', mode3D: false }]) {
+ for (let res = 3; res <= MAX; res++) {
+ for (const expr of [hexFillOpacity(res, view, MAX), hexBarHeight(res, view, MAX), hexBarSwitch(res, view, MAX)]) {
+ expect(rises(stopsOf(expr)), `${JSON.stringify(view)} res ${res}`).toBe(true)
+ }
+ }
+ for (const expr of [pointFillOpacity(view), pointStrokeOpacity(view)]) {
+ if (Array.isArray(expr) && expr[0] === 'interpolate') expect(rises(stopsOf(expr)), JSON.stringify(view)).toBe(true)
+ }
+ }
+ })
+ it('keeps zoom at the top of every expression', () => {
+ const inner = (e) => JSON.stringify(e.slice(3)).includes('"zoom"')
+ for (const expr of [hexFillOpacity(14, flat, MAX), hexBarHeight(20, tilted, MAX), hexBarSwitch(20, tilted, MAX), pointFillOpacity(flat), pointStrokeOpacity(flat), pillarSwitch(tilted)]) {
+ expect(['interpolate', 'step']).toContain(expr[0])
+ expect(inner(expr)).toBe(false)
+ }
+ })
+ it('multiplies a cell\'s own opacity and a bar\'s own height by the share', () => {
+ expect(hexFillOpacity(14, flat, MAX)[4]).toEqual(['*', ['get', 'op'], 0])
+ expect(hexBarHeight(14, tilted, MAX)[4]).toEqual(['*', ['get', 'height'], 0])
+ })
+ it('drops the bars to 95% at zoom 19 in auto, and not in hex mode', () => {
+ const last = (e) => e[e.length - 1]
+ expect(last(hexBarHeight(MAX, tilted, MAX))).toEqual(['*', ['get', 'height'], 0.95])
+ expect(last(hexBarHeight(MAX, { mode: 'hex', mode3D: true }, MAX))).toEqual(['*', ['get', 'height'], 1])
+ })
+ it('gives an earlier ride no fill and the product of both shares on its stroke', () => {
+ const fill = pointFillOpacity(flat)
+ expect(fill[fill.length - 1]).toEqual(['case', ['==', ['get', 'backlog'], 1], 0, ['*', ['get', 'fop'], 1]])
+ const stroke = pointStrokeOpacity(flat)
+ const at = (z) => stroke[3 + 2 * stopsOf(stroke).indexOf(z) + 1]
+ // Zoom 15.75: the points are halfway (0.5), earlier rides at 0.6.
+ expect(at(15.75)).toEqual(['case', ['==', ['get', 'backlog'], 1], ['*', ['get', 'op'], 0.3], ['*', ['get', 'op'], 0.5]])
+ expect(at(18.75)).toEqual(['case', ['==', ['get', 'backlog'], 1], ['*', ['get', 'op'], 1], ['*', ['get', 'op'], 1]])
+ })
+ it('draws the points at full strength in points mode, earlier rides on their own ramp', () => {
+ const view = { mode: 'points', mode3D: false }
+ expect(pointFillOpacity(view)).toEqual(['case', ['==', ['get', 'backlog'], 1], 0, ['*', ['get', 'fop'], 1]])
+ const stroke = pointStrokeOpacity(view)
+ expect(stroke[4]).toEqual(['case', ['==', ['get', 'backlog'], 1], ['*', ['get', 'op'], 0], ['*', ['get', 'op'], 1]])
+ })
+ it('keeps the pillars on at every zoom in points mode', () => {
+ expect(pillarSwitch({ mode: 'points', mode3D: true })).toBe(1)
+ })
+})
diff --git a/app/src/app.js b/app/src/app.js
index ed8ced11..0daa3157 100644
--- a/app/src/app.js
+++ b/app/src/app.js
@@ -3230,7 +3230,7 @@ function refreshWhatsNewBadge() {
// Was two FABs (layer-toggle: both/points/hex · mode3d-toggle: 2D/3D, #147
// phase 2) — merged into VIEW_STATES' 5-state cycle (maplayers.js) to free a
// FAB slot. Persisted like the sound mode; unknown/corrupt storage falls back
-// to both/2D (index 1), the app's cold default — see loadViewIndex in
+// to auto/2D (index 1), the app's cold default — see loadViewIndex in
// settings.js (guarded + unit-tested, #338).
function saveViewIndex(i) {
@@ -3251,7 +3251,7 @@ const VIEW_ICONS = {
// Hexagon (the hex-heatmap glyph) with a point dot inside — visually
// combines the other two modes' glyphs instead of reusing a generic
// stacked-layers icon that doesn't read as "points + hex together".
- both2d: ``,
// Faded hex-prism outline with one solid pillar standing inside — the 3D
- // twin of both2d's hex-outline-plus-center-dot.
- both3d: `
+ // twin of auto2d's hex-outline-plus-center-dot.
+ auto3d: `
diff --git a/app/src/hexgrid.js b/app/src/hexgrid.js
index 547d8b87..4ddae4fe 100644
--- a/app/src/hexgrid.js
+++ b/app/src/hexgrid.js
@@ -1,6 +1,8 @@
-// Pure-JS port of CoreScope's server hex grid (cmd/server/hexgrid.go) so the
-// app's live map uses the SAME cells as the analyzer coverage map. Pointy-top
-// hexes over Web Mercator; cell id "res:q:r".
+// Pure-JS port of the server's hex grid (server/internal/geo/hexgrid.go) so
+// the app's live map draws the same cells as the website. Pointy-top hexes over
+// Web Mercator; cell id "res:q:r".
+import { resForZoom } from './zoomfade.js'
+
const R = 6378137.0;
function mercator(lat, lon) {
@@ -10,37 +12,32 @@ function invMercator(x, y) {
return [(2 * Math.atan(Math.exp(y / R)) - Math.PI / 2) * 180 / Math.PI, x / R * 180 / Math.PI]; // [lat,lon]
}
+// One size per zoom level (#634), the server's rule: a cell is about
+// HEX_TARGET_PX tall on screen at every zoom, point to point. The size is the
+// circumradius in Web Mercator units and halves with every resolution.
+// MERC_UPP_Z0 is Web Mercator units per pixel at Leaflet zoom 0 (world / 256).
+//
+// Until #634 the app had ten bands of a fixed size, 1500 down to 3 Mercator
+// units, which the website had left behind: a cell ran from 19 to 75 px inside
+// one band, and a change of band was a jump of a factor two or more.
+export const HEX_TARGET_PX = 28
+const MERC_UPP_Z0 = 156543.03392
export function hexSizeForRes(res) {
- switch (true) {
- case res >= 15: return 3;
- case res === 14: return 5;
- case res === 13: return 10;
- case res === 12: return 20;
- case res === 11: return 40;
- case res === 10: return 90;
- case res === 9: return 180;
- case res === 8: return 360;
- case res === 7: return 720;
- default: return 1500;
- }
+ return (HEX_TARGET_PX / 2) * MERC_UPP_Z0 / Math.pow(2, res)
}
-// hexResForZoom maps the map zoom to a hex resolution. Coarser bands mirror the
-// server's zoomToHexRes; res 12–15 (20/10/5/3 m) extend it so the live map gets
-// finer cells than the server's 40 m floor when zoomed in for close-range
-// localization (down to 3 m at max zoom). Below GPS accuracy a single point is
-// mostly noise, but the aggregated heat still surfaces the hotspot.
+// The finest resolution the app draws. The server stops at 18, which is where
+// the website's zoom ends. The app is zoomed in further for the walk-in, and
+// 21 keeps a cell at 28 px up to MapLibre zoom 20: 2.1 Mercator units, finer
+// than the 3 the old bands ended on (docs/2026-06-29-hex-resolution-zoom.md).
+// Below GPS accuracy a single cell is mostly noise; the hotspot the cells pile
+// up into is what reads.
+export const HEX_MAX_RES = 21
+
+// hexResForZoom: the resolution drawn in full at a MapLibre zoom. The map
+// blends it with its neighbour around every change (zoomfade.js).
export function hexResForZoom(z) {
- if (z >= 19) return 15;
- if (z >= 18) return 14;
- if (z >= 17) return 13;
- if (z >= 16) return 12;
- if (z >= 15) return 11;
- if (z >= 13) return 10;
- if (z >= 11) return 9;
- if (z >= 9) return 8;
- if (z >= 7) return 7;
- return 6;
+ return resForZoom(z, HEX_MAX_RES)
}
function hexRound(q, r) {
diff --git a/app/src/hexlabels.js b/app/src/hexlabels.js
index 468a4fe9..ae5fbaec 100644
--- a/app/src/hexlabels.js
+++ b/app/src/hexlabels.js
@@ -1,19 +1,21 @@
// What a hex cell says about who was heard in it (#556).
//
// The cell's colour is the strongest signal heard there; the label is the
-// nodes: the 4-character prefixes of the ids heard in the cell, newest first,
-// three at most. A prefix is the house form for an id (idPrefix, feed.js),
+// node heard there last, as the 4-character prefix of its id, with the count
+// of the others on a line under it. A prefix is the house form for an id (idPrefix, feed.js),
// never a name, and it comes only from a record that carries an id of a node:
// a 1-byte hash names one of 256 (direct_hash, path_hash, names.js), and a
// refused identity carries no id at all (#558). Neither is printed as who was
// here.
//
// Kasper chose prefixes over a count (2026-09-04); #549's reach outline will
-// share this label's spot, so the two never fight for it.
+// share this label's spot, so the two never fight for it. It was three
+// prefixes on one line until #634 made a cell 28 px tall at every zoom, about
+// 24 wide: four characters at 10 px fit that, three prefixes ran over the
+// neighbours (Kasper, 2026-09-29).
import { isHashIdKind } from './names.js'
-export const HEX_LABEL_MIN_ZOOM = 16
-export const HEX_LABEL_MAX = 3
+export const HEX_LABEL_MAX = 1
const PREFIX_CHARS = 4
export function hexCellLabel(records) {
@@ -26,13 +28,7 @@ export function hexCellLabel(records) {
if (!seen.length) return ''
const shown = seen.slice(0, HEX_LABEL_MAX)
const more = seen.length - shown.length
- return shown.join(' ') + (more > 0 ? ` +${more}` : '')
-}
-
-// Four characters at 10px need a cell wider than the label: at zoom 16 a cell
-// is about 110 m across and the label fits with room, at 15 it does not.
-export function showHexLabels(zoom) {
- return Number(zoom) >= HEX_LABEL_MIN_ZOOM
+ return shown.join(' ') + (more > 0 ? `\n+${more}` : '')
}
// planHexLabels: what the map changes when the labelled cells in view change.
diff --git a/app/src/huntmap.js b/app/src/huntmap.js
index 22a67a1e..23d0146f 100644
--- a/app/src/huntmap.js
+++ b/app/src/huntmap.js
@@ -1,4 +1,5 @@
-import { hexCellAt, hexBoundary, hexResForZoom } from './hexgrid.js'
+import { hexCellAt, hexBoundary, hexResForZoom, HEX_MAX_RES } from './hexgrid.js'
+import { pointShare, backlogShare, labelShare, hexSlots, resForZoom, hexFillOpacity, hexBarHeight, hexBarSwitch, pointFillOpacity, pointStrokeOpacity, pillarSwitch } from './zoomfade.js'
import { rssiTier, tierColorVar, fillOpacity, effectivePlotOffset, extrusionHeight, tintOver, pillarAlpha, EXTRUSION_LIGHT_INTENSITY } from './signal.js'
import { getConfig } from './config.js'
import { nodesInView, driftPresentation, groupSenderPointsForNodes, estimateFor, circleRing } from './nodelayer.js'
@@ -11,8 +12,8 @@ import { coverageStars, coverageFeatures, assignHues, isRepeaterHearing, selecti
import { createRayLayer } from './raylayer.js'
import { octagonRing, pillarRadiusM, collapsePillars, PILLAR_MERGE_M } from './pointmarker.js'
import { recordsKey, lastValueCache, hueKey, selectionKey, ownersKey } from './rendercache.js'
-import { currentRideStart, isBacklog, showBacklogPoints } from './rides.js'
-import { hexCellLabel, showHexLabels, planHexLabels } from './hexlabels.js'
+import { currentRideStart, isBacklog } from './rides.js'
+import { hexCellLabel, planHexLabels } from './hexlabels.js'
import { senderText } from './receptionlog.js'
import { pickName } from './feed.js'
import { skyForHour, currentHour } from './sky.js'
@@ -53,7 +54,9 @@ const bareStyle = (bg) => ({ version: 8, sources: {}, layers: [{ id: 'bg', type:
export const LAYER_ORDER = [
'hillshade', // terrain shading, under everything we draw
'trail',
- 'hex', 'hex-3d',
+ // Two cell sizes at a time (#634): the second layer of each pair holds the
+ // size that is taking over from, or handing over to, the first.
+ 'hex', 'hex-b', 'hex-3d', 'hex-3d-b',
// The noise floor per cell (#410) takes the signal cells' place while it is
// on, so it sits where they do: under the buildings and the points.
'noise',
@@ -102,6 +105,20 @@ const POINT_PILLAR_MIN_RADIUS_PX = 4
// the same number rather than two copies of 0.5 that could drift apart.
const TRAIL_OPACITY = 0.5
+// What a zoom step asks of the draw while the fingers move (#634). What a
+// zoom fades is the style's work and costs nothing here. `data` moves where a
+// layer's share leaves nothing, so what it shows has to be built at all: that
+// takes the full draw. `cells` moves where a size changes: the pair of hex
+// layers at every whole zoom, and the one size the labels and the noise draw
+// at every half. Only the layers that hold cells are rebuilt for that.
+export function zoomKeys(z, view, maxRes) {
+ const s = hexSlots(z, maxRes)
+ return {
+ data: [pointShare(z, view) > 0 ? 1 : 0, backlogShare(z, view) > 0 ? 1 : 0, labelShare(z) > 0 ? 1 : 0].join('|'),
+ cells: [s.a, s.b, resForZoom(z, maxRes)].join('|'),
+ }
+}
+
export function createHuntMap(containerId) {
const stub = { setPosition() {}, centerOn() {}, recenter() {}, onFollowChange() {}, render() {}, setView() {}, applyBasemap() {}, focusReception() {}, setAttenuator() {}, setBearing() {}, onGestureRotate() {}, setHighlight() {}, onMarkerFocus() {}, setNodePositions() {}, releaseFollow() {}, setLookAhead() {}, setNodeLayer() {}, setExaggeration() {}, setNoise() {}, pulse() {}, destroy() {} }
// Degrade to a no-op map (never throw during app init) when MapLibre did not
@@ -131,7 +148,7 @@ export function createHuntMap(containerId) {
} catch (e) { return stub }
map.addControl(new maplibregl.AttributionControl({ compact: true }))
- let mode = 'both', lastRecords = [], lastSelected = null
+ let mode = 'auto', lastRecords = [], lastSelected = null
let highlightId = null, onMarkerFocusCb = null, rotateCb = null, mode3D = false
// Terrain (#396): the 3D view raises it (Kasper, 2026-09-06: no switch of
// its own), at the exaggeration from Settings, applied through terrainPlan.
@@ -301,6 +318,19 @@ export function createHuntMap(containerId) {
map.on('rotate', () => { if (rotateCb && !settingBearing) rotateCb(map.getBearing()) })
// Hex resolution depends on zoom — rebuild once the zoom settles.
map.on('zoomend', () => draw())
+ // While the fingers move, only what zoomKeys names is redrawn: the full
+ // draw when a layer has to be built at all, the cell layers when a size
+ // changed, nothing when only a share moved.
+ let zoomDrawn = { data: '', cells: '' }
+ // The selection of the last full draw, for drawCells (below).
+ let cellsFor = { sel: null, owners: '' }
+ const zoomKeysNow = () => zoomKeys(map.getZoom(), { mode, mode3D }, HEX_MAX_RES)
+ map.on('zoom', () => {
+ fadeHexLabels()
+ const k = zoomKeysNow()
+ if (k.data !== zoomDrawn.data) draw()
+ else if (k.cells !== zoomDrawn.cells) drawCells()
+ })
// ---- feature builders (GeoJSON sources are updated via setData) ----
// The current ride's first reception (#556): everything before it is
@@ -317,7 +347,9 @@ export function createHuntMap(containerId) {
// misses one input serves the previous tick's answer for a different question.
const pointsCache = lastValueCache()
function buildPointsFC(records, sel, owners) {
- const outlines = showBacklogPoints(map.getZoom())
+ // An earlier ride is in the collection from the zoom where it starts to
+ // show; how much of it shows is the style's (pointStrokeOpacity).
+ const outlines = backlogShare(map.getZoom(), { mode, mode3D }) > 0
const sig = recordsKey(records), hues = hueKey(coverageHue)
// Unsignable on either axis means recompute: a key carrying "null" would
// match another set that also could not be signed. The selection joined
@@ -331,10 +363,10 @@ export function createHuntMap(containerId) {
}
function buildPointsFCUncached(records, outlines, sel) {
const feats = []
- // Backlog (#556): below BACKLOG_OUTLINE_ZOOM a reception from an earlier
- // ride is coverage only, its hex cell; from that zoom it comes back as an
- // outline, in its tier colour, no fill. The ride itself is drawn filled,
- // as before.
+ // Backlog (#556): zoomed out, a reception from an earlier ride is coverage
+ // only, its hex cell; zooming in it comes back as an outline, in its tier
+ // colour, no fill, fading in over BACKLOG_FADE_2D (#634). The ride itself
+ // is drawn filled, as before.
const rideStart = rideStartFor(records)
for (const r of records) {
if (r.lat == null || r.lon == null) continue
@@ -423,9 +455,10 @@ export function createHuntMap(containerId) {
// A cell's colour and height come from the best RSSI in it and the attenuator
// offset, so the answer changes only when the records, the zoom resolution or
// that offset do — all three are in the key (#462).
- const hexCache = lastValueCache()
- function buildHexFC(records, sel, owners) {
- const res = hexResForZoom(map.getZoom()) // finer cells the more you zoom in
+ // One cache per hex layer (#634): two cell sizes are drawn at a time, and
+ // a single slot would rebuild both on every tick.
+ const hexCache = { hex: lastValueCache(), 'hex-b': lastValueCache() }
+ function buildHexFC(records, sel, owners, res, slot) {
// An unsignable set must not be cached under the string "null|10|0", which
// is a perfectly good cache key and exactly the wrong one — the null has to
// survive into the lookup.
@@ -435,7 +468,7 @@ export function createHuntMap(containerId) {
// serve the other theme's cells and bars from the cache. So is the
// selection (#624), which dims cells without changing a record, and so are
// the owners (#661), which decide the cells it keeps lit.
- return hexCache.get(sig === null || owners === null ? null : `${sig}|${res}|${currentOffset()}|${cssVar('--ch-basemap')}|${selectionKey(sel)}|${owners}`, () => buildHexFCUncached(records, res, sel))
+ return hexCache[slot].get(sig === null || owners === null ? null : `${sig}|${res}|${currentOffset()}|${cssVar('--ch-basemap')}|${selectionKey(sel)}|${owners}`, () => buildHexFCUncached(records, res, sel))
}
function buildHexFCUncached(records, res, sel) {
// A cell holds receptions from several repeaters at once, so it cannot ask
@@ -588,7 +621,7 @@ export function createHuntMap(containerId) {
// darkened a bar against its own cell (#412). Re-applied here like the
// sky, since setStyle drops it. Guarded for an older MapLibre.
if (typeof map.setLight === 'function') map.setLight({ anchor: 'viewport', intensity: EXTRUSION_LIGHT_INTENSITY })
- for (const id of ['trail', 'hex', 'noise', 'points', 'points-3d', 'highlight', 'here', 'nodedrift', 'nodecircle', 'reach', 'pulse', 'pulse-3d', NODE_GLYPH_SOURCE, NODE_DOT_SOURCE]) {
+ for (const id of ['trail', 'hex', 'hex-b', 'noise', 'points', 'points-3d', 'highlight', 'here', 'nodedrift', 'nodecircle', 'reach', 'pulse', 'pulse-3d', NODE_GLYPH_SOURCE, NODE_DOT_SOURCE]) {
if (!map.getSource(id)) map.addSource(id, { type: 'geojson', data: EMPTY })
}
// One decision for all four signal layers (#266) — see maplayers.js. Both
@@ -598,9 +631,16 @@ export function createHuntMap(containerId) {
const shown = (id) => (vis[id] ? 'visible' : 'none')
if (!map.getLayer('trail')) map.addLayer({ id: 'trail', type: 'line', source: 'trail',
paint: { 'line-color': cssVar('--ch-muted'), 'line-width': 3, 'line-opacity': TRAIL_OPACITY } })
- if (!map.getLayer('hex')) map.addLayer({ id: 'hex', type: 'fill', source: 'hex',
- layout: { visibility: shown('hex') },
- paint: { 'fill-color': ['get', 'color'], 'fill-opacity': ['get', 'op'] } })
+ // The opacity and the bar height of the hex layers, and the opacity of the
+ // points, are written by applyFades: they are zoom expressions that depend
+ // on the view and on the cell size a layer holds (#634). A style load drops
+ // them with the layers, so the signature is cleared here.
+ fadeSig = ''
+ // Each id spelled out, not looped: huntmap-order.test.js reads the adds
+ // from this file as text.
+ const hexPaint = { 'fill-color': ['get', 'color'], 'fill-opacity': 0 }
+ if (!map.getLayer('hex')) map.addLayer({ id: 'hex', type: 'fill', source: 'hex', layout: { visibility: shown('hex') }, paint: hexPaint })
+ if (!map.getLayer('hex-b')) map.addLayer({ id: 'hex-b', type: 'fill', source: 'hex-b', layout: { visibility: shown('hex-b') }, paint: hexPaint })
// Flat in 3D too: a noise floor has no height to give a pillar. The band
// colours are baked in like every overlay's; a theme swap re-adds them.
if (!map.getLayer('noise')) map.addLayer({ id: 'noise', type: 'fill', source: 'noise',
@@ -611,14 +651,16 @@ export function createHuntMap(containerId) {
// tier is what made a faint bar a solid purple on a 19% tint (#412). The
// bar takes 'pillar', the cell's tint pre-mixed over the background, and
// draws it opaque: no translucent compounding, shared walls depth-test.
- if (!map.getLayer('hex-3d')) map.addLayer({ id: 'hex-3d', type: 'fill-extrusion', source: 'hex',
- layout: { visibility: shown('hex-3d') },
- paint: { 'fill-extrusion-color': ['get', 'pillar'], 'fill-extrusion-height': ['get', 'height'],
- // MapLibre shades extrusion sides darker toward their base by default
- // (#412). On a building that reads as depth; on these it reads as a
- // different tier, because colour is the signal the palette carries.
- 'fill-extrusion-vertical-gradient': false,
- 'fill-extrusion-base': 0, 'fill-extrusion-opacity': 1 } })
+ const barPaint = { 'fill-extrusion-color': ['get', 'pillar'], 'fill-extrusion-height': 0,
+ // MapLibre shades extrusion sides darker toward their base by default
+ // (#412). On a building that reads as depth; on these it reads as a
+ // different tier, because colour is the signal the palette carries.
+ 'fill-extrusion-vertical-gradient': false,
+ // 0 until applyFades has written the switch: a bar of no height still
+ // draws an opaque polygon on the ground.
+ 'fill-extrusion-base': 0, 'fill-extrusion-opacity': 0 }
+ if (!map.getLayer('hex-3d')) map.addLayer({ id: 'hex-3d', type: 'fill-extrusion', source: 'hex', layout: { visibility: shown('hex-3d') }, paint: barPaint })
+ if (!map.getLayer('hex-3d-b')) map.addLayer({ id: 'hex-3d-b', type: 'fill-extrusion', source: 'hex-b', layout: { visibility: shown('hex-3d-b') }, paint: barPaint })
// Buildings reuse the hosted style's own vector source (already fetched for
// the 2D basemap) — only present on the hosted OpenFreeMap style, not the
// bare fallback, hence the source guard.
@@ -651,10 +693,10 @@ export function createHuntMap(containerId) {
// A backlog reception (#556) has no fill and a heavier stroke: colour and
// place stay, the fill says "this ride".
paint: { 'circle-radius': 8, 'circle-color': ['get', 'color'],
- 'circle-opacity': ['case', ['==', ['get', 'backlog'], 1], 0, ['get', 'fop']],
+ 'circle-opacity': 0,
'circle-stroke-color': ['get', 'color'],
'circle-stroke-width': ['case', ['==', ['get', 'backlog'], 1], 1.5, 1],
- 'circle-stroke-opacity': ['get', 'op'] } })
+ 'circle-stroke-opacity': 0 } })
// The pulse (#556): one ring on the reception that just arrived, animated
// by pulse() below through the paint properties, above the points and
// shown only where they are.
@@ -796,7 +838,7 @@ export function createHuntMap(containerId) {
const glyph = nodeGlyphAt(e.point)
if (glyph) { onGlyphTap(glyph); return }
if (!coverageSel.size) return
- const layers = ['points', 'points-3d', 'hex', 'hex-3d'].filter((id) => map.getLayer(id))
+ const layers = ['points', 'points-3d', 'hex', 'hex-b', 'hex-3d', 'hex-3d-b'].filter((id) => map.getLayer(id))
if (!map.queryRenderedFeatures(e.point, { layers }).length) clearCoverageSelection()
})
for (const layerId of NODE_GLYPH_LAYERS) {
@@ -845,10 +887,14 @@ export function createHuntMap(containerId) {
// Only the reach stop reads it: outside it no dot takes a star's hue and
// nothing dims, so the fold is skipped there.
const owners = cov ? ownersKey(records, ownerOf) : ''
- map.getSource('hex').setData(vis.hex || vis['hex-3d'] ? buildHexFC(records, sel, owners) : EMPTY)
- map.getSource('noise').setData(vis.noise ? buildNoiseFC() : EMPTY)
- map.getSource('points').setData(vis.points ? buildPointsFC(records, sel, owners) : EMPTY)
- map.getSource('points-3d').setData(vis['points-3d'] ? buildPoints3DFC(records, sel, owners) : EMPTY)
+ zoomDrawn.data = zoomKeysNow().data
+ const zoom = map.getZoom(), view = { mode, mode3D }
+ // Nothing is built for a layer that shows nothing at this zoom: in auto
+ // the points wait for their zoom, and in 3D that is what keeps a pillar
+ // per reception off the GPU until the closest one (#634).
+ const pointsOn = pointShare(zoom, view) > 0
+ map.getSource('points').setData(vis.points && pointsOn ? buildPointsFC(records, sel, owners) : EMPTY)
+ map.getSource('points-3d').setData(vis['points-3d'] && pointsOn ? buildPoints3DFC(records, sel, owners) : EMPTY)
map.getSource('trail').setData(buildTrailFC())
// The trail belongs to no repeater, so it is never part of a selection and
// always steps back with one. One LineString with no properties, so this
@@ -857,7 +903,51 @@ export function createHuntMap(containerId) {
map.getSource('highlight').setData(buildHighlightFC())
map.getSource('here').setData(buildHereFC())
drawNodeLayer(records, cov)
+ cellsFor = { sel, owners }
+ drawCells()
+ }
+
+ // The layers that hold cells, at the sizes of this zoom: the two hex layers
+ // (hexSlots, each resolution in its own layer; one whose share is nothing
+ // stays empty), the noise and the labels. With the selection of the last
+ // full draw, so a size change mid-pinch needs no coverage pass.
+ function drawCells() {
+ if (!map.getSource('hex')) return
+ const records = lastRecords, vis = visibleLayers()
+ const slots = hexSlots(map.getZoom(), HEX_MAX_RES)
+ const hexOn = vis.hex || vis['hex-3d']
+ for (const [id, res] of [['hex', slots.a], ['hex-b', slots.b]]) {
+ map.getSource(id).setData(hexOn && res != null ? buildHexFC(records, cellsFor.sel, cellsFor.owners, res, id) : EMPTY)
+ }
+ applyFades(slots)
+ map.getSource('noise').setData(vis.noise ? buildNoiseFC() : EMPTY)
drawHexLabels(records, vis['hex-labels'])
+ zoomDrawn.cells = zoomKeysNow().cells
+ }
+
+ // The zoom expressions of the hex and point layers (zoomfade.js, #634),
+ // written when the view or the cell size a layer holds has changed. Not on
+ // every draw: a tick that changes no input would re-validate nine
+ // expressions for nothing.
+ let fadeSig = ''
+ function applyFades(slots) {
+ const view = { mode, mode3D }
+ const sig = [mode, mode3D ? 3 : 2, slots.a, slots.b].join('|')
+ if (sig === fadeSig || !map.getLayer('hex')) return
+ fadeSig = sig
+ for (const [flat, bar, res] of [['hex', 'hex-3d', slots.a], ['hex-b', 'hex-3d-b', slots.b]]) {
+ if (res == null) {
+ map.setPaintProperty(flat, 'fill-opacity', 0)
+ map.setPaintProperty(bar, 'fill-extrusion-opacity', 0)
+ continue
+ }
+ map.setPaintProperty(flat, 'fill-opacity', hexFillOpacity(res, view, HEX_MAX_RES))
+ map.setPaintProperty(bar, 'fill-extrusion-height', hexBarHeight(res, view, HEX_MAX_RES))
+ map.setPaintProperty(bar, 'fill-extrusion-opacity', hexBarSwitch(res, view, HEX_MAX_RES))
+ }
+ map.setPaintProperty('points', 'circle-opacity', pointFillOpacity(view))
+ map.setPaintProperty('points', 'circle-stroke-opacity', pointStrokeOpacity(view))
+ map.setPaintProperty('points-3d', 'fill-extrusion-opacity', pillarSwitch(view))
}
// The noise cells at this zoom's resolution, rebuilt when a sample arrives
@@ -870,8 +960,9 @@ export function createHuntMap(containerId) {
// ---- hex labels (#556) ----
// Who was heard in a cell, as id prefixes (hexlabels.js decides the text),
// drawn as HTML markers like the node layer: the bare fallback style has no
- // glyphs, so a symbol layer would draw nothing there. Only from
- // HEX_LABEL_MIN_ZOOM and only for cells in view. One marker per cell id,
+ // glyphs, so a symbol layer would draw nothing there. They fade in over
+ // LABEL_FADE (#634, it was a step at zoom 16), name the cell size drawn in
+ // full at this zoom, and only cells in view. One marker per cell id,
// kept while the cell stays in view: planHexLabels decides which markers a
// draw adds, relabels or removes, so a new reception in one cell touches
// that cell's marker and a tick that changes nothing touches none.
@@ -879,8 +970,15 @@ export function createHuntMap(containerId) {
function clearHexLabels() {
hexLabelMarkers.forEach((m) => m.remove()); hexLabelMarkers.clear()
}
+ // Markers are DOM, so their share is a style on the element, written on
+ // every zoom step.
+ function fadeHexLabels() {
+ if (!hexLabelMarkers.size) return
+ const share = String(labelShare(map.getZoom()))
+ hexLabelMarkers.forEach((m) => { m.getElement().style.opacity = share })
+ }
function drawHexLabels(records, on) {
- if (!on || !showHexLabels(map.getZoom())) { if (hexLabelMarkers.size) clearHexLabels(); return }
+ if (!on || !(labelShare(map.getZoom()) > 0)) { if (hexLabelMarkers.size) clearHexLabels(); return }
const res = hexResForZoom(map.getZoom())
const b = map.getBounds()
const cells = new Map()
@@ -911,6 +1009,7 @@ export function createHuntMap(containerId) {
el.textContent = it.label
hexLabelMarkers.set(it.id, new maplibregl.Marker({ element: el }).setLngLat([it.lon, it.lat]).addTo(map))
}
+ fadeHexLabels()
}
// ---- pulse (#556) ----
@@ -938,6 +1037,9 @@ export function createHuntMap(containerId) {
const vis = layerVisibility({ mode, mode3D })
const id = vis.pulse ? 'pulse' : vis['pulse-3d'] ? 'pulse-3d' : null
if (!id || !map.getSource(id) || !map.getLayer(id)) return
+ // And only at a zoom that draws them (#634): a ring around a point that
+ // is not on the map marks nothing.
+ if (!(pointShare(map.getZoom(), { mode, mode3D }) > 0)) return
if (pulseTimer) { clearInterval(pulseTimer); pulseTimer = null }
const tier = rssiTier(rec.rssi, currentOffset())
const color = cssVar(tierColorVar(tier))
@@ -1229,15 +1331,15 @@ export function createHuntMap(containerId) {
function setBearing(deg) { settingBearing = true; try { map.setBearing(deg) } finally { settingBearing = false } }
function onGestureRotate(cb) { rotateCb = cb }
// Applies the current layer decision to the live style. Both the layer-mode
- // switch and the 3D toggle need it: in 3D the mode decides whether hex is
- // drawn flat (under the pillars) or extruded (#266).
+ // switch and the 3D toggle need it: the mode and the dimension together
+ // decide which layers exist (#266, maplayers.js).
function applyLayerVisibility() {
const vis = visibleLayers()
// Every layer layerVisibility decides, or the FAB and the style load
// disagree again — the exact drift #266 pulled the decision out for. The
// 3D pulse is the one that shows it: it loads 'none' in 2D, so a tap into
// 3D that skipped it would leave the flash off until the next style load.
- for (const id of ['hex', 'hex-3d', 'noise', 'points', 'points-3d', 'pulse', 'pulse-3d']) {
+ for (const id of ['hex', 'hex-b', 'hex-3d', 'hex-3d-b', 'noise', 'points', 'points-3d', 'pulse', 'pulse-3d']) {
if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', vis[id] ? 'visible' : 'none')
}
}
diff --git a/app/src/maplayers.js b/app/src/maplayers.js
index 6782d4cd..d22b1dba 100644
--- a/app/src/maplayers.js
+++ b/app/src/maplayers.js
@@ -6,17 +6,25 @@
// visible than a FAB tap. And huntmap.js is DOM-bound, so AGENTS.md §5 keeps
// it out of the unit suite; this is the part worth pinning.
//
-// The 3D rule that is not obvious: when pillars are drawn, the hex layer is
-// drawn FLAT rather than extruded. buildHexFC gives a cell the maximum RSSI
+// What a layer shows at a zoom is not decided here: that is a share, handed
+// to the style as a zoom expression (zoomfade.js, #634). This says which
+// layers exist for a view at all.
+//
+// 'auto' took the place of 'both' in #634: hex and points together, each at
+// the share its zoom gives it.
+//
+// The 3D rule that is not obvious: buildHexFC gives a cell the maximum RSSI
// inside it, so an extruded bar is by construction at least as tall as every
-// pillar standing in that cell — and both are fill-extrusion sharing one depth
+// pillar standing in that cell, and both are fill-extrusion sharing one depth
// pass with depth write, so the pillar's fragments fail the depth test and are
-// discarded. (fill-extrusion-opacity does not disable depth writes.) Flattening
-// the hex removes the occluder while keeping the coverage context under the
-// pillars. Extruded bars are still what 'hex' mode shows in 3D — there are no
-// pillars to hide there, and the bars are the whole point of that view.
+// discarded. (fill-extrusion-opacity does not disable depth writes.) Until
+// #634 the hex was therefore drawn FLAT whenever pillars shared the scene.
+// Now the pillars only arrive at zoom 19 (POINT_STEP_3D), and from there the
+// bars stand at 95% of their height (HEX_UNDER_PILLARS_3D), so the strongest
+// pillar of a cell clears its bar. Up to that zoom the bars are the whole
+// view, which is what flat cells under no pillars could not give.
-const MODES = ['both', 'hex', 'points']
+const MODES = ['auto', 'hex', 'points']
// The 5-state combined layer+3D cycle (#258): merges the old layer-toggle FAB
// (both/hex/points) and the 2D/3D FAB into one, freeing a FAB slot. "2D · hex
@@ -24,10 +32,10 @@ const MODES = ['both', 'hex', 'points']
// matrix (decided 2026-07-16).
export const VIEW_STATES = [
{ mode: 'points', mode3D: false },
- { mode: 'both', mode3D: false },
+ { mode: 'auto', mode3D: false },
{ mode: 'hex', mode3D: true },
{ mode: 'points', mode3D: true },
- { mode: 'both', mode3D: true },
+ { mode: 'auto', mode3D: true },
]
// Stable key per VIEW_STATES entry — the icon/label lookup in app.js and the
@@ -39,8 +47,8 @@ export const viewKey = (s) => s.mode + (s.mode3D ? '3d' : '2d')
// missing entry would otherwise reach a screen reader as "undefined".
// Comma, not "·": a middle dot is either spoken as "middle dot" or dropped.
export const VIEW_LABELS = {
- points2d: '2D, points', both2d: '2D, hex + points', hex3d: '3D, hex',
- points3d: '3D, points', both3d: '3D, hex + points',
+ points2d: '2D, points', auto2d: '2D, hex, points when zoomed in', hex3d: '3D, hex',
+ points3d: '3D, points', auto3d: '3D, hex, points when zoomed in',
}
// Cycles forward through VIEW_STATES. An out-of-range index (corrupt/legacy
@@ -72,19 +80,23 @@ export function pitchTransition(was3D, is3D) {
export function layerVisibility({ mode, mode3D } = {}) {
// Unknown/absent mode follows the app's cold default rather than hiding
// everything, so a corrupt persisted value can't produce a blank map.
- const m = MODES.includes(mode) ? mode : 'hex'
+ // 'both' is what a view stored before #634 still says: it reads as auto.
+ const m = mode === 'both' ? 'auto' : MODES.includes(mode) ? mode : 'hex'
const showHex = m !== 'points'
const showPoints = m !== 'hex'
if (!mode3D) {
// The hex labels (#556) ride the flat hex layer, in 2D only; the pulse
// rings where the flat points are drawn, and its 3D form stays off here.
- return { hex: showHex, 'hex-3d': false, points: showPoints, 'points-3d': false, 'hex-labels': showHex, pulse: showPoints, 'pulse-3d': false }
+ return { hex: showHex, 'hex-b': showHex, 'hex-3d': false, 'hex-3d-b': false, points: showPoints, 'points-3d': false, 'hex-labels': showHex, pulse: showPoints, 'pulse-3d': false }
}
return {
- // Flat when pillars share the scene, extruded when they don't.
- hex: showHex && showPoints,
- 'hex-3d': showHex && !showPoints,
+ // Extruded whenever the hex is on (#634); see the 3D rule above.
+ hex: false,
+ 'hex-b': false,
+ 'hex-3d': showHex,
+ // The second cell size, while one takes over from the other (zoomfade.js).
+ 'hex-3d-b': showHex,
points: false,
'points-3d': showPoints,
// A label on a pillar's top would float at ground level under it, and the
diff --git a/app/src/noise.js b/app/src/noise.js
index fa957531..1726a3a7 100644
--- a/app/src/noise.js
+++ b/app/src/noise.js
@@ -113,7 +113,7 @@ export function noiseFill(colors) {
export function withNoise(vis, on) {
if (!on) return { ...vis, noise: false }
const out = { ...vis, noise: true }
- for (const id of ['hex', 'hex-3d', 'hex-labels']) if (id in out) out[id] = false
+ for (const id of ['hex', 'hex-b', 'hex-3d', 'hex-3d-b', 'hex-labels']) if (id in out) out[id] = false
return out
}
diff --git a/app/src/rides.js b/app/src/rides.js
index 5e7e4f5a..e973588a 100644
--- a/app/src/rides.js
+++ b/app/src/rides.js
@@ -13,11 +13,6 @@
export const RIDE_GAP_MS = 10 * 60 * 1000
-// Below this zoom a backlog reception is coverage only (its hex cell); from
-// it, the backlog comes back as outline circles. Kasper: "redelijk snel al".
-// 15 at first; the field lost the outlines one pinch out, so 12 (#668).
-export const BACKLOG_OUTLINE_ZOOM = 12
-
const rxMs = (r) => {
const t = r && r.rx_at != null ? Date.parse(r.rx_at) : NaN
return Number.isNaN(t) ? null : t
@@ -54,7 +49,3 @@ export function isBacklog(rec, rideStartMs) {
const t = rxMs(rec)
return t != null && t < rideStartMs
}
-
-export function showBacklogPoints(zoom) {
- return Number(zoom) >= BACKLOG_OUTLINE_ZOOM
-}
diff --git a/app/src/settings.js b/app/src/settings.js
index 575f58e6..cb924691 100644
--- a/app/src/settings.js
+++ b/app/src/settings.js
@@ -86,7 +86,7 @@ export function loadShareName() {
}
// Index into VIEW_STATES for the persisted view (#258). No/corrupt stored
-// value falls back to both/2D — the app's cold default before that merge
+// value falls back to auto/2D (both/2D until #634) — the app's cold default before that merge
// (huntmap.js's own mode/mode3D defaults), not index 0.
export function loadViewIndex() {
const v = readStored('core-hunter-view')
diff --git a/app/src/styles/app.css b/app/src/styles/app.css
index be7430ae..1b46fc85 100644
--- a/app/src/styles/app.css
+++ b/app/src/styles/app.css
@@ -84,7 +84,7 @@ html, body { margin: 0; height: 100%; background: var(--ch-bg); color: var(--ch-
/* A hex cell's label (#556): the id prefixes heard there, centred on the cell,
in the map's own halo so it reads over any tier colour. Never a tap target. */
.hex-label { font-family: ui-monospace, "SF Mono", SFMono-Regular, "Cascadia Mono", Menlo, Consolas, monospace;
- font-size: 10px; line-height: 1; white-space: nowrap; color: var(--ch-text);
+ font-size: 10px; line-height: 1; white-space: pre; text-align: center; color: var(--ch-text);
text-shadow: 0 0 3px var(--ch-bg), 0 0 3px var(--ch-bg); pointer-events: none; }
.np-muted { color: var(--ch-muted); }
diff --git a/app/src/zoomfade.js b/app/src/zoomfade.js
new file mode 100644
index 00000000..502fcc6d
--- /dev/null
+++ b/app/src/zoomfade.js
@@ -0,0 +1,208 @@
+// What the map shows at a zoom, as shares between 0 and 1 (#634). Every rule
+// here used to be a step in JS: a threshold crossed, the data rebuilt, and the
+// thing appeared or vanished in one frame. A share is handed to the style as a
+// zoom expression, so the map eases it while the fingers are still moving.
+//
+// The numbers are Kasper's, picked on 29 September 2026 by pinching through a
+// mockup with 4559 receptions between Nijmegen and Arnhem, in 2D and in 3D.
+// Zoom is MapLibre's throughout.
+//
+// Copied whole between app/src/ and web/ (parity.test.js), since neither deploy
+// path can ship a file outside its own directory (#238). No imports.
+
+// The points of this ride come in over the hex, which stays at full strength.
+export const POINT_FADE_2D = { from: 13.25, to: 18.25 }
+// Earlier rides come in as outlines. It was a step at zoom 12 (#668).
+export const BACKLOG_FADE_2D = { from: 11.25, to: 18.75 }
+// The names in a cell. It was a step at zoom 16.
+export const LABEL_FADE = { from: 15.5, to: 16.5 }
+// In 3D a pillar per reception is the most expensive thing on the map, so the
+// pillars wait for the closest zoom and arrive in one step, this ride and the
+// earlier ones together. From there the hex bars stand at 95% of their height,
+// so the strongest pillar in a cell clears the bar it stands in.
+export const POINT_STEP_3D = 19
+export const HEX_UNDER_PILLARS_3D = 0.95
+// How many zoom levels two cell sizes share while one takes over from the
+// other, centred on the zoom where the size changes.
+export const HEX_BLEND_2D = 0.95
+export const HEX_BLEND_3D = 0.75
+export const hexBlend = (mode3D) => (mode3D ? HEX_BLEND_3D : HEX_BLEND_2D)
+
+const clamp01 = (v) => Math.max(0, Math.min(1, v))
+// ramp: 0 up to `from`, 1 from `to`, a straight line between. A band of no
+// width is a step at `from`.
+export function ramp(z, { from, to }) {
+ if (!(to > from)) return z >= from ? 1 : 0
+ return clamp01((z - from) / (to - from))
+}
+
+// The layer modes. 'auto' took the place of 'both': the two layers together,
+// each at the share its zoom gives it. 'hex' and 'points' draw one layer at
+// full strength, whatever the zoom.
+export const LAYER_MODES = ['auto', 'hex', 'points']
+const modeOf = (mode) => (LAYER_MODES.includes(mode) ? mode : 'auto')
+
+export function pointShare(z, { mode, mode3D } = {}) {
+ const m = modeOf(mode)
+ if (m === 'hex') return 0
+ if (m === 'points') return 1
+ return mode3D ? (z >= POINT_STEP_3D ? 1 : 0) : ramp(z, POINT_FADE_2D)
+}
+export function hexShare(z, { mode, mode3D } = {}) {
+ const m = modeOf(mode)
+ if (m === 'points') return 0
+ if (m === 'hex' || !mode3D) return 1
+ return z >= POINT_STEP_3D ? HEX_UNDER_PILLARS_3D : 1
+}
+// An earlier ride's share of its own point. In 3D it arrives with the pillars.
+export function backlogShare(z, { mode3D } = {}) {
+ return mode3D ? 1 : ramp(z, BACKLOG_FADE_2D)
+}
+export const labelShare = (z) => ramp(z, LABEL_FADE)
+
+// ---- cell sizes -------------------------------------------------------------
+// One size per zoom level, the server's rule (server/internal/geo/hexgrid.go):
+// the resolution is the Leaflet zoom, which is MapLibre's plus one, rounded
+// (web/mapmodel.js leafletZoom). So resolution r is the one drawn from zoom
+// r - 1.5 up to r - 0.5, and the size changes on every half.
+export const HEX_MIN_RES = 3
+export function resForZoom(z, maxRes) {
+ return Math.max(HEX_MIN_RES, Math.min(maxRes, Math.round(Number(z) + 1)))
+}
+// The two sizes alive around the nearest change of size: the coarser below it
+// and the finer above. At the ends of the range the two are the same one.
+export function resPair(z, maxRes) {
+ const k = Math.floor(Number(z))
+ const lim = (r) => Math.max(HEX_MIN_RES, Math.min(maxRes, r))
+ return { coarse: lim(k + 1), fine: lim(k + 2) }
+}
+// resShare: how much of resolution `res` the map shows at zoom z. Full inside
+// its own zoom level, and over `width` levels around each end it hands over to
+// its neighbour, so the two shares always add up to 1. The coarsest and the
+// finest have no neighbour on one side and stay full there. `minRes` and
+// `maxRes` are the sizes there are: the website holds the pair it fetched as
+// its whole range, so that pair stays on the map when the zoom runs past it
+// before the next one arrives.
+export function resShare(res, z, width, maxRes, minRes = HEX_MIN_RES) {
+ const lo = res - 1.5, hi = res - 0.5
+ const up = res <= minRes ? 1 : ramp(z, { from: lo - width / 2, to: lo + width / 2 })
+ const down = res >= maxRes ? 0 : ramp(z, { from: hi - width / 2, to: hi + width / 2 })
+ return Math.min(up, 1 - down)
+}
+// The zooms where resShare bends, which is where an expression needs a stop.
+export function resBreaks(res, width, maxRes, minRes = HEX_MIN_RES) {
+ const lo = res - 1.5, hi = res - 0.5
+ return [
+ ...(res <= minRes ? [] : [lo - width / 2, lo + width / 2]),
+ ...(res >= maxRes ? [] : [hi - width / 2, hi + width / 2]),
+ ]
+}
+
+// ---- style expressions --------------------------------------------------------
+// MapLibre takes ['zoom'] only as the input of a top-level interpolate or
+// step, so a share cannot be multiplied into a paint value around the ramp.
+// It rides in each stop's output instead: `wrap` turns the share at a stop
+// into that output, e.g. (v) => ['*', ['get', 'op'], v].
+//
+// A step inside a ramp is two stops STEP_EPS apart, since stops have to rise.
+export const STEP_EPS = 0.001
+const round3 = (v) => Math.round(v * 1000) / 1000
+function stopsFor(breaks) {
+ const zs = [...new Set(breaks.map(round3))].sort((a, b) => a - b)
+ return zs.length ? zs : [0]
+}
+// zoomRamp: a linear interpolate through shareAt at every break. Exact when
+// shareAt is a straight line between two breaks.
+export function zoomRamp(breaks, shareAt, wrap = (v) => v) {
+ const zs = stopsFor(breaks)
+ if (zs.length === 1) return wrap(round3(shareAt(zs[0])))
+ return ['interpolate', ['linear'], ['zoom'], ...zs.flatMap((z) => [z, wrap(round3(shareAt(z)))])]
+}
+// zoomSwitch: 1 where shareAt is above nothing, 0 elsewhere, as a step. For
+// what cannot be partly there: a fill-extrusion of no height still draws an
+// opaque polygon on the ground, and one with an opacity between 0 and 1
+// dissolves toward black (docs/2026-09-04-new-versus-old-on-the-map.md).
+export function zoomSwitch(breaks, shareAt) {
+ const zs = stopsFor(breaks)
+ const on = (z) => (shareAt(z) > 0 ? 1 : 0)
+ // The value of a segment is the value just inside it.
+ const inside = (i) => on(i + 1 < zs.length ? (zs[i] + zs[i + 1]) / 2 : zs[i] + 1)
+ return ['step', ['zoom'], on(zs[0] - 1), ...zs.flatMap((z, i) => [z, inside(i)])]
+}
+// A step at `at`, as the two breaks a ramp needs for it.
+export const stepBreaks = (at) => [at - STEP_EPS, at]
+// Breaks every `every` zoom levels between two zooms, for a share that is a
+// curve there (the product of two ramps).
+export function sampledBreaks(from, to, every = 0.25) {
+ const out = []
+ for (let z = from; z < to + 1e-9; z += every) out.push(z)
+ out.push(to)
+ return out
+}
+
+// ---- what the layers are painted with ------------------------------------------
+// Two hex layers, since two cell sizes are on the map while one takes over
+// from the other. A size keeps its layer for as long as it is drawn: the even
+// resolutions go to slot a and the odd ones to b. The pair moves up one at
+// every whole zoom, which is the middle of a level, where the size that stays
+// is at full strength and the one that leaves is at nothing. So the swap is
+// never seen.
+export function hexSlots(z, maxRes) {
+ const { coarse, fine } = resPair(z, maxRes)
+ const slots = { a: null, b: null }
+ for (const res of [coarse, fine]) slots[res % 2 === 0 ? 'a' : 'b'] = res
+ return slots
+}
+
+const hexBreaks = (res, view, maxRes, minRes) => [
+ ...resBreaks(res, hexBlend(view.mode3D), maxRes, minRes),
+ // The bars drop to 95% where the pillars arrive, in auto in 3D only.
+ ...(view.mode3D && modeOf(view.mode) === 'auto' ? stepBreaks(POINT_STEP_3D) : []),
+]
+const hexShareOf = (res, view, maxRes, minRes) => (z) => resShare(res, z, hexBlend(view.mode3D), maxRes, minRes) * hexShare(z, view)
+
+// The flat cells: each cell's own opacity ('op') times the share.
+export function hexFillOpacity(res, view, maxRes, minRes) {
+ return zoomRamp(hexBreaks(res, view, maxRes, minRes), hexShareOf(res, view, maxRes, minRes), (v) => ['*', ['get', 'op'], v])
+}
+// The bars: a bar cannot be see-through, so its share is its height, and the
+// layer is switched off where the share is nothing.
+export function hexBarHeight(res, view, maxRes, minRes) {
+ return zoomRamp(hexBreaks(res, view, maxRes, minRes), hexShareOf(res, view, maxRes, minRes), (v) => ['*', ['get', 'height'], v])
+}
+export function hexBarSwitch(res, view, maxRes, minRes) {
+ return zoomSwitch(hexBreaks(res, view, maxRes, minRes), hexShareOf(res, view, maxRes, minRes))
+}
+
+// The flat points. A point of an earlier ride ('backlog' 1) has no fill and
+// takes the share of the points times its own, which is a curve where both
+// move, so that stretch is sampled.
+const BACKLOG = ['==', ['get', 'backlog'], 1]
+const pointBreaks = (view) => (modeOf(view.mode) === 'auto' ? [POINT_FADE_2D.from, POINT_FADE_2D.to] : [])
+export function pointFillOpacity(view) {
+ return zoomRamp(pointBreaks(view), (z) => pointShare(z, view), (v) => ['case', BACKLOG, 0, ['*', ['get', 'fop'], v]])
+}
+export function pointStrokeOpacity(view) {
+ const zs = [...pointBreaks(view), ...sampledBreaks(BACKLOG_FADE_2D.from, BACKLOG_FADE_2D.to)]
+ const at = (z) => {
+ const p = pointShare(z, view)
+ return ['case', BACKLOG, ['*', ['get', 'op'], round3(p * backlogShare(z, view))], ['*', ['get', 'op'], round3(p)]]
+ }
+ const stops = stopsFor(zs)
+ return ['interpolate', ['linear'], ['zoom'], ...stops.flatMap((z) => [z, at(z)])]
+}
+// The pillars: on or off, never partly (zoomSwitch).
+export function pillarSwitch(view) {
+ if (modeOf(view.mode) !== 'auto') return pointShare(0, view)
+ return zoomSwitch(stepBreaks(POINT_STEP_3D), (z) => pointShare(z, view))
+}
+
+// The same two ramps for a layer that names its own properties. The website's
+// cells carry an outline and its points no ride, so it writes its own outputs
+// around the shares the app uses.
+export function hexRamp(res, view, maxRes, wrap, minRes) {
+ return zoomRamp(hexBreaks(res, view, maxRes, minRes), hexShareOf(res, view, maxRes, minRes), wrap)
+}
+export function pointRamp(view, wrap) {
+ return zoomRamp(pointBreaks(view), (z) => pointShare(z, view), wrap)
+}
diff --git a/changelog.d/2026-09-29-03-zoom-eases.json b/changelog.d/2026-09-29-03-zoom-eases.json
new file mode 100644
index 00000000..c833b92a
--- /dev/null
+++ b/changelog.d/2026-09-29-03-zoom-eases.json
@@ -0,0 +1,7 @@
+{
+ "id": "2026-09-29-zoom-eases",
+ "date": "2026-09-29",
+ "where": "both",
+ "title": "Zooming eases instead of jumping",
+ "body": "Cells change size gradually and points fade in as you zoom. Auto replaces Both. In 3D the pillars appear at the closest zoom."
+}
diff --git a/docs/2026-06-29-hex-resolution-zoom.md b/docs/2026-06-29-hex-resolution-zoom.md
index 86c123d3..bd209dad 100644
--- a/docs/2026-06-29-hex-resolution-zoom.md
+++ b/docs/2026-06-29-hex-resolution-zoom.md
@@ -45,6 +45,11 @@ time, not the target's.
> and AGENTS.md §1 carries it. The §7 rule that required it in every position-bearing output was
> removed.
+> **Amended 2026-09-29 by `docs/2026-09-29-zoom-transitions.md` (#634).** The app no longer has
+> bands of a fixed size. It takes the server's rule, a size per zoom level, and goes three
+> resolutions past the server's finest so the walk-in keeps its fine cells. The caveat above on
+> cells below GPS accuracy stands.
+
## Watch-outs
- **Render cost.** At res 15 over a dense session this rebuilds many small `L.polygon`s on every pan
diff --git a/docs/2026-09-29-zoom-transitions.md b/docs/2026-09-29-zoom-transitions.md
new file mode 100644
index 00000000..dd1eeb7b
--- /dev/null
+++ b/docs/2026-09-29-zoom-transitions.md
@@ -0,0 +1,88 @@
+# 2026-09-29: zoom transitions and one hex system (#634)
+
+## Context
+
+Everything the map showed or hid by zoom was a step in JS: a threshold crossed, the data rebuilt, the
+thing there or gone in one frame. Five rules did this: 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 two surfaces also binned their cells differently. The app had ten bands of a fixed size, 1500
+down to 3 Mercator units. The server gives a size per zoom level, about 28 px tall at every zoom
+(`server/internal/geo/hexgrid.go`).
+
+## Decision
+
+Kasper picked the numbers on 29 September 2026 in a mockup: a MapLibre page with 4559 receptions
+between Nijmegen and Arnhem, a slider per threshold, 2D and 3D, the app's and the server's cell sizes
+side by side, judged by pinching through it on a phone.
+
+| | 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 | fade in from zoom 11.25 to 18.75 | with the points, at zoom 19 |
+| Cell names | 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 |
+
+Zoom is MapLibre's. The numbers live in `zoomfade.js`, one file on both surfaces.
+
+- **One hex system, the server's.** The app takes a size per zoom level and gives up its bands.
+ This amends `docs/2026-06-29-hex-resolution-zoom.md`.
+- **Auto replaces `both`.** Hex and points together, each at the share its zoom gives it. `hex` and
+ `points` draw one layer at full strength at every zoom. On the website a stored view or a link
+ that still says `both` reads as auto. In the app a stored `both` falls back to the default, auto
+ in 2D, so a stored `both3d` opens in 2D once; no migration, since the app is in development.
+- **In 3D the pillars wait for zoom 19.** A pillar per reception is the most expensive thing on the
+ map. Up to there the bars are the view. From there the bars stand at 95%, so the strongest
+ pillar of a cell clears its bar. Until now the hex was drawn flat whenever pillars shared the
+ scene (#266).
+- **The hex does not fade out.** The issue had it fading out as the points came in. In the mockup
+ the hex at full strength under the points read better.
+
+## How it is drawn
+
+- A share is a zoom expression on the layer, so the map eases it while the fingers move and no
+ draw is needed for it.
+- **Two hex layers.** Resolution r is drawn from zoom r - 1.5 to r - 0.5. Around each end it hands
+ over to its neighbour over the blend width, so at most two sizes are on the map and their shares
+ add up to 1. The even resolutions go to one layer and the odd ones to the other. The pair moves up
+ at every whole zoom, where the size that stays is at 100% and the other two at 0, so the swap
+ is not seen.
+- **The website asks for two sizes**, one request each, by its own `z`. Where the server answers
+ both with the same size (below member it caps the zoom), that size is drawn once and stays in
+ full.
+- **The website holds the pair it fetched.** It refetches on `moveend`, so a pinch can run past
+ the pair before the next one arrives. The two sizes held are the whole range the ramps know of:
+ the coarser stays in full below its level and the finer above it, as before this change the old
+ cells stayed until they were replaced.
+- **Only what is drawn in full takes the pointer.** Both sizes are loaded, and hover, click and
+ `queryRenderedFeatures` ignore paint opacity. A cell answers only from the size drawn in full at
+ the zoom (the one the line under the map counts), a point only from half its strength on
+ (zoom 15.75 in 2D auto).
+- **The app redraws by what changed.** A zoom step that only moves a share draws nothing. One
+ that changes a size rebuilds the cell layers, the noise and the labels, which change size at
+ every half zoom. Only a layer whose share leaves nothing takes the full draw.
+- **Nothing is built for a share of 0.** In auto the points are not built, and on the website not
+ fetched, below the zoom that shows them.
+
+## Measured in MapLibre 4.7.1
+
+- `['zoom']` is only accepted as the input of a top-level `interpolate` or `step`. A per-feature
+ value such as `['get', 'op']` therefore rides in each stop's output.
+- A `fill-extrusion` of height 0 still draws an opaque polygon on the ground. A layer with nothing
+ to show is switched off with a `step` on `fill-extrusion-opacity`, 0 or 1.
+- A bar cannot fade by alpha: a translucent extrusion blends against black
+ (`docs/2026-09-04-new-versus-old-on-the-map.md`). In 3D a share is spent on the height.
+
+## Limits
+
+- **The finest cell differs, by choice** (Kasper, 2026-09-29). The server stops at resolution 18. The app goes to 21, so a cell
+ stays 28 px up to zoom 20 for the walk-in: 2.1 Mercator units, where the old bands ended on 3.
+ On the website a cell grows past 28 px above zoom 17.
+- **A cell names one node.** Three prefixes at 10 px need about 100 px, and a cell is 28 px tall
+ and about 24 wide at every zoom, so the names of neighbouring cells ran over each other. The
+ label is the prefix of the node heard last, with the count of the others on a line under it
+ (Kasper, 2026-09-29). The old bands gave a cell of about 110 m at zoom 16, which held three.
+- **The noise layer (#410) draws one size**, the one in full at the zoom, without a blend.
+- **The website has no earlier rides and no cell names**, so those two fades are the app's alone.
diff --git a/web/auth.test.js b/web/auth.test.js
index 01ddb8b4..e4eb23c6 100644
--- a/web/auth.test.js
+++ b/web/auth.test.js
@@ -94,13 +94,13 @@ describe('canSeePointLayer', () => {
describe('modeForRole — a deep link cannot open a gated layer', () => {
it('holds a degraded role on hex, whatever was asked for', () => {
- for (const m of ['points', 'both', 'hex']) {
+ for (const m of ['points', 'auto', 'hex']) {
expect(modeForRole(m, 'guest'), m).toBe('hex')
expect(modeForRole(m, 'hunter'), m).toBe('hex')
}
})
it('leaves a member on the mode they picked', () => {
- for (const m of ['points', 'both', 'hex']) expect(modeForRole(m, 'member'), m).toBe(m)
+ for (const m of ['points', 'auto', 'hex']) expect(modeForRole(m, 'member'), m).toBe(m)
})
})
diff --git a/web/changelog.json b/web/changelog.json
index a7415395..4cbe791b 100644
--- a/web/changelog.json
+++ b/web/changelog.json
@@ -27,6 +27,13 @@
"title": "The map names a relay like the app",
"body": "A relay's name in the ticker and the popup is marked ~ as a guess. Members get the name of the one known node in reach."
},
+ {
+ "id": "2026-09-29-zoom-eases",
+ "date": "2026-09-29",
+ "where": "both",
+ "title": "Zooming eases instead of jumping",
+ "body": "Cells change size gradually and points fade in as you zoom. Auto replaces Both. In 3D the pillars appear at the closest zoom."
+ },
{
"id": "2026-09-27-hud-bottom-space",
"date": "2026-09-27",
diff --git a/web/e2e/auth.spec.js b/web/e2e/auth.spec.js
index 724b4d15..7364ec29 100644
--- a/web/e2e/auth.spec.js
+++ b/web/e2e/auth.spec.js
@@ -300,7 +300,7 @@ test('a guest cannot switch to the point layer, and the panel says why', async (
// what it takes to open it is also what the map has to sell.
await expect(page.locator('#lm-points')).toBeVisible()
await expect(page.locator('#lm-points')).toBeDisabled()
- await expect(page.locator('#lm-both')).toBeDisabled()
+ await expect(page.locator('#lm-auto')).toBeDisabled()
await expect(page.locator('#lm-hex')).toBeEnabled()
await expect(page.locator('#lm-hex')).toHaveAttribute('aria-pressed', 'true')
@@ -311,7 +311,7 @@ test('a guest cannot switch to the point layer, and the panel says why', async (
await expect(note).toHaveText(/individual receptions/i)
await expect(note).toHaveText(/log in/i)
await expect(page.locator('#lm-points')).toHaveAttribute('aria-describedby', 'layer-gate-note')
- await expect(page.locator('#lm-both')).toHaveAttribute('aria-describedby', 'layer-gate-note')
+ await expect(page.locator('#lm-auto')).toHaveAttribute('aria-describedby', 'layer-gate-note')
// The disabled attribute is not the whole job: nothing in the sheet styled
// :disabled, so the segment rendered identical to a live one. Measure what
@@ -369,7 +369,7 @@ test('a member still gets all three layers', async ({ page }) => {
.toBeGreaterThan(0)
await openFilters(page)
await expect(page.locator('#lm-points')).toBeEnabled()
- await expect(page.locator('#lm-both')).toBeEnabled()
+ await expect(page.locator('#lm-auto')).toBeEnabled()
await expect(page.locator('#lm-points')).toHaveAttribute('aria-pressed', 'true')
await expect(page.locator('#layer-gate-note')).toBeHidden()
await page.click('#lm-hex')
diff --git a/web/e2e/ignorelist.spec.js b/web/e2e/ignorelist.spec.js
index 217072b3..260bee3f 100644
--- a/web/e2e/ignorelist.spec.js
+++ b/web/e2e/ignorelist.spec.js
@@ -37,9 +37,9 @@ async function openPointPopup(page) {
test('Ignore this ID drops the sender from the query, not just from the view', async ({ page }) => {
const urls = await setup(page)
- // mode=both, because the point layer and the hex layer are separate requests
+ // mode=auto, because the point layer and the hex layer are separate requests
// and the list has to reach both: the hex is what a visitor lands on (#141).
- await page.goto('/?mode=both')
+ await page.goto('/?mode=auto')
await openPointPopup(page)
await page.locator('.pp-ignore').first().click()
diff --git a/web/e2e/receptionticker.spec.js b/web/e2e/receptionticker.spec.js
index bbb48994..ef4b1464 100644
--- a/web/e2e/receptionticker.spec.js
+++ b/web/e2e/receptionticker.spec.js
@@ -68,19 +68,20 @@ test('clicking a map marker scrolls the ticker to that specific line (marker ->
}).toPass()
})
-// In 'both' mode a point is drawn over its hex cell, and the cell's click
+// In 'auto' mode a point is drawn over its hex cell, and the cell's click
// focuses the newest reception inside it. MapLibre runs the click listener of
// every layer under the pointer, so a click on an older point in a cell landed
// the ticker on the cell's newest row instead of the point's (#465). NEAR is
// that newest row: inside the cell, newer, and not under the click.
-test('clicking a point inside a hex cell in both mode keeps the ticker on that point', async ({ page }) => {
+// z=19 is MapLibre zoom 18: in auto the points only come in from 13.25 (#634).
+test('clicking a point inside a hex cell in auto mode keeps the ticker on that point', async ({ page }) => {
const NEAR = { ...POINT2, lat: 51.002, lon: 4.002 }
const ring = [[3.99, 50.995], [4.01, 50.995], [4.015, 51], [4.01, 51.005], [3.99, 51.005], [3.985, 51], [3.99, 50.995]]
await page.route('**/api/points*', (r) => r.fulfill({ json: { points: [POINT, NEAR] } }))
await page.route('**/api/heatmap*', (r) => r.fulfill({ json: { features: [
{ type: 'Feature', geometry: { type: 'Polygon', coordinates: [ring] }, properties: { best_rssi: -85, count: 2, hunters: ['h1'] } },
] } }))
- await page.goto('/?mode=both&lat=51&lon=4&z=14')
+ await page.goto('/?mode=auto&lat=51&lon=4&z=19')
await expect(page.locator('#rx-log .rx-ln')).toHaveCount(2, { timeout: 10000 })
await expect(page.locator('#rx-log .rx-ln.act')).toContainText('OTHER')
await expect.poll(() => page.evaluate(() => window.__featureCount('hex'))).toBe(1)
diff --git a/web/e2e/ui.spec.js b/web/e2e/ui.spec.js
index 5b4b2be7..c1bd4f99 100644
--- a/web/e2e/ui.spec.js
+++ b/web/e2e/ui.spec.js
@@ -29,7 +29,7 @@ test('theme toggle flips data-theme, persists, reflects in URL, and swaps the gl
// The layer mode is a segmented control in the filter panel (#539); the
// pressed segment is the mode. aria-pressed is readable panel-open or shut.
const expectMode = async (page, mode) => {
- for (const m of ['points', 'hex', 'both']) {
+ for (const m of ['points', 'hex', 'auto']) {
await expect(page.locator(`#lm-${m}`)).toHaveAttribute('aria-pressed', String(m === mode))
}
}
@@ -60,15 +60,15 @@ test('settings survive a reload via localStorage (no URL params)', async ({ page
await openSettings(page)
await page.click('#theme-toggle') // -> light
await page.click('#ss-close')
- await setLayerMode(page, 'both')
- await expectMode(page, 'both')
+ await setLayerMode(page, 'auto')
+ await expectMode(page, 'auto')
// Reload with a bare URL: the URL was rewritten by replaceState, so strip it to
// prove the state is also restored from localStorage alone.
await page.evaluate(() => history.replaceState(null, '', location.pathname))
await page.reload()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
- await expectMode(page, 'both')
+ await expectMode(page, 'auto')
})
test('map starts in hex mode (#141), fetches /api/heatmap, and the segments switch it', async ({ page }) => {
@@ -77,8 +77,8 @@ test('map starts in hex mode (#141), fetches /api/heatmap, and the segments swit
await expectMode(page, 'hex')
await heatmapReq // the cold default drew the heatmap layer
- await setLayerMode(page, 'both')
- await expectMode(page, 'both')
+ await setLayerMode(page, 'auto')
+ await expectMode(page, 'auto')
await setLayerMode(page, 'points')
await expectMode(page, 'points')
await setLayerMode(page, 'hex')
diff --git a/web/e2e/view3d.spec.js b/web/e2e/view3d.spec.js
index 1bf39fe8..291c1737 100644
--- a/web/e2e/view3d.spec.js
+++ b/web/e2e/view3d.spec.js
@@ -70,7 +70,7 @@ test('turning the map writes the bearing into the URL, and the compass puts nort
await expect(page).not.toHaveURL(/[?&]bearing=/)
})
-test('in 3D the receptions stand as pillars, collapsed where they coincide, and Both keeps the cells flat under them', async ({ page }) => {
+test('in 3D the receptions stand as pillars, collapsed where they coincide, and Auto holds them back to zoom 19', async ({ page }) => {
await page.route('**/api/auth/me', (r) => r.fulfill({ json: { role: 'member', username: 'alice' } }))
const M = 1 / 111320
await page.route('**/api/points*', (r) => r.fulfill({ json: { points: [
@@ -88,13 +88,22 @@ test('in 3D the receptions stand as pillars, collapsed where they coincide, and
expect(await vis(page, 'hex-3d')).toBe(false)
await openFilters(page)
- await page.click('#lm-both')
+ await page.click('#lm-auto')
await closeFilters(page)
- // Both in 3D: pillars over FLAT cells (maplayers.js), since an extruded
- // cell is at least as tall as every pillar in it and would hide them.
- await expect.poll(() => vis(page, 'hex')).toBe(true)
- expect(await vis(page, 'hex-3d')).toBe(false)
+ // Auto in 3D (#634): the cells are bars, and no pillar is built until the
+ // closest zoom, since a pillar per reception is what costs the most to draw.
+ await expect.poll(() => vis(page, 'hex-3d')).toBe(true)
+ expect(await vis(page, 'hex')).toBe(false)
expect(await vis(page, 'points-3d')).toBe(true)
+ await expect.poll(() => page.evaluate(() => window.__featureCount('points-3d'))).toBe(0)
+ expect(await page.evaluate(() => window.__paint('points-3d', 'fill-extrusion-opacity'))).toEqual(['step', ['zoom'], 0, 18.999, 0, 19, 1])
+ // MapLibre zoom 19 is z=20 in the URL's Leaflet units.
+ await page.goto('/?mode=auto&view=3d&lat=51&lon=4&z=20')
+ await mounted(page)
+ await expect.poll(() => page.evaluate(() => window.__featureCount('points-3d'))).toBe(2)
+ await page.goto('/?mode=points&view=3d&lat=51&lon=4&z=16')
+ await mounted(page)
+ await page.waitForFunction(() => window.__featureCount('points') === 3)
// Back in 2D the pillars are not built at all.
await page.click('#view-toggle')
diff --git a/web/e2e/zoomfade.spec.js b/web/e2e/zoomfade.spec.js
new file mode 100644
index 00000000..05ad981a
--- /dev/null
+++ b/web/e2e/zoomfade.spec.js
@@ -0,0 +1,161 @@
+import { test, expect, openFilters } from './fixtures.js'
+
+// #634: what the map shows at a zoom is a share the style eases, not a step.
+// Two cell sizes are on the map while one takes over from the other, the
+// points come in over the hex, and Auto took the place of Both. The canvas
+// has no DOM, so the page's hooks answer what a layer holds and is painted
+// with. The numbers are pinned in zoomfade.test.js; this is the wiring.
+
+const ring = [[3.99, 50.995], [4.01, 50.995], [4.015, 51], [4.01, 51.005], [3.99, 51.005], [3.985, 51], [3.99, 50.995]]
+// Each cell counts as many receptions as its size, so a hover line names it.
+const cell = (res, n = 1) => Array.from({ length: n }, (_, i) => ({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [ring] },
+ properties: { cell: `${res}:${i}:1`, best_rssi: -85, count: res, hunters: ['h1'] } }))
+const POINT = { lat: 51, lon: 4, rssi: -90, snr: -8, sender_id: 'aa11bb22', sender_label: 'NEO7HI', hunter_pubkey: 'h1', hunter_name: 'Hunter 1',
+ packet_type: 'Advert', rx_at: new Date(Date.now() - 5000).toISOString() }
+
+// The server as it answers: the size asked for, up to `max`.
+async function setup(page, { role = 'member', max = 18 } = {}) {
+ const asked = { heatmap: [], points: [] }
+ await page.route('**/api/auth/me', (r) => (role === 'guest'
+ ? r.fulfill({ status: 401, json: { error: 'unauthorised' } })
+ : r.fulfill({ json: { role, username: 'm' } })))
+ await page.route('**/api/hunters*', (r) => r.fulfill({ json: { hunters: [] } }))
+ await page.route('**/api/heatmap*', (r) => {
+ const z = Number(new URL(r.request().url()).searchParams.get('z'))
+ asked.heatmap.push(z)
+ const res = Math.min(z, max)
+ // A size has as many cells as its number, so a count names the size.
+ return r.fulfill({ json: { features: cell(res, res) } })
+ })
+ await page.route('**/api/points*', (r) => {
+ const q = new URL(r.request().url()).searchParams
+ if (q.get('bbox')) asked.points.push(q.get('z'))
+ return r.fulfill({ json: { points: [POINT] } })
+ })
+ return asked
+}
+const count = (page, id) => page.evaluate((l) => window.__featureCount(l), id)
+const paint = (page, id, prop) => page.evaluate(([l, p]) => window.__paint(l, p), [id, prop])
+
+test('two cell sizes are asked for and drawn, each in its own layer', async ({ page }) => {
+ const asked = await setup(page)
+ // z=14 in the URL is MapLibre zoom 13: size 14 in full, with 15 above it.
+ await page.goto('/?mode=hex&lat=51&lon=4&z=14')
+ await expect.poll(() => count(page, 'hex')).toBe(14)
+ await expect.poll(() => count(page, 'hex-b')).toBe(15)
+ expect([...new Set(asked.heatmap)].sort()).toEqual([14, 15])
+ // The pair hands over at 13.5; past that each holds on until the next pair.
+ expect(await paint(page, 'hex', 'fill-opacity')).toEqual(['interpolate', ['linear'], ['zoom'],
+ 13.025, ['*', ['get', 'op'], 1], 13.975, ['*', ['get', 'op'], 0]])
+ expect(await paint(page, 'hex-b', 'fill-opacity')).toEqual(['interpolate', ['linear'], ['zoom'],
+ 13.025, ['*', ['get', 'op'], 0], 13.975, ['*', ['get', 'op'], 1]])
+ // The outline fades with its cells.
+ expect(await paint(page, 'hex-outline', 'line-opacity')).toEqual(['interpolate', ['linear'], ['zoom'], 13.025, 0.9, 13.975, 0])
+ await expect(page.locator('#status')).toContainText('14 cells')
+})
+
+// A linear interpolate over ['zoom'] at z, with the cell's own opacity at 1.
+function shareAt(expr, z) {
+ const val = (v) => (Array.isArray(v) ? v[2] : v)
+ if (!Array.isArray(expr) || expr[0] !== 'interpolate') return val(expr)
+ const stops = expr.slice(3)
+ if (z <= stops[0]) return val(stops[1])
+ for (let i = 0; i + 2 < stops.length; i += 2) {
+ const [z0, v0, z1, v1] = [stops[i], val(stops[i + 1]), stops[i + 2], val(stops[i + 3])]
+ if (z <= z1) return v0 + ((v1 - v0) * (z - z0)) / (z1 - z0)
+ }
+ return val(stops[stops.length - 1])
+}
+
+test('a zoom past the fetched pair keeps its cells until the next pair arrives', async ({ page }) => {
+ await setup(page)
+ // z=14 is MapLibre zoom 13: sizes 14 and 15 fetched.
+ await page.goto('/?mode=hex&lat=51&lon=4&z=14')
+ await expect.poll(() => count(page, 'hex')).toBe(14)
+ await expect.poll(() => count(page, 'hex-b')).toBe(15)
+ // The refetch after the zoom never lands, so the pair of zoom 13 stays.
+ await page.route('**/api/heatmap*', () => {})
+ for (const z of [14.9, 11.5]) {
+ await page.evaluate((to) => window.__mapJumpZoom(to), z)
+ const a = shareAt(await paint(page, 'hex', 'fill-opacity'), z)
+ const b = shareAt(await paint(page, 'hex-b', 'fill-opacity'), z)
+ expect(a + b, `zoom ${z}`).toBeCloseTo(1, 2)
+ }
+})
+
+test('the pointer reads the cell drawn in full, not the one faded out over it', async ({ page }) => {
+ await setup(page)
+ // MapLibre zoom 13: size 14 in full, 15 loaded above it at nothing.
+ await page.goto('/?mode=hex&lat=51&lon=4&z=14')
+ await expect.poll(() => count(page, 'hex-b')).toBe(15)
+ const at = await page.evaluate(() => window.__mapProject(51, 4))
+ const box = await page.locator('#map').boundingBox()
+ await page.mouse.move(box.x + at.x, box.y + at.y)
+ await page.mouse.move(box.x + at.x + 2, box.y + at.y + 1)
+ await expect(page.locator('.ch-hover')).toContainText('14 pts')
+})
+
+test('a point takes a click from half its strength on', async ({ page }) => {
+ await setup(page)
+ const clickPoint = async () => {
+ const at = await page.evaluate(() => window.__mapProject(51, 4))
+ const box = await page.locator('#map').boundingBox()
+ await page.mouse.click(box.x + at.x, box.y + at.y)
+ }
+ // MapLibre zoom 14: the point is drawn at 15%, so the cell under it answers.
+ await page.goto('/?mode=auto&lat=51&lon=4&z=15')
+ await expect.poll(() => count(page, 'points')).toBe(1)
+ await clickPoint()
+ await expect(page.locator('.maplibregl-popup:not(.ch-hover)')).toHaveCount(0)
+ // MapLibre zoom 17: 75%, the point's own popup.
+ await page.goto('/?mode=auto&lat=51&lon=4&z=18')
+ await expect.poll(() => count(page, 'points')).toBe(1)
+ await expect(async () => {
+ await clickPoint()
+ await expect(page.locator('.maplibregl-popup:not(.ch-hover)')).toContainText('RSSI -90', { timeout: 500 })
+ }).toPass()
+})
+
+test('a size the server will not go past is drawn once, and stays in full', async ({ page }) => {
+ // Below member the server caps the zoom: both asks come back as size 12.
+ await setup(page, { role: 'guest', max: 12 })
+ await page.goto('/?mode=hex&lat=51&lon=4&z=14')
+ await expect.poll(() => count(page, 'hex')).toBe(12)
+ expect(await count(page, 'hex-b')).toBe(0)
+ // One size is the whole range: in full at every zoom, no ramp.
+ expect(await paint(page, 'hex', 'fill-opacity')).toEqual(['*', ['get', 'op'], 1])
+ expect(await paint(page, 'hex-b', 'fill-opacity')).toBe(0)
+})
+
+test('auto asks for no points below the zoom that shows them', async ({ page }) => {
+ const asked = await setup(page)
+ await page.goto('/?mode=auto&lat=51&lon=4&z=14')
+ await expect.poll(() => count(page, 'hex')).toBe(14)
+ expect(await count(page, 'points')).toBe(0)
+ expect(asked.points).toEqual([])
+ expect(await paint(page, 'points', 'circle-opacity')).toEqual(['interpolate', ['linear'], ['zoom'],
+ 13.25, ['*', ['get', 'op'], 0], 18.25, ['*', ['get', 'op'], 1]])
+
+ // z=17 is MapLibre zoom 16: past 13.25, so the points are on the map.
+ await page.goto('/?mode=auto&lat=51&lon=4&z=17')
+ await expect.poll(() => count(page, 'points')).toBe(1)
+ await expect.poll(() => count(page, 'hex')).toBeGreaterThan(0)
+})
+
+test('points mode draws the points at full strength at every zoom', async ({ page }) => {
+ await setup(page)
+ await page.goto('/?mode=points&lat=51&lon=4&z=10')
+ await expect.poll(() => count(page, 'points')).toBe(1)
+ expect(await paint(page, 'points', 'circle-opacity')).toEqual(['*', ['get', 'op'], 1])
+ expect(await paint(page, 'points', 'circle-stroke-opacity')).toBe(1)
+})
+
+test('Auto took the place of Both, and a link from before still opens it', async ({ page }) => {
+ await setup(page)
+ await page.goto('/?mode=both&lat=51&lon=4&z=17')
+ await expect.poll(() => count(page, 'points')).toBe(1)
+ await openFilters(page)
+ await expect(page.locator('#lm-auto')).toHaveText('Auto')
+ await expect(page.locator('#lm-auto')).toHaveAttribute('aria-pressed', 'true')
+ await expect(page.locator('#lm-both')).toHaveCount(0)
+})
diff --git a/web/index.html b/web/index.html
index 252c4d7f..c3426e10 100644
--- a/web/index.html
+++ b/web/index.html
@@ -258,7 +258,7 @@