@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,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
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// Turning a map file's features into what the map editor's terra-draw
|
|
2
|
+
// canvas can edit, and back into features to save (as GeoJSON, see
|
|
3
|
+
// mapItemToGeoJson), with nothing dropped on the way. terra-draw only
|
|
4
|
+
// knows single Points, LineStrings and Polygons with 2D, at-most-9-decimal
|
|
5
|
+
// coordinates, and only keeps the few properties the editor itself sets
|
|
6
|
+
// (name/color/opacity) --
|
|
7
|
+
// while a KML, GeoJSON or GPX file can hold multi-part shapes, arbitrary
|
|
8
|
+
// properties and a GPS track's per-point times. So:
|
|
9
|
+
//
|
|
10
|
+
// - a multi-part shape is split into single parts that share a group,
|
|
11
|
+
// and rejoined on save (parts whose name/colour/opacity were changed
|
|
12
|
+
// apart become separate shapes, since one KML Placemark has one style);
|
|
13
|
+
// - every other property is set aside per part and put back on save;
|
|
14
|
+
// - a track's times are kept only while its points are unchanged --
|
|
15
|
+
// reshaped, it no longer matches the recording, so it saves as a plain
|
|
16
|
+
// line;
|
|
17
|
+
// - elevations are dropped (nothing here uses them);
|
|
18
|
+
// - anything terra-draw still can't take (a polygon with holes, one that
|
|
19
|
+
// crosses itself) is "locked": shown but not editable, and saved back
|
|
20
|
+
// exactly as it was, rather than silently left out of the save.
|
|
21
|
+
import type { Feature, Geometry, LineString, Point, Polygon, Position } from "geojson";
|
|
22
|
+
|
|
23
|
+
/** What the editor reads and writes on each terra-draw feature itself. */
|
|
24
|
+
const EDITOR_OWNED = new Set(["name", "color", "opacity"]);
|
|
25
|
+
/** Parser bookkeeping with nothing worth saving: the per-point data that's
|
|
26
|
+
* carried separately (coordinateProperties), @tmcw/togeojson's GPX type tag,
|
|
27
|
+
* and a KML style reference whose <Style> doesn't come along. */
|
|
28
|
+
const DROPPED = new Set(["coordinateProperties", "_gpxType", "styleUrl", "@geometry-type"]);
|
|
29
|
+
|
|
30
|
+
export type EditableGeometry = Point | LineString | Polygon;
|
|
31
|
+
export type EditMode = "point" | "linestring" | "polygon";
|
|
32
|
+
|
|
33
|
+
/** One single-geometry part, ready for terra-draw. `sourceKey` rides on the
|
|
34
|
+
* terra-draw feature as a property so save can find what was set aside. */
|
|
35
|
+
export interface EditablePart {
|
|
36
|
+
sourceKey: string;
|
|
37
|
+
mode: EditMode;
|
|
38
|
+
geometry: EditableGeometry;
|
|
39
|
+
name?: string;
|
|
40
|
+
color?: string;
|
|
41
|
+
opacity?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface PartSource {
|
|
45
|
+
group: string;
|
|
46
|
+
/** Position within its group, so rejoined parts keep their order. */
|
|
47
|
+
index: number;
|
|
48
|
+
extra: Record<string, unknown>;
|
|
49
|
+
/** A track part's per-point times, and the (2D, rounded) points they
|
|
50
|
+
* belong to -- kept only while the saved points still match. */
|
|
51
|
+
times?: string[];
|
|
52
|
+
coords?: Position[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface EditSession {
|
|
56
|
+
parts: EditablePart[];
|
|
57
|
+
sources: Map<string, PartSource>;
|
|
58
|
+
/** Parts terra-draw wouldn't accept -- see lockPart. */
|
|
59
|
+
locked: Map<string, EditablePart>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** terra-draw rejects coordinates with more than 9 decimal places (its
|
|
63
|
+
* coordinatePrecision) -- well under a millimetre, so rounding loses
|
|
64
|
+
* nothing real. Also drops a third (elevation) value. */
|
|
65
|
+
function round2d(position: Position): Position {
|
|
66
|
+
return [Number(position[0].toFixed(9)), Number(position[1].toFixed(9))];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function singleParts(geometry: Geometry): EditableGeometry[] {
|
|
70
|
+
switch (geometry.type) {
|
|
71
|
+
case "Point":
|
|
72
|
+
return [{ type: "Point", coordinates: round2d(geometry.coordinates) }];
|
|
73
|
+
case "LineString":
|
|
74
|
+
return [{ type: "LineString", coordinates: geometry.coordinates.map(round2d) }];
|
|
75
|
+
case "Polygon":
|
|
76
|
+
return [{ type: "Polygon", coordinates: geometry.coordinates.map((ring) => ring.map(round2d)) }];
|
|
77
|
+
case "MultiPoint":
|
|
78
|
+
return geometry.coordinates.map((c) => ({ type: "Point", coordinates: round2d(c) }));
|
|
79
|
+
case "MultiLineString":
|
|
80
|
+
return geometry.coordinates.map((line) => ({ type: "LineString", coordinates: line.map(round2d) }));
|
|
81
|
+
case "MultiPolygon":
|
|
82
|
+
return geometry.coordinates.map((poly) => ({ type: "Polygon", coordinates: poly.map((ring) => ring.map(round2d)) }));
|
|
83
|
+
case "GeometryCollection":
|
|
84
|
+
return geometry.geometries.flatMap(singleParts);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const modeOf = (geometry: EditableGeometry): EditMode =>
|
|
89
|
+
geometry.type === "Point" ? "point" : geometry.type === "LineString" ? "linestring" : "polygon";
|
|
90
|
+
|
|
91
|
+
/** A feature's properties minus the editor's own and the parser's
|
|
92
|
+
* bookkeeping -- kept as they are, nested values included. */
|
|
93
|
+
function extraProperties(properties: Record<string, unknown>): Record<string, unknown> {
|
|
94
|
+
const extra: Record<string, unknown> = {};
|
|
95
|
+
for (const [key, value] of Object.entries(properties)) {
|
|
96
|
+
if (EDITOR_OWNED.has(key) || DROPPED.has(key) || value === undefined || value === null) continue;
|
|
97
|
+
extra[key] = value;
|
|
98
|
+
}
|
|
99
|
+
return extra;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Each part's times, if the feature has them in @tmcw/togeojson's own
|
|
103
|
+
* shape: string[] for a LineString, string[][] for a MultiLineString. */
|
|
104
|
+
function partTimes(feature: Feature, partCount: number): (string[] | undefined)[] {
|
|
105
|
+
const times = (feature.properties?.coordinateProperties as { times?: unknown } | undefined)?.times;
|
|
106
|
+
if (!Array.isArray(times) || times.length === 0) return [];
|
|
107
|
+
if (feature.geometry?.type === "LineString" && times.every((t) => typeof t === "string")) {
|
|
108
|
+
return [times as string[]];
|
|
109
|
+
}
|
|
110
|
+
if (feature.geometry?.type === "MultiLineString" && times.length === partCount && times.every(Array.isArray)) {
|
|
111
|
+
return times as string[][];
|
|
112
|
+
}
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Splits a file's features (e.g. parseMapFile's) into
|
|
117
|
+
* editable single parts. A polygon with holes is locked from the start:
|
|
118
|
+
* terra-draw's polygon editing only knows an outer ring. */
|
|
119
|
+
export function prepareForEditing(features: Feature[], firstIndex = 0): EditSession {
|
|
120
|
+
const session: EditSession = { parts: [], sources: new Map(), locked: new Map() };
|
|
121
|
+
features.forEach((feature, i) => {
|
|
122
|
+
const f = firstIndex + i;
|
|
123
|
+
if (!feature.geometry) return;
|
|
124
|
+
const properties = (feature.properties ?? {}) as Record<string, unknown>;
|
|
125
|
+
const extra = extraProperties(properties);
|
|
126
|
+
const geometries = singleParts(feature.geometry);
|
|
127
|
+
const times = partTimes(feature, geometries.length);
|
|
128
|
+
const opacityRaw = Number(properties.opacity);
|
|
129
|
+
const opacity = Number.isFinite(opacityRaw) && opacityRaw >= 0 && opacityRaw <= 1 ? opacityRaw : undefined;
|
|
130
|
+
geometries.forEach((geometry, index) => {
|
|
131
|
+
const sourceKey = `f${f}p${index}`;
|
|
132
|
+
const partTimesHere = times[index];
|
|
133
|
+
const keepTimes = geometry.type === "LineString" && partTimesHere?.length === geometry.coordinates.length;
|
|
134
|
+
session.sources.set(sourceKey, {
|
|
135
|
+
group: `f${f}`, index, extra,
|
|
136
|
+
...(keepTimes ? { times: partTimesHere, coords: geometry.coordinates } : {}),
|
|
137
|
+
});
|
|
138
|
+
const part: EditablePart = {
|
|
139
|
+
sourceKey, mode: modeOf(geometry), geometry,
|
|
140
|
+
name: typeof properties.name === "string" ? properties.name : undefined,
|
|
141
|
+
color: typeof properties.color === "string" ? properties.color : undefined,
|
|
142
|
+
...(geometry.type === "Polygon" ? { opacity } : {}),
|
|
143
|
+
};
|
|
144
|
+
if (geometry.type === "Polygon" && geometry.coordinates.length > 1) session.locked.set(sourceKey, part);
|
|
145
|
+
else session.parts.push(part);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
return session;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Adds more features (a file imported while editing) after the session's
|
|
152
|
+
* own, so they save after them too. Returns the session of just the new
|
|
153
|
+
* ones, whose parts the canvas still has to take. */
|
|
154
|
+
export function addToSession(session: EditSession, features: Feature[]): EditSession {
|
|
155
|
+
let next = 0;
|
|
156
|
+
for (const source of session.sources.values()) next = Math.max(next, Number(source.group.slice(1)) + 1);
|
|
157
|
+
const added = prepareForEditing(features, next);
|
|
158
|
+
session.parts.push(...added.parts);
|
|
159
|
+
for (const [key, source] of added.sources) session.sources.set(key, source);
|
|
160
|
+
for (const [key, part] of added.locked) session.locked.set(key, part);
|
|
161
|
+
return added;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Marks a part terra-draw refused to load (addFeatures' validation): it's
|
|
165
|
+
* no longer editable, and assembleForSaving writes it back unchanged. */
|
|
166
|
+
export function lockPart(session: EditSession, sourceKey: string): void {
|
|
167
|
+
const index = session.parts.findIndex((part) => part.sourceKey === sourceKey);
|
|
168
|
+
if (index < 0) return;
|
|
169
|
+
session.locked.set(sourceKey, session.parts[index]);
|
|
170
|
+
session.parts.splice(index, 1);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** A part as saved from the canvas: its (possibly edited) geometry and the
|
|
174
|
+
* editor's own properties, plus the sourceKey it was loaded with -- absent
|
|
175
|
+
* for a shape drawn new in this session. */
|
|
176
|
+
export interface SavedPart {
|
|
177
|
+
sourceKey?: string;
|
|
178
|
+
geometry: EditableGeometry;
|
|
179
|
+
name?: string;
|
|
180
|
+
color?: string;
|
|
181
|
+
opacity?: number;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const samePositions = (a: Position[], b: Position[]) =>
|
|
185
|
+
a.length === b.length && a.every((p, i) => p[0] === b[i][0] && p[1] === b[i][1]);
|
|
186
|
+
|
|
187
|
+
/** Joins single geometries back into one -- Multi* when they're all one
|
|
188
|
+
* kind, a GeometryCollection when mixed (what a KML MultiGeometry is). */
|
|
189
|
+
function joinGeometries(geometries: EditableGeometry[]): Geometry {
|
|
190
|
+
if (geometries.length === 1) return geometries[0];
|
|
191
|
+
const kinds = new Set(geometries.map((g) => g.type));
|
|
192
|
+
if (kinds.size === 1) {
|
|
193
|
+
if (kinds.has("Point")) return { type: "MultiPoint", coordinates: geometries.map((g) => (g as Point).coordinates) };
|
|
194
|
+
if (kinds.has("LineString")) return { type: "MultiLineString", coordinates: geometries.map((g) => (g as LineString).coordinates) };
|
|
195
|
+
return { type: "MultiPolygon", coordinates: geometries.map((g) => (g as Polygon).coordinates) };
|
|
196
|
+
}
|
|
197
|
+
return { type: "GeometryCollection", geometries };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Everything to save: the canvas's parts with their set-aside data put
|
|
201
|
+
* back, multi-part shapes rejoined, and locked parts as they were loaded.
|
|
202
|
+
* Features come out in their original file order, new shapes last. */
|
|
203
|
+
export function assembleForSaving(session: EditSession, saved: SavedPart[]): Feature[] {
|
|
204
|
+
interface Entry { part: SavedPart; source?: PartSource }
|
|
205
|
+
const entries: Entry[] = [
|
|
206
|
+
...saved.map((part) => ({ part, source: part.sourceKey ? session.sources.get(part.sourceKey) : undefined })),
|
|
207
|
+
...[...session.locked.values()].map((part) => ({ part, source: session.sources.get(part.sourceKey) })),
|
|
208
|
+
];
|
|
209
|
+
// One bucket per original feature and style (a part restyled apart from
|
|
210
|
+
// its siblings becomes its own shape), and one per new shape.
|
|
211
|
+
const buckets = new Map<string, Entry[]>();
|
|
212
|
+
let newCount = 0;
|
|
213
|
+
for (const entry of entries) {
|
|
214
|
+
const { part, source } = entry;
|
|
215
|
+
const key = source
|
|
216
|
+
? `${source.group}|${part.name ?? ""}|${part.color ?? ""}|${part.opacity ?? ""}`
|
|
217
|
+
: `new${newCount++}`;
|
|
218
|
+
const bucket = buckets.get(key);
|
|
219
|
+
if (bucket) bucket.push(entry);
|
|
220
|
+
else buckets.set(key, [entry]);
|
|
221
|
+
}
|
|
222
|
+
const groupOrder = (key: string) => (key.startsWith("new") ? Infinity : Number(key.slice(1, key.indexOf("|"))));
|
|
223
|
+
return [...buckets.entries()]
|
|
224
|
+
.sort(([a], [b]) => groupOrder(a) - groupOrder(b))
|
|
225
|
+
.map(([, bucket]) => {
|
|
226
|
+
bucket.sort((a, b) => (a.source?.index ?? 0) - (b.source?.index ?? 0));
|
|
227
|
+
const first = bucket[0];
|
|
228
|
+
const geometry = joinGeometries(bucket.map((e) => e.part.geometry));
|
|
229
|
+
// Times only when every line in the shape still has its own, on
|
|
230
|
+
// unchanged points -- @tmcw/togeojson's own shape for them is all of
|
|
231
|
+
// a track's segments timed or none.
|
|
232
|
+
const times = bucket.map(({ part, source }) =>
|
|
233
|
+
part.geometry.type === "LineString" && source?.times && source.coords
|
|
234
|
+
&& samePositions(part.geometry.coordinates, source.coords)
|
|
235
|
+
? source.times
|
|
236
|
+
: undefined);
|
|
237
|
+
const allTimed = bucket.every((e) => e.part.geometry.type === "LineString") && times.every(Boolean);
|
|
238
|
+
return {
|
|
239
|
+
type: "Feature",
|
|
240
|
+
geometry,
|
|
241
|
+
properties: {
|
|
242
|
+
...(first.source?.extra ?? {}),
|
|
243
|
+
...(first.part.color !== undefined ? { color: first.part.color } : {}),
|
|
244
|
+
...(first.part.name ? { name: first.part.name } : {}),
|
|
245
|
+
...(first.part.opacity !== undefined ? { opacity: first.part.opacity } : {}),
|
|
246
|
+
...(allTimed ? { coordinateProperties: { times: bucket.length === 1 ? times[0] : times } } : {}),
|
|
247
|
+
},
|
|
248
|
+
} as Feature;
|
|
249
|
+
});
|
|
250
|
+
}
|
|
@@ -182,7 +182,9 @@ export function MapBackground({ initialCenter, current, dark, colors, panelFract
|
|
|
182
182
|
map.addLayer({
|
|
183
183
|
id: FILL_LAYER, type: "fill", source: SHAPE_SOURCE,
|
|
184
184
|
filter: ["==", ["geometry-type"], "Polygon"],
|
|
185
|
-
|
|
185
|
+
// And its own `opacity` (what the map editor sets on an area) over
|
|
186
|
+
// a light default.
|
|
187
|
+
paint: { "fill-color": color, "fill-opacity": ["coalesce", ["to-number", ["get", "opacity"], 0.25], 0.25] },
|
|
186
188
|
});
|
|
187
189
|
map.addLayer({
|
|
188
190
|
id: LINE_LAYER, type: "line", source: SHAPE_SOURCE,
|