@gramps-connect/story 0.3.0 → 0.5.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 (40) hide show
  1. package/README.md +23 -5
  2. package/dist/editor.js +2065 -177
  3. package/dist/editor.js.map +4 -4
  4. package/dist/embed.js +1 -1
  5. package/dist/map-edit.js +361 -0
  6. package/dist/map-edit.js.map +7 -0
  7. package/dist/player.js +3 -1
  8. package/dist/player.js.map +2 -2
  9. package/dist/types/editor/MapEditor.d.ts +89 -0
  10. package/dist/types/editor/PlaceField.d.ts +24 -0
  11. package/dist/types/editor/PlaceMap.d.ts +12 -0
  12. package/dist/types/editor/PlaceMapDialog.d.ts +18 -0
  13. package/dist/types/editor/StoryEditor.d.ts +8 -1
  14. package/dist/types/editor/index.d.ts +7 -1
  15. package/dist/types/editor/mapEditorStrings.d.ts +50 -0
  16. package/dist/types/editor/model.d.ts +17 -1
  17. package/dist/types/editor/placeSearch.d.ts +22 -0
  18. package/dist/types/editor/placeShapes.d.ts +13 -0
  19. package/dist/types/editor/strings.d.ts +28 -0
  20. package/dist/types/mapEdit/geometry.d.ts +91 -0
  21. package/dist/types/mapEdit/index.d.ts +3 -0
  22. package/dist/types/mapEdit/mapFile.d.ts +77 -0
  23. package/dist/types/mapEdit/model.d.ts +55 -0
  24. package/package.json +18 -2
  25. package/src/editor/MapEditor.tsx +1545 -0
  26. package/src/editor/PlaceField.tsx +170 -0
  27. package/src/editor/PlaceMap.tsx +88 -0
  28. package/src/editor/PlaceMapDialog.tsx +228 -0
  29. package/src/editor/StoryEditor.tsx +50 -14
  30. package/src/editor/index.ts +11 -2
  31. package/src/editor/mapEditorStrings.ts +109 -0
  32. package/src/editor/model.ts +38 -1
  33. package/src/editor/placeSearch.ts +61 -0
  34. package/src/editor/placeShapes.ts +63 -0
  35. package/src/editor/strings.ts +56 -0
  36. package/src/mapEdit/geometry.ts +211 -0
  37. package/src/mapEdit/index.ts +15 -0
  38. package/src/mapEdit/mapFile.ts +199 -0
  39. package/src/mapEdit/model.ts +250 -0
  40. package/src/player/MapBackground.tsx +3 -1
@@ -0,0 +1,1545 @@
1
+ // A full map for drawing and editing shapes (points, lines, polygons,
2
+ // rectangles, with terra-draw) and placing image overlays such as scanned
3
+ // old maps (move, resize, rotate, drag each corner, opacity, order). It
4
+ // edits what it's given and hands it back when asked (MapEditorHandle);
5
+ // loading and saving are the host's: Gramps Connect's MapItemEditorDialog
6
+ // reads and writes a map file on the server, the Story Editor a slide's
7
+ // place.
8
+ //
9
+ // Shapes go through map-edit's model (prepareForEditing/assembleForSaving),
10
+ // so multi-part shapes, GPS track times and properties this editor doesn't
11
+ // know survive an edit; anything terra-draw can't take is shown dashed
12
+ // ("locked") and saved back exactly as it was.
13
+ //
14
+ // Needs Mantine (like the rest of the editor), maplibre-gl, terra-draw and
15
+ // terra-draw-maplibre-gl-adapter.
16
+ import { forwardRef, useEffect, useImperativeHandle, useRef, useState, type ReactNode } from "react";
17
+ import * as maplibregl from "maplibre-gl";
18
+ // maplibre's stylesheet comes with the player, which this entry includes
19
+ // (an import of it here would end up in the published declarations).
20
+ import type { Map as MapLibreMap } from "maplibre-gl";
21
+ import {
22
+ TerraDraw, TerraDrawLineStringMode, TerraDrawPointMode, TerraDrawPolygonMode, TerraDrawRectangleMode,
23
+ TerraDrawSelectMode,
24
+ } from "terra-draw";
25
+ import type { GeoJSONStoreFeatures, HexColor } from "terra-draw";
26
+ import { TerraDrawMapLibreGLAdapter } from "terra-draw-maplibre-gl-adapter";
27
+ import type { Feature, FeatureCollection, Geometry } from "geojson";
28
+ import {
29
+ Alert, Box, Button, ColorInput, Divider, Group, Kbd, List, Loader, NumberInput, SegmentedControl, Slider, Stack, Text,
30
+ TextInput,
31
+ } from "@mantine/core";
32
+ import {
33
+ addToSession, assembleForSaving, lockPart, mercatorBearing, mercatorDistance, prepareForEditing, rotatePoint, scalePoint,
34
+ type EditableGeometry, type EditablePart, type EditSession, type OverlayCorners, type SavedPart,
35
+ } from "../mapEdit";
36
+ import { mapStyleUrl } from "../player/mapStyles";
37
+ import { defaultColors } from "../player/theme";
38
+ import { MAP_EDITOR_ENGLISH, type MapEditorStrings } from "./mapEditorStrings";
39
+
40
+ type DrawMode = "point" | "linestring" | "polygon" | "rectangle" | "select";
41
+
42
+ const TOOLBAR: { mode: DrawMode; label: keyof MapEditorStrings }[] = [
43
+ { mode: "point", label: "point" },
44
+ { mode: "linestring", label: "line" },
45
+ { mode: "polygon", label: "polygon" },
46
+ { mode: "rectangle", label: "rectangle" },
47
+ { mode: "select", label: "select" },
48
+ ];
49
+
50
+ // A handful of preset swatches for the color picker -- ColorInput's own
51
+ // popover still offers the full picker for anything else, this just saves
52
+ // a trip there for the common case.
53
+ const COLOR_SWATCHES = [
54
+ "#2a78d6", "#d64545", "#3aa657", "#e0a325", "#8654c9", "#2aa7a0", "#e0678a", "#555555",
55
+ ];
56
+
57
+ const EMPTY_LABEL_PREVIEW: FeatureCollection = { type: "FeatureCollection", features: [] };
58
+
59
+ /** An image overlay as the host gives and gets it: `image` is the host's
60
+ * own name for the picture (a media handle, a story media key), turned
61
+ * into something displayable by `resolveImage`. */
62
+ export interface MapEditorOverlay {
63
+ image: string;
64
+ /** Top-left, top-right, bottom-right, bottom-left, as [lng, lat]. */
65
+ corners: OverlayCorners;
66
+ name?: string;
67
+ /** 0-1; undefined is fully opaque. */
68
+ opacity?: number;
69
+ /** Stacking rank; higher draws on top. */
70
+ order?: number;
71
+ }
72
+
73
+ /** What's in the editor, for the host to save. */
74
+ export interface MapEditorResult {
75
+ features: Feature[];
76
+ overlays: MapEditorOverlay[];
77
+ }
78
+
79
+ export interface MapEditorStatus {
80
+ /** Anything drawn or placed (shapes or images). */
81
+ hasDrawn: boolean;
82
+ /** Anything to save at all: drawn, placed, or locked. */
83
+ hasContent: boolean;
84
+ }
85
+
86
+ export interface MapEditorHandle {
87
+ /** Everything as it is now: shapes put back together (see map-edit's
88
+ * assembleForSaving) and the image overlays. */
89
+ getResult: () => MapEditorResult;
90
+ /** The box around everything drawn and placed, [west, south, east,
91
+ * north], or null with nothing there yet. */
92
+ drawnBounds: () => [number, number, number, number] | null;
93
+ /** Places an image in the middle of the current view, at its own aspect
94
+ * ratio, and selects it. False if that image is already one of the
95
+ * overlays. Throws if it can't be loaded. */
96
+ addImage: (image: string) => Promise<boolean>;
97
+ /** Adds shapes (an imported map file's, see parseMapFile) after what's
98
+ * there, and frames them. */
99
+ addFeatures: (features: Feature[]) => void;
100
+ }
101
+
102
+ export interface MapEditorProps {
103
+ /** What to edit: null while the host is still loading it (the map shows,
104
+ * and fills in once this arrives). Read once; later changes are
105
+ * ignored. */
106
+ initial: { features: Feature[]; overlays: MapEditorOverlay[] } | null;
107
+ /** Where to look when what's loaded has nothing to frame. */
108
+ initialView?: { center: [number, number]; zoom: number };
109
+ /** A displayable URL for an overlay's image. */
110
+ resolveImage: (image: string) => Promise<string>;
111
+ /** Called with a URL from resolveImage once the editor is done with it
112
+ * (e.g. URL.revokeObjectURL). */
113
+ releaseImage?: (url: string) => void;
114
+ /** Shows an Image button; the host picks one and calls addImage. */
115
+ onAddImage?: () => void;
116
+ onStatus?: (status: MapEditorStatus) => void;
117
+ /** A map that wouldn't load, or a terra-draw failure. */
118
+ onError?: (message: string) => void;
119
+ dark?: boolean;
120
+ /** A new shape's color until another is picked. */
121
+ defaultColor?: string;
122
+ /** For point labels drawn on the map. */
123
+ labelColors?: { text: string; surface: string };
124
+ /** Room to leave above the tool panel for the host's own bars. */
125
+ toolbarTop?: number;
126
+ /** Whether an image overlay has a name to edit (default true). */
127
+ overlayNames?: boolean;
128
+ /** More fields for the selected image overlay, below its own. */
129
+ renderOverlayOptions?: (image: string) => ReactNode;
130
+ strings?: Partial<MapEditorStrings>;
131
+ }
132
+
133
+ /** One placed-and-sized image overlay in this editor -- always 4 explicit
134
+ * world corners. `id` is a local key only (React list key + maplibre
135
+ * source/layer id prefix), not persisted.
136
+ *
137
+ * There's deliberately no separate "box" (north/south/east/west/rotation)
138
+ * representation: move/resize/rotate/corner-drag all transform these same
139
+ * 4 points directly (see mountOverlay) -- resize scales all 4 uniformly
140
+ * from their shared center, rotate spins all 4 uniformly around it, a
141
+ * corner drag moves just the one point -- so a plain rectangle is simply
142
+ * the case where the 4 points happen to describe one, and resize/rotate
143
+ * stay exactly as meaningful (and aspect-preserving) on an already-warped
144
+ * quad as on a rectangle. The free-transform control only ever shows or
145
+ * hides the 4 individual corner-drag handles (see showCornerHandles further
146
+ * down) -- it never touches `corners` itself. */
147
+ interface ImageOverlayDraft extends MapEditorOverlay {
148
+ id: string;
149
+ }
150
+
151
+ /** What's tracked per mounted image overlay -- everything needed to move
152
+ * it (imperatively, outside React) and tear it down again. `box.corners`
153
+ * is the live, authoritative geometry during a drag; `overlays` state
154
+ * (committed on drag end) only needs to be right when read back and on
155
+ * remount.
156
+ *
157
+ * No mousedown listener of its own -- maplibre's layer-filtered events
158
+ * (`map.on(type, layerId, listener)`) work by hit-testing via
159
+ * queryRenderedFeatures, which only supports vector-ish layer types
160
+ * (circle/fill/fill-extrusion/line/symbol); a `type: "raster"` layer (what
161
+ * an image source renders as) never matches, so a per-layer listener here
162
+ * would simply never fire (found live: this is why clicking an image did
163
+ * nothing at all). `startMove` is called instead by the one map-wide
164
+ * mousedown handler in the map-setup effect, which does its own point-in-
165
+ * box hit test against every mount's `box`. */
166
+ interface OverlayMount {
167
+ sourceId: string;
168
+ box: { corners: OverlayCorners };
169
+ deleteMarker: maplibregl.Marker;
170
+ resizeMarker: maplibregl.Marker;
171
+ rotateMarker: maplibregl.Marker;
172
+ /** The 4 free-transform corner handles, maplibre's own `coordinates`
173
+ * order -- shown only while this overlay's id is in `warpHandleIds` (see
174
+ * the selection/visibility effect below); dragging one is the only way
175
+ * to actually turn a rectangle into a non-rectangular quad. */
176
+ cornerMarkers: [maplibregl.Marker, maplibregl.Marker, maplibregl.Marker, maplibregl.Marker];
177
+ startMove: (e: maplibregl.MapMouseEvent) => void;
178
+ /** Applies a new set of corners to `box` and the live map source in one
179
+ * step -- shared by every drag handler (move/resize/rotate/corner-drag),
180
+ * so there's exactly one place that keeps `box` and the rendered image
181
+ * in sync. */
182
+ setCorners: (next: OverlayCorners) => void;
183
+ /** Snaps every handle back onto `box`'s current corners -- called after
184
+ * every geometry change. */
185
+ repositionHandles: () => void;
186
+ /** From resolveImage, given back to releaseImage on unmount, not
187
+ * before: maplibre's `image` source keeps reading from it for as long as
188
+ * the layer exists. */
189
+ imageUrl: string;
190
+ }
191
+
192
+ /** See the style.load handler's own doc comment on why every image
193
+ * overlay's raster layer is inserted directly below this one layer,
194
+ * rather than wherever addLayer's default "on top of everything" would
195
+ * otherwise put it. */
196
+ const OVERLAY_ANCHOR_LAYER = "image-overlay-anchor";
197
+
198
+ /** terra-draw-maplibre-gl-adapter's own fixed layer ids (its default
199
+ * `prefixId` is "td", never overridden here -- see the `TerraDraw`
200
+ * constructor below) -- every one of these is always created by its
201
+ * `register()` (called via `draw.start()`), regardless of which drawing
202
+ * modes are actually used, so this list is safe to `queryRenderedFeatures`
203
+ * against unconditionally once `ready` is true. Used by the map's own
204
+ * mousedown handler to tell whether a click landed on an actual drawn
205
+ * shape before falling through to the image-overlay hit test below it. */
206
+ const TERRA_DRAW_LAYERS = ["td-polygon", "td-polygon-outline", "td-linestring", "td-point", "td-point-marker"];
207
+
208
+ /** Shapes loaded that terra-draw can't edit (see map-edit's lockPart) --
209
+ * drawn dashed underneath, so they're still visible, and saved back
210
+ * exactly as they were. */
211
+ const LOCKED_SOURCE = "locked-shapes";
212
+ const LOCKED_FILL_LAYER = "locked-shapes-fill";
213
+ const LOCKED_LINE_LAYER = "locked-shapes-line";
214
+
215
+ /** terra-draw's own point mode has no text-label styling (see the
216
+ * label-preview effect below), so a labeled point's text is drawn by this
217
+ * separate, independent geojson source/layer stacked on top of
218
+ * terra-draw's own -- purely visual, never read back from directly
219
+ * (getResult reads terra-draw's own snapshot, this is a mirror of it). */
220
+ const LABEL_PREVIEW_SOURCE = "label-preview";
221
+ const LABEL_PREVIEW_LAYER = "label-preview-text";
222
+
223
+ /** The tool panel sits over the map; its own background and the gaps
224
+ * between controls let clicks through to the map below (a shape corner or
225
+ * overlay handle that ends up under the panel's empty space would
226
+ * otherwise be dead), the way maplibre's own control containers do. */
227
+ const TOOLBAR_CLASS = "gc-map-editor-toolbar";
228
+ const TOOLBAR_CSS = `.${TOOLBAR_CLASS} { pointer-events: none; } .${TOOLBAR_CLASS} > * { pointer-events: auto; }`;
229
+
230
+ /** The average of `corners` -- a plain centroid, not an area-weighted one,
231
+ * which is all a resize/rotate pivot or a delete-handle position needs.
232
+ * Corner indices are tracked by identity (index 0 is always "whichever
233
+ * point started as top-left", however far move/resize/rotate/warp has
234
+ * since carried it), so this stays a stable, sensible center throughout. */
235
+ function overlayCenter(corners: OverlayCorners): [number, number] {
236
+ let lng = 0, lat = 0;
237
+ for (const [x, y] of corners) { lng += x; lat += y; }
238
+ return [lng / 4, lat / 4];
239
+ }
240
+
241
+ /** The rotate handle's own resting position -- beyond the midpoint of the
242
+ * corners[0]-corners[1] edge (this overlay's "top" edge, by index, however
243
+ * it's currently oriented), extended further out from `center` so the
244
+ * handle doesn't crowd that edge. Since this is recomputed from the
245
+ * *current* corners every time (not from a persisted rotation angle), it
246
+ * naturally orbits the shape as move/resize/rotate/corner-drag change it,
247
+ * the standard "stalk above a selected object" rotate-handle convention --
248
+ * with no separate "unrotated" reference frame needed. */
249
+ function rotateHandleAnchor(corners: OverlayCorners, center: [number, number]): [number, number] {
250
+ const [topLeft, topRight] = corners;
251
+ const topMid: [number, number] = [(topLeft[0] + topRight[0]) / 2, (topLeft[1] + topRight[1]) / 2];
252
+ return [center[0] + (topMid[0] - center[0]) * 1.5, center[1] + (topMid[1] - center[1]) * 1.5];
253
+ }
254
+
255
+ /** Whether `point` (screen pixels) falls inside the convex quad described
256
+ * by `corners` (in order around the shape) -- a plain same-side-of-every-
257
+ * edge cross-product test, since a rotated overlay's screen footprint
258
+ * isn't an axis-aligned rectangle a simple min/max bounds check could
259
+ * handle anymore. */
260
+ function pointInQuad(point: { x: number; y: number }, corners: { x: number; y: number }[]): boolean {
261
+ let sign = 0;
262
+ for (let i = 0; i < corners.length; i++) {
263
+ const a = corners[i];
264
+ const b = corners[(i + 1) % corners.length];
265
+ const cross = (b.x - a.x) * (point.y - a.y) - (b.y - a.y) * (point.x - a.x);
266
+ if (cross === 0) continue;
267
+ const s = cross > 0 ? 1 : -1;
268
+ if (sign === 0) sign = s;
269
+ else if (s !== sign) return false;
270
+ }
271
+ return true;
272
+ }
273
+
274
+ // How see-through a dragged overlay image gets, so whatever's underneath
275
+ // (other images, map features) is visible for positioning it precisely.
276
+ const OVERLAY_DRAG_OPACITY = 0.5;
277
+
278
+ // zIndex above the floating left toolbar's own 3 (see its `Stack` style
279
+ // further down) -- that toolbar is positioned absolutely over the map
280
+ // itself and grows taller once an overlay is selected (its "Edit Options"
281
+ // section), so a handle whose current corner happens to land underneath
282
+ // it would otherwise be unclickable: the toolbar, not the handle, receives
283
+ // the click. Found live: rotating an overlay a little was enough to swing
284
+ // its resize handle under that panel. Plain maplibre markers have no
285
+ // z-index of their own (auto), so this alone is enough to always win.
286
+ const OVERLAY_HANDLE_STYLE = {
287
+ width: "20px", height: "20px", borderRadius: "50%", background: "white",
288
+ border: "2px solid #333", display: "flex", alignItems: "center", justifyContent: "center",
289
+ fontSize: "12px", fontWeight: "700", color: "#333", cursor: "pointer",
290
+ boxShadow: "0 1px 3px rgba(0,0,0,0.4)", userSelect: "none", zIndex: 4,
291
+ } as const;
292
+
293
+ function loadImageElement(src: string, failed: string): Promise<HTMLImageElement> {
294
+ return new Promise((resolve, reject) => {
295
+ const img = new Image();
296
+ img.onload = () => resolve(img);
297
+ img.onerror = () => reject(new Error(failed));
298
+ img.src = src;
299
+ });
300
+ }
301
+
302
+ function extendBounds(bounds: maplibregl.LngLatBounds, geometry: Geometry): void {
303
+ if (geometry.type === "Point") {
304
+ bounds.extend(geometry.coordinates as [number, number]);
305
+ } else if (geometry.type === "LineString") {
306
+ for (const c of geometry.coordinates) bounds.extend(c as [number, number]);
307
+ } else if (geometry.type === "Polygon") {
308
+ for (const ring of geometry.coordinates) for (const c of ring) bounds.extend(c as [number, number]);
309
+ }
310
+ }
311
+
312
+ export const MapEditor = forwardRef<MapEditorHandle, MapEditorProps>(function MapEditor({
313
+ initial, initialView, resolveImage, releaseImage, onAddImage, onStatus, onError, dark = false, defaultColor, labelColors,
314
+ toolbarTop = 12, overlayNames = true, renderOverlayOptions, strings: stringOverrides,
315
+ }, ref) {
316
+ const strings = { ...MAP_EDITOR_ENGLISH, ...stringOverrides };
317
+ // Callbacks and words the once-per-map listeners below read, kept live.
318
+ const live = useRef({ strings, resolveImage, releaseImage, onError });
319
+ live.current = { strings, resolveImage, releaseImage, onError };
320
+ const fail = (message: string) => live.current.onError?.(message);
321
+ const release = (url: string) => live.current.releaseImage?.(url);
322
+
323
+ // A callback ref surfaced as state, not a plain useRef -- a host may put
324
+ // this inside a Mantine Modal, whose own children aren't necessarily
325
+ // committed to the DOM in the same pass as this component's first render
326
+ // (found live: a plain `useRef` read inside a `[]`-effect saw `null`
327
+ // every time, so the map/draw setup below never ran at all). Assigning
328
+ // the DOM node to state instead means the effect below (keyed on
329
+ // `containerEl`) reliably fires once it's actually mounted.
330
+ const [containerEl, setContainerEl] = useState<HTMLDivElement | null>(null);
331
+ const mapRef = useRef<MapLibreMap | null>(null);
332
+ const drawRef = useRef<TerraDraw | null>(null);
333
+ // Image overlays live outside terra-draw entirely (see ImageOverlayDraft's
334
+ // own doc comment) -- this is their bookkeeping, keyed by
335
+ // ImageOverlayDraft.id, imperative like drawRef itself.
336
+ const overlayMountsRef = useRef<Map<string, OverlayMount>>(new Map());
337
+ const [mode, setMode] = useState<DrawMode>("select");
338
+ // Mirrors `mode` state for the finish listener set up once below (same
339
+ // reasoning as colorRef just under this) -- lets it tell a just-finished
340
+ // region (polygon/rectangle) apart from a point/line, since it's
341
+ // registered once per map rather than fresh on every `mode` change.
342
+ const modeRef = useRef(mode);
343
+ modeRef.current = mode;
344
+ const [ready, setReady] = useState(false);
345
+ // The color a newly-finished shape takes (draw.on("finish") below), and
346
+ // -- when a shape is currently selected -- what changing this recolors
347
+ // in place.
348
+ const [color, setColor] = useState(() => defaultColor ?? defaultColors(dark).mark);
349
+ // Mirrors `color` state for the terra-draw styling functions/listeners
350
+ // set up once (keyed on `containerEl`, see that effect) below -- they
351
+ // close over this ref instead of `color` itself so a later color change
352
+ // doesn't need the whole map/draw instance rebuilt to see it.
353
+ const colorRef = useRef(color);
354
+ colorRef.current = color;
355
+ // A region's (polygon/rectangle) fill opacity, same pattern as `color`:
356
+ // both a newly-finished region's own opacity and, while one is selected,
357
+ // what changing this edits in place. 0.25 matches Gramps Connect's map
358
+ // view default, so an untouched region renders the same there.
359
+ const [opacity, setOpacity] = useState(0.25);
360
+ const opacityRef = useRef(opacity);
361
+ opacityRef.current = opacity;
362
+ // The selected point/region's own `name` (a point's label text, or a
363
+ // region's purely-descriptive name), mirroring `color`'s own pattern --
364
+ // NOT derived from getSnapshot() on every render, since
365
+ // updateFeatureProperties (called on every keystroke below) doesn't
366
+ // itself trigger a re-render, which would leave a derived value stuck
367
+ // showing whatever it read on the last unrelated render. Seeded/cleared
368
+ // by the select/deselect listeners below instead.
369
+ const [labelText, setLabelText] = useState("");
370
+ // Populated fresh every render by the effect below -- lets the map-setup
371
+ // effect's own terra-draw listeners (registered once, see colorRef's own
372
+ // doc comment on this pattern) call the latest refreshLabelPreview
373
+ // without closing over a stale `labelText`.
374
+ const refreshLabelPreviewRef = useRef<(() => void) | null>(null);
375
+ // terra-draw's own select mode only ever has zero or one feature
376
+ // selected at a time (see TOOLBAR's "select" mode) -- this mirrors that
377
+ // via draw.on("select"/"deselect") so the color picker knows whether a
378
+ // change should recolor something instead of just setting the next
379
+ // shape's color.
380
+ const [selectedId, setSelectedId] = useState<string | null>(null);
381
+ // Mirrors `selectedId` for the canvas keydown listener set up once
382
+ // below (Backspace-deletes-the-selection), same reasoning as colorRef.
383
+ const selectedIdRef = useRef(selectedId);
384
+ selectedIdRef.current = selectedId;
385
+ const labelColorsRef = useRef(labelColors ?? defaultColors(dark));
386
+ labelColorsRef.current = labelColors ?? defaultColors(dark);
387
+ // Keeps LABEL_PREVIEW_SOURCE (the editor's own text-rendering layer,
388
+ // added in the style.load handler below since terra-draw's point mode
389
+ // can't render text itself) in sync with every named point -- including,
390
+ // for the currently selected feature only, `labelText` itself rather
391
+ // than its last-saved `name` property, so a keystroke shows up
392
+ // immediately instead of only once the point is deselected (terra-draw's
393
+ // updateFeatureProperties, called on every keystroke by the text field
394
+ // below, doesn't reliably fire draw's own "change" event).
395
+ useEffect(() => {
396
+ const draw = drawRef.current;
397
+ const map = mapRef.current;
398
+ const source = map?.getSource(LABEL_PREVIEW_SOURCE) as maplibregl.GeoJSONSource | undefined;
399
+ const refresh = () => {
400
+ const currentDraw = drawRef.current;
401
+ const currentSource = mapRef.current?.getSource(LABEL_PREVIEW_SOURCE) as maplibregl.GeoJSONSource | undefined;
402
+ if (!currentDraw || !currentSource) return;
403
+ const features: Feature[] = [];
404
+ for (const f of currentDraw.getSnapshot()) {
405
+ if (f.geometry?.type !== "Point") continue;
406
+ const isSelected = String(f.id) === selectedId;
407
+ const name = (isSelected ? labelText : (f.properties?.name as string | undefined))?.trim();
408
+ if (!name) continue;
409
+ features.push({
410
+ type: "Feature", geometry: f.geometry,
411
+ properties: { name, color: f.properties?.color as string | undefined },
412
+ });
413
+ }
414
+ currentSource.setData({ type: "FeatureCollection", features });
415
+ };
416
+ refreshLabelPreviewRef.current = refresh;
417
+ if (draw && source) refresh();
418
+ }, [labelText, selectedId]);
419
+ // Every placed image overlay -- a wholly separate system from terra-draw
420
+ // (which has no image geometry type), see the block of effects/handlers
421
+ // below this component's map-setup effect.
422
+ const [overlays, setOverlays] = useState<ImageOverlayDraft[]>([]);
423
+ // Mirrors `overlays` for startMove's onUp (below), set up once per mount
424
+ // and so unable to see a later opacity edit via closure alone -- same
425
+ // colorRef/selectedIdRef pattern as elsewhere in this file.
426
+ const overlaysRef = useRef(overlays);
427
+ overlaysRef.current = overlays;
428
+ // Which overlay's move/resize/delete handles are currently showing --
429
+ // set on mousedown over that overlay's image (see mountOverlay), cleared
430
+ // by clicking anywhere else on the map (see the map-setup effect's own
431
+ // mousedown handler) or by deleting the selected overlay itself.
432
+ const [selectedOverlayId, setSelectedOverlayId] = useState<string | null>(null);
433
+ // Which overlays currently show their 4 free-transform corner handles
434
+ // (in addition to the always-available delete/resize/rotate) -- purely a
435
+ // display preference, never touched by geometry (see
436
+ // setCornerHandlesVisible's own doc comment), so it lives here rather
437
+ // than on ImageOverlayDraft/`overlays` state.
438
+ const [warpHandleIds, setWarpHandleIds] = useState<Set<string>>(new Set());
439
+ // Whether terra-draw currently holds any shape.
440
+ const [hasFeatures, setHasFeatures] = useState(false);
441
+ // What was set aside when loading (map-edit's model) -- the properties,
442
+ // multi-part groups and track times terra-draw doesn't keep, put back by
443
+ // getResult. Null with nothing loaded.
444
+ const sessionRef = useRef<EditSession | null>(null);
445
+ // How many of the loaded shapes terra-draw couldn't take, shown locked.
446
+ const [lockedCount, setLockedCount] = useState(0);
447
+ const [lockedNoticeDismissed, setLockedNoticeDismissed] = useState(false);
448
+
449
+ const hasDrawn = hasFeatures || overlays.length > 0;
450
+ const hasContent = hasDrawn || lockedCount > 0;
451
+ const onStatusRef = useRef(onStatus);
452
+ onStatusRef.current = onStatus;
453
+ useEffect(() => { onStatusRef.current?.({ hasDrawn, hasContent }); }, [hasDrawn, hasContent]);
454
+
455
+ useEffect(() => {
456
+ if (!containerEl) return;
457
+ const map = new maplibregl.Map({
458
+ container: containerEl,
459
+ style: mapStyleUrl(dark, null),
460
+ center: [0, 20],
461
+ zoom: 1.5,
462
+ attributionControl: { compact: true },
463
+ });
464
+ mapRef.current = map;
465
+ map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
466
+ // maplibre's own default: holding Ctrl and dragging (or a two-finger
467
+ // touch twist) rotates/pitches the *camera*, which collides directly
468
+ // with terra-draw's own rotate gesture for rotating a *selected shape*
469
+ // (see the select mode's `rotateable` flag below) -- found live,
470
+ // holding the rotate key and dragging a vertex was spinning the whole
471
+ // map instead of the shape. This editor has no use for a tilted/
472
+ // rotated basemap anyway, so the camera's own rotate/pitch is disabled
473
+ // outright rather than trying to make the two coexist.
474
+ map.dragRotate.disable();
475
+ map.touchZoomRotate.disableRotation();
476
+ map.keyboard.disableRotation();
477
+
478
+ // Every drawing mode's color comes from the feature's own `color`
479
+ // property, not a fixed style -- colorRef (above) is what the styling
480
+ // functions actually read, kept live via a ref rather than closing
481
+ // over `color` state directly, since these mode instances are built
482
+ // once per map (this effect is keyed on `containerEl`, not `color`)
483
+ // and terra-draw calls a styling function fresh on every render, not
484
+ // just at construction time.
485
+ const readColor = (feature: GeoJSONStoreFeatures): HexColor =>
486
+ (feature.properties.color as HexColor | undefined) ?? (colorRef.current as HexColor);
487
+ // Same pattern as readColor, for a region's own `opacity` property --
488
+ // only polygon/rectangle modes take a `fillOpacity` style at all (point/
489
+ // line have nothing to fill).
490
+ const readOpacity = (feature: GeoJSONStoreFeatures): number =>
491
+ (feature.properties.opacity as number | undefined) ?? opacityRef.current;
492
+
493
+ const draw = new TerraDraw({
494
+ adapter: new TerraDrawMapLibreGLAdapter({ map }),
495
+ modes: [
496
+ new TerraDrawPointMode({ styles: { pointColor: readColor } }),
497
+ new TerraDrawLineStringMode({ styles: { lineStringColor: readColor } }),
498
+ new TerraDrawPolygonMode({ styles: { fillColor: readColor, fillOpacity: readOpacity, outlineColor: readColor } }),
499
+ new TerraDrawRectangleMode({ styles: { fillColor: readColor, fillOpacity: readOpacity, outlineColor: readColor } }),
500
+ // Dragging (both a whole feature and its individual vertices) and
501
+ // deletion (terra-draw's own default keybinding, Delete/Backspace
502
+ // on the current selection) are what this editor needs from
503
+ // Select for every mode; `rotateable` additionally turns on
504
+ // terra-draw's own built-in rotate gesture (hold R while dragging
505
+ // a selected shape's vertex) for anything 2D enough for "rotate"
506
+ // to mean something -- not `point`, a single vertex has nothing to
507
+ // rotate around.
508
+ new TerraDrawSelectMode({
509
+ // While a feature is selected, terra-draw renders it through
510
+ // *this* mode's own styling, not the drawing mode that created it
511
+ // (see TerraDraw's own per-feature style dispatch: only the
512
+ // selected feature goes through TerraDrawSelectMode.styleFeature;
513
+ // every other feature keeps using its own mode's, which is why an
514
+ // unselected shape's color/opacity already reflects its own
515
+ // properties fine). Without these, the selected-state styling
516
+ // falls back to terra-draw's fixed defaults regardless of
517
+ // `feature.properties` -- so dragging the opacity slider (or
518
+ // recoloring) while a shape is actively selected had no visible
519
+ // effect until it was deselected. Found live.
520
+ styles: {
521
+ selectedPointColor: readColor,
522
+ selectedLineStringColor: readColor,
523
+ selectedPolygonColor: readColor,
524
+ selectedPolygonOutlineColor: readColor,
525
+ selectedPolygonFillOpacity: readOpacity,
526
+ },
527
+ // Half terra-draw's own default (40px) -- with labels sitting
528
+ // right next to their point (see LABEL_PREVIEW_LAYER's
529
+ // text-offset below) and vertices sometimes placed close
530
+ // together, the default hit-test radius made clicking one point
531
+ // too likely to grab a neighboring one instead. Found live.
532
+ pointerDistance: 20,
533
+ // Every field restated (terra-draw's own type doesn't allow a
534
+ // partial override), only `rotate` actually changed from its
535
+ // default -- plain "r" instead of the default Ctrl+R, an
536
+ // awkward two-handed chord to hold through an entire drag. Bare
537
+ // "r" is safe here the same way Backspace-delete is (see the
538
+ // canvas keydown listener below): terra-draw's own keydown
539
+ // listener is bound to the map canvas specifically, so it never
540
+ // fires while typing "r" into a text field.
541
+ keyEvents: { deselect: "Escape", delete: "Delete", rotate: ["r"], scale: ["Control", "s"] },
542
+ flags: {
543
+ point: { feature: { draggable: true } },
544
+ linestring: {
545
+ feature: {
546
+ draggable: true, rotateable: true,
547
+ coordinates: { draggable: true, deletable: true, midpoints: true },
548
+ },
549
+ },
550
+ polygon: {
551
+ feature: {
552
+ draggable: true, rotateable: true,
553
+ coordinates: { draggable: true, deletable: true, midpoints: true },
554
+ },
555
+ },
556
+ rectangle: {
557
+ feature: {
558
+ draggable: true, rotateable: true,
559
+ coordinates: { draggable: true, deletable: true, midpoints: true },
560
+ },
561
+ },
562
+ },
563
+ }),
564
+ ],
565
+ });
566
+ drawRef.current = draw;
567
+ draw.on("change", () => {
568
+ // Same guidance-point exclusion as getResult's own filter below --
569
+ // otherwise a shape's selection handles alone (nothing actually
570
+ // drawn) would count as "has features".
571
+ setHasFeatures(
572
+ draw.getSnapshot().some((f) => f.geometry != null && !f.properties?.selectionPoint && !f.properties?.midPoint),
573
+ );
574
+ refreshLabelPreviewRef.current?.();
575
+ });
576
+ // Shared by the "select" listener below and the finish handler just
577
+ // under it: selecting an existing feature and just having finished
578
+ // drawing a new one both need the color/opacity/label widgets to catch
579
+ // up to *that* feature's own properties, not whatever the toolbar was
580
+ // last left showing. `id` null means deselected -- nothing to edit, so
581
+ // the widgets themselves go away entirely (see selectedFeature/
582
+ // selectedIsRegion in the render body below).
583
+ function syncSelection(id: string | null) {
584
+ setSelectedId(id);
585
+ const feature = id ? draw.getSnapshot().find((f) => String(f.id) === id) : undefined;
586
+ setLabelText((feature?.properties?.name as string | undefined) ?? "");
587
+ if (feature) {
588
+ setColor((feature.properties?.color as string | undefined) ?? colorRef.current);
589
+ setOpacity((feature.properties?.opacity as number | undefined) ?? 0.25);
590
+ // Mirrors the overlay mousedown handler's own deselectFeature call
591
+ // -- an image overlay and a drawn shape are mutually exclusive
592
+ // selections, so a shape becoming selected clears any overlay
593
+ // selection the same way.
594
+ setSelectedOverlayId(null);
595
+ }
596
+ }
597
+ // A shape takes the toolbar's current color (and, for a region, opacity)
598
+ // the moment it's finished drawing -- colorRef/opacityRef (not `color`/
599
+ // `opacity` state) since this listener, like the mode instances above,
600
+ // is registered once per map. Opacity only for polygon/rectangle (both
601
+ // land here as "polygon"/"rectangle" -- see modeRef): a point or line
602
+ // has no fill for it to mean anything on.
603
+ draw.on("finish", (id) => {
604
+ const isRegion = modeRef.current === "polygon" || modeRef.current === "rectangle";
605
+ draw.updateFeatureProperties(
606
+ id,
607
+ isRegion ? { color: colorRef.current, opacity: opacityRef.current } : { color: colorRef.current },
608
+ );
609
+ // Every finished shape drops straight into Select mode with itself
610
+ // selected -- lets someone place a boundary, say, and immediately
611
+ // see/adjust its color and opacity without an extra click to the
612
+ // Select tool and back onto the shape it just placed.
613
+ draw.setMode("select");
614
+ setMode("select");
615
+ draw.selectFeature(id);
616
+ syncSelection(String(id));
617
+ });
618
+ draw.on("select", (id) => syncSelection(String(id)));
619
+ draw.on("deselect", () => syncSelection(null));
620
+
621
+ // terra-draw's own delete keybinding only recognizes the literal
622
+ // "Delete" key (terra-draw-maplibre-gl-adapter's default keyEvents) --
623
+ // on a Mac laptop keyboard, the key labelled "delete" actually sends
624
+ // "Backspace" (only Fn+Delete sends "Delete"), so without this a Mac
625
+ // user's most natural keypress would silently do nothing. Bound to
626
+ // the same canvas element terra-draw's own keydown listener uses
627
+ // (getMapEventElement() in that adapter), so -- like terra-draw's own
628
+ // handler -- this only fires while the map canvas has focus, never
629
+ // while typing in a text field.
630
+ const canvas = map.getCanvas();
631
+ const onCanvasKeyDown = (e: KeyboardEvent) => {
632
+ if (e.key !== "Backspace" || !selectedIdRef.current) return;
633
+ try {
634
+ draw.removeFeatures([selectedIdRef.current]);
635
+ } catch {
636
+ // Selection may already be stale (e.g. removed some other way) --
637
+ // nothing more to do.
638
+ }
639
+ setSelectedId(null);
640
+ };
641
+ canvas.addEventListener("keydown", onCanvasKeyDown);
642
+
643
+ // Some WebKit-based environments (found live: a desktop build's native
644
+ // window) never dispatch PointerEvents for mouse input, even though
645
+ // ordinary MouseEvents work fine there -- map pan/zoom and the
646
+ // image-overlay drag below both ride on mousedown/mousemove/mouseup and
647
+ // both work. terra-draw's own Select mode listens *exclusively* for
648
+ // pointerdown/pointermove/pointerup on the map canvas (see
649
+ // TerraDrawBaseAdapter), so without a bridge, selecting a shape to drag
650
+ // it silently does nothing on such a platform while every other
651
+ // gesture in this editor keeps working. Detected once at runtime -- a
652
+ // "mousedown" on the canvas not followed by a real "pointerdown" within
653
+ // a frame means this environment isn't dispatching pointer events for
654
+ // mouse input at all, so from then on every mouse event on the canvas
655
+ // is re-dispatched as the equivalent pointer event. terra-draw's own
656
+ // listeners only read clientX/clientY/button/isPrimary/target, none of
657
+ // which require an actual PointerEvent instance -- a MouseEvent with
658
+ // `isPrimary` patched on satisfies them exactly the same.
659
+ let sawNativePointerdown = false;
660
+ let pointerBridgeActive = false;
661
+ let pendingFirstMouseDown: MouseEvent | null = null;
662
+ const onNativePointerDown = () => { sawNativePointerdown = true; };
663
+ canvas.addEventListener("pointerdown", onNativePointerDown, { once: true });
664
+ const dispatchAsPointerEvent = (type: string, e: MouseEvent) => {
665
+ const synthetic = new MouseEvent(type, {
666
+ bubbles: true, cancelable: true, view: window,
667
+ clientX: e.clientX, clientY: e.clientY, button: e.button, buttons: e.buttons,
668
+ });
669
+ Object.defineProperty(synthetic, "isPrimary", { value: true });
670
+ canvas.dispatchEvent(synthetic);
671
+ };
672
+ const onMouseDownBridge = (e: MouseEvent) => {
673
+ if (pointerBridgeActive) { dispatchAsPointerEvent("pointerdown", e); return; }
674
+ if (sawNativePointerdown) return;
675
+ pendingFirstMouseDown = e;
676
+ requestAnimationFrame(() => {
677
+ if (sawNativePointerdown || !pendingFirstMouseDown) return;
678
+ pointerBridgeActive = true;
679
+ dispatchAsPointerEvent("pointerdown", pendingFirstMouseDown);
680
+ pendingFirstMouseDown = null;
681
+ });
682
+ };
683
+ const onMouseMoveBridge = (e: MouseEvent) => { if (pointerBridgeActive) dispatchAsPointerEvent("pointermove", e); };
684
+ const onMouseUpBridge = (e: MouseEvent) => { if (pointerBridgeActive) dispatchAsPointerEvent("pointerup", e); };
685
+ canvas.addEventListener("mousedown", onMouseDownBridge);
686
+ canvas.addEventListener("mousemove", onMouseMoveBridge);
687
+ canvas.addEventListener("mouseup", onMouseUpBridge);
688
+
689
+ // Selects whichever image overlay (if any) was clicked, deselects
690
+ // otherwise, and kicks off that overlay's move-drag -- all in one
691
+ // map-wide handler rather than a per-overlay one, since raster/image
692
+ // layers can't be hit-tested via maplibre's layer-filtered events (see
693
+ // OverlayMount's own doc comment). Does its own point-in-quad check
694
+ // (a rotated overlay's screen footprint isn't axis-aligned) against
695
+ // every mounted overlay's live geometry, topmost (last-added) first,
696
+ // mirroring how they're actually layered on screen.
697
+ map.on("mousedown", (e) => {
698
+ // Marker handles (delete/resize/rotate/corner) are DOM elements
699
+ // appended into maplibre's own canvas container, not the canvas
700
+ // itself -- they still receive this same "mousedown" event, so
701
+ // without this check a click that landed on one would also match the
702
+ // point-in-quad test below (a handle sits right at or near the box's
703
+ // own edge) and start a move, fighting the marker's own native drag
704
+ // (found live: dragging the resize handle just moved the image
705
+ // instead of resizing it -- the same bug recurred for the 4 warp-
706
+ // mode corner handles until they were added to this same check).
707
+ // Bail out entirely for a click on any handle and let its own
708
+ // listener (Marker's built-in drag, or the delete element's own
709
+ // click handler) run uncontested.
710
+ const target = e.originalEvent.target;
711
+ if (target instanceof Node) {
712
+ for (const mount of overlayMountsRef.current.values()) {
713
+ if (
714
+ mount.deleteMarker.getElement().contains(target)
715
+ || mount.resizeMarker.getElement().contains(target)
716
+ || mount.rotateMarker.getElement().contains(target)
717
+ || mount.cornerMarkers.some((marker) => marker.getElement().contains(target))
718
+ ) return;
719
+ }
720
+ }
721
+ // A drawn shape (point/line/region) always renders above every image
722
+ // overlay in this editor (see OVERLAY_ANCHOR_LAYER's own doc
723
+ // comment), but the point-in-quad test below has no notion of that --
724
+ // it only asks "is this pixel inside some overlay's bounds", so a
725
+ // shape drawn on top of (or overlapping) an image used to have its
726
+ // clicks hijacked into moving that image out from under it instead of
727
+ // ever reaching terra-draw's own selection. Found live: a rectangle
728
+ // sitting over an image overlay was simply unclickable. Checking what
729
+ // maplibre actually rendered at this pixel first -- rather than
730
+ // reimplementing terra-draw's own per-geometry-type hit testing here
731
+ // -- defers to terra-draw whenever a shape is really the topmost
732
+ // thing at this point, same as a sighted user would expect from what
733
+ // they're looking at.
734
+ if (map.queryRenderedFeatures(e.point, { layers: TERRA_DRAW_LAYERS }).length > 0) return;
735
+ const mounts = [...overlayMountsRef.current.entries()].reverse();
736
+ for (const [id, mount] of mounts) {
737
+ const corners = mount.box.corners.map((c) => map.project(c));
738
+ if (pointInQuad(e.point, corners)) {
739
+ // An image overlay and a drawn shape are mutually exclusive
740
+ // selections -- deselect any terra-draw feature first, or its
741
+ // own color/name/opacity controls would keep showing alongside
742
+ // this overlay's, none of them meaningful for the other's kind
743
+ // of item. Found live.
744
+ if (selectedIdRef.current) draw.deselectFeature(selectedIdRef.current);
745
+ setSelectedOverlayId(id);
746
+ mount.startMove(e);
747
+ return;
748
+ }
749
+ }
750
+ setSelectedOverlayId(null);
751
+ });
752
+
753
+ // A failed style/tile fetch is the one thing that can leave this view
754
+ // blank for a reason the user can't guess.
755
+ map.on("error", (e) => {
756
+ const message = (e.error as Error | undefined)?.message ?? "";
757
+ if (/style|sprite|tile|fetch|network|Failed/i.test(message)) fail(message || live.current.strings.basemapFailed);
758
+ });
759
+
760
+ map.on("style.load", () => {
761
+ // draw.start() registers terra-draw's own sources/layers on the map,
762
+ // which maplibre only allows once the style has actually finished
763
+ // loading -- wrapped so a failure here (a version mismatch, a mode
764
+ // misconfiguration, ...) surfaces as a visible error instead of
765
+ // leaving `ready` false forever.
766
+ try {
767
+ // A permanent, invisible layer added before draw.start() -- image
768
+ // overlays (mountOverlay below) always insert themselves directly
769
+ // below this anchor, so they stay under terra-draw's own shape
770
+ // layers (added next, appended above everything as normal) no
771
+ // matter what order shapes/images actually get added in during the
772
+ // session: images first, geometries on top, matching how a photo
773
+ // of an old map should sit relative to pins/routes drawn over it.
774
+ map.addLayer({ id: OVERLAY_ANCHOR_LAYER, type: "background", paint: { "background-opacity": 0 } });
775
+ map.addSource(LOCKED_SOURCE, { type: "geojson", data: { type: "FeatureCollection", features: [] } });
776
+ map.addLayer({
777
+ id: LOCKED_FILL_LAYER, type: "fill", source: LOCKED_SOURCE,
778
+ filter: ["==", ["geometry-type"], "Polygon"],
779
+ paint: { "fill-color": ["coalesce", ["get", "color"], "#888888"], "fill-opacity": 0.15 },
780
+ });
781
+ map.addLayer({
782
+ id: LOCKED_LINE_LAYER, type: "line", source: LOCKED_SOURCE,
783
+ filter: ["any", ["==", ["geometry-type"], "Polygon"], ["==", ["geometry-type"], "LineString"]],
784
+ paint: { "line-color": ["coalesce", ["get", "color"], "#888888"], "line-width": 2, "line-dasharray": [2, 2] },
785
+ });
786
+ draw.start();
787
+ draw.setMode("select");
788
+ // Added after draw.start() so it stacks above terra-draw's own
789
+ // point layer -- see LABEL_PREVIEW_SOURCE's own doc comment on why
790
+ // this mirror exists at all. refreshLabelPreviewRef is populated by
791
+ // the effect above; called once here too, in case any features
792
+ // were already loaded before this handler ran.
793
+ const colors = labelColorsRef.current;
794
+ map.addSource(LABEL_PREVIEW_SOURCE, { type: "geojson", data: EMPTY_LABEL_PREVIEW });
795
+ map.addLayer({
796
+ id: LABEL_PREVIEW_LAYER,
797
+ type: "symbol",
798
+ source: LABEL_PREVIEW_SOURCE,
799
+ layout: {
800
+ "text-field": ["get", "name"],
801
+ "text-size": 12,
802
+ "text-font": ["Noto Sans Regular"],
803
+ // Offset right rather than centered on the point -- centered
804
+ // text sat directly on top of the point, making it hard to see
805
+ // (and, in the real map views, click) the point itself.
806
+ "text-anchor": "left",
807
+ "text-offset": [0.6, 0],
808
+ },
809
+ paint: {
810
+ "text-color": ["coalesce", ["get", "color"], colors.text],
811
+ "text-halo-color": colors.surface,
812
+ "text-halo-width": 1.5,
813
+ },
814
+ });
815
+ refreshLabelPreviewRef.current?.();
816
+ } catch (err) {
817
+ fail(err instanceof Error ? err.message : String(err));
818
+ } finally {
819
+ setReady(true);
820
+ }
821
+ });
822
+
823
+ return () => {
824
+ draw.stop();
825
+ canvas.removeEventListener("keydown", onCanvasKeyDown);
826
+ canvas.removeEventListener("pointerdown", onNativePointerDown);
827
+ canvas.removeEventListener("mousedown", onMouseDownBridge);
828
+ canvas.removeEventListener("mousemove", onMouseMoveBridge);
829
+ canvas.removeEventListener("mouseup", onMouseUpBridge);
830
+ // Markers aren't part of the map's own style/source tree -- map.remove()
831
+ // below doesn't clean these up on its own.
832
+ for (const mount of overlayMountsRef.current.values()) {
833
+ mount.deleteMarker.remove();
834
+ mount.resizeMarker.remove();
835
+ mount.rotateMarker.remove();
836
+ for (const marker of mount.cornerMarkers) marker.remove();
837
+ release(mount.imageUrl);
838
+ }
839
+ overlayMountsRef.current.clear();
840
+ map.remove();
841
+ mapRef.current = null;
842
+ drawRef.current = null;
843
+ };
844
+ // `dark` only matters for the very first style URL -- a scheme flip
845
+ // while this editor happens to be open isn't worth a style reload.
846
+ // eslint-disable-next-line react-hooks/exhaustive-deps
847
+ }, [containerEl]);
848
+
849
+ // Mounts one image overlay onto the live map: a maplibre `image` source +
850
+ // `raster` layer (created once, then moved/resized via setCoordinates
851
+ // rather than recreated) plus its handles and a whole-image move
852
+ // gesture. Called both when a new image is added and when the initial
853
+ // overlays are loaded in (the effect below).
854
+ async function mountOverlay(overlay: ImageOverlayDraft) {
855
+ const map = mapRef.current;
856
+ if (!map) return;
857
+ const sourceId = `img-${overlay.id}`;
858
+ const box: { corners: OverlayCorners } = { corners: overlay.corners };
859
+ const imageUrl = await live.current.resolveImage(overlay.image);
860
+ map.addSource(sourceId, { type: "image", url: imageUrl, coordinates: box.corners });
861
+ // beforeId keeps this under terra-draw's own shape layers -- see
862
+ // OVERLAY_ANCHOR_LAYER's own doc comment.
863
+ map.addLayer({ id: sourceId, type: "raster", source: sourceId }, OVERLAY_ANCHOR_LAYER);
864
+ map.setPaintProperty(sourceId, "raster-opacity", overlay.opacity ?? 1);
865
+
866
+ // The live geometry during a drag -- `overlays` state (below) is only
867
+ // synced at gesture end, both for perf (no re-render per drag frame)
868
+ // and because it's only read back at the end, not mid-drag.
869
+ const setCorners = (next: OverlayCorners) => {
870
+ box.corners = next;
871
+ (map.getSource(sourceId) as maplibregl.ImageSource | undefined)?.setCoordinates(box.corners);
872
+ };
873
+ const commit = () => {
874
+ setOverlays((prev) => prev.map((o) => (o.id === overlay.id ? { ...o, corners: box.corners } : o)));
875
+ };
876
+
877
+ // Not added to the map yet -- all handles are selection-gated (see the
878
+ // sync effect below), same as terra-draw's own vertex handles only
879
+ // showing up once a shape is actually selected, rather than cluttering
880
+ // every image on screen with several more controls all the time.
881
+ const words = live.current.strings;
882
+ const deleteEl = document.createElement("div");
883
+ deleteEl.textContent = "×";
884
+ deleteEl.title = words.removeImage;
885
+ Object.assign(deleteEl.style, OVERLAY_HANDLE_STYLE);
886
+ const deleteMarker = new maplibregl.Marker({ element: deleteEl, anchor: "center" });
887
+ deleteEl.addEventListener("click", (e) => {
888
+ e.stopPropagation();
889
+ unmountOverlay(overlay.id);
890
+ setOverlays((prev) => prev.filter((o) => o.id !== overlay.id));
891
+ setSelectedOverlayId((current) => (current === overlay.id ? null : current));
892
+ });
893
+
894
+ const resizeEl = document.createElement("div");
895
+ resizeEl.textContent = "↘";
896
+ resizeEl.title = words.resizeImage;
897
+ Object.assign(resizeEl.style, OVERLAY_HANDLE_STYLE);
898
+ const resizeMarker = new maplibregl.Marker({ element: resizeEl, anchor: "center", draggable: true });
899
+ // Captured at the start of each drag: the corners and the handle's own
900
+ // distance from their shared center right then, so every subsequent
901
+ // "drag" frame computes one clean scale factor relative to the drag's
902
+ // own start (distance-now / distance-then) rather than compounding a
903
+ // per-frame ratio, which would drift.
904
+ let resizeStartCorners: OverlayCorners = box.corners;
905
+ let resizeStartDist = 1;
906
+ resizeMarker.on("dragstart", () => {
907
+ resizeStartCorners = box.corners;
908
+ const center = overlayCenter(resizeStartCorners);
909
+ const p = resizeMarker.getLngLat();
910
+ resizeStartDist = Math.max(1e-9, mercatorDistance(center, [p.lng, p.lat]));
911
+ });
912
+ resizeMarker.on("drag", () => {
913
+ // Scales every corner's distance from their shared center by the
914
+ // same factor (in Mercator-projected space -- see scalePoint's own
915
+ // doc comment), so this works identically whether the current
916
+ // corners describe a plain rectangle or an already-warped quad: the
917
+ // exact shape is preserved either way, just bigger or smaller. No
918
+ // separate "aspect ratio" to track at all -- a uniform scale from
919
+ // center can't distort it.
920
+ const center = overlayCenter(resizeStartCorners);
921
+ const p = resizeMarker.getLngLat();
922
+ const scale = Math.max(0.02, mercatorDistance(center, [p.lng, p.lat]) / resizeStartDist);
923
+ setCorners(resizeStartCorners.map((c) => scalePoint(c, center, scale)) as OverlayCorners);
924
+ repositionHandles();
925
+ });
926
+ resizeMarker.on("dragend", commit);
927
+
928
+ const rotateEl = document.createElement("div");
929
+ rotateEl.textContent = "↻";
930
+ rotateEl.title = words.rotateImage;
931
+ Object.assign(rotateEl.style, OVERLAY_HANDLE_STYLE);
932
+ const rotateMarker = new maplibregl.Marker({ element: rotateEl, anchor: "center", draggable: true });
933
+ // Same start-of-drag capture as resize, but for an angle instead of a
934
+ // distance: every "drag" frame rotates the *start* corners by
935
+ // (current handle bearing - start handle bearing), an incremental
936
+ // delta from center rather than an absolute angle -- there's no
937
+ // persisted "rotation" field to be absolute relative to anymore (see
938
+ // rotateHandleAnchor's own doc comment on why that's fine).
939
+ let rotateStartCorners: OverlayCorners = box.corners;
940
+ let rotateStartAngle = 0;
941
+ rotateMarker.on("dragstart", () => {
942
+ rotateStartCorners = box.corners;
943
+ const center = overlayCenter(rotateStartCorners);
944
+ const p = rotateMarker.getLngLat();
945
+ rotateStartAngle = mercatorBearing(center, [p.lng, p.lat]);
946
+ });
947
+ rotateMarker.on("drag", () => {
948
+ const center = overlayCenter(rotateStartCorners);
949
+ const p = rotateMarker.getLngLat();
950
+ const delta = mercatorBearing(center, [p.lng, p.lat]) - rotateStartAngle;
951
+ setCorners(rotateStartCorners.map((c) => rotatePoint(c, center, delta)) as OverlayCorners);
952
+ repositionHandles();
953
+ });
954
+ rotateMarker.on("dragend", commit);
955
+
956
+ // Free-transform's 4 independent corner handles -- one per index of
957
+ // maplibre's own `coordinates` order (top-left, top-right,
958
+ // bottom-right, bottom-left). Each drag writes only its own corner,
959
+ // leaving the other 3 exactly where they were -- the actual "rubber
960
+ // sheet" warp, as opposed to resize/rotate's whole-shape transforms.
961
+ const cornerMarkers = [0, 1, 2, 3].map((i) => {
962
+ const el = document.createElement("div");
963
+ el.title = words.warpCorner;
964
+ Object.assign(el.style, OVERLAY_HANDLE_STYLE);
965
+ const marker = new maplibregl.Marker({ element: el, anchor: "center", draggable: true });
966
+ marker.on("drag", () => {
967
+ const corners = [...box.corners] as OverlayCorners;
968
+ const p = marker.getLngLat();
969
+ corners[i] = [p.lng, p.lat];
970
+ setCorners(corners);
971
+ repositionHandles();
972
+ });
973
+ marker.on("dragend", commit);
974
+ return marker;
975
+ }) as [maplibregl.Marker, maplibregl.Marker, maplibregl.Marker, maplibregl.Marker];
976
+
977
+ // Snaps every handle back onto the box's current corners -- called
978
+ // after every geometry change (move, resize, rotate, corner drag) so
979
+ // they stay glued to the image instead of drifting back to a stale
980
+ // position. Delete/resize/rotate always track the actual corners (no
981
+ // separate "rect" representation to fall back to); the 4 corner
982
+ // handles are additionally repositioned here too, since a resize or
983
+ // rotate gesture moves them right along with everything else.
984
+ const repositionHandles = () => {
985
+ const [topLeft, topRight, bottomRight, bottomLeft] = box.corners;
986
+ const center = overlayCenter(box.corners);
987
+ deleteMarker.setLngLat(center);
988
+ resizeMarker.setLngLat(bottomRight);
989
+ rotateMarker.setLngLat(rotateHandleAnchor(box.corners, center));
990
+ cornerMarkers[0].setLngLat(topLeft);
991
+ cornerMarkers[1].setLngLat(topRight);
992
+ cornerMarkers[2].setLngLat(bottomRight);
993
+ cornerMarkers[3].setLngLat(bottomLeft);
994
+ };
995
+
996
+ // Move -- dragging anywhere on the image itself (not a small handle;
997
+ // the image can be much bigger than one). Called by the map-wide
998
+ // mousedown handler in the map-setup effect once its own point-in-box
999
+ // test picks this overlay out (see OverlayMount's own doc comment on
1000
+ // why that test exists instead of a listener here). Tracked via plain
1001
+ // document listeners rather than map-scoped ones so a fast drag that
1002
+ // leaves the map div doesn't get stuck without a mouseup.
1003
+ const startMove = (e: maplibregl.MapMouseEvent) => {
1004
+ e.preventDefault();
1005
+ map.dragPan.disable();
1006
+ // Nearly-transparent while being dragged so whatever's underneath
1007
+ // (other images, the base map) stays visible for positioning --
1008
+ // restored on mouseup.
1009
+ map.setPaintProperty(sourceId, "raster-opacity", OVERLAY_DRAG_OPACITY);
1010
+ // In screen pixels, not raw lng/lat degrees -- a fixed degree offset
1011
+ // covers a different on-screen distance depending on latitude (Web
1012
+ // Mercator isn't uniformly scaled north-south), so translating this
1013
+ // way visibly grew/shrank the image as it crossed latitudes mid-drag,
1014
+ // even though its own lat/lng span never actually changed. Found
1015
+ // live. Projecting each corner once up front, shifting all of them by
1016
+ // the same pixel delta the cursor has moved, and unprojecting back
1017
+ // keeps the dragged image's on-screen size constant -- the same
1018
+ // "work in projected space, not raw degrees" fix rotatePoint/
1019
+ // scalePoint already apply to rotate/resize (see their own doc
1020
+ // comments), just via maplibre's own project/unproject here, since
1021
+ // screen pixels (not zoom-independent normalized Mercator units) are
1022
+ // exactly what a mouse drag is measured in.
1023
+ const startPoint = e.point;
1024
+ const startScreenCorners = box.corners.map((corner) => map.project(corner));
1025
+ const container = map.getContainer();
1026
+ const onMove = (moveEvent: MouseEvent) => {
1027
+ const rect = container.getBoundingClientRect();
1028
+ const dx = moveEvent.clientX - rect.left - startPoint.x;
1029
+ const dy = moveEvent.clientY - rect.top - startPoint.y;
1030
+ const newCorners = startScreenCorners.map((screenCorner) => {
1031
+ const lngLat = map.unproject([screenCorner.x + dx, screenCorner.y + dy]);
1032
+ return [lngLat.lng, lngLat.lat] as [number, number];
1033
+ });
1034
+ setCorners(newCorners as OverlayCorners);
1035
+ repositionHandles();
1036
+ };
1037
+ const onUp = () => {
1038
+ document.removeEventListener("mousemove", onMove);
1039
+ document.removeEventListener("mouseup", onUp);
1040
+ map.dragPan.enable();
1041
+ // Restores this overlay's own opacity (not necessarily fully
1042
+ // opaque) -- overlaysRef, not the `overlay` param captured at
1043
+ // mount time, so an opacity edit made since this overlay was
1044
+ // mounted is still honored.
1045
+ const current = overlaysRef.current.find((o) => o.id === overlay.id);
1046
+ map.setPaintProperty(sourceId, "raster-opacity", current?.opacity ?? 1);
1047
+ commit();
1048
+ };
1049
+ document.addEventListener("mousemove", onMove);
1050
+ document.addEventListener("mouseup", onUp);
1051
+ };
1052
+
1053
+ repositionHandles();
1054
+ overlayMountsRef.current.set(overlay.id, {
1055
+ sourceId, box, deleteMarker, resizeMarker, rotateMarker, cornerMarkers, startMove, setCorners, repositionHandles, imageUrl,
1056
+ });
1057
+ }
1058
+
1059
+ function unmountOverlay(id: string) {
1060
+ const map = mapRef.current;
1061
+ const mount = overlayMountsRef.current.get(id);
1062
+ if (!map || !mount) return;
1063
+ mount.deleteMarker.remove();
1064
+ mount.resizeMarker.remove();
1065
+ mount.rotateMarker.remove();
1066
+ for (const marker of mount.cornerMarkers) marker.remove();
1067
+ if (map.getLayer(mount.sourceId)) map.removeLayer(mount.sourceId);
1068
+ if (map.getSource(mount.sourceId)) map.removeSource(mount.sourceId);
1069
+ release(mount.imageUrl);
1070
+ overlayMountsRef.current.delete(id);
1071
+ }
1072
+
1073
+ /** Shows or hides one overlay's 4 free-transform corner handles -- the
1074
+ * "Scale" / "Transform" SegmentedControl's onChange. Purely a display
1075
+ * preference (see warpHandleIds' own doc comment): move/resize/rotate/
1076
+ * delete always work the same way regardless of this, uniformly
1077
+ * transforming whatever the current corners are (see mountOverlay), so
1078
+ * picking either option never touches geometry at all. */
1079
+ function setCornerHandlesVisible(id: string, visible: boolean) {
1080
+ setWarpHandleIds((prev) => {
1081
+ const next = new Set(prev);
1082
+ if (visible) next.add(id); else next.delete(id);
1083
+ return next;
1084
+ });
1085
+ }
1086
+
1087
+ // Shows the selected overlay's delete/resize/rotate handles (always) and
1088
+ // its 4 free-transform corner handles (only while its id is in
1089
+ // warpHandleIds), hides every other overlay's -- mountOverlay creates
1090
+ // every marker detached (not added to the map), so a freshly-added or
1091
+ // freshly-loaded overlay correctly starts with nothing showing until
1092
+ // it's actually clicked.
1093
+ const showCornerHandles = selectedOverlayId !== null && warpHandleIds.has(selectedOverlayId);
1094
+ useEffect(() => {
1095
+ const map = mapRef.current;
1096
+ if (!map) return;
1097
+ for (const [id, mount] of overlayMountsRef.current) {
1098
+ if (id !== selectedOverlayId) {
1099
+ mount.deleteMarker.remove();
1100
+ mount.resizeMarker.remove();
1101
+ mount.rotateMarker.remove();
1102
+ for (const marker of mount.cornerMarkers) marker.remove();
1103
+ continue;
1104
+ }
1105
+ mount.deleteMarker.addTo(map);
1106
+ mount.resizeMarker.addTo(map);
1107
+ mount.rotateMarker.addTo(map);
1108
+ for (const marker of mount.cornerMarkers) (showCornerHandles ? marker.addTo(map) : marker.remove());
1109
+ }
1110
+ }, [selectedOverlayId, showCornerHandles]);
1111
+
1112
+ const selectedOverlay = overlays.find((o) => o.id === selectedOverlayId) ?? null;
1113
+
1114
+ /** Patches the selected overlay's name/opacity/order in `overlays` state
1115
+ * -- and, for opacity, also pushes the change straight onto the live
1116
+ * mounted layer for immediate visual feedback, the same way color
1117
+ * changes update terra-draw's own selected feature in place. No-op with
1118
+ * nothing selected. */
1119
+ function updateSelectedOverlay(patch: Partial<Pick<ImageOverlayDraft, "name" | "opacity" | "order">>) {
1120
+ if (!selectedOverlayId) return;
1121
+ setOverlays((prev) => prev.map((o) => (o.id === selectedOverlayId ? { ...o, ...patch } : o)));
1122
+ if (patch.opacity !== undefined) {
1123
+ const mount = overlayMountsRef.current.get(selectedOverlayId);
1124
+ if (mount) mapRef.current?.setPaintProperty(mount.sourceId, "raster-opacity", patch.opacity);
1125
+ }
1126
+ }
1127
+
1128
+ // Loads what's to be edited once the map/draw instance is ready and the
1129
+ // host has it, then frames it -- shapes and image overlays together (an
1130
+ // overlay-only item has no shape to frame), or the host's initialView
1131
+ // when there's nothing to frame.
1132
+ const loaded = useRef(false);
1133
+ useEffect(() => {
1134
+ if (!ready || !initial || loaded.current) return;
1135
+ const draw = drawRef.current;
1136
+ const map = mapRef.current;
1137
+ if (!draw || !map) return;
1138
+ loaded.current = true;
1139
+ const bounds = new maplibregl.LngLatBounds();
1140
+ if (initial.features.length > 0) {
1141
+ const session = prepareForEditing(initial.features);
1142
+ sessionRef.current = session;
1143
+ for (const geometry of showShapes(session, session)) extendBounds(bounds, geometry);
1144
+ }
1145
+ const drafts = initial.overlays.map((overlay): ImageOverlayDraft => ({ ...overlay, id: crypto.randomUUID() }));
1146
+ setOverlays(drafts);
1147
+ for (const draft of drafts) {
1148
+ // One image that won't load shouldn't keep the rest from editing.
1149
+ mountOverlay(draft).catch(() => {});
1150
+ for (const corner of draft.corners) bounds.extend(corner);
1151
+ }
1152
+ if (!bounds.isEmpty()) map.fitBounds(bounds, { padding: 60, maxZoom: 17, duration: 0 });
1153
+ else if (initialView) map.jumpTo(initialView);
1154
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1155
+ }, [ready, initial]);
1156
+
1157
+ /** Puts a session's parts on the canvas -- `added`, the ones new to it
1158
+ * (all of them on load, an import's after) -- locking any terra-draw
1159
+ * refuses (a polygon that crosses itself, say) so the save keeps it.
1160
+ * Returns every new part's geometry, for framing. */
1161
+ function showShapes(session: EditSession, added: EditSession): Geometry[] {
1162
+ const draw = drawRef.current;
1163
+ const map = mapRef.current;
1164
+ if (!draw || !map) return [];
1165
+ // Split into single, 2D parts with everything terra-draw won't keep
1166
+ // set aside (map-edit's model), so a multi-part shape, a GPS track or
1167
+ // a property this editor doesn't know survives the save.
1168
+ const parts: EditablePart[] = [...added.parts];
1169
+ const results = draw.addFeatures(parts.map((part) => ({
1170
+ type: "Feature" as const,
1171
+ geometry: part.geometry,
1172
+ properties: {
1173
+ mode: part.mode, sourceKey: part.sourceKey, color: part.color, name: part.name,
1174
+ ...(part.mode === "polygon" ? { opacity: part.opacity } : {}),
1175
+ },
1176
+ })) as GeoJSONStoreFeatures[]);
1177
+ results.forEach((result, i) => {
1178
+ if (!result.valid) lockPart(session, parts[i].sourceKey);
1179
+ });
1180
+ const locked = [...session.locked.values()];
1181
+ setLockedCount(locked.length);
1182
+ (map.getSource(LOCKED_SOURCE) as maplibregl.GeoJSONSource | undefined)?.setData({
1183
+ type: "FeatureCollection",
1184
+ features: locked.map((part) => ({ type: "Feature", geometry: part.geometry, properties: { color: part.color } })),
1185
+ });
1186
+ return [...parts, ...added.locked.values()].map((part) => part.geometry);
1187
+ }
1188
+
1189
+ function handleModeChange(next: DrawMode) {
1190
+ // Guarded, not just disabled on the button below: terra-draw's own
1191
+ // setMode() throws ("Terra Draw is not enabled") until draw.start() has
1192
+ // actually run (the style.load handler above), and a click landing in
1193
+ // that window before `ready` flips would otherwise be an uncaught
1194
+ // exception -- found live.
1195
+ if (!ready) return;
1196
+ setMode(next);
1197
+ // Switching drawing tools while an image overlay is selected leaves it
1198
+ // selected otherwise -- the overlay mousedown handler and syncSelection
1199
+ // (map-setup effect above) only clear it from the *other* direction
1200
+ // (a shape getting selected), not a mere tool change with nothing
1201
+ // clicked yet.
1202
+ setSelectedOverlayId(null);
1203
+ drawRef.current?.setMode(next);
1204
+ }
1205
+
1206
+ useImperativeHandle(ref, () => ({
1207
+ getResult() {
1208
+ // Geometry plus the editor's own color/name (and, for a region,
1209
+ // opacity) and the part's sourceKey -- terra-draw's other bookkeeping
1210
+ // properties (mode, id, ...) mean nothing outside the editor.
1211
+ // Everything set aside when loading is put back by assembleForSaving.
1212
+ //
1213
+ // Also excluded: terra-draw's own selection-mode guidance points --
1214
+ // the small vertex/midpoint handle dots it draws on a shape's corners
1215
+ // and edges while selected. Select mode stores those as ordinary
1216
+ // Point features in the same snapshot (tagged `selectionPoint`/
1217
+ // `midPoint`), so saving mid-selection without this filter wrote them
1218
+ // into the file as if they were real map-item points. Found live.
1219
+ const savedParts: SavedPart[] = (drawRef.current?.getSnapshot() ?? [])
1220
+ .filter((f) => f.geometry != null && !f.properties?.selectionPoint && !f.properties?.midPoint)
1221
+ .map((f) => {
1222
+ // Trimmed, and omitted entirely rather than written as "" -- a
1223
+ // label point placed but never typed into should round-trip as a
1224
+ // plain, nameless point (the presence of `name` is itself what
1225
+ // marks a point as a label), not an invisible label with empty
1226
+ // text.
1227
+ const name = (f.properties?.name as string | undefined)?.trim();
1228
+ // Opacity only for a region (Polygon) -- meaningless on a point/
1229
+ // line, which have nothing to fill (see readOpacity/opacityRef).
1230
+ const regionOpacity = f.geometry.type === "Polygon"
1231
+ ? (f.properties?.opacity as number | undefined) ?? opacityRef.current
1232
+ : undefined;
1233
+ const sourceKey = f.properties?.sourceKey;
1234
+ return {
1235
+ ...(typeof sourceKey === "string" ? { sourceKey } : {}),
1236
+ geometry: f.geometry as EditableGeometry,
1237
+ color: (f.properties?.color as string | undefined) ?? colorRef.current,
1238
+ ...(name ? { name } : {}),
1239
+ ...(regionOpacity !== undefined ? { opacity: regionOpacity } : {}),
1240
+ };
1241
+ });
1242
+ const session = sessionRef.current ?? { parts: [], sources: new Map(), locked: new Map() };
1243
+ return {
1244
+ features: assembleForSaving(session, savedParts),
1245
+ overlays: overlaysRef.current.map(({ image, corners, name, opacity, order }) => ({ image, corners, name, opacity, order })),
1246
+ };
1247
+ },
1248
+ drawnBounds() {
1249
+ const bounds = new maplibregl.LngLatBounds();
1250
+ for (const f of drawRef.current?.getSnapshot() ?? []) {
1251
+ if (f.geometry == null || f.properties?.selectionPoint || f.properties?.midPoint) continue;
1252
+ extendBounds(bounds, f.geometry as Geometry);
1253
+ }
1254
+ for (const overlay of overlaysRef.current) for (const corner of overlay.corners) bounds.extend(corner);
1255
+ return bounds.isEmpty() ? null : (bounds.toArray().flat() as [number, number, number, number]);
1256
+ },
1257
+ async addImage(image: string) {
1258
+ // The same image placed twice has no use: in Gramps Connect an
1259
+ // overlay's dates are its place's, so a second copy couldn't even be
1260
+ // given a different era -- it would just always show (or not) in
1261
+ // lockstep with the first.
1262
+ if (overlaysRef.current.some((o) => o.image === image)) return false;
1263
+ const map = mapRef.current;
1264
+ if (!map) return false;
1265
+ // Only to measure the image's own pixel dimensions: mountOverlay
1266
+ // asks for it again for its persistent `image` source.
1267
+ const url = await live.current.resolveImage(image);
1268
+ const element = await loadImageElement(url, live.current.strings.imageFailed).finally(() => release(url));
1269
+ const aspect = element.naturalWidth / element.naturalHeight;
1270
+ const centerScreen = map.project(map.getCenter());
1271
+ const halfWidthPx = 130;
1272
+ const halfHeightPx = halfWidthPx / aspect;
1273
+ const topLeft = map.unproject([centerScreen.x - halfWidthPx, centerScreen.y - halfHeightPx]);
1274
+ const bottomRight = map.unproject([centerScreen.x + halfWidthPx, centerScreen.y + halfHeightPx]);
1275
+ const draft: ImageOverlayDraft = {
1276
+ id: crypto.randomUUID(),
1277
+ image,
1278
+ corners: [
1279
+ [topLeft.lng, topLeft.lat], [bottomRight.lng, topLeft.lat],
1280
+ [bottomRight.lng, bottomRight.lat], [topLeft.lng, bottomRight.lat],
1281
+ ],
1282
+ // Leaves room to insert between existing overlays later without
1283
+ // renumbering.
1284
+ order: overlaysRef.current.length * 10,
1285
+ };
1286
+ setOverlays((prev) => [...prev, draft]);
1287
+ // Awaited so the overlay's mount exists before selecting it --
1288
+ // selecting one before it's mounted would have nothing for the
1289
+ // handle-visibility effect above to find. Auto-selected so its
1290
+ // resize/move handles are immediately visible: the initial placement
1291
+ // is only ever an approximation.
1292
+ await mountOverlay(draft);
1293
+ setSelectedOverlayId(draft.id);
1294
+ return true;
1295
+ },
1296
+ addFeatures(features: Feature[]) {
1297
+ const map = mapRef.current;
1298
+ if (!map || features.length === 0) return;
1299
+ const session = sessionRef.current ?? { parts: [], sources: new Map(), locked: new Map() };
1300
+ sessionRef.current = session;
1301
+ const bounds = new maplibregl.LngLatBounds();
1302
+ for (const geometry of showShapes(session, addToSession(session, features))) extendBounds(bounds, geometry);
1303
+ if (!bounds.isEmpty()) map.fitBounds(bounds, { padding: 60, maxZoom: 17 });
1304
+ },
1305
+ // mountOverlay and friends read only refs: a fresh handle each render
1306
+ // is fine.
1307
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1308
+ }));
1309
+
1310
+ // Cheap to recompute on every render (getSnapshot() is just an array
1311
+ // read) rather than mirrored into its own state -- selectedId already
1312
+ // triggers a re-render via draw.on("select"/"deselect") above, so this
1313
+ // stays in sync for free.
1314
+ const selectedFeature = selectedId ? drawRef.current?.getSnapshot().find((f) => String(f.id) === selectedId) : undefined;
1315
+ const selectedIsPoint = selectedFeature?.geometry?.type === "Point";
1316
+ // Covers both polygon and rectangle tools -- a rectangle is stored as a
1317
+ // Polygon feature under the hood.
1318
+ const selectedIsRegion = selectedFeature?.geometry?.type === "Polygon";
1319
+
1320
+ function renderModeButton(entry: { mode: DrawMode; label: keyof MapEditorStrings }) {
1321
+ return (
1322
+ <Button
1323
+ key={entry.mode}
1324
+ size="xs"
1325
+ variant={mode === entry.mode ? "filled" : "default"}
1326
+ disabled={!ready}
1327
+ onClick={() => handleModeChange(entry.mode)}
1328
+ >
1329
+ {strings[entry.label] as string}
1330
+ </Button>
1331
+ );
1332
+ }
1333
+
1334
+ return (
1335
+ <Box style={{ position: "absolute", inset: 0, overflow: "hidden" }}>
1336
+ <style>{TOOLBAR_CSS}</style>
1337
+ <div ref={setContainerEl} style={{ position: "absolute", inset: 0 }} />
1338
+
1339
+ {/* Left toolbar: which terra-draw mode is active, then the selected
1340
+ item's own options. */}
1341
+ <Stack
1342
+ gap={4}
1343
+ p={6}
1344
+ className={TOOLBAR_CLASS}
1345
+ style={{
1346
+ position: "absolute", top: toolbarTop, left: 12, zIndex: 3, borderRadius: 8,
1347
+ background: dark ? "rgba(20,20,20,0.75)" : "rgba(255,255,255,0.85)",
1348
+ }}
1349
+ >
1350
+ {/* Point/Line/Polygon/Rectangle -- the terra-draw creation tools,
1351
+ TOOLBAR's own order minus its trailing "select" entry, which
1352
+ renders last below. */}
1353
+ {TOOLBAR.filter((entry) => entry.mode !== "select").map(renderModeButton)}
1354
+ {/* Not a terra-draw mode (there's no drawing gesture for it, just
1355
+ the host's picker), so it never shows as "active" the way the
1356
+ others do -- but it's one more kind of thing to add, same as
1357
+ Point/Line/Rectangle, and more than one image is supported. */}
1358
+ {onAddImage && (
1359
+ <Button size="xs" variant="default" disabled={!ready} onClick={onAddImage}>
1360
+ {strings.image}
1361
+ </Button>
1362
+ )}
1363
+ {TOOLBAR.filter((entry) => entry.mode === "select").map(renderModeButton)}
1364
+
1365
+ <Divider my={2} />
1366
+ <Text size="xs" fw={600} c="dimmed">{strings.editOptions}</Text>
1367
+
1368
+ {/* Only a selected drawn shape (point/line/region) offers this --
1369
+ hidden, not just disabled, the rest of the time: a picker with
1370
+ nothing to recolor is more confusing left sitting there than
1371
+ gone. */}
1372
+ {selectedFeature && (
1373
+ <ColorInput
1374
+ size="xs"
1375
+ value={color}
1376
+ onChange={(next) => {
1377
+ setColor(next);
1378
+ const draw = drawRef.current;
1379
+ if (draw && selectedId) draw.updateFeatureProperties(selectedId, { color: next });
1380
+ }}
1381
+ swatches={COLOR_SWATCHES}
1382
+ disabled={!ready}
1383
+ popoverProps={{ withinPortal: true, zIndex: 1000 }}
1384
+ />
1385
+ )}
1386
+ {/* A point's `name` places a text label on the map -- a label is
1387
+ always its own point. A region's `name` is purely descriptive,
1388
+ the same as an image overlay's own. */}
1389
+ {(selectedIsPoint || selectedIsRegion) && (
1390
+ <TextInput
1391
+ size="xs"
1392
+ placeholder={selectedIsPoint ? strings.labelText : strings.regionName}
1393
+ value={labelText}
1394
+ onChange={(e) => {
1395
+ setLabelText(e.currentTarget.value);
1396
+ const draw = drawRef.current;
1397
+ if (draw && selectedId) draw.updateFeatureProperties(selectedId, { name: e.currentTarget.value });
1398
+ }}
1399
+ disabled={!ready}
1400
+ />
1401
+ )}
1402
+ {/* Only a selected region (polygon/rectangle) offers this, same
1403
+ reasoning as the color picker above. */}
1404
+ {selectedIsRegion && (
1405
+ <Stack gap={2}>
1406
+ <Text size="xs" c="dimmed">{strings.fillOpacity}</Text>
1407
+ <Slider
1408
+ size="xs"
1409
+ min={0.1}
1410
+ max={1}
1411
+ step={0.05}
1412
+ label={(v) => `${Math.round(v * 100)}%`}
1413
+ value={opacity}
1414
+ onChange={(v) => {
1415
+ setOpacity(v);
1416
+ const draw = drawRef.current;
1417
+ if (draw && selectedId) draw.updateFeatureProperties(selectedId, { opacity: v });
1418
+ }}
1419
+ disabled={!ready}
1420
+ />
1421
+ </Stack>
1422
+ )}
1423
+ {/* Only a selected image overlay offers this -- purely shows or
1424
+ hides the 4 corner-drag handles (see setCornerHandlesVisible's
1425
+ own doc comment). */}
1426
+ {selectedOverlayId && (
1427
+ <SegmentedControl
1428
+ size="xs"
1429
+ value={showCornerHandles ? "transform" : "scale"}
1430
+ onChange={(value) => setCornerHandlesVisible(selectedOverlayId, value === "transform")}
1431
+ disabled={!ready}
1432
+ data={[
1433
+ { label: strings.scale, value: "scale" },
1434
+ { label: strings.transform, value: "transform" },
1435
+ ]}
1436
+ />
1437
+ )}
1438
+ {/* The selected image overlay's own name/opacity/order. Opacity
1439
+ applies live (see updateSelectedOverlay). */}
1440
+ {selectedOverlay && (
1441
+ <>
1442
+ {overlayNames && (
1443
+ <TextInput
1444
+ size="xs"
1445
+ placeholder={strings.overlayName}
1446
+ value={selectedOverlay.name ?? ""}
1447
+ onChange={(e) => updateSelectedOverlay({ name: e.currentTarget.value })}
1448
+ disabled={!ready}
1449
+ />
1450
+ )}
1451
+ <Stack gap={2}>
1452
+ <Text size="xs" c="dimmed">{strings.opacity}</Text>
1453
+ <Slider
1454
+ size="xs"
1455
+ min={0.1}
1456
+ max={1}
1457
+ step={0.05}
1458
+ label={(v) => `${Math.round(v * 100)}%`}
1459
+ value={selectedOverlay.opacity ?? 1}
1460
+ onChange={(v) => updateSelectedOverlay({ opacity: v })}
1461
+ disabled={!ready}
1462
+ />
1463
+ </Stack>
1464
+ <NumberInput
1465
+ size="xs"
1466
+ label={strings.stackingOrder}
1467
+ hideControls={false}
1468
+ value={selectedOverlay.order ?? 0}
1469
+ onChange={(v) => updateSelectedOverlay({ order: Number(v) || 0 })}
1470
+ disabled={!ready}
1471
+ />
1472
+ {renderOverlayOptions?.(selectedOverlay.image)}
1473
+ </>
1474
+ )}
1475
+ </Stack>
1476
+
1477
+ {!ready && (
1478
+ <Group justify="center" align="center" style={{ position: "absolute", inset: 0, zIndex: 2 }}>
1479
+ <Loader size="sm" />
1480
+ </Group>
1481
+ )}
1482
+
1483
+ {lockedCount > 0 && !lockedNoticeDismissed && (
1484
+ <Alert
1485
+ color="yellow"
1486
+ title={lockedCount === 1 ? strings.lockedOne : strings.lockedMany(lockedCount)}
1487
+ withCloseButton
1488
+ onClose={() => setLockedNoticeDismissed(true)}
1489
+ style={{ position: "absolute", bottom: 12, right: 12, zIndex: 3, maxWidth: 360 }}
1490
+ >
1491
+ {strings.lockedExplain}
1492
+ </Alert>
1493
+ )}
1494
+ </Box>
1495
+ );
1496
+ });
1497
+
1498
+ /** How to use the map editor, for the host to show where it likes (Gramps
1499
+ * Connect: an info button's modal). */
1500
+ export function MapEditorHelp({ strings: stringOverrides, images = true }: { strings?: Partial<MapEditorStrings>; images?: boolean }) {
1501
+ const strings = { ...MAP_EDITOR_ENGLISH, ...stringOverrides };
1502
+ return (
1503
+ <Stack gap="md">
1504
+ <Text size="sm" fw={600}>{strings.helpDrawing}</Text>
1505
+ <List spacing={4} size="sm">
1506
+ <List.Item>{strings.helpPoint}</List.Item>
1507
+ <List.Item>
1508
+ {strings.helpLine}{" "}
1509
+ <Kbd>Enter</Kbd> {strings.helpEnterFinishes} <Kbd>Esc</Kbd> {strings.helpEscCancels}
1510
+ </List.Item>
1511
+ <List.Item>{strings.helpRectangle}</List.Item>
1512
+ {images && (
1513
+ <List.Item>{strings.helpImage} <Text span fw={600}>{strings.helpImages}</Text>{" "}
1514
+ {strings.helpBelow}
1515
+ </List.Item>
1516
+ )}
1517
+ </List>
1518
+ <Text size="sm" fw={600}>{strings.helpEditing}</Text>
1519
+ <List spacing={4} size="sm">
1520
+ <List.Item>{strings.helpSelect}</List.Item>
1521
+ <List.Item>{strings.helpVertex}</List.Item>
1522
+ <List.Item>{strings.helpColor}</List.Item>
1523
+ <List.Item>
1524
+ {strings.helpDelete} <Kbd>Delete</Kbd> {strings.helpOr} <Kbd>Backspace</Kbd>.
1525
+ </List.Item>
1526
+ <List.Item>
1527
+ {strings.helpRotate} <Kbd>R</Kbd>{" "}
1528
+ {strings.helpRotateHandle}
1529
+ </List.Item>
1530
+ </List>
1531
+ {images && (
1532
+ <>
1533
+ <Text size="sm" fw={600}>{strings.helpImages}</Text>
1534
+ <List spacing={4} size="sm">
1535
+ <List.Item>{strings.helpImageMove}</List.Item>
1536
+ <List.Item>{strings.helpImageResize}</List.Item>
1537
+ <List.Item>{strings.helpImageRotate}</List.Item>
1538
+ <List.Item>{strings.helpImageTransform}</List.Item>
1539
+ <List.Item>{strings.helpImageRemove}</List.Item>
1540
+ </List>
1541
+ </>
1542
+ )}
1543
+ </Stack>
1544
+ );
1545
+ }