@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,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
- paint: { "fill-color": color, "fill-opacity": 0.25 },
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,