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.
- package/dist/react/components/ComboboxField.d.ts +10 -0
- package/dist/react/components/ComboboxField.d.ts.map +1 -1
- package/dist/react/components/ComboboxField.js +4 -4
- package/dist/react/components/ComboboxField.js.map +1 -1
- package/dist/react/components/CommentThread.d.ts +9 -3
- package/dist/react/components/CommentThread.d.ts.map +1 -1
- package/dist/react/components/CommentThread.js +25 -11
- package/dist/react/components/CommentThread.js.map +1 -1
- package/dist/react/components/VoiceNote.d.ts +20 -0
- package/dist/react/components/VoiceNote.d.ts.map +1 -0
- package/dist/react/components/VoiceNote.js +54 -0
- package/dist/react/components/VoiceNote.js.map +1 -0
- package/dist/react/components/VoiceRecorder.d.ts +35 -0
- package/dist/react/components/VoiceRecorder.d.ts.map +1 -0
- package/dist/react/components/VoiceRecorder.js +155 -0
- package/dist/react/components/VoiceRecorder.js.map +1 -0
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +2 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/map-frame/MapFrame.d.ts +219 -0
- package/dist/react/map-frame/MapFrame.d.ts.map +1 -0
- package/dist/react/map-frame/MapFrame.js +726 -0
- package/dist/react/map-frame/MapFrame.js.map +1 -0
- package/dist/react/map-frame/geo.d.ts +210 -0
- package/dist/react/map-frame/geo.d.ts.map +1 -0
- package/dist/react/map-frame/geo.js +345 -0
- package/dist/react/map-frame/geo.js.map +1 -0
- package/dist/react/map-frame/index.d.ts +5 -0
- package/dist/react/map-frame/index.d.ts.map +1 -0
- package/dist/react/map-frame/index.js +3 -0
- package/dist/react/map-frame/index.js.map +1 -0
- package/dist/styles-areas/canvas-hud.css +5 -5
- package/dist/styles-areas/workbench.css +17 -17
- package/package.json +13 -1
- package/scripts/guardrails-baseline.json +1 -1
- package/scripts/styleAreas.ts +1 -1
- package/src/react/components/ComboboxField.spec.tsx +39 -0
- package/src/react/components/ComboboxField.tsx +20 -4
- package/src/react/components/CommentThread.spec.tsx +25 -0
- package/src/react/components/CommentThread.tsx +60 -10
- package/src/react/components/VoiceNote.tsx +92 -0
- package/src/react/components/VoiceRecorder.spec.tsx +116 -0
- package/src/react/components/VoiceRecorder.tsx +224 -0
- package/src/react/index.ts +9 -0
- package/src/react/map-frame/MapFrame.spec.ts +168 -0
- package/src/react/map-frame/MapFrame.tsx +954 -0
- package/src/react/map-frame/geo.spec.ts +393 -0
- package/src/react/map-frame/geo.ts +421 -0
- package/src/react/map-frame/index.ts +34 -0
- package/src/react/media/HoverScrubVideoThumb.spec.tsx +4 -0
- package/src/styles-areas/canvas-hud.css +5 -5
- 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
|