1198 lines
37 KiB
TypeScript
1198 lines
37 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useMemo, useCallback } from 'react'
|
|
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData, SufficiencyState } from '@/lib/types'
|
|
import { computeSufficiencyState, computeSystemSufficiency } from '@/lib/simulation'
|
|
|
|
// ─── Layout Types ────────────────────────────────────────
|
|
|
|
interface RiverLayout {
|
|
sources: SourceLayout[]
|
|
funnels: FunnelLayout[]
|
|
outcomes: OutcomeLayout[]
|
|
sourceWaterfalls: WaterfallLayout[]
|
|
overflowBranches: BranchLayout[]
|
|
spendingWaterfalls: WaterfallLayout[]
|
|
width: number
|
|
height: number
|
|
}
|
|
|
|
interface SourceLayout {
|
|
id: string
|
|
label: string
|
|
flowRate: number
|
|
x: number
|
|
y: number
|
|
width: number
|
|
}
|
|
|
|
interface FunnelLayout {
|
|
id: string
|
|
label: string
|
|
data: FunnelNodeData
|
|
x: number
|
|
y: number
|
|
riverWidth: number
|
|
segmentLength: number
|
|
layer: number
|
|
status: 'healthy' | 'overflow' | 'critical'
|
|
sufficiency: SufficiencyState
|
|
}
|
|
|
|
interface OutcomeLayout {
|
|
id: string
|
|
label: string
|
|
data: OutcomeNodeData
|
|
x: number
|
|
y: number
|
|
poolWidth: number
|
|
fillPercent: number
|
|
}
|
|
|
|
interface WaterfallLayout {
|
|
id: string
|
|
sourceId: string
|
|
targetId: string
|
|
label: string
|
|
percentage: number
|
|
x: number
|
|
xSource: number
|
|
yStart: number
|
|
yEnd: number
|
|
width: number
|
|
riverEndWidth: number
|
|
farEndWidth: number
|
|
direction: 'inflow' | 'outflow'
|
|
color: string
|
|
flowAmount: number
|
|
}
|
|
|
|
interface BranchLayout {
|
|
sourceId: string
|
|
targetId: string
|
|
percentage: number
|
|
x1: number
|
|
y1: number
|
|
x2: number
|
|
y2: number
|
|
width: number
|
|
color: string
|
|
}
|
|
|
|
// ─── Constants ───────────────────────────────────────────
|
|
|
|
const LAYER_HEIGHT = 160
|
|
const WATERFALL_HEIGHT = 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
|
|
const SOURCE_HEIGHT = 40
|
|
|
|
const COLORS = {
|
|
sourceWaterfall: '#10b981',
|
|
riverHealthy: ['#0ea5e9', '#06b6d4'],
|
|
riverOverflow: ['#f59e0b', '#fbbf24'],
|
|
riverCritical: ['#ef4444', '#f87171'],
|
|
riverSufficient: ['#fbbf24', '#10b981'],
|
|
overflowBranch: '#f59e0b',
|
|
spendingWaterfall: ['#8b5cf6', '#ec4899', '#06b6d4', '#3b82f6', '#10b981', '#6366f1'],
|
|
outcomePool: '#3b82f6',
|
|
goldenGlow: '#fbbf24',
|
|
bg: '#0f172a',
|
|
text: '#e2e8f0',
|
|
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 {
|
|
const funnelNodes = nodes.filter(n => n.type === 'funnel')
|
|
const outcomeNodes = nodes.filter(n => n.type === 'outcome')
|
|
const sourceNodes = nodes.filter(n => n.type === 'source')
|
|
|
|
// Build adjacency: which funnels are children (overflow targets) of which
|
|
const overflowTargets = new Set<string>()
|
|
const spendingTargets = new Set<string>()
|
|
const sourceTargets = new Set<string>()
|
|
|
|
funnelNodes.forEach(n => {
|
|
const data = n.data as FunnelNodeData
|
|
data.overflowAllocations?.forEach(a => overflowTargets.add(a.targetId))
|
|
data.spendingAllocations?.forEach(a => spendingTargets.add(a.targetId))
|
|
})
|
|
sourceNodes.forEach(n => {
|
|
const data = n.data as SourceNodeData
|
|
data.targetAllocations?.forEach(a => sourceTargets.add(a.targetId))
|
|
})
|
|
|
|
// Root funnels = funnels that are NOT overflow targets of other funnels
|
|
const rootFunnels = funnelNodes.filter(n => !overflowTargets.has(n.id))
|
|
|
|
// Assign layers via BFS
|
|
const funnelLayers = new Map<string, number>()
|
|
rootFunnels.forEach(n => funnelLayers.set(n.id, 0))
|
|
|
|
const queue = [...rootFunnels]
|
|
while (queue.length > 0) {
|
|
const current = queue.shift()!
|
|
const data = current.data as FunnelNodeData
|
|
const parentLayer = funnelLayers.get(current.id) ?? 0
|
|
data.overflowAllocations?.forEach(a => {
|
|
const child = funnelNodes.find(n => n.id === a.targetId)
|
|
if (child && !funnelLayers.has(child.id)) {
|
|
funnelLayers.set(child.id, parentLayer + 1)
|
|
queue.push(child)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Find max flow rate for implicit waterfall normalization
|
|
const allFlowRates = funnelNodes.map(n => (n.data as FunnelNodeData).inflowRate || 1)
|
|
const maxFlowRate = Math.max(...allFlowRates, 1)
|
|
|
|
// Group funnels by layer, center each layer
|
|
const layerGroups = new Map<number, FlowNode[]>()
|
|
funnelNodes.forEach(n => {
|
|
const layer = funnelLayers.get(n.id) ?? 0
|
|
if (!layerGroups.has(layer)) layerGroups.set(layer, [])
|
|
layerGroups.get(layer)!.push(n)
|
|
})
|
|
|
|
const maxLayer = Math.max(...Array.from(layerGroups.keys()), 0)
|
|
const sourceLayerY = GAP
|
|
const funnelStartY = sourceLayerY + SOURCE_HEIGHT + WATERFALL_HEIGHT + GAP
|
|
|
|
const funnelLayouts: FunnelLayout[] = []
|
|
|
|
for (let layer = 0; layer <= maxLayer; layer++) {
|
|
const layerNodes = layerGroups.get(layer) || []
|
|
const layerY = funnelStartY + layer * (LAYER_HEIGHT + WATERFALL_HEIGHT + GAP)
|
|
const totalWidth = layerNodes.length * SEGMENT_LENGTH + (layerNodes.length - 1) * GAP * 2
|
|
|
|
layerNodes.forEach((n, i) => {
|
|
const data = n.data as FunnelNodeData
|
|
const fillRatio = Math.min(1, data.currentValue / (data.maxCapacity || 1))
|
|
const riverWidth = MIN_RIVER_WIDTH + fillRatio * (MAX_RIVER_WIDTH - MIN_RIVER_WIDTH)
|
|
const x = -totalWidth / 2 + i * (SEGMENT_LENGTH + GAP * 2)
|
|
const status: 'healthy' | 'overflow' | 'critical' =
|
|
data.currentValue > data.maxThreshold ? 'overflow' :
|
|
data.currentValue < data.minThreshold ? 'critical' : 'healthy'
|
|
|
|
funnelLayouts.push({
|
|
id: n.id,
|
|
label: data.label,
|
|
data,
|
|
x,
|
|
y: layerY,
|
|
riverWidth,
|
|
segmentLength: SEGMENT_LENGTH,
|
|
layer,
|
|
status,
|
|
sufficiency: computeSufficiencyState(data),
|
|
})
|
|
})
|
|
}
|
|
|
|
// Source layouts
|
|
const sourceLayouts: SourceLayout[] = sourceNodes.map((n, i) => {
|
|
const data = n.data as SourceNodeData
|
|
const totalWidth = sourceNodes.length * 120 + (sourceNodes.length - 1) * GAP
|
|
return {
|
|
id: n.id,
|
|
label: data.label,
|
|
flowRate: data.flowRate,
|
|
x: -totalWidth / 2 + i * (120 + GAP),
|
|
y: sourceLayerY,
|
|
width: 120,
|
|
}
|
|
})
|
|
|
|
// ─── Source waterfalls (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, allocIdx) => {
|
|
const targetLayout = funnelLayouts.find(f => f.id === alloc.targetId)
|
|
if (!targetLayout) return
|
|
|
|
const flowAmount = (alloc.percentage / 100) * data.flowRate
|
|
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}`,
|
|
sourceId: sn.id,
|
|
targetId: alloc.targetId,
|
|
label: `${alloc.percentage}%`,
|
|
percentage: alloc.percentage,
|
|
x: riverCenterX,
|
|
xSource: sourceCenterX,
|
|
yStart: sourceLayout.y + SOURCE_HEIGHT,
|
|
yEnd: targetLayout.y,
|
|
width: riverEndWidth,
|
|
riverEndWidth,
|
|
farEndWidth,
|
|
direction: 'inflow',
|
|
color: COLORS.sourceWaterfall,
|
|
flowAmount,
|
|
})
|
|
})
|
|
})
|
|
|
|
// Implicit waterfalls for root funnels with inflowRate but no source nodes
|
|
if (sourceNodes.length === 0) {
|
|
rootFunnels.forEach(rn => {
|
|
const data = rn.data as FunnelNodeData
|
|
if (data.inflowRate <= 0) return
|
|
const layout = funnelLayouts.find(f => f.id === rn.id)
|
|
if (!layout) return
|
|
|
|
const riverEndWidth = layout.riverWidth
|
|
const farEndWidth = Math.max(MIN_WATERFALL_WIDTH, layout.riverWidth * 0.4)
|
|
|
|
sourceWaterfalls.push({
|
|
id: `implicit-wf-${rn.id}`,
|
|
sourceId: 'implicit',
|
|
targetId: rn.id,
|
|
label: `$${Math.floor(data.inflowRate)}/mo`,
|
|
percentage: 100,
|
|
x: layout.x + layout.segmentLength / 2,
|
|
xSource: layout.x + layout.segmentLength / 2,
|
|
yStart: GAP,
|
|
yEnd: layout.y,
|
|
width: riverEndWidth,
|
|
riverEndWidth,
|
|
farEndWidth,
|
|
direction: 'inflow',
|
|
color: COLORS.sourceWaterfall,
|
|
flowAmount: data.inflowRate,
|
|
})
|
|
})
|
|
}
|
|
|
|
// ─── 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) => {
|
|
const childLayout = funnelLayouts.find(f => f.id === alloc.targetId)
|
|
if (!childLayout) return
|
|
|
|
const width = Math.max(MIN_WATERFALL_WIDTH, (alloc.percentage / 100) * parentLayout.riverWidth)
|
|
|
|
overflowBranches.push({
|
|
sourceId: n.id,
|
|
targetId: alloc.targetId,
|
|
percentage: alloc.percentage,
|
|
x1: parentLayout.x + parentLayout.segmentLength,
|
|
y1: parentLayout.y + parentLayout.riverWidth / 2,
|
|
x2: childLayout.x,
|
|
y2: childLayout.y + childLayout.riverWidth / 2,
|
|
width,
|
|
color: alloc.color || COLORS.overflowBranch,
|
|
})
|
|
})
|
|
})
|
|
|
|
// ─── Outcome layouts ──────
|
|
|
|
const outcomeY = funnelStartY + (maxLayer + 1) * (LAYER_HEIGHT + WATERFALL_HEIGHT + GAP) + WATERFALL_HEIGHT
|
|
const totalOutcomeWidth = outcomeNodes.length * (POOL_WIDTH + GAP) - GAP
|
|
const outcomeLayouts: OutcomeLayout[] = outcomeNodes.map((n, i) => {
|
|
const data = n.data as OutcomeNodeData
|
|
const fillPercent = data.fundingTarget > 0
|
|
? Math.min(100, (data.fundingReceived / data.fundingTarget) * 100)
|
|
: 0
|
|
|
|
return {
|
|
id: n.id,
|
|
label: data.label,
|
|
data,
|
|
x: -totalOutcomeWidth / 2 + i * (POOL_WIDTH + GAP),
|
|
y: outcomeY,
|
|
poolWidth: POOL_WIDTH,
|
|
fillPercent,
|
|
}
|
|
})
|
|
|
|
// ─── Spending waterfalls (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
|
|
|
|
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 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}`,
|
|
sourceId: n.id,
|
|
targetId: alloc.targetId,
|
|
label: `${alloc.percentage}%`,
|
|
percentage: alloc.percentage,
|
|
x: riverCenterX,
|
|
xSource: poolCenterX,
|
|
yStart: parentLayout.y + parentLayout.riverWidth + 4,
|
|
yEnd: outcomeLayout.y,
|
|
width: riverEndWidth,
|
|
riverEndWidth,
|
|
farEndWidth,
|
|
direction: 'outflow',
|
|
color: alloc.color || COLORS.spendingWaterfall[i % COLORS.spendingWaterfall.length],
|
|
flowAmount: (alloc.percentage / 100) * (data.inflowRate || 1),
|
|
})
|
|
})
|
|
})
|
|
|
|
// Compute total bounds
|
|
const allX = [
|
|
...funnelLayouts.map(f => f.x),
|
|
...funnelLayouts.map(f => f.x + f.segmentLength),
|
|
...outcomeLayouts.map(o => o.x),
|
|
...outcomeLayouts.map(o => o.x + o.poolWidth),
|
|
...sourceLayouts.map(s => s.x),
|
|
...sourceLayouts.map(s => s.x + s.width),
|
|
]
|
|
const allY = [
|
|
...funnelLayouts.map(f => f.y + f.riverWidth),
|
|
...outcomeLayouts.map(o => o.y + POOL_HEIGHT),
|
|
sourceLayerY,
|
|
]
|
|
|
|
const minX = Math.min(...allX, -100)
|
|
const maxX = Math.max(...allX, 100)
|
|
const maxY = Math.max(...allY, 400)
|
|
const padding = 80
|
|
|
|
// Shift everything so minX starts at padding
|
|
const offsetXGlobal = -minX + padding
|
|
const offsetYGlobal = padding
|
|
|
|
// Apply offsets
|
|
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,
|
|
funnels: funnelLayouts,
|
|
outcomes: outcomeLayouts,
|
|
sourceWaterfalls,
|
|
overflowBranches,
|
|
spendingWaterfalls,
|
|
width: maxX - minX + padding * 2,
|
|
height: maxY + offsetYGlobal + padding,
|
|
}
|
|
}
|
|
|
|
// ─── SVG Sub-components ──────────────────────────────────
|
|
|
|
function SankeyWaterfall({ wf }: { wf: WaterfallLayout }) {
|
|
const isInflow = wf.direction === 'inflow'
|
|
const height = wf.yEnd - wf.yStart
|
|
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>
|
|
<defs>
|
|
<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 shape */}
|
|
<path d={shapePath} fill={wf.color} opacity={0.08} />
|
|
|
|
{/* 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={pathMinX}
|
|
y={wf.yStart - height}
|
|
width={pathMaxW}
|
|
height={height}
|
|
fill={wf.color}
|
|
opacity={0.12}
|
|
style={{
|
|
animation: `waterFlow ${1.4 + i * 0.3}s linear infinite`,
|
|
animationDelay: `${i * -0.4}s`,
|
|
}}
|
|
/>
|
|
))}
|
|
</g>
|
|
|
|
{/* 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' }}
|
|
/>
|
|
<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' }}
|
|
/>
|
|
|
|
{/* Merge ripples at river junction */}
|
|
{[0, 1, 2].map(i => (
|
|
<ellipse
|
|
key={`merge-${wf.id}-${i}`}
|
|
cx={rippleCx}
|
|
cy={rippleCy}
|
|
rx={2}
|
|
ry={1}
|
|
fill="none"
|
|
stroke={wf.color}
|
|
strokeWidth={1}
|
|
style={{
|
|
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={(topCx + bottomCx) / 2}
|
|
y={wf.yStart + height / 2}
|
|
textAnchor="middle"
|
|
fill={COLORS.text}
|
|
fontSize={9}
|
|
fontWeight={600}
|
|
fontFamily="monospace"
|
|
>
|
|
{wf.label}
|
|
</text>
|
|
</g>
|
|
)
|
|
}
|
|
|
|
function RiverSegment({ funnel }: { funnel: FunnelLayout }) {
|
|
const { id, label, data, x, y, riverWidth, segmentLength, status, sufficiency } = funnel
|
|
const isSufficientOrAbundant = sufficiency === 'sufficient' || sufficiency === 'abundant'
|
|
const gradColors = isSufficientOrAbundant ? COLORS.riverSufficient :
|
|
status === 'overflow' ? COLORS.riverOverflow :
|
|
status === 'critical' ? COLORS.riverCritical : COLORS.riverHealthy
|
|
|
|
const thresholdMinY = y + riverWidth * 0.85
|
|
const thresholdMaxY = y + riverWidth * 0.15
|
|
|
|
// Sufficiency ring: arc fill from 0 to sufficientThreshold
|
|
const sufficientThreshold = data.sufficientThreshold ?? data.maxThreshold
|
|
const sufficiencyFill = Math.min(1, data.currentValue / (sufficientThreshold || 1))
|
|
|
|
// Status pill text/color
|
|
const pillText = isSufficientOrAbundant ? 'ENOUGH' :
|
|
status === 'overflow' ? 'OVER' : status === 'critical' ? 'LOW' : 'OK'
|
|
const pillColor = isSufficientOrAbundant ? COLORS.goldenGlow :
|
|
status === 'overflow' ? '#f59e0b' : status === 'critical' ? '#ef4444' : '#10b981'
|
|
const pillWidth = isSufficientOrAbundant ? 42 : 32
|
|
|
|
return (
|
|
<g>
|
|
<defs>
|
|
<linearGradient id={`river-grad-${id}`} x1="0" y1="0" x2="1" y2="0">
|
|
<stop offset="0%" stopColor={gradColors[0]} stopOpacity="0.6" />
|
|
<stop offset="30%" stopColor={gradColors[0]} stopOpacity="0.9" />
|
|
<stop offset="70%" stopColor={gradColors[1]} stopOpacity="0.9" />
|
|
<stop offset="100%" stopColor={gradColors[1]} stopOpacity="0.6" />
|
|
</linearGradient>
|
|
</defs>
|
|
|
|
{/* Golden glow for sufficient/abundant funnels */}
|
|
{isSufficientOrAbundant && (
|
|
<rect
|
|
x={x - 8}
|
|
y={y - 8}
|
|
width={segmentLength + 16}
|
|
height={riverWidth + 16}
|
|
rx={riverWidth / 2 + 8}
|
|
fill={COLORS.goldenGlow}
|
|
opacity={0.15}
|
|
style={{ animation: 'shimmer 2s ease-in-out infinite' }}
|
|
/>
|
|
)}
|
|
|
|
{/* River glow */}
|
|
<rect
|
|
x={x - 4}
|
|
y={y - 4}
|
|
width={segmentLength + 8}
|
|
height={riverWidth + 8}
|
|
rx={riverWidth / 2 + 4}
|
|
fill={gradColors[0]}
|
|
opacity={0.1}
|
|
/>
|
|
|
|
{/* Sufficiency ring: thin progress bar above the river */}
|
|
<rect
|
|
x={x}
|
|
y={y - 3}
|
|
width={segmentLength}
|
|
height={2}
|
|
rx={1}
|
|
fill="rgba(255,255,255,0.1)"
|
|
/>
|
|
<rect
|
|
x={x}
|
|
y={y - 3}
|
|
width={segmentLength * sufficiencyFill}
|
|
height={2}
|
|
rx={1}
|
|
fill={sufficiencyFill >= 1 ? COLORS.goldenGlow : '#06b6d4'}
|
|
opacity={0.8}
|
|
/>
|
|
|
|
{/* River body */}
|
|
<rect
|
|
x={x}
|
|
y={y}
|
|
width={segmentLength}
|
|
height={riverWidth}
|
|
rx={riverWidth / 2}
|
|
fill={`url(#river-grad-${id})`}
|
|
style={isSufficientOrAbundant ? { filter: `drop-shadow(0 0 8px ${COLORS.goldenGlow})` } : undefined}
|
|
/>
|
|
|
|
{/* Surface current lines */}
|
|
{[0.3, 0.5, 0.7].map((pos, i) => (
|
|
<line
|
|
key={`current-${id}-${i}`}
|
|
x1={x + 10}
|
|
y1={y + riverWidth * pos}
|
|
x2={x + segmentLength - 10}
|
|
y2={y + riverWidth * pos}
|
|
stroke="rgba(255,255,255,0.25)"
|
|
strokeWidth={1}
|
|
strokeDasharray="8 12"
|
|
style={{
|
|
animation: `riverCurrent ${1.5 + i * 0.3}s linear infinite`,
|
|
}}
|
|
/>
|
|
))}
|
|
|
|
{/* Shimmer highlight */}
|
|
<ellipse
|
|
cx={x + segmentLength * 0.4}
|
|
cy={y + riverWidth * 0.3}
|
|
rx={segmentLength * 0.15}
|
|
ry={riverWidth * 0.12}
|
|
fill="rgba(255,255,255,0.15)"
|
|
style={{ animation: 'shimmer 3s ease-in-out infinite' }}
|
|
/>
|
|
|
|
{/* Threshold markers */}
|
|
<line
|
|
x1={x + 5}
|
|
y1={thresholdMaxY}
|
|
x2={x + segmentLength - 5}
|
|
y2={thresholdMaxY}
|
|
stroke="#f59e0b"
|
|
strokeWidth={1.5}
|
|
strokeDasharray="4 3"
|
|
opacity={0.6}
|
|
/>
|
|
<text x={x + segmentLength + 6} y={thresholdMaxY + 3} fontSize={7} fill="#f59e0b" fontWeight="bold">MAX</text>
|
|
|
|
<line
|
|
x1={x + 5}
|
|
y1={thresholdMinY}
|
|
x2={x + segmentLength - 5}
|
|
y2={thresholdMinY}
|
|
stroke="#ef4444"
|
|
strokeWidth={1.5}
|
|
strokeDasharray="4 3"
|
|
opacity={0.6}
|
|
/>
|
|
<text x={x + segmentLength + 6} y={thresholdMinY + 3} fontSize={7} fill="#ef4444" fontWeight="bold">MIN</text>
|
|
|
|
{/* Dynamic overflow indicator */}
|
|
{data.dynamicOverflow && (
|
|
<text
|
|
x={x + segmentLength + 6}
|
|
y={y + riverWidth / 2 + 3}
|
|
fontSize={7}
|
|
fill={COLORS.goldenGlow}
|
|
fontWeight="bold"
|
|
opacity={0.7}
|
|
>
|
|
DYN
|
|
</text>
|
|
)}
|
|
|
|
{/* Label */}
|
|
<text
|
|
x={x + segmentLength / 2}
|
|
y={y - 14}
|
|
textAnchor="middle"
|
|
fill={COLORS.text}
|
|
fontSize={12}
|
|
fontWeight={700}
|
|
>
|
|
{label}
|
|
</text>
|
|
|
|
{/* Value */}
|
|
<text
|
|
x={x + segmentLength / 2}
|
|
y={y + riverWidth / 2 + 4}
|
|
textAnchor="middle"
|
|
fill="white"
|
|
fontSize={13}
|
|
fontWeight={700}
|
|
fontFamily="monospace"
|
|
>
|
|
${Math.floor(data.currentValue).toLocaleString()}
|
|
</text>
|
|
|
|
{/* Status pill */}
|
|
<rect
|
|
x={x + segmentLength / 2 - pillWidth / 2}
|
|
y={y + riverWidth + 6}
|
|
width={pillWidth}
|
|
height={14}
|
|
rx={7}
|
|
fill={pillColor}
|
|
opacity={0.9}
|
|
/>
|
|
<text
|
|
x={x + segmentLength / 2}
|
|
y={y + riverWidth + 16}
|
|
textAnchor="middle"
|
|
fill={isSufficientOrAbundant ? '#1a1a2e' : 'white'}
|
|
fontSize={7}
|
|
fontWeight={700}
|
|
>
|
|
{pillText}
|
|
</text>
|
|
</g>
|
|
)
|
|
}
|
|
|
|
function OverflowBranch({ branch }: { branch: BranchLayout }) {
|
|
const { x1, y1, x2, y2, width, color, percentage } = branch
|
|
const midX = (x1 + x2) / 2
|
|
const midY = (y1 + y2) / 2
|
|
|
|
// Curved path connecting two river segments
|
|
const path = `M ${x1} ${y1} C ${x1 + 60} ${y1}, ${x2 - 60} ${y2}, ${x2} ${y2}`
|
|
|
|
return (
|
|
<g>
|
|
{/* Glow */}
|
|
<path
|
|
d={path}
|
|
fill="none"
|
|
stroke={color}
|
|
strokeWidth={width + 6}
|
|
opacity={0.1}
|
|
strokeLinecap="round"
|
|
/>
|
|
{/* Main branch */}
|
|
<path
|
|
d={path}
|
|
fill="none"
|
|
stroke={color}
|
|
strokeWidth={width}
|
|
opacity={0.7}
|
|
strokeLinecap="round"
|
|
strokeDasharray="10 5"
|
|
style={{ animation: `riverCurrent 1.5s linear infinite` }}
|
|
/>
|
|
{/* Flow direction label */}
|
|
<text
|
|
x={midX}
|
|
y={midY - width / 2 - 4}
|
|
textAnchor="middle"
|
|
fill={color}
|
|
fontSize={9}
|
|
fontWeight={600}
|
|
fontFamily="monospace"
|
|
>
|
|
{percentage}%
|
|
</text>
|
|
</g>
|
|
)
|
|
}
|
|
|
|
function OutcomePool({ outcome }: { outcome: OutcomeLayout }) {
|
|
const { id, label, data, x, y, poolWidth, fillPercent } = outcome
|
|
const fillHeight = (fillPercent / 100) * POOL_HEIGHT
|
|
|
|
const statusColor =
|
|
data.status === 'completed' ? '#10b981' :
|
|
data.status === 'in-progress' ? '#3b82f6' :
|
|
data.status === 'blocked' ? '#ef4444' : '#64748b'
|
|
|
|
return (
|
|
<g>
|
|
{/* Pool container */}
|
|
<rect
|
|
x={x}
|
|
y={y}
|
|
width={poolWidth}
|
|
height={POOL_HEIGHT}
|
|
rx={8}
|
|
fill="rgba(15,23,42,0.8)"
|
|
stroke={statusColor}
|
|
strokeWidth={1.5}
|
|
opacity={0.9}
|
|
/>
|
|
|
|
{/* Water fill */}
|
|
<defs>
|
|
<clipPath id={`pool-clip-${id}`}>
|
|
<rect x={x + 2} y={y + 2} width={poolWidth - 4} height={POOL_HEIGHT - 4} rx={6} />
|
|
</clipPath>
|
|
<linearGradient id={`pool-grad-${id}`} x1="0" y1="1" x2="0" y2="0">
|
|
<stop offset="0%" stopColor={statusColor} stopOpacity="0.8" />
|
|
<stop offset="100%" stopColor={statusColor} stopOpacity="0.4" />
|
|
</linearGradient>
|
|
</defs>
|
|
|
|
<g clipPath={`url(#pool-clip-${id})`}>
|
|
<rect
|
|
x={x + 2}
|
|
y={y + POOL_HEIGHT - fillHeight}
|
|
width={poolWidth - 4}
|
|
height={fillHeight}
|
|
fill={`url(#pool-grad-${id})`}
|
|
/>
|
|
{/* Water surface wave */}
|
|
{fillPercent > 5 && (
|
|
<ellipse
|
|
cx={x + poolWidth / 2}
|
|
cy={y + POOL_HEIGHT - fillHeight}
|
|
rx={poolWidth * 0.3}
|
|
ry={2}
|
|
fill="rgba(255,255,255,0.2)"
|
|
style={{ animation: 'waveFloat 2s ease-in-out infinite' }}
|
|
/>
|
|
)}
|
|
</g>
|
|
|
|
{/* Label */}
|
|
<text
|
|
x={x + poolWidth / 2}
|
|
y={y - 6}
|
|
textAnchor="middle"
|
|
fill={COLORS.text}
|
|
fontSize={9}
|
|
fontWeight={600}
|
|
>
|
|
{label}
|
|
</text>
|
|
|
|
{/* Value */}
|
|
<text
|
|
x={x + poolWidth / 2}
|
|
y={y + POOL_HEIGHT / 2 + 2}
|
|
textAnchor="middle"
|
|
fill="white"
|
|
fontSize={9}
|
|
fontWeight={600}
|
|
fontFamily="monospace"
|
|
>
|
|
${Math.floor(data.fundingReceived).toLocaleString()}
|
|
</text>
|
|
|
|
{/* Progress */}
|
|
<text
|
|
x={x + poolWidth / 2}
|
|
y={y + POOL_HEIGHT / 2 + 14}
|
|
textAnchor="middle"
|
|
fill={COLORS.textMuted}
|
|
fontSize={7}
|
|
fontFamily="monospace"
|
|
>
|
|
{Math.round(fillPercent)}% of ${Math.floor(data.fundingTarget).toLocaleString()}
|
|
</text>
|
|
</g>
|
|
)
|
|
}
|
|
|
|
function SourceBox({ source }: { source: SourceLayout }) {
|
|
return (
|
|
<g>
|
|
<rect
|
|
x={source.x}
|
|
y={source.y}
|
|
width={source.width}
|
|
height={SOURCE_HEIGHT}
|
|
rx={8}
|
|
fill="rgba(16,185,129,0.15)"
|
|
stroke="#10b981"
|
|
strokeWidth={1.5}
|
|
/>
|
|
<text
|
|
x={source.x + source.width / 2}
|
|
y={source.y + SOURCE_HEIGHT / 2 - 4}
|
|
textAnchor="middle"
|
|
fill="#10b981"
|
|
fontSize={10}
|
|
fontWeight={700}
|
|
>
|
|
{source.label}
|
|
</text>
|
|
<text
|
|
x={source.x + source.width / 2}
|
|
y={source.y + SOURCE_HEIGHT / 2 + 10}
|
|
textAnchor="middle"
|
|
fill="#10b981"
|
|
fontSize={8}
|
|
fontFamily="monospace"
|
|
opacity={0.7}
|
|
>
|
|
${source.flowRate.toLocaleString()}/mo
|
|
</text>
|
|
</g>
|
|
)
|
|
}
|
|
|
|
// ─── Main Component ──────────────────────────────────────
|
|
|
|
interface BudgetRiverProps {
|
|
nodes: FlowNode[]
|
|
isSimulating?: boolean
|
|
}
|
|
|
|
export default function BudgetRiver({ nodes, isSimulating = false }: BudgetRiverProps) {
|
|
const [animatedNodes, setAnimatedNodes] = useState<FlowNode[]>(nodes)
|
|
|
|
// Update when parent nodes change
|
|
useEffect(() => {
|
|
setAnimatedNodes(nodes)
|
|
}, [nodes])
|
|
|
|
// Simulation
|
|
useEffect(() => {
|
|
if (!isSimulating) return
|
|
|
|
const interval = setInterval(() => {
|
|
setAnimatedNodes(prev =>
|
|
prev.map(node => {
|
|
if (node.type === 'funnel') {
|
|
const data = node.data as FunnelNodeData
|
|
const change = (Math.random() - 0.45) * 300
|
|
return {
|
|
...node,
|
|
data: {
|
|
...data,
|
|
currentValue: Math.max(0, Math.min(data.maxCapacity * 1.1, data.currentValue + change)),
|
|
},
|
|
}
|
|
} else if (node.type === 'outcome') {
|
|
const data = node.data as OutcomeNodeData
|
|
const change = Math.random() * 80
|
|
const newReceived = Math.min(data.fundingTarget * 1.05, data.fundingReceived + change)
|
|
return {
|
|
...node,
|
|
data: {
|
|
...data,
|
|
fundingReceived: newReceived,
|
|
status: newReceived >= data.fundingTarget ? 'completed' :
|
|
data.status === 'not-started' && newReceived > 0 ? 'in-progress' : data.status,
|
|
},
|
|
}
|
|
}
|
|
return node
|
|
})
|
|
)
|
|
}, 500)
|
|
|
|
return () => clearInterval(interval)
|
|
}, [isSimulating])
|
|
|
|
const layout = useMemo(() => computeLayout(animatedNodes), [animatedNodes])
|
|
const systemSufficiency = useMemo(() => computeSystemSufficiency(animatedNodes), [animatedNodes])
|
|
const sufficiencyPct = Math.round(systemSufficiency * 100)
|
|
const sufficiencyColor = sufficiencyPct >= 90 ? '#fbbf24' :
|
|
sufficiencyPct >= 60 ? '#10b981' :
|
|
sufficiencyPct >= 30 ? '#f59e0b' : '#ef4444'
|
|
|
|
return (
|
|
<div className="w-full h-full overflow-auto">
|
|
<svg
|
|
width={layout.width}
|
|
height={layout.height}
|
|
viewBox={`0 0 ${layout.width} ${layout.height}`}
|
|
className="mx-auto"
|
|
style={{ minWidth: layout.width, minHeight: layout.height }}
|
|
>
|
|
{/* Background */}
|
|
<rect width={layout.width} height={layout.height} fill={COLORS.bg} />
|
|
|
|
{/* Background stars/dots for atmosphere */}
|
|
{Array.from({ length: 30 }, (_, i) => (
|
|
<circle
|
|
key={`star-${i}`}
|
|
cx={Math.random() * layout.width}
|
|
cy={Math.random() * layout.height}
|
|
r={0.5 + Math.random() * 1}
|
|
fill="rgba(148,163,184,0.2)"
|
|
style={{
|
|
animation: `shimmer ${2 + Math.random() * 3}s ease-in-out infinite`,
|
|
animationDelay: `${Math.random() * 3}s`,
|
|
}}
|
|
/>
|
|
))}
|
|
|
|
{/* Layer 1: Source boxes */}
|
|
{layout.sources.map(s => (
|
|
<SourceBox key={s.id} source={s} />
|
|
))}
|
|
|
|
{/* Layer 2: Source waterfalls (sankey inflows pouring into river) */}
|
|
{layout.sourceWaterfalls.map(wf => (
|
|
<SankeyWaterfall key={wf.id} wf={wf} />
|
|
))}
|
|
|
|
{/* Layer 3: River segments */}
|
|
{layout.funnels.map(f => (
|
|
<RiverSegment key={f.id} funnel={f} />
|
|
))}
|
|
|
|
{/* Layer 4: Overflow branches */}
|
|
{layout.overflowBranches.map((b, i) => (
|
|
<OverflowBranch key={`overflow-${i}`} branch={b} />
|
|
))}
|
|
|
|
{/* Layer 5: Spending waterfalls (sankey outflows pouring out) */}
|
|
{layout.spendingWaterfalls.map(wf => (
|
|
<SankeyWaterfall key={wf.id} wf={wf} />
|
|
))}
|
|
|
|
{/* Layer 6: Outcome pools */}
|
|
{layout.outcomes.map(o => (
|
|
<OutcomePool key={o.id} outcome={o} />
|
|
))}
|
|
|
|
{/* System Enoughness Badge (top-right) */}
|
|
<g transform={`translate(${layout.width - 80}, 20)`}>
|
|
{/* Background circle */}
|
|
<circle cx={30} cy={30} r={28} fill="rgba(15,23,42,0.85)" stroke="rgba(255,255,255,0.1)" strokeWidth={1} />
|
|
{/* Track ring */}
|
|
<circle cx={30} cy={30} r={22} fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth={4} />
|
|
{/* Progress ring */}
|
|
<circle
|
|
cx={30} cy={30} r={22}
|
|
fill="none"
|
|
stroke={sufficiencyColor}
|
|
strokeWidth={4}
|
|
strokeLinecap="round"
|
|
strokeDasharray={`${sufficiencyPct * 1.382} ${138.2 - sufficiencyPct * 1.382}`}
|
|
strokeDashoffset={34.56}
|
|
style={{ transition: 'stroke-dasharray 0.5s ease, stroke 0.5s ease' }}
|
|
/>
|
|
{/* Percentage text */}
|
|
<text x={30} y={28} textAnchor="middle" fill={sufficiencyColor} fontSize={12} fontWeight={700} fontFamily="monospace">
|
|
{sufficiencyPct}%
|
|
</text>
|
|
{/* Label */}
|
|
<text x={30} y={40} textAnchor="middle" fill={COLORS.textMuted} fontSize={6} fontWeight={600} letterSpacing={0.5}>
|
|
ENOUGHNESS
|
|
</text>
|
|
</g>
|
|
|
|
{/* Title watermark */}
|
|
<text
|
|
x={layout.width / 2}
|
|
y={layout.height - 20}
|
|
textAnchor="middle"
|
|
fill="rgba(148,163,184,0.15)"
|
|
fontSize={14}
|
|
fontWeight={700}
|
|
letterSpacing={4}
|
|
>
|
|
rFUNDS BUDGET RIVER
|
|
</text>
|
|
</svg>
|
|
</div>
|
|
)
|
|
}
|