/** * — 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, OverflowAllocation, SpendingAllocation, SourceAllocation } from "../lib/types"; import { PORT_DEFS, deriveThresholds } 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"; import { flowsSchema, flowsDocId, type FlowsDoc, type CanvasFlow } from "../schemas"; import type { DocumentId } from "../../../shared/local-first/document"; import { FlowsLocalFirstClient } from "../local-first-client"; 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"; interface NodeAnalyticsStats { totalInflow: number; totalOutflow: number; totalOverflow: number; avgFillLevel: number; peakValue: number; outcomesAchieved: number; tickCount: number; fillLevelSum: number; // running sum for average } // ─── 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 flowId = ""; private analyticsOpen = false; private analyticsTab: "overview" | "transactions" = "overview"; private isDemo = false; private flows: FlowSummary[] = []; private nodes: FlowNode[] = []; private flowName = ""; private transactions: Transaction[] = []; private txLoaded = false; private loading = false; private error = ""; private _offlineUnsub: (() => void) | null = null; // 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 simSpeedMs = 100; private simTickCount = 0; private canvasInitialized = false; // Edge selection & drag state private selectedEdgeKey: string | null = null; // "fromId::toId::edgeType" private draggingEdgeKey: string | null = null; private edgeDragPointerId: number | null = null; // Inline config panel state private inlineEditNodeId: string | null = null; private inlineConfigTab: "config" | "analytics" | "allocations" = "config"; private inlineEditDragThreshold: string | null = null; private inlineEditDragStartY = 0; private inlineEditDragStartValue = 0; private nodeAnalytics: Map = new Map(); // 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; // Flow storage & switching private localFirstClient: FlowsLocalFirstClient | null = null; private currentFlowId = ""; private saveTimer: ReturnType | null = null; private flowDropdownOpen = false; private flowManagerOpen = false; private _lfcUnsub: (() => 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"; // Canvas-first: always open in detail (canvas) view this.view = "detail"; if (this.isDemo) { // Demo/anon: load from localStorage or demoNodes this.loadDemoOrLocalFlow(); } else if (this.flowId) { // Direct link to a specific API flow this.loadFlow(this.flowId); } else { // Authenticated: init local-first client and load active flow this.initLocalFirstClient(); } } private loadDemoOrLocalFlow() { const activeId = localStorage.getItem('rflows:local:active') || ''; if (activeId) { const raw = localStorage.getItem(`rflows:local:${activeId}`); if (raw) { try { const flow = JSON.parse(raw) as CanvasFlow; this.currentFlowId = flow.id; this.flowName = flow.name; this.nodes = flow.nodes.map((n: FlowNode) => ({ ...n, data: { ...n.data } })); this.restoreViewport(flow.id); this.render(); return; } catch { /* fall through to demoNodes */ } } } // Fallback: demoNodes this.currentFlowId = 'demo'; this.flowName = "TBFF Demo Flow"; this.nodes = demoNodes.map((n) => ({ ...n, data: { ...n.data } })); localStorage.setItem('rflows:local:active', 'demo'); this.render(); } private async initLocalFirstClient() { this.loading = true; this.render(); try { this.localFirstClient = new FlowsLocalFirstClient(this.space); await this.localFirstClient.init(); await this.localFirstClient.subscribe(); // Listen for remote changes this._lfcUnsub = this.localFirstClient.onChange((doc) => { if (!this.currentFlowId) return; const flow = doc.canvasFlows?.[this.currentFlowId]; if (flow && !this.saveTimer) { // Only update if we're not in the middle of saving this.nodes = flow.nodes.map((n: any) => ({ ...n, data: { ...n.data } })); this.drawCanvasContent(); } }); // Load active flow or first available or demoNodes const activeId = this.localFirstClient.getActiveFlowId(); const flows = this.localFirstClient.listCanvasFlows(); if (activeId && this.localFirstClient.getCanvasFlow(activeId)) { this.loadCanvasFlow(activeId); } else if (flows.length > 0) { this.loadCanvasFlow(flows[0].id); } else { // No flows yet — create one from demoNodes const newId = crypto.randomUUID(); const now = Date.now(); const username = getUsername(); const newFlow: CanvasFlow = { id: newId, name: 'My First Flow', nodes: demoNodes.map((n) => ({ ...n, data: { ...n.data } })), createdAt: now, updatedAt: now, createdBy: username ? `did:encryptid:${username}` : null, }; this.localFirstClient.saveCanvasFlow(newFlow); this.localFirstClient.setActiveFlow(newId); this.loadCanvasFlow(newId); } } catch { // Offline or error — fall back to demoNodes console.warn('[FlowsApp] Local-first init failed, using demo nodes'); this.loadDemoOrLocalFlow(); } this.loading = false; } private loadCanvasFlow(flowId: string) { const flow = this.localFirstClient?.getCanvasFlow(flowId); if (!flow) return; this.currentFlowId = flow.id; this.flowName = flow.name; this.nodes = flow.nodes.map((n: any) => ({ ...n, data: { ...n.data } })); this.localFirstClient?.setActiveFlow(flowId); this.restoreViewport(flowId); this.loading = false; this.canvasInitialized = false; // force re-fit on switch this.render(); } disconnectedCallback() { this._offlineUnsub?.(); this._offlineUnsub = null; this._lfcUnsub?.(); this._lfcUnsub = null; if (this.saveTimer) { clearTimeout(this.saveTimer); this.saveTimer = null; } this.localFirstClient?.disconnect(); } // ─── Auto-save (debounced) ────────────────────────────── private scheduleSave() { if (this.saveTimer) clearTimeout(this.saveTimer); this.saveTimer = setTimeout(() => { this.executeSave(); this.saveTimer = null; }, 1500); } private executeSave() { if (this.localFirstClient && this.currentFlowId) { this.localFirstClient.updateFlowNodes(this.currentFlowId, this.nodes); } else if (this.currentFlowId) { // Anon/demo: save to localStorage const flow: CanvasFlow = { id: this.currentFlowId, name: this.flowName, nodes: this.nodes, createdAt: Date.now(), updatedAt: Date.now(), createdBy: null, }; localStorage.setItem(`rflows:local:${this.currentFlowId}`, JSON.stringify(flow)); // Maintain local flow list const listRaw = localStorage.getItem('rflows:local:list'); const list: string[] = listRaw ? JSON.parse(listRaw) : []; if (!list.includes(this.currentFlowId)) { list.push(this.currentFlowId); localStorage.setItem('rflows:local:list', JSON.stringify(list)); } } } // ─── Viewport persistence ─────────────────────────────── private saveViewport() { if (!this.currentFlowId) return; localStorage.setItem(`rflows:viewport:${this.currentFlowId}`, JSON.stringify({ zoom: this.canvasZoom, panX: this.canvasPanX, panY: this.canvasPanY, })); } private restoreViewport(flowId: string) { const raw = localStorage.getItem(`rflows:viewport:${flowId}`); if (raw) { try { const { zoom, panX, panY } = JSON.parse(raw); this.canvasZoom = zoom; this.canvasPanX = panX; this.canvasPanY = panY; } catch { /* ignore corrupt data */ } } } // ─── Flow switching ───────────────────────────────────── private switchToFlow(flowId: string) { // Save current flow if dirty if (this.saveTimer) { clearTimeout(this.saveTimer); this.saveTimer = null; this.executeSave(); } this.saveViewport(); // Stop simulation if running if (this.isSimulating) this.toggleSimulation(); // Exit inline edit if (this.inlineEditNodeId) this.exitInlineEdit(); if (this.localFirstClient) { this.loadCanvasFlow(flowId); } else { // Local/demo mode const raw = localStorage.getItem(`rflows:local:${flowId}`); if (raw) { try { const flow = JSON.parse(raw) as CanvasFlow; this.currentFlowId = flow.id; this.flowName = flow.name; this.nodes = flow.nodes.map((n: FlowNode) => ({ ...n, data: { ...n.data } })); } catch { return; } } else if (flowId === 'demo') { this.currentFlowId = 'demo'; this.flowName = 'TBFF Demo Flow'; this.nodes = demoNodes.map((n) => ({ ...n, data: { ...n.data } })); } else { return; } localStorage.setItem('rflows:local:active', flowId); this.restoreViewport(flowId); this.canvasInitialized = false; this.render(); } } private createNewFlow() { const id = crypto.randomUUID(); const now = Date.now(); const newFlow: CanvasFlow = { id, name: 'Untitled Flow', nodes: [{ id: `source-${Date.now().toString(36)}`, type: 'source' as const, position: { x: 400, y: 200 }, data: { label: 'New Source', flowRate: 1000, sourceType: 'card', targetAllocations: [] }, }], createdAt: now, updatedAt: now, createdBy: getUsername() ? `did:encryptid:${getUsername()}` : null, }; if (this.localFirstClient) { this.localFirstClient.saveCanvasFlow(newFlow); this.localFirstClient.setActiveFlow(id); this.loadCanvasFlow(id); } else { localStorage.setItem(`rflows:local:${id}`, JSON.stringify(newFlow)); const listRaw = localStorage.getItem('rflows:local:list'); const list: string[] = listRaw ? JSON.parse(listRaw) : []; list.push(id); localStorage.setItem('rflows:local:list', JSON.stringify(list)); localStorage.setItem('rflows:local:active', id); this.switchToFlow(id); } } private getFlowList(): { id: string; name: string; nodeCount: number; updatedAt: number }[] { if (this.localFirstClient) { return this.localFirstClient.listCanvasFlows().map(f => ({ id: f.id, name: f.name, nodeCount: f.nodes?.length || 0, updatedAt: f.updatedAt, })); } // Local mode const listRaw = localStorage.getItem('rflows:local:list'); const list: string[] = listRaw ? JSON.parse(listRaw) : []; // Always include demo if not already tracked if (!list.includes('demo')) list.unshift('demo'); return list.map(id => { if (id === 'demo') return { id: 'demo', name: 'TBFF Demo Flow', nodeCount: demoNodes.length, updatedAt: 0 }; const raw = localStorage.getItem(`rflows:local:${id}`); if (!raw) return null; try { const f = JSON.parse(raw) as CanvasFlow; return { id: f.id, name: f.name, nodeCount: f.nodes?.length || 0, updatedAt: f.updatedAt }; } catch { return null; } }).filter(Boolean) as any[]; } private async subscribeOffline() { const runtime = (window as any).__rspaceOfflineRuntime; if (!runtime?.isInitialized) return; try { const docId = flowsDocId(this.space) as DocumentId; const doc = await runtime.subscribe(docId, flowsSchema); // Render cached flow associations immediately this.renderFlowsFromDoc(doc); // Listen for remote changes this._offlineUnsub = runtime.onChange(docId, (updated: any) => { this.renderFlowsFromDoc(updated); }); } catch { // Offline runtime unavailable — REST fallback already running } } private renderFlowsFromDoc(doc: FlowsDoc) { if (!doc?.spaceFlows) return; const entries = Object.values(doc.spaceFlows); if (entries.length === 0 && this.flows.length > 0) return; // Don't clobber REST data with empty doc // Merge Automerge flow associations as summaries const fromDoc: FlowSummary[] = entries.map(sf => ({ id: sf.flowId, name: sf.flowId, status: 'active', })); if (fromDoc.length > 0 && this.flows.length === 0) { this.flows = fromDoc; this.render(); } } 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 { if (this.loading) { return '
Loading...
'; } return `
${this.renderDiagramTab()}
`; } // ─── 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" ? "var(--rflows-status-completed)" : data.status === "blocked" ? "var(--rflows-status-blocked)" : data.status === "in-progress" ? "var(--rflows-status-inprogress)" : "var(--rflows-status-notstarted)"; 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 backUrl = this.getApiBase() ? `${this.getApiBase()}/` : "/rflows/"; const score = computeSystemSufficiency(this.nodes); const scorePct = Math.round(score * 100); const scoreColor = scorePct >= 90 ? "var(--rflows-score-gold)" : scorePct >= 60 ? "var(--rflows-score-green)" : scorePct >= 30 ? "var(--rflows-score-amber)" : "var(--rflows-score-red)"; return `
← Flows ${this.esc(this.flowName || "Flow Detail")} ${this.isDemo ? 'Demo' : ""}
${scorePct}%
ENOUGH
${this.renderAnalyticsPanel()}
Inflow Spending Overflow Critical Sustained Thriving
${this.simSpeedMs}ms
Tick 0
${this.flowManagerOpen ? this.renderFlowManagerModal() : ''}`; } private renderFlowDropdownItems(): string { const flows = this.getFlowList(); if (flows.length === 0) return '
No flows
'; return flows.map(f => `` ).join(''); } private renderFlowManagerModal(): string { const flows = this.getFlowList(); return `

Manage Flows

${flows.length === 0 ? '
No flows yet. Create one to get started.
' : flows.map(f => `
${this.esc(f.name)}
${f.nodeCount} nodes${f.updatedAt ? ' · ' + new Date(f.updatedAt).toLocaleDateString() : ''}
`).join('')}
`; } // ─── 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})`); this.saveViewport(); } 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: 70 }; if (n.type === "funnel") { const d = n.data as FunnelNodeData; const baseW = 200, baseH = 180; const scaleRef = d.desiredOutflow || d.inflowRate; const scale = 1 + Math.log10(Math.max(1, scaleRef / 1000)) * 0.3; return { w: Math.round(baseW * scale), h: Math.round(baseH * scale) }; } return { w: 200, h: 110 }; // outcome (basin) } // ─── Canvas event wiring ────────────────────────────── private attachCanvasListeners() { const svg = this.shadow.getElementById("flow-canvas"); if (!svg) return; // Wheel: pan (default) or zoom (Ctrl/pinch) // Trackpad two-finger scroll → pan; trackpad pinch / Ctrl+scroll → zoom svg.addEventListener("wheel", (e: WheelEvent) => { e.preventDefault(); if (e.ctrlKey || e.metaKey) { // Zoom — ctrlKey is set by trackpad pinch gestures and Ctrl+scroll const zoomFactor = 1 - e.deltaY * 0.003; 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 * zoomFactor)); // Zoom toward pointer this.canvasPanX = mx - (mx - this.canvasPanX) * (newZoom / this.canvasZoom); this.canvasPanY = my - (my - this.canvasPanY) * (newZoom / this.canvasZoom); this.canvasZoom = newZoom; } else { // Pan — two-finger trackpad scroll or mouse wheel this.canvasPanX -= e.deltaX; this.canvasPanY -= e.deltaY; } 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 and edge if (!target.closest(".flow-node") && !target.closest(".edge-group")) { this.selectedNodeId = null; this.selectedEdgeKey = 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; } // Edge drag — convert pointer to canvas coords and update waypoint if (this.draggingEdgeKey) { const rect = svg.getBoundingClientRect(); const canvasX = (e.clientX - rect.left - this.canvasPanX) / this.canvasZoom; const canvasY = (e.clientY - rect.top - this.canvasPanY) / this.canvasZoom; this.setEdgeWaypoint(this.draggingEdgeKey, canvasX, canvasY); this.redrawEdges(); 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; } // Edge drag end if (this.draggingEdgeKey) { this.draggingEdgeKey = null; this.edgeDragPointerId = null; } 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.selectedEdgeKey = null; this.updateSelectionHighlight(); } else { this.scheduleSave(); } } }; 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.selectedEdgeKey = null; 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 === "analytics") this.toggleAnalytics(); 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(); } else if (action === "flow-picker") this.toggleFlowDropdown(); }); }); // Flow dropdown items this.shadow.querySelectorAll("[data-flow-switch]").forEach((btn) => { btn.addEventListener("click", (e) => { e.stopPropagation(); const flowId = (btn as HTMLElement).dataset.flowSwitch; if (flowId && flowId !== this.currentFlowId) this.switchToFlow(flowId); this.closeFlowDropdown(); }); }); this.shadow.querySelectorAll("[data-flow-action]").forEach((btn) => { btn.addEventListener("click", (e) => { e.stopPropagation(); const action = (btn as HTMLElement).dataset.flowAction; if (action === "new-flow") { this.closeFlowDropdown(); this.createNewFlow(); } else if (action === "manage-flows") { this.closeFlowDropdown(); this.openFlowManager(); } }); }); // Close dropdown on outside click this.shadow.addEventListener("click", (e) => { const dropdown = this.shadow.getElementById("flow-dropdown"); if (dropdown && !dropdown.contains(e.target as Node)) this.closeFlowDropdown(); }); // Management modal listeners this.attachFlowManagerListeners(); // Speed slider const speedSlider = this.shadow.getElementById("sim-speed-slider") as HTMLInputElement | null; if (speedSlider) { speedSlider.addEventListener("input", () => { this.simSpeedMs = parseInt(speedSlider.value, 10); const label = this.shadow.getElementById("sim-speed-label"); if (label) label.textContent = `${this.simSpeedMs}ms`; if (this.isSimulating) this.startSimInterval(); }); } // 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); }); // Edge selection — click on edge path (not buttons) edgeLayer.addEventListener("pointerdown", (e: PointerEvent) => { const target = e.target as Element; // Ignore clicks on +/- buttons or drag handle if (target.closest("[data-edge-action]")) return; if (target.closest(".edge-drag-handle")) return; const edgeGroup = target.closest(".edge-group") as SVGGElement | null; if (!edgeGroup) return; e.stopPropagation(); const fromId = edgeGroup.dataset.from!; const toId = edgeGroup.dataset.to!; const edgeType = edgeGroup.dataset.edgeType || "source"; const key = `${fromId}::${toId}::${edgeType}`; this.selectedEdgeKey = key; this.selectedNodeId = null; this.updateSelectionHighlight(); }); // Double-click edge → open source node editor edgeLayer.addEventListener("dblclick", (e: Event) => { const target = e.target as Element; if (target.closest("[data-edge-action]")) return; if (target.closest(".edge-drag-handle")) return; const edgeGroup = target.closest(".edge-group") as SVGGElement | null; if (!edgeGroup) return; e.stopPropagation(); const fromId = edgeGroup.dataset.from!; this.openEditor(fromId); }); // Edge drag handle — pointerdown to start dragging edgeLayer.addEventListener("pointerdown", (e: PointerEvent) => { const handle = (e.target as Element).closest(".edge-drag-handle") as SVGElement | null; if (!handle) return; e.stopPropagation(); e.preventDefault(); const edgeGroup = handle.closest(".edge-group") as SVGGElement | null; if (!edgeGroup) return; const fromId = edgeGroup.dataset.from!; const toId = edgeGroup.dataset.to!; const edgeType = edgeGroup.dataset.edgeType || "source"; this.draggingEdgeKey = `${fromId}::${toId}::${edgeType}`; this.edgeDragPointerId = e.pointerId; (e.target as Element).setPointerCapture?.(e.pointerId); }); // Double-click drag handle → remove waypoint edgeLayer.addEventListener("dblclick", (e: Event) => { const handle = (e.target as Element).closest(".edge-drag-handle") as SVGElement | null; if (!handle) return; e.stopPropagation(); const edgeGroup = handle.closest(".edge-group") as SVGGElement | null; if (!edgeGroup) return; const fromId = edgeGroup.dataset.from!; const toId = edgeGroup.dataset.to!; const edgeType = edgeGroup.dataset.edgeType || "source"; this.removeEdgeWaypoint(fromId, toId, edgeType); }); } // 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; } if (this.analyticsOpen) { this.toggleAnalytics(); 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 = 70; const icons: Record = { card: "\u{1F4B3}", safe_wallet: "\u{1F512}", ridentity: "\u{1F464}", unconfigured: "\u{2699}" }; const icon = icons[d.sourceType] || "\u{1F4B0}"; const stubW = 24, stubH = 20; return ` ${icon} ${this.esc(d.label)} $${d.flowRate.toLocaleString()}/mo ${this.renderAllocBar(d.targetAllocations, w, 48)} ${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 threshold = d.sufficientThreshold ?? d.maxThreshold; const fillPct = Math.min(1, d.currentValue / (d.maxCapacity || 1)); const isOverflow = d.currentValue > d.maxThreshold; const isCritical = d.currentValue < d.minThreshold; const borderColorVar = isCritical ? "var(--rflows-status-critical)" : isOverflow ? "var(--rflows-status-overflow)" : "var(--rflows-status-sustained)"; const fillColor = borderColorVar; const statusLabel = isCritical ? "Critical" : isOverflow ? "Overflow" : "Sustained"; // Tank shape parameters const r = 10; const pipeW = 24; // overflow pipe extension from wall const basePipeH = 20; // base pipe height const pipeYFrac = 0.55; // pipe center at ~55% down const taperStart = 0.75; // body tapers at 75% down const taperInset = 0.2; const insetPx = Math.round(w * taperInset); const taperY = Math.round(h * taperStart); const clipId = `funnel-clip-${n.id}`; // Dynamic pipe sizing for overflow let pipeH = basePipeH; let pipeY = Math.round(h * pipeYFrac) - basePipeH / 2; let excessRatio = 0; if (isOverflow && d.maxCapacity > d.maxThreshold) { excessRatio = Math.min(1, (d.currentValue - d.maxThreshold) / (d.maxCapacity - d.maxThreshold)); pipeH = basePipeH + excessRatio * 16; pipeY = Math.round(h * pipeYFrac) - basePipeH / 2 - excessRatio * 8; } // Tank SVG path: flat-top wide body with pipe notches, tapering to drain at bottom const tankPath = [ `M ${r},0`, `L ${w - r},0`, `Q ${w},0 ${w},${r}`, `L ${w},${pipeY}`, `L ${w + pipeW},${pipeY}`, `L ${w + pipeW},${pipeY + pipeH}`, `L ${w},${pipeY + pipeH}`, `L ${w},${taperY}`, `Q ${w},${taperY + (h - taperY) * 0.3} ${w - insetPx},${h - r}`, `Q ${w - insetPx},${h} ${w - insetPx - r},${h}`, `L ${insetPx + r},${h}`, `Q ${insetPx},${h} ${insetPx},${h - r}`, `Q 0,${taperY + (h - taperY) * 0.3} 0,${taperY}`, `L 0,${pipeY + pipeH}`, `L ${-pipeW},${pipeY + pipeH}`, `L ${-pipeW},${pipeY}`, `L 0,${pipeY}`, `L 0,${r}`, `Q 0,0 ${r},0`, `Z`, ].join(" "); // Interior fill zones const zoneTop = 28; 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; // Threshold lines (always visible) const minFrac = d.minThreshold / (d.maxCapacity || 1); const sufFrac = (d.sufficientThreshold ?? d.maxThreshold) / (d.maxCapacity || 1); const maxFrac = d.maxThreshold / (d.maxCapacity || 1); const minLineY = zoneTop + zoneH * (1 - minFrac); const sufLineY = zoneTop + zoneH * (1 - sufFrac); const maxLineY = zoneTop + zoneH * (1 - maxFrac); const thresholdLines = ` Min Suf Overflow`; // Inflow satisfaction bar const satBarY = 40; 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="var(--rflows-sat-border)" stroke-width="1"` : ""; const glowStyle = isOverflow ? "filter: drop-shadow(0 0 6px rgba(16,185,129,0.5))" : !isCritical ? "filter: drop-shadow(0 0 6px rgba(245,158,11,0.4))" : ""; // Rate labels const inflowLabel = `\u2193 ${this.formatDollar(d.inflowRate)}/mo`; const baseRate = d.desiredOutflow || d.inflowRate; 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 spendingRate = baseRate * rateMultiplier; const spendingLabel = `\u2193 ${this.formatDollar(spendingRate)}/mo`; const excess = Math.max(0, d.currentValue - d.maxThreshold); const overflowLabel = isOverflow ? this.formatDollar(excess) : ""; return ` ${isOverflow ? `` : ""} ${thresholdLines} ${inflowLabel} ${this.esc(d.label)} ${statusLabel} ${satLabel} $${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()} ${spendingLabel} ${isOverflow ? `${overflowLabel} ${overflowLabel}` : ""} ${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 = 110; const fillPct = d.fundingTarget > 0 ? Math.min(1, d.fundingReceived / d.fundingTarget) : 0; const statusColorVar = d.status === "completed" ? "var(--rflows-status-completed)" : d.status === "blocked" ? "var(--rflows-status-blocked)" : d.status === "in-progress" ? "var(--rflows-status-inprogress)" : "var(--rflows-status-notstarted)"; // Basin shape: slightly flared walls (8px wider at top) const flare = 8; const clipId = `basin-clip-${n.id}`; const basinPath = [ `M ${-flare},0`, `L ${w + flare},0`, `Q ${w + flare},4 ${w + flare - 2},8`, `L ${w},${h - 8}`, `Q ${w},${h} ${w - 8},${h}`, `L 8,${h}`, `Q 0,${h} 0,${h - 8}`, `L ${-flare + 2},8`, `Q ${-flare},4 ${-flare},0`, `Z`, ].join(" "); // Fill level from bottom const fillZoneTop = 30; const fillZoneH = h - fillZoneTop - 4; const fillH = fillZoneH * fillPct; const fillY = fillZoneTop + fillZoneH - fillH; 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`; } 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; waypoint?: { x: number; y: number }; } 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: "var(--rflows-edge-inflow)", flowAmount, pct: alloc.percentage, dashed: false, fromId: n.id, toId: alloc.targetId, edgeType: "source", waypoint: alloc.waypoint, }); } } 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: "var(--rflows-edge-overflow)", flowAmount, pct: alloc.percentage, dashed: true, fromId: n.id, toId: alloc.targetId, edgeType: "overflow", waypoint: alloc.waypoint, }); } // 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: "var(--rflows-edge-spending)", flowAmount, pct: alloc.percentage, dashed: false, fromId: n.id, toId: alloc.targetId, edgeType: "spending", waypoint: alloc.waypoint, }); } } // 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: "var(--rflows-edge-overflow)", flowAmount, pct: alloc.percentage, dashed: true, fromId: n.id, toId: alloc.targetId, edgeType: "overflow", waypoint: alloc.waypoint, }); } } } // Second pass: render edges with percentage-proportional widths const MAX_EDGE_W = 16; const MIN_EDGE_W = 1.5; 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 : MIN_EDGE_W + (e.pct / 100) * (MAX_EDGE_W - MIN_EDGE_W); 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, e.waypoint, ); } 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", waypoint?: { x: number; y: number }, ): string { let d: string; let midX: number; let midY: number; if (waypoint) { // Cubic Bezier that passes through waypoint at t=0.5: // P(0.5) = 0.125*P0 + 0.375*C1 + 0.375*C2 + 0.125*P3 // To pass through waypoint W: C1 = (4W - P0 - P3) / 3 blended toward start, // C2 = (4W - P0 - P3) / 3 blended toward end const cx1 = (4 * waypoint.x - x1 - x2) / 3; const cy1 = (4 * waypoint.y - y1 - y2) / 3; const cx2 = cx1; const cy2 = cy1; // Blend control points to retain start/end tangent direction const c1x = x1 + (cx1 - x1) * 0.8; const c1y = y1 + (cy1 - y1) * 0.8; const c2x = x2 + (cx2 - x2) * 0.8; const c2y = y2 + (cy2 - y2) * 0.8; d = `M ${x1} ${y1} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x2} ${y2}`; midX = waypoint.x; midY = waypoint.y; } else if (fromSide) { // Side port: curve outward horizontally first, then turn toward target const burst = Math.max(100, strokeW * 8); const outwardX = fromSide === "left" ? x1 - burst : x1 + burst; 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; } // Invisible wide hit area for click/selection const hitPath = ``; if (ghost) { return ` ${hitPath} ${label} + `; } const overflowMul = dashed ? 1.3 : 1; const finalStrokeW = strokeW * overflowMul; 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; // Drag handle at midpoint const dragHandle = ``; return ` ${hitPath} ${dashed ? `` : ""} ${dragHandle} ${label} + `; } private redrawEdges() { const edgeLayer = this.shadow.getElementById("edge-layer"); if (edgeLayer) edgeLayer.innerHTML = this.renderAllEdges(); } // ─── Edge waypoint helpers ────────────────────────────── private findEdgeAllocation(fromId: string, toId: string, edgeType: string): (OverflowAllocation | SpendingAllocation | SourceAllocation) | null { const node = this.nodes.find((n) => n.id === fromId); if (!node) return null; if (edgeType === "source" && node.type === "source") { return (node.data as SourceNodeData).targetAllocations.find((a) => a.targetId === toId) || null; } if (edgeType === "overflow") { if (node.type === "funnel") return (node.data as FunnelNodeData).overflowAllocations.find((a) => a.targetId === toId) || null; if (node.type === "outcome") return ((node.data as OutcomeNodeData).overflowAllocations || []).find((a) => a.targetId === toId) || null; } if (edgeType === "spending" && node.type === "funnel") { return (node.data as FunnelNodeData).spendingAllocations.find((a) => a.targetId === toId) || null; } return null; } private setEdgeWaypoint(edgeKey: string, x: number, y: number) { const [fromId, toId, edgeType] = edgeKey.split("::"); const alloc = this.findEdgeAllocation(fromId, toId, edgeType); if (alloc) alloc.waypoint = { x, y }; } private removeEdgeWaypoint(fromId: string, toId: string, edgeType: string) { const alloc = this.findEdgeAllocation(fromId, toId, edgeType); if (alloc) { delete alloc.waypoint; this.redrawEdges(); } } // ─── 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", "var(--rflows-selected)"); 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"); } } } }); // Edge selection highlight const edgeLayer = this.shadow.getElementById("edge-layer"); if (!edgeLayer) return; edgeLayer.querySelectorAll(".edge-group").forEach((g) => { const el = g as SVGGElement; const fromId = el.dataset.from; const toId = el.dataset.to; const edgeType = el.dataset.edgeType || "source"; const key = `${fromId}::${toId}::${edgeType}`; el.classList.toggle("edge-group--selected", key === this.selectedEdgeKey); }); } private getNodeBorderColor(n: FlowNode): string { if (n.type === "source") return "var(--rflows-source-border)"; if (n.type === "funnel") { const d = n.data as FunnelNodeData; return d.currentValue < d.minThreshold ? "var(--rflows-status-critical)" : d.currentValue > d.maxThreshold ? "var(--rflows-status-overflow)" : "var(--rflows-status-sustained)"; } const d = n.data as OutcomeNodeData; return d.status === "completed" ? "var(--rflows-status-completed)" : d.status === "blocked" ? "var(--rflows-status-blocked)" : d.status === "in-progress" ? "var(--rflows-status-inprogress)" : "var(--rflows-status-notstarted)"; } // ─── 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 }; // Dynamic overflow port Y for funnels — match pipe position if (node.type === "funnel" && portKind === "overflow" && def.side) { const d = node.data as FunnelNodeData; const h = s.h; const basePipeH = 20; let pipeY = Math.round(h * 0.55) - basePipeH / 2; if (d.currentValue > d.maxThreshold && d.maxCapacity > d.maxThreshold) { const er = Math.min(1, (d.currentValue - d.maxThreshold) / (d.maxCapacity - d.maxThreshold)); pipeY = Math.round(h * 0.55) - basePipeH / 2 - er * 8; } const pipeMidY = pipeY + basePipeH / 2; return { x: node.position.x + s.w * def.xFrac, y: node.position.y + pipeMidY }; } 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); this.scheduleSave(); } 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); this.scheduleSave(); } // ─── 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 config panel ───────────────────────────── private enterInlineEdit(nodeId: string) { if (this.inlineEditNodeId && this.inlineEditNodeId !== nodeId) { this.exitInlineEdit(); } this.inlineEditNodeId = nodeId; this.inlineConfigTab = "config"; this.selectedNodeId = nodeId; this.updateSelectionHighlight(); const node = this.nodes.find((n) => n.id === nodeId); if (!node) return; const nodeLayer = this.shadow.getElementById("node-layer"); const g = nodeLayer?.querySelector(`[data-node-id="${nodeId}"]`) as SVGGElement | null; if (!g) return; 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"); // For funnels, render threshold drag markers on the node body if (node.type === "funnel") { this.renderFunnelThresholdMarkers(overlay, node, s); } // Panel positioned below the node const panelW = Math.max(280, s.w); const panelH = 260; const panelX = (s.w - panelW) / 2; const panelY = s.h + 8; overlay.innerHTML += `
${this.renderInlineConfigContent(node)}
`; g.appendChild(overlay); this.attachInlineConfigListeners(g, node); } private renderInlineConfigContent(node: FlowNode): string { if (this.inlineConfigTab === "config") return this.renderInlineConfigTab(node); if (this.inlineConfigTab === "analytics") return this.renderInlineAnalyticsTab(node); return this.renderInlineAllocTab(node); } // ── Config tab renderers ── private renderInlineConfigTab(node: FlowNode): string { if (node.type === "source") return this.renderSourceConfigTab(node); if (node.type === "funnel") return this.renderFunnelConfigTab(node); return this.renderOutcomeConfigTab(node); } private renderSourceConfigTab(node: FlowNode): string { const d = node.data as SourceNodeData; let html = `
`; if (d.sourceType === "card") { html += ``; } return html; } private renderFunnelConfigTab(node: FlowNode): string { const d = node.data as FunnelNodeData; const cap = d.maxCapacity || 1; const sufVal = d.sufficientThreshold ?? d.maxThreshold; return `
Min ${this.formatDollar(d.minThreshold)}
Suf ${this.formatDollar(sufVal)}
Max ${this.formatDollar(d.maxThreshold)}
`; } private renderOutcomeConfigTab(node: FlowNode): string { const d = node.data as OutcomeNodeData; return `
`; } // ── Analytics tab ── private renderInlineAnalyticsTab(node: FlowNode): string { const stats = this.nodeAnalytics.get(node.id); if (node.type === "funnel") { const d = node.data as FunnelNodeData; const suf = computeSufficiencyState(d); const threshold = d.sufficientThreshold ?? d.maxThreshold; const fillPct = Math.min(100, Math.round((d.currentValue / (threshold || 1)) * 100)); const fillColor = suf === "seeking" ? "#3b82f6" : suf === "sufficient" ? "#10b981" : "#f59e0b"; const totalOut = (stats?.totalOutflow || 0) + (stats?.totalOverflow || 0); const outflowPct = totalOut > 0 ? Math.round(((stats?.totalOutflow || 0) / totalOut) * 100) : 50; const overflowPct = 100 - outflowPct; return `
Fill Level${fillPct}%
${suf}
${totalOut > 0 ? `
Outflow ${outflowPct}%
Overflow ${overflowPct}%
` : ""}
Current Value${this.formatDollar(d.currentValue)}
Peak Value${this.formatDollar(stats?.peakValue || d.currentValue)}
Avg Fill${this.formatDollar(stats?.avgFillLevel || d.currentValue)}
Total Inflow${this.formatDollar(stats?.totalInflow || 0)}
`; } if (node.type === "outcome") { const d = node.data as OutcomeNodeData; const progressPct = Math.min(100, Math.round((d.fundingReceived / (d.fundingTarget || 1)) * 100)); const phasesTotal = d.phases?.length || 0; const phasesAchieved = d.phases?.filter((p) => d.fundingReceived >= p.fundingThreshold).length || 0; return `
Funding Progress${progressPct}%
Received${this.formatDollar(d.fundingReceived)}
Target${this.formatDollar(d.fundingTarget)}
${phasesTotal > 0 ? `
Phases${phasesAchieved} / ${phasesTotal}
` : ""}
Total Inflow${this.formatDollar(stats?.totalInflow || 0)}
`; } // Source const d = node.data as SourceNodeData; return `
Flow Rate${this.formatDollar(d.flowRate)}/mo
Total Dispensed${this.formatDollar(stats?.totalOutflow || 0)}
${d.targetAllocations.length > 0 ? `
Allocation Breakdown
${d.targetAllocations.map((a) => `
${this.esc(this.getNodeLabel(a.targetId))} ${a.percentage}%
`).join("")}` : ""}`; } // ── Allocations tab ── private renderInlineAllocTab(node: FlowNode): string { const renderRows = (title: string, allocs: { targetId: string; percentage: number; color: string }[]) => { if (!allocs || allocs.length === 0) return ""; let html = `
${title}
`; for (const a of allocs) { html += `
${this.esc(this.getNodeLabel(a.targetId))} ${a.percentage}%
`; } return html; }; if (node.type === "source") { const d = node.data as SourceNodeData; const html = renderRows("Target Allocations", d.targetAllocations); return html || '
No allocations configured
'; } if (node.type === "funnel") { const d = node.data as FunnelNodeData; let html = renderRows("Spending Allocations", d.spendingAllocations); html += renderRows("Overflow Allocations", d.overflowAllocations); return html || '
No allocations configured
'; } const od = node.data as OutcomeNodeData; const html = renderRows("Overflow Allocations", od.overflowAllocations || []); return html || '
No allocations configured
'; } // ── Funnel threshold markers (SVG on node body) ── private renderFunnelThresholdMarkers(overlay: SVGGElement, node: FlowNode, s: { w: number; h: number }) { const d = node.data as FunnelNodeData; const zoneTop = 28; const zoneBot = s.h - 4; const zoneH = zoneBot - zoneTop; const thresholds: Array<{ key: string; value: number; color: string; label: string }> = [ { key: "minThreshold", value: d.minThreshold, color: "var(--rflows-status-critical)", label: "Min" }, { key: "sufficientThreshold", value: d.sufficientThreshold ?? d.maxThreshold, color: "var(--rflows-status-thriving)", label: "Suf" }, { key: "maxThreshold", value: d.maxThreshold, color: "var(--rflows-status-sustained)", label: "Max" }, ]; 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)}`; } } // ── Inline config listeners ── private attachInlineConfigListeners(g: SVGGElement, node: FlowNode) { const overlay = g.querySelector(".inline-edit-overlay"); if (!overlay) return; // Tab switching overlay.querySelectorAll(".icp-tab").forEach((el) => { el.addEventListener("click", (e: Event) => { e.stopPropagation(); const tab = (el as HTMLElement).dataset.icpTab as "config" | "analytics" | "allocations"; if (!tab || tab === this.inlineConfigTab) return; this.inlineConfigTab = tab; overlay.querySelectorAll(".icp-tab").forEach((t) => t.classList.remove("icp-tab--active")); el.classList.add("icp-tab--active"); const body = overlay.querySelector(".icp-body") as HTMLElement; if (body) body.innerHTML = this.renderInlineConfigContent(node); this.attachInlineConfigFieldListeners(overlay as Element, node); }); }); // Field listeners this.attachInlineConfigFieldListeners(overlay, node); // Threshold drag handles (funnel) this.attachThresholdDragListeners(overlay, node); // 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 button overlay.querySelector(".iet-panel")?.addEventListener("click", (e: Event) => { e.stopPropagation(); this.exitInlineEdit(); this.openEditor(node.id); }); // Fund Now button (source card type) overlay.querySelector("[data-icp-action='fund']")?.addEventListener("click", (e: Event) => { e.stopPropagation(); const sd = node.data as SourceNodeData; const flowId = this.flowId || this.getAttribute("flow-id") || ""; if (!sd.walletAddress) { alert("Configure a wallet address first"); return; } this.openTransakWidget(flowId, sd.walletAddress); }); // Click-outside handler 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 attachInlineConfigFieldListeners(overlay: Element, node: FlowNode) { // Text/number/select input fields overlay.querySelectorAll("[data-icp-field]").forEach((el) => { const input = el as HTMLInputElement | HTMLSelectElement; const field = input.dataset.icpField!; const handler = () => { const numFields = ["flowRate", "currentValue", "minThreshold", "maxThreshold", "maxCapacity", "inflowRate", "sufficientThreshold", "fundingReceived", "fundingTarget"]; const val = numFields.includes(field) ? parseFloat((input as HTMLInputElement).value) || 0 : input.value; (node.data as any)[field] = val; this.redrawNodeOnly(node); this.redrawEdges(); }; input.addEventListener("input", handler); input.addEventListener("change", handler); input.addEventListener("keydown", (e: Event) => { const ke = e as KeyboardEvent; if (ke.key === "Enter") this.exitInlineEdit(); if (ke.key === "Escape") this.exitInlineEdit(); ke.stopPropagation(); }); }); // Range sliders overlay.querySelectorAll("[data-icp-range]").forEach((el) => { const input = el as HTMLInputElement; const field = input.dataset.icpRange!; input.addEventListener("input", () => { const val = parseFloat(input.value) || 0; (node.data as any)[field] = Math.round(val); const valueSpan = input.parentElement?.querySelector(".icp-range-value") as HTMLElement; if (valueSpan) valueSpan.textContent = this.formatDollar(val); this.redrawNodeOnly(node); this.redrawEdges(); this.redrawThresholdMarkers(node); }); }); } private attachThresholdDragListeners(overlay: Element, node: FlowNode) { 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 zoneH = s.h - 4 - 28; const deltaY = (pe.clientY - this.inlineEditDragStartY) / this.canvasZoom; const deltaDollars = -(deltaY / zoneH) * (d.maxCapacity || 1); let newVal = this.inlineEditDragStartValue + deltaDollars; 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); this.redrawNodeInlineEdit(node); }); el.addEventListener("pointerup", () => { this.inlineEditDragThreshold = null; }); }); } private redrawThresholdMarkers(node: FlowNode) { if (node.type !== "funnel") return; const nodeLayer = this.shadow.getElementById("node-layer"); const g = nodeLayer?.querySelector(`[data-node-id="${node.id}"]`) as SVGGElement | null; if (!g) return; const overlay = g.querySelector(".inline-edit-overlay"); if (!overlay) return; overlay.querySelectorAll(".threshold-marker, .threshold-handle").forEach((el) => el.remove()); overlay.querySelectorAll("text").forEach((t) => { if (t.getAttribute("pointer-events") === "none" && t.getAttribute("font-size") === "9") t.remove(); }); const s = this.getNodeSize(node); const tempG = document.createElementNS("http://www.w3.org/2000/svg", "g"); this.renderFunnelThresholdMarkers(tempG, node, s); const fo = overlay.querySelector("foreignObject"); while (tempG.firstChild) { if (fo) overlay.insertBefore(tempG.firstChild, fo); else overlay.appendChild(tempG.firstChild); } this.attachThresholdDragListeners(overlay, node); } 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); 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); } this.scheduleSave(); } private redrawNodeInlineEdit(node: FlowNode) { this.drawCanvasContent(); 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 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.renderFunnelThresholdMarkers(overlay, node, s); } const panelW = Math.max(280, s.w); const panelH = 260; const panelX = (s.w - panelW) / 2; const panelY = s.h + 8; const tabs = ["config", "analytics", "allocations"] as const; overlay.innerHTML += `
${tabs.map((t) => ``).join("")}
${this.renderInlineConfigContent(node)}
`; g.appendChild(overlay); this.attachInlineConfigListeners(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; this.drawCanvasContent(); } // ── Analytics accumulation ── private accumulateNodeAnalytics() { for (const node of this.nodes) { let stats = this.nodeAnalytics.get(node.id); if (!stats) { stats = { totalInflow: 0, totalOutflow: 0, totalOverflow: 0, avgFillLevel: 0, peakValue: 0, outcomesAchieved: 0, tickCount: 0, fillLevelSum: 0 }; this.nodeAnalytics.set(node.id, stats); } stats.tickCount++; if (node.type === "funnel") { const d = node.data as FunnelNodeData; stats.totalInflow += d.inflowRate; const threshold = d.sufficientThreshold ?? d.maxThreshold; if (d.currentValue >= d.maxCapacity) { stats.totalOverflow += d.inflowRate * 0.5; stats.totalOutflow += d.inflowRate * 0.5; } else if (d.currentValue >= threshold) { stats.totalOutflow += d.inflowRate * 0.3; } stats.fillLevelSum += d.currentValue; stats.avgFillLevel = stats.fillLevelSum / stats.tickCount; stats.peakValue = Math.max(stats.peakValue, d.currentValue); } else if (node.type === "outcome") { const d = node.data as OutcomeNodeData; stats.peakValue = Math.max(stats.peakValue, d.fundingReceived); stats.outcomesAchieved = d.phases?.filter((p) => d.fundingReceived >= p.fundingThreshold).length || 0; } else if (node.type === "source") { const d = node.data as SourceNodeData; stats.totalOutflow += d.flowRate / 10; } } } private updateInlineConfigAnalytics() { if (!this.inlineEditNodeId || this.inlineConfigTab !== "analytics") return; const node = this.nodes.find((n) => n.id === this.inlineEditNodeId); if (!node) return; const nodeLayer = this.shadow.getElementById("node-layer"); const g = nodeLayer?.querySelector(`[data-node-id="${node.id}"]`) as SVGGElement | null; if (!g) return; const body = g.querySelector(".icp-body") as HTMLElement | null; if (body) body.innerHTML = this.renderInlineAnalyticsTab(node); } 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; const derived = d.desiredOutflow ? deriveThresholds(d.desiredOutflow) : null; return `
Thresholds ${derived ? "(auto-derived from outflow)" : ""}
${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", "desiredOutflow"]; (node.data as any)[field] = numFields.includes(field) ? parseFloat(val) || 0 : val; // Auto-derive thresholds when desiredOutflow changes if (field === "desiredOutflow" && node.type === "funnel") { const fd = node.data as FunnelNodeData; if (fd.desiredOutflow) { const derived = deriveThresholds(fd.desiredOutflow); fd.minThreshold = derived.minThreshold; fd.sufficientThreshold = derived.sufficientThreshold; fd.maxThreshold = derived.maxThreshold; fd.maxCapacity = derived.maxCapacity; // Re-render the editor to reflect updated values this.openEditor(node.id); return; } } this.drawCanvasContent(); this.updateSufficiencyBadge(); this.scheduleSave(); }); }); // 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" ? "var(--rflows-status-completed)" : d.status === "blocked" ? "var(--rflows-status-blocked)" : d.status === "in-progress" ? "var(--rflows-status-inprogress)" : "var(--rflows-status-notstarted)"; 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, desiredOutflow: 5000, minThreshold: 5000, sufficientThreshold: 15000, maxThreshold: 30000, maxCapacity: 45000, inflowRate: 0, 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); this.scheduleSave(); } 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(); this.scheduleSave(); } // ─── Simulation ─────────────────────────────────────── private toggleSimulation() { this.isSimulating = !this.isSimulating; const btn = this.shadow.getElementById("sim-btn"); if (btn) btn.textContent = this.isSimulating ? "Pause" : "Play"; // Show/hide speed slider and timeline const speedContainer = this.shadow.getElementById("sim-speed-container"); const timelineContainer = this.shadow.getElementById("sim-timeline"); if (speedContainer) speedContainer.style.display = this.isSimulating ? "flex" : "none"; if (timelineContainer) timelineContainer.style.display = this.isSimulating ? "flex" : "none"; if (this.isSimulating) { this.simTickCount = 0; this.nodeAnalytics.clear(); this.startSimInterval(); } else { if (this.simInterval) { clearInterval(this.simInterval); this.simInterval = null; } } } private startSimInterval() { if (this.simInterval) clearInterval(this.simInterval); this.simInterval = setInterval(() => { this.simTickCount++; this.nodes = simulateTick(this.nodes); this.accumulateNodeAnalytics(); this.updateCanvasLive(); }, this.simSpeedMs); } /** Update canvas nodes and edges without full innerHTML rebuild during simulation */ private updateCanvasLive() { const nodeLayer = this.shadow.getElementById("node-layer"); if (!nodeLayer) return; // Try to patch fill rects in-place for smooth CSS transitions let didPatch = false; for (const n of this.nodes) { if (n.type !== "funnel") continue; const d = n.data as FunnelNodeData; const s = this.getNodeSize(n); const h = s.h; const zoneTop = 28; const zoneBot = h - 4; const zoneH = zoneBot - zoneTop; const fillPct = Math.min(1, d.currentValue / (d.maxCapacity || 1)); const totalFillH = zoneH * fillPct; const fillY = zoneTop + zoneH - totalFillH; const fillRect = nodeLayer.querySelector(`.funnel-fill-rect[data-node-id="${n.id}"]`) as SVGRectElement | null; if (fillRect) { fillRect.setAttribute("y", String(fillY)); fillRect.setAttribute("height", String(totalFillH)); didPatch = true; } // Patch value text const threshold = d.sufficientThreshold ?? d.maxThreshold; const valText = nodeLayer.querySelector(`.funnel-value-text[data-node-id="${n.id}"]`) as SVGTextElement | null; if (valText) { valText.textContent = `$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()}`; } } // Preserve inline config overlay during rebuild let overlayNodeId: string | null = null; let detachedOverlay: Element | null = null; if (this.inlineEditNodeId) { overlayNodeId = this.inlineEditNodeId; const existingG = nodeLayer.querySelector(`[data-node-id="${overlayNodeId}"]`); detachedOverlay = existingG?.querySelector(".inline-edit-overlay") || null; if (detachedOverlay) detachedOverlay.remove(); } nodeLayer.innerHTML = this.renderAllNodes(); // Reattach overlay to the new node if (detachedOverlay && overlayNodeId) { const newG = nodeLayer.querySelector(`[data-node-id="${overlayNodeId}"]`); if (newG) newG.appendChild(detachedOverlay); } this.redrawEdges(); this.updateSufficiencyBadge(); this.updateInlineConfigAnalytics(); // Update timeline bar const tickLabel = this.shadow.getElementById("timeline-tick"); const timelineFill = this.shadow.getElementById("timeline-fill"); if (tickLabel) tickLabel.textContent = `Tick ${this.simTickCount}`; if (timelineFill) { const pct = (this.simTickCount % 100); timelineFill.style.width = `${pct}%`; } } private updateSufficiencyBadge() { const score = computeSystemSufficiency(this.nodes); const scorePct = Math.round(score * 100); const scoreColor = scorePct >= 90 ? "var(--rflows-score-gold)" : scorePct >= 60 ? "var(--rflows-score-green)" : scorePct >= 30 ? "var(--rflows-score-amber)" : "var(--rflows-score-red)"; 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 } } // ─── Analytics popout panel ────────────────────────── private renderAnalyticsPanel(): string { return `
Analytics
${this.analyticsTab === "overview" ? this.renderTableTab() : this.renderTransactionsTab()}
`; } private toggleAnalytics() { this.analyticsOpen = !this.analyticsOpen; if (this.analyticsOpen && this.analyticsTab === "transactions" && !this.txLoaded) { this.loadTransactions(); } const panel = this.shadow.getElementById("analytics-panel"); if (panel) { panel.classList.toggle("open", this.analyticsOpen); } const btn = this.shadow.querySelector('[data-canvas-action="analytics"]'); if (btn) btn.classList.toggle("flows-canvas-btn--active", this.analyticsOpen); } private attachAnalyticsListeners() { const closeBtn = this.shadow.querySelector("[data-analytics-close]"); closeBtn?.addEventListener("click", () => this.toggleAnalytics()); this.shadow.querySelectorAll("[data-analytics-tab]").forEach((el) => { el.addEventListener("click", () => { const tab = (el as HTMLElement).dataset.analyticsTab as "overview" | "transactions"; if (tab === this.analyticsTab) return; this.analyticsTab = tab; if (tab === "transactions" && !this.txLoaded) { this.loadTransactions(); return; } const panel = this.shadow.getElementById("analytics-panel"); if (panel) { const content = panel.querySelector(".analytics-content"); if (content) { content.innerHTML = this.analyticsTab === "overview" ? this.renderTableTab() : this.renderTransactionsTab(); } panel.querySelectorAll(".analytics-tab").forEach((t) => { t.classList.toggle("analytics-tab--active", (t as HTMLElement).dataset.analyticsTab === tab); }); } }); }); } // ─── 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; } } // ─── Flow dropdown & management modal ──────────────── private toggleFlowDropdown() { const menu = this.shadow.getElementById("flow-dropdown-menu"); if (!menu) return; this.flowDropdownOpen = !this.flowDropdownOpen; menu.style.display = this.flowDropdownOpen ? "block" : "none"; } private closeFlowDropdown() { this.flowDropdownOpen = false; const menu = this.shadow.getElementById("flow-dropdown-menu"); if (menu) menu.style.display = "none"; } private openFlowManager() { this.flowManagerOpen = true; this.render(); } private closeFlowManager() { this.flowManagerOpen = false; this.render(); } private attachFlowManagerListeners() { const overlay = this.shadow.getElementById("flow-manager-overlay"); if (!overlay) return; // Close button & backdrop click overlay.querySelector('[data-mgmt-action="close"]')?.addEventListener("click", () => this.closeFlowManager()); overlay.addEventListener("click", (e) => { if (e.target === overlay) this.closeFlowManager(); }); // Row actions overlay.querySelectorAll("[data-mgmt-action]").forEach((btn) => { const action = (btn as HTMLElement).dataset.mgmtAction; const id = (btn as HTMLElement).dataset.mgmtId; if (!action || action === "close") return; btn.addEventListener("click", (e) => { e.stopPropagation(); if (action === "new") { this.closeFlowManager(); this.createNewFlow(); } else if (action === "import") this.importFlowJson(); else if (action === "rename" && id) this.renameFlowInline(id, btn as HTMLElement); else if (action === "duplicate" && id) this.duplicateFlow(id); else if (action === "export" && id) this.exportFlowJson(id); else if (action === "delete" && id) this.deleteFlowConfirm(id); }); }); // Click a row to switch to that flow overlay.querySelectorAll("[data-mgmt-flow]").forEach((row) => { row.addEventListener("dblclick", () => { const flowId = (row as HTMLElement).dataset.mgmtFlow; if (flowId) { this.closeFlowManager(); this.switchToFlow(flowId); } }); }); } private renameFlowInline(flowId: string, triggerBtn: HTMLElement) { const row = triggerBtn.closest("[data-mgmt-flow]"); const nameDiv = row?.querySelector(".flows-mgmt__row-name"); if (!nameDiv) return; const currentName = nameDiv.textContent?.trim() || ''; nameDiv.innerHTML = ``; const input = nameDiv.querySelector("input") as HTMLInputElement; input.focus(); input.select(); const commitRename = () => { const newName = input.value.trim() || currentName; if (this.localFirstClient) { this.localFirstClient.renameCanvasFlow(flowId, newName); } else { const raw = localStorage.getItem(`rflows:local:${flowId}`); if (raw) { const flow = JSON.parse(raw) as CanvasFlow; flow.name = newName; flow.updatedAt = Date.now(); localStorage.setItem(`rflows:local:${flowId}`, JSON.stringify(flow)); } } if (flowId === this.currentFlowId) this.flowName = newName; nameDiv.textContent = newName; }; input.addEventListener("blur", commitRename); input.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); input.blur(); } if (e.key === "Escape") { nameDiv.textContent = currentName; } }); } private duplicateFlow(flowId: string) { let sourceFlow: CanvasFlow | undefined; if (this.localFirstClient) { sourceFlow = this.localFirstClient.getCanvasFlow(flowId); } else { const raw = localStorage.getItem(`rflows:local:${flowId}`); if (raw) sourceFlow = JSON.parse(raw); } if (!sourceFlow) return; const newId = crypto.randomUUID(); const now = Date.now(); const copy: CanvasFlow = { id: newId, name: `${sourceFlow.name} (Copy)`, nodes: sourceFlow.nodes.map((n: any) => ({ ...n, data: { ...n.data } })), createdAt: now, updatedAt: now, createdBy: sourceFlow.createdBy, }; if (this.localFirstClient) { this.localFirstClient.saveCanvasFlow(copy); } else { localStorage.setItem(`rflows:local:${newId}`, JSON.stringify(copy)); const listRaw = localStorage.getItem('rflows:local:list'); const list: string[] = listRaw ? JSON.parse(listRaw) : []; list.push(newId); localStorage.setItem('rflows:local:list', JSON.stringify(list)); } this.closeFlowManager(); this.switchToFlow(newId); } private exportFlowJson(flowId: string) { let flow: CanvasFlow | undefined; if (this.localFirstClient) { flow = this.localFirstClient.getCanvasFlow(flowId); } else { const raw = localStorage.getItem(`rflows:local:${flowId}`); if (raw) flow = JSON.parse(raw); else if (flowId === 'demo') { flow = { id: 'demo', name: 'TBFF Demo Flow', nodes: demoNodes, createdAt: 0, updatedAt: 0, createdBy: null }; } } if (!flow) return; const blob = new Blob([JSON.stringify(flow.nodes, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${flow.name.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`; a.click(); URL.revokeObjectURL(url); } private importFlowJson() { const input = document.createElement("input"); input.type = "file"; input.accept = ".json"; input.addEventListener("change", async () => { const file = input.files?.[0]; if (!file) return; try { const text = await file.text(); const nodes = JSON.parse(text); if (!Array.isArray(nodes) || nodes.length === 0) { alert("Invalid flow JSON: expected a non-empty array of nodes."); return; } // Basic validation: each node should have id, type, position, data for (const n of nodes) { if (!n.id || !n.type || !n.position || !n.data) { alert("Invalid node structure in JSON."); return; } } const id = crypto.randomUUID(); const now = Date.now(); const name = file.name.replace(/\.json$/i, ''); const flow: CanvasFlow = { id, name, nodes, createdAt: now, updatedAt: now, createdBy: null }; if (this.localFirstClient) { this.localFirstClient.saveCanvasFlow(flow); } else { localStorage.setItem(`rflows:local:${id}`, JSON.stringify(flow)); const listRaw = localStorage.getItem('rflows:local:list'); const list: string[] = listRaw ? JSON.parse(listRaw) : []; list.push(id); localStorage.setItem('rflows:local:list', JSON.stringify(list)); } this.closeFlowManager(); this.switchToFlow(id); } catch { alert("Failed to parse JSON file."); } }); input.click(); } private deleteFlowConfirm(flowId: string) { if (!confirm("Delete this flow? This cannot be undone.")) return; if (this.localFirstClient) { this.localFirstClient.deleteCanvasFlow(flowId); } else { localStorage.removeItem(`rflows:local:${flowId}`); localStorage.removeItem(`rflows:viewport:${flowId}`); const listRaw = localStorage.getItem('rflows:local:list'); const list: string[] = listRaw ? JSON.parse(listRaw) : []; localStorage.setItem('rflows:local:list', JSON.stringify(list.filter(id => id !== flowId))); } if (flowId === this.currentFlowId) { // Switch to another flow or create new const remaining = this.getFlowList(); if (remaining.length > 0) this.switchToFlow(remaining[0].id); else this.createNewFlow(); } else { this.closeFlowManager(); this.openFlowManager(); // refresh list } } // ─── Event listeners ────────────────────────────────── private attachListeners() { // Initialize interactive canvas when detail view is active if (this.view === "detail" && this.nodes.length > 0) { this.initCanvas(); this.attachAnalyticsListeners(); } // 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);