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