@gramps-connect/story 0.1.1 → 0.1.3
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 +4 -0
- package/dist/embed.js +43 -43
- package/dist/player.js +70 -17
- package/dist/player.js.map +2 -2
- package/dist/types/player/MapBackground.d.ts +4 -0
- package/dist/types/player/geo.d.ts +8 -0
- package/package.json +1 -1
- package/src/player/MapBackground.tsx +68 -22
- package/src/player/StoryPlayer.tsx +8 -2
- package/src/player/geo.ts +22 -0
|
@@ -6,3 +6,11 @@ export declare function placeBounds(place: Place | undefined): Bounds | null;
|
|
|
6
6
|
/** Where to put the pin: the place's own point, else the middle of its
|
|
7
7
|
* shape and overlays. Null for a slide with no position at all. */
|
|
8
8
|
export declare function placeCenter(place: Place | undefined): LonLat | null;
|
|
9
|
+
export interface StoryPlaces {
|
|
10
|
+
points: LonLat[];
|
|
11
|
+
bounds: Bounds;
|
|
12
|
+
}
|
|
13
|
+
/** Every place in the story at once, for its opening view: one point per
|
|
14
|
+
* distinct place, and the box around all of them, shapes and overlays
|
|
15
|
+
* included. Null when no slide has a position. */
|
|
16
|
+
export declare function storyPlaces(places: (Place | undefined)[]): StoryPlaces | null;
|
package/package.json
CHANGED
|
@@ -12,7 +12,7 @@ import type { GeoJSONSource, Map as MapLibreMap, Marker as MapLibreMarker } from
|
|
|
12
12
|
import type { FeatureCollection, GeoJSON } from "geojson";
|
|
13
13
|
import type { Overlay, Place } from "../types";
|
|
14
14
|
import { dateCoversYear } from "./dates";
|
|
15
|
-
import { placeBounds, placeCenter } from "./geo";
|
|
15
|
+
import { placeBounds, placeCenter, type StoryPlaces } from "./geo";
|
|
16
16
|
import { applyOhmYear, crossfadeStyleSwap, mapStyleKey, mapStyleUrl } from "./mapStyles";
|
|
17
17
|
import { PIN_PATH_D, type PlayerColors } from "./theme";
|
|
18
18
|
|
|
@@ -22,6 +22,11 @@ const LINE_LAYER = "story-shape-line";
|
|
|
22
22
|
const POINT_LAYER = "story-shape-points";
|
|
23
23
|
const LABEL_LAYER = "story-shape-labels";
|
|
24
24
|
const OVERLAY_PREFIX = "story-overlay-";
|
|
25
|
+
const PLACES_SOURCE = "story-places";
|
|
26
|
+
const PLACES_LAYER = "story-places-dots";
|
|
27
|
+
// Room around the outermost dots of the opening view, inside the part of
|
|
28
|
+
// the map the slide panel leaves free.
|
|
29
|
+
const OVERVIEW_PADDING = 60;
|
|
25
30
|
const EMPTY: FeatureCollection = { type: "FeatureCollection", features: [] };
|
|
26
31
|
|
|
27
32
|
/** The current slide, as far as the map cares. `key` is the slide id:
|
|
@@ -29,8 +34,48 @@ const EMPTY: FeatureCollection = { type: "FeatureCollection", features: [] };
|
|
|
29
34
|
export interface MapSlide {
|
|
30
35
|
key: string;
|
|
31
36
|
place: Place | undefined;
|
|
37
|
+
/** Every place in the story, on an opening slide with no place of its
|
|
38
|
+
* own: framed together, a dot each, instead of the pin. */
|
|
39
|
+
overview: StoryPlaces | null;
|
|
32
40
|
}
|
|
33
41
|
|
|
42
|
+
/** Frame the story's places for an overview; else the place's shape or
|
|
43
|
+
* overlays (usually a field, a route or an old map, far tighter than a
|
|
44
|
+
* town-level zoom); else fly to its point. */
|
|
45
|
+
function moveCamera(map: MapLibreMap, slide: MapSlide, duration: number) {
|
|
46
|
+
if (slide.overview) {
|
|
47
|
+
const [west, south, east, north] = slide.overview.bounds;
|
|
48
|
+
map.fitBounds([[west, south], [east, north]], { maxZoom: 9, padding: OVERVIEW_PADDING, duration });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const place = slide.place;
|
|
52
|
+
const bounds = placeBounds(place);
|
|
53
|
+
if (bounds) {
|
|
54
|
+
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { maxZoom: 17, duration });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const center = placeCenter(place);
|
|
58
|
+
if (center) map.flyTo({ center, zoom: 9, essential: true, duration });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The pin on the place; hidden for an overview (the dots stand in for
|
|
62
|
+
* it), else held where it was, dimmed, for a slide with no place. */
|
|
63
|
+
function placeMarker(marker: MapLibreMarker | null, slide: MapSlide) {
|
|
64
|
+
if (!marker) return;
|
|
65
|
+
const center = placeCenter(slide.place);
|
|
66
|
+
if (center) marker.setLngLat(center);
|
|
67
|
+
// Through the marker, not its element's style: maplibre rewrites that
|
|
68
|
+
// from the marker's own opacity every frame.
|
|
69
|
+
marker.setOpacity(slide.overview ? 0 : center ? 1 : 0.4);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const placeDots = (overview: StoryPlaces | null): FeatureCollection => ({
|
|
73
|
+
type: "FeatureCollection",
|
|
74
|
+
features: (overview?.points ?? []).map((coordinates) => ({
|
|
75
|
+
type: "Feature", properties: {}, geometry: { type: "Point", coordinates },
|
|
76
|
+
})),
|
|
77
|
+
});
|
|
78
|
+
|
|
34
79
|
export function MapBackground({ initialCenter, current, dark, colors, panelFraction, ohmYear, resolveMedia }: {
|
|
35
80
|
initialCenter: [number, number];
|
|
36
81
|
current: MapSlide;
|
|
@@ -64,6 +109,8 @@ export function MapBackground({ initialCenter, current, dark, colors, panelFract
|
|
|
64
109
|
// What the map was built or last swapped with, so a re-render with the
|
|
65
110
|
// same effective style never triggers a reload.
|
|
66
111
|
const appliedStyleKeyRef = useRef(mapStyleKey(dark, ohmYear));
|
|
112
|
+
const currentRef = useRef(current);
|
|
113
|
+
currentRef.current = current;
|
|
67
114
|
|
|
68
115
|
useEffect(() => {
|
|
69
116
|
if (!containerEl) return;
|
|
@@ -81,7 +128,12 @@ export function MapBackground({ initialCenter, current, dark, colors, panelFract
|
|
|
81
128
|
map.on("style.load", () => setReady(true));
|
|
82
129
|
// Center points in the part of the map the slide panel doesn't cover.
|
|
83
130
|
map.setPadding({ top: 0, bottom: 0, left: 0, right: containerEl.clientWidth * panelFraction });
|
|
84
|
-
|
|
131
|
+
// Straight to the opening slide's place, unanimated. The camera effect
|
|
132
|
+
// below ran for it before this map existed (the container arrives a
|
|
133
|
+
// render late), so a story opened partway through starts there too.
|
|
134
|
+
const start = currentRef.current;
|
|
135
|
+
map.jumpTo({ center: placeCenter(start.place) ?? initialCenter });
|
|
136
|
+
moveCamera(map, start, 0);
|
|
85
137
|
map.on("load", () => {
|
|
86
138
|
const c = colorsRef.current;
|
|
87
139
|
const el = document.createElement("div");
|
|
@@ -93,6 +145,7 @@ export function MapBackground({ initialCenter, current, dark, colors, panelFract
|
|
|
93
145
|
+ `</svg>`;
|
|
94
146
|
// The pin's tip, not its middle, sits on the coordinate.
|
|
95
147
|
markerRef.current = new maplibregl.Marker({ element: el, anchor: "bottom" }).setLngLat(initialCenter).addTo(map);
|
|
148
|
+
placeMarker(markerRef.current, currentRef.current);
|
|
96
149
|
});
|
|
97
150
|
return () => {
|
|
98
151
|
markerRef.current?.remove();
|
|
@@ -158,42 +211,35 @@ export function MapBackground({ initialCenter, current, dark, colors, panelFract
|
|
|
158
211
|
"text-halo-width": 1.5,
|
|
159
212
|
},
|
|
160
213
|
});
|
|
214
|
+
map.addSource(PLACES_SOURCE, { type: "geojson", data: EMPTY });
|
|
215
|
+
map.addLayer({
|
|
216
|
+
id: PLACES_LAYER, type: "circle", source: PLACES_SOURCE,
|
|
217
|
+
paint: { "circle-radius": 6, "circle-color": c.mark, "circle-stroke-width": 2, "circle-stroke-color": c.surface },
|
|
218
|
+
});
|
|
161
219
|
}, [ready]);
|
|
162
220
|
|
|
163
221
|
const place = current.place;
|
|
164
222
|
|
|
165
|
-
// The current slide's shape. Re-supplied after each style load, since a
|
|
223
|
+
// The current slide's shape, or the overview's dots. Re-supplied after each style load, since a
|
|
166
224
|
// swap empties the source.
|
|
167
225
|
useEffect(() => {
|
|
168
226
|
const map = mapRef.current;
|
|
169
227
|
if (!map || !ready) return;
|
|
170
228
|
const source = map.getSource(SHAPE_SOURCE) as GeoJSONSource | undefined;
|
|
171
229
|
source?.setData((place?.geometry as GeoJSON | undefined) ?? EMPTY);
|
|
172
|
-
|
|
230
|
+
const dots = map.getSource(PLACES_SOURCE) as GeoJSONSource | undefined;
|
|
231
|
+
dots?.setData(placeDots(current.overview));
|
|
232
|
+
}, [ready, current.key, place, current.overview]);
|
|
173
233
|
|
|
174
|
-
// Camera: once per slide.
|
|
175
|
-
// field, a route or an old map, far tighter than a town-level zoom);
|
|
176
|
-
// otherwise fly to the point. Keyed to the slide, not to `ready`, so a
|
|
234
|
+
// Camera: once per slide (see moveCamera). Keyed to the slide, not to `ready`, so a
|
|
177
235
|
// map-mode switch never yanks the view back from wherever the viewer
|
|
178
236
|
// has since panned.
|
|
179
237
|
useEffect(() => {
|
|
180
238
|
const map = mapRef.current;
|
|
181
239
|
if (!map) return;
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
// No place: the camera stays put and the marker dims.
|
|
186
|
-
if (marker) marker.getElement().style.opacity = "0.4";
|
|
187
|
-
return;
|
|
188
|
-
}
|
|
189
|
-
marker?.setLngLat(center);
|
|
190
|
-
if (marker) marker.getElement().style.opacity = "1";
|
|
191
|
-
const bounds = placeBounds(place);
|
|
192
|
-
if (bounds) {
|
|
193
|
-
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { maxZoom: 17, duration: 900 });
|
|
194
|
-
} else {
|
|
195
|
-
map.flyTo({ center, zoom: 9, essential: true, duration: 900 });
|
|
196
|
-
}
|
|
240
|
+
placeMarker(markerRef.current, current);
|
|
241
|
+
// No place and no overview: the camera stays put.
|
|
242
|
+
moveCamera(map, current, 900);
|
|
197
243
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
198
244
|
}, [current.key]);
|
|
199
245
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, type TouchEvent } from "react";
|
|
7
7
|
import type { MediaItem, Slide, StoryDoc } from "../types";
|
|
8
8
|
import { timelineYear } from "./dates";
|
|
9
|
-
import { placeCenter } from "./geo";
|
|
9
|
+
import { placeCenter, storyPlaces } from "./geo";
|
|
10
10
|
import { MapBackground, type MapSlide } from "./MapBackground";
|
|
11
11
|
import { MapModeControl, type MapMode, type MapModeControlProps } from "./MapModeControl";
|
|
12
12
|
import { parseStoryText } from "./text";
|
|
@@ -299,7 +299,13 @@ export function StoryPlayer({
|
|
|
299
299
|
|
|
300
300
|
// Stable per slide, so a re-render for an unrelated reason (a map-mode
|
|
301
301
|
// click) never looks like a new slide to the map's camera.
|
|
302
|
-
const
|
|
302
|
+
const places = useMemo(() => storyPlaces(slides.map((s) => s.place)), [slides]);
|
|
303
|
+
const mapSlide = useMemo<MapSlide>(() => ({
|
|
304
|
+
key: slide?.id ?? String(index),
|
|
305
|
+
place: slide?.place,
|
|
306
|
+
// The opening card, with no place of its own, shows where the story goes.
|
|
307
|
+
overview: index === 0 && !placeCenter(slide?.place) ? places : null,
|
|
308
|
+
}), [slide, index, places]);
|
|
303
309
|
const firstCenter = useMemo(() => {
|
|
304
310
|
if (story.presentation?.basemap === "none") return null;
|
|
305
311
|
for (const s of slides) {
|
package/src/player/geo.ts
CHANGED
|
@@ -44,3 +44,25 @@ export function placeCenter(place: Place | undefined): LonLat | null {
|
|
|
44
44
|
const bounds = placeBounds(place);
|
|
45
45
|
return bounds ? [(bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2] : null;
|
|
46
46
|
}
|
|
47
|
+
|
|
48
|
+
export interface StoryPlaces {
|
|
49
|
+
points: LonLat[];
|
|
50
|
+
bounds: Bounds;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Every place in the story at once, for its opening view: one point per
|
|
54
|
+
* distinct place, and the box around all of them, shapes and overlays
|
|
55
|
+
* included. Null when no slide has a position. */
|
|
56
|
+
export function storyPlaces(places: (Place | undefined)[]): StoryPlaces | null {
|
|
57
|
+
const bounds: Bounds = [Infinity, Infinity, -Infinity, -Infinity];
|
|
58
|
+
const points = new Map<string, LonLat>();
|
|
59
|
+
for (const place of places) {
|
|
60
|
+
const center = placeCenter(place);
|
|
61
|
+
if (!center) continue;
|
|
62
|
+
points.set(`${center[0].toFixed(5)},${center[1].toFixed(5)}`, center);
|
|
63
|
+
visit(center, bounds);
|
|
64
|
+
const box = placeBounds(place);
|
|
65
|
+
if (box) visit([[box[0], box[1]], [box[2], box[3]]], bounds);
|
|
66
|
+
}
|
|
67
|
+
return points.size ? { points: [...points.values()], bounds } : null;
|
|
68
|
+
}
|