@projektemacher/storymapjs 0.10.6 → 0.10.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (552) hide show
  1. package/CHANGELOG +358 -16
  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 +101 -21
  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 +314 -12
  338. package/dist/docs/readme.html +40 -9
  339. package/dist/embed/index.html +130 -5
  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 +33 -10
  405. package/dist/js/storymap.d.ts +1558 -114
  406. package/dist/js/storymap.js +10113 -6490
  407. package/dist/js/storymap.js.map +1 -1
  408. package/dist/navplace-properties.json +40 -0
  409. package/docs/migration-from-knightlab.md +359 -0
  410. package/docs/storymap-as-iiif-manifest.md +934 -0
  411. package/package.json +16 -10
  412. package/schema/storymap.schema.json +431 -78
  413. package/src/animation/easings.ts +25 -0
  414. package/src/animation/tween.ts +186 -0
  415. package/src/core/Browser.ts +20 -0
  416. package/src/core/Load.ts +232 -0
  417. package/src/core/Util.ts +416 -0
  418. package/src/core/mixins.ts +188 -0
  419. package/src/core/viewers.ts +94 -0
  420. package/src/dom/Dom.ts +31 -0
  421. package/src/dom/DomEvent.ts +80 -0
  422. package/src/globals.d.ts +24 -0
  423. package/src/language/Language.ts +281 -0
  424. package/src/language/locale/.expected-gaps.json +86 -0
  425. package/src/language/locale/be.json +31 -0
  426. package/src/language/locale/bg.json +31 -0
  427. package/src/language/locale/cs.json +31 -0
  428. package/src/language/locale/de.json +31 -0
  429. package/src/language/locale/el.json +31 -0
  430. package/src/language/locale/en.json +32 -0
  431. package/src/language/locale/es.json +31 -0
  432. package/src/language/locale/et.json +22 -0
  433. package/src/language/locale/fr.json +31 -0
  434. package/src/language/locale/he.json +31 -0
  435. package/src/language/locale/hu.json +31 -0
  436. package/src/language/locale/is.json +22 -0
  437. package/src/language/locale/it.json +31 -0
  438. package/src/language/locale/jp.json +31 -0
  439. package/src/language/locale/ko.json +31 -0
  440. package/src/language/locale/nl.json +31 -0
  441. package/src/language/locale/nn.json +22 -0
  442. package/src/language/locale/no.json +31 -0
  443. package/src/language/locale/pl.json +31 -0
  444. package/src/language/locale/pt.json +31 -0
  445. package/src/language/locale/ru.json +31 -0
  446. package/src/language/locale/sk.json +31 -0
  447. package/src/language/locale/sr.json +21 -0
  448. package/src/language/locale/sv.json +31 -0
  449. package/src/language/locale/tr.json +31 -0
  450. package/src/language/locale/uk.json +31 -0
  451. package/src/language/locale/ur.json +22 -0
  452. package/src/language/locale/zh-cn.json +31 -0
  453. package/src/language/locale/zh-tw.json +31 -0
  454. package/src/main.ts +143 -0
  455. package/src/map/Map.ts +794 -0
  456. package/src/map/MapMarker.ts +172 -0
  457. package/src/map/georeference.ts +325 -0
  458. package/src/map/openlayers/Map.OpenLayers.ts +2492 -0
  459. package/src/map/openlayers/MapMarker.OpenLayers.ts +400 -0
  460. package/src/map/openlayers/zoomifyTiles.ts +25 -0
  461. package/src/map/types.ts +17 -0
  462. package/src/media/EmbedUtil.ts +452 -0
  463. package/src/media/Media.ts +597 -0
  464. package/src/media/MediaType.ts +163 -0
  465. package/src/media/embedId.ts +104 -0
  466. package/src/media/types/Audio.ts +14 -0
  467. package/src/media/types/Blockquote.ts +39 -0
  468. package/src/media/types/DailyMotion.ts +53 -0
  469. package/src/media/types/DocumentCloud.ts +12 -0
  470. package/src/media/types/Facebook.ts +40 -0
  471. package/src/media/types/Flickr.ts +115 -0
  472. package/src/media/types/GoogleDoc.ts +58 -0
  473. package/src/media/types/HtmlMedia.ts +167 -0
  474. package/src/media/types/IFrame.ts +50 -0
  475. package/src/media/types/Image.ts +115 -0
  476. package/src/media/types/Juxtapose.ts +41 -0
  477. package/src/media/types/SoundCloud.ts +99 -0
  478. package/src/media/types/Text.ts +178 -0
  479. package/src/media/types/Twitter.ts +109 -0
  480. package/src/media/types/Video.ts +14 -0
  481. package/src/media/types/Vimeo.ts +65 -0
  482. package/src/media/types/Website.ts +46 -0
  483. package/src/media/types/Wikipedia.ts +134 -0
  484. package/src/media/types/YouTube.ts +224 -0
  485. package/src/scss/Typography.scss +235 -0
  486. package/src/scss/VCO.StoryMap.Dark.scss +150 -0
  487. package/src/scss/VCO.StoryMap.scss +195 -0
  488. package/src/scss/Variables.Dark.scss +57 -0
  489. package/src/scss/Variables.scss +48 -0
  490. package/src/scss/core/Mixins.scss +54 -0
  491. package/src/scss/core/Reset.scss +206 -0
  492. package/src/scss/fonts/_font.base.scss +113 -0
  493. package/src/scss/fonts/font.abril-droidsans.scss +75 -0
  494. package/src/scss/fonts/font.amatic-andika.scss +76 -0
  495. package/src/scss/fonts/font.bitter-raleway.scss +92 -0
  496. package/src/scss/fonts/font.cairo.scss +79 -0
  497. package/src/scss/fonts/font.clicker-garamond.scss +97 -0
  498. package/src/scss/fonts/font.dancing-ledger.scss +75 -0
  499. package/src/scss/fonts/font.default.scss +9 -0
  500. package/src/scss/fonts/font.fjalla-average.scss +80 -0
  501. package/src/scss/fonts/font.georgia-helvetica.scss +58 -0
  502. package/src/scss/fonts/font.knightlab.scss +111 -0
  503. package/src/scss/fonts/font.lustria-lato.scss +80 -0
  504. package/src/scss/fonts/font.medula-lato.scss +73 -0
  505. package/src/scss/fonts/font.oldstandard.scss +94 -0
  506. package/src/scss/fonts/font.opensans-gentiumbook.scss +85 -0
  507. package/src/scss/fonts/font.playfair-faunaone.scss +88 -0
  508. package/src/scss/fonts/font.playfair.scss +89 -0
  509. package/src/scss/fonts/font.pt.scss +96 -0
  510. package/src/scss/fonts/font.roboto-megrim.scss +73 -0
  511. package/src/scss/fonts/font.rufina-sintony.scss +73 -0
  512. package/src/scss/fonts/font.ubuntu.scss +82 -0
  513. package/src/scss/fonts/font.unicaone-vollkorn.scss +90 -0
  514. package/src/scss/icons/Icons.scss +239 -0
  515. package/src/scss/map/VCO.ImageMarker.scss +23 -0
  516. package/src/scss/map/VCO.Map.scss +21 -0
  517. package/src/scss/map/VCO.MapMarker.scss +242 -0
  518. package/src/scss/map/openlayers/VCO.Map.OpenLayers.scss +126 -0
  519. package/src/scss/media/VCO.Media.scss +219 -0
  520. package/src/scss/media/types/VCO.Media.Blockquote.scss +76 -0
  521. package/src/scss/media/types/VCO.Media.IFrame.scss +11 -0
  522. package/src/scss/media/types/VCO.Media.Image.scss +13 -0
  523. package/src/scss/media/types/VCO.Media.Text.scss +98 -0
  524. package/src/scss/media/types/VCO.Media.Twitter.scss +87 -0
  525. package/src/scss/media/types/VCO.Media.Wikipedia.scss +65 -0
  526. package/src/scss/site/site.scss +748 -0
  527. package/src/scss/slider/VCO.Slide.scss +457 -0
  528. package/src/scss/slider/VCO.SlideNav.scss +332 -0
  529. package/src/scss/slider/VCO.StorySlider.scss +66 -0
  530. package/src/scss/snapmap/VCO.SnapMap.scss +103 -0
  531. package/src/scss/ui/VCO.Consent.scss +121 -0
  532. package/src/scss/ui/VCO.MenuBar.Button.scss +107 -0
  533. package/src/scss/ui/VCO.MenuBar.scss +20 -0
  534. package/src/scss/ui/VCO.Message.scss +101 -0
  535. package/src/site/site.ts +88 -0
  536. package/src/slider/Slide.ts +554 -0
  537. package/src/slider/SlideNav.ts +246 -0
  538. package/src/slider/StorySlider.ts +833 -0
  539. package/src/storymap/Consent.ts +471 -0
  540. package/src/storymap/StoryMap.ts +2118 -0
  541. package/src/storymap/content-state.ts +185 -0
  542. package/src/storymap/iiif.ts +1844 -0
  543. package/src/storymap/to-iiif.ts +921 -0
  544. package/src/storymap/validate.ts +257 -0
  545. package/src/types.ts +494 -0
  546. package/src/ui/MenuBar.ts +376 -0
  547. package/src/ui/Message.ts +127 -0
  548. package/src/ui/Swipable.ts +455 -0
  549. package/dist/assets/demo-CVQ7kFek.js +0 -1
  550. package/dist/assets/harness-C-VZV-ou.js +0 -1
  551. package/dist/assets/index-DDsPBPZA.js +0 -1
  552. package/dist/assets/main-DwbXrrYn.js +0 -17
@@ -0,0 +1,2118 @@
1
+ import { mergeData, slideTransitionDuration, updateData, prefersReducedMotion } from "../core/Util";
2
+ import { loadCSS } from "../core/Load";
3
+ import { validateStorymapAndReport } from "./validate";
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";
25
+ import Dom from "../dom/Dom";
26
+ import { easeInOutQuint, easeOutStrong } from "../animation/easings";
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";
42
+ import { Evented, type EventedInstance } from "../core/mixins";
43
+ import OpenLayersMap from "../map/openlayers/Map.OpenLayers";
44
+ import MenuBar from "../ui/MenuBar";
45
+ import StorySlider from "../slider/StorySlider";
46
+ import { Browser } from "../core/Browser";
47
+ import Animate from "../animation/tween";
48
+ import type { Map as OlMap } from "ol";
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;
61
+
62
+ type StoryMapListener = (e: unknown) => void;
63
+
64
+ /**
65
+ * Resolve a `font_css` value to an absolute stylesheet URL.
66
+ *
67
+ * - `stock:<name>` resolves against the library location.
68
+ * - Absolute URLs pass through untouched.
69
+ * - Anything else resolves against the page URL, so hosts can reference
70
+ * themes vendored into their own public dir (e.g. "fonts/font.css").
71
+ */
72
+ export function resolveFontCssUrl(font: string): string {
73
+ if (font.startsWith("stock:")) {
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
+ }
86
+ // resolved against the library location: one directory up from
87
+ // src/main.ts (dev) and js/storymap.js (build) in both cases
88
+ return new URL("../css/fonts/font." + font_name + ".css", import.meta.url).href;
89
+ }
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;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Interactive StoryMap viewer.
117
+ *
118
+ * Renders a storymap JSON document (or a IIIF Presentation 3 manifest) into an
119
+ * element: an OpenLayers map on one side and a slide slider on the other.
120
+ *
121
+ * @example
122
+ * ```ts
123
+ * import { StoryMap } from "@projektemacher/storymapjs";
124
+ * // from an inline data object
125
+ * const map = new StoryMap("embed", { storymap: { ... } }, { start_at_slide: 2 });
126
+ * // or straight from a source file (storymap JSON or IIIF manifest)
127
+ * const map2 = new StoryMap(document.getElementById("embed"), "./my-storymap.json");
128
+ * ```
129
+ */
130
+ class StoryMapBase {
131
+ declare "_loaded": { storyslider: boolean; map: boolean };
132
+ declare "on": EventedInstance["on"];
133
+ declare "version": string;
134
+ declare "ready": boolean;
135
+ declare "_el": {
136
+ container: HTMLElement;
137
+ menubar: HTMLElement;
138
+ map: HTMLElement | null;
139
+ storyslider: HTMLElement;
140
+ };
141
+ declare "_storyslider": StorySlider;
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;
151
+ declare "_menubar": MenuBar;
152
+ declare "data": StorymapData;
153
+ declare "options": StorymapOptions;
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;
173
+ declare "animator_map": AnimationHandle | null;
174
+ declare "animator_storyslider": AnimationHandle | null;
175
+ declare "_autoplay_timer": ReturnType<typeof setTimeout> | null;
176
+ declare "_transition_timer": ReturnType<typeof setTimeout> | null;
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;
190
+ declare "_hash_initialized": boolean;
191
+ declare "_collapsed": boolean;
192
+ /** the data source was a IIIF Presentation manifest (legacy zoomify options are ignored) */
193
+ declare "_data_from_manifest": boolean;
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;
209
+ declare "_resize_timer": ReturnType<typeof setTimeout> | null;
210
+ declare "fire": EventedInstance["fire"];
211
+ declare "hasEventListeners": EventedInstance["hasEventListeners"];
212
+
213
+ // TODO: mixin
214
+ // includes: VCO.Events,
215
+
216
+ /* Private Methods
217
+ ================================================== */
218
+ /**
219
+ * Create a StoryMap inside the given element.
220
+ *
221
+ * @param elem - The container element, or its DOM id.
222
+ * @param data - Either an inline storymap document (`{ storymap: ... }`),
223
+ * an inline IIIF Presentation 3 manifest, or the URL of a source file
224
+ * (storymap JSON or IIIF manifest) that is fetched automatically.
225
+ * @param options - Optional {@link StorymapOptions} overrides (map type,
226
+ * lines, fonts, animation, raw OpenLayers options via `map_options`...).
227
+ * @param listeners - Optional event listeners keyed by event name
228
+ * (`change`, `title`, `loaded`, ...) attached on creation.
229
+ */
230
+ constructor(
231
+ elem: string | HTMLElement,
232
+ data: string | StorymapDataWrapper | Record<string, unknown>,
233
+ options?: Partial<StorymapOptions>,
234
+ listeners?: Record<string, StoryMapListener | StoryMapListener[]>,
235
+ ) {
236
+ for (const key in listeners) {
237
+ const callbacks = listeners[key];
238
+ if (typeof callbacks == "function") {
239
+ this.on(key, callbacks);
240
+ } else {
241
+ for (const idx in callbacks) {
242
+ if (typeof callbacks[idx] == "function") {
243
+ this.on(key, callbacks[idx]);
244
+ } else {
245
+ console.log(
246
+ "WARNING: Ignoring invalid callback '" +
247
+ callbacks[idx] +
248
+ "' defined for " +
249
+ "listener '" +
250
+ key +
251
+ "' in StoryMap constructor",
252
+ );
253
+ }
254
+ }
255
+ }
256
+ }
257
+
258
+ // Version
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";
263
+
264
+ // Ready
265
+ this.ready = false;
266
+
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.
270
+ this._el = {
271
+ container: {} as HTMLElement,
272
+ storyslider: {} as HTMLElement,
273
+ map: null,
274
+ menubar: {} as HTMLElement,
275
+ };
276
+
277
+ // Determine Container Element
278
+ if (typeof elem === "object") {
279
+ this._el.container = elem;
280
+ } else {
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;
286
+ }
287
+
288
+ // Slider
289
+ this._storyslider = {} as StorySlider;
290
+
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;
298
+
299
+ // Menu Bar
300
+ this._menubar = {} as MenuBar;
301
+
302
+ // Loaded State
303
+ this._loaded = { storyslider: false, map: false };
304
+
305
+ // Data Object
306
+ // Test Data compiled from http://www.pbs.org/marktwain/learnmore/chronology.html
307
+ this.data = {} as StorymapData;
308
+
309
+ this.options = {
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: {},
315
+ height: this._el.container.offsetHeight,
316
+ width: this._el.container.offsetWidth,
317
+ layout: "landscape", // portrait or landscape
318
+ base_class: "",
319
+ default_bg_color: { r: 255, g: 255, b: 255 },
320
+ map_size_sticky: 2.5, // Set as division 1/3 etc
321
+ map_center_offset: null, // takes object {top:0,left:0}
322
+ start_at_slide: 0,
323
+ call_to_action: false,
324
+ call_to_action_text: "",
325
+ menubar_height: 0,
326
+ fullscreen: true,
327
+ show_overview: true,
328
+ show_back_to_start: true,
329
+ skinny_size: 650,
330
+ // animation
331
+ duration: 1000,
332
+ ease: easeInOutQuint,
333
+ trackResize: true,
334
+ keyboard: false,
335
+ nocache: false,
336
+ autoplay: 0,
337
+ autoplay_media: false,
338
+ show_progress: false,
339
+ marker_labels: false,
340
+ text_align: "left",
341
+ map_overview_center: null,
342
+ map_type: "", // "osm:standard",
343
+ tile_source_factory: null,
344
+ attribution: "",
345
+ map_mini: true,
346
+ map_as_image: false,
347
+ // no bundled credentials: pass map_access_token in the options
348
+ // if you use Mapbox/Stadia tiles, api_key_flickr for flickr API
349
+ // URLs
350
+ map_access_token: "",
351
+ map_background_color: "#d9d9d9",
352
+ map_area: "full", // "left" limits the map to the visible half (landscape)
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,
361
+ consent_required: false,
362
+ zoomify: undefined,
363
+ text_color: "",
364
+ text_background_color: "",
365
+ show_distance: false,
366
+ use_custom_markers: false,
367
+ iiif: {
368
+ url: "",
369
+ attribution: "",
370
+ },
371
+ map_height: 300,
372
+ storyslider_height: 600,
373
+ slide_padding_lr: 45, // padding on slide of slide
374
+ slide_default_fade: "0%", // landscape fade
375
+ menubar_default_y: 0,
376
+ calculate_zoom: true, // Allow map to determine best zoom level between markers (recommended)
377
+ line_follows_path: true, // Map history path follows default line, if false it will connect previous and current only
378
+ line_color: "#c34528", //"#DA0000",
379
+ line_color_inactive: "#CCC",
380
+ line_join: "miter",
381
+ line_weight: 3,
382
+ line_opacity: 0.8,
383
+ line_dash: "5,5",
384
+ show_lines: true,
385
+ show_history_line: true,
386
+ api_key_flickr: "", // no bundled key: pass api_key_flickr in the options
387
+ font_css: "stock:default",
388
+ language: "en",
389
+ } as StorymapOptions;
390
+
391
+ // Animation Objects
392
+ this.animator_map = null;
393
+ this.animator_storyslider = null;
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;
406
+ this._resize_timer = null;
407
+ this._autoplay_timer = null;
408
+ this._transition_timer = null;
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;
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;
426
+
427
+ // Merge Options -- legacy, in case people still need to pass in
428
+ mergeData(this.options, options);
429
+
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.
435
+ this.current_slide = this.options.start_at_slide;
436
+
437
+ this._initData(data);
438
+
439
+ return this;
440
+ }
441
+
442
+ /* Initialize the data
443
+ ================================================== */
444
+ _initData(data: string | StorymapDataWrapper | Record<string, unknown>) {
445
+ // legacy zoomify options are only honored for storymap JSON sources —
446
+ // a IIIF Presentation manifest cannot carry them
447
+ this._data_from_manifest = false;
448
+ if (typeof data === "string") {
449
+ // issue #417: optional cache-busting re-fetch of the source file
450
+ const url =
451
+ this.options.nocache === true
452
+ ? data + (data.includes("?") ? "&" : "?") + "_=" + Date.now()
453
+ : data;
454
+ void this._loadDataFromUrl(url, data);
455
+ } else if (typeof data === "object") {
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)) {
459
+ this._data_from_manifest = true;
460
+ this._raw_manifest = data;
461
+ this.data = manifestToStorymapData(data);
462
+ } else {
463
+ const wrapper = data as StorymapDataWrapper;
464
+ validateStorymapAndReport(wrapper);
465
+ if (wrapper.storymap) {
466
+ this.data = wrapper.storymap;
467
+ } else {
468
+ console.error("StoryMapJS: data must have a storymap property");
469
+ }
470
+ }
471
+ this._initOptions();
472
+ } else {
473
+ console.error("StoryMapJS: data has unknown type");
474
+ this._initOptions();
475
+ }
476
+ }
477
+
478
+ /* Load storymap data from a URL
479
+ ================================================== */
480
+ async _loadDataFromUrl(url: string, source: string) {
481
+ try {
482
+ const response = await fetch(url);
483
+ if (!response.ok) {
484
+ throw new Error("HTTP " + response.status + " " + response.statusText);
485
+ }
486
+ const result: unknown = await response.json();
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)) {
495
+ this._data_from_manifest = true;
496
+ this._raw_manifest = result;
497
+ this.data = manifestToStorymapData(result);
498
+ } else {
499
+ validateStorymapAndReport(result, source);
500
+ const wrapper = result as StorymapDataWrapper;
501
+ if (!wrapper.storymap) {
502
+ throw new Error("StoryMapJS: data must have a storymap property");
503
+ }
504
+ this.data = wrapper.storymap;
505
+ }
506
+ this._initOptions();
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
+ }
516
+ console.error("StoryMapJS: could not load storymap data from " + source, err);
517
+ this.fire("error", { message: String(err), source });
518
+ }
519
+ }
520
+
521
+ /* Initialize the options
522
+ ================================================== */
523
+ _initOptions() {
524
+ // Grab options from storymap data
525
+ updateData(this.options, this.data);
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
+
543
+ // legacy zoomify options only work with storymap JSON sources — a
544
+ // IIIF Presentation manifest cannot carry them
545
+ if (this._data_from_manifest) {
546
+ delete this.options.zoomify;
547
+ }
548
+
549
+ if (this.options.layout === "landscape") {
550
+ // map_area "left": the map is limited to the visible half with an
551
+ // opaque slide panel — no offset needed (the view center is the
552
+ // visible center already); "full" (default) offsets the view so
553
+ // markers clear the panel that fades in over the map
554
+ if (this.options.map_area !== "left") {
555
+ this.options.map_center_offset = { left: -200, top: 0 };
556
+ } else {
557
+ this.options.map_center_offset = { left: 0, top: 0 };
558
+ }
559
+ }
560
+ if (this.options.map_type === "iiif" && this.options.map_as_image) {
561
+ this.options.map_size_sticky = 2;
562
+ }
563
+ // legacy zoomify stories are image maps too — the map window is half
564
+ // the height (the original renderer's layout)
565
+ if (this.options.map_type === "zoomify") {
566
+ this.options.map_size_sticky = 2;
567
+ }
568
+ if (this.options.map_as_image) {
569
+ this.options.calculate_zoom = false;
570
+ }
571
+
572
+ // legacy zoomify support: rendered by the map via the image pyramid
573
+ // (options.zoomify) — deprecated, a JS warning points at iiif
574
+ if (this.options.map_type === "zoomify") {
575
+ console.warn(
576
+ "StoryMapJS: map_type 'zoomify' is a legacy image-pyramid basemap; consider map_type 'iiif' with options.iiif.url instead.",
577
+ );
578
+ const zoomify_opts = this.options.zoomify;
579
+ if (typeof zoomify_opts !== "object" || !zoomify_opts.path) {
580
+ console.error(
581
+ "StoryMapJS: map_type 'zoomify' needs a zoomify image pyramid (path, width, height) in the storymap data.",
582
+ );
583
+ }
584
+ }
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
+
598
+ // handle Stamen change
599
+ if (this.options.map_type.startsWith("stamen")) {
600
+ const old_type = this.options.map_type;
601
+ if (old_type === "stamen:watercolor") {
602
+ this.options.map_type = "ch-watercolor";
603
+ } else {
604
+ this.options.map_type = "osm:standard";
605
+ }
606
+ console.log(`Deprecated map_type ${old_type}; using ${this.options.map_type}`);
607
+ }
608
+
609
+ this._loadLanguage();
610
+ }
611
+
612
+ /* Load Language
613
+ ================================================== */
614
+
615
+ _loadLanguage() {
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
+ }
639
+ this._onDataLoaded();
640
+ }
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
+
656
+ /* Switch the UI language at runtime, without a page reload.
657
+ ================================================== */
658
+
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);
663
+ this.options.language = code;
664
+ setLanguage(code);
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());
680
+ }
681
+
682
+ /* Load the font theme stylesheet
683
+ ================================================== */
684
+ async _loadFontCss() {
685
+ // only genuinely external URLs ask for consent: stock: themes and
686
+ // relative paths resolve to same-origin / library assets
687
+ const original = this.options.font_css || "stock:default";
688
+ const font = resolveFontCssUrl(original);
689
+ const manager = consentManagerOf(this.options);
690
+ const external = /^(http|https|\/\/)/.test(original);
691
+ if (external && this.options.consent_required && manager) {
692
+ // external font CSS is an external service — ask first
693
+ const host = new URL(font.startsWith("//") ? "https:" + font : font).host;
694
+ const container = this._el.map ?? (this._el.container as HTMLElement);
695
+ const allowed = await manager.request(fontService(), host, container);
696
+ if (!allowed) {
697
+ return;
698
+ }
699
+ }
700
+ if (font) {
701
+ try {
702
+ await loadCSS(font);
703
+ } catch {
704
+ // a missing font theme must not block the storymap; the
705
+ // font-loaded event fires either way, as before
706
+ } finally {
707
+ this._onFontLoaded(font);
708
+ }
709
+ }
710
+ }
711
+
712
+ _onFontLoaded(font: string) {
713
+ this.fire("fontLoaded", { font: font });
714
+ }
715
+
716
+ /* Navigation
717
+ ================================================== */
718
+ /**
719
+ * Navigate to a slide by index. Out-of-range indices are ignored, as is
720
+ * the current index.
721
+ *
722
+ * @param n - Zero-based slide index; the map animates to the slide's
723
+ * marker. The overview is selected by the slide's own
724
+ * `type: "overview"`, not by its index.
725
+ */
726
+ goTo(n: number) {
727
+ if (this._disposed) return;
728
+ // out-of-range indices are ignored: they would desync the slider and
729
+ // map (no active slide) and write a broken #slide-N bookmark
730
+ if (n >= 0 && n < (this.data?.slides?.length ?? 0) && n !== this.current_slide) {
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") {
758
+ this._storyslider.goTo(this.current_slide);
759
+ }
760
+ if (navigate !== "map") {
761
+ this._map?.goTo(this.current_slide);
762
+ }
763
+ this._beginTransition(duration);
764
+ if (!navigated) {
765
+ // programmatic navigation reports outward like interaction does;
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
+ );
779
+ }
780
+ this._syncHash();
781
+ this._playNarration(this.data.slides?.[this.current_slide]);
782
+ this._scheduleAutoplay();
783
+ this._updateProgress();
784
+ }
785
+
786
+ /**
787
+ * Announce a slide transition: `transitionstart` now (with the glide
788
+ * duration both animations were started with) and `transitionend` once
789
+ * it elapses. Restarts on every navigation, so only the latest
790
+ * transition ever ends.
791
+ */
792
+ _beginTransition(duration: number): void {
793
+ if (this._transition_timer) {
794
+ clearTimeout(this._transition_timer);
795
+ }
796
+ this.fire("transitionstart", { current_slide: this.current_slide, duration }, this);
797
+ this._transition_timer = setTimeout(() => {
798
+ this._transition_timer = null;
799
+ this.fire("transitionend", { current_slide: this.current_slide }, this);
800
+ }, duration);
801
+ }
802
+
803
+ /**
804
+ * Re-measure the container and re-layout the map, slider and menubar.
805
+ * Called automatically on resize (see the `trackResize` option).
806
+ */
807
+ updateDisplay() {
808
+ if (this._disposed) {
809
+ return;
810
+ }
811
+ if (this.ready) {
812
+ this._updateDisplay();
813
+ }
814
+ }
815
+
816
+ /**
817
+ * Change a single map option at runtime and apply its effect immediately.
818
+ *
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`
823
+ * (rebuilds the stacked overlay layers), `show_lines`, `line_color`,
824
+ * `line_color_inactive`, `line_weight`, `line_opacity`, `line_dash`,
825
+ * `line_join`, `line_follows_path`, `show_history_line` (restyled
826
+ * instantly), `map_center_offset` (applied on the next navigation),
827
+ * `duration`, `ease`, `calculate_zoom`, `map_background_color`. All other
828
+ * options only take effect on the next navigation or require re-creating
829
+ * the StoryMap.
830
+ */
831
+ /**
832
+ * Sugar over {@link setMapOptions} — `setMapOption(name, value)` is the
833
+ * same call as `setMapOptions({ [name]: value })`.
834
+ */
835
+ setMapOption(name: string, value: unknown) {
836
+ if (this._disposed) return;
837
+ this.setMapOptions({ [name]: value } as Partial<StorymapOptions>);
838
+ }
839
+
840
+ /**
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.
844
+ */
845
+ setOverlayVisible(index: number, visible: boolean): void {
846
+ if (this._disposed) return;
847
+ this._map?.setOverlayVisible(index, visible);
848
+ }
849
+
850
+ /**
851
+ * Set a stacked overlay's opacity by index (see the `overlays` option).
852
+ * Alias: `getOverlayLayer(index)?.setOpacity(opacity)`.
853
+ */
854
+ setOverlayOpacity(index: number, opacity: number): void {
855
+ if (this._disposed) return;
856
+ this._map?.setOverlayOpacity(index, opacity);
857
+ }
858
+
859
+ /**
860
+ * Change several map options at runtime (see setMapOption).
861
+ */
862
+ setMapOptions(options: Partial<StorymapOptions>) {
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;
883
+ }
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
+ }
890
+ if (this.ready) {
891
+ // text color theming follows runtime option changes (issue #177)
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);
1006
+ }
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);
1043
+ }
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);
1109
+ }
1110
+
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
+ }
1120
+
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
+ }
1130
+
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 {
1171
+ if (this.options.text_color) {
1172
+ this._el.container.style.setProperty("--vco-color-text", this.options.text_color);
1173
+ }
1174
+ if (this.options.text_background_color) {
1175
+ this._el.container.style.setProperty(
1176
+ "--vco-color-text-background",
1177
+ this.options.text_background_color,
1178
+ );
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();
1196
+
1197
+ // Create Layout
1198
+ this._el.menubar = Dom.create("div", "vco-menubar", this._el.container);
1199
+ this._el.map = this._map_disabled
1200
+ ? null
1201
+ : (this._resolveMapElement() ?? Dom.create("div", "vco-map", this._el.container));
1202
+ this._el.storyslider = Dom.create("div", "vco-storyslider", this._el.container);
1203
+
1204
+ // Initial Default Layout
1205
+ this.options.width = this._el.container.offsetWidth;
1206
+ this.options.height = this._el.container.offsetHeight;
1207
+ if (this._el.map) {
1208
+ this._el.map.style.height = "1px";
1209
+ }
1210
+ this._el.storyslider.style.top = "1px";
1211
+
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
+ });
1235
+
1236
+ // Map Background Color
1237
+ this._map_el().style.backgroundColor = this.options.map_background_color;
1238
+ }
1239
+
1240
+ // Create Menu Bar
1241
+ this._menubar = new MenuBar(this._el.menubar, this._el.container, this.options);
1242
+
1243
+ // Create StorySlider
1244
+ this._storyslider = new StorySlider(this._el.storyslider, this.data, this.options);
1245
+ this._storyslider.on("loaded", this._onStorySliderLoaded, this);
1246
+ this._storyslider.on("title", this._onTitle, this);
1247
+ this._storyslider.init();
1248
+
1249
+ // LAYOUT
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") {
1256
+ // Set Default Component Sizes
1257
+ this.options.map_height = this.options.height / this.options.map_size_sticky;
1258
+ this.options.storyslider_height =
1259
+ this.options.height - this._el.menubar.offsetHeight - this.options.map_height - 1;
1260
+ this._menubar.setSticky(0);
1261
+ } else {
1262
+ this.options.menubar_height = this._el.menubar.offsetHeight;
1263
+ // Set Default Component Sizes
1264
+ this.options.map_height = this.options.height;
1265
+ this.options.storyslider_height =
1266
+ this.options.height - this._el.menubar.offsetHeight - 1;
1267
+ this._menubar.setSticky(this.options.menubar_height);
1268
+ }
1269
+
1270
+ // Update Display
1271
+ this._updateDisplay(this.options.map_height, true, 2000);
1272
+
1273
+ // Animate Menu Bar to Default Location
1274
+ this._menubar.show(2000);
1275
+ }
1276
+
1277
+ _initEvents() {
1278
+ // Sidebar Events
1279
+ this._menubar.on("collapse", this._onMenuBarCollapse, this);
1280
+ this._menubar.on("back_to_start", this._onBackToStart, this);
1281
+ this._menubar.on("overview", this._onOverview, this);
1282
+ this._menubar.on("fullscreen", this._onFullscreenToggle, this);
1283
+
1284
+ // StorySlider Events
1285
+ this._storyslider.on("change", this._onSlideChange, this);
1286
+ this._storyslider.on("colorchange", this._onColorChange, this);
1287
+
1288
+ // Map Events
1289
+ this._map?.on("change", this._onMapChange, this);
1290
+
1291
+ // Global slide navigation (opt-in): the slider only listens on its
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).
1299
+ if (this.options.keyboard) {
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);
1311
+ }
1312
+
1313
+ // Fullscreen state
1314
+ this._on_fullscreen = () => this._onFullscreenChange();
1315
+ document.addEventListener("fullscreenchange", this._on_fullscreen);
1316
+ }
1317
+
1318
+ _onKeyDownGlobal(e: KeyboardEvent) {
1319
+ if (e.defaultPrevented) {
1320
+ return;
1321
+ }
1322
+ const target = e.target as HTMLElement | null;
1323
+ const tag = target?.tagName?.toLowerCase();
1324
+ if (
1325
+ tag === "input" ||
1326
+ tag === "textarea" ||
1327
+ tag === "select" ||
1328
+ target?.isContentEditable
1329
+ ) {
1330
+ return;
1331
+ }
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)) {
1337
+ return;
1338
+ }
1339
+ if (e.key === "ArrowRight" || e.key === "ArrowDown") {
1340
+ e.preventDefault();
1341
+ this._storyslider.next();
1342
+ } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
1343
+ e.preventDefault();
1344
+ this._storyslider.previous();
1345
+ }
1346
+ }
1347
+
1348
+ // View
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
+ }
1354
+ let duration = this.options.duration,
1355
+ display_class = this.options.base_class;
1356
+
1357
+ if (d) {
1358
+ duration = d;
1359
+ }
1360
+
1361
+ // Update width and height
1362
+ this.options.width = this._el.container.offsetWidth;
1363
+ this.options.height = this._el.container.offsetHeight;
1364
+
1365
+ // Check if skinny
1366
+ if (this.options.width <= this.options.skinny_size) {
1367
+ this.options.layout = "portrait";
1368
+ } else {
1369
+ this.options.layout = "landscape";
1370
+ }
1371
+
1372
+ // Map Height
1373
+ if (map_height) {
1374
+ this.options.map_height = map_height;
1375
+ }
1376
+
1377
+ // Detect Mobile and Update Orientation on Touch devices
1378
+ if (Browser.touch) {
1379
+ this.options.layout = Browser.orientation();
1380
+ display_class += " vco-mobile";
1381
+ }
1382
+
1383
+ // LAYOUT
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") {
1422
+ display_class += " vco-skinny";
1423
+ // Map Offset
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
+ }
1436
+ this.options.storyslider_height = this.options.height - this.options.map_height - 1;
1437
+ this._menubar.setSticky(0);
1438
+
1439
+ // Portrait: the map spans the full width again (a landscape
1440
+ // map_area "left" pass narrowed it)
1441
+ display_class += " vco-layout-portrait";
1442
+ this._map_el().style.width = "100%";
1443
+
1444
+ if (animate) {
1445
+ // Animate Map
1446
+ if (this.animator_map) {
1447
+ this.animator_map.stop();
1448
+ }
1449
+
1450
+ this.animator_map = Animate(this._map_el(), {
1451
+ height: this.options.map_height + "px",
1452
+ duration: duration,
1453
+ easing: easeOutStrong,
1454
+ complete: () => {
1455
+ this._map_required().updateDisplay(
1456
+ this.options.width,
1457
+ this.options.map_height,
1458
+ animate,
1459
+ d,
1460
+ this.options.menubar_height,
1461
+ );
1462
+ },
1463
+ });
1464
+
1465
+ // Animate StorySlider
1466
+ if (this.animator_storyslider) {
1467
+ this.animator_storyslider.stop();
1468
+ }
1469
+ this.animator_storyslider = Animate(this._el.storyslider, {
1470
+ height: this.options.storyslider_height + "px",
1471
+ duration: duration,
1472
+ easing: easeOutStrong,
1473
+ });
1474
+ } else {
1475
+ // Map
1476
+ this._map_el().style.height = Math.ceil(this.options.map_height) + "px";
1477
+
1478
+ // StorySlider
1479
+ this._el.storyslider.style.height = this.options.storyslider_height + "px";
1480
+ }
1481
+
1482
+ // Update Component Displays
1483
+ this._menubar.updateDisplay(this.options.width, this.options.height, animate);
1484
+ this._map_required().updateDisplay(this.options.width, this.options.height, false);
1485
+ this._storyslider.updateDisplay(
1486
+ this.options.width,
1487
+ this.options.storyslider_height,
1488
+ animate,
1489
+ this.options.layout,
1490
+ );
1491
+ } else {
1492
+ // Landscape
1493
+ display_class += " vco-layout-landscape";
1494
+
1495
+ this.options.menubar_height = this._el.menubar.offsetHeight;
1496
+
1497
+ // Set Default Component Sizes
1498
+ this.options.map_height = this.options.height;
1499
+ this.options.storyslider_height = this.options.height;
1500
+
1501
+ // Set Sticky state of MenuBar
1502
+ this._menubar.setSticky(this.options.menubar_height);
1503
+
1504
+ this._map_el().style.height = this.options.height + "px";
1505
+
1506
+ // map_area "left": the map element is limited to the left, visible
1507
+ // half (the slide panel is opaque) — no view offset needed;
1508
+ // "full" (default): the map spans the whole width behind the
1509
+ // fading slide panel and the view is offset by a quarter width
1510
+ if (this.options.map_area === "left") {
1511
+ display_class += " vco-map-area-left";
1512
+ this._map_el().style.width = Math.floor(this.options.width / 2) + "px";
1513
+ this._map_required().setMapOffset(0, 0);
1514
+ } else {
1515
+ this._map_el().style.width = "100%";
1516
+ this._map_required().setMapOffset(-(this.options.width / 4), 0);
1517
+ }
1518
+
1519
+ // StorySlider
1520
+ this._el.storyslider.style.top = "0";
1521
+ this._el.storyslider.style.height = this.options.storyslider_height + "px";
1522
+
1523
+ this._menubar.updateDisplay(this.options.width, this.options.height, animate);
1524
+ this._map_required().updateDisplay(this.options.width, this.options.height, animate, d);
1525
+ this._storyslider.updateDisplay(
1526
+ this.options.width / 2,
1527
+ this.options.storyslider_height,
1528
+ animate,
1529
+ this.options.layout,
1530
+ );
1531
+ }
1532
+
1533
+ // the resolved locale decides the layout direction (issues #211, #245)
1534
+ if (isRtl()) {
1535
+ display_class += " vco-rtl";
1536
+ }
1537
+
1538
+ // Apply class
1539
+ this._el.container.className = display_class;
1540
+ }
1541
+
1542
+ /* Events
1543
+ ================================================== */
1544
+
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
+ }
1551
+ // attach the consent manager BEFORE the layout is created, so the
1552
+ // slider/map/media options copies all share it
1553
+ (this.options as Record<string, unknown>).consent_manager = new ConsentManager();
1554
+ this.fire("dataloaded");
1555
+ this._initLayout();
1556
+ // font themes load once the layout exists: the consent ask for
1557
+ // external font CSS needs a real container to render into
1558
+ void this._loadFontCss().catch((err: unknown) => {
1559
+ console.warn("StoryMapJS: font theme could not be loaded", err);
1560
+ });
1561
+ this._initEvents();
1562
+ this._initResizeHandling();
1563
+ this.ready = true;
1564
+ this._startConsentAsk();
1565
+ this._startAutoplay();
1566
+ }
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
+
1621
+ /* Autoplay (issue #380) and hash bookmarks (issue #146)
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
+
1691
+ _startAutoplay() {
1692
+ this._stopAutoplay();
1693
+ this._autoplay_stopped = false;
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()) {
1713
+ // any user interaction stops autoplay permanently
1714
+ const stop = () => {
1715
+ this._autoplay_stopped = true;
1716
+ this._stopAutoplay();
1717
+ };
1718
+ this._el.container.addEventListener("pointerdown", stop, { once: true });
1719
+ this._el.container.addEventListener("keydown", stop, { once: true });
1720
+ this._el.container.addEventListener("touchstart", stop, { once: true });
1721
+ this._scheduleAutoplay();
1722
+ }
1723
+ }
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
+ */
1737
+ _scheduleAutoplay() {
1738
+ this._stopAutoplay();
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
+ }
1755
+ }
1756
+
1757
+ this._autoplay_timer = setTimeout(advance, this.options.autoplay);
1758
+ }
1759
+
1760
+ _stopAutoplay() {
1761
+ if (this._autoplay_timer) {
1762
+ clearTimeout(this._autoplay_timer);
1763
+ this._autoplay_timer = null;
1764
+ }
1765
+ this._autoplay_advance = null;
1766
+ }
1767
+
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
+ */
1799
+ _syncHash() {
1800
+ try {
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();
1817
+ history.replaceState(
1818
+ null,
1819
+ "",
1820
+ window.location.pathname +
1821
+ (query === "" ? "" : "?" + query) +
1822
+ "#slide-" +
1823
+ (state !== null ? encodeURI(state.id) : this.current_slide),
1824
+ );
1825
+ } catch {
1826
+ // non-browser or sandboxed contexts
1827
+ }
1828
+ }
1829
+
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;
1880
+ }
1881
+ return null;
1882
+ }
1883
+
1884
+ /* Resize handling
1885
+ ================================================== */
1886
+ _initResizeHandling() {
1887
+ if (!this.options.trackResize) {
1888
+ return;
1889
+ }
1890
+ // Leading + trailing debounce: the first resize event re-layouts
1891
+ // immediately (instant feedback), the trailing one settles after the
1892
+ // resize burst ends.
1893
+ const onResize = () => {
1894
+ if (this._resize_timer) {
1895
+ clearTimeout(this._resize_timer);
1896
+ } else {
1897
+ this.updateDisplay();
1898
+ }
1899
+ this._resize_timer = setTimeout(() => {
1900
+ this._resize_timer = null;
1901
+ this.updateDisplay();
1902
+ }, 200);
1903
+ };
1904
+ if (typeof ResizeObserver !== "undefined") {
1905
+ // covers containers resized by their embedding layout
1906
+ this._resize_observer = new ResizeObserver(onResize);
1907
+ this._resize_observer.observe(this._el.container);
1908
+ }
1909
+ this._on_resize = onResize;
1910
+ window.addEventListener("resize", onResize);
1911
+ }
1912
+
1913
+ /**
1914
+ * Resolve a caller-supplied map element (options.map_options.element).
1915
+ * The passed element is "replaced by the real one": it is adopted as the
1916
+ * map container (given the vco-map class) and moved into place between
1917
+ * the menubar and the story slider.
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
+
1947
+ _resolveMapElement(): HTMLElement | null {
1948
+ const element = this.options.map_options?.element;
1949
+ if (!element) {
1950
+ return null;
1951
+ }
1952
+ const el = typeof element === "string" ? Dom.get(element) : element;
1953
+ if (!el) {
1954
+ console.error("StoryMapJS: map_options.element not found: " + String(element));
1955
+ return null;
1956
+ }
1957
+ el.classList.add("vco-map");
1958
+ this._el.menubar.after(el);
1959
+ return el;
1960
+ }
1961
+
1962
+ _onTitle(e: unknown) {
1963
+ this.fire("title", e);
1964
+ }
1965
+
1966
+ _onColorChange(e: { color?: unknown; image?: unknown }) {
1967
+ if (e.color || e.image) {
1968
+ this._menubar.setColor(true);
1969
+ } else {
1970
+ this._menubar.setColor(false);
1971
+ }
1972
+ }
1973
+
1974
+ _onSlideChange(e: { current_slide: number }) {
1975
+ if (this.current_slide !== e.current_slide) {
1976
+ // the slider already moved itself
1977
+ this._navigate(e.current_slide, { navigate: "slider" });
1978
+ }
1979
+ }
1980
+
1981
+ _onMapChange(e: { current_marker: number }) {
1982
+ if (this.current_slide !== e.current_marker) {
1983
+ // the map already moved itself
1984
+ this._navigate(e.current_marker, { navigate: "map" });
1985
+ }
1986
+ }
1987
+
1988
+ _updateProgress() {
1989
+ if (this.options.show_progress && this._menubar) {
1990
+ this._menubar.setProgress(this.current_slide, this.data.slides.length);
1991
+ }
1992
+ }
1993
+
1994
+ _onOverview(e?: unknown) {
1995
+ this._map?.markerOverview();
1996
+ }
1997
+
1998
+ /**
1999
+ * Toggle the native fullscreen API on the storymap container and keep the
2000
+ * menubar button state in sync.
2001
+ */
2002
+ _onFullscreenToggle(e?: unknown) {
2003
+ if (document.fullscreenElement === this._el.container) {
2004
+ document.exitFullscreen().catch((err: unknown) => {
2005
+ console.error("StoryMapJS: could not exit fullscreen", err);
2006
+ });
2007
+ } else {
2008
+ this._el.container.requestFullscreen().catch((err: unknown) => {
2009
+ console.error("StoryMapJS: could not enter fullscreen", err);
2010
+ });
2011
+ }
2012
+ }
2013
+
2014
+ _onFullscreenChange() {
2015
+ const active = document.fullscreenElement === this._el.container;
2016
+ this._menubar.setFullscreenState(active);
2017
+ // re-measure once the browser has applied the fullscreen layout
2018
+ // (the container resizes asynchronously after the event)
2019
+ requestAnimationFrame(() => {
2020
+ requestAnimationFrame(() => {
2021
+ this.updateDisplay();
2022
+ });
2023
+ });
2024
+ }
2025
+
2026
+ _onBackToStart(e?: unknown) {
2027
+ if (this.current_slide === 0) {
2028
+ return;
2029
+ }
2030
+ this._navigate(0);
2031
+ }
2032
+
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);
2039
+ }
2040
+
2041
+ _onMapLoaded() {
2042
+ this._loaded.map = true;
2043
+ this._onLoaded();
2044
+ }
2045
+
2046
+ _onStorySliderLoaded() {
2047
+ this._loaded.storyslider = true;
2048
+ this._onLoaded();
2049
+ }
2050
+
2051
+ /**
2052
+ * Compute and display the great-circle distance of the marker route
2053
+ * (issue #341); hidden when `show_distance` is off.
2054
+ */
2055
+ _updateDistance() {
2056
+ if (!this.options.show_distance) {
2057
+ return;
2058
+ }
2059
+ const km = this._map?.getRouteDistance();
2060
+ this._menubar.setDistance(km);
2061
+ }
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
+
2092
+ _onLoaded() {
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)) {
2098
+ this.fire("loaded", this.data);
2099
+ this._updateProgress();
2100
+ this._updateDistance();
2101
+ }
2102
+ }
2103
+ }
2104
+
2105
+ export default class StoryMap extends Evented(StoryMapBase) {
2106
+ /**
2107
+ * The library base path (the directory containing the module).
2108
+ * Derived from `import.meta.url`, which works both for the source module
2109
+ * (src/main.ts) and the built ESM bundle (js/storymap.js).
2110
+ */
2111
+ static SCRIPT_PATH = new URL(/* @vite-ignore */ "../", import.meta.url).href;
2112
+
2113
+ constructor(...args: ConstructorParameters<typeof StoryMapBase>) {
2114
+ super(...args);
2115
+ }
2116
+ }
2117
+
2118
+ export { StoryMap };