nandi/oripublic Fork 0
00224b167266e7d496672adfe1e60fcffeab7e63
Commits
Clone
git clone https://git.rickub.com/nandi/ori.git
git clone ssh://git@rickub.com/nandi/ori.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

forked from bots-garden/ori

make-diagram.mjs · 237 lines · 9.1 KBJavaScript Blame HistoryRaw
✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme 76d62ac k33g yesterday1#!/usr/bin/env node
2// Renders a diagram spec (JSON) into a draw.io-editable SVG.
3//
4// node make-diagram.mjs tool-detection.json
5//
6// The output is a plain SVG (readable anywhere, embeddable in Marp) whose
7// `content` attribute carries the mxfile source, so draw.io can reopen and
8// edit it.
9import { readFileSync, writeFileSync } from "node:fs";
10
11const FONT = "Helvetica Neue, Helvetica, Arial, sans-serif";
12const NODE_FONT = 17;
13const EDGE_FONT = 14;
14
15// The palette is the one from the slide theme.
16const KINDS = {
17 box: { fill: "#fdfdfb", stroke: "#15202b", font: "#15202b", bold: false },
18 dark: { fill: "#15202b", stroke: "#15202b", font: "#fdfdfb", bold: true },
19 accent: { fill: "#e8ebda", stroke: "#15202b", font: "#15202b", bold: true },
20 intent: { fill: "#fdfdfb", stroke: "#0062FF", font: "#0062FF", bold: true },
21 note: { fill: "#fdfdfb", stroke: "#15202b", font: "#15202b", bold: false, dashed: true, fontSize: 15 },
22};
23
24const spec = JSON.parse(readFileSync(process.argv[2], "utf8"));
25const byId = Object.fromEntries(spec.nodes.map((n) => [n.id, n]));
26const lines = (label) => label.split("\\n");
27
28// --- geometry ----------------------------------------------------------------
29
30const center = (n) => ({ x: n.x + n.w / 2, y: n.y + n.h / 2 });
31
32// An explicit side ("top" | "bottom" | "left" | "right") with an optional
33// absolute coordinate along it, when the automatic anchor picks the wrong edge
34// (two boxes stacked and linked twice, for instance).
35function sideAnchor(node, side, at, out) {
36 switch (side) {
37 case "top": return { x: at ?? node.x + node.w / 2, y: node.y - out };
38 case "bottom": return { x: at ?? node.x + node.w / 2, y: node.y + node.h + out };
39 case "left": return { x: node.x - out, y: at ?? node.y + node.h / 2 };
40 default: return { x: node.x + node.w + out, y: at ?? node.y + node.h / 2 };
41 }
42}
43
44// Where an edge leaves/enters a box. `out` is the outward gap: 2px on the
45// source side, 6px on the target side so the arrow head does not touch.
46function anchor(node, toward, out) {
47 const c = center(node);
48 const dx = toward.x - c.x;
49 const dy = toward.y - c.y;
50 if (Math.abs(dx) >= Math.abs(dy)) {
51 return dx >= 0
52 ? { x: node.x + node.w + out, y: c.y }
53 : { x: node.x - out, y: c.y };
54 }
55 return dy >= 0
56 ? { x: c.x, y: node.y + node.h + out }
57 : { x: c.x, y: node.y - out };
58}
59
60function route(edge) {
61 const src = byId[edge.from];
62 const tgt = byId[edge.to];
63 const wps = edge.waypoints ?? [];
64 const first = wps[0] ?? center(tgt);
65 const last = wps[wps.length - 1] ?? center(src);
66 const start = edge.fromSide
67 ? sideAnchor(src, edge.fromSide, edge.fromAt, 2)
68 : anchor(src, first, 2);
69 const end = edge.toSide
70 ? sideAnchor(tgt, edge.toSide, edge.toAt, 6)
71 : anchor(tgt, last, 6);
72 // Keep the orthogonal legs square when a waypoint dictates the axis.
73 const pts = [start, ...wps.map((p) => ({ ...p })), end];
74 if (wps.length === 0 && !edge.fromSide && !edge.toSide) {
75 if (Math.abs(end.x - start.x) >= Math.abs(end.y - start.y)) end.y = start.y;
76 else end.x = start.x;
77 }
78 return pts;
79}
80
81// The label sits on the longest segment of the route.
82function labelPoint(pts) {
83 let best = null;
84 let bestLen = -1;
85 for (let i = 0; i < pts.length - 1; i++) {
86 const len = Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y);
87 if (len > bestLen) {
88 bestLen = len;
89 best = { x: (pts[i].x + pts[i + 1].x) / 2, y: (pts[i].y + pts[i + 1].y) / 2 };
90 }
91 }
92 return best;
93}
94
95// --- escaping ----------------------------------------------------------------
96
97const xmlText = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
98const xmlAttr = (s) =>
99 s
100 .replace(/&/g, "&amp;")
101 .replace(/</g, "&lt;")
102 .replace(/>/g, "&gt;")
103 .replace(/"/g, "&quot;")
104 // Line breaks travel as an entity draw.io still sees after the SVG
105 // `content` attribute has been decoded once, hence the extra level.
106 .replace(/\\n/g, "&amp;#10;");
107
108// --- mxfile (what draw.io reopens) -------------------------------------------
109
110// The same side hint, expressed the way draw.io stores it.
111function sideStyle(prefix, node, side, at) {
112 if (!side) return "";
113 const along = { top: [null, 0], bottom: [null, 1], left: [0, null], right: [1, null] }[side];
114 const x = along[0] ?? (at === undefined ? 0.5 : (at - node.x) / node.w);
115 const y = along[1] ?? (at === undefined ? 0.5 : (at - node.y) / node.h);
116 return `${prefix}X=${x};${prefix}Y=${y};${prefix}Dx=0;${prefix}Dy=0;`;
117}
118
119function mxfile() {
120 const cells = [];
121 let id = 3;
122 const ids = {};
123
124 for (const n of spec.nodes) {
125 const k = KINDS[n.kind];
126 ids[n.id] = id;
127 const style =
128 `rounded=1;arcSize=12;whiteSpace=wrap;html=1;fillColor=${k.fill};strokeColor=${k.stroke};` +
129 `fontColor=${k.font};fontSize=${k.fontSize ?? NODE_FONT};fontStyle=${k.bold ? 1 : 0};` +
130 `strokeWidth=2;${k.dashed ? "dashed=1;" : ""}`;
131 cells.push(
132 `<mxCell id="${id}" value="${xmlAttr(n.label)}" style="${xmlAttr(style)}" vertex="1" parent="1">` +
133 `<mxGeometry x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" as="geometry"/></mxCell>`
134 );
135 id++;
136 }
137
138 for (const e of spec.edges) {
139 const color = e.dashed ? "#0062FF" : "#15202b";
140 const style =
141 `edgeStyle=orthogonalEdgeStyle;rounded=1;html=1;strokeWidth=2;strokeColor=${color};` +
142 `fontSize=${EDGE_FONT};fontColor=${color};${e.dashed ? "dashed=1;" : ""}` +
143 sideStyle("exit", byId[e.from], e.fromSide, e.fromAt) +
144 sideStyle("entry", byId[e.to], e.toSide, e.toAt);
145 const pts = (e.waypoints ?? [])
146 .map((p) => `<mxPoint x="${p.x}" y="${p.y}"/>`)
147 .join("");
148 cells.push(
149 `<mxCell id="${id}" value="${xmlAttr(e.label ?? "")}" style="${xmlAttr(style)}" edge="1" parent="1" ` +
150 `source="${ids[e.from]}" target="${ids[e.to]}"><mxGeometry relative="1" as="geometry">` +
151 (pts ? `<Array as="points">${pts}</Array>` : "") +
152 `</mxGeometry></mxCell>`
153 );
154 id++;
155 }
156
157 return (
158 `<mxfile host="Electron" agent="make-diagram.mjs" type="device">` +
159 `<diagram name="${xmlAttr(spec.name)}" id="diagram-1">` +
160 `<mxGraphModel dx="1000" dy="600" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" ` +
161 `arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="826" math="0" shadow="0">` +
162 `<root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells.join("")}</root>` +
163 `</mxGraphModel></diagram></mxfile>`
164 );
165}
166
167// --- svg ---------------------------------------------------------------------
168
169const out = [];
170out.push(
171 `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" ` +
172 `width="${spec.width}" height="${spec.height}" viewBox="0 0 ${spec.width} ${spec.height}" ` +
173 `content="${xmlAttr(mxfile())}">`
174);
175out.push(
176 ` <defs>` +
177 `<marker id="arrow-dark" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">` +
178 `<path d="M 0 0 L 10 5 L 0 10 z" fill="#15202b"/></marker>` +
179 `<marker id="arrow-blue" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">` +
180 `<path d="M 0 0 L 10 5 L 0 10 z" fill="#0062FF"/></marker>` +
181 `</defs>`
182);
183
184// Edges first, so the boxes paint over their tails.
185for (const e of spec.edges) {
186 const pts = route(e);
187 const color = e.dashed ? "#0062FF" : "#15202b";
188 const marker = e.dashed ? "arrow-blue" : "arrow-dark";
189 const d = pts.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" ");
190 out.push(
191 ` <path d="${d}" fill="none" stroke="${color}" stroke-width="2"` +
192 (e.dashed ? ` stroke-dasharray="6 5"` : "") +
193 ` marker-end="url(#${marker})"/>`
194 );
195 if (!e.label) continue;
196 const ls = lines(e.label);
197 const p = labelPoint(pts);
198 const w = Math.max(...ls.map((l) => l.length)) * 7.6 + 14;
199 const h = ls.length * 17 + 8;
200 out.push(
201 ` <rect x="${p.x - w / 2}" y="${p.y - h / 2}" width="${w}" height="${h}" rx="4" fill="#fdfdfb" opacity="0.92"/>`
202 );
203 ls.forEach((l, i) => {
204 const y = p.y + (i - (ls.length - 1) / 2) * 17;
205 out.push(
206 ` <text x="${p.x}" y="${y}" text-anchor="middle" dominant-baseline="central" ` +
207 `font-family="${FONT}" font-size="${EDGE_FONT}" fill="${color}">${xmlText(l)}</text>`
208 );
209 });
210}
211
212for (const n of spec.nodes) {
213 const k = KINDS[n.kind];
214 const size = k.fontSize ?? NODE_FONT;
215 out.push(
216 ` <rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" rx="10" ry="10" ` +
217 `fill="${k.fill}" stroke="${k.stroke}" stroke-width="2"` +
218 (k.dashed ? ` stroke-dasharray="6 5"` : "") +
219 `/>`
220 );
221 const ls = lines(n.label);
222 const c = center(n);
223 ls.forEach((l, i) => {
224 const y = c.y + (i - (ls.length - 1) / 2) * (size + 4);
225 out.push(
226 ` <text x="${c.x}" y="${y}" text-anchor="middle" dominant-baseline="central" ` +
227 `font-family="${FONT}" font-size="${size}" font-weight="${k.bold ? "bold" : "normal"}" ` +
228 `fill="${k.font}">${xmlText(l)}</text>`
229 );
230 });
231}
232
233out.push(`</svg>`);
234
235const target = process.argv[2].replace(/\.json$/, ".drawio.svg");
236writeFileSync(target, out.join("\n") + "\n");
237console.log(`wrote ${target}`);