1786 lines
53 KiB
TypeScript
1786 lines
53 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 },
|
|
});
|
|
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 icon = c.satisfied
|
|
? '<span style="color:#22c55e">✔</span>'
|
|
: '<span style="color:#ef4444">✘</span>';
|
|
return `
|
|
<div style="font-size:11px;color:#94a3b8;margin-top:2px">Assignee: ${esc(c.assignee || 'Unassigned')}</div>
|
|
<div style="display:flex;align-items:center;gap:6px;margin-top:4px">
|
|
${icon}
|
|
<span style="font-size:12px;color:#e2e8f0">${satisfied}</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">✔</span>' : '<span style="color:#f59e0b">●</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.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">☰</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">×</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">×</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 = '';
|
|
}
|
|
|
|
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 };
|
|
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);
|