/** * — main rFlows application component. * * Views: * "landing" — TBFF info hero + flow list cards * "detail" — Flow detail with tabs: Table | River | Transactions * * Attributes: * space — space slug * flow-id — if set, go straight to detail view * mode — "demo" to use hardcoded demo data (no API) */ import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData, PortDefinition, PortKind } from "../lib/types"; import { PORT_DEFS } from "../lib/types"; import { computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation"; import { demoNodes, SPENDING_COLORS, OVERFLOW_COLORS } from "../lib/presets"; import { mapFlowToNodes } from "../lib/map-flow"; interface FlowSummary { id: string; name: string; label?: string; status?: string; funnelCount?: number; outcomeCount?: number; totalValue?: number; } interface Transaction { id: string; type: string; amount: number; from?: string; to?: string; timestamp: string; description?: string; } type View = "landing" | "detail"; type Tab = "diagram" | "table" | "river" | "transactions"; // ─── Auth helpers (reads EncryptID session from localStorage) ── function getSession(): { accessToken: string; claims: { sub: string; username?: string } } | null { try { const raw = localStorage.getItem("encryptid_session"); if (!raw) return null; const session = JSON.parse(raw); if (!session?.accessToken) return null; return session; } catch { return null; } } function isAuthenticated(): boolean { return getSession() !== null; } function getAccessToken(): string | null { return getSession()?.accessToken ?? null; } function getUsername(): string | null { return getSession()?.claims?.username ?? null; } class FolkFlowsApp extends HTMLElement { private shadow: ShadowRoot; private space = ""; private view: View = "landing"; private tab: Tab = "diagram"; private flowId = ""; private isDemo = false; private flows: FlowSummary[] = []; private nodes: FlowNode[] = []; private flowName = ""; private transactions: Transaction[] = []; private txLoaded = false; private loading = false; private error = ""; // Canvas state private canvasZoom = 1; private canvasPanX = 0; private canvasPanY = 0; private selectedNodeId: string | null = null; private draggingNodeId: string | null = null; private dragStartX = 0; private dragStartY = 0; private dragNodeStartX = 0; private dragNodeStartY = 0; private isPanning = false; private panStartX = 0; private panStartY = 0; private panStartPanX = 0; private panStartPanY = 0; private editingNodeId: string | null = null; private isSimulating = false; private simInterval: ReturnType | null = null; private canvasInitialized = false; // Inline edit state private inlineEditNodeId: string | null = null; private inlineEditDragThreshold: string | null = null; private inlineEditDragStartY = 0; private inlineEditDragStartValue = 0; // Wiring state private wiringActive = false; private wiringSourceNodeId: string | null = null; private wiringSourcePortKind: PortKind | null = null; private wiringSourcePortSide: "left" | "right" | null = null; private wiringDragging = false; private wiringPointerX = 0; private wiringPointerY = 0; // Touch gesture state (two-finger pinch-to-zoom & pan) private isTouchPanning = false; private lastTouchCenter: { x: number; y: number } | null = null; private lastTouchDist: number | null = null; // Bound handlers for cleanup private _boundKeyDown: ((e: KeyboardEvent) => void) | null = null; private _boundPointerMove: ((e: PointerEvent) => void) | null = null; private _boundPointerUp: ((e: PointerEvent) => void) | null = null; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; this.flowId = this.getAttribute("flow-id") || ""; this.isDemo = this.getAttribute("mode") === "demo" || this.space === "demo"; if (this.isDemo) { this.view = "detail"; this.flowName = "TBFF Demo Flow"; this.nodes = demoNodes.map((n) => ({ ...n, data: { ...n.data } })); this.render(); } else if (this.flowId) { this.view = "detail"; this.loadFlow(this.flowId); } else { this.view = "landing"; this.loadFlows(); } } private getApiBase(): string { const path = window.location.pathname; // Subdomain: /rflows/... or Direct: /{space}/rflows/... const match = path.match(/^(\/[^/]+)?\/rflows/); return match ? `${match[0]}` : ""; } private async loadFlows() { this.loading = true; this.render(); try { const base = this.getApiBase(); const params = this.space ? `?space=${encodeURIComponent(this.space)}` : ""; const res = await fetch(`${base}/api/flows${params}`); if (res.ok) { const data = await res.json(); this.flows = Array.isArray(data) ? data : (data.flows || []); } } catch { // Flow service unavailable — landing page still works with demo link } this.loading = false; this.render(); } private async loadFlow(flowId: string) { this.loading = true; this.render(); try { const base = this.getApiBase(); const res = await fetch(`${base}/api/flows/${encodeURIComponent(flowId)}`); if (res.ok) { const data = await res.json(); this.nodes = mapFlowToNodes(data); this.flowName = data.name || data.label || flowId; } else { this.error = `Flow not found (${res.status})`; } } catch { this.error = "Failed to load flow"; } this.loading = false; this.render(); } private async loadTransactions() { if (this.txLoaded || this.isDemo) return; this.loading = true; this.render(); try { const base = this.getApiBase(); const res = await fetch(`${base}/api/flows/${encodeURIComponent(this.flowId)}/transactions`); if (res.ok) { const data = await res.json(); this.transactions = Array.isArray(data) ? data : (data.transactions || []); } } catch { // Transactions unavailable } this.txLoaded = true; this.loading = false; this.render(); } private getCssPath(): string { // In rSpace: /modules/rflows/flows.css | Standalone: /modules/rflows/flows.css // The shell always serves from /modules/rflows/ in both modes return "/modules/rflows/flows.css"; } private render() { this.shadow.innerHTML = ` ${this.error ? `
${this.esc(this.error)}
` : ""} ${this.loading && this.view === "landing" ? '
Loading...
' : ""} ${this.renderView()} `; this.attachListeners(); } private renderView(): string { if (this.view === "detail") return this.renderDetail(); return this.renderLanding(); } // ─── Landing page ────────────────────────────────────── private renderLanding(): string { const demoUrl = this.getApiBase() ? `${this.getApiBase()}/demo` : "/rflows/demo"; const authed = isAuthenticated(); const username = getUsername(); return `
Flows
Demo ${authed ? `` : `Sign in to create flows` }
Design transparent resource flows with sufficiency-based cascading. Funnels fill to their threshold, then overflow routes surplus to the next layer — ensuring every level has enough before abundance cascades forward.
💰

Sources

Revenue streams split across funnels by configurable allocation percentages.

🏛

Funnels

Budget buckets with min/max thresholds and sufficiency-based overflow cascading.

🎯

Outcomes

Funding targets that receive spending allocations. Track progress toward each goal.

🌊

River View

Animated sankey diagram showing live fund flows through your entire system.

Enoughness

System-wide sufficiency scoring. Golden glow when funnels reach their threshold.

${authed ? `Flows in ${this.esc(this.space)}` : "Your Flows"}

${authed ? `Signed in as ${this.esc(username || "")}` : ""}
${this.flows.length > 0 ? `
${this.flows.map((f) => this.renderFlowCard(f)).join("")}
` : `
${authed ? `

No flows in this space yet.

Explore the demo or create your first flow.

` : `

Sign in to see your space’s flows, or explore the demo.

` }
`}

How TBFF Works

1

Define Sources

Add revenue streams — grants, donations, sales, or any recurring income — with allocation splits.

2

Configure Funnels

Set minimum, sufficient, and maximum thresholds. Overflow rules determine where surplus flows.

3

Track Outcomes

Funding targets receive allocations as funnels reach sufficiency. Watch the river flow in real time.

`; } private renderFlowCard(f: FlowSummary): string { const detailUrl = this.getApiBase() ? `${this.getApiBase()}/flow/${encodeURIComponent(f.id)}` : `/rflows/flow/${encodeURIComponent(f.id)}`; const value = f.totalValue != null ? `$${Math.floor(f.totalValue).toLocaleString()}` : ""; return `
${this.esc(f.name || f.label || f.id)}
${value ? `
${value}
` : ""}
${f.funnelCount != null ? `${f.funnelCount} funnels` : ""} ${f.outcomeCount != null ? ` · ${f.outcomeCount} outcomes` : ""} ${f.status ? ` · ${f.status}` : ""}
`; } // ─── Detail view with tabs ───────────────────────────── private renderDetail(): string { const backUrl = this.getApiBase() ? `${this.getApiBase()}/` : "/rflows/"; return `
← Flows ${this.esc(this.flowName || "Flow Detail")} ${this.isDemo ? 'Demo' : ""}
${this.loading ? '
Loading...
' : this.renderTab()}
`; } private renderTab(): string { if (this.tab === "diagram") return this.renderDiagramTab(); if (this.tab === "river") return this.renderRiverTab(); if (this.tab === "transactions") return this.renderTransactionsTab(); return this.renderTableTab(); } // ─── Table tab ──────────────────────────────────────── private renderTableTab(): string { const funnels = this.nodes.filter((n) => n.type === "funnel"); const outcomes = this.nodes.filter((n) => n.type === "outcome"); const sources = this.nodes.filter((n) => n.type === "source"); return `
${sources.length > 0 ? `

Sources

${sources.map((n) => this.renderSourceCard(n.data as SourceNodeData, n.id)).join("")}
` : ""}

Funnels

${funnels.map((n) => this.renderFunnelCard(n.data as FunnelNodeData, n.id)).join("")}

Outcomes

${outcomes.map((n) => this.renderOutcomeCard(n.data as OutcomeNodeData, n.id)).join("")}
`; } private renderSourceCard(data: SourceNodeData, id: string): string { const allocations = data.targetAllocations || []; return `
💰 ${this.esc(data.label)} ${data.sourceType}
$${data.flowRate.toLocaleString()} /month
${allocations.length > 0 ? `
${allocations.map((a) => `
${a.percentage}% → ${this.esc(this.getNodeLabel(a.targetId))}
`).join("")}
` : ""}
`; } private renderFunnelCard(data: FunnelNodeData, id: string): string { const sufficiency = computeSufficiencyState(data); const threshold = data.sufficientThreshold ?? data.maxThreshold; const fillPct = Math.min(100, (data.currentValue / (data.maxCapacity || 1)) * 100); const suffPct = Math.min(100, (data.currentValue / (threshold || 1)) * 100); const statusClass = sufficiency === "abundant" ? "flows-status--abundant" : sufficiency === "sufficient" ? "flows-status--sufficient" : data.currentValue < data.minThreshold ? "flows-status--critical" : "flows-status--seeking"; const statusLabel = sufficiency === "abundant" ? "Abundant" : sufficiency === "sufficient" ? "Sufficient" : data.currentValue < data.minThreshold ? "Critical" : "Seeking"; return `
🏛 ${this.esc(data.label)} ${statusLabel}
$${Math.floor(data.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()}
${Math.round(suffPct)}% sufficiency
Min: $${Math.floor(data.minThreshold).toLocaleString()} Max: $${Math.floor(data.maxThreshold).toLocaleString()} Cap: $${Math.floor(data.maxCapacity).toLocaleString()}
${data.overflowAllocations.length > 0 ? `
Overflow
${data.overflowAllocations.map((a) => `
${a.percentage}% → ${this.esc(this.getNodeLabel(a.targetId))}
`).join("")}
` : ""} ${data.spendingAllocations.length > 0 ? `
Spending
${data.spendingAllocations.map((a) => `
${a.percentage}% → ${this.esc(this.getNodeLabel(a.targetId))}
`).join("")}
` : ""}
`; } private renderOutcomeCard(data: OutcomeNodeData, id: string): string { const fillPct = data.fundingTarget > 0 ? Math.min(100, (data.fundingReceived / data.fundingTarget) * 100) : 0; const statusColor = data.status === "completed" ? "#10b981" : data.status === "blocked" ? "#ef4444" : data.status === "in-progress" ? "#3b82f6" : "#64748b"; return `
🎯 ${this.esc(data.label)} ${data.status}
${data.description ? `
${this.esc(data.description)}
` : ""}
$${Math.floor(data.fundingReceived).toLocaleString()} / $${Math.floor(data.fundingTarget).toLocaleString()}
${Math.round(fillPct)}% funded
`; } private getNodeLabel(id: string): string { const node = this.nodes.find((n) => n.id === id); if (!node) return id; return (node.data as any).label || id; } // ─── Diagram tab (interactive canvas) ───────────────── private renderDiagramTab(): string { if (this.nodes.length === 0) { return '
No nodes to display.
'; } const score = computeSystemSufficiency(this.nodes); const scorePct = Math.round(score * 100); const scoreColor = scorePct >= 90 ? "#fbbf24" : scorePct >= 60 ? "#10b981" : scorePct >= 30 ? "#f59e0b" : "#ef4444"; return `
${scorePct}%
ENOUGH
Source Funnel Overflow Spending Outcome Sufficient
`; } // ─── Canvas lifecycle ───────────────────────────────── private initCanvas() { this.drawCanvasContent(); this.updateCanvasTransform(); this.attachCanvasListeners(); if (!this.canvasInitialized) { this.canvasInitialized = true; requestAnimationFrame(() => this.fitView()); } this.loadFromHash(); } private drawCanvasContent() { const edgeLayer = this.shadow.getElementById("edge-layer"); const nodeLayer = this.shadow.getElementById("node-layer"); if (!edgeLayer || !nodeLayer) return; edgeLayer.innerHTML = this.renderAllEdges(); nodeLayer.innerHTML = this.renderAllNodes(); } private updateCanvasTransform() { const g = this.shadow.getElementById("canvas-transform"); if (g) g.setAttribute("transform", `translate(${this.canvasPanX},${this.canvasPanY}) scale(${this.canvasZoom})`); } private fitView() { const svg = this.shadow.getElementById("flow-canvas") 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) { const s = this.getNodeSize(n); minX = Math.min(minX, n.position.x); minY = Math.min(minY, n.position.y); maxX = Math.max(maxX, n.position.x + s.w); maxY = Math.max(maxY, n.position.y + s.h); } 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 getNodeSize(n: FlowNode): { w: number; h: number } { if (n.type === "source") return { w: 200, h: 60 }; if (n.type === "funnel") { const d = n.data as FunnelNodeData; const baseW = 200, baseH = 160; // Scale: $1k/mo = 1x, $10k/mo = ~1.3x, $100k/mo = ~1.6x (logarithmic) const scale = 1 + Math.log10(Math.max(1, d.inflowRate / 1000)) * 0.3; return { w: Math.round(baseW * scale), h: Math.round(baseH * scale) }; } return { w: 200, h: 100 }; // outcome } // ─── Canvas event wiring ────────────────────────────── private attachCanvasListeners() { const svg = this.shadow.getElementById("flow-canvas"); if (!svg) return; // Wheel zoom svg.addEventListener("wheel", (e: WheelEvent) => { e.preventDefault(); const delta = e.deltaY > 0 ? 0.9 : 1.1; const rect = svg.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; const newZoom = Math.max(0.1, Math.min(4, this.canvasZoom * delta)); // Zoom toward pointer this.canvasPanX = mx - (mx - this.canvasPanX) * (newZoom / this.canvasZoom); this.canvasPanY = my - (my - this.canvasPanY) * (newZoom / this.canvasZoom); this.canvasZoom = newZoom; this.updateCanvasTransform(); }, { passive: false }); // Panning — pointerdown on SVG background svg.addEventListener("pointerdown", (e: PointerEvent) => { const target = e.target as Element; // Only pan when clicking SVG background (not on a node) if (target.closest(".flow-node")) return; if (target.closest(".edge-ctrl-group")) return; // Cancel wiring on empty canvas click 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; svg.classList.add("panning"); svg.setPointerCapture(e.pointerId); // Deselect node if (!target.closest(".flow-node")) { this.selectedNodeId = null; this.updateSelectionHighlight(); } }); // Global pointer move/up (for both panning and node drag) let nodeDragStarted = false; const DRAG_THRESHOLD = 5; this._boundPointerMove = (e: PointerEvent) => { if (this.wiringActive && this.wiringDragging) { this.wiringPointerX = e.clientX; this.wiringPointerY = e.clientY; this.updateWiringTempLine(); return; } if (this.isPanning) { this.canvasPanX = this.panStartPanX + (e.clientX - this.panStartX); this.canvasPanY = this.panStartPanY + (e.clientY - this.panStartY); this.updateCanvasTransform(); return; } if (this.draggingNodeId) { const rawDx = e.clientX - this.dragStartX; const rawDy = e.clientY - this.dragStartY; // Only start visual drag after exceeding threshold if (!nodeDragStarted) { if (Math.abs(rawDx) < DRAG_THRESHOLD && Math.abs(rawDy) < DRAG_THRESHOLD) return; nodeDragStarted = true; svg.classList.add("dragging"); } const dx = rawDx / this.canvasZoom; const dy = rawDy / this.canvasZoom; const node = this.nodes.find((n) => n.id === this.draggingNodeId); if (node) { node.position.x = this.dragNodeStartX + dx; node.position.y = this.dragNodeStartY + dy; this.updateNodePosition(node); this.redrawEdges(); } } }; this._boundPointerUp = (e: PointerEvent) => { if (this.wiringActive && this.wiringDragging) { // Hit-test: did we release on a compatible input port? const el = this.shadow.elementFromPoint(e.clientX, e.clientY); const portGroup = el?.closest?.(".port-group") as SVGGElement | null; if (portGroup && portGroup.dataset.portDir === "in" && portGroup.dataset.nodeId !== this.wiringSourceNodeId) { this.completeWiring(portGroup.dataset.nodeId!); } else { // Fall back to click-to-wire mode (source still glowing) this.wiringDragging = false; const wireLayer = this.shadow.getElementById("wire-layer"); if (wireLayer) wireLayer.innerHTML = ""; } return; } if (this.isPanning) { this.isPanning = false; svg.classList.remove("panning"); } if (this.draggingNodeId) { const clickedNodeId = this.draggingNodeId; const wasDragged = nodeDragStarted; this.draggingNodeId = null; nodeDragStarted = false; svg.classList.remove("dragging"); // Single click = select only (inline edit on double-click) if (!wasDragged) { this.selectedNodeId = clickedNodeId; this.updateSelectionHighlight(); } } }; svg.addEventListener("pointermove", this._boundPointerMove); svg.addEventListener("pointerup", this._boundPointerUp); // Node interactions — delegate from node-layer const nodeLayer = this.shadow.getElementById("node-layer"); if (nodeLayer) { nodeLayer.addEventListener("pointerdown", (e: PointerEvent) => { // Check port interaction FIRST const portGroup = (e.target as Element).closest(".port-group") as SVGGElement | null; if (portGroup) { e.stopPropagation(); const portNodeId = portGroup.dataset.nodeId!; const portKind = portGroup.dataset.portKind as PortKind; const portDir = portGroup.dataset.portDir!; if (this.wiringActive) { // Click-to-wire: complete on compatible input port if (portDir === "in" && portNodeId !== this.wiringSourceNodeId) { this.completeWiring(portNodeId); } else { this.cancelWiring(); } return; } // Start wiring from output port if (portDir === "out") { const portSide = portGroup.dataset.portSide as "left" | "right" | undefined; this.enterWiring(portNodeId, portKind, portSide); this.wiringDragging = true; this.wiringPointerX = e.clientX; this.wiringPointerY = e.clientY; svg.setPointerCapture(e.pointerId); return; } return; } const group = (e.target as Element).closest(".flow-node") as SVGGElement | null; if (!group) return; e.stopPropagation(); const nodeId = group.dataset.nodeId; if (!nodeId) return; const node = this.nodes.find((n) => n.id === nodeId); if (!node) return; // If wiring is active and clicked on a node (not port), cancel if (this.wiringActive) { this.cancelWiring(); return; } // Select this.selectedNodeId = nodeId; this.updateSelectionHighlight(); // Prepare drag (but don't start until threshold exceeded) nodeDragStarted = false; this.draggingNodeId = nodeId; this.dragStartX = e.clientX; this.dragStartY = e.clientY; this.dragNodeStartX = node.position.x; this.dragNodeStartY = node.position.y; svg.setPointerCapture(e.pointerId); }); nodeLayer.addEventListener("dblclick", (e: MouseEvent) => { const group = (e.target as Element).closest(".flow-node") as SVGGElement | null; if (!group) return; const nodeId = group.dataset.nodeId; if (!nodeId) return; const node = this.nodes.find((n) => n.id === nodeId); if (!node) return; this.enterInlineEdit(nodeId); }); // Hover: tooltip + edge highlighting let hoveredNodeId: string | null = null; nodeLayer.addEventListener("mouseover", (e: MouseEvent) => { const group = (e.target as Element).closest(".flow-node") as SVGGElement | null; if (!group) return; const nodeId = group.dataset.nodeId; if (nodeId && nodeId !== hoveredNodeId) { hoveredNodeId = nodeId; this.showNodeTooltip(nodeId, e); this.highlightNodeEdges(nodeId); } }); nodeLayer.addEventListener("mouseout", (e: MouseEvent) => { const related = (e.relatedTarget as Element | null)?.closest?.(".flow-node"); if (!related) { hoveredNodeId = null; this.hideNodeTooltip(); this.unhighlightEdges(); } }); } // Toolbar buttons this.shadow.querySelectorAll("[data-canvas-action]").forEach((btn) => { btn.addEventListener("click", () => { const action = (btn as HTMLElement).dataset.canvasAction; if (action === "add-source") this.addNode("source"); else if (action === "add-funnel") this.addNode("funnel"); else if (action === "add-outcome") this.addNode("outcome"); else if (action === "sim") this.toggleSimulation(); else if (action === "fit") this.fitView(); else if (action === "share") this.shareState(); else if (action === "zoom-in") { this.canvasZoom = Math.min(4, this.canvasZoom * 1.2); this.updateCanvasTransform(); } else if (action === "zoom-out") { this.canvasZoom = Math.max(0.1, this.canvasZoom * 0.8); this.updateCanvasTransform(); } }); }); // Edge +/- buttons (delegated) const edgeLayer = this.shadow.getElementById("edge-layer"); if (edgeLayer) { edgeLayer.addEventListener("click", (e: Event) => { const btn = (e.target as Element).closest("[data-edge-action]") as HTMLElement | null; if (!btn) return; e.stopPropagation(); const action = btn.dataset.edgeAction; // "inc" or "dec" const fromId = btn.dataset.edgeFrom!; const toId = btn.dataset.edgeTo!; const allocType = btn.dataset.edgeType as "overflow" | "spending" | "source"; this.handleAdjustAllocation(fromId, toId, allocType, action === "inc" ? 5 : -5); }); } // Touch gesture handling for two-finger pan + pinch-to-zoom const getTouchCenter = (touches: TouchList) => ({ x: (touches[0].clientX + touches[1].clientX) / 2, y: (touches[0].clientY + touches[1].clientY) / 2, }); const getTouchDist = (touches: TouchList) => { const dx = touches[0].clientX - touches[1].clientX; const dy = touches[0].clientY - touches[1].clientY; return Math.hypot(dx, dy); }; svg.addEventListener("touchstart", (e: TouchEvent) => { if (e.touches.length === 2) { e.preventDefault(); this.isTouchPanning = true; // Cancel any pointer-based pan or node drag this.isPanning = false; if (this.draggingNodeId) { this.draggingNodeId = null; nodeDragStarted = false; svg.classList.remove("dragging"); } if (this.wiringActive) this.cancelWiring(); this.lastTouchCenter = getTouchCenter(e.touches); this.lastTouchDist = getTouchDist(e.touches); } }, { passive: false }); svg.addEventListener("touchmove", (e: TouchEvent) => { if (e.touches.length === 2 && this.isTouchPanning) { e.preventDefault(); const currentCenter = getTouchCenter(e.touches); const currentDist = getTouchDist(e.touches); if (this.lastTouchCenter) { // Two-finger pan this.canvasPanX += currentCenter.x - this.lastTouchCenter.x; this.canvasPanY += currentCenter.y - this.lastTouchCenter.y; } if (this.lastTouchDist && this.lastTouchDist > 0) { // Pinch-to-zoom around gesture center const zoomDelta = currentDist / this.lastTouchDist; const newZoom = Math.max(0.2, Math.min(5, this.canvasZoom * zoomDelta)); const rect = svg.getBoundingClientRect(); const cx = currentCenter.x - rect.left; const cy = currentCenter.y - rect.top; this.canvasPanX = cx - (cx - this.canvasPanX) * (newZoom / this.canvasZoom); this.canvasPanY = cy - (cy - this.canvasPanY) * (newZoom / this.canvasZoom); this.canvasZoom = newZoom; } this.lastTouchCenter = currentCenter; this.lastTouchDist = currentDist; this.updateCanvasTransform(); } }, { passive: false }); svg.addEventListener("touchend", (e: TouchEvent) => { if (e.touches.length < 2) { this.lastTouchCenter = null; this.lastTouchDist = null; this.isTouchPanning = false; } }); // Keyboard this._boundKeyDown = (e: KeyboardEvent) => { // Skip if typing in editor input const tag = (e.target as Element).tagName; if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return; if (e.key === "Escape") { if (this.inlineEditNodeId) { this.exitInlineEdit(); return; } if (this.wiringActive) { this.cancelWiring(); return; } this.closeModal(); this.closeEditor(); } else if (e.key === " ") { e.preventDefault(); this.toggleSimulation(); } else if (e.key === "Delete" || e.key === "Backspace") { if (this.selectedNodeId) this.deleteNode(this.selectedNodeId); } else if (e.key === "f" || e.key === "F") this.fitView(); else if (e.key === "=" || e.key === "+") { this.canvasZoom = Math.min(4, this.canvasZoom * 1.2); this.updateCanvasTransform(); } else if (e.key === "-") { this.canvasZoom = Math.max(0.1, this.canvasZoom * 0.8); this.updateCanvasTransform(); } }; document.addEventListener("keydown", this._boundKeyDown); } // ─── Inflow satisfaction computation ───────────────── private computeInflowSatisfaction(): Map { const result = new Map(); for (const n of this.nodes) { if (n.type === "funnel") { const d = n.data as FunnelNodeData; const needed = d.inflowRate || 1; let actual = 0; // Sum source→funnel allocations for (const src of this.nodes) { if (src.type === "source") { const sd = src.data as SourceNodeData; for (const alloc of sd.targetAllocations) { if (alloc.targetId === n.id) actual += sd.flowRate * (alloc.percentage / 100); } } // Sum overflow from parent funnels if (src.type === "funnel" && src.id !== n.id) { const fd = src.data as FunnelNodeData; const excess = Math.max(0, fd.currentValue - fd.maxThreshold); for (const alloc of fd.overflowAllocations) { if (alloc.targetId === n.id) actual += excess * (alloc.percentage / 100); } } // Sum overflow from parent outcomes if (src.type === "outcome") { const od = src.data as OutcomeNodeData; const excess = Math.max(0, od.fundingReceived - od.fundingTarget); for (const alloc of (od.overflowAllocations || [])) { if (alloc.targetId === n.id) actual += excess * (alloc.percentage / 100); } } } result.set(n.id, { actual, needed, ratio: Math.min(actual / needed, 2) }); } if (n.type === "outcome") { const d = n.data as OutcomeNodeData; const needed = Math.max(d.fundingTarget, 1); const actual = d.fundingReceived; result.set(n.id, { actual, needed, ratio: Math.min(actual / needed, 2) }); } } return result; } // ─── Node SVG rendering ─────────────────────────────── private renderAllNodes(): string { const satisfaction = this.computeInflowSatisfaction(); return this.nodes.map((n) => this.renderNodeSvg(n, satisfaction)).join(""); } private renderNodeSvg(n: FlowNode, satisfaction: Map): string { const sel = this.selectedNodeId === n.id; if (n.type === "source") return this.renderSourceNodeSvg(n, sel); if (n.type === "funnel") return this.renderFunnelNodeSvg(n, sel, satisfaction.get(n.id)); return this.renderOutcomeNodeSvg(n, sel, satisfaction.get(n.id)); } private renderSourceNodeSvg(n: FlowNode, selected: boolean): string { const d = n.data as SourceNodeData; const x = n.position.x, y = n.position.y, w = 200, h = 60; const icons: Record = { card: "\u{1F4B3}", safe_wallet: "\u{1F512}", ridentity: "\u{1F464}", unconfigured: "\u{2699}" }; const icon = icons[d.sourceType] || "\u{1F4B0}"; return ` ${icon} ${this.esc(d.label)} $${d.flowRate.toLocaleString()}/mo ${this.renderAllocBar(d.targetAllocations, w, h - 6)} ${this.renderPortsSvg(n)} `; } private renderFunnelNodeSvg(n: FlowNode, selected: boolean, sat?: { actual: number; needed: number; ratio: number }): string { const d = n.data as FunnelNodeData; const s = this.getNodeSize(n); const x = n.position.x, y = n.position.y, w = s.w, h = s.h; const sufficiency = computeSufficiencyState(d); const isSufficient = sufficiency === "sufficient" || sufficiency === "abundant"; const isAbundant = sufficiency === "abundant"; const threshold = d.sufficientThreshold ?? d.maxThreshold; const fillPct = Math.min(1, d.currentValue / (d.maxCapacity || 1)); const borderColor = d.currentValue > d.maxThreshold ? "#f59e0b" : d.currentValue < d.minThreshold ? "#ef4444" : isSufficient ? "#fbbf24" : "#0ea5e9"; const fillColor = borderColor; const statusLabel = sufficiency === "abundant" ? "Abundant" : sufficiency === "sufficient" ? "Sufficient" : d.currentValue < d.minThreshold ? "Critical" : "Seeking"; // Funnel shape parameters const r = 10; // corner radius const lipW = 14; // overflow lip extension const lipH = Math.round(h * 0.08); // lip notch top offset const lipNotch = 14; // lip notch height const taperStart = 0.65; // body tapers at 65% down const taperInset = 0.2; // bottom is 60% of top width const insetPx = Math.round(w * taperInset); const taperY = Math.round(h * taperStart); const clipId = `funnel-clip-${n.id}`; // Funnel SVG path: wide top with lip notches, tapering to narrow bottom const funnelPath = [ `M ${r},0`, // top-left after corner `L ${w - r},0`, // across top `Q ${w},0 ${w},${r}`, // top-right corner `L ${w},${lipH}`, // down to right lip `L ${w + lipW},${lipH}`, // right lip extends `L ${w + lipW},${lipH + lipNotch}`, // right lip bottom `L ${w},${lipH + lipNotch}`, // back to body `L ${w},${taperY}`, // down right side to taper `Q ${w},${taperY + (h - taperY) * 0.3} ${w - insetPx},${h - r}`, // taper curve right `Q ${w - insetPx},${h} ${w - insetPx - r},${h}`, // bottom-right corner `L ${insetPx + r},${h}`, // across narrow bottom `Q ${insetPx},${h} ${insetPx},${h - r}`, // bottom-left corner `Q ${insetPx},${taperY + (h - taperY) * 0.3} 0,${taperY}`, // taper curve left `L 0,${lipH + lipNotch}`, // up left side from taper `L ${-lipW},${lipH + lipNotch}`, // left lip bottom `L ${-lipW},${lipH}`, // left lip top `L 0,${lipH}`, // back to body `L 0,${r}`, // up to top-left `Q 0,0 ${r},0`, // top-left corner `Z`, ].join(" "); // Interior regions (clipped to funnel shape) const zoneTop = lipH + lipNotch + 4; const zoneBot = h - 4; const zoneH = zoneBot - zoneTop; const drainPct = d.minThreshold / (d.maxCapacity || 1); const healthyPct = (d.maxThreshold - d.minThreshold) / (d.maxCapacity || 1); const overflowPct = Math.max(0, 1 - drainPct - healthyPct); const drainH = zoneH * drainPct; const healthyH = zoneH * healthyPct; const overflowH = zoneH * overflowPct; // Fill level const totalFillH = zoneH * fillPct; const fillY = zoneTop + zoneH - totalFillH; // Inflow satisfaction bar const satBarY = lipH + lipNotch + 22; const satBarW = w - 40; const satRatio = sat ? Math.min(sat.ratio, 1) : 0; const satOverflow = sat ? sat.ratio > 1 : false; const satFillW = satBarW * satRatio; const satLabel = sat ? `${this.formatDollar(sat.actual)} of ${this.formatDollar(sat.needed)}/mo` : ""; const satBarBorder = satOverflow ? `stroke="#fbbf24" stroke-width="1"` : ""; const glowClass = isSufficient ? " node-glow" : ""; return ` ${isSufficient ? `` : ""} ${this.esc(d.label)} ${statusLabel} ${satLabel} $${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()} ${this.renderPortsSvg(n)} `; } private renderOutcomeNodeSvg(n: FlowNode, selected: boolean, sat?: { actual: number; needed: number; ratio: number }): string { const d = n.data as OutcomeNodeData; const x = n.position.x, y = n.position.y, w = 200, h = 100; const fillPct = d.fundingTarget > 0 ? Math.min(1, d.fundingReceived / d.fundingTarget) : 0; const statusColor = d.status === "completed" ? "#10b981" : d.status === "blocked" ? "#ef4444" : d.status === "in-progress" ? "#3b82f6" : "#64748b"; let phaseBars = ""; if (d.phases && d.phases.length > 0) { const phaseW = (w - 20) / d.phases.length; phaseBars = d.phases.map((p, i) => { const unlocked = d.fundingReceived >= p.fundingThreshold; return ``; }).join(""); phaseBars += `${d.phases.filter((p) => d.fundingReceived >= p.fundingThreshold).length}/${d.phases.length} phases`; } // Enhanced progress bar (8px height, green funded portion + grey gap) const barW = w - 20; const barY = 34; const barH = 8; const dollarLabel = `${this.formatDollar(d.fundingReceived)} / ${this.formatDollar(d.fundingTarget)}`; return ` ${this.esc(d.label)} ${Math.round(fillPct * 100)}% — ${dollarLabel} ${phaseBars} ${this.renderPortsSvg(n)} `; } private renderAllocBar(allocs: { percentage: number; color: string }[], parentW: number, y: number): string { if (!allocs || allocs.length === 0) return ""; let bar = ""; let cx = 10; const barW = parentW - 20; for (const a of allocs) { const segW = barW * (a.percentage / 100); bar += ``; cx += segW; } return bar; } // ─── Edge rendering ─────────────────────────────────── private formatDollar(amount: number): string { if (amount >= 1_000_000) return `$${(amount / 1_000_000).toFixed(1)}M`; if (amount >= 1_000) return `$${(amount / 1_000).toFixed(1)}k`; return `$${Math.round(amount)}`; } private renderAllEdges(): string { // First pass: compute actual dollar flow per edge interface EdgeInfo { fromNode: FlowNode; toNode: FlowNode; fromPort: PortKind; fromSide?: "left" | "right"; color: string; flowAmount: number; pct: number; dashed: boolean; fromId: string; toId: string; edgeType: string; } const edges: EdgeInfo[] = []; for (const n of this.nodes) { if (n.type === "source") { const d = n.data as SourceNodeData; for (const alloc of d.targetAllocations) { const target = this.nodes.find((t) => t.id === alloc.targetId); if (!target) continue; const flowAmount = d.flowRate * (alloc.percentage / 100); edges.push({ fromNode: n, toNode: target, fromPort: "outflow", color: alloc.color || "#10b981", flowAmount, pct: alloc.percentage, dashed: false, fromId: n.id, toId: alloc.targetId, edgeType: "source", }); } } if (n.type === "funnel") { const d = n.data as FunnelNodeData; // Overflow edges — actual excess flow (routed through side ports) for (const alloc of d.overflowAllocations) { const target = this.nodes.find((t) => t.id === alloc.targetId); if (!target) continue; const excess = Math.max(0, d.currentValue - d.maxThreshold); const flowAmount = excess * (alloc.percentage / 100); const side = this.getOverflowSideForTarget(n, target); edges.push({ fromNode: n, toNode: target, fromPort: "overflow", fromSide: side, color: alloc.color || "#f59e0b", flowAmount, pct: alloc.percentage, dashed: true, fromId: n.id, toId: alloc.targetId, edgeType: "overflow", }); } // Spending edges — rate-based drain for (const alloc of d.spendingAllocations) { const target = this.nodes.find((t) => t.id === alloc.targetId); if (!target) continue; let rateMultiplier: number; if (d.currentValue > d.maxThreshold) rateMultiplier = 0.8; else if (d.currentValue >= d.minThreshold) rateMultiplier = 0.5; else rateMultiplier = 0.1; const drain = d.inflowRate * rateMultiplier; const flowAmount = drain * (alloc.percentage / 100); edges.push({ fromNode: n, toNode: target, fromPort: "spending", color: alloc.color || "#8b5cf6", flowAmount, pct: alloc.percentage, dashed: false, fromId: n.id, toId: alloc.targetId, edgeType: "spending", }); } } // Outcome overflow edges if (n.type === "outcome") { const d = n.data as OutcomeNodeData; const allocs = d.overflowAllocations || []; for (const alloc of allocs) { const target = this.nodes.find((t) => t.id === alloc.targetId); if (!target) continue; const excess = Math.max(0, d.fundingReceived - d.fundingTarget); const flowAmount = excess * (alloc.percentage / 100); edges.push({ fromNode: n, toNode: target, fromPort: "overflow", color: alloc.color || "#f59e0b", flowAmount, pct: alloc.percentage, dashed: true, fromId: n.id, toId: alloc.targetId, edgeType: "overflow", }); } } } // Find max flow amount for width normalization const maxFlowAmount = Math.max(1, ...edges.map((e) => e.flowAmount)); // Second pass: render edges with normalized widths let html = ""; for (const e of edges) { const from = this.getPortPosition(e.fromNode, e.fromPort, e.fromSide); const to = this.getPortPosition(e.toNode, "inflow"); const isGhost = e.flowAmount === 0; const strokeW = isGhost ? 1 : Math.max(1.5, (e.flowAmount / maxFlowAmount) * 14); const label = isGhost ? `${e.pct}%` : `${this.formatDollar(e.flowAmount)} (${e.pct}%)`; html += this.renderEdgePath( from.x, from.y, to.x, to.y, e.color, strokeW, e.dashed, isGhost, label, e.fromId, e.toId, e.edgeType, e.fromSide, ); } return html; } private renderEdgePath( x1: number, y1: number, x2: number, y2: number, color: string, strokeW: number, dashed: boolean, ghost: boolean, label: string, fromId: string, toId: string, edgeType: string, fromSide?: "left" | "right", ): string { let d: string; let midX: number; let midY: number; if (fromSide) { // Side port: curve outward horizontally first, then turn toward target const outwardX = fromSide === "left" ? x1 - 60 : x1 + 60; d = `M ${x1} ${y1} C ${outwardX} ${y1}, ${x2} ${y1 + (y2 - y1) * 0.4}, ${x2} ${y2}`; midX = (x1 + outwardX + x2) / 3; midY = (y1 + y2) / 2; } else { const cy1 = y1 + (y2 - y1) * 0.4; const cy2 = y1 + (y2 - y1) * 0.6; d = `M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}`; midX = (x1 + x2) / 2; midY = (y1 + y2) / 2; } if (ghost) { return ` ${label} + `; } const animClass = dashed ? "edge-path-overflow" : "edge-path-animated"; // Wider label box to fit dollar amounts const labelW = Math.max(68, label.length * 7 + 36); const halfW = labelW / 2; return ` ${label} + `; } private redrawEdges() { const edgeLayer = this.shadow.getElementById("edge-layer"); if (edgeLayer) edgeLayer.innerHTML = this.renderAllEdges(); } // ─── Selection highlight ────────────────────────────── private updateSelectionHighlight() { const nodeLayer = this.shadow.getElementById("node-layer"); if (!nodeLayer) return; nodeLayer.querySelectorAll(".flow-node").forEach((g) => { const el = g as SVGGElement; const isSelected = el.dataset.nodeId === this.selectedNodeId; el.classList.toggle("selected", isSelected); const bg = el.querySelector(".node-bg") as SVGElement | null; if (bg) { if (isSelected) { bg.setAttribute("stroke", "#6366f1"); bg.setAttribute("stroke-width", "3"); } else { // Restore original color const node = this.nodes.find((n) => n.id === el.dataset.nodeId); if (node) { const origColor = this.getNodeBorderColor(node); bg.setAttribute("stroke", origColor); bg.setAttribute("stroke-width", node.type === "outcome" ? "1.5" : "2"); } } } }); } private getNodeBorderColor(n: FlowNode): string { if (n.type === "source") return "#10b981"; if (n.type === "funnel") { const d = n.data as FunnelNodeData; const suf = computeSufficiencyState(d); const isSuf = suf === "sufficient" || suf === "abundant"; return d.currentValue > d.maxThreshold ? "#f59e0b" : d.currentValue < d.minThreshold ? "#ef4444" : isSuf ? "#fbbf24" : "#0ea5e9"; } const d = n.data as OutcomeNodeData; return d.status === "completed" ? "#10b981" : d.status === "blocked" ? "#ef4444" : d.status === "in-progress" ? "#3b82f6" : "#64748b"; } // ─── Port rendering & wiring ───────────────────────── private getPortDefs(nodeType: FlowNode["type"]): PortDefinition[] { return PORT_DEFS[nodeType] || []; } private getPortPosition(node: FlowNode, portKind: PortKind, side?: "left" | "right"): { x: number; y: number } { const s = this.getNodeSize(node); let def: PortDefinition | undefined; if (side) { def = this.getPortDefs(node.type).find((p) => p.kind === portKind && p.side === side); } if (!def) { def = this.getPortDefs(node.type).find((p) => p.kind === portKind && (!side || !p.side)); } if (!def) { // Fallback: pick first matching kind def = this.getPortDefs(node.type).find((p) => p.kind === portKind); } if (!def) return { x: node.position.x + s.w / 2, y: node.position.y + s.h / 2 }; return { x: node.position.x + s.w * def.xFrac, y: node.position.y + s.h * def.yFrac }; } /** Pick the overflow side port closest to a target node */ private getOverflowSideForTarget(fromNode: FlowNode, toNode: FlowNode): "left" | "right" { const toCenter = toNode.position.x + this.getNodeSize(toNode).w / 2; const fromCenter = fromNode.position.x + this.getNodeSize(fromNode).w / 2; return toCenter < fromCenter ? "left" : "right"; } private renderPortsSvg(n: FlowNode): string { const s = this.getNodeSize(n); const defs = this.getPortDefs(n.type); return defs.map((p) => { const cx = s.w * p.xFrac; const cy = s.h * p.yFrac; let arrow: string; const sideAttr = p.side ? ` data-port-side="${p.side}"` : ""; if (p.side) { // Side port: horizontal arrow if (p.side === "left") { arrow = ``; } else { arrow = ``; } } else if (p.dir === "out") { arrow = ``; } else { arrow = ``; } return ` ${arrow} `; }).join(""); } private enterWiring(nodeId: string, portKind: PortKind, portSide?: "left" | "right") { this.wiringActive = true; this.wiringSourceNodeId = nodeId; this.wiringSourcePortKind = portKind; this.wiringSourcePortSide = portSide || null; this.wiringDragging = false; const svg = this.shadow.getElementById("flow-canvas"); if (svg) svg.classList.add("wiring"); this.applyWiringClasses(); } private cancelWiring() { this.wiringActive = false; this.wiringSourceNodeId = null; this.wiringSourcePortKind = null; this.wiringSourcePortSide = null; this.wiringDragging = false; const svg = this.shadow.getElementById("flow-canvas"); if (svg) svg.classList.remove("wiring"); const wireLayer = this.shadow.getElementById("wire-layer"); if (wireLayer) wireLayer.innerHTML = ""; this.clearWiringClasses(); } private applyWiringClasses() { const nodeLayer = this.shadow.getElementById("node-layer"); if (!nodeLayer || !this.wiringSourceNodeId || !this.wiringSourcePortKind) return; const sourceNode = this.nodes.find((n) => n.id === this.wiringSourceNodeId); if (!sourceNode) return; const sourceDef = this.getPortDefs(sourceNode.type).find((p) => p.kind === this.wiringSourcePortKind); const connectsTo = sourceDef?.connectsTo || []; nodeLayer.querySelectorAll(".port-group").forEach((g) => { const el = g as SVGGElement; const nid = el.dataset.nodeId!; const pk = el.dataset.portKind as PortKind; const pd = el.dataset.portDir!; if (nid === this.wiringSourceNodeId && pk === this.wiringSourcePortKind) { el.classList.add("port-group--wiring-source"); } else if (pd === "in" && connectsTo.includes(pk) && nid !== this.wiringSourceNodeId && !this.allocationExists(this.wiringSourceNodeId!, nid, this.wiringSourcePortKind!)) { el.classList.add("port-group--wiring-target"); } else { el.classList.add("port-group--wiring-dimmed"); } }); } private clearWiringClasses() { const nodeLayer = this.shadow.getElementById("node-layer"); if (!nodeLayer) return; nodeLayer.querySelectorAll(".port-group").forEach((g) => { g.classList.remove("port-group--wiring-source", "port-group--wiring-target", "port-group--wiring-dimmed"); }); } private completeWiring(targetNodeId: string) { if (!this.wiringSourceNodeId || !this.wiringSourcePortKind) return; const sourceNode = this.nodes.find((n) => n.id === this.wiringSourceNodeId); const targetNode = this.nodes.find((n) => n.id === targetNodeId); if (!sourceNode || !targetNode) { this.cancelWiring(); return; } // Determine allocation type and color const portKind = this.wiringSourcePortKind; if (sourceNode.type === "source" && portKind === "outflow") { const d = sourceNode.data as SourceNodeData; const color = SPENDING_COLORS[d.targetAllocations.length % SPENDING_COLORS.length] || "#10b981"; d.targetAllocations.push({ targetId: targetNodeId, percentage: 0, color }); this.normalizeAllocations(d.targetAllocations); } else if (sourceNode.type === "funnel" && portKind === "overflow") { const d = sourceNode.data as FunnelNodeData; const color = OVERFLOW_COLORS[d.overflowAllocations.length % OVERFLOW_COLORS.length] || "#f59e0b"; d.overflowAllocations.push({ targetId: targetNodeId, percentage: 0, color }); this.normalizeAllocations(d.overflowAllocations); } else if (sourceNode.type === "funnel" && portKind === "spending") { const d = sourceNode.data as FunnelNodeData; const color = SPENDING_COLORS[d.spendingAllocations.length % SPENDING_COLORS.length] || "#8b5cf6"; d.spendingAllocations.push({ targetId: targetNodeId, percentage: 0, color }); this.normalizeAllocations(d.spendingAllocations); } else if (sourceNode.type === "outcome" && portKind === "overflow") { const d = sourceNode.data as OutcomeNodeData; if (!d.overflowAllocations) d.overflowAllocations = []; const color = OVERFLOW_COLORS[d.overflowAllocations.length % OVERFLOW_COLORS.length] || "#f59e0b"; d.overflowAllocations.push({ targetId: targetNodeId, percentage: 0, color }); this.normalizeAllocations(d.overflowAllocations); } this.cancelWiring(); this.drawCanvasContent(); this.openEditor(this.wiringSourceNodeId || sourceNode.id); } private normalizeAllocations(allocs: { targetId: string; percentage: number; color: string }[]) { if (allocs.length === 0) return; const equal = Math.floor(100 / allocs.length); const remainder = 100 - equal * allocs.length; allocs.forEach((a, i) => { a.percentage = equal + (i === 0 ? remainder : 0); }); } private allocationExists(fromId: string, toId: string, portKind: PortKind): boolean { const node = this.nodes.find((n) => n.id === fromId); if (!node) return false; if (node.type === "source" && portKind === "outflow") { return (node.data as SourceNodeData).targetAllocations.some((a) => a.targetId === toId); } if (node.type === "funnel" && portKind === "overflow") { return (node.data as FunnelNodeData).overflowAllocations.some((a) => a.targetId === toId); } if (node.type === "funnel" && portKind === "spending") { return (node.data as FunnelNodeData).spendingAllocations.some((a) => a.targetId === toId); } if (node.type === "outcome" && portKind === "overflow") { return ((node.data as OutcomeNodeData).overflowAllocations || []).some((a) => a.targetId === toId); } return false; } private updateWiringTempLine() { const wireLayer = this.shadow.getElementById("wire-layer"); if (!wireLayer || !this.wiringSourceNodeId || !this.wiringSourcePortKind) return; const sourceNode = this.nodes.find((n) => n.id === this.wiringSourceNodeId); if (!sourceNode) return; const { x: x1, y: y1 } = this.getPortPosition(sourceNode, this.wiringSourcePortKind, this.wiringSourcePortSide || undefined); const svg = this.shadow.getElementById("flow-canvas") as SVGSVGElement | null; if (!svg) return; const rect = svg.getBoundingClientRect(); const x2 = (this.wiringPointerX - rect.left - this.canvasPanX) / this.canvasZoom; const y2 = (this.wiringPointerY - rect.top - this.canvasPanY) / this.canvasZoom; let tempPath: string; if (this.wiringSourcePortSide) { // Side port: curve outward horizontally first const outwardX = this.wiringSourcePortSide === "left" ? x1 - 60 : x1 + 60; tempPath = `M ${x1} ${y1} C ${outwardX} ${y1}, ${x2} ${y1 + (y2 - y1) * 0.4}, ${x2} ${y2}`; } else { const cy1 = y1 + (y2 - y1) * 0.4; const cy2 = y1 + (y2 - y1) * 0.6; tempPath = `M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}`; } wireLayer.innerHTML = ``; } // ─── Node position update (direct DOM, no re-render) ── private updateNodePosition(n: FlowNode) { const nodeLayer = this.shadow.getElementById("node-layer"); if (!nodeLayer) return; const g = nodeLayer.querySelector(`[data-node-id="${n.id}"]`) as SVGGElement | null; if (g) g.setAttribute("transform", `translate(${n.position.x},${n.position.y})`); } // ─── Allocation adjustment ──────────────────────────── private handleAdjustAllocation(fromId: string, toId: string, allocType: string, delta: number) { const node = this.nodes.find((n) => n.id === fromId); if (!node) return; let allocs: { targetId: string; percentage: number; color: string }[]; if (allocType === "source") { allocs = (node.data as SourceNodeData).targetAllocations; } else if (allocType === "overflow") { if (node.type === "outcome") { allocs = (node.data as OutcomeNodeData).overflowAllocations || []; } else { allocs = (node.data as FunnelNodeData).overflowAllocations; } } else { allocs = (node.data as FunnelNodeData).spendingAllocations; } const idx = allocs.findIndex((a) => a.targetId === toId); if (idx < 0) return; const newPct = Math.max(1, Math.min(99, allocs[idx].percentage + delta)); const oldPct = allocs[idx].percentage; const diff = newPct - oldPct; allocs[idx].percentage = newPct; // Proportionally rebalance siblings const siblings = allocs.filter((_, i) => i !== idx); const sibTotal = siblings.reduce((s, a) => s + a.percentage, 0); if (sibTotal > 0) { for (const sib of siblings) { sib.percentage = Math.max(1, Math.round(sib.percentage - diff * (sib.percentage / sibTotal))); } } // Normalize to exactly 100 const total = allocs.reduce((s, a) => s + a.percentage, 0); if (total !== 100 && allocs.length > 1) { const last = allocs.find((_, i) => i !== idx) || allocs[allocs.length - 1]; last.percentage += 100 - total; last.percentage = Math.max(1, last.percentage); } this.redrawEdges(); this.refreshEditorIfOpen(fromId); } // ─── Editor panel ───────────────────────────────────── private openEditor(nodeId: string) { this.editingNodeId = nodeId; const panel = this.shadow.getElementById("editor-panel"); if (!panel) return; const node = this.nodes.find((n) => n.id === nodeId); if (!node) return; let content = `
${this.esc((node.data as any).label || node.type)}
`; if (node.type === "source") content += this.renderSourceEditor(node); else if (node.type === "funnel") content += this.renderFunnelEditor(node); else content += this.renderOutcomeEditor(node); content += `
`; panel.innerHTML = content; panel.classList.add("open"); this.attachEditorListeners(panel, node); } private closeEditor() { this.editingNodeId = null; const panel = this.shadow.getElementById("editor-panel"); if (panel) { panel.classList.remove("open"); panel.innerHTML = ""; } } // ─── Inline edit mode ───────────────────────────────── private enterInlineEdit(nodeId: string) { // Exit any previous inline edit if (this.inlineEditNodeId && this.inlineEditNodeId !== nodeId) { this.exitInlineEdit(); } this.inlineEditNodeId = nodeId; this.selectedNodeId = nodeId; this.updateSelectionHighlight(); const node = this.nodes.find((n) => n.id === nodeId); if (!node) return; // Overlay the inline edit SVG elements on the node const nodeLayer = this.shadow.getElementById("node-layer"); const g = nodeLayer?.querySelector(`[data-node-id="${nodeId}"]`) as SVGGElement | null; if (!g) return; // Remove any existing inline edit overlay g.querySelector(".inline-edit-overlay")?.remove(); const s = this.getNodeSize(node); const overlay = document.createElementNS("http://www.w3.org/2000/svg", "g"); overlay.classList.add("inline-edit-overlay"); if (node.type === "funnel") { this.renderFunnelInlineEdit(overlay, node, s); } else if (node.type === "source") { this.renderSourceInlineEdit(overlay, node, s); } else { this.renderOutcomeInlineEdit(overlay, node, s); } // Toolbar: Done | Delete | ... const toolbarY = s.h + 8; overlay.innerHTML += `
`; g.appendChild(overlay); this.attachInlineEditListeners(g, node); } private renderFunnelInlineEdit(overlay: SVGGElement, node: FlowNode, s: { w: number; h: number }) { const d = node.data as FunnelNodeData; const lipH = Math.round(s.h * 0.08); const lipNotch = 14; const zoneTop = lipH + lipNotch + 4; const zoneBot = s.h - 4; const zoneH = zoneBot - zoneTop; // Label edit overlay.innerHTML = ` `; // Threshold markers const thresholds: Array<{ key: string; value: number; color: string; label: string }> = [ { key: "minThreshold", value: d.minThreshold, color: "#ef4444", label: "Min" }, { key: "maxThreshold", value: d.maxThreshold, color: "#f59e0b", label: "Max" }, ]; if (d.sufficientThreshold !== undefined) { thresholds.push({ key: "sufficientThreshold", value: d.sufficientThreshold, color: "#10b981", label: "Suf" }); } for (const t of thresholds) { const frac = t.value / (d.maxCapacity || 1); const markerY = zoneTop + zoneH * (1 - frac); overlay.innerHTML += ` ${t.label} ${this.formatDollar(t.value)}`; } } private renderSourceInlineEdit(overlay: SVGGElement, node: FlowNode, s: { w: number; h: number }) { const d = node.data as SourceNodeData; overlay.innerHTML = ` `; } private renderOutcomeInlineEdit(overlay: SVGGElement, node: FlowNode, s: { w: number; h: number }) { const d = node.data as OutcomeNodeData; const statusColors: Record = { "not-started": "#64748b", "in-progress": "#3b82f6", "completed": "#10b981", "blocked": "#ef4444" }; const statusList = ["not-started", "in-progress", "completed", "blocked"] as const; const nextStatus = statusList[(statusList.indexOf(d.status) + 1) % statusList.length]; overlay.innerHTML = ` ${d.status}`; } private attachInlineEditListeners(g: SVGGElement, node: FlowNode) { const overlay = g.querySelector(".inline-edit-overlay"); if (!overlay) return; // Input fields overlay.querySelectorAll("input[data-inline-field]").forEach((el) => { const input = el as HTMLInputElement; const field = input.dataset.inlineField!; input.addEventListener("input", () => { const val = input.type === "number" ? parseFloat(input.value) || 0 : input.value; (node.data as any)[field] = val; // Re-render the node (but not the overlay) this.redrawNodeOnly(node); this.redrawEdges(); }); input.addEventListener("keydown", (e: Event) => { const ke = e as KeyboardEvent; if (ke.key === "Enter") this.exitInlineEdit(); if (ke.key === "Escape") this.exitInlineEdit(); ke.stopPropagation(); }); }); // Threshold drag handles overlay.querySelectorAll(".threshold-handle").forEach((el) => { el.addEventListener("pointerdown", (e: Event) => { const pe = e as PointerEvent; pe.stopPropagation(); pe.preventDefault(); const thresholdKey = (el as SVGElement).dataset.threshold!; this.inlineEditDragThreshold = thresholdKey; this.inlineEditDragStartY = pe.clientY; this.inlineEditDragStartValue = (node.data as any)[thresholdKey] || 0; (el as Element).setPointerCapture(pe.pointerId); }); el.addEventListener("pointermove", (e: Event) => { if (!this.inlineEditDragThreshold) return; const pe = e as PointerEvent; const d = node.data as FunnelNodeData; const s = this.getNodeSize(node); const lipH = Math.round(s.h * 0.08); const lipNotch = 14; const zoneH = s.h - 4 - (lipH + lipNotch + 4); // Pixels to dollar conversion const deltaY = (pe.clientY - this.inlineEditDragStartY) / this.canvasZoom; const deltaDollars = -(deltaY / zoneH) * (d.maxCapacity || 1); let newVal = this.inlineEditDragStartValue + deltaDollars; // Constrain: 0 ≤ min ≤ sufficient ≤ max ≤ capacity newVal = Math.max(0, Math.min(d.maxCapacity, newVal)); const key = this.inlineEditDragThreshold; if (key === "minThreshold") newVal = Math.min(newVal, d.maxThreshold); if (key === "maxThreshold") newVal = Math.max(newVal, d.minThreshold); if (key === "sufficientThreshold") newVal = Math.max(d.minThreshold, Math.min(d.maxThreshold, newVal)); (node.data as any)[key] = Math.round(newVal); // Update display this.redrawNodeInlineEdit(node); }); el.addEventListener("pointerup", () => { this.inlineEditDragThreshold = null; }); }); // Done button overlay.querySelector(".iet-done")?.addEventListener("click", (e: Event) => { e.stopPropagation(); this.exitInlineEdit(); }); // Delete button overlay.querySelector(".iet-delete")?.addEventListener("click", (e: Event) => { e.stopPropagation(); this.deleteNode(node.id); this.exitInlineEdit(); }); // "..." panel fallback button overlay.querySelector(".iet-panel")?.addEventListener("click", (e: Event) => { e.stopPropagation(); this.exitInlineEdit(); this.openEditor(node.id); }); // Status badge cycling (outcome) overlay.querySelector("[data-inline-action='cycle-status']")?.addEventListener("click", (e: Event) => { e.stopPropagation(); const d = node.data as OutcomeNodeData; const statusList = ["not-started", "in-progress", "completed", "blocked"] as const; d.status = statusList[(statusList.indexOf(d.status) + 1) % statusList.length]; this.redrawNodeInlineEdit(node); }); // Click-outside handler to exit inline edit const clickOutsideHandler = (e: PointerEvent) => { const target = e.target as Element; if (!target.closest(`[data-node-id="${node.id}"]`)) { this.exitInlineEdit(); document.removeEventListener("pointerdown", clickOutsideHandler as EventListener, true); } }; setTimeout(() => { document.addEventListener("pointerdown", clickOutsideHandler as EventListener, true); }, 100); } private redrawNodeOnly(node: FlowNode) { 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 satisfaction = this.computeInflowSatisfaction(); const newSvg = this.renderNodeSvg(node, satisfaction); // Parse and replace, preserving inline edit overlay const overlay = g.querySelector(".inline-edit-overlay"); const temp = document.createElementNS("http://www.w3.org/2000/svg", "g"); temp.innerHTML = newSvg; const newG = temp.firstElementChild as SVGGElement; if (newG && overlay) { newG.appendChild(overlay); } if (newG) { g.replaceWith(newG); } } private redrawNodeInlineEdit(node: FlowNode) { // Re-render the whole node + re-enter inline edit this.drawCanvasContent(); const s = this.getNodeSize(node); const nodeLayer = this.shadow.getElementById("node-layer"); const g = nodeLayer?.querySelector(`[data-node-id="${node.id}"]`) as SVGGElement | null; if (!g) return; g.querySelector(".inline-edit-overlay")?.remove(); const overlay = document.createElementNS("http://www.w3.org/2000/svg", "g"); overlay.classList.add("inline-edit-overlay"); if (node.type === "funnel") { this.renderFunnelInlineEdit(overlay, node, s); } else if (node.type === "source") { this.renderSourceInlineEdit(overlay, node, s); } else { this.renderOutcomeInlineEdit(overlay, node, s); } // Toolbar const toolbarY = s.h + 8; overlay.innerHTML += `
`; g.appendChild(overlay); this.attachInlineEditListeners(g, node); } private exitInlineEdit() { if (!this.inlineEditNodeId) return; const nodeLayer = this.shadow.getElementById("node-layer"); const g = nodeLayer?.querySelector(`[data-node-id="${this.inlineEditNodeId}"]`) as SVGGElement | null; if (g) g.querySelector(".inline-edit-overlay")?.remove(); this.inlineEditNodeId = null; this.inlineEditDragThreshold = null; // Re-render to apply any changes this.drawCanvasContent(); } private refreshEditorIfOpen(nodeId: string) { if (this.editingNodeId === nodeId) this.openEditor(nodeId); } private renderSourceEditor(n: FlowNode): string { const d = n.data as SourceNodeData; let html = `
`; if (d.sourceType === "card") { html += `
`; } html += this.renderAllocEditor("Target Allocations", d.targetAllocations); return html; } private renderFunnelEditor(n: FlowNode): string { const d = n.data as FunnelNodeData; return `
${this.renderAllocEditor("Overflow Allocations", d.overflowAllocations)} ${this.renderAllocEditor("Spending Allocations", d.spendingAllocations)}`; } private renderOutcomeEditor(n: FlowNode): string { const d = n.data as OutcomeNodeData; let html = `
`; if (d.phases && d.phases.length > 0) { html += `
Phases
`; for (const p of d.phases) { const unlocked = d.fundingReceived >= p.fundingThreshold; html += `
${this.esc(p.name)} — $${p.fundingThreshold.toLocaleString()}
${p.tasks.map((t) => `
${t.completed ? "✅" : "⬜"} ${this.esc(t.label)}
`).join("")}
`; } html += `
`; } if (d.overflowAllocations && d.overflowAllocations.length > 0) { html += this.renderAllocEditor("Overflow Allocations", d.overflowAllocations); } return html; } private renderAllocEditor(title: string, allocs: { targetId: string; percentage: number; color: string }[]): string { if (!allocs || allocs.length === 0) return ""; let html = `
${title}
`; for (const a of allocs) { html += `
${this.esc(this.getNodeLabel(a.targetId))} ${a.percentage}%
`; } html += `
`; return html; } private async openTransakWidget(flowId: string, walletAddress: string) { // Fetch Transak config from server let apiKey = "STAGING_KEY"; let env = "STAGING"; try { const base = this.space ? `/s/${this.space}/rflows` : "/rflows"; const res = await fetch(`${base}/api/transak/config`); if (res.ok) { const cfg = await res.json(); apiKey = cfg.apiKey || apiKey; env = cfg.environment || env; } } catch { /* use defaults */ } const baseUrl = env === "PRODUCTION" ? "https://global.transak.com" : "https://global-stg.transak.com"; const params = new URLSearchParams({ apiKey, environment: env, cryptoCurrencyCode: "USDC", network: "base", defaultCryptoCurrency: "USDC", walletAddress, partnerOrderId: flowId, themeColor: "6366f1", hideMenu: "true", }); const modal = document.createElement("div"); modal.id = "transak-modal"; modal.style.cssText = `position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;`; modal.innerHTML = `
`; document.body.appendChild(modal); modal.querySelector("#transak-close")!.addEventListener("click", () => modal.remove()); modal.addEventListener("click", (e) => { if (e.target === modal) modal.remove(); }); const handler = (e: MessageEvent) => { if (e.data?.event_id === "TRANSAK_ORDER_SUCCESSFUL") { console.log("[Transak] Order successful:", e.data.data); modal.remove(); window.removeEventListener("message", handler); } }; window.addEventListener("message", handler); } private attachEditorListeners(panel: HTMLElement, node: FlowNode) { // Close button panel.querySelector('[data-editor-action="close"]')?.addEventListener("click", () => this.closeEditor()); // Delete button panel.querySelector('[data-editor-action="delete"]')?.addEventListener("click", () => { this.deleteNode(node.id); this.closeEditor(); }); // Input changes — live update const inputs = panel.querySelectorAll(".editor-input, .editor-select"); inputs.forEach((input) => { input.addEventListener("change", () => { const field = (input as HTMLElement).dataset.field; if (!field) return; const val = (input as HTMLInputElement).value; const numFields = ["flowRate", "currentValue", "minThreshold", "maxThreshold", "maxCapacity", "inflowRate", "sufficientThreshold", "fundingReceived", "fundingTarget"]; (node.data as any)[field] = numFields.includes(field) ? parseFloat(val) || 0 : val; this.drawCanvasContent(); this.updateSufficiencyBadge(); }); }); // Fund with Card button (source nodes with sourceType "card") panel.querySelector('[data-action="fund-with-card"]')?.addEventListener("click", () => { const flowId = this.flowId || this.getAttribute("flow-id") || ""; const sourceData = node.data as SourceNodeData; const walletAddress = sourceData.walletAddress || ""; if (!walletAddress) { alert("Configure a wallet address first (use rIdentity passkey or enter manually)"); return; } this.openTransakWidget(flowId, walletAddress); }); } // ─── Node hover tooltip ────────────────────────────── private showNodeTooltip(nodeId: string, e: MouseEvent) { const tooltip = this.shadow.getElementById("node-tooltip"); const container = this.shadow.getElementById("canvas-container"); if (!tooltip || !container) return; const node = this.nodes.find((n) => n.id === nodeId); if (!node) return; let html = `
${this.esc((node.data as any).label)}
`; if (node.type === "source") { const d = node.data as SourceNodeData; html += `
$${d.flowRate.toLocaleString()}/mo · ${d.sourceType}
`; } else if (node.type === "funnel") { const d = node.data as FunnelNodeData; const suf = computeSufficiencyState(d); html += `
$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.sufficientThreshold ?? d.maxThreshold).toLocaleString()}
`; html += `
${suf}
`; } else { const d = node.data as OutcomeNodeData; const pct = d.fundingTarget > 0 ? Math.round((d.fundingReceived / d.fundingTarget) * 100) : 0; html += `
$${Math.floor(d.fundingReceived).toLocaleString()} / $${Math.floor(d.fundingTarget).toLocaleString()} (${pct}%)
`; html += `
${d.status}
`; } tooltip.innerHTML = html; tooltip.style.display = "block"; const rect = container.getBoundingClientRect(); tooltip.style.left = `${e.clientX - rect.left + 16}px`; tooltip.style.top = `${e.clientY - rect.top - 8}px`; } private hideNodeTooltip() { const tooltip = this.shadow.getElementById("node-tooltip"); if (tooltip) tooltip.style.display = "none"; } private highlightNodeEdges(nodeId: string) { const edgeLayer = this.shadow.getElementById("edge-layer"); if (!edgeLayer) return; edgeLayer.querySelectorAll(".edge-group").forEach((g) => { const el = g as SVGGElement; const isConnected = el.dataset.from === nodeId || el.dataset.to === nodeId; el.classList.toggle("edge-group--highlight", isConnected); }); } private unhighlightEdges() { const edgeLayer = this.shadow.getElementById("edge-layer"); if (!edgeLayer) return; edgeLayer.querySelectorAll(".edge-group--highlight").forEach((g) => { g.classList.remove("edge-group--highlight"); }); } // ─── Node detail modals ────────────────────────────── private closeModal() { const m = this.shadow.getElementById("flows-modal"); if (m) m.remove(); } private openOutcomeModal(nodeId: string) { this.closeModal(); const node = this.nodes.find((n) => n.id === nodeId); if (!node || node.type !== "outcome") return; const d = node.data as OutcomeNodeData; const fillPct = d.fundingTarget > 0 ? Math.min(100, (d.fundingReceived / d.fundingTarget) * 100) : 0; const statusColor = d.status === "completed" ? "#10b981" : d.status === "blocked" ? "#ef4444" : d.status === "in-progress" ? "#3b82f6" : "#64748b"; const statusLabel = d.status === "completed" ? "Completed" : d.status === "blocked" ? "Blocked" : d.status === "in-progress" ? "In Progress" : "Not Started"; let phasesHtml = ""; if (d.phases && d.phases.length > 0) { phasesHtml += `
`; for (const p of d.phases) { const unlocked = d.fundingReceived >= p.fundingThreshold; phasesHtml += `
`; } phasesHtml += `
`; for (let i = 0; i < d.phases.length; i++) { const p = d.phases[i]; const unlocked = d.fundingReceived >= p.fundingThreshold; const completedTasks = p.tasks.filter((t) => t.completed).length; const phasePct = p.fundingThreshold > 0 ? Math.min(100, Math.round((d.fundingReceived / p.fundingThreshold) * 100)) : 0; phasesHtml += `
${unlocked ? "🔓" : "🔒"} ${this.esc(p.name)} ${completedTasks}/${p.tasks.length} $${p.fundingThreshold.toLocaleString()}
`; } phasesHtml += ``; } const backdrop = document.createElement("div"); backdrop.className = "flows-modal-backdrop"; backdrop.id = "flows-modal"; backdrop.innerHTML = `
${statusLabel} ${this.esc(d.label)}
${d.description ? `
${this.esc(d.description)}
` : ""}
$${Math.floor(d.fundingReceived).toLocaleString()}
of $${Math.floor(d.fundingTarget).toLocaleString()} (${Math.round(fillPct)}%)
${d.phases && d.phases.length > 0 ? `
Phases
${phasesHtml}
` : ""}
`; this.shadow.appendChild(backdrop); this.attachOutcomeModalListeners(backdrop, nodeId); } private attachOutcomeModalListeners(backdrop: HTMLElement, nodeId: string) { const node = this.nodes.find((n) => n.id === nodeId); if (!node) return; const d = node.data as OutcomeNodeData; backdrop.addEventListener("click", (e) => { if (e.target === backdrop) this.closeModal(); }); backdrop.querySelector('[data-modal-action="close"]')?.addEventListener("click", () => this.closeModal()); // Phase accordion toggle backdrop.querySelectorAll(".phase-header").forEach((header) => { header.addEventListener("click", () => { const idx = (header as HTMLElement).dataset.phaseIdx; const content = backdrop.querySelector(`[data-phase-content="${idx}"]`) as HTMLElement | null; const chevron = backdrop.querySelector(`[data-phase-chevron="${idx}"]`) as HTMLElement | null; if (content) { const isOpen = content.style.display !== "none"; content.style.display = isOpen ? "none" : "block"; if (chevron) chevron.style.transform = isOpen ? "rotate(0deg)" : "rotate(90deg)"; } }); }); // Task checkbox toggle backdrop.querySelectorAll('input[type="checkbox"][data-phase]').forEach((cb) => { cb.addEventListener("change", () => { const phaseIdx = parseInt((cb as HTMLElement).dataset.phase!, 10); const taskIdx = parseInt((cb as HTMLElement).dataset.task!, 10); if (d.phases && d.phases[phaseIdx] && d.phases[phaseIdx].tasks[taskIdx]) { d.phases[phaseIdx].tasks[taskIdx].completed = (cb as HTMLInputElement).checked; const taskRow = (cb as HTMLElement).closest(".phase-task"); if (taskRow) taskRow.classList.toggle("phase-task--done", (cb as HTMLInputElement).checked); this.drawCanvasContent(); } }); }); // Add task backdrop.querySelectorAll("[data-add-task]").forEach((btn) => { btn.addEventListener("click", () => { const phaseIdx = parseInt((btn as HTMLElement).dataset.addTask!, 10); if (d.phases && d.phases[phaseIdx]) { const taskLabel = prompt("Task name:"); if (taskLabel) { d.phases[phaseIdx].tasks.push({ label: taskLabel, completed: false }); this.openOutcomeModal(nodeId); this.drawCanvasContent(); } } }); }); // Add phase backdrop.querySelector('[data-action="add-phase"]')?.addEventListener("click", () => { const name = prompt("Phase name:"); if (name) { const threshold = parseFloat(prompt("Funding threshold ($):") || "0") || 0; if (!d.phases) d.phases = []; d.phases.push({ name, fundingThreshold: threshold, tasks: [] }); this.openOutcomeModal(nodeId); this.drawCanvasContent(); } }); } private openSourceModal(nodeId: string) { this.closeModal(); const node = this.nodes.find((n) => n.id === nodeId); if (!node || node.type !== "source") return; const d = node.data as SourceNodeData; const icons: Record = { card: "\u{1F4B3}", safe_wallet: "\u{1F512}", ridentity: "\u{1F464}", unconfigured: "\u{2699}" }; const labels: Record = { card: "Card", safe_wallet: "Safe / Wallet", ridentity: "rIdentity", unconfigured: "Configure" }; let configHtml = ""; if (d.sourceType === "card") { configHtml = `
`; } else if (d.sourceType === "safe_wallet") { configHtml = `
`; } else if (d.sourceType === "ridentity") { configHtml = `
👤
${isAuthenticated() ? "Connected" : "Not connected"}
${!isAuthenticated() ? `` : `
✅ ${this.esc(getUsername() || "Connected")}
`}
`; } const backdrop = document.createElement("div"); backdrop.className = "flows-modal-backdrop"; backdrop.id = "flows-modal"; backdrop.innerHTML = `
${icons[d.sourceType] || "💰"} ${this.esc(d.label)}
Source Type
${["card", "safe_wallet", "ridentity"].map((t) => ` `).join("")}
${configHtml}
`; this.shadow.appendChild(backdrop); this.attachSourceModalListeners(backdrop, nodeId); } private attachSourceModalListeners(backdrop: HTMLElement, nodeId: string) { const node = this.nodes.find((n) => n.id === nodeId); if (!node) return; const d = node.data as SourceNodeData; backdrop.addEventListener("click", (e) => { if (e.target === backdrop) this.closeModal(); }); backdrop.querySelectorAll('[data-modal-action="close"]').forEach((btn) => { btn.addEventListener("click", () => this.closeModal()); }); // Source type picker backdrop.querySelectorAll("[data-source-type]").forEach((btn) => { btn.addEventListener("click", () => { d.sourceType = (btn as HTMLElement).dataset.sourceType as SourceNodeData["sourceType"]; this.openSourceModal(nodeId); this.drawCanvasContent(); }); }); // Field changes (live) backdrop.querySelectorAll(".editor-input[data-modal-field], .editor-select[data-modal-field]").forEach((input) => { input.addEventListener("change", () => { const field = (input as HTMLElement).dataset.modalField!; const val = (input as HTMLInputElement).value; const numFields = ["flowRate", "chainId"]; (d as any)[field] = numFields.includes(field) ? parseFloat(val) || 0 : val; this.drawCanvasContent(); }); }); // Save backdrop.querySelector('[data-modal-action="save"]')?.addEventListener("click", () => { backdrop.querySelectorAll(".editor-input[data-modal-field], .editor-select[data-modal-field]").forEach((input) => { const field = (input as HTMLElement).dataset.modalField!; const val = (input as HTMLInputElement).value; const numFields = ["flowRate", "chainId"]; (d as any)[field] = numFields.includes(field) ? parseFloat(val) || 0 : val; }); this.drawCanvasContent(); this.closeModal(); }); // Fund with card backdrop.querySelector('[data-action="fund-with-card"]')?.addEventListener("click", () => { const flowId = this.flowId || this.getAttribute("flow-id") || ""; if (!d.walletAddress) { alert("Configure a wallet address first (use rIdentity passkey or enter manually)"); return; } this.openTransakWidget(flowId, d.walletAddress); }); // Connect with EncryptID backdrop.querySelector('[data-action="connect-ridentity"]')?.addEventListener("click", () => { window.location.href = "/auth/login?redirect=" + encodeURIComponent(window.location.pathname); }); } // ─── Node CRUD ──────────────────────────────────────── private addNode(type: "source" | "funnel" | "outcome") { // Place at center of current viewport const svg = this.shadow.getElementById("flow-canvas") as SVGSVGElement | null; const rect = svg?.getBoundingClientRect(); const cx = rect ? (rect.width / 2 - this.canvasPanX) / this.canvasZoom : 400; const cy = rect ? (rect.height / 2 - this.canvasPanY) / this.canvasZoom : 300; const id = `${type}-${Date.now().toString(36)}`; let data: any; if (type === "source") { data = { label: "New Source", flowRate: 1000, sourceType: "card", targetAllocations: [] } as SourceNodeData; } else if (type === "funnel") { data = { label: "New Funnel", currentValue: 0, minThreshold: 5000, maxThreshold: 20000, maxCapacity: 30000, inflowRate: 500, sufficientThreshold: 15000, dynamicOverflow: false, overflowAllocations: [], spendingAllocations: [], } as FunnelNodeData; } else { data = { label: "New Outcome", description: "", fundingReceived: 0, fundingTarget: 10000, status: "not-started", overflowAllocations: [] } as OutcomeNodeData; } this.nodes.push({ id, type, position: { x: cx - 100, y: cy - 50 }, data }); this.drawCanvasContent(); this.selectedNodeId = id; this.updateSelectionHighlight(); this.openEditor(id); } private deleteNode(nodeId: string) { this.nodes = this.nodes.filter((n) => n.id !== nodeId); // Clean up allocations pointing to deleted node for (const n of this.nodes) { if (n.type === "source") { const d = n.data as SourceNodeData; d.targetAllocations = d.targetAllocations.filter((a) => a.targetId !== nodeId); } if (n.type === "funnel") { const d = n.data as FunnelNodeData; d.overflowAllocations = d.overflowAllocations.filter((a) => a.targetId !== nodeId); d.spendingAllocations = d.spendingAllocations.filter((a) => a.targetId !== nodeId); } if (n.type === "outcome") { const d = n.data as OutcomeNodeData; if (d.overflowAllocations) d.overflowAllocations = d.overflowAllocations.filter((a) => a.targetId !== nodeId); } } if (this.selectedNodeId === nodeId) this.selectedNodeId = null; this.drawCanvasContent(); this.updateSufficiencyBadge(); } // ─── Simulation ─────────────────────────────────────── private toggleSimulation() { this.isSimulating = !this.isSimulating; const btn = this.shadow.getElementById("sim-btn"); if (btn) btn.textContent = this.isSimulating ? "Pause" : "Play"; if (this.isSimulating) { this.simInterval = setInterval(() => { this.nodes = simulateTick(this.nodes); this.updateCanvasLive(); }, 100); } else { if (this.simInterval) { clearInterval(this.simInterval); this.simInterval = null; } } } /** Update canvas nodes and edges without full innerHTML rebuild during simulation */ private updateCanvasLive() { const nodeLayer = this.shadow.getElementById("node-layer"); if (!nodeLayer) return; // Rebuild node SVG content (can't do partial DOM updates easily for SVG text) nodeLayer.innerHTML = this.renderAllNodes(); this.redrawEdges(); this.updateSufficiencyBadge(); } private updateSufficiencyBadge() { const score = computeSystemSufficiency(this.nodes); const scorePct = Math.round(score * 100); const scoreColor = scorePct >= 90 ? "#fbbf24" : scorePct >= 60 ? "#10b981" : scorePct >= 30 ? "#f59e0b" : "#ef4444"; const badge = this.shadow.getElementById("badge-score"); if (badge) { badge.textContent = `${scorePct}%`; badge.style.color = scoreColor; } } // ─── URL sharing ────────────────────────────────────── private shareState() { try { const LZString = (window as any).LZString; if (!LZString) { // Fallback: copy JSON directly const json = JSON.stringify(this.nodes); navigator.clipboard.writeText(window.location.href.split("#")[0] + "#flow=" + btoa(json)); return; } const json = JSON.stringify(this.nodes); const compressed = LZString.compressToEncodedURIComponent(json); const url = window.location.href.split("#")[0] + "#flow=" + compressed; history.replaceState(null, "", url); navigator.clipboard.writeText(url); } catch { // Silent fail } } private loadFromHash() { try { const hash = window.location.hash; if (!hash.startsWith("#flow=")) return; const payload = hash.slice(6); let json: string; const LZString = (window as any).LZString; if (LZString) { json = LZString.decompressFromEncodedURIComponent(payload) || ""; } else { json = atob(payload); } if (!json) return; const nodes = JSON.parse(json) as FlowNode[]; if (Array.isArray(nodes) && nodes.length > 0) { this.nodes = nodes; this.drawCanvasContent(); this.fitView(); } } catch { // Invalid hash data — ignore } } // ─── River tab ──────────────────────────────────────── private renderRiverTab(): string { return `
`; } private mountRiver() { const mount = this.shadow.getElementById("river-mount"); if (!mount) return; // Check if already mounted if (mount.querySelector("folk-flow-river")) return; const river = document.createElement("folk-flow-river") as any; river.setAttribute("simulate", "true"); mount.appendChild(river); // Pass nodes after the element is connected requestAnimationFrame(() => { if (typeof river.setNodes === "function") { river.setNodes(this.nodes.map((n) => ({ ...n, data: { ...n.data } }))); } }); } // ─── Transactions tab ───────────────────────────────── private renderTransactionsTab(): string { if (this.isDemo) { return `

Transaction history is not available in demo mode.

`; } if (!this.txLoaded) { return '
Loading transactions...
'; } if (this.transactions.length === 0) { return `

No transactions yet for this flow.

`; } return `
${this.transactions.map((tx) => `
${tx.type === "deposit" ? "⬆" : tx.type === "withdraw" ? "⬇" : "🔄"}
${this.esc(tx.description || tx.type)}
${tx.from ? `From: ${this.esc(tx.from)}` : ""} ${tx.to ? ` → ${this.esc(tx.to)}` : ""}
${tx.type === "deposit" ? "+" : "-"}$${Math.abs(tx.amount).toLocaleString()}
${this.formatTime(tx.timestamp)}
`).join("")}
`; } private formatTime(ts: string): string { try { const d = new Date(ts); const now = new Date(); const diffMs = now.getTime() - d.getTime(); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); if (diffDays === 0) return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); if (diffDays === 1) return "Yesterday"; if (diffDays < 7) return `${diffDays}d ago`; return d.toLocaleDateString(); } catch { return ts; } } // ─── Event listeners ────────────────────────────────── private attachListeners() { // Tab switching this.shadow.querySelectorAll("[data-tab]").forEach((el) => { el.addEventListener("click", () => { const newTab = (el as HTMLElement).dataset.tab as Tab; if (newTab === this.tab) return; // Cleanup old canvas state if (this.tab === "diagram") this.cleanupCanvas(); this.tab = newTab; this.render(); if (newTab === "transactions" && !this.txLoaded) { this.loadTransactions(); } }); }); // Mount river component when river tab is active if (this.tab === "river" && this.nodes.length > 0) { this.mountRiver(); } // Initialize interactive canvas when diagram tab is active if (this.tab === "diagram" && this.nodes.length > 0) { this.initCanvas(); } // Create flow button (landing page, auth-gated) const createBtn = this.shadow.querySelector('[data-action="create-flow"]'); createBtn?.addEventListener("click", () => this.handleCreateFlow()); } private cleanupCanvas() { if (this.simInterval) { clearInterval(this.simInterval); this.simInterval = null; } this.isSimulating = false; if (this.wiringActive) this.cancelWiring(); if (this._boundKeyDown) { document.removeEventListener("keydown", this._boundKeyDown); this._boundKeyDown = null; } } private async handleCreateFlow() { const token = getAccessToken(); if (!token) return; const name = prompt("Flow name:"); if (!name) return; this.loading = true; this.render(); try { const base = this.getApiBase(); const res = await fetch(`${base}/api/flows`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ name }), }); if (res.ok) { const data = await res.json(); const flowId = data.id || data.flowId; // Associate with current space if (flowId && this.space) { await fetch(`${base}/api/space-flows`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ space: this.space, flowId }), }); } // Navigate to the new flow if (flowId) { const detailUrl = this.getApiBase() ? `${this.getApiBase()}/flow/${encodeURIComponent(flowId)}` : `/rflows/flow/${encodeURIComponent(flowId)}`; window.location.href = detailUrl; return; } } else { const err = await res.json().catch(() => ({})); this.error = (err as any).error || `Failed to create flow (${res.status})`; } } catch { this.error = "Failed to create flow"; } this.loading = false; this.render(); } private esc(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } } customElements.define("folk-flows-app", FolkFlowsApp);