@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
@@ -3,6 +3,7 @@ import { fromLonLat } from "ol/proj";
3
3
  import type { Map as OlMap } from "ol";
4
4
  import MapMarker from "../MapMarker";
5
5
  import { clearTimer } from "../../core/Util";
6
+ import { Language } from "../../language/Language";
6
7
  import { sanitizeSlideText } from "../../media/EmbedUtil";
7
8
  import type { LatLngLiteral, MapMarkerData, StorymapOptions } from "../../types";
8
9
 
@@ -27,6 +28,10 @@ interface MarkerPresentation {
27
28
  export default class OpenLayersMapMarker extends MapMarker {
28
29
  declare "_overlay": Overlay;
29
30
  declare "_onMarkerClickBound": ((e: Event) => void) | null;
31
+ /** Enter/Space activation for keyboard visitors, detached in dispose(). */
32
+ declare "_onMarkerKeyBound": ((e: KeyboardEvent) => void) | null;
33
+ /** Base accessible name (slide headline or label), without audio suffix. */
34
+ declare "_marker_label": string;
30
35
  /** marker.popup: the active marker opens its card when clicked. */
31
36
  declare "_popup_enabled": boolean;
32
37
  /** marker.audioBadge: flag a slide that has narration or audio media. */
@@ -48,7 +53,7 @@ export default class OpenLayersMapMarker extends MapMarker {
48
53
  ================================================== */
49
54
  /**
50
55
  * The merged marker presentation: `marker.*` wins, `location.*` is the
51
- * legacy spelling and still works (docs/plans/iiif-media-tours.md §2).
56
+ * legacy spelling and still works.
52
57
  * `location` also stays the carrier of geography, so lat/lon are read
53
58
  * from it alone — presentation never moves a marker.
54
59
  */
@@ -88,6 +93,15 @@ export default class OpenLayersMapMarker extends MapMarker {
88
93
  }
89
94
 
90
95
  this._marker = this._createMarkerElement(d as MapMarkerData, o);
96
+ // keyboard route to the same behaviour as a click: focusable,
97
+ // announced as a button, Enter/Space activate
98
+ const text = (d as MapMarkerData)?.text;
99
+ const headline =
100
+ typeof text?.headline === "string" && text.headline !== "" ? text.headline : null;
101
+ this._marker_label = presentation.label ?? headline ?? "Marker";
102
+ this._marker.setAttribute("tabindex", "0");
103
+ this._marker.setAttribute("role", "button");
104
+ this._marker.setAttribute("aria-label", this._marker_label);
91
105
  // kept as a field so dispose() can detach it; the inline arrow
92
106
  // had no other handle
93
107
  this._onMarkerClickBound = (e: Event) => {
@@ -102,6 +116,23 @@ export default class OpenLayersMapMarker extends MapMarker {
102
116
  this._onMarkerClick(e);
103
117
  };
104
118
  this._marker.addEventListener("click", this._onMarkerClickBound);
119
+ // Keyboard visitors get the click behaviour: Enter/Space
120
+ // activates, and an opened card takes focus so its close button
121
+ // is reachable without a pointer.
122
+ this._onMarkerKeyBound = (e: KeyboardEvent) => {
123
+ if (e.key !== "Enter" && e.key !== " " && e.key !== "Spacebar") return;
124
+ e.preventDefault();
125
+ const wasOpen = this.popupOpen;
126
+ this._onMarkerClickBound?.(e);
127
+ if (!wasOpen && this.popupOpen) {
128
+ (
129
+ this._popup_el?.querySelector(
130
+ ".vco-marker-popup-close",
131
+ ) as HTMLElement | null
132
+ )?.focus();
133
+ }
134
+ };
135
+ this._marker.addEventListener("keydown", this._onMarkerKeyBound);
105
136
  // Escape closes the card; the listener is on the document and is
106
137
  // detached in dispose(), so a torn-down map leaves nothing behind
107
138
  this._onPopupKeyBound = (e: KeyboardEvent) => {
@@ -122,6 +153,8 @@ export default class OpenLayersMapMarker extends MapMarker {
122
153
  el.className = "vco-mapmarker-custom";
123
154
  const img = document.createElement("img");
124
155
  img.src = this._custom_icon.url;
156
+ // decorative: the parent div carries the marker's title
157
+ img.alt = "";
125
158
  img.style.width = this._custom_icon.size[0] + "px";
126
159
  img.style.height = "auto";
127
160
  el.appendChild(img);
@@ -131,6 +164,7 @@ export default class OpenLayersMapMarker extends MapMarker {
131
164
  el.className = "vco-mapmarker-image-icon";
132
165
  const img = document.createElement("img");
133
166
  img.src = this._custom_image_icon;
167
+ img.alt = "";
134
168
  img.style.width = "48px";
135
169
  img.style.height = "auto";
136
170
  el.appendChild(img);
@@ -210,23 +244,69 @@ export default class OpenLayersMapMarker extends MapMarker {
210
244
  * marker's, and that is `latLon()`.
211
245
  */
212
246
  _togglePopup(): void {
213
- if (!this._popup_enabled) return;
214
- if (this._popup_el) {
215
- this._closePopup();
216
- return;
217
- }
247
+ this.togglePopup();
248
+ }
249
+
250
+ _closePopup(): void {
251
+ this.closePopup();
252
+ }
253
+
254
+ /** True when this marker is configured to show a popup card. */
255
+ isPopupEnabled(): boolean {
256
+ return this._popup_enabled === true;
257
+ }
258
+
259
+ /**
260
+ * Open the popup card for this marker.
261
+ *
262
+ * Returns false (and opens nothing) when popups are disabled for the
263
+ * marker or it is not a real map marker — e.g. an overview slide. A
264
+ * click on an inactive marker navigates instead of opening (see the
265
+ * click handler in `_createMarker`); programmatic callers that want the
266
+ * card on another stop should navigate first (`StoryMap.openMarkerPopup`
267
+ * does exactly that). Fires `popupopen` on success.
268
+ */
269
+ openPopup(): boolean {
270
+ if (!this._popup_enabled) return false;
271
+ if (!this.data.real_marker) return false;
272
+ if (this._popup_el) return true;
218
273
  const el = this._createPopupElement();
219
- if (el === null) return;
274
+ if (el === null) return false;
220
275
  this._marker.appendChild(el);
221
276
  this._popup_el = el;
222
277
  this._marker.classList.add("vco-mapmarker-popup-open");
278
+ this.fire("popupopen", { marker_number: this.marker_number });
279
+ return true;
223
280
  }
224
281
 
225
- _closePopup(): void {
282
+ /**
283
+ * Close the popup card if it is open. Fires `popupclose`. Also runs on
284
+ * Escape, the card's close button, deactivation and `dispose()`.
285
+ */
286
+ closePopup(): void {
226
287
  if (!this._popup_el) return;
288
+ // keyboard visitors may be inside the card (its close button is
289
+ // focusable): hand focus back to the marker instead of dropping it
290
+ // on the page body. Mouse visitors never had focus here, so theirs
291
+ // is untouched.
292
+ const hadFocus = this._popup_el.contains(document.activeElement);
227
293
  this._popup_el.parentNode?.removeChild(this._popup_el);
228
294
  this._popup_el = null;
229
295
  this._marker?.classList?.remove("vco-mapmarker-popup-open");
296
+ if (hadFocus) this._marker?.focus();
297
+ this.fire("popupclose", { marker_number: this.marker_number });
298
+ }
299
+
300
+ /**
301
+ * Open the card when closed, close it when open. Returns whether the
302
+ * card is open afterwards (false when popups are disabled).
303
+ */
304
+ togglePopup(): boolean {
305
+ if (this._popup_el) {
306
+ this.closePopup();
307
+ return false;
308
+ }
309
+ return this.openPopup();
230
310
  }
231
311
 
232
312
  get popupOpen(): boolean {
@@ -244,6 +324,7 @@ export default class OpenLayersMapMarker extends MapMarker {
244
324
  const data = this.data as unknown as {
245
325
  text?: { headline?: string; text?: string };
246
326
  media?: { thumb?: string | null; url?: string | null; caption?: string | null };
327
+ media_extra?: { thumb?: string | null; url?: string | null; caption?: string | null }[];
247
328
  };
248
329
  const card = document.createElement("div");
249
330
  card.className = "vco-marker-popup";
@@ -262,11 +343,13 @@ export default class OpenLayersMapMarker extends MapMarker {
262
343
  });
263
344
  card.appendChild(close);
264
345
 
265
- if (data.media?.thumb) {
346
+ // first item with a thumb across the primary and the extras (#358)
347
+ const thumb_item = [data.media, ...(data.media_extra ?? [])].find((item) => !!item?.thumb);
348
+ if (thumb_item?.thumb) {
266
349
  const img = document.createElement("img");
267
350
  img.className = "vco-marker-popup-thumb";
268
- img.src = data.media.thumb;
269
- img.alt = data.media.caption ?? "";
351
+ img.src = thumb_item.thumb;
352
+ img.alt = thumb_item.caption ?? "";
270
353
  img.loading = "lazy";
271
354
  card.appendChild(img);
272
355
  }
@@ -297,9 +380,22 @@ export default class OpenLayersMapMarker extends MapMarker {
297
380
  _updateAudioBadge(): void {
298
381
  if (!this._audio_badge) return;
299
382
  const media = (this.data as unknown as { media?: { mediatype?: { type?: string } } }).media;
300
- const kind = media?.mediatype?.type;
301
- const audible = kind === "audio" || kind === "video" || !!this.data.narration;
383
+ // any audible item badges the marker, not just item 0: an
384
+ // image-first/audio-second slide is still an audible stop (#358)
385
+ const extra =
386
+ (this.data as unknown as { media_extra?: { mediatype?: { type?: string } }[] })
387
+ .media_extra ?? [];
388
+ const audible =
389
+ [media, ...extra].some((item) => {
390
+ const kind = item?.mediatype?.type;
391
+ return kind === "audio" || kind === "video";
392
+ }) || !!this.data.narration;
302
393
  this._marker.classList.toggle("vco-mapmarker-has-audio", audible);
394
+ const suffix =
395
+ audible && typeof Language.buttons.audio_badge === "string"
396
+ ? `, ${Language.buttons.audio_badge}`
397
+ : "";
398
+ this._marker.setAttribute("aria-label", `${this._marker_label}${suffix}`);
303
399
  }
304
400
 
305
401
  dispose(): void {
@@ -313,6 +409,10 @@ export default class OpenLayersMapMarker extends MapMarker {
313
409
  marker?.removeEventListener?.("click", this._onMarkerClickBound);
314
410
  this._onMarkerClickBound = null;
315
411
  }
412
+ if (this._onMarkerKeyBound) {
413
+ marker?.removeEventListener?.("keydown", this._onMarkerKeyBound);
414
+ this._onMarkerKeyBound = null;
415
+ }
316
416
  if (this._overlay) {
317
417
  this._overlay.setElement(undefined);
318
418
  this._overlay = null as unknown as Overlay;
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Pure view helpers for slideshow-driven slide presentation: rotation,
3
+ * CSS filter grading, spotlight masks and per-slide basemaps.
4
+ *
5
+ * DOM- and OpenLayers-free so the CLI and unit tests can use them; the map
6
+ * engine (`Map.OpenLayers`) applies their output. Every helper treats
7
+ * absent/mistyped input as "feature off", so stories that never mention the
8
+ * new fields render exactly as before.
9
+ */
10
+
11
+ import type { StorymapSlideFilter, StorymapSlideLocation, StorymapSlideMask } from "../types";
12
+ import { asClampedNumber } from "../storymap/iiif-shared.ts";
13
+
14
+ /**
15
+ * Valid ranges for filter grading, shared by the translator (which reads
16
+ * them) and the renderer (which enforces them again — hand-written JSON
17
+ * reaches the renderer directly, so read-time clamping alone is not enough).
18
+ */
19
+ export const FILTER_RANGES = {
20
+ brightness: { min: 0, max: 200, def: 100 },
21
+ contrast: { min: 0, max: 200, def: 100 },
22
+ saturate: { min: 0, max: 200, def: 100 },
23
+ hueRotate: { min: -180, max: 180, def: 0 },
24
+ sepia: { min: 0, max: 100, def: 0 },
25
+ blur: { min: 0, max: 20, def: 0 },
26
+ } as const;
27
+
28
+ /** View rotation in radians, or null when the slide states none. */
29
+ export function slideRotationRad(
30
+ location: StorymapSlideLocation | null | undefined,
31
+ ): number | null {
32
+ const rotation = location?.rotation;
33
+ if (typeof rotation !== "number" || !Number.isFinite(rotation) || rotation === 0) {
34
+ return null;
35
+ }
36
+ return (rotation * Math.PI) / 180;
37
+ }
38
+
39
+ /**
40
+ * A `filter()` CSS value for the map viewport from a slide `filter` bag
41
+ * (slideshow `filters`): brightness/contrast/saturate in percent,
42
+ * hue-rotate in degrees, sepia in percent, blur in pixels. Unknown keys
43
+ * never reach CSS. Returns "" when nothing is graded.
44
+ */
45
+ export function buildSlideFilter(filter: StorymapSlideFilter | null | undefined): string {
46
+ if (!filter || typeof filter !== "object") return "";
47
+ const parts: string[] = [];
48
+ const percent = (value: unknown, name: "brightness" | "contrast" | "saturate" | "sepia") => {
49
+ if (typeof value !== "number" || !Number.isFinite(value)) return;
50
+ const { min, max, def } = FILTER_RANGES[name];
51
+ if (value === def) return;
52
+ parts.push(`${name}(${asClampedNumber(value, min, max)}%)`);
53
+ };
54
+ percent(filter.brightness, "brightness");
55
+ percent(filter.contrast, "contrast");
56
+ percent(filter.saturate, "saturate");
57
+ const hue = asClampedNumber(filter.hueRotate, ...filterRange("hueRotate"));
58
+ if (hue !== null && hue !== FILTER_RANGES.hueRotate.def) {
59
+ parts.push(`hue-rotate(${hue}deg)`);
60
+ }
61
+ percent(filter.sepia, "sepia");
62
+ const blur = asClampedNumber(filter.blur, ...filterRange("blur"));
63
+ if (blur !== null && blur !== FILTER_RANGES.blur.def) {
64
+ parts.push(`blur(${blur}px)`);
65
+ }
66
+ return parts.join(" ");
67
+ }
68
+
69
+ /** The `[min, max]` pair for a filter range (spread into `asClampedNumber`). */
70
+ function filterRange(key: keyof typeof FILTER_RANGES): [number, number] {
71
+ return [FILTER_RANGES[key].min, FILTER_RANGES[key].max];
72
+ }
73
+
74
+ const MASK_COLOR = /^(#[0-9a-f]{3}([0-9a-f]{3}([0-9a-f]{2})?)?|rgba?\([^)]*\))$/i;
75
+
76
+ export interface NormalizedMask {
77
+ x: number;
78
+ y: number;
79
+ w: number;
80
+ h: number;
81
+ color: string;
82
+ invert: boolean;
83
+ opacity: number;
84
+ }
85
+
86
+ /**
87
+ * A validated spotlight mask (slideshow `passepartout`): normalized
88
+ * viewport fractions plus presentation. Returns null for anything that is
89
+ * not a usable `{x, y, w, h}` shape; out-of-range fractions are clamped,
90
+ * an unparsable color falls back to translucent black.
91
+ */
92
+ export function normalizeMask(mask: StorymapSlideMask | null | undefined): NormalizedMask | null {
93
+ if (!mask || typeof mask !== "object") return null;
94
+ const { x, y, w, h } = mask;
95
+ if (
96
+ typeof x !== "number" ||
97
+ typeof y !== "number" ||
98
+ typeof w !== "number" ||
99
+ typeof h !== "number" ||
100
+ ![x, y, w, h].every(Number.isFinite)
101
+ ) {
102
+ return null;
103
+ }
104
+ const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
105
+ const nx = clamp01(x);
106
+ const ny = clamp01(y);
107
+ // shrink the far edge with a clamped origin (like clampRegion): a mask
108
+ // fully outside the viewport resolves to nothing, not a full mask
109
+ const nw = Math.min(1 - nx, Math.max(0, w + Math.min(0, x)));
110
+ const nh = Math.min(1 - ny, Math.max(0, h + Math.min(0, y)));
111
+ if (!(nw > 0) || !(nh > 0)) return null;
112
+ const rawColor = typeof mask.color === "string" ? mask.color.trim() : "";
113
+ const color = rawColor !== "" && MASK_COLOR.test(rawColor) ? rawColor : "rgba(0,0,0,0.5)";
114
+ const opacity =
115
+ typeof mask.opacity === "number" && Number.isFinite(mask.opacity)
116
+ ? Math.min(1, Math.max(0, mask.opacity))
117
+ : 1;
118
+ return { x: nx, y: ny, w: nw, h: nh, color, invert: mask.invert === true, opacity };
119
+ }
120
+
121
+ /** The per-slide basemap key, or null when the slide keeps the story basemap. */
122
+ export function slideBasemapKey(location: StorymapSlideLocation | null | undefined): string | null {
123
+ const basemap = location?.basemap;
124
+ return typeof basemap === "string" && basemap !== "" ? basemap : null;
125
+ }
126
+
127
+ /**
128
+ * True for plain image-file URLs (no IIIF service, `{z}` template or
129
+ * keyword). Only unambiguous raster extensions: `.jpx`/`.jp2` can be IIIF
130
+ * bases too (Leipzig serves `info.json` off `.jpx`), so those go through
131
+ * the service attempt with a static fallback instead. Relative URLs
132
+ * (same-origin static files) count; templates never match (no extension).
133
+ */
134
+ export function isStaticImageUrl(url: string): boolean {
135
+ if (url.includes("{z}")) return false;
136
+ if (!/\.(jpe?g|png|gif|webp|tif?f|bmp|avif)(\?.*)?$/i.test(url)) return false;
137
+ if (/^https?:\/\//i.test(url)) return true;
138
+ // same-origin relative file without any other scheme (mapbox:, osm:, …)
139
+ return !/^[a-z][a-z0-9+.-]*:/i.test(url);
140
+ }
141
+
142
+ /**
143
+ * An image service base as its `info.json` URL (idempotent for URLs that
144
+ * already name it). Shared by the slideshow translator (story default) and
145
+ * the per-slide basemap builder so the suffix rule cannot drift.
146
+ */
147
+ export function serviceToInfoJson(service: string): string {
148
+ return /info\.json$/i.test(service) ? service : `${service.replace(/\/$/, "")}/info.json`;
149
+ }
package/src/map/types.ts CHANGED
@@ -7,6 +7,8 @@ export interface ViewToOptions {
7
7
  calculate_zoom?: boolean;
8
8
  duration?: number;
9
9
  zoom?: number;
10
+ /** View rotation in degrees clockwise; absent leaves rotation unchanged. */
11
+ rotation?: number;
10
12
  }
11
13
 
12
14
  /** A point of the connection line: either a slide or a raw lat/lon pair. */
@@ -9,6 +9,8 @@
9
9
  in a storymap's JSON can never execute code in a viewer's browser.
10
10
  ================================================== */
11
11
 
12
+ import { Language } from "../language/Language";
13
+
12
14
  // Attributes copied from a pasted <iframe> onto the rebuilt element.
13
15
  // src is handled separately (validated); everything else is dropped.
14
16
  const IFRAME_ATTRIBUTES = [
@@ -99,6 +101,11 @@ const SLIDE_TEXT_DROP_TAGS = DROP_TAGS.filter((tag) => tag !== "IFRAME");
99
101
  *
100
102
  * This list covers prose and media embeds. Anything else is unwrapped (its
101
103
  * text is kept) rather than dropped with its contents.
104
+ *
105
+ * Note on `style` (trusted authors): inline declarations pass through
106
+ * verbatim — there is no script execution from declarations in modern
107
+ * engines, `url()` tracking is at parity with allowlisted `<img>`, and the
108
+ * slide root is positioned and isolated so nothing escapes the slide box.
102
109
  */
103
110
  const SLIDE_TEXT_TAGS = [
104
111
  // structure / text
@@ -165,13 +172,16 @@ const SLIDE_TEXT_TAGS = [
165
172
 
166
173
  /**
167
174
  * Attributes allowed on slide-text elements. `id`/`name` are deliberately
168
- * absent: they enable DOM clobbering of host-page globals. `style` is
169
- * deliberately absent (see above). `class` is kept — the story fixtures and
175
+ * absent: they enable DOM clobbering of host-page globals. `style` passes
176
+ * through verbatim — tour authors are trusted; the slide root is positioned
177
+ * and isolated, so declarations cannot escape it (see VCO.Slide.scss).
178
+ * `class` is kept — the story fixtures and
170
179
  * the migrated site content style slides through it, and a class name cannot
171
180
  * execute anything.
172
181
  */
173
182
  const SLIDE_TEXT_ATTRIBUTES = [
174
183
  "class",
184
+ "style",
175
185
  "title",
176
186
  "lang",
177
187
  "dir",
@@ -276,6 +286,21 @@ export function buildIframe(html: string): HTMLIFrameElement | null {
276
286
  iframe.setAttribute("frameborder", "0");
277
287
  iframe.setAttribute("allowfullscreen", "");
278
288
  }
289
+ if (!iframe.hasAttribute("title")) {
290
+ const localeMedia = (Language as unknown as { media?: { iframe_title?: unknown } }).media;
291
+ const localeTitle =
292
+ typeof localeMedia?.iframe_title === "string" && localeMedia.iframe_title !== ""
293
+ ? localeMedia.iframe_title
294
+ : undefined;
295
+ const messagesTitle = Language.messages["media.iframe_title"];
296
+ iframe.setAttribute(
297
+ "title",
298
+ localeTitle ??
299
+ (typeof messagesTitle === "string" && messagesTitle !== ""
300
+ ? messagesTitle
301
+ : "Embedded media"),
302
+ );
303
+ }
279
304
  return iframe;
280
305
  }
281
306
 
@@ -50,6 +50,16 @@ export function loadErrorIcon(media_type: unknown): string {
50
50
  return LOAD_ERROR_ICONS[type] ?? GENERIC_LOAD_ERROR_ICON;
51
51
  }
52
52
 
53
+ /**
54
+ * Whether media captions/credits render: `show_info: false` hides them, but
55
+ * only for slideshow tours (`slideshow_source`) — internal documents always
56
+ * show their captions. Pure for testability.
57
+ */
58
+ export function shouldShowInfo(options: MediaOptions): boolean {
59
+ if (options.slideshow_source !== true) return true;
60
+ return options.show_info !== false;
61
+ }
62
+
53
63
  /* Options for Media and its subclasses: the fields Media itself sets
54
64
  or reads. Everything else merged in via mergeData is absorbed by the
55
65
  index signature. */
@@ -74,6 +84,14 @@ export interface MediaData extends StorymapSlideMedia {
74
84
  [key: string]: unknown;
75
85
  }
76
86
 
87
+ export interface MediaEvents {
88
+ loaded: MediaData;
89
+ media_loaded: MediaData;
90
+ media_ended: MediaData;
91
+ added: MediaData;
92
+ removed: MediaData;
93
+ }
94
+
77
95
  /**
78
96
  * The media element cache.
79
97
  *
@@ -110,7 +128,7 @@ export class MediaBase {
110
128
  declare "options": MediaOptions;
111
129
  declare "animator": unknown;
112
130
  declare "_media": unknown;
113
- declare "fire": EventedInstance["fire"];
131
+ declare "fire": EventedInstance<MediaEvents>["fire"];
114
132
 
115
133
  /* Constructor
116
134
  ================================================== */
@@ -230,8 +248,7 @@ export class MediaBase {
230
248
  this._state.loaded = true;
231
249
  this._updateDisplay();
232
250
  } catch (e) {
233
- console.log("Error loading media for ", this._media);
234
- console.log(e);
251
+ console.warn("StoryMapJS: error loading media.", e);
235
252
  this.loadErrorDisplay("Error loading media.");
236
253
  }
237
254
  }, 1200);
@@ -441,6 +458,9 @@ export class MediaBase {
441
458
  const text = Dom.create("p", "");
442
459
  text.appendChild(document.createTextNode(message));
443
460
  this._el.content_item.appendChild(text);
461
+ // announced, not just shown: load failures otherwise pass silently
462
+ // for screen-reader visitors
463
+ this._el.content_item.setAttribute("role", "alert");
444
464
 
445
465
  // After Loaded
446
466
  this.onLoaded(true);
@@ -474,6 +494,9 @@ export class MediaBase {
474
494
 
475
495
  /** Render the media credit and caption, if the slide has them. */
476
496
  showMeta(): void {
497
+ // slideshow tours may hide the info block (show_info: false);
498
+ // internal documents always render it
499
+ if (!shouldShowInfo(this.options)) return;
477
500
  this._state.show_meta = true;
478
501
  // Credit and caption are author-supplied strings that may legitimately
479
502
  // contain formatting (they are documented as HTML), so they go through
@@ -590,7 +613,7 @@ export class MediaBase {
590
613
  _stopMedia() {}
591
614
  }
592
615
 
593
- export class Media extends Evented(MediaBase) {
616
+ export class Media extends Evented<MediaEvents, typeof MediaBase>(MediaBase) {
594
617
  constructor(...args: ConstructorParameters<typeof MediaBase>) {
595
618
  super(...args);
596
619
  }
@@ -102,3 +102,9 @@ export function flickrId(url: string): string | null {
102
102
  export function tweetId(url: string): string | null {
103
103
  return segmentAfter(url, /\/status(?:es)?\//i);
104
104
  }
105
+
106
+ /** Tweet author (the profile segment before `/status/<id>`), or null. */
107
+ export function tweetAuthor(url: string): string | null {
108
+ const match = /(?:twitter\.com|x\.com)\/([^\s/?#]+)\/status(?:es)?\//i.exec(url);
109
+ return match ? match[1] : null;
110
+ }
@@ -8,6 +8,8 @@ import Dom from "../../dom/Dom";
8
8
 
9
9
  export default class Flickr extends Media {
10
10
  declare "media_id": string;
11
+ /** The `load` handler on the image, stored so dispose() can detach it. */
12
+ declare "_onLoad": (() => void) | null;
11
13
 
12
14
  /* Load the media
13
15
  ================================================== */
@@ -22,10 +24,12 @@ export default class Flickr extends Media {
22
24
  this._el.content,
23
25
  );
24
26
 
25
- // Media Loaded Event
26
- this._el.content_item.addEventListener("load", (e) => {
27
+ // Media Loaded Event. The reference is kept so dispose() can detach
28
+ // it: an inline arrow function has no other handle.
29
+ this._onLoad = () => {
27
30
  this.onMediaLoaded();
28
- });
31
+ };
32
+ this._el.content_item.addEventListener("load", this._onLoad);
29
33
 
30
34
  // Get Media ID
31
35
  this.establishMediaID();
@@ -112,4 +116,11 @@ export default class Flickr extends Media {
112
116
 
113
117
  return _size;
114
118
  }
119
+
120
+ _disposeMedia(): void {
121
+ if (this._onLoad) {
122
+ this._el.content_item?.removeEventListener("load", this._onLoad);
123
+ this._onLoad = null;
124
+ }
125
+ }
115
126
  }
@@ -51,6 +51,19 @@ export class HtmlMediaBase extends Media {
51
51
  ) as HTMLMediaElement;
52
52
  this._el.content_item = media_item;
53
53
  media_item.controls = true;
54
+ const captionText =
55
+ typeof this.data.caption === "string"
56
+ ? this.data.caption
57
+ .replace(/<[^>]*>/g, " ")
58
+ .replace(/\s+/g, " ")
59
+ .trim()
60
+ : "";
61
+ const mediaName =
62
+ typeof this.options.media_name === "string" ? this.options.media_name : "";
63
+ media_item.setAttribute(
64
+ "aria-label",
65
+ captionText || mediaName || (spec.kind === "audio" ? "Audio" : "Video"),
66
+ );
54
67
 
55
68
  const source_item = Dom.create("source", "", media_item) as HTMLSourceElement;
56
69
  this._el.source_item = source_item;
@@ -58,9 +71,62 @@ export class HtmlMediaBase extends Media {
58
71
  // Media Loaded Event. The references are kept so dispose() can detach
59
72
  // them: an inline arrow function has no other handle.
60
73
  this._onEnded = null;
74
+ // Playback flags (slideshow audio terms): loop repeats, offset seeks
75
+ // once the metadata is available, play:"auto" starts on load
76
+ // (absent stays click-to-play with controls, as before). Slideshow
77
+ // tours only — internal documents ignore the bag even when present.
78
+ const sourced = (this.options as { slideshow_source?: unknown }).slideshow_source === true;
79
+ const bag = (
80
+ sourced
81
+ ? this.data
82
+ : ({} as {
83
+ loop?: boolean;
84
+ offset?: number;
85
+ play?: string;
86
+ })
87
+ ) as {
88
+ loop?: boolean;
89
+ offset?: number;
90
+ play?: string;
91
+ };
92
+ media_item.loop = bag.loop === true;
93
+ const offset =
94
+ typeof bag.offset === "number" && Number.isFinite(bag.offset) && bag.offset > 0
95
+ ? bag.offset
96
+ : 0;
97
+ const seek = () => {
98
+ if (offset <= 0) return;
99
+ try {
100
+ media_item.currentTime = offset;
101
+ } catch {
102
+ // pre-metadata seek throws in some browsers; canplay retries
103
+ }
104
+ };
105
+ const autoplay = bag.play === "auto";
61
106
  this._onCanPlay = () => {
107
+ seek();
108
+ if (autoplay) {
109
+ void media_item.play()?.catch?.(() => {
110
+ // autoplay policy or missing gesture: controls stay for
111
+ // the visitor, like a click-armed slide
112
+ });
113
+ }
62
114
  this.onLoaded();
63
115
  };
116
+ // offset with metadata already present (cached media): seek now too
117
+ if (offset > 0) {
118
+ if (media_item.readyState >= 1) {
119
+ seek();
120
+ } else {
121
+ media_item.addEventListener(
122
+ "loadedmetadata",
123
+ () => {
124
+ seek();
125
+ },
126
+ { once: true },
127
+ );
128
+ }
129
+ }
64
130
  media_item.addEventListener("canplay", this._onCanPlay);
65
131
 
66
132
  // Load Error Event (the source element fires it, not the media element)