1741 lines
94 KiB
TypeScript
1741 lines
94 KiB
TypeScript
/**
|
||
* <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);
|