rspace-online/modules/rgov/components/folk-gov-circuit.ts

1889 lines
57 KiB
TypeScript

/**
* <folk-gov-circuit> — n8n-style interactive mini-canvas for governance
* decision circuits in rGov.
*
* Renders governance-specific workflow nodes (signoff, threshold, knob,
* project, quadratic, conviction, multisig, sankey) on an SVG canvas
* with ports, Bezier wiring, node palette, detail panel, and fit-view.
*
* Standalone canvas — no Automerge, no server calls. Pure client-side
* with pre-loaded demo data.
*
* Attributes:
* circuit — which demo circuit to show (default: all)
*/
// ── Types ──
interface GovNodeDef {
type: string;
label: string;
icon: string;
color: string;
inputs: { name: string }[];
outputs: { name: string }[];
}
interface GovNode {
id: string;
type: string;
label: string;
position: { x: number; y: number };
config: Record<string, any>;
}
interface GovEdge {
id: string;
fromNode: string;
fromPort: string;
toNode: string;
toPort: string;
}
// ── Constants ──
const NODE_WIDTH = 240;
const NODE_HEIGHT = 120;
const PORT_RADIUS = 6;
const GOV_NODE_CATALOG: GovNodeDef[] = [
{
type: 'folk-gov-binary',
label: 'Signoff',
icon: '\u2714',
color: '#7c3aed',
inputs: [{ name: 'in' }],
outputs: [{ name: 'out' }],
},
{
type: 'folk-gov-threshold',
label: 'Threshold',
icon: '\u2593',
color: '#0891b2',
inputs: [{ name: 'in' }],
outputs: [{ name: 'out' }],
},
{
type: 'folk-gov-knob',
label: 'Knob',
icon: '\u2699',
color: '#b45309',
inputs: [{ name: 'in' }],
outputs: [{ name: 'out' }],
},
{
type: 'folk-gov-project',
label: 'Project',
icon: '\u25A3',
color: '#10b981',
inputs: [{ name: 'in' }],
outputs: [{ name: 'out' }],
},
{
type: 'folk-gov-quadratic',
label: 'Quadratic',
icon: '\u221A',
color: '#14b8a6',
inputs: [{ name: 'in' }],
outputs: [{ name: 'out' }],
},
{
type: 'folk-gov-conviction',
label: 'Conviction',
icon: '\u23F1',
color: '#d97706',
inputs: [{ name: 'in' }],
outputs: [{ name: 'out' }],
},
{
type: 'folk-gov-multisig',
label: 'Multisig',
icon: '\u{1F511}',
color: '#6366f1',
inputs: [{ name: 'in' }],
outputs: [{ name: 'out' }],
},
{
type: 'folk-gov-sankey',
label: 'Sankey',
icon: '\u2B82',
color: '#f43f5e',
inputs: [{ name: 'in' }],
outputs: [{ name: 'out' }],
},
];
// ── Demo Data ──
function buildDemoData(): { nodes: GovNode[]; edges: GovEdge[] } {
const nodes: GovNode[] = [];
const edges: GovEdge[] = [];
let eid = 0;
const mkEdge = (from: string, fp: string, to: string, tp: string) => {
edges.push({ id: `ge-${++eid}`, fromNode: from, fromPort: fp, toNode: to, toPort: tp });
};
// ── Circuit 1: Build a Climbing Wall ──
const c1y = 40;
nodes.push({
id: 'c1-labor', type: 'folk-gov-threshold', label: 'Labor Threshold',
position: { x: 50, y: c1y },
config: { target: 50, current: 20, unit: 'hours', contributors: 'Alice: 8h, Bob: 6h, Carol: 6h' },
});
nodes.push({
id: 'c1-capital', type: 'folk-gov-threshold', label: 'Capital Threshold',
position: { x: 50, y: c1y + 160 },
config: { target: 3000, current: 2000, unit: '$', contributors: 'Fund A: $1200, Dave: $800' },
});
nodes.push({
id: 'c1-signoff', type: 'folk-gov-binary', label: 'Proprietor Signoff',
position: { x: 50, y: c1y + 320 },
config: { assignee: 'Landlord (pending)', satisfied: false },
});
nodes.push({
id: 'c1-project', type: 'folk-gov-project', label: 'Build a Climbing Wall',
position: { x: 400, y: c1y + 140 },
config: { description: 'Community climbing wall in the courtyard', gatesSatisfied: 1, gatesTotal: 3 },
});
mkEdge('c1-labor', 'out', 'c1-project', 'in');
mkEdge('c1-capital', 'out', 'c1-project', 'in');
mkEdge('c1-signoff', 'out', 'c1-project', 'in');
// ── Circuit 2: Community Potluck ──
const c2y = c1y + 520;
nodes.push({
id: 'c2-budget', type: 'folk-gov-knob', label: 'Budget Knob',
position: { x: 50, y: c2y },
config: { min: 100, max: 5000, value: 1500, unit: '$' },
});
nodes.push({
id: 'c2-rsvps', type: 'folk-gov-threshold', label: 'RSVPs',
position: { x: 50, y: c2y + 160 },
config: { target: 20, current: 14, unit: 'people', contributors: '14 confirmed attendees' },
});
nodes.push({
id: 'c2-venue', type: 'folk-gov-binary', label: 'Venue Signoff',
position: { x: 50, y: c2y + 320 },
config: { assignee: 'Carlos', satisfied: true, signedBy: 'Carlos' },
});
nodes.push({
id: 'c2-project', type: 'folk-gov-project', label: 'Community Potluck',
position: { x: 400, y: c2y + 140 },
config: { description: 'Monthly community potluck event', gatesSatisfied: 2, gatesTotal: 3 },
});
mkEdge('c2-budget', 'out', 'c2-project', 'in');
mkEdge('c2-rsvps', 'out', 'c2-project', 'in');
mkEdge('c2-venue', 'out', 'c2-project', 'in');
// ── Circuit 3: Delegated Budget Approval ──
const c3y = c2y + 520;
nodes.push({
id: 'c3-quad', type: 'folk-gov-quadratic', label: 'Quadratic Dampener',
position: { x: 50, y: c3y },
config: { mode: 'sqrt', entries: 'Alice: 100, Bob: 49, Carol: 25' },
});
nodes.push({
id: 'c3-conviction', type: 'folk-gov-conviction', label: 'Conviction Accumulator',
position: { x: 50, y: c3y + 160 },
config: { threshold: 500, accumulated: 320, stakes: 'Alice: 150, Bob: 100, Carol: 70' },
});
nodes.push({
id: 'c3-multisig', type: 'folk-gov-multisig', label: '3-of-5 Multisig',
position: { x: 50, y: c3y + 320 },
config: { required: 3, total: 5, signers: 'Alice, Bob, Carol, Dave, Eve', signed: 'Alice, Bob' },
});
nodes.push({
id: 'c3-project', type: 'folk-gov-project', label: 'Delegated Budget Approval',
position: { x: 400, y: c3y + 140 },
config: { description: 'Multi-mechanism budget governance', gatesSatisfied: 0, gatesTotal: 3 },
});
nodes.push({
id: 'c3-sankey', type: 'folk-gov-sankey', label: 'Flow Visualizer',
position: { x: 700, y: c3y + 140 },
config: { note: 'Decorative flow diagram' },
});
mkEdge('c3-quad', 'out', 'c3-project', 'in');
mkEdge('c3-conviction', 'out', 'c3-project', 'in');
mkEdge('c3-multisig', 'out', 'c3-project', 'in');
return { nodes, edges };
}
// ── Helpers ──
function esc(s: string): string {
const d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}
function getNodeDef(type: string): GovNodeDef | undefined {
return GOV_NODE_CATALOG.find(n => n.type === type);
}
function getPortX(node: GovNode, _portName: string, direction: 'input' | 'output'): number {
return direction === 'input' ? node.position.x : node.position.x + NODE_WIDTH;
}
function getPortY(node: GovNode, _portName: string, direction: 'input' | 'output'): number {
const def = getNodeDef(node.type);
if (!def) return node.position.y + NODE_HEIGHT / 2;
const ports = direction === 'input' ? def.inputs : def.outputs;
const idx = ports.findIndex(p => p.name === _portName);
if (idx === -1) return node.position.y + NODE_HEIGHT / 2;
const spacing = NODE_HEIGHT / (ports.length + 1);
return node.position.y + spacing * (idx + 1);
}
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}`;
}
// ── Node body renderers ──
function renderNodeBody(node: GovNode): string {
const c = node.config;
switch (node.type) {
case 'folk-gov-binary': {
const satisfied = c.satisfied ? 'Yes' : 'No';
const checkColor = c.satisfied ? '#22c55e' : '#475569';
const checkBg = c.satisfied ? 'rgba(34,197,94,0.15)' : 'rgba(71,85,105,0.15)';
const checkIcon = c.satisfied ? '&#x2714;' : '';
return `
<div style="font-size:11px;color:#94a3b8;margin-top:2px">Assignee: ${esc(c.assignee || 'Unassigned')}</div>
<div class="gc-signoff-toggle" data-node-id="${node.id}" style="
display:flex;align-items:center;gap:8px;margin-top:6px;
cursor:pointer;padding:4px 8px;border-radius:6px;
background:${checkBg};border:1px solid ${checkColor}40;
transition:all 0.15s;user-select:none;
-webkit-tap-highlight-color:transparent;
">
<div style="
width:20px;height:20px;border-radius:4px;
border:2px solid ${checkColor};
display:flex;align-items:center;justify-content:center;
font-size:14px;color:${checkColor};
background:${c.satisfied ? checkColor + '20' : 'transparent'};
flex-shrink:0;transition:all 0.15s;
">${checkIcon}</div>
<span style="font-size:12px;color:#e2e8f0;font-weight:500">${satisfied}</span>
${c.satisfied && c.signedBy ? `<span style="font-size:10px;color:#64748b;margin-left:auto">by ${esc(c.signedBy)}</span>` : ''}
</div>`;
}
case 'folk-gov-threshold': {
const pct = c.target > 0 ? Math.min(100, Math.round((c.current / c.target) * 100)) : 0;
return `
<div style="display:flex;justify-content:space-between;font-size:11px;color:#94a3b8;margin-top:2px">
<span>${c.current}/${c.target} ${esc(c.unit || '')}</span>
<span>${pct}%</span>
</div>
<div style="background:#334155;border-radius:3px;height:6px;margin-top:4px;overflow:hidden">
<div style="background:#0891b2;width:${pct}%;height:100%;border-radius:3px;transition:width 0.3s"></div>
</div>`;
}
case 'folk-gov-knob': {
const pct = c.max > c.min ? Math.round(((c.value - c.min) / (c.max - c.min)) * 100) : 50;
return `
<div style="font-size:11px;color:#94a3b8;margin-top:2px">
${esc(c.unit || '')}${c.min} \u2014 ${esc(c.unit || '')}${c.max}
</div>
<div style="display:flex;align-items:center;gap:6px;margin-top:4px">
<div style="flex:1;background:#334155;border-radius:3px;height:6px;position:relative">
<div style="background:#b45309;width:${pct}%;height:100%;border-radius:3px"></div>
<div style="position:absolute;top:-4px;left:${pct}%;width:10px;height:14px;background:#fbbf24;border-radius:2px;transform:translateX(-5px)"></div>
</div>
<span style="font-size:12px;color:#e2e8f0;min-width:40px;text-align:right">${esc(c.unit || '')}${c.value}</span>
</div>`;
}
case 'folk-gov-project': {
const sat = c.gatesSatisfied || 0;
const tot = c.gatesTotal || 0;
return `
<div style="font-size:11px;color:#94a3b8;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(c.description || '')}</div>
<div style="display:flex;align-items:center;gap:6px;margin-top:6px">
<span style="font-size:12px;color:#10b981;font-weight:600">${sat} of ${tot} gates satisfied</span>
${sat >= tot ? '<span style="color:#22c55e">&#x2714;</span>' : '<span style="color:#f59e0b">&#x25CF;</span>'}
</div>`;
}
case 'folk-gov-quadratic': {
const mode = c.mode || 'sqrt';
return `
<div style="font-size:11px;color:#94a3b8;margin-top:2px">Mode: <span style="color:#5eead4">${esc(mode)}</span></div>
<div style="font-size:10px;color:#64748b;margin-top:4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(c.entries || '')}</div>`;
}
case 'folk-gov-conviction': {
const pct = c.threshold > 0 ? Math.min(100, Math.round((c.accumulated / c.threshold) * 100)) : 0;
return `
<div style="display:flex;justify-content:space-between;font-size:11px;color:#94a3b8;margin-top:2px">
<span>Score: ${c.accumulated}/${c.threshold}</span>
<span>${pct}%</span>
</div>
<div style="background:#334155;border-radius:3px;height:6px;margin-top:4px;overflow:hidden">
<div style="background:#d97706;width:${pct}%;height:100%;border-radius:3px"></div>
</div>`;
}
case 'folk-gov-multisig': {
const signed = (c.signed || '').split(',').map((s: string) => s.trim()).filter(Boolean);
const all = (c.signers || '').split(',').map((s: string) => s.trim()).filter(Boolean);
const checks = all.map((s: string) => {
const ok = signed.includes(s);
return `<span style="color:${ok ? '#22c55e' : '#475569'};font-size:11px" title="${esc(s)}">${ok ? '\u2714' : '\u25CB'}</span>`;
}).join(' ');
return `
<div style="font-size:11px;color:#94a3b8;margin-top:2px">${c.required} of ${c.total} required</div>
<div style="margin-top:4px;display:flex;gap:4px;flex-wrap:wrap">${checks}</div>`;
}
case 'folk-gov-sankey': {
return `
<div style="font-size:11px;color:#94a3b8;margin-top:2px">Flow visualization</div>
<div style="display:flex;gap:2px;margin-top:6px;height:20px;align-items:end">
<div style="width:16px;background:linear-gradient(to top,#f43f5e80,#f43f5e20);height:18px;border-radius:2px"></div>
<div style="width:16px;background:linear-gradient(to top,#f43f5e80,#f43f5e20);height:12px;border-radius:2px"></div>
<div style="width:16px;background:linear-gradient(to top,#f43f5e80,#f43f5e20);height:20px;border-radius:2px"></div>
<div style="width:16px;background:linear-gradient(to top,#f43f5e80,#f43f5e20);height:8px;border-radius:2px"></div>
<div style="width:16px;background:linear-gradient(to top,#f43f5e80,#f43f5e20);height:15px;border-radius:2px"></div>
</div>`;
}
default:
return '';
}
}
// ── Styles ──
const STYLES = `
:host {
display: block;
width: 100%;
height: 100%;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
color: #e2e8f0;
}
.gc-root {
display: flex;
flex-direction: column;
height: 100%;
background: #0f172a;
}
/* ── Toolbar ── */
.gc-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background: #1e293b;
border-bottom: 1px solid #334155;
flex-shrink: 0;
gap: 12px;
}
.gc-toolbar__title {
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
font-weight: 600;
color: #f1f5f9;
}
.gc-toolbar__actions {
display: flex;
align-items: center;
gap: 8px;
}
.gc-btn {
padding: 4px 12px;
border: 1px solid #334155;
border-radius: 6px;
background: #1e293b;
color: #cbd5e1;
font-size: 12px;
cursor: pointer;
transition: background 0.15s;
}
.gc-btn:hover {
background: #334155;
}
.gc-btn--fit {
color: #38bdf8;
border-color: #38bdf8;
}
/* ── Canvas area ── */
.gc-canvas-area {
display: flex;
flex: 1;
overflow: hidden;
position: relative;
}
/* ── Palette ── */
.gc-palette {
width: 180px;
flex-shrink: 0;
background: #1e293b;
border-right: 1px solid #334155;
overflow-y: auto;
padding: 12px 8px;
}
.gc-palette__title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
margin-bottom: 8px;
}
.gc-palette__card {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid #334155;
background: #0f172a;
margin-bottom: 4px;
cursor: grab;
transition: border-color 0.15s, background 0.15s;
font-size: 12px;
}
.gc-palette__card:hover {
border-color: #475569;
background: #1e293b;
}
.gc-palette__card-color {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.gc-palette__card-label {
color: #cbd5e1;
}
/* ── SVG Canvas ── */
.gc-canvas {
flex: 1;
position: relative;
overflow: hidden;
cursor: grab;
}
.gc-canvas.grabbing {
cursor: grabbing;
}
.gc-canvas.wiring {
cursor: crosshair;
}
.gc-canvas svg {
width: 100%;
height: 100%;
display: block;
}
/* ── Grid ── */
.gc-grid-pattern line {
stroke: #1e293b;
stroke-width: 1;
}
/* ── Zoom controls ── */
.gc-zoom-controls {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 0;
background: #1e293b;
border: 1px solid #334155;
border-radius: 8px;
padding: 2px 4px;
}
.gc-zoom-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: transparent;
color: #94a3b8;
cursor: pointer;
border-radius: 4px;
}
.gc-zoom-btn:hover {
background: #334155;
color: #e2e8f0;
}
.gc-zoom-level {
font-size: 11px;
color: #64748b;
min-width: 40px;
text-align: center;
user-select: none;
}
.gc-zoom-sep {
width: 1px;
height: 16px;
background: #334155;
margin: 0 2px;
}
/* ── Edges ── */
.gc-edge-path {
fill: none;
stroke-width: 2;
pointer-events: none;
}
.gc-edge-hit {
fill: none;
stroke: transparent;
stroke-width: 12;
cursor: pointer;
}
.gc-edge-hit:hover + .gc-edge-path {
stroke-opacity: 1;
stroke-width: 3;
}
/* ── Wiring temp ── */
.gc-wiring-temp {
fill: none;
stroke: #38bdf8;
stroke-width: 2;
stroke-dasharray: 6 4;
pointer-events: none;
}
/* ── Ports ── */
.gc-port-dot {
transition: r 0.1s;
}
.gc-port-hit {
cursor: crosshair;
}
.gc-port-hit:hover ~ .gc-port-dot,
.gc-port-group:hover .gc-port-dot {
r: 8;
}
/* ── Detail panel ── */
.gc-detail {
width: 0;
overflow: hidden;
background: #1e293b;
border-left: 1px solid #334155;
transition: width 0.2s;
flex-shrink: 0;
}
.gc-detail.open {
width: 280px;
}
.gc-detail__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #334155;
font-size: 13px;
font-weight: 600;
}
.gc-detail__close {
background: none;
border: none;
color: #64748b;
font-size: 18px;
cursor: pointer;
padding: 0 4px;
}
.gc-detail__close:hover {
color: #e2e8f0;
}
.gc-detail__body {
padding: 12px 16px;
overflow-y: auto;
max-height: calc(100% - 48px);
}
.gc-detail__field {
margin-bottom: 12px;
}
.gc-detail__field label {
display: block;
font-size: 11px;
color: #64748b;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.gc-detail__field input,
.gc-detail__field textarea,
.gc-detail__field select {
width: 100%;
padding: 6px 8px;
border: 1px solid #334155;
border-radius: 4px;
background: #0f172a;
color: #e2e8f0;
font-size: 12px;
box-sizing: border-box;
}
.gc-detail__field textarea {
resize: vertical;
min-height: 60px;
}
.gc-detail__delete {
width: 100%;
padding: 8px;
margin-top: 12px;
border: 1px solid #7f1d1d;
border-radius: 6px;
background: #450a0a;
color: #fca5a5;
font-size: 12px;
cursor: pointer;
}
.gc-detail__delete:hover {
background: #7f1d1d;
}
/* ── Collapsible sidebar toggle buttons ── */
.gc-sidebar-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid #334155;
border-radius: 6px;
background: #1e293b;
color: #94a3b8;
font-size: 16px;
cursor: pointer;
flex-shrink: 0;
}
.gc-sidebar-toggle:hover {
background: #334155;
color: #e2e8f0;
}
.gc-sidebar-toggle.active {
background: #334155;
color: #38bdf8;
border-color: #38bdf8;
}
/* ── Palette collapsed state ── */
.gc-palette {
transition: width 0.2s, padding 0.2s, opacity 0.15s;
}
.gc-palette.collapsed {
width: 0;
padding: 0;
overflow: hidden;
border-right: none;
opacity: 0;
pointer-events: none;
}
/* ── Touch & pen support ── */
.gc-canvas svg {
touch-action: none;
-webkit-user-select: none;
user-select: none;
}
/* ── Mobile responsive ── */
@media (max-width: 768px) {
.gc-toolbar {
padding: 6px 10px;
gap: 6px;
}
.gc-toolbar__title {
font-size: 13px;
gap: 6px;
}
.gc-btn {
padding: 4px 8px;
font-size: 11px;
}
.gc-palette {
width: 0;
padding: 0;
overflow: hidden;
border-right: none;
opacity: 0;
pointer-events: none;
}
.gc-palette.mobile-open {
width: 160px;
padding: 10px 6px;
overflow-y: auto;
border-right: 1px solid #334155;
opacity: 1;
pointer-events: auto;
position: absolute;
left: 0;
top: 0;
bottom: 0;
z-index: 10;
background: #1e293b;
}
.gc-detail.open {
width: 240px;
position: absolute;
right: 0;
top: 0;
bottom: 0;
z-index: 10;
background: #1e293b;
}
.gc-zoom-controls {
bottom: 10px;
}
.gc-zoom-btn {
width: 36px;
height: 36px;
}
.gc-zoom-level {
font-size: 12px;
min-width: 44px;
}
/* Larger port hit targets for touch */
.gc-port-hit {
r: 18;
}
.gc-palette__card {
padding: 8px 8px;
font-size: 13px;
}
}
@media (max-width: 480px) {
.gc-toolbar__title span:not(:first-child) {
display: none;
}
.gc-detail.open {
width: 100%;
}
.gc-palette.mobile-open {
width: 180px;
}
}
`;
// ── Component ──
export class FolkGovCircuit extends HTMLElement {
private shadow: ShadowRoot;
// Data
private nodes: GovNode[] = [];
private edges: GovEdge[] = [];
// Canvas state
private canvasZoom = 1;
private canvasPanX = 0;
private canvasPanY = 0;
private showGrid = true;
// Sidebar state
private paletteOpen = false;
private detailOpen = false;
// Interaction
private isPanning = false;
private panStartX = 0;
private panStartY = 0;
private panStartPanX = 0;
private panStartPanY = 0;
private draggingNodeId: string | null = null;
private dragStartX = 0;
private dragStartY = 0;
private dragNodeStartX = 0;
private dragNodeStartY = 0;
// Touch — pinch-to-zoom
private activeTouches: Map<number, { x: number; y: number }> = new Map();
private pinchStartDist = 0;
private pinchStartZoom = 1;
private pinchMidX = 0;
private pinchMidY = 0;
// Selection & detail panel
private selectedNodeId: string | null = null;
// Wiring
private wiringActive = false;
private wiringSourceNodeId: string | null = null;
private wiringSourcePortName: string | null = null;
private wiringPointerX = 0;
private wiringPointerY = 0;
// Bound listeners
private _boundPointerMove: ((e: PointerEvent) => void) | null = null;
private _boundPointerUp: ((e: PointerEvent) => void) | null = null;
private _boundKeyDown: ((e: KeyboardEvent) => void) | null = null;
private _boundTouchStart: ((e: TouchEvent) => void) | null = null;
private _boundTouchMove: ((e: TouchEvent) => void) | null = null;
private _boundTouchEnd: ((e: TouchEvent) => void) | null = null;
constructor() {
super();
this.shadow = this.attachShadow({ mode: 'open' });
}
static get observedAttributes() { return ['circuit']; }
connectedCallback() {
this.initData();
}
disconnectedCallback() {
if (this._boundPointerMove) document.removeEventListener('pointermove', this._boundPointerMove);
if (this._boundPointerUp) document.removeEventListener('pointerup', this._boundPointerUp);
if (this._boundKeyDown) document.removeEventListener('keydown', this._boundKeyDown);
if (this._boundTouchStart) document.removeEventListener('touchstart', this._boundTouchStart);
if (this._boundTouchMove) document.removeEventListener('touchmove', this._boundTouchMove);
if (this._boundTouchEnd) document.removeEventListener('touchend', this._boundTouchEnd);
}
// ── Data init ──
private initData() {
const demo = buildDemoData();
this.nodes = demo.nodes;
this.edges = demo.edges;
this.recalcProjectGates();
this.render();
requestAnimationFrame(() => this.fitView());
}
// ── Canvas transform ──
private updateCanvasTransform() {
const g = this.shadow.getElementById('canvas-transform');
if (g) g.setAttribute('transform', `translate(${this.canvasPanX},${this.canvasPanY}) scale(${this.canvasZoom})`);
this.updateZoomDisplay();
}
private updateZoomDisplay() {
const el = this.shadow.getElementById('zoom-level');
if (el) el.textContent = `${Math.round(this.canvasZoom * 100)}%`;
}
private fitView() {
const svg = this.shadow.getElementById('gc-svg') as SVGSVGElement | null;
if (!svg || this.nodes.length === 0) 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 = 60;
const contentW = maxX - minX + pad * 2;
const contentH = maxY - minY + pad * 2;
const scaleX = rect.width / contentW;
const scaleY = rect.height / contentH;
this.canvasZoom = Math.min(scaleX, scaleY, 1.5);
this.canvasPanX = (rect.width - contentW * this.canvasZoom) / 2 - (minX - pad) * this.canvasZoom;
this.canvasPanY = (rect.height - contentH * this.canvasZoom) / 2 - (minY - pad) * this.canvasZoom;
this.updateCanvasTransform();
}
private zoomAt(screenX: number, screenY: number, factor: number) {
const oldZoom = this.canvasZoom;
const newZoom = Math.max(0.1, Math.min(4, oldZoom * factor));
this.canvasPanX = screenX - (screenX - this.canvasPanX) * (newZoom / oldZoom);
this.canvasPanY = screenY - (screenY - this.canvasPanY) * (newZoom / oldZoom);
this.canvasZoom = newZoom;
this.updateCanvasTransform();
}
// ── Rendering ──
private render() {
const gridDef = this.showGrid ? `
<defs>
<pattern id="gc-grid" width="40" height="40" patternUnits="userSpaceOnUse">
<line x1="40" y1="0" x2="40" y2="40" class="gc-grid-pattern"/>
<line x1="0" y1="40" x2="40" y2="40" class="gc-grid-pattern"/>
</pattern>
</defs>
<rect width="10000" height="10000" x="-5000" y="-5000" fill="url(#gc-grid)"/>` : '';
this.shadow.innerHTML = `
<style>${STYLES}</style>
<div class="gc-root">
<div class="gc-toolbar">
<div class="gc-toolbar__title">
<button class="gc-sidebar-toggle ${this.paletteOpen ? 'active' : ''}" id="btn-palette-toggle" title="Toggle palette">&#9776;</button>
<span>\u25A3 Governance Circuits</span>
</div>
<div class="gc-toolbar__actions">
<button class="gc-btn" id="btn-zoom-out" title="Zoom out">\u2212</button>
<button class="gc-btn" id="btn-zoom-in" title="Zoom in">+</button>
<button class="gc-btn gc-btn--fit" id="btn-fit" title="Fit view">\u2922 Fit</button>
<button class="gc-btn" id="btn-grid" title="Toggle grid"># Grid</button>
</div>
</div>
<div class="gc-canvas-area">
<div class="gc-palette ${this.paletteOpen ? 'mobile-open' : 'collapsed'}" id="palette">
<div class="gc-palette__title">Node Types</div>
${GOV_NODE_CATALOG.map(n => `
<div class="gc-palette__card" data-node-type="${n.type}" draggable="true">
<div class="gc-palette__card-color" style="background:${n.color}"></div>
<span class="gc-palette__card-label">${n.icon} ${esc(n.label)}</span>
</div>
`).join('')}
</div>
<div class="gc-canvas" id="gc-canvas">
<svg id="gc-svg" xmlns="http://www.w3.org/2000/svg">
<g id="canvas-transform">
${gridDef}
<g id="edge-layer">${this.renderAllEdges()}</g>
<g id="wire-layer"></g>
<g id="node-layer">${this.renderAllNodes()}</g>
</g>
</svg>
<div class="gc-zoom-controls">
<button class="gc-zoom-btn" id="zoom-out" title="Zoom out">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8h10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button>
<span class="gc-zoom-sep"></span>
<span class="gc-zoom-level" id="zoom-level">${Math.round(this.canvasZoom * 100)}%</span>
<span class="gc-zoom-sep"></span>
<button class="gc-zoom-btn" id="zoom-in" title="Zoom in">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button>
<span class="gc-zoom-sep"></span>
<button class="gc-zoom-btn" id="zoom-fit" title="Fit to view">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M2 6V3a1 1 0 011-1h3M10 2h3a1 1 0 011 1v3M14 10v3a1 1 0 01-1 1h-3M6 14H3a1 1 0 01-1-1v-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
</div>
<div class="gc-detail ${this.detailOpen ? 'open' : ''}" id="detail-panel">
${this.renderDetailPanel()}
</div>
</div>
</div>
`;
this.attachEventListeners();
}
private renderAllNodes(): string {
return this.nodes.map(node => this.renderNode(node)).join('');
}
private renderNode(node: GovNode): string {
const def = getNodeDef(node.type);
if (!def) return '';
const isSelected = node.id === this.selectedNodeId;
// Ports
let portsHtml = '';
for (const inp of def.inputs) {
const y = getPortY(node, inp.name, 'input');
const x = node.position.x;
portsHtml += `
<g class="gc-port-group" data-node-id="${node.id}" data-port-name="${inp.name}" data-port-dir="input">
<circle class="gc-port-dot" cx="${x}" cy="${y}" r="${PORT_RADIUS}" fill="#94a3b8" stroke="#0f172a" stroke-width="2"/>
<circle cx="${x}" cy="${y}" r="12" fill="transparent" class="gc-port-hit"/>
</g>`;
}
for (const out of def.outputs) {
const y = getPortY(node, out.name, 'output');
const x = node.position.x + NODE_WIDTH;
portsHtml += `
<g class="gc-port-group" data-node-id="${node.id}" data-port-name="${out.name}" data-port-dir="output">
<circle class="gc-port-dot" cx="${x}" cy="${y}" r="${PORT_RADIUS}" fill="${def.color}" stroke="#0f172a" stroke-width="2"/>
<circle cx="${x}" cy="${y}" r="12" fill="transparent" class="gc-port-hit"/>
</g>`;
}
const bodyHtml = renderNodeBody(node);
return `
<g class="gc-node ${isSelected ? 'selected' : ''}" data-node-id="${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" style="
width:100%;height:100%;box-sizing:border-box;
background:#1e293b;
border:1px solid ${isSelected ? '#38bdf8' : '#334155'};
border-radius:8px;
overflow:hidden;
display:flex;
font-family:inherit;
cursor:pointer;
transition:border-color 0.15s;
">
<div style="width:4px;flex-shrink:0;background:${def.color};border-radius:8px 0 0 8px"></div>
<div style="flex:1;padding:8px 10px;overflow:hidden;min-width:0">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:2px">
<span style="font-size:14px">${def.icon}</span>
<span style="font-size:12px;font-weight:600;color:#f1f5f9;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(node.label)}</span>
</div>
${bodyHtml}
</div>
</div>
</foreignObject>
${portsHtml}
</g>`;
}
private renderAllEdges(): string {
return 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 = getPortX(fromNode, edge.fromPort, 'output');
const y1 = getPortY(fromNode, edge.fromPort, 'output');
const x2 = getPortX(toNode, edge.toPort, 'input');
const y2 = getPortY(toNode, edge.toPort, 'input');
const fromDef = getNodeDef(fromNode.type);
const color = fromDef ? fromDef.color : '#6b7280';
const d = bezierPath(x1, y1, x2, y2);
return `
<g class="gc-edge-group" data-edge-id="${edge.id}">
<path class="gc-edge-hit" d="${d}"/>
<path class="gc-edge-path" d="${d}" stroke="${color}" stroke-opacity="0.6"/>
</g>`;
}).join('');
}
private renderDetailPanel(): string {
if (!this.selectedNodeId) {
return `
<div class="gc-detail__header">
<span>No node selected</span>
<button class="gc-detail__close" id="detail-close">&times;</button>
</div>
<div class="gc-detail__body">
<p style="color:#64748b;font-size:12px">Click a node to view details.</p>
</div>`;
}
const node = this.nodes.find(n => n.id === this.selectedNodeId);
if (!node) return '';
const def = getNodeDef(node.type);
if (!def) return '';
const fieldsHtml = this.renderDetailFields(node);
return `
<div class="gc-detail__header">
<span>${def.icon} ${esc(node.label)}</span>
<button class="gc-detail__close" id="detail-close">&times;</button>
</div>
<div class="gc-detail__body">
<div class="gc-detail__field">
<label>Label</label>
<input type="text" id="detail-label" value="${esc(node.label)}">
</div>
<div class="gc-detail__field">
<label>Type</label>
<input type="text" value="${esc(def.label)}" disabled style="opacity:0.6">
</div>
${fieldsHtml}
<button class="gc-detail__delete" id="detail-delete-node">Delete Node</button>
</div>`;
}
private renderDetailFields(node: GovNode): string {
const c = node.config;
switch (node.type) {
case 'folk-gov-binary':
return `
<div class="gc-detail__field">
<label>Assignee</label>
<input type="text" data-config-key="assignee" value="${esc(c.assignee || '')}">
</div>
<div class="gc-detail__field">
<label>Satisfied</label>
<select data-config-key="satisfied">
<option value="true" ${c.satisfied ? 'selected' : ''}>Yes</option>
<option value="false" ${!c.satisfied ? 'selected' : ''}>No</option>
</select>
</div>`;
case 'folk-gov-threshold':
return `
<div class="gc-detail__field">
<label>Target</label>
<input type="number" data-config-key="target" value="${c.target || 0}">
</div>
<div class="gc-detail__field">
<label>Current</label>
<input type="number" data-config-key="current" value="${c.current || 0}">
</div>
<div class="gc-detail__field">
<label>Unit</label>
<input type="text" data-config-key="unit" value="${esc(c.unit || '')}">
</div>
<div class="gc-detail__field">
<label>Contributors</label>
<textarea data-config-key="contributors">${esc(c.contributors || '')}</textarea>
</div>`;
case 'folk-gov-knob':
return `
<div class="gc-detail__field">
<label>Min</label>
<input type="number" data-config-key="min" value="${c.min || 0}">
</div>
<div class="gc-detail__field">
<label>Max</label>
<input type="number" data-config-key="max" value="${c.max || 0}">
</div>
<div class="gc-detail__field">
<label>Value</label>
<input type="number" data-config-key="value" value="${c.value || 0}">
</div>
<div class="gc-detail__field">
<label>Unit</label>
<input type="text" data-config-key="unit" value="${esc(c.unit || '')}">
</div>`;
case 'folk-gov-project':
return `
<div class="gc-detail__field">
<label>Description</label>
<textarea data-config-key="description">${esc(c.description || '')}</textarea>
</div>
<div class="gc-detail__field">
<label>Gates Satisfied</label>
<input type="number" data-config-key="gatesSatisfied" value="${c.gatesSatisfied || 0}">
</div>
<div class="gc-detail__field">
<label>Gates Total</label>
<input type="number" data-config-key="gatesTotal" value="${c.gatesTotal || 0}">
</div>`;
case 'folk-gov-quadratic':
return `
<div class="gc-detail__field">
<label>Mode</label>
<select data-config-key="mode">
<option value="sqrt" ${c.mode === 'sqrt' ? 'selected' : ''}>sqrt</option>
<option value="log" ${c.mode === 'log' ? 'selected' : ''}>log</option>
</select>
</div>
<div class="gc-detail__field">
<label>Entries</label>
<textarea data-config-key="entries">${esc(c.entries || '')}</textarea>
</div>`;
case 'folk-gov-conviction':
return `
<div class="gc-detail__field">
<label>Threshold</label>
<input type="number" data-config-key="threshold" value="${c.threshold || 0}">
</div>
<div class="gc-detail__field">
<label>Accumulated</label>
<input type="number" data-config-key="accumulated" value="${c.accumulated || 0}">
</div>
<div class="gc-detail__field">
<label>Stakes</label>
<textarea data-config-key="stakes">${esc(c.stakes || '')}</textarea>
</div>`;
case 'folk-gov-multisig':
return `
<div class="gc-detail__field">
<label>Required</label>
<input type="number" data-config-key="required" value="${c.required || 0}">
</div>
<div class="gc-detail__field">
<label>Total Signers</label>
<input type="number" data-config-key="total" value="${c.total || 0}">
</div>
<div class="gc-detail__field">
<label>Signers (comma-separated)</label>
<textarea data-config-key="signers">${esc(c.signers || '')}</textarea>
</div>
<div class="gc-detail__field">
<label>Signed (comma-separated)</label>
<textarea data-config-key="signed">${esc(c.signed || '')}</textarea>
</div>`;
case 'folk-gov-sankey':
return `
<div class="gc-detail__field">
<label>Note</label>
<textarea data-config-key="note">${esc(c.note || '')}</textarea>
</div>`;
default:
return '';
}
}
// ── Redraw helpers ──
private drawCanvasContent() {
const edgeLayer = this.shadow.getElementById('edge-layer');
const nodeLayer = this.shadow.getElementById('node-layer');
const wireLayer = this.shadow.getElementById('wire-layer');
if (!edgeLayer || !nodeLayer) return;
edgeLayer.innerHTML = this.renderAllEdges();
nodeLayer.innerHTML = this.renderAllNodes();
if (wireLayer) wireLayer.innerHTML = '';
this.attachSignoffHandlers();
}
private attachSignoffHandlers() {
const nodeLayer = this.shadow.getElementById('node-layer');
if (!nodeLayer) return;
nodeLayer.querySelectorAll('.gc-signoff-toggle').forEach(el => {
el.addEventListener('click', (e: Event) => {
e.stopPropagation();
e.preventDefault();
const nodeId = (el as HTMLElement).dataset.nodeId;
if (!nodeId) return;
this.toggleSignoff(nodeId);
});
// Prevent the click from starting a node drag
el.addEventListener('pointerdown', (e: Event) => {
e.stopPropagation();
});
});
}
private toggleSignoff(nodeId: string) {
const node = this.nodes.find(n => n.id === nodeId);
if (!node || node.type !== 'folk-gov-binary') return;
// Authority check: resolve current user from EncryptID JWT if available
let currentUser = 'You';
try {
const token = document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('auth='));
if (token) {
const payload = JSON.parse(atob(token.split('=')[1].split('.')[1]));
currentUser = payload.username || payload.sub || 'You';
}
} catch { /* no auth context — allow toggle for demo */ }
// Check authority: if assignee is set, only the assignee (or admin) can toggle
const assignee = (node.config.assignee || '').toLowerCase().trim();
const userLower = currentUser.toLowerCase().trim();
if (assignee && assignee !== 'unassigned' && !assignee.includes('pending')) {
// Extract just the name part (handle "Carlos", "Landlord (pending)", etc.)
const assigneeName = assignee.replace(/\s*\(.*\)\s*$/, '');
if (assigneeName && assigneeName !== userLower && userLower !== 'you') {
// Not authorized — show brief feedback
const el = this.shadow.querySelector(`[data-node-id="${nodeId}"].gc-signoff-toggle`) as HTMLElement;
if (el) {
el.style.outline = '2px solid #ef4444';
el.style.outlineOffset = '2px';
setTimeout(() => { el.style.outline = ''; el.style.outlineOffset = ''; }, 600);
}
return;
}
}
// Toggle
node.config.satisfied = !node.config.satisfied;
node.config.signedBy = node.config.satisfied ? currentUser : '';
// Update connected project aggregators
this.recalcProjectGates();
// Re-render
this.drawCanvasContent();
if (this.selectedNodeId === nodeId) {
this.refreshDetailPanel();
}
}
private recalcProjectGates() {
// For each project node, count how many of its incoming edges come from satisfied gates
for (const node of this.nodes) {
if (node.type !== 'folk-gov-project') continue;
const incomingEdges = this.edges.filter(e => e.toNode === node.id);
let satisfied = 0;
let total = incomingEdges.length;
for (const edge of incomingEdges) {
const source = this.nodes.find(n => n.id === edge.fromNode);
if (!source) continue;
if (source.type === 'folk-gov-binary' && source.config.satisfied) satisfied++;
else if (source.type === 'folk-gov-threshold' && source.config.current >= source.config.target) satisfied++;
else if (source.type === 'folk-gov-multisig') {
const signed = (source.config.signed || '').split(',').filter((s: string) => s.trim()).length;
if (signed >= source.config.required) satisfied++;
}
else if (source.type === 'folk-gov-conviction' && source.config.accumulated >= source.config.threshold) satisfied++;
}
node.config.gatesSatisfied = satisfied;
node.config.gatesTotal = total;
}
}
private redrawEdges() {
const edgeLayer = this.shadow.getElementById('edge-layer');
if (edgeLayer) edgeLayer.innerHTML = this.renderAllEdges();
}
private updateNodePosition(node: GovNode) {
const nodeLayer = this.shadow.getElementById('node-layer');
if (!nodeLayer) return;
const g = nodeLayer.querySelector(`[data-node-id="${node.id}"]`) as SVGGElement | null;
if (!g) return;
const fo = g.querySelector('foreignObject');
if (fo) {
fo.setAttribute('x', String(node.position.x));
fo.setAttribute('y', String(node.position.y));
}
const def = getNodeDef(node.type);
if (!def) return;
const portGroups = g.querySelectorAll('.gc-port-group');
portGroups.forEach(pg => {
const portName = (pg as HTMLElement).dataset.portName!;
const dir = (pg as HTMLElement).dataset.portDir as 'input' | 'output';
const x = dir === 'input' ? node.position.x : node.position.x + NODE_WIDTH;
const ports = dir === 'input' ? def.inputs : def.outputs;
const idx = ports.findIndex(p => p.name === portName);
const spacing = NODE_HEIGHT / (ports.length + 1);
const y = node.position.y + spacing * (idx + 1);
pg.querySelectorAll('circle').forEach(c => {
c.setAttribute('cx', String(x));
c.setAttribute('cy', String(y));
});
});
}
private refreshDetailPanel() {
const panel = this.shadow.getElementById('detail-panel');
if (!panel) return;
panel.className = `gc-detail ${this.detailOpen ? 'open' : ''}`;
panel.innerHTML = this.renderDetailPanel();
this.attachDetailListeners();
}
// ── Node operations ──
private addNode(type: string, x: number, y: number) {
const def = getNodeDef(type);
if (!def) return;
const id = `gn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
const node: GovNode = {
id,
type: def.type,
label: def.label,
position: { x, y },
config: this.defaultConfigFor(type),
};
this.nodes.push(node);
this.drawCanvasContent();
this.selectNode(id);
}
private defaultConfigFor(type: string): Record<string, any> {
switch (type) {
case 'folk-gov-binary': return { assignee: '', satisfied: false, signedBy: '' };
case 'folk-gov-threshold': return { target: 100, current: 0, unit: '', contributors: '' };
case 'folk-gov-knob': return { min: 0, max: 100, value: 50, unit: '' };
case 'folk-gov-project': return { description: '', gatesSatisfied: 0, gatesTotal: 0 };
case 'folk-gov-quadratic': return { mode: 'sqrt', entries: '' };
case 'folk-gov-conviction': return { threshold: 100, accumulated: 0, stakes: '' };
case 'folk-gov-multisig': return { required: 2, total: 3, signers: '', signed: '' };
case 'folk-gov-sankey': return { note: '' };
default: return {};
}
}
private deleteNode(nodeId: string) {
this.nodes = this.nodes.filter(n => n.id !== nodeId);
this.edges = this.edges.filter(e => e.fromNode !== nodeId && e.toNode !== nodeId);
if (this.selectedNodeId === nodeId) {
this.selectedNodeId = null;
this.detailOpen = false;
}
this.drawCanvasContent();
this.refreshDetailPanel();
}
private selectNode(nodeId: string) {
this.selectedNodeId = nodeId;
this.detailOpen = true;
const nodeLayer = this.shadow.getElementById('node-layer');
if (nodeLayer) {
nodeLayer.querySelectorAll('.gc-node').forEach(g => {
g.classList.toggle('selected', g.getAttribute('data-node-id') === nodeId);
});
}
this.refreshDetailPanel();
// Re-render nodes to update border highlight
this.drawCanvasContent();
}
// ── Wiring ──
private enterWiring(nodeId: string, portName: string, dir: 'input' | 'output') {
if (dir !== 'output') return;
this.wiringActive = true;
this.wiringSourceNodeId = nodeId;
this.wiringSourcePortName = portName;
const canvas = this.shadow.getElementById('gc-canvas');
if (canvas) canvas.classList.add('wiring');
}
private cancelWiring() {
this.wiringActive = false;
this.wiringSourceNodeId = null;
this.wiringSourcePortName = null;
const canvas = this.shadow.getElementById('gc-canvas');
if (canvas) canvas.classList.remove('wiring');
const wireLayer = this.shadow.getElementById('wire-layer');
if (wireLayer) wireLayer.innerHTML = '';
}
private completeWiring(targetNodeId: string, targetPortName: string, targetDir: 'input' | 'output') {
if (!this.wiringSourceNodeId || !this.wiringSourcePortName) { this.cancelWiring(); return; }
if (targetDir !== 'input') { this.cancelWiring(); return; }
if (targetNodeId === this.wiringSourceNodeId) { this.cancelWiring(); return; }
const exists = this.edges.some(e =>
e.fromNode === this.wiringSourceNodeId && e.fromPort === this.wiringSourcePortName &&
e.toNode === targetNodeId && e.toPort === targetPortName
);
if (exists) { this.cancelWiring(); return; }
const edgeId = `ge-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
this.edges.push({
id: edgeId,
fromNode: this.wiringSourceNodeId,
fromPort: this.wiringSourcePortName,
toNode: targetNodeId,
toPort: targetPortName,
});
this.cancelWiring();
this.drawCanvasContent();
}
private updateWiringTempLine() {
const svg = this.shadow.getElementById('gc-svg') as SVGSVGElement | null;
const wireLayer = this.shadow.getElementById('wire-layer');
if (!svg || !wireLayer || !this.wiringSourceNodeId || !this.wiringSourcePortName) return;
const sourceNode = this.nodes.find(n => n.id === this.wiringSourceNodeId);
if (!sourceNode) return;
const x1 = getPortX(sourceNode, this.wiringSourcePortName!, 'output');
const y1 = getPortY(sourceNode, this.wiringSourcePortName!, 'output');
const rect = svg.getBoundingClientRect();
const x2 = (this.wiringPointerX - rect.left - this.canvasPanX) / this.canvasZoom;
const y2 = (this.wiringPointerY - rect.top - this.canvasPanY) / this.canvasZoom;
const d = bezierPath(x1, y1, x2, y2);
wireLayer.innerHTML = `<path class="gc-wiring-temp" d="${d}"/>`;
}
// ── Event listeners ──
private attachEventListeners() {
const canvas = this.shadow.getElementById('gc-canvas')!;
const svg = this.shadow.getElementById('gc-svg')!;
const palette = this.shadow.getElementById('palette')!;
// Toolbar buttons
this.shadow.getElementById('btn-zoom-in')?.addEventListener('click', () => {
const rect = svg.getBoundingClientRect();
this.zoomAt(rect.width / 2, rect.height / 2, 1.2);
});
this.shadow.getElementById('btn-zoom-out')?.addEventListener('click', () => {
const rect = svg.getBoundingClientRect();
this.zoomAt(rect.width / 2, rect.height / 2, 0.8);
});
this.shadow.getElementById('btn-fit')?.addEventListener('click', () => this.fitView());
this.shadow.getElementById('btn-grid')?.addEventListener('click', () => {
this.showGrid = !this.showGrid;
this.render();
requestAnimationFrame(() => this.fitView());
});
// Bottom zoom controls
this.shadow.getElementById('zoom-in')?.addEventListener('click', () => {
const rect = svg.getBoundingClientRect();
this.zoomAt(rect.width / 2, rect.height / 2, 1.2);
});
this.shadow.getElementById('zoom-out')?.addEventListener('click', () => {
const rect = svg.getBoundingClientRect();
this.zoomAt(rect.width / 2, rect.height / 2, 0.8);
});
this.shadow.getElementById('zoom-fit')?.addEventListener('click', () => this.fitView());
// Canvas wheel zoom/pan
canvas.addEventListener('wheel', (e: WheelEvent) => {
e.preventDefault();
if (e.ctrlKey || e.metaKey) {
const rect = svg.getBoundingClientRect();
const factor = e.deltaY < 0 ? 1.1 : 0.9;
this.zoomAt(e.clientX - rect.left, e.clientY - rect.top, factor);
} else {
this.canvasPanX -= e.deltaX;
this.canvasPanY -= e.deltaY;
this.updateCanvasTransform();
}
}, { passive: false });
// Palette drag
palette.querySelectorAll('.gc-palette__card').forEach(card => {
card.addEventListener('dragstart', (e: Event) => {
const de = e as DragEvent;
const type = (card as HTMLElement).dataset.nodeType!;
de.dataTransfer?.setData('text/plain', type);
});
});
// Canvas drop
canvas.addEventListener('dragover', (e: DragEvent) => { e.preventDefault(); });
canvas.addEventListener('drop', (e: DragEvent) => {
e.preventDefault();
const type = e.dataTransfer?.getData('text/plain');
if (!type) return;
const rect = svg.getBoundingClientRect();
const x = (e.clientX - rect.left - this.canvasPanX) / this.canvasZoom;
const y = (e.clientY - rect.top - this.canvasPanY) / this.canvasZoom;
this.addNode(type, x - NODE_WIDTH / 2, y - NODE_HEIGHT / 2);
});
// Palette toggle
this.shadow.getElementById('btn-palette-toggle')?.addEventListener('click', () => {
this.paletteOpen = !this.paletteOpen;
const pal = this.shadow.getElementById('palette');
const btn = this.shadow.getElementById('btn-palette-toggle');
if (pal) {
pal.classList.toggle('collapsed', !this.paletteOpen);
pal.classList.toggle('mobile-open', this.paletteOpen);
}
if (btn) btn.classList.toggle('active', this.paletteOpen);
});
// SVG pointer events
svg.addEventListener('pointerdown', (e: PointerEvent) => this.handlePointerDown(e));
// Global move/up/key
this._boundPointerMove = (e: PointerEvent) => this.handlePointerMove(e);
this._boundPointerUp = (e: PointerEvent) => this.handlePointerUp(e);
this._boundKeyDown = (e: KeyboardEvent) => this.handleKeyDown(e);
document.addEventListener('pointermove', this._boundPointerMove);
document.addEventListener('pointerup', this._boundPointerUp);
document.addEventListener('keydown', this._boundKeyDown);
// Touch: pinch-to-zoom + two-finger pan
this._boundTouchStart = (e: TouchEvent) => this.handleTouchStart(e);
this._boundTouchMove = (e: TouchEvent) => this.handleTouchMove(e);
this._boundTouchEnd = (e: TouchEvent) => this.handleTouchEnd(e);
svg.addEventListener('touchstart', this._boundTouchStart, { passive: false });
svg.addEventListener('touchmove', this._boundTouchMove, { passive: false });
svg.addEventListener('touchend', this._boundTouchEnd, { passive: false });
// Detail panel
this.attachDetailListeners();
}
private attachDetailListeners() {
this.shadow.getElementById('detail-close')?.addEventListener('click', () => {
this.detailOpen = false;
this.selectedNodeId = null;
const panel = this.shadow.getElementById('detail-panel');
if (panel) panel.className = 'gc-detail';
this.drawCanvasContent();
});
this.shadow.getElementById('detail-label')?.addEventListener('input', (e) => {
const node = this.nodes.find(n => n.id === this.selectedNodeId);
if (node) {
node.label = (e.target as HTMLInputElement).value;
this.drawCanvasContent();
}
});
this.shadow.getElementById('detail-delete-node')?.addEventListener('click', () => {
if (this.selectedNodeId) this.deleteNode(this.selectedNodeId);
});
const panel = this.shadow.getElementById('detail-panel');
if (panel) {
panel.querySelectorAll('[data-config-key]').forEach(el => {
const handler = (e: Event) => {
const key = (el as HTMLElement).dataset.configKey!;
const node = this.nodes.find(n => n.id === this.selectedNodeId);
if (!node) return;
let val: any = (e.target as HTMLInputElement).value;
// Type coerce numbers
if ((e.target as HTMLInputElement).type === 'number') {
val = parseFloat(val) || 0;
}
// Coerce booleans from select
if (val === 'true') val = true;
if (val === 'false') val = false;
node.config[key] = val;
this.drawCanvasContent();
};
el.addEventListener('input', handler);
el.addEventListener('change', handler);
});
}
}
private handlePointerDown(e: PointerEvent) {
const target = e.target as Element;
// Port click
const portGroup = target.closest('.gc-port-group') as SVGElement | null;
if (portGroup) {
e.stopPropagation();
const nodeId = portGroup.dataset.nodeId!;
const portName = portGroup.dataset.portName!;
const dir = portGroup.dataset.portDir as 'input' | 'output';
if (this.wiringActive) {
this.completeWiring(nodeId, portName, dir);
} else {
this.enterWiring(nodeId, portName, dir);
}
return;
}
// Edge click — delete
const edgeGroup = target.closest('.gc-edge-group') as SVGElement | null;
if (edgeGroup) {
e.stopPropagation();
const edgeId = edgeGroup.dataset.edgeId!;
this.edges = this.edges.filter(ed => ed.id !== edgeId);
this.redrawEdges();
return;
}
// Node click — select + drag
const nodeGroup = target.closest('.gc-node') as SVGElement | null;
if (nodeGroup) {
e.stopPropagation();
if (this.wiringActive) {
this.cancelWiring();
return;
}
const nodeId = nodeGroup.dataset.nodeId!;
this.selectNode(nodeId);
const node = this.nodes.find(n => n.id === nodeId);
if (node) {
this.draggingNodeId = nodeId;
this.dragStartX = e.clientX;
this.dragStartY = e.clientY;
this.dragNodeStartX = node.position.x;
this.dragNodeStartY = node.position.y;
}
return;
}
// Canvas — pan or deselect
if (this.wiringActive) {
this.cancelWiring();
return;
}
this.isPanning = true;
this.panStartX = e.clientX;
this.panStartY = e.clientY;
this.panStartPanX = this.canvasPanX;
this.panStartPanY = this.canvasPanY;
const canvas = this.shadow.getElementById('gc-canvas');
if (canvas) canvas.classList.add('grabbing');
if (this.selectedNodeId) {
this.selectedNodeId = null;
this.detailOpen = false;
this.drawCanvasContent();
this.refreshDetailPanel();
}
}
private handlePointerMove(e: PointerEvent) {
if (this.wiringActive) {
this.wiringPointerX = e.clientX;
this.wiringPointerY = e.clientY;
this.updateWiringTempLine();
return;
}
if (this.draggingNodeId) {
const node = this.nodes.find(n => n.id === this.draggingNodeId);
if (node) {
const dx = (e.clientX - this.dragStartX) / this.canvasZoom;
const dy = (e.clientY - this.dragStartY) / this.canvasZoom;
node.position.x = this.dragNodeStartX + dx;
node.position.y = this.dragNodeStartY + dy;
this.updateNodePosition(node);
this.redrawEdges();
}
return;
}
if (this.isPanning) {
this.canvasPanX = this.panStartPanX + (e.clientX - this.panStartX);
this.canvasPanY = this.panStartPanY + (e.clientY - this.panStartY);
this.updateCanvasTransform();
}
}
private handlePointerUp(_e: PointerEvent) {
if (this.draggingNodeId) {
this.draggingNodeId = null;
}
if (this.isPanning) {
this.isPanning = false;
const canvas = this.shadow.getElementById('gc-canvas');
if (canvas) canvas.classList.remove('grabbing');
}
}
// ── Touch handlers (pinch-to-zoom, two-finger pan) ──
private handleTouchStart(e: TouchEvent) {
for (let i = 0; i < e.changedTouches.length; i++) {
const t = e.changedTouches[i];
this.activeTouches.set(t.identifier, { x: t.clientX, y: t.clientY });
}
if (this.activeTouches.size === 2) {
e.preventDefault();
const pts = [...this.activeTouches.values()];
this.pinchStartDist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y);
this.pinchStartZoom = this.canvasZoom;
this.pinchMidX = (pts[0].x + pts[1].x) / 2;
this.pinchMidY = (pts[0].y + pts[1].y) / 2;
this.panStartPanX = this.canvasPanX;
this.panStartPanY = this.canvasPanY;
}
}
private handleTouchMove(e: TouchEvent) {
for (let i = 0; i < e.changedTouches.length; i++) {
const t = e.changedTouches[i];
this.activeTouches.set(t.identifier, { x: t.clientX, y: t.clientY });
}
if (this.activeTouches.size === 2) {
e.preventDefault();
const pts = [...this.activeTouches.values()];
const dist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y);
const midX = (pts[0].x + pts[1].x) / 2;
const midY = (pts[0].y + pts[1].y) / 2;
// Zoom
const svg = this.shadow.getElementById('gc-svg') as SVGSVGElement | null;
if (svg) {
const rect = svg.getBoundingClientRect();
const scale = dist / this.pinchStartDist;
const newZoom = Math.max(0.1, Math.min(4, this.pinchStartZoom * scale));
const cx = this.pinchMidX - rect.left;
const cy = this.pinchMidY - rect.top;
this.canvasPanX = cx - (cx - this.panStartPanX) * (newZoom / this.pinchStartZoom);
this.canvasPanY = cy - (cy - this.panStartPanY) * (newZoom / this.pinchStartZoom);
this.canvasZoom = newZoom;
// Pan offset from midpoint movement
this.canvasPanX += (midX - this.pinchMidX);
this.canvasPanY += (midY - this.pinchMidY);
this.pinchMidX = midX;
this.pinchMidY = midY;
this.updateCanvasTransform();
}
}
}
private handleTouchEnd(e: TouchEvent) {
for (let i = 0; i < e.changedTouches.length; i++) {
this.activeTouches.delete(e.changedTouches[i].identifier);
}
if (this.activeTouches.size < 2) {
this.pinchStartDist = 0;
}
}
private handleKeyDown(e: KeyboardEvent) {
const tag = (e.target as Element)?.tagName;
const isEditing = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
if (e.key === 'Escape') {
if (this.wiringActive) this.cancelWiring();
}
if ((e.key === 'Delete' || e.key === 'Backspace') && this.selectedNodeId) {
if (isEditing) return;
this.deleteNode(this.selectedNodeId);
}
if (isEditing) return;
if (e.key === 'f' || e.key === 'F') {
this.fitView();
}
if (e.key === '=' || e.key === '+') {
const svg = this.shadow.getElementById('gc-svg');
if (svg) { const r = svg.getBoundingClientRect(); this.zoomAt(r.width / 2, r.height / 2, 1.2); }
}
if (e.key === '-') {
const svg = this.shadow.getElementById('gc-svg');
if (svg) { const r = svg.getBoundingClientRect(); this.zoomAt(r.width / 2, r.height / 2, 0.8); }
}
}
}
customElements.define('folk-gov-circuit', FolkGovCircuit);