6479 lines
291 KiB
TypeScript
6479 lines
291 KiB
TypeScript
/**
|
|
* <folk-flows-app> — main rFlows application component.
|
|
*
|
|
* Views:
|
|
* "landing" — BCRG 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, MortgagePosition, ReinvestmentPosition, BudgetSegment } from "../lib/types";
|
|
import { PORT_DEFS, deriveThresholds } from "../lib/types";
|
|
import { TourEngine } from "../../../shared/tour-engine";
|
|
import { computeInflowRates, computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation";
|
|
import { demoNodes, simDemoNodes, 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" | "mortgage" | "budgets";
|
|
|
|
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;
|
|
|
|
// Sankey flow width pre-pass results
|
|
private _currentFlowWidths: Map<string, { totalOutflow: number; totalInflow: number; outflowWidthPx: number; inflowWidthPx: number; inflowFillRatio: number }> = new Map();
|
|
|
|
// Split control drag state
|
|
private _splitDragging = false;
|
|
private _splitDragNodeId: string | null = null;
|
|
private _splitDragAllocType: string | null = null;
|
|
private _splitDragDividerIdx = 0;
|
|
private _splitDragStartX = 0;
|
|
private _splitDragStartPcts: number[] = [];
|
|
|
|
// Source purchase modal state
|
|
private sourceModalNodeId: string | 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;
|
|
private _dragRafId: number | 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;
|
|
|
|
// Mortgage state
|
|
private mortgagePositions: MortgagePosition[] = [];
|
|
private reinvestmentPositions: ReinvestmentPosition[] = [];
|
|
private liveRates: { protocol: string; chain: string; asset: string; apy: number | null; error?: string; updatedAt: number }[] = [];
|
|
private selectedLenderId: string | null = null;
|
|
private projCalcAmount = 10000;
|
|
private projCalcMonths = 12;
|
|
private projCalcApy = 4.5;
|
|
|
|
// Borrower options state
|
|
private borrowerMonthlyBudget = 1500;
|
|
private showPoolOverview = false;
|
|
|
|
// Budget state
|
|
private budgetSegments: BudgetSegment[] = [];
|
|
private myAllocation: Record<string, number> = {};
|
|
private collectiveAllocations: { segmentId: string; avgPercentage: number; participantCount: number }[] = [];
|
|
private budgetTotalAmount = 0;
|
|
private budgetParticipantCount = 0;
|
|
|
|
// Tour engine
|
|
private _tour!: TourEngine;
|
|
private static readonly TOUR_STEPS = [
|
|
{ target: '[data-canvas-action="add-source"]', title: "Add a Source", message: "Sources represent inflows of resources. Click the + Source button to add one.", advanceOnClick: true },
|
|
{ target: '[data-canvas-action="add-funnel"]', title: "Add a Funnel", message: "Funnels allocate resources between spending and overflow. Click + Funnel to add one.", advanceOnClick: true },
|
|
{ target: '[data-canvas-action="add-outcome"]', title: "Add an Outcome", message: "Outcomes are the goals your flow is working towards. Click + Outcome to add one.", advanceOnClick: true },
|
|
{ target: '.flows-node', title: "Wire a Connection", message: "Drag from a port (the colored dots on nodes) to another node to create a flow connection. Click Next when ready.", advanceOnClick: false },
|
|
{ target: '[data-canvas-action="sim"]', title: "Run Simulation", message: "Press Play to simulate resource flows through your system. Click Play to finish the tour!", advanceOnClick: true },
|
|
];
|
|
|
|
constructor() {
|
|
super();
|
|
this.shadow = this.attachShadow({ mode: "open" });
|
|
this._tour = new TourEngine(
|
|
this.shadow,
|
|
FolkFlowsApp.TOUR_STEPS,
|
|
"rflows_tour_done",
|
|
() => this.shadow.getElementById("canvas-container"),
|
|
);
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.space = this.getAttribute("space") || "demo";
|
|
this.flowId = this.getAttribute("flow-id") || "";
|
|
this.isDemo = this.getAttribute("mode") === "demo" || this.space === "demo";
|
|
|
|
// Mirror document theme to host for shadow DOM CSS selectors
|
|
this._syncTheme();
|
|
document.addEventListener("theme-change", () => this._syncTheme());
|
|
new MutationObserver(() => this._syncTheme())
|
|
.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
|
|
|
|
// Read view attribute, default to canvas (detail) view
|
|
const viewAttr = this.getAttribute("view");
|
|
this.view = viewAttr === "budgets" ? "budgets" : viewAttr === "mortgage" ? "mortgage" : "detail";
|
|
|
|
if (this.view === "budgets") {
|
|
this.loadBudgetData();
|
|
return;
|
|
}
|
|
|
|
if (this.view === "mortgage") {
|
|
this.loadMortgageData();
|
|
return;
|
|
}
|
|
|
|
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 _syncTheme() {
|
|
const theme = document.documentElement.getAttribute("data-theme") || "dark";
|
|
this.setAttribute("data-theme", theme);
|
|
}
|
|
|
|
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 = "BCRG 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 = computeInflowRates(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 (err) {
|
|
// Offline or error — fall back to demoNodes
|
|
console.warn('[FlowsApp] Local-first init failed, using demo nodes', err);
|
|
this.loadDemoOrLocalFlow();
|
|
}
|
|
|
|
// Safety net: if no nodes were loaded (corrupted IDB, empty doc), show demo
|
|
if (this.nodes.length === 0) {
|
|
console.warn('[FlowsApp] No nodes after init, falling back to demo');
|
|
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 = computeInflowRates(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 = 'BCRG Demo Flow';
|
|
this.nodes = demoNodes.map((n) => ({ ...n, data: { ...n.data } }));
|
|
} else if (flowId === 'sim-demo') {
|
|
this.currentFlowId = 'sim-demo';
|
|
this.flowName = 'Simulation Demo';
|
|
this.nodes = simDemoNodes.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 demos if not already tracked
|
|
if (!list.includes('demo')) list.unshift('demo');
|
|
if (!list.includes('sim-demo')) list.splice(1, 0, 'sim-demo');
|
|
return list.map(id => {
|
|
if (id === 'demo') return { id: 'demo', name: 'BCRG Demo Flow', nodeCount: demoNodes.length, updatedAt: 0 };
|
|
if (id === 'sim-demo') return { id: 'sim-demo', name: 'Simulation Demo', nodeCount: simDemoNodes.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 === "budgets") return this.renderBudgetsTab();
|
|
if (this.view === "mortgage") return this.renderMortgageTab();
|
|
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 —
|
|
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">💰</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">🏛</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">🎯</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">🌊</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">✨</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’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 BCRG 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 — grants, donations, sales, or any recurring income — 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 ? ` · ${f.outcomeCount} outcomes` : ""}
|
|
${f.status ? ` · ${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">💰</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}% → ${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">🏛</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}% → ${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}% → ${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">🎯</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">← 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-toolbar">
|
|
<div class="flows-dropdown" id="flow-dropdown">
|
|
<button class="flows-toolbar-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">▾</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-toolbar-sep"></div>
|
|
<button class="flows-toolbar-btn flows-toolbar-btn--fund" data-canvas-action="quick-fund">💰 Fund</button>
|
|
<div class="flows-toolbar-sep"></div>
|
|
<button class="flows-toolbar-btn flows-toolbar-btn--source" data-canvas-action="add-source">+ Source</button>
|
|
<button class="flows-toolbar-btn flows-toolbar-btn--funnel" data-canvas-action="add-funnel">+ Funnel</button>
|
|
<button class="flows-toolbar-btn flows-toolbar-btn--outcome" data-canvas-action="add-outcome">+ Outcome</button>
|
|
<div class="flows-toolbar-sep"></div>
|
|
<button class="flows-toolbar-btn" data-canvas-action="sim" id="sim-btn">${this.isSimulating ? "⏸ Pause" : "▶ Play"}</button>
|
|
<button class="flows-toolbar-btn ${this.analyticsOpen ? "flows-toolbar-btn--active" : ""}" data-canvas-action="analytics">📊 Analytics</button>
|
|
<button class="flows-toolbar-btn" data-canvas-action="share">🔗 Share</button>
|
|
<button class="flows-toolbar-btn" data-canvas-action="tour">🎓 Tour</button>
|
|
</div>
|
|
<svg class="flows-canvas-svg" id="flow-canvas">
|
|
<defs>
|
|
<marker id="arrowhead-inflow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto" markerUnits="userSpaceOnUse">
|
|
<path d="M 0 0 L 8 3 L 0 6 Z" fill="var(--rflows-edge-inflow)"/>
|
|
</marker>
|
|
<marker id="arrowhead-spending" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto" markerUnits="userSpaceOnUse">
|
|
<path d="M 0 0 L 8 3 L 0 6 Z" fill="var(--rflows-edge-spending)"/>
|
|
</marker>
|
|
<marker id="arrowhead-overflow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto" markerUnits="userSpaceOnUse">
|
|
<path d="M 0 0 L 8 3 L 0 6 Z" fill="var(--rflows-edge-overflow)"/>
|
|
</marker>
|
|
<linearGradient id="faucet-pipe-grad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="#b0b8c4"/>
|
|
<stop offset="40%" stop-color="#8892a0"/>
|
|
<stop offset="100%" stop-color="#626d7d"/>
|
|
</linearGradient>
|
|
<linearGradient id="pipe-metal-h" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="#c0c8d4"/>
|
|
<stop offset="30%" stop-color="#9ca3af"/>
|
|
<stop offset="70%" stop-color="#6b7280"/>
|
|
<stop offset="100%" stop-color="#4b5563"/>
|
|
</linearGradient>
|
|
<linearGradient id="water-surface" x1="0" y1="0" x2="1" y2="0">
|
|
<stop offset="0%" stop-color="#60a5fa" stop-opacity="0.15"/>
|
|
<stop offset="30%" stop-color="#93c5fd" stop-opacity="0.4"/>
|
|
<stop offset="50%" stop-color="#bfdbfe" stop-opacity="0.6"/>
|
|
<stop offset="70%" stop-color="#93c5fd" stop-opacity="0.4"/>
|
|
<stop offset="100%" stop-color="#60a5fa" stop-opacity="0.15"/>
|
|
</linearGradient>
|
|
<pattern id="water-ripple" x="0" y="0" width="60" height="8" patternUnits="userSpaceOnUse">
|
|
<path d="M0 4 Q15 0 30 4 Q45 8 60 4" fill="none" stroke="rgba(147,197,253,0.3)" stroke-width="1.5">
|
|
<animateTransform attributeName="transform" type="translate" values="0,0;-60,0" dur="3s" repeatCount="indefinite"/>
|
|
</path>
|
|
</pattern>
|
|
<radialGradient id="overflow-splash" cx="50%" cy="50%" r="50%">
|
|
<stop offset="0%" stop-color="#93c5fd" stop-opacity="0.5"/>
|
|
<stop offset="100%" stop-color="#60a5fa" stop-opacity="0"/>
|
|
</radialGradient>
|
|
<linearGradient id="basin-water-blue" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="#60a5fa" stop-opacity="0.6"/>
|
|
<stop offset="100%" stop-color="#3b82f6" stop-opacity="0.35"/>
|
|
</linearGradient>
|
|
<linearGradient id="basin-water-green" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="#6ee7b7" stop-opacity="0.6"/>
|
|
<stop offset="100%" stop-color="#10b981" stop-opacity="0.35"/>
|
|
</linearGradient>
|
|
<linearGradient id="basin-water-grey" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="#94a3b8" stop-opacity="0.5"/>
|
|
<stop offset="100%" stop-color="#64748b" stop-opacity="0.3"/>
|
|
</linearGradient>
|
|
<linearGradient id="basin-water-red" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="#fca5a5" stop-opacity="0.6"/>
|
|
<stop offset="100%" stop-color="#ef4444" stop-opacity="0.35"/>
|
|
</linearGradient>
|
|
</defs>
|
|
<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-zoom-btn" data-canvas-action="zoom-in" title="Zoom in">
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
|
</button>
|
|
<span class="flows-zoom-level" id="zoom-level">${Math.round(this.canvasZoom * 100)}%</span>
|
|
<button class="flows-zoom-btn" data-canvas-action="zoom-out" title="Zoom out">
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8h10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
|
</button>
|
|
<div class="flows-zoom-sep"></div>
|
|
<button class="flows-zoom-btn" data-canvas-action="fit" title="Fit to view">
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M2 6V3a1 1 0 011-1h3M10 2h3a1 1 0 011 1v3M14 10v3a1 1 0 01-1 1h-3M6 14H3a1 1 0 01-1-1v-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
</button>
|
|
</div>
|
|
<button class="flows-fab-play" id="fab-play" data-canvas-action="sim" title="Play / Pause simulation">
|
|
<span class="flows-fab-play__icon" id="fab-play-icon">${this.isSimulating ? "⏸" : "▶"}</span>
|
|
</button>
|
|
<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">×</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 ? ' · ' + 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">✎</button>
|
|
<button class="flows-mgmt__row-btn" data-mgmt-action="duplicate" data-mgmt-id="${this.esc(f.id)}" title="Duplicate">⎘</button>
|
|
<button class="flows-mgmt__row-btn" data-mgmt-action="export" data-mgmt-id="${this.esc(f.id)}" title="Export JSON">⬇</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">✕</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());
|
|
// Auto-start simulation for demo flows
|
|
const isDemo = this.currentFlowId === 'demo' || this.currentFlowId === 'sim-demo' || this.isDemo;
|
|
if (isDemo && !this.isSimulating) {
|
|
setTimeout(() => this.toggleSimulation(), 600);
|
|
}
|
|
// Auto-start tour on first visit (skip for demos that auto-play)
|
|
else if (!localStorage.getItem("rflows_tour_done")) {
|
|
setTimeout(() => this.startTour(), 1200);
|
|
}
|
|
}
|
|
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();
|
|
const zl = this.shadow.getElementById("zoom-level");
|
|
if (zl) zl.textContent = `${Math.round(this.canvasZoom * 100)}%`;
|
|
}
|
|
|
|
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: 260, h: 120 };
|
|
}
|
|
if (n.type === "funnel") {
|
|
const d = n.data as FunnelNodeData;
|
|
const baseW = 260;
|
|
const cap = d.maxCapacity || 9000;
|
|
const h = Math.round(220 + Math.min(200, (cap / 50000) * 200));
|
|
return { w: baseW, h: Math.max(220, h) };
|
|
}
|
|
return { w: 260, h: 140 }; // basin pool
|
|
}
|
|
|
|
// ─── 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 });
|
|
|
|
// Delegated funnel valve + height drag handles
|
|
svg.addEventListener("pointerdown", (e: PointerEvent) => {
|
|
const target = e.target as Element;
|
|
const valveG = target.closest(".funnel-valve-handle") as SVGGElement | null;
|
|
const heightG = target.closest(".funnel-height-handle") as SVGGElement | null;
|
|
const handleG = valveG || heightG;
|
|
if (!handleG) return;
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
const nodeId = handleG.getAttribute("data-node-id");
|
|
const node = this.nodes.find((n) => n.id === nodeId);
|
|
if (!node || node.type !== "funnel") return;
|
|
const fd = node.data as FunnelNodeData;
|
|
const s = this.getNodeSize(node);
|
|
const startX = e.clientX;
|
|
const startY = e.clientY;
|
|
|
|
if (valveG) {
|
|
const startOutflow = fd.desiredOutflow || 0;
|
|
handleG.setPointerCapture(e.pointerId);
|
|
const label = handleG.querySelector("text");
|
|
const onMove = (ev: PointerEvent) => {
|
|
const deltaX = (ev.clientX - startX) / this.canvasZoom;
|
|
let newOutflow = Math.round((startOutflow + (deltaX / s.w) * 10000) / 50) * 50;
|
|
newOutflow = Math.max(0, Math.min(10000, newOutflow));
|
|
fd.desiredOutflow = newOutflow;
|
|
fd.minThreshold = newOutflow;
|
|
fd.maxThreshold = newOutflow * 6;
|
|
if (fd.maxCapacity < fd.maxThreshold * 1.5) {
|
|
fd.maxCapacity = Math.round(fd.maxThreshold * 1.5);
|
|
}
|
|
if (label) label.textContent = `◁ ${this.formatDollar(newOutflow)}/mo ▷`;
|
|
};
|
|
const onUp = () => {
|
|
handleG.removeEventListener("pointermove", onMove as EventListener);
|
|
handleG.removeEventListener("pointerup", onUp);
|
|
handleG.removeEventListener("lostpointercapture", onUp);
|
|
this.drawCanvasContent();
|
|
this.redrawEdges();
|
|
this.scheduleSave();
|
|
};
|
|
handleG.addEventListener("pointermove", onMove as EventListener);
|
|
handleG.addEventListener("pointerup", onUp);
|
|
handleG.addEventListener("lostpointercapture", onUp);
|
|
} else {
|
|
const startCapacity = fd.maxCapacity || 9000;
|
|
handleG.setPointerCapture(e.pointerId);
|
|
const label = handleG.querySelector("text");
|
|
const onMove = (ev: PointerEvent) => {
|
|
const deltaY = (ev.clientY - startY) / this.canvasZoom;
|
|
let newCapacity = Math.round((startCapacity + deltaY * 80) / 500) * 500;
|
|
newCapacity = Math.max(Math.round(fd.maxThreshold * 1.2), Math.min(100000, newCapacity));
|
|
fd.maxCapacity = newCapacity;
|
|
if (label) label.textContent = `⇕ ${this.formatDollar(newCapacity)}`;
|
|
};
|
|
const onUp = () => {
|
|
handleG.removeEventListener("pointermove", onMove as EventListener);
|
|
handleG.removeEventListener("pointerup", onUp);
|
|
handleG.removeEventListener("lostpointercapture", onUp);
|
|
this.drawCanvasContent();
|
|
this.redrawEdges();
|
|
this.scheduleSave();
|
|
};
|
|
handleG.addEventListener("pointermove", onMove as EventListener);
|
|
handleG.addEventListener("pointerup", onUp);
|
|
handleG.addEventListener("lostpointercapture", onUp);
|
|
}
|
|
}, { capture: true });
|
|
|
|
// 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) => {
|
|
// Split control drag
|
|
if (this._splitDragging) {
|
|
this.handleSplitDragMove(e.clientX);
|
|
return;
|
|
}
|
|
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");
|
|
}
|
|
// rAF throttle: skip if a frame is already queued
|
|
if (this._dragRafId) return;
|
|
this._dragRafId = requestAnimationFrame(() => {
|
|
this._dragRafId = null;
|
|
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.updateEdgesDuringDrag(node.id);
|
|
}
|
|
});
|
|
}
|
|
};
|
|
this._boundPointerUp = (e: PointerEvent) => {
|
|
// Split control drag end
|
|
if (this._splitDragging) {
|
|
this.handleSplitDragEnd();
|
|
return;
|
|
}
|
|
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");
|
|
// Cancel any pending rAF
|
|
if (this._dragRafId) { cancelAnimationFrame(this._dragRafId); this._dragRafId = null; }
|
|
|
|
// Single click = select + open inline editor
|
|
if (!wasDragged) {
|
|
this.selectedNodeId = clickedNodeId;
|
|
this.selectedEdgeKey = null;
|
|
this.updateSelectionHighlight();
|
|
this.enterInlineEdit(clickedNodeId);
|
|
} else {
|
|
// Full edge redraw for final accuracy
|
|
this.redrawEdges();
|
|
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();
|
|
|
|
// If click originated from HTML inside foreignObject, open inline edit but skip drag
|
|
const target = e.target as Element;
|
|
if (target instanceof HTMLElement && target.closest("foreignObject")) {
|
|
this.enterInlineEdit(nodeId);
|
|
return;
|
|
}
|
|
|
|
// 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 === "quick-fund") this.quickFund();
|
|
else if (action === "share") this.shareState();
|
|
else if (action === "tour") this.startTour();
|
|
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();
|
|
});
|
|
}
|
|
|
|
// Split control drag (delegated on node layer)
|
|
const nodeLayerForSplit = this.shadow.getElementById("node-layer");
|
|
if (nodeLayerForSplit) {
|
|
nodeLayerForSplit.addEventListener("pointerdown", (e: PointerEvent) => {
|
|
const divider = (e.target as Element).closest(".split-divider") as SVGGElement | null;
|
|
if (!divider) return;
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
const nodeId = divider.dataset.nodeId!;
|
|
const allocType = divider.dataset.allocType!;
|
|
const dividerIdx = parseInt(divider.dataset.dividerIdx!, 10);
|
|
|
|
// Capture starting percentages
|
|
const allocs = this.getSplitAllocs(nodeId, allocType);
|
|
if (!allocs || allocs.length < 2) return;
|
|
|
|
this._splitDragging = true;
|
|
this._splitDragNodeId = nodeId;
|
|
this._splitDragAllocType = allocType;
|
|
this._splitDragDividerIdx = dividerIdx;
|
|
this._splitDragStartX = e.clientX;
|
|
this._splitDragStartPcts = allocs.map(a => a.percentage);
|
|
(e.target as Element).setPointerCapture?.(e.pointerId);
|
|
});
|
|
}
|
|
|
|
// Edge layer — edge selection + drag handles
|
|
const edgeLayer = this.shadow.getElementById("edge-layer");
|
|
if (edgeLayer) {
|
|
// Edge selection — click on edge path
|
|
edgeLayer.addEventListener("pointerdown", (e: PointerEvent) => {
|
|
const target = e.target as Element;
|
|
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(".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) => {
|
|
// Use composedPath to pierce Shadow DOM retargeting (e.target is the host element, not the input)
|
|
const el = (e.composedPath()[0] || e.target) as HTMLElement;
|
|
const tag = el.tagName;
|
|
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable) return;
|
|
// Also skip if a modal overlay is open or the editor panel is focused
|
|
if (document.getElementById("onramp-modal") || el.closest?.("[style*='z-index:99999']")) return;
|
|
if (this.editingNodeId || this.inlineEditNodeId) {
|
|
if (e.key === "Delete" || e.key === "Backspace") return;
|
|
}
|
|
|
|
if (e.key === "Escape") {
|
|
if (this.inlineEditNodeId) { this.exitInlineEdit(); return; }
|
|
if (this.wiringActive) { this.cancelWiring(); return; }
|
|
if (this.analyticsOpen) { this.toggleAnalytics(); return; }
|
|
this.closeModal();
|
|
this.closeEditor();
|
|
}
|
|
else if (e.key === " ") { e.preventDefault(); this.toggleSimulation(); }
|
|
else if (e.key === "Delete" || e.key === "Backspace") { if (this.selectedNodeId) this.deleteNode(this.selectedNodeId); }
|
|
else if (e.key === "f" || e.key === "F") this.fitView();
|
|
else if (e.key === "=" || e.key === "+") { this.canvasZoom = Math.min(4, this.canvasZoom * 1.2); this.updateCanvasTransform(); }
|
|
else if (e.key === "-") { this.canvasZoom = Math.max(0.1, this.canvasZoom * 0.8); this.updateCanvasTransform(); }
|
|
};
|
|
document.addEventListener("keydown", this._boundKeyDown);
|
|
}
|
|
|
|
// ─── Inflow satisfaction computation ─────────────────
|
|
|
|
private computeInflowSatisfaction(): Map<string, { actual: number; needed: number; ratio: number }> {
|
|
const result = new Map<string, { actual: number; needed: number; ratio: number }>();
|
|
|
|
for (const n of this.nodes) {
|
|
if (n.type === "funnel") {
|
|
const d = n.data as FunnelNodeData;
|
|
const needed = d.inflowRate || 1;
|
|
let actual = 0;
|
|
// Sum source→funnel allocations
|
|
for (const src of this.nodes) {
|
|
if (src.type === "source") {
|
|
const sd = src.data as SourceNodeData;
|
|
for (const alloc of sd.targetAllocations) {
|
|
if (alloc.targetId === n.id) actual += sd.flowRate * (alloc.percentage / 100);
|
|
}
|
|
}
|
|
// Sum overflow from parent funnels
|
|
if (src.type === "funnel" && src.id !== n.id) {
|
|
const fd = src.data as FunnelNodeData;
|
|
const excess = Math.max(0, fd.currentValue - fd.maxThreshold);
|
|
for (const alloc of fd.overflowAllocations) {
|
|
if (alloc.targetId === n.id) actual += excess * (alloc.percentage / 100);
|
|
}
|
|
}
|
|
// Sum overflow from parent outcomes
|
|
if (src.type === "outcome") {
|
|
const od = src.data as OutcomeNodeData;
|
|
const excess = Math.max(0, od.fundingReceived - od.fundingTarget);
|
|
for (const alloc of (od.overflowAllocations || [])) {
|
|
if (alloc.targetId === n.id) actual += excess * (alloc.percentage / 100);
|
|
}
|
|
}
|
|
}
|
|
result.set(n.id, { actual, needed, ratio: Math.min(actual / needed, 2) });
|
|
}
|
|
if (n.type === "outcome") {
|
|
const d = n.data as OutcomeNodeData;
|
|
const needed = Math.max(d.fundingTarget, 1);
|
|
const actual = d.fundingReceived;
|
|
result.set(n.id, { actual, needed, ratio: Math.min(actual / needed, 2) });
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// ─── Node SVG rendering ───────────────────────────────
|
|
|
|
private renderAllNodes(): string {
|
|
const satisfaction = this.computeInflowSatisfaction();
|
|
return this.nodes.map((n) => this.renderNodeSvg(n, satisfaction)).join("");
|
|
}
|
|
|
|
private renderNodeSvg(n: FlowNode, satisfaction: Map<string, { actual: number; needed: number; ratio: number }>): string {
|
|
const sel = this.selectedNodeId === n.id;
|
|
if (n.type === "source") return this.renderSourceNodeSvg(n, sel);
|
|
if (n.type === "funnel") return this.renderFunnelNodeSvg(n, sel, satisfaction.get(n.id));
|
|
return this.renderOutcomeNodeSvg(n, sel, satisfaction.get(n.id));
|
|
}
|
|
|
|
private renderSourceNodeSvg(n: FlowNode, selected: boolean): string {
|
|
const d = n.data as SourceNodeData;
|
|
const s = this.getNodeSize(n);
|
|
const x = n.position.x, y = n.position.y, w = s.w, h = s.h;
|
|
|
|
// Valve color encodes sourceType
|
|
const valveColors: Record<string, string> = { card: "#3b82f6", safe_wallet: "#10b981", ridentity: "#7c3aed", metamask: "#f6851b", unconfigured: "#64748b" };
|
|
const valveColor = valveColors[d.sourceType] || "#64748b";
|
|
const isConfigured = d.sourceType !== "unconfigured";
|
|
|
|
// Horizontal pipe from left edge to valve
|
|
const pipeH = 22;
|
|
const pipeCY = 40; // vertical center of pipe
|
|
const pipeY = pipeCY - pipeH / 2;
|
|
|
|
// Valve: circle at center
|
|
const valveR = 22;
|
|
const valveCx = w * 0.5;
|
|
const valveCy = pipeCY;
|
|
|
|
// Handle rotation: 0°=closed(up), maps flowRate to angle (max 90°=open/right)
|
|
const maxRate = 50000;
|
|
const handleAngle = isConfigured ? Math.min(90, (d.flowRate / maxRate) * 90) : 0;
|
|
|
|
// Nozzle: trapezoid from valve right, angling 30° downward-right to x=w*0.75
|
|
const nozzleStartX = valveCx + valveR + 2;
|
|
const nozzleEndX = w * 0.75;
|
|
const nozzleStartY = pipeCY;
|
|
const nozzleEndY = pipeCY + (nozzleEndX - nozzleStartX) * Math.tan(30 * Math.PI / 180);
|
|
const nozzleTopW = 12; // half-width at start
|
|
const nozzleBotW = 7; // half-width at end
|
|
const nozzlePath = `M ${nozzleStartX},${nozzleStartY - nozzleTopW} L ${nozzleEndX},${nozzleEndY - nozzleBotW} L ${nozzleEndX},${nozzleEndY + nozzleBotW} L ${nozzleStartX},${nozzleStartY + nozzleTopW} Z`;
|
|
|
|
// Stream: rect from nozzle tip downward, width from Sankey pre-pass
|
|
const fw = this._currentFlowWidths.get(n.id);
|
|
const streamW = fw ? Math.max(4, Math.round(fw.outflowWidthPx * 0.4)) : Math.max(4, Math.round(Math.sqrt(d.flowRate / 100) * 2.5));
|
|
const streamX = nozzleEndX;
|
|
const streamY = nozzleEndY + nozzleBotW;
|
|
const streamH = h - streamY;
|
|
|
|
// Split control replaces old allocation bar
|
|
const allocBar = d.targetAllocations && d.targetAllocations.length >= 2
|
|
? this.renderSplitControl(n.id, "source", d.targetAllocations, w / 2, h - 8, w - 40)
|
|
: "";
|
|
|
|
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" data-collab-id="node:${n.id}" transform="translate(${x},${y})">
|
|
<rect class="node-bg" x="0" y="0" width="${w}" height="${h}" rx="8" fill="transparent" stroke="none"/>
|
|
<!-- Horizontal pipe from left to valve -->
|
|
<rect class="source-pipe" x="0" y="${pipeY}" width="${valveCx - valveR - 2}" height="${pipeH}" rx="4" fill="url(#pipe-metal-h)" stroke="${selected ? "var(--rflows-selected)" : "var(--rs-text-secondary)"}" stroke-width="${selected ? 2 : 1}"/>
|
|
<!-- Label on pipe -->
|
|
<text x="${(valveCx - valveR - 2) / 2}" y="${pipeCY + 1}" text-anchor="middle" dominant-baseline="central" fill="white" font-size="11" font-weight="600" pointer-events="none">${this.esc(d.label)}</text>
|
|
<!-- Rotary valve -->
|
|
<circle class="source-valve" cx="${valveCx}" cy="${valveCy}" r="${valveR}" fill="${valveColor}" stroke="${selected ? "var(--rflows-selected)" : "var(--rs-bg-surface)"}" stroke-width="${selected ? 2.5 : 1.5}" style="cursor:pointer"/>
|
|
<!-- Handle rotates 0-90° based on flowRate -->
|
|
<g transform="rotate(${-90 + handleAngle},${valveCx},${valveCy})">
|
|
<rect class="source-handle" x="${valveCx - 3}" y="${valveCy - valveR - 4}" width="6" height="${valveR + 4}" rx="3" fill="var(--rs-bg-surface)" opacity="0.8"/>
|
|
</g>
|
|
<!-- Nozzle angling downward-right -->
|
|
<path class="source-nozzle" d="${nozzlePath}" fill="url(#pipe-metal-h)" stroke="var(--rs-text-secondary)" stroke-width="1"/>
|
|
<!-- Flow stream downward from nozzle tip -->
|
|
<rect class="source-stream" x="${streamX - streamW / 2}" y="${streamY}" width="${streamW}" height="${Math.max(streamH, 4)}" rx="${streamW / 2}" fill="#10b981" opacity="${isConfigured ? 0.5 : 0.15}"/>
|
|
<!-- Amount label -->
|
|
<text x="${valveCx}" y="${h - 18}" text-anchor="middle" fill="var(--rs-text-primary)" font-size="13" font-weight="700" font-family="ui-monospace,monospace" pointer-events="none">$${d.flowRate.toLocaleString()}/mo</text>
|
|
${allocBar}
|
|
${this.renderPortsSvg(n)}
|
|
</g>`;
|
|
}
|
|
|
|
/** Compute the wall inset at a given Y fraction (0=top, 1=bottom) for the tapered vessel */
|
|
private vesselWallInset(yFrac: number, taperAtBottom: number): number {
|
|
return taperAtBottom * (yFrac * yFrac * 0.4 + yFrac * 0.6);
|
|
}
|
|
|
|
/** Compute the fill polygon path for a tapered vessel, tracing the walls from fillY to bottom */
|
|
private computeVesselFillPath(w: number, h: number, fillPct: number, taperAtBottom: number): string {
|
|
const zoneTop = 36;
|
|
const zoneBot = h - 6;
|
|
const zoneH = zoneBot - zoneTop;
|
|
const totalFillH = zoneH * fillPct;
|
|
const fillY = zoneTop + zoneH - totalFillH;
|
|
if (totalFillH <= 0) return "";
|
|
|
|
// Trace left wall downward from fillY, then right wall upward
|
|
const steps = 12;
|
|
const pts: string[] = [];
|
|
for (let i = 0; i <= steps; i++) {
|
|
const py = fillY + (zoneBot - fillY) * (i / steps);
|
|
const yf = (py - zoneTop) / zoneH; // 0-1 within zone
|
|
const inset = this.vesselWallInset(yf, taperAtBottom);
|
|
pts.push(`${inset},${py}`);
|
|
}
|
|
for (let i = steps; i >= 0; i--) {
|
|
const py = fillY + (zoneBot - fillY) * (i / steps);
|
|
const yf = (py - zoneTop) / zoneH;
|
|
const inset = this.vesselWallInset(yf, taperAtBottom);
|
|
pts.push(`${w - inset},${py}`);
|
|
}
|
|
return `M ${pts.join(" L ")} Z`;
|
|
}
|
|
|
|
private renderFunnelNodeSvg(n: FlowNode, selected: boolean, sat?: { actual: number; needed: number; ratio: number }): string {
|
|
const d = n.data as FunnelNodeData;
|
|
const s = this.getNodeSize(n);
|
|
const x = n.position.x, y = n.position.y, w = s.w, h = s.h;
|
|
const fillPct = Math.min(1, d.currentValue / (d.maxCapacity || 1));
|
|
|
|
const isOverflow = d.currentValue > d.maxThreshold;
|
|
const isCritical = d.currentValue < d.minThreshold;
|
|
const borderColorVar = isCritical ? "var(--rflows-status-critical)" : isOverflow ? "var(--rflows-status-overflow)" : "var(--rflows-status-sustained)";
|
|
const fillColor = borderColorVar;
|
|
const statusLabel = isCritical ? "Critical" : isOverflow ? "Overflow" : "Sufficient";
|
|
|
|
// Vessel shape parameters
|
|
const r = 10;
|
|
const drainW = 60; // narrow drain spout at bottom
|
|
const outflow = d.desiredOutflow || 0;
|
|
const outflowRatio = Math.min(1, outflow / 10000);
|
|
// taperAtBottom: how far walls inset at the very bottom (in px)
|
|
const taperAtBottom = (w - drainW) / 2;
|
|
|
|
// Overflow pipe parameters — positioned at max threshold
|
|
const pipeW = 28;
|
|
const basePipeH = 22;
|
|
const zoneTop = 36;
|
|
const zoneBot = h - 6;
|
|
const zoneH = zoneBot - zoneTop;
|
|
const minFrac = d.minThreshold / (d.maxCapacity || 1);
|
|
const maxFrac = d.maxThreshold / (d.maxCapacity || 1);
|
|
const maxLineY = zoneTop + zoneH * (1 - maxFrac);
|
|
// Fixed pipe height — animate fill/opacity instead of resizing to prevent frame jumps
|
|
const pipeH = basePipeH;
|
|
const pipeY = Math.round(maxLineY - basePipeH / 2);
|
|
const excessRatio = isOverflow && d.maxCapacity > d.maxThreshold
|
|
? Math.min(1, (d.currentValue - d.maxThreshold) / (d.maxCapacity - d.maxThreshold))
|
|
: 0;
|
|
|
|
// Wall inset at pipe Y position for pipe attachment
|
|
const pipeYFrac = (maxLineY - zoneTop) / zoneH;
|
|
const wallInsetAtPipe = this.vesselWallInset(pipeYFrac, taperAtBottom);
|
|
|
|
// Vessel outline: wide top, tapered walls to narrow drain spout
|
|
const steps = 16;
|
|
const leftWall: string[] = [];
|
|
const rightWall: string[] = [];
|
|
for (let i = 0; i <= steps; i++) {
|
|
const yf = i / steps;
|
|
const py = zoneTop + zoneH * yf;
|
|
const inset = this.vesselWallInset(yf, taperAtBottom);
|
|
leftWall.push(`${inset},${py}`);
|
|
rightWall.push(`${w - inset},${py}`);
|
|
}
|
|
|
|
// Compute interpolated wall insets at exact pipe boundaries to avoid path discontinuities
|
|
const pipeTopFrac = Math.max(0, (pipeY - zoneTop) / zoneH);
|
|
const pipeBotFrac = Math.min(1, (pipeY + pipeH - zoneTop) / zoneH);
|
|
const rightInsetAtPipeTop = this.vesselWallInset(pipeTopFrac, taperAtBottom);
|
|
const rightInsetAtPipeBot = this.vesselWallInset(pipeBotFrac, taperAtBottom);
|
|
|
|
// Right wall segments below pipe bottom
|
|
const rightWallBelow: string[] = [];
|
|
// Add interpolated point at exact pipe bottom
|
|
rightWallBelow.push(`${w - rightInsetAtPipeBot},${pipeY + pipeH}`);
|
|
for (let i = 0; i <= steps; i++) {
|
|
const py = zoneTop + zoneH * (i / steps);
|
|
if (py > pipeY + pipeH) rightWallBelow.push(rightWall[i]);
|
|
}
|
|
|
|
// Left wall segments below pipe bottom (reversed for upward traversal)
|
|
const leftWallBelow: string[] = [];
|
|
for (let i = 0; i <= steps; i++) {
|
|
const py = zoneTop + zoneH * (i / steps);
|
|
if (py > pipeY + pipeH) leftWallBelow.push(leftWall[i]);
|
|
}
|
|
// Add interpolated point at exact pipe bottom
|
|
leftWallBelow.push(`${this.vesselWallInset(pipeBotFrac, taperAtBottom)},${pipeY + pipeH}`);
|
|
leftWallBelow.reverse();
|
|
|
|
const vesselPath = [
|
|
`M ${r},0`,
|
|
`L ${w - r},0`,
|
|
`Q ${w},0 ${w},${r}`,
|
|
// Right wall: straight to pipe notch, then taper
|
|
`L ${w},${pipeY}`,
|
|
`L ${w + pipeW},${pipeY}`,
|
|
`L ${w + pipeW},${pipeY + pipeH}`,
|
|
// Continue right wall tapering from interpolated pipe bottom point
|
|
...rightWallBelow.map(p => `L ${p}`),
|
|
// Bottom: narrow drain spout with rounded corners
|
|
`L ${w - taperAtBottom + r},${zoneBot}`,
|
|
`Q ${w - taperAtBottom},${zoneBot} ${w - taperAtBottom},${h - r}`,
|
|
`L ${w - taperAtBottom},${h}`,
|
|
`L ${taperAtBottom},${h}`,
|
|
`L ${taperAtBottom},${h - r}`,
|
|
`Q ${taperAtBottom},${zoneBot} ${taperAtBottom + r},${zoneBot}`,
|
|
// Left wall tapering up from interpolated pipe bottom point
|
|
...leftWallBelow.map(p => `L ${p}`),
|
|
// Left pipe notch
|
|
`L ${-pipeW},${pipeY + pipeH}`,
|
|
`L ${-pipeW},${pipeY}`,
|
|
`L 0,${pipeY}`,
|
|
// Back up left wall to top
|
|
`L 0,${r}`,
|
|
`Q 0,0 ${r},0`,
|
|
`Z`,
|
|
].join(" ");
|
|
|
|
const clipId = `funnel-clip-${n.id}`;
|
|
|
|
// Zone dimensions
|
|
const criticalPct = minFrac;
|
|
const sufficientPct = maxFrac - minFrac;
|
|
const overflowPct = Math.max(0, 1 - maxFrac);
|
|
const criticalH = zoneH * criticalPct;
|
|
const sufficientH = zoneH * sufficientPct;
|
|
const overflowH = zoneH * overflowPct;
|
|
|
|
// Fill path (tapered polygon)
|
|
const fillPath = this.computeVesselFillPath(w, h, fillPct, taperAtBottom);
|
|
const totalFillH = zoneH * fillPct;
|
|
const fillY = zoneTop + zoneH - totalFillH;
|
|
|
|
// Threshold lines with X endpoints computed from wall taper
|
|
const minLineY = zoneTop + zoneH * (1 - minFrac);
|
|
const minYFrac = (minLineY - zoneTop) / zoneH;
|
|
const minInset = this.vesselWallInset(minYFrac, taperAtBottom);
|
|
const maxInset = this.vesselWallInset(pipeYFrac, taperAtBottom);
|
|
|
|
const thresholdLines = `
|
|
<line class="threshold-line" x1="${minInset + 4}" x2="${w - minInset - 4}" y1="${minLineY}" y2="${minLineY}" stroke="var(--rflows-status-critical)" stroke-width="2" stroke-dasharray="6 4" opacity="0.8"/>
|
|
<text x="${minInset + 8}" y="${minLineY - 5}" style="fill:var(--rflows-status-critical)" font-size="10" font-weight="500" opacity="0.9">Min</text>
|
|
<line class="threshold-line" x1="${maxInset + 4}" x2="${w - maxInset - 4}" y1="${maxLineY}" y2="${maxLineY}" stroke="var(--rflows-status-overflow)" stroke-width="2" stroke-dasharray="6 4" opacity="0.8"/>
|
|
<text x="${maxInset + 8}" y="${maxLineY - 5}" style="fill:var(--rflows-status-overflow)" font-size="10" font-weight="500" opacity="0.9">Max</text>`;
|
|
|
|
// Water surface shimmer line at fill level
|
|
const shimmerLine = fillPct > 0.01 ? `<line class="water-surface-line" x1="${this.vesselWallInset((fillY - zoneTop) / zoneH, taperAtBottom) + 2}" x2="${w - this.vesselWallInset((fillY - zoneTop) / zoneH, taperAtBottom) - 2}" y1="${fillY}" y2="${fillY}" stroke="url(#water-surface)" stroke-width="3"/>` : "";
|
|
|
|
// Overflow spill effects at pipe positions
|
|
const overflowSpill = isOverflow ? `
|
|
<ellipse class="overflow-spill-left" cx="${-pipeW - 4}" cy="${pipeY + pipeH / 2}" rx="8" ry="6" fill="url(#overflow-splash)"/>
|
|
<ellipse class="overflow-spill-right" cx="${w + pipeW + 4}" cy="${pipeY + pipeH / 2}" rx="8" ry="6" fill="url(#overflow-splash)"/>` : "";
|
|
|
|
// Inflow pipe indicator (Sankey-consistent)
|
|
const fwFunnel = this._currentFlowWidths.get(n.id);
|
|
const inflowPipeW = fwFunnel ? Math.min(w - 20, Math.round(fwFunnel.inflowWidthPx)) : 0;
|
|
const inflowFillRatio = fwFunnel ? fwFunnel.inflowFillRatio : 0;
|
|
const inflowPipeX = (w - inflowPipeW) / 2;
|
|
const inflowPipeIndicator = inflowPipeW > 0 ? `
|
|
<rect x="${inflowPipeX}" y="26" width="${inflowPipeW}" height="6" rx="3" fill="var(--rs-bg-surface-raised)" opacity="0.3"/>
|
|
<rect x="${inflowPipeX}" y="26" width="${Math.round(inflowPipeW * inflowFillRatio)}" height="6" rx="3" fill="var(--rflows-label-inflow)" opacity="0.7"/>` : "";
|
|
|
|
// Inflow satisfaction bar
|
|
const satBarY = 50;
|
|
const satBarW = w - 48;
|
|
const satRatio = sat ? Math.min(sat.ratio, 1) : 0;
|
|
const satOverflow = sat ? sat.ratio > 1 : false;
|
|
const satFillW = satBarW * satRatio;
|
|
const satLabel = sat ? `${this.formatDollar(sat.actual)} of ${this.formatDollar(sat.needed)}/mo` : "";
|
|
const satBarBorder = satOverflow ? `stroke="var(--rflows-sat-border)" stroke-width="1"` : "";
|
|
|
|
const glowStyle = isOverflow ? "filter: drop-shadow(0 0 8px rgba(16,185,129,0.5))"
|
|
: !isCritical ? "filter: drop-shadow(0 0 8px rgba(245,158,11,0.4))" : "";
|
|
|
|
// Rate labels
|
|
const inflowLabel = `${this.formatDollar(d.inflowRate)}/mo`;
|
|
const excess = Math.max(0, d.currentValue - d.maxThreshold);
|
|
const overflowLabel = isOverflow ? this.formatDollar(excess) : "";
|
|
|
|
// Status badge colors
|
|
const statusBadgeBg = isCritical ? "rgba(239,68,68,0.15)" : isOverflow ? "rgba(16,185,129,0.15)" : "rgba(245,158,11,0.15)";
|
|
const statusBadgeColor = isCritical ? "#ef4444" : isOverflow ? "#10b981" : "#f59e0b";
|
|
|
|
// Drain spout inset for valve handle positioning
|
|
const drainInset = taperAtBottom;
|
|
|
|
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" data-collab-id="node:${n.id}" transform="translate(${x},${y})" style="${glowStyle}">
|
|
<defs>
|
|
<clipPath id="${clipId}"><path d="${vesselPath}"/></clipPath>
|
|
</defs>
|
|
${isOverflow ? `<path d="${vesselPath}" fill="none" stroke="var(--rflows-status-overflow)" stroke-width="2.5" opacity="0.4" transform="translate(-2,-2) scale(${(w + 4) / w},${(h + 4) / h})"/>` : ""}
|
|
<path class="node-bg" d="${vesselPath}" style="fill:var(--rs-bg-surface)" stroke="${selected ? "var(--rflows-selected)" : borderColorVar}" stroke-width="${selected ? 3.5 : 2.5}"/>
|
|
<g clip-path="url(#${clipId})">
|
|
<rect x="${-pipeW}" y="${zoneTop + overflowH + sufficientH}" width="${w + pipeW * 2}" height="${criticalH}" style="fill:var(--rflows-zone-drain);opacity:var(--rflows-zone-drain-opacity)"/>
|
|
<rect x="${-pipeW}" y="${zoneTop + overflowH}" width="${w + pipeW * 2}" height="${sufficientH}" style="fill:var(--rflows-zone-healthy);opacity:var(--rflows-zone-healthy-opacity)"/>
|
|
<rect x="${-pipeW}" y="${zoneTop}" width="${w + pipeW * 2}" height="${overflowH}" style="fill:var(--rflows-zone-overflow);opacity:var(--rflows-zone-overflow-opacity)"/>
|
|
${fillPath ? `<path class="funnel-fill-path" data-node-id="${n.id}" d="${fillPath}" style="fill:${fillColor};opacity:var(--rflows-fill-opacity)"/>` : ""}
|
|
${shimmerLine}
|
|
${thresholdLines}
|
|
</g>
|
|
${inflowPipeIndicator}
|
|
<!-- Overflow pipes at max threshold -->
|
|
<rect class="funnel-pipe ${isOverflow ? "funnel-pipe--active" : ""}" data-pipe="left" data-node-id="${n.id}" x="${-pipeW}" y="${pipeY}" width="${pipeW}" height="${pipeH}" rx="4" style="fill:${isOverflow ? "var(--rflows-status-overflow)" : "var(--rs-bg-surface-raised)"}" opacity="${isOverflow ? 0.85 : 0.3}"/>
|
|
<rect class="funnel-pipe ${isOverflow ? "funnel-pipe--active" : ""}" data-pipe="right" data-node-id="${n.id}" x="${w}" y="${pipeY}" width="${pipeW}" height="${pipeH}" rx="4" style="fill:${isOverflow ? "var(--rflows-status-overflow)" : "var(--rs-bg-surface-raised)"}" opacity="${isOverflow ? 0.85 : 0.3}"/>
|
|
${overflowSpill}
|
|
<rect x="24" y="${satBarY}" width="${satBarW}" height="8" rx="4" style="fill:var(--rs-bg-surface-raised)" opacity="0.3" class="satisfaction-bar-bg"/>
|
|
<rect x="24" y="${satBarY}" width="${satFillW}" height="8" rx="4" style="fill:var(--rflows-sat-bar)" class="satisfaction-bar-fill" ${satBarBorder}/>
|
|
<!-- Drain valve handle at spout -->
|
|
<g class="funnel-valve-handle" data-handle="valve" data-node-id="${n.id}">
|
|
<rect x="${drainInset - 8}" y="${h - 16}" width="${drainW + 16}" height="18" rx="5"
|
|
style="fill:var(--rflows-label-spending);cursor:ew-resize;stroke:white;stroke-width:1.5"/>
|
|
<text x="${w / 2}" y="${h - 4}" text-anchor="middle" fill="white" font-size="11" font-weight="600" pointer-events="none">
|
|
◁ ${this.formatDollar(outflow)}/mo ▷
|
|
</text>
|
|
</g>
|
|
<!-- Spending split control at drain spout -->
|
|
${d.spendingAllocations.length >= 2
|
|
? this.renderSplitControl(n.id, "spending", d.spendingAllocations, w / 2, h + 26, Math.min(w - 20, drainW + 60))
|
|
: ""}
|
|
<!-- Overflow split control at pipe area -->
|
|
${d.overflowAllocations.length >= 2
|
|
? this.renderSplitControl(n.id, "overflow", d.overflowAllocations, w / 2, pipeY - 12, w - 40)
|
|
: ""}
|
|
<g class="funnel-height-handle" data-handle="height" data-node-id="${n.id}">
|
|
<rect x="${w / 2 - 28}" y="${h + 4}" width="56" height="12" rx="5"
|
|
style="fill:var(--rs-border-strong);cursor:ns-resize;stroke:var(--rs-text-muted);stroke-width:1"/>
|
|
<text x="${w / 2}" y="${h + 13}" text-anchor="middle" style="fill:var(--rs-text-muted)" font-size="9" font-weight="500" pointer-events="none">⇕ capacity</text>
|
|
</g>
|
|
<!-- Inflow label -->
|
|
<text x="${w / 2}" y="-8" text-anchor="middle" fill="#10b981" font-size="12" font-weight="500" opacity="0.9" pointer-events="none">\u2193 ${inflowLabel}</text>
|
|
<!-- Node label + status badge -->
|
|
<foreignObject x="0" y="0" width="${w}" height="32" class="funnel-overlay">
|
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:center;justify-content:space-between;padding:8px 10px 0;font-family:system-ui,-apple-system,sans-serif;pointer-events:none">
|
|
<span style="font-size:14px;font-weight:600;color:var(--rs-text-primary)">${this.esc(d.label)}</span>
|
|
<span style="font-size:10px;font-weight:600;padding:2px 8px;border-radius:9999px;background:${statusBadgeBg};color:${statusBadgeColor}">${statusLabel}</span>
|
|
</div>
|
|
</foreignObject>
|
|
<!-- Satisfaction label -->
|
|
<text x="${w / 2}" y="${satBarY + 22}" text-anchor="middle" fill="var(--rs-text-secondary)" font-size="10" pointer-events="none">${satLabel}</text>
|
|
<!-- Zone labels (SVG text in clip group) -->
|
|
${criticalH > 20 ? `<text x="${w / 2}" y="${zoneTop + overflowH + sufficientH + criticalH / 2 + 4}" text-anchor="middle" fill="#ef4444" font-size="10" font-weight="600" opacity="0.5" pointer-events="none">CRITICAL</text>` : ""}
|
|
${sufficientH > 20 ? `<text x="${w / 2}" y="${zoneTop + overflowH + sufficientH / 2 + 4}" text-anchor="middle" fill="#f59e0b" font-size="10" font-weight="600" opacity="0.5" pointer-events="none">SUFFICIENT</text>` : ""}
|
|
${overflowH > 20 ? `<text x="${w / 2}" y="${zoneTop + overflowH / 2 + 4}" text-anchor="middle" fill="#f59e0b" font-size="10" font-weight="600" opacity="0.5" pointer-events="none">OVERFLOW</text>` : ""}
|
|
<!-- Value text -->
|
|
<text class="funnel-value-text" data-node-id="${n.id}" x="${w / 2}" y="${h - drainInset - 44}" text-anchor="middle" fill="var(--rs-text-muted)" font-size="13" font-weight="500" pointer-events="none">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.maxThreshold).toLocaleString()}</text>
|
|
<!-- Outflow label -->
|
|
<text x="${w / 2}" y="${h + 20}" text-anchor="middle" fill="#34d399" font-size="12" font-weight="600" pointer-events="none">${this.formatDollar(outflow)}/mo \u25BE</text>
|
|
<!-- Overflow labels at pipe positions -->
|
|
${isOverflow ? `<text x="${-pipeW - 6}" y="${pipeY + pipeH / 2 + 4}" text-anchor="end" fill="#6ee7b7" font-size="11" font-weight="500" opacity="0.8" pointer-events="none">${overflowLabel}</text>
|
|
<text x="${w + pipeW + 6}" y="${pipeY + pipeH / 2 + 4}" text-anchor="start" fill="#6ee7b7" font-size="11" font-weight="500" opacity="0.8" pointer-events="none">${overflowLabel}</text>` : ""}
|
|
${this.renderPortsSvg(n)}
|
|
</g>`;
|
|
}
|
|
|
|
private renderOutcomeNodeSvg(n: FlowNode, selected: boolean, sat?: { actual: number; needed: number; ratio: number }): string {
|
|
const d = n.data as OutcomeNodeData;
|
|
const s = this.getNodeSize(n);
|
|
const x = n.position.x, y = n.position.y, w = s.w, h = s.h;
|
|
const fillPct = d.fundingTarget > 0 ? Math.min(1, d.fundingReceived / d.fundingTarget) : 0;
|
|
const isOverfunded = d.fundingReceived > d.fundingTarget && d.fundingTarget > 0;
|
|
const statusColors: Record<string, string> = { completed: "#10b981", blocked: "#ef4444", "in-progress": "#3b82f6", "not-started": "#64748b" };
|
|
const statusColor = statusColors[d.status] || "#64748b";
|
|
const statusLabel = d.status.replace("-", " ").replace(/\b\w/g, c => c.toUpperCase());
|
|
|
|
// Basin water gradient by status
|
|
const waterGrad: Record<string, string> = { completed: "url(#basin-water-green)", blocked: "url(#basin-water-red)", "in-progress": "url(#basin-water-blue)", "not-started": "url(#basin-water-grey)" };
|
|
const waterFill = waterGrad[d.status] || "url(#basin-water-grey)";
|
|
|
|
// Basin shape: open-top U with rounded bottom
|
|
const wallDrop = h * 0.30; // straight sides go down 30%
|
|
const curveY = wallDrop;
|
|
const basinPath = `M 0,0 L 0,${curveY} Q 0,${h} ${w / 2},${h} Q ${w},${h} ${w},${curveY} L ${w},0`;
|
|
const basinClosedPath = `${basinPath} Z`; // closed for clip
|
|
const clipId = `basin-clip-${n.id}`;
|
|
|
|
// Water fill: rises from bottom, clipped to basin
|
|
const waterTop = h - (h - 10) * fillPct; // 10px margin at bottom
|
|
const waterRect = fillPct > 0 ? `<rect class="basin-water-fill" x="0" y="${waterTop}" width="${w}" height="${h - waterTop}" fill="${waterFill}"/>` : "";
|
|
|
|
// Ripple pattern on water surface
|
|
const ripple = fillPct > 0.05 ? `<rect x="0" y="${waterTop}" width="${w}" height="8" fill="url(#water-ripple)" class="basin-ripple"/>` : "";
|
|
|
|
// Phase markers: short horizontal lines on left basin wall
|
|
let phaseMarkers = "";
|
|
if (d.phases && d.phases.length > 0) {
|
|
phaseMarkers = d.phases.map((p) => {
|
|
const phaseFrac = d.fundingTarget > 0 ? Math.min(1, p.fundingThreshold / d.fundingTarget) : 0;
|
|
const markerY = h - (h - 10) * phaseFrac;
|
|
const unlocked = d.fundingReceived >= p.fundingThreshold;
|
|
return `<line x1="4" x2="18" y1="${markerY}" y2="${markerY}" stroke="${unlocked ? "#10b981" : "var(--rs-text-muted)"}" stroke-width="2" opacity="0.7"/>
|
|
<circle cx="22" cy="${markerY}" r="3" fill="${unlocked ? "#10b981" : "var(--rs-border)"}" stroke="none"/>`;
|
|
}).join("");
|
|
}
|
|
|
|
// Overflow splash at rim when overfunded
|
|
const overflowSplash = isOverfunded ? `
|
|
<ellipse class="overflow-spill-left" cx="10" cy="0" rx="12" ry="6" fill="url(#overflow-splash)"/>
|
|
<ellipse class="overflow-spill-right" cx="${w - 10}" cy="0" rx="12" ry="6" fill="url(#overflow-splash)"/>` : "";
|
|
|
|
const dollarLabel = `${this.formatDollar(d.fundingReceived)} / ${this.formatDollar(d.fundingTarget)}`;
|
|
|
|
// Phase segments for header
|
|
let phaseSeg = "";
|
|
if (d.phases && d.phases.length > 0) {
|
|
const unlockedCount = d.phases.filter((p) => d.fundingReceived >= p.fundingThreshold).length;
|
|
phaseSeg = `<span style="font-size:9px;color:var(--rs-text-secondary)">${unlockedCount}/${d.phases.length} phases</span>`;
|
|
}
|
|
|
|
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" data-collab-id="node:${n.id}" transform="translate(${x},${y})">
|
|
<defs>
|
|
<clipPath id="${clipId}"><path d="${basinClosedPath}"/></clipPath>
|
|
</defs>
|
|
<!-- Basin outline -->
|
|
<path class="node-bg basin-outline" d="${basinPath}" fill="var(--rs-bg-surface)" stroke="${selected ? "var(--rflows-selected)" : statusColor}" stroke-width="${selected ? 3 : 2}" stroke-linecap="round"/>
|
|
<!-- Water fill clipped to basin -->
|
|
<g clip-path="url(#${clipId})">
|
|
${waterRect}
|
|
${ripple}
|
|
${phaseMarkers}
|
|
</g>
|
|
${overflowSplash}
|
|
<!-- Header above basin -->
|
|
<foreignObject x="-10" y="-32" width="${w + 20}" height="34">
|
|
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:center;justify-content:center;gap:6px;font-family:system-ui,-apple-system,sans-serif;pointer-events:none">
|
|
<span style="font-size:13px;font-weight:600;color:var(--rs-text-primary)">${this.esc(d.label)}</span>
|
|
<span style="font-size:10px;font-weight:600;padding:2px 8px;border-radius:9999px;background:${statusColor}20;color:${statusColor}">${statusLabel}</span>
|
|
${phaseSeg}
|
|
</div>
|
|
</foreignObject>
|
|
<!-- Funding text centered in basin -->
|
|
<text x="${w / 2}" y="${Math.max(waterTop + (h - waterTop) / 2 + 4, h / 2 + 4)}" text-anchor="middle" fill="var(--rs-text-primary)" font-size="12" font-weight="600" font-family="ui-monospace,monospace" pointer-events="none" opacity="0.9">${Math.round(fillPct * 100)}%</text>
|
|
<text x="${w / 2}" y="${Math.max(waterTop + (h - waterTop) / 2 + 18, h / 2 + 18)}" text-anchor="middle" fill="var(--rs-text-muted)" font-size="10" pointer-events="none">${dollarLabel}</text>
|
|
${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;
|
|
}
|
|
|
|
/** Render a proportional split control — draggable stacked bar showing allocation ratios */
|
|
private renderSplitControl(
|
|
nodeId: string, allocType: string,
|
|
allocs: { targetId: string; percentage: number; color: string }[],
|
|
cx: number, cy: number, trackW: number,
|
|
): string {
|
|
if (!allocs || allocs.length < 2) return "";
|
|
const trackH = 14;
|
|
const trackX = cx - trackW / 2;
|
|
const trackY = cy - trackH / 2;
|
|
|
|
let svg = `<g class="split-control" data-node-id="${nodeId}" data-alloc-type="${allocType}">`;
|
|
svg += `<rect class="split-track" x="${trackX}" y="${trackY}" width="${trackW}" height="${trackH}" rx="4" fill="var(--rs-bg-surface-raised)" opacity="0.5"/>`;
|
|
|
|
// Segments
|
|
let segX = trackX;
|
|
for (let i = 0; i < allocs.length; i++) {
|
|
const a = allocs[i];
|
|
const segW = trackW * (a.percentage / 100);
|
|
svg += `<rect class="split-seg" x="${segX}" y="${trackY}" width="${Math.max(segW, 2)}" height="${trackH}" ${i === 0 ? 'rx="4"' : ""} ${i === allocs.length - 1 ? 'rx="4"' : ""} fill="${a.color}" opacity="0.75"/>`;
|
|
segX += segW;
|
|
}
|
|
|
|
// Dividers between segments
|
|
let divX = trackX;
|
|
for (let i = 0; i < allocs.length - 1; i++) {
|
|
divX += trackW * (allocs[i].percentage / 100);
|
|
const leftPct = Math.round(allocs[i].percentage);
|
|
const rightPct = Math.round(allocs[i + 1].percentage);
|
|
svg += `<g class="split-divider" data-divider-idx="${i}" data-node-id="${nodeId}" data-alloc-type="${allocType}" style="cursor:ew-resize">
|
|
<rect x="${divX - 6}" y="${trackY - 3}" width="12" height="${trackH + 6}" rx="3" fill="var(--rs-bg-surface)" stroke="var(--rs-text-muted)" stroke-width="1" opacity="0.9"/>
|
|
<text x="${divX}" y="${trackY - 6}" text-anchor="middle" fill="var(--rs-text-secondary)" font-size="9" font-weight="600" pointer-events="none">${leftPct}% | ${rightPct}%</text>
|
|
</g>`;
|
|
}
|
|
|
|
svg += `</g>`;
|
|
return svg;
|
|
}
|
|
|
|
// ─── 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)}`;
|
|
}
|
|
|
|
/** Pre-pass: compute per-node flow totals and Sankey-consistent pixel widths */
|
|
private computeFlowWidths(): void {
|
|
const MIN_PX = 8, MAX_PX = 80;
|
|
const nodeFlows = new Map<string, { totalOutflow: number; totalInflow: number }>();
|
|
|
|
// Initialize all nodes
|
|
for (const n of this.nodes) nodeFlows.set(n.id, { totalOutflow: 0, totalInflow: 0 });
|
|
|
|
// Sum outflow/inflow per node (mirrors edge-building logic)
|
|
for (const n of this.nodes) {
|
|
if (n.type === "source") {
|
|
const d = n.data as SourceNodeData;
|
|
for (const alloc of d.targetAllocations) {
|
|
const flow = d.flowRate * (alloc.percentage / 100);
|
|
nodeFlows.get(n.id)!.totalOutflow += flow;
|
|
if (nodeFlows.has(alloc.targetId)) nodeFlows.get(alloc.targetId)!.totalInflow += flow;
|
|
}
|
|
}
|
|
if (n.type === "funnel") {
|
|
const d = n.data as FunnelNodeData;
|
|
const excess = Math.max(0, d.currentValue - d.maxThreshold);
|
|
for (const alloc of d.overflowAllocations) {
|
|
const flow = excess * (alloc.percentage / 100);
|
|
nodeFlows.get(n.id)!.totalOutflow += flow;
|
|
if (nodeFlows.has(alloc.targetId)) nodeFlows.get(alloc.targetId)!.totalInflow += flow;
|
|
}
|
|
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;
|
|
for (const alloc of d.spendingAllocations) {
|
|
const flow = drain * (alloc.percentage / 100);
|
|
nodeFlows.get(n.id)!.totalOutflow += flow;
|
|
if (nodeFlows.has(alloc.targetId)) nodeFlows.get(alloc.targetId)!.totalInflow += flow;
|
|
}
|
|
}
|
|
if (n.type === "outcome") {
|
|
const d = n.data as OutcomeNodeData;
|
|
const excess = Math.max(0, d.fundingReceived - d.fundingTarget);
|
|
for (const alloc of (d.overflowAllocations || [])) {
|
|
const flow = excess * (alloc.percentage / 100);
|
|
nodeFlows.get(n.id)!.totalOutflow += flow;
|
|
if (nodeFlows.has(alloc.targetId)) nodeFlows.get(alloc.targetId)!.totalInflow += flow;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Find global max outflow for scaling
|
|
let globalMaxFlow = 0;
|
|
for (const [, v] of nodeFlows) globalMaxFlow = Math.max(globalMaxFlow, v.totalOutflow);
|
|
if (globalMaxFlow === 0) globalMaxFlow = 1;
|
|
|
|
// Compute pixel widths
|
|
this._currentFlowWidths = new Map();
|
|
for (const n of this.nodes) {
|
|
const nf = nodeFlows.get(n.id)!;
|
|
const outflowWidthPx = nf.totalOutflow > 0 ? MIN_PX + (nf.totalOutflow / globalMaxFlow) * (MAX_PX - MIN_PX) : MIN_PX;
|
|
// Inflow: compute needed rate for funnels/outcomes
|
|
let neededInflow = 0;
|
|
if (n.type === "funnel") neededInflow = (n.data as FunnelNodeData).inflowRate || 1;
|
|
else if (n.type === "outcome") neededInflow = Math.max((n.data as OutcomeNodeData).fundingTarget, 1);
|
|
const inflowFillRatio = neededInflow > 0 ? Math.min(nf.totalInflow / neededInflow, 1) : 0;
|
|
const inflowWidthPx = nf.totalInflow > 0 ? MIN_PX + (nf.totalInflow / globalMaxFlow) * (MAX_PX - MIN_PX) : MIN_PX;
|
|
this._currentFlowWidths.set(n.id, { totalOutflow: nf.totalOutflow, totalInflow: nf.totalInflow, outflowWidthPx, inflowWidthPx, inflowFillRatio });
|
|
}
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pre-compute Sankey-consistent flow widths
|
|
this.computeFlowWidths();
|
|
|
|
// Second pass: render edges with per-node proportional widths (Sankey-consistent)
|
|
const MIN_EDGE_W = 3;
|
|
let html = "";
|
|
for (const e of edges) {
|
|
const from = this.getPortPosition(e.fromNode, e.fromPort, e.fromSide);
|
|
const to = this.getPortPosition(e.toNode, "inflow");
|
|
const isGhost = e.flowAmount === 0;
|
|
// Per-node proportional width: edge width = node's outflowWidthPx * (edgeFlow / totalOutflow)
|
|
const nodeWidths = this._currentFlowWidths.get(e.fromId);
|
|
let strokeW: number;
|
|
if (isGhost) {
|
|
strokeW = 1;
|
|
} else if (nodeWidths && nodeWidths.totalOutflow > 0) {
|
|
strokeW = Math.max(MIN_EDGE_W, nodeWidths.outflowWidthPx * (e.flowAmount / nodeWidths.totalOutflow));
|
|
} else {
|
|
strokeW = 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="0" y="5" style="fill:${color}" font-size="11" font-weight="600" text-anchor="middle" opacity="0.5">${label}</text>
|
|
</g>
|
|
</g>`;
|
|
}
|
|
|
|
const overflowMul = dashed ? 1.3 : 1;
|
|
const finalStrokeW = strokeW * overflowMul;
|
|
const animClass = dashed ? "edge-path-overflow" : "edge-path-animated";
|
|
// Label box — read-only, no +/- buttons (splits controlled at nodes)
|
|
const labelW = Math.max(68, label.length * 7 + 12);
|
|
const halfW = labelW / 2;
|
|
// Drag handle at midpoint
|
|
const dragHandle = `<circle cx="${midX}" cy="${midY - 18}" r="5" class="edge-drag-handle"/>`;
|
|
// Arrow marker
|
|
const markerId = edgeType === "overflow" ? "arrowhead-overflow" : edgeType === "spending" ? "arrowhead-spending" : "arrowhead-inflow";
|
|
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}" marker-end="url(#${markerId})"/>
|
|
${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>
|
|
</g>`;
|
|
}
|
|
|
|
private redrawEdges() {
|
|
const edgeLayer = this.shadow.getElementById("edge-layer");
|
|
if (edgeLayer) edgeLayer.innerHTML = this.renderAllEdges();
|
|
}
|
|
|
|
/** Pure path computation — returns { d, midX, midY } */
|
|
private computeEdgePath(
|
|
x1: number, y1: number, x2: number, y2: number,
|
|
strokeW: number, fromSide?: "left" | "right",
|
|
waypoint?: { x: number; y: number },
|
|
): { d: string; midX: number; midY: number } {
|
|
let d: string, midX: number, midY: number;
|
|
if (waypoint) {
|
|
const cx1 = (4 * waypoint.x - x1 - x2) / 3;
|
|
const cy1 = (4 * waypoint.y - y1 - y2) / 3;
|
|
const c1x = x1 + (cx1 - x1) * 0.8;
|
|
const c1y = y1 + (cy1 - y1) * 0.8;
|
|
const c2x = x2 + (cx1 - x2) * 0.8;
|
|
const c2y = y2 + (cy1 - y2) * 0.8;
|
|
d = `M ${x1} ${y1} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x2} ${y2}`;
|
|
midX = waypoint.x;
|
|
midY = waypoint.y;
|
|
} else if (fromSide) {
|
|
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;
|
|
}
|
|
return { d, midX, midY };
|
|
}
|
|
|
|
/** Lightweight edge update during drag — only patches path `d` attrs and label positions for edges connected to dragged node */
|
|
private updateEdgesDuringDrag(nodeId: string) {
|
|
const edgeLayer = this.shadow.getElementById("edge-layer");
|
|
if (!edgeLayer) return;
|
|
const groups = edgeLayer.querySelectorAll(`.edge-group[data-from="${nodeId}"], .edge-group[data-to="${nodeId}"]`);
|
|
for (const g of groups) {
|
|
const el = g as SVGGElement;
|
|
const fromId = el.dataset.from!;
|
|
const toId = el.dataset.to!;
|
|
const edgeType = el.dataset.edgeType || "source";
|
|
const fromNode = this.nodes.find(n => n.id === fromId);
|
|
const toNode = this.nodes.find(n => n.id === toId);
|
|
if (!fromNode || !toNode) continue;
|
|
|
|
// Determine port kinds and side
|
|
let fromPort: PortKind = "outflow";
|
|
let fromSide: "left" | "right" | undefined;
|
|
if (edgeType === "overflow") {
|
|
fromPort = "overflow";
|
|
fromSide = this.getOverflowSideForTarget(fromNode, toNode);
|
|
} else if (edgeType === "spending") {
|
|
fromPort = "spending";
|
|
}
|
|
|
|
const from = this.getPortPosition(fromNode, fromPort, fromSide);
|
|
const to = this.getPortPosition(toNode, "inflow");
|
|
|
|
// Get waypoint from allocation
|
|
const alloc = this.findEdgeAllocation(fromId, toId, edgeType);
|
|
const waypoint = alloc?.waypoint;
|
|
|
|
// Compute stroke width (approximate — use existing path width)
|
|
const mainPath = el.querySelector<SVGPathElement>(".edge-path-animated, .edge-path-overflow, .edge-ghost");
|
|
const existingStrokeW = mainPath ? parseFloat(mainPath.getAttribute("stroke-width") || "4") : 4;
|
|
|
|
const { d, midX, midY } = this.computeEdgePath(from.x, from.y, to.x, to.y, existingStrokeW, fromSide, waypoint);
|
|
|
|
// Update all path elements in this group
|
|
el.querySelectorAll("path").forEach(path => {
|
|
path.setAttribute("d", d);
|
|
});
|
|
|
|
// Update label/control group position
|
|
const ctrlGroup = el.querySelector(".edge-ctrl-group") as SVGGElement | null;
|
|
if (ctrlGroup) ctrlGroup.setAttribute("transform", `translate(${midX},${midY})`);
|
|
|
|
// Update drag handle
|
|
const dragHandle = el.querySelector(".edge-drag-handle") as SVGCircleElement | null;
|
|
if (dragHandle) {
|
|
dragHandle.setAttribute("cx", String(midX));
|
|
dragHandle.setAttribute("cy", String(midY - 18));
|
|
}
|
|
|
|
// Update splash circle for overflow edges
|
|
const splash = el.querySelector(".edge-splash") as SVGCircleElement | null;
|
|
if (splash) {
|
|
splash.setAttribute("cx", String(from.x));
|
|
splash.setAttribute("cy", String(from.y));
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── 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);
|
|
// Update SVG rect stroke
|
|
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 {
|
|
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" ? "2" : "2");
|
|
}
|
|
}
|
|
}
|
|
// Update HTML card selected class
|
|
const card = el.querySelector(".node-card") as HTMLElement | null;
|
|
if (card) card.classList.toggle("selected", isSelected);
|
|
});
|
|
|
|
// Edge selection highlight
|
|
const edgeLayer = this.shadow.getElementById("edge-layer");
|
|
if (!edgeLayer) return;
|
|
edgeLayer.querySelectorAll(".edge-group").forEach((g) => {
|
|
const el = g as SVGGElement;
|
|
const fromId = el.dataset.from;
|
|
const toId = el.dataset.to;
|
|
const edgeType = el.dataset.edgeType || "source";
|
|
const key = `${fromId}::${toId}::${edgeType}`;
|
|
el.classList.toggle("edge-group--selected", key === this.selectedEdgeKey);
|
|
});
|
|
}
|
|
|
|
private getNodeBorderColor(n: FlowNode): string {
|
|
if (n.type === "source") return "var(--rflows-source-border)";
|
|
if (n.type === "funnel") {
|
|
const d = n.data as FunnelNodeData;
|
|
return d.currentValue < d.minThreshold ? "var(--rflows-status-critical)"
|
|
: d.currentValue > d.maxThreshold ? "var(--rflows-status-overflow)"
|
|
: "var(--rflows-status-sustained)";
|
|
}
|
|
const d = n.data as OutcomeNodeData;
|
|
return d.status === "completed" ? "var(--rflows-status-completed)" : d.status === "blocked" ? "var(--rflows-status-blocked)" : d.status === "in-progress" ? "var(--rflows-status-inprogress)" : "var(--rflows-status-notstarted)";
|
|
}
|
|
|
|
// ─── Port rendering & wiring ─────────────────────────
|
|
|
|
private getPortDefs(nodeType: FlowNode["type"]): PortDefinition[] {
|
|
return PORT_DEFS[nodeType] || [];
|
|
}
|
|
|
|
private getPortPosition(node: FlowNode, portKind: PortKind, side?: "left" | "right"): { x: number; y: number } {
|
|
const s = this.getNodeSize(node);
|
|
let def: PortDefinition | undefined;
|
|
if (side) {
|
|
def = this.getPortDefs(node.type).find((p) => p.kind === portKind && p.side === side);
|
|
}
|
|
if (!def) {
|
|
def = this.getPortDefs(node.type).find((p) => p.kind === portKind && (!side || !p.side));
|
|
}
|
|
if (!def) {
|
|
// Fallback: pick first matching kind
|
|
def = this.getPortDefs(node.type).find((p) => p.kind === portKind);
|
|
}
|
|
if (!def) return { x: node.position.x + s.w / 2, y: node.position.y + s.h / 2 };
|
|
|
|
// Dynamic overflow port Y for funnels — match pipe at max threshold line
|
|
if (node.type === "funnel" && portKind === "overflow" && def.side) {
|
|
const d = node.data as FunnelNodeData;
|
|
const h = s.h;
|
|
const zoneTop = 36, zoneBot = h - 6, zoneH = zoneBot - zoneTop;
|
|
const maxFrac = d.maxThreshold / (d.maxCapacity || 1);
|
|
const maxLineY = zoneTop + zoneH * (1 - maxFrac);
|
|
// X position: fully outside the vessel walls (pipe extends outward)
|
|
const pipeW = 28;
|
|
const xPos = def.side === "left" ? node.position.x - pipeW : node.position.x + s.w + pipeW;
|
|
return { x: xPos, y: node.position.y + maxLineY };
|
|
}
|
|
|
|
return { x: node.position.x + s.w * def.xFrac, y: node.position.y + s.h * def.yFrac };
|
|
}
|
|
|
|
/** Pick the overflow side port closest to a target node */
|
|
private getOverflowSideForTarget(fromNode: FlowNode, toNode: FlowNode): "left" | "right" {
|
|
const toCenter = toNode.position.x + this.getNodeSize(toNode).w / 2;
|
|
const fromCenter = fromNode.position.x + this.getNodeSize(fromNode).w / 2;
|
|
return toCenter < fromCenter ? "left" : "right";
|
|
}
|
|
|
|
private renderPortsSvg(n: FlowNode): string {
|
|
const s = this.getNodeSize(n);
|
|
const defs = this.getPortDefs(n.type);
|
|
return defs.map((p) => {
|
|
let cx = s.w * p.xFrac;
|
|
let cy = s.h * p.yFrac;
|
|
|
|
// Funnel overflow ports: position at pipe ends (max threshold line)
|
|
if (n.type === "funnel" && p.kind === "overflow" && p.side) {
|
|
const d = n.data as FunnelNodeData;
|
|
const zoneTop = 36, zoneBot = s.h - 6, zoneH = zoneBot - zoneTop;
|
|
const maxFrac = d.maxThreshold / (d.maxCapacity || 1);
|
|
cy = zoneTop + zoneH * (1 - maxFrac);
|
|
const pipeW = 28;
|
|
cx = p.side === "left" ? -pipeW : s.w + pipeW;
|
|
}
|
|
|
|
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="14" fill="transparent"/>
|
|
<circle class="port-dot" cx="${cx}" cy="${cy}" r="7" style="fill:${p.color};color:${p.color}" stroke="white" stroke-width="2"/>
|
|
${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 ────────────────────────────
|
|
|
|
/** Get the allocation array for a node + allocType combo */
|
|
private getSplitAllocs(nodeId: string, allocType: string): { targetId: string; percentage: number; color: string }[] | null {
|
|
const node = this.nodes.find(n => n.id === nodeId);
|
|
if (!node) return null;
|
|
if (allocType === "source" && node.type === "source") return (node.data as SourceNodeData).targetAllocations;
|
|
if (allocType === "spending" && node.type === "funnel") return (node.data as FunnelNodeData).spendingAllocations;
|
|
if (allocType === "overflow") {
|
|
if (node.type === "funnel") return (node.data as FunnelNodeData).overflowAllocations;
|
|
if (node.type === "outcome") return (node.data as OutcomeNodeData).overflowAllocations || [];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Handle split divider drag — redistribute percentages between adjacent segments */
|
|
private handleSplitDragMove(clientX: number) {
|
|
if (!this._splitDragging || !this._splitDragNodeId) return;
|
|
const allocs = this.getSplitAllocs(this._splitDragNodeId, this._splitDragAllocType!);
|
|
if (!allocs || allocs.length < 2) return;
|
|
|
|
const idx = this._splitDragDividerIdx;
|
|
const startPcts = this._splitDragStartPcts;
|
|
const MIN_PCT = 5;
|
|
|
|
// Compute delta as percentage of track width (approximate from zoom-adjusted pixels)
|
|
const deltaX = clientX - this._splitDragStartX;
|
|
const trackW = 200; // approximate track width in pixels
|
|
const deltaPct = (deltaX / (trackW * this.canvasZoom)) * 100;
|
|
|
|
// Redistribute between segment[idx] and segment[idx+1]
|
|
const leftOrig = startPcts[idx];
|
|
const rightOrig = startPcts[idx + 1];
|
|
const combined = leftOrig + rightOrig;
|
|
let newLeft = Math.round(leftOrig + deltaPct);
|
|
newLeft = Math.max(MIN_PCT, Math.min(combined - MIN_PCT, newLeft));
|
|
const newRight = combined - newLeft;
|
|
|
|
allocs[idx].percentage = newLeft;
|
|
allocs[idx + 1].percentage = newRight;
|
|
|
|
// Normalize to exactly 100
|
|
const total = allocs.reduce((s, a) => s + a.percentage, 0);
|
|
if (total !== 100 && allocs.length > 0) {
|
|
allocs[allocs.length - 1].percentage += 100 - total;
|
|
allocs[allocs.length - 1].percentage = Math.max(MIN_PCT, allocs[allocs.length - 1].percentage);
|
|
}
|
|
|
|
// 60fps visual update: patch split control SVG in-place
|
|
this.updateSplitControlVisual(this._splitDragNodeId, this._splitDragAllocType!);
|
|
this.redrawEdges();
|
|
}
|
|
|
|
private handleSplitDragEnd() {
|
|
if (!this._splitDragging) return;
|
|
const nodeId = this._splitDragNodeId;
|
|
this._splitDragging = false;
|
|
this._splitDragNodeId = null;
|
|
this._splitDragAllocType = null;
|
|
this._splitDragStartPcts = [];
|
|
if (nodeId) {
|
|
this.refreshEditorIfOpen(nodeId);
|
|
this.scheduleSave();
|
|
}
|
|
}
|
|
|
|
/** Patch split control segment widths and divider positions without full re-render */
|
|
private updateSplitControlVisual(nodeId: string, allocType: string) {
|
|
const nodeLayer = this.shadow.getElementById("node-layer");
|
|
if (!nodeLayer) return;
|
|
const ctrl = nodeLayer.querySelector(`.split-control[data-node-id="${nodeId}"][data-alloc-type="${allocType}"]`) as SVGGElement | null;
|
|
if (!ctrl) return;
|
|
const allocs = this.getSplitAllocs(nodeId, allocType);
|
|
if (!allocs) return;
|
|
|
|
const track = ctrl.querySelector(".split-track") as SVGRectElement | null;
|
|
if (!track) return;
|
|
const trackX = parseFloat(track.getAttribute("x")!);
|
|
const trackW = parseFloat(track.getAttribute("width")!);
|
|
|
|
// Update segment rects
|
|
const segs = ctrl.querySelectorAll(".split-seg");
|
|
let segX = trackX;
|
|
segs.forEach((seg, i) => {
|
|
if (i >= allocs.length) return;
|
|
const segW = trackW * (allocs[i].percentage / 100);
|
|
(seg as SVGRectElement).setAttribute("x", String(segX));
|
|
(seg as SVGRectElement).setAttribute("width", String(Math.max(segW, 2)));
|
|
segX += segW;
|
|
});
|
|
|
|
// Update divider positions and labels
|
|
const dividers = ctrl.querySelectorAll(".split-divider");
|
|
let divX = trackX;
|
|
dividers.forEach((div, i) => {
|
|
if (i >= allocs.length - 1) return;
|
|
divX += trackW * (allocs[i].percentage / 100);
|
|
const rect = div.querySelector("rect");
|
|
const text = div.querySelector("text");
|
|
if (rect) rect.setAttribute("x", String(divX - 6));
|
|
if (text) {
|
|
text.setAttribute("x", String(divX));
|
|
text.textContent = `${Math.round(allocs[i].percentage)}% | ${Math.round(allocs[i + 1].percentage)}%`;
|
|
}
|
|
});
|
|
}
|
|
|
|
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">×</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) {
|
|
const clickedNode = this.nodes.find((n) => n.id === nodeId);
|
|
if (clickedNode?.type === "source") {
|
|
this.openSourcePurchaseModal(nodeId);
|
|
return;
|
|
}
|
|
if (this.inlineEditNodeId && this.inlineEditNodeId !== nodeId) {
|
|
this.exitInlineEdit();
|
|
}
|
|
this.inlineEditNodeId = nodeId;
|
|
this.inlineConfigTab = "config";
|
|
this.selectedNodeId = nodeId;
|
|
this.updateSelectionHighlight();
|
|
|
|
const node = this.nodes.find((n) => n.id === nodeId);
|
|
if (!node) return;
|
|
|
|
const nodeLayer = this.shadow.getElementById("node-layer");
|
|
const g = nodeLayer?.querySelector(`[data-node-id="${nodeId}"]`) as SVGGElement | null;
|
|
if (!g) return;
|
|
|
|
g.querySelector(".inline-edit-overlay")?.remove();
|
|
|
|
const s = this.getNodeSize(node);
|
|
const overlay = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
overlay.classList.add("inline-edit-overlay");
|
|
|
|
// Funnels: drag handles instead of config panel
|
|
if (node.type === "funnel") {
|
|
const d = node.data as FunnelNodeData;
|
|
const outflow = d.desiredOutflow || 0;
|
|
// Drain spout width for tapered vessel
|
|
const drainW = 60;
|
|
const drainInset = (s.w - drainW) / 2;
|
|
|
|
overlay.innerHTML = `
|
|
<rect class="valve-drag-handle" x="${drainInset - 8}" y="${s.h - 16}" width="${drainW + 16}" height="18" rx="5"
|
|
style="fill:var(--rflows-label-spending);cursor:ew-resize;opacity:0.85;stroke:white;stroke-width:1.5"/>
|
|
<text class="valve-drag-label" x="${s.w / 2}" y="${s.h - 4}" text-anchor="middle" fill="white" font-size="11" font-weight="600" pointer-events="none">
|
|
◁ ${this.formatDollar(outflow)}/mo ▷
|
|
</text>
|
|
<rect class="height-drag-handle" x="${s.w / 2 - 28}" y="${s.h + 22}" width="56" height="12" rx="5"
|
|
style="fill:var(--rs-border-strong);cursor:ns-resize;opacity:0.6;stroke:var(--rs-text-muted);stroke-width:1"/>
|
|
<text class="height-drag-label" x="${s.w / 2}" y="${s.h + 31}" text-anchor="middle" style="fill:var(--rs-text-muted)" font-size="9" font-weight="500" pointer-events="none">⇕ capacity</text>`;
|
|
|
|
g.appendChild(overlay);
|
|
this.attachFunnelDragListeners(overlay, node, s);
|
|
|
|
// Click-outside handler
|
|
const clickOutsideHandler = (e: Event) => {
|
|
const target = e.target as Element;
|
|
if (!target.closest(`[data-node-id="${node.id}"]`)) {
|
|
this.exitInlineEdit();
|
|
this.shadow.removeEventListener("pointerdown", clickOutsideHandler, true);
|
|
}
|
|
};
|
|
setTimeout(() => {
|
|
this.shadow.addEventListener("pointerdown", clickOutsideHandler, true);
|
|
}, 100);
|
|
return;
|
|
}
|
|
|
|
// Source/outcome: keep config panel — use DOM APIs for proper namespace handling
|
|
const panelW = 280;
|
|
const panelH = 260;
|
|
const panelX = s.w + 12;
|
|
const panelY = 0;
|
|
|
|
const fo = document.createElementNS("http://www.w3.org/2000/svg", "foreignObject");
|
|
fo.setAttribute("x", String(panelX));
|
|
fo.setAttribute("y", String(panelY));
|
|
fo.setAttribute("width", String(panelW));
|
|
fo.setAttribute("height", String(panelH));
|
|
const panelDiv = document.createElement("div");
|
|
panelDiv.className = "inline-config-panel";
|
|
panelDiv.style.height = `${panelH}px`;
|
|
panelDiv.innerHTML = `
|
|
<div class="icp-tabs">
|
|
<button class="icp-tab icp-tab--active" data-icp-tab="config">Config</button>
|
|
<button class="icp-tab" data-icp-tab="analytics">Analytics</button>
|
|
<button class="icp-tab" data-icp-tab="allocations">Alloc</button>
|
|
</div>
|
|
<div class="icp-body">${this.renderInlineConfigContent(node)}</div>
|
|
<div class="icp-toolbar">
|
|
<button class="iet-done" style="background:var(--rflows-btn-done);color:white">Done</button>
|
|
<button class="iet-delete" style="background:var(--rflows-btn-delete);color:white">Delete</button>
|
|
<button class="iet-panel" style="background:var(--rs-border-strong);color:var(--rs-text-primary)">...</button>
|
|
</div>`;
|
|
fo.appendChild(panelDiv);
|
|
overlay.appendChild(fo);
|
|
|
|
g.appendChild(overlay);
|
|
this.attachInlineConfigListeners(g, node);
|
|
}
|
|
|
|
private renderInlineConfigContent(node: FlowNode): string {
|
|
if (this.inlineConfigTab === "config") return this.renderInlineConfigTab(node);
|
|
if (this.inlineConfigTab === "analytics") return this.renderInlineAnalyticsTab(node);
|
|
return this.renderInlineAllocTab(node);
|
|
}
|
|
|
|
// ── Config tab renderers ──
|
|
|
|
private renderInlineConfigTab(node: FlowNode): string {
|
|
if (node.type === "source") return this.renderSourceConfigTab(node);
|
|
if (node.type === "funnel") return this.renderFunnelConfigTab(node);
|
|
return this.renderOutcomeConfigTab(node);
|
|
}
|
|
|
|
private renderSourceConfigTab(node: FlowNode): string {
|
|
const d = node.data as SourceNodeData;
|
|
let html = `
|
|
<div class="icp-field"><label class="icp-label">Label</label>
|
|
<input class="icp-input" data-icp-field="label" value="${this.esc(d.label)}"/></div>
|
|
<div class="icp-field"><label class="icp-label">Flow Rate ($/mo)</label>
|
|
<input class="icp-input" data-icp-field="flowRate" type="number" value="${d.flowRate}"/></div>
|
|
<div class="icp-field"><label class="icp-label">Pay by</label>
|
|
<div class="icp-pay-by">
|
|
<button class="icp-pay-btn ${d.sourceType === "card" ? "icp-pay-btn--active" : ""}" data-icp-source-type="card">💳 Card</button>
|
|
<button class="icp-pay-btn ${d.sourceType === "safe_wallet" ? "icp-pay-btn--active" : ""}" data-icp-source-type="safe_wallet">🔒 Wallet</button>
|
|
<button class="icp-pay-btn ${d.sourceType === "ridentity" ? "icp-pay-btn--active" : ""}" data-icp-source-type="ridentity">👤 EncryptID</button>
|
|
</div></div>`;
|
|
if (d.sourceType === "card") {
|
|
html += `<button class="icp-fund-btn" data-icp-action="fund">Fund with Card</button>`;
|
|
}
|
|
return html;
|
|
}
|
|
|
|
private renderFunnelConfigTab(node: FlowNode): string {
|
|
const d = node.data as FunnelNodeData;
|
|
const outflow = d.desiredOutflow || 0;
|
|
return `
|
|
<div class="icp-field"><label class="icp-label">Label</label>
|
|
<input class="icp-input" data-icp-field="label" value="${this.esc(d.label)}"/></div>
|
|
<div class="icp-range-group">
|
|
<span class="icp-range-label">$/mo</span>
|
|
<input class="icp-range" type="range" min="0" max="5000" step="50" value="${outflow}" data-icp-outflow="desiredOutflow"/>
|
|
<span class="icp-range-value">${this.formatDollar(outflow)}</span>
|
|
</div>
|
|
<div class="icp-threshold-bar-wrap">
|
|
<div class="icp-threshold-bar">
|
|
<div class="icp-threshold-seg icp-threshold-seg--red" style="flex:1"></div>
|
|
<div class="icp-threshold-seg icp-threshold-seg--yellow" style="flex:3"></div>
|
|
<div class="icp-threshold-seg icp-threshold-seg--green" style="flex:2"></div>
|
|
</div>
|
|
<div class="icp-threshold-labels" data-icp-thresholds>
|
|
<span>${this.formatDollar(d.minThreshold)}</span>
|
|
<span>${this.formatDollar(d.sufficientThreshold ?? d.maxThreshold)}</span>
|
|
<span>${this.formatDollar(d.maxThreshold)}</span>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
private renderOutcomeConfigTab(node: FlowNode): string {
|
|
const d = node.data as OutcomeNodeData;
|
|
return `
|
|
<div class="icp-field"><label class="icp-label">Label</label>
|
|
<input class="icp-input" data-icp-field="label" value="${this.esc(d.label)}"/></div>
|
|
<div class="icp-field"><label class="icp-label">Description</label>
|
|
<input class="icp-input" data-icp-field="description" value="${this.esc(d.description || "")}"/></div>
|
|
<div class="icp-field"><label class="icp-label">Funding Target ($)</label>
|
|
<input class="icp-input" data-icp-field="fundingTarget" type="number" value="${d.fundingTarget}"/></div>
|
|
<div class="icp-field"><label class="icp-label">Status</label>
|
|
<select class="icp-select" data-icp-field="status">
|
|
${["not-started", "in-progress", "completed", "blocked"].map((s) => `<option value="${s}" ${d.status === s ? "selected" : ""}>${s}</option>`).join("")}
|
|
</select></div>`;
|
|
}
|
|
|
|
// ── Analytics tab ──
|
|
|
|
private renderInlineAnalyticsTab(node: FlowNode): string {
|
|
const stats = this.nodeAnalytics.get(node.id);
|
|
|
|
if (node.type === "funnel") {
|
|
const d = node.data as FunnelNodeData;
|
|
const suf = computeSufficiencyState(d);
|
|
const threshold = d.sufficientThreshold ?? d.maxThreshold;
|
|
const fillPct = Math.min(100, Math.round((d.currentValue / (threshold || 1)) * 100));
|
|
const fillColor = suf === "seeking" ? "#3b82f6" : suf === "sufficient" ? "#10b981" : "#f59e0b";
|
|
|
|
const totalOut = (stats?.totalOutflow || 0) + (stats?.totalOverflow || 0);
|
|
const outflowPct = totalOut > 0 ? Math.round(((stats?.totalOutflow || 0) / totalOut) * 100) : 50;
|
|
const overflowPct = 100 - outflowPct;
|
|
|
|
return `
|
|
<div class="icp-analytics-row">
|
|
<div class="icp-analytics-label"><span>Fill Level</span><span>${fillPct}%</span></div>
|
|
<div class="icp-analytics-bar"><div class="icp-analytics-fill" style="width:${fillPct}%;background:${fillColor}"></div></div>
|
|
</div>
|
|
<div style="margin-bottom:6px">
|
|
<span class="icp-suf-badge icp-suf-badge--${suf}">${suf}</span>
|
|
</div>
|
|
${totalOut > 0 ? `
|
|
<div class="icp-proportion">
|
|
<div class="icp-proportion-ring" style="background:conic-gradient(#10b981 0% ${outflowPct}%, #f59e0b ${outflowPct}% 100%)"></div>
|
|
<div class="icp-proportion-legend">
|
|
<div class="icp-proportion-item"><span class="icp-proportion-dot" style="background:#10b981"></span>Outflow ${outflowPct}%</div>
|
|
<div class="icp-proportion-item"><span class="icp-proportion-dot" style="background:#f59e0b"></span>Overflow ${overflowPct}%</div>
|
|
</div>
|
|
</div>` : ""}
|
|
<div class="icp-stat-row"><span>Current Value</span><span class="icp-stat-value">${this.formatDollar(d.currentValue)}</span></div>
|
|
<div class="icp-stat-row"><span>Peak Value</span><span class="icp-stat-value">${this.formatDollar(stats?.peakValue || d.currentValue)}</span></div>
|
|
<div class="icp-stat-row"><span>Avg Fill</span><span class="icp-stat-value">${this.formatDollar(stats?.avgFillLevel || d.currentValue)}</span></div>
|
|
<div class="icp-stat-row"><span>Total Inflow</span><span class="icp-stat-value">${this.formatDollar(stats?.totalInflow || 0)}</span></div>`;
|
|
}
|
|
|
|
if (node.type === "outcome") {
|
|
const d = node.data as OutcomeNodeData;
|
|
const progressPct = Math.min(100, Math.round((d.fundingReceived / (d.fundingTarget || 1)) * 100));
|
|
const phasesTotal = d.phases?.length || 0;
|
|
const phasesAchieved = d.phases?.filter((p) => d.fundingReceived >= p.fundingThreshold).length || 0;
|
|
|
|
return `
|
|
<div class="icp-analytics-row">
|
|
<div class="icp-analytics-label"><span>Funding Progress</span><span>${progressPct}%</span></div>
|
|
<div class="icp-analytics-bar"><div class="icp-analytics-fill" style="width:${progressPct}%;background:#10b981"></div></div>
|
|
</div>
|
|
<div class="icp-stat-row"><span>Received</span><span class="icp-stat-value">${this.formatDollar(d.fundingReceived)}</span></div>
|
|
<div class="icp-stat-row"><span>Target</span><span class="icp-stat-value">${this.formatDollar(d.fundingTarget)}</span></div>
|
|
${phasesTotal > 0 ? `<div class="icp-stat-row"><span>Phases</span><span class="icp-stat-value">${phasesAchieved} / ${phasesTotal}</span></div>` : ""}
|
|
<div class="icp-stat-row"><span>Total Inflow</span><span class="icp-stat-value">${this.formatDollar(stats?.totalInflow || 0)}</span></div>`;
|
|
}
|
|
|
|
// Source
|
|
const d = node.data as SourceNodeData;
|
|
return `
|
|
<div class="icp-stat-row"><span>Flow Rate</span><span class="icp-stat-value">${this.formatDollar(d.flowRate)}/mo</span></div>
|
|
<div class="icp-stat-row"><span>Total Dispensed</span><span class="icp-stat-value">${this.formatDollar(stats?.totalOutflow || 0)}</span></div>
|
|
${d.targetAllocations.length > 0 ? `<div style="margin-top:8px;font-size:10px;color:var(--rs-text-muted);text-transform:uppercase;font-weight:600;margin-bottom:4px">Allocation Breakdown</div>
|
|
${d.targetAllocations.map((a) => `<div class="icp-alloc-row">
|
|
<span class="icp-alloc-dot" style="background:${a.color}"></span>
|
|
<span style="flex:1">${this.esc(this.getNodeLabel(a.targetId))}</span>
|
|
<span style="font-weight:600">${a.percentage}%</span>
|
|
</div>`).join("")}` : ""}`;
|
|
}
|
|
|
|
// ── Allocations tab ──
|
|
|
|
private renderInlineAllocTab(node: FlowNode): string {
|
|
const renderRows = (title: string, allocs: { targetId: string; percentage: number; color: string }[]) => {
|
|
if (!allocs || allocs.length === 0) return "";
|
|
let html = `<div style="font-size:10px;color:var(--rs-text-muted);text-transform:uppercase;font-weight:600;margin-bottom:4px">${title}</div>`;
|
|
for (const a of allocs) {
|
|
html += `<div class="icp-alloc-row">
|
|
<span class="icp-alloc-dot" style="background:${a.color}"></span>
|
|
<span style="flex:1">${this.esc(this.getNodeLabel(a.targetId))}</span>
|
|
<span style="font-weight:600">${a.percentage}%</span>
|
|
</div>`;
|
|
}
|
|
return html;
|
|
};
|
|
|
|
if (node.type === "source") {
|
|
const d = node.data as SourceNodeData;
|
|
const html = renderRows("Target Allocations", d.targetAllocations);
|
|
return html || '<div class="icp-empty">No allocations configured</div>';
|
|
}
|
|
if (node.type === "funnel") {
|
|
const d = node.data as FunnelNodeData;
|
|
let html = renderRows("Spending Allocations", d.spendingAllocations);
|
|
html += renderRows("Overflow Allocations", d.overflowAllocations);
|
|
return html || '<div class="icp-empty">No allocations configured</div>';
|
|
}
|
|
const od = node.data as OutcomeNodeData;
|
|
const html = renderRows("Overflow Allocations", od.overflowAllocations || []);
|
|
return html || '<div class="icp-empty">No allocations configured</div>';
|
|
}
|
|
|
|
// ── Funnel drag handles (valve width + tank height) ──
|
|
|
|
private attachFunnelDragListeners(overlay: Element, node: FlowNode, s: { w: number; h: number }) {
|
|
const valveHandle = overlay.querySelector(".valve-drag-handle");
|
|
const heightHandle = overlay.querySelector(".height-drag-handle");
|
|
|
|
// Valve drag (horizontal → desiredOutflow)
|
|
if (valveHandle) {
|
|
valveHandle.addEventListener("pointerdown", (e: Event) => {
|
|
const pe = e as PointerEvent;
|
|
pe.stopPropagation();
|
|
pe.preventDefault();
|
|
const startX = pe.clientX;
|
|
const fd = node.data as FunnelNodeData;
|
|
const startOutflow = fd.desiredOutflow || 0;
|
|
(valveHandle as Element).setPointerCapture(pe.pointerId);
|
|
|
|
const onMove = (ev: Event) => {
|
|
const me = ev as PointerEvent;
|
|
const deltaX = (me.clientX - startX) / this.canvasZoom;
|
|
let newOutflow = Math.round((startOutflow + (deltaX / s.w) * 10000) / 50) * 50;
|
|
newOutflow = Math.max(0, Math.min(10000, newOutflow));
|
|
fd.desiredOutflow = newOutflow;
|
|
fd.minThreshold = newOutflow;
|
|
fd.maxThreshold = newOutflow * 6;
|
|
if (fd.maxCapacity < fd.maxThreshold * 1.5) {
|
|
fd.maxCapacity = Math.round(fd.maxThreshold * 1.5);
|
|
}
|
|
// Update label text only during drag
|
|
const label = overlay.querySelector(".valve-drag-label");
|
|
if (label) label.textContent = `◁ ${this.formatDollar(newOutflow)}/mo ▷`;
|
|
};
|
|
|
|
const onUp = () => {
|
|
valveHandle.removeEventListener("pointermove", onMove);
|
|
valveHandle.removeEventListener("pointerup", onUp);
|
|
valveHandle.removeEventListener("lostpointercapture", onUp);
|
|
// Full redraw with new shape
|
|
this.drawCanvasContent();
|
|
this.redrawEdges();
|
|
this.enterInlineEdit(node.id);
|
|
this.scheduleSave();
|
|
};
|
|
|
|
valveHandle.addEventListener("pointermove", onMove);
|
|
valveHandle.addEventListener("pointerup", onUp);
|
|
valveHandle.addEventListener("lostpointercapture", onUp);
|
|
});
|
|
}
|
|
|
|
// Height drag (vertical → maxCapacity)
|
|
if (heightHandle) {
|
|
heightHandle.addEventListener("pointerdown", (e: Event) => {
|
|
const pe = e as PointerEvent;
|
|
pe.stopPropagation();
|
|
pe.preventDefault();
|
|
const startY = pe.clientY;
|
|
const fd = node.data as FunnelNodeData;
|
|
const startCapacity = fd.maxCapacity || 9000;
|
|
(heightHandle as Element).setPointerCapture(pe.pointerId);
|
|
|
|
const onMove = (ev: Event) => {
|
|
const me = ev as PointerEvent;
|
|
const deltaY = (me.clientY - startY) / this.canvasZoom;
|
|
// Down = more capacity, up = less
|
|
let newCapacity = Math.round((startCapacity + deltaY * 80) / 500) * 500;
|
|
newCapacity = Math.max(Math.round(fd.maxThreshold * 1.2), Math.min(100000, newCapacity));
|
|
fd.maxCapacity = newCapacity;
|
|
// Update label
|
|
const label = overlay.querySelector(".height-drag-label");
|
|
if (label) label.textContent = `⇕ ${this.formatDollar(newCapacity)}`;
|
|
};
|
|
|
|
const onUp = () => {
|
|
heightHandle.removeEventListener("pointermove", onMove);
|
|
heightHandle.removeEventListener("pointerup", onUp);
|
|
heightHandle.removeEventListener("lostpointercapture", onUp);
|
|
this.drawCanvasContent();
|
|
this.redrawEdges();
|
|
this.enterInlineEdit(node.id);
|
|
this.scheduleSave();
|
|
};
|
|
|
|
heightHandle.addEventListener("pointermove", onMove);
|
|
heightHandle.addEventListener("pointerup", onUp);
|
|
heightHandle.addEventListener("lostpointercapture", onUp);
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── Funnel threshold markers (SVG on node body) ──
|
|
|
|
private renderFunnelThresholdMarkers(overlay: SVGGElement, node: FlowNode, s: { w: number; h: number }) {
|
|
const d = node.data as FunnelNodeData;
|
|
const zoneTop = 36;
|
|
const zoneBot = s.h - 6;
|
|
const zoneH = zoneBot - zoneTop;
|
|
const drainW = 60;
|
|
const taperAtBottom = (s.w - drainW) / 2;
|
|
|
|
const thresholds: Array<{ key: string; value: number; color: string; label: string }> = [
|
|
{ key: "minThreshold", value: d.minThreshold, color: "var(--rflows-status-critical)", label: "Min" },
|
|
{ key: "maxThreshold", value: d.maxThreshold, color: "var(--rflows-status-overflow)", label: "Max" },
|
|
];
|
|
|
|
for (const t of thresholds) {
|
|
const frac = t.value / (d.maxCapacity || 1);
|
|
const markerY = zoneTop + zoneH * (1 - frac);
|
|
const yFrac = (markerY - zoneTop) / zoneH;
|
|
const inset = this.vesselWallInset(yFrac, taperAtBottom);
|
|
overlay.innerHTML += `
|
|
<line class="threshold-marker" x1="${inset + 4}" x2="${s.w - inset - 4}" y1="${markerY}" y2="${markerY}" style="stroke:${t.color}" stroke-width="2" stroke-dasharray="4 2"/>
|
|
<rect class="threshold-handle" x="${s.w - inset - 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 - inset - 30}" y="${markerY + 4}" fill="white" font-size="9" text-anchor="middle" pointer-events="none">${t.label} ${this.formatDollar(t.value)}</text>`;
|
|
}
|
|
}
|
|
|
|
// ── Inline config listeners ──
|
|
|
|
private attachInlineConfigListeners(g: SVGGElement, node: FlowNode) {
|
|
const overlay = g.querySelector(".inline-edit-overlay");
|
|
if (!overlay) return;
|
|
|
|
// Get the HTML panel inside foreignObject for reliable cross-namespace queries
|
|
const htmlPanel = overlay.querySelector("foreignObject")?.querySelector(".inline-config-panel") as HTMLElement | null;
|
|
const queryRoot = htmlPanel || overlay;
|
|
|
|
// Tab switching
|
|
queryRoot.querySelectorAll(".icp-tab").forEach((el) => {
|
|
el.addEventListener("click", (e: Event) => {
|
|
e.stopPropagation();
|
|
const tab = (el as HTMLElement).dataset.icpTab as "config" | "analytics" | "allocations";
|
|
if (!tab || tab === this.inlineConfigTab) return;
|
|
this.inlineConfigTab = tab;
|
|
queryRoot.querySelectorAll(".icp-tab").forEach((t) => t.classList.remove("icp-tab--active"));
|
|
el.classList.add("icp-tab--active");
|
|
const body = queryRoot.querySelector(".icp-body") as HTMLElement;
|
|
if (body) body.innerHTML = this.renderInlineConfigContent(node);
|
|
this.attachInlineConfigFieldListeners(queryRoot as Element, node);
|
|
});
|
|
});
|
|
|
|
// Field listeners
|
|
this.attachInlineConfigFieldListeners(queryRoot, node);
|
|
|
|
// Threshold drag handles (funnel — on SVG overlay, not HTML panel)
|
|
this.attachThresholdDragListeners(overlay, node);
|
|
|
|
// Done button
|
|
queryRoot.querySelector(".iet-done")?.addEventListener("click", (e: Event) => {
|
|
e.stopPropagation();
|
|
this.exitInlineEdit();
|
|
});
|
|
|
|
// Delete button
|
|
queryRoot.querySelector(".iet-delete")?.addEventListener("click", (e: Event) => {
|
|
e.stopPropagation();
|
|
this.deleteNode(node.id);
|
|
this.exitInlineEdit();
|
|
});
|
|
|
|
// "..." panel button
|
|
queryRoot.querySelector(".iet-panel")?.addEventListener("click", (e: Event) => {
|
|
e.stopPropagation();
|
|
this.exitInlineEdit();
|
|
this.openEditor(node.id);
|
|
});
|
|
|
|
// Fund Now button (source card type)
|
|
const fundBtn = queryRoot.querySelector("[data-icp-action='fund']");
|
|
fundBtn?.addEventListener("click", (e: Event) => {
|
|
e.stopPropagation();
|
|
const sd = node.data as SourceNodeData;
|
|
const flowId = this.flowId || this.getAttribute("flow-id") || "";
|
|
if (sd.walletAddress) {
|
|
this.openOnRampWidget(flowId, sd.walletAddress).catch((err) => console.error("[OnRamp] Error:", err));
|
|
} else {
|
|
this.openUserOnRamp(node.id).catch((err) => console.error("[UserOnRamp] Error:", err));
|
|
}
|
|
});
|
|
|
|
// Click-outside handler — listen on shadow root to avoid retargeting issues
|
|
const clickOutsideHandler = (e: Event) => {
|
|
const target = e.target as Element;
|
|
if (!target.closest(`[data-node-id="${node.id}"]`) && !target.closest(".inline-config-panel")) {
|
|
this.exitInlineEdit();
|
|
this.shadow.removeEventListener("pointerdown", clickOutsideHandler, true);
|
|
}
|
|
};
|
|
setTimeout(() => {
|
|
this.shadow.addEventListener("pointerdown", clickOutsideHandler, true);
|
|
}, 100);
|
|
}
|
|
|
|
private attachInlineConfigFieldListeners(overlay: Element, node: FlowNode) {
|
|
// Text/number/select input fields
|
|
overlay.querySelectorAll("[data-icp-field]").forEach((el) => {
|
|
const input = el as HTMLInputElement | HTMLSelectElement;
|
|
const field = input.dataset.icpField!;
|
|
const handler = () => {
|
|
const numFields = ["flowRate", "currentValue", "minThreshold", "maxThreshold", "maxCapacity", "inflowRate", "sufficientThreshold", "fundingReceived", "fundingTarget"];
|
|
const val = numFields.includes(field) ? parseFloat((input as HTMLInputElement).value) || 0 : input.value;
|
|
(node.data as any)[field] = val;
|
|
this.redrawNodeOnly(node);
|
|
this.redrawEdges();
|
|
};
|
|
input.addEventListener("input", handler);
|
|
input.addEventListener("change", handler);
|
|
input.addEventListener("keydown", (e: Event) => {
|
|
const ke = e as KeyboardEvent;
|
|
if (ke.key === "Enter") this.exitInlineEdit();
|
|
if (ke.key === "Escape") this.exitInlineEdit();
|
|
ke.stopPropagation();
|
|
});
|
|
});
|
|
|
|
// Pay-by buttons (source nodes)
|
|
overlay.querySelectorAll("[data-icp-source-type]").forEach((btn) => {
|
|
btn.addEventListener("click", (e: Event) => {
|
|
e.stopPropagation();
|
|
(node.data as SourceNodeData).sourceType = (btn as HTMLElement).dataset.icpSourceType as any;
|
|
const body = overlay.querySelector(".icp-body") as HTMLElement;
|
|
if (body) body.innerHTML = this.renderInlineConfigContent(node);
|
|
this.attachInlineConfigFieldListeners(overlay, node);
|
|
this.redrawNodeOnly(node);
|
|
this.redrawEdges();
|
|
});
|
|
});
|
|
|
|
// Range sliders
|
|
overlay.querySelectorAll("[data-icp-range]").forEach((el) => {
|
|
const input = el as HTMLInputElement;
|
|
const field = input.dataset.icpRange!;
|
|
input.addEventListener("input", () => {
|
|
const val = parseFloat(input.value) || 0;
|
|
(node.data as any)[field] = Math.round(val);
|
|
const valueSpan = input.parentElement?.querySelector(".icp-range-value") as HTMLElement;
|
|
if (valueSpan) valueSpan.textContent = this.formatDollar(val);
|
|
this.redrawNodeOnly(node);
|
|
this.redrawEdges();
|
|
this.redrawThresholdMarkers(node);
|
|
});
|
|
});
|
|
|
|
// Outflow slider — auto-derives all thresholds
|
|
overlay.querySelectorAll("[data-icp-outflow]").forEach((el) => {
|
|
const input = el as HTMLInputElement;
|
|
input.addEventListener("input", () => {
|
|
const val = parseFloat(input.value) || 0;
|
|
const fd = node.data as FunnelNodeData;
|
|
fd.desiredOutflow = val;
|
|
const derived = deriveThresholds(val);
|
|
fd.minThreshold = derived.minThreshold;
|
|
fd.sufficientThreshold = derived.sufficientThreshold;
|
|
fd.maxThreshold = derived.maxThreshold;
|
|
fd.maxCapacity = derived.maxCapacity;
|
|
const valueSpan = input.parentElement?.querySelector(".icp-range-value") as HTMLElement;
|
|
if (valueSpan) valueSpan.textContent = this.formatDollar(val);
|
|
// Update threshold bar labels
|
|
const thresholds = overlay.querySelector("[data-icp-thresholds]");
|
|
if (thresholds) {
|
|
const spans = thresholds.querySelectorAll("span");
|
|
if (spans[0]) spans[0].textContent = this.formatDollar(derived.minThreshold);
|
|
if (spans[1]) spans[1].textContent = this.formatDollar(derived.sufficientThreshold);
|
|
if (spans[2]) spans[2].textContent = this.formatDollar(derived.maxThreshold);
|
|
}
|
|
this.redrawNodeOnly(node);
|
|
this.redrawEdges();
|
|
this.redrawThresholdMarkers(node);
|
|
this.scheduleSave();
|
|
});
|
|
});
|
|
}
|
|
|
|
private attachThresholdDragListeners(overlay: Element, node: FlowNode) {
|
|
overlay.querySelectorAll(".threshold-handle").forEach((el) => {
|
|
el.addEventListener("pointerdown", (e: Event) => {
|
|
const pe = e as PointerEvent;
|
|
pe.stopPropagation();
|
|
pe.preventDefault();
|
|
const thresholdKey = (el as SVGElement).dataset.threshold!;
|
|
this.inlineEditDragThreshold = thresholdKey;
|
|
this.inlineEditDragStartY = pe.clientY;
|
|
this.inlineEditDragStartValue = (node.data as any)[thresholdKey] || 0;
|
|
(el as Element).setPointerCapture(pe.pointerId);
|
|
});
|
|
el.addEventListener("pointermove", (e: Event) => {
|
|
if (!this.inlineEditDragThreshold) return;
|
|
const pe = e as PointerEvent;
|
|
const d = node.data as FunnelNodeData;
|
|
const s = this.getNodeSize(node);
|
|
const zoneH = s.h - 6 - 36;
|
|
const deltaY = (pe.clientY - this.inlineEditDragStartY) / this.canvasZoom;
|
|
const deltaDollars = -(deltaY / zoneH) * (d.maxCapacity || 1);
|
|
let newVal = this.inlineEditDragStartValue + deltaDollars;
|
|
newVal = Math.max(0, Math.min(d.maxCapacity, newVal));
|
|
const key = this.inlineEditDragThreshold;
|
|
if (key === "minThreshold") newVal = Math.min(newVal, d.maxThreshold);
|
|
if (key === "maxThreshold") newVal = Math.max(newVal, d.minThreshold);
|
|
if (key === "sufficientThreshold") newVal = Math.max(d.minThreshold, Math.min(d.maxThreshold, newVal));
|
|
(node.data as any)[key] = Math.round(newVal);
|
|
this.redrawNodeInlineEdit(node);
|
|
});
|
|
el.addEventListener("pointerup", () => {
|
|
this.inlineEditDragThreshold = null;
|
|
});
|
|
});
|
|
}
|
|
|
|
private redrawThresholdMarkers(node: FlowNode) {
|
|
if (node.type !== "funnel") return;
|
|
const nodeLayer = this.shadow.getElementById("node-layer");
|
|
const g = nodeLayer?.querySelector(`[data-node-id="${node.id}"]`) as SVGGElement | null;
|
|
if (!g) return;
|
|
const overlay = g.querySelector(".inline-edit-overlay");
|
|
if (!overlay) return;
|
|
overlay.querySelectorAll(".threshold-marker, .threshold-handle").forEach((el) => el.remove());
|
|
overlay.querySelectorAll("text").forEach((t) => {
|
|
if (t.getAttribute("pointer-events") === "none" && t.getAttribute("font-size") === "9") t.remove();
|
|
});
|
|
const s = this.getNodeSize(node);
|
|
const tempG = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
this.renderFunnelThresholdMarkers(tempG, node, s);
|
|
const fo = overlay.querySelector("foreignObject");
|
|
while (tempG.firstChild) {
|
|
if (fo) overlay.insertBefore(tempG.firstChild, fo);
|
|
else overlay.appendChild(tempG.firstChild);
|
|
}
|
|
this.attachThresholdDragListeners(overlay, node);
|
|
}
|
|
|
|
private redrawNodeOnly(node: FlowNode) {
|
|
const nodeLayer = this.shadow.getElementById("node-layer");
|
|
if (!nodeLayer) return;
|
|
const g = nodeLayer.querySelector(`[data-node-id="${node.id}"]`) as SVGGElement | null;
|
|
if (!g) return;
|
|
const satisfaction = this.computeInflowSatisfaction();
|
|
const newSvg = this.renderNodeSvg(node, satisfaction);
|
|
// Detach overlay so it survives the content replacement
|
|
const overlay = g.querySelector(".inline-edit-overlay");
|
|
if (overlay) overlay.remove();
|
|
// Parse new SVG to extract attributes and inner content
|
|
const temp = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
temp.innerHTML = newSvg;
|
|
const newG = temp.firstElementChild as SVGGElement;
|
|
if (newG) {
|
|
for (const attr of Array.from(newG.attributes)) {
|
|
g.setAttribute(attr.name, attr.value);
|
|
}
|
|
// Use innerHTML for atomic replacement — avoids blur-event reentrancy
|
|
// that causes "node to be removed is no longer a child" errors
|
|
g.innerHTML = newG.innerHTML;
|
|
}
|
|
// Reattach overlay
|
|
if (overlay) g.appendChild(overlay);
|
|
this.scheduleSave();
|
|
}
|
|
|
|
private redrawNodeInlineEdit(node: FlowNode) {
|
|
if (node.type === "source") { this.redrawNodeOnly(node); return; }
|
|
this.drawCanvasContent();
|
|
// Re-enter inline edit to show appropriate handles/panel
|
|
this.enterInlineEdit(node.id);
|
|
}
|
|
|
|
private exitInlineEdit() {
|
|
if (!this.inlineEditNodeId) return;
|
|
const nodeLayer = this.shadow.getElementById("node-layer");
|
|
const g = nodeLayer?.querySelector(`[data-node-id="${this.inlineEditNodeId}"]`) as SVGGElement | null;
|
|
if (g) g.querySelector(".inline-edit-overlay")?.remove();
|
|
this.inlineEditNodeId = null;
|
|
this.inlineEditDragThreshold = null;
|
|
this.drawCanvasContent();
|
|
}
|
|
|
|
// ── Analytics accumulation ──
|
|
|
|
private accumulateNodeAnalytics() {
|
|
for (const node of this.nodes) {
|
|
let stats = this.nodeAnalytics.get(node.id);
|
|
if (!stats) {
|
|
stats = { totalInflow: 0, totalOutflow: 0, totalOverflow: 0, avgFillLevel: 0, peakValue: 0, outcomesAchieved: 0, tickCount: 0, fillLevelSum: 0 };
|
|
this.nodeAnalytics.set(node.id, stats);
|
|
}
|
|
stats.tickCount++;
|
|
|
|
if (node.type === "funnel") {
|
|
const d = node.data as FunnelNodeData;
|
|
stats.totalInflow += d.inflowRate;
|
|
const threshold = d.sufficientThreshold ?? d.maxThreshold;
|
|
if (d.currentValue >= d.maxCapacity) {
|
|
stats.totalOverflow += d.inflowRate * 0.5;
|
|
stats.totalOutflow += d.inflowRate * 0.5;
|
|
} else if (d.currentValue >= threshold) {
|
|
stats.totalOutflow += d.inflowRate * 0.3;
|
|
}
|
|
stats.fillLevelSum += d.currentValue;
|
|
stats.avgFillLevel = stats.fillLevelSum / stats.tickCount;
|
|
stats.peakValue = Math.max(stats.peakValue, d.currentValue);
|
|
} else if (node.type === "outcome") {
|
|
const d = node.data as OutcomeNodeData;
|
|
stats.peakValue = Math.max(stats.peakValue, d.fundingReceived);
|
|
stats.outcomesAchieved = d.phases?.filter((p) => d.fundingReceived >= p.fundingThreshold).length || 0;
|
|
} else if (node.type === "source") {
|
|
const d = node.data as SourceNodeData;
|
|
stats.totalOutflow += d.flowRate / 10;
|
|
}
|
|
}
|
|
}
|
|
|
|
private updateInlineConfigAnalytics() {
|
|
if (!this.inlineEditNodeId || this.inlineConfigTab !== "analytics") return;
|
|
const node = this.nodes.find((n) => n.id === this.inlineEditNodeId);
|
|
if (!node) return;
|
|
const nodeLayer = this.shadow.getElementById("node-layer");
|
|
const g = nodeLayer?.querySelector(`[data-node-id="${node.id}"]`) as SVGGElement | null;
|
|
if (!g) return;
|
|
const body = g.querySelector(".icp-body") as HTMLElement | null;
|
|
if (body) body.innerHTML = this.renderInlineAnalyticsTab(node);
|
|
}
|
|
|
|
private refreshEditorIfOpen(nodeId: string) {
|
|
if (this.editingNodeId === nodeId) this.openEditor(nodeId);
|
|
}
|
|
|
|
private renderSourceEditor(n: FlowNode): string {
|
|
const d = n.data as SourceNodeData;
|
|
let html = `
|
|
<div class="editor-field"><label class="editor-label">Label</label>
|
|
<input class="editor-input" data-field="label" value="${this.esc(d.label)}"/></div>
|
|
<div class="editor-field"><label class="editor-label">Flow Rate ($/mo)</label>
|
|
<input class="editor-input" data-field="flowRate" type="number" value="${d.flowRate}"/></div>
|
|
<div class="editor-field"><label class="editor-label">Pay by</label>
|
|
<div class="editor-pay-by">
|
|
<button class="editor-pay-btn ${d.sourceType === "card" ? "editor-pay-btn--active" : ""}" data-editor-source-type="card">💳<span>Card</span></button>
|
|
<button class="editor-pay-btn ${d.sourceType === "safe_wallet" ? "editor-pay-btn--active" : ""}" data-editor-source-type="safe_wallet">🔒<span>Wallet</span></button>
|
|
<button class="editor-pay-btn ${d.sourceType === "ridentity" ? "editor-pay-btn--active" : ""}" data-editor-source-type="ridentity">👤<span>EncryptID</span></button>
|
|
</div></div>`;
|
|
if (d.sourceType === "card") {
|
|
html += `<div class="editor-field" style="margin-top:12px">
|
|
<button class="editor-btn fund-card-btn" data-action="fund-with-card"
|
|
style="width:100%;padding:10px;background:var(--rs-primary);color:white;border:none;border-radius:8px;cursor:pointer;font-weight:600">
|
|
Fund with Card
|
|
</button>
|
|
</div>`;
|
|
}
|
|
html += this.renderAllocEditor("Target Allocations", d.targetAllocations);
|
|
return html;
|
|
}
|
|
|
|
private renderFunnelEditor(n: FlowNode): string {
|
|
const d = n.data as FunnelNodeData;
|
|
const derived = d.desiredOutflow ? deriveThresholds(d.desiredOutflow) : null;
|
|
return `
|
|
<div class="editor-field"><label class="editor-label">Label</label>
|
|
<input class="editor-input" data-field="label" value="${this.esc(d.label)}"/></div>
|
|
<div class="editor-field"><label class="editor-label">Desired Outflow ($/mo)</label>
|
|
<input class="editor-input" data-field="desiredOutflow" type="number" value="${d.desiredOutflow ?? 0}"/></div>
|
|
<div class="editor-field"><label class="editor-label">Current Value ($)</label>
|
|
<input class="editor-input" data-field="currentValue" type="number" value="${d.currentValue}"/></div>
|
|
<div class="editor-field"><label class="editor-label">Expected Inflow ($/mo)</label>
|
|
<input class="editor-input" data-field="inflowRate" type="number" value="${d.inflowRate}"/></div>
|
|
<div class="editor-section">
|
|
<div class="editor-section-title">Thresholds ${derived ? "(auto-derived from outflow)" : ""}</div>
|
|
<div class="editor-field"><label class="editor-label">Min (1mo)${derived ? ` — ${this.formatDollar(derived.minThreshold)}` : ""}</label>
|
|
<input class="editor-input" data-field="minThreshold" type="number" value="${d.minThreshold}"/></div>
|
|
<div class="editor-field"><label class="editor-label">Sufficient (4mo)${derived ? ` — ${this.formatDollar(derived.sufficientThreshold)}` : ""}</label>
|
|
<input class="editor-input" data-field="sufficientThreshold" type="number" value="${d.sufficientThreshold ?? d.maxThreshold}"/></div>
|
|
<div class="editor-field"><label class="editor-label">Overflow (6mo)${derived ? ` — ${this.formatDollar(derived.maxThreshold)}` : ""}</label>
|
|
<input class="editor-input" data-field="maxThreshold" type="number" value="${d.maxThreshold}"/></div>
|
|
<div class="editor-field"><label class="editor-label">Max Capacity (9mo)${derived ? ` — ${this.formatDollar(derived.maxCapacity)}` : ""}</label>
|
|
<input class="editor-input" data-field="maxCapacity" type="number" value="${d.maxCapacity}"/></div>
|
|
</div>
|
|
${this.renderAllocEditor("Overflow Allocations", d.overflowAllocations)}
|
|
${this.renderAllocEditor("Spending Allocations", d.spendingAllocations)}`;
|
|
}
|
|
|
|
private renderOutcomeEditor(n: FlowNode): string {
|
|
const d = n.data as OutcomeNodeData;
|
|
let html = `
|
|
<div class="editor-field"><label class="editor-label">Label</label>
|
|
<input class="editor-input" data-field="label" value="${this.esc(d.label)}"/></div>
|
|
<div class="editor-field"><label class="editor-label">Description</label>
|
|
<input class="editor-input" data-field="description" value="${this.esc(d.description || "")}"/></div>
|
|
<div class="editor-field"><label class="editor-label">Funding Received ($)</label>
|
|
<input class="editor-input" data-field="fundingReceived" type="number" value="${d.fundingReceived}"/></div>
|
|
<div class="editor-field"><label class="editor-label">Funding Target ($)</label>
|
|
<input class="editor-input" data-field="fundingTarget" type="number" value="${d.fundingTarget}"/></div>
|
|
<div class="editor-field"><label class="editor-label">Status</label>
|
|
<select class="editor-select" data-field="status">
|
|
${["not-started", "in-progress", "completed", "blocked"].map((s) => `<option value="${s}" ${d.status === s ? "selected" : ""}>${s}</option>`).join("")}
|
|
</select></div>`;
|
|
if (d.phases && d.phases.length > 0) {
|
|
html += `<div class="editor-section"><div class="editor-section-title">Phases</div>`;
|
|
for (const p of d.phases) {
|
|
const unlocked = d.fundingReceived >= p.fundingThreshold;
|
|
html += `<div style="margin-bottom:6px;padding:6px;background:var(--rs-bg-surface-sunken);border-radius:6px;border-left:3px solid ${unlocked ? "var(--rflows-phase-unlocked)" : "var(--rs-border-strong)"}">
|
|
<div style="font-size:12px;font-weight:600;color:${unlocked ? "var(--rs-success)" : "var(--rs-text-muted)"}">${this.esc(p.name)} — $${p.fundingThreshold.toLocaleString()}</div>
|
|
${p.tasks.map((t) => `<div style="font-size:11px;color:var(--rs-text-secondary);margin-top:2px">${t.completed ? "✅" : "⬜"} ${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 openOnRampWidget(flowId: string, walletAddress: string) {
|
|
console.log("[OnRamp] Opening widget for flow:", flowId, "wallet:", walletAddress);
|
|
// Re-use the user-onramp endpoint — it handles both providers server-side
|
|
this.openUserOnRamp(flowId).catch((err) => console.error("[OnRamp] Error:", err));
|
|
}
|
|
|
|
/**
|
|
* Prompt user for email via a modal dialog.
|
|
*/
|
|
private promptFundDetails(defaultAmount = 2): Promise<{ email: string; amount: number; label: string; provider: string } | null> {
|
|
return new Promise((resolve) => {
|
|
const modal = document.createElement("div");
|
|
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;`;
|
|
const inputStyle = `width:100%;padding:10px 12px;border:1px solid var(--rflows-modal-border);border-radius:8px;font-size:14px;box-sizing:border-box;background:var(--rs-bg-surface);color:var(--rs-text-primary)`;
|
|
|
|
modal.innerHTML = `
|
|
<div style="background:var(--rs-bg-surface);border-radius:16px;padding:28px;width:400px;max-width:90vw">
|
|
<h3 style="margin:0 0 4px;color:var(--rs-text-primary);font-size:17px">Fund a Flow</h3>
|
|
<p style="margin:0 0 20px;color:var(--rs-text-secondary);font-size:13px">Enter payment details below.</p>
|
|
<label style="display:block;margin-bottom:12px">
|
|
<span style="display:block;margin-bottom:4px;font-size:13px;font-weight:500;color:var(--rs-text-secondary)">Amount ($)</span>
|
|
<input id="fund-amount" type="number" min="1" step="0.01" value="${defaultAmount}"
|
|
style="${inputStyle}"/>
|
|
</label>
|
|
<label style="display:block;margin-bottom:12px">
|
|
<span style="display:block;margin-bottom:4px;font-size:13px;font-weight:500;color:var(--rs-text-secondary)">Recipient Email</span>
|
|
<input id="fund-email" type="email" placeholder="friend@example.com"
|
|
style="${inputStyle}"/>
|
|
</label>
|
|
<label style="display:block;margin-bottom:12px">
|
|
<span style="display:block;margin-bottom:4px;font-size:13px;font-weight:500;color:var(--rs-text-secondary)">Label (optional)</span>
|
|
<input id="fund-label" type="text" placeholder="Coffee Fund"
|
|
style="${inputStyle}"/>
|
|
</label>
|
|
<label style="display:block;margin-bottom:20px">
|
|
<span style="display:block;margin-bottom:4px;font-size:13px;font-weight:500;color:var(--rs-text-secondary)">Payment Provider</span>
|
|
<select id="fund-provider" style="${inputStyle}">
|
|
<option value="transak">Transak</option>
|
|
<option value="coinbase">Coinbase</option>
|
|
<option value="ramp">Ramp Network</option>
|
|
</select>
|
|
</label>
|
|
<div style="display:flex;gap:8px">
|
|
<button id="fund-cancel" style="flex:1;padding:10px;border:1px solid var(--rflows-modal-border);border-radius:8px;background:none;color:var(--rs-text-secondary);cursor:pointer">Cancel</button>
|
|
<button id="fund-submit" style="flex:1;padding:10px;border:none;border-radius:8px;background:var(--rs-primary);color:white;font-weight:600;cursor:pointer">Pay with Card</button>
|
|
</div>
|
|
</div>`;
|
|
document.body.appendChild(modal);
|
|
|
|
const amountInput = modal.querySelector("#fund-amount") as HTMLInputElement;
|
|
const emailInput = modal.querySelector("#fund-email") as HTMLInputElement;
|
|
const labelInput = modal.querySelector("#fund-label") as HTMLInputElement;
|
|
const providerSelect = modal.querySelector("#fund-provider") as HTMLSelectElement;
|
|
emailInput.focus();
|
|
|
|
// Populate provider dropdown from server config
|
|
const base = this.getApiBase();
|
|
fetch(`${base}/api/onramp/config`).then((r) => r.json()).then((cfg: any) => {
|
|
if (cfg.available && Array.isArray(cfg.available)) {
|
|
providerSelect.innerHTML = cfg.available.map((p: any) =>
|
|
`<option value="${p.id}">${p.name}</option>`
|
|
).join("");
|
|
}
|
|
}).catch(() => { /* keep static defaults */ });
|
|
|
|
const cleanup = (value: { email: string; amount: number; label: string; provider: string } | null) => { modal.remove(); resolve(value); };
|
|
|
|
const submit = () => {
|
|
const email = emailInput.value.trim();
|
|
const amount = parseFloat(amountInput.value) || 0;
|
|
if (!email || !email.includes("@")) { emailInput.style.borderColor = "red"; return; }
|
|
if (amount <= 0) { amountInput.style.borderColor = "red"; return; }
|
|
cleanup({ email, amount, label: labelInput.value.trim(), provider: providerSelect.value });
|
|
};
|
|
|
|
modal.querySelector("#fund-cancel")!.addEventListener("click", () => cleanup(null));
|
|
modal.addEventListener("click", (e) => { if (e.target === modal) cleanup(null); });
|
|
modal.querySelector("#fund-submit")!.addEventListener("click", submit);
|
|
modal.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter") submit();
|
|
if (e.key === "Escape") cleanup(null);
|
|
});
|
|
});
|
|
}
|
|
|
|
// ─── Source Purchase Modal ─────────────────────────────
|
|
|
|
private openSourcePurchaseModal(nodeId: string) {
|
|
if (this.sourceModalNodeId) return; // guard re-entry
|
|
const node = this.nodes.find((n) => n.id === nodeId);
|
|
if (!node || node.type !== "source") return;
|
|
this.sourceModalNodeId = nodeId;
|
|
const sd = node.data as SourceNodeData;
|
|
|
|
const valveColors: Record<string, string> = { card: "#3b82f6", safe_wallet: "#10b981", ridentity: "#7c3aed", metamask: "#f6851b", unconfigured: "#64748b" };
|
|
const inputStyle = `width:100%;padding:10px 12px;border:1px solid var(--rflows-modal-border, #334155);border-radius:8px;font-size:14px;box-sizing:border-box;background:var(--rs-bg-surface, #1e293b);color:var(--rs-text-primary, #e2e8f0)`;
|
|
|
|
const modal = document.createElement("div");
|
|
modal.className = "source-modal";
|
|
|
|
const renderMethodDetail = () => {
|
|
const detailEl = modal.querySelector(".spm-method-detail") as HTMLElement;
|
|
if (!detailEl) return;
|
|
if (sd.sourceType === "card") {
|
|
detailEl.innerHTML = `<button class="spm-action-btn" data-spm-action="fund-card" style="width:100%;padding:10px;border:none;border-radius:8px;background:var(--rs-primary);color:white;font-weight:600;cursor:pointer;margin-top:8px">Fund with Card</button>`;
|
|
} else if (sd.sourceType === "metamask") {
|
|
const addr = sd.walletAddress ? `<div style="margin-top:8px;font-size:12px;color:var(--rs-text-muted, #94a3b8);word-break:break-all">Connected: ${sd.walletAddress}</div>` : "";
|
|
detailEl.innerHTML = `<button class="spm-action-btn" data-spm-action="connect-metamask" style="width:100%;padding:10px;border:none;border-radius:8px;background:#f6851b;color:white;font-weight:600;cursor:pointer;margin-top:8px">${sd.walletAddress ? "Reconnect MetaMask" : "Connect MetaMask"}</button>${addr}`;
|
|
} else if (sd.sourceType === "ridentity") {
|
|
const session = getSession();
|
|
detailEl.innerHTML = `<div style="margin-top:8px;padding:10px;border:1px solid var(--rflows-modal-border, #334155);border-radius:8px;font-size:13px;color:var(--rs-text-secondary, #94a3b8)">${session ? `Linked as <strong style="color:var(--rs-text-primary, #e2e8f0)">${session.claims.username || session.claims.sub}</strong>` : "Not signed in"}</div>`;
|
|
} else {
|
|
detailEl.innerHTML = "";
|
|
}
|
|
// Re-attach detail listeners
|
|
modal.querySelector("[data-spm-action='fund-card']")?.addEventListener("click", (e: Event) => {
|
|
e.stopPropagation();
|
|
this.openUserOnRamp(nodeId).catch((err) => console.error("[UserOnRamp] Error:", err));
|
|
});
|
|
modal.querySelector("[data-spm-action='connect-metamask']")?.addEventListener("click", async (e: Event) => {
|
|
e.stopPropagation();
|
|
await this.connectMetaMask(nodeId);
|
|
renderMethodDetail();
|
|
});
|
|
};
|
|
|
|
const updateMethodBtns = () => {
|
|
modal.querySelectorAll(".spm-method-btn").forEach((btn) => {
|
|
const type = (btn as HTMLElement).dataset.spmType || "";
|
|
btn.classList.toggle("spm-method-btn--active", type === sd.sourceType);
|
|
});
|
|
};
|
|
|
|
// Build allocation display
|
|
let allocHtml = "";
|
|
if (sd.targetAllocations && sd.targetAllocations.length > 0) {
|
|
const barSegs = sd.targetAllocations.map(a =>
|
|
`<div style="flex:${a.percentage};height:6px;background:${a.color};border-radius:2px"></div>`
|
|
).join("");
|
|
const labels = sd.targetAllocations.map(a =>
|
|
`<div style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--rs-text-secondary, #94a3b8)"><span style="width:8px;height:8px;border-radius:50%;background:${a.color};flex-shrink:0"></span>${this.esc(this.getNodeLabel(a.targetId))} — ${a.percentage}%</div>`
|
|
).join("");
|
|
allocHtml = `<div style="margin-top:16px">
|
|
<div style="font-size:11px;text-transform:uppercase;font-weight:600;color:var(--rs-text-muted, #64748b);margin-bottom:6px">Allocations</div>
|
|
<div style="display:flex;gap:2px;margin-bottom:8px">${barSegs}</div>
|
|
<div style="display:flex;flex-direction:column;gap:4px">${labels}</div>
|
|
</div>`;
|
|
}
|
|
|
|
modal.innerHTML = `
|
|
<div class="spm-backdrop"></div>
|
|
<div class="spm-card">
|
|
<h3 style="margin:0 0 16px;color:var(--rs-text-primary, #e2e8f0);font-size:17px">Configure Source</h3>
|
|
<label style="display:block;margin-bottom:12px">
|
|
<span style="display:block;margin-bottom:4px;font-size:13px;font-weight:500;color:var(--rs-text-secondary, #94a3b8)">Label</span>
|
|
<input class="spm-label-input" type="text" value="${this.esc(sd.label)}" style="${inputStyle}"/>
|
|
</label>
|
|
<label style="display:block;margin-bottom:12px">
|
|
<span style="display:block;margin-bottom:4px;font-size:13px;font-weight:500;color:var(--rs-text-secondary, #94a3b8)">Monthly Amount ($)</span>
|
|
<input class="spm-amount-input" type="number" min="0" step="50" value="${sd.flowRate}" style="${inputStyle}"/>
|
|
</label>
|
|
<div style="margin-bottom:4px;font-size:13px;font-weight:500;color:var(--rs-text-secondary, #94a3b8)">Payment Method</div>
|
|
<div class="spm-method-grid">
|
|
<button class="spm-method-btn ${sd.sourceType === "card" ? "spm-method-btn--active" : ""}" data-spm-type="card">
|
|
<span style="font-size:20px">\u{1F4B3}</span><span>Card</span>
|
|
</button>
|
|
<button class="spm-method-btn ${sd.sourceType === "metamask" ? "spm-method-btn--active" : ""}" data-spm-type="metamask">
|
|
<span style="font-size:20px">\u{1F98A}</span><span>MetaMask</span>
|
|
</button>
|
|
<button class="spm-method-btn ${sd.sourceType === "ridentity" ? "spm-method-btn--active" : ""}" data-spm-type="ridentity">
|
|
<span style="font-size:20px">\u{1F464}</span><span>rIdentity</span>
|
|
</button>
|
|
</div>
|
|
<div class="spm-method-detail"></div>
|
|
${allocHtml}
|
|
<div style="display:flex;gap:8px;margin-top:20px;align-items:center">
|
|
<button class="spm-delete-btn" style="padding:8px 14px;border:1px solid var(--rs-error);border-radius:8px;background:none;color:var(--rs-error);cursor:pointer;font-size:13px">Delete</button>
|
|
<div style="flex:1"></div>
|
|
<button class="spm-close-btn" style="padding:10px 20px;border:none;border-radius:8px;background:var(--rs-primary, #10b981);color:white;font-weight:600;cursor:pointer;font-size:14px">Save & Close</button>
|
|
</div>
|
|
</div>`;
|
|
|
|
document.body.appendChild(modal);
|
|
renderMethodDetail();
|
|
|
|
// Live field updates
|
|
const labelInput = modal.querySelector(".spm-label-input") as HTMLInputElement;
|
|
const amountInput = modal.querySelector(".spm-amount-input") as HTMLInputElement;
|
|
|
|
const applyChanges = () => {
|
|
this.redrawNodeOnly(node);
|
|
this.redrawEdges();
|
|
this.scheduleSave();
|
|
};
|
|
|
|
labelInput.addEventListener("input", () => { sd.label = labelInput.value; applyChanges(); });
|
|
amountInput.addEventListener("input", () => { sd.flowRate = parseFloat(amountInput.value) || 0; applyChanges(); });
|
|
|
|
// Method selection
|
|
modal.querySelectorAll(".spm-method-btn").forEach((btn) => {
|
|
btn.addEventListener("click", (e: Event) => {
|
|
e.stopPropagation();
|
|
sd.sourceType = ((btn as HTMLElement).dataset.spmType || "unconfigured") as SourceNodeData["sourceType"];
|
|
updateMethodBtns();
|
|
renderMethodDetail();
|
|
applyChanges();
|
|
});
|
|
});
|
|
|
|
// Close / Delete
|
|
const closeModal = () => {
|
|
this.sourceModalNodeId = null;
|
|
modal.remove();
|
|
};
|
|
|
|
modal.querySelector(".spm-close-btn")!.addEventListener("click", closeModal);
|
|
modal.querySelector(".spm-backdrop")!.addEventListener("click", closeModal);
|
|
modal.addEventListener("keydown", (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") closeModal();
|
|
});
|
|
|
|
modal.querySelector(".spm-delete-btn")!.addEventListener("click", () => {
|
|
closeModal();
|
|
this.nodes = this.nodes.filter((nn) => nn.id !== nodeId);
|
|
this.drawCanvasContent();
|
|
this.scheduleSave();
|
|
});
|
|
|
|
labelInput.focus();
|
|
}
|
|
|
|
private async connectMetaMask(nodeId: string) {
|
|
const node = this.nodes.find((n) => n.id === nodeId);
|
|
if (!node || node.type !== "source") return;
|
|
const sd = node.data as SourceNodeData;
|
|
const ethereum = (window as any).ethereum;
|
|
if (!ethereum) {
|
|
alert("MetaMask not detected. Please install the MetaMask browser extension.");
|
|
return;
|
|
}
|
|
try {
|
|
const accounts: string[] = await ethereum.request({ method: "eth_requestAccounts" });
|
|
const chainId: string = await ethereum.request({ method: "eth_chainId" });
|
|
sd.walletAddress = accounts[0];
|
|
sd.chainId = parseInt(chainId, 16);
|
|
this.redrawNodeOnly(node);
|
|
this.redrawEdges();
|
|
this.scheduleSave();
|
|
} catch (err) {
|
|
console.error("[MetaMask] Connection failed:", err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open Transak on-ramp widget in an iframe modal.
|
|
*/
|
|
private openWidgetModal(url: string, provider: string = 'transak') {
|
|
const showClaimMessage = () => {
|
|
document.getElementById("onramp-modal")?.remove();
|
|
const successModal = document.createElement("div");
|
|
successModal.id = "onramp-modal";
|
|
successModal.style.cssText = `position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;`;
|
|
successModal.innerHTML = `
|
|
<div style="position:relative;width:450px;border-radius:16px;overflow:hidden;background:var(--rs-bg-surface);padding:40px;text-align:center">
|
|
<button id="onramp-close" style="position:absolute;top:8px;right:12px;background:none;border:none;color:var(--rs-text-secondary);font-size:24px;cursor:pointer">×</button>
|
|
<div style="font-size:48px;margin-bottom:16px">✅</div>
|
|
<h2 style="color:var(--rs-text-primary);margin-bottom:12px;font-size:20px">Payment Complete!</h2>
|
|
<p style="color:var(--rs-text-secondary);font-size:14px;line-height:1.6;margin-bottom:24px">
|
|
Check your email to claim your funds via EncryptID.<br>
|
|
No wallet keys or seed phrases needed.
|
|
</p>
|
|
<div style="background:var(--rs-bg-hover);border-radius:8px;padding:12px 16px;font-size:13px;color:var(--rs-text-secondary)">
|
|
📧 A claim link has been sent to your email
|
|
</div>
|
|
<button id="onramp-done" style="margin-top:20px;padding:12px 32px;background:linear-gradient(90deg,#00d4ff,#7c3aed);color:#fff;border:none;border-radius:8px;font-weight:600;font-size:15px;cursor:pointer;width:100%">Done</button>
|
|
</div>`;
|
|
document.body.appendChild(successModal);
|
|
successModal.querySelector("#onramp-close")!.addEventListener("click", () => successModal.remove());
|
|
successModal.querySelector("#onramp-done")!.addEventListener("click", () => successModal.remove());
|
|
};
|
|
|
|
const modal = document.createElement("div");
|
|
modal.id = "onramp-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="onramp-close" style="position:absolute;top:8px;right:12px;z-index:10;
|
|
background:none;border:none;color:white;font-size:24px;cursor:pointer">×</button>
|
|
<iframe src="${url}" style="width:100%;height:100%;border:none"
|
|
allow="camera;microphone;payment"
|
|
referrerpolicy="strict-origin-when-cross-origin"></iframe>
|
|
</div>`;
|
|
|
|
document.body.appendChild(modal);
|
|
|
|
modal.querySelector("#onramp-close")!.addEventListener("click", () => modal.remove());
|
|
modal.addEventListener("click", (e) => { if (e.target === modal) modal.remove(); });
|
|
|
|
const handler = (e: MessageEvent) => {
|
|
const d = e.data;
|
|
const isSuccess =
|
|
(provider === 'transak' && d?.event_id === "TRANSAK_ORDER_SUCCESSFUL") ||
|
|
(provider === 'coinbase' && d?.eventName === "success") ||
|
|
(provider === 'ramp' && d?.type === "PURCHASE_CREATED");
|
|
if (isSuccess) {
|
|
console.log(`[OnRamp] ${provider} order successful:`, d);
|
|
window.removeEventListener("message", handler);
|
|
modal.remove();
|
|
showClaimMessage();
|
|
}
|
|
};
|
|
window.addEventListener("message", handler);
|
|
}
|
|
|
|
/**
|
|
* Full user on-ramp flow: prompt email → create wallet → open Transak widget.
|
|
* Saves the provisioned wallet address back to the source node.
|
|
*/
|
|
private async openUserOnRamp(nodeId: string) {
|
|
const node = this.nodes.find((n) => n.id === nodeId);
|
|
const fiatAmount = node && node.type === "source" ? (node.data as SourceNodeData).flowRate || 50 : 50;
|
|
|
|
const details = await this.promptFundDetails(fiatAmount);
|
|
if (!details) return;
|
|
|
|
const base = this.getApiBase();
|
|
|
|
const url = `${base}/api/flows/user-onramp`;
|
|
console.log("[UserOnRamp] POST", url);
|
|
|
|
try {
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email: details.email, fiatAmount: details.amount, fiatCurrency: "USD", provider: details.provider }),
|
|
});
|
|
|
|
const ct = res.headers.get("content-type") || "";
|
|
if (!ct.includes("application/json")) {
|
|
console.error("[UserOnRamp] Expected JSON but got:", ct, "status:", res.status);
|
|
alert(`On-ramp failed: server returned ${res.status} (not JSON). Is the flow-service running?`);
|
|
return;
|
|
}
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ error: "Unknown error" }));
|
|
const msg = typeof err.error === 'string' ? err.error : JSON.stringify(err.error) || res.statusText;
|
|
console.error("[UserOnRamp] Server error:", res.status, err);
|
|
alert(`On-ramp failed: ${msg}`);
|
|
return;
|
|
}
|
|
|
|
const data = await res.json();
|
|
console.log("[UserOnRamp] Session created:", data.sessionId, "wallet:", data.walletAddress, "provider:", data.provider);
|
|
|
|
// Save wallet address and label to source node
|
|
const targetNode = this.nodes.find((n) => n.id === nodeId);
|
|
if (targetNode && targetNode.type === "source") {
|
|
const sd = targetNode.data as SourceNodeData;
|
|
sd.walletAddress = data.walletAddress;
|
|
if (details.label) sd.label = details.label;
|
|
sd.flowRate = details.amount;
|
|
this.drawCanvasContent();
|
|
this.scheduleSave();
|
|
}
|
|
|
|
// Open on-ramp widget
|
|
this.openWidgetModal(data.widgetUrl, data.provider || details.provider);
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
console.error("[UserOnRamp] Error:", msg, err);
|
|
alert(`On-ramp failed: ${msg}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Quick Fund: one-click toolbar flow — collects amount + email + label,
|
|
* calls user-onramp, creates a source node, and opens the on-ramp widget.
|
|
*/
|
|
private async quickFund() {
|
|
const details = await this.promptFundDetails();
|
|
if (!details) return;
|
|
|
|
const base = this.getApiBase();
|
|
const url = `${base}/api/flows/user-onramp`;
|
|
console.log("[QuickFund] POST", url);
|
|
try {
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email: details.email, fiatAmount: details.amount, fiatCurrency: "USD", provider: details.provider }),
|
|
});
|
|
|
|
const ct = res.headers.get("content-type") || "";
|
|
if (!ct.includes("application/json")) {
|
|
console.error("[QuickFund] Expected JSON but got:", ct, "status:", res.status);
|
|
alert(`Funding failed: server returned ${res.status} (not JSON). Is the flow-service running?`);
|
|
return;
|
|
}
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ error: "Unknown error" }));
|
|
const msg = typeof err.error === 'string' ? err.error : JSON.stringify(err.error) || res.statusText;
|
|
console.error("[QuickFund] Server error:", res.status, err);
|
|
alert(`On-ramp failed: ${msg}`);
|
|
return;
|
|
}
|
|
|
|
const data = await res.json();
|
|
console.log("[QuickFund] Session created:", data.sessionId, "wallet:", data.walletAddress);
|
|
|
|
// Create a source node on the canvas
|
|
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 = `source-${Date.now().toString(36)}`;
|
|
const sourceData: SourceNodeData = {
|
|
label: details.label || `$${details.amount} to ${details.email}`,
|
|
flowRate: details.amount,
|
|
sourceType: "card",
|
|
walletAddress: data.walletAddress,
|
|
targetAllocations: [],
|
|
};
|
|
this.nodes.push({ id, type: "source", position: { x: cx - 100, y: cy - 50 }, data: sourceData });
|
|
this.drawCanvasContent();
|
|
this.scheduleSave();
|
|
|
|
// Open on-ramp widget
|
|
this.openWidgetModal(data.widgetUrl, data.provider || details.provider);
|
|
} catch (err) {
|
|
console.error("[QuickFund] Error:", err);
|
|
alert("Failed to start funding. Check console for details.");
|
|
}
|
|
}
|
|
|
|
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();
|
|
});
|
|
|
|
// Prevent keydown from propagating to canvas shortcuts (Delete, Backspace, etc.)
|
|
const inputs = panel.querySelectorAll(".editor-input, .editor-select");
|
|
inputs.forEach((input) => {
|
|
input.addEventListener("keydown", (e) => e.stopPropagation());
|
|
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;
|
|
if (sourceData.walletAddress) {
|
|
this.openOnRampWidget(flowId, sourceData.walletAddress);
|
|
} else {
|
|
this.openUserOnRamp(node.id);
|
|
}
|
|
});
|
|
|
|
// Pay-by buttons (source editor)
|
|
panel.querySelectorAll("[data-editor-source-type]").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
(node.data as SourceNodeData).sourceType = (btn as HTMLElement).dataset.editorSourceType as any;
|
|
this.drawCanvasContent();
|
|
this.scheduleSave();
|
|
this.openEditor(node.id);
|
|
});
|
|
});
|
|
}
|
|
|
|
// ─── 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 · ${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 ? "🔓" : "🔒"}</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}">▶</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">×</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}", metamask: "\u{1F98A}", unconfigured: "\u{2699}" };
|
|
const labels: Record<string, string> = { card: "Card", safe_wallet: "Safe / Wallet", ridentity: "rIdentity", unconfigured: "Configure" };
|
|
|
|
let configHtml = "";
|
|
if (d.sourceType === "card") {
|
|
const truncWallet = d.walletAddress
|
|
? `${d.walletAddress.slice(0, 6)}...${d.walletAddress.slice(-4)}`
|
|
: "";
|
|
configHtml = `<div style="margin-top:12px">
|
|
${d.walletAddress ? `<div style="display:flex;align-items:center;gap:6px;margin-bottom:10px;padding:8px 10px;background:var(--rs-bg-surface-sunken);border-radius:8px;font-size:12px">
|
|
<span style="color:var(--rs-success)">✅</span>
|
|
<span style="color:var(--rs-text-secondary)">Funds linked via EncryptID</span>
|
|
<code style="color:var(--rs-text-primary);font-family:monospace;margin-left:auto">${truncWallet}</code>
|
|
</div>` : ""}
|
|
<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">
|
|
💳 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">👤</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)">✅ ${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] || "💰"}</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">×</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">Pay by</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) {
|
|
this.openOnRampWidget(flowId, d.walletAddress);
|
|
} else {
|
|
this.openUserOnRamp(nodeId);
|
|
}
|
|
});
|
|
|
|
// 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") {
|
|
const username = getUsername();
|
|
const defaultLabel = username
|
|
? `${username}'s stream to ${this.flowName || "Flow"}`
|
|
: `Stream to ${this.flowName || "Flow"}`;
|
|
data = { label: defaultLabel, flowRate: 1000, sourceType: "card", targetAllocations: [] } as SourceNodeData;
|
|
} else if (type === "funnel") {
|
|
data = {
|
|
label: "New Funnel", currentValue: 0, desiredOutflow: 5000,
|
|
minThreshold: 5000, sufficientThreshold: 15000, maxThreshold: 30000,
|
|
maxCapacity: 45000, inflowRate: 0, dynamicOverflow: false,
|
|
overflowAllocations: [], spendingAllocations: [],
|
|
} as FunnelNodeData;
|
|
} else {
|
|
data = { label: "New Outcome", description: "", fundingReceived: 0, fundingTarget: 10000, status: "not-started", overflowAllocations: [] } as OutcomeNodeData;
|
|
}
|
|
|
|
this.nodes.push({ id, type, position: { x: cx - 100, y: cy - 50 }, data });
|
|
this.drawCanvasContent();
|
|
this.selectedNodeId = id;
|
|
this.updateSelectionHighlight();
|
|
this.enterInlineEdit(id);
|
|
this.scheduleSave();
|
|
}
|
|
|
|
private deleteNode(nodeId: string) {
|
|
this.nodes = this.nodes.filter((n) => n.id !== nodeId);
|
|
// Clean up allocations pointing to deleted node
|
|
for (const n of this.nodes) {
|
|
if (n.type === "source") {
|
|
const d = n.data as SourceNodeData;
|
|
d.targetAllocations = d.targetAllocations.filter((a) => a.targetId !== nodeId);
|
|
}
|
|
if (n.type === "funnel") {
|
|
const d = n.data as FunnelNodeData;
|
|
d.overflowAllocations = d.overflowAllocations.filter((a) => a.targetId !== nodeId);
|
|
d.spendingAllocations = d.spendingAllocations.filter((a) => a.targetId !== nodeId);
|
|
}
|
|
if (n.type === "outcome") {
|
|
const d = n.data as OutcomeNodeData;
|
|
if (d.overflowAllocations) d.overflowAllocations = d.overflowAllocations.filter((a) => a.targetId !== nodeId);
|
|
}
|
|
}
|
|
if (this.selectedNodeId === nodeId) this.selectedNodeId = null;
|
|
this.drawCanvasContent();
|
|
this.updateSufficiencyBadge();
|
|
this.scheduleSave();
|
|
}
|
|
|
|
// ─── Simulation ───────────────────────────────────────
|
|
|
|
private toggleSimulation() {
|
|
this.isSimulating = !this.isSimulating;
|
|
const btn = this.shadow.getElementById("sim-btn");
|
|
if (btn) btn.textContent = this.isSimulating ? "⏸ Pause" : "▶ Play";
|
|
|
|
// Update floating play button
|
|
const fabIcon = this.shadow.getElementById("fab-play-icon");
|
|
if (fabIcon) fabIcon.textContent = this.isSimulating ? "⏸" : "▶";
|
|
const fab = this.shadow.getElementById("fab-play");
|
|
if (fab) fab.classList.toggle("playing", this.isSimulating);
|
|
|
|
// 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 = computeInflowRates(this.nodes);
|
|
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 paths 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 w = s.w, h = s.h;
|
|
const fillPct = Math.min(1, d.currentValue / (d.maxCapacity || 1));
|
|
const drainW = 60;
|
|
const taperAtBottom = (w - drainW) / 2;
|
|
const fillPath = this.computeVesselFillPath(w, h, fillPct, taperAtBottom);
|
|
const fillEl = nodeLayer.querySelector(`.funnel-fill-path[data-node-id="${n.id}"]`) as SVGPathElement | null;
|
|
if (fillEl && fillPath) {
|
|
fillEl.setAttribute("d", fillPath);
|
|
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-minimize" data-analytics-minimize title="Minimize panel">◀</button>
|
|
<button class="analytics-close" data-analytics-close>×</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-toolbar-btn--active", this.analyticsOpen);
|
|
}
|
|
|
|
private attachAnalyticsListeners() {
|
|
const closeBtn = this.shadow.querySelector("[data-analytics-close]");
|
|
closeBtn?.addEventListener("click", () => this.toggleAnalytics());
|
|
|
|
const minimizeBtn = this.shadow.querySelector("[data-analytics-minimize]");
|
|
minimizeBtn?.addEventListener("click", () => {
|
|
const panel = this.shadow.getElementById("analytics-panel");
|
|
if (panel) {
|
|
const isMin = panel.classList.toggle("minimized");
|
|
if (minimizeBtn) (minimizeBtn as HTMLElement).textContent = isMin ? "▶" : "◀";
|
|
}
|
|
});
|
|
|
|
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" ? "⬆" : tx.type === "withdraw" ? "⬇" : "🔄"}</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 ? ` → ${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: 'BCRG Demo Flow', nodes: demoNodes, createdAt: 0, updatedAt: 0, createdBy: null };
|
|
} else if (flowId === 'sim-demo') {
|
|
flow = { id: 'sim-demo', name: 'Simulation Demo', nodes: simDemoNodes, 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());
|
|
|
|
// Mortgage tab listeners
|
|
if (this.view === "mortgage") {
|
|
this.shadow.querySelectorAll('.mortgage-row').forEach(row => {
|
|
row.addEventListener('click', () => {
|
|
const id = (row as HTMLElement).dataset.mortgageId || null;
|
|
this.selectedLenderId = this.selectedLenderId === id ? null : id;
|
|
this.render();
|
|
});
|
|
});
|
|
|
|
this.shadow.querySelector('[data-action="close-lender"]')?.addEventListener('click', () => {
|
|
this.selectedLenderId = null;
|
|
this.render();
|
|
});
|
|
|
|
this.shadow.querySelector('[data-action="toggle-pool"]')?.addEventListener('click', () => {
|
|
this.showPoolOverview = !this.showPoolOverview;
|
|
this.selectedLenderId = null;
|
|
this.render();
|
|
});
|
|
|
|
this.shadow.querySelector('[data-action="close-pool"]')?.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
this.showPoolOverview = false;
|
|
this.render();
|
|
});
|
|
|
|
this.shadow.querySelector('[data-action="update-borrower"]')?.addEventListener('click', () => {
|
|
const budgetEl = this.shadow.querySelector('[data-borrower="budget"]') as HTMLInputElement;
|
|
if (budgetEl) this.borrowerMonthlyBudget = Math.max(parseFloat(budgetEl.value) || 100, 100);
|
|
this.render();
|
|
});
|
|
|
|
this.shadow.querySelector('[data-action="calc-projection"]')?.addEventListener('click', () => {
|
|
const amtEl = this.shadow.querySelector('[data-proj="amount"]') as HTMLInputElement;
|
|
const apyEl = this.shadow.querySelector('[data-proj="apy"]') as HTMLInputElement;
|
|
const moEl = this.shadow.querySelector('[data-proj="months"]') as HTMLInputElement;
|
|
if (amtEl) this.projCalcAmount = parseFloat(amtEl.value) || 0;
|
|
if (apyEl) this.projCalcApy = parseFloat(apyEl.value) || 0;
|
|
if (moEl) this.projCalcMonths = parseInt(moEl.value) || 0;
|
|
this.render();
|
|
});
|
|
}
|
|
|
|
// Budget tab listeners
|
|
if (this.view === "budgets") {
|
|
this.shadow.querySelectorAll('[data-budget-slider]').forEach((slider) => {
|
|
slider.addEventListener('input', (e) => {
|
|
const segId = (slider as HTMLElement).dataset.budgetSlider!;
|
|
this.myAllocation[segId] = parseInt((e.target as HTMLInputElement).value) || 0;
|
|
this.normalizeBudgetSliders(segId);
|
|
this.render();
|
|
});
|
|
});
|
|
|
|
this.shadow.querySelector('[data-budget-action="save"]')?.addEventListener('click', () => {
|
|
this.saveBudgetAllocation();
|
|
});
|
|
|
|
this.shadow.querySelector('[data-budget-action="add-segment"]')?.addEventListener('click', () => {
|
|
this.addBudgetSegment();
|
|
});
|
|
|
|
this.shadow.querySelectorAll('[data-budget-remove]').forEach((btn) => {
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const segId = (btn as HTMLElement).dataset.budgetRemove!;
|
|
if (confirm(`Remove segment "${this.budgetSegments.find(s => s.id === segId)?.name}"?`)) {
|
|
this.removeBudgetSegment(segId);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
// ── Guided Tour ──
|
|
|
|
startTour() {
|
|
this._tour.start();
|
|
}
|
|
|
|
// ─── Budget tab ─────────────────────────────────────────
|
|
|
|
private async loadBudgetData() {
|
|
this.loading = true;
|
|
this.render();
|
|
|
|
const base = this.getApiBase();
|
|
try {
|
|
const res = await fetch(`${base}/api/budgets?space=${encodeURIComponent(this.space)}`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
this.budgetSegments = data.segments || [];
|
|
this.collectiveAllocations = data.collective || [];
|
|
this.budgetTotalAmount = data.totalAmount || 0;
|
|
this.budgetParticipantCount = data.participantCount || 0;
|
|
|
|
// Load my allocation if authenticated
|
|
const session = getSession();
|
|
if (session) {
|
|
const myDid = (session.claims as any).did || session.claims.sub;
|
|
const myAlloc = (data.allocations || []).find((a: any) => a.participantDid === myDid);
|
|
if (myAlloc) this.myAllocation = myAlloc.allocations;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn('[rBudgets] Failed to load data:', err);
|
|
}
|
|
|
|
// Demo fallback if no segments
|
|
if (this.budgetSegments.length === 0) {
|
|
this.budgetSegments = [
|
|
{ id: 'eng', name: 'Engineering', color: '#3b82f6', createdBy: null },
|
|
{ id: 'ops', name: 'Operations', color: '#10b981', createdBy: null },
|
|
{ id: 'mkt', name: 'Marketing', color: '#f59e0b', createdBy: null },
|
|
{ id: 'com', name: 'Community', color: '#8b5cf6', createdBy: null },
|
|
{ id: 'res', name: 'Research', color: '#ef4444', createdBy: null },
|
|
];
|
|
this.collectiveAllocations = [
|
|
{ segmentId: 'eng', avgPercentage: 32.5, participantCount: 4 },
|
|
{ segmentId: 'ops', avgPercentage: 15, participantCount: 4 },
|
|
{ segmentId: 'mkt', avgPercentage: 17.5, participantCount: 4 },
|
|
{ segmentId: 'com', avgPercentage: 18.75, participantCount: 4 },
|
|
{ segmentId: 'res', avgPercentage: 16.25, participantCount: 4 },
|
|
];
|
|
this.budgetTotalAmount = 500000;
|
|
this.budgetParticipantCount = 4;
|
|
}
|
|
|
|
// Initialize myAllocation with equal splits if empty
|
|
if (Object.keys(this.myAllocation).length === 0 && this.budgetSegments.length > 0) {
|
|
const each = Math.floor(100 / this.budgetSegments.length);
|
|
const remainder = 100 - each * this.budgetSegments.length;
|
|
this.budgetSegments.forEach((seg, i) => {
|
|
this.myAllocation[seg.id] = each + (i === 0 ? remainder : 0);
|
|
});
|
|
}
|
|
|
|
this.loading = false;
|
|
this.render();
|
|
}
|
|
|
|
private async saveBudgetAllocation() {
|
|
const session = getSession();
|
|
if (!session) return;
|
|
|
|
const base = this.getApiBase();
|
|
try {
|
|
await fetch(`${base}/api/budgets/allocate`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },
|
|
body: JSON.stringify({ space: this.space, allocations: this.myAllocation }),
|
|
});
|
|
// Reload to get updated collective
|
|
await this.loadBudgetData();
|
|
} catch (err) {
|
|
console.error('[rBudgets] Save failed:', err);
|
|
}
|
|
}
|
|
|
|
private async addBudgetSegment() {
|
|
const session = getSession();
|
|
const name = prompt('Segment name:');
|
|
if (!name) return;
|
|
|
|
const colors = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#ec4899', '#14b8a6', '#f97316'];
|
|
const color = colors[this.budgetSegments.length % colors.length];
|
|
|
|
if (session) {
|
|
const base = this.getApiBase();
|
|
try {
|
|
await fetch(`${base}/api/budgets/segments`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },
|
|
body: JSON.stringify({ space: this.space, action: 'add', name, color }),
|
|
});
|
|
await this.loadBudgetData();
|
|
return;
|
|
} catch {}
|
|
}
|
|
|
|
// Local-only fallback for demo
|
|
const id = 'seg-' + Date.now();
|
|
this.budgetSegments.push({ id, name, color, createdBy: null });
|
|
this.myAllocation[id] = 0;
|
|
this.render();
|
|
}
|
|
|
|
private async removeBudgetSegment(segmentId: string) {
|
|
const session = getSession();
|
|
|
|
if (session) {
|
|
const base = this.getApiBase();
|
|
try {
|
|
await fetch(`${base}/api/budgets/segments`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },
|
|
body: JSON.stringify({ space: this.space, action: 'remove', segmentId }),
|
|
});
|
|
await this.loadBudgetData();
|
|
return;
|
|
} catch {}
|
|
}
|
|
|
|
// Local-only fallback
|
|
this.budgetSegments = this.budgetSegments.filter((s) => s.id !== segmentId);
|
|
delete this.myAllocation[segmentId];
|
|
this.collectiveAllocations = this.collectiveAllocations.filter((c) => c.segmentId !== segmentId);
|
|
this.normalizeBudgetSliders();
|
|
this.render();
|
|
}
|
|
|
|
private normalizeBudgetSliders(changedId?: string) {
|
|
const ids = this.budgetSegments.map((s) => s.id);
|
|
if (ids.length === 0) return;
|
|
|
|
const total = ids.reduce((s, id) => s + (this.myAllocation[id] || 0), 0);
|
|
if (total === 100) return;
|
|
|
|
if (changedId) {
|
|
const changedVal = this.myAllocation[changedId] || 0;
|
|
const others = ids.filter((id) => id !== changedId);
|
|
const othersTotal = others.reduce((s, id) => s + (this.myAllocation[id] || 0), 0);
|
|
const remaining = 100 - changedVal;
|
|
|
|
if (othersTotal === 0) {
|
|
// Distribute remaining equally
|
|
const each = Math.floor(remaining / others.length);
|
|
const rem = remaining - each * others.length;
|
|
others.forEach((id, i) => { this.myAllocation[id] = each + (i === 0 ? rem : 0); });
|
|
} else {
|
|
// Scale proportionally
|
|
const scale = remaining / othersTotal;
|
|
let assigned = 0;
|
|
others.forEach((id, i) => {
|
|
if (i === others.length - 1) {
|
|
this.myAllocation[id] = remaining - assigned;
|
|
} else {
|
|
const val = Math.round((this.myAllocation[id] || 0) * scale);
|
|
this.myAllocation[id] = val;
|
|
assigned += val;
|
|
}
|
|
});
|
|
}
|
|
} else {
|
|
// Simple normalize
|
|
const scale = 100 / total;
|
|
let assigned = 0;
|
|
ids.forEach((id, i) => {
|
|
if (i === ids.length - 1) {
|
|
this.myAllocation[id] = 100 - assigned;
|
|
} else {
|
|
const val = Math.round((this.myAllocation[id] || 0) * scale);
|
|
this.myAllocation[id] = val;
|
|
assigned += val;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
private renderPieChart(data: { id: string; label: string; value: number; color: string }[], size: number): string {
|
|
const total = data.reduce((s, d) => s + d.value, 0);
|
|
if (total === 0 || data.length === 0) {
|
|
return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
|
|
<circle cx="${size / 2}" cy="${size / 2}" r="${size / 2 - 4}" fill="none" stroke="var(--rs-border, #2a2a3e)" stroke-width="2"/>
|
|
<text x="${size / 2}" y="${size / 2}" text-anchor="middle" dominant-baseline="middle" fill="var(--rs-text-secondary)" font-size="14">No data</text>
|
|
</svg>`;
|
|
}
|
|
|
|
const cx = size / 2, cy = size / 2, r = size / 2 - 8;
|
|
let currentAngle = -Math.PI / 2; // Start at top
|
|
const paths: string[] = [];
|
|
const labels: string[] = [];
|
|
|
|
for (const d of data) {
|
|
const pct = d.value / total;
|
|
if (pct <= 0) continue;
|
|
const angle = pct * Math.PI * 2;
|
|
const x1 = cx + r * Math.cos(currentAngle);
|
|
const y1 = cy + r * Math.sin(currentAngle);
|
|
const x2 = cx + r * Math.cos(currentAngle + angle);
|
|
const y2 = cy + r * Math.sin(currentAngle + angle);
|
|
const largeArc = angle > Math.PI ? 1 : 0;
|
|
|
|
paths.push(`<path d="M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} Z" fill="${d.color}" stroke="var(--rs-bg, #0a0a1a)" stroke-width="2" opacity="0.9" data-segment="${d.id}">
|
|
<title>${d.label}: ${d.value.toFixed(1)}% ($${Math.round(this.budgetTotalAmount * d.value / 100).toLocaleString()})</title>
|
|
</path>`);
|
|
|
|
// Label at midpoint of arc
|
|
if (pct > 0.05) {
|
|
const midAngle = currentAngle + angle / 2;
|
|
const labelR = r * 0.65;
|
|
const lx = cx + labelR * Math.cos(midAngle);
|
|
const ly = cy + labelR * Math.sin(midAngle);
|
|
labels.push(`<text x="${lx}" y="${ly}" text-anchor="middle" dominant-baseline="middle" fill="white" font-size="${pct > 0.1 ? 12 : 10}" font-weight="600" pointer-events="none">${d.label.slice(0, 6)}</text>`);
|
|
labels.push(`<text x="${lx}" y="${ly + 14}" text-anchor="middle" dominant-baseline="middle" fill="rgba(255,255,255,0.8)" font-size="10" pointer-events="none">${d.value.toFixed(1)}%</text>`);
|
|
}
|
|
|
|
currentAngle += angle;
|
|
}
|
|
|
|
// Center label
|
|
const centerLabel = this.budgetTotalAmount > 0
|
|
? `<text x="${cx}" y="${cy - 6}" text-anchor="middle" dominant-baseline="middle" fill="var(--rs-text-secondary)" font-size="10">Total</text>
|
|
<text x="${cx}" y="${cy + 10}" text-anchor="middle" dominant-baseline="middle" fill="var(--rs-text-primary)" font-size="14" font-weight="700">${this.fmtUsd(this.budgetTotalAmount)}</text>`
|
|
: '';
|
|
|
|
return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" style="filter: drop-shadow(0 2px 8px rgba(0,0,0,0.3));">
|
|
${paths.join('\n')}
|
|
${labels.join('\n')}
|
|
<circle cx="${cx}" cy="${cy}" r="${r * 0.32}" fill="var(--rs-bg, #0a0a1a)" stroke="var(--rs-border, #2a2a3e)" stroke-width="1"/>
|
|
${centerLabel}
|
|
</svg>`;
|
|
}
|
|
|
|
private renderBudgetsTab(): string {
|
|
if (this.loading) return '<div class="flows-loading">Loading budget data...</div>';
|
|
|
|
const pieData = this.collectiveAllocations.map((c) => {
|
|
const seg = this.budgetSegments.find((s) => s.id === c.segmentId);
|
|
return { id: c.segmentId, label: seg?.name || c.segmentId, value: c.avgPercentage, color: seg?.color || '#666' };
|
|
}).filter((d) => d.value > 0);
|
|
|
|
const authenticated = isAuthenticated();
|
|
|
|
return `
|
|
<div class="budget-tab" style="padding: 24px; max-width: 1200px; margin: 0 auto;">
|
|
<!-- Header -->
|
|
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 24px;">
|
|
<a href="/${this.space === 'demo' ? 'demo/' : ''}rflows" style="color: var(--rs-text-secondary); text-decoration: none; font-size: 14px;">← Back to Flows</a>
|
|
<h2 style="margin: 0; font-size: 24px; color: var(--rs-text-primary);">rBudgets</h2>
|
|
<span style="background: rgba(99,102,241,0.15); color: #6366f1; padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 600;">COLLECTIVE</span>
|
|
<span style="margin-left: auto; color: var(--rs-text-secondary); font-size: 13px;">${this.budgetParticipantCount} participant${this.budgetParticipantCount !== 1 ? 's' : ''}</span>
|
|
</div>
|
|
|
|
<!-- Main layout: pie chart + sliders -->
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 24px;">
|
|
<!-- Collective Pie Chart -->
|
|
<div style="background: var(--rs-surface, #1a1a2e); border-radius: 12px; padding: 24px; border: 1px solid var(--rs-border, #2a2a3e); display: flex; flex-direction: column; align-items: center;">
|
|
<h3 style="margin: 0 0 16px; font-size: 16px; color: var(--rs-text-primary); width: 100%;">Collective Allocation</h3>
|
|
${this.renderPieChart(pieData, 280)}
|
|
<!-- Legend -->
|
|
<div style="display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; justify-content: center;">
|
|
${pieData.map((d) => `
|
|
<div style="display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--rs-text-secondary);">
|
|
<div style="width: 10px; height: 10px; border-radius: 2px; background: ${d.color};"></div>
|
|
${this.esc(d.label)}: ${d.value.toFixed(1)}%
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- My Allocation Sliders -->
|
|
<div style="background: var(--rs-surface, #1a1a2e); border-radius: 12px; padding: 24px; border: 1px solid var(--rs-border, #2a2a3e);">
|
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px;">
|
|
<h3 style="margin: 0; font-size: 16px; color: var(--rs-text-primary);">My Allocation</h3>
|
|
<span style="font-size: 12px; color: var(--rs-text-secondary);">Total: ${Object.values(this.myAllocation).reduce((s, v) => s + v, 0)}%</span>
|
|
</div>
|
|
|
|
<div style="display: flex; flex-direction: column; gap: 12px;">
|
|
${this.budgetSegments.map((seg) => {
|
|
const val = this.myAllocation[seg.id] || 0;
|
|
const amount = this.budgetTotalAmount > 0 ? Math.round(this.budgetTotalAmount * val / 100) : 0;
|
|
return `
|
|
<div style="display: flex; align-items: center; gap: 12px;">
|
|
<div style="width: 10px; height: 10px; border-radius: 2px; background: ${seg.color}; flex-shrink: 0;"></div>
|
|
<span style="font-size: 13px; color: var(--rs-text-primary); width: 90px; flex-shrink: 0;">${this.esc(seg.name)}</span>
|
|
<input type="range" min="0" max="100" value="${val}" data-budget-slider="${seg.id}"
|
|
style="flex: 1; accent-color: ${seg.color}; cursor: pointer;">
|
|
<span style="font-size: 13px; color: var(--rs-text-primary); width: 36px; text-align: right; font-variant-numeric: tabular-nums;">${val}%</span>
|
|
${this.budgetTotalAmount > 0 ? `<span style="font-size: 11px; color: var(--rs-text-secondary); width: 70px; text-align: right;">$${amount.toLocaleString()}</span>` : ''}
|
|
</div>`;
|
|
}).join('')}
|
|
</div>
|
|
|
|
<div style="display: flex; gap: 8px; margin-top: 16px;">
|
|
${authenticated
|
|
? `<button data-budget-action="save" style="flex: 1; padding: 10px; background: #6366f1; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 600;">Save Allocation</button>`
|
|
: `<div style="flex: 1; padding: 10px; background: rgba(99,102,241,0.1); color: var(--rs-text-secondary); border-radius: 8px; text-align: center; font-size: 13px;">Sign in to save your allocation</div>`
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Segment Management -->
|
|
<div style="background: var(--rs-surface, #1a1a2e); border-radius: 12px; padding: 20px; border: 1px solid var(--rs-border, #2a2a3e); margin-bottom: 24px;">
|
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;">
|
|
<h3 style="margin: 0; font-size: 16px; color: var(--rs-text-primary);">Segments</h3>
|
|
<button data-budget-action="add-segment" style="padding: 6px 14px; background: rgba(99,102,241,0.15); color: #6366f1; border: 1px solid rgba(99,102,241,0.3); border-radius: 8px; cursor: pointer; font-size: 12px; font-weight: 600;">+ Add Segment</button>
|
|
</div>
|
|
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
|
|
${this.budgetSegments.map((seg) => {
|
|
const collective = this.collectiveAllocations.find((c) => c.segmentId === seg.id);
|
|
const pct = collective?.avgPercentage || 0;
|
|
const amount = this.budgetTotalAmount > 0 ? Math.round(this.budgetTotalAmount * pct / 100) : 0;
|
|
return `
|
|
<div style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: var(--rs-bg, #0a0a1a); border-radius: 8px; border: 1px solid var(--rs-border, #2a2a3e);">
|
|
<div style="width: 12px; height: 12px; border-radius: 3px; background: ${seg.color};"></div>
|
|
<span style="font-size: 13px; color: var(--rs-text-primary);">${this.esc(seg.name)}</span>
|
|
<span style="font-size: 11px; color: var(--rs-text-secondary);">${pct.toFixed(1)}%${amount > 0 ? ` / $${amount.toLocaleString()}` : ''}</span>
|
|
<button data-budget-remove="${seg.id}" style="background: none; border: none; color: var(--rs-text-secondary); cursor: pointer; font-size: 14px; padding: 0 2px; opacity: 0.5;" title="Remove segment">×</button>
|
|
</div>`;
|
|
}).join('')}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Allocation Summary Table -->
|
|
<div style="background: var(--rs-surface, #1a1a2e); border-radius: 12px; padding: 20px; border: 1px solid var(--rs-border, #2a2a3e);">
|
|
<h3 style="margin: 0 0 12px; font-size: 16px; color: var(--rs-text-primary);">Allocation Breakdown</h3>
|
|
<div style="overflow-x: auto;">
|
|
<table style="width: 100%; border-collapse: collapse; font-size: 13px;">
|
|
<thead>
|
|
<tr style="border-bottom: 1px solid var(--rs-border, #2a2a3e);">
|
|
<th style="text-align: left; padding: 8px; color: var(--rs-text-secondary); font-weight: 500;">Segment</th>
|
|
<th style="text-align: right; padding: 8px; color: var(--rs-text-secondary); font-weight: 500;">Collective %</th>
|
|
<th style="text-align: right; padding: 8px; color: var(--rs-text-secondary); font-weight: 500;">Amount</th>
|
|
<th style="text-align: right; padding: 8px; color: var(--rs-text-secondary); font-weight: 500;">Your %</th>
|
|
<th style="text-align: left; padding: 8px; color: var(--rs-text-secondary); font-weight: 500;">Bar</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${this.budgetSegments.map((seg) => {
|
|
const collective = this.collectiveAllocations.find((c) => c.segmentId === seg.id);
|
|
const pct = collective?.avgPercentage || 0;
|
|
const myPct = this.myAllocation[seg.id] || 0;
|
|
const amount = this.budgetTotalAmount > 0 ? Math.round(this.budgetTotalAmount * pct / 100) : 0;
|
|
return `
|
|
<tr style="border-bottom: 1px solid var(--rs-border, #2a2a3e);">
|
|
<td style="padding: 8px; color: var(--rs-text-primary);">
|
|
<div style="display: flex; align-items: center; gap: 6px;">
|
|
<div style="width: 8px; height: 8px; border-radius: 2px; background: ${seg.color};"></div>
|
|
${this.esc(seg.name)}
|
|
</div>
|
|
</td>
|
|
<td style="text-align: right; padding: 8px; color: var(--rs-text-primary); font-variant-numeric: tabular-nums;">${pct.toFixed(1)}%</td>
|
|
<td style="text-align: right; padding: 8px; color: var(--rs-text-secondary); font-variant-numeric: tabular-nums;">${amount > 0 ? '$' + amount.toLocaleString() : '-'}</td>
|
|
<td style="text-align: right; padding: 8px; color: var(--rs-text-primary); font-variant-numeric: tabular-nums;">${myPct}%</td>
|
|
<td style="padding: 8px; width: 140px;">
|
|
<div style="height: 8px; background: var(--rs-bg, #0a0a1a); border-radius: 4px; overflow: hidden; position: relative;">
|
|
<div style="height: 100%; width: ${pct}%; background: ${seg.color}; border-radius: 4px; transition: width 0.3s ease;"></div>
|
|
<div style="position: absolute; top: 0; height: 100%; width: 2px; left: ${myPct}%; background: white; opacity: 0.7;"></div>
|
|
</div>
|
|
</td>
|
|
</tr>`;
|
|
}).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
// ─── Mortgage tab ───────────────────────────────────────
|
|
|
|
private async loadMortgageData() {
|
|
this.loading = true;
|
|
this.render();
|
|
|
|
const base = this.getApiBase();
|
|
try {
|
|
const [posRes, rateRes] = await Promise.all([
|
|
fetch(`${base}/api/mortgage/positions?space=${encodeURIComponent(this.space)}`),
|
|
fetch(`${base}/api/mortgage/rates`),
|
|
]);
|
|
if (posRes.ok) this.mortgagePositions = await posRes.json();
|
|
if (rateRes.ok) {
|
|
const data = await rateRes.json();
|
|
this.liveRates = data.rates || [];
|
|
}
|
|
} catch (err) {
|
|
console.warn('[rMortgage] Failed to load data:', err);
|
|
}
|
|
|
|
// If no positions from API (demo mode), use hardcoded demo data
|
|
if (this.mortgagePositions.length === 0) {
|
|
const now = Date.now();
|
|
this.mortgagePositions = [
|
|
{ id: '1', borrower: 'alice.eth', borrowerDid: 'did:key:alice123', principal: 250000, interestRate: 4.2, termMonths: 360, monthlyPayment: 1222.95, startDate: now - 86400000 * 120, trustScore: 92, status: 'active', collateralType: 'trust-backed' },
|
|
{ id: '2', borrower: 'bob.base', borrowerDid: 'did:key:bob456', principal: 180000, interestRate: 3.8, termMonths: 240, monthlyPayment: 1079.19, startDate: now - 86400000 * 60, trustScore: 87, status: 'active', collateralType: 'hybrid' },
|
|
{ id: '3', borrower: 'carol.eth', borrowerDid: 'did:key:carol789', principal: 75000, interestRate: 5.1, termMonths: 120, monthlyPayment: 799.72, startDate: now - 86400000 * 200, trustScore: 78, status: 'active', collateralType: 'trust-backed' },
|
|
{ id: '4', borrower: 'dave.base', borrowerDid: 'did:key:dave012', principal: 320000, interestRate: 3.5, termMonths: 360, monthlyPayment: 1436.94, startDate: now - 86400000 * 30, trustScore: 95, status: 'pending', collateralType: 'asset-backed' },
|
|
];
|
|
this.reinvestmentPositions = [
|
|
{ protocol: 'Aave v3', chain: 'Base', asset: 'USDC', deposited: 500000, currentValue: 512340, apy: 4.87, lastUpdated: now },
|
|
{ protocol: 'Morpho Blue', chain: 'Ethereum', asset: 'USDC', deposited: 200000, currentValue: 203120, apy: 3.12, lastUpdated: now },
|
|
];
|
|
}
|
|
|
|
this.loading = false;
|
|
this.render();
|
|
}
|
|
|
|
/** Compute aggregate pool stats across all positions */
|
|
private computePoolStats() {
|
|
const reinvestApy = this.reinvestmentPositions.length > 0
|
|
? this.reinvestmentPositions.reduce((s, r) => s + r.apy, 0) / this.reinvestmentPositions.length : 4.0;
|
|
let totalBorrowed = 0, totalRepaid = 0, totalReinvested = 0, totalLoanEarnings = 0, totalReinvestEarnings = 0;
|
|
for (const m of this.mortgagePositions) {
|
|
if (m.status !== 'active' && m.status !== 'paid-off') continue;
|
|
const me = Math.floor((Date.now() - m.startDate) / (86400000 * 30));
|
|
const paid = m.monthlyPayment * me;
|
|
const intPaid = paid - (paid * m.principal / (m.monthlyPayment * m.termMonths));
|
|
const prinRepaid = Math.min(paid - intPaid, m.principal);
|
|
const idle = prinRepaid * 0.6;
|
|
totalBorrowed += m.principal;
|
|
totalRepaid += prinRepaid;
|
|
totalReinvested += idle;
|
|
totalLoanEarnings += intPaid;
|
|
totalReinvestEarnings += idle * (reinvestApy / 100) * (me / 12);
|
|
}
|
|
const deployed = this.reinvestmentPositions.reduce((s, r) => s + r.deposited, 0);
|
|
const yieldValue = this.reinvestmentPositions.reduce((s, r) => s + (r.currentValue - r.deposited), 0);
|
|
return { totalBorrowed, totalRepaid, totalReinvested, totalLoanEarnings, totalReinvestEarnings, deployed, yieldValue, reinvestApy };
|
|
}
|
|
|
|
private renderMortgageTab(): string {
|
|
if (this.loading) return '<div class="flows-loading">Loading mortgage data...</div>';
|
|
|
|
const pool = this.computePoolStats();
|
|
const avgApy = this.reinvestmentPositions.length > 0
|
|
? this.reinvestmentPositions.reduce((s, r) => s + r.apy, 0) / this.reinvestmentPositions.length : 0;
|
|
|
|
const selectedLender = this.selectedLenderId
|
|
? this.mortgagePositions.find(m => m.id === this.selectedLenderId) || null
|
|
: null;
|
|
|
|
return `
|
|
<div class="mortgage-tab" style="padding: 24px; max-width: 1200px; margin: 0 auto;">
|
|
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 24px;">
|
|
<a href="/${this.space === 'demo' ? 'demo/' : ''}rflows" style="color: var(--rs-text-secondary); text-decoration: none; font-size: 14px;">← Back to Flows</a>
|
|
<h2 style="margin: 0; font-size: 24px; color: var(--rs-text-primary);">rMortgage</h2>
|
|
<span style="background: rgba(16,185,129,0.15); color: #10b981; padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 600;">BETA</span>
|
|
</div>
|
|
|
|
<!-- Pool Summary — clickable to toggle aggregate view -->
|
|
<div data-action="toggle-pool" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; cursor: pointer;" title="Click to view aggregate pool breakdown">
|
|
${this.renderPoolCard('Total Borrowed', this.fmtUsd(pool.totalBorrowed), `${this.mortgagePositions.filter(m => m.status === 'active').length} active positions`, '#3b82f6')}
|
|
${this.renderPoolCard('Total Repaid', this.fmtUsd(pool.totalRepaid), `${Math.round(pool.totalRepaid / (pool.totalBorrowed || 1) * 100)}% of principal`, '#60a5fa')}
|
|
${this.renderPoolCard('Reinvested', this.fmtUsd(pool.totalReinvested), `Earning ${avgApy.toFixed(1)}% APY`, '#10b981')}
|
|
${this.renderPoolCard('Total Earnings', this.fmtUsd(pool.totalLoanEarnings + pool.totalReinvestEarnings), `Interest + yield`, '#f59e0b')}
|
|
</div>
|
|
|
|
${this.showPoolOverview ? this.renderPoolOverview(pool) : ''}
|
|
|
|
<!-- Active Mortgages -->
|
|
<div style="background: var(--rs-surface, #1a1a2e); border-radius: 12px; padding: 20px; margin-bottom: 24px; border: 1px solid var(--rs-border, #2a2a3e);">
|
|
<h3 style="margin: 0 0 16px; font-size: 16px; color: var(--rs-text-primary);">Active Mortgages</h3>
|
|
<div style="overflow-x: auto;">
|
|
<table style="width: 100%; border-collapse: collapse; font-size: 14px;">
|
|
<thead>
|
|
<tr style="border-bottom: 1px solid var(--rs-border, #2a2a3e); color: var(--rs-text-secondary); text-align: left;">
|
|
<th style="padding: 8px 12px;">Borrower</th>
|
|
<th style="padding: 8px 12px;">Principal</th>
|
|
<th style="padding: 8px 12px;">Rate</th>
|
|
<th style="padding: 8px 12px;">Term</th>
|
|
<th style="padding: 8px 12px;">Monthly</th>
|
|
<th style="padding: 8px 12px;">Trust</th>
|
|
<th style="padding: 8px 12px;">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${this.mortgagePositions.map(m => this.renderMortgageRow(m)).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
${selectedLender ? this.renderLenderDetail(selectedLender) : ''}
|
|
|
|
<!-- Borrower Options -->
|
|
${this.renderBorrowerOptions()}
|
|
|
|
<!-- Reinvestment & Rates -->
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 24px;">
|
|
<div style="background: var(--rs-surface, #1a1a2e); border-radius: 12px; padding: 20px; border: 1px solid var(--rs-border, #2a2a3e);">
|
|
<h3 style="margin: 0 0 16px; font-size: 16px; color: var(--rs-text-primary);">Reinvestment Positions</h3>
|
|
${this.reinvestmentPositions.map(r => `
|
|
<div style="display: flex; justify-content: space-between; align-items: center; padding: 12px 0; border-bottom: 1px solid var(--rs-border, #2a2a3e);">
|
|
<div>
|
|
<div style="font-weight: 600; color: var(--rs-text-primary);">${this.esc(r.protocol)}</div>
|
|
<div style="font-size: 12px; color: var(--rs-text-secondary);">${this.esc(r.chain)} · ${this.esc(r.asset)}</div>
|
|
</div>
|
|
<div style="text-align: right;">
|
|
<div style="color: #10b981; font-weight: 600;">${r.apy.toFixed(2)}% APY</div>
|
|
<div style="font-size: 12px; color: var(--rs-text-secondary);">${this.fmtUsd(r.deposited)} → ${this.fmtUsd(r.currentValue)}</div>
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
${this.reinvestmentPositions.length === 0 ? '<div style="color: var(--rs-text-secondary); font-size: 14px;">No reinvestment positions yet</div>' : ''}
|
|
</div>
|
|
<div style="background: var(--rs-surface, #1a1a2e); border-radius: 12px; padding: 20px; border: 1px solid var(--rs-border, #2a2a3e);">
|
|
<h3 style="margin: 0 0 16px; font-size: 16px; color: var(--rs-text-primary);">Live DeFi Rates</h3>
|
|
${this.liveRates.map(r => `
|
|
<div style="display: flex; justify-content: space-between; align-items: center; padding: 12px 0; border-bottom: 1px solid var(--rs-border, #2a2a3e);">
|
|
<div>
|
|
<div style="font-weight: 600; color: var(--rs-text-primary);">${this.esc(r.protocol)}</div>
|
|
<div style="font-size: 12px; color: var(--rs-text-secondary);">${this.esc(r.chain)} · ${this.esc(r.asset)}</div>
|
|
</div>
|
|
<div style="text-align: right;">
|
|
${r.apy !== null
|
|
? `<div style="color: #10b981; font-weight: 600;">${r.apy.toFixed(2)}% APY</div>`
|
|
: `<div style="color: var(--rs-text-secondary);">${r.error || 'Unavailable'}</div>`
|
|
}
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
${this.liveRates.length === 0 ? '<div style="color: var(--rs-text-secondary); font-size: 14px;">Fetching rates...</div>' : ''}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Projection Calculator -->
|
|
<div style="background: var(--rs-surface, #1a1a2e); border-radius: 12px; padding: 20px; border: 1px solid var(--rs-border, #2a2a3e);">
|
|
<h3 style="margin: 0 0 16px; font-size: 16px; color: var(--rs-text-primary);">Yield Projection Calculator</h3>
|
|
<div style="display: grid; grid-template-columns: repeat(3, 1fr) auto; gap: 16px; align-items: end;">
|
|
<div>
|
|
<label style="display: block; font-size: 12px; color: var(--rs-text-secondary); margin-bottom: 4px;">Deposit Amount (USDC)</label>
|
|
<input type="number" data-proj="amount" value="${this.projCalcAmount}" style="width: 100%; padding: 8px 12px; background: var(--rs-bg, #0f0f1a); border: 1px solid var(--rs-border, #2a2a3e); border-radius: 8px; color: var(--rs-text-primary); font-size: 14px;">
|
|
</div>
|
|
<div>
|
|
<label style="display: block; font-size: 12px; color: var(--rs-text-secondary); margin-bottom: 4px;">APY (%)</label>
|
|
<input type="number" data-proj="apy" value="${this.projCalcApy}" step="0.1" style="width: 100%; padding: 8px 12px; background: var(--rs-bg, #0f0f1a); border: 1px solid var(--rs-border, #2a2a3e); border-radius: 8px; color: var(--rs-text-primary); font-size: 14px;">
|
|
</div>
|
|
<div>
|
|
<label style="display: block; font-size: 12px; color: var(--rs-text-secondary); margin-bottom: 4px;">Duration (months)</label>
|
|
<input type="number" data-proj="months" value="${this.projCalcMonths}" style="width: 100%; padding: 8px 12px; background: var(--rs-bg, #0f0f1a); border: 1px solid var(--rs-border, #2a2a3e); border-radius: 8px; color: var(--rs-text-primary); font-size: 14px;">
|
|
</div>
|
|
<button data-action="calc-projection" style="padding: 8px 20px; background: #3b82f6; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 600;">Calculate</button>
|
|
</div>
|
|
<div id="projection-result" style="margin-top: 16px; padding: 16px; background: var(--rs-bg, #0f0f1a); border-radius: 8px; display: ${this.projCalcAmount > 0 ? 'block' : 'none'};">
|
|
${this.renderProjection()}
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
private renderPoolOverview(pool: ReturnType<typeof FolkFlowsApp.prototype.computePoolStats>): string {
|
|
const total = pool.totalBorrowed || 1;
|
|
const repaidPct = (pool.totalRepaid / total) * 100;
|
|
const reinvestedPct = (pool.totalReinvested / total) * 100;
|
|
const outstandingPct = Math.max(100 - repaidPct, 0);
|
|
const totalEarnings = pool.totalLoanEarnings + pool.totalReinvestEarnings;
|
|
// Earnings comparison — show reinvestment dominance
|
|
const loanPct = totalEarnings > 0 ? (pool.totalLoanEarnings / totalEarnings) * 100 : 50;
|
|
const reinvPct = totalEarnings > 0 ? (pool.totalReinvestEarnings / totalEarnings) * 100 : 50;
|
|
|
|
return `
|
|
<div style="background: var(--rs-surface, #1a1a2e); border-radius: 12px; padding: 20px; margin-bottom: 24px; border: 1px solid #8b5cf6;">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
|
<h3 style="margin: 0; font-size: 16px; color: var(--rs-text-primary);">Aggregate Pool Breakdown</h3>
|
|
<button data-action="close-pool" style="background: none; border: none; color: var(--rs-text-secondary); cursor: pointer; font-size: 18px;">×</button>
|
|
</div>
|
|
<div style="display: grid; grid-template-columns: 180px 1fr; gap: 24px;">
|
|
<!-- Aggregate vessel -->
|
|
<div style="display: flex; flex-direction: column; align-items: center;">
|
|
<svg viewBox="0 0 120 200" width="140" height="200" style="margin-bottom: 8px;">
|
|
<rect x="10" y="10" width="100" height="180" rx="12" fill="none" stroke="var(--rs-text-secondary)" stroke-width="2" opacity="0.3"/>
|
|
<!-- Outstanding (empty) — top -->
|
|
<rect x="12" y="12" width="96" height="${outstandingPct * 1.76}" rx="10" fill="rgba(255,255,255,0.04)"/>
|
|
<!-- Repaid (blue) — fills from bottom -->
|
|
<rect x="12" y="${12 + outstandingPct * 1.76}" width="96" height="${(repaidPct - reinvestedPct) * 1.76}" fill="rgba(59,130,246,0.4)"/>
|
|
<!-- Reinvested (green) — bottom portion of repaid -->
|
|
<rect x="12" y="${12 + (100 - reinvestedPct) * 1.76}" width="96" height="${reinvestedPct * 1.76}" rx="10" fill="rgba(16,185,129,0.5)"/>
|
|
${outstandingPct > 12 ? `<text x="60" y="${12 + outstandingPct * 0.88}" text-anchor="middle" fill="var(--rs-text-secondary)" font-size="10">${Math.round(outstandingPct)}% Outstanding</text>` : ''}
|
|
${(repaidPct - reinvestedPct) > 12 ? `<text x="60" y="${12 + outstandingPct * 1.76 + (repaidPct - reinvestedPct) * 0.88}" text-anchor="middle" fill="#60a5fa" font-size="10" font-weight="600">${Math.round(repaidPct - reinvestedPct)}% Repaid</text>` : ''}
|
|
${reinvestedPct > 8 ? `<text x="60" y="${12 + (100 - reinvestedPct * 0.5) * 1.76}" text-anchor="middle" fill="#10b981" font-size="10" font-weight="600">${Math.round(reinvestedPct)}% Reinvested</text>` : ''}
|
|
</svg>
|
|
<div style="font-size: 11px; color: var(--rs-text-secondary);">${this.fmtUsd(pool.totalBorrowed)} total</div>
|
|
</div>
|
|
|
|
<!-- Aggregate stats + earnings comparison -->
|
|
<div>
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; margin-bottom: 20px;">
|
|
<div style="padding: 12px; background: var(--rs-bg, #0f0f1a); border-radius: 8px;">
|
|
<div style="font-size: 11px; color: var(--rs-text-secondary); text-transform: uppercase;">Outstanding</div>
|
|
<div style="font-size: 18px; font-weight: 700; color: var(--rs-text-primary);">${this.fmtUsd(pool.totalBorrowed - pool.totalRepaid)}</div>
|
|
</div>
|
|
<div style="padding: 12px; background: var(--rs-bg, #0f0f1a); border-radius: 8px;">
|
|
<div style="font-size: 11px; color: var(--rs-text-secondary); text-transform: uppercase;">Repaid</div>
|
|
<div style="font-size: 18px; font-weight: 700; color: #60a5fa;">${this.fmtUsd(pool.totalRepaid)}</div>
|
|
</div>
|
|
<div style="padding: 12px; background: var(--rs-bg, #0f0f1a); border-radius: 8px;">
|
|
<div style="font-size: 11px; color: var(--rs-text-secondary); text-transform: uppercase;">Reinvested</div>
|
|
<div style="font-size: 18px; font-weight: 700; color: #10b981;">${this.fmtUsd(pool.totalReinvested)}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Earnings comparison bar -->
|
|
<div style="margin-bottom: 8px; font-size: 13px; font-weight: 600; color: var(--rs-text-primary);">Earnings Breakdown: ${this.fmtUsd(totalEarnings)}</div>
|
|
<div style="height: 32px; border-radius: 8px; overflow: hidden; display: flex; margin-bottom: 8px;">
|
|
<div style="width: ${loanPct}%; background: #f59e0b; display: flex; align-items: center; justify-content: center; font-size: 11px; color: white; font-weight: 600;">${loanPct > 15 ? 'Interest ' + this.fmtUsd(pool.totalLoanEarnings) : ''}</div>
|
|
<div style="width: ${reinvPct}%; background: #10b981; display: flex; align-items: center; justify-content: center; font-size: 11px; color: white; font-weight: 600;">${reinvPct > 15 ? 'Reinvestment ' + this.fmtUsd(pool.totalReinvestEarnings) : ''}</div>
|
|
</div>
|
|
<div style="display: flex; gap: 16px; font-size: 12px;">
|
|
<span style="display: flex; align-items: center; gap: 4px;"><span style="width: 10px; height: 10px; border-radius: 2px; background: #f59e0b; display: inline-block;"></span> Loan interest: ${this.fmtUsd(pool.totalLoanEarnings)}</span>
|
|
<span style="display: flex; align-items: center; gap: 4px;"><span style="width: 10px; height: 10px; border-radius: 2px; background: #10b981; display: inline-block;"></span> Reinvestment yield: ${this.fmtUsd(pool.totalReinvestEarnings)}</span>
|
|
</div>
|
|
${pool.totalReinvestEarnings > pool.totalLoanEarnings
|
|
? `<div style="margin-top: 12px; padding: 10px 14px; background: rgba(16,185,129,0.1); border: 1px solid rgba(16,185,129,0.3); border-radius: 8px; font-size: 12px; color: #10b981;">Reinvesting returns earns ${((pool.totalReinvestEarnings / (pool.totalLoanEarnings || 1) - 1) * 100).toFixed(0)}% more than interest alone. Compounding idle capital is the most profitable strategy.</div>`
|
|
: ''}
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
private renderPoolCard(title: string, value: string, subtitle: string, color: string): string {
|
|
return `
|
|
<div style="background: var(--rs-surface, #1a1a2e); border-radius: 12px; padding: 20px; border: 1px solid var(--rs-border, #2a2a3e); border-top: 3px solid ${color};">
|
|
<div style="font-size: 12px; color: var(--rs-text-secondary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px;">${title}</div>
|
|
<div style="font-size: 24px; font-weight: 700; color: var(--rs-text-primary); margin-bottom: 2px;">${value}</div>
|
|
<div style="font-size: 12px; color: var(--rs-text-secondary);">${subtitle}</div>
|
|
</div>`;
|
|
}
|
|
|
|
private renderMortgageRow(m: MortgagePosition): string {
|
|
const statusColors: Record<string, string> = { active: '#10b981', 'paid-off': '#3b82f6', defaulted: '#ef4444', pending: '#f59e0b' };
|
|
const trustColor = m.trustScore >= 90 ? '#10b981' : m.trustScore >= 75 ? '#f59e0b' : '#ef4444';
|
|
const isSelected = this.selectedLenderId === m.id;
|
|
return `
|
|
<tr data-mortgage-id="${m.id}" style="border-bottom: 1px solid var(--rs-border, #2a2a3e); cursor: pointer; background: ${isSelected ? 'rgba(59,130,246,0.1)' : 'transparent'};" class="mortgage-row">
|
|
<td style="padding: 10px 12px; color: var(--rs-text-primary); font-weight: 500;">${this.esc(m.borrower)}</td>
|
|
<td style="padding: 10px 12px; color: var(--rs-text-primary);">${this.fmtUsd(m.principal)}</td>
|
|
<td style="padding: 10px 12px; color: var(--rs-text-primary);">${m.interestRate}%</td>
|
|
<td style="padding: 10px 12px; color: var(--rs-text-secondary);">${m.termMonths}mo</td>
|
|
<td style="padding: 10px 12px; color: var(--rs-text-primary);">${this.fmtUsd(m.monthlyPayment)}</td>
|
|
<td style="padding: 10px 12px;"><span style="color: ${trustColor}; font-weight: 600;">${m.trustScore}</span></td>
|
|
<td style="padding: 10px 12px;"><span style="background: ${statusColors[m.status] || '#666'}22; color: ${statusColors[m.status] || '#666'}; padding: 2px 8px; border-radius: 8px; font-size: 12px; font-weight: 600;">${m.status}</span></td>
|
|
</tr>`;
|
|
}
|
|
|
|
private renderLenderDetail(m: MortgagePosition): string {
|
|
const monthsElapsed = Math.floor((Date.now() - m.startDate) / (86400000 * 30));
|
|
const totalPaid = m.monthlyPayment * monthsElapsed;
|
|
const interestPaid = totalPaid - (totalPaid * m.principal / (m.monthlyPayment * m.termMonths));
|
|
const principalRepaid = Math.min(totalPaid - interestPaid, m.principal);
|
|
|
|
const idleCapital = principalRepaid * 0.6;
|
|
const reinvestApy = this.reinvestmentPositions.length > 0
|
|
? this.reinvestmentPositions.reduce((s, r) => s + r.apy, 0) / this.reinvestmentPositions.length : 4.0;
|
|
const reinvestEarnings = idleCapital * (reinvestApy / 100) * (monthsElapsed / 12);
|
|
const loanEarnings = interestPaid;
|
|
const totalEarnings = loanEarnings + reinvestEarnings;
|
|
|
|
// Vessel: outstanding at top (empty), repaid-idle in middle (blue), reinvested at bottom (green)
|
|
const total = m.principal || 1;
|
|
const outstandingPct = Math.max(((m.principal - principalRepaid) / total) * 100, 0);
|
|
const repaidIdlePct = ((principalRepaid - idleCapital) / total) * 100;
|
|
const reinvestedPct = (idleCapital / total) * 100;
|
|
|
|
// Earnings comparison
|
|
const earningsTotal = totalEarnings || 1;
|
|
const loanPct = (loanEarnings / earningsTotal) * 100;
|
|
const reinvPct = (reinvestEarnings / earningsTotal) * 100;
|
|
|
|
// Project: what if they reinvest ALL repaid principal vs none
|
|
const noReinvestEarnings = loanEarnings;
|
|
const fullReinvestEarnings = loanEarnings + (principalRepaid * (reinvestApy / 100) * (monthsElapsed / 12));
|
|
|
|
return `
|
|
<div style="background: var(--rs-surface, #1a1a2e); border-radius: 12px; padding: 20px; margin-bottom: 24px; border: 1px solid #3b82f6;">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
|
<h3 style="margin: 0; font-size: 16px; color: var(--rs-text-primary);">Lender Pool: ${this.esc(m.borrower)}</h3>
|
|
<button data-action="close-lender" style="background: none; border: none; color: var(--rs-text-secondary); cursor: pointer; font-size: 18px;">×</button>
|
|
</div>
|
|
<div style="display: grid; grid-template-columns: 160px 1fr; gap: 24px;">
|
|
<!-- Vessel -->
|
|
<div style="display: flex; flex-direction: column; align-items: center;">
|
|
<svg viewBox="0 0 120 200" width="130" height="200" style="margin-bottom: 8px;">
|
|
<rect x="10" y="10" width="100" height="180" rx="12" fill="none" stroke="var(--rs-text-secondary)" stroke-width="2" opacity="0.3"/>
|
|
<!-- Outstanding (empty) -->
|
|
<rect x="12" y="12" width="96" height="${outstandingPct * 1.76}" rx="10" fill="rgba(255,255,255,0.04)"/>
|
|
<!-- Repaid idle (blue) -->
|
|
<rect x="12" y="${12 + outstandingPct * 1.76}" width="96" height="${repaidIdlePct * 1.76}" fill="rgba(59,130,246,0.4)"/>
|
|
<!-- Reinvested (green) -->
|
|
<rect x="12" y="${12 + (outstandingPct + repaidIdlePct) * 1.76}" width="96" height="${reinvestedPct * 1.76}" rx="10" fill="rgba(16,185,129,0.5)"/>
|
|
${outstandingPct > 15 ? `<text x="60" y="${12 + outstandingPct * 0.88}" text-anchor="middle" fill="var(--rs-text-secondary)" font-size="10">${Math.round(outstandingPct)}%</text>` : ''}
|
|
${repaidIdlePct > 12 ? `<text x="60" y="${12 + outstandingPct * 1.76 + repaidIdlePct * 0.88}" text-anchor="middle" fill="#60a5fa" font-size="10" font-weight="600">${Math.round(repaidIdlePct)}% Idle</text>` : ''}
|
|
${reinvestedPct > 10 ? `<text x="60" y="${12 + (outstandingPct + repaidIdlePct) * 1.76 + reinvestedPct * 0.88}" text-anchor="middle" fill="#10b981" font-size="10" font-weight="600">${Math.round(reinvestedPct)}% DeFi</text>` : ''}
|
|
</svg>
|
|
<div style="font-size: 11px; color: var(--rs-text-secondary);">${this.fmtUsd(m.principal)} pool</div>
|
|
</div>
|
|
|
|
<div>
|
|
<!-- Stats row -->
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px; margin-bottom: 16px;">
|
|
<div style="padding: 10px; background: var(--rs-bg, #0f0f1a); border-radius: 8px;">
|
|
<div style="font-size: 10px; color: var(--rs-text-secondary); text-transform: uppercase;">Repaid</div>
|
|
<div style="font-size: 16px; font-weight: 700; color: #60a5fa;">${this.fmtUsd(principalRepaid)}</div>
|
|
</div>
|
|
<div style="padding: 10px; background: var(--rs-bg, #0f0f1a); border-radius: 8px;">
|
|
<div style="font-size: 10px; color: var(--rs-text-secondary); text-transform: uppercase;">Reinvested</div>
|
|
<div style="font-size: 16px; font-weight: 700; color: #10b981;">${this.fmtUsd(idleCapital)}</div>
|
|
</div>
|
|
<div style="padding: 10px; background: var(--rs-bg, #0f0f1a); border-radius: 8px;">
|
|
<div style="font-size: 10px; color: var(--rs-text-secondary); text-transform: uppercase;">Outstanding</div>
|
|
<div style="font-size: 16px; font-weight: 700; color: var(--rs-text-primary);">${this.fmtUsd(m.principal - principalRepaid)}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Earnings comparison bar -->
|
|
<div style="font-size: 13px; font-weight: 600; color: var(--rs-text-primary); margin-bottom: 6px;">Earnings: ${this.fmtUsd(totalEarnings)} <span style="font-size: 11px; color: var(--rs-text-secondary); font-weight: 400;">${monthsElapsed}mo of ${m.termMonths}mo</span></div>
|
|
<div style="height: 28px; border-radius: 6px; overflow: hidden; display: flex; margin-bottom: 6px;">
|
|
<div style="width: ${loanPct}%; background: #f59e0b; display: flex; align-items: center; justify-content: center; font-size: 10px; color: white; font-weight: 600;">${loanPct > 20 ? this.fmtUsd(loanEarnings) : ''}</div>
|
|
<div style="width: ${reinvPct}%; background: #10b981; display: flex; align-items: center; justify-content: center; font-size: 10px; color: white; font-weight: 600;">${reinvPct > 20 ? this.fmtUsd(reinvestEarnings) : ''}</div>
|
|
</div>
|
|
<div style="display: flex; gap: 14px; font-size: 11px; color: var(--rs-text-secondary); margin-bottom: 12px;">
|
|
<span><span style="width: 8px; height: 8px; border-radius: 2px; background: #f59e0b; display: inline-block; margin-right: 4px;"></span>Interest ${this.fmtUsd(loanEarnings)}</span>
|
|
<span><span style="width: 8px; height: 8px; border-radius: 2px; background: #10b981; display: inline-block; margin-right: 4px;"></span>Reinvestment ${this.fmtUsd(reinvestEarnings)}</span>
|
|
</div>
|
|
|
|
<!-- Reinvestment advantage callout -->
|
|
<div style="padding: 10px 14px; background: rgba(16,185,129,0.08); border: 1px solid rgba(16,185,129,0.25); border-radius: 8px; font-size: 12px;">
|
|
<div style="color: #10b981; font-weight: 600; margin-bottom: 4px;">Reinvestment advantage</div>
|
|
<div style="color: var(--rs-text-secondary);">
|
|
Without reinvestment: <strong style="color: var(--rs-text-primary);">${this.fmtUsd(noReinvestEarnings)}</strong>
|
|
→ With full reinvestment: <strong style="color: #10b981;">${this.fmtUsd(fullReinvestEarnings)}</strong>
|
|
<span style="color: #10b981; font-weight: 600;"> (+${((fullReinvestEarnings / (noReinvestEarnings || 1) - 1) * 100).toFixed(0)}%)</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
private renderBorrowerOptions(): string {
|
|
const termOptions = [60, 120, 180]; // 5yr, 10yr, 15yr
|
|
const budget = this.borrowerMonthlyBudget;
|
|
|
|
// Build lender pool: each active lender has available capital (simulated from repaid principal)
|
|
const lenders = this.mortgagePositions
|
|
.filter(m => m.status === 'active')
|
|
.map(m => {
|
|
const monthsElapsed = Math.floor((Date.now() - m.startDate) / (86400000 * 30));
|
|
const totalPaid = m.monthlyPayment * monthsElapsed;
|
|
const principalFraction = m.principal / (m.monthlyPayment * m.termMonths);
|
|
const principalRepaid = Math.min(totalPaid * principalFraction, m.principal);
|
|
// Available = repaid principal that can be re-lent
|
|
const available = Math.max(principalRepaid * 0.8, m.principal * 0.15); // At least 15% of pool
|
|
return { id: m.id, name: m.borrower, available: Math.round(available), trustScore: m.trustScore };
|
|
})
|
|
.sort((a, b) => b.trustScore - a.trustScore); // highest trust first
|
|
|
|
const totalAvailable = lenders.reduce((s, l) => s + l.available, 0);
|
|
|
|
// For each term, compute max principal borrower can afford at a blended rate
|
|
const avgRate = this.mortgagePositions.length > 0
|
|
? this.mortgagePositions.reduce((s, m) => s + m.interestRate, 0) / this.mortgagePositions.length
|
|
: 4.0;
|
|
|
|
const options = termOptions.map(months => {
|
|
const monthlyRate = avgRate / 100 / 12;
|
|
// PV of annuity: principal = payment * ((1 - (1+r)^-n) / r)
|
|
const maxPrincipal = monthlyRate > 0
|
|
? budget * (1 - Math.pow(1 + monthlyRate, -months)) / monthlyRate
|
|
: budget * months;
|
|
const principal = Math.min(Math.round(maxPrincipal), totalAvailable);
|
|
const actualMonthly = monthlyRate > 0
|
|
? principal * (monthlyRate * Math.pow(1 + monthlyRate, months)) / (Math.pow(1 + monthlyRate, months) - 1)
|
|
: principal / months;
|
|
|
|
// Fill from lenders in order
|
|
let remaining = principal;
|
|
const fills: { name: string; amount: number; pct: number; color: string }[] = [];
|
|
const fillColors = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899', '#06b6d4'];
|
|
for (let i = 0; i < lenders.length && remaining > 0; i++) {
|
|
const contribution = Math.min(lenders[i].available, remaining);
|
|
if (contribution <= 0) continue;
|
|
fills.push({
|
|
name: lenders[i].name,
|
|
amount: contribution,
|
|
pct: principal > 0 ? (contribution / principal) * 100 : 0,
|
|
color: fillColors[i % fillColors.length],
|
|
});
|
|
remaining -= contribution;
|
|
}
|
|
const funded = principal - remaining;
|
|
const fundedPct = principal > 0 ? (funded / principal) * 100 : 0;
|
|
|
|
return { months, principal, actualMonthly: Math.round(actualMonthly * 100) / 100, rate: avgRate, fills, funded, fundedPct };
|
|
});
|
|
|
|
return `
|
|
<div style="background: var(--rs-surface, #1a1a2e); border-radius: 12px; padding: 20px; margin-bottom: 24px; border: 1px solid var(--rs-border, #2a2a3e);">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
|
<h3 style="margin: 0; font-size: 16px; color: var(--rs-text-primary);">Borrower Options</h3>
|
|
<div style="display: flex; align-items: center; gap: 12px;">
|
|
<label style="font-size: 13px; color: var(--rs-text-secondary);">Max monthly payment:</label>
|
|
<div style="display: flex; align-items: center; gap: 4px;">
|
|
<span style="color: var(--rs-text-secondary);">$</span>
|
|
<input type="number" data-borrower="budget" value="${budget}" step="100" min="100"
|
|
style="width: 110px; padding: 6px 10px; background: var(--rs-bg, #0f0f1a); border: 1px solid var(--rs-border, #2a2a3e); border-radius: 6px; color: var(--rs-text-primary); font-size: 14px; font-weight: 600;">
|
|
<span style="color: var(--rs-text-secondary); font-size: 13px;">/mo</span>
|
|
</div>
|
|
<button data-action="update-borrower" style="padding: 6px 14px; background: #3b82f6; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 13px;">Update</button>
|
|
</div>
|
|
</div>
|
|
<div style="font-size: 12px; color: var(--rs-text-secondary); margin-bottom: 16px;">
|
|
Avg pool rate: ${avgRate.toFixed(1)}% · Total lender capital available: ${this.fmtUsd(totalAvailable)} · Lenders fill loans in trust-score order
|
|
</div>
|
|
<div style="display: flex; flex-direction: column; gap: 12px;">
|
|
${options.map(o => `
|
|
<div style="padding: 14px 16px; background: var(--rs-bg, #0f0f1a); border-radius: 10px; border: 1px solid var(--rs-border, #2a2a3e);">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
|
<div style="display: flex; align-items: baseline; gap: 10px;">
|
|
<span style="font-size: 18px; font-weight: 700; color: var(--rs-text-primary);">${o.months / 12}yr</span>
|
|
<span style="font-size: 13px; color: var(--rs-text-secondary);">${o.months} months @ ${o.rate.toFixed(1)}%</span>
|
|
</div>
|
|
<div style="text-align: right;">
|
|
<span style="font-size: 18px; font-weight: 700; color: var(--rs-text-primary);">${this.fmtUsd(o.principal)}</span>
|
|
<span style="font-size: 12px; color: var(--rs-text-secondary); margin-left: 6px;">${this.fmtUsd(o.actualMonthly)}/mo</span>
|
|
</div>
|
|
</div>
|
|
<!-- Lender fill bar -->
|
|
<div style="position: relative; height: 28px; background: rgba(255,255,255,0.03); border-radius: 6px; overflow: hidden; margin-bottom: 6px;">
|
|
<div style="display: flex; height: 100%;">
|
|
${o.fills.map(f => `
|
|
<div style="width: ${f.pct}%; background: ${f.color}; display: flex; align-items: center; justify-content: center; font-size: 10px; color: white; font-weight: 600; white-space: nowrap; overflow: hidden; min-width: ${f.pct > 8 ? '0' : '0'}px;"
|
|
title="${f.name}: ${this.fmtUsd(f.amount)} (${Math.round(f.pct)}%)">
|
|
${f.pct > 12 ? f.name : ''}
|
|
</div>
|
|
`).join('')}
|
|
${o.fundedPct < 100 ? `<div style="flex: 1; display: flex; align-items: center; justify-content: center; font-size: 10px; color: var(--rs-text-secondary);">${Math.round(100 - o.fundedPct)}% unfunded</div>` : ''}
|
|
</div>
|
|
</div>
|
|
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
|
${o.fills.map(f => `
|
|
<span style="font-size: 11px; color: var(--rs-text-secondary); display: flex; align-items: center; gap: 4px;">
|
|
<span style="width: 8px; height: 8px; border-radius: 2px; background: ${f.color}; display: inline-block;"></span>
|
|
${this.esc(f.name)} ${this.fmtUsd(f.amount)}
|
|
</span>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
private renderProjection(): string {
|
|
const monthlyRate = this.projCalcApy / 100 / 12;
|
|
const finalValue = this.projCalcAmount * Math.pow(1 + monthlyRate, this.projCalcMonths);
|
|
const earned = finalValue - this.projCalcAmount;
|
|
return `
|
|
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px;">
|
|
<div>
|
|
<div style="font-size: 12px; color: var(--rs-text-secondary);">Initial Deposit</div>
|
|
<div style="font-size: 20px; font-weight: 700; color: var(--rs-text-primary);">${this.fmtUsd(this.projCalcAmount)}</div>
|
|
</div>
|
|
<div>
|
|
<div style="font-size: 12px; color: var(--rs-text-secondary);">Projected Value</div>
|
|
<div style="font-size: 20px; font-weight: 700; color: #10b981;">${this.fmtUsd(finalValue)}</div>
|
|
</div>
|
|
<div>
|
|
<div style="font-size: 12px; color: var(--rs-text-secondary);">Yield Earned</div>
|
|
<div style="font-size: 20px; font-weight: 700; color: #f59e0b;">${this.fmtUsd(earned)}</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
private fmtUsd(v: number): string {
|
|
return '$' + v.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
|
}
|
|
|
|
private esc(s: string): string {
|
|
return s
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
}
|
|
|
|
customElements.define("folk-flows-app", FolkFlowsApp);
|