From c96eb0e73804cda824154e3f919f4ef6efb92ef6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kasper=20H=C3=A4gele?= Date: Tue, 29 Sep 2026 21:36:44 +0200 Subject: [PATCH] feat(app,web): ease what the map shows by zoom, on one hex system Everything the map showed or hid by zoom was a step: a threshold crossed, the data rebuilt, the thing there or gone in one frame. A share between 0 and 1 is now handed to the style as a zoom expression, so the map eases it while the fingers move. The app takes the server's cell sizes, one per zoom level at about 28 px, and gives up its ten bands in metres. Two sizes are on the map while one takes over from the other. The points and the earlier rides fade in over a hex that stays at full strength, the cell names fade in, and Auto takes the place of Both. In 3D the pillars wait for zoom 19. The numbers are Kasper's, picked in a mockup on 29 September 2026, and live in zoomfade.js, one file on both surfaces. Closes #634 Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 1 + app/changelog.json | 7 + app/src/__tests__/hexgrid.test.js | 56 ++-- app/src/__tests__/hexlabels.test.js | 25 +- app/src/__tests__/huntmap-contract.test.js | 2 +- app/src/__tests__/huntmap-zoomkeys.test.js | 26 ++ app/src/__tests__/maplayers.test.js | 62 +++-- app/src/__tests__/rides.test.js | 14 +- app/src/__tests__/settings.test.js | 2 +- app/src/__tests__/zoomfade.test.js | 278 +++++++++++++++++++ app/src/app.js | 8 +- app/src/hexgrid.js | 57 ++-- app/src/hexlabels.js | 20 +- app/src/huntmap.js | 180 ++++++++++--- app/src/maplayers.js | 46 ++-- app/src/noise.js | 2 +- app/src/rides.js | 9 - app/src/settings.js | 2 +- app/src/styles/app.css | 2 +- app/src/zoomfade.js | 208 +++++++++++++++ changelog.d/2026-09-29-03-zoom-eases.json | 7 + docs/2026-06-29-hex-resolution-zoom.md | 5 + docs/2026-09-29-zoom-transitions.md | 88 ++++++ web/auth.test.js | 4 +- web/changelog.json | 7 + web/e2e/auth.spec.js | 6 +- web/e2e/ignorelist.spec.js | 4 +- web/e2e/receptionticker.spec.js | 7 +- web/e2e/ui.spec.js | 12 +- web/e2e/view3d.spec.js | 21 +- web/e2e/zoomfade.spec.js | 161 +++++++++++ web/index.html | 2 +- web/map.js | 120 ++++++--- web/mapcore.js | 105 ++++++-- web/maplayers.js | 46 ++-- web/maplayers.test.js | 87 ++++-- web/parity.test.js | 4 +- web/zoomfade.js | 208 +++++++++++++++ web/zoomfade.test.js | 297 +++++++++++++++++++++ 39 files changed, 1891 insertions(+), 307 deletions(-) create mode 100644 app/src/__tests__/huntmap-zoomkeys.test.js create mode 100644 app/src/__tests__/zoomfade.test.js create mode 100644 app/src/zoomfade.js create mode 100644 changelog.d/2026-09-29-03-zoom-eases.json create mode 100644 docs/2026-09-29-zoom-transitions.md create mode 100644 web/e2e/zoomfade.spec.js create mode 100644 web/zoomfade.js create mode 100644 web/zoomfade.test.js diff --git a/AGENTS.md b/AGENTS.md index e0c77960..6c9a54c8 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 b2ca7c2a..c60eab22 100644 --- a/app/changelog.json +++ b/app/changelog.json @@ -1,4 +1,11 @@ [ + { + "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 ccca2b38..bd5e1cca 100644 --- a/app/src/app.js +++ b/app/src/app.js @@ -3198,7 +3198,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) { @@ -3219,7 +3219,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: `

Filters

- +