@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.
- package/CHANGELOG +81 -0
- package/README.md +129 -12
- package/dist/assets/{demo-DBpeF31K.js → demo-bWnOyfUw.js} +1 -1
- package/dist/assets/harness-C3JUv5Ii.js +1 -0
- package/dist/assets/{harness-multi-CYHgI0a9.js → harness-multi-XZNex3tb.js} +1 -1
- package/dist/assets/{harness-support-Do7sIp2j.js → harness-support-CjfZIVeb.js} +1 -1
- package/dist/assets/main-DQ32hWBM.js +17 -0
- package/dist/context.json +13 -2
- package/dist/css/storymap.css +1 -1
- package/dist/demo.html +3 -2
- package/dist/docs/api/assets/navigation.js +1 -1
- package/dist/docs/api/assets/search.js +1 -1
- package/dist/docs/api/classes/StoryMap.html +777 -269
- package/dist/docs/api/functions/MediaType.html +1 -1
- package/dist/docs/api/functions/isPresentation3Collection.html +1 -1
- package/dist/docs/api/functions/isPresentation3Manifest.html +1 -1
- package/dist/docs/api/functions/isSlideshowCollection.html +51 -0
- package/dist/docs/api/functions/loadCSS.html +1 -1
- package/dist/docs/api/functions/manifestToStorymapData.html +1 -1
- package/dist/docs/api/functions/setLanguage.html +1 -1
- package/dist/docs/api/functions/slideshowToStorymapData.html +51 -0
- package/dist/docs/api/functions/slideshowVersion.html +48 -0
- package/dist/docs/api/functions/storymapToManifest.html +1 -1
- package/dist/docs/api/functions/validateStorymap.html +1 -1
- package/dist/docs/api/functions/validateStorymapAndReport.html +1 -1
- package/dist/docs/api/index.html +115 -12
- package/dist/docs/api/interfaces/AnimationHandle.html +2 -2
- package/dist/docs/api/interfaces/ImagereadyPayload.html +4 -4
- package/dist/docs/api/interfaces/LanguageEntry.html +6 -6
- package/dist/docs/api/interfaces/LatLngLiteral.html +4 -4
- package/dist/docs/api/interfaces/LoadOptions.html +2 -2
- package/dist/docs/api/interfaces/MapEvents.html +116 -0
- package/dist/docs/api/interfaces/MapMarkerEvents.html +66 -0
- package/dist/docs/api/interfaces/MarkerEventPayload.html +57 -0
- package/dist/docs/api/interfaces/MediaEvents.html +78 -0
- package/dist/docs/api/interfaces/MenuBarEvents.html +114 -0
- package/dist/docs/api/interfaces/MessageEvents.html +72 -0
- package/dist/docs/api/interfaces/OlLayer.html +1 -1
- package/dist/docs/api/interfaces/OlMap.html +1 -1
- package/dist/docs/api/interfaces/OlSource.html +1 -1
- package/dist/docs/api/interfaces/OlTileLayer.html +1 -1
- package/dist/docs/api/interfaces/OlVectorLayer.html +1 -1
- package/dist/docs/api/interfaces/OlView.html +1 -1
- package/dist/docs/api/interfaces/SlideBackgroundState.html +66 -0
- package/dist/docs/api/interfaces/SlideEvents.html +78 -0
- package/dist/docs/api/interfaces/SlideNavEvents.html +72 -0
- package/dist/docs/api/interfaces/SlideshowPlayerSettings.html +182 -0
- package/dist/docs/api/interfaces/SlideshowToStorymapResult.html +60 -0
- package/dist/docs/api/interfaces/SlideshowTranslatorOptions.html +65 -0
- package/dist/docs/api/interfaces/SlideshowWarning.html +72 -0
- package/dist/docs/api/interfaces/StoryMapEvents.html +146 -0
- package/dist/docs/api/interfaces/StoryMapMarker.html +763 -0
- package/dist/docs/api/interfaces/StorySliderEvents.html +102 -0
- package/dist/docs/api/interfaces/StorymapBasemap.html +69 -0
- package/dist/docs/api/interfaces/StorymapData.html +12 -6
- package/dist/docs/api/interfaces/StorymapDataWrapper.html +2 -2
- package/dist/docs/api/interfaces/StorymapDocument.html +2 -2
- package/dist/docs/api/interfaces/StorymapError.html +3 -3
- package/dist/docs/api/interfaces/StorymapGeoreference.html +5 -5
- package/dist/docs/api/interfaces/StorymapGeoreferenceBody.html +4 -4
- package/dist/docs/api/interfaces/StorymapGroundControlPoint.html +4 -4
- package/dist/docs/api/interfaces/StorymapLanguageMap.html +2 -2
- package/dist/docs/api/interfaces/StorymapManifest.html +12 -12
- package/dist/docs/api/interfaces/StorymapManifestAnnotation.html +43 -11
- package/dist/docs/api/interfaces/StorymapManifestBackground.html +6 -6
- package/dist/docs/api/interfaces/StorymapManifestCanvas.html +65 -17
- package/dist/docs/api/interfaces/StorymapManifestContentResource.html +8 -8
- package/dist/docs/api/interfaces/StorymapManifestFeatureCollection.html +4 -4
- package/dist/docs/api/interfaces/StorymapManifestGeoreferencing.html +7 -7
- package/dist/docs/api/interfaces/StorymapManifestImageService.html +4 -4
- package/dist/docs/api/interfaces/StorymapManifestRange.html +5 -5
- package/dist/docs/api/interfaces/StorymapManifestService.html +4 -4
- package/dist/docs/api/interfaces/StorymapManifestSpecificResource.html +4 -4
- package/dist/docs/api/interfaces/StorymapManifestStatement.html +3 -3
- package/dist/docs/api/interfaces/StorymapMapOptions.html +5 -5
- package/dist/docs/api/interfaces/StorymapOptions.html +277 -75
- package/dist/docs/api/interfaces/StorymapOverlayLayer.html +42 -11
- package/dist/docs/api/interfaces/StorymapSlide.html +70 -16
- package/dist/docs/api/interfaces/StorymapSlideBackground.html +4 -4
- package/dist/docs/api/interfaces/StorymapSlideLocation.html +58 -14
- package/dist/docs/api/interfaces/StorymapSlideMarker.html +8 -9
- package/dist/docs/api/interfaces/StorymapSlideMedia.html +49 -10
- package/dist/docs/api/interfaces/StorymapSlideNarration.html +61 -9
- package/dist/docs/api/interfaces/StorymapSlideText.html +4 -4
- package/dist/docs/api/interfaces/StorymapTilejson.html +8 -8
- package/dist/docs/api/interfaces/SwipableEvents.html +102 -0
- package/dist/docs/api/interfaces/TextEvents.html +66 -0
- package/dist/docs/api/media/events.md +66 -0
- package/dist/docs/api/media/migration-from-knightlab.md +89 -49
- package/dist/docs/api/media/slideshow.md +109 -0
- package/dist/docs/api/media/storymap-as-iiif-manifest.md +1007 -0
- package/dist/docs/api/modules.html +6 -6
- package/dist/docs/api/types/ContentState.html +3 -3
- package/dist/docs/api/types/FiredEvent.html +43 -0
- package/dist/docs/api/types/ImagereadyKind.html +1 -1
- package/dist/docs/api/types/MediaTypeMatch.html +5 -5
- package/dist/docs/api/types/SwipeDirection.html +36 -0
- package/dist/docs/api/types/TileSourceFactory.html +1 -1
- package/dist/docs/authoring.html +20 -7
- package/dist/docs/events.html +263 -0
- package/dist/docs/iiif.html +136 -22
- package/dist/docs/migration.html +66 -19
- package/dist/docs/readme.html +113 -12
- package/dist/embed/index.html +7 -0
- package/dist/examples/font-css-false.json +33 -0
- package/dist/examples/issue-image-region.json +1 -1
- package/dist/examples/layers-control.json +60 -0
- package/dist/examples/map-template-provider.json +36 -0
- package/dist/examples/multi-media.json +71 -0
- package/dist/examples-iiif/annotated-image.json +16 -0
- package/dist/examples-iiif/font-css-false.json +114 -0
- package/dist/examples-iiif/issue-image-region.json +1 -1
- package/dist/examples-iiif/layers-control.json +226 -0
- package/dist/examples-iiif/map-template-provider.json +133 -0
- package/dist/examples-iiif/multi-media.json +237 -0
- package/dist/examples-slideshow/single.json +79 -0
- package/dist/examples-slideshow/static/pixel.png +0 -0
- package/dist/examples-slideshow/static.json +44 -0
- package/dist/harness-multi.html +3 -3
- package/dist/harness.html +4 -3
- package/dist/index.html +3 -2
- package/dist/js/storymap.d.ts +1326 -289
- package/dist/js/storymap.js +6690 -4784
- package/dist/js/storymap.js.map +1 -1
- package/dist/navplace-properties.json +14 -0
- package/dist/site.css +54 -36
- package/docs/migration-from-knightlab.md +89 -49
- package/docs/storymap-as-iiif-manifest.md +199 -126
- package/package.json +4 -2
- package/schema/storymap.schema.json +334 -34
- package/src/core/Util.ts +3 -1
- package/src/core/mixins.ts +42 -28
- package/src/language/Language.ts +7 -0
- package/src/language/locale/.expected-gaps.json +287 -27
- package/src/language/locale/de.json +11 -1
- package/src/language/locale/en.json +11 -1
- package/src/main.ts +51 -0
- package/src/map/Map.ts +44 -6
- package/src/map/MapMarker.ts +16 -3
- package/src/map/openlayers/Map.OpenLayers.ts +787 -62
- package/src/map/openlayers/MapMarker.OpenLayers.ts +113 -13
- package/src/map/slideView.ts +149 -0
- package/src/map/types.ts +2 -0
- package/src/media/EmbedUtil.ts +27 -2
- package/src/media/Media.ts +27 -4
- package/src/media/embedId.ts +6 -0
- package/src/media/types/Flickr.ts +14 -3
- package/src/media/types/HtmlMedia.ts +66 -0
- package/src/media/types/Image.ts +15 -3
- package/src/media/types/SoundCloud.ts +1 -1
- package/src/media/types/Text.ts +27 -8
- package/src/media/types/Twitter.ts +9 -7
- package/src/media/types/Vimeo.ts +1 -1
- package/src/media/types/YouTube.ts +2 -2
- package/src/scss/Typography.scss +15 -15
- package/src/scss/VCO.StoryMap.scss +173 -8
- package/src/scss/core/Reset.scss +5 -18
- package/src/scss/map/VCO.Map.scss +1 -1
- package/src/scss/map/VCO.MapMarker.scss +20 -9
- package/src/scss/map/openlayers/VCO.Map.OpenLayers.scss +7 -4
- package/src/scss/media/VCO.Media.scss +7 -7
- package/src/scss/media/types/VCO.Media.Blockquote.scss +1 -1
- package/src/scss/media/types/VCO.Media.Text.scss +5 -5
- package/src/scss/media/types/VCO.Media.Twitter.scss +4 -4
- package/src/scss/media/types/VCO.Media.Wikipedia.scss +2 -19
- package/src/scss/site/site.scss +55 -36
- package/src/scss/slider/VCO.Slide.scss +49 -11
- package/src/scss/slider/VCO.SlideNav.scss +23 -7
- package/src/scss/slider/VCO.StorySlider.scss +50 -0
- package/src/scss/snapmap/VCO.SnapMap.scss +3 -3
- package/src/scss/ui/VCO.Consent.scss +20 -14
- package/src/scss/ui/VCO.Layers.scss +119 -0
- package/src/scss/ui/VCO.MenuBar.Button.scss +140 -19
- package/src/scss/ui/VCO.MenuBar.scss +9 -2
- package/src/scss/ui/VCO.Message.scss +14 -0
- package/src/slider/Slide.ts +232 -74
- package/src/slider/SlideNav.ts +15 -4
- package/src/slider/StorySlider.ts +203 -22
- package/src/storymap/Consent.ts +22 -0
- package/src/storymap/StoryMap.ts +944 -131
- package/src/storymap/content-state.ts +3 -8
- package/src/storymap/font-css.ts +72 -0
- package/src/storymap/from-slideshow.ts +785 -0
- package/src/storymap/iiif-shared.ts +228 -0
- package/src/storymap/iiif.ts +289 -100
- package/src/storymap/to-iiif.ts +131 -44
- package/src/types.ts +382 -17
- package/src/ui/LayersControl.ts +302 -0
- package/src/ui/MenuBar.ts +232 -21
- package/src/ui/Message.ts +36 -3
- package/src/ui/Swipable.ts +25 -7
- package/dist/assets/harness-Cr0rYpvt.js +0 -1
- package/dist/assets/main-DXalctuR.js +0 -17
- package/src/scss/VCO.StoryMap.Dark.scss +0 -150
- 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
|
+
}
|