@projektemacher/storymapjs 0.10.7 → 0.10.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (532) hide show
  1. package/CHANGELOG +350 -19
  2. package/README.md +43 -9
  3. package/dist/assets/demo-DBpeF31K.js +1 -0
  4. package/dist/assets/harness-Cr0rYpvt.js +1 -0
  5. package/dist/assets/harness-multi-CYHgI0a9.js +1 -0
  6. package/dist/assets/harness-support-Do7sIp2j.js +1 -0
  7. package/dist/assets/index-wHPqGrBS.js +1 -0
  8. package/dist/assets/main-DXalctuR.js +17 -0
  9. package/dist/context.json +12 -9
  10. package/dist/css/fonts/files/amatic-sc-cyrillic-400-normal.woff2 +0 -0
  11. package/dist/css/fonts/files/amatic-sc-cyrillic-700-normal.woff2 +0 -0
  12. package/dist/css/fonts/files/amatic-sc-hebrew-400-normal.woff2 +0 -0
  13. package/dist/css/fonts/files/amatic-sc-hebrew-700-normal.woff2 +0 -0
  14. package/dist/css/fonts/files/amatic-sc-vietnamese-400-normal.woff2 +0 -0
  15. package/dist/css/fonts/files/amatic-sc-vietnamese-700-normal.woff2 +0 -0
  16. package/dist/css/fonts/files/bitter-cyrillic-100-italic.woff2 +0 -0
  17. package/dist/css/fonts/files/bitter-cyrillic-100-normal.woff2 +0 -0
  18. package/dist/css/fonts/files/bitter-cyrillic-200-italic.woff2 +0 -0
  19. package/dist/css/fonts/files/bitter-cyrillic-200-normal.woff2 +0 -0
  20. package/dist/css/fonts/files/bitter-cyrillic-300-italic.woff2 +0 -0
  21. package/dist/css/fonts/files/bitter-cyrillic-300-normal.woff2 +0 -0
  22. package/dist/css/fonts/files/bitter-cyrillic-400-italic.woff2 +0 -0
  23. package/dist/css/fonts/files/bitter-cyrillic-400-normal.woff2 +0 -0
  24. package/dist/css/fonts/files/bitter-cyrillic-500-italic.woff2 +0 -0
  25. package/dist/css/fonts/files/bitter-cyrillic-500-normal.woff2 +0 -0
  26. package/dist/css/fonts/files/bitter-cyrillic-600-italic.woff2 +0 -0
  27. package/dist/css/fonts/files/bitter-cyrillic-600-normal.woff2 +0 -0
  28. package/dist/css/fonts/files/bitter-cyrillic-700-italic.woff2 +0 -0
  29. package/dist/css/fonts/files/bitter-cyrillic-700-normal.woff2 +0 -0
  30. package/dist/css/fonts/files/bitter-cyrillic-800-italic.woff2 +0 -0
  31. package/dist/css/fonts/files/bitter-cyrillic-800-normal.woff2 +0 -0
  32. package/dist/css/fonts/files/bitter-cyrillic-900-italic.woff2 +0 -0
  33. package/dist/css/fonts/files/bitter-cyrillic-900-normal.woff2 +0 -0
  34. package/dist/css/fonts/files/bitter-cyrillic-ext-100-italic.woff2 +0 -0
  35. package/dist/css/fonts/files/bitter-cyrillic-ext-100-normal.woff2 +0 -0
  36. package/dist/css/fonts/files/bitter-cyrillic-ext-200-italic.woff2 +0 -0
  37. package/dist/css/fonts/files/bitter-cyrillic-ext-200-normal.woff2 +0 -0
  38. package/dist/css/fonts/files/bitter-cyrillic-ext-300-italic.woff2 +0 -0
  39. package/dist/css/fonts/files/bitter-cyrillic-ext-300-normal.woff2 +0 -0
  40. package/dist/css/fonts/files/bitter-cyrillic-ext-400-italic.woff2 +0 -0
  41. package/dist/css/fonts/files/bitter-cyrillic-ext-400-normal.woff2 +0 -0
  42. package/dist/css/fonts/files/bitter-cyrillic-ext-500-italic.woff2 +0 -0
  43. package/dist/css/fonts/files/bitter-cyrillic-ext-500-normal.woff2 +0 -0
  44. package/dist/css/fonts/files/bitter-cyrillic-ext-600-italic.woff2 +0 -0
  45. package/dist/css/fonts/files/bitter-cyrillic-ext-600-normal.woff2 +0 -0
  46. package/dist/css/fonts/files/bitter-cyrillic-ext-700-italic.woff2 +0 -0
  47. package/dist/css/fonts/files/bitter-cyrillic-ext-700-normal.woff2 +0 -0
  48. package/dist/css/fonts/files/bitter-cyrillic-ext-800-italic.woff2 +0 -0
  49. package/dist/css/fonts/files/bitter-cyrillic-ext-800-normal.woff2 +0 -0
  50. package/dist/css/fonts/files/bitter-cyrillic-ext-900-italic.woff2 +0 -0
  51. package/dist/css/fonts/files/bitter-cyrillic-ext-900-normal.woff2 +0 -0
  52. package/dist/css/fonts/files/bitter-cyrillic-ext-wght-italic.woff2 +0 -0
  53. package/dist/css/fonts/files/bitter-cyrillic-ext-wght-normal.woff2 +0 -0
  54. package/dist/css/fonts/files/bitter-cyrillic-wght-italic.woff2 +0 -0
  55. package/dist/css/fonts/files/bitter-cyrillic-wght-normal.woff2 +0 -0
  56. package/dist/css/fonts/files/bitter-vietnamese-100-italic.woff2 +0 -0
  57. package/dist/css/fonts/files/bitter-vietnamese-100-normal.woff2 +0 -0
  58. package/dist/css/fonts/files/bitter-vietnamese-200-italic.woff2 +0 -0
  59. package/dist/css/fonts/files/bitter-vietnamese-200-normal.woff2 +0 -0
  60. package/dist/css/fonts/files/bitter-vietnamese-300-italic.woff2 +0 -0
  61. package/dist/css/fonts/files/bitter-vietnamese-300-normal.woff2 +0 -0
  62. package/dist/css/fonts/files/bitter-vietnamese-400-italic.woff2 +0 -0
  63. package/dist/css/fonts/files/bitter-vietnamese-400-normal.woff2 +0 -0
  64. package/dist/css/fonts/files/bitter-vietnamese-500-italic.woff2 +0 -0
  65. package/dist/css/fonts/files/bitter-vietnamese-500-normal.woff2 +0 -0
  66. package/dist/css/fonts/files/bitter-vietnamese-600-italic.woff2 +0 -0
  67. package/dist/css/fonts/files/bitter-vietnamese-600-normal.woff2 +0 -0
  68. package/dist/css/fonts/files/bitter-vietnamese-700-italic.woff2 +0 -0
  69. package/dist/css/fonts/files/bitter-vietnamese-700-normal.woff2 +0 -0
  70. package/dist/css/fonts/files/bitter-vietnamese-800-italic.woff2 +0 -0
  71. package/dist/css/fonts/files/bitter-vietnamese-800-normal.woff2 +0 -0
  72. package/dist/css/fonts/files/bitter-vietnamese-900-italic.woff2 +0 -0
  73. package/dist/css/fonts/files/bitter-vietnamese-900-normal.woff2 +0 -0
  74. package/dist/css/fonts/files/bitter-vietnamese-wght-italic.woff2 +0 -0
  75. package/dist/css/fonts/files/bitter-vietnamese-wght-normal.woff2 +0 -0
  76. package/dist/css/fonts/files/cairo-arabic-200-normal.woff2 +0 -0
  77. package/dist/css/fonts/files/cairo-arabic-300-normal.woff2 +0 -0
  78. package/dist/css/fonts/files/cairo-arabic-400-normal.woff2 +0 -0
  79. package/dist/css/fonts/files/cairo-arabic-500-normal.woff2 +0 -0
  80. package/dist/css/fonts/files/cairo-arabic-600-normal.woff2 +0 -0
  81. package/dist/css/fonts/files/cairo-arabic-700-normal.woff2 +0 -0
  82. package/dist/css/fonts/files/cairo-arabic-800-normal.woff2 +0 -0
  83. package/dist/css/fonts/files/cairo-arabic-900-normal.woff2 +0 -0
  84. package/dist/css/fonts/files/cairo-latin-200-normal.woff2 +0 -0
  85. package/dist/css/fonts/files/cairo-latin-300-normal.woff2 +0 -0
  86. package/dist/css/fonts/files/cairo-latin-400-normal.woff2 +0 -0
  87. package/dist/css/fonts/files/cairo-latin-500-normal.woff2 +0 -0
  88. package/dist/css/fonts/files/cairo-latin-600-normal.woff2 +0 -0
  89. package/dist/css/fonts/files/cairo-latin-700-normal.woff2 +0 -0
  90. package/dist/css/fonts/files/cairo-latin-800-normal.woff2 +0 -0
  91. package/dist/css/fonts/files/cairo-latin-900-normal.woff2 +0 -0
  92. package/dist/css/fonts/files/cairo-latin-ext-200-normal.woff2 +0 -0
  93. package/dist/css/fonts/files/cairo-latin-ext-300-normal.woff2 +0 -0
  94. package/dist/css/fonts/files/cairo-latin-ext-400-normal.woff2 +0 -0
  95. package/dist/css/fonts/files/cairo-latin-ext-500-normal.woff2 +0 -0
  96. package/dist/css/fonts/files/cairo-latin-ext-600-normal.woff2 +0 -0
  97. package/dist/css/fonts/files/cairo-latin-ext-700-normal.woff2 +0 -0
  98. package/dist/css/fonts/files/cairo-latin-ext-800-normal.woff2 +0 -0
  99. package/dist/css/fonts/files/cairo-latin-ext-900-normal.woff2 +0 -0
  100. package/dist/css/fonts/files/dancing-script-vietnamese-wght-normal.woff2 +0 -0
  101. package/dist/css/fonts/files/eb-garamond-cyrillic-ext-wght-italic.woff2 +0 -0
  102. package/dist/css/fonts/files/eb-garamond-cyrillic-ext-wght-normal.woff2 +0 -0
  103. package/dist/css/fonts/files/eb-garamond-cyrillic-wght-italic.woff2 +0 -0
  104. package/dist/css/fonts/files/eb-garamond-cyrillic-wght-normal.woff2 +0 -0
  105. package/dist/css/fonts/files/eb-garamond-greek-ext-wght-italic.woff2 +0 -0
  106. package/dist/css/fonts/files/eb-garamond-greek-ext-wght-normal.woff2 +0 -0
  107. package/dist/css/fonts/files/eb-garamond-greek-wght-italic.woff2 +0 -0
  108. package/dist/css/fonts/files/eb-garamond-greek-wght-normal.woff2 +0 -0
  109. package/dist/css/fonts/files/eb-garamond-vietnamese-wght-italic.woff2 +0 -0
  110. package/dist/css/fonts/files/eb-garamond-vietnamese-wght-normal.woff2 +0 -0
  111. package/dist/css/fonts/files/gentium-book-plus-cyrillic-400-italic.woff2 +0 -0
  112. package/dist/css/fonts/files/gentium-book-plus-cyrillic-400-normal.woff2 +0 -0
  113. package/dist/css/fonts/files/gentium-book-plus-cyrillic-700-italic.woff2 +0 -0
  114. package/dist/css/fonts/files/gentium-book-plus-cyrillic-700-normal.woff2 +0 -0
  115. package/dist/css/fonts/files/gentium-book-plus-cyrillic-ext-400-italic.woff2 +0 -0
  116. package/dist/css/fonts/files/gentium-book-plus-cyrillic-ext-400-normal.woff2 +0 -0
  117. package/dist/css/fonts/files/gentium-book-plus-cyrillic-ext-700-italic.woff2 +0 -0
  118. package/dist/css/fonts/files/gentium-book-plus-cyrillic-ext-700-normal.woff2 +0 -0
  119. package/dist/css/fonts/files/gentium-book-plus-greek-400-italic.woff2 +0 -0
  120. package/dist/css/fonts/files/gentium-book-plus-greek-400-normal.woff2 +0 -0
  121. package/dist/css/fonts/files/gentium-book-plus-greek-700-italic.woff2 +0 -0
  122. package/dist/css/fonts/files/gentium-book-plus-greek-700-normal.woff2 +0 -0
  123. package/dist/css/fonts/files/gentium-book-plus-greek-ext-400-italic.woff2 +0 -0
  124. package/dist/css/fonts/files/gentium-book-plus-greek-ext-400-normal.woff2 +0 -0
  125. package/dist/css/fonts/files/gentium-book-plus-greek-ext-700-italic.woff2 +0 -0
  126. package/dist/css/fonts/files/gentium-book-plus-greek-ext-700-normal.woff2 +0 -0
  127. package/dist/css/fonts/files/gentium-book-plus-vietnamese-400-italic.woff2 +0 -0
  128. package/dist/css/fonts/files/gentium-book-plus-vietnamese-400-normal.woff2 +0 -0
  129. package/dist/css/fonts/files/gentium-book-plus-vietnamese-700-italic.woff2 +0 -0
  130. package/dist/css/fonts/files/gentium-book-plus-vietnamese-700-normal.woff2 +0 -0
  131. package/dist/css/fonts/files/old-standard-tt-cyrillic-400-italic.woff2 +0 -0
  132. package/dist/css/fonts/files/old-standard-tt-cyrillic-400-normal.woff2 +0 -0
  133. package/dist/css/fonts/files/old-standard-tt-cyrillic-700-normal.woff2 +0 -0
  134. package/dist/css/fonts/files/old-standard-tt-cyrillic-ext-400-italic.woff2 +0 -0
  135. package/dist/css/fonts/files/old-standard-tt-cyrillic-ext-400-normal.woff2 +0 -0
  136. package/dist/css/fonts/files/old-standard-tt-cyrillic-ext-700-normal.woff2 +0 -0
  137. package/dist/css/fonts/files/old-standard-tt-vietnamese-400-italic.woff2 +0 -0
  138. package/dist/css/fonts/files/old-standard-tt-vietnamese-400-normal.woff2 +0 -0
  139. package/dist/css/fonts/files/old-standard-tt-vietnamese-700-normal.woff2 +0 -0
  140. package/dist/css/fonts/files/open-sans-cyrillic-ext-wght-italic.woff2 +0 -0
  141. package/dist/css/fonts/files/open-sans-cyrillic-ext-wght-normal.woff2 +0 -0
  142. package/dist/css/fonts/files/open-sans-cyrillic-wght-italic.woff2 +0 -0
  143. package/dist/css/fonts/files/open-sans-cyrillic-wght-normal.woff2 +0 -0
  144. package/dist/css/fonts/files/open-sans-greek-ext-wght-italic.woff2 +0 -0
  145. package/dist/css/fonts/files/open-sans-greek-ext-wght-normal.woff2 +0 -0
  146. package/dist/css/fonts/files/open-sans-greek-wght-italic.woff2 +0 -0
  147. package/dist/css/fonts/files/open-sans-greek-wght-normal.woff2 +0 -0
  148. package/dist/css/fonts/files/open-sans-hebrew-wght-italic.woff2 +0 -0
  149. package/dist/css/fonts/files/open-sans-hebrew-wght-normal.woff2 +0 -0
  150. package/dist/css/fonts/files/open-sans-vietnamese-wght-italic.woff2 +0 -0
  151. package/dist/css/fonts/files/open-sans-vietnamese-wght-normal.woff2 +0 -0
  152. package/dist/css/fonts/files/playfair-display-cyrillic-wght-italic.woff2 +0 -0
  153. package/dist/css/fonts/files/playfair-display-cyrillic-wght-normal.woff2 +0 -0
  154. package/dist/css/fonts/files/playfair-display-sc-cyrillic-400-italic.woff2 +0 -0
  155. package/dist/css/fonts/files/playfair-display-sc-cyrillic-400-normal.woff2 +0 -0
  156. package/dist/css/fonts/files/playfair-display-sc-cyrillic-700-italic.woff2 +0 -0
  157. package/dist/css/fonts/files/playfair-display-sc-cyrillic-700-normal.woff2 +0 -0
  158. package/dist/css/fonts/files/playfair-display-sc-cyrillic-900-italic.woff2 +0 -0
  159. package/dist/css/fonts/files/playfair-display-sc-cyrillic-900-normal.woff2 +0 -0
  160. package/dist/css/fonts/files/playfair-display-sc-vietnamese-400-italic.woff2 +0 -0
  161. package/dist/css/fonts/files/playfair-display-sc-vietnamese-400-normal.woff2 +0 -0
  162. package/dist/css/fonts/files/playfair-display-sc-vietnamese-700-italic.woff2 +0 -0
  163. package/dist/css/fonts/files/playfair-display-sc-vietnamese-700-normal.woff2 +0 -0
  164. package/dist/css/fonts/files/playfair-display-sc-vietnamese-900-italic.woff2 +0 -0
  165. package/dist/css/fonts/files/playfair-display-sc-vietnamese-900-normal.woff2 +0 -0
  166. package/dist/css/fonts/files/playfair-display-vietnamese-wght-italic.woff2 +0 -0
  167. package/dist/css/fonts/files/playfair-display-vietnamese-wght-normal.woff2 +0 -0
  168. package/dist/css/fonts/files/pt-sans-cyrillic-400-italic.woff2 +0 -0
  169. package/dist/css/fonts/files/pt-sans-cyrillic-400-normal.woff2 +0 -0
  170. package/dist/css/fonts/files/pt-sans-cyrillic-700-italic.woff2 +0 -0
  171. package/dist/css/fonts/files/pt-sans-cyrillic-700-normal.woff2 +0 -0
  172. package/dist/css/fonts/files/pt-sans-cyrillic-ext-400-italic.woff2 +0 -0
  173. package/dist/css/fonts/files/pt-sans-cyrillic-ext-400-normal.woff2 +0 -0
  174. package/dist/css/fonts/files/pt-sans-cyrillic-ext-700-italic.woff2 +0 -0
  175. package/dist/css/fonts/files/pt-sans-cyrillic-ext-700-normal.woff2 +0 -0
  176. package/dist/css/fonts/files/pt-sans-narrow-cyrillic-400-normal.woff2 +0 -0
  177. package/dist/css/fonts/files/pt-sans-narrow-cyrillic-700-normal.woff2 +0 -0
  178. package/dist/css/fonts/files/pt-sans-narrow-cyrillic-ext-400-normal.woff2 +0 -0
  179. package/dist/css/fonts/files/pt-sans-narrow-cyrillic-ext-700-normal.woff2 +0 -0
  180. package/dist/css/fonts/files/pt-serif-cyrillic-400-italic.woff2 +0 -0
  181. package/dist/css/fonts/files/pt-serif-cyrillic-400-normal.woff2 +0 -0
  182. package/dist/css/fonts/files/pt-serif-cyrillic-700-italic.woff2 +0 -0
  183. package/dist/css/fonts/files/pt-serif-cyrillic-700-normal.woff2 +0 -0
  184. package/dist/css/fonts/files/pt-serif-cyrillic-ext-400-italic.woff2 +0 -0
  185. package/dist/css/fonts/files/pt-serif-cyrillic-ext-400-normal.woff2 +0 -0
  186. package/dist/css/fonts/files/pt-serif-cyrillic-ext-700-italic.woff2 +0 -0
  187. package/dist/css/fonts/files/pt-serif-cyrillic-ext-700-normal.woff2 +0 -0
  188. package/dist/css/fonts/files/raleway-cyrillic-ext-wght-italic.woff2 +0 -0
  189. package/dist/css/fonts/files/raleway-cyrillic-ext-wght-normal.woff2 +0 -0
  190. package/dist/css/fonts/files/raleway-cyrillic-wght-italic.woff2 +0 -0
  191. package/dist/css/fonts/files/raleway-cyrillic-wght-normal.woff2 +0 -0
  192. package/dist/css/fonts/files/raleway-vietnamese-wght-italic.woff2 +0 -0
  193. package/dist/css/fonts/files/raleway-vietnamese-wght-normal.woff2 +0 -0
  194. package/dist/css/fonts/files/roboto-slab-cyrillic-ext-wght-normal.woff2 +0 -0
  195. package/dist/css/fonts/files/roboto-slab-cyrillic-wght-normal.woff2 +0 -0
  196. package/dist/css/fonts/files/roboto-slab-greek-ext-wght-normal.woff2 +0 -0
  197. package/dist/css/fonts/files/roboto-slab-greek-wght-normal.woff2 +0 -0
  198. package/dist/css/fonts/files/roboto-slab-vietnamese-wght-normal.woff2 +0 -0
  199. package/dist/css/fonts/files/ubuntu-cyrillic-300-italic.woff2 +0 -0
  200. package/dist/css/fonts/files/ubuntu-cyrillic-300-normal.woff2 +0 -0
  201. package/dist/css/fonts/files/ubuntu-cyrillic-400-italic.woff2 +0 -0
  202. package/dist/css/fonts/files/ubuntu-cyrillic-400-normal.woff2 +0 -0
  203. package/dist/css/fonts/files/ubuntu-cyrillic-500-italic.woff2 +0 -0
  204. package/dist/css/fonts/files/ubuntu-cyrillic-500-normal.woff2 +0 -0
  205. package/dist/css/fonts/files/ubuntu-cyrillic-700-italic.woff2 +0 -0
  206. package/dist/css/fonts/files/ubuntu-cyrillic-700-normal.woff2 +0 -0
  207. package/dist/css/fonts/files/ubuntu-cyrillic-ext-300-italic.woff2 +0 -0
  208. package/dist/css/fonts/files/ubuntu-cyrillic-ext-300-normal.woff2 +0 -0
  209. package/dist/css/fonts/files/ubuntu-cyrillic-ext-400-italic.woff2 +0 -0
  210. package/dist/css/fonts/files/ubuntu-cyrillic-ext-400-normal.woff2 +0 -0
  211. package/dist/css/fonts/files/ubuntu-cyrillic-ext-500-italic.woff2 +0 -0
  212. package/dist/css/fonts/files/ubuntu-cyrillic-ext-500-normal.woff2 +0 -0
  213. package/dist/css/fonts/files/ubuntu-cyrillic-ext-700-italic.woff2 +0 -0
  214. package/dist/css/fonts/files/ubuntu-cyrillic-ext-700-normal.woff2 +0 -0
  215. package/dist/css/fonts/files/ubuntu-greek-300-italic.woff2 +0 -0
  216. package/dist/css/fonts/files/ubuntu-greek-300-normal.woff2 +0 -0
  217. package/dist/css/fonts/files/ubuntu-greek-400-italic.woff2 +0 -0
  218. package/dist/css/fonts/files/ubuntu-greek-400-normal.woff2 +0 -0
  219. package/dist/css/fonts/files/ubuntu-greek-500-italic.woff2 +0 -0
  220. package/dist/css/fonts/files/ubuntu-greek-500-normal.woff2 +0 -0
  221. package/dist/css/fonts/files/ubuntu-greek-700-italic.woff2 +0 -0
  222. package/dist/css/fonts/files/ubuntu-greek-700-normal.woff2 +0 -0
  223. package/dist/css/fonts/files/ubuntu-greek-ext-300-italic.woff2 +0 -0
  224. package/dist/css/fonts/files/ubuntu-greek-ext-300-normal.woff2 +0 -0
  225. package/dist/css/fonts/files/ubuntu-greek-ext-400-italic.woff2 +0 -0
  226. package/dist/css/fonts/files/ubuntu-greek-ext-400-normal.woff2 +0 -0
  227. package/dist/css/fonts/files/ubuntu-greek-ext-500-italic.woff2 +0 -0
  228. package/dist/css/fonts/files/ubuntu-greek-ext-500-normal.woff2 +0 -0
  229. package/dist/css/fonts/files/ubuntu-greek-ext-700-italic.woff2 +0 -0
  230. package/dist/css/fonts/files/ubuntu-greek-ext-700-normal.woff2 +0 -0
  231. package/dist/css/fonts/files/unica-one-vietnamese-400-normal.woff2 +0 -0
  232. package/dist/css/fonts/files/vollkorn-cyrillic-ext-wght-italic.woff2 +0 -0
  233. package/dist/css/fonts/files/vollkorn-cyrillic-ext-wght-normal.woff2 +0 -0
  234. package/dist/css/fonts/files/vollkorn-cyrillic-wght-italic.woff2 +0 -0
  235. package/dist/css/fonts/files/vollkorn-cyrillic-wght-normal.woff2 +0 -0
  236. package/dist/css/fonts/files/vollkorn-greek-wght-italic.woff2 +0 -0
  237. package/dist/css/fonts/files/vollkorn-greek-wght-normal.woff2 +0 -0
  238. package/dist/css/fonts/files/vollkorn-vietnamese-wght-italic.woff2 +0 -0
  239. package/dist/css/fonts/files/vollkorn-vietnamese-wght-normal.woff2 +0 -0
  240. package/dist/css/fonts/font.abril-droidsans.css +3 -0
  241. package/dist/css/fonts/font.amatic-andika.css +57 -0
  242. package/dist/css/fonts/font.bitter-raleway.css +111 -0
  243. package/dist/css/fonts/font.cairo.css +312 -0
  244. package/dist/css/fonts/font.clicker-garamond.css +93 -0
  245. package/dist/css/fonts/font.dancing-ledger.css +12 -0
  246. package/dist/css/fonts/font.default.css +183 -0
  247. package/dist/css/fonts/font.fjalla-average.css +3 -0
  248. package/dist/css/fonts/font.georgia-helvetica.css +3 -0
  249. package/dist/css/fonts/font.knightlab.css +642 -0
  250. package/dist/css/fonts/font.lustria-lato.css +3 -0
  251. package/dist/css/fonts/font.medula-lato.css +3 -0
  252. package/dist/css/fonts/font.oldstandard.css +84 -0
  253. package/dist/css/fonts/font.opensans-gentiumbook.css +183 -0
  254. package/dist/css/fonts/font.playfair-faunaone.css +39 -0
  255. package/dist/css/fonts/font.playfair.css +147 -0
  256. package/dist/css/fonts/font.pt.css +183 -0
  257. package/dist/css/fonts/font.roboto-megrim.css +3 -0
  258. package/dist/css/fonts/font.rufina-sintony.css +3 -0
  259. package/dist/css/fonts/font.ubuntu.css +291 -0
  260. package/dist/css/fonts/font.unicaone-vollkorn.css +84 -0
  261. package/dist/css/storymap.css +1 -1
  262. package/dist/demo.html +2 -2
  263. package/dist/docs/api/.nojekyll +1 -0
  264. package/dist/docs/api/assets/custom.css +47 -0
  265. package/dist/docs/api/assets/hierarchy.js +1 -0
  266. package/dist/docs/api/assets/highlight.css +57 -0
  267. package/dist/docs/api/assets/icons.js +18 -0
  268. package/dist/docs/api/assets/icons.svg +1 -0
  269. package/dist/docs/api/assets/main.js +60 -0
  270. package/dist/docs/api/assets/navigation.js +1 -0
  271. package/dist/docs/api/assets/search.js +1 -0
  272. package/dist/docs/api/assets/style.css +1648 -0
  273. package/dist/docs/api/classes/StoryMap.html +1604 -0
  274. package/dist/docs/api/functions/MediaType.html +51 -0
  275. package/dist/docs/api/functions/isPresentation3Collection.html +47 -0
  276. package/dist/docs/api/functions/isPresentation3Manifest.html +53 -0
  277. package/dist/docs/api/functions/loadCSS.html +63 -0
  278. package/dist/docs/api/functions/manifestToStorymapData.html +49 -0
  279. package/dist/docs/api/functions/setLanguage.html +54 -0
  280. package/dist/docs/api/functions/storymapToManifest.html +57 -0
  281. package/dist/docs/api/functions/validateStorymap.html +53 -0
  282. package/dist/docs/api/functions/validateStorymapAndReport.html +54 -0
  283. package/dist/docs/api/hierarchy.html +28 -0
  284. package/dist/docs/api/index.html +197 -0
  285. package/dist/docs/api/interfaces/AnimationHandle.html +60 -0
  286. package/dist/docs/api/interfaces/ImagereadyPayload.html +71 -0
  287. package/dist/docs/api/interfaces/LanguageEntry.html +102 -0
  288. package/dist/docs/api/interfaces/LatLngLiteral.html +73 -0
  289. package/dist/docs/api/interfaces/LoadOptions.html +59 -0
  290. package/dist/docs/api/interfaces/OlLayer.html +1400 -0
  291. package/dist/docs/api/interfaces/OlMap.html +1656 -0
  292. package/dist/docs/api/interfaces/OlProjection.html +332 -0
  293. package/dist/docs/api/interfaces/OlSource.html +784 -0
  294. package/dist/docs/api/interfaces/OlTileLayer.html +1497 -0
  295. package/dist/docs/api/interfaces/OlVectorLayer.html +1559 -0
  296. package/dist/docs/api/interfaces/OlView.html +1958 -0
  297. package/dist/docs/api/interfaces/StorymapData.html +71 -0
  298. package/dist/docs/api/interfaces/StorymapDataWrapper.html +60 -0
  299. package/dist/docs/api/interfaces/StorymapDocument.html +68 -0
  300. package/dist/docs/api/interfaces/StorymapError.html +63 -0
  301. package/dist/docs/api/interfaces/StorymapGeoreference.html +93 -0
  302. package/dist/docs/api/interfaces/StorymapGeoreferenceBody.html +74 -0
  303. package/dist/docs/api/interfaces/StorymapGroundControlPoint.html +69 -0
  304. package/dist/docs/api/interfaces/StorymapLanguageMap.html +57 -0
  305. package/dist/docs/api/interfaces/StorymapManifest.html +118 -0
  306. package/dist/docs/api/interfaces/StorymapManifestAnnotation.html +105 -0
  307. package/dist/docs/api/interfaces/StorymapManifestBackground.html +81 -0
  308. package/dist/docs/api/interfaces/StorymapManifestCanvas.html +140 -0
  309. package/dist/docs/api/interfaces/StorymapManifestContentResource.html +97 -0
  310. package/dist/docs/api/interfaces/StorymapManifestFeatureCollection.html +82 -0
  311. package/dist/docs/api/interfaces/StorymapManifestGeoreferencing.html +87 -0
  312. package/dist/docs/api/interfaces/StorymapManifestImageService.html +69 -0
  313. package/dist/docs/api/interfaces/StorymapManifestRange.html +75 -0
  314. package/dist/docs/api/interfaces/StorymapManifestService.html +77 -0
  315. package/dist/docs/api/interfaces/StorymapManifestSpecificResource.html +69 -0
  316. package/dist/docs/api/interfaces/StorymapManifestStatement.html +63 -0
  317. package/dist/docs/api/interfaces/StorymapMapOptions.html +92 -0
  318. package/dist/docs/api/interfaces/StorymapOptions.html +532 -0
  319. package/dist/docs/api/interfaces/StorymapOverlayLayer.html +126 -0
  320. package/dist/docs/api/interfaces/StorymapSlide.html +132 -0
  321. package/dist/docs/api/interfaces/StorymapSlideBackground.html +72 -0
  322. package/dist/docs/api/interfaces/StorymapSlideLocation.html +117 -0
  323. package/dist/docs/api/interfaces/StorymapSlideMarker.html +104 -0
  324. package/dist/docs/api/interfaces/StorymapSlideMedia.html +110 -0
  325. package/dist/docs/api/interfaces/StorymapSlideNarration.html +65 -0
  326. package/dist/docs/api/interfaces/StorymapSlideText.html +72 -0
  327. package/dist/docs/api/interfaces/StorymapTilejson.html +105 -0
  328. package/dist/docs/api/media/es.json +31 -0
  329. package/dist/docs/api/media/migration-from-knightlab.md +359 -0
  330. package/dist/docs/api/modules.html +50 -0
  331. package/dist/docs/api/types/ContentState.html +70 -0
  332. package/dist/docs/api/types/ImagereadyKind.html +34 -0
  333. package/dist/docs/api/types/MediaTypeMatch.html +78 -0
  334. package/dist/docs/api/types/TileSourceFactory.html +53 -0
  335. package/dist/docs/authoring.html +293 -0
  336. package/dist/docs/iiif.html +617 -62
  337. package/dist/docs/migration.html +313 -11
  338. package/dist/docs/readme.html +40 -9
  339. package/dist/embed/index.html +1 -1
  340. package/dist/examples/issue-242-nonlatin.json +55 -0
  341. package/dist/examples/issue-305-start-at-slide-data.json +96 -0
  342. package/dist/examples/issue-358-iframe.json +57 -0
  343. package/dist/examples/issue-iiif-geo.json +32 -0
  344. package/dist/examples/issue-image-alt.json +23 -0
  345. package/dist/examples/issue-image-region.json +42 -0
  346. package/dist/examples/issue-image-srcset.json +24 -0
  347. package/dist/examples/map-area-overlays.json +68 -0
  348. package/dist/examples/no-map.json +44 -0
  349. package/dist/examples-iiif/annotated-image.json +188 -0
  350. package/dist/examples-iiif/bob-boilen.json +11 -5
  351. package/dist/examples-iiif/courbet.json +9 -6
  352. package/dist/examples-iiif/empty.json +8 -4
  353. package/dist/examples-iiif/flickr-drewvigal.json +248 -24
  354. package/dist/examples-iiif/flickr-zach.wise.json +8 -4
  355. package/dist/examples-iiif/georeferenced-layer-unsupported.json +153 -0
  356. package/dist/examples-iiif/georeferenced-layer.json +318 -0
  357. package/dist/examples-iiif/iiif-wellcome.json +9 -6
  358. package/dist/examples-iiif/instagram-chenpamela.json +381 -36
  359. package/dist/examples-iiif/instagram_brentfoster.json +429 -40
  360. package/dist/examples-iiif/instagram_couch.json +261 -26
  361. package/dist/examples-iiif/instagram_joegermuska.json +441 -41
  362. package/dist/examples-iiif/instagram_matteich.json +261 -26
  363. package/dist/examples-iiif/instagram_mirandamulligan.json +261 -26
  364. package/dist/examples-iiif/instagram_raymondmccrea.json +261 -26
  365. package/dist/examples-iiif/instagram_test.json +261 -26
  366. package/dist/examples-iiif/issue-134-no-lines.json +9 -5
  367. package/dist/examples-iiif/issue-177-color-vars.json +9 -5
  368. package/dist/examples-iiif/issue-211-rtl.json +9 -5
  369. package/dist/examples-iiif/issue-242-nonlatin.json +239 -0
  370. package/dist/examples-iiif/issue-272-facebook.json +9 -5
  371. package/dist/examples-iiif/issue-305-start-at-slide-data.json +305 -0
  372. package/dist/examples-iiif/issue-305-start-at-slide.json +9 -5
  373. package/dist/examples-iiif/issue-341-distance.json +9 -5
  374. package/dist/examples-iiif/issue-358-iframe.json +190 -0
  375. package/dist/examples-iiif/issue-360-juxtapose.json +22 -7
  376. package/dist/examples-iiif/issue-368-null-fields.json +9 -5
  377. package/dist/examples-iiif/issue-375-code-lists.json +9 -5
  378. package/dist/examples-iiif/issue-381-dateline.json +9 -5
  379. package/dist/examples-iiif/issue-405-custom-icon.json +203 -0
  380. package/dist/examples-iiif/issue-425-many-slides.json +9 -5
  381. package/dist/examples-iiif/issue-428-background.json +20 -8
  382. package/dist/examples-iiif/issue-435-googlemaps.json +9 -5
  383. package/dist/examples-iiif/issue-437-documentcloud.json +22 -7
  384. package/dist/examples-iiif/issue-451-webp.json +9 -5
  385. package/dist/examples-iiif/issue-455-audio.json +9 -5
  386. package/dist/examples-iiif/issue-480-shorts.json +9 -5
  387. package/dist/examples-iiif/issue-506-marker-sync.json +9 -5
  388. package/dist/examples-iiif/issue-iiif-geo.json +200 -0
  389. package/dist/examples-iiif/issue-image-alt.json +90 -0
  390. package/dist/examples-iiif/issue-image-region.json +203 -0
  391. package/dist/examples-iiif/issue-image-srcset.json +87 -0
  392. package/dist/examples-iiif/jansteen.json +9 -6
  393. package/dist/examples-iiif/katrina.json +197 -41
  394. package/dist/examples-iiif/map-area-overlays.json +289 -0
  395. package/dist/examples-iiif/marktwain.json +255 -58
  396. package/dist/examples-iiif/no-map.json +148 -0
  397. package/dist/examples-iiif/population.json +440 -76
  398. package/dist/examples-iiif/president.json +456 -83
  399. package/dist/examples-iiif/scroll-hint.json +190 -0
  400. package/dist/examples-iiif/seurat.json +113 -22
  401. package/dist/examples-iiif/zoomify-clamp.json +153 -0
  402. package/dist/harness-multi.html +17 -0
  403. package/dist/harness.html +3 -2
  404. package/dist/index.html +16 -10
  405. package/dist/js/storymap.d.ts +1439 -114
  406. package/dist/js/storymap.js +24654 -21936
  407. package/dist/js/storymap.js.map +1 -1
  408. package/dist/navplace-properties.json +40 -0
  409. package/docs/migration-from-knightlab.md +229 -9
  410. package/docs/storymap-as-iiif-manifest.md +559 -135
  411. package/package.json +11 -8
  412. package/schema/storymap.schema.json +431 -78
  413. package/src/animation/easings.ts +8 -1
  414. package/src/animation/tween.ts +186 -0
  415. package/src/core/Browser.ts +6 -5
  416. package/src/core/Load.ts +54 -2
  417. package/src/core/Util.ts +255 -193
  418. package/src/core/mixins.ts +24 -7
  419. package/src/core/viewers.ts +94 -0
  420. package/src/dom/DomEvent.ts +27 -8
  421. package/src/globals.d.ts +8 -0
  422. package/src/language/Language.ts +199 -25
  423. package/src/language/locale/.expected-gaps.json +86 -0
  424. package/src/language/locale/be.json +15 -2
  425. package/src/language/locale/bg.json +15 -2
  426. package/src/language/locale/cs.json +15 -2
  427. package/src/language/locale/de.json +12 -1
  428. package/src/language/locale/el.json +15 -2
  429. package/src/language/locale/en.json +6 -2
  430. package/src/language/locale/es.json +15 -2
  431. package/src/language/locale/et.json +5 -1
  432. package/src/language/locale/fr.json +15 -2
  433. package/src/language/locale/he.json +16 -3
  434. package/src/language/locale/hu.json +15 -2
  435. package/src/language/locale/is.json +5 -1
  436. package/src/language/locale/it.json +15 -2
  437. package/src/language/locale/jp.json +15 -2
  438. package/src/language/locale/ko.json +15 -2
  439. package/src/language/locale/nl.json +15 -2
  440. package/src/language/locale/nn.json +5 -1
  441. package/src/language/locale/no.json +15 -2
  442. package/src/language/locale/pl.json +15 -2
  443. package/src/language/locale/pt.json +15 -2
  444. package/src/language/locale/ru.json +15 -2
  445. package/src/language/locale/sk.json +15 -2
  446. package/src/language/locale/sr.json +5 -1
  447. package/src/language/locale/sv.json +15 -2
  448. package/src/language/locale/tr.json +15 -2
  449. package/src/language/locale/uk.json +15 -2
  450. package/src/language/locale/ur.json +5 -1
  451. package/src/language/locale/zh-cn.json +15 -2
  452. package/src/language/locale/zh-tw.json +15 -2
  453. package/src/main.ts +92 -0
  454. package/src/map/Map.ts +183 -106
  455. package/src/map/MapMarker.ts +28 -5
  456. package/src/map/georeference.ts +325 -0
  457. package/src/map/openlayers/Map.OpenLayers.ts +850 -237
  458. package/src/map/openlayers/MapMarker.OpenLayers.ts +248 -24
  459. package/src/map/openlayers/zoomifyTiles.ts +1 -4
  460. package/src/media/EmbedUtil.ts +308 -24
  461. package/src/media/Media.ts +243 -55
  462. package/src/media/embedId.ts +104 -0
  463. package/src/media/types/Audio.ts +9 -82
  464. package/src/media/types/Blockquote.ts +2 -3
  465. package/src/media/types/DailyMotion.ts +5 -10
  466. package/src/media/types/DocumentCloud.ts +6 -35
  467. package/src/media/types/Facebook.ts +3 -4
  468. package/src/media/types/Flickr.ts +9 -9
  469. package/src/media/types/GoogleDoc.ts +3 -4
  470. package/src/media/types/HtmlMedia.ts +167 -0
  471. package/src/media/types/IFrame.ts +3 -4
  472. package/src/media/types/Image.ts +72 -6
  473. package/src/media/types/Juxtapose.ts +14 -5
  474. package/src/media/types/SoundCloud.ts +28 -6
  475. package/src/media/types/Text.ts +19 -13
  476. package/src/media/types/Twitter.ts +27 -14
  477. package/src/media/types/Video.ts +9 -79
  478. package/src/media/types/Vimeo.ts +10 -10
  479. package/src/media/types/Website.ts +23 -13
  480. package/src/media/types/Wikipedia.ts +18 -8
  481. package/src/media/types/YouTube.ts +71 -39
  482. package/src/scss/Typography.scss +1 -3
  483. package/src/scss/VCO.StoryMap.Dark.scss +18 -6
  484. package/src/scss/VCO.StoryMap.scss +37 -6
  485. package/src/scss/Variables.Dark.scss +3 -2
  486. package/src/scss/Variables.scss +3 -2
  487. package/src/scss/core/Mixins.scss +5 -63
  488. package/src/scss/fonts/font.amatic-andika.scss +3 -0
  489. package/src/scss/fonts/font.bitter-raleway.scss +6 -0
  490. package/src/scss/fonts/font.cairo.scss +79 -0
  491. package/src/scss/fonts/font.clicker-garamond.scss +5 -0
  492. package/src/scss/fonts/font.dancing-ledger.scss +1 -0
  493. package/src/scss/fonts/font.knightlab.scss +14 -0
  494. package/src/scss/fonts/font.oldstandard.scss +6 -0
  495. package/src/scss/fonts/font.opensans-gentiumbook.scss +5 -0
  496. package/src/scss/fonts/font.playfair-faunaone.scss +2 -0
  497. package/src/scss/fonts/font.playfair.scss +4 -0
  498. package/src/scss/fonts/font.pt.scss +6 -0
  499. package/src/scss/fonts/font.ubuntu.scss +4 -0
  500. package/src/scss/fonts/font.unicaone-vollkorn.scss +5 -0
  501. package/src/scss/map/VCO.MapMarker.scss +98 -3
  502. package/src/scss/media/VCO.Media.scss +7 -10
  503. package/src/scss/media/types/VCO.Media.Text.scss +8 -0
  504. package/src/scss/media/types/VCO.Media.Wikipedia.scss +2 -1
  505. package/src/scss/slider/VCO.Slide.scss +13 -3
  506. package/src/scss/slider/VCO.SlideNav.scss +11 -17
  507. package/src/scss/slider/VCO.StorySlider.scss +4 -2
  508. package/src/scss/ui/VCO.Consent.scss +44 -0
  509. package/src/scss/ui/VCO.MenuBar.Button.scss +8 -0
  510. package/src/scss/ui/VCO.Message.scss +2 -5
  511. package/src/site/site.ts +2 -1
  512. package/src/slider/Slide.ts +121 -27
  513. package/src/slider/SlideNav.ts +39 -5
  514. package/src/slider/StorySlider.ts +153 -42
  515. package/src/storymap/Consent.ts +407 -82
  516. package/src/storymap/StoryMap.ts +1192 -182
  517. package/src/storymap/content-state.ts +185 -0
  518. package/src/storymap/iiif.ts +1526 -68
  519. package/src/storymap/to-iiif.ts +921 -0
  520. package/src/storymap/validate.ts +80 -7
  521. package/src/types.ts +223 -26
  522. package/src/ui/MenuBar.ts +98 -45
  523. package/src/ui/Message.ts +9 -0
  524. package/src/ui/Swipable.ts +145 -78
  525. package/dist/assets/demo-D6cTmzn6.js +0 -1
  526. package/dist/assets/harness-BPFlzutp.js +0 -1
  527. package/dist/assets/index-DDsPBPZA.js +0 -1
  528. package/dist/assets/main-Y-MYUSJ4.js +0 -17
  529. package/src/animation/morpheus.d.ts +0 -41
  530. package/src/scss/media/types/VCO.Media.Instagram.scss +0 -7
  531. package/src/scss/media/types/VCO.Media.Profile.scss +0 -5
  532. package/src/ui/Draggable.ts +0 -374
@@ -6,11 +6,15 @@
6
6
 
7
7
  import type {
8
8
  StorymapData,
9
+ StorymapGeoreference,
10
+ StorymapTilejson,
11
+ StorymapOverlayLayer,
9
12
  StorymapSlide,
10
13
  StorymapSlideBackground,
11
14
  StorymapSlideLocation,
12
15
  StorymapSlideMedia,
13
16
  } from "../types";
17
+ import { readGroundControlPoints } from "../map/georeference";
14
18
 
15
19
  const PRESENTATION_3_CONTEXT = "iiif.io/api/presentation/3/context.json";
16
20
  const MAPCONFIG_PROFILE = "mapconfig";
@@ -25,6 +29,11 @@ const LOCATION_PROPERTIES = [
25
29
  "iconSize",
26
30
  "image",
27
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",
28
37
  ] as const;
29
38
 
30
39
  function asRecord(value: unknown): Record<string, unknown> | null {
@@ -54,6 +63,223 @@ function asStringArray(value: unknown): string[] {
54
63
  return [];
55
64
  }
56
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
+
57
283
  /**
58
284
  * Flattens a IIIF language map (`{"none": ["text"]}`, `{"en": ["Hello"]}`) to a
59
285
  * plain string. The `none` language is preferred; entries are joined with a
@@ -78,6 +304,46 @@ export function flattenLanguageMap(value: unknown): string {
78
304
  return parts.join(" ").trim();
79
305
  }
80
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
+
81
347
  /**
82
348
  * Reads a StoryMap extension term from a Canvas or service object. Manifests
83
349
  * use the `storymap:`-prefixed form, but JSON-LD processors may emit the bare
@@ -92,10 +358,20 @@ function readTerm(record: Record<string, unknown>, term: string): unknown {
92
358
  return term === "type" ? undefined : record[term];
93
359
  }
94
360
 
95
- /** True when `data` looks like a Presentation API 3.0 manifest. */
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
+ */
96
371
  export function isPresentation3Manifest(data: unknown): boolean {
97
372
  const record = asRecord(data);
98
373
  if (!record) return false;
374
+ if (record.type === "Collection") return false;
99
375
  if (record.type === "Manifest") return true;
100
376
  const context = record["@context"];
101
377
  const candidates = Array.isArray(context) ? context : [context];
@@ -104,10 +380,160 @@ export function isPresentation3Manifest(data: unknown): boolean {
104
380
  );
105
381
  }
106
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
+
107
447
  /**
108
448
  * Finds the manifest-level map configuration service (profile containing
109
449
  * "mapconfig") and returns its properties, or null when absent.
110
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
+
111
537
  function readMapConfig(manifest: Record<string, unknown>): Record<string, unknown> | null {
112
538
  const services = Array.isArray(manifest.service) ? manifest.service : [manifest.service];
113
539
  for (const service of services) {
@@ -121,12 +547,113 @@ function readMapConfig(manifest: Record<string, unknown>): Record<string, unknow
121
547
  return null;
122
548
  }
123
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
+
124
645
  /**
125
646
  * Reads the slide media URL from the canvas's painting annotation body:
126
647
  * typed bodies contribute their `id`, TextualBody (HTML) content its `value`
127
- * - the legacy format stores both in `media.url`.
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}.
128
651
  */
129
- function readPaintingBodyUrl(canvas: Record<string, unknown>): string | null {
652
+ function readPainting(
653
+ canvas: Record<string, unknown>,
654
+ width: number | null = null,
655
+ height: number | null = null,
656
+ ): PaintingBody | null {
130
657
  const annotationPages = Array.isArray(canvas.items) ? canvas.items : [];
131
658
  for (const page of annotationPages) {
132
659
  const pageRecord = asRecord(page);
@@ -137,27 +664,33 @@ function readPaintingBodyUrl(canvas: Record<string, unknown>): string | null {
137
664
  if (!annotationRecord) continue;
138
665
  const motivation = asString(annotationRecord.motivation);
139
666
  if (motivation !== null && motivation !== "painting") continue;
140
- const url = readBodyUrl(annotationRecord.body);
141
- if (url !== null) return url;
142
- }
143
- }
144
- return null;
145
- }
146
-
147
- function readBodyUrl(body: unknown): string | null {
148
- if (Array.isArray(body)) {
149
- for (const entry of body) {
150
- const url = readBodyUrl(entry);
151
- if (url !== null) return url;
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
+ };
152
692
  }
153
- return null;
154
693
  }
155
- const record = asRecord(body);
156
- if (!record) return null;
157
- const id = asString(record.id);
158
- if (id !== null) return id;
159
- const value = asString(record.value);
160
- if (value !== null) return value;
161
694
  return null;
162
695
  }
163
696
 
@@ -199,47 +732,258 @@ function readFeatureLocation(feature: unknown): StorymapSlideLocation | null {
199
732
  return location;
200
733
  }
201
734
 
202
- function readBackground(value: unknown): StorymapSlideBackground | string | null {
203
- const record = asRecord(value);
204
- if (record) {
205
- const background: StorymapSlideBackground = {};
206
- const url = asString(record.url);
207
- const color = asString(record.color);
208
- if (url !== null) background.url = url;
209
- if (color !== null) background.color = color;
210
- // slide background opacity is accepted but never read by the viewer
211
- return Object.keys(background).length > 0 ? background : null;
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;
212
771
  }
213
- if (typeof value === "string" && value !== "") {
214
- return value;
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;
215
886
  }
216
887
  return null;
217
888
  }
218
889
 
219
- function canvasToSlide(canvas: unknown, manifestFeature: unknown): StorymapSlide | null {
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 {
220
934
  const record = asRecord(canvas);
221
935
  if (!record) return null;
222
936
 
223
937
  const slide: StorymapSlide = {};
224
938
 
225
- // text: Canvas label → headline, Canvas summary → body text
226
- const headline = flattenLanguageMap(record.label);
227
- const text = flattenLanguageMap(record.summary);
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;
228
945
  if (headline !== "" || text !== "") {
229
946
  slide.text = {};
230
947
  if (headline !== "") slide.text.headline = headline;
231
948
  if (text !== "") slide.text.text = text;
232
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;
233
954
 
234
- // media: painting annotation body plus the caption/credit extension terms
235
- const mediaUrl = readPaintingBodyUrl(record);
236
- const caption = asString(readTerm(record, "mediaCaption"));
237
- const credit = asString(readTerm(record, "mediaCredit"));
238
- if (mediaUrl !== null || caption !== null || credit !== null) {
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
+ ) {
239
978
  const media: StorymapSlideMedia = {};
240
- if (mediaUrl !== null) media.url = mediaUrl;
979
+ if (painting !== null) media.url = painting.url;
241
980
  if (caption !== null) media.caption = caption;
242
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;
243
987
  slide.media = media;
244
988
  }
245
989
 
@@ -247,21 +991,485 @@ function canvasToSlide(canvas: unknown, manifestFeature: unknown): StorymapSlide
247
991
  // aggregated in items order (both are allowed by the proposal)
248
992
  const location = readLocation(record.navPlace) ?? readFeatureLocation(manifestFeature);
249
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
+ }
250
1003
 
251
1004
  // StoryMap extension terms
252
1005
  const slideType = asString(record[STORYMAP_PREFIX + "type"]);
253
1006
  if (slideType !== null) slide.type = slideType;
254
- const date = readTerm(record, "date");
255
- const dateString = asString(date);
256
- const dateRecord = asRecord(date);
257
- if (dateString !== null) slide.date = dateString;
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;
258
1014
  else if (dateRecord !== null) slide.date = dateRecord;
259
- const background = readBackground(readTerm(record, "background"));
1015
+ // background: the canvas's standard `background` painting annotation. The
1016
+ // storymap:background term is gone (§2.6).
1017
+ const background = readBackground(record.background);
260
1018
  if (background !== null) slide.background = background;
261
1019
 
262
1020
  return slide;
263
1021
  }
264
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
+
265
1473
  /**
266
1474
  * Converts a IIIF Presentation API 3.0 manifest into legacy StoryMapJS data
267
1475
  * (the `storymap` object). Malformed or missing pieces are skipped; the
@@ -272,25 +1480,85 @@ export function manifestToStorymapData(manifest: unknown): StorymapData {
272
1480
  const record = asRecord(manifest);
273
1481
  if (!record) return data;
274
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
+
275
1489
  const title = flattenLanguageMap(record.label);
276
1490
  if (title !== "") data.title = title;
277
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
+
278
1498
  // Manifest-level map configuration service → storymap options fields.
279
1499
  const config = readMapConfig(record);
280
1500
  if (config) {
281
1501
  applyMapConfig(data, config);
282
1502
  }
283
1503
 
284
- // requiredStatement → iiif.attribution
285
- const requiredStatement = asRecord(record.requiredStatement);
286
- const attribution = requiredStatement ? flattenLanguageMap(requiredStatement.value) : "";
287
- if (attribution !== "") {
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 !== "") {
288
1538
  const iiif = (data.iiif as { url?: string; attribution?: string } | undefined) ?? {};
289
- iiif.attribution = attribution;
1539
+ iiif.attribution = credit;
290
1540
  if (iiif.url === undefined) iiif.url = "";
291
1541
  data.iiif = iiif;
292
1542
  }
293
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
+
294
1562
  // items[] (Canvases) → slides[], in order
295
1563
  const items = Array.isArray(record.items) ? record.items : [];
296
1564
  const manifestNavPlace = asRecord(record.navPlace);
@@ -298,9 +1566,67 @@ export function manifestToStorymapData(manifest: unknown): StorymapData {
298
1566
  manifestNavPlace && Array.isArray(manifestNavPlace.features)
299
1567
  ? manifestNavPlace.features
300
1568
  : [];
301
- for (let index = 0; index < items.length; index++) {
302
- const slide = canvasToSlide(items[index], manifestFeatures[index]);
303
- if (slide !== null) data.slides.push(slide);
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
+ }
304
1630
  }
305
1631
 
306
1632
  return data;
@@ -308,8 +1634,21 @@ export function manifestToStorymapData(manifest: unknown): StorymapData {
308
1634
 
309
1635
  /** Copies the mapconfig service terms onto the storymap data root (legacy keys). */
310
1636
  function applyMapConfig(data: StorymapData, config: Record<string, unknown>): void {
311
- const mapType = asString(readTerm(config, "mapType"));
312
- if (mapType !== null && mapType !== "") data.map_type = mapType;
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
+ }
313
1652
 
314
1653
  const mapAsImage = asBoolean(readTerm(config, "mapAsImage"));
315
1654
  if (mapAsImage !== null) data.map_as_image = mapAsImage;
@@ -327,12 +1666,6 @@ function applyMapConfig(data: StorymapData, config: Record<string, unknown>): vo
327
1666
  if (left !== null && top !== null) data.map_center_offset = { left, top };
328
1667
  }
329
1668
 
330
- const mapSubdomains = asString(readTerm(config, "mapSubdomains"));
331
- if (mapSubdomains !== null) data.map_subdomains = mapSubdomains;
332
-
333
- const iiifUrl = asString(readTerm(config, "iiifUrl"));
334
- if (iiifUrl !== null) data.iiif = { url: iiifUrl, attribution: "" };
335
-
336
1669
  const fontCss = asString(readTerm(config, "fontCss"));
337
1670
  if (fontCss !== null) data.font_css = fontCss;
338
1671
 
@@ -351,9 +1684,6 @@ function applyMapConfig(data: StorymapData, config: Record<string, unknown>): vo
351
1684
  const calculateZoom = asBoolean(readTerm(config, "calculateZoom"));
352
1685
  if (calculateZoom !== null) data.calculate_zoom = calculateZoom;
353
1686
 
354
- const lessBounce = asBoolean(readTerm(config, "lessBounce"));
355
- if (lessBounce !== null) data.less_bounce = lessBounce;
356
-
357
1687
  const lineFollowsPath = asBoolean(readTerm(config, "lineFollowsPath"));
358
1688
  if (lineFollowsPath !== null) data.line_follows_path = lineFollowsPath;
359
1689
 
@@ -383,4 +1713,132 @@ function applyMapConfig(data: StorymapData, config: Record<string, unknown>): vo
383
1713
 
384
1714
  const useCustomMarkers = asBoolean(readTerm(config, "useCustomMarkers"));
385
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;
386
1844
  }