278 lines
6.9 KiB
TypeScript
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, "&").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 = `
|
|
<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);
|