537 lines
20 KiB
TypeScript
537 lines
20 KiB
TypeScript
'use client'
|
|
|
|
import { useMemo, useState } from 'react'
|
|
import type { MortgageState, MortgageTranche } from '@/lib/mortgage-types'
|
|
|
|
interface Props {
|
|
state: MortgageState
|
|
selectedTrancheId: string | null
|
|
onSelectTranche: (tranche: MortgageTranche | null) => void
|
|
}
|
|
|
|
function fmt(n: number): string {
|
|
if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(1)}M`
|
|
if (n >= 1_000) return `$${(n / 1_000).toFixed(0)}k`
|
|
return `$${n.toFixed(0)}`
|
|
}
|
|
|
|
// Deterministic pseudo-random from seed
|
|
function seededRandom(seed: number) {
|
|
let s = seed
|
|
return () => {
|
|
s = (s * 16807 + 0) % 2147483647
|
|
return (s - 1) / 2147483646
|
|
}
|
|
}
|
|
|
|
interface NodePos {
|
|
x: number
|
|
y: number
|
|
ring: number
|
|
angle: number
|
|
tranche: MortgageTranche
|
|
}
|
|
|
|
// Generate organic offsets for mycelial feel
|
|
function computeLayout(state: MortgageState, size: number) {
|
|
const cx = size / 2
|
|
const cy = size / 2
|
|
const n = state.tranches.length
|
|
|
|
// Determine rings: inner ring for active high-flow, outer for repaid/lower
|
|
const maxRings = n <= 20 ? 1 : n <= 60 ? 2 : 3
|
|
const baseRadius = size * 0.2
|
|
const ringGap = (size * 0.32) / maxRings
|
|
|
|
const rng = seededRandom(42)
|
|
const nodes: NodePos[] = []
|
|
|
|
// Sort: active first, then by principal remaining (largest closest)
|
|
const sorted = [...state.tranches].sort((a, b) => {
|
|
if (a.status !== b.status) return a.status === 'active' ? -1 : 1
|
|
return b.principalRemaining - a.principalRemaining
|
|
})
|
|
|
|
const perRing = Math.ceil(sorted.length / maxRings)
|
|
|
|
sorted.forEach((tranche, i) => {
|
|
const ring = Math.min(Math.floor(i / perRing), maxRings - 1)
|
|
const indexInRing = i - ring * perRing
|
|
const countInRing = Math.min(perRing, sorted.length - ring * perRing)
|
|
|
|
const baseAngle = (indexInRing / countInRing) * Math.PI * 2 - Math.PI / 2
|
|
// Organic jitter
|
|
const angleJitter = (rng() - 0.5) * 0.15
|
|
const radiusJitter = (rng() - 0.5) * ringGap * 0.25
|
|
const angle = baseAngle + angleJitter
|
|
|
|
const radius = baseRadius + ring * ringGap + ringGap * 0.5 + radiusJitter
|
|
|
|
nodes.push({
|
|
x: cx + Math.cos(angle) * radius,
|
|
y: cy + Math.sin(angle) * radius,
|
|
ring,
|
|
angle,
|
|
tranche,
|
|
})
|
|
})
|
|
|
|
// Build reinvestment connections from actual data (parent → child tranche)
|
|
const relendLinks: { from: number; to: number; strength: number }[] = []
|
|
for (let i = 0; i < nodes.length; i++) {
|
|
const t = nodes[i].tranche
|
|
if (!t.isReinvested || !t.parentTrancheId) continue
|
|
// Find parent node
|
|
const parentIdx = nodes.findIndex(n => n.tranche.id === t.parentTrancheId)
|
|
if (parentIdx < 0) continue
|
|
const strength = Math.min(1, t.principal / state.trancheSize)
|
|
relendLinks.push({ from: parentIdx, to: i, strength })
|
|
}
|
|
|
|
// Also add links between same-lender tranches (reinvestor who has multiple active)
|
|
const lenderNodes = new Map<string, number[]>()
|
|
nodes.forEach((n, i) => {
|
|
const lid = n.tranche.lender.id
|
|
if (!lenderNodes.has(lid)) lenderNodes.set(lid, [])
|
|
lenderNodes.get(lid)!.push(i)
|
|
})
|
|
for (const [, indices] of lenderNodes) {
|
|
if (indices.length < 2) continue
|
|
for (let j = 1; j < indices.length; j++) {
|
|
const from = indices[0]
|
|
const to = indices[j]
|
|
// Avoid duplicate links
|
|
if (!relendLinks.some(l => (l.from === from && l.to === to) || (l.from === to && l.to === from))) {
|
|
relendLinks.push({ from, to, strength: 0.5 })
|
|
}
|
|
}
|
|
}
|
|
|
|
return { cx, cy, nodes, relendLinks }
|
|
}
|
|
|
|
// Generate an organic bezier path (like a hypha)
|
|
function hyphaPath(x1: number, y1: number, x2: number, y2: number, curvature: number = 0.3): string {
|
|
const dx = x2 - x1
|
|
const dy = y2 - y1
|
|
const mx = (x1 + x2) / 2
|
|
const my = (y1 + y2) / 2
|
|
// Perpendicular offset for organic curve
|
|
const nx = -dy * curvature
|
|
const ny = dx * curvature
|
|
return `M${x1},${y1} Q${mx + nx},${my + ny} ${x2},${y2}`
|
|
}
|
|
|
|
// Generate a wavy mycelial path between two lenders
|
|
function mycelialPath(x1: number, y1: number, x2: number, y2: number, seed: number): string {
|
|
const rng = seededRandom(seed)
|
|
const dx = x2 - x1
|
|
const dy = y2 - y1
|
|
const dist = Math.sqrt(dx * dx + dy * dy)
|
|
const nx = -dy / dist
|
|
const ny = dx / dist
|
|
const wobble = dist * 0.15
|
|
|
|
const cp1x = x1 + dx * 0.33 + nx * wobble * (rng() - 0.5)
|
|
const cp1y = y1 + dy * 0.33 + ny * wobble * (rng() - 0.5)
|
|
const cp2x = x1 + dx * 0.66 + nx * wobble * (rng() - 0.5)
|
|
const cp2y = y1 + dy * 0.66 + ny * wobble * (rng() - 0.5)
|
|
|
|
return `M${x1},${y1} C${cp1x},${cp1y} ${cp2x},${cp2y} ${x2},${y2}`
|
|
}
|
|
|
|
export default function MycelialNetworkViz({ state, selectedTrancheId, onSelectTranche }: Props) {
|
|
const [hoveredId, setHoveredId] = useState<string | null>(null)
|
|
const size = 700
|
|
const layout = useMemo(() => computeLayout(state, size), [state, size])
|
|
|
|
const repaidPct = state.totalPrincipal > 0 ? state.totalPrincipalPaid / state.totalPrincipal : 0
|
|
|
|
return (
|
|
<svg
|
|
viewBox={`0 0 ${size} ${size}`}
|
|
className="w-full h-full"
|
|
style={{ minHeight: 500, maxHeight: '75vh' }}
|
|
>
|
|
<defs>
|
|
{/* Radial glow for center node */}
|
|
<radialGradient id="centerGlow" cx="50%" cy="50%" r="50%">
|
|
<stop offset="0%" stopColor="#0ea5e9" stopOpacity="0.3" />
|
|
<stop offset="70%" stopColor="#0ea5e9" stopOpacity="0.05" />
|
|
<stop offset="100%" stopColor="#0ea5e9" stopOpacity="0" />
|
|
</radialGradient>
|
|
|
|
{/* Mycelium pulse animation */}
|
|
<filter id="glowFilter">
|
|
<feGaussianBlur stdDeviation="2" result="blur" />
|
|
<feMerge>
|
|
<feMergeNode in="blur" />
|
|
<feMergeNode in="SourceGraphic" />
|
|
</feMerge>
|
|
</filter>
|
|
|
|
{/* Animated flow dot */}
|
|
<circle id="flowDot" r="2" fill="#0ea5e9" opacity="0.8" />
|
|
|
|
{/* Gradient for active hyphae */}
|
|
<linearGradient id="hyphaActive" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
<stop offset="0%" stopColor="#0ea5e9" stopOpacity="0.6" />
|
|
<stop offset="100%" stopColor="#10b981" stopOpacity="0.3" />
|
|
</linearGradient>
|
|
|
|
<linearGradient id="hyphaRepaid" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
<stop offset="0%" stopColor="#10b981" stopOpacity="0.3" />
|
|
<stop offset="100%" stopColor="#10b981" stopOpacity="0.1" />
|
|
</linearGradient>
|
|
|
|
<linearGradient id="hyphaRelend" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
<stop offset="0%" stopColor="#8b5cf6" stopOpacity="0.4" />
|
|
<stop offset="100%" stopColor="#f59e0b" stopOpacity="0.2" />
|
|
</linearGradient>
|
|
</defs>
|
|
|
|
{/* ─── Background ring guides ─── */}
|
|
{[0.2, 0.35, 0.5].map((r, i) => (
|
|
<circle
|
|
key={i}
|
|
cx={layout.cx} cy={layout.cy}
|
|
r={size * r}
|
|
fill="none"
|
|
stroke="#1e293b"
|
|
strokeWidth="0.5"
|
|
strokeDasharray="4,8"
|
|
/>
|
|
))}
|
|
|
|
{/* ─── Relend/staking connections (mycelial cross-links) ─── */}
|
|
{layout.relendLinks.map((link, i) => {
|
|
const from = layout.nodes[link.from]
|
|
const to = layout.nodes[link.to]
|
|
if (!from || !to) return null
|
|
|
|
const isHighlighted =
|
|
selectedTrancheId === from.tranche.id ||
|
|
selectedTrancheId === to.tranche.id ||
|
|
hoveredId === from.tranche.id ||
|
|
hoveredId === to.tranche.id
|
|
|
|
return (
|
|
<g key={`relend-${i}`}>
|
|
<path
|
|
d={mycelialPath(from.x, from.y, to.x, to.y, i * 7 + 31)}
|
|
fill="none"
|
|
stroke="url(#hyphaRelend)"
|
|
strokeWidth={isHighlighted ? 2 : 0.8}
|
|
opacity={isHighlighted ? 0.8 : 0.25 + link.strength * 0.3}
|
|
strokeDasharray={isHighlighted ? 'none' : '3,6'}
|
|
/>
|
|
{/* Animated pulse along relend path */}
|
|
{isHighlighted && (
|
|
<circle r="2.5" fill="#8b5cf6" opacity="0.7">
|
|
<animateMotion
|
|
path={mycelialPath(from.x, from.y, to.x, to.y, i * 7 + 31)}
|
|
dur={`${2 + link.strength}s`}
|
|
repeatCount="indefinite"
|
|
/>
|
|
</circle>
|
|
)}
|
|
</g>
|
|
)
|
|
})}
|
|
|
|
{/* ─── Hyphae: Center → Lenders ─── */}
|
|
{layout.nodes.map((node, i) => {
|
|
const t = node.tranche
|
|
const isSelected = t.id === selectedTrancheId
|
|
const isHovered = t.id === hoveredId
|
|
const isActive = t.status === 'active'
|
|
const isOpenNode = !t.funded
|
|
const flowStrength = isActive && !isOpenNode
|
|
? Math.max(0.15, t.monthlyPayment / (state.monthlyPayment || 1))
|
|
: 0
|
|
|
|
// Organic curvature varies per node
|
|
const curvature = 0.1 + (i % 5) * 0.06 * (i % 2 === 0 ? 1 : -1)
|
|
|
|
return (
|
|
<g key={`hypha-${t.id}`}>
|
|
<path
|
|
d={hyphaPath(layout.cx, layout.cy, node.x, node.y, curvature)}
|
|
fill="none"
|
|
stroke={isSelected || isHovered
|
|
? '#38bdf8'
|
|
: isOpenNode ? '#334155'
|
|
: isActive ? 'url(#hyphaActive)' : 'url(#hyphaRepaid)'}
|
|
strokeWidth={isSelected ? 2.5 : isHovered ? 2 : isOpenNode ? 0.5 : Math.max(0.5, flowStrength * 3)}
|
|
opacity={isSelected || isHovered ? 0.9 : isOpenNode ? 0.15 : isActive ? 0.4 : 0.15}
|
|
strokeDasharray={isOpenNode ? '4,6' : 'none'}
|
|
/>
|
|
{/* Animated flow dot along active hyphae */}
|
|
{isActive && (isSelected || isHovered) && (
|
|
<circle r="2" fill="#0ea5e9" opacity="0.9">
|
|
<animateMotion
|
|
path={hyphaPath(layout.cx, layout.cy, node.x, node.y, curvature)}
|
|
dur={`${1.5 + i * 0.05}s`}
|
|
repeatCount="indefinite"
|
|
/>
|
|
</circle>
|
|
)}
|
|
</g>
|
|
)
|
|
})}
|
|
|
|
{/* ─── Lender Nodes (spores) ─── */}
|
|
{layout.nodes.map((node) => {
|
|
const t = node.tranche
|
|
const isSelected = t.id === selectedTrancheId
|
|
const isHovered = t.id === hoveredId
|
|
const isActive = t.status === 'active'
|
|
const isOpen = !t.funded
|
|
const repaidFrac = t.principal > 0 ? t.totalPrincipalPaid / t.principal : 0
|
|
|
|
// Node size based on tranche principal
|
|
const baseR = Math.max(6, Math.min(16, 4 + (t.principal / state.trancheSize) * 8))
|
|
const r = isSelected ? baseR + 3 : isHovered ? baseR + 1.5 : isOpen ? baseR - 1 : baseR
|
|
|
|
return (
|
|
<g
|
|
key={t.id}
|
|
onClick={() => onSelectTranche(isSelected ? null : t)}
|
|
onMouseEnter={() => setHoveredId(t.id)}
|
|
onMouseLeave={() => setHoveredId(null)}
|
|
style={{ cursor: 'pointer' }}
|
|
>
|
|
{/* Outer ring (full tranche) */}
|
|
<circle
|
|
cx={node.x} cy={node.y} r={r}
|
|
fill={isOpen ? 'none' : isActive ? '#1e293b' : '#064e3b'}
|
|
fillOpacity={isOpen ? 0 : 0.8}
|
|
stroke={isSelected ? '#38bdf8' : isHovered ? '#64748b' : isOpen ? '#475569' : isActive ? '#334155' : '#065f46'}
|
|
strokeWidth={isSelected ? 2 : 1}
|
|
strokeDasharray={isOpen ? '3,3' : 'none'}
|
|
opacity={isOpen ? 0.5 : 1}
|
|
/>
|
|
|
|
{/* Repaid fill (grows from bottom like a filling circle) */}
|
|
{repaidFrac > 0 && repaidFrac < 1 && (
|
|
<clipPath id={`clip-${t.id}`}>
|
|
<rect
|
|
x={node.x - r} y={node.y + r - r * 2 * repaidFrac}
|
|
width={r * 2} height={r * 2 * repaidFrac}
|
|
/>
|
|
</clipPath>
|
|
)}
|
|
{repaidFrac > 0 && (
|
|
<circle
|
|
cx={node.x} cy={node.y} r={r - 1}
|
|
fill={isActive ? '#0ea5e9' : '#10b981'}
|
|
fillOpacity={0.5}
|
|
clipPath={repaidFrac < 1 ? `url(#clip-${t.id})` : undefined}
|
|
/>
|
|
)}
|
|
|
|
{/* Reinvestment glow (purple ring for reinvestors) */}
|
|
{t.reinvestmentRate != null && isActive && (
|
|
<circle
|
|
cx={node.x} cy={node.y} r={r + 3}
|
|
fill="none"
|
|
stroke="#a78bfa"
|
|
strokeWidth="1"
|
|
opacity={0.6}
|
|
strokeDasharray={t.isReinvested ? 'none' : '3,3'}
|
|
/>
|
|
)}
|
|
|
|
{/* Reinvested badge (inner dot) */}
|
|
{t.isReinvested && (
|
|
<circle
|
|
cx={node.x} cy={node.y} r={2.5}
|
|
fill="#8b5cf6"
|
|
opacity="0.9"
|
|
/>
|
|
)}
|
|
|
|
{/* Interest glow for high-yield nodes */}
|
|
{t.totalInterestPaid > t.principal * 0.1 && isActive && !t.reinvestmentRate && (
|
|
<circle
|
|
cx={node.x} cy={node.y} r={r + 3}
|
|
fill="none"
|
|
stroke="#f59e0b"
|
|
strokeWidth="0.5"
|
|
opacity={0.3 + Math.min(0.5, t.totalInterestPaid / t.principal)}
|
|
strokeDasharray="2,3"
|
|
/>
|
|
)}
|
|
|
|
{/* For-sale marker */}
|
|
{t.listedForSale && (
|
|
<circle
|
|
cx={node.x + r * 0.7} cy={node.y - r * 0.7}
|
|
r={3} fill="#f59e0b"
|
|
/>
|
|
)}
|
|
|
|
{/* Transfer history marker */}
|
|
{t.transferHistory.length > 0 && (
|
|
<circle
|
|
cx={node.x - r * 0.7} cy={node.y - r * 0.7}
|
|
r={3} fill="#8b5cf6"
|
|
/>
|
|
)}
|
|
|
|
{/* Label on hover/select */}
|
|
{(isSelected || isHovered) && (
|
|
<g>
|
|
{(() => {
|
|
const hasReinvest = t.reinvestmentRate != null
|
|
const boxH = hasReinvest ? 44 : 32
|
|
return (
|
|
<>
|
|
<rect
|
|
x={node.x - 42} y={node.y + r + 4}
|
|
width={84} height={boxH}
|
|
rx={4} fill="#0f172a" fillOpacity="0.95"
|
|
stroke={t.isReinvested ? '#8b5cf6' : '#334155'} strokeWidth="0.5"
|
|
/>
|
|
<text
|
|
x={node.x} y={node.y + r + 17}
|
|
textAnchor="middle" fill={isOpen ? '#64748b' : '#e2e8f0'} fontSize="9" fontWeight="600"
|
|
>
|
|
{isOpen ? 'Open Slot' : t.lender.name}{t.isReinvested ? ' (R)' : ''}
|
|
</text>
|
|
<text
|
|
x={node.x} y={node.y + r + 29}
|
|
textAnchor="middle" fill="#94a3b8" fontSize="8"
|
|
>
|
|
{fmt(t.principal)} @ {(t.interestRate * 100).toFixed(1)}%{isOpen ? ` (${t.tierLabel})` : ''}
|
|
</text>
|
|
{hasReinvest && (
|
|
<text
|
|
x={node.x} y={node.y + r + 41}
|
|
textAnchor="middle" fill="#a78bfa" fontSize="7"
|
|
>
|
|
reinvests @ {(t.reinvestmentRate! * 100).toFixed(0)}%
|
|
{t.reinvestmentPool > 0 ? ` (${fmt(t.reinvestmentPool)} pooled)` : ''}
|
|
</text>
|
|
)}
|
|
</>
|
|
)
|
|
})()}
|
|
</g>
|
|
)}
|
|
</g>
|
|
)
|
|
})}
|
|
|
|
{/* ─── Center Node: Borrower/Property ─── */}
|
|
<g>
|
|
{/* Glow */}
|
|
<circle cx={layout.cx} cy={layout.cy} r={size * 0.12} fill="url(#centerGlow)" />
|
|
|
|
{/* Repaid progress ring */}
|
|
<circle
|
|
cx={layout.cx} cy={layout.cy}
|
|
r={36}
|
|
fill="none"
|
|
stroke="#1e293b"
|
|
strokeWidth="4"
|
|
/>
|
|
<circle
|
|
cx={layout.cx} cy={layout.cy}
|
|
r={36}
|
|
fill="none"
|
|
stroke="#10b981"
|
|
strokeWidth="4"
|
|
strokeDasharray={`${repaidPct * 226.2} ${226.2}`}
|
|
strokeDashoffset={226.2 * 0.25}
|
|
strokeLinecap="round"
|
|
opacity="0.8"
|
|
/>
|
|
|
|
{/* Inner circle */}
|
|
<circle
|
|
cx={layout.cx} cy={layout.cy}
|
|
r={30}
|
|
fill="#0f172a"
|
|
stroke="#0ea5e9"
|
|
strokeWidth="2"
|
|
/>
|
|
|
|
{/* House icon (simple) */}
|
|
<path
|
|
d={`M${layout.cx},${layout.cy - 14} l-12,10 h4 v8 h5 v-5 h6 v5 h5 v-8 h4 z`}
|
|
fill="none"
|
|
stroke="#0ea5e9"
|
|
strokeWidth="1.5"
|
|
strokeLinejoin="round"
|
|
/>
|
|
|
|
{/* Labels */}
|
|
<text
|
|
x={layout.cx} y={layout.cy + 20}
|
|
textAnchor="middle" fill="#e2e8f0" fontSize="9" fontWeight="600"
|
|
>
|
|
{fmt(state.totalPrincipal)}
|
|
</text>
|
|
</g>
|
|
|
|
{/* ─── Legend ─── */}
|
|
<g transform={`translate(16, ${size - 120})`}>
|
|
<rect width={140} height={112} rx={6} fill="#0f172a" fillOpacity="0.9" stroke="#1e293b" strokeWidth="0.5" />
|
|
<g transform="translate(10, 14)">
|
|
<circle cx={5} cy={0} r={4} fill="#0ea5e9" fillOpacity="0.5" stroke="#334155" />
|
|
<text x={14} y={3} fill="#94a3b8" fontSize="8">Active lender</text>
|
|
</g>
|
|
<g transform="translate(10, 28)">
|
|
<circle cx={5} cy={0} r={4} fill="#10b981" fillOpacity="0.5" stroke="#065f46" />
|
|
<text x={14} y={3} fill="#94a3b8" fontSize="8">Repaid lender</text>
|
|
</g>
|
|
<g transform="translate(10, 42)">
|
|
<circle cx={5} cy={0} r={4} fill="none" stroke="#475569" strokeWidth="1" strokeDasharray="3,3" opacity="0.5" />
|
|
<text x={14} y={3} fill="#94a3b8" fontSize="8">Open slot</text>
|
|
</g>
|
|
<g transform="translate(10, 56)">
|
|
<circle cx={5} cy={0} r={4} fill="#1e293b" stroke="#a78bfa" strokeWidth="1" />
|
|
<circle cx={5} cy={0} r={2} fill="#8b5cf6" />
|
|
<text x={14} y={3} fill="#94a3b8" fontSize="8">Reinvested tranche</text>
|
|
</g>
|
|
<g transform="translate(10, 70)">
|
|
<line x1={0} y1={0} x2={10} y2={0} stroke="#8b5cf6" strokeWidth="1.5" opacity="0.6" />
|
|
<text x={14} y={3} fill="#94a3b8" fontSize="8">Reinvestment link</text>
|
|
</g>
|
|
<g transform="translate(10, 84)">
|
|
<line x1={0} y1={0} x2={10} y2={0} stroke="#0ea5e9" strokeWidth="1.5" opacity="0.6" />
|
|
<text x={14} y={3} fill="#94a3b8" fontSize="8">Payment flow</text>
|
|
</g>
|
|
<g transform="translate(10, 98)">
|
|
<circle cx={5} cy={0} r={3} fill="#f59e0b" />
|
|
<text x={14} y={3} fill="#94a3b8" fontSize="8">For sale / transferred</text>
|
|
</g>
|
|
</g>
|
|
|
|
{/* ─── Stats overlay ─── */}
|
|
<g transform={`translate(${size - 146}, ${size - 60})`}>
|
|
<rect width={130} height={52} rx={6} fill="#0f172a" fillOpacity="0.9" stroke="#1e293b" strokeWidth="0.5" />
|
|
<text x={10} y={16} fill="#94a3b8" fontSize="8">
|
|
Month {state.currentMonth} — {state.tranches.length} lenders
|
|
</text>
|
|
<text x={10} y={30} fill="#10b981" fontSize="9" fontWeight="600">
|
|
{(repaidPct * 100).toFixed(1)}% repaid
|
|
</text>
|
|
<text x={10} y={43} fill="#f59e0b" fontSize="8">
|
|
{fmt(state.totalInterestPaid)} interest earned
|
|
</text>
|
|
</g>
|
|
|
|
{/* ─── Community Fund (if active) ─── */}
|
|
{state.communityFundBalance > 0 && (
|
|
<g transform={`translate(${size - 100}, 16)`}>
|
|
<rect width={84} height={36} rx={6} fill="#8b5cf6" fillOpacity="0.15" stroke="#8b5cf6" strokeWidth="1" />
|
|
<text x={42} y={15} textAnchor="middle" fill="#8b5cf6" fontSize="8" fontWeight="600">Community Fund</text>
|
|
<text x={42} y={28} textAnchor="middle" fill="#c4b5fd" fontSize="10" fontWeight="500">{fmt(state.communityFundBalance)}</text>
|
|
</g>
|
|
)}
|
|
</svg>
|
|
)
|
|
}
|