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: `

Filters

- +