@archcode-io/engine 0.2.0-preview.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/CHANGELOG.md +31 -0
- package/LICENSE +202 -0
- package/README.md +88 -0
- package/dist/src/capacity.d.ts +67 -0
- package/dist/src/capacity.js +227 -0
- package/dist/src/check.d.ts +21 -0
- package/dist/src/check.js +192 -0
- package/dist/src/compiled.d.ts +61 -0
- package/dist/src/compiled.js +91 -0
- package/dist/src/cst.d.ts +76 -0
- package/dist/src/cst.js +1 -0
- package/dist/src/diagnostics.d.ts +9 -0
- package/dist/src/diagnostics.js +1 -0
- package/dist/src/edit.d.ts +48 -0
- package/dist/src/edit.js +442 -0
- package/dist/src/index.d.ts +25 -0
- package/dist/src/index.js +24 -0
- package/dist/src/layout/balance.d.ts +34 -0
- package/dist/src/layout/balance.js +327 -0
- package/dist/src/layout/elk.d.ts +64 -0
- package/dist/src/layout/elk.js +267 -0
- package/dist/src/layout/host.d.ts +23 -0
- package/dist/src/layout/host.js +23 -0
- package/dist/src/layout/label.d.ts +49 -0
- package/dist/src/layout/label.js +113 -0
- package/dist/src/layout/measure.d.ts +11 -0
- package/dist/src/layout/measure.js +27 -0
- package/dist/src/layout/ortho.d.ts +54 -0
- package/dist/src/layout/ortho.js +206 -0
- package/dist/src/layout/route.d.ts +57 -0
- package/dist/src/layout/route.js +230 -0
- package/dist/src/lens.d.ts +83 -0
- package/dist/src/lens.js +377 -0
- package/dist/src/lexer.d.ts +7 -0
- package/dist/src/lexer.js +135 -0
- package/dist/src/model.d.ts +63 -0
- package/dist/src/model.js +114 -0
- package/dist/src/parser.d.ts +7 -0
- package/dist/src/parser.js +305 -0
- package/dist/src/render/svg.d.ts +56 -0
- package/dist/src/render/svg.js +289 -0
- package/dist/src/serialize.d.ts +10 -0
- package/dist/src/serialize.js +12 -0
- package/dist/src/tokens.d.ts +26 -0
- package/dist/src/tokens.js +15 -0
- package/dist/src/vocab.d.ts +38 -0
- package/dist/src/vocab.js +58 -0
- package/package.json +61 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Obstacle-aware orthogonal router: a Hanan grid drawn at every obstacle
|
|
3
|
+
* border (± clearance, two lanes) plus the endpoints, searched with A* and a
|
|
4
|
+
* bend penalty. Deterministic — ties break by insertion order. A shared
|
|
5
|
+
* `used` map makes later edges prefer lanes nobody has taken yet.
|
|
6
|
+
*/
|
|
7
|
+
export const M = 16, LANE = 14, BEND = 70, REUSE = 40;
|
|
8
|
+
const insideRect = (p, r, eps = 0.5) => p.x > r.x + eps && p.x < r.x + r.w - eps && p.y > r.y + eps && p.y < r.y + r.h - eps;
|
|
9
|
+
export function makeGrid(obstacles, extra = []) {
|
|
10
|
+
const xs = new Set(), ys = new Set();
|
|
11
|
+
for (const r of obstacles) {
|
|
12
|
+
for (const d of [M, M + LANE]) {
|
|
13
|
+
xs.add(r.x - d);
|
|
14
|
+
xs.add(r.x + r.w + d);
|
|
15
|
+
ys.add(r.y - d);
|
|
16
|
+
ys.add(r.y + r.h + d);
|
|
17
|
+
}
|
|
18
|
+
xs.add(r.x + r.w / 2);
|
|
19
|
+
ys.add(r.y + r.h / 2);
|
|
20
|
+
}
|
|
21
|
+
for (const p of extra) {
|
|
22
|
+
xs.add(p.x);
|
|
23
|
+
ys.add(p.y);
|
|
24
|
+
}
|
|
25
|
+
const tidy = (s) => [...s].map(v => Math.round(v * 2) / 2).sort((a, b) => a - b).filter((v, i, a) => i === 0 || v - a[i - 1] > 0.9);
|
|
26
|
+
return { X: tidy(xs), Y: tidy(ys), obstacles };
|
|
27
|
+
}
|
|
28
|
+
const idx = (arr, v) => {
|
|
29
|
+
let lo = 0, hi = arr.length - 1;
|
|
30
|
+
while (lo < hi) {
|
|
31
|
+
const m = (lo + hi) >> 1;
|
|
32
|
+
if (arr[m] < v - 0.9)
|
|
33
|
+
lo = m + 1;
|
|
34
|
+
else
|
|
35
|
+
hi = m;
|
|
36
|
+
}
|
|
37
|
+
return lo;
|
|
38
|
+
};
|
|
39
|
+
/** A* from grid point s to t; `blocked(mid)` rejects a move whose midpoint is inside an obstacle. */
|
|
40
|
+
export function astar(grid, s, t, blocked, used = new Map()) {
|
|
41
|
+
const { X, Y } = grid;
|
|
42
|
+
const si = idx(X, s.x), sj = idx(Y, s.y), ti = idx(X, t.x), tj = idx(Y, t.y);
|
|
43
|
+
const W = X.length, H = Y.length;
|
|
44
|
+
const key = (i, j, d) => (i * H + j) * 4 + d;
|
|
45
|
+
const DIRS = [[1, 0], [-1, 0], [0, 1], [0, -1]];
|
|
46
|
+
const h = (i, j) => Math.abs(X[i] - X[ti]) + Math.abs(Y[j] - Y[tj]);
|
|
47
|
+
const best = new Map(), from = new Map();
|
|
48
|
+
const heap = [];
|
|
49
|
+
const push = (e) => { heap.push(e); let k = heap.length - 1; while (k > 0) {
|
|
50
|
+
const p = (k - 1) >> 1;
|
|
51
|
+
if (heap[p].f <= heap[k].f)
|
|
52
|
+
break;
|
|
53
|
+
[heap[p], heap[k]] = [heap[k], heap[p]];
|
|
54
|
+
k = p;
|
|
55
|
+
} };
|
|
56
|
+
const pop = () => {
|
|
57
|
+
const top = heap[0], last = heap.pop();
|
|
58
|
+
if (heap.length) {
|
|
59
|
+
heap[0] = last;
|
|
60
|
+
let k = 0;
|
|
61
|
+
for (;;) {
|
|
62
|
+
const l = 2 * k + 1, r = l + 1;
|
|
63
|
+
let m = k;
|
|
64
|
+
if (l < heap.length && heap[l].f < heap[m].f)
|
|
65
|
+
m = l;
|
|
66
|
+
if (r < heap.length && heap[r].f < heap[m].f)
|
|
67
|
+
m = r;
|
|
68
|
+
if (m === k)
|
|
69
|
+
break;
|
|
70
|
+
[heap[m], heap[k]] = [heap[k], heap[m]];
|
|
71
|
+
k = m;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return top;
|
|
75
|
+
};
|
|
76
|
+
for (let d = 0; d < 4; d++) {
|
|
77
|
+
best.set(key(si, sj, d), 0);
|
|
78
|
+
push({ i: si, j: sj, d, g: 0, f: h(si, sj) });
|
|
79
|
+
}
|
|
80
|
+
let endKey = null;
|
|
81
|
+
while (heap.length) {
|
|
82
|
+
const cur = pop();
|
|
83
|
+
const ck = key(cur.i, cur.j, cur.d);
|
|
84
|
+
if ((best.get(ck) ?? Infinity) < cur.g)
|
|
85
|
+
continue;
|
|
86
|
+
if (cur.i === ti && cur.j === tj) {
|
|
87
|
+
endKey = ck;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
for (let d = 0; d < 4; d++) {
|
|
91
|
+
const ni = cur.i + DIRS[d][0], nj = cur.j + DIRS[d][1];
|
|
92
|
+
if (ni < 0 || nj < 0 || ni >= W || nj >= H)
|
|
93
|
+
continue;
|
|
94
|
+
const mid = { x: (X[cur.i] + X[ni]) / 2, y: (Y[cur.j] + Y[nj]) / 2 };
|
|
95
|
+
if (blocked(mid, ni, nj))
|
|
96
|
+
continue;
|
|
97
|
+
const segKey = `${Math.min(cur.i, ni)},${Math.min(cur.j, nj)},${d < 2 ? 'h' : 'v'}`;
|
|
98
|
+
const g = cur.g + Math.abs(X[ni] - X[cur.i]) + Math.abs(Y[nj] - Y[cur.j])
|
|
99
|
+
+ (d !== cur.d && cur.g > 0 ? BEND : 0) + (used.get(segKey) ?? 0) * REUSE;
|
|
100
|
+
const nk = key(ni, nj, d);
|
|
101
|
+
if (g < (best.get(nk) ?? Infinity) - 1e-6) {
|
|
102
|
+
best.set(nk, g);
|
|
103
|
+
from.set(nk, ck);
|
|
104
|
+
push({ i: ni, j: nj, d, g, f: g + h(ni, nj) });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (endKey === null)
|
|
109
|
+
return null;
|
|
110
|
+
const pts = [];
|
|
111
|
+
for (let k = endKey; k !== undefined; k = from.get(k)) {
|
|
112
|
+
const d = k % 4, cell = (k - d) / 4, j = cell % H, i = (cell - j) / H;
|
|
113
|
+
pts.push({ x: X[i], y: Y[j] });
|
|
114
|
+
}
|
|
115
|
+
pts.reverse();
|
|
116
|
+
return simplify(pts);
|
|
117
|
+
}
|
|
118
|
+
/** Remember which grid segments a committed path occupies. */
|
|
119
|
+
export function markUsed(grid, pts, used) {
|
|
120
|
+
const { X, Y } = grid;
|
|
121
|
+
for (let k = 1; k < pts.length; k++) {
|
|
122
|
+
const a = pts[k - 1], b = pts[k];
|
|
123
|
+
const i0 = Math.min(idx(X, a.x), idx(X, b.x)), i1 = Math.max(idx(X, a.x), idx(X, b.x));
|
|
124
|
+
const j0 = Math.min(idx(Y, a.y), idx(Y, b.y)), j1 = Math.max(idx(Y, a.y), idx(Y, b.y));
|
|
125
|
+
if (Math.abs(a.y - b.y) < 0.01)
|
|
126
|
+
for (let i = i0; i < i1; i++) {
|
|
127
|
+
const sk = `${i},${j0},h`;
|
|
128
|
+
used.set(sk, (used.get(sk) ?? 0) + 1);
|
|
129
|
+
}
|
|
130
|
+
else
|
|
131
|
+
for (let j = j0; j < j1; j++) {
|
|
132
|
+
const sk = `${i0},${j},v`;
|
|
133
|
+
used.set(sk, (used.get(sk) ?? 0) + 1);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** Drop repeated and collinear points. */
|
|
138
|
+
export function simplify(pts) {
|
|
139
|
+
const out = [];
|
|
140
|
+
for (const p of pts) {
|
|
141
|
+
const q = out[out.length - 1];
|
|
142
|
+
if (!q || Math.abs(q.x - p.x) > 0.01 || Math.abs(q.y - p.y) > 0.01)
|
|
143
|
+
out.push({ ...p });
|
|
144
|
+
}
|
|
145
|
+
for (let i = out.length - 2; i > 0; i--) {
|
|
146
|
+
const a = out[i - 1], b = out[i], c = out[i + 1];
|
|
147
|
+
if ((Math.abs(a.x - b.x) < 0.01 && Math.abs(b.x - c.x) < 0.01) || (Math.abs(a.y - b.y) < 0.01 && Math.abs(b.y - c.y) < 0.01))
|
|
148
|
+
out.splice(i, 1);
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
/** Point on a node's side, and the same point pushed `M` outward. */
|
|
153
|
+
export function stub(n, side) {
|
|
154
|
+
const c = { x: n.x + n.w / 2, y: n.y + n.h / 2 };
|
|
155
|
+
const on = side === 'top' ? { x: c.x, y: n.y } : side === 'bottom' ? { x: c.x, y: n.y + n.h } : side === 'left' ? { x: n.x, y: c.y } : { x: n.x + n.w, y: c.y };
|
|
156
|
+
const out = side === 'top' ? { x: c.x, y: n.y - M } : side === 'bottom' ? { x: c.x, y: n.y + n.h + M } : side === 'left' ? { x: n.x - M, y: c.y } : { x: n.x + n.w + M, y: c.y };
|
|
157
|
+
return { on, out };
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Route edges `{ a, b, pref }` around the rectangles `obstaclesFor(edge)`
|
|
161
|
+
* returns. Preferred sides go first; when they only give a detour (more than
|
|
162
|
+
* two bends) every side pair is tried and the cheapest wins. One polyline (or
|
|
163
|
+
* null) per edge, in order.
|
|
164
|
+
*/
|
|
165
|
+
export function routeAll(edges, obstaclesFor) {
|
|
166
|
+
const used = new Map();
|
|
167
|
+
const out = [];
|
|
168
|
+
const order = (first, all) => [first, ...all.filter(x => x !== first)];
|
|
169
|
+
for (const e of edges) {
|
|
170
|
+
const obs = obstaclesFor(e);
|
|
171
|
+
const blocked = (p) => obs.some(r => insideRect(p, r)) || insideRect(p, e.a) || insideRect(p, e.b);
|
|
172
|
+
const sidesA = order(e.pref?.[0] ?? 'bottom', ['bottom', 'top', 'right', 'left']);
|
|
173
|
+
const sidesB = order(e.pref?.[1] ?? 'top', ['top', 'bottom', 'left', 'right']);
|
|
174
|
+
let best = null;
|
|
175
|
+
const trial = (sa, sb) => {
|
|
176
|
+
const A = stub(e.a, sa), B = stub(e.b, sb);
|
|
177
|
+
if (blocked(A.out) || blocked(B.out))
|
|
178
|
+
return;
|
|
179
|
+
const grid = makeGrid([...obs, e.a, e.b], [A.out, B.out]);
|
|
180
|
+
const path = astar(grid, A.out, B.out, blocked, used);
|
|
181
|
+
if (!path)
|
|
182
|
+
return;
|
|
183
|
+
let cost = 0;
|
|
184
|
+
for (let k = 1; k < path.length; k++)
|
|
185
|
+
cost += Math.abs(path[k].x - path[k - 1].x) + Math.abs(path[k].y - path[k - 1].y);
|
|
186
|
+
cost += (path.length - 2) * BEND + (sa !== sidesA[0] ? 40 : 0) + (sb !== sidesB[0] ? 40 : 0);
|
|
187
|
+
if (!best || cost < best.cost)
|
|
188
|
+
best = { cost, pts: simplify([A.on, ...path, B.on]), grid, path };
|
|
189
|
+
};
|
|
190
|
+
trial(sidesA[0], sidesB[0]);
|
|
191
|
+
if (!best || best.path.length > 4)
|
|
192
|
+
for (const sa of sidesA)
|
|
193
|
+
for (const sb of sidesB)
|
|
194
|
+
if (sa !== sidesA[0] || sb !== sidesB[0])
|
|
195
|
+
trial(sa, sb);
|
|
196
|
+
if (best) {
|
|
197
|
+
const b = best;
|
|
198
|
+
markUsed(b.grid, b.path, used);
|
|
199
|
+
out.push(b.pts);
|
|
200
|
+
}
|
|
201
|
+
else
|
|
202
|
+
out.push(null);
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
/** The plain two-bend route between two boxes — the fallback, and the live-drag router. */
|
|
207
|
+
export function routeSimple(a, b) {
|
|
208
|
+
const ax = a.x + a.w / 2, ay = a.y + a.h / 2, bx = b.x + b.w / 2, by = b.y + b.h / 2;
|
|
209
|
+
const dx = bx - ax, dy = by - ay;
|
|
210
|
+
if (Math.abs(dx) >= Math.abs(dy)) {
|
|
211
|
+
const sx = dx > 0 ? a.x + a.w : a.x, ex = dx > 0 ? b.x : b.x + b.w;
|
|
212
|
+
const mid = (sx + ex) / 2;
|
|
213
|
+
return simplify([{ x: sx, y: ay }, { x: mid, y: ay }, { x: mid, y: by }, { x: ex, y: by }]);
|
|
214
|
+
}
|
|
215
|
+
const sy = dy > 0 ? a.y + a.h : a.y, ey = dy > 0 ? b.y : b.y + b.h;
|
|
216
|
+
const mid = (sy + ey) / 2;
|
|
217
|
+
return simplify([{ x: ax, y: sy }, { x: ax, y: mid }, { x: bx, y: mid }, { x: bx, y: ey }]);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Route one edge between two boxes around obstacles — what the canvas uses
|
|
221
|
+
* while a card is being dragged. Falls back to the plain route when the
|
|
222
|
+
* search finds nothing.
|
|
223
|
+
*/
|
|
224
|
+
export function routeAround(a, b, obstacles) {
|
|
225
|
+
const vertical = a.y + a.h <= b.y || b.y + b.h <= a.y;
|
|
226
|
+
const down = a.y < b.y, right = a.x < b.x;
|
|
227
|
+
const pref = vertical ? [down ? 'bottom' : 'top', down ? 'top' : 'bottom'] : [right ? 'right' : 'left', right ? 'left' : 'right'];
|
|
228
|
+
const [p] = routeAll([{ a, b, pref }], () => obstacles.filter(o => o !== a && o !== b));
|
|
229
|
+
return p ?? routeSimple(a, b);
|
|
230
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { Model } from './model.js';
|
|
2
|
+
/** Render-time views over the model. Nothing here is ever written back (D-52). */
|
|
3
|
+
export type Lens = 'logical' | 'infrastructure';
|
|
4
|
+
/**
|
|
5
|
+
* A lens is not a mode — it is a **preset for one set**: which transit nodes are
|
|
6
|
+
* expanded. `logical` starts with none, `infrastructure` with all, and `shown`
|
|
7
|
+
* / `hidden` move individual nodes in and out. Everything downstream reads that
|
|
8
|
+
* single set, so a chip and the lens switch can never disagree, and no edge is
|
|
9
|
+
* ever produced twice or lost.
|
|
10
|
+
*/
|
|
11
|
+
export interface LensOptions {
|
|
12
|
+
lens: Lens;
|
|
13
|
+
shown?: string[];
|
|
14
|
+
hidden?: string[];
|
|
15
|
+
collapsed?: string[];
|
|
16
|
+
/**
|
|
17
|
+
* Which picture (spec §16.1): `model` draws the C4 objects; `deployment`
|
|
18
|
+
* draws environments, segments and nodes with the objects that run in them.
|
|
19
|
+
*/
|
|
20
|
+
kind?: 'model' | 'deployment';
|
|
21
|
+
/**
|
|
22
|
+
* Which architecture: an object or relation carrying `phase X` belongs to
|
|
23
|
+
* variant X only; anything without a phase belongs to every variant.
|
|
24
|
+
* Undefined draws everything.
|
|
25
|
+
*/
|
|
26
|
+
phase?: string;
|
|
27
|
+
/** Draw the agents installed on hosts as chips on the deployment picture (default on). */
|
|
28
|
+
agents?: boolean;
|
|
29
|
+
}
|
|
30
|
+
export interface RenderNode {
|
|
31
|
+
id: string;
|
|
32
|
+
kind: string;
|
|
33
|
+
label: string;
|
|
34
|
+
tech?: string;
|
|
35
|
+
/** For a placement card: the logical object it stands for. */
|
|
36
|
+
ref?: string;
|
|
37
|
+
/** `phase transit` — this thing exists in that variant only (spec §6.5). */
|
|
38
|
+
phase?: string;
|
|
39
|
+
/** For a host frame: `cpu 6/16 · mem 4/32Gi`, and whether demand exceeds it. */
|
|
40
|
+
load?: string;
|
|
41
|
+
over?: boolean;
|
|
42
|
+
/** One short line of figures worth seeing on the card: `×3 · 2 cpu · 4Gi`. */
|
|
43
|
+
meta?: string;
|
|
44
|
+
/** For a host frame: the agents installed on it (`agent antivirus, alloy`) — drawn as chips along the bottom. */
|
|
45
|
+
agents?: string[];
|
|
46
|
+
/** `stage sketch|proposed|approved|live|deprecated|retired` (§17.4): sketch and proposed draw dashed, deprecated dimmed, retired is not drawn. */
|
|
47
|
+
stage?: string;
|
|
48
|
+
/** A collapsed system: how many objects it folds. */
|
|
49
|
+
folded?: number;
|
|
50
|
+
transit: boolean;
|
|
51
|
+
external: boolean;
|
|
52
|
+
ownedBy?: string;
|
|
53
|
+
children: RenderNode[];
|
|
54
|
+
}
|
|
55
|
+
/** Space a frame's footer needs below its members: one row of agent chips is 22px. */
|
|
56
|
+
export declare const AGENT_ROW = 22;
|
|
57
|
+
/** A frame with agents is held open to at least this width, so the row count decided before layout holds after it. */
|
|
58
|
+
export declare const AGENT_MIN_W = 240;
|
|
59
|
+
export declare function footerOf(n: {
|
|
60
|
+
agents?: string[];
|
|
61
|
+
w?: number;
|
|
62
|
+
isBoundary?: boolean;
|
|
63
|
+
}): number;
|
|
64
|
+
/**
|
|
65
|
+
* The card shows what a reader of a sizing table would look for first —
|
|
66
|
+
* replicas, cpu, memory, disk, gpu, capacity — and nothing else. Every other
|
|
67
|
+
* attribute stays in the text and in the inspector.
|
|
68
|
+
*/
|
|
69
|
+
export declare function metaLine(attrs: Record<string, string[]>): string | undefined;
|
|
70
|
+
export interface RenderEdge {
|
|
71
|
+
from: string;
|
|
72
|
+
to: string;
|
|
73
|
+
verb: string;
|
|
74
|
+
label?: string;
|
|
75
|
+
derived: boolean;
|
|
76
|
+
folded?: boolean;
|
|
77
|
+
dashed: boolean;
|
|
78
|
+
}
|
|
79
|
+
export interface RenderGraph {
|
|
80
|
+
roots: RenderNode[];
|
|
81
|
+
edges: RenderEdge[];
|
|
82
|
+
}
|
|
83
|
+
export declare function applyLens(m: Model, opt: Lens | LensOptions): RenderGraph;
|
package/dist/src/lens.js
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { PLACEMENT_KINDS } from './vocab.js';
|
|
2
|
+
import { effectiveSizing, hostLoad } from './capacity.js';
|
|
3
|
+
/** Space a frame's footer needs below its members: one row of agent chips is 22px. */
|
|
4
|
+
export const AGENT_ROW = 22;
|
|
5
|
+
/** A frame with agents is held open to at least this width, so the row count decided before layout holds after it. */
|
|
6
|
+
export const AGENT_MIN_W = 240;
|
|
7
|
+
export function footerOf(n) {
|
|
8
|
+
if (!n.agents?.length)
|
|
9
|
+
return 0;
|
|
10
|
+
const width = n.agents.reduce((a, s) => a + s.length * 6 + 18, 0);
|
|
11
|
+
// a frame is held open to AGENT_MIN_W by the layout; a plain card is as wide as it is
|
|
12
|
+
const usable = (n.isBoundary === false && n.w ? n.w : Math.max(AGENT_MIN_W, n.w ?? AGENT_MIN_W)) - 28;
|
|
13
|
+
return AGENT_ROW * Math.max(1, Math.ceil(width / usable));
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The card shows what a reader of a sizing table would look for first —
|
|
17
|
+
* replicas, cpu, memory, disk, gpu, capacity — and nothing else. Every other
|
|
18
|
+
* attribute stays in the text and in the inspector.
|
|
19
|
+
*/
|
|
20
|
+
export function metaLine(attrs) {
|
|
21
|
+
const bits = [];
|
|
22
|
+
const one = (k) => attrs[k]?.join(' ');
|
|
23
|
+
const count = one('replicas') ?? one('count') ?? one('nodes');
|
|
24
|
+
if (count)
|
|
25
|
+
bits.push(`×${count}`);
|
|
26
|
+
if (one('cpu'))
|
|
27
|
+
bits.push(`${one('cpu')} cpu`);
|
|
28
|
+
if (one('mem'))
|
|
29
|
+
bits.push(`${one('mem')}`);
|
|
30
|
+
const disk = attrs['disk'];
|
|
31
|
+
if (disk?.length)
|
|
32
|
+
bits.push(`${disk.filter(v => /\d/.test(v)).join('+')} disk`); // `disk system 30Gi data 200Gi` → 30Gi+200Gi
|
|
33
|
+
if (one('gpu'))
|
|
34
|
+
bits.push(`gpu ${one('gpu')}`);
|
|
35
|
+
const cap = attrs['capacity'];
|
|
36
|
+
if (cap?.length) {
|
|
37
|
+
const pairs = [];
|
|
38
|
+
for (let i = 0; i + 1 < cap.length; i += 2)
|
|
39
|
+
pairs.push(`${cap[i]} ${cap[i + 1]}`);
|
|
40
|
+
bits.push(pairs.join(' · ') || cap.join(' '));
|
|
41
|
+
}
|
|
42
|
+
return bits.length ? bits.join(' · ') : undefined;
|
|
43
|
+
}
|
|
44
|
+
const isTransit = (o) => 'transit' in o.attrs;
|
|
45
|
+
// Frames: things that are drawn as an area holding other things. A system
|
|
46
|
+
// holds containers; the placement blocks hold what runs where.
|
|
47
|
+
const BOUNDARY = new Set(['system', 'env', 'cluster', 'managed', 'segment', 'node']);
|
|
48
|
+
const TRANSPARENT = new Set(['arch']); // document root, not a drawn boundary
|
|
49
|
+
/**
|
|
50
|
+
* Blocks that describe the document rather than the system. They belong to the
|
|
51
|
+
* model, but drawing them as boxes is meaningless — a `view` is the picture,
|
|
52
|
+
* not a thing in the picture.
|
|
53
|
+
*/
|
|
54
|
+
const NOT_DRAWN = new Set(['view', 'board', 'decision', 'rule', 'profile']);
|
|
55
|
+
/**
|
|
56
|
+
* Resolve a reference the way a reader would: innermost scope first, then
|
|
57
|
+
* outwards, then the bare id (D-48).
|
|
58
|
+
*/
|
|
59
|
+
function resolve(ref, scope, m) {
|
|
60
|
+
if (m.objects.has(ref))
|
|
61
|
+
return ref;
|
|
62
|
+
let s = scope;
|
|
63
|
+
while (s) {
|
|
64
|
+
const cand = `${s}.${ref}`;
|
|
65
|
+
if (m.objects.has(cand))
|
|
66
|
+
return cand;
|
|
67
|
+
const dot = s.lastIndexOf('.');
|
|
68
|
+
s = dot < 0 ? undefined : s.slice(0, dot);
|
|
69
|
+
}
|
|
70
|
+
for (const key of m.objects.keys())
|
|
71
|
+
if (key.endsWith(`.${ref}`))
|
|
72
|
+
return key;
|
|
73
|
+
return ref;
|
|
74
|
+
}
|
|
75
|
+
const scopeOf = (id, m) => m.objects.get(id)?.parent;
|
|
76
|
+
export function applyLens(m, opt) {
|
|
77
|
+
const o = typeof opt === 'string' ? { lens: opt } : opt;
|
|
78
|
+
const lens = o.lens;
|
|
79
|
+
// ---- the one set everything downstream depends on ----
|
|
80
|
+
const transitIds = [...m.objects.values()].filter(isTransit).map(x => x.id);
|
|
81
|
+
const matches = (id, name) => id === name || (m.objects.get(id)?.localId ?? '') === name;
|
|
82
|
+
/**
|
|
83
|
+
* A collapsed system is drawn as one box and everything inside it is folded
|
|
84
|
+
* away — this is the C1 ⇄ C2 move. Connections to its members re-point to the
|
|
85
|
+
* system itself, so the picture stays true: the outside world really does
|
|
86
|
+
* talk to that system, it just does not need to see through it right now.
|
|
87
|
+
*/
|
|
88
|
+
// The same move one level down folds a container to its C2 box (C2 ⇄ C3).
|
|
89
|
+
// Anything that has members can be folded; a leaf has nothing to hide.
|
|
90
|
+
const hasMembers = new Set();
|
|
91
|
+
for (const ob of m.objects.values())
|
|
92
|
+
if (ob.parent)
|
|
93
|
+
hasMembers.add(ob.parent);
|
|
94
|
+
const collapsedSystems = new Set();
|
|
95
|
+
for (const name of o.collapsed ?? [])
|
|
96
|
+
for (const [id, ob] of m.objects)
|
|
97
|
+
if (hasMembers.has(id) && (id === name || ob.localId === name))
|
|
98
|
+
collapsedSystems.add(id);
|
|
99
|
+
const foldInto = (id) => {
|
|
100
|
+
for (const sys of collapsedSystems)
|
|
101
|
+
if (id !== sys && id.startsWith(sys + '.'))
|
|
102
|
+
return sys;
|
|
103
|
+
return id;
|
|
104
|
+
};
|
|
105
|
+
const expanded = new Set(lens === 'infrastructure' ? transitIds : []);
|
|
106
|
+
for (const name of o.shown ?? [])
|
|
107
|
+
for (const id of transitIds)
|
|
108
|
+
if (matches(id, name))
|
|
109
|
+
expanded.add(id);
|
|
110
|
+
for (const name of o.hidden ?? [])
|
|
111
|
+
for (const id of transitIds)
|
|
112
|
+
if (matches(id, name))
|
|
113
|
+
expanded.delete(id);
|
|
114
|
+
// ---- phase: transit vs target on one model ----
|
|
115
|
+
const inPhase = (attrs) => !o.phase || !attrs['phase']?.length || attrs['phase'].includes(o.phase);
|
|
116
|
+
const offPhase = new Set();
|
|
117
|
+
if (o.phase)
|
|
118
|
+
for (const [id, ob] of m.objects)
|
|
119
|
+
if (!inPhase(ob.attrs))
|
|
120
|
+
offPhase.add(id);
|
|
121
|
+
const phased = (id) => { for (const off of offPhase)
|
|
122
|
+
if (id === off || id.startsWith(off + '.'))
|
|
123
|
+
return true; return false; };
|
|
124
|
+
const rels = m.relations.filter(r => inPhase(r.attrs)).map(r => ({
|
|
125
|
+
...r,
|
|
126
|
+
fromId: resolve(r.from, scopeOf(r.from, m) ?? r.from, m),
|
|
127
|
+
toId: resolve(r.to, m.objects.get(r.from) ? scopeOf(r.from, m) : undefined, m),
|
|
128
|
+
})).filter(r => !phased(r.fromId) && !phased(r.toId));
|
|
129
|
+
if (o.kind === 'deployment')
|
|
130
|
+
return deploymentGraph(m, rels, inPhase, phased, o.agents !== false);
|
|
131
|
+
const topics = new Map();
|
|
132
|
+
for (const [id, o] of m.objects)
|
|
133
|
+
if (o.kind === 'topic')
|
|
134
|
+
topics.set(id, o);
|
|
135
|
+
const hidden = new Set();
|
|
136
|
+
const edges = [];
|
|
137
|
+
const push = (rawFrom, rawTo, verb, label, derived) => {
|
|
138
|
+
const from = foldInto(rawFrom), to = foldInto(rawTo);
|
|
139
|
+
if (!from || !to || from === to)
|
|
140
|
+
return;
|
|
141
|
+
const folded = from !== rawFrom || to !== rawTo;
|
|
142
|
+
if (!folded && edges.some(e => e.from === from && e.to === to && e.verb === verb && e.label === label))
|
|
143
|
+
return;
|
|
144
|
+
edges.push({ from, to, verb, label, derived, dashed: derived, folded });
|
|
145
|
+
};
|
|
146
|
+
/**
|
|
147
|
+
* Relations that fold into a collapsed system become one aggregated, visibly
|
|
148
|
+
* derived edge per pair (§17.2): the shared verb when they agree, a count and
|
|
149
|
+
* no verb when they do not. A relation declared at that level stands on its
|
|
150
|
+
* own and takes the count.
|
|
151
|
+
*/
|
|
152
|
+
const aggregate = () => {
|
|
153
|
+
const out = [];
|
|
154
|
+
const groups = new Map();
|
|
155
|
+
for (const e of edges) {
|
|
156
|
+
if (!e.folded) {
|
|
157
|
+
out.push(e);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const k = `${e.from}\u0000${e.to}`;
|
|
161
|
+
(groups.get(k) ?? groups.set(k, []).get(k)).push(e);
|
|
162
|
+
}
|
|
163
|
+
for (const g of groups.values()) {
|
|
164
|
+
const { from, to } = g[0];
|
|
165
|
+
const explicit = out.find(e => e.from === from && e.to === to && !e.folded);
|
|
166
|
+
const n = g.length;
|
|
167
|
+
if (explicit) {
|
|
168
|
+
if (n > 1 || explicit.verb !== g[0].verb)
|
|
169
|
+
explicit.label = [explicit.label, `×${n} inside`].filter(Boolean).join(' · ');
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const verbs = new Set(g.map(e => e.verb));
|
|
173
|
+
if (verbs.size === 1)
|
|
174
|
+
out.push({ from, to, verb: g[0].verb, label: n > 1 ? `×${n}` : g[0].label, derived: true, dashed: true, folded: true });
|
|
175
|
+
else
|
|
176
|
+
out.push({ from, to, verb: 'relates', label: `${n} relations`, derived: true, dashed: true, folded: true });
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
};
|
|
180
|
+
for (const r of rels) {
|
|
181
|
+
const viaIds = (r.via ?? []).map(v => resolve(v, scopeOf(r.from, m), m));
|
|
182
|
+
const drawn = viaIds.filter(v => expanded.has(v));
|
|
183
|
+
const folded = viaIds.filter(v => !expanded.has(v));
|
|
184
|
+
// Hops that are expanded become real segments; the rest fold into the label.
|
|
185
|
+
// A chain can be partly expanded — that is the whole point of point overrides.
|
|
186
|
+
const foldedLabel = folded.length
|
|
187
|
+
? `via ${folded.map(v => m.objects.get(v)?.localId ?? v).join(' → ')}` : undefined;
|
|
188
|
+
const label = [r.label ?? r.over, foldedLabel].filter(Boolean).join(' · ') || undefined;
|
|
189
|
+
if (drawn.length) {
|
|
190
|
+
const hops = [r.fromId, ...drawn, r.toId];
|
|
191
|
+
for (let i = 0; i < hops.length - 1; i++)
|
|
192
|
+
push(hops[i], hops[i + 1], r.verb, i === 0 ? label : undefined, true);
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
push(r.fromId, r.toId, r.verb, label, false);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// ---- topics are channels, not boxes on a canvas ----
|
|
199
|
+
//
|
|
200
|
+
// A topic has a passport of its own, but on a C4 container diagram people draw
|
|
201
|
+
// either the broker between two services (infrastructure) or a single dashed
|
|
202
|
+
// arrow labelled with the event (logical). Drawing a topic box does neither,
|
|
203
|
+
// so the topic node is dropped from both lenses and its edges are derived.
|
|
204
|
+
for (const [tid, t] of topics) {
|
|
205
|
+
const pubs = new Set();
|
|
206
|
+
const subs = new Set();
|
|
207
|
+
for (const r of rels) {
|
|
208
|
+
if (r.toId !== tid)
|
|
209
|
+
continue;
|
|
210
|
+
if (r.verb === 'publishes')
|
|
211
|
+
pubs.add(r.fromId);
|
|
212
|
+
if (r.verb === 'subscribes')
|
|
213
|
+
subs.add(r.fromId);
|
|
214
|
+
}
|
|
215
|
+
for (const p of t.attrs['publisher'] ?? [])
|
|
216
|
+
pubs.add(resolve(p, t.parent, m));
|
|
217
|
+
for (const s of t.attrs['subscriber'] ?? [])
|
|
218
|
+
subs.add(resolve(s, t.parent, m));
|
|
219
|
+
const name = t.localId;
|
|
220
|
+
const brokers = (t.attrs['via'] ?? []).map(v => resolve(v, t.parent, m));
|
|
221
|
+
const liveBroker = brokers.find(b => expanded.has(b));
|
|
222
|
+
if (liveBroker) {
|
|
223
|
+
const b = liveBroker;
|
|
224
|
+
for (const p of pubs)
|
|
225
|
+
push(p, b, 'publishes', name, true);
|
|
226
|
+
for (const s of subs)
|
|
227
|
+
push(b, s, 'subscribes', name, true);
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
for (const p of pubs)
|
|
231
|
+
for (const s of subs)
|
|
232
|
+
push(p, s, 'publishes', name, true);
|
|
233
|
+
}
|
|
234
|
+
hidden.add(tid);
|
|
235
|
+
// the declared publish/subscribe edges into the topic are replaced by the above
|
|
236
|
+
for (let i = edges.length - 1; i >= 0; i--)
|
|
237
|
+
if (edges[i].to === tid || edges[i].from === tid)
|
|
238
|
+
edges.splice(i, 1);
|
|
239
|
+
}
|
|
240
|
+
{
|
|
241
|
+
// A transit node is often also a declared endpoint: `sales calls gw`.
|
|
242
|
+
// Collapsing it must therefore SPLICE it out — every in-edge is joined to
|
|
243
|
+
// every out-edge — not merely delete it, or the actors lose their arrows.
|
|
244
|
+
for (const id of transitIds)
|
|
245
|
+
if (!expanded.has(id))
|
|
246
|
+
hidden.add(id);
|
|
247
|
+
for (const h of hidden) {
|
|
248
|
+
const ins = edges.filter(e => e.to === h && e.from !== h);
|
|
249
|
+
const outs = edges.filter(e => e.from === h && e.to !== h);
|
|
250
|
+
for (let i = edges.length - 1; i >= 0; i--)
|
|
251
|
+
if (edges[i].from === h || edges[i].to === h)
|
|
252
|
+
edges.splice(i, 1);
|
|
253
|
+
const label = m.objects.get(h)?.localId;
|
|
254
|
+
for (const a of ins)
|
|
255
|
+
for (const b of outs)
|
|
256
|
+
push(a.from, b.to, b.verb, a.label ?? b.label ?? (label ? `via ${label}` : undefined), true);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// ---- build the node tree ----
|
|
260
|
+
const build = (parent) => {
|
|
261
|
+
const out = [];
|
|
262
|
+
for (const [id, o] of m.objects) {
|
|
263
|
+
if (o.parent !== parent)
|
|
264
|
+
continue;
|
|
265
|
+
if (hidden.has(id) || NOT_DRAWN.has(o.kind) || offPhase.has(id))
|
|
266
|
+
continue;
|
|
267
|
+
if (o.attrs['stage']?.[0] === 'retired')
|
|
268
|
+
continue; // kept in the model, off the picture (§17.4)
|
|
269
|
+
if (PLACEMENT_KINDS.has(o.kind))
|
|
270
|
+
continue; // where things run is the deployment picture
|
|
271
|
+
if (foldInto(id) !== id)
|
|
272
|
+
continue; // folded into a collapsed system
|
|
273
|
+
const kids = collapsedSystems.has(id) ? [] : build(id);
|
|
274
|
+
if (TRANSPARENT.has(o.kind)) {
|
|
275
|
+
out.push(...kids);
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
const folded = collapsedSystems.has(id) ? [...m.objects.keys()].filter(k => k.startsWith(id + '.') && !PLACEMENT_KINDS.has(m.objects.get(k).kind)).length : 0;
|
|
279
|
+
out.push({
|
|
280
|
+
id, kind: o.kind,
|
|
281
|
+
label: o.name ?? o.localId,
|
|
282
|
+
tech: o.attrs['tech']?.join(' '),
|
|
283
|
+
meta: [metaLine(o.attrs), folded ? `${folded} inside` : ''].filter(Boolean).join(' · ') || undefined,
|
|
284
|
+
phase: o.attrs['phase']?.[0],
|
|
285
|
+
stage: o.attrs['stage']?.[0],
|
|
286
|
+
folded: folded || undefined,
|
|
287
|
+
transit: isTransit(o),
|
|
288
|
+
ownedBy: o.attrs['owned_by']?.[0] ? resolve(o.attrs['owned_by'][0], o.parent, m) : undefined,
|
|
289
|
+
external: o.kind === 'external' || o.kind === 'actor',
|
|
290
|
+
children: BOUNDARY.has(o.kind) ? kids : kids.filter(k => k.kind === 'component'),
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
return out;
|
|
294
|
+
};
|
|
295
|
+
const roots = build(undefined);
|
|
296
|
+
const alive = new Set();
|
|
297
|
+
const mark = (ns) => ns.forEach(n => { alive.add(n.id); mark(n.children); });
|
|
298
|
+
mark(roots);
|
|
299
|
+
return { roots, edges: aggregate().filter(e => alive.has(e.from) && alive.has(e.to)) };
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* The deployment picture (spec §16.1 `kind deployment`): environments hold
|
|
303
|
+
* segments, clusters, managed services and nodes; each `run` puts a card for
|
|
304
|
+
* the logical object inside its host. The object is referenced, not copied —
|
|
305
|
+
* the same service running in two environments is two cards with one `ref`.
|
|
306
|
+
* Relations between placed objects are drawn between their cards when both
|
|
307
|
+
* sit in the same environment.
|
|
308
|
+
*/
|
|
309
|
+
function deploymentGraph(m, rels, inPhase, phased, withAgents = true) {
|
|
310
|
+
const cards = new Map();
|
|
311
|
+
const envOf = (id) => {
|
|
312
|
+
let cur = id;
|
|
313
|
+
while (cur) {
|
|
314
|
+
const ob = m.objects.get(cur);
|
|
315
|
+
if (!ob?.parent)
|
|
316
|
+
return cur;
|
|
317
|
+
cur = ob.parent;
|
|
318
|
+
}
|
|
319
|
+
return id;
|
|
320
|
+
};
|
|
321
|
+
const build = (parent) => {
|
|
322
|
+
const out = [];
|
|
323
|
+
for (const [id, ob] of m.objects) {
|
|
324
|
+
if (ob.parent !== parent || !PLACEMENT_KINDS.has(ob.kind) || phased(id))
|
|
325
|
+
continue;
|
|
326
|
+
const kids = build(id);
|
|
327
|
+
// objects that run here
|
|
328
|
+
for (const p of m.placements) {
|
|
329
|
+
if (p.host !== id || !inPhase(p.attrs))
|
|
330
|
+
continue;
|
|
331
|
+
const refId = resolve(p.ref, undefined, m);
|
|
332
|
+
const logical = m.objects.get(refId);
|
|
333
|
+
if (!logical || phased(refId))
|
|
334
|
+
continue;
|
|
335
|
+
const cid = `${id}/${refId}`;
|
|
336
|
+
cards.set(cid, { id: cid, ref: refId, env: envOf(id) });
|
|
337
|
+
kids.push({
|
|
338
|
+
id: cid, kind: logical.kind, ref: refId,
|
|
339
|
+
label: logical.name ?? logical.localId,
|
|
340
|
+
tech: logical.attrs['tech']?.join(' '),
|
|
341
|
+
meta: metaLine(effectiveSizing(m, p)),
|
|
342
|
+
phase: p.attrs['phase']?.[0] ?? logical.attrs['phase']?.[0],
|
|
343
|
+
transit: false, external: logical.kind === 'external', children: [],
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
const load = hostLoad(m, id);
|
|
347
|
+
const fmtG = (n) => (Number.isInteger(n) ? String(n) : (Math.round(n * 10) / 10).toString());
|
|
348
|
+
out.push({
|
|
349
|
+
id, kind: ob.kind, label: ob.name ?? ob.localId,
|
|
350
|
+
tech: ob.attrs['tech']?.join(' '), meta: metaLine(ob.attrs), phase: ob.attrs['phase']?.[0],
|
|
351
|
+
load: load ? [load.cpu ? `cpu ${fmtG(load.cpu[0])}/${fmtG(load.cpu[1])}` : '', load.mem ? `mem ${fmtG(load.mem[0])}/${fmtG(load.mem[1])}Gi` : '']
|
|
352
|
+
.filter(Boolean).join(' · ') + (load.over ? ' ⚠ over' : '') : undefined,
|
|
353
|
+
over: load?.over,
|
|
354
|
+
agents: withAgents && ob.attrs['agent']?.length ? ob.attrs['agent'] : undefined,
|
|
355
|
+
transit: false, external: false, children: kids,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
return out;
|
|
359
|
+
};
|
|
360
|
+
const roots = build(undefined);
|
|
361
|
+
const edges = [];
|
|
362
|
+
const byRef = new Map();
|
|
363
|
+
for (const c of cards.values())
|
|
364
|
+
(byRef.get(c.ref) ?? byRef.set(c.ref, []).get(c.ref)).push(c);
|
|
365
|
+
for (const r of rels) {
|
|
366
|
+
for (const a of byRef.get(r.fromId) ?? [])
|
|
367
|
+
for (const b of byRef.get(r.toId) ?? []) {
|
|
368
|
+
if (a.env !== b.env || a.id === b.id)
|
|
369
|
+
continue;
|
|
370
|
+
const label = [r.label ?? r.over, r.port ? `:${r.port}` : undefined].filter(Boolean).join(' ') || undefined;
|
|
371
|
+
if (edges.some(e => e.from === a.id && e.to === b.id && e.verb === r.verb))
|
|
372
|
+
continue;
|
|
373
|
+
edges.push({ from: a.id, to: b.id, verb: r.verb, label, derived: true, dashed: false });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return { roots, edges };
|
|
377
|
+
}
|