@projektemacher/storymapjs 0.10.6 → 0.10.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/CHANGELOG +11 -0
  2. package/dist/assets/{demo-CVQ7kFek.js → demo-D6cTmzn6.js} +1 -1
  3. package/dist/assets/{harness-C-VZV-ou.js → harness-BPFlzutp.js} +1 -1
  4. package/dist/assets/main-Y-MYUSJ4.js +17 -0
  5. package/dist/css/fonts/font.oldstandard.css +18 -22
  6. package/dist/demo.html +2 -2
  7. package/dist/docs/migration.html +1 -1
  8. package/dist/embed/index.html +129 -4
  9. package/dist/harness.html +2 -2
  10. package/dist/index.html +17 -0
  11. package/dist/js/storymap.d.ts +126 -7
  12. package/dist/js/storymap.js +4518 -3613
  13. package/dist/js/storymap.js.map +1 -1
  14. package/docs/migration-from-knightlab.md +139 -0
  15. package/docs/storymap-as-iiif-manifest.md +510 -0
  16. package/package.json +6 -3
  17. package/src/animation/easings.ts +18 -0
  18. package/src/animation/morpheus.d.ts +41 -0
  19. package/src/core/Browser.ts +19 -0
  20. package/src/core/Load.ts +180 -0
  21. package/src/core/Util.ts +354 -0
  22. package/src/core/mixins.ts +171 -0
  23. package/src/dom/Dom.ts +31 -0
  24. package/src/dom/DomEvent.ts +61 -0
  25. package/src/globals.d.ts +16 -0
  26. package/src/language/Language.ts +107 -0
  27. package/src/language/locale/be.json +18 -0
  28. package/src/language/locale/bg.json +18 -0
  29. package/src/language/locale/cs.json +18 -0
  30. package/src/language/locale/de.json +20 -0
  31. package/src/language/locale/el.json +18 -0
  32. package/src/language/locale/en.json +28 -0
  33. package/src/language/locale/es.json +18 -0
  34. package/src/language/locale/et.json +18 -0
  35. package/src/language/locale/fr.json +18 -0
  36. package/src/language/locale/he.json +18 -0
  37. package/src/language/locale/hu.json +18 -0
  38. package/src/language/locale/is.json +18 -0
  39. package/src/language/locale/it.json +18 -0
  40. package/src/language/locale/jp.json +18 -0
  41. package/src/language/locale/ko.json +18 -0
  42. package/src/language/locale/nl.json +18 -0
  43. package/src/language/locale/nn.json +18 -0
  44. package/src/language/locale/no.json +18 -0
  45. package/src/language/locale/pl.json +18 -0
  46. package/src/language/locale/pt.json +18 -0
  47. package/src/language/locale/ru.json +18 -0
  48. package/src/language/locale/sk.json +18 -0
  49. package/src/language/locale/sr.json +17 -0
  50. package/src/language/locale/sv.json +18 -0
  51. package/src/language/locale/tr.json +18 -0
  52. package/src/language/locale/uk.json +18 -0
  53. package/src/language/locale/ur.json +18 -0
  54. package/src/language/locale/zh-cn.json +18 -0
  55. package/src/language/locale/zh-tw.json +18 -0
  56. package/src/main.ts +51 -0
  57. package/src/map/Map.ts +717 -0
  58. package/src/map/MapMarker.ts +149 -0
  59. package/src/map/openlayers/Map.OpenLayers.ts +1879 -0
  60. package/src/map/openlayers/MapMarker.OpenLayers.ts +176 -0
  61. package/src/map/openlayers/zoomifyTiles.ts +28 -0
  62. package/src/map/types.ts +17 -0
  63. package/src/media/EmbedUtil.ts +168 -0
  64. package/src/media/Media.ts +409 -0
  65. package/src/media/MediaType.ts +163 -0
  66. package/src/media/types/Audio.ts +87 -0
  67. package/src/media/types/Blockquote.ts +40 -0
  68. package/src/media/types/DailyMotion.ts +58 -0
  69. package/src/media/types/DocumentCloud.ts +41 -0
  70. package/src/media/types/Facebook.ts +41 -0
  71. package/src/media/types/Flickr.ts +115 -0
  72. package/src/media/types/GoogleDoc.ts +59 -0
  73. package/src/media/types/IFrame.ts +51 -0
  74. package/src/media/types/Image.ts +49 -0
  75. package/src/media/types/Juxtapose.ts +32 -0
  76. package/src/media/types/SoundCloud.ts +77 -0
  77. package/src/media/types/Text.ts +172 -0
  78. package/src/media/types/Twitter.ts +96 -0
  79. package/src/media/types/Video.ts +84 -0
  80. package/src/media/types/Vimeo.ts +65 -0
  81. package/src/media/types/Website.ts +36 -0
  82. package/src/media/types/Wikipedia.ts +124 -0
  83. package/src/media/types/YouTube.ts +192 -0
  84. package/src/scss/Typography.scss +237 -0
  85. package/src/scss/VCO.StoryMap.Dark.scss +138 -0
  86. package/src/scss/VCO.StoryMap.scss +164 -0
  87. package/src/scss/Variables.Dark.scss +56 -0
  88. package/src/scss/Variables.scss +47 -0
  89. package/src/scss/core/Mixins.scss +112 -0
  90. package/src/scss/core/Reset.scss +206 -0
  91. package/src/scss/fonts/_font.base.scss +113 -0
  92. package/src/scss/fonts/font.abril-droidsans.scss +75 -0
  93. package/src/scss/fonts/font.amatic-andika.scss +73 -0
  94. package/src/scss/fonts/font.bitter-raleway.scss +86 -0
  95. package/src/scss/fonts/font.clicker-garamond.scss +92 -0
  96. package/src/scss/fonts/font.dancing-ledger.scss +74 -0
  97. package/src/scss/fonts/font.default.scss +9 -0
  98. package/src/scss/fonts/font.fjalla-average.scss +80 -0
  99. package/src/scss/fonts/font.georgia-helvetica.scss +58 -0
  100. package/src/scss/fonts/font.knightlab.scss +97 -0
  101. package/src/scss/fonts/font.lustria-lato.scss +80 -0
  102. package/src/scss/fonts/font.medula-lato.scss +73 -0
  103. package/src/scss/fonts/font.oldstandard.scss +88 -0
  104. package/src/scss/fonts/font.opensans-gentiumbook.scss +80 -0
  105. package/src/scss/fonts/font.playfair-faunaone.scss +86 -0
  106. package/src/scss/fonts/font.playfair.scss +85 -0
  107. package/src/scss/fonts/font.pt.scss +90 -0
  108. package/src/scss/fonts/font.roboto-megrim.scss +73 -0
  109. package/src/scss/fonts/font.rufina-sintony.scss +73 -0
  110. package/src/scss/fonts/font.ubuntu.scss +78 -0
  111. package/src/scss/fonts/font.unicaone-vollkorn.scss +85 -0
  112. package/src/scss/icons/Icons.scss +239 -0
  113. package/src/scss/map/VCO.ImageMarker.scss +23 -0
  114. package/src/scss/map/VCO.Map.scss +21 -0
  115. package/src/scss/map/VCO.MapMarker.scss +147 -0
  116. package/src/scss/map/openlayers/VCO.Map.OpenLayers.scss +126 -0
  117. package/src/scss/media/VCO.Media.scss +222 -0
  118. package/src/scss/media/types/VCO.Media.Blockquote.scss +76 -0
  119. package/src/scss/media/types/VCO.Media.IFrame.scss +11 -0
  120. package/src/scss/media/types/VCO.Media.Image.scss +13 -0
  121. package/src/scss/media/types/VCO.Media.Instagram.scss +7 -0
  122. package/src/scss/media/types/VCO.Media.Profile.scss +5 -0
  123. package/src/scss/media/types/VCO.Media.Text.scss +90 -0
  124. package/src/scss/media/types/VCO.Media.Twitter.scss +87 -0
  125. package/src/scss/media/types/VCO.Media.Wikipedia.scss +64 -0
  126. package/src/scss/site/site.scss +748 -0
  127. package/src/scss/slider/VCO.Slide.scss +447 -0
  128. package/src/scss/slider/VCO.SlideNav.scss +338 -0
  129. package/src/scss/slider/VCO.StorySlider.scss +64 -0
  130. package/src/scss/snapmap/VCO.SnapMap.scss +103 -0
  131. package/src/scss/ui/VCO.Consent.scss +77 -0
  132. package/src/scss/ui/VCO.MenuBar.Button.scss +99 -0
  133. package/src/scss/ui/VCO.MenuBar.scss +20 -0
  134. package/src/scss/ui/VCO.Message.scss +104 -0
  135. package/src/site/site.ts +87 -0
  136. package/src/slider/Slide.ts +460 -0
  137. package/src/slider/SlideNav.ts +212 -0
  138. package/src/slider/StorySlider.ts +722 -0
  139. package/src/storymap/Consent.ts +146 -0
  140. package/src/storymap/StoryMap.ts +1108 -0
  141. package/src/storymap/iiif.ts +386 -0
  142. package/src/storymap/validate.ts +184 -0
  143. package/src/types.ts +297 -0
  144. package/src/ui/Draggable.ts +374 -0
  145. package/src/ui/MenuBar.ts +323 -0
  146. package/src/ui/Message.ts +118 -0
  147. package/src/ui/Swipable.ts +388 -0
  148. package/dist/assets/main-DwbXrrYn.js +0 -17
@@ -0,0 +1,1879 @@
1
+ import OlMap from "ol/Map";
2
+ import View from "ol/View";
3
+ import type { Control } from "ol/control";
4
+ import type { Interaction } from "ol/interaction";
5
+ import { Tile as TileLayer, Vector as VectorLayer } from "ol/layer";
6
+ import VectorTileLayer from "ol/layer/VectorTile";
7
+ import { XYZ, OSM, IIIF } from "ol/source";
8
+ import VectorSource from "ol/source/Vector";
9
+ import LineString from "ol/geom/LineString";
10
+ import Feature from "ol/Feature";
11
+ import { Style, Stroke } from "ol/style";
12
+ import { fromLonLat, toLonLat } from "ol/proj";
13
+ import type Projection from "ol/proj/Projection";
14
+ import { boundingExtent, getIntersection } from "ol/extent";
15
+ import type { Extent } from "ol/extent";
16
+ import TileGrid from "ol/tilegrid/TileGrid";
17
+ import type ImageTile from "ol/ImageTile";
18
+ import OverviewMap from "ol/control/OverviewMap";
19
+ import { defaults as interactionDefaults } from "ol/interaction";
20
+ import { applyStyle } from "ol-mapbox-style";
21
+ import IIIFInfo, { type ImageInformationResponse } from "ol/format/IIIFInfo";
22
+
23
+ import "ol/ol.css";
24
+
25
+ import Map from "../Map";
26
+ import OpenLayersMapMarker from "./MapMarker.OpenLayers";
27
+ import { padCroppedZoomifyTile } from "./zoomifyTiles";
28
+ import type { LinePoint, ViewToOptions } from "../types";
29
+ import type { LatLngLiteral, StorymapSlide, StorymapSlideLocation } from "../../types";
30
+ import { consentManagerOf, consentMessage, type ConsentManager } from "../../storymap/Consent";
31
+
32
+ /* Map.OpenLayers
33
+ Creates a Map using OpenLayers
34
+ ================================================= */
35
+
36
+ const MAX_ZOOM = 19;
37
+
38
+ /**
39
+ * Zoom ladder for image-space maps (issue #465): rung 0 shows any image fully
40
+ * zoomed out, finer rungs reach sub-pixel detail. It keeps view zooms,
41
+ * marker zooms and tile loading on one consistent ladder (tile enqueueing
42
+ * silently drops tiles otherwise).
43
+ */
44
+ const IMAGE_RESOLUTIONS = Array.from({ length: 25 }, (_, i) => 2 ** (16 - i));
45
+
46
+ export default class OpenLayers extends Map {
47
+ declare "_map": OlMap;
48
+ declare "_tile_layer": TileLayer;
49
+ declare "_line": VectorLayer;
50
+ declare "_line_active": VectorLayer;
51
+ declare "_tile_layer_mini": TileLayer | null;
52
+ declare "_mini_map": OverviewMap;
53
+ declare "_markers": OpenLayersMapMarker[];
54
+ /** App-level stacked overlays (see the `overlays` option) */
55
+ declare "_overlay_layers": TileLayer[];
56
+ /** rAF handle of the running active-line draw animation */
57
+ declare "_line_animation": number | null;
58
+
59
+ /* Create the Map
60
+ ================================================== */
61
+ _createMap(): void {
62
+ const is_image_map = this.options.map_type === "iiif" && this.options.map_as_image;
63
+
64
+ // Caller-supplied OpenLayers options: controls/interactions replace the
65
+ // defaults, view merges over the computed default, other options pass through
66
+ const user_map_options = this.options.map_options ?? {};
67
+ const { element: _element, view: user_view, ...passthrough } = user_map_options;
68
+ const user_view_options = (user_view ?? {}) as Record<string, unknown>;
69
+
70
+ // bbox limitation (map_bbox): the view center is constrained to the
71
+ // box (constrainOnlyCenter) — a hard extent constraint would pin the
72
+ // center and resolution whenever the viewport fills or aspect-clips
73
+ // the box, undoing the panel offset and spreading the markers out
74
+ // (probed: a 640x800 viewport aspect-clips a wide bbox, so the
75
+ // strict constraint locks the zoom and the route overflows). Image-
76
+ // space maps use raw pixel coordinates.
77
+ const bbox_extent = this._bboxExtent();
78
+
79
+ this._map = new OlMap({
80
+ ...passthrough,
81
+ target: this._el.map,
82
+ controls: (user_map_options.controls as Control[]) ?? [],
83
+ interactions: (user_map_options.interactions as Interaction[]) ?? [],
84
+ view: new View({
85
+ projection: is_image_map ? "EPSG:4326" : "EPSG:3857",
86
+ center: [0, 0],
87
+ zoom: 0,
88
+ minZoom: 0,
89
+ maxZoom: is_image_map ? IMAGE_RESOLUTIONS.length - 1 : MAX_ZOOM,
90
+ // image maps live outside the 4326 world: opt out of the
91
+ // global-projection constraints (they cap resolution at
92
+ // fit-the-world and clamp the center to [-90, 90]) and use
93
+ // the image zoom ladder instead of the default one
94
+ // (issue #465)
95
+ ...(is_image_map ? { multiWorld: true, resolutions: IMAGE_RESOLUTIONS } : {}),
96
+ // legacy zoomify: the image pyramid needs zooms where the
97
+ // world is smaller than the viewport (the original renderer
98
+ // showed the painting at ~487px in a 1280px window)
99
+ ...(this.options.map_type === "zoomify" ? { multiWorld: true } : {}),
100
+ ...(bbox_extent ? { extent: bbox_extent, constrainOnlyCenter: true } : {}),
101
+ ...user_view_options,
102
+ }),
103
+ });
104
+
105
+ this._map.on("loadend", () => {
106
+ this._onMapLoaded(undefined);
107
+ });
108
+
109
+ // keep the marker overlays above the layer canvases: OL pins the
110
+ // overlay container at z-index 0 (inline), and during pan/zoom
111
+ // animations the composited canvases can transiently paint over it —
112
+ // the marker icons flicker
113
+ const overlay_container = this._el.map.querySelector(".ol-overlaycontainer");
114
+ if (overlay_container) {
115
+ (overlay_container as HTMLElement).style.zIndex = "1";
116
+ }
117
+
118
+ // Create Tile Layer
119
+ // Tile Layer — GDPR consent mode defers it until the visitor allows
120
+ // map tiles (the consent bar renders over the map)
121
+ const consent = consentManagerOf(this.options);
122
+ const tile_service = consentMessage("consent_service_tiles", "map tiles");
123
+ if (this.options.consent_required && consent) {
124
+ if (consent.isGranted(tile_service)) {
125
+ this._addTileLayer();
126
+ } else if (!consent.isDenied(tile_service)) {
127
+ void this._requestTileConsent(consent, tile_service);
128
+ }
129
+ // denied → the map renders with background color and markers only
130
+ } else {
131
+ this._addTileLayer();
132
+ }
133
+
134
+ // Create Overall Connection Line
135
+ this._line = this._createLine();
136
+ this._line.setStyle(this._lineStyle(this.options.line_color_inactive));
137
+ this._addLineToMap(this._line);
138
+ this._line.setZIndex(10);
139
+ this._line.setOpacity(this.options.line_opacity);
140
+
141
+ // Create Active Line
142
+ this._line_active = this._createLine();
143
+ this._line_active.setStyle(this._lineStyle(this.options.line_color));
144
+ this._addLineToMap(this._line_active);
145
+ this._line_active.setZIndex(11);
146
+ this._line_active.setOpacity(1);
147
+
148
+ if (this.options.map_as_image) {
149
+ this._line_active.setVisible(false);
150
+ this._line.setVisible(false);
151
+ }
152
+
153
+ // Stacked raster overlays (base tiles 1.., below the route lines)
154
+ this._overlay_layers = [];
155
+ this._buildOverlays();
156
+
157
+ // Native interactions (pan/zoom), no scroll zoom by default
158
+ const interactions = interactionDefaults({ mouseWheelZoom: false });
159
+ interactions.forEach((i) => this._map.addInteraction(i));
160
+
161
+ // Attribution
162
+ this._updateAttribution();
163
+ }
164
+
165
+ /** Extra attribution fragments (e.g. overlay credits), always listed last. */
166
+ declare "_extra_attributions": string[];
167
+
168
+ /**
169
+ * Append extra attribution fragments and refresh the line. Hosts with
170
+ * custom layers use this instead of rewriting `.vco-map-attribution`.
171
+ */
172
+ setExtraAttributions(parts: string[]): void {
173
+ this._extra_attributions = [...parts];
174
+ this._updateAttribution();
175
+ }
176
+
177
+ /**
178
+ * (Re)render the attribution line for the current map type. Called at
179
+ * creation and on every `map_type` switch, which previously left the
180
+ * initial text stale.
181
+ */
182
+ _updateAttribution(): void {
183
+ const extras = this._extra_attributions ?? [];
184
+ const parts = [this._getAttribution(this.options.map_type), ...extras]
185
+ .filter(Boolean)
186
+ .join(" | ");
187
+ let el = this._el.map.querySelector(".vco-map-attribution") as HTMLElement | null;
188
+ if (!el) {
189
+ this._el.map.insertAdjacentHTML("beforeend", `<div class="vco-map-attribution"></div>`);
190
+ el = this._el.map.querySelector(".vco-map-attribution") as HTMLElement | null;
191
+ }
192
+ if (el) {
193
+ el.innerHTML = parts;
194
+ }
195
+ }
196
+
197
+ _getAttribution(map_type: string): string {
198
+ const parts = [
199
+ "<a href='https://storymap.knightlab.com/' target='_blank' class='vco-knightlab-brand'><span>&#x25a0;</span> StoryMapJS</a>",
200
+ ];
201
+ if (map_type === "osm" || map_type === "" || map_type.startsWith("osm")) {
202
+ parts.push(
203
+ "© <a target='_blank' href='https://www.openstreetmap.org/copyright'>OpenStreetMap</a> contributors",
204
+ );
205
+ }
206
+ if (this.options.attribution) {
207
+ parts.push(this.options.attribution);
208
+ }
209
+ return parts.join(" | ");
210
+ }
211
+
212
+ /* Create Tile Layer
213
+ ================================================== */
214
+ /**
215
+ * Create the tile layer and register its load handler. Deferred until
216
+ * the visitor allows map tiles in consent mode.
217
+ */
218
+ _addTileLayer(): void {
219
+ this._tile_layer = this._createTileLayer(this.options.map_type);
220
+ // explicit stack order (base tiles 0, overlays 1.., route lines
221
+ // 10/11): the switch path re-adds the tile layer last, which used
222
+ // to bury the route lines under fresh tiles
223
+ this._tile_layer.setZIndex(0);
224
+ this._map.addLayer(this._tile_layer);
225
+ }
226
+
227
+ /**
228
+ * Ask for tile consent, then attach the layers if allowed.
229
+ */
230
+ async _requestTileConsent(consent: ConsentManager, tile_service: string) {
231
+ if (await consent.request(tile_service, "", this._el.map)) {
232
+ this._onTilesAllowed();
233
+ }
234
+ }
235
+
236
+ /* Stacked overlays (the `overlays` option)
237
+ ================================================== */
238
+ /**
239
+ * (Re)build the stacked raster overlays from the `overlays` option.
240
+ * Overlays sit above the base tiles (z 1..n) and below the route lines
241
+ * (z 10/11); every entry accepts any `map_type` value plus declarative
242
+ * presentation, so hosts no longer capture and patch layer objects.
243
+ */
244
+ _buildOverlays(): void {
245
+ for (const layer of this._overlay_layers) {
246
+ this._map.removeLayer(layer);
247
+ }
248
+ this._overlay_layers = [];
249
+ const consent = consentManagerOf(this.options);
250
+ const tile_service = consentMessage("consent_service_tiles", "map tiles");
251
+ if (
252
+ this.options.consent_required &&
253
+ consent &&
254
+ !consent.isGranted(tile_service)
255
+ ) {
256
+ return;
257
+ }
258
+ const overlays = this.options.overlays ?? [];
259
+ overlays.forEach((entry, i) => {
260
+ const layer = this._createTileLayer(entry.map_type);
261
+ layer.setZIndex(1 + i);
262
+ if (entry.opacity !== undefined) {
263
+ layer.setOpacity(entry.opacity);
264
+ }
265
+ if (entry.visible !== undefined) {
266
+ layer.setVisible(entry.visible);
267
+ }
268
+ if (entry.className) {
269
+ // OpenLayers exposes no className setter; overriding the
270
+ // per-frame getClassName hook paints this layer into its own
271
+ // container div, which blend modes can then target
272
+ const className = entry.className;
273
+ layer.getClassName = () => className;
274
+ }
275
+ if (entry.extent) {
276
+ const extent = this._lonLatBboxToExtent(entry.extent);
277
+ if (extent) {
278
+ layer.setExtent(extent);
279
+ }
280
+ }
281
+ this._map.addLayer(layer);
282
+ this._overlay_layers.push(layer);
283
+ this._paintOverlayBlend(entry);
284
+ });
285
+ this._syncOverlayAttributions();
286
+ }
287
+
288
+ /**
289
+ * Convert a lon/lat clip box to view units. Only meaningful on
290
+ * mercator maps; image-space maps (iiif/zoomify) and malformed
291
+ * boxes yield null (no constraint).
292
+ */
293
+ _lonLatBboxToExtent(bbox: [number, number, number, number]): Extent | null {
294
+ if (
295
+ !Array.isArray(bbox) ||
296
+ bbox.length !== 4 ||
297
+ bbox.some((n) => typeof n !== "number" || !isFinite(n))
298
+ ) {
299
+ return null;
300
+ }
301
+ if (this._map.getView().getProjection().getCode() !== "EPSG:3857") {
302
+ return null;
303
+ }
304
+ return boundingExtent([fromLonLat([bbox[0], bbox[1]]), fromLonLat([bbox[2], bbox[3]])]);
305
+ }
306
+
307
+ /**
308
+ * Apply an overlay's blend mode to its layer container. The container
309
+ * div only exists once the layer has rendered, so retry on later
310
+ * frames (bounded: rendering settles within a frame or two of any
311
+ * add/visibility change).
312
+ */
313
+ _paintOverlayBlend(
314
+ entry: { className?: string; blendMode?: string },
315
+ retries = 60,
316
+ ): void {
317
+ if (!entry.blendMode || !entry.className) {
318
+ return;
319
+ }
320
+ // OpenLayers replaces the container class wholesale (it is not
321
+ // merged with the default), so match every safe token instead of
322
+ // assuming a lone class name
323
+ const tokens = entry.className.split(/\s+/).filter((token) => /^[\w-]+$/.test(token));
324
+ if (tokens.length === 0) {
325
+ return;
326
+ }
327
+ const el = this._el.map.querySelector("." + tokens.join("."));
328
+ if (el) {
329
+ (el as HTMLElement).style.mixBlendMode = entry.blendMode;
330
+ return;
331
+ }
332
+ // the container div only exists once the layer has rendered
333
+ if (retries > 0) {
334
+ requestAnimationFrame(() => this._paintOverlayBlend(entry, retries - 1));
335
+ }
336
+ }
337
+
338
+ /** Refresh the attribution line with the visible overlays' credits. */
339
+ _syncOverlayAttributions(): void {
340
+ const overlays = this.options.overlays ?? [];
341
+ const parts: string[] = [];
342
+ this._overlay_layers.forEach((layer, i) => {
343
+ const credit = overlays[i]?.attribution;
344
+ if (credit && layer.getVisible()) {
345
+ parts.push(credit);
346
+ }
347
+ });
348
+ this.setExtraAttributions(parts);
349
+ }
350
+
351
+ /** Number of stacked overlay layers (see the `overlays` option). */
352
+ getOverlayCount(): number {
353
+ return this._overlay_layers.length;
354
+ }
355
+
356
+ /** Show or hide a stacked overlay by index (re-syncs attribution). */
357
+ setOverlayVisible(index: number, visible: boolean): void {
358
+ const layer = this._overlay_layers[index];
359
+ if (!layer) {
360
+ return;
361
+ }
362
+ layer.setVisible(visible);
363
+ const entry = (this.options.overlays ?? [])[index];
364
+ if (entry && visible) {
365
+ this._paintOverlayBlend(entry);
366
+ }
367
+ this._syncOverlayAttributions();
368
+ }
369
+
370
+ /** Set a stacked overlay's opacity by index. */
371
+ setOverlayOpacity(index: number, opacity: number): void {
372
+ this._overlay_layers[index]?.setOpacity(opacity);
373
+ }
374
+
375
+ /**
376
+ * Map tiles were allowed: attach the main tile layer, create the
377
+ * minimap's deferred layer if it was withheld, then re-fit.
378
+ */
379
+ _onTilesAllowed(): void {
380
+ this._addTileLayer();
381
+ this._buildOverlays();
382
+ if (!this._tile_layer_mini) {
383
+ this._tile_layer_mini = this._createTileLayer(this.options.map_type);
384
+ }
385
+ if (this._mini_map && this._tile_layer_mini) {
386
+ const overview = this._mini_map.getOverviewMap();
387
+ if (!overview.getLayers().getLength()) {
388
+ overview.addLayer(this._tile_layer_mini);
389
+ }
390
+ }
391
+ if (this._markers.length > 0 && this.current_marker < this._markers.length) {
392
+ const marker = this._markers[this.current_marker];
393
+ if (marker.data.type === "overview") {
394
+ this._markerOverview();
395
+ } else if (this._hasLocation(marker.data)) {
396
+ this._fitView(this._map, [[marker.data.location.lon, marker.data.location.lat]], 0);
397
+ }
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Legacy zoomify image pyramid: the levels (image size per level), the
403
+ * max zoom and the mercator bounds the image occupies (stretched from
404
+ * the world's top-left corner, the original renderer's mapping).
405
+ */
406
+ _zoomifyPyramid(): {
407
+ sizes: Array<[number, number]>;
408
+ maxZoom: number;
409
+ extent: number[];
410
+ tileGrid: TileGrid;
411
+ } | null {
412
+ const zoomify = this.options.zoomify;
413
+ if (!zoomify || typeof zoomify !== "object" || !zoomify.path) return null;
414
+ const width = zoomify.width ?? 600;
415
+ const height = zoomify.height ?? 600;
416
+
417
+ // pyramid levels: halve the image size until ≤ 256
418
+ const sizes: Array<[number, number]> = [];
419
+ let w = width;
420
+ let h = height;
421
+ while (w > 256 || h > 256) {
422
+ sizes.push([w, h]);
423
+ w = Math.floor(w / 2);
424
+ h = Math.floor(h / 2);
425
+ }
426
+ sizes.push([w, h]);
427
+ sizes.reverse(); // [0] = smallest level
428
+
429
+ const maxZoom = sizes.length - 1;
430
+ const worldPx = 256 * 2 ** maxZoom;
431
+ const extent = [
432
+ -20037508.342789244,
433
+ 20037508.342789244 - (height / worldPx) * 40075016.68557849,
434
+ -20037508.342789244 + (width / worldPx) * 40075016.68557849,
435
+ 20037508.342789244,
436
+ ];
437
+ // tile grid over the image's mercator bounds: the tiles keep their
438
+ // natural size (the legacy renderer clamped edge tiles the same way).
439
+ // One extra level below zoom 0 lets the overview show the complete
440
+ // image (the smallest pyramid tile downscaled)
441
+ const tileGrid = new TileGrid({
442
+ extent,
443
+ origin: [extent[0], extent[3]],
444
+ resolutions: Array.from(
445
+ { length: maxZoom + 2 },
446
+ (_, i) => 40075016.68557849 / (256 * 2 ** (i - 1)),
447
+ ),
448
+ tileSize: 256,
449
+ });
450
+ return { sizes, maxZoom, extent, tileGrid };
451
+ }
452
+
453
+ /**
454
+ * The zoomify overview state: the best-fit pyramid level for a map
455
+ * window (the level whose image, times the tolerance, fits the window —
456
+ * the legacy `_getBestFitZoom`) and the image's mercator center.
457
+ */
458
+ _zoomifyOverview(mapSize: [number, number]): { zoom: number; center: number[] } | null {
459
+ const pyramid = this._zoomifyPyramid();
460
+ if (!pyramid) return null;
461
+ const tolerance = (this.options.zoomify as { tolerance?: number })?.tolerance ?? 0.9;
462
+ let zoom = pyramid.maxZoom;
463
+ while (zoom > 0) {
464
+ const size = pyramid.sizes[zoom];
465
+ if (
466
+ size[0] * tolerance < (mapSize[0] || 1) &&
467
+ size[1] * tolerance < (mapSize[1] || 1)
468
+ ) {
469
+ break;
470
+ }
471
+ zoom--;
472
+ }
473
+ const center = [
474
+ (pyramid.extent[0] + pyramid.extent[2]) / 2,
475
+ (pyramid.extent[1] + pyramid.extent[3]) / 2,
476
+ ];
477
+ return { zoom, center };
478
+ }
479
+
480
+ _createTileLayer(map_type: string): TileLayer {
481
+ const _map_type_arr = map_type.split(":");
482
+
483
+ switch (_map_type_arr[0]) {
484
+ case "mapbox": {
485
+ if (_map_type_arr.length > 2) {
486
+ // mapbox://styles/<user>/<style> URLs render via the
487
+ // Mapbox styles tiles API (requires map_access_token)
488
+ const this_mapbox_map = _map_type_arr[2].slice("//styles/".length);
489
+ const mapbox_url =
490
+ "https://api.mapbox.com/styles/v1/" +
491
+ this_mapbox_map +
492
+ "/tiles/256/{z}/{x}/{y}@2x?access_token=" +
493
+ this.options.map_access_token;
494
+ return new TileLayer({
495
+ source: new XYZ({
496
+ url: mapbox_url,
497
+ attributions: [],
498
+ crossOrigin: "anonymous",
499
+ }),
500
+ });
501
+ }
502
+ console.error(
503
+ "StoryMapJS: legacy 'mapbox:<style>' map types are no longer supported (the Mapbox v4 tile API was retired); use 'mapbox://styles/<user>/<style>' with map_access_token instead.",
504
+ );
505
+ return new TileLayer({ source: new OSM({ attributions: [] }) });
506
+ }
507
+
508
+ case "stadia": {
509
+ let style_url = "osm:standard";
510
+ if (_map_type_arr.length > 1) {
511
+ style_url = _map_type_arr.slice(1).join(":");
512
+ if (this.options.map_access_token) {
513
+ style_url = `${style_url}?api_key=${this.options.map_access_token}`;
514
+ }
515
+ }
516
+ return new TileLayer({
517
+ source: new XYZ({
518
+ url: `https://tiles.stadiamaps.com/tiles/${style_url}/{z}/{x}/{y}.png`,
519
+ attributions: [],
520
+ }),
521
+ });
522
+ }
523
+
524
+ case "stamen":
525
+ this._map.getViewport().style.backgroundColor = "#FFFFFF";
526
+ return new TileLayer({
527
+ source: new XYZ({
528
+ url: "https://tiles.stadiamaps.com/tiles/stamen_toner_lite/{z}/{x}/{y}.png",
529
+ attributions: [],
530
+ }),
531
+ });
532
+
533
+ case "iiif": {
534
+ const iiif_layer: TileLayer = new TileLayer();
535
+ fetch(this.options.iiif.url)
536
+ .then((r) => r.json())
537
+ .then((info: unknown) => {
538
+ // parse the service description into proper tile
539
+ // source options (base URL, version, tiling) so tile
540
+ // URLs are valid and the grid matches the service
541
+ const parsed = new IIIFInfo(
542
+ info as ImageInformationResponse,
543
+ ).getTileSourceOptions();
544
+ const fallback = info as { width?: number; height?: number };
545
+ if (
546
+ typeof fallback.width !== "number" ||
547
+ typeof fallback.height !== "number"
548
+ ) {
549
+ console.error(
550
+ "IIIF info.json is missing width/height:",
551
+ this.options.iiif.url,
552
+ );
553
+ return;
554
+ }
555
+ const source = new IIIF({
556
+ ...(parsed ?? {}),
557
+ projection: "EPSG:4326",
558
+ size: [fallback.width, fallback.height],
559
+ crossOrigin: "anonymous",
560
+ attributions: this.options.iiif.attribution || [],
561
+ });
562
+ iiif_layer.setSource(source);
563
+ if (source.getState() === "ready") {
564
+ this._markerOverview();
565
+ } else {
566
+ source.once("change", () => {
567
+ if (source.getState() === "ready") {
568
+ this._markerOverview();
569
+ }
570
+ });
571
+ }
572
+ })
573
+ .catch((err) =>
574
+ console.error(
575
+ "IIIF info.json could not be loaded:",
576
+ this.options.iiif.url,
577
+ err?.stack || err,
578
+ ),
579
+ );
580
+ return iiif_layer;
581
+ }
582
+
583
+ case "http":
584
+ case "https":
585
+ // style JSON URLs (e.g. https://tiles.openfreemap.org/styles/bright)
586
+ // render a vector style, tile template URLs stay raster
587
+ if (!map_type.includes("{z}")) {
588
+ return this._createVectorStyleLayer(map_type);
589
+ }
590
+ return new TileLayer({
591
+ source: new XYZ({ url: map_type, attributions: [], crossOrigin: "anonymous" }),
592
+ });
593
+
594
+ case "ch-watercolor":
595
+ return new TileLayer({
596
+ source: new XYZ({
597
+ url: "https://watercolormaps.collection.cooperhewitt.org/tile/watercolor/{z}/{x}/{y}.jpg",
598
+ attributions: [],
599
+ maxZoom: 16,
600
+ }),
601
+ });
602
+
603
+ case "zoomify": {
604
+ // Legacy zoomify support: the image pyramid tiles are placed
605
+ // over the image's mercator bounds (the original renderer's
606
+ // mapping). Locations use native lat/lon. The tile grid's
607
+ // ladder is shifted one level down so the overview can show
608
+ // the complete image.
609
+ const pyramid = this._zoomifyPyramid();
610
+ if (!pyramid) {
611
+ console.error(
612
+ "StoryMapJS: map_type 'zoomify' needs a zoomify image pyramid (path, width, height) in the storymap data.",
613
+ );
614
+ return new TileLayer({ source: new OSM({ attributions: [] }) });
615
+ }
616
+ const path = (this.options.zoomify as { path?: string }).path ?? "";
617
+ const { sizes, maxZoom: pyramidMaxZoom } = pyramid;
618
+ const gridX = (z: number) => Math.ceil(sizes[z][0] / 256);
619
+ const gridY = (z: number) => Math.ceil(sizes[z][1] / 256);
620
+
621
+ return new TileLayer({
622
+ source: new XYZ({
623
+ tileGrid: pyramid.tileGrid,
624
+ crossOrigin: "anonymous",
625
+ attributions: [],
626
+ tileUrlFunction: (tile: number[]) => {
627
+ const [tileZ, x, y] = tile;
628
+ // the ladder is shifted one level down: mercator
629
+ // zoom z serves the pyramid level max(0, z - 1)
630
+ const z = Math.max(0, tileZ - 1);
631
+ if (z > pyramidMaxZoom) return undefined;
632
+ if (x < 0 || x >= gridX(z) || y < 0 || y >= gridY(z)) {
633
+ return undefined;
634
+ }
635
+ // TileGroup index: the running tile number ÷ 256
636
+ let num = 0;
637
+ for (let zz = 0; zz < z; zz++) {
638
+ num += gridX(zz) * gridY(zz);
639
+ }
640
+ num += y * gridX(z) + x;
641
+ return `${path}TileGroup${Math.floor(num / 256)}/${z}-${x}-${y}.jpg`;
642
+ },
643
+ // Zoomify edge tiles are cropped to the image bounds
644
+ // (e.g. a 256x19 bottom strip); OpenLayers draws the
645
+ // loaded image over the whole 256x256 tile box, which
646
+ // stretched those strips across the cell (the smeared
647
+ // bottom in the Bosch overview, stretched right/bottom
648
+ // edges in the Literary Trail). Pad them onto a full
649
+ // tile canvas instead.
650
+ tileLoadFunction: (tile, src) => {
651
+ const imageTile = tile as ImageTile;
652
+ const image = imageTile.getImage() as HTMLImageElement;
653
+ image.onload = () => padCroppedZoomifyTile(imageTile, image);
654
+ image.src = src;
655
+ },
656
+ }),
657
+ });
658
+ }
659
+
660
+ case "osm": {
661
+ // "osm:<style>" uses an OpenFreeMap vector style (osm:bright ->
662
+ // https://tiles.openfreemap.org/styles/bright), plain "osm" stays
663
+ // the classic raster tiles
664
+ const style_name = _map_type_arr.length > 1 ? _map_type_arr[1] : "";
665
+ if (style_name) {
666
+ return this._createVectorStyleLayer(
667
+ `https://tiles.openfreemap.org/styles/${style_name}`,
668
+ );
669
+ }
670
+ return new TileLayer({ source: new OSM({ attributions: [] }) });
671
+ }
672
+ default: {
673
+ // Relative/custom templates (./tiles/{z}/{x}/{y}.png,
674
+ // /tiles/{z}/..., tiles/{z}/...): render as raster XYZ so
675
+ // consumers on subpaths (GitLab Pages), bundlers (Vite) and
676
+ // the Electron kiosk server don't have to expand to an
677
+ // absolute http(s):// URL first. Style JSON paths without
678
+ // {z} render as vector styles, anything else falls back to
679
+ // OSM.
680
+ if (map_type.includes("{z}")) {
681
+ return new TileLayer({
682
+ source: new XYZ({
683
+ url: map_type,
684
+ attributions: [],
685
+ crossOrigin: "anonymous",
686
+ }),
687
+ });
688
+ }
689
+ if (map_type.includes("/") || map_type.endsWith(".json")) {
690
+ return this._createVectorStyleLayer(map_type);
691
+ }
692
+ return new TileLayer({ source: new OSM({ attributions: [] }) });
693
+ }
694
+ }
695
+ }
696
+
697
+ _createVectorStyleLayer(style_url: string): TileLayer {
698
+ // Mapbox style JSONs (OpenFreeMap, Mapbox) are applied onto a single
699
+ // vector tile layer, including its background and label decluttering
700
+ const layer = new VectorTileLayer({ declutter: true, updateWhileAnimating: true });
701
+ applyStyle(layer, style_url).catch((err: unknown) =>
702
+ console.error("Vector map style could not be loaded:", style_url, err),
703
+ );
704
+ return layer as unknown as TileLayer;
705
+ }
706
+
707
+ /* Create Mini Map
708
+ ================================================== */
709
+ _createMiniMap(): void {
710
+ if (this.options.map_as_image) {
711
+ this.zoom_min_max.min = 0;
712
+ }
713
+
714
+ if (!this.bounds_array) {
715
+ this.bounds_array = this._getAllMarkersBounds(this._markers);
716
+ }
717
+
718
+ // consent mode: the minimap layer is only created once map tiles are
719
+ // allowed — creating an IIIF/vector layer object fetches info.json or
720
+ // the style JSON immediately, which must not happen while denied
721
+ const consent = consentManagerOf(this.options);
722
+ const tile_service = consentMessage("consent_service_tiles", "map tiles");
723
+ const tiles_allowed = !(
724
+ this.options.consent_required &&
725
+ consent &&
726
+ !consent.isGranted(tile_service)
727
+ );
728
+ this._tile_layer_mini = tiles_allowed ? this._createTileLayer(this.options.map_type) : null;
729
+ const is_image_map = this.options.map_type === "iiif" && this.options.map_as_image;
730
+ // Legacy zoomify maps are mercator-based: the minimap fits the image's
731
+ // mercator bounds with a free zoom so the whole image stays visible
732
+ // at a downscaled (sharp) pyramid level
733
+ const is_zoomify = this.options.map_type === "zoomify";
734
+ const zoomify_pyramid = is_zoomify ? this._zoomifyPyramid() : null;
735
+ // the overview needs the pyramid's full ladder (including the R0
736
+ // floor below default zoom 0): fit() settles on a fractional zoom
737
+ // containing the whole image, which minZoom: 0 on the default ladder
738
+ // would clip back to a cropped upscale
739
+ const zoomify_resolutions = zoomify_pyramid
740
+ ? zoomify_pyramid.tileGrid.getResolutions()
741
+ : null;
742
+ // Constrain a standard mercator overview to an explicit lon/lat box
743
+ // (overview_extent); image maps keep their own views below. Without
744
+ // a constraint the default overview view roams the whole world.
745
+ const overview_extent =
746
+ !is_image_map && !zoomify_pyramid && this.options.overview_extent
747
+ ? this._lonLatBboxToExtent(this.options.overview_extent)
748
+ : null;
749
+ this._mini_map = new OverviewMap({
750
+ ...(is_image_map || zoomify_pyramid
751
+ ? {
752
+ view: (() => {
753
+ const view = new View({
754
+ projection: this._map.getView().getProjection(),
755
+ center: this._map.getView().getCenter(),
756
+ zoom: this.zoom_min_max.min || 0,
757
+ // same reasoning as the main image view: no world
758
+ // constraints
759
+ ...(is_image_map
760
+ ? {
761
+ multiWorld: true,
762
+ resolutions: IMAGE_RESOLUTIONS,
763
+ minZoom: 0,
764
+ maxZoom: IMAGE_RESOLUTIONS.length - 1,
765
+ }
766
+ : {}),
767
+ ...(zoomify_pyramid && zoomify_resolutions
768
+ ? {
769
+ // show the whole image: the zoom stays
770
+ // free on the pyramid ladder so fit()
771
+ // settles on the fractional resolution
772
+ // containing the image bounds (a pinned
773
+ // zoom stuck the smallest, blurry
774
+ // pyramid level on screen, cropped);
775
+ // the center stays within the image
776
+ // bounds
777
+ constrainOnlyCenter: true,
778
+ extent: zoomify_pyramid.extent,
779
+ resolutions: zoomify_resolutions,
780
+ minZoom: 0,
781
+ maxZoom: zoomify_resolutions.length - 1,
782
+ center: [
783
+ (zoomify_pyramid.extent[0] +
784
+ zoomify_pyramid.extent[2]) /
785
+ 2,
786
+ (zoomify_pyramid.extent[1] +
787
+ zoomify_pyramid.extent[3]) /
788
+ 2,
789
+ ],
790
+ }
791
+ : {}),
792
+ });
793
+ return view;
794
+ })(),
795
+ }
796
+ : {}),
797
+ ...(overview_extent
798
+ ? {
799
+ view: new View({
800
+ projection: this._map.getView().getProjection(),
801
+ center: [
802
+ (overview_extent[0] + overview_extent[2]) / 2,
803
+ (overview_extent[1] + overview_extent[3]) / 2,
804
+ ],
805
+ extent: overview_extent,
806
+ constrainOnlyCenter: false,
807
+ }),
808
+ }
809
+ : {}),
810
+ layers: this._tile_layer_mini ? [this._tile_layer_mini] : [],
811
+ // NB: label = the button shown when COLLAPSED (expands the
812
+ // minimap), collapseLabel = shown when EXPANDED (collapses it) —
813
+ // the chevrons point outward when collapsed and inward when open
814
+ collapseLabel: "\u00ab",
815
+ label: "\u00bb",
816
+ collapsed: true,
817
+ });
818
+ this._map.addControl(this._mini_map);
819
+
820
+ if (zoomify_pyramid) {
821
+ // show the image pyramid's extent in the minimap: with the zoom
822
+ // free, fit() picks the fractional resolution containing the
823
+ // whole image, so the overview serves the smallest pyramid level
824
+ // downscaled (sharp) instead of a cropped upscale
825
+ const overview_map = this._mini_map.getOverviewMap();
826
+ const fitZoomifyMini = () => {
827
+ const raw_size = overview_map.getSize();
828
+ // the minimap starts collapsed (no layout size yet) in its
829
+ // 150x100 box — fall back to that until it expands
830
+ const size =
831
+ raw_size && raw_size[0] >= 50 && raw_size[1] >= 50
832
+ ? raw_size
833
+ : [150, 100];
834
+ overview_map.getView().fit(zoomify_pyramid.extent, {
835
+ size: size,
836
+ });
837
+ };
838
+ fitZoomifyMini();
839
+ // re-fit once the minimap expands: the collapsed size is unknown
840
+ // at creation time
841
+ overview_map.on("change:size", fitZoomifyMini);
842
+ } else if (!is_zoomify && this.bounds_array && this.bounds_array.length) {
843
+ // the minimap shows the story's world: with a bbox set, markers
844
+ // outside of the box are unreachable and must not skew the fit
845
+ this._fitView(this._mini_map.getOverviewMap(), this.bounds_array, 0, this._bboxExtent() !== null);
846
+ }
847
+
848
+ if (this.options.map_type === "iiif" && this.options.map_as_image) {
849
+ // in image mode there are no geo markers to fit, so show the
850
+ // whole image instead (issues #465, #355)
851
+ this._fitMiniMapToImage();
852
+ }
853
+ }
854
+
855
+ _fitMiniMapToImage(): void {
856
+ if (!this._tile_layer_mini) return;
857
+ const fit_mini_image = () => {
858
+ try {
859
+ const mini_source = this._tile_layer_mini?.getSource() as {
860
+ getTileGrid?: () => { getExtent(): number[] };
861
+ } | null;
862
+ const grid = mini_source?.getTileGrid?.();
863
+ if (grid) {
864
+ const overview_map = this._mini_map.getOverviewMap();
865
+ const raw_size = overview_map.getSize();
866
+ // the minimap may still be collapsed (no layout size yet)
867
+ const size =
868
+ raw_size && raw_size[0] >= 50 && raw_size[1] >= 50 ? raw_size : [150, 150];
869
+ overview_map.getView().fit(grid.getExtent(), {
870
+ size: size,
871
+ });
872
+ }
873
+ } catch (e) {
874
+ console.warn("IIIF minimap fit failed:", e);
875
+ }
876
+ };
877
+ const mini_source = this._tile_layer_mini.getSource();
878
+ if (mini_source) {
879
+ if (mini_source.getState() === "ready") {
880
+ fit_mini_image();
881
+ } else {
882
+ mini_source.once("change", () => {
883
+ if (mini_source.getState() === "ready") fit_mini_image();
884
+ });
885
+ }
886
+ } else {
887
+ // the mini layer sets its source asynchronously
888
+ this._tile_layer_mini.once("change:source", () => {
889
+ const src = this._tile_layer_mini?.getSource();
890
+ if (!src) return;
891
+ if (src.getState() === "ready") {
892
+ fit_mini_image();
893
+ } else {
894
+ src.once("change", () => {
895
+ if (src.getState() === "ready") fit_mini_image();
896
+ });
897
+ }
898
+ });
899
+ }
900
+ }
901
+
902
+ /* Create Markers
903
+ ================================================== */
904
+ _createMarker(d: StorymapSlide): void {
905
+ const marker = new OpenLayersMapMarker(d, this.options);
906
+ marker.on("markerclick", this._onMarkerClick, this);
907
+ this._addMarker(marker);
908
+ this._markers.push(marker);
909
+ marker.marker_number = this._markers.length - 1;
910
+ this.fire("markerAdded", marker);
911
+ }
912
+
913
+ _addMarker(marker: OpenLayersMapMarker): void {
914
+ marker.addTo(this._map);
915
+ }
916
+
917
+ /**
918
+ * Position marker overlays on the unwrapped longitude path so they sit on
919
+ * the same world copy as the fitted view and the line (issue #381).
920
+ * Image-space coordinates are not degrees and stay untouched.
921
+ */
922
+ _afterCreateMarkers(): void {
923
+ if (this._map.getView().getProjection().getCode() === "EPSG:4326") {
924
+ return;
925
+ }
926
+ const real = this._markers.filter(
927
+ (m) => m.data.real_marker && m.data.location?.lon !== undefined,
928
+ );
929
+ const unwrapped = this._unwrapLongitudes(real.map((m) => m.data.location.lon as number));
930
+ real.forEach((m, i) => {
931
+ m._overlay?.setPosition(fromLonLat([unwrapped[i], m.data.location.lat as number]));
932
+ });
933
+ }
934
+
935
+ _removeMarker(marker: OpenLayersMapMarker): void {
936
+ if (marker && marker.data.real_marker) {
937
+ marker._removeFrom(this._map);
938
+ }
939
+ }
940
+
941
+ /* Marker helpers
942
+ ================================================== */
943
+ _getAllMarkersBounds(markers_array: OpenLayersMapMarker[]): number[][] {
944
+ const coords = [];
945
+ for (let i = 0; i < markers_array.length; i++) {
946
+ if (markers_array[i].data.real_marker) {
947
+ coords.push([
948
+ markers_array[i].data.location.lon,
949
+ markers_array[i].data.location.lat,
950
+ ]);
951
+ }
952
+ }
953
+ // unwrap dateline crossings so fits don't span the whole globe
954
+ // (issue #381)
955
+ const lons = this._unwrapLongitudes(coords.map((c) => c[0] as number));
956
+ return coords.map((c, i) => [lons[i], c[1]]);
957
+ }
958
+
959
+ /**
960
+ * Normalize a longitude sequence so consecutive values differ by at most
961
+ * 180 degrees: markers keep their raw coordinates (OpenLayers wraps the
962
+ * display), but fits and lines use the unwrapped path (issue #381).
963
+ */
964
+ _unwrapLongitudes(lons: number[]): number[] {
965
+ if (lons.length === 0) return [];
966
+ const out = [lons[0]];
967
+ for (let i = 1; i < lons.length; i++) {
968
+ let lon = lons[i];
969
+ while (lon - out[i - 1] > 180) lon -= 360;
970
+ while (lon - out[i - 1] < -180) lon += 360;
971
+ out.push(lon);
972
+ }
973
+ return out;
974
+ }
975
+
976
+ _markerCoordsToViewCoords(coords: number[][]): number[][] {
977
+ const is_image_space = this._map.getView().getProjection().getCode() === "EPSG:4326";
978
+ if (is_image_space) return coords.map((c) => [c[0], c[1]]);
979
+ return coords.map((c) => fromLonLat(c));
980
+ }
981
+
982
+ /**
983
+ * The View extent for the `map_bbox` option, or `null` when unset. The
984
+ * view uses it with `constrainOnlyCenter` (see _createMap).
985
+ */
986
+ _bboxExtent(): number[] | null {
987
+ const bbox = this.options.map_bbox as number[] | null | undefined;
988
+ if (!bbox || bbox.length !== 4) return null;
989
+ const is_image_space = this.options.map_type === "iiif" && this.options.map_as_image;
990
+ if (is_image_space) return bbox;
991
+ // NB: fromLonLat transforms a single [lon, lat] pair — transform the
992
+ // two corners separately
993
+ const min = fromLonLat([bbox[0], bbox[1]]);
994
+ const max = fromLonLat([bbox[2], bbox[3]]);
995
+ return [min[0], min[1], max[0], max[1]];
996
+ }
997
+ /**
998
+ * The slide content panel can be opaque — it then covers part of the map
999
+ * and the effective visible area shrinks. Returns the pixel padding for
1000
+ * the covered side (right in landscape, bottom in portrait) so fits keep
1001
+ * the story inside the visible region; transparent panels and panels
1002
+ * that do not overlap the map (map_area "left") add nothing.
1003
+ */
1004
+ _opaquePanelPadding(): [number, number, number, number] {
1005
+ const padding: [number, number, number, number] = [15, 15, 15, 15];
1006
+ if (typeof document === "undefined") return padding;
1007
+ const panel = document.querySelector(".vco-storyslider .vco-slide.vco-active .vco-text");
1008
+ if (!panel) return padding;
1009
+ const bg = getComputedStyle(panel).backgroundColor;
1010
+ const match = /rgba?\(([^)]+)\)/.exec(bg);
1011
+ if (!match) return padding;
1012
+ const parts = match[1]
1013
+ .split(/[,\s/]+/)
1014
+ .filter((v) => v !== "")
1015
+ .map(Number);
1016
+ const alpha = parts.length >= 4 ? parts[3] : 1;
1017
+ if (alpha < 0.9) return padding;
1018
+ const map_rect = this._el.map.getBoundingClientRect();
1019
+ const panel_rect = panel.getBoundingClientRect();
1020
+ if (!map_rect.width || !panel_rect.width) return padding;
1021
+ // the panel must actually overlap the map (it does not in the
1022
+ // map_area "left" layout, where map and panel sit side by side)
1023
+ const overlaps =
1024
+ panel_rect.left < map_rect.right &&
1025
+ panel_rect.right > map_rect.left &&
1026
+ panel_rect.top < map_rect.bottom &&
1027
+ panel_rect.bottom > map_rect.top;
1028
+ if (!overlaps) return padding;
1029
+ const layout = this.options.layout;
1030
+ if (layout === "portrait") {
1031
+ padding[2] += Math.max(0, map_rect.bottom - panel_rect.top);
1032
+ } else {
1033
+ padding[1] += Math.max(0, panel_rect.right - map_rect.left);
1034
+ }
1035
+ return padding;
1036
+ }
1037
+
1038
+ /**
1039
+ * Fit the given coordinates. With `clamp_to_bbox` (the strict bbox
1040
+ * layout, map_area "left"), markers outside of the box are unreachable
1041
+ * by design — they must not skew the fit target, so the extent is
1042
+ * intersected with the box and the in-box markers compose the view.
1043
+ */
1044
+ _fitView(ol_map: OlMap, coords: number[][], duration = 0, clamp_to_bbox = false): void {
1045
+ if (!coords || !coords.length) return;
1046
+ const view_coords = this._markerCoordsToViewCoords(coords);
1047
+ let extent = boundingExtent(view_coords);
1048
+ if (clamp_to_bbox) {
1049
+ const bbox = this._bboxExtent();
1050
+ if (bbox) {
1051
+ const clamped = getIntersection(extent, bbox as Extent);
1052
+ // getIntersection returns an inverted (empty) extent when the
1053
+ // boxes are disjoint — fit the box itself in that case
1054
+ extent =
1055
+ clamped[0] <= clamped[2] && clamped[1] <= clamped[3]
1056
+ ? clamped
1057
+ : (bbox as Extent);
1058
+ }
1059
+ }
1060
+ ol_map.getView().fit(extent, {
1061
+ size: ol_map.getSize(),
1062
+ padding: this._opaquePanelPadding(),
1063
+ maxZoom: 12,
1064
+ duration: duration,
1065
+ easing: this.options.ease as ((t: number) => number) | undefined,
1066
+ });
1067
+ }
1068
+
1069
+ _calculateMarkerZooms(): void {
1070
+ for (let i = 0; i < this._markers.length; i++) {
1071
+ if (this._markers[i].data.location) {
1072
+ const marker = this._markers[i];
1073
+ let marker_location, calculated_zoom;
1074
+
1075
+ // MARKER LOCATION
1076
+ if (marker.data.type && marker.data.type === "overview") {
1077
+ marker_location = this._getMapCenter(true);
1078
+ } else {
1079
+ marker_location = marker.location();
1080
+ }
1081
+
1082
+ // Fit-based zoom: zoom the view to this marker and neighbors
1083
+ const prev_marker =
1084
+ i > 0 ? this._markers[i - 1].location() : this._getMapCenter(true);
1085
+ const next_marker =
1086
+ i < this._markers.length - 1
1087
+ ? this._markers[i + 1].location()
1088
+ : this._getMapCenter(true);
1089
+
1090
+ const prev_marker_zoom = this._calculateZoomChange(prev_marker, marker_location);
1091
+ const next_marker_zoom = this._calculateZoomChange(next_marker, marker_location);
1092
+
1093
+ if (prev_marker_zoom && prev_marker_zoom < next_marker_zoom) {
1094
+ calculated_zoom = prev_marker_zoom;
1095
+ } else if (next_marker_zoom) {
1096
+ calculated_zoom = next_marker_zoom;
1097
+ } else {
1098
+ calculated_zoom = prev_marker_zoom;
1099
+ }
1100
+
1101
+ if (
1102
+ this.options.map_center_offset &&
1103
+ (this.options.map_center_offset.left !== 0 ||
1104
+ this.options.map_center_offset.top !== 0)
1105
+ ) {
1106
+ calculated_zoom = calculated_zoom - 1;
1107
+ }
1108
+
1109
+ marker.data.location.zoom = calculated_zoom;
1110
+ }
1111
+ }
1112
+ }
1113
+
1114
+ /* Line
1115
+ ================================================== */
1116
+
1117
+ /**
1118
+ * Stroke style for the route lines: the dash pattern and line join are
1119
+ * applied at init too, matching the original rendering (the lines are
1120
+ * dashed "5,5" by default, not solid).
1121
+ */
1122
+ _lineStyle(color: string): Style {
1123
+ return new Style({
1124
+ stroke: new Stroke({
1125
+ color: color,
1126
+ width: this.options.line_weight,
1127
+ lineDash: String(this.options.line_dash)
1128
+ .split(",")
1129
+ .map((v) => Number(v)),
1130
+ lineJoin: this.options.line_join as CanvasLineJoin,
1131
+ }),
1132
+ });
1133
+ }
1134
+
1135
+ _createLine(d?: StorymapSlide): VectorLayer {
1136
+ return new VectorLayer({
1137
+ source: new VectorSource({ features: [] }),
1138
+ // re-render vector geometry during view animations: without it
1139
+ // the animated line drawing is only painted after the
1140
+ // transition finishes
1141
+ updateWhileAnimating: true,
1142
+ style: this._lineStyle(this.options.line_color),
1143
+ });
1144
+ }
1145
+
1146
+ _addLineToMap(line: VectorLayer): void {
1147
+ // honor the show_lines option
1148
+ line.setVisible(this.options.show_lines);
1149
+ this._map.addLayer(line);
1150
+ }
1151
+
1152
+ _addToLine(line: VectorLayer, d: LinePoint): void {
1153
+ // Append a point to the line's geometry
1154
+ const source = line.getSource();
1155
+ let feature = source.getFeatures()[0];
1156
+ if (!feature) {
1157
+ feature = new Feature({ geometry: new LineString([]) });
1158
+ source.addFeature(feature);
1159
+ }
1160
+ const coords = feature.getGeometry().getCoordinates();
1161
+ let lon = d.location.lon;
1162
+ // unwrap dateline crossings against the previous point (issue #381).
1163
+ // NB: read the previous longitude straight from meters — toLonLat()
1164
+ // wraps into [-180, 180] and would collapse already-unwrapped values.
1165
+ if (coords.length > 0 && lon !== undefined) {
1166
+ const last_lon = coords[coords.length - 1][0] / 111319.49079327358;
1167
+ while (lon - last_lon > 180) lon -= 360;
1168
+ while (lon - last_lon < -180) lon += 360;
1169
+ }
1170
+ coords.push(this._toViewCoords({ lat: d.location.lat, lon }));
1171
+ feature.getGeometry().setCoordinates(coords);
1172
+ }
1173
+
1174
+ /**
1175
+ * Replace a line's geometry. With `animate.duration > 0` only the latest
1176
+ * hop animates, in sync with the view animation: the already-traveled
1177
+ * prefix stays drawn while the new segment traces progressively, so half
1178
+ * way through the pan the old connections are fully red and only half
1179
+ * the new hop is. Backward navigation mirrors this: the traveled prefix
1180
+ * stays drawn while the far end pulls back along the abandoned hop.
1181
+ */
1182
+ _replaceLines(
1183
+ line: VectorLayer,
1184
+ array: LinePoint[],
1185
+ animate?: {
1186
+ duration: number;
1187
+ retractFrom?: LinePoint[] | null;
1188
+ growFrom?: LinePoint[] | null;
1189
+ },
1190
+ ): void {
1191
+ const toLonLat = (d: LinePoint): number[] => {
1192
+ const lat = d.location ? d.location.lat : d.lat;
1193
+ const lon = d.location ? d.location.lon : d.lon;
1194
+ return [lon as number, lat as number];
1195
+ };
1196
+ const pts = array.map(toLonLat);
1197
+ const lons = this._unwrapLongitudes(pts.map((p) => p[0] as number));
1198
+ const unwrapped = pts.map((p, i) => [lons[i], p[1]]);
1199
+ const view_coords = this._markerCoordsToViewCoords(unwrapped);
1200
+ const source = line.getSource();
1201
+
1202
+ const setGeometry = (coords: number[][]) => {
1203
+ source.clear();
1204
+ source.addFeature(new Feature({ geometry: new LineString(coords) }));
1205
+ };
1206
+
1207
+ this._cancelLineAnimation();
1208
+
1209
+ const duration = animate?.duration ?? 0;
1210
+ if (duration <= 0 || view_coords.length < 2) {
1211
+ setGeometry(view_coords);
1212
+ return;
1213
+ }
1214
+
1215
+ // The already-traveled prefix, as a leading subsequence of the
1216
+ // target path: verified point-by-point so the shared joint cannot
1217
+ // drift (both go through the same unwrap + view projection).
1218
+ // Falls back to 0 (whole-path animation) on any mismatch.
1219
+ const leadingLength = (candidate: LinePoint[]): number => {
1220
+ const raw = candidate.map(toLonLat);
1221
+ if (raw.length < 1 || raw.length >= unwrapped.length) {
1222
+ return 0;
1223
+ }
1224
+ const cl = this._unwrapLongitudes(raw.map((p) => p[0] as number));
1225
+ for (let i = 0; i < raw.length; i++) {
1226
+ if (cl[i] !== unwrapped[i][0] || raw[i][1] !== unwrapped[i][1]) {
1227
+ return 0;
1228
+ }
1229
+ }
1230
+ return raw.length;
1231
+ };
1232
+
1233
+ // Retraction (backward navigation): the animation path is the route
1234
+ // up to the previous marker (retractFrom); the traveled prefix
1235
+ // [0..current] stays drawn while the abandoned tail shrinks back to
1236
+ // the joint — the far end pulls back from the old marker to the new
1237
+ // one.
1238
+ const retract_raw = (animate?.retractFrom ?? []).map(toLonLat);
1239
+ let tail: number[][] | null = null;
1240
+ if (retract_raw.length > 0) {
1241
+ const rl = this._unwrapLongitudes(retract_raw.map((p) => p[0] as number));
1242
+ const retract_unwrapped = retract_raw.map((p, i) => [rl[i], p[1]]);
1243
+ // The target must be the leading subsequence of the retraction
1244
+ // path; otherwise fall back to whole-path truncation below.
1245
+ let match = unwrapped.length >= 2 && unwrapped.length < retract_unwrapped.length;
1246
+ for (let i = 0; match && i < unwrapped.length; i++) {
1247
+ if (
1248
+ retract_unwrapped[i][0] !== unwrapped[i][0] ||
1249
+ retract_unwrapped[i][1] !== unwrapped[i][1]
1250
+ ) {
1251
+ match = false;
1252
+ }
1253
+ }
1254
+ if (match) {
1255
+ tail = this._markerCoordsToViewCoords(retract_unwrapped).slice(
1256
+ unwrapped.length - 1,
1257
+ );
1258
+ }
1259
+ }
1260
+ const tail_total = tail ? this._pathLength(tail) : 0;
1261
+
1262
+ // Growth (forward navigation): the already-traveled prefix
1263
+ // [0..previous] (growFrom) stays drawn while only the new hop
1264
+ // [prev..current] traces progressively.
1265
+ const grow_len = leadingLength(animate?.growFrom ?? []);
1266
+ const prefix = grow_len >= 1 ? view_coords.slice(0, grow_len) : null;
1267
+ const suffix = grow_len >= 1 ? view_coords.slice(grow_len - 1) : null;
1268
+ const suffix_total = suffix ? this._pathLength(suffix) : 0;
1269
+
1270
+ const easing = this.options.ease as ((t: number) => number) | undefined;
1271
+ const start_time = performance.now();
1272
+ if (prefix) {
1273
+ // Seed the already-traveled prefix so it stays red from frame 0
1274
+ // (also heals a partially-drawn line when a running animation
1275
+ // is cancelled by rapid stepping).
1276
+ setGeometry(prefix);
1277
+ }
1278
+ const step = (now: number) => {
1279
+ const t = Math.min(1, Math.max(0, (now - start_time) / duration));
1280
+ const eased = easing ? easing(t) : t;
1281
+ if (tail && tail_total > 0) {
1282
+ // pull the far end back along the abandoned hop
1283
+ const drawn = tail_total - eased * tail_total;
1284
+ const drawn_tail = this._truncatePath(tail, drawn);
1285
+ setGeometry([...view_coords, ...drawn_tail.slice(1)]);
1286
+ if (t >= 1) {
1287
+ setGeometry(view_coords);
1288
+ }
1289
+ } else if (prefix && suffix && suffix_total > 0) {
1290
+ const drawn_suffix = this._truncatePath(suffix, eased * suffix_total);
1291
+ setGeometry([...prefix, ...drawn_suffix.slice(1)]);
1292
+ if (t >= 1) {
1293
+ setGeometry(view_coords);
1294
+ }
1295
+ } else {
1296
+ const total_length = this._pathLength(view_coords);
1297
+ setGeometry(this._truncatePath(view_coords, eased * total_length));
1298
+ }
1299
+ this._line_animation = t < 1 ? requestAnimationFrame(step) : null;
1300
+ };
1301
+ this._line_animation = requestAnimationFrame(step);
1302
+ }
1303
+
1304
+ /** Cancel a running active-line draw animation. */
1305
+ _cancelLineAnimation(): void {
1306
+ if (this._line_animation !== null) {
1307
+ cancelAnimationFrame(this._line_animation);
1308
+ this._line_animation = null;
1309
+ }
1310
+ }
1311
+
1312
+ /** Total euclidean length of a path in view coordinates. */
1313
+ _pathLength(coords: number[][]): number {
1314
+ let total = 0;
1315
+ for (let i = 1; i < coords.length; i++) {
1316
+ total += Math.hypot(coords[i][0] - coords[i - 1][0], coords[i][1] - coords[i - 1][1]);
1317
+ }
1318
+ return total;
1319
+ }
1320
+
1321
+ /**
1322
+ * The prefix of the path up to `length` (in view units), with the cut
1323
+ * point interpolated inside its segment.
1324
+ */
1325
+ _truncatePath(coords: number[][], length: number): number[][] {
1326
+ if (length <= 0 || coords.length < 2) {
1327
+ return [];
1328
+ }
1329
+ const out = [coords[0]];
1330
+ let acc = 0;
1331
+ for (let i = 1; i < coords.length; i++) {
1332
+ const seg = Math.hypot(
1333
+ coords[i][0] - coords[i - 1][0],
1334
+ coords[i][1] - coords[i - 1][1],
1335
+ );
1336
+ if (acc + seg >= length) {
1337
+ const f = (length - acc) / seg;
1338
+ out.push([
1339
+ coords[i - 1][0] + f * (coords[i][0] - coords[i - 1][0]),
1340
+ coords[i - 1][1] + f * (coords[i][1] - coords[i - 1][1]),
1341
+ ]);
1342
+ return out;
1343
+ }
1344
+ acc += seg;
1345
+ out.push(coords[i]);
1346
+ }
1347
+ return out;
1348
+ }
1349
+
1350
+ /* Map
1351
+ ================================================== */
1352
+ _panTo(loc: LatLngLiteral, animate?: boolean): void {
1353
+ this._map.getView().animate({
1354
+ center: this._toViewCoords(loc),
1355
+ duration: this.options.duration,
1356
+ easing: this.options.ease as ((t: number) => number) | undefined,
1357
+ });
1358
+ }
1359
+
1360
+ _zoomTo(z: number, animate?: boolean): void {
1361
+ this._map.getView().animate({
1362
+ zoom: z,
1363
+ duration: this.options.duration,
1364
+ easing: this.options.ease as ((t: number) => number) | undefined,
1365
+ });
1366
+ }
1367
+
1368
+ _viewTo(loc: StorymapSlideLocation, opts?: ViewToOptions): void {
1369
+ let _animate = true,
1370
+ _duration = this.options.duration,
1371
+ _zoom = this._getMapZoom(),
1372
+ _location: LatLngLiteral = { lat: loc.lat, lon: loc.lon };
1373
+
1374
+ // Show Active Line
1375
+ if (!this.options.map_as_image) {
1376
+ this._line_active.setVisible(true);
1377
+ }
1378
+
1379
+ if (loc.zoom !== undefined && loc.zoom !== null) {
1380
+ _zoom = loc.zoom;
1381
+ }
1382
+
1383
+ // Options
1384
+ if (opts) {
1385
+ if (opts.duration !== undefined) {
1386
+ if (opts.duration === 0) {
1387
+ _animate = false;
1388
+ } else {
1389
+ _duration = opts.duration;
1390
+ }
1391
+ }
1392
+
1393
+ if (opts.zoom && this.options.calculate_zoom) {
1394
+ _zoom = opts.zoom;
1395
+ }
1396
+ }
1397
+
1398
+ // OFFSET
1399
+ if (this.options.map_center_offset) {
1400
+ _location = this._getMapCenterOffset(_location, _zoom);
1401
+ }
1402
+
1403
+ this._map.getView().animate({
1404
+ center: this._toViewCoords(_location),
1405
+ zoom: _zoom,
1406
+ duration: _animate ? _duration : 0,
1407
+ easing: this.options.ease as ((t: number) => number) | undefined,
1408
+ });
1409
+
1410
+ if (this._mini_map && this.options.width > this.options.skinny_size) {
1411
+ if (_zoom - 1 <= this.zoom_min_max.min) {
1412
+ this._mini_map.setCollapsed(true);
1413
+ } else {
1414
+ this._mini_map.setCollapsed(false);
1415
+ }
1416
+ }
1417
+ }
1418
+
1419
+ _toViewCoords(loc: LatLngLiteral): number[] {
1420
+ const is_image_space = this._map.getView().getProjection().getCode() === "EPSG:4326";
1421
+ if (is_image_space) return [loc.lon, loc.lat];
1422
+ return fromLonLat([loc.lon, loc.lat]);
1423
+ }
1424
+
1425
+ _getMapZoom(): number {
1426
+ // fractional zoom on purpose: overview fits produce non-integer zooms and
1427
+ // rounding here would snap the view on the next navigation
1428
+ return this._map.getView().getZoom() || 0;
1429
+ }
1430
+
1431
+ _getMapCenter(offset?: boolean): LatLngLiteral {
1432
+ const center = toLonLat(
1433
+ this._map.getView().getCenter(),
1434
+ this._map.getView().getProjection(),
1435
+ );
1436
+ return { lat: center[1], lon: center[0] };
1437
+ }
1438
+
1439
+ _getMapCenterOffset(location: LatLngLiteral, zoom: number): LatLngLiteral {
1440
+ // Offset the center by map_center_offset pixels at the given zoom
1441
+ const view = this._map.getView();
1442
+ const projection = view.getProjection();
1443
+ const center = this._toViewCoords(location);
1444
+ const resolution = view.getResolutionForZoom(zoom);
1445
+ return this._fromViewCoords(
1446
+ [
1447
+ center[0] - this.options.map_center_offset.left * resolution,
1448
+ center[1] + this.options.map_center_offset.top * resolution,
1449
+ ],
1450
+ projection,
1451
+ );
1452
+ }
1453
+
1454
+ _fromViewCoords(coord: number[], projection: Projection): LatLngLiteral {
1455
+ if (projection.getCode() === "EPSG:4326") return { lat: coord[1], lon: coord[0] };
1456
+ // linear lon inversion without dateline wrapping (issue #381);
1457
+ // lat uses the exact mercator inverse (6378137 = the 3857 sphere radius)
1458
+ const c = toLonLat(coord, projection);
1459
+ return { lat: c[1], lon: (coord[0] / 6378137) * (180 / Math.PI) };
1460
+ }
1461
+
1462
+ _getBoundsZoom(
1463
+ origin: LatLngLiteral,
1464
+ destination: LatLngLiteral,
1465
+ correct_for_center?: boolean,
1466
+ ): number {
1467
+ const coords = [
1468
+ [origin.lon !== undefined ? origin.lon : origin.lng, origin.lat],
1469
+ [destination.lon, destination.lat],
1470
+ ];
1471
+ const view_coords = this._markerCoordsToViewCoords(coords);
1472
+ const extent = boundingExtent(view_coords);
1473
+ const size = this._map.getSize();
1474
+ if (!size || !size[0] || !size[1]) return 0;
1475
+
1476
+ const resolution_x = (extent[2] - extent[0]) / size[0];
1477
+ const resolution_y = (extent[3] - extent[1]) / size[1];
1478
+ const resolution = Math.max(resolution_x, resolution_y) * 3; // padding factor
1479
+ if (!isFinite(resolution) || resolution <= 0) return 0;
1480
+
1481
+ const z = this._map.getView().getZoomForResolution(resolution);
1482
+ if (z === undefined || !isFinite(z)) return 0;
1483
+ return Math.max(0, Math.round(z));
1484
+ }
1485
+
1486
+ _initialMapLocation(): void {
1487
+ // OpenLayers renders independently; nothing to subscribe for initial location
1488
+ }
1489
+
1490
+ /**
1491
+ * Great-circle (haversine) length of the route through all markers in
1492
+ * kilometers (issue #341).
1493
+ */
1494
+ getRouteDistance(): number | undefined {
1495
+ const coords = this._markers
1496
+ .map((m) => m.location())
1497
+ .filter((l): l is LatLngLiteral => !!l && isFinite(l.lat) && isFinite(l.lon));
1498
+ if (coords.length < 2) {
1499
+ return undefined;
1500
+ }
1501
+ const R = 6371;
1502
+ let total = 0;
1503
+ for (let i = 1; i < coords.length; i++) {
1504
+ const d_lat = ((coords[i].lat - coords[i - 1].lat) * Math.PI) / 180;
1505
+ const d_lon = ((coords[i].lon - coords[i - 1].lon) * Math.PI) / 180;
1506
+ const a =
1507
+ Math.sin(d_lat / 2) ** 2 +
1508
+ Math.cos((coords[i - 1].lat * Math.PI) / 180) *
1509
+ Math.cos((coords[i].lat * Math.PI) / 180) *
1510
+ Math.sin(d_lon / 2) ** 2;
1511
+ total += 2 * R * Math.asin(Math.sqrt(a));
1512
+ }
1513
+ return total;
1514
+ }
1515
+
1516
+ _markerOverview(duration?: number): void {
1517
+ // Hide Active Line
1518
+ this._cancelLineAnimation();
1519
+ this._line_active.setVisible(false);
1520
+
1521
+ // repeated presses while an overview animation is running snap
1522
+ // instantly instead of restarting the ~1s animation
1523
+ if (duration && this._map.getView().getAnimating()) {
1524
+ duration = 0;
1525
+ }
1526
+
1527
+ if (this.options.map_type === "zoomify") {
1528
+ // legacy zoomify: show the whole image centered at the best-fit
1529
+ // pyramid level (the original renderer's overview); with the
1530
+ // panel offset the legacy renderer drops one zoom level
1531
+ const size = this._map.getSize();
1532
+ const overview = this._zoomifyOverview([size?.[0] || 1280, size?.[1] || 450]);
1533
+ if (overview) {
1534
+ const offset =
1535
+ this.options.map_center_offset &&
1536
+ (this.options.map_center_offset.left !== 0 ||
1537
+ this.options.map_center_offset.top !== 0);
1538
+ // with the panel offset the legacy renderer drops one zoom
1539
+ // level (the mercator zoom = the pyramid level)
1540
+ const view_zoom = offset ? overview.zoom - 1 : overview.zoom;
1541
+ // offset the center directly in view coords (a lat/lon
1542
+ // round-trip would corrupt the zoomify target)
1543
+ const resolution = this._map.getView().getResolutionForZoom(view_zoom);
1544
+ const center_view = [
1545
+ overview.center[0] - (this.options.map_center_offset?.left ?? 0) * resolution,
1546
+ overview.center[1] + (this.options.map_center_offset?.top ?? 0) * resolution,
1547
+ ];
1548
+ this._map.getView().animate({
1549
+ center: center_view,
1550
+ zoom: view_zoom,
1551
+ duration: duration ?? this._transition_duration,
1552
+ easing: this.options.ease as ((t: number) => number) | undefined,
1553
+ });
1554
+ }
1555
+ } else if (this.options.map_type === "iiif" && this.options.map_as_image) {
1556
+ const source = this._tile_layer?.getSource();
1557
+ if (!source) {
1558
+ return;
1559
+ }
1560
+ const fit = () => {
1561
+ try {
1562
+ const grid = source.getTileGrid();
1563
+ if (grid) {
1564
+ // compute the fit target directly and animate once (a
1565
+ // fit() followed by setCenter() would cancel the fit
1566
+ // animation, issue #465)
1567
+ const extent = grid.getExtent();
1568
+ const size = this._map.getSize();
1569
+ const resolution_x = (extent[2] - extent[0]) / (size[0] || 1);
1570
+ const resolution_y = (extent[3] - extent[1]) / (size[1] || 1);
1571
+ const resolution = Math.max(resolution_x, resolution_y);
1572
+ const view = this._map.getView();
1573
+ const zoom = view.getZoomForResolution(resolution);
1574
+ const overview_center = this.options.map_overview_center;
1575
+ const center_px = overview_center
1576
+ ? this._toViewCoords({
1577
+ lat: overview_center.lat,
1578
+ lon: overview_center.lon,
1579
+ })
1580
+ : [(extent[0] + extent[2]) / 2, (extent[1] + extent[3]) / 2];
1581
+ const projection = view.getProjection();
1582
+ const location = this._fromViewCoords(center_px, projection);
1583
+ const offset_location = this._getMapCenterOffset(location, zoom);
1584
+ view.animate({
1585
+ center: this._toViewCoords(offset_location),
1586
+ zoom: zoom,
1587
+ duration: duration ?? this._transition_duration,
1588
+ easing: this.options.ease as ((t: number) => number) | undefined,
1589
+ });
1590
+ }
1591
+ } catch (e) {
1592
+ console.warn("IIIF overview fit failed:", e);
1593
+ }
1594
+ };
1595
+
1596
+ if (source.getState() === "ready") {
1597
+ fit();
1598
+ } else {
1599
+ source.once("change", () => {
1600
+ if (source.getState() === "ready") fit();
1601
+ });
1602
+ }
1603
+ } else {
1604
+ this.bounds_array = this._getAllMarkersBounds(this._markers);
1605
+
1606
+ // user-selected overview centerpoint (issues #107, #271): fit the
1607
+ // zoom to the markers but center on the configured location
1608
+ const overview_center = this.options.map_overview_center;
1609
+ if (overview_center && this.bounds_array && this.bounds_array.length) {
1610
+ const view_coords = this._markerCoordsToViewCoords(this.bounds_array);
1611
+ const extent = boundingExtent(view_coords);
1612
+ const size = this._map.getSize();
1613
+ const resolution = Math.max(
1614
+ (extent[2] - extent[0]) / (size[0] || 1),
1615
+ (extent[3] - extent[1]) / (size[1] || 1),
1616
+ );
1617
+ const zoom = Math.max(
1618
+ 0,
1619
+ Math.round(this._map.getView().getZoomForResolution(resolution)) - 1,
1620
+ );
1621
+ const offset_location = this._getMapCenterOffset(
1622
+ { lat: overview_center.lat, lon: overview_center.lon },
1623
+ zoom,
1624
+ );
1625
+ this._map.getView().animate({
1626
+ center: this._toViewCoords(offset_location),
1627
+ zoom: zoom,
1628
+ duration: duration ?? this._transition_duration,
1629
+ easing: this.options.ease as ((t: number) => number) | undefined,
1630
+ });
1631
+ } else if (
1632
+ this.options.map_center_offset &&
1633
+ (this.options.map_center_offset.left !== 0 ||
1634
+ this.options.map_center_offset.top !== 0)
1635
+ ) {
1636
+ if (this.bounds_array && this.bounds_array.length) {
1637
+ const view_coords = this._markerCoordsToViewCoords(this.bounds_array);
1638
+ const extent = boundingExtent(view_coords);
1639
+ const size = this._map.getSize();
1640
+ const resolution_x = (extent[2] - extent[0]) / (size[0] || 1);
1641
+ const resolution_y = (extent[3] - extent[1]) / (size[1] || 1);
1642
+ const resolution = Math.max(resolution_x, resolution_y);
1643
+ const zoom = Math.max(
1644
+ 0,
1645
+ Math.round(this._map.getView().getZoomForResolution(resolution)) - 1,
1646
+ );
1647
+ const center_px = [(extent[0] + extent[2]) / 2, (extent[1] + extent[3]) / 2];
1648
+ const projection = this._map.getView().getProjection();
1649
+ const location = this._fromViewCoords(center_px, projection);
1650
+ const offset_location = this._getMapCenterOffset(location, zoom);
1651
+ this._map.getView().animate({
1652
+ center: this._toViewCoords(offset_location),
1653
+ zoom: zoom,
1654
+ duration: duration ?? this._transition_duration,
1655
+ easing: this.options.ease as ((t: number) => number) | undefined,
1656
+ });
1657
+ }
1658
+ } else {
1659
+ // fit instantly, then shift the center by the panel offset
1660
+ // and animate there so the markers clear the story panel;
1661
+ // with the strict bbox layout markers outside of the box are
1662
+ // unreachable — they must not skew the fit target
1663
+ this._fitView(this._map, this.bounds_array, 0, this._bboxExtent() !== null);
1664
+ const view = this._map.getView();
1665
+ const zoom = view.getZoom();
1666
+ if (zoom !== undefined) {
1667
+ const center = this._fromViewCoords(view.getCenter(), view.getProjection());
1668
+ const offset_location = this._getMapCenterOffset(center, zoom);
1669
+ view.animate({
1670
+ center: this._toViewCoords(offset_location),
1671
+ zoom: zoom,
1672
+ duration: duration ?? this._transition_duration,
1673
+ easing: this.options.ease as ((t: number) => number) | undefined,
1674
+ });
1675
+ }
1676
+ }
1677
+ }
1678
+
1679
+ if (this._mini_map) {
1680
+ this._mini_map.setCollapsed(true);
1681
+ }
1682
+ }
1683
+
1684
+ /**
1685
+ * Swap the minimap's layer for the current `map_type` (mirrors the
1686
+ * initial `_createMiniMap` fitting: zoomify extent, IIIF image extent,
1687
+ * otherwise the marker bounds). No-op when no minimap exists yet.
1688
+ */
1689
+ _refreshMiniMapLayer(): void {
1690
+ if (!this._mini_map) return;
1691
+ const overview = this._mini_map.getOverviewMap();
1692
+ overview.getLayers().clear();
1693
+ const consent = consentManagerOf(this.options);
1694
+ const tile_service = consentMessage("consent_service_tiles", "map tiles");
1695
+ const tiles_allowed = !(
1696
+ this.options.consent_required &&
1697
+ consent &&
1698
+ !consent.isGranted(tile_service)
1699
+ );
1700
+ if (!tiles_allowed) {
1701
+ this._tile_layer_mini = null;
1702
+ return;
1703
+ }
1704
+ this._tile_layer_mini = this._createTileLayer(this.options.map_type);
1705
+ overview.addLayer(this._tile_layer_mini);
1706
+ const is_zoomify = this.options.map_type === "zoomify";
1707
+ const zoomify_pyramid = is_zoomify ? this._zoomifyPyramid() : null;
1708
+ if (zoomify_pyramid) {
1709
+ const raw_size = overview.getSize();
1710
+ const size =
1711
+ raw_size && raw_size[0] >= 50 && raw_size[1] >= 50 ? raw_size : [150, 100];
1712
+ overview.getView().fit(zoomify_pyramid.extent, { size: size });
1713
+ } else if (this.bounds_array && this.bounds_array.length) {
1714
+ this._fitView(overview, this.bounds_array, 0, this._bboxExtent() !== null);
1715
+ }
1716
+ if (this.options.map_type === "iiif" && this.options.map_as_image) {
1717
+ this._fitMiniMapToImage();
1718
+ }
1719
+ }
1720
+
1721
+ /* Display
1722
+ ================================================== */
1723
+ /**
1724
+ * Re-apply runtime-changed options (driven by StoryMap.setMapOptions).
1725
+ * Only keys with an immediate effect are handled here; everything else is
1726
+ * picked up on the next navigation or layout pass.
1727
+ */
1728
+ applyOptions(keys: string[]): void {
1729
+ for (const key of keys) {
1730
+ switch (key) {
1731
+ case "map_type": {
1732
+ // Rebuild the main + minimap tile layers for the new type
1733
+ if (this._tile_layer) {
1734
+ this._map.removeLayer(this._tile_layer);
1735
+ }
1736
+ const consent = consentManagerOf(this.options);
1737
+ const tile_service = consentMessage("consent_service_tiles", "map tiles");
1738
+ if (!(
1739
+ this.options.consent_required &&
1740
+ consent &&
1741
+ !consent.isGranted(tile_service)
1742
+ )) {
1743
+ this._tile_layer = this._createTileLayer(this.options.map_type);
1744
+ this._tile_layer.setZIndex(0);
1745
+ this._map.addLayer(this._tile_layer);
1746
+ }
1747
+ this._refreshMiniMapLayer();
1748
+ this._updateAttribution();
1749
+ this._el.map.style.backgroundColor = this.options.map_background_color;
1750
+ break;
1751
+ }
1752
+ case "overlays": {
1753
+ // Rebuild the stacked overlays from the new option value
1754
+ this._buildOverlays();
1755
+ break;
1756
+ }
1757
+ case "show_lines":
1758
+ case "line_color":
1759
+ case "line_color_inactive":
1760
+ case "line_weight":
1761
+ case "line_opacity":
1762
+ case "line_dash":
1763
+ case "line_join": {
1764
+ const stroke = (color: string) => this._lineStyle(color);
1765
+ this._line.setStyle(stroke(this.options.line_color_inactive));
1766
+ this._line.setOpacity(this.options.line_opacity);
1767
+ this._line.setVisible(this.options.show_lines);
1768
+ this._line_active.setStyle(stroke(this.options.line_color));
1769
+ this._line_active.setVisible(this.options.show_lines);
1770
+ break;
1771
+ }
1772
+ case "map_background_color":
1773
+ this._el.map.style.backgroundColor = this.options.map_background_color;
1774
+ break;
1775
+ case "map_bbox": {
1776
+ // recreate the view so the new extent constraint applies,
1777
+ // preserving center and zoom
1778
+ const view = this._map.getView();
1779
+ const center = view.getCenter();
1780
+ const zoom = view.getZoom();
1781
+ const extent = this._bboxExtent();
1782
+ this._map.setView(
1783
+ new View({
1784
+ projection: view.getProjection(),
1785
+ center: center,
1786
+ zoom: zoom,
1787
+ minZoom: view.getMinZoom(),
1788
+ maxZoom: view.getMaxZoom(),
1789
+ ...(extent ? { extent: extent } : {}),
1790
+ }),
1791
+ );
1792
+ break;
1793
+ }
1794
+ default:
1795
+ // map_center_offset, duration, ease, calculate_zoom etc.
1796
+ // take effect on the next navigation
1797
+ break;
1798
+ }
1799
+ }
1800
+ // Re-fit the current view in case layer changes altered the rendering
1801
+ if (this._markers.length > 0 && this.current_marker < this._markers.length) {
1802
+ const marker = this._markers[this.current_marker];
1803
+ if (marker.data.type === "overview") {
1804
+ this._markerOverview();
1805
+ } else if (marker.data.location) {
1806
+ this._viewTo(marker.data.location, { duration: 0 });
1807
+ }
1808
+ }
1809
+ }
1810
+
1811
+ _updateMapDisplay(animate?: boolean, d?: number, instant?: boolean): void {
1812
+ if (animate) {
1813
+ const duration = d ? d : this.options.duration;
1814
+ if (this.timer) {
1815
+ clearTimeout(this.timer);
1816
+ }
1817
+
1818
+ this.timer = setTimeout(() => {
1819
+ this._refreshMap(false);
1820
+ }, duration);
1821
+ } else {
1822
+ if (!this.timer) {
1823
+ // size changes (resize/fullscreen) re-fit instantly so marker
1824
+ // overlays don't chase an animated view
1825
+ this._refreshMap(instant !== false);
1826
+ }
1827
+ }
1828
+
1829
+ if (this._mini_map) {
1830
+ this._mini_map.setCollapsed(this._el.container.offsetWidth < this.options.skinny_size);
1831
+ }
1832
+ }
1833
+
1834
+ _refreshMap(instant = false): void {
1835
+ if (this._map) {
1836
+ if (this.timer) {
1837
+ clearTimeout(this.timer);
1838
+ this.timer = null;
1839
+ }
1840
+
1841
+ this._map.updateSize();
1842
+
1843
+ // Check to see if it's an overview
1844
+ const marker = this._markers[this.current_marker];
1845
+ if (marker && marker.data.type && marker.data.type === "overview") {
1846
+ this._markerOverview(instant ? 0 : undefined);
1847
+ if (instant) {
1848
+ this._map.renderSync();
1849
+ }
1850
+ } else if (marker && marker.data.location) {
1851
+ if (instant) {
1852
+ this._setViewInstant(marker.data.location, this._getMapZoom());
1853
+ } else {
1854
+ this._viewTo(marker.data.location, {
1855
+ zoom: this._getMapZoom(),
1856
+ });
1857
+ }
1858
+ }
1859
+ }
1860
+ }
1861
+
1862
+ /**
1863
+ * Set the view synchronously (resize path): OL's animate() applies its
1864
+ * end state asynchronously which can leave marker overlays rendering
1865
+ * stale positions after a size change.
1866
+ */
1867
+ _setViewInstant(loc: StorymapSlideLocation, zoom: number): void {
1868
+ let _location: LatLngLiteral = { lat: loc.lat, lon: loc.lon };
1869
+ if (this.options.map_center_offset) {
1870
+ _location = this._getMapCenterOffset(_location, zoom);
1871
+ }
1872
+ const view = this._map.getView();
1873
+ view.setZoom(zoom);
1874
+ view.setCenter(this._toViewCoords(_location));
1875
+ this._map.renderSync();
1876
+ }
1877
+ }
1878
+
1879
+ export { OpenLayers };