/** * applet-circuit-canvas — Reusable SVG node graph renderer. * * Lightweight pan/zoom SVG canvas for rendering sub-node graphs * inside expanded folk-applet shapes. Extracted from folk-gov-circuit patterns. * * NOT a FolkShape — just an HTMLElement used inside folk-applet's shadow DOM. */ import type { AppletSubNode, AppletSubEdge } from "../shared/applet-types"; const NODE_WIDTH = 200; const NODE_HEIGHT = 80; const PORT_RADIUS = 5; function esc(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function bezierPath(x1: number, y1: number, x2: number, y2: number): string { const dx = Math.abs(x2 - x1) * 0.5; return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`; } const STYLES = ` :host { display: block; width: 100%; height: 100%; background: #0f172a; border-radius: 0 0 8px 8px; overflow: hidden; } svg { width: 100%; height: 100%; } .acc-node-body { width: 100%; height: 100%; box-sizing: border-box; background: #1e293b; border: 1.5px solid #334155; border-radius: 6px; padding: 8px; display: flex; flex-direction: column; justify-content: center; gap: 4px; font-family: inherit; } .acc-node-label { font-size: 11px; font-weight: 600; color: #e2e8f0; display: flex; align-items: center; gap: 4px; } .acc-node-meta { font-size: 10px; color: #94a3b8; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .acc-edge-path { fill: none; stroke-width: 1.5; stroke-opacity: 0.5; pointer-events: none; } .acc-edge-hit { fill: none; stroke: transparent; stroke-width: 10; cursor: pointer; } .acc-edge-hit:hover + .acc-edge-path { stroke-opacity: 1; stroke-width: 2.5; } .acc-port-dot { transition: r 0.1s; } .acc-port-hit { cursor: crosshair; } .acc-port-hit:hover ~ .acc-port-dot { r: 8; } .acc-grid-line { stroke: #1e293b; stroke-width: 0.5; } `; export class AppletCircuitCanvas extends HTMLElement { #shadow: ShadowRoot; #nodes: AppletSubNode[] = []; #edges: AppletSubEdge[] = []; #panX = 0; #panY = 0; #zoom = 1; #isPanning = false; #panStart = { x: 0, y: 0 }; constructor() { super(); this.#shadow = this.attachShadow({ mode: "open" }); } get nodes() { return this.#nodes; } set nodes(v: AppletSubNode[]) { this.#nodes = v; this.#render(); } get edges() { return this.#edges; } set edges(v: AppletSubEdge[]) { this.#edges = v; this.#render(); } connectedCallback() { this.#render(); this.#setupInteraction(); } #render(): void { const gridDef = ` `; const edgesHtml = this.#edges.map(edge => { const fromNode = this.#nodes.find(n => n.id === edge.fromNode); const toNode = this.#nodes.find(n => n.id === edge.toNode); if (!fromNode || !toNode) return ""; const x1 = fromNode.position.x + NODE_WIDTH; const y1 = fromNode.position.y + NODE_HEIGHT / 2; const x2 = toNode.position.x; const y2 = toNode.position.y + NODE_HEIGHT / 2; const d = bezierPath(x1, y1, x2, y2); return ` `; }).join(""); const nodesHtml = this.#nodes.map(node => { const configSummary = Object.entries(node.config) .slice(0, 2) .map(([k, v]) => `${k}: ${v}`) .join(", "); return ` ${esc(node.icon)} ${esc(node.label)} ${configSummary ? `${esc(configSummary)}` : ""} `; }).join(""); this.#shadow.innerHTML = ` ${gridDef} ${edgesHtml} ${nodesHtml} `; this.#fitView(); } #fitView(): void { if (this.#nodes.length === 0) return; const svg = this.#shadow.querySelector("svg"); if (!svg) return; const rect = svg.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) return; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const n of this.#nodes) { minX = Math.min(minX, n.position.x); minY = Math.min(minY, n.position.y); maxX = Math.max(maxX, n.position.x + NODE_WIDTH); maxY = Math.max(maxY, n.position.y + NODE_HEIGHT); } const pad = 30; const contentW = maxX - minX + pad * 2; const contentH = maxY - minY + pad * 2; const scaleX = rect.width / contentW; const scaleY = rect.height / contentH; this.#zoom = Math.min(scaleX, scaleY, 1.5); this.#panX = (rect.width - contentW * this.#zoom) / 2 - (minX - pad) * this.#zoom; this.#panY = (rect.height - contentH * this.#zoom) / 2 - (minY - pad) * this.#zoom; this.#updateTransform(); } #updateTransform(): void { const g = this.#shadow.getElementById("canvas-transform"); if (g) g.setAttribute("transform", `translate(${this.#panX},${this.#panY}) scale(${this.#zoom})`); } #setupInteraction(): void { const svg = this.#shadow.querySelector("svg"); if (!svg) return; // Pan svg.addEventListener("pointerdown", (e) => { if (e.button !== 0 && e.button !== 1) return; this.#isPanning = true; this.#panStart = { x: e.clientX - this.#panX, y: e.clientY - this.#panY }; svg.setPointerCapture(e.pointerId); e.preventDefault(); }); svg.addEventListener("pointermove", (e) => { if (!this.#isPanning) return; this.#panX = e.clientX - this.#panStart.x; this.#panY = e.clientY - this.#panStart.y; this.#updateTransform(); }); svg.addEventListener("pointerup", () => { this.#isPanning = false; }); // Zoom svg.addEventListener("wheel", (e) => { e.preventDefault(); const factor = e.deltaY > 0 ? 0.9 : 1.1; const oldZoom = this.#zoom; const newZoom = Math.max(0.2, Math.min(3, oldZoom * factor)); const rect = svg.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; this.#panX = mx - (mx - this.#panX) * (newZoom / oldZoom); this.#panY = my - (my - this.#panY) * (newZoom / oldZoom); this.#zoom = newZoom; this.#updateTransform(); }, { passive: false }); } } customElements.define("applet-circuit-canvas", AppletCircuitCanvas);