pi-openui 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.
@@ -0,0 +1,444 @@
1
+ // InfraMap(zones, links, direction?) -> DiagramResult.
2
+ // Zones are columns (LR) or rows (TB) that hold stacked nodes. Links are routed wires with a dot at
3
+ // the source, an arrowhead at the target and an optional label. Never throws on bad input.
4
+
5
+ import { Canvas, oneLine, textWidth, truncateText, type Rect } from "./canvas.ts";
6
+ import { paintBox } from "./dag.ts";
7
+ import type { DiagramResult, LegendEntry, PlacedEdge, PlacedNode } from "./layeredDiagram.ts";
8
+ import { budgeted, Router, type RouteReq, type Routed } from "./router.ts";
9
+ import { drawWires, type WireSpec } from "./wires.ts";
10
+
11
+ export const INFRA_KINDS = ["client", "gateway", "service", "gpu", "database", "storage", "queue", "cloud"] as const;
12
+ export const INFRA_STATUSES = ["up", "degraded", "down", "unknown"] as const;
13
+ export const INFRA_TONES = ["neutral", "info", "good", "warn", "bad"] as const;
14
+ export const KIND_ICON: Record<string, string> = { client: "□", gateway: "↔", service: "◇", gpu: "⌸", database: "◎", storage: "☰", queue: "≡", cloud: "◌" };
15
+ const STATUS_GLYPH: Record<string, string> = { up: "●", degraded: "●", down: "●", unknown: "○" };
16
+
17
+ export interface InfraNodeIn {
18
+ id: string;
19
+ label?: string;
20
+ kind?: string | null;
21
+ status?: string | null;
22
+ detail?: string | null;
23
+ }
24
+ export interface InfraZoneIn {
25
+ name: string;
26
+ nodes?: unknown;
27
+ tone?: string | null;
28
+ }
29
+ export interface InfraLinkIn {
30
+ from: string;
31
+ to: string;
32
+ label?: string | null;
33
+ style?: string | null;
34
+ }
35
+ export interface InfraOptions {
36
+ direction?: "LR" | "TB" | null;
37
+ maxWidth?: number;
38
+ }
39
+
40
+ interface INode {
41
+ id: string;
42
+ label: string;
43
+ kind: string | null;
44
+ status: string | null;
45
+ detail: string;
46
+ zone: number;
47
+ }
48
+ interface IZone {
49
+ name: string;
50
+ tone: string;
51
+ nodes: number[];
52
+ }
53
+ interface NormInfra {
54
+ zones: IZone[];
55
+ nodes: INode[];
56
+ links: Array<{ a: number; b: number; label?: string; dashed: boolean }>;
57
+ warnings: string[];
58
+ }
59
+
60
+ export function normalizeInfra(zonesIn: unknown, linksIn: unknown): NormInfra {
61
+ const warnings: string[] = [];
62
+ const zones: IZone[] = [];
63
+ const nodes: INode[] = [];
64
+ const ids = new Map<string, number>();
65
+ for (const rawZone of Array.isArray(zonesIn) ? zonesIn : []) {
66
+ const z = (rawZone ?? {}) as InfraZoneIn;
67
+ const name = oneLine(z.name ?? "");
68
+ let tone = "neutral";
69
+ if (z.tone != null && z.tone !== "") {
70
+ if ((INFRA_TONES as readonly string[]).includes(String(z.tone))) tone = String(z.tone);
71
+ else warnings.push(`unknown zone tone "${z.tone}" ignored`);
72
+ }
73
+ const zi = zones.length;
74
+ const zone: IZone = { name: name || `zone ${zi + 1}`, tone, nodes: [] };
75
+ for (const rawNode of Array.isArray(z.nodes) ? z.nodes : []) {
76
+ const n = (rawNode ?? {}) as InfraNodeIn;
77
+ const id = n.id == null ? "" : String(n.id);
78
+ if (!id) {
79
+ warnings.push("a node without an id was skipped");
80
+ continue;
81
+ }
82
+ if (ids.has(id)) {
83
+ warnings.push(`duplicate node id "${id}" ignored`);
84
+ continue;
85
+ }
86
+ let kind: string | null = null;
87
+ if (n.kind != null && n.kind !== "") {
88
+ if ((INFRA_KINDS as readonly string[]).includes(String(n.kind))) kind = String(n.kind);
89
+ else warnings.push(`unknown kind "${n.kind}" on "${id}" ignored`);
90
+ }
91
+ let status: string | null = null;
92
+ if (n.status != null && n.status !== "") {
93
+ if ((INFRA_STATUSES as readonly string[]).includes(String(n.status))) status = String(n.status);
94
+ else warnings.push(`unknown status "${n.status}" on "${id}" ignored`);
95
+ }
96
+ ids.set(id, nodes.length);
97
+ zone.nodes.push(nodes.length);
98
+ nodes.push({ id, label: oneLine(n.label ?? id) || id, kind, status, detail: n.detail ? oneLine(n.detail) : "", zone: zi });
99
+ }
100
+ if (!zone.nodes.length) {
101
+ warnings.push(`zone "${zone.name}" has no nodes and was skipped`);
102
+ continue;
103
+ }
104
+ zones.push(zone);
105
+ }
106
+ // zone indexes may have shifted when empty zones were skipped
107
+ zones.forEach((z, zi) => z.nodes.forEach((ni) => (nodes[ni].zone = zi)));
108
+ const links: NormInfra["links"] = [];
109
+ const seen = new Set<string>();
110
+ for (const raw of Array.isArray(linksIn) ? linksIn : []) {
111
+ const l = (raw ?? {}) as InfraLinkIn;
112
+ const a = ids.get(String(l.from));
113
+ const b = ids.get(String(l.to));
114
+ if (a === undefined || b === undefined) {
115
+ warnings.push(`link ${String(l.from)} -> ${String(l.to)} names an unknown node and was skipped`);
116
+ continue;
117
+ }
118
+ if (a === b) {
119
+ warnings.push(`link from "${nodes[a].id}" to itself was skipped`);
120
+ continue;
121
+ }
122
+ const label = l.label ? oneLine(l.label) : undefined;
123
+ const dashed = l.style === "dashed";
124
+ const key = `${a}>${b}>${label ?? ""}>${dashed}`;
125
+ if (seen.has(key)) continue;
126
+ seen.add(key);
127
+ links.push({ a, b, label, dashed });
128
+ }
129
+ return { zones, nodes, links, warnings };
130
+ }
131
+
132
+ interface Caps {
133
+ label: number;
134
+ detail: number;
135
+ link: number;
136
+ }
137
+
138
+ const MARGIN = 3;
139
+
140
+ interface Laid {
141
+ canvas: Canvas;
142
+ nodes: PlacedNode[];
143
+ zones: Array<{ name: string; tone: string; rect: Rect }>;
144
+ edges: PlacedEdge[];
145
+ failed: number;
146
+ forced: number;
147
+ footnotes: string[];
148
+ omitted: Array<{ from: string; to: string; label?: string }>;
149
+ }
150
+
151
+ function layout(norm: NormInfra, dir: "LR" | "TB", caps: Caps, spacing: number, relax: boolean): Laid | { failed: number } {
152
+ const TB = dir === "TB";
153
+ const n = norm.nodes.length;
154
+ // node content
155
+ const shown = norm.nodes.map((nd) => {
156
+ const label = truncateText(nd.label, caps.label);
157
+ const detail = nd.detail ? truncateText(nd.detail, caps.detail) : "";
158
+ const icon = nd.kind ? KIND_ICON[nd.kind] : "";
159
+ const l1 = (icon ? 2 : 0) + textWidth(label) + (nd.status ? 2 : 0);
160
+ const inner = Math.max(l1, textWidth(detail));
161
+ return { label, detail, icon, w: inner + 4, h: detail ? 4 : 3 };
162
+ });
163
+ // uniform node size per zone along the flow axis
164
+ const nodeW = new Array<number>(n).fill(0);
165
+ const nodeH = new Array<number>(n).fill(0);
166
+ for (const z of norm.zones) {
167
+ const maxW = Math.max(...z.nodes.map((i) => shown[i].w));
168
+ const maxH = Math.max(...z.nodes.map((i) => shown[i].h));
169
+ for (const i of z.nodes) {
170
+ nodeW[i] = TB ? shown[i].w : maxW;
171
+ nodeH[i] = TB ? maxH : shown[i].h;
172
+ }
173
+ }
174
+ const padF = TB ? 2 : 3;
175
+ const padC = TB ? 3 : 2;
176
+ const gapC = (TB ? 4 : 3) + spacing;
177
+ // gap after the k-th node of a zone along the cross axis: wide enough for a label of a link between neighbours (TB)
178
+ const gapAfter = (zi: number, k: number): number => {
179
+ const z = norm.zones[zi];
180
+ if (!TB || k + 1 >= z.nodes.length) return gapC;
181
+ const a = z.nodes[k];
182
+ const b = z.nodes[k + 1];
183
+ let need = 0;
184
+ for (const l of norm.links) if (l.label && ((l.a === a && l.b === b) || (l.a === b && l.b === a))) need = Math.max(need, Math.min(textWidth(l.label), caps.link));
185
+ return Math.max(gapC, need ? need + 4 : 0);
186
+ };
187
+ // zone sizes
188
+ const zoneF: number[] = [];
189
+ const zoneC: number[] = [];
190
+ norm.zones.forEach((z, zi) => {
191
+ const fs = Math.max(...z.nodes.map((i) => (TB ? nodeH[i] : nodeW[i])));
192
+ const cs = z.nodes.reduce((acc, i, k) => acc + (TB ? nodeW[i] : nodeH[i]) + (k ? gapAfter(zi, k - 1) : 0), 0);
193
+ zoneF.push(fs + 2 * padF);
194
+ zoneC.push(cs + 2 * padC);
195
+ });
196
+ // make sure a zone is wide enough for its caption
197
+ norm.zones.forEach((z, zi) => {
198
+ const need = textWidth(z.name) + 6;
199
+ if (TB) zoneC[zi] = Math.max(zoneC[zi], need);
200
+ else zoneF[zi] = Math.max(zoneF[zi], need);
201
+ });
202
+ // channels between zones
203
+ const labelNeed = norm.zones.map(() => 0);
204
+ for (const l of norm.links) {
205
+ const za = norm.nodes[l.a].zone;
206
+ const zb = norm.nodes[l.b].zone;
207
+ if (l.label && Math.abs(za - zb) === 1) {
208
+ const k = Math.min(za, zb);
209
+ labelNeed[k] = Math.max(labelNeed[k], Math.min(textWidth(l.label), caps.link));
210
+ }
211
+ }
212
+ const chan = (zi: number) => (TB ? 5 + spacing * 2 : 5 + Math.max(0, labelNeed[zi] - 4) + spacing * 2);
213
+ const F: number[] = [];
214
+ let pos = MARGIN;
215
+ norm.zones.forEach((_, zi) => {
216
+ F.push(pos);
217
+ pos += zoneF[zi] + chan(zi);
218
+ });
219
+ const zoneRect: Rect[] = norm.zones.map((_, zi) => (TB ? { x: MARGIN, y: F[zi], w: zoneC[zi], h: zoneF[zi] } : { x: F[zi], y: MARGIN, w: zoneF[zi], h: zoneC[zi] }));
220
+ const rect: Rect[] = new Array(n);
221
+ norm.zones.forEach((z, zi) => {
222
+ let c = padC;
223
+ z.nodes.forEach((i, k) => {
224
+ const zr = zoneRect[zi];
225
+ rect[i] = TB ? { x: zr.x + c, y: zr.y + padF, w: nodeW[i], h: nodeH[i] } : { x: zr.x + padF, y: zr.y + c, w: nodeW[i], h: nodeH[i] };
226
+ c += (TB ? nodeW[i] : nodeH[i]) + gapAfter(zi, k);
227
+ });
228
+ });
229
+ let W = 0;
230
+ let H = 0;
231
+ for (const r of [...zoneRect, ...rect]) {
232
+ W = Math.max(W, r.x + r.w);
233
+ H = Math.max(H, r.y + r.h);
234
+ }
235
+ W += MARGIN;
236
+ H += MARGIN;
237
+ const router = new Router(W, H);
238
+ for (const zr of zoneRect) router.addZoneBorder(zr);
239
+ rect.forEach((r, i) => router.addSolid(r, i));
240
+
241
+ // links
242
+ const center = (r: Rect) => ({ x: r.x + (r.w - 1) / 2, y: r.y + (r.h - 1) / 2 });
243
+ const order = norm.links
244
+ .map((_, i) => i)
245
+ .sort((p, q) => {
246
+ const dp = Math.abs(center(rect[norm.links[p].a]).x - center(rect[norm.links[p].b]).x) + Math.abs(center(rect[norm.links[p].a]).y - center(rect[norm.links[p].b]).y);
247
+ const dq = Math.abs(center(rect[norm.links[q].a]).x - center(rect[norm.links[q].b]).x) + Math.abs(center(rect[norm.links[q].a]).y - center(rect[norm.links[q].b]).y);
248
+ return dp - dq || p - q;
249
+ });
250
+ const routed = new Map<number, Routed>();
251
+ let failed = 0;
252
+ const forced = 0;
253
+ const omitted: Laid["omitted"] = [];
254
+ for (const li of order) {
255
+ const l = norm.links[li];
256
+ const za = norm.nodes[l.a].zone;
257
+ const zb = norm.nodes[l.b].zone;
258
+ // preferred sides: 0 for the side facing the other node, 120 for the sides next to it, 300 for the far side
259
+ let outSide: number;
260
+ let inSide: number;
261
+ if (za === zb) {
262
+ const ca = center(rect[l.a]);
263
+ const cb = center(rect[l.b]);
264
+ if (TB) {
265
+ outSide = cb.x >= ca.x ? 1 : 3;
266
+ inSide = cb.x >= ca.x ? 3 : 1;
267
+ } else {
268
+ outSide = cb.y >= ca.y ? 2 : 0;
269
+ inSide = cb.y >= ca.y ? 0 : 2;
270
+ }
271
+ } else {
272
+ const forward = zb > za;
273
+ if (TB) {
274
+ outSide = forward ? 2 : 0;
275
+ inSide = forward ? 0 : 2;
276
+ } else {
277
+ outSide = forward ? 1 : 3;
278
+ inSide = forward ? 3 : 1;
279
+ }
280
+ }
281
+ const pens = (side: number) => [0, 1, 2, 3].map((s) => (s === side ? 0 : s === (side + 2) % 4 ? 300 : 120));
282
+ const req: RouteReq = { id: li, from: rect[l.a], to: rect[l.b], fromId: l.a, toId: l.b, fromPen: pens(outSide), toPen: pens(inSide), waypoints: [] };
283
+ const r = router.route(req, false);
284
+ if (!r.ok) {
285
+ if (!relax) return { failed: failed + 1 };
286
+ failed++;
287
+ omitted.push({ from: norm.nodes[l.a].id, to: norm.nodes[l.b].id, label: l.label });
288
+ continue;
289
+ }
290
+ router.commit(r);
291
+ routed.set(li, r);
292
+ }
293
+
294
+ // raster
295
+ const canvas = new Canvas(W, H);
296
+ norm.zones.forEach((z, zi) => {
297
+ const zr = zoneRect[zi];
298
+ const role = `zone.${z.tone}`;
299
+ canvas.set(zr.x, zr.y, "┌", role);
300
+ canvas.set(zr.x + zr.w - 1, zr.y, "┐", role);
301
+ canvas.set(zr.x, zr.y + zr.h - 1, "└", role);
302
+ canvas.set(zr.x + zr.w - 1, zr.y + zr.h - 1, "┘", role);
303
+ for (let x = zr.x + 1; x < zr.x + zr.w - 1; x++) {
304
+ canvas.set(x, zr.y, "─", role);
305
+ canvas.set(x, zr.y + zr.h - 1, "─", role);
306
+ }
307
+ for (let y = zr.y + 1; y < zr.y + zr.h - 1; y++) {
308
+ canvas.set(zr.x, y, "│", role);
309
+ canvas.set(zr.x + zr.w - 1, y, "│", role);
310
+ }
311
+ canvas.text(zr.x + 2, zr.y, ` ${truncateText(z.name, zr.w - 6)} `, `zone.title.${z.tone}`);
312
+ });
313
+ norm.nodes.forEach((nd, i) => {
314
+ const r = rect[i];
315
+ const sh = shown[i];
316
+ const bad = nd.status === "degraded" || nd.status === "down" || nd.status === "unknown";
317
+ paintBox(canvas, r, bad ? `box.${nd.status}` : "box", nd.status === "unknown");
318
+ const y = r.y + 1;
319
+ let x = r.x + 2;
320
+ if (sh.icon) {
321
+ canvas.set(x, y, sh.icon, `icon.${nd.kind}`);
322
+ x += 2;
323
+ }
324
+ canvas.text(x, y, sh.label, "label");
325
+ if (nd.status) canvas.set(r.x + r.w - 3, y, STATUS_GLYPH[nd.status], `status.${nd.status}`);
326
+ if (sh.detail) canvas.text(r.x + 2, y + 1, sh.detail, "detail");
327
+ });
328
+ const wires: WireSpec[] = [];
329
+ for (const [li, r] of routed) {
330
+ const l = norm.links[li];
331
+ wires.push({ id: li, steps: r.steps, dashed: l.dashed, startMark: "dot", endMark: "arrow", label: l.label ? truncateText(l.label, caps.link) : undefined, desc: `${norm.nodes[l.a].id} -> ${norm.nodes[l.b].id}`, role: "wire" });
332
+ }
333
+ const { footnotes, labelCells } = drawWires(canvas, wires, (x, y) => router.solid[y * W + x] === 1);
334
+
335
+ // crop
336
+ let minX = W;
337
+ let minY = H;
338
+ let maxX = -1;
339
+ let maxY = -1;
340
+ for (let y = 0; y < H; y++)
341
+ for (let x = 0; x < W; x++)
342
+ if (canvas.getCh(x, y) !== " " || canvas.getRole(x, y) !== "") {
343
+ minX = Math.min(minX, x);
344
+ maxX = Math.max(maxX, x);
345
+ minY = Math.min(minY, y);
346
+ maxY = Math.max(maxY, y);
347
+ }
348
+ const out = new Canvas(maxX - minX + 1, maxY - minY + 1);
349
+ for (let y = 0; y < out.h; y++) for (let x = 0; x < out.w; x++) out.set(x, y, canvas.getCh(x + minX, y + minY), canvas.getRole(x + minX, y + minY));
350
+ const sh = (r: Rect): Rect => ({ x: r.x - minX, y: r.y - minY, w: r.w, h: r.h });
351
+ const nodes: PlacedNode[] = norm.nodes.map((nd, i) => ({ id: nd.id, index: i, label: nd.label, status: nd.status, rect: sh(rect[i]) }));
352
+ const edges: PlacedEdge[] = [...routed.entries()]
353
+ .sort((a, b) => a[0] - b[0])
354
+ .map(([li, r]) => ({
355
+ index: li,
356
+ from: norm.nodes[norm.links[li].a].id,
357
+ to: norm.nodes[norm.links[li].b].id,
358
+ reversed: false,
359
+ ok: true,
360
+ forced: r.forced,
361
+ label: norm.links[li].label,
362
+ cells: r.steps.map((s) => [s.x - minX, s.y - minY] as [number, number]),
363
+ labelCells: (labelCells.get(li) ?? []).map(([x, y]) => [x - minX, y - minY] as [number, number]),
364
+ }));
365
+ for (const o of omitted) footnotes.push(`${o.from} -> ${o.to}${o.label ? ` (${o.label})` : ""} (not drawn)`);
366
+ return { canvas: out, nodes, zones: norm.zones.map((z, zi) => ({ name: z.name, tone: z.tone, rect: sh(zoneRect[zi]) })), edges, failed, forced, footnotes, omitted };
367
+ }
368
+
369
+ function build(norm: NormInfra, dir: "LR" | "TB", caps: Caps): DiagramResult {
370
+ let laid: Laid | null = null;
371
+ for (const spacing of [0, 1, 2, 4, 7]) {
372
+ const r = layout(norm, dir, caps, spacing, false);
373
+ if ("canvas" in r) {
374
+ laid = r;
375
+ break;
376
+ }
377
+ }
378
+ if (!laid) {
379
+ const r = layout(norm, dir, caps, 4, true);
380
+ if ("canvas" in r) laid = r;
381
+ }
382
+ if (!laid) throw new Error("infra: could not place diagram");
383
+ const used = INFRA_STATUSES.filter((s) => norm.nodes.some((nd) => nd.status === s));
384
+ const legend: LegendEntry[] = used.map((s) => ({ glyph: STATUS_GLYPH[s], role: `status.${s}`, text: s }));
385
+ return {
386
+ kind: "infra",
387
+ direction: dir,
388
+ width: laid.canvas.w,
389
+ height: laid.canvas.h,
390
+ rows: laid.canvas.rows(),
391
+ nodes: laid.nodes,
392
+ edges: laid.edges,
393
+ warnings: [],
394
+ footnotes: laid.footnotes,
395
+ degraded: laid.omitted.length > 0,
396
+ overflow: false,
397
+ statuses: used,
398
+ legend,
399
+ zones: laid.zones,
400
+ omitted: laid.omitted,
401
+ };
402
+ }
403
+
404
+ const CAPS: Caps[] = [
405
+ { label: 22, detail: 26, link: 18 },
406
+ { label: 16, detail: 18, link: 12 },
407
+ { label: 12, detail: 12, link: 9 },
408
+ { label: 8, detail: 8, link: 6 },
409
+ ];
410
+
411
+ export function infra(zonesIn: unknown, linksIn: unknown, opts: InfraOptions = {}): DiagramResult {
412
+ return budgeted(() => infraLayout(zonesIn, linksIn, opts));
413
+ }
414
+
415
+ function infraLayout(zonesIn: unknown, linksIn: unknown, opts: InfraOptions): DiagramResult {
416
+ const norm = normalizeInfra(zonesIn, linksIn);
417
+ const maxWidth = Math.max(20, opts.maxWidth ?? 100);
418
+ if (!norm.nodes.length) {
419
+ return { kind: "infra", direction: "LR", width: 0, height: 0, rows: [], nodes: [], edges: [], warnings: [...norm.warnings, "the map has no nodes"], footnotes: [], degraded: false, overflow: false, statuses: [], legend: [], zones: [] };
420
+ }
421
+ const dirs: Array<"LR" | "TB"> = opts.direction === "LR" || opts.direction === "TB" ? [opts.direction] : ["LR", "TB"];
422
+ let best: DiagramResult | null = null;
423
+ let chosen: DiagramResult | null = null;
424
+ for (const caps of CAPS) {
425
+ for (const d of dirs) {
426
+ const r = build(norm, d, caps);
427
+ if (!best || r.width < best.width) best = r;
428
+ if (r.width <= maxWidth) {
429
+ chosen = r;
430
+ break;
431
+ }
432
+ }
433
+ if (chosen) break;
434
+ }
435
+ if (!chosen) {
436
+ const tries = dirs.map((d) => build(norm, d, CAPS[1]));
437
+ best = tries.reduce((a, b) => (b.width < a.width ? b : a));
438
+ }
439
+ const res = chosen ?? best!;
440
+ res.overflow = !chosen;
441
+ res.warnings = [...norm.warnings];
442
+ if (res.degraded) res.warnings.push("some links could not be routed cleanly");
443
+ return res;
444
+ }