@projektemacher/storymapjs 0.10.7 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (587) hide show
  1. package/CHANGELOG +431 -19
  2. package/README.md +168 -17
  3. package/dist/assets/demo-bWnOyfUw.js +1 -0
  4. package/dist/assets/harness-C3JUv5Ii.js +1 -0
  5. package/dist/assets/harness-multi-XZNex3tb.js +1 -0
  6. package/dist/assets/harness-support-CjfZIVeb.js +1 -0
  7. package/dist/assets/index-wHPqGrBS.js +1 -0
  8. package/dist/assets/main-DQ32hWBM.js +17 -0
  9. package/dist/context.json +23 -9
  10. package/dist/css/fonts/files/amatic-sc-cyrillic-400-normal.woff2 +0 -0
  11. package/dist/css/fonts/files/amatic-sc-cyrillic-700-normal.woff2 +0 -0
  12. package/dist/css/fonts/files/amatic-sc-hebrew-400-normal.woff2 +0 -0
  13. package/dist/css/fonts/files/amatic-sc-hebrew-700-normal.woff2 +0 -0
  14. package/dist/css/fonts/files/amatic-sc-vietnamese-400-normal.woff2 +0 -0
  15. package/dist/css/fonts/files/amatic-sc-vietnamese-700-normal.woff2 +0 -0
  16. package/dist/css/fonts/files/bitter-cyrillic-100-italic.woff2 +0 -0
  17. package/dist/css/fonts/files/bitter-cyrillic-100-normal.woff2 +0 -0
  18. package/dist/css/fonts/files/bitter-cyrillic-200-italic.woff2 +0 -0
  19. package/dist/css/fonts/files/bitter-cyrillic-200-normal.woff2 +0 -0
  20. package/dist/css/fonts/files/bitter-cyrillic-300-italic.woff2 +0 -0
  21. package/dist/css/fonts/files/bitter-cyrillic-300-normal.woff2 +0 -0
  22. package/dist/css/fonts/files/bitter-cyrillic-400-italic.woff2 +0 -0
  23. package/dist/css/fonts/files/bitter-cyrillic-400-normal.woff2 +0 -0
  24. package/dist/css/fonts/files/bitter-cyrillic-500-italic.woff2 +0 -0
  25. package/dist/css/fonts/files/bitter-cyrillic-500-normal.woff2 +0 -0
  26. package/dist/css/fonts/files/bitter-cyrillic-600-italic.woff2 +0 -0
  27. package/dist/css/fonts/files/bitter-cyrillic-600-normal.woff2 +0 -0
  28. package/dist/css/fonts/files/bitter-cyrillic-700-italic.woff2 +0 -0
  29. package/dist/css/fonts/files/bitter-cyrillic-700-normal.woff2 +0 -0
  30. package/dist/css/fonts/files/bitter-cyrillic-800-italic.woff2 +0 -0
  31. package/dist/css/fonts/files/bitter-cyrillic-800-normal.woff2 +0 -0
  32. package/dist/css/fonts/files/bitter-cyrillic-900-italic.woff2 +0 -0
  33. package/dist/css/fonts/files/bitter-cyrillic-900-normal.woff2 +0 -0
  34. package/dist/css/fonts/files/bitter-cyrillic-ext-100-italic.woff2 +0 -0
  35. package/dist/css/fonts/files/bitter-cyrillic-ext-100-normal.woff2 +0 -0
  36. package/dist/css/fonts/files/bitter-cyrillic-ext-200-italic.woff2 +0 -0
  37. package/dist/css/fonts/files/bitter-cyrillic-ext-200-normal.woff2 +0 -0
  38. package/dist/css/fonts/files/bitter-cyrillic-ext-300-italic.woff2 +0 -0
  39. package/dist/css/fonts/files/bitter-cyrillic-ext-300-normal.woff2 +0 -0
  40. package/dist/css/fonts/files/bitter-cyrillic-ext-400-italic.woff2 +0 -0
  41. package/dist/css/fonts/files/bitter-cyrillic-ext-400-normal.woff2 +0 -0
  42. package/dist/css/fonts/files/bitter-cyrillic-ext-500-italic.woff2 +0 -0
  43. package/dist/css/fonts/files/bitter-cyrillic-ext-500-normal.woff2 +0 -0
  44. package/dist/css/fonts/files/bitter-cyrillic-ext-600-italic.woff2 +0 -0
  45. package/dist/css/fonts/files/bitter-cyrillic-ext-600-normal.woff2 +0 -0
  46. package/dist/css/fonts/files/bitter-cyrillic-ext-700-italic.woff2 +0 -0
  47. package/dist/css/fonts/files/bitter-cyrillic-ext-700-normal.woff2 +0 -0
  48. package/dist/css/fonts/files/bitter-cyrillic-ext-800-italic.woff2 +0 -0
  49. package/dist/css/fonts/files/bitter-cyrillic-ext-800-normal.woff2 +0 -0
  50. package/dist/css/fonts/files/bitter-cyrillic-ext-900-italic.woff2 +0 -0
  51. package/dist/css/fonts/files/bitter-cyrillic-ext-900-normal.woff2 +0 -0
  52. package/dist/css/fonts/files/bitter-cyrillic-ext-wght-italic.woff2 +0 -0
  53. package/dist/css/fonts/files/bitter-cyrillic-ext-wght-normal.woff2 +0 -0
  54. package/dist/css/fonts/files/bitter-cyrillic-wght-italic.woff2 +0 -0
  55. package/dist/css/fonts/files/bitter-cyrillic-wght-normal.woff2 +0 -0
  56. package/dist/css/fonts/files/bitter-vietnamese-100-italic.woff2 +0 -0
  57. package/dist/css/fonts/files/bitter-vietnamese-100-normal.woff2 +0 -0
  58. package/dist/css/fonts/files/bitter-vietnamese-200-italic.woff2 +0 -0
  59. package/dist/css/fonts/files/bitter-vietnamese-200-normal.woff2 +0 -0
  60. package/dist/css/fonts/files/bitter-vietnamese-300-italic.woff2 +0 -0
  61. package/dist/css/fonts/files/bitter-vietnamese-300-normal.woff2 +0 -0
  62. package/dist/css/fonts/files/bitter-vietnamese-400-italic.woff2 +0 -0
  63. package/dist/css/fonts/files/bitter-vietnamese-400-normal.woff2 +0 -0
  64. package/dist/css/fonts/files/bitter-vietnamese-500-italic.woff2 +0 -0
  65. package/dist/css/fonts/files/bitter-vietnamese-500-normal.woff2 +0 -0
  66. package/dist/css/fonts/files/bitter-vietnamese-600-italic.woff2 +0 -0
  67. package/dist/css/fonts/files/bitter-vietnamese-600-normal.woff2 +0 -0
  68. package/dist/css/fonts/files/bitter-vietnamese-700-italic.woff2 +0 -0
  69. package/dist/css/fonts/files/bitter-vietnamese-700-normal.woff2 +0 -0
  70. package/dist/css/fonts/files/bitter-vietnamese-800-italic.woff2 +0 -0
  71. package/dist/css/fonts/files/bitter-vietnamese-800-normal.woff2 +0 -0
  72. package/dist/css/fonts/files/bitter-vietnamese-900-italic.woff2 +0 -0
  73. package/dist/css/fonts/files/bitter-vietnamese-900-normal.woff2 +0 -0
  74. package/dist/css/fonts/files/bitter-vietnamese-wght-italic.woff2 +0 -0
  75. package/dist/css/fonts/files/bitter-vietnamese-wght-normal.woff2 +0 -0
  76. package/dist/css/fonts/files/cairo-arabic-200-normal.woff2 +0 -0
  77. package/dist/css/fonts/files/cairo-arabic-300-normal.woff2 +0 -0
  78. package/dist/css/fonts/files/cairo-arabic-400-normal.woff2 +0 -0
  79. package/dist/css/fonts/files/cairo-arabic-500-normal.woff2 +0 -0
  80. package/dist/css/fonts/files/cairo-arabic-600-normal.woff2 +0 -0
  81. package/dist/css/fonts/files/cairo-arabic-700-normal.woff2 +0 -0
  82. package/dist/css/fonts/files/cairo-arabic-800-normal.woff2 +0 -0
  83. package/dist/css/fonts/files/cairo-arabic-900-normal.woff2 +0 -0
  84. package/dist/css/fonts/files/cairo-latin-200-normal.woff2 +0 -0
  85. package/dist/css/fonts/files/cairo-latin-300-normal.woff2 +0 -0
  86. package/dist/css/fonts/files/cairo-latin-400-normal.woff2 +0 -0
  87. package/dist/css/fonts/files/cairo-latin-500-normal.woff2 +0 -0
  88. package/dist/css/fonts/files/cairo-latin-600-normal.woff2 +0 -0
  89. package/dist/css/fonts/files/cairo-latin-700-normal.woff2 +0 -0
  90. package/dist/css/fonts/files/cairo-latin-800-normal.woff2 +0 -0
  91. package/dist/css/fonts/files/cairo-latin-900-normal.woff2 +0 -0
  92. package/dist/css/fonts/files/cairo-latin-ext-200-normal.woff2 +0 -0
  93. package/dist/css/fonts/files/cairo-latin-ext-300-normal.woff2 +0 -0
  94. package/dist/css/fonts/files/cairo-latin-ext-400-normal.woff2 +0 -0
  95. package/dist/css/fonts/files/cairo-latin-ext-500-normal.woff2 +0 -0
  96. package/dist/css/fonts/files/cairo-latin-ext-600-normal.woff2 +0 -0
  97. package/dist/css/fonts/files/cairo-latin-ext-700-normal.woff2 +0 -0
  98. package/dist/css/fonts/files/cairo-latin-ext-800-normal.woff2 +0 -0
  99. package/dist/css/fonts/files/cairo-latin-ext-900-normal.woff2 +0 -0
  100. package/dist/css/fonts/files/dancing-script-vietnamese-wght-normal.woff2 +0 -0
  101. package/dist/css/fonts/files/eb-garamond-cyrillic-ext-wght-italic.woff2 +0 -0
  102. package/dist/css/fonts/files/eb-garamond-cyrillic-ext-wght-normal.woff2 +0 -0
  103. package/dist/css/fonts/files/eb-garamond-cyrillic-wght-italic.woff2 +0 -0
  104. package/dist/css/fonts/files/eb-garamond-cyrillic-wght-normal.woff2 +0 -0
  105. package/dist/css/fonts/files/eb-garamond-greek-ext-wght-italic.woff2 +0 -0
  106. package/dist/css/fonts/files/eb-garamond-greek-ext-wght-normal.woff2 +0 -0
  107. package/dist/css/fonts/files/eb-garamond-greek-wght-italic.woff2 +0 -0
  108. package/dist/css/fonts/files/eb-garamond-greek-wght-normal.woff2 +0 -0
  109. package/dist/css/fonts/files/eb-garamond-vietnamese-wght-italic.woff2 +0 -0
  110. package/dist/css/fonts/files/eb-garamond-vietnamese-wght-normal.woff2 +0 -0
  111. package/dist/css/fonts/files/gentium-book-plus-cyrillic-400-italic.woff2 +0 -0
  112. package/dist/css/fonts/files/gentium-book-plus-cyrillic-400-normal.woff2 +0 -0
  113. package/dist/css/fonts/files/gentium-book-plus-cyrillic-700-italic.woff2 +0 -0
  114. package/dist/css/fonts/files/gentium-book-plus-cyrillic-700-normal.woff2 +0 -0
  115. package/dist/css/fonts/files/gentium-book-plus-cyrillic-ext-400-italic.woff2 +0 -0
  116. package/dist/css/fonts/files/gentium-book-plus-cyrillic-ext-400-normal.woff2 +0 -0
  117. package/dist/css/fonts/files/gentium-book-plus-cyrillic-ext-700-italic.woff2 +0 -0
  118. package/dist/css/fonts/files/gentium-book-plus-cyrillic-ext-700-normal.woff2 +0 -0
  119. package/dist/css/fonts/files/gentium-book-plus-greek-400-italic.woff2 +0 -0
  120. package/dist/css/fonts/files/gentium-book-plus-greek-400-normal.woff2 +0 -0
  121. package/dist/css/fonts/files/gentium-book-plus-greek-700-italic.woff2 +0 -0
  122. package/dist/css/fonts/files/gentium-book-plus-greek-700-normal.woff2 +0 -0
  123. package/dist/css/fonts/files/gentium-book-plus-greek-ext-400-italic.woff2 +0 -0
  124. package/dist/css/fonts/files/gentium-book-plus-greek-ext-400-normal.woff2 +0 -0
  125. package/dist/css/fonts/files/gentium-book-plus-greek-ext-700-italic.woff2 +0 -0
  126. package/dist/css/fonts/files/gentium-book-plus-greek-ext-700-normal.woff2 +0 -0
  127. package/dist/css/fonts/files/gentium-book-plus-vietnamese-400-italic.woff2 +0 -0
  128. package/dist/css/fonts/files/gentium-book-plus-vietnamese-400-normal.woff2 +0 -0
  129. package/dist/css/fonts/files/gentium-book-plus-vietnamese-700-italic.woff2 +0 -0
  130. package/dist/css/fonts/files/gentium-book-plus-vietnamese-700-normal.woff2 +0 -0
  131. package/dist/css/fonts/files/old-standard-tt-cyrillic-400-italic.woff2 +0 -0
  132. package/dist/css/fonts/files/old-standard-tt-cyrillic-400-normal.woff2 +0 -0
  133. package/dist/css/fonts/files/old-standard-tt-cyrillic-700-normal.woff2 +0 -0
  134. package/dist/css/fonts/files/old-standard-tt-cyrillic-ext-400-italic.woff2 +0 -0
  135. package/dist/css/fonts/files/old-standard-tt-cyrillic-ext-400-normal.woff2 +0 -0
  136. package/dist/css/fonts/files/old-standard-tt-cyrillic-ext-700-normal.woff2 +0 -0
  137. package/dist/css/fonts/files/old-standard-tt-vietnamese-400-italic.woff2 +0 -0
  138. package/dist/css/fonts/files/old-standard-tt-vietnamese-400-normal.woff2 +0 -0
  139. package/dist/css/fonts/files/old-standard-tt-vietnamese-700-normal.woff2 +0 -0
  140. package/dist/css/fonts/files/open-sans-cyrillic-ext-wght-italic.woff2 +0 -0
  141. package/dist/css/fonts/files/open-sans-cyrillic-ext-wght-normal.woff2 +0 -0
  142. package/dist/css/fonts/files/open-sans-cyrillic-wght-italic.woff2 +0 -0
  143. package/dist/css/fonts/files/open-sans-cyrillic-wght-normal.woff2 +0 -0
  144. package/dist/css/fonts/files/open-sans-greek-ext-wght-italic.woff2 +0 -0
  145. package/dist/css/fonts/files/open-sans-greek-ext-wght-normal.woff2 +0 -0
  146. package/dist/css/fonts/files/open-sans-greek-wght-italic.woff2 +0 -0
  147. package/dist/css/fonts/files/open-sans-greek-wght-normal.woff2 +0 -0
  148. package/dist/css/fonts/files/open-sans-hebrew-wght-italic.woff2 +0 -0
  149. package/dist/css/fonts/files/open-sans-hebrew-wght-normal.woff2 +0 -0
  150. package/dist/css/fonts/files/open-sans-vietnamese-wght-italic.woff2 +0 -0
  151. package/dist/css/fonts/files/open-sans-vietnamese-wght-normal.woff2 +0 -0
  152. package/dist/css/fonts/files/playfair-display-cyrillic-wght-italic.woff2 +0 -0
  153. package/dist/css/fonts/files/playfair-display-cyrillic-wght-normal.woff2 +0 -0
  154. package/dist/css/fonts/files/playfair-display-sc-cyrillic-400-italic.woff2 +0 -0
  155. package/dist/css/fonts/files/playfair-display-sc-cyrillic-400-normal.woff2 +0 -0
  156. package/dist/css/fonts/files/playfair-display-sc-cyrillic-700-italic.woff2 +0 -0
  157. package/dist/css/fonts/files/playfair-display-sc-cyrillic-700-normal.woff2 +0 -0
  158. package/dist/css/fonts/files/playfair-display-sc-cyrillic-900-italic.woff2 +0 -0
  159. package/dist/css/fonts/files/playfair-display-sc-cyrillic-900-normal.woff2 +0 -0
  160. package/dist/css/fonts/files/playfair-display-sc-vietnamese-400-italic.woff2 +0 -0
  161. package/dist/css/fonts/files/playfair-display-sc-vietnamese-400-normal.woff2 +0 -0
  162. package/dist/css/fonts/files/playfair-display-sc-vietnamese-700-italic.woff2 +0 -0
  163. package/dist/css/fonts/files/playfair-display-sc-vietnamese-700-normal.woff2 +0 -0
  164. package/dist/css/fonts/files/playfair-display-sc-vietnamese-900-italic.woff2 +0 -0
  165. package/dist/css/fonts/files/playfair-display-sc-vietnamese-900-normal.woff2 +0 -0
  166. package/dist/css/fonts/files/playfair-display-vietnamese-wght-italic.woff2 +0 -0
  167. package/dist/css/fonts/files/playfair-display-vietnamese-wght-normal.woff2 +0 -0
  168. package/dist/css/fonts/files/pt-sans-cyrillic-400-italic.woff2 +0 -0
  169. package/dist/css/fonts/files/pt-sans-cyrillic-400-normal.woff2 +0 -0
  170. package/dist/css/fonts/files/pt-sans-cyrillic-700-italic.woff2 +0 -0
  171. package/dist/css/fonts/files/pt-sans-cyrillic-700-normal.woff2 +0 -0
  172. package/dist/css/fonts/files/pt-sans-cyrillic-ext-400-italic.woff2 +0 -0
  173. package/dist/css/fonts/files/pt-sans-cyrillic-ext-400-normal.woff2 +0 -0
  174. package/dist/css/fonts/files/pt-sans-cyrillic-ext-700-italic.woff2 +0 -0
  175. package/dist/css/fonts/files/pt-sans-cyrillic-ext-700-normal.woff2 +0 -0
  176. package/dist/css/fonts/files/pt-sans-narrow-cyrillic-400-normal.woff2 +0 -0
  177. package/dist/css/fonts/files/pt-sans-narrow-cyrillic-700-normal.woff2 +0 -0
  178. package/dist/css/fonts/files/pt-sans-narrow-cyrillic-ext-400-normal.woff2 +0 -0
  179. package/dist/css/fonts/files/pt-sans-narrow-cyrillic-ext-700-normal.woff2 +0 -0
  180. package/dist/css/fonts/files/pt-serif-cyrillic-400-italic.woff2 +0 -0
  181. package/dist/css/fonts/files/pt-serif-cyrillic-400-normal.woff2 +0 -0
  182. package/dist/css/fonts/files/pt-serif-cyrillic-700-italic.woff2 +0 -0
  183. package/dist/css/fonts/files/pt-serif-cyrillic-700-normal.woff2 +0 -0
  184. package/dist/css/fonts/files/pt-serif-cyrillic-ext-400-italic.woff2 +0 -0
  185. package/dist/css/fonts/files/pt-serif-cyrillic-ext-400-normal.woff2 +0 -0
  186. package/dist/css/fonts/files/pt-serif-cyrillic-ext-700-italic.woff2 +0 -0
  187. package/dist/css/fonts/files/pt-serif-cyrillic-ext-700-normal.woff2 +0 -0
  188. package/dist/css/fonts/files/raleway-cyrillic-ext-wght-italic.woff2 +0 -0
  189. package/dist/css/fonts/files/raleway-cyrillic-ext-wght-normal.woff2 +0 -0
  190. package/dist/css/fonts/files/raleway-cyrillic-wght-italic.woff2 +0 -0
  191. package/dist/css/fonts/files/raleway-cyrillic-wght-normal.woff2 +0 -0
  192. package/dist/css/fonts/files/raleway-vietnamese-wght-italic.woff2 +0 -0
  193. package/dist/css/fonts/files/raleway-vietnamese-wght-normal.woff2 +0 -0
  194. package/dist/css/fonts/files/roboto-slab-cyrillic-ext-wght-normal.woff2 +0 -0
  195. package/dist/css/fonts/files/roboto-slab-cyrillic-wght-normal.woff2 +0 -0
  196. package/dist/css/fonts/files/roboto-slab-greek-ext-wght-normal.woff2 +0 -0
  197. package/dist/css/fonts/files/roboto-slab-greek-wght-normal.woff2 +0 -0
  198. package/dist/css/fonts/files/roboto-slab-vietnamese-wght-normal.woff2 +0 -0
  199. package/dist/css/fonts/files/ubuntu-cyrillic-300-italic.woff2 +0 -0
  200. package/dist/css/fonts/files/ubuntu-cyrillic-300-normal.woff2 +0 -0
  201. package/dist/css/fonts/files/ubuntu-cyrillic-400-italic.woff2 +0 -0
  202. package/dist/css/fonts/files/ubuntu-cyrillic-400-normal.woff2 +0 -0
  203. package/dist/css/fonts/files/ubuntu-cyrillic-500-italic.woff2 +0 -0
  204. package/dist/css/fonts/files/ubuntu-cyrillic-500-normal.woff2 +0 -0
  205. package/dist/css/fonts/files/ubuntu-cyrillic-700-italic.woff2 +0 -0
  206. package/dist/css/fonts/files/ubuntu-cyrillic-700-normal.woff2 +0 -0
  207. package/dist/css/fonts/files/ubuntu-cyrillic-ext-300-italic.woff2 +0 -0
  208. package/dist/css/fonts/files/ubuntu-cyrillic-ext-300-normal.woff2 +0 -0
  209. package/dist/css/fonts/files/ubuntu-cyrillic-ext-400-italic.woff2 +0 -0
  210. package/dist/css/fonts/files/ubuntu-cyrillic-ext-400-normal.woff2 +0 -0
  211. package/dist/css/fonts/files/ubuntu-cyrillic-ext-500-italic.woff2 +0 -0
  212. package/dist/css/fonts/files/ubuntu-cyrillic-ext-500-normal.woff2 +0 -0
  213. package/dist/css/fonts/files/ubuntu-cyrillic-ext-700-italic.woff2 +0 -0
  214. package/dist/css/fonts/files/ubuntu-cyrillic-ext-700-normal.woff2 +0 -0
  215. package/dist/css/fonts/files/ubuntu-greek-300-italic.woff2 +0 -0
  216. package/dist/css/fonts/files/ubuntu-greek-300-normal.woff2 +0 -0
  217. package/dist/css/fonts/files/ubuntu-greek-400-italic.woff2 +0 -0
  218. package/dist/css/fonts/files/ubuntu-greek-400-normal.woff2 +0 -0
  219. package/dist/css/fonts/files/ubuntu-greek-500-italic.woff2 +0 -0
  220. package/dist/css/fonts/files/ubuntu-greek-500-normal.woff2 +0 -0
  221. package/dist/css/fonts/files/ubuntu-greek-700-italic.woff2 +0 -0
  222. package/dist/css/fonts/files/ubuntu-greek-700-normal.woff2 +0 -0
  223. package/dist/css/fonts/files/ubuntu-greek-ext-300-italic.woff2 +0 -0
  224. package/dist/css/fonts/files/ubuntu-greek-ext-300-normal.woff2 +0 -0
  225. package/dist/css/fonts/files/ubuntu-greek-ext-400-italic.woff2 +0 -0
  226. package/dist/css/fonts/files/ubuntu-greek-ext-400-normal.woff2 +0 -0
  227. package/dist/css/fonts/files/ubuntu-greek-ext-500-italic.woff2 +0 -0
  228. package/dist/css/fonts/files/ubuntu-greek-ext-500-normal.woff2 +0 -0
  229. package/dist/css/fonts/files/ubuntu-greek-ext-700-italic.woff2 +0 -0
  230. package/dist/css/fonts/files/ubuntu-greek-ext-700-normal.woff2 +0 -0
  231. package/dist/css/fonts/files/unica-one-vietnamese-400-normal.woff2 +0 -0
  232. package/dist/css/fonts/files/vollkorn-cyrillic-ext-wght-italic.woff2 +0 -0
  233. package/dist/css/fonts/files/vollkorn-cyrillic-ext-wght-normal.woff2 +0 -0
  234. package/dist/css/fonts/files/vollkorn-cyrillic-wght-italic.woff2 +0 -0
  235. package/dist/css/fonts/files/vollkorn-cyrillic-wght-normal.woff2 +0 -0
  236. package/dist/css/fonts/files/vollkorn-greek-wght-italic.woff2 +0 -0
  237. package/dist/css/fonts/files/vollkorn-greek-wght-normal.woff2 +0 -0
  238. package/dist/css/fonts/files/vollkorn-vietnamese-wght-italic.woff2 +0 -0
  239. package/dist/css/fonts/files/vollkorn-vietnamese-wght-normal.woff2 +0 -0
  240. package/dist/css/fonts/font.abril-droidsans.css +3 -0
  241. package/dist/css/fonts/font.amatic-andika.css +57 -0
  242. package/dist/css/fonts/font.bitter-raleway.css +111 -0
  243. package/dist/css/fonts/font.cairo.css +312 -0
  244. package/dist/css/fonts/font.clicker-garamond.css +93 -0
  245. package/dist/css/fonts/font.dancing-ledger.css +12 -0
  246. package/dist/css/fonts/font.default.css +183 -0
  247. package/dist/css/fonts/font.fjalla-average.css +3 -0
  248. package/dist/css/fonts/font.georgia-helvetica.css +3 -0
  249. package/dist/css/fonts/font.knightlab.css +642 -0
  250. package/dist/css/fonts/font.lustria-lato.css +3 -0
  251. package/dist/css/fonts/font.medula-lato.css +3 -0
  252. package/dist/css/fonts/font.oldstandard.css +84 -0
  253. package/dist/css/fonts/font.opensans-gentiumbook.css +183 -0
  254. package/dist/css/fonts/font.playfair-faunaone.css +39 -0
  255. package/dist/css/fonts/font.playfair.css +147 -0
  256. package/dist/css/fonts/font.pt.css +183 -0
  257. package/dist/css/fonts/font.roboto-megrim.css +3 -0
  258. package/dist/css/fonts/font.rufina-sintony.css +3 -0
  259. package/dist/css/fonts/font.ubuntu.css +291 -0
  260. package/dist/css/fonts/font.unicaone-vollkorn.css +84 -0
  261. package/dist/css/storymap.css +1 -1
  262. package/dist/demo.html +3 -2
  263. package/dist/docs/api/.nojekyll +1 -0
  264. package/dist/docs/api/assets/custom.css +47 -0
  265. package/dist/docs/api/assets/hierarchy.js +1 -0
  266. package/dist/docs/api/assets/highlight.css +57 -0
  267. package/dist/docs/api/assets/icons.js +18 -0
  268. package/dist/docs/api/assets/icons.svg +1 -0
  269. package/dist/docs/api/assets/main.js +60 -0
  270. package/dist/docs/api/assets/navigation.js +1 -0
  271. package/dist/docs/api/assets/search.js +1 -0
  272. package/dist/docs/api/assets/style.css +1648 -0
  273. package/dist/docs/api/classes/StoryMap.html +2112 -0
  274. package/dist/docs/api/functions/MediaType.html +51 -0
  275. package/dist/docs/api/functions/isPresentation3Collection.html +47 -0
  276. package/dist/docs/api/functions/isPresentation3Manifest.html +53 -0
  277. package/dist/docs/api/functions/isSlideshowCollection.html +51 -0
  278. package/dist/docs/api/functions/loadCSS.html +63 -0
  279. package/dist/docs/api/functions/manifestToStorymapData.html +49 -0
  280. package/dist/docs/api/functions/setLanguage.html +54 -0
  281. package/dist/docs/api/functions/slideshowToStorymapData.html +51 -0
  282. package/dist/docs/api/functions/slideshowVersion.html +48 -0
  283. package/dist/docs/api/functions/storymapToManifest.html +57 -0
  284. package/dist/docs/api/functions/validateStorymap.html +53 -0
  285. package/dist/docs/api/functions/validateStorymapAndReport.html +54 -0
  286. package/dist/docs/api/hierarchy.html +28 -0
  287. package/dist/docs/api/index.html +300 -0
  288. package/dist/docs/api/interfaces/AnimationHandle.html +60 -0
  289. package/dist/docs/api/interfaces/ImagereadyPayload.html +71 -0
  290. package/dist/docs/api/interfaces/LanguageEntry.html +102 -0
  291. package/dist/docs/api/interfaces/LatLngLiteral.html +73 -0
  292. package/dist/docs/api/interfaces/LoadOptions.html +59 -0
  293. package/dist/docs/api/interfaces/MapEvents.html +116 -0
  294. package/dist/docs/api/interfaces/MapMarkerEvents.html +66 -0
  295. package/dist/docs/api/interfaces/MarkerEventPayload.html +57 -0
  296. package/dist/docs/api/interfaces/MediaEvents.html +78 -0
  297. package/dist/docs/api/interfaces/MenuBarEvents.html +114 -0
  298. package/dist/docs/api/interfaces/MessageEvents.html +72 -0
  299. package/dist/docs/api/interfaces/OlLayer.html +1400 -0
  300. package/dist/docs/api/interfaces/OlMap.html +1656 -0
  301. package/dist/docs/api/interfaces/OlProjection.html +332 -0
  302. package/dist/docs/api/interfaces/OlSource.html +784 -0
  303. package/dist/docs/api/interfaces/OlTileLayer.html +1497 -0
  304. package/dist/docs/api/interfaces/OlVectorLayer.html +1559 -0
  305. package/dist/docs/api/interfaces/OlView.html +1958 -0
  306. package/dist/docs/api/interfaces/SlideBackgroundState.html +66 -0
  307. package/dist/docs/api/interfaces/SlideEvents.html +78 -0
  308. package/dist/docs/api/interfaces/SlideNavEvents.html +72 -0
  309. package/dist/docs/api/interfaces/SlideshowPlayerSettings.html +182 -0
  310. package/dist/docs/api/interfaces/SlideshowToStorymapResult.html +60 -0
  311. package/dist/docs/api/interfaces/SlideshowTranslatorOptions.html +65 -0
  312. package/dist/docs/api/interfaces/SlideshowWarning.html +72 -0
  313. package/dist/docs/api/interfaces/StoryMapEvents.html +146 -0
  314. package/dist/docs/api/interfaces/StoryMapMarker.html +763 -0
  315. package/dist/docs/api/interfaces/StorySliderEvents.html +102 -0
  316. package/dist/docs/api/interfaces/StorymapBasemap.html +69 -0
  317. package/dist/docs/api/interfaces/StorymapData.html +77 -0
  318. package/dist/docs/api/interfaces/StorymapDataWrapper.html +60 -0
  319. package/dist/docs/api/interfaces/StorymapDocument.html +68 -0
  320. package/dist/docs/api/interfaces/StorymapError.html +63 -0
  321. package/dist/docs/api/interfaces/StorymapGeoreference.html +93 -0
  322. package/dist/docs/api/interfaces/StorymapGeoreferenceBody.html +74 -0
  323. package/dist/docs/api/interfaces/StorymapGroundControlPoint.html +69 -0
  324. package/dist/docs/api/interfaces/StorymapLanguageMap.html +57 -0
  325. package/dist/docs/api/interfaces/StorymapManifest.html +118 -0
  326. package/dist/docs/api/interfaces/StorymapManifestAnnotation.html +137 -0
  327. package/dist/docs/api/interfaces/StorymapManifestBackground.html +81 -0
  328. package/dist/docs/api/interfaces/StorymapManifestCanvas.html +188 -0
  329. package/dist/docs/api/interfaces/StorymapManifestContentResource.html +97 -0
  330. package/dist/docs/api/interfaces/StorymapManifestFeatureCollection.html +82 -0
  331. package/dist/docs/api/interfaces/StorymapManifestGeoreferencing.html +87 -0
  332. package/dist/docs/api/interfaces/StorymapManifestImageService.html +69 -0
  333. package/dist/docs/api/interfaces/StorymapManifestRange.html +75 -0
  334. package/dist/docs/api/interfaces/StorymapManifestService.html +77 -0
  335. package/dist/docs/api/interfaces/StorymapManifestSpecificResource.html +69 -0
  336. package/dist/docs/api/interfaces/StorymapManifestStatement.html +63 -0
  337. package/dist/docs/api/interfaces/StorymapMapOptions.html +92 -0
  338. package/dist/docs/api/interfaces/StorymapOptions.html +734 -0
  339. package/dist/docs/api/interfaces/StorymapOverlayLayer.html +157 -0
  340. package/dist/docs/api/interfaces/StorymapSlide.html +186 -0
  341. package/dist/docs/api/interfaces/StorymapSlideBackground.html +72 -0
  342. package/dist/docs/api/interfaces/StorymapSlideLocation.html +161 -0
  343. package/dist/docs/api/interfaces/StorymapSlideMarker.html +103 -0
  344. package/dist/docs/api/interfaces/StorymapSlideMedia.html +149 -0
  345. package/dist/docs/api/interfaces/StorymapSlideNarration.html +117 -0
  346. package/dist/docs/api/interfaces/StorymapSlideText.html +72 -0
  347. package/dist/docs/api/interfaces/StorymapTilejson.html +105 -0
  348. package/dist/docs/api/interfaces/SwipableEvents.html +102 -0
  349. package/dist/docs/api/interfaces/TextEvents.html +66 -0
  350. package/dist/docs/api/media/es.json +31 -0
  351. package/dist/docs/api/media/events.md +66 -0
  352. package/dist/docs/api/media/migration-from-knightlab.md +399 -0
  353. package/dist/docs/api/media/slideshow.md +109 -0
  354. package/dist/docs/api/media/storymap-as-iiif-manifest.md +1007 -0
  355. package/dist/docs/api/modules.html +50 -0
  356. package/dist/docs/api/types/ContentState.html +70 -0
  357. package/dist/docs/api/types/FiredEvent.html +43 -0
  358. package/dist/docs/api/types/ImagereadyKind.html +34 -0
  359. package/dist/docs/api/types/MediaTypeMatch.html +78 -0
  360. package/dist/docs/api/types/SwipeDirection.html +36 -0
  361. package/dist/docs/api/types/TileSourceFactory.html +53 -0
  362. package/dist/docs/authoring.html +306 -0
  363. package/dist/docs/events.html +263 -0
  364. package/dist/docs/iiif.html +738 -69
  365. package/dist/docs/migration.html +360 -11
  366. package/dist/docs/readme.html +149 -17
  367. package/dist/embed/index.html +8 -1
  368. package/dist/examples/font-css-false.json +33 -0
  369. package/dist/examples/issue-242-nonlatin.json +55 -0
  370. package/dist/examples/issue-305-start-at-slide-data.json +96 -0
  371. package/dist/examples/issue-358-iframe.json +57 -0
  372. package/dist/examples/issue-iiif-geo.json +32 -0
  373. package/dist/examples/issue-image-alt.json +23 -0
  374. package/dist/examples/issue-image-region.json +42 -0
  375. package/dist/examples/issue-image-srcset.json +24 -0
  376. package/dist/examples/layers-control.json +60 -0
  377. package/dist/examples/map-area-overlays.json +68 -0
  378. package/dist/examples/map-template-provider.json +36 -0
  379. package/dist/examples/multi-media.json +71 -0
  380. package/dist/examples/no-map.json +44 -0
  381. package/dist/examples-iiif/annotated-image.json +204 -0
  382. package/dist/examples-iiif/bob-boilen.json +11 -5
  383. package/dist/examples-iiif/courbet.json +9 -6
  384. package/dist/examples-iiif/empty.json +8 -4
  385. package/dist/examples-iiif/flickr-drewvigal.json +248 -24
  386. package/dist/examples-iiif/flickr-zach.wise.json +8 -4
  387. package/dist/examples-iiif/font-css-false.json +114 -0
  388. package/dist/examples-iiif/georeferenced-layer-unsupported.json +153 -0
  389. package/dist/examples-iiif/georeferenced-layer.json +318 -0
  390. package/dist/examples-iiif/iiif-wellcome.json +9 -6
  391. package/dist/examples-iiif/instagram-chenpamela.json +381 -36
  392. package/dist/examples-iiif/instagram_brentfoster.json +429 -40
  393. package/dist/examples-iiif/instagram_couch.json +261 -26
  394. package/dist/examples-iiif/instagram_joegermuska.json +441 -41
  395. package/dist/examples-iiif/instagram_matteich.json +261 -26
  396. package/dist/examples-iiif/instagram_mirandamulligan.json +261 -26
  397. package/dist/examples-iiif/instagram_raymondmccrea.json +261 -26
  398. package/dist/examples-iiif/instagram_test.json +261 -26
  399. package/dist/examples-iiif/issue-134-no-lines.json +9 -5
  400. package/dist/examples-iiif/issue-177-color-vars.json +9 -5
  401. package/dist/examples-iiif/issue-211-rtl.json +9 -5
  402. package/dist/examples-iiif/issue-242-nonlatin.json +239 -0
  403. package/dist/examples-iiif/issue-272-facebook.json +9 -5
  404. package/dist/examples-iiif/issue-305-start-at-slide-data.json +305 -0
  405. package/dist/examples-iiif/issue-305-start-at-slide.json +9 -5
  406. package/dist/examples-iiif/issue-341-distance.json +9 -5
  407. package/dist/examples-iiif/issue-358-iframe.json +190 -0
  408. package/dist/examples-iiif/issue-360-juxtapose.json +22 -7
  409. package/dist/examples-iiif/issue-368-null-fields.json +9 -5
  410. package/dist/examples-iiif/issue-375-code-lists.json +9 -5
  411. package/dist/examples-iiif/issue-381-dateline.json +9 -5
  412. package/dist/examples-iiif/issue-405-custom-icon.json +203 -0
  413. package/dist/examples-iiif/issue-425-many-slides.json +9 -5
  414. package/dist/examples-iiif/issue-428-background.json +20 -8
  415. package/dist/examples-iiif/issue-435-googlemaps.json +9 -5
  416. package/dist/examples-iiif/issue-437-documentcloud.json +22 -7
  417. package/dist/examples-iiif/issue-451-webp.json +9 -5
  418. package/dist/examples-iiif/issue-455-audio.json +9 -5
  419. package/dist/examples-iiif/issue-480-shorts.json +9 -5
  420. package/dist/examples-iiif/issue-506-marker-sync.json +9 -5
  421. package/dist/examples-iiif/issue-iiif-geo.json +200 -0
  422. package/dist/examples-iiif/issue-image-alt.json +90 -0
  423. package/dist/examples-iiif/issue-image-region.json +203 -0
  424. package/dist/examples-iiif/issue-image-srcset.json +87 -0
  425. package/dist/examples-iiif/jansteen.json +9 -6
  426. package/dist/examples-iiif/katrina.json +197 -41
  427. package/dist/examples-iiif/layers-control.json +226 -0
  428. package/dist/examples-iiif/map-area-overlays.json +289 -0
  429. package/dist/examples-iiif/map-template-provider.json +133 -0
  430. package/dist/examples-iiif/marktwain.json +255 -58
  431. package/dist/examples-iiif/multi-media.json +237 -0
  432. package/dist/examples-iiif/no-map.json +148 -0
  433. package/dist/examples-iiif/population.json +440 -76
  434. package/dist/examples-iiif/president.json +456 -83
  435. package/dist/examples-iiif/scroll-hint.json +190 -0
  436. package/dist/examples-iiif/seurat.json +113 -22
  437. package/dist/examples-iiif/zoomify-clamp.json +153 -0
  438. package/dist/examples-slideshow/single.json +79 -0
  439. package/dist/examples-slideshow/static/pixel.png +0 -0
  440. package/dist/examples-slideshow/static.json +44 -0
  441. package/dist/harness-multi.html +17 -0
  442. package/dist/harness.html +4 -2
  443. package/dist/index.html +19 -12
  444. package/dist/js/storymap.d.ts +2669 -307
  445. package/dist/js/storymap.js +10820 -6196
  446. package/dist/js/storymap.js.map +1 -1
  447. package/dist/navplace-properties.json +54 -0
  448. package/dist/site.css +54 -36
  449. package/docs/migration-from-knightlab.md +279 -19
  450. package/docs/storymap-as-iiif-manifest.md +633 -136
  451. package/package.json +12 -7
  452. package/schema/storymap.schema.json +733 -80
  453. package/src/animation/easings.ts +8 -1
  454. package/src/animation/tween.ts +186 -0
  455. package/src/core/Browser.ts +6 -5
  456. package/src/core/Load.ts +54 -2
  457. package/src/core/Util.ts +257 -193
  458. package/src/core/mixins.ts +66 -35
  459. package/src/core/viewers.ts +94 -0
  460. package/src/dom/DomEvent.ts +27 -8
  461. package/src/globals.d.ts +8 -0
  462. package/src/language/Language.ts +206 -25
  463. package/src/language/locale/.expected-gaps.json +346 -0
  464. package/src/language/locale/be.json +15 -2
  465. package/src/language/locale/bg.json +15 -2
  466. package/src/language/locale/cs.json +15 -2
  467. package/src/language/locale/de.json +23 -2
  468. package/src/language/locale/el.json +15 -2
  469. package/src/language/locale/en.json +17 -3
  470. package/src/language/locale/es.json +15 -2
  471. package/src/language/locale/et.json +5 -1
  472. package/src/language/locale/fr.json +15 -2
  473. package/src/language/locale/he.json +16 -3
  474. package/src/language/locale/hu.json +15 -2
  475. package/src/language/locale/is.json +5 -1
  476. package/src/language/locale/it.json +15 -2
  477. package/src/language/locale/jp.json +15 -2
  478. package/src/language/locale/ko.json +15 -2
  479. package/src/language/locale/nl.json +15 -2
  480. package/src/language/locale/nn.json +5 -1
  481. package/src/language/locale/no.json +15 -2
  482. package/src/language/locale/pl.json +15 -2
  483. package/src/language/locale/pt.json +15 -2
  484. package/src/language/locale/ru.json +15 -2
  485. package/src/language/locale/sk.json +15 -2
  486. package/src/language/locale/sr.json +5 -1
  487. package/src/language/locale/sv.json +15 -2
  488. package/src/language/locale/tr.json +15 -2
  489. package/src/language/locale/uk.json +15 -2
  490. package/src/language/locale/ur.json +5 -1
  491. package/src/language/locale/zh-cn.json +15 -2
  492. package/src/language/locale/zh-tw.json +15 -2
  493. package/src/main.ts +143 -0
  494. package/src/map/Map.ts +227 -112
  495. package/src/map/MapMarker.ts +44 -8
  496. package/src/map/georeference.ts +325 -0
  497. package/src/map/openlayers/Map.OpenLayers.ts +1601 -263
  498. package/src/map/openlayers/MapMarker.OpenLayers.ts +348 -24
  499. package/src/map/openlayers/zoomifyTiles.ts +1 -4
  500. package/src/map/slideView.ts +149 -0
  501. package/src/map/types.ts +2 -0
  502. package/src/media/EmbedUtil.ts +333 -24
  503. package/src/media/Media.ts +270 -59
  504. package/src/media/embedId.ts +110 -0
  505. package/src/media/types/Audio.ts +9 -82
  506. package/src/media/types/Blockquote.ts +2 -3
  507. package/src/media/types/DailyMotion.ts +5 -10
  508. package/src/media/types/DocumentCloud.ts +6 -35
  509. package/src/media/types/Facebook.ts +3 -4
  510. package/src/media/types/Flickr.ts +23 -12
  511. package/src/media/types/GoogleDoc.ts +3 -4
  512. package/src/media/types/HtmlMedia.ts +233 -0
  513. package/src/media/types/IFrame.ts +3 -4
  514. package/src/media/types/Image.ts +87 -9
  515. package/src/media/types/Juxtapose.ts +14 -5
  516. package/src/media/types/SoundCloud.ts +28 -6
  517. package/src/media/types/Text.ts +46 -21
  518. package/src/media/types/Twitter.ts +35 -20
  519. package/src/media/types/Video.ts +9 -79
  520. package/src/media/types/Vimeo.ts +11 -11
  521. package/src/media/types/Website.ts +23 -13
  522. package/src/media/types/Wikipedia.ts +18 -8
  523. package/src/media/types/YouTube.ts +72 -40
  524. package/src/scss/Typography.scss +16 -18
  525. package/src/scss/VCO.StoryMap.scss +208 -12
  526. package/src/scss/Variables.scss +3 -2
  527. package/src/scss/core/Mixins.scss +5 -63
  528. package/src/scss/core/Reset.scss +5 -18
  529. package/src/scss/fonts/font.amatic-andika.scss +3 -0
  530. package/src/scss/fonts/font.bitter-raleway.scss +6 -0
  531. package/src/scss/fonts/font.cairo.scss +79 -0
  532. package/src/scss/fonts/font.clicker-garamond.scss +5 -0
  533. package/src/scss/fonts/font.dancing-ledger.scss +1 -0
  534. package/src/scss/fonts/font.knightlab.scss +14 -0
  535. package/src/scss/fonts/font.oldstandard.scss +6 -0
  536. package/src/scss/fonts/font.opensans-gentiumbook.scss +5 -0
  537. package/src/scss/fonts/font.playfair-faunaone.scss +2 -0
  538. package/src/scss/fonts/font.playfair.scss +4 -0
  539. package/src/scss/fonts/font.pt.scss +6 -0
  540. package/src/scss/fonts/font.ubuntu.scss +4 -0
  541. package/src/scss/fonts/font.unicaone-vollkorn.scss +5 -0
  542. package/src/scss/map/VCO.Map.scss +1 -1
  543. package/src/scss/map/VCO.MapMarker.scss +114 -8
  544. package/src/scss/map/openlayers/VCO.Map.OpenLayers.scss +7 -4
  545. package/src/scss/media/VCO.Media.scss +14 -17
  546. package/src/scss/media/types/VCO.Media.Blockquote.scss +1 -1
  547. package/src/scss/media/types/VCO.Media.Text.scss +13 -5
  548. package/src/scss/media/types/VCO.Media.Twitter.scss +4 -4
  549. package/src/scss/media/types/VCO.Media.Wikipedia.scss +3 -19
  550. package/src/scss/site/site.scss +55 -36
  551. package/src/scss/slider/VCO.Slide.scss +61 -13
  552. package/src/scss/slider/VCO.SlideNav.scss +34 -24
  553. package/src/scss/slider/VCO.StorySlider.scss +53 -1
  554. package/src/scss/snapmap/VCO.SnapMap.scss +3 -3
  555. package/src/scss/ui/VCO.Consent.scss +63 -13
  556. package/src/scss/ui/VCO.Layers.scss +119 -0
  557. package/src/scss/ui/VCO.MenuBar.Button.scss +143 -14
  558. package/src/scss/ui/VCO.MenuBar.scss +9 -2
  559. package/src/scss/ui/VCO.Message.scss +16 -5
  560. package/src/site/site.ts +2 -1
  561. package/src/slider/Slide.ts +326 -74
  562. package/src/slider/SlideNav.ts +54 -9
  563. package/src/slider/StorySlider.ts +352 -60
  564. package/src/storymap/Consent.ts +429 -82
  565. package/src/storymap/StoryMap.ts +2163 -340
  566. package/src/storymap/content-state.ts +180 -0
  567. package/src/storymap/font-css.ts +72 -0
  568. package/src/storymap/from-slideshow.ts +785 -0
  569. package/src/storymap/iiif-shared.ts +228 -0
  570. package/src/storymap/iiif.ts +1742 -95
  571. package/src/storymap/to-iiif.ts +1008 -0
  572. package/src/storymap/validate.ts +80 -7
  573. package/src/types.ts +591 -29
  574. package/src/ui/LayersControl.ts +302 -0
  575. package/src/ui/MenuBar.ts +325 -61
  576. package/src/ui/Message.ts +44 -2
  577. package/src/ui/Swipable.ts +170 -85
  578. package/dist/assets/demo-D6cTmzn6.js +0 -1
  579. package/dist/assets/harness-BPFlzutp.js +0 -1
  580. package/dist/assets/index-DDsPBPZA.js +0 -1
  581. package/dist/assets/main-Y-MYUSJ4.js +0 -17
  582. package/src/animation/morpheus.d.ts +0 -41
  583. package/src/scss/VCO.StoryMap.Dark.scss +0 -138
  584. package/src/scss/Variables.Dark.scss +0 -56
  585. package/src/scss/media/types/VCO.Media.Instagram.scss +0 -7
  586. package/src/scss/media/types/VCO.Media.Profile.scss +0 -5
  587. package/src/ui/Draggable.ts +0 -374
@@ -1,10 +1,14 @@
1
1
  import OlMap from "ol/Map";
2
2
  import View from "ol/View";
3
+ import { unByKey } from "ol/Observable";
3
4
  import type { Control } from "ol/control";
4
5
  import type { Interaction } from "ol/interaction";
5
- import { Tile as TileLayer, Vector as VectorLayer } from "ol/layer";
6
+ import { Image as ImageLayer, Tile as TileLayer, Vector as VectorLayer } from "ol/layer";
7
+ import Layer from "ol/layer/Layer";
6
8
  import VectorTileLayer from "ol/layer/VectorTile";
7
9
  import { XYZ, OSM, IIIF } from "ol/source";
10
+ import Static from "ol/source/ImageStatic";
11
+ import type Source from "ol/source/Source";
8
12
  import VectorSource from "ol/source/Vector";
9
13
  import LineString from "ol/geom/LineString";
10
14
  import Feature from "ol/Feature";
@@ -25,9 +29,33 @@ import "ol/ol.css";
25
29
  import Map from "../Map";
26
30
  import OpenLayersMapMarker from "./MapMarker.OpenLayers";
27
31
  import { padCroppedZoomifyTile } from "./zoomifyTiles";
32
+ import { prefersReducedMotion } from "../../core/Util";
28
33
  import type { LinePoint, ViewToOptions } from "../types";
29
- import type { LatLngLiteral, StorymapSlide, StorymapSlideLocation } from "../../types";
30
- import { consentManagerOf, consentMessage, type ConsentManager } from "../../storymap/Consent";
34
+ import type {
35
+ LatLngLiteral,
36
+ StorymapImageOverlay,
37
+ StorymapOverlayLayer,
38
+ StorymapTilejson,
39
+ StorymapSlide,
40
+ StorymapSlideLocation,
41
+ } from "../../types";
42
+ import { fitGeoreference, resolveInfoJsonUrl } from "../georeference";
43
+ import {
44
+ buildSlideFilter,
45
+ isStaticImageUrl,
46
+ normalizeMask,
47
+ serviceToInfoJson,
48
+ slideBasemapKey,
49
+ slideRotationRad,
50
+ type NormalizedMask,
51
+ } from "../slideView";
52
+ import {
53
+ consentManagerOf,
54
+ tileService,
55
+ type ConsentManager,
56
+ type ConsentService,
57
+ } from "../../storymap/Consent";
58
+ import { sanitizeSlideText } from "../../media/EmbedUtil";
31
59
 
32
60
  /* Map.OpenLayers
33
61
  Creates a Map using OpenLayers
@@ -35,6 +63,30 @@ import { consentManagerOf, consentMessage, type ConsentManager } from "../../sto
35
63
 
36
64
  const MAX_ZOOM = 19;
37
65
 
66
+ /**
67
+ * Natural-size probe timeout for the static fallback: how long the viewer
68
+ * waits for the fallback image's dimensions before giving up and logging
69
+ * the IIIF error. A named constant (rather than an inline literal) so hosts
70
+ * and tests can find the single knob. There is no shared network-timeout
71
+ * option to reuse — `options.duration` is animation timing and the 1200ms
72
+ * media delay is a load deferral, not a watchdog.
73
+ */
74
+ const STATIC_PROBE_TIMEOUT_MS = 15000;
75
+
76
+ /** What kind of imagery an `imageready` source carries. */
77
+ export type ImagereadyKind = "iiif" | "zoomify" | "tiles";
78
+
79
+ /**
80
+ * Payload of the `imageready` event: the source that became usable, what
81
+ * kind of imagery it is, and the layer carrying it (`null` when the source
82
+ * outlives its layer).
83
+ */
84
+ export interface ImagereadyPayload {
85
+ source: { getState?(): string };
86
+ kind: ImagereadyKind;
87
+ layer: Layer | null;
88
+ }
89
+
38
90
  /**
39
91
  * Zoom ladder for image-space maps (issue #465): rung 0 shows any image fully
40
92
  * zoomed out, finer rungs reach sub-pixel detail. It keeps view zooms,
@@ -45,24 +97,73 @@ const IMAGE_RESOLUTIONS = Array.from({ length: 25 }, (_, i) => 2 ** (16 - i));
45
97
 
46
98
  export default class OpenLayers extends Map {
47
99
  declare "_map": OlMap;
48
- declare "_tile_layer": TileLayer;
100
+ declare "_tile_layer": Layer;
49
101
  declare "_line": VectorLayer;
50
102
  declare "_line_active": VectorLayer;
51
- declare "_tile_layer_mini": TileLayer | null;
103
+ declare "_tile_layer_mini": Layer | null;
52
104
  declare "_mini_map": OverviewMap;
53
105
  declare "_markers": OpenLayersMapMarker[];
54
106
  /** App-level stacked overlays (see the `overlays` option) */
55
- declare "_overlay_layers": TileLayer[];
107
+ declare "_overlay_layers": Layer[];
108
+ /**
109
+ * The `overlays[]` entries that produced a layer, parallel to
110
+ * `_overlay_layers`: a malformed entry is skipped, so the two arrays
111
+ * can be shorter than the option list.
112
+ */
113
+ declare "_overlay_entries": StorymapOverlayLayer[];
56
114
  /** rAF handle of the running active-line draw animation */
57
115
  declare "_line_animation": number | null;
116
+ /** Sources `imageready` has already been fired for. */
117
+ declare "_imageready_fired": WeakSet<object>;
118
+ /** The `loadend` subscription key, stored so dispose() can detach it. */
119
+ declare "_loadend_key": Parameters<typeof unByKey>[0] | null;
120
+ /**
121
+ * Per-slide basemap layers by key (multi-manifest tours): built on first
122
+ * use through the tile layer factory, then reused. Empty until a slide
123
+ * names a `location.basemap`.
124
+ */
125
+ declare "_basemap_cache": globalThis.Map<string, Layer>;
126
+ /**
127
+ * The active per-slide basemap key, or null when the story basemap is
128
+ * showing. Restored to the story basemap on slides without one.
129
+ */
130
+ declare "_slide_basemap": string | null;
131
+ /** Spotlight mask element (slideshow passepartout), or null when off. */
132
+ declare "_mask_el": HTMLDivElement | null;
133
+ /** Per-slide image overlay layer (slideshow imgoverlay), or null. */
134
+ declare "_imgoverlay_layer": Layer | null;
135
+ /** Guards stale static-fallback probes (see `_probeStaticFallback`). */
136
+ declare "_static_probe_token": number;
137
+ /**
138
+ * Set by `dispose()` so in-flight fetches and probes resolve into
139
+ * no-ops instead of touching a torn-down map.
140
+ */
141
+ declare "_disposed": boolean;
142
+ /**
143
+ * Per-URL probe tokens for static slide basemaps: concurrent probes for
144
+ * different slides must not cancel each other (see
145
+ * `_createStaticImageBasemap`).
146
+ */
147
+ declare "_slide_probe_tokens": globalThis.Map<string, number>;
58
148
 
59
149
  /* Create the Map
60
150
  ================================================== */
61
151
  _createMap(): void {
62
- const is_image_map = this.options.map_type === "iiif" && this.options.map_as_image;
63
-
64
- // Caller-supplied OpenLayers options: controls/interactions replace the
65
- // defaults, view merges over the computed default, other options pass through
152
+ const is_image_map = this.isImageSpace();
153
+ this._imageready_fired = new WeakSet();
154
+ this._disposed = false;
155
+ this._basemap_cache = new globalThis.Map();
156
+ this._slide_basemap = null;
157
+ this._story_basemap_layer = null;
158
+ this._mask_el = null;
159
+ this._imgoverlay_layer = null;
160
+ this._static_probe_token = 0;
161
+ this._slide_probe_tokens = new globalThis.Map();
162
+
163
+ // Caller-supplied OpenLayers options: `controls` replaces the defaults
164
+ // (the viewer installs none), `interactions` are added to the viewer's
165
+ // own pan/zoom set, `view` merges over the computed default, everything
166
+ // else passes through
66
167
  const user_map_options = this.options.map_options ?? {};
67
168
  const { element: _element, view: user_view, ...passthrough } = user_map_options;
68
169
  const user_view_options = (user_view ?? {}) as Record<string, unknown>;
@@ -76,6 +177,16 @@ export default class OpenLayers extends Map {
76
177
  // space maps use raw pixel coordinates.
77
178
  const bbox_extent = this._bboxExtent();
78
179
 
180
+ // A TileJSON tile source states its own zoom ladder, its covered area
181
+ // and its default view, and all three are honoured here (§2.9). A
182
+ // keyword basemap has none of these, so the defaults stand.
183
+ const tilejson = this._tilejsonFor(this.options.map_type);
184
+ const tile_min_zoom = tilejson?.minzoom ?? 0;
185
+ const tile_max_zoom = tilejson?.maxzoom ?? MAX_ZOOM;
186
+ const tile_extent = tilejson?.bounds
187
+ ? fromLonLat(tilejson.bounds as [number, number, number, number])
188
+ : null;
189
+
79
190
  this._map = new OlMap({
80
191
  ...passthrough,
81
192
  target: this._el.map,
@@ -83,10 +194,19 @@ export default class OpenLayers extends Map {
83
194
  interactions: (user_map_options.interactions as Interaction[]) ?? [],
84
195
  view: new View({
85
196
  projection: is_image_map ? "EPSG:4326" : "EPSG:3857",
86
- center: [0, 0],
87
- zoom: 0,
88
- minZoom: 0,
89
- maxZoom: is_image_map ? IMAGE_RESOLUTIONS.length - 1 : MAX_ZOOM,
197
+ ...(tilejson?.center
198
+ ? {
199
+ center: fromLonLat([tilejson.center[0], tilejson.center[1]]),
200
+ zoom: tilejson.center[2],
201
+ }
202
+ : { center: [0, 0], zoom: 0 }),
203
+ minZoom: tile_min_zoom,
204
+ maxZoom: is_image_map ? IMAGE_RESOLUTIONS.length - 1 : tile_max_zoom,
205
+ // a tile source that only covers part of the world: keep the
206
+ // view inside what it actually has tiles for
207
+ ...(tile_extent && !is_image_map
208
+ ? { extent: tile_extent, constrainOnlyCenter: true }
209
+ : {}),
90
210
  // image maps live outside the 4326 world: opt out of the
91
211
  // global-projection constraints (they cap resolution at
92
212
  // fit-the-world and clamp the center to [-90, 90]) and use
@@ -102,7 +222,7 @@ export default class OpenLayers extends Map {
102
222
  }),
103
223
  });
104
224
 
105
- this._map.on("loadend", () => {
225
+ this._loadend_key = this._map.on("loadend", () => {
106
226
  this._onMapLoaded(undefined);
107
227
  });
108
228
 
@@ -119,11 +239,11 @@ export default class OpenLayers extends Map {
119
239
  // Tile Layer — GDPR consent mode defers it until the visitor allows
120
240
  // map tiles (the consent bar renders over the map)
121
241
  const consent = consentManagerOf(this.options);
122
- const tile_service = consentMessage("consent_service_tiles", "map tiles");
242
+ const tile_service = tileService();
123
243
  if (this.options.consent_required && consent) {
124
- if (consent.isGranted(tile_service)) {
244
+ if (consent.isGranted(tile_service.key)) {
125
245
  this._addTileLayer();
126
- } else if (!consent.isDenied(tile_service)) {
246
+ } else if (!consent.isDenied(tile_service.key)) {
127
247
  void this._requestTileConsent(consent, tile_service);
128
248
  }
129
249
  // denied → the map renders with background color and markers only
@@ -131,7 +251,8 @@ export default class OpenLayers extends Map {
131
251
  this._addTileLayer();
132
252
  }
133
253
 
134
- // Create Overall Connection Line
254
+ // Create Overall Connection Line (reserved z-range 10/11 for the
255
+ // route lines; see the stack-order note at _addTileLayer)
135
256
  this._line = this._createLine();
136
257
  this._line.setStyle(this._lineStyle(this.options.line_color_inactive));
137
258
  this._addLineToMap(this._line);
@@ -152,6 +273,7 @@ export default class OpenLayers extends Map {
152
273
 
153
274
  // Stacked raster overlays (base tiles 1.., below the route lines)
154
275
  this._overlay_layers = [];
276
+ this._overlay_entries = [];
155
277
  this._buildOverlays();
156
278
 
157
279
  // Native interactions (pan/zoom), no scroll zoom by default
@@ -174,6 +296,57 @@ export default class OpenLayers extends Map {
174
296
  this._updateAttribution();
175
297
  }
176
298
 
299
+ /* Public accessors
300
+ Consumers get the raw OpenLayers map via `storymap.map`; these hand out
301
+ the internal objects the viewer keeps private (base/overlay/minimap layers,
302
+ the route lines, the markers) so nothing has to be reached for through an
303
+ underscore field. Layer order in `map.getLayers()` is NOT the overlay
304
+ order (creation order is [base, line, line_active, overlay0…] and zIndex is
305
+ a separate axis), so `getOverlayLayers()` is the only correct way to map an
306
+ `overlays[]` index onto a layer.
307
+ ================================================= */
308
+
309
+ /** The base tile layer (the one created for `map_type`), if consent has not deferred it. */
310
+ getBaseLayer(): Layer | null {
311
+ return this._tile_layer ?? null;
312
+ }
313
+
314
+ /** The stacked `overlays[]` layers, in `overlays[]` order. */
315
+ getOverlayLayers(): Layer[] {
316
+ return [...this._overlay_layers];
317
+ }
318
+
319
+ /** One stacked overlay layer by its `overlays[]` index, or `null`. */
320
+ getOverlayLayer(index: number): Layer | null {
321
+ return this._overlay_layers[index] ?? null;
322
+ }
323
+
324
+ /** The minimap's OpenLayers map (the `OverviewMap` control's inner map). */
325
+ getMinimap(): OlMap | null {
326
+ const overview = this._mini_map?.getOverviewMap?.() ?? null;
327
+ return overview ?? null;
328
+ }
329
+
330
+ /** The full (inactive) route line layer. */
331
+ getLine(): Layer | null {
332
+ return this._line ?? null;
333
+ }
334
+
335
+ /** The highlighted route line layer drawn up to the current slide. */
336
+ getLineActive(): Layer | null {
337
+ return this._line_active ?? null;
338
+ }
339
+
340
+ /** The markers, in slide order (the index matches the slide index). */
341
+ getMarkers(): OpenLayersMapMarker[] {
342
+ return [...this._markers];
343
+ }
344
+
345
+ /** One marker by slide index, or `null`. */
346
+ getMarker(index: number): OpenLayersMapMarker | null {
347
+ return this._markers[index] ?? null;
348
+ }
349
+
177
350
  /**
178
351
  * (Re)render the attribution line for the current map type. Called at
179
352
  * creation and on every `map_type` switch, which previously left the
@@ -181,32 +354,171 @@ export default class OpenLayers extends Map {
181
354
  */
182
355
  _updateAttribution(): void {
183
356
  const extras = this._extra_attributions ?? [];
184
- const parts = [this._getAttribution(this.options.map_type), ...extras]
185
- .filter(Boolean)
186
- .join(" | ");
357
+ // Identical credits (e.g. stacked overlays sharing one source, or the
358
+ // attribution option restating the base credit) collapse to one entry
359
+ const parts = [
360
+ ...new Set([...this._getAttribution(this.options.map_type), ...extras].filter(Boolean)),
361
+ ].join(" | ");
187
362
  let el = this._el.map.querySelector(".vco-map-attribution") as HTMLElement | null;
188
363
  if (!el) {
189
364
  this._el.map.insertAdjacentHTML("beforeend", `<div class="vco-map-attribution"></div>`);
190
365
  el = this._el.map.querySelector(".vco-map-attribution") as HTMLElement | null;
191
366
  }
192
367
  if (el) {
193
- el.innerHTML = parts;
368
+ // The library-authored credits are trusted markup, but
369
+ // `options.attribution` and overlay credits come from the
370
+ // storymap JSON. Rather than trusting them, the whole line is
371
+ // re-sanitized: a fragment that is not well-formed markup is
372
+ // unwrapped to text, so an author-supplied credit can never
373
+ // inject an element into the map.
374
+ el.textContent = "";
375
+ el.appendChild(sanitizeSlideText(parts));
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Provider credit for a `map_type`, in both renderings: the linked HTML
381
+ * for our own `.vco-map-attribution` line and the plain text for the
382
+ * OpenLayers source `attributions`. One table — the two previous
383
+ * functions repeated the same `osm/stadia/mapbox/esri` switch and could
384
+ * drift apart, leaving a provider credited in one place but not the other
385
+ * (which their terms of service require).
386
+ */
387
+ _providerCredit(map_type: string): { html: string; text: string } {
388
+ // A full XYZ template naming a known provider
389
+ // (https://tile.openstreetmap.org/{z}/{x}/{y}.png) is not the `osm`
390
+ // map type, so match its host as well as the type prefix — otherwise
391
+ // both renderings fall through to the generic "Map data" line.
392
+ const byHost = this._hostCredit(map_type);
393
+ if (byHost) return byHost;
394
+ // Emitting provider credit only for OSM left Stadia, Mapbox and the
395
+ // OL XYZ template providers uncredited, which their terms of service
396
+ // require. The credit is keyed off the resolved map_type, so a custom
397
+ // `{z}` template gets the generic "map data" line and the author can
398
+ // still override it with the `attribution` option.
399
+ const table: { test: (t: string) => boolean; html: string; text: string }[] = [
400
+ {
401
+ test: (t) => t === "" || t.startsWith("osm"),
402
+ html: "© <a target='_blank' href='https://www.openstreetmap.org/copyright'>OpenStreetMap</a> contributors",
403
+ text: "© OpenStreetMap contributors",
404
+ },
405
+ {
406
+ test: (t) => t.startsWith("stadia") || t === "stamen",
407
+ html:
408
+ '© <a target="_blank" rel="noopener noreferrer" href="https://stadiamaps.com/">Stadia Maps</a>, ' +
409
+ '© <a target="_blank" rel="noopener noreferrer" href="https://openmaptiles.org/">OpenMapTiles</a> ' +
410
+ '© <a target="_blank" rel="noopener noreferrer" href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
411
+ text: "© Stadia Maps, © OpenMapTiles © OpenStreetMap contributors",
412
+ },
413
+ {
414
+ test: (t) => t.startsWith("mapbox://"),
415
+ html: '© <a target="_blank" href="https://www.mapbox.com/about/maps/">Mapbox</a>',
416
+ text: "© Mapbox",
417
+ },
418
+ {
419
+ test: (t) => t.startsWith("ch-") || t.startsWith("esri"),
420
+ html: 'Map data © <a target="_blank" href="https://www.esri.com/">Esri</a>',
421
+ text: "Map data © Esri",
422
+ },
423
+ {
424
+ test: () => true,
425
+ html: "Map data",
426
+ text: "Map data",
427
+ },
428
+ ];
429
+ const entry = table.find((row) => row.test(map_type)) ?? table[table.length - 1];
430
+ return { html: entry.html, text: entry.text };
431
+ }
432
+
433
+ /**
434
+ * The table row for an absolute tile template's host, or null when the
435
+ * template is relative (a host serving its own tiles credits itself via
436
+ * `attribution`) or unknown. Subdomains match their parent, so
437
+ * `a.tile.openstreetmap.org` credits OSM.
438
+ */
439
+ _hostCredit(map_type: string): { html: string; text: string } | null {
440
+ let host: string | null = null;
441
+ try {
442
+ host = new URL(map_type).hostname;
443
+ } catch {
444
+ // Not absolute: only a protocol-relative template (`//host/...`)
445
+ // still names a host. A relative template resolves against the
446
+ // dummy base below and is rejected by the host check.
447
+ try {
448
+ const resolved = new URL(map_type, "https://storymap.invalid").hostname;
449
+ host = resolved === "storymap.invalid" ? null : resolved;
450
+ } catch {
451
+ host = null;
452
+ }
194
453
  }
454
+ if (!host) return null;
455
+ const rows: { hosts: string[]; html: string; text: string }[] = [
456
+ {
457
+ hosts: ["openstreetmap.org", "osm.org"],
458
+ html: "© <a target='_blank' href='https://www.openstreetmap.org/copyright'>OpenStreetMap</a> contributors",
459
+ text: "© OpenStreetMap contributors",
460
+ },
461
+ {
462
+ hosts: ["stadiamaps.com", "openmaptiles.org"],
463
+ html:
464
+ '© <a target="_blank" rel="noopener noreferrer" href="https://stadiamaps.com/">Stadia Maps</a>, ' +
465
+ '© <a target="_blank" rel="noopener noreferrer" href="https://openmaptiles.org/">OpenMapTiles</a> ' +
466
+ '© <a target="_blank" rel="noopener noreferrer" href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
467
+ text: "© Stadia Maps, © OpenMapTiles © OpenStreetMap contributors",
468
+ },
469
+ {
470
+ hosts: ["mapbox.com"],
471
+ html: '© <a target="_blank" href="https://www.mapbox.com/about/maps/">Mapbox</a>',
472
+ text: "© Mapbox",
473
+ },
474
+ {
475
+ hosts: ["arcgisonline.com", "esri.com"],
476
+ html: 'Map data © <a target="_blank" href="https://www.esri.com/">Esri</a>',
477
+ text: "Map data © Esri",
478
+ },
479
+ {
480
+ hosts: ["carto.com", "basemaps.cartocdn.com"],
481
+ html:
482
+ "© <a target='_blank' href='https://www.openstreetmap.org/copyright'>OpenStreetMap</a> contributors © " +
483
+ '<a target="_blank" href="https://carto.com/">CARTO</a>',
484
+ text: "© OpenStreetMap contributors © CARTO",
485
+ },
486
+ ];
487
+ const row = rows.find(({ hosts }) =>
488
+ hosts.some((h) => host === h || host.endsWith(`.${h}`)),
489
+ );
490
+ return row ? { html: row.html, text: row.text } : null;
195
491
  }
196
492
 
197
- _getAttribution(map_type: string): string {
493
+ _getAttribution(map_type: string): string[] {
198
494
  const parts = [
199
495
  "<a href='https://storymap.knightlab.com/' target='_blank' class='vco-knightlab-brand'><span>&#x25a0;</span> StoryMapJS</a>",
200
496
  ];
201
- if (map_type === "osm" || map_type === "" || map_type.startsWith("osm")) {
202
- parts.push(
203
- "© <a target='_blank' href='https://www.openstreetmap.org/copyright'>OpenStreetMap</a> contributors",
204
- );
497
+ // Our own `.vco-map-attribution` line is the rendered default (the
498
+ // OL attribution control is not added), so this hand-built line is
499
+ // what the visitor sees. Sources additionally carry the plain-text
500
+ // credit from `_sourceAttributions`, so `source.getAttributions()`
501
+ // and a consumer's own Attribution control are not left empty.
502
+ parts.push(this._providerCredit(map_type).html);
503
+ if (this.options.attribution) {
504
+ parts.push(this.options.attribution);
205
505
  }
506
+ return parts;
507
+ }
508
+
509
+ /**
510
+ * The provider credit for `map_type` as plain text (no HTML), for the
511
+ * OpenLayers source `attributions`. Our own `.vco-map-attribution` line
512
+ * stays the rendered default and uses the linked `_getAttribution`
513
+ * version; this makes `source.getAttributions()` (and a consumer's
514
+ * `ol/control/Attribution`) work instead of returning nothing.
515
+ */
516
+ _sourceAttributions(map_type: string): string[] {
517
+ const parts: string[] = [this._providerCredit(map_type).text];
206
518
  if (this.options.attribution) {
207
519
  parts.push(this.options.attribution);
208
520
  }
209
- return parts.join(" | ");
521
+ return parts;
210
522
  }
211
523
 
212
524
  /* Create Tile Layer
@@ -222,12 +534,16 @@ export default class OpenLayers extends Map {
222
534
  // to bury the route lines under fresh tiles
223
535
  this._tile_layer.setZIndex(0);
224
536
  this._map.addLayer(this._tile_layer);
537
+ // the story's own base layer: per-slide basemaps hand back to it
538
+ if (!this._story_basemap_layer) {
539
+ this._story_basemap_layer = this._tile_layer;
540
+ }
225
541
  }
226
542
 
227
543
  /**
228
544
  * Ask for tile consent, then attach the layers if allowed.
229
545
  */
230
- async _requestTileConsent(consent: ConsentManager, tile_service: string) {
546
+ async _requestTileConsent(consent: ConsentManager, tile_service: ConsentService) {
231
547
  if (await consent.request(tile_service, "", this._el.map)) {
232
548
  this._onTilesAllowed();
233
549
  }
@@ -240,24 +556,36 @@ export default class OpenLayers extends Map {
240
556
  * Overlays sit above the base tiles (z 1..n) and below the route lines
241
557
  * (z 10/11); every entry accepts any `map_type` value plus declarative
242
558
  * presentation, so hosts no longer capture and patch layer objects.
559
+ * An entry carrying a `georeference` places a IIIF image on the
560
+ * geographic map instead of stacking a tile source.
243
561
  */
244
562
  _buildOverlays(): void {
245
563
  for (const layer of this._overlay_layers) {
246
564
  this._map.removeLayer(layer);
247
565
  }
248
566
  this._overlay_layers = [];
249
- const consent = consentManagerOf(this.options);
250
- const tile_service = consentMessage("consent_service_tiles", "map tiles");
251
- if (
252
- this.options.consent_required &&
253
- consent &&
254
- !consent.isGranted(tile_service)
255
- ) {
567
+ this._overlay_entries = [];
568
+ if (!this._tilesAllowed()) {
256
569
  return;
257
570
  }
258
571
  const overlays = this.options.overlays ?? [];
259
572
  overlays.forEach((entry, i) => {
260
- const layer = this._createTileLayer(entry.map_type);
573
+ let layer: Layer | null = null;
574
+ if (entry.georeference) {
575
+ layer = this._createGeoreferencedOverlay(entry);
576
+ } else if (entry.map_type) {
577
+ layer = this._createTileLayer(entry.map_type);
578
+ } else {
579
+ console.warn(
580
+ "StoryMapJS: an overlays[] entry has neither map_type nor georeference and was skipped:",
581
+ entry,
582
+ );
583
+ }
584
+ if (!layer) {
585
+ return;
586
+ }
587
+ // reserved z-range 1..n for stacked overlays (base tiles 0,
588
+ // route lines 10/11; see _addTileLayer)
261
589
  layer.setZIndex(1 + i);
262
590
  if (entry.opacity !== undefined) {
263
591
  layer.setOpacity(entry.opacity);
@@ -280,11 +608,78 @@ export default class OpenLayers extends Map {
280
608
  }
281
609
  this._map.addLayer(layer);
282
610
  this._overlay_layers.push(layer);
611
+ this._overlay_entries.push(entry);
283
612
  this._paintOverlayBlend(entry);
284
613
  });
285
614
  this._syncOverlayAttributions();
286
615
  }
287
616
 
617
+ /**
618
+ * Build a stacked overlay from a IIIF Georeference Extension payload
619
+ * (`overlays[].georeference`): the ground control points are fitted
620
+ * affinely and the image is placed as a geographic extent, which
621
+ * OpenLayers reprojects onto the view exactly like the IIIF basemap.
622
+ *
623
+ * The layer is returned empty and filled in asynchronously, mirroring
624
+ * the `iiif` basemap path — only the fit (pure, synchronous) decides
625
+ * whether a layer exists at all, so overlay indices stay stable.
626
+ */
627
+ _createGeoreferencedOverlay(entry: StorymapOverlayLayer): TileLayer | null {
628
+ const georeference = entry.georeference;
629
+ if (!georeference) {
630
+ return null;
631
+ }
632
+ // image-space views (iiif/zoomify) have no geographic space to
633
+ // place the sheet in
634
+ if (this._map.getView().getProjection().getCode() !== "EPSG:3857") {
635
+ console.warn(
636
+ "StoryMapJS: a georeferenced overlay was skipped because this map has no geographic view (image maps cannot place sheets).",
637
+ );
638
+ return null;
639
+ }
640
+ const fit = fitGeoreference(georeference.body, georeference.width, georeference.height);
641
+ if (fit.kind === "skipped") {
642
+ console.warn(
643
+ `StoryMapJS: georeferenced overlay ${georeference.url} was skipped: ${fit.reason}.`,
644
+ );
645
+ return null;
646
+ }
647
+ const layer = new TileLayer();
648
+ const info_url = resolveInfoJsonUrl(georeference.url);
649
+ fetch(info_url)
650
+ .then((r) => r.json())
651
+ .then((info: unknown) => {
652
+ const parsed = new IIIFInfo(
653
+ info as ImageInformationResponse,
654
+ ).getTileSourceOptions();
655
+ const size = info as { width?: number; height?: number };
656
+ if (typeof size.width !== "number" || typeof size.height !== "number") {
657
+ console.error("IIIF info.json is missing width/height:", info_url);
658
+ layer.setVisible(false);
659
+ return;
660
+ }
661
+ const geo_source = new IIIF({
662
+ ...(parsed ?? {}),
663
+ projection: "EPSG:4326",
664
+ // the fitted corner box: the affine placement of the
665
+ // image in geographic coordinates
666
+ extent: fit.bbox,
667
+ size: [size.width, size.height],
668
+ crossOrigin: "anonymous",
669
+ attributions: entry.attribution ? [entry.attribution] : [],
670
+ });
671
+ layer.setSource(geo_source);
672
+ this._fireImageready(geo_source, "iiif", layer);
673
+ })
674
+ .catch((err) => {
675
+ // the layer stays in place (indices must not shift) but
676
+ // renders nothing; the console carries the reason
677
+ console.error("IIIF info.json could not be loaded:", info_url, err?.stack || err);
678
+ layer.setVisible(false);
679
+ });
680
+ return layer;
681
+ }
682
+
288
683
  /**
289
684
  * Convert a lon/lat clip box to view units. Only meaningful on
290
685
  * mercator maps; image-space maps (iiif/zoomify) and malformed
@@ -310,10 +705,7 @@ export default class OpenLayers extends Map {
310
705
  * frames (bounded: rendering settles within a frame or two of any
311
706
  * add/visibility change).
312
707
  */
313
- _paintOverlayBlend(
314
- entry: { className?: string; blendMode?: string },
315
- retries = 60,
316
- ): void {
708
+ _paintOverlayBlend(entry: { className?: string; blendMode?: string }, retries = 60): void {
317
709
  if (!entry.blendMode || !entry.className) {
318
710
  return;
319
711
  }
@@ -337,10 +729,9 @@ export default class OpenLayers extends Map {
337
729
 
338
730
  /** Refresh the attribution line with the visible overlays' credits. */
339
731
  _syncOverlayAttributions(): void {
340
- const overlays = this.options.overlays ?? [];
341
732
  const parts: string[] = [];
342
733
  this._overlay_layers.forEach((layer, i) => {
343
- const credit = overlays[i]?.attribution;
734
+ const credit = this._overlay_entries[i]?.attribution;
344
735
  if (credit && layer.getVisible()) {
345
736
  parts.push(credit);
346
737
  }
@@ -348,19 +739,38 @@ export default class OpenLayers extends Map {
348
739
  this.setExtraAttributions(parts);
349
740
  }
350
741
 
351
- /** Number of stacked overlay layers (see the `overlays` option). */
742
+ /**
743
+ * Number of stacked overlay layers (see the `overlays` option). Alias:
744
+ * `getOverlayLayers().length`.
745
+ */
352
746
  getOverlayCount(): number {
353
747
  return this._overlay_layers.length;
354
748
  }
355
749
 
356
- /** Show or hide a stacked overlay by index (re-syncs attribution). */
750
+ /**
751
+ * The built overlay entries, parallel to `getOverlayLayers()`: malformed
752
+ * entries are skipped, so this is what a control must iterate to stay on
753
+ * the same index space as `setOverlayVisible()`.
754
+ */
755
+ getOverlayEntries(): StorymapOverlayLayer[] {
756
+ return [...this._overlay_entries];
757
+ }
758
+
759
+ /**
760
+ * Show or hide a stacked overlay by index (re-syncs attribution). The
761
+ * index counts built layers, i.e. it skips malformed entries.
762
+ *
763
+ * Prefer `getOverlayLayer(index).setVisible(v)` on the layer itself; this
764
+ * wrapper exists because it also re-syncs the attribution line and the
765
+ * overlay blend mode, which a bare `setVisible()` does not.
766
+ */
357
767
  setOverlayVisible(index: number, visible: boolean): void {
358
768
  const layer = this._overlay_layers[index];
359
769
  if (!layer) {
360
770
  return;
361
771
  }
362
772
  layer.setVisible(visible);
363
- const entry = (this.options.overlays ?? [])[index];
773
+ const entry = this._overlay_entries[index];
364
774
  if (entry && visible) {
365
775
  this._paintOverlayBlend(entry);
366
776
  }
@@ -368,6 +778,9 @@ export default class OpenLayers extends Map {
368
778
  }
369
779
 
370
780
  /** Set a stacked overlay's opacity by index. */
781
+ /**
782
+ * Alias: `getOverlayLayer(index)?.setOpacity(opacity)`.
783
+ */
371
784
  setOverlayOpacity(index: number, opacity: number): void {
372
785
  this._overlay_layers[index]?.setOpacity(opacity);
373
786
  }
@@ -379,6 +792,7 @@ export default class OpenLayers extends Map {
379
792
  _onTilesAllowed(): void {
380
793
  this._addTileLayer();
381
794
  this._buildOverlays();
795
+ this.fire("tilesallowed");
382
796
  if (!this._tile_layer_mini) {
383
797
  this._tile_layer_mini = this._createTileLayer(this.options.map_type);
384
798
  }
@@ -392,8 +806,14 @@ export default class OpenLayers extends Map {
392
806
  const marker = this._markers[this.current_marker];
393
807
  if (marker.data.type === "overview") {
394
808
  this._markerOverview();
395
- } else if (this._hasLocation(marker.data)) {
396
- this._fitView(this._map, [[marker.data.location.lon, marker.data.location.lat]], 0);
809
+ } else {
810
+ const latlon = marker.latLon();
811
+ if (latlon) {
812
+ this._fitView(this._map, [[latlon.lon, latlon.lat]], 0);
813
+ }
814
+ // consent arrived after markers: the current slide may name
815
+ // a per-slide basemap
816
+ this._switchSlideBasemap(marker.data.location ?? null);
397
817
  }
398
818
  }
399
819
  }
@@ -477,7 +897,373 @@ export default class OpenLayers extends Map {
477
897
  return { zoom, center };
478
898
  }
479
899
 
480
- _createTileLayer(map_type: string): TileLayer {
900
+ /**
901
+ * Paint the story's static image when its IIIF `info.json` cannot be
902
+ * used (unreachable, or missing dimensions): a single-resolution
903
+ * `ImageStatic` layer replaces the empty tile layer, so the tour still
904
+ * shows its picture instead of a blank basemap. Needs
905
+ * `iiif.fallbackUrl` (the full-size image) plus `iiif.width`/`height`
906
+ * (canvas size); without dimensions the natural size is probed, but
907
+ * only for slideshow tours — internal documents keep the existing
908
+ * error path. Returns false when nothing was (or will be) painted.
909
+ */
910
+ _useStaticFallback(empty_layer: TileLayer): boolean {
911
+ const width = this.options.iiif.width;
912
+ const height = this.options.iiif.height;
913
+ const url = this.options.iiif.fallbackUrl;
914
+ if (typeof url !== "string" || url === "") {
915
+ return false;
916
+ }
917
+ if (typeof width === "number" && width > 0 && typeof height === "number" && height > 0) {
918
+ this._buildStaticLayer(empty_layer, url, width, height);
919
+ return true;
920
+ }
921
+ if (this.options.slideshow_source !== true) {
922
+ return false;
923
+ }
924
+ void this._probeStaticFallback(empty_layer, url);
925
+ return true;
926
+ }
927
+
928
+ /**
929
+ * Resolve a fallback image's natural size, then paint it. Stale probes
930
+ * (a newer probe started, e.g. after a basemap switch) and unusable
931
+ * dimensions fall back to the logged IIIF error.
932
+ */
933
+ async _probeStaticFallback(empty_layer: TileLayer, url: string): Promise<void> {
934
+ const token = ++this._static_probe_token;
935
+ try {
936
+ const dims = await this._probeStaticImage(url);
937
+ if (this._disposed || token !== this._static_probe_token) return;
938
+ if (!dims || !(dims.width > 0) || !(dims.height > 0)) {
939
+ console.error("Static fallback image has no usable dimensions:", url);
940
+ return;
941
+ }
942
+ this._buildStaticLayer(empty_layer, url, dims.width, dims.height);
943
+ } catch (err) {
944
+ if (this._disposed || token !== this._static_probe_token) return;
945
+ console.error(
946
+ "Static fallback image could not be probed:",
947
+ url,
948
+ (err as Error)?.stack || err,
949
+ );
950
+ }
951
+ }
952
+
953
+ /**
954
+ * The natural size of an image URL. Separated for testability: unit
955
+ * tests stub the `Image` constructor rather than the network.
956
+ */
957
+ _probeStaticImage(url: string): Promise<{ width: number; height: number }> {
958
+ return new Promise((resolve, reject) => {
959
+ const img = new Image();
960
+ const timer = setTimeout(() => {
961
+ reject(new Error(`timed out after ${STATIC_PROBE_TIMEOUT_MS}ms`));
962
+ }, STATIC_PROBE_TIMEOUT_MS);
963
+ img.onload = () => {
964
+ clearTimeout(timer);
965
+ resolve({ width: img.naturalWidth, height: img.naturalHeight });
966
+ };
967
+ img.onerror = () => {
968
+ clearTimeout(timer);
969
+ reject(new Error("load error"));
970
+ };
971
+ img.crossOrigin = "anonymous";
972
+ img.src = url;
973
+ });
974
+ }
975
+
976
+ /**
977
+ * One static image source: shared by the story fallback (known extent),
978
+ * per-slide static basemaps (probed extent) and per-slide overlays
979
+ * (view extent). Layer assembly stays per call site — base (z 0),
980
+ * pending-fill and overlay (z 5, opacity) have different lifecycles.
981
+ */
982
+ _newStaticSource(
983
+ url: string,
984
+ extent: Extent,
985
+ projection: string | Projection,
986
+ attributions?: string | string[],
987
+ ): Static {
988
+ return new Static({
989
+ url,
990
+ imageExtent: extent,
991
+ projection,
992
+ crossOrigin: "anonymous",
993
+ ...(attributions !== undefined ? { attributions } : {}),
994
+ });
995
+ }
996
+
997
+ /**
998
+ * One static image layer: the constructor shared by the story fallback,
999
+ * per-slide static basemaps and per-slide overlays. Extent and
1000
+ * projection are the caller's policy (canvas size, probed size, or view
1001
+ * extent); presentation rides the options.
1002
+ */
1003
+ _newStaticLayer(
1004
+ url: string,
1005
+ extent: Extent,
1006
+ projection: string | Projection,
1007
+ presentation: { opacity?: number; attributions?: string | string[]; zIndex?: number } = {},
1008
+ ): ImageLayer<Static> {
1009
+ const layer = new ImageLayer({
1010
+ source: this._newStaticSource(url, extent, projection, presentation.attributions),
1011
+ ...(presentation.opacity !== undefined ? { opacity: presentation.opacity } : {}),
1012
+ });
1013
+ layer.setZIndex(presentation.zIndex ?? 0);
1014
+ return layer;
1015
+ }
1016
+
1017
+ /** Swap the empty tile layer for a static image of the given extent. */
1018
+ _buildStaticLayer(empty_layer: TileLayer, url: string, width: number, height: number): void {
1019
+ if (this._disposed) return;
1020
+ const layer = this._newStaticLayer(url, [0, 0, width, height], "EPSG:4326", {
1021
+ attributions: this.options.iiif.attribution || [],
1022
+ });
1023
+ if (this._tile_layer === (empty_layer as unknown as Layer)) {
1024
+ this._map.removeLayer(empty_layer);
1025
+ this._tile_layer = layer;
1026
+ this._map.addLayer(layer);
1027
+ } else {
1028
+ // superseded while resolving (basemap switch, dispose): announce
1029
+ // nothing and fit nothing for a layer that never shows
1030
+ return;
1031
+ }
1032
+ const source = this._sourceOf(layer);
1033
+ if (!source) return;
1034
+ this._fireImageready(source, "iiif", layer);
1035
+ if (typeof source.getState === "function" && source.getState() === "ready") {
1036
+ this._markerOverview();
1037
+ } else if (typeof (source as { once?: unknown }).once === "function") {
1038
+ (source as unknown as { once(e: string, fn: () => void): void }).once("change", () => {
1039
+ if (source.getState() === "ready") {
1040
+ this._markerOverview();
1041
+ }
1042
+ });
1043
+ } else {
1044
+ this._markerOverview();
1045
+ }
1046
+ }
1047
+
1048
+ _createTileLayer(map_type: string): Layer {
1049
+ // issue #473: custom OpenLayers tile layer/source factory first —
1050
+ // the base layer, overlays, minimap and runtime map_type switches
1051
+ // all funnel through here, so one check covers them
1052
+ const factory = this.options.tile_source_factory;
1053
+ if (typeof factory === "function") {
1054
+ const custom = factory(map_type, {
1055
+ options: this.options,
1056
+ createDefault: () => this._createDefaultTileLayer(map_type),
1057
+ });
1058
+ if (custom) {
1059
+ // any ol/layer/Layer passes through as-is — including
1060
+ // subclasses without a getSource (e.g. an Allmaps
1061
+ // WarpedMapLayer), which must not be wrapped; a bare Source is
1062
+ // wrapped in a TileLayer
1063
+ if (
1064
+ custom instanceof Layer ||
1065
+ typeof (custom as { getSource?: unknown }).getSource === "function"
1066
+ ) {
1067
+ return custom as Layer;
1068
+ }
1069
+ return new TileLayer({ source: custom as unknown as XYZ });
1070
+ }
1071
+ }
1072
+ return this._createDefaultTileLayer(map_type);
1073
+ }
1074
+
1075
+ /**
1076
+ * A layer's source, or null when it has none. Custom `Layer` subclasses
1077
+ * passed through `_createTileLayer` (e.g. Allmaps' `WarpedMapLayer`)
1078
+ * deliberately have no `getSource`, so every read goes through here
1079
+ * instead of calling the method directly.
1080
+ */
1081
+ _sourceOf(layer: Layer | null | undefined): Source | null {
1082
+ if (!layer || typeof (layer as { getSource?: unknown }).getSource !== "function") {
1083
+ return null;
1084
+ }
1085
+ return (layer as TileLayer).getSource();
1086
+ }
1087
+
1088
+ /**
1089
+ * Move the map into another element, keeping OpenLayers in step.
1090
+ *
1091
+ * `DomMixed.addTo()` (the original semantics, unchanged: append the map
1092
+ * container, fire `added`) only moves DOM — the `ol/Map` still has the
1093
+ * viewport it measured at construction, so it would keep painting at the
1094
+ * old size. Re-attach the target, which `removeFrom()` detached, and
1095
+ * re-measure. The argument must be the new parent.
1096
+ */
1097
+ addTo(container: HTMLElement): this {
1098
+ super.addTo(container);
1099
+ this._map.setTarget(this._el.map);
1100
+ this._map.updateSize();
1101
+ return this;
1102
+ }
1103
+
1104
+ /**
1105
+ * Move the map out of `container`. Untargets the OpenLayers map so it
1106
+ * does not render into a detached node; a later {@link addTo} re-attaches
1107
+ * it. The argument must be the current parent.
1108
+ */
1109
+ removeFrom(container: HTMLElement): this {
1110
+ super.removeFrom(container);
1111
+ this._map.setTarget(undefined);
1112
+ return this;
1113
+ }
1114
+
1115
+ /**
1116
+ * True when the map is a plain image in an `EPSG:4326` "image space"
1117
+ * rather than a geographic map: a IIIF image presented as a picture of
1118
+ * the world (`map_as_image`), not georeferenced.
1119
+ *
1120
+ * In this mode the view projection is `EPSG:4326` with degrees as
1121
+ * resolution and the view is fitted to the image extent, so
1122
+ * `getCenter()`/`getZoom()` are in degrees/pixels-per-degree — not
1123
+ * mercator metres. Marker locations are pixel offsets into the image,
1124
+ * not lat/lon. Georeferenced IIIF (`map_bbox` with `map_as_image`
1125
+ * omitted) is a normal mercator map; use `isImageSpace()` rather than
1126
+ * testing `map_type` to tell the two apart.
1127
+ */
1128
+ isImageSpace(): boolean {
1129
+ return this.options.map_type === "iiif" && this.options.map_as_image === true;
1130
+ }
1131
+
1132
+ /**
1133
+ * Stop the line animation, drop the minimap control and dispose the
1134
+ * OpenLayers map. Called by `StoryMap.dispose()`; the engine must not be
1135
+ * used afterwards.
1136
+ */
1137
+ dispose(): void {
1138
+ this._disposed = true;
1139
+ // invalidate in-flight static probes and info.json fetches: their
1140
+ // continuations check the flag or their token and resolve quietly
1141
+ this._static_probe_token++;
1142
+ if (this._line_animation !== null) {
1143
+ cancelAnimationFrame(this._line_animation);
1144
+ this._line_animation = null;
1145
+ }
1146
+ if (this._loadend_key) {
1147
+ unByKey(this._loadend_key);
1148
+ this._loadend_key = null;
1149
+ }
1150
+ if (this._onWheelBound) {
1151
+ this._el.map?.removeEventListener("wheel", this._onWheelBound);
1152
+ this._onWheelBound = null;
1153
+ }
1154
+ // markers own their click listener and an overlay; disposing the
1155
+ // collection releases both
1156
+ for (const marker of this._markers) {
1157
+ marker.dispose?.();
1158
+ }
1159
+ this._markers = [];
1160
+ this._mask_el?.remove();
1161
+ this._mask_el = null;
1162
+ this._basemap_cache.clear();
1163
+ this._slide_probe_tokens.clear();
1164
+ this._slide_basemap = null;
1165
+ this._story_basemap_layer = null;
1166
+ this._imgoverlay_layer = null;
1167
+ // detach the viewport first: dispose() alone leaves the canvas in
1168
+ // the caller's DOM
1169
+ this._map.setTarget(undefined);
1170
+ this._map.dispose();
1171
+ }
1172
+
1173
+ /**
1174
+ * Fire `imageready` once `source` is usable (and immediately when it
1175
+ * already is). Image sources — IIIF, zoomify, the deferred tile layer —
1176
+ * attach asynchronously, so this is the only outward signal that the
1177
+ * imagery is really on the map; the `loaded` event can fire before the
1178
+ * source is attached at all.
1179
+ */
1180
+ _fireImageready(source: { getState?(): string }, kind: ImagereadyKind, layer?: Layer): void {
1181
+ // one event per source: the base layer and the minimap can be asked
1182
+ // about the same source, and a host toggling layers should not have
1183
+ // to filter duplicates
1184
+ if (this._imageready_fired.has(source)) return;
1185
+ this._imageready_fired.add(source);
1186
+ const payload = { source, kind, layer: layer ?? null };
1187
+ if (typeof source.getState !== "function" || source.getState() === "ready") {
1188
+ this.fire("imageready", payload);
1189
+ return;
1190
+ }
1191
+ const onChange = () => {
1192
+ if (source.getState?.() === "ready") {
1193
+ (source as { un?: (t: string, f: () => void) => void }).un?.("change", onChange);
1194
+ this.fire("imageready", payload);
1195
+ }
1196
+ };
1197
+ (source as { once?: (t: string, f: () => void) => void }).once?.("change", onChange);
1198
+ }
1199
+
1200
+ /** Computed zooms take precedence over the authored slide zoom. */
1201
+ _markerZoom(index: number): number | undefined {
1202
+ return this._marker_zooms[index] ?? super._markerZoom(index);
1203
+ }
1204
+
1205
+ /**
1206
+ * The TileJSON metadata, but only when it is describing `url`.
1207
+ *
1208
+ * `tilejson` describes the map's tile source, so its `tiles` template is
1209
+ * the one the map_type resolved to. When it names a different template it
1210
+ * is not about this source, and neither the zoom ladder nor the initial
1211
+ * view should be taken from it.
1212
+ */
1213
+ _tilejsonFor(url: string): StorymapTilejson | undefined {
1214
+ const tilejson = this.options.tilejson;
1215
+ if (tilejson === undefined) return undefined;
1216
+ const tiles = Array.isArray(tilejson.tiles) ? tilejson.tiles[0] : tilejson.tiles;
1217
+ return tiles === url ? tilejson : undefined;
1218
+ }
1219
+
1220
+ /**
1221
+ * An XYZ source for a URL template, honouring the TileJSON metadata of the
1222
+ * map's own tile source if it is describing this template (§2.9).
1223
+ *
1224
+ * `minzoom`/`maxzoom` become the tile grid's zoom range, so OpenLayers asks
1225
+ * for levels the service actually has and stops past the ones it does not.
1226
+ * `scheme: "tms"` is the TMS row order, which is the same URL with the tile
1227
+ * row counted from the bottom.
1228
+ */
1229
+ _xyzSource(url: string): XYZ {
1230
+ const tilejson = this._tilejsonFor(url);
1231
+ const options: Record<string, unknown> = {
1232
+ url,
1233
+ attributions: this._sourceAttributions(url),
1234
+ crossOrigin: "anonymous",
1235
+ };
1236
+ if (tilejson !== undefined) {
1237
+ if (tilejson.minzoom !== undefined || tilejson.maxzoom !== undefined) {
1238
+ options.minZoom = tilejson.minzoom ?? 0;
1239
+ options.maxZoom = tilejson.maxzoom ?? MAX_ZOOM;
1240
+ }
1241
+ }
1242
+ const source = new XYZ(options as never);
1243
+ if (tilejson?.scheme === "tms") {
1244
+ // TMS numbers tile rows from the bottom of the pyramid; XYZ counts
1245
+ // them from the top, so the row is flipped against the source's
1246
+ // deepest zoom.
1247
+ //
1248
+ // This is assigned rather than passed as the `tileUrlFunction`
1249
+ // constructor option, which this version of ol/source/XYZ does not
1250
+ // read.
1251
+ const deepest = tilejson.maxzoom ?? MAX_ZOOM;
1252
+ source.tileUrlFunction = (tileCoord: number[] | undefined): string | undefined => {
1253
+ if (tileCoord === undefined) return undefined;
1254
+ // OpenLayers hands over [z, x, y]
1255
+ const [z, x, y] = tileCoord;
1256
+ return url
1257
+ .replace("{z}", String(z))
1258
+ .replace("{x}", String(x))
1259
+ .replace("{y}", String(2 ** deepest - 1 - y))
1260
+ .replace("{ratio}", "");
1261
+ };
1262
+ }
1263
+ return source;
1264
+ }
1265
+
1266
+ _createDefaultTileLayer(map_type: string): TileLayer {
481
1267
  const _map_type_arr = map_type.split(":");
482
1268
 
483
1269
  switch (_map_type_arr[0]) {
@@ -494,7 +1280,7 @@ export default class OpenLayers extends Map {
494
1280
  return new TileLayer({
495
1281
  source: new XYZ({
496
1282
  url: mapbox_url,
497
- attributions: [],
1283
+ attributions: this._sourceAttributions(map_type),
498
1284
  crossOrigin: "anonymous",
499
1285
  }),
500
1286
  });
@@ -502,11 +1288,18 @@ export default class OpenLayers extends Map {
502
1288
  console.error(
503
1289
  "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.",
504
1290
  );
505
- return new TileLayer({ source: new OSM({ attributions: [] }) });
1291
+ return new TileLayer({
1292
+ source: new OSM({ attributions: this._sourceAttributions(map_type) }),
1293
+ });
506
1294
  }
507
1295
 
508
1296
  case "stadia": {
509
- let style_url = "osm:standard";
1297
+ // Stadia hosts a style *set*, not a single style. A bare
1298
+ // `map_type: "stadia"` used to fall back to the literal
1299
+ // "osm:standard", which is not one of them — every tile 404'd.
1300
+ // `alidade_smooth` is the correct default here (matches the
1301
+ // remapped `stamen` case below).
1302
+ let style_url = "alidade_smooth";
510
1303
  if (_map_type_arr.length > 1) {
511
1304
  style_url = _map_type_arr.slice(1).join(":");
512
1305
  if (this.options.map_access_token) {
@@ -515,8 +1308,8 @@ export default class OpenLayers extends Map {
515
1308
  }
516
1309
  return new TileLayer({
517
1310
  source: new XYZ({
518
- url: `https://tiles.stadiamaps.com/tiles/${style_url}/{z}/{x}/{y}.png`,
519
- attributions: [],
1311
+ url: `https://tiles.stadiamaps.com/tiles/${style_url}/{z}/{x}/{y}{r}.png`,
1312
+ attributions: this._sourceAttributions(map_type),
520
1313
  }),
521
1314
  });
522
1315
  }
@@ -526,12 +1319,23 @@ export default class OpenLayers extends Map {
526
1319
  return new TileLayer({
527
1320
  source: new XYZ({
528
1321
  url: "https://tiles.stadiamaps.com/tiles/stamen_toner_lite/{z}/{x}/{y}.png",
529
- attributions: [],
1322
+ attributions: this._sourceAttributions(map_type),
530
1323
  }),
531
1324
  });
532
1325
 
533
1326
  case "iiif": {
534
1327
  const iiif_layer: TileLayer = new TileLayer();
1328
+ // a static-image tour states no info.json at all: skip the
1329
+ // fetch and go straight to the fallback (which probes when
1330
+ // no dimensions are stated either)
1331
+ if (typeof this.options.iiif.url !== "string" || this.options.iiif.url === "") {
1332
+ if (!this._useStaticFallback(iiif_layer)) {
1333
+ console.error(
1334
+ "IIIF info.json URL is missing and no static fallback is stated.",
1335
+ );
1336
+ }
1337
+ return iiif_layer;
1338
+ }
535
1339
  fetch(this.options.iiif.url)
536
1340
  .then((r) => r.json())
537
1341
  .then((info: unknown) => {
@@ -546,10 +1350,15 @@ export default class OpenLayers extends Map {
546
1350
  typeof fallback.width !== "number" ||
547
1351
  typeof fallback.height !== "number"
548
1352
  ) {
549
- console.error(
550
- "IIIF info.json is missing width/height:",
551
- this.options.iiif.url,
552
- );
1353
+ // without dimensions there is no grid to build;
1354
+ // fall back to the static image when the story
1355
+ // states dimensions of its own
1356
+ if (!this._useStaticFallback(iiif_layer)) {
1357
+ console.error(
1358
+ "IIIF info.json is missing width/height:",
1359
+ this.options.iiif.url,
1360
+ );
1361
+ }
553
1362
  return;
554
1363
  }
555
1364
  const source = new IIIF({
@@ -560,6 +1369,7 @@ export default class OpenLayers extends Map {
560
1369
  attributions: this.options.iiif.attribution || [],
561
1370
  });
562
1371
  iiif_layer.setSource(source);
1372
+ this._fireImageready(source, "iiif", iiif_layer);
563
1373
  if (source.getState() === "ready") {
564
1374
  this._markerOverview();
565
1375
  } else {
@@ -571,11 +1381,18 @@ export default class OpenLayers extends Map {
571
1381
  }
572
1382
  })
573
1383
  .catch((err) =>
574
- console.error(
575
- "IIIF info.json could not be loaded:",
576
- this.options.iiif.url,
577
- err?.stack || err,
578
- ),
1384
+ // the tile pyramid is unreachable: paint the static
1385
+ // image instead when the story states one, so the
1386
+ // tour still shows its picture
1387
+ {
1388
+ if (!this._useStaticFallback(iiif_layer)) {
1389
+ console.error(
1390
+ "IIIF info.json could not be loaded:",
1391
+ this.options.iiif.url,
1392
+ err?.stack || err,
1393
+ );
1394
+ }
1395
+ },
579
1396
  );
580
1397
  return iiif_layer;
581
1398
  }
@@ -587,15 +1404,13 @@ export default class OpenLayers extends Map {
587
1404
  if (!map_type.includes("{z}")) {
588
1405
  return this._createVectorStyleLayer(map_type);
589
1406
  }
590
- return new TileLayer({
591
- source: new XYZ({ url: map_type, attributions: [], crossOrigin: "anonymous" }),
592
- });
1407
+ return new TileLayer({ source: this._xyzSource(map_type) });
593
1408
 
594
1409
  case "ch-watercolor":
595
1410
  return new TileLayer({
596
1411
  source: new XYZ({
597
1412
  url: "https://watercolormaps.collection.cooperhewitt.org/tile/watercolor/{z}/{x}/{y}.jpg",
598
- attributions: [],
1413
+ attributions: this._sourceAttributions(map_type),
599
1414
  maxZoom: 16,
600
1415
  }),
601
1416
  });
@@ -611,50 +1426,56 @@ export default class OpenLayers extends Map {
611
1426
  console.error(
612
1427
  "StoryMapJS: map_type 'zoomify' needs a zoomify image pyramid (path, width, height) in the storymap data.",
613
1428
  );
614
- return new TileLayer({ source: new OSM({ attributions: [] }) });
1429
+ return new TileLayer({
1430
+ source: new OSM({ attributions: this._sourceAttributions(map_type) }),
1431
+ });
615
1432
  }
616
1433
  const path = (this.options.zoomify as { path?: string }).path ?? "";
617
1434
  const { sizes, maxZoom: pyramidMaxZoom } = pyramid;
618
1435
  const gridX = (z: number) => Math.ceil(sizes[z][0] / 256);
619
1436
  const gridY = (z: number) => Math.ceil(sizes[z][1] / 256);
620
1437
 
621
- return new TileLayer({
622
- source: new XYZ({
623
- tileGrid: pyramid.tileGrid,
624
- crossOrigin: "anonymous",
625
- attributions: [],
626
- tileUrlFunction: (tile: number[]) => {
627
- const [tileZ, x, y] = tile;
628
- // the ladder is shifted one level down: mercator
629
- // zoom z serves the pyramid level max(0, z - 1)
630
- const z = Math.max(0, tileZ - 1);
631
- if (z > pyramidMaxZoom) return undefined;
632
- if (x < 0 || x >= gridX(z) || y < 0 || y >= gridY(z)) {
633
- return undefined;
634
- }
635
- // TileGroup index: the running tile number ÷ 256
636
- let num = 0;
637
- for (let zz = 0; zz < z; zz++) {
638
- num += gridX(zz) * gridY(zz);
639
- }
640
- num += y * gridX(z) + x;
641
- return `${path}TileGroup${Math.floor(num / 256)}/${z}-${x}-${y}.jpg`;
642
- },
643
- // Zoomify edge tiles are cropped to the image bounds
644
- // (e.g. a 256x19 bottom strip); OpenLayers draws the
645
- // loaded image over the whole 256x256 tile box, which
646
- // stretched those strips across the cell (the smeared
647
- // bottom in the Bosch overview, stretched right/bottom
648
- // edges in the Literary Trail). Pad them onto a full
649
- // tile canvas instead.
650
- tileLoadFunction: (tile, src) => {
651
- const imageTile = tile as ImageTile;
652
- const image = imageTile.getImage() as HTMLImageElement;
653
- image.onload = () => padCroppedZoomifyTile(imageTile, image);
654
- image.src = src;
655
- },
656
- }),
1438
+ const zoomify_source = new XYZ({
1439
+ tileGrid: pyramid.tileGrid,
1440
+ crossOrigin: "anonymous",
1441
+ attributions: this._sourceAttributions(map_type),
1442
+ tileUrlFunction: (tile: number[]) => {
1443
+ const [tileZ, x, y] = tile;
1444
+ // the ladder is shifted one level down: mercator
1445
+ // zoom z serves the pyramid level max(0, z - 1)
1446
+ const z = Math.max(0, tileZ - 1);
1447
+ if (z > pyramidMaxZoom) return undefined;
1448
+ if (x < 0 || x >= gridX(z) || y < 0 || y >= gridY(z)) {
1449
+ return undefined;
1450
+ }
1451
+ // TileGroup index: the running tile number ÷ 256
1452
+ let num = 0;
1453
+ for (let zz = 0; zz < z; zz++) {
1454
+ num += gridX(zz) * gridY(zz);
1455
+ }
1456
+ num += y * gridX(z) + x;
1457
+ return `${path}TileGroup${Math.floor(num / 256)}/${z}-${x}-${y}.jpg`;
1458
+ },
1459
+ // Zoomify edge tiles are cropped to the image bounds
1460
+ // (e.g. a 256x19 bottom strip); OpenLayers draws the
1461
+ // loaded image over the whole 256x256 tile box, which
1462
+ // stretched those strips across the cell (the smeared
1463
+ // bottom in the Bosch overview, stretched right/bottom
1464
+ // edges in the Literary Trail). Pad them onto a full
1465
+ // tile canvas instead.
1466
+ tileLoadFunction: (tile, src) => {
1467
+ const imageTile = tile as ImageTile;
1468
+ const image = imageTile.getImage() as HTMLImageElement;
1469
+ image.onload = () => padCroppedZoomifyTile(imageTile, image);
1470
+ image.src = src;
1471
+ },
657
1472
  });
1473
+ const zoomify_layer = new TileLayer({ source: zoomify_source });
1474
+ // the zoomify base never announced itself, so a host waiting
1475
+ // on imageready hung on exactly the legacy stories that need
1476
+ // it most; the per-source dedup keeps this to one event
1477
+ this._fireImageready(zoomify_source, "zoomify", zoomify_layer);
1478
+ return zoomify_layer;
658
1479
  }
659
1480
 
660
1481
  case "osm": {
@@ -667,7 +1488,9 @@ export default class OpenLayers extends Map {
667
1488
  `https://tiles.openfreemap.org/styles/${style_name}`,
668
1489
  );
669
1490
  }
670
- return new TileLayer({ source: new OSM({ attributions: [] }) });
1491
+ return new TileLayer({
1492
+ source: new OSM({ attributions: this._sourceAttributions(map_type) }),
1493
+ });
671
1494
  }
672
1495
  default: {
673
1496
  // Relative/custom templates (./tiles/{z}/{x}/{y}.png,
@@ -678,18 +1501,14 @@ export default class OpenLayers extends Map {
678
1501
  // {z} render as vector styles, anything else falls back to
679
1502
  // OSM.
680
1503
  if (map_type.includes("{z}")) {
681
- return new TileLayer({
682
- source: new XYZ({
683
- url: map_type,
684
- attributions: [],
685
- crossOrigin: "anonymous",
686
- }),
687
- });
1504
+ return new TileLayer({ source: this._xyzSource(map_type) });
688
1505
  }
689
1506
  if (map_type.includes("/") || map_type.endsWith(".json")) {
690
1507
  return this._createVectorStyleLayer(map_type);
691
1508
  }
692
- return new TileLayer({ source: new OSM({ attributions: [] }) });
1509
+ return new TileLayer({
1510
+ source: new OSM({ attributions: this._sourceAttributions(map_type) }),
1511
+ });
693
1512
  }
694
1513
  }
695
1514
  }
@@ -718,15 +1537,10 @@ export default class OpenLayers extends Map {
718
1537
  // consent mode: the minimap layer is only created once map tiles are
719
1538
  // allowed — creating an IIIF/vector layer object fetches info.json or
720
1539
  // the style JSON immediately, which must not happen while denied
721
- const consent = consentManagerOf(this.options);
722
- const tile_service = consentMessage("consent_service_tiles", "map tiles");
723
- const tiles_allowed = !(
724
- this.options.consent_required &&
725
- consent &&
726
- !consent.isGranted(tile_service)
727
- );
728
- this._tile_layer_mini = tiles_allowed ? this._createTileLayer(this.options.map_type) : null;
729
- const is_image_map = this.options.map_type === "iiif" && this.options.map_as_image;
1540
+ this._tile_layer_mini = this._tilesAllowed()
1541
+ ? this._createTileLayer(this.options.map_type)
1542
+ : null;
1543
+ const is_image_map = this.isImageSpace();
730
1544
  // Legacy zoomify maps are mercator-based: the minimap fits the image's
731
1545
  // mercator bounds with a free zoom so the whole image stays visible
732
1546
  // at a downscaled (sharp) pyramid level
@@ -828,9 +1642,7 @@ export default class OpenLayers extends Map {
828
1642
  // the minimap starts collapsed (no layout size yet) in its
829
1643
  // 150x100 box — fall back to that until it expands
830
1644
  const size =
831
- raw_size && raw_size[0] >= 50 && raw_size[1] >= 50
832
- ? raw_size
833
- : [150, 100];
1645
+ raw_size && raw_size[0] >= 50 && raw_size[1] >= 50 ? raw_size : [150, 100];
834
1646
  overview_map.getView().fit(zoomify_pyramid.extent, {
835
1647
  size: size,
836
1648
  });
@@ -842,10 +1654,15 @@ export default class OpenLayers extends Map {
842
1654
  } else if (!is_zoomify && this.bounds_array && this.bounds_array.length) {
843
1655
  // the minimap shows the story's world: with a bbox set, markers
844
1656
  // outside of the box are unreachable and must not skew the fit
845
- this._fitView(this._mini_map.getOverviewMap(), this.bounds_array, 0, this._bboxExtent() !== null);
1657
+ this._fitView(
1658
+ this._mini_map.getOverviewMap(),
1659
+ this.bounds_array,
1660
+ 0,
1661
+ this._bboxExtent() !== null,
1662
+ );
846
1663
  }
847
1664
 
848
- if (this.options.map_type === "iiif" && this.options.map_as_image) {
1665
+ if (this.isImageSpace()) {
849
1666
  // in image mode there are no geo markers to fit, so show the
850
1667
  // whole image instead (issues #465, #355)
851
1668
  this._fitMiniMapToImage();
@@ -856,7 +1673,7 @@ export default class OpenLayers extends Map {
856
1673
  if (!this._tile_layer_mini) return;
857
1674
  const fit_mini_image = () => {
858
1675
  try {
859
- const mini_source = this._tile_layer_mini?.getSource() as {
1676
+ const mini_source = this._sourceOf(this._tile_layer_mini) as {
860
1677
  getTileGrid?: () => { getExtent(): number[] };
861
1678
  } | null;
862
1679
  const grid = mini_source?.getTileGrid?.();
@@ -874,8 +1691,17 @@ export default class OpenLayers extends Map {
874
1691
  console.warn("IIIF minimap fit failed:", e);
875
1692
  }
876
1693
  };
877
- const mini_source = this._tile_layer_mini.getSource();
1694
+ const mini_source = this._sourceOf(this._tile_layer_mini);
878
1695
  if (mini_source) {
1696
+ this._fireImageready(
1697
+ mini_source,
1698
+ this.options.map_type === "zoomify"
1699
+ ? "zoomify"
1700
+ : this.options.map_type === "iiif"
1701
+ ? "iiif"
1702
+ : "tiles",
1703
+ this._tile_layer_mini,
1704
+ );
879
1705
  if (mini_source.getState() === "ready") {
880
1706
  fit_mini_image();
881
1707
  } else {
@@ -886,7 +1712,7 @@ export default class OpenLayers extends Map {
886
1712
  } else {
887
1713
  // the mini layer sets its source asynchronously
888
1714
  this._tile_layer_mini.once("change:source", () => {
889
- const src = this._tile_layer_mini?.getSource();
1715
+ const src = this._sourceOf(this._tile_layer_mini);
890
1716
  if (!src) return;
891
1717
  if (src.getState() === "ready") {
892
1718
  fit_mini_image();
@@ -904,6 +1730,10 @@ export default class OpenLayers extends Map {
904
1730
  _createMarker(d: StorymapSlide): void {
905
1731
  const marker = new OpenLayersMapMarker(d, this.options);
906
1732
  marker.on("markerclick", this._onMarkerClick, this);
1733
+ // Forward popup cards to the map surface (fresh payloads, so the
1734
+ // marker stays the event target of the original, not the re-fire).
1735
+ marker.on("popupopen", this._onMarkerPopupOpen, this);
1736
+ marker.on("popupclose", this._onMarkerPopupClose, this);
907
1737
  this._addMarker(marker);
908
1738
  this._markers.push(marker);
909
1739
  marker.marker_number = this._markers.length - 1;
@@ -923,12 +1753,16 @@ export default class OpenLayers extends Map {
923
1753
  if (this._map.getView().getProjection().getCode() === "EPSG:4326") {
924
1754
  return;
925
1755
  }
926
- const real = this._markers.filter(
927
- (m) => m.data.real_marker && m.data.location?.lon !== undefined,
928
- );
929
- const unwrapped = this._unwrapLongitudes(real.map((m) => m.data.location.lon as number));
930
- real.forEach((m, i) => {
931
- m._overlay?.setPosition(fromLonLat([unwrapped[i], m.data.location.lat as number]));
1756
+ const real: { marker: OpenLayersMapMarker; latlon: LatLngLiteral }[] = [];
1757
+ for (const marker of this._markers) {
1758
+ const latlon = marker.latLon();
1759
+ if (latlon) {
1760
+ real.push({ marker: marker, latlon: latlon });
1761
+ }
1762
+ }
1763
+ const unwrapped = this._unwrapLongitudes(real.map((entry) => entry.latlon.lon));
1764
+ real.forEach((entry, i) => {
1765
+ entry.marker._overlay?.setPosition(fromLonLat([unwrapped[i], entry.latlon.lat]));
932
1766
  });
933
1767
  }
934
1768
 
@@ -941,18 +1775,16 @@ export default class OpenLayers extends Map {
941
1775
  /* Marker helpers
942
1776
  ================================================== */
943
1777
  _getAllMarkersBounds(markers_array: OpenLayersMapMarker[]): number[][] {
944
- const coords = [];
945
- for (let i = 0; i < markers_array.length; i++) {
946
- if (markers_array[i].data.real_marker) {
947
- coords.push([
948
- markers_array[i].data.location.lon,
949
- markers_array[i].data.location.lat,
950
- ]);
1778
+ const coords: number[][] = [];
1779
+ for (const marker of markers_array) {
1780
+ const latlon = marker.latLon();
1781
+ if (latlon) {
1782
+ coords.push([latlon.lon, latlon.lat]);
951
1783
  }
952
1784
  }
953
1785
  // unwrap dateline crossings so fits don't span the whole globe
954
1786
  // (issue #381)
955
- const lons = this._unwrapLongitudes(coords.map((c) => c[0] as number));
1787
+ const lons = this._unwrapLongitudes(coords.map((c) => c[0]));
956
1788
  return coords.map((c, i) => [lons[i], c[1]]);
957
1789
  }
958
1790
 
@@ -986,8 +1818,7 @@ export default class OpenLayers extends Map {
986
1818
  _bboxExtent(): number[] | null {
987
1819
  const bbox = this.options.map_bbox as number[] | null | undefined;
988
1820
  if (!bbox || bbox.length !== 4) return null;
989
- const is_image_space = this.options.map_type === "iiif" && this.options.map_as_image;
990
- if (is_image_space) return bbox;
1821
+ if (this.isImageSpace()) return bbox;
991
1822
  // NB: fromLonLat transforms a single [lon, lat] pair — transform the
992
1823
  // two corners separately
993
1824
  const min = fromLonLat([bbox[0], bbox[1]]);
@@ -1004,7 +1835,17 @@ export default class OpenLayers extends Map {
1004
1835
  _opaquePanelPadding(): [number, number, number, number] {
1005
1836
  const padding: [number, number, number, number] = [15, 15, 15, 15];
1006
1837
  if (typeof document === "undefined") return padding;
1007
- const panel = document.querySelector(".vco-storyslider .vco-slide.vco-active .vco-text");
1838
+ // Scope the search to THIS viewer's root. The slide panel is a sibling
1839
+ // of the map, not a descendant, so it is not reachable from
1840
+ // this._el.container (the .vco-map div); a document-wide query would
1841
+ // find whichever panel comes first on the page, and then measure this
1842
+ // map against another viewer's rectangle — the overlap test below then
1843
+ // decides padding for a panel that may not overlap this map at all.
1844
+ const root = this._el.container.closest?.(".vco-storymap");
1845
+ // no viewer root means the map is standalone, so there is no slide
1846
+ // panel to make room for
1847
+ if (!root) return padding;
1848
+ const panel = root.querySelector(".vco-storyslider .vco-slide.vco-active .vco-text");
1008
1849
  if (!panel) return padding;
1009
1850
  const bg = getComputedStyle(panel).backgroundColor;
1010
1851
  const match = /rgba?\(([^)]+)\)/.exec(bg);
@@ -1027,7 +1868,7 @@ export default class OpenLayers extends Map {
1027
1868
  panel_rect.bottom > map_rect.top;
1028
1869
  if (!overlaps) return padding;
1029
1870
  const layout = this.options.layout;
1030
- if (layout === "portrait") {
1871
+ if (layout === "portrait" || this.options.textmode === "bottom") {
1031
1872
  padding[2] += Math.max(0, map_rect.bottom - panel_rect.top);
1032
1873
  } else {
1033
1874
  padding[1] += Math.max(0, panel_rect.right - map_rect.left);
@@ -1061,53 +1902,61 @@ export default class OpenLayers extends Map {
1061
1902
  size: ol_map.getSize(),
1062
1903
  padding: this._opaquePanelPadding(),
1063
1904
  maxZoom: 12,
1064
- duration: duration,
1065
- easing: this.options.ease as ((t: number) => number) | undefined,
1905
+ duration: prefersReducedMotion() ? 0 : duration,
1906
+ easing: this._easing,
1066
1907
  });
1067
1908
  }
1068
1909
 
1069
1910
  _calculateMarkerZooms(): void {
1070
- for (let i = 0; i < this._markers.length; i++) {
1071
- if (this._markers[i].data.location) {
1072
- const marker = this._markers[i];
1073
- let marker_location, calculated_zoom;
1074
-
1075
- // MARKER LOCATION
1076
- if (marker.data.type && marker.data.type === "overview") {
1077
- marker_location = this._getMapCenter(true);
1078
- } else {
1079
- marker_location = marker.location();
1080
- }
1911
+ const center = this._getMapCenter();
1912
+ for (const [i, marker] of this._markers.entries()) {
1913
+ // a marker with no location object at all is not navigable
1914
+ if (!marker.data.location) {
1915
+ continue;
1916
+ }
1917
+ let calculated_zoom: number | undefined;
1918
+
1919
+ // MARKER LOCATION — the overview fits around the current view
1920
+ // rather than a point on the map
1921
+ const marker_location =
1922
+ marker.data.type && marker.data.type === "overview" ? center : marker.location();
1923
+ if (!marker_location) {
1924
+ continue;
1925
+ }
1081
1926
 
1082
- // Fit-based zoom: zoom the view to this marker and neighbors
1083
- const prev_marker =
1084
- i > 0 ? this._markers[i - 1].location() : this._getMapCenter(true);
1085
- const next_marker =
1086
- i < this._markers.length - 1
1087
- ? this._markers[i + 1].location()
1088
- : this._getMapCenter(true);
1089
-
1090
- const prev_marker_zoom = this._calculateZoomChange(prev_marker, marker_location);
1091
- const next_marker_zoom = this._calculateZoomChange(next_marker, marker_location);
1092
-
1093
- if (prev_marker_zoom && prev_marker_zoom < next_marker_zoom) {
1094
- calculated_zoom = prev_marker_zoom;
1095
- } else if (next_marker_zoom) {
1096
- calculated_zoom = next_marker_zoom;
1097
- } else {
1098
- calculated_zoom = prev_marker_zoom;
1099
- }
1927
+ // Fit-based zoom: zoom the view to this marker and neighbors
1928
+ const prev_marker = i > 0 ? this._markers[i - 1].location() : center;
1929
+ const next_marker =
1930
+ i < this._markers.length - 1 ? this._markers[i + 1].location() : center;
1931
+
1932
+ const prev_marker_zoom = prev_marker
1933
+ ? this._calculateZoomChange(prev_marker, marker_location)
1934
+ : undefined;
1935
+ const next_marker_zoom = next_marker
1936
+ ? this._calculateZoomChange(next_marker, marker_location)
1937
+ : undefined;
1938
+
1939
+ if (prev_marker_zoom && prev_marker_zoom < (next_marker_zoom ?? Infinity)) {
1940
+ calculated_zoom = prev_marker_zoom;
1941
+ } else if (next_marker_zoom) {
1942
+ calculated_zoom = next_marker_zoom;
1943
+ } else {
1944
+ calculated_zoom = prev_marker_zoom;
1945
+ }
1100
1946
 
1101
- if (
1102
- this.options.map_center_offset &&
1103
- (this.options.map_center_offset.left !== 0 ||
1104
- this.options.map_center_offset.top !== 0)
1105
- ) {
1106
- calculated_zoom = calculated_zoom - 1;
1107
- }
1947
+ if (calculated_zoom === undefined) {
1948
+ continue;
1949
+ }
1108
1950
 
1109
- marker.data.location.zoom = calculated_zoom;
1951
+ if (
1952
+ this.options.map_center_offset &&
1953
+ (this.options.map_center_offset.left !== 0 ||
1954
+ this.options.map_center_offset.top !== 0)
1955
+ ) {
1956
+ calculated_zoom = calculated_zoom - 1;
1110
1957
  }
1958
+
1959
+ this._marker_zooms[i] = calculated_zoom;
1111
1960
  }
1112
1961
  }
1113
1962
 
@@ -1149,25 +1998,32 @@ export default class OpenLayers extends Map {
1149
1998
  this._map.addLayer(line);
1150
1999
  }
1151
2000
 
1152
- _addToLine(line: VectorLayer, d: LinePoint): void {
2001
+ _addToLine(line: VectorLayer, d: StorymapSlide): void {
1153
2002
  // Append a point to the line's geometry
1154
2003
  const source = line.getSource();
2004
+ if (!source) {
2005
+ return;
2006
+ }
1155
2007
  let feature = source.getFeatures()[0];
1156
2008
  if (!feature) {
1157
2009
  feature = new Feature({ geometry: new LineString([]) });
1158
2010
  source.addFeature(feature);
1159
2011
  }
1160
2012
  const coords = feature.getGeometry().getCoordinates();
1161
- let lon = d.location.lon;
2013
+ let lon = d.location?.lon;
2014
+ const lat = d.location?.lat;
2015
+ if (lat === undefined || lon === undefined) {
2016
+ return;
2017
+ }
1162
2018
  // unwrap dateline crossings against the previous point (issue #381).
1163
2019
  // NB: read the previous longitude straight from meters — toLonLat()
1164
2020
  // wraps into [-180, 180] and would collapse already-unwrapped values.
1165
- if (coords.length > 0 && lon !== undefined) {
2021
+ if (coords.length > 0) {
1166
2022
  const last_lon = coords[coords.length - 1][0] / 111319.49079327358;
1167
2023
  while (lon - last_lon > 180) lon -= 360;
1168
2024
  while (lon - last_lon < -180) lon += 360;
1169
2025
  }
1170
- coords.push(this._toViewCoords({ lat: d.location.lat, lon }));
2026
+ coords.push(this._toViewCoords({ lat, lon }));
1171
2027
  feature.getGeometry().setCoordinates(coords);
1172
2028
  }
1173
2029
 
@@ -1198,6 +2054,9 @@ export default class OpenLayers extends Map {
1198
2054
  const unwrapped = pts.map((p, i) => [lons[i], p[1]]);
1199
2055
  const view_coords = this._markerCoordsToViewCoords(unwrapped);
1200
2056
  const source = line.getSource();
2057
+ if (!source) {
2058
+ return;
2059
+ }
1201
2060
 
1202
2061
  const setGeometry = (coords: number[][]) => {
1203
2062
  source.clear();
@@ -1206,7 +2065,8 @@ export default class OpenLayers extends Map {
1206
2065
 
1207
2066
  this._cancelLineAnimation();
1208
2067
 
1209
- const duration = animate?.duration ?? 0;
2068
+ // Reduced motion draws the full path at once instead of tracing it.
2069
+ const duration = prefersReducedMotion() ? 0 : (animate?.duration ?? 0);
1210
2070
  if (duration <= 0 || view_coords.length < 2) {
1211
2071
  setGeometry(view_coords);
1212
2072
  return;
@@ -1267,7 +2127,7 @@ export default class OpenLayers extends Map {
1267
2127
  const suffix = grow_len >= 1 ? view_coords.slice(grow_len - 1) : null;
1268
2128
  const suffix_total = suffix ? this._pathLength(suffix) : 0;
1269
2129
 
1270
- const easing = this.options.ease as ((t: number) => number) | undefined;
2130
+ const easing = this._easing;
1271
2131
  const start_time = performance.now();
1272
2132
  if (prefix) {
1273
2133
  // Seed the already-traveled prefix so it stays red from frame 0
@@ -1352,24 +2212,52 @@ export default class OpenLayers extends Map {
1352
2212
  _panTo(loc: LatLngLiteral, animate?: boolean): void {
1353
2213
  this._map.getView().animate({
1354
2214
  center: this._toViewCoords(loc),
1355
- duration: this.options.duration,
1356
- easing: this.options.ease as ((t: number) => number) | undefined,
2215
+ duration: prefersReducedMotion() ? 0 : this.options.duration,
2216
+ easing: this._easing,
1357
2217
  });
1358
2218
  }
1359
2219
 
1360
2220
  _zoomTo(z: number, animate?: boolean): void {
1361
2221
  this._map.getView().animate({
1362
2222
  zoom: z,
1363
- duration: this.options.duration,
1364
- easing: this.options.ease as ((t: number) => number) | undefined,
2223
+ duration: prefersReducedMotion() ? 0 : this.options.duration,
2224
+ easing: this._easing,
1365
2225
  });
1366
2226
  }
1367
2227
 
1368
2228
  _viewTo(loc: StorymapSlideLocation, opts?: ViewToOptions): void {
2229
+ // Per-slide presentation first: basemap swap, filter grading and
2230
+ // spotlight mask are synchronous state, independent of movement.
2231
+ // Rotation joins the movement animation below (or _fitRegion's).
2232
+ // Slideshow tours only: internal documents move exactly as before.
2233
+ const feat = this.options.slideshow_source === true ? loc : undefined;
2234
+ this._switchSlideBasemap(feat);
2235
+ this._applySlideFilterAndMask(feat);
2236
+ // Image region stops (slideshow-style): in image mode the view is
2237
+ // EPSG:4326 where coordinates are raw image pixels — fit the xywh
2238
+ // region instead of flying to a point. Everything else (geo maps,
2239
+ // absent/invalid regions) keeps the point behavior unchanged.
2240
+ const is_image_space = this._map.getView().getProjection().getCode() === "EPSG:4326";
2241
+ const region =
2242
+ is_image_space && Array.isArray(loc.region) && loc.region.length === 4
2243
+ ? loc.region
2244
+ : null;
2245
+ if (region) {
2246
+ this._fitRegion(region, opts, feat);
2247
+ return;
2248
+ }
2249
+
2250
+ const start = this._latLngOf(loc);
2251
+ if (!start) {
2252
+ // location-less slide (region-only on a geo map): nothing to
2253
+ // fly to, but an explicit rotation still applies
2254
+ this._applySlideRotation(feat, opts?.duration, opts?.duration === 0);
2255
+ return;
2256
+ }
1369
2257
  let _animate = true,
1370
2258
  _duration = this.options.duration,
1371
2259
  _zoom = this._getMapZoom(),
1372
- _location: LatLngLiteral = { lat: loc.lat, lon: loc.lon };
2260
+ _location: LatLngLiteral = start;
1373
2261
 
1374
2262
  // Show Active Line
1375
2263
  if (!this.options.map_as_image) {
@@ -1400,15 +2288,25 @@ export default class OpenLayers extends Map {
1400
2288
  _location = this._getMapCenterOffset(_location, _zoom);
1401
2289
  }
1402
2290
 
2291
+ // A slide rotation joins the glide; the animation arg is only added
2292
+ // when the slide or the view is rotated, so unrotated stories
2293
+ // animate exactly as before.
2294
+ const _rotation = slideRotationRad(feat);
2295
+ const _current_rotation = this._map.getView().getRotation();
1403
2296
  this._map.getView().animate({
1404
2297
  center: this._toViewCoords(_location),
1405
2298
  zoom: _zoom,
1406
- duration: _animate ? _duration : 0,
1407
- easing: this.options.ease as ((t: number) => number) | undefined,
2299
+ ...(_rotation !== null || _current_rotation !== 0 ? { rotation: _rotation ?? 0 } : {}),
2300
+ duration: _animate && !prefersReducedMotion() ? _duration : 0,
2301
+ easing: this._easing,
1408
2302
  });
1409
2303
 
1410
2304
  if (this._mini_map && this.options.width > this.options.skinny_size) {
1411
- if (_zoom - 1 <= this.zoom_min_max.min) {
2305
+ // zoom_min_max stays null until the markers are measured (first
2306
+ // loadend), and `_zoom - 1 <= null` coerces to `<= 0` — the
2307
+ // minimap state was decided by an accidental coercion. Treat an
2308
+ // unmeasured range as "not zoomed out", i.e. keep it expanded.
2309
+ if (this.zoom_min_max.min !== null && _zoom - 1 <= this.zoom_min_max.min) {
1412
2310
  this._mini_map.setCollapsed(true);
1413
2311
  } else {
1414
2312
  this._mini_map.setCollapsed(false);
@@ -1416,37 +2314,370 @@ export default class OpenLayers extends Map {
1416
2314
  }
1417
2315
  }
1418
2316
 
2317
+ /**
2318
+ * Image region stop: fit the xywh bbox ([x, y, w, h] image pixels —
2319
+ * the EPSG:4326 image space) with the shared animation options, then
2320
+ * keep the minimap collapse state in sync like `_viewTo`. A slide
2321
+ * rotation follows the fit (fit() takes no rotation), plus the
2322
+ * filter/mask tail when the location is passed.
2323
+ */
2324
+ _fitRegion(
2325
+ region: [number, number, number, number],
2326
+ opts?: ViewToOptions,
2327
+ loc?: StorymapSlideLocation,
2328
+ ): void {
2329
+ const [x, y, w, h] = region;
2330
+ if (!(w > 0) || !(h > 0)) {
2331
+ return;
2332
+ }
2333
+ let _animate = true;
2334
+ let _duration = this.options.duration;
2335
+ if (opts && opts.duration !== undefined) {
2336
+ if (opts.duration === 0) {
2337
+ _animate = false;
2338
+ } else {
2339
+ _duration = opts.duration;
2340
+ }
2341
+ }
2342
+ this._map.getView().fit([x, y, x + w, y + h], {
2343
+ size: this._map.getSize(),
2344
+ padding: this._opaquePanelPadding(),
2345
+ duration: _animate && !prefersReducedMotion() ? _duration : 0,
2346
+ easing: this._easing,
2347
+ });
2348
+ if (loc) {
2349
+ this._applySlideRotation(loc, _duration, !_animate);
2350
+ this._applySlideFilterAndMask(loc);
2351
+ }
2352
+ if (this._mini_map && this.options.width > this.options.skinny_size) {
2353
+ this._mini_map.setCollapsed(true);
2354
+ }
2355
+ }
2356
+
1419
2357
  _toViewCoords(loc: LatLngLiteral): number[] {
1420
2358
  const is_image_space = this._map.getView().getProjection().getCode() === "EPSG:4326";
1421
2359
  if (is_image_space) return [loc.lon, loc.lat];
1422
2360
  return fromLonLat([loc.lon, loc.lat]);
1423
2361
  }
1424
2362
 
2363
+ /* Per-slide view presentation (slideshow rotation/filter/mask/basemap)
2364
+ ================================================== */
2365
+ /**
2366
+ * Swap the base layer for a slide `location.basemap` (multi-manifest
2367
+ * tours): built on first use through the tile layer factory (custom
2368
+ * factories apply), then cached. A slide without one restores the story
2369
+ * basemap. No-op when the requested key is already showing, so resize
2370
+ * and re-fit paths pass through untouched. Consent-denied tiles skip
2371
+ * the switch entirely.
2372
+ */
2373
+ _switchSlideBasemap(loc: StorymapSlideLocation | null | undefined): void {
2374
+ // slideshow tours only: internal documents keep the story basemap
2375
+ if (this.options.slideshow_source !== true) return;
2376
+ if (!this._tilesAllowed()) return;
2377
+ const key = slideBasemapKey(loc);
2378
+ if (key === this._slide_basemap) return;
2379
+ if (this._tile_layer) {
2380
+ this._map.removeLayer(this._tile_layer);
2381
+ }
2382
+ if (key === null) {
2383
+ this._tile_layer =
2384
+ this._story_basemap_layer ?? this._createTileLayer(this.options.map_type);
2385
+ this._story_basemap_layer = this._tile_layer;
2386
+ } else {
2387
+ let layer = this._basemap_cache.get(key);
2388
+ if (!layer) {
2389
+ layer = this._createSlideBasemap(key);
2390
+ layer.setZIndex(0);
2391
+ this._basemap_cache.set(key, layer);
2392
+ }
2393
+ this._tile_layer = layer;
2394
+ }
2395
+ this._tile_layer.setZIndex(0);
2396
+ this._map.addLayer(this._tile_layer);
2397
+ this._slide_basemap = key;
2398
+ this._updateAttribution();
2399
+ }
2400
+
2401
+ /**
2402
+ * Build a per-slide basemap layer: IIIF `info.json` URLs (or bare
2403
+ * service bases) become tile layers; plain image URLs become static
2404
+ * image layers (dimensions probed); anything else goes through the
2405
+ * regular tile layer factory, so keywords and templates keep working.
2406
+ */
2407
+ _createSlideBasemap(key: string): Layer {
2408
+ // plain image files (absolute or same-origin relative) skip the
2409
+ // doomed info.json fetch; services and keywords flow on
2410
+ if (isStaticImageUrl(key)) {
2411
+ return this._createStaticImageBasemap(key);
2412
+ }
2413
+ if (/^https?:\/\//i.test(key) && !key.includes("{z}")) {
2414
+ const info_url = serviceToInfoJson(key);
2415
+ const pending: TileLayer = new TileLayer();
2416
+ fetch(info_url)
2417
+ .then((r) => r.json())
2418
+ .then((info: unknown) => {
2419
+ if (this._disposed) return;
2420
+ const parsed = new IIIFInfo(
2421
+ info as ImageInformationResponse,
2422
+ ).getTileSourceOptions();
2423
+ const dims = info as { width?: number; height?: number };
2424
+ if (typeof dims.width !== "number" || typeof dims.height !== "number") {
2425
+ this._replaceWithStaticBasemap(key, pending);
2426
+ return;
2427
+ }
2428
+ const source = new IIIF({
2429
+ ...(parsed ?? {}),
2430
+ projection: "EPSG:4326",
2431
+ size: [dims.width, dims.height],
2432
+ crossOrigin: "anonymous",
2433
+ });
2434
+ pending.setSource(source);
2435
+ this._fireImageready(source, "iiif", pending);
2436
+ })
2437
+ .catch(() => {
2438
+ // not an image service after all (a plain file, e.g. a
2439
+ // `.jpx` that serves no info.json): probe it as one
2440
+ this._replaceWithStaticBasemap(key, pending);
2441
+ });
2442
+ return pending;
2443
+ }
2444
+ return this._createTileLayer(key);
2445
+ }
2446
+
2447
+ /**
2448
+ * A basemap from a plain image URL (no IIIF service): an `ImageLayer`
2449
+ * filled in once the natural size probes. Used for per-slide basemaps
2450
+ * pointing at static files, and as the fallback when an `info.json`
2451
+ * turns out unreachable (some `.jpx`/`.jp2` bases serve one, some are
2452
+ * plain files — only the fetch tells). Unusable dimensions keep the
2453
+ * error logged and the layer empty, like the failed-service path.
2454
+ */
2455
+ _createStaticImageBasemap(url: string): Layer {
2456
+ const pending = new ImageLayer();
2457
+ pending.setZIndex(0);
2458
+ const token = (this._slide_probe_tokens.get(url) ?? 0) + 1;
2459
+ this._slide_probe_tokens.set(url, token);
2460
+ void this._probeStaticImage(url).then(
2461
+ (dims) => {
2462
+ if (this._disposed || this._slide_probe_tokens.get(url) !== token) return;
2463
+ if (!dims || !(dims.width > 0) || !(dims.height > 0)) {
2464
+ console.error("Slide basemap image has no usable dimensions:", url);
2465
+ return;
2466
+ }
2467
+ pending.setSource(
2468
+ this._newStaticSource(url, [0, 0, dims.width, dims.height], "EPSG:4326"),
2469
+ );
2470
+ this._fireImageready(
2471
+ pending.getSource() as { getState?(): string },
2472
+ "iiif",
2473
+ pending,
2474
+ );
2475
+ },
2476
+ (err) =>
2477
+ console.error(
2478
+ "Slide basemap image could not be probed:",
2479
+ url,
2480
+ (err as Error)?.stack || err,
2481
+ ),
2482
+ );
2483
+ return pending;
2484
+ }
2485
+
2486
+ /**
2487
+ * Replace a failed IIIF slide basemap with a probed static image under
2488
+ * the same cache key, swapping it into the map when still current (the
2489
+ * visitor may have moved on while the fetch was in flight, in which
2490
+ * case the cache holds it for a return visit).
2491
+ */
2492
+ _replaceWithStaticBasemap(key: string, pending: TileLayer): void {
2493
+ if (this._disposed) return;
2494
+ const staticLayer = this._createStaticImageBasemap(key);
2495
+ this._basemap_cache.set(key, staticLayer);
2496
+ if (this._tile_layer === (pending as unknown as Layer)) {
2497
+ this._map.removeLayer(pending);
2498
+ this._tile_layer = staticLayer;
2499
+ this._map.addLayer(staticLayer);
2500
+ }
2501
+ }
2502
+
2503
+ /**
2504
+ * The story's own base layer (as opposed to a per-slide one): stashed
2505
+ * on creation so a slide basemap can hand back to it without refetching.
2506
+ */
2507
+ declare "_story_basemap_layer": Layer | null;
2508
+
2509
+ /**
2510
+ * Apply a slide's CSS filter grading to the map viewport
2511
+ * (slideshow `filters`). Always assigned — "" clears — so grading never
2512
+ * leaks onto the next slide.
2513
+ */
2514
+ _applySlideFilter(loc: StorymapSlideLocation | null | undefined): void {
2515
+ this._map.getViewport().style.filter = buildSlideFilter(loc?.filter);
2516
+ }
2517
+
2518
+ /**
2519
+ * Apply a slide's spotlight mask (slideshow `passepartout`): normalized
2520
+ * viewport fractions stay visible while the rest is dimmed. Rendered as
2521
+ * up to four percentage-positioned divs (or one for `invert`), so it
2522
+ * needs no updates on pan/zoom/resize. `pointer-events: none` keeps
2523
+ * markers clickable through the shade; the marker overlay container
2524
+ * (z-index 1) paints above it. Absent/invalid clears the mask.
2525
+ */
2526
+ _applySlideMask(loc: StorymapSlideLocation | null | undefined): void {
2527
+ const mask: NormalizedMask | null = normalizeMask(loc?.mask);
2528
+ if (!mask) {
2529
+ this._mask_el?.remove();
2530
+ this._mask_el = null;
2531
+ return;
2532
+ }
2533
+ const viewport = this._map.getViewport();
2534
+ if (!this._mask_el) {
2535
+ const el = document.createElement("div");
2536
+ el.className = "vco-map-mask";
2537
+ el.setAttribute("aria-hidden", "true");
2538
+ el.style.position = "absolute";
2539
+ el.style.inset = "0";
2540
+ el.style.zIndex = "0";
2541
+ el.style.pointerEvents = "none";
2542
+ el.style.overflow = "hidden";
2543
+ viewport.appendChild(el);
2544
+ this._mask_el = el;
2545
+ }
2546
+ const pct = (n: number) => `${n * 100}%`;
2547
+ const shade = (left: string, top: string, width: string, height: string) =>
2548
+ `<div style="position:absolute;left:${left};top:${top};width:${width};height:${height};background:${mask.color};opacity:${mask.opacity}"></div>`;
2549
+ this._mask_el.innerHTML = mask.invert
2550
+ ? shade(pct(mask.x), pct(mask.y), pct(mask.w), pct(mask.h))
2551
+ : shade("0", "0", "100%", pct(mask.y)) +
2552
+ shade("0", pct(mask.y + mask.h), "100%", pct(1 - mask.y - mask.h)) +
2553
+ shade("0", pct(mask.y), pct(mask.x), pct(mask.h)) +
2554
+ shade(pct(mask.x + mask.w), pct(mask.y), pct(1 - mask.x - mask.w), pct(mask.h));
2555
+ }
2556
+
2557
+ /** Filter + mask together: the synchronous tail of every navigation. */
2558
+ _applySlideFilterAndMask(loc: StorymapSlideLocation | null | undefined): void {
2559
+ this._applySlideFilter(loc);
2560
+ this._applySlideMask(loc);
2561
+ }
2562
+
2563
+ /**
2564
+ * Rotate the view to a slide `rotation` (degrees clockwise). Merges into
2565
+ * the caller's animation when one is running; on its own (region fits,
2566
+ * location-less slides, overview reset) it animates alone, or sets
2567
+ * synchronously when the navigation is instant. A no-op when neither
2568
+ * the slide nor the view is rotated, so unrotated stories animate
2569
+ * exactly as before.
2570
+ */
2571
+ _applySlideRotation(
2572
+ loc: StorymapSlideLocation | null | undefined,
2573
+ duration?: number,
2574
+ instant = false,
2575
+ ): void {
2576
+ const rotation = slideRotationRad(loc);
2577
+ const view = this._map.getView();
2578
+ if (rotation === null && view.getRotation() === 0) return;
2579
+ const target = rotation ?? 0;
2580
+ if (instant || prefersReducedMotion()) {
2581
+ view.setRotation(target);
2582
+ return;
2583
+ }
2584
+ view.animate({
2585
+ rotation: target,
2586
+ duration: duration ?? this.options.duration,
2587
+ easing: this._easing,
2588
+ });
2589
+ }
2590
+
2591
+ /**
2592
+ * Show a per-slide image overlay (slideshow `imgoverlay`) while its
2593
+ * slide is active. `extent` pins it (`[west, south, east, north]` lon/lat,
2594
+ * raw image pixels on image-space maps); otherwise it covers the current
2595
+ * view, optionally scaled to `size` (fraction of the viewport). Painted
2596
+ * above the base (z 5) below the route lines. Null/empty clears.
2597
+ * Consent-denied tiles skip it like every other layer.
2598
+ */
2599
+ setSlideImageOverlay(overlay: StorymapImageOverlay | null | undefined): void {
2600
+ if (this._imgoverlay_layer) {
2601
+ this._map.removeLayer(this._imgoverlay_layer);
2602
+ this._imgoverlay_layer = null;
2603
+ }
2604
+ if (!overlay || typeof overlay.url !== "string" || overlay.url === "") return;
2605
+ if (!this._tilesAllowed()) return;
2606
+ // slideshow tours only: internal documents never paint overlays
2607
+ if (this.options.slideshow_source !== true) return;
2608
+ const given = overlay.extent;
2609
+ let extent: Extent;
2610
+ if (
2611
+ Array.isArray(given) &&
2612
+ given.length === 4 &&
2613
+ given.every((n) => typeof n === "number" && Number.isFinite(n))
2614
+ ) {
2615
+ const raw = (
2616
+ this.isImageSpace()
2617
+ ? [given[0], given[1], given[2], given[3]]
2618
+ : [...fromLonLat([given[0], given[1]]), ...fromLonLat([given[2], given[3]])]
2619
+ ) as Extent;
2620
+ // lon/lat corners can arrive swapped; the view fit needs min/max
2621
+ extent = [
2622
+ Math.min(raw[0], raw[2]),
2623
+ Math.min(raw[1], raw[3]),
2624
+ Math.max(raw[0], raw[2]),
2625
+ Math.max(raw[1], raw[3]),
2626
+ ];
2627
+ } else {
2628
+ extent = this._map.getView().calculateExtent(this._viewportSize());
2629
+ if (typeof overlay.size === "number" && Number.isFinite(overlay.size)) {
2630
+ const scale = Math.min(1, Math.max(0.05, overlay.size));
2631
+ const cx = (extent[0] + extent[2]) / 2;
2632
+ const cy = (extent[1] + extent[3]) / 2;
2633
+ const hw = ((extent[2] - extent[0]) * scale) / 2;
2634
+ const hh = ((extent[3] - extent[1]) * scale) / 2;
2635
+ extent = [cx - hw, cy - hh, cx + hw, cy + hh];
2636
+ }
2637
+ }
2638
+ const opacity =
2639
+ typeof overlay.opacity === "number" && Number.isFinite(overlay.opacity)
2640
+ ? Math.min(1, Math.max(0, overlay.opacity))
2641
+ : 1;
2642
+ const layer = this._newStaticLayer(
2643
+ overlay.url,
2644
+ extent,
2645
+ this._map.getView().getProjection(),
2646
+ { opacity, zIndex: 5 },
2647
+ );
2648
+ this._map.addLayer(layer);
2649
+ this._imgoverlay_layer = layer;
2650
+ }
2651
+
1425
2652
  _getMapZoom(): number {
1426
2653
  // fractional zoom on purpose: overview fits produce non-integer zooms and
1427
2654
  // rounding here would snap the view on the next navigation
1428
2655
  return this._map.getView().getZoom() || 0;
1429
2656
  }
1430
2657
 
1431
- _getMapCenter(offset?: boolean): LatLngLiteral {
1432
- const center = toLonLat(
1433
- this._map.getView().getCenter(),
1434
- this._map.getView().getProjection(),
1435
- );
1436
- return { lat: center[1], lon: center[0] };
2658
+ // Note: there is deliberately no offset-aware variant. The exposed
2659
+ // center is panel-shifted (see the migration guide); a previous
2660
+ // `offset?: boolean` parameter promised a correction it never applied.
2661
+ _getMapCenter(): LatLngLiteral {
2662
+ const view = this._map.getView();
2663
+ const center = view.getCenter() ?? [0, 0];
2664
+ const [lon, lat] = toLonLat(center, view.getProjection());
2665
+ return { lat: lat, lon: lon };
1437
2666
  }
1438
2667
 
1439
2668
  _getMapCenterOffset(location: LatLngLiteral, zoom: number): LatLngLiteral {
1440
- // Offset the center by map_center_offset pixels at the given zoom
2669
+ // Offset the center by map_center_offset pixels at the given zoom.
2670
+ // A null map_center_offset means "no offset" (the default).
2671
+ const offset = this.options.map_center_offset;
2672
+ if (!offset || (offset.left === 0 && offset.top === 0)) {
2673
+ return location;
2674
+ }
1441
2675
  const view = this._map.getView();
1442
2676
  const projection = view.getProjection();
1443
2677
  const center = this._toViewCoords(location);
1444
2678
  const resolution = view.getResolutionForZoom(zoom);
1445
2679
  return this._fromViewCoords(
1446
- [
1447
- center[0] - this.options.map_center_offset.left * resolution,
1448
- center[1] + this.options.map_center_offset.top * resolution,
1449
- ],
2680
+ [center[0] - offset.left * resolution, center[1] + offset.top * resolution],
1450
2681
  projection,
1451
2682
  );
1452
2683
  }
@@ -1459,13 +2690,48 @@ export default class OpenLayers extends Map {
1459
2690
  return { lat: c[1], lon: (coord[0] / 6378137) * (180 / Math.PI) };
1460
2691
  }
1461
2692
 
2693
+ /**
2694
+ * May a tile-backed layer (base map, overlay, minimap, overview) be
2695
+ * created right now? Under `consent_required` that is only true once the
2696
+ * visitor has allowed the tile service. This was the same five-line
2697
+ * predicate at five call sites.
2698
+ */
2699
+ private _tilesAllowed(): boolean {
2700
+ const consent = consentManagerOf(this.options);
2701
+ if (!this.options.consent_required || !consent) {
2702
+ return true;
2703
+ }
2704
+ return consent.isGranted(tileService().key);
2705
+ }
2706
+
2707
+ /** A slide's {lat, lon} when both are real numbers, else null. */
2708
+ private _latLngOf(loc: StorymapSlideLocation | null | undefined): LatLngLiteral | null {
2709
+ if (!loc || typeof loc.lat !== "number" || typeof loc.lon !== "number") {
2710
+ return null;
2711
+ }
2712
+ return { lat: loc.lat, lon: loc.lon };
2713
+ }
2714
+
2715
+ /**
2716
+ * The map's pixel size, with a never-zero fallback.
2717
+ *
2718
+ * `getSize()` returns undefined until the map has a size, so the six
2719
+ * call sites that fitted an extent to the viewport all repeated the same
2720
+ * `(size[0] || 1)` guard — which silently fitted against a 1px viewport
2721
+ * rather than bailing out.
2722
+ */
2723
+ private _viewportSize(): [number, number] {
2724
+ const size = this._map?.getSize();
2725
+ return [size?.[0] || 1, size?.[1] || 1];
2726
+ }
2727
+
1462
2728
  _getBoundsZoom(
1463
2729
  origin: LatLngLiteral,
1464
2730
  destination: LatLngLiteral,
1465
2731
  correct_for_center?: boolean,
1466
2732
  ): number {
1467
2733
  const coords = [
1468
- [origin.lon !== undefined ? origin.lon : origin.lng, origin.lat],
2734
+ [origin.lon, origin.lat],
1469
2735
  [destination.lon, destination.lat],
1470
2736
  ];
1471
2737
  const view_coords = this._markerCoordsToViewCoords(coords);
@@ -1518,6 +2784,25 @@ export default class OpenLayers extends Map {
1518
2784
  this._cancelLineAnimation();
1519
2785
  this._line_active.setVisible(false);
1520
2786
 
2787
+ // The overview shows the whole story: per-slide grading, mask and
2788
+ // rotation do not apply. Each reset is guarded so the common path
2789
+ // (nothing set) touches nothing.
2790
+ if (this._map.getView().getRotation() !== 0) {
2791
+ this._map.getView().animate({
2792
+ rotation: 0,
2793
+ duration: prefersReducedMotion() ? 0 : (duration ?? this._transition_duration),
2794
+ easing: this._easing,
2795
+ });
2796
+ }
2797
+ if (this._map.getViewport().style.filter !== "") {
2798
+ this._map.getViewport().style.filter = "";
2799
+ }
2800
+ if (this._mask_el) {
2801
+ this._mask_el.remove();
2802
+ this._mask_el = null;
2803
+ }
2804
+ this._switchSlideBasemap(null);
2805
+
1521
2806
  // repeated presses while an overview animation is running snap
1522
2807
  // instantly instead of restarting the ~1s animation
1523
2808
  if (duration && this._map.getView().getAnimating()) {
@@ -1548,29 +2833,51 @@ export default class OpenLayers extends Map {
1548
2833
  this._map.getView().animate({
1549
2834
  center: center_view,
1550
2835
  zoom: view_zoom,
1551
- duration: duration ?? this._transition_duration,
1552
- easing: this.options.ease as ((t: number) => number) | undefined,
2836
+ duration: prefersReducedMotion() ? 0 : (duration ?? this._transition_duration),
2837
+ easing: this._easing,
1553
2838
  });
1554
2839
  }
1555
- } else if (this.options.map_type === "iiif" && this.options.map_as_image) {
1556
- const source = this._tile_layer?.getSource();
2840
+ } else if (this.isImageSpace()) {
2841
+ const source = this._sourceOf(this._tile_layer);
1557
2842
  if (!source) {
1558
2843
  return;
1559
2844
  }
1560
2845
  const fit = () => {
1561
2846
  try {
1562
- const grid = source.getTileGrid();
2847
+ // static fallback (no tile grid): fit the single image
2848
+ // extent directly so the overview still frames the picture
2849
+ const static_extent = (
2850
+ source as { getImageExtent?: () => Extent | null }
2851
+ ).getImageExtent?.();
2852
+ if (static_extent) {
2853
+ this._map.getView().fit(static_extent, {
2854
+ size: this._viewportSize(),
2855
+ padding: this._opaquePanelPadding(),
2856
+ duration: prefersReducedMotion()
2857
+ ? 0
2858
+ : (duration ?? this._transition_duration),
2859
+ easing: this._easing,
2860
+ });
2861
+ return;
2862
+ }
2863
+ // a custom layer with a source is not necessarily a tile
2864
+ // source; only tile grids can supply a fit extent
2865
+ const grid = (
2866
+ source as {
2867
+ getTileGrid?: () => { getExtent(): number[] } | null;
2868
+ }
2869
+ ).getTileGrid?.();
1563
2870
  if (grid) {
1564
2871
  // compute the fit target directly and animate once (a
1565
2872
  // fit() followed by setCenter() would cancel the fit
1566
2873
  // animation, issue #465)
1567
2874
  const extent = grid.getExtent();
1568
- const size = this._map.getSize();
1569
- const resolution_x = (extent[2] - extent[0]) / (size[0] || 1);
1570
- const resolution_y = (extent[3] - extent[1]) / (size[1] || 1);
2875
+ const size = this._viewportSize();
2876
+ const resolution_x = (extent[2] - extent[0]) / size[0];
2877
+ const resolution_y = (extent[3] - extent[1]) / size[1];
1571
2878
  const resolution = Math.max(resolution_x, resolution_y);
1572
2879
  const view = this._map.getView();
1573
- const zoom = view.getZoomForResolution(resolution);
2880
+ const zoom = view.getZoomForResolution(resolution) ?? 0;
1574
2881
  const overview_center = this.options.map_overview_center;
1575
2882
  const center_px = overview_center
1576
2883
  ? this._toViewCoords({
@@ -1584,8 +2891,10 @@ export default class OpenLayers extends Map {
1584
2891
  view.animate({
1585
2892
  center: this._toViewCoords(offset_location),
1586
2893
  zoom: zoom,
1587
- duration: duration ?? this._transition_duration,
1588
- easing: this.options.ease as ((t: number) => number) | undefined,
2894
+ duration: prefersReducedMotion()
2895
+ ? 0
2896
+ : (duration ?? this._transition_duration),
2897
+ easing: this._easing,
1589
2898
  });
1590
2899
  }
1591
2900
  } catch (e) {
@@ -1609,14 +2918,14 @@ export default class OpenLayers extends Map {
1609
2918
  if (overview_center && this.bounds_array && this.bounds_array.length) {
1610
2919
  const view_coords = this._markerCoordsToViewCoords(this.bounds_array);
1611
2920
  const extent = boundingExtent(view_coords);
1612
- const size = this._map.getSize();
2921
+ const size = this._viewportSize();
1613
2922
  const resolution = Math.max(
1614
- (extent[2] - extent[0]) / (size[0] || 1),
1615
- (extent[3] - extent[1]) / (size[1] || 1),
2923
+ (extent[2] - extent[0]) / size[0],
2924
+ (extent[3] - extent[1]) / size[1],
1616
2925
  );
1617
2926
  const zoom = Math.max(
1618
2927
  0,
1619
- Math.round(this._map.getView().getZoomForResolution(resolution)) - 1,
2928
+ Math.round(this._map.getView().getZoomForResolution(resolution) ?? 0) - 1,
1620
2929
  );
1621
2930
  const offset_location = this._getMapCenterOffset(
1622
2931
  { lat: overview_center.lat, lon: overview_center.lon },
@@ -1625,8 +2934,8 @@ export default class OpenLayers extends Map {
1625
2934
  this._map.getView().animate({
1626
2935
  center: this._toViewCoords(offset_location),
1627
2936
  zoom: zoom,
1628
- duration: duration ?? this._transition_duration,
1629
- easing: this.options.ease as ((t: number) => number) | undefined,
2937
+ duration: prefersReducedMotion() ? 0 : (duration ?? this._transition_duration),
2938
+ easing: this._easing,
1630
2939
  });
1631
2940
  } else if (
1632
2941
  this.options.map_center_offset &&
@@ -1636,13 +2945,13 @@ export default class OpenLayers extends Map {
1636
2945
  if (this.bounds_array && this.bounds_array.length) {
1637
2946
  const view_coords = this._markerCoordsToViewCoords(this.bounds_array);
1638
2947
  const extent = boundingExtent(view_coords);
1639
- const size = this._map.getSize();
1640
- const resolution_x = (extent[2] - extent[0]) / (size[0] || 1);
1641
- const resolution_y = (extent[3] - extent[1]) / (size[1] || 1);
2948
+ const size = this._viewportSize();
2949
+ const resolution_x = (extent[2] - extent[0]) / size[0];
2950
+ const resolution_y = (extent[3] - extent[1]) / size[1];
1642
2951
  const resolution = Math.max(resolution_x, resolution_y);
1643
2952
  const zoom = Math.max(
1644
2953
  0,
1645
- Math.round(this._map.getView().getZoomForResolution(resolution)) - 1,
2954
+ Math.round(this._map.getView().getZoomForResolution(resolution) ?? 0) - 1,
1646
2955
  );
1647
2956
  const center_px = [(extent[0] + extent[2]) / 2, (extent[1] + extent[3]) / 2];
1648
2957
  const projection = this._map.getView().getProjection();
@@ -1651,8 +2960,10 @@ export default class OpenLayers extends Map {
1651
2960
  this._map.getView().animate({
1652
2961
  center: this._toViewCoords(offset_location),
1653
2962
  zoom: zoom,
1654
- duration: duration ?? this._transition_duration,
1655
- easing: this.options.ease as ((t: number) => number) | undefined,
2963
+ duration: prefersReducedMotion()
2964
+ ? 0
2965
+ : (duration ?? this._transition_duration),
2966
+ easing: this._easing,
1656
2967
  });
1657
2968
  }
1658
2969
  } else {
@@ -1664,13 +2975,18 @@ export default class OpenLayers extends Map {
1664
2975
  const view = this._map.getView();
1665
2976
  const zoom = view.getZoom();
1666
2977
  if (zoom !== undefined) {
1667
- const center = this._fromViewCoords(view.getCenter(), view.getProjection());
2978
+ const center = this._fromViewCoords(
2979
+ view.getCenter() ?? [0, 0],
2980
+ view.getProjection(),
2981
+ );
1668
2982
  const offset_location = this._getMapCenterOffset(center, zoom);
1669
2983
  view.animate({
1670
2984
  center: this._toViewCoords(offset_location),
1671
2985
  zoom: zoom,
1672
- duration: duration ?? this._transition_duration,
1673
- easing: this.options.ease as ((t: number) => number) | undefined,
2986
+ duration: prefersReducedMotion()
2987
+ ? 0
2988
+ : (duration ?? this._transition_duration),
2989
+ easing: this._easing,
1674
2990
  });
1675
2991
  }
1676
2992
  }
@@ -1690,14 +3006,7 @@ export default class OpenLayers extends Map {
1690
3006
  if (!this._mini_map) return;
1691
3007
  const overview = this._mini_map.getOverviewMap();
1692
3008
  overview.getLayers().clear();
1693
- const consent = consentManagerOf(this.options);
1694
- const tile_service = consentMessage("consent_service_tiles", "map tiles");
1695
- const tiles_allowed = !(
1696
- this.options.consent_required &&
1697
- consent &&
1698
- !consent.isGranted(tile_service)
1699
- );
1700
- if (!tiles_allowed) {
3009
+ if (!this._tilesAllowed()) {
1701
3010
  this._tile_layer_mini = null;
1702
3011
  return;
1703
3012
  }
@@ -1707,13 +3016,12 @@ export default class OpenLayers extends Map {
1707
3016
  const zoomify_pyramid = is_zoomify ? this._zoomifyPyramid() : null;
1708
3017
  if (zoomify_pyramid) {
1709
3018
  const raw_size = overview.getSize();
1710
- const size =
1711
- raw_size && raw_size[0] >= 50 && raw_size[1] >= 50 ? raw_size : [150, 100];
3019
+ const size = raw_size && raw_size[0] >= 50 && raw_size[1] >= 50 ? raw_size : [150, 100];
1712
3020
  overview.getView().fit(zoomify_pyramid.extent, { size: size });
1713
3021
  } else if (this.bounds_array && this.bounds_array.length) {
1714
3022
  this._fitView(overview, this.bounds_array, 0, this._bboxExtent() !== null);
1715
3023
  }
1716
- if (this.options.map_type === "iiif" && this.options.map_as_image) {
3024
+ if (this.isImageSpace()) {
1717
3025
  this._fitMiniMapToImage();
1718
3026
  }
1719
3027
  }
@@ -1733,17 +3041,16 @@ export default class OpenLayers extends Map {
1733
3041
  if (this._tile_layer) {
1734
3042
  this._map.removeLayer(this._tile_layer);
1735
3043
  }
1736
- const consent = consentManagerOf(this.options);
1737
- const tile_service = consentMessage("consent_service_tiles", "map tiles");
1738
- if (!(
1739
- this.options.consent_required &&
1740
- consent &&
1741
- !consent.isGranted(tile_service)
1742
- )) {
3044
+ if (this._tilesAllowed()) {
1743
3045
  this._tile_layer = this._createTileLayer(this.options.map_type);
1744
3046
  this._tile_layer.setZIndex(0);
1745
3047
  this._map.addLayer(this._tile_layer);
1746
3048
  }
3049
+ // the story basemap changed: per-slide layers stay cached
3050
+ // (their keys are unchanged) but the active one is rebuilt
3051
+ // by the re-fit below through _switchSlideBasemap
3052
+ this._story_basemap_layer = this._tile_layer ?? null;
3053
+ this._slide_basemap = null;
1747
3054
  this._refreshMiniMapLayer();
1748
3055
  this._updateAttribution();
1749
3056
  this._el.map.style.backgroundColor = this.options.map_background_color;
@@ -1774,19 +3081,36 @@ export default class OpenLayers extends Map {
1774
3081
  break;
1775
3082
  case "map_bbox": {
1776
3083
  // recreate the view so the new extent constraint applies,
1777
- // preserving center and zoom
3084
+ // preserving center and zoom. The replacement has to
3085
+ // rebuild the *same* view configuration _createMap() uses —
3086
+ // dropping constrainOnlyCenter (which _createMap sets
3087
+ // deliberately, see above), the image zoom ladder or the
3088
+ // legacy zoomify multiWorld flag silently reintroduced the
3089
+ // strict extent constraint this code went out of its way
3090
+ // to avoid, and broke image-mode zoom (issue #465).
1778
3091
  const view = this._map.getView();
1779
3092
  const center = view.getCenter();
1780
3093
  const zoom = view.getZoom();
1781
3094
  const extent = this._bboxExtent();
3095
+ // must match _createMap's test exactly: a *georeferenced*
3096
+ // IIIF layer (map_as_image false) is a mercator map, so
3097
+ // testing map_type alone handed it the image-pixel view
3098
+ const is_image_map = this.options.map_type === "iiif" && this.isImageSpace();
3099
+ const user_view = this.options.map_options?.view as
3100
+ Record<string, unknown> | undefined;
1782
3101
  this._map.setView(
1783
3102
  new View({
1784
- projection: view.getProjection(),
3103
+ projection: is_image_map ? "EPSG:4326" : "EPSG:3857",
1785
3104
  center: center,
1786
3105
  zoom: zoom,
1787
3106
  minZoom: view.getMinZoom(),
1788
3107
  maxZoom: view.getMaxZoom(),
1789
- ...(extent ? { extent: extent } : {}),
3108
+ ...(is_image_map
3109
+ ? { multiWorld: true, resolutions: IMAGE_RESOLUTIONS }
3110
+ : {}),
3111
+ ...(this.options.map_type === "zoomify" ? { multiWorld: true } : {}),
3112
+ ...(extent ? { extent: extent, constrainOnlyCenter: true } : {}),
3113
+ ...((user_view ?? {}) as Record<string, unknown>),
1790
3114
  }),
1791
3115
  );
1792
3116
  break;
@@ -1865,13 +3189,27 @@ export default class OpenLayers extends Map {
1865
3189
  * stale positions after a size change.
1866
3190
  */
1867
3191
  _setViewInstant(loc: StorymapSlideLocation, zoom: number): void {
1868
- let _location: LatLngLiteral = { lat: loc.lat, lon: loc.lon };
3192
+ const start = this._latLngOf(loc);
3193
+ if (!start) {
3194
+ return;
3195
+ }
3196
+ let _location: LatLngLiteral = start;
1869
3197
  if (this.options.map_center_offset) {
1870
3198
  _location = this._getMapCenterOffset(_location, zoom);
1871
3199
  }
1872
3200
  const view = this._map.getView();
1873
3201
  view.setZoom(zoom);
1874
3202
  view.setCenter(this._toViewCoords(_location));
3203
+ // instant path (resize): rotation sets synchronously, filter and
3204
+ // mask re-apply idempotently — slideshow tours only, so the resize
3205
+ // path for internal documents touches nothing new
3206
+ if (this.options.slideshow_source === true) {
3207
+ const rotation = slideRotationRad(loc);
3208
+ if (rotation !== null || view.getRotation() !== 0) {
3209
+ view.setRotation(rotation ?? 0);
3210
+ }
3211
+ this._applySlideFilterAndMask(loc);
3212
+ }
1875
3213
  this._map.renderSync();
1876
3214
  }
1877
3215
  }