forked from bots-garden/ori
| ✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme | 1 | #!/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. | |
| 9 | import { readFileSync, writeFileSync } from "node:fs"; | |
| 10 | ||
| 11 | const FONT = "Helvetica Neue, Helvetica, Arial, sans-serif"; | |
| 12 | const NODE_FONT = 17; | |
| 13 | const EDGE_FONT = 14; | |
| 14 | ||
| 15 | // The palette is the one from the slide theme. | |
| 16 | const 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 | ||
| 24 | const spec = JSON.parse(readFileSync(process.argv[2], "utf8")); | |
| 25 | const byId = Object.fromEntries(spec.nodes.map((n) => [n.id, n])); | |
| 26 | const lines = (label) => label.split("\\n"); | |
| 27 | ||
| 28 | // --- geometry ---------------------------------------------------------------- | |
| 29 | ||
| 30 | const 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). | |
| 35 | function 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. | |
| 46 | function 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 | ||
| 60 | function 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. | |
| 82 | function 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 | ||
| 97 | const xmlText = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); | |
| 98 | const xmlAttr = (s) => | |
| 99 | s | |
| 100 | .replace(/&/g, "&") | |
| 101 | .replace(/</g, "<") | |
| 102 | .replace(/>/g, ">") | |
| 103 | .replace(/"/g, """) | |
| 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, "&#10;"); | |
| 107 | ||
| 108 | // --- mxfile (what draw.io reopens) ------------------------------------------- | |
| 109 | ||
| 110 | // The same side hint, expressed the way draw.io stores it. | |
| 111 | function 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 | ||
| 119 | function 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 | ||
| 169 | const out = []; | |
| 170 | out.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 | ); | |
| 175 | out.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. | |
| 185 | for (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 | ||
| 212 | for (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 | ||
| 233 | out.push(`</svg>`); | |
| 234 | ||
| 235 | const target = process.argv[2].replace(/\.json$/, ".drawio.svg"); | |
| 236 | writeFileSync(target, out.join("\n") + "\n"); | |
| 237 | console.log(`wrote ${target}`); |