@projektemacher/storymapjs 0.10.6 → 0.10.7

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.
Files changed (148) hide show
  1. package/CHANGELOG +11 -0
  2. package/dist/assets/{demo-CVQ7kFek.js → demo-D6cTmzn6.js} +1 -1
  3. package/dist/assets/{harness-C-VZV-ou.js → harness-BPFlzutp.js} +1 -1
  4. package/dist/assets/main-Y-MYUSJ4.js +17 -0
  5. package/dist/css/fonts/font.oldstandard.css +18 -22
  6. package/dist/demo.html +2 -2
  7. package/dist/docs/migration.html +1 -1
  8. package/dist/embed/index.html +129 -4
  9. package/dist/harness.html +2 -2
  10. package/dist/index.html +17 -0
  11. package/dist/js/storymap.d.ts +126 -7
  12. package/dist/js/storymap.js +4518 -3613
  13. package/dist/js/storymap.js.map +1 -1
  14. package/docs/migration-from-knightlab.md +139 -0
  15. package/docs/storymap-as-iiif-manifest.md +510 -0
  16. package/package.json +6 -3
  17. package/src/animation/easings.ts +18 -0
  18. package/src/animation/morpheus.d.ts +41 -0
  19. package/src/core/Browser.ts +19 -0
  20. package/src/core/Load.ts +180 -0
  21. package/src/core/Util.ts +354 -0
  22. package/src/core/mixins.ts +171 -0
  23. package/src/dom/Dom.ts +31 -0
  24. package/src/dom/DomEvent.ts +61 -0
  25. package/src/globals.d.ts +16 -0
  26. package/src/language/Language.ts +107 -0
  27. package/src/language/locale/be.json +18 -0
  28. package/src/language/locale/bg.json +18 -0
  29. package/src/language/locale/cs.json +18 -0
  30. package/src/language/locale/de.json +20 -0
  31. package/src/language/locale/el.json +18 -0
  32. package/src/language/locale/en.json +28 -0
  33. package/src/language/locale/es.json +18 -0
  34. package/src/language/locale/et.json +18 -0
  35. package/src/language/locale/fr.json +18 -0
  36. package/src/language/locale/he.json +18 -0
  37. package/src/language/locale/hu.json +18 -0
  38. package/src/language/locale/is.json +18 -0
  39. package/src/language/locale/it.json +18 -0
  40. package/src/language/locale/jp.json +18 -0
  41. package/src/language/locale/ko.json +18 -0
  42. package/src/language/locale/nl.json +18 -0
  43. package/src/language/locale/nn.json +18 -0
  44. package/src/language/locale/no.json +18 -0
  45. package/src/language/locale/pl.json +18 -0
  46. package/src/language/locale/pt.json +18 -0
  47. package/src/language/locale/ru.json +18 -0
  48. package/src/language/locale/sk.json +18 -0
  49. package/src/language/locale/sr.json +17 -0
  50. package/src/language/locale/sv.json +18 -0
  51. package/src/language/locale/tr.json +18 -0
  52. package/src/language/locale/uk.json +18 -0
  53. package/src/language/locale/ur.json +18 -0
  54. package/src/language/locale/zh-cn.json +18 -0
  55. package/src/language/locale/zh-tw.json +18 -0
  56. package/src/main.ts +51 -0
  57. package/src/map/Map.ts +717 -0
  58. package/src/map/MapMarker.ts +149 -0
  59. package/src/map/openlayers/Map.OpenLayers.ts +1879 -0
  60. package/src/map/openlayers/MapMarker.OpenLayers.ts +176 -0
  61. package/src/map/openlayers/zoomifyTiles.ts +28 -0
  62. package/src/map/types.ts +17 -0
  63. package/src/media/EmbedUtil.ts +168 -0
  64. package/src/media/Media.ts +409 -0
  65. package/src/media/MediaType.ts +163 -0
  66. package/src/media/types/Audio.ts +87 -0
  67. package/src/media/types/Blockquote.ts +40 -0
  68. package/src/media/types/DailyMotion.ts +58 -0
  69. package/src/media/types/DocumentCloud.ts +41 -0
  70. package/src/media/types/Facebook.ts +41 -0
  71. package/src/media/types/Flickr.ts +115 -0
  72. package/src/media/types/GoogleDoc.ts +59 -0
  73. package/src/media/types/IFrame.ts +51 -0
  74. package/src/media/types/Image.ts +49 -0
  75. package/src/media/types/Juxtapose.ts +32 -0
  76. package/src/media/types/SoundCloud.ts +77 -0
  77. package/src/media/types/Text.ts +172 -0
  78. package/src/media/types/Twitter.ts +96 -0
  79. package/src/media/types/Video.ts +84 -0
  80. package/src/media/types/Vimeo.ts +65 -0
  81. package/src/media/types/Website.ts +36 -0
  82. package/src/media/types/Wikipedia.ts +124 -0
  83. package/src/media/types/YouTube.ts +192 -0
  84. package/src/scss/Typography.scss +237 -0
  85. package/src/scss/VCO.StoryMap.Dark.scss +138 -0
  86. package/src/scss/VCO.StoryMap.scss +164 -0
  87. package/src/scss/Variables.Dark.scss +56 -0
  88. package/src/scss/Variables.scss +47 -0
  89. package/src/scss/core/Mixins.scss +112 -0
  90. package/src/scss/core/Reset.scss +206 -0
  91. package/src/scss/fonts/_font.base.scss +113 -0
  92. package/src/scss/fonts/font.abril-droidsans.scss +75 -0
  93. package/src/scss/fonts/font.amatic-andika.scss +73 -0
  94. package/src/scss/fonts/font.bitter-raleway.scss +86 -0
  95. package/src/scss/fonts/font.clicker-garamond.scss +92 -0
  96. package/src/scss/fonts/font.dancing-ledger.scss +74 -0
  97. package/src/scss/fonts/font.default.scss +9 -0
  98. package/src/scss/fonts/font.fjalla-average.scss +80 -0
  99. package/src/scss/fonts/font.georgia-helvetica.scss +58 -0
  100. package/src/scss/fonts/font.knightlab.scss +97 -0
  101. package/src/scss/fonts/font.lustria-lato.scss +80 -0
  102. package/src/scss/fonts/font.medula-lato.scss +73 -0
  103. package/src/scss/fonts/font.oldstandard.scss +88 -0
  104. package/src/scss/fonts/font.opensans-gentiumbook.scss +80 -0
  105. package/src/scss/fonts/font.playfair-faunaone.scss +86 -0
  106. package/src/scss/fonts/font.playfair.scss +85 -0
  107. package/src/scss/fonts/font.pt.scss +90 -0
  108. package/src/scss/fonts/font.roboto-megrim.scss +73 -0
  109. package/src/scss/fonts/font.rufina-sintony.scss +73 -0
  110. package/src/scss/fonts/font.ubuntu.scss +78 -0
  111. package/src/scss/fonts/font.unicaone-vollkorn.scss +85 -0
  112. package/src/scss/icons/Icons.scss +239 -0
  113. package/src/scss/map/VCO.ImageMarker.scss +23 -0
  114. package/src/scss/map/VCO.Map.scss +21 -0
  115. package/src/scss/map/VCO.MapMarker.scss +147 -0
  116. package/src/scss/map/openlayers/VCO.Map.OpenLayers.scss +126 -0
  117. package/src/scss/media/VCO.Media.scss +222 -0
  118. package/src/scss/media/types/VCO.Media.Blockquote.scss +76 -0
  119. package/src/scss/media/types/VCO.Media.IFrame.scss +11 -0
  120. package/src/scss/media/types/VCO.Media.Image.scss +13 -0
  121. package/src/scss/media/types/VCO.Media.Instagram.scss +7 -0
  122. package/src/scss/media/types/VCO.Media.Profile.scss +5 -0
  123. package/src/scss/media/types/VCO.Media.Text.scss +90 -0
  124. package/src/scss/media/types/VCO.Media.Twitter.scss +87 -0
  125. package/src/scss/media/types/VCO.Media.Wikipedia.scss +64 -0
  126. package/src/scss/site/site.scss +748 -0
  127. package/src/scss/slider/VCO.Slide.scss +447 -0
  128. package/src/scss/slider/VCO.SlideNav.scss +338 -0
  129. package/src/scss/slider/VCO.StorySlider.scss +64 -0
  130. package/src/scss/snapmap/VCO.SnapMap.scss +103 -0
  131. package/src/scss/ui/VCO.Consent.scss +77 -0
  132. package/src/scss/ui/VCO.MenuBar.Button.scss +99 -0
  133. package/src/scss/ui/VCO.MenuBar.scss +20 -0
  134. package/src/scss/ui/VCO.Message.scss +104 -0
  135. package/src/site/site.ts +87 -0
  136. package/src/slider/Slide.ts +460 -0
  137. package/src/slider/SlideNav.ts +212 -0
  138. package/src/slider/StorySlider.ts +722 -0
  139. package/src/storymap/Consent.ts +146 -0
  140. package/src/storymap/StoryMap.ts +1108 -0
  141. package/src/storymap/iiif.ts +386 -0
  142. package/src/storymap/validate.ts +184 -0
  143. package/src/types.ts +297 -0
  144. package/src/ui/Draggable.ts +374 -0
  145. package/src/ui/MenuBar.ts +323 -0
  146. package/src/ui/Message.ts +118 -0
  147. package/src/ui/Swipable.ts +388 -0
  148. package/dist/assets/main-DwbXrrYn.js +0 -17
@@ -0,0 +1,1108 @@
1
+ import { mergeData, slideTransitionDuration, updateData } from "../core/Util";
2
+ import { loadCSS } from "../core/Load";
3
+ import { validateStorymapAndReport } from "./validate";
4
+ import { isPresentation3Manifest, manifestToStorymapData } from "./iiif";
5
+ import { ConsentManager, consentManagerOf, consentMessage } from "./Consent";
6
+ import Dom from "../dom/Dom";
7
+ import { easeInOutQuint, easeOutStrong } from "../animation/easings";
8
+ import { setLanguage, Language } from "../language/Language";
9
+ import { Evented, type EventedInstance } from "../core/mixins";
10
+ import OpenLayersMap from "../map/openlayers/Map.OpenLayers";
11
+ import MenuBar from "../ui/MenuBar";
12
+ import StorySlider from "../slider/StorySlider";
13
+ import { Browser } from "../core/Browser";
14
+ import Animate from "morpheus";
15
+ import type { Map as OlMap } from "ol";
16
+ import type { AnimationHandle, StorymapData, StorymapDataWrapper, StorymapOptions } from "../types";
17
+
18
+ type StoryMapListener = (e: unknown) => void;
19
+
20
+ /**
21
+ * Resolve a `font_css` value to an absolute stylesheet URL.
22
+ *
23
+ * - `stock:<name>` resolves against the library location.
24
+ * - Absolute URLs pass through untouched.
25
+ * - Anything else resolves against the page URL, so hosts can reference
26
+ * themes vendored into their own public dir (e.g. "fonts/font.css").
27
+ */
28
+ export function resolveFontCssUrl(font: string): string {
29
+ if (font.startsWith("stock:")) {
30
+ const font_name = font.split(":")[1] || "default";
31
+ // resolved against the library location: one directory up from
32
+ // src/main.ts (dev) and js/storymap.js (build) in both cases
33
+ return new URL("../css/fonts/font." + font_name + ".css", import.meta.url).href;
34
+ }
35
+ if (!/^(http|https|\/\/)/.test(font)) {
36
+ return new URL(font, document.baseURI).href;
37
+ }
38
+ return font;
39
+ }
40
+
41
+ /**
42
+ * Interactive StoryMap viewer.
43
+ *
44
+ * Renders a storymap JSON document (or a IIIF Presentation 3 manifest) into an
45
+ * element: an OpenLayers map on one side and a slide slider on the other.
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * import { StoryMap } from "storymapjs";
50
+ * // from an inline data object
51
+ * const map = new StoryMap("embed", { storymap: { ... } }, { start_at_slide: 2 });
52
+ * // or straight from a source file (storymap JSON or IIIF manifest)
53
+ * const map2 = new StoryMap(document.getElementById("embed"), "./my-storymap.json");
54
+ * ```
55
+ */
56
+ class StoryMapBase {
57
+ declare "_loaded": { storyslider: boolean; map: boolean };
58
+ declare "on": EventedInstance["on"];
59
+ declare "version": string;
60
+ declare "ready": boolean;
61
+ declare "_el": {
62
+ container: HTMLElement;
63
+ menubar: HTMLElement;
64
+ map: HTMLElement;
65
+ storyslider: HTMLElement;
66
+ };
67
+ declare "_storyslider": StorySlider;
68
+ declare "_map": OpenLayersMap;
69
+ declare "map": OlMap;
70
+ declare "_menubar": MenuBar;
71
+ declare "data": StorymapData;
72
+ declare "options": StorymapOptions;
73
+ declare "current_slide": number;
74
+ declare "animator_map": AnimationHandle | null;
75
+ declare "animator_storyslider": AnimationHandle | null;
76
+ declare "_autoplay_timer": ReturnType<typeof setTimeout> | null;
77
+ declare "_transition_timer": ReturnType<typeof setTimeout> | null;
78
+ declare "_autoplay_stopped": boolean;
79
+ declare "_hash_initialized": boolean;
80
+ /** the data source was a IIIF Presentation manifest (legacy zoomify options are ignored) */
81
+ declare "_data_from_manifest": boolean;
82
+ declare "_resize_observer": ResizeObserver | null;
83
+ declare "_resize_timer": ReturnType<typeof setTimeout> | null;
84
+ declare "fire": EventedInstance["fire"];
85
+ declare "hasEventListeners": EventedInstance["hasEventListeners"];
86
+
87
+ // TODO: mixin
88
+ // includes: VCO.Events,
89
+
90
+ /* Private Methods
91
+ ================================================== */
92
+ /**
93
+ * Create a StoryMap inside the given element.
94
+ *
95
+ * @param elem - The container element, or its DOM id.
96
+ * @param data - Either an inline storymap document (`{ storymap: ... }`),
97
+ * an inline IIIF Presentation 3 manifest, or the URL of a source file
98
+ * (storymap JSON or IIIF manifest) that is fetched automatically.
99
+ * @param options - Optional {@link StorymapOptions} overrides (map type,
100
+ * lines, fonts, animation, raw OpenLayers options via `map_options`...).
101
+ * @param listeners - Optional event listeners keyed by event name
102
+ * (`change`, `title`, `loaded`, ...) attached on creation.
103
+ */
104
+ constructor(
105
+ elem: string | HTMLElement,
106
+ data: string | StorymapDataWrapper | Record<string, unknown>,
107
+ options?: Partial<StorymapOptions>,
108
+ listeners?: Record<string, StoryMapListener | StoryMapListener[]>,
109
+ ) {
110
+ for (const key in listeners) {
111
+ const callbacks = listeners[key];
112
+ if (typeof callbacks == "function") {
113
+ this.on(key, callbacks);
114
+ } else {
115
+ for (const idx in callbacks) {
116
+ if (typeof callbacks[idx] == "function") {
117
+ this.on(key, callbacks[idx]);
118
+ } else {
119
+ console.log(
120
+ "WARNING: Ignoring invalid callback '" +
121
+ callbacks[idx] +
122
+ "' defined for " +
123
+ "listener '" +
124
+ key +
125
+ "' in StoryMap constructor",
126
+ );
127
+ }
128
+ }
129
+ }
130
+ }
131
+
132
+ // Version
133
+ this.version = "0.1.16";
134
+
135
+ // Ready
136
+ this.ready = false;
137
+
138
+ // DOM ELEMENTS
139
+ this._el = {
140
+ container: {} as HTMLElement,
141
+ storyslider: {} as HTMLElement,
142
+ map: {} as HTMLElement,
143
+ menubar: {} as HTMLElement,
144
+ };
145
+
146
+ // Determine Container Element
147
+ if (typeof elem === "object") {
148
+ this._el.container = elem;
149
+ } else {
150
+ this._el.container = Dom.get(elem);
151
+ }
152
+
153
+ // Slider
154
+ this._storyslider = {} as StorySlider;
155
+
156
+ // Map
157
+ this._map = {} as OpenLayersMap;
158
+ this.map = {} as OlMap; // direct access to the OpenLayers map
159
+
160
+ // Menu Bar
161
+ this._menubar = {} as MenuBar;
162
+
163
+ // Loaded State
164
+ this._loaded = { storyslider: false, map: false };
165
+
166
+ // Data Object
167
+ // Test Data compiled from http://www.pbs.org/marktwain/learnmore/chronology.html
168
+ this.data = {} as StorymapData;
169
+
170
+ this.options = {
171
+ script_path: StoryMap.SCRIPT_PATH,
172
+ height: this._el.container.offsetHeight,
173
+ width: this._el.container.offsetWidth,
174
+ layout: "landscape", // portrait or landscape
175
+ base_class: "",
176
+ default_bg_color: { r: 255, g: 255, b: 255 },
177
+ map_size_sticky: 2.5, // Set as division 1/3 etc
178
+ map_center_offset: null, // takes object {top:0,left:0}
179
+ less_bounce: false, // Less map bounce when calculating zoom, false is good when there are clusters of tightly grouped markers
180
+ start_at_slide: 0,
181
+ call_to_action: false,
182
+ call_to_action_text: "",
183
+ menubar_height: 0,
184
+ fullscreen: true,
185
+ show_overview: true,
186
+ show_back_to_start: true,
187
+ skinny_size: 650,
188
+ // animation
189
+ duration: 1000,
190
+ ease: easeInOutQuint,
191
+ // interaction
192
+ dragging: true,
193
+ trackResize: true,
194
+ keyboard: false,
195
+ nocache: false,
196
+ autoplay: 0,
197
+ show_progress: false,
198
+ marker_labels: false,
199
+ text_align: "left",
200
+ map_overview_center: null,
201
+ map_type: "", // "osm:standard",
202
+ attribution: "",
203
+ map_mini: true,
204
+ map_subdomains: "",
205
+ map_as_image: false,
206
+ // no bundled credentials: pass map_access_token in the options
207
+ // if you use Mapbox/Stadia tiles, api_key_flickr for flickr API
208
+ // URLs
209
+ map_access_token: "",
210
+ map_background_color: "#d9d9d9",
211
+ map_area: "full", // "left" limits the map to the visible half (landscape)
212
+ map_bbox: null,
213
+ consent_required: false,
214
+ zoomify: undefined,
215
+ text_color: "",
216
+ text_background_color: "",
217
+ show_distance: false,
218
+ use_custom_markers: false,
219
+ iiif: {
220
+ url: "",
221
+ attribution: "",
222
+ },
223
+ map_height: 300,
224
+ storyslider_height: 600,
225
+ slide_padding_lr: 45, // padding on slide of slide
226
+ slide_default_fade: "0%", // landscape fade
227
+ menubar_default_y: 0,
228
+ path_gfx: "gfx",
229
+ map_popup: false,
230
+ zoom_distance: 100,
231
+ calculate_zoom: true, // Allow map to determine best zoom level between markers (recommended)
232
+ line_follows_path: true, // Map history path follows default line, if false it will connect previous and current only
233
+ line_color: "#c34528", //"#DA0000",
234
+ line_color_inactive: "#CCC",
235
+ line_join: "miter",
236
+ line_weight: 3,
237
+ line_opacity: 0.8,
238
+ line_dash: "5,5",
239
+ show_lines: true,
240
+ show_history_line: true,
241
+ api_key_flickr: "", // no bundled key: pass api_key_flickr in the options
242
+ font_css: "stock:default",
243
+ language: "en",
244
+ } as StorymapOptions;
245
+
246
+ // Animation Objects
247
+ this.animator_map = null;
248
+ this.animator_storyslider = null;
249
+ this._resize_observer = null;
250
+ this._resize_timer = null;
251
+ this._autoplay_timer = null;
252
+ this._transition_timer = null;
253
+ this._autoplay_stopped = false;
254
+ this._hash_initialized = false;
255
+
256
+ // Merge Options -- legacy, in case people still need to pass in
257
+ mergeData(this.options, options);
258
+
259
+ // Current Slide (after the options merge so start_at_slide applies)
260
+ this.current_slide = this.options.start_at_slide;
261
+
262
+ this._initData(data);
263
+
264
+ return this;
265
+ }
266
+
267
+ /* Initialize the data
268
+ ================================================== */
269
+ _initData(data: string | StorymapDataWrapper | Record<string, unknown>) {
270
+ // legacy zoomify options are only honored for storymap JSON sources —
271
+ // a IIIF Presentation manifest cannot carry them
272
+ this._data_from_manifest = false;
273
+ if (typeof data === "string") {
274
+ // issue #417: optional cache-busting re-fetch of the source file
275
+ const url =
276
+ this.options.nocache === true
277
+ ? data + (data.includes("?") ? "&" : "?") + "_=" + Date.now()
278
+ : data;
279
+ void this._loadDataFromUrl(url, data);
280
+ } else if (typeof data === "object") {
281
+ if (isPresentation3Manifest(data)) {
282
+ this._data_from_manifest = true;
283
+ this.data = manifestToStorymapData(data);
284
+ } else {
285
+ const wrapper = data as StorymapDataWrapper;
286
+ validateStorymapAndReport(wrapper);
287
+ if (wrapper.storymap) {
288
+ this.data = wrapper.storymap;
289
+ } else {
290
+ console.error("StoryMapJS: data must have a storymap property");
291
+ }
292
+ }
293
+ this._initOptions();
294
+ } else {
295
+ console.error("StoryMapJS: data has unknown type");
296
+ this._initOptions();
297
+ }
298
+ }
299
+
300
+ /* Load storymap data from a URL
301
+ ================================================== */
302
+ async _loadDataFromUrl(url: string, source: string) {
303
+ try {
304
+ const response = await fetch(url);
305
+ if (!response.ok) {
306
+ throw new Error("HTTP " + response.status + " " + response.statusText);
307
+ }
308
+ const result: unknown = await response.json();
309
+ if (isPresentation3Manifest(result)) {
310
+ this._data_from_manifest = true;
311
+ this.data = manifestToStorymapData(result);
312
+ } else {
313
+ validateStorymapAndReport(result, source);
314
+ const wrapper = result as StorymapDataWrapper;
315
+ if (!wrapper.storymap) {
316
+ throw new Error("StoryMapJS: data must have a storymap property");
317
+ }
318
+ this.data = wrapper.storymap;
319
+ }
320
+ this._initOptions();
321
+ } catch (err: unknown) {
322
+ console.error("StoryMapJS: could not load storymap data from " + source, err);
323
+ this.fire("error", { message: String(err), source });
324
+ }
325
+ }
326
+
327
+ /* Initialize the options
328
+ ================================================== */
329
+ _initOptions() {
330
+ // Grab options from storymap data
331
+ updateData(this.options, this.data);
332
+
333
+ // legacy zoomify options only work with storymap JSON sources — a
334
+ // IIIF Presentation manifest cannot carry them
335
+ if (this._data_from_manifest) {
336
+ delete this.options.zoomify;
337
+ }
338
+
339
+ if (this.options.layout === "landscape") {
340
+ // map_area "left": the map is limited to the visible half with an
341
+ // opaque slide panel — no offset needed (the view center is the
342
+ // visible center already); "full" (default) offsets the view so
343
+ // markers clear the panel that fades in over the map
344
+ if (this.options.map_area !== "left") {
345
+ this.options.map_center_offset = { left: -200, top: 0 };
346
+ } else {
347
+ this.options.map_center_offset = { left: 0, top: 0 };
348
+ }
349
+ }
350
+ if (this.options.map_type === "iiif" && this.options.map_as_image) {
351
+ this.options.map_size_sticky = 2;
352
+ }
353
+ // legacy zoomify stories are image maps too — the map window is half
354
+ // the height (the original renderer's layout)
355
+ if (this.options.map_type === "zoomify") {
356
+ this.options.map_size_sticky = 2;
357
+ }
358
+ if (this.options.map_as_image) {
359
+ this.options.calculate_zoom = false;
360
+ }
361
+
362
+ // legacy zoomify support: rendered by the map via the image pyramid
363
+ // (options.zoomify) — deprecated, a JS warning points at iiif
364
+ if (this.options.map_type === "zoomify") {
365
+ console.warn(
366
+ "StoryMapJS: map_type 'zoomify' is a legacy image-pyramid basemap; consider map_type 'iiif' with options.iiif.url instead.",
367
+ );
368
+ const zoomify_opts = this.options.zoomify;
369
+ if (typeof zoomify_opts !== "object" || !zoomify_opts.path) {
370
+ console.error(
371
+ "StoryMapJS: map_type 'zoomify' needs a zoomify image pyramid (path, width, height) in the storymap data.",
372
+ );
373
+ }
374
+ }
375
+
376
+ // handle Stamen change
377
+ if (this.options.map_type.startsWith("stamen")) {
378
+ const old_type = this.options.map_type;
379
+ if (old_type === "stamen:watercolor") {
380
+ this.options.map_type = "ch-watercolor";
381
+ } else {
382
+ this.options.map_type = "osm:standard";
383
+ }
384
+ console.log(`Deprecated map_type ${old_type}; using ${this.options.map_type}`);
385
+ }
386
+
387
+ this._loadLanguage();
388
+ }
389
+
390
+ /* Load Language
391
+ ================================================== */
392
+
393
+ _loadLanguage() {
394
+ setLanguage(this.options.language);
395
+ // the resolved locale decides the layout direction (issues #211, #245)
396
+ this.options.language = Language as unknown as string;
397
+ this._onDataLoaded();
398
+ }
399
+
400
+ /* Switch the UI language at runtime, without a page reload.
401
+ ================================================== */
402
+
403
+ refreshLanguage(code: string): void {
404
+ this.options.language = code;
405
+ setLanguage(code);
406
+ // the resolved locale decides the layout direction (issues #211, #245)
407
+ this.options.language = Language as unknown as string;
408
+ if (this._menubar && typeof this._menubar.refreshLabels === "function") {
409
+ this._menubar.refreshLabels();
410
+ }
411
+ this.updateDisplay();
412
+ }
413
+
414
+ /* Load the font theme stylesheet
415
+ ================================================== */
416
+ async _loadFontCss() {
417
+ // only genuinely external URLs ask for consent: stock: themes and
418
+ // relative paths resolve to same-origin / library assets
419
+ const original = this.options.font_css || "stock:default";
420
+ const font = resolveFontCssUrl(original);
421
+ const manager = consentManagerOf(this.options);
422
+ const external = /^(http|https|\/\/)/.test(original);
423
+ if (external && this.options.consent_required && manager) {
424
+ // external font CSS is an external service — ask first
425
+ const host = new URL(font.startsWith("//") ? "https:" + font : font).host;
426
+ const container = this._el.map ?? (this._el.container as HTMLElement);
427
+ const allowed = await manager.request(
428
+ consentMessage("consent_service_fonts", "web fonts"),
429
+ host,
430
+ container,
431
+ );
432
+ if (!allowed) {
433
+ return;
434
+ }
435
+ }
436
+ if (font) {
437
+ try {
438
+ await loadCSS(font);
439
+ } catch {
440
+ // a missing font theme must not block the storymap; the
441
+ // font-loaded event fires either way, as before
442
+ } finally {
443
+ this._onFontLoaded(font);
444
+ }
445
+ }
446
+ }
447
+
448
+ _onFontLoaded(font: string) {
449
+ this.fire("fontLoaded", { font: font });
450
+ }
451
+
452
+ /* Navigation
453
+ ================================================== */
454
+ /**
455
+ * Navigate to a slide by index.
456
+ *
457
+ * @param n - Zero-based slide index; the map animates to the slide's
458
+ * marker (or the overview when `n` is 0 for storymaps with one).
459
+ */
460
+ goTo(n: number) {
461
+ // out-of-range indices are ignored: they would desync the slider and
462
+ // map (no active slide) and write a broken #slide-N bookmark
463
+ if (n >= 0 && n < (this.data?.slides?.length ?? 0) && n !== this.current_slide) {
464
+ const duration = slideTransitionDuration(this.current_slide, n);
465
+ this.current_slide = n;
466
+ this._storyslider.goTo(this.current_slide);
467
+ this._map.goTo(this.current_slide);
468
+ this._beginTransition(duration);
469
+ // programmatic navigation reports outward like interaction does;
470
+ // the bubbled slider/map change events below are dropped by
471
+ // their equality guards, so this fires exactly once
472
+ this.fire("change", { current_slide: this.current_slide }, this);
473
+ this._syncHash();
474
+ this._scheduleAutoplay();
475
+ this._updateProgress();
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Announce a slide transition: `transitionstart` now (with the glide
481
+ * duration both animations were started with) and `transitionend` once
482
+ * it elapses. Restarts on every navigation, so only the latest
483
+ * transition ever ends.
484
+ */
485
+ _beginTransition(duration: number): void {
486
+ if (this._transition_timer) {
487
+ clearTimeout(this._transition_timer);
488
+ }
489
+ this.fire("transitionstart", { current_slide: this.current_slide, duration }, this);
490
+ this._transition_timer = setTimeout(() => {
491
+ this._transition_timer = null;
492
+ this.fire("transitionend", { current_slide: this.current_slide }, this);
493
+ }, duration);
494
+ }
495
+
496
+ /**
497
+ * Re-measure the container and re-layout the map, slider and menubar.
498
+ * Called automatically on resize (see the `trackResize` option).
499
+ */
500
+ updateDisplay() {
501
+ if (this.ready) {
502
+ this._updateDisplay();
503
+ }
504
+ }
505
+
506
+ /**
507
+ * Change a single map option at runtime and apply its effect immediately.
508
+ *
509
+ * Runtime-changeable options: `map_type` (rebuilds the main + minimap tile
510
+ * layers, keeping the overview fitted to the marker bounds), `overlays`
511
+ * (rebuilds the stacked overlay layers), `show_lines`, `line_color`,
512
+ * `line_color_inactive`, `line_weight`, `line_opacity`, `line_dash`,
513
+ * `line_join`, `line_follows_path`, `show_history_line` (restyled
514
+ * instantly), `map_center_offset` (applied on the next navigation),
515
+ * `duration`, `ease`, `calculate_zoom`, `map_background_color`. All other
516
+ * options only take effect on the next navigation or require re-creating
517
+ * the StoryMap.
518
+ */
519
+ setMapOption(name: string, value: unknown) {
520
+ this.setMapOptions({ [name]: value } as Partial<StorymapOptions>);
521
+ }
522
+
523
+ /**
524
+ * Show or hide a stacked overlay by index (see the `overlays` option).
525
+ */
526
+ setOverlayVisible(index: number, visible: boolean): void {
527
+ this._map.setOverlayVisible(index, visible);
528
+ }
529
+
530
+ /**
531
+ * Set a stacked overlay's opacity by index (see the `overlays` option).
532
+ */
533
+ setOverlayOpacity(index: number, opacity: number): void {
534
+ this._map.setOverlayOpacity(index, opacity);
535
+ }
536
+
537
+ /**
538
+ * Change several map options at runtime (see setMapOption).
539
+ */
540
+ setMapOptions(options: Partial<StorymapOptions>) {
541
+ mergeData(this.options, options);
542
+ if (this._map && this._map.options) {
543
+ mergeData(this._map.options, options);
544
+ this._map.applyOptions(Object.keys(options));
545
+ } else {
546
+ for (const key of Object.keys(options)) {
547
+ (this.options as Record<string, unknown>)[key] = (
548
+ options as Record<string, unknown>
549
+ )[key];
550
+ }
551
+ }
552
+ if (this.ready) {
553
+ // text color theming follows runtime option changes (issue #177)
554
+ if (this.options.text_color) {
555
+ this._el.container.style.setProperty("--vco-color-text", this.options.text_color);
556
+ }
557
+ if (this.options.text_background_color) {
558
+ this._el.container.style.setProperty(
559
+ "--vco-color-text-background",
560
+ this.options.text_background_color,
561
+ );
562
+ }
563
+ this.updateDisplay();
564
+ }
565
+ }
566
+
567
+ /* Private Methods
568
+ ================================================== */
569
+
570
+ // Initialize the layout
571
+ _initLayout() {
572
+ this._el.container.className += " vco-storymap";
573
+ this.options.base_class = this._el.container.className;
574
+
575
+ // Text color theming (issue #177): expose the text colors as CSS
576
+ // custom properties consumed by the slide typography
577
+ if (this.options.text_color) {
578
+ this._el.container.style.setProperty("--vco-color-text", this.options.text_color);
579
+ }
580
+ if (this.options.text_background_color) {
581
+ this._el.container.style.setProperty(
582
+ "--vco-color-text-background",
583
+ this.options.text_background_color,
584
+ );
585
+ }
586
+
587
+ // Create Layout
588
+ this._el.menubar = Dom.create("div", "vco-menubar", this._el.container);
589
+ this._el.map = this._resolveMapElement();
590
+ if (!this._el.map) {
591
+ this._el.map = Dom.create("div", "vco-map", this._el.container);
592
+ }
593
+ this._el.storyslider = Dom.create("div", "vco-storyslider", this._el.container);
594
+
595
+ // Initial Default Layout
596
+ this.options.width = this._el.container.offsetWidth;
597
+ this.options.height = this._el.container.offsetHeight;
598
+ this._el.map.style.height = "1px";
599
+ this._el.storyslider.style.top = "1px";
600
+
601
+ // Create Map using preferred Map API
602
+ this._map = new OpenLayersMap(this._el.map, this.data, this.options);
603
+ this.map = this._map._map; // For access to the OpenLayers map.
604
+ this._map.on("loaded", this._onMapLoaded, this);
605
+
606
+ // Map Background Color
607
+ this._el.map.style.backgroundColor = this.options.map_background_color;
608
+
609
+ // Create Menu Bar
610
+ this._menubar = new MenuBar(this._el.menubar, this._el.container, this.options);
611
+
612
+ // Create StorySlider
613
+ this._storyslider = new StorySlider(this._el.storyslider, this.data, this.options);
614
+ this._storyslider.on("loaded", this._onStorySliderLoaded, this);
615
+ this._storyslider.on("title", this._onTitle, this);
616
+ this._storyslider.init();
617
+
618
+ // LAYOUT
619
+ if (this.options.layout === "portrait") {
620
+ // Set Default Component Sizes
621
+ this.options.map_height = this.options.height / this.options.map_size_sticky;
622
+ this.options.storyslider_height =
623
+ this.options.height - this._el.menubar.offsetHeight - this.options.map_height - 1;
624
+ this._menubar.setSticky(0);
625
+ } else {
626
+ this.options.menubar_height = this._el.menubar.offsetHeight;
627
+ // Set Default Component Sizes
628
+ this.options.map_height = this.options.height;
629
+ this.options.storyslider_height =
630
+ this.options.height - this._el.menubar.offsetHeight - 1;
631
+ this._menubar.setSticky(this.options.menubar_height);
632
+ }
633
+
634
+ // Update Display
635
+ this._updateDisplay(this.options.map_height, true, 2000);
636
+
637
+ // Animate Menu Bar to Default Location
638
+ this._menubar.show(2000);
639
+ }
640
+
641
+ _initEvents() {
642
+ // Sidebar Events
643
+ this._menubar.on("collapse", this._onMenuBarCollapse, this);
644
+ this._menubar.on("back_to_start", this._onBackToStart, this);
645
+ this._menubar.on("overview", this._onOverview, this);
646
+ this._menubar.on("fullscreen", this._onFullscreenToggle, this);
647
+
648
+ // StorySlider Events
649
+ this._storyslider.on("change", this._onSlideChange, this);
650
+ this._storyslider.on("colorchange", this._onColorChange, this);
651
+
652
+ // Map Events
653
+ this._map.on("change", this._onMapChange, this);
654
+
655
+ // Global slide navigation (opt-in): the slider only listens on its
656
+ // own panel, which needs focus
657
+ if (this.options.keyboard) {
658
+ window.addEventListener("keydown", this._onKeyDownGlobal.bind(this));
659
+ }
660
+
661
+ // Fullscreen state
662
+ document.addEventListener("fullscreenchange", this._onFullscreenChange.bind(this));
663
+ }
664
+
665
+ _onKeyDownGlobal(e: KeyboardEvent) {
666
+ if (e.defaultPrevented) {
667
+ return;
668
+ }
669
+ const target = e.target as HTMLElement | null;
670
+ const tag = target?.tagName?.toLowerCase();
671
+ if (tag === "input" || tag === "textarea" || tag === "select" || target?.isContentEditable) {
672
+ return;
673
+ }
674
+ // let OpenLayers keep arrow-key map panning when the map has focus
675
+ if (target instanceof Element && target.closest(".vco-map")) {
676
+ return;
677
+ }
678
+ if (e.key === "ArrowRight" || e.key === "ArrowDown") {
679
+ e.preventDefault();
680
+ this._storyslider.next();
681
+ } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
682
+ e.preventDefault();
683
+ this._storyslider.previous();
684
+ }
685
+ }
686
+
687
+ // Update View
688
+ _updateDisplay(map_height?: number, animate?: boolean, d?: number) {
689
+ let duration = this.options.duration,
690
+ display_class = this.options.base_class;
691
+
692
+ if (d) {
693
+ duration = d;
694
+ }
695
+
696
+ // Update width and height
697
+ this.options.width = this._el.container.offsetWidth;
698
+ this.options.height = this._el.container.offsetHeight;
699
+
700
+ // Check if skinny
701
+ if (this.options.width <= this.options.skinny_size) {
702
+ this.options.layout = "portrait";
703
+ } else {
704
+ this.options.layout = "landscape";
705
+ }
706
+
707
+ // Map Height
708
+ if (map_height) {
709
+ this.options.map_height = map_height;
710
+ }
711
+
712
+ // Detect Mobile and Update Orientation on Touch devices
713
+ if (Browser.touch) {
714
+ this.options.layout = Browser.orientation();
715
+ display_class += " vco-mobile";
716
+ }
717
+
718
+ // LAYOUT
719
+ if (this.options.layout === "portrait") {
720
+ display_class += " vco-skinny";
721
+ // Map Offset
722
+ this._map.setMapOffset(0, 0);
723
+
724
+ this.options.map_height = this.options.height / this.options.map_size_sticky;
725
+ this.options.storyslider_height = this.options.height - this.options.map_height - 1;
726
+ this._menubar.setSticky(0);
727
+
728
+ // Portrait: the map spans the full width again (a landscape
729
+ // map_area "left" pass narrowed it)
730
+ display_class += " vco-layout-portrait";
731
+ this._el.map.style.width = "100%";
732
+
733
+ if (animate) {
734
+ // Animate Map
735
+ if (this.animator_map) {
736
+ this.animator_map.stop();
737
+ }
738
+
739
+ this.animator_map = Animate(this._el.map, {
740
+ height: this.options.map_height + "px",
741
+ duration: duration,
742
+ easing: easeOutStrong,
743
+ complete: () => {
744
+ this._map.updateDisplay(
745
+ this.options.width,
746
+ this.options.map_height,
747
+ animate,
748
+ d,
749
+ this.options.menubar_height,
750
+ );
751
+ },
752
+ });
753
+
754
+ // Animate StorySlider
755
+ if (this.animator_storyslider) {
756
+ this.animator_storyslider.stop();
757
+ }
758
+ this.animator_storyslider = Animate(this._el.storyslider, {
759
+ height: this.options.storyslider_height + "px",
760
+ duration: duration,
761
+ easing: easeOutStrong,
762
+ });
763
+ } else {
764
+ // Map
765
+ this._el.map.style.height = Math.ceil(this.options.map_height) + "px";
766
+
767
+ // StorySlider
768
+ this._el.storyslider.style.height = this.options.storyslider_height + "px";
769
+ }
770
+
771
+ // Update Component Displays
772
+ this._menubar.updateDisplay(this.options.width, this.options.height, animate);
773
+ this._map.updateDisplay(this.options.width, this.options.height, false);
774
+ this._storyslider.updateDisplay(
775
+ this.options.width,
776
+ this.options.storyslider_height,
777
+ animate,
778
+ this.options.layout,
779
+ );
780
+ } else {
781
+ // Landscape
782
+ display_class += " vco-layout-landscape";
783
+
784
+ this.options.menubar_height = this._el.menubar.offsetHeight;
785
+
786
+ // Set Default Component Sizes
787
+ this.options.map_height = this.options.height;
788
+ this.options.storyslider_height = this.options.height;
789
+ this._menubar.setSticky(this.options.menubar_height);
790
+
791
+ // Set Sticky state of MenuBar
792
+ this._menubar.setSticky(this.options.menubar_height);
793
+
794
+ this._el.map.style.height = this.options.height + "px";
795
+
796
+ // map_area "left": the map element is limited to the left, visible
797
+ // half (the slide panel is opaque) — no view offset needed;
798
+ // "full" (default): the map spans the whole width behind the
799
+ // fading slide panel and the view is offset by a quarter width
800
+ if (this.options.map_area === "left") {
801
+ display_class += " vco-map-area-left";
802
+ this._el.map.style.width = Math.floor(this.options.width / 2) + "px";
803
+ this._map.setMapOffset(0, 0);
804
+ } else {
805
+ this._el.map.style.width = "100%";
806
+ this._map.setMapOffset(-(this.options.width / 4), 0);
807
+ }
808
+
809
+ // StorySlider
810
+ this._el.storyslider.style.top = "0";
811
+ this._el.storyslider.style.height = this.options.storyslider_height + "px";
812
+
813
+ this._menubar.updateDisplay(this.options.width, this.options.height, animate);
814
+ this._map.updateDisplay(this.options.width, this.options.height, animate, d);
815
+ this._storyslider.updateDisplay(
816
+ this.options.width / 2,
817
+ this.options.storyslider_height,
818
+ animate,
819
+ this.options.layout,
820
+ );
821
+ }
822
+
823
+ if ((this.options.language as unknown as { direction?: string }).direction === "rtl") {
824
+ display_class += " vco-rtl";
825
+ }
826
+
827
+ // Apply class
828
+ this._el.container.className = display_class;
829
+ }
830
+
831
+ /* Events
832
+ ================================================== */
833
+
834
+ _onDataLoaded(e?: unknown) {
835
+ // attach the consent manager BEFORE the layout is created, so the
836
+ // slider/map/media options copies all share it
837
+ (this.options as Record<string, unknown>).consent_manager = new ConsentManager();
838
+ this.fire("dataloaded");
839
+ this._initLayout();
840
+ // font themes load once the layout exists: the consent ask for
841
+ // external font CSS needs a real container to render into
842
+ void this._loadFontCss().catch((err: unknown) => {
843
+ console.warn("StoryMapJS: font theme could not be loaded", err);
844
+ });
845
+ this._initEvents();
846
+ this._initResizeHandling();
847
+ this.ready = true;
848
+ this._startAutoplay();
849
+ }
850
+
851
+ /* Autoplay (issue #380) and hash bookmarks (issue #146)
852
+ ================================================== */
853
+ _startAutoplay() {
854
+ this._stopAutoplay();
855
+ this._autoplay_stopped = false;
856
+ if (this.options.autoplay > 0) {
857
+ // any user interaction stops autoplay permanently
858
+ const stop = () => {
859
+ this._autoplay_stopped = true;
860
+ this._stopAutoplay();
861
+ };
862
+ this._el.container.addEventListener("pointerdown", stop, { once: true });
863
+ this._el.container.addEventListener("keydown", stop, { once: true });
864
+ this._el.container.addEventListener("touchstart", stop, { once: true });
865
+ this._scheduleAutoplay();
866
+ }
867
+ }
868
+
869
+ _scheduleAutoplay() {
870
+ this._stopAutoplay();
871
+ if (this.options.autoplay > 0 && !this._autoplay_stopped) {
872
+ this._autoplay_timer = setTimeout(() => {
873
+ this._autoplay_timer = null;
874
+ if (this.current_slide + 1 < this.data.slides.length) {
875
+ this.goTo(this.current_slide + 1);
876
+ this._scheduleAutoplay();
877
+ }
878
+ }, this.options.autoplay);
879
+ }
880
+ }
881
+
882
+ _stopAutoplay() {
883
+ if (this._autoplay_timer) {
884
+ clearTimeout(this._autoplay_timer);
885
+ this._autoplay_timer = null;
886
+ }
887
+ }
888
+
889
+ /** Keep the URL hash in sync with the current slide (#slide-N). */
890
+ _syncHash() {
891
+ try {
892
+ // preserve the query string: pages like the embed player carry
893
+ // their configuration in it (?url=..., ?example=...)
894
+ history.replaceState(
895
+ null,
896
+ "",
897
+ window.location.pathname + window.location.search + "#slide-" + this.current_slide,
898
+ );
899
+ } catch {
900
+ // non-browser or sandboxed contexts
901
+ }
902
+ }
903
+
904
+ /** A #slide-N hash deep-links the storymap (applied on load + hashchange). */
905
+ _applyHashSlide(): boolean {
906
+ const match = /^#slide-(\d+)$/.exec(window.location.hash);
907
+ if (!match) return false;
908
+ const n = parseInt(match[1], 10);
909
+ if (n >= 0 && n < this.data.slides.length && n !== this.current_slide) {
910
+ this.goTo(n);
911
+ return true;
912
+ }
913
+ return false;
914
+ }
915
+
916
+ /* Resize handling
917
+ ================================================== */
918
+ _initResizeHandling() {
919
+ if (!this.options.trackResize) {
920
+ return;
921
+ }
922
+ // Leading + trailing debounce: the first resize event re-layouts
923
+ // immediately (instant feedback), the trailing one settles after the
924
+ // resize burst ends.
925
+ const onResize = () => {
926
+ if (this._resize_timer) {
927
+ clearTimeout(this._resize_timer);
928
+ } else {
929
+ this.updateDisplay();
930
+ }
931
+ this._resize_timer = setTimeout(() => {
932
+ this._resize_timer = null;
933
+ this.updateDisplay();
934
+ }, 200);
935
+ };
936
+ if (typeof ResizeObserver !== "undefined") {
937
+ // covers containers resized by their embedding layout
938
+ this._resize_observer = new ResizeObserver(onResize);
939
+ this._resize_observer.observe(this._el.container);
940
+ }
941
+ window.addEventListener("resize", onResize);
942
+ }
943
+
944
+ /**
945
+ * Resolve a caller-supplied map element (options.map_options.element).
946
+ * The passed element is "replaced by the real one": it is adopted as the
947
+ * map container (given the vco-map class) and moved into place between
948
+ * the menubar and the story slider.
949
+ */
950
+ _resolveMapElement(): HTMLElement | null {
951
+ const element = this.options.map_options?.element;
952
+ if (!element) {
953
+ return null;
954
+ }
955
+ const el = typeof element === "string" ? Dom.get(element) : element;
956
+ if (!el) {
957
+ console.error("StoryMapJS: map_options.element not found: " + String(element));
958
+ return null;
959
+ }
960
+ el.classList.add("vco-map");
961
+ this._el.menubar.after(el);
962
+ return el;
963
+ }
964
+
965
+ _onTitle(e: unknown) {
966
+ this.fire("title", e);
967
+ }
968
+
969
+ _onColorChange(e: { color?: unknown; image?: unknown }) {
970
+ if (e.color || e.image) {
971
+ this._menubar.setColor(true);
972
+ } else {
973
+ this._menubar.setColor(false);
974
+ }
975
+ }
976
+
977
+ _onSlideChange(e: { current_slide: number }) {
978
+ if (this.current_slide !== e.current_slide) {
979
+ const duration = slideTransitionDuration(this.current_slide, e.current_slide);
980
+ this.current_slide = e.current_slide;
981
+ this._map.goTo(this.current_slide);
982
+ this._beginTransition(duration);
983
+ this.fire("change", { current_slide: this.current_slide }, this);
984
+ this._syncHash();
985
+ this._scheduleAutoplay();
986
+ this._updateProgress();
987
+ }
988
+ }
989
+
990
+ _onMapChange(e: { current_marker: number }) {
991
+ if (this.current_slide !== e.current_marker) {
992
+ const duration = slideTransitionDuration(this.current_slide, e.current_marker);
993
+ this.current_slide = e.current_marker;
994
+ this._storyslider.goTo(this.current_slide);
995
+ this._beginTransition(duration);
996
+ this.fire("change", { current_slide: this.current_slide }, this);
997
+ this._syncHash();
998
+ this._scheduleAutoplay();
999
+ this._updateProgress();
1000
+ }
1001
+ }
1002
+
1003
+ _updateProgress() {
1004
+ if (this.options.show_progress && this._menubar) {
1005
+ this._menubar.setProgress(this.current_slide, this.data.slides.length);
1006
+ }
1007
+ }
1008
+
1009
+ _onOverview(e?: unknown) {
1010
+ this._map.markerOverview();
1011
+ }
1012
+
1013
+ /**
1014
+ * Toggle the native fullscreen API on the storymap container and keep the
1015
+ * menubar button state in sync.
1016
+ */
1017
+ _onFullscreenToggle(e?: unknown) {
1018
+ if (document.fullscreenElement === this._el.container) {
1019
+ document.exitFullscreen().catch((err: unknown) => {
1020
+ console.error("StoryMapJS: could not exit fullscreen", err);
1021
+ });
1022
+ } else {
1023
+ this._el.container.requestFullscreen().catch((err: unknown) => {
1024
+ console.error("StoryMapJS: could not enter fullscreen", err);
1025
+ });
1026
+ }
1027
+ }
1028
+
1029
+ _onFullscreenChange() {
1030
+ const active = document.fullscreenElement === this._el.container;
1031
+ this._menubar.setFullscreenState(active);
1032
+ // re-measure once the browser has applied the fullscreen layout
1033
+ // (the container resizes asynchronously after the event)
1034
+ requestAnimationFrame(() => {
1035
+ requestAnimationFrame(() => {
1036
+ this.updateDisplay();
1037
+ });
1038
+ });
1039
+ }
1040
+
1041
+ _onBackToStart(e?: unknown) {
1042
+ this.current_slide = 0;
1043
+ this._map.goTo(this.current_slide);
1044
+ this._storyslider.goTo(this.current_slide);
1045
+ this.fire("change", { current_slide: this.current_slide }, this);
1046
+ // keep hash, progress and autoplay in sync like the other paths
1047
+ this._syncHash();
1048
+ this._scheduleAutoplay();
1049
+ this._updateProgress();
1050
+ }
1051
+
1052
+ _onMenuBarCollapse(e: { y: number }) {
1053
+ this._updateDisplay(e.y, true);
1054
+ }
1055
+
1056
+ _onMapLoaded() {
1057
+ this._loaded.map = true;
1058
+ this._onLoaded();
1059
+ }
1060
+
1061
+ _onStorySliderLoaded() {
1062
+ this._loaded.storyslider = true;
1063
+ this._onLoaded();
1064
+ }
1065
+
1066
+ /**
1067
+ * Compute and display the great-circle distance of the marker route
1068
+ * (issue #341); hidden when `show_distance` is off.
1069
+ */
1070
+ _updateDistance() {
1071
+ if (!this.options.show_distance) {
1072
+ return;
1073
+ }
1074
+ const km = this._map.getRouteDistance();
1075
+ this._menubar.setDistance(km);
1076
+ }
1077
+
1078
+ _onLoaded() {
1079
+ if (this._loaded.storyslider && this._loaded.map) {
1080
+ this.fire("loaded", this.data);
1081
+ if (!this._hash_initialized) {
1082
+ this._hash_initialized = true;
1083
+ // a #slide-N hash deep-links the initial slide (issue #146)
1084
+ // and keeps working through the browser back/forward buttons
1085
+ this._applyHashSlide();
1086
+ window.addEventListener("hashchange", () => this._applyHashSlide());
1087
+ this._syncHash();
1088
+ }
1089
+ this._updateProgress();
1090
+ this._updateDistance();
1091
+ }
1092
+ }
1093
+ }
1094
+
1095
+ export default class StoryMap extends Evented(StoryMapBase) {
1096
+ /**
1097
+ * The library base path (the directory containing the module).
1098
+ * Derived from `import.meta.url`, which works both for the source module
1099
+ * (src/main.ts) and the built ESM bundle (js/storymap.js).
1100
+ */
1101
+ static SCRIPT_PATH = new URL("../", import.meta.url).href;
1102
+
1103
+ constructor(...args: ConstructorParameters<typeof StoryMapBase>) {
1104
+ super(...args);
1105
+ }
1106
+ }
1107
+
1108
+ export { StoryMap };