@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.
- package/README.md +23 -5
- package/dist/editor.js +2065 -177
- package/dist/editor.js.map +4 -4
- package/dist/embed.js +1 -1
- package/dist/map-edit.js +361 -0
- package/dist/map-edit.js.map +7 -0
- package/dist/player.js +3 -1
- package/dist/player.js.map +2 -2
- package/dist/types/editor/MapEditor.d.ts +89 -0
- package/dist/types/editor/PlaceField.d.ts +24 -0
- package/dist/types/editor/PlaceMap.d.ts +12 -0
- package/dist/types/editor/PlaceMapDialog.d.ts +18 -0
- package/dist/types/editor/StoryEditor.d.ts +8 -1
- package/dist/types/editor/index.d.ts +7 -1
- package/dist/types/editor/mapEditorStrings.d.ts +50 -0
- package/dist/types/editor/model.d.ts +17 -1
- package/dist/types/editor/placeSearch.d.ts +22 -0
- package/dist/types/editor/placeShapes.d.ts +13 -0
- package/dist/types/editor/strings.d.ts +28 -0
- package/dist/types/mapEdit/geometry.d.ts +91 -0
- package/dist/types/mapEdit/index.d.ts +3 -0
- package/dist/types/mapEdit/mapFile.d.ts +77 -0
- package/dist/types/mapEdit/model.d.ts +55 -0
- package/package.json +18 -2
- package/src/editor/MapEditor.tsx +1545 -0
- package/src/editor/PlaceField.tsx +170 -0
- package/src/editor/PlaceMap.tsx +88 -0
- package/src/editor/PlaceMapDialog.tsx +228 -0
- package/src/editor/StoryEditor.tsx +50 -14
- package/src/editor/index.ts +11 -2
- package/src/editor/mapEditorStrings.ts +109 -0
- package/src/editor/model.ts +38 -1
- package/src/editor/placeSearch.ts +61 -0
- package/src/editor/placeShapes.ts +63 -0
- package/src/editor/strings.ts +56 -0
- package/src/mapEdit/geometry.ts +211 -0
- package/src/mapEdit/index.ts +15 -0
- package/src/mapEdit/mapFile.ts +199 -0
- package/src/mapEdit/model.ts +250 -0
- 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
|
+
}
|