@gramps-connect/story 0.1.2 → 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/dist/embed.js +43 -43
- package/dist/player.js +57 -13
- 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 +49 -18
- 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,11 +34,21 @@ 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
|
|
|
34
|
-
/** Frame the
|
|
35
|
-
*
|
|
36
|
-
|
|
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;
|
|
37
52
|
const bounds = placeBounds(place);
|
|
38
53
|
if (bounds) {
|
|
39
54
|
map.fitBounds([[bounds[0], bounds[1]], [bounds[2], bounds[3]]], { maxZoom: 17, duration });
|
|
@@ -43,13 +58,24 @@ function moveCamera(map: MapLibreMap, place: Place | undefined, duration: number
|
|
|
43
58
|
if (center) map.flyTo({ center, zoom: 9, essential: true, duration });
|
|
44
59
|
}
|
|
45
60
|
|
|
46
|
-
/** The pin on the place;
|
|
47
|
-
|
|
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) {
|
|
48
64
|
if (!marker) return;
|
|
65
|
+
const center = placeCenter(slide.place);
|
|
49
66
|
if (center) marker.setLngLat(center);
|
|
50
|
-
marker
|
|
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);
|
|
51
70
|
}
|
|
52
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
|
+
|
|
53
79
|
export function MapBackground({ initialCenter, current, dark, colors, panelFraction, ohmYear, resolveMedia }: {
|
|
54
80
|
initialCenter: [number, number];
|
|
55
81
|
current: MapSlide;
|
|
@@ -105,10 +131,9 @@ export function MapBackground({ initialCenter, current, dark, colors, panelFract
|
|
|
105
131
|
// Straight to the opening slide's place, unanimated. The camera effect
|
|
106
132
|
// below ran for it before this map existed (the container arrives a
|
|
107
133
|
// render late), so a story opened partway through starts there too.
|
|
108
|
-
const start = currentRef.current
|
|
109
|
-
|
|
110
|
-
map
|
|
111
|
-
if (startCenter) moveCamera(map, start, 0);
|
|
134
|
+
const start = currentRef.current;
|
|
135
|
+
map.jumpTo({ center: placeCenter(start.place) ?? initialCenter });
|
|
136
|
+
moveCamera(map, start, 0);
|
|
112
137
|
map.on("load", () => {
|
|
113
138
|
const c = colorsRef.current;
|
|
114
139
|
const el = document.createElement("div");
|
|
@@ -120,7 +145,7 @@ export function MapBackground({ initialCenter, current, dark, colors, panelFract
|
|
|
120
145
|
+ `</svg>`;
|
|
121
146
|
// The pin's tip, not its middle, sits on the coordinate.
|
|
122
147
|
markerRef.current = new maplibregl.Marker({ element: el, anchor: "bottom" }).setLngLat(initialCenter).addTo(map);
|
|
123
|
-
placeMarker(markerRef.current,
|
|
148
|
+
placeMarker(markerRef.current, currentRef.current);
|
|
124
149
|
});
|
|
125
150
|
return () => {
|
|
126
151
|
markerRef.current?.remove();
|
|
@@ -186,18 +211,25 @@ export function MapBackground({ initialCenter, current, dark, colors, panelFract
|
|
|
186
211
|
"text-halo-width": 1.5,
|
|
187
212
|
},
|
|
188
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
|
+
});
|
|
189
219
|
}, [ready]);
|
|
190
220
|
|
|
191
221
|
const place = current.place;
|
|
192
222
|
|
|
193
|
-
// 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
|
|
194
224
|
// swap empties the source.
|
|
195
225
|
useEffect(() => {
|
|
196
226
|
const map = mapRef.current;
|
|
197
227
|
if (!map || !ready) return;
|
|
198
228
|
const source = map.getSource(SHAPE_SOURCE) as GeoJSONSource | undefined;
|
|
199
229
|
source?.setData((place?.geometry as GeoJSON | undefined) ?? EMPTY);
|
|
200
|
-
|
|
230
|
+
const dots = map.getSource(PLACES_SOURCE) as GeoJSONSource | undefined;
|
|
231
|
+
dots?.setData(placeDots(current.overview));
|
|
232
|
+
}, [ready, current.key, place, current.overview]);
|
|
201
233
|
|
|
202
234
|
// Camera: once per slide (see moveCamera). Keyed to the slide, not to `ready`, so a
|
|
203
235
|
// map-mode switch never yanks the view back from wherever the viewer
|
|
@@ -205,10 +237,9 @@ export function MapBackground({ initialCenter, current, dark, colors, panelFract
|
|
|
205
237
|
useEffect(() => {
|
|
206
238
|
const map = mapRef.current;
|
|
207
239
|
if (!map) return;
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
if (center) moveCamera(map, place, 900);
|
|
240
|
+
placeMarker(markerRef.current, current);
|
|
241
|
+
// No place and no overview: the camera stays put.
|
|
242
|
+
moveCamera(map, current, 900);
|
|
212
243
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
213
244
|
}, [current.key]);
|
|
214
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
|
+
}
|