@osolmaz/pi-workflows 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +182 -0
- package/dist/extension/executor.d.ts +58 -0
- package/dist/extension/executor.js +201 -0
- package/dist/extension/executor.js.map +1 -0
- package/dist/extension/index.d.ts +17 -0
- package/dist/extension/index.js +504 -0
- package/dist/extension/index.js.map +1 -0
- package/dist/extension/widget.d.ts +21 -0
- package/dist/extension/widget.js +142 -0
- package/dist/extension/widget.js.map +1 -0
- package/dist/render/ansi.d.ts +16 -0
- package/dist/render/ansi.js +42 -0
- package/dist/render/ansi.js.map +1 -0
- package/dist/render/canvas.d.ts +40 -0
- package/dist/render/canvas.js +177 -0
- package/dist/render/canvas.js.map +1 -0
- package/dist/render/format.d.ts +3 -0
- package/dist/render/format.js +17 -0
- package/dist/render/format.js.map +1 -0
- package/dist/render/graph-render.d.ts +22 -0
- package/dist/render/graph-render.js +520 -0
- package/dist/render/graph-render.js.map +1 -0
- package/dist/render/graph.d.ts +46 -0
- package/dist/render/graph.js +272 -0
- package/dist/render/graph.js.map +1 -0
- package/dist/viewer/cli.d.ts +10 -0
- package/dist/viewer/cli.js +132 -0
- package/dist/viewer/cli.js.map +1 -0
- package/dist/viewer/render.d.ts +19 -0
- package/dist/viewer/render.js +162 -0
- package/dist/viewer/render.js.map +1 -0
- package/dist/viewer/tui.d.ts +11 -0
- package/dist/viewer/tui.js +140 -0
- package/dist/viewer/tui.js.map +1 -0
- package/dist/viewer/watch.d.ts +9 -0
- package/dist/viewer/watch.js +46 -0
- package/dist/viewer/watch.js.map +1 -0
- package/dist/workflows/decision.d.ts +25 -0
- package/dist/workflows/decision.js +96 -0
- package/dist/workflows/decision.js.map +1 -0
- package/dist/workflows/definition.d.ts +9 -0
- package/dist/workflows/definition.js +61 -0
- package/dist/workflows/definition.js.map +1 -0
- package/dist/workflows/engine.d.ts +65 -0
- package/dist/workflows/engine.js +574 -0
- package/dist/workflows/engine.js.map +1 -0
- package/dist/workflows/errors.d.ts +9 -0
- package/dist/workflows/errors.js +24 -0
- package/dist/workflows/errors.js.map +1 -0
- package/dist/workflows/graph.d.ts +17 -0
- package/dist/workflows/graph.js +127 -0
- package/dist/workflows/graph.js.map +1 -0
- package/dist/workflows/index.d.ts +11 -0
- package/dist/workflows/index.js +11 -0
- package/dist/workflows/index.js.map +1 -0
- package/dist/workflows/json.d.ts +14 -0
- package/dist/workflows/json.js +134 -0
- package/dist/workflows/json.js.map +1 -0
- package/dist/workflows/loader.d.ts +28 -0
- package/dist/workflows/loader.js +94 -0
- package/dist/workflows/loader.js.map +1 -0
- package/dist/workflows/schema.d.ts +7 -0
- package/dist/workflows/schema.js +176 -0
- package/dist/workflows/schema.js.map +1 -0
- package/dist/workflows/shell.d.ts +9 -0
- package/dist/workflows/shell.js +177 -0
- package/dist/workflows/shell.js.map +1 -0
- package/dist/workflows/store.d.ts +35 -0
- package/dist/workflows/store.js +181 -0
- package/dist/workflows/store.js.map +1 -0
- package/dist/workflows/text.d.ts +10 -0
- package/dist/workflows/text.js +32 -0
- package/dist/workflows/text.js.map +1 -0
- package/dist/workflows/types.d.ts +280 -0
- package/dist/workflows/types.js +2 -0
- package/dist/workflows/types.js.map +1 -0
- package/docs/development.md +130 -0
- package/docs/run-bundles.md +114 -0
- package/docs/workflows.md +311 -0
- package/examples/workflows/autoimplement.workflow.ts +92 -0
- package/examples/workflows/autoresearch.workflow.ts +139 -0
- package/examples/workflows/branch.workflow.ts +63 -0
- package/examples/workflows/echo.workflow.ts +23 -0
- package/examples/workflows/elegant-solution.workflow.ts +95 -0
- package/examples/workflows/shell.workflow.ts +31 -0
- package/examples/workflows/two-turn.workflow.ts +64 -0
- package/package.json +80 -0
- package/src/extension/executor.ts +251 -0
- package/src/extension/index.ts +627 -0
- package/src/extension/widget.ts +183 -0
- package/src/render/ansi.ts +47 -0
- package/src/render/canvas.ts +196 -0
- package/src/render/format.ts +19 -0
- package/src/render/graph-render.ts +738 -0
- package/src/render/graph.ts +341 -0
- package/src/viewer/cli.ts +150 -0
- package/src/viewer/render.ts +236 -0
- package/src/viewer/tui.ts +159 -0
- package/src/viewer/watch.ts +55 -0
- package/src/workflows/decision.ts +127 -0
- package/src/workflows/definition.ts +104 -0
- package/src/workflows/engine.ts +793 -0
- package/src/workflows/errors.ts +27 -0
- package/src/workflows/graph.ts +161 -0
- package/src/workflows/index.ts +76 -0
- package/src/workflows/json.ts +155 -0
- package/src/workflows/loader.ts +123 -0
- package/src/workflows/schema.ts +218 -0
- package/src/workflows/shell.ts +199 -0
- package/src/workflows/store.ts +234 -0
- package/src/workflows/text.ts +34 -0
- package/src/workflows/types.ts +318 -0
|
@@ -0,0 +1,738 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
WorkflowDefinitionSnapshot,
|
|
3
|
+
WorkflowRunState,
|
|
4
|
+
WorkflowStepRecord,
|
|
5
|
+
} from "../workflows/types.js";
|
|
6
|
+
import { ansi, sanitizeText } from "./ansi.js";
|
|
7
|
+
import { CharCanvas, type CanvasStyle } from "./canvas.js";
|
|
8
|
+
import { formatDuration } from "./format.js";
|
|
9
|
+
import {
|
|
10
|
+
layoutGraph,
|
|
11
|
+
type GraphCell,
|
|
12
|
+
type GraphEdge,
|
|
13
|
+
type GraphLayout,
|
|
14
|
+
type GraphSegment,
|
|
15
|
+
} from "./graph.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Renders the workflow DAG as text, mirroring the acpx replay viewer's graph
|
|
19
|
+
* pane: statuses derive from the steps visible up to the selected step, taken
|
|
20
|
+
* transitions highlight, switch branches carry case labels, and loop edges
|
|
21
|
+
* route through a right-hand gutter.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Everything the graph needs; a LoadedRunBundle satisfies this shape. */
|
|
25
|
+
export type GraphView = {
|
|
26
|
+
state: WorkflowRunState;
|
|
27
|
+
snapshot: WorkflowDefinitionSnapshot | null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type NodeStatus = "completed" | "failed" | "active" | "waiting" | "queued" | "cancelled";
|
|
31
|
+
|
|
32
|
+
const STATUS_GLYPHS: Record<NodeStatus, string> = {
|
|
33
|
+
completed: "✓",
|
|
34
|
+
failed: "✗",
|
|
35
|
+
active: "◐",
|
|
36
|
+
waiting: "⏸",
|
|
37
|
+
cancelled: "~",
|
|
38
|
+
queued: "·",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const STATUS_STYLES: Record<NodeStatus, CanvasStyle> = {
|
|
42
|
+
completed: "ok",
|
|
43
|
+
failed: "fail",
|
|
44
|
+
active: "active",
|
|
45
|
+
waiting: "warn",
|
|
46
|
+
cancelled: "warn",
|
|
47
|
+
queued: "dim",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const CELL_GAP = 4;
|
|
51
|
+
const GUTTER_GAP = 2;
|
|
52
|
+
|
|
53
|
+
/** How node cells are drawn: single text lines or bordered boxes. */
|
|
54
|
+
export type GraphNodeStyle = "line" | "box";
|
|
55
|
+
|
|
56
|
+
export type GraphRenderOptions = {
|
|
57
|
+
nodeStyle?: GraphNodeStyle;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Rows a node cell occupies: boxes add a border row above and below. */
|
|
61
|
+
function cellHeight(nodeStyle: GraphNodeStyle): number {
|
|
62
|
+
return nodeStyle === "box" ? 3 : 1;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function paint(text: string, style: CanvasStyle): string {
|
|
66
|
+
switch (style) {
|
|
67
|
+
case "taken":
|
|
68
|
+
case "ok":
|
|
69
|
+
return ansi.green(text);
|
|
70
|
+
case "active":
|
|
71
|
+
return ansi.cyan(text);
|
|
72
|
+
case "back":
|
|
73
|
+
case "warn":
|
|
74
|
+
return ansi.yellow(text);
|
|
75
|
+
case "fail":
|
|
76
|
+
return ansi.red(text);
|
|
77
|
+
case "dim":
|
|
78
|
+
return ansi.dim(text);
|
|
79
|
+
default:
|
|
80
|
+
return text;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function latestVisibleAttempt(
|
|
85
|
+
steps: WorkflowStepRecord[],
|
|
86
|
+
nodeId: string,
|
|
87
|
+
): WorkflowStepRecord | undefined {
|
|
88
|
+
for (let index = steps.length - 1; index >= 0; index -= 1) {
|
|
89
|
+
if (steps[index]?.nodeId === nodeId) {
|
|
90
|
+
return steps[index];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function deriveNodeStatus(
|
|
97
|
+
view: GraphView,
|
|
98
|
+
nodeId: string,
|
|
99
|
+
visibleSteps: WorkflowStepRecord[],
|
|
100
|
+
atLatestStep: boolean,
|
|
101
|
+
): NodeStatus {
|
|
102
|
+
const state = view.state;
|
|
103
|
+
if (atLatestStep && state.currentNode === nodeId) {
|
|
104
|
+
return "active";
|
|
105
|
+
}
|
|
106
|
+
if (atLatestStep && state.waitingOn === nodeId) {
|
|
107
|
+
return "waiting";
|
|
108
|
+
}
|
|
109
|
+
const attempt = latestVisibleAttempt(visibleSteps, nodeId);
|
|
110
|
+
if (!attempt) {
|
|
111
|
+
return "queued";
|
|
112
|
+
}
|
|
113
|
+
// While scrubbing, the selected step's node reads as the active position.
|
|
114
|
+
if (!atLatestStep && visibleSteps.at(-1)?.nodeId === nodeId) {
|
|
115
|
+
return "active";
|
|
116
|
+
}
|
|
117
|
+
switch (attempt.outcome) {
|
|
118
|
+
case "ok":
|
|
119
|
+
return "completed";
|
|
120
|
+
case "cancelled":
|
|
121
|
+
return "cancelled";
|
|
122
|
+
default:
|
|
123
|
+
return "failed";
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
type RenderedCell = {
|
|
128
|
+
cell: GraphCell;
|
|
129
|
+
text: string;
|
|
130
|
+
status: NodeStatus | null;
|
|
131
|
+
width: number;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
function renderCellText(
|
|
135
|
+
view: GraphView,
|
|
136
|
+
cell: GraphCell,
|
|
137
|
+
visibleSteps: WorkflowStepRecord[],
|
|
138
|
+
atLatestStep: boolean,
|
|
139
|
+
now: Date,
|
|
140
|
+
nodeStyle: GraphNodeStyle,
|
|
141
|
+
): RenderedCell {
|
|
142
|
+
if (cell.kind === "virtual") {
|
|
143
|
+
return { cell, text: "", status: null, width: 1 };
|
|
144
|
+
}
|
|
145
|
+
const state = view.state;
|
|
146
|
+
const nodeId = cell.nodeId;
|
|
147
|
+
const status = deriveNodeStatus(view, nodeId, visibleSteps, atLatestStep);
|
|
148
|
+
const nodeType = view.snapshot?.nodes[nodeId]?.nodeType ?? "?";
|
|
149
|
+
const attempt = latestVisibleAttempt(visibleSteps, nodeId);
|
|
150
|
+
const attempts = visibleSteps.filter((step) => step.nodeId === nodeId).length;
|
|
151
|
+
const parts = [`${nodeId} [${nodeType}]`];
|
|
152
|
+
if (atLatestStep && state.currentNode === nodeId) {
|
|
153
|
+
const startedAt = state.currentNodeStartedAt
|
|
154
|
+
? Date.parse(state.currentNodeStartedAt)
|
|
155
|
+
: now.getTime();
|
|
156
|
+
parts.push(`running ${formatDuration(now.getTime() - startedAt)}`);
|
|
157
|
+
if (state.statusDetail) {
|
|
158
|
+
// statusDetail can be set by workflow authors; keep terminal-safe.
|
|
159
|
+
parts.push(`· ${sanitizeText(state.statusDetail)}`);
|
|
160
|
+
}
|
|
161
|
+
} else if (attempt) {
|
|
162
|
+
const durationMs = Date.parse(attempt.finishedAt) - Date.parse(attempt.startedAt);
|
|
163
|
+
parts.push(formatDuration(durationMs));
|
|
164
|
+
}
|
|
165
|
+
if (attempts > 1) {
|
|
166
|
+
parts.push(`×${attempts}`);
|
|
167
|
+
}
|
|
168
|
+
const text = parts.join(" ");
|
|
169
|
+
// Width includes the status glyph and the space after it; boxes add a
|
|
170
|
+
// border and one padding column on each side.
|
|
171
|
+
const contentWidth = text.length + 2;
|
|
172
|
+
return { cell, text, status, width: nodeStyle === "box" ? contentWidth + 4 : contentWidth };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
type RankGeometry = {
|
|
176
|
+
cells: RenderedCell[];
|
|
177
|
+
centers: number[];
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
type PlacedRank = RankGeometry & { y: number };
|
|
181
|
+
|
|
182
|
+
/** A strip segment with final pixel geometry and its assigned track row. */
|
|
183
|
+
type GeomSegment = {
|
|
184
|
+
edgeId: string;
|
|
185
|
+
label?: string | undefined;
|
|
186
|
+
fromX: number;
|
|
187
|
+
toX: number;
|
|
188
|
+
track: number;
|
|
189
|
+
targetIsNode: boolean;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
type StripGeometry = {
|
|
193
|
+
segments: GeomSegment[];
|
|
194
|
+
trackCount: number;
|
|
195
|
+
hasLabels: boolean;
|
|
196
|
+
/** True when every segment is an unlabeled vertical line. */
|
|
197
|
+
straight: boolean;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/** Transitions actually taken between the visible steps, as "from->to". */
|
|
201
|
+
function takenTransitions(visibleSteps: WorkflowStepRecord[]): Set<string> {
|
|
202
|
+
const transitions = new Set<string>();
|
|
203
|
+
for (let index = 1; index < visibleSteps.length; index += 1) {
|
|
204
|
+
transitions.add(`${visibleSteps[index - 1]?.nodeId}->${visibleSteps[index]?.nodeId}`);
|
|
205
|
+
}
|
|
206
|
+
return transitions;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Render the graph pane. `selectedStepIndex` scrubs the replay position;
|
|
211
|
+
* pass `steps.length - 1` (or larger) for the live view.
|
|
212
|
+
*/
|
|
213
|
+
export function renderGraphLines(
|
|
214
|
+
view: GraphView,
|
|
215
|
+
selectedStepIndex: number,
|
|
216
|
+
now: Date = new Date(),
|
|
217
|
+
options: GraphRenderOptions = {},
|
|
218
|
+
): string[] {
|
|
219
|
+
const snapshot = view.snapshot;
|
|
220
|
+
if (!snapshot) {
|
|
221
|
+
return [];
|
|
222
|
+
}
|
|
223
|
+
const nodeStyle = options.nodeStyle ?? "line";
|
|
224
|
+
const layout = layoutGraph(snapshot);
|
|
225
|
+
const steps = view.state.steps;
|
|
226
|
+
const boundedIndex = Math.min(Math.max(selectedStepIndex, -1), steps.length - 1);
|
|
227
|
+
const atLatestStep = boundedIndex >= steps.length - 1;
|
|
228
|
+
const visibleSteps = steps.slice(0, boundedIndex + 1);
|
|
229
|
+
const transitions = takenTransitions(visibleSteps);
|
|
230
|
+
const activePair = derivePairInFlight(view, visibleSteps, atLatestStep);
|
|
231
|
+
|
|
232
|
+
const rendered = layout.ranks.map((rank) =>
|
|
233
|
+
rank.map((cell) => renderCellText(view, cell, visibleSteps, atLatestStep, now, nodeStyle)),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
// Column positions: pack cells left to right per rank, then center every
|
|
237
|
+
// rank against the widest one so vertical edges stay near-vertical.
|
|
238
|
+
const rankWidths = rendered.map(
|
|
239
|
+
(cells) =>
|
|
240
|
+
cells.reduce((sum, cell) => sum + cell.width, 0) + Math.max(0, cells.length - 1) * CELL_GAP,
|
|
241
|
+
);
|
|
242
|
+
const graphWidth = Math.max(0, ...rankWidths);
|
|
243
|
+
const geometry: RankGeometry[] = rendered.map((cells, rankIndex) => {
|
|
244
|
+
const centers: number[] = [];
|
|
245
|
+
let x = Math.floor((graphWidth - (rankWidths[rankIndex] ?? 0)) / 2);
|
|
246
|
+
for (const cell of cells) {
|
|
247
|
+
// Single-cell ranks share the exact graph center so chains render as
|
|
248
|
+
// straight vertical lines instead of one-column elbows.
|
|
249
|
+
centers.push(
|
|
250
|
+
cells.length === 1 ? Math.floor(graphWidth / 2) : x + Math.floor(cell.width / 2),
|
|
251
|
+
);
|
|
252
|
+
x += cell.width + CELL_GAP;
|
|
253
|
+
}
|
|
254
|
+
return { cells, centers };
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// Horizontal edge geometry (exit/entry columns, pixel-space track rows) is
|
|
258
|
+
// fully decided before vertical placement, so row budgeting is exact.
|
|
259
|
+
const strips = geometry.map((_rank, rankIndex) =>
|
|
260
|
+
computeStripGeometry(layout, rankIndex, geometry),
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const lanes = backEdgeLanes(layout);
|
|
264
|
+
const placed: PlacedRank[] = [];
|
|
265
|
+
// Entry lanes above the first rank need an arrow row of their own.
|
|
266
|
+
const topLanes = lanes.above(0).length;
|
|
267
|
+
let y = topLanes > 0 ? topLanes + 1 : 0;
|
|
268
|
+
for (const [rankIndex, rank] of geometry.entries()) {
|
|
269
|
+
placed.push({ ...rank, y });
|
|
270
|
+
y +=
|
|
271
|
+
cellHeight(nodeStyle) +
|
|
272
|
+
lanes.below(rankIndex).length +
|
|
273
|
+
gapRows(strips[rankIndex] as StripGeometry, rankIndex, layout.ranks.length) +
|
|
274
|
+
lanes.above(rankIndex + 1).length;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const canvas = new CharCanvas();
|
|
278
|
+
drawNodes(canvas, placed, layout, transitions, nodeStyle);
|
|
279
|
+
const labels = drawSegments(
|
|
280
|
+
canvas,
|
|
281
|
+
placed,
|
|
282
|
+
strips,
|
|
283
|
+
layout,
|
|
284
|
+
transitions,
|
|
285
|
+
activePair,
|
|
286
|
+
graphWidth,
|
|
287
|
+
nodeStyle,
|
|
288
|
+
lanes,
|
|
289
|
+
);
|
|
290
|
+
drawBackEdges(canvas, placed, layout, transitions, graphWidth, nodeStyle, lanes);
|
|
291
|
+
// Labels go on last, once every line is on the canvas: placement can then
|
|
292
|
+
// guarantee no later stroke crosses through a label.
|
|
293
|
+
for (const label of labels) {
|
|
294
|
+
drawSegmentLabel(canvas, label);
|
|
295
|
+
}
|
|
296
|
+
return canvas.render(paint);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Back edges route through dedicated lane rows: one below their source rank
|
|
301
|
+
* (box bottom to the right gutter) and one above their target rank (gutter
|
|
302
|
+
* to the target's top). Dedicated rows mean a loop line can never collide
|
|
303
|
+
* with node cells or other horizontal runs, no matter where the loop's
|
|
304
|
+
* endpoints sit in their ranks; forward edges merely cross them vertically.
|
|
305
|
+
*/
|
|
306
|
+
type BackEdgeLanes = {
|
|
307
|
+
edges: GraphEdge[];
|
|
308
|
+
below: (rank: number) => GraphEdge[];
|
|
309
|
+
above: (rank: number) => GraphEdge[];
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
function backEdgeLanes(layout: GraphLayout): BackEdgeLanes {
|
|
313
|
+
const edges = layout.edges.filter((edge) => edge.isBackEdge);
|
|
314
|
+
return {
|
|
315
|
+
edges,
|
|
316
|
+
below: (rank) => edges.filter((edge) => layout.rankOfNode.get(edge.from) === rank),
|
|
317
|
+
above: (rank) => edges.filter((edge) => layout.rankOfNode.get(edge.to) === rank),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** The transition currently in flight, drawn in the active style. */
|
|
322
|
+
function derivePairInFlight(
|
|
323
|
+
view: GraphView,
|
|
324
|
+
visibleSteps: WorkflowStepRecord[],
|
|
325
|
+
atLatestStep: boolean,
|
|
326
|
+
): string | null {
|
|
327
|
+
const state = view.state;
|
|
328
|
+
if (atLatestStep) {
|
|
329
|
+
if (state.status === "running" && state.currentNode && visibleSteps.length > 0) {
|
|
330
|
+
return `${visibleSteps.at(-1)?.nodeId}->${state.currentNode}`;
|
|
331
|
+
}
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
if (visibleSteps.length >= 2) {
|
|
335
|
+
return `${visibleSteps.at(-2)?.nodeId}->${visibleSteps.at(-1)?.nodeId}`;
|
|
336
|
+
}
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Rows between rank r's cell rows and rank r+1's cell rows. */
|
|
341
|
+
function gapRows(strip: StripGeometry, rank: number, rankCount: number): number {
|
|
342
|
+
if (strip.segments.length === 0) {
|
|
343
|
+
return rank < rankCount - 1 ? 1 : 0;
|
|
344
|
+
}
|
|
345
|
+
// Straight unlabeled strips need no track rows: one line row, one arrow row.
|
|
346
|
+
if (strip.straight) {
|
|
347
|
+
return 2;
|
|
348
|
+
}
|
|
349
|
+
// Labelled strips reserve one extra row below the tracks so labels that do
|
|
350
|
+
// not fit on their horizontal run always have a collision-free home.
|
|
351
|
+
return 2 + strip.trackCount + (strip.hasLabels ? 1 : 0);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Resolve a strip (all segments between rank r and rank r+1) to final pixel
|
|
356
|
+
* geometry: exit and entry columns, and a horizontal track row per segment.
|
|
357
|
+
*
|
|
358
|
+
* Two rules make the drawing collision-free by construction. First, when
|
|
359
|
+
* several edges leave or enter one cell, they fan out over separate columns
|
|
360
|
+
* (ordered by the far end so lines inside a fan never cross), so corner
|
|
361
|
+
* characters cannot merge into fake junctions. Second, tracks are assigned
|
|
362
|
+
* from the final pixel spans, so two horizontal runs share a row only when
|
|
363
|
+
* they cannot touch, corners included.
|
|
364
|
+
*/
|
|
365
|
+
function computeStripGeometry(
|
|
366
|
+
layout: GraphLayout,
|
|
367
|
+
rank: number,
|
|
368
|
+
geometry: RankGeometry[],
|
|
369
|
+
): StripGeometry {
|
|
370
|
+
const strip = layout.segments.filter((segment) => segment.rank === rank);
|
|
371
|
+
const top = geometry[rank];
|
|
372
|
+
const bottom = geometry[rank + 1];
|
|
373
|
+
if (strip.length === 0 || !top || !bottom) {
|
|
374
|
+
return { segments: [], trackCount: 1, hasLabels: false, straight: true };
|
|
375
|
+
}
|
|
376
|
+
const exitOffsets = fanOffsets(strip, "from", top, bottom);
|
|
377
|
+
const entryOffsets = fanOffsets(strip, "to", top, bottom);
|
|
378
|
+
const resolved = strip.map((segment) => {
|
|
379
|
+
const fromX =
|
|
380
|
+
(top.centers[segment.fromCell] as number) + (exitOffsets.get(segment.edgeId) ?? 0);
|
|
381
|
+
let toX = (bottom.centers[segment.toCell] as number) + (entryOffsets.get(segment.edgeId) ?? 0);
|
|
382
|
+
const targetIsNode = (bottom.cells[segment.toCell] as RenderedCell).cell.kind === "node";
|
|
383
|
+
// A one-column jog reads as noise; draw it straight into the target,
|
|
384
|
+
// whose rendered cell is wide enough to absorb the offset. Virtual
|
|
385
|
+
// cells are exactly one column wide, so they must never be snapped.
|
|
386
|
+
if (targetIsNode && Math.abs(toX - fromX) <= 1) {
|
|
387
|
+
toX = fromX;
|
|
388
|
+
}
|
|
389
|
+
return { edgeId: segment.edgeId, label: segment.label, fromX, toX, targetIsNode };
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// First-fit track assignment over pixel spans; straight unlabeled
|
|
393
|
+
// segments draw a plain vertical line and need no track row.
|
|
394
|
+
const segments: GeomSegment[] = [];
|
|
395
|
+
const trackRanges: [number, number][][] = [];
|
|
396
|
+
for (const segment of resolved.toSorted((a, b) => a.fromX - b.fromX)) {
|
|
397
|
+
let track = 0;
|
|
398
|
+
if (segment.fromX !== segment.toX || segment.label !== undefined) {
|
|
399
|
+
const span: [number, number] = [
|
|
400
|
+
Math.min(segment.fromX, segment.toX),
|
|
401
|
+
Math.max(segment.fromX, segment.toX),
|
|
402
|
+
];
|
|
403
|
+
track = trackRanges.findIndex((ranges) =>
|
|
404
|
+
ranges.every(([start, end]) => span[1] < start || span[0] > end),
|
|
405
|
+
);
|
|
406
|
+
if (track === -1) {
|
|
407
|
+
track = trackRanges.length;
|
|
408
|
+
trackRanges.push([]);
|
|
409
|
+
}
|
|
410
|
+
(trackRanges[track] as [number, number][]).push(span);
|
|
411
|
+
}
|
|
412
|
+
segments.push({ ...segment, track });
|
|
413
|
+
}
|
|
414
|
+
return {
|
|
415
|
+
segments,
|
|
416
|
+
trackCount: Math.max(1, trackRanges.length),
|
|
417
|
+
hasLabels: segments.some((segment) => segment.label !== undefined),
|
|
418
|
+
straight: segments.every(
|
|
419
|
+
(segment) => segment.fromX === segment.toX && segment.label === undefined,
|
|
420
|
+
),
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Fan columns for edges sharing a cell: segment i (ordered by the far
|
|
426
|
+
* end's x) gets column center - 2*(n-1-i), clamped to the cell, never
|
|
427
|
+
* right of center. Forward fans stay at or left of center while back-edge
|
|
428
|
+
* anchors sit right of center, so the two can never collide.
|
|
429
|
+
*/
|
|
430
|
+
function fanOffsets(
|
|
431
|
+
strip: GraphSegment[],
|
|
432
|
+
side: "from" | "to",
|
|
433
|
+
top: RankGeometry,
|
|
434
|
+
bottom: RankGeometry,
|
|
435
|
+
): Map<string, number> {
|
|
436
|
+
const [ownRank, ownCell, farRank, farCell] =
|
|
437
|
+
side === "from"
|
|
438
|
+
? ([top, (s: GraphSegment) => s.fromCell, bottom, (s: GraphSegment) => s.toCell] as const)
|
|
439
|
+
: ([bottom, (s: GraphSegment) => s.toCell, top, (s: GraphSegment) => s.fromCell] as const);
|
|
440
|
+
const offsets = new Map<string, number>();
|
|
441
|
+
const groups = new Map<number, GraphSegment[]>();
|
|
442
|
+
for (const segment of strip) {
|
|
443
|
+
// Virtual cells are one column wide and always have one edge per side.
|
|
444
|
+
if ((ownRank.cells[ownCell(segment)] as RenderedCell).cell.kind === "node") {
|
|
445
|
+
groups.set(ownCell(segment), [...(groups.get(ownCell(segment)) ?? []), segment]);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
for (const [cellIndex, group] of groups) {
|
|
449
|
+
if (group.length < 2) {
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
const cell = ownRank.cells[cellIndex] as RenderedCell;
|
|
453
|
+
const maxOffset = Math.max(1, Math.floor(cell.width / 2) - 1);
|
|
454
|
+
const ordered = group.toSorted(
|
|
455
|
+
(a, b) => (farRank.centers[farCell(a)] as number) - (farRank.centers[farCell(b)] as number),
|
|
456
|
+
);
|
|
457
|
+
for (const [index, segment] of ordered.entries()) {
|
|
458
|
+
const offset = -2 * (ordered.length - 1 - index);
|
|
459
|
+
offsets.set(segment.edgeId, Math.max(-maxOffset, offset));
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return offsets;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const BOX_CHARS = {
|
|
466
|
+
light: { tl: "┌", tr: "┐", bl: "└", br: "┘", h: "─", v: "│" },
|
|
467
|
+
heavy: { tl: "┏", tr: "┓", bl: "┗", br: "┛", h: "━", v: "┃" },
|
|
468
|
+
} as const;
|
|
469
|
+
|
|
470
|
+
function drawNodes(
|
|
471
|
+
canvas: CharCanvas,
|
|
472
|
+
placed: PlacedRank[],
|
|
473
|
+
layout: GraphLayout,
|
|
474
|
+
transitions: Set<string>,
|
|
475
|
+
nodeStyle: GraphNodeStyle,
|
|
476
|
+
): void {
|
|
477
|
+
for (const rank of placed) {
|
|
478
|
+
for (const [index, rendered] of rank.cells.entries()) {
|
|
479
|
+
const center = rank.centers[index] as number;
|
|
480
|
+
const cell = rendered.cell;
|
|
481
|
+
if (cell.kind === "virtual") {
|
|
482
|
+
const edge = layout.edges.find((candidate) => candidate.edgeId === cell.edgeId);
|
|
483
|
+
const taken = edge ? transitions.has(`${edge.from}->${edge.to}`) : false;
|
|
484
|
+
// Pass-through cells span the full cell height so the edge stays
|
|
485
|
+
// visually continuous across the rank row(s).
|
|
486
|
+
canvas.vline(center, rank.y, rank.y + cellHeight(nodeStyle) - 1, taken ? "taken" : "dim");
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
const status = rendered.status ?? "queued";
|
|
490
|
+
const startX = center - Math.floor(rendered.width / 2);
|
|
491
|
+
if (nodeStyle === "box") {
|
|
492
|
+
drawNodeBox(canvas, startX, rank.y, rendered, status);
|
|
493
|
+
} else {
|
|
494
|
+
canvas.put(startX, rank.y, STATUS_GLYPHS[status], STATUS_STYLES[status]);
|
|
495
|
+
canvas.text(startX + 2, rank.y, rendered.text, status === "queued" ? "dim" : "plain");
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* A bordered node cell. Edge geometry keeps lines outside the border rows,
|
|
503
|
+
* so borders stay unbroken; the active node gets a heavy border so the
|
|
504
|
+
* current position stands out.
|
|
505
|
+
*/
|
|
506
|
+
function drawNodeBox(
|
|
507
|
+
canvas: CharCanvas,
|
|
508
|
+
startX: number,
|
|
509
|
+
y: number,
|
|
510
|
+
rendered: RenderedCell,
|
|
511
|
+
status: NodeStatus,
|
|
512
|
+
): void {
|
|
513
|
+
const chars = status === "active" ? BOX_CHARS.heavy : BOX_CHARS.light;
|
|
514
|
+
const style = STATUS_STYLES[status];
|
|
515
|
+
const innerWidth = rendered.width - 2;
|
|
516
|
+
canvas.text(startX, y, `${chars.tl}${chars.h.repeat(innerWidth)}${chars.tr}`, style);
|
|
517
|
+
canvas.text(startX, y + 1, chars.v, style);
|
|
518
|
+
canvas.put(startX + 2, y + 1, STATUS_GLYPHS[status], style);
|
|
519
|
+
canvas.text(startX + 4, y + 1, rendered.text, status === "queued" ? "dim" : "plain");
|
|
520
|
+
canvas.text(startX + rendered.width - 1, y + 1, chars.v, style);
|
|
521
|
+
canvas.text(startX, y + 2, `${chars.bl}${chars.h.repeat(innerWidth)}${chars.br}`, style);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function edgeStyle(
|
|
525
|
+
pairKey: string,
|
|
526
|
+
transitions: Set<string>,
|
|
527
|
+
activePair: string | null,
|
|
528
|
+
): CanvasStyle {
|
|
529
|
+
if (activePair === pairKey) {
|
|
530
|
+
return "active";
|
|
531
|
+
}
|
|
532
|
+
if (transitions.has(pairKey)) {
|
|
533
|
+
return "taken";
|
|
534
|
+
}
|
|
535
|
+
return "dim";
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function drawSegments(
|
|
539
|
+
canvas: CharCanvas,
|
|
540
|
+
placed: PlacedRank[],
|
|
541
|
+
strips: StripGeometry[],
|
|
542
|
+
layout: GraphLayout,
|
|
543
|
+
transitions: Set<string>,
|
|
544
|
+
activePair: string | null,
|
|
545
|
+
graphWidth: number,
|
|
546
|
+
nodeStyle: GraphNodeStyle,
|
|
547
|
+
lanes: BackEdgeLanes,
|
|
548
|
+
): PendingLabel[] {
|
|
549
|
+
const labels: PendingLabel[] = [];
|
|
550
|
+
for (let rank = 0; rank < placed.length - 1; rank += 1) {
|
|
551
|
+
const strip = strips[rank] as StripGeometry;
|
|
552
|
+
if (strip.segments.length === 0) {
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
const top = placed[rank] as PlacedRank;
|
|
556
|
+
const bottom = placed[rank + 1] as PlacedRank;
|
|
557
|
+
// Forward lines start right below the source cell, cross any back-edge
|
|
558
|
+
// lane rows (as ┼ crossings), run their strip tracks, then cross the
|
|
559
|
+
// entry lanes to the arrow row directly above the target cell.
|
|
560
|
+
const stubTop = top.y + cellHeight(nodeStyle);
|
|
561
|
+
const stripTop = stubTop + lanes.below(rank).length;
|
|
562
|
+
const arrowY = bottom.y - 1;
|
|
563
|
+
const stripBottom = arrowY - 1 - lanes.above(rank + 1).length;
|
|
564
|
+
for (const segment of strip.segments) {
|
|
565
|
+
const edge = layout.edges.find((candidate) => candidate.edgeId === segment.edgeId);
|
|
566
|
+
if (!edge) {
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
const style = edgeStyle(`${edge.from}->${edge.to}`, transitions, activePair);
|
|
570
|
+
const { fromX, toX } = segment;
|
|
571
|
+
const trackY = stripTop + segment.track;
|
|
572
|
+
if (fromX === toX) {
|
|
573
|
+
canvas.vline(fromX, stubTop, arrowY, style);
|
|
574
|
+
} else {
|
|
575
|
+
if (trackY > stubTop) {
|
|
576
|
+
canvas.vline(fromX, stubTop, trackY - 1, style);
|
|
577
|
+
}
|
|
578
|
+
canvas.put(fromX, trackY, toX > fromX ? "└" : "┘", style);
|
|
579
|
+
canvas.hline(trackY, Math.min(fromX, toX) + 1, Math.max(fromX, toX) - 1, style);
|
|
580
|
+
canvas.put(toX, trackY, toX > fromX ? "┐" : "┌", style);
|
|
581
|
+
if (arrowY > trackY) {
|
|
582
|
+
canvas.vline(toX, trackY + 1, arrowY, style);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
if (segment.targetIsNode) {
|
|
586
|
+
canvas.put(toX, arrowY, "▼", style);
|
|
587
|
+
}
|
|
588
|
+
if (segment.label !== undefined) {
|
|
589
|
+
labels.push({
|
|
590
|
+
text: segment.label,
|
|
591
|
+
style,
|
|
592
|
+
fromX,
|
|
593
|
+
toX,
|
|
594
|
+
trackY,
|
|
595
|
+
labelRow: Math.min(stripTop + strip.trackCount, stripBottom),
|
|
596
|
+
graphWidth,
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return labels;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
type PendingLabel = {
|
|
605
|
+
text: string;
|
|
606
|
+
style: CanvasStyle;
|
|
607
|
+
fromX: number;
|
|
608
|
+
toX: number;
|
|
609
|
+
trackY: number;
|
|
610
|
+
labelRow: number;
|
|
611
|
+
graphWidth: number;
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Place a branch label. Labels are drawn after every line is on the canvas,
|
|
616
|
+
* so a spot that is free now stays free: first try writing over the
|
|
617
|
+
* segment's own horizontal run (only plain `─` cells may be replaced), then
|
|
618
|
+
* the strip's reserved label row beside the descending line, trying the
|
|
619
|
+
* side facing the graph center first.
|
|
620
|
+
*/
|
|
621
|
+
function drawSegmentLabel(canvas: CharCanvas, label: PendingLabel): void {
|
|
622
|
+
const { text, style, fromX, toX, trackY, labelRow, graphWidth } = label;
|
|
623
|
+
const padded = ` ${text} `;
|
|
624
|
+
if (fromX !== toX) {
|
|
625
|
+
const runStart = Math.min(fromX, toX) + 1;
|
|
626
|
+
const runEnd = Math.max(fromX, toX) - 1;
|
|
627
|
+
const center = Math.floor((runStart + runEnd) / 2) - Math.floor(padded.length / 2);
|
|
628
|
+
if (
|
|
629
|
+
runEnd - runStart + 1 >= padded.length + 2 &&
|
|
630
|
+
canvas.textOverRun(center, trackY, padded, style)
|
|
631
|
+
) {
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
const candidates: [number, number][] =
|
|
636
|
+
toX >= Math.floor(graphWidth / 2)
|
|
637
|
+
? [
|
|
638
|
+
[toX - text.length - 1, labelRow],
|
|
639
|
+
[toX + 2, labelRow],
|
|
640
|
+
]
|
|
641
|
+
: [
|
|
642
|
+
[toX + 2, labelRow],
|
|
643
|
+
[toX - text.length - 1, labelRow],
|
|
644
|
+
];
|
|
645
|
+
for (const [x, y] of candidates) {
|
|
646
|
+
if (canvas.textIfEmpty(x, y, text, style)) {
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
// Last resort: beside the source corner on the track row.
|
|
651
|
+
canvas.textIfEmpty(fromX + 2, trackY, text, style);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Each back edge leaves its source cell downward into its own lane row,
|
|
656
|
+
* runs right to a private gutter column, climbs the gutter, and re-enters
|
|
657
|
+
* through its target's entry lane and arrow row from above. Every lane row
|
|
658
|
+
* and gutter column is exclusive to one edge, so loop lines can only ever
|
|
659
|
+
* cross other lines (merging into ┼), never run along them.
|
|
660
|
+
*/
|
|
661
|
+
function drawBackEdges(
|
|
662
|
+
canvas: CharCanvas,
|
|
663
|
+
placed: PlacedRank[],
|
|
664
|
+
layout: GraphLayout,
|
|
665
|
+
transitions: Set<string>,
|
|
666
|
+
graphWidth: number,
|
|
667
|
+
nodeStyle: GraphNodeStyle,
|
|
668
|
+
lanes: BackEdgeLanes,
|
|
669
|
+
): void {
|
|
670
|
+
let gutterX = graphWidth + GUTTER_GAP;
|
|
671
|
+
for (const edge of lanes.edges) {
|
|
672
|
+
const fromRank = layout.rankOfNode.get(edge.from);
|
|
673
|
+
const toRank = layout.rankOfNode.get(edge.to);
|
|
674
|
+
if (fromRank === undefined || toRank === undefined) {
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
const from = placed[fromRank] as PlacedRank;
|
|
678
|
+
const to = placed[toRank] as PlacedRank;
|
|
679
|
+
const exit = cellAnchor(from, edge.from, lanes.below(fromRank), edge);
|
|
680
|
+
const entry = cellAnchor(to, edge.to, lanes.above(toRank), edge);
|
|
681
|
+
if (!exit || !entry) {
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
const style: CanvasStyle = transitions.has(`${edge.from}->${edge.to}`) ? "taken" : "back";
|
|
685
|
+
const exitLaneY = from.y + cellHeight(nodeStyle) + exit.lane;
|
|
686
|
+
const aboveCount = lanes.above(toRank).length;
|
|
687
|
+
const arrowY = to.y - 1;
|
|
688
|
+
const entryLaneY = arrowY - aboveCount + entry.lane;
|
|
689
|
+
|
|
690
|
+
// Downward stub out of the source cell, then right along the exit lane.
|
|
691
|
+
if (exitLaneY > from.y + cellHeight(nodeStyle)) {
|
|
692
|
+
canvas.vline(exit.x, from.y + cellHeight(nodeStyle), exitLaneY - 1, style);
|
|
693
|
+
}
|
|
694
|
+
canvas.put(exit.x, exitLaneY, "└", style);
|
|
695
|
+
canvas.hline(exitLaneY, exit.x + 1, gutterX - 1, style);
|
|
696
|
+
canvas.put(gutterX, exitLaneY, "┘", style);
|
|
697
|
+
// Up the gutter, then left along the entry lane into the target.
|
|
698
|
+
canvas.put(gutterX, entryLaneY, "┐", style);
|
|
699
|
+
if (exitLaneY - entryLaneY > 1) {
|
|
700
|
+
canvas.vline(gutterX, entryLaneY + 1, exitLaneY - 1, style);
|
|
701
|
+
}
|
|
702
|
+
canvas.hline(entryLaneY, entry.x + 1, gutterX - 1, style);
|
|
703
|
+
canvas.put(entry.x, entryLaneY, "┌", style);
|
|
704
|
+
if (arrowY - entryLaneY > 1) {
|
|
705
|
+
canvas.vline(entry.x, entryLaneY + 1, arrowY - 1, style);
|
|
706
|
+
}
|
|
707
|
+
canvas.put(entry.x, arrowY, "▼", style);
|
|
708
|
+
if (edge.label !== undefined) {
|
|
709
|
+
canvas.text(gutterX + 2, entryLaneY, edge.label, style);
|
|
710
|
+
}
|
|
711
|
+
// Reserve horizontal room for this gutter and its label before the next.
|
|
712
|
+
gutterX += 2 + (edge.label === undefined ? 0 : edge.label.length + 1);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Where a back edge touches a node cell: offset right of center so the
|
|
718
|
+
* stub can never collide with forward-edge lines at the center column,
|
|
719
|
+
* clamped inside the cell.
|
|
720
|
+
*/
|
|
721
|
+
function cellAnchor(
|
|
722
|
+
rank: PlacedRank,
|
|
723
|
+
nodeId: string,
|
|
724
|
+
laneEdges: GraphEdge[],
|
|
725
|
+
edge: GraphEdge,
|
|
726
|
+
): { x: number; lane: number } | null {
|
|
727
|
+
const index = rank.cells.findIndex(
|
|
728
|
+
(cell) => cell.cell.kind === "node" && cell.cell.nodeId === nodeId,
|
|
729
|
+
);
|
|
730
|
+
const lane = laneEdges.findIndex((candidate) => candidate.edgeId === edge.edgeId);
|
|
731
|
+
if (index === -1 || lane === -1) {
|
|
732
|
+
return null;
|
|
733
|
+
}
|
|
734
|
+
const cell = rank.cells[index] as RenderedCell;
|
|
735
|
+
const center = rank.centers[index] as number;
|
|
736
|
+
const rightmost = center + Math.floor(cell.width / 2) - 1;
|
|
737
|
+
return { x: Math.min(center + 2 + lane * 2, rightmost), lane };
|
|
738
|
+
}
|