@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,2118 @@
|
|
|
1
|
+
import { mergeData, slideTransitionDuration, updateData, prefersReducedMotion } from "../core/Util";
|
|
2
|
+
import { loadCSS } from "../core/Load";
|
|
3
|
+
import { validateStorymapAndReport } from "./validate";
|
|
4
|
+
import {
|
|
5
|
+
CONTENT_STATE_PARAM,
|
|
6
|
+
formatContentState,
|
|
7
|
+
parseContentState,
|
|
8
|
+
type ContentState,
|
|
9
|
+
} from "./content-state";
|
|
10
|
+
import {
|
|
11
|
+
isPresentation3Manifest,
|
|
12
|
+
isPresentation3Collection,
|
|
13
|
+
loadSeeAlso,
|
|
14
|
+
manifestToStorymapData,
|
|
15
|
+
} from "./iiif";
|
|
16
|
+
import {
|
|
17
|
+
ConsentManager,
|
|
18
|
+
consentManagerOf,
|
|
19
|
+
fontService,
|
|
20
|
+
mediaService,
|
|
21
|
+
narrationService,
|
|
22
|
+
tileService,
|
|
23
|
+
type ConsentService,
|
|
24
|
+
} from "./Consent";
|
|
25
|
+
import Dom from "../dom/Dom";
|
|
26
|
+
import { easeInOutQuint, easeOutStrong } from "../animation/easings";
|
|
27
|
+
import {
|
|
28
|
+
setLanguage,
|
|
29
|
+
isRtl,
|
|
30
|
+
currentLanguageCode,
|
|
31
|
+
claimLanguage,
|
|
32
|
+
releaseLanguage,
|
|
33
|
+
isLanguageConflict,
|
|
34
|
+
} from "../language/Language";
|
|
35
|
+
import {
|
|
36
|
+
markInteraction,
|
|
37
|
+
ownsPageEvent,
|
|
38
|
+
registerParticipant,
|
|
39
|
+
unregisterParticipant,
|
|
40
|
+
} from "../core/viewers";
|
|
41
|
+
import MediaType from "../media/MediaType";
|
|
42
|
+
import { Evented, type EventedInstance } from "../core/mixins";
|
|
43
|
+
import OpenLayersMap from "../map/openlayers/Map.OpenLayers";
|
|
44
|
+
import MenuBar from "../ui/MenuBar";
|
|
45
|
+
import StorySlider from "../slider/StorySlider";
|
|
46
|
+
import { Browser } from "../core/Browser";
|
|
47
|
+
import Animate from "../animation/tween";
|
|
48
|
+
import type { Map as OlMap } from "ol";
|
|
49
|
+
import type OlLayer from "ol/layer/Layer";
|
|
50
|
+
import type OpenLayersMapMarker from "../map/openlayers/MapMarker.OpenLayers";
|
|
51
|
+
import type {
|
|
52
|
+
AnimationHandle,
|
|
53
|
+
StorymapData,
|
|
54
|
+
StorymapDataWrapper,
|
|
55
|
+
StorymapOptions,
|
|
56
|
+
StorymapSlide,
|
|
57
|
+
} from "../types";
|
|
58
|
+
|
|
59
|
+
/** Map height in pixels while the menubar has collapsed the map (portrait only). */
|
|
60
|
+
const COLLAPSED_MAP_HEIGHT = 1;
|
|
61
|
+
|
|
62
|
+
type StoryMapListener = (e: unknown) => void;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve a `font_css` value to an absolute stylesheet URL.
|
|
66
|
+
*
|
|
67
|
+
* - `stock:<name>` resolves against the library location.
|
|
68
|
+
* - Absolute URLs pass through untouched.
|
|
69
|
+
* - Anything else resolves against the page URL, so hosts can reference
|
|
70
|
+
* themes vendored into their own public dir (e.g. "fonts/font.css").
|
|
71
|
+
*/
|
|
72
|
+
export function resolveFontCssUrl(font: string): string {
|
|
73
|
+
if (font.startsWith("stock:")) {
|
|
74
|
+
const font_name = font.split(":")[1] || "default";
|
|
75
|
+
// A crafted name ("stock:../../secret") would otherwise resolve to an
|
|
76
|
+
// arbitrary same-origin file next to the bundle, because the name is
|
|
77
|
+
// concatenated into a URL path. Only accept a bare theme name.
|
|
78
|
+
if (!/^[a-z0-9-]+$/i.test(font_name)) {
|
|
79
|
+
console.warn(
|
|
80
|
+
"StoryMapJS: ignoring font_css with an invalid stock theme name",
|
|
81
|
+
font_name,
|
|
82
|
+
);
|
|
83
|
+
return new URL(/* @vite-ignore */ "../css/fonts/font.default.css", import.meta.url)
|
|
84
|
+
.href;
|
|
85
|
+
}
|
|
86
|
+
// resolved against the library location: one directory up from
|
|
87
|
+
// src/main.ts (dev) and js/storymap.js (build) in both cases
|
|
88
|
+
return new URL("../css/fonts/font." + font_name + ".css", import.meta.url).href;
|
|
89
|
+
}
|
|
90
|
+
// Absolute URL, protocol-relative, or a data: URL. Note this must be a
|
|
91
|
+
// real URL test, not a `/^(http|https|\/\/)/` prefix check: the old prefix
|
|
92
|
+
// test false-positived on a relative path that merely *starts* with those
|
|
93
|
+
// letters (e.g. "httpfonts.css"), which raised a spurious consent prompt
|
|
94
|
+
// for a same-origin file.
|
|
95
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(font) || font.startsWith("//")) {
|
|
96
|
+
return font;
|
|
97
|
+
}
|
|
98
|
+
return new URL(font, document.baseURI).href;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** True when `url` points off-origin (or at a data: URL) and needs a consent ask. */
|
|
102
|
+
export function isExternalUrl(url: string): boolean {
|
|
103
|
+
// data:/blob: are not off-origin but are not a theme stylesheet either
|
|
104
|
+
if (/^(data|blob):/i.test(url)) {
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
return new URL(url, document.baseURI).origin !== window.location.origin;
|
|
109
|
+
} catch {
|
|
110
|
+
// unparseable: treat as external, so we err towards asking
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Interactive StoryMap viewer.
|
|
117
|
+
*
|
|
118
|
+
* Renders a storymap JSON document (or a IIIF Presentation 3 manifest) into an
|
|
119
|
+
* element: an OpenLayers map on one side and a slide slider on the other.
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* ```ts
|
|
123
|
+
* import { StoryMap } from "@projektemacher/storymapjs";
|
|
124
|
+
* // from an inline data object
|
|
125
|
+
* const map = new StoryMap("embed", { storymap: { ... } }, { start_at_slide: 2 });
|
|
126
|
+
* // or straight from a source file (storymap JSON or IIIF manifest)
|
|
127
|
+
* const map2 = new StoryMap(document.getElementById("embed"), "./my-storymap.json");
|
|
128
|
+
* ```
|
|
129
|
+
*/
|
|
130
|
+
class StoryMapBase {
|
|
131
|
+
declare "_loaded": { storyslider: boolean; map: boolean };
|
|
132
|
+
declare "on": EventedInstance["on"];
|
|
133
|
+
declare "version": string;
|
|
134
|
+
declare "ready": boolean;
|
|
135
|
+
declare "_el": {
|
|
136
|
+
container: HTMLElement;
|
|
137
|
+
menubar: HTMLElement;
|
|
138
|
+
map: HTMLElement | null;
|
|
139
|
+
storyslider: HTMLElement;
|
|
140
|
+
};
|
|
141
|
+
declare "_storyslider": StorySlider;
|
|
142
|
+
declare "_map": OpenLayersMap | null;
|
|
143
|
+
/** `map_type: "none"`: the story is text-and-media, with no map at all. */
|
|
144
|
+
declare "_map_disabled": boolean;
|
|
145
|
+
/**
|
|
146
|
+
* The raw OpenLayers map. `null` until the map is built (the
|
|
147
|
+
* constructor assigns it during data load) — check it, or use the
|
|
148
|
+
* accessors, which return `null` until then.
|
|
149
|
+
*/
|
|
150
|
+
declare "map": OlMap | null;
|
|
151
|
+
declare "_menubar": MenuBar;
|
|
152
|
+
declare "data": StorymapData;
|
|
153
|
+
declare "options": StorymapOptions;
|
|
154
|
+
declare "current_slide": number;
|
|
155
|
+
/**
|
|
156
|
+
* The manifest this viewer was built from, kept so `loadAnnotations()` can
|
|
157
|
+
* resolve its `seeAlso` targets. `null` for a storymap-JSON viewer, which
|
|
158
|
+
* has no `seeAlso`.
|
|
159
|
+
*/
|
|
160
|
+
declare "_raw_manifest": unknown | null;
|
|
161
|
+
/**
|
|
162
|
+
* The deep link the page was opened with, captured before anything can
|
|
163
|
+
* rewrite the URL.
|
|
164
|
+
*
|
|
165
|
+
* This has to be read *early*. The slider's opening `goTo()` can reach
|
|
166
|
+
* `_navigate()` before `_onLoaded()` runs, and `_navigate()` calls
|
|
167
|
+
* `_syncHash()` — which would overwrite the incoming link with the current
|
|
168
|
+
* slide before `_onLoaded()` ever looked at it. Whether that happens
|
|
169
|
+
* depends on the order the slider and map finish loading, which is exactly
|
|
170
|
+
* the kind of race that makes a deep link work most of the time.
|
|
171
|
+
*/
|
|
172
|
+
declare "_initial_deep_link": { state: ContentState | null; hash: string } | null;
|
|
173
|
+
declare "animator_map": AnimationHandle | null;
|
|
174
|
+
declare "animator_storyslider": AnimationHandle | null;
|
|
175
|
+
declare "_autoplay_timer": ReturnType<typeof setTimeout> | null;
|
|
176
|
+
declare "_transition_timer": ReturnType<typeof setTimeout> | null;
|
|
177
|
+
declare "_autoplay_stopped": boolean;
|
|
178
|
+
/** The narration player: one <audio> for the whole story, reused. */
|
|
179
|
+
declare "_narration_el": HTMLAudioElement | null;
|
|
180
|
+
/** Guards a stale play() from a slide change that already moved on. */
|
|
181
|
+
declare "_narration_token": number;
|
|
182
|
+
/** Whether narration consent was granted (asked once per story). */
|
|
183
|
+
declare "_narration_allowed": boolean;
|
|
184
|
+
/** True once the visitor has clicked/keyed: unmuted audio needs this. */
|
|
185
|
+
declare "_user_gestured": boolean;
|
|
186
|
+
/** The advance armed for the current slide (media-ended or the timer). */
|
|
187
|
+
declare "_autoplay_advance": (() => void) | null;
|
|
188
|
+
/** A narration that was skipped for want of a gesture, to retry later. */
|
|
189
|
+
declare "_replayNarrationAfterGesture": boolean;
|
|
190
|
+
declare "_hash_initialized": boolean;
|
|
191
|
+
declare "_collapsed": boolean;
|
|
192
|
+
/** the data source was a IIIF Presentation manifest (legacy zoomify options are ignored) */
|
|
193
|
+
declare "_data_from_manifest": boolean;
|
|
194
|
+
declare "_resize_observer": ResizeObserver | null;
|
|
195
|
+
/** Stored so `dispose()` can remove them again (see AGENTS: no leaks) */
|
|
196
|
+
declare "_on_resize": (() => void) | null;
|
|
197
|
+
declare "_on_keydown_global": ((e: KeyboardEvent) => void) | null;
|
|
198
|
+
declare "_on_fullscreen": (() => void) | null;
|
|
199
|
+
declare "_on_hashchange": (() => void) | null;
|
|
200
|
+
/** Set by `dispose()` so a second call returns early. */
|
|
201
|
+
declare "_disposed": boolean;
|
|
202
|
+
/** identity of this viewer's claim on the page-wide locale */
|
|
203
|
+
declare "_language_holder": symbol;
|
|
204
|
+
/** the locale the caller actually asked for, if any; see _loadLanguage */
|
|
205
|
+
declare "_language_requested": string | undefined;
|
|
206
|
+
/** bumped on interaction, so page-wide keys can pick one viewer */
|
|
207
|
+
declare "_interaction": number;
|
|
208
|
+
declare "_onInteraction": (() => void) | null;
|
|
209
|
+
declare "_resize_timer": ReturnType<typeof setTimeout> | null;
|
|
210
|
+
declare "fire": EventedInstance["fire"];
|
|
211
|
+
declare "hasEventListeners": EventedInstance["hasEventListeners"];
|
|
212
|
+
|
|
213
|
+
// TODO: mixin
|
|
214
|
+
// includes: VCO.Events,
|
|
215
|
+
|
|
216
|
+
/* Private Methods
|
|
217
|
+
================================================== */
|
|
218
|
+
/**
|
|
219
|
+
* Create a StoryMap inside the given element.
|
|
220
|
+
*
|
|
221
|
+
* @param elem - The container element, or its DOM id.
|
|
222
|
+
* @param data - Either an inline storymap document (`{ storymap: ... }`),
|
|
223
|
+
* an inline IIIF Presentation 3 manifest, or the URL of a source file
|
|
224
|
+
* (storymap JSON or IIIF manifest) that is fetched automatically.
|
|
225
|
+
* @param options - Optional {@link StorymapOptions} overrides (map type,
|
|
226
|
+
* lines, fonts, animation, raw OpenLayers options via `map_options`...).
|
|
227
|
+
* @param listeners - Optional event listeners keyed by event name
|
|
228
|
+
* (`change`, `title`, `loaded`, ...) attached on creation.
|
|
229
|
+
*/
|
|
230
|
+
constructor(
|
|
231
|
+
elem: string | HTMLElement,
|
|
232
|
+
data: string | StorymapDataWrapper | Record<string, unknown>,
|
|
233
|
+
options?: Partial<StorymapOptions>,
|
|
234
|
+
listeners?: Record<string, StoryMapListener | StoryMapListener[]>,
|
|
235
|
+
) {
|
|
236
|
+
for (const key in listeners) {
|
|
237
|
+
const callbacks = listeners[key];
|
|
238
|
+
if (typeof callbacks == "function") {
|
|
239
|
+
this.on(key, callbacks);
|
|
240
|
+
} else {
|
|
241
|
+
for (const idx in callbacks) {
|
|
242
|
+
if (typeof callbacks[idx] == "function") {
|
|
243
|
+
this.on(key, callbacks[idx]);
|
|
244
|
+
} else {
|
|
245
|
+
console.log(
|
|
246
|
+
"WARNING: Ignoring invalid callback '" +
|
|
247
|
+
callbacks[idx] +
|
|
248
|
+
"' defined for " +
|
|
249
|
+
"listener '" +
|
|
250
|
+
key +
|
|
251
|
+
"' in StoryMap constructor",
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Version
|
|
259
|
+
// build-time constant from package.json (see vite.config.ts); the
|
|
260
|
+
// typeof guard keeps a dev-server run without the define working
|
|
261
|
+
this.version =
|
|
262
|
+
typeof __STORYMAP_VERSION__ === "string" ? __STORYMAP_VERSION__ : "0.0.0-dev";
|
|
263
|
+
|
|
264
|
+
// Ready
|
|
265
|
+
this.ready = false;
|
|
266
|
+
|
|
267
|
+
// DOM ELEMENTS. Only map may legitimately stay null: a mapless story
|
|
268
|
+
// never builds the pane, so unlike its siblings it gets a real null
|
|
269
|
+
// rather than a stub that would read truthy before _initLayout runs.
|
|
270
|
+
this._el = {
|
|
271
|
+
container: {} as HTMLElement,
|
|
272
|
+
storyslider: {} as HTMLElement,
|
|
273
|
+
map: null,
|
|
274
|
+
menubar: {} as HTMLElement,
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// Determine Container Element
|
|
278
|
+
if (typeof elem === "object") {
|
|
279
|
+
this._el.container = elem;
|
|
280
|
+
} else {
|
|
281
|
+
const found = Dom.get(elem);
|
|
282
|
+
if (!found) {
|
|
283
|
+
throw new Error("StoryMapJS: no element with id " + elem);
|
|
284
|
+
}
|
|
285
|
+
this._el.container = found;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Slider
|
|
289
|
+
this._storyslider = {} as StorySlider;
|
|
290
|
+
|
|
291
|
+
// Map. Genuinely null when the story has no map (map_type "none") or
|
|
292
|
+
// before the layout builds one: the accessor guards below read
|
|
293
|
+
// `!this._disposed && this._map`, which a `{}` stub would sail past
|
|
294
|
+
// and then call a method on.
|
|
295
|
+
this._map = null;
|
|
296
|
+
// direct access to the OpenLayers map; null until it is built
|
|
297
|
+
this.map = null;
|
|
298
|
+
|
|
299
|
+
// Menu Bar
|
|
300
|
+
this._menubar = {} as MenuBar;
|
|
301
|
+
|
|
302
|
+
// Loaded State
|
|
303
|
+
this._loaded = { storyslider: false, map: false };
|
|
304
|
+
|
|
305
|
+
// Data Object
|
|
306
|
+
// Test Data compiled from http://www.pbs.org/marktwain/learnmore/chronology.html
|
|
307
|
+
this.data = {} as StorymapData;
|
|
308
|
+
|
|
309
|
+
this.options = {
|
|
310
|
+
script_path: StoryMap.SCRIPT_PATH,
|
|
311
|
+
// raw OpenLayers Map/View options passed through (see map_options
|
|
312
|
+
// in the README); empty by default, and the OL code reads it
|
|
313
|
+
// defensively because the option is also settable from JSON
|
|
314
|
+
map_options: {},
|
|
315
|
+
height: this._el.container.offsetHeight,
|
|
316
|
+
width: this._el.container.offsetWidth,
|
|
317
|
+
layout: "landscape", // portrait or landscape
|
|
318
|
+
base_class: "",
|
|
319
|
+
default_bg_color: { r: 255, g: 255, b: 255 },
|
|
320
|
+
map_size_sticky: 2.5, // Set as division 1/3 etc
|
|
321
|
+
map_center_offset: null, // takes object {top:0,left:0}
|
|
322
|
+
start_at_slide: 0,
|
|
323
|
+
call_to_action: false,
|
|
324
|
+
call_to_action_text: "",
|
|
325
|
+
menubar_height: 0,
|
|
326
|
+
fullscreen: true,
|
|
327
|
+
show_overview: true,
|
|
328
|
+
show_back_to_start: true,
|
|
329
|
+
skinny_size: 650,
|
|
330
|
+
// animation
|
|
331
|
+
duration: 1000,
|
|
332
|
+
ease: easeInOutQuint,
|
|
333
|
+
trackResize: true,
|
|
334
|
+
keyboard: false,
|
|
335
|
+
nocache: false,
|
|
336
|
+
autoplay: 0,
|
|
337
|
+
autoplay_media: false,
|
|
338
|
+
show_progress: false,
|
|
339
|
+
marker_labels: false,
|
|
340
|
+
text_align: "left",
|
|
341
|
+
map_overview_center: null,
|
|
342
|
+
map_type: "", // "osm:standard",
|
|
343
|
+
tile_source_factory: null,
|
|
344
|
+
attribution: "",
|
|
345
|
+
map_mini: true,
|
|
346
|
+
map_as_image: false,
|
|
347
|
+
// no bundled credentials: pass map_access_token in the options
|
|
348
|
+
// if you use Mapbox/Stadia tiles, api_key_flickr for flickr API
|
|
349
|
+
// URLs
|
|
350
|
+
map_access_token: "",
|
|
351
|
+
map_background_color: "#d9d9d9",
|
|
352
|
+
map_area: "full", // "left" limits the map to the visible half (landscape)
|
|
353
|
+
map_bbox: null,
|
|
354
|
+
// options the map also declares: updateData only copies keys
|
|
355
|
+
// that already exist here, so storymap JSON could not reach them
|
|
356
|
+
overview_extent: null,
|
|
357
|
+
overlays: [],
|
|
358
|
+
// TileJSON 2.1 metadata for a tile-source basemap; a keyword
|
|
359
|
+
// basemap has none (interop §2.9)
|
|
360
|
+
tilejson: undefined,
|
|
361
|
+
consent_required: false,
|
|
362
|
+
zoomify: undefined,
|
|
363
|
+
text_color: "",
|
|
364
|
+
text_background_color: "",
|
|
365
|
+
show_distance: false,
|
|
366
|
+
use_custom_markers: false,
|
|
367
|
+
iiif: {
|
|
368
|
+
url: "",
|
|
369
|
+
attribution: "",
|
|
370
|
+
},
|
|
371
|
+
map_height: 300,
|
|
372
|
+
storyslider_height: 600,
|
|
373
|
+
slide_padding_lr: 45, // padding on slide of slide
|
|
374
|
+
slide_default_fade: "0%", // landscape fade
|
|
375
|
+
menubar_default_y: 0,
|
|
376
|
+
calculate_zoom: true, // Allow map to determine best zoom level between markers (recommended)
|
|
377
|
+
line_follows_path: true, // Map history path follows default line, if false it will connect previous and current only
|
|
378
|
+
line_color: "#c34528", //"#DA0000",
|
|
379
|
+
line_color_inactive: "#CCC",
|
|
380
|
+
line_join: "miter",
|
|
381
|
+
line_weight: 3,
|
|
382
|
+
line_opacity: 0.8,
|
|
383
|
+
line_dash: "5,5",
|
|
384
|
+
show_lines: true,
|
|
385
|
+
show_history_line: true,
|
|
386
|
+
api_key_flickr: "", // no bundled key: pass api_key_flickr in the options
|
|
387
|
+
font_css: "stock:default",
|
|
388
|
+
language: "en",
|
|
389
|
+
} as StorymapOptions;
|
|
390
|
+
|
|
391
|
+
// Animation Objects
|
|
392
|
+
this.animator_map = null;
|
|
393
|
+
this.animator_storyslider = null;
|
|
394
|
+
this._resize_observer = null;
|
|
395
|
+
this._on_resize = null;
|
|
396
|
+
this._on_keydown_global = null;
|
|
397
|
+
this._on_fullscreen = null;
|
|
398
|
+
this._on_hashchange = null;
|
|
399
|
+
this._raw_manifest = null;
|
|
400
|
+
this._initial_deep_link = null;
|
|
401
|
+
this._disposed = false;
|
|
402
|
+
this._language_holder = Symbol("storymap");
|
|
403
|
+
this._language_requested = undefined;
|
|
404
|
+
this._interaction = 0;
|
|
405
|
+
this._onInteraction = null;
|
|
406
|
+
this._resize_timer = null;
|
|
407
|
+
this._autoplay_timer = null;
|
|
408
|
+
this._transition_timer = null;
|
|
409
|
+
this._autoplay_stopped = false;
|
|
410
|
+
this._narration_el = null;
|
|
411
|
+
this._narration_token = 0;
|
|
412
|
+
this._narration_allowed = true;
|
|
413
|
+
this._user_gestured = false;
|
|
414
|
+
this._replayNarrationAfterGesture = false;
|
|
415
|
+
this._autoplay_advance = null;
|
|
416
|
+
this._hash_initialized = false;
|
|
417
|
+
this._collapsed = false;
|
|
418
|
+
|
|
419
|
+
// The caller's own `language`, read before the merge: afterwards
|
|
420
|
+
// options.language is always set (the default is "en"), so "asked for
|
|
421
|
+
// German" and "never mentioned a language" are indistinguishable.
|
|
422
|
+
this._language_requested =
|
|
423
|
+
typeof options?.language === "string" && options.language !== ""
|
|
424
|
+
? options.language
|
|
425
|
+
: undefined;
|
|
426
|
+
|
|
427
|
+
// Merge Options -- legacy, in case people still need to pass in
|
|
428
|
+
mergeData(this.options, options);
|
|
429
|
+
|
|
430
|
+
// Current Slide — the constructor options have been merged, but the
|
|
431
|
+
// storymap *data* has not (issue #305). _initData -> _initOptions
|
|
432
|
+
// merges the data in afterwards, and the data may carry a
|
|
433
|
+
// start_at_slide of its own, so re-read it there rather than
|
|
434
|
+
// freezing the constructor value here.
|
|
435
|
+
this.current_slide = this.options.start_at_slide;
|
|
436
|
+
|
|
437
|
+
this._initData(data);
|
|
438
|
+
|
|
439
|
+
return this;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/* Initialize the data
|
|
443
|
+
================================================== */
|
|
444
|
+
_initData(data: string | StorymapDataWrapper | Record<string, unknown>) {
|
|
445
|
+
// legacy zoomify options are only honored for storymap JSON sources —
|
|
446
|
+
// a IIIF Presentation manifest cannot carry them
|
|
447
|
+
this._data_from_manifest = false;
|
|
448
|
+
if (typeof data === "string") {
|
|
449
|
+
// issue #417: optional cache-busting re-fetch of the source file
|
|
450
|
+
const url =
|
|
451
|
+
this.options.nocache === true
|
|
452
|
+
? data + (data.includes("?") ? "&" : "?") + "_=" + Date.now()
|
|
453
|
+
: data;
|
|
454
|
+
void this._loadDataFromUrl(url, data);
|
|
455
|
+
} else if (typeof data === "object") {
|
|
456
|
+
// a Collection is a legitimate IIIF input too: its member
|
|
457
|
+
// Manifests' canvases concatenate into one linear story
|
|
458
|
+
if (isPresentation3Manifest(data) || isPresentation3Collection(data)) {
|
|
459
|
+
this._data_from_manifest = true;
|
|
460
|
+
this._raw_manifest = data;
|
|
461
|
+
this.data = manifestToStorymapData(data);
|
|
462
|
+
} else {
|
|
463
|
+
const wrapper = data as StorymapDataWrapper;
|
|
464
|
+
validateStorymapAndReport(wrapper);
|
|
465
|
+
if (wrapper.storymap) {
|
|
466
|
+
this.data = wrapper.storymap;
|
|
467
|
+
} else {
|
|
468
|
+
console.error("StoryMapJS: data must have a storymap property");
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
this._initOptions();
|
|
472
|
+
} else {
|
|
473
|
+
console.error("StoryMapJS: data has unknown type");
|
|
474
|
+
this._initOptions();
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/* Load storymap data from a URL
|
|
479
|
+
================================================== */
|
|
480
|
+
async _loadDataFromUrl(url: string, source: string) {
|
|
481
|
+
try {
|
|
482
|
+
const response = await fetch(url);
|
|
483
|
+
if (!response.ok) {
|
|
484
|
+
throw new Error("HTTP " + response.status + " " + response.statusText);
|
|
485
|
+
}
|
|
486
|
+
const result: unknown = await response.json();
|
|
487
|
+
// A teardown while the fetch was in flight must win: without
|
|
488
|
+
// this, a late-resolving response rebuilds the whole viewer into
|
|
489
|
+
// the container dispose() just emptied — menubar, slider, map,
|
|
490
|
+
// listeners, locale claim, autoplay — and sets ready = true.
|
|
491
|
+
if (this._disposed) {
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (isPresentation3Manifest(result) || isPresentation3Collection(result)) {
|
|
495
|
+
this._data_from_manifest = true;
|
|
496
|
+
this._raw_manifest = result;
|
|
497
|
+
this.data = manifestToStorymapData(result);
|
|
498
|
+
} else {
|
|
499
|
+
validateStorymapAndReport(result, source);
|
|
500
|
+
const wrapper = result as StorymapDataWrapper;
|
|
501
|
+
if (!wrapper.storymap) {
|
|
502
|
+
throw new Error("StoryMapJS: data must have a storymap property");
|
|
503
|
+
}
|
|
504
|
+
this.data = wrapper.storymap;
|
|
505
|
+
}
|
|
506
|
+
this._initOptions();
|
|
507
|
+
} catch (err: unknown) {
|
|
508
|
+
if (isLanguageConflict(err)) {
|
|
509
|
+
// a second viewer on this page asked for another locale. This
|
|
510
|
+
// is a configuration error, not a failed fetch, and reporting
|
|
511
|
+
// it as one would send the host looking for a 404.
|
|
512
|
+
console.error((err as Error).message);
|
|
513
|
+
this.fire("error", { message: (err as Error).message, source, conflict: true });
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
console.error("StoryMapJS: could not load storymap data from " + source, err);
|
|
517
|
+
this.fire("error", { message: String(err), source });
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/* Initialize the options
|
|
522
|
+
================================================== */
|
|
523
|
+
_initOptions() {
|
|
524
|
+
// Grab options from storymap data
|
|
525
|
+
updateData(this.options, this.data);
|
|
526
|
+
|
|
527
|
+
// Capture the deep link now, before the slider and map exist and can
|
|
528
|
+
// navigate — and so rewrite the URL we would read it from later.
|
|
529
|
+
this._initial_deep_link = {
|
|
530
|
+
state: parseContentState(
|
|
531
|
+
new URLSearchParams(window.location.search).get(CONTENT_STATE_PARAM),
|
|
532
|
+
),
|
|
533
|
+
hash: window.location.hash,
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
// issue #305: the data may override start_at_slide. The constructor
|
|
537
|
+
// read it before this merge, and the slider's opening goTo() fires
|
|
538
|
+
// before _initEvents() attaches our change listener — so nothing
|
|
539
|
+
// else resynchronises the field, leaving the hash, the progress bar
|
|
540
|
+
// and current_slide disagreeing with the visible slide.
|
|
541
|
+
this.current_slide = this.options.start_at_slide;
|
|
542
|
+
|
|
543
|
+
// legacy zoomify options only work with storymap JSON sources — a
|
|
544
|
+
// IIIF Presentation manifest cannot carry them
|
|
545
|
+
if (this._data_from_manifest) {
|
|
546
|
+
delete this.options.zoomify;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (this.options.layout === "landscape") {
|
|
550
|
+
// map_area "left": the map is limited to the visible half with an
|
|
551
|
+
// opaque slide panel — no offset needed (the view center is the
|
|
552
|
+
// visible center already); "full" (default) offsets the view so
|
|
553
|
+
// markers clear the panel that fades in over the map
|
|
554
|
+
if (this.options.map_area !== "left") {
|
|
555
|
+
this.options.map_center_offset = { left: -200, top: 0 };
|
|
556
|
+
} else {
|
|
557
|
+
this.options.map_center_offset = { left: 0, top: 0 };
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (this.options.map_type === "iiif" && this.options.map_as_image) {
|
|
561
|
+
this.options.map_size_sticky = 2;
|
|
562
|
+
}
|
|
563
|
+
// legacy zoomify stories are image maps too — the map window is half
|
|
564
|
+
// the height (the original renderer's layout)
|
|
565
|
+
if (this.options.map_type === "zoomify") {
|
|
566
|
+
this.options.map_size_sticky = 2;
|
|
567
|
+
}
|
|
568
|
+
if (this.options.map_as_image) {
|
|
569
|
+
this.options.calculate_zoom = false;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// legacy zoomify support: rendered by the map via the image pyramid
|
|
573
|
+
// (options.zoomify) — deprecated, a JS warning points at iiif
|
|
574
|
+
if (this.options.map_type === "zoomify") {
|
|
575
|
+
console.warn(
|
|
576
|
+
"StoryMapJS: map_type 'zoomify' is a legacy image-pyramid basemap; consider map_type 'iiif' with options.iiif.url instead.",
|
|
577
|
+
);
|
|
578
|
+
const zoomify_opts = this.options.zoomify;
|
|
579
|
+
if (typeof zoomify_opts !== "object" || !zoomify_opts.path) {
|
|
580
|
+
console.error(
|
|
581
|
+
"StoryMapJS: map_type 'zoomify' needs a zoomify image pyramid (path, width, height) in the storymap data.",
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// `map_type` is optional in the schema, and a hand-written document
|
|
587
|
+
// can carry an explicit null. Normalise rather than guard: leaving a
|
|
588
|
+
// null in place only moves the crash to the map's own
|
|
589
|
+
// `map_type.split(":")`. "" is the documented default and means OSM.
|
|
590
|
+
if (typeof this.options.map_type !== "string") {
|
|
591
|
+
this.options.map_type = "";
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// "none" is the only way to say there is no map. An absent key or ""
|
|
595
|
+
// keeps meaning OSM, so no existing document changes meaning.
|
|
596
|
+
this._map_disabled = this.options.map_type === "none";
|
|
597
|
+
|
|
598
|
+
// handle Stamen change
|
|
599
|
+
if (this.options.map_type.startsWith("stamen")) {
|
|
600
|
+
const old_type = this.options.map_type;
|
|
601
|
+
if (old_type === "stamen:watercolor") {
|
|
602
|
+
this.options.map_type = "ch-watercolor";
|
|
603
|
+
} else {
|
|
604
|
+
this.options.map_type = "osm:standard";
|
|
605
|
+
}
|
|
606
|
+
console.log(`Deprecated map_type ${old_type}; using ${this.options.map_type}`);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
this._loadLanguage();
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/* Load Language
|
|
613
|
+
================================================== */
|
|
614
|
+
|
|
615
|
+
_loadLanguage() {
|
|
616
|
+
// the locale chunk is fetched on demand, so the language is not
|
|
617
|
+
// available synchronously; layout proceeds with the English defaults
|
|
618
|
+
// and the labels are repainted once the locale arrives.
|
|
619
|
+
//
|
|
620
|
+
// The claim is what makes a second viewer on this page safe: the UI
|
|
621
|
+
// strings are one module-level binding, so a conflicting locale would
|
|
622
|
+
// otherwise repaint this viewer in the other viewer's language. It
|
|
623
|
+
// throws rather than warn, before any of this viewer's DOM exists.
|
|
624
|
+
//
|
|
625
|
+
// The document may carry its own `language` (updateData merges the
|
|
626
|
+
// document's keys into the options), so that counts as a request too.
|
|
627
|
+
const requested = this._requestedLanguage();
|
|
628
|
+
if (requested === undefined) {
|
|
629
|
+
// Nobody asked for a locale, so adopt whatever the page is already
|
|
630
|
+
// using. Claiming the "en" default instead would conflict with a
|
|
631
|
+
// sibling that did ask — a host that called setLanguage("de") and
|
|
632
|
+
// then built a viewer with no `language` option used to get
|
|
633
|
+
// English, silently repainting its German sibling's chrome.
|
|
634
|
+
claimLanguage(currentLanguageCode(), this._language_holder);
|
|
635
|
+
} else {
|
|
636
|
+
claimLanguage(requested, this._language_holder);
|
|
637
|
+
setLanguage(requested);
|
|
638
|
+
}
|
|
639
|
+
this._onDataLoaded();
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* The locale this viewer was actually asked for, or undefined when the
|
|
644
|
+
* caller and the document both stayed silent.
|
|
645
|
+
*/
|
|
646
|
+
_requestedLanguage(): string | undefined {
|
|
647
|
+
// the document wins, because updateData merges it over the options, so
|
|
648
|
+
// a document-level `language` is what options.language ends up holding
|
|
649
|
+
const fromDocument = (this.data as { language?: unknown } | null | undefined)?.language;
|
|
650
|
+
if (typeof fromDocument === "string" && fromDocument !== "") {
|
|
651
|
+
return fromDocument;
|
|
652
|
+
}
|
|
653
|
+
return this._language_requested;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/* Switch the UI language at runtime, without a page reload.
|
|
657
|
+
================================================== */
|
|
658
|
+
|
|
659
|
+
refreshLanguage(code: string): void {
|
|
660
|
+
if (this._disposed) return;
|
|
661
|
+
// throws if a sibling viewer is rendering in another language
|
|
662
|
+
claimLanguage(code, this._language_holder);
|
|
663
|
+
this.options.language = code;
|
|
664
|
+
setLanguage(code);
|
|
665
|
+
this._applyLanguageLayout();
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Repaint everything a runtime language switch affects: the menubar
|
|
670
|
+
* labels and the `vco-rtl` class.
|
|
671
|
+
*
|
|
672
|
+
* This deliberately does *not* call `updateDisplay()`. A full re-layout
|
|
673
|
+
* re-runs the slider's `goTo(current_slide)`, which goes through the
|
|
674
|
+
* navigation path that re-arms autoplay — so calling it here silently
|
|
675
|
+
* cancelled a pending autoplay tick and the story stopped advancing.
|
|
676
|
+
*/
|
|
677
|
+
_applyLanguageLayout(): void {
|
|
678
|
+
this._menubar?.refreshLabels?.();
|
|
679
|
+
this._el.container.classList.toggle("vco-rtl", isRtl());
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/* Load the font theme stylesheet
|
|
683
|
+
================================================== */
|
|
684
|
+
async _loadFontCss() {
|
|
685
|
+
// only genuinely external URLs ask for consent: stock: themes and
|
|
686
|
+
// relative paths resolve to same-origin / library assets
|
|
687
|
+
const original = this.options.font_css || "stock:default";
|
|
688
|
+
const font = resolveFontCssUrl(original);
|
|
689
|
+
const manager = consentManagerOf(this.options);
|
|
690
|
+
const external = /^(http|https|\/\/)/.test(original);
|
|
691
|
+
if (external && this.options.consent_required && manager) {
|
|
692
|
+
// external font CSS is an external service — ask first
|
|
693
|
+
const host = new URL(font.startsWith("//") ? "https:" + font : font).host;
|
|
694
|
+
const container = this._el.map ?? (this._el.container as HTMLElement);
|
|
695
|
+
const allowed = await manager.request(fontService(), host, container);
|
|
696
|
+
if (!allowed) {
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
if (font) {
|
|
701
|
+
try {
|
|
702
|
+
await loadCSS(font);
|
|
703
|
+
} catch {
|
|
704
|
+
// a missing font theme must not block the storymap; the
|
|
705
|
+
// font-loaded event fires either way, as before
|
|
706
|
+
} finally {
|
|
707
|
+
this._onFontLoaded(font);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
_onFontLoaded(font: string) {
|
|
713
|
+
this.fire("fontLoaded", { font: font });
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/* Navigation
|
|
717
|
+
================================================== */
|
|
718
|
+
/**
|
|
719
|
+
* Navigate to a slide by index. Out-of-range indices are ignored, as is
|
|
720
|
+
* the current index.
|
|
721
|
+
*
|
|
722
|
+
* @param n - Zero-based slide index; the map animates to the slide's
|
|
723
|
+
* marker. The overview is selected by the slide's own
|
|
724
|
+
* `type: "overview"`, not by its index.
|
|
725
|
+
*/
|
|
726
|
+
goTo(n: number) {
|
|
727
|
+
if (this._disposed) return;
|
|
728
|
+
// out-of-range indices are ignored: they would desync the slider and
|
|
729
|
+
// map (no active slide) and write a broken #slide-N bookmark
|
|
730
|
+
if (n >= 0 && n < (this.data?.slides?.length ?? 0) && n !== this.current_slide) {
|
|
731
|
+
this._navigate(n);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* The single navigation path shared by programmatic navigation, the
|
|
737
|
+
* slider's `change` event, the map's `change` event and "back to start".
|
|
738
|
+
*
|
|
739
|
+
* `navigate` names the single component that the bubble source already
|
|
740
|
+
* moved; the other component follows. `navigated` says whether the caller
|
|
741
|
+
* has *not* already reported the change outward, so the outward `change`
|
|
742
|
+
* event fires exactly once per navigation (the bubble paths would
|
|
743
|
+
* otherwise re-enter through the other component's handler).
|
|
744
|
+
*/
|
|
745
|
+
private _navigate(
|
|
746
|
+
n: number,
|
|
747
|
+
opts: {
|
|
748
|
+
navigate?: "slider" | "map";
|
|
749
|
+
navigated?: boolean;
|
|
750
|
+
animate?: boolean;
|
|
751
|
+
} = {},
|
|
752
|
+
): void {
|
|
753
|
+
const { navigate, navigated = false, animate = true } = opts;
|
|
754
|
+
const duration = animate ? slideTransitionDuration(this.current_slide, n) : 0;
|
|
755
|
+
|
|
756
|
+
this.current_slide = n;
|
|
757
|
+
if (navigate !== "slider") {
|
|
758
|
+
this._storyslider.goTo(this.current_slide);
|
|
759
|
+
}
|
|
760
|
+
if (navigate !== "map") {
|
|
761
|
+
this._map?.goTo(this.current_slide);
|
|
762
|
+
}
|
|
763
|
+
this._beginTransition(duration);
|
|
764
|
+
if (!navigated) {
|
|
765
|
+
// programmatic navigation reports outward like interaction does;
|
|
766
|
+
// the bubbled slider/map change events are dropped by their
|
|
767
|
+
// equality guards, so this fires exactly once
|
|
768
|
+
// `current_id` so a host can tell *which stop* it is on, not just
|
|
769
|
+
// how far along it is: an index moves when a slide is inserted, an
|
|
770
|
+
// id does not (§5.1)
|
|
771
|
+
this.fire(
|
|
772
|
+
"change",
|
|
773
|
+
{
|
|
774
|
+
current_slide: this.current_slide,
|
|
775
|
+
current_id: this._currentSlideId(),
|
|
776
|
+
},
|
|
777
|
+
this,
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
this._syncHash();
|
|
781
|
+
this._playNarration(this.data.slides?.[this.current_slide]);
|
|
782
|
+
this._scheduleAutoplay();
|
|
783
|
+
this._updateProgress();
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Announce a slide transition: `transitionstart` now (with the glide
|
|
788
|
+
* duration both animations were started with) and `transitionend` once
|
|
789
|
+
* it elapses. Restarts on every navigation, so only the latest
|
|
790
|
+
* transition ever ends.
|
|
791
|
+
*/
|
|
792
|
+
_beginTransition(duration: number): void {
|
|
793
|
+
if (this._transition_timer) {
|
|
794
|
+
clearTimeout(this._transition_timer);
|
|
795
|
+
}
|
|
796
|
+
this.fire("transitionstart", { current_slide: this.current_slide, duration }, this);
|
|
797
|
+
this._transition_timer = setTimeout(() => {
|
|
798
|
+
this._transition_timer = null;
|
|
799
|
+
this.fire("transitionend", { current_slide: this.current_slide }, this);
|
|
800
|
+
}, duration);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Re-measure the container and re-layout the map, slider and menubar.
|
|
805
|
+
* Called automatically on resize (see the `trackResize` option).
|
|
806
|
+
*/
|
|
807
|
+
updateDisplay() {
|
|
808
|
+
if (this._disposed) {
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
if (this.ready) {
|
|
812
|
+
this._updateDisplay();
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Change a single map option at runtime and apply its effect immediately.
|
|
818
|
+
*
|
|
819
|
+
* Runtime-changeable options: `map_type` (swaps one basemap for another and
|
|
820
|
+
* rebuilds the main + minimap tile layers, keeping the overview fitted to
|
|
821
|
+
* the marker bounds; it cannot add or remove a map, and `"none"` and
|
|
822
|
+
* non-strings are declined), `overlays`
|
|
823
|
+
* (rebuilds the stacked overlay layers), `show_lines`, `line_color`,
|
|
824
|
+
* `line_color_inactive`, `line_weight`, `line_opacity`, `line_dash`,
|
|
825
|
+
* `line_join`, `line_follows_path`, `show_history_line` (restyled
|
|
826
|
+
* instantly), `map_center_offset` (applied on the next navigation),
|
|
827
|
+
* `duration`, `ease`, `calculate_zoom`, `map_background_color`. All other
|
|
828
|
+
* options only take effect on the next navigation or require re-creating
|
|
829
|
+
* the StoryMap.
|
|
830
|
+
*/
|
|
831
|
+
/**
|
|
832
|
+
* Sugar over {@link setMapOptions} — `setMapOption(name, value)` is the
|
|
833
|
+
* same call as `setMapOptions({ [name]: value })`.
|
|
834
|
+
*/
|
|
835
|
+
setMapOption(name: string, value: unknown) {
|
|
836
|
+
if (this._disposed) return;
|
|
837
|
+
this.setMapOptions({ [name]: value } as Partial<StorymapOptions>);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Show or hide a stacked overlay by index (see the `overlays` option).
|
|
842
|
+
* Prefer `getOverlayLayer(index).setVisible(v)` on the layer itself; this
|
|
843
|
+
* wrapper also re-syncs the attribution line and the overlay blend mode.
|
|
844
|
+
*/
|
|
845
|
+
setOverlayVisible(index: number, visible: boolean): void {
|
|
846
|
+
if (this._disposed) return;
|
|
847
|
+
this._map?.setOverlayVisible(index, visible);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Set a stacked overlay's opacity by index (see the `overlays` option).
|
|
852
|
+
* Alias: `getOverlayLayer(index)?.setOpacity(opacity)`.
|
|
853
|
+
*/
|
|
854
|
+
setOverlayOpacity(index: number, opacity: number): void {
|
|
855
|
+
if (this._disposed) return;
|
|
856
|
+
this._map?.setOverlayOpacity(index, opacity);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Change several map options at runtime (see setMapOption).
|
|
861
|
+
*/
|
|
862
|
+
setMapOptions(options: Partial<StorymapOptions>) {
|
|
863
|
+
if (this._disposed) return;
|
|
864
|
+
// `map_type` decides at construction time whether a map exists. The
|
|
865
|
+
// engine can swap one basemap for another, but it can neither grow a
|
|
866
|
+
// map onto a mapless story nor remove a built one — "none" has no
|
|
867
|
+
// basemap branch and would silently install OpenStreetMap, the exact
|
|
868
|
+
// inverse of its meaning — and a non-string crashes the layer
|
|
869
|
+
// builder. So both are declined here, loudly, rather than half-applied.
|
|
870
|
+
let effective = options;
|
|
871
|
+
if (options.map_type !== undefined) {
|
|
872
|
+
if (typeof options.map_type !== "string") {
|
|
873
|
+
console.warn(
|
|
874
|
+
`StoryMapJS: setMapOptions ignores a non-string map_type (${String(options.map_type)}); pass "" for the default basemap.`,
|
|
875
|
+
);
|
|
876
|
+
effective = { ...options, map_type: "" };
|
|
877
|
+
} else if (options.map_type === "none" || this._map_disabled) {
|
|
878
|
+
console.warn(
|
|
879
|
+
"StoryMapJS: setMapOptions cannot add or remove a map after construction; map_type is load-time only.",
|
|
880
|
+
);
|
|
881
|
+
effective = { ...options };
|
|
882
|
+
delete effective.map_type;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
mergeData(this.options, effective);
|
|
886
|
+
if (this._map && this._map.options) {
|
|
887
|
+
mergeData(this._map.options, effective);
|
|
888
|
+
this._map.applyOptions(Object.keys(effective));
|
|
889
|
+
}
|
|
890
|
+
if (this.ready) {
|
|
891
|
+
// text color theming follows runtime option changes (issue #177)
|
|
892
|
+
this._applyTextColors();
|
|
893
|
+
this.updateDisplay();
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/* OpenLayers accessors
|
|
898
|
+
The map itself is public (`storymap.map`, the raw `ol/Map`). These hand
|
|
899
|
+
out the objects the viewer keeps private, so a consumer never has to reach
|
|
900
|
+
through an underscore field. `getBaseLayer()` returns `null` while the
|
|
901
|
+
base layer is deferred awaiting tile consent.
|
|
902
|
+
================================================= */
|
|
903
|
+
|
|
904
|
+
/** The base tile layer, or `null` when deferred (tile consent not granted). */
|
|
905
|
+
getBaseLayer(): OlLayer | null {
|
|
906
|
+
return !this._disposed && this._map ? this._map.getBaseLayer() : null;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/** The stacked `overlays[]` layers, in `overlays[]` order. */
|
|
910
|
+
getOverlayLayers(): OlLayer[] {
|
|
911
|
+
return !this._disposed && this._map ? this._map.getOverlayLayers() : [];
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** One stacked overlay layer by its `overlays[]` index, or `null`. */
|
|
915
|
+
getOverlayLayer(index: number): OlLayer | null {
|
|
916
|
+
return !this._disposed && this._map ? this._map.getOverlayLayer(index) : null;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/** Number of stacked overlay layers (see the `overlays` option). */
|
|
920
|
+
getOverlayCount(): number {
|
|
921
|
+
return !this._disposed && this._map ? this._map.getOverlayCount() : 0;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/** The minimap's OpenLayers map (the `OverviewMap` control's inner map). */
|
|
925
|
+
getMinimap(): OlMap | null {
|
|
926
|
+
return !this._disposed && this._map ? this._map.getMinimap() : null;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/** The full (inactive) route line layer. */
|
|
930
|
+
getLine(): OlLayer | null {
|
|
931
|
+
return !this._disposed && this._map ? this._map.getLine() : null;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* True when the map shows a IIIF image as a picture of the world
|
|
936
|
+
* (`map_as_image`) rather than as a georeferenced map. In that mode the
|
|
937
|
+
* view is `EPSG:4326` image space: `map.getView().getCenter()` is in
|
|
938
|
+
* degrees, the resolution is in pixels per degree, and marker locations
|
|
939
|
+
* are pixel offsets into the image. Ask this instead of checking
|
|
940
|
+
* `map_type` — georeferenced IIIF (`map_bbox` without `map_as_image`)
|
|
941
|
+
* is an ordinary mercator map.
|
|
942
|
+
*/
|
|
943
|
+
isImageSpace(): boolean {
|
|
944
|
+
return !this._disposed && this._map ? this._map.isImageSpace() : false;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* The `uniqueid` of a slide — the current one by default.
|
|
949
|
+
*
|
|
950
|
+
* A IIIF manifest supplies one per canvas (§2.3) and it is the only
|
|
951
|
+
* identity a link can be built on, since an index moves when a slide is
|
|
952
|
+
* inserted. A storymap-JSON slide usually has none, in which case this is
|
|
953
|
+
* `null` rather than a generated id: a random one would be stable to nobody.
|
|
954
|
+
*/
|
|
955
|
+
getSlideId(index?: number): string | null {
|
|
956
|
+
if (this._disposed) return null;
|
|
957
|
+
const at = index ?? this.current_slide;
|
|
958
|
+
const id = this.data?.slides?.[at]?.uniqueid;
|
|
959
|
+
return typeof id === "string" && id !== "" ? id : null;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Read the annotation documents this manifest points at with `seeAlso` and
|
|
964
|
+
* add the tour stops they carry.
|
|
965
|
+
*
|
|
966
|
+
* Explicit and asynchronous on purpose (§5.2). The alternative — following
|
|
967
|
+
* `seeAlso` inside `manifestToStorymapData` — would mean one blocking
|
|
968
|
+
* network round trip per referenced document before the viewer could paint
|
|
969
|
+
* its first slide, against third-party servers the host may not want to
|
|
970
|
+
* contact at all. Here the story stands on its own immediately and the
|
|
971
|
+
* annotations arrive afterwards, behind a per-(id, type) cache, and
|
|
972
|
+
* `annotationsloaded` fires when they are in.
|
|
973
|
+
*
|
|
974
|
+
* The stops are **appended**, not placed after the canvas they annotate.
|
|
975
|
+
* A canvas's own annotations are inserted in position at parse time; doing
|
|
976
|
+
* the same to a live tour would mean rebuilding the slider and every piece
|
|
977
|
+
* of state that hangs off it, for an ordering nicety. A host that needs
|
|
978
|
+
* the stops placed exactly can read them off the resolved value and place
|
|
979
|
+
* them itself.
|
|
980
|
+
*
|
|
981
|
+
* @returns the stops that were added, keyed by canvas id, plus any
|
|
982
|
+
* `SearchService1` endpoint seen and any document that failed to load.
|
|
983
|
+
*/
|
|
984
|
+
async loadAnnotations(
|
|
985
|
+
options: { fetchImpl?: typeof fetch } = {},
|
|
986
|
+
): Promise<{ stops: StorymapSlide[]; searchService: string | null; failed: string[] }> {
|
|
987
|
+
const added: StorymapSlide[] = [];
|
|
988
|
+
if (this._disposed || this._raw_manifest === null) {
|
|
989
|
+
return { stops: added, searchService: null, failed: [] };
|
|
990
|
+
}
|
|
991
|
+
const loaded = await loadSeeAlso(this._raw_manifest, options);
|
|
992
|
+
if (this._disposed) {
|
|
993
|
+
// torn down while the requests were in flight
|
|
994
|
+
return { stops: added, searchService: loaded.searchService, failed: loaded.failed };
|
|
995
|
+
}
|
|
996
|
+
// canvas order, so the appended stops follow the story rather than the
|
|
997
|
+
// order the documents happened to resolve in
|
|
998
|
+
for (const slide of this.data.slides ?? []) {
|
|
999
|
+
const stops = loaded.stops.get(slide.uniqueid ?? "");
|
|
1000
|
+
if (stops === undefined) continue;
|
|
1001
|
+
for (const stop of stops) {
|
|
1002
|
+
stop.uniqueid = `${slide.uniqueid ?? "slide"}#seealso-${added.length}`;
|
|
1003
|
+
added.push(stop);
|
|
1004
|
+
this.data.slides.push(stop);
|
|
1005
|
+
this._storyslider.createSlide(stop);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
if (added.length > 0) {
|
|
1009
|
+
this._updateProgress();
|
|
1010
|
+
this._updateDistance();
|
|
1011
|
+
}
|
|
1012
|
+
const result = { stops: added, searchService: loaded.searchService, failed: loaded.failed };
|
|
1013
|
+
this.fire("annotationsloaded", result, this);
|
|
1014
|
+
return result;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* Release everything the viewer attached to the page: window/document
|
|
1019
|
+
* listeners, timers, the resize observer, running Web Animations, the
|
|
1020
|
+
* slider, the menubar, the map and the map markers. Use this when tearing
|
|
1021
|
+
* a storymap down (SPA route change, modal close).
|
|
1022
|
+
*
|
|
1023
|
+
* Teardown is terminal: the host container is emptied, `map` becomes
|
|
1024
|
+
* null, and the public methods (`goTo`, `setMapOptions`,
|
|
1025
|
+
* `createMiniMap`, ...) become no-ops rather than throwing. There is no
|
|
1026
|
+
* re-init path; build a new StoryMap on the same element instead.
|
|
1027
|
+
* Calling `dispose()` twice is a no-op.
|
|
1028
|
+
*/
|
|
1029
|
+
dispose(): void {
|
|
1030
|
+
if (this._disposed) {
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
this._disposed = true;
|
|
1034
|
+
// `updateDisplay()` is gated on `ready`, not `_disposed`, so without
|
|
1035
|
+
// this a post-teardown resize listener (or a stray host call) re-runs
|
|
1036
|
+
// the whole layout — and reaches the map engine — against an emptied
|
|
1037
|
+
// container and a disposed ol/Map.
|
|
1038
|
+
this.ready = false;
|
|
1039
|
+
|
|
1040
|
+
for (const timer of [this._transition_timer, this._autoplay_timer, this._resize_timer]) {
|
|
1041
|
+
if (timer !== null && timer !== undefined) {
|
|
1042
|
+
clearTimeout(timer);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
this._transition_timer = null;
|
|
1046
|
+
this._autoplay_timer = null;
|
|
1047
|
+
this._resize_timer = null;
|
|
1048
|
+
|
|
1049
|
+
this._resize_observer?.disconnect();
|
|
1050
|
+
this._resize_observer = null;
|
|
1051
|
+
|
|
1052
|
+
if (this._on_resize) {
|
|
1053
|
+
window.removeEventListener("resize", this._on_resize);
|
|
1054
|
+
this._on_resize = null;
|
|
1055
|
+
}
|
|
1056
|
+
if (this._on_keydown_global) {
|
|
1057
|
+
window.removeEventListener("keydown", this._on_keydown_global);
|
|
1058
|
+
this._on_keydown_global = null;
|
|
1059
|
+
}
|
|
1060
|
+
if (this._on_fullscreen) {
|
|
1061
|
+
document.removeEventListener("fullscreenchange", this._on_fullscreen);
|
|
1062
|
+
this._on_fullscreen = null;
|
|
1063
|
+
}
|
|
1064
|
+
if (this._on_hashchange) {
|
|
1065
|
+
window.removeEventListener("hashchange", this._on_hashchange);
|
|
1066
|
+
this._on_hashchange = null;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// slide transitions in flight (Web Animations API)
|
|
1070
|
+
for (const el of [this._el?.container, this._el?.map]) {
|
|
1071
|
+
el?.getAnimations?.().forEach((a) => a.cancel());
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// an unanswered consent panel holds a promise that Media.loadMedia()
|
|
1075
|
+
// is awaiting; settle it before the children go away
|
|
1076
|
+
consentManagerOf(this.options)?.dispose();
|
|
1077
|
+
|
|
1078
|
+
this._stopNarration();
|
|
1079
|
+
if (this._narration_el) {
|
|
1080
|
+
this._narration_el.src = "";
|
|
1081
|
+
this._narration_el = null;
|
|
1082
|
+
}
|
|
1083
|
+
this._storyslider?.dispose?.();
|
|
1084
|
+
this._menubar?.dispose?.();
|
|
1085
|
+
this._map?.dispose?.();
|
|
1086
|
+
|
|
1087
|
+
// the public ol/Map handle. The viewer is terminal after dispose(), so
|
|
1088
|
+
// this is null rather than a disposed instance; the public methods
|
|
1089
|
+
// above are guarded and no-op instead of throwing.
|
|
1090
|
+
this.map = null;
|
|
1091
|
+
|
|
1092
|
+
// empty the host element: the children owned their own listeners and
|
|
1093
|
+
// players, but their nodes (and the ol canvas) would otherwise stay
|
|
1094
|
+
// in the document after teardown
|
|
1095
|
+
this._el?.container?.replaceChildren();
|
|
1096
|
+
|
|
1097
|
+
// let another viewer claim a different language, now that this one is
|
|
1098
|
+
// no longer reading the shared strings (refcounted, so a sibling in
|
|
1099
|
+
// the same language keeps its claim)
|
|
1100
|
+
releaseLanguage(this._language_holder);
|
|
1101
|
+
|
|
1102
|
+
if (this._onInteraction) {
|
|
1103
|
+
for (const type of ["pointerdown", "focusin", "wheel"] as const) {
|
|
1104
|
+
this._el.container.removeEventListener(type, this._onInteraction);
|
|
1105
|
+
}
|
|
1106
|
+
this._onInteraction = null;
|
|
1107
|
+
}
|
|
1108
|
+
unregisterParticipant(this);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* This viewer's root element. Satisfies the `Participant` shape the
|
|
1113
|
+
* page-wide key registry (`src/core/viewers.ts`) arbitrates on.
|
|
1114
|
+
*
|
|
1115
|
+
* @internal
|
|
1116
|
+
*/
|
|
1117
|
+
get element(): HTMLElement | null {
|
|
1118
|
+
return this._el?.container ?? null;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* Monotonic stamp of the last interaction with this viewer; the highest
|
|
1123
|
+
* one owns a page-wide keypress when nothing is focused.
|
|
1124
|
+
*
|
|
1125
|
+
* @internal
|
|
1126
|
+
*/
|
|
1127
|
+
get interaction(): number {
|
|
1128
|
+
return this._interaction;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/** The highlighted route line drawn up to the current slide. */
|
|
1132
|
+
getLineActive(): OlLayer | null {
|
|
1133
|
+
return !this._disposed && this._map ? this._map.getLineActive() : null;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/** The map markers, indexed by slide. */
|
|
1137
|
+
getMarkers(): OpenLayersMapMarker[] {
|
|
1138
|
+
return !this._disposed && this._map ? this._map.getMarkers() : [];
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/** One map marker by slide index, or `null`. */
|
|
1142
|
+
getMarker(index: number): OpenLayersMapMarker | null {
|
|
1143
|
+
return !this._disposed && this._map ? this._map.getMarker(index) : null;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* Rebuild the minimap (`OverviewMap` control). The constructor already
|
|
1148
|
+
* builds it; this is for hosts that recreate it after a `map_type` swap
|
|
1149
|
+
* or a deferred tile-consent grant.
|
|
1150
|
+
*/
|
|
1151
|
+
createMiniMap(): void {
|
|
1152
|
+
if (this._disposed) return;
|
|
1153
|
+
this._map?.createMiniMap();
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* Append attribution fragments and refresh the credit line (e.g. for a
|
|
1158
|
+
* custom layer added through `tile_source_factory`).
|
|
1159
|
+
*/
|
|
1160
|
+
setExtraAttributions(parts: string[]): void {
|
|
1161
|
+
if (this._disposed) return;
|
|
1162
|
+
this._map?.setExtraAttributions(parts);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/**
|
|
1166
|
+
* Publish the text colour custom properties. Called from both the initial
|
|
1167
|
+
* layout and every runtime option change (issue #177) so the two paths
|
|
1168
|
+
* cannot drift.
|
|
1169
|
+
*/
|
|
1170
|
+
_applyTextColors(): void {
|
|
1171
|
+
if (this.options.text_color) {
|
|
1172
|
+
this._el.container.style.setProperty("--vco-color-text", this.options.text_color);
|
|
1173
|
+
}
|
|
1174
|
+
if (this.options.text_background_color) {
|
|
1175
|
+
this._el.container.style.setProperty(
|
|
1176
|
+
"--vco-color-text-background",
|
|
1177
|
+
this.options.text_background_color,
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/* Private Methods
|
|
1183
|
+
================================================== */
|
|
1184
|
+
|
|
1185
|
+
// Initialize the layout
|
|
1186
|
+
_initLayout() {
|
|
1187
|
+
if (this._disposed) {
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
this._el.container.className += " vco-storymap";
|
|
1191
|
+
this.options.base_class = this._el.container.className;
|
|
1192
|
+
|
|
1193
|
+
// Text color theming (issue #177): expose the text colors as CSS
|
|
1194
|
+
// custom properties consumed by the slide typography
|
|
1195
|
+
this._applyTextColors();
|
|
1196
|
+
|
|
1197
|
+
// Create Layout
|
|
1198
|
+
this._el.menubar = Dom.create("div", "vco-menubar", this._el.container);
|
|
1199
|
+
this._el.map = this._map_disabled
|
|
1200
|
+
? null
|
|
1201
|
+
: (this._resolveMapElement() ?? Dom.create("div", "vco-map", this._el.container));
|
|
1202
|
+
this._el.storyslider = Dom.create("div", "vco-storyslider", this._el.container);
|
|
1203
|
+
|
|
1204
|
+
// Initial Default Layout
|
|
1205
|
+
this.options.width = this._el.container.offsetWidth;
|
|
1206
|
+
this.options.height = this._el.container.offsetHeight;
|
|
1207
|
+
if (this._el.map) {
|
|
1208
|
+
this._el.map.style.height = "1px";
|
|
1209
|
+
}
|
|
1210
|
+
this._el.storyslider.style.top = "1px";
|
|
1211
|
+
|
|
1212
|
+
if (this._map_disabled) {
|
|
1213
|
+
// No map pane, so the map options have nothing to size. The
|
|
1214
|
+
// slider takes the whole height below the menubar, and every
|
|
1215
|
+
// map-derived option is set to something the layout code below
|
|
1216
|
+
// does not need. Nothing is constructed - no ol/Map, no tile
|
|
1217
|
+
// layer, no consent ask. storyslider_height is set by the
|
|
1218
|
+
// _updateDisplay call below, so assigning it here would be dead.
|
|
1219
|
+
this.options.map_height = 0;
|
|
1220
|
+
// the overview control is a map control: with no map there is
|
|
1221
|
+
// nothing for it to zoom out to, so it is hidden the same way
|
|
1222
|
+
// `show_overview: false` hides it
|
|
1223
|
+
this.options.show_overview = false;
|
|
1224
|
+
} else {
|
|
1225
|
+
// Create Map using preferred Map API
|
|
1226
|
+
this._map = new OpenLayersMap(this._map_el(), this.data, this.options);
|
|
1227
|
+
this.map = this._map._map; // For access to the OpenLayers map.
|
|
1228
|
+
this._map.on("loaded", this._onMapLoaded, this);
|
|
1229
|
+
// image readiness (IIIF/zoomify sources attach asynchronously) is
|
|
1230
|
+
// re-fired on the StoryMap, the coordination point for hosts that
|
|
1231
|
+
// overlay or measure their own layers
|
|
1232
|
+
this._map.on("imageready", (e: unknown) => {
|
|
1233
|
+
this.fire("imageready", e);
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
// Map Background Color
|
|
1237
|
+
this._map_el().style.backgroundColor = this.options.map_background_color;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// Create Menu Bar
|
|
1241
|
+
this._menubar = new MenuBar(this._el.menubar, this._el.container, this.options);
|
|
1242
|
+
|
|
1243
|
+
// Create StorySlider
|
|
1244
|
+
this._storyslider = new StorySlider(this._el.storyslider, this.data, this.options);
|
|
1245
|
+
this._storyslider.on("loaded", this._onStorySliderLoaded, this);
|
|
1246
|
+
this._storyslider.on("title", this._onTitle, this);
|
|
1247
|
+
this._storyslider.init();
|
|
1248
|
+
|
|
1249
|
+
// LAYOUT
|
|
1250
|
+
if (this._map_disabled) {
|
|
1251
|
+
// one branch, both orientations: nothing to split the height
|
|
1252
|
+
// with, and the menubar is the only thing above the slider
|
|
1253
|
+
this.options.menubar_height = this._el.menubar.offsetHeight;
|
|
1254
|
+
this._menubar.setSticky(this.options.menubar_height);
|
|
1255
|
+
} else if (this.options.layout === "portrait") {
|
|
1256
|
+
// Set Default Component Sizes
|
|
1257
|
+
this.options.map_height = this.options.height / this.options.map_size_sticky;
|
|
1258
|
+
this.options.storyslider_height =
|
|
1259
|
+
this.options.height - this._el.menubar.offsetHeight - this.options.map_height - 1;
|
|
1260
|
+
this._menubar.setSticky(0);
|
|
1261
|
+
} else {
|
|
1262
|
+
this.options.menubar_height = this._el.menubar.offsetHeight;
|
|
1263
|
+
// Set Default Component Sizes
|
|
1264
|
+
this.options.map_height = this.options.height;
|
|
1265
|
+
this.options.storyslider_height =
|
|
1266
|
+
this.options.height - this._el.menubar.offsetHeight - 1;
|
|
1267
|
+
this._menubar.setSticky(this.options.menubar_height);
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// Update Display
|
|
1271
|
+
this._updateDisplay(this.options.map_height, true, 2000);
|
|
1272
|
+
|
|
1273
|
+
// Animate Menu Bar to Default Location
|
|
1274
|
+
this._menubar.show(2000);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
_initEvents() {
|
|
1278
|
+
// Sidebar Events
|
|
1279
|
+
this._menubar.on("collapse", this._onMenuBarCollapse, this);
|
|
1280
|
+
this._menubar.on("back_to_start", this._onBackToStart, this);
|
|
1281
|
+
this._menubar.on("overview", this._onOverview, this);
|
|
1282
|
+
this._menubar.on("fullscreen", this._onFullscreenToggle, this);
|
|
1283
|
+
|
|
1284
|
+
// StorySlider Events
|
|
1285
|
+
this._storyslider.on("change", this._onSlideChange, this);
|
|
1286
|
+
this._storyslider.on("colorchange", this._onColorChange, this);
|
|
1287
|
+
|
|
1288
|
+
// Map Events
|
|
1289
|
+
this._map?.on("change", this._onMapChange, this);
|
|
1290
|
+
|
|
1291
|
+
// Global slide navigation (opt-in): the slider only listens on its
|
|
1292
|
+
// own panel, which needs focus.
|
|
1293
|
+
//
|
|
1294
|
+
// With more than one viewer on the page, `window` is shared, and
|
|
1295
|
+
// e.preventDefault() does not stop a sibling listener on the same
|
|
1296
|
+
// target — so an arrow press used to advance every story at once. This
|
|
1297
|
+
// viewer takes part in the registry and then answers whether it owns
|
|
1298
|
+
// the event (focus first, then most-recently-interacted).
|
|
1299
|
+
if (this.options.keyboard) {
|
|
1300
|
+
this._onInteraction = () => {
|
|
1301
|
+
this._interaction = markInteraction();
|
|
1302
|
+
};
|
|
1303
|
+
for (const type of ["pointerdown", "focusin", "wheel"] as const) {
|
|
1304
|
+
this._el.container.addEventListener(type, this._onInteraction, {
|
|
1305
|
+
passive: true,
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
registerParticipant(this);
|
|
1309
|
+
this._on_keydown_global = (e: KeyboardEvent) => this._onKeyDownGlobal(e);
|
|
1310
|
+
window.addEventListener("keydown", this._on_keydown_global);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// Fullscreen state
|
|
1314
|
+
this._on_fullscreen = () => this._onFullscreenChange();
|
|
1315
|
+
document.addEventListener("fullscreenchange", this._on_fullscreen);
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
_onKeyDownGlobal(e: KeyboardEvent) {
|
|
1319
|
+
if (e.defaultPrevented) {
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
const target = e.target as HTMLElement | null;
|
|
1323
|
+
const tag = target?.tagName?.toLowerCase();
|
|
1324
|
+
if (
|
|
1325
|
+
tag === "input" ||
|
|
1326
|
+
tag === "textarea" ||
|
|
1327
|
+
tag === "select" ||
|
|
1328
|
+
target?.isContentEditable
|
|
1329
|
+
) {
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
// One viewer owns a page-wide keypress. This also covers the old
|
|
1333
|
+
// "let OpenLayers pan the map" carve-out: the map is inside the
|
|
1334
|
+
// viewer's own container, so a keypress aimed at another viewer (or at
|
|
1335
|
+
// nothing the visitor has focused) no longer reaches this slider.
|
|
1336
|
+
if (!ownsPageEvent(this)) {
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
|
1340
|
+
e.preventDefault();
|
|
1341
|
+
this._storyslider.next();
|
|
1342
|
+
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
|
1343
|
+
e.preventDefault();
|
|
1344
|
+
this._storyslider.previous();
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// View
|
|
1349
|
+
_updateDisplay(map_height?: number, animate?: boolean, d?: number) {
|
|
1350
|
+
// prefers-reduced-motion: glides collapse to instant size changes
|
|
1351
|
+
if (animate && prefersReducedMotion()) {
|
|
1352
|
+
animate = false;
|
|
1353
|
+
}
|
|
1354
|
+
let duration = this.options.duration,
|
|
1355
|
+
display_class = this.options.base_class;
|
|
1356
|
+
|
|
1357
|
+
if (d) {
|
|
1358
|
+
duration = d;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// Update width and height
|
|
1362
|
+
this.options.width = this._el.container.offsetWidth;
|
|
1363
|
+
this.options.height = this._el.container.offsetHeight;
|
|
1364
|
+
|
|
1365
|
+
// Check if skinny
|
|
1366
|
+
if (this.options.width <= this.options.skinny_size) {
|
|
1367
|
+
this.options.layout = "portrait";
|
|
1368
|
+
} else {
|
|
1369
|
+
this.options.layout = "landscape";
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
// Map Height
|
|
1373
|
+
if (map_height) {
|
|
1374
|
+
this.options.map_height = map_height;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// Detect Mobile and Update Orientation on Touch devices
|
|
1378
|
+
if (Browser.touch) {
|
|
1379
|
+
this.options.layout = Browser.orientation();
|
|
1380
|
+
display_class += " vco-mobile";
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// LAYOUT
|
|
1384
|
+
if (this._map_disabled) {
|
|
1385
|
+
// The mapless layout: the menubar on top, the slider filling
|
|
1386
|
+
// everything below it, in both orientations. No map element to
|
|
1387
|
+
// size, offset, animate or fit, so none of that runs, and
|
|
1388
|
+
// `map_area` has nothing to narrow.
|
|
1389
|
+
display_class += " vco-layout-no-map";
|
|
1390
|
+
// A narrow mapless story still needs the narrow-viewport slide
|
|
1391
|
+
// layout: every rule that stacks the two-column slide (the
|
|
1392
|
+
// 100px side padding, the floated 50% media block) keys on
|
|
1393
|
+
// vco-skinny, and without it a phone-width story renders broken.
|
|
1394
|
+
// The wide case needs nothing extra — the no-map rule already
|
|
1395
|
+
// owns the full-width opaque panel.
|
|
1396
|
+
if (this.options.layout === "portrait") {
|
|
1397
|
+
display_class += " vco-skinny vco-layout-portrait";
|
|
1398
|
+
}
|
|
1399
|
+
// A narrow mapless story still needs the narrow-viewport slide
|
|
1400
|
+
// layout: every rule that stacks the two-column slide (the
|
|
1401
|
+
// 100px side padding, the floated 50% media block) keys on
|
|
1402
|
+
// vco-skinny, and without it a phone-width story renders broken.
|
|
1403
|
+
// The wide case needs nothing extra — the no-map rule already
|
|
1404
|
+
// owns the full-width opaque panel.
|
|
1405
|
+
|
|
1406
|
+
this.options.menubar_height = this._el.menubar.offsetHeight;
|
|
1407
|
+
this.options.map_height = 0;
|
|
1408
|
+
this.options.storyslider_height = this.options.height - 1;
|
|
1409
|
+
this._menubar.setSticky(this.options.menubar_height);
|
|
1410
|
+
|
|
1411
|
+
this._el.storyslider.style.top = "0";
|
|
1412
|
+
this._el.storyslider.style.height = this.options.storyslider_height + "px";
|
|
1413
|
+
|
|
1414
|
+
this._menubar.updateDisplay(this.options.width, this.options.height, animate);
|
|
1415
|
+
this._storyslider.updateDisplay(
|
|
1416
|
+
this.options.width,
|
|
1417
|
+
this.options.storyslider_height,
|
|
1418
|
+
animate,
|
|
1419
|
+
this.options.layout,
|
|
1420
|
+
);
|
|
1421
|
+
} else if (this.options.layout === "portrait") {
|
|
1422
|
+
display_class += " vco-skinny";
|
|
1423
|
+
// Map Offset
|
|
1424
|
+
this._map_required().setMapOffset(0, 0);
|
|
1425
|
+
|
|
1426
|
+
// Portrait split. The collapse toggle is only offered in portrait
|
|
1427
|
+
// (MenuBar hides it in landscape), so this branch used to
|
|
1428
|
+
// unconditionally recompute map_height and discard the collapsed
|
|
1429
|
+
// height it was handed — the button was a visual no-op. Honour
|
|
1430
|
+
// the toggle state instead.
|
|
1431
|
+
if (this._collapsed) {
|
|
1432
|
+
this.options.map_height = COLLAPSED_MAP_HEIGHT;
|
|
1433
|
+
} else {
|
|
1434
|
+
this.options.map_height = this.options.height / this.options.map_size_sticky;
|
|
1435
|
+
}
|
|
1436
|
+
this.options.storyslider_height = this.options.height - this.options.map_height - 1;
|
|
1437
|
+
this._menubar.setSticky(0);
|
|
1438
|
+
|
|
1439
|
+
// Portrait: the map spans the full width again (a landscape
|
|
1440
|
+
// map_area "left" pass narrowed it)
|
|
1441
|
+
display_class += " vco-layout-portrait";
|
|
1442
|
+
this._map_el().style.width = "100%";
|
|
1443
|
+
|
|
1444
|
+
if (animate) {
|
|
1445
|
+
// Animate Map
|
|
1446
|
+
if (this.animator_map) {
|
|
1447
|
+
this.animator_map.stop();
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
this.animator_map = Animate(this._map_el(), {
|
|
1451
|
+
height: this.options.map_height + "px",
|
|
1452
|
+
duration: duration,
|
|
1453
|
+
easing: easeOutStrong,
|
|
1454
|
+
complete: () => {
|
|
1455
|
+
this._map_required().updateDisplay(
|
|
1456
|
+
this.options.width,
|
|
1457
|
+
this.options.map_height,
|
|
1458
|
+
animate,
|
|
1459
|
+
d,
|
|
1460
|
+
this.options.menubar_height,
|
|
1461
|
+
);
|
|
1462
|
+
},
|
|
1463
|
+
});
|
|
1464
|
+
|
|
1465
|
+
// Animate StorySlider
|
|
1466
|
+
if (this.animator_storyslider) {
|
|
1467
|
+
this.animator_storyslider.stop();
|
|
1468
|
+
}
|
|
1469
|
+
this.animator_storyslider = Animate(this._el.storyslider, {
|
|
1470
|
+
height: this.options.storyslider_height + "px",
|
|
1471
|
+
duration: duration,
|
|
1472
|
+
easing: easeOutStrong,
|
|
1473
|
+
});
|
|
1474
|
+
} else {
|
|
1475
|
+
// Map
|
|
1476
|
+
this._map_el().style.height = Math.ceil(this.options.map_height) + "px";
|
|
1477
|
+
|
|
1478
|
+
// StorySlider
|
|
1479
|
+
this._el.storyslider.style.height = this.options.storyslider_height + "px";
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
// Update Component Displays
|
|
1483
|
+
this._menubar.updateDisplay(this.options.width, this.options.height, animate);
|
|
1484
|
+
this._map_required().updateDisplay(this.options.width, this.options.height, false);
|
|
1485
|
+
this._storyslider.updateDisplay(
|
|
1486
|
+
this.options.width,
|
|
1487
|
+
this.options.storyslider_height,
|
|
1488
|
+
animate,
|
|
1489
|
+
this.options.layout,
|
|
1490
|
+
);
|
|
1491
|
+
} else {
|
|
1492
|
+
// Landscape
|
|
1493
|
+
display_class += " vco-layout-landscape";
|
|
1494
|
+
|
|
1495
|
+
this.options.menubar_height = this._el.menubar.offsetHeight;
|
|
1496
|
+
|
|
1497
|
+
// Set Default Component Sizes
|
|
1498
|
+
this.options.map_height = this.options.height;
|
|
1499
|
+
this.options.storyslider_height = this.options.height;
|
|
1500
|
+
|
|
1501
|
+
// Set Sticky state of MenuBar
|
|
1502
|
+
this._menubar.setSticky(this.options.menubar_height);
|
|
1503
|
+
|
|
1504
|
+
this._map_el().style.height = this.options.height + "px";
|
|
1505
|
+
|
|
1506
|
+
// map_area "left": the map element is limited to the left, visible
|
|
1507
|
+
// half (the slide panel is opaque) — no view offset needed;
|
|
1508
|
+
// "full" (default): the map spans the whole width behind the
|
|
1509
|
+
// fading slide panel and the view is offset by a quarter width
|
|
1510
|
+
if (this.options.map_area === "left") {
|
|
1511
|
+
display_class += " vco-map-area-left";
|
|
1512
|
+
this._map_el().style.width = Math.floor(this.options.width / 2) + "px";
|
|
1513
|
+
this._map_required().setMapOffset(0, 0);
|
|
1514
|
+
} else {
|
|
1515
|
+
this._map_el().style.width = "100%";
|
|
1516
|
+
this._map_required().setMapOffset(-(this.options.width / 4), 0);
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
// StorySlider
|
|
1520
|
+
this._el.storyslider.style.top = "0";
|
|
1521
|
+
this._el.storyslider.style.height = this.options.storyslider_height + "px";
|
|
1522
|
+
|
|
1523
|
+
this._menubar.updateDisplay(this.options.width, this.options.height, animate);
|
|
1524
|
+
this._map_required().updateDisplay(this.options.width, this.options.height, animate, d);
|
|
1525
|
+
this._storyslider.updateDisplay(
|
|
1526
|
+
this.options.width / 2,
|
|
1527
|
+
this.options.storyslider_height,
|
|
1528
|
+
animate,
|
|
1529
|
+
this.options.layout,
|
|
1530
|
+
);
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
// the resolved locale decides the layout direction (issues #211, #245)
|
|
1534
|
+
if (isRtl()) {
|
|
1535
|
+
display_class += " vco-rtl";
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
// Apply class
|
|
1539
|
+
this._el.container.className = display_class;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
/* Events
|
|
1543
|
+
================================================== */
|
|
1544
|
+
|
|
1545
|
+
_onDataLoaded(e?: unknown) {
|
|
1546
|
+
// Belt and braces with the _loadDataFromUrl guard: anything reaching
|
|
1547
|
+
// this point after a teardown must not rebuild the layout.
|
|
1548
|
+
if (this._disposed) {
|
|
1549
|
+
return;
|
|
1550
|
+
}
|
|
1551
|
+
// attach the consent manager BEFORE the layout is created, so the
|
|
1552
|
+
// slider/map/media options copies all share it
|
|
1553
|
+
(this.options as Record<string, unknown>).consent_manager = new ConsentManager();
|
|
1554
|
+
this.fire("dataloaded");
|
|
1555
|
+
this._initLayout();
|
|
1556
|
+
// font themes load once the layout exists: the consent ask for
|
|
1557
|
+
// external font CSS needs a real container to render into
|
|
1558
|
+
void this._loadFontCss().catch((err: unknown) => {
|
|
1559
|
+
console.warn("StoryMapJS: font theme could not be loaded", err);
|
|
1560
|
+
});
|
|
1561
|
+
this._initEvents();
|
|
1562
|
+
this._initResizeHandling();
|
|
1563
|
+
this.ready = true;
|
|
1564
|
+
this._startConsentAsk();
|
|
1565
|
+
this._startAutoplay();
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
/**
|
|
1569
|
+
* Start-of-story consent (issue: allow all / individually / decline
|
|
1570
|
+
* all): with `consent_required`, one dialog lists every external
|
|
1571
|
+
* service the story uses — map tiles, external web fonts and the media
|
|
1572
|
+
* services in the slides. Shown only while some service is unanswered;
|
|
1573
|
+
* per-service slide asks remain the fallback.
|
|
1574
|
+
*/
|
|
1575
|
+
_startConsentAsk(): void {
|
|
1576
|
+
const manager = consentManagerOf(this.options);
|
|
1577
|
+
if (!manager || !this.options.consent_required) {
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
const services: ConsentService[] = [];
|
|
1581
|
+
// map tiles — the same service the map's layer code keys off. A story
|
|
1582
|
+
// with no map asks about its media and fonts only: there is no tile
|
|
1583
|
+
// request to consent to, so asking would be noise the visitor cannot
|
|
1584
|
+
// act on.
|
|
1585
|
+
if (!this._map_disabled) {
|
|
1586
|
+
services.push(tileService());
|
|
1587
|
+
}
|
|
1588
|
+
// media services with a real URL in the slides (a storymap is allowed
|
|
1589
|
+
// to have no slides at all)
|
|
1590
|
+
const seen = new Set<string>();
|
|
1591
|
+
for (const slide of this.data.slides ?? []) {
|
|
1592
|
+
const url = (slide.media as { url?: string | null } | null)?.url;
|
|
1593
|
+
if (!url) continue;
|
|
1594
|
+
const match = MediaType({ url } as never) as { type: string; name: string } | false;
|
|
1595
|
+
if (!match || seen.has(match.type)) continue;
|
|
1596
|
+
seen.add(match.type);
|
|
1597
|
+
// same registry the per-slide panel uses, so the two dialogs can
|
|
1598
|
+
// never disagree about a service's name
|
|
1599
|
+
services.push(mediaService(match.type, match.name));
|
|
1600
|
+
}
|
|
1601
|
+
// Slide narration is its own service: the recording plays through a
|
|
1602
|
+
// dedicated audio element, not through Media, so nothing else asks
|
|
1603
|
+
// for it — without this the GDPR mode would have a hole.
|
|
1604
|
+
const has_narration = (this.data.slides ?? []).some(
|
|
1605
|
+
(slide) => !!(slide.narration as { url?: string } | null)?.url,
|
|
1606
|
+
);
|
|
1607
|
+
if (has_narration && !seen.has("narration")) {
|
|
1608
|
+
services.push(narrationService());
|
|
1609
|
+
}
|
|
1610
|
+
// external web fonts (same-origin themes never ask). Compared by
|
|
1611
|
+
// origin, not by a prefix test on the resolved URL: resolveFontCssUrl
|
|
1612
|
+
// has already turned a relative path into an absolute one, so testing
|
|
1613
|
+
// that for "http" made every relative font_css look external.
|
|
1614
|
+
const font = resolveFontCssUrl(this.options.font_css || "stock:default");
|
|
1615
|
+
if (isExternalUrl(font)) {
|
|
1616
|
+
services.push(fontService());
|
|
1617
|
+
}
|
|
1618
|
+
manager.requestAll(services, this._el.container);
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
/* Autoplay (issue #380) and hash bookmarks (issue #146)
|
|
1622
|
+
================================================== */
|
|
1623
|
+
/* Narration (docs/plans/iiif-media-tours.md §2)
|
|
1624
|
+
================================================== */
|
|
1625
|
+
|
|
1626
|
+
/**
|
|
1627
|
+
* Play the current slide's narration, if it has one.
|
|
1628
|
+
*
|
|
1629
|
+
* One `<audio>` element is reused for the whole story rather than a
|
|
1630
|
+
* player per slide, so switching slides cannot leave a recording playing
|
|
1631
|
+
* behind. Two things gate it:
|
|
1632
|
+
*
|
|
1633
|
+
* - **browser autoplay policy**: unmuted audio only plays after a user
|
|
1634
|
+
* gesture, so before the first interaction the element is left paused
|
|
1635
|
+
* and nothing is reported as broken. Re-arm on the first interaction.
|
|
1636
|
+
* - **consent**: the `media:narration` service is requested at start-up
|
|
1637
|
+
* (see `_startConsentAsk`); a denied or unanswered service means no
|
|
1638
|
+
* playback, and the story still works.
|
|
1639
|
+
*/
|
|
1640
|
+
_playNarration(slide: StorymapSlide | undefined, allow_without_gesture = false) {
|
|
1641
|
+
const url = (slide?.narration as { url?: string } | null)?.url;
|
|
1642
|
+
this._stopNarration();
|
|
1643
|
+
if (!url || this._disposed || !this._narration_allowed) return;
|
|
1644
|
+
if (!allow_without_gesture && !this._has_user_gesture()) {
|
|
1645
|
+
this._replayNarrationAfterGesture = true;
|
|
1646
|
+
return;
|
|
1647
|
+
}
|
|
1648
|
+
const el = this._narrationElement();
|
|
1649
|
+
el.src = url;
|
|
1650
|
+
el.currentTime = 0;
|
|
1651
|
+
const token = ++this._narration_token;
|
|
1652
|
+
// a rejected play() (policy, network) must not surface as an
|
|
1653
|
+
// unhandled rejection
|
|
1654
|
+
void el.play()?.catch?.(() => {
|
|
1655
|
+
if (token === this._narration_token) this._replayNarrationAfterGesture = true;
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
_narrationElement(): HTMLAudioElement {
|
|
1660
|
+
if (this._narration_el === null) {
|
|
1661
|
+
this._narration_el = document.createElement("audio");
|
|
1662
|
+
this._narration_el.className = "vco-media-item vco-narration";
|
|
1663
|
+
this._narration_el.preload = "none";
|
|
1664
|
+
this._narration_el.setAttribute("aria-hidden", "true");
|
|
1665
|
+
}
|
|
1666
|
+
return this._narration_el;
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
_stopNarration() {
|
|
1670
|
+
this._narration_token++;
|
|
1671
|
+
const el = this._narration_el;
|
|
1672
|
+
if (!el) return;
|
|
1673
|
+
el.pause();
|
|
1674
|
+
el.removeAttribute("src");
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
/** A gesture has happened, so unmuted audio is allowed from now on. */
|
|
1678
|
+
_note_user_gesture_for_narration() {
|
|
1679
|
+
if (this._disposed) return;
|
|
1680
|
+
this._narration_allowed = this._narration_allowed && true;
|
|
1681
|
+
if (this._replayNarrationAfterGesture) {
|
|
1682
|
+
this._replayNarrationAfterGesture = false;
|
|
1683
|
+
this._playNarration(this.data.slides?.[this.current_slide], true);
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
_has_user_gesture(): boolean {
|
|
1688
|
+
return this._user_gestured;
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
_startAutoplay() {
|
|
1692
|
+
this._stopAutoplay();
|
|
1693
|
+
this._autoplay_stopped = false;
|
|
1694
|
+
// a denied media:narration service means no narration for this story
|
|
1695
|
+
const manager = consentManagerOf(this.options);
|
|
1696
|
+
if (manager && this.options.consent_required) {
|
|
1697
|
+
this._narration_allowed = !manager.isDenied(narrationService().key);
|
|
1698
|
+
}
|
|
1699
|
+
// browsers only allow unmuted audio after a user gesture; the first
|
|
1700
|
+
// interaction arms narration for the rest of the story
|
|
1701
|
+
if (!this._user_gestured) {
|
|
1702
|
+
const on_gesture = () => {
|
|
1703
|
+
this._user_gestured = true;
|
|
1704
|
+
this._note_user_gesture_for_narration();
|
|
1705
|
+
this._el.container.removeEventListener("pointerdown", on_gesture);
|
|
1706
|
+
this._el.container.removeEventListener("keydown", on_gesture);
|
|
1707
|
+
};
|
|
1708
|
+
this._el.container.addEventListener("pointerdown", on_gesture, { once: true });
|
|
1709
|
+
this._el.container.addEventListener("keydown", on_gesture, { once: true });
|
|
1710
|
+
}
|
|
1711
|
+
// prefers-reduced-motion: no autoplay (WCAG 2.2.2)
|
|
1712
|
+
if (this.options.autoplay > 0 && !prefersReducedMotion()) {
|
|
1713
|
+
// any user interaction stops autoplay permanently
|
|
1714
|
+
const stop = () => {
|
|
1715
|
+
this._autoplay_stopped = true;
|
|
1716
|
+
this._stopAutoplay();
|
|
1717
|
+
};
|
|
1718
|
+
this._el.container.addEventListener("pointerdown", stop, { once: true });
|
|
1719
|
+
this._el.container.addEventListener("keydown", stop, { once: true });
|
|
1720
|
+
this._el.container.addEventListener("touchstart", stop, { once: true });
|
|
1721
|
+
this._scheduleAutoplay();
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
/**
|
|
1726
|
+
* Arm autoplay for the current slide.
|
|
1727
|
+
*
|
|
1728
|
+
* With `autoplay_media` on and a slide whose media is playable audio or
|
|
1729
|
+
* video, the advance waits for that media to end instead of using the
|
|
1730
|
+
* `autoplay` millisecond timer. The timer is **kept as a fallback** in
|
|
1731
|
+
* that case too: a media element that never fires `ended` (a stalled
|
|
1732
|
+
* fetch, a codec the browser cannot play, or a slide whose media was
|
|
1733
|
+
* never loaded because the visitor skipped past it) would otherwise stop
|
|
1734
|
+
* the story dead. So both are armed, whichever fires first advances, and
|
|
1735
|
+
* the other is cleared.
|
|
1736
|
+
*/
|
|
1737
|
+
_scheduleAutoplay() {
|
|
1738
|
+
this._stopAutoplay();
|
|
1739
|
+
if (!(this.options.autoplay > 0) || this._autoplay_stopped) return;
|
|
1740
|
+
|
|
1741
|
+
const advance = () => {
|
|
1742
|
+
this._stopAutoplay();
|
|
1743
|
+
if (this.current_slide + 1 < this.data.slides.length) {
|
|
1744
|
+
this.goTo(this.current_slide + 1);
|
|
1745
|
+
this._scheduleAutoplay();
|
|
1746
|
+
}
|
|
1747
|
+
};
|
|
1748
|
+
this._autoplay_advance = advance;
|
|
1749
|
+
|
|
1750
|
+
if (this.options.autoplay_media) {
|
|
1751
|
+
const slide = this._storyslider?._slides?.[this.current_slide];
|
|
1752
|
+
if (slide?.hasPlayableMedia?.()) {
|
|
1753
|
+
slide.onMediaEnded(advance);
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
this._autoplay_timer = setTimeout(advance, this.options.autoplay);
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
_stopAutoplay() {
|
|
1761
|
+
if (this._autoplay_timer) {
|
|
1762
|
+
clearTimeout(this._autoplay_timer);
|
|
1763
|
+
this._autoplay_timer = null;
|
|
1764
|
+
}
|
|
1765
|
+
this._autoplay_advance = null;
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
/**
|
|
1769
|
+
* The current slide's `uniqueid`, or null when the data carries none.
|
|
1770
|
+
*
|
|
1771
|
+
* A storymap-JSON slide usually has no `uniqueid` — the slider generates
|
|
1772
|
+
* one per slide for its own DOM ids, but that is not a shareable identity,
|
|
1773
|
+
* so it is deliberately not used here. A IIIF manifest supplies one per
|
|
1774
|
+
* canvas (§2.3), and that is what a deep link should name.
|
|
1775
|
+
*/
|
|
1776
|
+
_currentSlideId(): string | null {
|
|
1777
|
+
const id = this.data?.slides?.[this.current_slide]?.uniqueid;
|
|
1778
|
+
return typeof id === "string" && id !== "" ? id : null;
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
/** The current slide as a Content State, or null when it has no id. */
|
|
1782
|
+
_currentContentState(): ContentState | null {
|
|
1783
|
+
const id = this._currentSlideId();
|
|
1784
|
+
if (id === null) return null;
|
|
1785
|
+
const region = this.data.slides[this.current_slide]?.location?.region;
|
|
1786
|
+
return Array.isArray(region) && region.length === 4
|
|
1787
|
+
? { id, region: region as [number, number, number, number] }
|
|
1788
|
+
: { id };
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
/**
|
|
1792
|
+
* Keep the URL in sync with the current slide: the `#slide-…` hash and,
|
|
1793
|
+
* when the slide has a shareable id, an `iiif-content` parameter (§5.1).
|
|
1794
|
+
*
|
|
1795
|
+
* The query string is rewritten rather than replaced, because pages like
|
|
1796
|
+
* the embed player and the test harness carry their own configuration in
|
|
1797
|
+
* it (`?url=…`, `?example=…`, `?manifest=…`).
|
|
1798
|
+
*/
|
|
1799
|
+
_syncHash() {
|
|
1800
|
+
try {
|
|
1801
|
+
const state = this._currentContentState();
|
|
1802
|
+
const params = new URLSearchParams(window.location.search);
|
|
1803
|
+
if (state !== null) {
|
|
1804
|
+
params.set(
|
|
1805
|
+
CONTENT_STATE_PARAM,
|
|
1806
|
+
formatContentState(
|
|
1807
|
+
state,
|
|
1808
|
+
(this.data as { uniqueid?: string }).uniqueid ?? null,
|
|
1809
|
+
),
|
|
1810
|
+
);
|
|
1811
|
+
} else {
|
|
1812
|
+
// a storymap-JSON slide with no id cannot be named, so the
|
|
1813
|
+
// parameter would only ever point at the wrong stop
|
|
1814
|
+
params.delete(CONTENT_STATE_PARAM);
|
|
1815
|
+
}
|
|
1816
|
+
const query = params.toString();
|
|
1817
|
+
history.replaceState(
|
|
1818
|
+
null,
|
|
1819
|
+
"",
|
|
1820
|
+
window.location.pathname +
|
|
1821
|
+
(query === "" ? "" : "?" + query) +
|
|
1822
|
+
"#slide-" +
|
|
1823
|
+
(state !== null ? encodeURI(state.id) : this.current_slide),
|
|
1824
|
+
);
|
|
1825
|
+
} catch {
|
|
1826
|
+
// non-browser or sandboxed contexts
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
/**
|
|
1831
|
+
* A `#slide-…` hash or an `iiif-content` parameter deep-links the storymap
|
|
1832
|
+
* (applied on load + hashchange).
|
|
1833
|
+
*
|
|
1834
|
+
* The hash token is an index when it is all digits and a `uniqueid`
|
|
1835
|
+
* otherwise, which is unambiguous because an index never contains anything
|
|
1836
|
+
* else. The index form stays supported, so links shared before ids were
|
|
1837
|
+
* emitted keep working; they just stop being stable the moment a slide is
|
|
1838
|
+
* inserted.
|
|
1839
|
+
*/
|
|
1840
|
+
_applyHashSlide(captured?: { state: ContentState | null; hash: string } | null): boolean {
|
|
1841
|
+
const link = captured ?? {
|
|
1842
|
+
state: parseContentState(
|
|
1843
|
+
new URLSearchParams(window.location.search).get(CONTENT_STATE_PARAM),
|
|
1844
|
+
),
|
|
1845
|
+
hash: window.location.hash,
|
|
1846
|
+
};
|
|
1847
|
+
const target = this._resolveDeepLink(link.state, link.hash);
|
|
1848
|
+
if (target === null || target === this.current_slide) return false;
|
|
1849
|
+
this.goTo(target);
|
|
1850
|
+
return true;
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
/**
|
|
1854
|
+
* A content state or hash token as a slide index, or null when it names
|
|
1855
|
+
* nothing. A content state wins over the hash, being the standard spelling.
|
|
1856
|
+
*/
|
|
1857
|
+
_resolveDeepLink(state: ContentState | null, hash: string): number | null {
|
|
1858
|
+
if (state !== null) {
|
|
1859
|
+
const index = this._slideIndexById(state.id);
|
|
1860
|
+
if (index !== null) return index;
|
|
1861
|
+
console.warn(
|
|
1862
|
+
`StoryMapJS: the content state names a canvas this storymap does not have: ${state.id}`,
|
|
1863
|
+
);
|
|
1864
|
+
}
|
|
1865
|
+
const match = /^#slide-(.+)$/.exec(hash);
|
|
1866
|
+
if (match === null) return null;
|
|
1867
|
+
const token = match[1];
|
|
1868
|
+
if (/^\d+$/.test(token)) {
|
|
1869
|
+
const n = parseInt(token, 10);
|
|
1870
|
+
return n >= 0 && n < this.data.slides.length ? n : null;
|
|
1871
|
+
}
|
|
1872
|
+
return this._slideIndexById(decodeURIComponent(token));
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
/** A canvas id as a slide index, or null. */
|
|
1876
|
+
_slideIndexById(id: string): number | null {
|
|
1877
|
+
const slides = this.data?.slides ?? [];
|
|
1878
|
+
for (let i = 0; i < slides.length; i++) {
|
|
1879
|
+
if (slides[i]?.uniqueid === id) return i;
|
|
1880
|
+
}
|
|
1881
|
+
return null;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
/* Resize handling
|
|
1885
|
+
================================================== */
|
|
1886
|
+
_initResizeHandling() {
|
|
1887
|
+
if (!this.options.trackResize) {
|
|
1888
|
+
return;
|
|
1889
|
+
}
|
|
1890
|
+
// Leading + trailing debounce: the first resize event re-layouts
|
|
1891
|
+
// immediately (instant feedback), the trailing one settles after the
|
|
1892
|
+
// resize burst ends.
|
|
1893
|
+
const onResize = () => {
|
|
1894
|
+
if (this._resize_timer) {
|
|
1895
|
+
clearTimeout(this._resize_timer);
|
|
1896
|
+
} else {
|
|
1897
|
+
this.updateDisplay();
|
|
1898
|
+
}
|
|
1899
|
+
this._resize_timer = setTimeout(() => {
|
|
1900
|
+
this._resize_timer = null;
|
|
1901
|
+
this.updateDisplay();
|
|
1902
|
+
}, 200);
|
|
1903
|
+
};
|
|
1904
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
1905
|
+
// covers containers resized by their embedding layout
|
|
1906
|
+
this._resize_observer = new ResizeObserver(onResize);
|
|
1907
|
+
this._resize_observer.observe(this._el.container);
|
|
1908
|
+
}
|
|
1909
|
+
this._on_resize = onResize;
|
|
1910
|
+
window.addEventListener("resize", onResize);
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
/**
|
|
1914
|
+
* Resolve a caller-supplied map element (options.map_options.element).
|
|
1915
|
+
* The passed element is "replaced by the real one": it is adopted as the
|
|
1916
|
+
* map container (given the vco-map class) and moved into place between
|
|
1917
|
+
* the menubar and the story slider.
|
|
1918
|
+
*/
|
|
1919
|
+
/**
|
|
1920
|
+
* The map pane, for the code paths that only run when there is a map.
|
|
1921
|
+
* `_el.map` is null exactly when the engine is; see `_map_required()`.
|
|
1922
|
+
*/
|
|
1923
|
+
_map_el(): HTMLElement {
|
|
1924
|
+
if (!this._el.map) {
|
|
1925
|
+
throw new Error(
|
|
1926
|
+
'StoryMapJS: this operation needs a map pane, but the story has none (map_type: "none").',
|
|
1927
|
+
);
|
|
1928
|
+
}
|
|
1929
|
+
return this._el.map;
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
/**
|
|
1933
|
+
* The map engine, for the code paths that only run when there is a map.
|
|
1934
|
+
* Throws rather than returning null: reaching one of these from a mapless
|
|
1935
|
+
* story is a bug in this class, and a loud one, rather than a layout that
|
|
1936
|
+
* quietly misbehaves. Public entry points use `this._map?.` instead.
|
|
1937
|
+
*/
|
|
1938
|
+
_map_required(): OpenLayersMap {
|
|
1939
|
+
if (!this._map) {
|
|
1940
|
+
throw new Error(
|
|
1941
|
+
'StoryMapJS: this operation needs a map, but the story has none (map_type: "none").',
|
|
1942
|
+
);
|
|
1943
|
+
}
|
|
1944
|
+
return this._map;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
_resolveMapElement(): HTMLElement | null {
|
|
1948
|
+
const element = this.options.map_options?.element;
|
|
1949
|
+
if (!element) {
|
|
1950
|
+
return null;
|
|
1951
|
+
}
|
|
1952
|
+
const el = typeof element === "string" ? Dom.get(element) : element;
|
|
1953
|
+
if (!el) {
|
|
1954
|
+
console.error("StoryMapJS: map_options.element not found: " + String(element));
|
|
1955
|
+
return null;
|
|
1956
|
+
}
|
|
1957
|
+
el.classList.add("vco-map");
|
|
1958
|
+
this._el.menubar.after(el);
|
|
1959
|
+
return el;
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
_onTitle(e: unknown) {
|
|
1963
|
+
this.fire("title", e);
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
_onColorChange(e: { color?: unknown; image?: unknown }) {
|
|
1967
|
+
if (e.color || e.image) {
|
|
1968
|
+
this._menubar.setColor(true);
|
|
1969
|
+
} else {
|
|
1970
|
+
this._menubar.setColor(false);
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
_onSlideChange(e: { current_slide: number }) {
|
|
1975
|
+
if (this.current_slide !== e.current_slide) {
|
|
1976
|
+
// the slider already moved itself
|
|
1977
|
+
this._navigate(e.current_slide, { navigate: "slider" });
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
_onMapChange(e: { current_marker: number }) {
|
|
1982
|
+
if (this.current_slide !== e.current_marker) {
|
|
1983
|
+
// the map already moved itself
|
|
1984
|
+
this._navigate(e.current_marker, { navigate: "map" });
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
_updateProgress() {
|
|
1989
|
+
if (this.options.show_progress && this._menubar) {
|
|
1990
|
+
this._menubar.setProgress(this.current_slide, this.data.slides.length);
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
_onOverview(e?: unknown) {
|
|
1995
|
+
this._map?.markerOverview();
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
/**
|
|
1999
|
+
* Toggle the native fullscreen API on the storymap container and keep the
|
|
2000
|
+
* menubar button state in sync.
|
|
2001
|
+
*/
|
|
2002
|
+
_onFullscreenToggle(e?: unknown) {
|
|
2003
|
+
if (document.fullscreenElement === this._el.container) {
|
|
2004
|
+
document.exitFullscreen().catch((err: unknown) => {
|
|
2005
|
+
console.error("StoryMapJS: could not exit fullscreen", err);
|
|
2006
|
+
});
|
|
2007
|
+
} else {
|
|
2008
|
+
this._el.container.requestFullscreen().catch((err: unknown) => {
|
|
2009
|
+
console.error("StoryMapJS: could not enter fullscreen", err);
|
|
2010
|
+
});
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
_onFullscreenChange() {
|
|
2015
|
+
const active = document.fullscreenElement === this._el.container;
|
|
2016
|
+
this._menubar.setFullscreenState(active);
|
|
2017
|
+
// re-measure once the browser has applied the fullscreen layout
|
|
2018
|
+
// (the container resizes asynchronously after the event)
|
|
2019
|
+
requestAnimationFrame(() => {
|
|
2020
|
+
requestAnimationFrame(() => {
|
|
2021
|
+
this.updateDisplay();
|
|
2022
|
+
});
|
|
2023
|
+
});
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
_onBackToStart(e?: unknown) {
|
|
2027
|
+
if (this.current_slide === 0) {
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
this._navigate(0);
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
_onMenuBarCollapse(e: { y?: number; collapsed?: boolean }) {
|
|
2034
|
+
// `y` is the menubar position, not a map height; `collapsed` is the
|
|
2035
|
+
// authoritative signal. Older menubars omit it, so infer it from a
|
|
2036
|
+
// near-zero y for backwards compatibility.
|
|
2037
|
+
this._collapsed = e.collapsed !== undefined ? e.collapsed : e.y !== undefined && e.y < 5;
|
|
2038
|
+
this._updateDisplay(undefined, true);
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
_onMapLoaded() {
|
|
2042
|
+
this._loaded.map = true;
|
|
2043
|
+
this._onLoaded();
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
_onStorySliderLoaded() {
|
|
2047
|
+
this._loaded.storyslider = true;
|
|
2048
|
+
this._onLoaded();
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
/**
|
|
2052
|
+
* Compute and display the great-circle distance of the marker route
|
|
2053
|
+
* (issue #341); hidden when `show_distance` is off.
|
|
2054
|
+
*/
|
|
2055
|
+
_updateDistance() {
|
|
2056
|
+
if (!this.options.show_distance) {
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
const km = this._map?.getRouteDistance();
|
|
2060
|
+
this._menubar.setDistance(km);
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
/**
|
|
2064
|
+
* The URL is ours to own as soon as the story is: the deep link is read
|
|
2065
|
+
* and the hash is kept in step.
|
|
2066
|
+
*
|
|
2067
|
+
* This deliberately does **not** wait for the map. The map only reports
|
|
2068
|
+
* itself loaded on OpenLayers' first `loadend`, which is the first paint of
|
|
2069
|
+
* the base tiles — so a deep link used to be applied only once
|
|
2070
|
+
* `tile.openstreetmap.org` answered, and on a slow or unreachable tile
|
|
2071
|
+
* host it was never applied at all. Nothing about a URL depends on imagery:
|
|
2072
|
+
* the data and the slider are what it needs, and both are ready before the
|
|
2073
|
+
* map is. `_applyHashSlide` → `goTo` → `_navigate` already tolerates a map
|
|
2074
|
+
* that has not reported loaded, and the map re-syncs when it does.
|
|
2075
|
+
*/
|
|
2076
|
+
_initHash() {
|
|
2077
|
+
if (this._hash_initialized || !this._loaded.storyslider) {
|
|
2078
|
+
return;
|
|
2079
|
+
}
|
|
2080
|
+
this._hash_initialized = true;
|
|
2081
|
+
// a #slide-N hash or an iiif-content parameter deep-links the initial
|
|
2082
|
+
// slide (issue #146) and keeps working through the browser
|
|
2083
|
+
// back/forward buttons
|
|
2084
|
+
this._applyHashSlide(this._initial_deep_link);
|
|
2085
|
+
// from here on the URL is ours to keep in sync, so a hashchange means
|
|
2086
|
+
// the user moved and can be read live
|
|
2087
|
+
this._on_hashchange = () => this._applyHashSlide();
|
|
2088
|
+
window.addEventListener("hashchange", this._on_hashchange);
|
|
2089
|
+
this._syncHash();
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
_onLoaded() {
|
|
2093
|
+
this._initHash();
|
|
2094
|
+
// the slider is always required; the map only if there is one, so a
|
|
2095
|
+
// mapless story still fires `loaded` and gets its hash, progress and
|
|
2096
|
+
// distance applied
|
|
2097
|
+
if (this._loaded.storyslider && (this._map_disabled || this._loaded.map)) {
|
|
2098
|
+
this.fire("loaded", this.data);
|
|
2099
|
+
this._updateProgress();
|
|
2100
|
+
this._updateDistance();
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
export default class StoryMap extends Evented(StoryMapBase) {
|
|
2106
|
+
/**
|
|
2107
|
+
* The library base path (the directory containing the module).
|
|
2108
|
+
* Derived from `import.meta.url`, which works both for the source module
|
|
2109
|
+
* (src/main.ts) and the built ESM bundle (js/storymap.js).
|
|
2110
|
+
*/
|
|
2111
|
+
static SCRIPT_PATH = new URL(/* @vite-ignore */ "../", import.meta.url).href;
|
|
2112
|
+
|
|
2113
|
+
constructor(...args: ConstructorParameters<typeof StoryMapBase>) {
|
|
2114
|
+
super(...args);
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
export { StoryMap };
|