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

4144 lines
172 KiB
TypeScript

/**
* <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") return { w: 200, h: 70 };
if (n.type === "funnel") {
const d = n.data as FunnelNodeData;
const baseW = 200, baseH = 180;
const scaleRef = d.desiredOutflow || d.inflowRate;
const scale = 1 + Math.log10(Math.max(1, scaleRef / 1000)) * 0.3;
return { w: Math.round(baseW * scale), h: Math.round(baseH * scale) };
}
return { w: 200, h: 110 }; // outcome (basin)
}
// ─── Canvas event wiring ──────────────────────────────
private attachCanvasListeners() {
const svg = this.shadow.getElementById("flow-canvas");
if (!svg) return;
// Wheel: pan (default) or zoom (Ctrl/pinch)
// Trackpad two-finger scroll → pan; trackpad pinch / Ctrl+scroll → zoom
svg.addEventListener("wheel", (e: WheelEvent) => {
e.preventDefault();
if (e.ctrlKey || e.metaKey) {
// Zoom — ctrlKey is set by trackpad pinch gestures and Ctrl+scroll
const zoomFactor = 1 - e.deltaY * 0.003;
const rect = svg.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const newZoom = Math.max(0.1, Math.min(4, this.canvasZoom * zoomFactor));
// Zoom toward pointer
this.canvasPanX = mx - (mx - this.canvasPanX) * (newZoom / this.canvasZoom);
this.canvasPanY = my - (my - this.canvasPanY) * (newZoom / this.canvasZoom);
this.canvasZoom = newZoom;
} else {
// Pan — two-finger trackpad scroll or mouse wheel
this.canvasPanX -= e.deltaX;
this.canvasPanY -= e.deltaY;
}
this.updateCanvasTransform();
}, { passive: false });
// Panning — pointerdown on SVG background
svg.addEventListener("pointerdown", (e: PointerEvent) => {
const target = e.target as Element;
// Only pan when clicking SVG background (not on a node)
if (target.closest(".flow-node")) return;
if (target.closest(".edge-ctrl-group")) return;
// Cancel wiring on empty canvas click
if (this.wiringActive) { this.cancelWiring(); return; }
this.isPanning = true;
this.panStartX = e.clientX;
this.panStartY = e.clientY;
this.panStartPanX = this.canvasPanX;
this.panStartPanY = this.canvasPanY;
svg.classList.add("panning");
svg.setPointerCapture(e.pointerId);
// Deselect node and edge
if (!target.closest(".flow-node") && !target.closest(".edge-group")) {
this.selectedNodeId = null;
this.selectedEdgeKey = null;
this.updateSelectionHighlight();
}
});
// Global pointer move/up (for both panning and node drag)
let nodeDragStarted = false;
const DRAG_THRESHOLD = 5;
this._boundPointerMove = (e: PointerEvent) => {
if (this.wiringActive && this.wiringDragging) {
this.wiringPointerX = e.clientX;
this.wiringPointerY = e.clientY;
this.updateWiringTempLine();
return;
}
// Edge drag — convert pointer to canvas coords and update waypoint
if (this.draggingEdgeKey) {
const rect = svg.getBoundingClientRect();
const canvasX = (e.clientX - rect.left - this.canvasPanX) / this.canvasZoom;
const canvasY = (e.clientY - rect.top - this.canvasPanY) / this.canvasZoom;
this.setEdgeWaypoint(this.draggingEdgeKey, canvasX, canvasY);
this.redrawEdges();
return;
}
if (this.isPanning) {
this.canvasPanX = this.panStartPanX + (e.clientX - this.panStartX);
this.canvasPanY = this.panStartPanY + (e.clientY - this.panStartY);
this.updateCanvasTransform();
return;
}
if (this.draggingNodeId) {
const rawDx = e.clientX - this.dragStartX;
const rawDy = e.clientY - this.dragStartY;
// Only start visual drag after exceeding threshold
if (!nodeDragStarted) {
if (Math.abs(rawDx) < DRAG_THRESHOLD && Math.abs(rawDy) < DRAG_THRESHOLD) return;
nodeDragStarted = true;
svg.classList.add("dragging");
}
const dx = rawDx / this.canvasZoom;
const dy = rawDy / this.canvasZoom;
const node = this.nodes.find((n) => n.id === this.draggingNodeId);
if (node) {
node.position.x = this.dragNodeStartX + dx;
node.position.y = this.dragNodeStartY + dy;
this.updateNodePosition(node);
this.redrawEdges();
}
}
};
this._boundPointerUp = (e: PointerEvent) => {
if (this.wiringActive && this.wiringDragging) {
// Hit-test: did we release on a compatible input port?
const el = this.shadow.elementFromPoint(e.clientX, e.clientY);
const portGroup = el?.closest?.(".port-group") as SVGGElement | null;
if (portGroup && portGroup.dataset.portDir === "in" && portGroup.dataset.nodeId !== this.wiringSourceNodeId) {
this.completeWiring(portGroup.dataset.nodeId!);
} else {
// Fall back to click-to-wire mode (source still glowing)
this.wiringDragging = false;
const wireLayer = this.shadow.getElementById("wire-layer");
if (wireLayer) wireLayer.innerHTML = "";
}
return;
}
// Edge drag end
if (this.draggingEdgeKey) {
this.draggingEdgeKey = null;
this.edgeDragPointerId = null;
}
if (this.isPanning) {
this.isPanning = false;
svg.classList.remove("panning");
}
if (this.draggingNodeId) {
const clickedNodeId = this.draggingNodeId;
const wasDragged = nodeDragStarted;
this.draggingNodeId = null;
nodeDragStarted = false;
svg.classList.remove("dragging");
// Single click = select only (inline edit on double-click)
if (!wasDragged) {
this.selectedNodeId = clickedNodeId;
this.selectedEdgeKey = null;
this.updateSelectionHighlight();
} else {
this.scheduleSave();
}
}
};
svg.addEventListener("pointermove", this._boundPointerMove);
svg.addEventListener("pointerup", this._boundPointerUp);
// Node interactions — delegate from node-layer
const nodeLayer = this.shadow.getElementById("node-layer");
if (nodeLayer) {
nodeLayer.addEventListener("pointerdown", (e: PointerEvent) => {
// Check port interaction FIRST
const portGroup = (e.target as Element).closest(".port-group") as SVGGElement | null;
if (portGroup) {
e.stopPropagation();
const portNodeId = portGroup.dataset.nodeId!;
const portKind = portGroup.dataset.portKind as PortKind;
const portDir = portGroup.dataset.portDir!;
if (this.wiringActive) {
// Click-to-wire: complete on compatible input port
if (portDir === "in" && portNodeId !== this.wiringSourceNodeId) {
this.completeWiring(portNodeId);
} else {
this.cancelWiring();
}
return;
}
// Start wiring from output port
if (portDir === "out") {
const portSide = portGroup.dataset.portSide as "left" | "right" | undefined;
this.enterWiring(portNodeId, portKind, portSide);
this.wiringDragging = true;
this.wiringPointerX = e.clientX;
this.wiringPointerY = e.clientY;
svg.setPointerCapture(e.pointerId);
return;
}
return;
}
const group = (e.target as Element).closest(".flow-node") as SVGGElement | null;
if (!group) return;
e.stopPropagation();
const nodeId = group.dataset.nodeId;
if (!nodeId) return;
const node = this.nodes.find((n) => n.id === nodeId);
if (!node) return;
// If wiring is active and clicked on a node (not port), cancel
if (this.wiringActive) {
this.cancelWiring();
return;
}
// Select
this.selectedNodeId = nodeId;
this.selectedEdgeKey = null;
this.updateSelectionHighlight();
// Prepare drag (but don't start until threshold exceeded)
nodeDragStarted = false;
this.draggingNodeId = nodeId;
this.dragStartX = e.clientX;
this.dragStartY = e.clientY;
this.dragNodeStartX = node.position.x;
this.dragNodeStartY = node.position.y;
svg.setPointerCapture(e.pointerId);
});
nodeLayer.addEventListener("dblclick", (e: MouseEvent) => {
const group = (e.target as Element).closest(".flow-node") as SVGGElement | null;
if (!group) return;
const nodeId = group.dataset.nodeId;
if (!nodeId) return;
const node = this.nodes.find((n) => n.id === nodeId);
if (!node) return;
this.enterInlineEdit(nodeId);
});
// Hover: tooltip + edge highlighting
let hoveredNodeId: string | null = null;
nodeLayer.addEventListener("mouseover", (e: MouseEvent) => {
const group = (e.target as Element).closest(".flow-node") as SVGGElement | null;
if (!group) return;
const nodeId = group.dataset.nodeId;
if (nodeId && nodeId !== hoveredNodeId) {
hoveredNodeId = nodeId;
this.showNodeTooltip(nodeId, e);
this.highlightNodeEdges(nodeId);
}
});
nodeLayer.addEventListener("mouseout", (e: MouseEvent) => {
const related = (e.relatedTarget as Element | null)?.closest?.(".flow-node");
if (!related) {
hoveredNodeId = null;
this.hideNodeTooltip();
this.unhighlightEdges();
}
});
}
// Toolbar buttons
this.shadow.querySelectorAll("[data-canvas-action]").forEach((btn) => {
btn.addEventListener("click", () => {
const action = (btn as HTMLElement).dataset.canvasAction;
if (action === "add-source") this.addNode("source");
else if (action === "add-funnel") this.addNode("funnel");
else if (action === "add-outcome") this.addNode("outcome");
else if (action === "sim") this.toggleSimulation();
else if (action === "fit") this.fitView();
else if (action === "analytics") this.toggleAnalytics();
else if (action === "share") this.shareState();
else if (action === "zoom-in") { this.canvasZoom = Math.min(4, this.canvasZoom * 1.2); this.updateCanvasTransform(); }
else if (action === "zoom-out") { this.canvasZoom = Math.max(0.1, this.canvasZoom * 0.8); this.updateCanvasTransform(); }
else if (action === "flow-picker") this.toggleFlowDropdown();
});
});
// Flow dropdown items
this.shadow.querySelectorAll("[data-flow-switch]").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const flowId = (btn as HTMLElement).dataset.flowSwitch;
if (flowId && flowId !== this.currentFlowId) this.switchToFlow(flowId);
this.closeFlowDropdown();
});
});
this.shadow.querySelectorAll("[data-flow-action]").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const action = (btn as HTMLElement).dataset.flowAction;
if (action === "new-flow") { this.closeFlowDropdown(); this.createNewFlow(); }
else if (action === "manage-flows") { this.closeFlowDropdown(); this.openFlowManager(); }
});
});
// Close dropdown on outside click
this.shadow.addEventListener("click", (e) => {
const dropdown = this.shadow.getElementById("flow-dropdown");
if (dropdown && !dropdown.contains(e.target as Node)) this.closeFlowDropdown();
});
// Management modal listeners
this.attachFlowManagerListeners();
// Speed slider
const speedSlider = this.shadow.getElementById("sim-speed-slider") as HTMLInputElement | null;
if (speedSlider) {
speedSlider.addEventListener("input", () => {
this.simSpeedMs = parseInt(speedSlider.value, 10);
const label = this.shadow.getElementById("sim-speed-label");
if (label) label.textContent = `${this.simSpeedMs}ms`;
if (this.isSimulating) this.startSimInterval();
});
}
// Edge +/- buttons (delegated)
const edgeLayer = this.shadow.getElementById("edge-layer");
if (edgeLayer) {
edgeLayer.addEventListener("click", (e: Event) => {
const btn = (e.target as Element).closest("[data-edge-action]") as HTMLElement | null;
if (!btn) return;
e.stopPropagation();
const action = btn.dataset.edgeAction; // "inc" or "dec"
const fromId = btn.dataset.edgeFrom!;
const toId = btn.dataset.edgeTo!;
const allocType = btn.dataset.edgeType as "overflow" | "spending" | "source";
this.handleAdjustAllocation(fromId, toId, allocType, action === "inc" ? 5 : -5);
});
// Edge selection — click on edge path (not buttons)
edgeLayer.addEventListener("pointerdown", (e: PointerEvent) => {
const target = e.target as Element;
// Ignore clicks on +/- buttons or drag handle
if (target.closest("[data-edge-action]")) return;
if (target.closest(".edge-drag-handle")) return;
const edgeGroup = target.closest(".edge-group") as SVGGElement | null;
if (!edgeGroup) return;
e.stopPropagation();
const fromId = edgeGroup.dataset.from!;
const toId = edgeGroup.dataset.to!;
const edgeType = edgeGroup.dataset.edgeType || "source";
const key = `${fromId}::${toId}::${edgeType}`;
this.selectedEdgeKey = key;
this.selectedNodeId = null;
this.updateSelectionHighlight();
});
// Double-click edge → open source node editor
edgeLayer.addEventListener("dblclick", (e: Event) => {
const target = e.target as Element;
if (target.closest("[data-edge-action]")) return;
if (target.closest(".edge-drag-handle")) return;
const edgeGroup = target.closest(".edge-group") as SVGGElement | null;
if (!edgeGroup) return;
e.stopPropagation();
const fromId = edgeGroup.dataset.from!;
this.openEditor(fromId);
});
// Edge drag handle — pointerdown to start dragging
edgeLayer.addEventListener("pointerdown", (e: PointerEvent) => {
const handle = (e.target as Element).closest(".edge-drag-handle") as SVGElement | null;
if (!handle) return;
e.stopPropagation();
e.preventDefault();
const edgeGroup = handle.closest(".edge-group") as SVGGElement | null;
if (!edgeGroup) return;
const fromId = edgeGroup.dataset.from!;
const toId = edgeGroup.dataset.to!;
const edgeType = edgeGroup.dataset.edgeType || "source";
this.draggingEdgeKey = `${fromId}::${toId}::${edgeType}`;
this.edgeDragPointerId = e.pointerId;
(e.target as Element).setPointerCapture?.(e.pointerId);
});
// Double-click drag handle → remove waypoint
edgeLayer.addEventListener("dblclick", (e: Event) => {
const handle = (e.target as Element).closest(".edge-drag-handle") as SVGElement | null;
if (!handle) return;
e.stopPropagation();
const edgeGroup = handle.closest(".edge-group") as SVGGElement | null;
if (!edgeGroup) return;
const fromId = edgeGroup.dataset.from!;
const toId = edgeGroup.dataset.to!;
const edgeType = edgeGroup.dataset.edgeType || "source";
this.removeEdgeWaypoint(fromId, toId, edgeType);
});
}
// Touch gesture handling for two-finger pan + pinch-to-zoom
const getTouchCenter = (touches: TouchList) => ({
x: (touches[0].clientX + touches[1].clientX) / 2,
y: (touches[0].clientY + touches[1].clientY) / 2,
});
const getTouchDist = (touches: TouchList) => {
const dx = touches[0].clientX - touches[1].clientX;
const dy = touches[0].clientY - touches[1].clientY;
return Math.hypot(dx, dy);
};
svg.addEventListener("touchstart", (e: TouchEvent) => {
if (e.touches.length === 2) {
e.preventDefault();
this.isTouchPanning = true;
// Cancel any pointer-based pan or node drag
this.isPanning = false;
if (this.draggingNodeId) {
this.draggingNodeId = null;
nodeDragStarted = false;
svg.classList.remove("dragging");
}
if (this.wiringActive) this.cancelWiring();
this.lastTouchCenter = getTouchCenter(e.touches);
this.lastTouchDist = getTouchDist(e.touches);
}
}, { passive: false });
svg.addEventListener("touchmove", (e: TouchEvent) => {
if (e.touches.length === 2 && this.isTouchPanning) {
e.preventDefault();
const currentCenter = getTouchCenter(e.touches);
const currentDist = getTouchDist(e.touches);
if (this.lastTouchCenter) {
// Two-finger pan
this.canvasPanX += currentCenter.x - this.lastTouchCenter.x;
this.canvasPanY += currentCenter.y - this.lastTouchCenter.y;
}
if (this.lastTouchDist && this.lastTouchDist > 0) {
// Pinch-to-zoom around gesture center
const zoomDelta = currentDist / this.lastTouchDist;
const newZoom = Math.max(0.2, Math.min(5, this.canvasZoom * zoomDelta));
const rect = svg.getBoundingClientRect();
const cx = currentCenter.x - rect.left;
const cy = currentCenter.y - rect.top;
this.canvasPanX = cx - (cx - this.canvasPanX) * (newZoom / this.canvasZoom);
this.canvasPanY = cy - (cy - this.canvasPanY) * (newZoom / this.canvasZoom);
this.canvasZoom = newZoom;
}
this.lastTouchCenter = currentCenter;
this.lastTouchDist = currentDist;
this.updateCanvasTransform();
}
}, { passive: false });
svg.addEventListener("touchend", (e: TouchEvent) => {
if (e.touches.length < 2) {
this.lastTouchCenter = null;
this.lastTouchDist = null;
this.isTouchPanning = false;
}
});
// Keyboard
this._boundKeyDown = (e: KeyboardEvent) => {
// Skip if typing in editor input
const tag = (e.target as Element).tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
if (e.key === "Escape") {
if (this.inlineEditNodeId) { this.exitInlineEdit(); return; }
if (this.wiringActive) { this.cancelWiring(); return; }
if (this.analyticsOpen) { this.toggleAnalytics(); return; }
this.closeModal();
this.closeEditor();
}
else if (e.key === " ") { e.preventDefault(); this.toggleSimulation(); }
else if (e.key === "Delete" || e.key === "Backspace") { if (this.selectedNodeId) this.deleteNode(this.selectedNodeId); }
else if (e.key === "f" || e.key === "F") this.fitView();
else if (e.key === "=" || e.key === "+") { this.canvasZoom = Math.min(4, this.canvasZoom * 1.2); this.updateCanvasTransform(); }
else if (e.key === "-") { this.canvasZoom = Math.max(0.1, this.canvasZoom * 0.8); this.updateCanvasTransform(); }
};
document.addEventListener("keydown", this._boundKeyDown);
}
// ─── Inflow satisfaction computation ─────────────────
private computeInflowSatisfaction(): Map<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 x = n.position.x, y = n.position.y, w = 200, h = 70;
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 stubW = 24, stubH = 20;
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="50" rx="12" style="fill:var(--rflows-source-bg)" stroke="${selected ? "var(--rflows-selected)" : "var(--rflows-source-border)"}" stroke-width="${selected ? 3 : 2}"/>
<rect x="${(w - stubW) / 2}" y="50" 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="14" y="24" style="fill:var(--rs-text-primary)" font-size="15">${icon}</text>
<text x="36" y="24" style="fill:var(--rs-text-primary)" font-size="13" font-weight="600">${this.esc(d.label)}</text>
<text x="${w / 2}" y="44" text-anchor="middle" style="fill:var(--rflows-source-rate)" font-size="11">$${d.flowRate.toLocaleString()}/mo</text>
${this.renderAllocBar(d.targetAllocations, w, 48)}
${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 threshold = d.sufficientThreshold ?? d.maxThreshold;
const fillPct = Math.min(1, d.currentValue / (d.maxCapacity || 1));
const isOverflow = d.currentValue > d.maxThreshold;
const isCritical = d.currentValue < d.minThreshold;
const borderColorVar = isCritical ? "var(--rflows-status-critical)" : isOverflow ? "var(--rflows-status-overflow)" : "var(--rflows-status-sustained)";
const fillColor = borderColorVar;
const statusLabel = isCritical ? "Critical" : isOverflow ? "Overflow" : "Sustained";
// Tank shape parameters
const r = 10;
const pipeW = 24; // overflow pipe extension from wall
const basePipeH = 20; // base pipe height
const pipeYFrac = 0.55; // pipe center at ~55% down
const taperStart = 0.75; // body tapers at 75% down
const taperInset = 0.2;
const insetPx = Math.round(w * taperInset);
const taperY = Math.round(h * taperStart);
const clipId = `funnel-clip-${n.id}`;
// Dynamic pipe sizing for overflow
let pipeH = basePipeH;
let pipeY = Math.round(h * pipeYFrac) - basePipeH / 2;
let excessRatio = 0;
if (isOverflow && d.maxCapacity > d.maxThreshold) {
excessRatio = Math.min(1, (d.currentValue - d.maxThreshold) / (d.maxCapacity - d.maxThreshold));
pipeH = basePipeH + excessRatio * 16;
pipeY = Math.round(h * pipeYFrac) - basePipeH / 2 - excessRatio * 8;
}
// Tank SVG path: flat-top wide body with pipe notches, tapering to drain at bottom
const tankPath = [
`M ${r},0`,
`L ${w - r},0`,
`Q ${w},0 ${w},${r}`,
`L ${w},${pipeY}`,
`L ${w + pipeW},${pipeY}`,
`L ${w + pipeW},${pipeY + pipeH}`,
`L ${w},${pipeY + pipeH}`,
`L ${w},${taperY}`,
`Q ${w},${taperY + (h - taperY) * 0.3} ${w - insetPx},${h - r}`,
`Q ${w - insetPx},${h} ${w - insetPx - r},${h}`,
`L ${insetPx + r},${h}`,
`Q ${insetPx},${h} ${insetPx},${h - r}`,
`Q 0,${taperY + (h - taperY) * 0.3} 0,${taperY}`,
`L 0,${pipeY + pipeH}`,
`L ${-pipeW},${pipeY + pipeH}`,
`L ${-pipeW},${pipeY}`,
`L 0,${pipeY}`,
`L 0,${r}`,
`Q 0,0 ${r},0`,
`Z`,
].join(" ");
// Interior fill zones
const zoneTop = 28;
const zoneBot = h - 4;
const zoneH = zoneBot - zoneTop;
const drainPct = d.minThreshold / (d.maxCapacity || 1);
const healthyPct = (d.maxThreshold - d.minThreshold) / (d.maxCapacity || 1);
const overflowPct = Math.max(0, 1 - drainPct - healthyPct);
const drainH = zoneH * drainPct;
const healthyH = zoneH * healthyPct;
const overflowH = zoneH * overflowPct;
// Fill level
const totalFillH = zoneH * fillPct;
const fillY = zoneTop + zoneH - totalFillH;
// Threshold lines (always visible)
const minFrac = d.minThreshold / (d.maxCapacity || 1);
const sufFrac = (d.sufficientThreshold ?? d.maxThreshold) / (d.maxCapacity || 1);
const maxFrac = d.maxThreshold / (d.maxCapacity || 1);
const minLineY = zoneTop + zoneH * (1 - minFrac);
const sufLineY = zoneTop + zoneH * (1 - sufFrac);
const maxLineY = zoneTop + zoneH * (1 - maxFrac);
const thresholdLines = `
<line class="threshold-line" x1="4" x2="${w - 4}" y1="${minLineY}" y2="${minLineY}" stroke="var(--rflows-status-critical)" stroke-width="1.5" stroke-dasharray="4 3" opacity="0.7"/>
<text x="8" y="${minLineY - 3}" style="fill:var(--rflows-status-critical)" font-size="8" opacity="0.8">Min</text>
<line class="threshold-line" x1="4" x2="${w - 4}" y1="${sufLineY}" y2="${sufLineY}" stroke="var(--rflows-status-thriving)" stroke-width="1.5" stroke-dasharray="4 3" opacity="0.7"/>
<text x="8" y="${sufLineY - 3}" style="fill:var(--rflows-status-thriving)" font-size="8" opacity="0.8">Suf</text>
<line class="threshold-line" x1="4" x2="${w - 4}" y1="${maxLineY}" y2="${maxLineY}" stroke="var(--rflows-status-sustained)" stroke-width="1.5" stroke-dasharray="4 3" opacity="0.7"/>
<text x="8" y="${maxLineY - 3}" style="fill:var(--rflows-status-sustained)" font-size="8" opacity="0.8">Overflow</text>`;
// Inflow satisfaction bar
const satBarY = 40;
const satBarW = w - 40;
const satRatio = sat ? Math.min(sat.ratio, 1) : 0;
const satOverflow = sat ? sat.ratio > 1 : false;
const satFillW = satBarW * satRatio;
const satLabel = sat ? `${this.formatDollar(sat.actual)} of ${this.formatDollar(sat.needed)}/mo` : "";
const satBarBorder = satOverflow ? `stroke="var(--rflows-sat-border)" stroke-width="1"` : "";
const glowStyle = isOverflow ? "filter: drop-shadow(0 0 6px rgba(16,185,129,0.5))"
: !isCritical ? "filter: drop-shadow(0 0 6px rgba(245,158,11,0.4))" : "";
// Rate labels
const inflowLabel = `\u2193 ${this.formatDollar(d.inflowRate)}/mo`;
const baseRate = d.desiredOutflow || d.inflowRate;
let rateMultiplier: number;
if (d.currentValue > d.maxThreshold) rateMultiplier = 0.8;
else if (d.currentValue >= d.minThreshold) rateMultiplier = 0.5;
else rateMultiplier = 0.1;
const spendingRate = baseRate * rateMultiplier;
const spendingLabel = `\u2193 ${this.formatDollar(spendingRate)}/mo`;
const excess = Math.max(0, d.currentValue - d.maxThreshold);
const overflowLabel = isOverflow ? this.formatDollar(excess) : "";
return `<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" 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 : 2}"/>
<g clip-path="url(#${clipId})">
<rect x="${-pipeW}" y="${zoneTop + overflowH + healthyH}" width="${w + pipeW * 2}" height="${drainH}" style="fill:var(--rflows-zone-drain);opacity:var(--rflows-zone-drain-opacity)"/>
<rect x="${-pipeW}" y="${zoneTop + overflowH}" width="${w + pipeW * 2}" height="${healthyH}" 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}
</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="3" style="fill:${isOverflow ? "var(--rflows-status-overflow)" : "var(--rs-bg-surface-raised)"}" opacity="${isOverflow ? 0.8 : 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="3" style="fill:${isOverflow ? "var(--rflows-status-overflow)" : "var(--rs-bg-surface-raised)"}" opacity="${isOverflow ? 0.8 : 0.3}"/>
<text x="${w / 2}" y="-8" text-anchor="middle" style="fill:var(--rflows-label-inflow)" font-size="10" font-weight="500" opacity="0.8">${inflowLabel}</text>
<text x="${w / 2}" y="16" text-anchor="middle" style="fill:var(--rs-text-primary)" font-size="13" font-weight="600">${this.esc(d.label)}</text>
<text x="${w - 10}" y="16" text-anchor="end" style="fill:${borderColorVar}" font-size="10" font-weight="500" class="${!isCritical ? "sufficiency-glow" : ""}">${statusLabel}</text>
<rect x="20" y="${satBarY}" width="${satBarW}" height="6" rx="3" style="fill:var(--rs-bg-surface-raised)" opacity="0.3" class="satisfaction-bar-bg"/>
<rect x="20" y="${satBarY}" width="${satFillW}" height="6" rx="3" style="fill:var(--rflows-sat-bar)" class="satisfaction-bar-fill" ${satBarBorder}/>
<text x="${w / 2}" y="${satBarY + 16}" text-anchor="middle" style="fill:var(--rs-text-muted)" font-size="9">${satLabel}</text>
<text class="funnel-value-text" data-node-id="${n.id}" x="${w / 2}" y="${h - insetPx - 8}" text-anchor="middle" style="fill:var(--rs-text-secondary)" font-size="11">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()}</text>
<rect x="${insetPx + 4}" y="${h - 10}" width="${w - insetPx * 2 - 8}" height="4" rx="2" style="fill:var(--rs-bg-surface-raised)"/>
<rect x="${insetPx + 4}" y="${h - 10}" width="${(w - insetPx * 2 - 8) * fillPct}" height="4" rx="2" style="fill:${fillColor}"/>
<text x="${w / 2}" y="${h + 16}" text-anchor="middle" style="fill:var(--rflows-label-spending)" font-size="10" font-weight="500" opacity="0.8">${spendingLabel}</text>
${isOverflow ? `<text x="${-pipeW - 4}" y="${pipeY + pipeH / 2 + 3}" text-anchor="end" style="fill:var(--rflows-label-overflow)" font-size="9" opacity="0.7">${overflowLabel}</text>
<text x="${w + pipeW + 4}" y="${pipeY + pipeH / 2 + 3}" text-anchor="start" style="fill:var(--rflows-label-overflow)" font-size="9" opacity="0.7">${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 x = n.position.x, y = n.position.y, w = 200, h = 110;
const fillPct = d.fundingTarget > 0 ? Math.min(1, d.fundingReceived / d.fundingTarget) : 0;
const statusColorVar = d.status === "completed" ? "var(--rflows-status-completed)"
: d.status === "blocked" ? "var(--rflows-status-blocked)"
: d.status === "in-progress" ? "var(--rflows-status-inprogress)" : "var(--rflows-status-notstarted)";
// Basin shape: slightly flared walls (8px wider at top)
const flare = 8;
const clipId = `basin-clip-${n.id}`;
const basinPath = [
`M ${-flare},0`,
`L ${w + flare},0`,
`Q ${w + flare},4 ${w + flare - 2},8`,
`L ${w},${h - 8}`,
`Q ${w},${h} ${w - 8},${h}`,
`L 8,${h}`,
`Q 0,${h} 0,${h - 8}`,
`L ${-flare + 2},8`,
`Q ${-flare},4 ${-flare},0`,
`Z`,
].join(" ");
// Fill level from bottom
const fillZoneTop = 30;
const fillZoneH = h - fillZoneTop - 4;
const fillH = fillZoneH * fillPct;
const fillY = fillZoneTop + fillZoneH - fillH;
let phaseBars = "";
if (d.phases && d.phases.length > 0) {
const phaseW = (w - 20) / d.phases.length;
phaseBars = d.phases.map((p, i) => {
const unlocked = d.fundingReceived >= p.fundingThreshold;
return `<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 = 16;
const MIN_EDGE_W = 1.5;
let html = "";
for (const e of edges) {
const from = this.getPortPosition(e.fromNode, e.fromPort, e.fromSide);
const to = this.getPortPosition(e.toNode, "inflow");
const isGhost = e.flowAmount === 0;
const strokeW = isGhost ? 1 : MIN_EDGE_W + (e.pct / 100) * (MAX_EDGE_W - MIN_EDGE_W);
const label = isGhost ? `${e.pct}%` : `${this.formatDollar(e.flowAmount)} (${e.pct}%)`;
html += this.renderEdgePath(
from.x, from.y, to.x, to.y,
e.color, strokeW, e.dashed, isGhost,
label, e.fromId, e.toId, e.edgeType,
e.fromSide, e.waypoint,
);
}
return html;
}
private renderEdgePath(
x1: number, y1: number, x2: number, y2: number,
color: string, strokeW: number, dashed: boolean, ghost: boolean,
label: string, fromId: string, toId: string, edgeType: string,
fromSide?: "left" | "right",
waypoint?: { x: number; y: number },
): string {
let d: string;
let midX: number;
let midY: number;
if (waypoint) {
// Cubic Bezier that passes through waypoint at t=0.5:
// P(0.5) = 0.125*P0 + 0.375*C1 + 0.375*C2 + 0.125*P3
// To pass through waypoint W: C1 = (4W - P0 - P3) / 3 blended toward start,
// C2 = (4W - P0 - P3) / 3 blended toward end
const cx1 = (4 * waypoint.x - x1 - x2) / 3;
const cy1 = (4 * waypoint.y - y1 - y2) / 3;
const cx2 = cx1;
const cy2 = cy1;
// Blend control points to retain start/end tangent direction
const c1x = x1 + (cx1 - x1) * 0.8;
const c1y = y1 + (cy1 - y1) * 0.8;
const c2x = x2 + (cx2 - x2) * 0.8;
const c2y = y2 + (cy2 - y2) * 0.8;
d = `M ${x1} ${y1} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x2} ${y2}`;
midX = waypoint.x;
midY = waypoint.y;
} else if (fromSide) {
// Side port: curve outward horizontally first, then turn toward target
const burst = Math.max(100, strokeW * 8);
const outwardX = fromSide === "left" ? x1 - burst : x1 + burst;
d = `M ${x1} ${y1} C ${outwardX} ${y1}, ${x2} ${y1 + (y2 - y1) * 0.4}, ${x2} ${y2}`;
midX = (x1 + outwardX + x2) / 3;
midY = (y1 + y2) / 2;
} else {
const cy1 = y1 + (y2 - y1) * 0.4;
const cy2 = y1 + (y2 - y1) * 0.6;
d = `M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}`;
midX = (x1 + x2) / 2;
midY = (y1 + y2) / 2;
}
// Invisible wide hit area for click/selection
const hitPath = `<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 position
if (node.type === "funnel" && portKind === "overflow" && def.side) {
const d = node.data as FunnelNodeData;
const h = s.h;
const basePipeH = 20;
let pipeY = Math.round(h * 0.55) - basePipeH / 2;
if (d.currentValue > d.maxThreshold && d.maxCapacity > d.maxThreshold) {
const er = Math.min(1, (d.currentValue - d.maxThreshold) / (d.maxCapacity - d.maxThreshold));
pipeY = Math.round(h * 0.55) - basePipeH / 2 - er * 8;
}
const pipeMidY = pipeY + basePipeH / 2;
return { x: node.position.x + s.w * def.xFrac, y: node.position.y + pipeMidY };
}
return { x: node.position.x + s.w * def.xFrac, y: node.position.y + s.h * def.yFrac };
}
/** Pick the overflow side port closest to a target node */
private getOverflowSideForTarget(fromNode: FlowNode, toNode: FlowNode): "left" | "right" {
const toCenter = toNode.position.x + this.getNodeSize(toNode).w / 2;
const fromCenter = fromNode.position.x + this.getNodeSize(fromNode).w / 2;
return toCenter < fromCenter ? "left" : "right";
}
private renderPortsSvg(n: FlowNode): string {
const s = this.getNodeSize(n);
const defs = this.getPortDefs(n.type);
return defs.map((p) => {
const cx = s.w * p.xFrac;
const cy = s.h * p.yFrac;
let arrow: string;
const sideAttr = p.side ? ` data-port-side="${p.side}"` : "";
if (p.side) {
// Side port: horizontal arrow
if (p.side === "left") {
arrow = `<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");
// For funnels, render threshold drag markers on the node body
if (node.type === "funnel") {
this.renderFunnelThresholdMarkers(overlay, node, s);
}
// Panel positioned below the node
const panelW = Math.max(280, s.w);
const panelH = 260;
const panelX = (s.w - panelW) / 2;
const panelY = s.h + 8;
overlay.innerHTML += `
<foreignObject x="${panelX}" y="${panelY}" width="${panelW}" height="${panelH}">
<div xmlns="http://www.w3.org/1999/xhtml" class="inline-config-panel" style="height:${panelH}px">
<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>
</div>
</foreignObject>`;
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 cap = d.maxCapacity || 1;
const sufVal = d.sufficientThreshold ?? d.maxThreshold;
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">Min</span>
<input class="icp-range" type="range" min="0" max="${cap}" value="${d.minThreshold}" data-icp-range="minThreshold"/>
<span class="icp-range-value">${this.formatDollar(d.minThreshold)}</span>
</div>
<div class="icp-range-group">
<span class="icp-range-label">Suf</span>
<input class="icp-range" type="range" min="0" max="${cap}" value="${sufVal}" data-icp-range="sufficientThreshold"/>
<span class="icp-range-value">${this.formatDollar(sufVal)}</span>
</div>
<div class="icp-range-group">
<span class="icp-range-label">Max</span>
<input class="icp-range" type="range" min="0" max="${cap}" value="${d.maxThreshold}" data-icp-range="maxThreshold"/>
<span class="icp-range-value">${this.formatDollar(d.maxThreshold)}</span>
</div>
<div class="icp-field"><label class="icp-label">Max Capacity</label>
<input class="icp-input" data-icp-field="maxCapacity" type="number" value="${d.maxCapacity}"/></div>
<div class="icp-field"><label class="icp-label">Inflow Rate ($/tick)</label>
<input class="icp-input" data-icp-field="inflowRate" type="number" value="${d.inflowRate}"/></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 threshold markers (SVG on node body) ──
private renderFunnelThresholdMarkers(overlay: SVGGElement, node: FlowNode, s: { w: number; h: number }) {
const d = node.data as FunnelNodeData;
const zoneTop = 28;
const zoneBot = s.h - 4;
const zoneH = zoneBot - zoneTop;
const thresholds: Array<{ key: string; value: number; color: string; label: string }> = [
{ key: "minThreshold", value: d.minThreshold, color: "var(--rflows-status-critical)", label: "Min" },
{ key: "sufficientThreshold", value: d.sufficientThreshold ?? d.maxThreshold, color: "var(--rflows-status-thriving)", label: "Suf" },
{ key: "maxThreshold", value: d.maxThreshold, color: "var(--rflows-status-sustained)", label: "Max" },
];
for (const t of thresholds) {
const frac = t.value / (d.maxCapacity || 1);
const markerY = zoneTop + zoneH * (1 - frac);
overlay.innerHTML += `
<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;
// Tab switching
overlay.querySelectorAll(".icp-tab").forEach((el) => {
el.addEventListener("click", (e: Event) => {
e.stopPropagation();
const tab = (el as HTMLElement).dataset.icpTab as "config" | "analytics" | "allocations";
if (!tab || tab === this.inlineConfigTab) return;
this.inlineConfigTab = tab;
overlay.querySelectorAll(".icp-tab").forEach((t) => t.classList.remove("icp-tab--active"));
el.classList.add("icp-tab--active");
const body = overlay.querySelector(".icp-body") as HTMLElement;
if (body) body.innerHTML = this.renderInlineConfigContent(node);
this.attachInlineConfigFieldListeners(overlay as Element, node);
});
});
// Field listeners
this.attachInlineConfigFieldListeners(overlay, node);
// Threshold drag handles (funnel)
this.attachThresholdDragListeners(overlay, node);
// Done button
overlay.querySelector(".iet-done")?.addEventListener("click", (e: Event) => {
e.stopPropagation();
this.exitInlineEdit();
});
// Delete button
overlay.querySelector(".iet-delete")?.addEventListener("click", (e: Event) => {
e.stopPropagation();
this.deleteNode(node.id);
this.exitInlineEdit();
});
// "..." panel button
overlay.querySelector(".iet-panel")?.addEventListener("click", (e: Event) => {
e.stopPropagation();
this.exitInlineEdit();
this.openEditor(node.id);
});
// Fund Now button (source card type)
overlay.querySelector("[data-icp-action='fund']")?.addEventListener("click", (e: Event) => {
e.stopPropagation();
const sd = node.data as SourceNodeData;
const flowId = this.flowId || this.getAttribute("flow-id") || "";
if (!sd.walletAddress) {
alert("Configure a wallet address first");
return;
}
this.openTransakWidget(flowId, sd.walletAddress);
});
// Click-outside handler
const clickOutsideHandler = (e: PointerEvent) => {
const target = e.target as Element;
if (!target.closest(`[data-node-id="${node.id}"]`)) {
this.exitInlineEdit();
document.removeEventListener("pointerdown", clickOutsideHandler as EventListener, true);
}
};
setTimeout(() => {
document.addEventListener("pointerdown", clickOutsideHandler as EventListener, true);
}, 100);
}
private attachInlineConfigFieldListeners(overlay: Element, node: FlowNode) {
// Text/number/select input fields
overlay.querySelectorAll("[data-icp-field]").forEach((el) => {
const input = el as HTMLInputElement | HTMLSelectElement;
const field = input.dataset.icpField!;
const handler = () => {
const numFields = ["flowRate", "currentValue", "minThreshold", "maxThreshold", "maxCapacity", "inflowRate", "sufficientThreshold", "fundingReceived", "fundingTarget"];
const val = numFields.includes(field) ? parseFloat((input as HTMLInputElement).value) || 0 : input.value;
(node.data as any)[field] = val;
this.redrawNodeOnly(node);
this.redrawEdges();
};
input.addEventListener("input", handler);
input.addEventListener("change", handler);
input.addEventListener("keydown", (e: Event) => {
const ke = e as KeyboardEvent;
if (ke.key === "Enter") this.exitInlineEdit();
if (ke.key === "Escape") this.exitInlineEdit();
ke.stopPropagation();
});
});
// Range sliders
overlay.querySelectorAll("[data-icp-range]").forEach((el) => {
const input = el as HTMLInputElement;
const field = input.dataset.icpRange!;
input.addEventListener("input", () => {
const val = parseFloat(input.value) || 0;
(node.data as any)[field] = Math.round(val);
const valueSpan = input.parentElement?.querySelector(".icp-range-value") as HTMLElement;
if (valueSpan) valueSpan.textContent = this.formatDollar(val);
this.redrawNodeOnly(node);
this.redrawEdges();
this.redrawThresholdMarkers(node);
});
});
}
private attachThresholdDragListeners(overlay: Element, node: FlowNode) {
overlay.querySelectorAll(".threshold-handle").forEach((el) => {
el.addEventListener("pointerdown", (e: Event) => {
const pe = e as PointerEvent;
pe.stopPropagation();
pe.preventDefault();
const thresholdKey = (el as SVGElement).dataset.threshold!;
this.inlineEditDragThreshold = thresholdKey;
this.inlineEditDragStartY = pe.clientY;
this.inlineEditDragStartValue = (node.data as any)[thresholdKey] || 0;
(el as Element).setPointerCapture(pe.pointerId);
});
el.addEventListener("pointermove", (e: Event) => {
if (!this.inlineEditDragThreshold) return;
const pe = e as PointerEvent;
const d = node.data as FunnelNodeData;
const s = this.getNodeSize(node);
const zoneH = s.h - 4 - 28;
const deltaY = (pe.clientY - this.inlineEditDragStartY) / this.canvasZoom;
const deltaDollars = -(deltaY / zoneH) * (d.maxCapacity || 1);
let newVal = this.inlineEditDragStartValue + deltaDollars;
newVal = Math.max(0, Math.min(d.maxCapacity, newVal));
const key = this.inlineEditDragThreshold;
if (key === "minThreshold") newVal = Math.min(newVal, d.maxThreshold);
if (key === "maxThreshold") newVal = Math.max(newVal, d.minThreshold);
if (key === "sufficientThreshold") newVal = Math.max(d.minThreshold, Math.min(d.maxThreshold, newVal));
(node.data as any)[key] = Math.round(newVal);
this.redrawNodeInlineEdit(node);
});
el.addEventListener("pointerup", () => {
this.inlineEditDragThreshold = null;
});
});
}
private redrawThresholdMarkers(node: FlowNode) {
if (node.type !== "funnel") return;
const nodeLayer = this.shadow.getElementById("node-layer");
const g = nodeLayer?.querySelector(`[data-node-id="${node.id}"]`) as SVGGElement | null;
if (!g) return;
const overlay = g.querySelector(".inline-edit-overlay");
if (!overlay) return;
overlay.querySelectorAll(".threshold-marker, .threshold-handle").forEach((el) => el.remove());
overlay.querySelectorAll("text").forEach((t) => {
if (t.getAttribute("pointer-events") === "none" && t.getAttribute("font-size") === "9") t.remove();
});
const s = this.getNodeSize(node);
const tempG = document.createElementNS("http://www.w3.org/2000/svg", "g");
this.renderFunnelThresholdMarkers(tempG, node, s);
const fo = overlay.querySelector("foreignObject");
while (tempG.firstChild) {
if (fo) overlay.insertBefore(tempG.firstChild, fo);
else overlay.appendChild(tempG.firstChild);
}
this.attachThresholdDragListeners(overlay, node);
}
private redrawNodeOnly(node: FlowNode) {
const nodeLayer = this.shadow.getElementById("node-layer");
if (!nodeLayer) return;
const g = nodeLayer.querySelector(`[data-node-id="${node.id}"]`) as SVGGElement | null;
if (!g) return;
const satisfaction = this.computeInflowSatisfaction();
const newSvg = this.renderNodeSvg(node, satisfaction);
const overlay = g.querySelector(".inline-edit-overlay");
const temp = document.createElementNS("http://www.w3.org/2000/svg", "g");
temp.innerHTML = newSvg;
const newG = temp.firstElementChild as SVGGElement;
if (newG && overlay) {
newG.appendChild(overlay);
}
if (newG) {
g.replaceWith(newG);
}
this.scheduleSave();
}
private redrawNodeInlineEdit(node: FlowNode) {
this.drawCanvasContent();
const nodeLayer = this.shadow.getElementById("node-layer");
const g = nodeLayer?.querySelector(`[data-node-id="${node.id}"]`) as SVGGElement | null;
if (!g) return;
g.querySelector(".inline-edit-overlay")?.remove();
const s = this.getNodeSize(node);
const overlay = document.createElementNS("http://www.w3.org/2000/svg", "g");
overlay.classList.add("inline-edit-overlay");
if (node.type === "funnel") {
this.renderFunnelThresholdMarkers(overlay, node, s);
}
const panelW = Math.max(280, s.w);
const panelH = 260;
const panelX = (s.w - panelW) / 2;
const panelY = s.h + 8;
const tabs = ["config", "analytics", "allocations"] as const;
overlay.innerHTML += `
<foreignObject x="${panelX}" y="${panelY}" width="${panelW}" height="${panelH}">
<div xmlns="http://www.w3.org/1999/xhtml" class="inline-config-panel" style="height:${panelH}px">
<div class="icp-tabs">
${tabs.map((t) => `<button class="icp-tab ${t === this.inlineConfigTab ? "icp-tab--active" : ""}" data-icp-tab="${t}">${t === "allocations" ? "Alloc" : t.charAt(0).toUpperCase() + t.slice(1)}</button>`).join("")}
</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>
</div>
</foreignObject>`;
g.appendChild(overlay);
this.attachInlineConfigListeners(g, node);
}
private exitInlineEdit() {
if (!this.inlineEditNodeId) return;
const nodeLayer = this.shadow.getElementById("node-layer");
const g = nodeLayer?.querySelector(`[data-node-id="${this.inlineEditNodeId}"]`) as SVGGElement | null;
if (g) g.querySelector(".inline-edit-overlay")?.remove();
this.inlineEditNodeId = null;
this.inlineEditDragThreshold = null;
this.drawCanvasContent();
}
// ── Analytics accumulation ──
private accumulateNodeAnalytics() {
for (const node of this.nodes) {
let stats = this.nodeAnalytics.get(node.id);
if (!stats) {
stats = { totalInflow: 0, totalOutflow: 0, totalOverflow: 0, avgFillLevel: 0, peakValue: 0, outcomesAchieved: 0, tickCount: 0, fillLevelSum: 0 };
this.nodeAnalytics.set(node.id, stats);
}
stats.tickCount++;
if (node.type === "funnel") {
const d = node.data as FunnelNodeData;
stats.totalInflow += d.inflowRate;
const threshold = d.sufficientThreshold ?? d.maxThreshold;
if (d.currentValue >= d.maxCapacity) {
stats.totalOverflow += d.inflowRate * 0.5;
stats.totalOutflow += d.inflowRate * 0.5;
} else if (d.currentValue >= threshold) {
stats.totalOutflow += d.inflowRate * 0.3;
}
stats.fillLevelSum += d.currentValue;
stats.avgFillLevel = stats.fillLevelSum / stats.tickCount;
stats.peakValue = Math.max(stats.peakValue, d.currentValue);
} else if (node.type === "outcome") {
const d = node.data as OutcomeNodeData;
stats.peakValue = Math.max(stats.peakValue, d.fundingReceived);
stats.outcomesAchieved = d.phases?.filter((p) => d.fundingReceived >= p.fundingThreshold).length || 0;
} else if (node.type === "source") {
const d = node.data as SourceNodeData;
stats.totalOutflow += d.flowRate / 10;
}
}
}
private updateInlineConfigAnalytics() {
if (!this.inlineEditNodeId || this.inlineConfigTab !== "analytics") return;
const node = this.nodes.find((n) => n.id === this.inlineEditNodeId);
if (!node) return;
const nodeLayer = this.shadow.getElementById("node-layer");
const g = nodeLayer?.querySelector(`[data-node-id="${node.id}"]`) as SVGGElement | null;
if (!g) return;
const body = g.querySelector(".icp-body") as HTMLElement | null;
if (body) body.innerHTML = this.renderInlineAnalyticsTab(node);
}
private refreshEditorIfOpen(nodeId: string) {
if (this.editingNodeId === nodeId) this.openEditor(nodeId);
}
private renderSourceEditor(n: FlowNode): string {
const d = n.data as SourceNodeData;
let html = `
<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 (3mo)${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) {
// 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") || "";
if (!d.walletAddress) {
alert("Configure a wallet address first (use rIdentity passkey or enter manually)");
return;
}
this.openTransakWidget(flowId, d.walletAddress);
});
// Connect with EncryptID
backdrop.querySelector('[data-action="connect-ridentity"]')?.addEventListener("click", () => {
window.location.href = "/auth/login?redirect=" + encodeURIComponent(window.location.pathname);
});
}
// ─── Node CRUD ────────────────────────────────────────
private addNode(type: "source" | "funnel" | "outcome") {
// Place at center of current viewport
const svg = this.shadow.getElementById("flow-canvas") as SVGSVGElement | null;
const rect = svg?.getBoundingClientRect();
const cx = rect ? (rect.width / 2 - this.canvasPanX) / this.canvasZoom : 400;
const cy = rect ? (rect.height / 2 - this.canvasPanY) / this.canvasZoom : 300;
const id = `${type}-${Date.now().toString(36)}`;
let data: any;
if (type === "source") {
data = { label: "New Source", flowRate: 1000, sourceType: "card", targetAllocations: [] } as SourceNodeData;
} else if (type === "funnel") {
data = {
label: "New Funnel", currentValue: 0, desiredOutflow: 5000,
minThreshold: 5000, sufficientThreshold: 15000, maxThreshold: 30000,
maxCapacity: 45000, inflowRate: 0, dynamicOverflow: false,
overflowAllocations: [], spendingAllocations: [],
} as FunnelNodeData;
} else {
data = { label: "New Outcome", description: "", fundingReceived: 0, fundingTarget: 10000, status: "not-started", overflowAllocations: [] } as OutcomeNodeData;
}
this.nodes.push({ id, type, position: { x: cx - 100, y: cy - 50 }, data });
this.drawCanvasContent();
this.selectedNodeId = id;
this.updateSelectionHighlight();
this.openEditor(id);
this.scheduleSave();
}
private deleteNode(nodeId: string) {
this.nodes = this.nodes.filter((n) => n.id !== nodeId);
// Clean up allocations pointing to deleted node
for (const n of this.nodes) {
if (n.type === "source") {
const d = n.data as SourceNodeData;
d.targetAllocations = d.targetAllocations.filter((a) => a.targetId !== nodeId);
}
if (n.type === "funnel") {
const d = n.data as FunnelNodeData;
d.overflowAllocations = d.overflowAllocations.filter((a) => a.targetId !== nodeId);
d.spendingAllocations = d.spendingAllocations.filter((a) => a.targetId !== nodeId);
}
if (n.type === "outcome") {
const d = n.data as OutcomeNodeData;
if (d.overflowAllocations) d.overflowAllocations = d.overflowAllocations.filter((a) => a.targetId !== nodeId);
}
}
if (this.selectedNodeId === nodeId) this.selectedNodeId = null;
this.drawCanvasContent();
this.updateSufficiencyBadge();
this.scheduleSave();
}
// ─── Simulation ───────────────────────────────────────
private toggleSimulation() {
this.isSimulating = !this.isSimulating;
const btn = this.shadow.getElementById("sim-btn");
if (btn) btn.textContent = this.isSimulating ? "Pause" : "Play";
// Show/hide speed slider and timeline
const speedContainer = this.shadow.getElementById("sim-speed-container");
const timelineContainer = this.shadow.getElementById("sim-timeline");
if (speedContainer) speedContainer.style.display = this.isSimulating ? "flex" : "none";
if (timelineContainer) timelineContainer.style.display = this.isSimulating ? "flex" : "none";
if (this.isSimulating) {
this.simTickCount = 0;
this.nodeAnalytics.clear();
this.startSimInterval();
} else {
if (this.simInterval) { clearInterval(this.simInterval); this.simInterval = null; }
}
}
private startSimInterval() {
if (this.simInterval) clearInterval(this.simInterval);
this.simInterval = setInterval(() => {
this.simTickCount++;
this.nodes = simulateTick(this.nodes);
this.accumulateNodeAnalytics();
this.updateCanvasLive();
}, this.simSpeedMs);
}
/** Update canvas nodes and edges without full innerHTML rebuild during simulation */
private updateCanvasLive() {
const nodeLayer = this.shadow.getElementById("node-layer");
if (!nodeLayer) return;
// Try to patch fill rects in-place for smooth CSS transitions
let didPatch = false;
for (const n of this.nodes) {
if (n.type !== "funnel") continue;
const d = n.data as FunnelNodeData;
const s = this.getNodeSize(n);
const h = s.h;
const zoneTop = 28;
const zoneBot = h - 4;
const zoneH = zoneBot - zoneTop;
const fillPct = Math.min(1, d.currentValue / (d.maxCapacity || 1));
const totalFillH = zoneH * fillPct;
const fillY = zoneTop + zoneH - totalFillH;
const fillRect = nodeLayer.querySelector(`.funnel-fill-rect[data-node-id="${n.id}"]`) as SVGRectElement | null;
if (fillRect) {
fillRect.setAttribute("y", String(fillY));
fillRect.setAttribute("height", String(totalFillH));
didPatch = true;
}
// Patch value text
const threshold = d.sufficientThreshold ?? d.maxThreshold;
const valText = nodeLayer.querySelector(`.funnel-value-text[data-node-id="${n.id}"]`) as SVGTextElement | null;
if (valText) {
valText.textContent = `$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()}`;
}
}
// Preserve inline config overlay during rebuild
let overlayNodeId: string | null = null;
let detachedOverlay: Element | null = null;
if (this.inlineEditNodeId) {
overlayNodeId = this.inlineEditNodeId;
const existingG = nodeLayer.querySelector(`[data-node-id="${overlayNodeId}"]`);
detachedOverlay = existingG?.querySelector(".inline-edit-overlay") || null;
if (detachedOverlay) detachedOverlay.remove();
}
nodeLayer.innerHTML = this.renderAllNodes();
// Reattach overlay to the new node <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);