@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gramps-connect/story",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Read, write, validate and present .story.zip files: portable, versioned family-history stories.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "type": "module",
@@ -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
- map.jumpTo({ center: initialCenter });
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
- }, [ready, current.key, place]);
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. A shape or overlay is framed (it's usually a
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
- const center = placeCenter(place);
183
- const marker = markerRef.current;
184
- if (!center) {
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 mapSlide = useMemo<MapSlide>(() => ({ key: slide?.id ?? String(index), place: slide?.place }), [slide, index]);
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
+ }