prts-terrarchive 0.2.0

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 (287) hide show
  1. package/GAME_ASSETS.md +26 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +274 -0
  4. package/README.md +423 -0
  5. package/THIRD_PARTY_NOTICES.md +61 -0
  6. package/bin/bake-rhine-details.mjs +34 -0
  7. package/bin/install.js +273 -0
  8. package/bin/pack-map-assets.mjs +110 -0
  9. package/bin/preview-rhine.mjs +71 -0
  10. package/bin/rhine-detail-baker.ts +84 -0
  11. package/contracts/cloud_tools_v1.schema.json +243 -0
  12. package/contracts/corpus_i18n_v1.schema.json +179 -0
  13. package/contracts/corpus_tools_v1.schema.json +1801 -0
  14. package/cordis.patch.yml +32 -0
  15. package/docs/endfield-official-i18n.md +103 -0
  16. package/lib/client.js +3912 -0
  17. package/lib/endfield-map/map.js.br +0 -0
  18. package/lib/endfield-map/map.js.gz +0 -0
  19. package/lib/endfield-map/resources/map-00b0d0744a1b4404.json.br +0 -0
  20. package/lib/endfield-map/resources/map-00b0d0744a1b4404.json.gz +0 -0
  21. package/lib/endfield-map/resources/map-01616f534aaaeca1.png +0 -0
  22. package/lib/endfield-map/resources/map-1562e0dff4be3d23.png +0 -0
  23. package/lib/endfield-map/resources/map-160fe040f080f6a3.png +0 -0
  24. package/lib/endfield-map/resources/map-17b9d8be25dc2b39.json.br +0 -0
  25. package/lib/endfield-map/resources/map-17b9d8be25dc2b39.json.gz +0 -0
  26. package/lib/endfield-map/resources/map-1c4245c4e8b80c43.json.br +0 -0
  27. package/lib/endfield-map/resources/map-1c4245c4e8b80c43.json.gz +0 -0
  28. package/lib/endfield-map/resources/map-1d30baaaef3e29cc.png +0 -0
  29. package/lib/endfield-map/resources/map-21a61386af79cd12.json.br +0 -0
  30. package/lib/endfield-map/resources/map-21a61386af79cd12.json.gz +0 -0
  31. package/lib/endfield-map/resources/map-24b920b8dab49b44.json.br +0 -0
  32. package/lib/endfield-map/resources/map-24b920b8dab49b44.json.gz +0 -0
  33. package/lib/endfield-map/resources/map-26caf8d71247abb7.png +0 -0
  34. package/lib/endfield-map/resources/map-2729942c89545e73.json.br +0 -0
  35. package/lib/endfield-map/resources/map-2729942c89545e73.json.gz +0 -0
  36. package/lib/endfield-map/resources/map-29063a9802f67014.json.br +0 -0
  37. package/lib/endfield-map/resources/map-29063a9802f67014.json.gz +0 -0
  38. package/lib/endfield-map/resources/map-2f35d09a3ab60a63.json.br +0 -0
  39. package/lib/endfield-map/resources/map-2f35d09a3ab60a63.json.gz +0 -0
  40. package/lib/endfield-map/resources/map-2f98a873da81425e.json.br +0 -0
  41. package/lib/endfield-map/resources/map-2f98a873da81425e.json.gz +0 -0
  42. package/lib/endfield-map/resources/map-3ab978ae44190f80.json.br +0 -0
  43. package/lib/endfield-map/resources/map-3ab978ae44190f80.json.gz +0 -0
  44. package/lib/endfield-map/resources/map-3b62c80d14208902.json.br +0 -0
  45. package/lib/endfield-map/resources/map-3b62c80d14208902.json.gz +0 -0
  46. package/lib/endfield-map/resources/map-3d486e887ae121ac.png +0 -0
  47. package/lib/endfield-map/resources/map-418dc4865608fce5.png +0 -0
  48. package/lib/endfield-map/resources/map-4366b84607335f76.json.br +0 -0
  49. package/lib/endfield-map/resources/map-4366b84607335f76.json.gz +0 -0
  50. package/lib/endfield-map/resources/map-4371259d3ab7aac7.png +0 -0
  51. package/lib/endfield-map/resources/map-48dcc0fd55e13f7f.png +0 -0
  52. package/lib/endfield-map/resources/map-4aabda0e41197570.json.br +0 -0
  53. package/lib/endfield-map/resources/map-4aabda0e41197570.json.gz +0 -0
  54. package/lib/endfield-map/resources/map-505f5687b287043c.json.br +0 -0
  55. package/lib/endfield-map/resources/map-505f5687b287043c.json.gz +0 -0
  56. package/lib/endfield-map/resources/map-5177e9d1d46f47e2.png +0 -0
  57. package/lib/endfield-map/resources/map-51baeb59b7f8c28d.png +0 -0
  58. package/lib/endfield-map/resources/map-549561b46e621874.png +0 -0
  59. package/lib/endfield-map/resources/map-56c6a0bef799c8dc.json.br +0 -0
  60. package/lib/endfield-map/resources/map-56c6a0bef799c8dc.json.gz +0 -0
  61. package/lib/endfield-map/resources/map-5a6963909423b674.png +0 -0
  62. package/lib/endfield-map/resources/map-5a9c533c0552d7b7.png +0 -0
  63. package/lib/endfield-map/resources/map-5d20ae9ffa9a02b0.png +0 -0
  64. package/lib/endfield-map/resources/map-626c8847393f60bf.png +0 -0
  65. package/lib/endfield-map/resources/map-63d43cbec3bed3b8.png +0 -0
  66. package/lib/endfield-map/resources/map-661b6ec56e722574.png +0 -0
  67. package/lib/endfield-map/resources/map-6ddd384eca190828.png +0 -0
  68. package/lib/endfield-map/resources/map-6fc1aa9144f8156c.json.br +0 -0
  69. package/lib/endfield-map/resources/map-6fc1aa9144f8156c.json.gz +0 -0
  70. package/lib/endfield-map/resources/map-738ebaa002c33cfc.png +0 -0
  71. package/lib/endfield-map/resources/map-758c13be6085ec07.json.br +0 -0
  72. package/lib/endfield-map/resources/map-758c13be6085ec07.json.gz +0 -0
  73. package/lib/endfield-map/resources/map-78929f26d6844509.png +0 -0
  74. package/lib/endfield-map/resources/map-7f09c142950a0f74.png +0 -0
  75. package/lib/endfield-map/resources/map-8385e11f39e1a51c.json.br +0 -0
  76. package/lib/endfield-map/resources/map-8385e11f39e1a51c.json.gz +0 -0
  77. package/lib/endfield-map/resources/map-881343926a568f27.json.br +0 -0
  78. package/lib/endfield-map/resources/map-881343926a568f27.json.gz +0 -0
  79. package/lib/endfield-map/resources/map-8d1ade3f01d952b0.png +0 -0
  80. package/lib/endfield-map/resources/map-8fef3495bb7ed60f.png +0 -0
  81. package/lib/endfield-map/resources/map-907a7ecf8a0ce352.png +0 -0
  82. package/lib/endfield-map/resources/map-9113d5951c4db903.json.br +0 -0
  83. package/lib/endfield-map/resources/map-9113d5951c4db903.json.gz +0 -0
  84. package/lib/endfield-map/resources/map-94ad6634436224e3.json.br +0 -0
  85. package/lib/endfield-map/resources/map-94ad6634436224e3.json.gz +0 -0
  86. package/lib/endfield-map/resources/map-99722e61da5f3e53.png +0 -0
  87. package/lib/endfield-map/resources/map-9be9596cc059abe3.png +0 -0
  88. package/lib/endfield-map/resources/map-a4db9a4763e47fbf.png +0 -0
  89. package/lib/endfield-map/resources/map-a4e8ef07159997e7.json.br +0 -0
  90. package/lib/endfield-map/resources/map-a4e8ef07159997e7.json.gz +0 -0
  91. package/lib/endfield-map/resources/map-ab35d06875ee418f.png +0 -0
  92. package/lib/endfield-map/resources/map-ac51bb3e7623cd8b.png +0 -0
  93. package/lib/endfield-map/resources/map-ac830a66296bc83a.png +0 -0
  94. package/lib/endfield-map/resources/map-b3fd04209718e55e.json.br +0 -0
  95. package/lib/endfield-map/resources/map-b3fd04209718e55e.json.gz +0 -0
  96. package/lib/endfield-map/resources/map-b8164f1af7c5d895.png +0 -0
  97. package/lib/endfield-map/resources/map-b8cde93b965384d8.json.br +0 -0
  98. package/lib/endfield-map/resources/map-b8cde93b965384d8.json.gz +0 -0
  99. package/lib/endfield-map/resources/map-b8e48511502e0e8d.json.br +0 -0
  100. package/lib/endfield-map/resources/map-b8e48511502e0e8d.json.gz +0 -0
  101. package/lib/endfield-map/resources/map-bfc58277d5ba649d.png +0 -0
  102. package/lib/endfield-map/resources/map-c013f82048c6891a.png +0 -0
  103. package/lib/endfield-map/resources/map-c49c947841ae4ab5.json.br +0 -0
  104. package/lib/endfield-map/resources/map-c49c947841ae4ab5.json.gz +0 -0
  105. package/lib/endfield-map/resources/map-c4d1b1c6d6706c81.json.br +0 -0
  106. package/lib/endfield-map/resources/map-c4d1b1c6d6706c81.json.gz +0 -0
  107. package/lib/endfield-map/resources/map-c607f8b4c8ad8967.png +0 -0
  108. package/lib/endfield-map/resources/map-c6f20276b8781326.json.br +0 -0
  109. package/lib/endfield-map/resources/map-c6f20276b8781326.json.gz +0 -0
  110. package/lib/endfield-map/resources/map-d51adf5952af1e8e.png +0 -0
  111. package/lib/endfield-map/resources/map-d695eface22f8b74.json.br +0 -0
  112. package/lib/endfield-map/resources/map-d695eface22f8b74.json.gz +0 -0
  113. package/lib/endfield-map/resources/map-db11d394456a7465.png +0 -0
  114. package/lib/endfield-map/resources/map-dcc4d218581e3dc6.png +0 -0
  115. package/lib/endfield-map/resources/map-ddd43dbb8ab8a4e3.json.br +0 -0
  116. package/lib/endfield-map/resources/map-ddd43dbb8ab8a4e3.json.gz +0 -0
  117. package/lib/endfield-map/resources/map-defe5db7dabf748a.png +0 -0
  118. package/lib/endfield-map/resources/map-e1aeb833e22520aa.json.br +0 -0
  119. package/lib/endfield-map/resources/map-e1aeb833e22520aa.json.gz +0 -0
  120. package/lib/endfield-map/resources/map-e25ee9af6e493500.png +0 -0
  121. package/lib/endfield-map/resources/map-e3824082cd6f747d.json.br +0 -0
  122. package/lib/endfield-map/resources/map-e3824082cd6f747d.json.gz +0 -0
  123. package/lib/endfield-map/resources/map-e694a91e630b1bd6.png +0 -0
  124. package/lib/endfield-map/resources/map-eaa93af79d12a87d.png +0 -0
  125. package/lib/endfield-map/resources/map-ec9575ee65d7766f.json.br +0 -0
  126. package/lib/endfield-map/resources/map-ec9575ee65d7766f.json.gz +0 -0
  127. package/lib/endfield-map/resources/map-ede421a329dc78a4.png +0 -0
  128. package/lib/endfield-map/resources/map-eff3bce8f6888fc5.json.br +0 -0
  129. package/lib/endfield-map/resources/map-eff3bce8f6888fc5.json.gz +0 -0
  130. package/lib/endfield-map/resources/map-fb5f4a59a181452c.png +0 -0
  131. package/lib/endfield-map/resources/map-fdeaa17eeba90d8e.json.br +0 -0
  132. package/lib/endfield-map/resources/map-fdeaa17eeba90d8e.json.gz +0 -0
  133. package/lib/rhine/assets/archive-assembly.glb +0 -0
  134. package/lib/rhine/assets/archive-cassette.glb +0 -0
  135. package/lib/rhine/fonts/MiSans-Bold.woff2 +0 -0
  136. package/lib/rhine/fonts/MiSans-Demibold.woff2 +0 -0
  137. package/lib/rhine/fonts/MiSans-Light.woff2 +0 -0
  138. package/lib/rhine/fonts/MiSans-Regular.woff2 +0 -0
  139. package/lib/rhine/fonts/MiSans-license.pdf +0 -0
  140. package/lib/rhine/fonts/NOTICE.txt +6 -0
  141. package/lib/rhine/licenses/RhineLabUI-MIT.txt +21 -0
  142. package/lib/rhine/licenses/entities-BSD-2-Clause.txt +11 -0
  143. package/lib/rhine/licenses/linkify-it-MIT.txt +22 -0
  144. package/lib/rhine/licenses/markdown-it-MIT.txt +22 -0
  145. package/lib/rhine/licenses/mdurl-MIT.txt +45 -0
  146. package/lib/rhine/licenses/punycode.js-MIT.txt +20 -0
  147. package/lib/rhine/licenses/rolling-number-MIT.txt +21 -0
  148. package/lib/rhine/licenses/three-MIT.txt +21 -0
  149. package/lib/rhine/licenses/uc.micro-MIT.txt +20 -0
  150. package/lib/rhine/rhine.css +1 -0
  151. package/lib/rhine/rhine.js +5106 -0
  152. package/lib/skins/common.css +31 -0
  153. package/lib/skins/endfield-aic.css +141 -0
  154. package/lib/skins/prts-agent.css +136 -0
  155. package/lib/skins/rhine-lab.css +70 -0
  156. package/package.json +112 -0
  157. package/presets/definition.js +26 -0
  158. package/presets/prts/agent.cordis.yml +25 -0
  159. package/presets/prts/preset.yml +3 -0
  160. package/presets/register.js +86 -0
  161. package/resources/entity-routing.json +21 -0
  162. package/skills/prts-investigation/SKILL.md +121 -0
  163. package/skills/prts-retrieval/SKILL.md +35 -0
  164. package/skills/prts-retrieval/references/cloud-tools.md +13 -0
  165. package/skills/prts-retrieval/references/diagnostics.md +15 -0
  166. package/skills/prts-retrieval/references/local-tools.md +24 -0
  167. package/skills/prts-retrieval/references/module-arknights.md +23 -0
  168. package/skills/prts-retrieval/references/module-dual.md +15 -0
  169. package/skills/prts-retrieval/references/module-endfield.md +21 -0
  170. package/skills/prts-retrieval/references/retrieval-process.md +44 -0
  171. package/skills/prts-retrieval/references/retrieval-recipes-dual.md +34 -0
  172. package/skills/prts-retrieval/references/retrieval-recipes-endfield.md +37 -0
  173. package/skills/prts-retrieval/references/retrieval-recipes.md +39 -0
  174. package/skills/prts-retrieval/references/tools-runtime.md +48 -0
  175. package/skills/prts-retrieval/references/wiki-schema.md +38 -0
  176. package/src/archive-presentation.js +287 -0
  177. package/src/cloud-projection.js +124 -0
  178. package/src/cloud.js +498 -0
  179. package/src/entity-recognizer.js +311 -0
  180. package/src/entity-routing.js +82 -0
  181. package/src/evidence-state.js +332 -0
  182. package/src/i18n.js +365 -0
  183. package/src/index.js +1261 -0
  184. package/src/installer.js +1362 -0
  185. package/src/investigation-review.js +87 -0
  186. package/src/investigation-store.js +579 -0
  187. package/src/investigation-tools.js +113 -0
  188. package/src/localization-format.js +23 -0
  189. package/src/manual-search.js +103 -0
  190. package/src/read.js +1097 -0
  191. package/src/release-compatibility.js +73 -0
  192. package/src/release-watch.js +40 -0
  193. package/src/search-projection.js +139 -0
  194. package/src/search.js +1522 -0
  195. package/src/skill.js +98 -0
  196. package/src/source-map.js +348 -0
  197. package/src/state.js +314 -0
  198. package/src/store.js +1569 -0
  199. package/src/timeline.js +386 -0
  200. package/src/ui.js +1353 -0
  201. package/src/wiki.js +111 -0
  202. package/tsconfig.rhine.json +15 -0
  203. package/ui/rhine/archive-navigation-limit.ts +31 -0
  204. package/ui/rhine/archive-rack.ts +83 -0
  205. package/ui/rhine/assets/evidence-observatory.webp +0 -0
  206. package/ui/rhine/board-plane-transform.ts +24 -0
  207. package/ui/rhine/catalogue.ts +72 -0
  208. package/ui/rhine/deferred-preparation.ts +146 -0
  209. package/ui/rhine/evidence-board-controls.css +22 -0
  210. package/ui/rhine/evidence-board-history.ts +73 -0
  211. package/ui/rhine/evidence-board-model.ts +189 -0
  212. package/ui/rhine/evidence-board-panel.ts +762 -0
  213. package/ui/rhine/evidence-board-scene.ts +548 -0
  214. package/ui/rhine/evidence-board.css +191 -0
  215. package/ui/rhine/evidence-card-art.ts +470 -0
  216. package/ui/rhine/evidence-card-style.ts +17 -0
  217. package/ui/rhine/evidence-inbox-scene.ts +43 -0
  218. package/ui/rhine/evidence-inbox.css +29 -0
  219. package/ui/rhine/evidence-inbox.ts +104 -0
  220. package/ui/rhine/index.ts +12 -0
  221. package/ui/rhine/interaction.css +20 -0
  222. package/ui/rhine/investigation-board-plane.ts +5 -0
  223. package/ui/rhine/investigation-board.css +34 -0
  224. package/ui/rhine/investigation-board.ts +348 -0
  225. package/ui/rhine/investigation-edits.ts +46 -0
  226. package/ui/rhine/investigation-layout.ts +29 -0
  227. package/ui/rhine/label-prefetch.ts +34 -0
  228. package/ui/rhine/looping-title.ts +40 -0
  229. package/ui/rhine/manual-search.css +41 -0
  230. package/ui/rhine/manual-search.ts +89 -0
  231. package/ui/rhine/original/appearance.ts +180 -0
  232. package/ui/rhine/original/archive-label.ts +84 -0
  233. package/ui/rhine/original/archive-lighting.ts +39 -0
  234. package/ui/rhine/original/archive-loop.ts +82 -0
  235. package/ui/rhine/original/archive-refill.ts +64 -0
  236. package/ui/rhine/original/array-detail-data.ts +2 -0
  237. package/ui/rhine/original/array-detail.ts +41 -0
  238. package/ui/rhine/original/array-interior.ts +411 -0
  239. package/ui/rhine/original/array-occlusion.ts +166 -0
  240. package/ui/rhine/original/array-shell.ts +38 -0
  241. package/ui/rhine/original/array-visibility.ts +309 -0
  242. package/ui/rhine/original/asset-url.ts +4 -0
  243. package/ui/rhine/original/brand.ts +12 -0
  244. package/ui/rhine/original/cassette-lod.ts +81 -0
  245. package/ui/rhine/original/data.ts +21 -0
  246. package/ui/rhine/original/decryption.ts +185 -0
  247. package/ui/rhine/original/detail-signature.ts +18 -0
  248. package/ui/rhine/original/glass-reveal.ts +45 -0
  249. package/ui/rhine/original/internal-optics.ts +70 -0
  250. package/ui/rhine/original/label-renderer.ts +119 -0
  251. package/ui/rhine/original/label-textures.ts +117 -0
  252. package/ui/rhine/original/model-viewer.ts +658 -0
  253. package/ui/rhine/original/motion.ts +116 -0
  254. package/ui/rhine/original/opaque-backfaces.ts +64 -0
  255. package/ui/rhine/original/program-preparation.ts +111 -0
  256. package/ui/rhine/original/quality-renderer.ts +97 -0
  257. package/ui/rhine/original/quality-settings.ts +111 -0
  258. package/ui/rhine/original/render-quality.ts +163 -0
  259. package/ui/rhine/original/scene.ts +2051 -0
  260. package/ui/rhine/original/shelf-interior.ts +142 -0
  261. package/ui/rhine/original/ui-transitions.ts +111 -0
  262. package/ui/rhine/original/viewer-camera.ts +92 -0
  263. package/ui/rhine/original/viewport-layout.ts +41 -0
  264. package/ui/rhine/original-ui.css +2334 -0
  265. package/ui/rhine/readability.css +46 -0
  266. package/ui/rhine/reading-object.ts +96 -0
  267. package/ui/rhine/reading-origin.ts +28 -0
  268. package/ui/rhine/reading-room.css +55 -0
  269. package/ui/rhine/report-markdown.ts +98 -0
  270. package/ui/rhine/scene-edge-navigation.ts +84 -0
  271. package/ui/rhine/scene.ts +1726 -0
  272. package/ui/rhine/shelf-layout.ts +38 -0
  273. package/ui/rhine/snapshot-update-gate.ts +22 -0
  274. package/ui/rhine/source-reading.ts +15 -0
  275. package/ui/rhine/source-selection.css +164 -0
  276. package/ui/rhine/temporary-performance-observers.ts +163 -0
  277. package/ui/rhine/temporary-performance-panel.ts +124 -0
  278. package/ui/rhine/temporary-performance.css +19 -0
  279. package/ui/rhine/temporary-performance.ts +552 -0
  280. package/ui/rhine/temporary-render-diagnostics.ts +105 -0
  281. package/ui/rhine/tool-activity.ts +69 -0
  282. package/ui/rhine/types.ts +219 -0
  283. package/ui/rhine/visible-scene-hit.ts +13 -0
  284. package/ui/rhine/workbench.css +818 -0
  285. package/ui/rhine/workbench.ts +2245 -0
  286. package/ui/rhine/workspace-travel.ts +32 -0
  287. package/vite.rhine.config.ts +19 -0
@@ -0,0 +1,470 @@
1
+ import { evidenceCardAppearance } from './evidence-card-style';
2
+ import type { EvidenceCard } from './evidence-board-model';
3
+
4
+ const WIDTH = 768;
5
+ // Match the original archive: ivory polymer, warm silver trim and champagne accents.
6
+ // Palette sources: original/scene.ts materials and original-ui.css inspection marks.
7
+ const INK = '#24221f';
8
+ const MUTED = '#726f67';
9
+ const RED = '#a23b30';
10
+ type Context = CanvasRenderingContext2D;
11
+ type ArtCard = EvidenceCard & { visual?: 'observatory' | 'schematic' | 'signal' };
12
+
13
+ function font(context: Context, size: number, weight = 400) {
14
+ context.font = `${weight} ${size}px MiSans, sans-serif`;
15
+ }
16
+
17
+ function fitted(context: Context, value: string, width: number) {
18
+ if (context.measureText(value).width <= width) return value;
19
+ const characters = Array.from(value);
20
+ let low = 0, high = characters.length;
21
+ while (low < high) {
22
+ const middle = Math.ceil((low + high) / 2);
23
+ if (context.measureText(characters.slice(0, middle).join('') + '…').width <= width) low = middle;
24
+ else high = middle - 1;
25
+ }
26
+ return characters.slice(0, low).join('') + '…';
27
+ }
28
+
29
+ /** Preserve explicit line breaks and never paint beyond the available rows. */
30
+ function wrap(context: Context, value: string, width: number, maximum: number) {
31
+ if (maximum < 1) return [];
32
+ const characters = Array.from(value.replace(/\r/g, ''));
33
+ const result: string[] = [];
34
+ let line = '';
35
+ for (let index = 0; index < characters.length; index++) {
36
+ const character = characters[index];
37
+ if (character === '\n' || context.measureText(line + character).width > width) {
38
+ if (result.length === maximum - 1) {
39
+ result.push(fitted(context, line + characters.slice(index).join('').replace(/\n/g, ' '), width));
40
+ return result;
41
+ }
42
+ result.push(line);
43
+ line = character === '\n' ? '' : character;
44
+ } else line += character;
45
+ }
46
+ if (line) result.push(line);
47
+ return result;
48
+ }
49
+
50
+ function paragraph(context: Context, value: string, x: number, y: number,
51
+ width: number, size: number, leading: number, maximum: number, weight = 400) {
52
+ font(context, size, weight);
53
+ const rows = wrap(context, value, width, maximum);
54
+ rows.forEach((row, index) => context.fillText(row, x, y + index * leading));
55
+ return rows.length * leading;
56
+ }
57
+
58
+ function path(context: Context, points: number[][], color: string, width = 1.5, close = false) {
59
+ if (!points.length) return;
60
+ context.beginPath();
61
+ points.forEach(([x, y], index) => index ? context.lineTo(x, y) : context.moveTo(x, y));
62
+ if (close) context.closePath();
63
+ context.strokeStyle = color; context.lineWidth = width; context.stroke();
64
+ }
65
+
66
+ function circle(context: Context, x: number, y: number, radius: number, color: string, width = 1.5) {
67
+ context.beginPath(); context.arc(x, y, radius, 0, Math.PI * 2);
68
+ context.strokeStyle = color; context.lineWidth = width; context.stroke();
69
+ }
70
+
71
+ function randomFor(key: string) {
72
+ let seed = 2166136261;
73
+ for (let index = 0; index < key.length; index++) seed = Math.imul(seed ^ key.charCodeAt(index), 16777619);
74
+ return () => {
75
+ seed += 0x6d2b79f5;
76
+ let value = seed;
77
+ value = Math.imul(value ^ value >>> 15, value | 1);
78
+ value ^= value + Math.imul(value ^ value >>> 7, value | 61);
79
+ return ((value ^ value >>> 14) >>> 0) / 4294967296;
80
+ };
81
+ }
82
+
83
+ function paper(context: Context, height: number, color: string, key: string) {
84
+ context.fillStyle = color; context.fillRect(0, 0, WIDTH, height);
85
+ const random = randomFor(key);
86
+ context.fillStyle = 'rgba(98,89,80,.035)';
87
+ // Fixed grain is drawn before the ink; it never flickers when a card is edited.
88
+ for (let index = 0; index < 1050; index++) {
89
+ context.fillRect(random() * WIDTH, random() * height, 0.45 + random() * 1.2, 0.3 + random() * 0.8);
90
+ }
91
+ const edge = context.createLinearGradient(0, 0, WIDTH, 0);
92
+ edge.addColorStop(0, 'rgba(112,102,91,.055)'); edge.addColorStop(.016, 'rgba(112,102,91,0)');
93
+ edge.addColorStop(.986, 'rgba(112,102,91,0)'); edge.addColorStop(1, 'rgba(112,102,91,.045)');
94
+ context.fillStyle = edge; context.fillRect(0, 0, WIDTH, height);
95
+ context.strokeStyle = 'rgba(112,102,91,.1)'; context.lineWidth = 1;
96
+ for (let index = 0; index < 20; index++) {
97
+ const x = random() * WIDTH, y = index % 2 ? 1.5 : height - 1.5;
98
+ context.beginPath(); context.moveTo(x, y); context.lineTo(Math.min(WIDTH, x + 3 + random() * 13), y); context.stroke();
99
+ }
100
+ }
101
+
102
+ function sample(context: Context, card: ArtCard, x: number, y: number, align: CanvasTextAlign = 'left') {
103
+ if (!card.id.startsWith('preview:')) return;
104
+ context.save(); context.textAlign = align; font(context, 18, 600);
105
+ context.fillStyle = MUTED; context.fillText('LONE TRAIL / 示例', x, y); context.restore();
106
+ }
107
+
108
+ function imageSize(image: CanvasImageSource) {
109
+ const source = image as unknown as Record<string, unknown>;
110
+ const dimension = (...names: string[]) => {
111
+ for (const name of names) {
112
+ const value = source[name];
113
+ if (typeof value === 'number' && value > 0) return value;
114
+ if (value && typeof value === 'object' && 'baseVal' in value) {
115
+ const length = (value as { baseVal: { value: number } }).baseVal.value;
116
+ if (length > 0) return length;
117
+ }
118
+ }
119
+ return 0;
120
+ };
121
+ return { width: dimension('naturalWidth', 'videoWidth', 'displayWidth', 'width'),
122
+ height: dimension('naturalHeight', 'videoHeight', 'displayHeight', 'height') };
123
+ }
124
+
125
+ function photoCard(context: Context, card: ArtCard, height: number, image: CanvasImageSource) {
126
+ const dimensions = imageSize(image);
127
+ if (!dimensions.width || !dimensions.height) return false;
128
+ paper(context, height, '#fffdfa', card.id);
129
+ const margin = 27, imageWidth = WIDTH - margin * 2;
130
+ const imageHeight = Math.max(80, height - 144);
131
+ const scale = Math.max(imageWidth / dimensions.width, imageHeight / dimensions.height);
132
+ const cropWidth = imageWidth / scale, cropHeight = imageHeight / scale;
133
+ // Only supplied pixels are used. A missing photograph falls back to a text document.
134
+ context.drawImage(image, (dimensions.width - cropWidth) / 2, (dimensions.height - cropHeight) / 2,
135
+ cropWidth, cropHeight, margin, margin, imageWidth, imageHeight);
136
+ context.fillStyle = INK; font(context, 48, 600);
137
+ context.fillText(fitted(context, card.title || '未命名线索', WIDTH - 82), 40, height - 94);
138
+ context.fillStyle = MUTED; font(context, 22);
139
+ const caption = `主题意象 · ${card.sourceTitle || card.body.replace(/\s+/g, ' ').trim()}`;
140
+ context.fillText(fitted(context, caption, WIDTH - (card.id.startsWith('preview:') ? 285 : 84)), 42, height - 35);
141
+ sample(context, card, WIDTH - 37, height - 34, 'right');
142
+ return true;
143
+ }
144
+
145
+ function diagram(context: Context, top: number, height: number, loneTrail: boolean) {
146
+ const scale = Math.min(680 / 680, height / 380);
147
+ if (scale <= 0) return;
148
+ context.save(); context.translate((WIDTH - 680 * scale) / 2, top + (height - 380 * scale) / 2);
149
+ context.scale(scale, scale);
150
+ const line = '#57524d', pale = '#aaa49a', faint = 'rgba(126,118,107,.13)';
151
+ for (let x = 20; x < 680; x += 30) path(context, [[x, 0], [x, 380]], faint, .8);
152
+ for (let y = 20; y < 380; y += 30) path(context, [[0, y], [680, y]], faint, .8);
153
+ path(context, [[4, 340], [659, 340], [651, 334]], pale);
154
+ path(context, [[42, 373], [42, 5], [36, 13]], pale);
155
+ context.fillStyle = MUTED; font(context, 18, 600);
156
+ context.fillText('X', 659, 347); context.fillText('Y', 17, 5);
157
+ for (let x = 90; x < 650; x += 60) path(context, [[x, 336], [x, 344]], pale);
158
+ // Circular test chamber, service rings and a cutaway hatch, all illustrative.
159
+ const cx = 252, cy = 190;
160
+ context.fillStyle = '#e2dad4'; context.beginPath(); context.arc(cx, cy, 102, 0, Math.PI * 2); context.fill();
161
+ circle(context, cx, cy, 102, line, 2.8); circle(context, cx, cy, 92, line, 1.8);
162
+ circle(context, cx, cy, 63, pale, 1.5); circle(context, cx, cy, 38, line, 2.2);
163
+ circle(context, cx, cy, 17, line, 1.6);
164
+ context.setLineDash([5, 6]); circle(context, cx, cy, 77, pale, 1.3); context.setLineDash([]);
165
+ for (let index = 0; index < 12; index++) {
166
+ const angle = index * Math.PI / 6;
167
+ const polar = (radius: number) => [cx + Math.cos(angle) * radius, cy + Math.sin(angle) * radius];
168
+ path(context, [polar(94), polar(101)], line, 2);
169
+ if (index % 3 === 0) path(context, [polar(40), polar(61)], line, 2);
170
+ }
171
+ path(context, [[cx - 114, cy], [cx + 114, cy]], pale, .9);
172
+ path(context, [[cx, cy - 113], [cx, cy + 113]], pale, .9);
173
+ // Rectangular service modules with double walls and internal cells.
174
+ for (const [x, y, w, h] of [[458, 55, 142, 91], [468, 245, 144, 74], [78, 108, 64, 126]]) {
175
+ context.fillStyle = '#f7f4ef'; context.fillRect(x, y, w, h);
176
+ context.strokeStyle = line; context.lineWidth = 2; context.strokeRect(x, y, w, h);
177
+ context.strokeStyle = pale; context.lineWidth = 1; context.strokeRect(x + 6, y + 6, w - 12, h - 12);
178
+ for (let offset = 22; offset < w - 12; offset += 22) path(context, [[x + offset, y + 12], [x + offset, y + h - 12]], pale);
179
+ }
180
+ path(context, [[342, 150], [402, 150], [402, 101], [458, 101]], line, 2.5);
181
+ path(context, [[347, 157], [410, 157], [410, 109], [458, 109]], pale, 1.4);
182
+ path(context, [[337, 248], [402, 248], [402, 281], [468, 281]], line, 2.5);
183
+ path(context, [[332, 257], [394, 257], [394, 289], [468, 289]], pale, 1.4);
184
+ path(context, [[142, 157], [159, 157]], line, 2); path(context, [[142, 220], [159, 220]], line, 2);
185
+ path(context, [[252, 87], [252, 34], [387, 34], [387, 71]], line, 1.6);
186
+ circle(context, 387, 82, 11, line, 1.8);
187
+ for (let index = 0; index < 6; index++) path(context, [[194 + index * 12, 265], [207 + index * 12, 279]], pale, 1);
188
+ // Dotted travel path and dimension brackets, not fictitious measured values.
189
+ context.setLineDash([7, 5]);
190
+ context.beginPath(); context.moveTo(100, 300); context.bezierCurveTo(140, 345, 380, 368, 548, 184);
191
+ context.strokeStyle = line; context.lineWidth = 1.8; context.stroke(); context.setLineDash([]);
192
+ path(context, [[538, 187], [548, 184], [546, 196]], line, 1.8);
193
+ path(context, [[154, 70], [154, 53], [354, 53], [354, 70]], pale);
194
+ path(context, [[151, 50], [158, 57]], pale); path(context, [[350, 50], [357, 57]], pale);
195
+ context.fillStyle = line; font(context, 22, 600);
196
+ context.fillText(loneTrail ? '万星园' : 'A', loneTrail ? 219 : 244, 156);
197
+ context.fillText(loneTrail ? '星荚' : 'B', loneTrail ? 503 : 514, 20);
198
+ context.fillText(loneTrail ? '能量井' : 'C', loneTrail ? 511 : 538, 326);
199
+ context.fillStyle = MUTED; font(context, 18); context.fillText(loneTrail ? 'RHINE / 计划关联' : 'PLAN / 01', 47, 356);
200
+ // A restrained pencil-red circle and one short handwritten-looking mark.
201
+ context.beginPath(); context.ellipse(527, 102, 87, 57, -.09, -.12, Math.PI * 2 + .12);
202
+ context.strokeStyle = RED; context.lineWidth = 3.3; context.stroke();
203
+ context.beginPath(); context.moveTo(575, 174); context.quadraticCurveTo(591, 180, 611, 174);
204
+ context.strokeStyle = RED; context.lineWidth = 2.5; context.stroke();
205
+ context.restore();
206
+ }
207
+
208
+ function schematicCard(context: Context, card: ArtCard, height: number) {
209
+ paper(context, height, '#eeeae3', card.id);
210
+ // Fold shadows belong to the paper and remain quiet behind the dark drafting ink.
211
+ for (const x of [WIDTH * .34, WIDTH * .68]) {
212
+ const crease = context.createLinearGradient(x - 8, 0, x + 9, 0);
213
+ crease.addColorStop(0, 'rgba(112,102,91,0)'); crease.addColorStop(.43, 'rgba(112,102,91,.055)');
214
+ crease.addColorStop(.52, 'rgba(255,253,250,.55)'); crease.addColorStop(1, 'rgba(255,253,250,0)');
215
+ context.fillStyle = crease; context.fillRect(x - 8, 0, 17, height);
216
+ }
217
+ path(context, [[0, height * .56], [WIDTH, height * .56]], 'rgba(126,118,107,.08)', 1);
218
+ context.fillStyle = MUTED; font(context, 22, 600); context.fillText('关系示意', 43, 20);
219
+ sample(context, card, WIDTH - 40, 21, 'right');
220
+ context.fillStyle = INK;
221
+ const titleHeight = paragraph(context, card.title || '未命名线索', 40, 58, WIDTH - 80, 48, 59, 2, 600);
222
+ const top = 58 + titleHeight + 18;
223
+ const hasBody = Boolean(card.body.trim());
224
+ diagram(context, top, Math.max(0, height - top - (hasBody ? 120 : 30)), Boolean(card.sourceTitle?.startsWith('《孤星》')));
225
+ if (hasBody) {
226
+ context.fillStyle = INK;
227
+ paragraph(context, card.body, 43, height - 100, WIDTH - 86, 32, 42, 2);
228
+ }
229
+ }
230
+
231
+ function signalCard(context: Context, card: ArtCard, height: number, index: number) {
232
+ paper(context, height, '#e2dad4', card.id);
233
+ context.fillStyle = '#726f67'; font(context, 22, 600);
234
+ context.fillText(`OBS. ${String(index + 1).padStart(2, '0')} / 示意`, 39, 22);
235
+ sample(context, card, WIDTH - 39, 22, 'right');
236
+ context.fillStyle = '#24221f'; font(context, 48, 600);
237
+ context.fillText(fitted(context, card.title || '未命名线索', WIDTH - 80), 39, 63);
238
+ const top = 128, bottom = Math.max(top + 42, height - 122), center = (top + bottom) / 2;
239
+ for (let x = 42; x <= WIDTH - 36; x += 28) path(context, [[x, top], [x, bottom]], 'rgba(100,93,85,.14)', .9);
240
+ for (let y = top; y <= bottom; y += 25) path(context, [[38, y], [WIDTH - 37, y]], 'rgba(100,93,85,.12)', .9);
241
+ path(context, [[38, center], [WIDTH - 38, center]], '#a2998c', 1.3);
242
+ const amplitude = (bottom - top) * .4;
243
+ const waveform = (offset: number, color: string, weight: number) => {
244
+ const points = [];
245
+ for (let step = 0; step <= 260; step++) {
246
+ const t = step / 260;
247
+ const envelope = .19 + .58 * Math.exp(-(((t - .39) * 8) ** 2)) + .36 * Math.exp(-(((t - .78) * 12) ** 2));
248
+ const value = (Math.sin(t * 88 + offset) + .28 * Math.sin(t * 193 + offset)) * envelope;
249
+ points.push([39 + t * (WIDTH - 78), center - value * amplitude]);
250
+ }
251
+ path(context, points, color, weight);
252
+ };
253
+ waveform(.6, 'rgba(128,114,97,.35)', 1.3); waveform(0, '#504b41', 3);
254
+ const marker = WIDTH * .39;
255
+ path(context, [[marker, top - 8], [marker, bottom + 5]], RED, 2);
256
+ context.fillStyle = RED; context.beginPath(); context.moveTo(marker - 6, top - 12);
257
+ context.lineTo(marker + 6, top - 12); context.lineTo(marker, top - 3); context.closePath(); context.fill();
258
+ context.fillStyle = '#24221f';
259
+ paragraph(context, card.body, 40, height - 100, WIDTH - 80, 32, 42, 2);
260
+ }
261
+
262
+ function sourceCard(context: Context, card: ArtCard, height: number, index: number) {
263
+ paper(context, height, '#fffdfa', card.id);
264
+ context.fillStyle = '#c4baae'; context.fillRect(0, 0, 65, height);
265
+ context.save(); context.translate(23, height - 33); context.rotate(-Math.PI / 2);
266
+ context.fillStyle = '#504b41'; font(context, 23, 600);
267
+ context.fillText(`SOURCE / ${String(index + 1).padStart(2, '0')}`, 0, 0); context.restore();
268
+ const left = 103, width = WIDTH - left - 38;
269
+ context.fillStyle = MUTED; font(context, 22, 600); context.fillText('ARCHIVE / EXTRACT', left, 31);
270
+ sample(context, card, WIDTH - 37, 31, 'right');
271
+ context.fillStyle = INK;
272
+ const titleHeight = paragraph(context, card.title || '未命名线索', left, 86, width, 48, 60,
273
+ height < 550 ? 2 : 3, 600);
274
+ const top = 86 + titleHeight + 30;
275
+ const leading = 44;
276
+ const bottom = height - (card.sourceTitle ? 95 : 44);
277
+ const rows = Math.max(0, Math.min(12, Math.floor((bottom - top) / leading)));
278
+ const gutter = 29, column = (width - gutter) / 2;
279
+ font(context, 34); context.fillStyle = INK;
280
+ const text = wrap(context, card.body, column, rows * 2);
281
+ let split = Math.min(rows, Math.ceil(text.length / 2));
282
+ // Keep short supplied paragraphs together where both columns can fit them.
283
+ const paragraphs = card.body.replace(/\r/g, '').split('\n');
284
+ if (paragraphs.length > 1 && paragraphs.every(value => value.trim())) {
285
+ let boundary = 0, distance = Infinity;
286
+ for (const value of paragraphs.slice(0, -1)) {
287
+ boundary += wrap(context, value, column, rows * 2).length;
288
+ if (boundary > 0 && boundary <= rows && text.length - boundary > 0 && text.length - boundary <= rows
289
+ && Math.abs(text.length / 2 - boundary) < distance) {
290
+ split = boundary; distance = Math.abs(text.length / 2 - boundary);
291
+ }
292
+ }
293
+ }
294
+ text.forEach((line, at) => {
295
+ const second = at >= split;
296
+ context.fillText(line, left + (second ? column + gutter : 0), top + (second ? at - split : at) * leading);
297
+ });
298
+ if (text.length > split && split > 0) path(context,
299
+ [[left + column + gutter / 2, top + 4], [left + column + gutter / 2, top + split * leading - 12]], '#c7beb6', 1);
300
+ if (card.sourceTitle) {
301
+ context.fillStyle = MUTED; font(context, 23, 600);
302
+ context.fillText(fitted(context, card.sourceTitle, width), left, height - 58);
303
+ }
304
+ // A small folded corner changes the document silhouette without ornamental facts.
305
+ context.fillStyle = '#e2dad4'; context.beginPath(); context.moveTo(WIDTH - 34, height);
306
+ context.lineTo(WIDTH - 34, height - 32); context.lineTo(WIDTH, height - 32); context.closePath(); context.fill();
307
+ path(context, [[WIDTH - 34, height], [WIDTH - 34, height - 32], [WIDTH, height - 32]], '#c4baae', 1);
308
+ }
309
+
310
+ function noteCard(context: Context, card: ArtCard, height: number, index: number) {
311
+ paper(context, height, '#f0e7df', card.id);
312
+ context.fillStyle = MUTED; font(context, 22, 600);
313
+ context.fillText(String(index + 1).padStart(2, '0'), 43, 24);
314
+ sample(context, card, WIDTH - 41, 24, 'right');
315
+ font(context, 48, 600);
316
+ const title = wrap(context, card.title || '未命名线索', WIDTH - 89, 2);
317
+ const top = 65;
318
+ const titleLeading = 58;
319
+ const highlightWidth = Math.min(380, context.measureText(title[0] || '').width + 8);
320
+ context.fillStyle = 'rgba(203,179,151,.3)'; context.fillRect(39, top + 34, highlightWidth, 14);
321
+ context.fillStyle = INK; title.forEach((line, at) => context.fillText(line, 43, top + at * titleLeading));
322
+ const bodyTop = top + title.length * titleLeading + 33;
323
+ context.fillStyle = INK;
324
+ paragraph(context, card.body, 44, bodyTop, WIDTH - 88, 34, 45,
325
+ Math.max(0, Math.min(8, Math.floor((height - 50 - bodyTop) / 45))));
326
+ context.beginPath(); context.moveTo(42, top + title.length * titleLeading + 4);
327
+ context.bezierCurveTo(105, top + title.length * titleLeading, 191, top + title.length * titleLeading + 8,
328
+ Math.min(230, 43 + highlightWidth), top + title.length * titleLeading + 2);
329
+ context.strokeStyle = RED; context.lineWidth = 2.4; context.stroke();
330
+ // A faint crease and a short rough edge are paper details, not another footer.
331
+ path(context, [[16, 0], [22, height]], 'rgba(112,102,91,.07)', 1);
332
+ path(context, [[WIDTH - 30, height - 5], [WIDTH - 23, height - 9], [WIDTH - 17, height - 5]], 'rgba(112,102,91,.17)', 1.3);
333
+ }
334
+
335
+ function questionCard(context: Context, card: ArtCard, height: number) {
336
+ paper(context, height, '#e4d5c1', card.id);
337
+ const glue = context.createLinearGradient(0, 0, 0, 39);
338
+ glue.addColorStop(0, 'rgba(166,125,72,.09)'); glue.addColorStop(1, 'rgba(166,125,72,0)');
339
+ context.fillStyle = glue; context.fillRect(0, 0, WIDTH, 39);
340
+ context.fillStyle = RED; font(context, 34, 600); context.fillText('?', WIDTH - 66, 21);
341
+ context.fillStyle = '#24221f';
342
+ const titleRows = height < 400 ? 1 : height < 560 ? 2 : 3;
343
+ const titleHeight = paragraph(context, card.title || '未命名线索', 45, 66, WIDTH - 103, 48, 60, titleRows, 600);
344
+ const bodyTop = 66 + titleHeight + 26;
345
+ paragraph(context, card.body, 47, bodyTop, WIDTH - 99, 34, 45,
346
+ Math.max(0, Math.min(3, Math.floor((height - 51 - bodyTop) / 45))));
347
+ sample(context, card, 45, height - 34);
348
+ }
349
+
350
+ function compactCard(context: Context, card: ArtCard, height: number, index: number) {
351
+ const question = card.kind === 'question';
352
+ paper(context, height, question ? '#e4d5c1' : '#f0e7df', card.id);
353
+ context.fillStyle = MUTED; font(context, 20, 600);
354
+ context.fillText(`${question ? 'QUESTION' : 'OBSERVATION'} / ${String(index + 1).padStart(2, '0')}`, 43, 22);
355
+ if (question) { context.fillStyle = RED; font(context, 30, 600); context.fillText('?', WIDTH - 59, 19); }
356
+ context.fillStyle = INK;
357
+ const titleHeight = paragraph(context, card.title || '未命名线索', 43, 67, WIDTH - 87, 48, 59, 2, 600);
358
+ const bodyTop = 67 + titleHeight + 21, footer = card.sourceTitle ? 57 : 29;
359
+ paragraph(context, card.body, 44, bodyTop, WIDTH - 88, 33, 43,
360
+ Math.max(0, Math.min(3, Math.floor((height - footer - bodyTop) / 43))));
361
+ if (card.sourceTitle) {
362
+ context.fillStyle = MUTED; font(context, 19);
363
+ context.fillText(fitted(context, card.sourceTitle, WIDTH - 88), 44, height - 36);
364
+ }
365
+ path(context, [[42, 55], [WIDTH - 42, 55]], 'rgba(126,118,107,.15)', 1);
366
+ }
367
+
368
+ function tagCard(context: Context, card: ArtCard, height: number) {
369
+ paper(context, height, '#eee8dd', card.id);
370
+ context.fillStyle = '#c4b297'; context.fillRect(0, 0, 9, height);
371
+ context.fillStyle = INK; font(context, 62, 600);
372
+ context.fillText(fitted(context, card.title || '关键词', WIDTH - 118), 78, (height - 66) / 2);
373
+ // Labels deliberately show only their title; full content remains in the editor.
374
+ }
375
+
376
+ /** A board exposes one takeaway; the drawer retains the original title and complete text. */
377
+ function takeaway(value: string) {
378
+ const text=value.replace(/\s+/g,' ').trim(), sentence=text.match(/^.{12,}?[。!?](?:[”」』])?/u)?.[0];
379
+ return sentence && sentence.length < text.length ? sentence : text;
380
+ }
381
+ function boardHeading(context:Context, title:string, x:number, y:number, width:number, rows:number, size=55) {
382
+ let fittedSize=size;
383
+ while(fittedSize>44){font(context,fittedSize,550);if(wrap(context,title,width,20).length<=rows)break;fittedSize-=2;}
384
+ context.fillStyle=INK;
385
+ return paragraph(context,title,x,y,width,fittedSize,fittedSize*1.25,rows,550);
386
+ }
387
+ function investigationCard(context: Context, card: ArtCard, height: number) {
388
+ const kind=card.clueKind!, report=kind==='report';
389
+ const appearance = evidenceCardAppearance(card);
390
+ const accent=appearance.accent, left=report?66:44, right=report?66:44;
391
+ paper(context,height,appearance.paper,card.id);
392
+ context.fillStyle=accent;
393
+ // Real differences in document format: source slips, observation cards, labels and taped questions.
394
+ if(report){
395
+ context.fillStyle='#d6ddc9';context.fillRect(0,0,15,height);
396
+ path(context,[[27,0],[27,height]],'#d5d8c7',1);
397
+ context.fillStyle=accent;context.fillRect(left,37,86,4);
398
+ font(context,22,500);context.fillText('RESEARCH REPORT',left,60);
399
+ context.textAlign='right';font(context,20);context.fillText(card.evidenceLabel?.replace('REPORT / ','')||'IN PROGRESS',WIDTH-right,62);context.textAlign='left';
400
+ }else if(kind==='excerpt'){
401
+ context.fillStyle='#e0e5d9';context.fillRect(0,0,17,height);
402
+ path(context,[[left,73],[WIDTH-right,73]],'#bcc5b6',1.5);
403
+ }else if(kind==='finding'){
404
+ context.fillRect(0,0,WIDTH,8);context.fillRect(left,78,38,4);
405
+ }else if(kind==='time'){
406
+ context.fillStyle='#ded1b9';context.fillRect(0,0,18,height);
407
+ circle(context,WIDTH-67,43,14,accent,2);path(context,[[WIDTH-67,34],[WIDTH-67,43],[WIDTH-59,48]],accent,2);
408
+ }else if(kind==='relation'){
409
+ path(context,[[WIDTH-163,44],[WIDTH-74,44]],accent,2);
410
+ for(const x of [WIDTH-163,WIDTH-74]){circle(context,x,44,10,accent,2);}
411
+ }else if(kind==='question'){
412
+ const glue=context.createLinearGradient(0,0,0,75);glue.addColorStop(0,'#cdbc9866');glue.addColorStop(1,'#cdbc9800');
413
+ context.fillStyle=glue;context.fillRect(0,0,WIDTH,75);context.fillStyle=accent;context.globalAlpha=.16;
414
+ font(context,142,300);context.fillText('?',WIDTH-143,69);context.globalAlpha=1;
415
+ }else if(kind==='contrast'){
416
+ context.fillStyle='#aa9081';context.fillRect(0,0,WIDTH/2,7);context.fillStyle='#849380';context.fillRect(WIDTH/2,0,WIDTH/2,7);
417
+ path(context,[[left,79],[WIDTH-right,79]],'#c8bbae',1);
418
+ }
419
+ if(!report){
420
+ context.fillStyle=accent;font(context,23,500);context.fillText(appearance.name,left,32);
421
+ if(kind!=='time'&&kind!=='relation'){context.textAlign='right';font(context,22);context.fillText(card.id,WIDTH-right,34);context.textAlign='left';}
422
+ }
423
+ const titleY=report?116:kind==='question'?104:kind==='finding'?107:kind==='time'?84:98;
424
+ const titleWidth=WIDTH-left-right-(kind==='question'?30:0);
425
+ const used=boardHeading(context,card.title,left,titleY,titleWidth,report||kind==='question'||kind==='finding'?3:2,report?55:54);
426
+ const bodyTop=titleY+used+(report?26:22), footerTop=height-(report?101:80);
427
+ const leading=report?42:40;
428
+ const bodyRows=Math.max(0,Math.min(kind==='time'||kind==='relation'?1:2,Math.floor((footerTop-bodyTop)/leading)));
429
+ context.fillStyle=MUTED;
430
+ paragraph(context,takeaway(card.summary||card.body),left,bodyTop,WIDTH-left-right,report?31:32,leading,bodyRows);
431
+ const footerY=height-49;
432
+ path(context,[[left,height-69],[WIDTH-right,height-69]],report?'#b5bea6':'#a7ad993f',1);
433
+ context.fillStyle=accent;font(context,report?21:23,500);
434
+ if(report){
435
+ context.fillText('打开调查报告',left,footerY);context.textAlign='right';font(context,20);
436
+ context.fillText(card.sourceLabel||'',WIDTH-right-40,footerY+1);font(context,36);context.fillText('↗',WIDTH-right,footerY-10);context.textAlign='left';
437
+ }else{
438
+ context.fillText(fitted(context,card.sourceLabel||card.id,350),left,footerY);
439
+ context.textAlign='right';font(context,22);context.fillText(card.evidenceLabel||'待核验',WIDTH-right,footerY);context.textAlign='left';
440
+ }
441
+ }
442
+
443
+ /** Draw one card at its supplied resolution; no scene, network or animation state. */
444
+ export function drawEvidenceCard(canvas: HTMLCanvasElement, card: EvidenceCard, index: number, photo?: CanvasImageSource): void {
445
+ const context = canvas.getContext('2d');
446
+ if (!context || canvas.width < 1 || canvas.height < 1) return;
447
+ const height = canvas.height / canvas.width * WIDTH;
448
+ const art = card as ArtCard;
449
+ context.save();
450
+ try {
451
+ context.setTransform(1, 0, 0, 1, 0, 0); context.clearRect(0, 0, canvas.width, canvas.height);
452
+ context.scale(canvas.width / WIDTH, canvas.width / WIDTH);
453
+ context.globalAlpha = 1; context.globalCompositeOperation = 'source-over';
454
+ context.filter = 'none'; context.shadowColor = 'transparent'; context.shadowBlur = 0;
455
+ context.shadowOffsetX = 0; context.shadowOffsetY = 0;
456
+ context.textAlign = 'left'; context.textBaseline = 'top'; context.lineCap = 'round'; context.lineJoin = 'round'; context.setLineDash([]);
457
+ context.beginPath(); context.rect(0, 0, WIDTH, height); context.clip();
458
+ if (art.clueKind) investigationCard(context, art, height);
459
+ else if (art.presentation === 'tag') tagCard(context, art, height);
460
+ else if (art.presentation === 'compact') compactCard(context, art, height, index);
461
+ else if (art.visual === 'observatory' && photo && photoCard(context, art, height, photo)) return;
462
+ else if (art.visual === 'schematic') schematicCard(context, art, height);
463
+ else if (art.visual === 'signal') signalCard(context, art, height, index);
464
+ else if (art.kind === 'source' || art.visual === 'observatory') sourceCard(context, art, height, index);
465
+ else if (art.kind === 'question') questionCard(context, art, height);
466
+ else noteCard(context, art, height, index);
467
+ } finally {
468
+ context.restore();
469
+ }
470
+ }
@@ -0,0 +1,17 @@
1
+ import type { EvidenceCard } from './evidence-board-model';
2
+
3
+ const accents: Record<string, string> = { excerpt: '#7d887e', finding: '#647356', time: '#99815f', relation: '#6b8588', question: '#92774e', contrast: '#90756c', report: '#69765b', note: '#96806c' };
4
+ const stocks: Record<string, string[]> = {
5
+ excerpt: ['#f6f3eb', '#f8f5ed', '#f1eee5'], finding: ['#e8eddf', '#e4e9dc', '#edf0e4'],
6
+ time: ['#eee6d7', '#e9e2d4', '#f0eadd'], relation: ['#e6ecea', '#e1e9e7', '#e9eeec'],
7
+ question: ['#e9dfc7', '#eee3c9', '#e4d9bd'], contrast: ['#f4eee5', '#eee9df', '#f6f1e7'],
8
+ report: ['#f2f1e6'], note: ['#f0e7df'],
9
+ };
10
+ const names: Record<string, string> = { excerpt: '原文摘录', finding: '研究发现', time: '时间节点', relation: '关键关联', question: '待解问题', contrast: '交叉对照', report: '调查报告', note: '观察笔记' };
11
+
12
+ /** The printed card and its editor share the same paper stock and category accents. */
13
+ export function evidenceCardAppearance(card: EvidenceCard) {
14
+ const kind = card.clueKind || (card.kind === 'source' ? 'excerpt' : card.kind);
15
+ const variant = Math.abs(Math.trunc(card.variant || 0));
16
+ return { kind, name: names[kind], accent: accents[kind], paper: stocks[kind][variant % stocks[kind].length] };
17
+ }
@@ -0,0 +1,43 @@
1
+ import * as THREE from 'three';
2
+ import { ARCHIVE_LABEL_LAYER } from './original/label-renderer';
3
+ import type { EvidenceInboxView } from './evidence-inbox';
4
+
5
+ /** A compact sorting table beside the board. Reuses the existing scene and label pass. */
6
+ export function createEvidenceInboxScene(print:(texture:THREE.Texture)=>THREE.MeshBasicMaterial){
7
+ const group=new THREE.Group();group.name='Rhine_Priority_Evidence_Box';
8
+ const cube=new THREE.BoxGeometry(1,1,1),labelGeometry=new THREE.PlaneGeometry(2.58,.68),materials:THREE.Material[]=[];
9
+ const material=(color:string,metalness=0)=>{const m=new THREE.MeshStandardMaterial({color,roughness:.72,metalness,fog:false});materials.push(m);return m;};
10
+ const shell=material('#e5dfd3'),rim=material('#b4ad9b',.25),feet=material('#929683',.25),paper=material('#f5efdf'),tab=material('#a7ad8d'),lining=material('#a6a391');
11
+ const box=(parent:THREE.Group,size:number[],position:number[],m:THREE.Material)=>{const mesh=new THREE.Mesh(cube,m);mesh.scale.set(size[0],size[1],size[2]);mesh.position.set(position[0],position[1],position[2]);parent.add(mesh);return mesh;};
12
+ // Slender enamel work table; the foot height matches the archive furniture.
13
+ box(group,[4.3,.16,2.8],[0,4.92,0],shell);box(group,[4.12,.055,2.62],[0,5.025,0],rim);
14
+ for(const x of [-1.8,1.8])for(const z of [-1,1]){box(group,[.10,4.8,.1],[x,2.47,z],rim);box(group,[.28,.1,.34],[x,.1,z],feet);}
15
+ box(group,[3.7,.065,.065],[0,2.5,-1],rim);
16
+ // Open, lined sorting box with an olive metal index rail, not an archive cassette.
17
+ box(group,[3.48,.10,2.02],[0,5.14,0],shell);box(group,[3.25,.03,1.84],[0,5.20,0],lining);
18
+ box(group,[3.48,.87,.085],[0,5.57,1.01],shell);box(group,[3.48,1.35,.085],[0,5.8,-1.01],shell);
19
+ for(const x of [-1.70,1.70]){box(group,[.085,1.10,2.0],[x,5.68,0],shell);box(group,[.10,.05,2.02],[x,6.25,0],rim);}
20
+ box(group,[3.45,.045,.12],[0,6.02,1.01],rim);box(group,[.85,.09,.025],[0,5.34,1.06],rim);
21
+ const folders:THREE.Group[]=[];
22
+ for(let i=0;i<6;i++){
23
+ const folder=new THREE.Group();folder.name=`Priority_Folder_${i+1}`;folder.visible=false;folder.position.set((i%2)*.05,5.22,-.78+i*.28);group.add(folder);
24
+ box(folder,[3.0,1.48,.065],[0,.74,0],i%3===1?shell:paper);
25
+ box(folder,[.7,.2,.065],[-.98+(i%3)*.95,1.55,0],i%3===0?tab:paper);
26
+ box(folder,[2.7,.012,.015],[0,1.19,.043],rim);folders.push(folder);
27
+ }
28
+ const canvas=document.createElement('canvas');canvas.width=896;canvas.height=236;const ctx=canvas.getContext('2d')!;
29
+ const texture=new THREE.CanvasTexture(canvas);texture.colorSpace=THREE.SRGBColorSpace;const ink=print(texture);materials.push(ink);
30
+ const label=new THREE.Mesh(labelGeometry,ink);label.layers.set(ARCHIVE_LABEL_LAYER);label.position.set(0,5.62,1.058);group.add(label);
31
+ const initial=materials.map(m=>({m,opacity:m.opacity,transparent:m.transparent,depthWrite:m.depthWrite}));let reveal=1,count=0,boardId='',title='',opened=false;
32
+ function draw(){ctx.fillStyle='#e5dfd3';ctx.fillRect(0,0,896,236);ctx.fillStyle='#5b634c';ctx.font='500 38px MiSans, sans-serif';ctx.fillText('重点证据 / 待整理',36,73);ctx.textAlign='right';ctx.font='500 72px MiSans, sans-serif';ctx.fillText(String(count).padStart(2,'0'),855,97);ctx.textAlign='left';ctx.fillStyle='#858774';ctx.font='23px MiSans, sans-serif';ctx.fillText('RHINE LAB / EVIDENCE INBOX',38,133);ctx.font='22px MiSans, sans-serif';const text=title.length>30?title.slice(0,29)+'…':title||'选择调查后收集重点材料';ctx.fillText(text,38,195);texture.needsUpdate=true;}
33
+ draw();
34
+ return{group,
35
+ pick(ray:THREE.Raycaster){return group.visible&&ray.intersectObject(group,true).length>0;},
36
+ set(value:EvidenceInboxView,open:boolean){opened=open;if(boardId!==value.boardId||count!==value.count||title!==value.title){boardId=value.boardId;count=value.count;title=value.title;folders.forEach((folder,i)=>folder.visible=i<Math.min(6,count));draw();}},
37
+ update(amount:number){folders.forEach((folder,i)=>{folder.position.y=5.22+amount*(i===Math.min(count,6)-1?.16:.04);});},
38
+ setReveal(value:number){if(value===reveal)return;reveal=value;group.visible=value>0;for(const state of initial){state.m.opacity=state.opacity*value;state.m.depthWrite=value<1?false:state.depthWrite;const transparent=value<1||state.transparent;if(transparent!==state.m.transparent){state.m.transparent=transparent;state.m.needsUpdate=true;}}},
39
+ anchor(camera:THREE.Camera,width:number,height:number){group.updateWorldMatrix(true,false);const p=new THREE.Vector3(0,7.95,1.1).applyMatrix4(group.matrixWorld).project(camera);const x=(p.x+1)*width/2,y=(1-p.y)*height/2;return{x,y,visible:group.visible&&p.z>-1&&p.z<1&&x>100&&x<width-100&&y>height*.2&&y<height*.85};},
40
+ stats(){return{boardId,count,visible:group.visible,open:opened,folderCount:folders.filter(f=>f.visible).length,meshes:group.children.length,reveal};},
41
+ dispose(){group.removeFromParent();cube.dispose();labelGeometry.dispose();texture.dispose();materials.forEach(m=>m.dispose());}
42
+ };
43
+ }
@@ -0,0 +1,29 @@
1
+ .rhine-workbench .rhine-inbox-entry{position:absolute;left:4%;top:73%;z-index:24;display:grid;grid-template-columns:auto auto;align-items:baseline;gap:4px 16px;padding:10px 0;color:#515844;background:none;border:0;border-bottom:1px solid #96957b8c;cursor:pointer;text-align:left;min-width:160px;pointer-events:auto}
2
+ .rhine-workbench .rhine-inbox-entry b{font-size:24px;font-weight:450;font-variant-numeric:tabular-nums}.rhine-workbench .rhine-inbox-entry small{grid-column:1/-1;font-size:10px;color:#7c806e;letter-spacing:.1em}.rhine-workbench .rhine-inbox-entry.is-anchored{transform:translate(-50%,0)}
3
+ .rhine-workbench .rhine-inbox-panel{position:absolute;inset:0;z-index:44;overflow:hidden;background:linear-gradient(90deg,transparent 38%,#ebe8e073 52%,#ebe8e0ed 77%,#ebe8e0f5);color:#30362b;pointer-events:auto}
4
+ .rhine-workbench .rhine-inbox-panel>header{position:absolute;left:53%;right:5%;top:19%;display:flex;align-items:center;justify-content:space-between;gap:20px;font-size:11px;color:#7d806e;letter-spacing:.06em}
5
+ .rhine-workbench .rhine-inbox-content{position:absolute;left:53%;right:5%;top:25%;bottom:10%;overflow:auto;overscroll-behavior:contain;scrollbar-width:thin;scrollbar-color:#9a9b8170 transparent;padding-right:12px}
6
+ .rhine-workbench .rhine-inbox-content h2{font-size:32px;font-weight:450;margin:0 0 8px}.rhine-workbench .rhine-inbox-subtitle{font-size:12px;color:#7d806e;line-height:1.8;margin:0 0 20px}
7
+ .rhine-workbench .rhine-inbox-tools{display:flex;align-items:center;gap:20px;padding-bottom:12px;border-bottom:1px solid #aca78f88}.rhine-workbench .rhine-inbox-filter{flex:1;min-width:0;width:100%;font-size:13px}.rhine-workbench .rhine-inbox-ask{font-size:11px;white-space:nowrap}
8
+ .rhine-workbench .rhine-inbox-panel :is(button,input,textarea,select){font:inherit;color:inherit;background:transparent;border:0}.rhine-workbench .rhine-inbox-panel button{cursor:pointer}.rhine-workbench .rhine-inbox-panel button:disabled{opacity:.4;cursor:default}
9
+ .rhine-workbench .rhine-inbox-row{border-bottom:1px solid #aca78f55}.rhine-workbench .rhine-inbox-select{display:flex;flex-direction:column;gap:6px;width:100%;padding:14px 10px;text-align:left;border-left:2px solid transparent!important}.rhine-workbench .rhine-inbox-select[aria-pressed=true]{border-left-color:#858e6e!important}.rhine-workbench .rhine-inbox-select strong{font-size:16px;font-weight:500;line-height:1.55}.rhine-workbench .rhine-inbox-select :is(small,span){font-size:11px;line-height:1.6;color:#7d806e}.rhine-workbench .rhine-inbox-select span{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
10
+ .rhine-workbench .rhine-inbox-pager,.rhine-workbench .rhine-inbox-actions{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:15px 0;font-size:12px}.rhine-workbench .rhine-inbox-pager{color:#7d806e}.rhine-workbench .rhine-inbox-details{padding-top:22px}.rhine-workbench .rhine-inbox-details h3{font-size:17px;line-height:1.6;font-weight:500;margin:0 0 8px}.rhine-workbench .rhine-inbox-meta{font-size:11px;color:#7d806e}.rhine-workbench .rhine-inbox-note{font-size:13px;line-height:1.85;white-space:pre-wrap}.rhine-workbench .rhine-inbox-actions{border-top:1px solid #aca78f88}.rhine-workbench .rhine-inbox-remove{color:#847466}.rhine-workbench .rhine-inbox-empty{font-size:14px;color:#7d806e;line-height:2;white-space:pre-line;padding:26px 0}
11
+ .rhine-workbench .rhine-inbox-form{display:flex;flex-direction:column;gap:16px;padding-top:22px}.rhine-workbench .rhine-inbox-form small{font-size:11px;color:#7d806e}.rhine-workbench .rhine-inbox-form input{font-size:19px;padding:10px 0;border-bottom:1px solid #aca78f88}.rhine-workbench .rhine-inbox-form textarea{width:100%;resize:vertical;line-height:1.8;font-size:14px;border-bottom:1px solid #aca78f88}.rhine-workbench .rhine-inbox-form-options{display:flex;gap:24px;font-size:13px}
12
+ .rhine-workbench .has-evidence-inbox :is(.rhine-investigation-hud,.rhine-board-toolbar,.rhine-board-chalk-hit,.rhine-board-tool-status){visibility:hidden;pointer-events:none}.rhine-workbench.has-reading-object .rhine-inbox-entry{display:none}
13
+ .rhine-workbench :is(.rhine-inbox-entry,.rhine-inbox-panel,.rhine-inbox-form,.rhine-inbox-details,.rhine-inbox-pager,.rhine-inbox-ask)[hidden]{display:none}
14
+ @media(max-width:900px){.rhine-workbench .rhine-inbox-panel>header{left:45%;right:4%;top:21%}.rhine-workbench .rhine-inbox-content{left:45%;right:4%;top:28%;bottom:9%}.rhine-workbench .rhine-inbox-content h2{font-size:27px}.rhine-workbench .rhine-inbox-tools{flex-wrap:wrap;gap:10px}.rhine-workbench .rhine-inbox-entry{min-width:130px;font-size:12px}}
15
+ @media(max-aspect-ratio:4/5){.rhine-workbench .rhine-inbox-panel{background:linear-gradient(transparent 20%,#ebe8e0ba 36%,#ebe8e0f5 70%)}.rhine-workbench .rhine-inbox-panel>header{left:5%;right:5%;top:32%}.rhine-workbench .rhine-inbox-content{left:5%;right:5%;top:39%;bottom:7%}.rhine-workbench .rhine-inbox-entry{left:6%;top:66%}}
16
+
17
+ .rhine-workbench .rhine-priority-actions{display:flex;align-items:center;justify-content:space-between;gap:16px}.rhine-workbench .rhine-priority-actions>button{width:auto}.rhine-workbench :is(.rhine-array-stage,.rhine-shelf-stage){font-size:11px;white-space:nowrap;color:#788063}.rhine-workbench :is(.rhine-array-stage,.rhine-shelf-stage):disabled{opacity:.4}
18
+
19
+ .rhine-workbench .rhine-inbox-content{display:flex;flex-direction:column;gap:0}
20
+ .rhine-workbench .rhine-inbox-content>:not(.rhine-inbox-list){flex-shrink:0}
21
+ .rhine-workbench .rhine-inbox-list{flex:1 1 auto;min-height:100px;overflow:auto;overscroll-behavior:contain;scrollbar-width:thin;scrollbar-color:#9a9b8170 transparent}
22
+ .rhine-workbench .rhine-inbox-select{padding:12px 10px}
23
+ .rhine-workbench .rhine-inbox-details{padding-top:12px}
24
+ .rhine-workbench .rhine-inbox-details h3{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
25
+ .rhine-workbench .rhine-inbox-meta{margin:4px 0}
26
+ .rhine-workbench .rhine-inbox-note{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;margin:8px 0 12px}
27
+ .rhine-workbench .rhine-inbox-pager{padding:10px 0;font-size:11px}
28
+ .rhine-workbench .rhine-inbox-content.is-editing :is(.rhine-inbox-list,.rhine-inbox-pager,.rhine-inbox-tools){display:none}
29
+ .rhine-workbench .has-evidence-inbox :is(.rhine-evidence-panel,.rhine-evidence-launcher,.rhine-board-compact,.rhine-zone-nav,.rhine-scene-edge){visibility:hidden;pointer-events:none}