@projektemacher/storymapjs 0.10.8 → 0.11.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.
Files changed (195) hide show
  1. package/CHANGELOG +81 -0
  2. package/README.md +129 -12
  3. package/dist/assets/{demo-DBpeF31K.js → demo-bWnOyfUw.js} +1 -1
  4. package/dist/assets/harness-C3JUv5Ii.js +1 -0
  5. package/dist/assets/{harness-multi-CYHgI0a9.js → harness-multi-XZNex3tb.js} +1 -1
  6. package/dist/assets/{harness-support-Do7sIp2j.js → harness-support-CjfZIVeb.js} +1 -1
  7. package/dist/assets/main-DQ32hWBM.js +17 -0
  8. package/dist/context.json +13 -2
  9. package/dist/css/storymap.css +1 -1
  10. package/dist/demo.html +3 -2
  11. package/dist/docs/api/assets/navigation.js +1 -1
  12. package/dist/docs/api/assets/search.js +1 -1
  13. package/dist/docs/api/classes/StoryMap.html +777 -269
  14. package/dist/docs/api/functions/MediaType.html +1 -1
  15. package/dist/docs/api/functions/isPresentation3Collection.html +1 -1
  16. package/dist/docs/api/functions/isPresentation3Manifest.html +1 -1
  17. package/dist/docs/api/functions/isSlideshowCollection.html +51 -0
  18. package/dist/docs/api/functions/loadCSS.html +1 -1
  19. package/dist/docs/api/functions/manifestToStorymapData.html +1 -1
  20. package/dist/docs/api/functions/setLanguage.html +1 -1
  21. package/dist/docs/api/functions/slideshowToStorymapData.html +51 -0
  22. package/dist/docs/api/functions/slideshowVersion.html +48 -0
  23. package/dist/docs/api/functions/storymapToManifest.html +1 -1
  24. package/dist/docs/api/functions/validateStorymap.html +1 -1
  25. package/dist/docs/api/functions/validateStorymapAndReport.html +1 -1
  26. package/dist/docs/api/index.html +115 -12
  27. package/dist/docs/api/interfaces/AnimationHandle.html +2 -2
  28. package/dist/docs/api/interfaces/ImagereadyPayload.html +4 -4
  29. package/dist/docs/api/interfaces/LanguageEntry.html +6 -6
  30. package/dist/docs/api/interfaces/LatLngLiteral.html +4 -4
  31. package/dist/docs/api/interfaces/LoadOptions.html +2 -2
  32. package/dist/docs/api/interfaces/MapEvents.html +116 -0
  33. package/dist/docs/api/interfaces/MapMarkerEvents.html +66 -0
  34. package/dist/docs/api/interfaces/MarkerEventPayload.html +57 -0
  35. package/dist/docs/api/interfaces/MediaEvents.html +78 -0
  36. package/dist/docs/api/interfaces/MenuBarEvents.html +114 -0
  37. package/dist/docs/api/interfaces/MessageEvents.html +72 -0
  38. package/dist/docs/api/interfaces/OlLayer.html +1 -1
  39. package/dist/docs/api/interfaces/OlMap.html +1 -1
  40. package/dist/docs/api/interfaces/OlSource.html +1 -1
  41. package/dist/docs/api/interfaces/OlTileLayer.html +1 -1
  42. package/dist/docs/api/interfaces/OlVectorLayer.html +1 -1
  43. package/dist/docs/api/interfaces/OlView.html +1 -1
  44. package/dist/docs/api/interfaces/SlideBackgroundState.html +66 -0
  45. package/dist/docs/api/interfaces/SlideEvents.html +78 -0
  46. package/dist/docs/api/interfaces/SlideNavEvents.html +72 -0
  47. package/dist/docs/api/interfaces/SlideshowPlayerSettings.html +182 -0
  48. package/dist/docs/api/interfaces/SlideshowToStorymapResult.html +60 -0
  49. package/dist/docs/api/interfaces/SlideshowTranslatorOptions.html +65 -0
  50. package/dist/docs/api/interfaces/SlideshowWarning.html +72 -0
  51. package/dist/docs/api/interfaces/StoryMapEvents.html +146 -0
  52. package/dist/docs/api/interfaces/StoryMapMarker.html +763 -0
  53. package/dist/docs/api/interfaces/StorySliderEvents.html +102 -0
  54. package/dist/docs/api/interfaces/StorymapBasemap.html +69 -0
  55. package/dist/docs/api/interfaces/StorymapData.html +12 -6
  56. package/dist/docs/api/interfaces/StorymapDataWrapper.html +2 -2
  57. package/dist/docs/api/interfaces/StorymapDocument.html +2 -2
  58. package/dist/docs/api/interfaces/StorymapError.html +3 -3
  59. package/dist/docs/api/interfaces/StorymapGeoreference.html +5 -5
  60. package/dist/docs/api/interfaces/StorymapGeoreferenceBody.html +4 -4
  61. package/dist/docs/api/interfaces/StorymapGroundControlPoint.html +4 -4
  62. package/dist/docs/api/interfaces/StorymapLanguageMap.html +2 -2
  63. package/dist/docs/api/interfaces/StorymapManifest.html +12 -12
  64. package/dist/docs/api/interfaces/StorymapManifestAnnotation.html +43 -11
  65. package/dist/docs/api/interfaces/StorymapManifestBackground.html +6 -6
  66. package/dist/docs/api/interfaces/StorymapManifestCanvas.html +65 -17
  67. package/dist/docs/api/interfaces/StorymapManifestContentResource.html +8 -8
  68. package/dist/docs/api/interfaces/StorymapManifestFeatureCollection.html +4 -4
  69. package/dist/docs/api/interfaces/StorymapManifestGeoreferencing.html +7 -7
  70. package/dist/docs/api/interfaces/StorymapManifestImageService.html +4 -4
  71. package/dist/docs/api/interfaces/StorymapManifestRange.html +5 -5
  72. package/dist/docs/api/interfaces/StorymapManifestService.html +4 -4
  73. package/dist/docs/api/interfaces/StorymapManifestSpecificResource.html +4 -4
  74. package/dist/docs/api/interfaces/StorymapManifestStatement.html +3 -3
  75. package/dist/docs/api/interfaces/StorymapMapOptions.html +5 -5
  76. package/dist/docs/api/interfaces/StorymapOptions.html +277 -75
  77. package/dist/docs/api/interfaces/StorymapOverlayLayer.html +42 -11
  78. package/dist/docs/api/interfaces/StorymapSlide.html +70 -16
  79. package/dist/docs/api/interfaces/StorymapSlideBackground.html +4 -4
  80. package/dist/docs/api/interfaces/StorymapSlideLocation.html +58 -14
  81. package/dist/docs/api/interfaces/StorymapSlideMarker.html +8 -9
  82. package/dist/docs/api/interfaces/StorymapSlideMedia.html +49 -10
  83. package/dist/docs/api/interfaces/StorymapSlideNarration.html +61 -9
  84. package/dist/docs/api/interfaces/StorymapSlideText.html +4 -4
  85. package/dist/docs/api/interfaces/StorymapTilejson.html +8 -8
  86. package/dist/docs/api/interfaces/SwipableEvents.html +102 -0
  87. package/dist/docs/api/interfaces/TextEvents.html +66 -0
  88. package/dist/docs/api/media/events.md +66 -0
  89. package/dist/docs/api/media/migration-from-knightlab.md +89 -49
  90. package/dist/docs/api/media/slideshow.md +109 -0
  91. package/dist/docs/api/media/storymap-as-iiif-manifest.md +1007 -0
  92. package/dist/docs/api/modules.html +6 -6
  93. package/dist/docs/api/types/ContentState.html +3 -3
  94. package/dist/docs/api/types/FiredEvent.html +43 -0
  95. package/dist/docs/api/types/ImagereadyKind.html +1 -1
  96. package/dist/docs/api/types/MediaTypeMatch.html +5 -5
  97. package/dist/docs/api/types/SwipeDirection.html +36 -0
  98. package/dist/docs/api/types/TileSourceFactory.html +1 -1
  99. package/dist/docs/authoring.html +20 -7
  100. package/dist/docs/events.html +263 -0
  101. package/dist/docs/iiif.html +136 -22
  102. package/dist/docs/migration.html +66 -19
  103. package/dist/docs/readme.html +113 -12
  104. package/dist/embed/index.html +7 -0
  105. package/dist/examples/font-css-false.json +33 -0
  106. package/dist/examples/issue-image-region.json +1 -1
  107. package/dist/examples/layers-control.json +60 -0
  108. package/dist/examples/map-template-provider.json +36 -0
  109. package/dist/examples/multi-media.json +71 -0
  110. package/dist/examples-iiif/annotated-image.json +16 -0
  111. package/dist/examples-iiif/font-css-false.json +114 -0
  112. package/dist/examples-iiif/issue-image-region.json +1 -1
  113. package/dist/examples-iiif/layers-control.json +226 -0
  114. package/dist/examples-iiif/map-template-provider.json +133 -0
  115. package/dist/examples-iiif/multi-media.json +237 -0
  116. package/dist/examples-slideshow/single.json +79 -0
  117. package/dist/examples-slideshow/static/pixel.png +0 -0
  118. package/dist/examples-slideshow/static.json +44 -0
  119. package/dist/harness-multi.html +3 -3
  120. package/dist/harness.html +4 -3
  121. package/dist/index.html +3 -2
  122. package/dist/js/storymap.d.ts +1326 -289
  123. package/dist/js/storymap.js +6690 -4784
  124. package/dist/js/storymap.js.map +1 -1
  125. package/dist/navplace-properties.json +14 -0
  126. package/dist/site.css +54 -36
  127. package/docs/migration-from-knightlab.md +89 -49
  128. package/docs/storymap-as-iiif-manifest.md +199 -126
  129. package/package.json +4 -2
  130. package/schema/storymap.schema.json +334 -34
  131. package/src/core/Util.ts +3 -1
  132. package/src/core/mixins.ts +42 -28
  133. package/src/language/Language.ts +7 -0
  134. package/src/language/locale/.expected-gaps.json +287 -27
  135. package/src/language/locale/de.json +11 -1
  136. package/src/language/locale/en.json +11 -1
  137. package/src/main.ts +51 -0
  138. package/src/map/Map.ts +44 -6
  139. package/src/map/MapMarker.ts +16 -3
  140. package/src/map/openlayers/Map.OpenLayers.ts +787 -62
  141. package/src/map/openlayers/MapMarker.OpenLayers.ts +113 -13
  142. package/src/map/slideView.ts +149 -0
  143. package/src/map/types.ts +2 -0
  144. package/src/media/EmbedUtil.ts +27 -2
  145. package/src/media/Media.ts +27 -4
  146. package/src/media/embedId.ts +6 -0
  147. package/src/media/types/Flickr.ts +14 -3
  148. package/src/media/types/HtmlMedia.ts +66 -0
  149. package/src/media/types/Image.ts +15 -3
  150. package/src/media/types/SoundCloud.ts +1 -1
  151. package/src/media/types/Text.ts +27 -8
  152. package/src/media/types/Twitter.ts +9 -7
  153. package/src/media/types/Vimeo.ts +1 -1
  154. package/src/media/types/YouTube.ts +2 -2
  155. package/src/scss/Typography.scss +15 -15
  156. package/src/scss/VCO.StoryMap.scss +173 -8
  157. package/src/scss/core/Reset.scss +5 -18
  158. package/src/scss/map/VCO.Map.scss +1 -1
  159. package/src/scss/map/VCO.MapMarker.scss +20 -9
  160. package/src/scss/map/openlayers/VCO.Map.OpenLayers.scss +7 -4
  161. package/src/scss/media/VCO.Media.scss +7 -7
  162. package/src/scss/media/types/VCO.Media.Blockquote.scss +1 -1
  163. package/src/scss/media/types/VCO.Media.Text.scss +5 -5
  164. package/src/scss/media/types/VCO.Media.Twitter.scss +4 -4
  165. package/src/scss/media/types/VCO.Media.Wikipedia.scss +2 -19
  166. package/src/scss/site/site.scss +55 -36
  167. package/src/scss/slider/VCO.Slide.scss +49 -11
  168. package/src/scss/slider/VCO.SlideNav.scss +23 -7
  169. package/src/scss/slider/VCO.StorySlider.scss +50 -0
  170. package/src/scss/snapmap/VCO.SnapMap.scss +3 -3
  171. package/src/scss/ui/VCO.Consent.scss +20 -14
  172. package/src/scss/ui/VCO.Layers.scss +119 -0
  173. package/src/scss/ui/VCO.MenuBar.Button.scss +140 -19
  174. package/src/scss/ui/VCO.MenuBar.scss +9 -2
  175. package/src/scss/ui/VCO.Message.scss +14 -0
  176. package/src/slider/Slide.ts +232 -74
  177. package/src/slider/SlideNav.ts +15 -4
  178. package/src/slider/StorySlider.ts +203 -22
  179. package/src/storymap/Consent.ts +22 -0
  180. package/src/storymap/StoryMap.ts +944 -131
  181. package/src/storymap/content-state.ts +3 -8
  182. package/src/storymap/font-css.ts +72 -0
  183. package/src/storymap/from-slideshow.ts +785 -0
  184. package/src/storymap/iiif-shared.ts +228 -0
  185. package/src/storymap/iiif.ts +289 -100
  186. package/src/storymap/to-iiif.ts +131 -44
  187. package/src/types.ts +382 -17
  188. package/src/ui/LayersControl.ts +302 -0
  189. package/src/ui/MenuBar.ts +232 -21
  190. package/src/ui/Message.ts +36 -3
  191. package/src/ui/Swipable.ts +25 -7
  192. package/dist/assets/harness-Cr0rYpvt.js +0 -1
  193. package/dist/assets/main-DXalctuR.js +0 -17
  194. package/src/scss/VCO.StoryMap.Dark.scss +0 -150
  195. package/src/scss/Variables.Dark.scss +0 -57
@@ -1,4 +1,10 @@
1
- import { mergeData, slideTransitionDuration, updateData, prefersReducedMotion } from "../core/Util";
1
+ import {
2
+ mergeData,
3
+ slideTransitionDuration,
4
+ updateData,
5
+ prefersReducedMotion,
6
+ REDUCED_MOTION_QUERY,
7
+ } from "../core/Util";
2
8
  import { loadCSS } from "../core/Load";
3
9
  import { validateStorymapAndReport } from "./validate";
4
10
  import {
@@ -13,6 +19,14 @@ import {
13
19
  loadSeeAlso,
14
20
  manifestToStorymapData,
15
21
  } from "./iiif";
22
+ import {
23
+ followSlideshowPages,
24
+ formatSlideshowWarning,
25
+ isSlideshowCollection,
26
+ slideshowToStorymapData,
27
+ type SlideshowWarning,
28
+ } from "./from-slideshow";
29
+ import { asClampedNumber } from "./iiif-shared";
16
30
  import {
17
31
  ConsentManager,
18
32
  consentManagerOf,
@@ -31,6 +45,7 @@ import {
31
45
  claimLanguage,
32
46
  releaseLanguage,
33
47
  isLanguageConflict,
48
+ Language,
34
49
  } from "../language/Language";
35
50
  import {
36
51
  markInteraction,
@@ -39,78 +54,57 @@ import {
39
54
  unregisterParticipant,
40
55
  } from "../core/viewers";
41
56
  import MediaType from "../media/MediaType";
42
- import { Evented, type EventedInstance } from "../core/mixins";
57
+ import { Evented, type EventedInstance, type FiredEvent } from "../core/mixins";
43
58
  import OpenLayersMap from "../map/openlayers/Map.OpenLayers";
59
+ import type { ImagereadyPayload } from "../map/openlayers/Map.OpenLayers";
44
60
  import MenuBar from "../ui/MenuBar";
61
+ import type { LayersRow } from "../ui/LayersControl";
45
62
  import StorySlider from "../slider/StorySlider";
46
63
  import { Browser } from "../core/Browser";
47
64
  import Animate from "../animation/tween";
48
65
  import type { Map as OlMap } from "ol";
49
66
  import type OlLayer from "ol/layer/Layer";
50
67
  import type OpenLayersMapMarker from "../map/openlayers/MapMarker.OpenLayers";
68
+ export { resolveFontCssUrl, isExternalUrl, fontCssOriginal } from "./font-css";
69
+ import { resolveFontCssUrl, isExternalUrl, fontCssOriginal } from "./font-css";
51
70
  import type {
52
71
  AnimationHandle,
72
+ StorymapBasemap,
53
73
  StorymapData,
54
74
  StorymapDataWrapper,
55
75
  StorymapOptions,
56
76
  StorymapSlide,
77
+ StorymapSlideNarration,
57
78
  } from "../types";
58
79
 
59
80
  /** Map height in pixels while the menubar has collapsed the map (portrait only). */
60
81
  const COLLAPSED_MAP_HEIGHT = 1;
61
82
 
62
- type StoryMapListener = (e: unknown) => void;
63
-
64
- /**
65
- * Resolve a `font_css` value to an absolute stylesheet URL.
66
- *
67
- * - `stock:<name>` resolves against the library location.
68
- * - Absolute URLs pass through untouched.
69
- * - Anything else resolves against the page URL, so hosts can reference
70
- * themes vendored into their own public dir (e.g. "fonts/font.css").
71
- */
72
- export function resolveFontCssUrl(font: string): string {
73
- if (font.startsWith("stock:")) {
74
- const font_name = font.split(":")[1] || "default";
75
- // A crafted name ("stock:../../secret") would otherwise resolve to an
76
- // arbitrary same-origin file next to the bundle, because the name is
77
- // concatenated into a URL path. Only accept a bare theme name.
78
- if (!/^[a-z0-9-]+$/i.test(font_name)) {
79
- console.warn(
80
- "StoryMapJS: ignoring font_css with an invalid stock theme name",
81
- font_name,
82
- );
83
- return new URL(/* @vite-ignore */ "../css/fonts/font.default.css", import.meta.url)
84
- .href;
85
- }
86
- // resolved against the library location: one directory up from
87
- // src/main.ts (dev) and js/storymap.js (build) in both cases
88
- return new URL("../css/fonts/font." + font_name + ".css", import.meta.url).href;
89
- }
90
- // Absolute URL, protocol-relative, or a data: URL. Note this must be a
91
- // real URL test, not a `/^(http|https|\/\/)/` prefix check: the old prefix
92
- // test false-positived on a relative path that merely *starts* with those
93
- // letters (e.g. "httpfonts.css"), which raised a spurious consent prompt
94
- // for a same-origin file.
95
- if (/^[a-z][a-z0-9+.-]*:/i.test(font) || font.startsWith("//")) {
96
- return font;
97
- }
98
- return new URL(font, document.baseURI).href;
83
+ export interface StoryMapEvents {
84
+ change: { current_slide: number; current_id: string | null };
85
+ loaded: StorymapData;
86
+ title: { title: string };
87
+ dataloaded: undefined;
88
+ fontLoaded: { font: string };
89
+ transitionstart: { current_slide: number; duration: number };
90
+ transitionend: { current_slide: number };
91
+ error: { message: string; source: string; conflict?: boolean };
92
+ imageready: ImagereadyPayload;
93
+ annotationsloaded: {
94
+ stops: StorymapSlide[];
95
+ searchService: string | null;
96
+ failed: string[];
97
+ };
98
+ markerclick: { marker_number: number; current_slide: number };
99
+ popupopen: { marker_number: number; current_slide: number };
100
+ popupclose: { marker_number: number; current_slide: number };
101
+ basemapchange: { map_type: string; previous: string };
102
+ overlaychange: { index: number; visible: boolean };
99
103
  }
100
104
 
101
- /** True when `url` points off-origin (or at a data: URL) and needs a consent ask. */
102
- export function isExternalUrl(url: string): boolean {
103
- // data:/blob: are not off-origin but are not a theme stylesheet either
104
- if (/^(data|blob):/i.test(url)) {
105
- return true;
106
- }
107
- try {
108
- return new URL(url, document.baseURI).origin !== window.location.origin;
109
- } catch {
110
- // unparseable: treat as external, so we err towards asking
111
- return true;
112
- }
113
- }
105
+ type StoryMapListener<K extends keyof StoryMapEvents = keyof StoryMapEvents> = (
106
+ e: FiredEvent<StoryMapEvents, K>,
107
+ ) => void;
114
108
 
115
109
  /**
116
110
  * Interactive StoryMap viewer.
@@ -129,7 +123,7 @@ export function isExternalUrl(url: string): boolean {
129
123
  */
130
124
  class StoryMapBase {
131
125
  declare "_loaded": { storyslider: boolean; map: boolean };
132
- declare "on": EventedInstance["on"];
126
+ declare "on": EventedInstance<StoryMapEvents>["on"];
133
127
  declare "version": string;
134
128
  declare "ready": boolean;
135
129
  declare "_el": {
@@ -177,6 +171,21 @@ class StoryMapBase {
177
171
  declare "_autoplay_stopped": boolean;
178
172
  /** The narration player: one <audio> for the whole story, reused. */
179
173
  declare "_narration_el": HTMLAudioElement | null;
174
+ /**
175
+ * The ambient player: a second <audio> for a narration bed that outlives
176
+ * its slide (`narration.stopOnExit: false`, slideshow ambient audio).
177
+ * At most one ambient track plays; a new one replaces it.
178
+ */
179
+ declare "_ambient_el": HTMLAudioElement | null;
180
+ /** What each player is (or was) playing, so same-URL beds continue. */
181
+ declare "_narration_meta": { url: string; stopOnExit: boolean } | null;
182
+ declare "_ambient_meta": { url: string } | null;
183
+ /**
184
+ * Whether the ambient bed is actually playing: `HTMLMediaElement.paused`
185
+ * cannot be trusted here (test doubles and pre-gesture elements report
186
+ * stale state), so the viewer tracks it explicitly.
187
+ */
188
+ declare "_ambient_playing": boolean;
180
189
  /** Guards a stale play() from a slide change that already moved on. */
181
190
  declare "_narration_token": number;
182
191
  /** Whether narration consent was granted (asked once per story). */
@@ -191,12 +200,16 @@ class StoryMapBase {
191
200
  declare "_collapsed": boolean;
192
201
  /** the data source was a IIIF Presentation manifest (legacy zoomify options are ignored) */
193
202
  declare "_data_from_manifest": boolean;
203
+ /** the data source was a slideshow tour (slideshow-only features apply) */
204
+ declare "_data_from_slideshow": boolean;
194
205
  declare "_resize_observer": ResizeObserver | null;
195
206
  /** Stored so `dispose()` can remove them again (see AGENTS: no leaks) */
196
207
  declare "_on_resize": (() => void) | null;
197
208
  declare "_on_keydown_global": ((e: KeyboardEvent) => void) | null;
198
209
  declare "_on_fullscreen": (() => void) | null;
199
210
  declare "_on_hashchange": (() => void) | null;
211
+ /** Reduced-motion toggles mid-story stop the advance timer. */
212
+ declare "_on_reduced_motion": ((e: MediaQueryListEvent) => void) | null;
200
213
  /** Set by `dispose()` so a second call returns early. */
201
214
  declare "_disposed": boolean;
202
215
  /** identity of this viewer's claim on the page-wide locale */
@@ -207,11 +220,7 @@ class StoryMapBase {
207
220
  declare "_interaction": number;
208
221
  declare "_onInteraction": (() => void) | null;
209
222
  declare "_resize_timer": ReturnType<typeof setTimeout> | null;
210
- declare "fire": EventedInstance["fire"];
211
- declare "hasEventListeners": EventedInstance["hasEventListeners"];
212
-
213
- // TODO: mixin
214
- // includes: VCO.Events,
223
+ declare "fire": EventedInstance<StoryMapEvents>["fire"];
215
224
 
216
225
  /* Private Methods
217
226
  ================================================== */
@@ -231,26 +240,25 @@ class StoryMapBase {
231
240
  elem: string | HTMLElement,
232
241
  data: string | StorymapDataWrapper | Record<string, unknown>,
233
242
  options?: Partial<StorymapOptions>,
234
- listeners?: Record<string, StoryMapListener | StoryMapListener[]>,
243
+ listeners?: { [K in keyof StoryMapEvents]?: StoryMapListener<K> | StoryMapListener<K>[] },
235
244
  ) {
236
245
  for (const key in listeners) {
237
- const callbacks = listeners[key];
238
- if (typeof callbacks == "function") {
239
- this.on(key, callbacks);
240
- } else {
241
- for (const idx in callbacks) {
242
- if (typeof callbacks[idx] == "function") {
243
- this.on(key, callbacks[idx]);
244
- } else {
245
- console.log(
246
- "WARNING: Ignoring invalid callback '" +
247
- callbacks[idx] +
248
- "' defined for " +
249
- "listener '" +
250
- key +
251
- "' in StoryMap constructor",
252
- );
253
- }
246
+ const type = key as keyof StoryMapEvents;
247
+ const callbacks = listeners[type];
248
+ if (callbacks === undefined) continue;
249
+ const list = typeof callbacks == "function" ? [callbacks] : callbacks;
250
+ for (const callback of list) {
251
+ if (typeof callback == "function") {
252
+ this.on(type, callback as StoryMapListener);
253
+ } else {
254
+ console.warn(
255
+ "WARNING: Ignoring invalid callback '" +
256
+ callback +
257
+ "' defined for " +
258
+ "listener '" +
259
+ key +
260
+ "' in StoryMap constructor",
261
+ );
254
262
  }
255
263
  }
256
264
  }
@@ -333,11 +341,25 @@ class StoryMapBase {
333
341
  trackResize: true,
334
342
  keyboard: false,
335
343
  nocache: false,
344
+ slideshow: true,
336
345
  autoplay: 0,
337
346
  autoplay_media: false,
338
347
  show_progress: false,
348
+ progressbar: undefined,
339
349
  marker_labels: false,
340
350
  text_align: "left",
351
+ textmode: "right",
352
+ textsize: undefined,
353
+ fxmode: "slide",
354
+ mode: "standard",
355
+ hudcolor: "",
356
+ hudbgcolor: "",
357
+ hudopacity: undefined,
358
+ shownav: true,
359
+ show_headings: true,
360
+ show_scrollbars: true,
361
+ viewerheight: "",
362
+ show_info: true,
341
363
  map_overview_center: null,
342
364
  map_type: "", // "osm:standard",
343
365
  tile_source_factory: null,
@@ -355,6 +377,8 @@ class StoryMapBase {
355
377
  // that already exist here, so storymap JSON could not reach them
356
378
  overview_extent: null,
357
379
  overlays: [],
380
+ show_layers_control: false,
381
+ basemaps: undefined,
358
382
  // TileJSON 2.1 metadata for a tile-source basemap; a keyword
359
383
  // basemap has none (interop §2.9)
360
384
  tilejson: undefined,
@@ -396,6 +420,7 @@ class StoryMapBase {
396
420
  this._on_keydown_global = null;
397
421
  this._on_fullscreen = null;
398
422
  this._on_hashchange = null;
423
+ this._on_reduced_motion = null;
399
424
  this._raw_manifest = null;
400
425
  this._initial_deep_link = null;
401
426
  this._disposed = false;
@@ -408,6 +433,10 @@ class StoryMapBase {
408
433
  this._transition_timer = null;
409
434
  this._autoplay_stopped = false;
410
435
  this._narration_el = null;
436
+ this._ambient_el = null;
437
+ this._narration_meta = null;
438
+ this._ambient_meta = null;
439
+ this._ambient_playing = false;
411
440
  this._narration_token = 0;
412
441
  this._narration_allowed = true;
413
442
  this._user_gestured = false;
@@ -445,6 +474,10 @@ class StoryMapBase {
445
474
  // legacy zoomify options are only honored for storymap JSON sources —
446
475
  // a IIIF Presentation manifest cannot carry them
447
476
  this._data_from_manifest = false;
477
+ // slideshow tours unlock the slideshow-only presentation fields; the
478
+ // translator branch that sets this lives with the translator phase —
479
+ // until then only an explicit host flag (see slideshow_source) sets it
480
+ this._data_from_slideshow = false;
448
481
  if (typeof data === "string") {
449
482
  // issue #417: optional cache-busting re-fetch of the source file
450
483
  const url =
@@ -459,6 +492,12 @@ class StoryMapBase {
459
492
  this._data_from_manifest = true;
460
493
  this._raw_manifest = data;
461
494
  this.data = manifestToStorymapData(data);
495
+ } else if (this.options.slideshow !== false && isSlideshowCollection(data)) {
496
+ this._data_from_slideshow = true;
497
+ const { data: storymap, warnings } = slideshowToStorymapData(data);
498
+ this._warnSlideshow(warnings);
499
+ validateStorymapAndReport({ storymap }, "slideshow");
500
+ this.data = storymap;
462
501
  } else {
463
502
  const wrapper = data as StorymapDataWrapper;
464
503
  validateStorymapAndReport(wrapper);
@@ -477,6 +516,56 @@ class StoryMapBase {
477
516
 
478
517
  /* Load storymap data from a URL
479
518
  ================================================== */
519
+ /**
520
+ * One `console.warn` per warning kind: count, detail and sample ids.
521
+ * Translation losses are loud exactly once, never per slide. Shared
522
+ * formatting with the CLI (see `formatSlideshowWarning`).
523
+ */
524
+ _warnSlideshow(warnings: SlideshowWarning[]): void {
525
+ for (const warning of warnings) {
526
+ console.warn(`StoryMapJS: ${formatSlideshowWarning(warning)}`);
527
+ }
528
+ }
529
+
530
+ /**
531
+ * Follow a tour's `first.next` page chain, returning the following pages
532
+ * for the translator. Bounded (20) and cycle-guarded; a broken chain
533
+ * reports incompletely so the caller warns instead of truncating
534
+ * silently. Relative links resolve against the tour URL.
535
+ */
536
+ async _followSlideshowPages(
537
+ doc: unknown,
538
+ url: string,
539
+ ): Promise<{ pages: unknown[]; complete: boolean }> {
540
+ // relative tour URLs (harness, subpath deploys) resolve against the
541
+ // page; an unresolvable base still lets absolute links through
542
+ let base = url;
543
+ try {
544
+ base = new URL(url, window.location.href).href;
545
+ } catch {
546
+ // keep the raw url
547
+ }
548
+ const first = (doc as Record<string, unknown> | null)?.first;
549
+ return followSlideshowPages({
550
+ first,
551
+ base,
552
+ resolve: (link, current) => {
553
+ try {
554
+ return new URL(link, current).href;
555
+ } catch {
556
+ return null;
557
+ }
558
+ },
559
+ load: async (absolute: string) => {
560
+ const response = await fetch(absolute);
561
+ if (!response.ok) {
562
+ throw new Error(`HTTP ${response.status} ${response.statusText}`);
563
+ }
564
+ return response.json() as Promise<unknown>;
565
+ },
566
+ });
567
+ }
568
+
480
569
  async _loadDataFromUrl(url: string, source: string) {
481
570
  try {
482
571
  const response = await fetch(url);
@@ -495,6 +584,28 @@ class StoryMapBase {
495
584
  this._data_from_manifest = true;
496
585
  this._raw_manifest = result;
497
586
  this.data = manifestToStorymapData(result);
587
+ } else if (this.options.slideshow !== false && isSlideshowCollection(result)) {
588
+ this._data_from_slideshow = true;
589
+ const followed = await this._followSlideshowPages(result, url);
590
+ if (this._disposed) {
591
+ return;
592
+ }
593
+ const { data: storymap, warnings } = slideshowToStorymapData(result, {
594
+ extraPages: followed.pages,
595
+ });
596
+ this._warnSlideshow(warnings);
597
+ if (!followed.complete) {
598
+ this._warnSlideshow([
599
+ {
600
+ kind: "slideshow.paging",
601
+ count: 1,
602
+ sampleIds: [],
603
+ detail: "page chain broke, tour may be truncated",
604
+ },
605
+ ]);
606
+ }
607
+ validateStorymapAndReport({ storymap }, source);
608
+ this.data = storymap;
498
609
  } else {
499
610
  validateStorymapAndReport(result, source);
500
611
  const wrapper = result as StorymapDataWrapper;
@@ -520,10 +631,62 @@ class StoryMapBase {
520
631
 
521
632
  /* Initialize the options
522
633
  ================================================== */
634
+ /**
635
+ * Reset every slideshow-only option to its long-standing default unless
636
+ * the loaded tour came from a slideshow source. Internal documents keep
637
+ * the pre-existing rendering even when they (or a host, via
638
+ * setMapOptions) name the new keys; slideshow tours keep what the
639
+ * translator emitted. Runs on every load and every runtime option change,
640
+ * over the viewer options and (at runtime) the map's own options copy.
641
+ */
642
+ _normalizeNonSlideshowOptions(options: StorymapOptions = this.options): void {
643
+ if (options.slideshow_source === true) return;
644
+ options.textmode = "right";
645
+ options.textsize = undefined;
646
+ options.fxmode = "slide";
647
+ options.mode = "standard";
648
+ options.hudcolor = "";
649
+ options.hudbgcolor = "";
650
+ options.hudopacity = undefined;
651
+ options.shownav = true;
652
+ options.show_headings = true;
653
+ options.show_scrollbars = true;
654
+ options.viewerheight = "";
655
+ options.show_info = true;
656
+ options.progressbar = undefined;
657
+ }
658
+
523
659
  _initOptions() {
524
660
  // Grab options from storymap data
525
661
  updateData(this.options, this.data);
526
662
 
663
+ // Source marker for the slideshow-only presentation fields: set
664
+ // programmatically from the load path, never from data (the key is
665
+ // absent from defaults and schema, so updateData above cannot inject
666
+ // it — a host may still pass it explicitly as an escape hatch).
667
+ if (this._data_from_slideshow) {
668
+ this.options.slideshow_source = true;
669
+ }
670
+ // Slideshow-only chrome stays inert for internal documents: reset
671
+ // every such option to its default unless the tour came from a
672
+ // slideshow source. this.data itself is never touched (it is the
673
+ // host's object — see data-immutable.test.ts).
674
+ this._normalizeNonSlideshowOptions();
675
+ // Story credit (slideshow `creator`/`rights`) joins the map
676
+ // attribution line for slideshow tours only; the line re-sanitizes
677
+ // its input, so author strings render as text. Internal documents
678
+ // keep their own attribution untouched.
679
+ if (this._data_from_slideshow && this.data.credit) {
680
+ const credit = [this.data.credit.creator, this.data.credit.rights].filter(
681
+ (part): part is string => typeof part === "string" && part !== "",
682
+ );
683
+ if (credit.length > 0) {
684
+ this.options.attribution = [this.options.attribution, ...credit]
685
+ .filter((part) => part !== "")
686
+ .join(" · ");
687
+ }
688
+ }
689
+
527
690
  // Capture the deep link now, before the slider and map exist and can
528
691
  // navigate — and so rewrite the URL we would read it from later.
529
692
  this._initial_deep_link = {
@@ -550,9 +713,19 @@ class StoryMapBase {
550
713
  // map_area "left": the map is limited to the visible half with an
551
714
  // opaque slide panel — no offset needed (the view center is the
552
715
  // visible center already); "full" (default) offsets the view so
553
- // markers clear the panel that fades in over the map
716
+ // markers clear the panel that fades in over the map. A left
717
+ // text panel (textmode "left") mirrors the offset.
554
718
  if (this.options.map_area !== "left") {
555
- this.options.map_center_offset = { left: -200, top: 0 };
719
+ // side docks offset the view past the panel (mirrored for a
720
+ // left panel); a bottom dock recenters slightly upward so
721
+ // point stops clear the panel (fits use measured padding)
722
+ if (this.options.textmode === "left") {
723
+ this.options.map_center_offset = { left: 200, top: 0 };
724
+ } else if (this.options.textmode === "bottom") {
725
+ this.options.map_center_offset = { left: 0, top: -120 };
726
+ } else {
727
+ this.options.map_center_offset = { left: -200, top: 0 };
728
+ }
556
729
  } else {
557
730
  this.options.map_center_offset = { left: 0, top: 0 };
558
731
  }
@@ -591,6 +764,21 @@ class StoryMapBase {
591
764
  this.options.map_type = "";
592
765
  }
593
766
 
767
+ // Entry 0 of `basemaps` is the initial basemap unless `map_type` says
768
+ // otherwise ("" counts as "not saying": it is the default, not a
769
+ // choice). Omit `basemaps` for an overlays-only control.
770
+ const basemaps = this.options.basemaps;
771
+ if (
772
+ this.options.map_type === "" &&
773
+ Array.isArray(basemaps) &&
774
+ basemaps.length > 0 &&
775
+ typeof basemaps[0]?.map_type === "string" &&
776
+ basemaps[0].map_type !== "" &&
777
+ basemaps[0].map_type !== "none"
778
+ ) {
779
+ this.options.map_type = basemaps[0].map_type;
780
+ }
781
+
594
782
  // "none" is the only way to say there is no map. An absent key or ""
595
783
  // keeps meaning OSM, so no existing document changes meaning.
596
784
  this._map_disabled = this.options.map_type === "none";
@@ -603,7 +791,7 @@ class StoryMapBase {
603
791
  } else {
604
792
  this.options.map_type = "osm:standard";
605
793
  }
606
- console.log(`Deprecated map_type ${old_type}; using ${this.options.map_type}`);
794
+ console.warn(`Deprecated map_type ${old_type}; using ${this.options.map_type}`);
607
795
  }
608
796
 
609
797
  this._loadLanguage();
@@ -684,8 +872,14 @@ class StoryMapBase {
684
872
  async _loadFontCss() {
685
873
  // only genuinely external URLs ask for consent: stock: themes and
686
874
  // relative paths resolve to same-origin / library assets
687
- const original = this.options.font_css || "stock:default";
875
+ const original = fontCssOriginal(this.options.font_css);
876
+ if (original === null) {
877
+ // the theme ships in the host bundle (font_css: false): nothing
878
+ // to inject and no consent row for it
879
+ return;
880
+ }
688
881
  const font = resolveFontCssUrl(original);
882
+ if (font === null) return; // only "none" maps to null, handled above
689
883
  const manager = consentManagerOf(this.options);
690
884
  const external = /^(http|https|\/\/)/.test(original);
691
885
  if (external && this.options.consent_required && manager) {
@@ -760,6 +954,10 @@ class StoryMapBase {
760
954
  if (navigate !== "map") {
761
955
  this._map?.goTo(this.current_slide);
762
956
  }
957
+ // Per-slide image overlay (slideshow imgoverlay): shown while its
958
+ // slide is active, cleared otherwise. Null-safe: mapless stories
959
+ // have no map, slides without one clear a previous overlay.
960
+ this._map?.setSlideImageOverlay(this.data.slides?.[this.current_slide]?.imgoverlay ?? null);
763
961
  this._beginTransition(duration);
764
962
  if (!navigated) {
765
963
  // programmatic navigation reports outward like interaction does;
@@ -768,17 +966,14 @@ class StoryMapBase {
768
966
  // `current_id` so a host can tell *which stop* it is on, not just
769
967
  // how far along it is: an index moves when a slide is inserted, an
770
968
  // id does not (§5.1)
771
- this.fire(
772
- "change",
773
- {
774
- current_slide: this.current_slide,
775
- current_id: this._currentSlideId(),
776
- },
777
- this,
778
- );
969
+ this.fire("change", {
970
+ current_slide: this.current_slide,
971
+ current_id: this._currentSlideId(),
972
+ });
779
973
  }
780
974
  this._syncHash();
781
975
  this._playNarration(this.data.slides?.[this.current_slide]);
976
+ this._stopAmbientSlideMedia(this.data.slides?.[this.current_slide]);
782
977
  this._scheduleAutoplay();
783
978
  this._updateProgress();
784
979
  }
@@ -793,10 +988,10 @@ class StoryMapBase {
793
988
  if (this._transition_timer) {
794
989
  clearTimeout(this._transition_timer);
795
990
  }
796
- this.fire("transitionstart", { current_slide: this.current_slide, duration }, this);
991
+ this.fire("transitionstart", { current_slide: this.current_slide, duration });
797
992
  this._transition_timer = setTimeout(() => {
798
993
  this._transition_timer = null;
799
- this.fire("transitionend", { current_slide: this.current_slide }, this);
994
+ this.fire("transitionend", { current_slide: this.current_slide });
800
995
  }, duration);
801
996
  }
802
997
 
@@ -883,13 +1078,21 @@ class StoryMapBase {
883
1078
  }
884
1079
  }
885
1080
  mergeData(this.options, effective);
1081
+ // slideshow-only keys stay inert for internal documents at runtime too
1082
+ this._normalizeNonSlideshowOptions();
886
1083
  if (this._map && this._map.options) {
887
1084
  mergeData(this._map.options, effective);
1085
+ this._normalizeNonSlideshowOptions(this._map.options);
888
1086
  this._map.applyOptions(Object.keys(effective));
889
1087
  }
890
1088
  if (this.ready) {
891
1089
  // text color theming follows runtime option changes (issue #177)
892
1090
  this._applyTextColors();
1091
+ // slideshow chrome that maps onto container state does too;
1092
+ // layout modes (textmode, mode) need a display pass to take effect
1093
+ this._applyChrome();
1094
+ // layers may have been added, removed or re-projected
1095
+ this._menubar.refreshLayers();
893
1096
  this.updateDisplay();
894
1097
  }
895
1098
  }
@@ -1010,7 +1213,7 @@ class StoryMapBase {
1010
1213
  this._updateDistance();
1011
1214
  }
1012
1215
  const result = { stops: added, searchService: loaded.searchService, failed: loaded.failed };
1013
- this.fire("annotationsloaded", result, this);
1216
+ this.fire("annotationsloaded", result);
1014
1217
  return result;
1015
1218
  }
1016
1219
 
@@ -1065,6 +1268,12 @@ class StoryMapBase {
1065
1268
  window.removeEventListener("hashchange", this._on_hashchange);
1066
1269
  this._on_hashchange = null;
1067
1270
  }
1271
+ if (this._on_reduced_motion && typeof window.matchMedia === "function") {
1272
+ window
1273
+ .matchMedia(REDUCED_MOTION_QUERY)
1274
+ .removeEventListener("change", this._on_reduced_motion);
1275
+ this._on_reduced_motion = null;
1276
+ }
1068
1277
 
1069
1278
  // slide transitions in flight (Web Animations API)
1070
1279
  for (const el of [this._el?.container, this._el?.map]) {
@@ -1076,10 +1285,39 @@ class StoryMapBase {
1076
1285
  consentManagerOf(this.options)?.dispose();
1077
1286
 
1078
1287
  this._stopNarration();
1288
+ this._stopAmbient();
1079
1289
  if (this._narration_el) {
1080
1290
  this._narration_el.src = "";
1081
1291
  this._narration_el = null;
1082
1292
  }
1293
+ if (this._ambient_el) {
1294
+ this._ambient_el.src = "";
1295
+ this._ambient_el = null;
1296
+ }
1297
+ this._narration_meta = null;
1298
+ this._ambient_meta = null;
1299
+ // detach the child subscriptions so a host holding a documented
1300
+ // child handle (storymap._map) cannot keep this viewer alive
1301
+ // through the listener contexts
1302
+ this._map?.off("loaded", this._onMapLoaded, this);
1303
+ this._map?.off("change", this._onMapChange, this);
1304
+ this._map?.off("imageready", this._onImageReady, this);
1305
+ this._map?.off("popupopen", this._onMarkerPopupOpen, this);
1306
+ this._map?.off("popupclose", this._onMarkerPopupClose, this);
1307
+ this._map?.off("markerclick", this._onMarkerClick, this);
1308
+ this._map?.off("tilesallowed", this._onTilesAllowed, this);
1309
+ this._storyslider?.off("loaded", this._onStorySliderLoaded, this);
1310
+ this._storyslider?.off("title", this._onTitle, this);
1311
+ this._storyslider?.off("change", this._onSlideChange, this);
1312
+ this._storyslider?.off("colorchange", this._onColorChange, this);
1313
+ this._menubar?.off("collapse", this._onMenuBarCollapse, this);
1314
+ this._menubar?.off("back_to_start", this._onBackToStart, this);
1315
+ this._menubar?.off("overview", this._onOverview, this);
1316
+ this._menubar?.off("fullscreen", this._onFullscreenToggle, this);
1317
+ this._menubar?.off("basemapchange", this._onLayersBasemap, this);
1318
+ this._menubar?.off("overlaychange", this._onLayersOverlay, this);
1319
+ this._menubar?.off("autoplay_toggle", this._onAutoplayToggle, this);
1320
+ this._menubar?.off("progress_go", this._onProgressGo, this);
1083
1321
  this._storyslider?.dispose?.();
1084
1322
  this._menubar?.dispose?.();
1085
1323
  this._map?.dispose?.();
@@ -1143,6 +1381,53 @@ class StoryMapBase {
1143
1381
  return !this._disposed && this._map ? this._map.getMarker(index) : null;
1144
1382
  }
1145
1383
 
1384
+ /**
1385
+ * Open the popup card for slide `n`'s marker, navigating there first
1386
+ * when it is not the current slide.
1387
+ *
1388
+ * Returns false (and changes nothing) when the viewer is disposed,
1389
+ * has no map (`map_type: "none"`), `n` is out of range, or the marker
1390
+ * has popups disabled. Navigating fires the usual `change` (and
1391
+ * transition) events; a successful open fires `popupopen`.
1392
+ */
1393
+ openMarkerPopup(n: number): boolean {
1394
+ if (this._disposed) return false;
1395
+ if (!Number.isInteger(n) || n < 0 || n >= (this.data?.slides?.length ?? 0)) {
1396
+ return false;
1397
+ }
1398
+ if (this._map === null || this._map === undefined) return false;
1399
+ const marker = this._map.getMarker(n);
1400
+ if (!marker || !marker.isPopupEnabled()) return false;
1401
+ if (n !== this.current_slide) {
1402
+ this.goTo(n);
1403
+ }
1404
+ return marker.openPopup();
1405
+ }
1406
+
1407
+ /**
1408
+ * Close one marker's popup card (`n` given) or every open card
1409
+ * (omitted). No-op when the viewer is disposed or mapless. Fires
1410
+ * `popupclose` per card actually closed.
1411
+ */
1412
+ closeMarkerPopup(n?: number): void {
1413
+ if (this._disposed) return;
1414
+ if (this._map === null || this._map === undefined) return;
1415
+ if (n === undefined) {
1416
+ for (const marker of this._map.getMarkers()) {
1417
+ marker.closePopup();
1418
+ }
1419
+ return;
1420
+ }
1421
+ this._map.getMarker(n)?.closePopup();
1422
+ }
1423
+
1424
+ /** Whether slide `n`'s marker card is currently open. */
1425
+ isPopupOpen(n: number): boolean {
1426
+ if (this._disposed) return false;
1427
+ if (this._map === null || this._map === undefined) return false;
1428
+ return this._map.getMarker(n)?.popupOpen ?? false;
1429
+ }
1430
+
1146
1431
  /**
1147
1432
  * Rebuild the minimap (`OverviewMap` control). The constructor already
1148
1433
  * builds it; this is for hosts that recreate it after a `map_type` swap
@@ -1179,6 +1464,78 @@ class StoryMapBase {
1179
1464
  }
1180
1465
  }
1181
1466
 
1467
+ /**
1468
+ * Publish the slideshow-driven player chrome as container state.
1469
+ * Everything here is opt-in: unset options leave the container exactly
1470
+ * as the long-standing layout does, so existing stories are unaffected.
1471
+ */
1472
+ _applyChrome(): void {
1473
+ const options = this.options;
1474
+ const container = this._el.container;
1475
+ // idempotent: runtime option changes re-run this, so previous state
1476
+ // is cleared first (unset means the long-standing layout)
1477
+ container.classList.remove(
1478
+ "vco-textmode-left",
1479
+ "vco-textmode-bottom",
1480
+ "vco-no-headings",
1481
+ "vco-no-scrollbars",
1482
+ "vco-mode-static",
1483
+ );
1484
+ if (options.hudcolor) {
1485
+ container.style.setProperty("--vco-hud-fg", options.hudcolor);
1486
+ } else {
1487
+ container.style.removeProperty("--vco-hud-fg");
1488
+ }
1489
+ if (options.hudbgcolor) {
1490
+ const opacity = asClampedNumber(options.hudopacity, 0, 100);
1491
+ container.style.setProperty(
1492
+ "--vco-hud-bg",
1493
+ opacity === null
1494
+ ? options.hudbgcolor
1495
+ : `color-mix(in srgb, ${options.hudbgcolor} ${Math.round(opacity)}%, transparent)`,
1496
+ );
1497
+ } else {
1498
+ container.style.removeProperty("--vco-hud-bg");
1499
+ }
1500
+ if (typeof options.viewerheight === "string" && options.viewerheight !== "") {
1501
+ container.style.height = options.viewerheight;
1502
+ } else {
1503
+ container.style.removeProperty("height");
1504
+ }
1505
+ if (options.textmode === "left" || options.textmode === "bottom") {
1506
+ container.classList.add(`vco-textmode-${options.textmode}`);
1507
+ }
1508
+ if (options.show_headings === false) {
1509
+ container.classList.add("vco-no-headings");
1510
+ }
1511
+ if (options.show_scrollbars === false) {
1512
+ container.classList.add("vco-no-scrollbars");
1513
+ }
1514
+ if (options.mode === "static") {
1515
+ container.classList.add("vco-mode-static");
1516
+ }
1517
+ const textsize = options.textsize;
1518
+ if (typeof textsize === "number" && Number.isFinite(textsize)) {
1519
+ const clamped = asClampedNumber(textsize, 10, 80) ?? 50;
1520
+ container.style.setProperty("--vco-panel-size", `${clamped}%`);
1521
+ } else {
1522
+ container.style.removeProperty("--vco-panel-size");
1523
+ }
1524
+ }
1525
+
1526
+ /**
1527
+ * The slide panel share of the width (side docks) or height (bottom
1528
+ * dock) as a fraction: `textsize` percent when valid (10..80), else the
1529
+ * built-in split (half for sides, 40% height for the bottom dock).
1530
+ */
1531
+ _panelShare(): number {
1532
+ const textsize = this.options.textsize;
1533
+ if (typeof textsize === "number" && Number.isFinite(textsize)) {
1534
+ return (asClampedNumber(textsize, 10, 80) ?? 50) / 100;
1535
+ }
1536
+ return this.options.textmode === "bottom" ? 0.4 : 0.5;
1537
+ }
1538
+
1182
1539
  /* Private Methods
1183
1540
  ================================================== */
1184
1541
 
@@ -1190,6 +1547,18 @@ class StoryMapBase {
1190
1547
  this._el.container.className += " vco-storymap";
1191
1548
  this.options.base_class = this._el.container.className;
1192
1549
 
1550
+ // slideshow-driven player chrome first: viewerheight is an inline
1551
+ // height that the measurement below must see (classes are re-applied
1552
+ // in _updateDisplay, which rebuilds className from base_class)
1553
+ this._applyChrome();
1554
+
1555
+ // Colour theme (dark theme plan): an explicit option pins the
1556
+ // palette via data-vco-theme; unset, the CSS falls back to
1557
+ // prefers-color-scheme. Construction-time only.
1558
+ if (this.options.theme === "dark" || this.options.theme === "light") {
1559
+ this._el.container.setAttribute("data-vco-theme", this.options.theme);
1560
+ }
1561
+
1193
1562
  // Text color theming (issue #177): expose the text colors as CSS
1194
1563
  // custom properties consumed by the slide typography
1195
1564
  this._applyTextColors();
@@ -1200,6 +1569,17 @@ class StoryMapBase {
1200
1569
  ? null
1201
1570
  : (this._resolveMapElement() ?? Dom.create("div", "vco-map", this._el.container));
1202
1571
  this._el.storyslider = Dom.create("div", "vco-storyslider", this._el.container);
1572
+ // Skip link (a11y): first tab stop inside the widget, jumping to
1573
+ // the slide content. Only when the host container has an id to
1574
+ // anchor both ends to (multi-viewer pages need unique targets).
1575
+ if (this._el.container.id !== "") {
1576
+ this._el.storyslider.id = `${this._el.container.id}-slides`;
1577
+ const skip = Dom.create("a", "vco-skip-link", this._el.container);
1578
+ skip.setAttribute("href", `#${this._el.storyslider.id}`);
1579
+ skip.textContent =
1580
+ (Language.messages.skip_link as string | undefined) ?? "Skip to story content";
1581
+ this._el.container.prepend(skip);
1582
+ }
1203
1583
 
1204
1584
  // Initial Default Layout
1205
1585
  this.options.width = this._el.container.offsetWidth;
@@ -1229,9 +1609,7 @@ class StoryMapBase {
1229
1609
  // image readiness (IIIF/zoomify sources attach asynchronously) is
1230
1610
  // re-fired on the StoryMap, the coordination point for hosts that
1231
1611
  // overlay or measure their own layers
1232
- this._map.on("imageready", (e: unknown) => {
1233
- this.fire("imageready", e);
1234
- });
1612
+ this._map.on("imageready", this._onImageReady, this);
1235
1613
 
1236
1614
  // Map Background Color
1237
1615
  this._map_el().style.backgroundColor = this.options.map_background_color;
@@ -1239,6 +1617,10 @@ class StoryMapBase {
1239
1617
 
1240
1618
  // Create Menu Bar
1241
1619
  this._menubar = new MenuBar(this._el.menubar, this._el.container, this.options);
1620
+ // the progress chrome carries its initial state from construction,
1621
+ // not from the first map/slider load event — otherwise the bar (and
1622
+ // its ARIA) sits uninitialized until tiles arrive (issue #247)
1623
+ this._updateProgress();
1242
1624
 
1243
1625
  // Create StorySlider
1244
1626
  this._storyslider = new StorySlider(this._el.storyslider, this.data, this.options);
@@ -1287,6 +1669,16 @@ class StoryMapBase {
1287
1669
 
1288
1670
  // Map Events
1289
1671
  this._map?.on("change", this._onMapChange, this);
1672
+ this._map?.on("popupopen", this._onMarkerPopupOpen, this);
1673
+ this._map?.on("popupclose", this._onMarkerPopupClose, this);
1674
+ this._map?.on("markerclick", this._onMarkerClick, this);
1675
+ this._map?.on("tilesallowed", this._onTilesAllowed, this);
1676
+ this._menubar.on("basemapchange", this._onLayersBasemap, this);
1677
+ this._menubar.on("overlaychange", this._onLayersOverlay, this);
1678
+ this._menubar.on("autoplay_toggle", this._onAutoplayToggle, this);
1679
+ this._menubar.on("progress_go", this._onProgressGo, this);
1680
+ this._menubar.setLayersDelegate({ rows: () => this._layersRows() });
1681
+ this._menubar.refreshLayers();
1290
1682
 
1291
1683
  // Global slide navigation (opt-in): the slider only listens on its
1292
1684
  // own panel, which needs focus.
@@ -1313,6 +1705,18 @@ class StoryMapBase {
1313
1705
  // Fullscreen state
1314
1706
  this._on_fullscreen = () => this._onFullscreenChange();
1315
1707
  document.addEventListener("fullscreenchange", this._on_fullscreen);
1708
+
1709
+ // An OS reduced-motion toggle mid-story stops automatic advancement;
1710
+ // the next navigation's _scheduleAutoplay re-checks anyway, but an
1711
+ // armed timer would otherwise fire once more first.
1712
+ if (typeof window.matchMedia === "function") {
1713
+ this._on_reduced_motion = (e: MediaQueryListEvent) => {
1714
+ if (e.matches) this._stopAutoplay();
1715
+ };
1716
+ window
1717
+ .matchMedia(REDUCED_MOTION_QUERY)
1718
+ .addEventListener("change", this._on_reduced_motion);
1719
+ }
1316
1720
  }
1317
1721
 
1318
1722
  _onKeyDownGlobal(e: KeyboardEvent) {
@@ -1511,23 +1915,53 @@ class StoryMapBase {
1511
1915
  display_class += " vco-map-area-left";
1512
1916
  this._map_el().style.width = Math.floor(this.options.width / 2) + "px";
1513
1917
  this._map_required().setMapOffset(0, 0);
1918
+ } else if (this.options.textmode === "bottom") {
1919
+ // bottom dock: the map takes the top share, the slider the
1920
+ // bottom share (like the portrait split, but keeping the
1921
+ // landscape slide styling)
1922
+ display_class += " vco-textmode-bottom";
1923
+ const share = this._panelShare();
1924
+ this.options.map_height = Math.floor(this.options.height * (1 - share));
1925
+ this.options.storyslider_height = this.options.height - this.options.map_height - 1;
1926
+ this._map_el().style.width = "100%";
1927
+ this._map_el().style.height = this.options.map_height + "px";
1928
+ this._map_required().setMapOffset(0, 0);
1929
+ this._el.storyslider.style.top = this.options.map_height + "px";
1930
+ this._el.storyslider.style.height = this.options.storyslider_height + "px";
1931
+ this._menubar.updateDisplay(this.options.width, this.options.height, animate);
1932
+ this._map_required().updateDisplay(
1933
+ this.options.width,
1934
+ this.options.height,
1935
+ animate,
1936
+ d,
1937
+ );
1938
+ this._storyslider.updateDisplay(
1939
+ this.options.width,
1940
+ this.options.storyslider_height,
1941
+ animate,
1942
+ this.options.layout,
1943
+ );
1514
1944
  } else {
1515
1945
  this._map_el().style.width = "100%";
1516
1946
  this._map_required().setMapOffset(-(this.options.width / 4), 0);
1517
- }
1518
-
1519
- // StorySlider
1520
- this._el.storyslider.style.top = "0";
1521
- this._el.storyslider.style.height = this.options.storyslider_height + "px";
1947
+ // StorySlider
1948
+ this._el.storyslider.style.top = "0";
1949
+ this._el.storyslider.style.height = this.options.storyslider_height + "px";
1522
1950
 
1523
- this._menubar.updateDisplay(this.options.width, this.options.height, animate);
1524
- this._map_required().updateDisplay(this.options.width, this.options.height, animate, d);
1525
- this._storyslider.updateDisplay(
1526
- this.options.width / 2,
1527
- this.options.storyslider_height,
1528
- animate,
1529
- this.options.layout,
1530
- );
1951
+ this._menubar.updateDisplay(this.options.width, this.options.height, animate);
1952
+ this._map_required().updateDisplay(
1953
+ this.options.width,
1954
+ this.options.height,
1955
+ animate,
1956
+ d,
1957
+ );
1958
+ this._storyslider.updateDisplay(
1959
+ this.options.width * this._panelShare(),
1960
+ this.options.storyslider_height,
1961
+ animate,
1962
+ this.options.layout,
1963
+ );
1964
+ }
1531
1965
  }
1532
1966
 
1533
1967
  // the resolved locale decides the layout direction (issues #211, #245)
@@ -1537,6 +1971,10 @@ class StoryMapBase {
1537
1971
 
1538
1972
  // Apply class
1539
1973
  this._el.container.className = display_class;
1974
+ // _updateDisplay rebuilds the container class from base_class, which
1975
+ // wipes the opt-in chrome state _initLayout applied — re-apply it
1976
+ // after every layout pass (init, resize, runtime option changes)
1977
+ this._applyChrome();
1540
1978
  }
1541
1979
 
1542
1980
  /* Events
@@ -1589,14 +2027,18 @@ class StoryMapBase {
1589
2027
  // to have no slides at all)
1590
2028
  const seen = new Set<string>();
1591
2029
  for (const slide of this.data.slides ?? []) {
1592
- const url = (slide.media as { url?: string | null } | null)?.url;
1593
- if (!url) continue;
1594
- const match = MediaType({ url } as never) as { type: string; name: string } | false;
1595
- if (!match || seen.has(match.type)) continue;
1596
- seen.add(match.type);
1597
- // same registry the per-slide panel uses, so the two dialogs can
1598
- // never disagree about a service's name
1599
- services.push(mediaService(match.type, match.name));
2030
+ // every visible media item is its own service (#358)
2031
+ const items = [slide.media, ...(slide.media_extra ?? [])];
2032
+ for (const item of items) {
2033
+ const url = (item as { url?: string | null } | null)?.url;
2034
+ if (!url) continue;
2035
+ const match = MediaType({ url } as never) as { type: string; name: string } | false;
2036
+ if (!match || seen.has(match.type)) continue;
2037
+ seen.add(match.type);
2038
+ // same registry the per-slide panel uses, so the two dialogs can
2039
+ // never disagree about a service's name
2040
+ services.push(mediaService(match.type, match.name));
2041
+ }
1600
2042
  }
1601
2043
  // Slide narration is its own service: the recording plays through a
1602
2044
  // dedicated audio element, not through Media, so nothing else asks
@@ -1611,16 +2053,21 @@ class StoryMapBase {
1611
2053
  // origin, not by a prefix test on the resolved URL: resolveFontCssUrl
1612
2054
  // has already turned a relative path into an absolute one, so testing
1613
2055
  // that for "http" made every relative font_css look external.
1614
- const font = resolveFontCssUrl(this.options.font_css || "stock:default");
1615
- if (isExternalUrl(font)) {
1616
- services.push(fontService());
2056
+ // A host-bundled theme (font_css: false) loads no stylesheet, so it
2057
+ // contributes no font service either.
2058
+ const originalFontCss = fontCssOriginal(this.options.font_css);
2059
+ if (originalFontCss !== null) {
2060
+ const font = resolveFontCssUrl(originalFontCss);
2061
+ if (font !== null && isExternalUrl(font)) {
2062
+ services.push(fontService());
2063
+ }
1617
2064
  }
1618
2065
  manager.requestAll(services, this._el.container);
1619
2066
  }
1620
2067
 
1621
2068
  /* Autoplay (issue #380) and hash bookmarks (issue #146)
1622
2069
  ================================================== */
1623
- /* Narration (docs/plans/iiif-media-tours.md §2)
2070
+ /* Narration
1624
2071
  ================================================== */
1625
2072
 
1626
2073
  /**
@@ -1638,7 +2085,49 @@ class StoryMapBase {
1638
2085
  * playback, and the story still works.
1639
2086
  */
1640
2087
  _playNarration(slide: StorymapSlide | undefined, allow_without_gesture = false) {
1641
- const url = (slide?.narration as { url?: string } | null)?.url;
2088
+ // Slideshow-only playback flags render only for slideshow tours;
2089
+ // internal documents play the bare URL exactly as before (no data is
2090
+ // mutated — the bag is reduced to a local).
2091
+ const raw = slide?.narration ?? null;
2092
+ const bag =
2093
+ this.options.slideshow_source === true
2094
+ ? raw
2095
+ : raw && typeof raw.url === "string"
2096
+ ? { url: raw.url }
2097
+ : null;
2098
+ const url = typeof bag?.url === "string" && bag.url !== "" ? bag.url : null;
2099
+ const persistent = bag?.stopOnExit === false;
2100
+ if (url !== null && persistent) {
2101
+ // An audio bed that outlives its slide (slideshow ambient
2102
+ // audio): already playing this bed → keep it, dropping any
2103
+ // foreground left over from the slide we came from. At most one
2104
+ // ambient track plays; a new one replaces the old.
2105
+ if (this._ambient_meta?.url === url && this._ambient_playing) {
2106
+ this._stopNarration();
2107
+ return;
2108
+ }
2109
+ this._stopNarration();
2110
+ if (bag?.stopAllPrevious !== false) this._stopAmbient();
2111
+ if (this._disposed || !this._narration_allowed) return;
2112
+ if (!allow_without_gesture && !this._has_user_gesture()) {
2113
+ this._replayNarrationAfterGesture = true;
2114
+ return;
2115
+ }
2116
+ const el = this._ambientElement();
2117
+ this._ambient_meta = { url };
2118
+ this._startTrack(el, url, bag, allow_without_gesture);
2119
+ return;
2120
+ }
2121
+ // An ephemeral track (or none): a newcomer that does not opt out of
2122
+ // stopping replaces any ambient bed; a silent slide keeps a playing
2123
+ // bed (it belongs to the tour, not to the slide we left).
2124
+ if (url === null || bag?.stopAllPrevious !== false) {
2125
+ if (url === null && this._ambient_playing) {
2126
+ this._stopNarration();
2127
+ return;
2128
+ }
2129
+ this._stopAmbient();
2130
+ }
1642
2131
  this._stopNarration();
1643
2132
  if (!url || this._disposed || !this._narration_allowed) return;
1644
2133
  if (!allow_without_gesture && !this._has_user_gesture()) {
@@ -1646,16 +2135,111 @@ class StoryMapBase {
1646
2135
  return;
1647
2136
  }
1648
2137
  const el = this._narrationElement();
2138
+ this._narration_meta = { url, stopOnExit: bag?.stopOnExit !== false };
2139
+ this._startTrack(el, url, bag, allow_without_gesture);
2140
+ }
2141
+
2142
+ /**
2143
+ * Start (or arm) a narration track: loop flag, start offset once the
2144
+ * metadata is available, and `play: "click"` arms paused for the
2145
+ * visitor instead of playing (see `playNarration()`). A rejected play()
2146
+ * (policy, network) must not surface as an unhandled rejection.
2147
+ */
2148
+ _startTrack(
2149
+ el: HTMLAudioElement,
2150
+ url: string,
2151
+ bag: StorymapSlideNarration | null,
2152
+ allow_without_gesture: boolean,
2153
+ ) {
2154
+ el.loop = bag?.loop === true;
1649
2155
  el.src = url;
1650
- el.currentTime = 0;
2156
+ this._seekAudio(el, bag?.offset);
2157
+ if (bag?.play === "click") return;
2158
+ if (!allow_without_gesture && !this._has_user_gesture()) {
2159
+ this._replayNarrationAfterGesture = true;
2160
+ return;
2161
+ }
2162
+ if (el === this._ambient_el) {
2163
+ this._ambient_playing = true;
2164
+ }
2165
+ const token = ++this._narration_token;
2166
+ void el.play()?.catch?.(() => {
2167
+ if (token !== this._narration_token) return;
2168
+ // a failed start is not playing: the gesture retry must attempt
2169
+ // it again rather than take the same-URL fast path
2170
+ if (el === this._ambient_el) {
2171
+ this._ambient_playing = false;
2172
+ }
2173
+ this._replayNarrationAfterGesture = true;
2174
+ });
2175
+ }
2176
+
2177
+ /**
2178
+ * Start the current slide's click-armed narration (`narration.play:
2179
+ * "click"`): the control for visitors (or hosts) to start a track that
2180
+ * waits for them. Auto-armed tracks play on their own. No-op when
2181
+ * nothing is armed. Prefers the current slide's own track: with an
2182
+ * ambient bed running, the foreground it armed is the one to start.
2183
+ */
2184
+ playNarration(): void {
2185
+ const bag = this.data.slides?.[this.current_slide]?.narration ?? null;
2186
+ const persistent = bag?.stopOnExit === false && typeof bag?.url === "string";
2187
+ const el =
2188
+ persistent && this._ambient_meta?.url === bag?.url
2189
+ ? this._ambient_el
2190
+ : this._narration_el;
2191
+ if (!el || !el.getAttribute("src")) return;
2192
+ if (this._disposed || !this._narration_allowed || this._has_user_gesture() === false) {
2193
+ this._replayNarrationAfterGesture = true;
2194
+ return;
2195
+ }
2196
+ if (el === this._ambient_el) {
2197
+ this._ambient_playing = true;
2198
+ }
1651
2199
  const token = ++this._narration_token;
1652
- // a rejected play() (policy, network) must not surface as an
1653
- // unhandled rejection
1654
2200
  void el.play()?.catch?.(() => {
1655
- if (token === this._narration_token) this._replayNarrationAfterGesture = true;
2201
+ if (token !== this._narration_token) return;
2202
+ if (el === this._ambient_el) {
2203
+ this._ambient_playing = false;
2204
+ }
2205
+ this._replayNarrationAfterGesture = true;
1656
2206
  });
1657
2207
  }
1658
2208
 
2209
+ /**
2210
+ * Seek to a start offset in seconds (slideshow `audio.offset`):
2211
+ * immediately when the metadata is already there, otherwise once on
2212
+ * `loadedmetadata`. Guarded by the narration token, so a quick
2213
+ * pass-through cannot seek a newer track.
2214
+ */
2215
+ _seekAudio(el: HTMLAudioElement, offset: number | null | undefined) {
2216
+ const target =
2217
+ typeof offset === "number" && Number.isFinite(offset) && offset > 0 ? offset : 0;
2218
+ const token = this._narration_token;
2219
+ const apply = () => {
2220
+ if (token !== this._narration_token || this._disposed) return;
2221
+ try {
2222
+ el.currentTime = target;
2223
+ } catch {
2224
+ // metadata not ready yet; the listener below retries
2225
+ }
2226
+ };
2227
+ if (el.readyState >= 1) {
2228
+ apply();
2229
+ return;
2230
+ }
2231
+ const on_metadata = () => {
2232
+ el.removeEventListener("loadedmetadata", on_metadata);
2233
+ apply();
2234
+ };
2235
+ el.addEventListener("loadedmetadata", on_metadata);
2236
+ try {
2237
+ el.currentTime = target;
2238
+ } catch {
2239
+ // pre-metadata seek throws in some browsers; the listener covers it
2240
+ }
2241
+ }
2242
+
1659
2243
  _narrationElement(): HTMLAudioElement {
1660
2244
  if (this._narration_el === null) {
1661
2245
  this._narration_el = document.createElement("audio");
@@ -1668,12 +2252,33 @@ class StoryMapBase {
1668
2252
 
1669
2253
  _stopNarration() {
1670
2254
  this._narration_token++;
2255
+ this._narration_meta = null;
1671
2256
  const el = this._narration_el;
1672
2257
  if (!el) return;
1673
2258
  el.pause();
1674
2259
  el.removeAttribute("src");
1675
2260
  }
1676
2261
 
2262
+ _ambientElement(): HTMLAudioElement {
2263
+ if (this._ambient_el === null) {
2264
+ this._ambient_el = document.createElement("audio");
2265
+ this._ambient_el.className = "vco-media-item vco-ambient";
2266
+ this._ambient_el.preload = "none";
2267
+ this._ambient_el.setAttribute("aria-hidden", "true");
2268
+ }
2269
+ return this._ambient_el;
2270
+ }
2271
+
2272
+ _stopAmbient() {
2273
+ this._narration_token++;
2274
+ this._ambient_meta = null;
2275
+ this._ambient_playing = false;
2276
+ const el = this._ambient_el;
2277
+ if (!el) return;
2278
+ el.pause();
2279
+ el.removeAttribute("src");
2280
+ }
2281
+
1677
2282
  /** A gesture has happened, so unmuted audio is allowed from now on. */
1678
2283
  _note_user_gesture_for_narration() {
1679
2284
  if (this._disposed) return;
@@ -1710,10 +2315,16 @@ class StoryMapBase {
1710
2315
  }
1711
2316
  // prefers-reduced-motion: no autoplay (WCAG 2.2.2)
1712
2317
  if (this.options.autoplay > 0 && !prefersReducedMotion()) {
1713
- // any user interaction stops autoplay permanently
1714
- const stop = () => {
2318
+ // any user interaction stops autoplay permanently — except on
2319
+ // the pause/resume toggle itself, whose click would otherwise
2320
+ // stop and immediately restart (net no-op instead of pause)
2321
+ const stop = (e: Event) => {
2322
+ if ((e.target as HTMLElement | null)?.closest?.(".vco-menubar-autoplay")) {
2323
+ return;
2324
+ }
1715
2325
  this._autoplay_stopped = true;
1716
2326
  this._stopAutoplay();
2327
+ this._menubar.setAutoplayState(true);
1717
2328
  };
1718
2329
  this._el.container.addEventListener("pointerdown", stop, { once: true });
1719
2330
  this._el.container.addEventListener("keydown", stop, { once: true });
@@ -1722,6 +2333,45 @@ class StoryMapBase {
1722
2333
  }
1723
2334
  }
1724
2335
 
2336
+ /**
2337
+ * Stop other slides' ambient audio beds when the new slide claims the
2338
+ * audio: its narration (unless opted out via `stopAllPrevious: false`),
2339
+ * or playable media that is actually playing (a paused click-to-play
2340
+ * bed must not kill an inherited ambient track it never starts).
2341
+ * A silent slide inherits a playing bed.
2342
+ */
2343
+ _stopAmbientSlideMedia(slide: StorymapSlide | undefined) {
2344
+ const claimsNarration =
2345
+ typeof slide?.narration?.url === "string" &&
2346
+ slide.narration.url !== "" &&
2347
+ slide.narration.stopAllPrevious !== false;
2348
+ const claimsMedia =
2349
+ (slide?.media as { stopAllPrevious?: boolean } | null | undefined)?.stopAllPrevious !==
2350
+ false && this._isSlideMediaPlaying(this.current_slide);
2351
+ if (!claimsNarration && !claimsMedia) return;
2352
+ const slides = this._storyslider?._slides;
2353
+ if (!slides) return;
2354
+ slides.forEach((entry, index) => {
2355
+ if (index !== this.current_slide) entry.stopAmbientMedia?.();
2356
+ });
2357
+ }
2358
+
2359
+ /**
2360
+ * True when the slide's media element exists and is currently playing
2361
+ * (not paused, not ended, not merely loadable).
2362
+ */
2363
+ _isSlideMediaPlaying(index: number): boolean {
2364
+ const slide = this._storyslider?._slides?.[index];
2365
+ const medias = (
2366
+ slide as unknown as { _medias?: { player_element?: HTMLMediaElement | null }[] }
2367
+ )?._medias;
2368
+ if (!Array.isArray(medias)) return false;
2369
+ return medias.some((media) => {
2370
+ const el = media?.player_element;
2371
+ return !!el && typeof el.paused === "boolean" && !el.paused && !el.ended;
2372
+ });
2373
+ }
2374
+
1725
2375
  /**
1726
2376
  * Arm autoplay for the current slide.
1727
2377
  *
@@ -1737,6 +2387,10 @@ class StoryMapBase {
1737
2387
  _scheduleAutoplay() {
1738
2388
  this._stopAutoplay();
1739
2389
  if (!(this.options.autoplay > 0) || this._autoplay_stopped) return;
2390
+ // the OS setting may have flipped mid-story: re-check on every arm
2391
+ // so a reduced-motion switch stops future advances (the in-flight
2392
+ // timer is cleared by the next navigation's _scheduleAutoplay)
2393
+ if (prefersReducedMotion()) return;
1740
2394
 
1741
2395
  const advance = () => {
1742
2396
  this._stopAutoplay();
@@ -1747,14 +2401,33 @@ class StoryMapBase {
1747
2401
  };
1748
2402
  this._autoplay_advance = advance;
1749
2403
 
2404
+ // Per-slide dwell (slideshow slidetimeout): overrides the global
2405
+ // interval for this slide; 0 holds here even with autoplay on.
2406
+ // Internal documents always use the global interval.
2407
+ const rawTimeout = this.data.slides?.[this.current_slide]?.slidetimeout;
2408
+ const slideTimeout = this.options.slideshow_source === true ? rawTimeout : undefined;
2409
+ const dwell =
2410
+ typeof slideTimeout === "number" && Number.isFinite(slideTimeout) && slideTimeout >= 0
2411
+ ? slideTimeout
2412
+ : this.options.autoplay;
2413
+ if (!(dwell > 0)) return;
2414
+
1750
2415
  if (this.options.autoplay_media) {
1751
2416
  const slide = this._storyslider?._slides?.[this.current_slide];
1752
2417
  if (slide?.hasPlayableMedia?.()) {
1753
- slide.onMediaEnded(advance);
2418
+ // re-arming clears the previous advance: without this,
2419
+ // revisits accumulate listeners and a stale ended (e.g. an
2420
+ // ambient bed outliving its slide) advances the wrong slide.
2421
+ // The index guard covers the race between clearing and firing.
2422
+ slide.clearMediaEnded();
2423
+ const armed = this.current_slide;
2424
+ slide.onMediaEnded(() => {
2425
+ if (this.current_slide === armed) advance();
2426
+ });
1754
2427
  }
1755
2428
  }
1756
2429
 
1757
- this._autoplay_timer = setTimeout(advance, this.options.autoplay);
2430
+ this._autoplay_timer = setTimeout(advance, dwell);
1758
2431
  }
1759
2432
 
1760
2433
  _stopAutoplay() {
@@ -1959,7 +2632,7 @@ class StoryMapBase {
1959
2632
  return el;
1960
2633
  }
1961
2634
 
1962
- _onTitle(e: unknown) {
2635
+ _onTitle(e: { title: string }) {
1963
2636
  this.fire("title", e);
1964
2637
  }
1965
2638
 
@@ -1985,8 +2658,148 @@ class StoryMapBase {
1985
2658
  }
1986
2659
  }
1987
2660
 
2661
+ _onImageReady(e: ImagereadyPayload) {
2662
+ this.fire("imageready", e);
2663
+ }
2664
+
2665
+ _onMarkerPopupOpen(e: { marker_number: number }) {
2666
+ this.fire("popupopen", {
2667
+ marker_number: e.marker_number,
2668
+ current_slide: this.current_slide,
2669
+ });
2670
+ }
2671
+
2672
+ _onMarkerPopupClose(e: { marker_number: number }) {
2673
+ this.fire("popupclose", {
2674
+ marker_number: e.marker_number,
2675
+ current_slide: this.current_slide,
2676
+ });
2677
+ }
2678
+
2679
+ _onMarkerClick(e: { marker_number: number }) {
2680
+ // Re-fired from the map surface so a host listening on the viewer
2681
+ // sees marker clicks; the map already navigated itself.
2682
+ this.fire("markerclick", {
2683
+ marker_number: e.marker_number,
2684
+ current_slide: this.current_slide,
2685
+ });
2686
+ }
2687
+
2688
+ _onLayersBasemap(e: { map_type: string }) {
2689
+ const previous = this.options.map_type;
2690
+ this.setMapOption("map_type", e.map_type);
2691
+ this._menubar.refreshLayers();
2692
+ this.fire("basemapchange", { map_type: e.map_type, previous });
2693
+ }
2694
+
2695
+ _onLayersOverlay(e: { index: number; visible: boolean }) {
2696
+ // setOverlayVisible re-syncs the attribution line; the checkbox
2697
+ // already shows the new state, so no refresh (which would steal
2698
+ // keyboard focus from the row just toggled)
2699
+ this.setOverlayVisible(e.index, e.visible);
2700
+ this.fire("overlaychange", { index: e.index, visible: e.visible });
2701
+ }
2702
+
2703
+ _onAutoplayToggle() {
2704
+ if (this._autoplay_stopped) {
2705
+ this._autoplay_stopped = false;
2706
+ this._scheduleAutoplay();
2707
+ } else {
2708
+ this._autoplay_stopped = true;
2709
+ this._stopAutoplay();
2710
+ }
2711
+ this._menubar.setAutoplayState(this._autoplay_stopped);
2712
+ }
2713
+
2714
+ _onProgressGo(e: { slide: number }) {
2715
+ this.goTo(e.slide);
2716
+ }
2717
+
2718
+ _onTilesAllowed() {
2719
+ this._menubar.refreshLayers();
2720
+ }
2721
+
2722
+ /**
2723
+ * The layer switcher rows: allowed basemaps first, then built overlays.
2724
+ * Empty while there is nothing to switch (mapless story, consent-denied
2725
+ * tiles), in which case the control hides itself.
2726
+ */
2727
+ _layersRows(): LayersRow[] {
2728
+ if (this._map_disabled || !this._map || !this._tilesAllowed()) {
2729
+ return [];
2730
+ }
2731
+ const rows: LayersRow[] = [];
2732
+ for (const basemap of this._allowedBasemaps()) {
2733
+ const label = basemap.label;
2734
+ rows.push({
2735
+ kind: "basemap",
2736
+ map_type: basemap.map_type,
2737
+ label: typeof label === "function" ? label() : (label ?? basemap.map_type),
2738
+ checked: basemap.map_type === this.options.map_type,
2739
+ });
2740
+ }
2741
+ const entries = this._map.getOverlayEntries();
2742
+ const layers = this._map.getOverlayLayers();
2743
+ entries.forEach((entry, index) => {
2744
+ if (entry.control === false) return;
2745
+ const label = entry.label;
2746
+ rows.push({
2747
+ kind: "overlay",
2748
+ index,
2749
+ label: typeof label === "function" ? label() : (label ?? entry.map_type ?? ""),
2750
+ checked: layers[index]?.getVisible() ?? entry.visible ?? true,
2751
+ locked: entry.locked === true,
2752
+ });
2753
+ });
2754
+ return rows;
2755
+ }
2756
+
2757
+ /**
2758
+ * Basemap candidates compatible with the live view. A runtime basemap
2759
+ * swap rebuilds layers but never the view, so an image-space candidate
2760
+ * (`iiif` with `map_as_image`) on a mercator view — or the reverse —
2761
+ * would wrap the wrong projection around the tiles: markers land in the
2762
+ * wrong place and zoom is meaningless. Refused candidates are dropped
2763
+ * with a warning, like the existing `setMapOptions` declines.
2764
+ */
2765
+ _allowedBasemaps(): StorymapBasemap[] {
2766
+ const basemaps = this.options.basemaps;
2767
+ if (!Array.isArray(basemaps)) return [];
2768
+ const live = this.map?.getView()?.getProjection()?.getCode() ?? null;
2769
+ return basemaps.filter((entry) => {
2770
+ const map_type = entry?.map_type;
2771
+ if (typeof map_type !== "string" || map_type === "" || map_type === "none") {
2772
+ console.warn(
2773
+ `StoryMapJS: ignoring basemaps entry without a usable map_type: ${String(map_type)}`,
2774
+ );
2775
+ return false;
2776
+ }
2777
+ // the same rule `_createMap` builds the view by: image-space is
2778
+ // `iiif` with `map_as_image`, everything else is mercator
2779
+ const candidate =
2780
+ map_type === "iiif" && this.options.map_as_image ? "EPSG:4326" : "EPSG:3857";
2781
+ if (live !== null && candidate !== live) {
2782
+ console.warn(
2783
+ `StoryMapJS: ignoring basemap ${map_type}: it needs a ${candidate} view but the live view is ${live}`,
2784
+ );
2785
+ return false;
2786
+ }
2787
+ return true;
2788
+ });
2789
+ }
2790
+
2791
+ /** Whether deferred tile layers may attach (no consent gate, or granted). */
2792
+ _tilesAllowed(): boolean {
2793
+ if (!this.options.consent_required) return true;
2794
+ const manager = consentManagerOf(this.options);
2795
+ return manager ? manager.isGranted(tileService().key) : true;
2796
+ }
2797
+
1988
2798
  _updateProgress() {
1989
- if (this.options.show_progress && this._menubar) {
2799
+ // any enabled style updates (the classic bar via show_progress, the
2800
+ // variants via progressbar); a disabled indicator stays untouched so
2801
+ // its ARIA never goes stale
2802
+ if (this._menubar && this._menubar._progressStyle() !== "off") {
1990
2803
  this._menubar.setProgress(this.current_slide, this.data.slides.length);
1991
2804
  }
1992
2805
  }
@@ -2102,7 +2915,7 @@ class StoryMapBase {
2102
2915
  }
2103
2916
  }
2104
2917
 
2105
- export default class StoryMap extends Evented(StoryMapBase) {
2918
+ export default class StoryMap extends Evented<StoryMapEvents, typeof StoryMapBase>(StoryMapBase) {
2106
2919
  /**
2107
2920
  * The library base path (the directory containing the module).
2108
2921
  * Derived from `import.meta.url`, which works both for the source module