PageSourceSearch

https://learn.uug.ai/js/rete-diagram.21e4d2709e47d022326f8e82e41e01a4249ab716279f1e72c4f803cfa7c10e70.js

js uug.ai collected 2026-10-05 13:10:05 UTC 21,699 bytes, 525 lines download raw bytes

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.