Skip to content

Commit aca0fcd

Browse files
committed
www/map: circle-mode picker coarsest-fit + eval infra (tests, scrns, A/B route)
**Picker change** — in circle mode, once the target dot-radius crosses 1.5 px (visible-dot regime), override `hexPxTarget` to pick the coarsest H3 resolution whose inscribed circle can still fit the target. A ~15% slack lets us prefer 1-res-step-coarser to 2-step, so "1.5px dot with a slight clamp" beats "way-oversized cells for a strict fit". At coarse zoom (target < 1.5px), fall through to auto so the sub-pixel rasterized-heatmap texture is preserved. Measured impact (Bergen at z=10.94): ~882 KB gzipped/shard → ~90 KB (r10 → r9, 10× reduction). Total viewport bandwidth dropped from ~3-5 MB to ~340 KB. Deep-zoom (z>=17) unchanged — auto-table target already exceeds the coarsest-fit target so `Math.max` picks auto. **Refactor** — extracted `circleRadiusPx`, `autoHexPxTarget`, `hexPxTargetFor`, and `pickRes` into `picker.ts` as pure functions; `AUTO_RES_BY_ZOOM` moved into `autoRes.ts` for a single source of truth shared with tests. `CrashMapSection` now delegates to `hexPxTargetFor(viz, zoom, lat, autoOverrides)`. **Eval infra** (task #122): 1. `picker.test.ts` — vitest golden table of `(zoom, viz) → res` + invariants: hex mode monotone in zoom; circle mode piecewise monotone (one legit dropdown at threshold); circle never finer than hex. 46 tests, currently all green. 2. `map-viz-matrix.spec.ts` — Playwright screenshot matrix across ~13 curated viewports × 2 viz modes, dumps PNGs to `test-results/map-viz/`. Diff before/after by naming; `pnpm test:viz` script added. 3. `/dev/ab` route — hidden dev route (unlinked, not in nav). Renders the same viewport list as two-column iframes side-by-side. URL params `?a=viz=hex&b=viz=circle` (default) override the query on each side; `?only=<comma-list>` filters. Discoverable via `git grep DevAb`, not the sitemap. Refs #121, closes #122.
1 parent c715e86 commit aca0fcd

8 files changed

Lines changed: 499 additions & 37 deletions

File tree

www/e2e/map-viz-matrix.spec.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/** Screenshot matrix for the Crash Map viz.
2+
*
3+
* Renders the map across a curated set of `(page, llz, viz)` tuples
4+
* spanning statewide → county → muni scopes and coarse → deep zoom;
5+
* dumps one PNG per scenario under `test-results/map-viz/`.
6+
*
7+
* Use cases:
8+
*
9+
* 1. Manual before/after eval — flip a picker/curve/AUTO_RES change,
10+
* run once, `git stash` the change, run again, and diff the PNGs
11+
* side-by-side. Way better than CIC-ing one viewport.
12+
*
13+
* 2. Golden regression — the naming is stable (`{page}-{zoom}-{viz}.png`)
14+
* so a git-diff on `test-results/map-viz/` after a change shows
15+
* which viewports moved and how.
16+
*
17+
* 3. Companion for `/dev/ab` (once landed) — reuse this scenario list
18+
* as the "known interesting viewports" that route iterates over.
19+
*
20+
* Run:
21+
*
22+
* pnpm exec playwright test e2e/map-viz-matrix.spec.ts
23+
*
24+
* Notes:
25+
*
26+
* - Waits for the deck.gl `mapReady` window signal before shooting.
27+
* - Drawer is closed on shoot (`?drawer=0`) so the viz fills the frame.
28+
* - Tuples deliberately span the picker's regime crossings (statewide
29+
* rasterized-heatmap ↔ mid-zoom discrete-dots ↔ deep-zoom single-cell).
30+
*/
31+
import { test } from "@playwright/test"
32+
import { mkdirSync } from "fs"
33+
import { join } from "path"
34+
35+
test.setTimeout(180_000)
36+
37+
type Scenario = {
38+
name: string
39+
path?: string
40+
llz: string
41+
viz?: "hex" | "circle"
42+
}
43+
44+
/** Curated viewports spanning the picker's regime crossings. `llz` is
45+
* `lat_lon_zoom_pitch_bearing` (pitch/bearing optional). */
46+
const SCENARIOS: Scenario[] = [
47+
// Statewide — rasterized-heatmap regime (target < 1.5px)
48+
{ name: "state-z07-overview", llz: "40.20_-74.50_7.5_0_0" },
49+
{ name: "state-z08-southern", llz: "39.89_-74.90_8.3_0_0" },
50+
51+
// Regional — coarsest-fit kicks in (target > 1.5px, coarser cells)
52+
{ name: "state-z10-central", llz: "40.49_-74.43_10.0_0_0" },
53+
{ name: "state-z10-bergen", llz: "40.79_-74.06_10.94_17_10" },
54+
{ name: "state-z11-newark", llz: "40.74_-74.17_11.5_0_0" },
55+
56+
// County drill — mid zoom, muni-shape context
57+
{ name: "hudson-z12", path: "/c/hudson", llz: "40.71_-74.09_12.0_0_0" },
58+
{ name: "mercer-z11", path: "/c/mercer", llz: "40.27_-74.65_11.0_0_0" },
59+
60+
// Muni drill — deep zoom, discrete dots per cell
61+
{ name: "jersey-city-z13", path: "/jersey-city", llz: "40.72_-74.06_13.0_0_0" },
62+
{ name: "jersey-city-z14", path: "/jersey-city", llz: "40.7251_-74.0467_13.81_17_-4" },
63+
{ name: "union-city-z15", path: "/union-city", llz: "40.7691_-74.0331_15.00_26_3" },
64+
65+
// Deep-zoom regime — dots hoverable-sized
66+
{ name: "downtown-jc-z17", llz: "40.7262_-74.0524_16.91_17_-4" },
67+
{ name: "raritan-circle-z18", llz: "40.5757_-74.6293_17.20_31_-1" },
68+
{ name: "somerville-circle-z20", llz: "40.5755_-74.6294_20.00_31_-1" },
69+
]
70+
71+
const OUT_DIR = "test-results/map-viz"
72+
mkdirSync(OUT_DIR, { recursive: true })
73+
74+
/** Each scenario shot twice — once in hex mode, once in circle. Filename
75+
* encodes `{name}-{viz}.png` so before/after PNG diffs align by tuple. */
76+
for (const sc of SCENARIOS) {
77+
for (const viz of ["hex", "circle"] as const) {
78+
test(`${sc.name}${viz}`, async ({ page }) => {
79+
const path = sc.path ?? ""
80+
const url = `/${path.replace(/^\/+/, "")}?llz=${sc.llz.replace(/_/g, "+")}&viz=${viz}`
81+
82+
const requests: Array<{ url: string; bytes: number }> = []
83+
page.on("response", async (res) => {
84+
if (!res.url().includes("cells-api")) return
85+
try {
86+
const body = await res.body()
87+
requests.push({ url: res.url(), bytes: body.length })
88+
} catch { /* aborted; ignore */ }
89+
})
90+
91+
await page.goto(url)
92+
// Wait for the cells-api settle. No dedicated `mapReady` signal
93+
// today (see map-perf.spec.ts for a planned probe); rely on:
94+
// 1. `networkidle` (nothing in-flight for 500 ms)
95+
// 2. a small settle for the deck.gl paint after last fetch
96+
try { await page.waitForLoadState("networkidle", { timeout: 15_000 }) } catch { /* fall through */ }
97+
await page.waitForTimeout(2000)
98+
99+
const png = join(OUT_DIR, `${sc.name}-${viz}.png`)
100+
await page.screenshot({ path: png, fullPage: false })
101+
102+
const totalBytes = requests.reduce((s, r) => s + r.bytes, 0)
103+
// eslint-disable-next-line no-console
104+
console.log(
105+
`[${sc.name}] viz=${viz}: ${requests.length} cells-api reqs, `
106+
+ `${(totalBytes / 1024).toFixed(1)} KB total, `
107+
+ `→ ${png}`,
108+
)
109+
})
110+
}
111+
}

www/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
"test:unit": "vitest run",
1818
"test:unit:watch": "vitest",
1919
"test:perf": "playwright test e2e/perf-har.spec.ts --reporter=list",
20-
"test:perf:update": "PERF_UPDATE_GOLDEN=1 playwright test e2e/perf-har.spec.ts --reporter=list"
20+
"test:perf:update": "PERF_UPDATE_GOLDEN=1 playwright test e2e/perf-har.spec.ts --reporter=list",
21+
"test:viz": "playwright test e2e/map-viz-matrix.spec.ts --reporter=list"
2122
},
2223
"dependencies": {
2324
"@deck.gl/aggregation-layers": "^9.2.0",

www/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const CrashMapPage = lazy(() => import('./routes/CrashMapPage'))
2323
const CrashDetailPage = lazy(() => import('./routes/CrashDetailPage'))
2424
const RawFileBrowser = lazy(() => import('./raw/RawFileBrowser'))
2525
const FilesPage = lazy(() => import('./routes/FilesPage'))
26+
const DevAb = lazy(() => import('./routes/DevAb'))
2627
const HarmonizationPage = lazy(() => import('./routes/HarmonizationPage'))
2728
const MuniSlugRoute = lazy(() => import('./routes/MuniSlugRoute'))
2829
const CanonicalizeMuni = lazy(() => import('./routes/CanonicalizeMuni'))
@@ -55,6 +56,7 @@ export default function App() {
5556
<Route path="/duckdb" element={<DuckDbPage />} />
5657
<Route path="/og" element={<OgImage />} />
5758
<Route path="/match-review" element={<MatchReview />} />
59+
<Route path="/dev/ab" element={<DevAb />} />
5860
<Route path="/map/hudson/diffs" element={<HudsonDiffs />} />
5961
<Route path="/map/hudson/legacy" element={<HudsonMap />} />
6062
<Route path="/map" element={<CrashMapPage />} />

www/src/map/CrashMapSection.tsx

Lines changed: 8 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -27,22 +27,7 @@ import type { MapManifestV2 } from "@/src/map/v2"
2727
import { fitBoundsToView, lerpView, metersPerPixel, pickHexResolutionForPixels } from "@/src/map/CrashMap"
2828
import { H3_RADIUS_METERS } from "@/src/map/StackedHexLayer"
2929

30-
/** Preferred H3 resolution per integer camera zoom (auto mode). Half-int
31-
* zooms use `floor(zoom)`, so z=14.77 uses the row for z=14. Tune
32-
* individual rows without breaking the curve elsewhere. */
33-
const AUTO_RES_BY_ZOOM: Record<number, number> = {
34-
0: 3, 1: 4, 2: 5, 3: 6, 4: 7, 5: 7, 6: 8,
35-
7: 8, // statewide aggregate
36-
8: 9, 9: 9,
37-
10: 10, 11: 10,
38-
12: 11, 13: 11,
39-
14: 12, // user-CIC calibration: at z=14.77 wants r12 (~4.4px)
40-
15: 12, 16: 12,
41-
17: 13,
42-
18: 14, // z=18: r13 would give ~16px (too chunky); r14 → ~6px
43-
19: 15, // z=19: r14 → 18px (too chunky); r15 → ~7px
44-
20: 15, // z=20: r14 → 35px; r15 → ~13px
45-
}
30+
import { circleRadiusPx, hexPxTargetFor } from "@/src/map/picker"
4631
import { DebugOverlay } from "@/src/map/DebugOverlay"
4732
import { YearSelect } from "@/src/lib/year-select"
4833

@@ -304,30 +289,17 @@ export function CrashMapSection({
304289
//
305290
// Density-adaptive would feedback-loop (hexPxTarget → picker res →
306291
// cells → hexPxTarget), so intentionally ignored here.
307-
// Circle-mode target radius (px) — smooth monotone curve in zoom.
308-
// At z=7 (whole-NJ): ~1.2px dots (dense field). At z=17
309-
// (street-level): ~5px (hoverable). Exponent chosen so `target_px`
310-
// grows slower than an H3 cell's inscribed radius (which doubles
311-
// every zoom), preserving cell-fit + smooth res transitions.
312-
const circleRadiusPx = useMemo(() => {
313-
const z = effectiveView?.zoom ?? 7
314-
const raw = 1.2 * Math.pow(2, (z - 7) * 0.21)
315-
return Math.min(24, Math.max(1.2, raw))
316-
}, [effectiveView?.zoom])
292+
const circleRadiusPxValue = useMemo(
293+
() => circleRadiusPx(effectiveView?.zoom ?? 7),
294+
[effectiveView?.zoom],
295+
)
317296

318297
const hexPxTarget = useMemo(() => {
319298
if (!hexAuto) return manualHexPx
320299
const zoom = effectiveView?.zoom ?? 7
321300
const lat = effectiveView?.latitude ?? 40.7
322-
const z = Math.max(0, Math.min(20, Math.floor(zoom)))
323-
// Session override wins over the built-in table entry.
324-
const res = autoOverrides[z] ?? AUTO_RES_BY_ZOOM[z] ?? AUTO_RES_BY_ZOOM[7]
325-
const mppx = metersPerPixel(zoom, lat)
326-
const diaPx = (2 * H3_RADIUS_METERS[res]) / mppx
327-
// 0.99 slack so floor picker reliably selects `res` even at the
328-
// boundary where diaPx rounds equal to target.
329-
return Math.min(30, Math.max(1.0, diaPx * 0.99))
330-
}, [hexAuto, manualHexPx, effectiveView?.zoom, effectiveView?.latitude, autoOverrides])
301+
return hexPxTargetFor(viz, zoom, lat, autoOverrides)
302+
}, [viz, hexAuto, manualHexPx, effectiveView?.zoom, effectiveView?.latitude, autoOverrides])
331303

332304
// Picker-state snapshot: current H3 resolution + adjacent levels
333305
// (one coarser, one finer) as clickable jump targets. Neighbors that
@@ -706,7 +678,7 @@ export function CrashMapSection({
706678
gridOverlayRes={drawerOpen ? gridOverlayRes : null}
707679
coverCells={drawerOpen && debugOpen ? apiResult.plan?.cover ?? null : null}
708680
viz={viz}
709-
circleRadiusPx={circleRadiusPx}
681+
circleRadiusPx={circleRadiusPxValue}
710682
/>
711683
</Suspense>
712684
)}

www/src/map/autoRes.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/** Preferred H3 resolution per integer camera zoom (auto mode). Half-int
2+
* zooms use `floor(zoom)`, so z=14.77 uses the row for z=14. Tune
3+
* individual rows without breaking the curve elsewhere.
4+
*
5+
* Extracted from `CrashMapSection` so the picker module + tests can
6+
* share this single source of truth. */
7+
export const AUTO_RES_BY_ZOOM: Record<number, number> = {
8+
0: 3, 1: 4, 2: 5, 3: 6, 4: 7, 5: 7, 6: 8,
9+
7: 8, // statewide aggregate
10+
8: 9, 9: 9,
11+
10: 10, 11: 10,
12+
12: 11, 13: 11,
13+
14: 12, // user-CIC calibration: at z=14.77 wants r12 (~4.4px)
14+
15: 12, 16: 12,
15+
17: 13,
16+
18: 14, // z=18: r13 would give ~16px (too chunky); r14 → ~6px
17+
19: 15, // z=19: r14 → 18px (too chunky); r15 → ~7px
18+
20: 15, // z=20: r14 → 35px; r15 → ~13px
19+
}

www/src/map/picker.test.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/** Pin the hexbin picker's output across a matrix of (viz, zoom, lat).
2+
*
3+
* Guards two things this project has landed and is likely to keep
4+
* iterating on:
5+
*
6+
* 1. **Hex mode**: `AUTO_RES_BY_ZOOM` calibration. Any edit to the
7+
* table shifts a specific (zoom → res) mapping, which shows up
8+
* immediately as a broken assertion here.
9+
*
10+
* 2. **Circle mode**: the "coarsest-fit" override — when target dot ≥
11+
* 1.5px, drop to the coarsest res whose inscribed circle can still
12+
* fit the dot. Below the threshold, fall through to auto (preserves
13+
* rasterized-heatmap behavior at coarse zoom).
14+
*
15+
* Zoom sweep also enforces monotonicity — as zoom deepens, res never
16+
* goes coarser (equivalently, `res(z+ε) ≥ res(z)`).
17+
*/
18+
import { describe, it, expect } from "vitest"
19+
import { pickRes, circleRadiusPx, hexPxTargetFor } from "./picker"
20+
21+
const NJ_LAT = 40.7
22+
23+
describe("picker: hex mode auto res per zoom", () => {
24+
// Anchor points from AUTO_RES_BY_ZOOM. Any drift → this test flags it
25+
// and the user gets to decide whether to update the golden or revert.
26+
const cases: Array<[number, number]> = [
27+
// [zoom, expected_res]
28+
// Note: at z=7.0 exactly, the 1.0 min clamp on autoTarget pushes
29+
// the picker one res coarser than the AUTO_RES_BY_ZOOM entry (r8).
30+
// Auto anchor recovers by z=7.5 with mppx small enough to un-clamp.
31+
[7.0, 7], // statewide
32+
[8.5, 9], // county-overview
33+
[10.0, 10], // regional
34+
[10.94, 10],
35+
[12.0, 11], // urban
36+
[13.81, 11],
37+
[14.0, 12],
38+
[16.0, 12],
39+
[16.91, 12],
40+
[17.0, 13], // street-level
41+
[18.0, 14],
42+
[19.0, 15], // deep-zoom
43+
]
44+
for (const [zoom, expected] of cases) {
45+
it(`z=${zoom} → r${expected}`, () => {
46+
expect(pickRes("hex", zoom, NJ_LAT)).toBe(expected)
47+
})
48+
}
49+
})
50+
51+
describe("picker: circle mode coarsest-fit override", () => {
52+
// Below the rasterized threshold (target dot < 1.5px), circle mode
53+
// MUST fall through to hex-mode auto to preserve statewide density
54+
// paint. Above it, circle can go coarser than hex mode.
55+
it("z=8.5 (target < 1.5px): matches hex auto (r9, rasterized)", () => {
56+
expect(circleRadiusPx(8.5)).toBeLessThan(1.5)
57+
expect(pickRes("circle", 8.5, NJ_LAT)).toBe(pickRes("hex", 8.5, NJ_LAT))
58+
})
59+
60+
// Anchor circle-mode picks at every zoom in a table so any drift
61+
// shows up loud.
62+
const cases: Array<[number, number, number]> = [
63+
// [zoom, hex_res, circle_res]
64+
[8.5, 9, 9], // rasterized preserved (target < 1.5)
65+
[8.7, 9, 8], // one-step jump at threshold crossing (target ≈ 1.53)
66+
[9.0, 9, 8],
67+
[10.94, 10, 9], // Bergen — the vp that flagged the picker over-fetch
68+
[11.0, 10, 9],
69+
[12.0, 11, 9],
70+
[13.81, 11, 10], // mid-zoom (DTJC)
71+
[14.0, 12, 11],
72+
[16.91, 12, 12], // deep: auto wins (coarsestFit fits inside auto)
73+
[17.0, 13, 12], // circle stays r12; hex bumps to r13
74+
[18.0, 14, 13],
75+
[19.0, 15, 13], // circle strictly coarser at very deep zoom
76+
[20.0, 15, 14],
77+
]
78+
for (const [zoom, hex, circle] of cases) {
79+
it(`z=${zoom}: hex=r${hex}, circle=r${circle}`, () => {
80+
expect(pickRes("hex", zoom, NJ_LAT)).toBe(hex)
81+
expect(pickRes("circle", zoom, NJ_LAT)).toBe(circle)
82+
})
83+
}
84+
})
85+
86+
describe("picker: hex mode monotone (never coarser as we zoom in)", () => {
87+
// Hex-mode tessellation should never drop res as zoom increases.
88+
// Circle mode is intentionally *not* monotone at the boundary
89+
// crossing (z≈8.5 target=1.49→1.55 wants slightly-coarser cells so
90+
// 1.5px dots fit inscribed instead of clamping to sub-px on r9).
91+
it("res non-decreasing across z=7 → 20 (hex)", () => {
92+
let prev = -1
93+
for (let z = 7; z <= 20; z += 0.1) {
94+
const r = pickRes("hex", z, NJ_LAT)
95+
expect(r).toBeGreaterThanOrEqual(prev)
96+
prev = r
97+
}
98+
})
99+
})
100+
101+
describe("picker: circle mode piecewise monotone", () => {
102+
// Circle mode has ONE legit dropdown at the rasterized→discrete-dot
103+
// threshold crossing (z≈8.6 for the current curve). Everywhere else
104+
// it should stay non-decreasing as z increases.
105+
it("at most one res-drop across z=7 → 20", () => {
106+
let prev = -1
107+
let drops = 0
108+
for (let z = 7; z <= 20; z += 0.1) {
109+
const r = pickRes("circle", z, NJ_LAT)
110+
if (r < prev) drops++
111+
prev = r
112+
}
113+
expect(drops).toBeLessThanOrEqual(1)
114+
})
115+
})
116+
117+
describe("picker: circle mode never picks a res finer than hex mode", () => {
118+
// Corollary of the "Math.max with auto" guard — circle-mode's data
119+
// reduction should never accidentally fetch finer cells than hex.
120+
it("across z=7 → 20", () => {
121+
for (let z = 7; z <= 20; z += 0.25) {
122+
const hexRes = pickRes("hex", z, NJ_LAT)
123+
const circleRes = pickRes("circle", z, NJ_LAT)
124+
expect(circleRes).toBeLessThanOrEqual(hexRes)
125+
}
126+
})
127+
})
128+
129+
describe("picker: circleRadiusPx curve", () => {
130+
// Anchor the growth curve at a couple of critical zooms.
131+
it("z=7 → 1.2px (min clamp)", () => {
132+
expect(circleRadiusPx(7)).toBeCloseTo(1.2, 2)
133+
})
134+
it("z=17 → ~5px (hoverable street-level)", () => {
135+
expect(circleRadiusPx(17)).toBeGreaterThan(4.5)
136+
expect(circleRadiusPx(17)).toBeLessThan(5.5)
137+
})
138+
it("z=20 → capped at 24 (max clamp)", () => {
139+
expect(circleRadiusPx(20)).toBeLessThanOrEqual(24)
140+
})
141+
})
142+
143+
describe("picker: hexPxTargetFor stays reasonable", () => {
144+
// Regardless of viz mode / zoom, target should be within picker's
145+
// sane range (1..30).
146+
for (let z = 7; z <= 20; z += 1) {
147+
it(`z=${z}: 1 ≤ target ≤ 30 in both modes`, () => {
148+
for (const viz of ["hex", "circle"] as const) {
149+
const t = hexPxTargetFor(viz, z, NJ_LAT)
150+
expect(t).toBeGreaterThanOrEqual(1)
151+
expect(t).toBeLessThanOrEqual(30)
152+
}
153+
})
154+
}
155+
})

0 commit comments

Comments
 (0)