Merge branch 'main' of ssh://gitea.jeffemmett.com:223/jeffemmett/rfunds-online
# Conflicts: # app/page.tsx
This commit is contained in:
commit
d97adce1cd
|
|
@ -25,3 +25,48 @@ body {
|
|||
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; }
|
||||
}
|
||||
|
||||
@keyframes mergeSplash {
|
||||
0% { rx: 2; ry: 1; opacity: 0.5; }
|
||||
100% { rx: 20; ry: 4; opacity: 0; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,9 @@ export default function RootLayout({
|
|||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<script defer src="https://rdata.online/collect.js" data-website-id="0ebf1cfd-57e4-4fbe-925d-cd541d7bb3a8" />
|
||||
</head>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||
{children}
|
||||
</body>
|
||||
|
|
|
|||
44
app/page.tsx
44
app/page.tsx
|
|
@ -1,5 +1,6 @@
|
|||
import Link from 'next/link'
|
||||
import { AppSwitcher } from '@/components/AppSwitcher'
|
||||
import { AuthButton } from '@/components/AuthButton'
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
|
|
@ -21,12 +22,19 @@ export default function Home() {
|
|||
>
|
||||
Demo
|
||||
</Link>
|
||||
<Link
|
||||
href="/river"
|
||||
className="text-sm text-slate-300 hover:text-white transition-colors"
|
||||
>
|
||||
River View
|
||||
</Link>
|
||||
<Link
|
||||
href="/space"
|
||||
className="text-sm px-4 py-2 bg-emerald-600 hover:bg-emerald-500 rounded-lg transition-colors font-medium"
|
||||
>
|
||||
Create Space
|
||||
</Link>
|
||||
<AuthButton />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
|
@ -48,6 +56,12 @@ export default function Home() {
|
|||
>
|
||||
Try the Demo
|
||||
</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
|
||||
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"
|
||||
|
|
@ -167,17 +181,25 @@ export default function Home() {
|
|||
<div className="max-w-6xl mx-auto px-6">
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 text-sm text-slate-500 mb-4">
|
||||
<span className="font-medium text-slate-400">r* Ecosystem</span>
|
||||
<a href="https://rspace.online" className="hover:text-slate-300 transition-colors">🌌 rSpace</a>
|
||||
<a href="https://rmaps.online" className="hover:text-slate-300 transition-colors">🗺️ rMaps</a>
|
||||
<a href="https://rnotes.online" className="hover:text-slate-300 transition-colors">📝 rNotes</a>
|
||||
<a href="https://rvote.online" className="hover:text-slate-300 transition-colors">🗳️ rVote</a>
|
||||
<a href="https://rfunds.online" className="hover:text-slate-300 transition-colors font-medium text-slate-300">💰 rFunds</a>
|
||||
<a href="https://rtrips.online" className="hover:text-slate-300 transition-colors">✈️ rTrips</a>
|
||||
<a href="https://rcart.online" className="hover:text-slate-300 transition-colors">🛒 rCart</a>
|
||||
<a href="https://rwallet.online" className="hover:text-slate-300 transition-colors">💼 rWallet</a>
|
||||
<a href="https://rfiles.online" className="hover:text-slate-300 transition-colors">📁 rFiles</a>
|
||||
<a href="https://rinbox.online" className="hover:text-slate-300 transition-colors">✉️ rInbox</a>
|
||||
<a href="https://rnetwork.online" className="hover:text-slate-300 transition-colors">🌐 rNetwork</a>
|
||||
<a href="https://rspace.online" className="hover:text-slate-300 transition-colors">rSpace</a>
|
||||
<a href="https://rmaps.online" className="hover:text-slate-300 transition-colors">rMaps</a>
|
||||
<a href="https://rnotes.online" className="hover:text-slate-300 transition-colors">rNotes</a>
|
||||
<a href="https://rvote.online" className="hover:text-slate-300 transition-colors">rVote</a>
|
||||
<a href="https://rfunds.online" className="hover:text-slate-300 transition-colors font-medium text-slate-300">rFunds</a>
|
||||
<a href="https://rtrips.online" className="hover:text-slate-300 transition-colors">rTrips</a>
|
||||
<a href="https://rcart.online" className="hover:text-slate-300 transition-colors">rCart</a>
|
||||
<a href="https://rchoices.online" className="hover:text-slate-300 transition-colors">rChoices</a>
|
||||
<a href="https://rwallet.online" className="hover:text-slate-300 transition-colors">rWallet</a>
|
||||
<a href="https://rfiles.online" className="hover:text-slate-300 transition-colors">rFiles</a>
|
||||
<a href="https://rtube.online" className="hover:text-slate-300 transition-colors">rTube</a>
|
||||
<a href="https://rcal.online" className="hover:text-slate-300 transition-colors">rCal</a>
|
||||
<a href="https://rnetwork.online" className="hover:text-slate-300 transition-colors">rNetwork</a>
|
||||
<a href="https://rinbox.online" className="hover:text-slate-300 transition-colors">rInbox</a>
|
||||
<a href="https://rstack.online" className="hover:text-slate-300 transition-colors">rStack</a>
|
||||
<a href="https://rauctions.online" className="hover:text-slate-300 transition-colors">rAuctions</a>
|
||||
<a href="https://rpubs.online" className="hover:text-slate-300 transition-colors">rPubs</a>
|
||||
<a href="https://rbooks.online" className="hover:text-slate-300 transition-colors">rBooks</a>
|
||||
<a href="https://rdata.online" className="hover:text-slate-300 transition-colors">rData</a>
|
||||
</div>
|
||||
<p className="text-center text-xs text-slate-600">
|
||||
Part of the r* ecosystem — collaborative tools for communities.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,374 @@
|
|||
'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, computeTransferSummary } from '@/lib/api/safe-client'
|
||||
import { safeBalancesToFunnels } from '@/lib/integrations'
|
||||
import { getFlow, listFlows, syncNodesToBackend, fromSmallestUnit } from '@/lib/api/flows-client'
|
||||
import { computeSystemSufficiency } from '@/lib/simulation'
|
||||
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>
|
||||
),
|
||||
})
|
||||
|
||||
function EnoughnessPill({ nodes }: { nodes: FlowNode[] }) {
|
||||
const score = Math.round(computeSystemSufficiency(nodes) * 100)
|
||||
const color = score >= 90 ? 'bg-amber-500/30 text-amber-300' :
|
||||
score >= 60 ? 'bg-emerald-600/30 text-emerald-300' :
|
||||
score >= 30 ? 'bg-amber-600/30 text-amber-300' :
|
||||
'bg-red-600/30 text-red-300'
|
||||
return (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${color}`}>
|
||||
Enoughness: {score}%
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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 (Ethereum, Base, Polygon, Arbitrum, Optimism, Gnosis)', 'error')
|
||||
setConnecting(false)
|
||||
return
|
||||
}
|
||||
|
||||
const chainNames = detected.map(d => d.chain.name)
|
||||
setConnectedChains(chainNames)
|
||||
|
||||
// Fetch balances + transfer history from all detected chains
|
||||
const allFunnelNodes: FlowNode[] = []
|
||||
for (const chain of detected) {
|
||||
const [balances, transferSummary] = await Promise.all([
|
||||
getBalances(safeAddress.trim(), chain.chainId),
|
||||
computeTransferSummary(safeAddress.trim(), chain.chainId),
|
||||
])
|
||||
const funnels = safeBalancesToFunnels(
|
||||
balances,
|
||||
safeAddress.trim(),
|
||||
chain.chainId,
|
||||
transferSummary,
|
||||
{ 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>
|
||||
<span className="text-slate-500">|</span>
|
||||
<EnoughnessPill nodes={nodes} />
|
||||
{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 across Ethereum, Base, Polygon, Arbitrum, Optimism, and Gnosis.
|
||||
</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 className="flex items-center gap-2">
|
||||
<div className="w-3 h-1.5 rounded bg-amber-400" style={{ boxShadow: '0 0 6px #fbbf24' }} />
|
||||
<span className="text-slate-400">Sufficient (golden)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -18,16 +18,17 @@ import '@xyflow/react/dist/style.css'
|
|||
|
||||
import FunnelNode from './nodes/FunnelNode'
|
||||
import OutcomeNode from './nodes/OutcomeNode'
|
||||
import SourceNode from './nodes/SourceNode'
|
||||
import AllocationEdge from './edges/AllocationEdge'
|
||||
import StreamEdge from './edges/StreamEdge'
|
||||
import IntegrationPanel from './IntegrationPanel'
|
||||
import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData, IntegrationConfig, AllocationEdgeData } from '@/lib/types'
|
||||
import { SPENDING_COLORS, OVERFLOW_COLORS } from '@/lib/presets'
|
||||
import { simulateTick } from '@/lib/simulation'
|
||||
import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData, SourceNodeData, IntegrationConfig, AllocationEdgeData } from '@/lib/types'
|
||||
import { SPENDING_COLORS, OVERFLOW_COLORS, SOURCE_COLORS } from '@/lib/presets'
|
||||
|
||||
const nodeTypes = {
|
||||
funnel: FunnelNode,
|
||||
outcome: OutcomeNode,
|
||||
source: SourceNode,
|
||||
}
|
||||
|
||||
const edgeTypes = {
|
||||
|
|
@ -144,7 +145,7 @@ function generateEdges(
|
|||
})
|
||||
})
|
||||
|
||||
// Stream edges (Superfluid visual planning)
|
||||
// Stream edges (Superfluid planning)
|
||||
data.streamAllocations?.forEach((stream) => {
|
||||
const statusColor = stream.status === 'active' ? '#3b82f6' : stream.status === 'paused' ? '#f97316' : '#22c55e'
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -191,6 +234,7 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
|||
const [nodes, setNodes, onNodesChangeHandler] = useNodesState(initNodes)
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([] as FlowEdge[])
|
||||
const [isSimulating, setIsSimulating] = useState(mode === 'demo')
|
||||
const [panelsCollapsed, setPanelsCollapsed] = useState(false)
|
||||
const edgesRef = useRef(edges)
|
||||
edgesRef.current = edges
|
||||
const { screenToFlowPosition } = useReactFlow()
|
||||
|
|
@ -254,20 +298,29 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
|||
|
||||
// Smart edge regeneration
|
||||
const allocationsKey = useMemo(() => {
|
||||
return JSON.stringify(
|
||||
nodes
|
||||
.filter(n => n.type === 'funnel')
|
||||
.map(n => {
|
||||
const d = n.data as FunnelNodeData
|
||||
return {
|
||||
id: n.id,
|
||||
overflow: d.overflowAllocations,
|
||||
spending: d.spendingAllocations,
|
||||
streams: d.streamAllocations,
|
||||
rate: d.inflowRate,
|
||||
}
|
||||
})
|
||||
)
|
||||
const funnelKeys = nodes
|
||||
.filter(n => n.type === 'funnel')
|
||||
.map(n => {
|
||||
const d = n.data as FunnelNodeData
|
||||
return {
|
||||
id: n.id,
|
||||
overflow: d.overflowAllocations,
|
||||
spending: d.spendingAllocations,
|
||||
streams: d.streamAllocations,
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -282,8 +335,39 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
|||
const isOverflow = params.sourceHandle?.startsWith('outflow')
|
||||
const isSpending = params.sourceHandle === 'spending-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) => {
|
||||
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
|
||||
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
|
||||
if (oldEdge.type === 'stream') {
|
||||
setNodes((nds) => nds.map((node) => {
|
||||
|
|
@ -423,6 +525,27 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
|||
if (change.type === 'remove') {
|
||||
const edge = edgesRef.current.find(e => e.id === change.id)
|
||||
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) => {
|
||||
if (node.id !== edge.source || node.type !== 'funnel') return node
|
||||
const data = node.data as FunnelNodeData
|
||||
|
|
@ -524,17 +647,129 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
|||
])
|
||||
}, [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(() => {
|
||||
if (!isSimulating) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setNodes((nds) => simulateTick(nds as FlowNode[]))
|
||||
}, 1000)
|
||||
setNodes((nds) =>
|
||||
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)
|
||||
}, [isSimulating, setNodes])
|
||||
|
||||
// Auto-collapse title & legend panels after 5 seconds
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setPanelsCollapsed(true), 5000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<ReactFlow
|
||||
|
|
@ -542,11 +777,13 @@ function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange, integra
|
|||
edges={edges}
|
||||
onNodesChange={onNodesChangeHandler}
|
||||
onEdgesChange={handleEdgesChange}
|
||||
onNodesDelete={onNodesDelete}
|
||||
onConnect={onConnect}
|
||||
onReconnect={onReconnect}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
edgesReconnectable={true}
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.15 }}
|
||||
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" />
|
||||
|
||||
{/* Title Panel */}
|
||||
<Panel position="top-left" className="bg-white rounded-lg shadow-lg border border-slate-200 p-4 m-4">
|
||||
<h1 className="text-lg font-bold text-slate-800">Threshold-Based Flow Funding</h1>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
<span className="text-emerald-600">Inflows</span> (top) •
|
||||
<span className="text-amber-600 ml-1">Overflow</span> (sides) •
|
||||
<span className="text-blue-600 ml-1">Spending</span> (bottom)
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-400 mt-1">
|
||||
Drag handles to connect • Double-click funnels to edit • Select + Delete to remove edges
|
||||
</p>
|
||||
<Panel position="top-left" className="m-4">
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-lg border border-slate-200 cursor-pointer transition-all duration-300 overflow-hidden"
|
||||
onClick={() => setPanelsCollapsed((c) => !c)}
|
||||
>
|
||||
<div className="px-4 py-2 flex items-center gap-2">
|
||||
<h1 className="text-sm font-bold text-slate-800">TBFF</h1>
|
||||
<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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</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>
|
||||
|
||||
{/* Top-right Controls */}
|
||||
<Panel position="top-right" className="m-4 flex gap-2">
|
||||
{mode === 'space' && (
|
||||
<>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Link Data
|
||||
</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={() => 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"
|
||||
>
|
||||
Link Data
|
||||
</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
|
||||
onClick={() => setIsSimulating(!isSimulating)}
|
||||
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>
|
||||
|
||||
{/* Legend */}
|
||||
<Panel position="bottom-left" className="bg-white rounded-lg shadow-lg border border-slate-200 p-3 m-4">
|
||||
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide mb-2">Flow Types</div>
|
||||
<div className="space-y-1.5 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-emerald-500" />
|
||||
<span className="text-slate-600">Inflows (top)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-amber-500" />
|
||||
<span className="text-slate-600">Overflow (sides)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-blue-500" />
|
||||
<span className="text-slate-600">Spending (bottom)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-green-500 border border-dashed border-green-700" />
|
||||
<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>
|
||||
<Panel position="bottom-left" className="m-4">
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-lg border border-slate-200 cursor-pointer transition-all duration-300 overflow-hidden"
|
||||
onClick={() => setPanelsCollapsed((c) => !c)}
|
||||
>
|
||||
<div className="px-3 py-2 flex items-center gap-2">
|
||||
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide">Legend</div>
|
||||
{panelsCollapsed && (
|
||||
<div className="flex items-center gap-1 ml-1">
|
||||
<div className="w-2 h-2 rounded-sm bg-emerald-500" />
|
||||
<div className="w-2 h-2 rounded-sm bg-amber-500" />
|
||||
<div className="w-2 h-2 rounded-sm bg-pink-500" />
|
||||
</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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</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>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ export default function IntegrationPanel({
|
|||
let xOffset = 0
|
||||
|
||||
balances.forEach((chainBalances, chainId) => {
|
||||
const nodes = safeBalancesToFunnels(chainBalances, safeAddress, chainId, {
|
||||
const nodes = safeBalancesToFunnels(chainBalances, safeAddress, chainId, undefined, {
|
||||
x: xOffset,
|
||||
y: 100,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -13,6 +13,22 @@ export interface ChainConfig {
|
|||
}
|
||||
|
||||
export const SUPPORTED_CHAINS: Record<number, ChainConfig> = {
|
||||
1: {
|
||||
name: 'Ethereum',
|
||||
slug: 'mainnet',
|
||||
txService: 'https://safe-transaction-mainnet.safe.global',
|
||||
explorer: 'https://etherscan.io',
|
||||
color: '#627eea',
|
||||
symbol: 'ETH',
|
||||
},
|
||||
10: {
|
||||
name: 'Optimism',
|
||||
slug: 'optimism',
|
||||
txService: 'https://safe-transaction-optimism.safe.global',
|
||||
explorer: 'https://optimistic.etherscan.io',
|
||||
color: '#ff0420',
|
||||
symbol: 'ETH',
|
||||
},
|
||||
100: {
|
||||
name: 'Gnosis',
|
||||
slug: 'gnosis-chain',
|
||||
|
|
@ -21,12 +37,28 @@ export const SUPPORTED_CHAINS: Record<number, ChainConfig> = {
|
|||
color: '#04795b',
|
||||
symbol: 'xDAI',
|
||||
},
|
||||
10: {
|
||||
name: 'Optimism',
|
||||
slug: 'optimism',
|
||||
txService: 'https://safe-transaction-optimism.safe.global',
|
||||
explorer: 'https://optimistic.etherscan.io',
|
||||
color: '#ff0420',
|
||||
137: {
|
||||
name: 'Polygon',
|
||||
slug: 'polygon',
|
||||
txService: 'https://safe-transaction-polygon.safe.global',
|
||||
explorer: 'https://polygonscan.com',
|
||||
color: '#8247e5',
|
||||
symbol: 'MATIC',
|
||||
},
|
||||
8453: {
|
||||
name: 'Base',
|
||||
slug: 'base',
|
||||
txService: 'https://safe-transaction-base.safe.global',
|
||||
explorer: 'https://basescan.org',
|
||||
color: '#0052ff',
|
||||
symbol: 'ETH',
|
||||
},
|
||||
42161: {
|
||||
name: 'Arbitrum One',
|
||||
slug: 'arbitrum',
|
||||
txService: 'https://safe-transaction-arbitrum.safe.global',
|
||||
explorer: 'https://arbiscan.io',
|
||||
color: '#28a0f0',
|
||||
symbol: 'ETH',
|
||||
},
|
||||
}
|
||||
|
|
@ -148,3 +180,99 @@ export async function detectSafeChains(
|
|||
|
||||
return results
|
||||
}
|
||||
|
||||
// ─── Transfer History ──────────────────────────────────────
|
||||
|
||||
export interface SafeTransfer {
|
||||
type: 'ETHER_TRANSFER' | 'ERC20_TRANSFER'
|
||||
executionDate: string
|
||||
transactionHash: string
|
||||
to: string
|
||||
from: string
|
||||
value: string
|
||||
tokenAddress: string | null
|
||||
tokenInfo?: {
|
||||
name: string
|
||||
symbol: string
|
||||
decimals: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface TransferSummary {
|
||||
chainId: number
|
||||
totalInflow30d: number
|
||||
totalOutflow30d: number
|
||||
inflowRate: number
|
||||
outflowRate: number
|
||||
incomingTransfers: SafeTransfer[]
|
||||
outgoingTransfers: SafeTransfer[]
|
||||
}
|
||||
|
||||
export async function getIncomingTransfers(
|
||||
address: string,
|
||||
chainId: number,
|
||||
limit = 100
|
||||
): Promise<SafeTransfer[]> {
|
||||
const data = await fetchJSON<{ results: SafeTransfer[] }>(
|
||||
apiUrl(chainId, `/safes/${address}/incoming-transfers/?limit=${limit}&executed=true`)
|
||||
)
|
||||
return data?.results || []
|
||||
}
|
||||
|
||||
export async function getOutgoingTransfers(
|
||||
address: string,
|
||||
chainId: number,
|
||||
limit = 100
|
||||
): Promise<SafeTransfer[]> {
|
||||
const data = await fetchJSON<{ results: Array<Record<string, unknown>> }>(
|
||||
apiUrl(chainId, `/safes/${address}/multisig-transactions/?limit=${limit}&executed=true`)
|
||||
)
|
||||
if (!data?.results) return []
|
||||
|
||||
return data.results
|
||||
.filter(tx => tx.value && parseInt(tx.value as string, 10) > 0)
|
||||
.map(tx => ({
|
||||
type: (tx.dataDecoded ? 'ERC20_TRANSFER' : 'ETHER_TRANSFER') as SafeTransfer['type'],
|
||||
executionDate: (tx.executionDate as string) || '',
|
||||
transactionHash: (tx.transactionHash as string) || '',
|
||||
to: (tx.to as string) || '',
|
||||
from: address,
|
||||
value: (tx.value as string) || '0',
|
||||
tokenAddress: null,
|
||||
tokenInfo: undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function computeTransferSummary(
|
||||
address: string,
|
||||
chainId: number
|
||||
): Promise<TransferSummary> {
|
||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
|
||||
|
||||
const [incoming, outgoing] = await Promise.all([
|
||||
getIncomingTransfers(address, chainId),
|
||||
getOutgoingTransfers(address, chainId),
|
||||
])
|
||||
|
||||
const recentIncoming = incoming.filter(t => new Date(t.executionDate) >= thirtyDaysAgo)
|
||||
const recentOutgoing = outgoing.filter(t => new Date(t.executionDate) >= thirtyDaysAgo)
|
||||
|
||||
const sumTransfers = (transfers: SafeTransfer[]) =>
|
||||
transfers.reduce((sum, t) => {
|
||||
const decimals = t.tokenInfo?.decimals ?? 18
|
||||
return sum + parseFloat(t.value) / Math.pow(10, decimals)
|
||||
}, 0)
|
||||
|
||||
const totalIn = sumTransfers(recentIncoming)
|
||||
const totalOut = sumTransfers(recentOutgoing)
|
||||
|
||||
return {
|
||||
chainId,
|
||||
totalInflow30d: totalIn,
|
||||
totalOutflow30d: totalOut,
|
||||
inflowRate: totalIn,
|
||||
outflowRate: totalOut,
|
||||
incomingTransfers: recentIncoming,
|
||||
outgoingTransfers: recentOutgoing,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
*/
|
||||
|
||||
import type { FlowNode, FunnelNodeData, OutcomeNodeData, IntegrationSource } from './types'
|
||||
import type { SafeBalance } from './api/safe-client'
|
||||
import type { SafeBalance, TransferSummary } from './api/safe-client'
|
||||
|
||||
// ─── Safe Balances → Funnel Nodes ────────────────────────────
|
||||
|
||||
|
|
@ -11,6 +11,7 @@ export function safeBalancesToFunnels(
|
|||
balances: SafeBalance[],
|
||||
safeAddress: string,
|
||||
chainId: number,
|
||||
transferSummary?: TransferSummary,
|
||||
startPosition = { x: 0, y: 100 }
|
||||
): FlowNode[] {
|
||||
// Filter to non-zero balances with meaningful fiat value (> $1)
|
||||
|
|
@ -31,13 +32,26 @@ export function safeBalancesToFunnels(
|
|||
lastFetchedAt: Date.now(),
|
||||
}
|
||||
|
||||
// Compute per-token inflow rate from transfer summary
|
||||
let inflowRate = 0
|
||||
if (transferSummary) {
|
||||
const tokenTransfers = transferSummary.incomingTransfers.filter(t => {
|
||||
if (b.tokenAddress === null) return t.tokenAddress === null
|
||||
return t.tokenAddress?.toLowerCase() === b.tokenAddress?.toLowerCase()
|
||||
})
|
||||
inflowRate = tokenTransfers.reduce((sum, t) => {
|
||||
const decimals = t.tokenInfo?.decimals ?? (b.token?.decimals ?? 18)
|
||||
return sum + parseFloat(t.value) / Math.pow(10, decimals)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const data: FunnelNodeData = {
|
||||
label: `${b.symbol} Treasury`,
|
||||
currentValue: fiatValue,
|
||||
minThreshold: Math.round(fiatValue * 0.2),
|
||||
maxThreshold: Math.round(fiatValue * 0.8),
|
||||
maxCapacity: Math.round(fiatValue * 1.5),
|
||||
inflowRate: 0,
|
||||
inflowRate,
|
||||
overflowAllocations: [],
|
||||
spendingAllocations: [],
|
||||
source,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,27 @@
|
|||
import type { FlowNode, FunnelNodeData, OutcomeNodeData } from './types'
|
||||
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData } from './types'
|
||||
|
||||
// Colors for allocations
|
||||
export const SPENDING_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981', '#6366f1']
|
||||
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[] = [
|
||||
// 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',
|
||||
type: 'funnel',
|
||||
|
|
@ -18,6 +33,8 @@ export const demoNodes: FlowNode[] = [
|
|||
maxThreshold: 70000,
|
||||
maxCapacity: 100000,
|
||||
inflowRate: 1000,
|
||||
sufficientThreshold: 60000,
|
||||
dynamicOverflow: true,
|
||||
overflowAllocations: [
|
||||
{ targetId: 'public-goods', percentage: 40, color: OVERFLOW_COLORS[0] },
|
||||
{ targetId: 'research', percentage: 35, color: OVERFLOW_COLORS[1] },
|
||||
|
|
@ -40,6 +57,7 @@ export const demoNodes: FlowNode[] = [
|
|||
maxThreshold: 50000,
|
||||
maxCapacity: 70000,
|
||||
inflowRate: 400,
|
||||
sufficientThreshold: 42000,
|
||||
overflowAllocations: [],
|
||||
spendingAllocations: [
|
||||
{ targetId: 'pg-infra', percentage: 50, color: SPENDING_COLORS[0] },
|
||||
|
|
@ -59,6 +77,7 @@ export const demoNodes: FlowNode[] = [
|
|||
maxThreshold: 45000,
|
||||
maxCapacity: 60000,
|
||||
inflowRate: 350,
|
||||
sufficientThreshold: 38000,
|
||||
overflowAllocations: [],
|
||||
spendingAllocations: [
|
||||
{ targetId: 'research-grants', percentage: 70, color: SPENDING_COLORS[0] },
|
||||
|
|
@ -77,6 +96,7 @@ export const demoNodes: FlowNode[] = [
|
|||
maxThreshold: 60000,
|
||||
maxCapacity: 80000,
|
||||
inflowRate: 250,
|
||||
sufficientThreshold: 50000,
|
||||
overflowAllocations: [],
|
||||
spendingAllocations: [
|
||||
{ targetId: 'emergency-response', percentage: 100, color: SPENDING_COLORS[0] },
|
||||
|
|
|
|||
|
|
@ -3,9 +3,12 @@
|
|||
*
|
||||
* Replaces the random-noise simulation with actual flow logic:
|
||||
* inflow → overflow distribution → spending drain → outcome accumulation
|
||||
*
|
||||
* Sufficiency layer: funnels can declare a sufficientThreshold and dynamicOverflow.
|
||||
* When dynamicOverflow is true, surplus routes to the most underfunded targets by need-weight.
|
||||
*/
|
||||
|
||||
import type { FlowNode, FunnelNodeData, OutcomeNodeData } from './types'
|
||||
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SufficiencyState } from './types'
|
||||
|
||||
export interface SimulationConfig {
|
||||
tickDivisor: number // inflowRate divided by this per tick
|
||||
|
|
@ -21,6 +24,85 @@ export const DEFAULT_CONFIG: SimulationConfig = {
|
|||
spendingRateCritical: 0.1,
|
||||
}
|
||||
|
||||
// ─── Sufficiency helpers ────────────────────────────────────
|
||||
|
||||
export function computeSufficiencyState(data: FunnelNodeData): SufficiencyState {
|
||||
const threshold = data.sufficientThreshold ?? data.maxThreshold
|
||||
if (data.currentValue >= data.maxCapacity) return 'abundant'
|
||||
if (data.currentValue >= threshold) return 'sufficient'
|
||||
return 'seeking'
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute need-weights for a set of overflow target IDs.
|
||||
* For funnels: need = max(0, 1 - currentValue / sufficientThreshold)
|
||||
* For outcomes: need = max(0, 1 - fundingReceived / fundingTarget)
|
||||
* Returns a Map of targetId → percentage (normalized to 100).
|
||||
*/
|
||||
export function computeNeedWeights(
|
||||
targetIds: string[],
|
||||
allNodes: FlowNode[],
|
||||
): Map<string, number> {
|
||||
const nodeMap = new Map(allNodes.map(n => [n.id, n]))
|
||||
const needs = new Map<string, number>()
|
||||
|
||||
for (const tid of targetIds) {
|
||||
const node = nodeMap.get(tid)
|
||||
if (!node) { needs.set(tid, 0); continue }
|
||||
|
||||
if (node.type === 'funnel') {
|
||||
const d = node.data as FunnelNodeData
|
||||
const threshold = d.sufficientThreshold ?? d.maxThreshold
|
||||
const need = Math.max(0, 1 - d.currentValue / (threshold || 1))
|
||||
needs.set(tid, need)
|
||||
} else if (node.type === 'outcome') {
|
||||
const d = node.data as OutcomeNodeData
|
||||
const need = Math.max(0, 1 - d.fundingReceived / Math.max(d.fundingTarget, 1))
|
||||
needs.set(tid, need)
|
||||
} else {
|
||||
needs.set(tid, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize to percentages summing to 100
|
||||
const totalNeed = Array.from(needs.values()).reduce((s, n) => s + n, 0)
|
||||
const weights = new Map<string, number>()
|
||||
if (totalNeed === 0) {
|
||||
// Equal distribution when all targets are satisfied
|
||||
const equal = targetIds.length > 0 ? 100 / targetIds.length : 0
|
||||
targetIds.forEach(id => weights.set(id, equal))
|
||||
} else {
|
||||
needs.forEach((need, id) => {
|
||||
weights.set(id, (need / totalNeed) * 100)
|
||||
})
|
||||
}
|
||||
return weights
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute system-wide sufficiency score (0-1).
|
||||
* Averages fill ratios of all funnels and progress ratios of all outcomes.
|
||||
*/
|
||||
export function computeSystemSufficiency(nodes: FlowNode[]): number {
|
||||
let sum = 0
|
||||
let count = 0
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'funnel') {
|
||||
const d = node.data as FunnelNodeData
|
||||
const threshold = d.sufficientThreshold ?? d.maxThreshold
|
||||
sum += Math.min(1, d.currentValue / (threshold || 1))
|
||||
count++
|
||||
} else if (node.type === 'outcome') {
|
||||
const d = node.data as OutcomeNodeData
|
||||
sum += Math.min(1, d.fundingReceived / Math.max(d.fundingTarget, 1))
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return count > 0 ? sum / count : 0
|
||||
}
|
||||
|
||||
export function simulateTick(
|
||||
nodes: FlowNode[],
|
||||
config: SimulationConfig = DEFAULT_CONFIG,
|
||||
|
|
@ -55,12 +137,28 @@ export function simulateTick(
|
|||
// 4. Distribute overflow when above maxThreshold
|
||||
if (value > data.maxThreshold && data.overflowAllocations.length > 0) {
|
||||
const excess = value - data.maxThreshold
|
||||
for (const alloc of data.overflowAllocations) {
|
||||
const share = excess * (alloc.percentage / 100)
|
||||
overflowIncoming.set(
|
||||
alloc.targetId,
|
||||
(overflowIncoming.get(alloc.targetId) ?? 0) + share,
|
||||
)
|
||||
|
||||
if (data.dynamicOverflow) {
|
||||
// Dynamic overflow: route by need-weight instead of fixed percentages
|
||||
const targetIds = data.overflowAllocations.map(a => a.targetId)
|
||||
const needWeights = computeNeedWeights(targetIds, nodes)
|
||||
for (const alloc of data.overflowAllocations) {
|
||||
const weight = needWeights.get(alloc.targetId) ?? 0
|
||||
const share = excess * (weight / 100)
|
||||
overflowIncoming.set(
|
||||
alloc.targetId,
|
||||
(overflowIncoming.get(alloc.targetId) ?? 0) + share,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Fixed-percentage overflow (existing behavior)
|
||||
for (const alloc of data.overflowAllocations) {
|
||||
const share = excess * (alloc.percentage / 100)
|
||||
overflowIncoming.set(
|
||||
alloc.targetId,
|
||||
(overflowIncoming.get(alloc.targetId) ?? 0) + share,
|
||||
)
|
||||
}
|
||||
}
|
||||
value = data.maxThreshold
|
||||
}
|
||||
|
|
|
|||
23
lib/types.ts
23
lib/types.ts
|
|
@ -83,6 +83,22 @@ export interface FundingSource {
|
|||
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 ─────────────────────────────────────────
|
||||
|
||||
// Overflow allocation - funds flowing to OTHER FUNNELS when above max threshold
|
||||
|
|
@ -99,6 +115,8 @@ export interface SpendingAllocation {
|
|||
color: string
|
||||
}
|
||||
|
||||
export type SufficiencyState = 'seeking' | 'sufficient' | 'abundant'
|
||||
|
||||
export interface FunnelNodeData {
|
||||
label: string
|
||||
currentValue: number
|
||||
|
|
@ -106,6 +124,9 @@ export interface FunnelNodeData {
|
|||
maxThreshold: number
|
||||
maxCapacity: number
|
||||
inflowRate: number
|
||||
// Sufficiency layer
|
||||
sufficientThreshold?: number // level at which funnel has "enough" (defaults to maxThreshold)
|
||||
dynamicOverflow?: boolean // when true, overflow routes by need instead of fixed %
|
||||
// Overflow goes SIDEWAYS to other funnels
|
||||
overflowAllocations: OverflowAllocation[]
|
||||
// Spending goes DOWN to outcomes/outputs
|
||||
|
|
@ -129,7 +150,7 @@ export interface OutcomeNodeData {
|
|||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type FlowNode = Node<FunnelNodeData | OutcomeNodeData>
|
||||
export type FlowNode = Node<FunnelNodeData | OutcomeNodeData | SourceNodeData>
|
||||
|
||||
export interface AllocationEdgeData {
|
||||
allocation: number // percentage 0-100
|
||||
|
|
|
|||
Loading…
Reference in New Issue