@rse/ase 1.0.6 → 2.0.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 (279) hide show
  1. package/dst/ase-agent-definition-api.d.ts +184 -0
  2. package/dst/ase-agent-definition-api.js +6 -0
  3. package/dst/ase-agent-definition-builtin/condenser.js +42 -0
  4. package/dst/ase-agent-definition-builtin/editor.js +45 -0
  5. package/dst/ase-agent-definition-builtin/estimator.js +98 -0
  6. package/dst/ase-agent-definition-builtin/implementer.js +121 -0
  7. package/dst/ase-agent-definition-builtin/integrator.js +56 -0
  8. package/dst/ase-agent-definition-builtin/qualifier.js +61 -0
  9. package/dst/ase-agent-definition-builtin/rebooter.js +41 -0
  10. package/dst/ase-agent-definition-builtin/researcher.js +77 -0
  11. package/dst/ase-agent-definition-core.js +827 -0
  12. package/dst/ase-agent-fleet.js +772 -0
  13. package/dst/ase-agent-process.js +232 -0
  14. package/dst/ase-agent-relay-client.js +509 -0
  15. package/dst/ase-agent-relay-core.js +213 -0
  16. package/dst/ase-agent-relay-server-bind.js +242 -0
  17. package/dst/ase-agent-relay-server-cli.js +50 -0
  18. package/dst/ase-agent-runtime-claim.js +189 -0
  19. package/dst/ase-agent-runtime-engine.js +1100 -0
  20. package/dst/ase-agent-runtime-exec.js +275 -0
  21. package/dst/ase-agent-runtime-phase.js +581 -0
  22. package/dst/ase-agent-runtime-request.js +272 -0
  23. package/dst/ase-agent-runtime-tool.js +207 -0
  24. package/dst/ase-agent-runtime.js +1316 -0
  25. package/dst/ase-agent.js +65 -0
  26. package/dst/ase-artifact.js +6 -5
  27. package/dst/ase-config-core.js +465 -0
  28. package/dst/ase-config-schema.js +351 -0
  29. package/dst/ase-config-scope.js +172 -0
  30. package/dst/ase-config.js +25 -585
  31. package/dst/ase-hook.js +230 -116
  32. package/dst/ase-lib-daemon-cli.js +130 -0
  33. package/dst/ase-lib-hash.js +20 -0
  34. package/dst/{ase-log.js → ase-lib-log.js} +40 -7
  35. package/dst/ase-lib-server-bind.js +115 -0
  36. package/dst/ase-lib-server-cli.js +474 -0
  37. package/dst/{ase-table.js → ase-lib-table.js} +1 -1
  38. package/dst/{ase-version.js → ase-lib-version.js} +3 -0
  39. package/dst/ase-man.js +96 -0
  40. package/dst/ase-mcp.js +166 -13
  41. package/dst/{ase-getopt.js → ase-service-getopt.js} +82 -80
  42. package/dst/{ase-markdown.js → ase-service-markdown.js} +18 -11
  43. package/dst/ase-service-otel.js +1246 -0
  44. package/dst/{ase-skills.js → ase-service-skills.js} +29 -64
  45. package/dst/ase-service-state.js +101 -0
  46. package/dst/ase-service.js +251 -76
  47. package/dst/ase-setup-common.js +141 -0
  48. package/dst/ase-setup-mcp.js +359 -0
  49. package/dst/ase-setup-settings.js +837 -0
  50. package/dst/ase-setup.js +113 -869
  51. package/dst/ase-spec.js +31 -19
  52. package/dst/ase-statusline.js +108 -43
  53. package/dst/ase-task-board-core.js +739 -0
  54. package/dst/ase-task-board-filter.js +127 -0
  55. package/dst/ase-task-board-flow.js +237 -0
  56. package/dst/ase-task-board-graph.js +497 -0
  57. package/dst/ase-task-board-stats.js +263 -0
  58. package/dst/ase-task-board-tui-control.js +775 -0
  59. package/dst/ase-task-board-tui-model.js +1008 -0
  60. package/dst/ase-task-board-tui-popup.js +626 -0
  61. package/dst/ase-task-board-tui-style.js +106 -0
  62. package/dst/ase-task-board-tui-view.js +847 -0
  63. package/dst/ase-task-board-tui.js +189 -0
  64. package/dst/ase-task-board-web-client.css +1 -0
  65. package/dst/ase-task-board-web-client.html +20 -0
  66. package/dst/ase-task-board-web-client.js +81 -0
  67. package/dst/ase-task-board-web-server.js +823 -0
  68. package/dst/ase-task-board.js +134 -0
  69. package/dst/ase-task-format.js +757 -0
  70. package/dst/ase-task-request.js +208 -0
  71. package/dst/ase-task-store-client.js +495 -0
  72. package/dst/ase-task-store-core.js +804 -0
  73. package/dst/ase-task-store-issues.js +237 -0
  74. package/dst/ase-task-store-plugin-api.js +6 -0
  75. package/dst/ase-task-store-plugin-ase.js +484 -0
  76. package/dst/ase-task-store-plugin-delegate.js +137 -0
  77. package/dst/ase-task-store-plugin-gitea.js +666 -0
  78. package/dst/ase-task-store-plugin-github.js +696 -0
  79. package/dst/ase-task-store-plugin-gitlab.js +668 -0
  80. package/dst/ase-task-store-server-bind.js +538 -0
  81. package/dst/ase-task-store-server-cli.js +154 -0
  82. package/dst/ase-task.js +1335 -357
  83. package/dst/{ase-compat.js → ase-util-compat.js} +2 -2
  84. package/dst/{ase-diagram.js → ase-util-diagram.js} +107 -32
  85. package/dst/{ase-meta.js → ase-util-meta.js} +2 -2
  86. package/dst/{ase-metric.js → ase-util-metric.js} +2 -2
  87. package/dst/{ase-mint.js → ase-util-mint.js} +19 -12
  88. package/dst/{ase-worktree.js → ase-util-worktree.js} +30 -19
  89. package/dst/ase-util.js +20 -0
  90. package/dst/ase.js +108 -32
  91. package/dst/man/ase-agent-adhoc.1 +515 -0
  92. package/dst/man/ase-agent-condenser.1 +193 -0
  93. package/dst/man/ase-agent-editor.1 +212 -0
  94. package/dst/man/ase-agent-estimator.1 +214 -0
  95. package/dst/man/ase-agent-fleet.1 +185 -0
  96. package/dst/man/ase-agent-implementer.1 +233 -0
  97. package/dst/man/ase-agent-integrator.1 +213 -0
  98. package/dst/man/ase-agent-qualifier.1 +214 -0
  99. package/dst/man/ase-agent-rebooter.1 +193 -0
  100. package/dst/man/ase-agent-relay.1 +128 -0
  101. package/dst/man/ase-agent-researcher.1 +217 -0
  102. package/dst/man/ase-agent.1 +264 -0
  103. package/dst/man/ase-artifact-list.1 +38 -0
  104. package/dst/man/ase-artifact-name.1 +39 -0
  105. package/dst/man/ase-artifact.1 +56 -0
  106. package/dst/man/ase-config-delete.1 +45 -0
  107. package/dst/man/ase-config-edit.1 +48 -0
  108. package/dst/man/ase-config-get.1 +46 -0
  109. package/dst/man/ase-config-init.1 +60 -0
  110. package/dst/man/ase-config-list.1 +42 -0
  111. package/dst/man/ase-config-set.1 +52 -0
  112. package/dst/man/ase-config.1 +225 -0
  113. package/dst/man/ase-hook-permission-request.1 +36 -0
  114. package/dst/man/ase-hook-pre-tool-use.1 +59 -0
  115. package/dst/man/ase-hook-session-end.1 +47 -0
  116. package/dst/man/ase-hook-session-persona.1 +37 -0
  117. package/dst/man/ase-hook-session-start.1 +81 -0
  118. package/dst/man/ase-hook-stop.1 +34 -0
  119. package/dst/man/ase-hook-user-prompt-submit.1 +34 -0
  120. package/dst/man/ase-hook.1 +51 -0
  121. package/dst/man/ase-man.1 +67 -0
  122. package/dst/man/ase-mcp.1 +61 -0
  123. package/dst/man/ase-service-log.1 +43 -0
  124. package/dst/man/ase-service-send.1 +47 -0
  125. package/dst/man/ase-service-start.1 +54 -0
  126. package/dst/man/ase-service-status.1 +46 -0
  127. package/dst/man/ase-service-stop.1 +37 -0
  128. package/dst/man/ase-service.1 +85 -0
  129. package/dst/man/ase-setup-disable.1 +44 -0
  130. package/dst/man/ase-setup-enable.1 +45 -0
  131. package/dst/man/ase-setup-install.1 +73 -0
  132. package/dst/man/ase-setup-mcp.1 +124 -0
  133. package/dst/man/ase-setup-status.1 +60 -0
  134. package/dst/man/ase-setup-statusline.1 +92 -0
  135. package/dst/man/ase-setup-telemetry.1 +92 -0
  136. package/dst/man/ase-setup-uninstall.1 +59 -0
  137. package/dst/man/ase-setup-update.1 +64 -0
  138. package/dst/man/ase-setup.1 +93 -0
  139. package/dst/man/ase-spec-export.1 +62 -0
  140. package/dst/man/ase-spec-lint.1 +40 -0
  141. package/dst/man/ase-spec-preview.1 +40 -0
  142. package/dst/man/ase-spec.1 +46 -0
  143. package/dst/man/ase-statusline.1 +189 -0
  144. package/dst/man/ase-task-assign.1 +71 -0
  145. package/dst/man/ase-task-board.1 +384 -0
  146. package/dst/man/ase-task-delete.1 +35 -0
  147. package/dst/man/ase-task-edit.1 +46 -0
  148. package/dst/man/ase-task-idscheme.1 +65 -0
  149. package/dst/man/ase-task-lifecycle.1 +57 -0
  150. package/dst/man/ase-task-list.1 +47 -0
  151. package/dst/man/ase-task-load.1 +36 -0
  152. package/dst/man/ase-task-newid.1 +61 -0
  153. package/dst/man/ase-task-purge.1 +37 -0
  154. package/dst/man/ase-task-rename.1 +44 -0
  155. package/dst/man/ase-task-request.1 +61 -0
  156. package/dst/man/ase-task-save.1 +48 -0
  157. package/dst/man/ase-task-status.1 +55 -0
  158. package/dst/man/ase-task-store.1 +194 -0
  159. package/dst/man/ase-task-view.1 +40 -0
  160. package/dst/man/ase-task.1 +160 -0
  161. package/dst/man/ase-util-compat.1 +25 -0
  162. package/dst/man/ase-util-diagram.1 +76 -0
  163. package/dst/man/ase-util-meta.1 +45 -0
  164. package/dst/man/ase-util-metric.1 +54 -0
  165. package/dst/man/ase-util-mint.1 +59 -0
  166. package/dst/man/ase-util-worktree.1 +72 -0
  167. package/dst/man/ase-util.1 +50 -0
  168. package/dst/man/ase.1 +124 -0
  169. package/package.json +79 -21
  170. package/plugin/.claude-plugin/plugin.json +1 -1
  171. package/plugin/.codex-plugin/plugin.json +1 -1
  172. package/plugin/.github/plugin/plugin.json +1 -1
  173. package/plugin/agents/ase-meta-diagram.md +12 -8
  174. package/plugin/agents/ase-repo-review.md +322 -0
  175. package/plugin/hooks/hooks-codex.json +5 -0
  176. package/plugin/hooks/hooks-copilot.json +5 -0
  177. package/plugin/hooks/hooks.json +8 -0
  178. package/plugin/meta/ase-common-approach.md +117 -0
  179. package/plugin/meta/ase-common-changeset.md +256 -0
  180. package/plugin/meta/ase-common-code.md +10 -173
  181. package/plugin/meta/ase-common-grill.md +317 -0
  182. package/plugin/meta/ase-common-resolve.md +305 -0
  183. package/plugin/meta/ase-common-task.md +83 -4
  184. package/plugin/meta/ase-constitution.md +0 -2
  185. package/plugin/meta/ase-format-specbook.md +208 -25
  186. package/plugin/meta/ase-format-specbook.yaml +242 -851
  187. package/plugin/meta/ase-format-task.md +669 -125
  188. package/plugin/meta/ase-persona.md +3 -4
  189. package/plugin/meta/ase-skill.md +9 -0
  190. package/plugin/mods/ase-mods.ts +409 -0
  191. package/plugin/mods/claude-code.d.ts +13417 -0
  192. package/plugin/skills/ase-arch-analyze/SKILL.md +1 -1
  193. package/plugin/skills/ase-arch-analyze/help.md +2 -2
  194. package/plugin/skills/ase-code-analyze/SKILL.md +59 -8
  195. package/plugin/skills/ase-code-analyze/help.md +29 -4
  196. package/plugin/skills/ase-code-edit/SKILL.md +107 -181
  197. package/plugin/skills/ase-code-edit/help.md +118 -31
  198. package/plugin/skills/ase-code-lint/SKILL.md +64 -13
  199. package/plugin/skills/ase-code-lint/help.md +32 -2
  200. package/plugin/skills/ase-docs-proofread/SKILL.md +4 -4
  201. package/plugin/skills/ase-docs-proofread/help.md +1 -1
  202. package/plugin/skills/ase-docs-refine/SKILL.md +4 -4
  203. package/plugin/skills/ase-docs-shorten/SKILL.md +4 -4
  204. package/plugin/skills/ase-help-intent/data.md +2164 -1087
  205. package/plugin/skills/ase-help-intent/help.md +1 -1
  206. package/plugin/skills/ase-help-skill/catalog.md +12 -9
  207. package/plugin/skills/ase-meta-brainstorm/SKILL.md +1 -1
  208. package/plugin/skills/ase-meta-config/SKILL.md +3 -4
  209. package/plugin/skills/ase-meta-config/help.md +31 -10
  210. package/plugin/skills/ase-meta-mint/SKILL.md +5 -5
  211. package/plugin/skills/ase-meta-mint/help.md +10 -5
  212. package/plugin/skills/ase-meta-why/help.md +1 -1
  213. package/plugin/skills/ase-meta-workflow/SKILL.md +7 -7
  214. package/plugin/skills/ase-meta-workflow/help.md +4 -4
  215. package/plugin/skills/ase-meta-workflow/sample.md +3 -3
  216. package/plugin/skills/ase-meta-workflow/workflow.txt +22 -18
  217. package/plugin/skills/{ase-meta-changelog → ase-repo-changelog}/SKILL.md +4 -3
  218. package/plugin/skills/{ase-meta-changelog → ase-repo-changelog}/help.md +5 -5
  219. package/plugin/skills/{ase-meta-commit → ase-repo-commit}/SKILL.md +6 -6
  220. package/plugin/skills/{ase-meta-commit → ase-repo-commit}/help.md +5 -5
  221. package/plugin/skills/{ase-meta-diff → ase-repo-diff}/SKILL.md +6 -6
  222. package/plugin/skills/{ase-meta-diff → ase-repo-diff}/help.md +8 -8
  223. package/plugin/skills/{ase-code-dissect → ase-repo-dissect}/SKILL.md +19 -19
  224. package/plugin/skills/{ase-code-dissect → ase-repo-dissect}/help.md +10 -12
  225. package/plugin/skills/ase-repo-merge/SKILL.md +346 -0
  226. package/plugin/skills/ase-repo-merge/help.md +107 -0
  227. package/plugin/skills/ase-repo-resolve/SKILL.md +158 -0
  228. package/plugin/skills/ase-repo-resolve/help.md +102 -0
  229. package/plugin/skills/ase-repo-review/SKILL.md +406 -0
  230. package/plugin/skills/ase-repo-review/help.md +181 -0
  231. package/plugin/skills/ase-spec-edit/SKILL.md +117 -183
  232. package/plugin/skills/ase-spec-edit/help.md +107 -26
  233. package/plugin/skills/ase-sync-reconcile/help.md +1 -1
  234. package/plugin/skills/ase-task-assign/SKILL.md +111 -0
  235. package/plugin/skills/ase-task-assign/help.md +119 -0
  236. package/plugin/skills/ase-task-condense/SKILL.md +21 -21
  237. package/plugin/skills/ase-task-condense/help.md +12 -10
  238. package/plugin/skills/ase-task-delete/SKILL.md +1 -1
  239. package/plugin/skills/ase-task-dissect/SKILL.md +71 -48
  240. package/plugin/skills/ase-task-dissect/help.md +10 -9
  241. package/plugin/skills/ase-task-edit/SKILL.md +379 -94
  242. package/plugin/skills/ase-task-edit/help.md +95 -22
  243. package/plugin/skills/ase-task-estimate/SKILL.md +492 -0
  244. package/plugin/skills/ase-task-estimate/help.md +158 -0
  245. package/plugin/skills/ase-task-grill/SKILL.md +246 -138
  246. package/plugin/skills/ase-task-grill/help.md +129 -26
  247. package/plugin/skills/ase-task-id/SKILL.md +8 -1
  248. package/plugin/skills/ase-task-id/help.md +6 -3
  249. package/plugin/skills/ase-task-implement/SKILL.md +535 -145
  250. package/plugin/skills/ase-task-implement/help.md +138 -47
  251. package/plugin/skills/ase-task-integrate/SKILL.md +549 -0
  252. package/plugin/skills/ase-task-integrate/help.md +187 -0
  253. package/plugin/skills/ase-task-list/SKILL.md +55 -23
  254. package/plugin/skills/ase-task-list/help.md +45 -21
  255. package/plugin/skills/ase-task-reboot/SKILL.md +84 -43
  256. package/plugin/skills/ase-task-reboot/help.md +28 -8
  257. package/plugin/skills/ase-task-rename/SKILL.md +10 -2
  258. package/plugin/skills/ase-task-research/SKILL.md +386 -0
  259. package/plugin/skills/ase-task-research/help.md +102 -0
  260. package/plugin/skills/ase-task-status/SKILL.md +137 -0
  261. package/plugin/skills/ase-task-status/help.md +80 -0
  262. package/plugin/skills/ase-task-view/SKILL.md +49 -24
  263. package/plugin/skills/ase-task-view/help.md +19 -9
  264. package/plugin/agents/ase-meta-review.md +0 -187
  265. package/plugin/skills/ase-code-craft/SKILL.md +0 -264
  266. package/plugin/skills/ase-code-craft/help.md +0 -122
  267. package/plugin/skills/ase-code-refactor/SKILL.md +0 -232
  268. package/plugin/skills/ase-code-refactor/help.md +0 -109
  269. package/plugin/skills/ase-code-resolve/SKILL.md +0 -289
  270. package/plugin/skills/ase-code-resolve/help.md +0 -116
  271. package/plugin/skills/ase-meta-review/SKILL.md +0 -210
  272. package/plugin/skills/ase-meta-review/help.md +0 -96
  273. package/plugin/skills/ase-task-preflight/SKILL.md +0 -166
  274. package/plugin/skills/ase-task-preflight/help.md +0 -82
  275. /package/dst/{ase-ignore.js → ase-lib-ignore.js} +0 -0
  276. /package/dst/{ase-stdio.js → ase-lib-stdio.js} +0 -0
  277. /package/dst/{ase-kv.js → ase-service-kv.js} +0 -0
  278. /package/dst/{ase-sleep.js → ase-service-sleep.js} +0 -0
  279. /package/dst/{ase-timestamp.js → ase-service-timestamp.js} +0 -0
@@ -0,0 +1,497 @@
1
+ /*
2
+ ** Agentic Software Engineering (ASE)
3
+ ** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
+ */
6
+ import { toneOf, byCreation, cardLabel, groupLabel, clampLines, glueId, glueTitle, glueGroup } from "./ase-task-board-core.js";
7
+ /* the spacing between the ports of a node with a port per edge (see "layoutBoxes" with rows) */
8
+ export const portSpacing = { cell: 2, px: 14 };
9
+ /* the unit systems a layout is computed in: terminal character cells
10
+ (width in columns, height in rows) or browser pixels, with the height of
11
+ a single-line node, the height of each additional label line, (in
12
+ pixel units) the estimated characters per label line of the fixed-width node,
13
+ and the padding and the minimum width (by its name) of a group container;
14
+ in pixel units, a single-line node is its card with a border of 1px, a padding of
15
+ 24px in total (with room for the badges on its top border), and a line of 20.8px */
16
+ export const units = {
17
+ cell: { w: (label) => label.length + 4, h: 3, line: 1, chars: 0, nodeNode: 1, layers: 8, edgeEdge: 1, edgeNode: 1, pad: "[top=2,left=2,bottom=1,right=2]", gw: (name) => name.length + 4 },
18
+ px: { w: () => 260, h: 47, line: 20.8, chars: 32, nodeNode: 18, layers: 64, edgeEdge: 10, edgeNode: 14, pad: "[top=30,left=14,bottom=14,right=14]", gw: (name) => Math.ceil(name.length * 7.5) + 28 }
19
+ };
20
+ /* the id prefix of the compound node of a group (task ids never contain a colon) */
21
+ const GROUP = "group:";
22
+ /* the label lines of a node (in cell units only) of a given box width: the
23
+ task id (behind "≡", and of an epic also behind "♛") and title, cut onto a single line, or with titles shown, wrapped
24
+ onto at most three lines, with a placeholder column glued in front of
25
+ the task id to reserve the extra column of its inverse rendering (plus two for
26
+ the "≡", and two more for the "♛" of an epic), and the arrow in front of the title replaced by a placeholder
27
+ column glued to the title */
28
+ export const nodeLines = (board, id, titles, width) => {
29
+ const card = board.cards.get(id);
30
+ const pre = glueGroup.repeat(groupLabel(card) === id ? 4 : 2);
31
+ return clampLines(glueId + pre + cardLabel(card).replace(`${id} ▶ `, `${id} ${glueTitle}`), width - 4, titles ? 3 : 1);
32
+ };
33
+ /* lay out nodes with ELK Layered: left to right, orthogonal edge routing, every
34
+ edge leaving its source on the east side and entering its target on the west side
35
+ (at the first label line of a node), and the nodes of each group nested into a
36
+ compound node of the group (in the order of their first node, padded for its border
37
+ and label, and at least as wide as its label, and laid out across the hierarchy,
38
+ in root coordinates), or, with given rows of the top-level nodes and groups (by their
39
+ ids resp. names), top to bottom in these rows, with the top-level nodes of a row
40
+ in their given order, and every edge leaving its source on its bottom side and entering
41
+ its target on its top side at a port of its own (distributed along the side, so that
42
+ no edges overlap, where a node is widened for its ports if needed); "elkjs" is loaded
43
+ on demand only, as it is large and needed by the graph views alone */
44
+ export const layoutBoxes = async (unit, list, labels, links, rows) => {
45
+ const u = units[unit];
46
+ const port = { width: 0, height: 0 };
47
+ const options = {
48
+ "elk.algorithm": "layered",
49
+ "elk.direction": rows !== undefined ? "DOWN" : "RIGHT",
50
+ "elk.edgeRouting": "ORTHOGONAL",
51
+ "elk.spacing.nodeNode": String(u.nodeNode),
52
+ "elk.spacing.edgeEdge": String(u.edgeEdge),
53
+ "elk.spacing.edgeNode": String(u.edgeNode),
54
+ "elk.spacing.componentComponent": String(u.nodeNode),
55
+ "elk.spacing.portPort": String(portSpacing[unit]),
56
+ "elk.layered.spacing.nodeNodeBetweenLayers": String(rows !== undefined && unit === "cell" ? u.layers / 4 : u.layers),
57
+ "elk.layered.spacing.edgeNodeBetweenLayers": String(rows !== undefined && unit === "cell" ? 3 : u.edgeNode),
58
+ "elk.layered.spacing.edgeEdgeBetweenLayers": String(u.edgeEdge),
59
+ "elk.layered.mergeEdges": "false",
60
+ "elk.layered.considerModelOrder.components": "MODEL_ORDER",
61
+ "elk.layered.crossingMinimization.greedySwitch.type": "TWO_SIDED",
62
+ "elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX"
63
+ };
64
+ /* the rows are partitions of the top-level nodes only (as the nodes nested into
65
+ a group carry no partition, which would else fail the layout of the group) */
66
+ const partitioning = rows === undefined ? {} : {
67
+ "elk.partitioning.activate": "true",
68
+ "elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES",
69
+ "elk.layered.crossingMinimization.forceNodeModelOrder": "true"
70
+ };
71
+ const row = (id) => rows?.has(id) === true ? { "elk.partitioning.partition": String(rows.get(id)) } : {};
72
+ const edgeId = (e) => `${e.from}->${e.to}`;
73
+ const node = (n) => {
74
+ /* with rows, a port of its own per edge on the bottom (outgoing) resp. top (incoming) side */
75
+ if (rows !== undefined)
76
+ return {
77
+ id: n.id, width: n.w, height: n.h,
78
+ layoutOptions: {
79
+ "elk.portConstraints": "FIXED_SIDE",
80
+ "elk.nodeSize.constraints": "PORTS MINIMUM_SIZE",
81
+ "elk.nodeSize.minimum": `(${n.w}, ${n.h})`,
82
+ ...row(n.id)
83
+ },
84
+ ports: [
85
+ ...links.filter((e) => e.from === n.id).map((e) => ({ id: `${edgeId(e)}:src`, ...port, layoutOptions: { "elk.port.side": "SOUTH" } })),
86
+ ...links.filter((e) => e.to === n.id).map((e) => ({ id: `${edgeId(e)}:dst`, ...port, layoutOptions: { "elk.port.side": "NORTH" } }))
87
+ ]
88
+ };
89
+ /* else the ports stay at the first label line (the middle of a single-line node) */
90
+ const y = Math.floor(u.h / 2);
91
+ return {
92
+ id: n.id, width: n.w, height: n.h,
93
+ layoutOptions: { "elk.portConstraints": "FIXED_POS" },
94
+ ports: [
95
+ { id: `${n.id}:in`, x: 0, y, ...port },
96
+ { id: `${n.id}:out`, x: n.w, y, ...port }
97
+ ]
98
+ };
99
+ };
100
+ const children = [];
101
+ const members = new Map();
102
+ for (const n of list) {
103
+ const group = n.group ?? "";
104
+ if (group === "") {
105
+ children.push(node(n));
106
+ continue;
107
+ }
108
+ if (!members.has(group)) {
109
+ members.set(group, []);
110
+ children.push({
111
+ id: GROUP + group,
112
+ layoutOptions: {
113
+ ...options,
114
+ ...row(group),
115
+ /* with rows, the arrow heads of the edges entering the nodes of a group
116
+ need a line of their own below its label in cell units */
117
+ "elk.padding": rows !== undefined && unit === "cell" ? u.pad.replace("top=2", "top=3") : u.pad,
118
+ "elk.nodeSize.constraints": "MINIMUM_SIZE",
119
+ "elk.nodeSize.minimum": `(${u.gw(labels.get(group) ?? group)}, ${u.h})`
120
+ },
121
+ children: members.get(group)
122
+ });
123
+ }
124
+ members.get(group).push(node(n));
125
+ }
126
+ const graph = {
127
+ id: "root",
128
+ layoutOptions: {
129
+ ...options,
130
+ ...partitioning,
131
+ "elk.padding": "[top=0,left=0,bottom=0,right=0]",
132
+ "elk.hierarchyHandling": "INCLUDE_CHILDREN",
133
+ "elk.json.shapeCoords": "ROOT",
134
+ "elk.json.edgeCoords": "ROOT"
135
+ },
136
+ children,
137
+ edges: links.map((e) => rows !== undefined ?
138
+ { id: edgeId(e), sources: [`${edgeId(e)}:src`], targets: [`${edgeId(e)}:dst`] } :
139
+ { id: edgeId(e), sources: [`${e.from}:out`], targets: [`${e.to}:in`] })
140
+ };
141
+ const dashed = new Set(links.filter((e) => e.dashed === true).map(edgeId));
142
+ const ELK = (await import("elkjs/lib/elk.bundled.js")).default.default;
143
+ const res = await new ELK().layout(graph);
144
+ const round = (v) => Math.round(v ?? 0);
145
+ const box = (n, id) => ({ id, x: round(n.x), y: round(n.y), w: round(n.width), h: round(n.height) });
146
+ const nodes = new Map();
147
+ const groups = [];
148
+ for (const n of res.children ?? []) {
149
+ if (n.id.startsWith(GROUP)) {
150
+ groups.push(box(n, n.id.slice(GROUP.length)));
151
+ for (const m of n.children ?? [])
152
+ nodes.set(m.id, box(m, m.id));
153
+ }
154
+ else
155
+ nodes.set(n.id, box(n, n.id));
156
+ }
157
+ /* the edges may have been moved into the compound nodes of the groups */
158
+ const edges = [...(res.edges ?? []), ...(res.children ?? []).flatMap((n) => n.edges ?? [])].map((e) => {
159
+ const [from, to] = e.id.split("->");
160
+ const points = (e.sections ?? []).flatMap((s) => [s.startPoint, ...(s.bendPoints ?? []), s.endPoint].map((p) => ({ x: round(p.x), y: round(p.y) })));
161
+ return dashed.has(e.id) ? { from, to, points, dashed: true } : { from, to, points };
162
+ });
163
+ return { width: round(res.width), height: round(res.height), nodes, groups, edges };
164
+ };
165
+ /* lay out the dependency graph of the tasks, with the tasks of each group nested into its
166
+ container; in cell units, all nodes have the same given width and can optionally
167
+ be enlarged to wrap the task titles onto more lines */
168
+ export const layoutGraph = async (board, unit, titles = false, width = 40) => {
169
+ const u = units[unit];
170
+ const linked = (id) => (board.pred.get(id) ?? []).length + (board.succ.get(id) ?? []).length > 0 ? 0 : 1;
171
+ const ids = [...board.cards.values()].sort((a, b) => linked(a.id) - linked(b.id) || byCreation(a, b)).map((c) => c.id);
172
+ const labels = new Map();
173
+ const nodes = ids.map((id) => {
174
+ /* in cell units, a node holds its label lines, in pixel units its
175
+ estimated label lines (as wrapped by the browser within the card),
176
+ with two placeholder columns for the "≡" in front of the task id (and
177
+ two more for the "♛" of an epic), where the arrow in front of the title
178
+ stands for the padding and margin of the task id sub-box */
179
+ const card = board.cards.get(id);
180
+ const lines = unit === "cell" ? nodeLines(board, id, titles, width) :
181
+ clampLines(glueGroup.repeat(groupLabel(card) === id ? 4 : 2) + cardLabel(card), u.chars, titles ? 3 : 1);
182
+ const group = groupLabel(card);
183
+ if (group !== "")
184
+ labels.set(group, `♛ ${group} ▶`);
185
+ return { id, w: unit === "cell" ? width : u.w(id), h: Math.round(u.h + (lines.length - 1) * u.line), group };
186
+ });
187
+ const edges = ids.flatMap((id) => (board.pred.get(id) ?? []).map((p) => ({ from: p, to: id })));
188
+ return layoutBoxes(unit, nodes, labels, edges);
189
+ };
190
+ /* the box drawing character of a set of line directions */
191
+ const UP = 1;
192
+ const DOWN = 2;
193
+ const LEFT = 4;
194
+ const RIGHT = 8;
195
+ const junction = {
196
+ [LEFT]: "─", [RIGHT]: "─", [LEFT | RIGHT]: "─", [UP]: "│", [DOWN]: "│", [UP | DOWN]: "│",
197
+ [DOWN | RIGHT]: "┌", [DOWN | LEFT]: "┐", [UP | RIGHT]: "└", [UP | LEFT]: "┘",
198
+ [UP | DOWN | RIGHT]: "├", [UP | DOWN | LEFT]: "┤", [LEFT | RIGHT | DOWN]: "┬",
199
+ [LEFT | RIGHT | UP]: "┴", [UP | DOWN | LEFT | RIGHT]: "┼"
200
+ };
201
+ /* the heavy box drawing characters of the marked edges (touching the selected node) */
202
+ const heavy = {
203
+ "─": "━", "│": "┃", "┌": "┏", "┐": "┓", "└": "┗", "┘": "┛", "├": "┣", "┤": "┫",
204
+ "┬": "┳", "┴": "┻", "┼": "╋", "╌": "╍", "╎": "╏"
205
+ };
206
+ export const textGrid = (layout) => {
207
+ const W = layout.width + 2;
208
+ const H = layout.height + 1;
209
+ const grid = (fill) => Array.from({ length: H }, () => new Array(W).fill(fill));
210
+ return {
211
+ W, H,
212
+ chars: Array.from({ length: H }, () => new Array(W).fill(" ")),
213
+ tones: Array.from({ length: H }, () => new Array(W).fill("")),
214
+ mask: grid(0),
215
+ dash: grid(0)
216
+ };
217
+ };
218
+ /* draw the dashed group containers (to be crossed by the edges), with
219
+ the label of each on its first inner line at the left (cut with an ellipsis) */
220
+ export const drawContainers = (g, groups, label) => {
221
+ const { W, H, chars, tones } = g;
222
+ for (const c of groups)
223
+ for (let y = c.y; y < c.y + c.h && y < H; y++)
224
+ for (let x = c.x; x < c.x + c.w && x < W; x++) {
225
+ const top = y === c.y;
226
+ const bot = y === c.y + c.h - 1;
227
+ const lft = x === c.x;
228
+ const rgt = x === c.x + c.w - 1;
229
+ if (!(top || bot || lft || rgt))
230
+ continue;
231
+ chars[y][x] = top ? (lft ? "╭" : rgt ? "╮" : "╌") :
232
+ bot ? (lft ? "╰" : rgt ? "╯" : "╌") : "╎";
233
+ tones[y][x] = "group-frame";
234
+ }
235
+ drawContainerLabels(g, groups, label);
236
+ };
237
+ /* draw the labels of the group containers (again on top of the edges crossing them) */
238
+ export const drawContainerLabels = (g, groups, label) => {
239
+ const { W, H, chars, tones } = g;
240
+ for (const c of groups) {
241
+ const room = Math.max(0, c.w - 4);
242
+ const full = label(c.id);
243
+ const name = full.length > room ? full.slice(0, Math.max(0, room - 1)) + "…" : full;
244
+ for (let i = 0; i < name.length && c.y + 1 < H && c.x + 2 + i < W; i++) {
245
+ chars[c.y + 1][c.x + 2 + i] = name[i];
246
+ tones[c.y + 1][c.x + 2 + i] = "group-title";
247
+ }
248
+ }
249
+ };
250
+ /* draw the lines of all edges, where edges of the selected node are marked (and heavy), and the
251
+ straight parts of the cells crossed by dashed edges only are dashed, plus the arrow heads
252
+ in front of the side of each target the edge enters, and optionally with hops, i.e., where
253
+ exactly two edges cross straight, only one of them is drawn, so the other one passes under
254
+ it (the vertical one on top, unless only the horizontal one is marked) */
255
+ export const drawEdges = (g, edges, selected, hops = false) => {
256
+ const { W, H, chars, tones, mask, dash } = g;
257
+ const passes = new Map();
258
+ for (const e of edges) {
259
+ const sel = e.from === selected || e.to === selected;
260
+ const tone = sel ? "edge-sel" : "edge";
261
+ const own = new Map();
262
+ const set = (x, y, bits) => {
263
+ if (y < 0 || y >= H || x < 0 || x >= W)
264
+ return;
265
+ mask[y][x] |= bits;
266
+ own.set(y * W + x, (own.get(y * W + x) ?? 0) | bits);
267
+ dash[y][x] = e.dashed === true ? dash[y][x] | 1 : dash[y][x] | 2;
268
+ if (tones[y][x] !== "edge-sel")
269
+ tones[y][x] = tone;
270
+ };
271
+ for (let i = 1; i < e.points.length; i++) {
272
+ const a = e.points[i - 1];
273
+ const b = e.points[i];
274
+ if (a.y === b.y) {
275
+ const [x0, x1] = a.x < b.x ? [a.x, b.x] : [b.x, a.x];
276
+ for (let x = x0; x <= x1; x++)
277
+ set(x, a.y, (x > x0 ? LEFT : 0) | (x < x1 ? RIGHT : 0));
278
+ }
279
+ else {
280
+ const [y0, y1] = a.y < b.y ? [a.y, b.y] : [b.y, a.y];
281
+ for (let y = y0; y <= y1; y++)
282
+ set(a.x, y, (y > y0 ? UP : 0) | (y < y1 ? DOWN : 0));
283
+ }
284
+ }
285
+ for (const [cell, bits] of own) {
286
+ if (!passes.has(cell))
287
+ passes.set(cell, []);
288
+ passes.get(cell).push({ bits, dashed: e.dashed === true, sel });
289
+ }
290
+ }
291
+ for (let y = 0; y < H; y++)
292
+ for (let x = 0; x < W; x++)
293
+ if (mask[y][x] !== 0) {
294
+ /* a hop: a straight horizontal and a straight vertical pass of two edges */
295
+ const list = passes.get(y * W + x) ?? [];
296
+ const hor = list.find((p) => p.bits === (LEFT | RIGHT));
297
+ const ver = list.find((p) => p.bits === (UP | DOWN));
298
+ if (hops && list.length === 2 && hor !== undefined && ver !== undefined) {
299
+ const top = hor.sel && !ver.sel ? hor : ver;
300
+ const c = top === hor ? (top.dashed ? "╌" : "─") : (top.dashed ? "╎" : "│");
301
+ chars[y][x] = top.sel ? heavy[c] : c;
302
+ tones[y][x] = top.sel ? "edge-sel" : "edge";
303
+ continue;
304
+ }
305
+ const j = junction[mask[y][x]] ?? "┼";
306
+ const c = dash[y][x] !== 1 ? j : j === "─" ? "╌" : j === "│" ? "╎" : j;
307
+ chars[y][x] = tones[y][x] === "edge-sel" ? heavy[c] ?? c : c;
308
+ }
309
+ for (const e of edges) {
310
+ const end = e.points[e.points.length - 1];
311
+ const prev = e.points[e.points.length - 2];
312
+ if (end === undefined || prev === undefined)
313
+ continue;
314
+ const [x, y, head] = prev.y < end.y ? [end.x, end.y - 1, "▼"] : prev.y > end.y ? [end.x, end.y, "▲"] :
315
+ prev.x > end.x ? [end.x, end.y, "◄"] : [end.x - 1, end.y, "►"];
316
+ if (x >= 0 && x < W && y >= 0 && y < H)
317
+ chars[y][x] = head;
318
+ }
319
+ };
320
+ /* draw a node box (solid, dashed, or heavy) in a tone, with the tone suffix "-frame" on its border */
321
+ export const drawBox = (g, n, tone, dashed = false, bold = false) => {
322
+ const { W, H, chars, tones } = g;
323
+ const hor = bold ? "━" : dashed ? "╌" : "─";
324
+ const ver = bold ? "┃" : dashed ? "╎" : "│";
325
+ const [tl, tr, bl, br] = bold ? ["┏", "┓", "┗", "┛"] : ["┌", "┐", "└", "┘"];
326
+ for (let y = n.y; y < n.y + n.h && y < H; y++)
327
+ for (let x = n.x; x < n.x + n.w && x < W; x++) {
328
+ const top = y === n.y;
329
+ const bot = y === n.y + n.h - 1;
330
+ const lft = x === n.x;
331
+ const rgt = x === n.x + n.w - 1;
332
+ chars[y][x] = top ? (lft ? tl : rgt ? tr : hor) :
333
+ bot ? (lft ? bl : rgt ? br : hor) : (lft || rgt ? ver : " ");
334
+ tones[y][x] = top || bot || lft || rgt ? `${tone}-frame` : tone;
335
+ }
336
+ };
337
+ /* draw a cell-unit layout of the tasks onto a character grid, returning per cell the
338
+ character and a tone for coloring: the node tones "done", "active",
339
+ "idle", and "sel" (selected node) with a "-frame" suffix on the box border,
340
+ an "-id" suffix on the task id, a "-title" suffix on the rest of the label, and
341
+ "edge" or "edge-sel" (an edge touching the selected node), "group-frame" and
342
+ "group-title" on the group containers, with a given pulse
343
+ glyph (if any) in the top border of the active nodes and the given badges
344
+ (if any) on the left part of the top border of the nodes (like in the lane view) */
345
+ export const drawGraphText = (board, layout, selected, titles = false, pulse = "", badges = new Map()) => {
346
+ const g = textGrid(layout);
347
+ const { W, H, chars, tones } = g;
348
+ /* dashed group containers at the bottom (crossed by the edges), with the
349
+ group name between "♛" and "▶", and the edges above them */
350
+ drawContainers(g, layout.groups, (id) => `♛ ${id} ▶`);
351
+ drawEdges(g, layout.edges, selected);
352
+ /* node boxes on top, where a context node (of a filtered graph) is dimmed and dashed */
353
+ for (const n of layout.nodes.values()) {
354
+ const ctx = board.context.has(n.id);
355
+ const base = ctx ? "done" : toneOf(board, board.cards.get(n.id));
356
+ const tone = n.id === selected ? "sel" : base;
357
+ drawBox(g, n, tone, ctx);
358
+ if (pulse !== "" && base === "active")
359
+ chars[n.y][n.x + n.w - 3] = pulse;
360
+ /* the badges as inverse " <icon> <name> " boxes (separated by a border character)
361
+ in signal color when selected, in accent color when active, and else in normal
362
+ color, behind one border character and cut before the pulse (with an ellipsis) */
363
+ const list = badges.get(n.id) ?? [];
364
+ if (list.length > 0 && n.w > 10) {
365
+ const room = n.w - 7;
366
+ const tint = tone === "sel" ? "sel" : base === "active" ? "active" : "idle";
367
+ let x = n.x + 2;
368
+ for (const [k, badge] of list.entries()) {
369
+ if (k > 0)
370
+ x++;
371
+ const used = x - n.x - 2;
372
+ if (used >= room)
373
+ break;
374
+ const full = ` ${badge.icon} ${badge.name} `;
375
+ const text = used + full.length > room ? full.slice(0, room - used - 1) + "…" : full;
376
+ for (let i = 0; i < text.length && x < W; i++, x++) {
377
+ chars[n.y][x] = text[i];
378
+ tones[n.y][x] = `${tint}-id`;
379
+ }
380
+ if (text !== full)
381
+ break;
382
+ }
383
+ }
384
+ /* the label lines, with the task id at the start of the first line
385
+ (behind the placeholder and "≡" and, for an epic, also behind "♛",
386
+ and with one column of spacing on each side of it), where the part of a task id
387
+ too long for the first line is hard-wrapped onto the following lines */
388
+ const pre = groupLabel(board.cards.get(n.id)) === n.id ? 4 : 2;
389
+ const lines = nodeLines(board, n.id, titles, n.w);
390
+ let spill = Math.max(0, pre + 1 + n.id.length - (lines[0]?.length ?? 0));
391
+ lines.forEach((line, k) => {
392
+ const row = n.y + 1 + k;
393
+ if (row >= n.y + n.h - 1 || row >= H)
394
+ return;
395
+ const cont = k === 0 ? 0 : Math.min(spill, line.length);
396
+ spill -= cont;
397
+ for (let i = 0; i < line.length && n.x + 2 + i < W; i++) {
398
+ chars[row][n.x + 2 + i] = k === 0 && pre > 2 && i === 1 ? "♛" : k === 0 && i === pre - 1 ? "≡" :
399
+ (k === 0 && i <= pre) || line[i] === glueTitle ? " " : line[i];
400
+ tones[row][n.x + 2 + i] = (k === 0 && i <= pre + n.id.length + 1) || (cont > 0 && i <= cont) ? `${tone}-id` : `${tone}-title`;
401
+ }
402
+ if (k === 0 && n.x + 3 + pre + n.id.length < Math.min(W, n.x + n.w - 1))
403
+ tones[row][n.x + 3 + pre + n.id.length] = `${tone}-id`;
404
+ else if (cont > 0 && spill === 0 && n.x + 2 + cont < Math.min(W, n.x + n.w - 1))
405
+ tones[row][n.x + 2 + cont] = `${tone}-id`;
406
+ });
407
+ }
408
+ return { lines: chars.map((r) => r.join("")), tones };
409
+ };
410
+ /* find the nearest graph node in the direction of an arrow, as the nodes are placed
411
+ on the screen, preferring nodes in the same row (left/right) or column (up/down) */
412
+ export const nearestPlace = (places, from, dir) => {
413
+ const cur = places.get(from);
414
+ if (cur === undefined)
415
+ return undefined;
416
+ let best = Infinity;
417
+ let next;
418
+ for (const [id, p] of places) {
419
+ const dr = p.r - cur.r;
420
+ const dc = p.c - cur.c;
421
+ const ok = dir === "right" ? dc > 0 : dir === "left" ? dc < 0 : dir === "down" ? dr > 0 : dr < 0;
422
+ if (id === from || !ok)
423
+ continue;
424
+ const score = dir === "left" || dir === "right" ?
425
+ Math.abs(dc) + Math.abs(dr) * 4 : Math.abs(dr) + Math.abs(dc) / 4;
426
+ if (score < best) {
427
+ best = score;
428
+ next = id;
429
+ }
430
+ }
431
+ return next;
432
+ };
433
+ /* escape a text for embedding into SVG */
434
+ export const escapeXML = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
435
+ /* start the SVG drawing of a pixel-unit layout (with its arrow markers "<name>-arrow" and
436
+ "<name>-arrow-sel", the second one for the marked edges of the selected node, as a marker
437
+ does not inherit the stroke color of its edge, and both named uniquely per drawing, as the
438
+ page holds several drawings and a marker of a hidden one does not render), with every group a
439
+ dashed "g.cluster" container with its label in the top-left corner, and every edge a
440
+ "path.edge" carrying its "data-from" and "data-to" (plus the class "dashed" for a dashed one),
441
+ optionally with hops, i.e., where a horizontal part of an edge crosses a vertical part of
442
+ another edge, it hops over it in a small half circle */
443
+ export const drawSVGFrame = (name, layout, label, hops = false) => {
444
+ const out = [`<svg xmlns="http://www.w3.org/2000/svg" width="${layout.width}" ` +
445
+ `height="${layout.height}" viewBox="0 0 ${layout.width} ${layout.height}">`,
446
+ "<defs>" + ["arrow", "arrow-sel"].map((id) => `<marker id="${name}-${id}" viewBox="0 0 10 10" refX="10" refY="5" markerUnits="userSpaceOnUse" markerWidth="10" markerHeight="10" ` +
447
+ `orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" class="${id}"/></marker>`).join("") + "</defs>"];
448
+ for (const g of layout.groups)
449
+ out.push(`<g class="cluster"><rect x="${g.x}" y="${g.y}" width="${g.w}" height="${g.h}" rx="6"/>` +
450
+ `<text x="${g.x + 12}" y="${g.y + 20}">${escapeXML(label(g.id))}</text></g>`);
451
+ const R = 5;
452
+ const verts = layout.edges.flatMap((e, k) => e.points.slice(1).flatMap((b, i) => {
453
+ const a = e.points[i];
454
+ return a.x === b.x && a.y !== b.y ? [{ k, x: a.x, y0: Math.min(a.y, b.y), y1: Math.max(a.y, b.y) }] : [];
455
+ }));
456
+ for (const [k, e] of layout.edges.entries()) {
457
+ let d = `M${e.points[0].x},${e.points[0].y}`;
458
+ for (const [i, b] of e.points.slice(1).entries()) {
459
+ /* the hops over the vertical parts of the other edges (in the direction of the part) */
460
+ const a = e.points[i];
461
+ if (hops && a.y === b.y && a.x !== b.x) {
462
+ const s = Math.sign(b.x - a.x);
463
+ const xs = verts.filter((v) => v.k !== k && v.y0 < a.y && v.y1 > a.y
464
+ && Math.min(a.x, b.x) + R < v.x && Math.max(a.x, b.x) - R > v.x)
465
+ .map((v) => v.x).sort((p, q) => s * (p - q));
466
+ for (const x of xs)
467
+ d += ` L${x - s * R},${a.y} A${R},${R} 0 0 ${s > 0 ? 1 : 0} ${x + s * R},${a.y}`;
468
+ }
469
+ d += ` L${b.x},${b.y}`;
470
+ }
471
+ out.push(`<path class="edge${e.dashed === true ? " dashed" : ""}" data-from="${escapeXML(e.from)}" ` +
472
+ `data-to="${escapeXML(e.to)}" d="${d}" marker-end="url(#${name}-arrow)"/>`);
473
+ }
474
+ return out;
475
+ };
476
+ /* draw a pixel-unit layout of the tasks as SVG, with every group container labeled
477
+ with its name between "♛" and "▶", and every node a "g.node" carrying
478
+ its "data-id" and tone class (plus "context" for a context node of a filtered graph),
479
+ for the click delegation of the page, and its "data-assignee", for the badges placed
480
+ by the page, and holding the same HTML card (task id sub-box and truncated title) as the lanes,
481
+ with the number of label lines its node has room for in "data-lines" (to which the
482
+ page clamps the title, as the browser may wrap it onto more lines than estimated) */
483
+ export const drawGraphSVG = (board, layout) => {
484
+ const out = drawSVGFrame("graph", layout, (id) => `♛ ${id} ▶`);
485
+ for (const n of layout.nodes.values()) {
486
+ const card = board.cards.get(n.id);
487
+ const lines = Math.round((n.h - units.px.h) / units.px.line) + 1;
488
+ out.push(`<g class="node tone-${toneOf(board, card)}${board.context.has(card.id) ? " context" : ""}" data-id="${escapeXML(card.id)}" ` +
489
+ `data-assignee="${escapeXML(card.assignee.replace(/\p{Cc}/gu, ""))}">` +
490
+ `<foreignObject x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}">` +
491
+ `<div xmlns="http://www.w3.org/1999/xhtml" class="card" data-lines="${lines}"><div class="cbody"><span class="cid">${groupLabel(card) === card.id ? "♛ " : ""}≡ ${escapeXML(card.id)}</span>` +
492
+ `${escapeXML(card.title.replace(/\t/g, " ").replace(/\p{Cc}/gu, ""))
493
+ .replace(/`([^`]+)`/g, "<code>$1</code>")}</div></div></foreignObject></g>`);
494
+ }
495
+ out.push("</svg>");
496
+ return out.join("\n");
497
+ };