@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.
- package/README.md +13 -4
- package/dist/editor.js +1710 -121
- 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 +4 -1
- package/dist/types/editor/PlaceMapDialog.d.ts +18 -0
- package/dist/types/editor/StoryEditor.d.ts +4 -1
- package/dist/types/editor/index.d.ts +4 -0
- package/dist/types/editor/mapEditorStrings.d.ts +50 -0
- package/dist/types/editor/placeShapes.d.ts +13 -0
- package/dist/types/editor/strings.d.ts +13 -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 +12 -2
- package/src/editor/PlaceMapDialog.tsx +228 -0
- package/src/editor/StoryEditor.tsx +24 -1
- package/src/editor/index.ts +6 -0
- package/src/editor/mapEditorStrings.ts +109 -0
- package/src/editor/placeShapes.ts +63 -0
- package/src/editor/strings.ts +27 -1
- 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,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
|
+
}
|