@projektemacher/storymapjs 0.10.6 → 0.10.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG +358 -16
- package/README.md +43 -9
- package/dist/assets/demo-DBpeF31K.js +1 -0
- package/dist/assets/harness-Cr0rYpvt.js +1 -0
- package/dist/assets/harness-multi-CYHgI0a9.js +1 -0
- package/dist/assets/harness-support-Do7sIp2j.js +1 -0
- package/dist/assets/index-wHPqGrBS.js +1 -0
- package/dist/assets/main-DXalctuR.js +17 -0
- package/dist/context.json +12 -9
- package/dist/css/fonts/files/amatic-sc-cyrillic-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/amatic-sc-cyrillic-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/amatic-sc-hebrew-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/amatic-sc-hebrew-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/amatic-sc-vietnamese-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/amatic-sc-vietnamese-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-100-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-100-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-200-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-200-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-300-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-300-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-500-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-500-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-600-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-600-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-800-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-800-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-900-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-900-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-100-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-100-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-200-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-200-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-300-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-300-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-500-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-500-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-600-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-600-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-800-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-800-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-900-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-900-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-ext-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-cyrillic-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-100-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-100-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-200-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-200-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-300-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-300-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-500-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-500-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-600-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-600-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-800-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-800-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-900-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-900-normal.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/bitter-vietnamese-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-arabic-200-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-arabic-300-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-arabic-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-arabic-500-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-arabic-600-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-arabic-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-arabic-800-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-arabic-900-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-200-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-300-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-500-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-600-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-800-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-900-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-ext-200-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-ext-300-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-ext-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-ext-500-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-ext-600-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-ext-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-ext-800-normal.woff2 +0 -0
- package/dist/css/fonts/files/cairo-latin-ext-900-normal.woff2 +0 -0
- package/dist/css/fonts/files/dancing-script-vietnamese-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/eb-garamond-cyrillic-ext-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/eb-garamond-cyrillic-ext-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/eb-garamond-cyrillic-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/eb-garamond-cyrillic-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/eb-garamond-greek-ext-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/eb-garamond-greek-ext-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/eb-garamond-greek-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/eb-garamond-greek-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/eb-garamond-vietnamese-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/eb-garamond-vietnamese-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-cyrillic-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-cyrillic-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-cyrillic-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-cyrillic-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-cyrillic-ext-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-cyrillic-ext-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-cyrillic-ext-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-cyrillic-ext-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-greek-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-greek-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-greek-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-greek-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-greek-ext-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-greek-ext-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-greek-ext-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-greek-ext-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-vietnamese-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-vietnamese-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-vietnamese-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/gentium-book-plus-vietnamese-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/old-standard-tt-cyrillic-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/old-standard-tt-cyrillic-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/old-standard-tt-cyrillic-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/old-standard-tt-cyrillic-ext-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/old-standard-tt-cyrillic-ext-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/old-standard-tt-cyrillic-ext-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/old-standard-tt-vietnamese-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/old-standard-tt-vietnamese-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/old-standard-tt-vietnamese-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-cyrillic-ext-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-cyrillic-ext-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-cyrillic-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-cyrillic-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-greek-ext-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-greek-ext-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-greek-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-greek-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-hebrew-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-hebrew-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-vietnamese-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/open-sans-vietnamese-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-cyrillic-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-cyrillic-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-cyrillic-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-cyrillic-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-cyrillic-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-cyrillic-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-cyrillic-900-italic.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-cyrillic-900-normal.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-vietnamese-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-vietnamese-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-vietnamese-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-vietnamese-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-vietnamese-900-italic.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-sc-vietnamese-900-normal.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-vietnamese-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/playfair-display-vietnamese-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-cyrillic-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-cyrillic-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-cyrillic-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-cyrillic-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-cyrillic-ext-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-cyrillic-ext-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-cyrillic-ext-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-cyrillic-ext-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-narrow-cyrillic-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-narrow-cyrillic-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-narrow-cyrillic-ext-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-sans-narrow-cyrillic-ext-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-serif-cyrillic-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/pt-serif-cyrillic-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-serif-cyrillic-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/pt-serif-cyrillic-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-serif-cyrillic-ext-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/pt-serif-cyrillic-ext-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/pt-serif-cyrillic-ext-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/pt-serif-cyrillic-ext-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/raleway-cyrillic-ext-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/raleway-cyrillic-ext-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/raleway-cyrillic-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/raleway-cyrillic-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/raleway-vietnamese-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/raleway-vietnamese-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/roboto-slab-cyrillic-ext-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/roboto-slab-cyrillic-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/roboto-slab-greek-ext-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/roboto-slab-greek-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/roboto-slab-vietnamese-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-300-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-300-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-500-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-500-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-ext-300-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-ext-300-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-ext-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-ext-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-ext-500-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-ext-500-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-ext-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-cyrillic-ext-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-300-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-300-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-500-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-500-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-ext-300-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-ext-300-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-ext-400-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-ext-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-ext-500-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-ext-500-normal.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-ext-700-italic.woff2 +0 -0
- package/dist/css/fonts/files/ubuntu-greek-ext-700-normal.woff2 +0 -0
- package/dist/css/fonts/files/unica-one-vietnamese-400-normal.woff2 +0 -0
- package/dist/css/fonts/files/vollkorn-cyrillic-ext-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/vollkorn-cyrillic-ext-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/vollkorn-cyrillic-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/vollkorn-cyrillic-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/vollkorn-greek-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/vollkorn-greek-wght-normal.woff2 +0 -0
- package/dist/css/fonts/files/vollkorn-vietnamese-wght-italic.woff2 +0 -0
- package/dist/css/fonts/files/vollkorn-vietnamese-wght-normal.woff2 +0 -0
- package/dist/css/fonts/font.abril-droidsans.css +3 -0
- package/dist/css/fonts/font.amatic-andika.css +57 -0
- package/dist/css/fonts/font.bitter-raleway.css +111 -0
- package/dist/css/fonts/font.cairo.css +312 -0
- package/dist/css/fonts/font.clicker-garamond.css +93 -0
- package/dist/css/fonts/font.dancing-ledger.css +12 -0
- package/dist/css/fonts/font.default.css +183 -0
- package/dist/css/fonts/font.fjalla-average.css +3 -0
- package/dist/css/fonts/font.georgia-helvetica.css +3 -0
- package/dist/css/fonts/font.knightlab.css +642 -0
- package/dist/css/fonts/font.lustria-lato.css +3 -0
- package/dist/css/fonts/font.medula-lato.css +3 -0
- package/dist/css/fonts/font.oldstandard.css +101 -21
- package/dist/css/fonts/font.opensans-gentiumbook.css +183 -0
- package/dist/css/fonts/font.playfair-faunaone.css +39 -0
- package/dist/css/fonts/font.playfair.css +147 -0
- package/dist/css/fonts/font.pt.css +183 -0
- package/dist/css/fonts/font.roboto-megrim.css +3 -0
- package/dist/css/fonts/font.rufina-sintony.css +3 -0
- package/dist/css/fonts/font.ubuntu.css +291 -0
- package/dist/css/fonts/font.unicaone-vollkorn.css +84 -0
- package/dist/css/storymap.css +1 -1
- package/dist/demo.html +2 -2
- package/dist/docs/api/.nojekyll +1 -0
- package/dist/docs/api/assets/custom.css +47 -0
- package/dist/docs/api/assets/hierarchy.js +1 -0
- package/dist/docs/api/assets/highlight.css +57 -0
- package/dist/docs/api/assets/icons.js +18 -0
- package/dist/docs/api/assets/icons.svg +1 -0
- package/dist/docs/api/assets/main.js +60 -0
- package/dist/docs/api/assets/navigation.js +1 -0
- package/dist/docs/api/assets/search.js +1 -0
- package/dist/docs/api/assets/style.css +1648 -0
- package/dist/docs/api/classes/StoryMap.html +1604 -0
- package/dist/docs/api/functions/MediaType.html +51 -0
- package/dist/docs/api/functions/isPresentation3Collection.html +47 -0
- package/dist/docs/api/functions/isPresentation3Manifest.html +53 -0
- package/dist/docs/api/functions/loadCSS.html +63 -0
- package/dist/docs/api/functions/manifestToStorymapData.html +49 -0
- package/dist/docs/api/functions/setLanguage.html +54 -0
- package/dist/docs/api/functions/storymapToManifest.html +57 -0
- package/dist/docs/api/functions/validateStorymap.html +53 -0
- package/dist/docs/api/functions/validateStorymapAndReport.html +54 -0
- package/dist/docs/api/hierarchy.html +28 -0
- package/dist/docs/api/index.html +197 -0
- package/dist/docs/api/interfaces/AnimationHandle.html +60 -0
- package/dist/docs/api/interfaces/ImagereadyPayload.html +71 -0
- package/dist/docs/api/interfaces/LanguageEntry.html +102 -0
- package/dist/docs/api/interfaces/LatLngLiteral.html +73 -0
- package/dist/docs/api/interfaces/LoadOptions.html +59 -0
- package/dist/docs/api/interfaces/OlLayer.html +1400 -0
- package/dist/docs/api/interfaces/OlMap.html +1656 -0
- package/dist/docs/api/interfaces/OlProjection.html +332 -0
- package/dist/docs/api/interfaces/OlSource.html +784 -0
- package/dist/docs/api/interfaces/OlTileLayer.html +1497 -0
- package/dist/docs/api/interfaces/OlVectorLayer.html +1559 -0
- package/dist/docs/api/interfaces/OlView.html +1958 -0
- package/dist/docs/api/interfaces/StorymapData.html +71 -0
- package/dist/docs/api/interfaces/StorymapDataWrapper.html +60 -0
- package/dist/docs/api/interfaces/StorymapDocument.html +68 -0
- package/dist/docs/api/interfaces/StorymapError.html +63 -0
- package/dist/docs/api/interfaces/StorymapGeoreference.html +93 -0
- package/dist/docs/api/interfaces/StorymapGeoreferenceBody.html +74 -0
- package/dist/docs/api/interfaces/StorymapGroundControlPoint.html +69 -0
- package/dist/docs/api/interfaces/StorymapLanguageMap.html +57 -0
- package/dist/docs/api/interfaces/StorymapManifest.html +118 -0
- package/dist/docs/api/interfaces/StorymapManifestAnnotation.html +105 -0
- package/dist/docs/api/interfaces/StorymapManifestBackground.html +81 -0
- package/dist/docs/api/interfaces/StorymapManifestCanvas.html +140 -0
- package/dist/docs/api/interfaces/StorymapManifestContentResource.html +97 -0
- package/dist/docs/api/interfaces/StorymapManifestFeatureCollection.html +82 -0
- package/dist/docs/api/interfaces/StorymapManifestGeoreferencing.html +87 -0
- package/dist/docs/api/interfaces/StorymapManifestImageService.html +69 -0
- package/dist/docs/api/interfaces/StorymapManifestRange.html +75 -0
- package/dist/docs/api/interfaces/StorymapManifestService.html +77 -0
- package/dist/docs/api/interfaces/StorymapManifestSpecificResource.html +69 -0
- package/dist/docs/api/interfaces/StorymapManifestStatement.html +63 -0
- package/dist/docs/api/interfaces/StorymapMapOptions.html +92 -0
- package/dist/docs/api/interfaces/StorymapOptions.html +532 -0
- package/dist/docs/api/interfaces/StorymapOverlayLayer.html +126 -0
- package/dist/docs/api/interfaces/StorymapSlide.html +132 -0
- package/dist/docs/api/interfaces/StorymapSlideBackground.html +72 -0
- package/dist/docs/api/interfaces/StorymapSlideLocation.html +117 -0
- package/dist/docs/api/interfaces/StorymapSlideMarker.html +104 -0
- package/dist/docs/api/interfaces/StorymapSlideMedia.html +110 -0
- package/dist/docs/api/interfaces/StorymapSlideNarration.html +65 -0
- package/dist/docs/api/interfaces/StorymapSlideText.html +72 -0
- package/dist/docs/api/interfaces/StorymapTilejson.html +105 -0
- package/dist/docs/api/media/es.json +31 -0
- package/dist/docs/api/media/migration-from-knightlab.md +359 -0
- package/dist/docs/api/modules.html +50 -0
- package/dist/docs/api/types/ContentState.html +70 -0
- package/dist/docs/api/types/ImagereadyKind.html +34 -0
- package/dist/docs/api/types/MediaTypeMatch.html +78 -0
- package/dist/docs/api/types/TileSourceFactory.html +53 -0
- package/dist/docs/authoring.html +293 -0
- package/dist/docs/iiif.html +617 -62
- package/dist/docs/migration.html +314 -12
- package/dist/docs/readme.html +40 -9
- package/dist/embed/index.html +130 -5
- package/dist/examples/issue-242-nonlatin.json +55 -0
- package/dist/examples/issue-305-start-at-slide-data.json +96 -0
- package/dist/examples/issue-358-iframe.json +57 -0
- package/dist/examples/issue-iiif-geo.json +32 -0
- package/dist/examples/issue-image-alt.json +23 -0
- package/dist/examples/issue-image-region.json +42 -0
- package/dist/examples/issue-image-srcset.json +24 -0
- package/dist/examples/map-area-overlays.json +68 -0
- package/dist/examples/no-map.json +44 -0
- package/dist/examples-iiif/annotated-image.json +188 -0
- package/dist/examples-iiif/bob-boilen.json +11 -5
- package/dist/examples-iiif/courbet.json +9 -6
- package/dist/examples-iiif/empty.json +8 -4
- package/dist/examples-iiif/flickr-drewvigal.json +248 -24
- package/dist/examples-iiif/flickr-zach.wise.json +8 -4
- package/dist/examples-iiif/georeferenced-layer-unsupported.json +153 -0
- package/dist/examples-iiif/georeferenced-layer.json +318 -0
- package/dist/examples-iiif/iiif-wellcome.json +9 -6
- package/dist/examples-iiif/instagram-chenpamela.json +381 -36
- package/dist/examples-iiif/instagram_brentfoster.json +429 -40
- package/dist/examples-iiif/instagram_couch.json +261 -26
- package/dist/examples-iiif/instagram_joegermuska.json +441 -41
- package/dist/examples-iiif/instagram_matteich.json +261 -26
- package/dist/examples-iiif/instagram_mirandamulligan.json +261 -26
- package/dist/examples-iiif/instagram_raymondmccrea.json +261 -26
- package/dist/examples-iiif/instagram_test.json +261 -26
- package/dist/examples-iiif/issue-134-no-lines.json +9 -5
- package/dist/examples-iiif/issue-177-color-vars.json +9 -5
- package/dist/examples-iiif/issue-211-rtl.json +9 -5
- package/dist/examples-iiif/issue-242-nonlatin.json +239 -0
- package/dist/examples-iiif/issue-272-facebook.json +9 -5
- package/dist/examples-iiif/issue-305-start-at-slide-data.json +305 -0
- package/dist/examples-iiif/issue-305-start-at-slide.json +9 -5
- package/dist/examples-iiif/issue-341-distance.json +9 -5
- package/dist/examples-iiif/issue-358-iframe.json +190 -0
- package/dist/examples-iiif/issue-360-juxtapose.json +22 -7
- package/dist/examples-iiif/issue-368-null-fields.json +9 -5
- package/dist/examples-iiif/issue-375-code-lists.json +9 -5
- package/dist/examples-iiif/issue-381-dateline.json +9 -5
- package/dist/examples-iiif/issue-405-custom-icon.json +203 -0
- package/dist/examples-iiif/issue-425-many-slides.json +9 -5
- package/dist/examples-iiif/issue-428-background.json +20 -8
- package/dist/examples-iiif/issue-435-googlemaps.json +9 -5
- package/dist/examples-iiif/issue-437-documentcloud.json +22 -7
- package/dist/examples-iiif/issue-451-webp.json +9 -5
- package/dist/examples-iiif/issue-455-audio.json +9 -5
- package/dist/examples-iiif/issue-480-shorts.json +9 -5
- package/dist/examples-iiif/issue-506-marker-sync.json +9 -5
- package/dist/examples-iiif/issue-iiif-geo.json +200 -0
- package/dist/examples-iiif/issue-image-alt.json +90 -0
- package/dist/examples-iiif/issue-image-region.json +203 -0
- package/dist/examples-iiif/issue-image-srcset.json +87 -0
- package/dist/examples-iiif/jansteen.json +9 -6
- package/dist/examples-iiif/katrina.json +197 -41
- package/dist/examples-iiif/map-area-overlays.json +289 -0
- package/dist/examples-iiif/marktwain.json +255 -58
- package/dist/examples-iiif/no-map.json +148 -0
- package/dist/examples-iiif/population.json +440 -76
- package/dist/examples-iiif/president.json +456 -83
- package/dist/examples-iiif/scroll-hint.json +190 -0
- package/dist/examples-iiif/seurat.json +113 -22
- package/dist/examples-iiif/zoomify-clamp.json +153 -0
- package/dist/harness-multi.html +17 -0
- package/dist/harness.html +3 -2
- package/dist/index.html +33 -10
- package/dist/js/storymap.d.ts +1558 -114
- package/dist/js/storymap.js +10113 -6490
- package/dist/js/storymap.js.map +1 -1
- package/dist/navplace-properties.json +40 -0
- package/docs/migration-from-knightlab.md +359 -0
- package/docs/storymap-as-iiif-manifest.md +934 -0
- package/package.json +16 -10
- package/schema/storymap.schema.json +431 -78
- package/src/animation/easings.ts +25 -0
- package/src/animation/tween.ts +186 -0
- package/src/core/Browser.ts +20 -0
- package/src/core/Load.ts +232 -0
- package/src/core/Util.ts +416 -0
- package/src/core/mixins.ts +188 -0
- package/src/core/viewers.ts +94 -0
- package/src/dom/Dom.ts +31 -0
- package/src/dom/DomEvent.ts +80 -0
- package/src/globals.d.ts +24 -0
- package/src/language/Language.ts +281 -0
- package/src/language/locale/.expected-gaps.json +86 -0
- package/src/language/locale/be.json +31 -0
- package/src/language/locale/bg.json +31 -0
- package/src/language/locale/cs.json +31 -0
- package/src/language/locale/de.json +31 -0
- package/src/language/locale/el.json +31 -0
- package/src/language/locale/en.json +32 -0
- package/src/language/locale/es.json +31 -0
- package/src/language/locale/et.json +22 -0
- package/src/language/locale/fr.json +31 -0
- package/src/language/locale/he.json +31 -0
- package/src/language/locale/hu.json +31 -0
- package/src/language/locale/is.json +22 -0
- package/src/language/locale/it.json +31 -0
- package/src/language/locale/jp.json +31 -0
- package/src/language/locale/ko.json +31 -0
- package/src/language/locale/nl.json +31 -0
- package/src/language/locale/nn.json +22 -0
- package/src/language/locale/no.json +31 -0
- package/src/language/locale/pl.json +31 -0
- package/src/language/locale/pt.json +31 -0
- package/src/language/locale/ru.json +31 -0
- package/src/language/locale/sk.json +31 -0
- package/src/language/locale/sr.json +21 -0
- package/src/language/locale/sv.json +31 -0
- package/src/language/locale/tr.json +31 -0
- package/src/language/locale/uk.json +31 -0
- package/src/language/locale/ur.json +22 -0
- package/src/language/locale/zh-cn.json +31 -0
- package/src/language/locale/zh-tw.json +31 -0
- package/src/main.ts +143 -0
- package/src/map/Map.ts +794 -0
- package/src/map/MapMarker.ts +172 -0
- package/src/map/georeference.ts +325 -0
- package/src/map/openlayers/Map.OpenLayers.ts +2492 -0
- package/src/map/openlayers/MapMarker.OpenLayers.ts +400 -0
- package/src/map/openlayers/zoomifyTiles.ts +25 -0
- package/src/map/types.ts +17 -0
- package/src/media/EmbedUtil.ts +452 -0
- package/src/media/Media.ts +597 -0
- package/src/media/MediaType.ts +163 -0
- package/src/media/embedId.ts +104 -0
- package/src/media/types/Audio.ts +14 -0
- package/src/media/types/Blockquote.ts +39 -0
- package/src/media/types/DailyMotion.ts +53 -0
- package/src/media/types/DocumentCloud.ts +12 -0
- package/src/media/types/Facebook.ts +40 -0
- package/src/media/types/Flickr.ts +115 -0
- package/src/media/types/GoogleDoc.ts +58 -0
- package/src/media/types/HtmlMedia.ts +167 -0
- package/src/media/types/IFrame.ts +50 -0
- package/src/media/types/Image.ts +115 -0
- package/src/media/types/Juxtapose.ts +41 -0
- package/src/media/types/SoundCloud.ts +99 -0
- package/src/media/types/Text.ts +178 -0
- package/src/media/types/Twitter.ts +109 -0
- package/src/media/types/Video.ts +14 -0
- package/src/media/types/Vimeo.ts +65 -0
- package/src/media/types/Website.ts +46 -0
- package/src/media/types/Wikipedia.ts +134 -0
- package/src/media/types/YouTube.ts +224 -0
- package/src/scss/Typography.scss +235 -0
- package/src/scss/VCO.StoryMap.Dark.scss +150 -0
- package/src/scss/VCO.StoryMap.scss +195 -0
- package/src/scss/Variables.Dark.scss +57 -0
- package/src/scss/Variables.scss +48 -0
- package/src/scss/core/Mixins.scss +54 -0
- package/src/scss/core/Reset.scss +206 -0
- package/src/scss/fonts/_font.base.scss +113 -0
- package/src/scss/fonts/font.abril-droidsans.scss +75 -0
- package/src/scss/fonts/font.amatic-andika.scss +76 -0
- package/src/scss/fonts/font.bitter-raleway.scss +92 -0
- package/src/scss/fonts/font.cairo.scss +79 -0
- package/src/scss/fonts/font.clicker-garamond.scss +97 -0
- package/src/scss/fonts/font.dancing-ledger.scss +75 -0
- package/src/scss/fonts/font.default.scss +9 -0
- package/src/scss/fonts/font.fjalla-average.scss +80 -0
- package/src/scss/fonts/font.georgia-helvetica.scss +58 -0
- package/src/scss/fonts/font.knightlab.scss +111 -0
- package/src/scss/fonts/font.lustria-lato.scss +80 -0
- package/src/scss/fonts/font.medula-lato.scss +73 -0
- package/src/scss/fonts/font.oldstandard.scss +94 -0
- package/src/scss/fonts/font.opensans-gentiumbook.scss +85 -0
- package/src/scss/fonts/font.playfair-faunaone.scss +88 -0
- package/src/scss/fonts/font.playfair.scss +89 -0
- package/src/scss/fonts/font.pt.scss +96 -0
- package/src/scss/fonts/font.roboto-megrim.scss +73 -0
- package/src/scss/fonts/font.rufina-sintony.scss +73 -0
- package/src/scss/fonts/font.ubuntu.scss +82 -0
- package/src/scss/fonts/font.unicaone-vollkorn.scss +90 -0
- package/src/scss/icons/Icons.scss +239 -0
- package/src/scss/map/VCO.ImageMarker.scss +23 -0
- package/src/scss/map/VCO.Map.scss +21 -0
- package/src/scss/map/VCO.MapMarker.scss +242 -0
- package/src/scss/map/openlayers/VCO.Map.OpenLayers.scss +126 -0
- package/src/scss/media/VCO.Media.scss +219 -0
- package/src/scss/media/types/VCO.Media.Blockquote.scss +76 -0
- package/src/scss/media/types/VCO.Media.IFrame.scss +11 -0
- package/src/scss/media/types/VCO.Media.Image.scss +13 -0
- package/src/scss/media/types/VCO.Media.Text.scss +98 -0
- package/src/scss/media/types/VCO.Media.Twitter.scss +87 -0
- package/src/scss/media/types/VCO.Media.Wikipedia.scss +65 -0
- package/src/scss/site/site.scss +748 -0
- package/src/scss/slider/VCO.Slide.scss +457 -0
- package/src/scss/slider/VCO.SlideNav.scss +332 -0
- package/src/scss/slider/VCO.StorySlider.scss +66 -0
- package/src/scss/snapmap/VCO.SnapMap.scss +103 -0
- package/src/scss/ui/VCO.Consent.scss +121 -0
- package/src/scss/ui/VCO.MenuBar.Button.scss +107 -0
- package/src/scss/ui/VCO.MenuBar.scss +20 -0
- package/src/scss/ui/VCO.Message.scss +101 -0
- package/src/site/site.ts +88 -0
- package/src/slider/Slide.ts +554 -0
- package/src/slider/SlideNav.ts +246 -0
- package/src/slider/StorySlider.ts +833 -0
- package/src/storymap/Consent.ts +471 -0
- package/src/storymap/StoryMap.ts +2118 -0
- package/src/storymap/content-state.ts +185 -0
- package/src/storymap/iiif.ts +1844 -0
- package/src/storymap/to-iiif.ts +921 -0
- package/src/storymap/validate.ts +257 -0
- package/src/types.ts +494 -0
- package/src/ui/MenuBar.ts +376 -0
- package/src/ui/Message.ts +127 -0
- package/src/ui/Swipable.ts +455 -0
- package/dist/assets/demo-CVQ7kFek.js +0 -1
- package/dist/assets/harness-C-VZV-ou.js +0 -1
- package/dist/assets/index-DDsPBPZA.js +0 -1
- package/dist/assets/main-DwbXrrYn.js +0 -17
|
@@ -0,0 +1,2492 @@
|
|
|
1
|
+
import OlMap from "ol/Map";
|
|
2
|
+
import View from "ol/View";
|
|
3
|
+
import type { Control } from "ol/control";
|
|
4
|
+
import type { Interaction } from "ol/interaction";
|
|
5
|
+
import { Tile as TileLayer, Vector as VectorLayer } from "ol/layer";
|
|
6
|
+
import Layer from "ol/layer/Layer";
|
|
7
|
+
import VectorTileLayer from "ol/layer/VectorTile";
|
|
8
|
+
import { XYZ, OSM, IIIF } from "ol/source";
|
|
9
|
+
import type Source from "ol/source/Source";
|
|
10
|
+
import VectorSource from "ol/source/Vector";
|
|
11
|
+
import LineString from "ol/geom/LineString";
|
|
12
|
+
import Feature from "ol/Feature";
|
|
13
|
+
import { Style, Stroke } from "ol/style";
|
|
14
|
+
import { fromLonLat, toLonLat } from "ol/proj";
|
|
15
|
+
import type Projection from "ol/proj/Projection";
|
|
16
|
+
import { boundingExtent, getIntersection } from "ol/extent";
|
|
17
|
+
import type { Extent } from "ol/extent";
|
|
18
|
+
import TileGrid from "ol/tilegrid/TileGrid";
|
|
19
|
+
import type ImageTile from "ol/ImageTile";
|
|
20
|
+
import OverviewMap from "ol/control/OverviewMap";
|
|
21
|
+
import { defaults as interactionDefaults } from "ol/interaction";
|
|
22
|
+
import { applyStyle } from "ol-mapbox-style";
|
|
23
|
+
import IIIFInfo, { type ImageInformationResponse } from "ol/format/IIIFInfo";
|
|
24
|
+
|
|
25
|
+
import "ol/ol.css";
|
|
26
|
+
|
|
27
|
+
import Map from "../Map";
|
|
28
|
+
import OpenLayersMapMarker from "./MapMarker.OpenLayers";
|
|
29
|
+
import { padCroppedZoomifyTile } from "./zoomifyTiles";
|
|
30
|
+
import type { LinePoint, ViewToOptions } from "../types";
|
|
31
|
+
import type {
|
|
32
|
+
LatLngLiteral,
|
|
33
|
+
StorymapOverlayLayer,
|
|
34
|
+
StorymapTilejson,
|
|
35
|
+
StorymapSlide,
|
|
36
|
+
StorymapSlideLocation,
|
|
37
|
+
} from "../../types";
|
|
38
|
+
import { fitGeoreference, resolveInfoJsonUrl } from "../georeference";
|
|
39
|
+
import {
|
|
40
|
+
consentManagerOf,
|
|
41
|
+
tileService,
|
|
42
|
+
type ConsentManager,
|
|
43
|
+
type ConsentService,
|
|
44
|
+
} from "../../storymap/Consent";
|
|
45
|
+
import { sanitizeSlideText } from "../../media/EmbedUtil";
|
|
46
|
+
|
|
47
|
+
/* Map.OpenLayers
|
|
48
|
+
Creates a Map using OpenLayers
|
|
49
|
+
================================================= */
|
|
50
|
+
|
|
51
|
+
const MAX_ZOOM = 19;
|
|
52
|
+
|
|
53
|
+
/** What kind of imagery an `imageready` source carries. */
|
|
54
|
+
export type ImagereadyKind = "iiif" | "zoomify" | "tiles";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Payload of the `imageready` event: the source that became usable, what
|
|
58
|
+
* kind of imagery it is, and the layer carrying it (`null` when the source
|
|
59
|
+
* outlives its layer).
|
|
60
|
+
*/
|
|
61
|
+
export interface ImagereadyPayload {
|
|
62
|
+
source: { getState?(): string };
|
|
63
|
+
kind: ImagereadyKind;
|
|
64
|
+
layer: Layer | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Zoom ladder for image-space maps (issue #465): rung 0 shows any image fully
|
|
69
|
+
* zoomed out, finer rungs reach sub-pixel detail. It keeps view zooms,
|
|
70
|
+
* marker zooms and tile loading on one consistent ladder (tile enqueueing
|
|
71
|
+
* silently drops tiles otherwise).
|
|
72
|
+
*/
|
|
73
|
+
const IMAGE_RESOLUTIONS = Array.from({ length: 25 }, (_, i) => 2 ** (16 - i));
|
|
74
|
+
|
|
75
|
+
export default class OpenLayers extends Map {
|
|
76
|
+
declare "_map": OlMap;
|
|
77
|
+
declare "_tile_layer": Layer;
|
|
78
|
+
declare "_line": VectorLayer;
|
|
79
|
+
declare "_line_active": VectorLayer;
|
|
80
|
+
declare "_tile_layer_mini": Layer | null;
|
|
81
|
+
declare "_mini_map": OverviewMap;
|
|
82
|
+
declare "_markers": OpenLayersMapMarker[];
|
|
83
|
+
/** App-level stacked overlays (see the `overlays` option) */
|
|
84
|
+
declare "_overlay_layers": Layer[];
|
|
85
|
+
/**
|
|
86
|
+
* The `overlays[]` entries that produced a layer, parallel to
|
|
87
|
+
* `_overlay_layers`: a malformed entry is skipped, so the two arrays
|
|
88
|
+
* can be shorter than the option list.
|
|
89
|
+
*/
|
|
90
|
+
declare "_overlay_entries": StorymapOverlayLayer[];
|
|
91
|
+
/** rAF handle of the running active-line draw animation */
|
|
92
|
+
declare "_line_animation": number | null;
|
|
93
|
+
/** Sources `imageready` has already been fired for. */
|
|
94
|
+
declare "_imageready_fired": WeakSet<object>;
|
|
95
|
+
|
|
96
|
+
/* Create the Map
|
|
97
|
+
================================================== */
|
|
98
|
+
_createMap(): void {
|
|
99
|
+
const is_image_map = this.isImageSpace();
|
|
100
|
+
this._imageready_fired = new WeakSet();
|
|
101
|
+
|
|
102
|
+
// Caller-supplied OpenLayers options: `controls` replaces the defaults
|
|
103
|
+
// (the viewer installs none), `interactions` are added to the viewer's
|
|
104
|
+
// own pan/zoom set, `view` merges over the computed default, everything
|
|
105
|
+
// else passes through
|
|
106
|
+
const user_map_options = this.options.map_options ?? {};
|
|
107
|
+
const { element: _element, view: user_view, ...passthrough } = user_map_options;
|
|
108
|
+
const user_view_options = (user_view ?? {}) as Record<string, unknown>;
|
|
109
|
+
|
|
110
|
+
// bbox limitation (map_bbox): the view center is constrained to the
|
|
111
|
+
// box (constrainOnlyCenter) — a hard extent constraint would pin the
|
|
112
|
+
// center and resolution whenever the viewport fills or aspect-clips
|
|
113
|
+
// the box, undoing the panel offset and spreading the markers out
|
|
114
|
+
// (probed: a 640x800 viewport aspect-clips a wide bbox, so the
|
|
115
|
+
// strict constraint locks the zoom and the route overflows). Image-
|
|
116
|
+
// space maps use raw pixel coordinates.
|
|
117
|
+
const bbox_extent = this._bboxExtent();
|
|
118
|
+
|
|
119
|
+
// A TileJSON tile source states its own zoom ladder, its covered area
|
|
120
|
+
// and its default view, and all three are honoured here (§2.9). A
|
|
121
|
+
// keyword basemap has none of these, so the defaults stand.
|
|
122
|
+
const tilejson = this._tilejsonFor(this.options.map_type);
|
|
123
|
+
const tile_min_zoom = tilejson?.minzoom ?? 0;
|
|
124
|
+
const tile_max_zoom = tilejson?.maxzoom ?? MAX_ZOOM;
|
|
125
|
+
const tile_extent = tilejson?.bounds
|
|
126
|
+
? fromLonLat(tilejson.bounds as [number, number, number, number])
|
|
127
|
+
: null;
|
|
128
|
+
|
|
129
|
+
this._map = new OlMap({
|
|
130
|
+
...passthrough,
|
|
131
|
+
target: this._el.map,
|
|
132
|
+
controls: (user_map_options.controls as Control[]) ?? [],
|
|
133
|
+
interactions: (user_map_options.interactions as Interaction[]) ?? [],
|
|
134
|
+
view: new View({
|
|
135
|
+
projection: is_image_map ? "EPSG:4326" : "EPSG:3857",
|
|
136
|
+
...(tilejson?.center
|
|
137
|
+
? {
|
|
138
|
+
center: fromLonLat([tilejson.center[0], tilejson.center[1]]),
|
|
139
|
+
zoom: tilejson.center[2],
|
|
140
|
+
}
|
|
141
|
+
: { center: [0, 0], zoom: 0 }),
|
|
142
|
+
minZoom: tile_min_zoom,
|
|
143
|
+
maxZoom: is_image_map ? IMAGE_RESOLUTIONS.length - 1 : tile_max_zoom,
|
|
144
|
+
// a tile source that only covers part of the world: keep the
|
|
145
|
+
// view inside what it actually has tiles for
|
|
146
|
+
...(tile_extent && !is_image_map
|
|
147
|
+
? { extent: tile_extent, constrainOnlyCenter: true }
|
|
148
|
+
: {}),
|
|
149
|
+
// image maps live outside the 4326 world: opt out of the
|
|
150
|
+
// global-projection constraints (they cap resolution at
|
|
151
|
+
// fit-the-world and clamp the center to [-90, 90]) and use
|
|
152
|
+
// the image zoom ladder instead of the default one
|
|
153
|
+
// (issue #465)
|
|
154
|
+
...(is_image_map ? { multiWorld: true, resolutions: IMAGE_RESOLUTIONS } : {}),
|
|
155
|
+
// legacy zoomify: the image pyramid needs zooms where the
|
|
156
|
+
// world is smaller than the viewport (the original renderer
|
|
157
|
+
// showed the painting at ~487px in a 1280px window)
|
|
158
|
+
...(this.options.map_type === "zoomify" ? { multiWorld: true } : {}),
|
|
159
|
+
...(bbox_extent ? { extent: bbox_extent, constrainOnlyCenter: true } : {}),
|
|
160
|
+
...user_view_options,
|
|
161
|
+
}),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
this._map.on("loadend", () => {
|
|
165
|
+
this._onMapLoaded(undefined);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// keep the marker overlays above the layer canvases: OL pins the
|
|
169
|
+
// overlay container at z-index 0 (inline), and during pan/zoom
|
|
170
|
+
// animations the composited canvases can transiently paint over it —
|
|
171
|
+
// the marker icons flicker
|
|
172
|
+
const overlay_container = this._el.map.querySelector(".ol-overlaycontainer");
|
|
173
|
+
if (overlay_container) {
|
|
174
|
+
(overlay_container as HTMLElement).style.zIndex = "1";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Create Tile Layer
|
|
178
|
+
// Tile Layer — GDPR consent mode defers it until the visitor allows
|
|
179
|
+
// map tiles (the consent bar renders over the map)
|
|
180
|
+
const consent = consentManagerOf(this.options);
|
|
181
|
+
const tile_service = tileService();
|
|
182
|
+
if (this.options.consent_required && consent) {
|
|
183
|
+
if (consent.isGranted(tile_service.key)) {
|
|
184
|
+
this._addTileLayer();
|
|
185
|
+
} else if (!consent.isDenied(tile_service.key)) {
|
|
186
|
+
void this._requestTileConsent(consent, tile_service);
|
|
187
|
+
}
|
|
188
|
+
// denied → the map renders with background color and markers only
|
|
189
|
+
} else {
|
|
190
|
+
this._addTileLayer();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Create Overall Connection Line (reserved z-range 10/11 for the
|
|
194
|
+
// route lines; see the stack-order note at _addTileLayer)
|
|
195
|
+
this._line = this._createLine();
|
|
196
|
+
this._line.setStyle(this._lineStyle(this.options.line_color_inactive));
|
|
197
|
+
this._addLineToMap(this._line);
|
|
198
|
+
this._line.setZIndex(10);
|
|
199
|
+
this._line.setOpacity(this.options.line_opacity);
|
|
200
|
+
|
|
201
|
+
// Create Active Line
|
|
202
|
+
this._line_active = this._createLine();
|
|
203
|
+
this._line_active.setStyle(this._lineStyle(this.options.line_color));
|
|
204
|
+
this._addLineToMap(this._line_active);
|
|
205
|
+
this._line_active.setZIndex(11);
|
|
206
|
+
this._line_active.setOpacity(1);
|
|
207
|
+
|
|
208
|
+
if (this.options.map_as_image) {
|
|
209
|
+
this._line_active.setVisible(false);
|
|
210
|
+
this._line.setVisible(false);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Stacked raster overlays (base tiles 1.., below the route lines)
|
|
214
|
+
this._overlay_layers = [];
|
|
215
|
+
this._overlay_entries = [];
|
|
216
|
+
this._buildOverlays();
|
|
217
|
+
|
|
218
|
+
// Native interactions (pan/zoom), no scroll zoom by default
|
|
219
|
+
const interactions = interactionDefaults({ mouseWheelZoom: false });
|
|
220
|
+
interactions.forEach((i) => this._map.addInteraction(i));
|
|
221
|
+
|
|
222
|
+
// Attribution
|
|
223
|
+
this._updateAttribution();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Extra attribution fragments (e.g. overlay credits), always listed last. */
|
|
227
|
+
declare "_extra_attributions": string[];
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Append extra attribution fragments and refresh the line. Hosts with
|
|
231
|
+
* custom layers use this instead of rewriting `.vco-map-attribution`.
|
|
232
|
+
*/
|
|
233
|
+
setExtraAttributions(parts: string[]): void {
|
|
234
|
+
this._extra_attributions = [...parts];
|
|
235
|
+
this._updateAttribution();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/* Public accessors
|
|
239
|
+
Consumers get the raw OpenLayers map via `storymap.map`; these hand out
|
|
240
|
+
the internal objects the viewer keeps private (base/overlay/minimap layers,
|
|
241
|
+
the route lines, the markers) so nothing has to be reached for through an
|
|
242
|
+
underscore field. Layer order in `map.getLayers()` is NOT the overlay
|
|
243
|
+
order (creation order is [base, line, line_active, overlay0…] and zIndex is
|
|
244
|
+
a separate axis), so `getOverlayLayers()` is the only correct way to map an
|
|
245
|
+
`overlays[]` index onto a layer.
|
|
246
|
+
================================================= */
|
|
247
|
+
|
|
248
|
+
/** The base tile layer (the one created for `map_type`), if consent has not deferred it. */
|
|
249
|
+
getBaseLayer(): Layer | null {
|
|
250
|
+
return this._tile_layer ?? null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The stacked `overlays[]` layers, in `overlays[]` order. */
|
|
254
|
+
getOverlayLayers(): Layer[] {
|
|
255
|
+
return [...this._overlay_layers];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** One stacked overlay layer by its `overlays[]` index, or `null`. */
|
|
259
|
+
getOverlayLayer(index: number): Layer | null {
|
|
260
|
+
return this._overlay_layers[index] ?? null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The minimap's OpenLayers map (the `OverviewMap` control's inner map). */
|
|
264
|
+
getMinimap(): OlMap | null {
|
|
265
|
+
const overview = this._mini_map?.getOverviewMap?.() ?? null;
|
|
266
|
+
return overview ?? null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The full (inactive) route line layer. */
|
|
270
|
+
getLine(): Layer | null {
|
|
271
|
+
return this._line ?? null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The highlighted route line layer drawn up to the current slide. */
|
|
275
|
+
getLineActive(): Layer | null {
|
|
276
|
+
return this._line_active ?? null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The markers, in slide order (the index matches the slide index). */
|
|
280
|
+
getMarkers(): OpenLayersMapMarker[] {
|
|
281
|
+
return [...this._markers];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** One marker by slide index, or `null`. */
|
|
285
|
+
getMarker(index: number): OpenLayersMapMarker | null {
|
|
286
|
+
return this._markers[index] ?? null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* (Re)render the attribution line for the current map type. Called at
|
|
291
|
+
* creation and on every `map_type` switch, which previously left the
|
|
292
|
+
* initial text stale.
|
|
293
|
+
*/
|
|
294
|
+
_updateAttribution(): void {
|
|
295
|
+
const extras = this._extra_attributions ?? [];
|
|
296
|
+
// Identical credits (e.g. stacked overlays sharing one source, or the
|
|
297
|
+
// attribution option restating the base credit) collapse to one entry
|
|
298
|
+
const parts = [
|
|
299
|
+
...new Set([...this._getAttribution(this.options.map_type), ...extras].filter(Boolean)),
|
|
300
|
+
].join(" | ");
|
|
301
|
+
let el = this._el.map.querySelector(".vco-map-attribution") as HTMLElement | null;
|
|
302
|
+
if (!el) {
|
|
303
|
+
this._el.map.insertAdjacentHTML("beforeend", `<div class="vco-map-attribution"></div>`);
|
|
304
|
+
el = this._el.map.querySelector(".vco-map-attribution") as HTMLElement | null;
|
|
305
|
+
}
|
|
306
|
+
if (el) {
|
|
307
|
+
// The library-authored credits are trusted markup, but
|
|
308
|
+
// `options.attribution` and overlay credits come from the
|
|
309
|
+
// storymap JSON. Rather than trusting them, the whole line is
|
|
310
|
+
// re-sanitized: a fragment that is not well-formed markup is
|
|
311
|
+
// unwrapped to text, so an author-supplied credit can never
|
|
312
|
+
// inject an element into the map.
|
|
313
|
+
el.textContent = "";
|
|
314
|
+
el.appendChild(sanitizeSlideText(parts));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
_getAttribution(map_type: string): string[] {
|
|
319
|
+
const parts = [
|
|
320
|
+
"<a href='https://storymap.knightlab.com/' target='_blank' class='vco-knightlab-brand'><span>■</span> StoryMapJS</a>",
|
|
321
|
+
];
|
|
322
|
+
// Our own `.vco-map-attribution` line is the rendered default (the
|
|
323
|
+
// OL attribution control is not added), so this hand-built line is
|
|
324
|
+
// what the visitor sees. Sources additionally carry the plain-text
|
|
325
|
+
// credit from `_sourceAttributions`, so `source.getAttributions()`
|
|
326
|
+
// and a consumer's own Attribution control are not left empty.
|
|
327
|
+
// Emitting provider credit
|
|
328
|
+
// only for OSM left Stadia, Mapbox and the OL XYZ template providers
|
|
329
|
+
// uncredited, which their terms of service require. The credit is
|
|
330
|
+
// keyed off the resolved map_type, so a custom `{z}` template gets
|
|
331
|
+
// the generic "map data" line and the author can still override it
|
|
332
|
+
// with the `attribution` option.
|
|
333
|
+
if (map_type === "" || map_type.startsWith("osm")) {
|
|
334
|
+
parts.push(
|
|
335
|
+
"© <a target='_blank' href='https://www.openstreetmap.org/copyright'>OpenStreetMap</a> contributors",
|
|
336
|
+
);
|
|
337
|
+
} else if (map_type.startsWith("stadia") || map_type === "stamen") {
|
|
338
|
+
parts.push(
|
|
339
|
+
'© <a target="_blank" rel="noopener noreferrer" href="https://stadiamaps.com/">Stadia Maps</a>, ' +
|
|
340
|
+
'© <a target="_blank" rel="noopener noreferrer" href="https://openmaptiles.org/">OpenMapTiles</a> ' +
|
|
341
|
+
'© <a target="_blank" rel="noopener noreferrer" href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
|
342
|
+
);
|
|
343
|
+
} else if (map_type.startsWith("mapbox://")) {
|
|
344
|
+
parts.push('© <a target="_blank" href="https://www.mapbox.com/about/maps/">Mapbox</a>');
|
|
345
|
+
} else if (map_type.startsWith("ch-") || map_type.startsWith("esri")) {
|
|
346
|
+
parts.push('Map data © <a target="_blank" href="https://www.esri.com/">Esri</a>');
|
|
347
|
+
} else {
|
|
348
|
+
parts.push("Map data");
|
|
349
|
+
}
|
|
350
|
+
if (this.options.attribution) {
|
|
351
|
+
parts.push(this.options.attribution);
|
|
352
|
+
}
|
|
353
|
+
return parts;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The provider credit for `map_type` as plain text (no HTML), for the
|
|
358
|
+
* OpenLayers source `attributions`. Our own `.vco-map-attribution` line
|
|
359
|
+
* stays the rendered default and uses the linked `_getAttribution`
|
|
360
|
+
* version; this makes `source.getAttributions()` (and a consumer's
|
|
361
|
+
* `ol/control/Attribution`) work instead of returning nothing.
|
|
362
|
+
*/
|
|
363
|
+
_sourceAttributions(map_type: string): string[] {
|
|
364
|
+
const parts: string[] = [];
|
|
365
|
+
if (map_type === "" || map_type.startsWith("osm")) {
|
|
366
|
+
parts.push("© OpenStreetMap contributors");
|
|
367
|
+
} else if (map_type.startsWith("stadia") || map_type === "stamen") {
|
|
368
|
+
parts.push("© Stadia Maps, © OpenMapTiles © OpenStreetMap contributors");
|
|
369
|
+
} else if (map_type.startsWith("mapbox://")) {
|
|
370
|
+
parts.push("© Mapbox");
|
|
371
|
+
} else if (map_type.startsWith("ch-") || map_type.startsWith("esri")) {
|
|
372
|
+
parts.push("Map data © Esri");
|
|
373
|
+
} else {
|
|
374
|
+
parts.push("Map data");
|
|
375
|
+
}
|
|
376
|
+
if (this.options.attribution) {
|
|
377
|
+
parts.push(this.options.attribution);
|
|
378
|
+
}
|
|
379
|
+
return parts;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/* Create Tile Layer
|
|
383
|
+
================================================== */
|
|
384
|
+
/**
|
|
385
|
+
* Create the tile layer and register its load handler. Deferred until
|
|
386
|
+
* the visitor allows map tiles in consent mode.
|
|
387
|
+
*/
|
|
388
|
+
_addTileLayer(): void {
|
|
389
|
+
this._tile_layer = this._createTileLayer(this.options.map_type);
|
|
390
|
+
// explicit stack order (base tiles 0, overlays 1.., route lines
|
|
391
|
+
// 10/11): the switch path re-adds the tile layer last, which used
|
|
392
|
+
// to bury the route lines under fresh tiles
|
|
393
|
+
this._tile_layer.setZIndex(0);
|
|
394
|
+
this._map.addLayer(this._tile_layer);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Ask for tile consent, then attach the layers if allowed.
|
|
399
|
+
*/
|
|
400
|
+
async _requestTileConsent(consent: ConsentManager, tile_service: ConsentService) {
|
|
401
|
+
if (await consent.request(tile_service, "", this._el.map)) {
|
|
402
|
+
this._onTilesAllowed();
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/* Stacked overlays (the `overlays` option)
|
|
407
|
+
================================================== */
|
|
408
|
+
/**
|
|
409
|
+
* (Re)build the stacked raster overlays from the `overlays` option.
|
|
410
|
+
* Overlays sit above the base tiles (z 1..n) and below the route lines
|
|
411
|
+
* (z 10/11); every entry accepts any `map_type` value plus declarative
|
|
412
|
+
* presentation, so hosts no longer capture and patch layer objects.
|
|
413
|
+
* An entry carrying a `georeference` places a IIIF image on the
|
|
414
|
+
* geographic map instead of stacking a tile source.
|
|
415
|
+
*/
|
|
416
|
+
_buildOverlays(): void {
|
|
417
|
+
for (const layer of this._overlay_layers) {
|
|
418
|
+
this._map.removeLayer(layer);
|
|
419
|
+
}
|
|
420
|
+
this._overlay_layers = [];
|
|
421
|
+
this._overlay_entries = [];
|
|
422
|
+
if (!this._tilesAllowed()) {
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
const overlays = this.options.overlays ?? [];
|
|
426
|
+
overlays.forEach((entry, i) => {
|
|
427
|
+
let layer: Layer | null = null;
|
|
428
|
+
if (entry.georeference) {
|
|
429
|
+
layer = this._createGeoreferencedOverlay(entry);
|
|
430
|
+
} else if (entry.map_type) {
|
|
431
|
+
layer = this._createTileLayer(entry.map_type);
|
|
432
|
+
} else {
|
|
433
|
+
console.warn(
|
|
434
|
+
"StoryMapJS: an overlays[] entry has neither map_type nor georeference and was skipped:",
|
|
435
|
+
entry,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
if (!layer) {
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
// reserved z-range 1..n for stacked overlays (base tiles 0,
|
|
442
|
+
// route lines 10/11; see _addTileLayer)
|
|
443
|
+
layer.setZIndex(1 + i);
|
|
444
|
+
if (entry.opacity !== undefined) {
|
|
445
|
+
layer.setOpacity(entry.opacity);
|
|
446
|
+
}
|
|
447
|
+
if (entry.visible !== undefined) {
|
|
448
|
+
layer.setVisible(entry.visible);
|
|
449
|
+
}
|
|
450
|
+
if (entry.className) {
|
|
451
|
+
// OpenLayers exposes no className setter; overriding the
|
|
452
|
+
// per-frame getClassName hook paints this layer into its own
|
|
453
|
+
// container div, which blend modes can then target
|
|
454
|
+
const className = entry.className;
|
|
455
|
+
layer.getClassName = () => className;
|
|
456
|
+
}
|
|
457
|
+
if (entry.extent) {
|
|
458
|
+
const extent = this._lonLatBboxToExtent(entry.extent);
|
|
459
|
+
if (extent) {
|
|
460
|
+
layer.setExtent(extent);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
this._map.addLayer(layer);
|
|
464
|
+
this._overlay_layers.push(layer);
|
|
465
|
+
this._overlay_entries.push(entry);
|
|
466
|
+
this._paintOverlayBlend(entry);
|
|
467
|
+
});
|
|
468
|
+
this._syncOverlayAttributions();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Build a stacked overlay from a IIIF Georeference Extension payload
|
|
473
|
+
* (`overlays[].georeference`): the ground control points are fitted
|
|
474
|
+
* affinely and the image is placed as a geographic extent, which
|
|
475
|
+
* OpenLayers reprojects onto the view exactly like the IIIF basemap.
|
|
476
|
+
*
|
|
477
|
+
* The layer is returned empty and filled in asynchronously, mirroring
|
|
478
|
+
* the `iiif` basemap path — only the fit (pure, synchronous) decides
|
|
479
|
+
* whether a layer exists at all, so overlay indices stay stable.
|
|
480
|
+
*/
|
|
481
|
+
_createGeoreferencedOverlay(entry: StorymapOverlayLayer): TileLayer | null {
|
|
482
|
+
const georeference = entry.georeference;
|
|
483
|
+
if (!georeference) {
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
// image-space views (iiif/zoomify) have no geographic space to
|
|
487
|
+
// place the sheet in
|
|
488
|
+
if (this._map.getView().getProjection().getCode() !== "EPSG:3857") {
|
|
489
|
+
console.warn(
|
|
490
|
+
"StoryMapJS: a georeferenced overlay was skipped because this map has no geographic view (image maps cannot place sheets).",
|
|
491
|
+
);
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
const fit = fitGeoreference(georeference.body, georeference.width, georeference.height);
|
|
495
|
+
if (fit.kind === "skipped") {
|
|
496
|
+
console.warn(
|
|
497
|
+
`StoryMapJS: georeferenced overlay ${georeference.url} was skipped: ${fit.reason}.`,
|
|
498
|
+
);
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
const layer = new TileLayer();
|
|
502
|
+
const info_url = resolveInfoJsonUrl(georeference.url);
|
|
503
|
+
fetch(info_url)
|
|
504
|
+
.then((r) => r.json())
|
|
505
|
+
.then((info: unknown) => {
|
|
506
|
+
const parsed = new IIIFInfo(
|
|
507
|
+
info as ImageInformationResponse,
|
|
508
|
+
).getTileSourceOptions();
|
|
509
|
+
const size = info as { width?: number; height?: number };
|
|
510
|
+
if (typeof size.width !== "number" || typeof size.height !== "number") {
|
|
511
|
+
console.error("IIIF info.json is missing width/height:", info_url);
|
|
512
|
+
layer.setVisible(false);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
const geo_source = new IIIF({
|
|
516
|
+
...(parsed ?? {}),
|
|
517
|
+
projection: "EPSG:4326",
|
|
518
|
+
// the fitted corner box: the affine placement of the
|
|
519
|
+
// image in geographic coordinates
|
|
520
|
+
extent: fit.bbox,
|
|
521
|
+
size: [size.width, size.height],
|
|
522
|
+
crossOrigin: "anonymous",
|
|
523
|
+
attributions: entry.attribution ? [entry.attribution] : [],
|
|
524
|
+
});
|
|
525
|
+
layer.setSource(geo_source);
|
|
526
|
+
this._fireImageready(geo_source, "iiif", layer);
|
|
527
|
+
})
|
|
528
|
+
.catch((err) => {
|
|
529
|
+
// the layer stays in place (indices must not shift) but
|
|
530
|
+
// renders nothing; the console carries the reason
|
|
531
|
+
console.error("IIIF info.json could not be loaded:", info_url, err?.stack || err);
|
|
532
|
+
layer.setVisible(false);
|
|
533
|
+
});
|
|
534
|
+
return layer;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Convert a lon/lat clip box to view units. Only meaningful on
|
|
539
|
+
* mercator maps; image-space maps (iiif/zoomify) and malformed
|
|
540
|
+
* boxes yield null (no constraint).
|
|
541
|
+
*/
|
|
542
|
+
_lonLatBboxToExtent(bbox: [number, number, number, number]): Extent | null {
|
|
543
|
+
if (
|
|
544
|
+
!Array.isArray(bbox) ||
|
|
545
|
+
bbox.length !== 4 ||
|
|
546
|
+
bbox.some((n) => typeof n !== "number" || !isFinite(n))
|
|
547
|
+
) {
|
|
548
|
+
return null;
|
|
549
|
+
}
|
|
550
|
+
if (this._map.getView().getProjection().getCode() !== "EPSG:3857") {
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
return boundingExtent([fromLonLat([bbox[0], bbox[1]]), fromLonLat([bbox[2], bbox[3]])]);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Apply an overlay's blend mode to its layer container. The container
|
|
558
|
+
* div only exists once the layer has rendered, so retry on later
|
|
559
|
+
* frames (bounded: rendering settles within a frame or two of any
|
|
560
|
+
* add/visibility change).
|
|
561
|
+
*/
|
|
562
|
+
_paintOverlayBlend(entry: { className?: string; blendMode?: string }, retries = 60): void {
|
|
563
|
+
if (!entry.blendMode || !entry.className) {
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
// OpenLayers replaces the container class wholesale (it is not
|
|
567
|
+
// merged with the default), so match every safe token instead of
|
|
568
|
+
// assuming a lone class name
|
|
569
|
+
const tokens = entry.className.split(/\s+/).filter((token) => /^[\w-]+$/.test(token));
|
|
570
|
+
if (tokens.length === 0) {
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
const el = this._el.map.querySelector("." + tokens.join("."));
|
|
574
|
+
if (el) {
|
|
575
|
+
(el as HTMLElement).style.mixBlendMode = entry.blendMode;
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
// the container div only exists once the layer has rendered
|
|
579
|
+
if (retries > 0) {
|
|
580
|
+
requestAnimationFrame(() => this._paintOverlayBlend(entry, retries - 1));
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** Refresh the attribution line with the visible overlays' credits. */
|
|
585
|
+
_syncOverlayAttributions(): void {
|
|
586
|
+
const parts: string[] = [];
|
|
587
|
+
this._overlay_layers.forEach((layer, i) => {
|
|
588
|
+
const credit = this._overlay_entries[i]?.attribution;
|
|
589
|
+
if (credit && layer.getVisible()) {
|
|
590
|
+
parts.push(credit);
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
this.setExtraAttributions(parts);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Number of stacked overlay layers (see the `overlays` option). Alias:
|
|
598
|
+
* `getOverlayLayers().length`.
|
|
599
|
+
*/
|
|
600
|
+
getOverlayCount(): number {
|
|
601
|
+
return this._overlay_layers.length;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Show or hide a stacked overlay by index (re-syncs attribution). The
|
|
606
|
+
* index counts built layers, i.e. it skips malformed entries.
|
|
607
|
+
*
|
|
608
|
+
* Prefer `getOverlayLayer(index).setVisible(v)` on the layer itself; this
|
|
609
|
+
* wrapper exists because it also re-syncs the attribution line and the
|
|
610
|
+
* overlay blend mode, which a bare `setVisible()` does not.
|
|
611
|
+
*/
|
|
612
|
+
setOverlayVisible(index: number, visible: boolean): void {
|
|
613
|
+
const layer = this._overlay_layers[index];
|
|
614
|
+
if (!layer) {
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
layer.setVisible(visible);
|
|
618
|
+
const entry = this._overlay_entries[index];
|
|
619
|
+
if (entry && visible) {
|
|
620
|
+
this._paintOverlayBlend(entry);
|
|
621
|
+
}
|
|
622
|
+
this._syncOverlayAttributions();
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Set a stacked overlay's opacity by index. */
|
|
626
|
+
/**
|
|
627
|
+
* Alias: `getOverlayLayer(index)?.setOpacity(opacity)`.
|
|
628
|
+
*/
|
|
629
|
+
setOverlayOpacity(index: number, opacity: number): void {
|
|
630
|
+
this._overlay_layers[index]?.setOpacity(opacity);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Map tiles were allowed: attach the main tile layer, create the
|
|
635
|
+
* minimap's deferred layer if it was withheld, then re-fit.
|
|
636
|
+
*/
|
|
637
|
+
_onTilesAllowed(): void {
|
|
638
|
+
this._addTileLayer();
|
|
639
|
+
this._buildOverlays();
|
|
640
|
+
if (!this._tile_layer_mini) {
|
|
641
|
+
this._tile_layer_mini = this._createTileLayer(this.options.map_type);
|
|
642
|
+
}
|
|
643
|
+
if (this._mini_map && this._tile_layer_mini) {
|
|
644
|
+
const overview = this._mini_map.getOverviewMap();
|
|
645
|
+
if (!overview.getLayers().getLength()) {
|
|
646
|
+
overview.addLayer(this._tile_layer_mini);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
if (this._markers.length > 0 && this.current_marker < this._markers.length) {
|
|
650
|
+
const marker = this._markers[this.current_marker];
|
|
651
|
+
if (marker.data.type === "overview") {
|
|
652
|
+
this._markerOverview();
|
|
653
|
+
} else {
|
|
654
|
+
const latlon = marker.latLon();
|
|
655
|
+
if (latlon) {
|
|
656
|
+
this._fitView(this._map, [[latlon.lon, latlon.lat]], 0);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Legacy zoomify image pyramid: the levels (image size per level), the
|
|
664
|
+
* max zoom and the mercator bounds the image occupies (stretched from
|
|
665
|
+
* the world's top-left corner, the original renderer's mapping).
|
|
666
|
+
*/
|
|
667
|
+
_zoomifyPyramid(): {
|
|
668
|
+
sizes: Array<[number, number]>;
|
|
669
|
+
maxZoom: number;
|
|
670
|
+
extent: number[];
|
|
671
|
+
tileGrid: TileGrid;
|
|
672
|
+
} | null {
|
|
673
|
+
const zoomify = this.options.zoomify;
|
|
674
|
+
if (!zoomify || typeof zoomify !== "object" || !zoomify.path) return null;
|
|
675
|
+
const width = zoomify.width ?? 600;
|
|
676
|
+
const height = zoomify.height ?? 600;
|
|
677
|
+
|
|
678
|
+
// pyramid levels: halve the image size until ≤ 256
|
|
679
|
+
const sizes: Array<[number, number]> = [];
|
|
680
|
+
let w = width;
|
|
681
|
+
let h = height;
|
|
682
|
+
while (w > 256 || h > 256) {
|
|
683
|
+
sizes.push([w, h]);
|
|
684
|
+
w = Math.floor(w / 2);
|
|
685
|
+
h = Math.floor(h / 2);
|
|
686
|
+
}
|
|
687
|
+
sizes.push([w, h]);
|
|
688
|
+
sizes.reverse(); // [0] = smallest level
|
|
689
|
+
|
|
690
|
+
const maxZoom = sizes.length - 1;
|
|
691
|
+
const worldPx = 256 * 2 ** maxZoom;
|
|
692
|
+
const extent = [
|
|
693
|
+
-20037508.342789244,
|
|
694
|
+
20037508.342789244 - (height / worldPx) * 40075016.68557849,
|
|
695
|
+
-20037508.342789244 + (width / worldPx) * 40075016.68557849,
|
|
696
|
+
20037508.342789244,
|
|
697
|
+
];
|
|
698
|
+
// tile grid over the image's mercator bounds: the tiles keep their
|
|
699
|
+
// natural size (the legacy renderer clamped edge tiles the same way).
|
|
700
|
+
// One extra level below zoom 0 lets the overview show the complete
|
|
701
|
+
// image (the smallest pyramid tile downscaled)
|
|
702
|
+
const tileGrid = new TileGrid({
|
|
703
|
+
extent,
|
|
704
|
+
origin: [extent[0], extent[3]],
|
|
705
|
+
resolutions: Array.from(
|
|
706
|
+
{ length: maxZoom + 2 },
|
|
707
|
+
(_, i) => 40075016.68557849 / (256 * 2 ** (i - 1)),
|
|
708
|
+
),
|
|
709
|
+
tileSize: 256,
|
|
710
|
+
});
|
|
711
|
+
return { sizes, maxZoom, extent, tileGrid };
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* The zoomify overview state: the best-fit pyramid level for a map
|
|
716
|
+
* window (the level whose image, times the tolerance, fits the window —
|
|
717
|
+
* the legacy `_getBestFitZoom`) and the image's mercator center.
|
|
718
|
+
*/
|
|
719
|
+
_zoomifyOverview(mapSize: [number, number]): { zoom: number; center: number[] } | null {
|
|
720
|
+
const pyramid = this._zoomifyPyramid();
|
|
721
|
+
if (!pyramid) return null;
|
|
722
|
+
const tolerance = (this.options.zoomify as { tolerance?: number })?.tolerance ?? 0.9;
|
|
723
|
+
let zoom = pyramid.maxZoom;
|
|
724
|
+
while (zoom > 0) {
|
|
725
|
+
const size = pyramid.sizes[zoom];
|
|
726
|
+
if (
|
|
727
|
+
size[0] * tolerance < (mapSize[0] || 1) &&
|
|
728
|
+
size[1] * tolerance < (mapSize[1] || 1)
|
|
729
|
+
) {
|
|
730
|
+
break;
|
|
731
|
+
}
|
|
732
|
+
zoom--;
|
|
733
|
+
}
|
|
734
|
+
const center = [
|
|
735
|
+
(pyramid.extent[0] + pyramid.extent[2]) / 2,
|
|
736
|
+
(pyramid.extent[1] + pyramid.extent[3]) / 2,
|
|
737
|
+
];
|
|
738
|
+
return { zoom, center };
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
_createTileLayer(map_type: string): Layer {
|
|
742
|
+
// issue #473: custom OpenLayers tile layer/source factory first —
|
|
743
|
+
// the base layer, overlays, minimap and runtime map_type switches
|
|
744
|
+
// all funnel through here, so one check covers them
|
|
745
|
+
const factory = this.options.tile_source_factory;
|
|
746
|
+
if (typeof factory === "function") {
|
|
747
|
+
const custom = factory(map_type, {
|
|
748
|
+
options: this.options,
|
|
749
|
+
createDefault: () => this._createDefaultTileLayer(map_type),
|
|
750
|
+
});
|
|
751
|
+
if (custom) {
|
|
752
|
+
// any ol/layer/Layer passes through as-is — including
|
|
753
|
+
// subclasses without a getSource (e.g. an Allmaps
|
|
754
|
+
// WarpedMapLayer), which must not be wrapped; a bare Source is
|
|
755
|
+
// wrapped in a TileLayer
|
|
756
|
+
if (
|
|
757
|
+
custom instanceof Layer ||
|
|
758
|
+
typeof (custom as { getSource?: unknown }).getSource === "function"
|
|
759
|
+
) {
|
|
760
|
+
return custom as Layer;
|
|
761
|
+
}
|
|
762
|
+
return new TileLayer({ source: custom as unknown as XYZ });
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
return this._createDefaultTileLayer(map_type);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* A layer's source, or null when it has none. Custom `Layer` subclasses
|
|
770
|
+
* passed through `_createTileLayer` (e.g. Allmaps' `WarpedMapLayer`)
|
|
771
|
+
* deliberately have no `getSource`, so every read goes through here
|
|
772
|
+
* instead of calling the method directly.
|
|
773
|
+
*/
|
|
774
|
+
_sourceOf(layer: Layer | null | undefined): Source | null {
|
|
775
|
+
if (!layer || typeof (layer as { getSource?: unknown }).getSource !== "function") {
|
|
776
|
+
return null;
|
|
777
|
+
}
|
|
778
|
+
return (layer as TileLayer).getSource();
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Move the map into another element, keeping OpenLayers in step.
|
|
783
|
+
*
|
|
784
|
+
* `DomMixed.addTo()` (the original semantics, unchanged: append the map
|
|
785
|
+
* container, fire `added`) only moves DOM — the `ol/Map` still has the
|
|
786
|
+
* viewport it measured at construction, so it would keep painting at the
|
|
787
|
+
* old size. Re-attach the target, which `removeFrom()` detached, and
|
|
788
|
+
* re-measure. The argument must be the new parent.
|
|
789
|
+
*/
|
|
790
|
+
addTo(container: HTMLElement): this {
|
|
791
|
+
super.addTo(container);
|
|
792
|
+
this._map.setTarget(this._el.map);
|
|
793
|
+
this._map.updateSize();
|
|
794
|
+
return this;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Move the map out of `container`. Untargets the OpenLayers map so it
|
|
799
|
+
* does not render into a detached node; a later {@link addTo} re-attaches
|
|
800
|
+
* it. The argument must be the current parent.
|
|
801
|
+
*/
|
|
802
|
+
removeFrom(container: HTMLElement): this {
|
|
803
|
+
super.removeFrom(container);
|
|
804
|
+
this._map.setTarget(undefined);
|
|
805
|
+
return this;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* True when the map is a plain image in an `EPSG:4326` "image space"
|
|
810
|
+
* rather than a geographic map: a IIIF image presented as a picture of
|
|
811
|
+
* the world (`map_as_image`), not georeferenced.
|
|
812
|
+
*
|
|
813
|
+
* In this mode the view projection is `EPSG:4326` with degrees as
|
|
814
|
+
* resolution and the view is fitted to the image extent, so
|
|
815
|
+
* `getCenter()`/`getZoom()` are in degrees/pixels-per-degree — not
|
|
816
|
+
* mercator metres. Marker locations are pixel offsets into the image,
|
|
817
|
+
* not lat/lon. Georeferenced IIIF (`map_bbox` with `map_as_image`
|
|
818
|
+
* omitted) is a normal mercator map; use `isImageSpace()` rather than
|
|
819
|
+
* testing `map_type` to tell the two apart.
|
|
820
|
+
*/
|
|
821
|
+
isImageSpace(): boolean {
|
|
822
|
+
return this.options.map_type === "iiif" && this.options.map_as_image === true;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Stop the line animation, drop the minimap control and dispose the
|
|
827
|
+
* OpenLayers map. Called by `StoryMap.dispose()`; the engine must not be
|
|
828
|
+
* used afterwards.
|
|
829
|
+
*/
|
|
830
|
+
dispose(): void {
|
|
831
|
+
if (this._line_animation !== null) {
|
|
832
|
+
cancelAnimationFrame(this._line_animation);
|
|
833
|
+
this._line_animation = null;
|
|
834
|
+
}
|
|
835
|
+
// markers own their click listener and an overlay; disposing the
|
|
836
|
+
// collection releases both
|
|
837
|
+
for (const marker of this._markers) {
|
|
838
|
+
marker.dispose?.();
|
|
839
|
+
}
|
|
840
|
+
this._markers = [];
|
|
841
|
+
// detach the viewport first: dispose() alone leaves the canvas in
|
|
842
|
+
// the caller's DOM
|
|
843
|
+
this._map.setTarget(undefined);
|
|
844
|
+
this._map.dispose();
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Fire `imageready` once `source` is usable (and immediately when it
|
|
849
|
+
* already is). Image sources — IIIF, zoomify, the deferred tile layer —
|
|
850
|
+
* attach asynchronously, so this is the only outward signal that the
|
|
851
|
+
* imagery is really on the map; the `loaded` event can fire before the
|
|
852
|
+
* source is attached at all.
|
|
853
|
+
*/
|
|
854
|
+
_fireImageready(source: { getState?(): string }, kind: ImagereadyKind, layer?: Layer): void {
|
|
855
|
+
// one event per source: the base layer and the minimap can be asked
|
|
856
|
+
// about the same source, and a host toggling layers should not have
|
|
857
|
+
// to filter duplicates
|
|
858
|
+
if (this._imageready_fired.has(source)) return;
|
|
859
|
+
this._imageready_fired.add(source);
|
|
860
|
+
const payload = { source, kind, layer: layer ?? null };
|
|
861
|
+
if (typeof source.getState !== "function" || source.getState() === "ready") {
|
|
862
|
+
this.fire("imageready", payload);
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
const onChange = () => {
|
|
866
|
+
if (source.getState?.() === "ready") {
|
|
867
|
+
(source as { un?: (t: string, f: () => void) => void }).un?.("change", onChange);
|
|
868
|
+
this.fire("imageready", payload);
|
|
869
|
+
}
|
|
870
|
+
};
|
|
871
|
+
(source as { once?: (t: string, f: () => void) => void }).once?.("change", onChange);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/** Computed zooms take precedence over the authored slide zoom. */
|
|
875
|
+
_markerZoom(index: number): number | undefined {
|
|
876
|
+
return this._marker_zooms[index] ?? super._markerZoom(index);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* The TileJSON metadata, but only when it is describing `url`.
|
|
881
|
+
*
|
|
882
|
+
* `tilejson` describes the map's tile source, so its `tiles` template is
|
|
883
|
+
* the one the map_type resolved to. When it names a different template it
|
|
884
|
+
* is not about this source, and neither the zoom ladder nor the initial
|
|
885
|
+
* view should be taken from it.
|
|
886
|
+
*/
|
|
887
|
+
_tilejsonFor(url: string): StorymapTilejson | undefined {
|
|
888
|
+
const tilejson = this.options.tilejson;
|
|
889
|
+
if (tilejson === undefined) return undefined;
|
|
890
|
+
const tiles = Array.isArray(tilejson.tiles) ? tilejson.tiles[0] : tilejson.tiles;
|
|
891
|
+
return tiles === url ? tilejson : undefined;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* An XYZ source for a URL template, honouring the TileJSON metadata of the
|
|
896
|
+
* map's own tile source if it is describing this template (§2.9).
|
|
897
|
+
*
|
|
898
|
+
* `minzoom`/`maxzoom` become the tile grid's zoom range, so OpenLayers asks
|
|
899
|
+
* for levels the service actually has and stops past the ones it does not.
|
|
900
|
+
* `scheme: "tms"` is the TMS row order, which is the same URL with the tile
|
|
901
|
+
* row counted from the bottom.
|
|
902
|
+
*/
|
|
903
|
+
_xyzSource(url: string): XYZ {
|
|
904
|
+
const tilejson = this._tilejsonFor(url);
|
|
905
|
+
const options: Record<string, unknown> = {
|
|
906
|
+
url,
|
|
907
|
+
attributions: this._sourceAttributions(url),
|
|
908
|
+
crossOrigin: "anonymous",
|
|
909
|
+
};
|
|
910
|
+
if (tilejson !== undefined) {
|
|
911
|
+
if (tilejson.minzoom !== undefined || tilejson.maxzoom !== undefined) {
|
|
912
|
+
options.minZoom = tilejson.minzoom ?? 0;
|
|
913
|
+
options.maxZoom = tilejson.maxzoom ?? MAX_ZOOM;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
const source = new XYZ(options as never);
|
|
917
|
+
if (tilejson?.scheme === "tms") {
|
|
918
|
+
// TMS numbers tile rows from the bottom of the pyramid; XYZ counts
|
|
919
|
+
// them from the top, so the row is flipped against the source's
|
|
920
|
+
// deepest zoom.
|
|
921
|
+
//
|
|
922
|
+
// This is assigned rather than passed as the `tileUrlFunction`
|
|
923
|
+
// constructor option, which this version of ol/source/XYZ does not
|
|
924
|
+
// read.
|
|
925
|
+
const deepest = tilejson.maxzoom ?? MAX_ZOOM;
|
|
926
|
+
source.tileUrlFunction = (tileCoord: number[] | undefined): string | undefined => {
|
|
927
|
+
if (tileCoord === undefined) return undefined;
|
|
928
|
+
// OpenLayers hands over [z, x, y]
|
|
929
|
+
const [z, x, y] = tileCoord;
|
|
930
|
+
return url
|
|
931
|
+
.replace("{z}", String(z))
|
|
932
|
+
.replace("{x}", String(x))
|
|
933
|
+
.replace("{y}", String(2 ** deepest - 1 - y))
|
|
934
|
+
.replace("{ratio}", "");
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
return source;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
_createDefaultTileLayer(map_type: string): TileLayer {
|
|
941
|
+
const _map_type_arr = map_type.split(":");
|
|
942
|
+
|
|
943
|
+
switch (_map_type_arr[0]) {
|
|
944
|
+
case "mapbox": {
|
|
945
|
+
if (_map_type_arr.length > 2) {
|
|
946
|
+
// mapbox://styles/<user>/<style> URLs render via the
|
|
947
|
+
// Mapbox styles tiles API (requires map_access_token)
|
|
948
|
+
const this_mapbox_map = _map_type_arr[2].slice("//styles/".length);
|
|
949
|
+
const mapbox_url =
|
|
950
|
+
"https://api.mapbox.com/styles/v1/" +
|
|
951
|
+
this_mapbox_map +
|
|
952
|
+
"/tiles/256/{z}/{x}/{y}@2x?access_token=" +
|
|
953
|
+
this.options.map_access_token;
|
|
954
|
+
return new TileLayer({
|
|
955
|
+
source: new XYZ({
|
|
956
|
+
url: mapbox_url,
|
|
957
|
+
attributions: this._sourceAttributions(map_type),
|
|
958
|
+
crossOrigin: "anonymous",
|
|
959
|
+
}),
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
console.error(
|
|
963
|
+
"StoryMapJS: legacy 'mapbox:<style>' map types are no longer supported (the Mapbox v4 tile API was retired); use 'mapbox://styles/<user>/<style>' with map_access_token instead.",
|
|
964
|
+
);
|
|
965
|
+
return new TileLayer({
|
|
966
|
+
source: new OSM({ attributions: this._sourceAttributions(map_type) }),
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
case "stadia": {
|
|
971
|
+
// Stadia hosts a style *set*, not a single style. A bare
|
|
972
|
+
// `map_type: "stadia"` used to fall back to the literal
|
|
973
|
+
// "osm:standard", which is not one of them — every tile 404'd.
|
|
974
|
+
// `alidade_smooth` is the correct default here (matches the
|
|
975
|
+
// remapped `stamen` case below).
|
|
976
|
+
let style_url = "alidade_smooth";
|
|
977
|
+
if (_map_type_arr.length > 1) {
|
|
978
|
+
style_url = _map_type_arr.slice(1).join(":");
|
|
979
|
+
if (this.options.map_access_token) {
|
|
980
|
+
style_url = `${style_url}?api_key=${this.options.map_access_token}`;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
return new TileLayer({
|
|
984
|
+
source: new XYZ({
|
|
985
|
+
url: `https://tiles.stadiamaps.com/tiles/${style_url}/{z}/{x}/{y}{r}.png`,
|
|
986
|
+
attributions: this._sourceAttributions(map_type),
|
|
987
|
+
}),
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
case "stamen":
|
|
992
|
+
this._map.getViewport().style.backgroundColor = "#FFFFFF";
|
|
993
|
+
return new TileLayer({
|
|
994
|
+
source: new XYZ({
|
|
995
|
+
url: "https://tiles.stadiamaps.com/tiles/stamen_toner_lite/{z}/{x}/{y}.png",
|
|
996
|
+
attributions: this._sourceAttributions(map_type),
|
|
997
|
+
}),
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
case "iiif": {
|
|
1001
|
+
const iiif_layer: TileLayer = new TileLayer();
|
|
1002
|
+
fetch(this.options.iiif.url)
|
|
1003
|
+
.then((r) => r.json())
|
|
1004
|
+
.then((info: unknown) => {
|
|
1005
|
+
// parse the service description into proper tile
|
|
1006
|
+
// source options (base URL, version, tiling) so tile
|
|
1007
|
+
// URLs are valid and the grid matches the service
|
|
1008
|
+
const parsed = new IIIFInfo(
|
|
1009
|
+
info as ImageInformationResponse,
|
|
1010
|
+
).getTileSourceOptions();
|
|
1011
|
+
const fallback = info as { width?: number; height?: number };
|
|
1012
|
+
if (
|
|
1013
|
+
typeof fallback.width !== "number" ||
|
|
1014
|
+
typeof fallback.height !== "number"
|
|
1015
|
+
) {
|
|
1016
|
+
console.error(
|
|
1017
|
+
"IIIF info.json is missing width/height:",
|
|
1018
|
+
this.options.iiif.url,
|
|
1019
|
+
);
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
const source = new IIIF({
|
|
1023
|
+
...(parsed ?? {}),
|
|
1024
|
+
projection: "EPSG:4326",
|
|
1025
|
+
size: [fallback.width, fallback.height],
|
|
1026
|
+
crossOrigin: "anonymous",
|
|
1027
|
+
attributions: this.options.iiif.attribution || [],
|
|
1028
|
+
});
|
|
1029
|
+
iiif_layer.setSource(source);
|
|
1030
|
+
this._fireImageready(source, "iiif", iiif_layer);
|
|
1031
|
+
if (source.getState() === "ready") {
|
|
1032
|
+
this._markerOverview();
|
|
1033
|
+
} else {
|
|
1034
|
+
source.once("change", () => {
|
|
1035
|
+
if (source.getState() === "ready") {
|
|
1036
|
+
this._markerOverview();
|
|
1037
|
+
}
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
})
|
|
1041
|
+
.catch((err) =>
|
|
1042
|
+
console.error(
|
|
1043
|
+
"IIIF info.json could not be loaded:",
|
|
1044
|
+
this.options.iiif.url,
|
|
1045
|
+
err?.stack || err,
|
|
1046
|
+
),
|
|
1047
|
+
);
|
|
1048
|
+
return iiif_layer;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
case "http":
|
|
1052
|
+
case "https":
|
|
1053
|
+
// style JSON URLs (e.g. https://tiles.openfreemap.org/styles/bright)
|
|
1054
|
+
// render a vector style, tile template URLs stay raster
|
|
1055
|
+
if (!map_type.includes("{z}")) {
|
|
1056
|
+
return this._createVectorStyleLayer(map_type);
|
|
1057
|
+
}
|
|
1058
|
+
return new TileLayer({ source: this._xyzSource(map_type) });
|
|
1059
|
+
|
|
1060
|
+
case "ch-watercolor":
|
|
1061
|
+
return new TileLayer({
|
|
1062
|
+
source: new XYZ({
|
|
1063
|
+
url: "https://watercolormaps.collection.cooperhewitt.org/tile/watercolor/{z}/{x}/{y}.jpg",
|
|
1064
|
+
attributions: this._sourceAttributions(map_type),
|
|
1065
|
+
maxZoom: 16,
|
|
1066
|
+
}),
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
case "zoomify": {
|
|
1070
|
+
// Legacy zoomify support: the image pyramid tiles are placed
|
|
1071
|
+
// over the image's mercator bounds (the original renderer's
|
|
1072
|
+
// mapping). Locations use native lat/lon. The tile grid's
|
|
1073
|
+
// ladder is shifted one level down so the overview can show
|
|
1074
|
+
// the complete image.
|
|
1075
|
+
const pyramid = this._zoomifyPyramid();
|
|
1076
|
+
if (!pyramid) {
|
|
1077
|
+
console.error(
|
|
1078
|
+
"StoryMapJS: map_type 'zoomify' needs a zoomify image pyramid (path, width, height) in the storymap data.",
|
|
1079
|
+
);
|
|
1080
|
+
return new TileLayer({
|
|
1081
|
+
source: new OSM({ attributions: this._sourceAttributions(map_type) }),
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
const path = (this.options.zoomify as { path?: string }).path ?? "";
|
|
1085
|
+
const { sizes, maxZoom: pyramidMaxZoom } = pyramid;
|
|
1086
|
+
const gridX = (z: number) => Math.ceil(sizes[z][0] / 256);
|
|
1087
|
+
const gridY = (z: number) => Math.ceil(sizes[z][1] / 256);
|
|
1088
|
+
|
|
1089
|
+
const zoomify_source = new XYZ({
|
|
1090
|
+
tileGrid: pyramid.tileGrid,
|
|
1091
|
+
crossOrigin: "anonymous",
|
|
1092
|
+
attributions: this._sourceAttributions(map_type),
|
|
1093
|
+
tileUrlFunction: (tile: number[]) => {
|
|
1094
|
+
const [tileZ, x, y] = tile;
|
|
1095
|
+
// the ladder is shifted one level down: mercator
|
|
1096
|
+
// zoom z serves the pyramid level max(0, z - 1)
|
|
1097
|
+
const z = Math.max(0, tileZ - 1);
|
|
1098
|
+
if (z > pyramidMaxZoom) return undefined;
|
|
1099
|
+
if (x < 0 || x >= gridX(z) || y < 0 || y >= gridY(z)) {
|
|
1100
|
+
return undefined;
|
|
1101
|
+
}
|
|
1102
|
+
// TileGroup index: the running tile number ÷ 256
|
|
1103
|
+
let num = 0;
|
|
1104
|
+
for (let zz = 0; zz < z; zz++) {
|
|
1105
|
+
num += gridX(zz) * gridY(zz);
|
|
1106
|
+
}
|
|
1107
|
+
num += y * gridX(z) + x;
|
|
1108
|
+
return `${path}TileGroup${Math.floor(num / 256)}/${z}-${x}-${y}.jpg`;
|
|
1109
|
+
},
|
|
1110
|
+
// Zoomify edge tiles are cropped to the image bounds
|
|
1111
|
+
// (e.g. a 256x19 bottom strip); OpenLayers draws the
|
|
1112
|
+
// loaded image over the whole 256x256 tile box, which
|
|
1113
|
+
// stretched those strips across the cell (the smeared
|
|
1114
|
+
// bottom in the Bosch overview, stretched right/bottom
|
|
1115
|
+
// edges in the Literary Trail). Pad them onto a full
|
|
1116
|
+
// tile canvas instead.
|
|
1117
|
+
tileLoadFunction: (tile, src) => {
|
|
1118
|
+
const imageTile = tile as ImageTile;
|
|
1119
|
+
const image = imageTile.getImage() as HTMLImageElement;
|
|
1120
|
+
image.onload = () => padCroppedZoomifyTile(imageTile, image);
|
|
1121
|
+
image.src = src;
|
|
1122
|
+
},
|
|
1123
|
+
});
|
|
1124
|
+
const zoomify_layer = new TileLayer({ source: zoomify_source });
|
|
1125
|
+
// the zoomify base never announced itself, so a host waiting
|
|
1126
|
+
// on imageready hung on exactly the legacy stories that need
|
|
1127
|
+
// it most; the per-source dedup keeps this to one event
|
|
1128
|
+
this._fireImageready(zoomify_source, "zoomify", zoomify_layer);
|
|
1129
|
+
return zoomify_layer;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
case "osm": {
|
|
1133
|
+
// "osm:<style>" uses an OpenFreeMap vector style (osm:bright ->
|
|
1134
|
+
// https://tiles.openfreemap.org/styles/bright), plain "osm" stays
|
|
1135
|
+
// the classic raster tiles
|
|
1136
|
+
const style_name = _map_type_arr.length > 1 ? _map_type_arr[1] : "";
|
|
1137
|
+
if (style_name) {
|
|
1138
|
+
return this._createVectorStyleLayer(
|
|
1139
|
+
`https://tiles.openfreemap.org/styles/${style_name}`,
|
|
1140
|
+
);
|
|
1141
|
+
}
|
|
1142
|
+
return new TileLayer({
|
|
1143
|
+
source: new OSM({ attributions: this._sourceAttributions(map_type) }),
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
default: {
|
|
1147
|
+
// Relative/custom templates (./tiles/{z}/{x}/{y}.png,
|
|
1148
|
+
// /tiles/{z}/..., tiles/{z}/...): render as raster XYZ so
|
|
1149
|
+
// consumers on subpaths (GitLab Pages), bundlers (Vite) and
|
|
1150
|
+
// the Electron kiosk server don't have to expand to an
|
|
1151
|
+
// absolute http(s):// URL first. Style JSON paths without
|
|
1152
|
+
// {z} render as vector styles, anything else falls back to
|
|
1153
|
+
// OSM.
|
|
1154
|
+
if (map_type.includes("{z}")) {
|
|
1155
|
+
return new TileLayer({ source: this._xyzSource(map_type) });
|
|
1156
|
+
}
|
|
1157
|
+
if (map_type.includes("/") || map_type.endsWith(".json")) {
|
|
1158
|
+
return this._createVectorStyleLayer(map_type);
|
|
1159
|
+
}
|
|
1160
|
+
return new TileLayer({
|
|
1161
|
+
source: new OSM({ attributions: this._sourceAttributions(map_type) }),
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
_createVectorStyleLayer(style_url: string): TileLayer {
|
|
1168
|
+
// Mapbox style JSONs (OpenFreeMap, Mapbox) are applied onto a single
|
|
1169
|
+
// vector tile layer, including its background and label decluttering
|
|
1170
|
+
const layer = new VectorTileLayer({ declutter: true, updateWhileAnimating: true });
|
|
1171
|
+
applyStyle(layer, style_url).catch((err: unknown) =>
|
|
1172
|
+
console.error("Vector map style could not be loaded:", style_url, err),
|
|
1173
|
+
);
|
|
1174
|
+
return layer as unknown as TileLayer;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/* Create Mini Map
|
|
1178
|
+
================================================== */
|
|
1179
|
+
_createMiniMap(): void {
|
|
1180
|
+
if (this.options.map_as_image) {
|
|
1181
|
+
this.zoom_min_max.min = 0;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
if (!this.bounds_array) {
|
|
1185
|
+
this.bounds_array = this._getAllMarkersBounds(this._markers);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// consent mode: the minimap layer is only created once map tiles are
|
|
1189
|
+
// allowed — creating an IIIF/vector layer object fetches info.json or
|
|
1190
|
+
// the style JSON immediately, which must not happen while denied
|
|
1191
|
+
this._tile_layer_mini = this._tilesAllowed()
|
|
1192
|
+
? this._createTileLayer(this.options.map_type)
|
|
1193
|
+
: null;
|
|
1194
|
+
const is_image_map = this.isImageSpace();
|
|
1195
|
+
// Legacy zoomify maps are mercator-based: the minimap fits the image's
|
|
1196
|
+
// mercator bounds with a free zoom so the whole image stays visible
|
|
1197
|
+
// at a downscaled (sharp) pyramid level
|
|
1198
|
+
const is_zoomify = this.options.map_type === "zoomify";
|
|
1199
|
+
const zoomify_pyramid = is_zoomify ? this._zoomifyPyramid() : null;
|
|
1200
|
+
// the overview needs the pyramid's full ladder (including the R0
|
|
1201
|
+
// floor below default zoom 0): fit() settles on a fractional zoom
|
|
1202
|
+
// containing the whole image, which minZoom: 0 on the default ladder
|
|
1203
|
+
// would clip back to a cropped upscale
|
|
1204
|
+
const zoomify_resolutions = zoomify_pyramid
|
|
1205
|
+
? zoomify_pyramid.tileGrid.getResolutions()
|
|
1206
|
+
: null;
|
|
1207
|
+
// Constrain a standard mercator overview to an explicit lon/lat box
|
|
1208
|
+
// (overview_extent); image maps keep their own views below. Without
|
|
1209
|
+
// a constraint the default overview view roams the whole world.
|
|
1210
|
+
const overview_extent =
|
|
1211
|
+
!is_image_map && !zoomify_pyramid && this.options.overview_extent
|
|
1212
|
+
? this._lonLatBboxToExtent(this.options.overview_extent)
|
|
1213
|
+
: null;
|
|
1214
|
+
this._mini_map = new OverviewMap({
|
|
1215
|
+
...(is_image_map || zoomify_pyramid
|
|
1216
|
+
? {
|
|
1217
|
+
view: (() => {
|
|
1218
|
+
const view = new View({
|
|
1219
|
+
projection: this._map.getView().getProjection(),
|
|
1220
|
+
center: this._map.getView().getCenter(),
|
|
1221
|
+
zoom: this.zoom_min_max.min || 0,
|
|
1222
|
+
// same reasoning as the main image view: no world
|
|
1223
|
+
// constraints
|
|
1224
|
+
...(is_image_map
|
|
1225
|
+
? {
|
|
1226
|
+
multiWorld: true,
|
|
1227
|
+
resolutions: IMAGE_RESOLUTIONS,
|
|
1228
|
+
minZoom: 0,
|
|
1229
|
+
maxZoom: IMAGE_RESOLUTIONS.length - 1,
|
|
1230
|
+
}
|
|
1231
|
+
: {}),
|
|
1232
|
+
...(zoomify_pyramid && zoomify_resolutions
|
|
1233
|
+
? {
|
|
1234
|
+
// show the whole image: the zoom stays
|
|
1235
|
+
// free on the pyramid ladder so fit()
|
|
1236
|
+
// settles on the fractional resolution
|
|
1237
|
+
// containing the image bounds (a pinned
|
|
1238
|
+
// zoom stuck the smallest, blurry
|
|
1239
|
+
// pyramid level on screen, cropped);
|
|
1240
|
+
// the center stays within the image
|
|
1241
|
+
// bounds
|
|
1242
|
+
constrainOnlyCenter: true,
|
|
1243
|
+
extent: zoomify_pyramid.extent,
|
|
1244
|
+
resolutions: zoomify_resolutions,
|
|
1245
|
+
minZoom: 0,
|
|
1246
|
+
maxZoom: zoomify_resolutions.length - 1,
|
|
1247
|
+
center: [
|
|
1248
|
+
(zoomify_pyramid.extent[0] +
|
|
1249
|
+
zoomify_pyramid.extent[2]) /
|
|
1250
|
+
2,
|
|
1251
|
+
(zoomify_pyramid.extent[1] +
|
|
1252
|
+
zoomify_pyramid.extent[3]) /
|
|
1253
|
+
2,
|
|
1254
|
+
],
|
|
1255
|
+
}
|
|
1256
|
+
: {}),
|
|
1257
|
+
});
|
|
1258
|
+
return view;
|
|
1259
|
+
})(),
|
|
1260
|
+
}
|
|
1261
|
+
: {}),
|
|
1262
|
+
...(overview_extent
|
|
1263
|
+
? {
|
|
1264
|
+
view: new View({
|
|
1265
|
+
projection: this._map.getView().getProjection(),
|
|
1266
|
+
center: [
|
|
1267
|
+
(overview_extent[0] + overview_extent[2]) / 2,
|
|
1268
|
+
(overview_extent[1] + overview_extent[3]) / 2,
|
|
1269
|
+
],
|
|
1270
|
+
extent: overview_extent,
|
|
1271
|
+
constrainOnlyCenter: false,
|
|
1272
|
+
}),
|
|
1273
|
+
}
|
|
1274
|
+
: {}),
|
|
1275
|
+
layers: this._tile_layer_mini ? [this._tile_layer_mini] : [],
|
|
1276
|
+
// NB: label = the button shown when COLLAPSED (expands the
|
|
1277
|
+
// minimap), collapseLabel = shown when EXPANDED (collapses it) —
|
|
1278
|
+
// the chevrons point outward when collapsed and inward when open
|
|
1279
|
+
collapseLabel: "\u00ab",
|
|
1280
|
+
label: "\u00bb",
|
|
1281
|
+
collapsed: true,
|
|
1282
|
+
});
|
|
1283
|
+
this._map.addControl(this._mini_map);
|
|
1284
|
+
|
|
1285
|
+
if (zoomify_pyramid) {
|
|
1286
|
+
// show the image pyramid's extent in the minimap: with the zoom
|
|
1287
|
+
// free, fit() picks the fractional resolution containing the
|
|
1288
|
+
// whole image, so the overview serves the smallest pyramid level
|
|
1289
|
+
// downscaled (sharp) instead of a cropped upscale
|
|
1290
|
+
const overview_map = this._mini_map.getOverviewMap();
|
|
1291
|
+
const fitZoomifyMini = () => {
|
|
1292
|
+
const raw_size = overview_map.getSize();
|
|
1293
|
+
// the minimap starts collapsed (no layout size yet) in its
|
|
1294
|
+
// 150x100 box — fall back to that until it expands
|
|
1295
|
+
const size =
|
|
1296
|
+
raw_size && raw_size[0] >= 50 && raw_size[1] >= 50 ? raw_size : [150, 100];
|
|
1297
|
+
overview_map.getView().fit(zoomify_pyramid.extent, {
|
|
1298
|
+
size: size,
|
|
1299
|
+
});
|
|
1300
|
+
};
|
|
1301
|
+
fitZoomifyMini();
|
|
1302
|
+
// re-fit once the minimap expands: the collapsed size is unknown
|
|
1303
|
+
// at creation time
|
|
1304
|
+
overview_map.on("change:size", fitZoomifyMini);
|
|
1305
|
+
} else if (!is_zoomify && this.bounds_array && this.bounds_array.length) {
|
|
1306
|
+
// the minimap shows the story's world: with a bbox set, markers
|
|
1307
|
+
// outside of the box are unreachable and must not skew the fit
|
|
1308
|
+
this._fitView(
|
|
1309
|
+
this._mini_map.getOverviewMap(),
|
|
1310
|
+
this.bounds_array,
|
|
1311
|
+
0,
|
|
1312
|
+
this._bboxExtent() !== null,
|
|
1313
|
+
);
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
if (this.isImageSpace()) {
|
|
1317
|
+
// in image mode there are no geo markers to fit, so show the
|
|
1318
|
+
// whole image instead (issues #465, #355)
|
|
1319
|
+
this._fitMiniMapToImage();
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
_fitMiniMapToImage(): void {
|
|
1324
|
+
if (!this._tile_layer_mini) return;
|
|
1325
|
+
const fit_mini_image = () => {
|
|
1326
|
+
try {
|
|
1327
|
+
const mini_source = this._sourceOf(this._tile_layer_mini) as {
|
|
1328
|
+
getTileGrid?: () => { getExtent(): number[] };
|
|
1329
|
+
} | null;
|
|
1330
|
+
const grid = mini_source?.getTileGrid?.();
|
|
1331
|
+
if (grid) {
|
|
1332
|
+
const overview_map = this._mini_map.getOverviewMap();
|
|
1333
|
+
const raw_size = overview_map.getSize();
|
|
1334
|
+
// the minimap may still be collapsed (no layout size yet)
|
|
1335
|
+
const size =
|
|
1336
|
+
raw_size && raw_size[0] >= 50 && raw_size[1] >= 50 ? raw_size : [150, 150];
|
|
1337
|
+
overview_map.getView().fit(grid.getExtent(), {
|
|
1338
|
+
size: size,
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
} catch (e) {
|
|
1342
|
+
console.warn("IIIF minimap fit failed:", e);
|
|
1343
|
+
}
|
|
1344
|
+
};
|
|
1345
|
+
const mini_source = this._sourceOf(this._tile_layer_mini);
|
|
1346
|
+
if (mini_source) {
|
|
1347
|
+
this._fireImageready(
|
|
1348
|
+
mini_source,
|
|
1349
|
+
this.options.map_type === "zoomify"
|
|
1350
|
+
? "zoomify"
|
|
1351
|
+
: this.options.map_type === "iiif"
|
|
1352
|
+
? "iiif"
|
|
1353
|
+
: "tiles",
|
|
1354
|
+
this._tile_layer_mini,
|
|
1355
|
+
);
|
|
1356
|
+
if (mini_source.getState() === "ready") {
|
|
1357
|
+
fit_mini_image();
|
|
1358
|
+
} else {
|
|
1359
|
+
mini_source.once("change", () => {
|
|
1360
|
+
if (mini_source.getState() === "ready") fit_mini_image();
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
} else {
|
|
1364
|
+
// the mini layer sets its source asynchronously
|
|
1365
|
+
this._tile_layer_mini.once("change:source", () => {
|
|
1366
|
+
const src = this._sourceOf(this._tile_layer_mini);
|
|
1367
|
+
if (!src) return;
|
|
1368
|
+
if (src.getState() === "ready") {
|
|
1369
|
+
fit_mini_image();
|
|
1370
|
+
} else {
|
|
1371
|
+
src.once("change", () => {
|
|
1372
|
+
if (src.getState() === "ready") fit_mini_image();
|
|
1373
|
+
});
|
|
1374
|
+
}
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
/* Create Markers
|
|
1380
|
+
================================================== */
|
|
1381
|
+
_createMarker(d: StorymapSlide): void {
|
|
1382
|
+
const marker = new OpenLayersMapMarker(d, this.options);
|
|
1383
|
+
marker.on("markerclick", this._onMarkerClick, this);
|
|
1384
|
+
this._addMarker(marker);
|
|
1385
|
+
this._markers.push(marker);
|
|
1386
|
+
marker.marker_number = this._markers.length - 1;
|
|
1387
|
+
this.fire("markerAdded", marker);
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
_addMarker(marker: OpenLayersMapMarker): void {
|
|
1391
|
+
marker.addTo(this._map);
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
/**
|
|
1395
|
+
* Position marker overlays on the unwrapped longitude path so they sit on
|
|
1396
|
+
* the same world copy as the fitted view and the line (issue #381).
|
|
1397
|
+
* Image-space coordinates are not degrees and stay untouched.
|
|
1398
|
+
*/
|
|
1399
|
+
_afterCreateMarkers(): void {
|
|
1400
|
+
if (this._map.getView().getProjection().getCode() === "EPSG:4326") {
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
const real: { marker: OpenLayersMapMarker; latlon: LatLngLiteral }[] = [];
|
|
1404
|
+
for (const marker of this._markers) {
|
|
1405
|
+
const latlon = marker.latLon();
|
|
1406
|
+
if (latlon) {
|
|
1407
|
+
real.push({ marker: marker, latlon: latlon });
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
const unwrapped = this._unwrapLongitudes(real.map((entry) => entry.latlon.lon));
|
|
1411
|
+
real.forEach((entry, i) => {
|
|
1412
|
+
entry.marker._overlay?.setPosition(fromLonLat([unwrapped[i], entry.latlon.lat]));
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
_removeMarker(marker: OpenLayersMapMarker): void {
|
|
1417
|
+
if (marker && marker.data.real_marker) {
|
|
1418
|
+
marker._removeFrom(this._map);
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
/* Marker helpers
|
|
1423
|
+
================================================== */
|
|
1424
|
+
_getAllMarkersBounds(markers_array: OpenLayersMapMarker[]): number[][] {
|
|
1425
|
+
const coords: number[][] = [];
|
|
1426
|
+
for (const marker of markers_array) {
|
|
1427
|
+
const latlon = marker.latLon();
|
|
1428
|
+
if (latlon) {
|
|
1429
|
+
coords.push([latlon.lon, latlon.lat]);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
// unwrap dateline crossings so fits don't span the whole globe
|
|
1433
|
+
// (issue #381)
|
|
1434
|
+
const lons = this._unwrapLongitudes(coords.map((c) => c[0]));
|
|
1435
|
+
return coords.map((c, i) => [lons[i], c[1]]);
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
/**
|
|
1439
|
+
* Normalize a longitude sequence so consecutive values differ by at most
|
|
1440
|
+
* 180 degrees: markers keep their raw coordinates (OpenLayers wraps the
|
|
1441
|
+
* display), but fits and lines use the unwrapped path (issue #381).
|
|
1442
|
+
*/
|
|
1443
|
+
_unwrapLongitudes(lons: number[]): number[] {
|
|
1444
|
+
if (lons.length === 0) return [];
|
|
1445
|
+
const out = [lons[0]];
|
|
1446
|
+
for (let i = 1; i < lons.length; i++) {
|
|
1447
|
+
let lon = lons[i];
|
|
1448
|
+
while (lon - out[i - 1] > 180) lon -= 360;
|
|
1449
|
+
while (lon - out[i - 1] < -180) lon += 360;
|
|
1450
|
+
out.push(lon);
|
|
1451
|
+
}
|
|
1452
|
+
return out;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
_markerCoordsToViewCoords(coords: number[][]): number[][] {
|
|
1456
|
+
const is_image_space = this._map.getView().getProjection().getCode() === "EPSG:4326";
|
|
1457
|
+
if (is_image_space) return coords.map((c) => [c[0], c[1]]);
|
|
1458
|
+
return coords.map((c) => fromLonLat(c));
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
/**
|
|
1462
|
+
* The View extent for the `map_bbox` option, or `null` when unset. The
|
|
1463
|
+
* view uses it with `constrainOnlyCenter` (see _createMap).
|
|
1464
|
+
*/
|
|
1465
|
+
_bboxExtent(): number[] | null {
|
|
1466
|
+
const bbox = this.options.map_bbox as number[] | null | undefined;
|
|
1467
|
+
if (!bbox || bbox.length !== 4) return null;
|
|
1468
|
+
if (this.isImageSpace()) return bbox;
|
|
1469
|
+
// NB: fromLonLat transforms a single [lon, lat] pair — transform the
|
|
1470
|
+
// two corners separately
|
|
1471
|
+
const min = fromLonLat([bbox[0], bbox[1]]);
|
|
1472
|
+
const max = fromLonLat([bbox[2], bbox[3]]);
|
|
1473
|
+
return [min[0], min[1], max[0], max[1]];
|
|
1474
|
+
}
|
|
1475
|
+
/**
|
|
1476
|
+
* The slide content panel can be opaque — it then covers part of the map
|
|
1477
|
+
* and the effective visible area shrinks. Returns the pixel padding for
|
|
1478
|
+
* the covered side (right in landscape, bottom in portrait) so fits keep
|
|
1479
|
+
* the story inside the visible region; transparent panels and panels
|
|
1480
|
+
* that do not overlap the map (map_area "left") add nothing.
|
|
1481
|
+
*/
|
|
1482
|
+
_opaquePanelPadding(): [number, number, number, number] {
|
|
1483
|
+
const padding: [number, number, number, number] = [15, 15, 15, 15];
|
|
1484
|
+
if (typeof document === "undefined") return padding;
|
|
1485
|
+
// Scope the search to THIS viewer's root. The slide panel is a sibling
|
|
1486
|
+
// of the map, not a descendant, so it is not reachable from
|
|
1487
|
+
// this._el.container (the .vco-map div); a document-wide query would
|
|
1488
|
+
// find whichever panel comes first on the page, and then measure this
|
|
1489
|
+
// map against another viewer's rectangle — the overlap test below then
|
|
1490
|
+
// decides padding for a panel that may not overlap this map at all.
|
|
1491
|
+
const root = this._el.container.closest?.(".vco-storymap");
|
|
1492
|
+
// no viewer root means the map is standalone, so there is no slide
|
|
1493
|
+
// panel to make room for
|
|
1494
|
+
if (!root) return padding;
|
|
1495
|
+
const panel = root.querySelector(".vco-storyslider .vco-slide.vco-active .vco-text");
|
|
1496
|
+
if (!panel) return padding;
|
|
1497
|
+
const bg = getComputedStyle(panel).backgroundColor;
|
|
1498
|
+
const match = /rgba?\(([^)]+)\)/.exec(bg);
|
|
1499
|
+
if (!match) return padding;
|
|
1500
|
+
const parts = match[1]
|
|
1501
|
+
.split(/[,\s/]+/)
|
|
1502
|
+
.filter((v) => v !== "")
|
|
1503
|
+
.map(Number);
|
|
1504
|
+
const alpha = parts.length >= 4 ? parts[3] : 1;
|
|
1505
|
+
if (alpha < 0.9) return padding;
|
|
1506
|
+
const map_rect = this._el.map.getBoundingClientRect();
|
|
1507
|
+
const panel_rect = panel.getBoundingClientRect();
|
|
1508
|
+
if (!map_rect.width || !panel_rect.width) return padding;
|
|
1509
|
+
// the panel must actually overlap the map (it does not in the
|
|
1510
|
+
// map_area "left" layout, where map and panel sit side by side)
|
|
1511
|
+
const overlaps =
|
|
1512
|
+
panel_rect.left < map_rect.right &&
|
|
1513
|
+
panel_rect.right > map_rect.left &&
|
|
1514
|
+
panel_rect.top < map_rect.bottom &&
|
|
1515
|
+
panel_rect.bottom > map_rect.top;
|
|
1516
|
+
if (!overlaps) return padding;
|
|
1517
|
+
const layout = this.options.layout;
|
|
1518
|
+
if (layout === "portrait") {
|
|
1519
|
+
padding[2] += Math.max(0, map_rect.bottom - panel_rect.top);
|
|
1520
|
+
} else {
|
|
1521
|
+
padding[1] += Math.max(0, panel_rect.right - map_rect.left);
|
|
1522
|
+
}
|
|
1523
|
+
return padding;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/**
|
|
1527
|
+
* Fit the given coordinates. With `clamp_to_bbox` (the strict bbox
|
|
1528
|
+
* layout, map_area "left"), markers outside of the box are unreachable
|
|
1529
|
+
* by design — they must not skew the fit target, so the extent is
|
|
1530
|
+
* intersected with the box and the in-box markers compose the view.
|
|
1531
|
+
*/
|
|
1532
|
+
_fitView(ol_map: OlMap, coords: number[][], duration = 0, clamp_to_bbox = false): void {
|
|
1533
|
+
if (!coords || !coords.length) return;
|
|
1534
|
+
const view_coords = this._markerCoordsToViewCoords(coords);
|
|
1535
|
+
let extent = boundingExtent(view_coords);
|
|
1536
|
+
if (clamp_to_bbox) {
|
|
1537
|
+
const bbox = this._bboxExtent();
|
|
1538
|
+
if (bbox) {
|
|
1539
|
+
const clamped = getIntersection(extent, bbox as Extent);
|
|
1540
|
+
// getIntersection returns an inverted (empty) extent when the
|
|
1541
|
+
// boxes are disjoint — fit the box itself in that case
|
|
1542
|
+
extent =
|
|
1543
|
+
clamped[0] <= clamped[2] && clamped[1] <= clamped[3]
|
|
1544
|
+
? clamped
|
|
1545
|
+
: (bbox as Extent);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
ol_map.getView().fit(extent, {
|
|
1549
|
+
size: ol_map.getSize(),
|
|
1550
|
+
padding: this._opaquePanelPadding(),
|
|
1551
|
+
maxZoom: 12,
|
|
1552
|
+
duration: duration,
|
|
1553
|
+
easing: this._easing,
|
|
1554
|
+
});
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
_calculateMarkerZooms(): void {
|
|
1558
|
+
const center = this._getMapCenter();
|
|
1559
|
+
for (const [i, marker] of this._markers.entries()) {
|
|
1560
|
+
// a marker with no location object at all is not navigable
|
|
1561
|
+
if (!marker.data.location) {
|
|
1562
|
+
continue;
|
|
1563
|
+
}
|
|
1564
|
+
let calculated_zoom: number | undefined;
|
|
1565
|
+
|
|
1566
|
+
// MARKER LOCATION — the overview fits around the current view
|
|
1567
|
+
// rather than a point on the map
|
|
1568
|
+
const marker_location =
|
|
1569
|
+
marker.data.type && marker.data.type === "overview" ? center : marker.location();
|
|
1570
|
+
if (!marker_location) {
|
|
1571
|
+
continue;
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
// Fit-based zoom: zoom the view to this marker and neighbors
|
|
1575
|
+
const prev_marker = i > 0 ? this._markers[i - 1].location() : center;
|
|
1576
|
+
const next_marker =
|
|
1577
|
+
i < this._markers.length - 1 ? this._markers[i + 1].location() : center;
|
|
1578
|
+
|
|
1579
|
+
const prev_marker_zoom = prev_marker
|
|
1580
|
+
? this._calculateZoomChange(prev_marker, marker_location)
|
|
1581
|
+
: undefined;
|
|
1582
|
+
const next_marker_zoom = next_marker
|
|
1583
|
+
? this._calculateZoomChange(next_marker, marker_location)
|
|
1584
|
+
: undefined;
|
|
1585
|
+
|
|
1586
|
+
if (prev_marker_zoom && prev_marker_zoom < (next_marker_zoom ?? Infinity)) {
|
|
1587
|
+
calculated_zoom = prev_marker_zoom;
|
|
1588
|
+
} else if (next_marker_zoom) {
|
|
1589
|
+
calculated_zoom = next_marker_zoom;
|
|
1590
|
+
} else {
|
|
1591
|
+
calculated_zoom = prev_marker_zoom;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
if (calculated_zoom === undefined) {
|
|
1595
|
+
continue;
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
if (
|
|
1599
|
+
this.options.map_center_offset &&
|
|
1600
|
+
(this.options.map_center_offset.left !== 0 ||
|
|
1601
|
+
this.options.map_center_offset.top !== 0)
|
|
1602
|
+
) {
|
|
1603
|
+
calculated_zoom = calculated_zoom - 1;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
this._marker_zooms[i] = calculated_zoom;
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
/* Line
|
|
1611
|
+
================================================== */
|
|
1612
|
+
|
|
1613
|
+
/**
|
|
1614
|
+
* Stroke style for the route lines: the dash pattern and line join are
|
|
1615
|
+
* applied at init too, matching the original rendering (the lines are
|
|
1616
|
+
* dashed "5,5" by default, not solid).
|
|
1617
|
+
*/
|
|
1618
|
+
_lineStyle(color: string): Style {
|
|
1619
|
+
return new Style({
|
|
1620
|
+
stroke: new Stroke({
|
|
1621
|
+
color: color,
|
|
1622
|
+
width: this.options.line_weight,
|
|
1623
|
+
lineDash: String(this.options.line_dash)
|
|
1624
|
+
.split(",")
|
|
1625
|
+
.map((v) => Number(v)),
|
|
1626
|
+
lineJoin: this.options.line_join as CanvasLineJoin,
|
|
1627
|
+
}),
|
|
1628
|
+
});
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
_createLine(d?: StorymapSlide): VectorLayer {
|
|
1632
|
+
return new VectorLayer({
|
|
1633
|
+
source: new VectorSource({ features: [] }),
|
|
1634
|
+
// re-render vector geometry during view animations: without it
|
|
1635
|
+
// the animated line drawing is only painted after the
|
|
1636
|
+
// transition finishes
|
|
1637
|
+
updateWhileAnimating: true,
|
|
1638
|
+
style: this._lineStyle(this.options.line_color),
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
_addLineToMap(line: VectorLayer): void {
|
|
1643
|
+
// honor the show_lines option
|
|
1644
|
+
line.setVisible(this.options.show_lines);
|
|
1645
|
+
this._map.addLayer(line);
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
_addToLine(line: VectorLayer, d: StorymapSlide): void {
|
|
1649
|
+
// Append a point to the line's geometry
|
|
1650
|
+
const source = line.getSource();
|
|
1651
|
+
if (!source) {
|
|
1652
|
+
return;
|
|
1653
|
+
}
|
|
1654
|
+
let feature = source.getFeatures()[0];
|
|
1655
|
+
if (!feature) {
|
|
1656
|
+
feature = new Feature({ geometry: new LineString([]) });
|
|
1657
|
+
source.addFeature(feature);
|
|
1658
|
+
}
|
|
1659
|
+
const coords = feature.getGeometry().getCoordinates();
|
|
1660
|
+
let lon = d.location?.lon;
|
|
1661
|
+
const lat = d.location?.lat;
|
|
1662
|
+
if (lat === undefined || lon === undefined) {
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1665
|
+
// unwrap dateline crossings against the previous point (issue #381).
|
|
1666
|
+
// NB: read the previous longitude straight from meters — toLonLat()
|
|
1667
|
+
// wraps into [-180, 180] and would collapse already-unwrapped values.
|
|
1668
|
+
if (coords.length > 0) {
|
|
1669
|
+
const last_lon = coords[coords.length - 1][0] / 111319.49079327358;
|
|
1670
|
+
while (lon - last_lon > 180) lon -= 360;
|
|
1671
|
+
while (lon - last_lon < -180) lon += 360;
|
|
1672
|
+
}
|
|
1673
|
+
coords.push(this._toViewCoords({ lat, lon }));
|
|
1674
|
+
feature.getGeometry().setCoordinates(coords);
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
/**
|
|
1678
|
+
* Replace a line's geometry. With `animate.duration > 0` only the latest
|
|
1679
|
+
* hop animates, in sync with the view animation: the already-traveled
|
|
1680
|
+
* prefix stays drawn while the new segment traces progressively, so half
|
|
1681
|
+
* way through the pan the old connections are fully red and only half
|
|
1682
|
+
* the new hop is. Backward navigation mirrors this: the traveled prefix
|
|
1683
|
+
* stays drawn while the far end pulls back along the abandoned hop.
|
|
1684
|
+
*/
|
|
1685
|
+
_replaceLines(
|
|
1686
|
+
line: VectorLayer,
|
|
1687
|
+
array: LinePoint[],
|
|
1688
|
+
animate?: {
|
|
1689
|
+
duration: number;
|
|
1690
|
+
retractFrom?: LinePoint[] | null;
|
|
1691
|
+
growFrom?: LinePoint[] | null;
|
|
1692
|
+
},
|
|
1693
|
+
): void {
|
|
1694
|
+
const toLonLat = (d: LinePoint): number[] => {
|
|
1695
|
+
const lat = d.location ? d.location.lat : d.lat;
|
|
1696
|
+
const lon = d.location ? d.location.lon : d.lon;
|
|
1697
|
+
return [lon as number, lat as number];
|
|
1698
|
+
};
|
|
1699
|
+
const pts = array.map(toLonLat);
|
|
1700
|
+
const lons = this._unwrapLongitudes(pts.map((p) => p[0] as number));
|
|
1701
|
+
const unwrapped = pts.map((p, i) => [lons[i], p[1]]);
|
|
1702
|
+
const view_coords = this._markerCoordsToViewCoords(unwrapped);
|
|
1703
|
+
const source = line.getSource();
|
|
1704
|
+
if (!source) {
|
|
1705
|
+
return;
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
const setGeometry = (coords: number[][]) => {
|
|
1709
|
+
source.clear();
|
|
1710
|
+
source.addFeature(new Feature({ geometry: new LineString(coords) }));
|
|
1711
|
+
};
|
|
1712
|
+
|
|
1713
|
+
this._cancelLineAnimation();
|
|
1714
|
+
|
|
1715
|
+
const duration = animate?.duration ?? 0;
|
|
1716
|
+
if (duration <= 0 || view_coords.length < 2) {
|
|
1717
|
+
setGeometry(view_coords);
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
// The already-traveled prefix, as a leading subsequence of the
|
|
1722
|
+
// target path: verified point-by-point so the shared joint cannot
|
|
1723
|
+
// drift (both go through the same unwrap + view projection).
|
|
1724
|
+
// Falls back to 0 (whole-path animation) on any mismatch.
|
|
1725
|
+
const leadingLength = (candidate: LinePoint[]): number => {
|
|
1726
|
+
const raw = candidate.map(toLonLat);
|
|
1727
|
+
if (raw.length < 1 || raw.length >= unwrapped.length) {
|
|
1728
|
+
return 0;
|
|
1729
|
+
}
|
|
1730
|
+
const cl = this._unwrapLongitudes(raw.map((p) => p[0] as number));
|
|
1731
|
+
for (let i = 0; i < raw.length; i++) {
|
|
1732
|
+
if (cl[i] !== unwrapped[i][0] || raw[i][1] !== unwrapped[i][1]) {
|
|
1733
|
+
return 0;
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
return raw.length;
|
|
1737
|
+
};
|
|
1738
|
+
|
|
1739
|
+
// Retraction (backward navigation): the animation path is the route
|
|
1740
|
+
// up to the previous marker (retractFrom); the traveled prefix
|
|
1741
|
+
// [0..current] stays drawn while the abandoned tail shrinks back to
|
|
1742
|
+
// the joint — the far end pulls back from the old marker to the new
|
|
1743
|
+
// one.
|
|
1744
|
+
const retract_raw = (animate?.retractFrom ?? []).map(toLonLat);
|
|
1745
|
+
let tail: number[][] | null = null;
|
|
1746
|
+
if (retract_raw.length > 0) {
|
|
1747
|
+
const rl = this._unwrapLongitudes(retract_raw.map((p) => p[0] as number));
|
|
1748
|
+
const retract_unwrapped = retract_raw.map((p, i) => [rl[i], p[1]]);
|
|
1749
|
+
// The target must be the leading subsequence of the retraction
|
|
1750
|
+
// path; otherwise fall back to whole-path truncation below.
|
|
1751
|
+
let match = unwrapped.length >= 2 && unwrapped.length < retract_unwrapped.length;
|
|
1752
|
+
for (let i = 0; match && i < unwrapped.length; i++) {
|
|
1753
|
+
if (
|
|
1754
|
+
retract_unwrapped[i][0] !== unwrapped[i][0] ||
|
|
1755
|
+
retract_unwrapped[i][1] !== unwrapped[i][1]
|
|
1756
|
+
) {
|
|
1757
|
+
match = false;
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
if (match) {
|
|
1761
|
+
tail = this._markerCoordsToViewCoords(retract_unwrapped).slice(
|
|
1762
|
+
unwrapped.length - 1,
|
|
1763
|
+
);
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
const tail_total = tail ? this._pathLength(tail) : 0;
|
|
1767
|
+
|
|
1768
|
+
// Growth (forward navigation): the already-traveled prefix
|
|
1769
|
+
// [0..previous] (growFrom) stays drawn while only the new hop
|
|
1770
|
+
// [prev..current] traces progressively.
|
|
1771
|
+
const grow_len = leadingLength(animate?.growFrom ?? []);
|
|
1772
|
+
const prefix = grow_len >= 1 ? view_coords.slice(0, grow_len) : null;
|
|
1773
|
+
const suffix = grow_len >= 1 ? view_coords.slice(grow_len - 1) : null;
|
|
1774
|
+
const suffix_total = suffix ? this._pathLength(suffix) : 0;
|
|
1775
|
+
|
|
1776
|
+
const easing = this._easing;
|
|
1777
|
+
const start_time = performance.now();
|
|
1778
|
+
if (prefix) {
|
|
1779
|
+
// Seed the already-traveled prefix so it stays red from frame 0
|
|
1780
|
+
// (also heals a partially-drawn line when a running animation
|
|
1781
|
+
// is cancelled by rapid stepping).
|
|
1782
|
+
setGeometry(prefix);
|
|
1783
|
+
}
|
|
1784
|
+
const step = (now: number) => {
|
|
1785
|
+
const t = Math.min(1, Math.max(0, (now - start_time) / duration));
|
|
1786
|
+
const eased = easing ? easing(t) : t;
|
|
1787
|
+
if (tail && tail_total > 0) {
|
|
1788
|
+
// pull the far end back along the abandoned hop
|
|
1789
|
+
const drawn = tail_total - eased * tail_total;
|
|
1790
|
+
const drawn_tail = this._truncatePath(tail, drawn);
|
|
1791
|
+
setGeometry([...view_coords, ...drawn_tail.slice(1)]);
|
|
1792
|
+
if (t >= 1) {
|
|
1793
|
+
setGeometry(view_coords);
|
|
1794
|
+
}
|
|
1795
|
+
} else if (prefix && suffix && suffix_total > 0) {
|
|
1796
|
+
const drawn_suffix = this._truncatePath(suffix, eased * suffix_total);
|
|
1797
|
+
setGeometry([...prefix, ...drawn_suffix.slice(1)]);
|
|
1798
|
+
if (t >= 1) {
|
|
1799
|
+
setGeometry(view_coords);
|
|
1800
|
+
}
|
|
1801
|
+
} else {
|
|
1802
|
+
const total_length = this._pathLength(view_coords);
|
|
1803
|
+
setGeometry(this._truncatePath(view_coords, eased * total_length));
|
|
1804
|
+
}
|
|
1805
|
+
this._line_animation = t < 1 ? requestAnimationFrame(step) : null;
|
|
1806
|
+
};
|
|
1807
|
+
this._line_animation = requestAnimationFrame(step);
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
/** Cancel a running active-line draw animation. */
|
|
1811
|
+
_cancelLineAnimation(): void {
|
|
1812
|
+
if (this._line_animation !== null) {
|
|
1813
|
+
cancelAnimationFrame(this._line_animation);
|
|
1814
|
+
this._line_animation = null;
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
/** Total euclidean length of a path in view coordinates. */
|
|
1819
|
+
_pathLength(coords: number[][]): number {
|
|
1820
|
+
let total = 0;
|
|
1821
|
+
for (let i = 1; i < coords.length; i++) {
|
|
1822
|
+
total += Math.hypot(coords[i][0] - coords[i - 1][0], coords[i][1] - coords[i - 1][1]);
|
|
1823
|
+
}
|
|
1824
|
+
return total;
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
/**
|
|
1828
|
+
* The prefix of the path up to `length` (in view units), with the cut
|
|
1829
|
+
* point interpolated inside its segment.
|
|
1830
|
+
*/
|
|
1831
|
+
_truncatePath(coords: number[][], length: number): number[][] {
|
|
1832
|
+
if (length <= 0 || coords.length < 2) {
|
|
1833
|
+
return [];
|
|
1834
|
+
}
|
|
1835
|
+
const out = [coords[0]];
|
|
1836
|
+
let acc = 0;
|
|
1837
|
+
for (let i = 1; i < coords.length; i++) {
|
|
1838
|
+
const seg = Math.hypot(
|
|
1839
|
+
coords[i][0] - coords[i - 1][0],
|
|
1840
|
+
coords[i][1] - coords[i - 1][1],
|
|
1841
|
+
);
|
|
1842
|
+
if (acc + seg >= length) {
|
|
1843
|
+
const f = (length - acc) / seg;
|
|
1844
|
+
out.push([
|
|
1845
|
+
coords[i - 1][0] + f * (coords[i][0] - coords[i - 1][0]),
|
|
1846
|
+
coords[i - 1][1] + f * (coords[i][1] - coords[i - 1][1]),
|
|
1847
|
+
]);
|
|
1848
|
+
return out;
|
|
1849
|
+
}
|
|
1850
|
+
acc += seg;
|
|
1851
|
+
out.push(coords[i]);
|
|
1852
|
+
}
|
|
1853
|
+
return out;
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
/* Map
|
|
1857
|
+
================================================== */
|
|
1858
|
+
_panTo(loc: LatLngLiteral, animate?: boolean): void {
|
|
1859
|
+
this._map.getView().animate({
|
|
1860
|
+
center: this._toViewCoords(loc),
|
|
1861
|
+
duration: this.options.duration,
|
|
1862
|
+
easing: this._easing,
|
|
1863
|
+
});
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
_zoomTo(z: number, animate?: boolean): void {
|
|
1867
|
+
this._map.getView().animate({
|
|
1868
|
+
zoom: z,
|
|
1869
|
+
duration: this.options.duration,
|
|
1870
|
+
easing: this._easing,
|
|
1871
|
+
});
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
_viewTo(loc: StorymapSlideLocation, opts?: ViewToOptions): void {
|
|
1875
|
+
// Image region stops (StrollView-style): in image mode the view is
|
|
1876
|
+
// EPSG:4326 where coordinates are raw image pixels — fit the xywh
|
|
1877
|
+
// region instead of flying to a point. Everything else (geo maps,
|
|
1878
|
+
// absent/invalid regions) keeps the point behavior unchanged.
|
|
1879
|
+
const is_image_space = this._map.getView().getProjection().getCode() === "EPSG:4326";
|
|
1880
|
+
const region =
|
|
1881
|
+
is_image_space && Array.isArray(loc.region) && loc.region.length === 4
|
|
1882
|
+
? loc.region
|
|
1883
|
+
: null;
|
|
1884
|
+
if (region) {
|
|
1885
|
+
this._fitRegion(region, opts);
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
const start = this._latLngOf(loc);
|
|
1890
|
+
if (!start) {
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
let _animate = true,
|
|
1894
|
+
_duration = this.options.duration,
|
|
1895
|
+
_zoom = this._getMapZoom(),
|
|
1896
|
+
_location: LatLngLiteral = start;
|
|
1897
|
+
|
|
1898
|
+
// Show Active Line
|
|
1899
|
+
if (!this.options.map_as_image) {
|
|
1900
|
+
this._line_active.setVisible(true);
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
if (loc.zoom !== undefined && loc.zoom !== null) {
|
|
1904
|
+
_zoom = loc.zoom;
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
// Options
|
|
1908
|
+
if (opts) {
|
|
1909
|
+
if (opts.duration !== undefined) {
|
|
1910
|
+
if (opts.duration === 0) {
|
|
1911
|
+
_animate = false;
|
|
1912
|
+
} else {
|
|
1913
|
+
_duration = opts.duration;
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
if (opts.zoom && this.options.calculate_zoom) {
|
|
1918
|
+
_zoom = opts.zoom;
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
// OFFSET
|
|
1923
|
+
if (this.options.map_center_offset) {
|
|
1924
|
+
_location = this._getMapCenterOffset(_location, _zoom);
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
this._map.getView().animate({
|
|
1928
|
+
center: this._toViewCoords(_location),
|
|
1929
|
+
zoom: _zoom,
|
|
1930
|
+
duration: _animate ? _duration : 0,
|
|
1931
|
+
easing: this._easing,
|
|
1932
|
+
});
|
|
1933
|
+
|
|
1934
|
+
if (this._mini_map && this.options.width > this.options.skinny_size) {
|
|
1935
|
+
// zoom_min_max stays null until the markers are measured (first
|
|
1936
|
+
// loadend), and `_zoom - 1 <= null` coerces to `<= 0` — the
|
|
1937
|
+
// minimap state was decided by an accidental coercion. Treat an
|
|
1938
|
+
// unmeasured range as "not zoomed out", i.e. keep it expanded.
|
|
1939
|
+
if (this.zoom_min_max.min !== null && _zoom - 1 <= this.zoom_min_max.min) {
|
|
1940
|
+
this._mini_map.setCollapsed(true);
|
|
1941
|
+
} else {
|
|
1942
|
+
this._mini_map.setCollapsed(false);
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
/**
|
|
1948
|
+
* Image region stop: fit the xywh bbox ([x, y, w, h] image pixels —
|
|
1949
|
+
* the EPSG:4326 image space) with the shared animation options, then
|
|
1950
|
+
* keep the minimap collapse state in sync like `_viewTo`.
|
|
1951
|
+
*/
|
|
1952
|
+
_fitRegion(region: [number, number, number, number], opts?: ViewToOptions): void {
|
|
1953
|
+
const [x, y, w, h] = region;
|
|
1954
|
+
if (!(w > 0) || !(h > 0)) {
|
|
1955
|
+
return;
|
|
1956
|
+
}
|
|
1957
|
+
let _animate = true;
|
|
1958
|
+
let _duration = this.options.duration;
|
|
1959
|
+
if (opts && opts.duration !== undefined) {
|
|
1960
|
+
if (opts.duration === 0) {
|
|
1961
|
+
_animate = false;
|
|
1962
|
+
} else {
|
|
1963
|
+
_duration = opts.duration;
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
this._map.getView().fit([x, y, x + w, y + h], {
|
|
1967
|
+
size: this._map.getSize(),
|
|
1968
|
+
padding: this._opaquePanelPadding(),
|
|
1969
|
+
duration: _animate ? _duration : 0,
|
|
1970
|
+
easing: this._easing,
|
|
1971
|
+
});
|
|
1972
|
+
if (this._mini_map && this.options.width > this.options.skinny_size) {
|
|
1973
|
+
this._mini_map.setCollapsed(true);
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
_toViewCoords(loc: LatLngLiteral): number[] {
|
|
1978
|
+
const is_image_space = this._map.getView().getProjection().getCode() === "EPSG:4326";
|
|
1979
|
+
if (is_image_space) return [loc.lon, loc.lat];
|
|
1980
|
+
return fromLonLat([loc.lon, loc.lat]);
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
_getMapZoom(): number {
|
|
1984
|
+
// fractional zoom on purpose: overview fits produce non-integer zooms and
|
|
1985
|
+
// rounding here would snap the view on the next navigation
|
|
1986
|
+
return this._map.getView().getZoom() || 0;
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
// Note: there is deliberately no offset-aware variant. The exposed
|
|
1990
|
+
// center is panel-shifted (see the migration guide); a previous
|
|
1991
|
+
// `offset?: boolean` parameter promised a correction it never applied.
|
|
1992
|
+
_getMapCenter(): LatLngLiteral {
|
|
1993
|
+
const view = this._map.getView();
|
|
1994
|
+
const center = view.getCenter() ?? [0, 0];
|
|
1995
|
+
const [lon, lat] = toLonLat(center, view.getProjection());
|
|
1996
|
+
return { lat: lat, lon: lon };
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
_getMapCenterOffset(location: LatLngLiteral, zoom: number): LatLngLiteral {
|
|
2000
|
+
// Offset the center by map_center_offset pixels at the given zoom.
|
|
2001
|
+
// A null map_center_offset means "no offset" (the default).
|
|
2002
|
+
const offset = this.options.map_center_offset;
|
|
2003
|
+
if (!offset || (offset.left === 0 && offset.top === 0)) {
|
|
2004
|
+
return location;
|
|
2005
|
+
}
|
|
2006
|
+
const view = this._map.getView();
|
|
2007
|
+
const projection = view.getProjection();
|
|
2008
|
+
const center = this._toViewCoords(location);
|
|
2009
|
+
const resolution = view.getResolutionForZoom(zoom);
|
|
2010
|
+
return this._fromViewCoords(
|
|
2011
|
+
[center[0] - offset.left * resolution, center[1] + offset.top * resolution],
|
|
2012
|
+
projection,
|
|
2013
|
+
);
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
_fromViewCoords(coord: number[], projection: Projection): LatLngLiteral {
|
|
2017
|
+
if (projection.getCode() === "EPSG:4326") return { lat: coord[1], lon: coord[0] };
|
|
2018
|
+
// linear lon inversion without dateline wrapping (issue #381);
|
|
2019
|
+
// lat uses the exact mercator inverse (6378137 = the 3857 sphere radius)
|
|
2020
|
+
const c = toLonLat(coord, projection);
|
|
2021
|
+
return { lat: c[1], lon: (coord[0] / 6378137) * (180 / Math.PI) };
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
/**
|
|
2025
|
+
* May a tile-backed layer (base map, overlay, minimap, overview) be
|
|
2026
|
+
* created right now? Under `consent_required` that is only true once the
|
|
2027
|
+
* visitor has allowed the tile service. This was the same five-line
|
|
2028
|
+
* predicate at five call sites.
|
|
2029
|
+
*/
|
|
2030
|
+
private _tilesAllowed(): boolean {
|
|
2031
|
+
const consent = consentManagerOf(this.options);
|
|
2032
|
+
if (!this.options.consent_required || !consent) {
|
|
2033
|
+
return true;
|
|
2034
|
+
}
|
|
2035
|
+
return consent.isGranted(tileService().key);
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
/** A slide's {lat, lon} when both are real numbers, else null. */
|
|
2039
|
+
private _latLngOf(loc: StorymapSlideLocation | null | undefined): LatLngLiteral | null {
|
|
2040
|
+
if (!loc || typeof loc.lat !== "number" || typeof loc.lon !== "number") {
|
|
2041
|
+
return null;
|
|
2042
|
+
}
|
|
2043
|
+
return { lat: loc.lat, lon: loc.lon };
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
/**
|
|
2047
|
+
* The map's pixel size, with a never-zero fallback.
|
|
2048
|
+
*
|
|
2049
|
+
* `getSize()` returns undefined until the map has a size, so the six
|
|
2050
|
+
* call sites that fitted an extent to the viewport all repeated the same
|
|
2051
|
+
* `(size[0] || 1)` guard — which silently fitted against a 1px viewport
|
|
2052
|
+
* rather than bailing out.
|
|
2053
|
+
*/
|
|
2054
|
+
private _viewportSize(): [number, number] {
|
|
2055
|
+
const size = this._map?.getSize();
|
|
2056
|
+
return [size?.[0] || 1, size?.[1] || 1];
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
_getBoundsZoom(
|
|
2060
|
+
origin: LatLngLiteral,
|
|
2061
|
+
destination: LatLngLiteral,
|
|
2062
|
+
correct_for_center?: boolean,
|
|
2063
|
+
): number {
|
|
2064
|
+
const coords = [
|
|
2065
|
+
[origin.lon, origin.lat],
|
|
2066
|
+
[destination.lon, destination.lat],
|
|
2067
|
+
];
|
|
2068
|
+
const view_coords = this._markerCoordsToViewCoords(coords);
|
|
2069
|
+
const extent = boundingExtent(view_coords);
|
|
2070
|
+
const size = this._map.getSize();
|
|
2071
|
+
if (!size || !size[0] || !size[1]) return 0;
|
|
2072
|
+
|
|
2073
|
+
const resolution_x = (extent[2] - extent[0]) / size[0];
|
|
2074
|
+
const resolution_y = (extent[3] - extent[1]) / size[1];
|
|
2075
|
+
const resolution = Math.max(resolution_x, resolution_y) * 3; // padding factor
|
|
2076
|
+
if (!isFinite(resolution) || resolution <= 0) return 0;
|
|
2077
|
+
|
|
2078
|
+
const z = this._map.getView().getZoomForResolution(resolution);
|
|
2079
|
+
if (z === undefined || !isFinite(z)) return 0;
|
|
2080
|
+
return Math.max(0, Math.round(z));
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
_initialMapLocation(): void {
|
|
2084
|
+
// OpenLayers renders independently; nothing to subscribe for initial location
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
/**
|
|
2088
|
+
* Great-circle (haversine) length of the route through all markers in
|
|
2089
|
+
* kilometers (issue #341).
|
|
2090
|
+
*/
|
|
2091
|
+
getRouteDistance(): number | undefined {
|
|
2092
|
+
const coords = this._markers
|
|
2093
|
+
.map((m) => m.location())
|
|
2094
|
+
.filter((l): l is LatLngLiteral => !!l && isFinite(l.lat) && isFinite(l.lon));
|
|
2095
|
+
if (coords.length < 2) {
|
|
2096
|
+
return undefined;
|
|
2097
|
+
}
|
|
2098
|
+
const R = 6371;
|
|
2099
|
+
let total = 0;
|
|
2100
|
+
for (let i = 1; i < coords.length; i++) {
|
|
2101
|
+
const d_lat = ((coords[i].lat - coords[i - 1].lat) * Math.PI) / 180;
|
|
2102
|
+
const d_lon = ((coords[i].lon - coords[i - 1].lon) * Math.PI) / 180;
|
|
2103
|
+
const a =
|
|
2104
|
+
Math.sin(d_lat / 2) ** 2 +
|
|
2105
|
+
Math.cos((coords[i - 1].lat * Math.PI) / 180) *
|
|
2106
|
+
Math.cos((coords[i].lat * Math.PI) / 180) *
|
|
2107
|
+
Math.sin(d_lon / 2) ** 2;
|
|
2108
|
+
total += 2 * R * Math.asin(Math.sqrt(a));
|
|
2109
|
+
}
|
|
2110
|
+
return total;
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
_markerOverview(duration?: number): void {
|
|
2114
|
+
// Hide Active Line
|
|
2115
|
+
this._cancelLineAnimation();
|
|
2116
|
+
this._line_active.setVisible(false);
|
|
2117
|
+
|
|
2118
|
+
// repeated presses while an overview animation is running snap
|
|
2119
|
+
// instantly instead of restarting the ~1s animation
|
|
2120
|
+
if (duration && this._map.getView().getAnimating()) {
|
|
2121
|
+
duration = 0;
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
if (this.options.map_type === "zoomify") {
|
|
2125
|
+
// legacy zoomify: show the whole image centered at the best-fit
|
|
2126
|
+
// pyramid level (the original renderer's overview); with the
|
|
2127
|
+
// panel offset the legacy renderer drops one zoom level
|
|
2128
|
+
const size = this._map.getSize();
|
|
2129
|
+
const overview = this._zoomifyOverview([size?.[0] || 1280, size?.[1] || 450]);
|
|
2130
|
+
if (overview) {
|
|
2131
|
+
const offset =
|
|
2132
|
+
this.options.map_center_offset &&
|
|
2133
|
+
(this.options.map_center_offset.left !== 0 ||
|
|
2134
|
+
this.options.map_center_offset.top !== 0);
|
|
2135
|
+
// with the panel offset the legacy renderer drops one zoom
|
|
2136
|
+
// level (the mercator zoom = the pyramid level)
|
|
2137
|
+
const view_zoom = offset ? overview.zoom - 1 : overview.zoom;
|
|
2138
|
+
// offset the center directly in view coords (a lat/lon
|
|
2139
|
+
// round-trip would corrupt the zoomify target)
|
|
2140
|
+
const resolution = this._map.getView().getResolutionForZoom(view_zoom);
|
|
2141
|
+
const center_view = [
|
|
2142
|
+
overview.center[0] - (this.options.map_center_offset?.left ?? 0) * resolution,
|
|
2143
|
+
overview.center[1] + (this.options.map_center_offset?.top ?? 0) * resolution,
|
|
2144
|
+
];
|
|
2145
|
+
this._map.getView().animate({
|
|
2146
|
+
center: center_view,
|
|
2147
|
+
zoom: view_zoom,
|
|
2148
|
+
duration: duration ?? this._transition_duration,
|
|
2149
|
+
easing: this._easing,
|
|
2150
|
+
});
|
|
2151
|
+
}
|
|
2152
|
+
} else if (this.isImageSpace()) {
|
|
2153
|
+
const source = this._sourceOf(this._tile_layer);
|
|
2154
|
+
if (!source) {
|
|
2155
|
+
return;
|
|
2156
|
+
}
|
|
2157
|
+
const fit = () => {
|
|
2158
|
+
try {
|
|
2159
|
+
// a custom layer with a source is not necessarily a tile
|
|
2160
|
+
// source; only tile grids can supply a fit extent
|
|
2161
|
+
const grid = (
|
|
2162
|
+
source as {
|
|
2163
|
+
getTileGrid?: () => { getExtent(): number[] } | null;
|
|
2164
|
+
}
|
|
2165
|
+
).getTileGrid?.();
|
|
2166
|
+
if (grid) {
|
|
2167
|
+
// compute the fit target directly and animate once (a
|
|
2168
|
+
// fit() followed by setCenter() would cancel the fit
|
|
2169
|
+
// animation, issue #465)
|
|
2170
|
+
const extent = grid.getExtent();
|
|
2171
|
+
const size = this._viewportSize();
|
|
2172
|
+
const resolution_x = (extent[2] - extent[0]) / size[0];
|
|
2173
|
+
const resolution_y = (extent[3] - extent[1]) / size[1];
|
|
2174
|
+
const resolution = Math.max(resolution_x, resolution_y);
|
|
2175
|
+
const view = this._map.getView();
|
|
2176
|
+
const zoom = view.getZoomForResolution(resolution) ?? 0;
|
|
2177
|
+
const overview_center = this.options.map_overview_center;
|
|
2178
|
+
const center_px = overview_center
|
|
2179
|
+
? this._toViewCoords({
|
|
2180
|
+
lat: overview_center.lat,
|
|
2181
|
+
lon: overview_center.lon,
|
|
2182
|
+
})
|
|
2183
|
+
: [(extent[0] + extent[2]) / 2, (extent[1] + extent[3]) / 2];
|
|
2184
|
+
const projection = view.getProjection();
|
|
2185
|
+
const location = this._fromViewCoords(center_px, projection);
|
|
2186
|
+
const offset_location = this._getMapCenterOffset(location, zoom);
|
|
2187
|
+
view.animate({
|
|
2188
|
+
center: this._toViewCoords(offset_location),
|
|
2189
|
+
zoom: zoom,
|
|
2190
|
+
duration: duration ?? this._transition_duration,
|
|
2191
|
+
easing: this._easing,
|
|
2192
|
+
});
|
|
2193
|
+
}
|
|
2194
|
+
} catch (e) {
|
|
2195
|
+
console.warn("IIIF overview fit failed:", e);
|
|
2196
|
+
}
|
|
2197
|
+
};
|
|
2198
|
+
|
|
2199
|
+
if (source.getState() === "ready") {
|
|
2200
|
+
fit();
|
|
2201
|
+
} else {
|
|
2202
|
+
source.once("change", () => {
|
|
2203
|
+
if (source.getState() === "ready") fit();
|
|
2204
|
+
});
|
|
2205
|
+
}
|
|
2206
|
+
} else {
|
|
2207
|
+
this.bounds_array = this._getAllMarkersBounds(this._markers);
|
|
2208
|
+
|
|
2209
|
+
// user-selected overview centerpoint (issues #107, #271): fit the
|
|
2210
|
+
// zoom to the markers but center on the configured location
|
|
2211
|
+
const overview_center = this.options.map_overview_center;
|
|
2212
|
+
if (overview_center && this.bounds_array && this.bounds_array.length) {
|
|
2213
|
+
const view_coords = this._markerCoordsToViewCoords(this.bounds_array);
|
|
2214
|
+
const extent = boundingExtent(view_coords);
|
|
2215
|
+
const size = this._viewportSize();
|
|
2216
|
+
const resolution = Math.max(
|
|
2217
|
+
(extent[2] - extent[0]) / size[0],
|
|
2218
|
+
(extent[3] - extent[1]) / size[1],
|
|
2219
|
+
);
|
|
2220
|
+
const zoom = Math.max(
|
|
2221
|
+
0,
|
|
2222
|
+
Math.round(this._map.getView().getZoomForResolution(resolution) ?? 0) - 1,
|
|
2223
|
+
);
|
|
2224
|
+
const offset_location = this._getMapCenterOffset(
|
|
2225
|
+
{ lat: overview_center.lat, lon: overview_center.lon },
|
|
2226
|
+
zoom,
|
|
2227
|
+
);
|
|
2228
|
+
this._map.getView().animate({
|
|
2229
|
+
center: this._toViewCoords(offset_location),
|
|
2230
|
+
zoom: zoom,
|
|
2231
|
+
duration: duration ?? this._transition_duration,
|
|
2232
|
+
easing: this._easing,
|
|
2233
|
+
});
|
|
2234
|
+
} else if (
|
|
2235
|
+
this.options.map_center_offset &&
|
|
2236
|
+
(this.options.map_center_offset.left !== 0 ||
|
|
2237
|
+
this.options.map_center_offset.top !== 0)
|
|
2238
|
+
) {
|
|
2239
|
+
if (this.bounds_array && this.bounds_array.length) {
|
|
2240
|
+
const view_coords = this._markerCoordsToViewCoords(this.bounds_array);
|
|
2241
|
+
const extent = boundingExtent(view_coords);
|
|
2242
|
+
const size = this._viewportSize();
|
|
2243
|
+
const resolution_x = (extent[2] - extent[0]) / size[0];
|
|
2244
|
+
const resolution_y = (extent[3] - extent[1]) / size[1];
|
|
2245
|
+
const resolution = Math.max(resolution_x, resolution_y);
|
|
2246
|
+
const zoom = Math.max(
|
|
2247
|
+
0,
|
|
2248
|
+
Math.round(this._map.getView().getZoomForResolution(resolution) ?? 0) - 1,
|
|
2249
|
+
);
|
|
2250
|
+
const center_px = [(extent[0] + extent[2]) / 2, (extent[1] + extent[3]) / 2];
|
|
2251
|
+
const projection = this._map.getView().getProjection();
|
|
2252
|
+
const location = this._fromViewCoords(center_px, projection);
|
|
2253
|
+
const offset_location = this._getMapCenterOffset(location, zoom);
|
|
2254
|
+
this._map.getView().animate({
|
|
2255
|
+
center: this._toViewCoords(offset_location),
|
|
2256
|
+
zoom: zoom,
|
|
2257
|
+
duration: duration ?? this._transition_duration,
|
|
2258
|
+
easing: this._easing,
|
|
2259
|
+
});
|
|
2260
|
+
}
|
|
2261
|
+
} else {
|
|
2262
|
+
// fit instantly, then shift the center by the panel offset
|
|
2263
|
+
// and animate there so the markers clear the story panel;
|
|
2264
|
+
// with the strict bbox layout markers outside of the box are
|
|
2265
|
+
// unreachable — they must not skew the fit target
|
|
2266
|
+
this._fitView(this._map, this.bounds_array, 0, this._bboxExtent() !== null);
|
|
2267
|
+
const view = this._map.getView();
|
|
2268
|
+
const zoom = view.getZoom();
|
|
2269
|
+
if (zoom !== undefined) {
|
|
2270
|
+
const center = this._fromViewCoords(
|
|
2271
|
+
view.getCenter() ?? [0, 0],
|
|
2272
|
+
view.getProjection(),
|
|
2273
|
+
);
|
|
2274
|
+
const offset_location = this._getMapCenterOffset(center, zoom);
|
|
2275
|
+
view.animate({
|
|
2276
|
+
center: this._toViewCoords(offset_location),
|
|
2277
|
+
zoom: zoom,
|
|
2278
|
+
duration: duration ?? this._transition_duration,
|
|
2279
|
+
easing: this._easing,
|
|
2280
|
+
});
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
if (this._mini_map) {
|
|
2286
|
+
this._mini_map.setCollapsed(true);
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
/**
|
|
2291
|
+
* Swap the minimap's layer for the current `map_type` (mirrors the
|
|
2292
|
+
* initial `_createMiniMap` fitting: zoomify extent, IIIF image extent,
|
|
2293
|
+
* otherwise the marker bounds). No-op when no minimap exists yet.
|
|
2294
|
+
*/
|
|
2295
|
+
_refreshMiniMapLayer(): void {
|
|
2296
|
+
if (!this._mini_map) return;
|
|
2297
|
+
const overview = this._mini_map.getOverviewMap();
|
|
2298
|
+
overview.getLayers().clear();
|
|
2299
|
+
if (!this._tilesAllowed()) {
|
|
2300
|
+
this._tile_layer_mini = null;
|
|
2301
|
+
return;
|
|
2302
|
+
}
|
|
2303
|
+
this._tile_layer_mini = this._createTileLayer(this.options.map_type);
|
|
2304
|
+
overview.addLayer(this._tile_layer_mini);
|
|
2305
|
+
const is_zoomify = this.options.map_type === "zoomify";
|
|
2306
|
+
const zoomify_pyramid = is_zoomify ? this._zoomifyPyramid() : null;
|
|
2307
|
+
if (zoomify_pyramid) {
|
|
2308
|
+
const raw_size = overview.getSize();
|
|
2309
|
+
const size = raw_size && raw_size[0] >= 50 && raw_size[1] >= 50 ? raw_size : [150, 100];
|
|
2310
|
+
overview.getView().fit(zoomify_pyramid.extent, { size: size });
|
|
2311
|
+
} else if (this.bounds_array && this.bounds_array.length) {
|
|
2312
|
+
this._fitView(overview, this.bounds_array, 0, this._bboxExtent() !== null);
|
|
2313
|
+
}
|
|
2314
|
+
if (this.isImageSpace()) {
|
|
2315
|
+
this._fitMiniMapToImage();
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
/* Display
|
|
2320
|
+
================================================== */
|
|
2321
|
+
/**
|
|
2322
|
+
* Re-apply runtime-changed options (driven by StoryMap.setMapOptions).
|
|
2323
|
+
* Only keys with an immediate effect are handled here; everything else is
|
|
2324
|
+
* picked up on the next navigation or layout pass.
|
|
2325
|
+
*/
|
|
2326
|
+
applyOptions(keys: string[]): void {
|
|
2327
|
+
for (const key of keys) {
|
|
2328
|
+
switch (key) {
|
|
2329
|
+
case "map_type": {
|
|
2330
|
+
// Rebuild the main + minimap tile layers for the new type
|
|
2331
|
+
if (this._tile_layer) {
|
|
2332
|
+
this._map.removeLayer(this._tile_layer);
|
|
2333
|
+
}
|
|
2334
|
+
if (this._tilesAllowed()) {
|
|
2335
|
+
this._tile_layer = this._createTileLayer(this.options.map_type);
|
|
2336
|
+
this._tile_layer.setZIndex(0);
|
|
2337
|
+
this._map.addLayer(this._tile_layer);
|
|
2338
|
+
}
|
|
2339
|
+
this._refreshMiniMapLayer();
|
|
2340
|
+
this._updateAttribution();
|
|
2341
|
+
this._el.map.style.backgroundColor = this.options.map_background_color;
|
|
2342
|
+
break;
|
|
2343
|
+
}
|
|
2344
|
+
case "overlays": {
|
|
2345
|
+
// Rebuild the stacked overlays from the new option value
|
|
2346
|
+
this._buildOverlays();
|
|
2347
|
+
break;
|
|
2348
|
+
}
|
|
2349
|
+
case "show_lines":
|
|
2350
|
+
case "line_color":
|
|
2351
|
+
case "line_color_inactive":
|
|
2352
|
+
case "line_weight":
|
|
2353
|
+
case "line_opacity":
|
|
2354
|
+
case "line_dash":
|
|
2355
|
+
case "line_join": {
|
|
2356
|
+
const stroke = (color: string) => this._lineStyle(color);
|
|
2357
|
+
this._line.setStyle(stroke(this.options.line_color_inactive));
|
|
2358
|
+
this._line.setOpacity(this.options.line_opacity);
|
|
2359
|
+
this._line.setVisible(this.options.show_lines);
|
|
2360
|
+
this._line_active.setStyle(stroke(this.options.line_color));
|
|
2361
|
+
this._line_active.setVisible(this.options.show_lines);
|
|
2362
|
+
break;
|
|
2363
|
+
}
|
|
2364
|
+
case "map_background_color":
|
|
2365
|
+
this._el.map.style.backgroundColor = this.options.map_background_color;
|
|
2366
|
+
break;
|
|
2367
|
+
case "map_bbox": {
|
|
2368
|
+
// recreate the view so the new extent constraint applies,
|
|
2369
|
+
// preserving center and zoom. The replacement has to
|
|
2370
|
+
// rebuild the *same* view configuration _createMap() uses —
|
|
2371
|
+
// dropping constrainOnlyCenter (which _createMap sets
|
|
2372
|
+
// deliberately, see above), the image zoom ladder or the
|
|
2373
|
+
// legacy zoomify multiWorld flag silently reintroduced the
|
|
2374
|
+
// strict extent constraint this code went out of its way
|
|
2375
|
+
// to avoid, and broke image-mode zoom (issue #465).
|
|
2376
|
+
const view = this._map.getView();
|
|
2377
|
+
const center = view.getCenter();
|
|
2378
|
+
const zoom = view.getZoom();
|
|
2379
|
+
const extent = this._bboxExtent();
|
|
2380
|
+
// must match _createMap's test exactly: a *georeferenced*
|
|
2381
|
+
// IIIF layer (map_as_image false) is a mercator map, so
|
|
2382
|
+
// testing map_type alone handed it the image-pixel view
|
|
2383
|
+
const is_image_map = this.options.map_type === "iiif" && this.isImageSpace();
|
|
2384
|
+
const user_view = this.options.map_options?.view as
|
|
2385
|
+
Record<string, unknown> | undefined;
|
|
2386
|
+
this._map.setView(
|
|
2387
|
+
new View({
|
|
2388
|
+
projection: is_image_map ? "EPSG:4326" : "EPSG:3857",
|
|
2389
|
+
center: center,
|
|
2390
|
+
zoom: zoom,
|
|
2391
|
+
minZoom: view.getMinZoom(),
|
|
2392
|
+
maxZoom: view.getMaxZoom(),
|
|
2393
|
+
...(is_image_map
|
|
2394
|
+
? { multiWorld: true, resolutions: IMAGE_RESOLUTIONS }
|
|
2395
|
+
: {}),
|
|
2396
|
+
...(this.options.map_type === "zoomify" ? { multiWorld: true } : {}),
|
|
2397
|
+
...(extent ? { extent: extent, constrainOnlyCenter: true } : {}),
|
|
2398
|
+
...((user_view ?? {}) as Record<string, unknown>),
|
|
2399
|
+
}),
|
|
2400
|
+
);
|
|
2401
|
+
break;
|
|
2402
|
+
}
|
|
2403
|
+
default:
|
|
2404
|
+
// map_center_offset, duration, ease, calculate_zoom etc.
|
|
2405
|
+
// take effect on the next navigation
|
|
2406
|
+
break;
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
// Re-fit the current view in case layer changes altered the rendering
|
|
2410
|
+
if (this._markers.length > 0 && this.current_marker < this._markers.length) {
|
|
2411
|
+
const marker = this._markers[this.current_marker];
|
|
2412
|
+
if (marker.data.type === "overview") {
|
|
2413
|
+
this._markerOverview();
|
|
2414
|
+
} else if (marker.data.location) {
|
|
2415
|
+
this._viewTo(marker.data.location, { duration: 0 });
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
_updateMapDisplay(animate?: boolean, d?: number, instant?: boolean): void {
|
|
2421
|
+
if (animate) {
|
|
2422
|
+
const duration = d ? d : this.options.duration;
|
|
2423
|
+
if (this.timer) {
|
|
2424
|
+
clearTimeout(this.timer);
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
this.timer = setTimeout(() => {
|
|
2428
|
+
this._refreshMap(false);
|
|
2429
|
+
}, duration);
|
|
2430
|
+
} else {
|
|
2431
|
+
if (!this.timer) {
|
|
2432
|
+
// size changes (resize/fullscreen) re-fit instantly so marker
|
|
2433
|
+
// overlays don't chase an animated view
|
|
2434
|
+
this._refreshMap(instant !== false);
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
if (this._mini_map) {
|
|
2439
|
+
this._mini_map.setCollapsed(this._el.container.offsetWidth < this.options.skinny_size);
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
_refreshMap(instant = false): void {
|
|
2444
|
+
if (this._map) {
|
|
2445
|
+
if (this.timer) {
|
|
2446
|
+
clearTimeout(this.timer);
|
|
2447
|
+
this.timer = null;
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
this._map.updateSize();
|
|
2451
|
+
|
|
2452
|
+
// Check to see if it's an overview
|
|
2453
|
+
const marker = this._markers[this.current_marker];
|
|
2454
|
+
if (marker && marker.data.type && marker.data.type === "overview") {
|
|
2455
|
+
this._markerOverview(instant ? 0 : undefined);
|
|
2456
|
+
if (instant) {
|
|
2457
|
+
this._map.renderSync();
|
|
2458
|
+
}
|
|
2459
|
+
} else if (marker && marker.data.location) {
|
|
2460
|
+
if (instant) {
|
|
2461
|
+
this._setViewInstant(marker.data.location, this._getMapZoom());
|
|
2462
|
+
} else {
|
|
2463
|
+
this._viewTo(marker.data.location, {
|
|
2464
|
+
zoom: this._getMapZoom(),
|
|
2465
|
+
});
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
/**
|
|
2472
|
+
* Set the view synchronously (resize path): OL's animate() applies its
|
|
2473
|
+
* end state asynchronously which can leave marker overlays rendering
|
|
2474
|
+
* stale positions after a size change.
|
|
2475
|
+
*/
|
|
2476
|
+
_setViewInstant(loc: StorymapSlideLocation, zoom: number): void {
|
|
2477
|
+
const start = this._latLngOf(loc);
|
|
2478
|
+
if (!start) {
|
|
2479
|
+
return;
|
|
2480
|
+
}
|
|
2481
|
+
let _location: LatLngLiteral = start;
|
|
2482
|
+
if (this.options.map_center_offset) {
|
|
2483
|
+
_location = this._getMapCenterOffset(_location, zoom);
|
|
2484
|
+
}
|
|
2485
|
+
const view = this._map.getView();
|
|
2486
|
+
view.setZoom(zoom);
|
|
2487
|
+
view.setCenter(this._toViewCoords(_location));
|
|
2488
|
+
this._map.renderSync();
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
export { OpenLayers };
|