@gramps-connect/story 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -5
- package/dist/editor.js +2065 -177
- package/dist/editor.js.map +4 -4
- package/dist/embed.js +1 -1
- package/dist/map-edit.js +361 -0
- package/dist/map-edit.js.map +7 -0
- package/dist/player.js +3 -1
- package/dist/player.js.map +2 -2
- package/dist/types/editor/MapEditor.d.ts +89 -0
- package/dist/types/editor/PlaceField.d.ts +24 -0
- package/dist/types/editor/PlaceMap.d.ts +12 -0
- package/dist/types/editor/PlaceMapDialog.d.ts +18 -0
- package/dist/types/editor/StoryEditor.d.ts +8 -1
- package/dist/types/editor/index.d.ts +7 -1
- package/dist/types/editor/mapEditorStrings.d.ts +50 -0
- package/dist/types/editor/model.d.ts +17 -1
- package/dist/types/editor/placeSearch.d.ts +22 -0
- package/dist/types/editor/placeShapes.d.ts +13 -0
- package/dist/types/editor/strings.d.ts +28 -0
- package/dist/types/mapEdit/geometry.d.ts +91 -0
- package/dist/types/mapEdit/index.d.ts +3 -0
- package/dist/types/mapEdit/mapFile.d.ts +77 -0
- package/dist/types/mapEdit/model.d.ts +55 -0
- package/package.json +18 -2
- package/src/editor/MapEditor.tsx +1545 -0
- package/src/editor/PlaceField.tsx +170 -0
- package/src/editor/PlaceMap.tsx +88 -0
- package/src/editor/PlaceMapDialog.tsx +228 -0
- package/src/editor/StoryEditor.tsx +50 -14
- package/src/editor/index.ts +11 -2
- package/src/editor/mapEditorStrings.ts +109 -0
- package/src/editor/model.ts +38 -1
- package/src/editor/placeSearch.ts +61 -0
- package/src/editor/placeShapes.ts +63 -0
- package/src/editor/strings.ts +56 -0
- package/src/mapEdit/geometry.ts +211 -0
- package/src/mapEdit/index.ts +15 -0
- package/src/mapEdit/mapFile.ts +199 -0
- package/src/mapEdit/model.ts +250 -0
- package/src/player/MapBackground.tsx +3 -1
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// The map editor's words. English here; Gramps Connect passes its own
|
|
2
|
+
// translations (its t() is keyed by these same English texts).
|
|
3
|
+
|
|
4
|
+
export interface MapEditorStrings {
|
|
5
|
+
point: string;
|
|
6
|
+
line: string;
|
|
7
|
+
polygon: string;
|
|
8
|
+
rectangle: string;
|
|
9
|
+
select: string;
|
|
10
|
+
image: string;
|
|
11
|
+
editOptions: string;
|
|
12
|
+
labelText: string;
|
|
13
|
+
regionName: string;
|
|
14
|
+
fillOpacity: string;
|
|
15
|
+
scale: string;
|
|
16
|
+
transform: string;
|
|
17
|
+
overlayName: string;
|
|
18
|
+
opacity: string;
|
|
19
|
+
stackingOrder: string;
|
|
20
|
+
removeImage: string;
|
|
21
|
+
resizeImage: string;
|
|
22
|
+
rotateImage: string;
|
|
23
|
+
warpCorner: string;
|
|
24
|
+
basemapFailed: string;
|
|
25
|
+
imageFailed: string;
|
|
26
|
+
lockedOne: string;
|
|
27
|
+
lockedMany: (count: number) => string;
|
|
28
|
+
lockedExplain: string;
|
|
29
|
+
// The help (MapEditorHelp), in the pieces it's put together from.
|
|
30
|
+
helpTitle: string;
|
|
31
|
+
helpDrawing: string;
|
|
32
|
+
helpPoint: string;
|
|
33
|
+
helpLine: string;
|
|
34
|
+
helpEnterFinishes: string;
|
|
35
|
+
helpEscCancels: string;
|
|
36
|
+
helpRectangle: string;
|
|
37
|
+
helpImage: string;
|
|
38
|
+
helpImages: string;
|
|
39
|
+
helpBelow: string;
|
|
40
|
+
helpEditing: string;
|
|
41
|
+
helpSelect: string;
|
|
42
|
+
helpVertex: string;
|
|
43
|
+
helpColor: string;
|
|
44
|
+
helpDelete: string;
|
|
45
|
+
helpOr: string;
|
|
46
|
+
helpRotate: string;
|
|
47
|
+
helpRotateHandle: string;
|
|
48
|
+
helpImageMove: string;
|
|
49
|
+
helpImageResize: string;
|
|
50
|
+
helpImageRotate: string;
|
|
51
|
+
helpImageTransform: string;
|
|
52
|
+
helpImageRemove: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const MAP_EDITOR_ENGLISH: MapEditorStrings = {
|
|
56
|
+
point: "Point",
|
|
57
|
+
line: "Line",
|
|
58
|
+
polygon: "Polygon",
|
|
59
|
+
rectangle: "Rectangle",
|
|
60
|
+
select: "Select",
|
|
61
|
+
image: "Image",
|
|
62
|
+
editOptions: "Edit Options",
|
|
63
|
+
labelText: "Label text",
|
|
64
|
+
regionName: "Region name (optional)",
|
|
65
|
+
fillOpacity: "Fill opacity",
|
|
66
|
+
scale: "Scale",
|
|
67
|
+
transform: "Transform",
|
|
68
|
+
overlayName: "Overlay name (optional)",
|
|
69
|
+
opacity: "Opacity",
|
|
70
|
+
stackingOrder: "Stacking order",
|
|
71
|
+
removeImage: "Remove this image",
|
|
72
|
+
resizeImage: "Drag to resize (keeps its exact shape, warped or not)",
|
|
73
|
+
rotateImage: "Drag to rotate",
|
|
74
|
+
warpCorner: "Drag to warp this corner",
|
|
75
|
+
basemapFailed: "The basemap could not be loaded.",
|
|
76
|
+
imageFailed: "The image failed to load.",
|
|
77
|
+
lockedOne: "1 shape can't be edited here",
|
|
78
|
+
lockedMany: (count) => `${count} shapes can't be edited here`,
|
|
79
|
+
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.",
|
|
80
|
+
helpTitle: "How to use this editor",
|
|
81
|
+
helpDrawing: "Drawing",
|
|
82
|
+
helpPoint: "Point: click once to place it.",
|
|
83
|
+
helpLine: "Line, Polygon: click to place each point, then click that first point's own marker again to "
|
|
84
|
+
+ "finish -- it's styled differently once there's enough points to close the shape. Pressing",
|
|
85
|
+
helpEnterFinishes: "finishes it too;",
|
|
86
|
+
helpEscCancels: "cancels it.",
|
|
87
|
+
helpRectangle: "Rectangle: click and drag from one corner to the opposite corner.",
|
|
88
|
+
helpImage: "Image: pick a media object to overlay -- see",
|
|
89
|
+
helpImages: "Images",
|
|
90
|
+
helpBelow: "below.",
|
|
91
|
+
helpEditing: "Editing a shape",
|
|
92
|
+
helpSelect: "Select: click a shape to select it, then drag it or one of its handles.",
|
|
93
|
+
helpVertex: "Drag a vertex to move it. To add a new point on a Line, Polygon, or Rectangle, drag the small "
|
|
94
|
+
+ "midpoint marker between two existing vertices outward.",
|
|
95
|
+
helpColor: "The color swatch sets the next shape's color -- or, with a shape selected, changes its color.",
|
|
96
|
+
helpDelete: "Delete a selected shape or image with",
|
|
97
|
+
helpOr: "or",
|
|
98
|
+
helpRotate: "Rotate a selected Polygon, Rectangle, or Line: hold",
|
|
99
|
+
helpRotateHandle: "while dragging one of its handles.",
|
|
100
|
+
helpImageMove: "Drag anywhere on an image to move it.",
|
|
101
|
+
helpImageResize: "Drag its ↘ handle to resize it -- grows or shrinks from its own center, "
|
|
102
|
+
+ "keeping its exact shape (works the same on a warped image, too).",
|
|
103
|
+
helpImageRotate: "Drag its ↻ handle to rotate it -- handy for aligning a scanned old map.",
|
|
104
|
+
helpImageTransform: "Switch to \"Transform\" to also show 4 corner handles you can drag independently "
|
|
105
|
+
+ "(rubber-sheeting) -- for a scanned map that isn't printed at a uniform scale/orientation. "
|
|
106
|
+
+ "Switching back to \"Scale\" just hides those 4 handles again; move/resize/rotate keep "
|
|
107
|
+
+ "working on the image's exact current shape either way.",
|
|
108
|
+
helpImageRemove: "Click its × handle to remove it.",
|
|
109
|
+
};
|
package/src/editor/model.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// know (a newer minor version's, an extension's) ride along untouched:
|
|
5
5
|
// opening and saving a story must never lose anything.
|
|
6
6
|
import { FORMAT_ID, FORMAT_VERSION } from "../version";
|
|
7
|
-
import type { MediaItem, Slide, SlideMedia, StoryDate, StoryDoc } from "../types";
|
|
7
|
+
import type { MediaItem, Place, Slide, SlideMedia, StoryDate, StoryDoc } from "../types";
|
|
8
8
|
|
|
9
9
|
/** A story with one empty slide: the smallest valid one. */
|
|
10
10
|
export function blankStory(title: string, language?: string): StoryDoc {
|
|
@@ -100,6 +100,43 @@ export function slideDate(previous: StoryDate | undefined, text: string, edtf: s
|
|
|
100
100
|
return next;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/** A place with some fields set or (undefined) removed; one left with
|
|
104
|
+
* nothing in it is no place at all. Its shape and overlays, and any field
|
|
105
|
+
* this version doesn't know, stay as they were. */
|
|
106
|
+
export function patchPlace(place: Place | undefined, patch: Partial<Place>): Place | undefined {
|
|
107
|
+
if (place && Object.entries(patch).every(([key, value]) => place[key as keyof Place] === value)) return place;
|
|
108
|
+
const next = patched(place ?? {}, patch);
|
|
109
|
+
return Object.keys(next).length ? next : undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Degrees to 5 decimals: about a metre, and no float noise. */
|
|
113
|
+
export const roundDegrees = (value: number) => Math.round(value * 1e5) / 1e5;
|
|
114
|
+
|
|
115
|
+
/** "47.3769, 8.5417" (or with a space or semicolon between) -> a point;
|
|
116
|
+
* null when that's not two numbers in range. */
|
|
117
|
+
export function parseLatLon(text: string): { lat: number; lon: number } | null {
|
|
118
|
+
const parts = text.trim().split(/\s*[,;\s]\s*/);
|
|
119
|
+
if (parts.length !== 2 || parts.some((p) => !/^[-+]?(\d+\.?\d*|\.\d+)$/.test(p))) return null;
|
|
120
|
+
const [lat, lon] = parts.map(Number);
|
|
121
|
+
if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null;
|
|
122
|
+
return { lat, lon };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function formatLatLon(lat: number, lon: number): string {
|
|
126
|
+
return `${roundDegrees(lat)}, ${roundDegrees(lon)}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The places of the story's other slides, one per name (the first slide
|
|
130
|
+
* to use it), for giving another slide the very same place. */
|
|
131
|
+
export function storyPlaceChoices(doc: StoryDoc, exceptIndex?: number): Place[] {
|
|
132
|
+
const byName = new Map<string, Place>();
|
|
133
|
+
doc.slides.forEach((slide, i) => {
|
|
134
|
+
const name = slide.place?.name?.trim();
|
|
135
|
+
if (i !== exceptIndex && name && !byName.has(name)) byName.set(name, slide.place!);
|
|
136
|
+
});
|
|
137
|
+
return [...byName.values()];
|
|
138
|
+
}
|
|
139
|
+
|
|
103
140
|
/** A prepared image to add to a story (see optimizeImage). */
|
|
104
141
|
export interface NewImage {
|
|
105
142
|
data: Uint8Array;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Finding a place by name, for the place field's opt-in search: Wikidata's
|
|
2
|
+
// own search, then the chosen item's coordinates (P625). Both endpoints
|
|
3
|
+
// allow any origin and need no key. This is the only thing the editor
|
|
4
|
+
// sends anywhere, and only when asked to: it sends the name typed.
|
|
5
|
+
// (Gramps Connect's place lookup walks the same API further, up the
|
|
6
|
+
// "located in" chain, to build a place hierarchy; a story place is just a
|
|
7
|
+
// name and a point.)
|
|
8
|
+
|
|
9
|
+
const WIKIDATA_API = "https://www.wikidata.org/w/api.php";
|
|
10
|
+
const WIKIDATA_ENTITY_DATA = "https://www.wikidata.org/wiki/Special:EntityData";
|
|
11
|
+
|
|
12
|
+
export interface PlaceSearchResult {
|
|
13
|
+
/** The Wikidata item, e.g. "Q72". */
|
|
14
|
+
id: string;
|
|
15
|
+
label: string;
|
|
16
|
+
/** Usually names the region and country, which tells same-named places
|
|
17
|
+
* apart. */
|
|
18
|
+
description?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface PlaceSearch {
|
|
22
|
+
search: (term: string, language: string | undefined, signal?: AbortSignal) => Promise<PlaceSearchResult[]>;
|
|
23
|
+
/** The result's position, or null when it has none. */
|
|
24
|
+
point: (result: PlaceSearchResult, signal?: AbortSignal) => Promise<{ lat: number; lon: number } | null>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** "fr_CA" or "fr-CA" -> "fr-ca", Wikidata's form. */
|
|
28
|
+
function wikidataLanguage(language: string | undefined): string {
|
|
29
|
+
return (language ?? "").trim().toLowerCase().replace(/_/g, "-") || "en";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function searchWikidataPlaces(term: string, language?: string, signal?: AbortSignal): Promise<PlaceSearchResult[]> {
|
|
33
|
+
const code = wikidataLanguage(language);
|
|
34
|
+
const params = new URLSearchParams({
|
|
35
|
+
action: "wbsearchentities", search: term, language: code, uselang: code, format: "json", limit: "7",
|
|
36
|
+
origin: "*", // what makes an anonymous api.php GET answer cross-origin
|
|
37
|
+
});
|
|
38
|
+
const res = await fetch(`${WIKIDATA_API}?${params}`, { signal });
|
|
39
|
+
if (!res.ok) throw new Error(`Wikidata search failed (${res.status})`);
|
|
40
|
+
const data = (await res.json()) as { search?: { id: string; label?: string; description?: string }[] };
|
|
41
|
+
return (data.search ?? []).map((hit) => ({
|
|
42
|
+
id: hit.id,
|
|
43
|
+
label: hit.label ?? hit.id,
|
|
44
|
+
...(hit.description ? { description: hit.description } : {}),
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function wikidataPoint(result: PlaceSearchResult, signal?: AbortSignal): Promise<{ lat: number; lon: number } | null> {
|
|
49
|
+
const res = await fetch(`${WIKIDATA_ENTITY_DATA}/${encodeURIComponent(result.id)}.json`, { signal });
|
|
50
|
+
if (!res.ok) throw new Error(`Wikidata fetch failed for ${result.id} (${res.status})`);
|
|
51
|
+
const data = (await res.json()) as {
|
|
52
|
+
entities?: Record<string, { claims?: Record<string, { mainsnak?: { datavalue?: { value?: unknown } } }[]> }>;
|
|
53
|
+
};
|
|
54
|
+
// A redirected item comes back under its new id: take whichever is there.
|
|
55
|
+
const entity = data.entities?.[result.id] ?? Object.values(data.entities ?? {})[0];
|
|
56
|
+
const value = entity?.claims?.P625?.[0]?.mainsnak?.datavalue?.value as { latitude?: number; longitude?: number } | undefined;
|
|
57
|
+
if (typeof value?.latitude !== "number" || typeof value.longitude !== "number") return null;
|
|
58
|
+
return { lat: value.latitude, lon: value.longitude };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const WIKIDATA_PLACE_SEARCH: PlaceSearch = { search: searchWikidataPlaces, point: wikidataPoint };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// A slide's place as the map editor sees it, and back: the place's
|
|
2
|
+
// `geometry` (a FeatureCollection, a Feature or a bare geometry) as
|
|
3
|
+
// features, and its `overlays` as image overlays keyed by media. Whatever
|
|
4
|
+
// the editor doesn't edit -- an overlay's date, fields this version
|
|
5
|
+
// doesn't know, a FeatureCollection's own members -- is carried over from
|
|
6
|
+
// the place as it was.
|
|
7
|
+
import type { Feature, FeatureCollection, Geometry } from "geojson";
|
|
8
|
+
import type { Overlay, Place, StoryDate } from "../types";
|
|
9
|
+
import type { MapEditorOverlay, MapEditorResult } from "./MapEditor";
|
|
10
|
+
import { patchPlace } from "./model";
|
|
11
|
+
|
|
12
|
+
/** The features in a place's `geometry`, whichever form it takes. */
|
|
13
|
+
export function placeFeatures(place: Place | undefined): Feature[] {
|
|
14
|
+
const geometry = place?.geometry as { type: string; features?: unknown[] } | undefined;
|
|
15
|
+
if (!geometry) return [];
|
|
16
|
+
if (geometry.type === "FeatureCollection") {
|
|
17
|
+
return ((geometry.features ?? []) as Feature[]).filter((f) => f && f.geometry);
|
|
18
|
+
}
|
|
19
|
+
if (geometry.type === "Feature") return (geometry as unknown as Feature).geometry ? [geometry as unknown as Feature] : [];
|
|
20
|
+
return [{ type: "Feature", properties: {}, geometry: geometry as unknown as Geometry }];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** What the map editor opens with for a place. */
|
|
24
|
+
export function placeToEditor(place: Place | undefined): { features: Feature[]; overlays: MapEditorOverlay[] } {
|
|
25
|
+
return {
|
|
26
|
+
features: placeFeatures(place),
|
|
27
|
+
overlays: (place?.overlays ?? []).map((o) => ({
|
|
28
|
+
image: o.media, corners: o.corners,
|
|
29
|
+
...(o.opacity !== undefined ? { opacity: o.opacity } : {}),
|
|
30
|
+
...(o.order !== undefined ? { order: o.order } : {}),
|
|
31
|
+
})),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The place with what the map editor ended with. `dates` holds each
|
|
36
|
+
* overlay's date by media key, as edited beside the editor. */
|
|
37
|
+
export function editorToPlace(
|
|
38
|
+
place: Place | undefined, result: MapEditorResult, dates: Map<string, StoryDate | undefined>,
|
|
39
|
+
): Place | undefined {
|
|
40
|
+
const before = place?.geometry as (FeatureCollection & Record<string, unknown>) | undefined;
|
|
41
|
+
const geometry = result.features.length === 0
|
|
42
|
+
? undefined
|
|
43
|
+
// A FeatureCollection keeps its own other members; anything else
|
|
44
|
+
// becomes one.
|
|
45
|
+
: { ...(before?.type === "FeatureCollection" ? before : {}), type: "FeatureCollection", features: result.features };
|
|
46
|
+
const previous = new Map((place?.overlays ?? []).map((o) => [o.media, o]));
|
|
47
|
+
const overlays = result.overlays.map((o): Overlay => {
|
|
48
|
+
const { opacity: _o, order: _r, date: _d, ...kept } = previous.get(o.image) ?? ({} as Partial<Overlay>);
|
|
49
|
+
const date = dates.has(o.image) ? dates.get(o.image) : previous.get(o.image)?.date;
|
|
50
|
+
return {
|
|
51
|
+
...kept,
|
|
52
|
+
media: o.image,
|
|
53
|
+
corners: o.corners,
|
|
54
|
+
...(o.opacity !== undefined && o.opacity !== 1 ? { opacity: o.opacity } : {}),
|
|
55
|
+
...(o.order !== undefined ? { order: Math.round(o.order) } : {}),
|
|
56
|
+
...(date ? { date } : {}),
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
return patchPlace(place, {
|
|
60
|
+
geometry: geometry as Place["geometry"],
|
|
61
|
+
overlays: overlays.length ? overlays : undefined,
|
|
62
|
+
});
|
|
63
|
+
}
|
package/src/editor/strings.ts
CHANGED
|
@@ -32,6 +32,34 @@ export interface EditorStrings {
|
|
|
32
32
|
matchDateAsWritten: string;
|
|
33
33
|
noPreciseDate: string;
|
|
34
34
|
place: string;
|
|
35
|
+
placeName: string;
|
|
36
|
+
removePlace: string;
|
|
37
|
+
findPlace: string;
|
|
38
|
+
findPlaceHint: string;
|
|
39
|
+
findPlaceNone: string;
|
|
40
|
+
findPlaceFailed: string;
|
|
41
|
+
findPlaceNoPoint: (name: string) => string;
|
|
42
|
+
coordinates: string;
|
|
43
|
+
coordinatesHint: string;
|
|
44
|
+
coordinatesInvalid: string;
|
|
45
|
+
placeMap: string;
|
|
46
|
+
placeMapHint: string;
|
|
47
|
+
placeShape: string;
|
|
48
|
+
placeOverlays: (count: number) => string;
|
|
49
|
+
placeKeeps: (what: string) => string;
|
|
50
|
+
editMap: string;
|
|
51
|
+
editMapHint: string;
|
|
52
|
+
mapDialogTitle: (placeName: string | undefined) => string;
|
|
53
|
+
mapHelp: string;
|
|
54
|
+
importShapes: string;
|
|
55
|
+
importShapesHint: string;
|
|
56
|
+
importFailed: (fileName: string) => string;
|
|
57
|
+
importNothing: (fileName: string) => string;
|
|
58
|
+
overlayAlready: string;
|
|
59
|
+
overlayDate: string;
|
|
60
|
+
overlayDateHint: string;
|
|
61
|
+
cancel: string;
|
|
62
|
+
done: string;
|
|
35
63
|
photos: string;
|
|
36
64
|
photosHint: string;
|
|
37
65
|
addPhoto: string;
|
|
@@ -84,6 +112,34 @@ export const EDITOR_ENGLISH: EditorStrings = {
|
|
|
84
112
|
matchDateAsWritten: "Work it out from the date",
|
|
85
113
|
noPreciseDate: "None: this slide stays off the timeline.",
|
|
86
114
|
place: "Place",
|
|
115
|
+
placeName: "Name",
|
|
116
|
+
removePlace: "Remove place",
|
|
117
|
+
findPlace: "Find on Wikidata",
|
|
118
|
+
findPlaceHint: "Looks the name up on Wikidata, to put the pin there. The name is all that's sent, and only when you ask.",
|
|
119
|
+
findPlaceNone: "Wikidata has no place by that name.",
|
|
120
|
+
findPlaceFailed: "Couldn't reach Wikidata.",
|
|
121
|
+
findPlaceNoPoint: (name) => `Wikidata has no map position for ${name}.`,
|
|
122
|
+
coordinates: "Latitude, longitude",
|
|
123
|
+
coordinatesHint: "e.g. 47.3769, 8.5417",
|
|
124
|
+
coordinatesInvalid: "Two numbers: latitude (-90 to 90), then longitude (-180 to 180).",
|
|
125
|
+
placeMap: "Map: click to put the pin",
|
|
126
|
+
placeMapHint: "Click the map to put the pin there, or drag the pin.",
|
|
127
|
+
placeShape: "a shape",
|
|
128
|
+
placeOverlays: (count) => (count === 1 ? "a map overlay" : `${count} map overlays`),
|
|
129
|
+
placeKeeps: (what) => `This place also has ${what}.`,
|
|
130
|
+
editMap: "Edit map",
|
|
131
|
+
editMapHint: "Shapes under the pin (a parish, a route) and scans of old maps laid over it.",
|
|
132
|
+
mapDialogTitle: (placeName) => (placeName ? `Map: ${placeName}` : "Map"),
|
|
133
|
+
mapHelp: "How to use this editor",
|
|
134
|
+
importShapes: "Import shapes",
|
|
135
|
+
importShapesHint: "Adds the points, lines and areas in a KML, GPX or GeoJSON file to this map.",
|
|
136
|
+
importFailed: (fileName) => `Couldn't read ${fileName} as a KML, GPX or GeoJSON file.`,
|
|
137
|
+
importNothing: (fileName) => `${fileName} has no shapes in it.`,
|
|
138
|
+
overlayAlready: "That image is already on this map.",
|
|
139
|
+
overlayDate: "Years it shows",
|
|
140
|
+
overlayDateHint: "e.g. 1850, or 1850 to 1880. With historical maps, it's hidden on slides outside them.",
|
|
141
|
+
cancel: "Cancel",
|
|
142
|
+
done: "Done",
|
|
87
143
|
photos: "Photos",
|
|
88
144
|
photosHint: "Add, drop or paste a photo. It's resized for the screen, and hidden details like camera and location are left out.",
|
|
89
145
|
addPhoto: "Add photo",
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Map geometry for shapes and image overlays, as plain math (no map
|
|
2
|
+
// library): image overlay corners moved, scaled and rotated in Web Mercator
|
|
3
|
+
// space so a rectangle stays one at any latitude, and bounding boxes over
|
|
4
|
+
// features and overlays. Moved here from Gramps Connect (its kmlMedia.ts),
|
|
5
|
+
// which shares it with the Story Editor; the "kml" in some names is from
|
|
6
|
+
// there, where KML was the first map file format.
|
|
7
|
+
import type { Feature, Position } from "geojson";
|
|
8
|
+
|
|
9
|
+
/** An overlay's 4 world corners, maplibre's own ImageSource `coordinates`
|
|
10
|
+
* order (top-left, top-right, bottom-right, bottom-left) and type. */
|
|
11
|
+
export type OverlayCorners = [[number, number], [number, number], [number, number], [number, number]];
|
|
12
|
+
|
|
13
|
+
const DEG_TO_RAD = Math.PI / 180;
|
|
14
|
+
const RAD_TO_DEG = 180 / Math.PI;
|
|
15
|
+
|
|
16
|
+
/** [lng,lat] -> normalized Web Mercator x/y (x east-positive, y
|
|
17
|
+
* *south*-positive) -- the exact projection maplibre's own map.project()
|
|
18
|
+
* uses internally, reimplemented here as plain math (no maplibre-gl
|
|
19
|
+
* import) so this module stays free of that ~900KB dependency (see
|
|
20
|
+
* MapItemEditorDialog.tsx's own doc comment on why maplibre-gl is always
|
|
21
|
+
* behind a lazy import elsewhere in Gramps Connect). Unlike raw [lng,lat],
|
|
22
|
+
* Mercator x/y is locally isotropic -- equal scale in both directions at
|
|
23
|
+
* any given point, which is the whole point of the projection -- making it
|
|
24
|
+
* the correct space to rotate a box in; see rotatePoint. */
|
|
25
|
+
function mercatorFromLngLat([lng, lat]: [number, number]): [number, number] {
|
|
26
|
+
const x = (180 + lng) / 360;
|
|
27
|
+
const y = (180 - RAD_TO_DEG * Math.log(Math.tan(Math.PI / 4 + (lat * DEG_TO_RAD) / 2))) / 360;
|
|
28
|
+
return [x, y];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** mercatorFromLngLat's inverse. */
|
|
32
|
+
function lngLatFromMercator([x, y]: [number, number]): [number, number] {
|
|
33
|
+
const lng = x * 360 - 180;
|
|
34
|
+
const lat = 90 - 2 * Math.atan(Math.exp((y * 360 - 180) * DEG_TO_RAD)) * RAD_TO_DEG;
|
|
35
|
+
return [lng, lat];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Rotates one point by `rotationDeg` around `center` (positive =
|
|
39
|
+
* counterclockwise, matching KML's own <LatLonBox><rotation> convention)
|
|
40
|
+
* -- done in Mercator-projected space (see mercatorFromLngLat), not raw
|
|
41
|
+
* [lng,lat], so a rectangle stays a rectangle (right angles preserved)
|
|
42
|
+
* after rotating, regardless of latitude. A naive rotation directly on
|
|
43
|
+
* [lng,lat] -- this function's original implementation -- shears instead,
|
|
44
|
+
* since a degree of longitude and a degree of latitude aren't the same
|
|
45
|
+
* ground (or screen) distance away from the equator (found live: a square
|
|
46
|
+
* image's corners visibly stopped being 90 degrees once rotated away from
|
|
47
|
+
* 0). Gramps Connect's own image overlays are never meant to be opened in an
|
|
48
|
+
* external viewer (their picture is an app-internal Media reference), so diverging here
|
|
49
|
+
* from what Google Earth's own <LatLonBox><rotation> would render is an
|
|
50
|
+
* acceptable trade for Gramps Connect always rendering its own files correctly.
|
|
51
|
+
* Pass a negative `rotationDeg` to go the other way (world -> local). */
|
|
52
|
+
export function rotatePoint(point: [number, number], center: [number, number], rotationDeg: number): [number, number] {
|
|
53
|
+
const angle = rotationDeg * DEG_TO_RAD;
|
|
54
|
+
const cos = Math.cos(angle);
|
|
55
|
+
const sin = Math.sin(angle);
|
|
56
|
+
const [px, py] = mercatorFromLngLat(point);
|
|
57
|
+
const [cx, cy] = mercatorFromLngLat(center);
|
|
58
|
+
// Mercator y increases southward -- negated here so this rotation is
|
|
59
|
+
// expressed in a conventional north-up, east-right plane (dx
|
|
60
|
+
// east-positive, dy north-positive), keeping "positive rotationDeg =
|
|
61
|
+
// counterclockwise" the same sense every caller already expects.
|
|
62
|
+
const dx = px - cx;
|
|
63
|
+
const dy = -(py - cy);
|
|
64
|
+
const rx = dx * cos - dy * sin;
|
|
65
|
+
const ry = dx * sin + dy * cos;
|
|
66
|
+
return lngLatFromMercator([cx + rx, cy - ry]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Scales the distance from `center` to `point` by `scale`, in the same
|
|
70
|
+
* Mercator-projected space rotatePoint rotates in -- so scaling a
|
|
71
|
+
* rectangle (or an already-warped quad) uniformly from its own center
|
|
72
|
+
* preserves its exact shape/aspect ratio regardless of latitude, the same
|
|
73
|
+
* way rotatePoint preserves angles. MapItemEditorDialog.tsx's resize
|
|
74
|
+
* handle applies this to all 4 corners at once (see mountOverlay). */
|
|
75
|
+
export function scalePoint(point: [number, number], center: [number, number], scale: number): [number, number] {
|
|
76
|
+
const [px, py] = mercatorFromLngLat(point);
|
|
77
|
+
const [cx, cy] = mercatorFromLngLat(center);
|
|
78
|
+
return lngLatFromMercator([cx + (px - cx) * scale, cy + (py - cy) * scale]);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The distance between `a` and `b` in the same Mercator-projected space
|
|
82
|
+
* scalePoint scales in -- not a real-world distance (Mercator distorts
|
|
83
|
+
* those away from the equator), just a consistent unit for computing a
|
|
84
|
+
* resize handle's before/after scale ratio, which only ever needs to be
|
|
85
|
+
* internally consistent with scalePoint's own projection, not physically
|
|
86
|
+
* meaningful on its own. */
|
|
87
|
+
export function mercatorDistance(a: [number, number], b: [number, number]): number {
|
|
88
|
+
const [ax, ay] = mercatorFromLngLat(a);
|
|
89
|
+
const [bx, by] = mercatorFromLngLat(b);
|
|
90
|
+
return Math.hypot(bx - ax, by - ay);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The angle (degrees, positive = counterclockwise, 0 = due east) from
|
|
94
|
+
* `from` to `to` -- measured in the same Mercator-projected, north-up
|
|
95
|
+
* plane rotatePoint itself rotates in, so a caller tracking "how far has
|
|
96
|
+
* the user dragged this handle around the center" gets an angle that
|
|
97
|
+
* matches how rotatePoint will then actually render it (unlike a plain
|
|
98
|
+
* atan2 on raw [lng,lat], which suffers the same latitude-dependent skew
|
|
99
|
+
* rotatePoint's own doc comment describes). */
|
|
100
|
+
export function mercatorBearing(from: [number, number], to: [number, number]): number {
|
|
101
|
+
const [fx, fy] = mercatorFromLngLat(from);
|
|
102
|
+
const [tx, ty] = mercatorFromLngLat(to);
|
|
103
|
+
return Math.atan2(-(ty - fy), tx - fx) * RAD_TO_DEG;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** An axis-aligned box plus a rotation about its own center's 4 world
|
|
107
|
+
* corners -- KML's own <LatLonBox><rotation> model. Only ever needed to
|
|
108
|
+
* interpret an *old* overlay saved before Gramps Connect always wrote
|
|
109
|
+
* explicit corners (see its fetchAllKmlImageOverlays); the map editor
|
|
110
|
+
* itself only ever deals with corners directly, never this box shape. */
|
|
111
|
+
export function cornersFromBox(
|
|
112
|
+
box: { north: number; south: number; east: number; west: number; rotation: number },
|
|
113
|
+
): OverlayCorners {
|
|
114
|
+
const center: [number, number] = [(box.west + box.east) / 2, (box.north + box.south) / 2];
|
|
115
|
+
const corners: [number, number][] = [
|
|
116
|
+
[box.west, box.north], [box.east, box.north], [box.east, box.south], [box.west, box.south],
|
|
117
|
+
];
|
|
118
|
+
return corners.map((p) => rotatePoint(p, center, box.rotation)) as OverlayCorners;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function visitCoordinates(coords: unknown, bounds: [number, number, number, number]): void {
|
|
122
|
+
if (typeof (coords as Position)[0] === "number") {
|
|
123
|
+
const [lng, lat] = coords as Position;
|
|
124
|
+
if (lng < bounds[0]) bounds[0] = lng;
|
|
125
|
+
if (lat < bounds[1]) bounds[1] = lat;
|
|
126
|
+
if (lng > bounds[2]) bounds[2] = lng;
|
|
127
|
+
if (lat > bounds[3]) bounds[3] = lat;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
for (const c of coords as unknown[]) visitCoordinates(c, bounds);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Every coordinate's bounding box across a set of KML features, as
|
|
134
|
+
* [west, south, east, north] -- maplibre's own LngLatBoundsLike corner
|
|
135
|
+
* order, so a caller can hand this straight to fitBounds/cameraForBounds to
|
|
136
|
+
* frame a field boundary or a route at whatever zoom actually fits it,
|
|
137
|
+
* rather than guessing one. Null when the features carry no coordinates at
|
|
138
|
+
* all (an empty file, or every fetch above failed). */
|
|
139
|
+
export function kmlBounds(features: Feature[]): [west: number, south: number, east: number, north: number] | null {
|
|
140
|
+
const bounds: [number, number, number, number] = [Infinity, Infinity, -Infinity, -Infinity];
|
|
141
|
+
for (const feature of features) {
|
|
142
|
+
const geometry = feature.geometry;
|
|
143
|
+
if (!geometry) continue;
|
|
144
|
+
if (geometry.type === "GeometryCollection") {
|
|
145
|
+
for (const g of geometry.geometries) if ("coordinates" in g) visitCoordinates(g.coordinates, bounds);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
visitCoordinates(geometry.coordinates, bounds);
|
|
149
|
+
}
|
|
150
|
+
return Number.isFinite(bounds[0]) ? bounds : null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The center of kmlBounds -- a plain bbox center rather than an
|
|
154
|
+
* area-weighted centroid, which is all a "roughly where does this file sit"
|
|
155
|
+
* marker position needs. */
|
|
156
|
+
export function kmlCenter(features: Feature[]): [lat: number, long: number] | null {
|
|
157
|
+
const bounds = kmlBounds(features);
|
|
158
|
+
if (!bounds) return null;
|
|
159
|
+
return [(bounds[1] + bounds[3]) / 2, (bounds[0] + bounds[2]) / 2];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Same bbox as kmlBounds, but over a set of image overlays' corners instead
|
|
163
|
+
* of vector features -- overlays (GroundOverlay/region placemarks) carry no
|
|
164
|
+
* Point/LineString/Polygon geometry for kmlBounds to find anything in, since
|
|
165
|
+
* fetchAllKmlFeatures excludes them entirely. Lets a caller fit the camera to
|
|
166
|
+
* an overlay-only KML attachment the same way it would a vector one. */
|
|
167
|
+
export function kmlOverlayBounds(
|
|
168
|
+
overlays: { corners: OverlayCorners }[],
|
|
169
|
+
): [west: number, south: number, east: number, north: number] | null {
|
|
170
|
+
if (overlays.length === 0) return null;
|
|
171
|
+
const bounds: [number, number, number, number] = [Infinity, Infinity, -Infinity, -Infinity];
|
|
172
|
+
for (const overlay of overlays) {
|
|
173
|
+
for (const [lng, lat] of overlay.corners) {
|
|
174
|
+
if (lng < bounds[0]) bounds[0] = lng;
|
|
175
|
+
if (lat < bounds[1]) bounds[1] = lat;
|
|
176
|
+
if (lng > bounds[2]) bounds[2] = lng;
|
|
177
|
+
if (lat > bounds[3]) bounds[3] = lat;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return bounds;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Combines two nullable bboxes (as returned by kmlBounds/kmlOverlayBounds)
|
|
184
|
+
* into the box that encloses both -- a place's KML attachment(s) can carry
|
|
185
|
+
* vector shapes and image overlays side by side, and framing the camera to
|
|
186
|
+
* just one kind would clip whichever the caller didn't ask for. Either side
|
|
187
|
+
* missing (no shapes, or no overlays) just passes the other through; both
|
|
188
|
+
* missing is null, same as either input alone would be. */
|
|
189
|
+
export function unionBounds(
|
|
190
|
+
a: [west: number, south: number, east: number, north: number] | null,
|
|
191
|
+
b: [west: number, south: number, east: number, north: number] | null,
|
|
192
|
+
): [west: number, south: number, east: number, north: number] | null {
|
|
193
|
+
if (!a) return b;
|
|
194
|
+
if (!b) return a;
|
|
195
|
+
return [Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[2], b[2]), Math.max(a[3], b[3])];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Same bbox-center guess as kmlCenter, but over a set of image overlays'
|
|
199
|
+
* corners instead of vector features -- useVisualData.ts's fallback for a
|
|
200
|
+
* PendingKmlPlace whose only KML attachment is a GroundOverlay (an
|
|
201
|
+
* overlay-only file: no Point/LineString/Polygon placemark for kmlCenter to
|
|
202
|
+
* find anything in, since fetchAllKmlFeatures excludes GroundOverlay
|
|
203
|
+
* placemarks entirely). Without this, a place with only an image overlay
|
|
204
|
+
* attached and no lat/long of its own never gets a derived position and so
|
|
205
|
+
* never renders on the map until someone sets its coordinates by hand
|
|
206
|
+
* (found live). */
|
|
207
|
+
export function kmlOverlayCenter(overlays: { corners: OverlayCorners }[]): [lat: number, long: number] | null {
|
|
208
|
+
const bounds = kmlOverlayBounds(overlays);
|
|
209
|
+
if (!bounds) return null;
|
|
210
|
+
return [(bounds[1] + bounds[3]) / 2, (bounds[0] + bounds[2]) / 2];
|
|
211
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// @gramps-connect/story/map-edit: the map editor's model, map files and
|
|
2
|
+
// geometry -- plain functions, no React or map library, shared by the
|
|
3
|
+
// Story Editor and Gramps Connect's own map editor.
|
|
4
|
+
export {
|
|
5
|
+
addToSession, assembleForSaving, lockPart, prepareForEditing,
|
|
6
|
+
type EditableGeometry, type EditablePart, type EditMode, type EditSession, type SavedPart,
|
|
7
|
+
} from "./model";
|
|
8
|
+
export {
|
|
9
|
+
IMAGE_OVERLAY_PROPERTY, isKmlLine, isKmlRegion, lineCoordsOf, mapItemToGeoJson, MEDIA_HANDLE_PREFIX, overlaySourceOfText,
|
|
10
|
+
parseMapFile, polygonRingsOf, SOURCE_PROPERTY, type ImageOverlay,
|
|
11
|
+
} from "./mapFile";
|
|
12
|
+
export {
|
|
13
|
+
cornersFromBox, kmlBounds, kmlCenter, kmlOverlayBounds, kmlOverlayCenter, mercatorBearing, mercatorDistance, rotatePoint,
|
|
14
|
+
scalePoint, unionBounds, type OverlayCorners,
|
|
15
|
+
} from "./geometry";
|