feat: sankey-proportional waterfalls + multi-chain Safe support
Rewrite budget river waterfalls with bezier-curved tapered shapes where width encodes flow magnitude (inflows flare into river, outflows taper out). Add Ethereum, Base, Polygon, Arbitrum to Safe chain detection. Fetch real transaction history for live inflow rates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e683175c65
commit
0afb85e9f7
|
|
@ -65,3 +65,8 @@ body {
|
|||
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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ 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 { detectSafeChains, getBalances, computeTransferSummary } 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'
|
||||
|
|
@ -52,7 +52,7 @@ export default function RiverPage() {
|
|||
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')
|
||||
showStatus('No Safe found on supported chains (Ethereum, Base, Polygon, Arbitrum, Optimism, Gnosis)', 'error')
|
||||
setConnecting(false)
|
||||
return
|
||||
}
|
||||
|
|
@ -60,14 +60,18 @@ export default function RiverPage() {
|
|||
const chainNames = detected.map(d => d.chain.name)
|
||||
setConnectedChains(chainNames)
|
||||
|
||||
// Fetch balances from all detected chains and create funnel nodes
|
||||
// Fetch balances + transfer history from all detected chains
|
||||
const allFunnelNodes: FlowNode[] = []
|
||||
for (const chain of detected) {
|
||||
const balances = await getBalances(safeAddress.trim(), chain.chainId)
|
||||
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)
|
||||
|
|
@ -282,7 +286,7 @@ export default function RiverPage() {
|
|||
<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.
|
||||
Enter a Safe address to load real token balances across Ethereum, Base, Polygon, Arbitrum, Optimism, and Gnosis.
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
|
|
|
|||
|
|
@ -54,9 +54,13 @@ interface WaterfallLayout {
|
|||
label: string
|
||||
percentage: number
|
||||
x: number
|
||||
xSource: number
|
||||
yStart: number
|
||||
yEnd: number
|
||||
width: number
|
||||
riverEndWidth: number
|
||||
farEndWidth: number
|
||||
direction: 'inflow' | 'outflow'
|
||||
color: string
|
||||
flowAmount: number
|
||||
}
|
||||
|
|
@ -76,10 +80,11 @@ interface BranchLayout {
|
|||
// ─── Constants ───────────────────────────────────────────
|
||||
|
||||
const LAYER_HEIGHT = 160
|
||||
const WATERFALL_HEIGHT = 100
|
||||
const WATERFALL_HEIGHT = 120
|
||||
const GAP = 40
|
||||
const MIN_RIVER_WIDTH = 24
|
||||
const MAX_RIVER_WIDTH = 100
|
||||
const MIN_WATERFALL_WIDTH = 4
|
||||
const SEGMENT_LENGTH = 200
|
||||
const POOL_WIDTH = 100
|
||||
const POOL_HEIGHT = 60
|
||||
|
|
@ -98,6 +103,34 @@ const COLORS = {
|
|||
textMuted: '#94a3b8',
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────
|
||||
|
||||
/** Distribute widths proportionally with a minimum floor */
|
||||
function distributeWidths(
|
||||
percentages: number[],
|
||||
totalAvailable: number,
|
||||
minWidth: number
|
||||
): number[] {
|
||||
const totalPct = percentages.reduce((s, p) => s + p, 0)
|
||||
if (totalPct === 0) return percentages.map(() => minWidth)
|
||||
|
||||
let widths = percentages.map(p => (p / totalPct) * totalAvailable)
|
||||
|
||||
// Enforce minimums: bump small ones up, proportionally reduce large ones
|
||||
const belowMin = widths.filter(w => w < minWidth)
|
||||
if (belowMin.length > 0 && belowMin.length < widths.length) {
|
||||
const deficit = belowMin.reduce((s, w) => s + (minWidth - w), 0)
|
||||
const aboveMinTotal = widths.filter(w => w >= minWidth).reduce((s, w) => s + w, 0)
|
||||
|
||||
widths = widths.map(w => {
|
||||
if (w < minWidth) return minWidth
|
||||
return Math.max(minWidth, w - (w / aboveMinTotal) * deficit)
|
||||
})
|
||||
}
|
||||
|
||||
return widths
|
||||
}
|
||||
|
||||
// ─── Layout Engine ───────────────────────────────────────
|
||||
|
||||
function computeLayout(nodes: FlowNode[]): RiverLayout {
|
||||
|
|
@ -121,15 +154,12 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
|
|||
})
|
||||
|
||||
// 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
|
||||
// Assign layers via BFS
|
||||
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()!
|
||||
|
|
@ -144,13 +174,11 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
|
|||
})
|
||||
}
|
||||
|
||||
// Find max flow rate for normalization
|
||||
// Find max flow rate for implicit waterfall 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
|
||||
// Group funnels by layer, center each layer
|
||||
const layerGroups = new Map<number, FlowNode[]>()
|
||||
funnelNodes.forEach(n => {
|
||||
const layer = funnelLayers.get(n.id) ?? 0
|
||||
|
|
@ -163,7 +191,6 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
|
|||
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) || []
|
||||
|
|
@ -190,11 +217,6 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
|
|||
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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -212,19 +234,56 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
|
|||
}
|
||||
})
|
||||
|
||||
// Source waterfalls (source → root funnel)
|
||||
// ─── Source waterfalls (sankey-proportional inflows) ──────
|
||||
|
||||
// First pass: collect all inflows per funnel to compute shares
|
||||
const inflowsByFunnel = new Map<string, { sourceNodeId: string; allocIndex: number; flowAmount: number; percentage: number }[]>()
|
||||
|
||||
sourceNodes.forEach(sn => {
|
||||
const data = sn.data as SourceNodeData
|
||||
data.targetAllocations?.forEach((alloc, i) => {
|
||||
const flowAmount = (alloc.percentage / 100) * data.flowRate
|
||||
if (!inflowsByFunnel.has(alloc.targetId)) inflowsByFunnel.set(alloc.targetId, [])
|
||||
inflowsByFunnel.get(alloc.targetId)!.push({ sourceNodeId: sn.id, allocIndex: i, flowAmount, percentage: alloc.percentage })
|
||||
})
|
||||
})
|
||||
|
||||
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) => {
|
||||
data.targetAllocations?.forEach((alloc, allocIdx) => {
|
||||
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)
|
||||
const allInflowsToTarget = inflowsByFunnel.get(alloc.targetId) || []
|
||||
const totalInflowToTarget = allInflowsToTarget.reduce((s, i) => s + i.flowAmount, 0)
|
||||
|
||||
// Sankey width: proportional share of the target river width
|
||||
const share = totalInflowToTarget > 0 ? flowAmount / totalInflowToTarget : 1
|
||||
const riverEndWidth = Math.max(MIN_WATERFALL_WIDTH, share * targetLayout.riverWidth)
|
||||
|
||||
// Far end width: proportional share of source box width
|
||||
const farEndWidth = Math.max(MIN_WATERFALL_WIDTH, (alloc.percentage / 100) * sourceLayout.width * 0.8)
|
||||
|
||||
// Position along river top edge: distribute side by side
|
||||
const myIndex = allInflowsToTarget.findIndex(i => i.sourceNodeId === sn.id && i.allocIndex === allocIdx)
|
||||
const inflowWidths = distributeWidths(
|
||||
allInflowsToTarget.map(i => i.flowAmount),
|
||||
targetLayout.segmentLength * 0.7,
|
||||
MIN_WATERFALL_WIDTH
|
||||
)
|
||||
const startX = targetLayout.x + targetLayout.segmentLength * 0.15
|
||||
let offsetX = 0
|
||||
for (let k = 0; k < myIndex; k++) offsetX += inflowWidths[k]
|
||||
const riverCenterX = startX + offsetX + inflowWidths[myIndex] / 2
|
||||
|
||||
// Source center x
|
||||
const sourceCenterX = sourceLayout.x + sourceLayout.width / 2
|
||||
|
||||
sourceWaterfalls.push({
|
||||
id: `src-wf-${sn.id}-${alloc.targetId}`,
|
||||
|
|
@ -232,17 +291,21 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
|
|||
targetId: alloc.targetId,
|
||||
label: `${alloc.percentage}%`,
|
||||
percentage: alloc.percentage,
|
||||
x: sourceLayout.x + sourceLayout.width / 2 + (i - (data.targetAllocations.length - 1) / 2) * 30,
|
||||
x: riverCenterX,
|
||||
xSource: sourceCenterX,
|
||||
yStart: sourceLayout.y + SOURCE_HEIGHT,
|
||||
yEnd: targetLayout.y,
|
||||
width,
|
||||
width: riverEndWidth,
|
||||
riverEndWidth,
|
||||
farEndWidth,
|
||||
direction: 'inflow',
|
||||
color: COLORS.sourceWaterfall,
|
||||
flowAmount,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// If no source nodes, create implicit waterfalls for root funnels with inflowRate
|
||||
// Implicit waterfalls for root funnels with inflowRate but no source nodes
|
||||
if (sourceNodes.length === 0) {
|
||||
rootFunnels.forEach(rn => {
|
||||
const data = rn.data as FunnelNodeData
|
||||
|
|
@ -250,6 +313,9 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
|
|||
const layout = funnelLayouts.find(f => f.id === rn.id)
|
||||
if (!layout) return
|
||||
|
||||
const riverEndWidth = layout.riverWidth
|
||||
const farEndWidth = Math.max(MIN_WATERFALL_WIDTH, layout.riverWidth * 0.4)
|
||||
|
||||
sourceWaterfalls.push({
|
||||
id: `implicit-wf-${rn.id}`,
|
||||
sourceId: 'implicit',
|
||||
|
|
@ -257,28 +323,32 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
|
|||
label: `$${Math.floor(data.inflowRate)}/mo`,
|
||||
percentage: 100,
|
||||
x: layout.x + layout.segmentLength / 2,
|
||||
xSource: layout.x + layout.segmentLength / 2,
|
||||
yStart: GAP,
|
||||
yEnd: layout.y,
|
||||
width: Math.max(8, (data.inflowRate / maxFlowRate) * 30),
|
||||
width: riverEndWidth,
|
||||
riverEndWidth,
|
||||
farEndWidth,
|
||||
direction: 'inflow',
|
||||
color: COLORS.sourceWaterfall,
|
||||
flowAmount: data.inflowRate,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Overflow branches (funnel → child funnel)
|
||||
// ─── Overflow branches (sankey-proportional) ──────
|
||||
|
||||
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) => {
|
||||
data.overflowAllocations?.forEach((alloc) => {
|
||||
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)
|
||||
const width = Math.max(MIN_WATERFALL_WIDTH, (alloc.percentage / 100) * parentLayout.riverWidth)
|
||||
|
||||
overflowBranches.push({
|
||||
sourceId: n.id,
|
||||
|
|
@ -294,7 +364,8 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
|
|||
})
|
||||
})
|
||||
|
||||
// Outcome layouts
|
||||
// ─── 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) => {
|
||||
|
|
@ -314,19 +385,43 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
|
|||
}
|
||||
})
|
||||
|
||||
// Spending waterfalls (funnel → outcome)
|
||||
// ─── Spending waterfalls (sankey-proportional outflows) ──────
|
||||
|
||||
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 allocations = data.spendingAllocations || []
|
||||
if (allocations.length === 0) return
|
||||
|
||||
// Distribute outflows along the river bottom edge
|
||||
const percentages = allocations.map(a => a.percentage)
|
||||
const slotWidths = distributeWidths(
|
||||
percentages,
|
||||
parentLayout.segmentLength * 0.7,
|
||||
MIN_WATERFALL_WIDTH
|
||||
)
|
||||
const riverEndWidths = distributeWidths(
|
||||
percentages,
|
||||
parentLayout.riverWidth,
|
||||
MIN_WATERFALL_WIDTH
|
||||
)
|
||||
const startX = parentLayout.x + parentLayout.segmentLength * 0.15
|
||||
|
||||
let offsetX = 0
|
||||
allocations.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)
|
||||
const riverEndWidth = riverEndWidths[i]
|
||||
const farEndWidth = Math.max(MIN_WATERFALL_WIDTH, outcomeLayout.poolWidth * 0.6)
|
||||
|
||||
const riverCenterX = startX + offsetX + slotWidths[i] / 2
|
||||
offsetX += slotWidths[i]
|
||||
|
||||
const poolCenterX = outcomeLayout.x + outcomeLayout.poolWidth / 2
|
||||
|
||||
spendingWaterfalls.push({
|
||||
id: `spend-wf-${n.id}-${alloc.targetId}`,
|
||||
|
|
@ -334,13 +429,16 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
|
|||
targetId: alloc.targetId,
|
||||
label: `${alloc.percentage}%`,
|
||||
percentage: alloc.percentage,
|
||||
x: parentLayout.x + parentLayout.segmentLength / 2 +
|
||||
(i - ((data.spendingAllocations?.length || 1) - 1) / 2) * 24,
|
||||
x: riverCenterX,
|
||||
xSource: poolCenterX,
|
||||
yStart: parentLayout.y + parentLayout.riverWidth + 4,
|
||||
yEnd: outcomeLayout.y,
|
||||
width,
|
||||
width: riverEndWidth,
|
||||
riverEndWidth,
|
||||
farEndWidth,
|
||||
direction: 'outflow',
|
||||
color: alloc.color || COLORS.spendingWaterfall[i % COLORS.spendingWaterfall.length],
|
||||
flowAmount,
|
||||
flowAmount: (alloc.percentage / 100) * (data.inflowRate || 1),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -366,16 +464,16 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
|
|||
const padding = 80
|
||||
|
||||
// Shift everything so minX starts at padding
|
||||
const offsetX = -minX + padding
|
||||
const offsetY = padding
|
||||
const offsetXGlobal = -minX + padding
|
||||
const offsetYGlobal = 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 })
|
||||
funnelLayouts.forEach(f => { f.x += offsetXGlobal; f.y += offsetYGlobal })
|
||||
outcomeLayouts.forEach(o => { o.x += offsetXGlobal; o.y += offsetYGlobal })
|
||||
sourceLayouts.forEach(s => { s.x += offsetXGlobal; s.y += offsetYGlobal })
|
||||
sourceWaterfalls.forEach(w => { w.x += offsetXGlobal; w.xSource += offsetXGlobal; w.yStart += offsetYGlobal; w.yEnd += offsetYGlobal })
|
||||
overflowBranches.forEach(b => { b.x1 += offsetXGlobal; b.y1 += offsetYGlobal; b.x2 += offsetXGlobal; b.y2 += offsetYGlobal })
|
||||
spendingWaterfalls.forEach(w => { w.x += offsetXGlobal; w.xSource += offsetXGlobal; w.yStart += offsetYGlobal; w.yEnd += offsetYGlobal })
|
||||
|
||||
return {
|
||||
sources: sourceLayouts,
|
||||
|
|
@ -385,125 +483,157 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
|
|||
overflowBranches,
|
||||
spendingWaterfalls,
|
||||
width: maxX - minX + padding * 2,
|
||||
height: maxY + offsetY + padding,
|
||||
height: maxY + offsetYGlobal + padding,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SVG Sub-components ──────────────────────────────────
|
||||
|
||||
function WaterfallStream({ wf, index }: { wf: WaterfallLayout; index: number }) {
|
||||
function SankeyWaterfall({ wf }: { wf: WaterfallLayout }) {
|
||||
const isInflow = wf.direction === 'inflow'
|
||||
const height = wf.yEnd - wf.yStart
|
||||
const numDroplets = Math.max(3, Math.min(8, Math.floor(wf.width / 3)))
|
||||
if (height <= 0) return null
|
||||
|
||||
// For inflows: narrow at top (source), wide at bottom (river)
|
||||
// For outflows: wide at top (river), narrow at bottom (pool)
|
||||
const topWidth = isInflow ? wf.farEndWidth : wf.riverEndWidth
|
||||
const bottomWidth = isInflow ? wf.riverEndWidth : wf.farEndWidth
|
||||
const topCx = isInflow ? wf.xSource : wf.x
|
||||
const bottomCx = isInflow ? wf.x : wf.xSource
|
||||
|
||||
// Bezier control points for the taper curve
|
||||
// Inflow: narrow for 55%, then flares in last 45%
|
||||
// Outflow: wide for first 20%, then tapers
|
||||
const cpFrac1 = isInflow ? 0.55 : 0.2
|
||||
const cpFrac2 = isInflow ? 0.75 : 0.45
|
||||
const cpY1 = wf.yStart + height * cpFrac1
|
||||
const cpY2 = wf.yStart + height * cpFrac2
|
||||
|
||||
const tl = topCx - topWidth / 2
|
||||
const tr = topCx + topWidth / 2
|
||||
const bl = bottomCx - bottomWidth / 2
|
||||
const br = bottomCx + bottomWidth / 2
|
||||
|
||||
const shapePath = [
|
||||
`M ${tl} ${wf.yStart}`,
|
||||
`C ${tl} ${cpY1}, ${bl} ${cpY2}, ${bl} ${wf.yEnd}`,
|
||||
`L ${br} ${wf.yEnd}`,
|
||||
`C ${br} ${cpY2}, ${tr} ${cpY1}, ${tr} ${wf.yStart}`,
|
||||
`Z`
|
||||
].join(' ')
|
||||
|
||||
const clipId = `sankey-clip-${wf.id}`
|
||||
const gradId = `sankey-grad-${wf.id}`
|
||||
|
||||
// Ripple at the wide (river) end
|
||||
const rippleCx = isInflow ? bottomCx : topCx
|
||||
const rippleCy = isInflow ? wf.yEnd : wf.yStart
|
||||
|
||||
// Bounding box for animated strips
|
||||
const pathMinX = Math.min(tl, bl) - 5
|
||||
const pathMaxW = Math.max(topWidth, bottomWidth) + 10
|
||||
|
||||
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 id={clipId}>
|
||||
<path d={shapePath} />
|
||||
</clipPath>
|
||||
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={wf.color} stopOpacity={isInflow ? 0.85 : 0.5} />
|
||||
<stop offset="50%" stopColor={wf.color} stopOpacity={0.65} />
|
||||
<stop offset="100%" stopColor={wf.color} stopOpacity={isInflow ? 0.35 : 0.85} />
|
||||
</linearGradient>
|
||||
</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}
|
||||
/>
|
||||
{/* Glow behind shape */}
|
||||
<path d={shapePath} fill={wf.color} opacity={0.08} />
|
||||
|
||||
{/* Animated water strips */}
|
||||
<g clipPath={`url(#wf-clip-${wf.id})`}>
|
||||
{/* Main filled shape */}
|
||||
<path d={shapePath} fill={`url(#${gradId})`} />
|
||||
|
||||
{/* Animated water strips inside the shape */}
|
||||
<g clipPath={`url(#${clipId})`}>
|
||||
{[0, 1, 2].map(i => (
|
||||
<rect
|
||||
key={i}
|
||||
x={wf.x - wf.width / 2 + 1}
|
||||
x={pathMinX}
|
||||
y={wf.yStart - height}
|
||||
width={wf.width - 2}
|
||||
width={pathMaxW}
|
||||
height={height}
|
||||
fill={`url(#wf-grad-${wf.id})`}
|
||||
rx={2}
|
||||
fill={wf.color}
|
||||
opacity={0.12}
|
||||
style={{
|
||||
animation: `waterFlow ${1.2 + i * 0.3}s linear infinite`,
|
||||
animation: `waterFlow ${1.4 + 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}
|
||||
{/* Edge highlight lines along the bezier edges */}
|
||||
<path
|
||||
d={`M ${tl} ${wf.yStart} C ${tl} ${cpY1}, ${bl} ${cpY2}, ${bl} ${wf.yEnd}`}
|
||||
fill="none"
|
||||
stroke={wf.color}
|
||||
strokeWidth={1}
|
||||
opacity={0.3}
|
||||
strokeDasharray="4 6"
|
||||
style={{ animation: `riverCurrent 1s linear infinite` }}
|
||||
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}
|
||||
<path
|
||||
d={`M ${tr} ${wf.yStart} C ${tr} ${cpY1}, ${br} ${cpY2}, ${br} ${wf.yEnd}`}
|
||||
fill="none"
|
||||
stroke={wf.color}
|
||||
strokeWidth={1}
|
||||
opacity={0.3}
|
||||
strokeDasharray="4 6"
|
||||
style={{ animation: `riverCurrent 1s linear infinite` }}
|
||||
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 */}
|
||||
{/* Merge ripples at river junction */}
|
||||
{[0, 1, 2].map(i => (
|
||||
<circle
|
||||
key={`ripple-${wf.id}-${i}`}
|
||||
cx={wf.x}
|
||||
cy={wf.yEnd}
|
||||
r={2}
|
||||
<ellipse
|
||||
key={`merge-${wf.id}-${i}`}
|
||||
cx={rippleCx}
|
||||
cy={rippleCy}
|
||||
rx={2}
|
||||
ry={1}
|
||||
fill="none"
|
||||
stroke={wf.color}
|
||||
strokeWidth={1}
|
||||
style={{
|
||||
animation: `ripple 2s ease-out infinite`,
|
||||
animation: `mergeSplash 2s ease-out infinite`,
|
||||
animationDelay: `${i * 0.6}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Droplets at narrow end */}
|
||||
{Array.from({ length: Math.max(2, Math.min(5, Math.floor(wf.riverEndWidth / 8))) }, (_, i) => {
|
||||
const narrowCx = isInflow ? topCx : bottomCx
|
||||
const narrowCy = isInflow ? wf.yStart : wf.yEnd
|
||||
const narrowW = isInflow ? topWidth : bottomWidth
|
||||
const dx = (Math.random() - 0.5) * narrowW
|
||||
return (
|
||||
<circle
|
||||
key={`drop-${wf.id}-${i}`}
|
||||
cx={narrowCx + dx}
|
||||
cy={narrowCy + (isInflow ? -5 : 5)}
|
||||
r={1.2}
|
||||
fill={wf.color}
|
||||
opacity={0.5}
|
||||
style={{
|
||||
animation: `droplet ${1 + Math.random() * 1.5}s ease-in infinite`,
|
||||
animationDelay: `${Math.random() * 2}s`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Label */}
|
||||
<text
|
||||
x={wf.x}
|
||||
x={(topCx + bottomCx) / 2}
|
||||
y={wf.yStart + height / 2}
|
||||
textAnchor="middle"
|
||||
fill={COLORS.text}
|
||||
|
|
@ -522,7 +652,6 @@ function RiverSegment({ funnel }: { funnel: FunnelLayout }) {
|
|||
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
|
||||
|
||||
|
|
@ -689,7 +818,7 @@ function OverflowBranch({ branch }: { branch: BranchLayout }) {
|
|||
strokeDasharray="10 5"
|
||||
style={{ animation: `riverCurrent 1.5s linear infinite` }}
|
||||
/>
|
||||
{/* Flow direction arrows */}
|
||||
{/* Flow direction label */}
|
||||
<text
|
||||
x={midX}
|
||||
y={midY - width / 2 - 4}
|
||||
|
|
@ -927,9 +1056,9 @@ export default function BudgetRiver({ nodes, isSimulating = false }: BudgetRiver
|
|||
<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 2: Source waterfalls (sankey inflows pouring into river) */}
|
||||
{layout.sourceWaterfalls.map(wf => (
|
||||
<SankeyWaterfall key={wf.id} wf={wf} />
|
||||
))}
|
||||
|
||||
{/* Layer 3: River segments */}
|
||||
|
|
@ -942,9 +1071,9 @@ export default function BudgetRiver({ nodes, isSimulating = false }: BudgetRiver
|
|||
<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 5: Spending waterfalls (sankey outflows pouring out) */}
|
||||
{layout.spendingWaterfalls.map(wf => (
|
||||
<SankeyWaterfall key={wf.id} wf={wf} />
|
||||
))}
|
||||
|
||||
{/* Layer 6: Outcome pools */}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue