Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions app/changelog.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
56 changes: 30 additions & 26 deletions app/src/__tests__/hexgrid.test.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
25 changes: 9 additions & 16 deletions app/src/__tests__/hexlabels.test.js
Original file line number Diff line number Diff line change
@@ -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'))]
Expand All @@ -21,27 +24,17 @@ 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('')
expect(hexCellLabel(undefined)).toBe('')
})
})

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
Expand Down
2 changes: 1 addition & 1 deletion app/src/__tests__/huntmap-contract.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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())
})
})
26 changes: 26 additions & 0 deletions app/src/__tests__/huntmap-zoomkeys.test.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
62 changes: 37 additions & 25 deletions app/src/__tests__/maplayers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand All @@ -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 })
}
})
Expand All @@ -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', () => {
Expand All @@ -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')
}
}
Expand All @@ -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".
Expand All @@ -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)
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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 }))
})
})

Expand Down Expand Up @@ -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)
})
})
Expand All @@ -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)
}
})
Expand Down
14 changes: 1 addition & 13 deletions app/src/__tests__/rides.test.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
})
})
2 changes: 1 addition & 1 deletion app/src/__tests__/settings.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' }))
Expand Down
Loading
Loading