feat: add Budget River waterfall visualization view
New /river page showing funding flows as an animated river with waterfalls cascading in (inflows) and out (spending). SVG-based with CSS animations for water effects, ripples, and current lines. Supports demo mode with simulated data and live mode via Safe Global API for real on-chain balances. Also includes source node type and landing page nav links. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
cff46bb0fc
commit
e683175c65
|
|
@ -25,3 +25,43 @@ body {
|
||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Budget River Animations ─────────────────────────── */
|
||||||
|
|
||||||
|
@keyframes waterFlow {
|
||||||
|
0% { transform: translateY(-100%); }
|
||||||
|
100% { transform: translateY(100%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes riverCurrent {
|
||||||
|
0% { stroke-dashoffset: 0; }
|
||||||
|
100% { stroke-dashoffset: -24; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes droplet {
|
||||||
|
0% { transform: translateY(0) scale(1); opacity: 0.8; }
|
||||||
|
80% { opacity: 0.4; }
|
||||||
|
100% { transform: translateY(50px) scale(0.2); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ripple {
|
||||||
|
0% { r: 2; opacity: 0.6; }
|
||||||
|
100% { r: 14; opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes waveFloat {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-3px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes poolWave {
|
||||||
|
0% { d: path("M0,8 Q15,4 30,8 T60,8 T90,8 V20 H0 Z"); }
|
||||||
|
50% { d: path("M0,8 Q15,12 30,8 T60,8 T90,8 V20 H0 Z"); }
|
||||||
|
100% { d: path("M0,8 Q15,4 30,8 T60,8 T90,8 V20 H0 Z"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { opacity: 0.3; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
100% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
|
|
||||||
12
app/page.tsx
12
app/page.tsx
|
|
@ -20,6 +20,12 @@ export default function Home() {
|
||||||
>
|
>
|
||||||
Demo
|
Demo
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/river"
|
||||||
|
className="text-sm text-slate-300 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
River View
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/space"
|
href="/space"
|
||||||
className="text-sm px-4 py-2 bg-emerald-600 hover:bg-emerald-500 rounded-lg transition-colors font-medium"
|
className="text-sm px-4 py-2 bg-emerald-600 hover:bg-emerald-500 rounded-lg transition-colors font-medium"
|
||||||
|
|
@ -48,6 +54,12 @@ export default function Home() {
|
||||||
>
|
>
|
||||||
Try the Demo
|
Try the Demo
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/river"
|
||||||
|
className="px-6 py-3 bg-cyan-700 hover:bg-cyan-600 rounded-xl text-lg font-medium transition-all border border-cyan-600"
|
||||||
|
>
|
||||||
|
River View
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/space"
|
href="/space"
|
||||||
className="px-6 py-3 bg-emerald-600 hover:bg-emerald-500 rounded-xl text-lg font-medium transition-all shadow-lg shadow-emerald-900/30"
|
className="px-6 py-3 bg-emerald-600 hover:bg-emerald-500 rounded-xl text-lg font-medium transition-all shadow-lg shadow-emerald-900/30"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,350 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { useState, useCallback, useEffect } from 'react'
|
||||||
|
import { demoNodes } from '@/lib/presets'
|
||||||
|
import { detectSafeChains, getBalances } from '@/lib/api/safe-client'
|
||||||
|
import { safeBalancesToFunnels } from '@/lib/integrations'
|
||||||
|
import { getFlow, listFlows, syncNodesToBackend, fromSmallestUnit } from '@/lib/api/flows-client'
|
||||||
|
import type { FlowNode, FunnelNodeData } from '@/lib/types'
|
||||||
|
import type { BackendFlow } from '@/lib/api/flows-client'
|
||||||
|
|
||||||
|
const BudgetRiver = dynamic(() => import('@/components/BudgetRiver'), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="w-full h-full flex items-center justify-center bg-slate-900">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 border-4 border-cyan-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span className="text-slate-400">Loading river view...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function RiverPage() {
|
||||||
|
const [mode, setMode] = useState<'demo' | 'live'>('demo')
|
||||||
|
const [nodes, setNodes] = useState<FlowNode[]>(demoNodes)
|
||||||
|
const [isSimulating, setIsSimulating] = useState(true)
|
||||||
|
const [showConnect, setShowConnect] = useState(false)
|
||||||
|
const [safeAddress, setSafeAddress] = useState('')
|
||||||
|
const [connecting, setConnecting] = useState(false)
|
||||||
|
const [connectedChains, setConnectedChains] = useState<string[]>([])
|
||||||
|
const [statusMessage, setStatusMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null)
|
||||||
|
const [deployedFlow, setDeployedFlow] = useState<BackendFlow | null>(null)
|
||||||
|
|
||||||
|
// Load saved address
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
const saved = localStorage.getItem('rfunds-owner-address')
|
||||||
|
if (saved) setSafeAddress(saved)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const showStatus = useCallback((text: string, type: 'success' | 'error') => {
|
||||||
|
setStatusMessage({ text, type })
|
||||||
|
setTimeout(() => setStatusMessage(null), 4000)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleConnect = useCallback(async () => {
|
||||||
|
if (!safeAddress.trim()) return
|
||||||
|
setConnecting(true)
|
||||||
|
try {
|
||||||
|
localStorage.setItem('rfunds-owner-address', safeAddress.trim())
|
||||||
|
const detected = await detectSafeChains(safeAddress.trim())
|
||||||
|
if (detected.length === 0) {
|
||||||
|
showStatus('No Safe found on supported chains (Gnosis, Optimism)', 'error')
|
||||||
|
setConnecting(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const chainNames = detected.map(d => d.chain.name)
|
||||||
|
setConnectedChains(chainNames)
|
||||||
|
|
||||||
|
// Fetch balances from all detected chains and create funnel nodes
|
||||||
|
const allFunnelNodes: FlowNode[] = []
|
||||||
|
for (const chain of detected) {
|
||||||
|
const balances = await getBalances(safeAddress.trim(), chain.chainId)
|
||||||
|
const funnels = safeBalancesToFunnels(
|
||||||
|
balances,
|
||||||
|
safeAddress.trim(),
|
||||||
|
chain.chainId,
|
||||||
|
{ x: allFunnelNodes.length * 280, y: 100 }
|
||||||
|
)
|
||||||
|
allFunnelNodes.push(...funnels)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allFunnelNodes.length === 0) {
|
||||||
|
showStatus('Safe found but no token balances > $1', 'error')
|
||||||
|
setConnecting(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setNodes(allFunnelNodes)
|
||||||
|
setMode('live')
|
||||||
|
setIsSimulating(false)
|
||||||
|
setShowConnect(false)
|
||||||
|
showStatus(`Connected: ${allFunnelNodes.length} tokens across ${chainNames.join(', ')}`, 'success')
|
||||||
|
} catch (err) {
|
||||||
|
showStatus(`Connection failed: ${err instanceof Error ? err.message : 'Unknown error'}`, 'error')
|
||||||
|
} finally {
|
||||||
|
setConnecting(false)
|
||||||
|
}
|
||||||
|
}, [safeAddress, showStatus])
|
||||||
|
|
||||||
|
const handleLoadFlow = useCallback(async () => {
|
||||||
|
if (!safeAddress.trim()) {
|
||||||
|
showStatus('Enter a wallet address first', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const flows = await listFlows(safeAddress.trim())
|
||||||
|
if (flows.length === 0) {
|
||||||
|
showStatus('No deployed flows found for this address', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the most recent active flow
|
||||||
|
const activeFlow = flows.find(f => f.status === 'active') || flows[0]
|
||||||
|
const flow = await getFlow(activeFlow.id)
|
||||||
|
setDeployedFlow(flow)
|
||||||
|
|
||||||
|
// Convert backend flow to visual nodes
|
||||||
|
const funnelNodes: FlowNode[] = flow.funnels.map((f, i) => ({
|
||||||
|
id: f.id,
|
||||||
|
type: 'funnel' as const,
|
||||||
|
position: { x: i * 280, y: 100 },
|
||||||
|
data: {
|
||||||
|
label: f.name,
|
||||||
|
currentValue: fromSmallestUnit(f.balance),
|
||||||
|
minThreshold: fromSmallestUnit(f.minThreshold),
|
||||||
|
maxThreshold: fromSmallestUnit(f.maxThreshold),
|
||||||
|
maxCapacity: fromSmallestUnit(f.maxThreshold) * 1.5,
|
||||||
|
inflowRate: f.inflowRate ? fromSmallestUnit(f.inflowRate) : 0,
|
||||||
|
overflowAllocations: f.overflowAllocations.map((a, j) => ({
|
||||||
|
targetId: a.targetId,
|
||||||
|
percentage: a.percentage,
|
||||||
|
color: ['#f59e0b', '#ef4444', '#f97316'][j % 3],
|
||||||
|
})),
|
||||||
|
spendingAllocations: f.spendingAllocations.map((a, j) => ({
|
||||||
|
targetId: a.targetId,
|
||||||
|
percentage: a.percentage,
|
||||||
|
color: ['#3b82f6', '#8b5cf6', '#ec4899'][j % 3],
|
||||||
|
})),
|
||||||
|
} as FunnelNodeData,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const outcomeNodes: FlowNode[] = flow.outcomes.map((o, i) => ({
|
||||||
|
id: o.id,
|
||||||
|
type: 'outcome' as const,
|
||||||
|
position: { x: i * 250, y: 600 },
|
||||||
|
data: {
|
||||||
|
label: o.name,
|
||||||
|
description: o.description || '',
|
||||||
|
fundingReceived: fromSmallestUnit(o.currentAmount),
|
||||||
|
fundingTarget: fromSmallestUnit(o.targetAmount),
|
||||||
|
status: o.status === 'completed' ? 'completed' as const :
|
||||||
|
fromSmallestUnit(o.currentAmount) > 0 ? 'in-progress' as const : 'not-started' as const,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
setNodes([...funnelNodes, ...outcomeNodes])
|
||||||
|
setMode('live')
|
||||||
|
setIsSimulating(false)
|
||||||
|
showStatus(`Loaded flow "${flow.name}" (${flow.funnels.length} funnels, ${flow.outcomes.length} outcomes)`, 'success')
|
||||||
|
} catch (err) {
|
||||||
|
showStatus(`Failed to load flow: ${err instanceof Error ? err.message : 'Unknown error'}`, 'error')
|
||||||
|
}
|
||||||
|
}, [safeAddress, showStatus])
|
||||||
|
|
||||||
|
// Auto-refresh in live mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== 'live' || !deployedFlow) return
|
||||||
|
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const latest = await getFlow(deployedFlow.id)
|
||||||
|
setDeployedFlow(latest)
|
||||||
|
setNodes(prev => syncNodesToBackend(prev, latest, Object.fromEntries(
|
||||||
|
prev.map(n => [n.id, n.id])
|
||||||
|
)))
|
||||||
|
} catch {
|
||||||
|
// Silent refresh failure
|
||||||
|
}
|
||||||
|
}, 30000)
|
||||||
|
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [mode, deployedFlow])
|
||||||
|
|
||||||
|
const handleSwitchToDemo = useCallback(() => {
|
||||||
|
setNodes(demoNodes)
|
||||||
|
setMode('demo')
|
||||||
|
setIsSimulating(true)
|
||||||
|
setDeployedFlow(null)
|
||||||
|
setConnectedChains([])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="h-screen w-screen flex flex-col bg-slate-900">
|
||||||
|
{/* Status Toast */}
|
||||||
|
{statusMessage && (
|
||||||
|
<div className={`fixed top-16 left-1/2 -translate-x-1/2 z-[60] px-4 py-2 rounded-lg shadow-lg text-sm font-medium ${
|
||||||
|
statusMessage.type === 'success' ? 'bg-emerald-600 text-white' : 'bg-red-600 text-white'
|
||||||
|
}`}>
|
||||||
|
{statusMessage.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Banner */}
|
||||||
|
<div className="bg-slate-800 text-white px-4 py-2 flex items-center justify-between text-sm flex-shrink-0 border-b border-slate-700/50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/" className="flex items-center gap-2 hover:opacity-80 transition-opacity">
|
||||||
|
<div className="w-6 h-6 bg-gradient-to-br from-amber-400 to-emerald-500 rounded flex items-center justify-center font-bold text-slate-900 text-[10px]">
|
||||||
|
rF
|
||||||
|
</div>
|
||||||
|
<span className="font-medium">rFunds</span>
|
||||||
|
</Link>
|
||||||
|
<span className="text-slate-500">|</span>
|
||||||
|
<span className="text-cyan-300 font-medium">Budget River</span>
|
||||||
|
{mode === 'live' && connectedChains.length > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-slate-500">|</span>
|
||||||
|
<span className="text-xs text-emerald-400">
|
||||||
|
{connectedChains.join(' + ')}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{deployedFlow && (
|
||||||
|
<>
|
||||||
|
<span className="text-slate-500">|</span>
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||||
|
deployedFlow.status === 'active' ? 'bg-emerald-600/30 text-emerald-300' :
|
||||||
|
'bg-amber-600/30 text-amber-300'
|
||||||
|
}`}>
|
||||||
|
{deployedFlow.status} · {deployedFlow.name}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href="/tbff"
|
||||||
|
className="px-3 py-1 text-slate-400 hover:text-white text-xs transition-colors"
|
||||||
|
>
|
||||||
|
Canvas View
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/space"
|
||||||
|
className="px-3 py-1 text-slate-400 hover:text-white text-xs transition-colors"
|
||||||
|
>
|
||||||
|
Editor
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="w-px h-5 bg-slate-600 mx-1" />
|
||||||
|
|
||||||
|
{mode === 'live' ? (
|
||||||
|
<button
|
||||||
|
onClick={handleSwitchToDemo}
|
||||||
|
className="px-3 py-1 bg-slate-600 hover:bg-slate-500 rounded text-xs font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Demo
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowConnect(true)}
|
||||||
|
className="px-3 py-1 bg-cyan-600 hover:bg-cyan-500 rounded text-xs font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Connect Safe
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setIsSimulating(!isSimulating)}
|
||||||
|
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
|
||||||
|
isSimulating
|
||||||
|
? 'bg-emerald-600 hover:bg-emerald-500 text-white'
|
||||||
|
: 'bg-slate-700 hover:bg-slate-600 text-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSimulating ? 'Pause' : 'Simulate'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* River Canvas */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<BudgetRiver nodes={nodes} isSimulating={isSimulating} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connect Safe Dialog */}
|
||||||
|
{showConnect && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={() => setShowConnect(false)}>
|
||||||
|
<div className="bg-slate-800 rounded-xl shadow-2xl p-6 w-96 border border-slate-700" onClick={e => e.stopPropagation()}>
|
||||||
|
<h3 className="text-lg font-bold text-white mb-1">Connect Safe Treasury</h3>
|
||||||
|
<p className="text-xs text-slate-400 mb-4">
|
||||||
|
Enter a Safe address to load real token balances from Gnosis and Optimism chains.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={safeAddress}
|
||||||
|
onChange={e => setSafeAddress(e.target.value)}
|
||||||
|
placeholder="Safe address (0x...)"
|
||||||
|
className="w-full px-3 py-2 bg-slate-900 border border-slate-600 rounded-lg text-sm text-white font-mono text-xs mb-3 focus:border-cyan-500 focus:outline-none"
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleConnect()}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 mb-3">
|
||||||
|
<button
|
||||||
|
onClick={handleConnect}
|
||||||
|
disabled={!safeAddress.trim() || connecting}
|
||||||
|
className="flex-1 px-4 py-2 bg-cyan-600 text-white rounded-lg text-sm font-medium hover:bg-cyan-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{connecting ? 'Connecting...' : 'Connect Safe'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleLoadFlow}
|
||||||
|
disabled={!safeAddress.trim()}
|
||||||
|
className="px-4 py-2 bg-violet-600 text-white rounded-lg text-sm font-medium hover:bg-violet-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
Load Flow
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowConnect(false)}
|
||||||
|
className="w-full px-4 py-2 text-slate-400 hover:text-white text-sm border border-slate-600 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="absolute bottom-4 left-4 bg-slate-800/90 backdrop-blur-sm rounded-lg border border-slate-700/50 p-3 text-xs">
|
||||||
|
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide mb-2">Legend</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded bg-emerald-500" />
|
||||||
|
<span className="text-slate-400">Source / Inflow</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-1.5 rounded bg-cyan-500" />
|
||||||
|
<span className="text-slate-400">River (healthy)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-1.5 rounded bg-amber-500" />
|
||||||
|
<span className="text-slate-400">Overflow branch</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded bg-violet-500" />
|
||||||
|
<span className="text-slate-400">Spending outflow</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-md border border-blue-500 bg-blue-500/30" />
|
||||||
|
<span className="text-slate-400">Outcome pool</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,970 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||||
|
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData } from '@/lib/types'
|
||||||
|
|
||||||
|
// ─── Layout Types ────────────────────────────────────────
|
||||||
|
|
||||||
|
interface RiverLayout {
|
||||||
|
sources: SourceLayout[]
|
||||||
|
funnels: FunnelLayout[]
|
||||||
|
outcomes: OutcomeLayout[]
|
||||||
|
sourceWaterfalls: WaterfallLayout[]
|
||||||
|
overflowBranches: BranchLayout[]
|
||||||
|
spendingWaterfalls: WaterfallLayout[]
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SourceLayout {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
flowRate: number
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
width: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FunnelLayout {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
data: FunnelNodeData
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
riverWidth: number
|
||||||
|
segmentLength: number
|
||||||
|
layer: number
|
||||||
|
status: 'healthy' | 'overflow' | 'critical'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OutcomeLayout {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
data: OutcomeNodeData
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
poolWidth: number
|
||||||
|
fillPercent: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WaterfallLayout {
|
||||||
|
id: string
|
||||||
|
sourceId: string
|
||||||
|
targetId: string
|
||||||
|
label: string
|
||||||
|
percentage: number
|
||||||
|
x: number
|
||||||
|
yStart: number
|
||||||
|
yEnd: number
|
||||||
|
width: number
|
||||||
|
color: string
|
||||||
|
flowAmount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BranchLayout {
|
||||||
|
sourceId: string
|
||||||
|
targetId: string
|
||||||
|
percentage: number
|
||||||
|
x1: number
|
||||||
|
y1: number
|
||||||
|
x2: number
|
||||||
|
y2: number
|
||||||
|
width: number
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Constants ───────────────────────────────────────────
|
||||||
|
|
||||||
|
const LAYER_HEIGHT = 160
|
||||||
|
const WATERFALL_HEIGHT = 100
|
||||||
|
const GAP = 40
|
||||||
|
const MIN_RIVER_WIDTH = 24
|
||||||
|
const MAX_RIVER_WIDTH = 100
|
||||||
|
const SEGMENT_LENGTH = 200
|
||||||
|
const POOL_WIDTH = 100
|
||||||
|
const POOL_HEIGHT = 60
|
||||||
|
const SOURCE_HEIGHT = 40
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
sourceWaterfall: '#10b981',
|
||||||
|
riverHealthy: ['#0ea5e9', '#06b6d4'],
|
||||||
|
riverOverflow: ['#f59e0b', '#fbbf24'],
|
||||||
|
riverCritical: ['#ef4444', '#f87171'],
|
||||||
|
overflowBranch: '#f59e0b',
|
||||||
|
spendingWaterfall: ['#8b5cf6', '#ec4899', '#06b6d4', '#3b82f6', '#10b981', '#6366f1'],
|
||||||
|
outcomePool: '#3b82f6',
|
||||||
|
bg: '#0f172a',
|
||||||
|
text: '#e2e8f0',
|
||||||
|
textMuted: '#94a3b8',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Layout Engine ───────────────────────────────────────
|
||||||
|
|
||||||
|
function computeLayout(nodes: FlowNode[]): RiverLayout {
|
||||||
|
const funnelNodes = nodes.filter(n => n.type === 'funnel')
|
||||||
|
const outcomeNodes = nodes.filter(n => n.type === 'outcome')
|
||||||
|
const sourceNodes = nodes.filter(n => n.type === 'source')
|
||||||
|
|
||||||
|
// Build adjacency: which funnels are children (overflow targets) of which
|
||||||
|
const overflowTargets = new Set<string>()
|
||||||
|
const spendingTargets = new Set<string>()
|
||||||
|
const sourceTargets = new Set<string>()
|
||||||
|
|
||||||
|
funnelNodes.forEach(n => {
|
||||||
|
const data = n.data as FunnelNodeData
|
||||||
|
data.overflowAllocations?.forEach(a => overflowTargets.add(a.targetId))
|
||||||
|
data.spendingAllocations?.forEach(a => spendingTargets.add(a.targetId))
|
||||||
|
})
|
||||||
|
sourceNodes.forEach(n => {
|
||||||
|
const data = n.data as SourceNodeData
|
||||||
|
data.targetAllocations?.forEach(a => sourceTargets.add(a.targetId))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Root funnels = funnels that are NOT overflow targets of other funnels
|
||||||
|
// (but may be source targets)
|
||||||
|
const rootFunnels = funnelNodes.filter(n => !overflowTargets.has(n.id))
|
||||||
|
const childFunnels = funnelNodes.filter(n => overflowTargets.has(n.id))
|
||||||
|
|
||||||
|
// Assign layers
|
||||||
|
const funnelLayers = new Map<string, number>()
|
||||||
|
rootFunnels.forEach(n => funnelLayers.set(n.id, 0))
|
||||||
|
|
||||||
|
// BFS to assign layers to overflow children
|
||||||
|
const queue = [...rootFunnels]
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const current = queue.shift()!
|
||||||
|
const data = current.data as FunnelNodeData
|
||||||
|
const parentLayer = funnelLayers.get(current.id) ?? 0
|
||||||
|
data.overflowAllocations?.forEach(a => {
|
||||||
|
const child = funnelNodes.find(n => n.id === a.targetId)
|
||||||
|
if (child && !funnelLayers.has(child.id)) {
|
||||||
|
funnelLayers.set(child.id, parentLayer + 1)
|
||||||
|
queue.push(child)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find max flow rate for normalization
|
||||||
|
const allFlowRates = funnelNodes.map(n => (n.data as FunnelNodeData).inflowRate || 1)
|
||||||
|
const maxFlowRate = Math.max(...allFlowRates, 1)
|
||||||
|
const maxValue = Math.max(...funnelNodes.map(n => (n.data as FunnelNodeData).currentValue || 1), 1)
|
||||||
|
|
||||||
|
// Compute funnel layouts
|
||||||
|
// Group by layer, center each layer
|
||||||
|
const layerGroups = new Map<number, FlowNode[]>()
|
||||||
|
funnelNodes.forEach(n => {
|
||||||
|
const layer = funnelLayers.get(n.id) ?? 0
|
||||||
|
if (!layerGroups.has(layer)) layerGroups.set(layer, [])
|
||||||
|
layerGroups.get(layer)!.push(n)
|
||||||
|
})
|
||||||
|
|
||||||
|
const maxLayer = Math.max(...Array.from(layerGroups.keys()), 0)
|
||||||
|
const sourceLayerY = GAP
|
||||||
|
const funnelStartY = sourceLayerY + SOURCE_HEIGHT + WATERFALL_HEIGHT + GAP
|
||||||
|
|
||||||
|
const funnelLayouts: FunnelLayout[] = []
|
||||||
|
const layerXRanges = new Map<number, { minX: number; maxX: number }>()
|
||||||
|
|
||||||
|
for (let layer = 0; layer <= maxLayer; layer++) {
|
||||||
|
const layerNodes = layerGroups.get(layer) || []
|
||||||
|
const layerY = funnelStartY + layer * (LAYER_HEIGHT + WATERFALL_HEIGHT + GAP)
|
||||||
|
const totalWidth = layerNodes.length * SEGMENT_LENGTH + (layerNodes.length - 1) * GAP * 2
|
||||||
|
|
||||||
|
layerNodes.forEach((n, i) => {
|
||||||
|
const data = n.data as FunnelNodeData
|
||||||
|
const fillRatio = Math.min(1, data.currentValue / (data.maxCapacity || 1))
|
||||||
|
const riverWidth = MIN_RIVER_WIDTH + fillRatio * (MAX_RIVER_WIDTH - MIN_RIVER_WIDTH)
|
||||||
|
const x = -totalWidth / 2 + i * (SEGMENT_LENGTH + GAP * 2)
|
||||||
|
const status: 'healthy' | 'overflow' | 'critical' =
|
||||||
|
data.currentValue > data.maxThreshold ? 'overflow' :
|
||||||
|
data.currentValue < data.minThreshold ? 'critical' : 'healthy'
|
||||||
|
|
||||||
|
funnelLayouts.push({
|
||||||
|
id: n.id,
|
||||||
|
label: data.label,
|
||||||
|
data,
|
||||||
|
x,
|
||||||
|
y: layerY,
|
||||||
|
riverWidth,
|
||||||
|
segmentLength: SEGMENT_LENGTH,
|
||||||
|
layer,
|
||||||
|
status,
|
||||||
|
})
|
||||||
|
|
||||||
|
const range = layerXRanges.get(layer) || { minX: Infinity, maxX: -Infinity }
|
||||||
|
range.minX = Math.min(range.minX, x)
|
||||||
|
range.maxX = Math.max(range.maxX, x + SEGMENT_LENGTH)
|
||||||
|
layerXRanges.set(layer, range)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source layouts
|
||||||
|
const sourceLayouts: SourceLayout[] = sourceNodes.map((n, i) => {
|
||||||
|
const data = n.data as SourceNodeData
|
||||||
|
const totalWidth = sourceNodes.length * 120 + (sourceNodes.length - 1) * GAP
|
||||||
|
return {
|
||||||
|
id: n.id,
|
||||||
|
label: data.label,
|
||||||
|
flowRate: data.flowRate,
|
||||||
|
x: -totalWidth / 2 + i * (120 + GAP),
|
||||||
|
y: sourceLayerY,
|
||||||
|
width: 120,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Source waterfalls (source → root funnel)
|
||||||
|
const sourceWaterfalls: WaterfallLayout[] = []
|
||||||
|
sourceNodes.forEach(sn => {
|
||||||
|
const data = sn.data as SourceNodeData
|
||||||
|
const sourceLayout = sourceLayouts.find(s => s.id === sn.id)
|
||||||
|
if (!sourceLayout) return
|
||||||
|
|
||||||
|
data.targetAllocations?.forEach((alloc, i) => {
|
||||||
|
const targetLayout = funnelLayouts.find(f => f.id === alloc.targetId)
|
||||||
|
if (!targetLayout) return
|
||||||
|
|
||||||
|
const flowAmount = (alloc.percentage / 100) * data.flowRate
|
||||||
|
const width = Math.max(6, (flowAmount / Math.max(data.flowRate, 1)) * 30)
|
||||||
|
|
||||||
|
sourceWaterfalls.push({
|
||||||
|
id: `src-wf-${sn.id}-${alloc.targetId}`,
|
||||||
|
sourceId: sn.id,
|
||||||
|
targetId: alloc.targetId,
|
||||||
|
label: `${alloc.percentage}%`,
|
||||||
|
percentage: alloc.percentage,
|
||||||
|
x: sourceLayout.x + sourceLayout.width / 2 + (i - (data.targetAllocations.length - 1) / 2) * 30,
|
||||||
|
yStart: sourceLayout.y + SOURCE_HEIGHT,
|
||||||
|
yEnd: targetLayout.y,
|
||||||
|
width,
|
||||||
|
color: COLORS.sourceWaterfall,
|
||||||
|
flowAmount,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// If no source nodes, create implicit waterfalls for root funnels with inflowRate
|
||||||
|
if (sourceNodes.length === 0) {
|
||||||
|
rootFunnels.forEach(rn => {
|
||||||
|
const data = rn.data as FunnelNodeData
|
||||||
|
if (data.inflowRate <= 0) return
|
||||||
|
const layout = funnelLayouts.find(f => f.id === rn.id)
|
||||||
|
if (!layout) return
|
||||||
|
|
||||||
|
sourceWaterfalls.push({
|
||||||
|
id: `implicit-wf-${rn.id}`,
|
||||||
|
sourceId: 'implicit',
|
||||||
|
targetId: rn.id,
|
||||||
|
label: `$${Math.floor(data.inflowRate)}/mo`,
|
||||||
|
percentage: 100,
|
||||||
|
x: layout.x + layout.segmentLength / 2,
|
||||||
|
yStart: GAP,
|
||||||
|
yEnd: layout.y,
|
||||||
|
width: Math.max(8, (data.inflowRate / maxFlowRate) * 30),
|
||||||
|
color: COLORS.sourceWaterfall,
|
||||||
|
flowAmount: data.inflowRate,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overflow branches (funnel → child funnel)
|
||||||
|
const overflowBranches: BranchLayout[] = []
|
||||||
|
funnelNodes.forEach(n => {
|
||||||
|
const data = n.data as FunnelNodeData
|
||||||
|
const parentLayout = funnelLayouts.find(f => f.id === n.id)
|
||||||
|
if (!parentLayout) return
|
||||||
|
|
||||||
|
data.overflowAllocations?.forEach((alloc, i) => {
|
||||||
|
const childLayout = funnelLayouts.find(f => f.id === alloc.targetId)
|
||||||
|
if (!childLayout) return
|
||||||
|
|
||||||
|
const flowAmount = (alloc.percentage / 100) * (data.inflowRate || 1)
|
||||||
|
const width = Math.max(4, (alloc.percentage / 100) * parentLayout.riverWidth * 0.6)
|
||||||
|
|
||||||
|
overflowBranches.push({
|
||||||
|
sourceId: n.id,
|
||||||
|
targetId: alloc.targetId,
|
||||||
|
percentage: alloc.percentage,
|
||||||
|
x1: parentLayout.x + parentLayout.segmentLength,
|
||||||
|
y1: parentLayout.y + parentLayout.riverWidth / 2,
|
||||||
|
x2: childLayout.x,
|
||||||
|
y2: childLayout.y + childLayout.riverWidth / 2,
|
||||||
|
width,
|
||||||
|
color: alloc.color || COLORS.overflowBranch,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Outcome layouts
|
||||||
|
const outcomeY = funnelStartY + (maxLayer + 1) * (LAYER_HEIGHT + WATERFALL_HEIGHT + GAP) + WATERFALL_HEIGHT
|
||||||
|
const totalOutcomeWidth = outcomeNodes.length * (POOL_WIDTH + GAP) - GAP
|
||||||
|
const outcomeLayouts: OutcomeLayout[] = outcomeNodes.map((n, i) => {
|
||||||
|
const data = n.data as OutcomeNodeData
|
||||||
|
const fillPercent = data.fundingTarget > 0
|
||||||
|
? Math.min(100, (data.fundingReceived / data.fundingTarget) * 100)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: n.id,
|
||||||
|
label: data.label,
|
||||||
|
data,
|
||||||
|
x: -totalOutcomeWidth / 2 + i * (POOL_WIDTH + GAP),
|
||||||
|
y: outcomeY,
|
||||||
|
poolWidth: POOL_WIDTH,
|
||||||
|
fillPercent,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Spending waterfalls (funnel → outcome)
|
||||||
|
const spendingWaterfalls: WaterfallLayout[] = []
|
||||||
|
funnelNodes.forEach(n => {
|
||||||
|
const data = n.data as FunnelNodeData
|
||||||
|
const parentLayout = funnelLayouts.find(f => f.id === n.id)
|
||||||
|
if (!parentLayout) return
|
||||||
|
|
||||||
|
data.spendingAllocations?.forEach((alloc, i) => {
|
||||||
|
const outcomeLayout = outcomeLayouts.find(o => o.id === alloc.targetId)
|
||||||
|
if (!outcomeLayout) return
|
||||||
|
|
||||||
|
const flowAmount = (alloc.percentage / 100) * (data.inflowRate || 1)
|
||||||
|
const width = Math.max(4, (alloc.percentage / 100) * 24)
|
||||||
|
|
||||||
|
spendingWaterfalls.push({
|
||||||
|
id: `spend-wf-${n.id}-${alloc.targetId}`,
|
||||||
|
sourceId: n.id,
|
||||||
|
targetId: alloc.targetId,
|
||||||
|
label: `${alloc.percentage}%`,
|
||||||
|
percentage: alloc.percentage,
|
||||||
|
x: parentLayout.x + parentLayout.segmentLength / 2 +
|
||||||
|
(i - ((data.spendingAllocations?.length || 1) - 1) / 2) * 24,
|
||||||
|
yStart: parentLayout.y + parentLayout.riverWidth + 4,
|
||||||
|
yEnd: outcomeLayout.y,
|
||||||
|
width,
|
||||||
|
color: alloc.color || COLORS.spendingWaterfall[i % COLORS.spendingWaterfall.length],
|
||||||
|
flowAmount,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Compute total bounds
|
||||||
|
const allX = [
|
||||||
|
...funnelLayouts.map(f => f.x),
|
||||||
|
...funnelLayouts.map(f => f.x + f.segmentLength),
|
||||||
|
...outcomeLayouts.map(o => o.x),
|
||||||
|
...outcomeLayouts.map(o => o.x + o.poolWidth),
|
||||||
|
...sourceLayouts.map(s => s.x),
|
||||||
|
...sourceLayouts.map(s => s.x + s.width),
|
||||||
|
]
|
||||||
|
const allY = [
|
||||||
|
...funnelLayouts.map(f => f.y + f.riverWidth),
|
||||||
|
...outcomeLayouts.map(o => o.y + POOL_HEIGHT),
|
||||||
|
sourceLayerY,
|
||||||
|
]
|
||||||
|
|
||||||
|
const minX = Math.min(...allX, -100)
|
||||||
|
const maxX = Math.max(...allX, 100)
|
||||||
|
const maxY = Math.max(...allY, 400)
|
||||||
|
const padding = 80
|
||||||
|
|
||||||
|
// Shift everything so minX starts at padding
|
||||||
|
const offsetX = -minX + padding
|
||||||
|
const offsetY = padding
|
||||||
|
|
||||||
|
// Apply offsets
|
||||||
|
funnelLayouts.forEach(f => { f.x += offsetX; f.y += offsetY })
|
||||||
|
outcomeLayouts.forEach(o => { o.x += offsetX; o.y += offsetY })
|
||||||
|
sourceLayouts.forEach(s => { s.x += offsetX; s.y += offsetY })
|
||||||
|
sourceWaterfalls.forEach(w => { w.x += offsetX; w.yStart += offsetY; w.yEnd += offsetY })
|
||||||
|
overflowBranches.forEach(b => { b.x1 += offsetX; b.y1 += offsetY; b.x2 += offsetX; b.y2 += offsetY })
|
||||||
|
spendingWaterfalls.forEach(w => { w.x += offsetX; w.yStart += offsetY; w.yEnd += offsetY })
|
||||||
|
|
||||||
|
return {
|
||||||
|
sources: sourceLayouts,
|
||||||
|
funnels: funnelLayouts,
|
||||||
|
outcomes: outcomeLayouts,
|
||||||
|
sourceWaterfalls,
|
||||||
|
overflowBranches,
|
||||||
|
spendingWaterfalls,
|
||||||
|
width: maxX - minX + padding * 2,
|
||||||
|
height: maxY + offsetY + padding,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SVG Sub-components ──────────────────────────────────
|
||||||
|
|
||||||
|
function WaterfallStream({ wf, index }: { wf: WaterfallLayout; index: number }) {
|
||||||
|
const height = wf.yEnd - wf.yStart
|
||||||
|
const numDroplets = Math.max(3, Math.min(8, Math.floor(wf.width / 3)))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
{/* Main water stream */}
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`wf-grad-${wf.id}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={wf.color} stopOpacity="0.9" />
|
||||||
|
<stop offset="50%" stopColor={wf.color} stopOpacity="0.6" />
|
||||||
|
<stop offset="100%" stopColor={wf.color} stopOpacity="0.3" />
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id={`wf-clip-${wf.id}`}>
|
||||||
|
<rect x={wf.x - wf.width / 2} y={wf.yStart} width={wf.width} height={height} />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Glow behind */}
|
||||||
|
<rect
|
||||||
|
x={wf.x - wf.width / 2 - 3}
|
||||||
|
y={wf.yStart}
|
||||||
|
width={wf.width + 6}
|
||||||
|
height={height}
|
||||||
|
rx={wf.width / 2}
|
||||||
|
fill={wf.color}
|
||||||
|
opacity={0.12}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Animated water strips */}
|
||||||
|
<g clipPath={`url(#wf-clip-${wf.id})`}>
|
||||||
|
{[0, 1, 2].map(i => (
|
||||||
|
<rect
|
||||||
|
key={i}
|
||||||
|
x={wf.x - wf.width / 2 + 1}
|
||||||
|
y={wf.yStart - height}
|
||||||
|
width={wf.width - 2}
|
||||||
|
height={height}
|
||||||
|
fill={`url(#wf-grad-${wf.id})`}
|
||||||
|
rx={2}
|
||||||
|
style={{
|
||||||
|
animation: `waterFlow ${1.2 + i * 0.3}s linear infinite`,
|
||||||
|
animationDelay: `${i * -0.4}s`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Side mist lines */}
|
||||||
|
<line
|
||||||
|
x1={wf.x - wf.width / 2 - 1}
|
||||||
|
y1={wf.yStart}
|
||||||
|
x2={wf.x - wf.width / 2 - 1}
|
||||||
|
y2={wf.yEnd}
|
||||||
|
stroke={wf.color}
|
||||||
|
strokeWidth={1}
|
||||||
|
opacity={0.3}
|
||||||
|
strokeDasharray="4 6"
|
||||||
|
style={{ animation: `riverCurrent 1s linear infinite` }}
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1={wf.x + wf.width / 2 + 1}
|
||||||
|
y1={wf.yStart}
|
||||||
|
x2={wf.x + wf.width / 2 + 1}
|
||||||
|
y2={wf.yEnd}
|
||||||
|
stroke={wf.color}
|
||||||
|
strokeWidth={1}
|
||||||
|
opacity={0.3}
|
||||||
|
strokeDasharray="4 6"
|
||||||
|
style={{ animation: `riverCurrent 1s linear infinite` }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Droplets */}
|
||||||
|
{Array.from({ length: numDroplets }, (_, i) => {
|
||||||
|
const dx = (Math.random() - 0.5) * (wf.width + 10)
|
||||||
|
const delay = Math.random() * 2
|
||||||
|
const dur = 1 + Math.random() * 1.5
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
key={`drop-${wf.id}-${i}`}
|
||||||
|
cx={wf.x + dx}
|
||||||
|
cy={wf.yEnd - 10}
|
||||||
|
r={1.5}
|
||||||
|
fill={wf.color}
|
||||||
|
opacity={0.6}
|
||||||
|
style={{
|
||||||
|
animation: `droplet ${dur}s ease-in infinite`,
|
||||||
|
animationDelay: `${delay}s`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Splash ripples at bottom */}
|
||||||
|
{[0, 1, 2].map(i => (
|
||||||
|
<circle
|
||||||
|
key={`ripple-${wf.id}-${i}`}
|
||||||
|
cx={wf.x}
|
||||||
|
cy={wf.yEnd}
|
||||||
|
r={2}
|
||||||
|
fill="none"
|
||||||
|
stroke={wf.color}
|
||||||
|
strokeWidth={1}
|
||||||
|
style={{
|
||||||
|
animation: `ripple 2s ease-out infinite`,
|
||||||
|
animationDelay: `${i * 0.6}s`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
<text
|
||||||
|
x={wf.x}
|
||||||
|
y={wf.yStart + height / 2}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill={COLORS.text}
|
||||||
|
fontSize={9}
|
||||||
|
fontWeight={600}
|
||||||
|
fontFamily="monospace"
|
||||||
|
>
|
||||||
|
{wf.label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RiverSegment({ funnel }: { funnel: FunnelLayout }) {
|
||||||
|
const { id, label, data, x, y, riverWidth, segmentLength, status } = funnel
|
||||||
|
const gradColors = status === 'overflow' ? COLORS.riverOverflow :
|
||||||
|
status === 'critical' ? COLORS.riverCritical : COLORS.riverHealthy
|
||||||
|
|
||||||
|
const fillPercent = Math.min(100, (data.currentValue / (data.maxCapacity || 1)) * 100)
|
||||||
|
const thresholdMinY = y + riverWidth * 0.85
|
||||||
|
const thresholdMaxY = y + riverWidth * 0.15
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`river-grad-${id}`} x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0%" stopColor={gradColors[0]} stopOpacity="0.6" />
|
||||||
|
<stop offset="30%" stopColor={gradColors[0]} stopOpacity="0.9" />
|
||||||
|
<stop offset="70%" stopColor={gradColors[1]} stopOpacity="0.9" />
|
||||||
|
<stop offset="100%" stopColor={gradColors[1]} stopOpacity="0.6" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* River glow */}
|
||||||
|
<rect
|
||||||
|
x={x - 4}
|
||||||
|
y={y - 4}
|
||||||
|
width={segmentLength + 8}
|
||||||
|
height={riverWidth + 8}
|
||||||
|
rx={riverWidth / 2 + 4}
|
||||||
|
fill={gradColors[0]}
|
||||||
|
opacity={0.1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* River body */}
|
||||||
|
<rect
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
width={segmentLength}
|
||||||
|
height={riverWidth}
|
||||||
|
rx={riverWidth / 2}
|
||||||
|
fill={`url(#river-grad-${id})`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Surface current lines */}
|
||||||
|
{[0.3, 0.5, 0.7].map((pos, i) => (
|
||||||
|
<line
|
||||||
|
key={`current-${id}-${i}`}
|
||||||
|
x1={x + 10}
|
||||||
|
y1={y + riverWidth * pos}
|
||||||
|
x2={x + segmentLength - 10}
|
||||||
|
y2={y + riverWidth * pos}
|
||||||
|
stroke="rgba(255,255,255,0.25)"
|
||||||
|
strokeWidth={1}
|
||||||
|
strokeDasharray="8 12"
|
||||||
|
style={{
|
||||||
|
animation: `riverCurrent ${1.5 + i * 0.3}s linear infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Shimmer highlight */}
|
||||||
|
<ellipse
|
||||||
|
cx={x + segmentLength * 0.4}
|
||||||
|
cy={y + riverWidth * 0.3}
|
||||||
|
rx={segmentLength * 0.15}
|
||||||
|
ry={riverWidth * 0.12}
|
||||||
|
fill="rgba(255,255,255,0.15)"
|
||||||
|
style={{ animation: 'shimmer 3s ease-in-out infinite' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Threshold markers */}
|
||||||
|
<line
|
||||||
|
x1={x + 5}
|
||||||
|
y1={thresholdMaxY}
|
||||||
|
x2={x + segmentLength - 5}
|
||||||
|
y2={thresholdMaxY}
|
||||||
|
stroke="#f59e0b"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeDasharray="4 3"
|
||||||
|
opacity={0.6}
|
||||||
|
/>
|
||||||
|
<text x={x + segmentLength + 6} y={thresholdMaxY + 3} fontSize={7} fill="#f59e0b" fontWeight="bold">MAX</text>
|
||||||
|
|
||||||
|
<line
|
||||||
|
x1={x + 5}
|
||||||
|
y1={thresholdMinY}
|
||||||
|
x2={x + segmentLength - 5}
|
||||||
|
y2={thresholdMinY}
|
||||||
|
stroke="#ef4444"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeDasharray="4 3"
|
||||||
|
opacity={0.6}
|
||||||
|
/>
|
||||||
|
<text x={x + segmentLength + 6} y={thresholdMinY + 3} fontSize={7} fill="#ef4444" fontWeight="bold">MIN</text>
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
<text
|
||||||
|
x={x + segmentLength / 2}
|
||||||
|
y={y - 10}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill={COLORS.text}
|
||||||
|
fontSize={12}
|
||||||
|
fontWeight={700}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Value */}
|
||||||
|
<text
|
||||||
|
x={x + segmentLength / 2}
|
||||||
|
y={y + riverWidth / 2 + 4}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="white"
|
||||||
|
fontSize={13}
|
||||||
|
fontWeight={700}
|
||||||
|
fontFamily="monospace"
|
||||||
|
>
|
||||||
|
${Math.floor(data.currentValue).toLocaleString()}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Status pill */}
|
||||||
|
<rect
|
||||||
|
x={x + segmentLength / 2 - 16}
|
||||||
|
y={y + riverWidth + 6}
|
||||||
|
width={32}
|
||||||
|
height={14}
|
||||||
|
rx={7}
|
||||||
|
fill={status === 'overflow' ? '#f59e0b' : status === 'critical' ? '#ef4444' : '#10b981'}
|
||||||
|
opacity={0.9}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={x + segmentLength / 2}
|
||||||
|
y={y + riverWidth + 16}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="white"
|
||||||
|
fontSize={7}
|
||||||
|
fontWeight={700}
|
||||||
|
>
|
||||||
|
{status === 'overflow' ? 'OVER' : status === 'critical' ? 'LOW' : 'OK'}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverflowBranch({ branch }: { branch: BranchLayout }) {
|
||||||
|
const { x1, y1, x2, y2, width, color, percentage } = branch
|
||||||
|
const midX = (x1 + x2) / 2
|
||||||
|
const midY = (y1 + y2) / 2
|
||||||
|
|
||||||
|
// Curved path connecting two river segments
|
||||||
|
const path = `M ${x1} ${y1} C ${x1 + 60} ${y1}, ${x2 - 60} ${y2}, ${x2} ${y2}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
{/* Glow */}
|
||||||
|
<path
|
||||||
|
d={path}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={width + 6}
|
||||||
|
opacity={0.1}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
{/* Main branch */}
|
||||||
|
<path
|
||||||
|
d={path}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={width}
|
||||||
|
opacity={0.7}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray="10 5"
|
||||||
|
style={{ animation: `riverCurrent 1.5s linear infinite` }}
|
||||||
|
/>
|
||||||
|
{/* Flow direction arrows */}
|
||||||
|
<text
|
||||||
|
x={midX}
|
||||||
|
y={midY - width / 2 - 4}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill={color}
|
||||||
|
fontSize={9}
|
||||||
|
fontWeight={600}
|
||||||
|
fontFamily="monospace"
|
||||||
|
>
|
||||||
|
{percentage}%
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OutcomePool({ outcome }: { outcome: OutcomeLayout }) {
|
||||||
|
const { id, label, data, x, y, poolWidth, fillPercent } = outcome
|
||||||
|
const fillHeight = (fillPercent / 100) * POOL_HEIGHT
|
||||||
|
|
||||||
|
const statusColor =
|
||||||
|
data.status === 'completed' ? '#10b981' :
|
||||||
|
data.status === 'in-progress' ? '#3b82f6' :
|
||||||
|
data.status === 'blocked' ? '#ef4444' : '#64748b'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
{/* Pool container */}
|
||||||
|
<rect
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
width={poolWidth}
|
||||||
|
height={POOL_HEIGHT}
|
||||||
|
rx={8}
|
||||||
|
fill="rgba(15,23,42,0.8)"
|
||||||
|
stroke={statusColor}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
opacity={0.9}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Water fill */}
|
||||||
|
<defs>
|
||||||
|
<clipPath id={`pool-clip-${id}`}>
|
||||||
|
<rect x={x + 2} y={y + 2} width={poolWidth - 4} height={POOL_HEIGHT - 4} rx={6} />
|
||||||
|
</clipPath>
|
||||||
|
<linearGradient id={`pool-grad-${id}`} x1="0" y1="1" x2="0" y2="0">
|
||||||
|
<stop offset="0%" stopColor={statusColor} stopOpacity="0.8" />
|
||||||
|
<stop offset="100%" stopColor={statusColor} stopOpacity="0.4" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g clipPath={`url(#pool-clip-${id})`}>
|
||||||
|
<rect
|
||||||
|
x={x + 2}
|
||||||
|
y={y + POOL_HEIGHT - fillHeight}
|
||||||
|
width={poolWidth - 4}
|
||||||
|
height={fillHeight}
|
||||||
|
fill={`url(#pool-grad-${id})`}
|
||||||
|
/>
|
||||||
|
{/* Water surface wave */}
|
||||||
|
{fillPercent > 5 && (
|
||||||
|
<ellipse
|
||||||
|
cx={x + poolWidth / 2}
|
||||||
|
cy={y + POOL_HEIGHT - fillHeight}
|
||||||
|
rx={poolWidth * 0.3}
|
||||||
|
ry={2}
|
||||||
|
fill="rgba(255,255,255,0.2)"
|
||||||
|
style={{ animation: 'waveFloat 2s ease-in-out infinite' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
<text
|
||||||
|
x={x + poolWidth / 2}
|
||||||
|
y={y - 6}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill={COLORS.text}
|
||||||
|
fontSize={9}
|
||||||
|
fontWeight={600}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Value */}
|
||||||
|
<text
|
||||||
|
x={x + poolWidth / 2}
|
||||||
|
y={y + POOL_HEIGHT / 2 + 2}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="white"
|
||||||
|
fontSize={9}
|
||||||
|
fontWeight={600}
|
||||||
|
fontFamily="monospace"
|
||||||
|
>
|
||||||
|
${Math.floor(data.fundingReceived).toLocaleString()}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<text
|
||||||
|
x={x + poolWidth / 2}
|
||||||
|
y={y + POOL_HEIGHT / 2 + 14}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill={COLORS.textMuted}
|
||||||
|
fontSize={7}
|
||||||
|
fontFamily="monospace"
|
||||||
|
>
|
||||||
|
{Math.round(fillPercent)}% of ${Math.floor(data.fundingTarget).toLocaleString()}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourceBox({ source }: { source: SourceLayout }) {
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<rect
|
||||||
|
x={source.x}
|
||||||
|
y={source.y}
|
||||||
|
width={source.width}
|
||||||
|
height={SOURCE_HEIGHT}
|
||||||
|
rx={8}
|
||||||
|
fill="rgba(16,185,129,0.15)"
|
||||||
|
stroke="#10b981"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={source.x + source.width / 2}
|
||||||
|
y={source.y + SOURCE_HEIGHT / 2 - 4}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="#10b981"
|
||||||
|
fontSize={10}
|
||||||
|
fontWeight={700}
|
||||||
|
>
|
||||||
|
{source.label}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
x={source.x + source.width / 2}
|
||||||
|
y={source.y + SOURCE_HEIGHT / 2 + 10}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="#10b981"
|
||||||
|
fontSize={8}
|
||||||
|
fontFamily="monospace"
|
||||||
|
opacity={0.7}
|
||||||
|
>
|
||||||
|
${source.flowRate.toLocaleString()}/mo
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Component ──────────────────────────────────────
|
||||||
|
|
||||||
|
interface BudgetRiverProps {
|
||||||
|
nodes: FlowNode[]
|
||||||
|
isSimulating?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BudgetRiver({ nodes, isSimulating = false }: BudgetRiverProps) {
|
||||||
|
const [animatedNodes, setAnimatedNodes] = useState<FlowNode[]>(nodes)
|
||||||
|
|
||||||
|
// Update when parent nodes change
|
||||||
|
useEffect(() => {
|
||||||
|
setAnimatedNodes(nodes)
|
||||||
|
}, [nodes])
|
||||||
|
|
||||||
|
// Simulation
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSimulating) return
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setAnimatedNodes(prev =>
|
||||||
|
prev.map(node => {
|
||||||
|
if (node.type === 'funnel') {
|
||||||
|
const data = node.data as FunnelNodeData
|
||||||
|
const change = (Math.random() - 0.45) * 300
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
currentValue: Math.max(0, Math.min(data.maxCapacity * 1.1, data.currentValue + change)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else if (node.type === 'outcome') {
|
||||||
|
const data = node.data as OutcomeNodeData
|
||||||
|
const change = Math.random() * 80
|
||||||
|
const newReceived = Math.min(data.fundingTarget * 1.05, data.fundingReceived + change)
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
fundingReceived: newReceived,
|
||||||
|
status: newReceived >= data.fundingTarget ? 'completed' :
|
||||||
|
data.status === 'not-started' && newReceived > 0 ? 'in-progress' : data.status,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}, 500)
|
||||||
|
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [isSimulating])
|
||||||
|
|
||||||
|
const layout = useMemo(() => computeLayout(animatedNodes), [animatedNodes])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full overflow-auto">
|
||||||
|
<svg
|
||||||
|
width={layout.width}
|
||||||
|
height={layout.height}
|
||||||
|
viewBox={`0 0 ${layout.width} ${layout.height}`}
|
||||||
|
className="mx-auto"
|
||||||
|
style={{ minWidth: layout.width, minHeight: layout.height }}
|
||||||
|
>
|
||||||
|
{/* Background */}
|
||||||
|
<rect width={layout.width} height={layout.height} fill={COLORS.bg} />
|
||||||
|
|
||||||
|
{/* Background stars/dots for atmosphere */}
|
||||||
|
{Array.from({ length: 30 }, (_, i) => (
|
||||||
|
<circle
|
||||||
|
key={`star-${i}`}
|
||||||
|
cx={Math.random() * layout.width}
|
||||||
|
cy={Math.random() * layout.height}
|
||||||
|
r={0.5 + Math.random() * 1}
|
||||||
|
fill="rgba(148,163,184,0.2)"
|
||||||
|
style={{
|
||||||
|
animation: `shimmer ${2 + Math.random() * 3}s ease-in-out infinite`,
|
||||||
|
animationDelay: `${Math.random() * 3}s`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Layer 1: Source boxes */}
|
||||||
|
{layout.sources.map(s => (
|
||||||
|
<SourceBox key={s.id} source={s} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Layer 2: Source waterfalls (flowing into river) */}
|
||||||
|
{layout.sourceWaterfalls.map((wf, i) => (
|
||||||
|
<WaterfallStream key={wf.id} wf={wf} index={i} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Layer 3: River segments */}
|
||||||
|
{layout.funnels.map(f => (
|
||||||
|
<RiverSegment key={f.id} funnel={f} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Layer 4: Overflow branches */}
|
||||||
|
{layout.overflowBranches.map((b, i) => (
|
||||||
|
<OverflowBranch key={`overflow-${i}`} branch={b} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Layer 5: Spending waterfalls (flowing out) */}
|
||||||
|
{layout.spendingWaterfalls.map((wf, i) => (
|
||||||
|
<WaterfallStream key={wf.id} wf={wf} index={i} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Layer 6: Outcome pools */}
|
||||||
|
{layout.outcomes.map(o => (
|
||||||
|
<OutcomePool key={o.id} outcome={o} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Title watermark */}
|
||||||
|
<text
|
||||||
|
x={layout.width / 2}
|
||||||
|
y={layout.height - 20}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="rgba(148,163,184,0.15)"
|
||||||
|
fontSize={14}
|
||||||
|
fontWeight={700}
|
||||||
|
letterSpacing={4}
|
||||||
|
>
|
||||||
|
rFUNDS BUDGET RIVER
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -18,16 +18,17 @@ import '@xyflow/react/dist/style.css'
|
||||||
|
|
||||||
import FunnelNode from './nodes/FunnelNode'
|
import FunnelNode from './nodes/FunnelNode'
|
||||||
import OutcomeNode from './nodes/OutcomeNode'
|
import OutcomeNode from './nodes/OutcomeNode'
|
||||||
|
import SourceNode from './nodes/SourceNode'
|
||||||
import AllocationEdge from './edges/AllocationEdge'
|
import AllocationEdge from './edges/AllocationEdge'
|
||||||
import StreamEdge from './edges/StreamEdge'
|
import StreamEdge from './edges/StreamEdge'
|
||||||
import IntegrationPanel from './IntegrationPanel'
|
import IntegrationPanel from './IntegrationPanel'
|
||||||
import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData, IntegrationConfig, AllocationEdgeData } from '@/lib/types'
|
import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData, SourceNodeData, IntegrationConfig, AllocationEdgeData } from '@/lib/types'
|
||||||
import { SPENDING_COLORS, OVERFLOW_COLORS } from '@/lib/presets'
|
import { SPENDING_COLORS, OVERFLOW_COLORS, SOURCE_COLORS } from '@/lib/presets'
|
||||||
import { simulateTick } from '@/lib/simulation'
|
|
||||||
|
|
||||||
const nodeTypes = {
|
const nodeTypes = {
|
||||||
funnel: FunnelNode,
|
funnel: FunnelNode,
|
||||||
outcome: OutcomeNode,
|
outcome: OutcomeNode,
|
||||||
|
source: SourceNode,
|
||||||
}
|
}
|
||||||
|
|
||||||
const edgeTypes = {
|
const edgeTypes = {
|
||||||
|
|
@ -144,7 +145,7 @@ function generateEdges(
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Stream edges (Superfluid visual planning)
|
// Stream edges (Superfluid planning)
|
||||||
data.streamAllocations?.forEach((stream) => {
|
data.streamAllocations?.forEach((stream) => {
|
||||||
const statusColor = stream.status === 'active' ? '#3b82f6' : stream.status === 'paused' ? '#f97316' : '#22c55e'
|
const statusColor = stream.status === 'active' ? '#3b82f6' : stream.status === 'paused' ? '#f97316' : '#22c55e'
|
||||||
edges.push({
|
edges.push({
|
||||||
|
|
@ -175,6 +176,48 @@ function generateEdges(
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Source node edges
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
if (node.type !== 'source') return
|
||||||
|
const data = node.data as SourceNodeData
|
||||||
|
const rate = data.flowRate || 1
|
||||||
|
const allocCount = data.targetAllocations?.length ?? 0
|
||||||
|
|
||||||
|
data.targetAllocations?.forEach((alloc) => {
|
||||||
|
const flowValue = (alloc.percentage / 100) * rate
|
||||||
|
const strokeWidth = MIN_WIDTH + (flowValue / maxFlow) * (MAX_WIDTH - MIN_WIDTH)
|
||||||
|
|
||||||
|
edges.push({
|
||||||
|
id: `source-${node.id}-${alloc.targetId}`,
|
||||||
|
source: node.id,
|
||||||
|
target: alloc.targetId,
|
||||||
|
sourceHandle: 'source-out',
|
||||||
|
animated: true,
|
||||||
|
style: {
|
||||||
|
stroke: alloc.color || '#10b981',
|
||||||
|
strokeWidth,
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
markerEnd: {
|
||||||
|
type: MarkerType.ArrowClosed,
|
||||||
|
color: alloc.color || '#10b981',
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
allocation: alloc.percentage,
|
||||||
|
color: alloc.color || '#10b981',
|
||||||
|
edgeType: 'spending' as const,
|
||||||
|
sourceId: node.id,
|
||||||
|
targetId: alloc.targetId,
|
||||||
|
siblingCount: allocCount,
|
||||||
|
onAdjust,
|
||||||
|
},
|
||||||
|
type: 'allocation',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
return edges
|
return edges
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -191,6 +234,7 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
const [nodes, setNodes, onNodesChangeHandler] = useNodesState(initNodes)
|
const [nodes, setNodes, onNodesChangeHandler] = useNodesState(initNodes)
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState([] as FlowEdge[])
|
const [edges, setEdges, onEdgesChange] = useEdgesState([] as FlowEdge[])
|
||||||
const [isSimulating, setIsSimulating] = useState(mode === 'demo')
|
const [isSimulating, setIsSimulating] = useState(mode === 'demo')
|
||||||
|
const [panelsCollapsed, setPanelsCollapsed] = useState(false)
|
||||||
const edgesRef = useRef(edges)
|
const edgesRef = useRef(edges)
|
||||||
edgesRef.current = edges
|
edgesRef.current = edges
|
||||||
const { screenToFlowPosition } = useReactFlow()
|
const { screenToFlowPosition } = useReactFlow()
|
||||||
|
|
@ -254,20 +298,29 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
|
|
||||||
// Smart edge regeneration
|
// Smart edge regeneration
|
||||||
const allocationsKey = useMemo(() => {
|
const allocationsKey = useMemo(() => {
|
||||||
return JSON.stringify(
|
const funnelKeys = nodes
|
||||||
nodes
|
.filter(n => n.type === 'funnel')
|
||||||
.filter(n => n.type === 'funnel')
|
.map(n => {
|
||||||
.map(n => {
|
const d = n.data as FunnelNodeData
|
||||||
const d = n.data as FunnelNodeData
|
return {
|
||||||
return {
|
id: n.id,
|
||||||
id: n.id,
|
overflow: d.overflowAllocations,
|
||||||
overflow: d.overflowAllocations,
|
spending: d.spendingAllocations,
|
||||||
spending: d.spendingAllocations,
|
streams: d.streamAllocations,
|
||||||
streams: d.streamAllocations,
|
rate: d.inflowRate,
|
||||||
rate: d.inflowRate,
|
}
|
||||||
}
|
})
|
||||||
})
|
const sourceKeys = nodes
|
||||||
)
|
.filter(n => n.type === 'source')
|
||||||
|
.map(n => {
|
||||||
|
const d = n.data as SourceNodeData
|
||||||
|
return {
|
||||||
|
id: n.id,
|
||||||
|
targets: d.targetAllocations,
|
||||||
|
rate: d.flowRate,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return JSON.stringify({ funnelKeys, sourceKeys })
|
||||||
}, [nodes])
|
}, [nodes])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -282,8 +335,39 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
const isOverflow = params.sourceHandle?.startsWith('outflow')
|
const isOverflow = params.sourceHandle?.startsWith('outflow')
|
||||||
const isSpending = params.sourceHandle === 'spending-out'
|
const isSpending = params.sourceHandle === 'spending-out'
|
||||||
const isStream = params.sourceHandle === 'stream-out'
|
const isStream = params.sourceHandle === 'stream-out'
|
||||||
|
const isSourceOut = params.sourceHandle === 'source-out'
|
||||||
|
|
||||||
if (!isOverflow && !isSpending && !isStream) return
|
if (!isOverflow && !isSpending && !isStream && !isSourceOut) return
|
||||||
|
|
||||||
|
// Handle source node connections
|
||||||
|
if (isSourceOut) {
|
||||||
|
setNodes((nds) => nds.map((node) => {
|
||||||
|
if (node.id !== params.source || node.type !== 'source') return node
|
||||||
|
const data = node.data as SourceNodeData
|
||||||
|
const existing = data.targetAllocations || []
|
||||||
|
if (existing.some(a => a.targetId === params.target)) return node
|
||||||
|
const newPct = existing.length === 0 ? 100 : Math.floor(100 / (existing.length + 1))
|
||||||
|
const redistributed = existing.map(a => ({
|
||||||
|
...a,
|
||||||
|
percentage: Math.floor(a.percentage * existing.length / (existing.length + 1))
|
||||||
|
}))
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
targetAllocations: [
|
||||||
|
...redistributed,
|
||||||
|
{
|
||||||
|
targetId: params.target!,
|
||||||
|
percentage: newPct,
|
||||||
|
color: SOURCE_COLORS[existing.length % SOURCE_COLORS.length],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setNodes((nds) => nds.map((node) => {
|
setNodes((nds) => nds.map((node) => {
|
||||||
if (node.id !== params.source || node.type !== 'funnel') return node
|
if (node.id !== params.source || node.type !== 'funnel') return node
|
||||||
|
|
@ -369,6 +453,24 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
const newTargetId = newConnection.target
|
const newTargetId = newConnection.target
|
||||||
if (oldTargetId === newTargetId) return
|
if (oldTargetId === newTargetId) return
|
||||||
|
|
||||||
|
// Source edges: reconnect source allocation
|
||||||
|
if (oldEdge.id?.startsWith('source-')) {
|
||||||
|
setNodes((nds) => nds.map((node) => {
|
||||||
|
if (node.id !== oldEdge.source || node.type !== 'source') return node
|
||||||
|
const data = node.data as SourceNodeData
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
targetAllocations: (data.targetAllocations || []).map(a =>
|
||||||
|
a.targetId === oldTargetId ? { ...a, targetId: newTargetId } : a
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Stream edges: reconnect stream allocation
|
// Stream edges: reconnect stream allocation
|
||||||
if (oldEdge.type === 'stream') {
|
if (oldEdge.type === 'stream') {
|
||||||
setNodes((nds) => nds.map((node) => {
|
setNodes((nds) => nds.map((node) => {
|
||||||
|
|
@ -423,6 +525,27 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
if (change.type === 'remove') {
|
if (change.type === 'remove') {
|
||||||
const edge = edgesRef.current.find(e => e.id === change.id)
|
const edge = edgesRef.current.find(e => e.id === change.id)
|
||||||
if (edge?.data) {
|
if (edge?.data) {
|
||||||
|
// Source edge removal
|
||||||
|
if (edge.id?.startsWith('source-')) {
|
||||||
|
setNodes((nds) => nds.map((node) => {
|
||||||
|
if (node.id !== edge.source || node.type !== 'source') return node
|
||||||
|
const data = node.data as SourceNodeData
|
||||||
|
const filtered = (data.targetAllocations || []).filter(a => a.targetId !== edge.target)
|
||||||
|
const total = filtered.reduce((s, a) => s + a.percentage, 0)
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
targetAllocations: total > 0
|
||||||
|
? filtered.map(a => ({ ...a, percentage: Math.round((a.percentage / total) * 100) }))
|
||||||
|
: filtered,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
onEdgesChange([change])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setNodes((nds) => nds.map((node) => {
|
setNodes((nds) => nds.map((node) => {
|
||||||
if (node.id !== edge.source || node.type !== 'funnel') return node
|
if (node.id !== edge.source || node.type !== 'funnel') return node
|
||||||
const data = node.data as FunnelNodeData
|
const data = node.data as FunnelNodeData
|
||||||
|
|
@ -524,17 +647,129 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
])
|
])
|
||||||
}, [setNodes, screenToFlowPosition])
|
}, [setNodes, screenToFlowPosition])
|
||||||
|
|
||||||
// Simulation — real flow logic (inflow → overflow → spending → outcomes)
|
// Add source node at viewport center
|
||||||
|
const addSource = useCallback(() => {
|
||||||
|
const pos = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 - 100 })
|
||||||
|
const newId = `source-${Date.now()}`
|
||||||
|
setNodes((nds) => [
|
||||||
|
...nds,
|
||||||
|
{
|
||||||
|
id: newId,
|
||||||
|
type: 'source',
|
||||||
|
position: pos,
|
||||||
|
data: {
|
||||||
|
label: 'New Source',
|
||||||
|
flowRate: 500,
|
||||||
|
sourceType: 'recurring',
|
||||||
|
targetAllocations: [],
|
||||||
|
} as SourceNodeData,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}, [setNodes, screenToFlowPosition])
|
||||||
|
|
||||||
|
// Handle node deletion — clean up references in other nodes
|
||||||
|
const onNodesDelete = useCallback((deletedNodes: FlowNode[]) => {
|
||||||
|
const deletedIds = new Set(deletedNodes.map(n => n.id))
|
||||||
|
|
||||||
|
setNodes((nds) => nds.map((node) => {
|
||||||
|
if (node.type === 'source') {
|
||||||
|
const data = node.data as SourceNodeData
|
||||||
|
const filtered = (data.targetAllocations || []).filter(a => !deletedIds.has(a.targetId))
|
||||||
|
if (filtered.length === data.targetAllocations?.length) return node
|
||||||
|
const total = filtered.reduce((s, a) => s + a.percentage, 0)
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
targetAllocations: total > 0
|
||||||
|
? filtered.map(a => ({ ...a, percentage: Math.round((a.percentage / total) * 100) }))
|
||||||
|
: filtered,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.type === 'funnel') {
|
||||||
|
const data = node.data as FunnelNodeData
|
||||||
|
let changed = false
|
||||||
|
|
||||||
|
let overflow = data.overflowAllocations || []
|
||||||
|
const filteredOverflow = overflow.filter(a => !deletedIds.has(a.targetId))
|
||||||
|
if (filteredOverflow.length !== overflow.length) {
|
||||||
|
changed = true
|
||||||
|
const total = filteredOverflow.reduce((s, a) => s + a.percentage, 0)
|
||||||
|
overflow = total > 0
|
||||||
|
? filteredOverflow.map(a => ({ ...a, percentage: Math.round((a.percentage / total) * 100) }))
|
||||||
|
: filteredOverflow
|
||||||
|
}
|
||||||
|
|
||||||
|
let spending = data.spendingAllocations || []
|
||||||
|
const filteredSpending = spending.filter(a => !deletedIds.has(a.targetId))
|
||||||
|
if (filteredSpending.length !== spending.length) {
|
||||||
|
changed = true
|
||||||
|
const total = filteredSpending.reduce((s, a) => s + a.percentage, 0)
|
||||||
|
spending = total > 0
|
||||||
|
? filteredSpending.map(a => ({ ...a, percentage: Math.round((a.percentage / total) * 100) }))
|
||||||
|
: filteredSpending
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) return node
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
overflowAllocations: overflow,
|
||||||
|
spendingAllocations: spending,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
}))
|
||||||
|
}, [setNodes])
|
||||||
|
|
||||||
|
// Simulation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSimulating) return
|
if (!isSimulating) return
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setNodes((nds) => simulateTick(nds as FlowNode[]))
|
setNodes((nds) =>
|
||||||
}, 1000)
|
nds.map((node) => {
|
||||||
|
if (node.type === 'funnel') {
|
||||||
|
const data = node.data as FunnelNodeData
|
||||||
|
const change = (Math.random() - 0.45) * 300
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
currentValue: Math.max(0, Math.min(data.maxCapacity * 1.1, data.currentValue + change)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else if (node.type === 'outcome') {
|
||||||
|
const data = node.data as OutcomeNodeData
|
||||||
|
const change = Math.random() * 80
|
||||||
|
const newReceived = Math.min(data.fundingTarget * 1.05, data.fundingReceived + change)
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
fundingReceived: newReceived,
|
||||||
|
status: newReceived >= data.fundingTarget ? 'completed' :
|
||||||
|
data.status === 'not-started' && newReceived > 0 ? 'in-progress' : data.status,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}, 500)
|
||||||
|
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [isSimulating, setNodes])
|
}, [isSimulating, setNodes])
|
||||||
|
|
||||||
|
// Auto-collapse title & legend panels after 5 seconds
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setPanelsCollapsed(true), 5000)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full">
|
<div className="w-full h-full">
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
|
|
@ -542,11 +777,13 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
edges={edges}
|
edges={edges}
|
||||||
onNodesChange={onNodesChangeHandler}
|
onNodesChange={onNodesChangeHandler}
|
||||||
onEdgesChange={handleEdgesChange}
|
onEdgesChange={handleEdgesChange}
|
||||||
|
onNodesDelete={onNodesDelete}
|
||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
onReconnect={onReconnect}
|
onReconnect={onReconnect}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
edgesReconnectable={true}
|
edgesReconnectable={true}
|
||||||
|
deleteKeyCode={['Backspace', 'Delete']}
|
||||||
fitView
|
fitView
|
||||||
fitViewOptions={{ padding: 0.15 }}
|
fitViewOptions={{ padding: 0.15 }}
|
||||||
className="bg-slate-50"
|
className="bg-slate-50"
|
||||||
|
|
@ -558,42 +795,60 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
<Controls className="bg-white border border-slate-200 rounded-lg shadow-sm" />
|
<Controls className="bg-white border border-slate-200 rounded-lg shadow-sm" />
|
||||||
|
|
||||||
{/* Title Panel */}
|
{/* Title Panel */}
|
||||||
<Panel position="top-left" className="bg-white rounded-lg shadow-lg border border-slate-200 p-4 m-4">
|
<Panel position="top-left" className="m-4">
|
||||||
<h1 className="text-lg font-bold text-slate-800">Threshold-Based Flow Funding</h1>
|
<div
|
||||||
<p className="text-xs text-slate-500 mt-1">
|
className="bg-white rounded-lg shadow-lg border border-slate-200 cursor-pointer transition-all duration-300 overflow-hidden"
|
||||||
<span className="text-emerald-600">Inflows</span> (top) •
|
onClick={() => setPanelsCollapsed((c) => !c)}
|
||||||
<span className="text-amber-600 ml-1">Overflow</span> (sides) •
|
>
|
||||||
<span className="text-blue-600 ml-1">Spending</span> (bottom)
|
<div className="px-4 py-2 flex items-center gap-2">
|
||||||
</p>
|
<h1 className="text-sm font-bold text-slate-800">TBFF</h1>
|
||||||
<p className="text-[10px] text-slate-400 mt-1">
|
<svg className={`w-3 h-3 text-slate-400 transition-transform duration-300 ${panelsCollapsed ? '' : 'rotate-180'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
Drag handles to connect • Double-click funnels to edit • Select + Delete to remove edges
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
</p>
|
</svg>
|
||||||
|
</div>
|
||||||
|
{!panelsCollapsed && (
|
||||||
|
<div className="px-4 pb-3 border-t border-slate-100 pt-2">
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
<span className="text-emerald-600">Sources</span> →
|
||||||
|
<span className="text-amber-600 ml-1">Funnels</span> →
|
||||||
|
<span className="text-pink-600 ml-1">Outcomes</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-slate-400 mt-1">
|
||||||
|
Drag handles to connect • Double-click to edit • Select + Delete to remove
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{/* Top-right Controls */}
|
{/* Top-right Controls */}
|
||||||
<Panel position="top-right" className="m-4 flex gap-2">
|
<Panel position="top-right" className="m-4 flex gap-2">
|
||||||
{mode === 'space' && (
|
{mode === 'space' && (
|
||||||
<>
|
<button
|
||||||
<button
|
onClick={() => setShowIntegrations(true)}
|
||||||
onClick={() => setShowIntegrations(true)}
|
className="px-3 py-2 rounded-lg font-medium shadow-sm text-sm bg-indigo-500 text-white hover:bg-indigo-600 transition-all"
|
||||||
className="px-3 py-2 rounded-lg font-medium shadow-sm text-sm bg-indigo-500 text-white hover:bg-indigo-600 transition-all"
|
>
|
||||||
>
|
Link Data
|
||||||
Link Data
|
</button>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={addFunnel}
|
|
||||||
className="px-3 py-2 rounded-lg font-medium shadow-sm text-sm bg-amber-500 text-white hover:bg-amber-600 transition-all"
|
|
||||||
>
|
|
||||||
+ Funnel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={addOutcome}
|
|
||||||
className="px-3 py-2 rounded-lg font-medium shadow-sm text-sm bg-pink-500 text-white hover:bg-pink-600 transition-all"
|
|
||||||
>
|
|
||||||
+ Outcome
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
onClick={addSource}
|
||||||
|
className="px-3 py-2 rounded-lg font-medium shadow-sm text-sm bg-emerald-500 text-white hover:bg-emerald-600 transition-all"
|
||||||
|
>
|
||||||
|
+ Source
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={addFunnel}
|
||||||
|
className="px-3 py-2 rounded-lg font-medium shadow-sm text-sm bg-amber-500 text-white hover:bg-amber-600 transition-all"
|
||||||
|
>
|
||||||
|
+ Funnel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={addOutcome}
|
||||||
|
className="px-3 py-2 rounded-lg font-medium shadow-sm text-sm bg-pink-500 text-white hover:bg-pink-600 transition-all"
|
||||||
|
>
|
||||||
|
+ Outcome
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsSimulating(!isSimulating)}
|
onClick={() => setIsSimulating(!isSimulating)}
|
||||||
className={`px-4 py-2 rounded-lg font-medium shadow-sm transition-all text-sm ${
|
className={`px-4 py-2 rounded-lg font-medium shadow-sm transition-all text-sm ${
|
||||||
|
|
@ -607,28 +862,59 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
<Panel position="bottom-left" className="bg-white rounded-lg shadow-lg border border-slate-200 p-3 m-4">
|
<Panel position="bottom-left" className="m-4">
|
||||||
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide mb-2">Flow Types</div>
|
<div
|
||||||
<div className="space-y-1.5 text-xs">
|
className="bg-white rounded-lg shadow-lg border border-slate-200 cursor-pointer transition-all duration-300 overflow-hidden"
|
||||||
<div className="flex items-center gap-2">
|
onClick={() => setPanelsCollapsed((c) => !c)}
|
||||||
<div className="w-3 h-3 rounded-full bg-emerald-500" />
|
>
|
||||||
<span className="text-slate-600">Inflows (top)</span>
|
<div className="px-3 py-2 flex items-center gap-2">
|
||||||
</div>
|
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide">Legend</div>
|
||||||
<div className="flex items-center gap-2">
|
{panelsCollapsed && (
|
||||||
<div className="w-3 h-3 rounded-full bg-amber-500" />
|
<div className="flex items-center gap-1 ml-1">
|
||||||
<span className="text-slate-600">Overflow (sides)</span>
|
<div className="w-2 h-2 rounded-sm bg-emerald-500" />
|
||||||
</div>
|
<div className="w-2 h-2 rounded-sm bg-amber-500" />
|
||||||
<div className="flex items-center gap-2">
|
<div className="w-2 h-2 rounded-sm bg-pink-500" />
|
||||||
<div className="w-3 h-3 rounded-full bg-blue-500" />
|
</div>
|
||||||
<span className="text-slate-600">Spending (bottom)</span>
|
)}
|
||||||
</div>
|
<svg className={`w-3 h-3 text-slate-400 transition-transform duration-300 ${panelsCollapsed ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<div className="flex items-center gap-2">
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
||||||
<div className="w-3 h-3 rounded-full bg-green-500 border border-dashed border-green-700" />
|
</svg>
|
||||||
<span className="text-slate-600">Stream (Superfluid)</span>
|
|
||||||
</div>
|
|
||||||
<div className="border-t border-slate-200 pt-1.5 mt-1.5">
|
|
||||||
<span className="text-[10px] text-slate-400">Edge width = relative flow amount</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
{!panelsCollapsed && (
|
||||||
|
<div className="px-3 pb-3 border-t border-slate-100 pt-2">
|
||||||
|
<div className="space-y-1.5 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded bg-emerald-500" />
|
||||||
|
<span className="text-slate-600">Source (funding origin)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded bg-amber-500" />
|
||||||
|
<span className="text-slate-600">Funnel (threshold pool)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded bg-pink-500" />
|
||||||
|
<span className="text-slate-600">Outcome (deliverable)</span>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-slate-200 pt-1.5 mt-1.5 space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-0.5 bg-emerald-500" />
|
||||||
|
<span className="text-slate-600">Source flow</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-0.5 bg-amber-500" />
|
||||||
|
<span className="text-slate-600">Overflow (sides)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-0.5 bg-blue-500" />
|
||||||
|
<span className="text-slate-600">Spending (down)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-slate-200 pt-1.5 mt-1.5">
|
||||||
|
<span className="text-[10px] text-slate-400">Edge width = relative flow • Select + Delete to remove</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { memo, useState, useCallback } from 'react'
|
||||||
|
import { Handle, Position, useReactFlow } from '@xyflow/react'
|
||||||
|
import type { NodeProps } from '@xyflow/react'
|
||||||
|
import type { SourceNodeData } from '@/lib/types'
|
||||||
|
|
||||||
|
const SOURCE_COLORS = ['#10b981', '#14b8a6', '#06b6d4', '#0ea5e9']
|
||||||
|
|
||||||
|
function SourceNode({ data, selected, id }: NodeProps) {
|
||||||
|
const nodeData = data as SourceNodeData
|
||||||
|
const { label, flowRate, sourceType, targetAllocations = [] } = nodeData
|
||||||
|
const { setNodes } = useReactFlow()
|
||||||
|
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const [editLabel, setEditLabel] = useState(label)
|
||||||
|
const [editRate, setEditRate] = useState(String(flowRate))
|
||||||
|
const [editType, setEditType] = useState(sourceType)
|
||||||
|
|
||||||
|
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setEditLabel(label)
|
||||||
|
setEditRate(String(flowRate))
|
||||||
|
setEditType(sourceType)
|
||||||
|
setIsEditing(true)
|
||||||
|
}, [label, flowRate, sourceType])
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
setNodes((nds) => nds.map((node) => {
|
||||||
|
if (node.id !== id) return node
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...(node.data as SourceNodeData),
|
||||||
|
label: editLabel.trim() || 'Source',
|
||||||
|
flowRate: Math.max(0, Number(editRate) || 0),
|
||||||
|
sourceType: editType,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
setIsEditing(false)
|
||||||
|
}, [id, editLabel, editRate, editType, setNodes])
|
||||||
|
|
||||||
|
const typeLabels = {
|
||||||
|
'recurring': 'Recurring',
|
||||||
|
'one-time': 'One-time',
|
||||||
|
'treasury': 'Treasury',
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeColors = {
|
||||||
|
'recurring': 'bg-emerald-100 text-emerald-700',
|
||||||
|
'one-time': 'bg-blue-100 text-blue-700',
|
||||||
|
'treasury': 'bg-violet-100 text-violet-700',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
bg-white rounded-xl shadow-lg border-2 min-w-[180px] max-w-[220px]
|
||||||
|
transition-all duration-200
|
||||||
|
${selected ? 'border-emerald-500 shadow-emerald-200' : 'border-slate-200'}
|
||||||
|
`}
|
||||||
|
onDoubleClick={handleDoubleClick}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-3 py-2 border-b border-slate-100 bg-gradient-to-r from-emerald-50 to-teal-50 rounded-t-[10px]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-6 h-6 bg-emerald-500 rounded flex items-center justify-center flex-shrink-0">
|
||||||
|
<svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-slate-800 text-sm truncate">{label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className={`text-[10px] px-2 py-0.5 rounded-full font-medium uppercase tracking-wide ${typeColors[sourceType]}`}>
|
||||||
|
{typeLabels[sourceType]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<span className="text-lg font-bold font-mono text-emerald-600">
|
||||||
|
${flowRate.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-400 ml-1">/mo</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Allocation bars */}
|
||||||
|
{targetAllocations.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[8px] text-emerald-600 uppercase w-10">Flow</span>
|
||||||
|
<div className="flex-1 flex h-2 rounded overflow-hidden">
|
||||||
|
{targetAllocations.map((alloc, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: alloc.color || SOURCE_COLORS[idx % SOURCE_COLORS.length],
|
||||||
|
width: `${alloc.percentage}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Flow indicator */}
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<svg className="w-3 h-3 text-emerald-500" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 16l6-6h-4V4h-4v6H6z"/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-[9px] text-emerald-500 uppercase font-medium">Outflow</span>
|
||||||
|
<svg className="w-3 h-3 text-emerald-500" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 16l6-6h-4V4h-4v6H6z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<span className="text-[8px] text-slate-400">Double-click to edit</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom handle — connects to funnel top */}
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
id="source-out"
|
||||||
|
className="!w-4 !h-4 !bg-emerald-500 !border-2 !border-white !-bottom-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
{isEditing && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
onClick={() => setIsEditing(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-white rounded-2xl shadow-2xl p-6 w-80"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-bold text-slate-800">Edit Source</h3>
|
||||||
|
<button onClick={() => setIsEditing(false)} className="text-slate-400 hover:text-slate-600">
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-500 uppercase tracking-wide block mb-1">Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editLabel}
|
||||||
|
onChange={(e) => setEditLabel(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm text-slate-800"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-500 uppercase tracking-wide block mb-1">Flow Rate ($/mo)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={editRate}
|
||||||
|
onChange={(e) => setEditRate(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm text-slate-800 font-mono"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-500 uppercase tracking-wide block mb-1">Type</label>
|
||||||
|
<select
|
||||||
|
value={editType}
|
||||||
|
onChange={(e) => setEditType(e.target.value as SourceNodeData['sourceType'])}
|
||||||
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm text-slate-800"
|
||||||
|
>
|
||||||
|
<option value="recurring">Recurring</option>
|
||||||
|
<option value="one-time">One-time</option>
|
||||||
|
<option value="treasury">Treasury</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center mt-6">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="px-6 py-2 bg-slate-800 text-white rounded-lg hover:bg-slate-700 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(SourceNode)
|
||||||
|
|
@ -1,12 +1,27 @@
|
||||||
import type { FlowNode, FunnelNodeData, OutcomeNodeData } from './types'
|
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData } from './types'
|
||||||
|
|
||||||
// Colors for allocations
|
// Colors for allocations
|
||||||
export const SPENDING_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981', '#6366f1']
|
export const SPENDING_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981', '#6366f1']
|
||||||
export const OVERFLOW_COLORS = ['#f59e0b', '#ef4444', '#f97316', '#eab308', '#dc2626', '#ea580c']
|
export const OVERFLOW_COLORS = ['#f59e0b', '#ef4444', '#f97316', '#eab308', '#dc2626', '#ea580c']
|
||||||
|
export const SOURCE_COLORS = ['#10b981', '#14b8a6', '#06b6d4', '#0ea5e9']
|
||||||
|
|
||||||
// Demo preset: Treasury → 3 sub-funnels → 7 outcomes
|
// Demo preset: Source → Treasury → 3 sub-funnels → 7 outcomes
|
||||||
export const demoNodes: FlowNode[] = [
|
export const demoNodes: FlowNode[] = [
|
||||||
// Main Treasury Funnel (top center)
|
// Revenue source (top)
|
||||||
|
{
|
||||||
|
id: 'revenue',
|
||||||
|
type: 'source',
|
||||||
|
position: { x: 660, y: -200 },
|
||||||
|
data: {
|
||||||
|
label: 'Revenue Stream',
|
||||||
|
flowRate: 5000,
|
||||||
|
sourceType: 'recurring',
|
||||||
|
targetAllocations: [
|
||||||
|
{ targetId: 'treasury', percentage: 100, color: '#10b981' },
|
||||||
|
],
|
||||||
|
} as SourceNodeData,
|
||||||
|
},
|
||||||
|
// Main Treasury Funnel
|
||||||
{
|
{
|
||||||
id: 'treasury',
|
id: 'treasury',
|
||||||
type: 'funnel',
|
type: 'funnel',
|
||||||
|
|
|
||||||
18
lib/types.ts
18
lib/types.ts
|
|
@ -83,6 +83,22 @@ export interface FundingSource {
|
||||||
lastUsedAt?: number;
|
lastUsedAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Source Node Types ───────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SourceAllocation {
|
||||||
|
targetId: string
|
||||||
|
percentage: number // 0-100
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourceNodeData {
|
||||||
|
label: string
|
||||||
|
flowRate: number // tokens per month flowing out
|
||||||
|
sourceType: 'recurring' | 'one-time' | 'treasury'
|
||||||
|
targetAllocations: SourceAllocation[]
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Core Flow Types ─────────────────────────────────────────
|
// ─── Core Flow Types ─────────────────────────────────────────
|
||||||
|
|
||||||
// Overflow allocation - funds flowing to OTHER FUNNELS when above max threshold
|
// Overflow allocation - funds flowing to OTHER FUNNELS when above max threshold
|
||||||
|
|
@ -129,7 +145,7 @@ export interface OutcomeNodeData {
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FlowNode = Node<FunnelNodeData | OutcomeNodeData>
|
export type FlowNode = Node<FunnelNodeData | OutcomeNodeData | SourceNodeData>
|
||||||
|
|
||||||
export interface AllocationEdgeData {
|
export interface AllocationEdgeData {
|
||||||
allocation: number // percentage 0-100
|
allocation: number // percentage 0-100
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue