rspace-online/modules/rflows/components/folk-mortgage-simulator.ts

1741 lines
94 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* <folk-mortgage-simulator> — 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<string, string> = {
'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<typeof setInterval> | null = null;
private advancedMode = false;
private _listenersAttached = false;
private _rafId: number | 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._attachListeners();
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._updatePlayBtn();
return;
}
this.currentMonth = Math.min(this.currentMonth + this.speed, this.maxMonth);
this._updateView();
}, 100);
}
private _updatePlayBtn() {
const btn = this.shadow.querySelector('[data-action="play-toggle"]') as HTMLElement | null;
if (btn) btn.textContent = this.playing ? '\u23F8' : '\u25B6';
}
private _stopPlayback() {
if (this.playInterval) {
clearInterval(this.playInterval);
this.playInterval = null;
}
}
// ─── Config update ────────────────────────────────────
private _updateConfig(partial: Partial<MortgageSimulatorConfig>) {
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 = `
<style>${this._styles()}</style>
<div class="root">
${this._renderHeader(state, numTranches)}
<div class="layout">
${this._renderControls(numTranches)}
<main class="main" id="viz">
${this._renderActiveView(state)}
${this._renderLenderDetail(state)}
</main>
<aside class="comparison" id="cmp">
${this._renderComparison(state)}
</aside>
</div>
</div>
`;
}
/** Partial update: just viz + comparison + timeline (no controls rebuild) */
private _updateView() {
const state = this.currentState;
const viz = this.shadow.getElementById('viz');
const cmp = this.shadow.getElementById('cmp');
if (!viz || !cmp) { this.render(); return; }
viz.innerHTML = this._renderActiveView(state) + this._renderLenderDetail(state);
cmp.innerHTML = this._renderComparison(state);
this._syncTimeline();
}
/** Sync timeline slider + label without rebuilding controls */
private _syncTimeline() {
const scrub = this.shadow.querySelector('[data-action="month-scrub"]') as HTMLInputElement | null;
if (scrub && scrub !== this.shadow.activeElement) {
scrub.value = String(this.currentMonth);
scrub.max = String(this.maxMonth);
}
const label = this.shadow.getElementById('tl-label');
if (label) {
const y = Math.floor(this.currentMonth / 12);
const m = this.currentMonth % 12;
label.textContent = `Month ${this.currentMonth} (${y}y ${m}m)`;
}
}
// ─── 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; }
.mode-toggle { padding: 6px 12px; border-radius: 6px; font-size: 12px; cursor: pointer; border: 1px solid #334155; transition: all 0.15s; margin-left: 8px; background: #1e293b; color: #64748b; font-weight: 600; }
.mode-toggle:hover { border-color: #475569; color: #94a3b8; }
.mode-toggle.advanced { background: #7c3aed; color: #fff; border-color: #7c3aed; }
/* 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<ViewMode, string> = {
mycelial: 'Network', flow: 'Flow', grid: 'Grid', lender: 'Lender', borrower: 'Borrower'
};
return `
<header class="header">
<div>
<h1>(you)rMortgage</h1>
<div class="subtitle">${state.tranches.length} lenders × ${fmtCurrency(this.config.trancheSize)} tranches</div>
</div>
<div class="view-tabs">
${(Object.keys(labels) as ViewMode[]).map(mode => `
<button class="view-tab ${this.viewMode === mode ? 'active' : ''}" data-action="view" data-view="${mode}">
${labels[mode]}
</button>
`).join('')}
<button class="mode-toggle ${this.advancedMode ? 'advanced' : ''}" data-action="toggle-advanced">
${this.advancedMode ? 'Advanced' : 'Basic'}
</button>
</div>
</header>
`;
}
// ─── 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 `
<aside class="sidebar ${this.controlsOpen ? 'open' : 'closed'}">
<button class="sidebar-toggle" data-action="toggle-controls">${this.controlsOpen ? '\u2039' : '\u203A'}</button>
${!this.controlsOpen ? `<div class="sidebar-label" data-action="toggle-controls">Loan Settings</div>` : ''}
<div class="sidebar-content" style="${this.controlsOpen ? '' : 'opacity:0;pointer-events:none'}">
<div class="section">
<div class="section-title">Property</div>
${this._slider('propertyValue', 'Value', c.propertyValue, 100000, 2000000, 25000, fmtCurrency)}
${this._slider('downPaymentPercent', 'Down Payment', c.downPaymentPercent, 0, 50, 5, v => `${v}% (${fmtCurrency(c.propertyValue * v / 100)})`)}
<div class="info-text">Loan: ${fmtCurrency(totalPrincipal)}</div>
</div>
${this.advancedMode ? `
<div class="section">
<div class="section-title">Tranches</div>
${this._slider('trancheSize', 'Size', c.trancheSize, 1000, 25000, 1000, fmtCurrency)}
<div class="info-text">= ${numTranches} lenders</div>
${this._slider('fundingPercent', 'Funded', c.fundingPercent, 50, 100, 5, v => `${v}% (${Math.round(numTranches * v / 100)} / ${numTranches})`)}
</div>
<div class="section">
<div class="section-title">Interest</div>
${this._slider('interestRate', 'Base Rate', c.interestRate * 100, 1, 12, 0.25, v => `${v.toFixed(2)}%`, true)}
${this._slider('rateVariation', 'Rate Spread', c.rateVariation * 100, 0, 3, 0.25, v => v === 0 ? 'None' : `+/- ${v.toFixed(2)}%`, true)}
</div>
<div class="section">
<div class="section-title">Lending Terms</div>
${this._slider('termYears', 'Max Term', c.termYears, 5, 30, 5, v => `${v} years`)}
<div style="margin-top:8px">
<button class="btn ${c.useVariableTerms ? 'btn-active' : 'btn-inactive'}" data-action="toggle-variable">
${c.useVariableTerms ? 'Variable Tiers' : 'Uniform Term'}
</button>
</div>
${c.useVariableTerms ? `
<div style="margin-top:8px">
${c.lendingTiers.map((tier, i) => `
<div class="tier-row">
<span class="tier-label">${tier.label}</span>
<span class="tier-rate">${(tier.rate * 100).toFixed(1)}%</span>
<input type="range" class="tier-slider" min="0" max="50" step="5"
value="${tier.allocation * 100}" data-action="tier-alloc" data-tier="${i}"
style="accent-color:#10b981">
<span class="tier-alloc">${(tier.allocation * 100).toFixed(0)}%</span>
</div>
`).join('')}
</div>
` : ''}
</div>
<div class="section">
<div class="section-title">Overpayment</div>
${this._slider('overpayment', 'Extra / mo', c.overpayment, 0, 2000, 50, fmtCurrency)}
${c.overpayment > 0 ? `
<div class="btn-group">
${(['extra_principal', 'community_fund', 'split'] as const).map(t => `
<button class="btn ${c.overpaymentTarget === t ? 'btn-active' : 'btn-inactive'}" data-action="overpayment-target" data-target="${t}">
${t === 'extra_principal' ? 'Principal' : t === 'community_fund' ? 'Community' : 'Split'}
</button>
`).join('')}
</div>
` : ''}
</div>
<div class="section">
<div class="section-title">Reinvestment</div>
${this._slider('reinvestorPercent', 'Reinvestors', c.reinvestorPercent, 0, 80, 10, v => `${v}%`)}
${c.reinvestorPercent > 0 ? `
<div class="info-text">Relend rates:</div>
<div class="btn-group">
${[[0.03, 0.05], [0.02, 0.04], [0.03], [0.05]].map((rates, i) => {
const label = rates.map(r => `${(r * 100).toFixed(0)}%`).join(' / ');
const isActive = JSON.stringify(c.reinvestmentRates) === JSON.stringify(rates);
return `<button class="btn ${isActive ? 'btn-purple' : 'btn-inactive'}" data-action="reinvest-rates" data-rates='${JSON.stringify(rates)}'>${label}</button>`;
}).join('')}
</div>
` : ''}
</div>
` : ''}
<div class="section">
<div class="section-title">Timeline</div>
<div class="playback">
<button class="play-btn" data-action="play-toggle">${this.playing ? '\u23F8' : '\u25B6'}</button>
<button class="btn btn-inactive" data-action="reset-month">Reset</button>
<div class="speed-group">
${[1, 2, 4].map(s => `
<button class="btn ${this.speed === s ? 'btn-sky' : 'btn-inactive'}" data-action="speed" data-speed="${s}">${s}x</button>
`).join('')}
</div>
</div>
<input type="range" min="0" max="${this.maxMonth}" value="${this.currentMonth}" data-action="month-scrub">
<div class="timeline-label" id="tl-label">Month ${this.currentMonth} (${years}y ${months}m)</div>
</div>
</div>
</aside>
`;
}
private _slider(key: string, label: string, value: number, min: number, max: number, step: number, format: (v: number) => string, isPercent = false): string {
return `
<div class="slider-row">
<div class="slider-header">
<span class="slider-label">${label}</span>
<span class="slider-value">${format(value)}</span>
</div>
<input type="range" min="${min}" max="${max}" step="${step}" value="${value}" data-action="config-slider" data-key="${key}" ${isPercent ? 'data-percent="true"' : ''}>
</div>
`;
}
// ─── 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 = `<svg viewBox="0 0 ${size} ${size}" style="min-height:500px;max-height:75vh">`;
// Defs
svg += `<defs>
<radialGradient id="centerGlow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#0ea5e9" stop-opacity="0.3"/>
<stop offset="70%" stop-color="#0ea5e9" stop-opacity="0.05"/>
<stop offset="100%" stop-color="#0ea5e9" stop-opacity="0"/>
</radialGradient>
<linearGradient id="hyphaActive" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#0ea5e9" stop-opacity="0.6"/>
<stop offset="100%" stop-color="#10b981" stop-opacity="0.3"/>
</linearGradient>
<linearGradient id="hyphaRepaid" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#10b981" stop-opacity="0.3"/>
<stop offset="100%" stop-color="#10b981" stop-opacity="0.1"/>
</linearGradient>
<linearGradient id="hyphaRelend" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#8b5cf6" stop-opacity="0.4"/>
<stop offset="100%" stop-color="#f59e0b" stop-opacity="0.2"/>
</linearGradient>
</defs>`;
// Background ring guides
[0.2, 0.35, 0.5].forEach(r => {
svg += `<circle cx="${layout.cx}" cy="${layout.cy}" r="${size * r}" fill="none" stroke="#1e293b" stroke-width="0.5" stroke-dasharray="4,8"/>`;
});
// 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 += `<path d="${path}" fill="none" stroke="url(#hyphaRelend)" stroke-width="${isHighlighted ? 2 : 0.8}" opacity="${isHighlighted ? 0.8 : 0.25 + link.strength * 0.3}" stroke-dasharray="${isHighlighted ? 'none' : '3,6'}"/>`;
if (isHighlighted) {
svg += `<circle r="2.5" fill="#8b5cf6" opacity="0.7"><animateMotion path="${path}" dur="${2 + link.strength}s" repeatCount="indefinite"/></circle>`;
}
});
// 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 += `<path d="${path}" fill="none" stroke="${stroke}" stroke-width="${sw}" opacity="${op}" stroke-dasharray="${isOpen ? '4,6' : 'none'}"/>`;
if (isActive && isSelected) {
svg += `<circle r="2" fill="#0ea5e9" opacity="0.9"><animateMotion path="${path}" dur="${1.5 + i * 0.05}s" repeatCount="indefinite"/></circle>`;
}
});
// 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 += `<g data-action="select-tranche" data-tranche-id="${t.id}" style="cursor:pointer">`;
// Outer ring
svg += `<circle cx="${node.x}" cy="${node.y}" r="${r}" fill="${isOpen ? 'none' : isActive ? '#1e293b' : '#064e3b'}" fill-opacity="${isOpen ? 0 : 0.8}" stroke="${isSelected ? '#38bdf8' : isOpen ? '#475569' : isActive ? '#334155' : '#065f46'}" stroke-width="${isSelected ? 2 : 1}" stroke-dasharray="${isOpen ? '3,3' : 'none'}" opacity="${isOpen ? 0.5 : 1}"/>`;
// Repaid fill
if (repaidFrac > 0 && repaidFrac < 1) {
svg += `<clipPath id="clip-${t.id}"><rect x="${node.x - r}" y="${node.y + r - r * 2 * repaidFrac}" width="${r * 2}" height="${r * 2 * repaidFrac}"/></clipPath>`;
}
if (repaidFrac > 0) {
svg += `<circle cx="${node.x}" cy="${node.y}" r="${r - 1}" fill="${isActive ? '#0ea5e9' : '#10b981'}" fill-opacity="0.5" ${repaidFrac < 1 ? `clip-path="url(#clip-${t.id})"` : ''}/>`;
}
// Reinvestment glow
if (t.reinvestmentRate != null && isActive) {
svg += `<circle cx="${node.x}" cy="${node.y}" r="${r + 3}" fill="none" stroke="#a78bfa" stroke-width="1" opacity="0.6" stroke-dasharray="${t.isReinvested ? 'none' : '3,3'}"/>`;
}
if (t.isReinvested) {
svg += `<circle cx="${node.x}" cy="${node.y}" r="2.5" fill="#8b5cf6" opacity="0.9"/>`;
}
// Interest glow
if (t.totalInterestPaid > t.principal * 0.1 && isActive && !t.reinvestmentRate) {
svg += `<circle cx="${node.x}" cy="${node.y}" r="${r + 3}" fill="none" stroke="#f59e0b" stroke-width="0.5" opacity="${0.3 + Math.min(0.5, t.totalInterestPaid / t.principal)}" stroke-dasharray="2,3"/>`;
}
// For-sale marker
if (t.listedForSale) {
svg += `<circle cx="${node.x + r * 0.7}" cy="${node.y - r * 0.7}" r="3" fill="#f59e0b"/>`;
}
// Transfer history marker
if (t.transferHistory.length > 0) {
svg += `<circle cx="${node.x - r * 0.7}" cy="${node.y - r * 0.7}" r="3" fill="#8b5cf6"/>`;
}
// Label on select
if (isSelected) {
const hasReinvest = t.reinvestmentRate != null;
const boxH = hasReinvest ? 44 : 32;
svg += `<rect x="${node.x - 42}" y="${node.y + r + 4}" width="84" height="${boxH}" rx="4" fill="#0f172a" fill-opacity="0.95" stroke="${t.isReinvested ? '#8b5cf6' : '#334155'}" stroke-width="0.5"/>`;
svg += `<text x="${node.x}" y="${node.y + r + 17}" text-anchor="middle" fill="${isOpen ? '#64748b' : '#e2e8f0'}" font-size="9" font-weight="600">${isOpen ? 'Open Slot' : t.lender.name}${t.isReinvested ? ' (R)' : ''}</text>`;
svg += `<text x="${node.x}" y="${node.y + r + 29}" text-anchor="middle" fill="#94a3b8" font-size="8">${fmt(t.principal)} @ ${(t.interestRate * 100).toFixed(1)}%</text>`;
if (hasReinvest) {
svg += `<text x="${node.x}" y="${node.y + r + 41}" text-anchor="middle" fill="#a78bfa" font-size="7">reinvests @ ${(t.reinvestmentRate! * 100).toFixed(0)}%${t.reinvestmentPool > 0 ? ` (${fmt(t.reinvestmentPool)} pooled)` : ''}</text>`;
}
}
svg += `</g>`;
});
// Center Node
svg += `<g>`;
svg += `<circle cx="${layout.cx}" cy="${layout.cy}" r="${size * 0.12}" fill="url(#centerGlow)"/>`;
svg += `<circle cx="${layout.cx}" cy="${layout.cy}" r="36" fill="none" stroke="#1e293b" stroke-width="4"/>`;
svg += `<circle cx="${layout.cx}" cy="${layout.cy}" r="36" fill="none" stroke="#10b981" stroke-width="4" stroke-dasharray="${repaidPct * 226.2} ${226.2}" stroke-dashoffset="${226.2 * 0.25}" stroke-linecap="round" opacity="0.8"/>`;
svg += `<circle cx="${layout.cx}" cy="${layout.cy}" r="30" fill="#0f172a" stroke="#0ea5e9" stroke-width="2"/>`;
svg += `<path d="M${layout.cx},${layout.cy - 14} l-12,10 h4 v8 h5 v-5 h6 v5 h5 v-8 h4 z" fill="none" stroke="#0ea5e9" stroke-width="1.5" stroke-linejoin="round"/>`;
svg += `<text x="${layout.cx}" y="${layout.cy + 20}" text-anchor="middle" fill="#e2e8f0" font-size="9" font-weight="600">${fmt(state.totalPrincipal)}</text>`;
svg += `</g>`;
// Legend
svg += `<g transform="translate(16, ${size - 120})">
<rect width="140" height="112" rx="6" fill="#0f172a" fill-opacity="0.9" stroke="#1e293b" stroke-width="0.5"/>
<g transform="translate(10, 14)"><circle cx="5" cy="0" r="4" fill="#0ea5e9" fill-opacity="0.5" stroke="#334155"/><text x="14" y="3" fill="#94a3b8" font-size="8">Active lender</text></g>
<g transform="translate(10, 28)"><circle cx="5" cy="0" r="4" fill="#10b981" fill-opacity="0.5" stroke="#065f46"/><text x="14" y="3" fill="#94a3b8" font-size="8">Repaid lender</text></g>
<g transform="translate(10, 42)"><circle cx="5" cy="0" r="4" fill="none" stroke="#475569" stroke-width="1" stroke-dasharray="3,3" opacity="0.5"/><text x="14" y="3" fill="#94a3b8" font-size="8">Open slot</text></g>
<g transform="translate(10, 56)"><circle cx="5" cy="0" r="4" fill="#1e293b" stroke="#a78bfa" stroke-width="1"/><circle cx="5" cy="0" r="2" fill="#8b5cf6"/><text x="14" y="3" fill="#94a3b8" font-size="8">Reinvested tranche</text></g>
<g transform="translate(10, 70)"><line x1="0" y1="0" x2="10" y2="0" stroke="#8b5cf6" stroke-width="1.5" opacity="0.6"/><text x="14" y="3" fill="#94a3b8" font-size="8">Reinvestment link</text></g>
<g transform="translate(10, 84)"><line x1="0" y1="0" x2="10" y2="0" stroke="#0ea5e9" stroke-width="1.5" opacity="0.6"/><text x="14" y="3" fill="#94a3b8" font-size="8">Payment flow</text></g>
<g transform="translate(10, 98)"><circle cx="5" cy="0" r="3" fill="#f59e0b"/><text x="14" y="3" fill="#94a3b8" font-size="8">For sale / transferred</text></g>
</g>`;
// Stats overlay
svg += `<g transform="translate(${size - 146}, ${size - 60})">
<rect width="130" height="52" rx="6" fill="#0f172a" fill-opacity="0.9" stroke="#1e293b" stroke-width="0.5"/>
<text x="10" y="16" fill="#94a3b8" font-size="8">Month ${state.currentMonth}${state.tranches.length} lenders</text>
<text x="10" y="30" fill="#10b981" font-size="9" font-weight="600">${(repaidPct * 100).toFixed(1)}% repaid</text>
<text x="10" y="43" fill="#f59e0b" font-size="8">${fmt(state.totalInterestPaid)} interest earned</text>
</g>`;
// Community Fund
if (state.communityFundBalance > 0) {
svg += `<g transform="translate(${size - 100}, 16)">
<rect width="84" height="36" rx="6" fill="#8b5cf6" fill-opacity="0.15" stroke="#8b5cf6" stroke-width="1"/>
<text x="42" y="15" text-anchor="middle" fill="#8b5cf6" font-size="8" font-weight="600">Community Fund</text>
<text x="42" y="28" text-anchor="middle" fill="#c4b5fd" font-size="10" font-weight="500">${fmt(state.communityFundBalance)}</text>
</g>`;
}
svg += `</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<string, number[]>();
nodes.forEach((n, i) => {
const lid = n.tranche.lender.id;
if (!lenderNodes.has(lid)) lenderNodes.set(lid, []);
lenderNodes.get(lid)!.push(i);
});
for (const [, indices] of Array.from(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<string, MortgageTranche[]>();
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 Array.from(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 `<path d="M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2} L${x2},${y2 + h2} C${mx},${y2 + h2} ${mx},${y1 + h1} ${x1},${y1 + h1} Z" fill="${color}"/>`;
};
let svg = `<svg viewBox="0 0 ${W} ${H}" style="min-height:400px">`;
// Defs
svg += `<defs>
<linearGradient id="sankeyBorrower" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="${COLORS.borrower}" stop-opacity="0.8"/><stop offset="100%" stop-color="${COLORS.borrower}" stop-opacity="0.4"/></linearGradient>
${tierNodes.map(t => `<linearGradient id="tierGrad-${t.label}" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="${t.color}" stop-opacity="0.6"/><stop offset="100%" stop-color="${t.color}" stop-opacity="0.3"/></linearGradient>`).join('')}
<linearGradient id="principalGrad" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="${COLORS.principal}" stop-opacity="0.5"/><stop offset="100%" stop-color="${COLORS.principal}" stop-opacity="0.2"/></linearGradient>
<linearGradient id="interestGrad" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stop-color="${COLORS.interest}" stop-opacity="0.5"/><stop offset="100%" stop-color="${COLORS.interest}" stop-opacity="0.2"/></linearGradient>
</defs>`;
// Borrower node
svg += `<rect x="${colBorrower - nodeW / 2}" y="${borrowerY}" width="${nodeW}" height="${borrowerH}" rx="4" fill="${COLORS.borrower}"/>`;
svg += `<text x="${colBorrower}" y="${borrowerY - 8}" text-anchor="middle" fill="${COLORS.text}" font-size="12" font-weight="600">Borrower</text>`;
svg += `<text x="${colBorrower}" y="${borrowerY + borrowerH + 16}" text-anchor="middle" fill="${COLORS.textMuted}" font-size="10">${fmt(totalMonthly + state.overpayment)}/mo</text>`;
// Split node
const principalH = totalMonthly > 0 ? splitH * (totalPrincipalFlow / totalMonthly) : splitH / 2;
svg += `<rect x="${colSplit - nodeW / 2}" y="${splitY}" width="${nodeW}" height="${principalH}" rx="2" fill="${COLORS.principal}"/>`;
svg += `<rect x="${colSplit - nodeW / 2}" y="${splitY + principalH}" width="${nodeW}" height="${splitH - principalH}" rx="2" fill="${COLORS.interest}"/>`;
svg += `<text x="${colSplit}" y="${splitY - 8}" text-anchor="middle" fill="${COLORS.text}" font-size="11" font-weight="600">Payment Split</text>`;
svg += `<text x="${colSplit + nodeW / 2 + 6}" y="${splitY + 14}" text-anchor="start" fill="${COLORS.principal}" font-size="9">Principal ${fmt(totalPrincipalFlow)}</text>`;
svg += `<text x="${colSplit + nodeW / 2 + 6}" y="${splitY + splitH - 4}" text-anchor="start" fill="${COLORS.interest}" font-size="9">Interest ${fmt(totalInterest)}</text>`;
// 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 += `<g data-action="select-tier" data-tier-label="${tier.label}" style="cursor:pointer">`;
svg += `<rect x="${colTiers - nodeW / 2}" y="${tier.y}" width="${nodeW}" height="${tier.h * pFrac}" rx="2" fill="${tier.color}" ${selected ? 'stroke="#fff" stroke-width="2"' : ''}/>`;
svg += `<rect x="${colTiers - nodeW / 2}" y="${tier.y + tier.h * pFrac}" width="${nodeW}" height="${tier.h * (1 - pFrac)}" rx="2" fill="${tier.color}" opacity="0.5" ${selected ? 'stroke="#fff" stroke-width="2"' : ''}/>`;
svg += `<text x="${colTiers + nodeW / 2 + 8}" y="${tier.y + tier.h / 2 - 6}" fill="${tier.color}" font-size="11" font-weight="600" dominant-baseline="middle">${isReinvest ? `Reinvest (${tier.label.replace('reinvest@', '')})` : tier.label}</text>`;
svg += `<text x="${colTiers + nodeW / 2 + 8}" y="${tier.y + tier.h / 2 + 8}" fill="${COLORS.textMuted}" font-size="9" dominant-baseline="middle">${tier.activeCount} active / ${tier.repaidCount} repaid · ${fmt(tier.totalMonthlyPayment)}/mo</text>`;
if (tier.reinvestPool > 100) {
svg += `<text x="${colTiers + nodeW / 2 + 8}" y="${tier.y + tier.h / 2 + 22}" fill="${COLORS.reinvest}" font-size="8" dominant-baseline="middle">Pool: ${fmt(tier.reinvestPool)}</text>`;
}
svg += `</g>`;
// 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 += `<text x="${colEnd}" y="30" fill="${COLORS.text}" font-size="11" font-weight="600">Lenders</text>`;
tierNodes.forEach(tier => {
svg += `<circle cx="${colEnd}" cy="${tier.y + tier.h / 2}" r="3" fill="${tier.color}"/>`;
svg += `<text x="${colEnd + 8}" y="${tier.y + tier.h / 2}" fill="${COLORS.textMuted}" font-size="9" dominant-baseline="middle">${tier.tranches.length} × ${fmt(tier.totalPrincipal / tier.tranches.length)}</text>`;
});
// Reinvestment loop
if (reinvestTotal > 100) {
svg += `<g opacity="0.6">
<defs><marker id="arrowReinvest" viewBox="0 0 6 6" refX="6" refY="3" markerWidth="6" markerHeight="6" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill="${COLORS.reinvest}"/></marker></defs>
<path d="M${colEnd - 10},${H / 2 + 40} Q${colEnd + 30},${H / 2 + 80} ${colEnd + 30},${H - 30} Q${colEnd + 30},${H - 10} ${colTiers},${H - 10} Q${colSplit},${H - 10} ${colSplit},${H / 2 + 60}" fill="none" stroke="${COLORS.reinvest}" stroke-width="${Math.max(1.5, Math.min(4, reinvestTotal / 1000))}" stroke-dasharray="6,4" marker-end="url(#arrowReinvest)"/>
<text x="${(colTiers + colSplit) / 2}" y="${H - 16}" text-anchor="middle" fill="${COLORS.reinvest}" font-size="9">Reinvestment: ${fmt(reinvestTotal)} pooled</text>
</g>`;
}
// Community fund
if (state.communityFundBalance > 0) {
svg += `<rect x="10" y="${H - 50}" width="100" height="36" rx="6" fill="${COLORS.community}" fill-opacity="0.15" stroke="${COLORS.community}" stroke-width="1"/>`;
svg += `<text x="60" y="${H - 36}" text-anchor="middle" fill="${COLORS.community}" font-size="9" font-weight="600">Community Fund</text>`;
svg += `<text x="60" y="${H - 22}" text-anchor="middle" fill="${COLORS.textMuted}" font-size="9">${fmt(state.communityFundBalance)}</text>`;
}
// Summary stats
svg += `<text x="${W / 2}" y="${H - 8}" text-anchor="middle" fill="${COLORS.textMuted}" font-size="9">Month ${state.currentMonth} · ${state.tranches.length} tranches · ${fmt(state.totalPrincipalRemaining)} remaining · ${((state.totalPrincipalPaid / state.totalPrincipal) * 100).toFixed(1)}% repaid</text>`;
svg += `</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 = `<div>`;
// Header
html += `<div class="filter-bar">
<h3>Lenders (${tranches.length})${repaidCount > 0 ? ` <span style="color:#34d399">/ ${repaidCount} repaid</span>` : ''}</h3>
<div style="display:flex;gap:4px">
${(['all', 'active', 'repaid', 'open', 'for-sale'] as const).map(f => `
<button class="btn ${this.gridFilter === f ? 'btn-sky' : 'btn-inactive'}" data-action="grid-filter" data-filter="${f}">
${f === 'for-sale' ? `sale (${forSaleCount})` : f === 'open' ? `open (${openCount})` : f}
</button>
`).join('')}
</div>
</div>`;
// Sort
html += `<div class="sort-bar">
<span>Sort:</span>
${(['index', 'repaid', 'rate', 'remaining'] as const).map(s => `
<button class="btn ${this.gridSortBy === s ? 'btn-sky' : 'btn-inactive'}" data-action="grid-sort" data-sort="${s}">
${s === 'index' ? '#' : s}
</button>
`).join('')}
</div>`;
// Grid
html += `<div class="lender-grid" style="grid-template-columns:repeat(auto-fill, minmax(${colSize}, 1fr))">`;
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 += `<button class="grid-cell ${cls} ${bgCls}" data-action="select-tranche" data-tranche-id="${t.id}" title="${isOpen ? `Open: ${fmtCurrency(t.principal)} @ ${(t.interestRate * 100).toFixed(1)}% — ${t.tierLabel}` : `${t.lender.name}: ${fmtCurrency(t.principal)} @ ${(t.interestRate * 100).toFixed(1)}%`}">`;
if (!isOpen) {
html += `<div class="fill-bar" style="background:${isRepaid ? '#34d399' : '#38bdf8'};clip-path:inset(${100 - repaidPct}% 0 0 0)"></div>`;
}
html += `<div style="position:relative">
<div class="name" style="color:${isOpen ? '#64748b' : isRepaid ? '#6ee7b7' : '#e2e8f0'};${isOpen ? 'font-style:italic' : ''}">${isOpen ? 'Open' : t.lender.name}</div>
<div class="amount">${fmtCurrency(t.principal)}</div>
<div class="pct" style="color:${isOpen ? '#0284c7' : isRepaid ? '#34d399' : '#38bdf8'}">${isOpen ? t.tierLabel : isRepaid ? '100%' : `${repaidPct.toFixed(0)}%`}</div>
</div>`;
if (t.listedForSale) html += `<div style="position:absolute;top:-2px;right:-2px;width:12px;height:12px;background:#f59e0b;border-radius:50%;font-size:8px;display:flex;align-items:center;justify-content:center">$</div>`;
if (t.transferHistory.length > 0) html += `<div style="position:absolute;top:-2px;left:-2px;width:12px;height:12px;background:#8b5cf6;border-radius:50%;font-size:8px;display:flex;align-items:center;justify-content:center">${t.transferHistory.length}</div>`;
html += `</button>`;
});
html += `</div></div>`;
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 = `<div class="detail-panel">`;
// Header
html += `<div class="detail-header">
<div>
<div style="font-size:18px;font-weight:600;color:#fff">${tranche.lender.name}</div>
<div style="font-size:11px;color:#64748b;font-family:monospace">${tranche.lender.walletAddress.slice(0, 10)}...</div>
</div>
<button class="detail-close" data-action="close-detail">×</button>
</div>`;
// Stats
html += `<div class="stats-grid">
<div class="stat-item"><div class="stat-label">Tranche</div><div class="stat-value">${fmtCurrency(tranche.principal)}</div></div>
<div class="stat-item"><div class="stat-label">Rate</div><div class="stat-value">${(tranche.interestRate * 100).toFixed(2)}%</div></div>
<div class="stat-item"><div class="stat-label">Status</div><div class="stat-value" style="color:${tranche.status === 'repaid' ? '#34d399' : '#38bdf8'}">${tranche.status}</div></div>
<div class="stat-item"><div class="stat-label">Remaining</div><div class="stat-value">${fmtCurrency(tranche.principalRemaining)}</div></div>
<div class="stat-item"><div class="stat-label">Interest Earned</div><div class="stat-value">${fmtCurrency(tranche.totalInterestPaid)}</div></div>
<div class="stat-item"><div class="stat-label">Repaid</div><div class="stat-value">${repaidPct.toFixed(2)}%</div></div>
</div>`;
// Progress bar
html += `<div style="margin-bottom:16px">
<div style="display:flex;justify-content:space-between;font-size:11px;color:#64748b;margin-bottom:4px">
<span>Principal Repayment</span><span>${repaidPct.toFixed(2)}%</span>
</div>
<div class="progress-bar"><div class="progress-fill" style="width:${Math.min(100, repaidPct)}%;background:${tranche.status === 'repaid' ? '#10b981' : '#0ea5e9'}"></div></div>
</div>`;
// This month
if (tranche.status === 'active') {
const piRatio = tranche.monthlyPayment > 0 ? (tranche.monthlyPrincipal / tranche.monthlyPayment) * 100 : 50;
html += `<div class="detail-section">
<div class="detail-section-title">This Month</div>
<div style="display:flex;gap:16px">
<div style="flex:1"><div style="font-size:11px;color:#475569">Payment</div><div style="font-size:13px;font-family:monospace;color:#fff">${fmtCurrency(tranche.monthlyPayment)}</div></div>
<div style="flex:1"><div style="font-size:11px;color:#475569">Principal</div><div style="font-size:13px;font-family:monospace;color:#34d399">${fmtCurrency(tranche.monthlyPrincipal)}</div></div>
<div style="flex:1"><div style="font-size:11px;color:#475569">Interest</div><div style="font-size:13px;font-family:monospace;color:#fbbf24">${fmtCurrency(tranche.monthlyInterest)}</div></div>
</div>
<div style="display:flex;height:8px;border-radius:999px;overflow:hidden;margin-top:8px">
<div style="width:${piRatio}%;background:#10b981"></div>
<div style="flex:1;background:#f59e0b"></div>
</div>
<div style="display:flex;justify-content:space-between;font-size:10px;color:#475569;margin-top:2px"><span>Principal</span><span>Interest</span></div>
</div>`;
}
// Secondary market
html += `<div class="detail-section">
<div class="detail-section-title">Secondary Market</div>
${tranche.status === 'repaid'
? `<div style="font-size:11px;color:#475569">Tranche fully repaid — not tradeable</div>`
: `<button class="btn ${tranche.listedForSale ? 'btn-active' : 'btn-inactive'}" data-action="toggle-sale" data-tranche-id="${tranche.id}">${tranche.listedForSale ? 'Remove Listing' : 'List for Sale'}</button>
${tranche.listedForSale ? `<div style="margin-top:8px;font-size:11px;color:#64748b">
<div>Asking: ${fmtCurrency(tranche.askingPrice ?? tranche.principalRemaining)}</div>
<div>Buyer yield: ${buyerYield.annualYield.toFixed(2)}%/yr</div>
<div>Months remaining: ${buyerYield.monthsRemaining}</div>
</div>` : ''}`
}
</div>`;
// Transfer history
if (tranche.transferHistory.length > 0) {
html += `<div class="detail-section">
<div class="detail-section-title">Transfer History (${tranche.transferHistory.length})</div>
${tranche.transferHistory.map(t => `
<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:4px">
<span style="color:#64748b">${t.fromLenderId.replace('lender-', 'L')}${t.toLenderId.replace('lender-', 'L')}</span>
<span style="font-family:monospace;color:${t.premiumPercent >= 0 ? '#34d399' : '#ef4444'}">${fmtCurrency(t.price)} (${t.premiumPercent >= 0 ? '+' : ''}${t.premiumPercent.toFixed(1)}%)</span>
</div>
`).join('')}
</div>`;
}
// 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 += `<div class="detail-section">
<div class="detail-section-title">Amortization</div>
<svg viewBox="0 0 ${w} ${h + 20}" style="max-height:100px">
<path d="${balancePath}" fill="none" stroke="#0ea5e9" stroke-width="1.5" opacity="0.8"/>
<path d="${interestPath}" fill="none" stroke="#f59e0b" stroke-width="1.5" opacity="0.8"/>
${tranche.monthsElapsed > 0 ? `<line x1="${currentX}" y1="0" x2="${currentX}" y2="${h}" stroke="#94a3b8" stroke-width="1" stroke-dasharray="3,3"/>` : ''}
<circle cx="10" cy="${h + 10}" r="3" fill="#0ea5e9"/><text x="18" y="${h + 13}" fill="#94a3b8" font-size="8">Balance</text>
<circle cx="80" cy="${h + 10}" r="3" fill="#f59e0b"/><text x="88" y="${h + 13}" fill="#94a3b8" font-size="8">Cum. Interest</text>
</svg>
</div>`;
}
html += `</div>`;
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 = `<div style="max-width:900px;margin:0 auto">`;
html += `<div class="calc-header"><h2>Lender Return Calculator</h2><p>See what you could earn lending into the rMortgage</p></div>`;
// Investment amount
html += `<div class="calc-input" style="margin-bottom:24px">
<div class="calc-input-header"><span class="calc-input-label">Your Investment</span><span class="calc-input-value">${fmtCurrency(this.lenderInvestment)}</span></div>
<input type="range" min="1000" max="25000" step="1000" value="${this.lenderInvestment}" data-action="lender-investment">
<div class="calc-range-labels"><span>$1,000</span><span>$25,000</span></div>
</div>`;
// Strategy toggle
html += `<div class="strategy-toggle">
${(['compare', 'liquid', 'reinvest'] as const).map(k => `
<button class="strategy-btn ${this.lenderStrategy === k ? 'active' : ''}" data-action="lender-strategy" data-strategy="${k}">
${k === 'compare' ? 'Compare Both' : k === 'liquid' ? 'Monthly Liquidity' : 'Reinvest to Term'}
</button>
`).join('')}
</div>`;
if (this.lenderStrategy === 'compare') {
scenarios.forEach(s => {
const color = TIER_COLORS[s.tierLabel] || '#64748b';
const reinvestGain = s.reinvested.totalInterest - s.liquid.totalInterest;
html += `<div class="tier-card">
<div class="tier-header" style="border-left:3px solid ${color}">
<span style="font-size:18px;font-weight:700;color:${color}">${s.tierLabel}</span>
<span style="font-size:13px;color:#64748b">@ ${(s.rate * 100).toFixed(1)}% APR</span>
<span style="font-size:11px;color:#475569;margin-left:auto">${s.termYears} year commitment</span>
</div>
<div class="tier-columns">
<div class="tier-col">
<div class="tier-col-title"><span class="dot" style="background:#22d3ee"></span>Monthly Liquidity</div>
<div class="yield-row"><span class="yield-label">Monthly income</span><span class="yield-value">${fmtDetail(s.liquid.monthlyPayment)}</span></div>
<div class="yield-row"><span class="yield-label">Total interest</span><span class="yield-value${s.liquid.effectiveYield === bestLiquid ? ' highlight' : ''}">${fmtCurrency(s.liquid.totalInterest)}</span></div>
<div class="yield-row"><span class="yield-label">Total received</span><span class="yield-value">${fmtCurrency(s.liquid.totalReturn)}</span></div>
<div style="border-top:1px solid rgba(51,65,85,0.5);padding-top:8px;margin-top:8px">
<div class="yield-row"><span class="yield-label">Effective yield</span><span class="yield-value large${s.liquid.effectiveYield === bestLiquid ? ' highlight' : ''}">${s.liquid.effectiveYield.toFixed(2)}%/yr</span></div>
</div>
</div>
<div class="tier-col">
<div class="tier-col-title"><span class="dot" style="background:#a78bfa"></span>Reinvest to Term</div>
<div class="yield-row"><span class="yield-label">Final value</span><span class="yield-value">${fmtCurrency(s.reinvested.finalValue)}</span></div>
<div class="yield-row"><span class="yield-label">Total interest</span><span class="yield-value${s.reinvested.effectiveYield === bestReinvest ? ' highlight' : ''}">${fmtCurrency(s.reinvested.totalInterest)}</span></div>
<div class="yield-row"><span class="yield-label">Extra vs liquid</span><span class="yield-value" style="color:${reinvestGain > 0 ? '#34d399' : '#64748b'}">${reinvestGain > 0 ? '+' : ''}${fmtCurrency(reinvestGain)}</span></div>
<div style="border-top:1px solid rgba(51,65,85,0.5);padding-top:8px;margin-top:8px">
<div class="yield-row"><span class="yield-label">Effective yield</span><span class="yield-value large${s.reinvested.effectiveYield === bestReinvest ? ' highlight' : ''}">${s.reinvested.effectiveYield.toFixed(2)}%/yr</span></div>
</div>
</div>
</div>
</div>`;
});
} 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 += `<div class="scenario-card${isBest ? ' best' : ''}" style="border-left:3px solid ${color};padding:16px;margin-bottom:12px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<div style="display:flex;align-items:center;gap:8px">
<span style="font-size:18px;font-weight:700;color:${color}">${s.tierLabel}</span>
<span style="font-size:13px;color:#64748b">@ ${(s.rate * 100).toFixed(1)}%</span>
${isBest ? '<span class="badge badge-best">Best</span>' : ''}
</div>
<div style="text-align:right">
<div style="font-size:20px;font-family:monospace;font-weight:700;color:#fff">${data.effectiveYield.toFixed(2)}%</div>
<div style="font-size:11px;color:#475569">effective yield/yr</div>
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px">
${this.lenderStrategy === 'liquid' ? `
<div class="mini-stat"><div class="ms-label">Monthly</div><div class="ms-value">${fmtDetail(s.liquid.monthlyPayment)}</div></div>
<div class="mini-stat"><div class="ms-label">Total Interest</div><div class="ms-value" style="color:#fbbf24">${fmtCurrency(s.liquid.totalInterest)}</div></div>
<div class="mini-stat"><div class="ms-label">Total Received</div><div class="ms-value" style="color:#34d399">${fmtCurrency(s.liquid.totalReturn)}</div></div>
` : `
<div class="mini-stat"><div class="ms-label">Final Value</div><div class="ms-value" style="color:#34d399">${fmtCurrency(s.reinvested.finalValue)}</div></div>
<div class="mini-stat"><div class="ms-label">Total Interest</div><div class="ms-value" style="color:#fbbf24">${fmtCurrency(s.reinvested.totalInterest)}</div></div>
<div class="mini-stat"><div class="ms-label">Growth</div><div class="ms-value" style="color:#a78bfa">${((s.reinvested.totalReturn / this.lenderInvestment - 1) * 100).toFixed(1)}%</div></div>
`}
</div>
<div class="stacked-bar" style="margin-top:12px">
<div style="width:${(this.lenderInvestment / data.totalReturn) * 100}%;background:#0ea5e9;border-radius:999px 0 0 999px"></div>
<div style="width:${(data.totalInterest / data.totalReturn) * 100}%;background:#f59e0b"></div>
</div>
<div style="display:flex;justify-content:space-between;font-size:10px;color:#475569;margin-top:4px"><span>Principal</span><span>Interest</span></div>
</div>`;
});
}
// 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 += `<div class="insight-box purple">
Reinvesting returns in a <strong style="color:#c4b5fd">${best.tierLabel}</strong> tranche yields
<strong style="color:#34d399">${best.reinvested.effectiveYield.toFixed(2)}%/yr</strong> vs
<strong style="color:#22d3ee">${liquidBest.liquid.effectiveYield.toFixed(2)}%/yr</strong> with monthly liquidity.
${best.reinvested.totalInterest > liquidBest.liquid.totalInterest
? ` That's <strong style="color:#fff">${fmtCurrency(best.reinvested.totalInterest - liquidBest.liquid.totalInterest)}</strong> more over the term.`
: ''}
</div>`;
html += `</div>`;
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 = `<div style="max-width:900px;margin:0 auto">`;
html += `<div class="calc-header"><h2>What Can I Afford?</h2><p>Enter what you can pay monthly — see how tier mix affects your borrowing power</p></div>`;
// Inputs
html += `<div class="inputs-grid">
<div class="calc-input">
<div class="calc-input-header"><span class="calc-input-label">Monthly Budget</span><span class="calc-input-value">${fmtCurrency(this.borrowerBudget)}</span></div>
<input type="range" min="500" max="10000" step="100" value="${this.borrowerBudget}" data-action="borrower-budget">
<div class="calc-range-labels"><span>$500</span><span>$10,000</span></div>
</div>
<div class="calc-input">
<div class="calc-input-header"><span class="calc-input-label">Down Payment</span><span class="calc-input-value">${this.borrowerDownPayment}%</span></div>
<input type="range" min="0" max="50" step="5" value="${this.borrowerDownPayment}" data-action="borrower-down" style="accent-color:#10b981">
<div class="calc-range-labels"><span>0%</span><span>50%</span></div>
</div>
</div>`;
// Scenarios
scenarios.forEach((s, idx) => {
const isBest = s.maxLoan === bestLoan;
const isCheapest = s.totalInterest === leastInterest;
const isExpanded = this.borrowerExpandedIdx === idx;
html += `<div class="scenario-card${isBest ? ' best' : isCheapest ? ' cheapest' : ''}">
<button class="scenario-summary" data-action="borrower-expand" data-idx="${idx}">
<div class="scenario-badges">
<div class="name">${s.label}</div>
<div>${isBest ? '<span class="badge badge-green">Max $</span> ' : ''}${isCheapest ? '<span class="badge badge-amber">Low Int</span>' : ''}</div>
</div>
<div class="scenario-stats">
<div><div style="font-size:10px;color:#475569;text-transform:uppercase">You Can Borrow</div><div style="font-size:13px;font-family:monospace;color:#34d399;font-weight:700">${fmtCurrency(s.maxLoan)}</div></div>
<div><div style="font-size:10px;color:#475569;text-transform:uppercase">Property Value</div><div style="font-size:13px;font-family:monospace;color:#fff">${fmtCurrency(s.propertyValue)}</div></div>
<div><div style="font-size:10px;color:#475569;text-transform:uppercase">Total Interest</div><div style="font-size:13px;font-family:monospace;color:#fbbf24">${fmtCurrency(s.totalInterest)}</div></div>
<div><div style="font-size:10px;color:#475569;text-transform:uppercase">Payoff</div><div style="font-size:13px;font-family:monospace;color:#38bdf8">${s.payoffYears}yr</div></div>
</div>
<span class="expand-icon${isExpanded ? ' open' : ''}">▾</span>
</button>`;
if (isExpanded) {
html += `<div class="scenario-detail">
<div style="font-size:11px;color:#64748b;margin-bottom:12px">${s.description}</div>
<div style="margin-bottom:16px">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
<span style="font-size:11px;color:#475569">Borrowing power</span>
<span style="font-size:11px;color:#64748b;margin-left:auto">${fmtCurrency(s.maxLoan)} / ${fmtCurrency(bestLoan)} max</span>
</div>
<div class="progress-bar" style="height:12px"><div class="progress-fill" style="width:${(s.maxLoan / bestLoan) * 100}%;background:linear-gradient(90deg,#0ea5e9,#10b981)"></div></div>
</div>
<div style="margin-bottom:16px">
<div style="font-size:11px;color:#475569;margin-bottom:4px">Tier allocation of your ${fmtCurrency(this.borrowerBudget)}/mo</div>
<div style="height:24px;background:#334155;border-radius:999px;overflow:hidden;display:flex">
${s.breakdown.map((b, i) => {
const frac = b.monthlyPayment / this.borrowerBudget;
const colors = ['#06b6d4', '#10b981', '#3b82f6', '#f59e0b', '#ef4444'];
return `<div style="width:${frac * 100}%;height:100%;background:${colors[i % colors.length]};display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:700;color:rgba(255,255,255,0.8)" title="${b.tierLabel}: ${fmtCurrency(b.monthlyPayment)}/mo → ${fmtCurrency(b.principal)}">${frac > 0.08 ? b.tierLabel : ''}</div>`;
}).join('')}
</div>
</div>
<table class="data-table">
<thead><tr><th>Tier</th><th>Rate</th><th>Monthly</th><th>Principal</th><th>Interest</th><th>Term</th></tr></thead>
<tbody>
${s.breakdown.map(b => `<tr>
<td style="font-weight:500;color:#fff">${b.tierLabel}</td>
<td style="color:#94a3b8">${(b.rate * 100).toFixed(1)}%</td>
<td style="color:#38bdf8">${fmtCurrency(b.monthlyPayment)}</td>
<td style="color:#34d399">${fmtCurrency(b.principal)}</td>
<td style="color:#fbbf24">${fmtCurrency(b.totalInterest)}</td>
<td style="color:#64748b">${b.termYears}yr</td>
</tr>`).join('')}
<tr class="total">
<td style="color:#fff">Total</td><td></td>
<td style="color:#fff">${fmtCurrency(this.borrowerBudget)}</td>
<td style="color:#34d399">${fmtCurrency(s.maxLoan)}</td>
<td style="color:#fbbf24">${fmtCurrency(s.totalInterest)}</td>
<td style="color:#64748b">${s.payoffYears}yr</td>
</tr>
</tbody>
</table>
<div style="margin-top:12px">
<div style="font-size:10px;color:#475569;margin-bottom:4px">Total cost breakdown</div>
<div class="stacked-bar">
<div style="width:${(s.maxLoan / s.totalPaid) * 100}%;background:#10b981"></div>
<div style="width:${(s.totalInterest / s.totalPaid) * 100}%;background:#f59e0b"></div>
</div>
<div style="display:flex;justify-content:space-between;font-size:10px;color:#475569;margin-top:4px">
<span>Principal ${fmtCurrency(s.maxLoan)}</span>
<span>Interest ${fmtCurrency(s.totalInterest)}</span>
${this.borrowerDownPayment > 0 ? `<span>Down ${fmtCurrency(s.propertyValue - s.maxLoan)}</span>` : ''}
</div>
</div>
</div>`;
}
html += `</div>`;
});
// 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 += `<div class="insight-box">
<strong style="color:#34d399">${best.label}</strong> lets you borrow the most (${fmtCurrency(best.maxLoan)})${
diff > 0 && cheapest.label !== best.label
? `, but <strong style="color:#fbbf24">${cheapest.label}</strong> saves you <strong style="color:#fff">${fmtCurrency(savedInterest)}</strong> 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.
</div>`;
}
html += `</div>`;
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 = `<div style="display:flex;flex-direction:column;gap:16px;font-size:13px">`;
// Progress
html += `<div>
<div class="section-title">Progress</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
<div class="stat-card"><div class="sc-label">Principal Repaid</div><div class="sc-value">${fmtCurrency(state.totalPrincipalPaid)} <span style="font-size:11px;color:#64748b">${fmtPct(repaidPct)}</span></div></div>
<div class="stat-card"><div class="sc-label">Remaining</div><div class="sc-value">${fmtCurrency(state.totalPrincipalRemaining)}</div></div>
<div class="stat-card"><div class="sc-label">Interest Paid</div><div class="sc-value" style="color:#fbbf24">${fmtCurrency(state.totalInterestPaid)}</div></div>
<div class="stat-card"><div class="sc-label">Tranches Done</div><div class="sc-value" style="color:#34d399">${state.tranchesRepaid} / ${state.tranches.length}</div></div>
</div>
<div style="margin-top:8px"><div class="progress-bar" style="height:8px"><div class="progress-fill" style="width:${Math.min(100, repaidPct)}%;background:linear-gradient(90deg,#0ea5e9,#10b981)"></div></div></div>
</div>`;
// Community Fund
if (state.communityFundBalance > 0) {
html += `<div>
<div class="section-title">Community Fund</div>
<div style="background:rgba(16,185,129,0.15);border-radius:6px;padding:12px;border:1px solid rgba(16,185,129,0.3)">
<div style="font-size:18px;font-family:monospace;color:#34d399">${fmtCurrency(state.communityFundBalance)}</div>
<div style="font-size:11px;color:#64748b;margin-top:4px">Overflow directed to community resilience</div>
</div>
</div>`;
}
// Comparison
html += `<div>
<div class="section-title">rMortgage vs Traditional</div>
<div style="display:grid;grid-template-columns:auto 1fr 1fr;gap:4px 8px;margin-bottom:4px">
<div></div><div style="font-size:10px;color:#475569;text-align:center;text-transform:uppercase">Myco</div><div style="font-size:10px;color:#475569;text-align:center;text-transform:uppercase">Trad</div>
</div>
${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)}
</div>`;
// Key insight
html += `<div class="community-insight">
<div style="font-size:11px;font-weight:600;color:#7dd3fc;text-transform:uppercase;margin-bottom:8px">Community Wealth</div>
<div style="font-size:24px;font-family:monospace;color:#34d399;margin-bottom:4px">${fmtCurrency(s.communityRetained)}</div>
<div style="font-size:11px;color:#64748b">Interest that stays in the community instead of flowing to a distant institution.${s.interestSaved > 0 ? ` Plus ${fmtCurrency(s.interestSaved)} saved through distributed rates.` : ''}</div>
</div>`;
html += `</div>`;
return html;
}
private _compareRow(label: string, myco: string, trad: string, highlight: boolean): string {
return `<div class="compare-row">
<div class="compare-label">${label}</div>
<div class="compare-val compare-myco${highlight ? ' highlight' : ''}">${myco}</div>
<div class="compare-val compare-trad">${trad}</div>
</div>`;
}
// ─── Event handling ───────────────────────────────────
private _attachListeners() {
if (this._listenersAttached) return;
this._listenersAttached = true;
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;
// Update tab highlights in-place
const tabs = this.shadow.querySelectorAll('.view-tab');
tabs.forEach((tab: Element) => {
const t = tab as HTMLElement;
t.className = `view-tab ${t.dataset.view === this.viewMode ? 'active' : ''}`;
});
this._updateView();
break;
}
case 'toggle-advanced':
this.advancedMode = !this.advancedMode;
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._updatePlayBtn();
break;
case 'reset-month':
this.currentMonth = 0;
this._stopPlayback();
this.playing = false;
this._updatePlayBtn();
this._updateView();
break;
case 'speed': {
this.speed = Number(target.dataset.speed);
// Update speed button highlights
const btns = this.shadow.querySelectorAll('[data-action="speed"]');
btns.forEach((b: Element) => {
const s = Number((b as HTMLElement).dataset.speed);
(b as HTMLElement).className = `btn ${this.speed === s ? 'btn-sky' : 'btn-inactive'}`;
});
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._updateView();
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._updateView();
}
break;
}
case 'close-detail':
this.selectedTrancheId = null;
this._updateView();
break;
case 'toggle-sale':
this._updateView();
break;
case 'grid-filter':
this.gridFilter = target.dataset.filter as any;
this._updateView();
break;
case 'grid-sort':
this.gridSortBy = target.dataset.sort as any;
this._updateView();
break;
case 'lender-strategy':
this.lenderStrategy = target.dataset.strategy as any;
this._updateView();
break;
case 'borrower-expand': {
const idx = Number(target.dataset.idx);
this.borrowerExpandedIdx = this.borrowerExpandedIdx === idx ? null : idx;
this._updateView();
break;
}
}
});
// Range inputs — debounced to avoid full DOM rebuilds during drag
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.config = { ...this.config, [key]: isPercent ? val / 100 : val } as any;
// Update the value label next to the slider immediately
const valEl = target.closest('.slider-row')?.querySelector('.slider-value') as HTMLElement;
if (valEl) {
// Re-derive display from current config value
const v = isPercent ? val : val;
if (key === 'propertyValue') valEl.textContent = fmtCurrency(val);
else if (key === 'downPaymentPercent') valEl.textContent = `${val}% (${fmtCurrency(this.config.propertyValue * val / 100)})`;
else if (key === 'interestRate') valEl.textContent = `${val.toFixed(2)}%`;
else if (key === 'rateVariation') valEl.textContent = val === 0 ? 'None' : `+/- ${val.toFixed(2)}%`;
else if (key === 'termYears') valEl.textContent = `${val} years`;
else if (key === 'overpayment') valEl.textContent = fmtCurrency(val);
else if (key === 'reinvestorPercent') valEl.textContent = `${val}%`;
else if (key === 'trancheSize') valEl.textContent = fmtCurrency(val);
else if (key === 'fundingPercent') {
const nt = Math.ceil(this.config.propertyValue * (1 - this.config.downPaymentPercent / 100) / this.config.trancheSize);
valEl.textContent = `${val}% (${Math.round(nt * val / 100)} / ${nt})`;
}
}
// Debounce the expensive recompute + view update
if (this._rafId) cancelAnimationFrame(this._rafId);
this._rafId = requestAnimationFrame(() => {
this._recompute();
this.currentMonth = Math.min(this.config.startMonth, this.maxMonth);
this._saveToHash();
this._updateView();
this._rafId = null;
});
break;
}
case 'tier-alloc': {
const tierIdx = Number(target.dataset.tier);
const newTiers = [...this.config.lendingTiers];
newTiers[tierIdx] = { ...newTiers[tierIdx], allocation: val / 100 };
this.config = { ...this.config, lendingTiers: newTiers };
// Update alloc label
const allocEl = target.closest('.tier-row')?.querySelector('.tier-alloc') as HTMLElement;
if (allocEl) allocEl.textContent = `${val.toFixed(0)}%`;
if (this._rafId) cancelAnimationFrame(this._rafId);
this._rafId = requestAnimationFrame(() => {
this._recompute();
this._saveToHash();
this._updateView();
this._rafId = null;
});
break;
}
case 'month-scrub':
this.currentMonth = val;
this._updateView();
break;
case 'lender-investment':
this.lenderInvestment = val;
this._updateView();
break;
case 'borrower-budget':
this.borrowerBudget = val;
this._updateView();
break;
case 'borrower-down':
this.borrowerDownPayment = val;
this._updateView();
break;
}
});
}
}
customElements.define('folk-mortgage-simulator', FolkMortgageSimulator);