/** * — 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; } 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 ? '✔' : ''; return `
Assignee: ${esc(c.assignee || 'Unassigned')}
${checkIcon}
${satisfied} ${c.satisfied && c.signedBy ? `by ${esc(c.signedBy)}` : ''}
`; } case 'folk-gov-threshold': { const pct = c.target > 0 ? Math.min(100, Math.round((c.current / c.target) * 100)) : 0; return `
${c.current}/${c.target} ${esc(c.unit || '')} ${pct}%
`; } case 'folk-gov-knob': { const pct = c.max > c.min ? Math.round(((c.value - c.min) / (c.max - c.min)) * 100) : 50; return `
${esc(c.unit || '')}${c.min} \u2014 ${esc(c.unit || '')}${c.max}
${esc(c.unit || '')}${c.value}
`; } case 'folk-gov-project': { const sat = c.gatesSatisfied || 0; const tot = c.gatesTotal || 0; return `
${esc(c.description || '')}
${sat} of ${tot} gates satisfied ${sat >= tot ? '' : ''}
`; } case 'folk-gov-quadratic': { const mode = c.mode || 'sqrt'; return `
Mode: ${esc(mode)}
${esc(c.entries || '')}
`; } case 'folk-gov-conviction': { const pct = c.threshold > 0 ? Math.min(100, Math.round((c.accumulated / c.threshold) * 100)) : 0; return `
Score: ${c.accumulated}/${c.threshold} ${pct}%
`; } 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 `${ok ? '\u2714' : '\u25CB'}`; }).join(' '); return `
${c.required} of ${c.total} required
${checks}
`; } case 'folk-gov-sankey': { return `
Flow visualization
`; } 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 = 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 ? ` ` : ''; this.shadow.innerHTML = `
\u25A3 Governance Circuits
Node Types
${GOV_NODE_CATALOG.map(n => `
${n.icon} ${esc(n.label)}
`).join('')}
${gridDef} ${this.renderAllEdges()} ${this.renderAllNodes()}
${Math.round(this.canvasZoom * 100)}%
${this.renderDetailPanel()}
`; 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 += ` `; } for (const out of def.outputs) { const y = getPortY(node, out.name, 'output'); const x = node.position.x + NODE_WIDTH; portsHtml += ` `; } const bodyHtml = renderNodeBody(node); return `
${def.icon} ${esc(node.label)}
${bodyHtml}
${portsHtml}
`; } 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 ` `; }).join(''); } private renderDetailPanel(): string { if (!this.selectedNodeId) { return `
No node selected

Click a node to view details.

`; } 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 `
${def.icon} ${esc(node.label)}
${fieldsHtml}
`; } private renderDetailFields(node: GovNode): string { const c = node.config; switch (node.type) { case 'folk-gov-binary': return `
`; case 'folk-gov-threshold': return `
`; case 'folk-gov-knob': return `
`; case 'folk-gov-project': return `
`; case 'folk-gov-quadratic': return `
`; case 'folk-gov-conviction': return `
`; case 'folk-gov-multisig': return `
`; case 'folk-gov-sankey': return `
`; 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 { 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 = ``; } // ── 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);