@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,11 +1,13 @@
1
1
  import OlMap from "ol/Map";
2
2
  import View from "ol/View";
3
+ import { unByKey } from "ol/Observable";
3
4
  import type { Control } from "ol/control";
4
5
  import type { Interaction } from "ol/interaction";
5
- import { Tile as TileLayer, Vector as VectorLayer } from "ol/layer";
6
+ import { Image as ImageLayer, Tile as TileLayer, Vector as VectorLayer } from "ol/layer";
6
7
  import Layer from "ol/layer/Layer";
7
8
  import VectorTileLayer from "ol/layer/VectorTile";
8
9
  import { XYZ, OSM, IIIF } from "ol/source";
10
+ import Static from "ol/source/ImageStatic";
9
11
  import type Source from "ol/source/Source";
10
12
  import VectorSource from "ol/source/Vector";
11
13
  import LineString from "ol/geom/LineString";
@@ -27,15 +29,26 @@ import "ol/ol.css";
27
29
  import Map from "../Map";
28
30
  import OpenLayersMapMarker from "./MapMarker.OpenLayers";
29
31
  import { padCroppedZoomifyTile } from "./zoomifyTiles";
32
+ import { prefersReducedMotion } from "../../core/Util";
30
33
  import type { LinePoint, ViewToOptions } from "../types";
31
34
  import type {
32
35
  LatLngLiteral,
36
+ StorymapImageOverlay,
33
37
  StorymapOverlayLayer,
34
38
  StorymapTilejson,
35
39
  StorymapSlide,
36
40
  StorymapSlideLocation,
37
41
  } from "../../types";
38
42
  import { fitGeoreference, resolveInfoJsonUrl } from "../georeference";
43
+ import {
44
+ buildSlideFilter,
45
+ isStaticImageUrl,
46
+ normalizeMask,
47
+ serviceToInfoJson,
48
+ slideBasemapKey,
49
+ slideRotationRad,
50
+ type NormalizedMask,
51
+ } from "../slideView";
39
52
  import {
40
53
  consentManagerOf,
41
54
  tileService,
@@ -50,6 +63,16 @@ import { sanitizeSlideText } from "../../media/EmbedUtil";
50
63
 
51
64
  const MAX_ZOOM = 19;
52
65
 
66
+ /**
67
+ * Natural-size probe timeout for the static fallback: how long the viewer
68
+ * waits for the fallback image's dimensions before giving up and logging
69
+ * the IIIF error. A named constant (rather than an inline literal) so hosts
70
+ * and tests can find the single knob. There is no shared network-timeout
71
+ * option to reuse — `options.duration` is animation timing and the 1200ms
72
+ * media delay is a load deferral, not a watchdog.
73
+ */
74
+ const STATIC_PROBE_TIMEOUT_MS = 15000;
75
+
53
76
  /** What kind of imagery an `imageready` source carries. */
54
77
  export type ImagereadyKind = "iiif" | "zoomify" | "tiles";
55
78
 
@@ -92,12 +115,50 @@ export default class OpenLayers extends Map {
92
115
  declare "_line_animation": number | null;
93
116
  /** Sources `imageready` has already been fired for. */
94
117
  declare "_imageready_fired": WeakSet<object>;
118
+ /** The `loadend` subscription key, stored so dispose() can detach it. */
119
+ declare "_loadend_key": Parameters<typeof unByKey>[0] | null;
120
+ /**
121
+ * Per-slide basemap layers by key (multi-manifest tours): built on first
122
+ * use through the tile layer factory, then reused. Empty until a slide
123
+ * names a `location.basemap`.
124
+ */
125
+ declare "_basemap_cache": globalThis.Map<string, Layer>;
126
+ /**
127
+ * The active per-slide basemap key, or null when the story basemap is
128
+ * showing. Restored to the story basemap on slides without one.
129
+ */
130
+ declare "_slide_basemap": string | null;
131
+ /** Spotlight mask element (slideshow passepartout), or null when off. */
132
+ declare "_mask_el": HTMLDivElement | null;
133
+ /** Per-slide image overlay layer (slideshow imgoverlay), or null. */
134
+ declare "_imgoverlay_layer": Layer | null;
135
+ /** Guards stale static-fallback probes (see `_probeStaticFallback`). */
136
+ declare "_static_probe_token": number;
137
+ /**
138
+ * Set by `dispose()` so in-flight fetches and probes resolve into
139
+ * no-ops instead of touching a torn-down map.
140
+ */
141
+ declare "_disposed": boolean;
142
+ /**
143
+ * Per-URL probe tokens for static slide basemaps: concurrent probes for
144
+ * different slides must not cancel each other (see
145
+ * `_createStaticImageBasemap`).
146
+ */
147
+ declare "_slide_probe_tokens": globalThis.Map<string, number>;
95
148
 
96
149
  /* Create the Map
97
150
  ================================================== */
98
151
  _createMap(): void {
99
152
  const is_image_map = this.isImageSpace();
100
153
  this._imageready_fired = new WeakSet();
154
+ this._disposed = false;
155
+ this._basemap_cache = new globalThis.Map();
156
+ this._slide_basemap = null;
157
+ this._story_basemap_layer = null;
158
+ this._mask_el = null;
159
+ this._imgoverlay_layer = null;
160
+ this._static_probe_token = 0;
161
+ this._slide_probe_tokens = new globalThis.Map();
101
162
 
102
163
  // Caller-supplied OpenLayers options: `controls` replaces the defaults
103
164
  // (the viewer installs none), `interactions` are added to the viewer's
@@ -161,7 +222,7 @@ export default class OpenLayers extends Map {
161
222
  }),
162
223
  });
163
224
 
164
- this._map.on("loadend", () => {
225
+ this._loadend_key = this._map.on("loadend", () => {
165
226
  this._onMapLoaded(undefined);
166
227
  });
167
228
 
@@ -315,6 +376,120 @@ export default class OpenLayers extends Map {
315
376
  }
316
377
  }
317
378
 
379
+ /**
380
+ * Provider credit for a `map_type`, in both renderings: the linked HTML
381
+ * for our own `.vco-map-attribution` line and the plain text for the
382
+ * OpenLayers source `attributions`. One table — the two previous
383
+ * functions repeated the same `osm/stadia/mapbox/esri` switch and could
384
+ * drift apart, leaving a provider credited in one place but not the other
385
+ * (which their terms of service require).
386
+ */
387
+ _providerCredit(map_type: string): { html: string; text: string } {
388
+ // A full XYZ template naming a known provider
389
+ // (https://tile.openstreetmap.org/{z}/{x}/{y}.png) is not the `osm`
390
+ // map type, so match its host as well as the type prefix — otherwise
391
+ // both renderings fall through to the generic "Map data" line.
392
+ const byHost = this._hostCredit(map_type);
393
+ if (byHost) return byHost;
394
+ // Emitting provider credit only for OSM left Stadia, Mapbox and the
395
+ // OL XYZ template providers uncredited, which their terms of service
396
+ // require. The credit is keyed off the resolved map_type, so a custom
397
+ // `{z}` template gets the generic "map data" line and the author can
398
+ // still override it with the `attribution` option.
399
+ const table: { test: (t: string) => boolean; html: string; text: string }[] = [
400
+ {
401
+ test: (t) => t === "" || t.startsWith("osm"),
402
+ html: "© <a target='_blank' href='https://www.openstreetmap.org/copyright'>OpenStreetMap</a> contributors",
403
+ text: "© OpenStreetMap contributors",
404
+ },
405
+ {
406
+ test: (t) => t.startsWith("stadia") || t === "stamen",
407
+ html:
408
+ '© <a target="_blank" rel="noopener noreferrer" href="https://stadiamaps.com/">Stadia Maps</a>, ' +
409
+ '© <a target="_blank" rel="noopener noreferrer" href="https://openmaptiles.org/">OpenMapTiles</a> ' +
410
+ '© <a target="_blank" rel="noopener noreferrer" href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
411
+ text: "© Stadia Maps, © OpenMapTiles © OpenStreetMap contributors",
412
+ },
413
+ {
414
+ test: (t) => t.startsWith("mapbox://"),
415
+ html: '© <a target="_blank" href="https://www.mapbox.com/about/maps/">Mapbox</a>',
416
+ text: "© Mapbox",
417
+ },
418
+ {
419
+ test: (t) => t.startsWith("ch-") || t.startsWith("esri"),
420
+ html: 'Map data © <a target="_blank" href="https://www.esri.com/">Esri</a>',
421
+ text: "Map data © Esri",
422
+ },
423
+ {
424
+ test: () => true,
425
+ html: "Map data",
426
+ text: "Map data",
427
+ },
428
+ ];
429
+ const entry = table.find((row) => row.test(map_type)) ?? table[table.length - 1];
430
+ return { html: entry.html, text: entry.text };
431
+ }
432
+
433
+ /**
434
+ * The table row for an absolute tile template's host, or null when the
435
+ * template is relative (a host serving its own tiles credits itself via
436
+ * `attribution`) or unknown. Subdomains match their parent, so
437
+ * `a.tile.openstreetmap.org` credits OSM.
438
+ */
439
+ _hostCredit(map_type: string): { html: string; text: string } | null {
440
+ let host: string | null = null;
441
+ try {
442
+ host = new URL(map_type).hostname;
443
+ } catch {
444
+ // Not absolute: only a protocol-relative template (`//host/...`)
445
+ // still names a host. A relative template resolves against the
446
+ // dummy base below and is rejected by the host check.
447
+ try {
448
+ const resolved = new URL(map_type, "https://storymap.invalid").hostname;
449
+ host = resolved === "storymap.invalid" ? null : resolved;
450
+ } catch {
451
+ host = null;
452
+ }
453
+ }
454
+ if (!host) return null;
455
+ const rows: { hosts: string[]; html: string; text: string }[] = [
456
+ {
457
+ hosts: ["openstreetmap.org", "osm.org"],
458
+ html: "© <a target='_blank' href='https://www.openstreetmap.org/copyright'>OpenStreetMap</a> contributors",
459
+ text: "© OpenStreetMap contributors",
460
+ },
461
+ {
462
+ hosts: ["stadiamaps.com", "openmaptiles.org"],
463
+ html:
464
+ '© <a target="_blank" rel="noopener noreferrer" href="https://stadiamaps.com/">Stadia Maps</a>, ' +
465
+ '© <a target="_blank" rel="noopener noreferrer" href="https://openmaptiles.org/">OpenMapTiles</a> ' +
466
+ '© <a target="_blank" rel="noopener noreferrer" href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
467
+ text: "© Stadia Maps, © OpenMapTiles © OpenStreetMap contributors",
468
+ },
469
+ {
470
+ hosts: ["mapbox.com"],
471
+ html: '© <a target="_blank" href="https://www.mapbox.com/about/maps/">Mapbox</a>',
472
+ text: "© Mapbox",
473
+ },
474
+ {
475
+ hosts: ["arcgisonline.com", "esri.com"],
476
+ html: 'Map data © <a target="_blank" href="https://www.esri.com/">Esri</a>',
477
+ text: "Map data © Esri",
478
+ },
479
+ {
480
+ hosts: ["carto.com", "basemaps.cartocdn.com"],
481
+ html:
482
+ "© <a target='_blank' href='https://www.openstreetmap.org/copyright'>OpenStreetMap</a> contributors © " +
483
+ '<a target="_blank" href="https://carto.com/">CARTO</a>',
484
+ text: "© OpenStreetMap contributors © CARTO",
485
+ },
486
+ ];
487
+ const row = rows.find(({ hosts }) =>
488
+ hosts.some((h) => host === h || host.endsWith(`.${h}`)),
489
+ );
490
+ return row ? { html: row.html, text: row.text } : null;
491
+ }
492
+
318
493
  _getAttribution(map_type: string): string[] {
319
494
  const parts = [
320
495
  "<a href='https://storymap.knightlab.com/' target='_blank' class='vco-knightlab-brand'><span>&#x25a0;</span> StoryMapJS</a>",
@@ -324,29 +499,7 @@ export default class OpenLayers extends Map {
324
499
  // what the visitor sees. Sources additionally carry the plain-text
325
500
  // credit from `_sourceAttributions`, so `source.getAttributions()`
326
501
  // and a consumer's own Attribution control are not left empty.
327
- // Emitting provider credit
328
- // only for OSM left Stadia, Mapbox and the OL XYZ template providers
329
- // uncredited, which their terms of service require. The credit is
330
- // keyed off the resolved map_type, so a custom `{z}` template gets
331
- // the generic "map data" line and the author can still override it
332
- // with the `attribution` option.
333
- if (map_type === "" || map_type.startsWith("osm")) {
334
- parts.push(
335
- "© <a target='_blank' href='https://www.openstreetmap.org/copyright'>OpenStreetMap</a> contributors",
336
- );
337
- } else if (map_type.startsWith("stadia") || map_type === "stamen") {
338
- parts.push(
339
- '© <a target="_blank" rel="noopener noreferrer" href="https://stadiamaps.com/">Stadia Maps</a>, ' +
340
- '© <a target="_blank" rel="noopener noreferrer" href="https://openmaptiles.org/">OpenMapTiles</a> ' +
341
- '© <a target="_blank" rel="noopener noreferrer" href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
342
- );
343
- } else if (map_type.startsWith("mapbox://")) {
344
- parts.push('© <a target="_blank" href="https://www.mapbox.com/about/maps/">Mapbox</a>');
345
- } else if (map_type.startsWith("ch-") || map_type.startsWith("esri")) {
346
- parts.push('Map data © <a target="_blank" href="https://www.esri.com/">Esri</a>');
347
- } else {
348
- parts.push("Map data");
349
- }
502
+ parts.push(this._providerCredit(map_type).html);
350
503
  if (this.options.attribution) {
351
504
  parts.push(this.options.attribution);
352
505
  }
@@ -361,18 +514,7 @@ export default class OpenLayers extends Map {
361
514
  * `ol/control/Attribution`) work instead of returning nothing.
362
515
  */
363
516
  _sourceAttributions(map_type: string): string[] {
364
- const parts: string[] = [];
365
- if (map_type === "" || map_type.startsWith("osm")) {
366
- parts.push("© OpenStreetMap contributors");
367
- } else if (map_type.startsWith("stadia") || map_type === "stamen") {
368
- parts.push("© Stadia Maps, © OpenMapTiles © OpenStreetMap contributors");
369
- } else if (map_type.startsWith("mapbox://")) {
370
- parts.push("© Mapbox");
371
- } else if (map_type.startsWith("ch-") || map_type.startsWith("esri")) {
372
- parts.push("Map data © Esri");
373
- } else {
374
- parts.push("Map data");
375
- }
517
+ const parts: string[] = [this._providerCredit(map_type).text];
376
518
  if (this.options.attribution) {
377
519
  parts.push(this.options.attribution);
378
520
  }
@@ -392,6 +534,10 @@ export default class OpenLayers extends Map {
392
534
  // to bury the route lines under fresh tiles
393
535
  this._tile_layer.setZIndex(0);
394
536
  this._map.addLayer(this._tile_layer);
537
+ // the story's own base layer: per-slide basemaps hand back to it
538
+ if (!this._story_basemap_layer) {
539
+ this._story_basemap_layer = this._tile_layer;
540
+ }
395
541
  }
396
542
 
397
543
  /**
@@ -601,6 +747,15 @@ export default class OpenLayers extends Map {
601
747
  return this._overlay_layers.length;
602
748
  }
603
749
 
750
+ /**
751
+ * The built overlay entries, parallel to `getOverlayLayers()`: malformed
752
+ * entries are skipped, so this is what a control must iterate to stay on
753
+ * the same index space as `setOverlayVisible()`.
754
+ */
755
+ getOverlayEntries(): StorymapOverlayLayer[] {
756
+ return [...this._overlay_entries];
757
+ }
758
+
604
759
  /**
605
760
  * Show or hide a stacked overlay by index (re-syncs attribution). The
606
761
  * index counts built layers, i.e. it skips malformed entries.
@@ -637,6 +792,7 @@ export default class OpenLayers extends Map {
637
792
  _onTilesAllowed(): void {
638
793
  this._addTileLayer();
639
794
  this._buildOverlays();
795
+ this.fire("tilesallowed");
640
796
  if (!this._tile_layer_mini) {
641
797
  this._tile_layer_mini = this._createTileLayer(this.options.map_type);
642
798
  }
@@ -655,6 +811,9 @@ export default class OpenLayers extends Map {
655
811
  if (latlon) {
656
812
  this._fitView(this._map, [[latlon.lon, latlon.lat]], 0);
657
813
  }
814
+ // consent arrived after markers: the current slide may name
815
+ // a per-slide basemap
816
+ this._switchSlideBasemap(marker.data.location ?? null);
658
817
  }
659
818
  }
660
819
  }
@@ -738,6 +897,154 @@ export default class OpenLayers extends Map {
738
897
  return { zoom, center };
739
898
  }
740
899
 
900
+ /**
901
+ * Paint the story's static image when its IIIF `info.json` cannot be
902
+ * used (unreachable, or missing dimensions): a single-resolution
903
+ * `ImageStatic` layer replaces the empty tile layer, so the tour still
904
+ * shows its picture instead of a blank basemap. Needs
905
+ * `iiif.fallbackUrl` (the full-size image) plus `iiif.width`/`height`
906
+ * (canvas size); without dimensions the natural size is probed, but
907
+ * only for slideshow tours — internal documents keep the existing
908
+ * error path. Returns false when nothing was (or will be) painted.
909
+ */
910
+ _useStaticFallback(empty_layer: TileLayer): boolean {
911
+ const width = this.options.iiif.width;
912
+ const height = this.options.iiif.height;
913
+ const url = this.options.iiif.fallbackUrl;
914
+ if (typeof url !== "string" || url === "") {
915
+ return false;
916
+ }
917
+ if (typeof width === "number" && width > 0 && typeof height === "number" && height > 0) {
918
+ this._buildStaticLayer(empty_layer, url, width, height);
919
+ return true;
920
+ }
921
+ if (this.options.slideshow_source !== true) {
922
+ return false;
923
+ }
924
+ void this._probeStaticFallback(empty_layer, url);
925
+ return true;
926
+ }
927
+
928
+ /**
929
+ * Resolve a fallback image's natural size, then paint it. Stale probes
930
+ * (a newer probe started, e.g. after a basemap switch) and unusable
931
+ * dimensions fall back to the logged IIIF error.
932
+ */
933
+ async _probeStaticFallback(empty_layer: TileLayer, url: string): Promise<void> {
934
+ const token = ++this._static_probe_token;
935
+ try {
936
+ const dims = await this._probeStaticImage(url);
937
+ if (this._disposed || token !== this._static_probe_token) return;
938
+ if (!dims || !(dims.width > 0) || !(dims.height > 0)) {
939
+ console.error("Static fallback image has no usable dimensions:", url);
940
+ return;
941
+ }
942
+ this._buildStaticLayer(empty_layer, url, dims.width, dims.height);
943
+ } catch (err) {
944
+ if (this._disposed || token !== this._static_probe_token) return;
945
+ console.error(
946
+ "Static fallback image could not be probed:",
947
+ url,
948
+ (err as Error)?.stack || err,
949
+ );
950
+ }
951
+ }
952
+
953
+ /**
954
+ * The natural size of an image URL. Separated for testability: unit
955
+ * tests stub the `Image` constructor rather than the network.
956
+ */
957
+ _probeStaticImage(url: string): Promise<{ width: number; height: number }> {
958
+ return new Promise((resolve, reject) => {
959
+ const img = new Image();
960
+ const timer = setTimeout(() => {
961
+ reject(new Error(`timed out after ${STATIC_PROBE_TIMEOUT_MS}ms`));
962
+ }, STATIC_PROBE_TIMEOUT_MS);
963
+ img.onload = () => {
964
+ clearTimeout(timer);
965
+ resolve({ width: img.naturalWidth, height: img.naturalHeight });
966
+ };
967
+ img.onerror = () => {
968
+ clearTimeout(timer);
969
+ reject(new Error("load error"));
970
+ };
971
+ img.crossOrigin = "anonymous";
972
+ img.src = url;
973
+ });
974
+ }
975
+
976
+ /**
977
+ * One static image source: shared by the story fallback (known extent),
978
+ * per-slide static basemaps (probed extent) and per-slide overlays
979
+ * (view extent). Layer assembly stays per call site — base (z 0),
980
+ * pending-fill and overlay (z 5, opacity) have different lifecycles.
981
+ */
982
+ _newStaticSource(
983
+ url: string,
984
+ extent: Extent,
985
+ projection: string | Projection,
986
+ attributions?: string | string[],
987
+ ): Static {
988
+ return new Static({
989
+ url,
990
+ imageExtent: extent,
991
+ projection,
992
+ crossOrigin: "anonymous",
993
+ ...(attributions !== undefined ? { attributions } : {}),
994
+ });
995
+ }
996
+
997
+ /**
998
+ * One static image layer: the constructor shared by the story fallback,
999
+ * per-slide static basemaps and per-slide overlays. Extent and
1000
+ * projection are the caller's policy (canvas size, probed size, or view
1001
+ * extent); presentation rides the options.
1002
+ */
1003
+ _newStaticLayer(
1004
+ url: string,
1005
+ extent: Extent,
1006
+ projection: string | Projection,
1007
+ presentation: { opacity?: number; attributions?: string | string[]; zIndex?: number } = {},
1008
+ ): ImageLayer<Static> {
1009
+ const layer = new ImageLayer({
1010
+ source: this._newStaticSource(url, extent, projection, presentation.attributions),
1011
+ ...(presentation.opacity !== undefined ? { opacity: presentation.opacity } : {}),
1012
+ });
1013
+ layer.setZIndex(presentation.zIndex ?? 0);
1014
+ return layer;
1015
+ }
1016
+
1017
+ /** Swap the empty tile layer for a static image of the given extent. */
1018
+ _buildStaticLayer(empty_layer: TileLayer, url: string, width: number, height: number): void {
1019
+ if (this._disposed) return;
1020
+ const layer = this._newStaticLayer(url, [0, 0, width, height], "EPSG:4326", {
1021
+ attributions: this.options.iiif.attribution || [],
1022
+ });
1023
+ if (this._tile_layer === (empty_layer as unknown as Layer)) {
1024
+ this._map.removeLayer(empty_layer);
1025
+ this._tile_layer = layer;
1026
+ this._map.addLayer(layer);
1027
+ } else {
1028
+ // superseded while resolving (basemap switch, dispose): announce
1029
+ // nothing and fit nothing for a layer that never shows
1030
+ return;
1031
+ }
1032
+ const source = this._sourceOf(layer);
1033
+ if (!source) return;
1034
+ this._fireImageready(source, "iiif", layer);
1035
+ if (typeof source.getState === "function" && source.getState() === "ready") {
1036
+ this._markerOverview();
1037
+ } else if (typeof (source as { once?: unknown }).once === "function") {
1038
+ (source as unknown as { once(e: string, fn: () => void): void }).once("change", () => {
1039
+ if (source.getState() === "ready") {
1040
+ this._markerOverview();
1041
+ }
1042
+ });
1043
+ } else {
1044
+ this._markerOverview();
1045
+ }
1046
+ }
1047
+
741
1048
  _createTileLayer(map_type: string): Layer {
742
1049
  // issue #473: custom OpenLayers tile layer/source factory first —
743
1050
  // the base layer, overlays, minimap and runtime map_type switches
@@ -828,16 +1135,35 @@ export default class OpenLayers extends Map {
828
1135
  * used afterwards.
829
1136
  */
830
1137
  dispose(): void {
1138
+ this._disposed = true;
1139
+ // invalidate in-flight static probes and info.json fetches: their
1140
+ // continuations check the flag or their token and resolve quietly
1141
+ this._static_probe_token++;
831
1142
  if (this._line_animation !== null) {
832
1143
  cancelAnimationFrame(this._line_animation);
833
1144
  this._line_animation = null;
834
1145
  }
1146
+ if (this._loadend_key) {
1147
+ unByKey(this._loadend_key);
1148
+ this._loadend_key = null;
1149
+ }
1150
+ if (this._onWheelBound) {
1151
+ this._el.map?.removeEventListener("wheel", this._onWheelBound);
1152
+ this._onWheelBound = null;
1153
+ }
835
1154
  // markers own their click listener and an overlay; disposing the
836
1155
  // collection releases both
837
1156
  for (const marker of this._markers) {
838
1157
  marker.dispose?.();
839
1158
  }
840
1159
  this._markers = [];
1160
+ this._mask_el?.remove();
1161
+ this._mask_el = null;
1162
+ this._basemap_cache.clear();
1163
+ this._slide_probe_tokens.clear();
1164
+ this._slide_basemap = null;
1165
+ this._story_basemap_layer = null;
1166
+ this._imgoverlay_layer = null;
841
1167
  // detach the viewport first: dispose() alone leaves the canvas in
842
1168
  // the caller's DOM
843
1169
  this._map.setTarget(undefined);
@@ -999,6 +1325,17 @@ export default class OpenLayers extends Map {
999
1325
 
1000
1326
  case "iiif": {
1001
1327
  const iiif_layer: TileLayer = new TileLayer();
1328
+ // a static-image tour states no info.json at all: skip the
1329
+ // fetch and go straight to the fallback (which probes when
1330
+ // no dimensions are stated either)
1331
+ if (typeof this.options.iiif.url !== "string" || this.options.iiif.url === "") {
1332
+ if (!this._useStaticFallback(iiif_layer)) {
1333
+ console.error(
1334
+ "IIIF info.json URL is missing and no static fallback is stated.",
1335
+ );
1336
+ }
1337
+ return iiif_layer;
1338
+ }
1002
1339
  fetch(this.options.iiif.url)
1003
1340
  .then((r) => r.json())
1004
1341
  .then((info: unknown) => {
@@ -1013,10 +1350,15 @@ export default class OpenLayers extends Map {
1013
1350
  typeof fallback.width !== "number" ||
1014
1351
  typeof fallback.height !== "number"
1015
1352
  ) {
1016
- console.error(
1017
- "IIIF info.json is missing width/height:",
1018
- this.options.iiif.url,
1019
- );
1353
+ // without dimensions there is no grid to build;
1354
+ // fall back to the static image when the story
1355
+ // states dimensions of its own
1356
+ if (!this._useStaticFallback(iiif_layer)) {
1357
+ console.error(
1358
+ "IIIF info.json is missing width/height:",
1359
+ this.options.iiif.url,
1360
+ );
1361
+ }
1020
1362
  return;
1021
1363
  }
1022
1364
  const source = new IIIF({
@@ -1039,11 +1381,18 @@ export default class OpenLayers extends Map {
1039
1381
  }
1040
1382
  })
1041
1383
  .catch((err) =>
1042
- console.error(
1043
- "IIIF info.json could not be loaded:",
1044
- this.options.iiif.url,
1045
- err?.stack || err,
1046
- ),
1384
+ // the tile pyramid is unreachable: paint the static
1385
+ // image instead when the story states one, so the
1386
+ // tour still shows its picture
1387
+ {
1388
+ if (!this._useStaticFallback(iiif_layer)) {
1389
+ console.error(
1390
+ "IIIF info.json could not be loaded:",
1391
+ this.options.iiif.url,
1392
+ err?.stack || err,
1393
+ );
1394
+ }
1395
+ },
1047
1396
  );
1048
1397
  return iiif_layer;
1049
1398
  }
@@ -1381,6 +1730,10 @@ export default class OpenLayers extends Map {
1381
1730
  _createMarker(d: StorymapSlide): void {
1382
1731
  const marker = new OpenLayersMapMarker(d, this.options);
1383
1732
  marker.on("markerclick", this._onMarkerClick, this);
1733
+ // Forward popup cards to the map surface (fresh payloads, so the
1734
+ // marker stays the event target of the original, not the re-fire).
1735
+ marker.on("popupopen", this._onMarkerPopupOpen, this);
1736
+ marker.on("popupclose", this._onMarkerPopupClose, this);
1384
1737
  this._addMarker(marker);
1385
1738
  this._markers.push(marker);
1386
1739
  marker.marker_number = this._markers.length - 1;
@@ -1515,7 +1868,7 @@ export default class OpenLayers extends Map {
1515
1868
  panel_rect.bottom > map_rect.top;
1516
1869
  if (!overlaps) return padding;
1517
1870
  const layout = this.options.layout;
1518
- if (layout === "portrait") {
1871
+ if (layout === "portrait" || this.options.textmode === "bottom") {
1519
1872
  padding[2] += Math.max(0, map_rect.bottom - panel_rect.top);
1520
1873
  } else {
1521
1874
  padding[1] += Math.max(0, panel_rect.right - map_rect.left);
@@ -1549,7 +1902,7 @@ export default class OpenLayers extends Map {
1549
1902
  size: ol_map.getSize(),
1550
1903
  padding: this._opaquePanelPadding(),
1551
1904
  maxZoom: 12,
1552
- duration: duration,
1905
+ duration: prefersReducedMotion() ? 0 : duration,
1553
1906
  easing: this._easing,
1554
1907
  });
1555
1908
  }
@@ -1712,7 +2065,8 @@ export default class OpenLayers extends Map {
1712
2065
 
1713
2066
  this._cancelLineAnimation();
1714
2067
 
1715
- const duration = animate?.duration ?? 0;
2068
+ // Reduced motion draws the full path at once instead of tracing it.
2069
+ const duration = prefersReducedMotion() ? 0 : (animate?.duration ?? 0);
1716
2070
  if (duration <= 0 || view_coords.length < 2) {
1717
2071
  setGeometry(view_coords);
1718
2072
  return;
@@ -1858,7 +2212,7 @@ export default class OpenLayers extends Map {
1858
2212
  _panTo(loc: LatLngLiteral, animate?: boolean): void {
1859
2213
  this._map.getView().animate({
1860
2214
  center: this._toViewCoords(loc),
1861
- duration: this.options.duration,
2215
+ duration: prefersReducedMotion() ? 0 : this.options.duration,
1862
2216
  easing: this._easing,
1863
2217
  });
1864
2218
  }
@@ -1866,13 +2220,20 @@ export default class OpenLayers extends Map {
1866
2220
  _zoomTo(z: number, animate?: boolean): void {
1867
2221
  this._map.getView().animate({
1868
2222
  zoom: z,
1869
- duration: this.options.duration,
2223
+ duration: prefersReducedMotion() ? 0 : this.options.duration,
1870
2224
  easing: this._easing,
1871
2225
  });
1872
2226
  }
1873
2227
 
1874
2228
  _viewTo(loc: StorymapSlideLocation, opts?: ViewToOptions): void {
1875
- // Image region stops (StrollView-style): in image mode the view is
2229
+ // Per-slide presentation first: basemap swap, filter grading and
2230
+ // spotlight mask are synchronous state, independent of movement.
2231
+ // Rotation joins the movement animation below (or _fitRegion's).
2232
+ // Slideshow tours only: internal documents move exactly as before.
2233
+ const feat = this.options.slideshow_source === true ? loc : undefined;
2234
+ this._switchSlideBasemap(feat);
2235
+ this._applySlideFilterAndMask(feat);
2236
+ // Image region stops (slideshow-style): in image mode the view is
1876
2237
  // EPSG:4326 where coordinates are raw image pixels — fit the xywh
1877
2238
  // region instead of flying to a point. Everything else (geo maps,
1878
2239
  // absent/invalid regions) keeps the point behavior unchanged.
@@ -1882,12 +2243,15 @@ export default class OpenLayers extends Map {
1882
2243
  ? loc.region
1883
2244
  : null;
1884
2245
  if (region) {
1885
- this._fitRegion(region, opts);
2246
+ this._fitRegion(region, opts, feat);
1886
2247
  return;
1887
2248
  }
1888
2249
 
1889
2250
  const start = this._latLngOf(loc);
1890
2251
  if (!start) {
2252
+ // location-less slide (region-only on a geo map): nothing to
2253
+ // fly to, but an explicit rotation still applies
2254
+ this._applySlideRotation(feat, opts?.duration, opts?.duration === 0);
1891
2255
  return;
1892
2256
  }
1893
2257
  let _animate = true,
@@ -1924,10 +2288,16 @@ export default class OpenLayers extends Map {
1924
2288
  _location = this._getMapCenterOffset(_location, _zoom);
1925
2289
  }
1926
2290
 
2291
+ // A slide rotation joins the glide; the animation arg is only added
2292
+ // when the slide or the view is rotated, so unrotated stories
2293
+ // animate exactly as before.
2294
+ const _rotation = slideRotationRad(feat);
2295
+ const _current_rotation = this._map.getView().getRotation();
1927
2296
  this._map.getView().animate({
1928
2297
  center: this._toViewCoords(_location),
1929
2298
  zoom: _zoom,
1930
- duration: _animate ? _duration : 0,
2299
+ ...(_rotation !== null || _current_rotation !== 0 ? { rotation: _rotation ?? 0 } : {}),
2300
+ duration: _animate && !prefersReducedMotion() ? _duration : 0,
1931
2301
  easing: this._easing,
1932
2302
  });
1933
2303
 
@@ -1947,9 +2317,15 @@ export default class OpenLayers extends Map {
1947
2317
  /**
1948
2318
  * Image region stop: fit the xywh bbox ([x, y, w, h] image pixels —
1949
2319
  * the EPSG:4326 image space) with the shared animation options, then
1950
- * keep the minimap collapse state in sync like `_viewTo`.
2320
+ * keep the minimap collapse state in sync like `_viewTo`. A slide
2321
+ * rotation follows the fit (fit() takes no rotation), plus the
2322
+ * filter/mask tail when the location is passed.
1951
2323
  */
1952
- _fitRegion(region: [number, number, number, number], opts?: ViewToOptions): void {
2324
+ _fitRegion(
2325
+ region: [number, number, number, number],
2326
+ opts?: ViewToOptions,
2327
+ loc?: StorymapSlideLocation,
2328
+ ): void {
1953
2329
  const [x, y, w, h] = region;
1954
2330
  if (!(w > 0) || !(h > 0)) {
1955
2331
  return;
@@ -1966,9 +2342,13 @@ export default class OpenLayers extends Map {
1966
2342
  this._map.getView().fit([x, y, x + w, y + h], {
1967
2343
  size: this._map.getSize(),
1968
2344
  padding: this._opaquePanelPadding(),
1969
- duration: _animate ? _duration : 0,
2345
+ duration: _animate && !prefersReducedMotion() ? _duration : 0,
1970
2346
  easing: this._easing,
1971
2347
  });
2348
+ if (loc) {
2349
+ this._applySlideRotation(loc, _duration, !_animate);
2350
+ this._applySlideFilterAndMask(loc);
2351
+ }
1972
2352
  if (this._mini_map && this.options.width > this.options.skinny_size) {
1973
2353
  this._mini_map.setCollapsed(true);
1974
2354
  }
@@ -1980,6 +2360,295 @@ export default class OpenLayers extends Map {
1980
2360
  return fromLonLat([loc.lon, loc.lat]);
1981
2361
  }
1982
2362
 
2363
+ /* Per-slide view presentation (slideshow rotation/filter/mask/basemap)
2364
+ ================================================== */
2365
+ /**
2366
+ * Swap the base layer for a slide `location.basemap` (multi-manifest
2367
+ * tours): built on first use through the tile layer factory (custom
2368
+ * factories apply), then cached. A slide without one restores the story
2369
+ * basemap. No-op when the requested key is already showing, so resize
2370
+ * and re-fit paths pass through untouched. Consent-denied tiles skip
2371
+ * the switch entirely.
2372
+ */
2373
+ _switchSlideBasemap(loc: StorymapSlideLocation | null | undefined): void {
2374
+ // slideshow tours only: internal documents keep the story basemap
2375
+ if (this.options.slideshow_source !== true) return;
2376
+ if (!this._tilesAllowed()) return;
2377
+ const key = slideBasemapKey(loc);
2378
+ if (key === this._slide_basemap) return;
2379
+ if (this._tile_layer) {
2380
+ this._map.removeLayer(this._tile_layer);
2381
+ }
2382
+ if (key === null) {
2383
+ this._tile_layer =
2384
+ this._story_basemap_layer ?? this._createTileLayer(this.options.map_type);
2385
+ this._story_basemap_layer = this._tile_layer;
2386
+ } else {
2387
+ let layer = this._basemap_cache.get(key);
2388
+ if (!layer) {
2389
+ layer = this._createSlideBasemap(key);
2390
+ layer.setZIndex(0);
2391
+ this._basemap_cache.set(key, layer);
2392
+ }
2393
+ this._tile_layer = layer;
2394
+ }
2395
+ this._tile_layer.setZIndex(0);
2396
+ this._map.addLayer(this._tile_layer);
2397
+ this._slide_basemap = key;
2398
+ this._updateAttribution();
2399
+ }
2400
+
2401
+ /**
2402
+ * Build a per-slide basemap layer: IIIF `info.json` URLs (or bare
2403
+ * service bases) become tile layers; plain image URLs become static
2404
+ * image layers (dimensions probed); anything else goes through the
2405
+ * regular tile layer factory, so keywords and templates keep working.
2406
+ */
2407
+ _createSlideBasemap(key: string): Layer {
2408
+ // plain image files (absolute or same-origin relative) skip the
2409
+ // doomed info.json fetch; services and keywords flow on
2410
+ if (isStaticImageUrl(key)) {
2411
+ return this._createStaticImageBasemap(key);
2412
+ }
2413
+ if (/^https?:\/\//i.test(key) && !key.includes("{z}")) {
2414
+ const info_url = serviceToInfoJson(key);
2415
+ const pending: TileLayer = new TileLayer();
2416
+ fetch(info_url)
2417
+ .then((r) => r.json())
2418
+ .then((info: unknown) => {
2419
+ if (this._disposed) return;
2420
+ const parsed = new IIIFInfo(
2421
+ info as ImageInformationResponse,
2422
+ ).getTileSourceOptions();
2423
+ const dims = info as { width?: number; height?: number };
2424
+ if (typeof dims.width !== "number" || typeof dims.height !== "number") {
2425
+ this._replaceWithStaticBasemap(key, pending);
2426
+ return;
2427
+ }
2428
+ const source = new IIIF({
2429
+ ...(parsed ?? {}),
2430
+ projection: "EPSG:4326",
2431
+ size: [dims.width, dims.height],
2432
+ crossOrigin: "anonymous",
2433
+ });
2434
+ pending.setSource(source);
2435
+ this._fireImageready(source, "iiif", pending);
2436
+ })
2437
+ .catch(() => {
2438
+ // not an image service after all (a plain file, e.g. a
2439
+ // `.jpx` that serves no info.json): probe it as one
2440
+ this._replaceWithStaticBasemap(key, pending);
2441
+ });
2442
+ return pending;
2443
+ }
2444
+ return this._createTileLayer(key);
2445
+ }
2446
+
2447
+ /**
2448
+ * A basemap from a plain image URL (no IIIF service): an `ImageLayer`
2449
+ * filled in once the natural size probes. Used for per-slide basemaps
2450
+ * pointing at static files, and as the fallback when an `info.json`
2451
+ * turns out unreachable (some `.jpx`/`.jp2` bases serve one, some are
2452
+ * plain files — only the fetch tells). Unusable dimensions keep the
2453
+ * error logged and the layer empty, like the failed-service path.
2454
+ */
2455
+ _createStaticImageBasemap(url: string): Layer {
2456
+ const pending = new ImageLayer();
2457
+ pending.setZIndex(0);
2458
+ const token = (this._slide_probe_tokens.get(url) ?? 0) + 1;
2459
+ this._slide_probe_tokens.set(url, token);
2460
+ void this._probeStaticImage(url).then(
2461
+ (dims) => {
2462
+ if (this._disposed || this._slide_probe_tokens.get(url) !== token) return;
2463
+ if (!dims || !(dims.width > 0) || !(dims.height > 0)) {
2464
+ console.error("Slide basemap image has no usable dimensions:", url);
2465
+ return;
2466
+ }
2467
+ pending.setSource(
2468
+ this._newStaticSource(url, [0, 0, dims.width, dims.height], "EPSG:4326"),
2469
+ );
2470
+ this._fireImageready(
2471
+ pending.getSource() as { getState?(): string },
2472
+ "iiif",
2473
+ pending,
2474
+ );
2475
+ },
2476
+ (err) =>
2477
+ console.error(
2478
+ "Slide basemap image could not be probed:",
2479
+ url,
2480
+ (err as Error)?.stack || err,
2481
+ ),
2482
+ );
2483
+ return pending;
2484
+ }
2485
+
2486
+ /**
2487
+ * Replace a failed IIIF slide basemap with a probed static image under
2488
+ * the same cache key, swapping it into the map when still current (the
2489
+ * visitor may have moved on while the fetch was in flight, in which
2490
+ * case the cache holds it for a return visit).
2491
+ */
2492
+ _replaceWithStaticBasemap(key: string, pending: TileLayer): void {
2493
+ if (this._disposed) return;
2494
+ const staticLayer = this._createStaticImageBasemap(key);
2495
+ this._basemap_cache.set(key, staticLayer);
2496
+ if (this._tile_layer === (pending as unknown as Layer)) {
2497
+ this._map.removeLayer(pending);
2498
+ this._tile_layer = staticLayer;
2499
+ this._map.addLayer(staticLayer);
2500
+ }
2501
+ }
2502
+
2503
+ /**
2504
+ * The story's own base layer (as opposed to a per-slide one): stashed
2505
+ * on creation so a slide basemap can hand back to it without refetching.
2506
+ */
2507
+ declare "_story_basemap_layer": Layer | null;
2508
+
2509
+ /**
2510
+ * Apply a slide's CSS filter grading to the map viewport
2511
+ * (slideshow `filters`). Always assigned — "" clears — so grading never
2512
+ * leaks onto the next slide.
2513
+ */
2514
+ _applySlideFilter(loc: StorymapSlideLocation | null | undefined): void {
2515
+ this._map.getViewport().style.filter = buildSlideFilter(loc?.filter);
2516
+ }
2517
+
2518
+ /**
2519
+ * Apply a slide's spotlight mask (slideshow `passepartout`): normalized
2520
+ * viewport fractions stay visible while the rest is dimmed. Rendered as
2521
+ * up to four percentage-positioned divs (or one for `invert`), so it
2522
+ * needs no updates on pan/zoom/resize. `pointer-events: none` keeps
2523
+ * markers clickable through the shade; the marker overlay container
2524
+ * (z-index 1) paints above it. Absent/invalid clears the mask.
2525
+ */
2526
+ _applySlideMask(loc: StorymapSlideLocation | null | undefined): void {
2527
+ const mask: NormalizedMask | null = normalizeMask(loc?.mask);
2528
+ if (!mask) {
2529
+ this._mask_el?.remove();
2530
+ this._mask_el = null;
2531
+ return;
2532
+ }
2533
+ const viewport = this._map.getViewport();
2534
+ if (!this._mask_el) {
2535
+ const el = document.createElement("div");
2536
+ el.className = "vco-map-mask";
2537
+ el.setAttribute("aria-hidden", "true");
2538
+ el.style.position = "absolute";
2539
+ el.style.inset = "0";
2540
+ el.style.zIndex = "0";
2541
+ el.style.pointerEvents = "none";
2542
+ el.style.overflow = "hidden";
2543
+ viewport.appendChild(el);
2544
+ this._mask_el = el;
2545
+ }
2546
+ const pct = (n: number) => `${n * 100}%`;
2547
+ const shade = (left: string, top: string, width: string, height: string) =>
2548
+ `<div style="position:absolute;left:${left};top:${top};width:${width};height:${height};background:${mask.color};opacity:${mask.opacity}"></div>`;
2549
+ this._mask_el.innerHTML = mask.invert
2550
+ ? shade(pct(mask.x), pct(mask.y), pct(mask.w), pct(mask.h))
2551
+ : shade("0", "0", "100%", pct(mask.y)) +
2552
+ shade("0", pct(mask.y + mask.h), "100%", pct(1 - mask.y - mask.h)) +
2553
+ shade("0", pct(mask.y), pct(mask.x), pct(mask.h)) +
2554
+ shade(pct(mask.x + mask.w), pct(mask.y), pct(1 - mask.x - mask.w), pct(mask.h));
2555
+ }
2556
+
2557
+ /** Filter + mask together: the synchronous tail of every navigation. */
2558
+ _applySlideFilterAndMask(loc: StorymapSlideLocation | null | undefined): void {
2559
+ this._applySlideFilter(loc);
2560
+ this._applySlideMask(loc);
2561
+ }
2562
+
2563
+ /**
2564
+ * Rotate the view to a slide `rotation` (degrees clockwise). Merges into
2565
+ * the caller's animation when one is running; on its own (region fits,
2566
+ * location-less slides, overview reset) it animates alone, or sets
2567
+ * synchronously when the navigation is instant. A no-op when neither
2568
+ * the slide nor the view is rotated, so unrotated stories animate
2569
+ * exactly as before.
2570
+ */
2571
+ _applySlideRotation(
2572
+ loc: StorymapSlideLocation | null | undefined,
2573
+ duration?: number,
2574
+ instant = false,
2575
+ ): void {
2576
+ const rotation = slideRotationRad(loc);
2577
+ const view = this._map.getView();
2578
+ if (rotation === null && view.getRotation() === 0) return;
2579
+ const target = rotation ?? 0;
2580
+ if (instant || prefersReducedMotion()) {
2581
+ view.setRotation(target);
2582
+ return;
2583
+ }
2584
+ view.animate({
2585
+ rotation: target,
2586
+ duration: duration ?? this.options.duration,
2587
+ easing: this._easing,
2588
+ });
2589
+ }
2590
+
2591
+ /**
2592
+ * Show a per-slide image overlay (slideshow `imgoverlay`) while its
2593
+ * slide is active. `extent` pins it (`[west, south, east, north]` lon/lat,
2594
+ * raw image pixels on image-space maps); otherwise it covers the current
2595
+ * view, optionally scaled to `size` (fraction of the viewport). Painted
2596
+ * above the base (z 5) below the route lines. Null/empty clears.
2597
+ * Consent-denied tiles skip it like every other layer.
2598
+ */
2599
+ setSlideImageOverlay(overlay: StorymapImageOverlay | null | undefined): void {
2600
+ if (this._imgoverlay_layer) {
2601
+ this._map.removeLayer(this._imgoverlay_layer);
2602
+ this._imgoverlay_layer = null;
2603
+ }
2604
+ if (!overlay || typeof overlay.url !== "string" || overlay.url === "") return;
2605
+ if (!this._tilesAllowed()) return;
2606
+ // slideshow tours only: internal documents never paint overlays
2607
+ if (this.options.slideshow_source !== true) return;
2608
+ const given = overlay.extent;
2609
+ let extent: Extent;
2610
+ if (
2611
+ Array.isArray(given) &&
2612
+ given.length === 4 &&
2613
+ given.every((n) => typeof n === "number" && Number.isFinite(n))
2614
+ ) {
2615
+ const raw = (
2616
+ this.isImageSpace()
2617
+ ? [given[0], given[1], given[2], given[3]]
2618
+ : [...fromLonLat([given[0], given[1]]), ...fromLonLat([given[2], given[3]])]
2619
+ ) as Extent;
2620
+ // lon/lat corners can arrive swapped; the view fit needs min/max
2621
+ extent = [
2622
+ Math.min(raw[0], raw[2]),
2623
+ Math.min(raw[1], raw[3]),
2624
+ Math.max(raw[0], raw[2]),
2625
+ Math.max(raw[1], raw[3]),
2626
+ ];
2627
+ } else {
2628
+ extent = this._map.getView().calculateExtent(this._viewportSize());
2629
+ if (typeof overlay.size === "number" && Number.isFinite(overlay.size)) {
2630
+ const scale = Math.min(1, Math.max(0.05, overlay.size));
2631
+ const cx = (extent[0] + extent[2]) / 2;
2632
+ const cy = (extent[1] + extent[3]) / 2;
2633
+ const hw = ((extent[2] - extent[0]) * scale) / 2;
2634
+ const hh = ((extent[3] - extent[1]) * scale) / 2;
2635
+ extent = [cx - hw, cy - hh, cx + hw, cy + hh];
2636
+ }
2637
+ }
2638
+ const opacity =
2639
+ typeof overlay.opacity === "number" && Number.isFinite(overlay.opacity)
2640
+ ? Math.min(1, Math.max(0, overlay.opacity))
2641
+ : 1;
2642
+ const layer = this._newStaticLayer(
2643
+ overlay.url,
2644
+ extent,
2645
+ this._map.getView().getProjection(),
2646
+ { opacity, zIndex: 5 },
2647
+ );
2648
+ this._map.addLayer(layer);
2649
+ this._imgoverlay_layer = layer;
2650
+ }
2651
+
1983
2652
  _getMapZoom(): number {
1984
2653
  // fractional zoom on purpose: overview fits produce non-integer zooms and
1985
2654
  // rounding here would snap the view on the next navigation
@@ -2115,6 +2784,25 @@ export default class OpenLayers extends Map {
2115
2784
  this._cancelLineAnimation();
2116
2785
  this._line_active.setVisible(false);
2117
2786
 
2787
+ // The overview shows the whole story: per-slide grading, mask and
2788
+ // rotation do not apply. Each reset is guarded so the common path
2789
+ // (nothing set) touches nothing.
2790
+ if (this._map.getView().getRotation() !== 0) {
2791
+ this._map.getView().animate({
2792
+ rotation: 0,
2793
+ duration: prefersReducedMotion() ? 0 : (duration ?? this._transition_duration),
2794
+ easing: this._easing,
2795
+ });
2796
+ }
2797
+ if (this._map.getViewport().style.filter !== "") {
2798
+ this._map.getViewport().style.filter = "";
2799
+ }
2800
+ if (this._mask_el) {
2801
+ this._mask_el.remove();
2802
+ this._mask_el = null;
2803
+ }
2804
+ this._switchSlideBasemap(null);
2805
+
2118
2806
  // repeated presses while an overview animation is running snap
2119
2807
  // instantly instead of restarting the ~1s animation
2120
2808
  if (duration && this._map.getView().getAnimating()) {
@@ -2145,7 +2833,7 @@ export default class OpenLayers extends Map {
2145
2833
  this._map.getView().animate({
2146
2834
  center: center_view,
2147
2835
  zoom: view_zoom,
2148
- duration: duration ?? this._transition_duration,
2836
+ duration: prefersReducedMotion() ? 0 : (duration ?? this._transition_duration),
2149
2837
  easing: this._easing,
2150
2838
  });
2151
2839
  }
@@ -2156,6 +2844,22 @@ export default class OpenLayers extends Map {
2156
2844
  }
2157
2845
  const fit = () => {
2158
2846
  try {
2847
+ // static fallback (no tile grid): fit the single image
2848
+ // extent directly so the overview still frames the picture
2849
+ const static_extent = (
2850
+ source as { getImageExtent?: () => Extent | null }
2851
+ ).getImageExtent?.();
2852
+ if (static_extent) {
2853
+ this._map.getView().fit(static_extent, {
2854
+ size: this._viewportSize(),
2855
+ padding: this._opaquePanelPadding(),
2856
+ duration: prefersReducedMotion()
2857
+ ? 0
2858
+ : (duration ?? this._transition_duration),
2859
+ easing: this._easing,
2860
+ });
2861
+ return;
2862
+ }
2159
2863
  // a custom layer with a source is not necessarily a tile
2160
2864
  // source; only tile grids can supply a fit extent
2161
2865
  const grid = (
@@ -2187,7 +2891,9 @@ export default class OpenLayers extends Map {
2187
2891
  view.animate({
2188
2892
  center: this._toViewCoords(offset_location),
2189
2893
  zoom: zoom,
2190
- duration: duration ?? this._transition_duration,
2894
+ duration: prefersReducedMotion()
2895
+ ? 0
2896
+ : (duration ?? this._transition_duration),
2191
2897
  easing: this._easing,
2192
2898
  });
2193
2899
  }
@@ -2228,7 +2934,7 @@ export default class OpenLayers extends Map {
2228
2934
  this._map.getView().animate({
2229
2935
  center: this._toViewCoords(offset_location),
2230
2936
  zoom: zoom,
2231
- duration: duration ?? this._transition_duration,
2937
+ duration: prefersReducedMotion() ? 0 : (duration ?? this._transition_duration),
2232
2938
  easing: this._easing,
2233
2939
  });
2234
2940
  } else if (
@@ -2254,7 +2960,9 @@ export default class OpenLayers extends Map {
2254
2960
  this._map.getView().animate({
2255
2961
  center: this._toViewCoords(offset_location),
2256
2962
  zoom: zoom,
2257
- duration: duration ?? this._transition_duration,
2963
+ duration: prefersReducedMotion()
2964
+ ? 0
2965
+ : (duration ?? this._transition_duration),
2258
2966
  easing: this._easing,
2259
2967
  });
2260
2968
  }
@@ -2275,7 +2983,9 @@ export default class OpenLayers extends Map {
2275
2983
  view.animate({
2276
2984
  center: this._toViewCoords(offset_location),
2277
2985
  zoom: zoom,
2278
- duration: duration ?? this._transition_duration,
2986
+ duration: prefersReducedMotion()
2987
+ ? 0
2988
+ : (duration ?? this._transition_duration),
2279
2989
  easing: this._easing,
2280
2990
  });
2281
2991
  }
@@ -2336,6 +3046,11 @@ export default class OpenLayers extends Map {
2336
3046
  this._tile_layer.setZIndex(0);
2337
3047
  this._map.addLayer(this._tile_layer);
2338
3048
  }
3049
+ // the story basemap changed: per-slide layers stay cached
3050
+ // (their keys are unchanged) but the active one is rebuilt
3051
+ // by the re-fit below through _switchSlideBasemap
3052
+ this._story_basemap_layer = this._tile_layer ?? null;
3053
+ this._slide_basemap = null;
2339
3054
  this._refreshMiniMapLayer();
2340
3055
  this._updateAttribution();
2341
3056
  this._el.map.style.backgroundColor = this.options.map_background_color;
@@ -2485,6 +3200,16 @@ export default class OpenLayers extends Map {
2485
3200
  const view = this._map.getView();
2486
3201
  view.setZoom(zoom);
2487
3202
  view.setCenter(this._toViewCoords(_location));
3203
+ // instant path (resize): rotation sets synchronously, filter and
3204
+ // mask re-apply idempotently — slideshow tours only, so the resize
3205
+ // path for internal documents touches nothing new
3206
+ if (this.options.slideshow_source === true) {
3207
+ const rotation = slideRotationRad(loc);
3208
+ if (rotation !== null || view.getRotation() !== 0) {
3209
+ view.setRotation(rotation ?? 0);
3210
+ }
3211
+ this._applySlideFilterAndMask(loc);
3212
+ }
2488
3213
  this._map.renderSync();
2489
3214
  }
2490
3215
  }