@projektemacher/storymapjs 0.10.7 → 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 +431 -19
- package/README.md +168 -17
- package/dist/assets/demo-bWnOyfUw.js +1 -0
- package/dist/assets/harness-C3JUv5Ii.js +1 -0
- package/dist/assets/harness-multi-XZNex3tb.js +1 -0
- package/dist/assets/harness-support-CjfZIVeb.js +1 -0
- package/dist/assets/index-wHPqGrBS.js +1 -0
- package/dist/assets/main-DQ32hWBM.js +17 -0
- package/dist/context.json +23 -9
- package/dist/css/fonts/files/amatic-sc-cyrillic-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/amatic-sc-cyrillic-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/amatic-sc-hebrew-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/amatic-sc-hebrew-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/amatic-sc-vietnamese-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/amatic-sc-vietnamese-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-100-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-100-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-200-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-200-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-300-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-300-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-500-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-500-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-600-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-600-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-800-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-800-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-900-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-900-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-100-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-100-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-200-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-200-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-300-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-300-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-500-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-500-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-600-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-600-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-800-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-800-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-900-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-900-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-100-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-100-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-200-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-200-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-300-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-300-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-500-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-500-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-600-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-600-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-800-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-800-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-900-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-900-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-arabic-200-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-arabic-300-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-arabic-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-arabic-500-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-arabic-600-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-arabic-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-arabic-800-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-arabic-900-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-200-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-300-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-500-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-600-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-800-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-900-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-ext-200-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-ext-300-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-ext-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-ext-500-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-ext-600-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-ext-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-ext-800-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-ext-900-normal.woff2 +0 -0
- package/dist/css/fonts/files/dancing-script-vietnamese-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/eb-garamond-cyrillic-ext-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/eb-garamond-cyrillic-ext-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/eb-garamond-cyrillic-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/eb-garamond-cyrillic-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/eb-garamond-greek-ext-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/eb-garamond-greek-ext-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/eb-garamond-greek-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/eb-garamond-greek-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/eb-garamond-vietnamese-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/eb-garamond-vietnamese-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-cyrillic-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-cyrillic-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-cyrillic-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-cyrillic-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-cyrillic-ext-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-cyrillic-ext-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-cyrillic-ext-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-cyrillic-ext-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-greek-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-greek-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-greek-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-greek-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-greek-ext-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-greek-ext-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-greek-ext-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-greek-ext-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-vietnamese-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-vietnamese-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-vietnamese-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-vietnamese-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/old-standard-tt-cyrillic-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/old-standard-tt-cyrillic-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/old-standard-tt-cyrillic-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/old-standard-tt-cyrillic-ext-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/old-standard-tt-cyrillic-ext-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/old-standard-tt-cyrillic-ext-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/old-standard-tt-vietnamese-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/old-standard-tt-vietnamese-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/old-standard-tt-vietnamese-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-cyrillic-ext-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-cyrillic-ext-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-cyrillic-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-cyrillic-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-greek-ext-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-greek-ext-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-greek-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-greek-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-hebrew-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-hebrew-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-vietnamese-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-vietnamese-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-cyrillic-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-cyrillic-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-cyrillic-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-cyrillic-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-cyrillic-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-cyrillic-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-cyrillic-900-italic.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-cyrillic-900-normal.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-vietnamese-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-vietnamese-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-vietnamese-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-vietnamese-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-vietnamese-900-italic.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-vietnamese-900-normal.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-vietnamese-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-vietnamese-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-cyrillic-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-cyrillic-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-cyrillic-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-cyrillic-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-cyrillic-ext-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-cyrillic-ext-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-cyrillic-ext-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-cyrillic-ext-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-narrow-cyrillic-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-narrow-cyrillic-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-narrow-cyrillic-ext-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-narrow-cyrillic-ext-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-serif-cyrillic-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/pt-serif-cyrillic-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-serif-cyrillic-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/pt-serif-cyrillic-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-serif-cyrillic-ext-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/pt-serif-cyrillic-ext-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-serif-cyrillic-ext-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/pt-serif-cyrillic-ext-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/raleway-cyrillic-ext-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/raleway-cyrillic-ext-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/raleway-cyrillic-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/raleway-cyrillic-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/raleway-vietnamese-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/raleway-vietnamese-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/roboto-slab-cyrillic-ext-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/roboto-slab-cyrillic-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/roboto-slab-greek-ext-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/roboto-slab-greek-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/roboto-slab-vietnamese-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-300-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-300-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-500-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-500-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-ext-300-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-ext-300-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-ext-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-ext-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-ext-500-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-ext-500-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-ext-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-ext-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-300-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-300-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-500-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-500-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-ext-300-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-ext-300-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-ext-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-ext-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-ext-500-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-ext-500-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-ext-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-ext-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/unica-one-vietnamese-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/vollkorn-cyrillic-ext-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/vollkorn-cyrillic-ext-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/vollkorn-cyrillic-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/vollkorn-cyrillic-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/vollkorn-greek-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/vollkorn-greek-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/vollkorn-vietnamese-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/vollkorn-vietnamese-wght-normal.woff2 +0 -0
- package/dist/css/fonts/font.abril-droidsans.css +3 -0
- package/dist/css/fonts/font.amatic-andika.css +57 -0
- package/dist/css/fonts/font.bitter-raleway.css +111 -0
- package/dist/css/fonts/font.cairo.css +312 -0
- package/dist/css/fonts/font.clicker-garamond.css +93 -0
- package/dist/css/fonts/font.dancing-ledger.css +12 -0
- package/dist/css/fonts/font.default.css +183 -0
- package/dist/css/fonts/font.fjalla-average.css +3 -0
- package/dist/css/fonts/font.georgia-helvetica.css +3 -0
- package/dist/css/fonts/font.knightlab.css +642 -0
- package/dist/css/fonts/font.lustria-lato.css +3 -0
- package/dist/css/fonts/font.medula-lato.css +3 -0
- package/dist/css/fonts/font.oldstandard.css +84 -0
- package/dist/css/fonts/font.opensans-gentiumbook.css +183 -0
- package/dist/css/fonts/font.playfair-faunaone.css +39 -0
- package/dist/css/fonts/font.playfair.css +147 -0
- package/dist/css/fonts/font.pt.css +183 -0
- package/dist/css/fonts/font.roboto-megrim.css +3 -0
- package/dist/css/fonts/font.rufina-sintony.css +3 -0
- package/dist/css/fonts/font.ubuntu.css +291 -0
- package/dist/css/fonts/font.unicaone-vollkorn.css +84 -0
- package/dist/css/storymap.css +1 -1
- package/dist/demo.html +3 -2
- package/dist/docs/api/.nojekyll +1 -0
- package/dist/docs/api/assets/custom.css +47 -0
- package/dist/docs/api/assets/hierarchy.js +1 -0
- package/dist/docs/api/assets/highlight.css +57 -0
- package/dist/docs/api/assets/icons.js +18 -0
- package/dist/docs/api/assets/icons.svg +1 -0
- package/dist/docs/api/assets/main.js +60 -0
- package/dist/docs/api/assets/navigation.js +1 -0
- package/dist/docs/api/assets/search.js +1 -0
- package/dist/docs/api/assets/style.css +1648 -0
- package/dist/docs/api/classes/StoryMap.html +2112 -0
- package/dist/docs/api/functions/MediaType.html +51 -0
- package/dist/docs/api/functions/isPresentation3Collection.html +47 -0
- package/dist/docs/api/functions/isPresentation3Manifest.html +53 -0
- package/dist/docs/api/functions/isSlideshowCollection.html +51 -0
- package/dist/docs/api/functions/loadCSS.html +63 -0
- package/dist/docs/api/functions/manifestToStorymapData.html +49 -0
- package/dist/docs/api/functions/setLanguage.html +54 -0
- 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 +57 -0
- package/dist/docs/api/functions/validateStorymap.html +53 -0
- package/dist/docs/api/functions/validateStorymapAndReport.html +54 -0
- package/dist/docs/api/hierarchy.html +28 -0
- package/dist/docs/api/index.html +300 -0
- package/dist/docs/api/interfaces/AnimationHandle.html +60 -0
- package/dist/docs/api/interfaces/ImagereadyPayload.html +71 -0
- package/dist/docs/api/interfaces/LanguageEntry.html +102 -0
- package/dist/docs/api/interfaces/LatLngLiteral.html +73 -0
- package/dist/docs/api/interfaces/LoadOptions.html +59 -0
- 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 +1400 -0
- package/dist/docs/api/interfaces/OlMap.html +1656 -0
- package/dist/docs/api/interfaces/OlProjection.html +332 -0
- package/dist/docs/api/interfaces/OlSource.html +784 -0
- package/dist/docs/api/interfaces/OlTileLayer.html +1497 -0
- package/dist/docs/api/interfaces/OlVectorLayer.html +1559 -0
- package/dist/docs/api/interfaces/OlView.html +1958 -0
- 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 +77 -0
- package/dist/docs/api/interfaces/StorymapDataWrapper.html +60 -0
- package/dist/docs/api/interfaces/StorymapDocument.html +68 -0
- package/dist/docs/api/interfaces/StorymapError.html +63 -0
- package/dist/docs/api/interfaces/StorymapGeoreference.html +93 -0
- package/dist/docs/api/interfaces/StorymapGeoreferenceBody.html +74 -0
- package/dist/docs/api/interfaces/StorymapGroundControlPoint.html +69 -0
- package/dist/docs/api/interfaces/StorymapLanguageMap.html +57 -0
- package/dist/docs/api/interfaces/StorymapManifest.html +118 -0
- package/dist/docs/api/interfaces/StorymapManifestAnnotation.html +137 -0
- package/dist/docs/api/interfaces/StorymapManifestBackground.html +81 -0
- package/dist/docs/api/interfaces/StorymapManifestCanvas.html +188 -0
- package/dist/docs/api/interfaces/StorymapManifestContentResource.html +97 -0
- package/dist/docs/api/interfaces/StorymapManifestFeatureCollection.html +82 -0
- package/dist/docs/api/interfaces/StorymapManifestGeoreferencing.html +87 -0
- package/dist/docs/api/interfaces/StorymapManifestImageService.html +69 -0
- package/dist/docs/api/interfaces/StorymapManifestRange.html +75 -0
- package/dist/docs/api/interfaces/StorymapManifestService.html +77 -0
- package/dist/docs/api/interfaces/StorymapManifestSpecificResource.html +69 -0
- package/dist/docs/api/interfaces/StorymapManifestStatement.html +63 -0
- package/dist/docs/api/interfaces/StorymapMapOptions.html +92 -0
- package/dist/docs/api/interfaces/StorymapOptions.html +734 -0
- package/dist/docs/api/interfaces/StorymapOverlayLayer.html +157 -0
- package/dist/docs/api/interfaces/StorymapSlide.html +186 -0
- package/dist/docs/api/interfaces/StorymapSlideBackground.html +72 -0
- package/dist/docs/api/interfaces/StorymapSlideLocation.html +161 -0
- package/dist/docs/api/interfaces/StorymapSlideMarker.html +103 -0
- package/dist/docs/api/interfaces/StorymapSlideMedia.html +149 -0
- package/dist/docs/api/interfaces/StorymapSlideNarration.html +117 -0
- package/dist/docs/api/interfaces/StorymapSlideText.html +72 -0
- package/dist/docs/api/interfaces/StorymapTilejson.html +105 -0
- package/dist/docs/api/interfaces/SwipableEvents.html +102 -0
- package/dist/docs/api/interfaces/TextEvents.html +66 -0
- package/dist/docs/api/media/es.json +31 -0
- package/dist/docs/api/media/events.md +66 -0
- package/dist/docs/api/media/migration-from-knightlab.md +399 -0
- 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 +50 -0
- package/dist/docs/api/types/ContentState.html +70 -0
- package/dist/docs/api/types/FiredEvent.html +43 -0
- package/dist/docs/api/types/ImagereadyKind.html +34 -0
- package/dist/docs/api/types/MediaTypeMatch.html +78 -0
- package/dist/docs/api/types/SwipeDirection.html +36 -0
- package/dist/docs/api/types/TileSourceFactory.html +53 -0
- package/dist/docs/authoring.html +306 -0
- package/dist/docs/events.html +263 -0
- package/dist/docs/iiif.html +738 -69
- package/dist/docs/migration.html +360 -11
- package/dist/docs/readme.html +149 -17
- package/dist/embed/index.html +8 -1
- package/dist/examples/font-css-false.json +33 -0
- package/dist/examples/issue-242-nonlatin.json +55 -0
- package/dist/examples/issue-305-start-at-slide-data.json +96 -0
- package/dist/examples/issue-358-iframe.json +57 -0
- package/dist/examples/issue-iiif-geo.json +32 -0
- package/dist/examples/issue-image-alt.json +23 -0
- package/dist/examples/issue-image-region.json +42 -0
- package/dist/examples/issue-image-srcset.json +24 -0
- package/dist/examples/layers-control.json +60 -0
- package/dist/examples/map-area-overlays.json +68 -0
- package/dist/examples/map-template-provider.json +36 -0
- package/dist/examples/multi-media.json +71 -0
- package/dist/examples/no-map.json +44 -0
- package/dist/examples-iiif/annotated-image.json +204 -0
- package/dist/examples-iiif/bob-boilen.json +11 -5
- package/dist/examples-iiif/courbet.json +9 -6
- package/dist/examples-iiif/empty.json +8 -4
- package/dist/examples-iiif/flickr-drewvigal.json +248 -24
- package/dist/examples-iiif/flickr-zach.wise.json +8 -4
- package/dist/examples-iiif/font-css-false.json +114 -0
- package/dist/examples-iiif/georeferenced-layer-unsupported.json +153 -0
- package/dist/examples-iiif/georeferenced-layer.json +318 -0
- package/dist/examples-iiif/iiif-wellcome.json +9 -6
- package/dist/examples-iiif/instagram-chenpamela.json +381 -36
- package/dist/examples-iiif/instagram_brentfoster.json +429 -40
- package/dist/examples-iiif/instagram_couch.json +261 -26
- package/dist/examples-iiif/instagram_joegermuska.json +441 -41
- package/dist/examples-iiif/instagram_matteich.json +261 -26
- package/dist/examples-iiif/instagram_mirandamulligan.json +261 -26
- package/dist/examples-iiif/instagram_raymondmccrea.json +261 -26
- package/dist/examples-iiif/instagram_test.json +261 -26
- package/dist/examples-iiif/issue-134-no-lines.json +9 -5
- package/dist/examples-iiif/issue-177-color-vars.json +9 -5
- package/dist/examples-iiif/issue-211-rtl.json +9 -5
- package/dist/examples-iiif/issue-242-nonlatin.json +239 -0
- package/dist/examples-iiif/issue-272-facebook.json +9 -5
- package/dist/examples-iiif/issue-305-start-at-slide-data.json +305 -0
- package/dist/examples-iiif/issue-305-start-at-slide.json +9 -5
- package/dist/examples-iiif/issue-341-distance.json +9 -5
- package/dist/examples-iiif/issue-358-iframe.json +190 -0
- package/dist/examples-iiif/issue-360-juxtapose.json +22 -7
- package/dist/examples-iiif/issue-368-null-fields.json +9 -5
- package/dist/examples-iiif/issue-375-code-lists.json +9 -5
- package/dist/examples-iiif/issue-381-dateline.json +9 -5
- package/dist/examples-iiif/issue-405-custom-icon.json +203 -0
- package/dist/examples-iiif/issue-425-many-slides.json +9 -5
- package/dist/examples-iiif/issue-428-background.json +20 -8
- package/dist/examples-iiif/issue-435-googlemaps.json +9 -5
- package/dist/examples-iiif/issue-437-documentcloud.json +22 -7
- package/dist/examples-iiif/issue-451-webp.json +9 -5
- package/dist/examples-iiif/issue-455-audio.json +9 -5
- package/dist/examples-iiif/issue-480-shorts.json +9 -5
- package/dist/examples-iiif/issue-506-marker-sync.json +9 -5
- package/dist/examples-iiif/issue-iiif-geo.json +200 -0
- package/dist/examples-iiif/issue-image-alt.json +90 -0
- package/dist/examples-iiif/issue-image-region.json +203 -0
- package/dist/examples-iiif/issue-image-srcset.json +87 -0
- package/dist/examples-iiif/jansteen.json +9 -6
- package/dist/examples-iiif/katrina.json +197 -41
- package/dist/examples-iiif/layers-control.json +226 -0
- package/dist/examples-iiif/map-area-overlays.json +289 -0
- package/dist/examples-iiif/map-template-provider.json +133 -0
- package/dist/examples-iiif/marktwain.json +255 -58
- package/dist/examples-iiif/multi-media.json +237 -0
- package/dist/examples-iiif/no-map.json +148 -0
- package/dist/examples-iiif/population.json +440 -76
- package/dist/examples-iiif/president.json +456 -83
- package/dist/examples-iiif/scroll-hint.json +190 -0
- package/dist/examples-iiif/seurat.json +113 -22
- package/dist/examples-iiif/zoomify-clamp.json +153 -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 +17 -0
- package/dist/harness.html +4 -2
- package/dist/index.html +19 -12
- package/dist/js/storymap.d.ts +2669 -307
- package/dist/js/storymap.js +10820 -6196
- package/dist/js/storymap.js.map +1 -1
- package/dist/navplace-properties.json +54 -0
- package/dist/site.css +54 -36
- package/docs/migration-from-knightlab.md +279 -19
- package/docs/storymap-as-iiif-manifest.md +633 -136
- package/package.json +12 -7
- package/schema/storymap.schema.json +733 -80
- package/src/animation/easings.ts +8 -1
- package/src/animation/tween.ts +186 -0
- package/src/core/Browser.ts +6 -5
- package/src/core/Load.ts +54 -2
- package/src/core/Util.ts +257 -193
- package/src/core/mixins.ts +66 -35
- package/src/core/viewers.ts +94 -0
- package/src/dom/DomEvent.ts +27 -8
- package/src/globals.d.ts +8 -0
- package/src/language/Language.ts +206 -25
- package/src/language/locale/.expected-gaps.json +346 -0
- package/src/language/locale/be.json +15 -2
- package/src/language/locale/bg.json +15 -2
- package/src/language/locale/cs.json +15 -2
- package/src/language/locale/de.json +23 -2
- package/src/language/locale/el.json +15 -2
- package/src/language/locale/en.json +17 -3
- package/src/language/locale/es.json +15 -2
- package/src/language/locale/et.json +5 -1
- package/src/language/locale/fr.json +15 -2
- package/src/language/locale/he.json +16 -3
- package/src/language/locale/hu.json +15 -2
- package/src/language/locale/is.json +5 -1
- package/src/language/locale/it.json +15 -2
- package/src/language/locale/jp.json +15 -2
- package/src/language/locale/ko.json +15 -2
- package/src/language/locale/nl.json +15 -2
- package/src/language/locale/nn.json +5 -1
- package/src/language/locale/no.json +15 -2
- package/src/language/locale/pl.json +15 -2
- package/src/language/locale/pt.json +15 -2
- package/src/language/locale/ru.json +15 -2
- package/src/language/locale/sk.json +15 -2
- package/src/language/locale/sr.json +5 -1
- package/src/language/locale/sv.json +15 -2
- package/src/language/locale/tr.json +15 -2
- package/src/language/locale/uk.json +15 -2
- package/src/language/locale/ur.json +5 -1
- package/src/language/locale/zh-cn.json +15 -2
- package/src/language/locale/zh-tw.json +15 -2
- package/src/main.ts +143 -0
- package/src/map/Map.ts +227 -112
- package/src/map/MapMarker.ts +44 -8
- package/src/map/georeference.ts +325 -0
- package/src/map/openlayers/Map.OpenLayers.ts +1601 -263
- package/src/map/openlayers/MapMarker.OpenLayers.ts +348 -24
- package/src/map/openlayers/zoomifyTiles.ts +1 -4
- package/src/map/slideView.ts +149 -0
- package/src/map/types.ts +2 -0
- package/src/media/EmbedUtil.ts +333 -24
- package/src/media/Media.ts +270 -59
- package/src/media/embedId.ts +110 -0
- package/src/media/types/Audio.ts +9 -82
- package/src/media/types/Blockquote.ts +2 -3
- package/src/media/types/DailyMotion.ts +5 -10
- package/src/media/types/DocumentCloud.ts +6 -35
- package/src/media/types/Facebook.ts +3 -4
- package/src/media/types/Flickr.ts +23 -12
- package/src/media/types/GoogleDoc.ts +3 -4
- package/src/media/types/HtmlMedia.ts +233 -0
- package/src/media/types/IFrame.ts +3 -4
- package/src/media/types/Image.ts +87 -9
- package/src/media/types/Juxtapose.ts +14 -5
- package/src/media/types/SoundCloud.ts +28 -6
- package/src/media/types/Text.ts +46 -21
- package/src/media/types/Twitter.ts +35 -20
- package/src/media/types/Video.ts +9 -79
- package/src/media/types/Vimeo.ts +11 -11
- package/src/media/types/Website.ts +23 -13
- package/src/media/types/Wikipedia.ts +18 -8
- package/src/media/types/YouTube.ts +72 -40
- package/src/scss/Typography.scss +16 -18
- package/src/scss/VCO.StoryMap.scss +208 -12
- package/src/scss/Variables.scss +3 -2
- package/src/scss/core/Mixins.scss +5 -63
- package/src/scss/core/Reset.scss +5 -18
- package/src/scss/fonts/font.amatic-andika.scss +3 -0
- package/src/scss/fonts/font.bitter-raleway.scss +6 -0
- package/src/scss/fonts/font.cairo.scss +79 -0
- package/src/scss/fonts/font.clicker-garamond.scss +5 -0
- package/src/scss/fonts/font.dancing-ledger.scss +1 -0
- package/src/scss/fonts/font.knightlab.scss +14 -0
- package/src/scss/fonts/font.oldstandard.scss +6 -0
- package/src/scss/fonts/font.opensans-gentiumbook.scss +5 -0
- package/src/scss/fonts/font.playfair-faunaone.scss +2 -0
- package/src/scss/fonts/font.playfair.scss +4 -0
- package/src/scss/fonts/font.pt.scss +6 -0
- package/src/scss/fonts/font.ubuntu.scss +4 -0
- package/src/scss/fonts/font.unicaone-vollkorn.scss +5 -0
- package/src/scss/map/VCO.Map.scss +1 -1
- package/src/scss/map/VCO.MapMarker.scss +114 -8
- package/src/scss/map/openlayers/VCO.Map.OpenLayers.scss +7 -4
- package/src/scss/media/VCO.Media.scss +14 -17
- package/src/scss/media/types/VCO.Media.Blockquote.scss +1 -1
- package/src/scss/media/types/VCO.Media.Text.scss +13 -5
- package/src/scss/media/types/VCO.Media.Twitter.scss +4 -4
- package/src/scss/media/types/VCO.Media.Wikipedia.scss +3 -19
- package/src/scss/site/site.scss +55 -36
- package/src/scss/slider/VCO.Slide.scss +61 -13
- package/src/scss/slider/VCO.SlideNav.scss +34 -24
- package/src/scss/slider/VCO.StorySlider.scss +53 -1
- package/src/scss/snapmap/VCO.SnapMap.scss +3 -3
- package/src/scss/ui/VCO.Consent.scss +63 -13
- package/src/scss/ui/VCO.Layers.scss +119 -0
- package/src/scss/ui/VCO.MenuBar.Button.scss +143 -14
- package/src/scss/ui/VCO.MenuBar.scss +9 -2
- package/src/scss/ui/VCO.Message.scss +16 -5
- package/src/site/site.ts +2 -1
- package/src/slider/Slide.ts +326 -74
- package/src/slider/SlideNav.ts +54 -9
- package/src/slider/StorySlider.ts +352 -60
- package/src/storymap/Consent.ts +429 -82
- package/src/storymap/StoryMap.ts +2163 -340
- package/src/storymap/content-state.ts +180 -0
- 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 +1742 -95
- package/src/storymap/to-iiif.ts +1008 -0
- package/src/storymap/validate.ts +80 -7
- package/src/types.ts +591 -29
- package/src/ui/LayersControl.ts +302 -0
- package/src/ui/MenuBar.ts +325 -61
- package/src/ui/Message.ts +44 -2
- package/src/ui/Swipable.ts +170 -85
- package/dist/assets/demo-D6cTmzn6.js +0 -1
- package/dist/assets/harness-BPFlzutp.js +0 -1
- package/dist/assets/index-DDsPBPZA.js +0 -1
- package/dist/assets/main-Y-MYUSJ4.js +0 -17
- package/src/animation/morpheus.d.ts +0 -41
- package/src/scss/VCO.StoryMap.Dark.scss +0 -138
- package/src/scss/Variables.Dark.scss +0 -56
- package/src/scss/media/types/VCO.Media.Instagram.scss +0 -7
- package/src/scss/media/types/VCO.Media.Profile.scss +0 -5
- package/src/ui/Draggable.ts +0 -374
package/src/storymap/iiif.ts
CHANGED
|
@@ -6,52 +6,258 @@
|
|
|
6
6
|
|
|
7
7
|
import type {
|
|
8
8
|
StorymapData,
|
|
9
|
+
StorymapGeoreference,
|
|
10
|
+
StorymapTilejson,
|
|
11
|
+
StorymapOverlayLayer,
|
|
9
12
|
StorymapSlide,
|
|
10
13
|
StorymapSlideBackground,
|
|
11
14
|
StorymapSlideLocation,
|
|
12
15
|
StorymapSlideMedia,
|
|
13
16
|
} from "../types";
|
|
17
|
+
import { readGroundControlPoints } from "../map/georeference";
|
|
18
|
+
import {
|
|
19
|
+
LOCATION_PROPERTIES,
|
|
20
|
+
asBoolean,
|
|
21
|
+
asNumber,
|
|
22
|
+
asRecord,
|
|
23
|
+
asString,
|
|
24
|
+
asStringArray,
|
|
25
|
+
clampRegion,
|
|
26
|
+
escapeText,
|
|
27
|
+
isDuplicateNarration,
|
|
28
|
+
isLonLatBox,
|
|
29
|
+
readPlaybackBag,
|
|
30
|
+
} from "./iiif-shared";
|
|
14
31
|
|
|
15
32
|
const PRESENTATION_3_CONTEXT = "iiif.io/api/presentation/3/context.json";
|
|
16
33
|
const MAPCONFIG_PROFILE = "mapconfig";
|
|
17
34
|
const STORYMAP_PREFIX = "storymap:";
|
|
18
35
|
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Reads a IIIF xywh image region (`storymap:imageRegion`): an array of
|
|
38
|
+
* exactly 4 finite numbers ([x, y, w, h] in image pixels). Returns null
|
|
39
|
+
* for anything else — invalid regions are ignored.
|
|
40
|
+
*/
|
|
41
|
+
/**
|
|
42
|
+
* A language map reduced to one string in the language the viewer is using,
|
|
43
|
+
* together with the language that was actually chosen (§3.4).
|
|
44
|
+
*
|
|
45
|
+
* `flattenLanguageMap` used to concatenate *every* language, which loses which
|
|
46
|
+
* language each string was in — for a bilingual tour that produced a headline
|
|
47
|
+
* of "Hallo Hello" and no way to tell a host what the viewer had picked. The
|
|
48
|
+
* choice is: the configured language, then its base subtag (`de-AT` → `de`),
|
|
49
|
+
* then the language-neutral `none`, then the first key present.
|
|
50
|
+
*
|
|
51
|
+
* `language` is null for `none`, which is genuinely language-neutral and not a
|
|
52
|
+
* language a host could offer in a switch.
|
|
53
|
+
*/
|
|
54
|
+
export function pickLanguageMap(
|
|
55
|
+
value: unknown,
|
|
56
|
+
preferred: string | null = null,
|
|
57
|
+
): { value: string; language: string | null } {
|
|
58
|
+
if (typeof value === "string") return { value, language: null };
|
|
59
|
+
const record = asRecord(value);
|
|
60
|
+
if (!record) return { value: "", language: null };
|
|
61
|
+
// a TextualBody carries its text in `value`; that is a single string, not
|
|
62
|
+
// a language map, so it keeps flattenLanguageMap's shape
|
|
63
|
+
const textual = record.value;
|
|
64
|
+
if (typeof textual === "string") return { value: textual, language: null };
|
|
65
|
+
if (Array.isArray(textual)) {
|
|
66
|
+
return { value: asStringArray(textual).join(" ").trim(), language: null };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const keys = Object.keys(record);
|
|
70
|
+
if (keys.length === 0) return { value: "", language: null };
|
|
71
|
+
const join = (key: string): string => asStringArray(record[key]).join(" ").trim();
|
|
72
|
+
|
|
73
|
+
if (preferred !== null && preferred !== "") {
|
|
74
|
+
for (const key of keys) {
|
|
75
|
+
if (key.toLowerCase() === preferred.toLowerCase()) {
|
|
76
|
+
return { value: join(key), language: key };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const base = preferred.split("-")[0].toLowerCase();
|
|
80
|
+
for (const key of keys) {
|
|
81
|
+
if (key.toLowerCase() === base) return { value: join(key), language: key };
|
|
82
|
+
}
|
|
33
83
|
}
|
|
34
|
-
return value
|
|
84
|
+
if ("none" in record) return { value: join("none"), language: null };
|
|
85
|
+
return { value: join(keys[0]), language: keys[0] };
|
|
35
86
|
}
|
|
36
87
|
|
|
37
|
-
|
|
38
|
-
|
|
88
|
+
/**
|
|
89
|
+
* A selector, normalized. Every field is optional and the object is a
|
|
90
|
+
* *superset* carrier: an annotation may carry a region, a point, a quote and a
|
|
91
|
+
* time range at once, and we keep what we understand instead of stopping at
|
|
92
|
+
* the first hit.
|
|
93
|
+
*
|
|
94
|
+
* - `region`: xywh image pixels. From an Image API Selector / FragmentSelector
|
|
95
|
+
* (`xywh=`), or synthesized from a `point` when the canvas size is known.
|
|
96
|
+
* - `point`: x/y image pixels, from a `PointSelector`.
|
|
97
|
+
* - `quote`: a `TextQuoteSelector`. Preserved, **not resolved** — a canvas
|
|
98
|
+
* carries no transcript, so matching the quoted text needs one the tour
|
|
99
|
+
* supplies. See docs/iiif-authoring.md.
|
|
100
|
+
* - `time`: start/end in seconds, from a `TimeState` or `start`/`end` on a
|
|
101
|
+
* SpecificResource, for time-anchored stops and narration.
|
|
102
|
+
* - `svg`: an `SvgSelector`'s shape markup. Preserved for round-tripping; the
|
|
103
|
+
* viewer fits `region`, so a non-rectangular selection is not highlighted
|
|
104
|
+
* with its true outline yet.
|
|
105
|
+
*/
|
|
106
|
+
export interface ReadSelector {
|
|
107
|
+
region: [number, number, number, number] | null;
|
|
108
|
+
point: { x: number; y: number } | null;
|
|
109
|
+
quote: { exact: string; prefix?: string; suffix?: string } | null;
|
|
110
|
+
time: { start?: number; end?: number } | null;
|
|
111
|
+
svg: string | null;
|
|
39
112
|
}
|
|
40
113
|
|
|
41
|
-
|
|
42
|
-
|
|
114
|
+
const EMPTY_SELECTOR: ReadSelector = {
|
|
115
|
+
region: null,
|
|
116
|
+
point: null,
|
|
117
|
+
quote: null,
|
|
118
|
+
time: null,
|
|
119
|
+
svg: null,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
function isEmptySelector(sel: ReadSelector): boolean {
|
|
123
|
+
return (
|
|
124
|
+
sel.region === null &&
|
|
125
|
+
sel.point === null &&
|
|
126
|
+
sel.quote === null &&
|
|
127
|
+
sel.time === null &&
|
|
128
|
+
sel.svg === null
|
|
129
|
+
);
|
|
43
130
|
}
|
|
44
131
|
|
|
45
|
-
|
|
46
|
-
|
|
132
|
+
/** A `PointSelector` is a pin, which the viewer cannot fit, so we synthesize
|
|
133
|
+
* a square of 5% of the canvas's smaller side, centred on the point and
|
|
134
|
+
* clamped to the canvas. Stated once, here, so annotation-driven stops
|
|
135
|
+
* share it rather than re-deriving it. */
|
|
136
|
+
const POINT_SQUARE_FRACTION = 0.05;
|
|
137
|
+
|
|
138
|
+
function pointToRegion(
|
|
139
|
+
point: { x: number; y: number },
|
|
140
|
+
width: number | null,
|
|
141
|
+
height: number | null,
|
|
142
|
+
): [number, number, number, number] | null {
|
|
143
|
+
if (width === null || height === null) return null;
|
|
144
|
+
if (!(width > 0) || !(height > 0)) return null;
|
|
145
|
+
const side = Math.max(1, Math.round(Math.min(width, height) * POINT_SQUARE_FRACTION));
|
|
146
|
+
const x = Math.max(0, Math.min(width - side, Math.round(point.x - side / 2)));
|
|
147
|
+
const y = Math.max(0, Math.min(height - side, Math.round(point.y - side / 2)));
|
|
148
|
+
return [x, y, side, side];
|
|
47
149
|
}
|
|
48
150
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
151
|
+
/** One selector entry, plus one level of `refinedBy`. */
|
|
152
|
+
function readSelectorEntry(entry: unknown, out: ReadSelector, depth = 0): void {
|
|
153
|
+
const selector = asRecord(entry);
|
|
154
|
+
if (!selector) return;
|
|
155
|
+
const type = asString(selector.type);
|
|
156
|
+
|
|
157
|
+
if (type === "ImageApiSelector" || type === "FragmentSelector") {
|
|
158
|
+
const raw = asString(selector.value);
|
|
159
|
+
if (raw !== null && out.region === null) {
|
|
160
|
+
const match = /xywh=(pixel:)?([^,]+),([^,]+),([^,]+),([^,]+)/.exec(raw);
|
|
161
|
+
if (match) {
|
|
162
|
+
const parts = match.slice(2).map(Number);
|
|
163
|
+
// Origins may be negative (slideshow overshoots its canvas);
|
|
164
|
+
// clamping to the canvas happens in readSelector, which knows
|
|
165
|
+
// the size. Only the size must already be positive here.
|
|
166
|
+
if (parts.length === 4 && parts.every((n) => Number.isFinite(n))) {
|
|
167
|
+
if (parts[2] > 0 && parts[3] > 0) {
|
|
168
|
+
out.region = parts as [number, number, number, number];
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
} else if (type === "PointSelector") {
|
|
174
|
+
const x = asNumber(selector.x);
|
|
175
|
+
const y = asNumber(selector.y);
|
|
176
|
+
if (x !== null && y !== null && out.point === null) {
|
|
177
|
+
out.point = { x, y };
|
|
178
|
+
}
|
|
179
|
+
} else if (type === "TextQuoteSelector") {
|
|
180
|
+
const exact = asString(selector.exact);
|
|
181
|
+
if (exact !== null && out.quote === null) {
|
|
182
|
+
const prefix = asString(selector.prefix);
|
|
183
|
+
const suffix = asString(selector.suffix);
|
|
184
|
+
out.quote = {
|
|
185
|
+
exact,
|
|
186
|
+
...(prefix !== null ? { prefix } : {}),
|
|
187
|
+
...(suffix !== null ? { suffix } : {}),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
} else if (type === "SvgSelector") {
|
|
191
|
+
const value = asString(selector.value);
|
|
192
|
+
if (value !== null && out.svg === null) out.svg = value;
|
|
193
|
+
} else if (type === "TimeState" || type === "oa:TimeState") {
|
|
194
|
+
const state = asRecord(selector);
|
|
195
|
+
const start = asNumber(state?.start);
|
|
196
|
+
const end = asNumber(state?.end);
|
|
197
|
+
if ((start !== null || end !== null) && out.time === null) {
|
|
198
|
+
out.time = { ...(start !== null ? { start } : {}), ...(end !== null ? { end } : {}) };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (depth < 1) {
|
|
203
|
+
const refined = Array.isArray(selector.refinedBy)
|
|
204
|
+
? selector.refinedBy
|
|
205
|
+
: [selector.refinedBy];
|
|
206
|
+
for (const nested of refined) readSelectorEntry(nested, out, depth + 1);
|
|
53
207
|
}
|
|
54
|
-
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Reads a Web Annotation target (a string fragment, a SpecificResource, or
|
|
212
|
+
* either carrying `selector`/`state` arrays) into a normalized
|
|
213
|
+
* {@link ReadSelector}. `width`/`height` are the canvas size in pixels, used
|
|
214
|
+
* only to synthesize a region from a point selector; pass null when unknown.
|
|
215
|
+
*/
|
|
216
|
+
export function readSelector(
|
|
217
|
+
target: unknown,
|
|
218
|
+
width: number | null = null,
|
|
219
|
+
height: number | null = null,
|
|
220
|
+
): ReadSelector {
|
|
221
|
+
const out: ReadSelector = { ...EMPTY_SELECTOR };
|
|
222
|
+
|
|
223
|
+
// A bare string target may carry the fragment: "…/canvas/2#xywh=10,20,30,40"
|
|
224
|
+
const asText = asString(target);
|
|
225
|
+
if (asText !== null) {
|
|
226
|
+
readSelectorEntry({ type: "FragmentSelector", value: asText }, out);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const record = asRecord(target);
|
|
230
|
+
if (record) {
|
|
231
|
+
const selectors = Array.isArray(record.selector) ? record.selector : [record.selector];
|
|
232
|
+
for (const entry of selectors) readSelectorEntry(entry, out);
|
|
233
|
+
const states = Array.isArray(record.state) ? record.state : [record.state];
|
|
234
|
+
for (const entry of states) readSelectorEntry(entry, out);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// A SpecificResource may carry the time range directly.
|
|
238
|
+
if (record && out.time === null) {
|
|
239
|
+
const start = asNumber(record.start);
|
|
240
|
+
const end = asNumber(record.end);
|
|
241
|
+
if (start !== null || end !== null) {
|
|
242
|
+
out.time = {
|
|
243
|
+
...(start !== null ? { start } : {}),
|
|
244
|
+
...(end !== null ? { end } : {}),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (out.region !== null) {
|
|
250
|
+
const clamped = clampRegion(out.region, width, height);
|
|
251
|
+
out.region = clamped;
|
|
252
|
+
if (clamped === null && out.point !== null) {
|
|
253
|
+
// fully outside the canvas: fall back to the point square
|
|
254
|
+
out.region = pointToRegion(out.point, width, height);
|
|
255
|
+
}
|
|
256
|
+
} else if (out.point !== null) {
|
|
257
|
+
out.region = pointToRegion(out.point, width, height);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return isEmptySelector(out) ? { ...EMPTY_SELECTOR } : out;
|
|
55
261
|
}
|
|
56
262
|
|
|
57
263
|
/**
|
|
@@ -78,6 +284,46 @@ export function flattenLanguageMap(value: unknown): string {
|
|
|
78
284
|
return parts.join(" ").trim();
|
|
79
285
|
}
|
|
80
286
|
|
|
287
|
+
/**
|
|
288
|
+
* A `requiredStatement` reduced to its label and value.
|
|
289
|
+
*
|
|
290
|
+
* In Presentation 3 `requiredStatement` is a **single `{label, value}`
|
|
291
|
+
* object** — the official IIIF validator rejects the array form with "is not of
|
|
292
|
+
* type 'object'" — so the array branch below is producer leniency, not
|
|
293
|
+
* conformance. The `label` ("Credit", "Rights holder", a language-tagged term)
|
|
294
|
+
* is half of what a real institutional manifest ships, and reading only
|
|
295
|
+
* `.value` dropped it.
|
|
296
|
+
*/
|
|
297
|
+
function readRequiredStatement(statement: unknown): { label: string; value: string } {
|
|
298
|
+
const entries = Array.isArray(statement)
|
|
299
|
+
? statement
|
|
300
|
+
: statement !== undefined
|
|
301
|
+
? [statement]
|
|
302
|
+
: [];
|
|
303
|
+
for (const entry of entries) {
|
|
304
|
+
const record = asRecord(entry);
|
|
305
|
+
if (!record) continue;
|
|
306
|
+
const value = flattenLanguageMap(record.value);
|
|
307
|
+
if (value === "") continue;
|
|
308
|
+
return { label: flattenLanguageMap(record.label), value };
|
|
309
|
+
}
|
|
310
|
+
return { label: "", value: "" };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Attribution text for a `requiredStatement`, keeping its label.
|
|
315
|
+
*
|
|
316
|
+
* A labelled statement reads as "Label: value", because that is how the value
|
|
317
|
+
* was meant to be attributed. An unlabelled one is returned verbatim, exactly
|
|
318
|
+
* as before — plenty of manifests state a bare rights line and prefixing it
|
|
319
|
+
* would be noise.
|
|
320
|
+
*/
|
|
321
|
+
function formatAttribution(statement: { label: string; value: string }): string {
|
|
322
|
+
if (statement.value === "") return "";
|
|
323
|
+
if (statement.label === "") return statement.value;
|
|
324
|
+
return `${statement.label}: ${statement.value}`;
|
|
325
|
+
}
|
|
326
|
+
|
|
81
327
|
/**
|
|
82
328
|
* Reads a StoryMap extension term from a Canvas or service object. Manifests
|
|
83
329
|
* use the `storymap:`-prefixed form, but JSON-LD processors may emit the bare
|
|
@@ -92,10 +338,20 @@ function readTerm(record: Record<string, unknown>, term: string): unknown {
|
|
|
92
338
|
return term === "type" ? undefined : record[term];
|
|
93
339
|
}
|
|
94
340
|
|
|
95
|
-
/**
|
|
341
|
+
/**
|
|
342
|
+
* True when `data` looks like a Presentation API 3.0 **Manifest**.
|
|
343
|
+
*
|
|
344
|
+
* A `Collection` is explicitly *not* one, even though it carries the same
|
|
345
|
+
* `@context`. Detection used to accept anything with the P3 context, so a
|
|
346
|
+
* Collection passed and its member Manifests were fed to `canvasToSlide` as if
|
|
347
|
+
* they were Canvases — every member became a text-only slide, with no media, no
|
|
348
|
+
* locations and no warning. `within`-style Collections are now handled by the
|
|
349
|
+
* private `collectionToStorymapData` below, and anything else is rejected here.
|
|
350
|
+
*/
|
|
96
351
|
export function isPresentation3Manifest(data: unknown): boolean {
|
|
97
352
|
const record = asRecord(data);
|
|
98
353
|
if (!record) return false;
|
|
354
|
+
if (record.type === "Collection") return false;
|
|
99
355
|
if (record.type === "Manifest") return true;
|
|
100
356
|
const context = record["@context"];
|
|
101
357
|
const candidates = Array.isArray(context) ? context : [context];
|
|
@@ -104,10 +360,159 @@ export function isPresentation3Manifest(data: unknown): boolean {
|
|
|
104
360
|
);
|
|
105
361
|
}
|
|
106
362
|
|
|
363
|
+
/** True when `data` is a Presentation API 3.0 Collection. */
|
|
364
|
+
export function isPresentation3Collection(data: unknown): boolean {
|
|
365
|
+
const record = asRecord(data);
|
|
366
|
+
return record !== null && record.type === "Collection";
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* A Collection: reported and rejected, with nothing silently mangled.
|
|
371
|
+
*
|
|
372
|
+
* §2.2 offered two branches — flatten the member Manifests, or reject with an
|
|
373
|
+
* explicit error. Neither survives contact with the specification, and the
|
|
374
|
+
* choice is worth recording:
|
|
375
|
+
*
|
|
376
|
+
* - **Flattening is impossible here.** A Presentation 3 `Collection`'s
|
|
377
|
+
* `items` are `id` **references** to manifests in other documents, so
|
|
378
|
+
* flattening means fetching them, and this converter is synchronous and does
|
|
379
|
+
* no I/O.
|
|
380
|
+
* - **A Collection fixture could not be authored either.** The official IIIF
|
|
381
|
+
* validator rejects both shapes of member the obvious forms suggest: an
|
|
382
|
+
* embedded `Manifest` object ("not valid under any of the given schemas") and
|
|
383
|
+
* a bare id string (the same). So no `public/examples-iiif/` fixture can
|
|
384
|
+
* exercise this path, and shipping one that fails `npm run validate:iiif` is
|
|
385
|
+
* not an option.
|
|
386
|
+
*
|
|
387
|
+
* So the honest outcome is the one that removes a defect and promises no
|
|
388
|
+
* feature: previously a Collection was *accepted* and its members were fed to
|
|
389
|
+
* `canvasToSlide` as if they were Canvases, producing text-only slides with no
|
|
390
|
+
* media, no locations and no warning. Now it is named and skipped.
|
|
391
|
+
*
|
|
392
|
+
* A host that wants a multi-manifest tour composes it: fetch the members, call
|
|
393
|
+
* {@link manifestToStorymapData} on each, and concatenate the `slides`. That
|
|
394
|
+
* keeps the "no I/O on the load path" property §5.2's `seeAlso` reader is being
|
|
395
|
+
* held back for.
|
|
396
|
+
*/
|
|
397
|
+
function collectionToStorymapData(collection: Record<string, unknown>): StorymapData {
|
|
398
|
+
const data: StorymapData = { slides: [] };
|
|
399
|
+
const title = flattenLanguageMap(collection.label);
|
|
400
|
+
if (title !== "") data.title = title;
|
|
401
|
+
|
|
402
|
+
const required = formatAttribution(readRequiredStatement(collection.requiredStatement));
|
|
403
|
+
if (required !== "") {
|
|
404
|
+
const iiif = (data.iiif as { url?: string; attribution?: string } | undefined) ?? {};
|
|
405
|
+
iiif.attribution = required;
|
|
406
|
+
if (iiif.url === undefined) iiif.url = "";
|
|
407
|
+
data.iiif = iiif;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const members = Array.isArray(collection.items) ? collection.items : [];
|
|
411
|
+
const memberIds = members
|
|
412
|
+
.map((member) => (typeof member === "string" ? member : asString(asRecord(member)?.id)))
|
|
413
|
+
.filter((id): id is string => typeof id === "string");
|
|
414
|
+
console.warn(
|
|
415
|
+
"StoryMapJS: this is a IIIF Collection with " +
|
|
416
|
+
`${memberIds.length} member manifest(s) [${memberIds.join(", ")}]. ` +
|
|
417
|
+
"A Presentation 3 Collection references its members from other documents, and " +
|
|
418
|
+
"the converter is synchronous, so it contributes no slides rather than " +
|
|
419
|
+
"mangling them. To build a multi-manifest tour, fetch the members and " +
|
|
420
|
+
"concatenate their manifestToStorymapData() slides yourself.",
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
return data;
|
|
424
|
+
}
|
|
425
|
+
|
|
107
426
|
/**
|
|
108
427
|
* Finds the manifest-level map configuration service (profile containing
|
|
109
428
|
* "mapconfig") and returns its properties, or null when absent.
|
|
110
429
|
*/
|
|
430
|
+
/**
|
|
431
|
+
* The `info.json` URL of the image basemap, taken from the `ImageService3` a
|
|
432
|
+
* painting body carries (§2.4).
|
|
433
|
+
*
|
|
434
|
+
* `service[0].id` is the service base, so the description is that plus
|
|
435
|
+
* `/info.json`. A producer that put the description URL in `body.id` instead
|
|
436
|
+
* is handled by the fallback. `map_type: "iiif"` means every canvas paints the
|
|
437
|
+
* same basemap image, so the first one carrying a service is the basemap.
|
|
438
|
+
*/
|
|
439
|
+
function readImageServiceUrl(manifest: Record<string, unknown>): string | null {
|
|
440
|
+
const canvases = Array.isArray(manifest.items) ? manifest.items : [];
|
|
441
|
+
for (const canvas of canvases) {
|
|
442
|
+
const record = asRecord(canvas);
|
|
443
|
+
if (!record) continue;
|
|
444
|
+
const pages = Array.isArray(record.items) ? record.items : [];
|
|
445
|
+
for (const page of pages) {
|
|
446
|
+
const pageRecord = asRecord(page);
|
|
447
|
+
if (!pageRecord) continue;
|
|
448
|
+
const annotations = Array.isArray(pageRecord.items) ? pageRecord.items : [];
|
|
449
|
+
for (const annotation of annotations) {
|
|
450
|
+
const annotationRecord = asRecord(annotation);
|
|
451
|
+
if (!annotationRecord) continue;
|
|
452
|
+
const bodies = Array.isArray(annotationRecord.body)
|
|
453
|
+
? annotationRecord.body
|
|
454
|
+
: [annotationRecord.body];
|
|
455
|
+
for (const entry of bodies) {
|
|
456
|
+
const body = asRecord(entry);
|
|
457
|
+
if (!body) continue;
|
|
458
|
+
const services = Array.isArray(body.service) ? body.service : [];
|
|
459
|
+
for (const service of services) {
|
|
460
|
+
const serviceRecord = asRecord(service);
|
|
461
|
+
if (!serviceRecord) continue;
|
|
462
|
+
const id = asString(serviceRecord.id);
|
|
463
|
+
if (id === null) continue;
|
|
464
|
+
return id.endsWith("/info.json") ? id : `${id}/info.json`;
|
|
465
|
+
}
|
|
466
|
+
// A body that is itself the service description
|
|
467
|
+
const bodyId = asString(body.id);
|
|
468
|
+
if (bodyId !== null && bodyId.endsWith("/info.json")) return bodyId;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* TileJSON 2.1 metadata from a manifest's map configuration service (§2.9).
|
|
478
|
+
*
|
|
479
|
+
* `tiles` is the only required member, and the value we need is the template,
|
|
480
|
+
* so anything without one is not a tile source. `minzoom`/`maxzoom` are
|
|
481
|
+
* clamped to sane numbers rather than rejected, because a service that
|
|
482
|
+
* advertises a maxzoom of 200 should not fail to load — the map caps it.
|
|
483
|
+
*/
|
|
484
|
+
function readTilejson(config: Record<string, unknown>): StorymapTilejson | null {
|
|
485
|
+
const record = asRecord(config.tilejson);
|
|
486
|
+
if (!record) return null;
|
|
487
|
+
const tiles = Array.isArray(record.tiles)
|
|
488
|
+
? asStringArray(record.tiles).filter((t) => t !== "")
|
|
489
|
+
: asString(record.tiles);
|
|
490
|
+
if (Array.isArray(tiles) ? tiles.length === 0 : tiles === null) return null;
|
|
491
|
+
const template = tiles as string | string[];
|
|
492
|
+
|
|
493
|
+
const out: StorymapTilejson = { tiles: template };
|
|
494
|
+
const minzoom = asNumber(record.minzoom);
|
|
495
|
+
if (minzoom !== null && minzoom >= 0) out.minzoom = minzoom;
|
|
496
|
+
const maxzoom = asNumber(record.maxzoom);
|
|
497
|
+
if (maxzoom !== null && maxzoom >= 0) out.maxzoom = maxzoom;
|
|
498
|
+
const bounds = readLonLatBox(record.bounds);
|
|
499
|
+
if (bounds !== null) out.bounds = bounds;
|
|
500
|
+
const scheme = asString(record.scheme);
|
|
501
|
+
if (scheme === "xyz" || scheme === "tms") out.scheme = scheme;
|
|
502
|
+
// TileJSON's `center` is [lon, lat, zoom]; the third member is the zoom
|
|
503
|
+
const center = Array.isArray(record.center) ? record.center : null;
|
|
504
|
+
if (center && center.length >= 2) {
|
|
505
|
+
const lon = asNumber(center[0]);
|
|
506
|
+
const lat = asNumber(center[1]);
|
|
507
|
+
if (lon !== null && lat !== null && Math.abs(lon) <= 180 && Math.abs(lat) <= 90) {
|
|
508
|
+
const zoom = center.length > 2 ? asNumber(center[2]) : null;
|
|
509
|
+
if (zoom !== null) out.center = [lon, lat, zoom];
|
|
510
|
+
else out.center = [lon, lat, 0];
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return out;
|
|
514
|
+
}
|
|
515
|
+
|
|
111
516
|
function readMapConfig(manifest: Record<string, unknown>): Record<string, unknown> | null {
|
|
112
517
|
const services = Array.isArray(manifest.service) ? manifest.service : [manifest.service];
|
|
113
518
|
for (const service of services) {
|
|
@@ -122,11 +527,148 @@ function readMapConfig(manifest: Record<string, unknown>): Record<string, unknow
|
|
|
122
527
|
}
|
|
123
528
|
|
|
124
529
|
/**
|
|
125
|
-
*
|
|
530
|
+
* What a painting annotation tells us about a slide's media.
|
|
531
|
+
*
|
|
532
|
+
* This is the **shared body record** for annotation-driven stops:
|
|
533
|
+
* `type` is how a `Sound` body is told
|
|
534
|
+
* from an `Image` or a `TextualBody`. Anything else that needs a field from
|
|
535
|
+
* the body belongs on this record rather than in a second reader.
|
|
536
|
+
*/
|
|
537
|
+
export interface PaintingBody {
|
|
538
|
+
url: string;
|
|
539
|
+
region: [number, number, number, number] | null;
|
|
540
|
+
/** Body class: `Image`, `Sound`, `Video`, `Text`, `Dataset`, … */
|
|
541
|
+
type: string | null;
|
|
542
|
+
format: string | null;
|
|
543
|
+
/** `body.label` — the interoperable caption. */
|
|
544
|
+
label: string | null;
|
|
545
|
+
/** `body.accessibilitySummary` — the interoperable alt text. */
|
|
546
|
+
accessibilitySummary: string | null;
|
|
547
|
+
/**
|
|
548
|
+
* `body.requiredStatement` (label and value), or `body.provider`.
|
|
549
|
+
* See {@link readRequiredStatement} on the single-object shape.
|
|
550
|
+
*/
|
|
551
|
+
credit: string | null;
|
|
552
|
+
thumbnail: string | null;
|
|
553
|
+
/** `duration` in seconds, and an explicit `start`/`end` range. */
|
|
554
|
+
duration: number | null;
|
|
555
|
+
start: number | null;
|
|
556
|
+
end: number | null;
|
|
557
|
+
/**
|
|
558
|
+
* A WebVTT subtitle file, from a `TextualBody` body with
|
|
559
|
+
* `format: "text/vtt"` and an `id`. IIIF has no subtitle term, so this is
|
|
560
|
+
* the one standard shape we opportunistically accept; `media.subtitles`
|
|
561
|
+
* in storymap JSON is the documented route.
|
|
562
|
+
*/
|
|
563
|
+
subtitles: string | null;
|
|
564
|
+
/**
|
|
565
|
+
* Playback flags from `storymap:` foreign members on the painting
|
|
566
|
+
* annotation (slideshow audio terms); absent stays absent.
|
|
567
|
+
*/
|
|
568
|
+
offset?: number;
|
|
569
|
+
loop?: boolean;
|
|
570
|
+
play?: "auto" | "click";
|
|
571
|
+
stopOnExit?: boolean;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Reads `storymap:` playback flags off a painting annotation into the
|
|
576
|
+
* painting body: `offset` (seconds), `loop`, `play` and `stopOnExit`
|
|
577
|
+
* (painting annotations never carry `stopAllPrevious`).
|
|
578
|
+
*/
|
|
579
|
+
function readPaintingPlayback(
|
|
580
|
+
annotationRecord: Record<string, unknown>,
|
|
581
|
+
painting: { offset?: number; loop?: boolean; play?: "auto" | "click"; stopOnExit?: boolean },
|
|
582
|
+
): void {
|
|
583
|
+
const { stopAllPrevious: _dropped, ...bag } = readPlaybackBag(annotationRecord);
|
|
584
|
+
Object.assign(painting, bag);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Credit from `body.requiredStatement`, or `body.provider`, which is where a
|
|
589
|
+
* manifest records who made the media. The statement's `label` is kept, like
|
|
590
|
+
* the manifest-level attribution (§2.1) — a body that says
|
|
591
|
+
* `{label: "Photographer", value: "Someone"}` should not reduce to a bare
|
|
592
|
+
* "Someone".
|
|
593
|
+
*/
|
|
594
|
+
function readBodyCredit(body: unknown): string | null {
|
|
595
|
+
const record = asRecord(body);
|
|
596
|
+
if (!record) return null;
|
|
597
|
+
const statement = formatAttribution(readRequiredStatement(record.requiredStatement));
|
|
598
|
+
if (statement !== "") return statement;
|
|
599
|
+
const provider = asRecord(record.provider);
|
|
600
|
+
const providerLabel = provider === null ? "" : flattenLanguageMap(provider.label);
|
|
601
|
+
if (providerLabel !== "") return providerLabel;
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Credit from the painting annotation's own `requiredStatement`, which is
|
|
607
|
+
* where P3 defines it and where our converter now writes it (§2.7). The
|
|
608
|
+
* `label` is kept, as everywhere else.
|
|
609
|
+
*/
|
|
610
|
+
function readAnnotationCredit(annotation: Record<string, unknown>): string | null {
|
|
611
|
+
if (annotation.requiredStatement === undefined) return null;
|
|
612
|
+
const statement = formatAttribution(readRequiredStatement(annotation.requiredStatement));
|
|
613
|
+
return statement === "" ? null : statement;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** A `TextualBody` carrying WebVTT is the closest standard spelling of a
|
|
617
|
+
* subtitle track, so accept it alongside `media.subtitles`. */
|
|
618
|
+
function readBodySubtitles(body: unknown): string | null {
|
|
619
|
+
const entries = Array.isArray(body) ? body : [body];
|
|
620
|
+
for (const entry of entries) {
|
|
621
|
+
const record = asRecord(entry);
|
|
622
|
+
if (!record) continue;
|
|
623
|
+
const format = asString(record.format);
|
|
624
|
+
const id = asString(record.id);
|
|
625
|
+
if (format !== null && /^text\/vtt$/i.test(format) && id !== null) return id;
|
|
626
|
+
}
|
|
627
|
+
return null;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** Picks the first body of a `body` array that yields a URL. */
|
|
631
|
+
function readFirstTypedBody(body: unknown): Record<string, unknown> | null {
|
|
632
|
+
if (Array.isArray(body)) {
|
|
633
|
+
for (const entry of body) {
|
|
634
|
+
const found = readFirstTypedBody(entry);
|
|
635
|
+
if (found !== null) return found;
|
|
636
|
+
}
|
|
637
|
+
return null;
|
|
638
|
+
}
|
|
639
|
+
const record = asRecord(body);
|
|
640
|
+
if (!record) return null;
|
|
641
|
+
const url = asString(record.id) ?? asString(record.value);
|
|
642
|
+
return url === null ? null : record;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** Every body of a `body` array that yields a URL, in order (#358). */
|
|
646
|
+
function readPaintingBodies(body: unknown): Record<string, unknown>[] {
|
|
647
|
+
if (Array.isArray(body)) {
|
|
648
|
+
return body.flatMap((entry) => readPaintingBodies(entry));
|
|
649
|
+
}
|
|
650
|
+
const record = asRecord(body);
|
|
651
|
+
if (!record) return [];
|
|
652
|
+
const url = asString(record.id) ?? asString(record.value);
|
|
653
|
+
return url === null ? [] : [record];
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Reads the slide media URLs from the canvas's painting annotation bodies:
|
|
126
658
|
* typed bodies contribute their `id`, TextualBody (HTML) content its `value`
|
|
127
|
-
* - the legacy format stores both in `media.url`.
|
|
659
|
+
* - the legacy format stores both in `media.url`. Also returns the region of
|
|
660
|
+
* a selector on the annotation target, and the body fields listed on
|
|
661
|
+
* {@link PaintingBody}.
|
|
662
|
+
*
|
|
663
|
+
* Every painting match is collected in order (#358): the first becomes the
|
|
664
|
+
* slide's `media`, further ones its `media_extra`.
|
|
128
665
|
*/
|
|
129
|
-
function
|
|
666
|
+
function readPaintings(
|
|
667
|
+
canvas: Record<string, unknown>,
|
|
668
|
+
width: number | null = null,
|
|
669
|
+
height: number | null = null,
|
|
670
|
+
): PaintingBody[] {
|
|
671
|
+
const found: PaintingBody[] = [];
|
|
130
672
|
const annotationPages = Array.isArray(canvas.items) ? canvas.items : [];
|
|
131
673
|
for (const page of annotationPages) {
|
|
132
674
|
const pageRecord = asRecord(page);
|
|
@@ -135,29 +677,94 @@ function readPaintingBodyUrl(canvas: Record<string, unknown>): string | null {
|
|
|
135
677
|
for (const annotation of annotations) {
|
|
136
678
|
const annotationRecord = asRecord(annotation);
|
|
137
679
|
if (!annotationRecord) continue;
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
680
|
+
// motivation may be an array (notably ["painting","supplementing"]):
|
|
681
|
+
// accept when any entry names painting. An absent or unparsable
|
|
682
|
+
// motivation stays lenient (treated as painting), as before — only
|
|
683
|
+
// the array form is new.
|
|
684
|
+
const rawMotivation = annotationRecord.motivation;
|
|
685
|
+
const motivations = Array.isArray(rawMotivation) ? rawMotivation : [rawMotivation];
|
|
686
|
+
const isPainting = motivations.some((m) => {
|
|
687
|
+
const s = asString(m);
|
|
688
|
+
return s === null || s === "painting";
|
|
689
|
+
});
|
|
690
|
+
if (!isPainting) continue;
|
|
691
|
+
for (const body of readPaintingBodies(annotationRecord.body)) {
|
|
692
|
+
const painting: PaintingBody = {
|
|
693
|
+
url: asString(body.id) ?? asString(body.value) ?? "",
|
|
694
|
+
region: readSelector(annotationRecord.target, width, height).region,
|
|
695
|
+
type: asString(body.type),
|
|
696
|
+
format: asString(body.format),
|
|
697
|
+
// P3 puts label / requiredStatement / accessibilitySummary on
|
|
698
|
+
// the Annotation; some producers put them on the body, so the
|
|
699
|
+
// body is still consulted as a fallback (§2.7)
|
|
700
|
+
label:
|
|
701
|
+
flattenLanguageMap(annotationRecord.label) ||
|
|
702
|
+
flattenLanguageMap(body.label) ||
|
|
703
|
+
null,
|
|
704
|
+
accessibilitySummary:
|
|
705
|
+
flattenLanguageMap(annotationRecord.accessibilitySummary) ||
|
|
706
|
+
flattenLanguageMap(body.accessibilitySummary) ||
|
|
707
|
+
null,
|
|
708
|
+
credit: readAnnotationCredit(annotationRecord) ?? readBodyCredit(body),
|
|
709
|
+
thumbnail: readThumbnailId(body.thumbnail),
|
|
710
|
+
duration: asNumber(body.duration),
|
|
711
|
+
start: asNumber(body.start),
|
|
712
|
+
end: asNumber(body.end),
|
|
713
|
+
subtitles: readBodySubtitles(annotationRecord.body),
|
|
714
|
+
};
|
|
715
|
+
readPaintingPlayback(annotationRecord, painting);
|
|
716
|
+
found.push(painting);
|
|
717
|
+
}
|
|
142
718
|
}
|
|
143
719
|
}
|
|
144
|
-
return
|
|
720
|
+
return found;
|
|
145
721
|
}
|
|
146
722
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
723
|
+
/**
|
|
724
|
+
* Reads a slide narration from `motivation: "supplementing"` annotations:
|
|
725
|
+
* the first `Sound`/`Video` body wins. Painting bodies are skipped here —
|
|
726
|
+
* the painting reader owns them, including the array-motivation form — and
|
|
727
|
+
* anything else (commenting, tagging, …) belongs to the annotation stops.
|
|
728
|
+
*
|
|
729
|
+
* Playback flags ride `storymap:` foreign members on the annotation (or the
|
|
730
|
+
* body as a fallback), which need no vocabulary registration.
|
|
731
|
+
*/
|
|
732
|
+
function readSupplementing(canvas: Record<string, unknown>): {
|
|
733
|
+
url: string;
|
|
734
|
+
loop?: boolean;
|
|
735
|
+
offset?: number;
|
|
736
|
+
play?: "auto" | "click";
|
|
737
|
+
stopOnExit?: boolean;
|
|
738
|
+
stopAllPrevious?: boolean;
|
|
739
|
+
} | null {
|
|
740
|
+
const annotationPages = Array.isArray(canvas.items) ? canvas.items : [];
|
|
741
|
+
for (const page of annotationPages) {
|
|
742
|
+
const pageRecord = asRecord(page);
|
|
743
|
+
if (!pageRecord) continue;
|
|
744
|
+
const annotations = Array.isArray(pageRecord.items) ? pageRecord.items : [];
|
|
745
|
+
for (const annotation of annotations) {
|
|
746
|
+
const annotationRecord = asRecord(annotation);
|
|
747
|
+
if (!annotationRecord) continue;
|
|
748
|
+
const rawMotivation = annotationRecord.motivation;
|
|
749
|
+
const motivations = Array.isArray(rawMotivation) ? rawMotivation : [rawMotivation];
|
|
750
|
+
if (!motivations.some((m) => asString(m) === "supplementing")) continue;
|
|
751
|
+
const body = readFirstTypedBody(annotationRecord.body);
|
|
752
|
+
if (body === null) continue;
|
|
753
|
+
const type = asString(body.type);
|
|
754
|
+
if (type !== "Sound" && type !== "Video") continue;
|
|
755
|
+
const url = asString(body.id) ?? asString(body.value);
|
|
756
|
+
if (url === null) continue;
|
|
757
|
+
const narration: {
|
|
758
|
+
url: string;
|
|
759
|
+
loop?: boolean;
|
|
760
|
+
offset?: number;
|
|
761
|
+
play?: "auto" | "click";
|
|
762
|
+
stopOnExit?: boolean;
|
|
763
|
+
stopAllPrevious?: boolean;
|
|
764
|
+
} = { url, ...readPlaybackBag(annotationRecord), ...readPlaybackBag(body) };
|
|
765
|
+
return narration;
|
|
152
766
|
}
|
|
153
|
-
return null;
|
|
154
767
|
}
|
|
155
|
-
const record = asRecord(body);
|
|
156
|
-
if (!record) return null;
|
|
157
|
-
const id = asString(record.id);
|
|
158
|
-
if (id !== null) return id;
|
|
159
|
-
const value = asString(record.value);
|
|
160
|
-
if (value !== null) return value;
|
|
161
768
|
return null;
|
|
162
769
|
}
|
|
163
770
|
|
|
@@ -195,73 +802,857 @@ function readFeatureLocation(feature: unknown): StorymapSlideLocation | null {
|
|
|
195
802
|
if (value === undefined || value === null || value === "") continue;
|
|
196
803
|
locationProps[key] = value;
|
|
197
804
|
}
|
|
805
|
+
// Narrow the slideshow-driven keys the verbatim copy cannot check:
|
|
806
|
+
// a mistyped value must not reach the map as a wrong-typed field.
|
|
807
|
+
if (typeof location.rotation !== "number" || !Number.isFinite(location.rotation)) {
|
|
808
|
+
delete location.rotation;
|
|
809
|
+
}
|
|
810
|
+
if (typeof location.basemap !== "string" || location.basemap === "") {
|
|
811
|
+
delete location.basemap;
|
|
812
|
+
}
|
|
813
|
+
const filter = asRecord(location.filter);
|
|
814
|
+
if (location.filter !== undefined && filter === null) delete location.filter;
|
|
815
|
+
const mask = asRecord(location.mask);
|
|
816
|
+
if (location.mask !== undefined) {
|
|
817
|
+
if (
|
|
818
|
+
mask === null ||
|
|
819
|
+
typeof mask.x !== "number" ||
|
|
820
|
+
typeof mask.y !== "number" ||
|
|
821
|
+
typeof mask.w !== "number" ||
|
|
822
|
+
typeof mask.h !== "number"
|
|
823
|
+
) {
|
|
824
|
+
delete location.mask;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
198
827
|
}
|
|
199
828
|
return location;
|
|
200
829
|
}
|
|
201
830
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
831
|
+
/**
|
|
832
|
+
* Reads a lon/lat bounding box from a navPlace FeatureCollection holding a
|
|
833
|
+
* Polygon or MultiPolygon. The navPlace extension lists "supplying a single
|
|
834
|
+
* geographic bounding box" as a use case, which is the interoperable way to
|
|
835
|
+
* state the extent of a story — the legacy format has no such field, so it
|
|
836
|
+
* becomes `map_bbox` (the map is constrained to the box). Returns null when
|
|
837
|
+
* the collection has no polygon.
|
|
838
|
+
*/
|
|
839
|
+
function readNavPlaceBbox(navPlace: unknown): [number, number, number, number] | null {
|
|
840
|
+
const collection = asRecord(navPlace);
|
|
841
|
+
if (!collection) return null;
|
|
842
|
+
const features = Array.isArray(collection.features) ? collection.features : [];
|
|
843
|
+
for (const feature of features) {
|
|
844
|
+
const featureRecord = asRecord(feature);
|
|
845
|
+
if (!featureRecord) continue;
|
|
846
|
+
const geometry = asRecord(featureRecord.geometry);
|
|
847
|
+
const type = geometry ? asString(geometry.type) : null;
|
|
848
|
+
if (type !== "Polygon" && type !== "MultiPolygon") continue;
|
|
849
|
+
const positions: number[][] = [];
|
|
850
|
+
collectPositions(geometry?.coordinates, positions, 0);
|
|
851
|
+
const lons = positions.map((p) => p[0]).filter((n) => n !== undefined);
|
|
852
|
+
const lats = positions.map((p) => p[1]).filter((n) => n !== undefined);
|
|
853
|
+
if (lons.length === 0 || lats.length === 0) continue;
|
|
854
|
+
return [Math.min(...lons), Math.min(...lats), Math.max(...lons), Math.max(...lats)];
|
|
855
|
+
}
|
|
856
|
+
return null;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/** Flattens arbitrarily nested GeoJSON coordinate arrays into positions. */
|
|
860
|
+
function collectPositions(value: unknown, out: number[][], depth: number): void {
|
|
861
|
+
if (depth > 4 || !Array.isArray(value)) return;
|
|
862
|
+
if (value.length >= 2 && typeof value[0] === "number" && typeof value[1] === "number") {
|
|
863
|
+
const lon = asNumber(value[0]);
|
|
864
|
+
const lat = asNumber(value[1]);
|
|
865
|
+
if (lon !== null && lat !== null) out.push([lon, lat]);
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
for (const entry of value) {
|
|
869
|
+
collectPositions(entry, out, depth + 1);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* The first id of a P3 `thumbnail`, which is a *list* of content resources
|
|
875
|
+
* (§3.1). A bare resource is accepted too, since that is what a one-element
|
|
876
|
+
* list collapses to once it has been through a few tools.
|
|
877
|
+
*/
|
|
878
|
+
/**
|
|
879
|
+
* A manifest's `structures` as a slide order and a group per canvas (§3.5).
|
|
880
|
+
*
|
|
881
|
+
* Two jobs, both of which the ecosystem expects — Annona's range storyboard
|
|
882
|
+
* and TimelineJS's groups:
|
|
883
|
+
*
|
|
884
|
+
* 1. a Range with a `label` and **no `start`** is a *group* for its member
|
|
885
|
+
* canvases, which is the first meaning the inert `slide.group` field ever
|
|
886
|
+
* had. A Range with a `start` is a time segment of one canvas, not a group,
|
|
887
|
+
* so it is skipped.
|
|
888
|
+
* 2. the order canvases appear in, which is the curated sequence. That is the
|
|
889
|
+
* whole point of a storyboard: it may run the canvases in an order the
|
|
890
|
+
* document does not.
|
|
891
|
+
*
|
|
892
|
+
* Nested Ranges are chapters inside the group, so the outermost labelled Range
|
|
893
|
+
* is the group a slide belongs to — `slide.group` is one string, and a
|
|
894
|
+
* "Part 1 / Chapter 2" pair is more use to a host as the part.
|
|
895
|
+
*/
|
|
896
|
+
function readStructures(value: unknown): {
|
|
897
|
+
order: string[];
|
|
898
|
+
groups: Map<string, string>;
|
|
899
|
+
} {
|
|
900
|
+
const order: string[] = [];
|
|
901
|
+
const groups = new Map<string, string>();
|
|
902
|
+
const seen = new Set<string>();
|
|
903
|
+
|
|
904
|
+
const walk = (entries: unknown, group: string | null): void => {
|
|
905
|
+
for (const entry of Array.isArray(entries) ? entries : [entries]) {
|
|
906
|
+
// P3 lets a Range's `items` be a list of ids, of resource objects,
|
|
907
|
+
// or a mix; only the ids matter here
|
|
908
|
+
const bare = asString(entry);
|
|
909
|
+
if (bare !== null) {
|
|
910
|
+
if (!seen.has(bare)) {
|
|
911
|
+
seen.add(bare);
|
|
912
|
+
order.push(bare);
|
|
913
|
+
if (group !== null) groups.set(bare, group);
|
|
914
|
+
}
|
|
915
|
+
continue;
|
|
916
|
+
}
|
|
917
|
+
const record = asRecord(entry);
|
|
918
|
+
if (!record) continue;
|
|
919
|
+
const isRange = asString(record.type) === "Range";
|
|
920
|
+
if (!isRange) {
|
|
921
|
+
// a canvas or annotation resource reference in items
|
|
922
|
+
const id = asString(record.id);
|
|
923
|
+
if (id !== null && !seen.has(id)) {
|
|
924
|
+
seen.add(id);
|
|
925
|
+
order.push(id);
|
|
926
|
+
if (group !== null) groups.set(id, group);
|
|
927
|
+
}
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
const label = flattenLanguageMap(record.label);
|
|
931
|
+
const isGroup = label !== "" && record.start === undefined;
|
|
932
|
+
const nextGroup = isGroup ? (group ?? label) : group;
|
|
933
|
+
walk(record.items, nextGroup);
|
|
934
|
+
}
|
|
935
|
+
};
|
|
936
|
+
|
|
937
|
+
walk(value, null);
|
|
938
|
+
return { order, groups };
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* An Agent as a credit fragment: its label, or its id when it has none. An
|
|
943
|
+
* empty `label` produces a bare fragment, which is what a homepage-only Agent
|
|
944
|
+
* wants (§3.2).
|
|
945
|
+
*/
|
|
946
|
+
function agentCredit(value: unknown, label: string): string {
|
|
947
|
+
const entries = Array.isArray(value) ? value : [value];
|
|
948
|
+
const parts: string[] = [];
|
|
949
|
+
for (const entry of entries) {
|
|
950
|
+
const record = asRecord(entry);
|
|
951
|
+
if (!record) continue;
|
|
952
|
+
const name = flattenLanguageMap(record.label) || asString(record.id) || "";
|
|
953
|
+
if (name === "") continue;
|
|
954
|
+
parts.push(label === "" ? name : `${label}: ${name}`);
|
|
212
955
|
}
|
|
213
|
-
|
|
214
|
-
|
|
956
|
+
return parts.join(", ");
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* P3 `metadata` as plain `{label, value}` pairs (§3.2). Entries without both a
|
|
961
|
+
* label and a value are dropped, since a pair with neither says nothing.
|
|
962
|
+
*/
|
|
963
|
+
function readMetadata(value: unknown): { label: string; value: string }[] {
|
|
964
|
+
const entries = Array.isArray(value) ? value : [value];
|
|
965
|
+
const out: { label: string; value: string }[] = [];
|
|
966
|
+
for (const entry of entries) {
|
|
967
|
+
const record = asRecord(entry);
|
|
968
|
+
if (!record) continue;
|
|
969
|
+
const label = flattenLanguageMap(record.label);
|
|
970
|
+
const text = flattenLanguageMap(record.value);
|
|
971
|
+
if (label === "" && text === "") continue;
|
|
972
|
+
out.push({ label, value: text });
|
|
973
|
+
}
|
|
974
|
+
return out;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function readThumbnailId(value: unknown): string | null {
|
|
978
|
+
const entries = Array.isArray(value) ? value : [value];
|
|
979
|
+
for (const entry of entries) {
|
|
980
|
+
const id = asString(asRecord(entry)?.id);
|
|
981
|
+
if (id !== null) return id;
|
|
215
982
|
}
|
|
216
983
|
return null;
|
|
217
984
|
}
|
|
218
985
|
|
|
219
|
-
|
|
986
|
+
/**
|
|
987
|
+
* A canvas's P3 `background` annotation into a slide background (§2.6).
|
|
988
|
+
*
|
|
989
|
+
* The annotation is a painting annotation whose body is the image and/or the
|
|
990
|
+
* colour; an image body contributes `url` and a `Color` body contributes
|
|
991
|
+
* `color`.
|
|
992
|
+
*
|
|
993
|
+
* The result is always the object form. The bare-string form of
|
|
994
|
+
* `slide.background` means a *colour* — that is what the converter has always
|
|
995
|
+
* done with it — so returning a url as a bare string would read back as a
|
|
996
|
+
* colour on the next trip.
|
|
997
|
+
*
|
|
998
|
+
* `opacity` is not read. IIIF has no vocabulary for a background opacity and
|
|
999
|
+
* the viewer never rendered one, so nothing is lost by leaving it out.
|
|
1000
|
+
*/
|
|
1001
|
+
function readBackground(annotation: unknown): StorymapSlideBackground | string | null {
|
|
1002
|
+
const record = asRecord(annotation);
|
|
1003
|
+
if (!record) return null;
|
|
1004
|
+
const bodies = Array.isArray(record.body) ? record.body : [record.body];
|
|
1005
|
+
let url: string | null = null;
|
|
1006
|
+
let color: string | null = null;
|
|
1007
|
+
for (const entry of bodies) {
|
|
1008
|
+
const body = asRecord(entry);
|
|
1009
|
+
if (!body) continue;
|
|
1010
|
+
if (url === null && isMediaBody(body)) {
|
|
1011
|
+
url = asString(body.id);
|
|
1012
|
+
}
|
|
1013
|
+
if (color === null) {
|
|
1014
|
+
const type = asString(body.type);
|
|
1015
|
+
const value = asString(body.value);
|
|
1016
|
+
if (value !== null && (type === "Color" || type === null)) color = value;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
const background: StorymapSlideBackground = {};
|
|
1020
|
+
if (url !== null) background.url = url;
|
|
1021
|
+
if (color !== null) background.color = color;
|
|
1022
|
+
return Object.keys(background).length > 0 ? background : null;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function canvasToSlide(
|
|
1026
|
+
canvas: unknown,
|
|
1027
|
+
manifestFeature: unknown,
|
|
1028
|
+
preferredLanguage: string | null = null,
|
|
1029
|
+
): StorymapSlide | null {
|
|
220
1030
|
const record = asRecord(canvas);
|
|
221
1031
|
if (!record) return null;
|
|
222
1032
|
|
|
223
1033
|
const slide: StorymapSlide = {};
|
|
224
1034
|
|
|
225
|
-
// text: Canvas label → headline, Canvas summary → body text
|
|
226
|
-
|
|
227
|
-
const
|
|
1035
|
+
// text: Canvas label → headline, Canvas summary → body text, both in the
|
|
1036
|
+
// language the viewer is configured for (§3.4)
|
|
1037
|
+
const label = pickLanguageMap(record.label, preferredLanguage);
|
|
1038
|
+
const summary = pickLanguageMap(record.summary, preferredLanguage);
|
|
1039
|
+
const headline = label.value;
|
|
1040
|
+
const text = summary.value;
|
|
228
1041
|
if (headline !== "" || text !== "") {
|
|
229
1042
|
slide.text = {};
|
|
230
1043
|
if (headline !== "") slide.text.headline = headline;
|
|
231
1044
|
if (text !== "") slide.text.text = text;
|
|
232
1045
|
}
|
|
1046
|
+
// Which language this slide's text came from, so a host can offer a
|
|
1047
|
+
// language switch. `none` is language-neutral, so it is not reported.
|
|
1048
|
+
const language = label.language ?? summary.language;
|
|
1049
|
+
if (language !== null) slide.language = language;
|
|
233
1050
|
|
|
234
|
-
// media: painting annotation body
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
1051
|
+
// media: the painting annotation's body, with its label /
|
|
1052
|
+
// requiredStatement / accessibilitySummary as caption, credit and alt
|
|
1053
|
+
// text (§2.7 — the `mediaCaption`/`mediaCredit`/`mediaAlt` canvas terms are
|
|
1054
|
+
// gone). srcset/sizes remain terms: IIIF has no vocabulary for either.
|
|
1055
|
+
const paintings = readPaintings(record, asNumber(record.width), asNumber(record.height));
|
|
1056
|
+
// the first painting is the slide's media; further ones are its
|
|
1057
|
+
// media_extra (#358)
|
|
1058
|
+
const painting = paintings[0] ?? null;
|
|
1059
|
+
const extraPaintings = paintings.slice(1);
|
|
1060
|
+
const caption = painting?.label ?? null;
|
|
1061
|
+
const credit = painting?.credit ?? null;
|
|
1062
|
+
const alt = painting?.accessibilitySummary ?? null;
|
|
1063
|
+
const srcset = asString(readTerm(record, "mediaSrcset"));
|
|
1064
|
+
const sizes = asString(readTerm(record, "mediaSizes"));
|
|
1065
|
+
// P3 allows the thumbnail on the body or on the canvas, and real manifests
|
|
1066
|
+
// use both; the body's wins because it describes the painted resource
|
|
1067
|
+
// specifically
|
|
1068
|
+
const thumbnail = painting?.thumbnail ?? readThumbnailId(record.thumbnail);
|
|
1069
|
+
if (
|
|
1070
|
+
painting !== null ||
|
|
1071
|
+
caption !== null ||
|
|
1072
|
+
credit !== null ||
|
|
1073
|
+
alt !== null ||
|
|
1074
|
+
srcset !== null ||
|
|
1075
|
+
sizes !== null ||
|
|
1076
|
+
thumbnail !== null
|
|
1077
|
+
) {
|
|
239
1078
|
const media: StorymapSlideMedia = {};
|
|
240
|
-
if (
|
|
1079
|
+
if (painting !== null) media.url = painting.url;
|
|
241
1080
|
if (caption !== null) media.caption = caption;
|
|
242
1081
|
if (credit !== null) media.credit = credit;
|
|
1082
|
+
if (alt !== null) media.alt = alt;
|
|
1083
|
+
if (srcset !== null) media.srcset = srcset;
|
|
1084
|
+
if (sizes !== null) media.sizes = sizes;
|
|
1085
|
+
if (thumbnail !== null) media.thumb = thumbnail;
|
|
1086
|
+
if (painting?.subtitles != null) media.subtitles = painting.subtitles;
|
|
1087
|
+
if (painting?.offset !== undefined) media.offset = painting.offset;
|
|
1088
|
+
if (painting?.loop !== undefined) media.loop = painting.loop;
|
|
1089
|
+
if (painting?.play !== undefined) media.play = painting.play;
|
|
1090
|
+
if (painting?.stopOnExit !== undefined) media.stopOnExit = painting.stopOnExit;
|
|
243
1091
|
slide.media = media;
|
|
244
1092
|
}
|
|
1093
|
+
if (extraPaintings.length > 0) {
|
|
1094
|
+
slide.media_extra = extraPaintings.map((extra) => {
|
|
1095
|
+
const item: StorymapSlideMedia = { url: extra.url };
|
|
1096
|
+
if (extra.label !== null) item.caption = extra.label;
|
|
1097
|
+
if (extra.credit !== null) item.credit = extra.credit;
|
|
1098
|
+
if (extra.accessibilitySummary !== null) item.alt = extra.accessibilitySummary;
|
|
1099
|
+
return item;
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// narration: a supplementing Sound/Video body plays as the slide's
|
|
1104
|
+
// narration (V1 of #358). Skipped when it duplicates a painting URL —
|
|
1105
|
+
// a body carrying both motivations is already the slide's media.
|
|
1106
|
+
const supplement = readSupplementing(record);
|
|
1107
|
+
const mediaUrls = new Set(
|
|
1108
|
+
[slide.media?.url, ...(slide.media_extra ?? []).map((item) => item.url)].filter(
|
|
1109
|
+
(url): url is string => typeof url === "string" && url !== "",
|
|
1110
|
+
),
|
|
1111
|
+
);
|
|
1112
|
+
if (supplement !== null && !isDuplicateNarration([...mediaUrls], supplement.url)) {
|
|
1113
|
+
const { url, ...flags } = supplement;
|
|
1114
|
+
slide.narration = { url, ...flags };
|
|
1115
|
+
}
|
|
245
1116
|
|
|
246
1117
|
// location: canvas navPlace, falling back to a manifest-level navPlace
|
|
247
1118
|
// aggregated in items order (both are allowed by the proposal)
|
|
248
1119
|
const location = readLocation(record.navPlace) ?? readFeatureLocation(manifestFeature);
|
|
249
1120
|
if (location !== null) slide.location = location;
|
|
1121
|
+
// IIIF xywh region (slideshow-style image stops): [x, y, w, h] pixels.
|
|
1122
|
+
// The extension term wins over the interoperable spelling — an Image API
|
|
1123
|
+
// Selector on the painting annotation target
|
|
1124
|
+
// region: the painting annotation's target selector, the only source since
|
|
1125
|
+
// the storymap:imageRegion term went (§2.8)
|
|
1126
|
+
const region = painting?.region ?? null;
|
|
1127
|
+
if (region !== null) {
|
|
1128
|
+
slide.location = { ...(slide.location ?? {}), region };
|
|
1129
|
+
}
|
|
1130
|
+
// View directives as canvas `storymap:` terms: the fallback for slides
|
|
1131
|
+
// without lat/lon (no navPlace to carry them). navPlace wins when both
|
|
1132
|
+
// spell the same key.
|
|
1133
|
+
const canvasRotation = record[STORYMAP_PREFIX + "rotation"];
|
|
1134
|
+
if (
|
|
1135
|
+
slide.location?.rotation === undefined &&
|
|
1136
|
+
typeof canvasRotation === "number" &&
|
|
1137
|
+
Number.isFinite(canvasRotation)
|
|
1138
|
+
) {
|
|
1139
|
+
slide.location = { ...(slide.location ?? {}), rotation: canvasRotation };
|
|
1140
|
+
}
|
|
1141
|
+
const canvasBasemap = record[STORYMAP_PREFIX + "basemap"];
|
|
1142
|
+
if (
|
|
1143
|
+
slide.location?.basemap === undefined &&
|
|
1144
|
+
typeof canvasBasemap === "string" &&
|
|
1145
|
+
canvasBasemap !== ""
|
|
1146
|
+
) {
|
|
1147
|
+
slide.location = { ...(slide.location ?? {}), basemap: canvasBasemap };
|
|
1148
|
+
}
|
|
1149
|
+
const canvasFilter = asRecord(record[STORYMAP_PREFIX + "filter"]);
|
|
1150
|
+
if (slide.location?.filter === undefined && canvasFilter !== null) {
|
|
1151
|
+
slide.location = { ...(slide.location ?? {}), filter: canvasFilter };
|
|
1152
|
+
}
|
|
1153
|
+
const canvasMask = asRecord(record[STORYMAP_PREFIX + "mask"]);
|
|
1154
|
+
if (slide.location?.mask === undefined && canvasMask !== null) {
|
|
1155
|
+
const mx = canvasMask.x;
|
|
1156
|
+
const my = canvasMask.y;
|
|
1157
|
+
const mw = canvasMask.w;
|
|
1158
|
+
const mh = canvasMask.h;
|
|
1159
|
+
if (
|
|
1160
|
+
typeof mx === "number" &&
|
|
1161
|
+
typeof my === "number" &&
|
|
1162
|
+
typeof mw === "number" &&
|
|
1163
|
+
typeof mh === "number"
|
|
1164
|
+
) {
|
|
1165
|
+
slide.location = {
|
|
1166
|
+
...(slide.location ?? {}),
|
|
1167
|
+
mask: { ...canvasMask, x: mx, y: my, w: mw, h: mh },
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
const canvasSlidetimeout = record[STORYMAP_PREFIX + "slidetimeout"];
|
|
1172
|
+
if (
|
|
1173
|
+
slide.slidetimeout === undefined &&
|
|
1174
|
+
typeof canvasSlidetimeout === "number" &&
|
|
1175
|
+
Number.isFinite(canvasSlidetimeout) &&
|
|
1176
|
+
canvasSlidetimeout >= 0
|
|
1177
|
+
) {
|
|
1178
|
+
slide.slidetimeout = canvasSlidetimeout;
|
|
1179
|
+
}
|
|
1180
|
+
const canvasImgoverlay = asRecord(record[STORYMAP_PREFIX + "imgoverlay"]);
|
|
1181
|
+
if (slide.imgoverlay === undefined && canvasImgoverlay !== null) {
|
|
1182
|
+
const overlayUrl = asString(canvasImgoverlay.url);
|
|
1183
|
+
if (overlayUrl !== null) slide.imgoverlay = { ...canvasImgoverlay, url: overlayUrl };
|
|
1184
|
+
}
|
|
1185
|
+
const canvasProvenance = asRecord(record[STORYMAP_PREFIX + "provenance"]);
|
|
1186
|
+
if (slide.provenance === undefined && canvasProvenance !== null) {
|
|
1187
|
+
const provenance: Record<string, unknown> = {};
|
|
1188
|
+
for (const key of ["manifest", "canvas", "image"]) {
|
|
1189
|
+
const value = asString(canvasProvenance[key]);
|
|
1190
|
+
if (value !== null) provenance[key] = value;
|
|
1191
|
+
}
|
|
1192
|
+
if (Object.keys(provenance).length > 0) {
|
|
1193
|
+
slide.provenance = provenance as StorymapSlide["provenance"];
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
250
1196
|
|
|
251
1197
|
// StoryMap extension terms
|
|
252
1198
|
const slideType = asString(record[STORYMAP_PREFIX + "type"]);
|
|
253
1199
|
if (slideType !== null) slide.type = slideType;
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
1200
|
+
// P3's `navDate`, a string. A language map is read for leniency, since
|
|
1201
|
+
// other producers do emit one, but the official validator rejects it and
|
|
1202
|
+
// we never write one (§2.5).
|
|
1203
|
+
const navDate = record.navDate;
|
|
1204
|
+
const dateString = asString(navDate) ?? flattenLanguageMap(navDate);
|
|
1205
|
+
const dateRecord = asRecord(navDate);
|
|
1206
|
+
if (dateString !== null && dateString !== "") slide.date = dateString;
|
|
258
1207
|
else if (dateRecord !== null) slide.date = dateRecord;
|
|
259
|
-
|
|
1208
|
+
// background: the canvas's standard `background` painting annotation. The
|
|
1209
|
+
// storymap:background term is gone (§2.6).
|
|
1210
|
+
const background = readBackground(record.background);
|
|
260
1211
|
if (background !== null) slide.background = background;
|
|
261
1212
|
|
|
262
1213
|
return slide;
|
|
263
1214
|
}
|
|
264
1215
|
|
|
1216
|
+
/**
|
|
1217
|
+
* Motivations that turn an annotation into a tour stop. `painting` is the
|
|
1218
|
+
* canvas's image and is handled separately; everything here is commentary
|
|
1219
|
+
* about it, which is what a guided tour is made of.
|
|
1220
|
+
*/
|
|
1221
|
+
const STOP_MOTIVATIONS = new Set(["commenting", "tagging", "classifying", "describing"]);
|
|
1222
|
+
|
|
1223
|
+
/**
|
|
1224
|
+
* A `text/plain` body becomes one paragraph per blank-line-separated block,
|
|
1225
|
+
* escaped (`escapeText` is shared with the slideshow translator, which
|
|
1226
|
+
* wraps single paragraphs the same way). A `text/html` body is passed
|
|
1227
|
+
* through as markup — the slide text
|
|
1228
|
+
* pipeline (`sanitizeSlideText`, via `media/types/Text.ts`) sanitizes whatever
|
|
1229
|
+
* it is given, which is the same path a storymap JSON `text.text` takes.
|
|
1230
|
+
*/
|
|
1231
|
+
function annotationTextToSlideText(format: string | null, value: string): string | null {
|
|
1232
|
+
if (value.trim() === "") return null;
|
|
1233
|
+
const is_html = format !== null && /^text\/html/i.test(format);
|
|
1234
|
+
if (is_html) return value;
|
|
1235
|
+
const blocks = value
|
|
1236
|
+
.split(/\n\s*\n/)
|
|
1237
|
+
.map((block) => block.trim())
|
|
1238
|
+
.filter((block) => block !== "");
|
|
1239
|
+
if (blocks.length === 0) return null;
|
|
1240
|
+
return blocks.map((block) => `<p>${escapeText(block)}</p>`).join("");
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/** True for a body that is a media resource rather than text. */
|
|
1244
|
+
function isMediaBody(body: Record<string, unknown>): boolean {
|
|
1245
|
+
const id = asString(body.id);
|
|
1246
|
+
if (id === null) return false;
|
|
1247
|
+
const type = asString(body.type);
|
|
1248
|
+
if (type !== null) {
|
|
1249
|
+
return ["Image", "Sound", "Video", "Dataset", "Model"].includes(type);
|
|
1250
|
+
}
|
|
1251
|
+
// An untyped body with an id is a media resource by P3 convention.
|
|
1252
|
+
return true;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/**
|
|
1256
|
+
* One annotation → one tour stop, or null when it is not a stop.
|
|
1257
|
+
*
|
|
1258
|
+
* A stop needs somewhere to go: the target has to resolve to a region, either
|
|
1259
|
+
* from an `xywh` fragment / Image API Selector or from a `PointSelector`
|
|
1260
|
+
* (synthesized into a square by `readSelector`, which needs the canvas size).
|
|
1261
|
+
* An annotation targeting the whole canvas is not a stop — the canvas slide
|
|
1262
|
+
* already covers it.
|
|
1263
|
+
*
|
|
1264
|
+
* The body may be one resource or an array. A `TextualBody` contributes the
|
|
1265
|
+
* slide text, a typed media body (typically a `Sound`) contributes
|
|
1266
|
+
* `media.url`, and both contribute caption/credit/alt.
|
|
1267
|
+
*/
|
|
1268
|
+
function readAnnotationStop(
|
|
1269
|
+
annotation: unknown,
|
|
1270
|
+
width: number | null,
|
|
1271
|
+
height: number | null,
|
|
1272
|
+
): StorymapSlide | null {
|
|
1273
|
+
const record = asRecord(annotation);
|
|
1274
|
+
if (!record) return null;
|
|
1275
|
+
|
|
1276
|
+
const motivations = Array.isArray(record.motivation) ? record.motivation : [record.motivation];
|
|
1277
|
+
const is_stop = motivations.some((m) => {
|
|
1278
|
+
const value = asString(m);
|
|
1279
|
+
return value !== null && STOP_MOTIVATIONS.has(value);
|
|
1280
|
+
});
|
|
1281
|
+
if (!is_stop) return null;
|
|
1282
|
+
|
|
1283
|
+
const selector = readSelector(record.target, width, height);
|
|
1284
|
+
if (selector.region === null) return null;
|
|
1285
|
+
|
|
1286
|
+
const bodies = Array.isArray(record.body) ? record.body : [record.body];
|
|
1287
|
+
let text: string | null = null;
|
|
1288
|
+
let media: StorymapSlideMedia | null = null;
|
|
1289
|
+
|
|
1290
|
+
for (const entry of bodies) {
|
|
1291
|
+
const body = asRecord(entry);
|
|
1292
|
+
if (!body) continue;
|
|
1293
|
+
if (isMediaBody(body)) {
|
|
1294
|
+
if (media === null) {
|
|
1295
|
+
const url = asString(body.id);
|
|
1296
|
+
if (url !== null) {
|
|
1297
|
+
// Same precedence as readPainting: the annotation's own
|
|
1298
|
+
// P3 properties first, the body's as a fallback (§2.7)
|
|
1299
|
+
const caption =
|
|
1300
|
+
flattenLanguageMap(record.label) || flattenLanguageMap(body.label);
|
|
1301
|
+
const credit = readAnnotationCredit(record) ?? readBodyCredit(body);
|
|
1302
|
+
const alt =
|
|
1303
|
+
flattenLanguageMap(record.accessibilitySummary) ||
|
|
1304
|
+
flattenLanguageMap(body.accessibilitySummary);
|
|
1305
|
+
media = {
|
|
1306
|
+
url,
|
|
1307
|
+
...(caption !== "" ? { caption } : {}),
|
|
1308
|
+
...(credit !== null ? { credit } : {}),
|
|
1309
|
+
...(alt !== "" ? { alt } : {}),
|
|
1310
|
+
...(readThumbnailId(body.thumbnail) !== null
|
|
1311
|
+
? { thumb: readThumbnailId(body.thumbnail) as string }
|
|
1312
|
+
: {}),
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
continue;
|
|
1317
|
+
}
|
|
1318
|
+
if (text === null) {
|
|
1319
|
+
const value = asString(body.value);
|
|
1320
|
+
if (value !== null) {
|
|
1321
|
+
text = annotationTextToSlideText(asString(body.format), value);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// An annotation carrying nothing but a target is still a usable stop: it
|
|
1327
|
+
// focuses the image on a region.
|
|
1328
|
+
if (text === null && media === null) return null;
|
|
1329
|
+
|
|
1330
|
+
// The annotation's own label is the stop's headline, which is what the
|
|
1331
|
+
// slider and the marker label show. A body `label` is the media caption and
|
|
1332
|
+
// is handled above.
|
|
1333
|
+
const headline = flattenLanguageMap(record.label);
|
|
1334
|
+
|
|
1335
|
+
const slide: StorymapSlide = { location: { region: selector.region } };
|
|
1336
|
+
if (text !== null || headline !== "") {
|
|
1337
|
+
slide.text = {
|
|
1338
|
+
...(text !== null ? { text } : {}),
|
|
1339
|
+
...(headline !== "" ? { headline } : {}),
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
if (media !== null) slide.media = media;
|
|
1343
|
+
return slide;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
/**
|
|
1347
|
+
* Annotation-driven tour stops for one canvas, in annotation page order.
|
|
1348
|
+
* Appended after the canvas's own slide, so the story reads "here is the
|
|
1349
|
+
* whole picture, then here is each detail".
|
|
1350
|
+
*/
|
|
1351
|
+
export function readCommentingAnnotations(canvas: unknown): StorymapSlide[] {
|
|
1352
|
+
const record = asRecord(canvas);
|
|
1353
|
+
if (!record) return [];
|
|
1354
|
+
const width = asNumber(record.width);
|
|
1355
|
+
const height = asNumber(record.height);
|
|
1356
|
+
|
|
1357
|
+
const stops: StorymapSlide[] = [];
|
|
1358
|
+
const annotationPages = Array.isArray(record.items) ? record.items : [];
|
|
1359
|
+
for (const page of annotationPages) {
|
|
1360
|
+
const annotations = asRecord(page);
|
|
1361
|
+
if (!annotations || !Array.isArray(annotations.items)) continue;
|
|
1362
|
+
for (const annotation of annotations.items) {
|
|
1363
|
+
const stop = readAnnotationStop(annotation, width, height);
|
|
1364
|
+
if (stop !== null) stops.push(stop);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
return stops;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
/** An external resource a manifest points at with `seeAlso`. */
|
|
1371
|
+
export type SeeAlsoReference = {
|
|
1372
|
+
id: string;
|
|
1373
|
+
/** The IIIF class, which is what tells us how to read it. */
|
|
1374
|
+
type: string;
|
|
1375
|
+
};
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* The `seeAlso` targets of a manifest, canvas or range.
|
|
1379
|
+
*
|
|
1380
|
+
* The class is recorded because it is what decides how a target is read: an
|
|
1381
|
+
* `AnnotationCollection` or `AnnotationPage` is a list of annotations to merge,
|
|
1382
|
+
* while a `SearchService1` is an endpoint to hand to a host rather than
|
|
1383
|
+
* annotations to load. An untyped entry is reported as `unknown` rather than
|
|
1384
|
+
* guessed at, because fetching a URI on the strength of a guess is how a viewer
|
|
1385
|
+
* ends up dereferencing something it should not have.
|
|
1386
|
+
*/
|
|
1387
|
+
export function collectSeeAlso(value: unknown): SeeAlsoReference[] {
|
|
1388
|
+
const record = asRecord(value);
|
|
1389
|
+
if (!record) return [];
|
|
1390
|
+
const entries = Array.isArray(record.seeAlso) ? record.seeAlso : [record.seeAlso];
|
|
1391
|
+
const out: SeeAlsoReference[] = [];
|
|
1392
|
+
for (const entry of entries) {
|
|
1393
|
+
const linked = asRecord(entry);
|
|
1394
|
+
if (!linked) continue;
|
|
1395
|
+
const id = asString(linked.id) ?? asString(linked);
|
|
1396
|
+
if (id === null) continue;
|
|
1397
|
+
out.push({ id, type: asString(linked.type) ?? "unknown" });
|
|
1398
|
+
}
|
|
1399
|
+
return out;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
/** Every `seeAlso` a manifest points at: its own, plus one per canvas. */
|
|
1403
|
+
export function collectManifestSeeAlso(manifest: unknown): SeeAlsoReference[] {
|
|
1404
|
+
const record = asRecord(manifest);
|
|
1405
|
+
if (!record) return [];
|
|
1406
|
+
const seen = new Set<string>();
|
|
1407
|
+
const out: SeeAlsoReference[] = [];
|
|
1408
|
+
const add = (reference: SeeAlsoReference) => {
|
|
1409
|
+
if (seen.has(reference.id)) return;
|
|
1410
|
+
seen.add(reference.id);
|
|
1411
|
+
out.push(reference);
|
|
1412
|
+
};
|
|
1413
|
+
for (const reference of collectSeeAlso(record)) add(reference);
|
|
1414
|
+
const canvases = Array.isArray(record.items) ? record.items : [];
|
|
1415
|
+
for (const canvas of canvases) {
|
|
1416
|
+
for (const reference of collectSeeAlso(canvas)) add(reference);
|
|
1417
|
+
}
|
|
1418
|
+
return out;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/** The canvas an annotation targets, with a `#xywh=` fragment stripped. */
|
|
1422
|
+
function canvasIdOfTarget(target: unknown): string | null {
|
|
1423
|
+
const text = asString(target);
|
|
1424
|
+
if (text !== null) {
|
|
1425
|
+
const at = text.indexOf("#");
|
|
1426
|
+
return at === -1 ? text : text.slice(0, at);
|
|
1427
|
+
}
|
|
1428
|
+
const record = asRecord(target);
|
|
1429
|
+
if (!record) return null;
|
|
1430
|
+
if (record.source !== undefined) {
|
|
1431
|
+
const source = asString(record.source) ?? asString(asRecord(record.source)?.id);
|
|
1432
|
+
return source;
|
|
1433
|
+
}
|
|
1434
|
+
return asString(record.id);
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
function pushAnnotation(annotation: unknown, byCanvas: Map<string, unknown[]>): void {
|
|
1438
|
+
const record = asRecord(annotation);
|
|
1439
|
+
if (!record) return;
|
|
1440
|
+
const canvasId = canvasIdOfTarget(record.target);
|
|
1441
|
+
if (canvasId === null) return;
|
|
1442
|
+
const list = byCanvas.get(canvasId);
|
|
1443
|
+
if (list === undefined) byCanvas.set(canvasId, [annotation]);
|
|
1444
|
+
else list.push(annotation);
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
function pushPageItems(page: Record<string, unknown>, byCanvas: Map<string, unknown[]>): void {
|
|
1448
|
+
const items = Array.isArray(page.items) ? page.items : [];
|
|
1449
|
+
for (const annotation of items) {
|
|
1450
|
+
pushAnnotation(annotation, byCanvas);
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/**
|
|
1455
|
+
* The annotations of an external page, indexed by canvas id, plus the
|
|
1456
|
+
* annotation-page URLs it refers to.
|
|
1457
|
+
*
|
|
1458
|
+
* One `AnnotationCollection` can carry annotations for many canvases, and a
|
|
1459
|
+
* canvas's annotations may be split across several referenced pages, so a
|
|
1460
|
+
* collection, a single page, and a bare list of annotations are all accepted.
|
|
1461
|
+
*/
|
|
1462
|
+
function indexExternalAnnotations(document: unknown): {
|
|
1463
|
+
byCanvas: Map<string, unknown[]>;
|
|
1464
|
+
pages: string[];
|
|
1465
|
+
} {
|
|
1466
|
+
const byCanvas = new Map<string, unknown[]>();
|
|
1467
|
+
const pages: string[] = [];
|
|
1468
|
+
const record = asRecord(document);
|
|
1469
|
+
if (!record) return { byCanvas, pages };
|
|
1470
|
+
|
|
1471
|
+
const type = asString(record.type);
|
|
1472
|
+
if (type === "AnnotationPage") {
|
|
1473
|
+
pushPageItems(record, byCanvas);
|
|
1474
|
+
const id = asString(record.id);
|
|
1475
|
+
if (id !== null) pages.push(id);
|
|
1476
|
+
return { byCanvas, pages };
|
|
1477
|
+
}
|
|
1478
|
+
if (type === "AnnotationCollection") {
|
|
1479
|
+
const items = Array.isArray(record.items) ? record.items : [];
|
|
1480
|
+
for (const item of items) {
|
|
1481
|
+
const entry = asRecord(item);
|
|
1482
|
+
if (!entry) continue;
|
|
1483
|
+
// A referenced page rather than an embedded annotation. The type
|
|
1484
|
+
// is what tells them apart: an `Annotation` also has an `id` and
|
|
1485
|
+
// no `items`, so keying on the absence of `items` alone sent every
|
|
1486
|
+
// annotation back out to be fetched as a page of its own.
|
|
1487
|
+
const entryType = asString(entry.type);
|
|
1488
|
+
const isReference =
|
|
1489
|
+
entry.items === undefined &&
|
|
1490
|
+
(entryType === "AnnotationPage" || entryType === "AnnotationCollection") &&
|
|
1491
|
+
asString(entry.id) !== null;
|
|
1492
|
+
if (isReference) {
|
|
1493
|
+
pages.push(asString(entry.id) as string);
|
|
1494
|
+
continue;
|
|
1495
|
+
}
|
|
1496
|
+
// A collection's item is either an embedded page (which carries its
|
|
1497
|
+
// own `items`) or a bare annotation, and a bare annotation has no
|
|
1498
|
+
// `items` of its own — reading it as a page-shaped object silently
|
|
1499
|
+
// dropped every annotation in the collection.
|
|
1500
|
+
if (entry.items !== undefined) pushPageItems(entry, byCanvas);
|
|
1501
|
+
else pushAnnotation(entry, byCanvas);
|
|
1502
|
+
}
|
|
1503
|
+
return { byCanvas, pages };
|
|
1504
|
+
}
|
|
1505
|
+
return { byCanvas, pages };
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
export type ExternalAnnotations = {
|
|
1509
|
+
/** Tour stops, keyed by the canvas id they annotate. */
|
|
1510
|
+
stops: Map<string, StorymapSlide[]>;
|
|
1511
|
+
/** A `SearchService1` seen along the way, for a host to query. */
|
|
1512
|
+
searchService: string | null;
|
|
1513
|
+
/** Annotation-page URLs that could not be read, for a console report. */
|
|
1514
|
+
failed: string[];
|
|
1515
|
+
};
|
|
1516
|
+
|
|
1517
|
+
/**
|
|
1518
|
+
* A per-(id, type) cache of fetched annotation documents.
|
|
1519
|
+
*
|
|
1520
|
+
* Keyed on both, because the same URI can be reached as a collection and as a
|
|
1521
|
+
* page and the two are read differently. The *promise* is cached rather than
|
|
1522
|
+
* the result, so two callers racing the same page share one request.
|
|
1523
|
+
*/
|
|
1524
|
+
const annotationCache = new Map<string, Promise<unknown>>();
|
|
1525
|
+
|
|
1526
|
+
/** Forget every cached annotation document. Exported for tests. */
|
|
1527
|
+
export function clearSeeAlsoCache(): void {
|
|
1528
|
+
annotationCache.clear();
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
/**
|
|
1532
|
+
* Read the annotation documents a manifest points at with `seeAlso`.
|
|
1533
|
+
*
|
|
1534
|
+
* Asynchronous by design, and deliberately not called from
|
|
1535
|
+
* `manifestToStorymapData`: a manifest can point at anything, a viewer should
|
|
1536
|
+
* not block its first paint on a third party, and a host needs to decide whether
|
|
1537
|
+
* it wants the extra round trips at all. One level is followed — a referenced
|
|
1538
|
+
* `AnnotationPage` inside a collection is fetched, but a `seeAlso` *inside* that
|
|
1539
|
+
* page is not — which is what keeps a cycle from becoming an infinite walk.
|
|
1540
|
+
*
|
|
1541
|
+
* A `SearchService1` is recorded and not followed: it is an endpoint for a host
|
|
1542
|
+
* to query, not a list of annotations to load.
|
|
1543
|
+
*/
|
|
1544
|
+
export async function loadSeeAlso(
|
|
1545
|
+
manifest: unknown,
|
|
1546
|
+
options: { fetchImpl?: typeof fetch } = {},
|
|
1547
|
+
): Promise<ExternalAnnotations> {
|
|
1548
|
+
const result: ExternalAnnotations = { stops: new Map(), searchService: null, failed: [] };
|
|
1549
|
+
const record = asRecord(manifest);
|
|
1550
|
+
if (!record) return result;
|
|
1551
|
+
|
|
1552
|
+
const impl = options.fetchImpl ?? fetch;
|
|
1553
|
+
const canvases = Array.isArray(record.items) ? record.items : [];
|
|
1554
|
+
const wanted = collectManifestSeeAlso(record);
|
|
1555
|
+
|
|
1556
|
+
const stopCollector = async (annotations: unknown[], canvasId: string): Promise<void> => {
|
|
1557
|
+
const canvas = canvasStub(canvasId, canvases);
|
|
1558
|
+
if (canvas === null) return;
|
|
1559
|
+
const stops = readCommentingAnnotations({
|
|
1560
|
+
...(asRecord(canvas) as Record<string, unknown>),
|
|
1561
|
+
items: [{ type: "AnnotationPage", items: annotations }],
|
|
1562
|
+
});
|
|
1563
|
+
if (stops.length === 0) return;
|
|
1564
|
+
const existing = result.stops.get(canvasId);
|
|
1565
|
+
if (existing === undefined) result.stops.set(canvasId, stops);
|
|
1566
|
+
else existing.push(...stops);
|
|
1567
|
+
};
|
|
1568
|
+
|
|
1569
|
+
await Promise.all(
|
|
1570
|
+
wanted.map(async (reference) => {
|
|
1571
|
+
if (reference.type === "SearchService1") {
|
|
1572
|
+
result.searchService = reference.id;
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
if (
|
|
1576
|
+
reference.type !== "AnnotationCollection" &&
|
|
1577
|
+
reference.type !== "AnnotationPage" &&
|
|
1578
|
+
reference.type !== "unknown"
|
|
1579
|
+
) {
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
const loaded = await loadDocument(reference, impl, result);
|
|
1583
|
+
if (loaded === null) return;
|
|
1584
|
+
for (const [canvasId, annotations] of loaded.byCanvas) {
|
|
1585
|
+
await stopCollector(annotations, canvasId);
|
|
1586
|
+
}
|
|
1587
|
+
for (const pageId of loaded.pages) {
|
|
1588
|
+
const page = await loadDocument(
|
|
1589
|
+
{ id: pageId, type: "AnnotationPage" },
|
|
1590
|
+
impl,
|
|
1591
|
+
result,
|
|
1592
|
+
);
|
|
1593
|
+
if (page === null) continue;
|
|
1594
|
+
for (const [canvasId, annotations] of page.byCanvas) {
|
|
1595
|
+
await stopCollector(annotations, canvasId);
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
}),
|
|
1599
|
+
);
|
|
1600
|
+
|
|
1601
|
+
return result;
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
/**
|
|
1605
|
+
* The canvas object an external annotation names, or null when the manifest
|
|
1606
|
+
* does not have that canvas.
|
|
1607
|
+
*
|
|
1608
|
+
* Null rather than a synthetic stub on purpose: the plan merges external
|
|
1609
|
+
* annotations "for the canvases we render", so an annotation aimed at a canvas
|
|
1610
|
+
* this story does not carry is not a tour stop. A stub would also invent a
|
|
1611
|
+
* canvas with no size, leaving a region with nothing to be relative to.
|
|
1612
|
+
*/
|
|
1613
|
+
function canvasStub(canvasId: string, manifestCanvases: unknown[]): unknown {
|
|
1614
|
+
for (const canvas of manifestCanvases) {
|
|
1615
|
+
const record = asRecord(canvas);
|
|
1616
|
+
if (record !== null && asString(record.id) === canvasId) return record;
|
|
1617
|
+
}
|
|
1618
|
+
return null;
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
async function loadDocument(
|
|
1622
|
+
reference: SeeAlsoReference,
|
|
1623
|
+
impl: typeof fetch,
|
|
1624
|
+
result: ExternalAnnotations,
|
|
1625
|
+
): Promise<{ byCanvas: Map<string, unknown[]>; pages: string[] } | null> {
|
|
1626
|
+
const key = `${reference.type} ${reference.id}`;
|
|
1627
|
+
let pending = annotationCache.get(key);
|
|
1628
|
+
if (pending === undefined) {
|
|
1629
|
+
pending = impl(reference.id, {
|
|
1630
|
+
headers: { Accept: "application/ld+json, application/json" },
|
|
1631
|
+
})
|
|
1632
|
+
.then((response) => {
|
|
1633
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
1634
|
+
return response.json() as Promise<unknown>;
|
|
1635
|
+
})
|
|
1636
|
+
.catch((error: unknown) => {
|
|
1637
|
+
// a cached rejection would be remembered forever, so drop it
|
|
1638
|
+
// and let a later call retry
|
|
1639
|
+
annotationCache.delete(key);
|
|
1640
|
+
throw error;
|
|
1641
|
+
});
|
|
1642
|
+
annotationCache.set(key, pending);
|
|
1643
|
+
}
|
|
1644
|
+
try {
|
|
1645
|
+
return indexExternalAnnotations(await pending);
|
|
1646
|
+
} catch (error) {
|
|
1647
|
+
result.failed.push(reference.id);
|
|
1648
|
+
console.warn(
|
|
1649
|
+
`StoryMapJS: the seeAlso target could not be read: ${reference.id}`,
|
|
1650
|
+
error instanceof Error ? error.message : error,
|
|
1651
|
+
);
|
|
1652
|
+
return null;
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
|
|
265
1656
|
/**
|
|
266
1657
|
* Converts a IIIF Presentation API 3.0 manifest into legacy StoryMapJS data
|
|
267
1658
|
* (the `storymap` object). Malformed or missing pieces are skipped; the
|
|
@@ -272,25 +1663,85 @@ export function manifestToStorymapData(manifest: unknown): StorymapData {
|
|
|
272
1663
|
const record = asRecord(manifest);
|
|
273
1664
|
if (!record) return data;
|
|
274
1665
|
|
|
1666
|
+
// A Collection is not a Manifest, but it is a legitimate IIIF input: its
|
|
1667
|
+
// member Manifests' canvases concatenate into one linear story (§2.2)
|
|
1668
|
+
if (isPresentation3Collection(record)) {
|
|
1669
|
+
return collectionToStorymapData(record);
|
|
1670
|
+
}
|
|
1671
|
+
|
|
275
1672
|
const title = flattenLanguageMap(record.label);
|
|
276
1673
|
if (title !== "") data.title = title;
|
|
277
1674
|
|
|
1675
|
+
// A slide is addressed by its Canvas id, falling back to the Manifest's
|
|
1676
|
+
// (§2.3). The viewer already resolves deep links by `uniqueid`
|
|
1677
|
+
// (`StorySlider.goToId`) and generates one when falsy, so this is what
|
|
1678
|
+
// makes a manifest stop shareable by its canonical id.
|
|
1679
|
+
const manifestId = asString(record.id);
|
|
1680
|
+
|
|
278
1681
|
// Manifest-level map configuration service → storymap options fields.
|
|
279
1682
|
const config = readMapConfig(record);
|
|
280
1683
|
if (config) {
|
|
281
1684
|
applyMapConfig(data, config);
|
|
282
1685
|
}
|
|
283
1686
|
|
|
284
|
-
//
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
1687
|
+
// georeferenced IIIF images (Georeference Extension) sit above the
|
|
1688
|
+
// term-based overlay layers. The annotations are canvas-scoped but the
|
|
1689
|
+
// layers they describe are map-wide, so they are collected from *every*
|
|
1690
|
+
// canvas: a layer annotated on canvas 0 must not disappear for the rest
|
|
1691
|
+
// of the story (§2.10).
|
|
1692
|
+
const georeferenced = readGeoreferencedLayers(record);
|
|
1693
|
+
if (georeferenced.length > 0) {
|
|
1694
|
+
const existing = (data.overlays as StorymapOverlayLayer[] | undefined) ?? [];
|
|
1695
|
+
data.overlays = [...existing, ...georeferenced];
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
// An image basemap is a painting body carrying an Image API service in
|
|
1699
|
+
// `service[]`; its base is where `iiif.url` comes from (§2.4). Gated on
|
|
1700
|
+
// map_type "iiif" because an ordinary slide can just as easily be an IIIF
|
|
1701
|
+
// image, and only the basemap one is the map.
|
|
1702
|
+
if (data.map_type === "iiif") {
|
|
1703
|
+
const imageService = readImageServiceUrl(record);
|
|
1704
|
+
if (imageService !== null) data.iiif = { url: imageService, attribution: "" };
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
// Institutional credit: the manifest's own `requiredStatement`, then who
|
|
1708
|
+
// provided it and under what licence. All of it lands on the one credit
|
|
1709
|
+
// string the viewer renders, so a licence stated in the manifest is
|
|
1710
|
+
// actually shown rather than silently dropped (§3.2).
|
|
1711
|
+
const credit = [
|
|
1712
|
+
formatAttribution(readRequiredStatement(record.requiredStatement)),
|
|
1713
|
+
agentCredit(record.provider, "Provider"),
|
|
1714
|
+
asString(record.rights) !== null ? `Licence: ${asString(record.rights)}` : "",
|
|
1715
|
+
// an Agent with only a homepage still says who published this
|
|
1716
|
+
agentCredit(record.homepage, ""),
|
|
1717
|
+
]
|
|
1718
|
+
.filter((part) => part !== "")
|
|
1719
|
+
.join(" · ");
|
|
1720
|
+
if (credit !== "") {
|
|
288
1721
|
const iiif = (data.iiif as { url?: string; attribution?: string } | undefined) ?? {};
|
|
289
|
-
iiif.attribution =
|
|
1722
|
+
iiif.attribution = credit;
|
|
290
1723
|
if (iiif.url === undefined) iiif.url = "";
|
|
291
1724
|
data.iiif = iiif;
|
|
292
1725
|
}
|
|
293
1726
|
|
|
1727
|
+
// `logo` is an image, not a credit line, so it is offered as data for a
|
|
1728
|
+
// host to show (§3.2)
|
|
1729
|
+
const logo = asString(asRecord(record.logo)?.id);
|
|
1730
|
+
if (logo !== null) data.logo = logo;
|
|
1731
|
+
|
|
1732
|
+
// `metadata` is a list of label/value pairs, which cannot be rendered
|
|
1733
|
+
// generically — they are handed on as data (§3.2)
|
|
1734
|
+
const metadata = readMetadata(record.metadata);
|
|
1735
|
+
if (metadata.length > 0) data.metadata = metadata;
|
|
1736
|
+
|
|
1737
|
+
// The `seeAlso` targets are recorded but not followed here: reading them is
|
|
1738
|
+
// a network round trip per document, and a viewer should not block its
|
|
1739
|
+
// first paint on a third party. `loadSeeAlso()` fetches them on request,
|
|
1740
|
+
// behind its own cache, and the storymap's own slides stand on their own
|
|
1741
|
+
// meanwhile (§5.2).
|
|
1742
|
+
const seeAlso = collectManifestSeeAlso(record);
|
|
1743
|
+
if (seeAlso.length > 0) data.see_also = seeAlso;
|
|
1744
|
+
|
|
294
1745
|
// items[] (Canvases) → slides[], in order
|
|
295
1746
|
const items = Array.isArray(record.items) ? record.items : [];
|
|
296
1747
|
const manifestNavPlace = asRecord(record.navPlace);
|
|
@@ -298,9 +1749,67 @@ export function manifestToStorymapData(manifest: unknown): StorymapData {
|
|
|
298
1749
|
manifestNavPlace && Array.isArray(manifestNavPlace.features)
|
|
299
1750
|
? manifestNavPlace.features
|
|
300
1751
|
: [];
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
1752
|
+
|
|
1753
|
+
// `structures` states the curated order and the groups (§3.5). A Range
|
|
1754
|
+
// with a label and no `start` is a group; nested Ranges are chapters
|
|
1755
|
+
// within it, and a Range whose items run in a different order than the
|
|
1756
|
+
// canvases is a storyboard. Either way the order comes from here, not
|
|
1757
|
+
// from the document.
|
|
1758
|
+
const structures = readStructures(record.structures);
|
|
1759
|
+
const order = new Map<string, number>();
|
|
1760
|
+
for (const [position, canvasId] of structures.order.entries()) {
|
|
1761
|
+
order.set(canvasId, position);
|
|
1762
|
+
}
|
|
1763
|
+
const indexes = items.map((_item, index) => index);
|
|
1764
|
+
indexes.sort((a, b) => {
|
|
1765
|
+
const ia = order.get(asString(asRecord(items[a])?.id) ?? "");
|
|
1766
|
+
const ib = order.get(asString(asRecord(items[b])?.id) ?? "");
|
|
1767
|
+
// a canvas no Range mentions keeps its document position, after the
|
|
1768
|
+
// ones a Range does mention
|
|
1769
|
+
if (ia === undefined && ib === undefined) return a - b;
|
|
1770
|
+
if (ia === undefined) return 1;
|
|
1771
|
+
if (ib === undefined) return -1;
|
|
1772
|
+
return ia - ib;
|
|
1773
|
+
});
|
|
1774
|
+
|
|
1775
|
+
for (const index of indexes) {
|
|
1776
|
+
// The canvas index, not the output-slide index: annotation stops
|
|
1777
|
+
// appended below shift the slide array, and the manifest-level
|
|
1778
|
+
// navPlace features line up with canvases.
|
|
1779
|
+
const canvasId = asString(asRecord(items[index])?.id);
|
|
1780
|
+
const slide = canvasToSlide(
|
|
1781
|
+
items[index],
|
|
1782
|
+
manifestFeatures[index],
|
|
1783
|
+
asString(data.language) ?? null,
|
|
1784
|
+
);
|
|
1785
|
+
if (slide !== null) {
|
|
1786
|
+
slide.uniqueid = canvasId ?? manifestId ?? "";
|
|
1787
|
+
if (canvasId !== null) {
|
|
1788
|
+
const group = structures.groups.get(canvasId);
|
|
1789
|
+
if (group !== undefined) slide.group = group;
|
|
1790
|
+
}
|
|
1791
|
+
data.slides.push(slide);
|
|
1792
|
+
}
|
|
1793
|
+
// Annotation-driven tour stops, in annotation page order. They share
|
|
1794
|
+
// the canvas they annotate, suffixed so they stay addressable and do
|
|
1795
|
+
// not collide with it.
|
|
1796
|
+
for (const stop of readCommentingAnnotations(items[index])) {
|
|
1797
|
+
if (canvasId) {
|
|
1798
|
+
stop.uniqueid = `${canvasId}#${data.slides.length}`;
|
|
1799
|
+
}
|
|
1800
|
+
data.slides.push(stop);
|
|
1801
|
+
}
|
|
1802
|
+
// A polygon navPlace states the geographic extent of the story
|
|
1803
|
+
// ("supplying a single geographic bounding box" in the navPlace
|
|
1804
|
+
// extension), which the legacy format expresses as map_bbox. The
|
|
1805
|
+
// first canvas carrying one wins; Point navPlaces are unaffected.
|
|
1806
|
+
if (data.map_bbox === undefined) {
|
|
1807
|
+
const canvas = asRecord(items[index]);
|
|
1808
|
+
const bbox =
|
|
1809
|
+
(canvas ? readNavPlaceBbox(canvas.navPlace) : null) ??
|
|
1810
|
+
readNavPlaceBbox(record.navPlace);
|
|
1811
|
+
if (bbox !== null) data.map_bbox = bbox;
|
|
1812
|
+
}
|
|
304
1813
|
}
|
|
305
1814
|
|
|
306
1815
|
return data;
|
|
@@ -308,8 +1817,21 @@ export function manifestToStorymapData(manifest: unknown): StorymapData {
|
|
|
308
1817
|
|
|
309
1818
|
/** Copies the mapconfig service terms onto the storymap data root (legacy keys). */
|
|
310
1819
|
function applyMapConfig(data: StorymapData, config: Record<string, unknown>): void {
|
|
311
|
-
|
|
312
|
-
|
|
1820
|
+
// The basemap is either a keyword or a tile source: `storymap:basemap`
|
|
1821
|
+
// names one the viewer knows how to configure (osm, stadia, iiif, …) and
|
|
1822
|
+
// TileJSON describes an arbitrary tile service. `mapType` used to carry
|
|
1823
|
+
// both in one string, which is why a URL template and a vendor keyword
|
|
1824
|
+
// were the same field (§2.9).
|
|
1825
|
+
const basemap = asString(readTerm(config, "basemap"));
|
|
1826
|
+
if (basemap !== null && basemap !== "") {
|
|
1827
|
+
data.map_type = basemap;
|
|
1828
|
+
} else {
|
|
1829
|
+
const tilejson = readTilejson(config);
|
|
1830
|
+
if (tilejson !== null) {
|
|
1831
|
+
data.map_type = Array.isArray(tilejson.tiles) ? tilejson.tiles[0] : tilejson.tiles;
|
|
1832
|
+
data.tilejson = tilejson;
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
313
1835
|
|
|
314
1836
|
const mapAsImage = asBoolean(readTerm(config, "mapAsImage"));
|
|
315
1837
|
if (mapAsImage !== null) data.map_as_image = mapAsImage;
|
|
@@ -327,14 +1849,15 @@ function applyMapConfig(data: StorymapData, config: Record<string, unknown>): vo
|
|
|
327
1849
|
if (left !== null && top !== null) data.map_center_offset = { left, top };
|
|
328
1850
|
}
|
|
329
1851
|
|
|
330
|
-
const
|
|
331
|
-
if (
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
1852
|
+
const fontCssRaw = readTerm(config, "fontCss");
|
|
1853
|
+
if (fontCssRaw === false) {
|
|
1854
|
+
// a manifest can disable the injected theme exactly like storymap
|
|
1855
|
+
// JSON with font_css: false (the host bundles the theme itself)
|
|
1856
|
+
data.font_css = false;
|
|
1857
|
+
} else {
|
|
1858
|
+
const fontCss = asString(fontCssRaw);
|
|
1859
|
+
if (fontCss !== null) data.font_css = fontCss;
|
|
1860
|
+
}
|
|
338
1861
|
|
|
339
1862
|
const callToAction = asBoolean(readTerm(config, "callToAction"));
|
|
340
1863
|
if (callToAction !== null) data.call_to_action = callToAction;
|
|
@@ -351,9 +1874,6 @@ function applyMapConfig(data: StorymapData, config: Record<string, unknown>): vo
|
|
|
351
1874
|
const calculateZoom = asBoolean(readTerm(config, "calculateZoom"));
|
|
352
1875
|
if (calculateZoom !== null) data.calculate_zoom = calculateZoom;
|
|
353
1876
|
|
|
354
|
-
const lessBounce = asBoolean(readTerm(config, "lessBounce"));
|
|
355
|
-
if (lessBounce !== null) data.less_bounce = lessBounce;
|
|
356
|
-
|
|
357
1877
|
const lineFollowsPath = asBoolean(readTerm(config, "lineFollowsPath"));
|
|
358
1878
|
if (lineFollowsPath !== null) data.line_follows_path = lineFollowsPath;
|
|
359
1879
|
|
|
@@ -383,4 +1903,131 @@ function applyMapConfig(data: StorymapData, config: Record<string, unknown>): vo
|
|
|
383
1903
|
|
|
384
1904
|
const useCustomMarkers = asBoolean(readTerm(config, "useCustomMarkers"));
|
|
385
1905
|
if (useCustomMarkers !== null) data.use_custom_markers = useCustomMarkers;
|
|
1906
|
+
|
|
1907
|
+
// landscape map layout: "full" (default) or "left"
|
|
1908
|
+
const mapArea = asString(readTerm(config, "mapArea"));
|
|
1909
|
+
if (mapArea === "full" || mapArea === "left") data.map_area = mapArea;
|
|
1910
|
+
|
|
1911
|
+
const overviewExtent = readLonLatBox(readTerm(config, "overviewExtent"));
|
|
1912
|
+
if (overviewExtent !== null) data.overview_extent = overviewExtent;
|
|
1913
|
+
|
|
1914
|
+
const keyboard = asBoolean(readTerm(config, "keyboard"));
|
|
1915
|
+
if (keyboard !== null) data.keyboard = keyboard;
|
|
1916
|
+
|
|
1917
|
+
// stacked layers above the basemap, as extension-term entries
|
|
1918
|
+
const overlays = readOverlays(readTerm(config, "overlays"));
|
|
1919
|
+
if (overlays.length > 0) data.overlays = overlays;
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
/** Reads a `[west, south, east, north]` lon/lat box of four finite numbers. */
|
|
1923
|
+
function readLonLatBox(value: unknown): [number, number, number, number] | null {
|
|
1924
|
+
if (!isLonLatBox(value)) return null;
|
|
1925
|
+
const [west, south, east, north] = value;
|
|
1926
|
+
if (east <= west || north <= south) return null;
|
|
1927
|
+
return [west, south, east, north];
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
/** Reads the shared presentation keys of an `overlays[]` entry. */
|
|
1931
|
+
function readOverlayPresentation(
|
|
1932
|
+
record: Record<string, unknown>,
|
|
1933
|
+
entry: StorymapOverlayLayer,
|
|
1934
|
+
): void {
|
|
1935
|
+
const opacity = asNumber(record.opacity);
|
|
1936
|
+
if (opacity !== null) entry.opacity = opacity;
|
|
1937
|
+
const visible = asBoolean(record.visible);
|
|
1938
|
+
if (visible !== null) entry.visible = visible;
|
|
1939
|
+
const attribution = asString(record.attribution);
|
|
1940
|
+
if (attribution !== null) entry.attribution = attribution;
|
|
1941
|
+
const className = asString(record.className);
|
|
1942
|
+
if (className !== null) entry.className = className;
|
|
1943
|
+
const blendMode = asString(record.blendMode);
|
|
1944
|
+
if (blendMode !== null) entry.blendMode = blendMode;
|
|
1945
|
+
const extent = readLonLatBox(record.extent);
|
|
1946
|
+
if (extent !== null) entry.extent = extent;
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
/**
|
|
1950
|
+
* Reads `storymap:overlays`: stacked raster layers, each naming any
|
|
1951
|
+
* `map_type` the tile layer factory accepts plus declarative presentation.
|
|
1952
|
+
* Entries without a `map_type` are dropped here (the map skips them too).
|
|
1953
|
+
*/
|
|
1954
|
+
function readOverlays(value: unknown): StorymapOverlayLayer[] {
|
|
1955
|
+
if (!Array.isArray(value)) return [];
|
|
1956
|
+
const overlays: StorymapOverlayLayer[] = [];
|
|
1957
|
+
for (const item of value) {
|
|
1958
|
+
const record = asRecord(item);
|
|
1959
|
+
if (!record) continue;
|
|
1960
|
+
const mapType = asString(record.map_type);
|
|
1961
|
+
if (mapType === null || mapType === "") continue;
|
|
1962
|
+
const entry: StorymapOverlayLayer = { map_type: mapType };
|
|
1963
|
+
readOverlayPresentation(record, entry);
|
|
1964
|
+
overlays.push(entry);
|
|
1965
|
+
}
|
|
1966
|
+
return overlays;
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
/**
|
|
1970
|
+
* IIIF images placed on the geographic map, from the Georeference Extension's
|
|
1971
|
+
* `motivation: "georeferencing"` annotations (§2.10).
|
|
1972
|
+
*
|
|
1973
|
+
* Per the extension, a layer that is not part of the canvas it ships in — which
|
|
1974
|
+
* is every map layer — carries the image it places in `target` as an embedded
|
|
1975
|
+
* resource, and the ground control points in `body` as a FeatureCollection
|
|
1976
|
+
* whose features carry `resourceCoords`.
|
|
1977
|
+
*
|
|
1978
|
+
* The annotations are read from every canvas, because the layers they describe
|
|
1979
|
+
* are map-wide. Entries without a usable image or without ground control points
|
|
1980
|
+
* are dropped; whether the points can be placed affinely is the map's decision.
|
|
1981
|
+
*
|
|
1982
|
+
* `requiredStatement` is read as the layer's attribution, which is where P3 puts
|
|
1983
|
+
* attribution. The term also carried `opacity`, `visible`, `className`,
|
|
1984
|
+
* `blendMode` and `extent`; none of those has IIIF vocabulary for a
|
|
1985
|
+
* georeferencing annotation, so they no longer round-trip. The extent is not
|
|
1986
|
+
* lost in substance — the control points describe it.
|
|
1987
|
+
*/
|
|
1988
|
+
function readGeoreferencedLayers(manifest: Record<string, unknown>): StorymapOverlayLayer[] {
|
|
1989
|
+
const overlays: StorymapOverlayLayer[] = [];
|
|
1990
|
+
const canvases = Array.isArray(manifest.items) ? manifest.items : [];
|
|
1991
|
+
for (const canvas of canvases) {
|
|
1992
|
+
const canvasRecord = asRecord(canvas);
|
|
1993
|
+
if (!canvasRecord) continue;
|
|
1994
|
+
const pages = Array.isArray(canvasRecord.items) ? canvasRecord.items : [];
|
|
1995
|
+
for (const page of pages) {
|
|
1996
|
+
const pageRecord = asRecord(page);
|
|
1997
|
+
if (!pageRecord) continue;
|
|
1998
|
+
const annotations = Array.isArray(pageRecord.items) ? pageRecord.items : [];
|
|
1999
|
+
for (const annotation of annotations) {
|
|
2000
|
+
const record = asRecord(annotation);
|
|
2001
|
+
if (!record) continue;
|
|
2002
|
+
const motivations = Array.isArray(record.motivation)
|
|
2003
|
+
? record.motivation
|
|
2004
|
+
: [record.motivation];
|
|
2005
|
+
if (!motivations.some((m) => asString(m) === "georeferencing")) continue;
|
|
2006
|
+
|
|
2007
|
+
const target = asRecord(record.target);
|
|
2008
|
+
if (!target) continue;
|
|
2009
|
+
const url = asString(target.id);
|
|
2010
|
+
if (url === null) continue;
|
|
2011
|
+
const width = asNumber(target.width);
|
|
2012
|
+
const height = asNumber(target.height);
|
|
2013
|
+
if (width === null || height === null || width <= 0 || height <= 0) continue;
|
|
2014
|
+
const body = asRecord(record.body);
|
|
2015
|
+
if (!body || readGroundControlPoints(body) === null) continue;
|
|
2016
|
+
|
|
2017
|
+
const georeference: StorymapGeoreference = {
|
|
2018
|
+
url,
|
|
2019
|
+
width,
|
|
2020
|
+
height,
|
|
2021
|
+
body: body as unknown as StorymapGeoreference["body"],
|
|
2022
|
+
};
|
|
2023
|
+
const entry: StorymapOverlayLayer = { georeference };
|
|
2024
|
+
const attribution = formatAttribution(
|
|
2025
|
+
readRequiredStatement(record.requiredStatement),
|
|
2026
|
+
);
|
|
2027
|
+
if (attribution !== "") entry.attribution = attribution;
|
|
2028
|
+
overlays.push(entry);
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
return overlays;
|
|
386
2033
|
}
|