#!/usr/bin/env node // Renders a diagram spec (JSON) into a draw.io-editable SVG. // // node make-diagram.mjs tool-detection.json // // The output is a plain SVG (readable anywhere, embeddable in Marp) whose // `content` attribute carries the mxfile source, so draw.io can reopen and // edit it. import { readFileSync, writeFileSync } from "node:fs"; const FONT = "Helvetica Neue, Helvetica, Arial, sans-serif"; const NODE_FONT = 17; const EDGE_FONT = 14; // The palette is the one from the slide theme. const KINDS = { box: { fill: "#fdfdfb", stroke: "#15202b", font: "#15202b", bold: false }, dark: { fill: "#15202b", stroke: "#15202b", font: "#fdfdfb", bold: true }, accent: { fill: "#e8ebda", stroke: "#15202b", font: "#15202b", bold: true }, intent: { fill: "#fdfdfb", stroke: "#0062FF", font: "#0062FF", bold: true }, note: { fill: "#fdfdfb", stroke: "#15202b", font: "#15202b", bold: false, dashed: true, fontSize: 15 }, }; const spec = JSON.parse(readFileSync(process.argv[2], "utf8")); const byId = Object.fromEntries(spec.nodes.map((n) => [n.id, n])); const lines = (label) => label.split("\\n"); // --- geometry ---------------------------------------------------------------- const center = (n) => ({ x: n.x + n.w / 2, y: n.y + n.h / 2 }); // An explicit side ("top" | "bottom" | "left" | "right") with an optional // absolute coordinate along it, when the automatic anchor picks the wrong edge // (two boxes stacked and linked twice, for instance). function sideAnchor(node, side, at, out) { switch (side) { case "top": return { x: at ?? node.x + node.w / 2, y: node.y - out }; case "bottom": return { x: at ?? node.x + node.w / 2, y: node.y + node.h + out }; case "left": return { x: node.x - out, y: at ?? node.y + node.h / 2 }; default: return { x: node.x + node.w + out, y: at ?? node.y + node.h / 2 }; } } // Where an edge leaves/enters a box. `out` is the outward gap: 2px on the // source side, 6px on the target side so the arrow head does not touch. function anchor(node, toward, out) { const c = center(node); const dx = toward.x - c.x; const dy = toward.y - c.y; if (Math.abs(dx) >= Math.abs(dy)) { return dx >= 0 ? { x: node.x + node.w + out, y: c.y } : { x: node.x - out, y: c.y }; } return dy >= 0 ? { x: c.x, y: node.y + node.h + out } : { x: c.x, y: node.y - out }; } function route(edge) { const src = byId[edge.from]; const tgt = byId[edge.to]; const wps = edge.waypoints ?? []; const first = wps[0] ?? center(tgt); const last = wps[wps.length - 1] ?? center(src); const start = edge.fromSide ? sideAnchor(src, edge.fromSide, edge.fromAt, 2) : anchor(src, first, 2); const end = edge.toSide ? sideAnchor(tgt, edge.toSide, edge.toAt, 6) : anchor(tgt, last, 6); // Keep the orthogonal legs square when a waypoint dictates the axis. const pts = [start, ...wps.map((p) => ({ ...p })), end]; if (wps.length === 0 && !edge.fromSide && !edge.toSide) { if (Math.abs(end.x - start.x) >= Math.abs(end.y - start.y)) end.y = start.y; else end.x = start.x; } return pts; } // The label sits on the longest segment of the route. function labelPoint(pts) { let best = null; let bestLen = -1; for (let i = 0; i < pts.length - 1; i++) { const len = Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y); if (len > bestLen) { bestLen = len; best = { x: (pts[i].x + pts[i + 1].x) / 2, y: (pts[i].y + pts[i + 1].y) / 2 }; } } return best; } // --- escaping ---------------------------------------------------------------- const xmlText = (s) => s.replace(/&/g, "&").replace(//g, ">"); const xmlAttr = (s) => s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) // Line breaks travel as an entity draw.io still sees after the SVG // `content` attribute has been decoded once, hence the extra level. .replace(/\\n/g, "&#10;"); // --- mxfile (what draw.io reopens) ------------------------------------------- // The same side hint, expressed the way draw.io stores it. function sideStyle(prefix, node, side, at) { if (!side) return ""; const along = { top: [null, 0], bottom: [null, 1], left: [0, null], right: [1, null] }[side]; const x = along[0] ?? (at === undefined ? 0.5 : (at - node.x) / node.w); const y = along[1] ?? (at === undefined ? 0.5 : (at - node.y) / node.h); return `${prefix}X=${x};${prefix}Y=${y};${prefix}Dx=0;${prefix}Dy=0;`; } function mxfile() { const cells = []; let id = 3; const ids = {}; for (const n of spec.nodes) { const k = KINDS[n.kind]; ids[n.id] = id; const style = `rounded=1;arcSize=12;whiteSpace=wrap;html=1;fillColor=${k.fill};strokeColor=${k.stroke};` + `fontColor=${k.font};fontSize=${k.fontSize ?? NODE_FONT};fontStyle=${k.bold ? 1 : 0};` + `strokeWidth=2;${k.dashed ? "dashed=1;" : ""}`; cells.push( `` + `` ); id++; } for (const e of spec.edges) { const color = e.dashed ? "#0062FF" : "#15202b"; const style = `edgeStyle=orthogonalEdgeStyle;rounded=1;html=1;strokeWidth=2;strokeColor=${color};` + `fontSize=${EDGE_FONT};fontColor=${color};${e.dashed ? "dashed=1;" : ""}` + sideStyle("exit", byId[e.from], e.fromSide, e.fromAt) + sideStyle("entry", byId[e.to], e.toSide, e.toAt); const pts = (e.waypoints ?? []) .map((p) => ``) .join(""); cells.push( `` + (pts ? `${pts}` : "") + `` ); id++; } return ( `` + `` + `` + `${cells.join("")}` + `` ); } // --- svg --------------------------------------------------------------------- const out = []; out.push( `` ); out.push( ` ` + `` + `` + `` + `` + `` ); // Edges first, so the boxes paint over their tails. for (const e of spec.edges) { const pts = route(e); const color = e.dashed ? "#0062FF" : "#15202b"; const marker = e.dashed ? "arrow-blue" : "arrow-dark"; const d = pts.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" "); out.push( ` ` ); if (!e.label) continue; const ls = lines(e.label); const p = labelPoint(pts); const w = Math.max(...ls.map((l) => l.length)) * 7.6 + 14; const h = ls.length * 17 + 8; out.push( ` ` ); ls.forEach((l, i) => { const y = p.y + (i - (ls.length - 1) / 2) * 17; out.push( ` ${xmlText(l)}` ); }); } for (const n of spec.nodes) { const k = KINDS[n.kind]; const size = k.fontSize ?? NODE_FONT; out.push( ` ` ); const ls = lines(n.label); const c = center(n); ls.forEach((l, i) => { const y = c.y + (i - (ls.length - 1) / 2) * (size + 4); out.push( ` ${xmlText(l)}` ); }); } out.push(``); const target = process.argv[2].replace(/\.json$/, ".drawio.svg"); writeFileSync(target, out.join("\n") + "\n"); console.log(`wrote ${target}`);