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