diff --git a/modules/rflows/components/folk-mortgage-simulator.ts b/modules/rflows/components/folk-mortgage-simulator.ts index 825ba93..531209c 100644 --- a/modules/rflows/components/folk-mortgage-simulator.ts +++ b/modules/rflows/components/folk-mortgage-simulator.ts @@ -95,6 +95,9 @@ class FolkMortgageSimulator extends HTMLElement { private playing = false; private speed = 1; private playInterval: ReturnType | null = null; + private advancedMode = false; + private _listenersAttached = false; + private _rafId: number | null = null; // Calculator state private lenderInvestment = DEFAULT_CONFIG.trancheSize; @@ -116,6 +119,7 @@ class FolkMortgageSimulator extends HTMLElement { this.space = this.getAttribute('space') || 'demo'; this._loadFromHash(); this._recompute(); + this._attachListeners(); this.render(); } @@ -175,14 +179,19 @@ class FolkMortgageSimulator extends HTMLElement { if (this.currentMonth >= this.maxMonth) { this._stopPlayback(); this.playing = false; - this.render(); + this._updatePlayBtn(); return; } this.currentMonth = Math.min(this.currentMonth + this.speed, this.maxMonth); - this.render(); + 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); @@ -217,17 +226,42 @@ class FolkMortgageSimulator extends HTMLElement { ${this._renderHeader(state, numTranches)}
${this._renderControls(numTranches)} -
+
${this._renderActiveView(state)} ${this._renderLenderDetail(state)}
-
`; - this._attachListeners(); + } + + /** 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 ─────────────────────────────────────────── @@ -247,6 +281,9 @@ class FolkMortgageSimulator extends HTMLElement { .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); } @@ -446,6 +483,9 @@ class FolkMortgageSimulator extends HTMLElement { ${labels[mode]} `).join('')} + `; @@ -472,6 +512,7 @@ class FolkMortgageSimulator extends HTMLElement {
Loan: ${fmtCurrency(totalPrincipal)}
+ ${this.advancedMode ? `
Tranches
${this._slider('trancheSize', 'Size', c.trancheSize, 1000, 25000, 1000, fmtCurrency)} @@ -537,6 +578,7 @@ class FolkMortgageSimulator extends HTMLElement {
` : ''} + ` : ''}
Timeline
@@ -550,7 +592,7 @@ class FolkMortgageSimulator extends HTMLElement {
-
Month ${this.currentMonth} (${years}y ${months}m)
+
Month ${this.currentMonth} (${years}y ${months}m)
@@ -803,7 +845,7 @@ class FolkMortgageSimulator extends HTMLElement { if (!lenderNodes.has(lid)) lenderNodes.set(lid, []); lenderNodes.get(lid)!.push(i); }); - for (const [, indices] of lenderNodes) { + 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]))) { @@ -844,7 +886,7 @@ class FolkMortgageSimulator extends HTMLElement { interface TierGroup { label: string; color: string; tranches: MortgageTranche[]; totalPrincipal: number; totalPrincipalRemaining: number; totalMonthlyPayment: number; totalMonthlyPrincipal: number; totalMonthlyInterest: number; activeCount: number; repaidCount: number; reinvestCount: number; reinvestPool: number; } const tiers: TierGroup[] = []; - for (const [label, tranches] of groups) { + 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); @@ -1496,14 +1538,29 @@ class FolkMortgageSimulator extends HTMLElement { // ─── 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': + 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; @@ -1515,20 +1572,27 @@ class FolkMortgageSimulator extends HTMLElement { case 'play-toggle': if (this.playing) { this._stopPlayback(); this.playing = false; } else this._startPlayback(); - this.render(); + this._updatePlayBtn(); break; case 'reset-month': this.currentMonth = 0; this._stopPlayback(); this.playing = false; - this.render(); + this._updatePlayBtn(); + this._updateView(); break; - case 'speed': + case 'speed': { this.speed = Number(target.dataset.speed); - this.render(); + // 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 }); @@ -1542,11 +1606,12 @@ class FolkMortgageSimulator extends HTMLElement { this._updateConfig({ reinvestmentRates: JSON.parse(target.dataset.rates!) }); break; - case 'select-tranche': + case 'select-tranche': { const id = target.dataset.trancheId!; this.selectedTrancheId = this.selectedTrancheId === id ? null : id; - this.render(); + this._updateView(); break; + } case 'select-tier': { const tierLabel = target.dataset.tierLabel!; @@ -1555,46 +1620,45 @@ class FolkMortgageSimulator extends HTMLElement { const first = tierTranches.find(t => t.status === 'active') ?? tierTranches[0]; if (first) { this.selectedTrancheId = this.selectedTrancheId === first.id ? null : first.id; - this.render(); + this._updateView(); } break; } case 'close-detail': this.selectedTrancheId = null; - this.render(); + this._updateView(); break; - case 'toggle-sale': { - // Toggle for-sale in simulation state (local only, visual feedback) - this.render(); + case 'toggle-sale': + this._updateView(); break; - } case 'grid-filter': this.gridFilter = target.dataset.filter as any; - this.render(); + this._updateView(); break; case 'grid-sort': this.gridSortBy = target.dataset.sort as any; - this.render(); + this._updateView(); break; case 'lender-strategy': this.lenderStrategy = target.dataset.strategy as any; - this.render(); + this._updateView(); break; - case 'borrower-expand': + case 'borrower-expand': { const idx = Number(target.dataset.idx); this.borrowerExpandedIdx = this.borrowerExpandedIdx === idx ? null : idx; - this.render(); + this._updateView(); break; + } } }); - // Range inputs + // 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; @@ -1605,31 +1669,68 @@ class FolkMortgageSimulator extends HTMLElement { case 'config-slider': { const key = target.dataset.key as keyof MortgageSimulatorConfig; const isPercent = target.dataset.percent === 'true'; - this._updateConfig({ [key]: isPercent ? val / 100 : val } as any); + 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._updateConfig({ lendingTiers: newTiers }); + 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.render(); + this._updateView(); break; case 'lender-investment': this.lenderInvestment = val; - this.render(); + this._updateView(); break; case 'borrower-budget': this.borrowerBudget = val; - this.render(); + this._updateView(); break; case 'borrower-down': this.borrowerDownPayment = val; - this.render(); + this._updateView(); break; } });