Merge branch 'dev'
CI/CD / deploy (push) Failing after 2m22s
Details
CI/CD / deploy (push) Failing after 2m22s
Details
This commit is contained in:
commit
218ee73993
|
|
@ -95,6 +95,9 @@ class FolkMortgageSimulator extends HTMLElement {
|
|||
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;
|
||||
|
|
@ -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)}
|
||||
<div class="layout">
|
||||
${this._renderControls(numTranches)}
|
||||
<main class="main">
|
||||
<main class="main" id="viz">
|
||||
${this._renderActiveView(state)}
|
||||
${this._renderLenderDetail(state)}
|
||||
</main>
|
||||
<aside class="comparison">
|
||||
<aside class="comparison" id="cmp">
|
||||
${this._renderComparison(state)}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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]}
|
||||
</button>
|
||||
`).join('')}
|
||||
<button class="mode-toggle ${this.advancedMode ? 'advanced' : ''}" data-action="toggle-advanced">
|
||||
${this.advancedMode ? 'Advanced' : 'Basic'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
`;
|
||||
|
|
@ -472,6 +512,7 @@ class FolkMortgageSimulator extends HTMLElement {
|
|||
<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)}
|
||||
|
|
@ -537,6 +578,7 @@ class FolkMortgageSimulator extends HTMLElement {
|
|||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Timeline</div>
|
||||
|
|
@ -550,7 +592,7 @@ class FolkMortgageSimulator extends HTMLElement {
|
|||
</div>
|
||||
</div>
|
||||
<input type="range" min="0" max="${this.maxMonth}" value="${this.currentMonth}" data-action="month-scrub">
|
||||
<div class="timeline-label">Month ${this.currentMonth} (${years}y ${months}m)</div>
|
||||
<div class="timeline-label" id="tl-label">Month ${this.currentMonth} (${years}y ${months}m)</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue