@gramps-connect/story 0.1.2 → 0.2.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.
- package/README.md +49 -2
- package/dist/editor.js +3539 -0
- package/dist/editor.js.map +7 -0
- package/dist/embed.js +43 -43
- package/dist/player.js +74 -13
- package/dist/player.js.map +2 -2
- package/dist/types/editor/DateField.d.ts +12 -0
- package/dist/types/editor/SlideList.d.ts +12 -0
- package/dist/types/editor/StoryEditor.d.ts +28 -0
- package/dist/types/editor/StoryTextField.d.ts +9 -0
- package/dist/types/editor/dates.d.ts +6 -0
- package/dist/types/editor/index.d.ts +7 -0
- package/dist/types/editor/model.d.ts +55 -0
- package/dist/types/editor/strings.d.ts +40 -0
- package/dist/types/player/MapBackground.d.ts +4 -0
- package/dist/types/player/StoryPlayer.d.ts +4 -1
- package/dist/types/player/geo.d.ts +8 -0
- package/package.json +21 -5
- package/src/editor/DateField.tsx +95 -0
- package/src/editor/SlideList.tsx +54 -0
- package/src/editor/StoryEditor.tsx +333 -0
- package/src/editor/StoryTextField.tsx +61 -0
- package/src/editor/dates.ts +21 -0
- package/src/editor/index.ts +10 -0
- package/src/editor/model.ts +167 -0
- package/src/editor/strings.ts +82 -0
- package/src/player/MapBackground.tsx +49 -18
- package/src/player/StoryPlayer.tsx +22 -3
- package/src/player/geo.ts +22 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// A plain Textarea plus Bold/Italic buttons that wrap the selection in the
|
|
2
|
+
// story format's `**`/`*` markers -- the only formatting a slide's text has
|
|
3
|
+
// (parseStoryText is the matching reader), so no rich-text editor.
|
|
4
|
+
import { useRef } from "react";
|
|
5
|
+
import { Button, Group, Stack, Text, Textarea } from "@mantine/core";
|
|
6
|
+
|
|
7
|
+
/** Wraps the textarea's selection in `marker` (or unwraps it, if the
|
|
8
|
+
* selection is already exactly `marker…marker`); with nothing selected,
|
|
9
|
+
* inserts an empty pair and leaves the cursor between them. */
|
|
10
|
+
function applyMarker(el: HTMLTextAreaElement, marker: string, value: string, onChange: (v: string) => void) {
|
|
11
|
+
const start = el.selectionStart ?? value.length;
|
|
12
|
+
const end = el.selectionEnd ?? value.length;
|
|
13
|
+
if (start === end) {
|
|
14
|
+
onChange(value.slice(0, start) + marker + marker + value.slice(end));
|
|
15
|
+
requestAnimationFrame(() => {
|
|
16
|
+
el.focus();
|
|
17
|
+
el.setSelectionRange(start + marker.length, start + marker.length);
|
|
18
|
+
});
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const selected = value.slice(start, end);
|
|
22
|
+
const alreadyWrapped = selected.length > marker.length * 2 && selected.startsWith(marker) && selected.endsWith(marker);
|
|
23
|
+
const replacement = alreadyWrapped ? selected.slice(marker.length, selected.length - marker.length) : `${marker}${selected}${marker}`;
|
|
24
|
+
onChange(value.slice(0, start) + replacement + value.slice(end));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface StoryTextFieldProps {
|
|
28
|
+
label: string;
|
|
29
|
+
value: string;
|
|
30
|
+
onChange: (value: string) => void;
|
|
31
|
+
boldLabel?: string;
|
|
32
|
+
italicLabel?: string;
|
|
33
|
+
minRows?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function StoryTextField({ label, value, onChange, boldLabel = "Bold", italicLabel = "Italic", minRows = 3 }: StoryTextFieldProps) {
|
|
37
|
+
const ref = useRef<HTMLTextAreaElement>(null);
|
|
38
|
+
return (
|
|
39
|
+
<Stack gap={4}>
|
|
40
|
+
<Group gap="xs">
|
|
41
|
+
<Text size="sm" fw={500}>{label}</Text>
|
|
42
|
+
<Button
|
|
43
|
+
variant="default" size="compact-xs" fw={700} aria-label={boldLabel} title={boldLabel}
|
|
44
|
+
onClick={() => ref.current && applyMarker(ref.current, "**", value, onChange)}
|
|
45
|
+
>
|
|
46
|
+
B
|
|
47
|
+
</Button>
|
|
48
|
+
<Button
|
|
49
|
+
variant="default" size="compact-xs" fs="italic" aria-label={italicLabel} title={italicLabel}
|
|
50
|
+
onClick={() => ref.current && applyMarker(ref.current, "*", value, onChange)}
|
|
51
|
+
>
|
|
52
|
+
i
|
|
53
|
+
</Button>
|
|
54
|
+
</Group>
|
|
55
|
+
<Textarea
|
|
56
|
+
ref={ref} aria-label={label} autosize minRows={minRows} maxRows={14} value={value}
|
|
57
|
+
onChange={(e) => onChange(e.currentTarget.value)}
|
|
58
|
+
/>
|
|
59
|
+
</Stack>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// A date as typed ("about 1850", "um 1850") to its precise form (EDTF),
|
|
2
|
+
// read the way Gramps reads dates, in the story's language: the player
|
|
3
|
+
// needs the precise form for the timeline and for picking historical maps.
|
|
4
|
+
import { loadLocale, parseDate, resolveLocaleCode, toEdtf } from "@gramps-connect/gramps-date";
|
|
5
|
+
|
|
6
|
+
/** Turns date text into EDTF, or undefined when the text isn't a date
|
|
7
|
+
* that has one ("spring 1850", a non-Gregorian date). */
|
|
8
|
+
export type DateTextParser = (text: string, language: string | undefined) => Promise<string | undefined>;
|
|
9
|
+
|
|
10
|
+
/** The default parser: gramps-date in the story's language (loaded on first
|
|
11
|
+
* use), English when the story has none or gramps-date doesn't know it. */
|
|
12
|
+
export const suggestEdtf: DateTextParser = async (text, language) => {
|
|
13
|
+
if (!text.trim()) return undefined;
|
|
14
|
+
const code = (language && resolveLocaleCode(language.replace(/-/g, "_"))) || "en";
|
|
15
|
+
if (code !== "en" && !(await loadLocale(code))) return undefined;
|
|
16
|
+
try {
|
|
17
|
+
return toEdtf(parseDate(text, { locale: code }));
|
|
18
|
+
} catch {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { StoryEditor, type StoryEditorProps } from "./StoryEditor";
|
|
2
|
+
export { StoryTextField, type StoryTextFieldProps } from "./StoryTextField";
|
|
3
|
+
export { SlideList, type SlideListProps } from "./SlideList";
|
|
4
|
+
export { DateField, type DateFieldProps } from "./DateField";
|
|
5
|
+
export { suggestEdtf, type DateTextParser } from "./dates";
|
|
6
|
+
export { EDITOR_ENGLISH, type EditorStrings } from "./strings";
|
|
7
|
+
export {
|
|
8
|
+
addSlide, blankStory, COALESCE_MS, deleteSlide, duplicateSlide, moveSlide, nextSlideId, pruneMedia, record, redo,
|
|
9
|
+
slideDate, startHistory, storyFileName, undo, updateSlide, updateStory, usedMedia, type History,
|
|
10
|
+
} from "./model";
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// The editor's changes to a story, as pure functions from one StoryDoc to
|
|
2
|
+
// the next -- never mutating, so undo is just keeping the old ones. Every
|
|
3
|
+
// change spreads the object it touches, so fields this version doesn't
|
|
4
|
+
// know (a newer minor version's, an extension's) ride along untouched:
|
|
5
|
+
// opening and saving a story must never lose anything.
|
|
6
|
+
import { FORMAT_ID, FORMAT_VERSION } from "../version";
|
|
7
|
+
import type { Slide, StoryDate, StoryDoc } from "../types";
|
|
8
|
+
|
|
9
|
+
/** A story with one empty slide: the smallest valid one. */
|
|
10
|
+
export function blankStory(title: string, language?: string): StoryDoc {
|
|
11
|
+
return {
|
|
12
|
+
format: FORMAT_ID,
|
|
13
|
+
version: FORMAT_VERSION,
|
|
14
|
+
title,
|
|
15
|
+
...(language ? { language } : {}),
|
|
16
|
+
slides: [{ id: "s0", title }],
|
|
17
|
+
media: {},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** "s<n>" past every id already of that form, so it's unique however the
|
|
22
|
+
* story's other ids were made. */
|
|
23
|
+
export function nextSlideId(doc: StoryDoc): string {
|
|
24
|
+
let max = -1;
|
|
25
|
+
for (const slide of doc.slides) {
|
|
26
|
+
const match = /^s(\d+)$/.exec(slide.id);
|
|
27
|
+
if (match) max = Math.max(max, Number(match[1]));
|
|
28
|
+
}
|
|
29
|
+
let n = max + 1;
|
|
30
|
+
const ids = new Set(doc.slides.map((s) => s.id));
|
|
31
|
+
while (ids.has(`s${n}`)) n += 1;
|
|
32
|
+
return `s${n}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function withSlides(doc: StoryDoc, slides: Slide[]): StoryDoc {
|
|
36
|
+
return { ...doc, slides };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A new empty slide after `index`; returns the story and the new slide's
|
|
40
|
+
* index. */
|
|
41
|
+
export function addSlide(doc: StoryDoc, index: number): { doc: StoryDoc; index: number } {
|
|
42
|
+
const slides = [...doc.slides];
|
|
43
|
+
const at = Math.min(Math.max(index + 1, 0), slides.length);
|
|
44
|
+
slides.splice(at, 0, { id: nextSlideId(doc) });
|
|
45
|
+
return { doc: withSlides(doc, slides), index: at };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A copy of the slide right after it, with its own id. */
|
|
49
|
+
export function duplicateSlide(doc: StoryDoc, index: number): { doc: StoryDoc; index: number } {
|
|
50
|
+
const slides = [...doc.slides];
|
|
51
|
+
const copy = structuredClone(slides[index]);
|
|
52
|
+
copy.id = nextSlideId(doc);
|
|
53
|
+
slides.splice(index + 1, 0, copy);
|
|
54
|
+
return { doc: withSlides(doc, slides), index: index + 1 };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Removes a slide, unless it's the only one (a story has at least one). */
|
|
58
|
+
export function deleteSlide(doc: StoryDoc, index: number): { doc: StoryDoc; index: number } {
|
|
59
|
+
if (doc.slides.length <= 1) return { doc, index };
|
|
60
|
+
const slides = doc.slides.filter((_, i) => i !== index);
|
|
61
|
+
return { doc: withSlides(doc, slides), index: Math.min(index, slides.length - 1) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function moveSlide(doc: StoryDoc, from: number, to: number): StoryDoc {
|
|
65
|
+
if (from === to || to < 0 || to >= doc.slides.length) return doc;
|
|
66
|
+
const slides = [...doc.slides];
|
|
67
|
+
const [slide] = slides.splice(from, 1);
|
|
68
|
+
slides.splice(to, 0, slide);
|
|
69
|
+
return withSlides(doc, slides);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Sets fields on an object; an undefined value removes the field, so
|
|
73
|
+
* cleared text doesn't linger as "". */
|
|
74
|
+
function patched<T extends object>(base: T, patch: Partial<T>): T {
|
|
75
|
+
const next = { ...base } as Record<string, unknown>;
|
|
76
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
77
|
+
if (value === undefined) delete next[key];
|
|
78
|
+
else next[key] = value;
|
|
79
|
+
}
|
|
80
|
+
return next as T;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function updateSlide(doc: StoryDoc, index: number, patch: Partial<Slide>): StoryDoc {
|
|
84
|
+
const slides = [...doc.slides];
|
|
85
|
+
slides[index] = patched(slides[index], patch);
|
|
86
|
+
return withSlides(doc, slides);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function updateStory(doc: StoryDoc, patch: Partial<Omit<StoryDoc, "slides" | "media">>): StoryDoc {
|
|
90
|
+
return patched(doc, patch as Partial<StoryDoc>);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A slide date from what was typed and its precise form. Empty text is no
|
|
94
|
+
* date at all. A new precise form drops a `sortYear` it would contradict
|
|
95
|
+
* (the timeline reads sortYear first); the date's other fields stay. */
|
|
96
|
+
export function slideDate(previous: StoryDate | undefined, text: string, edtf: string | undefined): StoryDate | undefined {
|
|
97
|
+
if (!text.trim()) return undefined;
|
|
98
|
+
const next = patched(previous ?? { text }, { text, edtf: edtf || undefined });
|
|
99
|
+
if (next.edtf !== previous?.edtf) delete next.sortYear;
|
|
100
|
+
return next;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Every media key a slide or overlay refers to. */
|
|
104
|
+
export function usedMedia(doc: StoryDoc): Set<string> {
|
|
105
|
+
const used = new Set<string>();
|
|
106
|
+
for (const slide of doc.slides) {
|
|
107
|
+
for (const m of slide.media ?? []) used.add(m.ref);
|
|
108
|
+
for (const o of slide.place?.overlays ?? []) used.add(o.media);
|
|
109
|
+
}
|
|
110
|
+
return used;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The story as it should be saved: media nothing refers to any more left
|
|
114
|
+
* out, and the files only those that are still used. */
|
|
115
|
+
export function pruneMedia(doc: StoryDoc, files: Map<string, Uint8Array>): { doc: StoryDoc; files: Map<string, Uint8Array> } {
|
|
116
|
+
const used = usedMedia(doc);
|
|
117
|
+
const media = Object.fromEntries(Object.entries(doc.media).filter(([key]) => used.has(key)));
|
|
118
|
+
const srcs = new Set(Object.values(media).map((m) => m.src));
|
|
119
|
+
return {
|
|
120
|
+
doc: { ...doc, media },
|
|
121
|
+
files: new Map([...files].filter(([path]) => srcs.has(path))),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** "Josef Meyer" -> "Josef Meyer.story.zip", minus characters some file
|
|
126
|
+
* systems refuse. */
|
|
127
|
+
export function storyFileName(title: string): string {
|
|
128
|
+
const safe = title.replace(/[\\/:*?"<>|\u0000-\u001f]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 120);
|
|
129
|
+
return `${safe || "story"}.story.zip`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Undo and redo over whole documents. Typing in one field makes one step,
|
|
133
|
+
* not one per keystroke: a change with the same `key` within COALESCE_MS
|
|
134
|
+
* of the last replaces it. */
|
|
135
|
+
export interface History {
|
|
136
|
+
past: StoryDoc[];
|
|
137
|
+
present: StoryDoc;
|
|
138
|
+
future: StoryDoc[];
|
|
139
|
+
lastKey?: string;
|
|
140
|
+
lastAt?: number;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export const COALESCE_MS = 1500;
|
|
144
|
+
const MAX_STEPS = 200;
|
|
145
|
+
|
|
146
|
+
export function startHistory(doc: StoryDoc): History {
|
|
147
|
+
return { past: [], present: doc, future: [] };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function record(history: History, next: StoryDoc, key?: string, now = Date.now()): History {
|
|
151
|
+
if (next === history.present) return history;
|
|
152
|
+
const coalesce = key !== undefined && key === history.lastKey && history.lastAt !== undefined && now - history.lastAt < COALESCE_MS;
|
|
153
|
+
const past = coalesce ? history.past : [...history.past, history.present].slice(-MAX_STEPS);
|
|
154
|
+
return { past, present: next, future: [], lastKey: key, lastAt: now };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function undo(history: History): History {
|
|
158
|
+
const previous = history.past[history.past.length - 1];
|
|
159
|
+
if (!previous) return history;
|
|
160
|
+
return { past: history.past.slice(0, -1), present: previous, future: [history.present, ...history.future] };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function redo(history: History): History {
|
|
164
|
+
const [next, ...future] = history.future;
|
|
165
|
+
if (!next) return history;
|
|
166
|
+
return { past: [...history.past, history.present], present: next, future };
|
|
167
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// The editor's own words. English here; a host with its own translations
|
|
2
|
+
// (Gramps Connect) passes StoryEditor's `strings` instead.
|
|
3
|
+
|
|
4
|
+
export interface EditorStrings {
|
|
5
|
+
story: string;
|
|
6
|
+
storySettings: string;
|
|
7
|
+
title: string;
|
|
8
|
+
language: string;
|
|
9
|
+
languageHint: string;
|
|
10
|
+
map: string;
|
|
11
|
+
mapStandard: string;
|
|
12
|
+
mapHistorical: string;
|
|
13
|
+
mapAuto: string;
|
|
14
|
+
mapNone: string;
|
|
15
|
+
slides: string;
|
|
16
|
+
untitledSlide: string;
|
|
17
|
+
addSlide: string;
|
|
18
|
+
duplicateSlide: string;
|
|
19
|
+
deleteSlide: string;
|
|
20
|
+
moveUp: string;
|
|
21
|
+
moveDown: string;
|
|
22
|
+
slideTitle: string;
|
|
23
|
+
text: string;
|
|
24
|
+
bold: string;
|
|
25
|
+
italic: string;
|
|
26
|
+
date: string;
|
|
27
|
+
dateHint: string;
|
|
28
|
+
preciseDate: string;
|
|
29
|
+
preciseDateHint: string;
|
|
30
|
+
preciseDateFollows: string;
|
|
31
|
+
preciseDateNotUnderstood: string;
|
|
32
|
+
matchDateAsWritten: string;
|
|
33
|
+
noPreciseDate: string;
|
|
34
|
+
place: string;
|
|
35
|
+
photos: string;
|
|
36
|
+
undo: string;
|
|
37
|
+
redo: string;
|
|
38
|
+
present: string;
|
|
39
|
+
preview: string;
|
|
40
|
+
problems: (count: number) => string;
|
|
41
|
+
openingSlide: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const EDITOR_ENGLISH: EditorStrings = {
|
|
45
|
+
story: "Story",
|
|
46
|
+
storySettings: "Title, language and map",
|
|
47
|
+
title: "Title",
|
|
48
|
+
language: "Language",
|
|
49
|
+
languageHint: "The language the story is written in, e.g. en, de or fr-CA. Dates are read in it.",
|
|
50
|
+
map: "Map",
|
|
51
|
+
mapStandard: "Today's map",
|
|
52
|
+
mapHistorical: "Historical maps for each slide's year",
|
|
53
|
+
mapAuto: "Viewer's choice",
|
|
54
|
+
mapNone: "No map",
|
|
55
|
+
slides: "Slides",
|
|
56
|
+
untitledSlide: "Untitled slide",
|
|
57
|
+
addSlide: "Add slide",
|
|
58
|
+
duplicateSlide: "Duplicate",
|
|
59
|
+
deleteSlide: "Delete",
|
|
60
|
+
moveUp: "Move up",
|
|
61
|
+
moveDown: "Move down",
|
|
62
|
+
slideTitle: "Title",
|
|
63
|
+
text: "Text",
|
|
64
|
+
bold: "Bold",
|
|
65
|
+
italic: "Italic",
|
|
66
|
+
date: "Date",
|
|
67
|
+
dateHint: "As readers should see it, e.g. about 1850, 3 May 1850, between 1850 and 1860.",
|
|
68
|
+
preciseDate: "Precise date (EDTF)",
|
|
69
|
+
preciseDateHint: "Places the slide on the timeline and picks historical maps.",
|
|
70
|
+
preciseDateFollows: "Worked out from the date as written.",
|
|
71
|
+
preciseDateNotUnderstood: "Not a date the timeline can use.",
|
|
72
|
+
matchDateAsWritten: "Work it out from the date",
|
|
73
|
+
noPreciseDate: "None: this slide stays off the timeline.",
|
|
74
|
+
place: "Place",
|
|
75
|
+
photos: "Photos",
|
|
76
|
+
undo: "Undo",
|
|
77
|
+
redo: "Redo",
|
|
78
|
+
present: "Present",
|
|
79
|
+
preview: "Preview",
|
|
80
|
+
problems: (count) => (count === 1 ? "1 problem to fix before saving" : `${count} problems to fix before saving`),
|
|
81
|
+
openingSlide: "Opening slide",
|
|
82
|
+
};
|
|
@@ -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";
|
|
@@ -44,6 +44,9 @@ export interface StoryPlayerProps {
|
|
|
44
44
|
renderMapModeControl?: (props: MapModeControlProps) => ReactNode;
|
|
45
45
|
/** Shown before the title, e.g. ViewerBrand on a standalone page. */
|
|
46
46
|
brand?: ReactNode;
|
|
47
|
+
/** The host's own buttons, at the right of the title bar (before the
|
|
48
|
+
* close button), e.g. the viewer site's Edit. */
|
|
49
|
+
actions?: ReactNode;
|
|
47
50
|
initialSlide?: number;
|
|
48
51
|
}
|
|
49
52
|
|
|
@@ -201,7 +204,7 @@ function NavArrows({ index, total, onPrev, onNext, variant, strings, colors }: {
|
|
|
201
204
|
|
|
202
205
|
export function StoryPlayer({
|
|
203
206
|
story, resolveMedia, dark = false, colors: colorOverrides, strings: stringOverrides, onClose,
|
|
204
|
-
keyboard = true, onSlideChange, renderMapModeControl, brand, initialSlide = 0,
|
|
207
|
+
keyboard = true, onSlideChange, renderMapModeControl, brand, actions, initialSlide = 0,
|
|
205
208
|
}: StoryPlayerProps) {
|
|
206
209
|
const colors = useMemo(() => ({ ...defaultColors(dark), ...colorOverrides }), [dark, colorOverrides]);
|
|
207
210
|
const strings = useMemo(
|
|
@@ -299,7 +302,13 @@ export function StoryPlayer({
|
|
|
299
302
|
|
|
300
303
|
// Stable per slide, so a re-render for an unrelated reason (a map-mode
|
|
301
304
|
// click) never looks like a new slide to the map's camera.
|
|
302
|
-
const
|
|
305
|
+
const places = useMemo(() => storyPlaces(slides.map((s) => s.place)), [slides]);
|
|
306
|
+
const mapSlide = useMemo<MapSlide>(() => ({
|
|
307
|
+
key: slide?.id ?? String(index),
|
|
308
|
+
place: slide?.place,
|
|
309
|
+
// The opening card, with no place of its own, shows where the story goes.
|
|
310
|
+
overview: index === 0 && !placeCenter(slide?.place) ? places : null,
|
|
311
|
+
}), [slide, index, places]);
|
|
303
312
|
const firstCenter = useMemo(() => {
|
|
304
313
|
if (story.presentation?.basemap === "none") return null;
|
|
305
314
|
for (const s of slides) {
|
|
@@ -365,6 +374,16 @@ export function StoryPlayer({
|
|
|
365
374
|
</div>
|
|
366
375
|
</div>
|
|
367
376
|
)}
|
|
377
|
+
{actions && (
|
|
378
|
+
<div
|
|
379
|
+
style={{
|
|
380
|
+
position: "absolute", top: 0, right: onClose ? 60 : 12, height: BAR_HEIGHT, zIndex: 3,
|
|
381
|
+
display: "flex", alignItems: "center", gap: 8,
|
|
382
|
+
}}
|
|
383
|
+
>
|
|
384
|
+
{actions}
|
|
385
|
+
</div>
|
|
386
|
+
)}
|
|
368
387
|
{onClose && (
|
|
369
388
|
<RoundButton
|
|
370
389
|
label={strings.close} size={36} onClick={onClose}
|
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
|
+
}
|