@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,1844 @@
1
+ // Conversion of IIIF Presentation API 3.0 manifests into legacy StoryMapJS
2
+ // data, following the mapping proposed in docs/storymap-as-iiif-manifest.md.
3
+ //
4
+ // The converter is intentionally defensive: every property is read through
5
+ // narrowing helpers and malformed shapes are skipped, never thrown.
6
+
7
+ import type {
8
+ StorymapData,
9
+ StorymapGeoreference,
10
+ StorymapTilejson,
11
+ StorymapOverlayLayer,
12
+ StorymapSlide,
13
+ StorymapSlideBackground,
14
+ StorymapSlideLocation,
15
+ StorymapSlideMedia,
16
+ } from "../types";
17
+ import { readGroundControlPoints } from "../map/georeference";
18
+
19
+ const PRESENTATION_3_CONTEXT = "iiif.io/api/presentation/3/context.json";
20
+ const MAPCONFIG_PROFILE = "mapconfig";
21
+ const STORYMAP_PREFIX = "storymap:";
22
+
23
+ /** Keys copied verbatim from navPlace Feature properties onto the location. */
24
+ const LOCATION_PROPERTIES = [
25
+ "name",
26
+ "zoom",
27
+ "line",
28
+ "icon",
29
+ "iconSize",
30
+ "image",
31
+ "use_custom_marker",
32
+ // marker presentation with no IIIF vocabulary of its own: a GeoJSON
33
+ // foreign member needs no registration, which is why these live here
34
+ // rather than in a storymap: term (docs/plans/iiif-media-tours.md §2)
35
+ "popup",
36
+ "audioBadge",
37
+ ] as const;
38
+
39
+ function asRecord(value: unknown): Record<string, unknown> | null {
40
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
41
+ return null;
42
+ }
43
+ return value as Record<string, unknown>;
44
+ }
45
+
46
+ function asString(value: unknown): string | null {
47
+ return typeof value === "string" && value !== "" ? value : null;
48
+ }
49
+
50
+ function asNumber(value: unknown): number | null {
51
+ return typeof value === "number" && !isNaN(value) ? value : null;
52
+ }
53
+
54
+ function asBoolean(value: unknown): boolean | null {
55
+ return typeof value === "boolean" ? value : null;
56
+ }
57
+
58
+ function asStringArray(value: unknown): string[] {
59
+ if (typeof value === "string") return [value];
60
+ if (Array.isArray(value)) {
61
+ return value.filter((entry): entry is string => typeof entry === "string");
62
+ }
63
+ return [];
64
+ }
65
+
66
+ /**
67
+ * Reads a IIIF xywh image region (`storymap:imageRegion`): an array of
68
+ * exactly 4 finite numbers ([x, y, w, h] in image pixels). Returns null
69
+ * for anything else — invalid regions are ignored.
70
+ */
71
+ /**
72
+ * A language map reduced to one string in the language the viewer is using,
73
+ * together with the language that was actually chosen (§3.4).
74
+ *
75
+ * `flattenLanguageMap` used to concatenate *every* language, which loses which
76
+ * language each string was in — for a bilingual tour that produced a headline
77
+ * of "Hallo Hello" and no way to tell a host what the viewer had picked. The
78
+ * choice is: the configured language, then its base subtag (`de-AT` → `de`),
79
+ * then the language-neutral `none`, then the first key present.
80
+ *
81
+ * `language` is null for `none`, which is genuinely language-neutral and not a
82
+ * language a host could offer in a switch.
83
+ */
84
+ export function pickLanguageMap(
85
+ value: unknown,
86
+ preferred: string | null = null,
87
+ ): { value: string; language: string | null } {
88
+ if (typeof value === "string") return { value, language: null };
89
+ const record = asRecord(value);
90
+ if (!record) return { value: "", language: null };
91
+ // a TextualBody carries its text in `value`; that is a single string, not
92
+ // a language map, so it keeps flattenLanguageMap's shape
93
+ const textual = record.value;
94
+ if (typeof textual === "string") return { value: textual, language: null };
95
+ if (Array.isArray(textual)) {
96
+ return { value: asStringArray(textual).join(" ").trim(), language: null };
97
+ }
98
+
99
+ const keys = Object.keys(record);
100
+ if (keys.length === 0) return { value: "", language: null };
101
+ const join = (key: string): string => asStringArray(record[key]).join(" ").trim();
102
+
103
+ if (preferred !== null && preferred !== "") {
104
+ for (const key of keys) {
105
+ if (key.toLowerCase() === preferred.toLowerCase()) {
106
+ return { value: join(key), language: key };
107
+ }
108
+ }
109
+ const base = preferred.split("-")[0].toLowerCase();
110
+ for (const key of keys) {
111
+ if (key.toLowerCase() === base) return { value: join(key), language: key };
112
+ }
113
+ }
114
+ if ("none" in record) return { value: join("none"), language: null };
115
+ return { value: join(keys[0]), language: keys[0] };
116
+ }
117
+
118
+ /**
119
+ * A selector, normalized. Every field is optional and the object is a
120
+ * *superset* carrier: an annotation may carry a region, a point, a quote and a
121
+ * time range at once, and we keep what we understand instead of stopping at
122
+ * the first hit.
123
+ *
124
+ * - `region`: xywh image pixels. From an Image API Selector / FragmentSelector
125
+ * (`xywh=`), or synthesized from a `point` when the canvas size is known.
126
+ * - `point`: x/y image pixels, from a `PointSelector`.
127
+ * - `quote`: a `TextQuoteSelector`. Preserved, **not resolved** — a canvas
128
+ * carries no transcript, so matching the quoted text needs one the tour
129
+ * supplies. See docs/iiif-authoring.md.
130
+ * - `time`: start/end in seconds, from a `TimeState` or `start`/`end` on a
131
+ * SpecificResource, for time-anchored stops and narration.
132
+ * - `svg`: an `SvgSelector`'s shape markup. Preserved for round-tripping; the
133
+ * viewer fits `region`, so a non-rectangular selection is not highlighted
134
+ * with its true outline yet.
135
+ */
136
+ export interface ReadSelector {
137
+ region: [number, number, number, number] | null;
138
+ point: { x: number; y: number } | null;
139
+ quote: { exact: string; prefix?: string; suffix?: string } | null;
140
+ time: { start?: number; end?: number } | null;
141
+ svg: string | null;
142
+ }
143
+
144
+ const EMPTY_SELECTOR: ReadSelector = {
145
+ region: null,
146
+ point: null,
147
+ quote: null,
148
+ time: null,
149
+ svg: null,
150
+ };
151
+
152
+ function isEmptySelector(sel: ReadSelector): boolean {
153
+ return (
154
+ sel.region === null &&
155
+ sel.point === null &&
156
+ sel.quote === null &&
157
+ sel.time === null &&
158
+ sel.svg === null
159
+ );
160
+ }
161
+
162
+ /** A `PointSelector` is a pin, which the viewer cannot fit, so we synthesize
163
+ * a square of 5% of the canvas's smaller side, centred on the point and
164
+ * clamped to the canvas. Stated once, here: the annotation-driven stops in
165
+ * docs/plans/iiif-media-tours.md consume this rather than re-deriving it. */
166
+ const POINT_SQUARE_FRACTION = 0.05;
167
+
168
+ function pointToRegion(
169
+ point: { x: number; y: number },
170
+ width: number | null,
171
+ height: number | null,
172
+ ): [number, number, number, number] | null {
173
+ if (width === null || height === null) return null;
174
+ if (!(width > 0) || !(height > 0)) return null;
175
+ const side = Math.max(1, Math.round(Math.min(width, height) * POINT_SQUARE_FRACTION));
176
+ const x = Math.max(0, Math.min(width - side, Math.round(point.x - side / 2)));
177
+ const y = Math.max(0, Math.min(height - side, Math.round(point.y - side / 2)));
178
+ return [x, y, side, side];
179
+ }
180
+
181
+ /** One selector entry, plus one level of `refinedBy`. */
182
+ function readSelectorEntry(entry: unknown, out: ReadSelector, depth = 0): void {
183
+ const selector = asRecord(entry);
184
+ if (!selector) return;
185
+ const type = asString(selector.type);
186
+
187
+ if (type === "ImageApiSelector" || type === "FragmentSelector") {
188
+ const raw = asString(selector.value);
189
+ if (raw !== null && out.region === null) {
190
+ const match = /xywh=(pixel:)?([^,]+),([^,]+),([^,]+),([^,]+)/.exec(raw);
191
+ if (match) {
192
+ const parts = match.slice(2).map(Number);
193
+ if (parts.length === 4 && parts.every((n) => Number.isFinite(n))) {
194
+ if (parts[0] >= 0 && parts[1] >= 0 && parts[2] > 0 && parts[3] > 0) {
195
+ out.region = parts as [number, number, number, number];
196
+ }
197
+ }
198
+ }
199
+ }
200
+ } else if (type === "PointSelector") {
201
+ const x = asNumber(selector.x);
202
+ const y = asNumber(selector.y);
203
+ if (x !== null && y !== null && out.point === null) {
204
+ out.point = { x, y };
205
+ }
206
+ } else if (type === "TextQuoteSelector") {
207
+ const exact = asString(selector.exact);
208
+ if (exact !== null && out.quote === null) {
209
+ const prefix = asString(selector.prefix);
210
+ const suffix = asString(selector.suffix);
211
+ out.quote = {
212
+ exact,
213
+ ...(prefix !== null ? { prefix } : {}),
214
+ ...(suffix !== null ? { suffix } : {}),
215
+ };
216
+ }
217
+ } else if (type === "SvgSelector") {
218
+ const value = asString(selector.value);
219
+ if (value !== null && out.svg === null) out.svg = value;
220
+ } else if (type === "TimeState" || type === "oa:TimeState") {
221
+ const state = asRecord(selector);
222
+ const start = asNumber(state?.start);
223
+ const end = asNumber(state?.end);
224
+ if ((start !== null || end !== null) && out.time === null) {
225
+ out.time = { ...(start !== null ? { start } : {}), ...(end !== null ? { end } : {}) };
226
+ }
227
+ }
228
+
229
+ if (depth < 1) {
230
+ const refined = Array.isArray(selector.refinedBy)
231
+ ? selector.refinedBy
232
+ : [selector.refinedBy];
233
+ for (const nested of refined) readSelectorEntry(nested, out, depth + 1);
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Reads a Web Annotation target (a string fragment, a SpecificResource, or
239
+ * either carrying `selector`/`state` arrays) into a normalized
240
+ * {@link ReadSelector}. `width`/`height` are the canvas size in pixels, used
241
+ * only to synthesize a region from a point selector; pass null when unknown.
242
+ */
243
+ export function readSelector(
244
+ target: unknown,
245
+ width: number | null = null,
246
+ height: number | null = null,
247
+ ): ReadSelector {
248
+ const out: ReadSelector = { ...EMPTY_SELECTOR };
249
+
250
+ // A bare string target may carry the fragment: "…/canvas/2#xywh=10,20,30,40"
251
+ const asText = asString(target);
252
+ if (asText !== null) {
253
+ readSelectorEntry({ type: "FragmentSelector", value: asText }, out);
254
+ }
255
+
256
+ const record = asRecord(target);
257
+ if (record) {
258
+ const selectors = Array.isArray(record.selector) ? record.selector : [record.selector];
259
+ for (const entry of selectors) readSelectorEntry(entry, out);
260
+ const states = Array.isArray(record.state) ? record.state : [record.state];
261
+ for (const entry of states) readSelectorEntry(entry, out);
262
+ }
263
+
264
+ // A SpecificResource may carry the time range directly.
265
+ if (record && out.time === null) {
266
+ const start = asNumber(record.start);
267
+ const end = asNumber(record.end);
268
+ if (start !== null || end !== null) {
269
+ out.time = {
270
+ ...(start !== null ? { start } : {}),
271
+ ...(end !== null ? { end } : {}),
272
+ };
273
+ }
274
+ }
275
+
276
+ if (out.region === null && out.point !== null) {
277
+ out.region = pointToRegion(out.point, width, height);
278
+ }
279
+
280
+ return isEmptySelector(out) ? { ...EMPTY_SELECTOR } : out;
281
+ }
282
+
283
+ /**
284
+ * Flattens a IIIF language map (`{"none": ["text"]}`, `{"en": ["Hello"]}`) to a
285
+ * plain string. The `none` language is preferred; entries are joined with a
286
+ * space. Also accepts a plain string and a TextualBody (`{value: "..."}`),
287
+ * which the proposal allows for Canvas summaries.
288
+ */
289
+ export function flattenLanguageMap(value: unknown): string {
290
+ if (typeof value === "string") return value;
291
+ const record = asRecord(value);
292
+ if (!record) return "";
293
+ const textual = record.value;
294
+ if (typeof textual === "string") return textual;
295
+ if (Array.isArray(textual)) {
296
+ return asStringArray(textual).join(" ").trim();
297
+ }
298
+ // Language map: prefer the "none" key, fall back to every language in order.
299
+ const keys = "none" in record ? ["none"] : Object.keys(record);
300
+ const parts: string[] = [];
301
+ for (const key of keys) {
302
+ parts.push(...asStringArray(record[key]));
303
+ }
304
+ return parts.join(" ").trim();
305
+ }
306
+
307
+ /**
308
+ * A `requiredStatement` reduced to its label and value.
309
+ *
310
+ * In Presentation 3 `requiredStatement` is a **single `{label, value}`
311
+ * object** — the official IIIF validator rejects the array form with "is not of
312
+ * type 'object'" — so the array branch below is producer leniency, not
313
+ * conformance. The `label` ("Credit", "Rights holder", a language-tagged term)
314
+ * is half of what a real institutional manifest ships, and reading only
315
+ * `.value` dropped it. See docs/plans/iiif-interop.md §2.1.
316
+ */
317
+ function readRequiredStatement(statement: unknown): { label: string; value: string } {
318
+ const entries = Array.isArray(statement)
319
+ ? statement
320
+ : statement !== undefined
321
+ ? [statement]
322
+ : [];
323
+ for (const entry of entries) {
324
+ const record = asRecord(entry);
325
+ if (!record) continue;
326
+ const value = flattenLanguageMap(record.value);
327
+ if (value === "") continue;
328
+ return { label: flattenLanguageMap(record.label), value };
329
+ }
330
+ return { label: "", value: "" };
331
+ }
332
+
333
+ /**
334
+ * Attribution text for a `requiredStatement`, keeping its label.
335
+ *
336
+ * A labelled statement reads as "Label: value", because that is how the value
337
+ * was meant to be attributed. An unlabelled one is returned verbatim, exactly
338
+ * as before — plenty of manifests state a bare rights line and prefixing it
339
+ * would be noise.
340
+ */
341
+ function formatAttribution(statement: { label: string; value: string }): string {
342
+ if (statement.value === "") return "";
343
+ if (statement.label === "") return statement.value;
344
+ return `${statement.label}: ${statement.value}`;
345
+ }
346
+
347
+ /**
348
+ * Reads a StoryMap extension term from a Canvas or service object. Manifests
349
+ * use the `storymap:`-prefixed form, but JSON-LD processors may emit the bare
350
+ * term, so both are accepted. The bare `type` key is never read because it
351
+ * collides with the IIIF resource type (`"Canvas"`).
352
+ */
353
+ function readTerm(record: Record<string, unknown>, term: string): unknown {
354
+ const prefixed = record[STORYMAP_PREFIX + term];
355
+ if (prefixed !== undefined) {
356
+ return prefixed;
357
+ }
358
+ return term === "type" ? undefined : record[term];
359
+ }
360
+
361
+ /**
362
+ * True when `data` looks like a Presentation API 3.0 **Manifest**.
363
+ *
364
+ * A `Collection` is explicitly *not* one, even though it carries the same
365
+ * `@context`. Detection used to accept anything with the P3 context, so a
366
+ * Collection passed and its member Manifests were fed to `canvasToSlide` as if
367
+ * they were Canvases — every member became a text-only slide, with no media, no
368
+ * locations and no warning. `within`-style Collections are now handled by the
369
+ * private `collectionToStorymapData` below, and anything else is rejected here.
370
+ */
371
+ export function isPresentation3Manifest(data: unknown): boolean {
372
+ const record = asRecord(data);
373
+ if (!record) return false;
374
+ if (record.type === "Collection") return false;
375
+ if (record.type === "Manifest") return true;
376
+ const context = record["@context"];
377
+ const candidates = Array.isArray(context) ? context : [context];
378
+ return candidates.some(
379
+ (entry) => typeof entry === "string" && entry.includes(PRESENTATION_3_CONTEXT),
380
+ );
381
+ }
382
+
383
+ /** True when `data` is a Presentation API 3.0 Collection. */
384
+ export function isPresentation3Collection(data: unknown): boolean {
385
+ const record = asRecord(data);
386
+ return record !== null && record.type === "Collection";
387
+ }
388
+
389
+ /**
390
+ * A Collection: reported and rejected, with nothing silently mangled.
391
+ *
392
+ * §2.2 offered two branches — flatten the member Manifests, or reject with an
393
+ * explicit error. Neither survives contact with the specification, and the
394
+ * choice is worth recording:
395
+ *
396
+ * - **Flattening is impossible here.** A Presentation 3 `Collection`'s
397
+ * `items` are `id` **references** to manifests in other documents, so
398
+ * flattening means fetching them, and this converter is synchronous and does
399
+ * no I/O.
400
+ * - **A Collection fixture could not be authored either.** The official IIIF
401
+ * validator rejects both shapes of member the obvious forms suggest: an
402
+ * embedded `Manifest` object ("not valid under any of the given schemas") and
403
+ * a bare id string (the same). So no `public/examples-iiif/` fixture can
404
+ * exercise this path, and shipping one that fails `npm run validate:iiif` is
405
+ * not an option.
406
+ *
407
+ * So the honest outcome is the one that removes a defect and promises no
408
+ * feature: previously a Collection was *accepted* and its members were fed to
409
+ * `canvasToSlide` as if they were Canvases, producing text-only slides with no
410
+ * media, no locations and no warning. Now it is named and skipped.
411
+ *
412
+ * A host that wants a multi-manifest tour composes it: fetch the members, call
413
+ * {@link manifestToStorymapData} on each, and concatenate the `slides`. That
414
+ * keeps the "no I/O on the load path" property §5.2's `seeAlso` reader is being
415
+ * held back for.
416
+ */
417
+ function collectionToStorymapData(collection: Record<string, unknown>): StorymapData {
418
+ const data: StorymapData = { slides: [] };
419
+ const title = flattenLanguageMap(collection.label);
420
+ if (title !== "") data.title = title;
421
+
422
+ const required = formatAttribution(readRequiredStatement(collection.requiredStatement));
423
+ if (required !== "") {
424
+ const iiif = (data.iiif as { url?: string; attribution?: string } | undefined) ?? {};
425
+ iiif.attribution = required;
426
+ if (iiif.url === undefined) iiif.url = "";
427
+ data.iiif = iiif;
428
+ }
429
+
430
+ const members = Array.isArray(collection.items) ? collection.items : [];
431
+ const memberIds = members
432
+ .map((member) => (typeof member === "string" ? member : asString(asRecord(member)?.id)))
433
+ .filter((id): id is string => typeof id === "string");
434
+ console.warn(
435
+ "StoryMapJS: this is a IIIF Collection with " +
436
+ `${memberIds.length} member manifest(s) [${memberIds.join(", ")}]. ` +
437
+ "A Presentation 3 Collection references its members from other documents, and " +
438
+ "the converter is synchronous, so it contributes no slides rather than " +
439
+ "mangling them. To build a multi-manifest tour, fetch the members and " +
440
+ "concatenate their manifestToStorymapData() slides yourself " +
441
+ "(see docs/plans/iiif-interop.md §2.2).",
442
+ );
443
+
444
+ return data;
445
+ }
446
+
447
+ /**
448
+ * Finds the manifest-level map configuration service (profile containing
449
+ * "mapconfig") and returns its properties, or null when absent.
450
+ */
451
+ /**
452
+ * The `info.json` URL of the image basemap, taken from the `ImageService3` a
453
+ * painting body carries (§2.4).
454
+ *
455
+ * `service[0].id` is the service base, so the description is that plus
456
+ * `/info.json`. A producer that put the description URL in `body.id` instead
457
+ * is handled by the fallback. `map_type: "iiif"` means every canvas paints the
458
+ * same basemap image, so the first one carrying a service is the basemap.
459
+ */
460
+ function readImageServiceUrl(manifest: Record<string, unknown>): string | null {
461
+ const canvases = Array.isArray(manifest.items) ? manifest.items : [];
462
+ for (const canvas of canvases) {
463
+ const record = asRecord(canvas);
464
+ if (!record) continue;
465
+ const pages = Array.isArray(record.items) ? record.items : [];
466
+ for (const page of pages) {
467
+ const pageRecord = asRecord(page);
468
+ if (!pageRecord) continue;
469
+ const annotations = Array.isArray(pageRecord.items) ? pageRecord.items : [];
470
+ for (const annotation of annotations) {
471
+ const annotationRecord = asRecord(annotation);
472
+ if (!annotationRecord) continue;
473
+ const bodies = Array.isArray(annotationRecord.body)
474
+ ? annotationRecord.body
475
+ : [annotationRecord.body];
476
+ for (const entry of bodies) {
477
+ const body = asRecord(entry);
478
+ if (!body) continue;
479
+ const services = Array.isArray(body.service) ? body.service : [];
480
+ for (const service of services) {
481
+ const serviceRecord = asRecord(service);
482
+ if (!serviceRecord) continue;
483
+ const id = asString(serviceRecord.id);
484
+ if (id === null) continue;
485
+ return id.endsWith("/info.json") ? id : `${id}/info.json`;
486
+ }
487
+ // A body that is itself the service description
488
+ const bodyId = asString(body.id);
489
+ if (bodyId !== null && bodyId.endsWith("/info.json")) return bodyId;
490
+ }
491
+ }
492
+ }
493
+ }
494
+ return null;
495
+ }
496
+
497
+ /**
498
+ * TileJSON 2.1 metadata from a manifest's map configuration service (§2.9).
499
+ *
500
+ * `tiles` is the only required member, and the value we need is the template,
501
+ * so anything without one is not a tile source. `minzoom`/`maxzoom` are
502
+ * clamped to sane numbers rather than rejected, because a service that
503
+ * advertises a maxzoom of 200 should not fail to load — the map caps it.
504
+ */
505
+ function readTilejson(config: Record<string, unknown>): StorymapTilejson | null {
506
+ const record = asRecord(config.tilejson);
507
+ if (!record) return null;
508
+ const tiles = Array.isArray(record.tiles)
509
+ ? asStringArray(record.tiles).filter((t) => t !== "")
510
+ : asString(record.tiles);
511
+ if (Array.isArray(tiles) ? tiles.length === 0 : tiles === null) return null;
512
+ const template = tiles as string | string[];
513
+
514
+ const out: StorymapTilejson = { tiles: template };
515
+ const minzoom = asNumber(record.minzoom);
516
+ if (minzoom !== null && minzoom >= 0) out.minzoom = minzoom;
517
+ const maxzoom = asNumber(record.maxzoom);
518
+ if (maxzoom !== null && maxzoom >= 0) out.maxzoom = maxzoom;
519
+ const bounds = readLonLatBox(record.bounds);
520
+ if (bounds !== null) out.bounds = bounds;
521
+ const scheme = asString(record.scheme);
522
+ if (scheme === "xyz" || scheme === "tms") out.scheme = scheme;
523
+ // TileJSON's `center` is [lon, lat, zoom]; the third member is the zoom
524
+ const center = Array.isArray(record.center) ? record.center : null;
525
+ if (center && center.length >= 2) {
526
+ const lon = asNumber(center[0]);
527
+ const lat = asNumber(center[1]);
528
+ if (lon !== null && lat !== null && Math.abs(lon) <= 180 && Math.abs(lat) <= 90) {
529
+ const zoom = center.length > 2 ? asNumber(center[2]) : null;
530
+ if (zoom !== null) out.center = [lon, lat, zoom];
531
+ else out.center = [lon, lat, 0];
532
+ }
533
+ }
534
+ return out;
535
+ }
536
+
537
+ function readMapConfig(manifest: Record<string, unknown>): Record<string, unknown> | null {
538
+ const services = Array.isArray(manifest.service) ? manifest.service : [manifest.service];
539
+ for (const service of services) {
540
+ const record = asRecord(service);
541
+ if (!record) continue;
542
+ const profile = asString(record.profile);
543
+ if (profile !== null && profile.includes(MAPCONFIG_PROFILE)) {
544
+ return record;
545
+ }
546
+ }
547
+ return null;
548
+ }
549
+
550
+ /**
551
+ * What a painting annotation tells us about a slide's media.
552
+ *
553
+ * This is the **shared body record** the annotation-driven stops in
554
+ * docs/plans/iiif-media-tours.md read: `type` is how a `Sound` body is told
555
+ * from an `Image` or a `TextualBody`. Anything else that needs a field from
556
+ * the body belongs on this record rather than in a second reader.
557
+ */
558
+ export interface PaintingBody {
559
+ url: string;
560
+ region: [number, number, number, number] | null;
561
+ /** Body class: `Image`, `Sound`, `Video`, `Text`, `Dataset`, … */
562
+ type: string | null;
563
+ format: string | null;
564
+ /** `body.label` — the interoperable caption. */
565
+ label: string | null;
566
+ /** `body.accessibilitySummary` — the interoperable alt text. */
567
+ accessibilitySummary: string | null;
568
+ /**
569
+ * `body.requiredStatement` (label and value), or `body.provider`.
570
+ * See {@link readRequiredStatement} on the single-object shape.
571
+ */
572
+ credit: string | null;
573
+ thumbnail: string | null;
574
+ /** `duration` in seconds, and an explicit `start`/`end` range. */
575
+ duration: number | null;
576
+ start: number | null;
577
+ end: number | null;
578
+ /**
579
+ * A WebVTT subtitle file, from a `TextualBody` body with
580
+ * `format: "text/vtt"` and an `id`. IIIF has no subtitle term, so this is
581
+ * the one standard shape we opportunistically accept; `media.subtitles`
582
+ * in storymap JSON is the documented route.
583
+ */
584
+ subtitles: string | null;
585
+ }
586
+
587
+ /**
588
+ * Credit from `body.requiredStatement`, or `body.provider`, which is where a
589
+ * manifest records who made the media. The statement's `label` is kept, like
590
+ * the manifest-level attribution (§2.1) — a body that says
591
+ * `{label: "Photographer", value: "Someone"}` should not reduce to a bare
592
+ * "Someone".
593
+ */
594
+ function readBodyCredit(body: unknown): string | null {
595
+ const record = asRecord(body);
596
+ if (!record) return null;
597
+ const statement = formatAttribution(readRequiredStatement(record.requiredStatement));
598
+ if (statement !== "") return statement;
599
+ const provider = asRecord(record.provider);
600
+ const providerLabel = provider === null ? "" : flattenLanguageMap(provider.label);
601
+ if (providerLabel !== "") return providerLabel;
602
+ return null;
603
+ }
604
+
605
+ /**
606
+ * Credit from the painting annotation's own `requiredStatement`, which is
607
+ * where P3 defines it and where our converter now writes it (§2.7). The
608
+ * `label` is kept, as everywhere else.
609
+ */
610
+ function readAnnotationCredit(annotation: Record<string, unknown>): string | null {
611
+ if (annotation.requiredStatement === undefined) return null;
612
+ const statement = formatAttribution(readRequiredStatement(annotation.requiredStatement));
613
+ return statement === "" ? null : statement;
614
+ }
615
+
616
+ /** A `TextualBody` carrying WebVTT is the closest standard spelling of a
617
+ * subtitle track, so accept it alongside `media.subtitles`. */
618
+ function readBodySubtitles(body: unknown): string | null {
619
+ const entries = Array.isArray(body) ? body : [body];
620
+ for (const entry of entries) {
621
+ const record = asRecord(entry);
622
+ if (!record) continue;
623
+ const format = asString(record.format);
624
+ const id = asString(record.id);
625
+ if (format !== null && /^text\/vtt$/i.test(format) && id !== null) return id;
626
+ }
627
+ return null;
628
+ }
629
+
630
+ /** Picks the first body of a `body` array that yields a URL. */
631
+ function readFirstTypedBody(body: unknown): Record<string, unknown> | null {
632
+ if (Array.isArray(body)) {
633
+ for (const entry of body) {
634
+ const found = readFirstTypedBody(entry);
635
+ if (found !== null) return found;
636
+ }
637
+ return null;
638
+ }
639
+ const record = asRecord(body);
640
+ if (!record) return null;
641
+ const url = asString(record.id) ?? asString(record.value);
642
+ return url === null ? null : record;
643
+ }
644
+
645
+ /**
646
+ * Reads the slide media URL from the canvas's painting annotation body:
647
+ * typed bodies contribute their `id`, TextualBody (HTML) content its `value`
648
+ * - the legacy format stores both in `media.url`. Also returns the region of
649
+ * a selector on the annotation target, and the body fields listed on
650
+ * {@link PaintingBody}.
651
+ */
652
+ function readPainting(
653
+ canvas: Record<string, unknown>,
654
+ width: number | null = null,
655
+ height: number | null = null,
656
+ ): PaintingBody | null {
657
+ const annotationPages = Array.isArray(canvas.items) ? canvas.items : [];
658
+ for (const page of annotationPages) {
659
+ const pageRecord = asRecord(page);
660
+ if (!pageRecord) continue;
661
+ const annotations = Array.isArray(pageRecord.items) ? pageRecord.items : [];
662
+ for (const annotation of annotations) {
663
+ const annotationRecord = asRecord(annotation);
664
+ if (!annotationRecord) continue;
665
+ const motivation = asString(annotationRecord.motivation);
666
+ if (motivation !== null && motivation !== "painting") continue;
667
+ const body = readFirstTypedBody(annotationRecord.body);
668
+ if (body === null) continue;
669
+ return {
670
+ url: asString(body.id) ?? asString(body.value) ?? "",
671
+ region: readSelector(annotationRecord.target, width, height).region,
672
+ type: asString(body.type),
673
+ format: asString(body.format),
674
+ // P3 puts label / requiredStatement / accessibilitySummary on
675
+ // the Annotation; some producers put them on the body, so the
676
+ // body is still consulted as a fallback (§2.7)
677
+ label:
678
+ flattenLanguageMap(annotationRecord.label) ||
679
+ flattenLanguageMap(body.label) ||
680
+ null,
681
+ accessibilitySummary:
682
+ flattenLanguageMap(annotationRecord.accessibilitySummary) ||
683
+ flattenLanguageMap(body.accessibilitySummary) ||
684
+ null,
685
+ credit: readAnnotationCredit(annotationRecord) ?? readBodyCredit(body),
686
+ thumbnail: readThumbnailId(body.thumbnail),
687
+ duration: asNumber(body.duration),
688
+ start: asNumber(body.start),
689
+ end: asNumber(body.end),
690
+ subtitles: readBodySubtitles(annotationRecord.body),
691
+ };
692
+ }
693
+ }
694
+ return null;
695
+ }
696
+
697
+ /**
698
+ * Reads the slide location from a navPlace FeatureCollection: the first
699
+ * Feature's Point geometry becomes `{lat, lon}` and the Feature properties
700
+ * carry the marker data.
701
+ */
702
+ function readLocation(navPlace: unknown): StorymapSlideLocation | null {
703
+ const collection = asRecord(navPlace);
704
+ if (!collection) return null;
705
+ const features = Array.isArray(collection.features) ? collection.features : [];
706
+ for (const feature of features) {
707
+ const location = readFeatureLocation(feature);
708
+ if (location !== null) return location;
709
+ }
710
+ return null;
711
+ }
712
+
713
+ function readFeatureLocation(feature: unknown): StorymapSlideLocation | null {
714
+ const featureRecord = asRecord(feature);
715
+ if (!featureRecord) return null;
716
+ const geometry = asRecord(featureRecord.geometry);
717
+ if (!geometry || asString(geometry.type) !== "Point") return null;
718
+ const coordinates = Array.isArray(geometry.coordinates) ? geometry.coordinates : [];
719
+ const lon = asNumber(coordinates[0]);
720
+ const lat = asNumber(coordinates[1]);
721
+ if (lon === null || lat === null) return null;
722
+ const location: StorymapSlideLocation = { lat, lon };
723
+ const properties = asRecord(featureRecord.properties);
724
+ if (properties) {
725
+ const locationProps = location as Record<string, unknown>;
726
+ for (const key of LOCATION_PROPERTIES) {
727
+ const value = properties[key];
728
+ if (value === undefined || value === null || value === "") continue;
729
+ locationProps[key] = value;
730
+ }
731
+ }
732
+ return location;
733
+ }
734
+
735
+ /**
736
+ * Reads a lon/lat bounding box from a navPlace FeatureCollection holding a
737
+ * Polygon or MultiPolygon. The navPlace extension lists "supplying a single
738
+ * geographic bounding box" as a use case, which is the interoperable way to
739
+ * state the extent of a story — the legacy format has no such field, so it
740
+ * becomes `map_bbox` (the map is constrained to the box). Returns null when
741
+ * the collection has no polygon.
742
+ */
743
+ function readNavPlaceBbox(navPlace: unknown): [number, number, number, number] | null {
744
+ const collection = asRecord(navPlace);
745
+ if (!collection) return null;
746
+ const features = Array.isArray(collection.features) ? collection.features : [];
747
+ for (const feature of features) {
748
+ const featureRecord = asRecord(feature);
749
+ if (!featureRecord) continue;
750
+ const geometry = asRecord(featureRecord.geometry);
751
+ const type = geometry ? asString(geometry.type) : null;
752
+ if (type !== "Polygon" && type !== "MultiPolygon") continue;
753
+ const positions: number[][] = [];
754
+ collectPositions(geometry?.coordinates, positions, 0);
755
+ const lons = positions.map((p) => p[0]).filter((n) => n !== undefined);
756
+ const lats = positions.map((p) => p[1]).filter((n) => n !== undefined);
757
+ if (lons.length === 0 || lats.length === 0) continue;
758
+ return [Math.min(...lons), Math.min(...lats), Math.max(...lons), Math.max(...lats)];
759
+ }
760
+ return null;
761
+ }
762
+
763
+ /** Flattens arbitrarily nested GeoJSON coordinate arrays into positions. */
764
+ function collectPositions(value: unknown, out: number[][], depth: number): void {
765
+ if (depth > 4 || !Array.isArray(value)) return;
766
+ if (value.length >= 2 && typeof value[0] === "number" && typeof value[1] === "number") {
767
+ const lon = asNumber(value[0]);
768
+ const lat = asNumber(value[1]);
769
+ if (lon !== null && lat !== null) out.push([lon, lat]);
770
+ return;
771
+ }
772
+ for (const entry of value) {
773
+ collectPositions(entry, out, depth + 1);
774
+ }
775
+ }
776
+
777
+ /**
778
+ * The first id of a P3 `thumbnail`, which is a *list* of content resources
779
+ * (§3.1). A bare resource is accepted too, since that is what a one-element
780
+ * list collapses to once it has been through a few tools.
781
+ */
782
+ /**
783
+ * A manifest's `structures` as a slide order and a group per canvas (§3.5).
784
+ *
785
+ * Two jobs, both of which the ecosystem expects — Annona's range storyboard
786
+ * and TimelineJS's groups:
787
+ *
788
+ * 1. a Range with a `label` and **no `start`** is a *group* for its member
789
+ * canvases, which is the first meaning the inert `slide.group` field ever
790
+ * had. A Range with a `start` is a time segment of one canvas, not a group,
791
+ * so it is skipped.
792
+ * 2. the order canvases appear in, which is the curated sequence. That is the
793
+ * whole point of a storyboard: it may run the canvases in an order the
794
+ * document does not.
795
+ *
796
+ * Nested Ranges are chapters inside the group, so the outermost labelled Range
797
+ * is the group a slide belongs to — `slide.group` is one string, and a
798
+ * "Part 1 / Chapter 2" pair is more use to a host as the part.
799
+ */
800
+ function readStructures(value: unknown): {
801
+ order: string[];
802
+ groups: Map<string, string>;
803
+ } {
804
+ const order: string[] = [];
805
+ const groups = new Map<string, string>();
806
+ const seen = new Set<string>();
807
+
808
+ const walk = (entries: unknown, group: string | null): void => {
809
+ for (const entry of Array.isArray(entries) ? entries : [entries]) {
810
+ // P3 lets a Range's `items` be a list of ids, of resource objects,
811
+ // or a mix; only the ids matter here
812
+ const bare = asString(entry);
813
+ if (bare !== null) {
814
+ if (!seen.has(bare)) {
815
+ seen.add(bare);
816
+ order.push(bare);
817
+ if (group !== null) groups.set(bare, group);
818
+ }
819
+ continue;
820
+ }
821
+ const record = asRecord(entry);
822
+ if (!record) continue;
823
+ const isRange = asString(record.type) === "Range";
824
+ if (!isRange) {
825
+ // a canvas or annotation resource reference in items
826
+ const id = asString(record.id);
827
+ if (id !== null && !seen.has(id)) {
828
+ seen.add(id);
829
+ order.push(id);
830
+ if (group !== null) groups.set(id, group);
831
+ }
832
+ continue;
833
+ }
834
+ const label = flattenLanguageMap(record.label);
835
+ const isGroup = label !== "" && record.start === undefined;
836
+ const nextGroup = isGroup ? (group ?? label) : group;
837
+ walk(record.items, nextGroup);
838
+ }
839
+ };
840
+
841
+ walk(value, null);
842
+ return { order, groups };
843
+ }
844
+
845
+ /**
846
+ * An Agent as a credit fragment: its label, or its id when it has none. An
847
+ * empty `label` produces a bare fragment, which is what a homepage-only Agent
848
+ * wants (§3.2).
849
+ */
850
+ function agentCredit(value: unknown, label: string): string {
851
+ const entries = Array.isArray(value) ? value : [value];
852
+ const parts: string[] = [];
853
+ for (const entry of entries) {
854
+ const record = asRecord(entry);
855
+ if (!record) continue;
856
+ const name = flattenLanguageMap(record.label) || asString(record.id) || "";
857
+ if (name === "") continue;
858
+ parts.push(label === "" ? name : `${label}: ${name}`);
859
+ }
860
+ return parts.join(", ");
861
+ }
862
+
863
+ /**
864
+ * P3 `metadata` as plain `{label, value}` pairs (§3.2). Entries without both a
865
+ * label and a value are dropped, since a pair with neither says nothing.
866
+ */
867
+ function readMetadata(value: unknown): { label: string; value: string }[] {
868
+ const entries = Array.isArray(value) ? value : [value];
869
+ const out: { label: string; value: string }[] = [];
870
+ for (const entry of entries) {
871
+ const record = asRecord(entry);
872
+ if (!record) continue;
873
+ const label = flattenLanguageMap(record.label);
874
+ const text = flattenLanguageMap(record.value);
875
+ if (label === "" && text === "") continue;
876
+ out.push({ label, value: text });
877
+ }
878
+ return out;
879
+ }
880
+
881
+ function readThumbnailId(value: unknown): string | null {
882
+ const entries = Array.isArray(value) ? value : [value];
883
+ for (const entry of entries) {
884
+ const id = asString(asRecord(entry)?.id);
885
+ if (id !== null) return id;
886
+ }
887
+ return null;
888
+ }
889
+
890
+ /**
891
+ * A canvas's P3 `background` annotation into a slide background (§2.6).
892
+ *
893
+ * The annotation is a painting annotation whose body is the image and/or the
894
+ * colour; an image body contributes `url` and a `Color` body contributes
895
+ * `color`.
896
+ *
897
+ * The result is always the object form. The bare-string form of
898
+ * `slide.background` means a *colour* — that is what the converter has always
899
+ * done with it — so returning a url as a bare string would read back as a
900
+ * colour on the next trip.
901
+ *
902
+ * `opacity` is not read. IIIF has no vocabulary for a background opacity and
903
+ * the viewer never rendered one, so nothing is lost by leaving it out.
904
+ */
905
+ function readBackground(annotation: unknown): StorymapSlideBackground | string | null {
906
+ const record = asRecord(annotation);
907
+ if (!record) return null;
908
+ const bodies = Array.isArray(record.body) ? record.body : [record.body];
909
+ let url: string | null = null;
910
+ let color: string | null = null;
911
+ for (const entry of bodies) {
912
+ const body = asRecord(entry);
913
+ if (!body) continue;
914
+ if (url === null && isMediaBody(body)) {
915
+ url = asString(body.id);
916
+ }
917
+ if (color === null) {
918
+ const type = asString(body.type);
919
+ const value = asString(body.value);
920
+ if (value !== null && (type === "Color" || type === null)) color = value;
921
+ }
922
+ }
923
+ const background: StorymapSlideBackground = {};
924
+ if (url !== null) background.url = url;
925
+ if (color !== null) background.color = color;
926
+ return Object.keys(background).length > 0 ? background : null;
927
+ }
928
+
929
+ function canvasToSlide(
930
+ canvas: unknown,
931
+ manifestFeature: unknown,
932
+ preferredLanguage: string | null = null,
933
+ ): StorymapSlide | null {
934
+ const record = asRecord(canvas);
935
+ if (!record) return null;
936
+
937
+ const slide: StorymapSlide = {};
938
+
939
+ // text: Canvas label → headline, Canvas summary → body text, both in the
940
+ // language the viewer is configured for (§3.4)
941
+ const label = pickLanguageMap(record.label, preferredLanguage);
942
+ const summary = pickLanguageMap(record.summary, preferredLanguage);
943
+ const headline = label.value;
944
+ const text = summary.value;
945
+ if (headline !== "" || text !== "") {
946
+ slide.text = {};
947
+ if (headline !== "") slide.text.headline = headline;
948
+ if (text !== "") slide.text.text = text;
949
+ }
950
+ // Which language this slide's text came from, so a host can offer a
951
+ // language switch. `none` is language-neutral, so it is not reported.
952
+ const language = label.language ?? summary.language;
953
+ if (language !== null) slide.language = language;
954
+
955
+ // media: the painting annotation's body, with its label /
956
+ // requiredStatement / accessibilitySummary as caption, credit and alt
957
+ // text (§2.7 — the `mediaCaption`/`mediaCredit`/`mediaAlt` canvas terms are
958
+ // gone). srcset/sizes remain terms: IIIF has no vocabulary for either.
959
+ const painting = readPainting(record, asNumber(record.width), asNumber(record.height));
960
+ const caption = painting?.label ?? null;
961
+ const credit = painting?.credit ?? null;
962
+ const alt = painting?.accessibilitySummary ?? null;
963
+ const srcset = asString(readTerm(record, "mediaSrcset"));
964
+ const sizes = asString(readTerm(record, "mediaSizes"));
965
+ // P3 allows the thumbnail on the body or on the canvas, and real manifests
966
+ // use both; the body's wins because it describes the painted resource
967
+ // specifically
968
+ const thumbnail = painting?.thumbnail ?? readThumbnailId(record.thumbnail);
969
+ if (
970
+ painting !== null ||
971
+ caption !== null ||
972
+ credit !== null ||
973
+ alt !== null ||
974
+ srcset !== null ||
975
+ sizes !== null ||
976
+ thumbnail !== null
977
+ ) {
978
+ const media: StorymapSlideMedia = {};
979
+ if (painting !== null) media.url = painting.url;
980
+ if (caption !== null) media.caption = caption;
981
+ if (credit !== null) media.credit = credit;
982
+ if (alt !== null) media.alt = alt;
983
+ if (srcset !== null) media.srcset = srcset;
984
+ if (sizes !== null) media.sizes = sizes;
985
+ if (thumbnail !== null) media.thumb = thumbnail;
986
+ if (painting?.subtitles != null) media.subtitles = painting.subtitles;
987
+ slide.media = media;
988
+ }
989
+
990
+ // location: canvas navPlace, falling back to a manifest-level navPlace
991
+ // aggregated in items order (both are allowed by the proposal)
992
+ const location = readLocation(record.navPlace) ?? readFeatureLocation(manifestFeature);
993
+ if (location !== null) slide.location = location;
994
+ // IIIF xywh region (StrollView-style image stops): [x, y, w, h] pixels.
995
+ // The extension term wins over the interoperable spelling — an Image API
996
+ // Selector on the painting annotation target
997
+ // region: the painting annotation's target selector, the only source since
998
+ // the storymap:imageRegion term went (§2.8)
999
+ const region = painting?.region ?? null;
1000
+ if (region !== null) {
1001
+ slide.location = { ...(slide.location ?? {}), region };
1002
+ }
1003
+
1004
+ // StoryMap extension terms
1005
+ const slideType = asString(record[STORYMAP_PREFIX + "type"]);
1006
+ if (slideType !== null) slide.type = slideType;
1007
+ // P3's `navDate`, a string. A language map is read for leniency, since
1008
+ // other producers do emit one, but the official validator rejects it and
1009
+ // we never write one (§2.5).
1010
+ const navDate = record.navDate;
1011
+ const dateString = asString(navDate) ?? flattenLanguageMap(navDate);
1012
+ const dateRecord = asRecord(navDate);
1013
+ if (dateString !== null && dateString !== "") slide.date = dateString;
1014
+ else if (dateRecord !== null) slide.date = dateRecord;
1015
+ // background: the canvas's standard `background` painting annotation. The
1016
+ // storymap:background term is gone (§2.6).
1017
+ const background = readBackground(record.background);
1018
+ if (background !== null) slide.background = background;
1019
+
1020
+ return slide;
1021
+ }
1022
+
1023
+ /**
1024
+ * Motivations that turn an annotation into a tour stop. `painting` is the
1025
+ * canvas's image and is handled separately; everything here is commentary
1026
+ * about it, which is what a guided tour is made of.
1027
+ */
1028
+ const STOP_MOTIVATIONS = new Set(["commenting", "tagging", "classifying", "describing"]);
1029
+
1030
+ /** Escapes the five characters that would otherwise be markup, so a
1031
+ * `text/plain` annotation body cannot inject HTML. The renderer sanitizes
1032
+ * slide text as well; this keeps the stored value honest on its own. */
1033
+ function escapeText(value: string): string {
1034
+ return value
1035
+ .replace(/&/g, "&amp;")
1036
+ .replace(/</g, "&lt;")
1037
+ .replace(/>/g, "&gt;")
1038
+ .replace(/"/g, "&quot;")
1039
+ .replace(/'/g, "&#39;");
1040
+ }
1041
+
1042
+ /**
1043
+ * A `text/plain` body becomes one paragraph per blank-line-separated block,
1044
+ * escaped. A `text/html` body is passed through as markup — the slide text
1045
+ * pipeline (`sanitizeSlideText`, via `media/types/Text.ts`) sanitizes whatever
1046
+ * it is given, which is the same path a storymap JSON `text.text` takes.
1047
+ */
1048
+ function annotationTextToSlideText(format: string | null, value: string): string | null {
1049
+ if (value.trim() === "") return null;
1050
+ const is_html = format !== null && /^text\/html/i.test(format);
1051
+ if (is_html) return value;
1052
+ const blocks = value
1053
+ .split(/\n\s*\n/)
1054
+ .map((block) => block.trim())
1055
+ .filter((block) => block !== "");
1056
+ if (blocks.length === 0) return null;
1057
+ return blocks.map((block) => `<p>${escapeText(block)}</p>`).join("");
1058
+ }
1059
+
1060
+ /** True for a body that is a media resource rather than text. */
1061
+ function isMediaBody(body: Record<string, unknown>): boolean {
1062
+ const id = asString(body.id);
1063
+ if (id === null) return false;
1064
+ const type = asString(body.type);
1065
+ if (type !== null) {
1066
+ return ["Image", "Sound", "Video", "Dataset", "Model"].includes(type);
1067
+ }
1068
+ // An untyped body with an id is a media resource by P3 convention.
1069
+ return true;
1070
+ }
1071
+
1072
+ /**
1073
+ * One annotation → one tour stop, or null when it is not a stop.
1074
+ *
1075
+ * A stop needs somewhere to go: the target has to resolve to a region, either
1076
+ * from an `xywh` fragment / Image API Selector or from a `PointSelector`
1077
+ * (synthesized into a square by `readSelector`, which needs the canvas size).
1078
+ * An annotation targeting the whole canvas is not a stop — the canvas slide
1079
+ * already covers it.
1080
+ *
1081
+ * The body may be one resource or an array. A `TextualBody` contributes the
1082
+ * slide text, a typed media body (typically a `Sound`) contributes
1083
+ * `media.url`, and both contribute caption/credit/alt.
1084
+ */
1085
+ function readAnnotationStop(
1086
+ annotation: unknown,
1087
+ width: number | null,
1088
+ height: number | null,
1089
+ ): StorymapSlide | null {
1090
+ const record = asRecord(annotation);
1091
+ if (!record) return null;
1092
+
1093
+ const motivations = Array.isArray(record.motivation) ? record.motivation : [record.motivation];
1094
+ const is_stop = motivations.some((m) => {
1095
+ const value = asString(m);
1096
+ return value !== null && STOP_MOTIVATIONS.has(value);
1097
+ });
1098
+ if (!is_stop) return null;
1099
+
1100
+ const selector = readSelector(record.target, width, height);
1101
+ if (selector.region === null) return null;
1102
+
1103
+ const bodies = Array.isArray(record.body) ? record.body : [record.body];
1104
+ let text: string | null = null;
1105
+ let media: StorymapSlideMedia | null = null;
1106
+
1107
+ for (const entry of bodies) {
1108
+ const body = asRecord(entry);
1109
+ if (!body) continue;
1110
+ if (isMediaBody(body)) {
1111
+ if (media === null) {
1112
+ const url = asString(body.id);
1113
+ if (url !== null) {
1114
+ // Same precedence as readPainting: the annotation's own
1115
+ // P3 properties first, the body's as a fallback (§2.7)
1116
+ const caption =
1117
+ flattenLanguageMap(record.label) || flattenLanguageMap(body.label);
1118
+ const credit = readAnnotationCredit(record) ?? readBodyCredit(body);
1119
+ const alt =
1120
+ flattenLanguageMap(record.accessibilitySummary) ||
1121
+ flattenLanguageMap(body.accessibilitySummary);
1122
+ media = {
1123
+ url,
1124
+ ...(caption !== "" ? { caption } : {}),
1125
+ ...(credit !== null ? { credit } : {}),
1126
+ ...(alt !== "" ? { alt } : {}),
1127
+ ...(readThumbnailId(body.thumbnail) !== null
1128
+ ? { thumb: readThumbnailId(body.thumbnail) as string }
1129
+ : {}),
1130
+ };
1131
+ }
1132
+ }
1133
+ continue;
1134
+ }
1135
+ if (text === null) {
1136
+ const value = asString(body.value);
1137
+ if (value !== null) {
1138
+ text = annotationTextToSlideText(asString(body.format), value);
1139
+ }
1140
+ }
1141
+ }
1142
+
1143
+ // An annotation carrying nothing but a target is still a usable stop: it
1144
+ // focuses the image on a region.
1145
+ if (text === null && media === null) return null;
1146
+
1147
+ // The annotation's own label is the stop's headline, which is what the
1148
+ // slider and the marker label show. A body `label` is the media caption and
1149
+ // is handled above.
1150
+ const headline = flattenLanguageMap(record.label);
1151
+
1152
+ const slide: StorymapSlide = { location: { region: selector.region } };
1153
+ if (text !== null || headline !== "") {
1154
+ slide.text = {
1155
+ ...(text !== null ? { text } : {}),
1156
+ ...(headline !== "" ? { headline } : {}),
1157
+ };
1158
+ }
1159
+ if (media !== null) slide.media = media;
1160
+ return slide;
1161
+ }
1162
+
1163
+ /**
1164
+ * Annotation-driven tour stops for one canvas, in annotation page order.
1165
+ * Appended after the canvas's own slide, so the story reads "here is the
1166
+ * whole picture, then here is each detail".
1167
+ */
1168
+ export function readCommentingAnnotations(canvas: unknown): StorymapSlide[] {
1169
+ const record = asRecord(canvas);
1170
+ if (!record) return [];
1171
+ const width = asNumber(record.width);
1172
+ const height = asNumber(record.height);
1173
+
1174
+ const stops: StorymapSlide[] = [];
1175
+ const annotationPages = Array.isArray(record.items) ? record.items : [];
1176
+ for (const page of annotationPages) {
1177
+ const annotations = asRecord(page);
1178
+ if (!annotations || !Array.isArray(annotations.items)) continue;
1179
+ for (const annotation of annotations.items) {
1180
+ const stop = readAnnotationStop(annotation, width, height);
1181
+ if (stop !== null) stops.push(stop);
1182
+ }
1183
+ }
1184
+ return stops;
1185
+ }
1186
+
1187
+ /** An external resource a manifest points at with `seeAlso`. */
1188
+ export type SeeAlsoReference = {
1189
+ id: string;
1190
+ /** The IIIF class, which is what tells us how to read it. */
1191
+ type: string;
1192
+ };
1193
+
1194
+ /**
1195
+ * The `seeAlso` targets of a manifest, canvas or range.
1196
+ *
1197
+ * The class is recorded because it is what decides how a target is read: an
1198
+ * `AnnotationCollection` or `AnnotationPage` is a list of annotations to merge,
1199
+ * while a `SearchService1` is an endpoint to hand to a host rather than
1200
+ * annotations to load. An untyped entry is reported as `unknown` rather than
1201
+ * guessed at, because fetching a URI on the strength of a guess is how a viewer
1202
+ * ends up dereferencing something it should not have.
1203
+ */
1204
+ export function collectSeeAlso(value: unknown): SeeAlsoReference[] {
1205
+ const record = asRecord(value);
1206
+ if (!record) return [];
1207
+ const entries = Array.isArray(record.seeAlso) ? record.seeAlso : [record.seeAlso];
1208
+ const out: SeeAlsoReference[] = [];
1209
+ for (const entry of entries) {
1210
+ const linked = asRecord(entry);
1211
+ if (!linked) continue;
1212
+ const id = asString(linked.id) ?? asString(linked);
1213
+ if (id === null) continue;
1214
+ out.push({ id, type: asString(linked.type) ?? "unknown" });
1215
+ }
1216
+ return out;
1217
+ }
1218
+
1219
+ /** Every `seeAlso` a manifest points at: its own, plus one per canvas. */
1220
+ export function collectManifestSeeAlso(manifest: unknown): SeeAlsoReference[] {
1221
+ const record = asRecord(manifest);
1222
+ if (!record) return [];
1223
+ const seen = new Set<string>();
1224
+ const out: SeeAlsoReference[] = [];
1225
+ const add = (reference: SeeAlsoReference) => {
1226
+ if (seen.has(reference.id)) return;
1227
+ seen.add(reference.id);
1228
+ out.push(reference);
1229
+ };
1230
+ for (const reference of collectSeeAlso(record)) add(reference);
1231
+ const canvases = Array.isArray(record.items) ? record.items : [];
1232
+ for (const canvas of canvases) {
1233
+ for (const reference of collectSeeAlso(canvas)) add(reference);
1234
+ }
1235
+ return out;
1236
+ }
1237
+
1238
+ /** The canvas an annotation targets, with a `#xywh=` fragment stripped. */
1239
+ function canvasIdOfTarget(target: unknown): string | null {
1240
+ const text = asString(target);
1241
+ if (text !== null) {
1242
+ const at = text.indexOf("#");
1243
+ return at === -1 ? text : text.slice(0, at);
1244
+ }
1245
+ const record = asRecord(target);
1246
+ if (!record) return null;
1247
+ if (record.source !== undefined) {
1248
+ const source = asString(record.source) ?? asString(asRecord(record.source)?.id);
1249
+ return source;
1250
+ }
1251
+ return asString(record.id);
1252
+ }
1253
+
1254
+ function pushAnnotation(annotation: unknown, byCanvas: Map<string, unknown[]>): void {
1255
+ const record = asRecord(annotation);
1256
+ if (!record) return;
1257
+ const canvasId = canvasIdOfTarget(record.target);
1258
+ if (canvasId === null) return;
1259
+ const list = byCanvas.get(canvasId);
1260
+ if (list === undefined) byCanvas.set(canvasId, [annotation]);
1261
+ else list.push(annotation);
1262
+ }
1263
+
1264
+ function pushPageItems(page: Record<string, unknown>, byCanvas: Map<string, unknown[]>): void {
1265
+ const items = Array.isArray(page.items) ? page.items : [];
1266
+ for (const annotation of items) {
1267
+ pushAnnotation(annotation, byCanvas);
1268
+ }
1269
+ }
1270
+
1271
+ /**
1272
+ * The annotations of an external page, indexed by canvas id, plus the
1273
+ * annotation-page URLs it refers to.
1274
+ *
1275
+ * One `AnnotationCollection` can carry annotations for many canvases, and a
1276
+ * canvas's annotations may be split across several referenced pages, so a
1277
+ * collection, a single page, and a bare list of annotations are all accepted.
1278
+ */
1279
+ function indexExternalAnnotations(document: unknown): {
1280
+ byCanvas: Map<string, unknown[]>;
1281
+ pages: string[];
1282
+ } {
1283
+ const byCanvas = new Map<string, unknown[]>();
1284
+ const pages: string[] = [];
1285
+ const record = asRecord(document);
1286
+ if (!record) return { byCanvas, pages };
1287
+
1288
+ const type = asString(record.type);
1289
+ if (type === "AnnotationPage") {
1290
+ pushPageItems(record, byCanvas);
1291
+ const id = asString(record.id);
1292
+ if (id !== null) pages.push(id);
1293
+ return { byCanvas, pages };
1294
+ }
1295
+ if (type === "AnnotationCollection") {
1296
+ const items = Array.isArray(record.items) ? record.items : [];
1297
+ for (const item of items) {
1298
+ const entry = asRecord(item);
1299
+ if (!entry) continue;
1300
+ // A referenced page rather than an embedded annotation. The type
1301
+ // is what tells them apart: an `Annotation` also has an `id` and
1302
+ // no `items`, so keying on the absence of `items` alone sent every
1303
+ // annotation back out to be fetched as a page of its own.
1304
+ const entryType = asString(entry.type);
1305
+ const isReference =
1306
+ entry.items === undefined &&
1307
+ (entryType === "AnnotationPage" || entryType === "AnnotationCollection") &&
1308
+ asString(entry.id) !== null;
1309
+ if (isReference) {
1310
+ pages.push(asString(entry.id) as string);
1311
+ continue;
1312
+ }
1313
+ // A collection's item is either an embedded page (which carries its
1314
+ // own `items`) or a bare annotation, and a bare annotation has no
1315
+ // `items` of its own — reading it as a page-shaped object silently
1316
+ // dropped every annotation in the collection.
1317
+ if (entry.items !== undefined) pushPageItems(entry, byCanvas);
1318
+ else pushAnnotation(entry, byCanvas);
1319
+ }
1320
+ return { byCanvas, pages };
1321
+ }
1322
+ return { byCanvas, pages };
1323
+ }
1324
+
1325
+ export type ExternalAnnotations = {
1326
+ /** Tour stops, keyed by the canvas id they annotate. */
1327
+ stops: Map<string, StorymapSlide[]>;
1328
+ /** A `SearchService1` seen along the way, for a host to query. */
1329
+ searchService: string | null;
1330
+ /** Annotation-page URLs that could not be read, for a console report. */
1331
+ failed: string[];
1332
+ };
1333
+
1334
+ /**
1335
+ * A per-(id, type) cache of fetched annotation documents.
1336
+ *
1337
+ * Keyed on both, because the same URI can be reached as a collection and as a
1338
+ * page and the two are read differently. The *promise* is cached rather than
1339
+ * the result, so two callers racing the same page share one request.
1340
+ */
1341
+ const annotationCache = new Map<string, Promise<unknown>>();
1342
+
1343
+ /** Forget every cached annotation document. Exported for tests. */
1344
+ export function clearSeeAlsoCache(): void {
1345
+ annotationCache.clear();
1346
+ }
1347
+
1348
+ /**
1349
+ * Read the annotation documents a manifest points at with `seeAlso`.
1350
+ *
1351
+ * Asynchronous by design, and deliberately not called from
1352
+ * `manifestToStorymapData`: a manifest can point at anything, a viewer should
1353
+ * not block its first paint on a third party, and a host needs to decide whether
1354
+ * it wants the extra round trips at all. One level is followed — a referenced
1355
+ * `AnnotationPage` inside a collection is fetched, but a `seeAlso` *inside* that
1356
+ * page is not — which is what keeps a cycle from becoming an infinite walk.
1357
+ *
1358
+ * A `SearchService1` is recorded and not followed: it is an endpoint for a host
1359
+ * to query, not a list of annotations to load.
1360
+ */
1361
+ export async function loadSeeAlso(
1362
+ manifest: unknown,
1363
+ options: { fetchImpl?: typeof fetch } = {},
1364
+ ): Promise<ExternalAnnotations> {
1365
+ const result: ExternalAnnotations = { stops: new Map(), searchService: null, failed: [] };
1366
+ const record = asRecord(manifest);
1367
+ if (!record) return result;
1368
+
1369
+ const impl = options.fetchImpl ?? fetch;
1370
+ const canvases = Array.isArray(record.items) ? record.items : [];
1371
+ const wanted = collectManifestSeeAlso(record);
1372
+
1373
+ const stopCollector = async (annotations: unknown[], canvasId: string): Promise<void> => {
1374
+ const canvas = canvasStub(canvasId, canvases);
1375
+ if (canvas === null) return;
1376
+ const stops = readCommentingAnnotations({
1377
+ ...(asRecord(canvas) as Record<string, unknown>),
1378
+ items: [{ type: "AnnotationPage", items: annotations }],
1379
+ });
1380
+ if (stops.length === 0) return;
1381
+ const existing = result.stops.get(canvasId);
1382
+ if (existing === undefined) result.stops.set(canvasId, stops);
1383
+ else existing.push(...stops);
1384
+ };
1385
+
1386
+ await Promise.all(
1387
+ wanted.map(async (reference) => {
1388
+ if (reference.type === "SearchService1") {
1389
+ result.searchService = reference.id;
1390
+ return;
1391
+ }
1392
+ if (
1393
+ reference.type !== "AnnotationCollection" &&
1394
+ reference.type !== "AnnotationPage" &&
1395
+ reference.type !== "unknown"
1396
+ ) {
1397
+ return;
1398
+ }
1399
+ const loaded = await loadDocument(reference, impl, result);
1400
+ if (loaded === null) return;
1401
+ for (const [canvasId, annotations] of loaded.byCanvas) {
1402
+ await stopCollector(annotations, canvasId);
1403
+ }
1404
+ for (const pageId of loaded.pages) {
1405
+ const page = await loadDocument(
1406
+ { id: pageId, type: "AnnotationPage" },
1407
+ impl,
1408
+ result,
1409
+ );
1410
+ if (page === null) continue;
1411
+ for (const [canvasId, annotations] of page.byCanvas) {
1412
+ await stopCollector(annotations, canvasId);
1413
+ }
1414
+ }
1415
+ }),
1416
+ );
1417
+
1418
+ return result;
1419
+ }
1420
+
1421
+ /**
1422
+ * The canvas object an external annotation names, or null when the manifest
1423
+ * does not have that canvas.
1424
+ *
1425
+ * Null rather than a synthetic stub on purpose: the plan merges external
1426
+ * annotations "for the canvases we render", so an annotation aimed at a canvas
1427
+ * this story does not carry is not a tour stop. A stub would also invent a
1428
+ * canvas with no size, leaving a region with nothing to be relative to.
1429
+ */
1430
+ function canvasStub(canvasId: string, manifestCanvases: unknown[]): unknown {
1431
+ for (const canvas of manifestCanvases) {
1432
+ const record = asRecord(canvas);
1433
+ if (record !== null && asString(record.id) === canvasId) return record;
1434
+ }
1435
+ return null;
1436
+ }
1437
+
1438
+ async function loadDocument(
1439
+ reference: SeeAlsoReference,
1440
+ impl: typeof fetch,
1441
+ result: ExternalAnnotations,
1442
+ ): Promise<{ byCanvas: Map<string, unknown[]>; pages: string[] } | null> {
1443
+ const key = `${reference.type} ${reference.id}`;
1444
+ let pending = annotationCache.get(key);
1445
+ if (pending === undefined) {
1446
+ pending = impl(reference.id, {
1447
+ headers: { Accept: "application/ld+json, application/json" },
1448
+ })
1449
+ .then((response) => {
1450
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
1451
+ return response.json() as Promise<unknown>;
1452
+ })
1453
+ .catch((error: unknown) => {
1454
+ // a cached rejection would be remembered forever, so drop it
1455
+ // and let a later call retry
1456
+ annotationCache.delete(key);
1457
+ throw error;
1458
+ });
1459
+ annotationCache.set(key, pending);
1460
+ }
1461
+ try {
1462
+ return indexExternalAnnotations(await pending);
1463
+ } catch (error) {
1464
+ result.failed.push(reference.id);
1465
+ console.warn(
1466
+ `StoryMapJS: the seeAlso target could not be read: ${reference.id}`,
1467
+ error instanceof Error ? error.message : error,
1468
+ );
1469
+ return null;
1470
+ }
1471
+ }
1472
+
1473
+ /**
1474
+ * Converts a IIIF Presentation API 3.0 manifest into legacy StoryMapJS data
1475
+ * (the `storymap` object). Malformed or missing pieces are skipped; the
1476
+ * result always contains at least `{slides: []}`.
1477
+ */
1478
+ export function manifestToStorymapData(manifest: unknown): StorymapData {
1479
+ const data: StorymapData = { slides: [] };
1480
+ const record = asRecord(manifest);
1481
+ if (!record) return data;
1482
+
1483
+ // A Collection is not a Manifest, but it is a legitimate IIIF input: its
1484
+ // member Manifests' canvases concatenate into one linear story (§2.2)
1485
+ if (isPresentation3Collection(record)) {
1486
+ return collectionToStorymapData(record);
1487
+ }
1488
+
1489
+ const title = flattenLanguageMap(record.label);
1490
+ if (title !== "") data.title = title;
1491
+
1492
+ // A slide is addressed by its Canvas id, falling back to the Manifest's
1493
+ // (§2.3). The viewer already resolves deep links by `uniqueid`
1494
+ // (`StorySlider.goToId`) and generates one when falsy, so this is what
1495
+ // makes a manifest stop shareable by its canonical id.
1496
+ const manifestId = asString(record.id);
1497
+
1498
+ // Manifest-level map configuration service → storymap options fields.
1499
+ const config = readMapConfig(record);
1500
+ if (config) {
1501
+ applyMapConfig(data, config);
1502
+ }
1503
+
1504
+ // georeferenced IIIF images (Georeference Extension) sit above the
1505
+ // term-based overlay layers. The annotations are canvas-scoped but the
1506
+ // layers they describe are map-wide, so they are collected from *every*
1507
+ // canvas: a layer annotated on canvas 0 must not disappear for the rest
1508
+ // of the story (§2.10).
1509
+ const georeferenced = readGeoreferencedLayers(record);
1510
+ if (georeferenced.length > 0) {
1511
+ const existing = (data.overlays as StorymapOverlayLayer[] | undefined) ?? [];
1512
+ data.overlays = [...existing, ...georeferenced];
1513
+ }
1514
+
1515
+ // An image basemap is a painting body carrying an Image API service in
1516
+ // `service[]`; its base is where `iiif.url` comes from (§2.4). Gated on
1517
+ // map_type "iiif" because an ordinary slide can just as easily be an IIIF
1518
+ // image, and only the basemap one is the map.
1519
+ if (data.map_type === "iiif") {
1520
+ const imageService = readImageServiceUrl(record);
1521
+ if (imageService !== null) data.iiif = { url: imageService, attribution: "" };
1522
+ }
1523
+
1524
+ // Institutional credit: the manifest's own `requiredStatement`, then who
1525
+ // provided it and under what licence. All of it lands on the one credit
1526
+ // string the viewer renders, so a licence stated in the manifest is
1527
+ // actually shown rather than silently dropped (§3.2).
1528
+ const credit = [
1529
+ formatAttribution(readRequiredStatement(record.requiredStatement)),
1530
+ agentCredit(record.provider, "Provider"),
1531
+ asString(record.rights) !== null ? `Licence: ${asString(record.rights)}` : "",
1532
+ // an Agent with only a homepage still says who published this
1533
+ agentCredit(record.homepage, ""),
1534
+ ]
1535
+ .filter((part) => part !== "")
1536
+ .join(" · ");
1537
+ if (credit !== "") {
1538
+ const iiif = (data.iiif as { url?: string; attribution?: string } | undefined) ?? {};
1539
+ iiif.attribution = credit;
1540
+ if (iiif.url === undefined) iiif.url = "";
1541
+ data.iiif = iiif;
1542
+ }
1543
+
1544
+ // `logo` is an image, not a credit line, so it is offered as data for a
1545
+ // host to show (§3.2)
1546
+ const logo = asString(asRecord(record.logo)?.id);
1547
+ if (logo !== null) data.logo = logo;
1548
+
1549
+ // `metadata` is a list of label/value pairs, which cannot be rendered
1550
+ // generically — they are handed on as data (§3.2)
1551
+ const metadata = readMetadata(record.metadata);
1552
+ if (metadata.length > 0) data.metadata = metadata;
1553
+
1554
+ // The `seeAlso` targets are recorded but not followed here: reading them is
1555
+ // a network round trip per document, and a viewer should not block its
1556
+ // first paint on a third party. `loadSeeAlso()` fetches them on request,
1557
+ // behind its own cache, and the storymap's own slides stand on their own
1558
+ // meanwhile (§5.2).
1559
+ const seeAlso = collectManifestSeeAlso(record);
1560
+ if (seeAlso.length > 0) data.see_also = seeAlso;
1561
+
1562
+ // items[] (Canvases) → slides[], in order
1563
+ const items = Array.isArray(record.items) ? record.items : [];
1564
+ const manifestNavPlace = asRecord(record.navPlace);
1565
+ const manifestFeatures =
1566
+ manifestNavPlace && Array.isArray(manifestNavPlace.features)
1567
+ ? manifestNavPlace.features
1568
+ : [];
1569
+
1570
+ // `structures` states the curated order and the groups (§3.5). A Range
1571
+ // with a label and no `start` is a group; nested Ranges are chapters
1572
+ // within it, and a Range whose items run in a different order than the
1573
+ // canvases is a storyboard. Either way the order comes from here, not
1574
+ // from the document.
1575
+ const structures = readStructures(record.structures);
1576
+ const order = new Map<string, number>();
1577
+ for (const [position, canvasId] of structures.order.entries()) {
1578
+ order.set(canvasId, position);
1579
+ }
1580
+ const indexes = items.map((_item, index) => index);
1581
+ indexes.sort((a, b) => {
1582
+ const ia = order.get(asString(asRecord(items[a])?.id) ?? "");
1583
+ const ib = order.get(asString(asRecord(items[b])?.id) ?? "");
1584
+ // a canvas no Range mentions keeps its document position, after the
1585
+ // ones a Range does mention
1586
+ if (ia === undefined && ib === undefined) return a - b;
1587
+ if (ia === undefined) return 1;
1588
+ if (ib === undefined) return -1;
1589
+ return ia - ib;
1590
+ });
1591
+
1592
+ for (const index of indexes) {
1593
+ // The canvas index, not the output-slide index: annotation stops
1594
+ // appended below shift the slide array, and the manifest-level
1595
+ // navPlace features line up with canvases.
1596
+ const canvasId = asString(asRecord(items[index])?.id);
1597
+ const slide = canvasToSlide(
1598
+ items[index],
1599
+ manifestFeatures[index],
1600
+ asString(data.language) ?? null,
1601
+ );
1602
+ if (slide !== null) {
1603
+ slide.uniqueid = canvasId ?? manifestId ?? "";
1604
+ if (canvasId !== null) {
1605
+ const group = structures.groups.get(canvasId);
1606
+ if (group !== undefined) slide.group = group;
1607
+ }
1608
+ data.slides.push(slide);
1609
+ }
1610
+ // Annotation-driven tour stops, in annotation page order. They share
1611
+ // the canvas they annotate, suffixed so they stay addressable and do
1612
+ // not collide with it.
1613
+ for (const stop of readCommentingAnnotations(items[index])) {
1614
+ if (canvasId) {
1615
+ stop.uniqueid = `${canvasId}#${data.slides.length}`;
1616
+ }
1617
+ data.slides.push(stop);
1618
+ }
1619
+ // A polygon navPlace states the geographic extent of the story
1620
+ // ("supplying a single geographic bounding box" in the navPlace
1621
+ // extension), which the legacy format expresses as map_bbox. The
1622
+ // first canvas carrying one wins; Point navPlaces are unaffected.
1623
+ if (data.map_bbox === undefined) {
1624
+ const canvas = asRecord(items[index]);
1625
+ const bbox =
1626
+ (canvas ? readNavPlaceBbox(canvas.navPlace) : null) ??
1627
+ readNavPlaceBbox(record.navPlace);
1628
+ if (bbox !== null) data.map_bbox = bbox;
1629
+ }
1630
+ }
1631
+
1632
+ return data;
1633
+ }
1634
+
1635
+ /** Copies the mapconfig service terms onto the storymap data root (legacy keys). */
1636
+ function applyMapConfig(data: StorymapData, config: Record<string, unknown>): void {
1637
+ // The basemap is either a keyword or a tile source: `storymap:basemap`
1638
+ // names one the viewer knows how to configure (osm, stadia, iiif, …) and
1639
+ // TileJSON describes an arbitrary tile service. `mapType` used to carry
1640
+ // both in one string, which is why a URL template and a vendor keyword
1641
+ // were the same field (§2.9).
1642
+ const basemap = asString(readTerm(config, "basemap"));
1643
+ if (basemap !== null && basemap !== "") {
1644
+ data.map_type = basemap;
1645
+ } else {
1646
+ const tilejson = readTilejson(config);
1647
+ if (tilejson !== null) {
1648
+ data.map_type = Array.isArray(tilejson.tiles) ? tilejson.tiles[0] : tilejson.tiles;
1649
+ data.tilejson = tilejson;
1650
+ }
1651
+ }
1652
+
1653
+ const mapAsImage = asBoolean(readTerm(config, "mapAsImage"));
1654
+ if (mapAsImage !== null) data.map_as_image = mapAsImage;
1655
+
1656
+ const mapAccessToken = asString(readTerm(config, "mapAccessToken"));
1657
+ if (mapAccessToken !== null) data.map_access_token = mapAccessToken;
1658
+
1659
+ const mapBackgroundColor = asString(readTerm(config, "mapBackgroundColor"));
1660
+ if (mapBackgroundColor !== null) data.map_background_color = mapBackgroundColor;
1661
+
1662
+ const mapCenterOffset = asRecord(readTerm(config, "mapCenterOffset"));
1663
+ if (mapCenterOffset) {
1664
+ const left = asNumber(mapCenterOffset.left);
1665
+ const top = asNumber(mapCenterOffset.top);
1666
+ if (left !== null && top !== null) data.map_center_offset = { left, top };
1667
+ }
1668
+
1669
+ const fontCss = asString(readTerm(config, "fontCss"));
1670
+ if (fontCss !== null) data.font_css = fontCss;
1671
+
1672
+ const callToAction = asBoolean(readTerm(config, "callToAction"));
1673
+ if (callToAction !== null) data.call_to_action = callToAction;
1674
+
1675
+ const callToActionText = asString(readTerm(config, "callToActionText"));
1676
+ if (callToActionText !== null) data.call_to_action_text = callToActionText;
1677
+
1678
+ const startAtSlide = asNumber(readTerm(config, "startAtSlide"));
1679
+ if (startAtSlide !== null) data.start_at_slide = startAtSlide;
1680
+
1681
+ const language = asString(readTerm(config, "language"));
1682
+ if (language !== null) data.language = language;
1683
+
1684
+ const calculateZoom = asBoolean(readTerm(config, "calculateZoom"));
1685
+ if (calculateZoom !== null) data.calculate_zoom = calculateZoom;
1686
+
1687
+ const lineFollowsPath = asBoolean(readTerm(config, "lineFollowsPath"));
1688
+ if (lineFollowsPath !== null) data.line_follows_path = lineFollowsPath;
1689
+
1690
+ const showLines = asBoolean(readTerm(config, "showLines"));
1691
+ if (showLines !== null) data.show_lines = showLines;
1692
+
1693
+ const showHistoryLine = asBoolean(readTerm(config, "showHistoryLine"));
1694
+ if (showHistoryLine !== null) data.show_history_line = showHistoryLine;
1695
+
1696
+ const lineColor = asString(readTerm(config, "lineColor"));
1697
+ if (lineColor !== null) data.line_color = lineColor;
1698
+
1699
+ const lineColorInactive = asString(readTerm(config, "lineColorInactive"));
1700
+ if (lineColorInactive !== null) data.line_color_inactive = lineColorInactive;
1701
+
1702
+ const lineWeight = asNumber(readTerm(config, "lineWeight"));
1703
+ if (lineWeight !== null) data.line_weight = lineWeight;
1704
+
1705
+ const lineOpacity = asNumber(readTerm(config, "lineOpacity"));
1706
+ if (lineOpacity !== null) data.line_opacity = lineOpacity;
1707
+
1708
+ const lineDash = asString(readTerm(config, "lineDash"));
1709
+ if (lineDash !== null) data.line_dash = lineDash;
1710
+
1711
+ const lineJoin = asString(readTerm(config, "lineJoin"));
1712
+ if (lineJoin !== null) data.line_join = lineJoin;
1713
+
1714
+ const useCustomMarkers = asBoolean(readTerm(config, "useCustomMarkers"));
1715
+ if (useCustomMarkers !== null) data.use_custom_markers = useCustomMarkers;
1716
+
1717
+ // landscape map layout: "full" (default) or "left"
1718
+ const mapArea = asString(readTerm(config, "mapArea"));
1719
+ if (mapArea === "full" || mapArea === "left") data.map_area = mapArea;
1720
+
1721
+ const overviewExtent = readLonLatBox(readTerm(config, "overviewExtent"));
1722
+ if (overviewExtent !== null) data.overview_extent = overviewExtent;
1723
+
1724
+ const keyboard = asBoolean(readTerm(config, "keyboard"));
1725
+ if (keyboard !== null) data.keyboard = keyboard;
1726
+
1727
+ // stacked layers above the basemap, as extension-term entries
1728
+ const overlays = readOverlays(readTerm(config, "overlays"));
1729
+ if (overlays.length > 0) data.overlays = overlays;
1730
+ }
1731
+
1732
+ /** Reads a `[west, south, east, north]` lon/lat box of four finite numbers. */
1733
+ function readLonLatBox(value: unknown): [number, number, number, number] | null {
1734
+ if (!Array.isArray(value) || value.length !== 4) return null;
1735
+ if (!value.every((n) => typeof n === "number" && Number.isFinite(n))) return null;
1736
+ const [west, south, east, north] = value as number[];
1737
+ if (east <= west || north <= south) return null;
1738
+ return [west, south, east, north];
1739
+ }
1740
+
1741
+ /** Reads the shared presentation keys of an `overlays[]` entry. */
1742
+ function readOverlayPresentation(
1743
+ record: Record<string, unknown>,
1744
+ entry: StorymapOverlayLayer,
1745
+ ): void {
1746
+ const opacity = asNumber(record.opacity);
1747
+ if (opacity !== null) entry.opacity = opacity;
1748
+ const visible = asBoolean(record.visible);
1749
+ if (visible !== null) entry.visible = visible;
1750
+ const attribution = asString(record.attribution);
1751
+ if (attribution !== null) entry.attribution = attribution;
1752
+ const className = asString(record.className);
1753
+ if (className !== null) entry.className = className;
1754
+ const blendMode = asString(record.blendMode);
1755
+ if (blendMode !== null) entry.blendMode = blendMode;
1756
+ const extent = readLonLatBox(record.extent);
1757
+ if (extent !== null) entry.extent = extent;
1758
+ }
1759
+
1760
+ /**
1761
+ * Reads `storymap:overlays`: stacked raster layers, each naming any
1762
+ * `map_type` the tile layer factory accepts plus declarative presentation.
1763
+ * Entries without a `map_type` are dropped here (the map skips them too).
1764
+ */
1765
+ function readOverlays(value: unknown): StorymapOverlayLayer[] {
1766
+ if (!Array.isArray(value)) return [];
1767
+ const overlays: StorymapOverlayLayer[] = [];
1768
+ for (const item of value) {
1769
+ const record = asRecord(item);
1770
+ if (!record) continue;
1771
+ const mapType = asString(record.map_type);
1772
+ if (mapType === null || mapType === "") continue;
1773
+ const entry: StorymapOverlayLayer = { map_type: mapType };
1774
+ readOverlayPresentation(record, entry);
1775
+ overlays.push(entry);
1776
+ }
1777
+ return overlays;
1778
+ }
1779
+
1780
+ /**
1781
+ * IIIF images placed on the geographic map, from the Georeference Extension's
1782
+ * `motivation: "georeferencing"` annotations (§2.10).
1783
+ *
1784
+ * Per the extension, a layer that is not part of the canvas it ships in — which
1785
+ * is every map layer — carries the image it places in `target` as an embedded
1786
+ * resource, and the ground control points in `body` as a FeatureCollection
1787
+ * whose features carry `resourceCoords`.
1788
+ *
1789
+ * The annotations are read from every canvas, because the layers they describe
1790
+ * are map-wide. Entries without a usable image or without ground control points
1791
+ * are dropped; whether the points can be placed affinely is the map's decision.
1792
+ *
1793
+ * `requiredStatement` is read as the layer's attribution, which is where P3 puts
1794
+ * attribution. The term also carried `opacity`, `visible`, `className`,
1795
+ * `blendMode` and `extent`; none of those has IIIF vocabulary for a
1796
+ * georeferencing annotation, so they no longer round-trip. The extent is not
1797
+ * lost in substance — the control points describe it.
1798
+ */
1799
+ function readGeoreferencedLayers(manifest: Record<string, unknown>): StorymapOverlayLayer[] {
1800
+ const overlays: StorymapOverlayLayer[] = [];
1801
+ const canvases = Array.isArray(manifest.items) ? manifest.items : [];
1802
+ for (const canvas of canvases) {
1803
+ const canvasRecord = asRecord(canvas);
1804
+ if (!canvasRecord) continue;
1805
+ const pages = Array.isArray(canvasRecord.items) ? canvasRecord.items : [];
1806
+ for (const page of pages) {
1807
+ const pageRecord = asRecord(page);
1808
+ if (!pageRecord) continue;
1809
+ const annotations = Array.isArray(pageRecord.items) ? pageRecord.items : [];
1810
+ for (const annotation of annotations) {
1811
+ const record = asRecord(annotation);
1812
+ if (!record) continue;
1813
+ const motivations = Array.isArray(record.motivation)
1814
+ ? record.motivation
1815
+ : [record.motivation];
1816
+ if (!motivations.some((m) => asString(m) === "georeferencing")) continue;
1817
+
1818
+ const target = asRecord(record.target);
1819
+ if (!target) continue;
1820
+ const url = asString(target.id);
1821
+ if (url === null) continue;
1822
+ const width = asNumber(target.width);
1823
+ const height = asNumber(target.height);
1824
+ if (width === null || height === null || width <= 0 || height <= 0) continue;
1825
+ const body = asRecord(record.body);
1826
+ if (!body || readGroundControlPoints(body) === null) continue;
1827
+
1828
+ const georeference: StorymapGeoreference = {
1829
+ url,
1830
+ width,
1831
+ height,
1832
+ body: body as unknown as StorymapGeoreference["body"],
1833
+ };
1834
+ const entry: StorymapOverlayLayer = { georeference };
1835
+ const attribution = formatAttribution(
1836
+ readRequiredStatement(record.requiredStatement),
1837
+ );
1838
+ if (attribution !== "") entry.attribution = attribution;
1839
+ overlays.push(entry);
1840
+ }
1841
+ }
1842
+ }
1843
+ return overlays;
1844
+ }