rfunds-online/components/mortgage/MycelialNetworkViz.tsx

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>
)
}