@gramps-connect/story 0.4.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.
@@ -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";
@@ -0,0 +1,199 @@
1
+ // Reading and writing map files: KML, GPX and GeoJSON in (read into GeoJSON
2
+ // features, with each format's own colours where they're reliable), and
3
+ // GeoJSON out, with image overlays as Polygon features marked
4
+ // IMAGE_OVERLAY_PROPERTY. A GeoJSON overlay's `image` names its picture;
5
+ // read back, it becomes the same feature @tmcw/togeojson makes of a KML
6
+ // GroundOverlay, with the picture behind MEDIA_HANDLE_PREFIX. Moved here
7
+ // from Gramps Connect (kmlMedia.ts and mapFileWrite.ts), which shares it
8
+ // with the Story Editor. GPX and KML need a DOMParser (a browser, or jsdom).
9
+ //
10
+ // GeoJSON keeps every property as JSON and multi-part shapes as they are,
11
+ // with no format translation to lose anything in; a GPS track's per-point
12
+ // times ride along in `coordinateProperties.times`, the same place
13
+ // @tmcw/togeojson's GPX/KML readers put them.
14
+ import type { Feature, FeatureCollection, GeoJsonObject, Geometry, Position } from "geojson";
15
+ import { gpx, kml } from "@tmcw/togeojson";
16
+ import type { OverlayCorners } from "./geometry";
17
+
18
+ /** An image overlay's picture, as a Media handle behind this marker (not a
19
+ * real URL: an app-internal reference, since a resolvable link would need
20
+ * an expiring auth token baked in) -- in a KML GroundOverlay's href, and
21
+ * what asGroundOverlay makes of a GeoJSON one's `image`. */
22
+ export const MEDIA_HANDLE_PREFIX = "media-handle:";
23
+
24
+ /** A GeoJSON or GPX file's own colours, in the simplestyle convention
25
+ * (`stroke`/`fill`/`fill-opacity`/`marker-color`, which both GeoJSON tools
26
+ * and @tmcw/togeojson's gpx() use), copied onto the `color`/`opacity`
27
+ * properties every map layer actually reads (see MapCanvas.tsx's
28
+ * KML_FILL_LAYER). Fills only what's missing, so an explicit `color` wins.
29
+ * Not applied to KML: Gramps Connect's own KML carries `color` already, and
30
+ * leaving foreign KML alone keeps every existing overlay looking the same. */
31
+ function applySimplestyle(feature: Feature): Feature {
32
+ const props = feature.properties ?? {};
33
+ const isRegion = feature.geometry?.type === "Polygon" || feature.geometry?.type === "MultiPolygon";
34
+ const color = props.color ?? (isRegion ? props.fill ?? props.stroke : props.stroke ?? props["marker-color"]);
35
+ const opacity = props.opacity ?? (isRegion ? props["fill-opacity"] : undefined);
36
+ if (color === props.color && opacity === props.opacity) return feature;
37
+ return { ...feature, properties: { ...props, color, opacity } };
38
+ }
39
+
40
+ /** A GeoJSON file's features: a FeatureCollection, a single Feature or a
41
+ * bare Geometry are all valid GeoJSON documents. */
42
+ function geoJsonFeatures(doc: GeoJsonObject): Feature<Geometry | null>[] {
43
+ if (doc.type === "FeatureCollection") return (doc as { features?: Feature<Geometry | null>[] }).features ?? [];
44
+ if (doc.type === "Feature") return [doc as Feature<Geometry | null>];
45
+ return [{ type: "Feature", geometry: doc as Geometry, properties: {} }];
46
+ }
47
+
48
+ /** Gramps Connect's GeoJSON image overlay (see mapItemToGeoJson), turned into
49
+ * the same feature @tmcw/togeojson makes of a KML <gx:LatLonQuad>
50
+ * GroundOverlay -- so fetchAllKmlImageOverlays and everything that skips
51
+ * overlays read both formats the same way. */
52
+ function asGroundOverlay(feature: Feature): Feature {
53
+ const props = feature.properties ?? {};
54
+ if (props[IMAGE_OVERLAY_PROPERTY] !== true || typeof props.image !== "string") return feature;
55
+ const { [IMAGE_OVERLAY_PROPERTY]: _marker, image, ...rest } = props;
56
+ return {
57
+ ...feature,
58
+ properties: { ...rest, "@geometry-type": "groundoverlay", icon: `${MEDIA_HANDLE_PREFIX}${image}` },
59
+ };
60
+ }
61
+
62
+ /** A map file's text, as GeoJSON features. The format is read off the
63
+ * content (GeoJSON is JSON; GPX and KML differ by root element) rather
64
+ * than the media object's stored mime, which a file attached by another
65
+ * client may not have set to anything Gramps Connect recognizes. Throws on
66
+ * unparseable text; features with no geometry are dropped -- a KML
67
+ * Placemark without Point/LineString/Polygon (see @tmcw/togeojson's
68
+ * KMLOptions.skipNullGeometry), or a GeoJSON Feature with `geometry: null`,
69
+ * is of no use to a caller here, so dropped rather than threaded through
70
+ * as `Feature<Geometry | null>` everywhere downstream. */
71
+ export function parseMapFile(text: string): Feature[] {
72
+ const hasGeometry = (f: Feature<Geometry | null>): f is Feature => f.geometry != null;
73
+ if (text.trimStart().startsWith("{")) {
74
+ return geoJsonFeatures(JSON.parse(text) as GeoJsonObject).filter(hasGeometry).map(asGroundOverlay).map(applySimplestyle);
75
+ }
76
+ const dom = new DOMParser().parseFromString(text, "text/xml");
77
+ if (dom.documentElement.localName === "gpx") {
78
+ return gpx(dom).features.filter(hasGeometry).map(applySimplestyle);
79
+ }
80
+ return kml(dom).features.filter(hasGeometry);
81
+ }
82
+
83
+ /** An overlay file's SOURCE_PROPERTY, if it's GeoJSON that has one. */
84
+ export function overlaySourceOfText(text: string): string | undefined {
85
+ if (!text.trimStart().startsWith("{")) return undefined;
86
+ try {
87
+ const source = (JSON.parse(text) as Record<string, unknown>)[SOURCE_PROPERTY];
88
+ return typeof source === "string" ? source : undefined;
89
+ } catch {
90
+ return undefined;
91
+ }
92
+ }
93
+
94
+ /** Every Polygon ring inside `feature` -- itself (a plain Polygon), or each
95
+ * Polygon sub-geometry of a GeometryCollection. A country-level Wikidata/
96
+ * Commons outline (e.g. the United States) is often a disjoint shape --
97
+ * islands/territories a single Polygon ring can't express -- unlike a
98
+ * state's simpler single-ring Polygon. A KML file stores one as a
99
+ * <MultiGeometry> of several <Polygon>s, and @tmcw/togeojson reads *any* multi-child
100
+ * Placemark geometry back as a GeometryCollection (never reconstituting
101
+ * MultiPolygon) -- so that's the shape that actually reaches here, not
102
+ * "MultiPolygon" itself (confirmed live, 2026-09-09, round-tripping a
103
+ * synthetic MultiPolygon through both libraries). A non-Polygon sub-geometry
104
+ * (there shouldn't be one -- Gramps Connect never writes anything else into a
105
+ * region's own Placemark) is simply skipped rather than failing the whole
106
+ * feature. `[]` for anything else (a Point, a bare LineString route, ...),
107
+ * meaning "not a region" to every caller below. */
108
+ export function polygonRingsOf(feature: Feature): Position[][] {
109
+ if (feature.geometry?.type === "Polygon") return [feature.geometry.coordinates[0]];
110
+ if (feature.geometry?.type === "GeometryCollection") {
111
+ return feature.geometry.geometries.filter((g) => g.type === "Polygon").map((g) => g.coordinates[0]);
112
+ }
113
+ return [];
114
+ }
115
+
116
+ /** True for a feature fetchAllKmlRegions/MapCanvas.tsx's own shapes effect
117
+ * both treat as one drawable/controllable region -- see polygonRingsOf.
118
+ * Exported so MapCanvas.tsx's key/hidden/opacity stamping can use the exact
119
+ * same test fetchAllKmlRegions does: the two must never drift, or a row's
120
+ * key stops matching the feature it's meant to control -- found live as a
121
+ * place whose outline drew on the map (MapLibre's GeoJSON source splits a
122
+ * GeometryCollection into one sub-feature per geometry before rendering,
123
+ * each individually typed "Polygon") but never showed up in
124
+ * OverlayLayersPanel.tsx's list. */
125
+ export function isKmlRegion(feature: Feature): boolean {
126
+ return polygonRingsOf(feature).length > 0;
127
+ }
128
+
129
+ /** Every vertex of `feature` if it's a line: a LineString, a MultiLineString
130
+ * (a GPX track with several segments) or a GeometryCollection of only
131
+ * LineStrings (how @tmcw/togeojson reads a KML MultiGeometry -- see
132
+ * polygonRingsOf). `[]` for anything else, meaning "not a line". */
133
+ export function lineCoordsOf(feature: Feature): Position[] {
134
+ const geometry = feature.geometry;
135
+ if (geometry?.type === "LineString") return geometry.coordinates;
136
+ if (geometry?.type === "MultiLineString") return geometry.coordinates.flat();
137
+ if (geometry?.type === "GeometryCollection" && geometry.geometries.length > 0
138
+ && geometry.geometries.every((g) => g.type === "LineString")) {
139
+ return geometry.geometries.flatMap((g) => (g.type === "LineString" ? g.coordinates : []));
140
+ }
141
+ return [];
142
+ }
143
+
144
+ /** True for a feature fetchAllKmlLines lists and MapCanvas.tsx's shapes
145
+ * effect lets the panel hide/dim -- shared for the same reason as
146
+ * isKmlRegion: the two must never drift apart. A region is never a line. */
147
+ export function isKmlLine(feature: Feature): boolean {
148
+ return !isKmlRegion(feature) && lineCoordsOf(feature).length > 0;
149
+ }
150
+
151
+ /** An image overlay, as MapItemEditorDialog.tsx tracks one -- always 4
152
+ * explicit world corners; see kmlMedia.ts's KmlImageOverlay, which this
153
+ * mirrors on the write side. */
154
+ export interface ImageOverlay {
155
+ handle: string;
156
+ corners: OverlayCorners;
157
+ name?: string;
158
+ opacity?: number;
159
+ order?: number;
160
+ }
161
+
162
+ /** The property marking a feature as an image overlay rather than a shape:
163
+ * its Polygon is the image's 4 corners (top-left, top-right, bottom-right,
164
+ * bottom-left, closed), and `image` is the picture's Media handle. This
165
+ * app's own convention -- plain GeoJSON has no image overlays -- read back
166
+ * by parseMapFile. */
167
+ export const IMAGE_OVERLAY_PROPERTY = "gramps:image-overlay";
168
+
169
+ function overlayFeature(overlay: ImageOverlay): Feature {
170
+ const ring = [...overlay.corners, overlay.corners[0]];
171
+ return {
172
+ type: "Feature",
173
+ geometry: { type: "Polygon", coordinates: [ring] },
174
+ properties: {
175
+ [IMAGE_OVERLAY_PROPERTY]: true,
176
+ image: overlay.handle,
177
+ ...(overlay.name ? { name: overlay.name } : {}),
178
+ ...(overlay.opacity !== undefined && overlay.opacity !== 1 ? { opacity: overlay.opacity } : {}),
179
+ ...(overlay.order !== undefined ? { order: overlay.order } : {}),
180
+ },
181
+ };
182
+ }
183
+
184
+ /** Records which Media object an overlay was made from (mapOverlays.ts) --
185
+ * a "foreign member" of the FeatureCollection, which GeoJSON allows. */
186
+ export const SOURCE_PROPERTY = "gramps:source";
187
+
188
+ /** Every shape plus every image overlay, as one GeoJSON FeatureCollection.
189
+ * Overlays go last: kmlMedia.ts's region/line row indexes count every
190
+ * feature in the file but MapCanvas.tsx's leave overlays out, and the two
191
+ * only agree while no overlay comes before a shape. */
192
+ export function mapItemToGeoJson(features: Feature[], overlays: ImageOverlay[] = [], source?: string): string {
193
+ const collection: FeatureCollection & { [SOURCE_PROPERTY]?: string } = {
194
+ type: "FeatureCollection",
195
+ ...(source ? { [SOURCE_PROPERTY]: source } : {}),
196
+ features: [...features, ...overlays.map(overlayFeature)],
197
+ };
198
+ return JSON.stringify(collection);
199
+ }