nandi/oripublic Fork 0
db7446cc6b52b6bd9196294c1e66e5eb56754d5e
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
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
#!/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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const xmlAttr = (s) =>
  s
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    // 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, "&amp;#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(
      `<mxCell id="${id}" value="${xmlAttr(n.label)}" style="${xmlAttr(style)}" vertex="1" parent="1">` +
        `<mxGeometry x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" as="geometry"/></mxCell>`
    );
    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) => `<mxPoint x="${p.x}" y="${p.y}"/>`)
      .join("");
    cells.push(
      `<mxCell id="${id}" value="${xmlAttr(e.label ?? "")}" style="${xmlAttr(style)}" edge="1" parent="1" ` +
        `source="${ids[e.from]}" target="${ids[e.to]}"><mxGeometry relative="1" as="geometry">` +
        (pts ? `<Array as="points">${pts}</Array>` : "") +
        `</mxGeometry></mxCell>`
    );
    id++;
  }

  return (
    `<mxfile host="Electron" agent="make-diagram.mjs" type="device">` +
    `<diagram name="${xmlAttr(spec.name)}" id="diagram-1">` +
    `<mxGraphModel dx="1000" dy="600" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" ` +
    `arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="826" math="0" shadow="0">` +
    `<root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells.join("")}</root>` +
    `</mxGraphModel></diagram></mxfile>`
  );
}

// --- svg ---------------------------------------------------------------------

const out = [];
out.push(
  `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" ` +
    `width="${spec.width}" height="${spec.height}" viewBox="0 0 ${spec.width} ${spec.height}" ` +
    `content="${xmlAttr(mxfile())}">`
);
out.push(
  `    <defs>` +
    `<marker id="arrow-dark" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">` +
    `<path d="M 0 0 L 10 5 L 0 10 z" fill="#15202b"/></marker>` +
    `<marker id="arrow-blue" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">` +
    `<path d="M 0 0 L 10 5 L 0 10 z" fill="#0062FF"/></marker>` +
    `</defs>`
);

// 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(
    `    <path d="${d}" fill="none" stroke="${color}" stroke-width="2"` +
      (e.dashed ? ` stroke-dasharray="6 5"` : "") +
      ` marker-end="url(#${marker})"/>`
  );
  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(
    `    <rect x="${p.x - w / 2}" y="${p.y - h / 2}" width="${w}" height="${h}" rx="4" fill="#fdfdfb" opacity="0.92"/>`
  );
  ls.forEach((l, i) => {
    const y = p.y + (i - (ls.length - 1) / 2) * 17;
    out.push(
      `    <text x="${p.x}" y="${y}" text-anchor="middle" dominant-baseline="central" ` +
        `font-family="${FONT}" font-size="${EDGE_FONT}" fill="${color}">${xmlText(l)}</text>`
    );
  });
}

for (const n of spec.nodes) {
  const k = KINDS[n.kind];
  const size = k.fontSize ?? NODE_FONT;
  out.push(
    `    <rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" rx="10" ry="10" ` +
      `fill="${k.fill}" stroke="${k.stroke}" stroke-width="2"` +
      (k.dashed ? ` stroke-dasharray="6 5"` : "") +
      `/>`
  );
  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(
      `    <text x="${c.x}" y="${y}" text-anchor="middle" dominant-baseline="central" ` +
        `font-family="${FONT}" font-size="${size}" font-weight="${k.bold ? "bold" : "normal"}" ` +
        `fill="${k.font}">${xmlText(l)}</text>`
    );
  });
}

out.push(`</svg>`);

const target = process.argv[2].replace(/\.json$/, ".drawio.svg");
writeFileSync(target, out.join("\n") + "\n");
console.log(`wrote ${target}`);