@rse/ase 1.0.7 → 2.0.1
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.
- package/dst/ase-agent-definition-api.d.ts +184 -0
- package/dst/ase-agent-definition-api.js +6 -0
- package/dst/ase-agent-definition-builtin/condenser.js +42 -0
- package/dst/ase-agent-definition-builtin/editor.js +45 -0
- package/dst/ase-agent-definition-builtin/estimator.js +98 -0
- package/dst/ase-agent-definition-builtin/implementer.js +121 -0
- package/dst/ase-agent-definition-builtin/integrator.js +56 -0
- package/dst/ase-agent-definition-builtin/qualifier.js +61 -0
- package/dst/ase-agent-definition-builtin/rebooter.js +41 -0
- package/dst/ase-agent-definition-builtin/researcher.js +77 -0
- package/dst/ase-agent-definition-core.js +827 -0
- package/dst/ase-agent-fleet.js +772 -0
- package/dst/ase-agent-process.js +232 -0
- package/dst/ase-agent-relay-client.js +509 -0
- package/dst/ase-agent-relay-core.js +213 -0
- package/dst/ase-agent-relay-server-bind.js +242 -0
- package/dst/ase-agent-relay-server-cli.js +50 -0
- package/dst/ase-agent-runtime-claim.js +189 -0
- package/dst/ase-agent-runtime-engine.js +1100 -0
- package/dst/ase-agent-runtime-exec.js +275 -0
- package/dst/ase-agent-runtime-phase.js +581 -0
- package/dst/ase-agent-runtime-request.js +272 -0
- package/dst/ase-agent-runtime-tool.js +207 -0
- package/dst/ase-agent-runtime.js +1316 -0
- package/dst/ase-agent.js +65 -0
- package/dst/ase-artifact.js +6 -5
- package/dst/ase-config-core.js +465 -0
- package/dst/ase-config-schema.js +357 -0
- package/dst/ase-config-scope.js +172 -0
- package/dst/ase-config.js +25 -585
- package/dst/ase-hook.js +228 -111
- package/dst/ase-lib-daemon-cli.js +130 -0
- package/dst/ase-lib-hash.js +20 -0
- package/dst/{ase-log.js → ase-lib-log.js} +40 -7
- package/dst/ase-lib-server-bind.js +115 -0
- package/dst/ase-lib-server-cli.js +474 -0
- package/dst/{ase-table.js → ase-lib-table.js} +1 -1
- package/dst/{ase-version.js → ase-lib-version.js} +8 -1
- package/dst/ase-man.js +96 -0
- package/dst/ase-mcp.js +166 -13
- package/dst/{ase-getopt.js → ase-service-getopt.js} +82 -80
- package/dst/{ase-markdown.js → ase-service-markdown.js} +18 -11
- package/dst/ase-service-otel.js +1246 -0
- package/dst/{ase-skills.js → ase-service-skills.js} +29 -64
- package/dst/ase-service-state.js +101 -0
- package/dst/ase-service.js +269 -81
- package/dst/ase-setup-common.js +141 -0
- package/dst/ase-setup-mcp.js +359 -0
- package/dst/ase-setup-settings.js +837 -0
- package/dst/ase-setup.js +113 -869
- package/dst/ase-spec.js +31 -19
- package/dst/ase-statusline.js +108 -43
- package/dst/ase-task-board-core.js +739 -0
- package/dst/ase-task-board-filter.js +127 -0
- package/dst/ase-task-board-flow.js +237 -0
- package/dst/ase-task-board-graph.js +497 -0
- package/dst/ase-task-board-stats.js +263 -0
- package/dst/ase-task-board-tui-control.js +775 -0
- package/dst/ase-task-board-tui-model.js +1008 -0
- package/dst/ase-task-board-tui-popup.js +626 -0
- package/dst/ase-task-board-tui-style.js +106 -0
- package/dst/ase-task-board-tui-view.js +847 -0
- package/dst/ase-task-board-tui.js +189 -0
- package/dst/ase-task-board-web-client.css +1 -0
- package/dst/ase-task-board-web-client.html +20 -0
- package/dst/ase-task-board-web-client.js +81 -0
- package/dst/ase-task-board-web-server.js +823 -0
- package/dst/ase-task-board.js +134 -0
- package/dst/ase-task-format.js +757 -0
- package/dst/ase-task-request.js +208 -0
- package/dst/ase-task-store-client.js +495 -0
- package/dst/ase-task-store-core.js +804 -0
- package/dst/ase-task-store-issues.js +237 -0
- package/dst/ase-task-store-plugin-api.js +6 -0
- package/dst/ase-task-store-plugin-ase.js +484 -0
- package/dst/ase-task-store-plugin-delegate.js +137 -0
- package/dst/ase-task-store-plugin-gitea.js +666 -0
- package/dst/ase-task-store-plugin-github.js +696 -0
- package/dst/ase-task-store-plugin-gitlab.js +668 -0
- package/dst/ase-task-store-server-bind.js +538 -0
- package/dst/ase-task-store-server-cli.js +154 -0
- package/dst/ase-task.js +1335 -357
- package/dst/{ase-compat.js → ase-util-compat.js} +2 -2
- package/dst/{ase-diagram.js → ase-util-diagram.js} +107 -32
- package/dst/{ase-meta.js → ase-util-meta.js} +2 -2
- package/dst/{ase-metric.js → ase-util-metric.js} +2 -2
- package/dst/{ase-mint.js → ase-util-mint.js} +19 -12
- package/dst/{ase-worktree.js → ase-util-worktree.js} +30 -19
- package/dst/ase-util.js +20 -0
- package/dst/ase.js +108 -32
- package/dst/man/ase-agent-adhoc.1 +515 -0
- package/dst/man/ase-agent-condenser.1 +193 -0
- package/dst/man/ase-agent-editor.1 +212 -0
- package/dst/man/ase-agent-estimator.1 +214 -0
- package/dst/man/ase-agent-fleet.1 +185 -0
- package/dst/man/ase-agent-implementer.1 +233 -0
- package/dst/man/ase-agent-integrator.1 +213 -0
- package/dst/man/ase-agent-qualifier.1 +214 -0
- package/dst/man/ase-agent-rebooter.1 +193 -0
- package/dst/man/ase-agent-relay.1 +128 -0
- package/dst/man/ase-agent-researcher.1 +217 -0
- package/dst/man/ase-agent.1 +264 -0
- package/dst/man/ase-artifact-list.1 +38 -0
- package/dst/man/ase-artifact-name.1 +39 -0
- package/dst/man/ase-artifact.1 +56 -0
- package/dst/man/ase-config-delete.1 +45 -0
- package/dst/man/ase-config-edit.1 +48 -0
- package/dst/man/ase-config-get.1 +46 -0
- package/dst/man/ase-config-init.1 +60 -0
- package/dst/man/ase-config-list.1 +42 -0
- package/dst/man/ase-config-set.1 +52 -0
- package/dst/man/ase-config.1 +231 -0
- package/dst/man/ase-hook-permission-request.1 +36 -0
- package/dst/man/ase-hook-pre-tool-use.1 +59 -0
- package/dst/man/ase-hook-session-end.1 +47 -0
- package/dst/man/ase-hook-session-persona.1 +37 -0
- package/dst/man/ase-hook-session-start.1 +81 -0
- package/dst/man/ase-hook-stop.1 +34 -0
- package/dst/man/ase-hook-user-prompt-submit.1 +34 -0
- package/dst/man/ase-hook.1 +51 -0
- package/dst/man/ase-man.1 +67 -0
- package/dst/man/ase-mcp.1 +61 -0
- package/dst/man/ase-service-log.1 +43 -0
- package/dst/man/ase-service-send.1 +47 -0
- package/dst/man/ase-service-start.1 +54 -0
- package/dst/man/ase-service-status.1 +46 -0
- package/dst/man/ase-service-stop.1 +37 -0
- package/dst/man/ase-service.1 +85 -0
- package/dst/man/ase-setup-disable.1 +44 -0
- package/dst/man/ase-setup-enable.1 +45 -0
- package/dst/man/ase-setup-install.1 +73 -0
- package/dst/man/ase-setup-mcp.1 +124 -0
- package/dst/man/ase-setup-status.1 +60 -0
- package/dst/man/ase-setup-statusline.1 +92 -0
- package/dst/man/ase-setup-telemetry.1 +92 -0
- package/dst/man/ase-setup-uninstall.1 +59 -0
- package/dst/man/ase-setup-update.1 +64 -0
- package/dst/man/ase-setup.1 +93 -0
- package/dst/man/ase-spec-export.1 +62 -0
- package/dst/man/ase-spec-lint.1 +40 -0
- package/dst/man/ase-spec-preview.1 +40 -0
- package/dst/man/ase-spec.1 +46 -0
- package/dst/man/ase-statusline.1 +189 -0
- package/dst/man/ase-task-assign.1 +71 -0
- package/dst/man/ase-task-board.1 +384 -0
- package/dst/man/ase-task-delete.1 +35 -0
- package/dst/man/ase-task-edit.1 +46 -0
- package/dst/man/ase-task-idscheme.1 +65 -0
- package/dst/man/ase-task-lifecycle.1 +57 -0
- package/dst/man/ase-task-list.1 +47 -0
- package/dst/man/ase-task-load.1 +36 -0
- package/dst/man/ase-task-newid.1 +61 -0
- package/dst/man/ase-task-purge.1 +37 -0
- package/dst/man/ase-task-rename.1 +44 -0
- package/dst/man/ase-task-request.1 +61 -0
- package/dst/man/ase-task-save.1 +48 -0
- package/dst/man/ase-task-status.1 +55 -0
- package/dst/man/ase-task-store.1 +194 -0
- package/dst/man/ase-task-view.1 +40 -0
- package/dst/man/ase-task.1 +160 -0
- package/dst/man/ase-util-compat.1 +25 -0
- package/dst/man/ase-util-diagram.1 +76 -0
- package/dst/man/ase-util-meta.1 +45 -0
- package/dst/man/ase-util-metric.1 +54 -0
- package/dst/man/ase-util-mint.1 +59 -0
- package/dst/man/ase-util-worktree.1 +72 -0
- package/dst/man/ase-util.1 +50 -0
- package/dst/man/ase.1 +124 -0
- package/package.json +81 -21
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/.github/plugin/plugin.json +1 -1
- package/plugin/agents/ase-meta-diagram.md +12 -8
- package/plugin/agents/ase-repo-review.md +322 -0
- package/plugin/hooks/hooks-codex.json +5 -0
- package/plugin/hooks/hooks-copilot.json +5 -0
- package/plugin/hooks/hooks.json +8 -0
- package/plugin/meta/ase-common-approach.md +117 -0
- package/plugin/meta/ase-common-changeset.md +256 -0
- package/plugin/meta/ase-common-code.md +10 -173
- package/plugin/meta/ase-common-grill.md +317 -0
- package/plugin/meta/ase-common-resolve.md +305 -0
- package/plugin/meta/ase-common-task.md +83 -4
- package/plugin/meta/ase-constitution.md +0 -2
- package/plugin/meta/ase-format-specbook.md +208 -25
- package/plugin/meta/ase-format-specbook.yaml +242 -851
- package/plugin/meta/ase-format-task.md +669 -125
- package/plugin/meta/ase-persona.md +3 -4
- package/plugin/meta/ase-skill.md +9 -0
- package/plugin/mods/ase-mods.ts +409 -0
- package/plugin/mods/claude-code.d.ts +13417 -0
- package/plugin/skills/ase-arch-analyze/SKILL.md +1 -1
- package/plugin/skills/ase-arch-analyze/help.md +2 -2
- package/plugin/skills/ase-code-analyze/SKILL.md +59 -8
- package/plugin/skills/ase-code-analyze/help.md +29 -4
- package/plugin/skills/ase-code-edit/SKILL.md +107 -181
- package/plugin/skills/ase-code-edit/help.md +118 -31
- package/plugin/skills/ase-code-lint/SKILL.md +64 -13
- package/plugin/skills/ase-code-lint/help.md +32 -2
- package/plugin/skills/ase-docs-proofread/SKILL.md +4 -4
- package/plugin/skills/ase-docs-proofread/help.md +1 -1
- package/plugin/skills/ase-docs-refine/SKILL.md +4 -4
- package/plugin/skills/ase-docs-shorten/SKILL.md +4 -4
- package/plugin/skills/ase-help-intent/data.md +2164 -1087
- package/plugin/skills/ase-help-intent/help.md +1 -1
- package/plugin/skills/ase-help-skill/catalog.md +12 -9
- package/plugin/skills/ase-meta-brainstorm/SKILL.md +1 -1
- package/plugin/skills/ase-meta-config/SKILL.md +3 -4
- package/plugin/skills/ase-meta-config/help.md +31 -10
- package/plugin/skills/ase-meta-mint/SKILL.md +5 -5
- package/plugin/skills/ase-meta-mint/help.md +10 -5
- package/plugin/skills/ase-meta-why/help.md +1 -1
- package/plugin/skills/ase-meta-workflow/SKILL.md +7 -7
- package/plugin/skills/ase-meta-workflow/help.md +4 -4
- package/plugin/skills/ase-meta-workflow/sample.md +3 -3
- package/plugin/skills/ase-meta-workflow/workflow.txt +22 -18
- package/plugin/skills/{ase-meta-changelog → ase-repo-changelog}/SKILL.md +4 -3
- package/plugin/skills/{ase-meta-changelog → ase-repo-changelog}/help.md +5 -5
- package/plugin/skills/{ase-meta-commit → ase-repo-commit}/SKILL.md +6 -6
- package/plugin/skills/{ase-meta-commit → ase-repo-commit}/help.md +5 -5
- package/plugin/skills/{ase-meta-diff → ase-repo-diff}/SKILL.md +6 -6
- package/plugin/skills/{ase-meta-diff → ase-repo-diff}/help.md +8 -8
- package/plugin/skills/{ase-code-dissect → ase-repo-dissect}/SKILL.md +19 -19
- package/plugin/skills/{ase-code-dissect → ase-repo-dissect}/help.md +10 -12
- package/plugin/skills/ase-repo-merge/SKILL.md +346 -0
- package/plugin/skills/ase-repo-merge/help.md +107 -0
- package/plugin/skills/ase-repo-resolve/SKILL.md +158 -0
- package/plugin/skills/ase-repo-resolve/help.md +102 -0
- package/plugin/skills/ase-repo-review/SKILL.md +406 -0
- package/plugin/skills/ase-repo-review/help.md +181 -0
- package/plugin/skills/ase-spec-edit/SKILL.md +117 -183
- package/plugin/skills/ase-spec-edit/help.md +107 -26
- package/plugin/skills/ase-sync-reconcile/help.md +1 -1
- package/plugin/skills/ase-task-assign/SKILL.md +111 -0
- package/plugin/skills/ase-task-assign/help.md +119 -0
- package/plugin/skills/ase-task-condense/SKILL.md +21 -21
- package/plugin/skills/ase-task-condense/help.md +12 -10
- package/plugin/skills/ase-task-delete/SKILL.md +1 -1
- package/plugin/skills/ase-task-dissect/SKILL.md +71 -48
- package/plugin/skills/ase-task-dissect/help.md +10 -9
- package/plugin/skills/ase-task-edit/SKILL.md +379 -94
- package/plugin/skills/ase-task-edit/help.md +95 -22
- package/plugin/skills/ase-task-estimate/SKILL.md +492 -0
- package/plugin/skills/ase-task-estimate/help.md +158 -0
- package/plugin/skills/ase-task-grill/SKILL.md +246 -138
- package/plugin/skills/ase-task-grill/help.md +129 -26
- package/plugin/skills/ase-task-id/SKILL.md +8 -1
- package/plugin/skills/ase-task-id/help.md +6 -3
- package/plugin/skills/ase-task-implement/SKILL.md +535 -145
- package/plugin/skills/ase-task-implement/help.md +138 -47
- package/plugin/skills/ase-task-integrate/SKILL.md +549 -0
- package/plugin/skills/ase-task-integrate/help.md +187 -0
- package/plugin/skills/ase-task-list/SKILL.md +55 -23
- package/plugin/skills/ase-task-list/help.md +45 -21
- package/plugin/skills/ase-task-reboot/SKILL.md +84 -43
- package/plugin/skills/ase-task-reboot/help.md +28 -8
- package/plugin/skills/ase-task-rename/SKILL.md +10 -2
- package/plugin/skills/ase-task-research/SKILL.md +386 -0
- package/plugin/skills/ase-task-research/help.md +102 -0
- package/plugin/skills/ase-task-status/SKILL.md +137 -0
- package/plugin/skills/ase-task-status/help.md +80 -0
- package/plugin/skills/ase-task-view/SKILL.md +49 -24
- package/plugin/skills/ase-task-view/help.md +19 -9
- package/plugin/agents/ase-meta-review.md +0 -187
- package/plugin/skills/ase-code-craft/SKILL.md +0 -264
- package/plugin/skills/ase-code-craft/help.md +0 -122
- package/plugin/skills/ase-code-refactor/SKILL.md +0 -232
- package/plugin/skills/ase-code-refactor/help.md +0 -109
- package/plugin/skills/ase-code-resolve/SKILL.md +0 -289
- package/plugin/skills/ase-code-resolve/help.md +0 -116
- package/plugin/skills/ase-meta-review/SKILL.md +0 -210
- package/plugin/skills/ase-meta-review/help.md +0 -96
- package/plugin/skills/ase-task-preflight/SKILL.md +0 -166
- package/plugin/skills/ase-task-preflight/help.md +0 -82
- /package/dst/{ase-ignore.js → ase-lib-ignore.js} +0 -0
- /package/dst/{ase-stdio.js → ase-lib-stdio.js} +0 -0
- /package/dst/{ase-kv.js → ase-service-kv.js} +0 -0
- /package/dst/{ase-sleep.js → ase-service-sleep.js} +0 -0
- /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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
+
};
|