1827 lines
72 KiB
TypeScript
1827 lines
72 KiB
TypeScript
/**
|
|
* <folk-funds-app> — main rFunds application component.
|
|
*
|
|
* Views:
|
|
* "landing" — TBFF info hero + flow list cards
|
|
* "detail" — Flow detail with tabs: Table | River | Transactions
|
|
*
|
|
* Attributes:
|
|
* space — space slug
|
|
* flow-id — if set, go straight to detail view
|
|
* mode — "demo" to use hardcoded demo data (no API)
|
|
*/
|
|
|
|
import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData, PortDefinition, PortKind } from "../lib/types";
|
|
import { PORT_DEFS } from "../lib/types";
|
|
import { computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation";
|
|
import { demoNodes, SPENDING_COLORS, OVERFLOW_COLORS } from "../lib/presets";
|
|
import { mapFlowToNodes } from "../lib/map-flow";
|
|
|
|
interface FlowSummary {
|
|
id: string;
|
|
name: string;
|
|
label?: string;
|
|
status?: string;
|
|
funnelCount?: number;
|
|
outcomeCount?: number;
|
|
totalValue?: number;
|
|
}
|
|
|
|
interface Transaction {
|
|
id: string;
|
|
type: string;
|
|
amount: number;
|
|
from?: string;
|
|
to?: string;
|
|
timestamp: string;
|
|
description?: string;
|
|
}
|
|
|
|
type View = "landing" | "detail";
|
|
type Tab = "diagram" | "table" | "river" | "transactions";
|
|
|
|
// ─── Auth helpers (reads EncryptID session from localStorage) ──
|
|
|
|
function getSession(): { accessToken: string; claims: { sub: string; username?: string } } | null {
|
|
try {
|
|
const raw = localStorage.getItem("encryptid_session");
|
|
if (!raw) return null;
|
|
const session = JSON.parse(raw);
|
|
if (!session?.accessToken) return null;
|
|
return session;
|
|
} catch { return null; }
|
|
}
|
|
|
|
function isAuthenticated(): boolean { return getSession() !== null; }
|
|
function getAccessToken(): string | null { return getSession()?.accessToken ?? null; }
|
|
function getUsername(): string | null { return getSession()?.claims?.username ?? null; }
|
|
|
|
class FolkFundsApp extends HTMLElement {
|
|
private shadow: ShadowRoot;
|
|
private space = "";
|
|
private view: View = "landing";
|
|
private tab: Tab = "diagram";
|
|
private flowId = "";
|
|
private isDemo = false;
|
|
|
|
private flows: FlowSummary[] = [];
|
|
private nodes: FlowNode[] = [];
|
|
private flowName = "";
|
|
private transactions: Transaction[] = [];
|
|
private txLoaded = false;
|
|
|
|
private loading = false;
|
|
private error = "";
|
|
|
|
// Canvas state
|
|
private canvasZoom = 1;
|
|
private canvasPanX = 0;
|
|
private canvasPanY = 0;
|
|
private selectedNodeId: string | null = null;
|
|
private draggingNodeId: string | null = null;
|
|
private dragStartX = 0;
|
|
private dragStartY = 0;
|
|
private dragNodeStartX = 0;
|
|
private dragNodeStartY = 0;
|
|
private isPanning = false;
|
|
private panStartX = 0;
|
|
private panStartY = 0;
|
|
private panStartPanX = 0;
|
|
private panStartPanY = 0;
|
|
private editingNodeId: string | null = null;
|
|
private isSimulating = false;
|
|
private simInterval: ReturnType<typeof setInterval> | null = null;
|
|
private canvasInitialized = false;
|
|
|
|
// Wiring state
|
|
private wiringActive = false;
|
|
private wiringSourceNodeId: string | null = null;
|
|
private wiringSourcePortKind: PortKind | null = null;
|
|
private wiringDragging = false;
|
|
private wiringPointerX = 0;
|
|
private wiringPointerY = 0;
|
|
|
|
// Bound handlers for cleanup
|
|
private _boundKeyDown: ((e: KeyboardEvent) => void) | null = null;
|
|
private _boundPointerMove: ((e: PointerEvent) => void) | null = null;
|
|
private _boundPointerUp: ((e: PointerEvent) => void) | null = null;
|
|
|
|
constructor() {
|
|
super();
|
|
this.shadow = this.attachShadow({ mode: "open" });
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.space = this.getAttribute("space") || "demo";
|
|
this.flowId = this.getAttribute("flow-id") || "";
|
|
this.isDemo = this.getAttribute("mode") === "demo" || this.space === "demo";
|
|
|
|
if (this.isDemo) {
|
|
this.view = "detail";
|
|
this.flowName = "TBFF Demo Flow";
|
|
this.nodes = demoNodes.map((n) => ({ ...n, data: { ...n.data } }));
|
|
this.render();
|
|
} else if (this.flowId) {
|
|
this.view = "detail";
|
|
this.loadFlow(this.flowId);
|
|
} else {
|
|
this.view = "landing";
|
|
this.loadFlows();
|
|
}
|
|
}
|
|
|
|
private getApiBase(): string {
|
|
const match = window.location.pathname.match(/^\/([^/]+)\/rfunds/);
|
|
if (match) return `/${match[1]}/rfunds`;
|
|
if (this.space) return `/${this.space}/rfunds`;
|
|
return "";
|
|
}
|
|
|
|
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/rfunds/funds.css | Standalone: /modules/rfunds/funds.css
|
|
// The shell always serves from /modules/rfunds/ in both modes
|
|
return "/modules/rfunds/funds.css";
|
|
}
|
|
|
|
private render() {
|
|
this.shadow.innerHTML = `
|
|
<style>
|
|
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e2e8f0; }
|
|
*, *::before, *::after { box-sizing: border-box; }
|
|
</style>
|
|
<link rel="stylesheet" href="${this.getCssPath()}">
|
|
${this.error ? `<div class="funds-error">${this.esc(this.error)}</div>` : ""}
|
|
${this.loading && this.view === "landing" ? '<div class="funds-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().replace(/\/funds$/, "")}/funds/demo` : "/demo";
|
|
const authed = isAuthenticated();
|
|
const username = getUsername();
|
|
|
|
return `
|
|
<div class="funds-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:#64748b">Sign in to create flows</span>`
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="funds-desc" style="color:#94a3b8;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 —
|
|
ensuring every level has <em style="color:#fbbf24;font-style:normal;font-weight:600">enough</em> before abundance cascades forward.
|
|
</div>
|
|
|
|
<div class="funds-features">
|
|
<div class="funds-features__grid">
|
|
<div class="funds-features__card">
|
|
<div class="funds-features__icon">💰</div>
|
|
<h3>Sources</h3>
|
|
<p>Revenue streams split across funnels by configurable allocation percentages.</p>
|
|
</div>
|
|
<div class="funds-features__card">
|
|
<div class="funds-features__icon">🏛</div>
|
|
<h3>Funnels</h3>
|
|
<p>Budget buckets with min/max thresholds and sufficiency-based overflow cascading.</p>
|
|
</div>
|
|
<div class="funds-features__card">
|
|
<div class="funds-features__icon">🎯</div>
|
|
<h3>Outcomes</h3>
|
|
<p>Funding targets that receive spending allocations. Track progress toward each goal.</p>
|
|
</div>
|
|
<div class="funds-features__card">
|
|
<div class="funds-features__icon">🌊</div>
|
|
<h3>River View</h3>
|
|
<p>Animated sankey diagram showing live fund flows through your entire system.</p>
|
|
</div>
|
|
<div class="funds-features__card">
|
|
<div class="funds-features__icon">✨</div>
|
|
<h3>Enoughness</h3>
|
|
<p>System-wide sufficiency scoring. Golden glow when funnels reach their threshold.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="funds-flows">
|
|
<div class="funds-flows__header">
|
|
<h2 class="funds-flows__heading">${authed ? `Flows in ${this.esc(this.space)}` : "Your Flows"}</h2>
|
|
${authed ? `<span class="funds-flows__user">Signed in as ${this.esc(username || "")}</span>` : ""}
|
|
</div>
|
|
${this.flows.length > 0 ? `
|
|
<div class="funds-flows__grid">
|
|
${this.flows.map((f) => this.renderFlowCard(f)).join("")}
|
|
</div>
|
|
` : `
|
|
<div class="funds-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’s flows, or <a href="${this.esc(demoUrl)}">explore the demo</a>.</p>`
|
|
}
|
|
</div>
|
|
`}
|
|
</div>
|
|
|
|
<div class="funds-about">
|
|
<h2 class="funds-about__heading">How TBFF Works</h2>
|
|
<div class="funds-about__steps">
|
|
<div class="funds-about__step">
|
|
<div class="funds-about__step-num">1</div>
|
|
<div>
|
|
<h3>Define Sources</h3>
|
|
<p>Add revenue streams — grants, donations, sales, or any recurring income — with allocation splits.</p>
|
|
</div>
|
|
</div>
|
|
<div class="funds-about__step">
|
|
<div class="funds-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="funds-about__step">
|
|
<div class="funds-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().replace(/\/funds$/, "")}/funds/flow/${encodeURIComponent(f.id)}`
|
|
: `/flow/${encodeURIComponent(f.id)}`;
|
|
const value = f.totalValue != null ? `$${Math.floor(f.totalValue).toLocaleString()}` : "";
|
|
|
|
return `
|
|
<a href="${this.esc(detailUrl)}" class="funds-flow-card" data-flow="${this.esc(f.id)}">
|
|
<div class="funds-flow-card__name">${this.esc(f.name || f.label || f.id)}</div>
|
|
${value ? `<div class="funds-flow-card__value">${value}</div>` : ""}
|
|
<div class="funds-flow-card__meta">
|
|
${f.funnelCount != null ? `${f.funnelCount} funnels` : ""}
|
|
${f.outcomeCount != null ? ` · ${f.outcomeCount} outcomes` : ""}
|
|
${f.status ? ` · ${f.status}` : ""}
|
|
</div>
|
|
</a>`;
|
|
}
|
|
|
|
// ─── Detail view with tabs ─────────────────────────────
|
|
|
|
private renderDetail(): string {
|
|
const backUrl = this.getApiBase()
|
|
? `${this.getApiBase().replace(/\/funds$/, "")}/funds/`
|
|
: "/";
|
|
|
|
return `
|
|
<div class="funds-detail">
|
|
<div class="rapp-nav">
|
|
<a href="${this.esc(backUrl)}" class="rapp-nav__back">← 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="funds-tabs">
|
|
<button class="funds-tab ${this.tab === "diagram" ? "funds-tab--active" : ""}" data-tab="diagram">Diagram</button>
|
|
<button class="funds-tab ${this.tab === "river" ? "funds-tab--active" : ""}" data-tab="river">River</button>
|
|
<button class="funds-tab ${this.tab === "table" ? "funds-tab--active" : ""}" data-tab="table">Table</button>
|
|
<button class="funds-tab ${this.tab === "transactions" ? "funds-tab--active" : ""}" data-tab="transactions">Transactions</button>
|
|
</div>
|
|
|
|
<div class="funds-tab-content">
|
|
${this.loading ? '<div class="funds-loading">Loading...</div>' : this.renderTab()}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
private renderTab(): string {
|
|
if (this.tab === "diagram") return this.renderDiagramTab();
|
|
if (this.tab === "river") return this.renderRiverTab();
|
|
if (this.tab === "transactions") return this.renderTransactionsTab();
|
|
return this.renderTableTab();
|
|
}
|
|
|
|
// ─── Table tab ────────────────────────────────────────
|
|
|
|
private renderTableTab(): string {
|
|
const funnels = this.nodes.filter((n) => n.type === "funnel");
|
|
const outcomes = this.nodes.filter((n) => n.type === "outcome");
|
|
const sources = this.nodes.filter((n) => n.type === "source");
|
|
|
|
return `
|
|
<div class="funds-table">
|
|
${sources.length > 0 ? `
|
|
<div class="funds-section">
|
|
<h3 class="funds-section__title">Sources</h3>
|
|
<div class="funds-cards">
|
|
${sources.map((n) => this.renderSourceCard(n.data as SourceNodeData, n.id)).join("")}
|
|
</div>
|
|
</div>
|
|
` : ""}
|
|
|
|
<div class="funds-section">
|
|
<h3 class="funds-section__title">Funnels</h3>
|
|
<div class="funds-cards">
|
|
${funnels.map((n) => this.renderFunnelCard(n.data as FunnelNodeData, n.id)).join("")}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="funds-section">
|
|
<h3 class="funds-section__title">Outcomes</h3>
|
|
<div class="funds-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="funds-card">
|
|
<div class="funds-card__header">
|
|
<span class="funds-card__icon">💰</span>
|
|
<span class="funds-card__label">${this.esc(data.label)}</span>
|
|
<span class="funds-card__type">${data.sourceType}</span>
|
|
</div>
|
|
<div class="funds-card__stat">
|
|
<span class="funds-card__stat-value">$${data.flowRate.toLocaleString()}</span>
|
|
<span class="funds-card__stat-label">/month</span>
|
|
</div>
|
|
${allocations.length > 0 ? `
|
|
<div class="funds-card__allocs">
|
|
${allocations.map((a) => `
|
|
<div class="funds-card__alloc">
|
|
<span class="funds-card__alloc-dot" style="background:${a.color}"></span>
|
|
${a.percentage}% → ${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" ? "funds-status--abundant"
|
|
: sufficiency === "sufficient" ? "funds-status--sufficient"
|
|
: data.currentValue < data.minThreshold ? "funds-status--critical"
|
|
: "funds-status--seeking";
|
|
|
|
const statusLabel = sufficiency === "abundant" ? "Abundant"
|
|
: sufficiency === "sufficient" ? "Sufficient"
|
|
: data.currentValue < data.minThreshold ? "Critical"
|
|
: "Seeking";
|
|
|
|
return `
|
|
<div class="funds-card">
|
|
<div class="funds-card__header">
|
|
<span class="funds-card__icon">🏛</span>
|
|
<span class="funds-card__label">${this.esc(data.label)}</span>
|
|
<span class="funds-card__status ${statusClass}">${statusLabel}</span>
|
|
</div>
|
|
<div class="funds-card__bar-container">
|
|
<div class="funds-card__bar" style="width:${fillPct}%"></div>
|
|
<div class="funds-card__bar-threshold" style="left:${Math.min(100, (threshold / (data.maxCapacity || 1)) * 100)}%"></div>
|
|
</div>
|
|
<div class="funds-card__stats">
|
|
<div>
|
|
<span class="funds-card__stat-value">$${Math.floor(data.currentValue).toLocaleString()}</span>
|
|
<span class="funds-card__stat-label">/ $${Math.floor(threshold).toLocaleString()}</span>
|
|
</div>
|
|
<div>
|
|
<span class="funds-card__stat-value">${Math.round(suffPct)}%</span>
|
|
<span class="funds-card__stat-label">sufficiency</span>
|
|
</div>
|
|
</div>
|
|
<div class="funds-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="funds-card__allocs">
|
|
<div class="funds-card__alloc-title">Overflow</div>
|
|
${data.overflowAllocations.map((a) => `
|
|
<div class="funds-card__alloc">
|
|
<span class="funds-card__alloc-dot" style="background:${a.color}"></span>
|
|
${a.percentage}% → ${this.esc(this.getNodeLabel(a.targetId))}
|
|
</div>
|
|
`).join("")}
|
|
</div>
|
|
` : ""}
|
|
${data.spendingAllocations.length > 0 ? `
|
|
<div class="funds-card__allocs">
|
|
<div class="funds-card__alloc-title">Spending</div>
|
|
${data.spendingAllocations.map((a) => `
|
|
<div class="funds-card__alloc">
|
|
<span class="funds-card__alloc-dot" style="background:${a.color}"></span>
|
|
${a.percentage}% → ${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" ? "#10b981"
|
|
: data.status === "blocked" ? "#ef4444"
|
|
: data.status === "in-progress" ? "#3b82f6"
|
|
: "#64748b";
|
|
|
|
return `
|
|
<div class="funds-card">
|
|
<div class="funds-card__header">
|
|
<span class="funds-card__icon">🎯</span>
|
|
<span class="funds-card__label">${this.esc(data.label)}</span>
|
|
<span class="funds-card__status" style="color:${statusColor}">${data.status}</span>
|
|
</div>
|
|
${data.description ? `<div class="funds-card__desc">${this.esc(data.description)}</div>` : ""}
|
|
<div class="funds-card__bar-container">
|
|
<div class="funds-card__bar funds-card__bar--outcome" style="width:${fillPct}%;background:${statusColor}"></div>
|
|
</div>
|
|
<div class="funds-card__stats">
|
|
<div>
|
|
<span class="funds-card__stat-value">$${Math.floor(data.fundingReceived).toLocaleString()}</span>
|
|
<span class="funds-card__stat-label">/ $${Math.floor(data.fundingTarget).toLocaleString()}</span>
|
|
</div>
|
|
<div>
|
|
<span class="funds-card__stat-value">${Math.round(fillPct)}%</span>
|
|
<span class="funds-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="funds-loading">No nodes to display.</div>';
|
|
}
|
|
|
|
const score = computeSystemSufficiency(this.nodes);
|
|
const scorePct = Math.round(score * 100);
|
|
const scoreColor = scorePct >= 90 ? "#fbbf24" : scorePct >= 60 ? "#10b981" : scorePct >= 30 ? "#f59e0b" : "#ef4444";
|
|
|
|
return `
|
|
<div class="funds-canvas-container" id="canvas-container">
|
|
<div class="funds-canvas-badge" id="canvas-badge">
|
|
<div>
|
|
<div class="funds-canvas-badge__score" id="badge-score" style="color:${scoreColor}">${scorePct}%</div>
|
|
<div class="funds-canvas-badge__label">ENOUGH</div>
|
|
</div>
|
|
</div>
|
|
<div class="funds-canvas-toolbar">
|
|
<button class="funds-canvas-btn funds-canvas-btn--source" data-canvas-action="add-source">+ Source</button>
|
|
<button class="funds-canvas-btn funds-canvas-btn--funnel" data-canvas-action="add-funnel">+ Funnel</button>
|
|
<button class="funds-canvas-btn funds-canvas-btn--outcome" data-canvas-action="add-outcome">+ Outcome</button>
|
|
<div class="funds-canvas-sep"></div>
|
|
<button class="funds-canvas-btn" data-canvas-action="sim" id="sim-btn">${this.isSimulating ? "Pause" : "Play"}</button>
|
|
<button class="funds-canvas-btn" data-canvas-action="fit">Fit</button>
|
|
<button class="funds-canvas-btn" data-canvas-action="share">Share</button>
|
|
</div>
|
|
<svg class="funds-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="funds-editor-panel" id="editor-panel"></div>
|
|
<div class="funds-canvas-legend">
|
|
<span class="funds-canvas-legend-item"><span class="funds-canvas-legend-dot" style="background:#10b981"></span>Source</span>
|
|
<span class="funds-canvas-legend-item"><span class="funds-canvas-legend-dot" style="background:#0ea5e9"></span>Funnel</span>
|
|
<span class="funds-canvas-legend-item"><span class="funds-canvas-legend-dot" style="background:#f59e0b"></span>Overflow</span>
|
|
<span class="funds-canvas-legend-item"><span class="funds-canvas-legend-dot" style="background:#8b5cf6"></span>Spending</span>
|
|
<span class="funds-canvas-legend-item"><span class="funds-canvas-legend-dot" style="background:#3b82f6"></span>Outcome</span>
|
|
<span class="funds-canvas-legend-item"><span class="funds-canvas-legend-dot" style="background:#fbbf24"></span>Sufficient</span>
|
|
</div>
|
|
<div class="funds-canvas-zoom">
|
|
<button class="funds-canvas-btn" data-canvas-action="zoom-in">+</button>
|
|
<button class="funds-canvas-btn" data-canvas-action="zoom-out">−</button>
|
|
</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})`);
|
|
}
|
|
|
|
private fitView() {
|
|
const svg = this.shadow.getElementById("flow-canvas") as SVGSVGElement | null;
|
|
if (!svg || this.nodes.length === 0) return;
|
|
const rect = svg.getBoundingClientRect();
|
|
if (rect.width === 0 || rect.height === 0) return;
|
|
|
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
for (const n of this.nodes) {
|
|
const s = this.getNodeSize(n);
|
|
minX = Math.min(minX, n.position.x);
|
|
minY = Math.min(minY, n.position.y);
|
|
maxX = Math.max(maxX, n.position.x + s.w);
|
|
maxY = Math.max(maxY, n.position.y + s.h);
|
|
}
|
|
const pad = 60;
|
|
const contentW = maxX - minX + pad * 2;
|
|
const contentH = maxY - minY + pad * 2;
|
|
const scaleX = rect.width / contentW;
|
|
const scaleY = rect.height / contentH;
|
|
this.canvasZoom = Math.min(scaleX, scaleY, 1.5);
|
|
this.canvasPanX = (rect.width - contentW * this.canvasZoom) / 2 - (minX - pad) * this.canvasZoom;
|
|
this.canvasPanY = (rect.height - contentH * this.canvasZoom) / 2 - (minY - pad) * this.canvasZoom;
|
|
this.updateCanvasTransform();
|
|
}
|
|
|
|
private getNodeSize(n: FlowNode): { w: number; h: number } {
|
|
if (n.type === "source") return { w: 200, h: 60 };
|
|
if (n.type === "funnel") return { w: 220, h: 160 };
|
|
return { w: 200, h: 100 }; // outcome
|
|
}
|
|
|
|
// ─── Canvas event wiring ──────────────────────────────
|
|
|
|
private attachCanvasListeners() {
|
|
const svg = this.shadow.getElementById("flow-canvas");
|
|
if (!svg) return;
|
|
|
|
// Wheel zoom
|
|
svg.addEventListener("wheel", (e: WheelEvent) => {
|
|
e.preventDefault();
|
|
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
|
const rect = svg.getBoundingClientRect();
|
|
const mx = e.clientX - rect.left;
|
|
const my = e.clientY - rect.top;
|
|
const newZoom = Math.max(0.1, Math.min(4, this.canvasZoom * delta));
|
|
// Zoom toward pointer
|
|
this.canvasPanX = mx - (mx - this.canvasPanX) * (newZoom / this.canvasZoom);
|
|
this.canvasPanY = my - (my - this.canvasPanY) * (newZoom / this.canvasZoom);
|
|
this.canvasZoom = newZoom;
|
|
this.updateCanvasTransform();
|
|
}, { passive: false });
|
|
|
|
// Panning — pointerdown on SVG background
|
|
svg.addEventListener("pointerdown", (e: PointerEvent) => {
|
|
const target = e.target as Element;
|
|
// Only pan when clicking SVG background (not on a node)
|
|
if (target.closest(".flow-node")) return;
|
|
if (target.closest(".edge-ctrl-group")) return;
|
|
|
|
// Cancel wiring on empty canvas click
|
|
if (this.wiringActive) { this.cancelWiring(); return; }
|
|
|
|
this.isPanning = true;
|
|
this.panStartX = e.clientX;
|
|
this.panStartY = e.clientY;
|
|
this.panStartPanX = this.canvasPanX;
|
|
this.panStartPanY = this.canvasPanY;
|
|
svg.classList.add("panning");
|
|
svg.setPointerCapture(e.pointerId);
|
|
|
|
// Deselect node
|
|
if (!target.closest(".flow-node")) {
|
|
this.selectedNodeId = null;
|
|
this.updateSelectionHighlight();
|
|
}
|
|
});
|
|
|
|
// Global pointer move/up (for both panning and node drag)
|
|
this._boundPointerMove = (e: PointerEvent) => {
|
|
if (this.wiringActive && this.wiringDragging) {
|
|
this.wiringPointerX = e.clientX;
|
|
this.wiringPointerY = e.clientY;
|
|
this.updateWiringTempLine();
|
|
return;
|
|
}
|
|
if (this.isPanning) {
|
|
this.canvasPanX = this.panStartPanX + (e.clientX - this.panStartX);
|
|
this.canvasPanY = this.panStartPanY + (e.clientY - this.panStartY);
|
|
this.updateCanvasTransform();
|
|
return;
|
|
}
|
|
if (this.draggingNodeId) {
|
|
const dx = (e.clientX - this.dragStartX) / this.canvasZoom;
|
|
const dy = (e.clientY - this.dragStartY) / this.canvasZoom;
|
|
const node = this.nodes.find((n) => n.id === this.draggingNodeId);
|
|
if (node) {
|
|
node.position.x = this.dragNodeStartX + dx;
|
|
node.position.y = this.dragNodeStartY + dy;
|
|
this.updateNodePosition(node);
|
|
this.redrawEdges();
|
|
}
|
|
}
|
|
};
|
|
this._boundPointerUp = (e: PointerEvent) => {
|
|
if (this.wiringActive && this.wiringDragging) {
|
|
// Hit-test: did we release on a compatible input port?
|
|
const el = this.shadow.elementFromPoint(e.clientX, e.clientY);
|
|
const portGroup = el?.closest?.(".port-group") as SVGGElement | null;
|
|
if (portGroup && portGroup.dataset.portDir === "in" && portGroup.dataset.nodeId !== this.wiringSourceNodeId) {
|
|
this.completeWiring(portGroup.dataset.nodeId!);
|
|
} else {
|
|
// Fall back to click-to-wire mode (source still glowing)
|
|
this.wiringDragging = false;
|
|
const wireLayer = this.shadow.getElementById("wire-layer");
|
|
if (wireLayer) wireLayer.innerHTML = "";
|
|
}
|
|
return;
|
|
}
|
|
if (this.isPanning) {
|
|
this.isPanning = false;
|
|
svg.classList.remove("panning");
|
|
}
|
|
if (this.draggingNodeId) {
|
|
this.draggingNodeId = null;
|
|
svg.classList.remove("dragging");
|
|
}
|
|
};
|
|
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") {
|
|
this.enterWiring(portNodeId, portKind);
|
|
this.wiringDragging = true;
|
|
this.wiringPointerX = e.clientX;
|
|
this.wiringPointerY = e.clientY;
|
|
svg.setPointerCapture(e.pointerId);
|
|
return;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const group = (e.target as Element).closest(".flow-node") as SVGGElement | null;
|
|
if (!group) return;
|
|
e.stopPropagation();
|
|
const nodeId = group.dataset.nodeId;
|
|
if (!nodeId) return;
|
|
const node = this.nodes.find((n) => n.id === nodeId);
|
|
if (!node) return;
|
|
|
|
// If wiring is active and clicked on a node (not port), cancel
|
|
if (this.wiringActive) {
|
|
this.cancelWiring();
|
|
return;
|
|
}
|
|
|
|
// Select
|
|
this.selectedNodeId = nodeId;
|
|
this.updateSelectionHighlight();
|
|
|
|
// Start drag
|
|
this.draggingNodeId = nodeId;
|
|
this.dragStartX = e.clientX;
|
|
this.dragStartY = e.clientY;
|
|
this.dragNodeStartX = node.position.x;
|
|
this.dragNodeStartY = node.position.y;
|
|
svg.classList.add("dragging");
|
|
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) this.openEditor(nodeId);
|
|
});
|
|
}
|
|
|
|
// Toolbar buttons
|
|
this.shadow.querySelectorAll("[data-canvas-action]").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const action = (btn as HTMLElement).dataset.canvasAction;
|
|
if (action === "add-source") this.addNode("source");
|
|
else if (action === "add-funnel") this.addNode("funnel");
|
|
else if (action === "add-outcome") this.addNode("outcome");
|
|
else if (action === "sim") this.toggleSimulation();
|
|
else if (action === "fit") this.fitView();
|
|
else if (action === "share") this.shareState();
|
|
else if (action === "zoom-in") { this.canvasZoom = Math.min(4, this.canvasZoom * 1.2); this.updateCanvasTransform(); }
|
|
else if (action === "zoom-out") { this.canvasZoom = Math.max(0.1, this.canvasZoom * 0.8); this.updateCanvasTransform(); }
|
|
});
|
|
});
|
|
|
|
// Edge +/- buttons (delegated)
|
|
const edgeLayer = this.shadow.getElementById("edge-layer");
|
|
if (edgeLayer) {
|
|
edgeLayer.addEventListener("click", (e: Event) => {
|
|
const btn = (e.target as Element).closest("[data-edge-action]") as HTMLElement | null;
|
|
if (!btn) return;
|
|
e.stopPropagation();
|
|
const action = btn.dataset.edgeAction; // "inc" or "dec"
|
|
const fromId = btn.dataset.edgeFrom!;
|
|
const toId = btn.dataset.edgeTo!;
|
|
const allocType = btn.dataset.edgeType as "overflow" | "spending" | "source";
|
|
this.handleAdjustAllocation(fromId, toId, allocType, action === "inc" ? 5 : -5);
|
|
});
|
|
}
|
|
|
|
// 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.wiringActive) { this.cancelWiring(); return; }
|
|
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);
|
|
}
|
|
|
|
// ─── Node SVG rendering ───────────────────────────────
|
|
|
|
private renderAllNodes(): string {
|
|
return this.nodes.map((n) => this.renderNodeSvg(n)).join("");
|
|
}
|
|
|
|
private renderNodeSvg(n: FlowNode): 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);
|
|
return this.renderOutcomeNodeSvg(n, sel);
|
|
}
|
|
|
|
private renderSourceNodeSvg(n: FlowNode, selected: boolean): string {
|
|
const d = n.data as SourceNodeData;
|
|
const x = n.position.x, y = n.position.y, w = 200, h = 60;
|
|
const icons: Record<string, string> = { card: "\u{1F4B3}", safe_wallet: "\u{1F512}", ridentity: "\u{1F464}", unconfigured: "\u{2699}" };
|
|
const icon = icons[d.sourceType] || "\u{1F4B0}";
|
|
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="${h}" rx="8" fill="#064e3b" stroke="${selected ? "#6366f1" : "#10b981"}" stroke-width="${selected ? 3 : 2}"/>
|
|
<text x="14" y="24" fill="#e2e8f0" font-size="15">${icon}</text>
|
|
<text x="36" y="24" fill="#e2e8f0" font-size="13" font-weight="600">${this.esc(d.label)}</text>
|
|
<text x="${w / 2}" y="46" text-anchor="middle" fill="#6ee7b7" font-size="11">$${d.flowRate.toLocaleString()}/mo</text>
|
|
${this.renderAllocBar(d.targetAllocations, w, h - 6)}
|
|
${this.renderPortsSvg(n)}
|
|
</g>`;
|
|
}
|
|
|
|
private renderFunnelNodeSvg(n: FlowNode, selected: boolean): string {
|
|
const d = n.data as FunnelNodeData;
|
|
const x = n.position.x, y = n.position.y, w = 220, h = 160;
|
|
const sufficiency = computeSufficiencyState(d);
|
|
const isSufficient = sufficiency === "sufficient" || sufficiency === "abundant";
|
|
const threshold = d.sufficientThreshold ?? d.maxThreshold;
|
|
const fillPct = Math.min(1, d.currentValue / (d.maxCapacity || 1));
|
|
|
|
const borderColor = d.currentValue > d.maxThreshold ? "#f59e0b"
|
|
: d.currentValue < d.minThreshold ? "#ef4444"
|
|
: isSufficient ? "#fbbf24" : "#0ea5e9";
|
|
const fillColor = borderColor;
|
|
const statusLabel = sufficiency === "abundant" ? "Abundant"
|
|
: sufficiency === "sufficient" ? "Sufficient"
|
|
: d.currentValue < d.minThreshold ? "Critical" : "Seeking";
|
|
|
|
// 3-zone background: drain (red), healthy (blue), overflow (amber)
|
|
const zoneH = h - 56; // area for zones (below header, above value text)
|
|
const zoneY = 32;
|
|
const drainPct = d.minThreshold / (d.maxCapacity || 1);
|
|
const healthyPct = (d.maxThreshold - d.minThreshold) / (d.maxCapacity || 1);
|
|
const overflowPct = 1 - drainPct - healthyPct;
|
|
const drainH = zoneH * drainPct;
|
|
const healthyH = zoneH * healthyPct;
|
|
const overflowH = zoneH * overflowPct;
|
|
|
|
// Fill level
|
|
const totalFillH = zoneH * fillPct;
|
|
const fillY = zoneY + zoneH - totalFillH;
|
|
|
|
const glowClass = isSufficient ? " node-glow" : "";
|
|
|
|
return `<g class="flow-node${glowClass} ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})">
|
|
${isSufficient ? `<rect x="-3" y="-3" width="${w + 6}" height="${h + 6}" rx="14" fill="none" stroke="#fbbf24" stroke-width="2" opacity="0.4"/>` : ""}
|
|
<rect class="node-bg" x="0" y="0" width="${w}" height="${h}" rx="10" fill="#1e293b" stroke="${selected ? "#6366f1" : borderColor}" stroke-width="${selected ? 3 : 2}"/>
|
|
<rect x="2" y="${zoneY + overflowH + healthyH}" width="${w - 4}" height="${drainH}" fill="#ef4444" opacity="0.08" rx="0"/>
|
|
<rect x="2" y="${zoneY + overflowH}" width="${w - 4}" height="${healthyH}" fill="#0ea5e9" opacity="0.06" rx="0"/>
|
|
<rect x="2" y="${zoneY}" width="${w - 4}" height="${overflowH}" fill="#f59e0b" opacity="0.06" rx="0"/>
|
|
<rect x="2" y="${fillY}" width="${w - 4}" height="${totalFillH}" fill="${fillColor}" opacity="0.25"/>
|
|
<text x="10" y="22" fill="#e2e8f0" font-size="13" font-weight="600">${this.esc(d.label)}</text>
|
|
<text x="${w - 10}" y="22" text-anchor="end" fill="${borderColor}" font-size="10" font-weight="500">${statusLabel}</text>
|
|
<text x="${w / 2}" y="${h - 24}" text-anchor="middle" fill="#94a3b8" font-size="11">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()}</text>
|
|
<rect x="8" y="${h - 10}" width="${w - 16}" height="4" rx="2" fill="#334155"/>
|
|
<rect x="8" y="${h - 10}" width="${(w - 16) * fillPct}" height="4" rx="2" fill="${fillColor}"/>
|
|
${this.renderPortsSvg(n)}
|
|
</g>`;
|
|
}
|
|
|
|
private renderOutcomeNodeSvg(n: FlowNode, selected: boolean): string {
|
|
const d = n.data as OutcomeNodeData;
|
|
const x = n.position.x, y = n.position.y, w = 200, h = 100;
|
|
const fillPct = d.fundingTarget > 0 ? Math.min(1, d.fundingReceived / d.fundingTarget) : 0;
|
|
const statusColor = d.status === "completed" ? "#10b981"
|
|
: d.status === "blocked" ? "#ef4444"
|
|
: d.status === "in-progress" ? "#3b82f6" : "#64748b";
|
|
|
|
let phaseBars = "";
|
|
if (d.phases && d.phases.length > 0) {
|
|
const phaseW = (w - 20) / d.phases.length;
|
|
phaseBars = d.phases.map((p, i) => {
|
|
const unlocked = d.fundingReceived >= p.fundingThreshold;
|
|
return `<rect x="${10 + i * phaseW}" y="62" width="${phaseW - 2}" height="6" rx="2" fill="${unlocked ? "#10b981" : "#334155"}" opacity="${unlocked ? 0.8 : 0.5}"/>`;
|
|
}).join("");
|
|
phaseBars += `<text x="${w / 2}" y="80" text-anchor="middle" fill="#64748b" font-size="9">${d.phases.filter((p) => d.fundingReceived >= p.fundingThreshold).length}/${d.phases.length} phases</text>`;
|
|
}
|
|
|
|
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="${h}" rx="8" fill="#1e293b" stroke="${selected ? "#6366f1" : statusColor}" stroke-width="${selected ? 3 : 1.5}"/>
|
|
<circle cx="14" cy="18" r="5" fill="${statusColor}" opacity="0.7"/>
|
|
<text x="26" y="22" fill="#e2e8f0" font-size="12" font-weight="600">${this.esc(d.label)}</text>
|
|
<rect x="10" y="34" width="${w - 20}" height="5" rx="2.5" fill="#334155"/>
|
|
<rect x="10" y="34" width="${(w - 20) * fillPct}" height="5" rx="2.5" fill="${statusColor}" opacity="0.8"/>
|
|
<text x="${w / 2}" y="52" text-anchor="middle" fill="#94a3b8" font-size="10">${Math.round(fillPct * 100)}% — $${Math.floor(d.fundingReceived).toLocaleString()}</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 renderAllEdges(): string {
|
|
let html = "";
|
|
// Find max flow rate for Sankey width scaling
|
|
const maxFlow = Math.max(1, ...this.nodes.filter((n) => n.type === "source").map((n) => (n.data as SourceNodeData).flowRate));
|
|
|
|
for (const n of this.nodes) {
|
|
if (n.type === "source") {
|
|
const d = n.data as SourceNodeData;
|
|
const from = this.getPortPosition(n, "outflow");
|
|
for (const alloc of d.targetAllocations) {
|
|
const target = this.nodes.find((t) => t.id === alloc.targetId);
|
|
if (!target) continue;
|
|
const to = this.getPortPosition(target, "inflow");
|
|
const strokeW = Math.max(2, (d.flowRate / maxFlow) * (alloc.percentage / 100) * 8);
|
|
html += this.renderEdgePath(
|
|
from.x, from.y, to.x, to.y,
|
|
alloc.color || "#10b981", strokeW, false,
|
|
alloc.percentage, n.id, alloc.targetId, "source",
|
|
);
|
|
}
|
|
}
|
|
if (n.type === "funnel") {
|
|
const d = n.data as FunnelNodeData;
|
|
// Overflow edges — from overflow port
|
|
for (const alloc of d.overflowAllocations) {
|
|
const target = this.nodes.find((t) => t.id === alloc.targetId);
|
|
if (!target) continue;
|
|
const from = this.getPortPosition(n, "overflow");
|
|
const to = this.getPortPosition(target, "inflow");
|
|
const strokeW = Math.max(1.5, (alloc.percentage / 100) * 6);
|
|
html += this.renderEdgePath(
|
|
from.x, from.y, to.x, to.y,
|
|
alloc.color || "#f59e0b", strokeW, true,
|
|
alloc.percentage, n.id, alloc.targetId, "overflow",
|
|
);
|
|
}
|
|
// Spending edges — from spending port
|
|
for (const alloc of d.spendingAllocations) {
|
|
const target = this.nodes.find((t) => t.id === alloc.targetId);
|
|
if (!target) continue;
|
|
const from = this.getPortPosition(n, "spending");
|
|
const to = this.getPortPosition(target, "inflow");
|
|
const strokeW = Math.max(1.5, (alloc.percentage / 100) * 5);
|
|
html += this.renderEdgePath(
|
|
from.x, from.y, to.x, to.y,
|
|
alloc.color || "#8b5cf6", strokeW, false,
|
|
alloc.percentage, n.id, alloc.targetId, "spending",
|
|
);
|
|
}
|
|
}
|
|
// Outcome overflow edges
|
|
if (n.type === "outcome") {
|
|
const d = n.data as OutcomeNodeData;
|
|
const allocs = d.overflowAllocations || [];
|
|
for (const alloc of allocs) {
|
|
const target = this.nodes.find((t) => t.id === alloc.targetId);
|
|
if (!target) continue;
|
|
const from = this.getPortPosition(n, "overflow");
|
|
const to = this.getPortPosition(target, "inflow");
|
|
const strokeW = Math.max(1.5, (alloc.percentage / 100) * 5);
|
|
html += this.renderEdgePath(
|
|
from.x, from.y, to.x, to.y,
|
|
alloc.color || "#f59e0b", strokeW, true,
|
|
alloc.percentage, n.id, alloc.targetId, "overflow",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return html;
|
|
}
|
|
|
|
private renderEdgePath(
|
|
x1: number, y1: number, x2: number, y2: number,
|
|
color: string, strokeW: number, dashed: boolean,
|
|
pct: number, fromId: string, toId: string, edgeType: string,
|
|
): string {
|
|
const cy1 = y1 + (y2 - y1) * 0.4;
|
|
const cy2 = y1 + (y2 - y1) * 0.6;
|
|
const midX = (x1 + x2) / 2;
|
|
const midY = (y1 + y2) / 2;
|
|
const dash = dashed ? ' stroke-dasharray="6 3"' : "";
|
|
return `<g class="edge-group" data-from="${fromId}" data-to="${toId}">
|
|
<path d="M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}" fill="none" stroke="${color}" stroke-width="${strokeW}" stroke-opacity="0.7"${dash}/>
|
|
<g class="edge-ctrl-group" transform="translate(${midX},${midY})">
|
|
<rect x="-34" y="-12" width="68" height="24" rx="6" fill="#1e293b" stroke="#475569" stroke-width="1" opacity="0.9"/>
|
|
<text x="-14" y="5" fill="${color}" font-size="11" font-weight="600" text-anchor="middle">${pct}%</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" fill="#334155"/>
|
|
<text x="-24" y="5" fill="#e2e8f0" font-size="13" text-anchor="middle">−</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" fill="#334155"/>
|
|
<text x="24" y="5" fill="#e2e8f0" 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();
|
|
}
|
|
|
|
// ─── 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 SVGRectElement | null;
|
|
if (bg) {
|
|
if (isSelected) {
|
|
bg.setAttribute("stroke", "#6366f1");
|
|
bg.setAttribute("stroke-width", "3");
|
|
} else {
|
|
// Restore original color
|
|
const node = this.nodes.find((n) => n.id === el.dataset.nodeId);
|
|
if (node) {
|
|
const origColor = this.getNodeBorderColor(node);
|
|
bg.setAttribute("stroke", origColor);
|
|
bg.setAttribute("stroke-width", node.type === "outcome" ? "1.5" : "2");
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
private getNodeBorderColor(n: FlowNode): string {
|
|
if (n.type === "source") return "#10b981";
|
|
if (n.type === "funnel") {
|
|
const d = n.data as FunnelNodeData;
|
|
const suf = computeSufficiencyState(d);
|
|
const isSuf = suf === "sufficient" || suf === "abundant";
|
|
return d.currentValue > d.maxThreshold ? "#f59e0b"
|
|
: d.currentValue < d.minThreshold ? "#ef4444"
|
|
: isSuf ? "#fbbf24" : "#0ea5e9";
|
|
}
|
|
const d = n.data as OutcomeNodeData;
|
|
return d.status === "completed" ? "#10b981" : d.status === "blocked" ? "#ef4444" : d.status === "in-progress" ? "#3b82f6" : "#64748b";
|
|
}
|
|
|
|
// ─── Port rendering & wiring ─────────────────────────
|
|
|
|
private getPortDefs(nodeType: FlowNode["type"]): PortDefinition[] {
|
|
return PORT_DEFS[nodeType] || [];
|
|
}
|
|
|
|
private getPortPosition(node: FlowNode, portKind: PortKind): { x: number; y: number } {
|
|
const s = this.getNodeSize(node);
|
|
const def = this.getPortDefs(node.type).find((p) => p.kind === portKind);
|
|
if (!def) return { x: node.position.x + s.w / 2, y: node.position.y + s.h / 2 };
|
|
return { x: node.position.x + s.w * def.xFrac, y: node.position.y + s.h * def.yFrac };
|
|
}
|
|
|
|
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;
|
|
const arrow = p.dir === "out"
|
|
? `<path d="M ${cx - 3} ${cy + (p.yFrac === 0 ? -8 : 4)} l 3 4 l 3 -4" fill="${p.color}" opacity="0.7"/>`
|
|
: `<path d="M ${cx - 3} ${cy + (p.yFrac === 0 ? -4 : 8)} l 3 -4 l 3 4" 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}">
|
|
<circle class="port-hit" cx="${cx}" cy="${cy}" r="12" fill="transparent"/>
|
|
<circle class="port-dot" cx="${cx}" cy="${cy}" r="5" fill="${p.color}" style="color:${p.color}"/>
|
|
${arrow}
|
|
</g>`;
|
|
}).join("");
|
|
}
|
|
|
|
private enterWiring(nodeId: string, portKind: PortKind) {
|
|
this.wiringActive = true;
|
|
this.wiringSourceNodeId = nodeId;
|
|
this.wiringSourcePortKind = portKind;
|
|
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.wiringDragging = false;
|
|
const svg = this.shadow.getElementById("flow-canvas");
|
|
if (svg) svg.classList.remove("wiring");
|
|
const wireLayer = this.shadow.getElementById("wire-layer");
|
|
if (wireLayer) wireLayer.innerHTML = "";
|
|
this.clearWiringClasses();
|
|
}
|
|
|
|
private applyWiringClasses() {
|
|
const nodeLayer = this.shadow.getElementById("node-layer");
|
|
if (!nodeLayer || !this.wiringSourceNodeId || !this.wiringSourcePortKind) return;
|
|
|
|
const sourceNode = this.nodes.find((n) => n.id === this.wiringSourceNodeId);
|
|
if (!sourceNode) return;
|
|
|
|
const sourceDef = this.getPortDefs(sourceNode.type).find((p) => p.kind === this.wiringSourcePortKind);
|
|
const connectsTo = sourceDef?.connectsTo || [];
|
|
|
|
nodeLayer.querySelectorAll(".port-group").forEach((g) => {
|
|
const el = g as SVGGElement;
|
|
const nid = el.dataset.nodeId!;
|
|
const pk = el.dataset.portKind as PortKind;
|
|
const pd = el.dataset.portDir!;
|
|
|
|
if (nid === this.wiringSourceNodeId && pk === this.wiringSourcePortKind) {
|
|
el.classList.add("port-group--wiring-source");
|
|
} else if (pd === "in" && connectsTo.includes(pk) && nid !== this.wiringSourceNodeId && !this.allocationExists(this.wiringSourceNodeId!, nid, this.wiringSourcePortKind!)) {
|
|
el.classList.add("port-group--wiring-target");
|
|
} else {
|
|
el.classList.add("port-group--wiring-dimmed");
|
|
}
|
|
});
|
|
}
|
|
|
|
private clearWiringClasses() {
|
|
const nodeLayer = this.shadow.getElementById("node-layer");
|
|
if (!nodeLayer) return;
|
|
nodeLayer.querySelectorAll(".port-group").forEach((g) => {
|
|
g.classList.remove("port-group--wiring-source", "port-group--wiring-target", "port-group--wiring-dimmed");
|
|
});
|
|
}
|
|
|
|
private completeWiring(targetNodeId: string) {
|
|
if (!this.wiringSourceNodeId || !this.wiringSourcePortKind) return;
|
|
|
|
const sourceNode = this.nodes.find((n) => n.id === this.wiringSourceNodeId);
|
|
const targetNode = this.nodes.find((n) => n.id === targetNodeId);
|
|
if (!sourceNode || !targetNode) { this.cancelWiring(); return; }
|
|
|
|
// Determine allocation type and color
|
|
const portKind = this.wiringSourcePortKind;
|
|
if (sourceNode.type === "source" && portKind === "outflow") {
|
|
const d = sourceNode.data as SourceNodeData;
|
|
const color = SPENDING_COLORS[d.targetAllocations.length % SPENDING_COLORS.length] || "#10b981";
|
|
d.targetAllocations.push({ targetId: targetNodeId, percentage: 0, color });
|
|
this.normalizeAllocations(d.targetAllocations);
|
|
} else if (sourceNode.type === "funnel" && portKind === "overflow") {
|
|
const d = sourceNode.data as FunnelNodeData;
|
|
const color = OVERFLOW_COLORS[d.overflowAllocations.length % OVERFLOW_COLORS.length] || "#f59e0b";
|
|
d.overflowAllocations.push({ targetId: targetNodeId, percentage: 0, color });
|
|
this.normalizeAllocations(d.overflowAllocations);
|
|
} else if (sourceNode.type === "funnel" && portKind === "spending") {
|
|
const d = sourceNode.data as FunnelNodeData;
|
|
const color = SPENDING_COLORS[d.spendingAllocations.length % SPENDING_COLORS.length] || "#8b5cf6";
|
|
d.spendingAllocations.push({ targetId: targetNodeId, percentage: 0, color });
|
|
this.normalizeAllocations(d.spendingAllocations);
|
|
} else if (sourceNode.type === "outcome" && portKind === "overflow") {
|
|
const d = sourceNode.data as OutcomeNodeData;
|
|
if (!d.overflowAllocations) d.overflowAllocations = [];
|
|
const color = OVERFLOW_COLORS[d.overflowAllocations.length % OVERFLOW_COLORS.length] || "#f59e0b";
|
|
d.overflowAllocations.push({ targetId: targetNodeId, percentage: 0, color });
|
|
this.normalizeAllocations(d.overflowAllocations);
|
|
}
|
|
|
|
this.cancelWiring();
|
|
this.drawCanvasContent();
|
|
this.openEditor(this.wiringSourceNodeId || sourceNode.id);
|
|
}
|
|
|
|
private normalizeAllocations(allocs: { targetId: string; percentage: number; color: string }[]) {
|
|
if (allocs.length === 0) return;
|
|
const equal = Math.floor(100 / allocs.length);
|
|
const remainder = 100 - equal * allocs.length;
|
|
allocs.forEach((a, i) => { a.percentage = equal + (i === 0 ? remainder : 0); });
|
|
}
|
|
|
|
private allocationExists(fromId: string, toId: string, portKind: PortKind): boolean {
|
|
const node = this.nodes.find((n) => n.id === fromId);
|
|
if (!node) return false;
|
|
if (node.type === "source" && portKind === "outflow") {
|
|
return (node.data as SourceNodeData).targetAllocations.some((a) => a.targetId === toId);
|
|
}
|
|
if (node.type === "funnel" && portKind === "overflow") {
|
|
return (node.data as FunnelNodeData).overflowAllocations.some((a) => a.targetId === toId);
|
|
}
|
|
if (node.type === "funnel" && portKind === "spending") {
|
|
return (node.data as FunnelNodeData).spendingAllocations.some((a) => a.targetId === toId);
|
|
}
|
|
if (node.type === "outcome" && portKind === "overflow") {
|
|
return ((node.data as OutcomeNodeData).overflowAllocations || []).some((a) => a.targetId === toId);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private updateWiringTempLine() {
|
|
const wireLayer = this.shadow.getElementById("wire-layer");
|
|
if (!wireLayer || !this.wiringSourceNodeId || !this.wiringSourcePortKind) return;
|
|
|
|
const sourceNode = this.nodes.find((n) => n.id === this.wiringSourceNodeId);
|
|
if (!sourceNode) return;
|
|
const { x: x1, y: y1 } = this.getPortPosition(sourceNode, this.wiringSourcePortKind);
|
|
|
|
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;
|
|
|
|
const cy1 = y1 + (y2 - y1) * 0.4;
|
|
const cy2 = y1 + (y2 - y1) * 0.6;
|
|
wireLayer.innerHTML = `<path class="wiring-temp-path" d="M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}"/>`;
|
|
}
|
|
|
|
// ─── Node position update (direct DOM, no re-render) ──
|
|
|
|
private updateNodePosition(n: FlowNode) {
|
|
const nodeLayer = this.shadow.getElementById("node-layer");
|
|
if (!nodeLayer) return;
|
|
const g = nodeLayer.querySelector(`[data-node-id="${n.id}"]`) as SVGGElement | null;
|
|
if (g) g.setAttribute("transform", `translate(${n.position.x},${n.position.y})`);
|
|
}
|
|
|
|
// ─── Allocation adjustment ────────────────────────────
|
|
|
|
private handleAdjustAllocation(fromId: string, toId: string, allocType: string, delta: number) {
|
|
const node = this.nodes.find((n) => n.id === fromId);
|
|
if (!node) return;
|
|
|
|
let allocs: { targetId: string; percentage: number; color: string }[];
|
|
if (allocType === "source") {
|
|
allocs = (node.data as SourceNodeData).targetAllocations;
|
|
} else if (allocType === "overflow") {
|
|
if (node.type === "outcome") {
|
|
allocs = (node.data as OutcomeNodeData).overflowAllocations || [];
|
|
} else {
|
|
allocs = (node.data as FunnelNodeData).overflowAllocations;
|
|
}
|
|
} else {
|
|
allocs = (node.data as FunnelNodeData).spendingAllocations;
|
|
}
|
|
|
|
const idx = allocs.findIndex((a) => a.targetId === toId);
|
|
if (idx < 0) return;
|
|
|
|
const newPct = Math.max(1, Math.min(99, allocs[idx].percentage + delta));
|
|
const oldPct = allocs[idx].percentage;
|
|
const diff = newPct - oldPct;
|
|
allocs[idx].percentage = newPct;
|
|
|
|
// Proportionally rebalance siblings
|
|
const siblings = allocs.filter((_, i) => i !== idx);
|
|
const sibTotal = siblings.reduce((s, a) => s + a.percentage, 0);
|
|
if (sibTotal > 0) {
|
|
for (const sib of siblings) {
|
|
sib.percentage = Math.max(1, Math.round(sib.percentage - diff * (sib.percentage / sibTotal)));
|
|
}
|
|
}
|
|
|
|
// Normalize to exactly 100
|
|
const total = allocs.reduce((s, a) => s + a.percentage, 0);
|
|
if (total !== 100 && allocs.length > 1) {
|
|
const last = allocs.find((_, i) => i !== idx) || allocs[allocs.length - 1];
|
|
last.percentage += 100 - total;
|
|
last.percentage = Math.max(1, last.percentage);
|
|
}
|
|
|
|
this.redrawEdges();
|
|
this.refreshEditorIfOpen(fromId);
|
|
}
|
|
|
|
// ─── Editor panel ─────────────────────────────────────
|
|
|
|
private openEditor(nodeId: string) {
|
|
this.editingNodeId = nodeId;
|
|
const panel = this.shadow.getElementById("editor-panel");
|
|
if (!panel) return;
|
|
const node = this.nodes.find((n) => n.id === nodeId);
|
|
if (!node) return;
|
|
|
|
let content = `<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">×</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 = ""; }
|
|
}
|
|
|
|
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>`;
|
|
html += this.renderAllocEditor("Target Allocations", d.targetAllocations);
|
|
return html;
|
|
}
|
|
|
|
private renderFunnelEditor(n: FlowNode): string {
|
|
const d = n.data as FunnelNodeData;
|
|
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">Current Value ($)</label>
|
|
<input class="editor-input" data-field="currentValue" type="number" value="${d.currentValue}"/></div>
|
|
<div class="editor-field"><label class="editor-label">Min Threshold</label>
|
|
<input class="editor-input" data-field="minThreshold" type="number" value="${d.minThreshold}"/></div>
|
|
<div class="editor-field"><label class="editor-label">Max Threshold</label>
|
|
<input class="editor-input" data-field="maxThreshold" type="number" value="${d.maxThreshold}"/></div>
|
|
<div class="editor-field"><label class="editor-label">Max Capacity</label>
|
|
<input class="editor-input" data-field="maxCapacity" type="number" value="${d.maxCapacity}"/></div>
|
|
<div class="editor-field"><label class="editor-label">Inflow Rate ($/tick)</label>
|
|
<input class="editor-input" data-field="inflowRate" type="number" value="${d.inflowRate}"/></div>
|
|
<div class="editor-field"><label class="editor-label">Sufficient Threshold</label>
|
|
<input class="editor-input" data-field="sufficientThreshold" type="number" value="${d.sufficientThreshold ?? d.maxThreshold}"/></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:#0f172a;border-radius:6px;border-left:3px solid ${unlocked ? "#10b981" : "#334155"}">
|
|
<div style="font-size:12px;font-weight:600;color:${unlocked ? "#6ee7b7" : "#64748b"}">${this.esc(p.name)} — $${p.fundingThreshold.toLocaleString()}</div>
|
|
${p.tasks.map((t) => `<div style="font-size:11px;color:#94a3b8;margin-top:2px">${t.completed ? "✅" : "⬜"} ${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:#e2e8f0">${a.percentage}%</span>
|
|
</div>`;
|
|
}
|
|
html += `</div>`;
|
|
return html;
|
|
}
|
|
|
|
private attachEditorListeners(panel: HTMLElement, node: FlowNode) {
|
|
// Close button
|
|
panel.querySelector('[data-editor-action="close"]')?.addEventListener("click", () => this.closeEditor());
|
|
|
|
// Delete button
|
|
panel.querySelector('[data-editor-action="delete"]')?.addEventListener("click", () => {
|
|
this.deleteNode(node.id);
|
|
this.closeEditor();
|
|
});
|
|
|
|
// Input changes — live update
|
|
const inputs = panel.querySelectorAll(".editor-input, .editor-select");
|
|
inputs.forEach((input) => {
|
|
input.addEventListener("change", () => {
|
|
const field = (input as HTMLElement).dataset.field;
|
|
if (!field) return;
|
|
const val = (input as HTMLInputElement).value;
|
|
const numFields = ["flowRate", "currentValue", "minThreshold", "maxThreshold", "maxCapacity", "inflowRate", "sufficientThreshold", "fundingReceived", "fundingTarget"];
|
|
(node.data as any)[field] = numFields.includes(field) ? parseFloat(val) || 0 : val;
|
|
this.drawCanvasContent();
|
|
this.updateSufficiencyBadge();
|
|
});
|
|
});
|
|
}
|
|
|
|
// ─── Node CRUD ────────────────────────────────────────
|
|
|
|
private addNode(type: "source" | "funnel" | "outcome") {
|
|
// Place at center of current viewport
|
|
const svg = this.shadow.getElementById("flow-canvas") as SVGSVGElement | null;
|
|
const rect = svg?.getBoundingClientRect();
|
|
const cx = rect ? (rect.width / 2 - this.canvasPanX) / this.canvasZoom : 400;
|
|
const cy = rect ? (rect.height / 2 - this.canvasPanY) / this.canvasZoom : 300;
|
|
|
|
const id = `${type}-${Date.now().toString(36)}`;
|
|
let data: any;
|
|
if (type === "source") {
|
|
data = { label: "New Source", flowRate: 1000, sourceType: "card", targetAllocations: [] } as SourceNodeData;
|
|
} else if (type === "funnel") {
|
|
data = {
|
|
label: "New Funnel", currentValue: 0, minThreshold: 5000, maxThreshold: 20000,
|
|
maxCapacity: 30000, inflowRate: 500, sufficientThreshold: 15000, dynamicOverflow: false,
|
|
overflowAllocations: [], spendingAllocations: [],
|
|
} as FunnelNodeData;
|
|
} else {
|
|
data = { label: "New Outcome", description: "", fundingReceived: 0, fundingTarget: 10000, status: "not-started", overflowAllocations: [] } as OutcomeNodeData;
|
|
}
|
|
|
|
this.nodes.push({ id, type, position: { x: cx - 100, y: cy - 50 }, data });
|
|
this.drawCanvasContent();
|
|
this.selectedNodeId = id;
|
|
this.updateSelectionHighlight();
|
|
this.openEditor(id);
|
|
}
|
|
|
|
private deleteNode(nodeId: string) {
|
|
this.nodes = this.nodes.filter((n) => n.id !== nodeId);
|
|
// Clean up allocations pointing to deleted node
|
|
for (const n of this.nodes) {
|
|
if (n.type === "source") {
|
|
const d = n.data as SourceNodeData;
|
|
d.targetAllocations = d.targetAllocations.filter((a) => a.targetId !== nodeId);
|
|
}
|
|
if (n.type === "funnel") {
|
|
const d = n.data as FunnelNodeData;
|
|
d.overflowAllocations = d.overflowAllocations.filter((a) => a.targetId !== nodeId);
|
|
d.spendingAllocations = d.spendingAllocations.filter((a) => a.targetId !== nodeId);
|
|
}
|
|
if (n.type === "outcome") {
|
|
const d = n.data as OutcomeNodeData;
|
|
if (d.overflowAllocations) d.overflowAllocations = d.overflowAllocations.filter((a) => a.targetId !== nodeId);
|
|
}
|
|
}
|
|
if (this.selectedNodeId === nodeId) this.selectedNodeId = null;
|
|
this.drawCanvasContent();
|
|
this.updateSufficiencyBadge();
|
|
}
|
|
|
|
// ─── Simulation ───────────────────────────────────────
|
|
|
|
private toggleSimulation() {
|
|
this.isSimulating = !this.isSimulating;
|
|
const btn = this.shadow.getElementById("sim-btn");
|
|
if (btn) btn.textContent = this.isSimulating ? "Pause" : "Play";
|
|
|
|
if (this.isSimulating) {
|
|
this.simInterval = setInterval(() => {
|
|
this.nodes = simulateTick(this.nodes);
|
|
this.updateCanvasLive();
|
|
}, 100);
|
|
} else {
|
|
if (this.simInterval) { clearInterval(this.simInterval); this.simInterval = null; }
|
|
}
|
|
}
|
|
|
|
/** Update canvas nodes and edges without full innerHTML rebuild during simulation */
|
|
private updateCanvasLive() {
|
|
const nodeLayer = this.shadow.getElementById("node-layer");
|
|
if (!nodeLayer) return;
|
|
|
|
// Rebuild node SVG content (can't do partial DOM updates easily for SVG text)
|
|
nodeLayer.innerHTML = this.renderAllNodes();
|
|
this.redrawEdges();
|
|
this.updateSufficiencyBadge();
|
|
}
|
|
|
|
private updateSufficiencyBadge() {
|
|
const score = computeSystemSufficiency(this.nodes);
|
|
const scorePct = Math.round(score * 100);
|
|
const scoreColor = scorePct >= 90 ? "#fbbf24" : scorePct >= 60 ? "#10b981" : scorePct >= 30 ? "#f59e0b" : "#ef4444";
|
|
const badge = this.shadow.getElementById("badge-score");
|
|
if (badge) {
|
|
badge.textContent = `${scorePct}%`;
|
|
badge.style.color = scoreColor;
|
|
}
|
|
}
|
|
|
|
// ─── URL sharing ──────────────────────────────────────
|
|
|
|
private shareState() {
|
|
try {
|
|
const LZString = (window as any).LZString;
|
|
if (!LZString) {
|
|
// Fallback: copy JSON directly
|
|
const json = JSON.stringify(this.nodes);
|
|
navigator.clipboard.writeText(window.location.href.split("#")[0] + "#flow=" + btoa(json));
|
|
return;
|
|
}
|
|
const json = JSON.stringify(this.nodes);
|
|
const compressed = LZString.compressToEncodedURIComponent(json);
|
|
const url = window.location.href.split("#")[0] + "#flow=" + compressed;
|
|
history.replaceState(null, "", url);
|
|
navigator.clipboard.writeText(url);
|
|
} catch {
|
|
// Silent fail
|
|
}
|
|
}
|
|
|
|
private loadFromHash() {
|
|
try {
|
|
const hash = window.location.hash;
|
|
if (!hash.startsWith("#flow=")) return;
|
|
const payload = hash.slice(6);
|
|
let json: string;
|
|
const LZString = (window as any).LZString;
|
|
if (LZString) {
|
|
json = LZString.decompressFromEncodedURIComponent(payload) || "";
|
|
} else {
|
|
json = atob(payload);
|
|
}
|
|
if (!json) return;
|
|
const nodes = JSON.parse(json) as FlowNode[];
|
|
if (Array.isArray(nodes) && nodes.length > 0) {
|
|
this.nodes = nodes;
|
|
this.drawCanvasContent();
|
|
this.fitView();
|
|
}
|
|
} catch {
|
|
// Invalid hash data — ignore
|
|
}
|
|
}
|
|
|
|
// ─── River tab ────────────────────────────────────────
|
|
|
|
private renderRiverTab(): string {
|
|
return `<div class="funds-river-container" id="river-mount"></div>`;
|
|
}
|
|
|
|
private mountRiver() {
|
|
const mount = this.shadow.getElementById("river-mount");
|
|
if (!mount) return;
|
|
|
|
// Check if already mounted
|
|
if (mount.querySelector("folk-budget-river")) return;
|
|
|
|
const river = document.createElement("folk-budget-river") as any;
|
|
river.setAttribute("simulate", "true");
|
|
mount.appendChild(river);
|
|
|
|
// Pass nodes after the element is connected
|
|
requestAnimationFrame(() => {
|
|
if (typeof river.setNodes === "function") {
|
|
river.setNodes(this.nodes.map((n) => ({ ...n, data: { ...n.data } })));
|
|
}
|
|
});
|
|
}
|
|
|
|
// ─── Transactions tab ─────────────────────────────────
|
|
|
|
private renderTransactionsTab(): string {
|
|
if (this.isDemo) {
|
|
return `
|
|
<div class="funds-tx-empty">
|
|
<p>Transaction history is not available in demo mode.</p>
|
|
</div>`;
|
|
}
|
|
|
|
if (!this.txLoaded) {
|
|
return '<div class="funds-loading">Loading transactions...</div>';
|
|
}
|
|
|
|
if (this.transactions.length === 0) {
|
|
return `
|
|
<div class="funds-tx-empty">
|
|
<p>No transactions yet for this flow.</p>
|
|
</div>`;
|
|
}
|
|
|
|
return `
|
|
<div class="funds-tx-list">
|
|
${this.transactions.map((tx) => `
|
|
<div class="funds-tx">
|
|
<div class="funds-tx__icon">${tx.type === "deposit" ? "⬆" : tx.type === "withdraw" ? "⬇" : "🔄"}</div>
|
|
<div class="funds-tx__body">
|
|
<div class="funds-tx__desc">${this.esc(tx.description || tx.type)}</div>
|
|
<div class="funds-tx__meta">
|
|
${tx.from ? `From: ${this.esc(tx.from)}` : ""}
|
|
${tx.to ? ` → ${this.esc(tx.to)}` : ""}
|
|
</div>
|
|
</div>
|
|
<div class="funds-tx__amount ${tx.type === "deposit" ? "funds-tx__amount--positive" : "funds-tx__amount--negative"}">
|
|
${tx.type === "deposit" ? "+" : "-"}$${Math.abs(tx.amount).toLocaleString()}
|
|
</div>
|
|
<div class="funds-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;
|
|
}
|
|
}
|
|
|
|
// ─── Event listeners ──────────────────────────────────
|
|
|
|
private attachListeners() {
|
|
// Tab switching
|
|
this.shadow.querySelectorAll("[data-tab]").forEach((el) => {
|
|
el.addEventListener("click", () => {
|
|
const newTab = (el as HTMLElement).dataset.tab as Tab;
|
|
if (newTab === this.tab) return;
|
|
// Cleanup old canvas state
|
|
if (this.tab === "diagram") this.cleanupCanvas();
|
|
this.tab = newTab;
|
|
this.render();
|
|
|
|
if (newTab === "transactions" && !this.txLoaded) {
|
|
this.loadTransactions();
|
|
}
|
|
});
|
|
});
|
|
|
|
// Mount river component when river tab is active
|
|
if (this.tab === "river" && this.nodes.length > 0) {
|
|
this.mountRiver();
|
|
}
|
|
|
|
// Initialize interactive canvas when diagram tab is active
|
|
if (this.tab === "diagram" && this.nodes.length > 0) {
|
|
this.initCanvas();
|
|
}
|
|
|
|
// Create flow button (landing page, auth-gated)
|
|
const createBtn = this.shadow.querySelector('[data-action="create-flow"]');
|
|
createBtn?.addEventListener("click", () => this.handleCreateFlow());
|
|
}
|
|
|
|
private cleanupCanvas() {
|
|
if (this.simInterval) { clearInterval(this.simInterval); this.simInterval = null; }
|
|
this.isSimulating = false;
|
|
if (this.wiringActive) this.cancelWiring();
|
|
if (this._boundKeyDown) { document.removeEventListener("keydown", this._boundKeyDown); this._boundKeyDown = null; }
|
|
}
|
|
|
|
private async handleCreateFlow() {
|
|
const token = getAccessToken();
|
|
if (!token) return;
|
|
|
|
const name = prompt("Flow name:");
|
|
if (!name) return;
|
|
|
|
this.loading = true;
|
|
this.render();
|
|
try {
|
|
const base = this.getApiBase();
|
|
const res = await fetch(`${base}/api/flows`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify({ name }),
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
const flowId = data.id || data.flowId;
|
|
// Associate with current space
|
|
if (flowId && this.space) {
|
|
await fetch(`${base}/api/space-flows`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify({ space: this.space, flowId }),
|
|
});
|
|
}
|
|
// Navigate to the new flow
|
|
if (flowId) {
|
|
const detailUrl = this.getApiBase()
|
|
? `${this.getApiBase().replace(/\/funds$/, "")}/funds/flow/${encodeURIComponent(flowId)}`
|
|
: `/flow/${encodeURIComponent(flowId)}`;
|
|
window.location.href = detailUrl;
|
|
return;
|
|
}
|
|
} else {
|
|
const err = await res.json().catch(() => ({}));
|
|
this.error = (err as any).error || `Failed to create flow (${res.status})`;
|
|
}
|
|
} catch {
|
|
this.error = "Failed to create flow";
|
|
}
|
|
this.loading = false;
|
|
this.render();
|
|
}
|
|
|
|
private esc(s: string): string {
|
|
return s
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
}
|
|
|
|
customElements.define("folk-funds-app", FolkFundsApp);
|