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.
- package/CHANGELOG.md +35 -0
- package/LICENSE +21 -0
- package/README.md +200 -2
- package/package.json +54 -4
- package/src/blocks/braille.ts +121 -0
- package/src/blocks/bubble.ts +30 -0
- package/src/blocks/charts.ts +370 -0
- package/src/blocks/code.ts +101 -0
- package/src/blocks/core.ts +343 -0
- package/src/blocks/diagrams.ts +142 -0
- package/src/blocks/geo.ts +206 -0
- package/src/blocks/interact.ts +252 -0
- package/src/blocks/markdown.ts +135 -0
- package/src/blocks/notes.ts +277 -0
- package/src/blocks/render.ts +148 -0
- package/src/blocks/sequence.ts +266 -0
- package/src/blocks/table.ts +70 -0
- package/src/blocks/types.ts +126 -0
- package/src/blocks/util.ts +171 -0
- package/src/data/land.ts +3 -0
- package/src/data/sample-image.ts +67 -0
- package/src/diagram/canvas.ts +182 -0
- package/src/diagram/dag.ts +174 -0
- package/src/diagram/decode.ts +241 -0
- package/src/diagram/erd.ts +190 -0
- package/src/diagram/infra.ts +444 -0
- package/src/diagram/layered.ts +334 -0
- package/src/diagram/layeredDiagram.ts +350 -0
- package/src/diagram/palette.ts +209 -0
- package/src/diagram/render.ts +60 -0
- package/src/diagram/router.ts +402 -0
- package/src/diagram/wires.ts +271 -0
- package/src/lang/docs.ts +575 -0
- package/src/lang/library.ts +613 -0
- package/src/lang/md.ts +47 -0
- package/src/lang/parse.ts +230 -0
- package/src/lang/samples.ts +227 -0
- package/src/pi/extension.ts +190 -0
- package/src/pi/images.ts +185 -0
- package/src/pi/widget.ts +562 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// Test oracle: recover the wires of a rendered diagram from its glyphs alone.
|
|
2
|
+
// It shares no code with the router: it only looks at DiagramResult.rows and the node rectangles.
|
|
3
|
+
|
|
4
|
+
import type { DiagramResult } from "./layeredDiagram.ts";
|
|
5
|
+
import { MANY_END, ONE_END } from "./wires.ts";
|
|
6
|
+
|
|
7
|
+
const DX = [0, 1, 0, -1];
|
|
8
|
+
const DY = [-1, 0, 1, 0];
|
|
9
|
+
const OPP = [2, 3, 0, 1];
|
|
10
|
+
const BIT = [1, 2, 4, 8];
|
|
11
|
+
|
|
12
|
+
const CONN: Record<string, number> = {
|
|
13
|
+
"│": 5,
|
|
14
|
+
"┆": 5,
|
|
15
|
+
"─": 10,
|
|
16
|
+
"┄": 10,
|
|
17
|
+
"╭": 6,
|
|
18
|
+
"╮": 12,
|
|
19
|
+
"╰": 3,
|
|
20
|
+
"╯": 9,
|
|
21
|
+
"┼": 15,
|
|
22
|
+
};
|
|
23
|
+
const ARROW_POINTS: Record<string, number> = { "▲": 0, "▶": 1, "▼": 2, "◀": 3 };
|
|
24
|
+
const JUNCTION_SIDE: Record<string, number> = { "┴": 0, "├": 1, "┬": 2, "┤": 3 }; // outward normal of the border cell
|
|
25
|
+
|
|
26
|
+
export type EndKind = "attach" | "arrow" | "dot" | "one" | "many";
|
|
27
|
+
|
|
28
|
+
export interface WireEnd {
|
|
29
|
+
node: number;
|
|
30
|
+
side: number;
|
|
31
|
+
kind: EndKind;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DecodedWire {
|
|
35
|
+
a: WireEnd;
|
|
36
|
+
b: WireEnd;
|
|
37
|
+
cells: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Decoded {
|
|
41
|
+
wires: DecodedWire[];
|
|
42
|
+
/** wire-like cells that no walk reached */
|
|
43
|
+
stray: Array<[number, number]>;
|
|
44
|
+
/** walks that ended somewhere that is not a box */
|
|
45
|
+
broken: number;
|
|
46
|
+
/** where each broken walk started and stopped */
|
|
47
|
+
brokenInfo: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function decodeWires(res: DiagramResult): Decoded {
|
|
51
|
+
const rows = res.rows;
|
|
52
|
+
const H = rows.length;
|
|
53
|
+
const W = H ? rows[0].length : 0;
|
|
54
|
+
const at = (x: number, y: number) => (x >= 0 && y >= 0 && x < W && y < H ? rows[y][x] : { ch: " ", role: "" });
|
|
55
|
+
const nodeAt = (x: number, y: number): number => {
|
|
56
|
+
for (let i = 0; i < res.nodes.length; i++) {
|
|
57
|
+
const r = res.nodes[i].rect;
|
|
58
|
+
if (x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h) return i;
|
|
59
|
+
}
|
|
60
|
+
return -1;
|
|
61
|
+
};
|
|
62
|
+
const visited = new Set<number>();
|
|
63
|
+
const wires: Array<{ key: string; wire: DecodedWire }> = [];
|
|
64
|
+
let broken = 0;
|
|
65
|
+
const brokenInfo: string[] = [];
|
|
66
|
+
|
|
67
|
+
type Start = { end: WireEnd; x: number; y: number; px: number; py: number };
|
|
68
|
+
const starts: Start[] = [];
|
|
69
|
+
res.nodes.forEach((n, ni) => {
|
|
70
|
+
const r = n.rect;
|
|
71
|
+
for (let side = 0; side < 4; side++) {
|
|
72
|
+
const cells: Array<[number, number]> = [];
|
|
73
|
+
if (side === 0 || side === 2) for (let x = r.x + 1; x <= r.x + r.w - 2; x++) cells.push([x, side === 0 ? r.y - 1 : r.y + r.h]);
|
|
74
|
+
else for (let y = r.y + 1; y <= r.y + r.h - 2; y++) cells.push([side === 1 ? r.x + r.w : r.x - 1, y]);
|
|
75
|
+
for (const [x, y] of cells) {
|
|
76
|
+
const c = at(x, y);
|
|
77
|
+
if (c.role === "arrow" && ARROW_POINTS[c.ch] === OPP[side]) starts.push({ end: { node: ni, side, kind: "arrow" }, x, y, px: x, py: y });
|
|
78
|
+
else if (c.role === "mark" && (c.ch === "●" || c.ch === ONE_END[OPP[side]] || c.ch === MANY_END[OPP[side]])) {
|
|
79
|
+
const kind: EndKind = c.ch === "●" ? "dot" : c.ch === MANY_END[OPP[side]] ? "many" : "one";
|
|
80
|
+
starts.push({ end: { node: ni, side, kind }, x, y, px: x, py: y });
|
|
81
|
+
} else if (c.role.startsWith("wire")) {
|
|
82
|
+
const bxx = x + DX[OPP[side]];
|
|
83
|
+
const byy = y + DY[OPP[side]];
|
|
84
|
+
const b = at(bxx, byy);
|
|
85
|
+
if (JUNCTION_SIDE[b.ch] === side && nodeAt(bxx, byy) === ni) starts.push({ end: { node: ni, side, kind: "attach" }, x, y, px: x, py: y });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
for (const st of starts) {
|
|
92
|
+
let x = st.x;
|
|
93
|
+
let y = st.y;
|
|
94
|
+
let d = st.end.side; // travel direction: away from the box
|
|
95
|
+
let count = 0;
|
|
96
|
+
if (st.end.kind !== "attach") {
|
|
97
|
+
visited.add(y * W + x);
|
|
98
|
+
x += DX[d];
|
|
99
|
+
y += DY[d];
|
|
100
|
+
}
|
|
101
|
+
let guard = W * H * 4 + 16;
|
|
102
|
+
let result: WireEnd | null = null;
|
|
103
|
+
let resultCell = "";
|
|
104
|
+
while (guard-- > 0) {
|
|
105
|
+
const ni = nodeAt(x, y);
|
|
106
|
+
if (ni >= 0) {
|
|
107
|
+
// arrived at a box border: only valid for an attach end
|
|
108
|
+
const side = OPP[d];
|
|
109
|
+
const b = at(x, y);
|
|
110
|
+
if (JUNCTION_SIDE[b.ch] === side) {
|
|
111
|
+
result = { node: ni, side, kind: "attach" };
|
|
112
|
+
resultCell = `${x - DX[d]},${y - DY[d]}`;
|
|
113
|
+
}
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
const c = at(x, y);
|
|
117
|
+
visited.add(y * W + x);
|
|
118
|
+
count++;
|
|
119
|
+
if (c.role === "arrow" && c.ch in ARROW_POINTS) {
|
|
120
|
+
if (ARROW_POINTS[c.ch] !== d) break;
|
|
121
|
+
const nx = nodeAt(x + DX[d], y + DY[d]);
|
|
122
|
+
if (nx >= 0) {
|
|
123
|
+
result = { node: nx, side: OPP[d], kind: "arrow" };
|
|
124
|
+
resultCell = `${x},${y}`;
|
|
125
|
+
}
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
if (c.role === "mark") {
|
|
129
|
+
const nx = nodeAt(x + DX[d], y + DY[d]);
|
|
130
|
+
if (nx < 0) break;
|
|
131
|
+
let kind: EndKind | null = null;
|
|
132
|
+
if (c.ch === "●") kind = "dot";
|
|
133
|
+
else if (c.ch === MANY_END[d]) kind = "many";
|
|
134
|
+
else if (c.ch === ONE_END[d]) kind = "one";
|
|
135
|
+
if (!kind) break; // a mark that does not face the box it touches is not a valid end
|
|
136
|
+
result = { node: nx, side: OPP[d], kind };
|
|
137
|
+
resultCell = `${x},${y}`;
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
if (c.role === "elabel") {
|
|
141
|
+
x += DX[d];
|
|
142
|
+
y += DY[d];
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const bits = c.role.startsWith("wire") ? (CONN[c.ch] ?? 0) : 0;
|
|
146
|
+
if (!bits || !(bits & BIT[OPP[d]])) break;
|
|
147
|
+
if (c.ch === "┼") {
|
|
148
|
+
x += DX[d];
|
|
149
|
+
y += DY[d];
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const exits = bits & ~BIT[OPP[d]];
|
|
153
|
+
let nd = -1;
|
|
154
|
+
for (let k = 0; k < 4; k++) if (exits === BIT[k]) nd = k;
|
|
155
|
+
if (nd < 0) break;
|
|
156
|
+
d = nd;
|
|
157
|
+
x += DX[d];
|
|
158
|
+
y += DY[d];
|
|
159
|
+
}
|
|
160
|
+
if (!result) {
|
|
161
|
+
broken++;
|
|
162
|
+
brokenInfo.push(`start node ${res.nodes[st.end.node].id} side ${st.end.side} kind ${st.end.kind} at ${st.px},${st.py}; stopped at ${x},${y} glyph '${at(x, y).ch}' role '${at(x, y).role}' dir ${d}`);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
wires.push({ key: [`${st.px},${st.py}`, resultCell].sort().join("|"), wire: { a: st.end, b: result, cells: count } });
|
|
166
|
+
}
|
|
167
|
+
// every wire is seen from both ends: keep one per pair of end cells
|
|
168
|
+
const seen = new Set<string>();
|
|
169
|
+
const uniq: DecodedWire[] = [];
|
|
170
|
+
for (const { key, wire } of wires) {
|
|
171
|
+
if (seen.has(key)) continue;
|
|
172
|
+
seen.add(key);
|
|
173
|
+
uniq.push(wire);
|
|
174
|
+
}
|
|
175
|
+
const stray: Array<[number, number]> = [];
|
|
176
|
+
for (let y = 0; y < H; y++)
|
|
177
|
+
for (let x = 0; x < W; x++) {
|
|
178
|
+
const c = rows[y][x];
|
|
179
|
+
const wireLike = c.role.startsWith("wire") || c.role === "arrow" || c.role === "mark" || c.role === "elabel";
|
|
180
|
+
if (wireLike && !visited.has(y * W + x)) stray.push([x, y]);
|
|
181
|
+
}
|
|
182
|
+
return { wires: uniq, stray, broken, brokenInfo };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Edges as "from>to" keys, for graphs whose edges are drawn with an arrow at one end and an attached start. */
|
|
186
|
+
export function decodeDirected(res: DiagramResult): { edges: Set<string>; problems: string[]; decoded: Decoded } {
|
|
187
|
+
const decoded = decodeWires(res);
|
|
188
|
+
const edges = new Set<string>();
|
|
189
|
+
const problems: string[] = [];
|
|
190
|
+
for (const w of decoded.wires) {
|
|
191
|
+
const kinds = [w.a.kind, w.b.kind];
|
|
192
|
+
const ai = kinds.indexOf("arrow");
|
|
193
|
+
const ti = kinds.indexOf("attach");
|
|
194
|
+
if (kinds.filter((k) => k === "arrow").length === 1 && kinds.filter((k) => k === "attach").length === 1) {
|
|
195
|
+
const to = ai === 0 ? w.a : w.b;
|
|
196
|
+
const from = ti === 0 ? w.a : w.b;
|
|
197
|
+
const key = `${res.nodes[from.node].id}>${res.nodes[to.node].id}`;
|
|
198
|
+
if (edges.has(key)) problems.push(`duplicate wire ${key}`);
|
|
199
|
+
edges.add(key);
|
|
200
|
+
} else problems.push(`wire with ends ${kinds.join("/")} between ${res.nodes[w.a.node].id} and ${res.nodes[w.b.node].id}`);
|
|
201
|
+
}
|
|
202
|
+
if (decoded.broken) problems.push(`${decoded.broken} broken walk(s)`);
|
|
203
|
+
if (decoded.stray.length) problems.push(`${decoded.stray.length} stray wire cell(s)`);
|
|
204
|
+
return { edges, problems, decoded };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Relations as sorted "idA:kindA~idB:kindB" keys (kinds one/many), as a multiset. */
|
|
208
|
+
export function decodeRelations(res: DiagramResult): { keys: string[]; problems: string[]; decoded: Decoded } {
|
|
209
|
+
const decoded = decodeWires(res);
|
|
210
|
+
const keys: string[] = [];
|
|
211
|
+
const problems: string[] = [];
|
|
212
|
+
for (const w of decoded.wires) {
|
|
213
|
+
const ok = (k: EndKind) => k === "one" || k === "many";
|
|
214
|
+
if (!ok(w.a.kind) || !ok(w.b.kind)) {
|
|
215
|
+
problems.push(`wire with ends ${w.a.kind}/${w.b.kind}`);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
keys.push([`${res.nodes[w.a.node].id}:${w.a.kind}`, `${res.nodes[w.b.node].id}:${w.b.kind}`].sort().join("~"));
|
|
219
|
+
}
|
|
220
|
+
if (decoded.broken) problems.push(`${decoded.broken} broken walk(s): ${decoded.brokenInfo.join(" | ")}`);
|
|
221
|
+
if (decoded.stray.length) problems.push(`${decoded.stray.length} stray cell(s)`);
|
|
222
|
+
return { keys: keys.sort(), problems, decoded };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Links drawn with a dot at the source and an arrowhead at the target, as "from>to" keys (multiset). */
|
|
226
|
+
export function decodeLinks(res: DiagramResult): { keys: string[]; problems: string[]; decoded: Decoded } {
|
|
227
|
+
const decoded = decodeWires(res);
|
|
228
|
+
const keys: string[] = [];
|
|
229
|
+
const problems: string[] = [];
|
|
230
|
+
for (const w of decoded.wires) {
|
|
231
|
+
const kinds = [w.a.kind, w.b.kind];
|
|
232
|
+
if (kinds.includes("dot") && kinds.includes("arrow") && kinds[0] !== kinds[1]) {
|
|
233
|
+
const from = w.a.kind === "dot" ? w.a : w.b;
|
|
234
|
+
const to = w.a.kind === "arrow" ? w.a : w.b;
|
|
235
|
+
keys.push(`${res.nodes[from.node].id}>${res.nodes[to.node].id}`);
|
|
236
|
+
} else problems.push(`wire with ends ${kinds.join("/")}`);
|
|
237
|
+
}
|
|
238
|
+
if (decoded.broken) problems.push(`${decoded.broken} broken walk(s): ${decoded.brokenInfo.join(" | ")}`);
|
|
239
|
+
if (decoded.stray.length) problems.push(`${decoded.stray.length} stray cell(s)`);
|
|
240
|
+
return { keys: keys.sort(), problems, decoded };
|
|
241
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Erd(entities, relations) -> DiagramResult. Tables are boxes, relations are wires with
|
|
2
|
+
// bar (one) and crow's-foot (many) ends. Never throws on bad input.
|
|
3
|
+
|
|
4
|
+
import { Canvas, oneLine, textWidth, truncateText, type Rect } from "./canvas.ts";
|
|
5
|
+
import { paintBox } from "./dag.ts";
|
|
6
|
+
import { layeredDiagram, type DiagramResult, type LEdgeSpec, type LNodeSpec } from "./layeredDiagram.ts";
|
|
7
|
+
import { budgeted } from "./router.ts";
|
|
8
|
+
import type { Mark } from "./wires.ts";
|
|
9
|
+
|
|
10
|
+
export interface EntityIn {
|
|
11
|
+
name: string;
|
|
12
|
+
fields?: unknown;
|
|
13
|
+
}
|
|
14
|
+
export interface RelationIn {
|
|
15
|
+
from: string;
|
|
16
|
+
to: string;
|
|
17
|
+
kind?: string | null;
|
|
18
|
+
label?: string | null;
|
|
19
|
+
}
|
|
20
|
+
export interface ErdOptions {
|
|
21
|
+
direction?: "LR" | "TB" | null;
|
|
22
|
+
maxWidth?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type RelKind = "1:1" | "1:N" | "N:1" | "N:M";
|
|
26
|
+
const KINDS: RelKind[] = ["1:1", "1:N", "N:1", "N:M"];
|
|
27
|
+
|
|
28
|
+
interface Field {
|
|
29
|
+
name: string;
|
|
30
|
+
type: string;
|
|
31
|
+
flags: string;
|
|
32
|
+
}
|
|
33
|
+
interface NormErd {
|
|
34
|
+
entities: Array<{ name: string; fields: Field[] }>;
|
|
35
|
+
rels: Array<{ a: number; b: number; kind: RelKind; label?: string }>;
|
|
36
|
+
selfRefs: Set<number>;
|
|
37
|
+
warnings: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function endMarks(kind: RelKind): { from: Mark; to: Mark } {
|
|
41
|
+
switch (kind) {
|
|
42
|
+
case "1:1":
|
|
43
|
+
return { from: "one", to: "one" };
|
|
44
|
+
case "N:1":
|
|
45
|
+
return { from: "many", to: "one" };
|
|
46
|
+
case "N:M":
|
|
47
|
+
return { from: "many", to: "many" };
|
|
48
|
+
default:
|
|
49
|
+
return { from: "one", to: "many" };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function normalizeErd(entitiesIn: unknown, relationsIn: unknown): NormErd {
|
|
54
|
+
const warnings: string[] = [];
|
|
55
|
+
const entities: NormErd["entities"] = [];
|
|
56
|
+
const ids = new Map<string, number>();
|
|
57
|
+
for (const raw of Array.isArray(entitiesIn) ? entitiesIn : []) {
|
|
58
|
+
const r = (raw ?? {}) as EntityIn;
|
|
59
|
+
const name = oneLine(r.name ?? "");
|
|
60
|
+
if (!name) {
|
|
61
|
+
warnings.push("an entity without a name was skipped");
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (ids.has(name)) {
|
|
65
|
+
warnings.push(`duplicate entity "${name}" ignored`);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const fields: Field[] = [];
|
|
69
|
+
for (const f of Array.isArray(r.fields) ? r.fields : []) {
|
|
70
|
+
const words = oneLine(String(f ?? "")).split(" ").filter(Boolean);
|
|
71
|
+
if (!words.length) continue;
|
|
72
|
+
fields.push({ name: words[0], type: words[1] ?? "", flags: words.slice(2).join(" ") });
|
|
73
|
+
}
|
|
74
|
+
ids.set(name, entities.length);
|
|
75
|
+
entities.push({ name, fields });
|
|
76
|
+
}
|
|
77
|
+
const rels: NormErd["rels"] = [];
|
|
78
|
+
const seen = new Set<string>();
|
|
79
|
+
const selfRefs = new Set<number>();
|
|
80
|
+
for (const raw of Array.isArray(relationsIn) ? relationsIn : []) {
|
|
81
|
+
const r = (raw ?? {}) as RelationIn;
|
|
82
|
+
const a = ids.get(String(r.from));
|
|
83
|
+
const b = ids.get(String(r.to));
|
|
84
|
+
if (a === undefined || b === undefined) {
|
|
85
|
+
warnings.push(`relation ${String(r.from)} -> ${String(r.to)} names an unknown entity and was skipped`);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
let kind: RelKind = "1:N";
|
|
89
|
+
if (r.kind != null && r.kind !== "") {
|
|
90
|
+
if ((KINDS as string[]).includes(String(r.kind))) kind = r.kind as RelKind;
|
|
91
|
+
else warnings.push(`unknown relation kind "${r.kind}" treated as 1:N`);
|
|
92
|
+
}
|
|
93
|
+
const label = r.label ? oneLine(r.label) : undefined;
|
|
94
|
+
if (a === b) {
|
|
95
|
+
selfRefs.add(a);
|
|
96
|
+
warnings.push(`self relation on "${entities[a].name}" shown as a marker`);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const key = `${a}>${b}>${kind}>${label ?? ""}`;
|
|
100
|
+
if (seen.has(key)) continue;
|
|
101
|
+
seen.add(key);
|
|
102
|
+
rels.push({ a, b, kind, label });
|
|
103
|
+
}
|
|
104
|
+
return { entities, rels, selfRefs, warnings };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function tableSpec(e: NormErd["entities"][number], fieldCap: number, selfRef: boolean): LNodeSpec {
|
|
108
|
+
const title = (selfRef ? "∞ " : "") + e.name;
|
|
109
|
+
const rows = e.fields.map((f) => ({ name: f.name, type: f.type, flags: f.flags }));
|
|
110
|
+
const nw = Math.max(0, ...rows.map((r) => textWidth(r.name)));
|
|
111
|
+
const tw = Math.max(0, ...rows.map((r) => textWidth(r.type)));
|
|
112
|
+
const fw = Math.max(0, ...rows.map((r) => textWidth(r.flags)));
|
|
113
|
+
const rowW = (r: { name: string; type: string; flags: string }) => nw + (tw ? 2 + tw : 0) + (fw ? 2 + fw : 0) + 0 * textWidth(r.name);
|
|
114
|
+
const bodyW = rows.length ? rowW(rows[0]) : 0;
|
|
115
|
+
const inner = Math.min(Math.max(textWidth(title), bodyW), Math.max(fieldCap, textWidth(title)));
|
|
116
|
+
const w = inner + 4;
|
|
117
|
+
const h = rows.length ? 4 + rows.length : 3;
|
|
118
|
+
return {
|
|
119
|
+
id: e.name,
|
|
120
|
+
label: e.name,
|
|
121
|
+
status: null,
|
|
122
|
+
baseW: w,
|
|
123
|
+
baseH: h,
|
|
124
|
+
noPortRows: rows.length ? [2] : [],
|
|
125
|
+
paint: (c: Canvas, r: Rect) => {
|
|
126
|
+
paintBox(c, r, "box.erd", false);
|
|
127
|
+
c.text(r.x + 2, r.y + 1, truncateText(title, r.w - 4), "erd.title");
|
|
128
|
+
if (!rows.length) return;
|
|
129
|
+
c.set(r.x, r.y + 2, "├", "box.erd");
|
|
130
|
+
c.set(r.x + r.w - 1, r.y + 2, "┤", "box.erd");
|
|
131
|
+
for (let x = r.x + 1; x < r.x + r.w - 1; x++) c.set(x, r.y + 2, "─", "box.erd");
|
|
132
|
+
rows.forEach((row, i) => {
|
|
133
|
+
const y = r.y + 3 + i;
|
|
134
|
+
const avail = r.w - 4;
|
|
135
|
+
let x = r.x + 2;
|
|
136
|
+
const put = (s: string, role: string, width: number) => {
|
|
137
|
+
const t = truncateText(s, Math.max(0, avail - (x - (r.x + 2))));
|
|
138
|
+
c.text(x, y, t, role);
|
|
139
|
+
x += width + 2;
|
|
140
|
+
};
|
|
141
|
+
put(row.name, /\bPK\b/.test(row.flags) ? "erd.pk" : "erd.field", nw);
|
|
142
|
+
if (tw) put(row.type, "erd.type", tw);
|
|
143
|
+
if (fw) put(row.flags, /\bPK\b|\bFK\b/.test(row.flags) ? "erd.key" : "erd.flag", fw);
|
|
144
|
+
});
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const CAPS = [60, 40, 30, 22, 16];
|
|
150
|
+
|
|
151
|
+
export function erd(entitiesIn: unknown, relationsIn: unknown, opts: ErdOptions = {}): DiagramResult {
|
|
152
|
+
return budgeted(() => erdLayout(entitiesIn, relationsIn, opts));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function erdLayout(entitiesIn: unknown, relationsIn: unknown, opts: ErdOptions): DiagramResult {
|
|
156
|
+
const norm = normalizeErd(entitiesIn, relationsIn);
|
|
157
|
+
const maxWidth = Math.max(20, opts.maxWidth ?? 100);
|
|
158
|
+
if (!norm.entities.length) {
|
|
159
|
+
return { kind: "erd", direction: "LR", width: 0, height: 0, rows: [], nodes: [], edges: [], warnings: [...norm.warnings, "the diagram has no entities"], footnotes: [], degraded: false, overflow: false, statuses: [], legend: [] };
|
|
160
|
+
}
|
|
161
|
+
const edges: LEdgeSpec[] = norm.rels.map((r) => {
|
|
162
|
+
const m = endMarks(r.kind);
|
|
163
|
+
return { a: r.a, b: r.b, label: r.label, fromMark: m.from, toMark: m.to };
|
|
164
|
+
});
|
|
165
|
+
const dirs: Array<"LR" | "TB"> = opts.direction === "LR" || opts.direction === "TB" ? [opts.direction] : ["LR", "TB"];
|
|
166
|
+
let best: DiagramResult | null = null;
|
|
167
|
+
let chosen: DiagramResult | null = null;
|
|
168
|
+
for (const cap of CAPS) {
|
|
169
|
+
const nodes = norm.entities.map((e, i) => tableSpec(e, cap, norm.selfRefs.has(i)));
|
|
170
|
+
for (const d of dirs) {
|
|
171
|
+
const r = layeredDiagram("erd", { nodes, edges, portSpacing: 4 }, d);
|
|
172
|
+
if (!best || r.width < best.width) best = r;
|
|
173
|
+
if (r.width <= maxWidth) {
|
|
174
|
+
chosen = r;
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (chosen) break;
|
|
179
|
+
}
|
|
180
|
+
if (!chosen) {
|
|
181
|
+
const nodes = norm.entities.map((e, i) => tableSpec(e, 30, norm.selfRefs.has(i)));
|
|
182
|
+
const tries = dirs.map((d) => layeredDiagram("erd", { nodes, edges, portSpacing: 4 }, d));
|
|
183
|
+
best = tries.reduce((a, b) => (b.width < a.width ? b : a));
|
|
184
|
+
}
|
|
185
|
+
const res = chosen ?? best!;
|
|
186
|
+
res.overflow = !chosen;
|
|
187
|
+
res.warnings = [...norm.warnings];
|
|
188
|
+
if (res.degraded) res.warnings.push("some relations could not be routed cleanly");
|
|
189
|
+
return res;
|
|
190
|
+
}
|