@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,785 @@
1
+ /**
2
+ * Translation of slideshow tours (W3C `AnnotationCollection` + the
3
+ * `strollview` extension) into storymap data.
4
+ *
5
+ * Pure, no I/O/DOM/fetch: paging beyond the first page arrives via
6
+ * `opts.extraPages` (the CLI and the viewer follow `next` links), so the
7
+ * same document always produces the same story. Reuses the shared
8
+ * narrowing helpers; the core features it targets (rotation, filter/mask,
9
+ * per-slide basemaps, narration bags, player chrome) all render only with
10
+ * `slideshow_source`, so translator output is unaffected by the internal
11
+ * gate and internal documents never take this path (see the detector).
12
+ */
13
+
14
+ import type {
15
+ StorymapData,
16
+ StorymapSlide,
17
+ StorymapSlideFilter,
18
+ StorymapSlideMask,
19
+ StorymapSlideNarration,
20
+ } from "../types";
21
+ import {
22
+ asNumber,
23
+ asRecord,
24
+ asString,
25
+ asStringArray,
26
+ clampRegion,
27
+ escapeText,
28
+ isDuplicateNarration,
29
+ } from "./iiif-shared.ts";
30
+ import { FILTER_RANGES, isStaticImageUrl, serviceToInfoJson } from "../map/slideView.ts";
31
+ // Explicit extension like the other two: the CLI imports this module
32
+ // straight into node --experimental-strip-types.
33
+ import { PROGRESSBAR_STYLES } from "../types.ts";
34
+
35
+ /** The embed `settings` JSON (string → web component), all fields optional. */
36
+ export interface SlideshowPlayerSettings {
37
+ uri?: unknown;
38
+ mode?: unknown;
39
+ viewerheight?: unknown;
40
+ autoplay?: unknown;
41
+ slidetimeout?: unknown;
42
+ fxmode?: unknown;
43
+ shownav?: unknown;
44
+ showfullscreen?: unknown;
45
+ showinfo?: unknown;
46
+ showscrollbars?: unknown;
47
+ progressbar?: unknown;
48
+ textmode?: unknown;
49
+ textsize?: unknown;
50
+ imgoverlay?: unknown;
51
+ imgoverlayurl?: unknown;
52
+ imgoverlaysize?: unknown;
53
+ showheadings?: unknown;
54
+ bgcolor?: unknown;
55
+ hudbgcolor?: unknown;
56
+ hudcolor?: unknown;
57
+ hudopacity?: unknown;
58
+ [key: string]: unknown;
59
+ }
60
+
61
+ export interface SlideshowWarning {
62
+ kind: string;
63
+ count: number;
64
+ sampleIds: string[];
65
+ detail: string;
66
+ }
67
+
68
+ /**
69
+ * One log line per warning kind: count, detail and sample ids. Shared by
70
+ * the viewer (console) and the CLI so the two surfaces cannot drift.
71
+ */
72
+ export function formatSlideshowWarning(warning: SlideshowWarning): string {
73
+ const samples = warning.sampleIds.length > 0 ? ` [${warning.sampleIds.join(", ")}]` : "";
74
+ return `slideshow ${warning.kind} (×${warning.count}): ${warning.detail}${samples}`;
75
+ }
76
+
77
+ export interface SlideshowToStorymapResult {
78
+ data: StorymapData;
79
+ warnings: SlideshowWarning[];
80
+ }
81
+
82
+ export interface SlideshowTranslatorOptions {
83
+ /** Parsed embed settings; option keys land on the storymap object. */
84
+ settings?: SlideshowPlayerSettings | null;
85
+ /**
86
+ * Following `AnnotationPage`s for `first.next` chains (fetched by the
87
+ * caller: CLI and viewer). Items append in order.
88
+ */
89
+ extraPages?: unknown[];
90
+ }
91
+
92
+ /** Maximum `next` pages consumed (collect + warn when truncated). */
93
+ export const SLIDESHOW_MAX_PAGES = 20;
94
+ /** Sample ids kept per warning kind (counts are exact regardless). */
95
+ const MAX_WARNING_SAMPLES = 5;
96
+
97
+ const STROLLVIEW_CONTEXT = "strollview/2/context.jsonld";
98
+ const V1_CONTEXT = "ns/iiif.jsonld";
99
+ const P3_CONTEXT = "iiif.io/api/presentation/3/context.json";
100
+
101
+ function contextsOf(doc: Record<string, unknown>): string[] {
102
+ const raw = doc["@context"];
103
+ const list = Array.isArray(raw) ? raw : [raw];
104
+ return list.filter((entry): entry is string => typeof entry === "string");
105
+ }
106
+
107
+ /** 2 for the `strollview` extension, 1 for the legacy context, else 0. */
108
+ export function slideshowVersion(doc: unknown): 0 | 1 | 2 {
109
+ const record = asRecord(doc);
110
+ if (!record) return 0;
111
+ const contexts = contextsOf(record);
112
+ if (contexts.some((c) => c.includes(STROLLVIEW_CONTEXT))) return 2;
113
+ if (contexts.some((c) => c.includes(V1_CONTEXT))) return 1;
114
+ return 0;
115
+ }
116
+
117
+ /**
118
+ * True for a slideshow tour document: a v2 `AnnotationCollection` or a v1
119
+ * `AnnotationPageSequence` carrying the matching extension context (never a
120
+ * P3 `Manifest`/`Collection`), with items shaped like tour annotations.
121
+ * Narrow by conjunction so P3 `seeAlso` collections never match.
122
+ */
123
+ export function isSlideshowCollection(doc: unknown): boolean {
124
+ const record = asRecord(doc);
125
+ if (!record) return false;
126
+ const contexts = contextsOf(record);
127
+ if (contexts.some((c) => c.includes(P3_CONTEXT))) return false;
128
+ if (record.type === "AnnotationCollection" && slideshowVersion(doc) === 2) {
129
+ const first = asRecord(record.first);
130
+ const items = first && Array.isArray(first.items) ? first.items : [];
131
+ const head = asRecord(items[0]);
132
+ if (!head) return false;
133
+ if (asRecord(head.strollview) !== null) return true;
134
+ const target = asRecord(head.target);
135
+ const targetId = target ? asString(target.id) : null;
136
+ return targetId !== null && /#([^,]+),([^,]+),([^,]+),([^,]+)\s*$/.test(targetId);
137
+ }
138
+ // v1 has no extension object: the context plus page/item shapes decide
139
+ if (record.type === "AnnotationPageSequence" && slideshowVersion(doc) === 1) {
140
+ const pages = Array.isArray(record.pages) ? record.pages : [];
141
+ const page = asRecord(pages[0]);
142
+ const items = page && Array.isArray(page.items) ? page.items : [];
143
+ const head = asRecord(items[0]);
144
+ if (!head) return false;
145
+ return asRecord(head.target) !== null;
146
+ }
147
+ return false;
148
+ }
149
+
150
+ /** A language map (or plain string) reduced to one string, `none` first. */
151
+ function flattenLabel(value: unknown): string {
152
+ if (typeof value === "string") return value;
153
+ const record = asRecord(value);
154
+ if (!record) return "";
155
+ if (typeof record.value === "string") return record.value;
156
+ const none = record.none;
157
+ if (typeof none === "string" && none !== "") return none;
158
+ const fromNone = asStringArray(none).join(" ").trim();
159
+ if (fromNone !== "") return fromNone;
160
+ for (const key of Object.keys(record)) {
161
+ const text = asStringArray(record[key]).join(" ").trim();
162
+ if (text !== "") return text;
163
+ }
164
+ return "";
165
+ }
166
+
167
+ class Warnings {
168
+ private map = new Map<string, { count: number; sampleIds: string[]; detail: string }>();
169
+
170
+ add(kind: string, sampleId: string | null, detail: string): void {
171
+ const entry = this.map.get(kind) ?? { count: 0, sampleIds: [], detail };
172
+ entry.count += 1;
173
+ if (sampleId !== null && entry.sampleIds.length < MAX_WARNING_SAMPLES) {
174
+ entry.sampleIds.push(sampleId);
175
+ }
176
+ this.map.set(kind, entry);
177
+ }
178
+
179
+ list(): SlideshowWarning[] {
180
+ return [...this.map.entries()].map(([kind, entry]) => ({ kind, ...entry }));
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Follow a tour's `first.next` page chain for the translator. Bounded by
186
+ * `SLIDESHOW_MAX_PAGES` and cycle-guarded; loading and link resolution are
187
+ * injected so the viewer (fetch) and the CLI (fetch or filesystem) share
188
+ * the walk. A broken chain reports incompletely so callers warn instead of
189
+ * truncating silently.
190
+ */
191
+ export async function followSlideshowPages(opts: {
192
+ first: unknown;
193
+ base: string;
194
+ resolve: (link: string, current: string) => string | null;
195
+ load: (absolute: string) => Promise<unknown>;
196
+ maxPages?: number;
197
+ }): Promise<{ pages: unknown[]; complete: boolean }> {
198
+ const pages: unknown[] = [];
199
+ const seen = new Set<string>([opts.base]);
200
+ let current = opts.base;
201
+ let next: unknown = opts.first;
202
+ const maxPages = opts.maxPages ?? SLIDESHOW_MAX_PAGES;
203
+ for (let hop = 0; hop < maxPages; hop++) {
204
+ const link = (next as Record<string, unknown> | null)?.next;
205
+ if (typeof link !== "string" || link === "") return { pages, complete: true };
206
+ const absolute = opts.resolve(link, current);
207
+ if (absolute === null || seen.has(absolute)) return { pages, complete: false };
208
+ seen.add(absolute);
209
+ try {
210
+ const page: unknown = await opts.load(absolute);
211
+ pages.push(page);
212
+ next = page;
213
+ current = absolute;
214
+ } catch {
215
+ return { pages, complete: false };
216
+ }
217
+ }
218
+ return { pages, complete: false };
219
+ }
220
+
221
+ /** Split a canvas target id into its base and an `x,y,w,h` fragment. */
222
+ function splitTarget(target: unknown): {
223
+ canvas: string | null;
224
+ region: [number, number, number, number] | null;
225
+ raw: boolean;
226
+ } {
227
+ const record = asRecord(target);
228
+ const id = record ? asString(record.id) : asString(target);
229
+ if (id === null) return { canvas: null, region: null, raw: false };
230
+ const hash = id.indexOf("#");
231
+ if (hash === -1) return { canvas: id, region: null, raw: false };
232
+ // standard `xywh=` / `xywh=pixel:` prefixes as well as bare `x,y,w,h`
233
+ const fragment = id
234
+ .slice(hash + 1)
235
+ .replace(/^xywh=(pixel:)?/i, "")
236
+ .trim();
237
+ const parts = fragment.split(",").map(Number);
238
+ if (parts.length === 4 && parts.every((n) => Number.isFinite(n))) {
239
+ const [x, y, w, h] = parts;
240
+ if (w > 0 && h > 0) {
241
+ // canvas dimensions are unknown to the pure translator, so this
242
+ // is the origin-only clamp; fully-outside regions become null
243
+ return {
244
+ canvas: id.slice(0, hash),
245
+ region: clampRegion([x, y, w, h], null, null),
246
+ raw: true,
247
+ };
248
+ }
249
+ }
250
+ return { canvas: id.slice(0, hash), region: null, raw: true };
251
+ }
252
+
253
+ function readFilter(value: unknown): StorymapSlideFilter | null {
254
+ const record = asRecord(value);
255
+ if (!record) return null;
256
+ const filter: StorymapSlideFilter = {};
257
+ // same ranges the renderer enforces (see FILTER_RANGES); read-time
258
+ // clamping keeps translator output inside the schema
259
+ const percent = (key: "brightness" | "contrast" | "saturate" | "sepia") => {
260
+ const range = FILTER_RANGES[key];
261
+ const n = record[key];
262
+ if (typeof n === "number" && Number.isFinite(n)) {
263
+ filter[key] = Math.min(range.max, Math.max(range.min, n));
264
+ }
265
+ };
266
+ percent("brightness");
267
+ percent("contrast");
268
+ percent("saturate");
269
+ percent("sepia");
270
+ const hue = record["hue_rotate"];
271
+ if (typeof hue === "number" && Number.isFinite(hue)) {
272
+ filter.hueRotate = Math.min(
273
+ FILTER_RANGES.hueRotate.max,
274
+ Math.max(FILTER_RANGES.hueRotate.min, hue),
275
+ );
276
+ }
277
+ const blur = record["blur"];
278
+ if (typeof blur === "number" && Number.isFinite(blur)) {
279
+ filter.blur = Math.min(FILTER_RANGES.blur.max, Math.max(FILTER_RANGES.blur.min, blur));
280
+ }
281
+ return Object.keys(filter).length > 0 ? filter : null;
282
+ }
283
+
284
+ function readMask(value: unknown, color: unknown, invert: unknown): StorymapSlideMask | null {
285
+ const record = asRecord(value);
286
+ if (!record) return null;
287
+ const { x, y, w, h } = record;
288
+ if (
289
+ typeof x !== "number" ||
290
+ typeof y !== "number" ||
291
+ typeof w !== "number" ||
292
+ typeof h !== "number" ||
293
+ ![x, y, w, h].every(Number.isFinite)
294
+ ) {
295
+ return null;
296
+ }
297
+ const mask: StorymapSlideMask = { x, y, w, h };
298
+ // normalized at render (see normalizeMask); keep author values verbatim
299
+ if (typeof color === "string" && color !== "") mask.color = color;
300
+ if (invert === true) mask.invert = true;
301
+ return mask;
302
+ }
303
+
304
+ function readAudio(
305
+ value: unknown,
306
+ warnings: Warnings,
307
+ sampleId: string,
308
+ ): StorymapSlideNarration | null {
309
+ const record = asRecord(value);
310
+ if (!record) return null;
311
+ const url = asString(record.url);
312
+ if (url === null) return null;
313
+ const narration: StorymapSlideNarration = { url };
314
+ if (typeof record.loop === "boolean") narration.loop = record.loop;
315
+ const offset = record.offset;
316
+ if (typeof offset === "number" && Number.isFinite(offset) && offset >= 0) {
317
+ narration.offset = offset;
318
+ }
319
+ const play = record.play;
320
+ if (play === "auto" || play === "click") {
321
+ narration.play = play;
322
+ } else if (play !== undefined) {
323
+ warnings.add(
324
+ "slideshow.audio-play",
325
+ sampleId,
326
+ `unknown audio.play ${String(play)}, kept auto`,
327
+ );
328
+ narration.play = "auto";
329
+ }
330
+ if (typeof record.stoponexit === "boolean") narration.stopOnExit = record.stoponexit;
331
+ if (typeof record.stopallprevious === "boolean") {
332
+ narration.stopAllPrevious = record.stopallprevious;
333
+ }
334
+ return narration;
335
+ }
336
+
337
+ /** A percent string or number as a 0..1 fraction, or null. */
338
+ function asFraction(value: unknown): number | null {
339
+ if (typeof value === "number" && Number.isFinite(value)) {
340
+ return Math.min(1, Math.max(0.05, value > 1 ? value / 100 : value));
341
+ }
342
+ if (typeof value === "string") {
343
+ const n = Number(value.replace("%", ""));
344
+ if (Number.isFinite(n)) return Math.min(1, Math.max(0.05, n / 100));
345
+ }
346
+ return null;
347
+ }
348
+
349
+ /** A percent-ish value as 10..80, or null. */
350
+ function asPercent(value: unknown): number | null {
351
+ if (typeof value === "number" && Number.isFinite(value)) {
352
+ return Math.min(80, Math.max(10, value));
353
+ }
354
+ if (typeof value === "string") {
355
+ const n = Number(value.replace("%", ""));
356
+ if (Number.isFinite(n)) return Math.min(80, Math.max(10, n));
357
+ }
358
+ return null;
359
+ }
360
+
361
+ /** A 0..100 opacity number, or null. */
362
+ function asOpacity(value: unknown): number | null {
363
+ const n = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
364
+ if (!Number.isFinite(n)) return null;
365
+ return Math.min(100, Math.max(0, n));
366
+ }
367
+
368
+ /** True for a `px`/`%`/`vh` viewer height, matching the schema pattern. */
369
+ function asViewerHeight(value: unknown): string | null {
370
+ if (typeof value !== "string") return null;
371
+ return /^[0-9]+(\.[0-9]+)?(px|%|vh)$/.test(value) ? value : null;
372
+ }
373
+
374
+ function applySettings(
375
+ data: StorymapData,
376
+ slides: StorymapSlide[],
377
+ settings: SlideshowPlayerSettings | null | undefined,
378
+ warnings: Warnings,
379
+ ): void {
380
+ if (!settings) return;
381
+ const target = data as unknown as Record<string, unknown>;
382
+ const mode = settings.mode;
383
+ if (mode === "standard" || mode === "static") {
384
+ target.mode = mode;
385
+ } else if (mode !== undefined) {
386
+ warnings.add("slideshow.mode", null, `unknown mode ${String(mode)}, kept standard`);
387
+ }
388
+ const viewerheight = asViewerHeight(settings.viewerheight);
389
+ if (viewerheight !== null) {
390
+ target.viewerheight = viewerheight;
391
+ } else if (settings.viewerheight !== undefined) {
392
+ warnings.add(
393
+ "slideshow.viewerheight",
394
+ null,
395
+ `unparsable viewerheight ${String(settings.viewerheight)}, omitted`,
396
+ );
397
+ }
398
+ if (settings.autoplay === true) {
399
+ const timeout = asNumber(settings.slidetimeout);
400
+ target.autoplay =
401
+ timeout !== null && Number.isFinite(timeout) && timeout >= 0
402
+ ? Math.round(timeout)
403
+ : 6000;
404
+ if (target.autoplay === 6000 && settings.slidetimeout !== undefined) {
405
+ const raw = Number(settings.slidetimeout);
406
+ if (!Number.isFinite(raw) || raw < 0) {
407
+ warnings.add(
408
+ "slideshow.slidetimeout",
409
+ null,
410
+ `unparsable slidetimeout ${String(settings.slidetimeout)}, kept 6000`,
411
+ );
412
+ }
413
+ }
414
+ } else {
415
+ target.autoplay = 0;
416
+ }
417
+ const fxmode = settings.fxmode;
418
+ if (fxmode === "basic" || fxmode === undefined) {
419
+ if (fxmode === "basic") target.fxmode = "slide";
420
+ } else {
421
+ warnings.add("slideshow.fxmode", null, `unknown fxmode ${String(fxmode)}, omitted`);
422
+ }
423
+ const progressbar = settings.progressbar;
424
+ if (
425
+ typeof progressbar === "string" &&
426
+ (PROGRESSBAR_STYLES as readonly string[]).includes(progressbar)
427
+ ) {
428
+ target.progressbar = progressbar as (typeof PROGRESSBAR_STYLES)[number];
429
+ } else if (progressbar === false || progressbar === "off") {
430
+ target.progressbar = false;
431
+ } else if (progressbar !== undefined) {
432
+ warnings.add(
433
+ "slideshow.progressbar",
434
+ null,
435
+ `unknown progressbar ${String(progressbar)}, omitted`,
436
+ );
437
+ }
438
+ const textmode = settings.textmode;
439
+ if (textmode === "left" || textmode === "right" || textmode === "bottom") {
440
+ target.textmode = textmode;
441
+ // side-dock textsize is dead in the player (a "20%"/2 NaN poisons
442
+ // the calc, so the browser drops the declaration); only the bottom
443
+ // marginBottom takes effect — map it to the panel height share
444
+ if (textmode === "bottom") {
445
+ const size = asPercent(settings.textsize);
446
+ if (size !== null) {
447
+ target.textsize = size;
448
+ } else if (settings.textsize !== undefined) {
449
+ warnings.add(
450
+ "slideshow.textsize",
451
+ null,
452
+ `unparsable textsize ${String(settings.textsize)}, omitted`,
453
+ );
454
+ }
455
+ }
456
+ } else if (textmode !== undefined) {
457
+ warnings.add("slideshow.textmode", null, `unknown textmode ${String(textmode)}, omitted`);
458
+ }
459
+ if (typeof settings.hudcolor === "string" && settings.hudcolor !== "") {
460
+ target.hudcolor = settings.hudcolor;
461
+ }
462
+ if (typeof settings.hudbgcolor === "string" && settings.hudbgcolor !== "") {
463
+ target.hudbgcolor = settings.hudbgcolor;
464
+ }
465
+ const hudopacity = asOpacity(settings.hudopacity);
466
+ if (hudopacity !== null) {
467
+ target.hudopacity = hudopacity;
468
+ } else if (settings.hudopacity !== undefined) {
469
+ warnings.add(
470
+ "slideshow.hudopacity",
471
+ null,
472
+ `unparsable hudopacity ${String(settings.hudopacity)}, omitted`,
473
+ );
474
+ }
475
+ if (typeof settings.shownav === "boolean") target.shownav = settings.shownav;
476
+ if (typeof settings.showfullscreen === "boolean") target.fullscreen = settings.showfullscreen;
477
+ if (typeof settings.showinfo === "boolean") target.show_info = settings.showinfo;
478
+ if (typeof settings.showscrollbars === "boolean") {
479
+ target.show_scrollbars = settings.showscrollbars;
480
+ }
481
+ if (typeof settings.showheadings === "boolean") target.show_headings = settings.showheadings;
482
+ if (typeof settings.bgcolor === "string" && settings.bgcolor !== "") {
483
+ // approximation, documented: the single player background becomes
484
+ // the map background
485
+ target.map_background_color = settings.bgcolor;
486
+ }
487
+ // a player-global overlay fans out to every slide (documented); an
488
+ // enabled overlay without a URL warns
489
+ if (settings.imgoverlay === true) {
490
+ const url = asString(settings.imgoverlayurl);
491
+ if (url !== null) {
492
+ const size = asFraction(settings.imgoverlaysize) ?? 0.5;
493
+ for (const slide of slides) {
494
+ slide.imgoverlay = { url, size };
495
+ }
496
+ } else {
497
+ warnings.add("slideshow.imgoverlay", null, "imgoverlay enabled without a URL");
498
+ }
499
+ }
500
+ }
501
+
502
+ /**
503
+ * Translate a v1 tour (`AnnotationPageSequence`): one page is one slide,
504
+ * ids come from the first item's target, bodies concatenate with a space
505
+ * and the last `#x,y,w,h` fragment wins — mirroring the canonical
506
+ * v1 parser. v1 has no filters/rotation/mask/audio/static fields.
507
+ */
508
+ function translateV1(
509
+ record: Record<string, unknown>,
510
+ docId: string,
511
+ opts: SlideshowTranslatorOptions,
512
+ warnings: Warnings,
513
+ ): SlideshowToStorymapResult {
514
+ const data: StorymapData = { slides: [] };
515
+ const meta = asRecord(record.metadata) ?? {};
516
+ const label = asString(meta.title) ?? "";
517
+ if (label !== "") {
518
+ (data as unknown as Record<string, unknown>).title = label;
519
+ }
520
+ const creator = asString(meta.author);
521
+ const rights = asString(meta.license);
522
+ if (creator !== null || rights !== null) {
523
+ data.credit = {
524
+ ...(creator !== null ? { creator } : {}),
525
+ ...(rights !== null ? { rights } : {}),
526
+ };
527
+ }
528
+ const pages = Array.isArray(record.pages) ? record.pages : [];
529
+ const slides: StorymapSlide[] = [];
530
+ const services: string[] = [];
531
+ pages.forEach((page, index) => {
532
+ const sampleId = `page/${index}`;
533
+ const pageRecord = asRecord(page);
534
+ const items = pageRecord && Array.isArray(pageRecord.items) ? pageRecord.items : [];
535
+ if (items.length === 0) {
536
+ warnings.add("slideshow.page", sampleId, "no items, skipped");
537
+ return;
538
+ }
539
+ const slide: StorymapSlide = {};
540
+ const head = asRecord(items[0]);
541
+ const headTarget = head ? asRecord(head.target) : null;
542
+ const annotationId = (head ? asString(head.id) : null) ?? `${docId}/page/${index}`;
543
+ slide.uniqueid = annotationId;
544
+ const texts: string[] = [];
545
+ let region: [number, number, number, number] | null = null;
546
+ let rawTarget = false;
547
+ for (const item of items) {
548
+ const annotation = asRecord(item);
549
+ if (!annotation) continue;
550
+ const body = asRecord(annotation.body);
551
+ const value = body ? (asString(body.value) ?? "") : "";
552
+ if (value !== "") texts.push(value);
553
+ // last fragment wins, like the canonical parser
554
+ const split = splitTarget(annotation.target);
555
+ if (split.raw) {
556
+ rawTarget = true;
557
+ if (split.region !== null) region = split.region;
558
+ }
559
+ }
560
+ if (texts.length === 0 && region === null && headTarget === null) {
561
+ warnings.add("slideshow.page", sampleId, "nothing to show, skipped");
562
+ return;
563
+ }
564
+ if (rawTarget && region === null) {
565
+ warnings.add("slideshow.target", sampleId, "unparsable region, kept whole");
566
+ }
567
+ const text = texts.join(" ");
568
+ slide.text = {
569
+ ...(index === 0 && label !== "" ? { headline: label } : {}),
570
+ ...(text !== "" ? { text } : {}),
571
+ };
572
+ const location: StorymapSlide["location"] = {};
573
+ if (region !== null) location.region = region;
574
+ const service = headTarget ? asString(headTarget.image_srv) : null;
575
+ if (service !== null) {
576
+ location.basemap = service;
577
+ services.push(service);
578
+ }
579
+ const provenance: Record<string, unknown> = {};
580
+ if (headTarget) {
581
+ for (const [key, source] of [
582
+ ["manifest", headTarget.manifest_id],
583
+ ["canvas", headTarget.canvas_id],
584
+ ["image", headTarget.image_id],
585
+ ] as const) {
586
+ const id = asString(source);
587
+ if (id !== null) provenance[key] = id;
588
+ }
589
+ }
590
+ if (Object.keys(provenance).length > 0) {
591
+ slide.provenance = provenance as StorymapSlide["provenance"];
592
+ }
593
+ if (Object.keys(location).length > 0) slide.location = location;
594
+ slides.push(slide);
595
+ });
596
+ data.slides = slides;
597
+ data.map_type = "iiif";
598
+ data.map_as_image = true;
599
+ const iiif: { url: string; attribution: string } = { url: "", attribution: "" };
600
+ const firstService = services.length > 0 ? (services[0] as string) : null;
601
+ if (firstService !== null && !isStaticImageUrl(firstService)) {
602
+ iiif.url = serviceToInfoJson(firstService);
603
+ }
604
+ (data as unknown as Record<string, unknown>).iiif = iiif;
605
+ applySettings(data, slides, opts.settings, warnings);
606
+ return { data, warnings: warnings.list() };
607
+ }
608
+
609
+ /**
610
+ * Translate one slideshow tour document (plus any following pages) into
611
+ * storymap data. Slideshow-only fields land verbatim; internal validation
612
+ * (`validateStorymap`) must still pass on the result.
613
+ */
614
+ export function slideshowToStorymapData(
615
+ doc: unknown,
616
+ opts: SlideshowTranslatorOptions = {},
617
+ ): SlideshowToStorymapResult {
618
+ const warnings = new Warnings();
619
+ const record = asRecord(doc);
620
+ const data: StorymapData = { slides: [] };
621
+ if (!record) {
622
+ warnings.add("slideshow.document", null, "not an object, no slides");
623
+ return { data, warnings: warnings.list() };
624
+ }
625
+ const version = slideshowVersion(doc);
626
+ const docId = asString(record.id) ?? "";
627
+ if (version === 0) {
628
+ warnings.add("slideshow.version", null, "unknown version, no slides");
629
+ return { data, warnings: warnings.list() };
630
+ }
631
+ if (version === 1) {
632
+ return translateV1(record, docId, opts, warnings);
633
+ }
634
+ const label = flattenLabel(record.label);
635
+ if (label !== "") {
636
+ (data as unknown as Record<string, unknown>).title = label;
637
+ }
638
+ const creator = asString(record.creator);
639
+ const rights = asString(record.rights);
640
+ if (creator !== null || rights !== null) {
641
+ data.credit = {
642
+ ...(creator !== null ? { creator } : {}),
643
+ ...(rights !== null ? { rights } : {}),
644
+ };
645
+ }
646
+ const metadata = record.metadata;
647
+ if (Array.isArray(metadata)) {
648
+ (data as unknown as Record<string, unknown>).metadata = metadata.filter(
649
+ (entry) => asRecord(entry) !== null,
650
+ );
651
+ }
652
+ // page chain: the first page inline, the rest via opts (callers follow
653
+ // `next` links: CLI and viewer, bounded)
654
+ const pages: unknown[] = [];
655
+ const first = asRecord(record.first);
656
+ if (first) pages.push(first);
657
+ for (const extra of opts.extraPages ?? []) {
658
+ if (pages.length - 1 >= SLIDESHOW_MAX_PAGES) {
659
+ warnings.add(
660
+ "slideshow.paging",
661
+ null,
662
+ `kept the first ${SLIDESHOW_MAX_PAGES} extra pages, dropped the rest`,
663
+ );
664
+ break;
665
+ }
666
+ pages.push(extra);
667
+ }
668
+ const nextLink = first ? asString(first.next) : null;
669
+ if (nextLink !== null && (opts.extraPages ?? []).length === 0) {
670
+ warnings.add(
671
+ "slideshow.paging",
672
+ null,
673
+ `paged tour truncated after the first page (${nextLink})`,
674
+ );
675
+ }
676
+ const items: unknown[] = [];
677
+ for (const page of pages) {
678
+ const pageRecord = asRecord(page);
679
+ if (pageRecord && Array.isArray(pageRecord.items)) {
680
+ items.push(...pageRecord.items);
681
+ }
682
+ }
683
+ const slides: StorymapSlide[] = [];
684
+ let fallbackUrl: string | null = null;
685
+ const services: string[] = [];
686
+ items.forEach((item, index) => {
687
+ const sampleId = `annotation/${index}`;
688
+ const annotation = asRecord(item);
689
+ if (!annotation) {
690
+ warnings.add("slideshow.annotation", sampleId, "not an object, skipped");
691
+ return;
692
+ }
693
+ const slide: StorymapSlide = {};
694
+ const annotationId =
695
+ asString(annotation.id) ?? (docId !== "" ? `${docId}/annotation/${index}` : sampleId);
696
+ slide.uniqueid = annotationId;
697
+ const body = asRecord(annotation.body);
698
+ const value = body ? (asString(body.value) ?? "") : "";
699
+ const format = body ? asString(body.format) : null;
700
+ const text =
701
+ format !== null && /^text\/plain/i.test(format) && value.trim() !== ""
702
+ ? `<p>${escapeText(value)}</p>`
703
+ : value;
704
+ slide.text = {
705
+ ...(index === 0 && label !== "" ? { headline: label } : {}),
706
+ ...(text !== "" ? { text } : {}),
707
+ };
708
+ const target = splitTarget(annotation.target);
709
+ if (target.raw && target.region === null) {
710
+ warnings.add(
711
+ "slideshow.target",
712
+ sampleId,
713
+ "unparsable or fully-outside region, kept whole",
714
+ );
715
+ }
716
+ const location: StorymapSlide["location"] = {};
717
+ if (target.region !== null) location.region = target.region;
718
+ const strollview = asRecord(annotation.strollview) ?? {};
719
+ const rotation = asNumber(strollview.rotation);
720
+ if (rotation !== null && rotation !== 0) location.rotation = rotation;
721
+ const filter = readFilter(strollview.filters);
722
+ if (filter !== null) location.filter = filter;
723
+ const mask = readMask(
724
+ strollview.passepartout,
725
+ strollview.passepartout_color,
726
+ strollview.passepartout_invert,
727
+ );
728
+ if (mask !== null) {
729
+ location.mask = mask;
730
+ } else if (strollview.passepartout !== undefined && strollview.passepartout !== false) {
731
+ warnings.add("slideshow.passepartout", sampleId, "unusable shape, omitted");
732
+ }
733
+ const narration = readAudio(strollview.audio, warnings, sampleId);
734
+ // only an image service (or static file) can back a basemap; a
735
+ // manifest URL is provenance, never a tile source
736
+ const service = asString(strollview.image_srv);
737
+ if (service !== null) {
738
+ location.basemap = service;
739
+ services.push(service);
740
+ }
741
+ const provenance: Record<string, unknown> = {};
742
+ for (const [key, source] of [
743
+ ["manifest", strollview.manifest_id],
744
+ ["canvas", strollview.canvas_id],
745
+ ["image", strollview.image_id],
746
+ ] as const) {
747
+ const id = asString(source) ?? (key === "canvas" ? target.canvas : null);
748
+ if (id !== null) provenance[key] = id;
749
+ }
750
+ if (Object.keys(provenance).length > 0) {
751
+ slide.provenance = provenance as StorymapSlide["provenance"];
752
+ }
753
+ const imageStatic = asString(strollview.image_static);
754
+ if (imageStatic !== null) {
755
+ slide.media = { url: imageStatic };
756
+ if (fallbackUrl === null) fallbackUrl = imageStatic;
757
+ }
758
+ if (Object.keys(location).length > 0) slide.location = location;
759
+ if (narration !== null) {
760
+ if (isDuplicateNarration([slide.media?.url], narration.url)) {
761
+ warnings.add("slideshow.audio-dup", sampleId, "audio duplicates media, kept media");
762
+ } else {
763
+ slide.narration = narration;
764
+ }
765
+ }
766
+ slides.push(slide);
767
+ });
768
+ data.slides = slides;
769
+ data.map_type = "iiif";
770
+ data.map_as_image = true;
771
+ const iiif: { url: string; attribution: string; fallbackUrl?: string } = {
772
+ url: "",
773
+ attribution: "",
774
+ };
775
+ const firstService = services.length > 0 ? (services[0] as string) : null;
776
+ if (firstService !== null && !isStaticImageUrl(firstService)) {
777
+ iiif.url = serviceToInfoJson(firstService);
778
+ }
779
+ // a static first service (or none at all) leaves url empty: the story
780
+ // renders from the fallback image once probed (slideshow tours only)
781
+ if (fallbackUrl !== null) iiif.fallbackUrl = fallbackUrl;
782
+ (data as unknown as Record<string, unknown>).iiif = iiif;
783
+ applySettings(data, slides, opts.settings, warnings);
784
+ return { data, warnings: warnings.list() };
785
+ }