pi-openui 0.0.0-stage → 0.1.1
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 +41 -0
- package/LICENSE +21 -0
- package/README.md +167 -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,343 @@
|
|
|
1
|
+
// Layout and simple components: Stack, Text, Card, Callout, Metric, Button, Choice, Tabs, Faq, Image + tooltips.
|
|
2
|
+
import { textWidth, truncateText, type Rect } from "../diagram/canvas.ts";
|
|
3
|
+
import type { El } from "../lang/library.ts";
|
|
4
|
+
import { codeInner } from "./code.ts";
|
|
5
|
+
import { markdownBlock } from "./markdown.ts";
|
|
6
|
+
import { itemId, itemInfo, NOTEABLE, parentKeyOf, WHOLE_ONLY } from "./notes.ts";
|
|
7
|
+
import type { Block, Ctx, Target, Tip } from "./types.ts";
|
|
8
|
+
import { blank, boxed, cell, fromRows, hstack, padRow, shiftTargets, textCells, vstack, wrapRuns, wrapText } from "./util.ts";
|
|
9
|
+
|
|
10
|
+
const ROW_GAP: Record<string, number> = { none: 0, xs: 0, s: 0, m: 1, l: 1, xl: 2 };
|
|
11
|
+
const COL_GAP: Record<string, number> = { none: 0, xs: 1, s: 1, m: 2, l: 3, xl: 4 };
|
|
12
|
+
|
|
13
|
+
/** width an element wants when it sits in a row, or null when it should share the space */
|
|
14
|
+
export function natural(el: El): number | null {
|
|
15
|
+
const p = el.props;
|
|
16
|
+
switch (el.type) {
|
|
17
|
+
case "Button":
|
|
18
|
+
return textWidth(String(p.label ?? "")) + 4;
|
|
19
|
+
case "Metric":
|
|
20
|
+
return Math.max(16, textWidth(String(p.label ?? "")) + 4, textWidth(String(p.value ?? "")) + 4, textWidth(String(p.delta ?? "")) + 4);
|
|
21
|
+
case "Text":
|
|
22
|
+
return Math.min(60, textWidth(String(p.text ?? "")));
|
|
23
|
+
default:
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function stack(el: El, width: number, ctx: Ctx, path: string): Block {
|
|
29
|
+
const kids: El[] = el.props.children ?? [];
|
|
30
|
+
const dir = el.props.direction ?? "column";
|
|
31
|
+
const gapTok = el.props.gap ?? "m";
|
|
32
|
+
if (dir === "column") {
|
|
33
|
+
const blocks = kids.map((k, i) => ctx.render(k, width, `${path}.${i}`));
|
|
34
|
+
return vstack(blocks, ROW_GAP[gapTok] ?? 1, width);
|
|
35
|
+
}
|
|
36
|
+
const gap = COL_GAP[gapTok] ?? 2;
|
|
37
|
+
const nat = kids.map(natural);
|
|
38
|
+
const lines: number[][] = [];
|
|
39
|
+
if (el.props.wrap) {
|
|
40
|
+
let cur: number[] = [];
|
|
41
|
+
let used = 0;
|
|
42
|
+
const share = Math.max(14, Math.floor((width - 2 * gap) / 3));
|
|
43
|
+
kids.forEach((_, i) => {
|
|
44
|
+
const w = Math.min(width, nat[i] ?? share);
|
|
45
|
+
if (cur.length && used + gap + w > width) {
|
|
46
|
+
lines.push(cur);
|
|
47
|
+
cur = [];
|
|
48
|
+
used = 0;
|
|
49
|
+
}
|
|
50
|
+
used += (cur.length ? gap : 0) + w;
|
|
51
|
+
cur.push(i);
|
|
52
|
+
});
|
|
53
|
+
if (cur.length) lines.push(cur);
|
|
54
|
+
} else lines.push(kids.map((_, i) => i));
|
|
55
|
+
const rendered = lines.map((idxs) => {
|
|
56
|
+
const n = idxs.length;
|
|
57
|
+
const fixed = idxs.reduce((a, i) => a + (nat[i] ?? 0), 0);
|
|
58
|
+
const flex = idxs.filter((i) => nat[i] === null);
|
|
59
|
+
const room = width - gap * (n - 1) - fixed;
|
|
60
|
+
const flexW = flex.length ? Math.max(8, Math.floor(room / flex.length)) : 0;
|
|
61
|
+
let widths = idxs.map((i) => nat[i] ?? flexW);
|
|
62
|
+
let total = widths.reduce((a, b) => a + b, 0) + gap * (n - 1);
|
|
63
|
+
while (total > width && Math.max(...widths) > 6) {
|
|
64
|
+
const k = widths.indexOf(Math.max(...widths));
|
|
65
|
+
widths[k]--;
|
|
66
|
+
total--;
|
|
67
|
+
}
|
|
68
|
+
widths = widths.map((w) => Math.max(1, w));
|
|
69
|
+
const blocks = idxs.map((i, k) => ctx.render(kids[i], widths[k], `${path}.${i}`));
|
|
70
|
+
return hstack(blocks, gap);
|
|
71
|
+
});
|
|
72
|
+
const out = vstack(rendered, 0, width);
|
|
73
|
+
return { ...out, rows: out.rows.map((r) => padRow(r, width)) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function text(el: El, width: number): Block {
|
|
77
|
+
const v = String(el.props.text ?? "");
|
|
78
|
+
const role = { title: "text.title", heading: "text.heading", muted: "text.muted", code: "text.code" }[el.props.variant as string] ?? "";
|
|
79
|
+
return fromRows(width, wrapText(v, width, role));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function card(el: El, width: number, ctx: Ctx, path: string): Block {
|
|
83
|
+
const inner = stack({ type: "Stack", props: { children: el.props.children ?? [], gap: "m" }, line: el.line }, Math.max(4, width - 4), ctx, path);
|
|
84
|
+
return boxed(inner, { role: "card", title: el.props.title, titleRole: "card.title" });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const CALLOUT_ICON: Record<string, string> = { info: "◉", success: "✓", warn: "⚠", error: "✗" };
|
|
88
|
+
export function callout(el: El, width: number): Block {
|
|
89
|
+
const tone = el.props.tone ?? "info";
|
|
90
|
+
const inner = fromRows(
|
|
91
|
+
Math.max(4, width - 6),
|
|
92
|
+
wrapText(String(el.props.text ?? ""), Math.max(4, width - 6), ""),
|
|
93
|
+
);
|
|
94
|
+
const body = fromRows(inner.w + 2, inner.rows.map((r, i) => [i === 0 ? cell(CALLOUT_ICON[tone], `callout.${tone}`) : cell(" "), cell(" "), ...r]));
|
|
95
|
+
return boxed(body, { role: `callout.${tone}` });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function metric(el: El, width: number, path: string): { block: Block; anchors: Map<string, { rect: Rect; targetId?: string }> } {
|
|
99
|
+
const p = el.props;
|
|
100
|
+
const w = Math.max(10, width);
|
|
101
|
+
const rows = [textCells(truncateText(String(p.label), w - 4), "metric.label"), textCells(truncateText(String(p.value), w - 4), "metric.value")];
|
|
102
|
+
if (p.delta) rows.push(textCells(truncateText(String(p.delta), w - 4), `metric.${p.tone ?? "neutral"}`));
|
|
103
|
+
const block = boxed(fromRows(w - 4, rows), { role: "tile" });
|
|
104
|
+
void path;
|
|
105
|
+
return { block, anchors: new Map([[String(p.label), { rect: { x: 0, y: 0, w: block.w, h: block.rows.length } }]]) };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function button(el: El, width: number, path: string): Block {
|
|
109
|
+
const p = el.props;
|
|
110
|
+
const label = String(p.label ?? "");
|
|
111
|
+
const steps: Array<{ name: string; args: any[] }> = p.action?.steps ?? [];
|
|
112
|
+
const say = steps.find((s) => s.name === "ToAssistant");
|
|
113
|
+
const url = steps.find((s) => s.name === "OpenUrl");
|
|
114
|
+
const send = say ? String(say.args[0]) : url ? undefined : label;
|
|
115
|
+
const shown = truncateText(label, Math.max(1, width - 4));
|
|
116
|
+
const cells = [cell("[", "btn"), cell(" ", "btn"), ...textCells(shown, "btn"), cell(" ", "btn"), cell("]", "btn")];
|
|
117
|
+
const t: Target = { id: `btn:${path}`, kind: "button", rect: { x: 0, y: 0, w: cells.length, h: 1 }, label, send, openUrl: url ? String(url.args[0]) : undefined };
|
|
118
|
+
return fromRows(Math.max(cells.length, 1), [cells], [t]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function choice(el: El, width: number, ctx: Ctx, path: string): Block {
|
|
122
|
+
const p = el.props;
|
|
123
|
+
const q = String(p.question ?? "");
|
|
124
|
+
const opts: string[] = p.options ?? [];
|
|
125
|
+
const picked = ctx.state.picked[path];
|
|
126
|
+
const head = wrapRuns([{ t: q, role: "choice.q" }], width);
|
|
127
|
+
const hint = p.hint ? wrapText(String(p.hint), width, "text.muted") : [];
|
|
128
|
+
const targets: Target[] = [];
|
|
129
|
+
const lines: Array<ReturnType<typeof textCells>> = [];
|
|
130
|
+
const optCells = opts.map((o, i) => {
|
|
131
|
+
const on = picked === o;
|
|
132
|
+
return { i, cells: [cell(on ? "●" : "○", on ? "choice.picked" : "choice"), cell(" "), ...textCells(truncateText(o, Math.max(4, width - 3)), on ? "choice.picked" : "choice")] };
|
|
133
|
+
});
|
|
134
|
+
const total = optCells.reduce((a, o, k) => a + o.cells.length + (k ? 3 : 0), 0);
|
|
135
|
+
const baseY = head.length + hint.length;
|
|
136
|
+
if (total <= width) {
|
|
137
|
+
let line: typeof lines[number] = [];
|
|
138
|
+
optCells.forEach((o, k) => {
|
|
139
|
+
if (k) line.push(cell(" "), cell(" "), cell(" "));
|
|
140
|
+
targets.push({ id: `choice:${path}:${o.i}`, kind: "choice", rect: { x: line.length, y: baseY, w: o.cells.length, h: 1 }, label: opts[o.i], send: `${q} — ${opts[o.i]}` });
|
|
141
|
+
line.push(...o.cells);
|
|
142
|
+
});
|
|
143
|
+
lines.push(line);
|
|
144
|
+
} else {
|
|
145
|
+
optCells.forEach((o, k) => {
|
|
146
|
+
targets.push({ id: `choice:${path}:${o.i}`, kind: "choice", rect: { x: 0, y: baseY + k, w: o.cells.length, h: 1 }, label: opts[o.i], send: `${q} — ${opts[o.i]}` });
|
|
147
|
+
lines.push(o.cells);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return fromRows(width, [...head, ...hint, ...lines], targets);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function tabs(el: El, width: number, ctx: Ctx, path: string): Block {
|
|
154
|
+
const items: El[] = el.props.items ?? [];
|
|
155
|
+
if (!items.length) return blank(width, 0);
|
|
156
|
+
const sel = Math.min(Math.max(0, ctx.state.tab[path] ?? 0), items.length - 1);
|
|
157
|
+
let labels = items.map((it) => truncateText(String(it.props.label ?? ""), 22));
|
|
158
|
+
const need = () => labels.reduce((a, l) => a + textWidth(l) + 4, 0);
|
|
159
|
+
let cap = 22;
|
|
160
|
+
while (need() > width && cap > 4) labels = items.map((it) => truncateText(String(it.props.label ?? ""), --cap));
|
|
161
|
+
const l0: ReturnType<typeof textCells> = [];
|
|
162
|
+
const l1: typeof l0 = [];
|
|
163
|
+
const l2: typeof l0 = [];
|
|
164
|
+
const targets: Target[] = [];
|
|
165
|
+
labels.forEach((lab, i) => {
|
|
166
|
+
const iw = textWidth(lab) + 2;
|
|
167
|
+
const on = i === sel;
|
|
168
|
+
const role = on ? "tab.on" : "tab.off";
|
|
169
|
+
const x0 = l1.length;
|
|
170
|
+
if (on) {
|
|
171
|
+
l0.push(cell("╭", role), ...Array.from({ length: iw }, () => cell("─", role)), cell("╮", role));
|
|
172
|
+
l1.push(cell("│", role), cell(" ", role), ...textCells(lab, "tab.label"), cell(" ", role), cell("│", role));
|
|
173
|
+
l2.push(cell("╯", role), ...Array.from({ length: iw }, () => cell(" ")), cell("╰", role));
|
|
174
|
+
} else {
|
|
175
|
+
l0.push(...Array.from({ length: iw + 2 }, () => cell(" ")));
|
|
176
|
+
l1.push(cell(" "), cell(" "), ...textCells(lab, role), cell(" "), cell(" "));
|
|
177
|
+
l2.push(...Array.from({ length: iw + 2 }, () => cell("─", "tab.rule")));
|
|
178
|
+
}
|
|
179
|
+
targets.push({ id: `tab:${path}:${i}`, kind: "tab", rect: { x: x0, y: 1, w: iw + 2, h: 1 }, label: String(items[i].props.label ?? "") });
|
|
180
|
+
});
|
|
181
|
+
while (l2.length < width) l2.push(cell("─", "tab.rule"));
|
|
182
|
+
const strip = fromRows(width, [l0, l1, l2], targets);
|
|
183
|
+
ctx.tabs.push(String(items[sel].props.label ?? ""));
|
|
184
|
+
let content: Block;
|
|
185
|
+
try {
|
|
186
|
+
content = stack({ type: "Stack", props: { children: items[sel].props.content ?? [], gap: "m" }, line: el.line }, width, ctx, `${path}.t${sel}`);
|
|
187
|
+
} finally {
|
|
188
|
+
ctx.tabs.pop();
|
|
189
|
+
}
|
|
190
|
+
return vstack([strip, vstack([blank(width, 0), content], 0, width)], 0, width);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function faq(el: El, width: number, ctx: Ctx, path: string): Block {
|
|
194
|
+
const items: El[] = el.props.items ?? [];
|
|
195
|
+
const out: Block[] = [];
|
|
196
|
+
items.forEach((it, i) => {
|
|
197
|
+
const id = `faq:${path}:${i}`;
|
|
198
|
+
if (ctx.state.faq[id] === undefined) ctx.state.faq[id] = !!el.props.firstOpen && i === 0;
|
|
199
|
+
const open = ctx.state.faq[id];
|
|
200
|
+
const qRows = wrapRuns([{ t: String(it.props.question ?? ""), role: "faq.q" }], width - 2);
|
|
201
|
+
const head = fromRows(
|
|
202
|
+
width,
|
|
203
|
+
qRows.map((r, k) => [cell(k === 0 ? (open ? "▾" : "▸") : " ", "faq.mark"), cell(" "), ...r]),
|
|
204
|
+
[{ id, kind: "faq", rect: { x: 0, y: 0, w: width, h: qRows.length }, label: String(it.props.question ?? "") }],
|
|
205
|
+
);
|
|
206
|
+
if (!open) {
|
|
207
|
+
out.push(head);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const ans = markdownBlock(String(it.props.answer ?? ""), Math.max(4, width - 2), ctx, `${path}.a${i}`);
|
|
211
|
+
// the answer sits two columns in; its targets (a picture) move with it
|
|
212
|
+
const body = fromRows(width, ans.rows.map((r) => [cell(" "), cell(" "), ...r]), shiftTargets(ans.targets, 2, 0));
|
|
213
|
+
out.push(vstack([head, body], 0, width));
|
|
214
|
+
});
|
|
215
|
+
return vstack(out, 0, width);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function image(el: El, width: number, path: string, ctx: Ctx): { block: Block; anchors: Map<string, Anchor> } {
|
|
219
|
+
const p = el.props;
|
|
220
|
+
const src = String(p.src ?? "");
|
|
221
|
+
const svc = ctx.services.images;
|
|
222
|
+
const name = src.startsWith("data:") ? "inline image" : src.split(/[\\/]/).pop() || src;
|
|
223
|
+
const alt = String(p.alt ?? name);
|
|
224
|
+
const placed = svc?.place(src, Math.min(Math.max(8, width - 2), 64), 22) ?? null;
|
|
225
|
+
let block: Block;
|
|
226
|
+
if (placed) {
|
|
227
|
+
// the picture is drawn over these cells by the host; here they are reserved, framed and clickable
|
|
228
|
+
const area = blank(placed.cols, placed.rows);
|
|
229
|
+
area.targets.push({ id: `image:${path}`, kind: "image", rect: { x: 0, y: 0, w: placed.cols, h: placed.rows }, label: `${alt}`, image: { key: placed.key, cols: placed.cols, rows: placed.rows } });
|
|
230
|
+
const cap = p.caption ? fromRows(placed.cols, wrapText(String(p.caption), placed.cols, "text.muted")) : null;
|
|
231
|
+
block = boxed(cap ? vstack([area, cap], 0, placed.cols) : area, { role: "image.border", title: alt, titleRole: "image.alt", padX: 0 });
|
|
232
|
+
} else {
|
|
233
|
+
const facts = svc ? svc.describe(src) : name;
|
|
234
|
+
const lines = [textCells(`▒ ${alt}`, "image.alt"), textCells(truncateText(facts, Math.max(4, width - 6)), "text.muted")];
|
|
235
|
+
if (p.caption) lines.push(textCells(truncateText(String(p.caption), Math.max(4, width - 6)), "text.muted"));
|
|
236
|
+
block = boxed(fromRows(Math.max(8, Math.min(width - 4, Math.max(...lines.map((l) => l.length)) + 1)), lines), { role: "image.border", dashed: true });
|
|
237
|
+
block.targets.push({ id: `image:${path}`, kind: "image", rect: { x: 0, y: 0, w: block.w, h: block.rows.length }, label: alt });
|
|
238
|
+
}
|
|
239
|
+
const frame = { x: 0, y: 0, w: block.w, h: block.rows.length };
|
|
240
|
+
const self = block.targets.find((t) => t.kind === "image");
|
|
241
|
+
return { block, anchors: new Map([["image", { rect: frame, targetId: self?.id }]]) };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---------- tooltips ----------
|
|
245
|
+
|
|
246
|
+
export interface Anchor {
|
|
247
|
+
rect: Rect;
|
|
248
|
+
targetId?: string;
|
|
249
|
+
/** where the tooltip badge goes, when the default (end of the first row of `rect`) would be wrong */
|
|
250
|
+
badge?: [number, number];
|
|
251
|
+
/** draw the badge even over a cell that is not empty (a point on a braille curve) */
|
|
252
|
+
badgeForce?: boolean;
|
|
253
|
+
/** cells besides `rect` that belong to the element (a pie wedge) */
|
|
254
|
+
cells?: Array<[number, number]>;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The mark that says "this element has a tooltip". Present in the user's font (JetBrains Mono), unlike the circled i. */
|
|
258
|
+
export const BADGE = "◆";
|
|
259
|
+
|
|
260
|
+
const isBlank = (c: { ch: string; role: string } | undefined) => !!c && c.ch === " " && c.role === "";
|
|
261
|
+
const isDash = (c: { ch: string; role: string } | undefined) => !!c && (c.ch === "─" || c.ch === "╌") && /^(box|zone|card|tile|image|code|seq\.head|tip)/.test(c.role);
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Put the badge where a reader looks first: on the top border of a box (just before the corner), or right after
|
|
265
|
+
* the last character of a one-line element. Returns false when there is no free cell, in which case the element is
|
|
266
|
+
* still hoverable and the hint line under the widget still announces that tooltips exist.
|
|
267
|
+
*/
|
|
268
|
+
export function placeBadge(block: Block, a: Anchor, tone: string): boolean {
|
|
269
|
+
const put = (x: number, y: number) => {
|
|
270
|
+
const row = block.rows[y];
|
|
271
|
+
if (!row || x < 0 || x >= row.length) return false;
|
|
272
|
+
row[x] = cell(BADGE, `note.${tone}`);
|
|
273
|
+
return true;
|
|
274
|
+
};
|
|
275
|
+
if (a.badge) {
|
|
276
|
+
const c = block.rows[a.badge[1]]?.[a.badge[0]];
|
|
277
|
+
if (a.badgeForce || isBlank(c) || isDash(c)) return put(a.badge[0], a.badge[1]);
|
|
278
|
+
}
|
|
279
|
+
const r = a.rect;
|
|
280
|
+
const row = block.rows[r.y];
|
|
281
|
+
if (!row) return false;
|
|
282
|
+
let xe = Math.min(block.w, r.x + r.w) - 1;
|
|
283
|
+
while (xe >= r.x && isBlank(row[xe])) xe--;
|
|
284
|
+
if (xe < r.x) return r.w > 0 && put(r.x, r.y);
|
|
285
|
+
if (/[╮┐]/.test(row[xe].ch) && isDash(row[xe - 1])) return put(xe - 1, r.y);
|
|
286
|
+
if (xe + 1 < block.w && isBlank(row[xe + 1])) return put(xe + 1, r.y);
|
|
287
|
+
if (r.x - 1 >= 0 && isBlank(row[r.x - 1])) return put(r.x - 1, r.y);
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function tipPanel(label: string, tip: Tip, width: number): Block {
|
|
292
|
+
const iw = Math.max(8, width - 4);
|
|
293
|
+
const parts: Block[] = [];
|
|
294
|
+
if (tip.hint) parts.push(fromRows(iw, wrapText(tip.hint, iw, "tip.hint")));
|
|
295
|
+
if (tip.detail) parts.push(markdownBlock(tip.detail, iw));
|
|
296
|
+
if (tip.code) parts.push(codeInner(tip.code, tip.language, iw));
|
|
297
|
+
if (tip.link) parts.push(fromRows(iw, [textCells(truncateText(`↗ ${tip.linkLabel ?? tip.link}${tip.linkLabel ? ` — ${tip.link}` : ""}`, iw), "md.link")]));
|
|
298
|
+
const inner = vstack(parts.length ? parts : [fromRows(iw, [textCells(label, "tip.hint")])], 1, iw);
|
|
299
|
+
return boxed(inner, { role: `tip.${tip.tone ?? "info"}`, title: label });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** The parts of a component a note can point at: one item per anchor (a row, a node, a point, a slice ...). */
|
|
303
|
+
export function registerItems(el: El, block: Block, anchors: Map<string, Anchor>, path: string, ctx: Ctx): void {
|
|
304
|
+
if (!NOTEABLE.has(el.type) || WHOLE_ONLY.has(el.type)) return;
|
|
305
|
+
for (const [key, a] of anchors) {
|
|
306
|
+
const r = a.rect;
|
|
307
|
+
if (!key || r.w <= 0 || r.h <= 0 || r.x + r.w <= 0 || r.x >= block.w) continue;
|
|
308
|
+
const info = itemInfo(el, key, path, ctx.tabs, parentKeyOf(el.type, key));
|
|
309
|
+
block.targets.push({ id: itemId(path, key), kind: "item", rect: { ...r }, label: info.short, item: info, ...(a.cells ? { cells: a.cells } : {}) });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** One item for the whole component, so there is always something to attach a note to. */
|
|
314
|
+
export function addWholeItem(el: El, block: Block, path: string, ctx: Ctx): void {
|
|
315
|
+
if (!NOTEABLE.has(el.type) || !block.rows.length) return;
|
|
316
|
+
const info = itemInfo(el, "", path, ctx.tabs);
|
|
317
|
+
block.targets.push({ id: itemId(path, ""), kind: "item", rect: { x: 0, y: 0, w: block.w, h: block.rows.length }, label: info.short || el.type, item: info });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function withTips(el: El, block: Block, anchors: Map<string, Anchor>, path: string, ctx: Ctx, avail = block.w): Block {
|
|
321
|
+
registerItems(el, block, anchors, path, ctx);
|
|
322
|
+
const tips: El[] = el.props.tooltips ?? [];
|
|
323
|
+
if (!tips.length) return block;
|
|
324
|
+
let panel: Block | null = null;
|
|
325
|
+
for (const t of tips) {
|
|
326
|
+
const key = String(t.props.target ?? "");
|
|
327
|
+
const a = anchors.get(key);
|
|
328
|
+
if (!a) {
|
|
329
|
+
ctx.warnings.push(`${el.type}: tooltip target "${key}" does not match anything in the component`);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const tip: Tip = { hint: t.props.hint, detail: t.props.detail, code: t.props.code, language: t.props.language, tone: t.props.tone, link: t.props.link, linkLabel: t.props.linkLabel };
|
|
333
|
+
const existing = a.targetId ? block.targets.find((x) => x.id === a.targetId) : undefined;
|
|
334
|
+
const id = existing ? existing.id : `tip:${path}:${key}`;
|
|
335
|
+
if (existing) existing.tip = tip;
|
|
336
|
+
else block.targets.push({ id, kind: "tip", rect: a.rect, label: key, tip, ...(a.cells ? { cells: a.cells } : {}) });
|
|
337
|
+
placeBadge(block, a, tip.tone ?? "info");
|
|
338
|
+
if (ctx.state.tip === id && (tip.detail || tip.code || tip.link)) panel = tipPanel(key, tip, Math.min(Math.max(avail, 20), Math.max(block.w, 44)));
|
|
339
|
+
}
|
|
340
|
+
return panel ? vstack([block, panel], 0, Math.max(block.w, panel.w)) : block;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export const shift = shiftTargets;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Adapters: DiagramResult (Dag, Erd, InfraMap) and wide blocks -> Block with targets and tooltip anchors.
|
|
2
|
+
import { textWidth, type Cell } from "../diagram/canvas.ts";
|
|
3
|
+
import { dag } from "../diagram/dag.ts";
|
|
4
|
+
import { erd } from "../diagram/erd.ts";
|
|
5
|
+
import { infra } from "../diagram/infra.ts";
|
|
6
|
+
import type { DiagramResult } from "../diagram/layeredDiagram.ts";
|
|
7
|
+
import type { El } from "../lang/library.ts";
|
|
8
|
+
import type { Anchor } from "./core.ts";
|
|
9
|
+
import type { Block, Ctx, Target } from "./types.ts";
|
|
10
|
+
import { cell, fromRows, padRow, textCells, vstack } from "./util.ts";
|
|
11
|
+
|
|
12
|
+
/** Crop a block wider than `width` to a window starting at the saved pan offset. */
|
|
13
|
+
export function panBlock(inner: Block, width: number, key: string, ctx: Ctx): Block {
|
|
14
|
+
if (inner.w <= width) return inner;
|
|
15
|
+
const maxPan = inner.w - width;
|
|
16
|
+
const pan = Math.min(maxPan, Math.max(0, ctx.state.pan[key] ?? 0));
|
|
17
|
+
ctx.state.pan[key] = pan;
|
|
18
|
+
const rows = inner.rows.map((r) => {
|
|
19
|
+
const seg = padRow(r, inner.w).slice(pan, pan + width);
|
|
20
|
+
if (seg.length && seg[0].ch === "") seg[0] = cell(" ");
|
|
21
|
+
if (seg.length && pan + width < inner.w && seg[seg.length - 1].ch !== "" && r[pan + width]?.ch === "") seg[seg.length - 1] = cell(" ");
|
|
22
|
+
return seg;
|
|
23
|
+
});
|
|
24
|
+
const targets: Target[] = inner.targets.map((t) => ({ ...t, rect: { ...t.rect, x: t.rect.x - pan }, pan: { key, x: t.rect.x, w: t.rect.w, view: width, total: inner.w } }));
|
|
25
|
+
const hint = textCells(`${pan > 0 ? "‹" : " "} columns ${pan + 1}–${pan + width} of ${inner.w} ${pan < maxPan ? "›" : " "} select a node to pan`, "text.muted");
|
|
26
|
+
return { w: width, rows: [...rows, padRow(hint, width)], targets };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A diagram's layout depends only on the component and the width, but the widget lays out again for a note, a tab, a resize
|
|
31
|
+
* or a late picture. Routing wires is the costly part, so each result is kept with its component (and dropped with it). Nothing
|
|
32
|
+
* downstream changes a result: resultBlock copies the cells.
|
|
33
|
+
*/
|
|
34
|
+
const layouts = new WeakMap<El, Map<number, DiagramResult>>();
|
|
35
|
+
function layoutOnce(el: El, width: number, make: () => DiagramResult): DiagramResult {
|
|
36
|
+
let byWidth = layouts.get(el);
|
|
37
|
+
if (!byWidth) layouts.set(el, (byWidth = new Map()));
|
|
38
|
+
let res = byWidth.get(width);
|
|
39
|
+
if (!res) {
|
|
40
|
+
res = make();
|
|
41
|
+
byWidth.set(width, res);
|
|
42
|
+
if (byWidth.size > 4) byWidth.delete(byWidth.keys().next().value as number); // a window being dragged tries many widths
|
|
43
|
+
}
|
|
44
|
+
return res;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function resultBlock(res: DiagramResult, title: string | undefined, path: string, extra?: (res: DiagramResult) => Cell[][]): Block {
|
|
48
|
+
const w = Math.max(res.width, 1);
|
|
49
|
+
const rows: Cell[][] = res.rows.map((r) => r.slice()); // the cells are shared and frozen
|
|
50
|
+
const targets: Target[] = res.nodes.map((n) => ({
|
|
51
|
+
id: `node:${path}:${n.id}`,
|
|
52
|
+
kind: "node",
|
|
53
|
+
rect: { ...n.rect },
|
|
54
|
+
label: n.label + (n.status ? ` ${n.status}` : ""),
|
|
55
|
+
}));
|
|
56
|
+
const body = fromRows(w, rows, targets);
|
|
57
|
+
const top: Block[] = title ? [fromRows(w, [textCells(title, "chart.title")])] : [];
|
|
58
|
+
const tail: Cell[][] = [];
|
|
59
|
+
if (res.legend.length) {
|
|
60
|
+
const parts: Cell[] = [];
|
|
61
|
+
res.legend.forEach((e, i) => {
|
|
62
|
+
if (i) parts.push(cell(" "), cell(" "));
|
|
63
|
+
parts.push(...textCells(e.glyph, e.role), cell(" "), ...textCells(e.text, "legend"));
|
|
64
|
+
});
|
|
65
|
+
tail.push([], parts);
|
|
66
|
+
}
|
|
67
|
+
for (const f of res.footnotes) tail.push(textCells(f, "legend"));
|
|
68
|
+
for (const wn of res.warnings) tail.push(textCells(`! ${wn}`, "text.warn"));
|
|
69
|
+
if (extra) tail.push(...extra(res));
|
|
70
|
+
const out = vstack([...top, body, ...(tail.length ? [fromRows(w, tail)] : [])], 0, w);
|
|
71
|
+
const off = top.length;
|
|
72
|
+
void off;
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function offsetAnchors(m: Map<string, Anchor>, dy: number) {
|
|
77
|
+
for (const [, a] of m) a.rect = { ...a.rect, y: a.rect.y + dy };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function dagBlock(el: El, width: number, path: string, ctx: Ctx): { block: Block; anchors: Map<string, Anchor> } {
|
|
81
|
+
const p = el.props;
|
|
82
|
+
const res = layoutOnce(el, width, () => dag((p.nodes ?? []).map((n: El) => ({ id: n.props.id, label: n.props.label, status: n.props.status })), p.edges ?? [], { direction: p.direction ?? null, maxWidth: width }));
|
|
83
|
+
const inner = resultBlock(res, p.title, path);
|
|
84
|
+
const anchors = new Map<string, Anchor>();
|
|
85
|
+
const dy = p.title ? 1 : 0;
|
|
86
|
+
for (const n of res.nodes) anchors.set(n.id, { rect: { ...n.rect, y: n.rect.y + dy }, targetId: `node:${path}:${n.id}` });
|
|
87
|
+
const block = panBlock(inner, width, `pan:${path}`, ctx);
|
|
88
|
+
remapAnchors(anchors, inner, block);
|
|
89
|
+
return { block, anchors };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function erdBlock(el: El, width: number, path: string, ctx: Ctx): { block: Block; anchors: Map<string, Anchor> } {
|
|
93
|
+
const p = el.props;
|
|
94
|
+
const res = layoutOnce(el, width, () => erd((p.entities ?? []).map((e: El) => ({ name: e.props.name, fields: e.props.fields })), (p.relations ?? []).map((r: El) => ({ from: r.props.from, to: r.props.to, kind: r.props.kind, label: r.props.label })), { maxWidth: width }));
|
|
95
|
+
const inner = resultBlock(res, p.title, path);
|
|
96
|
+
const anchors = new Map<string, Anchor>();
|
|
97
|
+
const dy = p.title ? 1 : 0;
|
|
98
|
+
for (const ent of (p.entities ?? []) as El[]) {
|
|
99
|
+
const node = res.nodes.find((n) => n.id === ent.props.name);
|
|
100
|
+
if (!node) continue;
|
|
101
|
+
anchors.set(String(ent.props.name), { rect: { ...node.rect, y: node.rect.y + dy }, targetId: `node:${path}:${node.id}` });
|
|
102
|
+
((ent.props.fields ?? []) as string[]).forEach((f, i) => {
|
|
103
|
+
const col = String(f).trim().split(/\s+/)[0];
|
|
104
|
+
if (col) anchors.set(`${ent.props.name}.${col}`, { rect: { x: node.rect.x + 1, y: node.rect.y + dy + 3 + i, w: Math.max(1, node.rect.w - 2), h: 1 }, badge: [node.rect.x + node.rect.w - 2, node.rect.y + dy + 3 + i] });
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
const block = panBlock(inner, width, `pan:${path}`, ctx);
|
|
108
|
+
remapAnchors(anchors, inner, block);
|
|
109
|
+
return { block, anchors };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function infraBlock(el: El, width: number, path: string, ctx: Ctx): { block: Block; anchors: Map<string, Anchor> } {
|
|
113
|
+
const p = el.props;
|
|
114
|
+
const zones = (p.zones ?? []).map((z: El) => ({ name: z.props.name, tone: z.props.tone, nodes: (z.props.nodes ?? []).map((n: El) => ({ id: n.props.id, label: n.props.label, kind: n.props.kind, status: n.props.status, detail: n.props.detail })) }));
|
|
115
|
+
const links = (p.links ?? []).map((l: El) => ({ from: l.props.from, to: l.props.to, label: l.props.label, style: l.props.style }));
|
|
116
|
+
const res = layoutOnce(el, width, () => infra(zones, links, { direction: p.direction ?? null, maxWidth: width }));
|
|
117
|
+
const inner = resultBlock(res, p.title, path);
|
|
118
|
+
const anchors = new Map<string, Anchor>();
|
|
119
|
+
const dy = p.title ? 1 : 0;
|
|
120
|
+
for (const n of res.nodes) anchors.set(n.id, { rect: { ...n.rect, y: n.rect.y + dy }, targetId: `node:${path}:${n.id}` });
|
|
121
|
+
for (const z of res.zones ?? []) anchors.set(`zone:${z.name}`, { rect: { x: z.rect.x, y: z.rect.y + dy, w: z.rect.w, h: 1 } });
|
|
122
|
+
for (const e of res.edges) {
|
|
123
|
+
const c = e.labelCells[0] ?? e.cells[Math.floor(e.cells.length / 2)];
|
|
124
|
+
if (c) anchors.set(`${e.from}>${e.to}`, { rect: { x: Math.max(0, c[0] - 1), y: c[1] + dy, w: Math.max(3, e.labelCells.length + 2), h: 1 } });
|
|
125
|
+
}
|
|
126
|
+
const block = panBlock(inner, width, `pan:${path}`, ctx);
|
|
127
|
+
remapAnchors(anchors, inner, block);
|
|
128
|
+
return { block, anchors };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** after panning, anchors must follow the targets they point at */
|
|
132
|
+
function remapAnchors(anchors: Map<string, Anchor>, inner: Block, panned: Block) {
|
|
133
|
+
if (panned === inner) return;
|
|
134
|
+
const pan = panned.targets[0]?.pan;
|
|
135
|
+
const dx = pan ? pan.x - panned.targets[0].rect.x : 0;
|
|
136
|
+
for (const [, a] of anchors) {
|
|
137
|
+
a.rect = { ...a.rect, x: a.rect.x - dx };
|
|
138
|
+
if (a.badge) a.badge = [a.badge[0] - dx, a.badge[1]];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export { offsetAnchors, textWidth };
|