diagrammer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,727 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'json'
5
+
6
+ module Diagrammer
7
+ # Holds the standalone HTML template (inline CSS + JS card renderer); the bulk
8
+ # of its length is that template string rather than Ruby logic.
9
+ class HtmlRenderer # rubocop:disable Metrics/ClassLength
10
+ TEMPLATE = <<~HTML
11
+ <!doctype html>
12
+ <html lang="en">
13
+ <head>
14
+ <meta charset="utf-8">
15
+ <meta name="viewport" content="width=device-width, initial-scale=1">
16
+ <title><%= ERB::Util.html_escape(title) %></title>
17
+ <style>
18
+ :root {
19
+ color-scheme: light;
20
+ --bg: #f6f1e8;
21
+ --panel: #fbfcfd;
22
+ --ink: #1f2933;
23
+ --muted: #687385;
24
+ --accent: #0f766e;
25
+ --line: #9aa5b1;
26
+ --card-border: #d2d9e0;
27
+ }
28
+
29
+ * { box-sizing: border-box; }
30
+
31
+ body {
32
+ margin: 0;
33
+ min-height: 100vh;
34
+ font-family: Georgia, "Times New Roman", serif;
35
+ color: var(--ink);
36
+ background:
37
+ radial-gradient(circle at 20% 15%, rgba(15, 118, 110, 0.18), transparent 28rem),
38
+ linear-gradient(135deg, #f6f1e8 0%, #e7dbc8 100%);
39
+ }
40
+
41
+ main {
42
+ box-sizing: border-box;
43
+ width: 100%;
44
+ margin: 0;
45
+ padding: 24px 16px 28px;
46
+ }
47
+
48
+ header { margin-bottom: 16px; }
49
+
50
+ h1 {
51
+ margin: 0 0 6px;
52
+ font-size: clamp(1.8rem, 4vw, 3.2rem);
53
+ letter-spacing: -0.05em;
54
+ }
55
+
56
+ header p { margin: 0; color: var(--muted); }
57
+
58
+ .notice {
59
+ margin-bottom: 16px;
60
+ padding: 16px 18px;
61
+ border: 1px solid rgba(180, 83, 9, 0.28);
62
+ border-radius: 18px;
63
+ background: rgba(254, 243, 199, 0.9);
64
+ color: #92400e;
65
+ }
66
+
67
+ .toolbar {
68
+ display: flex;
69
+ align-items: center;
70
+ gap: 10px;
71
+ margin-bottom: 10px;
72
+ font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
73
+ font-size: 0.85rem;
74
+ color: var(--muted);
75
+ }
76
+
77
+ .toolbar button {
78
+ font: inherit;
79
+ padding: 6px 12px;
80
+ border: 1px solid var(--card-border);
81
+ border-radius: 999px;
82
+ background: #fff;
83
+ color: var(--ink);
84
+ cursor: pointer;
85
+ }
86
+ .toolbar button:hover { border-color: var(--accent); color: var(--accent); }
87
+ .toolbar .spacer { flex: 1; }
88
+
89
+ .viewport {
90
+ position: relative;
91
+ height: min(82vh, 940px);
92
+ overflow: hidden;
93
+ border: 1px solid rgba(31, 41, 51, 0.14);
94
+ border-radius: 18px;
95
+ background-color: var(--panel);
96
+ background-image: radial-gradient(rgba(31, 41, 51, 0.10) 1px, transparent 1px);
97
+ background-size: 22px 22px;
98
+ box-shadow: 0 24px 80px rgba(31, 41, 51, 0.16);
99
+ touch-action: none;
100
+ cursor: grab;
101
+ }
102
+ .viewport.panning { cursor: grabbing; }
103
+
104
+ .world {
105
+ position: absolute;
106
+ top: 0;
107
+ left: 0;
108
+ transform-origin: 0 0;
109
+ will-change: transform;
110
+ }
111
+
112
+ .edges {
113
+ position: absolute;
114
+ top: 0;
115
+ left: 0;
116
+ overflow: visible;
117
+ pointer-events: none;
118
+ }
119
+ .edges .link {
120
+ fill: none;
121
+ stroke: var(--line);
122
+ stroke-width: 1.5;
123
+ vector-effect: non-scaling-stroke;
124
+ }
125
+ .edges .marker {
126
+ fill: none;
127
+ stroke: var(--line);
128
+ stroke-width: 1.5;
129
+ stroke-linecap: round;
130
+ stroke-linejoin: round;
131
+ vector-effect: non-scaling-stroke;
132
+ }
133
+ .edges .edge.active .link,
134
+ .edges .edge.active .marker {
135
+ stroke: var(--accent);
136
+ stroke-width: 2.25;
137
+ }
138
+
139
+ .card {
140
+ position: absolute;
141
+ min-width: 180px;
142
+ background: #fff;
143
+ border: 1px solid var(--card-border);
144
+ border-radius: 8px;
145
+ box-shadow: 0 6px 18px rgba(31, 41, 51, 0.12);
146
+ font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
147
+ font-size: 12px;
148
+ overflow: hidden;
149
+ cursor: grab;
150
+ user-select: none;
151
+ }
152
+ .card.dragging { cursor: grabbing; box-shadow: 0 12px 28px rgba(31, 41, 51, 0.22); z-index: 5; }
153
+ .card.dim { opacity: 0.25; }
154
+ .card.focus { box-shadow: 0 0 0 2px var(--accent), 0 12px 28px rgba(31, 41, 51, 0.22); }
155
+
156
+ .card-header {
157
+ padding: 8px 12px;
158
+ font-weight: 700;
159
+ font-size: 13px;
160
+ color: #fff;
161
+ white-space: nowrap;
162
+ letter-spacing: 0.01em;
163
+ text-shadow: 0 1px 1px rgba(0, 0, 0, 0.18);
164
+ box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.12);
165
+ }
166
+
167
+ .col {
168
+ display: flex;
169
+ align-items: center;
170
+ gap: 14px;
171
+ padding: 4px 12px;
172
+ border-top: 1px solid #eef1f4;
173
+ white-space: nowrap;
174
+ }
175
+ .col .name { font-weight: 500; }
176
+ .col .type {
177
+ margin-left: auto;
178
+ color: var(--muted);
179
+ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
180
+ font-size: 11px;
181
+ }
182
+ .col .badge {
183
+ font-family: ui-monospace, Menlo, Consolas, monospace;
184
+ font-size: 9px;
185
+ font-weight: 700;
186
+ padding: 1px 4px;
187
+ border-radius: 4px;
188
+ letter-spacing: 0.04em;
189
+ }
190
+ .col .badge.pk { background: #fef3c7; color: #92400e; }
191
+ .col .badge.fk { background: #dbeafe; color: #1e40af; }
192
+ .col.is-pk .name { font-weight: 700; }
193
+ .col.is-fk .name { color: #1e40af; }
194
+ .col:hover { background: #f4f6f8; }
195
+ .col.highlight { background: #f0fdfa; }
196
+ </style>
197
+ </head>
198
+ <body>
199
+ <main>
200
+ <header>
201
+ <h1><%= ERB::Util.html_escape(title) %></h1>
202
+ <p>Generated by Diagrammer. Standalone &mdash; no network needed.</p>
203
+ </header>
204
+ <% if notice %>
205
+ <aside class="notice"><%= ERB::Util.html_escape(notice) %></aside>
206
+ <% end %>
207
+ <div class="toolbar">
208
+ <button type="button" data-action="relayout">Re-layout</button>
209
+ <button type="button" data-action="reset">Reset view</button>
210
+ <button type="button" data-action="fit">Fit</button>
211
+ <span class="spacer"></span>
212
+ <span>Scroll to zoom &middot; drag background to pan &middot; drag a table to move it</span>
213
+ </div>
214
+ <div class="viewport" id="viewport">
215
+ <div class="world" id="world">
216
+ <svg class="edges" id="edges"></svg>
217
+ </div>
218
+ </div>
219
+ </main>
220
+ <script id="diagram-data" type="application/json"><%= data_json %></script>
221
+ <script>
222
+ (function () {
223
+ "use strict";
224
+ var data = JSON.parse(document.getElementById("diagram-data").textContent);
225
+ var tables = data.tables || [];
226
+ var relationships = data.relationships || [];
227
+
228
+ var viewport = document.getElementById("viewport");
229
+ var world = document.getElementById("world");
230
+ var edges = document.getElementById("edges");
231
+ var SVGNS = "http://www.w3.org/2000/svg";
232
+
233
+ // Deterministic per-table header colour for visual variety.
234
+ var PALETTE = [
235
+ "#0f766e", "#1d4ed8", "#9333ea", "#be123c", "#b45309",
236
+ "#047857", "#7c3aed", "#0e7490", "#a21caf", "#4d7c0f"
237
+ ];
238
+ function colorFor(name) {
239
+ var h = 0;
240
+ for (var i = 0; i < name.length; i++) { h = (h * 31 + name.charCodeAt(i)) >>> 0; }
241
+ return PALETTE[h % PALETTE.length];
242
+ }
243
+
244
+ // ---- Build cards -------------------------------------------------
245
+ var nodes = {}; // table_name -> node
246
+ var order = []; // stable iteration order
247
+ tables.forEach(function (t) {
248
+ var el = document.createElement("div");
249
+ el.className = "card";
250
+ var header = document.createElement("div");
251
+ header.className = "card-header";
252
+ header.style.background = colorFor(t.table_name);
253
+ header.textContent = t.table_name;
254
+ el.appendChild(header);
255
+
256
+ var rows = {}; // column name -> row element
257
+ (t.columns || []).forEach(function (c) {
258
+ var row = document.createElement("div");
259
+ row.className = "col" + (c.primary_key ? " is-pk" : "") + (c.foreign_key ? " is-fk" : "");
260
+ var name = document.createElement("span");
261
+ name.className = "name";
262
+ name.textContent = c.name;
263
+ row.appendChild(name);
264
+ if (c.primary_key) { row.appendChild(badge("pk", "PK")); }
265
+ if (c.foreign_key) { row.appendChild(badge("fk", "FK")); }
266
+ var type = document.createElement("span");
267
+ type.className = "type";
268
+ type.textContent = c.type;
269
+ row.appendChild(type);
270
+ el.appendChild(row);
271
+ rows[c.name] = row;
272
+ });
273
+
274
+ world.appendChild(el);
275
+ var node = {
276
+ name: t.table_name, el: el, rows: rows, columns: t.columns || [],
277
+ x: 0, y: 0, w: 0, h: 0, vx: 0, vy: 0, edges: []
278
+ };
279
+ nodes[t.table_name] = node;
280
+ order.push(node);
281
+ });
282
+
283
+ function badge(kind, text) {
284
+ var b = document.createElement("span");
285
+ b.className = "badge " + kind;
286
+ b.textContent = text;
287
+ return b;
288
+ }
289
+
290
+ // Measure rendered card sizes.
291
+ order.forEach(function (n) {
292
+ n.w = n.el.offsetWidth;
293
+ n.h = n.el.offsetHeight;
294
+ });
295
+
296
+ // ---- Resolve relationship anchors -------------------------------
297
+ // We only know table->table links. Try to anchor the line at the FK
298
+ // column row ("<assoc>_id") when present, else at the card centre.
299
+ var links = [];
300
+ relationships.forEach(function (r) {
301
+ var from = nodes[r.from], to = nodes[r.to];
302
+ if (!from || !to) { return; }
303
+ var fkName = (r.name || "") + "_id";
304
+ var fkRow = from.rows[fkName] ? fkName : null;
305
+ var pkCol = pkColumn(to);
306
+ var link = { from: from, to: to, fkRow: fkRow, pkRow: pkCol, card: cardinality(r.macro), group: null };
307
+ links.push(link);
308
+ from.edges.push(link);
309
+ to.edges.push(link);
310
+ });
311
+
312
+ function pkColumn(node) {
313
+ for (var i = 0; i < node.columns.length; i++) {
314
+ if (node.columns[i].primary_key) { return node.columns[i].name; }
315
+ }
316
+ return null;
317
+ }
318
+
319
+ // Map an association macro to crow's-foot cardinality at each end.
320
+ function cardinality(macro) {
321
+ switch (macro) {
322
+ case "belongs_to": return { from: "many", to: "one" };
323
+ case "has_one": return { from: "one", to: "one" };
324
+ case "has_many": return { from: "one", to: "many" };
325
+ case "has_and_belongs_to_many": return { from: "many", to: "many" };
326
+ default: return { from: "one", to: "many" };
327
+ }
328
+ }
329
+
330
+ // ---- Cluster layout ---------------------------------------------
331
+ // Split the graph into connected components, lay each one out on its
332
+ // own with a force simulation, then pack the components onto shelves.
333
+ // This keeps related tables together and stops everything collapsing
334
+ // into one central hairball.
335
+ function layout() {
336
+ if (order.length === 0) { return; }
337
+ // Target the viewport's aspect ratio so the diagram fills the width
338
+ // instead of growing tall (which leaves side gaps and vertical scroll).
339
+ var target = clamp(viewport.clientWidth / Math.max(1, viewport.clientHeight), 1.3, 2.4);
340
+ var boxes = connectedComponents().map(function (comp) {
341
+ forceLayout(comp, componentLinks(comp), comp.length > 60 ? 380 : 260);
342
+ var b = bbox(comp);
343
+ comp.forEach(function (node) { node.x -= b.minX; node.y -= b.minY; });
344
+ var w = b.w, h = b.h;
345
+ // Widen tall clusters horizontally toward the target aspect.
346
+ if (comp.length >= 4 && h > 0 && w / h < target) {
347
+ var factor = Math.min(target / (w / h), 2.2);
348
+ comp.forEach(function (node) { node.x *= factor; });
349
+ w *= factor;
350
+ }
351
+ return { comp: comp, w: w, h: h, area: w * h };
352
+ });
353
+ boxes.sort(function (a, b) { return b.area - a.area; });
354
+ packComponents(boxes, target);
355
+ order.forEach(place);
356
+ drawEdges();
357
+ fitView();
358
+ }
359
+
360
+ function connectedComponents() {
361
+ var seen = {}, comps = [];
362
+ order.forEach(function (start) {
363
+ if (seen[start.name]) { return; }
364
+ var queue = [start], comp = [];
365
+ seen[start.name] = true;
366
+ while (queue.length) {
367
+ var node = queue.pop();
368
+ comp.push(node);
369
+ node.edges.forEach(function (l) {
370
+ var other = l.from === node ? l.to : l.from;
371
+ if (!seen[other.name]) { seen[other.name] = true; queue.push(other); }
372
+ });
373
+ }
374
+ comps.push(comp);
375
+ });
376
+ return comps;
377
+ }
378
+
379
+ // All links inside a component (deduped via a transient flag).
380
+ function componentLinks(comp) {
381
+ var list = [];
382
+ comp.forEach(function (node) {
383
+ node.edges.forEach(function (l) {
384
+ if (!l._seen) { l._seen = true; list.push(l); }
385
+ });
386
+ });
387
+ list.forEach(function (l) { l._seen = false; });
388
+ return list;
389
+ }
390
+
391
+ function forceLayout(nodes, lnks, iterations) {
392
+ var n = nodes.length;
393
+ if (n === 1) { nodes[0].x = 0; nodes[0].y = 0; return; }
394
+ var cols = Math.ceil(Math.sqrt(n));
395
+ nodes.forEach(function (node, i) {
396
+ node.x = (i % cols) * 320;
397
+ node.y = Math.floor(i / cols) * 230;
398
+ node.vx = 0; node.vy = 0;
399
+ });
400
+
401
+ var rep = 120000, ideal = 300;
402
+ for (var step = 0; step < iterations; step++) {
403
+ var cooling = 1 - step / iterations;
404
+ for (var i = 0; i < n; i++) {
405
+ var a = nodes[i];
406
+ for (var j = i + 1; j < n; j++) {
407
+ var b = nodes[j];
408
+ var dx = (a.x + a.w / 2) - (b.x + b.w / 2);
409
+ var dy = (a.y + a.h / 2) - (b.y + b.h / 2);
410
+ var d2 = dx * dx + dy * dy + 0.01;
411
+ var d = Math.sqrt(d2);
412
+ var rf = rep / d2;
413
+ var ux = dx / d, uy = dy / d;
414
+ a.vx += ux * rf; a.vy += uy * rf;
415
+ b.vx -= ux * rf; b.vy -= uy * rf;
416
+ }
417
+ }
418
+ lnks.forEach(function (l) {
419
+ if (l.from === l.to) { return; }
420
+ var a = l.from, b = l.to;
421
+ var dx = (b.x + b.w / 2) - (a.x + a.w / 2);
422
+ var dy = (b.y + b.h / 2) - (a.y + a.h / 2);
423
+ var d = Math.sqrt(dx * dx + dy * dy) + 0.01;
424
+ var f = (d - ideal) * 0.01;
425
+ var ux = dx / d, uy = dy / d;
426
+ a.vx += ux * f; a.vy += uy * f;
427
+ b.vx -= ux * f; b.vy -= uy * f;
428
+ });
429
+ for (var k = 0; k < n; k++) {
430
+ var node = nodes[k];
431
+ node.x += clamp(node.vx, -60, 60) * cooling;
432
+ node.y += clamp(node.vy, -60, 60) * cooling;
433
+ node.vx *= 0.85; node.vy *= 0.85;
434
+ }
435
+ resolveCollisions(nodes, 30);
436
+ }
437
+ }
438
+
439
+ function resolveCollisions(nodes, pad) {
440
+ var n = nodes.length;
441
+ for (var i = 0; i < n; i++) {
442
+ var a = nodes[i];
443
+ for (var j = i + 1; j < n; j++) {
444
+ var b = nodes[j];
445
+ var ox = Math.min(a.x + a.w + pad, b.x + b.w + pad) - Math.max(a.x, b.x);
446
+ var oy = Math.min(a.y + a.h + pad, b.y + b.h + pad) - Math.max(a.y, b.y);
447
+ if (ox > 0 && oy > 0) {
448
+ if (ox < oy) {
449
+ var sx = ox / 2;
450
+ if (a.x < b.x) { a.x -= sx; b.x += sx; } else { a.x += sx; b.x -= sx; }
451
+ } else {
452
+ var sy = oy / 2;
453
+ if (a.y < b.y) { a.y -= sy; b.y += sy; } else { a.y += sy; b.y -= sy; }
454
+ }
455
+ }
456
+ }
457
+ }
458
+ }
459
+
460
+ function bbox(nodes) {
461
+ var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
462
+ nodes.forEach(function (n) {
463
+ if (n.x < minX) minX = n.x;
464
+ if (n.y < minY) minY = n.y;
465
+ if (n.x + n.w > maxX) maxX = n.x + n.w;
466
+ if (n.y + n.h > maxY) maxY = n.y + n.h;
467
+ });
468
+ return { minX: minX, minY: minY, w: maxX - minX, h: maxY - minY };
469
+ }
470
+
471
+ // Shelf-pack components left-to-right, wrapping into rows.
472
+ function packComponents(boxes, target) {
473
+ var gap = 90, totalArea = 0, maxW = 0;
474
+ boxes.forEach(function (bx) {
475
+ totalArea += (bx.w + gap) * (bx.h + gap);
476
+ if (bx.w > maxW) { maxW = bx.w; }
477
+ });
478
+ var rowWidth = Math.max(maxW, Math.sqrt(totalArea * target));
479
+ var x = 0, y = 0, rowH = 0;
480
+ boxes.forEach(function (bx) {
481
+ if (x > 0 && x + bx.w > rowWidth) { x = 0; y += rowH + gap; rowH = 0; }
482
+ var ox = x + 60, oy = y + 60;
483
+ bx.comp.forEach(function (node) { node.x += ox; node.y += oy; });
484
+ x += bx.w + gap;
485
+ if (bx.h > rowH) { rowH = bx.h; }
486
+ });
487
+ }
488
+
489
+ function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
490
+
491
+ function place(node) {
492
+ node.el.style.left = node.x + "px";
493
+ node.el.style.top = node.y + "px";
494
+ }
495
+
496
+ // ---- Edge drawing ------------------------------------------------
497
+ function anchor(node, rowName, towardX) {
498
+ // Returns a point on the left or right edge of the card, at the
499
+ // vertical centre of the given row (or the card centre).
500
+ var cy;
501
+ if (rowName && node.rows[rowName]) {
502
+ cy = node.y + node.rows[rowName].offsetTop + node.rows[rowName].offsetHeight / 2;
503
+ } else {
504
+ cy = node.y + node.h / 2;
505
+ }
506
+ var left = node.x, right = node.x + node.w;
507
+ var x = (towardX < node.x + node.w / 2) ? left : right;
508
+ return { x: x, y: cy, side: (x === left ? -1 : 1) };
509
+ }
510
+
511
+ function unit(dx, dy) {
512
+ var d = Math.sqrt(dx * dx + dy * dy);
513
+ return d < 0.001 ? [0, 0] : [dx / d, dy / d];
514
+ }
515
+
516
+ // Orthogonal path with rounded corners through a list of points.
517
+ function roundedPath(pts, r) {
518
+ if (pts.length < 2) { return ""; }
519
+ var d = "M " + pts[0][0] + " " + pts[0][1];
520
+ for (var i = 1; i < pts.length - 1; i++) {
521
+ var p0 = pts[i - 1], p1 = pts[i], p2 = pts[i + 1];
522
+ var len1 = Math.hypot(p0[0] - p1[0], p0[1] - p1[1]);
523
+ var len2 = Math.hypot(p2[0] - p1[0], p2[1] - p1[1]);
524
+ var rr = Math.min(r, len1 / 2, len2 / 2);
525
+ if (rr < 0.5) { d += " L " + p1[0] + " " + p1[1]; continue; }
526
+ var v1 = unit(p0[0] - p1[0], p0[1] - p1[1]);
527
+ var v2 = unit(p2[0] - p1[0], p2[1] - p1[1]);
528
+ d += " L " + (p1[0] + v1[0] * rr) + " " + (p1[1] + v1[1] * rr) +
529
+ " Q " + p1[0] + " " + p1[1] + " " +
530
+ (p1[0] + v2[0] * rr) + " " + (p1[1] + v2[1] * rr);
531
+ }
532
+ var last = pts[pts.length - 1];
533
+ return d + " L " + last[0] + " " + last[1];
534
+ }
535
+
536
+ // Lines exit horizontally from the card side, then step through a
537
+ // shared vertical channel — the dbdiagram.io connector style.
538
+ function orthoPoints(a, b) {
539
+ var stub = 18;
540
+ var a1 = a.x + a.side * stub;
541
+ var b1 = b.x + b.side * stub;
542
+ var midX = (a1 + b1) / 2;
543
+ return [[a.x, a.y], [a1, a.y], [midX, a.y], [midX, b.y], [b1, b.y], [b.x, b.y]];
544
+ }
545
+
546
+ // Crow's-foot ("many") or a single tick ("one") at a card-edge point.
547
+ function markerPath(point, kind) {
548
+ var s = point.side, x = point.x, y = point.y;
549
+ if (kind === "many") {
550
+ var base = x + s * 13;
551
+ return "M " + base + " " + y + " L " + x + " " + (y - 5) +
552
+ " M " + base + " " + y + " L " + x + " " + y +
553
+ " M " + base + " " + y + " L " + x + " " + (y + 5);
554
+ }
555
+ var tx = x + s * 9;
556
+ return "M " + tx + " " + (y - 5) + " L " + tx + " " + (y + 5);
557
+ }
558
+
559
+ function renderEdge(l) {
560
+ var g = l.group;
561
+ while (g.firstChild) { g.removeChild(g.firstChild); }
562
+ var a = anchor(l.from, l.fkRow, l.to.x + l.to.w / 2);
563
+ var b = anchor(l.to, l.pkRow, l.from.x + l.from.w / 2);
564
+
565
+ var link = document.createElementNS(SVGNS, "path");
566
+ link.setAttribute("class", "link");
567
+ link.setAttribute("d", roundedPath(orthoPoints(a, b), 9));
568
+ g.appendChild(link);
569
+
570
+ [[a, l.card.from], [b, l.card.to]].forEach(function (pair) {
571
+ var m = document.createElementNS(SVGNS, "path");
572
+ m.setAttribute("class", "marker");
573
+ m.setAttribute("d", markerPath(pair[0], pair[1]));
574
+ g.appendChild(m);
575
+ });
576
+ }
577
+
578
+ function drawEdges() {
579
+ while (edges.firstChild) { edges.removeChild(edges.firstChild); }
580
+ var bounds = worldBounds();
581
+ edges.setAttribute("width", bounds.w);
582
+ edges.setAttribute("height", bounds.h);
583
+ links.forEach(function (l) {
584
+ var g = document.createElementNS(SVGNS, "g");
585
+ g.setAttribute("class", "edge");
586
+ l.group = g;
587
+ edges.appendChild(g);
588
+ renderEdge(l);
589
+ });
590
+ }
591
+
592
+ function redrawEdgesFor(node) {
593
+ node.edges.forEach(renderEdge);
594
+ }
595
+
596
+ function worldBounds() {
597
+ var w = 0, h = 0;
598
+ order.forEach(function (n) {
599
+ if (n.x + n.w > w) w = n.x + n.w;
600
+ if (n.y + n.h > h) h = n.y + n.h;
601
+ });
602
+ return { w: w + 80, h: h + 80 };
603
+ }
604
+
605
+ // ---- Pan / zoom --------------------------------------------------
606
+ var view = { x: 0, y: 0, scale: 1 };
607
+ function applyView() {
608
+ world.style.transform =
609
+ "translate(" + view.x + "px," + view.y + "px) scale(" + view.scale + ")";
610
+ }
611
+
612
+ function fitView() {
613
+ var b = worldBounds();
614
+ var vw = viewport.clientWidth, vh = viewport.clientHeight;
615
+ var s = Math.min(vw / b.w, vh / b.h);
616
+ s = clamp(s, 0.05, 1);
617
+ view.scale = s;
618
+ view.x = (vw - b.w * s) / 2;
619
+ view.y = (vh - b.h * s) / 2;
620
+ applyView();
621
+ }
622
+
623
+ viewport.addEventListener("wheel", function (e) {
624
+ e.preventDefault();
625
+ var rect = viewport.getBoundingClientRect();
626
+ var mx = e.clientX - rect.left, my = e.clientY - rect.top;
627
+ var factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
628
+ var ns = clamp(view.scale * factor, 0.05, 3);
629
+ // Zoom toward cursor.
630
+ view.x = mx - (mx - view.x) * (ns / view.scale);
631
+ view.y = my - (my - view.y) * (ns / view.scale);
632
+ view.scale = ns;
633
+ applyView();
634
+ }, { passive: false });
635
+
636
+ // Pan vs card drag.
637
+ var panning = false, dragNode = null, last = { x: 0, y: 0 };
638
+ viewport.addEventListener("pointerdown", function (e) {
639
+ var card = e.target.closest(".card");
640
+ last.x = e.clientX; last.y = e.clientY;
641
+ if (card && card._node) {
642
+ dragNode = card._node;
643
+ card.classList.add("dragging");
644
+ highlight(dragNode, true);
645
+ } else {
646
+ panning = true;
647
+ viewport.classList.add("panning");
648
+ }
649
+ viewport.setPointerCapture(e.pointerId);
650
+ });
651
+ viewport.addEventListener("pointermove", function (e) {
652
+ if (!panning && !dragNode) { return; }
653
+ var dx = e.clientX - last.x, dy = e.clientY - last.y;
654
+ last.x = e.clientX; last.y = e.clientY;
655
+ if (dragNode) {
656
+ dragNode.x += dx / view.scale;
657
+ dragNode.y += dy / view.scale;
658
+ place(dragNode);
659
+ redrawEdgesFor(dragNode);
660
+ } else {
661
+ view.x += dx; view.y += dy;
662
+ applyView();
663
+ }
664
+ });
665
+ function endPointer() {
666
+ if (dragNode) {
667
+ dragNode.el.classList.remove("dragging");
668
+ highlight(dragNode, false);
669
+ }
670
+ dragNode = null;
671
+ panning = false;
672
+ viewport.classList.remove("panning");
673
+ }
674
+ viewport.addEventListener("pointerup", endPointer);
675
+ viewport.addEventListener("pointercancel", endPointer);
676
+
677
+ function highlight(node, on) {
678
+ var related = {};
679
+ related[node.name] = true;
680
+ node.edges.forEach(function (l) {
681
+ related[l.from.name] = true; related[l.to.name] = true;
682
+ if (l.group) { l.group.classList.toggle("active", on); }
683
+ });
684
+ order.forEach(function (n) {
685
+ n.el.classList.toggle("dim", on && !related[n.name]);
686
+ n.el.classList.toggle("focus", on && n === node);
687
+ });
688
+ }
689
+
690
+ // Tag cards with their node for hit-testing.
691
+ order.forEach(function (n) { n.el._node = n; });
692
+
693
+ // ---- Controls ----------------------------------------------------
694
+ document.querySelector(".toolbar").addEventListener("click", function (e) {
695
+ var action = e.target.getAttribute("data-action");
696
+ if (action === "relayout") { layout(); }
697
+ else if (action === "reset") { view.scale = 1; view.x = 60; view.y = 60; applyView(); }
698
+ else if (action === "fit") { fitView(); }
699
+ });
700
+
701
+ layout();
702
+ })();
703
+ </script>
704
+ </body>
705
+ </html>
706
+ HTML
707
+
708
+ def initialize(diagram:, title:, notice: nil)
709
+ @diagram = diagram
710
+ @title = title
711
+ @notice = notice
712
+ end
713
+
714
+ def call
715
+ ERB.new(TEMPLATE).result(binding)
716
+ end
717
+
718
+ private
719
+
720
+ attr_reader :diagram, :notice, :title
721
+
722
+ def data_json
723
+ # JSON is the escaping boundary; neutralise any "</script>" in the data.
724
+ JSON.generate(diagram).gsub('</', '<\/')
725
+ end
726
+ end
727
+ end