rspace-online/lib/applet-circuit-canvas.ts

278 lines
6.9 KiB
TypeScript

/**
* 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
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 = `
<defs>
<pattern id="acc-grid" width="30" height="30" patternUnits="userSpaceOnUse">
<line x1="30" y1="0" x2="30" y2="30" class="acc-grid-line"/>
<line x1="0" y1="30" x2="30" y2="30" class="acc-grid-line"/>
</pattern>
</defs>
<rect width="8000" height="8000" x="-4000" y="-4000" fill="url(#acc-grid)"/>
`;
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 `
<g data-edge-id="${esc(edge.id)}">
<path class="acc-edge-hit" d="${d}"/>
<path class="acc-edge-path" d="${d}" stroke="#6366f1" stroke-opacity="0.5"/>
</g>
`;
}).join("");
const nodesHtml = this.#nodes.map(node => {
const configSummary = Object.entries(node.config)
.slice(0, 2)
.map(([k, v]) => `${k}: ${v}`)
.join(", ");
return `
<g data-node-id="${esc(node.id)}">
<foreignObject x="${node.position.x}" y="${node.position.y}" width="${NODE_WIDTH}" height="${NODE_HEIGHT}">
<div xmlns="http://www.w3.org/1999/xhtml" class="acc-node-body">
<div class="acc-node-label">${esc(node.icon)} ${esc(node.label)}</div>
${configSummary ? `<div class="acc-node-meta">${esc(configSummary)}</div>` : ""}
</div>
</foreignObject>
</g>
`;
}).join("");
this.#shadow.innerHTML = `
<style>${STYLES}</style>
<svg xmlns="http://www.w3.org/2000/svg">
<g id="canvas-transform" transform="translate(${this.#panX},${this.#panY}) scale(${this.#zoom})">
${gridDef}
<g id="edge-layer">${edgesHtml}</g>
<g id="node-layer">${nodesHtml}</g>
</g>
</svg>
`;
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);