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