rspace-online/modules/rflows/components/folk-flows-app.ts

4273 lines
178 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* <folk-flows-app> — 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<typeof setInterval> | 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<string, NodeAnalyticsStats> = 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<typeof setTimeout> | 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 = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: var(--rs-text-primary); }
*, *::before, *::after { box-sizing: border-box; }
</style>
<link rel="stylesheet" href="${this.getCssPath()}">
${this.error ? `<div class="flows-error">${this.esc(this.error)}</div>` : ""}
${this.loading && this.view === "landing" ? '<div class="flows-loading">Loading...</div>' : ""}
${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 `
<div class="flows-landing">
<div class="rapp-nav">
<span class="rapp-nav__title">Flows</span>
<div class="rapp-nav__actions">
<a href="${this.esc(demoUrl)}" class="rapp-nav__btn rapp-nav__btn--secondary">Demo</a>
${authed
? `<button class="rapp-nav__btn" data-action="create-flow">+ Create Flow</button>`
: `<span style="font-size:12px;color:var(--rs-text-muted)">Sign in to create flows</span>`
}
</div>
</div>
<div class="flows-desc" style="color:var(--rs-text-secondary);font-size:14px;line-height:1.6;max-width:600px;margin-bottom:24px">
Design transparent resource flows with sufficiency-based cascading.
Funnels fill to their threshold, then overflow routes surplus to the next layer &mdash;
ensuring every level has <em style="color:var(--rs-warning);font-style:normal;font-weight:600">enough</em> before abundance cascades forward.
</div>
<div class="flows-features">
<div class="flows-features__grid">
<div class="flows-features__card">
<div class="flows-features__icon">&#x1F4B0;</div>
<h3>Sources</h3>
<p>Revenue streams split across funnels by configurable allocation percentages.</p>
</div>
<div class="flows-features__card">
<div class="flows-features__icon">&#x1F3DB;</div>
<h3>Funnels</h3>
<p>Budget buckets with min/max thresholds and sufficiency-based overflow cascading.</p>
</div>
<div class="flows-features__card">
<div class="flows-features__icon">&#x1F3AF;</div>
<h3>Outcomes</h3>
<p>Funding targets that receive spending allocations. Track progress toward each goal.</p>
</div>
<div class="flows-features__card">
<div class="flows-features__icon">&#x1F30A;</div>
<h3>River View</h3>
<p>Animated sankey diagram showing live fund flows through your entire system.</p>
</div>
<div class="flows-features__card">
<div class="flows-features__icon">&#x2728;</div>
<h3>Enoughness</h3>
<p>System-wide sufficiency scoring. Golden glow when funnels reach their threshold.</p>
</div>
</div>
</div>
<div class="flows-flows">
<div class="flows-flows__header">
<h2 class="flows-flows__heading">${authed ? `Flows in ${this.esc(this.space)}` : "Your Flows"}</h2>
${authed ? `<span class="flows-flows__user">Signed in as ${this.esc(username || "")}</span>` : ""}
</div>
${this.flows.length > 0 ? `
<div class="flows-flows__grid">
${this.flows.map((f) => this.renderFlowCard(f)).join("")}
</div>
` : `
<div class="flows-flows__empty">
${authed
? `<p>No flows in this space yet.</p>
<p><a href="${this.esc(demoUrl)}">Explore the demo</a> or create your first flow.</p>`
: `<p>Sign in to see your space&rsquo;s flows, or <a href="${this.esc(demoUrl)}">explore the demo</a>.</p>`
}
</div>
`}
</div>
<div class="flows-about">
<h2 class="flows-about__heading">How TBFF Works</h2>
<div class="flows-about__steps">
<div class="flows-about__step">
<div class="flows-about__step-num">1</div>
<div>
<h3>Define Sources</h3>
<p>Add revenue streams &mdash; grants, donations, sales, or any recurring income &mdash; with allocation splits.</p>
</div>
</div>
<div class="flows-about__step">
<div class="flows-about__step-num">2</div>
<div>
<h3>Configure Funnels</h3>
<p>Set minimum, sufficient, and maximum thresholds. Overflow rules determine where surplus flows.</p>
</div>
</div>
<div class="flows-about__step">
<div class="flows-about__step-num">3</div>
<div>
<h3>Track Outcomes</h3>
<p>Funding targets receive allocations as funnels reach sufficiency. Watch the river flow in real time.</p>
</div>
</div>
</div>
</div>
</div>`;
}
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 `
<a href="${this.esc(detailUrl)}" class="flows-flow-card" data-flow="${this.esc(f.id)}">
<div class="flows-flow-card__name">${this.esc(f.name || f.label || f.id)}</div>
${value ? `<div class="flows-flow-card__value">${value}</div>` : ""}
<div class="flows-flow-card__meta">
${f.funnelCount != null ? `${f.funnelCount} funnels` : ""}
${f.outcomeCount != null ? ` &middot; ${f.outcomeCount} outcomes` : ""}
${f.status ? ` &middot; ${f.status}` : ""}
</div>
</a>`;
}
// ─── Detail view with tabs ─────────────────────────────
private renderDetail(): string {
if (this.loading) {
return '<div class="flows-loading">Loading...</div>';
}
return `
<div class="flows-detail flows-detail--fullpage">
${this.renderDiagramTab()}
</div>`;
}
// ─── 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 `
<div class="flows-table">
${sources.length > 0 ? `
<div class="flows-section">
<h3 class="flows-section__title">Sources</h3>
<div class="flows-cards">
${sources.map((n) => this.renderSourceCard(n.data as SourceNodeData, n.id)).join("")}
</div>
</div>
` : ""}
<div class="flows-section">
<h3 class="flows-section__title">Funnels</h3>
<div class="flows-cards">
${funnels.map((n) => this.renderFunnelCard(n.data as FunnelNodeData, n.id)).join("")}
</div>
</div>
<div class="flows-section">
<h3 class="flows-section__title">Outcomes</h3>
<div class="flows-cards">
${outcomes.map((n) => this.renderOutcomeCard(n.data as OutcomeNodeData, n.id)).join("")}
</div>
</div>
</div>`;
}
private renderSourceCard(data: SourceNodeData, id: string): string {
const allocations = data.targetAllocations || [];
return `
<div class="flows-card">
<div class="flows-card__header">
<span class="flows-card__icon">&#x1F4B0;</span>
<span class="flows-card__label">${this.esc(data.label)}</span>
<span class="flows-card__type">${data.sourceType}</span>
</div>
<div class="flows-card__stat">
<span class="flows-card__stat-value">$${data.flowRate.toLocaleString()}</span>
<span class="flows-card__stat-label">/month</span>
</div>
${allocations.length > 0 ? `
<div class="flows-card__allocs">
${allocations.map((a) => `
<div class="flows-card__alloc">
<span class="flows-card__alloc-dot" style="background:${a.color}"></span>
${a.percentage}% &rarr; ${this.esc(this.getNodeLabel(a.targetId))}
</div>
`).join("")}
</div>
` : ""}
</div>`;
}
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 `
<div class="flows-card">
<div class="flows-card__header">
<span class="flows-card__icon">&#x1F3DB;</span>
<span class="flows-card__label">${this.esc(data.label)}</span>
<span class="flows-card__status ${statusClass}">${statusLabel}</span>
</div>
<div class="flows-card__bar-container">
<div class="flows-card__bar" style="width:${fillPct}%"></div>
<div class="flows-card__bar-threshold" style="left:${Math.min(100, (threshold / (data.maxCapacity || 1)) * 100)}%"></div>
</div>
<div class="flows-card__stats">
<div>
<span class="flows-card__stat-value">$${Math.floor(data.currentValue).toLocaleString()}</span>
<span class="flows-card__stat-label">/ $${Math.floor(threshold).toLocaleString()}</span>
</div>
<div>
<span class="flows-card__stat-value">${Math.round(suffPct)}%</span>
<span class="flows-card__stat-label">sufficiency</span>
</div>
</div>
<div class="flows-card__thresholds">
<span>Min: $${Math.floor(data.minThreshold).toLocaleString()}</span>
<span>Max: $${Math.floor(data.maxThreshold).toLocaleString()}</span>
<span>Cap: $${Math.floor(data.maxCapacity).toLocaleString()}</span>
</div>
${data.overflowAllocations.length > 0 ? `
<div class="flows-card__allocs">
<div class="flows-card__alloc-title">Overflow</div>
${data.overflowAllocations.map((a) => `
<div class="flows-card__alloc">
<span class="flows-card__alloc-dot" style="background:${a.color}"></span>
${a.percentage}% &rarr; ${this.esc(this.getNodeLabel(a.targetId))}
</div>
`).join("")}
</div>
` : ""}
${data.spendingAllocations.length > 0 ? `
<div class="flows-card__allocs">
<div class="flows-card__alloc-title">Spending</div>
${data.spendingAllocations.map((a) => `
<div class="flows-card__alloc">
<span class="flows-card__alloc-dot" style="background:${a.color}"></span>
${a.percentage}% &rarr; ${this.esc(this.getNodeLabel(a.targetId))}
</div>
`).join("")}
</div>
` : ""}
</div>`;
}
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 `
<div class="flows-card">
<div class="flows-card__header">
<span class="flows-card__icon">&#x1F3AF;</span>
<span class="flows-card__label">${this.esc(data.label)}</span>
<span class="flows-card__status" style="color:${statusColor}">${data.status}</span>
</div>
${data.description ? `<div class="flows-card__desc">${this.esc(data.description)}</div>` : ""}
<div class="flows-card__bar-container">
<div class="flows-card__bar flows-card__bar--outcome" style="width:${fillPct}%;background:${statusColor}"></div>
</div>
<div class="flows-card__stats">
<div>
<span class="flows-card__stat-value">$${Math.floor(data.fundingReceived).toLocaleString()}</span>
<span class="flows-card__stat-label">/ $${Math.floor(data.fundingTarget).toLocaleString()}</span>
</div>
<div>
<span class="flows-card__stat-value">${Math.round(fillPct)}%</span>
<span class="flows-card__stat-label">funded</span>
</div>
</div>
</div>`;
}
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 '<div class="flows-loading">No nodes to display.</div>';
}
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 `
<div class="flows-canvas-container flows-canvas-container--fullpage" id="canvas-container">
<div class="flows-nav-overlay">
<a href="${this.esc(backUrl)}" class="rapp-nav__back">&larr; Flows</a>
<span class="rapp-nav__title">${this.esc(this.flowName || "Flow Detail")}</span>
${this.isDemo ? '<span class="rapp-nav__badge">Demo</span>' : ""}
</div>
<div class="flows-canvas-badge" id="canvas-badge">
<div>
<div class="flows-canvas-badge__score" id="badge-score" style="color:${scoreColor}">${scorePct}%</div>
<div class="flows-canvas-badge__label">ENOUGH</div>
</div>
</div>
<div class="flows-canvas-toolbar">
<div class="flows-dropdown" id="flow-dropdown">
<button class="flows-canvas-btn flows-dropdown__trigger" data-canvas-action="flow-picker">
<span class="flows-dropdown__name">${this.esc(this.flowName || 'Untitled')}</span>
<span class="flows-dropdown__chevron">&#x25BE;</span>
</button>
<div class="flows-dropdown__menu" id="flow-dropdown-menu" style="display:none">
${this.renderFlowDropdownItems()}
<div class="flows-dropdown__sep"></div>
<button class="flows-dropdown__item flows-dropdown__item--new" data-flow-action="new-flow">+ New Flow</button>
<button class="flows-dropdown__item" data-flow-action="manage-flows">Manage Flows...</button>
</div>
</div>
<div class="flows-canvas-sep"></div>
<button class="flows-canvas-btn flows-canvas-btn--source" data-canvas-action="add-source">+ Source</button>
<button class="flows-canvas-btn flows-canvas-btn--funnel" data-canvas-action="add-funnel">+ Funnel</button>
<button class="flows-canvas-btn flows-canvas-btn--outcome" data-canvas-action="add-outcome">+ Outcome</button>
<div class="flows-canvas-sep"></div>
<button class="flows-canvas-btn" data-canvas-action="sim" id="sim-btn">${this.isSimulating ? "Pause" : "Play"}</button>
<button class="flows-canvas-btn" data-canvas-action="fit">Fit</button>
<button class="flows-canvas-btn ${this.analyticsOpen ? "flows-canvas-btn--active" : ""}" data-canvas-action="analytics">Analytics</button>
<button class="flows-canvas-btn" data-canvas-action="share">Share</button>
</div>
<svg class="flows-canvas-svg" id="flow-canvas">
<g id="canvas-transform">
<g id="edge-layer"></g>
<g id="wire-layer"></g>
<g id="node-layer"></g>
</g>
</svg>
<div class="flows-editor-panel" id="editor-panel"></div>
${this.renderAnalyticsPanel()}
<div class="flows-canvas-legend">
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:var(--rflows-edge-inflow)"></span>Inflow</span>
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:var(--rflows-edge-spending)"></span>Spending</span>
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:var(--rflows-edge-overflow)"></span>Overflow</span>
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:var(--rflows-status-critical);border-radius:50%"></span>Critical</span>
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:var(--rflows-status-sustained);border-radius:50%"></span>Sustained</span>
<span class="flows-canvas-legend-item"><span class="flows-canvas-legend-dot" style="background:var(--rflows-status-thriving);border-radius:50%"></span>Thriving</span>
</div>
<div class="flows-canvas-zoom">
<button class="flows-canvas-btn" data-canvas-action="zoom-in">+</button>
<button class="flows-canvas-btn" data-canvas-action="zoom-out">&minus;</button>
</div>
<div class="flows-sim-speed" id="sim-speed-container" style="display:${this.isSimulating ? "flex" : "none"}">
<input type="range" class="flows-speed-slider" id="sim-speed-slider" min="20" max="1000" value="${this.simSpeedMs}" step="10"/>
<span class="flows-speed-label" id="sim-speed-label">${this.simSpeedMs}ms</span>
</div>
<div class="flows-timeline" id="sim-timeline" style="display:${this.isSimulating ? "flex" : "none"}">
<div class="flows-timeline__track">
<div class="flows-timeline__fill" id="timeline-fill" style="width:0%"></div>
</div>
<span class="flows-timeline__tick" id="timeline-tick">Tick 0</span>
</div>
<div class="flows-node-tooltip" id="node-tooltip" style="display:none"></div>
</div>
${this.flowManagerOpen ? this.renderFlowManagerModal() : ''}`;
}
private renderFlowDropdownItems(): string {
const flows = this.getFlowList();
if (flows.length === 0) return '<div class="flows-dropdown__item" style="color:var(--rs-text-muted);pointer-events:none">No flows</div>';
return flows.map(f =>
`<button class="flows-dropdown__item ${f.id === this.currentFlowId ? 'flows-dropdown__item--active' : ''}" data-flow-switch="${this.esc(f.id)}">${this.esc(f.name)}</button>`
).join('');
}
private renderFlowManagerModal(): string {
const flows = this.getFlowList();
return `
<div class="flows-mgmt-overlay" id="flow-manager-overlay">
<div class="flows-mgmt-modal">
<div class="flows-mgmt__header">
<h2>Manage Flows</h2>
<button class="flows-mgmt__close" data-mgmt-action="close">&times;</button>
</div>
<div class="flows-mgmt__body">
${flows.length === 0
? '<div class="flows-mgmt__empty">No flows yet. Create one to get started.</div>'
: flows.map(f => `
<div class="flows-mgmt__row" data-mgmt-flow="${this.esc(f.id)}">
<div class="flows-mgmt__row-name">${this.esc(f.name)}</div>
<div class="flows-mgmt__row-meta">${f.nodeCount} nodes${f.updatedAt ? ' &middot; ' + new Date(f.updatedAt).toLocaleDateString() : ''}</div>
<div class="flows-mgmt__row-actions">
<button class="flows-mgmt__row-btn" data-mgmt-action="rename" data-mgmt-id="${this.esc(f.id)}" title="Rename">&#x270E;</button>
<button class="flows-mgmt__row-btn" data-mgmt-action="duplicate" data-mgmt-id="${this.esc(f.id)}" title="Duplicate">&#x2398;</button>
<button class="flows-mgmt__row-btn" data-mgmt-action="export" data-mgmt-id="${this.esc(f.id)}" title="Export JSON">&#x2B07;</button>
<button class="flows-mgmt__row-btn flows-mgmt__row-btn--danger" data-mgmt-action="delete" data-mgmt-id="${this.esc(f.id)}" title="Delete">&#x2715;</button>
</div>
</div>
`).join('')}
</div>
<div class="flows-mgmt__footer">
<button data-mgmt-action="import">Import JSON</button>
<button class="primary" data-mgmt-action="new">+ New Flow</button>
</div>
</div>
</div>`;
}
// ─── 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") {
const d = n.data as SourceNodeData;
const rate = d.flowRate || 100;
// Low rate → tall & thin (recurring drip), high rate → short & thick (large chunk)
const ratio = Math.min(1, rate / 5000);
const w = 140 + Math.round(ratio * 140); // 140280
const h = 110 - Math.round(ratio * 40); // 11070
return { w, h };
}
if (n.type === "funnel") {
const d = n.data as FunnelNodeData;
const baseW = 280, baseH = 250;
// Height scales with capacity (draggable), width stays fixed
const hRef = d.maxCapacity || 9000;
const hScale = 0.8 + Math.log10(Math.max(1, hRef / 5000)) * 0.35;
return { w: baseW, h: Math.round(baseH * Math.max(0.75, hScale)) };
}
return { w: 220, h: 120 }; // 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 + open inline editor
if (!wasDragged) {
this.selectedNodeId = clickedNodeId;
this.selectedEdgeKey = null;
this.updateSelectionHighlight();
this.enterInlineEdit(clickedNodeId);
} 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<string, { actual: number; needed: number; ratio: number }> {
const result = new Map<string, { actual: number; needed: number; ratio: number }>();
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, { actual: number; needed: number; ratio: number }>): 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 s = this.getNodeSize(n);
const x = n.position.x, y = n.position.y, w = s.w, h = s.h;
const icons: Record<string, string> = { card: "\u{1F4B3}", safe_wallet: "\u{1F512}", ridentity: "\u{1F464}", unconfigured: "\u{2699}" };
const icon = icons[d.sourceType] || "\u{1F4B0}";
const bodyH = h - 20;
const stubW = 26, stubH = 20;
const isSmall = d.flowRate < 1000;
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})">
<rect class="node-bg" x="0" y="0" width="${w}" height="${bodyH}" rx="12" style="fill:var(--rflows-source-bg)" stroke="${selected ? "var(--rflows-selected)" : "var(--rflows-source-border)"}" stroke-width="${selected ? 3 : 2.5}"/>
<rect x="${(w - stubW) / 2}" y="${bodyH}" width="${stubW}" height="${stubH}" rx="4" style="fill:var(--rflows-source-bg)" stroke="${selected ? "var(--rflows-selected)" : "var(--rflows-source-border)"}" stroke-width="1.5"/>
<text x="${isSmall ? w / 2 : 16}" y="${bodyH * 0.35}" ${isSmall ? 'text-anchor="middle"' : ""} style="fill:var(--rs-text-primary)" font-size="16">${icon}</text>
<text x="${w / 2}" y="${bodyH * 0.35 + (isSmall ? 18 : 0)}" text-anchor="middle" style="fill:var(--rs-text-primary)" font-size="14" font-weight="600">${this.esc(d.label)}</text>
<text x="${w / 2}" y="${bodyH * 0.75}" text-anchor="middle" style="fill:var(--rflows-source-rate)" font-size="12" font-weight="500">$${d.flowRate.toLocaleString()}/mo</text>
${this.renderAllocBar(d.targetAllocations, w, bodyH - 4)}
${this.renderPortsSvg(n)}
</g>`;
}
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 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" : "Sufficient";
// Tank shape parameters
const r = 12;
const pipeW = 30; // overflow pipe extension from wall
const basePipeH = 24; // base pipe height
const taperStart = 0.80; // body tapers at 80% down
// Drain width proportional to outflow: wider drain = more outflow
const outflow = d.desiredOutflow || 0;
const outflowRatio = Math.min(1, outflow / 3000);
const taperInset = 0.30 - outflowRatio * 0.18; // 0.30 (narrow/$0) → 0.12 (wide/$3000)
const insetPx = Math.round(w * taperInset);
const taperY = Math.round(h * taperStart);
const clipId = `funnel-clip-${n.id}`;
// Interior zone boundaries
const zoneTop = 36;
const zoneBot = h - 6;
const zoneH = zoneBot - zoneTop;
// Zone fractions for 3 zones: Critical (below min), Sufficient (min-max), Overflow (above max)
const minFrac = d.minThreshold / (d.maxCapacity || 1);
const maxFrac = d.maxThreshold / (d.maxCapacity || 1);
const criticalPct = minFrac;
const sufficientPct = maxFrac - minFrac;
const overflowPct = Math.max(0, 1 - maxFrac);
const criticalH = zoneH * criticalPct;
const sufficientH = zoneH * sufficientPct;
const overflowH = zoneH * overflowPct;
// Pipe position at max threshold line
const maxLineY = zoneTop + zoneH * (1 - maxFrac);
let pipeH = basePipeH;
let pipeY = Math.round(maxLineY - 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 + Math.round(excessRatio * 20);
pipeY = Math.round(maxLineY - pipeH / 2);
}
// 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(" ");
// Fill level
const totalFillH = zoneH * fillPct;
const fillY = zoneTop + zoneH - totalFillH;
// Threshold lines: only min and max (2 lines, 3 zones)
const minLineY = zoneTop + zoneH * (1 - minFrac);
const thresholdLines = `
<line class="threshold-line" x1="6" x2="${w - 6}" y1="${minLineY}" y2="${minLineY}" stroke="var(--rflows-status-critical)" stroke-width="2" stroke-dasharray="6 4" opacity="0.8"/>
<text x="10" y="${minLineY - 5}" style="fill:var(--rflows-status-critical)" font-size="10" font-weight="500" opacity="0.9">Min</text>
<line class="threshold-line" x1="6" x2="${w - 6}" y1="${maxLineY}" y2="${maxLineY}" stroke="var(--rflows-status-overflow)" stroke-width="2" stroke-dasharray="6 4" opacity="0.8"/>
<text x="10" y="${maxLineY - 5}" style="fill:var(--rflows-status-overflow)" font-size="10" font-weight="500" opacity="0.9">Max</text>`;
// Zone labels (centered in each zone)
const criticalMidY = zoneTop + zoneH - criticalH / 2;
const sufficientMidY = zoneTop + overflowH + sufficientH / 2;
const overflowMidY = zoneTop + overflowH / 2;
const zoneLabels = `
${criticalH > 20 ? `<text x="${w / 2}" y="${criticalMidY + 4}" text-anchor="middle" style="fill:var(--rflows-status-critical)" font-size="10" font-weight="600" opacity="0.5">CRITICAL</text>` : ""}
${sufficientH > 20 ? `<text x="${w / 2}" y="${sufficientMidY + 4}" text-anchor="middle" style="fill:var(--rflows-status-sustained)" font-size="10" font-weight="600" opacity="0.5">SUFFICIENT</text>` : ""}
${overflowH > 20 ? `<text x="${w / 2}" y="${overflowMidY + 4}" text-anchor="middle" style="fill:var(--rflows-status-overflow)" font-size="10" font-weight="600" opacity="0.5">OVERFLOW</text>` : ""}`;
// Inflow satisfaction bar
const satBarY = 50;
const satBarW = w - 48;
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 8px rgba(16,185,129,0.5))"
: !isCritical ? "filter: drop-shadow(0 0 8px 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 `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})" style="${glowStyle}">
<defs>
<clipPath id="${clipId}"><path d="${tankPath}"/></clipPath>
</defs>
${isOverflow ? `<path d="${tankPath}" fill="none" stroke="var(--rflows-status-overflow)" stroke-width="2.5" opacity="0.4" transform="translate(-2,-2) scale(${(w + 4) / w},${(h + 4) / h})"/>` : ""}
<path class="node-bg" d="${tankPath}" style="fill:var(--rs-bg-surface)" stroke="${selected ? "var(--rflows-selected)" : borderColorVar}" stroke-width="${selected ? 3.5 : 2.5}"/>
<g clip-path="url(#${clipId})">
<rect x="${-pipeW}" y="${zoneTop + overflowH + sufficientH}" width="${w + pipeW * 2}" height="${criticalH}" style="fill:var(--rflows-zone-drain);opacity:var(--rflows-zone-drain-opacity)"/>
<rect x="${-pipeW}" y="${zoneTop + overflowH}" width="${w + pipeW * 2}" height="${sufficientH}" style="fill:var(--rflows-zone-healthy);opacity:var(--rflows-zone-healthy-opacity)"/>
<rect x="${-pipeW}" y="${zoneTop}" width="${w + pipeW * 2}" height="${overflowH}" style="fill:var(--rflows-zone-overflow);opacity:var(--rflows-zone-overflow-opacity)"/>
<rect class="funnel-fill-rect" data-node-id="${n.id}" x="${-pipeW}" y="${fillY}" width="${w + pipeW * 2}" height="${totalFillH}" style="fill:${fillColor};opacity:var(--rflows-fill-opacity)"/>
${thresholdLines}
${zoneLabels}
</g>
<rect class="funnel-pipe ${isOverflow ? "funnel-pipe--active" : ""}" data-pipe="left" data-node-id="${n.id}" x="${-pipeW}" y="${pipeY}" width="${pipeW}" height="${pipeH}" rx="4" style="fill:${isOverflow ? "var(--rflows-status-overflow)" : "var(--rs-bg-surface-raised)"}" opacity="${isOverflow ? 0.85 : 0.3}"/>
<rect class="funnel-pipe ${isOverflow ? "funnel-pipe--active" : ""}" data-pipe="right" data-node-id="${n.id}" x="${w}" y="${pipeY}" width="${pipeW}" height="${pipeH}" rx="4" style="fill:${isOverflow ? "var(--rflows-status-overflow)" : "var(--rs-bg-surface-raised)"}" opacity="${isOverflow ? 0.85 : 0.3}"/>
<text x="${w / 2}" y="-10" text-anchor="middle" style="fill:var(--rflows-label-inflow)" font-size="12" font-weight="500" opacity="0.8">${inflowLabel}</text>
<text x="${w / 2}" y="20" text-anchor="middle" style="fill:var(--rs-text-primary)" font-size="16" font-weight="600">${this.esc(d.label)}</text>
<text x="${w - 12}" y="20" text-anchor="end" style="fill:${borderColorVar}" font-size="12" font-weight="500" class="${!isCritical ? "sufficiency-glow" : ""}">${statusLabel}</text>
<rect x="24" y="${satBarY}" width="${satBarW}" height="8" rx="4" style="fill:var(--rs-bg-surface-raised)" opacity="0.3" class="satisfaction-bar-bg"/>
<rect x="24" y="${satBarY}" width="${satFillW}" height="8" rx="4" style="fill:var(--rflows-sat-bar)" class="satisfaction-bar-fill" ${satBarBorder}/>
<text x="${w / 2}" y="${satBarY + 20}" text-anchor="middle" style="fill:var(--rs-text-muted)" font-size="11">${satLabel}</text>
<text class="funnel-value-text" data-node-id="${n.id}" x="${w / 2}" y="${h - insetPx - 10}" text-anchor="middle" style="fill:var(--rs-text-secondary)" font-size="14" font-weight="500">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.maxThreshold).toLocaleString()}</text>
<rect class="funnel-valve-bar" x="${insetPx + 2}" y="${h - 10}" width="${w - insetPx * 2 - 4}" height="8" rx="3" style="fill:var(--rflows-label-spending);opacity:0.6;cursor:ew-resize"/>
<text x="${w / 2}" y="${h + 18}" text-anchor="middle" style="fill:var(--rflows-label-spending)" font-size="12" font-weight="600" opacity="0.9">${this.formatDollar(outflow)}/mo ▾</text>
${isOverflow ? `<text x="${-pipeW - 6}" y="${pipeY + pipeH / 2 + 4}" text-anchor="end" style="fill:var(--rflows-label-overflow)" font-size="11" font-weight="500" opacity="0.8">${overflowLabel}</text>
<text x="${w + pipeW + 6}" y="${pipeY + pipeH / 2 + 4}" text-anchor="start" style="fill:var(--rflows-label-overflow)" font-size="11" font-weight="500" opacity="0.8">${overflowLabel}</text>` : ""}
${this.renderPortsSvg(n)}
</g>`;
}
private renderOutcomeNodeSvg(n: FlowNode, selected: boolean, sat?: { actual: number; needed: number; ratio: number }): string {
const d = n.data as OutcomeNodeData;
const s = this.getNodeSize(n);
const x = n.position.x, y = n.position.y, w = s.w, h = s.h;
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 `<rect x="${10 + i * phaseW}" y="75" width="${phaseW - 2}" height="6" rx="2" style="fill:${unlocked ? "var(--rflows-phase-unlocked)" : "var(--rs-bg-surface-raised)"}" opacity="${unlocked ? 0.8 : 0.5}"/>`;
}).join("");
phaseBars += `<text x="${w / 2}" y="93" text-anchor="middle" style="fill:var(--rs-text-muted)" font-size="9">${d.phases.filter((p) => d.fundingReceived >= p.fundingThreshold).length}/${d.phases.length} phases</text>`;
}
const dollarLabel = `${this.formatDollar(d.fundingReceived)} / ${this.formatDollar(d.fundingTarget)}`;
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})">
<defs>
<clipPath id="${clipId}"><path d="${basinPath}"/></clipPath>
</defs>
<path class="node-bg" d="${basinPath}" style="fill:var(--rs-bg-surface)" stroke="${selected ? "var(--rflows-selected)" : statusColorVar}" stroke-width="${selected ? 3 : 1.5}"/>
<g clip-path="url(#${clipId})">
<rect class="basin-fill-rect" x="${-flare}" y="${fillY}" width="${w + flare * 2}" height="${fillH}" style="fill:${statusColorVar};opacity:0.25"/>
</g>
<circle cx="14" cy="18" r="5" style="fill:${statusColorVar}" opacity="0.7"/>
<text x="26" y="22" style="fill:var(--rs-text-primary)" font-size="12" font-weight="600">${this.esc(d.label)}</text>
<text x="${w / 2}" y="${fillY > 50 ? fillY - 4 : 50}" text-anchor="middle" style="fill:var(--rs-text-secondary)" font-size="10">${Math.round(fillPct * 100)}% — ${dollarLabel}</text>
${phaseBars}
${this.renderPortsSvg(n)}
</g>`;
}
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 += `<rect x="${cx}" y="${y}" width="${segW}" height="3" rx="1" fill="${a.color}" opacity="0.7"/>`;
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 = 28;
const MIN_EDGE_W = 3;
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 = `<path d="${d}" fill="none" stroke="transparent" stroke-width="${Math.max(12, strokeW * 3)}" class="edge-hit-area" style="cursor:pointer"/>`;
if (ghost) {
return `<g class="edge-group" data-from="${fromId}" data-to="${toId}" data-edge-type="${edgeType}">
${hitPath}
<path d="${d}" fill="none" style="stroke:${color}" stroke-width="1" stroke-opacity="0.2" stroke-dasharray="4 6" class="edge-ghost"/>
<g class="edge-ctrl-group" transform="translate(${midX},${midY})">
<rect x="-34" y="-12" width="68" height="24" rx="6" style="fill:var(--rs-bg-surface);stroke:var(--rs-bg-surface-raised)" stroke-width="1" opacity="0.5"/>
<text x="-14" y="5" style="fill:${color}" font-size="11" font-weight="600" text-anchor="middle" opacity="0.5">${label}</text>
<g data-edge-action="dec" data-edge-from="${fromId}" data-edge-to="${toId}" data-edge-type="${edgeType}" style="cursor:pointer">
<rect x="-32" y="-9" width="16" height="18" rx="3" style="fill:var(--rs-bg-surface-raised)"/>
<text x="-24" y="5" style="fill:var(--rs-text-primary)" font-size="13" text-anchor="middle">&minus;</text>
</g>
<g data-edge-action="inc" data-edge-from="${fromId}" data-edge-to="${toId}" data-edge-type="${edgeType}" style="cursor:pointer">
<rect x="16" y="-9" width="16" height="18" rx="3" style="fill:var(--rs-bg-surface-raised)"/>
<text x="24" y="5" style="fill:var(--rs-text-primary)" font-size="13" text-anchor="middle">+</text>
</g>
</g>
</g>`;
}
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 = `<circle cx="${midX}" cy="${midY - 18}" r="5" class="edge-drag-handle"/>`;
return `<g class="edge-group" data-from="${fromId}" data-to="${toId}" data-edge-type="${edgeType}">
${hitPath}
<path d="${d}" fill="none" style="stroke:${color}" stroke-width="${finalStrokeW * 2.5}" stroke-opacity="0.12" class="edge-glow"/>
<path d="${d}" fill="none" style="stroke:${color}" stroke-width="${finalStrokeW}" stroke-opacity="0.8" stroke-linecap="round" class="${animClass}"/>
${dashed ? `<circle cx="${x1}" cy="${y1}" r="${Math.max(4, finalStrokeW * 0.6)}" style="fill:${color}" opacity="0.5" class="edge-splash"><animate attributeName="r" values="${Math.max(4, finalStrokeW * 0.6)};${Math.max(8, finalStrokeW)};${Math.max(4, finalStrokeW * 0.6)}" dur="1.2s" repeatCount="indefinite"/><animate attributeName="opacity" values="0.5;0.2;0.5" dur="1.2s" repeatCount="indefinite"/></circle>` : ""}
${dragHandle}
<g class="edge-ctrl-group" transform="translate(${midX},${midY})">
<rect x="${-halfW}" y="-12" width="${labelW}" height="24" rx="6" style="fill:var(--rs-bg-surface);stroke:var(--rs-bg-surface-raised)" stroke-width="1" opacity="0.9"/>
<text x="0" y="5" style="fill:${color}" font-size="10" font-weight="600" text-anchor="middle">${label}</text>
<g data-edge-action="dec" data-edge-from="${fromId}" data-edge-to="${toId}" data-edge-type="${edgeType}" style="cursor:pointer">
<rect x="${-halfW + 2}" y="-9" width="16" height="18" rx="3" style="fill:var(--rs-bg-surface-raised)"/>
<text x="${-halfW + 10}" y="5" style="fill:var(--rs-text-primary)" font-size="13" text-anchor="middle">&minus;</text>
</g>
<g data-edge-action="inc" data-edge-from="${fromId}" data-edge-to="${toId}" data-edge-type="${edgeType}" style="cursor:pointer">
<rect x="${halfW - 18}" y="-9" width="16" height="18" rx="3" style="fill:var(--rs-bg-surface-raised)"/>
<text x="${halfW - 10}" y="5" style="fill:var(--rs-text-primary)" font-size="13" text-anchor="middle">+</text>
</g>
</g>
</g>`;
}
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 at max threshold line
if (node.type === "funnel" && portKind === "overflow" && def.side) {
const d = node.data as FunnelNodeData;
const h = s.h;
const zoneTop = 36, zoneBot = h - 6, zoneH = zoneBot - zoneTop;
const maxFrac = d.maxThreshold / (d.maxCapacity || 1);
const maxLineY = zoneTop + zoneH * (1 - maxFrac);
return { x: node.position.x + s.w * def.xFrac, y: node.position.y + maxLineY };
}
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 = `<path class="port-arrow" d="M ${cx - 4} ${cy - 3} l -4 3 l 4 3" style="fill:${p.color}" opacity="0.7"/>`;
} else {
arrow = `<path class="port-arrow" d="M ${cx + 4} ${cy - 3} l 4 3 l -4 3" style="fill:${p.color}" opacity="0.7"/>`;
}
} else if (p.dir === "out") {
arrow = `<path class="port-arrow" d="M ${cx - 3} ${cy + (p.yFrac === 0 ? -8 : 4)} l 3 4 l 3 -4" style="fill:${p.color}" opacity="0.7"/>`;
} else {
arrow = `<path class="port-arrow" d="M ${cx - 3} ${cy + (p.yFrac === 0 ? -4 : 8)} l 3 -4 l 3 4" style="fill:${p.color}" opacity="0.7"/>`;
}
return `<g class="port-group" data-port-kind="${p.kind}" data-port-dir="${p.dir}" data-node-id="${n.id}"${sideAttr}>
<circle class="port-hit" cx="${cx}" cy="${cy}" r="12" fill="transparent"/>
<circle class="port-dot" cx="${cx}" cy="${cy}" r="5" style="fill:${p.color};color:${p.color}"/>
${arrow}
</g>`;
}).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 = `<path class="wiring-temp-path" d="${tempPath}"/>`;
}
// ─── 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 = `<div class="editor-header">
<span class="editor-title">${this.esc((node.data as any).label || node.type)}</span>
<button class="editor-close" data-editor-action="close">&times;</button>
</div>`;
if (node.type === "source") content += this.renderSourceEditor(node);
else if (node.type === "funnel") content += this.renderFunnelEditor(node);
else content += this.renderOutcomeEditor(node);
content += `<div class="editor-section">
<button class="editor-btn editor-btn--danger" data-editor-action="delete">Delete Node</button>
</div>`;
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");
// Funnels: drag handles instead of config panel
if (node.type === "funnel") {
const d = node.data as FunnelNodeData;
const outflow = d.desiredOutflow || 0;
const outflowRatio = Math.min(1, outflow / 3000);
const valveInset = 0.30 - outflowRatio * 0.18;
const valveInsetPx = Math.round(s.w * valveInset);
const drainWidth = s.w - 2 * valveInsetPx;
overlay.innerHTML = `
<rect class="valve-drag-handle" x="${valveInsetPx - 8}" y="${s.h - 16}" width="${drainWidth + 16}" height="18" rx="5"
style="fill:var(--rflows-label-spending);cursor:ew-resize;opacity:0.85;stroke:white;stroke-width:1.5"/>
<text class="valve-drag-label" x="${s.w / 2}" y="${s.h - 4}" text-anchor="middle" fill="white" font-size="11" font-weight="600" pointer-events="none">
${this.formatDollar(outflow)}/mo ▷
</text>
<rect class="height-drag-handle" x="${s.w / 2 - 28}" y="${s.h + 22}" width="56" height="12" rx="5"
style="fill:var(--rs-border-strong);cursor:ns-resize;opacity:0.6;stroke:var(--rs-text-muted);stroke-width:1"/>
<text class="height-drag-label" x="${s.w / 2}" y="${s.h + 31}" text-anchor="middle" style="fill:var(--rs-text-muted)" font-size="9" font-weight="500" pointer-events="none">⇕ capacity</text>`;
g.appendChild(overlay);
this.attachFunnelDragListeners(overlay, node, s);
// Click-outside handler
const clickOutsideHandler = (e: Event) => {
const target = e.target as Element;
if (!target.closest(`[data-node-id="${node.id}"]`)) {
this.exitInlineEdit();
this.shadow.removeEventListener("pointerdown", clickOutsideHandler, true);
}
};
setTimeout(() => {
this.shadow.addEventListener("pointerdown", clickOutsideHandler, true);
}, 100);
return;
}
// Source/outcome: keep config panel — use DOM APIs for proper namespace handling
const panelW = 280;
const panelH = 260;
const panelX = s.w + 12;
const panelY = 0;
const fo = document.createElementNS("http://www.w3.org/2000/svg", "foreignObject");
fo.setAttribute("x", String(panelX));
fo.setAttribute("y", String(panelY));
fo.setAttribute("width", String(panelW));
fo.setAttribute("height", String(panelH));
const panelDiv = document.createElement("div");
panelDiv.className = "inline-config-panel";
panelDiv.style.height = `${panelH}px`;
panelDiv.innerHTML = `
<div class="icp-tabs">
<button class="icp-tab icp-tab--active" data-icp-tab="config">Config</button>
<button class="icp-tab" data-icp-tab="analytics">Analytics</button>
<button class="icp-tab" data-icp-tab="allocations">Alloc</button>
</div>
<div class="icp-body">${this.renderInlineConfigContent(node)}</div>
<div class="icp-toolbar">
<button class="iet-done" style="background:var(--rflows-btn-done);color:white">Done</button>
<button class="iet-delete" style="background:var(--rflows-btn-delete);color:white">Delete</button>
<button class="iet-panel" style="background:var(--rs-border-strong);color:var(--rs-text-primary)">...</button>
</div>`;
fo.appendChild(panelDiv);
overlay.appendChild(fo);
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 = `
<div class="icp-field"><label class="icp-label">Label</label>
<input class="icp-input" data-icp-field="label" value="${this.esc(d.label)}"/></div>
<div class="icp-field"><label class="icp-label">Flow Rate ($/mo)</label>
<input class="icp-input" data-icp-field="flowRate" type="number" value="${d.flowRate}"/></div>
<div class="icp-field"><label class="icp-label">Source Type</label>
<select class="icp-select" data-icp-field="sourceType">
${["card", "safe_wallet", "ridentity", "unconfigured"].map((t) => `<option value="${t}" ${d.sourceType === t ? "selected" : ""}>${t}</option>`).join("")}
</select></div>`;
if (d.sourceType === "card") {
html += `<button class="icp-fund-btn" data-icp-action="fund">Fund with Card</button>`;
}
return html;
}
private renderFunnelConfigTab(node: FlowNode): string {
const d = node.data as FunnelNodeData;
const outflow = d.desiredOutflow || 0;
return `
<div class="icp-field"><label class="icp-label">Label</label>
<input class="icp-input" data-icp-field="label" value="${this.esc(d.label)}"/></div>
<div class="icp-range-group">
<span class="icp-range-label">$/mo</span>
<input class="icp-range" type="range" min="0" max="3000" step="50" value="${outflow}" data-icp-outflow="desiredOutflow"/>
<span class="icp-range-value">${this.formatDollar(outflow)}</span>
</div>
<div class="icp-derived-info">
<div class="icp-derived-row"><span class="icp-derived-dot" style="background:var(--rflows-status-critical)"></span>Min (1mo): ${this.formatDollar(d.minThreshold)}</div>
<div class="icp-derived-row"><span class="icp-derived-dot" style="background:var(--rflows-status-sustained)"></span>Sufficient (4mo): ${this.formatDollar(d.sufficientThreshold ?? d.maxThreshold)}</div>
<div class="icp-derived-row"><span class="icp-derived-dot" style="background:var(--rflows-status-thriving)"></span>Overflow (6mo): ${this.formatDollar(d.maxThreshold)}</div>
</div>`;
}
private renderOutcomeConfigTab(node: FlowNode): string {
const d = node.data as OutcomeNodeData;
return `
<div class="icp-field"><label class="icp-label">Label</label>
<input class="icp-input" data-icp-field="label" value="${this.esc(d.label)}"/></div>
<div class="icp-field"><label class="icp-label">Description</label>
<input class="icp-input" data-icp-field="description" value="${this.esc(d.description || "")}"/></div>
<div class="icp-field"><label class="icp-label">Funding Target ($)</label>
<input class="icp-input" data-icp-field="fundingTarget" type="number" value="${d.fundingTarget}"/></div>
<div class="icp-field"><label class="icp-label">Status</label>
<select class="icp-select" data-icp-field="status">
${["not-started", "in-progress", "completed", "blocked"].map((s) => `<option value="${s}" ${d.status === s ? "selected" : ""}>${s}</option>`).join("")}
</select></div>`;
}
// ── 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 `
<div class="icp-analytics-row">
<div class="icp-analytics-label"><span>Fill Level</span><span>${fillPct}%</span></div>
<div class="icp-analytics-bar"><div class="icp-analytics-fill" style="width:${fillPct}%;background:${fillColor}"></div></div>
</div>
<div style="margin-bottom:6px">
<span class="icp-suf-badge icp-suf-badge--${suf}">${suf}</span>
</div>
${totalOut > 0 ? `
<div class="icp-proportion">
<div class="icp-proportion-ring" style="background:conic-gradient(#10b981 0% ${outflowPct}%, #f59e0b ${outflowPct}% 100%)"></div>
<div class="icp-proportion-legend">
<div class="icp-proportion-item"><span class="icp-proportion-dot" style="background:#10b981"></span>Outflow ${outflowPct}%</div>
<div class="icp-proportion-item"><span class="icp-proportion-dot" style="background:#f59e0b"></span>Overflow ${overflowPct}%</div>
</div>
</div>` : ""}
<div class="icp-stat-row"><span>Current Value</span><span class="icp-stat-value">${this.formatDollar(d.currentValue)}</span></div>
<div class="icp-stat-row"><span>Peak Value</span><span class="icp-stat-value">${this.formatDollar(stats?.peakValue || d.currentValue)}</span></div>
<div class="icp-stat-row"><span>Avg Fill</span><span class="icp-stat-value">${this.formatDollar(stats?.avgFillLevel || d.currentValue)}</span></div>
<div class="icp-stat-row"><span>Total Inflow</span><span class="icp-stat-value">${this.formatDollar(stats?.totalInflow || 0)}</span></div>`;
}
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 `
<div class="icp-analytics-row">
<div class="icp-analytics-label"><span>Funding Progress</span><span>${progressPct}%</span></div>
<div class="icp-analytics-bar"><div class="icp-analytics-fill" style="width:${progressPct}%;background:#10b981"></div></div>
</div>
<div class="icp-stat-row"><span>Received</span><span class="icp-stat-value">${this.formatDollar(d.fundingReceived)}</span></div>
<div class="icp-stat-row"><span>Target</span><span class="icp-stat-value">${this.formatDollar(d.fundingTarget)}</span></div>
${phasesTotal > 0 ? `<div class="icp-stat-row"><span>Phases</span><span class="icp-stat-value">${phasesAchieved} / ${phasesTotal}</span></div>` : ""}
<div class="icp-stat-row"><span>Total Inflow</span><span class="icp-stat-value">${this.formatDollar(stats?.totalInflow || 0)}</span></div>`;
}
// Source
const d = node.data as SourceNodeData;
return `
<div class="icp-stat-row"><span>Flow Rate</span><span class="icp-stat-value">${this.formatDollar(d.flowRate)}/mo</span></div>
<div class="icp-stat-row"><span>Total Dispensed</span><span class="icp-stat-value">${this.formatDollar(stats?.totalOutflow || 0)}</span></div>
${d.targetAllocations.length > 0 ? `<div style="margin-top:8px;font-size:10px;color:var(--rs-text-muted);text-transform:uppercase;font-weight:600;margin-bottom:4px">Allocation Breakdown</div>
${d.targetAllocations.map((a) => `<div class="icp-alloc-row">
<span class="icp-alloc-dot" style="background:${a.color}"></span>
<span style="flex:1">${this.esc(this.getNodeLabel(a.targetId))}</span>
<span style="font-weight:600">${a.percentage}%</span>
</div>`).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 = `<div style="font-size:10px;color:var(--rs-text-muted);text-transform:uppercase;font-weight:600;margin-bottom:4px">${title}</div>`;
for (const a of allocs) {
html += `<div class="icp-alloc-row">
<span class="icp-alloc-dot" style="background:${a.color}"></span>
<span style="flex:1">${this.esc(this.getNodeLabel(a.targetId))}</span>
<span style="font-weight:600">${a.percentage}%</span>
</div>`;
}
return html;
};
if (node.type === "source") {
const d = node.data as SourceNodeData;
const html = renderRows("Target Allocations", d.targetAllocations);
return html || '<div class="icp-empty">No allocations configured</div>';
}
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 || '<div class="icp-empty">No allocations configured</div>';
}
const od = node.data as OutcomeNodeData;
const html = renderRows("Overflow Allocations", od.overflowAllocations || []);
return html || '<div class="icp-empty">No allocations configured</div>';
}
// ── Funnel drag handles (valve width + tank height) ──
private attachFunnelDragListeners(overlay: Element, node: FlowNode, s: { w: number; h: number }) {
const valveHandle = overlay.querySelector(".valve-drag-handle");
const heightHandle = overlay.querySelector(".height-drag-handle");
// Valve drag (horizontal → desiredOutflow)
if (valveHandle) {
valveHandle.addEventListener("pointerdown", (e: Event) => {
const pe = e as PointerEvent;
pe.stopPropagation();
pe.preventDefault();
const startX = pe.clientX;
const fd = node.data as FunnelNodeData;
const startOutflow = fd.desiredOutflow || 0;
(valveHandle as Element).setPointerCapture(pe.pointerId);
const onMove = (ev: Event) => {
const me = ev as PointerEvent;
const deltaX = (me.clientX - startX) / this.canvasZoom;
let newOutflow = Math.round((startOutflow + (deltaX / s.w) * 3000) / 50) * 50;
newOutflow = Math.max(0, Math.min(3000, newOutflow));
fd.desiredOutflow = newOutflow;
fd.minThreshold = newOutflow;
fd.maxThreshold = newOutflow * 6;
if (fd.maxCapacity < fd.maxThreshold * 1.5) {
fd.maxCapacity = Math.round(fd.maxThreshold * 1.5);
}
// Update label text only during drag
const label = overlay.querySelector(".valve-drag-label");
if (label) label.textContent = `${this.formatDollar(newOutflow)}/mo ▷`;
};
const onUp = () => {
valveHandle.removeEventListener("pointermove", onMove);
valveHandle.removeEventListener("pointerup", onUp);
valveHandle.removeEventListener("lostpointercapture", onUp);
// Full redraw with new shape
this.drawCanvasContent();
this.redrawEdges();
this.enterInlineEdit(node.id);
this.scheduleSave();
};
valveHandle.addEventListener("pointermove", onMove);
valveHandle.addEventListener("pointerup", onUp);
valveHandle.addEventListener("lostpointercapture", onUp);
});
}
// Height drag (vertical → maxCapacity)
if (heightHandle) {
heightHandle.addEventListener("pointerdown", (e: Event) => {
const pe = e as PointerEvent;
pe.stopPropagation();
pe.preventDefault();
const startY = pe.clientY;
const fd = node.data as FunnelNodeData;
const startCapacity = fd.maxCapacity || 9000;
(heightHandle as Element).setPointerCapture(pe.pointerId);
const onMove = (ev: Event) => {
const me = ev as PointerEvent;
const deltaY = (me.clientY - startY) / this.canvasZoom;
// Down = more capacity, up = less
let newCapacity = Math.round((startCapacity + deltaY * 80) / 500) * 500;
newCapacity = Math.max(Math.round(fd.maxThreshold * 1.2), Math.min(50000, newCapacity));
fd.maxCapacity = newCapacity;
// Update label
const label = overlay.querySelector(".height-drag-label");
if (label) label.textContent = `${this.formatDollar(newCapacity)}`;
};
const onUp = () => {
heightHandle.removeEventListener("pointermove", onMove);
heightHandle.removeEventListener("pointerup", onUp);
heightHandle.removeEventListener("lostpointercapture", onUp);
this.drawCanvasContent();
this.redrawEdges();
this.enterInlineEdit(node.id);
this.scheduleSave();
};
heightHandle.addEventListener("pointermove", onMove);
heightHandle.addEventListener("pointerup", onUp);
heightHandle.addEventListener("lostpointercapture", onUp);
});
}
}
// ── 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 = 36;
const zoneBot = s.h - 6;
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: "maxThreshold", value: d.maxThreshold, color: "var(--rflows-status-overflow)", label: "Max" },
];
for (const t of thresholds) {
const frac = t.value / (d.maxCapacity || 1);
const markerY = zoneTop + zoneH * (1 - frac);
overlay.innerHTML += `
<line class="threshold-marker" x1="8" x2="${s.w - 8}" y1="${markerY}" y2="${markerY}" style="stroke:${t.color}" stroke-width="2" stroke-dasharray="4 2"/>
<rect class="threshold-handle" x="${s.w - 56}" y="${markerY - 9}" width="52" height="18" rx="4" style="fill:${t.color};cursor:ns-resize" data-threshold="${t.key}"/>
<text x="${s.w - 30}" y="${markerY + 4}" fill="white" font-size="9" text-anchor="middle" pointer-events="none">${t.label} ${this.formatDollar(t.value)}</text>`;
}
}
// ── Inline config listeners ──
private attachInlineConfigListeners(g: SVGGElement, node: FlowNode) {
const overlay = g.querySelector(".inline-edit-overlay");
if (!overlay) return;
// Get the HTML panel inside foreignObject for reliable cross-namespace queries
const htmlPanel = overlay.querySelector("foreignObject")?.querySelector(".inline-config-panel") as HTMLElement | null;
const queryRoot = htmlPanel || overlay;
// Tab switching
queryRoot.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;
queryRoot.querySelectorAll(".icp-tab").forEach((t) => t.classList.remove("icp-tab--active"));
el.classList.add("icp-tab--active");
const body = queryRoot.querySelector(".icp-body") as HTMLElement;
if (body) body.innerHTML = this.renderInlineConfigContent(node);
this.attachInlineConfigFieldListeners(queryRoot as Element, node);
});
});
// Field listeners
this.attachInlineConfigFieldListeners(queryRoot, node);
// Threshold drag handles (funnel — on SVG overlay, not HTML panel)
this.attachThresholdDragListeners(overlay, node);
// Done button
queryRoot.querySelector(".iet-done")?.addEventListener("click", (e: Event) => {
e.stopPropagation();
this.exitInlineEdit();
});
// Delete button
queryRoot.querySelector(".iet-delete")?.addEventListener("click", (e: Event) => {
e.stopPropagation();
this.deleteNode(node.id);
this.exitInlineEdit();
});
// "..." panel button
queryRoot.querySelector(".iet-panel")?.addEventListener("click", (e: Event) => {
e.stopPropagation();
this.exitInlineEdit();
this.openEditor(node.id);
});
// Fund Now button (source card type)
const fundBtn = queryRoot.querySelector("[data-icp-action='fund']");
fundBtn?.addEventListener("click", (e: Event) => {
e.stopPropagation();
const sd = node.data as SourceNodeData;
const flowId = this.flowId || this.getAttribute("flow-id") || "";
// Use configured wallet or demo default (Transak staging accepts any valid address)
const wallet = sd.walletAddress || "0x0000000000000000000000000000000000000000";
this.openTransakWidget(flowId, wallet).catch((err) => console.error("[Transak] Error:", err));
});
// Click-outside handler — listen on shadow root to avoid retargeting issues
const clickOutsideHandler = (e: Event) => {
const target = e.target as Element;
if (!target.closest(`[data-node-id="${node.id}"]`) && !target.closest(".inline-config-panel")) {
this.exitInlineEdit();
this.shadow.removeEventListener("pointerdown", clickOutsideHandler, true);
}
};
setTimeout(() => {
this.shadow.addEventListener("pointerdown", clickOutsideHandler, 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);
});
});
// Outflow slider — auto-derives all thresholds
overlay.querySelectorAll("[data-icp-outflow]").forEach((el) => {
const input = el as HTMLInputElement;
input.addEventListener("input", () => {
const val = parseFloat(input.value) || 0;
const fd = node.data as FunnelNodeData;
fd.desiredOutflow = val;
const derived = deriveThresholds(val);
fd.minThreshold = derived.minThreshold;
fd.sufficientThreshold = derived.sufficientThreshold;
fd.maxThreshold = derived.maxThreshold;
fd.maxCapacity = derived.maxCapacity;
const valueSpan = input.parentElement?.querySelector(".icp-range-value") as HTMLElement;
if (valueSpan) valueSpan.textContent = this.formatDollar(val);
// Update derived info display
const info = overlay.querySelector(".icp-derived-info");
if (info) {
const rows = info.querySelectorAll(".icp-derived-row");
if (rows[0]) rows[0].innerHTML = `<span class="icp-derived-dot" style="background:var(--rflows-status-critical)"></span>Min (1mo): ${this.formatDollar(derived.minThreshold)}`;
if (rows[1]) rows[1].innerHTML = `<span class="icp-derived-dot" style="background:var(--rflows-status-sustained)"></span>Sufficient (4mo): ${this.formatDollar(derived.sufficientThreshold)}`;
if (rows[2]) rows[2].innerHTML = `<span class="icp-derived-dot" style="background:var(--rflows-status-thriving)"></span>Overflow (6mo): ${this.formatDollar(derived.maxThreshold)}`;
}
this.redrawNodeOnly(node);
this.redrawEdges();
this.redrawThresholdMarkers(node);
this.scheduleSave();
});
});
}
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 - 6 - 36;
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();
// Re-enter inline edit to show appropriate handles/panel
this.enterInlineEdit(node.id);
}
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 = `
<div class="editor-field"><label class="editor-label">Label</label>
<input class="editor-input" data-field="label" value="${this.esc(d.label)}"/></div>
<div class="editor-field"><label class="editor-label">Flow Rate ($/mo)</label>
<input class="editor-input" data-field="flowRate" type="number" value="${d.flowRate}"/></div>
<div class="editor-field"><label class="editor-label">Source Type</label>
<select class="editor-select" data-field="sourceType">
${["card", "safe_wallet", "ridentity", "unconfigured"].map((t) => `<option value="${t}" ${d.sourceType === t ? "selected" : ""}>${t}</option>`).join("")}
</select></div>`;
if (d.sourceType === "card") {
html += `<div class="editor-field" style="margin-top:12px">
<button class="editor-btn fund-card-btn" data-action="fund-with-card"
style="width:100%;padding:10px;background:var(--rs-primary);color:white;border:none;border-radius:8px;cursor:pointer;font-weight:600">
Fund with Card
</button>
</div>`;
}
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 `
<div class="editor-field"><label class="editor-label">Label</label>
<input class="editor-input" data-field="label" value="${this.esc(d.label)}"/></div>
<div class="editor-field"><label class="editor-label">Desired Outflow ($/mo)</label>
<input class="editor-input" data-field="desiredOutflow" type="number" value="${d.desiredOutflow ?? 0}"/></div>
<div class="editor-field"><label class="editor-label">Current Value ($)</label>
<input class="editor-input" data-field="currentValue" type="number" value="${d.currentValue}"/></div>
<div class="editor-field"><label class="editor-label">Expected Inflow ($/mo)</label>
<input class="editor-input" data-field="inflowRate" type="number" value="${d.inflowRate}"/></div>
<div class="editor-section">
<div class="editor-section-title">Thresholds ${derived ? "(auto-derived from outflow)" : ""}</div>
<div class="editor-field"><label class="editor-label">Min (1mo)${derived ? `${this.formatDollar(derived.minThreshold)}` : ""}</label>
<input class="editor-input" data-field="minThreshold" type="number" value="${d.minThreshold}"/></div>
<div class="editor-field"><label class="editor-label">Sufficient (4mo)${derived ? `${this.formatDollar(derived.sufficientThreshold)}` : ""}</label>
<input class="editor-input" data-field="sufficientThreshold" type="number" value="${d.sufficientThreshold ?? d.maxThreshold}"/></div>
<div class="editor-field"><label class="editor-label">Overflow (6mo)${derived ? `${this.formatDollar(derived.maxThreshold)}` : ""}</label>
<input class="editor-input" data-field="maxThreshold" type="number" value="${d.maxThreshold}"/></div>
<div class="editor-field"><label class="editor-label">Max Capacity (9mo)${derived ? `${this.formatDollar(derived.maxCapacity)}` : ""}</label>
<input class="editor-input" data-field="maxCapacity" type="number" value="${d.maxCapacity}"/></div>
</div>
${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 = `
<div class="editor-field"><label class="editor-label">Label</label>
<input class="editor-input" data-field="label" value="${this.esc(d.label)}"/></div>
<div class="editor-field"><label class="editor-label">Description</label>
<input class="editor-input" data-field="description" value="${this.esc(d.description || "")}"/></div>
<div class="editor-field"><label class="editor-label">Funding Received ($)</label>
<input class="editor-input" data-field="fundingReceived" type="number" value="${d.fundingReceived}"/></div>
<div class="editor-field"><label class="editor-label">Funding Target ($)</label>
<input class="editor-input" data-field="fundingTarget" type="number" value="${d.fundingTarget}"/></div>
<div class="editor-field"><label class="editor-label">Status</label>
<select class="editor-select" data-field="status">
${["not-started", "in-progress", "completed", "blocked"].map((s) => `<option value="${s}" ${d.status === s ? "selected" : ""}>${s}</option>`).join("")}
</select></div>`;
if (d.phases && d.phases.length > 0) {
html += `<div class="editor-section"><div class="editor-section-title">Phases</div>`;
for (const p of d.phases) {
const unlocked = d.fundingReceived >= p.fundingThreshold;
html += `<div style="margin-bottom:6px;padding:6px;background:var(--rs-bg-surface-sunken);border-radius:6px;border-left:3px solid ${unlocked ? "var(--rflows-phase-unlocked)" : "var(--rs-border-strong)"}">
<div style="font-size:12px;font-weight:600;color:${unlocked ? "var(--rs-success)" : "var(--rs-text-muted)"}">${this.esc(p.name)}$${p.fundingThreshold.toLocaleString()}</div>
${p.tasks.map((t) => `<div style="font-size:11px;color:var(--rs-text-secondary);margin-top:2px">${t.completed ? "&#x2705;" : "&#x2B1C;"} ${this.esc(t.label)}</div>`).join("")}
</div>`;
}
html += `</div>`;
}
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 = `<div class="editor-section"><div class="editor-section-title">${title}</div>`;
for (const a of allocs) {
html += `<div class="editor-alloc-row">
<span class="editor-alloc-dot" style="background:${a.color}"></span>
<span style="flex:1">${this.esc(this.getNodeLabel(a.targetId))}</span>
<span style="font-weight:600;color:var(--rs-text-primary)">${a.percentage}%</span>
</div>`;
}
html += `</div>`;
return html;
}
private async openTransakWidget(flowId: string, walletAddress: string) {
console.log("[Transak] Opening widget for flow:", flowId, "wallet:", walletAddress);
// 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 = `
<div style="position:relative;width:450px;height:680px;border-radius:16px;overflow:hidden;background:var(--rs-bg-surface)">
<button id="transak-close" style="position:absolute;top:8px;right:12px;z-index:10;
background:none;border:none;color:white;font-size:24px;cursor:pointer">&times;</button>
<iframe src="${baseUrl}?${params}" style="width:100%;height:100%;border:none"
allow="camera;microphone;payment" sandbox="allow-scripts allow-same-origin allow-popups allow-forms"></iframe>
</div>`;
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 = `<div class="flows-node-tooltip__label">${this.esc((node.data as any).label)}</div>`;
if (node.type === "source") {
const d = node.data as SourceNodeData;
html += `<div class="flows-node-tooltip__stat">$${d.flowRate.toLocaleString()}/mo &middot; ${d.sourceType}</div>`;
} else if (node.type === "funnel") {
const d = node.data as FunnelNodeData;
const suf = computeSufficiencyState(d);
html += `<div class="flows-node-tooltip__stat">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.sufficientThreshold ?? d.maxThreshold).toLocaleString()}</div>`;
html += `<div class="flows-node-tooltip__stat" style="text-transform:capitalize;color:${suf === "sufficient" || suf === "abundant" ? "var(--rflows-sufficiency-highlight)" : "var(--rs-text-secondary)"}">${suf}</div>`;
} else {
const d = node.data as OutcomeNodeData;
const pct = d.fundingTarget > 0 ? Math.round((d.fundingReceived / d.fundingTarget) * 100) : 0;
html += `<div class="flows-node-tooltip__stat">$${Math.floor(d.fundingReceived).toLocaleString()} / $${Math.floor(d.fundingTarget).toLocaleString()} (${pct}%)</div>`;
html += `<div class="flows-node-tooltip__stat" style="text-transform:capitalize">${d.status}</div>`;
}
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 += `<div class="phase-tier-bar">`;
for (const p of d.phases) {
const unlocked = d.fundingReceived >= p.fundingThreshold;
phasesHtml += `<div class="phase-tier-segment" style="background:${unlocked ? "var(--rflows-phase-unlocked)" : "var(--rs-bg-surface-raised)"}"></div>`;
}
phasesHtml += `</div>`;
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 += `<div class="phase-card ${unlocked ? "" : "phase-card--locked"}">
<div class="phase-header" data-phase-idx="${i}">
<span style="font-size:14px">${unlocked ? "&#x1F513;" : "&#x1F512;"}</span>
<span style="flex:1;font-size:13px;font-weight:600;color:${unlocked ? "var(--rs-text-primary)" : "var(--rs-text-muted)"}">${this.esc(p.name)}</span>
<span style="font-size:11px;color:var(--rs-text-muted)">${completedTasks}/${p.tasks.length}</span>
<span style="font-size:11px;color:var(--rs-text-muted)">$${p.fundingThreshold.toLocaleString()}</span>
<span style="font-size:12px;color:var(--rs-text-muted);transition:transform 0.2s" data-phase-chevron="${i}">&#x25B6;</span>
</div>
<div class="phase-content" data-phase-content="${i}" style="display:none">
<div style="margin-bottom:8px">
<div style="display:flex;justify-content:space-between;font-size:11px;color:var(--rs-text-muted);margin-bottom:4px">
<span>${Math.min(phasePct, 100)}% funded</span>
<span>$${Math.floor(Math.min(d.fundingReceived, p.fundingThreshold)).toLocaleString()} / $${p.fundingThreshold.toLocaleString()}</span>
</div>
<div class="flows-modal__progress-bar">
<div class="flows-modal__progress-fill" style="width:${Math.min(phasePct, 100)}%;background:${unlocked ? "var(--rflows-phase-unlocked)" : "var(--rflows-status-inprogress)"}"></div>
</div>
</div>
${p.tasks.map((t, ti) => `
<div class="phase-task ${t.completed ? "phase-task--done" : ""}">
<input type="checkbox" data-phase="${i}" data-task="${ti}" ${t.completed ? "checked" : ""} ${!unlocked ? "disabled" : ""}/>
<span>${this.esc(t.label)}</span>
</div>
`).join("")}
${unlocked ? `<button class="phase-add-btn" data-add-task="${i}">+ Add Task</button>` : ""}
</div>
</div>`;
}
phasesHtml += `<button class="phase-add-btn" data-action="add-phase" style="width:100%;justify-content:center;padding:8px">+ Add Phase</button>`;
}
const backdrop = document.createElement("div");
backdrop.className = "flows-modal-backdrop";
backdrop.id = "flows-modal";
backdrop.innerHTML = `<div class="flows-modal">
<div class="flows-modal__header">
<div style="display:flex;align-items:center;gap:10px">
<span style="display:inline-block;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600;background:${statusColor}22;color:${statusColor}">${statusLabel}</span>
<span style="font-size:16px;font-weight:700;color:var(--rs-text-primary)">${this.esc(d.label)}</span>
</div>
<button class="flows-modal__close" data-modal-action="close">&times;</button>
</div>
${d.description ? `<div style="font-size:13px;color:var(--rs-text-secondary);line-height:1.6;margin-bottom:16px;padding:10px 12px;background:var(--rs-bg-surface-sunken);border-radius:8px;border-left:3px solid ${statusColor}">${this.esc(d.description)}</div>` : ""}
<div style="margin-bottom:20px">
<div style="font-size:28px;font-weight:700;color:var(--rs-text-primary)">$${Math.floor(d.fundingReceived).toLocaleString()}</div>
<div style="font-size:13px;color:var(--rs-text-muted);margin-top:2px">of $${Math.floor(d.fundingTarget).toLocaleString()} (${Math.round(fillPct)}%)</div>
<div class="flows-modal__progress-bar" style="margin-top:10px;height:10px">
<div class="flows-modal__progress-fill" style="width:${fillPct}%;background:${statusColor}"></div>
</div>
</div>
${d.phases && d.phases.length > 0 ? `<div style="margin-bottom:16px">
<div style="font-size:13px;font-weight:600;color:var(--rs-text-secondary);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:10px">Phases</div>
${phasesHtml}
</div>` : ""}
</div>`;
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<string, string> = { card: "\u{1F4B3}", safe_wallet: "\u{1F512}", ridentity: "\u{1F464}", unconfigured: "\u{2699}" };
const labels: Record<string, string> = { card: "Card", safe_wallet: "Safe / Wallet", ridentity: "rIdentity", unconfigured: "Configure" };
let configHtml = "";
if (d.sourceType === "card") {
configHtml = `<div style="margin-top:12px">
<div class="editor-field" style="margin-bottom:10px">
<label class="editor-label">Default Amount ($)</label>
<input class="editor-input" data-modal-field="flowRate" type="number" value="${d.flowRate}"/>
</div>
<button class="editor-btn" data-action="fund-with-card" style="width:100%;padding:10px;background:var(--rflows-btn-fund);color:white;border:none;border-radius:8px;font-weight:600;cursor:pointer">
&#x1F4B3; Fund with Card
</button>
</div>`;
} else if (d.sourceType === "safe_wallet") {
configHtml = `<div style="margin-top:12px">
<div class="editor-field" style="margin-bottom:10px">
<label class="editor-label">Wallet Address</label>
<input class="editor-input" data-modal-field="walletAddress" value="${this.esc(d.walletAddress || "")}"/>
</div>
<div class="editor-field" style="margin-bottom:10px">
<label class="editor-label">Safe Address</label>
<input class="editor-input" data-modal-field="safeAddress" value="${this.esc(d.safeAddress || "")}"/>
</div>
<div class="editor-field">
<label class="editor-label">Chain</label>
<select class="editor-select" data-modal-field="chainId">
${[{ id: 1, name: "Ethereum" }, { id: 10, name: "Optimism" }, { id: 8453, name: "Base" }, { id: 100, name: "Gnosis" }]
.map((c) => `<option value="${c.id}" ${d.chainId === c.id ? "selected" : ""}>${c.name}</option>`).join("")}
</select>
</div>
</div>`;
} else if (d.sourceType === "ridentity") {
configHtml = `<div style="text-align:center;padding:16px 0;margin-top:12px">
<div style="font-size:32px;margin-bottom:8px">&#x1F464;</div>
<div style="font-size:13px;color:var(--rs-text-secondary);margin-bottom:12px">${isAuthenticated() ? "Connected" : "Not connected"}</div>
${!isAuthenticated() ? `<button class="editor-btn" data-action="connect-ridentity" style="background:var(--rs-primary);color:white;border:none;padding:8px 20px;border-radius:8px;font-weight:600;cursor:pointer">Connect with EncryptID</button>` : `<div style="font-size:12px;color:var(--rs-success)">&#x2705; ${this.esc(getUsername() || "Connected")}</div>`}
</div>`;
}
const backdrop = document.createElement("div");
backdrop.className = "flows-modal-backdrop";
backdrop.id = "flows-modal";
backdrop.innerHTML = `<div class="flows-modal">
<div class="flows-modal__header">
<div style="display:flex;align-items:center;gap:10px">
<span style="font-size:20px">${icons[d.sourceType] || "&#x1F4B0;"}</span>
<span style="font-size:16px;font-weight:700;color:var(--rs-text-primary)">${this.esc(d.label)}</span>
</div>
<button class="flows-modal__close" data-modal-action="close">&times;</button>
</div>
<div style="margin-bottom:16px">
<div style="font-size:11px;font-weight:600;color:var(--rs-text-secondary);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:8px">Source Type</div>
<div class="source-type-grid">
${["card", "safe_wallet", "ridentity"].map((t) => `
<button class="source-type-btn ${d.sourceType === t ? "source-type-btn--active" : ""}" data-source-type="${t}">
<span style="font-size:20px">${icons[t]}</span>
<span>${labels[t]}</span>
</button>
`).join("")}
</div>
</div>
<div class="editor-field" style="margin-bottom:12px">
<label class="editor-label">Label</label>
<input class="editor-input" data-modal-field="label" value="${this.esc(d.label)}"/>
</div>
<div class="editor-field" style="margin-bottom:12px">
<label class="editor-label">Flow Rate ($/mo)</label>
<input class="editor-input" data-modal-field="flowRate" type="number" value="${d.flowRate}"/>
</div>
<div id="source-config">${configHtml}</div>
<div style="display:flex;gap:8px;margin-top:20px;padding-top:16px;border-top:1px solid var(--rflows-modal-border)">
<button class="editor-btn" data-modal-action="save" style="flex:1;background:var(--rflows-btn-save);color:white;border:none;font-weight:600">Save</button>
<button class="editor-btn" data-modal-action="close" style="flex:1">Cancel</button>
</div>
</div>`;
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") || "";
const wallet = d.walletAddress || "0x0000000000000000000000000000000000000000";
this.openTransakWidget(flowId, wallet);
});
// 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.enterInlineEdit(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 <g>
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 `
<div class="flows-analytics-panel ${this.analyticsOpen ? "open" : ""}" id="analytics-panel">
<div class="analytics-header">
<span class="analytics-title">Analytics</span>
<div class="analytics-tabs">
<button class="analytics-tab ${this.analyticsTab === "overview" ? "analytics-tab--active" : ""}" data-analytics-tab="overview">Overview</button>
<button class="analytics-tab ${this.analyticsTab === "transactions" ? "analytics-tab--active" : ""}" data-analytics-tab="transactions">Transactions</button>
</div>
<button class="analytics-close" data-analytics-close>&times;</button>
</div>
<div class="analytics-content">
${this.analyticsTab === "overview" ? this.renderTableTab() : this.renderTransactionsTab()}
</div>
</div>`;
}
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 `
<div class="flows-tx-empty">
<p>Transaction history is not available in demo mode.</p>
</div>`;
}
if (!this.txLoaded) {
return '<div class="flows-loading">Loading transactions...</div>';
}
if (this.transactions.length === 0) {
return `
<div class="flows-tx-empty">
<p>No transactions yet for this flow.</p>
</div>`;
}
return `
<div class="flows-tx-list">
${this.transactions.map((tx) => `
<div class="flows-tx">
<div class="flows-tx__icon">${tx.type === "deposit" ? "&#x2B06;" : tx.type === "withdraw" ? "&#x2B07;" : "&#x1F504;"}</div>
<div class="flows-tx__body">
<div class="flows-tx__desc">${this.esc(tx.description || tx.type)}</div>
<div class="flows-tx__meta">
${tx.from ? `From: ${this.esc(tx.from)}` : ""}
${tx.to ? ` &rarr; ${this.esc(tx.to)}` : ""}
</div>
</div>
<div class="flows-tx__amount ${tx.type === "deposit" ? "flows-tx__amount--positive" : "flows-tx__amount--negative"}">
${tx.type === "deposit" ? "+" : "-"}$${Math.abs(tx.amount).toLocaleString()}
</div>
<div class="flows-tx__time">${this.formatTime(tx.timestamp)}</div>
</div>
`).join("")}
</div>`;
}
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 = `<input type="text" value="${this.esc(currentName)}" />`;
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
}
customElements.define("folk-flows-app", FolkFlowsApp);