#!/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, " ");
// --- 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(
``);
const target = process.argv[2].replace(/\.json$/, ".drawio.svg");
writeFileSync(target, out.join("\n") + "\n");
console.log(`wrote ${target}`);