cursedbelt 5.92.3 → 5.94.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/react/components/ComboboxField.d.ts +10 -0
  2. package/dist/react/components/ComboboxField.d.ts.map +1 -1
  3. package/dist/react/components/ComboboxField.js +4 -4
  4. package/dist/react/components/ComboboxField.js.map +1 -1
  5. package/dist/react/components/CommentThread.d.ts +9 -3
  6. package/dist/react/components/CommentThread.d.ts.map +1 -1
  7. package/dist/react/components/CommentThread.js +25 -11
  8. package/dist/react/components/CommentThread.js.map +1 -1
  9. package/dist/react/components/VoiceNote.d.ts +20 -0
  10. package/dist/react/components/VoiceNote.d.ts.map +1 -0
  11. package/dist/react/components/VoiceNote.js +54 -0
  12. package/dist/react/components/VoiceNote.js.map +1 -0
  13. package/dist/react/components/VoiceRecorder.d.ts +35 -0
  14. package/dist/react/components/VoiceRecorder.d.ts.map +1 -0
  15. package/dist/react/components/VoiceRecorder.js +155 -0
  16. package/dist/react/components/VoiceRecorder.js.map +1 -0
  17. package/dist/react/index.d.ts +2 -0
  18. package/dist/react/index.d.ts.map +1 -1
  19. package/dist/react/index.js +2 -0
  20. package/dist/react/index.js.map +1 -1
  21. package/dist/react/map-frame/MapFrame.d.ts +219 -0
  22. package/dist/react/map-frame/MapFrame.d.ts.map +1 -0
  23. package/dist/react/map-frame/MapFrame.js +726 -0
  24. package/dist/react/map-frame/MapFrame.js.map +1 -0
  25. package/dist/react/map-frame/geo.d.ts +210 -0
  26. package/dist/react/map-frame/geo.d.ts.map +1 -0
  27. package/dist/react/map-frame/geo.js +345 -0
  28. package/dist/react/map-frame/geo.js.map +1 -0
  29. package/dist/react/map-frame/index.d.ts +5 -0
  30. package/dist/react/map-frame/index.d.ts.map +1 -0
  31. package/dist/react/map-frame/index.js +3 -0
  32. package/dist/react/map-frame/index.js.map +1 -0
  33. package/dist/styles-areas/canvas-hud.css +5 -5
  34. package/dist/styles-areas/workbench.css +17 -17
  35. package/package.json +13 -1
  36. package/scripts/guardrails-baseline.json +1 -1
  37. package/scripts/styleAreas.ts +1 -1
  38. package/src/react/components/ComboboxField.spec.tsx +39 -0
  39. package/src/react/components/ComboboxField.tsx +20 -4
  40. package/src/react/components/CommentThread.spec.tsx +25 -0
  41. package/src/react/components/CommentThread.tsx +60 -10
  42. package/src/react/components/VoiceNote.tsx +92 -0
  43. package/src/react/components/VoiceRecorder.spec.tsx +116 -0
  44. package/src/react/components/VoiceRecorder.tsx +224 -0
  45. package/src/react/index.ts +9 -0
  46. package/src/react/map-frame/MapFrame.spec.ts +168 -0
  47. package/src/react/map-frame/MapFrame.tsx +954 -0
  48. package/src/react/map-frame/geo.spec.ts +393 -0
  49. package/src/react/map-frame/geo.ts +421 -0
  50. package/src/react/map-frame/index.ts +34 -0
  51. package/src/react/media/HoverScrubVideoThumb.spec.tsx +4 -0
  52. package/src/styles-areas/canvas-hud.css +5 -5
  53. package/src/styles-areas/workbench.css +17 -17
@@ -0,0 +1,954 @@
1
+ /**
2
+ * A map: Google's imagery underneath, our own interactive layer on top.
3
+ *
4
+ * cursedbelt/react/map-frame — lifted from apps/family (forge task 3700) so every app draws
5
+ * maps through ONE frame. The imagery comes from the app's own `/api/map` proxy
6
+ * (`imageSource` overrides the URL); `cursedbelt-server/maps-static` is that proxy's shared
7
+ * half. The owner's reports quoted below were made on family's atlas.
8
+ *
9
+ * ── Why not a map library ───────────────────────────────────────────────────
10
+ * Three reasons, and the first is not negotiable:
11
+ *
12
+ * 1. **The key can never reach the browser.** `GOOGLE_MAPS_API_KEY` is
13
+ * restricted by API and NOT by HTTP referrer, so a Maps JavaScript API embed
14
+ * — which requires the key in a script URL — would publish a credential
15
+ * anybody could spend. The imagery is therefore proxied by this app's own
16
+ * `/api/map`.
17
+ * 2. **It is cheaper.** A static render bills at roughly a third of a JS map
18
+ * load, and identical viewports are shared between every viewer by the
19
+ * server's cache.
20
+ * 3. **The overlay is the product.** What makes this a family atlas rather than
21
+ * a map is that a dot is a person you can open, an arc is a journey somebody
22
+ * made, and both are clickable, focusable and announced to a screen reader.
23
+ * Those are our SVG elements either way; a tile engine underneath would only
24
+ * add weight.
25
+ *
26
+ * ── 🔴 The alignment contract ───────────────────────────────────────────────
27
+ * The overlay is only correct if it uses the SAME projection as the imagery. It
28
+ * does — `shared/geo.ts` implements Google's own Web Mercator formula — and the
29
+ * two are locked together by:
30
+ *
31
+ * · one FIXED logical viewport (`VIEW_WIDTH` × `VIEW_HEIGHT`) for both,
32
+ * · `preserveAspectRatio="xMidYMid slice"` on the SVG, which now carries BOTH
33
+ * layers (see "one coordinate system" below),
34
+ * · the same `center` and integer `zoom` behind both.
35
+ *
36
+ * Break any one and every pin drifts from its coastline — more the further from
37
+ * the equator, which is exactly where this family's records are.
38
+ *
39
+ * ── 🔴 Why the imagery is an SVG `<image>` and not a sibling `<img>` ─────────
40
+ * It used to be a CSS-positioned `<img>` under the SVG, kept in step only by
41
+ * `object-fit: cover` matching `slice`. That is two coordinate systems agreeing
42
+ * by construction, which is fine while nothing MOVES — and stops being fine the
43
+ * moment a drag has to translate both by the same amount, because a viewport unit
44
+ * is not a CSS pixel and the conversion depends on the element's measured box.
45
+ * Putting the imagery inside the SVG makes the translation one number in one
46
+ * coordinate system, which is what makes the pan below correct rather than
47
+ * approximately correct.
48
+ *
49
+ * ── 🔴 The drag defect this file was rewritten for ──────────────────────────
50
+ * The owner's report, verbatim: *"Dragging on a map moves the nodes and paths
51
+ * while the map stays still and catches up after dropping."* Both halves of that
52
+ * were one cause. The old `onPointerMove` called `onViewChange` on EVERY move, so:
53
+ *
54
+ * · the SVG overlay re-projected instantly — pins and arcs moved with the finger;
55
+ * · the imagery is a NETWORK FETCH (`/api/map?lat=…&lng=…`), so it could only
56
+ * appear once a round trip completed, one round trip per mouse move, arriving
57
+ * long after the gesture. The map therefore lagged and "caught up" at the end.
58
+ *
59
+ * It was also **spending money on every mouse move**: each distinct center is a
60
+ * separate billable Google Static Maps render, so one drag across the atlas minted
61
+ * dozens of renders against the app's ceiling — for pictures nobody ever
62
+ * saw, because the next move superseded them.
63
+ *
64
+ * The fix is the one every map makes: a drag translates the WHOLE SCENE locally
65
+ * (`pan`, in viewport units, applied to imagery and overlay by a single
66
+ * `translate`), and the viewport is committed exactly ONCE, on release. So a drag
67
+ * is now one render rather than fifty, and the two layers cannot separate because
68
+ * neither one re-projects while the finger is down.
69
+ *
70
+ * ── …and why the released frame does not flash ──────────────────────────────
71
+ * Committing swaps `center`, which changes the image URL, which is a fetch. Naively
72
+ * that blanks the map for as long as the network takes — replacing a lag at the end
73
+ * of the drag with a hole at the end of the drag. So the frame keeps the imagery it
74
+ * has (`settled`) and DRAWS IT WHERE IT BELONGS in the new viewport: same math as
75
+ * a pin, applied to a picture. The old imagery slides out of frame, its blank edge
76
+ * follows the drag, and the new render replaces it when it lands. That is exactly
77
+ * what a tiled map does, without tiles.
78
+ *
79
+ * ── 🔴 Continuous gestures on top of an INTEGER-zoom imagery source ─────────
80
+ * The owner's second report: *"I wish zooming in and out was possible with mouse
81
+ * gestures… mobile should allow touch movements and zoom too."* The obvious fix —
82
+ * fetch a render per wheel tick — is the drag defect above wearing a different
83
+ * hat, and it bills for every frame.
84
+ *
85
+ * So a gesture runs entirely LOCALLY against a fractional zoom, and only the
86
+ * settled value is snapped and committed:
87
+ *
88
+ * · `live` holds the viewport the reader is currently manipulating. Its zoom may
89
+ * be 6.37; `project` and `imagePlacement` are both continuous, so the overlay
90
+ * and the imagery scale together through the whole gesture with no fetch.
91
+ * · the image URL is built from the PROPS — the last COMMITTED integer viewport —
92
+ * so no amount of wheeling or pinching can mint a render.
93
+ * · a wheel commits 220 ms after it stops; a pinch commits on release; a drag
94
+ * commits when it comes to rest. One billable render per gesture, same as before.
95
+ *
96
+ * The zoom is anchored under the cursor (`zoomAnchoredAt`), not about the frame's
97
+ * center, which is the difference between closing in on a town and watching it
98
+ * slide off the edge as you approach it.
99
+ *
100
+ * ── 🔴 …and the fraction SURVIVES the commit (2026-09-11) ───────────────────
101
+ * The imagery takes an integer zoom, so a commit rounds — and a notch is only
102
+ * ~0.26 of a level. Every notch that fell outside the settle window therefore
103
+ * rounded itself away: measured against the counter in
104
+ * `@satellites/maps-platform`, six notches 260 ms apart committed six times and
105
+ * left the map at **exactly the zoom it started on**, and no number of trackpad
106
+ * flicks (whose deltas are a tenth of a notch) could zoom it at all.
107
+ *
108
+ * 🔴 That bug was also the only reason the slow path looked cheap — six commits
109
+ * rounding to one integer are six identical URLs, so five were cache hits. So the
110
+ * two belong together: `wheelLeftover` carries the discarded fraction into the
111
+ * next notch's TARGET (never into the viewport it is anchored against), and
112
+ * family's `FORGE/apps/family/src/components/wheelBilling.test.tsx` pins the gesture's cost and the fact that it moved in
113
+ * the same assertions. Repair one without the other and the atlas starts billing
114
+ * a render per notch, which is the defect this whole section exists to prevent.
115
+ *
116
+ * ── Degrading with no key ───────────────────────────────────────────────────
117
+ * With no imagery the frame draws a graticule and keeps every pin, arc and
118
+ * interaction working. A family atlas with no coastlines is still a diagram of
119
+ * who went where; refusing to draw would be worse.
120
+ */
121
+ import React from "react";
122
+ import {
123
+ type LatLng,
124
+ MAX_ZOOM,
125
+ MIN_ZOOM,
126
+ project,
127
+ scaleBar,
128
+ snapZoom,
129
+ unproject,
130
+ type Viewport,
131
+ zoomAnchoredAt,
132
+ } from "./geo.js";
133
+
134
+ /**
135
+ * The one logical viewport, for both layers.
136
+ *
137
+ * 640×400 because Google caps a single static render at 640px before `scale`, and
138
+ * a fixed size is what keeps the cache key space tiny — see `/api/map`. The frame
139
+ * is then CSS-scaled to whatever the layout gives it.
140
+ */
141
+ export const VIEW_WIDTH = 640;
142
+ export const VIEW_HEIGHT = 400;
143
+
144
+ /**
145
+ * 🔴 How far the pointer must travel before this becomes a pan.
146
+ *
147
+ * The first version captured the pointer on `pointerdown`, which redirected a
148
+ * press on a PIN to the map — so the primary interaction of the whole screen
149
+ * (touch a dot to open the place) silently did nothing, while panning worked
150
+ * perfectly. Found by driving the live page in a browser; no unit test would
151
+ * have caught it, because both halves are individually correct.
152
+ *
153
+ * Six viewport units is above the tremor of a deliberate click and well below
154
+ * any intended drag.
155
+ */
156
+ const PAN_THRESHOLD = 6;
157
+
158
+ /**
159
+ * Wheel travel → zoom levels.
160
+ *
161
+ * A notched mouse wheel reports ~100 `deltaY` per click and a trackpad reports a
162
+ * stream of small ones, so a single constant has to serve both. 1/380 puts a
163
+ * mouse click at just over a quarter of a zoom level — four clicks to double the
164
+ * scale, which is deliberate: a wheel that jumped a whole level per notch is what
165
+ * makes people overshoot and then hunt.
166
+ */
167
+ const ZOOM_PER_WHEEL_PIXEL = 1 / 380;
168
+
169
+ /** How long after the last wheel event the viewport is committed — i.e. how long
170
+ * after the reader stops before the sharp render is asked for. Long enough that
171
+ * a multi-notch scroll is ONE render, short enough not to feel sticky.
172
+ *
173
+ * 🔴 Exported so a smoke (family's `__smoke/atlas.spec.ts`) can DERIVE its billing ceiling from it
174
+ * rather than hard-coding a number that has to be loosened every time the Mac
175
+ * gets busy. See that file — a gate whose constant drifts upward under load is
176
+ * a gate that stops guarding the thing it was written for. */
177
+ export const WHEEL_SETTLE_MS = 220;
178
+
179
+ /**
180
+ * Momentum: how fast a flick decays, as the time constant of an exponential.
181
+ *
182
+ * 120 ms gives a glide of roughly a third of a second — enough that a flick
183
+ * crosses the Atlantic in one gesture, short enough that the map never feels like
184
+ * it is running away from the reader.
185
+ */
186
+ const GLIDE_DECAY_MS = 120;
187
+
188
+ /** Below this (viewport units per ms) a release was a stop, not a flick. */
189
+ const GLIDE_MIN_VELOCITY = 0.02;
190
+
191
+ const clampZoom = (zoom: number): number => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom));
192
+
193
+ /** The viewport whose imagery is currently on screen. Tracked separately from the
194
+ * prop viewport because the two differ for exactly as long as a render is in
195
+ * flight, and that gap is what the frame draws through instead of blanking. */
196
+ interface SettledImage {
197
+ src: string;
198
+ center: LatLng;
199
+ zoom: number;
200
+ }
201
+
202
+ /** The URL of one rendered viewport. Pure, and exported so a test can name the
203
+ * picture it expects without restating the query string. */
204
+ export function mapImageSource(center: LatLng, zoom: number, theme: "light" | "dark"): string {
205
+ return (
206
+ `/api/map?lat=${center.lat.toFixed(4)}&lng=${center.lng.toFixed(4)}` +
207
+ `&z=${zoom}&w=${VIEW_WIDTH}&h=${VIEW_HEIGHT}&theme=${theme}`
208
+ );
209
+ }
210
+
211
+ /**
212
+ * Where a already-rendered viewport sits inside the one being drawn now.
213
+ *
214
+ * Pure, and the whole reason a committed pan does not flash: it is the same
215
+ * projection a pin gets, applied to a 640×400 picture. `scale` is the zoom
216
+ * difference (a double-click leaves a half-size image covering the middle of the
217
+ * frame until the closer render lands), and the position is the settled center
218
+ * projected into the current view, minus half the scaled picture.
219
+ */
220
+ export function imagePlacement(
221
+ settled: { center: LatLng; zoom: number },
222
+ view: Viewport,
223
+ ): { x: number; y: number; width: number; height: number } {
224
+ const scale = 2 ** (view.zoom - settled.zoom);
225
+ const at = project(settled.center, view);
226
+ const width = VIEW_WIDTH * scale;
227
+ const height = VIEW_HEIGHT * scale;
228
+ return { x: at.x - width / 2, y: at.y - height / 2, width, height };
229
+ }
230
+
231
+ /**
232
+ * The viewport a completed drag lands on.
233
+ *
234
+ * Pure, because it is the one piece of the gesture worth pinning in a test: the
235
+ * center moves OPPOSITE the finger, through the projection, so a drag covers the
236
+ * same ground at every zoom.
237
+ */
238
+ export function pannedCenter(
239
+ center: LatLng,
240
+ zoom: number,
241
+ pan: { dx: number; dy: number },
242
+ ): LatLng {
243
+ const view: Viewport = { center, zoom, width: VIEW_WIDTH, height: VIEW_HEIGHT };
244
+ const anchor = project(center, view);
245
+ return unproject({ x: anchor.x - pan.dx, y: anchor.y - pan.dy }, view);
246
+ }
247
+
248
+ /**
249
+ * Pure: the part of the logical viewport a `box` of that size actually shows. The SVG is
250
+ * `xMidYMid slice`, so a box narrower than 16:10 crops the sides and a wider one crops top and
251
+ * bottom — a consumer that fits pins to the whole 640×400 would put some of them out of sight on a
252
+ * phone. Fit to this instead (its `width`/`height` are `zoomToFit`'s size), and keep a label inside
253
+ * `x … x + width`. An unmeasured box (0×0) shows the whole viewport.
254
+ */
255
+ export function visibleArea(box: { width: number; height: number }): { x: number; y: number; width: number; height: number } {
256
+ if (!(box.width > 0 && box.height > 0)) return { x: 0, y: 0, width: VIEW_WIDTH, height: VIEW_HEIGHT };
257
+ const scale = Math.max(box.width / VIEW_WIDTH, box.height / VIEW_HEIGHT);
258
+ const width = Math.min(VIEW_WIDTH, box.width / scale);
259
+ const height = Math.min(VIEW_HEIGHT, box.height / scale);
260
+ return { x: (VIEW_WIDTH - width) / 2, y: (VIEW_HEIGHT - height) / 2, width, height };
261
+ }
262
+
263
+ /** Any other attribute (`data-*`, `aria-describedby`, an `id`) lands on the frame's root `<div>`. */
264
+ export interface MapFrameProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
265
+ center: LatLng;
266
+ zoom: number;
267
+ theme: "light" | "dark";
268
+ /** Drawn inside the SVG, in viewport coordinates. Given `project` so children
269
+ * never re-derive the math. */
270
+ children: (helpers: {
271
+ project: (point: LatLng) => { x: number; y: number };
272
+ view: Viewport;
273
+ }) => React.ReactNode;
274
+ /** A completed drag or a double-click asks for a new viewport. Absent means the
275
+ * map is fixed — which is right for a thumbnail and wrong for the atlas.
276
+ *
277
+ * 🔴 Called ONCE per gesture, on release — never per pointer move. See the
278
+ * file header: a call here is a billable render. */
279
+ onViewChange?: (next: { center: LatLng; zoom: number }) => void;
280
+ /** Announced to assistive technology. The map is decorative without one. */
281
+ label: string;
282
+ /** The URL of one rendered viewport. Defaults to {@link mapImageSource} — the app's own
283
+ * `/api/map` proxy, which `cursedbelt-server/maps-static` serves the shared half of. */
284
+ imageSource?: (center: LatLng, zoom: number, theme: "light" | "dark") => string;
285
+ }
286
+
287
+ /*
288
+ * 🔴 Every style is INLINE, so an app mounts the frame with no stylesheet to import and no
289
+ * class to keep in step (family's `.ft-map*` rules were the frame's whole CSS). A consumer
290
+ * sizes the frame by sizing its container — the frame is 100% of it.
291
+ */
292
+ const FRAME_STYLE: React.CSSProperties = {
293
+ position: "relative",
294
+ width: "100%",
295
+ height: "100%",
296
+ overflow: "hidden",
297
+ background: "var(--muted)",
298
+ };
299
+ const LAYER_STYLE: React.CSSProperties = { position: "absolute", inset: 0, width: "100%", height: "100%" };
300
+ /** The render being fetched: it occupies no space and paints nothing. `display: none` would
301
+ * stop some browsers fetching at all. */
302
+ const PRELOAD_STYLE: React.CSSProperties = { position: "absolute", width: 1, height: 1, opacity: 0, pointerEvents: "none" };
303
+ const IMAGE_STYLE = { userSelect: "none", WebkitUserDrag: "none" } as React.CSSProperties;
304
+
305
+ export function MapFrame({
306
+ center,
307
+ zoom,
308
+ theme,
309
+ children,
310
+ onViewChange,
311
+ label,
312
+ className,
313
+ style,
314
+ imageSource = mapImageSource,
315
+ ...rest
316
+ }: MapFrameProps) {
317
+ /** Per instance: two frames on one page must not share one `<pattern>` id. */
318
+ const graticuleId = `cb-map-graticule-${React.useId().replace(/:/g, "")}`;
319
+ const svgRef = React.useRef<SVGSVGElement | null>(null);
320
+
321
+ /**
322
+ * The viewport the reader is manipulating right now, fractional zoom and all.
323
+ *
324
+ * Null when nothing is in flight, and then the props ARE the viewport. Non-null
325
+ * only between the first wheel notch (or the second finger) and the commit —
326
+ * see the file header for why this cannot be allowed to reach the image URL.
327
+ */
328
+ const [live, setLive] = React.useState<{ center: LatLng; zoom: number } | null>(null);
329
+ const effective = live ?? { center, zoom };
330
+ const view: Viewport = {
331
+ center: effective.center,
332
+ zoom: effective.zoom,
333
+ width: VIEW_WIDTH,
334
+ height: VIEW_HEIGHT,
335
+ };
336
+
337
+ /** 🔴 Built from the PROPS, never from `effective`. The committed viewport is
338
+ * the only one that is allowed to cost money. */
339
+ const source = imageSource(center, zoom, theme);
340
+
341
+ /** The imagery on screen, which is the last one that LOADED — not the one the
342
+ * props ask for. They differ only while a render is in flight. */
343
+ const [settled, setSettled] = React.useState<SettledImage | null>(null);
344
+ /** Sources that answered with an error. Keyed by URL rather than a boolean, so
345
+ * one 404 (a ceiling hit, a blip) cannot permanently blank every map in the
346
+ * session the way a sticky flag did — a different viewport is a different
347
+ * picture and deserves its own attempt. */
348
+ const [failed, setFailed] = React.useState<ReadonlySet<string>>(() => new Set());
349
+
350
+ /**
351
+ * The live drag, in viewport units.
352
+ *
353
+ * State rather than a ref because the scene has to re-render as it moves; a ref
354
+ * would translate nothing. The gesture's origin lives in `drag` (a ref, because
355
+ * changing it must not re-render).
356
+ */
357
+ const [pan, setPan] = React.useState<{ dx: number; dy: number } | null>(null);
358
+ const drag = React.useRef<{
359
+ pointerId: number;
360
+ startX: number;
361
+ startY: number;
362
+ /** Set once the pointer has moved far enough to BE a drag. Until then the
363
+ * gesture is still a click, and the pointer is deliberately not captured. */
364
+ panning: boolean;
365
+ /** The last few positions, for the flick velocity. Trimmed to the recent past
366
+ * so a slow drag that PAUSES before release glides nowhere, which is what
367
+ * "put it down here" should mean. */
368
+ samples: { x: number; y: number; at: number }[];
369
+ } | null>(null);
370
+
371
+ /** Every pointer currently on the frame. Two of them is a pinch; one is a drag.
372
+ * A ref because tracking a finger must not re-render. */
373
+ const pointers = React.useRef(new Map<number, { x: number; y: number }>());
374
+ /** The pinch in flight: the viewport it started from, so every frame is computed
375
+ * from the ORIGIN rather than accumulated from the last one (which drifts). */
376
+ const pinch = React.useRef<{
377
+ startDistance: number;
378
+ startMidpoint: { x: number; y: number };
379
+ startView: Viewport;
380
+ } | null>(null);
381
+
382
+ /** Mirrors `live` for the callbacks that fire outside a render — the wheel's
383
+ * settle timer and the glide's animation frames. */
384
+ const liveRef = React.useRef(live);
385
+ liveRef.current = live;
386
+ const committedRef = React.useRef({ center, zoom, onViewChange });
387
+ committedRef.current = { center, zoom, onViewChange };
388
+
389
+ const wheelTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
390
+ const glide = React.useRef<number | null>(null);
391
+
392
+ /**
393
+ * 🔴 The fraction the last commit ROUNDED AWAY, and what it rounded to.
394
+ *
395
+ * The imagery source takes an integer zoom (`/api/map` rounds it), so `commit`
396
+ * snaps — and a notch is only ~0.26 of a level. A gesture whose notches land
397
+ * inside one settle window accumulates in `live` and is fine; one whose notches
398
+ * are further apart than `WHEEL_SETTLE_MS` commits each notch on its own, and
399
+ * every one of those commits used to start again from the SNAPPED integer.
400
+ *
401
+ * Measured 2026-09-11, six notches driven through this component with the spend
402
+ * counted at `@satellites/maps-platform`: at 260 ms apart the map committed six
403
+ * times and ended at **exactly the zoom it started at**. A slow wheel could not
404
+ * zoom, and a trackpad — whose deltas are a tenth of a notch and whose momentum
405
+ * tail outlasts any fixed debounce — could not zoom at all.
406
+ *
407
+ * 🔴 That bug was also the only reason the slow path looked CHEAP: six commits
408
+ * that all round to the same integer are six identical URLs, so five of them
409
+ * were cache hits. Fix the zoom without this and the atlas silently starts
410
+ * billing a render per notch — the exact defect the debounce exists to prevent.
411
+ * So the fraction is carried, and family's `FORGE/apps/family/src/components/wheelBilling.test.tsx` pins both halves
412
+ * together: the gesture must zoom AND it must cost one render.
413
+ *
414
+ * Only the WHEEL reads it. A drag, a pinch and a double-click all start from a
415
+ * viewport the reader can see, and a double-click resuming from a hidden 0.4 of
416
+ * a level would jump two levels from the one that is drawn.
417
+ */
418
+ const carried = React.useRef<{ center: LatLng; fractional: number; snapped: number } | null>(
419
+ null,
420
+ );
421
+
422
+ /** The viewport a gesture is standing on right now, wherever it is being read
423
+ * from. Always prefers the live one — a wheel then a drag is one continuous
424
+ * interaction to the reader and must be one to the arithmetic. */
425
+ const readEffective = (): { center: LatLng; zoom: number } =>
426
+ liveRef.current ?? { center: committedRef.current.center, zoom: committedRef.current.zoom };
427
+
428
+ /**
429
+ * How much of a zoom level the last commit rounded away — added to the TARGET
430
+ * of the next notch, never to the viewport it is anchored against.
431
+ *
432
+ * 🔴 That split is the whole subtlety. The anchor (`zoomAnchoredAt`) has to be
433
+ * computed against the viewport actually on screen, or the ground under the
434
+ * cursor is unprojected at a scale nothing is drawn at and the point slides out
435
+ * from under the pointer — worse at the edges, which is where somebody zooming
436
+ * toward a town is aiming. So the fraction goes into where the notch is HEADED,
437
+ * and the picture it starts from stays the one the reader can see.
438
+ *
439
+ * Zero while a gesture is live: `live` already holds the fraction then.
440
+ *
441
+ * It counts only when the props are still EXACTLY what we handed up, which is
442
+ * what makes it safe — a parent that moved the map itself (a deep link, a
443
+ * cluster click, a fly-to) fails the comparison and the props win, so a stale
444
+ * fraction can never shadow somebody else's viewport.
445
+ */
446
+ const wheelLeftover = (): number => {
447
+ if (liveRef.current) return 0;
448
+ const held = carried.current;
449
+ if (!held) return 0;
450
+ const committed = committedRef.current;
451
+ const ours =
452
+ held.snapped === committed.zoom &&
453
+ held.center.lat === committed.center.lat &&
454
+ held.center.lng === committed.center.lng;
455
+ return ours ? held.fractional - held.snapped : 0;
456
+ };
457
+
458
+ const stopGlide = (): void => {
459
+ if (glide.current !== null) cancelAnimationFrame(glide.current);
460
+ glide.current = null;
461
+ };
462
+
463
+ /**
464
+ * Hand a settled viewport up, and drop every local override in the same tick.
465
+ *
466
+ * Both halves matter. Committing without clearing `live` leaves the gesture's
467
+ * fractional zoom permanently shadowing the props; clearing without committing
468
+ * springs the map back to where the gesture started.
469
+ */
470
+ const commit = (next: { center: LatLng; zoom: number }): void => {
471
+ setLive(null);
472
+ liveRef.current = null;
473
+ setPan(null);
474
+ const snapped = snapZoom(next.zoom);
475
+ // Keep the fraction `snapped` just discarded, so the next wheel notch resumes
476
+ // where this gesture actually ended rather than at the rounded integer — see
477
+ // `carried` above for the measurement that made this necessary.
478
+ carried.current = { center: next.center, fractional: next.zoom, snapped };
479
+ committedRef.current.onViewChange?.({ center: next.center, zoom: snapped });
480
+ };
481
+
482
+ // Takes the base mouse shape rather than `React.PointerEvent`, because a
483
+ // double-click and a native wheel event arrive as MouseEvents and need the same
484
+ // arithmetic.
485
+ const toViewportUnits = (event: {
486
+ clientX: number;
487
+ clientY: number;
488
+ }): { x: number; y: number } | null => {
489
+ const svg = svgRef.current;
490
+ if (!svg) return null;
491
+ const box = svg.getBoundingClientRect();
492
+ if (box.width === 0 || box.height === 0) return null;
493
+ // `slice` scales by the LARGER ratio and centers the overflow, so undoing it
494
+ // needs the same scale in both axes plus the centering offset.
495
+ const scale = Math.max(box.width / VIEW_WIDTH, box.height / VIEW_HEIGHT);
496
+ return {
497
+ x: (event.clientX - box.left - (box.width - VIEW_WIDTH * scale) / 2) / scale,
498
+ y: (event.clientY - box.top - (box.height - VIEW_HEIGHT * scale) / 2) / scale,
499
+ };
500
+ };
501
+
502
+ /**
503
+ * 🔴 The wheel is bound by hand, non-passive, and it has to be.
504
+ *
505
+ * React registers `onWheel` at the root as a PASSIVE listener, so
506
+ * `event.preventDefault()` inside a JSX `onWheel` prop is silently ignored and
507
+ * the page scrolls behind the map while the map also zooms. A native listener
508
+ * with `{ passive: false }` is the only way to own the gesture. Bound once and
509
+ * kept — every value it reads comes from a ref, so it can never go stale.
510
+ */
511
+ // biome-ignore lint/correctness/useExhaustiveDependencies: bound exactly once, on purpose. Everything the handler reads lives in a ref (`committedRef`, `liveRef`) or is a stable setter, so re-binding per render would only churn listeners — and re-binding mid-gesture would drop the wheel the reader is in the middle of.
512
+ React.useEffect(() => {
513
+ const svg = svgRef.current;
514
+ if (!svg) return;
515
+ const onWheel = (event: WheelEvent): void => {
516
+ if (!committedRef.current.onViewChange) return;
517
+ event.preventDefault();
518
+ stopGlide();
519
+ const point = toViewportUnits(event);
520
+ if (!point) return;
521
+ // `deltaMode` 1 is lines and 2 is pages — Firefox reports lines for a
522
+ // notched wheel, so a raw deltaY would zoom ~15× too slowly there.
523
+ const lines = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? VIEW_HEIGHT : 1;
524
+ // Anchored against what is DRAWN…
525
+ const from = readEffective();
526
+ const startView: Viewport = {
527
+ center: from.center,
528
+ zoom: from.zoom,
529
+ width: VIEW_WIDTH,
530
+ height: VIEW_HEIGHT,
531
+ };
532
+ // …and aimed from where the last gesture actually ended, so notches spaced
533
+ // further apart than the settle still accumulate instead of each rounding
534
+ // back to the integer they started on. See `wheelLeftover`.
535
+ const base = from.zoom + wheelLeftover();
536
+ const next = clampZoom(base - event.deltaY * lines * ZOOM_PER_WHEEL_PIXEL);
537
+ if (next === base) return;
538
+ const moved = { center: zoomAnchoredAt(startView, point, next), zoom: next };
539
+ liveRef.current = moved;
540
+ setLive(moved);
541
+
542
+ if (wheelTimer.current) clearTimeout(wheelTimer.current);
543
+ wheelTimer.current = setTimeout(() => {
544
+ wheelTimer.current = null;
545
+ const settledView = liveRef.current;
546
+ if (settledView) commit(settledView);
547
+ }, WHEEL_SETTLE_MS);
548
+ };
549
+ svg.addEventListener("wheel", onWheel, { passive: false });
550
+ return () => svg.removeEventListener("wheel", onWheel);
551
+ }, []);
552
+
553
+ // Nothing should outlive the component: a pending settle would commit a
554
+ // viewport into an unmounted parent, and a running glide would leak a frame
555
+ // loop for as long as the tab is open.
556
+ React.useEffect(
557
+ () => () => {
558
+ if (wheelTimer.current) clearTimeout(wheelTimer.current);
559
+ if (glide.current !== null) cancelAnimationFrame(glide.current);
560
+ },
561
+ [],
562
+ );
563
+
564
+ /**
565
+ * The flick.
566
+ *
567
+ * A released drag keeps moving and decays exponentially, then commits ONCE when
568
+ * it comes to rest — so momentum costs the same single render a plain drag does.
569
+ * Readers who have asked for less motion get the plain drag.
570
+ */
571
+ const startGlide = (from: { dx: number; dy: number }, velocity: { vx: number; vy: number }) => {
572
+ const base = readEffective();
573
+ const settle = (offset: { dx: number; dy: number }): void => {
574
+ commit({ center: pannedCenter(base.center, base.zoom, offset), zoom: base.zoom });
575
+ };
576
+ const speed = Math.hypot(velocity.vx, velocity.vy);
577
+ const reduced =
578
+ typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
579
+ if (speed < GLIDE_MIN_VELOCITY || reduced) {
580
+ settle(from);
581
+ return;
582
+ }
583
+
584
+ let offset = from;
585
+ let vx = velocity.vx;
586
+ let vy = velocity.vy;
587
+ let last = performance.now();
588
+ const step = (now: number): void => {
589
+ // Clamped: a backgrounded tab hands back one enormous delta, which would
590
+ // teleport the map on return.
591
+ const elapsed = Math.min(48, now - last);
592
+ last = now;
593
+ offset = { dx: offset.dx + vx * elapsed, dy: offset.dy + vy * elapsed };
594
+ const decay = Math.exp(-elapsed / GLIDE_DECAY_MS);
595
+ vx *= decay;
596
+ vy *= decay;
597
+ setPan(offset);
598
+ if (Math.hypot(vx, vy) > GLIDE_MIN_VELOCITY / 4) {
599
+ glide.current = requestAnimationFrame(step);
600
+ return;
601
+ }
602
+ glide.current = null;
603
+ settle(offset);
604
+ };
605
+ glide.current = requestAnimationFrame(step);
606
+ };
607
+
608
+ /**
609
+ * Release the drag and let it come to rest on a viewport.
610
+ *
611
+ * Committing here (or at the end of the glide this may hand off to) and nowhere
612
+ * else is the whole fix — see the header. A gesture that never crossed
613
+ * `PAN_THRESHOLD` was a click and commits nothing.
614
+ */
615
+ const endDrag = (element: Element | null, pointerId: number): void => {
616
+ const state = drag.current;
617
+ drag.current = null;
618
+ const offset = pan;
619
+ if (element?.hasPointerCapture?.(pointerId)) element.releasePointerCapture(pointerId);
620
+ if (!state?.panning || !offset || !committedRef.current.onViewChange) {
621
+ setPan(null);
622
+ return;
623
+ }
624
+ if (offset.dx === 0 && offset.dy === 0) {
625
+ setPan(null);
626
+ return;
627
+ }
628
+ // Velocity from the recent samples only. A drag that stalled before release
629
+ // has none, and gliding it anyway is the "it kept going after I stopped"
630
+ // complaint every over-eager momentum implementation earns.
631
+ const recent = state.samples.filter((sample) => performance.now() - sample.at < 90);
632
+ const first = recent[0];
633
+ const last = recent[recent.length - 1];
634
+ const span = first && last ? last.at - first.at : 0;
635
+ const velocity =
636
+ first && last && span > 8
637
+ ? { vx: (last.x - first.x) / span, vy: (last.y - first.y) / span }
638
+ : { vx: 0, vy: 0 };
639
+ startGlide(offset, velocity);
640
+ };
641
+
642
+ /** A pointer has gone away — from a release, a cancel, or leaving the window. */
643
+ const dropPointer = (element: Element | null, pointerId: number): void => {
644
+ pointers.current.delete(pointerId);
645
+ if (pinch.current) {
646
+ // The pinch ends the moment it stops being two fingers, and where it ends
647
+ // is where it stays — a one-finger remainder must not silently become a
648
+ // drag from a position nobody put their finger on.
649
+ if (pointers.current.size < 2) {
650
+ pinch.current = null;
651
+ drag.current = null;
652
+ const settledView = liveRef.current;
653
+ if (settledView) commit(settledView);
654
+ }
655
+ if (element?.hasPointerCapture?.(pointerId)) element.releasePointerCapture(pointerId);
656
+ return;
657
+ }
658
+ endDrag(element, pointerId);
659
+ };
660
+
661
+ const placement = settled ? imagePlacement(settled, view) : null;
662
+ const loading = source !== settled?.src && !failed.has(source);
663
+
664
+ return (
665
+ <div {...rest} className={className} style={style ? { ...FRAME_STYLE, ...style } : FRAME_STYLE} data-map-frame="" data-interactive={Boolean(onViewChange)}>
666
+ {/*
667
+ * The render currently being fetched, off-screen.
668
+ *
669
+ * A real element rather than `new Image()` so the browser's own decoding,
670
+ * priority and cache behavior apply, and so a test can drive `load` and
671
+ * `error` without stubbing a global. It never paints; the SVG `<image>`
672
+ * below is what the reader sees, and it only ever points at bytes that
673
+ * have already arrived.
674
+ */}
675
+ {loading ? (
676
+ <img
677
+ key={source}
678
+ src={source}
679
+ alt=""
680
+ aria-hidden="true"
681
+ style={PRELOAD_STYLE}
682
+ draggable={false}
683
+ data-testid="map-preload"
684
+ onLoad={() => setSettled({ src: source, center, zoom })}
685
+ onError={() => setFailed((current) => new Set(current).add(source))}
686
+ />
687
+ ) : null}
688
+
689
+ <svg
690
+ ref={svgRef}
691
+ style={{
692
+ ...LAYER_STYLE,
693
+ ...(onViewChange ? { cursor: pan ? "grabbing" : "grab", touchAction: "none" } : {}),
694
+ }}
695
+ viewBox={`0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`}
696
+ preserveAspectRatio="xMidYMid slice"
697
+ role="img"
698
+ aria-label={label}
699
+ data-panning={pan ? "true" : "false"}
700
+ onPointerDown={(event) => {
701
+ if (!onViewChange) return;
702
+ // A wheel-and-then-grab is one interaction: land the wheel's viewport
703
+ // before the hand takes over, or the drag starts from a stale origin.
704
+ stopGlide();
705
+ const point = toViewportUnits(event);
706
+ if (!point) return;
707
+ // Touch and pen have no buttons worth filtering; a mouse has three and
708
+ // only the primary one pans.
709
+ if (event.pointerType === "mouse" && event.button !== 0) return;
710
+ pointers.current.set(event.pointerId, point);
711
+
712
+ if (pointers.current.size === 2) {
713
+ // A second finger converts whatever was happening into a pinch. Any
714
+ // drag offset already on screen is folded into the live center first,
715
+ // so the map does not jump back the instant the finger lands.
716
+ const from = readEffective();
717
+ const base = pan
718
+ ? { center: pannedCenter(from.center, from.zoom, pan), zoom: from.zoom }
719
+ : from;
720
+ setPan(null);
721
+ drag.current = null;
722
+ const [a, b] = [...pointers.current.values()];
723
+ if (!a || !b) return;
724
+ const startView: Viewport = {
725
+ center: base.center,
726
+ zoom: base.zoom,
727
+ width: VIEW_WIDTH,
728
+ height: VIEW_HEIGHT,
729
+ };
730
+ pinch.current = {
731
+ startDistance: Math.max(1, Math.hypot(b.x - a.x, b.y - a.y)),
732
+ startMidpoint: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 },
733
+ startView,
734
+ };
735
+ liveRef.current = base;
736
+ setLive(base);
737
+ event.currentTarget.setPointerCapture(event.pointerId);
738
+ return;
739
+ }
740
+ if (pointers.current.size > 2) return;
741
+
742
+ // Recorded, but NOT captured — see PAN_THRESHOLD. Capturing here is
743
+ // what stole every click on a pin.
744
+ drag.current = {
745
+ pointerId: event.pointerId,
746
+ startX: point.x,
747
+ startY: point.y,
748
+ panning: false,
749
+ samples: [{ x: point.x, y: point.y, at: performance.now() }],
750
+ };
751
+ }}
752
+ onPointerMove={(event) => {
753
+ if (!onViewChange) return;
754
+ const point = toViewportUnits(event);
755
+ if (!point) return;
756
+ if (pointers.current.has(event.pointerId)) pointers.current.set(event.pointerId, point);
757
+
758
+ const pinching = pinch.current;
759
+ if (pinching) {
760
+ if (pointers.current.size < 2) return;
761
+ const [a, b] = [...pointers.current.values()];
762
+ if (!a || !b) return;
763
+ const distance = Math.max(1, Math.hypot(b.x - a.x, b.y - a.y));
764
+ const midpoint = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
765
+ // Computed from the pinch's ORIGIN every frame rather than from the
766
+ // previous frame: accumulating ratios drifts, and a drifting pinch
767
+ // ends somewhere the fingers never asked for.
768
+ const next = clampZoom(
769
+ pinching.startView.zoom + Math.log2(distance / pinching.startDistance),
770
+ );
771
+ const moved = {
772
+ center: zoomAnchoredAt(pinching.startView, pinching.startMidpoint, next, midpoint),
773
+ zoom: next,
774
+ };
775
+ liveRef.current = moved;
776
+ setLive(moved);
777
+ return;
778
+ }
779
+
780
+ const state = drag.current;
781
+ if (!state || state.pointerId !== event.pointerId) return;
782
+ const dx = point.x - state.startX;
783
+ const dy = point.y - state.startY;
784
+ state.samples.push({ x: dx, y: dy, at: performance.now() });
785
+ if (state.samples.length > 8) state.samples.shift();
786
+ if (!state.panning) {
787
+ if (Math.hypot(dx, dy) < PAN_THRESHOLD) return;
788
+ // NOW it is a drag: take the pointer so it survives leaving the
789
+ // frame, which is exactly when you are trying to pan.
790
+ state.panning = true;
791
+ event.currentTarget.setPointerCapture(event.pointerId);
792
+ }
793
+ // 🔴 LOCAL only. The viewport is committed on release; see the header.
794
+ setPan({ dx, dy });
795
+ }}
796
+ onPointerUp={(event) => dropPointer(event.currentTarget, event.pointerId)}
797
+ onPointerCancel={(event) => {
798
+ // A canceled gesture is not a pan — drop the offset without
799
+ // committing, so the scene springs back rather than half-moving.
800
+ pointers.current.delete(event.pointerId);
801
+ pinch.current = null;
802
+ drag.current = null;
803
+ setPan(null);
804
+ if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
805
+ event.currentTarget.releasePointerCapture(event.pointerId);
806
+ }
807
+ }}
808
+ onDoubleClick={(event) => {
809
+ if (!onViewChange) return;
810
+ stopGlide();
811
+ const point = toViewportUnits(event);
812
+ if (!point) return;
813
+ // Zoom toward the point under the cursor — held exactly still, rather
814
+ // than the old halfway-to-the-center approximation, which drifted the
815
+ // target away by more the further from the middle you aimed. Shift
816
+ // reverses it, which is the convention every map shares.
817
+ const from = readEffective();
818
+ const next = snapZoom(from.zoom + (event.shiftKey ? -1 : 1));
819
+ if (next === from.zoom) return;
820
+ const startView: Viewport = {
821
+ center: from.center,
822
+ zoom: from.zoom,
823
+ width: VIEW_WIDTH,
824
+ height: VIEW_HEIGHT,
825
+ };
826
+ commit({ center: zoomAnchoredAt(startView, point, next), zoom: next });
827
+ }}
828
+ >
829
+ {/*
830
+ * ONE translate for the whole scene. Imagery and overlay are siblings
831
+ * inside it, so they physically cannot separate during a drag — which is
832
+ * the property the owner's report was about.
833
+ */}
834
+ <g transform={pan ? `translate(${pan.dx} ${pan.dy})` : undefined}>
835
+ {placement ? (
836
+ <image
837
+ href={settled?.src}
838
+ x={placement.x}
839
+ y={placement.y}
840
+ width={placement.width}
841
+ height={placement.height}
842
+ preserveAspectRatio="none"
843
+ style={IMAGE_STYLE}
844
+ data-testid="map-image"
845
+ />
846
+ ) : (
847
+ // No imagery yet (or the render failed): the graticule, so the frame
848
+ // reads as a map rather than as a broken picture. Drawn oversized so
849
+ // a drag does not expose an edge before the render lands.
850
+ <>
851
+ <defs>
852
+ <pattern id={graticuleId} width={40} height={40} patternUnits="userSpaceOnUse">
853
+ <path
854
+ d="M40 0 L0 0 0 40"
855
+ fill="none"
856
+ stroke="color-mix(in oklab, var(--border) 60%, transparent)"
857
+ strokeWidth={1}
858
+ />
859
+ </pattern>
860
+ </defs>
861
+ <rect
862
+ x={-VIEW_WIDTH}
863
+ y={-VIEW_HEIGHT}
864
+ width={VIEW_WIDTH * 3}
865
+ height={VIEW_HEIGHT * 3}
866
+ fill="var(--muted)"
867
+ data-testid="map-fallback"
868
+ />
869
+ {/* The grid itself. No `aria-hidden`: the whole SVG is one
870
+ `role="img"` with a label, so nothing inside it is announced
871
+ separately and marking a child hidden only confuses the tree. */}
872
+ <rect
873
+ x={-VIEW_WIDTH}
874
+ y={-VIEW_HEIGHT}
875
+ width={VIEW_WIDTH * 3}
876
+ height={VIEW_HEIGHT * 3}
877
+ fill={`url(#${graticuleId})`}
878
+ />
879
+ </>
880
+ )}
881
+ {children({ project: (point) => project(point, view), view })}
882
+ </g>
883
+ <ScaleKey view={view} />
884
+ </svg>
885
+ </div>
886
+ );
887
+ }
888
+
889
+ /**
890
+ * The distance key — "5 mi · 8 km" under a bar of exactly that length.
891
+ *
892
+ * ── 🔴 OUTSIDE the panning `<g>`, on purpose ────────────────────────────────
893
+ * A drag translates the whole scene, and a scale bar that slid off the corner
894
+ * with it would be the one mark on this map that is not about a place. The zoom
895
+ * does not change during a pan, so the bar is correct throughout without moving.
896
+ *
897
+ * ── 🔴 Every color is an INLINE style, and that is a budget decision ────────
898
+ * `apps/family`'s entry stylesheet had 89 bytes of headroom when the tree wires
899
+ * landed, and `.ft-map*` lived in its global sheet because the dossier's
900
+ * birthplace thumbnail draws it — so a handful of new rules here would land on
901
+ * the first-paint route with no lazier route to push them into. Presentation
902
+ * attributes reading the theme's own tokens cost the stylesheet nothing.
903
+ *
904
+ * It is `aria-hidden`: the whole SVG is one `role="img"` with a label, so a
905
+ * child announced separately only clutters the tree — the rule the graticule
906
+ * comment already states.
907
+ */
908
+ /** About how wide one character of the 12-unit label runs, so the plate holds a label longer than its bar. */
909
+ const LABEL_EM = 6.6;
910
+
911
+ function ScaleKey({ view }: { view: Viewport }) {
912
+ const bar = scaleBar(view);
913
+ // A frame too small (or a zoom too far out) for even the smallest step of the
914
+ // 1/2/5 series draws nothing rather than a bar labeled with a fraction.
915
+ if (bar.widthUnits <= 0) return null;
916
+ const left = 12;
917
+ const bottom = VIEW_HEIGHT - 12;
918
+ return (
919
+ /*
920
+ * 🔴 No `aria-hidden`, and the graticule's comment beside it says why: the
921
+ * whole SVG is ONE `role="img"` with a label, so nothing inside it is
922
+ * announced separately and marking a child hidden only adds a node to the
923
+ * tree that says so. Biome's `noAriaHiddenOnFocusable` flags it as well,
924
+ * which is the same finding from the other direction.
925
+ */
926
+ <g data-testid="map-scale">
927
+ {/* A dark plate under the mark, because the imagery underneath is a
928
+ photograph and a hairline over a bright field is unreadable. */}
929
+ <rect
930
+ x={left - 5}
931
+ y={bottom - 22}
932
+ width={Math.max(bar.widthUnits, bar.label.length * LABEL_EM) + 10}
933
+ height={28}
934
+ rx={4}
935
+ fill="var(--card)"
936
+ opacity={0.78}
937
+ />
938
+ <path
939
+ d={`M${left} ${bottom - 5} v5 h${bar.widthUnits} v-5`}
940
+ fill="none"
941
+ stroke="var(--foreground)"
942
+ strokeWidth={1.5}
943
+ />
944
+ <text
945
+ x={left}
946
+ y={bottom - 8}
947
+ fontSize={12}
948
+ fill="var(--foreground)"
949
+ >
950
+ {bar.label}
951
+ </text>
952
+ </g>
953
+ );
954
+ }