1/* 2 * Architecture diagram renderer. 3 * 4 * Originally prototyped on rete.js, but we use none of rete's graph features 5 * (no engine, no validated connections, no sockets) â just positioned cards 6 * with bezier connectors. Pulling rete + a render plugin in via esm.sh 7 * triggered `instanceof Scope` mismatches and empty node DOM. Replaced with a 8 * ~200 LOC vanilla implementation: no external runtime deps, no import map. 9 * 10 * Each `<div class="rete-diagram" data-rete-config="...">` element is hydrated 11 * with a draggable / pan- and zoom-able canvas built from the JSON config: 12 * 13 * { 14 * "groups": [ { "id": "edge", "label": "Edge", "x": 0, "y": 0, "w": 320, "h": 460 } ], 15 * "nodes": [ { "id": "src", "label": "...", "kind": "source", 16 * "x": 540, "y": 110, "w": 252, "h": 120, 17 * "header": "VAULT SOURCE", "title": "Forwarder", 18 * "subtitle": "..." } ], 19 * "connections": [ { "from": "src", "to": "sink1", 20 * "fromSide": "right", "toSide": "left", 21 * "kind": "thick" } ] 22 * } 23 * 24 * The class name is kept as `.rete-diagram` for backward compatibility with 25 * the existing CSS and shortcode. 26 */ 27 28const SVG_NS = 'http://www.w3.org/2000/svg'; 29 30function createNodeEl(n) { 31 const el = document.createElement('div'); 32 el.className = `rete-node rete-node--${n.kind || 'default'}`; 33 el.style.position = 'absolute'; 34 el.style.left = '0'; 35 el.style.top = '0'; 36 el.style.width = (n.w || 220) + 'px'; 37 el.style.height = (n.h || 120) + 'px'; 38 el.style.transform = `translate(${n.x}px, ${n.y}px)`; 39 el.dataset.nodeId = n.id; 40 41 if (n.header) { 42 const header = document.createElement('div'); 43 header.className = 'rete-node__header'; 44 header.textContent = n.header; 45 el.appendChild(header); 46 } 47 if (n.step !== undefined && n.step !== null && n.step !== '') { 48 const step = document.createElement('div'); 49 step.className = 'rete-node__step'; 50 step.textContent = n.step; 51 el.appendChild(step); 52 } 53 const body = document.createElement('div'); 54 body.className = 'rete-node__body'; 55 if (n.title) { 56 const title = document.createElement('div'); 57 title.className = 'rete-node__title'; 58 title.textContent = n.title; 59 body.appendChild(title); 60 } 61 if (n.subtitle) { 62 const sub = document.createElement('div'); 63 sub.className = 'rete-node__subtitle'; 64 sub.textContent = n.subtitle; 65 body.appendChild(sub); 66 } 67 if (Array.isArray(n.badges) && n.badges.length) { 68 const badges = document.createElement('div'); 69 badges.className = 'rete-node__badges'; 70 n.badges.forEach(b => { 71 const slug = (typeof b === 'string') ? b : b.slug; 72 const label = (typeof b === 'string') ? b : (b.label || b.slug); 73 const img = document.createElement('img'); 74 img.className = 'rete-node__badge'; 75 img.alt = label; 76 img.title = label; 77 img.loading = 'lazy'; 78 img.src = `/icons/brands/${slug}.svg`; 79 badges.appendChild(img); 80 }); 81 body.appendChild(badges); 82 } 83 el.appendChild(body); 84 return el; 85} 86 87function createGroupEl(g) { 88 const el = document.createElement('div'); 89 el.className = 'rete-group'; 90 el.style.position = 'absolute'; 91 el.style.left = '0'; 92 el.style.top = '0'; 93 el.style.width = g.w + 'px'; 94 el.style.height = g.h + 'px'; 95 el.style.transform = `translate(${g.x}px, ${g.y}px)`; 96 if (g.label) { 97 const label = document.createElement('div'); 98 label.className = 'rete-group__label'; 99 label.textContent = g.label; 100 el.appendChild(label); 101 } 102 return el; 103} 104 105// Orthogonal connector. The line leaves `fromSide` and enters `toSide` 106// perpendicular to the node face, then joins the two stubs with axis-aligned 107// segments so every corner is a clean 90°. `offset` shifts the shared 108// riser/channel so parallel edges between the same faces don't overlap. 109// Returns the polyline points (corners included). 110function orthogonalPoints(x1, y1, x2, y2, fromSide, toSide, offset = 0) { 111 const STUB = 20; // perpendicular lead-in / lead-out from each node face 112 const DIR = { 113 left: [-1, 0], right: [1, 0], 114 top: [0, -1], bottom: [0, 1], 115 }; 116 const [fdx, fdy] = DIR[fromSide] || DIR.right; 117 const [tdx, tdy] = DIR[toSide] || DIR.left; 118 const p1 = { x: x1 + fdx * STUB, y: y1 + fdy * STUB }; 119 const p2 = { x: x2 + tdx * STUB, y: y2 + tdy * STUB }; 120 const fromH = fdy === 0; // horizontal stub (left/right face) 121 const toH = tdy === 0; 122 123 const pts = [{ x: x1, y: y1 }, p1]; 124 if (fromH && toH) { 125 // Two horizontal stubs joined by a vertical riser at the mid-x. 126 const mx = (p1.x + p2.x) / 2 + offset; 127 pts.push({ x: mx, y: p1.y }, { x: mx, y: p2.y }); 128 } else if (!fromH && !toH) { 129 // Two vertical stubs joined by a horizontal channel at the mid-y. 130 const my = (p1.y + p2.y) / 2 + offset; 131 pts.push({ x: p1.x, y: my }, { x: p2.x, y: my }); 132 } else if (fromH) { 133 // Horizontal then vertical: a single right-angle elbow. 134 pts.push({ x: p2.x, y: p1.y }); 135 } else { 136 // Vertical then horizontal: a single right-angle elbow. 137 pts.push({ x: p1.x, y: p2.y }); 138 } 139 pts.push(p2, { x: x2, y: y2 }); 140 141 // Drop zero-length steps so coincident points don't leave stray nubs. 142 return pts.filter((p, i) => 143 i === 0 || p.x !== pts[i - 1].x || p.y !== pts[i - 1].y); 144} 145 146// Build an SVG path from an orthogonal polyline. Each corner is softened by a 147// short quadratic arc (`radius`), capped so it can never overshoot a short 148// segment; pass radius 0 for perfectly square corners. 149function orthogonalPath(pts, radius = 8) { 150 if (pts.length < 2) return ''; 151 if (radius <= 0 || pts.length === 2) { 152 return 'M ' + pts.map(p => `${p.x} ${p.y}`).join(' L '); 153 } 154 let d = `M ${pts[0].x} ${pts[0].y}`; 155 for (let i = 1; i < pts.length - 1; i++) { 156 const prev = pts[i - 1], cur = pts[i], next = pts[i + 1]; 157 const inLen = Math.hypot(cur.x - prev.x, cur.y - prev.y); 158 const outLen = Math.hypot(next.x - cur.x, next.y - cur.y); 159 const r = Math.min(radius, inLen / 2, outLen / 2); 160 const inx = cur.x - Math.sign(cur.x - prev.x) * r; 161 const iny = cur.y - Math.sign(cur.y - prev.y) * r; 162 const outx = cur.x + Math.sign(next.x - cur.x) * r; 163 const outy = cur.y + Math.sign(next.y - cur.y) * r; 164 d += ` L ${inx} ${iny} Q ${cur.x} ${cur.y} ${outx} ${outy}`; 165 } 166 const last = pts[pts.length - 1]; 167 return d + ` L ${last.x} ${last.y}`; 168} 169 170// Point halfway along the polyline (by length) â used to place an edge label. 171function polylineMidpoint(pts) { 172 let total = 0; 173 for (let i = 1; i < pts.length; i++) { 174 total += Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y); 175 } 176 let half = total / 2; 177 for (let i = 1; i < pts.length; i++) { 178 const seg = Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y); 179 if (half <= seg) { 180 const t = seg === 0 ? 0 : half / seg; 181 return { 182 x: pts[i - 1].x + (pts[i].x - pts[i - 1].x) * t, 183 y: pts[i - 1].y + (pts[i].y - pts[i - 1].y) * t, 184 }; 185 } 186 half -= seg; 187 } 188 return pts[pts.length - 1]; 189} 190 191function anchorPoint(node, side) { 192 const w = node.w || 220; 193 const h = node.h || 120; 194 switch (side) {
195 case 'left': return { x: node.x, y: node.y + h / 2 }; 196 case 'right': return { x: node.x + w, y: node.y + h / 2 }; 197 case 'top': return { x: node.x + w / 2, y: node.y }; 198 case 'bottom': return { x: node.x + w / 2, y: node.y + h }; 199 default: return { x: node.x + w / 2, y: node.y + h / 2 }; 200 } 201} 202 203function init(container) { 204 let config; 205 try { 206 config = JSON.parse(container.dataset.reteConfig); 207 } catch (err) { 208 console.error('[rete-diagram] invalid config', err, container); 209 return; 210 } 211 container.classList.add('rete-diagram--ready'); 212 container.innerHTML = ''; 213 214 // Layered structure (all transformed together by the viewport): 215 // viewport 216 // ââ groups (background rectangles, non-interactive) 217 // ââ svg (connection paths) 218 // ââ nodes (draggable cards on top) 219 const viewport = document.createElement('div'); 220 viewport.className = 'rete-viewport'; 221 viewport.style.position = 'absolute'; 222 viewport.style.left = '0'; 223 viewport.style.top = '0'; 224 viewport.style.transformOrigin = '0 0'; 225 container.appendChild(viewport); 226 227 const groupLayer = document.createElement('div'); 228 groupLayer.className = 'rete-group-layer'; 229 groupLayer.style.position = 'absolute'; 230 groupLayer.style.inset = '0'; 231 groupLayer.style.pointerEvents = 'none'; 232 viewport.appendChild(groupLayer); 233 234 const svg = document.createElementNS(SVG_NS, 'svg'); 235 svg.setAttribute('class', 'rete-connections'); 236 svg.style.position = 'absolute'; 237 svg.style.left = '0'; 238 svg.style.top = '0'; 239 svg.style.overflow = 'visible'; 240 svg.style.pointerEvents = 'none'; 241 viewport.appendChild(svg); 242 243 const nodeLayer = document.createElement('div'); 244 nodeLayer.className = 'rete-node-layer'; 245 nodeLayer.style.position = 'absolute'; 246 nodeLayer.style.inset = '0'; 247 viewport.appendChild(nodeLayer); 248 249 // Toolbar (fullscreen toggle). Sits above the viewport, fixed in container 250 // coords so it stays put when panning/zooming. 251 const toolbar = document.createElement('div'); 252 toolbar.className = 'rete-toolbar'; 253 const fsBtn = document.createElement('button'); 254 fsBtn.type = 'button'; 255 fsBtn.className = 'rete-toolbar__btn'; 256 fsBtn.setAttribute('aria-label', 'Toggle fullscreen'); 257 fsBtn.title = 'Toggle fullscreen'; 258 const ICON_EXPAND = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9V4h5"/><path d="M20 9V4h-5"/><path d="M4 15v5h5"/><path d="M20 15v5h-5"/></svg>'; 259 const ICON_COLLAPSE = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4v5H4"/><path d="M15 4v5h5"/><path d="M9 20v-5H4"/><path d="M15 20v-5h5"/></svg>'; 260 fsBtn.innerHTML = ICON_EXPAND; 261 toolbar.appendChild(fsBtn); 262 container.appendChild(toolbar); 263 264 // Build groups. 265 (config.groups || []).forEach(g => groupLayer.appendChild(createGroupEl(g))); 266 267 // Build nodes; remember each node's element so we can update its transform. 268 const nodes = (config.nodes || []).map(n => ({ ...n })); 269 const nodeEls = new Map(); 270 nodes.forEach(n => { 271 const el = createNodeEl(n); 272 nodeLayer.appendChild(el); 273 nodeEls.set(n.id, el); 274 }); 275 const nodeById = new Map(nodes.map(n => [n.id, n])); 276 277 // Assign each node to the group that contains it at init time. Drag is 278 // then clamped to that group's bounds so a node can't escape its group. 279 const groups = config.groups || []; 280 function findGroupFor(n) { 281 const w = n.w || 220, h = n.h || 120; 282 const cx = n.x + w / 2, cy = n.y + h / 2; 283 return groups.find(g => 284 cx >= g.x && cx <= g.x + g.w && 285 cy >= g.y && cy <= g.y + g.h 286 ) || null; 287 } 288 nodes.forEach(n => { n._group = findGroupFor(n); }); 289 290 function redraw() { 291 let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
292 (config.groups || []).forEach(g => { 293 minX = Math.min(minX, g.x); minY = Math.min(minY, g.y); 294 maxX = Math.max(maxX, g.x + g.w); maxY = Math.max(maxY, g.y + g.h); 295 }); 296 nodes.forEach(n => { 297 minX = Math.min(minX, n.x); minY = Math.min(minY, n.y); 298 maxX = Math.max(maxX, n.x + (n.w || 220)); 299 maxY = Math.max(maxY, n.y + (n.h || 120)); 300 }); 301 if (!isFinite(minX)) return; 302 const pad = 40; 303 svg.setAttribute('viewBox', 304 `${minX - pad} ${minY - pad} ${maxX - minX + pad * 2} ${maxY - minY + pad * 2}`); 305 svg.style.left = (minX - pad) + 'px'; 306 svg.style.top = (minY - pad) + 'px'; 307 svg.style.width = (maxX - minX + pad * 2) + 'px'; 308 svg.style.height = (maxY - minY + pad * 2) + 'px'; 309 310 svg.innerHTML = ''; 311 // Two passes: draw every connection path first, then every label 312 // on top. Without this, a later (e.g. dashed fallback) path can 313 // paint over an earlier connection's label and hide it. 314 const labelJobs = []; 315 (config.connections || []).forEach(c => { 316 const from = nodeById.get(c.from); 317 const to = nodeById.get(c.to); 318 if (!from || !to) return; 319 const fromSide = c.fromSide || 'right'; 320 const toSide = c.toSide || 'left'; 321 const a = anchorPoint(from, fromSide); 322 const b = anchorPoint(to, toSide); 323 const pts = orthogonalPoints(a.x, a.y, b.x, b.y, fromSide, toSide, c.offset || 0); 324 const path = document.createElementNS(SVG_NS, 'path'); 325 path.setAttribute('d', orthogonalPath(pts)); 326 const classes = ['rete-connection', `rete-connection--${c.kind || 'default'}`]; 327 if (c.label) classes.push('rete-connection--labelled'); 328 if (c.animated) classes.push('rete-connection--animated'); 329 path.setAttribute('class', classes.join(' ')); 330 svg.appendChild(path); 331 332 if (c.label) { 333 labelJobs.push({ mid: polylineMidpoint(pts), label: c.label }); 334 } 335 }); 336 337 labelJobs.forEach(({ mid, label }) => { 338 // Orthogonal edges read best with an upright label sat on the 339 // mid-point of the route (white halo keeps it legible over the line). 340 const text = document.createElementNS(SVG_NS, 'text'); 341 text.setAttribute('x', mid.x); 342 text.setAttribute('y', mid.y); 343 text.setAttribute('class', 'rete-connection__label'); 344 text.setAttribute('text-anchor', 'middle'); 345 text.setAttribute('dominant-baseline', 'middle'); 346 text.textContent = label; 347 svg.appendChild(text); 348 }); 349 } 350 351 // Pan + zoom state (applied as a single transform on the viewport). 352 let scale = 1, tx = 0, ty = 0; 353 function applyViewport() { 354 viewport.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`; 355 } 356 357 // Center initial viewport so the content fits within the container. 358 function fit() { 359 const rect = container.getBoundingClientRect(); 360 let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; 361 (config.groups || []).forEach(g => { 362 minX = Math.min(minX, g.x); minY = Math.min(minY, g.y); 363 maxX = Math.max(maxX, g.x + g.w); maxY = Math.max(maxY, g.y + g.h); 364 }); 365 nodes.forEach(n => { 366 minX = Math.min(minX, n.x); minY = Math.min(minY, n.y); 367 maxX = Math.max(maxX, n.x + (n.w || 220)); 368 maxY = Math.max(maxY, n.y + (n.h || 120)); 369 }); 370 if (!isFinite(minX) || rect.width === 0 || rect.height === 0) { 371 applyViewport(); 372 return; 373 } 374 const cw = maxX - minX, ch = maxY - minY; 375 const margin = 40; 376 scale = Math.min( 377 (rect.width - margin * 2) / cw, 378 (rect.height - margin * 2) / ch, 379 1.2 380 ); 381 if (!isFinite(scale) || scale <= 0) scale = 1; 382 tx = (rect.width - cw * scale) / 2 - minX * scale; 383 ty = (rect.height - ch * scale) / 2 - minY * scale; 384 applyViewport(); 385 } 386 387 // --- Pan: drag on empty container background. 388 let panning = false, panStartX = 0, panStartY = 0, panStartTx = 0, panStartTy = 0; 389 container.addEventListener('mousedown', e => { 390 if (e.target.closest('.rete-node')) return; // node drag handles itself 391 panning = true; 392 panStartX = e.clientX; panStartY = e.clientY; 393 panStartTx = tx; panStartTy = ty; 394 container.style.cursor = 'grabbing'; 395 e.preventDefault(); 396 }); 397 window.addEventListener('mousemove', e => { 398 if (!panning) return; 399 tx = panStartTx + (e.clientX - panStartX); 400 ty = panStartTy + (e.clientY - panStartY); 401 applyViewport(); 402 }); 403 window.addEventListener('mouseup', () => { 404 if (!panning) return; 405 panning = false;
406 container.style.cursor = ''; 407 }); 408 409 // --- Zoom: wheel, anchored at cursor. Only active in fullscreen so the 410 // page can still scroll normally when reading the docs. 411 container.addEventListener('wheel', e => { 412 if (!isFullscreen()) return; 413 e.preventDefault(); 414 const rect = container.getBoundingClientRect(); 415 const cx = e.clientX - rect.left; 416 const cy = e.clientY - rect.top; 417 const factor = Math.exp(-e.deltaY * 0.0015); 418 const next = Math.max(0.2, Math.min(4, scale * factor)); 419 // Keep the point under the cursor stationary in canvas space. 420 tx = cx - (cx - tx) * (next / scale); 421 ty = cy - (cy - ty) * (next / scale); 422 scale = next; 423 applyViewport(); 424 }, { passive: false }); 425 426 // --- Per-node drag (translates the node within the viewport's coordinate 427 // space; divide screen delta by scale to compensate for zoom). 428 nodes.forEach(n => { 429 const el = nodeEls.get(n.id); 430 let dragging = false, sx = 0, sy = 0, ox = 0, oy = 0; 431 el.addEventListener('mousedown', e => { 432 dragging = true; 433 sx = e.clientX; sy = e.clientY; 434 ox = n.x; oy = n.y; 435 el.classList.add('is-dragging'); 436 e.stopPropagation(); // don't trigger pan 437 e.preventDefault(); 438 }); 439 window.addEventListener('mousemove', e => { 440 if (!dragging) return; 441 let nx = ox + (e.clientX - sx) / scale; 442 let ny = oy + (e.clientY - sy) / scale; 443 // Clamp to group bounds if the node belongs to one. 444 const g = n._group; 445 if (g) { 446 const w = n.w || 220, h = n.h || 120; 447 nx = Math.max(g.x, Math.min(nx, g.x + g.w - w)); 448 ny = Math.max(g.y, Math.min(ny, g.y + g.h - h)); 449 } 450 n.x = nx; 451 n.y = ny; 452 el.style.transform = `translate(${n.x}px, ${n.y}px)`; 453 redraw(); 454 }); 455 window.addEventListener('mouseup', () => { 456 if (!dragging) return; 457 dragging = false; 458 el.classList.remove('is-dragging'); 459 }); 460 }); 461 462 redraw(); 463 // Defer fit until after layout settles (container may have 0 width on first paint). 464 requestAnimationFrame(fit); 465 window.addEventListener('resize', fit); 466 467 // --- Fullscreen toggle (window-level only). We deliberately don't use 468 // the native Fullscreen API: it takes over the whole OS screen and breaks 469 // out of the docs reading flow. Instead we toggle a class that pins the 470 // container to fill the browser viewport. 471 function isFullscreen() { 472 return container.classList.contains('rete-diagram--pseudo-fullscreen'); 473 } 474 function syncFsIcon() { 475 fsBtn.innerHTML = isFullscreen() ? ICON_COLLAPSE : ICON_EXPAND; 476 fsBtn.title = isFullscreen() ? 'Exit fullscreen' : 'Toggle fullscreen'; 477 } 478 function setFullscreen(on) { 479 container.classList.toggle('rete-diagram--pseudo-fullscreen', on); 480 document.body.classList.toggle('rete-diagram-fs-lock', on); 481 // Two RAFs: one to apply the new size, one to measure + refit. 482 requestAnimationFrame(() => requestAnimationFrame(() => { fit(); syncFsIcon(); })); 483 } 484 fsBtn.addEventListener('click', e => { 485 e.stopPropagation(); 486 setFullscreen(!isFullscreen()); 487 }); 488 // Double-click on the canvas background (not on a node) toggles fullscreen. 489 container.addEventListener('dblclick', e => { 490 if (e.target.closest('.rete-node, .rete-toolbar')) return; 491 setFullscreen(!isFullscreen()); 492 }); 493 window.addEventListener('keydown', e => { 494 if (e.key === 'Escape' && isFullscreen()) setFullscreen(false); 495 }); 496} 497 498function bootstrap() { 499 document.querySelectorAll('.rete-diagram:not(.rete-diagram--ready)').forEach(init); 500} 501 502if (document.readyState === 'loading') { 503 document.addEventListener('DOMContentLoaded', bootstrap); 504} else { 505 bootstrap(); 506} 507 508// Hextra (and other Hugo themes) sometimes swap page content via client-side 509// navigation, which doesn't re-execute the module. Watch for new diagram 510// elements being inserted into the DOM and bootstrap them on the fly. 511if (typeof MutationObserver !== 'undefined') { 512 const mo = new MutationObserver(muts => { 513 for (const m of muts) { 514 for (const node of m.addedNodes) { 515 if (!(node instanceof HTMLElement)) continue; 516 if (node.matches?.('.rete-diagram:not(.rete-diagram--ready)') || 517 node.querySelector?.('.rete-diagram:not(.rete-diagram--ready)')) { 518 bootstrap(); 519 return; 520 } 521 } 522 } 523 }); 524 mo.observe(document.body, { childList: true, subtree: true }); 525}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.