diff --git a/modules/rflows/components/folk-mortgage-simulator.ts b/modules/rflows/components/folk-mortgage-simulator.ts new file mode 100644 index 0000000..825ba93 --- /dev/null +++ b/modules/rflows/components/folk-mortgage-simulator.ts @@ -0,0 +1,1639 @@ +/** + * — 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); diff --git a/modules/rflows/lib/mortgage-engine.ts b/modules/rflows/lib/mortgage-engine.ts new file mode 100644 index 0000000..7e58815 --- /dev/null +++ b/modules/rflows/lib/mortgage-engine.ts @@ -0,0 +1,748 @@ +/** + * rMortgage Amortization Engine + * + * Pure math functions — no React dependencies. + * Handles amortization, tranche splitting, overpayment distribution, + * and comparison with traditional mortgages. + */ + +import type { + MortgageTranche, + MortgageState, + MortgageSimulatorConfig, + AmortizationEntry, + Lender, + LendingTier, +} from './mortgage-types' +import { DEFAULT_CONFIG } from './mortgage-types' + +// ─── Core Amortization Math ────────────────────────────── + +/** Monthly payment for a fixed-rate fully-amortizing loan */ +export function monthlyPayment(principal: number, annualRate: number, termMonths: number): number { + if (annualRate === 0) return principal / termMonths + const r = annualRate / 12 + return principal * (r * Math.pow(1 + r, termMonths)) / (Math.pow(1 + r, termMonths) - 1) +} + +/** Interest portion of a payment given current balance */ +export function interestPortion(balance: number, annualRate: number): number { + return balance * (annualRate / 12) +} + +/** Generate full amortization schedule for a single loan */ +export function amortizationSchedule( + principal: number, + annualRate: number, + termMonths: number, + monthlyOverpayment: number = 0, +): AmortizationEntry[] { + const schedule: AmortizationEntry[] = [] + let balance = principal + const basePayment = monthlyPayment(principal, annualRate, termMonths) + let cumulativeInterest = 0 + let cumulativePrincipal = 0 + + for (let month = 1; month <= termMonths && balance > 0.01; month++) { + const interest = interestPortion(balance, annualRate) + const totalPayment = Math.min(balance + interest, basePayment + monthlyOverpayment) + const principalPaid = totalPayment - interest + balance = Math.max(0, balance - principalPaid) + cumulativeInterest += interest + cumulativePrincipal += principalPaid + + schedule.push({ + month, + payment: totalPayment, + principal: principalPaid, + interest, + balance, + cumulativeInterest, + cumulativePrincipal, + }) + } + + return schedule +} + +// ─── Lender Name Generator ─────────────────────────────── + +const FIRST_NAMES = [ + 'Alice', 'Bob', 'Carmen', 'David', 'Elena', 'Frank', 'Grace', 'Hassan', + 'Iris', 'James', 'Kira', 'Leo', 'Maya', 'Noah', 'Olga', 'Pat', + 'Quinn', 'Rosa', 'Sam', 'Tara', 'Uma', 'Victor', 'Wren', 'Xavier', + 'Yara', 'Zane', 'Amara', 'Basil', 'Clara', 'Diego', 'Ella', 'Felix', + 'Gia', 'Hugo', 'Ivy', 'Jules', 'Kaia', 'Liam', 'Mira', 'Nico', + 'Opal', 'Priya', 'Ravi', 'Sage', 'Teo', 'Una', 'Val', 'Willow', + 'Xena', 'Yuri', 'Zara', 'Arlo', 'Bea', 'Cruz', 'Dahlia', 'Elio', + 'Flora', 'Gael', 'Hana', 'Idris', 'June', 'Kai', 'Luz', 'Marcel', + 'Noor', 'Owen', 'Pearl', 'Rio', 'Soren', 'Thea', 'Umar', 'Vera', +] + +function generateLender(index: number): Lender { + const name = FIRST_NAMES[index % FIRST_NAMES.length] + const suffix = index >= FIRST_NAMES.length ? ` ${Math.floor(index / FIRST_NAMES.length) + 1}` : '' + return { + id: `lender-${index}`, + name: `${name}${suffix}`, + walletAddress: `0x${index.toString(16).padStart(40, '0')}`, + } +} + +// ─── Initialize Mortgage State ─────────────────────────── + +export function initializeMortgage(config: MortgageSimulatorConfig = DEFAULT_CONFIG): MortgageState { + const totalPrincipal = config.propertyValue * (1 - config.downPaymentPercent / 100) + const numTranches = Math.ceil(totalPrincipal / config.trancheSize) + const defaultTermMonths = config.termYears * 12 + + // Create tranches with optional rate variation + const tranches: MortgageTranche[] = [] + let remainingPrincipal = totalPrincipal + + // Determine which tranches are funded vs open + const fundedCount = Math.round(numTranches * (config.fundingPercent / 100)) + + // Determine which lenders are reinvestors (among funded tranches only) + const reinvestorCount = Math.round(fundedCount * (config.reinvestorPercent / 100)) + const reinvestRates = config.reinvestmentRates.length > 0 ? config.reinvestmentRates : [0.05] + + // Build tier assignments: distribute tranches across tiers by allocation + const tiers = config.useVariableTerms ? config.lendingTiers : [] + const tierAssignments: (LendingTier | null)[] = [] + if (tiers.length > 0) { + // Normalize allocations + const totalAlloc = tiers.reduce((s, t) => s + t.allocation, 0) + let assigned = 0 + for (const tier of tiers) { + const count = Math.round((tier.allocation / totalAlloc) * numTranches) + for (let j = 0; j < count && assigned < numTranches; j++) { + tierAssignments.push(tier) + assigned++ + } + } + // Fill any remainder with the last tier + while (tierAssignments.length < numTranches) { + tierAssignments.push(tiers[tiers.length - 1]) + } + } + + for (let i = 0; i < numTranches; i++) { + const principal = Math.min(config.trancheSize, remainingPrincipal) + remainingPrincipal -= principal + + // Determine rate and term from tier or base config + const tier = tierAssignments[i] ?? null + const termMonths = tier ? tier.termYears * 12 : defaultTermMonths + const tierLabel = tier ? tier.label : `${config.termYears}yr` + + // Apply rate variation: spread linearly across tranches + const baseRate = tier ? tier.rate : config.interestRate + const variationOffset = config.rateVariation > 0 + ? config.rateVariation * (2 * (i / Math.max(numTranches - 1, 1)) - 1) + : 0 + const rate = Math.max(0.01, baseRate + variationOffset) + + const isFunded = i < fundedCount + const mp = monthlyPayment(principal, rate, termMonths) + + // Assign reinvestment strategy: spread reinvestors evenly among funded + const isReinvestor = isFunded && i < reinvestorCount + const reinvestmentRate = isReinvestor + ? reinvestRates[i % reinvestRates.length] + : undefined + + tranches.push({ + id: `tranche-${i}`, + lender: generateLender(i), + principal, + principalRemaining: principal, + interestRate: rate, + termMonths, + monthsElapsed: 0, + monthlyPayment: isFunded ? mp : 0, + monthlyPrincipal: isFunded ? mp - interestPortion(principal, rate) : 0, + monthlyInterest: isFunded ? interestPortion(principal, rate) : 0, + totalInterestPaid: 0, + totalPrincipalPaid: 0, + transferable: true, + listedForSale: false, + transferHistory: [], + status: 'active', + funded: isFunded, + tierLabel, + reinvestmentRate, + isReinvested: false, + reinvestmentPool: 0, + }) + } + + const totalMonthly = tranches.reduce((s, t) => s + t.monthlyPayment, 0) + const tradMonthly = monthlyPayment(totalPrincipal, config.interestRate, defaultTermMonths) + const tradSchedule = amortizationSchedule(totalPrincipal, config.interestRate, defaultTermMonths) + const tradTotalInterest = tradSchedule.length > 0 + ? tradSchedule[tradSchedule.length - 1].cumulativeInterest + : 0 + + return { + propertyValue: config.propertyValue, + downPayment: config.propertyValue * (config.downPaymentPercent / 100), + totalPrincipal, + trancheSize: config.trancheSize, + baseInterestRate: config.interestRate, + termMonths: defaultTermMonths, + borrower: { + id: 'borrower-0', + name: 'Borrower', + walletAddress: '0x' + 'b'.repeat(40), + }, + tranches, + currentMonth: 0, + monthlyPayment: totalMonthly, + overpayment: config.overpayment, + overpaymentTarget: config.overpaymentTarget, + totalInterestPaid: 0, + totalPrincipalPaid: 0, + totalPrincipalRemaining: totalPrincipal, + tranchesRepaid: 0, + communityFundBalance: 0, + traditionalTotalInterest: tradTotalInterest, + traditionalMonthlyPayment: tradMonthly, + } +} + +// ─── Advance Simulation by One Month ───────────────────── + +export function advanceMonth(state: MortgageState): MortgageState { + const next = { ...state, tranches: state.tranches.map(t => ({ ...t, transferHistory: [...t.transferHistory] })) } + next.currentMonth += 1 + + const activeTranches = next.tranches.filter(t => t.status === 'active' && t.funded) + if (activeTranches.length === 0) return next + + // Distribute overpayment + let overpaymentPool = next.overpayment + let communityContribution = 0 + + if (overpaymentPool > 0) { + switch (next.overpaymentTarget) { + case 'community_fund': + communityContribution = overpaymentPool + overpaymentPool = 0 + break + case 'split': + communityContribution = overpaymentPool / 2 + overpaymentPool = overpaymentPool / 2 + break + case 'extra_principal': + // all goes to extra principal + break + } + } + + // Extra principal distributed evenly across active tranches + const extraPrincipalPerTranche = activeTranches.length > 0 + ? overpaymentPool / activeTranches.length + : 0 + + let totalInterestThisMonth = 0 + let totalPrincipalThisMonth = 0 + + // Track newly repaid reinvestors for reinvestment + const newlyRepaidReinvestors: MortgageTranche[] = [] + + for (const tranche of next.tranches) { + if (tranche.status === 'repaid' || !tranche.funded) continue + + const interest = interestPortion(tranche.principalRemaining, tranche.interestRate) + const basePrincipal = tranche.monthlyPayment - interest + const extraPrincipal = extraPrincipalPerTranche + const principalPaid = Math.min(tranche.principalRemaining, basePrincipal + extraPrincipal) + + tranche.principalRemaining = Math.max(0, tranche.principalRemaining - principalPaid) + tranche.monthsElapsed += 1 + tranche.monthlyInterest = interest + tranche.monthlyPrincipal = principalPaid + tranche.totalInterestPaid += interest + tranche.totalPrincipalPaid += principalPaid + + totalInterestThisMonth += interest + totalPrincipalThisMonth += principalPaid + + // Reinvestors accumulate their received payments + if (tranche.reinvestmentRate != null) { + tranche.reinvestmentPool += principalPaid + interest + } + + if (tranche.principalRemaining < 0.01) { + tranche.principalRemaining = 0 + tranche.status = 'repaid' + if (tranche.reinvestmentRate != null) { + newlyRepaidReinvestors.push(tranche) + } + } + } + + // Create reinvestment tranches from repaid reinvestors + for (const repaid of newlyRepaidReinvestors) { + const reinvestAmount = repaid.reinvestmentPool + if (reinvestAmount < 100) continue // minimum reinvestment threshold + + const rate = repaid.reinvestmentRate! + const remainingTermMonths = Math.max(12, next.termMonths - next.currentMonth) + const mp = monthlyPayment(reinvestAmount, rate, remainingTermMonths) + + const newTranche: MortgageTranche = { + id: `reinvest-${repaid.id}-m${next.currentMonth}`, + lender: repaid.lender, // same lender reinvests + principal: reinvestAmount, + principalRemaining: reinvestAmount, + interestRate: rate, + termMonths: remainingTermMonths, + monthsElapsed: 0, + monthlyPayment: mp, + monthlyPrincipal: mp - interestPortion(reinvestAmount, rate), + monthlyInterest: interestPortion(reinvestAmount, rate), + totalInterestPaid: 0, + totalPrincipalPaid: 0, + transferable: true, + listedForSale: false, + transferHistory: [], + status: 'active', + funded: true, + tierLabel: `reinvest@${(rate * 100).toFixed(0)}%`, + reinvestmentRate: rate, // continues reinvesting + isReinvested: true, + parentTrancheId: repaid.id, + reinvestmentPool: 0, + } + + next.tranches.push(newTranche) + repaid.reinvestmentPool = 0 + } + + // Also create reinvestment tranches periodically for active reinvestors + // who've accumulated enough in their pool (annual reinvestment cycle) + if (next.currentMonth % 12 === 0) { + for (const tranche of next.tranches) { + if (tranche.status !== 'active' || tranche.reinvestmentRate == null) continue + if (tranche.reinvestmentPool < 500) continue // need at least $500 to reinvest + + const reinvestAmount = tranche.reinvestmentPool + const rate = tranche.reinvestmentRate + const remainingTermMonths = Math.max(12, next.termMonths - next.currentMonth) + const mp = monthlyPayment(reinvestAmount, rate, remainingTermMonths) + + const newTranche: MortgageTranche = { + id: `reinvest-${tranche.id}-m${next.currentMonth}`, + lender: tranche.lender, + principal: reinvestAmount, + principalRemaining: reinvestAmount, + interestRate: rate, + termMonths: remainingTermMonths, + monthsElapsed: 0, + monthlyPayment: mp, + monthlyPrincipal: mp - interestPortion(reinvestAmount, rate), + monthlyInterest: interestPortion(reinvestAmount, rate), + totalInterestPaid: 0, + totalPrincipalPaid: 0, + transferable: true, + listedForSale: false, + transferHistory: [], + status: 'active', + funded: true, + tierLabel: `reinvest@${(rate * 100).toFixed(0)}%`, + reinvestmentRate: rate, + isReinvested: true, + parentTrancheId: tranche.id, + reinvestmentPool: 0, + } + + next.tranches.push(newTranche) + tranche.reinvestmentPool = 0 + } + } + + // Simulate some secondary market activity (transfers every ~6 months for a few tranches) + if (next.currentMonth % 6 === 0 && next.currentMonth > 0) { + const activeForSale = next.tranches.filter(t => + t.status === 'active' && !t.isReinvested && t.monthsElapsed > 12 + ) + // ~5% of eligible tranches get transferred per cycle + const transferCount = Math.max(0, Math.floor(activeForSale.length * 0.05)) + for (let i = 0; i < transferCount; i++) { + const idx = (next.currentMonth * 7 + i * 13) % activeForSale.length + const tranche = activeForSale[idx] + if (!tranche || tranche.transferHistory.length >= 3) continue + + const buyerIdx = next.tranches.length + 100 + next.currentMonth + i + const buyer = generateLender(buyerIdx) + const premium = 1 + (0.02 + (idx % 5) * 0.01) // 2-6% premium + const price = tranche.principalRemaining * premium + + tranche.transferHistory.push({ + id: `transfer-${tranche.id}-m${next.currentMonth}`, + fromLenderId: tranche.lender.id, + toLenderId: buyer.id, + price, + principalRemaining: tranche.principalRemaining, + premiumPercent: (premium - 1) * 100, + date: next.currentMonth, + }) + tranche.lender = buyer + } + } + + next.totalInterestPaid += totalInterestThisMonth + next.totalPrincipalPaid += totalPrincipalThisMonth + next.totalPrincipalRemaining = next.tranches.reduce((s, t) => s + t.principalRemaining, 0) + next.tranchesRepaid = next.tranches.filter(t => t.status === 'repaid').length + next.communityFundBalance += communityContribution + + // Recalculate monthly payment (decreases as tranches are repaid) + next.monthlyPayment = next.tranches + .filter(t => t.status === 'active') + .reduce((s, t) => s + t.monthlyPayment, 0) + + return next +} + +// ─── Run Full Simulation ───────────────────────────────── + +export function runFullSimulation(config: MortgageSimulatorConfig): MortgageState[] { + const states: MortgageState[] = [] + let state = initializeMortgage(config) + states.push(state) + + const maxMonths = config.termYears * 12 + for (let m = 0; m < maxMonths; m++) { + if (state.totalPrincipalRemaining < 0.01) break + state = advanceMonth(state) + states.push(state) + } + + return states +} + +// ─── Tranche Transfer (Secondary Market) ───────────────── + +export function transferTranche( + state: MortgageState, + trancheId: string, + buyerLender: Lender, + price: number, +): MortgageState { + const next = { ...state, tranches: state.tranches.map(t => ({ ...t, transferHistory: [...t.transferHistory] })) } + + const tranche = next.tranches.find(t => t.id === trancheId) + if (!tranche || tranche.status === 'repaid' || !tranche.transferable) return state + + const premiumPercent = ((price - tranche.principalRemaining) / tranche.principalRemaining) * 100 + + tranche.transferHistory.push({ + id: `transfer-${tranche.transferHistory.length}`, + fromLenderId: tranche.lender.id, + toLenderId: buyerLender.id, + price, + principalRemaining: tranche.principalRemaining, + premiumPercent, + date: Date.now(), + }) + + tranche.lender = buyerLender + tranche.listedForSale = false + tranche.askingPrice = undefined + + return next +} + +// ─── Yield Calculator for Secondary Market ─────────────── + +export function calculateBuyerYield( + tranche: MortgageTranche, + purchasePrice: number, +): { annualYield: number; totalReturn: number; monthsRemaining: number } { + const remainingPrincipal = tranche.principalRemaining + const monthsRemaining = tranche.termMonths - tranche.monthsElapsed + + // Estimate total remaining payments + let totalPayments = 0 + let balance = remainingPrincipal + for (let m = 0; m < monthsRemaining && balance > 0.01; m++) { + const interest = balance * (tranche.interestRate / 12) + const payment = tranche.monthlyPayment + const principal = Math.min(balance, payment - interest) + totalPayments += payment + balance -= principal + } + + const totalReturn = totalPayments - purchasePrice + const annualYield = monthsRemaining > 0 + ? (totalReturn / purchasePrice) / (monthsRemaining / 12) * 100 + : 0 + + return { annualYield, totalReturn, monthsRemaining } +} + +// ─── Summary Stats ─────────────────────────────────────── + +export interface MortgageSummary { + // Myco + mycoTotalInterest: number + mycoMonthlyPayment: number + mycoPayoffMonths: number + avgLenderYield: number + communityRetained: number // interest that stays in community + // Traditional + tradTotalInterest: number + tradMonthlyPayment: number + tradPayoffMonths: number + // Delta + interestSaved: number + monthsSaved: number +} + +export function computeSummary(states: MortgageState[]): MortgageSummary { + const final = states[states.length - 1] + const initial = states[0] + + const mycoPayoffMonths = final.currentMonth + const tradSchedule = amortizationSchedule( + initial.totalPrincipal, + initial.baseInterestRate, + initial.termMonths, + ) + const tradPayoffMonths = tradSchedule.length + + // Average lender yield: weighted by tranche size + const totalWeight = final.tranches.reduce((s, t) => s + t.principal, 0) + const avgYield = totalWeight > 0 + ? final.tranches.reduce((s, t) => { + const yieldPct = t.principal > 0 ? (t.totalInterestPaid / t.principal) / (mycoPayoffMonths / 12) * 100 : 0 + return s + yieldPct * (t.principal / totalWeight) + }, 0) + : 0 + + return { + mycoTotalInterest: final.totalInterestPaid, + mycoMonthlyPayment: initial.monthlyPayment, + mycoPayoffMonths, + avgLenderYield: avgYield, + communityRetained: final.totalInterestPaid, // all interest stays local + tradTotalInterest: final.traditionalTotalInterest, + tradMonthlyPayment: initial.traditionalMonthlyPayment, + tradPayoffMonths, + interestSaved: final.traditionalTotalInterest - final.totalInterestPaid, + monthsSaved: tradPayoffMonths - mycoPayoffMonths, + } +} + +// ─── Lender Return Calculator ──────────────────────────── + +export interface LenderReturnScenario { + tierLabel: string + termYears: number + rate: number + investment: number + // Monthly liquidity: take all returns each month + liquid: { + monthlyPayment: number + totalInterest: number + totalReturn: number + effectiveYield: number // annualized + } + // Reinvest to term: compound returns at the same rate + reinvested: { + finalValue: number + totalInterest: number + totalReturn: number + effectiveYield: number // annualized + } +} + +export function calculateLenderReturns( + investment: number, + tiers: { label: string; termYears: number; rate: number }[], +): LenderReturnScenario[] { + return tiers.map(tier => { + const termMonths = tier.termYears * 12 + const r = tier.rate / 12 + const mp = r === 0 + ? investment / termMonths + : investment * (r * Math.pow(1 + r, termMonths)) / (Math.pow(1 + r, termMonths) - 1) + + // Liquid: lender receives mp each month, keeps interest, gets principal back over time + const liquidTotalPayments = mp * termMonths + const liquidTotalInterest = liquidTotalPayments - investment + const liquidEffectiveYield = investment > 0 + ? (liquidTotalInterest / investment) / tier.termYears * 100 + : 0 + + // Reinvested: compound monthly payments at the tier rate + // Each monthly payment is reinvested and earns interest for remaining months + let reinvestAccum = 0 + for (let m = 0; m < termMonths; m++) { + const monthsRemaining = termMonths - m - 1 + // Future value of this month's payment compounded for remaining months + reinvestAccum += mp * Math.pow(1 + r, monthsRemaining) + } + const reinvestTotalInterest = reinvestAccum - investment + const reinvestEffectiveYield = investment > 0 && tier.termYears > 0 + ? (Math.pow(reinvestAccum / investment, 1 / tier.termYears) - 1) * 100 + : 0 + + return { + tierLabel: tier.label, + termYears: tier.termYears, + rate: tier.rate, + investment, + liquid: { + monthlyPayment: mp, + totalInterest: liquidTotalInterest, + totalReturn: liquidTotalPayments, + effectiveYield: liquidEffectiveYield, + }, + reinvested: { + finalValue: reinvestAccum, + totalInterest: reinvestTotalInterest, + totalReturn: reinvestAccum, + effectiveYield: reinvestEffectiveYield, + }, + } + }) +} + +// ─── Borrower Affordability Calculator ─────────────────── + +/** Given a monthly payment, how much can you borrow at a given rate and term? */ +export function maxPrincipal(monthlyBudget: number, annualRate: number, termMonths: number): number { + if (annualRate === 0) return monthlyBudget * termMonths + const r = annualRate / 12 + return monthlyBudget * (Math.pow(1 + r, termMonths) - 1) / (r * Math.pow(1 + r, termMonths)) +} + +export interface AffordabilityScenario { + label: string + description: string + // Tier mix used + tiers: { label: string; termYears: number; rate: number; allocation: number }[] + // Results + maxLoan: number + propertyValue: number // with down payment + monthlyPayment: number + totalInterest: number + totalPaid: number + payoffYears: number + // Per-tier breakdown + breakdown: { + tierLabel: string + principal: number + monthlyPayment: number + totalInterest: number + termYears: number + rate: number + }[] +} + +export function calculateAffordability( + monthlyBudget: number, + downPaymentPercent: number, + tiers: { label: string; termYears: number; rate: number; allocation: number }[], +): AffordabilityScenario[] { + const scenarios: AffordabilityScenario[] = [] + + // Helper: compute a scenario for a given tier mix + function compute( + label: string, + description: string, + mix: { label: string; termYears: number; rate: number; allocation: number }[], + ): AffordabilityScenario { + // Each tier gets a fraction of the monthly budget proportional to its allocation + const totalAlloc = mix.reduce((s, t) => s + t.allocation, 0) + const breakdown = mix.map(tier => { + const allocFrac = tier.allocation / totalAlloc + const tierBudget = monthlyBudget * allocFrac + const termMonths = tier.termYears * 12 + const principal = maxPrincipal(tierBudget, tier.rate, termMonths) + const mp = tierBudget + const totalPaid = mp * termMonths + const totalInterest = totalPaid - principal + return { + tierLabel: tier.label, + principal, + monthlyPayment: mp, + totalInterest, + termYears: tier.termYears, + rate: tier.rate, + } + }) + + const totalLoan = breakdown.reduce((s, b) => s + b.principal, 0) + const totalInterest = breakdown.reduce((s, b) => s + b.totalInterest, 0) + const totalPaid = breakdown.reduce((s, b) => s + b.monthlyPayment * b.termYears * 12, 0) + const longestTerm = Math.max(...mix.map(t => t.termYears)) + const propertyValue = downPaymentPercent > 0 + ? totalLoan / (1 - downPaymentPercent / 100) + : totalLoan + + return { + label, + description, + tiers: mix, + maxLoan: totalLoan, + propertyValue, + monthlyPayment: monthlyBudget, + totalInterest, + totalPaid, + payoffYears: longestTerm, + breakdown, + } + } + + // Scenario 1: Default tier mix + if (tiers.length > 1) { + scenarios.push(compute('Blended', 'Default tier allocation across all terms', tiers)) + } + + // Scenario 2: Each single tier + for (const tier of tiers) { + scenarios.push(compute( + `All ${tier.label}`, + `Entire loan at ${(tier.rate * 100).toFixed(1)}% for ${tier.termYears} years`, + [{ ...tier, allocation: 1 }], + )) + } + + // Scenario 3: Short-heavy mix (if multiple tiers) + if (tiers.length >= 3) { + const sorted = [...tiers].sort((a, b) => a.termYears - b.termYears) + const shortHeavy = sorted.map((t, i) => ({ + ...t, + allocation: i === 0 ? 0.5 : (0.5 / (sorted.length - 1)), + })) + scenarios.push(compute( + 'Short-Heavy', + `50% in shortest term (${sorted[0].label}), rest spread`, + shortHeavy, + )) + } + + // Scenario 4: Long-heavy mix (if multiple tiers) + if (tiers.length >= 3) { + const sorted = [...tiers].sort((a, b) => b.termYears - a.termYears) + const longHeavy = sorted.map((t, i) => ({ + ...t, + allocation: i === 0 ? 0.5 : (0.5 / (sorted.length - 1)), + })) + scenarios.push(compute( + 'Long-Heavy', + `50% in longest term (${sorted[0].label}), rest spread`, + longHeavy, + )) + } + + // Sort by max loan descending + scenarios.sort((a, b) => b.maxLoan - a.maxLoan) + + return scenarios +} diff --git a/modules/rflows/lib/mortgage-types.ts b/modules/rflows/lib/mortgage-types.ts new file mode 100644 index 0000000..fb426e0 --- /dev/null +++ b/modules/rflows/lib/mortgage-types.ts @@ -0,0 +1,173 @@ +/** + * rMortgage Types + * + * Models a distributed mortgage where N lenders each hold $1k-$5k tranches + * of a single property, using TBFF primitives (Flow/Funnel/Outcome). + */ + +// ─── Lender ────────────────────────────────────────────── + +export interface Lender { + id: string + name: string + walletAddress: string +} + +// ─── Tranche Transfer (secondary market) ───────────────── + +export interface TrancheTransfer { + id: string + fromLenderId: string + toLenderId: string + price: number // what buyer paid + principalRemaining: number // at time of transfer + premiumPercent: number // (price - principalRemaining) / principalRemaining * 100 + date: number // timestamp +} + +// ─── Mortgage Tranche ──────────────────────────────────── + +export interface MortgageTranche { + id: string + lender: Lender + principal: number // original tranche size (e.g., 5000) + principalRemaining: number // how much principal is still owed + interestRate: number // annual rate (e.g., 0.06 for 6%) + termMonths: number + monthsElapsed: number + // Per-payment breakdown (current month) + monthlyPayment: number + monthlyPrincipal: number + monthlyInterest: number + // Cumulative + totalInterestPaid: number + totalPrincipalPaid: number + // Secondary market + transferable: boolean + listedForSale: boolean + askingPrice?: number + transferHistory: TrancheTransfer[] + // Status + status: 'active' | 'repaid' + funded: boolean // true if a lender has claimed this tranche + // Lending tier + tierLabel: string // e.g., "5yr", "10yr", etc. + // Reinvestment + reinvestmentRate?: number // if set, lender reinvests repayments at this rate + isReinvested: boolean // true if this tranche was created from reinvestment + parentTrancheId?: string // original tranche this was reinvested from + reinvestmentPool: number // accumulated returns waiting to be reinvested +} + +// ─── Mortgage Simulation State ─────────────────────────── + +export interface MortgageState { + // Property + propertyValue: number + downPayment: number + totalPrincipal: number // propertyValue - downPayment + + // Structure + trancheSize: number // default tranche (e.g., 5000) + baseInterestRate: number // e.g., 0.06 + termMonths: number // e.g., 360 (30 years) + + // Borrower + borrower: { + id: string + name: string + walletAddress: string + } + + // All tranches + tranches: MortgageTranche[] + + // Simulation + currentMonth: number + monthlyPayment: number // total monthly payment (sum of all tranches) + overpayment: number // extra per month above minimum + overpaymentTarget: 'extra_principal' | 'community_fund' | 'split' + + // Aggregates + totalInterestPaid: number + totalPrincipalPaid: number + totalPrincipalRemaining: number + tranchesRepaid: number + + // Overflow / Community + communityFundBalance: number + + // Comparison + traditionalTotalInterest: number + traditionalMonthlyPayment: number +} + +// ─── Lending Term Tiers ───────────────────────────────── + +export interface LendingTier { + label: string // e.g., "5yr Short" + termYears: number // e.g., 5 + rate: number // e.g., 0.035 + allocation: number // fraction of total (0-1), all tiers sum to 1 +} + +export const DEFAULT_TIERS: LendingTier[] = [ + { label: '2yr', termYears: 2, rate: 0.025, allocation: 0.10 }, + { label: '5yr', termYears: 5, rate: 0.035, allocation: 0.20 }, + { label: '10yr', termYears: 10, rate: 0.045, allocation: 0.25 }, + { label: '15yr', termYears: 15, rate: 0.055, allocation: 0.25 }, + { label: '30yr', termYears: 30, rate: 0.065, allocation: 0.20 }, +] + +// ─── Simulator Controls ────────────────────────────────── + +export interface MortgageSimulatorConfig { + propertyValue: number + downPaymentPercent: number + trancheSize: number + interestRate: number // as decimal (0.06) — base rate, overridden by tiers + termYears: number // max term (for the full mortgage) + overpayment: number + overpaymentTarget: 'extra_principal' | 'community_fund' | 'split' + // Rate variation: spread across tranches + rateVariation: number // e.g., 0.01 means rates vary +/- 1% + // Reinvestment: % of lenders who reinvest repayments + reinvestorPercent: number // 0-100 (default 40) + reinvestmentRates: number[] // rates at which reinvestors lend (e.g., [0.03, 0.05]) + // Funding level: % of tranches that have been claimed by lenders + fundingPercent: number // 0-100 (default 85) + // Starting point + startMonth: number // skip to this month on load (e.g., 60 = 5 years) + // Lending tiers: different terms & rates + lendingTiers: LendingTier[] + useVariableTerms: boolean // false = all same term, true = use tiers +} + +export const DEFAULT_CONFIG: MortgageSimulatorConfig = { + propertyValue: 500_000, + downPaymentPercent: 20, + trancheSize: 5_000, + interestRate: 0.06, + termYears: 30, + overpayment: 0, + overpaymentTarget: 'extra_principal', + rateVariation: 0, + fundingPercent: 85, + reinvestorPercent: 40, + reinvestmentRates: [0.03, 0.05], + startMonth: 60, + lendingTiers: DEFAULT_TIERS, + useVariableTerms: true, +} + +// ─── Amortization Schedule Entry ───────────────────────── + +export interface AmortizationEntry { + month: number + payment: number + principal: number + interest: number + balance: number + cumulativeInterest: number + cumulativePrincipal: number +} diff --git a/modules/rflows/mod.ts b/modules/rflows/mod.ts index 67d2d36..52434f3 100644 --- a/modules/rflows/mod.ts +++ b/modules/rflows/mod.ts @@ -788,6 +788,10 @@ const flowsScripts = ` `; +const mortgageScripts = ` + + `; + const flowsStyles = ``; // Landing page (also serves demo via centralized /demo → space="demo" rewrite) @@ -805,17 +809,17 @@ routes.get("/", (c) => { })); }); -// Mortgage sub-tab +// Mortgage sub-tab — full distributed mortgage simulator routes.get("/mortgage", (c) => { const spaceSlug = c.req.param("space") || "demo"; return c.html(renderShell({ - title: `${spaceSlug} — Mortgage | rFlows | rSpace`, + title: `${spaceSlug} — rMortgage | rFlows | rSpace`, moduleId: "rflows", spaceSlug, modules: getModuleInfoList(), theme: "dark", - body: ``, - scripts: flowsScripts, + body: ``, + scripts: mortgageScripts, styles: flowsStyles, })); }); diff --git a/modules/rmeets/mod.ts b/modules/rmeets/mod.ts index d730b1d..611590e 100644 --- a/modules/rmeets/mod.ts +++ b/modules/rmeets/mod.ts @@ -454,6 +454,19 @@ routes.get("/:room", (c) => { const director = c.req.query("director") === "1"; const sessionId = c.req.query("session") || ""; + // Full rSpace shell mode — only when explicitly requested via ?shell=1 or director mode + if (c.req.query("shell") === "1" || director) { + return c.html(renderShell({ + title: `${room} — rMeets | rSpace`, + moduleId: "rmeets", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body: ``, + scripts: ``, + })); + } + if (c.req.query("iframe") === "1") { return c.html(renderExternalAppShell({ title: `${room} — rMeets | rSpace`, @@ -466,21 +479,27 @@ routes.get("/:room", (c) => { })); } - // Minimal mode — full-screen Jitsi without rSpace shell (for scheduled meeting links) - if (c.req.query("minimal") === "1" || c.req.query("join") === "1") { - const jitsiRoom = encodeURIComponent(room); - return c.html(` + // Default: clean full-screen Jitsi — no rSpace shell, mobile-friendly + const jitsiRoom = encodeURIComponent(room); + return c.html(` - + + + + ${escapeHtml(room)} — Meeting @@ -500,34 +519,35 @@ routes.get("/:room", (c) => { startWithVideoMuted: false, prejoinPageEnabled: true, disableDeepLinking: true, + disableThirdPartyRequests: true, + enableClosePage: false, + disableInviteFunctions: false, + toolbarButtons: [ + "microphone","camera","desktop","hangup", + "raisehand","tileview","toggle-camera", + "fullscreen","chat","settings", + "participants-pane","select-background", + ], }, interfaceConfigOverrides: { SHOW_JITSI_WATERMARK: false, SHOW_BRAND_WATERMARK: false, SHOW_POWERED_BY: false, - TOOLBAR_BUTTONS: [ - "microphone","camera","closedcaptions","desktop","fullscreen", - "fodeviceselection","hangup","chat","recording","livestreaming", - "etherpad","settings","raisehand","videoquality","filmstrip", - "feedback","shortcuts","tileview","participants-pane", - ], + MOBILE_APP_PROMO: false, + HIDE_DEEP_LINKING_LOGO: true, + DISABLE_JOIN_LEAVE_NOTIFICATIONS: false, }, }); - api.addEventListener("readyToClose", () => { window.close(); }); + api.addEventListener("readyToClose", function() { + try { window.close(); } catch(e) {} + // window.close() fails if the tab wasn't opened by JS (common on mobile) + document.getElementById("jitsi-container").innerHTML = + '
Meeting ended' + + 'Back to rMeets
'; + }); `); - } - - return c.html(renderShell({ - title: `${room} — rMeets | rSpace`, - moduleId: "rmeets", - spaceSlug: space, - modules: getModuleInfoList(), - theme: "dark", - body: ``, - scripts: ``, - })); }); // ── Helpers ── diff --git a/vite.config.ts b/vite.config.ts index 9c7d58e..2dd8d66 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -466,6 +466,28 @@ export default defineConfig({ }, }); + // Build mortgage simulator component + await wasmBuild({ + configFile: false, + root: resolve(__dirname, "modules/rflows/components"), + resolve: { + alias: { + "../lib/mortgage-types": resolve(__dirname, "modules/rflows/lib/mortgage-types.ts"), + "../lib/mortgage-engine": resolve(__dirname, "modules/rflows/lib/mortgage-engine.ts"), + }, + }, + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/rflows"), + lib: { + entry: resolve(__dirname, "modules/rflows/components/folk-mortgage-simulator.ts"), + formats: ["es"], + fileName: () => "folk-mortgage-simulator.js", + }, + rollupOptions: { output: { entryFileNames: "folk-mortgage-simulator.js" } }, + }, + }); + // Copy flows CSS mkdirSync(resolve(__dirname, "dist/modules/rflows"), { recursive: true }); copyFileSync(