paseo-plugin-chat-view 0.0.0-stage → 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/LICENSE.compact-agent-activity +21 -0
- package/LICENSE.mermaid +21 -0
- package/README.md +428 -2
- package/README_zh.md +429 -0
- package/client/activity/Activity.test.ts +220 -0
- package/client/activity/Activity.tsx +784 -0
- package/client/activity/detail/detail.test.ts +102 -0
- package/client/activity/detail/dispatch.tsx +94 -0
- package/client/activity/detail/file.tsx +93 -0
- package/client/activity/detail/index.tsx +39 -0
- package/client/activity/detail/parts.tsx +221 -0
- package/client/activity/detail/search.tsx +59 -0
- package/client/activity/detail/terminal.tsx +53 -0
- package/client/activity/detail/texts.tsx +42 -0
- package/client/activity/disclosure.ts +49 -0
- package/client/activity/dom.test.ts +52 -0
- package/client/activity/dom.ts +138 -0
- package/client/activity/group-store.test.ts +126 -0
- package/client/activity/group-store.ts +265 -0
- package/client/activity/highlight.test.ts +116 -0
- package/client/activity/highlight.ts +418 -0
- package/client/activity/history.test.ts +602 -0
- package/client/activity/history.ts +239 -0
- package/client/activity/index.ts +74 -0
- package/client/activity/latest-marker.test.ts +41 -0
- package/client/activity/latest-marker.ts +70 -0
- package/client/activity/styles.ts +535 -0
- package/client/activity/tools/child-agent.tsx +308 -0
- package/client/activity/tools/exa.tsx +99 -0
- package/client/activity/tools/github.tsx +501 -0
- package/client/activity/tools/list.tsx +102 -0
- package/client/activity/tools/lists.test.ts +193 -0
- package/client/activity/tools/paseo/agent.tsx +384 -0
- package/client/activity/tools/paseo/browser.tsx +322 -0
- package/client/activity/tools/paseo/index.tsx +79 -0
- package/client/activity/tools/paseo/parts.tsx +133 -0
- package/client/activity/tools/paseo/provider.tsx +220 -0
- package/client/activity/tools/paseo/schedule.tsx +246 -0
- package/client/activity/tools/paseo/speak.tsx +20 -0
- package/client/activity/tools/paseo/terminal.tsx +214 -0
- package/client/activity/tools/paseo/workspace.tsx +162 -0
- package/client/activity/tools/shared.tsx +431 -0
- package/client/activity/transform.test.ts +28 -0
- package/client/activity/transform.ts +43 -0
- package/client/agent-pills.ts +56 -0
- package/client/dom.ts +75 -0
- package/client/enhance/controller.ts +111 -0
- package/client/enhance/dom.test.ts +28 -0
- package/client/enhance/dom.ts +101 -0
- package/client/enhance/index.ts +95 -0
- package/client/enhance/pill.tsx +19 -0
- package/client/enhance/state.ts +58 -0
- package/client/feature.ts +22 -0
- package/client/features.test.ts +149 -0
- package/client/features.ts +83 -0
- package/client/locale.ts +45 -0
- package/client/mermaid/diagram.tsx +438 -0
- package/client/mermaid/index.ts +27 -0
- package/client/mermaid/item.tsx +28 -0
- package/client/mermaid/markdown.tsx +217 -0
- package/client/mermaid/registration.ts +32 -0
- package/client/mermaid/toolbar.tsx +175 -0
- package/client/mermaid/viewer.tsx +314 -0
- package/client/readonly/dom.test.ts +66 -0
- package/client/readonly/dom.ts +32 -0
- package/client/readonly/index.ts +27 -0
- package/client/registration.test.ts +243 -0
- package/client/settings.tsx +438 -0
- package/client/sticky/StickyBar.tsx +144 -0
- package/client/sticky/dom.test.ts +230 -0
- package/client/sticky/dom.ts +517 -0
- package/client/sticky/index.ts +38 -0
- package/client/sticky/pill.tsx +70 -0
- package/client/timeline-pager.test.ts +121 -0
- package/client/timeline-pager.ts +123 -0
- package/index.client.tsx +44 -0
- package/index.server.ts +12 -0
- package/package.json +42 -4
- package/paseo-plugin.json +6 -0
- package/server/enhance/index.ts +63 -0
- package/server/enhance/manifest.ts +176 -0
- package/server/enhance/probe.test.ts +215 -0
- package/server/enhance/probe.ts +118 -0
- package/server/enhance/provider.ts +149 -0
- package/shared/activity/child-agent.test.ts +27 -0
- package/shared/activity/child-agent.ts +26 -0
- package/shared/activity/diff.test.ts +74 -0
- package/shared/activity/diff.ts +207 -0
- package/shared/activity/exa.test.ts +66 -0
- package/shared/activity/exa.ts +183 -0
- package/shared/activity/file-kind.test.ts +13 -0
- package/shared/activity/file-kind.ts +112 -0
- package/shared/activity/github.test.ts +56 -0
- package/shared/activity/github.ts +174 -0
- package/shared/activity/palette.test.ts +26 -0
- package/shared/activity/palette.ts +106 -0
- package/shared/activity/parse.test.ts +39 -0
- package/shared/activity/parse.ts +115 -0
- package/shared/activity/paseo-tools.test.ts +163 -0
- package/shared/activity/paseo-tools.ts +497 -0
- package/shared/activity/text.test.ts +85 -0
- package/shared/activity/text.ts +202 -0
- package/shared/activity/timeline.test.ts +26 -0
- package/shared/activity/timeline.ts +108 -0
- package/shared/activity/tool-presentation.test.ts +154 -0
- package/shared/activity/tool-presentation.ts +352 -0
- package/shared/enhance/contract.ts +105 -0
- package/shared/enhance/decide.ts +97 -0
- package/shared/enhance/enhance.test.ts +188 -0
- package/shared/enhance/failure.ts +40 -0
- package/shared/enhance/index.ts +8 -0
- package/shared/enhance/normalize.ts +16 -0
- package/shared/enhance/parse.ts +79 -0
- package/shared/enhance/policy.ts +48 -0
- package/shared/enhance/prompt.ts +134 -0
- package/shared/enhance/run.ts +84 -0
- package/shared/i18n.test.ts +81 -0
- package/shared/i18n.ts +300 -0
- package/shared/mermaid/flowchart.test.ts +169 -0
- package/shared/mermaid/flowchart.ts +271 -0
- package/shared/mermaid/layout.test.ts +305 -0
- package/shared/mermaid/layout.ts +496 -0
- package/shared/mermaid/parse.test.ts +110 -0
- package/shared/mermaid/parse.ts +193 -0
- package/shared/mermaid/segment.test.ts +55 -0
- package/shared/mermaid/segment.ts +95 -0
- package/shared/mermaid/sequence.test.ts +68 -0
- package/shared/mermaid/sequence.ts +130 -0
- package/shared/mermaid/transform.test.ts +52 -0
- package/shared/mermaid/transform.ts +45 -0
- package/shared/mermaid/zoom.test.ts +134 -0
- package/shared/mermaid/zoom.ts +114 -0
- package/shared/settings.ts +41 -0
- package/shared/sticky/history.test.ts +91 -0
- package/shared/sticky/history.ts +86 -0
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
import type { Direction, Flowchart, FlowEdge, FlowNode, FlowSubgraph } from "./flowchart";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Layered layout for a flowchart, computed here rather than in the renderer so
|
|
5
|
+
* the geometry can be tested without mounting anything. React Native has no SVG,
|
|
6
|
+
* so the renderer places plain Views at these coordinates and draws edges as
|
|
7
|
+
* orthogonal segments.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface LaidOutNode extends FlowNode {
|
|
11
|
+
layer: number;
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Segment {
|
|
19
|
+
x: number;
|
|
20
|
+
y: number;
|
|
21
|
+
width: number;
|
|
22
|
+
height: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface LaidOutEdge extends FlowEdge {
|
|
26
|
+
segments: Segment[];
|
|
27
|
+
/** Where the arrowhead sits and which way it points. */
|
|
28
|
+
arrowAt: { x: number; y: number; direction: "up" | "down" | "left" | "right" } | null;
|
|
29
|
+
labelAt: { x: number; y: number } | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface LaidOutSubgraph extends FlowSubgraph {
|
|
33
|
+
x: number;
|
|
34
|
+
y: number;
|
|
35
|
+
width: number;
|
|
36
|
+
height: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface FlowLayout {
|
|
40
|
+
direction: Direction;
|
|
41
|
+
nodes: LaidOutNode[];
|
|
42
|
+
edges: LaidOutEdge[];
|
|
43
|
+
/** Cluster boxes behind the nodes, in the same coordinates. */
|
|
44
|
+
subgraphs: LaidOutSubgraph[];
|
|
45
|
+
width: number;
|
|
46
|
+
height: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const CHAR_WIDTH = 7.1;
|
|
50
|
+
const LINE_HEIGHT = 17;
|
|
51
|
+
const PADDING_X = 14;
|
|
52
|
+
const PADDING_Y = 10;
|
|
53
|
+
const MIN_WIDTH = 56;
|
|
54
|
+
const LAYER_GAP = 56;
|
|
55
|
+
const NODE_GAP = 22;
|
|
56
|
+
const LINE = 1.5;
|
|
57
|
+
/** Breadth reserved for each lane an edge that skips a layer runs through. */
|
|
58
|
+
const LANE_GAP = 26;
|
|
59
|
+
/** Air between a cluster box and the nodes it wraps, and under its title. */
|
|
60
|
+
const SUBGRAPH_PAD = 12;
|
|
61
|
+
const SUBGRAPH_TITLE = 18;
|
|
62
|
+
|
|
63
|
+
export function measureNode(node: FlowNode): { width: number; height: number } {
|
|
64
|
+
const lines = node.label.split("\n");
|
|
65
|
+
const longest = Math.max(...lines.map((line) => line.length), 1);
|
|
66
|
+
const width = Math.max(MIN_WIDTH, Math.round(longest * CHAR_WIDTH) + PADDING_X * 2);
|
|
67
|
+
const height = lines.length * LINE_HEIGHT + PADDING_Y * 2;
|
|
68
|
+
// A diamond is a square rotated 45 degrees, so its box has to be square too:
|
|
69
|
+
// the rendered corners then touch the box edges exactly, and an edge routed to
|
|
70
|
+
// the box lands on the shape instead of stopping short of it.
|
|
71
|
+
if (node.shape === "diamond") {
|
|
72
|
+
const side = Math.round(Math.max(width, height) * Math.SQRT2 * 0.78);
|
|
73
|
+
return { width: side, height: side };
|
|
74
|
+
}
|
|
75
|
+
if (node.shape === "circle") {
|
|
76
|
+
const side = Math.max(width, height);
|
|
77
|
+
return { width: side, height: side };
|
|
78
|
+
}
|
|
79
|
+
return { width, height };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Longest-path layering. Back edges are found first and left out of the ranking,
|
|
84
|
+
* so a cycle produces a readable graph instead of an infinite descent.
|
|
85
|
+
*/
|
|
86
|
+
export function assignLayers(nodes: FlowNode[], edges: FlowEdge[]): Map<string, number> {
|
|
87
|
+
const ids = new Set(nodes.map((node) => node.id));
|
|
88
|
+
const outgoing = new Map<string, string[]>();
|
|
89
|
+
for (const edge of edges) {
|
|
90
|
+
if (!ids.has(edge.from) || !ids.has(edge.to)) continue;
|
|
91
|
+
outgoing.set(edge.from, [...(outgoing.get(edge.from) ?? []), edge.to]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const back = new Set<FlowEdge>();
|
|
95
|
+
const state = new Map<string, "open" | "done">();
|
|
96
|
+
const walk = (id: string) => {
|
|
97
|
+
state.set(id, "open");
|
|
98
|
+
for (const next of outgoing.get(id) ?? []) {
|
|
99
|
+
const seen = state.get(next);
|
|
100
|
+
if (seen === "open") {
|
|
101
|
+
for (const edge of edges) if (edge.from === id && edge.to === next) back.add(edge);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (seen === undefined) walk(next);
|
|
105
|
+
}
|
|
106
|
+
state.set(id, "done");
|
|
107
|
+
};
|
|
108
|
+
for (const node of nodes) if (!state.has(node.id)) walk(node.id);
|
|
109
|
+
|
|
110
|
+
const forward = edges.filter((edge) => !back.has(edge) && ids.has(edge.from) && ids.has(edge.to));
|
|
111
|
+
const layer = new Map<string, number>(nodes.map((node) => [node.id, 0]));
|
|
112
|
+
|
|
113
|
+
// Relax until stable; the graph is a DAG now, so this settles in at most |V| rounds.
|
|
114
|
+
for (let round = 0; round < nodes.length; round += 1) {
|
|
115
|
+
let moved = false;
|
|
116
|
+
for (const edge of forward) {
|
|
117
|
+
const candidate = (layer.get(edge.from) ?? 0) + 1;
|
|
118
|
+
if (candidate > (layer.get(edge.to) ?? 0)) {
|
|
119
|
+
layer.set(edge.to, candidate);
|
|
120
|
+
moved = true;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (!moved) break;
|
|
124
|
+
}
|
|
125
|
+
return layer;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** One barycentre pass: each node drifts toward the average position of its parents. */
|
|
129
|
+
export function orderLayers(
|
|
130
|
+
nodes: FlowNode[],
|
|
131
|
+
edges: FlowEdge[],
|
|
132
|
+
layers: Map<string, number>,
|
|
133
|
+
): string[][] {
|
|
134
|
+
const depth = Math.max(...layers.values(), 0) + 1;
|
|
135
|
+
const rows: string[][] = Array.from({ length: depth }, () => []);
|
|
136
|
+
for (const node of nodes) rows[layers.get(node.id) ?? 0].push(node.id);
|
|
137
|
+
|
|
138
|
+
for (let index = 1; index < rows.length; index += 1) {
|
|
139
|
+
const previous = new Map(rows[index - 1].map((id, position) => [id, position]));
|
|
140
|
+
const score = new Map<string, number>();
|
|
141
|
+
for (const id of rows[index]) {
|
|
142
|
+
const parents = edges
|
|
143
|
+
.filter((edge) => edge.to === id && previous.has(edge.from))
|
|
144
|
+
.map((edge) => previous.get(edge.from) as number);
|
|
145
|
+
score.set(
|
|
146
|
+
id,
|
|
147
|
+
parents.length > 0
|
|
148
|
+
? parents.reduce((a, b) => a + b, 0) / parents.length
|
|
149
|
+
: Number.MAX_SAFE_INTEGER,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
const original = new Map(rows[index].map((id, position) => [id, position]));
|
|
153
|
+
rows[index] = [...rows[index]].sort(
|
|
154
|
+
(left, right) =>
|
|
155
|
+
(score.get(left) as number) - (score.get(right) as number) ||
|
|
156
|
+
(original.get(left) as number) - (original.get(right) as number),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
return rows;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const horizontal = (direction: Direction) => direction === "LR" || direction === "RL";
|
|
163
|
+
|
|
164
|
+
export function layoutFlowchart(chart: Flowchart): FlowLayout {
|
|
165
|
+
const layers = assignLayers(chart.nodes, chart.edges);
|
|
166
|
+
const rows = orderLayers(chart.nodes, chart.edges, layers);
|
|
167
|
+
const sizes = new Map(chart.nodes.map((node) => [node.id, measureNode(node)]));
|
|
168
|
+
const byId = new Map(chart.nodes.map((node) => [node.id, node]));
|
|
169
|
+
const across = horizontal(chart.direction);
|
|
170
|
+
|
|
171
|
+
// "Depth" runs along the flow, "breadth" across it; transposing at the end is
|
|
172
|
+
// what makes LR fall out of the same maths as TD.
|
|
173
|
+
const depthOffsets: number[] = [];
|
|
174
|
+
let depthCursor = 0;
|
|
175
|
+
const rowBreadth = rows.map((row) => {
|
|
176
|
+
const total = row.reduce((sum, id) => {
|
|
177
|
+
const size = sizes.get(id) as { width: number; height: number };
|
|
178
|
+
return sum + (across ? size.height : size.width);
|
|
179
|
+
}, 0);
|
|
180
|
+
return total + Math.max(0, row.length - 1) * NODE_GAP;
|
|
181
|
+
});
|
|
182
|
+
const widestRow = Math.max(...rowBreadth, 0);
|
|
183
|
+
|
|
184
|
+
for (const row of rows) {
|
|
185
|
+
depthOffsets.push(depthCursor);
|
|
186
|
+
const deepest = Math.max(
|
|
187
|
+
...row.map((id) => {
|
|
188
|
+
const size = sizes.get(id) as { width: number; height: number };
|
|
189
|
+
return across ? size.width : size.height;
|
|
190
|
+
}),
|
|
191
|
+
0,
|
|
192
|
+
);
|
|
193
|
+
depthCursor += deepest + LAYER_GAP;
|
|
194
|
+
}
|
|
195
|
+
const totalDepth = Math.max(0, depthCursor - LAYER_GAP);
|
|
196
|
+
|
|
197
|
+
const placed = new Map<string, LaidOutNode>();
|
|
198
|
+
rows.forEach((row, rowIndex) => {
|
|
199
|
+
let breadthCursor = (widestRow - rowBreadth[rowIndex]) / 2;
|
|
200
|
+
for (const id of row) {
|
|
201
|
+
const size = sizes.get(id) as { width: number; height: number };
|
|
202
|
+
const node = byId.get(id) as FlowNode;
|
|
203
|
+
const breadthSpan = across ? size.height : size.width;
|
|
204
|
+
const depthStart = depthOffsets[rowIndex];
|
|
205
|
+
|
|
206
|
+
placed.set(id, {
|
|
207
|
+
...node,
|
|
208
|
+
layer: rowIndex,
|
|
209
|
+
width: size.width,
|
|
210
|
+
height: size.height,
|
|
211
|
+
x: across ? depthStart : breadthCursor,
|
|
212
|
+
y: across ? breadthCursor : depthStart,
|
|
213
|
+
});
|
|
214
|
+
breadthCursor += breadthSpan + NODE_GAP;
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const flip = chart.direction === "BT" || chart.direction === "RL";
|
|
219
|
+
let nodes = [...placed.values()].map((node) =>
|
|
220
|
+
flip
|
|
221
|
+
? across
|
|
222
|
+
? { ...node, x: totalDepth - node.x - node.width }
|
|
223
|
+
: { ...node, y: totalDepth - node.y - node.height }
|
|
224
|
+
: node,
|
|
225
|
+
);
|
|
226
|
+
const finals = new Map(nodes.map((node) => [node.id, node]));
|
|
227
|
+
|
|
228
|
+
// A cluster box hugs its members; the padding and the title strip can push it
|
|
229
|
+
// past the origin, so nodes and boxes shift right/down together afterwards.
|
|
230
|
+
const boxes: LaidOutSubgraph[] = [];
|
|
231
|
+
for (const box of chart.subgraphs) {
|
|
232
|
+
const members = box.members
|
|
233
|
+
.map((id) => finals.get(id))
|
|
234
|
+
.filter((node): node is LaidOutNode => node !== undefined);
|
|
235
|
+
if (members.length === 0) continue;
|
|
236
|
+
const left = Math.min(...members.map((node) => node.x)) - SUBGRAPH_PAD;
|
|
237
|
+
const top = Math.min(...members.map((node) => node.y)) - SUBGRAPH_PAD - SUBGRAPH_TITLE;
|
|
238
|
+
const right = Math.max(...members.map((node) => node.x + node.width)) + SUBGRAPH_PAD;
|
|
239
|
+
const bottom = Math.max(...members.map((node) => node.y + node.height)) + SUBGRAPH_PAD;
|
|
240
|
+
boxes.push({ ...box, x: left, y: top, width: right - left, height: bottom - top });
|
|
241
|
+
}
|
|
242
|
+
const shiftX = -Math.min(0, ...boxes.map((box) => box.x));
|
|
243
|
+
const shiftY = -Math.min(0, ...boxes.map((box) => box.y));
|
|
244
|
+
if (shiftX > 0 || shiftY > 0) {
|
|
245
|
+
nodes = nodes.map((node) => ({ ...node, x: node.x + shiftX, y: node.y + shiftY }));
|
|
246
|
+
for (const box of boxes) {
|
|
247
|
+
box.x += shiftX;
|
|
248
|
+
box.y += shiftY;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const shifted = new Map(nodes.map((node) => [node.id, node]));
|
|
252
|
+
|
|
253
|
+
// An edge that skips a layer cannot be routed between its endpoints: the
|
|
254
|
+
// straight path crosses whatever sits in the layers between them. Those edges
|
|
255
|
+
// get a lane outside the node band instead, one per edge so they do not stack.
|
|
256
|
+
const spans = (edge: FlowEdge): boolean => {
|
|
257
|
+
const from = shifted.get(edge.from);
|
|
258
|
+
const to = shifted.get(edge.to);
|
|
259
|
+
return !!from && !!to && Math.abs(to.layer - from.layer) > 1;
|
|
260
|
+
};
|
|
261
|
+
const longEdges = chart.edges.filter(spans);
|
|
262
|
+
const laneBand = longEdges.length * LANE_GAP + (longEdges.length > 0 ? LANE_GAP : 0);
|
|
263
|
+
|
|
264
|
+
// Lanes always run in the breadth axis, just past the node band, whatever the
|
|
265
|
+
// direction: the depth extent has nothing to do with where nodes sit across.
|
|
266
|
+
const breadthOrigin = across ? shiftY : shiftX;
|
|
267
|
+
const depthOrigin = across ? shiftX : shiftY;
|
|
268
|
+
const lanes = new Map(
|
|
269
|
+
longEdges.map((edge, index) => [edge, breadthOrigin + widestRow + LANE_GAP * (index + 1)]),
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
const edges = chart.edges.map((edge) =>
|
|
273
|
+
route(edge, shifted.get(edge.from), shifted.get(edge.to), across, lanes.get(edge)),
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const breadthExtent = breadthOrigin + widestRow + laneBand;
|
|
277
|
+
const depthExtent = depthOrigin + totalDepth;
|
|
278
|
+
const boxRight = Math.max(0, ...boxes.map((box) => box.x + box.width));
|
|
279
|
+
const boxBottom = Math.max(0, ...boxes.map((box) => box.y + box.height));
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
direction: chart.direction,
|
|
283
|
+
nodes,
|
|
284
|
+
edges,
|
|
285
|
+
subgraphs: boxes,
|
|
286
|
+
width: across ? Math.max(depthExtent, boxRight) : Math.max(breadthExtent, boxRight),
|
|
287
|
+
height: across ? Math.max(breadthExtent, boxBottom) : Math.max(depthExtent, boxBottom),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Three orthogonal segments between neighbours; five through a lane when not. */
|
|
292
|
+
function route(
|
|
293
|
+
edge: FlowEdge,
|
|
294
|
+
from: LaidOutNode | undefined,
|
|
295
|
+
to: LaidOutNode | undefined,
|
|
296
|
+
across: boolean,
|
|
297
|
+
lane?: number,
|
|
298
|
+
): LaidOutEdge {
|
|
299
|
+
if (!from || !to) return { ...edge, segments: [], arrowAt: null, labelAt: null };
|
|
300
|
+
if (lane !== undefined) return routeThroughLane(edge, from, to, across, lane);
|
|
301
|
+
|
|
302
|
+
if (across) {
|
|
303
|
+
const forward = to.x >= from.x;
|
|
304
|
+
const startX = forward ? from.x + from.width : from.x;
|
|
305
|
+
const endX = forward ? to.x : to.x + to.width;
|
|
306
|
+
const startY = from.y + from.height / 2;
|
|
307
|
+
const endY = to.y + to.height / 2;
|
|
308
|
+
const midX = (startX + endX) / 2;
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
...edge,
|
|
312
|
+
segments: [
|
|
313
|
+
span(Math.min(startX, midX), startY, Math.abs(midX - startX), LINE),
|
|
314
|
+
span(midX - LINE / 2, Math.min(startY, endY), LINE, Math.abs(endY - startY)),
|
|
315
|
+
span(Math.min(midX, endX), endY, Math.abs(endX - midX), LINE),
|
|
316
|
+
],
|
|
317
|
+
arrowAt: edge.arrow ? { x: endX, y: endY, direction: forward ? "right" : "left" } : null,
|
|
318
|
+
labelAt: { x: midX, y: (startY + endY) / 2 },
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const forward = to.y >= from.y;
|
|
323
|
+
const startY = forward ? from.y + from.height : from.y;
|
|
324
|
+
const endY = forward ? to.y : to.y + to.height;
|
|
325
|
+
const startX = from.x + from.width / 2;
|
|
326
|
+
const endX = to.x + to.width / 2;
|
|
327
|
+
const midY = (startY + endY) / 2;
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
...edge,
|
|
331
|
+
segments: [
|
|
332
|
+
span(startX - LINE / 2, Math.min(startY, midY), LINE, Math.abs(midY - startY)),
|
|
333
|
+
span(Math.min(startX, endX), midY, Math.abs(endX - startX), LINE),
|
|
334
|
+
span(endX - LINE / 2, Math.min(midY, endY), LINE, Math.abs(endY - midY)),
|
|
335
|
+
],
|
|
336
|
+
arrowAt: edge.arrow ? { x: endX, y: endY, direction: forward ? "down" : "up" } : null,
|
|
337
|
+
labelAt: { x: (startX + endX) / 2, y: midY },
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Out of the source, down to a lane clear of every node, across, and back up
|
|
343
|
+
* into the target. Longer than a direct route, and the only one that is honest
|
|
344
|
+
* about not passing through the boxes in between.
|
|
345
|
+
*/
|
|
346
|
+
function routeThroughLane(
|
|
347
|
+
edge: FlowEdge,
|
|
348
|
+
from: LaidOutNode,
|
|
349
|
+
to: LaidOutNode,
|
|
350
|
+
across: boolean,
|
|
351
|
+
lane: number,
|
|
352
|
+
): LaidOutEdge {
|
|
353
|
+
if (across) {
|
|
354
|
+
const startX = from.x + from.width;
|
|
355
|
+
const endX = to.x;
|
|
356
|
+
const startY = from.y + from.height / 2;
|
|
357
|
+
const endY = to.y + to.height / 2;
|
|
358
|
+
const outX = startX + LANE_GAP / 2;
|
|
359
|
+
const inX = endX - LANE_GAP / 2;
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
...edge,
|
|
363
|
+
segments: [
|
|
364
|
+
span(startX, startY, outX - startX, LINE),
|
|
365
|
+
span(outX - LINE / 2, Math.min(startY, lane), LINE, Math.abs(lane - startY)),
|
|
366
|
+
span(Math.min(outX, inX), lane, Math.abs(inX - outX), LINE),
|
|
367
|
+
span(inX - LINE / 2, Math.min(endY, lane), LINE, Math.abs(lane - endY)),
|
|
368
|
+
span(inX, endY, endX - inX, LINE),
|
|
369
|
+
],
|
|
370
|
+
arrowAt: edge.arrow ? { x: endX, y: endY, direction: "right" } : null,
|
|
371
|
+
labelAt: { x: (outX + inX) / 2, y: lane },
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const startY = from.y + from.height;
|
|
376
|
+
const endY = to.y;
|
|
377
|
+
const startX = from.x + from.width / 2;
|
|
378
|
+
const endX = to.x + to.width / 2;
|
|
379
|
+
const outY = startY + LANE_GAP / 2;
|
|
380
|
+
const inY = endY - LANE_GAP / 2;
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
...edge,
|
|
384
|
+
segments: [
|
|
385
|
+
span(startX - LINE / 2, startY, LINE, outY - startY),
|
|
386
|
+
span(Math.min(startX, lane), outY, Math.abs(lane - startX), LINE),
|
|
387
|
+
span(lane - LINE / 2, Math.min(outY, inY), LINE, Math.abs(inY - outY)),
|
|
388
|
+
span(Math.min(lane, endX), inY, Math.abs(endX - lane), LINE),
|
|
389
|
+
span(endX - LINE / 2, inY, LINE, endY - inY),
|
|
390
|
+
],
|
|
391
|
+
arrowAt: edge.arrow ? { x: endX, y: endY, direction: "down" } : null,
|
|
392
|
+
labelAt: { x: lane, y: (outY + inY) / 2 },
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const span = (x: number, y: number, width: number, height: number): Segment => ({
|
|
397
|
+
x,
|
|
398
|
+
y,
|
|
399
|
+
width: Math.max(width, LINE),
|
|
400
|
+
height: Math.max(height, LINE),
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
/* -------------------------------------------------------------------------- */
|
|
404
|
+
/* Sequence diagrams */
|
|
405
|
+
/* -------------------------------------------------------------------------- */
|
|
406
|
+
|
|
407
|
+
export interface SequenceColumn {
|
|
408
|
+
id: string;
|
|
409
|
+
label: string;
|
|
410
|
+
actor: boolean;
|
|
411
|
+
centerX: number;
|
|
412
|
+
width: number;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export interface SequenceRow {
|
|
416
|
+
y: number;
|
|
417
|
+
height: number;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export interface SequenceLayout {
|
|
421
|
+
columns: SequenceColumn[];
|
|
422
|
+
rows: SequenceRow[];
|
|
423
|
+
headerHeight: number;
|
|
424
|
+
width: number;
|
|
425
|
+
height: number;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const SEQ_ROW = 44;
|
|
429
|
+
const SEQ_HEADER = 40;
|
|
430
|
+
const SEQ_GAP = 40;
|
|
431
|
+
const SEQ_MIN_COLUMN = 96;
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Columns are sized to their own label, not to a shared width: one participant
|
|
435
|
+
* called "Payment gateway" should not push every other column that wide.
|
|
436
|
+
*/
|
|
437
|
+
export function layoutSequence(
|
|
438
|
+
participants: { id: string; label: string; actor: boolean }[],
|
|
439
|
+
eventCount: number,
|
|
440
|
+
): SequenceLayout {
|
|
441
|
+
const columns: SequenceColumn[] = [];
|
|
442
|
+
let cursor = 0;
|
|
443
|
+
|
|
444
|
+
for (const participant of participants) {
|
|
445
|
+
const width = Math.max(
|
|
446
|
+
SEQ_MIN_COLUMN,
|
|
447
|
+
Math.round(participant.label.length * CHAR_WIDTH) + PADDING_X * 2,
|
|
448
|
+
);
|
|
449
|
+
columns.push({ ...participant, width, centerX: cursor + width / 2 });
|
|
450
|
+
cursor += width + SEQ_GAP;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const rows = Array.from({ length: eventCount }, (_, index) => ({
|
|
454
|
+
y: SEQ_HEADER + index * SEQ_ROW,
|
|
455
|
+
height: SEQ_ROW,
|
|
456
|
+
}));
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
columns,
|
|
460
|
+
rows,
|
|
461
|
+
headerHeight: SEQ_HEADER,
|
|
462
|
+
width: Math.max(0, cursor - SEQ_GAP),
|
|
463
|
+
height: SEQ_HEADER + eventCount * SEQ_ROW + 12,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/* -------------------------------------------------------------------------- */
|
|
468
|
+
/* Fitting a drawing to the space it gets */
|
|
469
|
+
/* -------------------------------------------------------------------------- */
|
|
470
|
+
|
|
471
|
+
/** Below this the labels stop being readable; scroll instead of shrinking further. */
|
|
472
|
+
export const MIN_SCALE = 0.55;
|
|
473
|
+
/** Above this a three-node diagram would look like a poster. */
|
|
474
|
+
export const MAX_SCALE = 1.3;
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* How much to scale a drawing for the width it was actually given.
|
|
478
|
+
*
|
|
479
|
+
* A diagram has no business being rendered at whatever size its node labels
|
|
480
|
+
* happened to add up to. Wide ones shrink to fit, small ones grow a little so
|
|
481
|
+
* they do not sit lost in a wide row, and the shrinking stops where the text
|
|
482
|
+
* stops being readable — past that the caller scrolls instead.
|
|
483
|
+
*/
|
|
484
|
+
export function fitScale(available: number, natural: number): number {
|
|
485
|
+
if (!(available > 0) || !(natural > 0)) return 1;
|
|
486
|
+
return Math.min(MAX_SCALE, Math.max(MIN_SCALE, available / natural));
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* True when the drawing still does not fit after shrinking as far as it may,
|
|
491
|
+
* or when the reader has zoomed a fitted drawing past the row.
|
|
492
|
+
*/
|
|
493
|
+
export function overflowsAfterFit(available: number, natural: number, zoom = 1): boolean {
|
|
494
|
+
if (!(available > 0) || !(natural > 0)) return false;
|
|
495
|
+
return natural * fitScale(available, natural) * zoom > available + 0.5;
|
|
496
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { parseInline, parseMarkdown } from "./parse";
|
|
3
|
+
|
|
4
|
+
describe("parseInline", () => {
|
|
5
|
+
it("reads bold, italic and code", () => {
|
|
6
|
+
expect(parseInline("a **b** c `d` e *f*")).toEqual([
|
|
7
|
+
{ kind: "text", text: "a " },
|
|
8
|
+
{ kind: "bold", text: "b" },
|
|
9
|
+
{ kind: "text", text: " c " },
|
|
10
|
+
{ kind: "code", text: "d" },
|
|
11
|
+
{ kind: "text", text: " e " },
|
|
12
|
+
{ kind: "italic", text: "f" },
|
|
13
|
+
]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("leaves an unmatched marker as text rather than eating the rest", () => {
|
|
17
|
+
expect(parseInline("2 * 3 = 6")).toEqual([{ kind: "text", text: "2 * 3 = 6" }]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("does not read emphasis inside code", () => {
|
|
21
|
+
expect(parseInline("`a **b**`")).toEqual([{ kind: "code", text: "a **b**" }]);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("parseMarkdown", () => {
|
|
26
|
+
it("reads headings with their level", () => {
|
|
27
|
+
expect(parseMarkdown("### Title")[0]).toMatchObject({ kind: "heading", level: 3 });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("joins wrapped lines into one paragraph", () => {
|
|
31
|
+
const blocks = parseMarkdown("one\ntwo\n\nthree");
|
|
32
|
+
expect(blocks).toHaveLength(2);
|
|
33
|
+
expect(blocks[0]).toMatchObject({ kind: "paragraph" });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("groups consecutive bullets into a single list", () => {
|
|
37
|
+
const blocks = parseMarkdown("- a\n- b\n- c");
|
|
38
|
+
expect(blocks).toHaveLength(1);
|
|
39
|
+
expect(blocks[0]).toMatchObject({ kind: "bullet" });
|
|
40
|
+
expect((blocks[0] as { items: unknown[] }).items).toHaveLength(3);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("keeps the starting number of an ordered list", () => {
|
|
44
|
+
expect(parseMarkdown("3. third\n4. fourth")[0]).toMatchObject({ kind: "ordered", start: 3 });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("keeps a fenced block verbatim, including its blank lines", () => {
|
|
48
|
+
const blocks = parseMarkdown("```ts\nconst a = 1;\n\nconst b = 2;\n```");
|
|
49
|
+
expect(blocks[0]).toMatchObject({ kind: "code", language: "ts" });
|
|
50
|
+
expect((blocks[0] as { text: string }).text).toContain("\n\n");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("does not treat a list inside a code block as a list", () => {
|
|
54
|
+
const blocks = parseMarkdown("```\n- not a bullet\n```");
|
|
55
|
+
expect(blocks).toHaveLength(1);
|
|
56
|
+
expect(blocks[0].kind).toBe("code");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("reads a rule and a quote", () => {
|
|
60
|
+
expect(parseMarkdown("---")[0]).toMatchObject({ kind: "rule" });
|
|
61
|
+
expect(parseMarkdown("> quoted")[0]).toMatchObject({ kind: "quote" });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("reads a table with alignment and inline spans in cells", () => {
|
|
65
|
+
const blocks = parseMarkdown("| Name | Value |\n|:--- | ---:|\n| **a** | `b` |\n| c | d |");
|
|
66
|
+
expect(blocks).toHaveLength(1);
|
|
67
|
+
expect(blocks[0]).toMatchObject({
|
|
68
|
+
kind: "table",
|
|
69
|
+
align: ["left", "right"],
|
|
70
|
+
header: [[{ kind: "text", text: "Name" }], [{ kind: "text", text: "Value" }]],
|
|
71
|
+
});
|
|
72
|
+
const table = blocks[0] as { rows: { kind: string; text: string }[][][] };
|
|
73
|
+
expect(table.rows).toHaveLength(2);
|
|
74
|
+
expect(table.rows[0]).toEqual([[{ kind: "bold", text: "a" }], [{ kind: "code", text: "b" }]]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("lets a table interrupt a paragraph without a blank line", () => {
|
|
78
|
+
const blocks = parseMarkdown("intro text\nA | B\n--- | ---\n1 | 2");
|
|
79
|
+
expect(blocks).toHaveLength(2);
|
|
80
|
+
expect(blocks[0]).toMatchObject({ kind: "paragraph" });
|
|
81
|
+
expect(blocks[1]).toMatchObject({ kind: "table" });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("hides an escaped pipe from the cell split", () => {
|
|
85
|
+
const blocks = parseMarkdown("| A | B |\n| --- | --- |\n| a \\| b | c |");
|
|
86
|
+
const table = blocks[0] as { rows: { kind: string; text: string }[][][] };
|
|
87
|
+
expect(table.rows[0]).toEqual([
|
|
88
|
+
[{ kind: "text", text: "a | b" }],
|
|
89
|
+
[{ kind: "text", text: "c" }],
|
|
90
|
+
]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("reads rows without edge pipes and stops at a plain line", () => {
|
|
94
|
+
const blocks = parseMarkdown("A | B\n--- | ---\n1 | 2\nplain tail");
|
|
95
|
+
const table = blocks[0] as { rows: { kind: string; text: string }[][][] };
|
|
96
|
+
expect(blocks[0]).toMatchObject({ kind: "table" });
|
|
97
|
+
expect(table.rows).toHaveLength(1);
|
|
98
|
+
expect(blocks[1]).toMatchObject({ kind: "paragraph" });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("leaves pipe lines without a delimiter row as a paragraph", () => {
|
|
102
|
+
const blocks = parseMarkdown("a | b\nc | d");
|
|
103
|
+
expect(blocks).toHaveLength(1);
|
|
104
|
+
expect(blocks[0]).toMatchObject({ kind: "paragraph" });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("returns nothing for empty input", () => {
|
|
108
|
+
expect(parseMarkdown(" \n\n ")).toEqual([]);
|
|
109
|
+
});
|
|
110
|
+
});
|