@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
package/dist/editor.js CHANGED
@@ -1,20 +1,20 @@
1
1
  // src/editor/StoryEditor.tsx
2
- import { useCallback, useEffect as useEffect6, useMemo as useMemo2, useRef as useRef7, useState as useState7 } from "react";
2
+ import { useCallback, useEffect as useEffect9, useMemo as useMemo4, useRef as useRef11, useState as useState10 } from "react";
3
3
  import {
4
- ActionIcon as ActionIcon2,
4
+ ActionIcon as ActionIcon3,
5
5
  Badge,
6
- Box as Box2,
7
- Button as Button3,
8
- Divider,
9
- Group as Group4,
10
- Modal,
6
+ Box as Box4,
7
+ Button as Button6,
8
+ Divider as Divider2,
9
+ Group as Group7,
10
+ Modal as Modal2,
11
11
  Popover,
12
12
  Select,
13
- Stack as Stack5,
14
- Text as Text4,
15
- TextInput as TextInput3,
16
- Tooltip as Tooltip2,
17
- UnstyledButton as UnstyledButton2
13
+ Stack as Stack8,
14
+ Text as Text7,
15
+ TextInput as TextInput6,
16
+ Tooltip as Tooltip4,
17
+ UnstyledButton as UnstyledButton3
18
18
  } from "@mantine/core";
19
19
  import { useMediaQuery } from "@mantine/hooks";
20
20
 
@@ -1787,6 +1787,53 @@ function findUnknownFields(value, node, path, warnings, root = story_schema_defa
1787
1787
  }
1788
1788
  }
1789
1789
 
1790
+ // src/player/geo.ts
1791
+ function visit(coords, bounds) {
1792
+ if (!Array.isArray(coords)) return;
1793
+ if (coords.length >= 2 && typeof coords[0] === "number" && typeof coords[1] === "number") {
1794
+ const [lon, lat] = coords;
1795
+ bounds[0] = Math.min(bounds[0], lon);
1796
+ bounds[1] = Math.min(bounds[1], lat);
1797
+ bounds[2] = Math.max(bounds[2], lon);
1798
+ bounds[3] = Math.max(bounds[3], lat);
1799
+ return;
1800
+ }
1801
+ for (const child of coords) visit(child, bounds);
1802
+ }
1803
+ function visitGeoJson(node, bounds) {
1804
+ if (!node || typeof node !== "object") return;
1805
+ const obj = node;
1806
+ if (obj.coordinates !== void 0) visit(obj.coordinates, bounds);
1807
+ if (obj.geometry) visitGeoJson(obj.geometry, bounds);
1808
+ for (const g of obj.geometries ?? []) visitGeoJson(g, bounds);
1809
+ for (const f of obj.features ?? []) visitGeoJson(f, bounds);
1810
+ }
1811
+ function placeBounds(place) {
1812
+ if (!place) return null;
1813
+ const bounds = [Infinity, Infinity, -Infinity, -Infinity];
1814
+ visitGeoJson(place.geometry, bounds);
1815
+ for (const overlay of place.overlays ?? []) visit(overlay.corners, bounds);
1816
+ return Number.isFinite(bounds[0]) ? bounds : null;
1817
+ }
1818
+ function placeCenter(place) {
1819
+ if (place?.lat !== void 0 && place.lon !== void 0) return [place.lon, place.lat];
1820
+ const bounds = placeBounds(place);
1821
+ return bounds ? [(bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2] : null;
1822
+ }
1823
+ function storyPlaces(places) {
1824
+ const bounds = [Infinity, Infinity, -Infinity, -Infinity];
1825
+ const points = /* @__PURE__ */ new Map();
1826
+ for (const place of places) {
1827
+ const center = placeCenter(place);
1828
+ if (!center) continue;
1829
+ points.set(`${center[0].toFixed(5)},${center[1].toFixed(5)}`, center);
1830
+ visit(center, bounds);
1831
+ const box = placeBounds(place);
1832
+ if (box) visit([[box[0], box[1]], [box[2], box[3]]], bounds);
1833
+ }
1834
+ return points.size ? { points: [...points.values()], bounds } : null;
1835
+ }
1836
+
1790
1837
  // src/player/StoryPlayer.tsx
1791
1838
  import { useEffect as useEffect3, useMemo, useRef as useRef3, useState as useState3 } from "react";
1792
1839
 
@@ -1839,53 +1886,6 @@ function timelineYear(date) {
1839
1886
  return sortYear + (month - 1) / 12 + (day ? (day - 1) / 365 : 0);
1840
1887
  }
1841
1888
 
1842
- // src/player/geo.ts
1843
- function visit(coords, bounds) {
1844
- if (!Array.isArray(coords)) return;
1845
- if (coords.length >= 2 && typeof coords[0] === "number" && typeof coords[1] === "number") {
1846
- const [lon, lat] = coords;
1847
- bounds[0] = Math.min(bounds[0], lon);
1848
- bounds[1] = Math.min(bounds[1], lat);
1849
- bounds[2] = Math.max(bounds[2], lon);
1850
- bounds[3] = Math.max(bounds[3], lat);
1851
- return;
1852
- }
1853
- for (const child of coords) visit(child, bounds);
1854
- }
1855
- function visitGeoJson(node, bounds) {
1856
- if (!node || typeof node !== "object") return;
1857
- const obj = node;
1858
- if (obj.coordinates !== void 0) visit(obj.coordinates, bounds);
1859
- if (obj.geometry) visitGeoJson(obj.geometry, bounds);
1860
- for (const g of obj.geometries ?? []) visitGeoJson(g, bounds);
1861
- for (const f of obj.features ?? []) visitGeoJson(f, bounds);
1862
- }
1863
- function placeBounds(place) {
1864
- if (!place) return null;
1865
- const bounds = [Infinity, Infinity, -Infinity, -Infinity];
1866
- visitGeoJson(place.geometry, bounds);
1867
- for (const overlay of place.overlays ?? []) visit(overlay.corners, bounds);
1868
- return Number.isFinite(bounds[0]) ? bounds : null;
1869
- }
1870
- function placeCenter(place) {
1871
- if (place?.lat !== void 0 && place.lon !== void 0) return [place.lon, place.lat];
1872
- const bounds = placeBounds(place);
1873
- return bounds ? [(bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2] : null;
1874
- }
1875
- function storyPlaces(places) {
1876
- const bounds = [Infinity, Infinity, -Infinity, -Infinity];
1877
- const points = /* @__PURE__ */ new Map();
1878
- for (const place of places) {
1879
- const center = placeCenter(place);
1880
- if (!center) continue;
1881
- points.set(`${center[0].toFixed(5)},${center[1].toFixed(5)}`, center);
1882
- visit(center, bounds);
1883
- const box = placeBounds(place);
1884
- if (box) visit([[box[0], box[1]], [box[2], box[3]]], bounds);
1885
- }
1886
- return points.size ? { points: [...points.values()], bounds } : null;
1887
- }
1888
-
1889
1889
  // src/player/MapBackground.tsx
1890
1890
  import { useEffect, useRef, useState } from "react";
1891
1891
  import * as maplibregl from "maplibre-gl";
@@ -2057,7 +2057,9 @@ function MapBackground({ initialCenter, current, dark, colors, panelFraction, oh
2057
2057
  type: "fill",
2058
2058
  source: SHAPE_SOURCE,
2059
2059
  filter: ["==", ["geometry-type"], "Polygon"],
2060
- paint: { "fill-color": color, "fill-opacity": 0.25 }
2060
+ // And its own `opacity` (what the map editor sets on an area) over
2061
+ // a light default.
2062
+ paint: { "fill-color": color, "fill-opacity": ["coalesce", ["to-number", ["get", "opacity"], 0.25], 0.25] }
2061
2063
  });
2062
2064
  map.addLayer({
2063
2065
  id: LINE_LAYER,
@@ -2967,6 +2969,30 @@ function slideDate(previous, text, edtf) {
2967
2969
  if (next.edtf !== previous?.edtf) delete next.sortYear;
2968
2970
  return next;
2969
2971
  }
2972
+ function patchPlace(place, patch) {
2973
+ if (place && Object.entries(patch).every(([key, value]) => place[key] === value)) return place;
2974
+ const next = patched(place ?? {}, patch);
2975
+ return Object.keys(next).length ? next : void 0;
2976
+ }
2977
+ var roundDegrees = (value) => Math.round(value * 1e5) / 1e5;
2978
+ function parseLatLon(text) {
2979
+ const parts = text.trim().split(/\s*[,;\s]\s*/);
2980
+ if (parts.length !== 2 || parts.some((p) => !/^[-+]?(\d+\.?\d*|\.\d+)$/.test(p))) return null;
2981
+ const [lat, lon] = parts.map(Number);
2982
+ if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null;
2983
+ return { lat, lon };
2984
+ }
2985
+ function formatLatLon(lat, lon) {
2986
+ return `${roundDegrees(lat)}, ${roundDegrees(lon)}`;
2987
+ }
2988
+ function storyPlaceChoices(doc, exceptIndex) {
2989
+ const byName = /* @__PURE__ */ new Map();
2990
+ doc.slides.forEach((slide, i) => {
2991
+ const name = slide.place?.name?.trim();
2992
+ if (i !== exceptIndex && name && !byName.has(name)) byName.set(name, slide.place);
2993
+ });
2994
+ return [...byName.values()];
2995
+ }
2970
2996
  function sameBytes(a, b) {
2971
2997
  if (!a || a.length !== b.length) return false;
2972
2998
  for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
@@ -3249,22 +3275,1803 @@ function PhotosField({ photos, media, urlFor, strings, busy, onChange, onFiles,
3249
3275
  );
3250
3276
  }
3251
3277
 
3252
- // src/editor/SlideList.tsx
3253
- import { useState as useState6 } from "react";
3254
- import { Stack as Stack3, UnstyledButton } from "@mantine/core";
3278
+ // src/editor/PlaceField.tsx
3279
+ import { useEffect as useEffect7, useMemo as useMemo2, useRef as useRef7, useState as useState6 } from "react";
3280
+ import { Autocomplete, Button as Button2, Group as Group3, Loader as Loader2, Stack as Stack3, Text as Text3, TextInput as TextInput3, Tooltip as Tooltip2, UnstyledButton } from "@mantine/core";
3281
+
3282
+ // src/editor/PlaceMap.tsx
3283
+ import { useEffect as useEffect6, useRef as useRef6 } from "react";
3284
+ import * as maplibregl2 from "maplibre-gl";
3285
+ import "maplibre-gl/dist/maplibre-gl.css";
3255
3286
  import { jsx as jsx7 } from "react/jsx-runtime";
3287
+ function PlaceMap({ point, around, dark = false, label, onPick }) {
3288
+ const container = useRef6(null);
3289
+ const mapRef = useRef6(null);
3290
+ const markerRef = useRef6(null);
3291
+ const onPickRef = useRef6(onPick);
3292
+ onPickRef.current = onPick;
3293
+ const pick = (lngLat) => onPickRef.current([roundDegrees(lngLat.lng), roundDegrees(lngLat.lat)]);
3294
+ const start = useRef6({ point, around });
3295
+ useEffect6(() => {
3296
+ if (!container.current) return;
3297
+ const { point: first, around: box } = start.current;
3298
+ const map = new maplibregl2.Map({
3299
+ container: container.current,
3300
+ style: mapStyleUrl(dark, null),
3301
+ ...first ? { center: first, zoom: 9 } : box ? { bounds: box, fitBoundsOptions: { padding: 30, maxZoom: 9 } } : { center: [10, 30], zoom: 0.5 },
3302
+ attributionControl: { compact: true },
3303
+ // Scrolling the form past the map shouldn't zoom it.
3304
+ cooperativeGestures: true
3305
+ });
3306
+ map.addControl(new maplibregl2.NavigationControl({ showCompass: false }), "top-right");
3307
+ map.getCanvas().style.cursor = "crosshair";
3308
+ map.on("click", (e) => pick(e.lngLat));
3309
+ mapRef.current = map;
3310
+ return () => {
3311
+ markerRef.current?.remove();
3312
+ markerRef.current = null;
3313
+ map.remove();
3314
+ mapRef.current = null;
3315
+ };
3316
+ }, [dark]);
3317
+ useEffect6(() => {
3318
+ const map = mapRef.current;
3319
+ if (!map) return;
3320
+ if (!point) {
3321
+ markerRef.current?.remove();
3322
+ markerRef.current = null;
3323
+ return;
3324
+ }
3325
+ if (!markerRef.current) {
3326
+ const el = document.createElement("div");
3327
+ const c = defaultColors(dark);
3328
+ el.innerHTML = `<svg viewBox="0 0 24 24" width="28" height="28" style="display:block;cursor:grab"><path d="${PIN_PATH_D}" fill="${c.mark}" stroke="${c.surface}" stroke-width="1.5" /></svg>`;
3329
+ const marker = new maplibregl2.Marker({ element: el, anchor: "bottom", draggable: true }).setLngLat(point).addTo(map);
3330
+ marker.on("dragend", () => pick(marker.getLngLat()));
3331
+ markerRef.current = marker;
3332
+ } else {
3333
+ markerRef.current.setLngLat(point);
3334
+ }
3335
+ if (!map.getBounds().contains(point)) map.easeTo({ center: point, zoom: Math.max(map.getZoom(), 8) });
3336
+ }, [point?.[0], point?.[1], dark]);
3337
+ return /* @__PURE__ */ jsx7(
3338
+ "div",
3339
+ {
3340
+ ref: container,
3341
+ role: "application",
3342
+ "aria-label": label,
3343
+ style: { height: 240, borderRadius: 6, overflow: "hidden", border: "1px solid var(--mantine-color-default-border)" }
3344
+ }
3345
+ );
3346
+ }
3347
+
3348
+ // src/editor/PlaceField.tsx
3349
+ import { jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
3350
+ function PlaceField({ place, choices, around, language, search, dark, strings, onChange, onEditMap }) {
3351
+ const lat = place?.lat;
3352
+ const lon = place?.lon;
3353
+ const point = lat !== void 0 && lon !== void 0 ? [lon, lat] : null;
3354
+ const name = place?.name ?? "";
3355
+ const [coords, setCoords] = useState6(point ? formatLatLon(point[1], point[0]) : "");
3356
+ useEffect7(() => {
3357
+ setCoords((typed) => {
3358
+ const parsed = parseLatLon(typed);
3359
+ if (lat === void 0 || lon === void 0) return parsed ? "" : typed;
3360
+ return parsed && parsed.lat === lat && parsed.lon === lon ? typed : formatLatLon(lat, lon);
3361
+ });
3362
+ }, [lat, lon]);
3363
+ const coordsInvalid = coords.trim() !== "" && !parseLatLon(coords);
3364
+ const [found, setFound] = useState6({ state: "idle" });
3365
+ const searching = useRef7(null);
3366
+ useEffect7(() => () => searching.current?.abort(), []);
3367
+ const find = async () => {
3368
+ if (!search || !name.trim()) return;
3369
+ searching.current?.abort();
3370
+ const controller = new AbortController();
3371
+ searching.current = controller;
3372
+ setFound({ state: "searching" });
3373
+ try {
3374
+ const results = await search.search(name.trim(), language, controller.signal);
3375
+ setFound(results.length ? { state: "results", results } : { state: "message", message: strings.findPlaceNone });
3376
+ } catch {
3377
+ if (!controller.signal.aborted) setFound({ state: "message", message: strings.findPlaceFailed });
3378
+ }
3379
+ };
3380
+ const choose = async (result) => {
3381
+ if (!search) return;
3382
+ searching.current?.abort();
3383
+ const controller = new AbortController();
3384
+ searching.current = controller;
3385
+ setFound({ state: "searching" });
3386
+ try {
3387
+ const at = await search.point(result, controller.signal);
3388
+ if (!at) {
3389
+ setFound({ state: "message", message: strings.findPlaceNoPoint(result.label) });
3390
+ return;
3391
+ }
3392
+ setFound({ state: "idle" });
3393
+ onChange((p) => patchPlace(p, { name: result.label, lat: roundDegrees(at.lat), lon: roundDegrees(at.lon) }), "place");
3394
+ } catch {
3395
+ if (!controller.signal.aborted) setFound({ state: "message", message: strings.findPlaceFailed });
3396
+ }
3397
+ };
3398
+ const byName = useMemo2(() => new Map(choices.map((c) => [c.name.trim(), c])), [choices]);
3399
+ const keeps = [
3400
+ place?.geometry ? strings.placeShape : null,
3401
+ place?.overlays?.length ? strings.placeOverlays(place.overlays.length) : null
3402
+ ].filter(Boolean);
3403
+ return /* @__PURE__ */ jsxs6(Stack3, { gap: "xs", children: [
3404
+ /* @__PURE__ */ jsxs6(Group3, { justify: "space-between", children: [
3405
+ /* @__PURE__ */ jsx8(Text3, { size: "sm", fw: 500, children: strings.place }),
3406
+ place && /* @__PURE__ */ jsx8(Button2, { variant: "subtle", size: "compact-xs", color: "red", onClick: () => {
3407
+ setFound({ state: "idle" });
3408
+ onChange(() => void 0, "place");
3409
+ }, children: strings.removePlace })
3410
+ ] }),
3411
+ /* @__PURE__ */ jsxs6(Group3, { gap: "xs", align: "flex-end", wrap: "nowrap", children: [
3412
+ /* @__PURE__ */ jsx8(
3413
+ Autocomplete,
3414
+ {
3415
+ size: "xs",
3416
+ label: strings.placeName,
3417
+ style: { flex: 1 },
3418
+ value: name,
3419
+ data: [...byName.keys()],
3420
+ comboboxProps: { withinPortal: true },
3421
+ onChange: (value) => onChange((p) => patchPlace(p, { name: value || void 0 }), "place:name"),
3422
+ onOptionSubmit: (value) => {
3423
+ const chosen = byName.get(value);
3424
+ if (chosen) onChange(() => structuredClone(chosen), "place");
3425
+ }
3426
+ }
3427
+ ),
3428
+ search && /* @__PURE__ */ jsx8(Tooltip2, { label: strings.findPlaceHint, multiline: true, w: 260, children: /* @__PURE__ */ jsx8(Button2, { variant: "default", size: "xs", disabled: !name.trim() || found.state === "searching", onClick: () => void find(), children: strings.findPlace }) })
3429
+ ] }),
3430
+ found.state === "searching" && /* @__PURE__ */ jsx8(Loader2, { size: "xs" }),
3431
+ found.state === "message" && /* @__PURE__ */ jsx8(Text3, { size: "xs", c: "dimmed", role: "status", children: found.message }),
3432
+ found.state === "results" && /* @__PURE__ */ jsx8(Stack3, { gap: 2, role: "list", "aria-label": strings.findPlace, children: found.results.map((result) => /* @__PURE__ */ jsxs6(
3433
+ UnstyledButton,
3434
+ {
3435
+ role: "listitem",
3436
+ onClick: () => void choose(result),
3437
+ style: { padding: "4px 8px", borderRadius: 4, border: "1px solid var(--mantine-color-default-border)" },
3438
+ children: [
3439
+ /* @__PURE__ */ jsx8(Text3, { size: "sm", children: result.label }),
3440
+ result.description && /* @__PURE__ */ jsx8(Text3, { size: "xs", c: "dimmed", children: result.description })
3441
+ ]
3442
+ },
3443
+ result.id
3444
+ )) }),
3445
+ /* @__PURE__ */ jsx8(
3446
+ TextInput3,
3447
+ {
3448
+ size: "xs",
3449
+ label: strings.coordinates,
3450
+ placeholder: strings.coordinatesHint,
3451
+ w: 260,
3452
+ value: coords,
3453
+ error: coordsInvalid ? strings.coordinatesInvalid : void 0,
3454
+ onChange: (e) => {
3455
+ const text = e.currentTarget.value;
3456
+ setCoords(text);
3457
+ const parsed = parseLatLon(text);
3458
+ if (!text.trim()) onChange((p) => patchPlace(p, { lat: void 0, lon: void 0 }), "place:point");
3459
+ else if (parsed) onChange((p) => patchPlace(p, parsed), "place:point");
3460
+ }
3461
+ }
3462
+ ),
3463
+ /* @__PURE__ */ jsx8(
3464
+ PlaceMap,
3465
+ {
3466
+ point,
3467
+ around,
3468
+ dark,
3469
+ label: strings.placeMap,
3470
+ onPick: ([pickedLon, pickedLat]) => onChange((p) => patchPlace(p, { lat: pickedLat, lon: pickedLon }), "place:point")
3471
+ }
3472
+ ),
3473
+ /* @__PURE__ */ jsx8(Text3, { size: "xs", c: "dimmed", children: strings.placeMapHint }),
3474
+ keeps.length > 0 && /* @__PURE__ */ jsx8(Text3, { size: "xs", c: "dimmed", children: strings.placeKeeps(keeps.join(", ")) }),
3475
+ onEditMap && /* @__PURE__ */ jsxs6(Group3, { gap: "sm", wrap: "nowrap", align: "center", children: [
3476
+ /* @__PURE__ */ jsx8(Button2, { variant: "default", size: "xs", style: { flex: "none" }, onClick: onEditMap, children: strings.editMap }),
3477
+ /* @__PURE__ */ jsx8(Text3, { size: "xs", c: "dimmed", children: strings.editMapHint })
3478
+ ] })
3479
+ ] });
3480
+ }
3481
+
3482
+ // src/editor/PlaceMapDialog.tsx
3483
+ import { useMemo as useMemo3, useRef as useRef9, useState as useState8 } from "react";
3484
+ import { ActionIcon as ActionIcon2, Box as Box3, Button as Button4, Group as Group5, Modal, Stack as Stack5, Text as Text5, TextInput as TextInput5, Tooltip as Tooltip3 } from "@mantine/core";
3485
+
3486
+ // src/mapEdit/model.ts
3487
+ var EDITOR_OWNED = /* @__PURE__ */ new Set(["name", "color", "opacity"]);
3488
+ var DROPPED = /* @__PURE__ */ new Set(["coordinateProperties", "_gpxType", "styleUrl", "@geometry-type"]);
3489
+ function round2d(position) {
3490
+ return [Number(position[0].toFixed(9)), Number(position[1].toFixed(9))];
3491
+ }
3492
+ function singleParts(geometry) {
3493
+ switch (geometry.type) {
3494
+ case "Point":
3495
+ return [{ type: "Point", coordinates: round2d(geometry.coordinates) }];
3496
+ case "LineString":
3497
+ return [{ type: "LineString", coordinates: geometry.coordinates.map(round2d) }];
3498
+ case "Polygon":
3499
+ return [{ type: "Polygon", coordinates: geometry.coordinates.map((ring) => ring.map(round2d)) }];
3500
+ case "MultiPoint":
3501
+ return geometry.coordinates.map((c) => ({ type: "Point", coordinates: round2d(c) }));
3502
+ case "MultiLineString":
3503
+ return geometry.coordinates.map((line) => ({ type: "LineString", coordinates: line.map(round2d) }));
3504
+ case "MultiPolygon":
3505
+ return geometry.coordinates.map((poly) => ({ type: "Polygon", coordinates: poly.map((ring) => ring.map(round2d)) }));
3506
+ case "GeometryCollection":
3507
+ return geometry.geometries.flatMap(singleParts);
3508
+ }
3509
+ }
3510
+ var modeOf = (geometry) => geometry.type === "Point" ? "point" : geometry.type === "LineString" ? "linestring" : "polygon";
3511
+ function extraProperties(properties) {
3512
+ const extra = {};
3513
+ for (const [key, value] of Object.entries(properties)) {
3514
+ if (EDITOR_OWNED.has(key) || DROPPED.has(key) || value === void 0 || value === null) continue;
3515
+ extra[key] = value;
3516
+ }
3517
+ return extra;
3518
+ }
3519
+ function partTimes(feature, partCount) {
3520
+ const times = feature.properties?.coordinateProperties?.times;
3521
+ if (!Array.isArray(times) || times.length === 0) return [];
3522
+ if (feature.geometry?.type === "LineString" && times.every((t) => typeof t === "string")) {
3523
+ return [times];
3524
+ }
3525
+ if (feature.geometry?.type === "MultiLineString" && times.length === partCount && times.every(Array.isArray)) {
3526
+ return times;
3527
+ }
3528
+ return [];
3529
+ }
3530
+ function prepareForEditing(features, firstIndex = 0) {
3531
+ const session = { parts: [], sources: /* @__PURE__ */ new Map(), locked: /* @__PURE__ */ new Map() };
3532
+ features.forEach((feature, i) => {
3533
+ const f = firstIndex + i;
3534
+ if (!feature.geometry) return;
3535
+ const properties = feature.properties ?? {};
3536
+ const extra = extraProperties(properties);
3537
+ const geometries = singleParts(feature.geometry);
3538
+ const times = partTimes(feature, geometries.length);
3539
+ const opacityRaw = Number(properties.opacity);
3540
+ const opacity = Number.isFinite(opacityRaw) && opacityRaw >= 0 && opacityRaw <= 1 ? opacityRaw : void 0;
3541
+ geometries.forEach((geometry, index) => {
3542
+ const sourceKey = `f${f}p${index}`;
3543
+ const partTimesHere = times[index];
3544
+ const keepTimes = geometry.type === "LineString" && partTimesHere?.length === geometry.coordinates.length;
3545
+ session.sources.set(sourceKey, {
3546
+ group: `f${f}`,
3547
+ index,
3548
+ extra,
3549
+ ...keepTimes ? { times: partTimesHere, coords: geometry.coordinates } : {}
3550
+ });
3551
+ const part = {
3552
+ sourceKey,
3553
+ mode: modeOf(geometry),
3554
+ geometry,
3555
+ name: typeof properties.name === "string" ? properties.name : void 0,
3556
+ color: typeof properties.color === "string" ? properties.color : void 0,
3557
+ ...geometry.type === "Polygon" ? { opacity } : {}
3558
+ };
3559
+ if (geometry.type === "Polygon" && geometry.coordinates.length > 1) session.locked.set(sourceKey, part);
3560
+ else session.parts.push(part);
3561
+ });
3562
+ });
3563
+ return session;
3564
+ }
3565
+ function addToSession(session, features) {
3566
+ let next = 0;
3567
+ for (const source of session.sources.values()) next = Math.max(next, Number(source.group.slice(1)) + 1);
3568
+ const added = prepareForEditing(features, next);
3569
+ session.parts.push(...added.parts);
3570
+ for (const [key, source] of added.sources) session.sources.set(key, source);
3571
+ for (const [key, part] of added.locked) session.locked.set(key, part);
3572
+ return added;
3573
+ }
3574
+ function lockPart(session, sourceKey) {
3575
+ const index = session.parts.findIndex((part) => part.sourceKey === sourceKey);
3576
+ if (index < 0) return;
3577
+ session.locked.set(sourceKey, session.parts[index]);
3578
+ session.parts.splice(index, 1);
3579
+ }
3580
+ var samePositions = (a, b) => a.length === b.length && a.every((p, i) => p[0] === b[i][0] && p[1] === b[i][1]);
3581
+ function joinGeometries(geometries) {
3582
+ if (geometries.length === 1) return geometries[0];
3583
+ const kinds = new Set(geometries.map((g) => g.type));
3584
+ if (kinds.size === 1) {
3585
+ if (kinds.has("Point")) return { type: "MultiPoint", coordinates: geometries.map((g) => g.coordinates) };
3586
+ if (kinds.has("LineString")) return { type: "MultiLineString", coordinates: geometries.map((g) => g.coordinates) };
3587
+ return { type: "MultiPolygon", coordinates: geometries.map((g) => g.coordinates) };
3588
+ }
3589
+ return { type: "GeometryCollection", geometries };
3590
+ }
3591
+ function assembleForSaving(session, saved) {
3592
+ const entries = [
3593
+ ...saved.map((part) => ({ part, source: part.sourceKey ? session.sources.get(part.sourceKey) : void 0 })),
3594
+ ...[...session.locked.values()].map((part) => ({ part, source: session.sources.get(part.sourceKey) }))
3595
+ ];
3596
+ const buckets = /* @__PURE__ */ new Map();
3597
+ let newCount = 0;
3598
+ for (const entry of entries) {
3599
+ const { part, source } = entry;
3600
+ const key = source ? `${source.group}|${part.name ?? ""}|${part.color ?? ""}|${part.opacity ?? ""}` : `new${newCount++}`;
3601
+ const bucket = buckets.get(key);
3602
+ if (bucket) bucket.push(entry);
3603
+ else buckets.set(key, [entry]);
3604
+ }
3605
+ const groupOrder = (key) => key.startsWith("new") ? Infinity : Number(key.slice(1, key.indexOf("|")));
3606
+ return [...buckets.entries()].sort(([a], [b]) => groupOrder(a) - groupOrder(b)).map(([, bucket]) => {
3607
+ bucket.sort((a, b) => (a.source?.index ?? 0) - (b.source?.index ?? 0));
3608
+ const first = bucket[0];
3609
+ const geometry = joinGeometries(bucket.map((e) => e.part.geometry));
3610
+ const times = bucket.map(({ part, source }) => part.geometry.type === "LineString" && source?.times && source.coords && samePositions(part.geometry.coordinates, source.coords) ? source.times : void 0);
3611
+ const allTimed = bucket.every((e) => e.part.geometry.type === "LineString") && times.every(Boolean);
3612
+ return {
3613
+ type: "Feature",
3614
+ geometry,
3615
+ properties: {
3616
+ ...first.source?.extra ?? {},
3617
+ ...first.part.color !== void 0 ? { color: first.part.color } : {},
3618
+ ...first.part.name ? { name: first.part.name } : {},
3619
+ ...first.part.opacity !== void 0 ? { opacity: first.part.opacity } : {},
3620
+ ...allTimed ? { coordinateProperties: { times: bucket.length === 1 ? times[0] : times } } : {}
3621
+ }
3622
+ };
3623
+ });
3624
+ }
3625
+
3626
+ // src/mapEdit/mapFile.ts
3627
+ import { gpx, kml } from "@tmcw/togeojson";
3628
+ var MEDIA_HANDLE_PREFIX = "media-handle:";
3629
+ function applySimplestyle(feature) {
3630
+ const props = feature.properties ?? {};
3631
+ const isRegion = feature.geometry?.type === "Polygon" || feature.geometry?.type === "MultiPolygon";
3632
+ const color = props.color ?? (isRegion ? props.fill ?? props.stroke : props.stroke ?? props["marker-color"]);
3633
+ const opacity = props.opacity ?? (isRegion ? props["fill-opacity"] : void 0);
3634
+ if (color === props.color && opacity === props.opacity) return feature;
3635
+ return { ...feature, properties: { ...props, color, opacity } };
3636
+ }
3637
+ function geoJsonFeatures(doc) {
3638
+ if (doc.type === "FeatureCollection") return doc.features ?? [];
3639
+ if (doc.type === "Feature") return [doc];
3640
+ return [{ type: "Feature", geometry: doc, properties: {} }];
3641
+ }
3642
+ function asGroundOverlay(feature) {
3643
+ const props = feature.properties ?? {};
3644
+ if (props[IMAGE_OVERLAY_PROPERTY] !== true || typeof props.image !== "string") return feature;
3645
+ const { [IMAGE_OVERLAY_PROPERTY]: _marker, image, ...rest } = props;
3646
+ return {
3647
+ ...feature,
3648
+ properties: { ...rest, "@geometry-type": "groundoverlay", icon: `${MEDIA_HANDLE_PREFIX}${image}` }
3649
+ };
3650
+ }
3651
+ function parseMapFile(text) {
3652
+ const hasGeometry = (f) => f.geometry != null;
3653
+ if (text.trimStart().startsWith("{")) {
3654
+ return geoJsonFeatures(JSON.parse(text)).filter(hasGeometry).map(asGroundOverlay).map(applySimplestyle);
3655
+ }
3656
+ const dom = new DOMParser().parseFromString(text, "text/xml");
3657
+ if (dom.documentElement.localName === "gpx") {
3658
+ return gpx(dom).features.filter(hasGeometry).map(applySimplestyle);
3659
+ }
3660
+ return kml(dom).features.filter(hasGeometry);
3661
+ }
3662
+ var IMAGE_OVERLAY_PROPERTY = "gramps:image-overlay";
3663
+
3664
+ // src/mapEdit/geometry.ts
3665
+ var DEG_TO_RAD = Math.PI / 180;
3666
+ var RAD_TO_DEG = 180 / Math.PI;
3667
+ function mercatorFromLngLat([lng, lat]) {
3668
+ const x = (180 + lng) / 360;
3669
+ const y = (180 - RAD_TO_DEG * Math.log(Math.tan(Math.PI / 4 + lat * DEG_TO_RAD / 2))) / 360;
3670
+ return [x, y];
3671
+ }
3672
+ function lngLatFromMercator([x, y]) {
3673
+ const lng = x * 360 - 180;
3674
+ const lat = 90 - 2 * Math.atan(Math.exp((y * 360 - 180) * DEG_TO_RAD)) * RAD_TO_DEG;
3675
+ return [lng, lat];
3676
+ }
3677
+ function rotatePoint(point, center, rotationDeg) {
3678
+ const angle = rotationDeg * DEG_TO_RAD;
3679
+ const cos = Math.cos(angle);
3680
+ const sin = Math.sin(angle);
3681
+ const [px, py] = mercatorFromLngLat(point);
3682
+ const [cx, cy] = mercatorFromLngLat(center);
3683
+ const dx = px - cx;
3684
+ const dy = -(py - cy);
3685
+ const rx = dx * cos - dy * sin;
3686
+ const ry = dx * sin + dy * cos;
3687
+ return lngLatFromMercator([cx + rx, cy - ry]);
3688
+ }
3689
+ function scalePoint(point, center, scale) {
3690
+ const [px, py] = mercatorFromLngLat(point);
3691
+ const [cx, cy] = mercatorFromLngLat(center);
3692
+ return lngLatFromMercator([cx + (px - cx) * scale, cy + (py - cy) * scale]);
3693
+ }
3694
+ function mercatorDistance(a, b) {
3695
+ const [ax, ay] = mercatorFromLngLat(a);
3696
+ const [bx, by] = mercatorFromLngLat(b);
3697
+ return Math.hypot(bx - ax, by - ay);
3698
+ }
3699
+ function mercatorBearing(from, to) {
3700
+ const [fx, fy] = mercatorFromLngLat(from);
3701
+ const [tx, ty] = mercatorFromLngLat(to);
3702
+ return Math.atan2(-(ty - fy), tx - fx) * RAD_TO_DEG;
3703
+ }
3704
+
3705
+ // src/editor/MapEditor.tsx
3706
+ import { forwardRef, useEffect as useEffect8, useImperativeHandle, useRef as useRef8, useState as useState7 } from "react";
3707
+ import * as maplibregl3 from "maplibre-gl";
3708
+ import {
3709
+ TerraDraw,
3710
+ TerraDrawLineStringMode,
3711
+ TerraDrawPointMode,
3712
+ TerraDrawPolygonMode,
3713
+ TerraDrawRectangleMode,
3714
+ TerraDrawSelectMode
3715
+ } from "terra-draw";
3716
+ import { TerraDrawMapLibreGLAdapter } from "terra-draw-maplibre-gl-adapter";
3717
+ import {
3718
+ Alert,
3719
+ Box as Box2,
3720
+ Button as Button3,
3721
+ ColorInput,
3722
+ Divider,
3723
+ Group as Group4,
3724
+ Kbd,
3725
+ List,
3726
+ Loader as Loader3,
3727
+ NumberInput,
3728
+ SegmentedControl,
3729
+ Slider,
3730
+ Stack as Stack4,
3731
+ Text as Text4,
3732
+ TextInput as TextInput4
3733
+ } from "@mantine/core";
3734
+
3735
+ // src/editor/mapEditorStrings.ts
3736
+ var MAP_EDITOR_ENGLISH = {
3737
+ point: "Point",
3738
+ line: "Line",
3739
+ polygon: "Polygon",
3740
+ rectangle: "Rectangle",
3741
+ select: "Select",
3742
+ image: "Image",
3743
+ editOptions: "Edit Options",
3744
+ labelText: "Label text",
3745
+ regionName: "Region name (optional)",
3746
+ fillOpacity: "Fill opacity",
3747
+ scale: "Scale",
3748
+ transform: "Transform",
3749
+ overlayName: "Overlay name (optional)",
3750
+ opacity: "Opacity",
3751
+ stackingOrder: "Stacking order",
3752
+ removeImage: "Remove this image",
3753
+ resizeImage: "Drag to resize (keeps its exact shape, warped or not)",
3754
+ rotateImage: "Drag to rotate",
3755
+ warpCorner: "Drag to warp this corner",
3756
+ basemapFailed: "The basemap could not be loaded.",
3757
+ imageFailed: "The image failed to load.",
3758
+ lockedOne: "1 shape can't be edited here",
3759
+ lockedMany: (count) => `${count} shapes can't be edited here`,
3760
+ lockedExplain: "For example, a region with a hole in it, or one whose outline crosses itself. It's drawn dashed, and saved exactly as it was.",
3761
+ helpTitle: "How to use this editor",
3762
+ helpDrawing: "Drawing",
3763
+ helpPoint: "Point: click once to place it.",
3764
+ helpLine: "Line, Polygon: click to place each point, then click that first point's own marker again to finish -- it's styled differently once there's enough points to close the shape. Pressing",
3765
+ helpEnterFinishes: "finishes it too;",
3766
+ helpEscCancels: "cancels it.",
3767
+ helpRectangle: "Rectangle: click and drag from one corner to the opposite corner.",
3768
+ helpImage: "Image: pick a media object to overlay -- see",
3769
+ helpImages: "Images",
3770
+ helpBelow: "below.",
3771
+ helpEditing: "Editing a shape",
3772
+ helpSelect: "Select: click a shape to select it, then drag it or one of its handles.",
3773
+ helpVertex: "Drag a vertex to move it. To add a new point on a Line, Polygon, or Rectangle, drag the small midpoint marker between two existing vertices outward.",
3774
+ helpColor: "The color swatch sets the next shape's color -- or, with a shape selected, changes its color.",
3775
+ helpDelete: "Delete a selected shape or image with",
3776
+ helpOr: "or",
3777
+ helpRotate: "Rotate a selected Polygon, Rectangle, or Line: hold",
3778
+ helpRotateHandle: "while dragging one of its handles.",
3779
+ helpImageMove: "Drag anywhere on an image to move it.",
3780
+ helpImageResize: "Drag its \u2198 handle to resize it -- grows or shrinks from its own center, keeping its exact shape (works the same on a warped image, too).",
3781
+ helpImageRotate: "Drag its \u21BB handle to rotate it -- handy for aligning a scanned old map.",
3782
+ helpImageTransform: `Switch to "Transform" to also show 4 corner handles you can drag independently (rubber-sheeting) -- for a scanned map that isn't printed at a uniform scale/orientation. Switching back to "Scale" just hides those 4 handles again; move/resize/rotate keep working on the image's exact current shape either way.`,
3783
+ helpImageRemove: "Click its \xD7 handle to remove it."
3784
+ };
3785
+
3786
+ // src/editor/MapEditor.tsx
3787
+ import { Fragment as Fragment2, jsx as jsx9, jsxs as jsxs7 } from "react/jsx-runtime";
3788
+ var TOOLBAR = [
3789
+ { mode: "point", label: "point" },
3790
+ { mode: "linestring", label: "line" },
3791
+ { mode: "polygon", label: "polygon" },
3792
+ { mode: "rectangle", label: "rectangle" },
3793
+ { mode: "select", label: "select" }
3794
+ ];
3795
+ var COLOR_SWATCHES = [
3796
+ "#2a78d6",
3797
+ "#d64545",
3798
+ "#3aa657",
3799
+ "#e0a325",
3800
+ "#8654c9",
3801
+ "#2aa7a0",
3802
+ "#e0678a",
3803
+ "#555555"
3804
+ ];
3805
+ var EMPTY_LABEL_PREVIEW = { type: "FeatureCollection", features: [] };
3806
+ var OVERLAY_ANCHOR_LAYER = "image-overlay-anchor";
3807
+ var TERRA_DRAW_LAYERS = ["td-polygon", "td-polygon-outline", "td-linestring", "td-point", "td-point-marker"];
3808
+ var LOCKED_SOURCE = "locked-shapes";
3809
+ var LOCKED_FILL_LAYER = "locked-shapes-fill";
3810
+ var LOCKED_LINE_LAYER = "locked-shapes-line";
3811
+ var LABEL_PREVIEW_SOURCE = "label-preview";
3812
+ var LABEL_PREVIEW_LAYER = "label-preview-text";
3813
+ var TOOLBAR_CLASS = "gc-map-editor-toolbar";
3814
+ var TOOLBAR_CSS = `.${TOOLBAR_CLASS} { pointer-events: none; } .${TOOLBAR_CLASS} > * { pointer-events: auto; }`;
3815
+ function overlayCenter(corners) {
3816
+ let lng = 0, lat = 0;
3817
+ for (const [x, y] of corners) {
3818
+ lng += x;
3819
+ lat += y;
3820
+ }
3821
+ return [lng / 4, lat / 4];
3822
+ }
3823
+ function rotateHandleAnchor(corners, center) {
3824
+ const [topLeft, topRight] = corners;
3825
+ const topMid = [(topLeft[0] + topRight[0]) / 2, (topLeft[1] + topRight[1]) / 2];
3826
+ return [center[0] + (topMid[0] - center[0]) * 1.5, center[1] + (topMid[1] - center[1]) * 1.5];
3827
+ }
3828
+ function pointInQuad(point, corners) {
3829
+ let sign = 0;
3830
+ for (let i = 0; i < corners.length; i++) {
3831
+ const a = corners[i];
3832
+ const b = corners[(i + 1) % corners.length];
3833
+ const cross = (b.x - a.x) * (point.y - a.y) - (b.y - a.y) * (point.x - a.x);
3834
+ if (cross === 0) continue;
3835
+ const s = cross > 0 ? 1 : -1;
3836
+ if (sign === 0) sign = s;
3837
+ else if (s !== sign) return false;
3838
+ }
3839
+ return true;
3840
+ }
3841
+ var OVERLAY_DRAG_OPACITY = 0.5;
3842
+ var OVERLAY_HANDLE_STYLE = {
3843
+ width: "20px",
3844
+ height: "20px",
3845
+ borderRadius: "50%",
3846
+ background: "white",
3847
+ border: "2px solid #333",
3848
+ display: "flex",
3849
+ alignItems: "center",
3850
+ justifyContent: "center",
3851
+ fontSize: "12px",
3852
+ fontWeight: "700",
3853
+ color: "#333",
3854
+ cursor: "pointer",
3855
+ boxShadow: "0 1px 3px rgba(0,0,0,0.4)",
3856
+ userSelect: "none",
3857
+ zIndex: 4
3858
+ };
3859
+ function loadImageElement(src, failed) {
3860
+ return new Promise((resolve, reject) => {
3861
+ const img = new Image();
3862
+ img.onload = () => resolve(img);
3863
+ img.onerror = () => reject(new Error(failed));
3864
+ img.src = src;
3865
+ });
3866
+ }
3867
+ function extendBounds(bounds, geometry) {
3868
+ if (geometry.type === "Point") {
3869
+ bounds.extend(geometry.coordinates);
3870
+ } else if (geometry.type === "LineString") {
3871
+ for (const c of geometry.coordinates) bounds.extend(c);
3872
+ } else if (geometry.type === "Polygon") {
3873
+ for (const ring of geometry.coordinates) for (const c of ring) bounds.extend(c);
3874
+ }
3875
+ }
3876
+ var MapEditor = forwardRef(function MapEditor2({
3877
+ initial,
3878
+ initialView,
3879
+ resolveImage,
3880
+ releaseImage,
3881
+ onAddImage,
3882
+ onStatus,
3883
+ onError,
3884
+ dark = false,
3885
+ defaultColor,
3886
+ labelColors,
3887
+ toolbarTop = 12,
3888
+ overlayNames = true,
3889
+ renderOverlayOptions,
3890
+ strings: stringOverrides
3891
+ }, ref) {
3892
+ const strings = { ...MAP_EDITOR_ENGLISH, ...stringOverrides };
3893
+ const live = useRef8({ strings, resolveImage, releaseImage, onError });
3894
+ live.current = { strings, resolveImage, releaseImage, onError };
3895
+ const fail = (message) => live.current.onError?.(message);
3896
+ const release = (url) => live.current.releaseImage?.(url);
3897
+ const [containerEl, setContainerEl] = useState7(null);
3898
+ const mapRef = useRef8(null);
3899
+ const drawRef = useRef8(null);
3900
+ const overlayMountsRef = useRef8(/* @__PURE__ */ new Map());
3901
+ const [mode, setMode] = useState7("select");
3902
+ const modeRef = useRef8(mode);
3903
+ modeRef.current = mode;
3904
+ const [ready, setReady] = useState7(false);
3905
+ const [color, setColor] = useState7(() => defaultColor ?? defaultColors(dark).mark);
3906
+ const colorRef = useRef8(color);
3907
+ colorRef.current = color;
3908
+ const [opacity, setOpacity] = useState7(0.25);
3909
+ const opacityRef = useRef8(opacity);
3910
+ opacityRef.current = opacity;
3911
+ const [labelText, setLabelText] = useState7("");
3912
+ const refreshLabelPreviewRef = useRef8(null);
3913
+ const [selectedId, setSelectedId] = useState7(null);
3914
+ const selectedIdRef = useRef8(selectedId);
3915
+ selectedIdRef.current = selectedId;
3916
+ const labelColorsRef = useRef8(labelColors ?? defaultColors(dark));
3917
+ labelColorsRef.current = labelColors ?? defaultColors(dark);
3918
+ useEffect8(() => {
3919
+ const draw = drawRef.current;
3920
+ const map = mapRef.current;
3921
+ const source = map?.getSource(LABEL_PREVIEW_SOURCE);
3922
+ const refresh = () => {
3923
+ const currentDraw = drawRef.current;
3924
+ const currentSource = mapRef.current?.getSource(LABEL_PREVIEW_SOURCE);
3925
+ if (!currentDraw || !currentSource) return;
3926
+ const features = [];
3927
+ for (const f of currentDraw.getSnapshot()) {
3928
+ if (f.geometry?.type !== "Point") continue;
3929
+ const isSelected = String(f.id) === selectedId;
3930
+ const name = (isSelected ? labelText : f.properties?.name)?.trim();
3931
+ if (!name) continue;
3932
+ features.push({
3933
+ type: "Feature",
3934
+ geometry: f.geometry,
3935
+ properties: { name, color: f.properties?.color }
3936
+ });
3937
+ }
3938
+ currentSource.setData({ type: "FeatureCollection", features });
3939
+ };
3940
+ refreshLabelPreviewRef.current = refresh;
3941
+ if (draw && source) refresh();
3942
+ }, [labelText, selectedId]);
3943
+ const [overlays, setOverlays] = useState7([]);
3944
+ const overlaysRef = useRef8(overlays);
3945
+ overlaysRef.current = overlays;
3946
+ const [selectedOverlayId, setSelectedOverlayId] = useState7(null);
3947
+ const [warpHandleIds, setWarpHandleIds] = useState7(/* @__PURE__ */ new Set());
3948
+ const [hasFeatures, setHasFeatures] = useState7(false);
3949
+ const sessionRef = useRef8(null);
3950
+ const [lockedCount, setLockedCount] = useState7(0);
3951
+ const [lockedNoticeDismissed, setLockedNoticeDismissed] = useState7(false);
3952
+ const hasDrawn = hasFeatures || overlays.length > 0;
3953
+ const hasContent = hasDrawn || lockedCount > 0;
3954
+ const onStatusRef = useRef8(onStatus);
3955
+ onStatusRef.current = onStatus;
3956
+ useEffect8(() => {
3957
+ onStatusRef.current?.({ hasDrawn, hasContent });
3958
+ }, [hasDrawn, hasContent]);
3959
+ useEffect8(() => {
3960
+ if (!containerEl) return;
3961
+ const map = new maplibregl3.Map({
3962
+ container: containerEl,
3963
+ style: mapStyleUrl(dark, null),
3964
+ center: [0, 20],
3965
+ zoom: 1.5,
3966
+ attributionControl: { compact: true }
3967
+ });
3968
+ mapRef.current = map;
3969
+ map.addControl(new maplibregl3.NavigationControl({ showCompass: false }), "top-right");
3970
+ map.dragRotate.disable();
3971
+ map.touchZoomRotate.disableRotation();
3972
+ map.keyboard.disableRotation();
3973
+ const readColor = (feature) => feature.properties.color ?? colorRef.current;
3974
+ const readOpacity = (feature) => feature.properties.opacity ?? opacityRef.current;
3975
+ const draw = new TerraDraw({
3976
+ adapter: new TerraDrawMapLibreGLAdapter({ map }),
3977
+ modes: [
3978
+ new TerraDrawPointMode({ styles: { pointColor: readColor } }),
3979
+ new TerraDrawLineStringMode({ styles: { lineStringColor: readColor } }),
3980
+ new TerraDrawPolygonMode({ styles: { fillColor: readColor, fillOpacity: readOpacity, outlineColor: readColor } }),
3981
+ new TerraDrawRectangleMode({ styles: { fillColor: readColor, fillOpacity: readOpacity, outlineColor: readColor } }),
3982
+ // Dragging (both a whole feature and its individual vertices) and
3983
+ // deletion (terra-draw's own default keybinding, Delete/Backspace
3984
+ // on the current selection) are what this editor needs from
3985
+ // Select for every mode; `rotateable` additionally turns on
3986
+ // terra-draw's own built-in rotate gesture (hold R while dragging
3987
+ // a selected shape's vertex) for anything 2D enough for "rotate"
3988
+ // to mean something -- not `point`, a single vertex has nothing to
3989
+ // rotate around.
3990
+ new TerraDrawSelectMode({
3991
+ // While a feature is selected, terra-draw renders it through
3992
+ // *this* mode's own styling, not the drawing mode that created it
3993
+ // (see TerraDraw's own per-feature style dispatch: only the
3994
+ // selected feature goes through TerraDrawSelectMode.styleFeature;
3995
+ // every other feature keeps using its own mode's, which is why an
3996
+ // unselected shape's color/opacity already reflects its own
3997
+ // properties fine). Without these, the selected-state styling
3998
+ // falls back to terra-draw's fixed defaults regardless of
3999
+ // `feature.properties` -- so dragging the opacity slider (or
4000
+ // recoloring) while a shape is actively selected had no visible
4001
+ // effect until it was deselected. Found live.
4002
+ styles: {
4003
+ selectedPointColor: readColor,
4004
+ selectedLineStringColor: readColor,
4005
+ selectedPolygonColor: readColor,
4006
+ selectedPolygonOutlineColor: readColor,
4007
+ selectedPolygonFillOpacity: readOpacity
4008
+ },
4009
+ // Half terra-draw's own default (40px) -- with labels sitting
4010
+ // right next to their point (see LABEL_PREVIEW_LAYER's
4011
+ // text-offset below) and vertices sometimes placed close
4012
+ // together, the default hit-test radius made clicking one point
4013
+ // too likely to grab a neighboring one instead. Found live.
4014
+ pointerDistance: 20,
4015
+ // Every field restated (terra-draw's own type doesn't allow a
4016
+ // partial override), only `rotate` actually changed from its
4017
+ // default -- plain "r" instead of the default Ctrl+R, an
4018
+ // awkward two-handed chord to hold through an entire drag. Bare
4019
+ // "r" is safe here the same way Backspace-delete is (see the
4020
+ // canvas keydown listener below): terra-draw's own keydown
4021
+ // listener is bound to the map canvas specifically, so it never
4022
+ // fires while typing "r" into a text field.
4023
+ keyEvents: { deselect: "Escape", delete: "Delete", rotate: ["r"], scale: ["Control", "s"] },
4024
+ flags: {
4025
+ point: { feature: { draggable: true } },
4026
+ linestring: {
4027
+ feature: {
4028
+ draggable: true,
4029
+ rotateable: true,
4030
+ coordinates: { draggable: true, deletable: true, midpoints: true }
4031
+ }
4032
+ },
4033
+ polygon: {
4034
+ feature: {
4035
+ draggable: true,
4036
+ rotateable: true,
4037
+ coordinates: { draggable: true, deletable: true, midpoints: true }
4038
+ }
4039
+ },
4040
+ rectangle: {
4041
+ feature: {
4042
+ draggable: true,
4043
+ rotateable: true,
4044
+ coordinates: { draggable: true, deletable: true, midpoints: true }
4045
+ }
4046
+ }
4047
+ }
4048
+ })
4049
+ ]
4050
+ });
4051
+ drawRef.current = draw;
4052
+ draw.on("change", () => {
4053
+ setHasFeatures(
4054
+ draw.getSnapshot().some((f) => f.geometry != null && !f.properties?.selectionPoint && !f.properties?.midPoint)
4055
+ );
4056
+ refreshLabelPreviewRef.current?.();
4057
+ });
4058
+ function syncSelection(id) {
4059
+ setSelectedId(id);
4060
+ const feature = id ? draw.getSnapshot().find((f) => String(f.id) === id) : void 0;
4061
+ setLabelText(feature?.properties?.name ?? "");
4062
+ if (feature) {
4063
+ setColor(feature.properties?.color ?? colorRef.current);
4064
+ setOpacity(feature.properties?.opacity ?? 0.25);
4065
+ setSelectedOverlayId(null);
4066
+ }
4067
+ }
4068
+ draw.on("finish", (id) => {
4069
+ const isRegion = modeRef.current === "polygon" || modeRef.current === "rectangle";
4070
+ draw.updateFeatureProperties(
4071
+ id,
4072
+ isRegion ? { color: colorRef.current, opacity: opacityRef.current } : { color: colorRef.current }
4073
+ );
4074
+ draw.setMode("select");
4075
+ setMode("select");
4076
+ draw.selectFeature(id);
4077
+ syncSelection(String(id));
4078
+ });
4079
+ draw.on("select", (id) => syncSelection(String(id)));
4080
+ draw.on("deselect", () => syncSelection(null));
4081
+ const canvas = map.getCanvas();
4082
+ const onCanvasKeyDown = (e) => {
4083
+ if (e.key !== "Backspace" || !selectedIdRef.current) return;
4084
+ try {
4085
+ draw.removeFeatures([selectedIdRef.current]);
4086
+ } catch {
4087
+ }
4088
+ setSelectedId(null);
4089
+ };
4090
+ canvas.addEventListener("keydown", onCanvasKeyDown);
4091
+ let sawNativePointerdown = false;
4092
+ let pointerBridgeActive = false;
4093
+ let pendingFirstMouseDown = null;
4094
+ const onNativePointerDown = () => {
4095
+ sawNativePointerdown = true;
4096
+ };
4097
+ canvas.addEventListener("pointerdown", onNativePointerDown, { once: true });
4098
+ const dispatchAsPointerEvent = (type, e) => {
4099
+ const synthetic = new MouseEvent(type, {
4100
+ bubbles: true,
4101
+ cancelable: true,
4102
+ view: window,
4103
+ clientX: e.clientX,
4104
+ clientY: e.clientY,
4105
+ button: e.button,
4106
+ buttons: e.buttons
4107
+ });
4108
+ Object.defineProperty(synthetic, "isPrimary", { value: true });
4109
+ canvas.dispatchEvent(synthetic);
4110
+ };
4111
+ const onMouseDownBridge = (e) => {
4112
+ if (pointerBridgeActive) {
4113
+ dispatchAsPointerEvent("pointerdown", e);
4114
+ return;
4115
+ }
4116
+ if (sawNativePointerdown) return;
4117
+ pendingFirstMouseDown = e;
4118
+ requestAnimationFrame(() => {
4119
+ if (sawNativePointerdown || !pendingFirstMouseDown) return;
4120
+ pointerBridgeActive = true;
4121
+ dispatchAsPointerEvent("pointerdown", pendingFirstMouseDown);
4122
+ pendingFirstMouseDown = null;
4123
+ });
4124
+ };
4125
+ const onMouseMoveBridge = (e) => {
4126
+ if (pointerBridgeActive) dispatchAsPointerEvent("pointermove", e);
4127
+ };
4128
+ const onMouseUpBridge = (e) => {
4129
+ if (pointerBridgeActive) dispatchAsPointerEvent("pointerup", e);
4130
+ };
4131
+ canvas.addEventListener("mousedown", onMouseDownBridge);
4132
+ canvas.addEventListener("mousemove", onMouseMoveBridge);
4133
+ canvas.addEventListener("mouseup", onMouseUpBridge);
4134
+ map.on("mousedown", (e) => {
4135
+ const target = e.originalEvent.target;
4136
+ if (target instanceof Node) {
4137
+ for (const mount of overlayMountsRef.current.values()) {
4138
+ if (mount.deleteMarker.getElement().contains(target) || mount.resizeMarker.getElement().contains(target) || mount.rotateMarker.getElement().contains(target) || mount.cornerMarkers.some((marker) => marker.getElement().contains(target))) return;
4139
+ }
4140
+ }
4141
+ if (map.queryRenderedFeatures(e.point, { layers: TERRA_DRAW_LAYERS }).length > 0) return;
4142
+ const mounts = [...overlayMountsRef.current.entries()].reverse();
4143
+ for (const [id, mount] of mounts) {
4144
+ const corners = mount.box.corners.map((c) => map.project(c));
4145
+ if (pointInQuad(e.point, corners)) {
4146
+ if (selectedIdRef.current) draw.deselectFeature(selectedIdRef.current);
4147
+ setSelectedOverlayId(id);
4148
+ mount.startMove(e);
4149
+ return;
4150
+ }
4151
+ }
4152
+ setSelectedOverlayId(null);
4153
+ });
4154
+ map.on("error", (e) => {
4155
+ const message = e.error?.message ?? "";
4156
+ if (/style|sprite|tile|fetch|network|Failed/i.test(message)) fail(message || live.current.strings.basemapFailed);
4157
+ });
4158
+ map.on("style.load", () => {
4159
+ try {
4160
+ map.addLayer({ id: OVERLAY_ANCHOR_LAYER, type: "background", paint: { "background-opacity": 0 } });
4161
+ map.addSource(LOCKED_SOURCE, { type: "geojson", data: { type: "FeatureCollection", features: [] } });
4162
+ map.addLayer({
4163
+ id: LOCKED_FILL_LAYER,
4164
+ type: "fill",
4165
+ source: LOCKED_SOURCE,
4166
+ filter: ["==", ["geometry-type"], "Polygon"],
4167
+ paint: { "fill-color": ["coalesce", ["get", "color"], "#888888"], "fill-opacity": 0.15 }
4168
+ });
4169
+ map.addLayer({
4170
+ id: LOCKED_LINE_LAYER,
4171
+ type: "line",
4172
+ source: LOCKED_SOURCE,
4173
+ filter: ["any", ["==", ["geometry-type"], "Polygon"], ["==", ["geometry-type"], "LineString"]],
4174
+ paint: { "line-color": ["coalesce", ["get", "color"], "#888888"], "line-width": 2, "line-dasharray": [2, 2] }
4175
+ });
4176
+ draw.start();
4177
+ draw.setMode("select");
4178
+ const colors = labelColorsRef.current;
4179
+ map.addSource(LABEL_PREVIEW_SOURCE, { type: "geojson", data: EMPTY_LABEL_PREVIEW });
4180
+ map.addLayer({
4181
+ id: LABEL_PREVIEW_LAYER,
4182
+ type: "symbol",
4183
+ source: LABEL_PREVIEW_SOURCE,
4184
+ layout: {
4185
+ "text-field": ["get", "name"],
4186
+ "text-size": 12,
4187
+ "text-font": ["Noto Sans Regular"],
4188
+ // Offset right rather than centered on the point -- centered
4189
+ // text sat directly on top of the point, making it hard to see
4190
+ // (and, in the real map views, click) the point itself.
4191
+ "text-anchor": "left",
4192
+ "text-offset": [0.6, 0]
4193
+ },
4194
+ paint: {
4195
+ "text-color": ["coalesce", ["get", "color"], colors.text],
4196
+ "text-halo-color": colors.surface,
4197
+ "text-halo-width": 1.5
4198
+ }
4199
+ });
4200
+ refreshLabelPreviewRef.current?.();
4201
+ } catch (err) {
4202
+ fail(err instanceof Error ? err.message : String(err));
4203
+ } finally {
4204
+ setReady(true);
4205
+ }
4206
+ });
4207
+ return () => {
4208
+ draw.stop();
4209
+ canvas.removeEventListener("keydown", onCanvasKeyDown);
4210
+ canvas.removeEventListener("pointerdown", onNativePointerDown);
4211
+ canvas.removeEventListener("mousedown", onMouseDownBridge);
4212
+ canvas.removeEventListener("mousemove", onMouseMoveBridge);
4213
+ canvas.removeEventListener("mouseup", onMouseUpBridge);
4214
+ for (const mount of overlayMountsRef.current.values()) {
4215
+ mount.deleteMarker.remove();
4216
+ mount.resizeMarker.remove();
4217
+ mount.rotateMarker.remove();
4218
+ for (const marker of mount.cornerMarkers) marker.remove();
4219
+ release(mount.imageUrl);
4220
+ }
4221
+ overlayMountsRef.current.clear();
4222
+ map.remove();
4223
+ mapRef.current = null;
4224
+ drawRef.current = null;
4225
+ };
4226
+ }, [containerEl]);
4227
+ async function mountOverlay(overlay) {
4228
+ const map = mapRef.current;
4229
+ if (!map) return;
4230
+ const sourceId = `img-${overlay.id}`;
4231
+ const box = { corners: overlay.corners };
4232
+ const imageUrl = await live.current.resolveImage(overlay.image);
4233
+ map.addSource(sourceId, { type: "image", url: imageUrl, coordinates: box.corners });
4234
+ map.addLayer({ id: sourceId, type: "raster", source: sourceId }, OVERLAY_ANCHOR_LAYER);
4235
+ map.setPaintProperty(sourceId, "raster-opacity", overlay.opacity ?? 1);
4236
+ const setCorners = (next) => {
4237
+ box.corners = next;
4238
+ map.getSource(sourceId)?.setCoordinates(box.corners);
4239
+ };
4240
+ const commit = () => {
4241
+ setOverlays((prev) => prev.map((o) => o.id === overlay.id ? { ...o, corners: box.corners } : o));
4242
+ };
4243
+ const words = live.current.strings;
4244
+ const deleteEl = document.createElement("div");
4245
+ deleteEl.textContent = "\xD7";
4246
+ deleteEl.title = words.removeImage;
4247
+ Object.assign(deleteEl.style, OVERLAY_HANDLE_STYLE);
4248
+ const deleteMarker = new maplibregl3.Marker({ element: deleteEl, anchor: "center" });
4249
+ deleteEl.addEventListener("click", (e) => {
4250
+ e.stopPropagation();
4251
+ unmountOverlay(overlay.id);
4252
+ setOverlays((prev) => prev.filter((o) => o.id !== overlay.id));
4253
+ setSelectedOverlayId((current) => current === overlay.id ? null : current);
4254
+ });
4255
+ const resizeEl = document.createElement("div");
4256
+ resizeEl.textContent = "\u2198";
4257
+ resizeEl.title = words.resizeImage;
4258
+ Object.assign(resizeEl.style, OVERLAY_HANDLE_STYLE);
4259
+ const resizeMarker = new maplibregl3.Marker({ element: resizeEl, anchor: "center", draggable: true });
4260
+ let resizeStartCorners = box.corners;
4261
+ let resizeStartDist = 1;
4262
+ resizeMarker.on("dragstart", () => {
4263
+ resizeStartCorners = box.corners;
4264
+ const center = overlayCenter(resizeStartCorners);
4265
+ const p = resizeMarker.getLngLat();
4266
+ resizeStartDist = Math.max(1e-9, mercatorDistance(center, [p.lng, p.lat]));
4267
+ });
4268
+ resizeMarker.on("drag", () => {
4269
+ const center = overlayCenter(resizeStartCorners);
4270
+ const p = resizeMarker.getLngLat();
4271
+ const scale = Math.max(0.02, mercatorDistance(center, [p.lng, p.lat]) / resizeStartDist);
4272
+ setCorners(resizeStartCorners.map((c) => scalePoint(c, center, scale)));
4273
+ repositionHandles();
4274
+ });
4275
+ resizeMarker.on("dragend", commit);
4276
+ const rotateEl = document.createElement("div");
4277
+ rotateEl.textContent = "\u21BB";
4278
+ rotateEl.title = words.rotateImage;
4279
+ Object.assign(rotateEl.style, OVERLAY_HANDLE_STYLE);
4280
+ const rotateMarker = new maplibregl3.Marker({ element: rotateEl, anchor: "center", draggable: true });
4281
+ let rotateStartCorners = box.corners;
4282
+ let rotateStartAngle = 0;
4283
+ rotateMarker.on("dragstart", () => {
4284
+ rotateStartCorners = box.corners;
4285
+ const center = overlayCenter(rotateStartCorners);
4286
+ const p = rotateMarker.getLngLat();
4287
+ rotateStartAngle = mercatorBearing(center, [p.lng, p.lat]);
4288
+ });
4289
+ rotateMarker.on("drag", () => {
4290
+ const center = overlayCenter(rotateStartCorners);
4291
+ const p = rotateMarker.getLngLat();
4292
+ const delta = mercatorBearing(center, [p.lng, p.lat]) - rotateStartAngle;
4293
+ setCorners(rotateStartCorners.map((c) => rotatePoint(c, center, delta)));
4294
+ repositionHandles();
4295
+ });
4296
+ rotateMarker.on("dragend", commit);
4297
+ const cornerMarkers = [0, 1, 2, 3].map((i) => {
4298
+ const el = document.createElement("div");
4299
+ el.title = words.warpCorner;
4300
+ Object.assign(el.style, OVERLAY_HANDLE_STYLE);
4301
+ const marker = new maplibregl3.Marker({ element: el, anchor: "center", draggable: true });
4302
+ marker.on("drag", () => {
4303
+ const corners = [...box.corners];
4304
+ const p = marker.getLngLat();
4305
+ corners[i] = [p.lng, p.lat];
4306
+ setCorners(corners);
4307
+ repositionHandles();
4308
+ });
4309
+ marker.on("dragend", commit);
4310
+ return marker;
4311
+ });
4312
+ const repositionHandles = () => {
4313
+ const [topLeft, topRight, bottomRight, bottomLeft] = box.corners;
4314
+ const center = overlayCenter(box.corners);
4315
+ deleteMarker.setLngLat(center);
4316
+ resizeMarker.setLngLat(bottomRight);
4317
+ rotateMarker.setLngLat(rotateHandleAnchor(box.corners, center));
4318
+ cornerMarkers[0].setLngLat(topLeft);
4319
+ cornerMarkers[1].setLngLat(topRight);
4320
+ cornerMarkers[2].setLngLat(bottomRight);
4321
+ cornerMarkers[3].setLngLat(bottomLeft);
4322
+ };
4323
+ const startMove = (e) => {
4324
+ e.preventDefault();
4325
+ map.dragPan.disable();
4326
+ map.setPaintProperty(sourceId, "raster-opacity", OVERLAY_DRAG_OPACITY);
4327
+ const startPoint = e.point;
4328
+ const startScreenCorners = box.corners.map((corner) => map.project(corner));
4329
+ const container = map.getContainer();
4330
+ const onMove = (moveEvent) => {
4331
+ const rect = container.getBoundingClientRect();
4332
+ const dx = moveEvent.clientX - rect.left - startPoint.x;
4333
+ const dy = moveEvent.clientY - rect.top - startPoint.y;
4334
+ const newCorners = startScreenCorners.map((screenCorner) => {
4335
+ const lngLat = map.unproject([screenCorner.x + dx, screenCorner.y + dy]);
4336
+ return [lngLat.lng, lngLat.lat];
4337
+ });
4338
+ setCorners(newCorners);
4339
+ repositionHandles();
4340
+ };
4341
+ const onUp = () => {
4342
+ document.removeEventListener("mousemove", onMove);
4343
+ document.removeEventListener("mouseup", onUp);
4344
+ map.dragPan.enable();
4345
+ const current = overlaysRef.current.find((o) => o.id === overlay.id);
4346
+ map.setPaintProperty(sourceId, "raster-opacity", current?.opacity ?? 1);
4347
+ commit();
4348
+ };
4349
+ document.addEventListener("mousemove", onMove);
4350
+ document.addEventListener("mouseup", onUp);
4351
+ };
4352
+ repositionHandles();
4353
+ overlayMountsRef.current.set(overlay.id, {
4354
+ sourceId,
4355
+ box,
4356
+ deleteMarker,
4357
+ resizeMarker,
4358
+ rotateMarker,
4359
+ cornerMarkers,
4360
+ startMove,
4361
+ setCorners,
4362
+ repositionHandles,
4363
+ imageUrl
4364
+ });
4365
+ }
4366
+ function unmountOverlay(id) {
4367
+ const map = mapRef.current;
4368
+ const mount = overlayMountsRef.current.get(id);
4369
+ if (!map || !mount) return;
4370
+ mount.deleteMarker.remove();
4371
+ mount.resizeMarker.remove();
4372
+ mount.rotateMarker.remove();
4373
+ for (const marker of mount.cornerMarkers) marker.remove();
4374
+ if (map.getLayer(mount.sourceId)) map.removeLayer(mount.sourceId);
4375
+ if (map.getSource(mount.sourceId)) map.removeSource(mount.sourceId);
4376
+ release(mount.imageUrl);
4377
+ overlayMountsRef.current.delete(id);
4378
+ }
4379
+ function setCornerHandlesVisible(id, visible) {
4380
+ setWarpHandleIds((prev) => {
4381
+ const next = new Set(prev);
4382
+ if (visible) next.add(id);
4383
+ else next.delete(id);
4384
+ return next;
4385
+ });
4386
+ }
4387
+ const showCornerHandles = selectedOverlayId !== null && warpHandleIds.has(selectedOverlayId);
4388
+ useEffect8(() => {
4389
+ const map = mapRef.current;
4390
+ if (!map) return;
4391
+ for (const [id, mount] of overlayMountsRef.current) {
4392
+ if (id !== selectedOverlayId) {
4393
+ mount.deleteMarker.remove();
4394
+ mount.resizeMarker.remove();
4395
+ mount.rotateMarker.remove();
4396
+ for (const marker of mount.cornerMarkers) marker.remove();
4397
+ continue;
4398
+ }
4399
+ mount.deleteMarker.addTo(map);
4400
+ mount.resizeMarker.addTo(map);
4401
+ mount.rotateMarker.addTo(map);
4402
+ for (const marker of mount.cornerMarkers) showCornerHandles ? marker.addTo(map) : marker.remove();
4403
+ }
4404
+ }, [selectedOverlayId, showCornerHandles]);
4405
+ const selectedOverlay = overlays.find((o) => o.id === selectedOverlayId) ?? null;
4406
+ function updateSelectedOverlay(patch) {
4407
+ if (!selectedOverlayId) return;
4408
+ setOverlays((prev) => prev.map((o) => o.id === selectedOverlayId ? { ...o, ...patch } : o));
4409
+ if (patch.opacity !== void 0) {
4410
+ const mount = overlayMountsRef.current.get(selectedOverlayId);
4411
+ if (mount) mapRef.current?.setPaintProperty(mount.sourceId, "raster-opacity", patch.opacity);
4412
+ }
4413
+ }
4414
+ const loaded = useRef8(false);
4415
+ useEffect8(() => {
4416
+ if (!ready || !initial || loaded.current) return;
4417
+ const draw = drawRef.current;
4418
+ const map = mapRef.current;
4419
+ if (!draw || !map) return;
4420
+ loaded.current = true;
4421
+ const bounds = new maplibregl3.LngLatBounds();
4422
+ if (initial.features.length > 0) {
4423
+ const session = prepareForEditing(initial.features);
4424
+ sessionRef.current = session;
4425
+ for (const geometry of showShapes(session, session)) extendBounds(bounds, geometry);
4426
+ }
4427
+ const drafts = initial.overlays.map((overlay) => ({ ...overlay, id: crypto.randomUUID() }));
4428
+ setOverlays(drafts);
4429
+ for (const draft of drafts) {
4430
+ mountOverlay(draft).catch(() => {
4431
+ });
4432
+ for (const corner of draft.corners) bounds.extend(corner);
4433
+ }
4434
+ if (!bounds.isEmpty()) map.fitBounds(bounds, { padding: 60, maxZoom: 17, duration: 0 });
4435
+ else if (initialView) map.jumpTo(initialView);
4436
+ }, [ready, initial]);
4437
+ function showShapes(session, added) {
4438
+ const draw = drawRef.current;
4439
+ const map = mapRef.current;
4440
+ if (!draw || !map) return [];
4441
+ const parts = [...added.parts];
4442
+ const results = draw.addFeatures(parts.map((part) => ({
4443
+ type: "Feature",
4444
+ geometry: part.geometry,
4445
+ properties: {
4446
+ mode: part.mode,
4447
+ sourceKey: part.sourceKey,
4448
+ color: part.color,
4449
+ name: part.name,
4450
+ ...part.mode === "polygon" ? { opacity: part.opacity } : {}
4451
+ }
4452
+ })));
4453
+ results.forEach((result, i) => {
4454
+ if (!result.valid) lockPart(session, parts[i].sourceKey);
4455
+ });
4456
+ const locked = [...session.locked.values()];
4457
+ setLockedCount(locked.length);
4458
+ map.getSource(LOCKED_SOURCE)?.setData({
4459
+ type: "FeatureCollection",
4460
+ features: locked.map((part) => ({ type: "Feature", geometry: part.geometry, properties: { color: part.color } }))
4461
+ });
4462
+ return [...parts, ...added.locked.values()].map((part) => part.geometry);
4463
+ }
4464
+ function handleModeChange(next) {
4465
+ if (!ready) return;
4466
+ setMode(next);
4467
+ setSelectedOverlayId(null);
4468
+ drawRef.current?.setMode(next);
4469
+ }
4470
+ useImperativeHandle(ref, () => ({
4471
+ getResult() {
4472
+ const savedParts = (drawRef.current?.getSnapshot() ?? []).filter((f) => f.geometry != null && !f.properties?.selectionPoint && !f.properties?.midPoint).map((f) => {
4473
+ const name = f.properties?.name?.trim();
4474
+ const regionOpacity = f.geometry.type === "Polygon" ? f.properties?.opacity ?? opacityRef.current : void 0;
4475
+ const sourceKey = f.properties?.sourceKey;
4476
+ return {
4477
+ ...typeof sourceKey === "string" ? { sourceKey } : {},
4478
+ geometry: f.geometry,
4479
+ color: f.properties?.color ?? colorRef.current,
4480
+ ...name ? { name } : {},
4481
+ ...regionOpacity !== void 0 ? { opacity: regionOpacity } : {}
4482
+ };
4483
+ });
4484
+ const session = sessionRef.current ?? { parts: [], sources: /* @__PURE__ */ new Map(), locked: /* @__PURE__ */ new Map() };
4485
+ return {
4486
+ features: assembleForSaving(session, savedParts),
4487
+ overlays: overlaysRef.current.map(({ image, corners, name, opacity: opacity2, order }) => ({ image, corners, name, opacity: opacity2, order }))
4488
+ };
4489
+ },
4490
+ drawnBounds() {
4491
+ const bounds = new maplibregl3.LngLatBounds();
4492
+ for (const f of drawRef.current?.getSnapshot() ?? []) {
4493
+ if (f.geometry == null || f.properties?.selectionPoint || f.properties?.midPoint) continue;
4494
+ extendBounds(bounds, f.geometry);
4495
+ }
4496
+ for (const overlay of overlaysRef.current) for (const corner of overlay.corners) bounds.extend(corner);
4497
+ return bounds.isEmpty() ? null : bounds.toArray().flat();
4498
+ },
4499
+ async addImage(image) {
4500
+ if (overlaysRef.current.some((o) => o.image === image)) return false;
4501
+ const map = mapRef.current;
4502
+ if (!map) return false;
4503
+ const url = await live.current.resolveImage(image);
4504
+ const element = await loadImageElement(url, live.current.strings.imageFailed).finally(() => release(url));
4505
+ const aspect = element.naturalWidth / element.naturalHeight;
4506
+ const centerScreen = map.project(map.getCenter());
4507
+ const halfWidthPx = 130;
4508
+ const halfHeightPx = halfWidthPx / aspect;
4509
+ const topLeft = map.unproject([centerScreen.x - halfWidthPx, centerScreen.y - halfHeightPx]);
4510
+ const bottomRight = map.unproject([centerScreen.x + halfWidthPx, centerScreen.y + halfHeightPx]);
4511
+ const draft = {
4512
+ id: crypto.randomUUID(),
4513
+ image,
4514
+ corners: [
4515
+ [topLeft.lng, topLeft.lat],
4516
+ [bottomRight.lng, topLeft.lat],
4517
+ [bottomRight.lng, bottomRight.lat],
4518
+ [topLeft.lng, bottomRight.lat]
4519
+ ],
4520
+ // Leaves room to insert between existing overlays later without
4521
+ // renumbering.
4522
+ order: overlaysRef.current.length * 10
4523
+ };
4524
+ setOverlays((prev) => [...prev, draft]);
4525
+ await mountOverlay(draft);
4526
+ setSelectedOverlayId(draft.id);
4527
+ return true;
4528
+ },
4529
+ addFeatures(features) {
4530
+ const map = mapRef.current;
4531
+ if (!map || features.length === 0) return;
4532
+ const session = sessionRef.current ?? { parts: [], sources: /* @__PURE__ */ new Map(), locked: /* @__PURE__ */ new Map() };
4533
+ sessionRef.current = session;
4534
+ const bounds = new maplibregl3.LngLatBounds();
4535
+ for (const geometry of showShapes(session, addToSession(session, features))) extendBounds(bounds, geometry);
4536
+ if (!bounds.isEmpty()) map.fitBounds(bounds, { padding: 60, maxZoom: 17 });
4537
+ }
4538
+ // mountOverlay and friends read only refs: a fresh handle each render
4539
+ // is fine.
4540
+ // eslint-disable-next-line react-hooks/exhaustive-deps
4541
+ }));
4542
+ const selectedFeature = selectedId ? drawRef.current?.getSnapshot().find((f) => String(f.id) === selectedId) : void 0;
4543
+ const selectedIsPoint = selectedFeature?.geometry?.type === "Point";
4544
+ const selectedIsRegion = selectedFeature?.geometry?.type === "Polygon";
4545
+ function renderModeButton(entry) {
4546
+ return /* @__PURE__ */ jsx9(
4547
+ Button3,
4548
+ {
4549
+ size: "xs",
4550
+ variant: mode === entry.mode ? "filled" : "default",
4551
+ disabled: !ready,
4552
+ onClick: () => handleModeChange(entry.mode),
4553
+ children: strings[entry.label]
4554
+ },
4555
+ entry.mode
4556
+ );
4557
+ }
4558
+ return /* @__PURE__ */ jsxs7(Box2, { style: { position: "absolute", inset: 0, overflow: "hidden" }, children: [
4559
+ /* @__PURE__ */ jsx9("style", { children: TOOLBAR_CSS }),
4560
+ /* @__PURE__ */ jsx9("div", { ref: setContainerEl, style: { position: "absolute", inset: 0 } }),
4561
+ /* @__PURE__ */ jsxs7(
4562
+ Stack4,
4563
+ {
4564
+ gap: 4,
4565
+ p: 6,
4566
+ className: TOOLBAR_CLASS,
4567
+ style: {
4568
+ position: "absolute",
4569
+ top: toolbarTop,
4570
+ left: 12,
4571
+ zIndex: 3,
4572
+ borderRadius: 8,
4573
+ background: dark ? "rgba(20,20,20,0.75)" : "rgba(255,255,255,0.85)"
4574
+ },
4575
+ children: [
4576
+ TOOLBAR.filter((entry) => entry.mode !== "select").map(renderModeButton),
4577
+ onAddImage && /* @__PURE__ */ jsx9(Button3, { size: "xs", variant: "default", disabled: !ready, onClick: onAddImage, children: strings.image }),
4578
+ TOOLBAR.filter((entry) => entry.mode === "select").map(renderModeButton),
4579
+ /* @__PURE__ */ jsx9(Divider, { my: 2 }),
4580
+ /* @__PURE__ */ jsx9(Text4, { size: "xs", fw: 600, c: "dimmed", children: strings.editOptions }),
4581
+ selectedFeature && /* @__PURE__ */ jsx9(
4582
+ ColorInput,
4583
+ {
4584
+ size: "xs",
4585
+ value: color,
4586
+ onChange: (next) => {
4587
+ setColor(next);
4588
+ const draw = drawRef.current;
4589
+ if (draw && selectedId) draw.updateFeatureProperties(selectedId, { color: next });
4590
+ },
4591
+ swatches: COLOR_SWATCHES,
4592
+ disabled: !ready,
4593
+ popoverProps: { withinPortal: true, zIndex: 1e3 }
4594
+ }
4595
+ ),
4596
+ (selectedIsPoint || selectedIsRegion) && /* @__PURE__ */ jsx9(
4597
+ TextInput4,
4598
+ {
4599
+ size: "xs",
4600
+ placeholder: selectedIsPoint ? strings.labelText : strings.regionName,
4601
+ value: labelText,
4602
+ onChange: (e) => {
4603
+ setLabelText(e.currentTarget.value);
4604
+ const draw = drawRef.current;
4605
+ if (draw && selectedId) draw.updateFeatureProperties(selectedId, { name: e.currentTarget.value });
4606
+ },
4607
+ disabled: !ready
4608
+ }
4609
+ ),
4610
+ selectedIsRegion && /* @__PURE__ */ jsxs7(Stack4, { gap: 2, children: [
4611
+ /* @__PURE__ */ jsx9(Text4, { size: "xs", c: "dimmed", children: strings.fillOpacity }),
4612
+ /* @__PURE__ */ jsx9(
4613
+ Slider,
4614
+ {
4615
+ size: "xs",
4616
+ min: 0.1,
4617
+ max: 1,
4618
+ step: 0.05,
4619
+ label: (v) => `${Math.round(v * 100)}%`,
4620
+ value: opacity,
4621
+ onChange: (v) => {
4622
+ setOpacity(v);
4623
+ const draw = drawRef.current;
4624
+ if (draw && selectedId) draw.updateFeatureProperties(selectedId, { opacity: v });
4625
+ },
4626
+ disabled: !ready
4627
+ }
4628
+ )
4629
+ ] }),
4630
+ selectedOverlayId && /* @__PURE__ */ jsx9(
4631
+ SegmentedControl,
4632
+ {
4633
+ size: "xs",
4634
+ value: showCornerHandles ? "transform" : "scale",
4635
+ onChange: (value) => setCornerHandlesVisible(selectedOverlayId, value === "transform"),
4636
+ disabled: !ready,
4637
+ data: [
4638
+ { label: strings.scale, value: "scale" },
4639
+ { label: strings.transform, value: "transform" }
4640
+ ]
4641
+ }
4642
+ ),
4643
+ selectedOverlay && /* @__PURE__ */ jsxs7(Fragment2, { children: [
4644
+ overlayNames && /* @__PURE__ */ jsx9(
4645
+ TextInput4,
4646
+ {
4647
+ size: "xs",
4648
+ placeholder: strings.overlayName,
4649
+ value: selectedOverlay.name ?? "",
4650
+ onChange: (e) => updateSelectedOverlay({ name: e.currentTarget.value }),
4651
+ disabled: !ready
4652
+ }
4653
+ ),
4654
+ /* @__PURE__ */ jsxs7(Stack4, { gap: 2, children: [
4655
+ /* @__PURE__ */ jsx9(Text4, { size: "xs", c: "dimmed", children: strings.opacity }),
4656
+ /* @__PURE__ */ jsx9(
4657
+ Slider,
4658
+ {
4659
+ size: "xs",
4660
+ min: 0.1,
4661
+ max: 1,
4662
+ step: 0.05,
4663
+ label: (v) => `${Math.round(v * 100)}%`,
4664
+ value: selectedOverlay.opacity ?? 1,
4665
+ onChange: (v) => updateSelectedOverlay({ opacity: v }),
4666
+ disabled: !ready
4667
+ }
4668
+ )
4669
+ ] }),
4670
+ /* @__PURE__ */ jsx9(
4671
+ NumberInput,
4672
+ {
4673
+ size: "xs",
4674
+ label: strings.stackingOrder,
4675
+ hideControls: false,
4676
+ value: selectedOverlay.order ?? 0,
4677
+ onChange: (v) => updateSelectedOverlay({ order: Number(v) || 0 }),
4678
+ disabled: !ready
4679
+ }
4680
+ ),
4681
+ renderOverlayOptions?.(selectedOverlay.image)
4682
+ ] })
4683
+ ]
4684
+ }
4685
+ ),
4686
+ !ready && /* @__PURE__ */ jsx9(Group4, { justify: "center", align: "center", style: { position: "absolute", inset: 0, zIndex: 2 }, children: /* @__PURE__ */ jsx9(Loader3, { size: "sm" }) }),
4687
+ lockedCount > 0 && !lockedNoticeDismissed && /* @__PURE__ */ jsx9(
4688
+ Alert,
4689
+ {
4690
+ color: "yellow",
4691
+ title: lockedCount === 1 ? strings.lockedOne : strings.lockedMany(lockedCount),
4692
+ withCloseButton: true,
4693
+ onClose: () => setLockedNoticeDismissed(true),
4694
+ style: { position: "absolute", bottom: 12, right: 12, zIndex: 3, maxWidth: 360 },
4695
+ children: strings.lockedExplain
4696
+ }
4697
+ )
4698
+ ] });
4699
+ });
4700
+ function MapEditorHelp({ strings: stringOverrides, images = true }) {
4701
+ const strings = { ...MAP_EDITOR_ENGLISH, ...stringOverrides };
4702
+ return /* @__PURE__ */ jsxs7(Stack4, { gap: "md", children: [
4703
+ /* @__PURE__ */ jsx9(Text4, { size: "sm", fw: 600, children: strings.helpDrawing }),
4704
+ /* @__PURE__ */ jsxs7(List, { spacing: 4, size: "sm", children: [
4705
+ /* @__PURE__ */ jsx9(List.Item, { children: strings.helpPoint }),
4706
+ /* @__PURE__ */ jsxs7(List.Item, { children: [
4707
+ strings.helpLine,
4708
+ " ",
4709
+ /* @__PURE__ */ jsx9(Kbd, { children: "Enter" }),
4710
+ " ",
4711
+ strings.helpEnterFinishes,
4712
+ " ",
4713
+ /* @__PURE__ */ jsx9(Kbd, { children: "Esc" }),
4714
+ " ",
4715
+ strings.helpEscCancels
4716
+ ] }),
4717
+ /* @__PURE__ */ jsx9(List.Item, { children: strings.helpRectangle }),
4718
+ images && /* @__PURE__ */ jsxs7(List.Item, { children: [
4719
+ strings.helpImage,
4720
+ " ",
4721
+ /* @__PURE__ */ jsx9(Text4, { span: true, fw: 600, children: strings.helpImages }),
4722
+ " ",
4723
+ strings.helpBelow
4724
+ ] })
4725
+ ] }),
4726
+ /* @__PURE__ */ jsx9(Text4, { size: "sm", fw: 600, children: strings.helpEditing }),
4727
+ /* @__PURE__ */ jsxs7(List, { spacing: 4, size: "sm", children: [
4728
+ /* @__PURE__ */ jsx9(List.Item, { children: strings.helpSelect }),
4729
+ /* @__PURE__ */ jsx9(List.Item, { children: strings.helpVertex }),
4730
+ /* @__PURE__ */ jsx9(List.Item, { children: strings.helpColor }),
4731
+ /* @__PURE__ */ jsxs7(List.Item, { children: [
4732
+ strings.helpDelete,
4733
+ " ",
4734
+ /* @__PURE__ */ jsx9(Kbd, { children: "Delete" }),
4735
+ " ",
4736
+ strings.helpOr,
4737
+ " ",
4738
+ /* @__PURE__ */ jsx9(Kbd, { children: "Backspace" }),
4739
+ "."
4740
+ ] }),
4741
+ /* @__PURE__ */ jsxs7(List.Item, { children: [
4742
+ strings.helpRotate,
4743
+ " ",
4744
+ /* @__PURE__ */ jsx9(Kbd, { children: "R" }),
4745
+ " ",
4746
+ strings.helpRotateHandle
4747
+ ] })
4748
+ ] }),
4749
+ images && /* @__PURE__ */ jsxs7(Fragment2, { children: [
4750
+ /* @__PURE__ */ jsx9(Text4, { size: "sm", fw: 600, children: strings.helpImages }),
4751
+ /* @__PURE__ */ jsxs7(List, { spacing: 4, size: "sm", children: [
4752
+ /* @__PURE__ */ jsx9(List.Item, { children: strings.helpImageMove }),
4753
+ /* @__PURE__ */ jsx9(List.Item, { children: strings.helpImageResize }),
4754
+ /* @__PURE__ */ jsx9(List.Item, { children: strings.helpImageRotate }),
4755
+ /* @__PURE__ */ jsx9(List.Item, { children: strings.helpImageTransform }),
4756
+ /* @__PURE__ */ jsx9(List.Item, { children: strings.helpImageRemove })
4757
+ ] })
4758
+ ] })
4759
+ ] });
4760
+ }
4761
+
4762
+ // src/editor/placeShapes.ts
4763
+ function placeFeatures(place) {
4764
+ const geometry = place?.geometry;
4765
+ if (!geometry) return [];
4766
+ if (geometry.type === "FeatureCollection") {
4767
+ return (geometry.features ?? []).filter((f) => f && f.geometry);
4768
+ }
4769
+ if (geometry.type === "Feature") return geometry.geometry ? [geometry] : [];
4770
+ return [{ type: "Feature", properties: {}, geometry }];
4771
+ }
4772
+ function placeToEditor(place) {
4773
+ return {
4774
+ features: placeFeatures(place),
4775
+ overlays: (place?.overlays ?? []).map((o) => ({
4776
+ image: o.media,
4777
+ corners: o.corners,
4778
+ ...o.opacity !== void 0 ? { opacity: o.opacity } : {},
4779
+ ...o.order !== void 0 ? { order: o.order } : {}
4780
+ }))
4781
+ };
4782
+ }
4783
+ function editorToPlace(place, result, dates) {
4784
+ const before = place?.geometry;
4785
+ const geometry = result.features.length === 0 ? void 0 : { ...before?.type === "FeatureCollection" ? before : {}, type: "FeatureCollection", features: result.features };
4786
+ const previous = new Map((place?.overlays ?? []).map((o) => [o.media, o]));
4787
+ const overlays = result.overlays.map((o) => {
4788
+ const { opacity: _o, order: _r, date: _d, ...kept } = previous.get(o.image) ?? {};
4789
+ const date = dates.has(o.image) ? dates.get(o.image) : previous.get(o.image)?.date;
4790
+ return {
4791
+ ...kept,
4792
+ media: o.image,
4793
+ corners: o.corners,
4794
+ ...o.opacity !== void 0 && o.opacity !== 1 ? { opacity: o.opacity } : {},
4795
+ ...o.order !== void 0 ? { order: Math.round(o.order) } : {},
4796
+ ...date ? { date } : {}
4797
+ };
4798
+ });
4799
+ return patchPlace(place, {
4800
+ geometry,
4801
+ overlays: overlays.length ? overlays : void 0
4802
+ });
4803
+ }
4804
+
4805
+ // src/editor/PlaceMapDialog.tsx
4806
+ import { jsx as jsx10, jsxs as jsxs8 } from "react/jsx-runtime";
4807
+ var BAR_HEIGHT2 = 56;
4808
+ var STORY_MAP_WORDS = {
4809
+ helpImage: "Image: add a picture file, such as a scan of an old map -- see"
4810
+ };
4811
+ async function prepareOverlay(file) {
4812
+ const image = await optimizeImage(file, { purpose: "overlay" });
4813
+ return { data: new Uint8Array(await image.blob.arrayBuffer()), mime: image.mime, ext: image.ext, width: image.width, height: image.height };
4814
+ }
4815
+ function OverlayDateField({ value, language, parse, strings, onChange }) {
4816
+ const [text, setText] = useState8(value?.text ?? "");
4817
+ const request = useRef9(0);
4818
+ const edtf = value?.edtf;
4819
+ return /* @__PURE__ */ jsxs8(Stack5, { gap: 2, maw: 220, children: [
4820
+ /* @__PURE__ */ jsx10(
4821
+ TextInput5,
4822
+ {
4823
+ size: "xs",
4824
+ label: strings.overlayDate,
4825
+ description: strings.overlayDateHint,
4826
+ value: text,
4827
+ onChange: (e) => {
4828
+ const typed = e.currentTarget.value;
4829
+ setText(typed);
4830
+ const id = ++request.current;
4831
+ if (!typed.trim()) {
4832
+ onChange(void 0);
4833
+ return;
4834
+ }
4835
+ void parse(typed, language).then((parsed) => {
4836
+ if (id === request.current) onChange(slideDate(value, typed, parsed));
4837
+ });
4838
+ }
4839
+ }
4840
+ ),
4841
+ text.trim() && /* @__PURE__ */ jsx10(Text5, { size: "xs", c: edtf && edtfYearRange(edtf) ? "dimmed" : "red", children: edtf && edtfYearRange(edtf) ? `EDTF ${edtf}` : strings.preciseDateNotUnderstood })
4842
+ ] });
4843
+ }
4844
+ function PlaceMapDialog({
4845
+ place,
4846
+ doc,
4847
+ files,
4848
+ dark,
4849
+ parseDateText,
4850
+ strings,
4851
+ mapStrings: mapStringOverrides,
4852
+ onCancel,
4853
+ onDone
4854
+ }) {
4855
+ const mapStrings = { ...STORY_MAP_WORDS, ...mapStringOverrides };
4856
+ const editorRef = useRef9(null);
4857
+ const initial = useMemo3(() => placeToEditor(place), [place]);
4858
+ const [work, setWork] = useState8({ doc, files });
4859
+ const workRef = useRef9(work);
4860
+ workRef.current = work;
4861
+ const [dates, setDates] = useState8(() => /* @__PURE__ */ new Map());
4862
+ const [message, setMessage] = useState8(null);
4863
+ const [helpOpen, setHelpOpen] = useState8(false);
4864
+ const imageInput = useRef9(null);
4865
+ const shapesInput = useRef9(null);
4866
+ const initialView = useMemo3(() => {
4867
+ if (place?.lat !== void 0 && place.lon !== void 0) return { center: [place.lon, place.lat], zoom: 12 };
4868
+ const all = storyPlaces(doc.slides.map((s2) => s2.place));
4869
+ if (!all) return void 0;
4870
+ const [w, s, e, n] = all.bounds;
4871
+ return { center: [(w + e) / 2, (s + n) / 2], zoom: 5 };
4872
+ }, [place, doc]);
4873
+ const resolveImage = async (key) => {
4874
+ const item = Object.prototype.hasOwnProperty.call(workRef.current.doc.media, key) ? workRef.current.doc.media[key] : void 0;
4875
+ const data = item && workRef.current.files.get(item.src);
4876
+ if (!item || !data) throw new Error(strings.photoFailed(key));
4877
+ return URL.createObjectURL(new Blob([data], { type: item.mime }));
4878
+ };
4879
+ const addImages = async (chosen) => {
4880
+ setMessage(null);
4881
+ for (const file of chosen) {
4882
+ let key;
4883
+ try {
4884
+ const added = addImage(workRef.current.doc, workRef.current.files, await prepareOverlay(file), "map");
4885
+ workRef.current = { doc: added.doc, files: added.files };
4886
+ setWork(workRef.current);
4887
+ key = added.key;
4888
+ } catch {
4889
+ setMessage(strings.photoFailed(file.name || "the image"));
4890
+ continue;
4891
+ }
4892
+ try {
4893
+ if (!await editorRef.current?.addImage(key)) setMessage(strings.overlayAlready);
4894
+ } catch (err) {
4895
+ setMessage(err instanceof Error ? err.message : String(err));
4896
+ }
4897
+ }
4898
+ };
4899
+ const importShapes = async (chosen) => {
4900
+ setMessage(null);
4901
+ for (const file of chosen) {
4902
+ try {
4903
+ const features = parseMapFile(await file.text()).filter((f) => f.properties?.["@geometry-type"] !== "groundoverlay");
4904
+ if (features.length === 0) setMessage(strings.importNothing(file.name));
4905
+ else editorRef.current?.addFeatures(features);
4906
+ } catch {
4907
+ setMessage(strings.importFailed(file.name));
4908
+ }
4909
+ }
4910
+ };
4911
+ const done = () => {
4912
+ const editor = editorRef.current;
4913
+ if (!editor) return;
4914
+ onDone(editorToPlace(place, editor.getResult(), dates), workRef.current.doc.media, workRef.current.files);
4915
+ };
4916
+ const bar = dark ? "rgba(20,20,20,0.85)" : "rgba(255,255,255,0.9)";
4917
+ return /* @__PURE__ */ jsxs8(Modal, { opened: true, onClose: onCancel, fullScreen: true, withCloseButton: false, closeOnEscape: false, padding: 0, children: [
4918
+ /* @__PURE__ */ jsxs8(Box3, { style: { position: "fixed", inset: 0, overflow: "hidden" }, children: [
4919
+ /* @__PURE__ */ jsx10(
4920
+ MapEditor,
4921
+ {
4922
+ ref: editorRef,
4923
+ initial,
4924
+ initialView,
4925
+ resolveImage,
4926
+ releaseImage: (url) => URL.revokeObjectURL(url),
4927
+ onAddImage: () => imageInput.current?.click(),
4928
+ onError: setMessage,
4929
+ dark,
4930
+ toolbarTop: BAR_HEIGHT2 + 12,
4931
+ overlayNames: false,
4932
+ strings: mapStrings,
4933
+ renderOverlayOptions: (key) => /* @__PURE__ */ jsx10(
4934
+ OverlayDateField,
4935
+ {
4936
+ value: dates.has(key) ? dates.get(key) : place?.overlays?.find((o) => o.media === key)?.date,
4937
+ language: doc.language,
4938
+ parse: parseDateText,
4939
+ strings,
4940
+ onChange: (date) => setDates((prev) => new Map(prev).set(key, date))
4941
+ },
4942
+ key
4943
+ )
4944
+ }
4945
+ ),
4946
+ /* @__PURE__ */ jsxs8(
4947
+ Group5,
4948
+ {
4949
+ justify: "space-between",
4950
+ wrap: "nowrap",
4951
+ px: "md",
4952
+ gap: "sm",
4953
+ style: { position: "absolute", top: 0, left: 0, right: 0, height: BAR_HEIGHT2, zIndex: 3, background: bar },
4954
+ children: [
4955
+ /* @__PURE__ */ jsxs8(Group5, { gap: 6, wrap: "nowrap", style: { minWidth: 0 }, children: [
4956
+ /* @__PURE__ */ jsx10(Text5, { fw: 600, truncate: true, dir: "auto", children: strings.mapDialogTitle(place?.name) }),
4957
+ /* @__PURE__ */ jsx10(Tooltip3, { label: strings.mapHelp, children: /* @__PURE__ */ jsx10(ActionIcon2, { variant: "subtle", radius: "xl", "aria-label": strings.mapHelp, onClick: () => setHelpOpen(true), children: "\u24D8" }) })
4958
+ ] }),
4959
+ /* @__PURE__ */ jsxs8(Group5, { gap: "xs", wrap: "nowrap", children: [
4960
+ /* @__PURE__ */ jsx10(Tooltip3, { label: strings.importShapesHint, multiline: true, w: 280, children: /* @__PURE__ */ jsx10(Button4, { variant: "default", size: "sm", onClick: () => shapesInput.current?.click(), children: strings.importShapes }) }),
4961
+ /* @__PURE__ */ jsx10(Button4, { variant: "default", size: "sm", onClick: onCancel, children: strings.cancel }),
4962
+ /* @__PURE__ */ jsx10(Button4, { size: "sm", onClick: done, children: strings.done })
4963
+ ] })
4964
+ ]
4965
+ }
4966
+ ),
4967
+ message && /* @__PURE__ */ jsx10(
4968
+ Text5,
4969
+ {
4970
+ size: "sm",
4971
+ role: "alert",
4972
+ style: {
4973
+ position: "absolute",
4974
+ bottom: 12,
4975
+ left: 12,
4976
+ zIndex: 3,
4977
+ maxWidth: 480,
4978
+ padding: "6px 10px",
4979
+ borderRadius: 6,
4980
+ background: bar,
4981
+ color: "var(--mantine-color-red-filled)"
4982
+ },
4983
+ children: message
4984
+ }
4985
+ ),
4986
+ /* @__PURE__ */ jsx10(
4987
+ "input",
4988
+ {
4989
+ ref: imageInput,
4990
+ type: "file",
4991
+ accept: "image/*",
4992
+ multiple: true,
4993
+ hidden: true,
4994
+ onChange: (e) => {
4995
+ const chosen = [...e.currentTarget.files ?? []];
4996
+ e.currentTarget.value = "";
4997
+ void addImages(chosen);
4998
+ }
4999
+ }
5000
+ ),
5001
+ /* @__PURE__ */ jsx10(
5002
+ "input",
5003
+ {
5004
+ ref: shapesInput,
5005
+ type: "file",
5006
+ accept: ".kml,.gpx,.geojson,.json,application/vnd.google-earth.kml+xml,application/gpx+xml,application/geo+json",
5007
+ multiple: true,
5008
+ hidden: true,
5009
+ onChange: (e) => {
5010
+ const chosen = [...e.currentTarget.files ?? []];
5011
+ e.currentTarget.value = "";
5012
+ void importShapes(chosen);
5013
+ }
5014
+ }
5015
+ )
5016
+ ] }),
5017
+ /* @__PURE__ */ jsx10(Modal, { opened: helpOpen, onClose: () => setHelpOpen(false), title: strings.mapHelp, size: "md", zIndex: 1e3, children: /* @__PURE__ */ jsx10(MapEditorHelp, { strings: mapStrings }) })
5018
+ ] });
5019
+ }
5020
+
5021
+ // src/editor/placeSearch.ts
5022
+ var WIKIDATA_API = "https://www.wikidata.org/w/api.php";
5023
+ var WIKIDATA_ENTITY_DATA = "https://www.wikidata.org/wiki/Special:EntityData";
5024
+ function wikidataLanguage(language) {
5025
+ return (language ?? "").trim().toLowerCase().replace(/_/g, "-") || "en";
5026
+ }
5027
+ async function searchWikidataPlaces(term, language, signal) {
5028
+ const code = wikidataLanguage(language);
5029
+ const params = new URLSearchParams({
5030
+ action: "wbsearchentities",
5031
+ search: term,
5032
+ language: code,
5033
+ uselang: code,
5034
+ format: "json",
5035
+ limit: "7",
5036
+ origin: "*"
5037
+ // what makes an anonymous api.php GET answer cross-origin
5038
+ });
5039
+ const res = await fetch(`${WIKIDATA_API}?${params}`, { signal });
5040
+ if (!res.ok) throw new Error(`Wikidata search failed (${res.status})`);
5041
+ const data = await res.json();
5042
+ return (data.search ?? []).map((hit) => ({
5043
+ id: hit.id,
5044
+ label: hit.label ?? hit.id,
5045
+ ...hit.description ? { description: hit.description } : {}
5046
+ }));
5047
+ }
5048
+ async function wikidataPoint(result, signal) {
5049
+ const res = await fetch(`${WIKIDATA_ENTITY_DATA}/${encodeURIComponent(result.id)}.json`, { signal });
5050
+ if (!res.ok) throw new Error(`Wikidata fetch failed for ${result.id} (${res.status})`);
5051
+ const data = await res.json();
5052
+ const entity = data.entities?.[result.id] ?? Object.values(data.entities ?? {})[0];
5053
+ const value = entity?.claims?.P625?.[0]?.mainsnak?.datavalue?.value;
5054
+ if (typeof value?.latitude !== "number" || typeof value.longitude !== "number") return null;
5055
+ return { lat: value.latitude, lon: value.longitude };
5056
+ }
5057
+ var WIKIDATA_PLACE_SEARCH = { search: searchWikidataPlaces, point: wikidataPoint };
5058
+
5059
+ // src/editor/SlideList.tsx
5060
+ import { useState as useState9 } from "react";
5061
+ import { Stack as Stack6, UnstyledButton as UnstyledButton2 } from "@mantine/core";
5062
+ import { jsx as jsx11 } from "react/jsx-runtime";
3256
5063
  function SlideList({ items, selected, onSelect, onMove, getKey, renderItem, label }) {
3257
- const [dragging, setDragging] = useState6(null);
3258
- const [over, setOver] = useState6(null);
5064
+ const [dragging, setDragging] = useState9(null);
5065
+ const [over, setOver] = useState9(null);
3259
5066
  const end = () => {
3260
5067
  setDragging(null);
3261
5068
  setOver(null);
3262
5069
  };
3263
- return /* @__PURE__ */ jsx7(Stack3, { gap: 4, role: "listbox", "aria-label": label, children: items.map((item, i) => {
5070
+ return /* @__PURE__ */ jsx11(Stack6, { gap: 4, role: "listbox", "aria-label": label, children: items.map((item, i) => {
3264
5071
  const isSelected = i === selected;
3265
5072
  const dropLine = over === i && dragging !== null && dragging !== i ? dragging < i ? { borderBottom: "2px solid var(--mantine-primary-color-filled)" } : { borderTop: "2px solid var(--mantine-primary-color-filled)" } : {};
3266
- return /* @__PURE__ */ jsx7(
3267
- UnstyledButton,
5073
+ return /* @__PURE__ */ jsx11(
5074
+ UnstyledButton2,
3268
5075
  {
3269
5076
  role: "option",
3270
5077
  "aria-selected": isSelected,
@@ -3303,9 +5110,9 @@ function SlideList({ items, selected, onSelect, onMove, getKey, renderItem, labe
3303
5110
  }
3304
5111
 
3305
5112
  // src/editor/StoryTextField.tsx
3306
- import { useRef as useRef6 } from "react";
3307
- import { Button as Button2, Group as Group3, Stack as Stack4, Text as Text3, Textarea } from "@mantine/core";
3308
- import { jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
5113
+ import { useRef as useRef10 } from "react";
5114
+ import { Button as Button5, Group as Group6, Stack as Stack7, Text as Text6, Textarea } from "@mantine/core";
5115
+ import { jsx as jsx12, jsxs as jsxs9 } from "react/jsx-runtime";
3309
5116
  function applyMarker(el, marker, value, onChange) {
3310
5117
  const start = el.selectionStart ?? value.length;
3311
5118
  const end = el.selectionEnd ?? value.length;
@@ -3323,12 +5130,12 @@ function applyMarker(el, marker, value, onChange) {
3323
5130
  onChange(value.slice(0, start) + replacement + value.slice(end));
3324
5131
  }
3325
5132
  function StoryTextField({ label, value, onChange, boldLabel = "Bold", italicLabel = "Italic", minRows = 3 }) {
3326
- const ref = useRef6(null);
3327
- return /* @__PURE__ */ jsxs6(Stack4, { gap: 4, children: [
3328
- /* @__PURE__ */ jsxs6(Group3, { gap: "xs", children: [
3329
- /* @__PURE__ */ jsx8(Text3, { size: "sm", fw: 500, children: label }),
3330
- /* @__PURE__ */ jsx8(
3331
- Button2,
5133
+ const ref = useRef10(null);
5134
+ return /* @__PURE__ */ jsxs9(Stack7, { gap: 4, children: [
5135
+ /* @__PURE__ */ jsxs9(Group6, { gap: "xs", children: [
5136
+ /* @__PURE__ */ jsx12(Text6, { size: "sm", fw: 500, children: label }),
5137
+ /* @__PURE__ */ jsx12(
5138
+ Button5,
3332
5139
  {
3333
5140
  variant: "default",
3334
5141
  size: "compact-xs",
@@ -3339,8 +5146,8 @@ function StoryTextField({ label, value, onChange, boldLabel = "Bold", italicLabe
3339
5146
  children: "B"
3340
5147
  }
3341
5148
  ),
3342
- /* @__PURE__ */ jsx8(
3343
- Button2,
5149
+ /* @__PURE__ */ jsx12(
5150
+ Button5,
3344
5151
  {
3345
5152
  variant: "default",
3346
5153
  size: "compact-xs",
@@ -3352,7 +5159,7 @@ function StoryTextField({ label, value, onChange, boldLabel = "Bold", italicLabe
3352
5159
  }
3353
5160
  )
3354
5161
  ] }),
3355
- /* @__PURE__ */ jsx8(
5162
+ /* @__PURE__ */ jsx12(
3356
5163
  Textarea,
3357
5164
  {
3358
5165
  ref,
@@ -3399,6 +5206,34 @@ var EDITOR_ENGLISH = {
3399
5206
  matchDateAsWritten: "Work it out from the date",
3400
5207
  noPreciseDate: "None: this slide stays off the timeline.",
3401
5208
  place: "Place",
5209
+ placeName: "Name",
5210
+ removePlace: "Remove place",
5211
+ findPlace: "Find on Wikidata",
5212
+ findPlaceHint: "Looks the name up on Wikidata, to put the pin there. The name is all that's sent, and only when you ask.",
5213
+ findPlaceNone: "Wikidata has no place by that name.",
5214
+ findPlaceFailed: "Couldn't reach Wikidata.",
5215
+ findPlaceNoPoint: (name) => `Wikidata has no map position for ${name}.`,
5216
+ coordinates: "Latitude, longitude",
5217
+ coordinatesHint: "e.g. 47.3769, 8.5417",
5218
+ coordinatesInvalid: "Two numbers: latitude (-90 to 90), then longitude (-180 to 180).",
5219
+ placeMap: "Map: click to put the pin",
5220
+ placeMapHint: "Click the map to put the pin there, or drag the pin.",
5221
+ placeShape: "a shape",
5222
+ placeOverlays: (count) => count === 1 ? "a map overlay" : `${count} map overlays`,
5223
+ placeKeeps: (what) => `This place also has ${what}.`,
5224
+ editMap: "Edit map",
5225
+ editMapHint: "Shapes under the pin (a parish, a route) and scans of old maps laid over it.",
5226
+ mapDialogTitle: (placeName) => placeName ? `Map: ${placeName}` : "Map",
5227
+ mapHelp: "How to use this editor",
5228
+ importShapes: "Import shapes",
5229
+ importShapesHint: "Adds the points, lines and areas in a KML, GPX or GeoJSON file to this map.",
5230
+ importFailed: (fileName) => `Couldn't read ${fileName} as a KML, GPX or GeoJSON file.`,
5231
+ importNothing: (fileName) => `${fileName} has no shapes in it.`,
5232
+ overlayAlready: "That image is already on this map.",
5233
+ overlayDate: "Years it shows",
5234
+ overlayDateHint: "e.g. 1850, or 1850 to 1880. With historical maps, it's hidden on slides outside them.",
5235
+ cancel: "Cancel",
5236
+ done: "Done",
3402
5237
  photos: "Photos",
3403
5238
  photosHint: "Add, drop or paste a photo. It's resized for the screen, and hidden details like camera and location are left out.",
3404
5239
  addPhoto: "Add photo",
@@ -3421,8 +5256,8 @@ var EDITOR_ENGLISH = {
3421
5256
  };
3422
5257
 
3423
5258
  // src/editor/StoryEditor.tsx
3424
- import { jsx as jsx9, jsxs as jsxs7 } from "react/jsx-runtime";
3425
- var BAR_HEIGHT2 = 56;
5259
+ import { jsx as jsx13, jsxs as jsxs10 } from "react/jsx-runtime";
5260
+ var BAR_HEIGHT3 = 56;
3426
5261
  var STORY = -1;
3427
5262
  function issueSlide(issue) {
3428
5263
  const match = /^\/slides\/(\d+)/.exec(issue.path);
@@ -3435,8 +5270,8 @@ async function prepareImage(file) {
3435
5270
  function Thumb({ doc, slide, resolveMedia }) {
3436
5271
  const ref = slide.media?.[0]?.ref;
3437
5272
  const item = ref && Object.prototype.hasOwnProperty.call(doc.media, ref) ? doc.media[ref] : void 0;
3438
- const [url, setUrl] = useState7(null);
3439
- useEffect6(() => {
5273
+ const [url, setUrl] = useState10(null);
5274
+ useEffect9(() => {
3440
5275
  let live = true;
3441
5276
  setUrl(null);
3442
5277
  if (ref && item) void resolveMedia(ref, item).then((u) => {
@@ -3446,13 +5281,13 @@ function Thumb({ doc, slide, resolveMedia }) {
3446
5281
  live = false;
3447
5282
  };
3448
5283
  }, [ref, item, resolveMedia]);
3449
- return /* @__PURE__ */ jsx9(
3450
- Box2,
5284
+ return /* @__PURE__ */ jsx13(
5285
+ Box4,
3451
5286
  {
3452
5287
  w: 40,
3453
5288
  h: 40,
3454
5289
  style: { flex: "none", borderRadius: 4, background: "var(--mantine-color-default-hover)", overflow: "hidden" },
3455
- children: url && /* @__PURE__ */ jsx9("img", { src: url, alt: "", style: { width: "100%", height: "100%", objectFit: "cover", display: "block" } })
5290
+ children: url && /* @__PURE__ */ jsx13("img", { src: url, alt: "", style: { width: "100%", height: "100%", objectFit: "cover", display: "block" } })
3456
5291
  }
3457
5292
  );
3458
5293
  }
@@ -3466,24 +5301,26 @@ function StoryEditor({
3466
5301
  dark = false,
3467
5302
  strings: stringOverrides,
3468
5303
  playerStrings,
3469
- parseDateText = suggestEdtf
5304
+ parseDateText = suggestEdtf,
5305
+ placeSearch = WIKIDATA_PLACE_SEARCH,
5306
+ mapStrings
3470
5307
  }) {
3471
- const strings = useMemo2(() => ({ ...EDITOR_ENGLISH, ...stringOverrides }), [stringOverrides]);
3472
- const [history, setHistory] = useState7(() => startHistory(initialStory));
3473
- const [selected, setSelected] = useState7(0);
3474
- const [presenting, setPresenting] = useState7(false);
3475
- const [problemsOpen, setProblemsOpen] = useState7(false);
5308
+ const strings = useMemo4(() => ({ ...EDITOR_ENGLISH, ...stringOverrides }), [stringOverrides]);
5309
+ const [history, setHistory] = useState10(() => startHistory(initialStory));
5310
+ const [selected, setSelected] = useState10(0);
5311
+ const [presenting, setPresenting] = useState10(false);
5312
+ const [problemsOpen, setProblemsOpen] = useState10(false);
3476
5313
  const wide = useMediaQuery("(min-width: 1000px)", true);
3477
5314
  const doc = history.present;
3478
- const [files, setFiles] = useState7(() => new Map(initialFiles ?? []));
3479
- const [busy, setBusy] = useState7(false);
3480
- const [photoError, setPhotoError] = useState7(null);
3481
- const docRef = useRef7(doc);
5315
+ const [files, setFiles] = useState10(() => new Map(initialFiles ?? []));
5316
+ const [busy, setBusy] = useState10(false);
5317
+ const [photoError, setPhotoError] = useState10(null);
5318
+ const docRef = useRef11(doc);
3482
5319
  docRef.current = doc;
3483
- const filesRef = useRef7(files);
5320
+ const filesRef = useRef11(files);
3484
5321
  filesRef.current = files;
3485
- const urls = useRef7(/* @__PURE__ */ new Map());
3486
- useEffect6(() => () => {
5322
+ const urls = useRef11(/* @__PURE__ */ new Map());
5323
+ useEffect9(() => () => {
3487
5324
  for (const url of urls.current.values()) URL.revokeObjectURL(url);
3488
5325
  }, []);
3489
5326
  const resolveMedia = useCallback(async (_key, item) => {
@@ -3502,7 +5339,7 @@ function StoryEditor({
3502
5339
  const apply = useCallback((next, key) => {
3503
5340
  setHistory((h) => record(h, next, key));
3504
5341
  }, []);
3505
- useEffect6(() => {
5342
+ useEffect9(() => {
3506
5343
  onChange?.(doc, files);
3507
5344
  }, [doc, files, onChange]);
3508
5345
  const index = selected === STORY ? STORY : Math.min(selected, doc.slides.length - 1);
@@ -3510,7 +5347,20 @@ function StoryEditor({
3510
5347
  const setSlide = (patch, key) => {
3511
5348
  if (index !== STORY) apply(updateSlide(history.present, index, patch), `${slide.id}:${key}`);
3512
5349
  };
3513
- const issues = useMemo2(() => validate2(doc, files).errors, [doc, files]);
5350
+ const setPlace = (slideId2, update, key) => {
5351
+ setHistory((h) => {
5352
+ const at = h.present.slides.findIndex((s) => s.id === slideId2);
5353
+ if (at < 0) return h;
5354
+ const before = h.present.slides[at].place;
5355
+ const after = update(before);
5356
+ return after === before ? h : record(h, updateSlide(h.present, at, { place: after }), `${slideId2}:${key}`);
5357
+ });
5358
+ };
5359
+ const [mapSlideId, setMapSlideId] = useState10(null);
5360
+ const mapSlide = mapSlideId ? doc.slides.find((s) => s.id === mapSlideId) : void 0;
5361
+ const placeChoices = useMemo4(() => storyPlaceChoices(doc, index), [doc, index]);
5362
+ const around = useMemo4(() => storyPlaces(doc.slides.map((s) => s.place))?.bounds ?? null, [doc.slides]);
5363
+ const issues = useMemo4(() => validate2(doc, files).errors, [doc, files]);
3514
5364
  const addPhotos = useCallback(async (chosen, slideId2, replace) => {
3515
5365
  setBusy(true);
3516
5366
  setPhotoError(null);
@@ -3547,7 +5397,7 @@ function StoryEditor({
3547
5397
  apply(setSlidePhotos(next, at, photos));
3548
5398
  }, [apply, prepare, strings]);
3549
5399
  const slideId = slide?.id;
3550
- useEffect6(() => {
5400
+ useEffect9(() => {
3551
5401
  if (!slideId) return;
3552
5402
  const onPaste = (e) => {
3553
5403
  if (e.target?.closest("input, textarea, [contenteditable=true]")) return;
@@ -3559,7 +5409,7 @@ function StoryEditor({
3559
5409
  window.addEventListener("paste", onPaste);
3560
5410
  return () => window.removeEventListener("paste", onPaste);
3561
5411
  }, [slideId, addPhotos]);
3562
- useEffect6(() => {
5412
+ useEffect9(() => {
3563
5413
  const onKey = (e) => {
3564
5414
  if (!(e.ctrlKey || e.metaKey) || e.altKey) return;
3565
5415
  const target = e.target;
@@ -3576,9 +5426,9 @@ function StoryEditor({
3576
5426
  window.addEventListener("keydown", onKey);
3577
5427
  return () => window.removeEventListener("keydown", onKey);
3578
5428
  }, []);
3579
- const slideActions = slide && /* @__PURE__ */ jsxs7(Group4, { gap: 6, children: [
3580
- /* @__PURE__ */ jsx9(Tooltip2, { label: strings.moveUp, children: /* @__PURE__ */ jsx9(
3581
- ActionIcon2,
5429
+ const slideActions = slide && /* @__PURE__ */ jsxs10(Group7, { gap: 6, children: [
5430
+ /* @__PURE__ */ jsx13(Tooltip4, { label: strings.moveUp, children: /* @__PURE__ */ jsx13(
5431
+ ActionIcon3,
3582
5432
  {
3583
5433
  variant: "default",
3584
5434
  "aria-label": strings.moveUp,
@@ -3590,8 +5440,8 @@ function StoryEditor({
3590
5440
  children: "\u2191"
3591
5441
  }
3592
5442
  ) }),
3593
- /* @__PURE__ */ jsx9(Tooltip2, { label: strings.moveDown, children: /* @__PURE__ */ jsx9(
3594
- ActionIcon2,
5443
+ /* @__PURE__ */ jsx13(Tooltip4, { label: strings.moveDown, children: /* @__PURE__ */ jsx13(
5444
+ ActionIcon3,
3595
5445
  {
3596
5446
  variant: "default",
3597
5447
  "aria-label": strings.moveDown,
@@ -3603,13 +5453,13 @@ function StoryEditor({
3603
5453
  children: "\u2193"
3604
5454
  }
3605
5455
  ) }),
3606
- /* @__PURE__ */ jsx9(Button3, { variant: "default", size: "xs", onClick: () => {
5456
+ /* @__PURE__ */ jsx13(Button6, { variant: "default", size: "xs", onClick: () => {
3607
5457
  const r = duplicateSlide(doc, index);
3608
5458
  apply(r.doc);
3609
5459
  setSelected(r.index);
3610
5460
  }, children: strings.duplicateSlide }),
3611
- /* @__PURE__ */ jsx9(
3612
- Button3,
5461
+ /* @__PURE__ */ jsx13(
5462
+ Button6,
3613
5463
  {
3614
5464
  variant: "default",
3615
5465
  size: "xs",
@@ -3624,10 +5474,10 @@ function StoryEditor({
3624
5474
  }
3625
5475
  )
3626
5476
  ] });
3627
- const storyForm = /* @__PURE__ */ jsxs7(Stack5, { gap: "md", children: [
3628
- /* @__PURE__ */ jsx9(Text4, { fw: 600, size: "lg", children: strings.story }),
3629
- /* @__PURE__ */ jsx9(
3630
- TextInput3,
5477
+ const storyForm = /* @__PURE__ */ jsxs10(Stack8, { gap: "md", children: [
5478
+ /* @__PURE__ */ jsx13(Text7, { fw: 600, size: "lg", children: strings.story }),
5479
+ /* @__PURE__ */ jsx13(
5480
+ TextInput6,
3631
5481
  {
3632
5482
  label: strings.title,
3633
5483
  required: true,
@@ -3635,8 +5485,8 @@ function StoryEditor({
3635
5485
  onChange: (e) => apply(updateStory(doc, { title: e.currentTarget.value }), "story:title")
3636
5486
  }
3637
5487
  ),
3638
- /* @__PURE__ */ jsx9(
3639
- TextInput3,
5488
+ /* @__PURE__ */ jsx13(
5489
+ TextInput6,
3640
5490
  {
3641
5491
  label: strings.language,
3642
5492
  description: strings.languageHint,
@@ -3645,7 +5495,7 @@ function StoryEditor({
3645
5495
  onChange: (e) => apply(updateStory(doc, { language: e.currentTarget.value.trim() || void 0 }), "story:language")
3646
5496
  }
3647
5497
  ),
3648
- /* @__PURE__ */ jsx9(
5498
+ /* @__PURE__ */ jsx13(
3649
5499
  Select,
3650
5500
  {
3651
5501
  label: strings.map,
@@ -3664,20 +5514,20 @@ function StoryEditor({
3664
5514
  }
3665
5515
  )
3666
5516
  ] });
3667
- const slideForm = slide && /* @__PURE__ */ jsxs7(Stack5, { gap: "md", children: [
3668
- /* @__PURE__ */ jsxs7(Group4, { justify: "space-between", wrap: "wrap", gap: "xs", children: [
3669
- /* @__PURE__ */ jsx9(Text4, { fw: 600, size: "lg", children: index === 0 ? strings.openingSlide : `${index + 1} / ${doc.slides.length}` }),
5517
+ const slideForm = slide && /* @__PURE__ */ jsxs10(Stack8, { gap: "md", children: [
5518
+ /* @__PURE__ */ jsxs10(Group7, { justify: "space-between", wrap: "wrap", gap: "xs", children: [
5519
+ /* @__PURE__ */ jsx13(Text7, { fw: 600, size: "lg", children: index === 0 ? strings.openingSlide : `${index + 1} / ${doc.slides.length}` }),
3670
5520
  slideActions
3671
5521
  ] }),
3672
- /* @__PURE__ */ jsx9(
3673
- TextInput3,
5522
+ /* @__PURE__ */ jsx13(
5523
+ TextInput6,
3674
5524
  {
3675
5525
  label: strings.slideTitle,
3676
5526
  value: slide.title ?? "",
3677
5527
  onChange: (e) => setSlide({ title: e.currentTarget.value || void 0 }, "title")
3678
5528
  }
3679
5529
  ),
3680
- /* @__PURE__ */ jsx9(
5530
+ /* @__PURE__ */ jsx13(
3681
5531
  DateField,
3682
5532
  {
3683
5533
  value: slide.date,
@@ -3687,7 +5537,7 @@ function StoryEditor({
3687
5537
  onChange: (date) => setSlide({ date }, "date")
3688
5538
  }
3689
5539
  ),
3690
- /* @__PURE__ */ jsx9(
5540
+ /* @__PURE__ */ jsx13(
3691
5541
  StoryTextField,
3692
5542
  {
3693
5543
  label: strings.text,
@@ -3698,19 +5548,21 @@ function StoryEditor({
3698
5548
  onChange: (text) => setSlide({ text: text || void 0 }, "text")
3699
5549
  }
3700
5550
  ),
3701
- slide.place && /* @__PURE__ */ jsxs7(Stack5, { gap: 2, children: [
3702
- /* @__PURE__ */ jsx9(Text4, { size: "sm", fw: 500, children: strings.place }),
3703
- /* @__PURE__ */ jsxs7(Text4, { size: "sm", children: [
3704
- slide.place.name ?? "",
3705
- slide.place.lat !== void 0 && slide.place.lon !== void 0 && /* @__PURE__ */ jsxs7(Text4, { span: true, c: "dimmed", size: "sm", children: [
3706
- " ",
3707
- slide.place.lat.toFixed(4),
3708
- ", ",
3709
- slide.place.lon.toFixed(4)
3710
- ] })
3711
- ] })
3712
- ] }),
3713
- /* @__PURE__ */ jsx9(
5551
+ /* @__PURE__ */ jsx13(
5552
+ PlaceField,
5553
+ {
5554
+ place: slide.place,
5555
+ choices: placeChoices,
5556
+ around,
5557
+ language: doc.language,
5558
+ search: placeSearch,
5559
+ dark,
5560
+ strings,
5561
+ onChange: (update, key) => setPlace(slide.id, update, key),
5562
+ onEditMap: () => setMapSlideId(slide.id)
5563
+ }
5564
+ ),
5565
+ /* @__PURE__ */ jsx13(
3714
5566
  PhotosField,
3715
5567
  {
3716
5568
  photos: slide.media ?? [],
@@ -3723,22 +5575,22 @@ function StoryEditor({
3723
5575
  onDescription: (key, alt) => apply(updateMedia(doc, key, { alt: alt || void 0 }), `media:${key}:alt`)
3724
5576
  }
3725
5577
  ),
3726
- photoError && /* @__PURE__ */ jsx9(Text4, { size: "xs", c: "red", role: "alert", children: photoError })
5578
+ photoError && /* @__PURE__ */ jsx13(Text7, { size: "xs", c: "red", role: "alert", children: photoError })
3727
5579
  ] }, slide.id);
3728
- const list = /* @__PURE__ */ jsxs7(Stack5, { gap: "xs", children: [
3729
- /* @__PURE__ */ jsxs7(
3730
- UnstyledButton2,
5580
+ const list = /* @__PURE__ */ jsxs10(Stack8, { gap: "xs", children: [
5581
+ /* @__PURE__ */ jsxs10(
5582
+ UnstyledButton3,
3731
5583
  {
3732
5584
  onClick: () => setSelected(STORY),
3733
5585
  style: { padding: "8px", borderRadius: 6, background: index === STORY ? "var(--mantine-primary-color-light)" : void 0 },
3734
5586
  children: [
3735
- /* @__PURE__ */ jsx9(Text4, { fw: 600, size: "sm", children: strings.story }),
3736
- /* @__PURE__ */ jsx9(Text4, { size: "xs", c: "dimmed", children: strings.storySettings })
5587
+ /* @__PURE__ */ jsx13(Text7, { fw: 600, size: "sm", children: strings.story }),
5588
+ /* @__PURE__ */ jsx13(Text7, { size: "xs", c: "dimmed", children: strings.storySettings })
3737
5589
  ]
3738
5590
  }
3739
5591
  ),
3740
- /* @__PURE__ */ jsx9(Divider, { label: strings.slides, labelPosition: "left" }),
3741
- /* @__PURE__ */ jsx9(
5592
+ /* @__PURE__ */ jsx13(Divider2, { label: strings.slides, labelPosition: "left" }),
5593
+ /* @__PURE__ */ jsx13(
3742
5594
  SlideList,
3743
5595
  {
3744
5596
  label: strings.slides,
@@ -3750,17 +5602,17 @@ function StoryEditor({
3750
5602
  setSelected(to);
3751
5603
  },
3752
5604
  getKey: (s) => s.id,
3753
- renderItem: (s, i) => /* @__PURE__ */ jsxs7(Group4, { gap: "sm", wrap: "nowrap", children: [
3754
- /* @__PURE__ */ jsx9(Thumb, { doc, slide: s, resolveMedia }),
3755
- /* @__PURE__ */ jsxs7(Box2, { style: { minWidth: 0 }, children: [
3756
- /* @__PURE__ */ jsx9(Text4, { size: "sm", fw: 500, truncate: true, dir: "auto", c: s.title ? void 0 : "dimmed", children: s.title || strings.untitledSlide }),
3757
- /* @__PURE__ */ jsx9(Text4, { size: "xs", c: "dimmed", truncate: true, children: i === 0 ? strings.openingSlide : s.date?.text ?? "" })
5605
+ renderItem: (s, i) => /* @__PURE__ */ jsxs10(Group7, { gap: "sm", wrap: "nowrap", children: [
5606
+ /* @__PURE__ */ jsx13(Thumb, { doc, slide: s, resolveMedia }),
5607
+ /* @__PURE__ */ jsxs10(Box4, { style: { minWidth: 0 }, children: [
5608
+ /* @__PURE__ */ jsx13(Text7, { size: "sm", fw: 500, truncate: true, dir: "auto", c: s.title ? void 0 : "dimmed", children: s.title || strings.untitledSlide }),
5609
+ /* @__PURE__ */ jsx13(Text7, { size: "xs", c: "dimmed", truncate: true, children: i === 0 ? strings.openingSlide : s.date?.text ?? "" })
3758
5610
  ] })
3759
5611
  ] })
3760
5612
  }
3761
5613
  ),
3762
- /* @__PURE__ */ jsxs7(
3763
- Button3,
5614
+ /* @__PURE__ */ jsxs10(
5615
+ Button6,
3764
5616
  {
3765
5617
  variant: "light",
3766
5618
  size: "xs",
@@ -3777,7 +5629,7 @@ function StoryEditor({
3777
5629
  }
3778
5630
  )
3779
5631
  ] });
3780
- const preview = /* @__PURE__ */ jsx9(Box2, { style: { position: "relative", height: wide ? "100%" : 460, borderRadius: 8, overflow: "hidden", border: "1px solid var(--mantine-color-default-border)" }, children: /* @__PURE__ */ jsx9(
5632
+ const preview = /* @__PURE__ */ jsx13(Box4, { style: { position: "relative", height: wide ? "100%" : 460, borderRadius: 8, overflow: "hidden", border: "1px solid var(--mantine-color-default-border)" }, children: /* @__PURE__ */ jsx13(
3781
5633
  StoryPlayer,
3782
5634
  {
3783
5635
  story: doc,
@@ -3791,8 +5643,8 @@ function StoryEditor({
3791
5643
  }
3792
5644
  }
3793
5645
  ) });
3794
- const column = (children, width) => /* @__PURE__ */ jsx9(
3795
- Box2,
5646
+ const column = (children, width) => /* @__PURE__ */ jsx13(
5647
+ Box4,
3796
5648
  {
3797
5649
  style: {
3798
5650
  width,
@@ -3805,32 +5657,32 @@ function StoryEditor({
3805
5657
  children
3806
5658
  }
3807
5659
  );
3808
- return /* @__PURE__ */ jsxs7(Box2, { style: { height: "100%", display: "flex", flexDirection: "column" }, children: [
3809
- /* @__PURE__ */ jsxs7(
3810
- Group4,
5660
+ return /* @__PURE__ */ jsxs10(Box4, { style: { height: "100%", display: "flex", flexDirection: "column" }, children: [
5661
+ /* @__PURE__ */ jsxs10(
5662
+ Group7,
3811
5663
  {
3812
- h: BAR_HEIGHT2,
5664
+ h: BAR_HEIGHT3,
3813
5665
  px: "md",
3814
5666
  gap: "sm",
3815
5667
  wrap: "nowrap",
3816
5668
  justify: "space-between",
3817
5669
  style: { flex: "none", borderBottom: "1px solid var(--mantine-color-default-border)" },
3818
5670
  children: [
3819
- /* @__PURE__ */ jsxs7(Group4, { gap: "sm", wrap: "nowrap", style: { minWidth: 0 }, children: [
5671
+ /* @__PURE__ */ jsxs10(Group7, { gap: "sm", wrap: "nowrap", style: { minWidth: 0 }, children: [
3820
5672
  brand,
3821
- /* @__PURE__ */ jsx9(Text4, { fw: 600, truncate: true, dir: "auto", children: doc.title })
5673
+ /* @__PURE__ */ jsx13(Text7, { fw: 600, truncate: true, dir: "auto", children: doc.title })
3822
5674
  ] }),
3823
- /* @__PURE__ */ jsxs7(Group4, { gap: "xs", wrap: "nowrap", children: [
3824
- issues.length > 0 && /* @__PURE__ */ jsxs7(Popover, { opened: problemsOpen, onChange: setProblemsOpen, width: 360, position: "bottom-end", withArrow: true, children: [
3825
- /* @__PURE__ */ jsx9(Popover.Target, { children: /* @__PURE__ */ jsx9(Badge, { color: "red", variant: "light", style: { cursor: "pointer" }, onClick: () => setProblemsOpen((o) => !o), children: strings.problems(issues.length) }) }),
3826
- /* @__PURE__ */ jsx9(Popover.Dropdown, { children: /* @__PURE__ */ jsx9(Stack5, { gap: 6, children: issues.map((issue, i) => {
5675
+ /* @__PURE__ */ jsxs10(Group7, { gap: "xs", wrap: "nowrap", children: [
5676
+ issues.length > 0 && /* @__PURE__ */ jsxs10(Popover, { opened: problemsOpen, onChange: setProblemsOpen, width: 360, position: "bottom-end", withArrow: true, children: [
5677
+ /* @__PURE__ */ jsx13(Popover.Target, { children: /* @__PURE__ */ jsx13(Badge, { color: "red", variant: "light", style: { cursor: "pointer" }, onClick: () => setProblemsOpen((o) => !o), children: strings.problems(issues.length) }) }),
5678
+ /* @__PURE__ */ jsx13(Popover.Dropdown, { children: /* @__PURE__ */ jsx13(Stack8, { gap: 6, children: issues.map((issue, i) => {
3827
5679
  const at = issueSlide(issue);
3828
- return /* @__PURE__ */ jsxs7(UnstyledButton2, { onClick: () => {
5680
+ return /* @__PURE__ */ jsxs10(UnstyledButton3, { onClick: () => {
3829
5681
  setSelected(at);
3830
5682
  setProblemsOpen(false);
3831
5683
  }, children: [
3832
- /* @__PURE__ */ jsx9(Text4, { size: "sm", fw: 500, children: at === STORY ? strings.story : doc.slides[at]?.title || `${strings.untitledSlide} ${at + 1}` }),
3833
- /* @__PURE__ */ jsxs7(Text4, { size: "xs", c: "dimmed", children: [
5684
+ /* @__PURE__ */ jsx13(Text7, { size: "sm", fw: 500, children: at === STORY ? strings.story : doc.slides[at]?.title || `${strings.untitledSlide} ${at + 1}` }),
5685
+ /* @__PURE__ */ jsxs10(Text7, { size: "xs", c: "dimmed", children: [
3834
5686
  issue.path,
3835
5687
  " ",
3836
5688
  issue.message
@@ -3838,16 +5690,16 @@ function StoryEditor({
3838
5690
  ] }, i);
3839
5691
  }) }) })
3840
5692
  ] }),
3841
- /* @__PURE__ */ jsx9(Tooltip2, { label: strings.undo, children: /* @__PURE__ */ jsx9(ActionIcon2, { variant: "default", size: "lg", "aria-label": strings.undo, disabled: history.past.length === 0, onClick: () => setHistory(undo), children: "\u21B6" }) }),
3842
- /* @__PURE__ */ jsx9(Tooltip2, { label: strings.redo, children: /* @__PURE__ */ jsx9(ActionIcon2, { variant: "default", size: "lg", "aria-label": strings.redo, disabled: history.future.length === 0, onClick: () => setHistory(redo), children: "\u21B7" }) }),
3843
- /* @__PURE__ */ jsx9(Button3, { variant: "default", onClick: () => setPresenting(true), children: strings.present }),
5693
+ /* @__PURE__ */ jsx13(Tooltip4, { label: strings.undo, children: /* @__PURE__ */ jsx13(ActionIcon3, { variant: "default", size: "lg", "aria-label": strings.undo, disabled: history.past.length === 0, onClick: () => setHistory(undo), children: "\u21B6" }) }),
5694
+ /* @__PURE__ */ jsx13(Tooltip4, { label: strings.redo, children: /* @__PURE__ */ jsx13(ActionIcon3, { variant: "default", size: "lg", "aria-label": strings.redo, disabled: history.future.length === 0, onClick: () => setHistory(redo), children: "\u21B7" }) }),
5695
+ /* @__PURE__ */ jsx13(Button6, { variant: "default", onClick: () => setPresenting(true), children: strings.present }),
3844
5696
  toolbar
3845
5697
  ] })
3846
5698
  ]
3847
5699
  }
3848
5700
  ),
3849
- /* @__PURE__ */ jsxs7(
3850
- Box2,
5701
+ /* @__PURE__ */ jsxs10(
5702
+ Box4,
3851
5703
  {
3852
5704
  style: {
3853
5705
  flex: 1,
@@ -3863,7 +5715,27 @@ function StoryEditor({
3863
5715
  ]
3864
5716
  }
3865
5717
  ),
3866
- /* @__PURE__ */ jsx9(Modal, { opened: presenting, onClose: () => setPresenting(false), fullScreen: true, withCloseButton: false, padding: 0, children: /* @__PURE__ */ jsx9(Box2, { style: { position: "fixed", inset: 0 }, children: /* @__PURE__ */ jsx9(
5718
+ mapSlide && /* @__PURE__ */ jsx13(
5719
+ PlaceMapDialog,
5720
+ {
5721
+ place: mapSlide.place,
5722
+ doc,
5723
+ files,
5724
+ dark,
5725
+ parseDateText,
5726
+ strings,
5727
+ mapStrings,
5728
+ onCancel: () => setMapSlideId(null),
5729
+ onDone: (place, media, nextFiles) => {
5730
+ const at = doc.slides.findIndex((s) => s.id === mapSlide.id);
5731
+ setMapSlideId(null);
5732
+ if (at < 0) return;
5733
+ setFiles(nextFiles);
5734
+ apply(updateSlide({ ...doc, media }, at, { place }));
5735
+ }
5736
+ }
5737
+ ),
5738
+ /* @__PURE__ */ jsx13(Modal2, { opened: presenting, onClose: () => setPresenting(false), fullScreen: true, withCloseButton: false, padding: 0, children: /* @__PURE__ */ jsx13(Box4, { style: { position: "fixed", inset: 0 }, children: /* @__PURE__ */ jsx13(
3867
5739
  StoryPlayer,
3868
5740
  {
3869
5741
  story: doc,
@@ -3880,31 +5752,47 @@ export {
3880
5752
  COALESCE_MS,
3881
5753
  DateField,
3882
5754
  EDITOR_ENGLISH,
5755
+ MAP_EDITOR_ENGLISH,
5756
+ MapEditor,
5757
+ MapEditorHelp,
3883
5758
  PhotosField,
5759
+ PlaceField,
5760
+ PlaceMapDialog,
3884
5761
  SlideList,
3885
5762
  StoryEditor,
3886
5763
  StoryTextField,
5764
+ WIKIDATA_PLACE_SEARCH,
3887
5765
  addImage,
3888
5766
  addSlide,
3889
5767
  blankStory,
3890
5768
  deleteSlide,
3891
5769
  duplicateSlide,
5770
+ editorToPlace,
3892
5771
  findMedia,
5772
+ formatLatLon,
3893
5773
  moveSlide,
3894
5774
  nextSlideId,
5775
+ parseLatLon,
5776
+ patchPlace,
5777
+ placeFeatures,
5778
+ placeToEditor,
3895
5779
  prepareImage,
3896
5780
  pruneMedia,
3897
5781
  record,
3898
5782
  redo,
5783
+ roundDegrees,
5784
+ searchWikidataPlaces,
3899
5785
  setSlidePhotos,
3900
5786
  slideDate,
3901
5787
  startHistory,
3902
5788
  storyFileName,
5789
+ storyPlaceChoices,
3903
5790
  suggestEdtf,
3904
5791
  undo,
3905
5792
  updateMedia,
3906
5793
  updateSlide,
3907
5794
  updateStory,
3908
- usedMedia
5795
+ usedMedia,
5796
+ wikidataPoint
3909
5797
  };
3910
5798
  //# sourceMappingURL=editor.js.map