195 lines
7.6 KiB
TypeScript
195 lines
7.6 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
|
|
import type { MortgageSimulatorConfig, MortgageTranche } from '@/lib/mortgage-types'
|
|
import { DEFAULT_CONFIG } from '@/lib/mortgage-types'
|
|
import { runFullSimulation, computeSummary } from '@/lib/mortgage-engine'
|
|
import MortgageControls from './MortgageControls'
|
|
import MortgageFlowViz from './MortgageFlowViz'
|
|
import LenderGrid from './LenderGrid'
|
|
import MycelialNetworkViz from './MycelialNetworkViz'
|
|
import LenderDetail from './LenderDetail'
|
|
import LenderReturnCalculator from './LenderReturnCalculator'
|
|
import ComparisonPanel from './ComparisonPanel'
|
|
|
|
type ViewMode = 'mycelial' | 'flow' | 'grid' | 'lender'
|
|
|
|
export default function MortgageSimulator() {
|
|
const [config, setConfig] = useState<MortgageSimulatorConfig>(DEFAULT_CONFIG)
|
|
const [currentMonth, setCurrentMonth] = useState(DEFAULT_CONFIG.startMonth)
|
|
const [playing, setPlaying] = useState(false)
|
|
const [speed, setSpeed] = useState(1)
|
|
const [selectedTranche, setSelectedTranche] = useState<MortgageTranche | null>(null)
|
|
const [viewMode, setViewMode] = useState<ViewMode>('mycelial')
|
|
const [controlsOpen, setControlsOpen] = useState(true)
|
|
const playRef = useRef(playing)
|
|
const speedRef = useRef(speed)
|
|
|
|
// Run full simulation when config changes
|
|
const states = useMemo(() => runFullSimulation(config), [config])
|
|
const maxMonth = states.length - 1
|
|
const currentState = states[Math.min(currentMonth, maxMonth)]
|
|
const summary = useMemo(() => computeSummary(states), [states])
|
|
|
|
// Keep refs in sync
|
|
useEffect(() => { playRef.current = playing }, [playing])
|
|
useEffect(() => { speedRef.current = speed }, [speed])
|
|
|
|
// Playback
|
|
useEffect(() => {
|
|
if (!playing) return
|
|
const interval = setInterval(() => {
|
|
setCurrentMonth(prev => {
|
|
if (prev >= maxMonth) {
|
|
setPlaying(false)
|
|
return prev
|
|
}
|
|
return prev + speedRef.current
|
|
})
|
|
}, 100)
|
|
return () => clearInterval(interval)
|
|
}, [playing, maxMonth])
|
|
|
|
// Reset month when config changes
|
|
useEffect(() => {
|
|
setCurrentMonth(Math.min(config.startMonth, maxMonth))
|
|
setPlaying(false)
|
|
setSelectedTranche(null)
|
|
}, [config, maxMonth])
|
|
|
|
const handleSelectTranche = useCallback((t: MortgageTranche | null) => {
|
|
setSelectedTranche(t)
|
|
}, [])
|
|
|
|
const handleToggleForSale = useCallback((trancheId: string) => {
|
|
// In a real app this would update the backend; for the simulator we just toggle locally
|
|
setSelectedTranche(prev => {
|
|
if (!prev || prev.id !== trancheId) return prev
|
|
return {
|
|
...prev,
|
|
listedForSale: !prev.listedForSale,
|
|
askingPrice: !prev.listedForSale ? prev.principalRemaining : undefined,
|
|
}
|
|
})
|
|
}, [])
|
|
|
|
// Keep selected tranche in sync with current state
|
|
const selectedTrancheState = useMemo(() => {
|
|
if (!selectedTranche) return null
|
|
const fromState = currentState.tranches.find(t => t.id === selectedTranche.id)
|
|
if (!fromState) return null
|
|
// Merge any local toggles (listedForSale) with simulation state
|
|
return {
|
|
...fromState,
|
|
listedForSale: selectedTranche.listedForSale,
|
|
askingPrice: selectedTranche.askingPrice,
|
|
}
|
|
}, [currentState, selectedTranche])
|
|
|
|
return (
|
|
<div className="min-h-screen bg-[#0f172a] text-slate-200">
|
|
{/* Header */}
|
|
<header className="border-b border-slate-800 px-6 py-4">
|
|
<div className="max-w-[1600px] mx-auto flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-xl font-bold text-white">(you)rMortgage</h1>
|
|
<p className="text-sm text-slate-400">
|
|
{currentState.tranches.length} lenders × {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(config.trancheSize)} tranches
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{(['mycelial', 'flow', 'grid', 'lender'] as const).map(mode => (
|
|
<button
|
|
key={mode}
|
|
onClick={() => setViewMode(mode)}
|
|
className={`px-3 py-1.5 rounded text-sm transition-colors ${
|
|
viewMode === mode ? 'bg-sky-600 text-white' : 'bg-slate-800 text-slate-400 hover:bg-slate-700'
|
|
}`}
|
|
>
|
|
{mode === 'mycelial' ? 'Network' : mode === 'flow' ? 'Flow' : mode === 'grid' ? 'Grid' : 'Lender'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Main Layout */}
|
|
<div className="max-w-[1600px] mx-auto flex gap-0 min-h-[calc(100vh-73px)]">
|
|
{/* Left: Controls (collapsible) */}
|
|
<aside className={`flex-shrink-0 border-r border-slate-800 transition-all duration-300 ${controlsOpen ? 'w-64' : 'w-10'} relative overflow-hidden`}>
|
|
<button
|
|
onClick={() => setControlsOpen(o => !o)}
|
|
className="absolute top-2 right-2 z-10 w-6 h-6 rounded bg-slate-700 hover:bg-slate-600 flex items-center justify-center text-xs text-slate-300 transition-colors"
|
|
title={controlsOpen ? 'Minimize settings' : 'Expand settings'}
|
|
>
|
|
{controlsOpen ? '\u2039' : '\u203A'}
|
|
</button>
|
|
{!controlsOpen && (
|
|
<button
|
|
onClick={() => setControlsOpen(true)}
|
|
className="absolute inset-0 w-full h-full flex items-start justify-center pt-12 text-slate-500 hover:text-slate-300 transition-colors"
|
|
title="Expand settings"
|
|
>
|
|
<span className="[writing-mode:vertical-lr] text-xs tracking-widest uppercase font-semibold">Loan Settings</span>
|
|
</button>
|
|
)}
|
|
<div className={`p-4 overflow-y-auto h-full transition-opacity duration-200 ${controlsOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}>
|
|
<MortgageControls
|
|
config={config}
|
|
onChange={setConfig}
|
|
currentMonth={Math.min(currentMonth, maxMonth)}
|
|
maxMonth={maxMonth}
|
|
onMonthChange={setCurrentMonth}
|
|
playing={playing}
|
|
onPlayToggle={() => setPlaying(p => !p)}
|
|
speed={speed}
|
|
onSpeedChange={setSpeed}
|
|
/>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Center: Visualization */}
|
|
<main className="flex-1 p-4 overflow-y-auto">
|
|
{viewMode === 'mycelial' ? (
|
|
<MycelialNetworkViz
|
|
state={currentState}
|
|
selectedTrancheId={selectedTranche?.id ?? null}
|
|
onSelectTranche={handleSelectTranche}
|
|
/>
|
|
) : viewMode === 'flow' ? (
|
|
<MortgageFlowViz
|
|
state={currentState}
|
|
selectedTrancheId={selectedTranche?.id ?? null}
|
|
onSelectTranche={handleSelectTranche}
|
|
/>
|
|
) : viewMode === 'grid' ? (
|
|
<LenderGrid
|
|
tranches={currentState.tranches}
|
|
onSelectTranche={handleSelectTranche}
|
|
selectedTrancheId={selectedTranche?.id ?? null}
|
|
/>
|
|
) : (
|
|
<LenderReturnCalculator config={config} />
|
|
)}
|
|
|
|
{/* Lender Detail (below viz when selected) */}
|
|
{selectedTrancheState && (
|
|
<div className="mt-4 max-w-xl">
|
|
<LenderDetail
|
|
tranche={selectedTrancheState}
|
|
onClose={() => setSelectedTranche(null)}
|
|
onToggleForSale={handleToggleForSale}
|
|
/>
|
|
</div>
|
|
)}
|
|
</main>
|
|
|
|
{/* Right: Comparison */}
|
|
<aside className="w-72 flex-shrink-0 border-l border-slate-800 p-4 overflow-y-auto">
|
|
<ComparisonPanel state={currentState} summary={summary} />
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|