@worca/app 1.0.0-rc.1 → 1.1.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/README.md +22 -9
- package/agents/clarify.meta.json +4 -4
- package/agents/decomposer.meta.json +5 -5
- package/agents/implementer.meta.json +15 -5
- package/agents/manualTestsChecklist.meta.json +5 -4
- package/agents/manualWebUiTesting.meta.json +9 -4
- package/agents/planReviewer.meta.json +12 -4
- package/agents/planner.meta.json +12 -5
- package/agents/refiner.meta.json +15 -4
- package/agents/reviewer.meta.json +14 -4
- package/agents/worca-cc-clarify.md +7 -0
- package/agents/worca-cc-code-reviewer.md +11 -6
- package/agents/worca-cc-decomposer.md +7 -0
- package/agents/worca-cc-implementer.md +9 -0
- package/agents/worca-cc-manual-tests-checklist.md +8 -5
- package/agents/worca-cc-manual-web-ui-testing.md +10 -6
- package/agents/worca-cc-plan-refiner.md +11 -6
- package/agents/worca-cc-plan-reviewer.md +10 -7
- package/agents/worca-cc-planner.md +9 -0
- package/agents/worca-cc-workspace-reviewer.md +11 -4
- package/agents/worca-cc-workspace-scanner.md +8 -4
- package/agents/workspaceReviewer.meta.json +15 -4
- package/agents/workspaceScanner.meta.json +5 -4
- package/package.json +8 -2
- package/skills/worca/SKILL.md +5 -5
- package/src/cli/render.mjs +148 -0
- package/src/cli/worca-cc.mjs +319 -45
- package/src/core/agent-gen.mjs +69 -31
- package/src/core/agent-registry.mjs +124 -144
- package/src/core/agent-store.mjs +164 -4
- package/src/core/artifacts.mjs +189 -21
- package/src/core/ask/catalog.mjs +111 -0
- package/src/core/ask/comment-deps.mjs +55 -0
- package/src/core/ask/events.mjs +506 -0
- package/src/core/ask/follow.mjs +107 -0
- package/src/core/ask/git-allowlist.mjs +226 -0
- package/src/core/ask/limits.mjs +54 -0
- package/src/core/ask/mcp-stdio.mjs +135 -0
- package/src/core/ask/models.mjs +125 -0
- package/src/core/ask/prompt.mjs +261 -0
- package/src/core/ask/proposal.mjs +170 -0
- package/src/core/ask/redact.mjs +30 -0
- package/src/core/ask/spawn.mjs +153 -0
- package/src/core/ask/store.mjs +360 -0
- package/src/core/ask/tool-deps.mjs +63 -0
- package/src/core/ask/tools.mjs +848 -0
- package/src/core/ask/turn.mjs +416 -0
- package/src/core/ask/worktree-deps.mjs +27 -0
- package/src/core/ask/worktrees.mjs +285 -0
- package/src/core/chat/command-router.mjs +20 -3
- package/src/core/claude-runner.mjs +434 -57
- package/src/core/config.mjs +264 -41
- package/src/core/cost-budget.mjs +29 -2
- package/src/core/db.mjs +684 -47
- package/src/core/diff-anchor.mjs +213 -0
- package/src/core/diff-comments.mjs +273 -0
- package/src/core/engine-select.mjs +32 -0
- package/src/core/git-info.mjs +49 -10
- package/src/core/graph/builtin-workflows.mjs +51 -0
- package/src/core/graph/executor.mjs +894 -0
- package/src/core/graph/registry-ports.mjs +12 -0
- package/src/core/graph/scheduler.mjs +1065 -0
- package/src/core/graph/seed-templates.mjs +318 -0
- package/src/core/model-env.mjs +112 -8
- package/src/core/model-test.mjs +79 -0
- package/src/core/orchestrator.mjs +902 -4098
- package/src/core/overview-agent.mjs +15 -3
- package/src/core/phases.mjs +208 -537
- package/src/core/pipeline-delete.mjs +13 -2
- package/src/core/plugin-api.mjs +8 -3
- package/src/core/plugin-config.mjs +178 -28
- package/src/core/plugin-inventory.mjs +6 -2
- package/src/core/plugin-manifest.mjs +199 -11
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-repo.mjs +16 -4
- package/src/core/plugin-shim-child.mjs +9 -3
- package/src/core/plugin-shim.mjs +77 -14
- package/src/core/plugin-store.mjs +236 -29
- package/src/core/plugin-workflows.mjs +90 -41
- package/src/core/preflight.mjs +135 -3
- package/src/core/projects.mjs +7 -5
- package/src/core/protocol.mjs +8 -35
- package/src/core/recoverable-error.mjs +1 -1
- package/src/core/run-harness.mjs +3585 -0
- package/src/core/run-manifest.mjs +5 -1
- package/src/core/settings.mjs +109 -13
- package/src/core/skills.mjs +10 -3
- package/src/core/source-bindings.mjs +175 -0
- package/src/core/sources.mjs +87 -25
- package/src/core/stats.mjs +25 -6
- package/src/core/title.mjs +51 -4
- package/src/core/workflows.mjs +358 -259
- package/src/core/workspace-scan.mjs +4 -0
- package/src/core/worktree.mjs +98 -7
- package/src/shared/graph/agent-meta.mjs +278 -0
- package/src/shared/graph/constants.mjs +105 -0
- package/src/shared/graph/geometry.mjs +157 -0
- package/src/shared/graph/layout.mjs +134 -0
- package/src/shared/graph/loops.mjs +130 -0
- package/src/shared/graph/manifest.mjs +257 -0
- package/src/shared/graph/ports.mjs +153 -0
- package/src/shared/graph/route.mjs +397 -0
- package/src/shared/graph/template.mjs +165 -0
- package/src/shared/graph/thumbnail.mjs +67 -0
- package/src/shared/graph/validate.mjs +491 -0
- package/src/shared/graph/verdict.mjs +41 -0
- package/ui/public/app.js +4008 -1670
- package/ui/public/ask-markdown.mjs +145 -0
- package/ui/public/ask-model.mjs +264 -0
- package/ui/public/ask-panel.mjs +1880 -0
- package/ui/public/chat-settings-view.mjs +6 -2
- package/ui/public/diff-view.mjs +66 -11
- package/ui/public/file-tree.mjs +305 -0
- package/ui/public/graph/composer.mjs +889 -0
- package/ui/public/graph/inspector.mjs +183 -0
- package/ui/public/graph/model.mjs +37 -0
- package/ui/public/graph/palette.mjs +144 -0
- package/ui/public/graph/run-decor.mjs +410 -0
- package/ui/public/graph/run-hosts.mjs +201 -0
- package/ui/public/graph/save-dialog.mjs +56 -0
- package/ui/public/graph/view.mjs +858 -0
- package/ui/public/guardrails-view.mjs +4 -2
- package/ui/public/hljs-loader.mjs +180 -0
- package/ui/public/index.html +269 -265
- package/ui/public/log-filter.mjs +22 -4
- package/ui/public/log-line.mjs +45 -19
- package/ui/public/models-view.mjs +171 -9
- package/ui/public/plugins-view.mjs +106 -4
- package/ui/public/source-pane.mjs +190 -8
- package/ui/public/stats-view.mjs +81 -1
- package/ui/public/style.css +1459 -229
- package/ui/public/syntax-highlight.mjs +270 -0
- package/ui/public/thinking-orb.mjs +110 -0
- package/ui/server.mjs +1667 -98
- package/src/core/channels.mjs +0 -302
- package/src/core/runners.mjs +0 -167
- package/src/core/workflow-validator.mjs +0 -185
- package/ui/public/composer-core.mjs +0 -211
|
@@ -0,0 +1,858 @@
|
|
|
1
|
+
// ui/public/graph/view.mjs
|
|
2
|
+
// The ONE v2 graph renderer: DOM cards + one SVG wire layer (the ghost path
|
|
3
|
+
// included), both inside `.gv-world`, which carries the only pan/zoom transform.
|
|
4
|
+
// Three callers share it — the composer (edit), the run monitor (monitor) and
|
|
5
|
+
// the previews (static) — so it renders a template plus a decor bag and owns no
|
|
6
|
+
// interaction: composer.mjs binds the pointers to `view.stage`.
|
|
7
|
+
//
|
|
8
|
+
// CONTRACT: the render path NEVER measures. Every anchor, size and wire path is
|
|
9
|
+
// derived from the model x/y through the SHARED geometry module, which is what
|
|
10
|
+
// makes the renderer testable under jsdom (no layout there) and what keeps a
|
|
11
|
+
// repaint O(n) instead of a forced reflow per node. Do not reach for
|
|
12
|
+
// getBoundingClientRect in here.
|
|
13
|
+
//
|
|
14
|
+
// This file lives at depth 3 below the repo root, so the shared core is three
|
|
15
|
+
// `..` up; the browser clamps that at the URL root and the server serves it at
|
|
16
|
+
// /src/shared (P1). Absolute specifiers would break the Node-side UI tests.
|
|
17
|
+
// graphBounds/fitBounds are imported HERE even though only Task 3 calls them:
|
|
18
|
+
// this is the file's one geometry import and Task 3 appends code, not imports.
|
|
19
|
+
import {
|
|
20
|
+
NODE_W, ZOOM_MIN, ZOOM_MAX,
|
|
21
|
+
injectGeometry, nodeSize, portAnchor, graphBounds, fitBounds,
|
|
22
|
+
} from '../../../src/shared/graph/geometry.mjs';
|
|
23
|
+
import { routeAll, routeWire, routePathD, routeMid } from '../../../src/shared/graph/route.mjs';
|
|
24
|
+
import { portsOf, resolveOrOutType } from '../../../src/shared/graph/ports.mjs';
|
|
25
|
+
import { classifyLoops } from '../../../src/shared/graph/loops.mjs';
|
|
26
|
+
import { thumbnailSvg } from '../../../src/shared/graph/thumbnail.mjs';
|
|
27
|
+
import { sanitizeIcon } from '../../../src/shared/graph/manifest.mjs';
|
|
28
|
+
|
|
29
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
30
|
+
|
|
31
|
+
/** Legend copy is NORMATIVE (spec §7.1); the agents card header row renders it. */
|
|
32
|
+
export const LEGEND_TEXT = 'grey = data · amber = loop · ◆ = conditional · ○ = gate · ⤫N = fan-out';
|
|
33
|
+
export const FANOUT_GLYPH = '⤫';
|
|
34
|
+
|
|
35
|
+
/** Per-mode zoom clamps (§7.6). `edit` uses the geometry defaults. */
|
|
36
|
+
export const MODE_ZOOM = {
|
|
37
|
+
edit: { min: ZOOM_MIN, max: ZOOM_MAX },
|
|
38
|
+
monitor: { min: 0.3, max: ZOOM_MAX },
|
|
39
|
+
static: { min: 0.3, max: 1 },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** The 24px caption row each captioned kind closes with. */
|
|
43
|
+
const CAPTIONS = { task: 'prompt + attached files', end: 'pipeline result', or: 'forwards freshest input' };
|
|
44
|
+
|
|
45
|
+
/** Flow cards are engine builtins — no sidecar — so their glyphs live here. */
|
|
46
|
+
const FLOW_META = {
|
|
47
|
+
task: { title: 'Task', icon: '<path d="M5.2 3.4h9.6v13.2H5.2z"/><path d="M7.6 7.2h4.8M7.6 10h4.8M7.6 12.8h3"/>' },
|
|
48
|
+
end: { title: 'End', icon: '<path d="M5.6 3.4v13.2"/><path d="M5.6 4.2h8.6l-2.4 3.4 2.4 3.4H5.6z"/>' },
|
|
49
|
+
and: { title: 'AND', icon: '<path d="M3.4 6h3.2M3.4 14h3.2"/><path d="M6.6 4.2h3.2a5.8 5.8 0 010 11.6H6.6z"/><path d="M15.6 10h1.8"/>' },
|
|
50
|
+
or: { title: 'OR', icon: '<path d="M3.4 5.5h3.6l4.4 4.5h5.4M3.4 14.5h3.6l4.4-4.5"/><path d="M14.6 7.8l2.2 2.2-2.2 2.2"/>' },
|
|
51
|
+
combine: { title: 'Combine', icon: '<path d="M3.4 5.5h4.2l4.4 4.5h4.6M3.4 14.5h4.2l4.4-4.5"/><path d="M14.4 7.8l2.2 2.2-2.2 2.2"/>' },
|
|
52
|
+
};
|
|
53
|
+
const FLOW_VIEWBOX = '0 0 20 20';
|
|
54
|
+
const AGENT_VIEWBOX = '0 0 24 24';
|
|
55
|
+
|
|
56
|
+
// Builtin icons are repo-shipped SVG fragments (trusted, injected raw). EVERY
|
|
57
|
+
// other origin is DATA someone else wrote: a user agent's meta is writable
|
|
58
|
+
// through POST /api/agents, and a plugin agent's rides in on a sidecar a
|
|
59
|
+
// marketplace plugin ships (no code-execution consent, SHA-only updates). Both
|
|
60
|
+
// land in an SVG innerHTML below, in an origin with no CSP and no auth on the
|
|
61
|
+
// API — so both go through the SHARED allowlist, and an icon the allowlist
|
|
62
|
+
// rejects whole is replaced by this neutral glyph.
|
|
63
|
+
//
|
|
64
|
+
// The gate used to be `origin === 'user'` — a one-value DENYLIST that let every
|
|
65
|
+
// plugin icon through raw (C-2). Sanitizing rather than blanking is not a
|
|
66
|
+
// relaxation: the run monitor already renders these same icons through
|
|
67
|
+
// sanitizeIcon (manifest.mjs), so this is what makes the two canvases agree.
|
|
68
|
+
export const USER_AGENT_ICON = '<circle cx="12" cy="12" r="3.4"></circle><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1"></path>';
|
|
69
|
+
export function safeAgentIcon(meta) {
|
|
70
|
+
const raw = String((meta && meta.icon) || '');
|
|
71
|
+
if (!raw || (meta && meta.origin === 'builtin')) return raw;
|
|
72
|
+
return sanitizeIcon(raw) || USER_AGENT_ICON;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const dotClass = (t) => `dot ${t === 'md' || t === 'json' || t === 'void' || t === 'any' ? t : 'md'}`;
|
|
76
|
+
const whenCaption = (w) => (w === 'blocking' ? 'on blocking' : w === 'clean' ? 'on clean' : '');
|
|
77
|
+
|
|
78
|
+
export function createGraphView(host, {
|
|
79
|
+
doc = globalThis.document,
|
|
80
|
+
mode = 'edit',
|
|
81
|
+
portsFn,
|
|
82
|
+
agents: agentsIn = {},
|
|
83
|
+
raf = null,
|
|
84
|
+
viewport = null,
|
|
85
|
+
zoomMin = null,
|
|
86
|
+
zoomMax = null,
|
|
87
|
+
wheelPan = 'always',
|
|
88
|
+
} = {}) {
|
|
89
|
+
const win = doc.defaultView || globalThis;
|
|
90
|
+
const clamps = MODE_ZOOM[mode] || MODE_ZOOM.edit;
|
|
91
|
+
const zMin = zoomMin == null ? clamps.min : zoomMin;
|
|
92
|
+
const zMax = zoomMax == null ? clamps.max : zoomMax;
|
|
93
|
+
const schedule = raf || ((fn) => (win.requestAnimationFrame ? win.requestAnimationFrame(fn) : setTimeout(fn, 16)));
|
|
94
|
+
let agents = agentsIn || {};
|
|
95
|
+
|
|
96
|
+
const stage = doc.createElement('div');
|
|
97
|
+
stage.className = `gv-stage gv-${mode}`;
|
|
98
|
+
stage.setAttribute('tabindex', '0');
|
|
99
|
+
stage.setAttribute('aria-label', 'pipeline canvas');
|
|
100
|
+
const world = doc.createElement('div');
|
|
101
|
+
world.className = 'gv-world';
|
|
102
|
+
const wiresEl = doc.createElementNS(SVG_NS, 'svg');
|
|
103
|
+
wiresEl.setAttribute('class', 'gv-wires');
|
|
104
|
+
wiresEl.setAttribute('width', '1');
|
|
105
|
+
wiresEl.setAttribute('height', '1');
|
|
106
|
+
const ghost = doc.createElementNS(SVG_NS, 'path');
|
|
107
|
+
ghost.setAttribute('class', 'wire ghost');
|
|
108
|
+
ghost.dataset.ghost = '1';
|
|
109
|
+
wiresEl.appendChild(ghost); // ALWAYS last: committed wires insert BEFORE it
|
|
110
|
+
world.appendChild(wiresEl);
|
|
111
|
+
stage.appendChild(world);
|
|
112
|
+
// Never replaceChildren(host): `.gv-chip` and `.gv-ins-rail` are the stage's
|
|
113
|
+
// SIBLINGS inside the same canvas host and must survive a (re)mount.
|
|
114
|
+
host.prepend(stage);
|
|
115
|
+
injectGeometry(stage);
|
|
116
|
+
|
|
117
|
+
const nodeEls = new Map(); // nodeId -> card element
|
|
118
|
+
const wireEls = new Map(); // wireId -> path element
|
|
119
|
+
const badgeEls = new Map(); // wireId -> .wbadge element
|
|
120
|
+
const incident = new Map(); // nodeId -> Set(wireId)
|
|
121
|
+
const dCache = new Map(); // wireId -> last written `d`
|
|
122
|
+
const footers = new Map(); // nodeId -> footer LINES (Σ bandUnits, what nodeSize bills)
|
|
123
|
+
const navs = []; // nav controllers created by createNav()
|
|
124
|
+
let T = { x: 0, y: 0, z: 1 };
|
|
125
|
+
let current = null; // last rendered template
|
|
126
|
+
let ctx = null; // last render context (ports, loops, wired inputs)
|
|
127
|
+
let routesBag = { raw: new Map(), routes: new Map() };
|
|
128
|
+
const lastRect = new Map(); // nodeId -> the card rect as of the last reroute()
|
|
129
|
+
const stats = { wireDUpdates: 0, ghostUpdates: 0, rectReads: 0 };
|
|
130
|
+
|
|
131
|
+
const h = (tag, cls, text) => {
|
|
132
|
+
const n = doc.createElement(tag);
|
|
133
|
+
if (cls) n.className = cls;
|
|
134
|
+
if (text != null) n.textContent = text;
|
|
135
|
+
return n;
|
|
136
|
+
};
|
|
137
|
+
const svgEl = (tag, cls) => {
|
|
138
|
+
const n = doc.createElementNS(SVG_NS, tag);
|
|
139
|
+
if (cls) n.setAttribute('class', cls);
|
|
140
|
+
return n;
|
|
141
|
+
};
|
|
142
|
+
const portsAt = (node) => portsOf(portsFn, node) || { inputs: [], outputs: [] };
|
|
143
|
+
const sizeOf = (node) => nodeSize(node, portsAt(node), { footerRows: footers.get(node.id) || 0 });
|
|
144
|
+
|
|
145
|
+
// ------------------------------------------------------------ wire routing
|
|
146
|
+
// Cards are the router's OBSTACLES, so every repaint derives the wire shapes
|
|
147
|
+
// from the same model x/y the cards are placed by — still zero measurement.
|
|
148
|
+
const isNodeObj = (n) => Boolean(n) && typeof n === 'object' && !Array.isArray(n);
|
|
149
|
+
const rectOf = (node) => ({ x: Number(node.x) || 0, y: Number(node.y) || 0, ...sizeOf(node) });
|
|
150
|
+
const obstacleRects = () => (current ? current.nodes.filter(isNodeObj).map(rectOf) : []);
|
|
151
|
+
const unionRect = (a, b) => {
|
|
152
|
+
if (!a) return b;
|
|
153
|
+
const x = Math.min(a.x, b.x); const y = Math.min(a.y, b.y);
|
|
154
|
+
return { x, y, w: Math.max(a.x + a.w, b.x + b.w) - x, h: Math.max(a.y + a.h, b.y + b.h) - y };
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** Re-route the template's wires. `dirty` (world rect) is the drag fast path:
|
|
158
|
+
* wires far from the change reuse their cached raw route. `dirty = null` is
|
|
159
|
+
* the canonical full pass (D15). Also refreshes lastRect for every node. */
|
|
160
|
+
function reroute(dirty = null) {
|
|
161
|
+
if (!ctx || !current) return;
|
|
162
|
+
const list = [];
|
|
163
|
+
for (const w of current.wires) {
|
|
164
|
+
if (!w || !w.from || !w.to) continue;
|
|
165
|
+
const a = anchorOf(w.from, 'out');
|
|
166
|
+
const b = anchorOf(w.to, 'in');
|
|
167
|
+
if (a && b) list.push({ id: w.id, a, b });
|
|
168
|
+
}
|
|
169
|
+
routesBag = routeAll(list, obstacleRects(), { prev: routesBag.raw, dirty });
|
|
170
|
+
for (const n of current.nodes) if (isNodeObj(n)) lastRect.set(n.id, rectOf(n));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** D15: canonical full re-route + repaint (drag cancel, footer line change). */
|
|
174
|
+
function rerouteAll() {
|
|
175
|
+
reroute(null);
|
|
176
|
+
for (const id of wireEls.keys()) paintWire(id);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function headerOf(node) {
|
|
180
|
+
if (node.kind === 'agent') {
|
|
181
|
+
const meta = agents[node.key] || null;
|
|
182
|
+
return {
|
|
183
|
+
cls: `h-${(meta && meta.color) || 'blue'}`,
|
|
184
|
+
title: (meta && meta.displayName) || node.key || node.id,
|
|
185
|
+
icon: safeAgentIcon(meta),
|
|
186
|
+
viewBox: AGENT_VIEWBOX,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
const flow = FLOW_META[node.kind] || { title: node.kind, icon: '' };
|
|
190
|
+
return { cls: 'h-flow', title: flow.title, icon: flow.icon, viewBox: FLOW_VIEWBOX };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function portRow(port, dir, resolvedType) {
|
|
194
|
+
const type = resolvedType || port.type;
|
|
195
|
+
const row = h('div', `prow ${dir}`);
|
|
196
|
+
row.dataset.port = port.id;
|
|
197
|
+
row.dataset.dir = dir;
|
|
198
|
+
row.dataset.type = type;
|
|
199
|
+
const cond = dir === 'out' && (port.when === 'blocking' || port.when === 'clean');
|
|
200
|
+
const glyph = cond ? h('i', 'dia') : h('i', dotClass(type));
|
|
201
|
+
const name = h('span', 'pn', port.id);
|
|
202
|
+
const cap = h('span', cond ? 'pt cond' : 'pt', cond ? whenCaption(port.when) : type);
|
|
203
|
+
if (dir === 'in') {
|
|
204
|
+
row.append(glyph, name, cap);
|
|
205
|
+
if (port.loop) row.appendChild(h('span', 'chip am mla', 'loop'));
|
|
206
|
+
else if (port.expands) row.appendChild(h('span', 'chip fan mla', `${FANOUT_GLYPH}N`));
|
|
207
|
+
} else {
|
|
208
|
+
row.append(cap, name, glyph);
|
|
209
|
+
}
|
|
210
|
+
return row;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function gateRow(wired) {
|
|
214
|
+
const row = h('div', `prow in gate${wired ? ' wired' : ''}`);
|
|
215
|
+
row.dataset.port = 'await';
|
|
216
|
+
row.dataset.dir = 'in';
|
|
217
|
+
row.dataset.type = 'any';
|
|
218
|
+
row.append(h('i', 'gdot'), h('span', 'pn', 'await'), h('span', 'pt', 'any'));
|
|
219
|
+
return row;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// The body's identity: rebuild the rows ONLY when one of these changes. A pure
|
|
223
|
+
// move, a selection or a status flip must never touch a row element (the
|
|
224
|
+
// PR #359 defect: replaceChildren per pointermove for every card).
|
|
225
|
+
function bodySig(node, p, orType, awaitWired) {
|
|
226
|
+
const io = [...p.inputs, ...p.outputs]
|
|
227
|
+
.map((q) => `${q.id}:${q.type}:${q.when || ''}:${q.loop ? 'L' : ''}${q.expands ? 'X' : ''}`)
|
|
228
|
+
.join(',');
|
|
229
|
+
return [node.kind, node.key || '', io, node.config && node.config.arity != null ? node.config.arity : '',
|
|
230
|
+
orType || '', awaitWired ? '1' : '0'].join('|');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function paintBody(el, node, p, orType, awaitWired) {
|
|
234
|
+
const body = el.querySelector(':scope > .nbody');
|
|
235
|
+
const sig = bodySig(node, p, orType, awaitWired);
|
|
236
|
+
if (body.dataset.sig === sig) return;
|
|
237
|
+
body.dataset.sig = sig;
|
|
238
|
+
const metaIns = p.inputs.filter((x) => !x.synthetic);
|
|
239
|
+
const gate = p.inputs.find((x) => x.synthetic) || null;
|
|
240
|
+
const caption = CAPTIONS[node.kind] || '';
|
|
241
|
+
const kids = [];
|
|
242
|
+
// Zones top->bottom, each emitted only when non-empty, a 9px separator only
|
|
243
|
+
// BETWEEN emitted zones — this is exactly what nodeSize counts.
|
|
244
|
+
const zone = (rows) => { if (kids.length) kids.push(h('div', 'psep')); kids.push(...rows); };
|
|
245
|
+
if (metaIns.length) zone(metaIns.map((q) => portRow(q, 'in')));
|
|
246
|
+
if (p.outputs.length) zone(p.outputs.map((q) => portRow(q, 'out', node.kind === 'or' ? (orType || 'any') : null)));
|
|
247
|
+
if (gate) zone([gateRow(awaitWired)]);
|
|
248
|
+
if (caption) zone([(() => { const c = h('div', 'prow cap'); c.appendChild(h('span', 'pt', caption)); return c; })()]);
|
|
249
|
+
body.replaceChildren(...kids);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function placeCard(node) {
|
|
253
|
+
const el = nodeEls.get(node.id);
|
|
254
|
+
if (el) el.style.transform = `translate(${node.x}px, ${node.y}px)`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const svgChevron = () => {
|
|
258
|
+
const s = doc.createElementNS(SVG_NS, 'svg');
|
|
259
|
+
s.setAttribute('class', 'chev'); s.setAttribute('viewBox', '0 0 24 24');
|
|
260
|
+
s.setAttribute('fill', 'none'); s.setAttribute('stroke', 'currentColor');
|
|
261
|
+
s.innerHTML = '<path d="M6 9l6 6 6-6" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>';
|
|
262
|
+
return s;
|
|
263
|
+
};
|
|
264
|
+
/** One footer band -> one element (the run monitor's vocabulary; see Interfaces). */
|
|
265
|
+
function bandEl(nodeId, band) {
|
|
266
|
+
if (band.kind === 'fan') {
|
|
267
|
+
// Squares wrap inside .fsq (a fixed --gv-fan-w column, FAN_PER_ROW per
|
|
268
|
+
// line) so they never shrink; `.f2` is the two-line height the model bills.
|
|
269
|
+
const fan = h('div', `fan${(band.lines || 1) > 1 ? ' f2' : ''}`);
|
|
270
|
+
const sq = h('span', 'fsq');
|
|
271
|
+
for (const led of band.leds || []) sq.appendChild(h('i', `sq${led === 'run' ? ' on' : ''}`));
|
|
272
|
+
fan.append(sq, h('span', 'fl', `×${band.count}`));
|
|
273
|
+
return fan;
|
|
274
|
+
}
|
|
275
|
+
if (band.kind === 'strip') {
|
|
276
|
+
const btn = doc.createElement('button');
|
|
277
|
+
btn.type = 'button'; btn.className = 'xtoggle'; btn.dataset.nodeId = nodeId;
|
|
278
|
+
btn.setAttribute('aria-expanded', band.expanded ? 'true' : 'false');
|
|
279
|
+
const sq = h('span', 'xsq');
|
|
280
|
+
for (const led of band.leds || []) sq.appendChild(h('i', `xq is-${led}`));
|
|
281
|
+
btn.append(sq, h('span', 'xsum', band.summary || ''), svgChevron());
|
|
282
|
+
return btn;
|
|
283
|
+
}
|
|
284
|
+
if (band.kind === 'exec') {
|
|
285
|
+
const row = h('div', execRowClass(band));
|
|
286
|
+
row.dataset.executionId = band.executionId || '';
|
|
287
|
+
row.dataset.nodeId = nodeId;
|
|
288
|
+
row.append(h('i', 'led'), h('span', 'xl', band.label || ''), h('span', 'xr', band.right || ''));
|
|
289
|
+
return row;
|
|
290
|
+
}
|
|
291
|
+
const res = h('div', 'xresult'); // kind: 'result'
|
|
292
|
+
if (!band.path) { res.textContent = band.text || ''; return res; }
|
|
293
|
+
const a = h('a', null, band.text || '');
|
|
294
|
+
a.href = '#'; a.dataset.path = band.path; a.title = band.path;
|
|
295
|
+
res.appendChild(a);
|
|
296
|
+
return res;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** The identity of a band ACROSS repaints. `exec` is keyed by its execution,
|
|
300
|
+
* the three singletons by their kind — so the strip's <button> survives every
|
|
301
|
+
* decor generation (MAJ-20). */
|
|
302
|
+
const bandKey = (band) => (band.kind === 'exec' ? `exec|${band.executionId || ''}` : band.kind);
|
|
303
|
+
|
|
304
|
+
/** `.stack` = dur · cost on its own line, `.l2` = two clamped label lines —
|
|
305
|
+
* the layout the band's own `units` billed (run-decor execBandLayout). */
|
|
306
|
+
const execRowClass = (band) => `xrow is-${band.led || 'pending'}${band.stack ? ' stack' : ''}${band.l2 ? ' l2' : ''}`;
|
|
307
|
+
|
|
308
|
+
/** Footer LINES a band occupies — what nodeSize bills as footerRows. */
|
|
309
|
+
const bandUnits = (band) => (band.kind === 'fan' ? (band.lines || 1) : band.kind === 'exec' ? (band.units || 1) : 1);
|
|
310
|
+
|
|
311
|
+
/** Update an EXISTING band element in place. Returns false when the element's
|
|
312
|
+
* shape cannot express the new band (a different led count on a fan, a result
|
|
313
|
+
* gaining or losing its link) and the caller must rebuild it. Nothing here is
|
|
314
|
+
* focusable except the strip's own <button>, which is only ever written to —
|
|
315
|
+
* never replaced. */
|
|
316
|
+
function syncBand(el, band) {
|
|
317
|
+
if (band.kind === 'fan') {
|
|
318
|
+
const leds = band.leds || [];
|
|
319
|
+
const cur = el.querySelectorAll(':scope > .fsq > .sq');
|
|
320
|
+
if (cur.length !== leds.length) return false;
|
|
321
|
+
leds.forEach((led, i) => { const c = `sq${led === 'run' ? ' on' : ''}`; if (cur[i].className !== c) cur[i].className = c; });
|
|
322
|
+
const cls = `fan${(band.lines || 1) > 1 ? ' f2' : ''}`;
|
|
323
|
+
if (el.className !== cls) el.className = cls;
|
|
324
|
+
const fl = el.querySelector(':scope > .fl');
|
|
325
|
+
const txt = `×${band.count}`;
|
|
326
|
+
if (fl && fl.textContent !== txt) fl.textContent = txt;
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
if (band.kind === 'strip') {
|
|
330
|
+
const exp = band.expanded ? 'true' : 'false';
|
|
331
|
+
if (el.getAttribute('aria-expanded') !== exp) el.setAttribute('aria-expanded', exp);
|
|
332
|
+
const sq = el.querySelector(':scope > .xsq');
|
|
333
|
+
const leds = band.leds || [];
|
|
334
|
+
const cur = sq.querySelectorAll(':scope > .xq');
|
|
335
|
+
if (cur.length !== leds.length) sq.replaceChildren(...leds.map((led) => h('i', `xq is-${led}`)));
|
|
336
|
+
else leds.forEach((led, i) => { const c = `xq is-${led}`; if (cur[i].className !== c) cur[i].className = c; });
|
|
337
|
+
const sum = el.querySelector(':scope > .xsum');
|
|
338
|
+
const t = band.summary || '';
|
|
339
|
+
if (sum && sum.textContent !== t) sum.textContent = t;
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
if (band.kind === 'exec') {
|
|
343
|
+
const cls = execRowClass(band);
|
|
344
|
+
if (el.className !== cls) el.className = cls;
|
|
345
|
+
const l = el.querySelector(':scope > .xl');
|
|
346
|
+
const r = el.querySelector(':scope > .xr');
|
|
347
|
+
const lt = band.label || '';
|
|
348
|
+
const rt = band.right || '';
|
|
349
|
+
if (l && l.textContent !== lt) l.textContent = lt;
|
|
350
|
+
if (r && r.textContent !== rt) r.textContent = rt;
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
const a = el.querySelector(':scope > a'); // kind: 'result'
|
|
354
|
+
if (Boolean(band.path) !== Boolean(a)) return false;
|
|
355
|
+
const txt = band.text || '';
|
|
356
|
+
if (!a) { if (el.textContent !== txt) el.textContent = txt; return true; }
|
|
357
|
+
if (a.textContent !== txt) a.textContent = txt;
|
|
358
|
+
if (a.dataset.path !== band.path) { a.dataset.path = band.path; a.title = band.path; }
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function paintCard(el, node) {
|
|
363
|
+
const p = portsAt(node);
|
|
364
|
+
const orType = node.kind === 'or' ? resolveOrOutType(current, portsFn, node.id, new Set()) : null;
|
|
365
|
+
const awaitWired = ctx.wiredInputs.has(`${node.id}.await`);
|
|
366
|
+
// Never rewrite className wholesale: `sel`, `is-*` and `bad` are owned by the
|
|
367
|
+
// fast paths and must survive a repaint.
|
|
368
|
+
if (el.dataset.kind !== node.kind) {
|
|
369
|
+
for (const c of [...el.classList]) if (c.startsWith('node-')) el.classList.remove(c);
|
|
370
|
+
el.classList.add('node', `node-${node.kind}`);
|
|
371
|
+
el.dataset.kind = node.kind;
|
|
372
|
+
}
|
|
373
|
+
el.style.width = `${NODE_W}px`;
|
|
374
|
+
el.style.height = `${sizeOf(node).h}px`;
|
|
375
|
+
const head = el.querySelector(':scope > .nhead');
|
|
376
|
+
const hd = headerOf(node);
|
|
377
|
+
const sig = `${hd.cls}|${hd.title}|${hd.icon}`;
|
|
378
|
+
if (head.dataset.sig !== sig) {
|
|
379
|
+
head.dataset.sig = sig;
|
|
380
|
+
head.className = `nhead ${hd.cls}`;
|
|
381
|
+
const icon = svgEl('svg');
|
|
382
|
+
icon.setAttribute('viewBox', hd.viewBox);
|
|
383
|
+
icon.setAttribute('fill', 'none');
|
|
384
|
+
icon.setAttribute('stroke', 'currentColor');
|
|
385
|
+
icon.innerHTML = hd.icon;
|
|
386
|
+
head.replaceChildren(icon, h('span', 'tt', hd.title));
|
|
387
|
+
}
|
|
388
|
+
paintBody(el, node, p, orType, awaitWired);
|
|
389
|
+
placeCard(node);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function buildCard(node) {
|
|
393
|
+
const el = h('div', `node node-${node.kind}`);
|
|
394
|
+
el.dataset.nodeId = node.id;
|
|
395
|
+
el.setAttribute('tabindex', '0');
|
|
396
|
+
el.setAttribute('aria-label', `${node.kind} ${node.key || node.id}`);
|
|
397
|
+
el.append(h('div', 'nhead'), h('div', 'nbody'));
|
|
398
|
+
return el;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const anchorOf = (end, dir) => {
|
|
402
|
+
const node = ctx.byId.get(end.node);
|
|
403
|
+
return node ? portAnchor(node, portsAt(node), end.port, dir) : null;
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
/** Writes `d` only when the cached string differs — the whole point of the cache. */
|
|
407
|
+
function paintWire(wireId) {
|
|
408
|
+
const path = wireEls.get(wireId);
|
|
409
|
+
if (!path) return;
|
|
410
|
+
const pts = routesBag.routes.get(wireId);
|
|
411
|
+
if (!pts) return; // dangling endpoint paints nothing, never NaN
|
|
412
|
+
const d = routePathD(pts);
|
|
413
|
+
if (dCache.get(wireId) !== d) {
|
|
414
|
+
dCache.set(wireId, d);
|
|
415
|
+
path.setAttribute('d', d);
|
|
416
|
+
stats.wireDUpdates += 1;
|
|
417
|
+
}
|
|
418
|
+
const badge = badgeEls.get(wireId);
|
|
419
|
+
if (badge) {
|
|
420
|
+
const mid = routeMid(pts);
|
|
421
|
+
badge.style.left = `${mid.x}px`;
|
|
422
|
+
badge.style.top = `${mid.y}px`;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function renderNodes() {
|
|
427
|
+
const seen = new Set();
|
|
428
|
+
for (const node of current.nodes) {
|
|
429
|
+
seen.add(node.id);
|
|
430
|
+
let el = nodeEls.get(node.id);
|
|
431
|
+
if (!el) { el = buildCard(node); nodeEls.set(node.id, el); world.appendChild(el); }
|
|
432
|
+
paintCard(el, node);
|
|
433
|
+
}
|
|
434
|
+
for (const [id, el] of [...nodeEls]) {
|
|
435
|
+
if (seen.has(id)) continue;
|
|
436
|
+
el.remove(); nodeEls.delete(id); footers.delete(id);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function renderWires() {
|
|
441
|
+
const seenW = new Set();
|
|
442
|
+
const seenB = new Set();
|
|
443
|
+
reroute(); // canonical full pass (D15): every render routes from scratch
|
|
444
|
+
incident.clear();
|
|
445
|
+
for (const w of current.wires) {
|
|
446
|
+
if (!w || !w.from || !w.to) continue;
|
|
447
|
+
seenW.add(w.id);
|
|
448
|
+
for (const id of [w.from.node, w.to.node]) {
|
|
449
|
+
if (!incident.has(id)) incident.set(id, new Set());
|
|
450
|
+
incident.get(id).add(w.id);
|
|
451
|
+
}
|
|
452
|
+
let path = wireEls.get(w.id);
|
|
453
|
+
if (!path) {
|
|
454
|
+
path = svgEl('path', 'wire');
|
|
455
|
+
path.dataset.wireId = w.id;
|
|
456
|
+
wireEls.set(w.id, path);
|
|
457
|
+
wiresEl.insertBefore(path, ghost); // committed wires go BEFORE the ghost
|
|
458
|
+
}
|
|
459
|
+
const loop = ctx.loopWireIds.has(w.id);
|
|
460
|
+
path.setAttribute('class', `wire${loop ? ' loop' : ''}`);
|
|
461
|
+
const budget = w.config && w.config.maxCycles;
|
|
462
|
+
if (Number.isInteger(budget)) {
|
|
463
|
+
seenB.add(w.id);
|
|
464
|
+
let badge = badgeEls.get(w.id);
|
|
465
|
+
if (!badge) { badge = h('div', 'wbadge'); badge.dataset.wireId = w.id; badgeEls.set(w.id, badge); world.appendChild(badge); }
|
|
466
|
+
badge.textContent = `≤${budget}`;
|
|
467
|
+
}
|
|
468
|
+
dCache.delete(w.id); // geometry may have moved: force one write
|
|
469
|
+
paintWire(w.id);
|
|
470
|
+
}
|
|
471
|
+
for (const [id, el] of [...wireEls]) if (!seenW.has(id)) { el.remove(); wireEls.delete(id); dCache.delete(id); }
|
|
472
|
+
for (const [id, el] of [...badgeEls]) if (!seenB.has(id)) { el.remove(); badgeEls.delete(id); }
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Validation pips + `bad` wires. One pip per node, title = the first message. */
|
|
476
|
+
function applyReport(report) {
|
|
477
|
+
const byNode = new Map();
|
|
478
|
+
const badWires = new Set();
|
|
479
|
+
for (const e of (report && report.errors) || []) {
|
|
480
|
+
if (e.nodeId && !byNode.has(e.nodeId)) byNode.set(e.nodeId, e.message || e.code);
|
|
481
|
+
if (e.wireId) badWires.add(e.wireId);
|
|
482
|
+
}
|
|
483
|
+
for (const [id, el] of nodeEls) {
|
|
484
|
+
const msg = byNode.get(id);
|
|
485
|
+
let pip = el.querySelector(':scope > .npip');
|
|
486
|
+
if (!msg) { if (pip) pip.remove(); continue; }
|
|
487
|
+
if (!pip) { pip = h('div', 'npip'); pip.dataset.nodeId = id; el.appendChild(pip); }
|
|
488
|
+
pip.title = msg;
|
|
489
|
+
}
|
|
490
|
+
for (const [id, el] of wireEls) el.classList.toggle('bad', badWires.has(id));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function render(template, state = {}) {
|
|
494
|
+
current = template;
|
|
495
|
+
ctx = {
|
|
496
|
+
byId: new Map(template.nodes.map((n) => [n.id, n])),
|
|
497
|
+
wireById: new Map(template.wires.map((w) => [w.id, w])),
|
|
498
|
+
wiredInputs: new Set(template.wires.filter((w) => w && w.to).map((w) => `${w.to.node}.${w.to.port}`)),
|
|
499
|
+
loopWireIds: classifyLoops(template, portsFn).loopWireIds,
|
|
500
|
+
};
|
|
501
|
+
renderNodes();
|
|
502
|
+
renderWires();
|
|
503
|
+
view.setSelection(state.selection || null);
|
|
504
|
+
applyReport(state.report || null);
|
|
505
|
+
// (No decor here: run-decor.mjs's applyDecor(view, decor) — P6 — is the ONE
|
|
506
|
+
// decor pass, run AFTER render through the fast paths below.)
|
|
507
|
+
return view;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function setTransform(next) {
|
|
511
|
+
T = { x: Number(next && next.x) || 0, y: Number(next && next.y) || 0, z: Number(next && next.z) || T.z || 1 };
|
|
512
|
+
world.style.transform = `translate(${T.x}px, ${T.y}px) scale(${T.z})`;
|
|
513
|
+
return { ...T };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
517
|
+
|
|
518
|
+
/** MODEL bounds (no DOM measure): the shared `graphBounds` over the rendered
|
|
519
|
+
* template, with this view's footer rows (only the view knows them), UNIONED
|
|
520
|
+
* with the routed wire vertices — a backward detour or a lane offset leaves
|
|
521
|
+
* the card union, and a fit must never clip it (D9). */
|
|
522
|
+
function bounds(pad = 0) {
|
|
523
|
+
if (!current || !current.nodes.length) return null;
|
|
524
|
+
const base = graphBounds(current, portsAt, { pad: 0, footerRowsOf: (n) => footers.get(n.id) || 0 });
|
|
525
|
+
if (!base) return null;
|
|
526
|
+
let x0 = base.x; let y0 = base.y; let x1 = base.x + base.w; let y1 = base.y + base.h;
|
|
527
|
+
for (const pts of routesBag.routes.values()) {
|
|
528
|
+
for (const p of pts) { x0 = Math.min(x0, p.x); y0 = Math.min(y0, p.y); x1 = Math.max(x1, p.x); y1 = Math.max(y1, p.y); }
|
|
529
|
+
}
|
|
530
|
+
return { x: x0 - pad, y: y0 - pad, w: x1 - x0 + 2 * pad, h: y1 - y0 + 2 * pad };
|
|
531
|
+
}
|
|
532
|
+
/** `fitBounds` → `{z, tx, ty}` mapped onto this view's `{x, y, z}` transform. */
|
|
533
|
+
const applyFit = (b, width, height, zoomMax) => {
|
|
534
|
+
const f = fitBounds(b, { width, height }, { zoomMin: zMin, zoomMax });
|
|
535
|
+
setTransform({ x: f.tx, y: f.ty, z: f.z });
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
/** Zoom about a stage-local point s: w = (s − t)/z is invariant ⇒ t' = s − w·z'. */
|
|
539
|
+
function zoomAbout(zNext, sx, sy) {
|
|
540
|
+
const z2 = clamp(zNext, zMin, zMax);
|
|
541
|
+
const wx = (sx - T.x) / T.z;
|
|
542
|
+
const wy = (sy - T.y) / T.z;
|
|
543
|
+
setTransform({ x: sx - wx * z2, y: sy - wy * z2, z: z2 });
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
let R = { left: 0, top: 0, width: 0, height: 0 };
|
|
547
|
+
/** The ONE measurement in the whole renderer. `viewport` injects it under jsdom. */
|
|
548
|
+
function readRect() {
|
|
549
|
+
if (viewport) { R = { ...viewport() }; return R; }
|
|
550
|
+
const b = stage.getBoundingClientRect();
|
|
551
|
+
R = { left: b.left, top: b.top, width: b.width, height: b.height };
|
|
552
|
+
stats.rectReads += 1;
|
|
553
|
+
return R;
|
|
554
|
+
}
|
|
555
|
+
const toWorld = (cx, cy) => ({ x: (cx - R.left - T.x) / T.z, y: (cy - R.top - T.y) / T.z });
|
|
556
|
+
const toScreen = (wx, wy) => ({ x: wx * T.z + T.x, y: wy * T.z + T.y });
|
|
557
|
+
|
|
558
|
+
const view = {
|
|
559
|
+
stage, world, wiresEl, ghostEl: ghost, mode, stats, schedule, wheelPan,
|
|
560
|
+
zoomMin: zMin, zoomMax: zMax,
|
|
561
|
+
render,
|
|
562
|
+
setTransform,
|
|
563
|
+
getTransform: () => ({ ...T }),
|
|
564
|
+
nodeEl: (id) => nodeEls.get(id) || null,
|
|
565
|
+
wireEl: (id) => wireEls.get(id) || null,
|
|
566
|
+
template: () => current,
|
|
567
|
+
ports: (node) => portsAt(node),
|
|
568
|
+
size: (node) => sizeOf(node),
|
|
569
|
+
anchor: (node, portId, dir) => portAnchor(node, portsAt(node), portId, dir),
|
|
570
|
+
incidentOf: (nodeId) => incident.get(nodeId) || new Set(),
|
|
571
|
+
isLoopWire: (wireId) => Boolean(ctx && ctx.loopWireIds.has(wireId)),
|
|
572
|
+
setSelection(sel) {
|
|
573
|
+
for (const [id, el] of nodeEls) el.classList.toggle('sel', Boolean(sel && sel.kind === 'node' && sel.id === id));
|
|
574
|
+
for (const [id, el] of wireEls) el.classList.toggle('sel', Boolean(sel && sel.kind === 'wire' && sel.id === id));
|
|
575
|
+
},
|
|
576
|
+
// (no applyDecor on the view: run-decor.mjs's applyDecor(view, decor) — P6 — owns the decor pass)
|
|
577
|
+
/** Statuses the monitor sets; every one is a class toggle, never a rebuild. */
|
|
578
|
+
setStatus(nodeId, status) {
|
|
579
|
+
const el = nodeEls.get(nodeId);
|
|
580
|
+
if (!el) return;
|
|
581
|
+
for (const s of ['pending', 'active', 'done', 'paused', 'stopped', 'error', 'skipped']) {
|
|
582
|
+
el.classList.toggle(`is-${s}`, s === status);
|
|
583
|
+
}
|
|
584
|
+
if (status) el.dataset.status = status; else delete el.dataset.status;
|
|
585
|
+
},
|
|
586
|
+
/** Reconcile the executions footer to `bands` (the run monitor's vocabulary,
|
|
587
|
+
* see the Interfaces block) and RE-SIZE the card from the footer LINE count
|
|
588
|
+
* (Σ bandUnits — a wrapped fan or stacked exec row bills every line). Anchors
|
|
589
|
+
* are top-relative, so no wire re-routes (D8) — only height, hit box and fit
|
|
590
|
+
* bounds change. The ONE place a run-mode card height is written.
|
|
591
|
+
*
|
|
592
|
+
* REUSE, never rebuild (MAJ-20): applyDecor calls this for every node on
|
|
593
|
+
* every decor generation, and the strip is a real <button> — the run
|
|
594
|
+
* monitor's only interactive footer control. Rebuilding it moved a keyboard
|
|
595
|
+
* user's focus to <body> (so the strip could not be expanded by keyboard at
|
|
596
|
+
* all on a live run) and swallowed any click whose down/up straddled a
|
|
597
|
+
* repaint. A dataset.sig diff is not enough: an expanded node's exec rows
|
|
598
|
+
* carry a live duration, so its footer changes on every tick and would
|
|
599
|
+
* rebuild anyway. Elements are therefore keyed by bandKey() and written in
|
|
600
|
+
* place; only a band that vanished is removed. */
|
|
601
|
+
setFooter(nodeId, bands) {
|
|
602
|
+
const node = ctx && ctx.byId.get(nodeId);
|
|
603
|
+
const el = nodeEls.get(nodeId);
|
|
604
|
+
if (!node || !el) return;
|
|
605
|
+
const prevLines = footers.get(nodeId) || 0;
|
|
606
|
+
const list = Array.isArray(bands) ? bands.filter(Boolean) : [];
|
|
607
|
+
const feet = el.querySelectorAll(':scope > .xfoot');
|
|
608
|
+
let foot = feet[0] || null;
|
|
609
|
+
for (const stale of feet) if (stale !== foot) stale.remove(); // a card owns ONE footer
|
|
610
|
+
if (!list.length) {
|
|
611
|
+
if (foot) foot.remove();
|
|
612
|
+
footers.delete(nodeId);
|
|
613
|
+
el.style.height = `${sizeOf(node).h}px`;
|
|
614
|
+
if ((footers.get(nodeId) || 0) !== prevLines) rerouteAll(); // a changed obstacle box re-routes (D16)
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
if (!foot) {
|
|
618
|
+
foot = h('div', 'xfoot');
|
|
619
|
+
foot.dataset.nodeId = nodeId;
|
|
620
|
+
el.appendChild(foot);
|
|
621
|
+
}
|
|
622
|
+
// Index what is already there; a duplicate key cannot be reused twice.
|
|
623
|
+
const have = new Map();
|
|
624
|
+
for (const kid of [...foot.children]) {
|
|
625
|
+
const k = kid.dataset.bandKey;
|
|
626
|
+
if (k && !have.has(k)) have.set(k, kid); else kid.remove();
|
|
627
|
+
}
|
|
628
|
+
let i = 0;
|
|
629
|
+
for (const band of list) {
|
|
630
|
+
const key = bandKey(band);
|
|
631
|
+
let kid = have.get(key) || null;
|
|
632
|
+
if (kid) {
|
|
633
|
+
have.delete(key);
|
|
634
|
+
if (!syncBand(kid, band)) {
|
|
635
|
+
const next = bandEl(nodeId, band);
|
|
636
|
+
next.dataset.bandKey = key;
|
|
637
|
+
kid.replaceWith(next);
|
|
638
|
+
kid = next;
|
|
639
|
+
}
|
|
640
|
+
} else {
|
|
641
|
+
kid = bandEl(nodeId, band);
|
|
642
|
+
kid.dataset.bandKey = key;
|
|
643
|
+
}
|
|
644
|
+
// Only ever moves when the band ORDER changed; the steady state (and an
|
|
645
|
+
// expand, which appends) leaves every element exactly where it is.
|
|
646
|
+
if (foot.children[i] !== kid) foot.insertBefore(kid, foot.children[i] || null);
|
|
647
|
+
i += 1;
|
|
648
|
+
}
|
|
649
|
+
for (const gone of have.values()) gone.remove();
|
|
650
|
+
footers.set(nodeId, list.reduce((a, band) => a + bandUnits(band), 0));
|
|
651
|
+
el.style.height = `${sizeOf(node).h}px`;
|
|
652
|
+
if ((footers.get(nodeId) || 0) !== prevLines) rerouteAll(); // a changed obstacle box re-routes (D16)
|
|
653
|
+
},
|
|
654
|
+
/** Per-card ornaments: agent colour, gate pip, header duration · cost. */
|
|
655
|
+
setNodeChrome(nodeId, { color = '', gate = null, totals = null } = {}) {
|
|
656
|
+
const el = nodeEls.get(nodeId);
|
|
657
|
+
if (!el) return;
|
|
658
|
+
el.style.setProperty('--c', color ? `var(--${color})` : '');
|
|
659
|
+
// Keep the 1 s elapsed tick (app.js `.run-node[data-id] .dur`) working on v2 cards.
|
|
660
|
+
el.classList.add('run-node');
|
|
661
|
+
el.dataset.id = nodeId;
|
|
662
|
+
for (const stale of el.querySelectorAll(':scope > .ngate')) stale.remove();
|
|
663
|
+
if (gate) {
|
|
664
|
+
const pip = h('div', 'ngate', '?');
|
|
665
|
+
pip.dataset.wireId = gate.wireId || '';
|
|
666
|
+
pip.title = gate.title || '';
|
|
667
|
+
el.appendChild(pip);
|
|
668
|
+
}
|
|
669
|
+
let run = el.querySelector(':scope > .nrun');
|
|
670
|
+
if (!totals) { if (run) run.remove(); return; }
|
|
671
|
+
if (!run) { run = h('div', 'nrun'); run.append(h('span', 'dur'), h('span', 'cost')); el.appendChild(run); }
|
|
672
|
+
run.querySelector('.dur').textContent = totals.dur || '';
|
|
673
|
+
run.querySelector('.cost').textContent = totals.cost || '';
|
|
674
|
+
},
|
|
675
|
+
/** The amber `N×` delivery badge on a loop wire's bow (no-op on a plain wire). */
|
|
676
|
+
setWireBadge(wireId, badge) {
|
|
677
|
+
const badgeHost = badgeEls.get(wireId);
|
|
678
|
+
if (!badgeHost) return;
|
|
679
|
+
for (const stale of badgeHost.querySelectorAll('.wfired')) stale.remove();
|
|
680
|
+
if (!badge) return;
|
|
681
|
+
const b = h('span', 'wfired', badge.text || '');
|
|
682
|
+
if (badge.title) b.title = badge.title;
|
|
683
|
+
badgeHost.appendChild(b);
|
|
684
|
+
},
|
|
685
|
+
/** Ants march per path (the :root clock is retired — it forced a whole-
|
|
686
|
+
* document style recalc per frame). A wire GOING live starts its own
|
|
687
|
+
* animation, so it is seated at the shared document-timeline phase with a
|
|
688
|
+
* negative delay: every live wire marches in step, and because one
|
|
689
|
+
* iteration is exactly one 12px dash period the seat itself is seamless.
|
|
690
|
+
* 600 = the .6s period style.css declares on `animation:wireDash`.
|
|
691
|
+
* Steady-state calls must NOT rewrite the stamp: re-stamping a RUNNING
|
|
692
|
+
* animation re-maps its time against the ORIGINAL start and desyncs the
|
|
693
|
+
* phase — so the stamp is written only on the off→on edge, and it leaves
|
|
694
|
+
* with the class. render() wipes wire classes wholesale (the
|
|
695
|
+
* setAttribute('class', …) repaint), so a remounted wire re-enters
|
|
696
|
+
* through was=false and gets a fresh, phase-exact seat; a stale inline
|
|
697
|
+
* delay on a dark wire is inert (no animation without the class).
|
|
698
|
+
* jsdom: no document.timeline -> t=0 -> a zero stamp. */
|
|
699
|
+
setWireLive(ids) {
|
|
700
|
+
const live = new Set(ids || []);
|
|
701
|
+
for (const [id, el] of wireEls) {
|
|
702
|
+
const was = el.classList.contains('wire-live');
|
|
703
|
+
const on = live.has(id);
|
|
704
|
+
el.classList.toggle('wire-live', on);
|
|
705
|
+
if (on && !was) {
|
|
706
|
+
const t = Number(doc.timeline && doc.timeline.currentTime) || 0;
|
|
707
|
+
el.style.animationDelay = `-${t % 600}ms`;
|
|
708
|
+
} else if (!on && was) {
|
|
709
|
+
el.style.removeProperty('animation-delay');
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
},
|
|
713
|
+
/** One transform write per dragged node, then a dirty-filtered re-route: a
|
|
714
|
+
* card is an OBSTACLE, so moving it changes the wires it starts and stops
|
|
715
|
+
* blocking, not just its own (D7). The dCache gate keeps the DOM writes to
|
|
716
|
+
* the wires whose route actually moved. */
|
|
717
|
+
moveNode(nodeId) {
|
|
718
|
+
const node = ctx && ctx.byId.get(nodeId);
|
|
719
|
+
if (!node) return;
|
|
720
|
+
const before = lastRect.get(nodeId) || null;
|
|
721
|
+
placeCard(node);
|
|
722
|
+
reroute(unionRect(before, rectOf(node)));
|
|
723
|
+
for (const id of wireEls.keys()) paintWire(id);
|
|
724
|
+
},
|
|
725
|
+
/** D15: the canonical `dirty = null` pass every gesture must END in. */
|
|
726
|
+
rerouteAll,
|
|
727
|
+
/** The EXACT painted polyline for a wire (null while dangling) — the hit test
|
|
728
|
+
* and the tests consume this, so paint and hit can never diverge. */
|
|
729
|
+
wireRoute(wireId) { return routesBag.routes.get(wireId) || null; },
|
|
730
|
+
/** Routed ghost `d` for the composer's wiring drag. looseEnd: the cursor may
|
|
731
|
+
* sit inside a card while hovering its port. */
|
|
732
|
+
routeGhost(anchor, end, { mirror = false } = {}) {
|
|
733
|
+
return routePathD(routeWire(anchor, end, obstacleRects(), { mirror, looseEnd: true }));
|
|
734
|
+
},
|
|
735
|
+
paintWire,
|
|
736
|
+
/** `d = null` hides the ghost. Identical `d` never re-writes the attribute. */
|
|
737
|
+
setGhost(d, cls = '') {
|
|
738
|
+
if (d == null) { ghost.setAttribute('class', 'wire ghost'); return; }
|
|
739
|
+
if (ghost.getAttribute('d') !== d) { ghost.setAttribute('d', d); stats.ghostUpdates += 1; }
|
|
740
|
+
ghost.setAttribute('class', `wire ghost on${cls ? ` ${cls}` : ''}`);
|
|
741
|
+
},
|
|
742
|
+
/** Pan (never zoom) the node's box centre to the viewport centre. */
|
|
743
|
+
centerOn(nodeId) {
|
|
744
|
+
const node = ctx && ctx.byId.get(nodeId);
|
|
745
|
+
if (!node) return;
|
|
746
|
+
const r = view.readRect();
|
|
747
|
+
const s = sizeOf(node);
|
|
748
|
+
setTransform({
|
|
749
|
+
x: r.width / 2 - (node.x + s.w / 2) * T.z,
|
|
750
|
+
y: r.height / 2 - (node.y + s.h / 2) * T.z,
|
|
751
|
+
z: T.z,
|
|
752
|
+
});
|
|
753
|
+
},
|
|
754
|
+
readRect, toWorld, toScreen, rect: () => ({ ...R }),
|
|
755
|
+
bounds,
|
|
756
|
+
zoomAbout,
|
|
757
|
+
/** Auto-fit from MODEL bounds into the band left of the floating inspector.
|
|
758
|
+
* Fit NEVER magnifies past 1x; the user zoom range stays zoomMin..zoomMax.
|
|
759
|
+
* Runs on view entry/re-entry and template load only — never after an edit. */
|
|
760
|
+
fit({ insetRight = 0, pad = 60 } = {}) {
|
|
761
|
+
const r = view.readRect();
|
|
762
|
+
const b = bounds(pad);
|
|
763
|
+
if (!b) return;
|
|
764
|
+
applyFit(b, Math.max(1, (r.width || 0) - insetRight), Math.max(1, r.height || 0), 1); // never past 1×
|
|
765
|
+
},
|
|
766
|
+
/** Static hosts: fit the graph into a card of width `w` (ResizeObserver-driven). */
|
|
767
|
+
fitToWidth(w) {
|
|
768
|
+
const r = view.readRect();
|
|
769
|
+
const b = bounds(60);
|
|
770
|
+
if (!b) return;
|
|
771
|
+
const vw = Math.max(1, w || r.width || 0);
|
|
772
|
+
const f = fitBounds(b, { width: vw, height: Number.MAX_SAFE_INTEGER }, { zoomMin: zMin, zoomMax: 1 }); // width decides z
|
|
773
|
+
setTransform({ x: f.tx, y: (Math.max(1, r.height || 0) - b.h * f.z) / 2 - b.y * f.z, z: f.z });
|
|
774
|
+
},
|
|
775
|
+
/** Wheel/zoom nav for `monitor` hosts. `static` gets nothing; `edit` binds its
|
|
776
|
+
* own richer pipeline in composer.mjs and does NOT call this. */
|
|
777
|
+
createNav({ wheelPan: pan = wheelPan, onEngaged = null } = {}) {
|
|
778
|
+
if (mode === 'static') return { destroy() {} };
|
|
779
|
+
let engaged = pan === 'always';
|
|
780
|
+
const setEngaged = (v) => { if (engaged !== v) { engaged = v; if (onEngaged) onEngaged(v); } };
|
|
781
|
+
const onWheel = (ev) => {
|
|
782
|
+
const zoom = ev.ctrlKey || ev.metaKey;
|
|
783
|
+
if (!zoom && !engaged) return; // engaged-only: let the PAGE scroll
|
|
784
|
+
ev.preventDefault();
|
|
785
|
+
const m = ev.deltaMode === 1 ? 16 : ev.deltaMode === 2 ? (R.height || 560) : 1;
|
|
786
|
+
if (zoom) { zoomAbout(T.z * Math.exp(-ev.deltaY * m * 0.002), ev.clientX - R.left, ev.clientY - R.top); return; }
|
|
787
|
+
setTransform({ x: T.x - ev.deltaX * m, y: T.y - ev.deltaY * m, z: T.z });
|
|
788
|
+
};
|
|
789
|
+
const engage = () => setEngaged(true);
|
|
790
|
+
const disengage = (ev) => { if (pan !== 'always' && !stage.contains(ev.target)) setEngaged(false); };
|
|
791
|
+
const onKey = (ev) => { if (ev.key === 'Escape' && pan !== 'always') setEngaged(false); };
|
|
792
|
+
view.readRect();
|
|
793
|
+
stage.addEventListener('wheel', onWheel, { passive: false });
|
|
794
|
+
stage.addEventListener('pointerdown', engage);
|
|
795
|
+
stage.addEventListener('focus', engage);
|
|
796
|
+
doc.addEventListener('pointerdown', disengage, true);
|
|
797
|
+
doc.addEventListener('keydown', onKey);
|
|
798
|
+
const nav = {
|
|
799
|
+
isEngaged: () => engaged,
|
|
800
|
+
destroy() {
|
|
801
|
+
stage.removeEventListener('wheel', onWheel);
|
|
802
|
+
stage.removeEventListener('pointerdown', engage);
|
|
803
|
+
stage.removeEventListener('focus', engage);
|
|
804
|
+
doc.removeEventListener('pointerdown', disengage, true);
|
|
805
|
+
doc.removeEventListener('keydown', onKey);
|
|
806
|
+
},
|
|
807
|
+
};
|
|
808
|
+
navs.push(nav);
|
|
809
|
+
return nav;
|
|
810
|
+
},
|
|
811
|
+
/** Swap the registry the headers read (the palette arrives after the first
|
|
812
|
+
* paint when /api/agents is slow). Header signatures are invalidated so the
|
|
813
|
+
* next render repaints tint, icon and title. Never destroy the view here —
|
|
814
|
+
* that would repaint into a detached root (a permanently blank canvas). */
|
|
815
|
+
setAgents(next) {
|
|
816
|
+
agents = next || {};
|
|
817
|
+
for (const el of nodeEls.values()) {
|
|
818
|
+
const head = el.querySelector(':scope > .nhead');
|
|
819
|
+
if (head) delete head.dataset.sig;
|
|
820
|
+
}
|
|
821
|
+
if (current) render(current, {});
|
|
822
|
+
},
|
|
823
|
+
destroy() {
|
|
824
|
+
for (const n of navs.splice(0)) n.destroy();
|
|
825
|
+
stage.remove();
|
|
826
|
+
nodeEls.clear(); wireEls.clear(); badgeEls.clear(); incident.clear(); dCache.clear(); footers.clear();
|
|
827
|
+
current = null; ctx = null;
|
|
828
|
+
},
|
|
829
|
+
};
|
|
830
|
+
// Internals the later tasks' fast paths close over.
|
|
831
|
+
view._internals = { incident, dCache, footers };
|
|
832
|
+
setTransform(T);
|
|
833
|
+
return view;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/** Saved-pipeline preview markup. Numbers only — no DOM, no measure. */
|
|
837
|
+
export function thumbnailFor(template, portsFn, { width = 240, height = 96 } = {}) {
|
|
838
|
+
if (!template || !Array.isArray(template.nodes) || !template.nodes.length) return '';
|
|
839
|
+
return thumbnailSvg(template, portsFn, { width, height });
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** A non-interactive graph for a fixed-width card (saved rows, Running list).
|
|
843
|
+
* NO listeners: the card's own click handler must keep working, which is why
|
|
844
|
+
* `.gv-static .node` is pointer-events:none in style.css. */
|
|
845
|
+
export function mountStaticGraph(host, template, { doc = globalThis.document, portsFn, agents = {}, width = 0, viewport = null } = {}) {
|
|
846
|
+
const view = createGraphView(host, { doc, mode: 'static', portsFn, agents, viewport });
|
|
847
|
+
view.render(template, {});
|
|
848
|
+
const paint = () => view.fitToWidth(width || host.clientWidth || 0);
|
|
849
|
+
paint();
|
|
850
|
+
const win = doc.defaultView || globalThis;
|
|
851
|
+
if (typeof win.ResizeObserver === 'function') {
|
|
852
|
+
const ro = new win.ResizeObserver(() => view.fitToWidth(host.clientWidth || width || 0));
|
|
853
|
+
ro.observe(host);
|
|
854
|
+
const inner = view.destroy;
|
|
855
|
+
view.destroy = () => { ro.disconnect(); inner(); };
|
|
856
|
+
}
|
|
857
|
+
return view;
|
|
858
|
+
}
|