diff --git a/modules/rfunds/components/folk-funds-app.ts b/modules/rfunds/components/folk-funds-app.ts index 955de5a..2584f7d 100644 --- a/modules/rfunds/components/folk-funds-app.ts +++ b/modules/rfunds/components/folk-funds-app.ts @@ -648,7 +648,7 @@ class FolkFundsApp extends HTMLElement { private getNodeSize(n: FlowNode): { w: number; h: number } { if (n.type === "source") return { w: 200, h: 60 }; - if (n.type === "funnel") return { w: 220, h: 160 }; + if (n.type === "funnel") return { w: 220, h: 180 }; return { w: 200, h: 100 }; // outcome } @@ -916,17 +916,65 @@ class FolkFundsApp extends HTMLElement { document.addEventListener("keydown", this._boundKeyDown); } + // ─── Inflow satisfaction computation ───────────────── + + private computeInflowSatisfaction(): Map { + const result = new Map(); + + for (const n of this.nodes) { + if (n.type === "funnel") { + const d = n.data as FunnelNodeData; + const needed = d.inflowRate || 1; + let actual = 0; + // Sum source→funnel allocations + for (const src of this.nodes) { + if (src.type === "source") { + const sd = src.data as SourceNodeData; + for (const alloc of sd.targetAllocations) { + if (alloc.targetId === n.id) actual += sd.flowRate * (alloc.percentage / 100); + } + } + // Sum overflow from parent funnels + if (src.type === "funnel" && src.id !== n.id) { + const fd = src.data as FunnelNodeData; + const excess = Math.max(0, fd.currentValue - fd.maxThreshold); + for (const alloc of fd.overflowAllocations) { + if (alloc.targetId === n.id) actual += excess * (alloc.percentage / 100); + } + } + // Sum overflow from parent outcomes + if (src.type === "outcome") { + const od = src.data as OutcomeNodeData; + const excess = Math.max(0, od.fundingReceived - od.fundingTarget); + for (const alloc of (od.overflowAllocations || [])) { + if (alloc.targetId === n.id) actual += excess * (alloc.percentage / 100); + } + } + } + result.set(n.id, { actual, needed, ratio: Math.min(actual / needed, 2) }); + } + if (n.type === "outcome") { + const d = n.data as OutcomeNodeData; + const needed = Math.max(d.fundingTarget, 1); + const actual = d.fundingReceived; + result.set(n.id, { actual, needed, ratio: Math.min(actual / needed, 2) }); + } + } + return result; + } + // ─── Node SVG rendering ─────────────────────────────── private renderAllNodes(): string { - return this.nodes.map((n) => this.renderNodeSvg(n)).join(""); + const satisfaction = this.computeInflowSatisfaction(); + return this.nodes.map((n) => this.renderNodeSvg(n, satisfaction)).join(""); } - private renderNodeSvg(n: FlowNode): string { + private renderNodeSvg(n: FlowNode, satisfaction: Map): string { const sel = this.selectedNodeId === n.id; if (n.type === "source") return this.renderSourceNodeSvg(n, sel); - if (n.type === "funnel") return this.renderFunnelNodeSvg(n, sel); - return this.renderOutcomeNodeSvg(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 { @@ -944,9 +992,9 @@ class FolkFundsApp extends HTMLElement { `; } - private renderFunnelNodeSvg(n: FlowNode, selected: boolean): string { + private renderFunnelNodeSvg(n: FlowNode, selected: boolean, sat?: { actual: number; needed: number; ratio: number }): string { const d = n.data as FunnelNodeData; - const x = n.position.x, y = n.position.y, w = 220, h = 160; + const x = n.position.x, y = n.position.y, w = 220, h = 180; const sufficiency = computeSufficiencyState(d); const isSufficient = sufficiency === "sufficient" || sufficiency === "abundant"; const threshold = d.sufficientThreshold ?? d.maxThreshold; @@ -960,9 +1008,18 @@ class FolkFundsApp extends HTMLElement { : sufficiency === "sufficient" ? "Sufficient" : d.currentValue < d.minThreshold ? "Critical" : "Seeking"; + // Inflow satisfaction bar + const satBarY = 28; + const satBarW = w - 20; + 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="#fbbf24" stroke-width="1"` : ""; + // 3-zone background: drain (red), healthy (blue), overflow (amber) - const zoneH = h - 56; // area for zones (below header, above value text) - const zoneY = 32; + const zoneY = 52; + const zoneH = h - 76; const drainPct = d.minThreshold / (d.maxCapacity || 1); const healthyPct = (d.maxThreshold - d.minThreshold) / (d.maxCapacity || 1); const overflowPct = 1 - drainPct - healthyPct; @@ -979,12 +1036,15 @@ class FolkFundsApp extends HTMLElement { return ` ${isSufficient ? `` : ""} + ${this.esc(d.label)} + ${statusLabel} + + + ${satLabel} - ${this.esc(d.label)} - ${statusLabel} $${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()} @@ -992,7 +1052,7 @@ class FolkFundsApp extends HTMLElement { `; } - private renderOutcomeNodeSvg(n: FlowNode, selected: boolean): string { + private renderOutcomeNodeSvg(n: FlowNode, selected: boolean, sat?: { actual: number; needed: number; ratio: number }): string { const d = n.data as OutcomeNodeData; const x = n.position.x, y = n.position.y, w = 200, h = 100; const fillPct = d.fundingTarget > 0 ? Math.min(1, d.fundingReceived / d.fundingTarget) : 0; @@ -1005,18 +1065,24 @@ class FolkFundsApp extends HTMLElement { const phaseW = (w - 20) / d.phases.length; phaseBars = d.phases.map((p, i) => { const unlocked = d.fundingReceived >= p.fundingThreshold; - return ``; + return ``; }).join(""); - phaseBars += `${d.phases.filter((p) => d.fundingReceived >= p.fundingThreshold).length}/${d.phases.length} phases`; + phaseBars += `${d.phases.filter((p) => d.fundingReceived >= p.fundingThreshold).length}/${d.phases.length} phases`; } + // Enhanced progress bar (8px height, green funded portion + grey gap) + const barW = w - 20; + const barY = 34; + const barH = 8; + const dollarLabel = `${this.formatDollar(d.fundingReceived)} / ${this.formatDollar(d.fundingTarget)}`; + return ` ${this.esc(d.label)} - - - ${Math.round(fillPct * 100)}% — $${Math.floor(d.fundingReceived).toLocaleString()} + + + ${Math.round(fillPct * 100)}% — ${dollarLabel} ${phaseBars} ${this.renderPortsSvg(n)} `; @@ -1037,54 +1103,74 @@ class FolkFundsApp extends HTMLElement { // ─── Edge rendering ─────────────────────────────────── + private formatDollar(amount: number): string { + if (amount >= 1_000_000) return `$${(amount / 1_000_000).toFixed(1)}M`; + if (amount >= 1_000) return `$${(amount / 1_000).toFixed(1)}k`; + return `$${Math.round(amount)}`; + } + private renderAllEdges(): string { - let html = ""; - // Find max flow rate for Sankey width scaling - const maxFlow = Math.max(1, ...this.nodes.filter((n) => n.type === "source").map((n) => (n.data as SourceNodeData).flowRate)); + // First pass: compute actual dollar flow per edge + interface EdgeInfo { + fromNode: FlowNode; + toNode: FlowNode; + fromPort: PortKind; + color: string; + flowAmount: number; + pct: number; + dashed: boolean; + fromId: string; + toId: string; + edgeType: string; + } + const edges: EdgeInfo[] = []; for (const n of this.nodes) { if (n.type === "source") { const d = n.data as SourceNodeData; - const from = this.getPortPosition(n, "outflow"); for (const alloc of d.targetAllocations) { const target = this.nodes.find((t) => t.id === alloc.targetId); if (!target) continue; - const to = this.getPortPosition(target, "inflow"); - const strokeW = Math.max(2, (d.flowRate / maxFlow) * (alloc.percentage / 100) * 12); - html += this.renderEdgePath( - from.x, from.y, to.x, to.y, - alloc.color || "#10b981", strokeW, false, - alloc.percentage, n.id, alloc.targetId, "source", - ); + const flowAmount = d.flowRate * (alloc.percentage / 100); + edges.push({ + fromNode: n, toNode: target, fromPort: "outflow", + color: alloc.color || "#10b981", flowAmount, + pct: alloc.percentage, dashed: false, + fromId: n.id, toId: alloc.targetId, edgeType: "source", + }); } } if (n.type === "funnel") { const d = n.data as FunnelNodeData; - // Overflow edges — from overflow port + // Overflow edges — actual excess flow for (const alloc of d.overflowAllocations) { const target = this.nodes.find((t) => t.id === alloc.targetId); if (!target) continue; - const from = this.getPortPosition(n, "overflow"); - const to = this.getPortPosition(target, "inflow"); - const strokeW = Math.max(1.5, (alloc.percentage / 100) * 10); - html += this.renderEdgePath( - from.x, from.y, to.x, to.y, - alloc.color || "#f59e0b", strokeW, true, - alloc.percentage, n.id, alloc.targetId, "overflow", - ); + const excess = Math.max(0, d.currentValue - d.maxThreshold); + const flowAmount = excess * (alloc.percentage / 100); + edges.push({ + fromNode: n, toNode: target, fromPort: "overflow", + color: alloc.color || "#f59e0b", flowAmount, + pct: alloc.percentage, dashed: true, + fromId: n.id, toId: alloc.targetId, edgeType: "overflow", + }); } - // Spending edges — from spending port + // Spending edges — rate-based drain for (const alloc of d.spendingAllocations) { const target = this.nodes.find((t) => t.id === alloc.targetId); if (!target) continue; - const from = this.getPortPosition(n, "spending"); - const to = this.getPortPosition(target, "inflow"); - const strokeW = Math.max(1.5, (alloc.percentage / 100) * 8); - html += this.renderEdgePath( - from.x, from.y, to.x, to.y, - alloc.color || "#8b5cf6", strokeW, false, - alloc.percentage, n.id, alloc.targetId, "spending", - ); + 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: alloc.color || "#8b5cf6", flowAmount, + pct: alloc.percentage, dashed: false, + fromId: n.id, toId: alloc.targetId, edgeType: "spending", + }); } } // Outcome overflow edges @@ -1094,44 +1180,84 @@ class FolkFundsApp extends HTMLElement { for (const alloc of allocs) { const target = this.nodes.find((t) => t.id === alloc.targetId); if (!target) continue; - const from = this.getPortPosition(n, "overflow"); - const to = this.getPortPosition(target, "inflow"); - const strokeW = Math.max(1.5, (alloc.percentage / 100) * 8); - html += this.renderEdgePath( - from.x, from.y, to.x, to.y, - alloc.color || "#f59e0b", strokeW, true, - alloc.percentage, n.id, alloc.targetId, "overflow", - ); + const excess = Math.max(0, d.fundingReceived - d.fundingTarget); + const flowAmount = excess * (alloc.percentage / 100); + edges.push({ + fromNode: n, toNode: target, fromPort: "overflow", + color: alloc.color || "#f59e0b", flowAmount, + pct: alloc.percentage, dashed: true, + fromId: n.id, toId: alloc.targetId, edgeType: "overflow", + }); } } } + + // Find max flow amount for width normalization + const maxFlowAmount = Math.max(1, ...edges.map((e) => e.flowAmount)); + + // Second pass: render edges with normalized widths + let html = ""; + for (const e of edges) { + const from = this.getPortPosition(e.fromNode, e.fromPort); + const to = this.getPortPosition(e.toNode, "inflow"); + const isGhost = e.flowAmount === 0; + const strokeW = isGhost ? 1 : Math.max(1.5, (e.flowAmount / maxFlowAmount) * 14); + 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, + ); + } return html; } private renderEdgePath( x1: number, y1: number, x2: number, y2: number, - color: string, strokeW: number, dashed: boolean, - pct: number, fromId: string, toId: string, edgeType: string, + color: string, strokeW: number, dashed: boolean, ghost: boolean, + label: string, fromId: string, toId: string, edgeType: string, ): string { const cy1 = y1 + (y2 - y1) * 0.4; const cy2 = y1 + (y2 - y1) * 0.6; const midX = (x1 + x2) / 2; const midY = (y1 + y2) / 2; const d = `M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}`; + + if (ghost) { + return ` + + + + ${label} + + + + + + + + + + + `; + } + const animClass = dashed ? "edge-path-overflow" : "edge-path-animated"; + // Wider label box to fit dollar amounts + const labelW = Math.max(68, label.length * 7 + 36); + const halfW = labelW / 2; return ` - - ${pct}% + + ${label} - - + + - - + + + + `; diff --git a/modules/rfunds/components/funds.css b/modules/rfunds/components/funds.css index 2617e5c..1c6db8d 100644 --- a/modules/rfunds/components/funds.css +++ b/modules/rfunds/components/funds.css @@ -334,6 +334,13 @@ .edge-group--highlight path:not(.edge-glow) { stroke-opacity: 1 !important; filter: brightness(1.4); } .edge-group--highlight .edge-glow { stroke-opacity: 0.25 !important; } +/* Ghost edge (zero-flow potential paths) */ +.edge-ghost { pointer-events: none; } + +/* Satisfaction bar (inflow bar on funnels & outcomes) */ +.satisfaction-bar-bg { opacity: 0.3; } +.satisfaction-bar-fill { transition: width 0.3s ease; } + /* ── Node detail modals ──────────────────────────────── */ .funds-modal-backdrop { position: fixed; inset: 0; z-index: 50;