rdstudio 0.2.0__py3-none-any.whl

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 (251) hide show
  1. rdstudio/__init__.py +3 -0
  2. rdstudio/_node/rdstudio.mjs +59351 -0
  3. rdstudio/brief.py +46 -0
  4. rdstudio/build.py +218 -0
  5. rdstudio/classify.py +131 -0
  6. rdstudio/cli.py +369 -0
  7. rdstudio/config.py +115 -0
  8. rdstudio/gitlog.py +92 -0
  9. rdstudio/launcher.py +63 -0
  10. rdstudio/learner.py +187 -0
  11. rdstudio/mcp_server.py +313 -0
  12. rdstudio/okf.py +775 -0
  13. rdstudio/procedures.py +233 -0
  14. rdstudio/references.py +228 -0
  15. rdstudio/reports.py +75 -0
  16. rdstudio/scaffold.py +227 -0
  17. rdstudio/scopes.py +162 -0
  18. rdstudio/search.py +149 -0
  19. rdstudio/serve.py +214 -0
  20. rdstudio/store.py +161 -0
  21. rdstudio/templates/agents/critic.md +20 -0
  22. rdstudio/templates/agents/librarian.md +21 -0
  23. rdstudio/templates/agents/searcher.md +21 -0
  24. rdstudio/templates/agents_section.md +31 -0
  25. rdstudio/templates/claude_section.md +3 -0
  26. rdstudio/templates/knowledge/overview.md +20 -0
  27. rdstudio/templates/knowledge/tasks/bootstrap-knowledge-base.md +45 -0
  28. rdstudio/templates/skills/decision/SKILL.md +47 -0
  29. rdstudio/templates/skills/handoff/SKILL.md +18 -0
  30. rdstudio/templates/skills/ingest-ref/SKILL.md +32 -0
  31. rdstudio/templates/skills/lint-okf/SKILL.md +15 -0
  32. rdstudio/templates/skills/promote/SKILL.md +25 -0
  33. rdstudio/templates/skills/question/SKILL.md +33 -0
  34. rdstudio/templates/skills/record-okf/SKILL.md +61 -0
  35. rdstudio/templates/skills/report/SKILL.md +46 -0
  36. rdstudio/templates/skills/report/template.html +74 -0
  37. rdstudio/templates/skills/search-okf/SKILL.md +36 -0
  38. rdstudio/templates/skills/task/SKILL.md +51 -0
  39. rdstudio/web/app.js +140 -0
  40. rdstudio/web/fonts.css +48 -0
  41. rdstudio/web/icon-192.png +0 -0
  42. rdstudio/web/icon-512.png +0 -0
  43. rdstudio/web/index.html +109 -0
  44. rdstudio/web/js/data.js +125 -0
  45. rdstudio/web/js/diagrams.js +116 -0
  46. rdstudio/web/js/graph.js +396 -0
  47. rdstudio/web/js/knowledge.js +212 -0
  48. rdstudio/web/js/layout-worker.js +15 -0
  49. rdstudio/web/js/layout.js +226 -0
  50. rdstudio/web/js/learn.js +83 -0
  51. rdstudio/web/js/map.js +1293 -0
  52. rdstudio/web/js/markdown.js +173 -0
  53. rdstudio/web/js/pages.js +219 -0
  54. rdstudio/web/js/procedures.js +262 -0
  55. rdstudio/web/js/settings.js +95 -0
  56. rdstudio/web/js/util.js +99 -0
  57. rdstudio/web/report.css +35 -0
  58. rdstudio/web/report.js +104 -0
  59. rdstudio/web/style.css +566 -0
  60. rdstudio/web/sw.js +166 -0
  61. rdstudio/web/themes/cyber.css +139 -0
  62. rdstudio/web/themes/map.css +133 -0
  63. rdstudio/web/themes/notebook.css +134 -0
  64. rdstudio/web/themes/space.css +135 -0
  65. rdstudio/web/themes/studio.css +110 -0
  66. rdstudio/web/vendor/d3.min.js +2 -0
  67. rdstudio/web/vendor/fonts/atkinson-hyperlegible-next-latin-ext-wght-italic.woff2 +0 -0
  68. rdstudio/web/vendor/fonts/atkinson-hyperlegible-next-latin-ext-wght-normal.woff2 +0 -0
  69. rdstudio/web/vendor/fonts/atkinson-hyperlegible-next-latin-wght-italic.woff2 +0 -0
  70. rdstudio/web/vendor/fonts/atkinson-hyperlegible-next-latin-wght-normal.woff2 +0 -0
  71. rdstudio/web/vendor/fonts/caveat-latin-wght-normal.woff2 +0 -0
  72. rdstudio/web/vendor/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
  73. rdstudio/web/vendor/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
  74. rdstudio/web/vendor/fonts/ibm-plex-sans-latin-wght-italic.woff2 +0 -0
  75. rdstudio/web/vendor/fonts/ibm-plex-sans-latin-wght-normal.woff2 +0 -0
  76. rdstudio/web/vendor/fonts/inter-latin-opsz-italic.woff2 +0 -0
  77. rdstudio/web/vendor/fonts/inter-latin-opsz-normal.woff2 +0 -0
  78. rdstudio/web/vendor/fonts/jetbrains-mono-latin-wght-italic.woff2 +0 -0
  79. rdstudio/web/vendor/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
  80. rdstudio/web/vendor/fonts/literata-latin-ext-opsz-italic.woff2 +0 -0
  81. rdstudio/web/vendor/fonts/literata-latin-ext-opsz-normal.woff2 +0 -0
  82. rdstudio/web/vendor/fonts/literata-latin-opsz-italic.woff2 +0 -0
  83. rdstudio/web/vendor/fonts/literata-latin-opsz-normal.woff2 +0 -0
  84. rdstudio/web/vendor/fonts/source-sans-3-latin-wght-italic.woff2 +0 -0
  85. rdstudio/web/vendor/fonts/source-sans-3-latin-wght-normal.woff2 +0 -0
  86. rdstudio/web/vendor/fonts/source-serif-4-latin-opsz-italic.woff2 +0 -0
  87. rdstudio/web/vendor/fonts/source-serif-4-latin-opsz-normal.woff2 +0 -0
  88. rdstudio/web/vendor/hljs/github-dark.min.css +10 -0
  89. rdstudio/web/vendor/hljs/github.min.css +10 -0
  90. rdstudio/web/vendor/hljs/highlight.min.js +1262 -0
  91. rdstudio/web/vendor/hljs/julia.min.js +18 -0
  92. rdstudio/web/vendor/hljs/latex.min.js +33 -0
  93. rdstudio/web/vendor/hljs/matlab.min.js +15 -0
  94. rdstudio/web/vendor/katex/auto-render.min.js +1 -0
  95. rdstudio/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  96. rdstudio/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  97. rdstudio/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  98. rdstudio/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  99. rdstudio/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  100. rdstudio/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  101. rdstudio/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  102. rdstudio/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  103. rdstudio/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  104. rdstudio/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  105. rdstudio/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  106. rdstudio/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  107. rdstudio/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  108. rdstudio/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  109. rdstudio/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  110. rdstudio/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  111. rdstudio/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  112. rdstudio/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  113. rdstudio/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  114. rdstudio/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  115. rdstudio/web/vendor/katex/katex.min.css +1 -0
  116. rdstudio/web/vendor/katex/katex.min.js +1 -0
  117. rdstudio/web/vendor/licenses/atkinson-hyperlegible-next-OFL.txt +93 -0
  118. rdstudio/web/vendor/licenses/caveat-OFL.txt +93 -0
  119. rdstudio/web/vendor/licenses/d3-7.9.0.txt +13 -0
  120. rdstudio/web/vendor/licenses/highlightjs-cdn-assets-11.12.0.txt +29 -0
  121. rdstudio/web/vendor/licenses/ibm-plex-mono-OFL.txt +93 -0
  122. rdstudio/web/vendor/licenses/ibm-plex-sans-OFL.txt +93 -0
  123. rdstudio/web/vendor/licenses/inter-OFL.txt +93 -0
  124. rdstudio/web/vendor/licenses/jetbrains-mono-OFL.txt +93 -0
  125. rdstudio/web/vendor/licenses/katex-0.18.7.txt +21 -0
  126. rdstudio/web/vendor/licenses/literata-OFL.txt +93 -0
  127. rdstudio/web/vendor/licenses/markdown-it-15.0.2.txt +22 -0
  128. rdstudio/web/vendor/licenses/markdown-it-footnote-4.0.0.txt +22 -0
  129. rdstudio/web/vendor/licenses/markdown-it-texmath-1.0.0.txt +21 -0
  130. rdstudio/web/vendor/licenses/mermaid-12.0.0.txt +21 -0
  131. rdstudio/web/vendor/licenses/source-sans-3-OFL.txt +93 -0
  132. rdstudio/web/vendor/licenses/source-serif-4-OFL.txt +93 -0
  133. rdstudio/web/vendor/licenses/vega-6.4.0.txt +27 -0
  134. rdstudio/web/vendor/licenses/vega-embed-7.2.0.txt +27 -0
  135. rdstudio/web/vendor/licenses/vega-lite-6.4.3.txt +27 -0
  136. rdstudio/web/vendor/markdown-it-footnote.min.js +2 -0
  137. rdstudio/web/vendor/markdown-it.min.js +14 -0
  138. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/abnfDiagram-DGLNOSUI.mjs +1 -0
  139. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/architecture-7GRP2DOG-HF4J73XS.mjs +1 -0
  140. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/architectureDiagram-IU6H5EVY.mjs +36 -0
  141. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/blockDiagram-FSX6DT2V.mjs +140 -0
  142. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/c4Diagram-YNRVYILN.mjs +38 -0
  143. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-22SYA5HA.mjs +1 -0
  144. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-2FTYKNOQ.mjs +1 -0
  145. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-36VM3LGA.mjs +1 -0
  146. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-3SEI7E7V.mjs +1 -0
  147. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-3T2MNPEU.mjs +1 -0
  148. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-44AU3KAP.mjs +174 -0
  149. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-4MZOTL6B.mjs +1 -0
  150. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-52IMQVNL.mjs +1 -0
  151. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-5NYYYZGL.mjs +1 -0
  152. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-BOZ75642.mjs +15 -0
  153. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-CVBX6EWK.mjs +1 -0
  154. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-D36F7FYG.mjs +14 -0
  155. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-DEGLBX6O.mjs +213 -0
  156. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-F6PIGZHG.mjs +1 -0
  157. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-FE53TMRB.mjs +133 -0
  158. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-FNR2RGIF.mjs +1 -0
  159. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-GDOATIN3.mjs +32 -0
  160. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-H7BX327W.mjs +2 -0
  161. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-HI3EPXIC.mjs +2 -0
  162. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-JCBDXJ5W.mjs +1 -0
  163. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-JGXDHUYS.mjs +1 -0
  164. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-L2B47AAZ.mjs +1 -0
  165. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-LIEGGFIX.mjs +1 -0
  166. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-LLWYPJEZ.mjs +1 -0
  167. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-MFYRHTYG.mjs +1 -0
  168. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-MIE3LDYP.mjs +1 -0
  169. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-MLQVGYRW.mjs +1 -0
  170. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-MNT3HHAC.mjs +1 -0
  171. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-NRRTYJAG.mjs +1 -0
  172. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-OCMJQKYE.mjs +10 -0
  173. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-PKJCTQEK.mjs +339 -0
  174. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-QADHU5CF.mjs +2 -0
  175. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-QVIEFLGF.mjs +1 -0
  176. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-QX7LGXZU.mjs +1 -0
  177. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-R2KH53MI.mjs +1 -0
  178. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-RXM7Z7NK.mjs +2 -0
  179. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-S4TE5DX6.mjs +1 -0
  180. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-SDIBIOXT.mjs +1 -0
  181. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-TDFULVG6.mjs +1 -0
  182. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-TECWHJ6D.mjs +62 -0
  183. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-TINYC7RT.mjs +70 -0
  184. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-V3KJR4XU.mjs +1 -0
  185. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-VTMXW4ES.mjs +1 -0
  186. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-Y5NT6LW5.mjs +88 -0
  187. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-Y5O5OPKC.mjs +3 -0
  188. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-YZG4BXY6.mjs +1 -0
  189. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/chunk-ZZ52EIBP.mjs +1 -0
  190. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/classDiagram-v2-VV4INRHB.mjs +217 -0
  191. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/cose-bilkent-MSIMRU2B.mjs +1 -0
  192. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/cynefin-OW5HDTMX-KMPDV6GS.mjs +1 -0
  193. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/cynefinDiagram-KTGLXFXK.mjs +62 -0
  194. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/dagre-QJJCSHY4.mjs +4 -0
  195. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/diagram-2ZIM67RP.mjs +3 -0
  196. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/diagram-FYHH5O6R.mjs +24 -0
  197. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/diagram-LYLDACMS.mjs +200 -0
  198. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/diagram-TYT3HRBC.mjs +24 -0
  199. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/diagram-XVIN6YVD.mjs +30 -0
  200. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/diagram-ZO2NLLYC.mjs +41 -0
  201. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/ebnfDiagram-TU3FGH35.mjs +1 -0
  202. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/elk-GHAXNSLK.mjs +27 -0
  203. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/erDiagram-XID3VV23.mjs +99 -0
  204. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/eventmodeling-NTZA5JFV-P5QJSWSK.mjs +1 -0
  205. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/flowDiagram-WPHQAKTU.mjs +1 -0
  206. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/ganttDiagram-QACGSKG6.mjs +292 -0
  207. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/gitGraph-4MIJSDKK-V5NEQQYP.mjs +1 -0
  208. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/gitGraphDiagram-EB76O4HC.mjs +106 -0
  209. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/info-A6RAGUB7-IGBHH2L7.mjs +1 -0
  210. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/infoDiagram-343ZKIDN.mjs +2 -0
  211. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/ishikawaDiagram-UEEU5D75.mjs +70 -0
  212. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/journeyDiagram-4QTZOVLF.mjs +139 -0
  213. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/kanban-definition-VEJL4RG2.mjs +89 -0
  214. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/katex-QVRI4OIV.mjs +257 -0
  215. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/mindmap-definition-C5K5ZT7T.mjs +96 -0
  216. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/packet-AYTQ26CC-PJTKDECJ.mjs +1 -0
  217. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/pegDiagram-FHIILVJB.mjs +1 -0
  218. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/pie-WAS4IAKB-U3JFQXZN.mjs +1 -0
  219. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/pieDiagram-S76BU6V7.mjs +39 -0
  220. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/quadrantDiagram-HZBLWUZE.mjs +7 -0
  221. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/radar-RG4KPBEZ-7T2UW32D.mjs +1 -0
  222. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/railroad-74A4TZTK-DZY7ARDL.mjs +1 -0
  223. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/railroad-abnf-HS5TGJTU-LWS535ZK.mjs +1 -0
  224. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/railroad-ebnf-LZEXJU2U-EA3S4ZFM.mjs +1 -0
  225. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/railroad-peg-WCYAUIDC-DUKZONPX.mjs +1 -0
  226. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/railroadDiagram-R6RP3MQ2.mjs +1 -0
  227. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/requirementDiagram-SGBABQV6.mjs +84 -0
  228. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/sankeyDiagram-IM6M5BOW.mjs +40 -0
  229. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/sequenceDiagram-FGJOIFJ2.mjs +169 -0
  230. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/sizeCapture-ERV5D3W5.mjs +1 -0
  231. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/stateDiagram-v2-6KDGCZ5J.mjs +291 -0
  232. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/swimlanes-OJBUSBEF.mjs +1 -0
  233. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/swimlanesDiagram-LNXYTHDW.mjs +8 -0
  234. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/timeline-definition-7G6YQRTY.mjs +120 -0
  235. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/treeView-Q6P3EWNA-AABPVERA.mjs +1 -0
  236. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/treemap-WGGIJYW6-F2JSY3V3.mjs +1 -0
  237. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/usecaseDiagram-6Y6TMLBO.mjs +383 -0
  238. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/vennDiagram-6RODYPCK.mjs +34 -0
  239. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/wardley-WFR3VGLG-EIT5NWVN.mjs +1 -0
  240. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/wardleyDiagram-RFZKLAPA.mjs +78 -0
  241. rdstudio/web/vendor/mermaid/chunks/mermaid.esm.min/xychartDiagram-TWWI2QJZ.mjs +7 -0
  242. rdstudio/web/vendor/mermaid/mermaid.esm.min.mjs +14 -0
  243. rdstudio/web/vendor/texmath.js +368 -0
  244. rdstudio/web/vendor/vega/vega-embed.min.js +7 -0
  245. rdstudio/web/vendor/vega/vega-lite.min.js +2 -0
  246. rdstudio/web/vendor/vega/vega.min.js +1 -0
  247. rdstudio-0.2.0.dist-info/METADATA +201 -0
  248. rdstudio-0.2.0.dist-info/RECORD +251 -0
  249. rdstudio-0.2.0.dist-info/WHEEL +4 -0
  250. rdstudio-0.2.0.dist-info/entry_points.txt +3 -0
  251. rdstudio-0.2.0.dist-info/licenses/LICENSE +21 -0
rdstudio/web/js/map.js ADDED
@@ -0,0 +1,1293 @@
1
+ // Map tab: the folder tree as nested territories (circle packing), notes as
2
+ // places, and links as routes that travel through the hierarchy: out of each
3
+ // folder by a gate on its edge, across the lowest folder containing both ends,
4
+ // and in again. See knowledge/design/map-view.md.
5
+ //
6
+ // Layout and routes are computed in layout units and cached; every zoom frame
7
+ // only transforms them to the screen and decides what is open and labelled.
8
+
9
+ import { store } from "./data.js";
10
+ import { prerequisites } from "./learn.js";
11
+ import { h, conceptHref, trustState, TRUST_LABEL, titleCase, measure, timed } from "./util.js";
12
+ import { start, plainModel, layoutKey, cached, remember, applyPositions, computeLayout } from "./layout.js";
13
+
14
+ /* global d3 */
15
+
16
+ const KEY = "rdstudio.map";
17
+
18
+ // View settings, shown in the Options panel.
19
+ export const VIEW_DEFAULTS = {
20
+ labels: 30, detail: 140, showLinks: true,
21
+ // How far apart a link's ends are in the folder tree, counted in bubble walls:
22
+ // "out" is the larger of the two ends' distances out to the lowest shared
23
+ // folder, "path" is the total crossed going out and back in.
24
+ distMeasure: "out", distMin: 0, distMax: 9,
25
+ lanes: false, // one-way links keep to one side of their route, two-way links take the middle
26
+ rateMin: 2, rateMax: 3, // 1 see also, 2 uses (and unrated), 3 requires
27
+ hideImplied: true, focusOnly: false,
28
+ };
29
+ // How consequential a link is, from its Markdown title ("requires", "uses", "see also").
30
+ const STRENGTH = { requires: 3, uses: 2, "see also": 1 };
31
+ const STRENGTH_LABEL = { 1: "see also", 2: "uses", 3: "requires" };
32
+
33
+ // Tuning parameters, shown in the Tuning panel and settable per project under
34
+ // [map] in rdstudio.toml. [key, label, min, max, step, default, what it does]
35
+ export const TUNING = [
36
+ ["room", "Room inside folders", 0.15, 0.9, 0.05, 0.3, "How much of a folder its contents fill; lower leaves more space between everything."],
37
+ ["spread", "Spread", 0, 3, 0.1, 1, "How strongly the items in a folder push apart to use its space evenly."],
38
+ ["outward", "Pull towards links", 0, 3, 0.1, 1, "How strongly an item moves to the side of its folder where its links leave."],
39
+ ["spacing", "Space between items", 4, 160, 1, 90, "Least gap between neighbouring items, in map units (the map is 1000 across) and scaled down inside smaller folders."],
40
+ ["margin", "Margin inside folders", 0, 160, 1, 60, "Space between a folder's edge and its contents, where routes reach the gates; scaled like spacing."],
41
+ ["dot", "Dot size", 0.25, 0.9, 0.05, 0.6, "A note's dot as a fraction of the space the layout gives it."],
42
+ ["dotMax", "Largest dot", 5, 24, 1, 10, "Cap on a dot's radius on screen, in pixels."],
43
+ ["bundle", "Bundling", 0, 0.5, 0.05, 0.1, "How much cheaper a corridor becomes each time a route uses it; higher gathers routes into trunks."],
44
+ ["detour", "Avoid crossing bubbles", 1, 50, 1, 8, "Cost multiplier for a route segment that passes through a bubble."],
45
+ ["bow", "Bow of direct links", 0, 0.3, 0.01, 0.12, "Sideways curve of a link with nothing in its way, as a fraction of its length."],
46
+ ["width", "Line width", 0.5, 3, 0.1, 1.2, "Width of a route carrying one link, in pixels."],
47
+ ["laneGap", "Lane spacing", 1, 12, 0.5, 4, "With lanes on, how far one-way routes sit to each side of the middle, in pixels."],
48
+ ];
49
+ const TUNING_DEFAULTS = Object.fromEntries(TUNING.map((t) => [t[0], t[5]]));
50
+
51
+ // Marker shape per concept type (lower case). Projects override or extend this
52
+ // under [map.markers] in rdstudio.toml; unknown types are circles.
53
+ export const MARKERS = {
54
+ definition: "circle", theorem: "diamond", lemma: "diamond", proposition: "diamond", corollary: "diamond",
55
+ example: "triangle", trick: "square", reference: "ring", overview: "star",
56
+ decision: "square", task: "triangle", question: "cross", idea: "wye", procedure: "star",
57
+ };
58
+ const SYMBOLS = {
59
+ circle: d3.symbolCircle, diamond: d3.symbolDiamond, triangle: d3.symbolTriangle, square: d3.symbolSquare,
60
+ star: d3.symbolStar, cross: d3.symbolCross, wye: d3.symbolWye, ring: d3.symbolCircle,
61
+ };
62
+ const PALETTE = [0, 1, 2, 3, 4, 5, 6, 7].map((i) => `var(--g${i})`);
63
+
64
+ const saved = (() => {
65
+ try { return JSON.parse(localStorage.getItem(KEY) || "{}"); } catch { return {}; }
66
+ })();
67
+
68
+ // User choices (this browser) layered over project defaults over built-in defaults.
69
+ const M = {
70
+ user: saved.user || saved.opts || {},
71
+ transform: saved.transform ? d3.zoomIdentity.translate(saved.transform.x, saved.transform.y).scale(saved.transform.k) : null,
72
+ };
73
+
74
+ function projectMap() {
75
+ return store.site.map || {};
76
+ }
77
+
78
+ function effective() {
79
+ const project = Object.fromEntries(Object.entries(projectMap()).filter(([k]) => k !== "markers"));
80
+ return Object.assign({}, VIEW_DEFAULTS, TUNING_DEFAULTS, project, M.user);
81
+ }
82
+
83
+ function markerFor(type) {
84
+ const custom = Object.fromEntries(Object.entries(projectMap().markers || {}).map(([k, v]) => [k.toLowerCase(), v]));
85
+ const shape = custom[String(type || "").toLowerCase()] || MARKERS[String(type || "").toLowerCase()] || "circle";
86
+ return SYMBOLS[shape] ? shape : "circle";
87
+ }
88
+
89
+ function persist() {
90
+ const t = M.transform;
91
+ try {
92
+ localStorage.setItem(KEY, JSON.stringify({ user: M.user, transform: t ? { x: t.x, y: t.y, k: t.k } : null }));
93
+ } catch { /* storage unavailable */ }
94
+ }
95
+
96
+ // --------------------------------------------------------------- model
97
+
98
+ function pagerank(ids, edges, damping = 0.85, rounds = 40) {
99
+ const n = ids.length;
100
+ const out = new Map(ids.map((id) => [id, []]));
101
+ for (const [a, b] of edges) out.get(a).push(b);
102
+ let rank = new Map(ids.map((id) => [id, 1 / n]));
103
+ for (let i = 0; i < rounds; i++) {
104
+ const next = new Map(ids.map((id) => [id, (1 - damping) / n]));
105
+ let dangling = 0;
106
+ for (const id of ids) {
107
+ const targets = out.get(id);
108
+ if (!targets.length) { dangling += rank.get(id); continue; }
109
+ const share = damping * rank.get(id) / targets.length;
110
+ for (const t of targets) next.set(t, next.get(t) + share);
111
+ }
112
+ for (const id of ids) next.set(id, next.get(id) + damping * dangling / n);
113
+ rank = next;
114
+ }
115
+ return rank;
116
+ }
117
+
118
+ // Order notes so that linked notes are packed next to each other: start from
119
+ // the most important, then repeatedly take the note most linked to the last few.
120
+ function chainOrder(leaves, adjacent) {
121
+ const left = [...leaves].sort((a, b) => b.weight - a.weight);
122
+ const order = [];
123
+ while (left.length) {
124
+ let best = 0, bestScore = -1;
125
+ const recent = order.slice(-3);
126
+ left.forEach((leaf, i) => {
127
+ const score = recent.reduce((s, r) => s + (adjacent.get(r.ref)?.has(leaf.ref) ? 1 : 0), 0);
128
+ if (score > bestScore) { best = i; bestScore = score; }
129
+ });
130
+ order.push(left.splice(best, 1)[0]);
131
+ }
132
+ return order;
133
+ }
134
+
135
+ function buildModel() {
136
+ const concepts = [...store.concepts.values()];
137
+ const ids = concepts.map((c) => c.id);
138
+ const known = new Set(ids);
139
+ // Directed links, each with its strongest rating: [from, to, strength].
140
+ const strongest = new Map();
141
+ let hasRatings = false;
142
+ for (const c of concepts) {
143
+ for (const l of c.links) {
144
+ if (l.broken || l.kind !== "concept" || !known.has(l.target) || l.target === c.id) continue;
145
+ if (l.rel) hasRatings = true;
146
+ const key = c.id + "\n" + l.target;
147
+ strongest.set(key, Math.max(strongest.get(key) || 0, STRENGTH[l.rel] || 2));
148
+ }
149
+ }
150
+ const edges = [...strongest].map(([key, s]) => [...key.split("\n"), s]);
151
+ const adjacent = new Map(ids.map((id) => [id, new Set()]));
152
+ for (const [a, b] of edges) { adjacent.get(a).add(b); adjacent.get(b).add(a); }
153
+ const rank = pagerank(ids, edges);
154
+ const top = Math.max(...rank.values(), 1e-9);
155
+
156
+ const dirs = new Map();
157
+ for (const d of Object.values(store.tree)) {
158
+ dirs.set(d.id, {
159
+ kind: "dir", id: "d:" + d.id, ref: d.id, children: [],
160
+ label: d.id ? titleCase(d.name) : store.site.title || "Knowledge",
161
+ });
162
+ }
163
+ const leaves = new Map();
164
+ for (const c of concepts) {
165
+ const landmark = c.meta?.landmark === true;
166
+ leaves.set(c.id, {
167
+ kind: "concept", id: "c:" + c.id, ref: c.id, label: c.title, c, landmark, marker: markerFor(c.type),
168
+ weight: 1 + 2.5 * (rank.get(c.id) / top) + (landmark ? 1.5 : 0),
169
+ });
170
+ }
171
+ for (const d of Object.values(store.tree)) {
172
+ const node = dirs.get(d.id);
173
+ node.subdirs = d.children.map((id) => dirs.get(id)).filter(Boolean);
174
+ node.notes = chainOrder(d.concepts.map((id) => leaves.get(id)).filter(Boolean), adjacent);
175
+ }
176
+ // Larger folders first packs more tidily; notes follow in link order.
177
+ const size = (d) => d.notes.length + d.subdirs.reduce((s, x) => s + size(x), 0);
178
+ for (const node of dirs.values()) {
179
+ node.children = [...node.subdirs.sort((a, b) => size(b) - size(a)), ...node.notes];
180
+ }
181
+ const maxDepth = Math.max(0, ...Object.keys(store.tree).filter(Boolean).map((id) => id.split("/").length));
182
+ return { root: dirs.get(""), edges, implied: impliedLinks(ids, edges), hasRatings, maxDepth };
183
+ }
184
+
185
+ // Links implied by others: a → c is implied when c can also be reached from a
186
+ // through a chain of links at least as strong (never through "see also"), as
187
+ // in a transitive reduction. Links inside a group of notes that require each
188
+ // other (a cycle) are left alone. Only the map hides them; notes keep them all.
189
+ function impliedLinks(ids, edges) {
190
+ const out = new Map(ids.map((id) => [id, []]));
191
+ for (const [a, b, s] of edges) out.get(a).push([b, s]);
192
+ const component = stronglyConnected(ids, (v) => out.get(v).filter(([, s]) => s >= 2).map(([w]) => w));
193
+ const implied = new Set();
194
+ for (const [a, c, s] of edges) {
195
+ if (component.get(a) === component.get(c)) continue;
196
+ const need = Math.max(s, 2);
197
+ const seen = new Set([a]);
198
+ const stack = [];
199
+ for (const [b, sb] of out.get(a)) if (b !== c && sb >= need && !seen.has(b)) { seen.add(b); stack.push(b); }
200
+ while (stack.length) {
201
+ const v = stack.pop();
202
+ if (v === c) { implied.add(a + "\n" + c); break; }
203
+ for (const [w, sw] of out.get(v)) if (sw >= need && !seen.has(w)) { seen.add(w); stack.push(w); }
204
+ }
205
+ }
206
+ return implied;
207
+ }
208
+
209
+ // Tarjan's strongly connected components: node -> component number.
210
+ function stronglyConnected(ids, next) {
211
+ const index = new Map(), low = new Map(), onStack = new Set(), stack = [], component = new Map();
212
+ let counter = 0, groups = 0;
213
+ const visit = (v) => {
214
+ index.set(v, counter); low.set(v, counter); counter++;
215
+ stack.push(v); onStack.add(v);
216
+ for (const w of next(v)) {
217
+ if (!index.has(w)) { visit(w); low.set(v, Math.min(low.get(v), low.get(w))); }
218
+ else if (onStack.has(w)) low.set(v, Math.min(low.get(v), index.get(w)));
219
+ }
220
+ if (low.get(v) === index.get(v)) {
221
+ let w;
222
+ do { w = stack.pop(); onStack.delete(w); component.set(w, groups); } while (w !== v);
223
+ groups++;
224
+ }
225
+ };
226
+ for (const v of ids) if (!index.has(v)) visit(v);
227
+ return component;
228
+ }
229
+
230
+ function wrapWords(text, width, charW) {
231
+ const words = text.split(/ +/); // not \s: non-breaking spaces keep words together
232
+ const lines = [];
233
+ let line = "";
234
+ for (const word of words) {
235
+ const next = line ? line + " " + word : word;
236
+ if (line && next.length * charW > width) { lines.push(line); line = word; } else line = next;
237
+ }
238
+ if (line) lines.push(line);
239
+ return lines;
240
+ }
241
+
242
+ // Bubble walls between two notes: "out" is the larger of the two distances out
243
+ // to the lowest folder they share, "path" the total out and back in.
244
+ function wallsBetween(na, nb, measure) {
245
+ const shared = lowestCommon(na.parent, nb.parent);
246
+ const wa = na.parent.depth - shared.depth, wb = nb.parent.depth - shared.depth;
247
+ return measure === "path" ? wa + wb : Math.max(wa, wb);
248
+ }
249
+
250
+ function lowestCommon(a, b) {
251
+ const up = new Set(a.ancestors());
252
+ for (const n of b.ancestors()) if (up.has(n)) return n;
253
+ return null;
254
+ }
255
+
256
+ // --------------------------------------------------------------- routing
257
+ //
258
+ // Each folder has a network of corridors through the open space inside it:
259
+ // a waypoint in the middle of each gap between neighbouring items (from a
260
+ // Delaunay triangulation of their centres), one in the open space of each
261
+ // triangle, and a gate on the folder's edge directly outward from each item.
262
+ // Gates next to each other are joined, like a ring road inside the wall.
263
+ // Routes are shortest paths through these networks, straightened where clear.
264
+
265
+ // Does the segment p–q pass through circle c of radius r?
266
+ function crosses(p, q, c, r) {
267
+ const dx = q.x - p.x, dy = q.y - p.y, len2 = dx * dx + dy * dy || 1;
268
+ const t = Math.max(0, Math.min(1, ((c.x - p.x) * dx + (c.y - p.y) * dy) / len2));
269
+ return Math.hypot(p.x + t * dx - c.x, p.y + t * dy - c.y) < r * 0.98;
270
+ }
271
+
272
+ function makeRouter(o) {
273
+ const itemRadius = (n) => (n.data.kind === "concept" ? n.r * o.dot : n.r);
274
+ const nets = new Map();
275
+
276
+ function network(folder) {
277
+ if (nets.has(folder)) return nets.get(folder);
278
+ const items = folder.children || [];
279
+ const nodes = [], adj = [];
280
+ const touching = new Map(items.map((_, i) => [i, []]));
281
+ const index = new Map(items.map((n, i) => [n, i]));
282
+ const discount = new Map();
283
+ const node = (x, y) => { adj.push([]); return nodes.push({ x, y }) - 1; };
284
+ const blockedBy = (p, q, skip) => items.some((m) => !skip.includes(m) && crosses(p, q, m, itemRadius(m)));
285
+ const link = (u, v, skip = []) => {
286
+ const p = nodes[u], q = nodes[v];
287
+ const w = Math.hypot(p.x - q.x, p.y - q.y) * (blockedBy(p, q, skip) ? o.detour : 1);
288
+ const key = u < v ? `${u}-${v}` : `${v}-${u}`;
289
+ adj[u].push({ to: v, w, key });
290
+ adj[v].push({ to: u, w, key });
291
+ };
292
+ const gapWaypoint = new Map();
293
+ const waypoint = (i, j) => {
294
+ const key = i < j ? `${i}-${j}` : `${j}-${i}`;
295
+ if (gapWaypoint.has(key)) return gapWaypoint.get(key);
296
+ const a = items[i], b = items[j];
297
+ const dx = b.x - a.x, dy = b.y - a.y, d = Math.hypot(dx, dy) || 1;
298
+ const gap = d - itemRadius(a) - itemRadius(b);
299
+ let id = -1;
300
+ if (gap > 1) {
301
+ const t = (itemRadius(a) + gap / 2) / d;
302
+ id = node(a.x + dx * t, a.y + dy * t);
303
+ touching.get(i).push(id); touching.get(j).push(id);
304
+ }
305
+ gapWaypoint.set(key, id);
306
+ return id;
307
+ };
308
+ if (items.length === 2) waypoint(0, 1);
309
+ if (items.length >= 3) {
310
+ const tri = d3.Delaunay.from(items, (n) => n.x, (n) => n.y).triangles;
311
+ for (let t = 0; t < tri.length; t += 3) {
312
+ const ws = [waypoint(tri[t], tri[t + 1]), waypoint(tri[t + 1], tri[t + 2]), waypoint(tri[t + 2], tri[t])].filter((x) => x >= 0);
313
+ if (ws.length < 2) continue;
314
+ // Start from the centroid of the gaps and push it out of any item it
315
+ // falls in, so the middle waypoint sits in open space.
316
+ let mx = ws.reduce((s, x) => s + nodes[x].x, 0) / ws.length, my = ws.reduce((s, x) => s + nodes[x].y, 0) / ws.length;
317
+ for (let round = 0; round < 4; round++) {
318
+ for (const m of [items[tri[t]], items[tri[t + 1]], items[tri[t + 2]]]) {
319
+ const dx = mx - m.x, dy = my - m.y, d = Math.hypot(dx, dy) || 1e-6, r = itemRadius(m) * 1.04;
320
+ if (d < r) { mx = m.x + (dx / d) * r; my = m.y + (dy / d) * r; }
321
+ }
322
+ }
323
+ const mid = node(mx, my);
324
+ for (const x of ws) link(x, mid);
325
+ }
326
+ }
327
+ // Gates on the folder's edge (the root has no edge to cross).
328
+ const gates = [];
329
+ const firstGate = nodes.length; // nodes before this are corridors between items
330
+ if (folder.parent) {
331
+ const R = folder.r * 0.995;
332
+ items.forEach((item, i) => {
333
+ const dx = item.x - folder.x, dy = item.y - folder.y, d = Math.hypot(dx, dy);
334
+ const angle = d > 1e-6 ? Math.atan2(dy, dx) : (i / items.length) * 2 * Math.PI;
335
+ const g = node(folder.x + R * Math.cos(angle), folder.y + R * Math.sin(angle));
336
+ gates.push({ id: g, angle, item: i });
337
+ touching.get(i).push(g);
338
+ });
339
+ gates.sort((a, b) => a.angle - b.angle);
340
+ // The ring road follows the wall in short steps rather than long chords.
341
+ for (let k = 0; k < gates.length && gates.length > 1; k++) {
342
+ const g0 = gates[k], g1 = gates[(k + 1) % gates.length];
343
+ let span = g1.angle - g0.angle;
344
+ if (span <= 0) span += 2 * Math.PI;
345
+ const steps = Math.max(1, Math.ceil(span / (Math.PI / 12)));
346
+ let prev = g0.id;
347
+ for (let st = 1; st < steps; st++) {
348
+ const a = g0.angle + (span * st) / steps;
349
+ const id = node(folder.x + R * Math.cos(a), folder.y + R * Math.sin(a));
350
+ link(prev, id);
351
+ prev = id;
352
+ }
353
+ link(prev, g1.id);
354
+ }
355
+ // Join each gate to the nearest corridors inside.
356
+ const inner = nodes.map((_, id) => id).slice(0, firstGate);
357
+ for (const g of gates) {
358
+ const p = nodes[g.id];
359
+ inner.sort((a, b) => Math.hypot(nodes[a].x - p.x, nodes[a].y - p.y) - Math.hypot(nodes[b].x - p.x, nodes[b].y - p.y));
360
+ for (const id of inner.slice(0, 3)) link(g.id, id);
361
+ }
362
+ }
363
+ const net = { folder, items, nodes, adj, touching, index, gates, discount, itemRadius, blockedBy };
364
+ nets.set(folder, net);
365
+ return net;
366
+ }
367
+
368
+ // Dijkstra from a set of start nodes to a set of end nodes (with the cost of
369
+ // stepping off the network added); the networks are small.
370
+ function shortest(net, starts, ends) {
371
+ const dist = new Map(), prev = new Map(), done = new Set();
372
+ for (const [id, c] of starts) if (c < (dist.get(id) ?? Infinity)) dist.set(id, c);
373
+ let best = null, bestCost = Infinity;
374
+ while (true) {
375
+ let u = -1, du = Infinity;
376
+ for (const [k, v] of dist) if (!done.has(k) && v < du) { u = k; du = v; }
377
+ if (u < 0 || du >= bestCost) break;
378
+ done.add(u);
379
+ if (ends.has(u) && du + ends.get(u) < bestCost) { best = u; bestCost = du + ends.get(u); }
380
+ for (const e of net.adj[u]) {
381
+ const nd = du + e.w * (net.discount.get(e.key) || 1);
382
+ if (nd < (dist.get(e.to) ?? Infinity)) { dist.set(e.to, nd); prev.set(e.to, { from: u, key: e.key }); }
383
+ }
384
+ }
385
+ if (best === null) return null;
386
+ const path = [];
387
+ for (let u = best; u !== undefined; u = prev.get(u)?.from) {
388
+ path.push(u);
389
+ const step = prev.get(u);
390
+ if (step && o.bundle > 0) net.discount.set(step.key, Math.max(0.3, (net.discount.get(step.key) || 1) * (1 - o.bundle)));
391
+ }
392
+ return path.reverse();
393
+ }
394
+
395
+ // How a route gets on or off the network at an item or at a point.
396
+ function attachItem(net, item) {
397
+ const i = net.index.get(item);
398
+ const out = new Map();
399
+ for (const id of net.touching.get(i) || []) {
400
+ const p = net.nodes[id];
401
+ const c = Math.hypot(p.x - item.x, p.y - item.y) * (net.blockedBy(item, p, [item]) ? o.detour : 1);
402
+ out.set(id, c);
403
+ }
404
+ return out;
405
+ }
406
+ function attachPoint(net, pt) {
407
+ const out = new Map();
408
+ const near = net.nodes.map((p, id) => [id, Math.hypot(p.x - pt.x, p.y - pt.y)]).sort((a, b) => a[1] - b[1]).slice(0, 4);
409
+ for (const [id, d] of near) out.set(id, d * (net.blockedBy(pt, net.nodes[id], []) ? o.detour : 1));
410
+ return out;
411
+ }
412
+
413
+ // Straighten a route: keep only the waypoints needed to get around obstacles.
414
+ function pull(points, obstacles) {
415
+ const clear = (p, q) => !obstacles.some((m) => crosses(p, q, m, itemRadius(m)));
416
+ const out = [points[0]];
417
+ let i = 0;
418
+ while (i < points.length - 1) {
419
+ let j = points.length - 1;
420
+ while (j > i + 1 && !clear(points[i], points[j])) j--;
421
+ out.push(points[j]);
422
+ i = j;
423
+ }
424
+ return out;
425
+ }
426
+
427
+ // Inside `folder`: from item a to item b, or from item a to a point on the edge.
428
+ function within(folder, a, b, pt) {
429
+ const net = network(folder);
430
+ const end = pt || { x: b.x, y: b.y };
431
+ const obstacles = net.items.filter((n) => n !== a && n !== b);
432
+ const start = { x: a.x, y: a.y };
433
+ if (!obstacles.some((m) => crosses(start, end, m, itemRadius(m)))) return [start, end];
434
+ const starts = attachItem(net, a);
435
+ const ends = b ? attachItem(net, b) : attachPoint(net, pt);
436
+ const path = starts.size && ends.size ? shortest(net, starts, ends) : null;
437
+ return pull([start, ...(path || []).map((id) => net.nodes[id]), end], obstacles);
438
+ }
439
+
440
+ // Where a route leaves circle c heading for point q.
441
+ const edgeToward = (c, q) => {
442
+ const dx = q.x - c.x, dy = q.y - c.y, d = Math.hypot(dx, dy) || 1;
443
+ return { x: c.x + (dx / d) * c.r, y: c.y + (dy / d) * c.r };
444
+ };
445
+ const childToward = (folder, rep) => rep.ancestors().find((n) => n.parent === folder);
446
+
447
+ // From rep out to the point `exit` on the edge of `folder` (which contains rep).
448
+ function climb(folder, rep, exit) {
449
+ const child = childToward(folder, rep);
450
+ const path = within(folder, child, null, exit);
451
+ if (child === rep) return path;
452
+ const inner = climb(child, rep, edgeToward(child, path[1]));
453
+ return [...inner, ...path.slice(1)];
454
+ }
455
+
456
+ // The full route between two shown items, through their folders.
457
+ function route(ra, rb) {
458
+ const lca = lowestCommon(ra, rb);
459
+ const ca = childToward(lca, ra), cb = childToward(lca, rb);
460
+ const middle = within(lca, ca, cb);
461
+ const left = ca === ra ? [middle[0]] : climb(ca, ra, edgeToward(ca, middle[1]));
462
+ const right = cb === rb ? [middle[middle.length - 1]] : climb(cb, rb, edgeToward(cb, middle[middle.length - 2])).reverse();
463
+ return [...left, ...middle.slice(1, -1), ...right].map((p) => [p.x, p.y]);
464
+ }
465
+
466
+ return { route, itemRadius };
467
+ }
468
+
469
+ // Crossings, for tuning: routes through bubbles they do not belong to, and
470
+ // routes crossing each other.
471
+ function crossings(routes, shown, itemRadius) {
472
+ let bubbles = 0, lines = 0;
473
+ const seg = (a, b, c, d) => {
474
+ const o1 = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
475
+ const o2 = (b[0] - a[0]) * (d[1] - a[1]) - (b[1] - a[1]) * (d[0] - a[0]);
476
+ const o3 = (d[0] - c[0]) * (a[1] - c[1]) - (d[1] - c[1]) * (a[0] - c[0]);
477
+ const o4 = (d[0] - c[0]) * (b[1] - c[1]) - (d[1] - c[1]) * (b[0] - c[0]);
478
+ return o1 * o2 < 0 && o3 * o4 < 0;
479
+ };
480
+ for (const r of routes) {
481
+ const own = new Set([...r.p.ancestors(), ...r.q.ancestors()]);
482
+ for (const n of shown) {
483
+ if (own.has(n)) continue;
484
+ const rad = itemRadius(n);
485
+ for (let i = 0; i < r.pts.length - 1; i++) {
486
+ if (crosses({ x: r.pts[i][0], y: r.pts[i][1] }, { x: r.pts[i + 1][0], y: r.pts[i + 1][1] }, n, rad)) { bubbles++; break; }
487
+ }
488
+ }
489
+ }
490
+ for (let i = 0; i < routes.length; i++) {
491
+ for (let j = i + 1; j < routes.length; j++) {
492
+ const A = routes[i].pts, B = routes[j].pts;
493
+ let hit = false;
494
+ for (let a = 0; a < A.length - 1 && !hit; a++) {
495
+ for (let b = 0; b < B.length - 1 && !hit; b++) hit = seg(A[a], A[a + 1], B[b], B[b + 1]);
496
+ }
497
+ if (hit) lines++;
498
+ }
499
+ }
500
+ return { bubbles, lines };
501
+ }
502
+
503
+ // --------------------------------------------------------------- view
504
+
505
+ const curve = d3.line().curve(d3.curveBasis); // stays within its waypoints: no loops
506
+
507
+ export function mapView(focusRef = "", { path = "" } = {}) {
508
+ document.title = `Map · ${store.site.title}`;
509
+ let o = effective();
510
+ // A study path: a note and everything it requires, numbered in reading order.
511
+ // Only requires links between them are drawn, and everything else fades.
512
+ const goal = path && store.concepts.get(path);
513
+ const trail = goal ? [...prerequisites(goal.id), goal].map((c) => c.id) : null;
514
+ const step = new Map((trail || []).map((id, i) => [id, i + 1]));
515
+ const wrap = h("div", { class: "graph-wrap map-wrap" });
516
+ const svg = d3.select(wrap).append("svg").attr("role", "img").attr("aria-label", "Knowledge map");
517
+ const tip = h("div", { class: "graph-tip", hidden: true });
518
+ const crumbs = h("nav", { class: "map-crumbs", "aria-label": "Current folder" });
519
+ const readout = h("p", { class: "map-readout" });
520
+ const panel = controls({
521
+ view: () => { o = effective(); cache = null; schedule(); },
522
+ tune: () => { o = effective(); cache = null; arrange(); schedule(); },
523
+ readout,
524
+ });
525
+ const status = h("p", { class: "map-status", role: "status", hidden: true }, "Arranging the map…");
526
+ wrap.append(panel, crumbs, tip, status, h("div", { class: "graph-hint" }, "Click a note to open it, a region to zoom in, empty space to step out."));
527
+ if (trail) wrap.append(trailCard());
528
+
529
+ const defs = svg.append("defs");
530
+ const back = svg.append("rect").attr("class", "m-back");
531
+ // Everything drawn lives in one layer. During a gesture that layer is only
532
+ // moved and scaled (cheap for the GPU); the full redraw, which places routes
533
+ // and labels, happens when the gesture pauses, ends or has changed a lot.
534
+ const world = svg.append("g").attr("class", "m-world");
535
+ const gRegions = world.append("g");
536
+ // Routes are drawn twice when a folder is in focus: faded everywhere, and at
537
+ // full strength clipped to the focused folder, so the detail you are looking
538
+ // at is clear while routes still show where they lead.
539
+ const clip = defs.append("clipPath").attr("id", "m-focus-clip").append("circle");
540
+ const gradients = defs.append("g"); // one per one-way route: direction as colour
541
+ const gLinks = world.append("g").attr("class", "m-routes");
542
+ const gFocus = world.append("g").attr("class", "m-routes m-focus").attr("clip-path", "url(#m-focus-clip)");
543
+ const gImplied = world.append("g").attr("class", "m-implied-links");
544
+ const gNotes = world.append("g");
545
+ const gSteps = world.append("g").attr("class", "m-steps");
546
+ const gLabels = world.append("g").attr("class", "m-labels");
547
+
548
+ let model = timed("map-model", buildModel);
549
+ // The map is placed at once: from this browser's cache when it has laid out
550
+ // the same contents with the same settings before, otherwise in the quick
551
+ // starting arrangement while the full layout is worked out in a worker
552
+ // (layout.js); then it settles. Positions are updated in place, so the
553
+ // hierarchy (L) changes only when the contents do.
554
+ let L = timed("map-place", () => start(model.root));
555
+ let placedKey = null, wantedKey = null; // the layout L shows, and the one asked for
556
+ let settled = false, viewed = false, userMoved = false, left = false, markSettled = false;
557
+ let cache = null; // routes for the current set of open folders
558
+ let w = 800, hgt = 600;
559
+ let focus = L.root;
560
+ let hovered = null;
561
+ let current = null; // screen helpers from the last render, for hover
562
+ const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
563
+
564
+ function arrange() {
565
+ const plain = plainModel(model);
566
+ const key = layoutKey(plain, o);
567
+ if (key === wantedKey) return;
568
+ wantedKey = key;
569
+ if (key === placedKey) { status.hidden = true; return; } // back to what is shown
570
+ const hit = cached(key, L.root.descendants().length);
571
+ if (hit) return settle(key, hit);
572
+ status.hidden = false;
573
+ const t0 = performance.now();
574
+ computeLayout(plain, o).then((xyr) => {
575
+ if (left || key !== wantedKey) return; // left the map, or the settings moved on
576
+ measure("map-layout", t0);
577
+ remember(key, xyr);
578
+ settle(key, xyr);
579
+ });
580
+ }
581
+
582
+ function settle(key, xyr) {
583
+ applyPositions(L.root, xyr);
584
+ placedKey = key;
585
+ status.hidden = true;
586
+ cache = null;
587
+ markSettled = true;
588
+ if (!settled) {
589
+ settled = true;
590
+ if (viewed && !userMoved) initialView(); // fit the settled map, unless the reader has moved it
591
+ }
592
+ schedule();
593
+ }
594
+
595
+ // After a change of contents: a new hierarchy, then a layout for it.
596
+ function rebuild() {
597
+ L = timed("map-place", () => start(model.root));
598
+ placedKey = wantedKey = null;
599
+ settled = false;
600
+ cache = null;
601
+ focus = L.root;
602
+ arrange();
603
+ schedule();
604
+ }
605
+
606
+ let drawnAt = null, drawnWhen = 0; // the transform and time of the last full redraw
607
+ let moving = false;
608
+ const zoom = d3.zoom().scaleExtent([0.2, 80])
609
+ .on("start", (event) => { if (event.sourceEvent) userMoved = true; moving = true; svg.classed("moving", true); })
610
+ .on("zoom", (event) => {
611
+ const t = event.transform;
612
+ M.transform = t;
613
+ const s = drawnAt ? t.k / drawnAt.k : 0;
614
+ if (moving && drawnAt && s > 0.67 && s < 1.5 && performance.now() - drawnWhen < 350) {
615
+ world.attr("transform", `translate(${t.x - drawnAt.x * s},${t.y - drawnAt.y * s}) scale(${s})`);
616
+ } else schedule();
617
+ })
618
+ .on("end", () => { moving = false; svg.classed("moving", false); schedule(); persist(); });
619
+ svg.call(zoom).on("dblclick.zoom", null);
620
+
621
+ let queued = false;
622
+ function schedule() {
623
+ if (queued) return;
624
+ queued = true;
625
+ requestAnimationFrame(() => {
626
+ queued = false;
627
+ timed("map-render", render);
628
+ if (markSettled) { markSettled = false; measure("map-settled", 0); } // since the page opened
629
+ });
630
+ }
631
+
632
+ function fitTransform(n) {
633
+ const k = (Math.min(w, hgt) / (2 * n.r)) * 0.92;
634
+ return d3.zoomIdentity.translate(w / 2 - k * n.x, hgt / 2 - k * n.y).scale(k);
635
+ }
636
+
637
+ function zoomTo(n) {
638
+ const t = fitTransform(n);
639
+ if (reduceMotion) svg.call(zoom.transform, t);
640
+ else svg.transition().duration(550).call(zoom.transform, t);
641
+ }
642
+
643
+ back.on("click", () => { if (focus.parent) zoomTo(focus.parent); });
644
+
645
+ const fadeIn = (enter) => {
646
+ if (!reduceMotion) enter.style("opacity", 0).transition().duration(220).style("opacity", null);
647
+ return enter;
648
+ };
649
+
650
+ // Routes for every link between shown items; recomputed only when the set of
651
+ // open folders (or a setting) changes, so zooming stays cheap.
652
+ function routesFor(open) {
653
+ const filters = ["showLinks", "distMeasure", "distMin", "distMax", "rateMin", "rateMax", "hideImplied", "focusOnly", "lanes"].map((k) => o[k]).join(",");
654
+ const signature = [...open].map((n) => n.data.id).sort().join(",") + "|" + filters + (o.focusOnly ? "|" + focus.data.id : "") + (trail ? "|" + path : "");
655
+ if (cache?.signature === signature) return cache;
656
+ const start = performance.now();
657
+ const shownRep = (leaf) => {
658
+ for (const a of leaf.ancestors().reverse()) if (!(a.data.kind === "dir" && open.has(a))) return a;
659
+ return leaf;
660
+ };
661
+ const merged = new Map();
662
+ const inFocus = (n) => n.ancestors().includes(focus);
663
+ let hidden = 0;
664
+ const kept = [];
665
+ // (m.dir: 1 if every link in a route runs p → q, -1 if q → p, 0 if both ways.)
666
+ if (trail) {
667
+ for (const [a, b, s] of model.edges) {
668
+ if (s !== 3 || !step.has(a) || !step.has(b)) continue;
669
+ if (o.hideImplied && model.implied.has(a + "\n" + b)) { hidden++; continue; }
670
+ const na = L.byId.get("c:" + a), nb = L.byId.get("c:" + b);
671
+ if (na && nb) kept.push([a, b, s, na, nb]);
672
+ }
673
+ } else if (o.showLinks) {
674
+ for (const [a, b, s] of model.edges) {
675
+ if (s < o.rateMin || s > o.rateMax) continue;
676
+ if (o.hideImplied && model.hasRatings && model.implied.has(a + "\n" + b)) { hidden++; continue; }
677
+ const na = L.byId.get("c:" + a), nb = L.byId.get("c:" + b);
678
+ if (!na || !nb) continue;
679
+ const d = wallsBetween(na, nb, o.distMeasure);
680
+ if (d < o.distMin || d > o.distMax) continue;
681
+ if (o.focusOnly && focus !== L.root && !inFocus(na) && !inFocus(nb)) continue;
682
+ kept.push([a, b, s, na, nb]);
683
+ }
684
+ }
685
+ const keptKeys = new Set(kept.map(([a, b]) => a + "\n" + b));
686
+ for (const [a, b, s, na, nb] of kept) {
687
+ {
688
+ const within = na.parent === nb.parent;
689
+ const ra = shownRep(na), rb = shownRep(nb);
690
+ if (ra === rb) continue;
691
+ const [p, q] = ra.data.id < rb.data.id ? [ra, rb] : [rb, ra];
692
+ // With lanes on, one-way links travel apart from two-way ones: +1 from p
693
+ // to q, -1 from q to p, 0 both ways.
694
+ const lane = !o.lanes || keptKeys.has(b + "\n" + a) ? 0 : ra === p ? 1 : -1;
695
+ const key = p.data.id + "|" + q.data.id + (o.lanes ? "|" + lane : "");
696
+ const m = merged.get(key) || { key, pair: p.data.id + "|" + q.data.id, lane, p, q, count: 0, across: 0, strength: 0, ends: new Set(), links: [] };
697
+ m.count += 1;
698
+ if (!within) m.across += 1;
699
+ m.strength = Math.max(m.strength, s);
700
+ m.ends.add(na.data.id); m.ends.add(nb.data.id);
701
+ m.links.push([a, b, s]);
702
+ const way = ra === p ? 1 : -1; // this link runs p → q (1) or q → p (-1)
703
+ m.dir = m.count === 1 ? way : m.dir === way ? way : 0;
704
+ merged.set(key, m);
705
+ }
706
+ }
707
+ // Heavier bundles first, so lighter ones follow their corridors.
708
+ const router = makeRouter(o);
709
+ const routes = [...merged.values()].sort((a, b) => b.count - a.count || (a.key < b.key ? -1 : 1));
710
+ const byPair = new Map(); // lanes of the same pair share one route
711
+ for (const m of routes) {
712
+ if (!byPair.has(m.pair)) byPair.set(m.pair, router.route(m.p, m.q));
713
+ m.pts = byPair.get(m.pair);
714
+ }
715
+ const shown = [];
716
+ L.root.each((n) => { if (n.parent && open.has(n.parent)) shown.push(n); });
717
+ // Measure each physical route once, however many lanes share it.
718
+ const physical = [...new Map(routes.map((m) => [m.pair, m])).values()];
719
+ const count = crossings(physical, shown, router.itemRadius);
720
+ // Stretch: how much longer routes are than straight lines, on average.
721
+ const len = (pts) => pts.slice(1).reduce((s, p, i) => s + Math.hypot(p[0] - pts[i][0], p[1] - pts[i][1]), 0);
722
+ const stretch = physical.length ? physical.reduce((s, r) => s + len(r.pts) / Math.max(1, Math.hypot(r.p.x - r.q.x, r.p.y - r.q.y)), 0) / physical.length : 1;
723
+ readout.textContent = `${routes.length} routes · ${hidden} implied links hidden · ${count.bubbles} through bubbles · ${count.lines} route crossings · ${stretch.toFixed(2)}× stretch`;
724
+ Object.assign(readout.dataset, { bubbles: count.bubbles, lines: count.lines, stretch: stretch.toFixed(3), routes: routes.length, hidden });
725
+ cache = { signature, routes, shownRep };
726
+ measure("map-routes", start);
727
+ return cache;
728
+ }
729
+
730
+ function render() {
731
+ const t = M.transform || d3.zoomIdentity;
732
+ drawnAt = t; drawnWhen = performance.now();
733
+ world.attr("transform", null);
734
+ const sx = (n) => t.applyX(n.x), sy = (n) => t.applyY(n.y), sr = (n) => n.r * t.k;
735
+ // Notes are drawn as places of bounded size.
736
+ const dot = (n) => Math.max(2.5, Math.min(n.data.landmark ? o.dotMax + 3 : o.dotMax, n.r * o.dot * t.k));
737
+ const radius = (n) => (n.data.kind === "concept" ? dot(n) + (n.data.landmark ? 3 : 0) : sr(n));
738
+ const onScreen = (n) => sx(n) + sr(n) > 0 && sx(n) - sr(n) < w && sy(n) + sr(n) > 0 && sy(n) - sr(n) < hgt;
739
+
740
+ // What is open: the root, and any folder big enough on screen whose parent is open.
741
+ const open = new Set();
742
+ L.root.each((n) => {
743
+ if (n.data.kind !== "dir") return;
744
+ if (!n.parent || (open.has(n.parent) && sr(n) >= o.detail)) open.add(n);
745
+ });
746
+ const visible = [];
747
+ L.root.each((n) => { if (n.parent && open.has(n.parent) && onScreen(n)) visible.push(n); });
748
+
749
+ // Focus: the deepest open folder under the centre of the view.
750
+ focus = L.root;
751
+ for (const n of open) {
752
+ const d = Math.hypot(sx(n) - w / 2, sy(n) - hgt / 2);
753
+ if (d < sr(n) && sr(n) >= Math.min(w, hgt) * 0.3 && n.depth > focus.depth) focus = n;
754
+ }
755
+ drawCrumbs();
756
+
757
+ // Territories (folders), outermost first so inner ones sit on top.
758
+ const regions = visible.filter((n) => n.data.kind === "dir").sort((a, b) => a.depth - b.depth);
759
+ gRegions.selectAll("circle").data(regions, (n) => n.data.id).join((enter) => fadeIn(enter.append("circle")))
760
+ .attr("class", (n) => `m-dir ${open.has(n) ? "open" : "closed"} depth-${Math.min(n.depth, 3)}`)
761
+ .attr("cx", sx).attr("cy", sy).attr("r", sr)
762
+ .style("--c", (n) => (n.group >= 0 ? PALETTE[n.group % PALETTE.length] : "var(--ink-faint)"))
763
+ .on("click", (event, n) => { event.stopPropagation(); zoomTo(n === focus && n.parent ? n.parent : n); })
764
+ .on("pointerenter", (event, n) => showTip(event, n))
765
+ .on("pointerleave", () => { tip.hidden = true; });
766
+
767
+ // Routes.
768
+ const { routes } = routesFor(open);
769
+ const toScreen = (pts) => pts.map(([x, y]) => [t.applyX(x), t.applyY(y)]);
770
+ const trim = (from, toward, r) => {
771
+ const dx = toward[0] - from[0], dy = toward[1] - from[1], d = Math.hypot(dx, dy) || 1;
772
+ return [from[0] + (dx / d) * (r + 2), from[1] + (dy / d) * (r + 2)];
773
+ };
774
+ // Shift a polyline sideways (for lanes): each point moves along the average
775
+ // of its segments' normals.
776
+ const shift = (pts, by) => {
777
+ if (!by) return pts;
778
+ return pts.map((pt, i) => {
779
+ let nx = 0, ny = 0;
780
+ for (const [u, v] of [[pts[i - 1], pt], [pt, pts[i + 1]]]) {
781
+ if (!u || !v) continue;
782
+ const dx = v[0] - u[0], dy = v[1] - u[1], d = Math.hypot(dx, dy) || 1;
783
+ nx += -dy / d; ny += dx / d;
784
+ }
785
+ const n = Math.hypot(nx, ny) || 1;
786
+ return [pt[0] + (nx / n) * by, pt[1] + (ny / n) * by];
787
+ });
788
+ };
789
+ const pathFor = (m) => {
790
+ let s = toScreen(m.pts);
791
+ const near = (pt, n) => Math.hypot(pt[0] - sx(n), pt[1] - sy(n)) < radius(n) + 14;
792
+ s = [s[0], ...s.slice(1, -1).filter((pt) => !near(pt, m.p) && !near(pt, m.q)), s[s.length - 1]];
793
+ const offset = (m.lane || 0) * o.laneGap;
794
+ if (s.length === 2) {
795
+ if (Math.hypot(s[1][0] - s[0][0], s[1][1] - s[0][1]) < radius(m.p) + radius(m.q) + 4) return "";
796
+ let f = trim(s[0], s[1], radius(m.p)), g = trim(s[1], s[0], radius(m.q));
797
+ const dx = g[0] - f[0], dy = g[1] - f[1], d = Math.hypot(dx, dy) || 1;
798
+ const bow = Math.min(d * o.bow, 36);
799
+ let c = [(f[0] + g[0]) / 2 - (dy / d) * bow, (f[1] + g[1]) / 2 + (dx / d) * bow];
800
+ [f, c, g] = [f, c, g].map((pt) => [pt[0] - (dy / d) * offset, pt[1] + (dx / d) * offset]);
801
+ return `M${f[0]},${f[1]} Q${c[0]},${c[1]} ${g[0]},${g[1]}`;
802
+ }
803
+ s[0] = trim(s[0], s[1], radius(m.p));
804
+ s[s.length - 1] = trim(s[s.length - 1], s[s.length - 2], radius(m.q));
805
+ return curve(shift(s, offset));
806
+ };
807
+ const focused = focus !== L.root;
808
+ const inFocus = (n) => n.ancestors().includes(focus);
809
+ // Direction as colour: a gradient running from the note that links to the
810
+ // note it links to; links both ways (or mixed) take the neutral colour.
811
+ const gradId = (m) => "rg-" + m.key.replace(/[^\w-]/g, "_");
812
+ const oneWay = routes.filter((m) => m.dir);
813
+ gradients.selectAll("linearGradient").data(oneWay, (m) => m.key).join((enter) => {
814
+ const g = enter.append("linearGradient").attr("gradientUnits", "userSpaceOnUse");
815
+ g.append("stop").attr("offset", "0").style("stop-color", "var(--route-from, var(--accent))");
816
+ g.append("stop").attr("offset", "1").style("stop-color", "var(--route-to, var(--stale))");
817
+ return g;
818
+ })
819
+ .attr("id", gradId)
820
+ .each(function (m) {
821
+ const [from, to] = m.dir > 0 ? [m.p, m.q] : [m.q, m.p];
822
+ d3.select(this).attr("x1", sx(from)).attr("y1", sy(from)).attr("x2", sx(to)).attr("y2", sy(to));
823
+ });
824
+ const drawRoutes = (group, list) => group.selectAll("path").data(list, (m) => m.key).join((enter) => fadeIn(enter.append("path")))
825
+ .attr("class", (m) => `m-link s${m.strength}`)
826
+ .style("stroke", (m) => (m.dir ? `url(#${gradId(m)})` : "var(--route-both, var(--ink-faint))"))
827
+ .attr("stroke-width", (m) => o.width * (1 + 0.9 * Math.log2(m.count)))
828
+ .attr("d", pathFor)
829
+ .selectAll("title").data((m) => [m]).join("title")
830
+ .text((m) => `${m.count} link${m.count > 1 ? "s" : ""} between ${m.p.data.label} and ${m.q.data.label} (strongest: ${STRENGTH_LABEL[m.strength]})`);
831
+ gLinks.classed("context", focused);
832
+ drawRoutes(gLinks, routes);
833
+ clip.attr("cx", sx(focus)).attr("cy", sy(focus)).attr("r", focused ? sr(focus) : 0);
834
+ drawRoutes(gFocus, focused ? routes.filter((m) => inFocus(m.p) || inFocus(m.q)) : []);
835
+
836
+ // Places (notes): a marker shaped by type, with a ring for landmarks.
837
+ const notes = visible.filter((n) => n.data.kind === "concept");
838
+ gNotes.selectAll("g.m-place").data(notes, (n) => n.data.id).join((enter) => {
839
+ const g = enter.append("g").attr("tabindex", 0).attr("role", "link");
840
+ g.append("circle").attr("class", "ring");
841
+ g.append("path").attr("class", "mark");
842
+ return fadeIn(g);
843
+ })
844
+ .attr("class", (n) => `m-place ${n.data.marker}${n.data.landmark ? " landmark" : ""} ${trustState(n.data.c)}${step.has(n.data.ref) ? " on-trail" : ""}`)
845
+ .attr("aria-label", (n) => n.data.label)
846
+ .attr("transform", (n) => `translate(${sx(n)},${sy(n)})`)
847
+ .style("--c", (n) => (n.group >= 0 ? PALETTE[n.group % PALETTE.length] : "var(--ink-soft)"))
848
+ .on("click", (event, n) => { event.stopPropagation(); persist(); location.hash = conceptHref(n.data.ref); })
849
+ .on("keydown", (event, n) => { if (event.key === "Enter") { persist(); location.hash = conceptHref(n.data.ref); } })
850
+ .on("pointerenter", (event, n) => { showTip(event, n); hover(n); })
851
+ .on("pointerleave", () => { tip.hidden = true; hover(null); })
852
+ .each(function (n) {
853
+ const r = dot(n);
854
+ const g = d3.select(this);
855
+ g.select(".ring").attr("r", n.data.landmark ? r + 3.5 : 0);
856
+ g.select(".mark").attr("d", d3.symbol(SYMBOLS[n.data.marker], Math.PI * r * r)());
857
+ });
858
+
859
+ current = { sx, sy, radius };
860
+ svg.classed("trail", !!trail);
861
+ if (trail) drawSteps(visible, open, sx, sy, sr, dot);
862
+ drawLabels(open, visible, notes, sx, sy, sr, dot);
863
+ if (hovered) hover(hovered);
864
+ }
865
+
866
+ function drawLabels(open, visible, notes, sx, sy, sr, dot) {
867
+ const budget = o.labels;
868
+ const placed = [];
869
+ // Places are obstacles too, so labels do not cover other places.
870
+ const dots = notes.map((n) => { const r = dot(n) + (n.data.landmark ? 4 : 0); return [sx(n) - r, sy(n) - r, sx(n) + r, sy(n) + r]; });
871
+ const clash = (box, list) => list.some((b) => box[0] < b[2] && box[2] > b[0] && box[1] < b[3] && box[3] > b[1]);
872
+ const fits = (box, own) => box[0] > -40 && box[2] < w + 40 && box[1] > -20 && box[3] < hgt + 20 &&
873
+ !clash(box, placed) && !clash(box, dots.filter((d) => d !== own));
874
+ const arcs = [], texts = [];
875
+
876
+ // Open territories: name along the top of the region, outermost first.
877
+ for (const n of [...open].filter((n) => n.parent).sort((a, b) => a.depth - b.depth)) {
878
+ if (arcs.length + texts.length >= budget) break;
879
+ const R = sr(n) - 8;
880
+ const width = n.data.label.length * 8.6;
881
+ if (R < 30 || width > R * 2.2) continue;
882
+ const box = [sx(n) - width / 2, sy(n) - R - 8, sx(n) + width / 2, sy(n) - R + 14];
883
+ if (!fits(box)) continue;
884
+ placed.push(box);
885
+ arcs.push({ n, R });
886
+ }
887
+ // Closed territories: name and size in the middle.
888
+ const closed = visible.filter((n) => n.data.kind === "dir" && !open.has(n)).sort((a, b) => sr(b) - sr(a));
889
+ for (const n of closed) {
890
+ if (arcs.length + texts.length >= budget) break;
891
+ const count = n.leaves().filter((l) => l.data.kind === "concept").length;
892
+ const lines = [n.data.label, `${count} note${count === 1 ? "" : "s"}`];
893
+ const width = n.data.label.length * 8.2;
894
+ if (sr(n) < 14) continue;
895
+ const box = [sx(n) - width / 2, sy(n) - 12, sx(n) + width / 2, sy(n) + 18];
896
+ if (!fits(box)) continue;
897
+ placed.push(box);
898
+ texts.push({ n, x: sx(n), y: sy(n) - 1, lines, anchor: "middle", cls: "territory" });
899
+ }
900
+ // Places: beside the marker, trying right, left, above and below.
901
+ const ranked = [...notes].sort((a, b) => step.has(b.data.ref) - step.has(a.data.ref) || b.data.weight - a.data.weight || sr(b) - sr(a));
902
+ for (const n of ranked) {
903
+ if (arcs.length + texts.length >= budget) break;
904
+ const r = dot(n) + (n.data.landmark ? 4 : 0), x = sx(n), y = sy(n);
905
+ const charW = n.data.landmark ? 6.9 : 6.4;
906
+ const lines = wrapWords(n.data.label, 150, charW);
907
+ if (lines.length > 3) continue;
908
+ const width = Math.max(...lines.map((l) => l.length)) * charW;
909
+ const height = lines.length * 14;
910
+ const own = dots[notes.indexOf(n)];
911
+ const spots = [
912
+ { box: [x + r + 4, y - height / 2, x + r + 4 + width, y + height / 2], tx: x + r + 4, anchor: "start" },
913
+ { box: [x - r - 4 - width, y - height / 2, x - r - 4, y + height / 2], tx: x - r - 4, anchor: "end" },
914
+ { box: [x - width / 2, y - r - 3 - height, x + width / 2, y - r - 3], tx: x, anchor: "middle" },
915
+ { box: [x - width / 2, y + r + 3, x + width / 2, y + r + 3 + height], tx: x, anchor: "middle" },
916
+ ];
917
+ const spot = spots.find((s) => fits(s.box, own));
918
+ if (!spot) continue;
919
+ placed.push(spot.box);
920
+ texts.push({ n, x: spot.tx, y: spot.box[1] + 11, lines, anchor: spot.anchor, cls: (n.data.landmark ? "place landmark" : "place") + (step.has(n.data.ref) ? " on-trail" : "") });
921
+ }
922
+
923
+ defs.selectAll("path").data(arcs, (a) => a.n.data.id).join("path")
924
+ .attr("id", (a) => "arc-" + a.n.data.id.replace(/[^\w-]/g, "_"))
925
+ .attr("d", (a) => {
926
+ const cx = sx(a.n), cy = sy(a.n), R = a.R;
927
+ const a0 = (-160 * Math.PI) / 180, a1 = (-20 * Math.PI) / 180;
928
+ return `M${cx + R * Math.cos(a0)},${cy + R * Math.sin(a0)} A${R},${R} 0 0 1 ${cx + R * Math.cos(a1)},${cy + R * Math.sin(a1)}`;
929
+ });
930
+ gLabels.selectAll("text.m-arc").data(arcs, (a) => a.n.data.id).join((enter) => {
931
+ const text = enter.append("text").attr("class", "m-arc");
932
+ text.append("textPath").attr("startOffset", "50%").attr("text-anchor", "middle");
933
+ return text;
934
+ })
935
+ .select("textPath")
936
+ .attr("href", (a) => "#arc-" + a.n.data.id.replace(/[^\w-]/g, "_"))
937
+ .text((a) => a.n.data.label);
938
+ gLabels.selectAll("text.m-text").data(texts, (d) => d.n.data.id).join("text")
939
+ .attr("class", (d) => `m-text ${d.cls}`)
940
+ .attr("data-id", (d) => d.n.data.id)
941
+ .attr("y", (d) => d.y)
942
+ .attr("text-anchor", (d) => d.anchor)
943
+ .selectAll("tspan").data((d) => d.lines.map((line, i) => ({ line, i, x: d.x, cls: d.cls }))).join("tspan")
944
+ .attr("class", (s) => (s.cls === "territory" && s.i ? "count" : null))
945
+ .attr("x", (s) => s.x).attr("dy", (s) => (s.i ? 15 : 0))
946
+ .text((s) => s.line);
947
+ }
948
+
949
+ // Hover: fade what is unrelated and bring forward the note's own routes.
950
+ function hover(n) {
951
+ hovered = n;
952
+ svg.classed("focusing", !!n);
953
+ if (!n || !cache) {
954
+ gNotes.selectAll("g.m-place").classed("related", false);
955
+ svg.selectAll(".m-routes path").classed("hot", false).classed("needs", false).classed("needed", false);
956
+ gLabels.selectAll("text").classed("related", false);
957
+ gImplied.selectAll("path").remove();
958
+ return;
959
+ }
960
+ const related = new Set([n.data.id]);
961
+ for (const [a, b] of model.edges) {
962
+ const other = a === n.data.ref ? b : b === n.data.ref ? a : null;
963
+ const on = other && L.byId.get("c:" + other);
964
+ if (!on) continue;
965
+ related.add(on.data.id);
966
+ related.add(cache.shownRep(on).data.id);
967
+ }
968
+ gNotes.selectAll("g.m-place").classed("related", (m) => related.has(m.data.id));
969
+ gLabels.selectAll("text.m-text").classed("related", function () { return related.has(this.getAttribute("data-id")); });
970
+ // Direction, by colour rather than arrows: what the note needs, and what needs it.
971
+ const mine = (m) => m.links.filter(([a, b]) => a === n.data.ref || b === n.data.ref);
972
+ svg.selectAll(".m-routes path")
973
+ .classed("hot", (m) => mine(m).length > 0)
974
+ .classed("needs", (m) => mine(m).some(([a]) => a === n.data.ref))
975
+ .classed("needed", (m) => mine(m).length > 0 && mine(m).every(([, b]) => b === n.data.ref));
976
+ svg.selectAll(".m-routes path.hot").raise();
977
+ // Its implied links, hidden from the map, shown faintly.
978
+ const { sx, sy, radius } = current;
979
+ const hiddenEnds = [];
980
+ if (o.hideImplied && model.hasRatings) {
981
+ for (const [a, b] of model.edges) {
982
+ if (!model.implied.has(a + "\n" + b) || (a !== n.data.ref && b !== n.data.ref)) continue;
983
+ const on = L.byId.get("c:" + (a === n.data.ref ? b : a));
984
+ const r = on && cache.shownRep(on);
985
+ if (r && r !== n) hiddenEnds.push(r);
986
+ }
987
+ }
988
+ gImplied.selectAll("path").data(hiddenEnds).join("path").attr("class", "m-implied")
989
+ .attr("d", (r) => {
990
+ const x1 = sx(n), y1 = sy(n), x2 = sx(r), y2 = sy(r);
991
+ const dx = x2 - x1, dy = y2 - y1, d = Math.hypot(dx, dy) || 1;
992
+ const r1 = radius(n) + 2, r2 = radius(r) + 2;
993
+ const bow = Math.min(40, d * 0.15);
994
+ return `M${x1 + (dx / d) * r1},${y1 + (dy / d) * r1} Q${(x1 + x2) / 2 - (dy / d) * bow},${(y1 + y2) / 2 + (dx / d) * bow} ${x2 - (dx / d) * r2},${y2 - (dy / d) * r2}`;
995
+ });
996
+ }
997
+
998
+ // Step numbers: on each note of the path, and on each closed folder holding
999
+ // some of it (as the steps inside, e.g. 3–5).
1000
+ function drawSteps(visible, open, sx, sy, sr, dot) {
1001
+ const badges = [];
1002
+ for (const n of visible) {
1003
+ if (n.data.kind === "concept") {
1004
+ if (!step.has(n.data.ref)) continue;
1005
+ const r = dot(n) + (n.data.landmark ? 3.5 : 0);
1006
+ badges.push({ id: n.data.id, x: sx(n) - r * 0.75, y: sy(n) - r * 0.75, text: String(step.get(n.data.ref)), last: n.data.ref === path });
1007
+ } else if (!open.has(n)) {
1008
+ const inside = n.leaves().map((l) => step.get(l.data.ref)).filter(Boolean).sort((a, b) => a - b);
1009
+ if (!inside.length) continue;
1010
+ badges.push({ id: n.data.id, x: sx(n) + sr(n) * 0.62, y: sy(n) - sr(n) * 0.62, text: spans(inside), last: inside.includes(trail.length) });
1011
+ }
1012
+ }
1013
+ gSteps.selectAll("g").data(badges, (b) => b.id).join((enter) => {
1014
+ const g = enter.append("g");
1015
+ g.append("rect");
1016
+ g.append("text");
1017
+ return g;
1018
+ })
1019
+ .attr("class", (b) => `m-step${b.last ? " goal" : ""}`)
1020
+ .attr("transform", (b) => `translate(${b.x},${b.y})`)
1021
+ .each(function (b) {
1022
+ const g = d3.select(this);
1023
+ const width = Math.max(16, b.text.length * 6.6 + 8);
1024
+ g.select("rect").attr("x", -width / 2).attr("y", -8).attr("width", width).attr("height", 16).attr("rx", 8);
1025
+ g.select("text").attr("y", 4).text(b.text);
1026
+ });
1027
+ }
1028
+
1029
+ function spans(nums) {
1030
+ const out = [];
1031
+ for (let i = 0; i < nums.length; i++) {
1032
+ let j = i;
1033
+ while (j + 1 < nums.length && nums[j + 1] === nums[j] + 1) j++;
1034
+ out.push(j > i ? `${nums[i]}–${nums[j]}` : String(nums[i]));
1035
+ i = j;
1036
+ }
1037
+ return out.length > 3 ? out.slice(0, 3).join(", ") + "…" : out.join(", ");
1038
+ }
1039
+
1040
+ // The list of steps beside the map; choosing one zooms to where it is.
1041
+ function trailCard() {
1042
+ const narrowNow = matchMedia("(max-width: 760px), (max-height: 560px)").matches;
1043
+ const list = h("ol", { class: "trail-steps" }, trail.map((id) => {
1044
+ const c = store.concepts.get(id);
1045
+ const b = h("button", { type: "button", title: "Show on the map" }, c.title);
1046
+ b.addEventListener("click", () => {
1047
+ const n = L.byId.get("c:" + id);
1048
+ if (!n) return;
1049
+ zoomTo(n.parent);
1050
+ hover(n);
1051
+ });
1052
+ return h("li", { class: id === path ? "goal" : null }, b,
1053
+ h("a", { href: conceptHref(id), "aria-label": `Open ${c.title}`, title: "Open the note" }, "open"));
1054
+ }));
1055
+ const close = h("a", { class: "trail-close", href: "#/map", "aria-label": "Close the study path" }, "×");
1056
+ return h("details", { class: "trail-card", open: !narrowNow },
1057
+ h("summary", {}, `Study path to ${goal.title}`, h("span", { class: "count" }, trail.length)),
1058
+ h("p", { class: "section-note" }, trail.length > 1
1059
+ ? "Read in this order: each note comes after the notes it requires."
1060
+ : "Nothing is marked as required before this note."),
1061
+ list, close);
1062
+ }
1063
+
1064
+ // A view that holds every note on the path.
1065
+ function trailTransform() {
1066
+ const nodes = trail.map((id) => L.byId.get("c:" + id)).filter(Boolean);
1067
+ if (!nodes.length) return null;
1068
+ const x0 = d3.min(nodes, (n) => n.x - n.r), x1 = d3.max(nodes, (n) => n.x + n.r);
1069
+ const y0 = d3.min(nodes, (n) => n.y - n.r), y1 = d3.max(nodes, (n) => n.y + n.r);
1070
+ const pad = 70; // screen pixels, for labels and badges
1071
+ const k = Math.min(80, (w - 2 * pad) / (x1 - x0), (hgt - 2 * pad) / (y1 - y0));
1072
+ return d3.zoomIdentity.translate(w / 2 - k * (x0 + x1) / 2, hgt / 2 - k * (y0 + y1) / 2).scale(k);
1073
+ }
1074
+
1075
+ function drawCrumbs() {
1076
+ const path = focus.ancestors().reverse();
1077
+ crumbs.replaceChildren(...path.flatMap((n, i) => {
1078
+ const b = h("button", { type: "button", "aria-current": n === focus ? "location" : null }, n.data.label);
1079
+ b.addEventListener("click", () => zoomTo(n));
1080
+ return i ? [h("span", { "aria-hidden": "true" }, "/"), b] : [b];
1081
+ }));
1082
+ }
1083
+
1084
+ function showTip(event, n) {
1085
+ const d = n.data;
1086
+ const lines = [h("strong", {}, d.label)];
1087
+ if (d.kind === "concept") {
1088
+ lines.push(h("span", {}, `${d.c.type || "Concept"} · ${TRUST_LABEL[trustState(d.c)]}${d.landmark ? " · Landmark" : ""}`));
1089
+ if (d.c.description) lines.push(h("div", {}, d.c.description));
1090
+ } else {
1091
+ const count = n.leaves().filter((l) => l.data.kind === "concept").length;
1092
+ lines.push(h("span", {}, `Folder · ${count} note${count === 1 ? "" : "s"}`));
1093
+ }
1094
+ tip.replaceChildren(...lines);
1095
+ const box = wrap.getBoundingClientRect();
1096
+ tip.style.left = `${Math.min(event.clientX - box.left + 14, box.width - 290)}px`;
1097
+ tip.style.top = `${event.clientY - box.top + 14}px`;
1098
+ tip.hidden = false;
1099
+ }
1100
+
1101
+ function size() {
1102
+ const rect = wrap.getBoundingClientRect();
1103
+ w = rect.width || 800; hgt = rect.height || 600;
1104
+ back.attr("width", w).attr("height", hgt);
1105
+ }
1106
+
1107
+ function initialView() {
1108
+ const target = focusRef ? L.byId.get("d:" + focusRef) : null;
1109
+ const onTrail = trail && trailTransform();
1110
+ if (onTrail) svg.call(zoom.transform, onTrail);
1111
+ else if (target) svg.call(zoom.transform, fitTransform(target));
1112
+ else svg.call(zoom.transform, M.transform || fitTransform(L.root));
1113
+ }
1114
+ arrange();
1115
+ requestAnimationFrame(() => {
1116
+ size();
1117
+ viewed = true;
1118
+ initialView();
1119
+ });
1120
+ const onResize = () => { size(); schedule(); };
1121
+ window.addEventListener("resize", onResize);
1122
+ wrap.leave = () => { left = true; window.removeEventListener("resize", onResize); persist(); };
1123
+ wrap.refresh = () => { model = timed("map-model", buildModel); o = effective(); rebuild(); };
1124
+ M.reset = () => zoomTo(L.root);
1125
+ wrap.routes = () => cache?.routes || []; // for tests and inspection
1126
+ wrap.layout = () => L;
1127
+ wrap.model = () => model;
1128
+ return wrap;
1129
+ }
1130
+
1131
+ // --------------------------------------------------------------- panel
1132
+
1133
+ function controls({ view, tune, readout }) {
1134
+ const narrow = matchMedia("(max-width: 760px), (max-height: 560px)").matches; // start collapsed where space is short
1135
+ const o = effective();
1136
+ const slider = (key, text, min, max, step, onChange, help) => {
1137
+ const fmt = (v) => (step < 1 ? Number(v).toFixed(2) : String(v));
1138
+ const input = h("input", { type: "range", min, max, step, value: o[key], "aria-label": text });
1139
+ const out = h("output", {}, fmt(o[key]));
1140
+ input.addEventListener("input", () => {
1141
+ M.user[key] = Number(input.value);
1142
+ out.textContent = fmt(M.user[key]);
1143
+ persist();
1144
+ onChange();
1145
+ });
1146
+ return h("label", { class: "slider", title: help || null }, h("span", {}, text), input, out);
1147
+ };
1148
+ // Two thumbs on one track: the lowest and highest value to show. The thumb
1149
+ // nearest the pointer moves; when they sit together, the direction of the
1150
+ // drag decides which. Arrow keys move a focused thumb.
1151
+ const dual = ([kLo, kHi], text, min, max, label, help) => {
1152
+ const clamp = (v) => Math.max(min, Math.min(max, Math.round(v)));
1153
+ const now = effective();
1154
+ let lo = clamp(now[kLo]), hi = Math.max(lo, clamp(now[kHi]));
1155
+ const track = h("span", { class: "dual" });
1156
+ const fill = h("span", { class: "dual-fill" });
1157
+ const thumb = (end, cls) => h("span", { class: `dual-thumb ${cls}`, role: "slider", tabindex: 0, "aria-label": `${text}: ${end}`,
1158
+ "aria-valuemin": min, "aria-valuemax": max });
1159
+ const tLo = thumb("from", "lo"), tHi = thumb("to", "hi");
1160
+ track.append(fill, tLo, tHi);
1161
+ const out = h("output");
1162
+ const pct = (v) => (max === min ? 0 : ((v - min) / (max - min)) * 100);
1163
+ const draw = () => {
1164
+ tLo.style.left = `${pct(lo)}%`; tHi.style.left = `${pct(hi)}%`;
1165
+ fill.style.left = `${pct(lo)}%`; fill.style.width = `${pct(hi) - pct(lo)}%`;
1166
+ for (const [t, v] of [[tLo, lo], [tHi, hi]]) { t.setAttribute("aria-valuenow", v); t.setAttribute("aria-valuetext", label(v)); }
1167
+ out.textContent = lo === hi ? label(lo) : `${label(lo)} to ${label(hi)}`;
1168
+ };
1169
+ const commit = () => {
1170
+ if (M.user[kLo] === lo && M.user[kHi] === hi) return;
1171
+ M.user[kLo] = lo; M.user[kHi] = hi; persist(); view();
1172
+ };
1173
+ const valueAt = (x) => { const r = track.getBoundingClientRect(); return clamp(min + Math.max(0, Math.min(1, (x - r.left) / r.width)) * (max - min)); };
1174
+ let active = null, startX = 0;
1175
+ const move = (v) => { if (active === "lo") lo = Math.min(v, hi); else if (active === "hi") hi = Math.max(v, lo); draw(); commit(); };
1176
+ track.addEventListener("pointerdown", (e) => {
1177
+ e.preventDefault();
1178
+ track.setPointerCapture(e.pointerId);
1179
+ startX = e.clientX;
1180
+ const v = valueAt(e.clientX);
1181
+ if (lo === hi) {
1182
+ // Together: the half pressed (the lower thumb sits left of the value)
1183
+ // decides, and dragging still works in either direction.
1184
+ const r = track.getBoundingClientRect(), at = r.left + (pct(lo) / 100) * r.width;
1185
+ active = v < lo ? "lo" : v > hi ? "hi" : Math.abs(e.clientX - at) > 1 ? (e.clientX < at ? "lo" : "hi") : null;
1186
+ } else active = Math.abs(v - lo) <= Math.abs(v - hi) ? "lo" : "hi";
1187
+ if (active) move(v);
1188
+ });
1189
+ track.addEventListener("pointermove", (e) => {
1190
+ if (!track.hasPointerCapture(e.pointerId)) return;
1191
+ if (!active) { if (Math.abs(e.clientX - startX) < 3) return; active = e.clientX < startX ? "lo" : "hi"; }
1192
+ move(valueAt(e.clientX));
1193
+ });
1194
+ const release = (e) => { if (track.hasPointerCapture(e.pointerId)) track.releasePointerCapture(e.pointerId); active = null; };
1195
+ track.addEventListener("pointerup", release);
1196
+ track.addEventListener("pointercancel", release);
1197
+ for (const [t, end] of [[tLo, "lo"], [tHi, "hi"]]) {
1198
+ t.addEventListener("keydown", (e) => {
1199
+ const step = { ArrowLeft: -1, ArrowDown: -1, ArrowRight: 1, ArrowUp: 1 }[e.key];
1200
+ if (!step) return;
1201
+ e.preventDefault();
1202
+ active = end;
1203
+ move(clamp((end === "lo" ? lo : hi) + step));
1204
+ active = null;
1205
+ });
1206
+ }
1207
+ draw();
1208
+ return h("div", { class: "slider dual-slider", title: help }, h("span", {}, text), track, out);
1209
+ };
1210
+ const toggle = (key, text, help) => {
1211
+ const b = h("button", { class: "toggle", type: "button", "aria-pressed": String(!!o[key]), title: help }, text);
1212
+ b.addEventListener("click", () => { M.user[key] = !effective()[key]; b.setAttribute("aria-pressed", String(M.user[key])); persist(); view(); });
1213
+ return b;
1214
+ };
1215
+ const maxDepth = Math.max(0, ...Object.keys(store.tree).filter(Boolean).map((id) => id.split("/").length));
1216
+ const rated = [...store.concepts.values()].some((c) => c.links.some((l) => l.rel));
1217
+ const walls = (v) => (v === 0 ? "same folder" : `${v} bubble${v === 1 ? "" : "s"}`);
1218
+ // The distance slider's range depends on how distance is counted.
1219
+ const distance = h("div", { class: "dist" });
1220
+ const drawDistance = () => {
1221
+ const e = effective();
1222
+ const max = e.distMeasure === "path" ? 2 * maxDepth : maxDepth;
1223
+ const measure = h("div", { class: "row seg" }, h("span", {}, "Count"),
1224
+ ...[["out", "Steps out", "The larger of the two ends' distances out to the lowest folder they share."],
1225
+ ["path", "Path length", "All bubble walls crossed, out from one end and in to the other."]].map(([key, text, help]) => {
1226
+ const b = h("button", { class: "toggle", type: "button", "aria-pressed": String(e.distMeasure === key), title: help }, text);
1227
+ b.addEventListener("click", () => {
1228
+ if (effective().distMeasure === key) return;
1229
+ M.user.distMeasure = key;
1230
+ M.user.distMin = 0; M.user.distMax = key === "path" ? 2 * maxDepth : maxDepth;
1231
+ persist(); drawDistance(); view();
1232
+ });
1233
+ return b;
1234
+ }));
1235
+ distance.replaceChildren(
1236
+ dual(["distMin", "distMax"], "Distance", 0, max, walls,
1237
+ "Which links to show by how many bubble walls separate their ends; 0 is links within one folder."),
1238
+ measure);
1239
+ };
1240
+ drawDistance();
1241
+ const button = (text, fn) => { const b = h("button", { class: "toggle", type: "button" }, text); b.addEventListener("click", fn); return b; };
1242
+
1243
+ // Tuning: every layout and routing parameter, with a way to keep the result.
1244
+ const snippet = h("pre", { class: "map-snippet", hidden: true });
1245
+ const showSnippet = () => {
1246
+ const e = effective();
1247
+ const lines = ["[map]", ...Object.keys(VIEW_DEFAULTS).map((k) => `${k} = ${e[k]}`), ...TUNING.map(([k]) => `${k} = ${e[k]}`)];
1248
+ snippet.textContent = lines.join("\n");
1249
+ snippet.hidden = !snippet.hidden;
1250
+ };
1251
+ const tuning = h("details", { class: "graph-options map-tuning" },
1252
+ h("summary", {}, "Tuning"),
1253
+ h("div", { class: "sliders" }, TUNING.map(([key, text, min, max, step, , help]) => slider(key, text, min, max, step, tune, help))),
1254
+ readout,
1255
+ h("div", { class: "row" },
1256
+ button("Reset tuning", () => { for (const [k] of TUNING) delete M.user[k]; persist(); location.reload(); }),
1257
+ button("Show as rdstudio.toml", showSnippet)),
1258
+ snippet);
1259
+
1260
+ const legend = h("div", { class: "legend map-legend" });
1261
+ const types = new Map();
1262
+ for (const c of store.concepts.values()) if (c.type && !types.has(c.type)) types.set(c.type, markerFor(c.type));
1263
+ for (const [type, shape] of [...types].sort()) {
1264
+ const icon = d3.select(h("svg:svg", { width: 14, height: 14, viewBox: "-7 -7 14 14", class: `m-key ${shape}` }));
1265
+ icon.append("path").attr("d", d3.symbol(SYMBOLS[shape], 36)());
1266
+ legend.append(h("span", {}, icon.node(), type));
1267
+ }
1268
+ legend.append(h("span", {}, h("i", { class: "key-landmark" }), "Landmark"));
1269
+
1270
+ return h("div", { class: "graph-panel map-panel" },
1271
+ h("details", { class: "graph-options", open: !narrow },
1272
+ h("summary", {}, "Options"),
1273
+ h("div", { class: "row" },
1274
+ toggle("showLinks", "Links", "Show links at all."),
1275
+ rated ? toggle("hideImplied", "Hide implied", "Hide a link when a chain of links at least as strong already connects its ends.") : "",
1276
+ toggle("focusOnly", "Focused folder only", "When zoomed into a folder, show only links with an end inside it."),
1277
+ toggle("lanes", "Lanes", "One-way links keep to one side of their route and two-way links take the middle, so opposite directions separate.")),
1278
+ h("div", { class: "sliders" },
1279
+ distance,
1280
+ rated ? dual(["rateMin", "rateMax"], "Importance", 1, 3, (v) => STRENGTH_LABEL[v],
1281
+ "Which links to show by rating: see also, uses (and unrated links), requires.") : "",
1282
+ slider("labels", "Labels", 5, 120, 1, view, "Most labels shown at once, most important first."),
1283
+ slider("detail", "Open folders at", 60, 400, 10, view, "A folder opens when its radius on screen passes this many pixels.")),
1284
+ legend,
1285
+ h("div", { class: "legend route-key" },
1286
+ h("span", {}, h("i", { class: "key-dir" }), "from a note to what it links to"),
1287
+ h("span", {}, h("i", { class: "key-both" }), "both ways"),
1288
+ rated ? h("span", {}, h("i", { class: "key-dotted" }), "see also") : ""),
1289
+ h("div", { class: "row" },
1290
+ button("Show everything", () => M.reset?.()),
1291
+ button("Default view", () => { for (const k of Object.keys(VIEW_DEFAULTS)) delete M.user[k]; persist(); location.reload(); }))),
1292
+ tuning);
1293
+ }