@projektemacher/storymapjs 0.10.7 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG +431 -19
- package/README.md +168 -17
- package/dist/assets/demo-bWnOyfUw.js +1 -0
- package/dist/assets/harness-C3JUv5Ii.js +1 -0
- package/dist/assets/harness-multi-XZNex3tb.js +1 -0
- package/dist/assets/harness-support-CjfZIVeb.js +1 -0
- package/dist/assets/index-wHPqGrBS.js +1 -0
- package/dist/assets/main-DQ32hWBM.js +17 -0
- package/dist/context.json +23 -9
- package/dist/css/fonts/files/amatic-sc-cyrillic-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/amatic-sc-cyrillic-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/amatic-sc-hebrew-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/amatic-sc-hebrew-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/amatic-sc-vietnamese-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/amatic-sc-vietnamese-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-100-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-100-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-200-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-200-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-300-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-300-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-500-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-500-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-600-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-600-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-800-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-800-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-900-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-900-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-100-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-100-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-200-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-200-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-300-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-300-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-500-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-500-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-600-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-600-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-800-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-800-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-900-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-900-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-100-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-100-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-200-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-200-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-300-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-300-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-500-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-500-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-600-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-600-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-800-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-800-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-900-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-900-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-arabic-200-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-arabic-300-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-arabic-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-arabic-500-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-arabic-600-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-arabic-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-arabic-800-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-arabic-900-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-200-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-300-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-500-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-600-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-800-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-900-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-ext-200-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-ext-300-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-ext-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-ext-500-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-ext-600-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-ext-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-ext-800-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-ext-900-normal.woff2 +0 -0
- package/dist/css/fonts/files/dancing-script-vietnamese-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/eb-garamond-cyrillic-ext-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/eb-garamond-cyrillic-ext-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/eb-garamond-cyrillic-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/eb-garamond-cyrillic-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/eb-garamond-greek-ext-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/eb-garamond-greek-ext-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/eb-garamond-greek-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/eb-garamond-greek-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/eb-garamond-vietnamese-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/eb-garamond-vietnamese-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-cyrillic-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-cyrillic-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-cyrillic-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-cyrillic-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-cyrillic-ext-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-cyrillic-ext-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-cyrillic-ext-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-cyrillic-ext-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-greek-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-greek-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-greek-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-greek-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-greek-ext-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-greek-ext-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-greek-ext-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-greek-ext-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-vietnamese-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-vietnamese-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-vietnamese-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-vietnamese-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/old-standard-tt-cyrillic-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/old-standard-tt-cyrillic-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/old-standard-tt-cyrillic-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/old-standard-tt-cyrillic-ext-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/old-standard-tt-cyrillic-ext-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/old-standard-tt-cyrillic-ext-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/old-standard-tt-vietnamese-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/old-standard-tt-vietnamese-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/old-standard-tt-vietnamese-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-cyrillic-ext-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-cyrillic-ext-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-cyrillic-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-cyrillic-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-greek-ext-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-greek-ext-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-greek-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-greek-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-hebrew-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-hebrew-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-vietnamese-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-vietnamese-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-cyrillic-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-cyrillic-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-cyrillic-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-cyrillic-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-cyrillic-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-cyrillic-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-cyrillic-900-italic.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-cyrillic-900-normal.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-vietnamese-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-vietnamese-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-vietnamese-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-vietnamese-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-vietnamese-900-italic.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-vietnamese-900-normal.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-vietnamese-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-vietnamese-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-cyrillic-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-cyrillic-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-cyrillic-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-cyrillic-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-cyrillic-ext-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-cyrillic-ext-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-cyrillic-ext-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-cyrillic-ext-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-narrow-cyrillic-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-narrow-cyrillic-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-narrow-cyrillic-ext-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-narrow-cyrillic-ext-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-serif-cyrillic-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/pt-serif-cyrillic-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-serif-cyrillic-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/pt-serif-cyrillic-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-serif-cyrillic-ext-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/pt-serif-cyrillic-ext-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-serif-cyrillic-ext-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/pt-serif-cyrillic-ext-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/raleway-cyrillic-ext-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/raleway-cyrillic-ext-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/raleway-cyrillic-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/raleway-cyrillic-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/raleway-vietnamese-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/raleway-vietnamese-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/roboto-slab-cyrillic-ext-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/roboto-slab-cyrillic-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/roboto-slab-greek-ext-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/roboto-slab-greek-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/roboto-slab-vietnamese-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-300-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-300-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-500-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-500-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-ext-300-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-ext-300-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-ext-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-ext-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-ext-500-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-ext-500-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-ext-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-ext-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-300-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-300-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-500-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-500-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-ext-300-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-ext-300-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-ext-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-ext-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-ext-500-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-ext-500-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-ext-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-ext-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/unica-one-vietnamese-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/vollkorn-cyrillic-ext-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/vollkorn-cyrillic-ext-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/vollkorn-cyrillic-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/vollkorn-cyrillic-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/vollkorn-greek-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/vollkorn-greek-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/vollkorn-vietnamese-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/vollkorn-vietnamese-wght-normal.woff2 +0 -0
- package/dist/css/fonts/font.abril-droidsans.css +3 -0
- package/dist/css/fonts/font.amatic-andika.css +57 -0
- package/dist/css/fonts/font.bitter-raleway.css +111 -0
- package/dist/css/fonts/font.cairo.css +312 -0
- package/dist/css/fonts/font.clicker-garamond.css +93 -0
- package/dist/css/fonts/font.dancing-ledger.css +12 -0
- package/dist/css/fonts/font.default.css +183 -0
- package/dist/css/fonts/font.fjalla-average.css +3 -0
- package/dist/css/fonts/font.georgia-helvetica.css +3 -0
- package/dist/css/fonts/font.knightlab.css +642 -0
- package/dist/css/fonts/font.lustria-lato.css +3 -0
- package/dist/css/fonts/font.medula-lato.css +3 -0
- package/dist/css/fonts/font.oldstandard.css +84 -0
- package/dist/css/fonts/font.opensans-gentiumbook.css +183 -0
- package/dist/css/fonts/font.playfair-faunaone.css +39 -0
- package/dist/css/fonts/font.playfair.css +147 -0
- package/dist/css/fonts/font.pt.css +183 -0
- package/dist/css/fonts/font.roboto-megrim.css +3 -0
- package/dist/css/fonts/font.rufina-sintony.css +3 -0
- package/dist/css/fonts/font.ubuntu.css +291 -0
- package/dist/css/fonts/font.unicaone-vollkorn.css +84 -0
- package/dist/css/storymap.css +1 -1
- package/dist/demo.html +3 -2
- package/dist/docs/api/.nojekyll +1 -0
- package/dist/docs/api/assets/custom.css +47 -0
- package/dist/docs/api/assets/hierarchy.js +1 -0
- package/dist/docs/api/assets/highlight.css +57 -0
- package/dist/docs/api/assets/icons.js +18 -0
- package/dist/docs/api/assets/icons.svg +1 -0
- package/dist/docs/api/assets/main.js +60 -0
- package/dist/docs/api/assets/navigation.js +1 -0
- package/dist/docs/api/assets/search.js +1 -0
- package/dist/docs/api/assets/style.css +1648 -0
- package/dist/docs/api/classes/StoryMap.html +2112 -0
- package/dist/docs/api/functions/MediaType.html +51 -0
- package/dist/docs/api/functions/isPresentation3Collection.html +47 -0
- package/dist/docs/api/functions/isPresentation3Manifest.html +53 -0
- package/dist/docs/api/functions/isSlideshowCollection.html +51 -0
- package/dist/docs/api/functions/loadCSS.html +63 -0
- package/dist/docs/api/functions/manifestToStorymapData.html +49 -0
- package/dist/docs/api/functions/setLanguage.html +54 -0
- package/dist/docs/api/functions/slideshowToStorymapData.html +51 -0
- package/dist/docs/api/functions/slideshowVersion.html +48 -0
- package/dist/docs/api/functions/storymapToManifest.html +57 -0
- package/dist/docs/api/functions/validateStorymap.html +53 -0
- package/dist/docs/api/functions/validateStorymapAndReport.html +54 -0
- package/dist/docs/api/hierarchy.html +28 -0
- package/dist/docs/api/index.html +300 -0
- package/dist/docs/api/interfaces/AnimationHandle.html +60 -0
- package/dist/docs/api/interfaces/ImagereadyPayload.html +71 -0
- package/dist/docs/api/interfaces/LanguageEntry.html +102 -0
- package/dist/docs/api/interfaces/LatLngLiteral.html +73 -0
- package/dist/docs/api/interfaces/LoadOptions.html +59 -0
- package/dist/docs/api/interfaces/MapEvents.html +116 -0
- package/dist/docs/api/interfaces/MapMarkerEvents.html +66 -0
- package/dist/docs/api/interfaces/MarkerEventPayload.html +57 -0
- package/dist/docs/api/interfaces/MediaEvents.html +78 -0
- package/dist/docs/api/interfaces/MenuBarEvents.html +114 -0
- package/dist/docs/api/interfaces/MessageEvents.html +72 -0
- package/dist/docs/api/interfaces/OlLayer.html +1400 -0
- package/dist/docs/api/interfaces/OlMap.html +1656 -0
- package/dist/docs/api/interfaces/OlProjection.html +332 -0
- package/dist/docs/api/interfaces/OlSource.html +784 -0
- package/dist/docs/api/interfaces/OlTileLayer.html +1497 -0
- package/dist/docs/api/interfaces/OlVectorLayer.html +1559 -0
- package/dist/docs/api/interfaces/OlView.html +1958 -0
- package/dist/docs/api/interfaces/SlideBackgroundState.html +66 -0
- package/dist/docs/api/interfaces/SlideEvents.html +78 -0
- package/dist/docs/api/interfaces/SlideNavEvents.html +72 -0
- package/dist/docs/api/interfaces/SlideshowPlayerSettings.html +182 -0
- package/dist/docs/api/interfaces/SlideshowToStorymapResult.html +60 -0
- package/dist/docs/api/interfaces/SlideshowTranslatorOptions.html +65 -0
- package/dist/docs/api/interfaces/SlideshowWarning.html +72 -0
- package/dist/docs/api/interfaces/StoryMapEvents.html +146 -0
- package/dist/docs/api/interfaces/StoryMapMarker.html +763 -0
- package/dist/docs/api/interfaces/StorySliderEvents.html +102 -0
- package/dist/docs/api/interfaces/StorymapBasemap.html +69 -0
- package/dist/docs/api/interfaces/StorymapData.html +77 -0
- package/dist/docs/api/interfaces/StorymapDataWrapper.html +60 -0
- package/dist/docs/api/interfaces/StorymapDocument.html +68 -0
- package/dist/docs/api/interfaces/StorymapError.html +63 -0
- package/dist/docs/api/interfaces/StorymapGeoreference.html +93 -0
- package/dist/docs/api/interfaces/StorymapGeoreferenceBody.html +74 -0
- package/dist/docs/api/interfaces/StorymapGroundControlPoint.html +69 -0
- package/dist/docs/api/interfaces/StorymapLanguageMap.html +57 -0
- package/dist/docs/api/interfaces/StorymapManifest.html +118 -0
- package/dist/docs/api/interfaces/StorymapManifestAnnotation.html +137 -0
- package/dist/docs/api/interfaces/StorymapManifestBackground.html +81 -0
- package/dist/docs/api/interfaces/StorymapManifestCanvas.html +188 -0
- package/dist/docs/api/interfaces/StorymapManifestContentResource.html +97 -0
- package/dist/docs/api/interfaces/StorymapManifestFeatureCollection.html +82 -0
- package/dist/docs/api/interfaces/StorymapManifestGeoreferencing.html +87 -0
- package/dist/docs/api/interfaces/StorymapManifestImageService.html +69 -0
- package/dist/docs/api/interfaces/StorymapManifestRange.html +75 -0
- package/dist/docs/api/interfaces/StorymapManifestService.html +77 -0
- package/dist/docs/api/interfaces/StorymapManifestSpecificResource.html +69 -0
- package/dist/docs/api/interfaces/StorymapManifestStatement.html +63 -0
- package/dist/docs/api/interfaces/StorymapMapOptions.html +92 -0
- package/dist/docs/api/interfaces/StorymapOptions.html +734 -0
- package/dist/docs/api/interfaces/StorymapOverlayLayer.html +157 -0
- package/dist/docs/api/interfaces/StorymapSlide.html +186 -0
- package/dist/docs/api/interfaces/StorymapSlideBackground.html +72 -0
- package/dist/docs/api/interfaces/StorymapSlideLocation.html +161 -0
- package/dist/docs/api/interfaces/StorymapSlideMarker.html +103 -0
- package/dist/docs/api/interfaces/StorymapSlideMedia.html +149 -0
- package/dist/docs/api/interfaces/StorymapSlideNarration.html +117 -0
- package/dist/docs/api/interfaces/StorymapSlideText.html +72 -0
- package/dist/docs/api/interfaces/StorymapTilejson.html +105 -0
- package/dist/docs/api/interfaces/SwipableEvents.html +102 -0
- package/dist/docs/api/interfaces/TextEvents.html +66 -0
- package/dist/docs/api/media/es.json +31 -0
- package/dist/docs/api/media/events.md +66 -0
- package/dist/docs/api/media/migration-from-knightlab.md +399 -0
- package/dist/docs/api/media/slideshow.md +109 -0
- package/dist/docs/api/media/storymap-as-iiif-manifest.md +1007 -0
- package/dist/docs/api/modules.html +50 -0
- package/dist/docs/api/types/ContentState.html +70 -0
- package/dist/docs/api/types/FiredEvent.html +43 -0
- package/dist/docs/api/types/ImagereadyKind.html +34 -0
- package/dist/docs/api/types/MediaTypeMatch.html +78 -0
- package/dist/docs/api/types/SwipeDirection.html +36 -0
- package/dist/docs/api/types/TileSourceFactory.html +53 -0
- package/dist/docs/authoring.html +306 -0
- package/dist/docs/events.html +263 -0
- package/dist/docs/iiif.html +738 -69
- package/dist/docs/migration.html +360 -11
- package/dist/docs/readme.html +149 -17
- package/dist/embed/index.html +8 -1
- package/dist/examples/font-css-false.json +33 -0
- package/dist/examples/issue-242-nonlatin.json +55 -0
- package/dist/examples/issue-305-start-at-slide-data.json +96 -0
- package/dist/examples/issue-358-iframe.json +57 -0
- package/dist/examples/issue-iiif-geo.json +32 -0
- package/dist/examples/issue-image-alt.json +23 -0
- package/dist/examples/issue-image-region.json +42 -0
- package/dist/examples/issue-image-srcset.json +24 -0
- package/dist/examples/layers-control.json +60 -0
- package/dist/examples/map-area-overlays.json +68 -0
- package/dist/examples/map-template-provider.json +36 -0
- package/dist/examples/multi-media.json +71 -0
- package/dist/examples/no-map.json +44 -0
- package/dist/examples-iiif/annotated-image.json +204 -0
- package/dist/examples-iiif/bob-boilen.json +11 -5
- package/dist/examples-iiif/courbet.json +9 -6
- package/dist/examples-iiif/empty.json +8 -4
- package/dist/examples-iiif/flickr-drewvigal.json +248 -24
- package/dist/examples-iiif/flickr-zach.wise.json +8 -4
- package/dist/examples-iiif/font-css-false.json +114 -0
- package/dist/examples-iiif/georeferenced-layer-unsupported.json +153 -0
- package/dist/examples-iiif/georeferenced-layer.json +318 -0
- package/dist/examples-iiif/iiif-wellcome.json +9 -6
- package/dist/examples-iiif/instagram-chenpamela.json +381 -36
- package/dist/examples-iiif/instagram_brentfoster.json +429 -40
- package/dist/examples-iiif/instagram_couch.json +261 -26
- package/dist/examples-iiif/instagram_joegermuska.json +441 -41
- package/dist/examples-iiif/instagram_matteich.json +261 -26
- package/dist/examples-iiif/instagram_mirandamulligan.json +261 -26
- package/dist/examples-iiif/instagram_raymondmccrea.json +261 -26
- package/dist/examples-iiif/instagram_test.json +261 -26
- package/dist/examples-iiif/issue-134-no-lines.json +9 -5
- package/dist/examples-iiif/issue-177-color-vars.json +9 -5
- package/dist/examples-iiif/issue-211-rtl.json +9 -5
- package/dist/examples-iiif/issue-242-nonlatin.json +239 -0
- package/dist/examples-iiif/issue-272-facebook.json +9 -5
- package/dist/examples-iiif/issue-305-start-at-slide-data.json +305 -0
- package/dist/examples-iiif/issue-305-start-at-slide.json +9 -5
- package/dist/examples-iiif/issue-341-distance.json +9 -5
- package/dist/examples-iiif/issue-358-iframe.json +190 -0
- package/dist/examples-iiif/issue-360-juxtapose.json +22 -7
- package/dist/examples-iiif/issue-368-null-fields.json +9 -5
- package/dist/examples-iiif/issue-375-code-lists.json +9 -5
- package/dist/examples-iiif/issue-381-dateline.json +9 -5
- package/dist/examples-iiif/issue-405-custom-icon.json +203 -0
- package/dist/examples-iiif/issue-425-many-slides.json +9 -5
- package/dist/examples-iiif/issue-428-background.json +20 -8
- package/dist/examples-iiif/issue-435-googlemaps.json +9 -5
- package/dist/examples-iiif/issue-437-documentcloud.json +22 -7
- package/dist/examples-iiif/issue-451-webp.json +9 -5
- package/dist/examples-iiif/issue-455-audio.json +9 -5
- package/dist/examples-iiif/issue-480-shorts.json +9 -5
- package/dist/examples-iiif/issue-506-marker-sync.json +9 -5
- package/dist/examples-iiif/issue-iiif-geo.json +200 -0
- package/dist/examples-iiif/issue-image-alt.json +90 -0
- package/dist/examples-iiif/issue-image-region.json +203 -0
- package/dist/examples-iiif/issue-image-srcset.json +87 -0
- package/dist/examples-iiif/jansteen.json +9 -6
- package/dist/examples-iiif/katrina.json +197 -41
- package/dist/examples-iiif/layers-control.json +226 -0
- package/dist/examples-iiif/map-area-overlays.json +289 -0
- package/dist/examples-iiif/map-template-provider.json +133 -0
- package/dist/examples-iiif/marktwain.json +255 -58
- package/dist/examples-iiif/multi-media.json +237 -0
- package/dist/examples-iiif/no-map.json +148 -0
- package/dist/examples-iiif/population.json +440 -76
- package/dist/examples-iiif/president.json +456 -83
- package/dist/examples-iiif/scroll-hint.json +190 -0
- package/dist/examples-iiif/seurat.json +113 -22
- package/dist/examples-iiif/zoomify-clamp.json +153 -0
- package/dist/examples-slideshow/single.json +79 -0
- package/dist/examples-slideshow/static/pixel.png +0 -0
- package/dist/examples-slideshow/static.json +44 -0
- package/dist/harness-multi.html +17 -0
- package/dist/harness.html +4 -2
- package/dist/index.html +19 -12
- package/dist/js/storymap.d.ts +2669 -307
- package/dist/js/storymap.js +10820 -6196
- package/dist/js/storymap.js.map +1 -1
- package/dist/navplace-properties.json +54 -0
- package/dist/site.css +54 -36
- package/docs/migration-from-knightlab.md +279 -19
- package/docs/storymap-as-iiif-manifest.md +633 -136
- package/package.json +12 -7
- package/schema/storymap.schema.json +733 -80
- package/src/animation/easings.ts +8 -1
- package/src/animation/tween.ts +186 -0
- package/src/core/Browser.ts +6 -5
- package/src/core/Load.ts +54 -2
- package/src/core/Util.ts +257 -193
- package/src/core/mixins.ts +66 -35
- package/src/core/viewers.ts +94 -0
- package/src/dom/DomEvent.ts +27 -8
- package/src/globals.d.ts +8 -0
- package/src/language/Language.ts +206 -25
- package/src/language/locale/.expected-gaps.json +346 -0
- package/src/language/locale/be.json +15 -2
- package/src/language/locale/bg.json +15 -2
- package/src/language/locale/cs.json +15 -2
- package/src/language/locale/de.json +23 -2
- package/src/language/locale/el.json +15 -2
- package/src/language/locale/en.json +17 -3
- package/src/language/locale/es.json +15 -2
- package/src/language/locale/et.json +5 -1
- package/src/language/locale/fr.json +15 -2
- package/src/language/locale/he.json +16 -3
- package/src/language/locale/hu.json +15 -2
- package/src/language/locale/is.json +5 -1
- package/src/language/locale/it.json +15 -2
- package/src/language/locale/jp.json +15 -2
- package/src/language/locale/ko.json +15 -2
- package/src/language/locale/nl.json +15 -2
- package/src/language/locale/nn.json +5 -1
- package/src/language/locale/no.json +15 -2
- package/src/language/locale/pl.json +15 -2
- package/src/language/locale/pt.json +15 -2
- package/src/language/locale/ru.json +15 -2
- package/src/language/locale/sk.json +15 -2
- package/src/language/locale/sr.json +5 -1
- package/src/language/locale/sv.json +15 -2
- package/src/language/locale/tr.json +15 -2
- package/src/language/locale/uk.json +15 -2
- package/src/language/locale/ur.json +5 -1
- package/src/language/locale/zh-cn.json +15 -2
- package/src/language/locale/zh-tw.json +15 -2
- package/src/main.ts +143 -0
- package/src/map/Map.ts +227 -112
- package/src/map/MapMarker.ts +44 -8
- package/src/map/georeference.ts +325 -0
- package/src/map/openlayers/Map.OpenLayers.ts +1601 -263
- package/src/map/openlayers/MapMarker.OpenLayers.ts +348 -24
- package/src/map/openlayers/zoomifyTiles.ts +1 -4
- package/src/map/slideView.ts +149 -0
- package/src/map/types.ts +2 -0
- package/src/media/EmbedUtil.ts +333 -24
- package/src/media/Media.ts +270 -59
- package/src/media/embedId.ts +110 -0
- package/src/media/types/Audio.ts +9 -82
- package/src/media/types/Blockquote.ts +2 -3
- package/src/media/types/DailyMotion.ts +5 -10
- package/src/media/types/DocumentCloud.ts +6 -35
- package/src/media/types/Facebook.ts +3 -4
- package/src/media/types/Flickr.ts +23 -12
- package/src/media/types/GoogleDoc.ts +3 -4
- package/src/media/types/HtmlMedia.ts +233 -0
- package/src/media/types/IFrame.ts +3 -4
- package/src/media/types/Image.ts +87 -9
- package/src/media/types/Juxtapose.ts +14 -5
- package/src/media/types/SoundCloud.ts +28 -6
- package/src/media/types/Text.ts +46 -21
- package/src/media/types/Twitter.ts +35 -20
- package/src/media/types/Video.ts +9 -79
- package/src/media/types/Vimeo.ts +11 -11
- package/src/media/types/Website.ts +23 -13
- package/src/media/types/Wikipedia.ts +18 -8
- package/src/media/types/YouTube.ts +72 -40
- package/src/scss/Typography.scss +16 -18
- package/src/scss/VCO.StoryMap.scss +208 -12
- package/src/scss/Variables.scss +3 -2
- package/src/scss/core/Mixins.scss +5 -63
- package/src/scss/core/Reset.scss +5 -18
- package/src/scss/fonts/font.amatic-andika.scss +3 -0
- package/src/scss/fonts/font.bitter-raleway.scss +6 -0
- package/src/scss/fonts/font.cairo.scss +79 -0
- package/src/scss/fonts/font.clicker-garamond.scss +5 -0
- package/src/scss/fonts/font.dancing-ledger.scss +1 -0
- package/src/scss/fonts/font.knightlab.scss +14 -0
- package/src/scss/fonts/font.oldstandard.scss +6 -0
- package/src/scss/fonts/font.opensans-gentiumbook.scss +5 -0
- package/src/scss/fonts/font.playfair-faunaone.scss +2 -0
- package/src/scss/fonts/font.playfair.scss +4 -0
- package/src/scss/fonts/font.pt.scss +6 -0
- package/src/scss/fonts/font.ubuntu.scss +4 -0
- package/src/scss/fonts/font.unicaone-vollkorn.scss +5 -0
- package/src/scss/map/VCO.Map.scss +1 -1
- package/src/scss/map/VCO.MapMarker.scss +114 -8
- package/src/scss/map/openlayers/VCO.Map.OpenLayers.scss +7 -4
- package/src/scss/media/VCO.Media.scss +14 -17
- package/src/scss/media/types/VCO.Media.Blockquote.scss +1 -1
- package/src/scss/media/types/VCO.Media.Text.scss +13 -5
- package/src/scss/media/types/VCO.Media.Twitter.scss +4 -4
- package/src/scss/media/types/VCO.Media.Wikipedia.scss +3 -19
- package/src/scss/site/site.scss +55 -36
- package/src/scss/slider/VCO.Slide.scss +61 -13
- package/src/scss/slider/VCO.SlideNav.scss +34 -24
- package/src/scss/slider/VCO.StorySlider.scss +53 -1
- package/src/scss/snapmap/VCO.SnapMap.scss +3 -3
- package/src/scss/ui/VCO.Consent.scss +63 -13
- package/src/scss/ui/VCO.Layers.scss +119 -0
- package/src/scss/ui/VCO.MenuBar.Button.scss +143 -14
- package/src/scss/ui/VCO.MenuBar.scss +9 -2
- package/src/scss/ui/VCO.Message.scss +16 -5
- package/src/site/site.ts +2 -1
- package/src/slider/Slide.ts +326 -74
- package/src/slider/SlideNav.ts +54 -9
- package/src/slider/StorySlider.ts +352 -60
- package/src/storymap/Consent.ts +429 -82
- package/src/storymap/StoryMap.ts +2163 -340
- package/src/storymap/content-state.ts +180 -0
- package/src/storymap/font-css.ts +72 -0
- package/src/storymap/from-slideshow.ts +785 -0
- package/src/storymap/iiif-shared.ts +228 -0
- package/src/storymap/iiif.ts +1742 -95
- package/src/storymap/to-iiif.ts +1008 -0
- package/src/storymap/validate.ts +80 -7
- package/src/types.ts +591 -29
- package/src/ui/LayersControl.ts +302 -0
- package/src/ui/MenuBar.ts +325 -61
- package/src/ui/Message.ts +44 -2
- package/src/ui/Swipable.ts +170 -85
- package/dist/assets/demo-D6cTmzn6.js +0 -1
- package/dist/assets/harness-BPFlzutp.js +0 -1
- package/dist/assets/index-DDsPBPZA.js +0 -1
- package/dist/assets/main-Y-MYUSJ4.js +0 -17
- package/src/animation/morpheus.d.ts +0 -41
- package/src/scss/VCO.StoryMap.Dark.scss +0 -138
- package/src/scss/Variables.Dark.scss +0 -56
- package/src/scss/media/types/VCO.Media.Instagram.scss +0 -7
- package/src/scss/media/types/VCO.Media.Profile.scss +0 -5
- package/src/ui/Draggable.ts +0 -374
package/src/storymap/StoryMap.ts
CHANGED
|
@@ -1,43 +1,111 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
mergeData,
|
|
3
|
+
slideTransitionDuration,
|
|
4
|
+
updateData,
|
|
5
|
+
prefersReducedMotion,
|
|
6
|
+
REDUCED_MOTION_QUERY,
|
|
7
|
+
} from "../core/Util";
|
|
2
8
|
import { loadCSS } from "../core/Load";
|
|
3
9
|
import { validateStorymapAndReport } from "./validate";
|
|
4
|
-
import {
|
|
5
|
-
|
|
10
|
+
import {
|
|
11
|
+
CONTENT_STATE_PARAM,
|
|
12
|
+
formatContentState,
|
|
13
|
+
parseContentState,
|
|
14
|
+
type ContentState,
|
|
15
|
+
} from "./content-state";
|
|
16
|
+
import {
|
|
17
|
+
isPresentation3Manifest,
|
|
18
|
+
isPresentation3Collection,
|
|
19
|
+
loadSeeAlso,
|
|
20
|
+
manifestToStorymapData,
|
|
21
|
+
} from "./iiif";
|
|
22
|
+
import {
|
|
23
|
+
followSlideshowPages,
|
|
24
|
+
formatSlideshowWarning,
|
|
25
|
+
isSlideshowCollection,
|
|
26
|
+
slideshowToStorymapData,
|
|
27
|
+
type SlideshowWarning,
|
|
28
|
+
} from "./from-slideshow";
|
|
29
|
+
import { asClampedNumber } from "./iiif-shared";
|
|
30
|
+
import {
|
|
31
|
+
ConsentManager,
|
|
32
|
+
consentManagerOf,
|
|
33
|
+
fontService,
|
|
34
|
+
mediaService,
|
|
35
|
+
narrationService,
|
|
36
|
+
tileService,
|
|
37
|
+
type ConsentService,
|
|
38
|
+
} from "./Consent";
|
|
6
39
|
import Dom from "../dom/Dom";
|
|
7
40
|
import { easeInOutQuint, easeOutStrong } from "../animation/easings";
|
|
8
|
-
import {
|
|
9
|
-
|
|
41
|
+
import {
|
|
42
|
+
setLanguage,
|
|
43
|
+
isRtl,
|
|
44
|
+
currentLanguageCode,
|
|
45
|
+
claimLanguage,
|
|
46
|
+
releaseLanguage,
|
|
47
|
+
isLanguageConflict,
|
|
48
|
+
Language,
|
|
49
|
+
} from "../language/Language";
|
|
50
|
+
import {
|
|
51
|
+
markInteraction,
|
|
52
|
+
ownsPageEvent,
|
|
53
|
+
registerParticipant,
|
|
54
|
+
unregisterParticipant,
|
|
55
|
+
} from "../core/viewers";
|
|
56
|
+
import MediaType from "../media/MediaType";
|
|
57
|
+
import { Evented, type EventedInstance, type FiredEvent } from "../core/mixins";
|
|
10
58
|
import OpenLayersMap from "../map/openlayers/Map.OpenLayers";
|
|
59
|
+
import type { ImagereadyPayload } from "../map/openlayers/Map.OpenLayers";
|
|
11
60
|
import MenuBar from "../ui/MenuBar";
|
|
61
|
+
import type { LayersRow } from "../ui/LayersControl";
|
|
12
62
|
import StorySlider from "../slider/StorySlider";
|
|
13
63
|
import { Browser } from "../core/Browser";
|
|
14
|
-
import Animate from "
|
|
64
|
+
import Animate from "../animation/tween";
|
|
15
65
|
import type { Map as OlMap } from "ol";
|
|
16
|
-
import type
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
66
|
+
import type OlLayer from "ol/layer/Layer";
|
|
67
|
+
import type OpenLayersMapMarker from "../map/openlayers/MapMarker.OpenLayers";
|
|
68
|
+
export { resolveFontCssUrl, isExternalUrl, fontCssOriginal } from "./font-css";
|
|
69
|
+
import { resolveFontCssUrl, isExternalUrl, fontCssOriginal } from "./font-css";
|
|
70
|
+
import type {
|
|
71
|
+
AnimationHandle,
|
|
72
|
+
StorymapBasemap,
|
|
73
|
+
StorymapData,
|
|
74
|
+
StorymapDataWrapper,
|
|
75
|
+
StorymapOptions,
|
|
76
|
+
StorymapSlide,
|
|
77
|
+
StorymapSlideNarration,
|
|
78
|
+
} from "../types";
|
|
79
|
+
|
|
80
|
+
/** Map height in pixels while the menubar has collapsed the map (portrait only). */
|
|
81
|
+
const COLLAPSED_MAP_HEIGHT = 1;
|
|
82
|
+
|
|
83
|
+
export interface StoryMapEvents {
|
|
84
|
+
change: { current_slide: number; current_id: string | null };
|
|
85
|
+
loaded: StorymapData;
|
|
86
|
+
title: { title: string };
|
|
87
|
+
dataloaded: undefined;
|
|
88
|
+
fontLoaded: { font: string };
|
|
89
|
+
transitionstart: { current_slide: number; duration: number };
|
|
90
|
+
transitionend: { current_slide: number };
|
|
91
|
+
error: { message: string; source: string; conflict?: boolean };
|
|
92
|
+
imageready: ImagereadyPayload;
|
|
93
|
+
annotationsloaded: {
|
|
94
|
+
stops: StorymapSlide[];
|
|
95
|
+
searchService: string | null;
|
|
96
|
+
failed: string[];
|
|
97
|
+
};
|
|
98
|
+
markerclick: { marker_number: number; current_slide: number };
|
|
99
|
+
popupopen: { marker_number: number; current_slide: number };
|
|
100
|
+
popupclose: { marker_number: number; current_slide: number };
|
|
101
|
+
basemapchange: { map_type: string; previous: string };
|
|
102
|
+
overlaychange: { index: number; visible: boolean };
|
|
39
103
|
}
|
|
40
104
|
|
|
105
|
+
type StoryMapListener<K extends keyof StoryMapEvents = keyof StoryMapEvents> = (
|
|
106
|
+
e: FiredEvent<StoryMapEvents, K>,
|
|
107
|
+
) => void;
|
|
108
|
+
|
|
41
109
|
/**
|
|
42
110
|
* Interactive StoryMap viewer.
|
|
43
111
|
*
|
|
@@ -46,7 +114,7 @@ export function resolveFontCssUrl(font: string): string {
|
|
|
46
114
|
*
|
|
47
115
|
* @example
|
|
48
116
|
* ```ts
|
|
49
|
-
* import { StoryMap } from "storymapjs";
|
|
117
|
+
* import { StoryMap } from "@projektemacher/storymapjs";
|
|
50
118
|
* // from an inline data object
|
|
51
119
|
* const map = new StoryMap("embed", { storymap: { ... } }, { start_at_slide: 2 });
|
|
52
120
|
* // or straight from a source file (storymap JSON or IIIF manifest)
|
|
@@ -55,37 +123,104 @@ export function resolveFontCssUrl(font: string): string {
|
|
|
55
123
|
*/
|
|
56
124
|
class StoryMapBase {
|
|
57
125
|
declare "_loaded": { storyslider: boolean; map: boolean };
|
|
58
|
-
declare "on": EventedInstance["on"];
|
|
126
|
+
declare "on": EventedInstance<StoryMapEvents>["on"];
|
|
59
127
|
declare "version": string;
|
|
60
128
|
declare "ready": boolean;
|
|
61
129
|
declare "_el": {
|
|
62
130
|
container: HTMLElement;
|
|
63
131
|
menubar: HTMLElement;
|
|
64
|
-
map: HTMLElement;
|
|
132
|
+
map: HTMLElement | null;
|
|
65
133
|
storyslider: HTMLElement;
|
|
66
134
|
};
|
|
67
135
|
declare "_storyslider": StorySlider;
|
|
68
|
-
declare "_map": OpenLayersMap;
|
|
69
|
-
|
|
136
|
+
declare "_map": OpenLayersMap | null;
|
|
137
|
+
/** `map_type: "none"`: the story is text-and-media, with no map at all. */
|
|
138
|
+
declare "_map_disabled": boolean;
|
|
139
|
+
/**
|
|
140
|
+
* The raw OpenLayers map. `null` until the map is built (the
|
|
141
|
+
* constructor assigns it during data load) — check it, or use the
|
|
142
|
+
* accessors, which return `null` until then.
|
|
143
|
+
*/
|
|
144
|
+
declare "map": OlMap | null;
|
|
70
145
|
declare "_menubar": MenuBar;
|
|
71
146
|
declare "data": StorymapData;
|
|
72
147
|
declare "options": StorymapOptions;
|
|
73
148
|
declare "current_slide": number;
|
|
149
|
+
/**
|
|
150
|
+
* The manifest this viewer was built from, kept so `loadAnnotations()` can
|
|
151
|
+
* resolve its `seeAlso` targets. `null` for a storymap-JSON viewer, which
|
|
152
|
+
* has no `seeAlso`.
|
|
153
|
+
*/
|
|
154
|
+
declare "_raw_manifest": unknown | null;
|
|
155
|
+
/**
|
|
156
|
+
* The deep link the page was opened with, captured before anything can
|
|
157
|
+
* rewrite the URL.
|
|
158
|
+
*
|
|
159
|
+
* This has to be read *early*. The slider's opening `goTo()` can reach
|
|
160
|
+
* `_navigate()` before `_onLoaded()` runs, and `_navigate()` calls
|
|
161
|
+
* `_syncHash()` — which would overwrite the incoming link with the current
|
|
162
|
+
* slide before `_onLoaded()` ever looked at it. Whether that happens
|
|
163
|
+
* depends on the order the slider and map finish loading, which is exactly
|
|
164
|
+
* the kind of race that makes a deep link work most of the time.
|
|
165
|
+
*/
|
|
166
|
+
declare "_initial_deep_link": { state: ContentState | null; hash: string } | null;
|
|
74
167
|
declare "animator_map": AnimationHandle | null;
|
|
75
168
|
declare "animator_storyslider": AnimationHandle | null;
|
|
76
169
|
declare "_autoplay_timer": ReturnType<typeof setTimeout> | null;
|
|
77
170
|
declare "_transition_timer": ReturnType<typeof setTimeout> | null;
|
|
78
171
|
declare "_autoplay_stopped": boolean;
|
|
172
|
+
/** The narration player: one <audio> for the whole story, reused. */
|
|
173
|
+
declare "_narration_el": HTMLAudioElement | null;
|
|
174
|
+
/**
|
|
175
|
+
* The ambient player: a second <audio> for a narration bed that outlives
|
|
176
|
+
* its slide (`narration.stopOnExit: false`, slideshow ambient audio).
|
|
177
|
+
* At most one ambient track plays; a new one replaces it.
|
|
178
|
+
*/
|
|
179
|
+
declare "_ambient_el": HTMLAudioElement | null;
|
|
180
|
+
/** What each player is (or was) playing, so same-URL beds continue. */
|
|
181
|
+
declare "_narration_meta": { url: string; stopOnExit: boolean } | null;
|
|
182
|
+
declare "_ambient_meta": { url: string } | null;
|
|
183
|
+
/**
|
|
184
|
+
* Whether the ambient bed is actually playing: `HTMLMediaElement.paused`
|
|
185
|
+
* cannot be trusted here (test doubles and pre-gesture elements report
|
|
186
|
+
* stale state), so the viewer tracks it explicitly.
|
|
187
|
+
*/
|
|
188
|
+
declare "_ambient_playing": boolean;
|
|
189
|
+
/** Guards a stale play() from a slide change that already moved on. */
|
|
190
|
+
declare "_narration_token": number;
|
|
191
|
+
/** Whether narration consent was granted (asked once per story). */
|
|
192
|
+
declare "_narration_allowed": boolean;
|
|
193
|
+
/** True once the visitor has clicked/keyed: unmuted audio needs this. */
|
|
194
|
+
declare "_user_gestured": boolean;
|
|
195
|
+
/** The advance armed for the current slide (media-ended or the timer). */
|
|
196
|
+
declare "_autoplay_advance": (() => void) | null;
|
|
197
|
+
/** A narration that was skipped for want of a gesture, to retry later. */
|
|
198
|
+
declare "_replayNarrationAfterGesture": boolean;
|
|
79
199
|
declare "_hash_initialized": boolean;
|
|
200
|
+
declare "_collapsed": boolean;
|
|
80
201
|
/** the data source was a IIIF Presentation manifest (legacy zoomify options are ignored) */
|
|
81
202
|
declare "_data_from_manifest": boolean;
|
|
203
|
+
/** the data source was a slideshow tour (slideshow-only features apply) */
|
|
204
|
+
declare "_data_from_slideshow": boolean;
|
|
82
205
|
declare "_resize_observer": ResizeObserver | null;
|
|
206
|
+
/** Stored so `dispose()` can remove them again (see AGENTS: no leaks) */
|
|
207
|
+
declare "_on_resize": (() => void) | null;
|
|
208
|
+
declare "_on_keydown_global": ((e: KeyboardEvent) => void) | null;
|
|
209
|
+
declare "_on_fullscreen": (() => void) | null;
|
|
210
|
+
declare "_on_hashchange": (() => void) | null;
|
|
211
|
+
/** Reduced-motion toggles mid-story stop the advance timer. */
|
|
212
|
+
declare "_on_reduced_motion": ((e: MediaQueryListEvent) => void) | null;
|
|
213
|
+
/** Set by `dispose()` so a second call returns early. */
|
|
214
|
+
declare "_disposed": boolean;
|
|
215
|
+
/** identity of this viewer's claim on the page-wide locale */
|
|
216
|
+
declare "_language_holder": symbol;
|
|
217
|
+
/** the locale the caller actually asked for, if any; see _loadLanguage */
|
|
218
|
+
declare "_language_requested": string | undefined;
|
|
219
|
+
/** bumped on interaction, so page-wide keys can pick one viewer */
|
|
220
|
+
declare "_interaction": number;
|
|
221
|
+
declare "_onInteraction": (() => void) | null;
|
|
83
222
|
declare "_resize_timer": ReturnType<typeof setTimeout> | null;
|
|
84
|
-
declare "fire": EventedInstance["fire"];
|
|
85
|
-
declare "hasEventListeners": EventedInstance["hasEventListeners"];
|
|
86
|
-
|
|
87
|
-
// TODO: mixin
|
|
88
|
-
// includes: VCO.Events,
|
|
223
|
+
declare "fire": EventedInstance<StoryMapEvents>["fire"];
|
|
89
224
|
|
|
90
225
|
/* Private Methods
|
|
91
226
|
================================================== */
|
|
@@ -105,41 +240,45 @@ class StoryMapBase {
|
|
|
105
240
|
elem: string | HTMLElement,
|
|
106
241
|
data: string | StorymapDataWrapper | Record<string, unknown>,
|
|
107
242
|
options?: Partial<StorymapOptions>,
|
|
108
|
-
listeners?:
|
|
243
|
+
listeners?: { [K in keyof StoryMapEvents]?: StoryMapListener<K> | StoryMapListener<K>[] },
|
|
109
244
|
) {
|
|
110
245
|
for (const key in listeners) {
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
246
|
+
const type = key as keyof StoryMapEvents;
|
|
247
|
+
const callbacks = listeners[type];
|
|
248
|
+
if (callbacks === undefined) continue;
|
|
249
|
+
const list = typeof callbacks == "function" ? [callbacks] : callbacks;
|
|
250
|
+
for (const callback of list) {
|
|
251
|
+
if (typeof callback == "function") {
|
|
252
|
+
this.on(type, callback as StoryMapListener);
|
|
253
|
+
} else {
|
|
254
|
+
console.warn(
|
|
255
|
+
"WARNING: Ignoring invalid callback '" +
|
|
256
|
+
callback +
|
|
257
|
+
"' defined for " +
|
|
258
|
+
"listener '" +
|
|
259
|
+
key +
|
|
260
|
+
"' in StoryMap constructor",
|
|
261
|
+
);
|
|
128
262
|
}
|
|
129
263
|
}
|
|
130
264
|
}
|
|
131
265
|
|
|
132
266
|
// Version
|
|
133
|
-
|
|
267
|
+
// build-time constant from package.json (see vite.config.ts); the
|
|
268
|
+
// typeof guard keeps a dev-server run without the define working
|
|
269
|
+
this.version =
|
|
270
|
+
typeof __STORYMAP_VERSION__ === "string" ? __STORYMAP_VERSION__ : "0.0.0-dev";
|
|
134
271
|
|
|
135
272
|
// Ready
|
|
136
273
|
this.ready = false;
|
|
137
274
|
|
|
138
|
-
// DOM ELEMENTS
|
|
275
|
+
// DOM ELEMENTS. Only map may legitimately stay null: a mapless story
|
|
276
|
+
// never builds the pane, so unlike its siblings it gets a real null
|
|
277
|
+
// rather than a stub that would read truthy before _initLayout runs.
|
|
139
278
|
this._el = {
|
|
140
279
|
container: {} as HTMLElement,
|
|
141
280
|
storyslider: {} as HTMLElement,
|
|
142
|
-
map:
|
|
281
|
+
map: null,
|
|
143
282
|
menubar: {} as HTMLElement,
|
|
144
283
|
};
|
|
145
284
|
|
|
@@ -147,15 +286,23 @@ class StoryMapBase {
|
|
|
147
286
|
if (typeof elem === "object") {
|
|
148
287
|
this._el.container = elem;
|
|
149
288
|
} else {
|
|
150
|
-
|
|
289
|
+
const found = Dom.get(elem);
|
|
290
|
+
if (!found) {
|
|
291
|
+
throw new Error("StoryMapJS: no element with id " + elem);
|
|
292
|
+
}
|
|
293
|
+
this._el.container = found;
|
|
151
294
|
}
|
|
152
295
|
|
|
153
296
|
// Slider
|
|
154
297
|
this._storyslider = {} as StorySlider;
|
|
155
298
|
|
|
156
|
-
// Map
|
|
157
|
-
|
|
158
|
-
this.
|
|
299
|
+
// Map. Genuinely null when the story has no map (map_type "none") or
|
|
300
|
+
// before the layout builds one: the accessor guards below read
|
|
301
|
+
// `!this._disposed && this._map`, which a `{}` stub would sail past
|
|
302
|
+
// and then call a method on.
|
|
303
|
+
this._map = null;
|
|
304
|
+
// direct access to the OpenLayers map; null until it is built
|
|
305
|
+
this.map = null;
|
|
159
306
|
|
|
160
307
|
// Menu Bar
|
|
161
308
|
this._menubar = {} as MenuBar;
|
|
@@ -169,6 +316,10 @@ class StoryMapBase {
|
|
|
169
316
|
|
|
170
317
|
this.options = {
|
|
171
318
|
script_path: StoryMap.SCRIPT_PATH,
|
|
319
|
+
// raw OpenLayers Map/View options passed through (see map_options
|
|
320
|
+
// in the README); empty by default, and the OL code reads it
|
|
321
|
+
// defensively because the option is also settable from JSON
|
|
322
|
+
map_options: {},
|
|
172
323
|
height: this._el.container.offsetHeight,
|
|
173
324
|
width: this._el.container.offsetWidth,
|
|
174
325
|
layout: "landscape", // portrait or landscape
|
|
@@ -176,7 +327,6 @@ class StoryMapBase {
|
|
|
176
327
|
default_bg_color: { r: 255, g: 255, b: 255 },
|
|
177
328
|
map_size_sticky: 2.5, // Set as division 1/3 etc
|
|
178
329
|
map_center_offset: null, // takes object {top:0,left:0}
|
|
179
|
-
less_bounce: false, // Less map bounce when calculating zoom, false is good when there are clusters of tightly grouped markers
|
|
180
330
|
start_at_slide: 0,
|
|
181
331
|
call_to_action: false,
|
|
182
332
|
call_to_action_text: "",
|
|
@@ -188,20 +338,33 @@ class StoryMapBase {
|
|
|
188
338
|
// animation
|
|
189
339
|
duration: 1000,
|
|
190
340
|
ease: easeInOutQuint,
|
|
191
|
-
// interaction
|
|
192
|
-
dragging: true,
|
|
193
341
|
trackResize: true,
|
|
194
342
|
keyboard: false,
|
|
195
343
|
nocache: false,
|
|
344
|
+
slideshow: true,
|
|
196
345
|
autoplay: 0,
|
|
346
|
+
autoplay_media: false,
|
|
197
347
|
show_progress: false,
|
|
348
|
+
progressbar: undefined,
|
|
198
349
|
marker_labels: false,
|
|
199
350
|
text_align: "left",
|
|
351
|
+
textmode: "right",
|
|
352
|
+
textsize: undefined,
|
|
353
|
+
fxmode: "slide",
|
|
354
|
+
mode: "standard",
|
|
355
|
+
hudcolor: "",
|
|
356
|
+
hudbgcolor: "",
|
|
357
|
+
hudopacity: undefined,
|
|
358
|
+
shownav: true,
|
|
359
|
+
show_headings: true,
|
|
360
|
+
show_scrollbars: true,
|
|
361
|
+
viewerheight: "",
|
|
362
|
+
show_info: true,
|
|
200
363
|
map_overview_center: null,
|
|
201
364
|
map_type: "", // "osm:standard",
|
|
365
|
+
tile_source_factory: null,
|
|
202
366
|
attribution: "",
|
|
203
367
|
map_mini: true,
|
|
204
|
-
map_subdomains: "",
|
|
205
368
|
map_as_image: false,
|
|
206
369
|
// no bundled credentials: pass map_access_token in the options
|
|
207
370
|
// if you use Mapbox/Stadia tiles, api_key_flickr for flickr API
|
|
@@ -210,6 +373,15 @@ class StoryMapBase {
|
|
|
210
373
|
map_background_color: "#d9d9d9",
|
|
211
374
|
map_area: "full", // "left" limits the map to the visible half (landscape)
|
|
212
375
|
map_bbox: null,
|
|
376
|
+
// options the map also declares: updateData only copies keys
|
|
377
|
+
// that already exist here, so storymap JSON could not reach them
|
|
378
|
+
overview_extent: null,
|
|
379
|
+
overlays: [],
|
|
380
|
+
show_layers_control: false,
|
|
381
|
+
basemaps: undefined,
|
|
382
|
+
// TileJSON 2.1 metadata for a tile-source basemap; a keyword
|
|
383
|
+
// basemap has none (interop §2.9)
|
|
384
|
+
tilejson: undefined,
|
|
213
385
|
consent_required: false,
|
|
214
386
|
zoomify: undefined,
|
|
215
387
|
text_color: "",
|
|
@@ -225,9 +397,6 @@ class StoryMapBase {
|
|
|
225
397
|
slide_padding_lr: 45, // padding on slide of slide
|
|
226
398
|
slide_default_fade: "0%", // landscape fade
|
|
227
399
|
menubar_default_y: 0,
|
|
228
|
-
path_gfx: "gfx",
|
|
229
|
-
map_popup: false,
|
|
230
|
-
zoom_distance: 100,
|
|
231
400
|
calculate_zoom: true, // Allow map to determine best zoom level between markers (recommended)
|
|
232
401
|
line_follows_path: true, // Map history path follows default line, if false it will connect previous and current only
|
|
233
402
|
line_color: "#c34528", //"#DA0000",
|
|
@@ -247,16 +416,51 @@ class StoryMapBase {
|
|
|
247
416
|
this.animator_map = null;
|
|
248
417
|
this.animator_storyslider = null;
|
|
249
418
|
this._resize_observer = null;
|
|
419
|
+
this._on_resize = null;
|
|
420
|
+
this._on_keydown_global = null;
|
|
421
|
+
this._on_fullscreen = null;
|
|
422
|
+
this._on_hashchange = null;
|
|
423
|
+
this._on_reduced_motion = null;
|
|
424
|
+
this._raw_manifest = null;
|
|
425
|
+
this._initial_deep_link = null;
|
|
426
|
+
this._disposed = false;
|
|
427
|
+
this._language_holder = Symbol("storymap");
|
|
428
|
+
this._language_requested = undefined;
|
|
429
|
+
this._interaction = 0;
|
|
430
|
+
this._onInteraction = null;
|
|
250
431
|
this._resize_timer = null;
|
|
251
432
|
this._autoplay_timer = null;
|
|
252
433
|
this._transition_timer = null;
|
|
253
434
|
this._autoplay_stopped = false;
|
|
435
|
+
this._narration_el = null;
|
|
436
|
+
this._ambient_el = null;
|
|
437
|
+
this._narration_meta = null;
|
|
438
|
+
this._ambient_meta = null;
|
|
439
|
+
this._ambient_playing = false;
|
|
440
|
+
this._narration_token = 0;
|
|
441
|
+
this._narration_allowed = true;
|
|
442
|
+
this._user_gestured = false;
|
|
443
|
+
this._replayNarrationAfterGesture = false;
|
|
444
|
+
this._autoplay_advance = null;
|
|
254
445
|
this._hash_initialized = false;
|
|
446
|
+
this._collapsed = false;
|
|
447
|
+
|
|
448
|
+
// The caller's own `language`, read before the merge: afterwards
|
|
449
|
+
// options.language is always set (the default is "en"), so "asked for
|
|
450
|
+
// German" and "never mentioned a language" are indistinguishable.
|
|
451
|
+
this._language_requested =
|
|
452
|
+
typeof options?.language === "string" && options.language !== ""
|
|
453
|
+
? options.language
|
|
454
|
+
: undefined;
|
|
255
455
|
|
|
256
456
|
// Merge Options -- legacy, in case people still need to pass in
|
|
257
457
|
mergeData(this.options, options);
|
|
258
458
|
|
|
259
|
-
// Current Slide
|
|
459
|
+
// Current Slide — the constructor options have been merged, but the
|
|
460
|
+
// storymap *data* has not (issue #305). _initData -> _initOptions
|
|
461
|
+
// merges the data in afterwards, and the data may carry a
|
|
462
|
+
// start_at_slide of its own, so re-read it there rather than
|
|
463
|
+
// freezing the constructor value here.
|
|
260
464
|
this.current_slide = this.options.start_at_slide;
|
|
261
465
|
|
|
262
466
|
this._initData(data);
|
|
@@ -270,6 +474,10 @@ class StoryMapBase {
|
|
|
270
474
|
// legacy zoomify options are only honored for storymap JSON sources —
|
|
271
475
|
// a IIIF Presentation manifest cannot carry them
|
|
272
476
|
this._data_from_manifest = false;
|
|
477
|
+
// slideshow tours unlock the slideshow-only presentation fields; the
|
|
478
|
+
// translator branch that sets this lives with the translator phase —
|
|
479
|
+
// until then only an explicit host flag (see slideshow_source) sets it
|
|
480
|
+
this._data_from_slideshow = false;
|
|
273
481
|
if (typeof data === "string") {
|
|
274
482
|
// issue #417: optional cache-busting re-fetch of the source file
|
|
275
483
|
const url =
|
|
@@ -278,9 +486,18 @@ class StoryMapBase {
|
|
|
278
486
|
: data;
|
|
279
487
|
void this._loadDataFromUrl(url, data);
|
|
280
488
|
} else if (typeof data === "object") {
|
|
281
|
-
|
|
489
|
+
// a Collection is a legitimate IIIF input too: its member
|
|
490
|
+
// Manifests' canvases concatenate into one linear story
|
|
491
|
+
if (isPresentation3Manifest(data) || isPresentation3Collection(data)) {
|
|
282
492
|
this._data_from_manifest = true;
|
|
493
|
+
this._raw_manifest = data;
|
|
283
494
|
this.data = manifestToStorymapData(data);
|
|
495
|
+
} else if (this.options.slideshow !== false && isSlideshowCollection(data)) {
|
|
496
|
+
this._data_from_slideshow = true;
|
|
497
|
+
const { data: storymap, warnings } = slideshowToStorymapData(data);
|
|
498
|
+
this._warnSlideshow(warnings);
|
|
499
|
+
validateStorymapAndReport({ storymap }, "slideshow");
|
|
500
|
+
this.data = storymap;
|
|
284
501
|
} else {
|
|
285
502
|
const wrapper = data as StorymapDataWrapper;
|
|
286
503
|
validateStorymapAndReport(wrapper);
|
|
@@ -299,6 +516,56 @@ class StoryMapBase {
|
|
|
299
516
|
|
|
300
517
|
/* Load storymap data from a URL
|
|
301
518
|
================================================== */
|
|
519
|
+
/**
|
|
520
|
+
* One `console.warn` per warning kind: count, detail and sample ids.
|
|
521
|
+
* Translation losses are loud exactly once, never per slide. Shared
|
|
522
|
+
* formatting with the CLI (see `formatSlideshowWarning`).
|
|
523
|
+
*/
|
|
524
|
+
_warnSlideshow(warnings: SlideshowWarning[]): void {
|
|
525
|
+
for (const warning of warnings) {
|
|
526
|
+
console.warn(`StoryMapJS: ${formatSlideshowWarning(warning)}`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Follow a tour's `first.next` page chain, returning the following pages
|
|
532
|
+
* for the translator. Bounded (20) and cycle-guarded; a broken chain
|
|
533
|
+
* reports incompletely so the caller warns instead of truncating
|
|
534
|
+
* silently. Relative links resolve against the tour URL.
|
|
535
|
+
*/
|
|
536
|
+
async _followSlideshowPages(
|
|
537
|
+
doc: unknown,
|
|
538
|
+
url: string,
|
|
539
|
+
): Promise<{ pages: unknown[]; complete: boolean }> {
|
|
540
|
+
// relative tour URLs (harness, subpath deploys) resolve against the
|
|
541
|
+
// page; an unresolvable base still lets absolute links through
|
|
542
|
+
let base = url;
|
|
543
|
+
try {
|
|
544
|
+
base = new URL(url, window.location.href).href;
|
|
545
|
+
} catch {
|
|
546
|
+
// keep the raw url
|
|
547
|
+
}
|
|
548
|
+
const first = (doc as Record<string, unknown> | null)?.first;
|
|
549
|
+
return followSlideshowPages({
|
|
550
|
+
first,
|
|
551
|
+
base,
|
|
552
|
+
resolve: (link, current) => {
|
|
553
|
+
try {
|
|
554
|
+
return new URL(link, current).href;
|
|
555
|
+
} catch {
|
|
556
|
+
return null;
|
|
557
|
+
}
|
|
558
|
+
},
|
|
559
|
+
load: async (absolute: string) => {
|
|
560
|
+
const response = await fetch(absolute);
|
|
561
|
+
if (!response.ok) {
|
|
562
|
+
throw new Error(`HTTP ${response.status} ${response.statusText}`);
|
|
563
|
+
}
|
|
564
|
+
return response.json() as Promise<unknown>;
|
|
565
|
+
},
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
302
569
|
async _loadDataFromUrl(url: string, source: string) {
|
|
303
570
|
try {
|
|
304
571
|
const response = await fetch(url);
|
|
@@ -306,9 +573,39 @@ class StoryMapBase {
|
|
|
306
573
|
throw new Error("HTTP " + response.status + " " + response.statusText);
|
|
307
574
|
}
|
|
308
575
|
const result: unknown = await response.json();
|
|
309
|
-
|
|
576
|
+
// A teardown while the fetch was in flight must win: without
|
|
577
|
+
// this, a late-resolving response rebuilds the whole viewer into
|
|
578
|
+
// the container dispose() just emptied — menubar, slider, map,
|
|
579
|
+
// listeners, locale claim, autoplay — and sets ready = true.
|
|
580
|
+
if (this._disposed) {
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
if (isPresentation3Manifest(result) || isPresentation3Collection(result)) {
|
|
310
584
|
this._data_from_manifest = true;
|
|
585
|
+
this._raw_manifest = result;
|
|
311
586
|
this.data = manifestToStorymapData(result);
|
|
587
|
+
} else if (this.options.slideshow !== false && isSlideshowCollection(result)) {
|
|
588
|
+
this._data_from_slideshow = true;
|
|
589
|
+
const followed = await this._followSlideshowPages(result, url);
|
|
590
|
+
if (this._disposed) {
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const { data: storymap, warnings } = slideshowToStorymapData(result, {
|
|
594
|
+
extraPages: followed.pages,
|
|
595
|
+
});
|
|
596
|
+
this._warnSlideshow(warnings);
|
|
597
|
+
if (!followed.complete) {
|
|
598
|
+
this._warnSlideshow([
|
|
599
|
+
{
|
|
600
|
+
kind: "slideshow.paging",
|
|
601
|
+
count: 1,
|
|
602
|
+
sampleIds: [],
|
|
603
|
+
detail: "page chain broke, tour may be truncated",
|
|
604
|
+
},
|
|
605
|
+
]);
|
|
606
|
+
}
|
|
607
|
+
validateStorymapAndReport({ storymap }, source);
|
|
608
|
+
this.data = storymap;
|
|
312
609
|
} else {
|
|
313
610
|
validateStorymapAndReport(result, source);
|
|
314
611
|
const wrapper = result as StorymapDataWrapper;
|
|
@@ -319,6 +616,14 @@ class StoryMapBase {
|
|
|
319
616
|
}
|
|
320
617
|
this._initOptions();
|
|
321
618
|
} catch (err: unknown) {
|
|
619
|
+
if (isLanguageConflict(err)) {
|
|
620
|
+
// a second viewer on this page asked for another locale. This
|
|
621
|
+
// is a configuration error, not a failed fetch, and reporting
|
|
622
|
+
// it as one would send the host looking for a 404.
|
|
623
|
+
console.error((err as Error).message);
|
|
624
|
+
this.fire("error", { message: (err as Error).message, source, conflict: true });
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
322
627
|
console.error("StoryMapJS: could not load storymap data from " + source, err);
|
|
323
628
|
this.fire("error", { message: String(err), source });
|
|
324
629
|
}
|
|
@@ -326,10 +631,78 @@ class StoryMapBase {
|
|
|
326
631
|
|
|
327
632
|
/* Initialize the options
|
|
328
633
|
================================================== */
|
|
634
|
+
/**
|
|
635
|
+
* Reset every slideshow-only option to its long-standing default unless
|
|
636
|
+
* the loaded tour came from a slideshow source. Internal documents keep
|
|
637
|
+
* the pre-existing rendering even when they (or a host, via
|
|
638
|
+
* setMapOptions) name the new keys; slideshow tours keep what the
|
|
639
|
+
* translator emitted. Runs on every load and every runtime option change,
|
|
640
|
+
* over the viewer options and (at runtime) the map's own options copy.
|
|
641
|
+
*/
|
|
642
|
+
_normalizeNonSlideshowOptions(options: StorymapOptions = this.options): void {
|
|
643
|
+
if (options.slideshow_source === true) return;
|
|
644
|
+
options.textmode = "right";
|
|
645
|
+
options.textsize = undefined;
|
|
646
|
+
options.fxmode = "slide";
|
|
647
|
+
options.mode = "standard";
|
|
648
|
+
options.hudcolor = "";
|
|
649
|
+
options.hudbgcolor = "";
|
|
650
|
+
options.hudopacity = undefined;
|
|
651
|
+
options.shownav = true;
|
|
652
|
+
options.show_headings = true;
|
|
653
|
+
options.show_scrollbars = true;
|
|
654
|
+
options.viewerheight = "";
|
|
655
|
+
options.show_info = true;
|
|
656
|
+
options.progressbar = undefined;
|
|
657
|
+
}
|
|
658
|
+
|
|
329
659
|
_initOptions() {
|
|
330
660
|
// Grab options from storymap data
|
|
331
661
|
updateData(this.options, this.data);
|
|
332
662
|
|
|
663
|
+
// Source marker for the slideshow-only presentation fields: set
|
|
664
|
+
// programmatically from the load path, never from data (the key is
|
|
665
|
+
// absent from defaults and schema, so updateData above cannot inject
|
|
666
|
+
// it — a host may still pass it explicitly as an escape hatch).
|
|
667
|
+
if (this._data_from_slideshow) {
|
|
668
|
+
this.options.slideshow_source = true;
|
|
669
|
+
}
|
|
670
|
+
// Slideshow-only chrome stays inert for internal documents: reset
|
|
671
|
+
// every such option to its default unless the tour came from a
|
|
672
|
+
// slideshow source. this.data itself is never touched (it is the
|
|
673
|
+
// host's object — see data-immutable.test.ts).
|
|
674
|
+
this._normalizeNonSlideshowOptions();
|
|
675
|
+
// Story credit (slideshow `creator`/`rights`) joins the map
|
|
676
|
+
// attribution line for slideshow tours only; the line re-sanitizes
|
|
677
|
+
// its input, so author strings render as text. Internal documents
|
|
678
|
+
// keep their own attribution untouched.
|
|
679
|
+
if (this._data_from_slideshow && this.data.credit) {
|
|
680
|
+
const credit = [this.data.credit.creator, this.data.credit.rights].filter(
|
|
681
|
+
(part): part is string => typeof part === "string" && part !== "",
|
|
682
|
+
);
|
|
683
|
+
if (credit.length > 0) {
|
|
684
|
+
this.options.attribution = [this.options.attribution, ...credit]
|
|
685
|
+
.filter((part) => part !== "")
|
|
686
|
+
.join(" · ");
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Capture the deep link now, before the slider and map exist and can
|
|
691
|
+
// navigate — and so rewrite the URL we would read it from later.
|
|
692
|
+
this._initial_deep_link = {
|
|
693
|
+
state: parseContentState(
|
|
694
|
+
new URLSearchParams(window.location.search).get(CONTENT_STATE_PARAM),
|
|
695
|
+
),
|
|
696
|
+
hash: window.location.hash,
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
// issue #305: the data may override start_at_slide. The constructor
|
|
700
|
+
// read it before this merge, and the slider's opening goTo() fires
|
|
701
|
+
// before _initEvents() attaches our change listener — so nothing
|
|
702
|
+
// else resynchronises the field, leaving the hash, the progress bar
|
|
703
|
+
// and current_slide disagreeing with the visible slide.
|
|
704
|
+
this.current_slide = this.options.start_at_slide;
|
|
705
|
+
|
|
333
706
|
// legacy zoomify options only work with storymap JSON sources — a
|
|
334
707
|
// IIIF Presentation manifest cannot carry them
|
|
335
708
|
if (this._data_from_manifest) {
|
|
@@ -340,9 +713,19 @@ class StoryMapBase {
|
|
|
340
713
|
// map_area "left": the map is limited to the visible half with an
|
|
341
714
|
// opaque slide panel — no offset needed (the view center is the
|
|
342
715
|
// visible center already); "full" (default) offsets the view so
|
|
343
|
-
// markers clear the panel that fades in over the map
|
|
716
|
+
// markers clear the panel that fades in over the map. A left
|
|
717
|
+
// text panel (textmode "left") mirrors the offset.
|
|
344
718
|
if (this.options.map_area !== "left") {
|
|
345
|
-
|
|
719
|
+
// side docks offset the view past the panel (mirrored for a
|
|
720
|
+
// left panel); a bottom dock recenters slightly upward so
|
|
721
|
+
// point stops clear the panel (fits use measured padding)
|
|
722
|
+
if (this.options.textmode === "left") {
|
|
723
|
+
this.options.map_center_offset = { left: 200, top: 0 };
|
|
724
|
+
} else if (this.options.textmode === "bottom") {
|
|
725
|
+
this.options.map_center_offset = { left: 0, top: -120 };
|
|
726
|
+
} else {
|
|
727
|
+
this.options.map_center_offset = { left: -200, top: 0 };
|
|
728
|
+
}
|
|
346
729
|
} else {
|
|
347
730
|
this.options.map_center_offset = { left: 0, top: 0 };
|
|
348
731
|
}
|
|
@@ -373,6 +756,33 @@ class StoryMapBase {
|
|
|
373
756
|
}
|
|
374
757
|
}
|
|
375
758
|
|
|
759
|
+
// `map_type` is optional in the schema, and a hand-written document
|
|
760
|
+
// can carry an explicit null. Normalise rather than guard: leaving a
|
|
761
|
+
// null in place only moves the crash to the map's own
|
|
762
|
+
// `map_type.split(":")`. "" is the documented default and means OSM.
|
|
763
|
+
if (typeof this.options.map_type !== "string") {
|
|
764
|
+
this.options.map_type = "";
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Entry 0 of `basemaps` is the initial basemap unless `map_type` says
|
|
768
|
+
// otherwise ("" counts as "not saying": it is the default, not a
|
|
769
|
+
// choice). Omit `basemaps` for an overlays-only control.
|
|
770
|
+
const basemaps = this.options.basemaps;
|
|
771
|
+
if (
|
|
772
|
+
this.options.map_type === "" &&
|
|
773
|
+
Array.isArray(basemaps) &&
|
|
774
|
+
basemaps.length > 0 &&
|
|
775
|
+
typeof basemaps[0]?.map_type === "string" &&
|
|
776
|
+
basemaps[0].map_type !== "" &&
|
|
777
|
+
basemaps[0].map_type !== "none"
|
|
778
|
+
) {
|
|
779
|
+
this.options.map_type = basemaps[0].map_type;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// "none" is the only way to say there is no map. An absent key or ""
|
|
783
|
+
// keeps meaning OSM, so no existing document changes meaning.
|
|
784
|
+
this._map_disabled = this.options.map_type === "none";
|
|
785
|
+
|
|
376
786
|
// handle Stamen change
|
|
377
787
|
if (this.options.map_type.startsWith("stamen")) {
|
|
378
788
|
const old_type = this.options.map_type;
|
|
@@ -381,7 +791,7 @@ class StoryMapBase {
|
|
|
381
791
|
} else {
|
|
382
792
|
this.options.map_type = "osm:standard";
|
|
383
793
|
}
|
|
384
|
-
console.
|
|
794
|
+
console.warn(`Deprecated map_type ${old_type}; using ${this.options.map_type}`);
|
|
385
795
|
}
|
|
386
796
|
|
|
387
797
|
this._loadLanguage();
|
|
@@ -391,24 +801,70 @@ class StoryMapBase {
|
|
|
391
801
|
================================================== */
|
|
392
802
|
|
|
393
803
|
_loadLanguage() {
|
|
394
|
-
|
|
395
|
-
//
|
|
396
|
-
|
|
804
|
+
// the locale chunk is fetched on demand, so the language is not
|
|
805
|
+
// available synchronously; layout proceeds with the English defaults
|
|
806
|
+
// and the labels are repainted once the locale arrives.
|
|
807
|
+
//
|
|
808
|
+
// The claim is what makes a second viewer on this page safe: the UI
|
|
809
|
+
// strings are one module-level binding, so a conflicting locale would
|
|
810
|
+
// otherwise repaint this viewer in the other viewer's language. It
|
|
811
|
+
// throws rather than warn, before any of this viewer's DOM exists.
|
|
812
|
+
//
|
|
813
|
+
// The document may carry its own `language` (updateData merges the
|
|
814
|
+
// document's keys into the options), so that counts as a request too.
|
|
815
|
+
const requested = this._requestedLanguage();
|
|
816
|
+
if (requested === undefined) {
|
|
817
|
+
// Nobody asked for a locale, so adopt whatever the page is already
|
|
818
|
+
// using. Claiming the "en" default instead would conflict with a
|
|
819
|
+
// sibling that did ask — a host that called setLanguage("de") and
|
|
820
|
+
// then built a viewer with no `language` option used to get
|
|
821
|
+
// English, silently repainting its German sibling's chrome.
|
|
822
|
+
claimLanguage(currentLanguageCode(), this._language_holder);
|
|
823
|
+
} else {
|
|
824
|
+
claimLanguage(requested, this._language_holder);
|
|
825
|
+
setLanguage(requested);
|
|
826
|
+
}
|
|
397
827
|
this._onDataLoaded();
|
|
398
828
|
}
|
|
399
829
|
|
|
830
|
+
/**
|
|
831
|
+
* The locale this viewer was actually asked for, or undefined when the
|
|
832
|
+
* caller and the document both stayed silent.
|
|
833
|
+
*/
|
|
834
|
+
_requestedLanguage(): string | undefined {
|
|
835
|
+
// the document wins, because updateData merges it over the options, so
|
|
836
|
+
// a document-level `language` is what options.language ends up holding
|
|
837
|
+
const fromDocument = (this.data as { language?: unknown } | null | undefined)?.language;
|
|
838
|
+
if (typeof fromDocument === "string" && fromDocument !== "") {
|
|
839
|
+
return fromDocument;
|
|
840
|
+
}
|
|
841
|
+
return this._language_requested;
|
|
842
|
+
}
|
|
843
|
+
|
|
400
844
|
/* Switch the UI language at runtime, without a page reload.
|
|
401
845
|
================================================== */
|
|
402
846
|
|
|
403
847
|
refreshLanguage(code: string): void {
|
|
848
|
+
if (this._disposed) return;
|
|
849
|
+
// throws if a sibling viewer is rendering in another language
|
|
850
|
+
claimLanguage(code, this._language_holder);
|
|
404
851
|
this.options.language = code;
|
|
405
852
|
setLanguage(code);
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
853
|
+
this._applyLanguageLayout();
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Repaint everything a runtime language switch affects: the menubar
|
|
858
|
+
* labels and the `vco-rtl` class.
|
|
859
|
+
*
|
|
860
|
+
* This deliberately does *not* call `updateDisplay()`. A full re-layout
|
|
861
|
+
* re-runs the slider's `goTo(current_slide)`, which goes through the
|
|
862
|
+
* navigation path that re-arms autoplay — so calling it here silently
|
|
863
|
+
* cancelled a pending autoplay tick and the story stopped advancing.
|
|
864
|
+
*/
|
|
865
|
+
_applyLanguageLayout(): void {
|
|
866
|
+
this._menubar?.refreshLabels?.();
|
|
867
|
+
this._el.container.classList.toggle("vco-rtl", isRtl());
|
|
412
868
|
}
|
|
413
869
|
|
|
414
870
|
/* Load the font theme stylesheet
|
|
@@ -416,19 +872,21 @@ class StoryMapBase {
|
|
|
416
872
|
async _loadFontCss() {
|
|
417
873
|
// only genuinely external URLs ask for consent: stock: themes and
|
|
418
874
|
// relative paths resolve to same-origin / library assets
|
|
419
|
-
const original = this.options.font_css
|
|
875
|
+
const original = fontCssOriginal(this.options.font_css);
|
|
876
|
+
if (original === null) {
|
|
877
|
+
// the theme ships in the host bundle (font_css: false): nothing
|
|
878
|
+
// to inject and no consent row for it
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
420
881
|
const font = resolveFontCssUrl(original);
|
|
882
|
+
if (font === null) return; // only "none" maps to null, handled above
|
|
421
883
|
const manager = consentManagerOf(this.options);
|
|
422
884
|
const external = /^(http|https|\/\/)/.test(original);
|
|
423
885
|
if (external && this.options.consent_required && manager) {
|
|
424
886
|
// external font CSS is an external service — ask first
|
|
425
887
|
const host = new URL(font.startsWith("//") ? "https:" + font : font).host;
|
|
426
888
|
const container = this._el.map ?? (this._el.container as HTMLElement);
|
|
427
|
-
const allowed = await manager.request(
|
|
428
|
-
consentMessage("consent_service_fonts", "web fonts"),
|
|
429
|
-
host,
|
|
430
|
-
container,
|
|
431
|
-
);
|
|
889
|
+
const allowed = await manager.request(fontService(), host, container);
|
|
432
890
|
if (!allowed) {
|
|
433
891
|
return;
|
|
434
892
|
}
|
|
@@ -452,28 +910,72 @@ class StoryMapBase {
|
|
|
452
910
|
/* Navigation
|
|
453
911
|
================================================== */
|
|
454
912
|
/**
|
|
455
|
-
* Navigate to a slide by index.
|
|
913
|
+
* Navigate to a slide by index. Out-of-range indices are ignored, as is
|
|
914
|
+
* the current index.
|
|
456
915
|
*
|
|
457
916
|
* @param n - Zero-based slide index; the map animates to the slide's
|
|
458
|
-
* marker
|
|
917
|
+
* marker. The overview is selected by the slide's own
|
|
918
|
+
* `type: "overview"`, not by its index.
|
|
459
919
|
*/
|
|
460
920
|
goTo(n: number) {
|
|
921
|
+
if (this._disposed) return;
|
|
461
922
|
// out-of-range indices are ignored: they would desync the slider and
|
|
462
923
|
// map (no active slide) and write a broken #slide-N bookmark
|
|
463
924
|
if (n >= 0 && n < (this.data?.slides?.length ?? 0) && n !== this.current_slide) {
|
|
464
|
-
|
|
465
|
-
|
|
925
|
+
this._navigate(n);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* The single navigation path shared by programmatic navigation, the
|
|
931
|
+
* slider's `change` event, the map's `change` event and "back to start".
|
|
932
|
+
*
|
|
933
|
+
* `navigate` names the single component that the bubble source already
|
|
934
|
+
* moved; the other component follows. `navigated` says whether the caller
|
|
935
|
+
* has *not* already reported the change outward, so the outward `change`
|
|
936
|
+
* event fires exactly once per navigation (the bubble paths would
|
|
937
|
+
* otherwise re-enter through the other component's handler).
|
|
938
|
+
*/
|
|
939
|
+
private _navigate(
|
|
940
|
+
n: number,
|
|
941
|
+
opts: {
|
|
942
|
+
navigate?: "slider" | "map";
|
|
943
|
+
navigated?: boolean;
|
|
944
|
+
animate?: boolean;
|
|
945
|
+
} = {},
|
|
946
|
+
): void {
|
|
947
|
+
const { navigate, navigated = false, animate = true } = opts;
|
|
948
|
+
const duration = animate ? slideTransitionDuration(this.current_slide, n) : 0;
|
|
949
|
+
|
|
950
|
+
this.current_slide = n;
|
|
951
|
+
if (navigate !== "slider") {
|
|
466
952
|
this._storyslider.goTo(this.current_slide);
|
|
467
|
-
|
|
468
|
-
|
|
953
|
+
}
|
|
954
|
+
if (navigate !== "map") {
|
|
955
|
+
this._map?.goTo(this.current_slide);
|
|
956
|
+
}
|
|
957
|
+
// Per-slide image overlay (slideshow imgoverlay): shown while its
|
|
958
|
+
// slide is active, cleared otherwise. Null-safe: mapless stories
|
|
959
|
+
// have no map, slides without one clear a previous overlay.
|
|
960
|
+
this._map?.setSlideImageOverlay(this.data.slides?.[this.current_slide]?.imgoverlay ?? null);
|
|
961
|
+
this._beginTransition(duration);
|
|
962
|
+
if (!navigated) {
|
|
469
963
|
// programmatic navigation reports outward like interaction does;
|
|
470
|
-
// the bubbled slider/map change events
|
|
471
|
-
//
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
this.
|
|
964
|
+
// the bubbled slider/map change events are dropped by their
|
|
965
|
+
// equality guards, so this fires exactly once
|
|
966
|
+
// `current_id` so a host can tell *which stop* it is on, not just
|
|
967
|
+
// how far along it is: an index moves when a slide is inserted, an
|
|
968
|
+
// id does not (§5.1)
|
|
969
|
+
this.fire("change", {
|
|
970
|
+
current_slide: this.current_slide,
|
|
971
|
+
current_id: this._currentSlideId(),
|
|
972
|
+
});
|
|
476
973
|
}
|
|
974
|
+
this._syncHash();
|
|
975
|
+
this._playNarration(this.data.slides?.[this.current_slide]);
|
|
976
|
+
this._stopAmbientSlideMedia(this.data.slides?.[this.current_slide]);
|
|
977
|
+
this._scheduleAutoplay();
|
|
978
|
+
this._updateProgress();
|
|
477
979
|
}
|
|
478
980
|
|
|
479
981
|
/**
|
|
@@ -486,10 +988,10 @@ class StoryMapBase {
|
|
|
486
988
|
if (this._transition_timer) {
|
|
487
989
|
clearTimeout(this._transition_timer);
|
|
488
990
|
}
|
|
489
|
-
this.fire("transitionstart", { current_slide: this.current_slide, duration }
|
|
991
|
+
this.fire("transitionstart", { current_slide: this.current_slide, duration });
|
|
490
992
|
this._transition_timer = setTimeout(() => {
|
|
491
993
|
this._transition_timer = null;
|
|
492
|
-
this.fire("transitionend", { current_slide: this.current_slide }
|
|
994
|
+
this.fire("transitionend", { current_slide: this.current_slide });
|
|
493
995
|
}, duration);
|
|
494
996
|
}
|
|
495
997
|
|
|
@@ -498,6 +1000,9 @@ class StoryMapBase {
|
|
|
498
1000
|
* Called automatically on resize (see the `trackResize` option).
|
|
499
1001
|
*/
|
|
500
1002
|
updateDisplay() {
|
|
1003
|
+
if (this._disposed) {
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
501
1006
|
if (this.ready) {
|
|
502
1007
|
this._updateDisplay();
|
|
503
1008
|
}
|
|
@@ -506,8 +1011,10 @@ class StoryMapBase {
|
|
|
506
1011
|
/**
|
|
507
1012
|
* Change a single map option at runtime and apply its effect immediately.
|
|
508
1013
|
*
|
|
509
|
-
* Runtime-changeable options: `map_type` (
|
|
510
|
-
* layers, keeping the overview fitted to
|
|
1014
|
+
* Runtime-changeable options: `map_type` (swaps one basemap for another and
|
|
1015
|
+
* rebuilds the main + minimap tile layers, keeping the overview fitted to
|
|
1016
|
+
* the marker bounds; it cannot add or remove a map, and `"none"` and
|
|
1017
|
+
* non-strings are declined), `overlays`
|
|
511
1018
|
* (rebuilds the stacked overlay layers), `show_lines`, `line_color`,
|
|
512
1019
|
* `line_color_inactive`, `line_weight`, `line_opacity`, `line_dash`,
|
|
513
1020
|
* `line_join`, `line_follows_path`, `show_history_line` (restyled
|
|
@@ -516,219 +1023,827 @@ class StoryMapBase {
|
|
|
516
1023
|
* options only take effect on the next navigation or require re-creating
|
|
517
1024
|
* the StoryMap.
|
|
518
1025
|
*/
|
|
1026
|
+
/**
|
|
1027
|
+
* Sugar over {@link setMapOptions} — `setMapOption(name, value)` is the
|
|
1028
|
+
* same call as `setMapOptions({ [name]: value })`.
|
|
1029
|
+
*/
|
|
519
1030
|
setMapOption(name: string, value: unknown) {
|
|
1031
|
+
if (this._disposed) return;
|
|
520
1032
|
this.setMapOptions({ [name]: value } as Partial<StorymapOptions>);
|
|
521
1033
|
}
|
|
522
1034
|
|
|
523
1035
|
/**
|
|
524
1036
|
* Show or hide a stacked overlay by index (see the `overlays` option).
|
|
1037
|
+
* Prefer `getOverlayLayer(index).setVisible(v)` on the layer itself; this
|
|
1038
|
+
* wrapper also re-syncs the attribution line and the overlay blend mode.
|
|
525
1039
|
*/
|
|
526
1040
|
setOverlayVisible(index: number, visible: boolean): void {
|
|
527
|
-
this.
|
|
1041
|
+
if (this._disposed) return;
|
|
1042
|
+
this._map?.setOverlayVisible(index, visible);
|
|
528
1043
|
}
|
|
529
1044
|
|
|
530
1045
|
/**
|
|
531
1046
|
* Set a stacked overlay's opacity by index (see the `overlays` option).
|
|
1047
|
+
* Alias: `getOverlayLayer(index)?.setOpacity(opacity)`.
|
|
532
1048
|
*/
|
|
533
1049
|
setOverlayOpacity(index: number, opacity: number): void {
|
|
534
|
-
this.
|
|
1050
|
+
if (this._disposed) return;
|
|
1051
|
+
this._map?.setOverlayOpacity(index, opacity);
|
|
535
1052
|
}
|
|
536
1053
|
|
|
537
1054
|
/**
|
|
538
1055
|
* Change several map options at runtime (see setMapOption).
|
|
539
1056
|
*/
|
|
540
1057
|
setMapOptions(options: Partial<StorymapOptions>) {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
1058
|
+
if (this._disposed) return;
|
|
1059
|
+
// `map_type` decides at construction time whether a map exists. The
|
|
1060
|
+
// engine can swap one basemap for another, but it can neither grow a
|
|
1061
|
+
// map onto a mapless story nor remove a built one — "none" has no
|
|
1062
|
+
// basemap branch and would silently install OpenStreetMap, the exact
|
|
1063
|
+
// inverse of its meaning — and a non-string crashes the layer
|
|
1064
|
+
// builder. So both are declined here, loudly, rather than half-applied.
|
|
1065
|
+
let effective = options;
|
|
1066
|
+
if (options.map_type !== undefined) {
|
|
1067
|
+
if (typeof options.map_type !== "string") {
|
|
1068
|
+
console.warn(
|
|
1069
|
+
`StoryMapJS: setMapOptions ignores a non-string map_type (${String(options.map_type)}); pass "" for the default basemap.`,
|
|
1070
|
+
);
|
|
1071
|
+
effective = { ...options, map_type: "" };
|
|
1072
|
+
} else if (options.map_type === "none" || this._map_disabled) {
|
|
1073
|
+
console.warn(
|
|
1074
|
+
"StoryMapJS: setMapOptions cannot add or remove a map after construction; map_type is load-time only.",
|
|
1075
|
+
);
|
|
1076
|
+
effective = { ...options };
|
|
1077
|
+
delete effective.map_type;
|
|
550
1078
|
}
|
|
551
1079
|
}
|
|
1080
|
+
mergeData(this.options, effective);
|
|
1081
|
+
// slideshow-only keys stay inert for internal documents at runtime too
|
|
1082
|
+
this._normalizeNonSlideshowOptions();
|
|
1083
|
+
if (this._map && this._map.options) {
|
|
1084
|
+
mergeData(this._map.options, effective);
|
|
1085
|
+
this._normalizeNonSlideshowOptions(this._map.options);
|
|
1086
|
+
this._map.applyOptions(Object.keys(effective));
|
|
1087
|
+
}
|
|
552
1088
|
if (this.ready) {
|
|
553
1089
|
// text color theming follows runtime option changes (issue #177)
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
this.options.text_background_color,
|
|
561
|
-
);
|
|
562
|
-
}
|
|
1090
|
+
this._applyTextColors();
|
|
1091
|
+
// slideshow chrome that maps onto container state does too;
|
|
1092
|
+
// layout modes (textmode, mode) need a display pass to take effect
|
|
1093
|
+
this._applyChrome();
|
|
1094
|
+
// layers may have been added, removed or re-projected
|
|
1095
|
+
this._menubar.refreshLayers();
|
|
563
1096
|
this.updateDisplay();
|
|
564
1097
|
}
|
|
565
1098
|
}
|
|
566
1099
|
|
|
567
|
-
/*
|
|
568
|
-
|
|
1100
|
+
/* OpenLayers accessors
|
|
1101
|
+
The map itself is public (`storymap.map`, the raw `ol/Map`). These hand
|
|
1102
|
+
out the objects the viewer keeps private, so a consumer never has to reach
|
|
1103
|
+
through an underscore field. `getBaseLayer()` returns `null` while the
|
|
1104
|
+
base layer is deferred awaiting tile consent.
|
|
1105
|
+
================================================= */
|
|
569
1106
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
this.
|
|
573
|
-
|
|
1107
|
+
/** The base tile layer, or `null` when deferred (tile consent not granted). */
|
|
1108
|
+
getBaseLayer(): OlLayer | null {
|
|
1109
|
+
return !this._disposed && this._map ? this._map.getBaseLayer() : null;
|
|
1110
|
+
}
|
|
574
1111
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
}
|
|
580
|
-
if (this.options.text_background_color) {
|
|
581
|
-
this._el.container.style.setProperty(
|
|
582
|
-
"--vco-color-text-background",
|
|
583
|
-
this.options.text_background_color,
|
|
584
|
-
);
|
|
585
|
-
}
|
|
1112
|
+
/** The stacked `overlays[]` layers, in `overlays[]` order. */
|
|
1113
|
+
getOverlayLayers(): OlLayer[] {
|
|
1114
|
+
return !this._disposed && this._map ? this._map.getOverlayLayers() : [];
|
|
1115
|
+
}
|
|
586
1116
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
this.
|
|
590
|
-
|
|
591
|
-
this._el.map = Dom.create("div", "vco-map", this._el.container);
|
|
592
|
-
}
|
|
593
|
-
this._el.storyslider = Dom.create("div", "vco-storyslider", this._el.container);
|
|
1117
|
+
/** One stacked overlay layer by its `overlays[]` index, or `null`. */
|
|
1118
|
+
getOverlayLayer(index: number): OlLayer | null {
|
|
1119
|
+
return !this._disposed && this._map ? this._map.getOverlayLayer(index) : null;
|
|
1120
|
+
}
|
|
594
1121
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
this.
|
|
598
|
-
|
|
599
|
-
this._el.storyslider.style.top = "1px";
|
|
1122
|
+
/** Number of stacked overlay layers (see the `overlays` option). */
|
|
1123
|
+
getOverlayCount(): number {
|
|
1124
|
+
return !this._disposed && this._map ? this._map.getOverlayCount() : 0;
|
|
1125
|
+
}
|
|
600
1126
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
this.
|
|
604
|
-
|
|
1127
|
+
/** The minimap's OpenLayers map (the `OverviewMap` control's inner map). */
|
|
1128
|
+
getMinimap(): OlMap | null {
|
|
1129
|
+
return !this._disposed && this._map ? this._map.getMinimap() : null;
|
|
1130
|
+
}
|
|
605
1131
|
|
|
606
|
-
|
|
607
|
-
|
|
1132
|
+
/** The full (inactive) route line layer. */
|
|
1133
|
+
getLine(): OlLayer | null {
|
|
1134
|
+
return !this._disposed && this._map ? this._map.getLine() : null;
|
|
1135
|
+
}
|
|
608
1136
|
|
|
609
|
-
|
|
610
|
-
|
|
1137
|
+
/**
|
|
1138
|
+
* True when the map shows a IIIF image as a picture of the world
|
|
1139
|
+
* (`map_as_image`) rather than as a georeferenced map. In that mode the
|
|
1140
|
+
* view is `EPSG:4326` image space: `map.getView().getCenter()` is in
|
|
1141
|
+
* degrees, the resolution is in pixels per degree, and marker locations
|
|
1142
|
+
* are pixel offsets into the image. Ask this instead of checking
|
|
1143
|
+
* `map_type` — georeferenced IIIF (`map_bbox` without `map_as_image`)
|
|
1144
|
+
* is an ordinary mercator map.
|
|
1145
|
+
*/
|
|
1146
|
+
isImageSpace(): boolean {
|
|
1147
|
+
return !this._disposed && this._map ? this._map.isImageSpace() : false;
|
|
1148
|
+
}
|
|
611
1149
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
1150
|
+
/**
|
|
1151
|
+
* The `uniqueid` of a slide — the current one by default.
|
|
1152
|
+
*
|
|
1153
|
+
* A IIIF manifest supplies one per canvas (§2.3) and it is the only
|
|
1154
|
+
* identity a link can be built on, since an index moves when a slide is
|
|
1155
|
+
* inserted. A storymap-JSON slide usually has none, in which case this is
|
|
1156
|
+
* `null` rather than a generated id: a random one would be stable to nobody.
|
|
1157
|
+
*/
|
|
1158
|
+
getSlideId(index?: number): string | null {
|
|
1159
|
+
if (this._disposed) return null;
|
|
1160
|
+
const at = index ?? this.current_slide;
|
|
1161
|
+
const id = this.data?.slides?.[at]?.uniqueid;
|
|
1162
|
+
return typeof id === "string" && id !== "" ? id : null;
|
|
1163
|
+
}
|
|
617
1164
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
1165
|
+
/**
|
|
1166
|
+
* Read the annotation documents this manifest points at with `seeAlso` and
|
|
1167
|
+
* add the tour stops they carry.
|
|
1168
|
+
*
|
|
1169
|
+
* Explicit and asynchronous on purpose (§5.2). The alternative — following
|
|
1170
|
+
* `seeAlso` inside `manifestToStorymapData` — would mean one blocking
|
|
1171
|
+
* network round trip per referenced document before the viewer could paint
|
|
1172
|
+
* its first slide, against third-party servers the host may not want to
|
|
1173
|
+
* contact at all. Here the story stands on its own immediately and the
|
|
1174
|
+
* annotations arrive afterwards, behind a per-(id, type) cache, and
|
|
1175
|
+
* `annotationsloaded` fires when they are in.
|
|
1176
|
+
*
|
|
1177
|
+
* The stops are **appended**, not placed after the canvas they annotate.
|
|
1178
|
+
* A canvas's own annotations are inserted in position at parse time; doing
|
|
1179
|
+
* the same to a live tour would mean rebuilding the slider and every piece
|
|
1180
|
+
* of state that hangs off it, for an ordering nicety. A host that needs
|
|
1181
|
+
* the stops placed exactly can read them off the resolved value and place
|
|
1182
|
+
* them itself.
|
|
1183
|
+
*
|
|
1184
|
+
* @returns the stops that were added, keyed by canvas id, plus any
|
|
1185
|
+
* `SearchService1` endpoint seen and any document that failed to load.
|
|
1186
|
+
*/
|
|
1187
|
+
async loadAnnotations(
|
|
1188
|
+
options: { fetchImpl?: typeof fetch } = {},
|
|
1189
|
+
): Promise<{ stops: StorymapSlide[]; searchService: string | null; failed: string[] }> {
|
|
1190
|
+
const added: StorymapSlide[] = [];
|
|
1191
|
+
if (this._disposed || this._raw_manifest === null) {
|
|
1192
|
+
return { stops: added, searchService: null, failed: [] };
|
|
632
1193
|
}
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
1194
|
+
const loaded = await loadSeeAlso(this._raw_manifest, options);
|
|
1195
|
+
if (this._disposed) {
|
|
1196
|
+
// torn down while the requests were in flight
|
|
1197
|
+
return { stops: added, searchService: loaded.searchService, failed: loaded.failed };
|
|
1198
|
+
}
|
|
1199
|
+
// canvas order, so the appended stops follow the story rather than the
|
|
1200
|
+
// order the documents happened to resolve in
|
|
1201
|
+
for (const slide of this.data.slides ?? []) {
|
|
1202
|
+
const stops = loaded.stops.get(slide.uniqueid ?? "");
|
|
1203
|
+
if (stops === undefined) continue;
|
|
1204
|
+
for (const stop of stops) {
|
|
1205
|
+
stop.uniqueid = `${slide.uniqueid ?? "slide"}#seealso-${added.length}`;
|
|
1206
|
+
added.push(stop);
|
|
1207
|
+
this.data.slides.push(stop);
|
|
1208
|
+
this._storyslider.createSlide(stop);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
if (added.length > 0) {
|
|
1212
|
+
this._updateProgress();
|
|
1213
|
+
this._updateDistance();
|
|
1214
|
+
}
|
|
1215
|
+
const result = { stops: added, searchService: loaded.searchService, failed: loaded.failed };
|
|
1216
|
+
this.fire("annotationsloaded", result);
|
|
1217
|
+
return result;
|
|
639
1218
|
}
|
|
640
1219
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
1220
|
+
/**
|
|
1221
|
+
* Release everything the viewer attached to the page: window/document
|
|
1222
|
+
* listeners, timers, the resize observer, running Web Animations, the
|
|
1223
|
+
* slider, the menubar, the map and the map markers. Use this when tearing
|
|
1224
|
+
* a storymap down (SPA route change, modal close).
|
|
1225
|
+
*
|
|
1226
|
+
* Teardown is terminal: the host container is emptied, `map` becomes
|
|
1227
|
+
* null, and the public methods (`goTo`, `setMapOptions`,
|
|
1228
|
+
* `createMiniMap`, ...) become no-ops rather than throwing. There is no
|
|
1229
|
+
* re-init path; build a new StoryMap on the same element instead.
|
|
1230
|
+
* Calling `dispose()` twice is a no-op.
|
|
1231
|
+
*/
|
|
1232
|
+
dispose(): void {
|
|
1233
|
+
if (this._disposed) {
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
this._disposed = true;
|
|
1237
|
+
// `updateDisplay()` is gated on `ready`, not `_disposed`, so without
|
|
1238
|
+
// this a post-teardown resize listener (or a stray host call) re-runs
|
|
1239
|
+
// the whole layout — and reaches the map engine — against an emptied
|
|
1240
|
+
// container and a disposed ol/Map.
|
|
1241
|
+
this.ready = false;
|
|
654
1242
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
1243
|
+
for (const timer of [this._transition_timer, this._autoplay_timer, this._resize_timer]) {
|
|
1244
|
+
if (timer !== null && timer !== undefined) {
|
|
1245
|
+
clearTimeout(timer);
|
|
1246
|
+
}
|
|
659
1247
|
}
|
|
1248
|
+
this._transition_timer = null;
|
|
1249
|
+
this._autoplay_timer = null;
|
|
1250
|
+
this._resize_timer = null;
|
|
660
1251
|
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
}
|
|
1252
|
+
this._resize_observer?.disconnect();
|
|
1253
|
+
this._resize_observer = null;
|
|
664
1254
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
1255
|
+
if (this._on_resize) {
|
|
1256
|
+
window.removeEventListener("resize", this._on_resize);
|
|
1257
|
+
this._on_resize = null;
|
|
668
1258
|
}
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
return;
|
|
1259
|
+
if (this._on_keydown_global) {
|
|
1260
|
+
window.removeEventListener("keydown", this._on_keydown_global);
|
|
1261
|
+
this._on_keydown_global = null;
|
|
673
1262
|
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
1263
|
+
if (this._on_fullscreen) {
|
|
1264
|
+
document.removeEventListener("fullscreenchange", this._on_fullscreen);
|
|
1265
|
+
this._on_fullscreen = null;
|
|
677
1266
|
}
|
|
678
|
-
if (
|
|
679
|
-
|
|
680
|
-
this.
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
|
|
1267
|
+
if (this._on_hashchange) {
|
|
1268
|
+
window.removeEventListener("hashchange", this._on_hashchange);
|
|
1269
|
+
this._on_hashchange = null;
|
|
1270
|
+
}
|
|
1271
|
+
if (this._on_reduced_motion && typeof window.matchMedia === "function") {
|
|
1272
|
+
window
|
|
1273
|
+
.matchMedia(REDUCED_MOTION_QUERY)
|
|
1274
|
+
.removeEventListener("change", this._on_reduced_motion);
|
|
1275
|
+
this._on_reduced_motion = null;
|
|
684
1276
|
}
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
// Update View
|
|
688
|
-
_updateDisplay(map_height?: number, animate?: boolean, d?: number) {
|
|
689
|
-
let duration = this.options.duration,
|
|
690
|
-
display_class = this.options.base_class;
|
|
691
1277
|
|
|
692
|
-
|
|
693
|
-
|
|
1278
|
+
// slide transitions in flight (Web Animations API)
|
|
1279
|
+
for (const el of [this._el?.container, this._el?.map]) {
|
|
1280
|
+
el?.getAnimations?.().forEach((a) => a.cancel());
|
|
694
1281
|
}
|
|
695
1282
|
|
|
696
|
-
//
|
|
697
|
-
|
|
698
|
-
this.options
|
|
1283
|
+
// an unanswered consent panel holds a promise that Media.loadMedia()
|
|
1284
|
+
// is awaiting; settle it before the children go away
|
|
1285
|
+
consentManagerOf(this.options)?.dispose();
|
|
699
1286
|
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
this.
|
|
1287
|
+
this._stopNarration();
|
|
1288
|
+
this._stopAmbient();
|
|
1289
|
+
if (this._narration_el) {
|
|
1290
|
+
this._narration_el.src = "";
|
|
1291
|
+
this._narration_el = null;
|
|
705
1292
|
}
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
this.options.map_height = map_height;
|
|
1293
|
+
if (this._ambient_el) {
|
|
1294
|
+
this._ambient_el.src = "";
|
|
1295
|
+
this._ambient_el = null;
|
|
710
1296
|
}
|
|
1297
|
+
this._narration_meta = null;
|
|
1298
|
+
this._ambient_meta = null;
|
|
1299
|
+
// detach the child subscriptions so a host holding a documented
|
|
1300
|
+
// child handle (storymap._map) cannot keep this viewer alive
|
|
1301
|
+
// through the listener contexts
|
|
1302
|
+
this._map?.off("loaded", this._onMapLoaded, this);
|
|
1303
|
+
this._map?.off("change", this._onMapChange, this);
|
|
1304
|
+
this._map?.off("imageready", this._onImageReady, this);
|
|
1305
|
+
this._map?.off("popupopen", this._onMarkerPopupOpen, this);
|
|
1306
|
+
this._map?.off("popupclose", this._onMarkerPopupClose, this);
|
|
1307
|
+
this._map?.off("markerclick", this._onMarkerClick, this);
|
|
1308
|
+
this._map?.off("tilesallowed", this._onTilesAllowed, this);
|
|
1309
|
+
this._storyslider?.off("loaded", this._onStorySliderLoaded, this);
|
|
1310
|
+
this._storyslider?.off("title", this._onTitle, this);
|
|
1311
|
+
this._storyslider?.off("change", this._onSlideChange, this);
|
|
1312
|
+
this._storyslider?.off("colorchange", this._onColorChange, this);
|
|
1313
|
+
this._menubar?.off("collapse", this._onMenuBarCollapse, this);
|
|
1314
|
+
this._menubar?.off("back_to_start", this._onBackToStart, this);
|
|
1315
|
+
this._menubar?.off("overview", this._onOverview, this);
|
|
1316
|
+
this._menubar?.off("fullscreen", this._onFullscreenToggle, this);
|
|
1317
|
+
this._menubar?.off("basemapchange", this._onLayersBasemap, this);
|
|
1318
|
+
this._menubar?.off("overlaychange", this._onLayersOverlay, this);
|
|
1319
|
+
this._menubar?.off("autoplay_toggle", this._onAutoplayToggle, this);
|
|
1320
|
+
this._menubar?.off("progress_go", this._onProgressGo, this);
|
|
1321
|
+
this._storyslider?.dispose?.();
|
|
1322
|
+
this._menubar?.dispose?.();
|
|
1323
|
+
this._map?.dispose?.();
|
|
1324
|
+
|
|
1325
|
+
// the public ol/Map handle. The viewer is terminal after dispose(), so
|
|
1326
|
+
// this is null rather than a disposed instance; the public methods
|
|
1327
|
+
// above are guarded and no-op instead of throwing.
|
|
1328
|
+
this.map = null;
|
|
1329
|
+
|
|
1330
|
+
// empty the host element: the children owned their own listeners and
|
|
1331
|
+
// players, but their nodes (and the ol canvas) would otherwise stay
|
|
1332
|
+
// in the document after teardown
|
|
1333
|
+
this._el?.container?.replaceChildren();
|
|
1334
|
+
|
|
1335
|
+
// let another viewer claim a different language, now that this one is
|
|
1336
|
+
// no longer reading the shared strings (refcounted, so a sibling in
|
|
1337
|
+
// the same language keeps its claim)
|
|
1338
|
+
releaseLanguage(this._language_holder);
|
|
1339
|
+
|
|
1340
|
+
if (this._onInteraction) {
|
|
1341
|
+
for (const type of ["pointerdown", "focusin", "wheel"] as const) {
|
|
1342
|
+
this._el.container.removeEventListener(type, this._onInteraction);
|
|
1343
|
+
}
|
|
1344
|
+
this._onInteraction = null;
|
|
1345
|
+
}
|
|
1346
|
+
unregisterParticipant(this);
|
|
1347
|
+
}
|
|
711
1348
|
|
|
712
|
-
|
|
1349
|
+
/**
|
|
1350
|
+
* This viewer's root element. Satisfies the `Participant` shape the
|
|
1351
|
+
* page-wide key registry (`src/core/viewers.ts`) arbitrates on.
|
|
1352
|
+
*
|
|
1353
|
+
* @internal
|
|
1354
|
+
*/
|
|
1355
|
+
get element(): HTMLElement | null {
|
|
1356
|
+
return this._el?.container ?? null;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/**
|
|
1360
|
+
* Monotonic stamp of the last interaction with this viewer; the highest
|
|
1361
|
+
* one owns a page-wide keypress when nothing is focused.
|
|
1362
|
+
*
|
|
1363
|
+
* @internal
|
|
1364
|
+
*/
|
|
1365
|
+
get interaction(): number {
|
|
1366
|
+
return this._interaction;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
/** The highlighted route line drawn up to the current slide. */
|
|
1370
|
+
getLineActive(): OlLayer | null {
|
|
1371
|
+
return !this._disposed && this._map ? this._map.getLineActive() : null;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/** The map markers, indexed by slide. */
|
|
1375
|
+
getMarkers(): OpenLayersMapMarker[] {
|
|
1376
|
+
return !this._disposed && this._map ? this._map.getMarkers() : [];
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
/** One map marker by slide index, or `null`. */
|
|
1380
|
+
getMarker(index: number): OpenLayersMapMarker | null {
|
|
1381
|
+
return !this._disposed && this._map ? this._map.getMarker(index) : null;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
/**
|
|
1385
|
+
* Open the popup card for slide `n`'s marker, navigating there first
|
|
1386
|
+
* when it is not the current slide.
|
|
1387
|
+
*
|
|
1388
|
+
* Returns false (and changes nothing) when the viewer is disposed,
|
|
1389
|
+
* has no map (`map_type: "none"`), `n` is out of range, or the marker
|
|
1390
|
+
* has popups disabled. Navigating fires the usual `change` (and
|
|
1391
|
+
* transition) events; a successful open fires `popupopen`.
|
|
1392
|
+
*/
|
|
1393
|
+
openMarkerPopup(n: number): boolean {
|
|
1394
|
+
if (this._disposed) return false;
|
|
1395
|
+
if (!Number.isInteger(n) || n < 0 || n >= (this.data?.slides?.length ?? 0)) {
|
|
1396
|
+
return false;
|
|
1397
|
+
}
|
|
1398
|
+
if (this._map === null || this._map === undefined) return false;
|
|
1399
|
+
const marker = this._map.getMarker(n);
|
|
1400
|
+
if (!marker || !marker.isPopupEnabled()) return false;
|
|
1401
|
+
if (n !== this.current_slide) {
|
|
1402
|
+
this.goTo(n);
|
|
1403
|
+
}
|
|
1404
|
+
return marker.openPopup();
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
/**
|
|
1408
|
+
* Close one marker's popup card (`n` given) or every open card
|
|
1409
|
+
* (omitted). No-op when the viewer is disposed or mapless. Fires
|
|
1410
|
+
* `popupclose` per card actually closed.
|
|
1411
|
+
*/
|
|
1412
|
+
closeMarkerPopup(n?: number): void {
|
|
1413
|
+
if (this._disposed) return;
|
|
1414
|
+
if (this._map === null || this._map === undefined) return;
|
|
1415
|
+
if (n === undefined) {
|
|
1416
|
+
for (const marker of this._map.getMarkers()) {
|
|
1417
|
+
marker.closePopup();
|
|
1418
|
+
}
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
this._map.getMarker(n)?.closePopup();
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
/** Whether slide `n`'s marker card is currently open. */
|
|
1425
|
+
isPopupOpen(n: number): boolean {
|
|
1426
|
+
if (this._disposed) return false;
|
|
1427
|
+
if (this._map === null || this._map === undefined) return false;
|
|
1428
|
+
return this._map.getMarker(n)?.popupOpen ?? false;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
/**
|
|
1432
|
+
* Rebuild the minimap (`OverviewMap` control). The constructor already
|
|
1433
|
+
* builds it; this is for hosts that recreate it after a `map_type` swap
|
|
1434
|
+
* or a deferred tile-consent grant.
|
|
1435
|
+
*/
|
|
1436
|
+
createMiniMap(): void {
|
|
1437
|
+
if (this._disposed) return;
|
|
1438
|
+
this._map?.createMiniMap();
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
/**
|
|
1442
|
+
* Append attribution fragments and refresh the credit line (e.g. for a
|
|
1443
|
+
* custom layer added through `tile_source_factory`).
|
|
1444
|
+
*/
|
|
1445
|
+
setExtraAttributions(parts: string[]): void {
|
|
1446
|
+
if (this._disposed) return;
|
|
1447
|
+
this._map?.setExtraAttributions(parts);
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
/**
|
|
1451
|
+
* Publish the text colour custom properties. Called from both the initial
|
|
1452
|
+
* layout and every runtime option change (issue #177) so the two paths
|
|
1453
|
+
* cannot drift.
|
|
1454
|
+
*/
|
|
1455
|
+
_applyTextColors(): void {
|
|
1456
|
+
if (this.options.text_color) {
|
|
1457
|
+
this._el.container.style.setProperty("--vco-color-text", this.options.text_color);
|
|
1458
|
+
}
|
|
1459
|
+
if (this.options.text_background_color) {
|
|
1460
|
+
this._el.container.style.setProperty(
|
|
1461
|
+
"--vco-color-text-background",
|
|
1462
|
+
this.options.text_background_color,
|
|
1463
|
+
);
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/**
|
|
1468
|
+
* Publish the slideshow-driven player chrome as container state.
|
|
1469
|
+
* Everything here is opt-in: unset options leave the container exactly
|
|
1470
|
+
* as the long-standing layout does, so existing stories are unaffected.
|
|
1471
|
+
*/
|
|
1472
|
+
_applyChrome(): void {
|
|
1473
|
+
const options = this.options;
|
|
1474
|
+
const container = this._el.container;
|
|
1475
|
+
// idempotent: runtime option changes re-run this, so previous state
|
|
1476
|
+
// is cleared first (unset means the long-standing layout)
|
|
1477
|
+
container.classList.remove(
|
|
1478
|
+
"vco-textmode-left",
|
|
1479
|
+
"vco-textmode-bottom",
|
|
1480
|
+
"vco-no-headings",
|
|
1481
|
+
"vco-no-scrollbars",
|
|
1482
|
+
"vco-mode-static",
|
|
1483
|
+
);
|
|
1484
|
+
if (options.hudcolor) {
|
|
1485
|
+
container.style.setProperty("--vco-hud-fg", options.hudcolor);
|
|
1486
|
+
} else {
|
|
1487
|
+
container.style.removeProperty("--vco-hud-fg");
|
|
1488
|
+
}
|
|
1489
|
+
if (options.hudbgcolor) {
|
|
1490
|
+
const opacity = asClampedNumber(options.hudopacity, 0, 100);
|
|
1491
|
+
container.style.setProperty(
|
|
1492
|
+
"--vco-hud-bg",
|
|
1493
|
+
opacity === null
|
|
1494
|
+
? options.hudbgcolor
|
|
1495
|
+
: `color-mix(in srgb, ${options.hudbgcolor} ${Math.round(opacity)}%, transparent)`,
|
|
1496
|
+
);
|
|
1497
|
+
} else {
|
|
1498
|
+
container.style.removeProperty("--vco-hud-bg");
|
|
1499
|
+
}
|
|
1500
|
+
if (typeof options.viewerheight === "string" && options.viewerheight !== "") {
|
|
1501
|
+
container.style.height = options.viewerheight;
|
|
1502
|
+
} else {
|
|
1503
|
+
container.style.removeProperty("height");
|
|
1504
|
+
}
|
|
1505
|
+
if (options.textmode === "left" || options.textmode === "bottom") {
|
|
1506
|
+
container.classList.add(`vco-textmode-${options.textmode}`);
|
|
1507
|
+
}
|
|
1508
|
+
if (options.show_headings === false) {
|
|
1509
|
+
container.classList.add("vco-no-headings");
|
|
1510
|
+
}
|
|
1511
|
+
if (options.show_scrollbars === false) {
|
|
1512
|
+
container.classList.add("vco-no-scrollbars");
|
|
1513
|
+
}
|
|
1514
|
+
if (options.mode === "static") {
|
|
1515
|
+
container.classList.add("vco-mode-static");
|
|
1516
|
+
}
|
|
1517
|
+
const textsize = options.textsize;
|
|
1518
|
+
if (typeof textsize === "number" && Number.isFinite(textsize)) {
|
|
1519
|
+
const clamped = asClampedNumber(textsize, 10, 80) ?? 50;
|
|
1520
|
+
container.style.setProperty("--vco-panel-size", `${clamped}%`);
|
|
1521
|
+
} else {
|
|
1522
|
+
container.style.removeProperty("--vco-panel-size");
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/**
|
|
1527
|
+
* The slide panel share of the width (side docks) or height (bottom
|
|
1528
|
+
* dock) as a fraction: `textsize` percent when valid (10..80), else the
|
|
1529
|
+
* built-in split (half for sides, 40% height for the bottom dock).
|
|
1530
|
+
*/
|
|
1531
|
+
_panelShare(): number {
|
|
1532
|
+
const textsize = this.options.textsize;
|
|
1533
|
+
if (typeof textsize === "number" && Number.isFinite(textsize)) {
|
|
1534
|
+
return (asClampedNumber(textsize, 10, 80) ?? 50) / 100;
|
|
1535
|
+
}
|
|
1536
|
+
return this.options.textmode === "bottom" ? 0.4 : 0.5;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
/* Private Methods
|
|
1540
|
+
================================================== */
|
|
1541
|
+
|
|
1542
|
+
// Initialize the layout
|
|
1543
|
+
_initLayout() {
|
|
1544
|
+
if (this._disposed) {
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
this._el.container.className += " vco-storymap";
|
|
1548
|
+
this.options.base_class = this._el.container.className;
|
|
1549
|
+
|
|
1550
|
+
// slideshow-driven player chrome first: viewerheight is an inline
|
|
1551
|
+
// height that the measurement below must see (classes are re-applied
|
|
1552
|
+
// in _updateDisplay, which rebuilds className from base_class)
|
|
1553
|
+
this._applyChrome();
|
|
1554
|
+
|
|
1555
|
+
// Colour theme (dark theme plan): an explicit option pins the
|
|
1556
|
+
// palette via data-vco-theme; unset, the CSS falls back to
|
|
1557
|
+
// prefers-color-scheme. Construction-time only.
|
|
1558
|
+
if (this.options.theme === "dark" || this.options.theme === "light") {
|
|
1559
|
+
this._el.container.setAttribute("data-vco-theme", this.options.theme);
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
// Text color theming (issue #177): expose the text colors as CSS
|
|
1563
|
+
// custom properties consumed by the slide typography
|
|
1564
|
+
this._applyTextColors();
|
|
1565
|
+
|
|
1566
|
+
// Create Layout
|
|
1567
|
+
this._el.menubar = Dom.create("div", "vco-menubar", this._el.container);
|
|
1568
|
+
this._el.map = this._map_disabled
|
|
1569
|
+
? null
|
|
1570
|
+
: (this._resolveMapElement() ?? Dom.create("div", "vco-map", this._el.container));
|
|
1571
|
+
this._el.storyslider = Dom.create("div", "vco-storyslider", this._el.container);
|
|
1572
|
+
// Skip link (a11y): first tab stop inside the widget, jumping to
|
|
1573
|
+
// the slide content. Only when the host container has an id to
|
|
1574
|
+
// anchor both ends to (multi-viewer pages need unique targets).
|
|
1575
|
+
if (this._el.container.id !== "") {
|
|
1576
|
+
this._el.storyslider.id = `${this._el.container.id}-slides`;
|
|
1577
|
+
const skip = Dom.create("a", "vco-skip-link", this._el.container);
|
|
1578
|
+
skip.setAttribute("href", `#${this._el.storyslider.id}`);
|
|
1579
|
+
skip.textContent =
|
|
1580
|
+
(Language.messages.skip_link as string | undefined) ?? "Skip to story content";
|
|
1581
|
+
this._el.container.prepend(skip);
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// Initial Default Layout
|
|
1585
|
+
this.options.width = this._el.container.offsetWidth;
|
|
1586
|
+
this.options.height = this._el.container.offsetHeight;
|
|
1587
|
+
if (this._el.map) {
|
|
1588
|
+
this._el.map.style.height = "1px";
|
|
1589
|
+
}
|
|
1590
|
+
this._el.storyslider.style.top = "1px";
|
|
1591
|
+
|
|
1592
|
+
if (this._map_disabled) {
|
|
1593
|
+
// No map pane, so the map options have nothing to size. The
|
|
1594
|
+
// slider takes the whole height below the menubar, and every
|
|
1595
|
+
// map-derived option is set to something the layout code below
|
|
1596
|
+
// does not need. Nothing is constructed - no ol/Map, no tile
|
|
1597
|
+
// layer, no consent ask. storyslider_height is set by the
|
|
1598
|
+
// _updateDisplay call below, so assigning it here would be dead.
|
|
1599
|
+
this.options.map_height = 0;
|
|
1600
|
+
// the overview control is a map control: with no map there is
|
|
1601
|
+
// nothing for it to zoom out to, so it is hidden the same way
|
|
1602
|
+
// `show_overview: false` hides it
|
|
1603
|
+
this.options.show_overview = false;
|
|
1604
|
+
} else {
|
|
1605
|
+
// Create Map using preferred Map API
|
|
1606
|
+
this._map = new OpenLayersMap(this._map_el(), this.data, this.options);
|
|
1607
|
+
this.map = this._map._map; // For access to the OpenLayers map.
|
|
1608
|
+
this._map.on("loaded", this._onMapLoaded, this);
|
|
1609
|
+
// image readiness (IIIF/zoomify sources attach asynchronously) is
|
|
1610
|
+
// re-fired on the StoryMap, the coordination point for hosts that
|
|
1611
|
+
// overlay or measure their own layers
|
|
1612
|
+
this._map.on("imageready", this._onImageReady, this);
|
|
1613
|
+
|
|
1614
|
+
// Map Background Color
|
|
1615
|
+
this._map_el().style.backgroundColor = this.options.map_background_color;
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
// Create Menu Bar
|
|
1619
|
+
this._menubar = new MenuBar(this._el.menubar, this._el.container, this.options);
|
|
1620
|
+
// the progress chrome carries its initial state from construction,
|
|
1621
|
+
// not from the first map/slider load event — otherwise the bar (and
|
|
1622
|
+
// its ARIA) sits uninitialized until tiles arrive (issue #247)
|
|
1623
|
+
this._updateProgress();
|
|
1624
|
+
|
|
1625
|
+
// Create StorySlider
|
|
1626
|
+
this._storyslider = new StorySlider(this._el.storyslider, this.data, this.options);
|
|
1627
|
+
this._storyslider.on("loaded", this._onStorySliderLoaded, this);
|
|
1628
|
+
this._storyslider.on("title", this._onTitle, this);
|
|
1629
|
+
this._storyslider.init();
|
|
1630
|
+
|
|
1631
|
+
// LAYOUT
|
|
1632
|
+
if (this._map_disabled) {
|
|
1633
|
+
// one branch, both orientations: nothing to split the height
|
|
1634
|
+
// with, and the menubar is the only thing above the slider
|
|
1635
|
+
this.options.menubar_height = this._el.menubar.offsetHeight;
|
|
1636
|
+
this._menubar.setSticky(this.options.menubar_height);
|
|
1637
|
+
} else if (this.options.layout === "portrait") {
|
|
1638
|
+
// Set Default Component Sizes
|
|
1639
|
+
this.options.map_height = this.options.height / this.options.map_size_sticky;
|
|
1640
|
+
this.options.storyslider_height =
|
|
1641
|
+
this.options.height - this._el.menubar.offsetHeight - this.options.map_height - 1;
|
|
1642
|
+
this._menubar.setSticky(0);
|
|
1643
|
+
} else {
|
|
1644
|
+
this.options.menubar_height = this._el.menubar.offsetHeight;
|
|
1645
|
+
// Set Default Component Sizes
|
|
1646
|
+
this.options.map_height = this.options.height;
|
|
1647
|
+
this.options.storyslider_height =
|
|
1648
|
+
this.options.height - this._el.menubar.offsetHeight - 1;
|
|
1649
|
+
this._menubar.setSticky(this.options.menubar_height);
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
// Update Display
|
|
1653
|
+
this._updateDisplay(this.options.map_height, true, 2000);
|
|
1654
|
+
|
|
1655
|
+
// Animate Menu Bar to Default Location
|
|
1656
|
+
this._menubar.show(2000);
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
_initEvents() {
|
|
1660
|
+
// Sidebar Events
|
|
1661
|
+
this._menubar.on("collapse", this._onMenuBarCollapse, this);
|
|
1662
|
+
this._menubar.on("back_to_start", this._onBackToStart, this);
|
|
1663
|
+
this._menubar.on("overview", this._onOverview, this);
|
|
1664
|
+
this._menubar.on("fullscreen", this._onFullscreenToggle, this);
|
|
1665
|
+
|
|
1666
|
+
// StorySlider Events
|
|
1667
|
+
this._storyslider.on("change", this._onSlideChange, this);
|
|
1668
|
+
this._storyslider.on("colorchange", this._onColorChange, this);
|
|
1669
|
+
|
|
1670
|
+
// Map Events
|
|
1671
|
+
this._map?.on("change", this._onMapChange, this);
|
|
1672
|
+
this._map?.on("popupopen", this._onMarkerPopupOpen, this);
|
|
1673
|
+
this._map?.on("popupclose", this._onMarkerPopupClose, this);
|
|
1674
|
+
this._map?.on("markerclick", this._onMarkerClick, this);
|
|
1675
|
+
this._map?.on("tilesallowed", this._onTilesAllowed, this);
|
|
1676
|
+
this._menubar.on("basemapchange", this._onLayersBasemap, this);
|
|
1677
|
+
this._menubar.on("overlaychange", this._onLayersOverlay, this);
|
|
1678
|
+
this._menubar.on("autoplay_toggle", this._onAutoplayToggle, this);
|
|
1679
|
+
this._menubar.on("progress_go", this._onProgressGo, this);
|
|
1680
|
+
this._menubar.setLayersDelegate({ rows: () => this._layersRows() });
|
|
1681
|
+
this._menubar.refreshLayers();
|
|
1682
|
+
|
|
1683
|
+
// Global slide navigation (opt-in): the slider only listens on its
|
|
1684
|
+
// own panel, which needs focus.
|
|
1685
|
+
//
|
|
1686
|
+
// With more than one viewer on the page, `window` is shared, and
|
|
1687
|
+
// e.preventDefault() does not stop a sibling listener on the same
|
|
1688
|
+
// target — so an arrow press used to advance every story at once. This
|
|
1689
|
+
// viewer takes part in the registry and then answers whether it owns
|
|
1690
|
+
// the event (focus first, then most-recently-interacted).
|
|
1691
|
+
if (this.options.keyboard) {
|
|
1692
|
+
this._onInteraction = () => {
|
|
1693
|
+
this._interaction = markInteraction();
|
|
1694
|
+
};
|
|
1695
|
+
for (const type of ["pointerdown", "focusin", "wheel"] as const) {
|
|
1696
|
+
this._el.container.addEventListener(type, this._onInteraction, {
|
|
1697
|
+
passive: true,
|
|
1698
|
+
});
|
|
1699
|
+
}
|
|
1700
|
+
registerParticipant(this);
|
|
1701
|
+
this._on_keydown_global = (e: KeyboardEvent) => this._onKeyDownGlobal(e);
|
|
1702
|
+
window.addEventListener("keydown", this._on_keydown_global);
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
// Fullscreen state
|
|
1706
|
+
this._on_fullscreen = () => this._onFullscreenChange();
|
|
1707
|
+
document.addEventListener("fullscreenchange", this._on_fullscreen);
|
|
1708
|
+
|
|
1709
|
+
// An OS reduced-motion toggle mid-story stops automatic advancement;
|
|
1710
|
+
// the next navigation's _scheduleAutoplay re-checks anyway, but an
|
|
1711
|
+
// armed timer would otherwise fire once more first.
|
|
1712
|
+
if (typeof window.matchMedia === "function") {
|
|
1713
|
+
this._on_reduced_motion = (e: MediaQueryListEvent) => {
|
|
1714
|
+
if (e.matches) this._stopAutoplay();
|
|
1715
|
+
};
|
|
1716
|
+
window
|
|
1717
|
+
.matchMedia(REDUCED_MOTION_QUERY)
|
|
1718
|
+
.addEventListener("change", this._on_reduced_motion);
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
_onKeyDownGlobal(e: KeyboardEvent) {
|
|
1723
|
+
if (e.defaultPrevented) {
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1726
|
+
const target = e.target as HTMLElement | null;
|
|
1727
|
+
const tag = target?.tagName?.toLowerCase();
|
|
1728
|
+
if (
|
|
1729
|
+
tag === "input" ||
|
|
1730
|
+
tag === "textarea" ||
|
|
1731
|
+
tag === "select" ||
|
|
1732
|
+
target?.isContentEditable
|
|
1733
|
+
) {
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
// One viewer owns a page-wide keypress. This also covers the old
|
|
1737
|
+
// "let OpenLayers pan the map" carve-out: the map is inside the
|
|
1738
|
+
// viewer's own container, so a keypress aimed at another viewer (or at
|
|
1739
|
+
// nothing the visitor has focused) no longer reaches this slider.
|
|
1740
|
+
if (!ownsPageEvent(this)) {
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
1743
|
+
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
|
1744
|
+
e.preventDefault();
|
|
1745
|
+
this._storyslider.next();
|
|
1746
|
+
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
|
1747
|
+
e.preventDefault();
|
|
1748
|
+
this._storyslider.previous();
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
// View
|
|
1753
|
+
_updateDisplay(map_height?: number, animate?: boolean, d?: number) {
|
|
1754
|
+
// prefers-reduced-motion: glides collapse to instant size changes
|
|
1755
|
+
if (animate && prefersReducedMotion()) {
|
|
1756
|
+
animate = false;
|
|
1757
|
+
}
|
|
1758
|
+
let duration = this.options.duration,
|
|
1759
|
+
display_class = this.options.base_class;
|
|
1760
|
+
|
|
1761
|
+
if (d) {
|
|
1762
|
+
duration = d;
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
// Update width and height
|
|
1766
|
+
this.options.width = this._el.container.offsetWidth;
|
|
1767
|
+
this.options.height = this._el.container.offsetHeight;
|
|
1768
|
+
|
|
1769
|
+
// Check if skinny
|
|
1770
|
+
if (this.options.width <= this.options.skinny_size) {
|
|
1771
|
+
this.options.layout = "portrait";
|
|
1772
|
+
} else {
|
|
1773
|
+
this.options.layout = "landscape";
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
// Map Height
|
|
1777
|
+
if (map_height) {
|
|
1778
|
+
this.options.map_height = map_height;
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
// Detect Mobile and Update Orientation on Touch devices
|
|
713
1782
|
if (Browser.touch) {
|
|
714
1783
|
this.options.layout = Browser.orientation();
|
|
715
1784
|
display_class += " vco-mobile";
|
|
716
1785
|
}
|
|
717
1786
|
|
|
718
1787
|
// LAYOUT
|
|
719
|
-
if (this.
|
|
1788
|
+
if (this._map_disabled) {
|
|
1789
|
+
// The mapless layout: the menubar on top, the slider filling
|
|
1790
|
+
// everything below it, in both orientations. No map element to
|
|
1791
|
+
// size, offset, animate or fit, so none of that runs, and
|
|
1792
|
+
// `map_area` has nothing to narrow.
|
|
1793
|
+
display_class += " vco-layout-no-map";
|
|
1794
|
+
// A narrow mapless story still needs the narrow-viewport slide
|
|
1795
|
+
// layout: every rule that stacks the two-column slide (the
|
|
1796
|
+
// 100px side padding, the floated 50% media block) keys on
|
|
1797
|
+
// vco-skinny, and without it a phone-width story renders broken.
|
|
1798
|
+
// The wide case needs nothing extra — the no-map rule already
|
|
1799
|
+
// owns the full-width opaque panel.
|
|
1800
|
+
if (this.options.layout === "portrait") {
|
|
1801
|
+
display_class += " vco-skinny vco-layout-portrait";
|
|
1802
|
+
}
|
|
1803
|
+
// A narrow mapless story still needs the narrow-viewport slide
|
|
1804
|
+
// layout: every rule that stacks the two-column slide (the
|
|
1805
|
+
// 100px side padding, the floated 50% media block) keys on
|
|
1806
|
+
// vco-skinny, and without it a phone-width story renders broken.
|
|
1807
|
+
// The wide case needs nothing extra — the no-map rule already
|
|
1808
|
+
// owns the full-width opaque panel.
|
|
1809
|
+
|
|
1810
|
+
this.options.menubar_height = this._el.menubar.offsetHeight;
|
|
1811
|
+
this.options.map_height = 0;
|
|
1812
|
+
this.options.storyslider_height = this.options.height - 1;
|
|
1813
|
+
this._menubar.setSticky(this.options.menubar_height);
|
|
1814
|
+
|
|
1815
|
+
this._el.storyslider.style.top = "0";
|
|
1816
|
+
this._el.storyslider.style.height = this.options.storyslider_height + "px";
|
|
1817
|
+
|
|
1818
|
+
this._menubar.updateDisplay(this.options.width, this.options.height, animate);
|
|
1819
|
+
this._storyslider.updateDisplay(
|
|
1820
|
+
this.options.width,
|
|
1821
|
+
this.options.storyslider_height,
|
|
1822
|
+
animate,
|
|
1823
|
+
this.options.layout,
|
|
1824
|
+
);
|
|
1825
|
+
} else if (this.options.layout === "portrait") {
|
|
720
1826
|
display_class += " vco-skinny";
|
|
721
1827
|
// Map Offset
|
|
722
|
-
this.
|
|
723
|
-
|
|
724
|
-
|
|
1828
|
+
this._map_required().setMapOffset(0, 0);
|
|
1829
|
+
|
|
1830
|
+
// Portrait split. The collapse toggle is only offered in portrait
|
|
1831
|
+
// (MenuBar hides it in landscape), so this branch used to
|
|
1832
|
+
// unconditionally recompute map_height and discard the collapsed
|
|
1833
|
+
// height it was handed — the button was a visual no-op. Honour
|
|
1834
|
+
// the toggle state instead.
|
|
1835
|
+
if (this._collapsed) {
|
|
1836
|
+
this.options.map_height = COLLAPSED_MAP_HEIGHT;
|
|
1837
|
+
} else {
|
|
1838
|
+
this.options.map_height = this.options.height / this.options.map_size_sticky;
|
|
1839
|
+
}
|
|
725
1840
|
this.options.storyslider_height = this.options.height - this.options.map_height - 1;
|
|
726
1841
|
this._menubar.setSticky(0);
|
|
727
1842
|
|
|
728
1843
|
// Portrait: the map spans the full width again (a landscape
|
|
729
1844
|
// map_area "left" pass narrowed it)
|
|
730
1845
|
display_class += " vco-layout-portrait";
|
|
731
|
-
this.
|
|
1846
|
+
this._map_el().style.width = "100%";
|
|
732
1847
|
|
|
733
1848
|
if (animate) {
|
|
734
1849
|
// Animate Map
|
|
@@ -736,12 +1851,12 @@ class StoryMapBase {
|
|
|
736
1851
|
this.animator_map.stop();
|
|
737
1852
|
}
|
|
738
1853
|
|
|
739
|
-
this.animator_map = Animate(this.
|
|
1854
|
+
this.animator_map = Animate(this._map_el(), {
|
|
740
1855
|
height: this.options.map_height + "px",
|
|
741
1856
|
duration: duration,
|
|
742
1857
|
easing: easeOutStrong,
|
|
743
1858
|
complete: () => {
|
|
744
|
-
this.
|
|
1859
|
+
this._map_required().updateDisplay(
|
|
745
1860
|
this.options.width,
|
|
746
1861
|
this.options.map_height,
|
|
747
1862
|
animate,
|
|
@@ -762,7 +1877,7 @@ class StoryMapBase {
|
|
|
762
1877
|
});
|
|
763
1878
|
} else {
|
|
764
1879
|
// Map
|
|
765
|
-
this.
|
|
1880
|
+
this._map_el().style.height = Math.ceil(this.options.map_height) + "px";
|
|
766
1881
|
|
|
767
1882
|
// StorySlider
|
|
768
1883
|
this._el.storyslider.style.height = this.options.storyslider_height + "px";
|
|
@@ -770,7 +1885,7 @@ class StoryMapBase {
|
|
|
770
1885
|
|
|
771
1886
|
// Update Component Displays
|
|
772
1887
|
this._menubar.updateDisplay(this.options.width, this.options.height, animate);
|
|
773
|
-
this.
|
|
1888
|
+
this._map_required().updateDisplay(this.options.width, this.options.height, false);
|
|
774
1889
|
this._storyslider.updateDisplay(
|
|
775
1890
|
this.options.width,
|
|
776
1891
|
this.options.storyslider_height,
|
|
@@ -786,12 +1901,11 @@ class StoryMapBase {
|
|
|
786
1901
|
// Set Default Component Sizes
|
|
787
1902
|
this.options.map_height = this.options.height;
|
|
788
1903
|
this.options.storyslider_height = this.options.height;
|
|
789
|
-
this._menubar.setSticky(this.options.menubar_height);
|
|
790
1904
|
|
|
791
1905
|
// Set Sticky state of MenuBar
|
|
792
1906
|
this._menubar.setSticky(this.options.menubar_height);
|
|
793
1907
|
|
|
794
|
-
this.
|
|
1908
|
+
this._map_el().style.height = this.options.height + "px";
|
|
795
1909
|
|
|
796
1910
|
// map_area "left": the map element is limited to the left, visible
|
|
797
1911
|
// half (the slide panel is opaque) — no view offset needed;
|
|
@@ -799,39 +1913,79 @@ class StoryMapBase {
|
|
|
799
1913
|
// fading slide panel and the view is offset by a quarter width
|
|
800
1914
|
if (this.options.map_area === "left") {
|
|
801
1915
|
display_class += " vco-map-area-left";
|
|
802
|
-
this.
|
|
803
|
-
this.
|
|
1916
|
+
this._map_el().style.width = Math.floor(this.options.width / 2) + "px";
|
|
1917
|
+
this._map_required().setMapOffset(0, 0);
|
|
1918
|
+
} else if (this.options.textmode === "bottom") {
|
|
1919
|
+
// bottom dock: the map takes the top share, the slider the
|
|
1920
|
+
// bottom share (like the portrait split, but keeping the
|
|
1921
|
+
// landscape slide styling)
|
|
1922
|
+
display_class += " vco-textmode-bottom";
|
|
1923
|
+
const share = this._panelShare();
|
|
1924
|
+
this.options.map_height = Math.floor(this.options.height * (1 - share));
|
|
1925
|
+
this.options.storyslider_height = this.options.height - this.options.map_height - 1;
|
|
1926
|
+
this._map_el().style.width = "100%";
|
|
1927
|
+
this._map_el().style.height = this.options.map_height + "px";
|
|
1928
|
+
this._map_required().setMapOffset(0, 0);
|
|
1929
|
+
this._el.storyslider.style.top = this.options.map_height + "px";
|
|
1930
|
+
this._el.storyslider.style.height = this.options.storyslider_height + "px";
|
|
1931
|
+
this._menubar.updateDisplay(this.options.width, this.options.height, animate);
|
|
1932
|
+
this._map_required().updateDisplay(
|
|
1933
|
+
this.options.width,
|
|
1934
|
+
this.options.height,
|
|
1935
|
+
animate,
|
|
1936
|
+
d,
|
|
1937
|
+
);
|
|
1938
|
+
this._storyslider.updateDisplay(
|
|
1939
|
+
this.options.width,
|
|
1940
|
+
this.options.storyslider_height,
|
|
1941
|
+
animate,
|
|
1942
|
+
this.options.layout,
|
|
1943
|
+
);
|
|
804
1944
|
} else {
|
|
805
|
-
this.
|
|
806
|
-
this.
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
this._el.storyslider.style.top = "0";
|
|
811
|
-
this._el.storyslider.style.height = this.options.storyslider_height + "px";
|
|
1945
|
+
this._map_el().style.width = "100%";
|
|
1946
|
+
this._map_required().setMapOffset(-(this.options.width / 4), 0);
|
|
1947
|
+
// StorySlider
|
|
1948
|
+
this._el.storyslider.style.top = "0";
|
|
1949
|
+
this._el.storyslider.style.height = this.options.storyslider_height + "px";
|
|
812
1950
|
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
1951
|
+
this._menubar.updateDisplay(this.options.width, this.options.height, animate);
|
|
1952
|
+
this._map_required().updateDisplay(
|
|
1953
|
+
this.options.width,
|
|
1954
|
+
this.options.height,
|
|
1955
|
+
animate,
|
|
1956
|
+
d,
|
|
1957
|
+
);
|
|
1958
|
+
this._storyslider.updateDisplay(
|
|
1959
|
+
this.options.width * this._panelShare(),
|
|
1960
|
+
this.options.storyslider_height,
|
|
1961
|
+
animate,
|
|
1962
|
+
this.options.layout,
|
|
1963
|
+
);
|
|
1964
|
+
}
|
|
821
1965
|
}
|
|
822
1966
|
|
|
823
|
-
|
|
1967
|
+
// the resolved locale decides the layout direction (issues #211, #245)
|
|
1968
|
+
if (isRtl()) {
|
|
824
1969
|
display_class += " vco-rtl";
|
|
825
1970
|
}
|
|
826
1971
|
|
|
827
1972
|
// Apply class
|
|
828
1973
|
this._el.container.className = display_class;
|
|
1974
|
+
// _updateDisplay rebuilds the container class from base_class, which
|
|
1975
|
+
// wipes the opt-in chrome state _initLayout applied — re-apply it
|
|
1976
|
+
// after every layout pass (init, resize, runtime option changes)
|
|
1977
|
+
this._applyChrome();
|
|
829
1978
|
}
|
|
830
1979
|
|
|
831
1980
|
/* Events
|
|
832
1981
|
================================================== */
|
|
833
1982
|
|
|
834
1983
|
_onDataLoaded(e?: unknown) {
|
|
1984
|
+
// Belt and braces with the _loadDataFromUrl guard: anything reaching
|
|
1985
|
+
// this point after a teardown must not rebuild the layout.
|
|
1986
|
+
if (this._disposed) {
|
|
1987
|
+
return;
|
|
1988
|
+
}
|
|
835
1989
|
// attach the consent manager BEFORE the layout is created, so the
|
|
836
1990
|
// slider/map/media options copies all share it
|
|
837
1991
|
(this.options as Record<string, unknown>).consent_manager = new ConsentManager();
|
|
@@ -845,19 +1999,332 @@ class StoryMapBase {
|
|
|
845
1999
|
this._initEvents();
|
|
846
2000
|
this._initResizeHandling();
|
|
847
2001
|
this.ready = true;
|
|
2002
|
+
this._startConsentAsk();
|
|
848
2003
|
this._startAutoplay();
|
|
849
2004
|
}
|
|
850
2005
|
|
|
2006
|
+
/**
|
|
2007
|
+
* Start-of-story consent (issue: allow all / individually / decline
|
|
2008
|
+
* all): with `consent_required`, one dialog lists every external
|
|
2009
|
+
* service the story uses — map tiles, external web fonts and the media
|
|
2010
|
+
* services in the slides. Shown only while some service is unanswered;
|
|
2011
|
+
* per-service slide asks remain the fallback.
|
|
2012
|
+
*/
|
|
2013
|
+
_startConsentAsk(): void {
|
|
2014
|
+
const manager = consentManagerOf(this.options);
|
|
2015
|
+
if (!manager || !this.options.consent_required) {
|
|
2016
|
+
return;
|
|
2017
|
+
}
|
|
2018
|
+
const services: ConsentService[] = [];
|
|
2019
|
+
// map tiles — the same service the map's layer code keys off. A story
|
|
2020
|
+
// with no map asks about its media and fonts only: there is no tile
|
|
2021
|
+
// request to consent to, so asking would be noise the visitor cannot
|
|
2022
|
+
// act on.
|
|
2023
|
+
if (!this._map_disabled) {
|
|
2024
|
+
services.push(tileService());
|
|
2025
|
+
}
|
|
2026
|
+
// media services with a real URL in the slides (a storymap is allowed
|
|
2027
|
+
// to have no slides at all)
|
|
2028
|
+
const seen = new Set<string>();
|
|
2029
|
+
for (const slide of this.data.slides ?? []) {
|
|
2030
|
+
// every visible media item is its own service (#358)
|
|
2031
|
+
const items = [slide.media, ...(slide.media_extra ?? [])];
|
|
2032
|
+
for (const item of items) {
|
|
2033
|
+
const url = (item as { url?: string | null } | null)?.url;
|
|
2034
|
+
if (!url) continue;
|
|
2035
|
+
const match = MediaType({ url } as never) as { type: string; name: string } | false;
|
|
2036
|
+
if (!match || seen.has(match.type)) continue;
|
|
2037
|
+
seen.add(match.type);
|
|
2038
|
+
// same registry the per-slide panel uses, so the two dialogs can
|
|
2039
|
+
// never disagree about a service's name
|
|
2040
|
+
services.push(mediaService(match.type, match.name));
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
// Slide narration is its own service: the recording plays through a
|
|
2044
|
+
// dedicated audio element, not through Media, so nothing else asks
|
|
2045
|
+
// for it — without this the GDPR mode would have a hole.
|
|
2046
|
+
const has_narration = (this.data.slides ?? []).some(
|
|
2047
|
+
(slide) => !!(slide.narration as { url?: string } | null)?.url,
|
|
2048
|
+
);
|
|
2049
|
+
if (has_narration && !seen.has("narration")) {
|
|
2050
|
+
services.push(narrationService());
|
|
2051
|
+
}
|
|
2052
|
+
// external web fonts (same-origin themes never ask). Compared by
|
|
2053
|
+
// origin, not by a prefix test on the resolved URL: resolveFontCssUrl
|
|
2054
|
+
// has already turned a relative path into an absolute one, so testing
|
|
2055
|
+
// that for "http" made every relative font_css look external.
|
|
2056
|
+
// A host-bundled theme (font_css: false) loads no stylesheet, so it
|
|
2057
|
+
// contributes no font service either.
|
|
2058
|
+
const originalFontCss = fontCssOriginal(this.options.font_css);
|
|
2059
|
+
if (originalFontCss !== null) {
|
|
2060
|
+
const font = resolveFontCssUrl(originalFontCss);
|
|
2061
|
+
if (font !== null && isExternalUrl(font)) {
|
|
2062
|
+
services.push(fontService());
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
manager.requestAll(services, this._el.container);
|
|
2066
|
+
}
|
|
2067
|
+
|
|
851
2068
|
/* Autoplay (issue #380) and hash bookmarks (issue #146)
|
|
852
2069
|
================================================== */
|
|
2070
|
+
/* Narration
|
|
2071
|
+
================================================== */
|
|
2072
|
+
|
|
2073
|
+
/**
|
|
2074
|
+
* Play the current slide's narration, if it has one.
|
|
2075
|
+
*
|
|
2076
|
+
* One `<audio>` element is reused for the whole story rather than a
|
|
2077
|
+
* player per slide, so switching slides cannot leave a recording playing
|
|
2078
|
+
* behind. Two things gate it:
|
|
2079
|
+
*
|
|
2080
|
+
* - **browser autoplay policy**: unmuted audio only plays after a user
|
|
2081
|
+
* gesture, so before the first interaction the element is left paused
|
|
2082
|
+
* and nothing is reported as broken. Re-arm on the first interaction.
|
|
2083
|
+
* - **consent**: the `media:narration` service is requested at start-up
|
|
2084
|
+
* (see `_startConsentAsk`); a denied or unanswered service means no
|
|
2085
|
+
* playback, and the story still works.
|
|
2086
|
+
*/
|
|
2087
|
+
_playNarration(slide: StorymapSlide | undefined, allow_without_gesture = false) {
|
|
2088
|
+
// Slideshow-only playback flags render only for slideshow tours;
|
|
2089
|
+
// internal documents play the bare URL exactly as before (no data is
|
|
2090
|
+
// mutated — the bag is reduced to a local).
|
|
2091
|
+
const raw = slide?.narration ?? null;
|
|
2092
|
+
const bag =
|
|
2093
|
+
this.options.slideshow_source === true
|
|
2094
|
+
? raw
|
|
2095
|
+
: raw && typeof raw.url === "string"
|
|
2096
|
+
? { url: raw.url }
|
|
2097
|
+
: null;
|
|
2098
|
+
const url = typeof bag?.url === "string" && bag.url !== "" ? bag.url : null;
|
|
2099
|
+
const persistent = bag?.stopOnExit === false;
|
|
2100
|
+
if (url !== null && persistent) {
|
|
2101
|
+
// An audio bed that outlives its slide (slideshow ambient
|
|
2102
|
+
// audio): already playing this bed → keep it, dropping any
|
|
2103
|
+
// foreground left over from the slide we came from. At most one
|
|
2104
|
+
// ambient track plays; a new one replaces the old.
|
|
2105
|
+
if (this._ambient_meta?.url === url && this._ambient_playing) {
|
|
2106
|
+
this._stopNarration();
|
|
2107
|
+
return;
|
|
2108
|
+
}
|
|
2109
|
+
this._stopNarration();
|
|
2110
|
+
if (bag?.stopAllPrevious !== false) this._stopAmbient();
|
|
2111
|
+
if (this._disposed || !this._narration_allowed) return;
|
|
2112
|
+
if (!allow_without_gesture && !this._has_user_gesture()) {
|
|
2113
|
+
this._replayNarrationAfterGesture = true;
|
|
2114
|
+
return;
|
|
2115
|
+
}
|
|
2116
|
+
const el = this._ambientElement();
|
|
2117
|
+
this._ambient_meta = { url };
|
|
2118
|
+
this._startTrack(el, url, bag, allow_without_gesture);
|
|
2119
|
+
return;
|
|
2120
|
+
}
|
|
2121
|
+
// An ephemeral track (or none): a newcomer that does not opt out of
|
|
2122
|
+
// stopping replaces any ambient bed; a silent slide keeps a playing
|
|
2123
|
+
// bed (it belongs to the tour, not to the slide we left).
|
|
2124
|
+
if (url === null || bag?.stopAllPrevious !== false) {
|
|
2125
|
+
if (url === null && this._ambient_playing) {
|
|
2126
|
+
this._stopNarration();
|
|
2127
|
+
return;
|
|
2128
|
+
}
|
|
2129
|
+
this._stopAmbient();
|
|
2130
|
+
}
|
|
2131
|
+
this._stopNarration();
|
|
2132
|
+
if (!url || this._disposed || !this._narration_allowed) return;
|
|
2133
|
+
if (!allow_without_gesture && !this._has_user_gesture()) {
|
|
2134
|
+
this._replayNarrationAfterGesture = true;
|
|
2135
|
+
return;
|
|
2136
|
+
}
|
|
2137
|
+
const el = this._narrationElement();
|
|
2138
|
+
this._narration_meta = { url, stopOnExit: bag?.stopOnExit !== false };
|
|
2139
|
+
this._startTrack(el, url, bag, allow_without_gesture);
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
/**
|
|
2143
|
+
* Start (or arm) a narration track: loop flag, start offset once the
|
|
2144
|
+
* metadata is available, and `play: "click"` arms paused for the
|
|
2145
|
+
* visitor instead of playing (see `playNarration()`). A rejected play()
|
|
2146
|
+
* (policy, network) must not surface as an unhandled rejection.
|
|
2147
|
+
*/
|
|
2148
|
+
_startTrack(
|
|
2149
|
+
el: HTMLAudioElement,
|
|
2150
|
+
url: string,
|
|
2151
|
+
bag: StorymapSlideNarration | null,
|
|
2152
|
+
allow_without_gesture: boolean,
|
|
2153
|
+
) {
|
|
2154
|
+
el.loop = bag?.loop === true;
|
|
2155
|
+
el.src = url;
|
|
2156
|
+
this._seekAudio(el, bag?.offset);
|
|
2157
|
+
if (bag?.play === "click") return;
|
|
2158
|
+
if (!allow_without_gesture && !this._has_user_gesture()) {
|
|
2159
|
+
this._replayNarrationAfterGesture = true;
|
|
2160
|
+
return;
|
|
2161
|
+
}
|
|
2162
|
+
if (el === this._ambient_el) {
|
|
2163
|
+
this._ambient_playing = true;
|
|
2164
|
+
}
|
|
2165
|
+
const token = ++this._narration_token;
|
|
2166
|
+
void el.play()?.catch?.(() => {
|
|
2167
|
+
if (token !== this._narration_token) return;
|
|
2168
|
+
// a failed start is not playing: the gesture retry must attempt
|
|
2169
|
+
// it again rather than take the same-URL fast path
|
|
2170
|
+
if (el === this._ambient_el) {
|
|
2171
|
+
this._ambient_playing = false;
|
|
2172
|
+
}
|
|
2173
|
+
this._replayNarrationAfterGesture = true;
|
|
2174
|
+
});
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
/**
|
|
2178
|
+
* Start the current slide's click-armed narration (`narration.play:
|
|
2179
|
+
* "click"`): the control for visitors (or hosts) to start a track that
|
|
2180
|
+
* waits for them. Auto-armed tracks play on their own. No-op when
|
|
2181
|
+
* nothing is armed. Prefers the current slide's own track: with an
|
|
2182
|
+
* ambient bed running, the foreground it armed is the one to start.
|
|
2183
|
+
*/
|
|
2184
|
+
playNarration(): void {
|
|
2185
|
+
const bag = this.data.slides?.[this.current_slide]?.narration ?? null;
|
|
2186
|
+
const persistent = bag?.stopOnExit === false && typeof bag?.url === "string";
|
|
2187
|
+
const el =
|
|
2188
|
+
persistent && this._ambient_meta?.url === bag?.url
|
|
2189
|
+
? this._ambient_el
|
|
2190
|
+
: this._narration_el;
|
|
2191
|
+
if (!el || !el.getAttribute("src")) return;
|
|
2192
|
+
if (this._disposed || !this._narration_allowed || this._has_user_gesture() === false) {
|
|
2193
|
+
this._replayNarrationAfterGesture = true;
|
|
2194
|
+
return;
|
|
2195
|
+
}
|
|
2196
|
+
if (el === this._ambient_el) {
|
|
2197
|
+
this._ambient_playing = true;
|
|
2198
|
+
}
|
|
2199
|
+
const token = ++this._narration_token;
|
|
2200
|
+
void el.play()?.catch?.(() => {
|
|
2201
|
+
if (token !== this._narration_token) return;
|
|
2202
|
+
if (el === this._ambient_el) {
|
|
2203
|
+
this._ambient_playing = false;
|
|
2204
|
+
}
|
|
2205
|
+
this._replayNarrationAfterGesture = true;
|
|
2206
|
+
});
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
/**
|
|
2210
|
+
* Seek to a start offset in seconds (slideshow `audio.offset`):
|
|
2211
|
+
* immediately when the metadata is already there, otherwise once on
|
|
2212
|
+
* `loadedmetadata`. Guarded by the narration token, so a quick
|
|
2213
|
+
* pass-through cannot seek a newer track.
|
|
2214
|
+
*/
|
|
2215
|
+
_seekAudio(el: HTMLAudioElement, offset: number | null | undefined) {
|
|
2216
|
+
const target =
|
|
2217
|
+
typeof offset === "number" && Number.isFinite(offset) && offset > 0 ? offset : 0;
|
|
2218
|
+
const token = this._narration_token;
|
|
2219
|
+
const apply = () => {
|
|
2220
|
+
if (token !== this._narration_token || this._disposed) return;
|
|
2221
|
+
try {
|
|
2222
|
+
el.currentTime = target;
|
|
2223
|
+
} catch {
|
|
2224
|
+
// metadata not ready yet; the listener below retries
|
|
2225
|
+
}
|
|
2226
|
+
};
|
|
2227
|
+
if (el.readyState >= 1) {
|
|
2228
|
+
apply();
|
|
2229
|
+
return;
|
|
2230
|
+
}
|
|
2231
|
+
const on_metadata = () => {
|
|
2232
|
+
el.removeEventListener("loadedmetadata", on_metadata);
|
|
2233
|
+
apply();
|
|
2234
|
+
};
|
|
2235
|
+
el.addEventListener("loadedmetadata", on_metadata);
|
|
2236
|
+
try {
|
|
2237
|
+
el.currentTime = target;
|
|
2238
|
+
} catch {
|
|
2239
|
+
// pre-metadata seek throws in some browsers; the listener covers it
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
_narrationElement(): HTMLAudioElement {
|
|
2244
|
+
if (this._narration_el === null) {
|
|
2245
|
+
this._narration_el = document.createElement("audio");
|
|
2246
|
+
this._narration_el.className = "vco-media-item vco-narration";
|
|
2247
|
+
this._narration_el.preload = "none";
|
|
2248
|
+
this._narration_el.setAttribute("aria-hidden", "true");
|
|
2249
|
+
}
|
|
2250
|
+
return this._narration_el;
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
_stopNarration() {
|
|
2254
|
+
this._narration_token++;
|
|
2255
|
+
this._narration_meta = null;
|
|
2256
|
+
const el = this._narration_el;
|
|
2257
|
+
if (!el) return;
|
|
2258
|
+
el.pause();
|
|
2259
|
+
el.removeAttribute("src");
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
_ambientElement(): HTMLAudioElement {
|
|
2263
|
+
if (this._ambient_el === null) {
|
|
2264
|
+
this._ambient_el = document.createElement("audio");
|
|
2265
|
+
this._ambient_el.className = "vco-media-item vco-ambient";
|
|
2266
|
+
this._ambient_el.preload = "none";
|
|
2267
|
+
this._ambient_el.setAttribute("aria-hidden", "true");
|
|
2268
|
+
}
|
|
2269
|
+
return this._ambient_el;
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
_stopAmbient() {
|
|
2273
|
+
this._narration_token++;
|
|
2274
|
+
this._ambient_meta = null;
|
|
2275
|
+
this._ambient_playing = false;
|
|
2276
|
+
const el = this._ambient_el;
|
|
2277
|
+
if (!el) return;
|
|
2278
|
+
el.pause();
|
|
2279
|
+
el.removeAttribute("src");
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
/** A gesture has happened, so unmuted audio is allowed from now on. */
|
|
2283
|
+
_note_user_gesture_for_narration() {
|
|
2284
|
+
if (this._disposed) return;
|
|
2285
|
+
this._narration_allowed = this._narration_allowed && true;
|
|
2286
|
+
if (this._replayNarrationAfterGesture) {
|
|
2287
|
+
this._replayNarrationAfterGesture = false;
|
|
2288
|
+
this._playNarration(this.data.slides?.[this.current_slide], true);
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
_has_user_gesture(): boolean {
|
|
2293
|
+
return this._user_gestured;
|
|
2294
|
+
}
|
|
2295
|
+
|
|
853
2296
|
_startAutoplay() {
|
|
854
2297
|
this._stopAutoplay();
|
|
855
2298
|
this._autoplay_stopped = false;
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
2299
|
+
// a denied media:narration service means no narration for this story
|
|
2300
|
+
const manager = consentManagerOf(this.options);
|
|
2301
|
+
if (manager && this.options.consent_required) {
|
|
2302
|
+
this._narration_allowed = !manager.isDenied(narrationService().key);
|
|
2303
|
+
}
|
|
2304
|
+
// browsers only allow unmuted audio after a user gesture; the first
|
|
2305
|
+
// interaction arms narration for the rest of the story
|
|
2306
|
+
if (!this._user_gestured) {
|
|
2307
|
+
const on_gesture = () => {
|
|
2308
|
+
this._user_gestured = true;
|
|
2309
|
+
this._note_user_gesture_for_narration();
|
|
2310
|
+
this._el.container.removeEventListener("pointerdown", on_gesture);
|
|
2311
|
+
this._el.container.removeEventListener("keydown", on_gesture);
|
|
2312
|
+
};
|
|
2313
|
+
this._el.container.addEventListener("pointerdown", on_gesture, { once: true });
|
|
2314
|
+
this._el.container.addEventListener("keydown", on_gesture, { once: true });
|
|
2315
|
+
}
|
|
2316
|
+
// prefers-reduced-motion: no autoplay (WCAG 2.2.2)
|
|
2317
|
+
if (this.options.autoplay > 0 && !prefersReducedMotion()) {
|
|
2318
|
+
// any user interaction stops autoplay permanently — except on
|
|
2319
|
+
// the pause/resume toggle itself, whose click would otherwise
|
|
2320
|
+
// stop and immediately restart (net no-op instead of pause)
|
|
2321
|
+
const stop = (e: Event) => {
|
|
2322
|
+
if ((e.target as HTMLElement | null)?.closest?.(".vco-menubar-autoplay")) {
|
|
2323
|
+
return;
|
|
2324
|
+
}
|
|
859
2325
|
this._autoplay_stopped = true;
|
|
860
2326
|
this._stopAutoplay();
|
|
2327
|
+
this._menubar.setAutoplayState(true);
|
|
861
2328
|
};
|
|
862
2329
|
this._el.container.addEventListener("pointerdown", stop, { once: true });
|
|
863
2330
|
this._el.container.addEventListener("keydown", stop, { once: true });
|
|
@@ -866,17 +2333,101 @@ class StoryMapBase {
|
|
|
866
2333
|
}
|
|
867
2334
|
}
|
|
868
2335
|
|
|
2336
|
+
/**
|
|
2337
|
+
* Stop other slides' ambient audio beds when the new slide claims the
|
|
2338
|
+
* audio: its narration (unless opted out via `stopAllPrevious: false`),
|
|
2339
|
+
* or playable media that is actually playing (a paused click-to-play
|
|
2340
|
+
* bed must not kill an inherited ambient track it never starts).
|
|
2341
|
+
* A silent slide inherits a playing bed.
|
|
2342
|
+
*/
|
|
2343
|
+
_stopAmbientSlideMedia(slide: StorymapSlide | undefined) {
|
|
2344
|
+
const claimsNarration =
|
|
2345
|
+
typeof slide?.narration?.url === "string" &&
|
|
2346
|
+
slide.narration.url !== "" &&
|
|
2347
|
+
slide.narration.stopAllPrevious !== false;
|
|
2348
|
+
const claimsMedia =
|
|
2349
|
+
(slide?.media as { stopAllPrevious?: boolean } | null | undefined)?.stopAllPrevious !==
|
|
2350
|
+
false && this._isSlideMediaPlaying(this.current_slide);
|
|
2351
|
+
if (!claimsNarration && !claimsMedia) return;
|
|
2352
|
+
const slides = this._storyslider?._slides;
|
|
2353
|
+
if (!slides) return;
|
|
2354
|
+
slides.forEach((entry, index) => {
|
|
2355
|
+
if (index !== this.current_slide) entry.stopAmbientMedia?.();
|
|
2356
|
+
});
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
/**
|
|
2360
|
+
* True when the slide's media element exists and is currently playing
|
|
2361
|
+
* (not paused, not ended, not merely loadable).
|
|
2362
|
+
*/
|
|
2363
|
+
_isSlideMediaPlaying(index: number): boolean {
|
|
2364
|
+
const slide = this._storyslider?._slides?.[index];
|
|
2365
|
+
const medias = (
|
|
2366
|
+
slide as unknown as { _medias?: { player_element?: HTMLMediaElement | null }[] }
|
|
2367
|
+
)?._medias;
|
|
2368
|
+
if (!Array.isArray(medias)) return false;
|
|
2369
|
+
return medias.some((media) => {
|
|
2370
|
+
const el = media?.player_element;
|
|
2371
|
+
return !!el && typeof el.paused === "boolean" && !el.paused && !el.ended;
|
|
2372
|
+
});
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
/**
|
|
2376
|
+
* Arm autoplay for the current slide.
|
|
2377
|
+
*
|
|
2378
|
+
* With `autoplay_media` on and a slide whose media is playable audio or
|
|
2379
|
+
* video, the advance waits for that media to end instead of using the
|
|
2380
|
+
* `autoplay` millisecond timer. The timer is **kept as a fallback** in
|
|
2381
|
+
* that case too: a media element that never fires `ended` (a stalled
|
|
2382
|
+
* fetch, a codec the browser cannot play, or a slide whose media was
|
|
2383
|
+
* never loaded because the visitor skipped past it) would otherwise stop
|
|
2384
|
+
* the story dead. So both are armed, whichever fires first advances, and
|
|
2385
|
+
* the other is cleared.
|
|
2386
|
+
*/
|
|
869
2387
|
_scheduleAutoplay() {
|
|
870
2388
|
this._stopAutoplay();
|
|
871
|
-
if (this.options.autoplay > 0
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
2389
|
+
if (!(this.options.autoplay > 0) || this._autoplay_stopped) return;
|
|
2390
|
+
// the OS setting may have flipped mid-story: re-check on every arm
|
|
2391
|
+
// so a reduced-motion switch stops future advances (the in-flight
|
|
2392
|
+
// timer is cleared by the next navigation's _scheduleAutoplay)
|
|
2393
|
+
if (prefersReducedMotion()) return;
|
|
2394
|
+
|
|
2395
|
+
const advance = () => {
|
|
2396
|
+
this._stopAutoplay();
|
|
2397
|
+
if (this.current_slide + 1 < this.data.slides.length) {
|
|
2398
|
+
this.goTo(this.current_slide + 1);
|
|
2399
|
+
this._scheduleAutoplay();
|
|
2400
|
+
}
|
|
2401
|
+
};
|
|
2402
|
+
this._autoplay_advance = advance;
|
|
2403
|
+
|
|
2404
|
+
// Per-slide dwell (slideshow slidetimeout): overrides the global
|
|
2405
|
+
// interval for this slide; 0 holds here even with autoplay on.
|
|
2406
|
+
// Internal documents always use the global interval.
|
|
2407
|
+
const rawTimeout = this.data.slides?.[this.current_slide]?.slidetimeout;
|
|
2408
|
+
const slideTimeout = this.options.slideshow_source === true ? rawTimeout : undefined;
|
|
2409
|
+
const dwell =
|
|
2410
|
+
typeof slideTimeout === "number" && Number.isFinite(slideTimeout) && slideTimeout >= 0
|
|
2411
|
+
? slideTimeout
|
|
2412
|
+
: this.options.autoplay;
|
|
2413
|
+
if (!(dwell > 0)) return;
|
|
2414
|
+
|
|
2415
|
+
if (this.options.autoplay_media) {
|
|
2416
|
+
const slide = this._storyslider?._slides?.[this.current_slide];
|
|
2417
|
+
if (slide?.hasPlayableMedia?.()) {
|
|
2418
|
+
// re-arming clears the previous advance: without this,
|
|
2419
|
+
// revisits accumulate listeners and a stale ended (e.g. an
|
|
2420
|
+
// ambient bed outliving its slide) advances the wrong slide.
|
|
2421
|
+
// The index guard covers the race between clearing and firing.
|
|
2422
|
+
slide.clearMediaEnded();
|
|
2423
|
+
const armed = this.current_slide;
|
|
2424
|
+
slide.onMediaEnded(() => {
|
|
2425
|
+
if (this.current_slide === armed) advance();
|
|
2426
|
+
});
|
|
2427
|
+
}
|
|
879
2428
|
}
|
|
2429
|
+
|
|
2430
|
+
this._autoplay_timer = setTimeout(advance, dwell);
|
|
880
2431
|
}
|
|
881
2432
|
|
|
882
2433
|
_stopAutoplay() {
|
|
@@ -884,33 +2435,123 @@ class StoryMapBase {
|
|
|
884
2435
|
clearTimeout(this._autoplay_timer);
|
|
885
2436
|
this._autoplay_timer = null;
|
|
886
2437
|
}
|
|
2438
|
+
this._autoplay_advance = null;
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
/**
|
|
2442
|
+
* The current slide's `uniqueid`, or null when the data carries none.
|
|
2443
|
+
*
|
|
2444
|
+
* A storymap-JSON slide usually has no `uniqueid` — the slider generates
|
|
2445
|
+
* one per slide for its own DOM ids, but that is not a shareable identity,
|
|
2446
|
+
* so it is deliberately not used here. A IIIF manifest supplies one per
|
|
2447
|
+
* canvas (§2.3), and that is what a deep link should name.
|
|
2448
|
+
*/
|
|
2449
|
+
_currentSlideId(): string | null {
|
|
2450
|
+
const id = this.data?.slides?.[this.current_slide]?.uniqueid;
|
|
2451
|
+
return typeof id === "string" && id !== "" ? id : null;
|
|
887
2452
|
}
|
|
888
2453
|
|
|
889
|
-
/**
|
|
2454
|
+
/** The current slide as a Content State, or null when it has no id. */
|
|
2455
|
+
_currentContentState(): ContentState | null {
|
|
2456
|
+
const id = this._currentSlideId();
|
|
2457
|
+
if (id === null) return null;
|
|
2458
|
+
const region = this.data.slides[this.current_slide]?.location?.region;
|
|
2459
|
+
return Array.isArray(region) && region.length === 4
|
|
2460
|
+
? { id, region: region as [number, number, number, number] }
|
|
2461
|
+
: { id };
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
/**
|
|
2465
|
+
* Keep the URL in sync with the current slide: the `#slide-…` hash and,
|
|
2466
|
+
* when the slide has a shareable id, an `iiif-content` parameter (§5.1).
|
|
2467
|
+
*
|
|
2468
|
+
* The query string is rewritten rather than replaced, because pages like
|
|
2469
|
+
* the embed player and the test harness carry their own configuration in
|
|
2470
|
+
* it (`?url=…`, `?example=…`, `?manifest=…`).
|
|
2471
|
+
*/
|
|
890
2472
|
_syncHash() {
|
|
891
2473
|
try {
|
|
892
|
-
|
|
893
|
-
|
|
2474
|
+
const state = this._currentContentState();
|
|
2475
|
+
const params = new URLSearchParams(window.location.search);
|
|
2476
|
+
if (state !== null) {
|
|
2477
|
+
params.set(
|
|
2478
|
+
CONTENT_STATE_PARAM,
|
|
2479
|
+
formatContentState(
|
|
2480
|
+
state,
|
|
2481
|
+
(this.data as { uniqueid?: string }).uniqueid ?? null,
|
|
2482
|
+
),
|
|
2483
|
+
);
|
|
2484
|
+
} else {
|
|
2485
|
+
// a storymap-JSON slide with no id cannot be named, so the
|
|
2486
|
+
// parameter would only ever point at the wrong stop
|
|
2487
|
+
params.delete(CONTENT_STATE_PARAM);
|
|
2488
|
+
}
|
|
2489
|
+
const query = params.toString();
|
|
894
2490
|
history.replaceState(
|
|
895
2491
|
null,
|
|
896
2492
|
"",
|
|
897
|
-
window.location.pathname +
|
|
2493
|
+
window.location.pathname +
|
|
2494
|
+
(query === "" ? "" : "?" + query) +
|
|
2495
|
+
"#slide-" +
|
|
2496
|
+
(state !== null ? encodeURI(state.id) : this.current_slide),
|
|
898
2497
|
);
|
|
899
2498
|
} catch {
|
|
900
2499
|
// non-browser or sandboxed contexts
|
|
901
2500
|
}
|
|
902
2501
|
}
|
|
903
2502
|
|
|
904
|
-
/**
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
2503
|
+
/**
|
|
2504
|
+
* A `#slide-…` hash or an `iiif-content` parameter deep-links the storymap
|
|
2505
|
+
* (applied on load + hashchange).
|
|
2506
|
+
*
|
|
2507
|
+
* The hash token is an index when it is all digits and a `uniqueid`
|
|
2508
|
+
* otherwise, which is unambiguous because an index never contains anything
|
|
2509
|
+
* else. The index form stays supported, so links shared before ids were
|
|
2510
|
+
* emitted keep working; they just stop being stable the moment a slide is
|
|
2511
|
+
* inserted.
|
|
2512
|
+
*/
|
|
2513
|
+
_applyHashSlide(captured?: { state: ContentState | null; hash: string } | null): boolean {
|
|
2514
|
+
const link = captured ?? {
|
|
2515
|
+
state: parseContentState(
|
|
2516
|
+
new URLSearchParams(window.location.search).get(CONTENT_STATE_PARAM),
|
|
2517
|
+
),
|
|
2518
|
+
hash: window.location.hash,
|
|
2519
|
+
};
|
|
2520
|
+
const target = this._resolveDeepLink(link.state, link.hash);
|
|
2521
|
+
if (target === null || target === this.current_slide) return false;
|
|
2522
|
+
this.goTo(target);
|
|
2523
|
+
return true;
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
/**
|
|
2527
|
+
* A content state or hash token as a slide index, or null when it names
|
|
2528
|
+
* nothing. A content state wins over the hash, being the standard spelling.
|
|
2529
|
+
*/
|
|
2530
|
+
_resolveDeepLink(state: ContentState | null, hash: string): number | null {
|
|
2531
|
+
if (state !== null) {
|
|
2532
|
+
const index = this._slideIndexById(state.id);
|
|
2533
|
+
if (index !== null) return index;
|
|
2534
|
+
console.warn(
|
|
2535
|
+
`StoryMapJS: the content state names a canvas this storymap does not have: ${state.id}`,
|
|
2536
|
+
);
|
|
912
2537
|
}
|
|
913
|
-
|
|
2538
|
+
const match = /^#slide-(.+)$/.exec(hash);
|
|
2539
|
+
if (match === null) return null;
|
|
2540
|
+
const token = match[1];
|
|
2541
|
+
if (/^\d+$/.test(token)) {
|
|
2542
|
+
const n = parseInt(token, 10);
|
|
2543
|
+
return n >= 0 && n < this.data.slides.length ? n : null;
|
|
2544
|
+
}
|
|
2545
|
+
return this._slideIndexById(decodeURIComponent(token));
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
/** A canvas id as a slide index, or null. */
|
|
2549
|
+
_slideIndexById(id: string): number | null {
|
|
2550
|
+
const slides = this.data?.slides ?? [];
|
|
2551
|
+
for (let i = 0; i < slides.length; i++) {
|
|
2552
|
+
if (slides[i]?.uniqueid === id) return i;
|
|
2553
|
+
}
|
|
2554
|
+
return null;
|
|
914
2555
|
}
|
|
915
2556
|
|
|
916
2557
|
/* Resize handling
|
|
@@ -938,6 +2579,7 @@ class StoryMapBase {
|
|
|
938
2579
|
this._resize_observer = new ResizeObserver(onResize);
|
|
939
2580
|
this._resize_observer.observe(this._el.container);
|
|
940
2581
|
}
|
|
2582
|
+
this._on_resize = onResize;
|
|
941
2583
|
window.addEventListener("resize", onResize);
|
|
942
2584
|
}
|
|
943
2585
|
|
|
@@ -947,6 +2589,34 @@ class StoryMapBase {
|
|
|
947
2589
|
* map container (given the vco-map class) and moved into place between
|
|
948
2590
|
* the menubar and the story slider.
|
|
949
2591
|
*/
|
|
2592
|
+
/**
|
|
2593
|
+
* The map pane, for the code paths that only run when there is a map.
|
|
2594
|
+
* `_el.map` is null exactly when the engine is; see `_map_required()`.
|
|
2595
|
+
*/
|
|
2596
|
+
_map_el(): HTMLElement {
|
|
2597
|
+
if (!this._el.map) {
|
|
2598
|
+
throw new Error(
|
|
2599
|
+
'StoryMapJS: this operation needs a map pane, but the story has none (map_type: "none").',
|
|
2600
|
+
);
|
|
2601
|
+
}
|
|
2602
|
+
return this._el.map;
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
/**
|
|
2606
|
+
* The map engine, for the code paths that only run when there is a map.
|
|
2607
|
+
* Throws rather than returning null: reaching one of these from a mapless
|
|
2608
|
+
* story is a bug in this class, and a loud one, rather than a layout that
|
|
2609
|
+
* quietly misbehaves. Public entry points use `this._map?.` instead.
|
|
2610
|
+
*/
|
|
2611
|
+
_map_required(): OpenLayersMap {
|
|
2612
|
+
if (!this._map) {
|
|
2613
|
+
throw new Error(
|
|
2614
|
+
'StoryMapJS: this operation needs a map, but the story has none (map_type: "none").',
|
|
2615
|
+
);
|
|
2616
|
+
}
|
|
2617
|
+
return this._map;
|
|
2618
|
+
}
|
|
2619
|
+
|
|
950
2620
|
_resolveMapElement(): HTMLElement | null {
|
|
951
2621
|
const element = this.options.map_options?.element;
|
|
952
2622
|
if (!element) {
|
|
@@ -962,7 +2632,7 @@ class StoryMapBase {
|
|
|
962
2632
|
return el;
|
|
963
2633
|
}
|
|
964
2634
|
|
|
965
|
-
_onTitle(e:
|
|
2635
|
+
_onTitle(e: { title: string }) {
|
|
966
2636
|
this.fire("title", e);
|
|
967
2637
|
}
|
|
968
2638
|
|
|
@@ -976,38 +2646,166 @@ class StoryMapBase {
|
|
|
976
2646
|
|
|
977
2647
|
_onSlideChange(e: { current_slide: number }) {
|
|
978
2648
|
if (this.current_slide !== e.current_slide) {
|
|
979
|
-
|
|
980
|
-
this.current_slide
|
|
981
|
-
this._map.goTo(this.current_slide);
|
|
982
|
-
this._beginTransition(duration);
|
|
983
|
-
this.fire("change", { current_slide: this.current_slide }, this);
|
|
984
|
-
this._syncHash();
|
|
985
|
-
this._scheduleAutoplay();
|
|
986
|
-
this._updateProgress();
|
|
2649
|
+
// the slider already moved itself
|
|
2650
|
+
this._navigate(e.current_slide, { navigate: "slider" });
|
|
987
2651
|
}
|
|
988
2652
|
}
|
|
989
2653
|
|
|
990
2654
|
_onMapChange(e: { current_marker: number }) {
|
|
991
2655
|
if (this.current_slide !== e.current_marker) {
|
|
992
|
-
|
|
993
|
-
this.
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
2656
|
+
// the map already moved itself
|
|
2657
|
+
this._navigate(e.current_marker, { navigate: "map" });
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
_onImageReady(e: ImagereadyPayload) {
|
|
2662
|
+
this.fire("imageready", e);
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
_onMarkerPopupOpen(e: { marker_number: number }) {
|
|
2666
|
+
this.fire("popupopen", {
|
|
2667
|
+
marker_number: e.marker_number,
|
|
2668
|
+
current_slide: this.current_slide,
|
|
2669
|
+
});
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
_onMarkerPopupClose(e: { marker_number: number }) {
|
|
2673
|
+
this.fire("popupclose", {
|
|
2674
|
+
marker_number: e.marker_number,
|
|
2675
|
+
current_slide: this.current_slide,
|
|
2676
|
+
});
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
_onMarkerClick(e: { marker_number: number }) {
|
|
2680
|
+
// Re-fired from the map surface so a host listening on the viewer
|
|
2681
|
+
// sees marker clicks; the map already navigated itself.
|
|
2682
|
+
this.fire("markerclick", {
|
|
2683
|
+
marker_number: e.marker_number,
|
|
2684
|
+
current_slide: this.current_slide,
|
|
2685
|
+
});
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
_onLayersBasemap(e: { map_type: string }) {
|
|
2689
|
+
const previous = this.options.map_type;
|
|
2690
|
+
this.setMapOption("map_type", e.map_type);
|
|
2691
|
+
this._menubar.refreshLayers();
|
|
2692
|
+
this.fire("basemapchange", { map_type: e.map_type, previous });
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
_onLayersOverlay(e: { index: number; visible: boolean }) {
|
|
2696
|
+
// setOverlayVisible re-syncs the attribution line; the checkbox
|
|
2697
|
+
// already shows the new state, so no refresh (which would steal
|
|
2698
|
+
// keyboard focus from the row just toggled)
|
|
2699
|
+
this.setOverlayVisible(e.index, e.visible);
|
|
2700
|
+
this.fire("overlaychange", { index: e.index, visible: e.visible });
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
_onAutoplayToggle() {
|
|
2704
|
+
if (this._autoplay_stopped) {
|
|
2705
|
+
this._autoplay_stopped = false;
|
|
998
2706
|
this._scheduleAutoplay();
|
|
999
|
-
|
|
2707
|
+
} else {
|
|
2708
|
+
this._autoplay_stopped = true;
|
|
2709
|
+
this._stopAutoplay();
|
|
1000
2710
|
}
|
|
2711
|
+
this._menubar.setAutoplayState(this._autoplay_stopped);
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
_onProgressGo(e: { slide: number }) {
|
|
2715
|
+
this.goTo(e.slide);
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
_onTilesAllowed() {
|
|
2719
|
+
this._menubar.refreshLayers();
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
/**
|
|
2723
|
+
* The layer switcher rows: allowed basemaps first, then built overlays.
|
|
2724
|
+
* Empty while there is nothing to switch (mapless story, consent-denied
|
|
2725
|
+
* tiles), in which case the control hides itself.
|
|
2726
|
+
*/
|
|
2727
|
+
_layersRows(): LayersRow[] {
|
|
2728
|
+
if (this._map_disabled || !this._map || !this._tilesAllowed()) {
|
|
2729
|
+
return [];
|
|
2730
|
+
}
|
|
2731
|
+
const rows: LayersRow[] = [];
|
|
2732
|
+
for (const basemap of this._allowedBasemaps()) {
|
|
2733
|
+
const label = basemap.label;
|
|
2734
|
+
rows.push({
|
|
2735
|
+
kind: "basemap",
|
|
2736
|
+
map_type: basemap.map_type,
|
|
2737
|
+
label: typeof label === "function" ? label() : (label ?? basemap.map_type),
|
|
2738
|
+
checked: basemap.map_type === this.options.map_type,
|
|
2739
|
+
});
|
|
2740
|
+
}
|
|
2741
|
+
const entries = this._map.getOverlayEntries();
|
|
2742
|
+
const layers = this._map.getOverlayLayers();
|
|
2743
|
+
entries.forEach((entry, index) => {
|
|
2744
|
+
if (entry.control === false) return;
|
|
2745
|
+
const label = entry.label;
|
|
2746
|
+
rows.push({
|
|
2747
|
+
kind: "overlay",
|
|
2748
|
+
index,
|
|
2749
|
+
label: typeof label === "function" ? label() : (label ?? entry.map_type ?? ""),
|
|
2750
|
+
checked: layers[index]?.getVisible() ?? entry.visible ?? true,
|
|
2751
|
+
locked: entry.locked === true,
|
|
2752
|
+
});
|
|
2753
|
+
});
|
|
2754
|
+
return rows;
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
/**
|
|
2758
|
+
* Basemap candidates compatible with the live view. A runtime basemap
|
|
2759
|
+
* swap rebuilds layers but never the view, so an image-space candidate
|
|
2760
|
+
* (`iiif` with `map_as_image`) on a mercator view — or the reverse —
|
|
2761
|
+
* would wrap the wrong projection around the tiles: markers land in the
|
|
2762
|
+
* wrong place and zoom is meaningless. Refused candidates are dropped
|
|
2763
|
+
* with a warning, like the existing `setMapOptions` declines.
|
|
2764
|
+
*/
|
|
2765
|
+
_allowedBasemaps(): StorymapBasemap[] {
|
|
2766
|
+
const basemaps = this.options.basemaps;
|
|
2767
|
+
if (!Array.isArray(basemaps)) return [];
|
|
2768
|
+
const live = this.map?.getView()?.getProjection()?.getCode() ?? null;
|
|
2769
|
+
return basemaps.filter((entry) => {
|
|
2770
|
+
const map_type = entry?.map_type;
|
|
2771
|
+
if (typeof map_type !== "string" || map_type === "" || map_type === "none") {
|
|
2772
|
+
console.warn(
|
|
2773
|
+
`StoryMapJS: ignoring basemaps entry without a usable map_type: ${String(map_type)}`,
|
|
2774
|
+
);
|
|
2775
|
+
return false;
|
|
2776
|
+
}
|
|
2777
|
+
// the same rule `_createMap` builds the view by: image-space is
|
|
2778
|
+
// `iiif` with `map_as_image`, everything else is mercator
|
|
2779
|
+
const candidate =
|
|
2780
|
+
map_type === "iiif" && this.options.map_as_image ? "EPSG:4326" : "EPSG:3857";
|
|
2781
|
+
if (live !== null && candidate !== live) {
|
|
2782
|
+
console.warn(
|
|
2783
|
+
`StoryMapJS: ignoring basemap ${map_type}: it needs a ${candidate} view but the live view is ${live}`,
|
|
2784
|
+
);
|
|
2785
|
+
return false;
|
|
2786
|
+
}
|
|
2787
|
+
return true;
|
|
2788
|
+
});
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
/** Whether deferred tile layers may attach (no consent gate, or granted). */
|
|
2792
|
+
_tilesAllowed(): boolean {
|
|
2793
|
+
if (!this.options.consent_required) return true;
|
|
2794
|
+
const manager = consentManagerOf(this.options);
|
|
2795
|
+
return manager ? manager.isGranted(tileService().key) : true;
|
|
1001
2796
|
}
|
|
1002
2797
|
|
|
1003
2798
|
_updateProgress() {
|
|
1004
|
-
|
|
2799
|
+
// any enabled style updates (the classic bar via show_progress, the
|
|
2800
|
+
// variants via progressbar); a disabled indicator stays untouched so
|
|
2801
|
+
// its ARIA never goes stale
|
|
2802
|
+
if (this._menubar && this._menubar._progressStyle() !== "off") {
|
|
1005
2803
|
this._menubar.setProgress(this.current_slide, this.data.slides.length);
|
|
1006
2804
|
}
|
|
1007
2805
|
}
|
|
1008
2806
|
|
|
1009
2807
|
_onOverview(e?: unknown) {
|
|
1010
|
-
this._map
|
|
2808
|
+
this._map?.markerOverview();
|
|
1011
2809
|
}
|
|
1012
2810
|
|
|
1013
2811
|
/**
|
|
@@ -1039,18 +2837,18 @@ class StoryMapBase {
|
|
|
1039
2837
|
}
|
|
1040
2838
|
|
|
1041
2839
|
_onBackToStart(e?: unknown) {
|
|
1042
|
-
this.current_slide
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
this.
|
|
1046
|
-
// keep hash, progress and autoplay in sync like the other paths
|
|
1047
|
-
this._syncHash();
|
|
1048
|
-
this._scheduleAutoplay();
|
|
1049
|
-
this._updateProgress();
|
|
2840
|
+
if (this.current_slide === 0) {
|
|
2841
|
+
return;
|
|
2842
|
+
}
|
|
2843
|
+
this._navigate(0);
|
|
1050
2844
|
}
|
|
1051
2845
|
|
|
1052
|
-
_onMenuBarCollapse(e: { y
|
|
1053
|
-
|
|
2846
|
+
_onMenuBarCollapse(e: { y?: number; collapsed?: boolean }) {
|
|
2847
|
+
// `y` is the menubar position, not a map height; `collapsed` is the
|
|
2848
|
+
// authoritative signal. Older menubars omit it, so infer it from a
|
|
2849
|
+
// near-zero y for backwards compatibility.
|
|
2850
|
+
this._collapsed = e.collapsed !== undefined ? e.collapsed : e.y !== undefined && e.y < 5;
|
|
2851
|
+
this._updateDisplay(undefined, true);
|
|
1054
2852
|
}
|
|
1055
2853
|
|
|
1056
2854
|
_onMapLoaded() {
|
|
@@ -1071,34 +2869,59 @@ class StoryMapBase {
|
|
|
1071
2869
|
if (!this.options.show_distance) {
|
|
1072
2870
|
return;
|
|
1073
2871
|
}
|
|
1074
|
-
const km = this._map
|
|
2872
|
+
const km = this._map?.getRouteDistance();
|
|
1075
2873
|
this._menubar.setDistance(km);
|
|
1076
2874
|
}
|
|
1077
2875
|
|
|
2876
|
+
/**
|
|
2877
|
+
* The URL is ours to own as soon as the story is: the deep link is read
|
|
2878
|
+
* and the hash is kept in step.
|
|
2879
|
+
*
|
|
2880
|
+
* This deliberately does **not** wait for the map. The map only reports
|
|
2881
|
+
* itself loaded on OpenLayers' first `loadend`, which is the first paint of
|
|
2882
|
+
* the base tiles — so a deep link used to be applied only once
|
|
2883
|
+
* `tile.openstreetmap.org` answered, and on a slow or unreachable tile
|
|
2884
|
+
* host it was never applied at all. Nothing about a URL depends on imagery:
|
|
2885
|
+
* the data and the slider are what it needs, and both are ready before the
|
|
2886
|
+
* map is. `_applyHashSlide` → `goTo` → `_navigate` already tolerates a map
|
|
2887
|
+
* that has not reported loaded, and the map re-syncs when it does.
|
|
2888
|
+
*/
|
|
2889
|
+
_initHash() {
|
|
2890
|
+
if (this._hash_initialized || !this._loaded.storyslider) {
|
|
2891
|
+
return;
|
|
2892
|
+
}
|
|
2893
|
+
this._hash_initialized = true;
|
|
2894
|
+
// a #slide-N hash or an iiif-content parameter deep-links the initial
|
|
2895
|
+
// slide (issue #146) and keeps working through the browser
|
|
2896
|
+
// back/forward buttons
|
|
2897
|
+
this._applyHashSlide(this._initial_deep_link);
|
|
2898
|
+
// from here on the URL is ours to keep in sync, so a hashchange means
|
|
2899
|
+
// the user moved and can be read live
|
|
2900
|
+
this._on_hashchange = () => this._applyHashSlide();
|
|
2901
|
+
window.addEventListener("hashchange", this._on_hashchange);
|
|
2902
|
+
this._syncHash();
|
|
2903
|
+
}
|
|
2904
|
+
|
|
1078
2905
|
_onLoaded() {
|
|
1079
|
-
|
|
2906
|
+
this._initHash();
|
|
2907
|
+
// the slider is always required; the map only if there is one, so a
|
|
2908
|
+
// mapless story still fires `loaded` and gets its hash, progress and
|
|
2909
|
+
// distance applied
|
|
2910
|
+
if (this._loaded.storyslider && (this._map_disabled || this._loaded.map)) {
|
|
1080
2911
|
this.fire("loaded", this.data);
|
|
1081
|
-
if (!this._hash_initialized) {
|
|
1082
|
-
this._hash_initialized = true;
|
|
1083
|
-
// a #slide-N hash deep-links the initial slide (issue #146)
|
|
1084
|
-
// and keeps working through the browser back/forward buttons
|
|
1085
|
-
this._applyHashSlide();
|
|
1086
|
-
window.addEventListener("hashchange", () => this._applyHashSlide());
|
|
1087
|
-
this._syncHash();
|
|
1088
|
-
}
|
|
1089
2912
|
this._updateProgress();
|
|
1090
2913
|
this._updateDistance();
|
|
1091
2914
|
}
|
|
1092
2915
|
}
|
|
1093
2916
|
}
|
|
1094
2917
|
|
|
1095
|
-
export default class StoryMap extends Evented(StoryMapBase) {
|
|
2918
|
+
export default class StoryMap extends Evented<StoryMapEvents, typeof StoryMapBase>(StoryMapBase) {
|
|
1096
2919
|
/**
|
|
1097
2920
|
* The library base path (the directory containing the module).
|
|
1098
2921
|
* Derived from `import.meta.url`, which works both for the source module
|
|
1099
2922
|
* (src/main.ts) and the built ESM bundle (js/storymap.js).
|
|
1100
2923
|
*/
|
|
1101
|
-
static SCRIPT_PATH = new URL("../", import.meta.url).href;
|
|
2924
|
+
static SCRIPT_PATH = new URL(/* @vite-ignore */ "../", import.meta.url).href;
|
|
1102
2925
|
|
|
1103
2926
|
constructor(...args: ConstructorParameters<typeof StoryMapBase>) {
|
|
1104
2927
|
super(...args);
|