@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
@@ -0,0 +1,302 @@
1
+ import Dom from "../dom/Dom";
2
+ import { DomEvent } from "../dom/DomEvent";
3
+ import { Evented, type EventedInstance } from "../core/mixins";
4
+ import { Language } from "../language/Language";
5
+
6
+ /* LayersControl
7
+ Basemap/overlay switcher panel for the menubar (show_layers_control).
8
+ Owns the disclosure button and the panel; the host of the layers state
9
+ (StoryMap, via MenuBar) supplies rows through the delegate and executes
10
+ toggles. Fires basemapchange/overlaychange, which MenuBar forwards.
11
+ ================================================== */
12
+
13
+ export interface LayersOverlayRow {
14
+ kind: "overlay";
15
+ /** Built-layer index: malformed entries are skipped, like setOverlayVisible. */
16
+ index: number;
17
+ label: string;
18
+ checked: boolean;
19
+ locked: boolean;
20
+ }
21
+
22
+ export interface LayersBasemapRow {
23
+ kind: "basemap";
24
+ map_type: string;
25
+ label: string;
26
+ checked: boolean;
27
+ }
28
+
29
+ export type LayersRow = LayersOverlayRow | LayersBasemapRow;
30
+
31
+ export interface LayersControlDelegate {
32
+ /** Current rows, basemaps first. Empty while there is nothing to switch. */
33
+ rows(): LayersRow[];
34
+ }
35
+
36
+ export interface LayersControlEvents {
37
+ basemapchange: { map_type: string };
38
+ overlaychange: { index: number; visible: boolean };
39
+ }
40
+
41
+ /** Panel id suffix: unique per control so two viewers never share one. */
42
+ let layersPanelSeq = 0;
43
+
44
+ class LayersControlBase {
45
+ declare "_el": {
46
+ container: HTMLElement;
47
+ button: HTMLButtonElement;
48
+ panel: HTMLElement;
49
+ };
50
+ declare "_menubar": HTMLElement;
51
+ declare "_delegate": LayersControlDelegate | null;
52
+ declare "_rows": LayersRow[];
53
+ declare "_open": boolean;
54
+ declare "_panel_id": string;
55
+ declare "_onKeyDownBound": ((e: KeyboardEvent) => void) | null;
56
+ declare "_onPointerDownBound": ((e: Event) => void) | null;
57
+ declare "fire": EventedInstance<LayersControlEvents>["fire"];
58
+
59
+ constructor(menubar: HTMLElement) {
60
+ this._menubar = menubar;
61
+ const panel_id = `vco-layers-panel-${++layersPanelSeq}`;
62
+ this._panel_id = panel_id;
63
+ this._delegate = null;
64
+ this._rows = [];
65
+ this._open = false;
66
+ this._onKeyDownBound = null;
67
+ this._onPointerDownBound = null;
68
+
69
+ const button = Dom.create(
70
+ "button",
71
+ "vco-menubar-button vco-menubar-layers",
72
+ menubar,
73
+ ) as HTMLButtonElement;
74
+ button.setAttribute("type", "button");
75
+ button.setAttribute("aria-expanded", "false");
76
+ button.setAttribute("aria-controls", panel_id);
77
+ button.textContent = Language.buttons.layers;
78
+ DomEvent.addListener(button, "click", this._onButtonClick, this);
79
+ this._el = {
80
+ container: menubar,
81
+ button,
82
+ panel: document.createElement("div"),
83
+ };
84
+
85
+ const panel = this._el.panel;
86
+ panel.id = panel_id;
87
+ panel.className = "vco-layers";
88
+ panel.hidden = true;
89
+ menubar.appendChild(panel);
90
+ // hidden until the first refresh() hands over rows (consent-denied
91
+ // tiles and mapless stories never get any)
92
+ button.style.display = "none";
93
+ }
94
+
95
+ /* Public
96
+ ================================================== */
97
+
98
+ /** Hand the control its state source (StoryMap wires this via MenuBar). */
99
+ setDelegate(delegate: LayersControlDelegate): void {
100
+ this._delegate = delegate;
101
+ }
102
+
103
+ /** Re-pull rows from the delegate and sync the panel in place. */
104
+ refresh(): void {
105
+ const rows = this._delegate?.rows() ?? [];
106
+ // nothing to switch (consent-denied tiles, mapless story): no
107
+ // control at all rather than a panel of dead rows
108
+ this._el.button.style.display = rows.length === 0 ? "none" : "";
109
+ if (rows.length === 0) {
110
+ this.close();
111
+ this._rows = rows;
112
+ return;
113
+ }
114
+ if (this._sameRows(rows)) {
115
+ this._updateRows(rows);
116
+ } else {
117
+ this._buildRows(rows);
118
+ }
119
+ this._rows = rows;
120
+ }
121
+
122
+ /** Re-resolve row labels (function labels repaint on language change). */
123
+ refreshLabels(): void {
124
+ this._el.button.textContent = Language.buttons.layers;
125
+ this.refresh();
126
+ }
127
+
128
+ open(): void {
129
+ if (this._rows.length === 0) return;
130
+ this._open = true;
131
+ this._el.panel.hidden = false;
132
+ this._el.button.setAttribute("aria-expanded", "true");
133
+ this._el.button.classList.add("vco-active");
134
+ }
135
+
136
+ close(): void {
137
+ this._open = false;
138
+ this._el.panel.hidden = true;
139
+ this._el.button.setAttribute("aria-expanded", "false");
140
+ this._el.button.classList.remove("vco-active");
141
+ }
142
+
143
+ /**
144
+ * Release the document listeners and drop the nodes. Called by
145
+ * `MenuBar.dispose()`; the control must not be used afterwards.
146
+ */
147
+ dispose(): void {
148
+ if (this._onKeyDownBound) {
149
+ document.removeEventListener("keydown", this._onKeyDownBound);
150
+ this._onKeyDownBound = null;
151
+ }
152
+ if (this._onPointerDownBound) {
153
+ document.removeEventListener("pointerdown", this._onPointerDownBound);
154
+ this._onPointerDownBound = null;
155
+ }
156
+ this._el.button.remove();
157
+ this._el.panel.remove();
158
+ this._delegate = null;
159
+ }
160
+
161
+ /* Events
162
+ ================================================== */
163
+
164
+ _onButtonClick(): void {
165
+ if (this._open) {
166
+ this.close();
167
+ } else {
168
+ this.open();
169
+ }
170
+ }
171
+
172
+ _onRowChange(e: Event, row: LayersRow): void {
173
+ const input = e.target as HTMLInputElement | null;
174
+ if (!input) return;
175
+ if (row.kind === "overlay") {
176
+ if (row.locked) {
177
+ // disabled inputs never fire from user input; a programmatic
178
+ // dispatch still reaches this handler, so ignore it loudly
179
+ // by restoring the checked state instead of toggling
180
+ input.checked = row.checked;
181
+ return;
182
+ }
183
+ this.fire("overlaychange", { index: row.index, visible: input.checked });
184
+ } else {
185
+ if (!input.checked) return;
186
+ this.fire("basemapchange", { map_type: row.map_type });
187
+ }
188
+ }
189
+
190
+ _onPanelKeyDown(e: KeyboardEvent): void {
191
+ if (e.key !== "Escape" || !this._open) return;
192
+ // Close from anywhere while open: Safari/WebKit does not move focus
193
+ // on mouse clicks, so a focus-scoped handler would strand mouse
194
+ // visitors. No stopPropagation — a host Escape handler may run too.
195
+ // Focus returns to the button only when it was inside the panel;
196
+ // a mouse visitor's focus (body) is left alone.
197
+ const inside = this._el.panel.contains(document.activeElement);
198
+ this.close();
199
+ if (inside) this._el.button.focus();
200
+ }
201
+
202
+ _onDocumentPointerDown(e: Event): void {
203
+ if (!this._open) return;
204
+ if (this._el.container.contains(e.target as Node | null)) return;
205
+ this.close();
206
+ }
207
+
208
+ /* Private Methods
209
+ ================================================== */
210
+
211
+ _sameRows(rows: LayersRow[]): boolean {
212
+ if (rows.length !== this._rows.length) return false;
213
+ return rows.every((row, i) => {
214
+ const prev = this._rows[i];
215
+ if (prev.kind !== row.kind) return false;
216
+ return row.kind === "overlay"
217
+ ? (prev as LayersOverlayRow).index === row.index
218
+ : (prev as LayersBasemapRow).map_type === row.map_type;
219
+ });
220
+ }
221
+
222
+ _updateRows(rows: LayersRow[]): void {
223
+ const inputs = this._el.panel.querySelectorAll("input");
224
+ rows.forEach((row, position) => {
225
+ const input = inputs[position] as HTMLInputElement | undefined;
226
+ const label = input?.closest("label");
227
+ if (!input || !label) return;
228
+ input.checked = row.checked;
229
+ input.disabled = row.kind === "overlay" && row.locked;
230
+ const text = label.querySelector(".vco-layers-label");
231
+ if (text) text.textContent = row.label;
232
+ });
233
+ }
234
+
235
+ _buildRows(rows: LayersRow[]): void {
236
+ const panel = this._el.panel;
237
+ panel.replaceChildren();
238
+ const basemaps = rows.filter((row): row is LayersBasemapRow => row.kind === "basemap");
239
+ const overlays = rows.filter((row): row is LayersOverlayRow => row.kind === "overlay");
240
+ if (basemaps.length > 0) {
241
+ panel.appendChild(this._buildGroup("basemap", basemaps));
242
+ }
243
+ if (overlays.length > 0) {
244
+ panel.appendChild(this._buildGroup("overlay", overlays));
245
+ }
246
+ if (!this._onKeyDownBound) {
247
+ this._onKeyDownBound = (e: KeyboardEvent) => this._onPanelKeyDown(e);
248
+ document.addEventListener("keydown", this._onKeyDownBound);
249
+ }
250
+ if (!this._onPointerDownBound) {
251
+ this._onPointerDownBound = (e: Event) => this._onDocumentPointerDown(e);
252
+ document.addEventListener("pointerdown", this._onPointerDownBound);
253
+ }
254
+ }
255
+
256
+ _buildGroup(kind: "basemap" | "overlay", rows: LayersRow[]): HTMLElement {
257
+ const group = document.createElement("fieldset");
258
+ group.className = "vco-layers-group";
259
+ const legend = document.createElement("legend");
260
+ legend.textContent =
261
+ kind === "basemap"
262
+ ? Language.buttons.layers_basemaps
263
+ : Language.buttons.layers_overlays;
264
+ group.appendChild(legend);
265
+ for (const row of rows) {
266
+ group.appendChild(this._buildRow(row));
267
+ }
268
+ return group;
269
+ }
270
+
271
+ _buildRow(row: LayersRow): HTMLElement {
272
+ const label = document.createElement("label");
273
+ const input = document.createElement("input");
274
+ input.setAttribute("type", row.kind === "basemap" ? "radio" : "checkbox");
275
+ if (row.kind === "basemap") {
276
+ input.setAttribute("name", "basemap");
277
+ }
278
+ input.checked = row.checked;
279
+ if (row.kind === "overlay" && row.locked) {
280
+ input.disabled = true;
281
+ }
282
+ const text = document.createElement("span");
283
+ text.className = "vco-layers-label";
284
+ text.textContent = row.label;
285
+ label.appendChild(input);
286
+ label.appendChild(text);
287
+ DomEvent.addListener(input, "change", (e: Event) => this._onRowChange(e, row), this);
288
+ return label;
289
+ }
290
+ }
291
+
292
+ const EventedLayersControlBase = Evented<LayersControlEvents, typeof LayersControlBase>(
293
+ LayersControlBase,
294
+ );
295
+
296
+ export default class LayersControl extends Evented<LayersControlEvents, typeof LayersControlBase>(
297
+ EventedLayersControlBase,
298
+ ) {
299
+ constructor(...args: ConstructorParameters<typeof LayersControlBase>) {
300
+ super(...args);
301
+ }
302
+ }
package/src/ui/MenuBar.ts CHANGED
@@ -4,8 +4,10 @@ import Dom from "../dom/Dom";
4
4
  import { easeInOutQuint } from "../animation/easings";
5
5
 
6
6
  import { DomEvent } from "../dom/DomEvent";
7
+ import { PROGRESSBAR_STYLES } from "../types";
7
8
  import { Browser } from "../core/Browser";
8
9
  import { Language, currentLocale } from "../language/Language";
10
+ import LayersControl, { type LayersControlDelegate } from "./LayersControl";
9
11
 
10
12
  /* MenuBar
11
13
  Buttons, progress and distance display for the storymap
@@ -20,13 +22,30 @@ interface MenuBarOptions {
20
22
  [key: string]: unknown;
21
23
  }
22
24
 
25
+ export interface MenuBarEvents {
26
+ overview: Event;
27
+ back_to_start: Event;
28
+ fullscreen: Event;
29
+ autoplay_toggle: undefined;
30
+ progress_go: { slide: number };
31
+ collapse: { y: number; collapsed: boolean };
32
+ basemapchange: { map_type: string };
33
+ overlaychange: { index: number; visible: boolean };
34
+ loaded: undefined;
35
+ added: undefined;
36
+ removed: undefined;
37
+ }
38
+
23
39
  class MenuBarBase {
24
40
  declare "_el": Record<string, HTMLElement>;
25
41
  declare "collapsed": boolean;
26
42
  declare "options": MenuBarOptions;
27
43
  declare "animator": Record<string, unknown>;
28
- declare "fire": EventedInstance["fire"];
44
+ declare "fire": EventedInstance<MenuBarEvents>["fire"];
29
45
  _fullscreenActive = false;
46
+ _autoplayStopped = false;
47
+ /** The layer switcher, if `show_layers_control` is on. */
48
+ declare "_layersControl": LayersControl | null;
30
49
 
31
50
  /* Constructor
32
51
  ================================================== */
@@ -76,6 +95,8 @@ class MenuBarBase {
76
95
  // Animation
77
96
  this.animator = {};
78
97
 
98
+ this._layersControl = null;
99
+
79
100
  // Merge Data and Options
80
101
  mergeData(this.options, options);
81
102
 
@@ -116,18 +137,24 @@ class MenuBarBase {
116
137
  this._el.button_fullscreen.innerHTML = `<span class='${icon}'></span>`;
117
138
  } else {
118
139
  const label = active ? Language.buttons.exit_fullscreen : Language.buttons.fullscreen;
119
- this._el.button_fullscreen.innerHTML = `${label} <span class='${icon}'></span>`;
140
+ this._el.button_fullscreen.innerHTML = `${label}<span class='${icon}'></span>`;
120
141
  }
121
142
  }
122
143
 
123
144
  /**
124
145
  * Repaint the overview button label. An image map has no interactive
125
- * overview, so it gets the shorter wording.
146
+ * overview, so it gets the shorter wording. Icon-only on mobile.
126
147
  */
127
148
  _renderOverviewLabel(): void {
128
- this._el.button_overview.innerHTML = this.options.map_as_image
149
+ const label = this.options.map_as_image
129
150
  ? Language.buttons.overview
130
151
  : Language.buttons.map_overview;
152
+ if (Browser.mobile) {
153
+ this._el.button_overview.innerHTML = "<span class='vco-icon-mappin'></span>";
154
+ this._el.button_overview.setAttribute("aria-label", label);
155
+ } else {
156
+ this._el.button_overview.innerHTML = `${label}<span class='vco-icon-mappin'></span>`;
157
+ }
131
158
  }
132
159
 
133
160
  /**
@@ -136,7 +163,7 @@ class MenuBarBase {
136
163
  _renderBackToStartLabel(): void {
137
164
  this._el.button_backtostart.innerHTML = Browser.mobile
138
165
  ? "<span class='vco-icon-goback'></span>"
139
- : Language.buttons.backtostart + " <span class='vco-icon-goback'></span>";
166
+ : Language.buttons.backtostart + "<span class='vco-icon-goback'></span>";
140
167
  }
141
168
 
142
169
  /**
@@ -150,38 +177,177 @@ class MenuBarBase {
150
177
  const text = collapsed
151
178
  ? Language.buttons.uncollapse_toggle
152
179
  : Language.buttons.collapse_toggle;
153
- this._el.button_collapse_toggle.innerHTML = `${text} <span class='vco-icon-${arrow}'></span>`;
180
+ this._el.button_collapse_toggle.innerHTML = `${text}<span class='vco-icon-${arrow}'></span>`;
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Reflect the autoplay state in the toggle label.
186
+ */
187
+ setAutoplayState(stopped: boolean): void {
188
+ this._autoplayStopped = stopped;
189
+ if (!this._el.button_autoplay) return;
190
+ const label = stopped ? Language.buttons.autoplay_play : Language.buttons.autoplay_pause;
191
+ if (Browser.mobile) {
192
+ this._el.button_autoplay.setAttribute("aria-label", label);
193
+ } else {
194
+ this._el.button_autoplay.textContent = label;
154
195
  }
196
+ this._el.button_autoplay.setAttribute("aria-pressed", String(!stopped));
155
197
  }
156
198
 
157
199
  /**
158
200
  * Repaint every text label from the active language (see `setLanguage`).
159
- * Icon-only mobile buttons carry no text and are left untouched.
201
+ * Icon-only mobile buttons carry text via aria-label instead.
160
202
  */
161
203
  refreshLabels(): void {
204
+ // panel rows are always text, so the control repaints even where
205
+ // the buttons below go icon-only
206
+ this._layersControl?.refreshLabels();
162
207
  if (Browser.mobile) {
208
+ this._renderMobileLabels();
163
209
  return;
164
210
  }
165
211
  this._renderOverviewLabel();
166
212
  this._renderBackToStartLabel();
167
213
  this.setFullscreenState(this._fullscreenActive);
214
+ this.setAutoplayState(this._autoplayStopped);
168
215
  this._renderCollapseLabel(this.collapsed);
169
216
  }
170
217
 
171
218
  /**
172
- * Update the progress indicator (issue #247); no-op when disabled.
219
+ * Icon-only mobile buttons expose their labels to assistive tech since
220
+ * the visible text is icon glyphs.
221
+ */
222
+ _renderMobileLabels(): void {
223
+ const labels: [string, string][] = [
224
+ [
225
+ "button_overview",
226
+ this.options.map_as_image
227
+ ? Language.buttons.overview
228
+ : Language.buttons.map_overview,
229
+ ],
230
+ ["button_backtostart", Language.buttons.backtostart],
231
+ [
232
+ "button_collapse_toggle",
233
+ this.collapsed
234
+ ? Language.buttons.uncollapse_toggle
235
+ : Language.buttons.collapse_toggle,
236
+ ],
237
+ [
238
+ "button_fullscreen",
239
+ this._fullscreenActive
240
+ ? Language.buttons.exit_fullscreen
241
+ : Language.buttons.fullscreen,
242
+ ],
243
+ [
244
+ "button_autoplay",
245
+ this._autoplayStopped
246
+ ? Language.buttons.autoplay_play
247
+ : Language.buttons.autoplay_pause,
248
+ ],
249
+ ];
250
+ for (const [key, label] of labels) {
251
+ this._el[key]?.setAttribute("aria-label", label);
252
+ }
253
+ }
254
+
255
+ /** Hand the layer switcher its state source (StoryMap wires this). */
256
+ setLayersDelegate(delegate: LayersControlDelegate): void {
257
+ this._layersControl?.setDelegate(delegate);
258
+ }
259
+
260
+ /** Re-pull the switcher rows (overlays, basemaps, consent state). */
261
+ refreshLayers(): void {
262
+ this._layersControl?.refresh();
263
+ }
264
+
265
+ /**
266
+ * Update the progress indicator; no-op when disabled. The `progressbar`
267
+ * option selects the style (slideshow `progressbar`): the classic fill
268
+ * `bar` (default), height variants `block`/`thinblock`, or per-slide
269
+ * `dots`/`squares` buttons that jump to their slide.
173
270
  */
174
271
  setProgress(current: number, total: number): void {
175
- if (!this.options.show_progress || !this._el.progress_fill) {
272
+ const style = this._progressStyle();
273
+ if (style === "off" || !this._el.progress) {
274
+ return;
275
+ }
276
+ if (style === "dots" || style === "squares") {
277
+ this._renderProgressSteps(style, current, total);
278
+ return;
279
+ }
280
+ if (!this._el.progress_fill) {
281
+ return;
282
+ }
283
+ // a single slide (or none) is fully read by definition, but a
284
+ // progressbar over zero slides is invalid ARIA — clear it instead
285
+ if (!(total > 1)) {
286
+ this._el.progress_fill.style.width = "100%";
287
+ this._el.progress.removeAttribute("role");
288
+ this._el.progress.removeAttribute("aria-valuenow");
289
+ this._el.progress.removeAttribute("aria-valuemin");
290
+ this._el.progress.removeAttribute("aria-valuemax");
291
+ this._el.progress.setAttribute("aria-label", "1 / 1");
176
292
  return;
177
293
  }
178
- const percent = total > 1 ? Math.round((current / (total - 1)) * 100) : 100;
294
+ const percent = Math.round((Math.min(current, total - 1) / (total - 1)) * 100);
179
295
  this._el.progress_fill.style.width = percent + "%";
180
296
  this._el.progress.setAttribute("role", "progressbar");
181
- this._el.progress.setAttribute("aria-valuenow", String(current + 1));
297
+ this._el.progress.setAttribute("aria-valuenow", String(Math.min(current, total - 1) + 1));
182
298
  this._el.progress.setAttribute("aria-valuemin", "1");
183
299
  this._el.progress.setAttribute("aria-valuemax", String(total));
184
- this._el.progress.setAttribute("aria-label", `${current + 1} / ${total}`);
300
+ this._el.progress.setAttribute(
301
+ "aria-label",
302
+ `${Math.min(current, total - 1) + 1} / ${total}`,
303
+ );
304
+ }
305
+
306
+ /** The resolved progress style: `progressbar` wins, `show_progress` decides the default. */
307
+ _progressStyle(): "off" | "bar" | "dots" | "squares" | "block" | "thinblock" {
308
+ const raw = this.options.progressbar as string | boolean | undefined;
309
+ if (raw === false || raw === "off") return "off";
310
+ if (typeof raw === "string" && (PROGRESSBAR_STYLES as readonly string[]).includes(raw)) {
311
+ return raw as "bar" | "dots" | "squares" | "block" | "thinblock";
312
+ }
313
+ return this.options.show_progress ? "bar" : "off";
314
+ }
315
+
316
+ /** Per-slide progress buttons for the `dots`/`squares` styles. */
317
+ _renderProgressSteps(style: "dots" | "squares", current: number, total: number): void {
318
+ const container = this._el.progress;
319
+ if (!(total > 0)) {
320
+ container.innerHTML = "";
321
+ container.removeAttribute("role");
322
+ container.setAttribute("aria-label", "No slides");
323
+ return;
324
+ }
325
+ const built = Number(container.getAttribute("data-steps") ?? "0");
326
+ if (built !== total) {
327
+ container.innerHTML = "";
328
+ container.setAttribute("role", "tablist");
329
+ container.setAttribute("aria-label", `Slides 1 / ${total}`);
330
+ for (let i = 0; i < total; i++) {
331
+ const step = document.createElement("button");
332
+ step.setAttribute("type", "button");
333
+ step.className = `vco-menubar-progress-step vco-menubar-progress-${style}`;
334
+ step.setAttribute("role", "tab");
335
+ step.setAttribute("aria-label", `Slide ${i + 1} / ${total}`);
336
+ step.setAttribute("data-slide", String(i));
337
+ step.addEventListener("click", () => {
338
+ this.fire("progress_go", { slide: i });
339
+ });
340
+ container.appendChild(step);
341
+ }
342
+ container.setAttribute("data-steps", String(total));
343
+ }
344
+ const steps = container.querySelectorAll("[data-slide]");
345
+ steps.forEach((entry, index) => {
346
+ const el = entry as HTMLElement;
347
+ el.setAttribute("aria-selected", String(index === current));
348
+ el.classList.toggle("vco-active", index === current);
349
+ el.classList.toggle("vco-past", index < current);
350
+ });
185
351
  }
186
352
 
187
353
  /**
@@ -226,6 +392,18 @@ class MenuBarBase {
226
392
  this.fire("fullscreen", e);
227
393
  }
228
394
 
395
+ _onButtonAutoplay() {
396
+ this.fire("autoplay_toggle");
397
+ }
398
+
399
+ _onLayersBasemap(e: { map_type: string }) {
400
+ this.fire("basemapchange", { map_type: e.map_type });
401
+ }
402
+
403
+ _onLayersOverlay(e: { index: number; visible: boolean }) {
404
+ this.fire("overlaychange", { index: e.index, visible: e.visible });
405
+ }
406
+
229
407
  _onButtonCollapseMap(e: Event) {
230
408
  if (this.collapsed) {
231
409
  this.collapsed = false;
@@ -283,6 +461,19 @@ class MenuBarBase {
283
461
  this._el.button_fullscreen.style.display = "none";
284
462
  }
285
463
 
464
+ // Autoplay pause/resume toggle (only when the story auto-advances)
465
+ if (typeof this.options.autoplay === "number" && this.options.autoplay > 0) {
466
+ this._el.button_autoplay = Dom.create(
467
+ "button",
468
+ "vco-menubar-button vco-menubar-autoplay",
469
+ this._el.container,
470
+ );
471
+ this._el.button_autoplay.setAttribute("type", "button");
472
+ this._el.button_autoplay.setAttribute("aria-pressed", "true");
473
+ DomEvent.addListener(this._el.button_autoplay, "click", this._onButtonAutoplay, this);
474
+ this.setAutoplayState(false);
475
+ }
476
+
286
477
  this._el.button_collapse_toggle = Dom.create(
287
478
  "button",
288
479
  "vco-menubar-button",
@@ -296,15 +487,15 @@ class MenuBarBase {
296
487
  this,
297
488
  );
298
489
 
299
- if (this.options.map_as_image) {
300
- this._el.button_overview.innerHTML = Language.buttons.overview;
301
- } else {
302
- this._el.button_overview.innerHTML = Language.buttons.map_overview;
303
- }
490
+ this._renderOverviewLabel();
304
491
 
305
- // Progress indicator (issue #247)
306
- if (this.options.show_progress) {
492
+ // Progress indicator (issue #247; dots/squares/block/thinblock via progressbar)
493
+ if (this._progressStyle() !== "off") {
307
494
  this._el.progress = Dom.create("span", "vco-menubar-progress", this._el.container);
495
+ const style = this._progressStyle();
496
+ if (style !== "bar") {
497
+ this._el.progress.classList.add(`vco-menubar-progress-${style}`);
498
+ }
308
499
  this._el.progress_fill = Dom.create(
309
500
  "span",
310
501
  "vco-menubar-progress-fill",
@@ -326,7 +517,7 @@ class MenuBarBase {
326
517
  this._renderBackToStartLabel();
327
518
  this._renderCollapseLabel(this.collapsed);
328
519
  this._el.button_fullscreen.innerHTML =
329
- Language.buttons.fullscreen + " <span class='vco-icon-resize-full'></span>";
520
+ Language.buttons.fullscreen + "<span class='vco-icon-resize-full'></span>";
330
521
  }
331
522
 
332
523
  if (this.options.layout === "landscape" || this.options.map_type === "none") {
@@ -335,6 +526,15 @@ class MenuBarBase {
335
526
  // on screen changes.
336
527
  this._el.button_collapse_toggle.style.display = "none";
337
528
  }
529
+
530
+ // Layer switcher (opt-in): the control renders nothing until a
531
+ // delegate hands it rows, so consent-denied and mapless stories
532
+ // show no button at all
533
+ if (this.options.show_layers_control) {
534
+ this._layersControl = new LayersControl(this._el.container);
535
+ this._layersControl.on("basemapchange", this._onLayersBasemap, this);
536
+ this._layersControl.on("overlaychange", this._onLayersOverlay, this);
537
+ }
338
538
  }
339
539
 
340
540
  // Update Display
@@ -356,6 +556,7 @@ class MenuBarBase {
356
556
  ["button_overview", this._onButtonOverview as EventListener],
357
557
  ["button_backtostart", this._onButtonBackToStart as EventListener],
358
558
  ["button_fullscreen", this._onButtonFullscreen as EventListener],
559
+ ["button_autoplay", this._onButtonAutoplay as EventListener],
359
560
  ["button_collapse_toggle", this._onButtonCollapseMap as EventListener],
360
561
  ];
361
562
  for (const [key, handler] of buttons) {
@@ -364,12 +565,22 @@ class MenuBarBase {
364
565
  DomEvent.removeListener(el, "click", handler, this);
365
566
  }
366
567
  }
568
+ if (this._layersControl) {
569
+ this._layersControl.off("basemapchange", this._onLayersBasemap, this);
570
+ this._layersControl.off("overlaychange", this._onLayersOverlay, this);
571
+ this._layersControl.dispose();
572
+ this._layersControl = null;
573
+ }
367
574
  this._el.container?.getAnimations?.().forEach((a) => a.cancel());
368
575
  this._el.container?.remove();
369
576
  }
370
577
  }
371
578
 
372
- export default class MenuBar extends DomMixed(Evented(MenuBarBase)) {
579
+ const EventedMenuBarBase = Evented<MenuBarEvents, typeof MenuBarBase>(MenuBarBase);
580
+
581
+ export default class MenuBar extends DomMixed<MenuBarEvents, typeof EventedMenuBarBase>(
582
+ EventedMenuBarBase,
583
+ ) {
373
584
  constructor(...args: ConstructorParameters<typeof MenuBarBase>) {
374
585
  super(...args);
375
586
  }