@projektemacher/storymapjs 0.10.7 → 0.10.8

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