@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.
@@ -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.2",
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,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 place's shape or overlays (usually a field, a route or an old
35
- * map, far tighter than a town-level zoom), else fly to its point. */
36
- function moveCamera(map: MapLibreMap, place: Place | undefined, duration: number) {
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; held where it was, dimmed, for a slide with none. */
47
- function placeMarker(marker: MapLibreMarker | null, center: [number, number] | null) {
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.getElement().style.opacity = center ? "1" : "0.4";
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.place;
109
- const startCenter = placeCenter(start);
110
- map.jumpTo({ center: startCenter ?? initialCenter });
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, placeCenter(currentRef.current.place));
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
- }, [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]);
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
- const center = placeCenter(place);
209
- placeMarker(markerRef.current, center);
210
- // No place: the camera stays put.
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 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
+ }