/** * — Distributed mortgage simulator web component. * * Ports the rfunds-online mortgage simulator (React) to a single rSpace web component. * Models community-funded mortgages split into 80+ tranches with variable terms, * reinvestment loops, secondary markets, and 5 visualization modes. * * Attributes: * space — space slug (used for URL sharing) */ import type { MortgageSimulatorConfig, MortgageTranche, MortgageState, LendingTier, } from '../lib/mortgage-types'; import { DEFAULT_CONFIG } from '../lib/mortgage-types'; import { runFullSimulation, computeSummary, amortizationSchedule, calculateBuyerYield, calculateLenderReturns, calculateAffordability, } from '../lib/mortgage-engine'; import type { MortgageSummary } from '../lib/mortgage-engine'; type ViewMode = 'mycelial' | 'flow' | 'grid' | 'lender' | 'borrower'; // ─── Helper functions ──────────────────────────────────── 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)}`; } function fmtCurrency(n: number): string { return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n); } function fmtDetail(n: number): string { return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 2 }).format(n); } function fmtPct(n: number): string { return `${n.toFixed(1)}%`; } function seededRandom(seed: number) { let s = seed; return () => { s = (s * 16807 + 0) % 2147483647; return (s - 1) / 2147483646; }; } // ─── Constants ─────────────────────────────────────────── const TIER_COLORS: Record = { '2yr': '#06b6d4', '5yr': '#10b981', '10yr': '#3b82f6', '15yr': '#f59e0b', '30yr': '#ef4444', }; const COLORS = { borrower: '#0ea5e9', principal: '#10b981', interest: '#f59e0b', community: '#8b5cf6', reinvest: '#a855f7', text: '#e2e8f0', textMuted: '#94a3b8', bg: '#0f172a', pipe: '#334155', }; // ─── Component ─────────────────────────────────────────── class FolkMortgageSimulator extends HTMLElement { private shadow: ShadowRoot; private space = ''; // State private config: MortgageSimulatorConfig = { ...DEFAULT_CONFIG }; private states: MortgageState[] = []; private summary!: MortgageSummary; private currentMonth = DEFAULT_CONFIG.startMonth; private viewMode: ViewMode = 'mycelial'; private selectedTrancheId: string | null = null; private controlsOpen = true; private playing = false; private speed = 1; private playInterval: ReturnType | null = null; // Calculator state private lenderInvestment = DEFAULT_CONFIG.trancheSize; private lenderStrategy: 'compare' | 'liquid' | 'reinvest' = 'compare'; private borrowerBudget = 2000; private borrowerDownPayment = DEFAULT_CONFIG.downPaymentPercent; private borrowerExpandedIdx: number | null = 0; // Grid state private gridSortBy: 'index' | 'repaid' | 'rate' | 'remaining' = 'index'; private gridFilter: 'all' | 'active' | 'repaid' | 'open' | 'for-sale' = 'all'; constructor() { super(); this.shadow = this.attachShadow({ mode: 'open' }); } connectedCallback() { this.space = this.getAttribute('space') || 'demo'; this._loadFromHash(); this._recompute(); this.render(); } disconnectedCallback() { this._stopPlayback(); } // ─── Core simulation ────────────────────────────────── private _recompute() { this.states = runFullSimulation(this.config); this.summary = computeSummary(this.states); if (this.currentMonth > this.maxMonth) { this.currentMonth = this.maxMonth; } } private get maxMonth(): number { return this.states.length - 1; } private get currentState(): MortgageState { return this.states[Math.min(this.currentMonth, this.maxMonth)]; } // ─── URL hash sharing ───────────────────────────────── private _loadFromHash() { try { const hash = location.hash.slice(1); if (!hash) return; const LZString = (window as any).LZString; if (!LZString) return; const json = LZString.decompressFromEncodedURIComponent(hash); if (!json) return; const parsed = JSON.parse(json); this.config = { ...DEFAULT_CONFIG, ...parsed }; if (parsed.startMonth != null) this.currentMonth = parsed.startMonth; } catch { /* ignore invalid hash */ } } private _saveToHash() { try { const LZString = (window as any).LZString; if (!LZString) return; const json = JSON.stringify(this.config); location.hash = LZString.compressToEncodedURIComponent(json); } catch { /* ignore */ } } // ─── Playback ───────────────────────────────────────── private _startPlayback() { this.playing = true; this._stopPlayback(); this.playInterval = setInterval(() => { if (this.currentMonth >= this.maxMonth) { this._stopPlayback(); this.playing = false; this.render(); return; } this.currentMonth = Math.min(this.currentMonth + this.speed, this.maxMonth); this.render(); }, 100); } private _stopPlayback() { if (this.playInterval) { clearInterval(this.playInterval); this.playInterval = null; } } // ─── Config update ──────────────────────────────────── private _updateConfig(partial: Partial) { this.config = { ...this.config, ...partial }; this._recompute(); this.currentMonth = Math.min(this.config.startMonth, this.maxMonth); this.playing = false; this._stopPlayback(); this.selectedTrancheId = null; this._saveToHash(); this.render(); } // ─── Render ─────────────────────────────────────────── render() { const state = this.currentState; const numTranches = Math.ceil( this.config.propertyValue * (1 - this.config.downPaymentPercent / 100) / this.config.trancheSize ); this.shadow.innerHTML = `
${this._renderHeader(state, numTranches)}
${this._renderControls(numTranches)}
${this._renderActiveView(state)} ${this._renderLenderDetail(state)}
`; this._attachListeners(); } // ─── Styles ─────────────────────────────────────────── private _styles(): string { return ` :host { display: block; font-family: system-ui, -apple-system, sans-serif; } * { box-sizing: border-box; margin: 0; padding: 0; } .root { min-height: 100vh; background: #0f172a; color: #cbd5e1; } /* Header */ .header { border-bottom: 1px solid #1e293b; padding: 12px 24px; display: flex; align-items: center; justify-content: space-between; max-width: 1600px; margin: 0 auto; } .header h1 { font-size: 18px; font-weight: 700; color: #fff; margin: 0; } .header .subtitle { font-size: 12px; color: #64748b; margin-top: 2px; } .view-tabs { display: flex; gap: 6px; } .view-tab { padding: 6px 12px; border-radius: 6px; font-size: 13px; cursor: pointer; border: none; transition: all 0.15s; } .view-tab.active { background: #0284c7; color: #fff; } .view-tab:not(.active) { background: #1e293b; color: #64748b; } .view-tab:not(.active):hover { background: #334155; color: #94a3b8; } /* Layout */ .layout { max-width: 1600px; margin: 0 auto; display: flex; gap: 0; min-height: calc(100vh - 57px); } /* Controls sidebar */ .sidebar { flex-shrink: 0; border-right: 1px solid #1e293b; transition: width 0.3s; overflow: hidden; position: relative; } .sidebar.open { width: 256px; } .sidebar.closed { width: 40px; } .sidebar-toggle { position: absolute; top: 8px; right: 8px; z-index: 10; width: 24px; height: 24px; border-radius: 4px; background: #334155; border: none; color: #94a3b8; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 14px; } .sidebar-toggle:hover { background: #475569; } .sidebar-content { padding: 16px; overflow-y: auto; height: 100%; } .sidebar.closed .sidebar-content { opacity: 0; pointer-events: none; } .sidebar-label { writing-mode: vertical-lr; font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; font-weight: 600; color: #64748b; position: absolute; inset: 0; display: flex; align-items: flex-start; justify-content: center; padding-top: 48px; cursor: pointer; } .sidebar-label:hover { color: #94a3b8; } /* Section */ .section { margin-bottom: 20px; } .section-title { font-size: 11px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 8px; } /* Slider */ .slider-row { margin-bottom: 8px; } .slider-header { display: flex; justify-content: space-between; margin-bottom: 4px; } .slider-label { font-size: 12px; color: #94a3b8; } .slider-value { font-size: 12px; color: #fff; font-family: monospace; } input[type="range"] { width: 100%; accent-color: #0ea5e9; height: 4px; } .info-text { font-size: 11px; color: #64748b; margin-top: 4px; } /* Buttons */ .btn { padding: 4px 8px; border-radius: 4px; font-size: 11px; cursor: pointer; border: none; transition: all 0.15s; } .btn-active { background: #059669; color: #fff; } .btn-inactive { background: #334155; color: #94a3b8; } .btn-inactive:hover { background: #475569; } .btn-sky { background: #0284c7; color: #fff; } .btn-purple { background: #7c3aed; color: #fff; } .btn-group { display: flex; gap: 4px; margin-top: 8px; } /* Playback */ .playback { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; } .play-btn { width: 32px; height: 32px; border-radius: 6px; background: #334155; border: none; color: #e2e8f0; cursor: pointer; font-size: 16px; display: flex; align-items: center; justify-content: center; } .play-btn:hover { background: #475569; } .speed-group { display: flex; gap: 4px; margin-left: auto; } .timeline-label { font-size: 11px; color: #64748b; text-align: center; margin-top: 4px; } /* Main */ .main { flex: 1; padding: 16px; overflow-y: auto; } /* Comparison sidebar */ .comparison { width: 288px; flex-shrink: 0; border-left: 1px solid #1e293b; padding: 16px; overflow-y: auto; } /* SVG */ svg { width: 100%; } /* Tier sliders */ .tier-row { display: flex; align-items: center; gap: 8px; font-size: 11px; margin-bottom: 6px; } .tier-label { width: 40px; font-family: monospace; color: #94a3b8; } .tier-rate { color: #64748b; width: 36px; } .tier-slider { flex: 1; } .tier-alloc { width: 32px; text-align: right; color: #64748b; } /* Grid */ .lender-grid { display: grid; gap: 6px; } .grid-cell { position: relative; border-radius: 6px; padding: 6px; text-align: center; cursor: pointer; transition: all 0.15s; border: none; color: inherit; font-family: inherit; } .grid-cell:hover { transform: scale(1.02); } .grid-cell.selected { outline: 2px solid #38bdf8; transform: scale(1.05); z-index: 10; } .grid-cell.active { background: #1e293b; } .grid-cell.repaid { background: rgba(6, 78, 59, 0.4); } .grid-cell.open { background: rgba(30, 41, 59, 0.4); border: 1px dashed #475569; } .grid-cell .fill-bar { position: absolute; inset: 0; border-radius: 6px; opacity: 0.2; transition: all 0.3s; } .grid-cell .name { font-size: 11px; font-weight: 500; } .grid-cell .amount { font-size: 10px; font-family: monospace; color: #64748b; } .grid-cell .pct { font-size: 10px; font-family: monospace; } /* Detail panel */ .detail-panel { background: #1e293b; border-radius: 8px; padding: 16px; border: 1px solid #334155; max-width: 560px; margin-top: 16px; } .detail-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } .detail-close { background: none; border: none; color: #64748b; cursor: pointer; font-size: 18px; } .detail-close:hover { color: #fff; } .stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 16px; } .stat-item .stat-label { font-size: 10px; color: #64748b; text-transform: uppercase; } .stat-item .stat-value { font-size: 13px; font-family: monospace; color: #fff; } .detail-section { background: #0f172a; border-radius: 6px; padding: 12px; margin-bottom: 12px; } .detail-section-title { font-size: 11px; font-weight: 600; color: #64748b; text-transform: uppercase; margin-bottom: 8px; } .progress-bar { height: 12px; background: #334155; border-radius: 999px; overflow: hidden; } .progress-fill { height: 100%; border-radius: 999px; transition: width 0.3s; } /* Comparison panel */ .compare-row { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; align-items: center; margin-bottom: 8px; } .compare-label { font-size: 11px; color: #64748b; } .compare-val { font-size: 11px; font-family: monospace; text-align: center; padding: 4px 8px; border-radius: 4px; } .compare-myco { background: #1e293b; color: #fff; } .compare-myco.highlight { background: rgba(6, 78, 59, 0.4); color: #34d399; } .compare-trad { background: #1e293b; color: #64748b; } .stat-card { background: #1e293b; border-radius: 6px; padding: 8px; } .stat-card .sc-label { font-size: 10px; color: #64748b; text-transform: uppercase; } .stat-card .sc-value { font-size: 13px; font-family: monospace; color: #fff; } .community-insight { background: linear-gradient(135deg, rgba(14, 116, 144, 0.2), rgba(16, 185, 129, 0.2)); border-radius: 8px; padding: 16px; border: 1px solid rgba(14, 116, 144, 0.3); } /* Calculator views */ .calc-header { text-align: center; margin-bottom: 24px; } .calc-header h2 { font-size: 18px; font-weight: 700; color: #fff; } .calc-header p { font-size: 13px; color: #64748b; margin-top: 4px; } .calc-input { background: rgba(30, 41, 59, 0.6); border-radius: 8px; padding: 16px; border: 1px solid #334155; } .calc-input-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } .calc-input-label { font-size: 13px; color: #94a3b8; } .calc-input-value { font-size: 20px; font-family: monospace; font-weight: 700; color: #fff; } .calc-range-labels { display: flex; justify-content: space-between; font-size: 11px; color: #64748b; margin-top: 4px; } .inputs-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 24px; } .strategy-toggle { display: flex; gap: 8px; justify-content: center; margin-bottom: 24px; } .strategy-btn { padding: 8px 16px; border-radius: 8px; font-size: 13px; cursor: pointer; border: none; transition: all 0.15s; } .strategy-btn.active { background: #0284c7; color: #fff; } .strategy-btn:not(.active) { background: #1e293b; color: #64748b; } .strategy-btn:not(.active):hover { background: #334155; } /* Scenario cards */ .scenario-card { background: rgba(30, 41, 59, 0.6); border-radius: 8px; border: 1px solid #334155; overflow: hidden; margin-bottom: 12px; } .scenario-card.best { border-color: rgba(16, 185, 129, 0.5); } .scenario-card.cheapest { border-color: rgba(245, 158, 11, 0.4); } .scenario-summary { display: flex; align-items: center; gap: 16px; padding: 12px 16px; cursor: pointer; width: 100%; border: none; background: none; color: inherit; font-family: inherit; text-align: left; } .scenario-summary:hover { background: rgba(51, 65, 85, 0.3); } .scenario-detail { border-top: 1px solid rgba(51, 65, 85, 0.5); padding: 12px 16px; } .scenario-badges { width: 96px; flex-shrink: 0; } .scenario-badges .name { font-size: 13px; font-weight: 700; color: #fff; } .badge { font-size: 9px; padding: 2px 4px; border-radius: 2px; text-transform: uppercase; letter-spacing: 0.05em; color: #fff; display: inline-block; margin-top: 2px; } .badge-green { background: #059669; } .badge-amber { background: #d97706; } .badge-best { background: #059669; } .scenario-stats { flex: 1; display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; } .expand-icon { color: #64748b; transition: transform 0.2s; } .expand-icon.open { transform: rotate(180deg); } /* Tier comparison cards */ .tier-card { background: rgba(30, 41, 59, 0.6); border-radius: 8px; border: 1px solid #334155; overflow: hidden; margin-bottom: 16px; } .tier-header { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-bottom: 1px solid rgba(51, 65, 85, 0.5); } .tier-columns { display: grid; grid-template-columns: 1fr 1fr; } .tier-col { padding: 16px; } .tier-col + .tier-col { border-left: 1px solid rgba(51, 65, 85, 0.5); } .tier-col-title { font-size: 11px; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 12px; display: flex; align-items: center; gap: 4px; } .tier-col-title .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } .yield-row { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 8px; } .yield-label { font-size: 11px; color: #64748b; } .yield-value { font-family: monospace; font-size: 13px; color: #fff; } .yield-value.large { font-size: 16px; font-weight: 700; } .yield-value.highlight { color: #34d399; } /* Mini stat */ .mini-stat { background: #1e293b; border-radius: 6px; padding: 8px; text-align: center; } .mini-stat .ms-label { font-size: 10px; color: #64748b; text-transform: uppercase; } .mini-stat .ms-value { font-size: 13px; font-family: monospace; color: #fff; } /* Stacked bar */ .stacked-bar { height: 8px; background: #334155; border-radius: 999px; overflow: hidden; display: flex; } .stacked-bar > div { height: 100%; } /* Table */ .data-table { width: 100%; font-size: 11px; border-collapse: collapse; } .data-table th { text-align: left; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; padding: 4px 12px 4px 0; } .data-table th:not(:first-child) { text-align: right; } .data-table td { padding: 6px 12px 6px 0; border-top: 1px solid rgba(51, 65, 85, 0.3); } .data-table td:not(:first-child) { text-align: right; font-family: monospace; } .data-table tr.total td { border-top: 1px solid #475569; font-weight: 700; } /* Insight box */ .insight-box { background: linear-gradient(135deg, rgba(14, 116, 144, 0.15), rgba(16, 185, 129, 0.15)); border-radius: 8px; padding: 16px; border: 1px solid rgba(14, 116, 144, 0.3); text-align: center; font-size: 13px; color: #94a3b8; margin-top: 24px; } .insight-box.purple { background: linear-gradient(135deg, rgba(124, 58, 237, 0.15), rgba(14, 116, 144, 0.15)); border-color: rgba(124, 58, 237, 0.3); } /* Filter/sort bars */ .filter-bar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } .filter-bar h3 { font-size: 11px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; } .sort-bar { display: flex; gap: 4px; margin-bottom: 12px; } .sort-bar span { font-size: 11px; color: #475569; margin-right: 4px; } /* Responsive */ @media (max-width: 900px) { .layout { flex-direction: column; } .sidebar { width: 100% !important; border-right: none; border-bottom: 1px solid #1e293b; } .comparison { width: 100%; border-left: none; border-top: 1px solid #1e293b; } .inputs-grid { grid-template-columns: 1fr; } } `; } // ─── Header ─────────────────────────────────────────── private _renderHeader(state: MortgageState, numTranches: number): string { const labels: Record = { mycelial: 'Network', flow: 'Flow', grid: 'Grid', lender: 'Lender', borrower: 'Borrower' }; return `

(you)rMortgage

${state.tranches.length} lenders × ${fmtCurrency(this.config.trancheSize)} tranches
${(Object.keys(labels) as ViewMode[]).map(mode => ` `).join('')}
`; } // ─── Controls sidebar ───────────────────────────────── private _renderControls(numTranches: number): string { const c = this.config; const totalPrincipal = c.propertyValue * (1 - c.downPaymentPercent / 100); const years = Math.floor(this.currentMonth / 12); const months = this.currentMonth % 12; return ` `; } private _slider(key: string, label: string, value: number, min: number, max: number, step: number, format: (v: number) => string, isPercent = false): string { return `
${label} ${format(value)}
`; } // ─── Active view dispatcher ─────────────────────────── private _renderActiveView(state: MortgageState): string { switch (this.viewMode) { case 'mycelial': return this._renderNetworkViz(state); case 'flow': return this._renderFlowViz(state); case 'grid': return this._renderGrid(state); case 'lender': return this._renderLenderCalc(); case 'borrower': return this._renderBorrowerCalc(); } } // ─── Network Viz (Mycelial) ─────────────────────────── private _renderNetworkViz(state: MortgageState): string { const size = 700; const layout = this._computeNetworkLayout(state, size); const repaidPct = state.totalPrincipal > 0 ? state.totalPrincipalPaid / state.totalPrincipal : 0; let svg = ``; // Defs svg += ` `; // Background ring guides [0.2, 0.35, 0.5].forEach(r => { svg += ``; }); // Relend/staking connections layout.relendLinks.forEach((link, i) => { const from = layout.nodes[link.from]; const to = layout.nodes[link.to]; if (!from || !to) return; const isHighlighted = this.selectedTrancheId === from.tranche.id || this.selectedTrancheId === to.tranche.id; const path = this._mycelialPath(from.x, from.y, to.x, to.y, i * 7 + 31); svg += ``; if (isHighlighted) { svg += ``; } }); // Hyphae: Center → Lenders layout.nodes.forEach((node, i) => { const t = node.tranche; const isSelected = t.id === this.selectedTrancheId; const isActive = t.status === 'active'; const isOpen = !t.funded; const flowStrength = isActive && !isOpen ? Math.max(0.15, t.monthlyPayment / (state.monthlyPayment || 1)) : 0; const curvature = 0.1 + (i % 5) * 0.06 * (i % 2 === 0 ? 1 : -1); const path = this._hyphaPath(layout.cx, layout.cy, node.x, node.y, curvature); const stroke = isSelected ? '#38bdf8' : isOpen ? '#334155' : isActive ? 'url(#hyphaActive)' : 'url(#hyphaRepaid)'; const sw = isSelected ? 2.5 : isOpen ? 0.5 : Math.max(0.5, flowStrength * 3); const op = isSelected ? 0.9 : isOpen ? 0.15 : isActive ? 0.4 : 0.15; svg += ``; if (isActive && isSelected) { svg += ``; } }); // Lender Nodes layout.nodes.forEach(node => { const t = node.tranche; const isSelected = t.id === this.selectedTrancheId; const isActive = t.status === 'active'; const isOpen = !t.funded; const repaidFrac = t.principal > 0 ? t.totalPrincipalPaid / t.principal : 0; const baseR = Math.max(6, Math.min(16, 4 + (t.principal / state.trancheSize) * 8)); const r = isSelected ? baseR + 3 : isOpen ? baseR - 1 : baseR; svg += ``; // Outer ring svg += ``; // Repaid fill if (repaidFrac > 0 && repaidFrac < 1) { svg += ``; } if (repaidFrac > 0) { svg += ``; } // Reinvestment glow if (t.reinvestmentRate != null && isActive) { svg += ``; } if (t.isReinvested) { svg += ``; } // Interest glow if (t.totalInterestPaid > t.principal * 0.1 && isActive && !t.reinvestmentRate) { svg += ``; } // For-sale marker if (t.listedForSale) { svg += ``; } // Transfer history marker if (t.transferHistory.length > 0) { svg += ``; } // Label on select if (isSelected) { const hasReinvest = t.reinvestmentRate != null; const boxH = hasReinvest ? 44 : 32; svg += ``; svg += `${isOpen ? 'Open Slot' : t.lender.name}${t.isReinvested ? ' (R)' : ''}`; svg += `${fmt(t.principal)} @ ${(t.interestRate * 100).toFixed(1)}%`; if (hasReinvest) { svg += `reinvests @ ${(t.reinvestmentRate! * 100).toFixed(0)}%${t.reinvestmentPool > 0 ? ` (${fmt(t.reinvestmentPool)} pooled)` : ''}`; } } svg += ``; }); // Center Node svg += ``; svg += ``; svg += ``; svg += ``; svg += ``; svg += ``; svg += `${fmt(state.totalPrincipal)}`; svg += ``; // Legend svg += ` Active lender Repaid lender Open slot Reinvested tranche Reinvestment link Payment flow For sale / transferred `; // Stats overlay svg += ` Month ${state.currentMonth} — ${state.tranches.length} lenders ${(repaidPct * 100).toFixed(1)}% repaid ${fmt(state.totalInterestPaid)} interest earned `; // Community Fund if (state.communityFundBalance > 0) { svg += ` Community Fund ${fmt(state.communityFundBalance)} `; } svg += ``; return svg; } private _computeNetworkLayout(state: MortgageState, size: number) { const cx = size / 2, cy = size / 2; const n = state.tranches.length; 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 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); const nodes: { x: number; y: number; tranche: MortgageTranche }[] = []; 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; 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, tranche, }); }); // Relend links 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; const parentIdx = nodes.findIndex(n => n.tranche.id === t.parentTrancheId); if (parentIdx < 0) continue; relendLinks.push({ from: parentIdx, to: i, strength: Math.min(1, t.principal / state.trancheSize) }); } // Same-lender links const lenderNodes = new Map(); 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++) { if (!relendLinks.some(l => (l.from === indices[0] && l.to === indices[j]) || (l.from === indices[j] && l.to === indices[0]))) { relendLinks.push({ from: indices[0], to: indices[j], strength: 0.5 }); } } } return { cx, cy, nodes, relendLinks }; } private _hyphaPath(x1: number, y1: number, x2: number, y2: number, curvature: number): string { const dx = x2 - x1, dy = y2 - y1; const mx = (x1 + x2) / 2, my = (y1 + y2) / 2; return `M${x1},${y1} Q${mx - dy * curvature},${my + dx * curvature} ${x2},${y2}`; } private _mycelialPath(x1: number, y1: number, x2: number, y2: number, seed: number): string { const rng = seededRandom(seed); const dx = x2 - x1, dy = y2 - y1; const dist = Math.sqrt(dx * dx + dy * dy); const nx = -dy / dist, ny = dx / dist; const wobble = dist * 0.15; return `M${x1},${y1} C${x1 + dx * 0.33 + nx * wobble * (rng() - 0.5)},${y1 + dy * 0.33 + ny * wobble * (rng() - 0.5)} ${x1 + dx * 0.66 + nx * wobble * (rng() - 0.5)},${y1 + dy * 0.66 + ny * wobble * (rng() - 0.5)} ${x2},${y2}`; } // ─── Flow (Sankey) Viz ──────────────────────────────── private _renderFlowViz(state: MortgageState): string { // Group tranches by tier const groups = new Map(); for (const t of state.tranches) { const label = t.tierLabel || 'default'; if (!groups.has(label)) groups.set(label, []); groups.get(label)!.push(t); } interface TierGroup { label: string; color: string; tranches: MortgageTranche[]; totalPrincipal: number; totalPrincipalRemaining: number; totalMonthlyPayment: number; totalMonthlyPrincipal: number; totalMonthlyInterest: number; activeCount: number; repaidCount: number; reinvestCount: number; reinvestPool: number; } const tiers: TierGroup[] = []; for (const [label, tranches] of groups) { const active = tranches.filter(t => t.status === 'active'); const repaid = tranches.filter(t => t.status === 'repaid'); const reinvesting = tranches.filter(t => t.reinvestmentRate != null); tiers.push({ label, color: TIER_COLORS[label] || `hsl(${(tiers.length * 67) % 360}, 60%, 55%)`, tranches, totalPrincipal: tranches.reduce((s, t) => s + t.principal, 0), totalPrincipalRemaining: tranches.reduce((s, t) => s + t.principalRemaining, 0), totalMonthlyPayment: active.reduce((s, t) => s + t.monthlyPayment, 0), totalMonthlyPrincipal: active.reduce((s, t) => s + t.monthlyPrincipal, 0), totalMonthlyInterest: active.reduce((s, t) => s + t.monthlyInterest, 0), activeCount: active.length, repaidCount: repaid.length, reinvestCount: reinvesting.length, reinvestPool: reinvesting.reduce((s, t) => s + t.reinvestmentPool, 0), }); } tiers.sort((a, b) => { const aR = a.label.startsWith('reinvest'), bR = b.label.startsWith('reinvest'); if (aR !== bR) return aR ? 1 : -1; return a.label.localeCompare(b.label, undefined, { numeric: true }); }); const totalMonthly = tiers.reduce((s, t) => s + t.totalMonthlyPayment, 0); const totalInterest = tiers.reduce((s, t) => s + t.totalMonthlyInterest, 0); const totalPrincipalFlow = tiers.reduce((s, t) => s + t.totalMonthlyPrincipal, 0); const reinvestTotal = tiers.reduce((s, t) => s + t.reinvestPool, 0); const W = 800, H = 520; const colBorrower = 60, colSplit = 280, colTiers = 520, colEnd = 740, nodeW = 20, tierGap = 8; const borrowerH = Math.max(60, totalMonthly > 0 ? 120 : 60); const borrowerY = (H - borrowerH) / 2; const maxFlow = Math.max(1, totalMonthly); const availH = H - 60; const tierNodes = tiers.map((tier, i) => ({ ...tier, h: Math.max(20, (tier.totalMonthlyPayment / maxFlow) * availH * 0.7), y: 0, idx: i })); const totalTierH = tierNodes.reduce((s, n) => s + n.h, 0) + (tierNodes.length - 1) * tierGap; let tierStartY = (H - totalTierH) / 2; for (const node of tierNodes) { node.y = tierStartY; tierStartY += node.h + tierGap; } const splitH = borrowerH, splitY = borrowerY; const sankeyLink = (x1: number, y1: number, h1: number, x2: number, y2: number, h2: number, color: string) => { const mx = (x1 + x2) / 2; return ``; }; let svg = ``; // Defs svg += ` ${tierNodes.map(t => ``).join('')} `; // Borrower node svg += ``; svg += `Borrower`; svg += `${fmt(totalMonthly + state.overpayment)}/mo`; // Split node const principalH = totalMonthly > 0 ? splitH * (totalPrincipalFlow / totalMonthly) : splitH / 2; svg += ``; svg += ``; svg += `Payment Split`; svg += `Principal ${fmt(totalPrincipalFlow)}`; svg += `Interest ${fmt(totalInterest)}`; // Borrower → Split svg += sankeyLink(colBorrower + nodeW / 2, borrowerY, borrowerH, colSplit - nodeW / 2, splitY, splitH, 'url(#sankeyBorrower)'); // Split → Tiers let principalOffset = 0, interestOffset = 0; const interestStart = splitY + principalH; tierNodes.forEach(tier => { if (tier.totalMonthlyPayment === 0) return; const pFrac = totalPrincipalFlow > 0 ? tier.totalMonthlyPrincipal / totalPrincipalFlow : 0; const iFrac = totalInterest > 0 ? tier.totalMonthlyInterest / totalInterest : 0; const pH = principalH * pFrac; const iH = (splitH - principalH) * iFrac; const pDstFrac = tier.totalMonthlyPayment > 0 ? tier.totalMonthlyPrincipal / tier.totalMonthlyPayment : 0.5; const pDstH = tier.h * pDstFrac; if (pH > 0.5) svg += sankeyLink(colSplit + nodeW / 2, splitY + principalOffset, pH, colTiers - nodeW / 2, tier.y, pDstH, 'url(#principalGrad)'); if (iH > 0.5) svg += sankeyLink(colSplit + nodeW / 2, interestStart + interestOffset, iH, colTiers - nodeW / 2, tier.y + pDstH, tier.h - pDstH, 'url(#interestGrad)'); principalOffset += pH; interestOffset += iH; }); // Tier nodes tierNodes.forEach(tier => { const pFrac = tier.totalMonthlyPayment > 0 ? tier.totalMonthlyPrincipal / tier.totalMonthlyPayment : 0.5; const isReinvest = tier.label.startsWith('reinvest'); const selected = tier.tranches.some(t => t.id === this.selectedTrancheId); svg += ``; svg += ``; svg += ``; svg += `${isReinvest ? `Reinvest (${tier.label.replace('reinvest@', '')})` : tier.label}`; svg += `${tier.activeCount} active / ${tier.repaidCount} repaid · ${fmt(tier.totalMonthlyPayment)}/mo`; if (tier.reinvestPool > 100) { svg += `Pool: ${fmt(tier.reinvestPool)}`; } svg += ``; // Tier → Lender outcome if (tier.totalMonthlyPayment > 0) { svg += sankeyLink(colTiers + nodeW / 2, tier.y, tier.h, colEnd - 6, tier.y, tier.h, `url(#tierGrad-${tier.label})`); } }); // Right-side labels svg += `Lenders`; tierNodes.forEach(tier => { svg += ``; svg += `${tier.tranches.length} × ${fmt(tier.totalPrincipal / tier.tranches.length)}`; }); // Reinvestment loop if (reinvestTotal > 100) { svg += ` Reinvestment: ${fmt(reinvestTotal)} pooled `; } // Community fund if (state.communityFundBalance > 0) { svg += ``; svg += `Community Fund`; svg += `${fmt(state.communityFundBalance)}`; } // Summary stats svg += `Month ${state.currentMonth} · ${state.tranches.length} tranches · ${fmt(state.totalPrincipalRemaining)} remaining · ${((state.totalPrincipalPaid / state.totalPrincipal) * 100).toFixed(1)}% repaid`; svg += ``; return svg; } // ─── Grid View ──────────────────────────────────────── private _renderGrid(state: MortgageState): string { const tranches = state.tranches; let filtered = [...tranches]; switch (this.gridFilter) { case 'active': filtered = filtered.filter(t => t.status === 'active' && t.funded); break; case 'repaid': filtered = filtered.filter(t => t.status === 'repaid'); break; case 'open': filtered = filtered.filter(t => !t.funded); break; case 'for-sale': filtered = filtered.filter(t => t.listedForSale); break; } switch (this.gridSortBy) { case 'repaid': filtered.sort((a, b) => (b.totalPrincipalPaid / b.principal) - (a.totalPrincipalPaid / a.principal)); break; case 'rate': filtered.sort((a, b) => b.interestRate - a.interestRate); break; case 'remaining': filtered.sort((a, b) => a.principalRemaining - b.principalRemaining); break; } const repaidCount = tranches.filter(t => t.status === 'repaid').length; const openCount = tranches.filter(t => !t.funded).length; const forSaleCount = tranches.filter(t => t.listedForSale).length; const colSize = tranches.length > 50 ? '56px' : '72px'; let html = `
`; // Header html += `

Lenders (${tranches.length})${repaidCount > 0 ? ` / ${repaidCount} repaid` : ''}

${(['all', 'active', 'repaid', 'open', 'for-sale'] as const).map(f => ` `).join('')}
`; // Sort html += `
Sort: ${(['index', 'repaid', 'rate', 'remaining'] as const).map(s => ` `).join('')}
`; // Grid html += `
`; filtered.forEach(t => { const repaidPct = t.principal > 0 ? (t.totalPrincipalPaid / t.principal) * 100 : 0; const isSelected = t.id === this.selectedTrancheId; const isRepaid = t.status === 'repaid'; const isOpen = !t.funded; const cls = isSelected ? 'selected' : ''; const bgCls = isOpen ? 'open' : isRepaid ? 'repaid' : 'active'; html += ``; }); html += `
`; return html; } // ─── Lender Detail ──────────────────────────────────── private _renderLenderDetail(state: MortgageState): string { if (!this.selectedTrancheId) return ''; const tranche = state.tranches.find(t => t.id === this.selectedTrancheId); if (!tranche) return ''; const repaidPct = tranche.principal > 0 ? (tranche.totalPrincipalPaid / tranche.principal) * 100 : 0; const schedule = amortizationSchedule(tranche.principal, tranche.interestRate, tranche.termMonths); const buyerYield = calculateBuyerYield(tranche, tranche.askingPrice ?? tranche.principalRemaining); let html = `
`; // Header html += `
${tranche.lender.name}
${tranche.lender.walletAddress.slice(0, 10)}...
`; // Stats html += `
Tranche
${fmtCurrency(tranche.principal)}
Rate
${(tranche.interestRate * 100).toFixed(2)}%
Status
${tranche.status}
Remaining
${fmtCurrency(tranche.principalRemaining)}
Interest Earned
${fmtCurrency(tranche.totalInterestPaid)}
Repaid
${repaidPct.toFixed(2)}%
`; // Progress bar html += `
Principal Repayment${repaidPct.toFixed(2)}%
`; // This month if (tranche.status === 'active') { const piRatio = tranche.monthlyPayment > 0 ? (tranche.monthlyPrincipal / tranche.monthlyPayment) * 100 : 50; html += `
This Month
Payment
${fmtCurrency(tranche.monthlyPayment)}
Principal
${fmtCurrency(tranche.monthlyPrincipal)}
Interest
${fmtCurrency(tranche.monthlyInterest)}
PrincipalInterest
`; } // Secondary market html += `
Secondary Market
${tranche.status === 'repaid' ? `
Tranche fully repaid — not tradeable
` : ` ${tranche.listedForSale ? `
Asking: ${fmtCurrency(tranche.askingPrice ?? tranche.principalRemaining)}
Buyer yield: ${buyerYield.annualYield.toFixed(2)}%/yr
Months remaining: ${buyerYield.monthsRemaining}
` : ''}` }
`; // Transfer history if (tranche.transferHistory.length > 0) { html += `
Transfer History (${tranche.transferHistory.length})
${tranche.transferHistory.map(t => `
${t.fromLenderId.replace('lender-', 'L')} → ${t.toLenderId.replace('lender-', 'L')} ${fmtCurrency(t.price)} (${t.premiumPercent >= 0 ? '+' : ''}${t.premiumPercent.toFixed(1)}%)
`).join('')}
`; } // Mini amortization chart if (schedule.length > 0) { const maxBalance = schedule[0].balance; const maxInterest = schedule[schedule.length - 1].cumulativeInterest; const maxVal = Math.max(maxBalance, maxInterest); const h = 80, w = 300; const step = Math.max(1, Math.floor(schedule.length / 60)); const points = schedule.filter((_, i) => i % step === 0 || i === schedule.length - 1); const balancePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${(p.month / schedule.length) * w},${h - (p.balance / maxVal) * h}`).join(' '); const interestPath = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${(p.month / schedule.length) * w},${h - (p.cumulativeInterest / maxVal) * h}`).join(' '); const currentX = (tranche.monthsElapsed / schedule.length) * w; html += `
Amortization
${tranche.monthsElapsed > 0 ? `` : ''} Balance Cum. Interest
`; } html += `
`; return html; } // ─── Lender Calculator ──────────────────────────────── private _renderLenderCalc(): string { const c = this.config; const tiers = c.useVariableTerms ? c.lendingTiers.map(t => ({ label: t.label, termYears: t.termYears, rate: t.rate })) : [{ label: `${c.termYears}yr`, termYears: c.termYears, rate: c.interestRate }]; const scenarios = calculateLenderReturns(this.lenderInvestment, tiers); const bestLiquid = Math.max(...scenarios.map(s => s.liquid.effectiveYield)); const bestReinvest = Math.max(...scenarios.map(s => s.reinvested.effectiveYield)); let html = `
`; html += `

Lender Return Calculator

See what you could earn lending into the rMortgage

`; // Investment amount html += `
Your Investment${fmtCurrency(this.lenderInvestment)}
$1,000$25,000
`; // Strategy toggle html += `
${(['compare', 'liquid', 'reinvest'] as const).map(k => ` `).join('')}
`; if (this.lenderStrategy === 'compare') { scenarios.forEach(s => { const color = TIER_COLORS[s.tierLabel] || '#64748b'; const reinvestGain = s.reinvested.totalInterest - s.liquid.totalInterest; html += `
${s.tierLabel} @ ${(s.rate * 100).toFixed(1)}% APR ${s.termYears} year commitment
Monthly Liquidity
Monthly income${fmtDetail(s.liquid.monthlyPayment)}
Total interest${fmtCurrency(s.liquid.totalInterest)}
Total received${fmtCurrency(s.liquid.totalReturn)}
Effective yield${s.liquid.effectiveYield.toFixed(2)}%/yr
Reinvest to Term
Final value${fmtCurrency(s.reinvested.finalValue)}
Total interest${fmtCurrency(s.reinvested.totalInterest)}
Extra vs liquid${reinvestGain > 0 ? '+' : ''}${fmtCurrency(reinvestGain)}
Effective yield${s.reinvested.effectiveYield.toFixed(2)}%/yr
`; }); } else { // Single strategy card view scenarios.forEach(s => { const color = TIER_COLORS[s.tierLabel] || '#64748b'; const data = this.lenderStrategy === 'liquid' ? s.liquid : s.reinvested; const isBest = this.lenderStrategy === 'liquid' ? s.liquid.effectiveYield === bestLiquid : s.reinvested.effectiveYield === bestReinvest; html += `
${s.tierLabel} @ ${(s.rate * 100).toFixed(1)}% ${isBest ? 'Best' : ''}
${data.effectiveYield.toFixed(2)}%
effective yield/yr
${this.lenderStrategy === 'liquid' ? `
Monthly
${fmtDetail(s.liquid.monthlyPayment)}
Total Interest
${fmtCurrency(s.liquid.totalInterest)}
Total Received
${fmtCurrency(s.liquid.totalReturn)}
` : `
Final Value
${fmtCurrency(s.reinvested.finalValue)}
Total Interest
${fmtCurrency(s.reinvested.totalInterest)}
Growth
${((s.reinvested.totalReturn / this.lenderInvestment - 1) * 100).toFixed(1)}%
`}
PrincipalInterest
`; }); } // Insight const best = scenarios.reduce((a, b) => b.reinvested.effectiveYield > a.reinvested.effectiveYield ? b : a); const liquidBest = scenarios.reduce((a, b) => b.liquid.effectiveYield > a.liquid.effectiveYield ? b : a); html += `
Reinvesting returns in a ${best.tierLabel} tranche yields ${best.reinvested.effectiveYield.toFixed(2)}%/yr vs ${liquidBest.liquid.effectiveYield.toFixed(2)}%/yr with monthly liquidity. ${best.reinvested.totalInterest > liquidBest.liquid.totalInterest ? ` That's ${fmtCurrency(best.reinvested.totalInterest - liquidBest.liquid.totalInterest)} more over the term.` : ''}
`; html += `
`; return html; } // ─── Borrower Calculator ────────────────────────────── private _renderBorrowerCalc(): string { const c = this.config; const tiers = c.useVariableTerms ? c.lendingTiers : [{ label: `${c.termYears}yr`, termYears: c.termYears, rate: c.interestRate, allocation: 1 }]; const scenarios = calculateAffordability(this.borrowerBudget, this.borrowerDownPayment, tiers); const bestLoan = Math.max(...scenarios.map(s => s.maxLoan)); const leastInterest = Math.min(...scenarios.map(s => s.totalInterest)); let html = `
`; html += `

What Can I Afford?

Enter what you can pay monthly — see how tier mix affects your borrowing power

`; // Inputs html += `
Monthly Budget${fmtCurrency(this.borrowerBudget)}
$500$10,000
Down Payment${this.borrowerDownPayment}%
0%50%
`; // Scenarios scenarios.forEach((s, idx) => { const isBest = s.maxLoan === bestLoan; const isCheapest = s.totalInterest === leastInterest; const isExpanded = this.borrowerExpandedIdx === idx; html += `
`; if (isExpanded) { html += `
${s.description}
Borrowing power ${fmtCurrency(s.maxLoan)} / ${fmtCurrency(bestLoan)} max
Tier allocation of your ${fmtCurrency(this.borrowerBudget)}/mo
${s.breakdown.map((b, i) => { const frac = b.monthlyPayment / this.borrowerBudget; const colors = ['#06b6d4', '#10b981', '#3b82f6', '#f59e0b', '#ef4444']; return `
${frac > 0.08 ? b.tierLabel : ''}
`; }).join('')}
${s.breakdown.map(b => ``).join('')}
TierRateMonthlyPrincipalInterestTerm
${b.tierLabel} ${(b.rate * 100).toFixed(1)}% ${fmtCurrency(b.monthlyPayment)} ${fmtCurrency(b.principal)} ${fmtCurrency(b.totalInterest)} ${b.termYears}yr
Total ${fmtCurrency(this.borrowerBudget)} ${fmtCurrency(s.maxLoan)} ${fmtCurrency(s.totalInterest)} ${s.payoffYears}yr
Total cost breakdown
Principal ${fmtCurrency(s.maxLoan)} Interest ${fmtCurrency(s.totalInterest)} ${this.borrowerDownPayment > 0 ? `Down ${fmtCurrency(s.propertyValue - s.maxLoan)}` : ''}
`; } html += `
`; }); // Insight const best = scenarios.find(s => s.maxLoan === bestLoan); const cheapest = scenarios.find(s => s.totalInterest === leastInterest); if (best && cheapest) { const diff = best.maxLoan - cheapest.maxLoan; const savedInterest = best.totalInterest - cheapest.totalInterest; html += `
${best.label} lets you borrow the most (${fmtCurrency(best.maxLoan)})${ diff > 0 && cheapest.label !== best.label ? `, but ${cheapest.label} saves you ${fmtCurrency(savedInterest)} in interest${cheapest.payoffYears < best.payoffYears ? ` and pays off ${best.payoffYears - cheapest.payoffYears} years sooner` : ''}` : '' }. With rMortgage, your interest stays in the community either way.
`; } html += `
`; return html; } // ─── Comparison Panel ───────────────────────────────── private _renderComparison(state: MortgageState): string { const s = this.summary; const repaidPct = state.totalPrincipal > 0 ? (state.totalPrincipalPaid / state.totalPrincipal) * 100 : 0; let html = `
`; // Progress html += `
Progress
Principal Repaid
${fmtCurrency(state.totalPrincipalPaid)} ${fmtPct(repaidPct)}
Remaining
${fmtCurrency(state.totalPrincipalRemaining)}
Interest Paid
${fmtCurrency(state.totalInterestPaid)}
Tranches Done
${state.tranchesRepaid} / ${state.tranches.length}
`; // Community Fund if (state.communityFundBalance > 0) { html += `
Community Fund
${fmtCurrency(state.communityFundBalance)}
Overflow directed to community resilience
`; } // Comparison html += `
rMortgage vs Traditional
Myco
Trad
${this._compareRow('Monthly', fmtCurrency(s.mycoMonthlyPayment), fmtCurrency(s.tradMonthlyPayment), false)} ${this._compareRow('Total Interest', fmtCurrency(s.mycoTotalInterest), fmtCurrency(s.tradTotalInterest), s.interestSaved > 0)} ${this._compareRow('Payoff', `${Math.ceil(s.mycoPayoffMonths / 12)}y`, `${Math.ceil(s.tradPayoffMonths / 12)}y`, s.monthsSaved > 0)} ${this._compareRow('Avg Yield', fmtPct(s.avgLenderYield), 'N/A (bank)', false)}
`; // Key insight html += `
Community Wealth
${fmtCurrency(s.communityRetained)}
Interest that stays in the community instead of flowing to a distant institution.${s.interestSaved > 0 ? ` Plus ${fmtCurrency(s.interestSaved)} saved through distributed rates.` : ''}
`; html += `
`; return html; } private _compareRow(label: string, myco: string, trad: string, highlight: boolean): string { return `
${label}
${myco}
${trad}
`; } // ─── Event handling ─────────────────────────────────── private _attachListeners() { this.shadow.addEventListener('click', (e: Event) => { const target = (e.target as HTMLElement).closest('[data-action]') as HTMLElement | null; if (!target) return; const action = target.dataset.action; switch (action) { case 'view': this.viewMode = target.dataset.view as ViewMode; this.render(); break; case 'toggle-controls': this.controlsOpen = !this.controlsOpen; this.render(); break; case 'play-toggle': if (this.playing) { this._stopPlayback(); this.playing = false; } else this._startPlayback(); this.render(); break; case 'reset-month': this.currentMonth = 0; this._stopPlayback(); this.playing = false; this.render(); break; case 'speed': this.speed = Number(target.dataset.speed); this.render(); break; case 'toggle-variable': this._updateConfig({ useVariableTerms: !this.config.useVariableTerms }); break; case 'overpayment-target': this._updateConfig({ overpaymentTarget: target.dataset.target as any }); break; case 'reinvest-rates': this._updateConfig({ reinvestmentRates: JSON.parse(target.dataset.rates!) }); break; case 'select-tranche': const id = target.dataset.trancheId!; this.selectedTrancheId = this.selectedTrancheId === id ? null : id; this.render(); break; case 'select-tier': { const tierLabel = target.dataset.tierLabel!; const state = this.currentState; const tierTranches = state.tranches.filter(t => t.tierLabel === tierLabel); const first = tierTranches.find(t => t.status === 'active') ?? tierTranches[0]; if (first) { this.selectedTrancheId = this.selectedTrancheId === first.id ? null : first.id; this.render(); } break; } case 'close-detail': this.selectedTrancheId = null; this.render(); break; case 'toggle-sale': { // Toggle for-sale in simulation state (local only, visual feedback) this.render(); break; } case 'grid-filter': this.gridFilter = target.dataset.filter as any; this.render(); break; case 'grid-sort': this.gridSortBy = target.dataset.sort as any; this.render(); break; case 'lender-strategy': this.lenderStrategy = target.dataset.strategy as any; this.render(); break; case 'borrower-expand': const idx = Number(target.dataset.idx); this.borrowerExpandedIdx = this.borrowerExpandedIdx === idx ? null : idx; this.render(); break; } }); // Range inputs this.shadow.addEventListener('input', (e: Event) => { const target = e.target as HTMLInputElement; if (!target.dataset.action) return; const action = target.dataset.action; const val = Number(target.value); switch (action) { case 'config-slider': { const key = target.dataset.key as keyof MortgageSimulatorConfig; const isPercent = target.dataset.percent === 'true'; this._updateConfig({ [key]: isPercent ? val / 100 : val } as any); break; } case 'tier-alloc': { const tierIdx = Number(target.dataset.tier); const newTiers = [...this.config.lendingTiers]; newTiers[tierIdx] = { ...newTiers[tierIdx], allocation: val / 100 }; this._updateConfig({ lendingTiers: newTiers }); break; } case 'month-scrub': this.currentMonth = val; this.render(); break; case 'lender-investment': this.lenderInvestment = val; this.render(); break; case 'borrower-budget': this.borrowerBudget = val; this.render(); break; case 'borrower-down': this.borrowerDownPayment = val; this.render(); break; } }); } } customElements.define('folk-mortgage-simulator', FolkMortgageSimulator);