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