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 playing = false;
|
||||||
private speed = 1;
|
private speed = 1;
|
||||||
private playInterval: ReturnType<typeof setInterval> | null = null;
|
private playInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private advancedMode = false;
|
||||||
|
private _listenersAttached = false;
|
||||||
|
private _rafId: number | null = null;
|
||||||
|
|
||||||
// Calculator state
|
// Calculator state
|
||||||
private lenderInvestment = DEFAULT_CONFIG.trancheSize;
|
private lenderInvestment = DEFAULT_CONFIG.trancheSize;
|
||||||
|
|
@ -116,6 +119,7 @@ class FolkMortgageSimulator extends HTMLElement {
|
||||||
this.space = this.getAttribute('space') || 'demo';
|
this.space = this.getAttribute('space') || 'demo';
|
||||||
this._loadFromHash();
|
this._loadFromHash();
|
||||||
this._recompute();
|
this._recompute();
|
||||||
|
this._attachListeners();
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,14 +179,19 @@ class FolkMortgageSimulator extends HTMLElement {
|
||||||
if (this.currentMonth >= this.maxMonth) {
|
if (this.currentMonth >= this.maxMonth) {
|
||||||
this._stopPlayback();
|
this._stopPlayback();
|
||||||
this.playing = false;
|
this.playing = false;
|
||||||
this.render();
|
this._updatePlayBtn();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.currentMonth = Math.min(this.currentMonth + this.speed, this.maxMonth);
|
this.currentMonth = Math.min(this.currentMonth + this.speed, this.maxMonth);
|
||||||
this.render();
|
this._updateView();
|
||||||
}, 100);
|
}, 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() {
|
private _stopPlayback() {
|
||||||
if (this.playInterval) {
|
if (this.playInterval) {
|
||||||
clearInterval(this.playInterval);
|
clearInterval(this.playInterval);
|
||||||
|
|
@ -217,17 +226,42 @@ class FolkMortgageSimulator extends HTMLElement {
|
||||||
${this._renderHeader(state, numTranches)}
|
${this._renderHeader(state, numTranches)}
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
${this._renderControls(numTranches)}
|
${this._renderControls(numTranches)}
|
||||||
<main class="main">
|
<main class="main" id="viz">
|
||||||
${this._renderActiveView(state)}
|
${this._renderActiveView(state)}
|
||||||
${this._renderLenderDetail(state)}
|
${this._renderLenderDetail(state)}
|
||||||
</main>
|
</main>
|
||||||
<aside class="comparison">
|
<aside class="comparison" id="cmp">
|
||||||
${this._renderComparison(state)}
|
${this._renderComparison(state)}
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</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 ───────────────────────────────────────────
|
// ─── Styles ───────────────────────────────────────────
|
||||||
|
|
@ -247,6 +281,9 @@ class FolkMortgageSimulator extends HTMLElement {
|
||||||
.view-tab.active { background: #0284c7; color: #fff; }
|
.view-tab.active { background: #0284c7; color: #fff; }
|
||||||
.view-tab:not(.active) { background: #1e293b; color: #64748b; }
|
.view-tab:not(.active) { background: #1e293b; color: #64748b; }
|
||||||
.view-tab:not(.active):hover { background: #334155; color: #94a3b8; }
|
.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 */
|
||||||
.layout { max-width: 1600px; margin: 0 auto; display: flex; gap: 0; min-height: calc(100vh - 57px); }
|
.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]}
|
${labels[mode]}
|
||||||
</button>
|
</button>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
|
<button class="mode-toggle ${this.advancedMode ? 'advanced' : ''}" data-action="toggle-advanced">
|
||||||
|
${this.advancedMode ? 'Advanced' : 'Basic'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
`;
|
`;
|
||||||
|
|
@ -472,6 +512,7 @@ class FolkMortgageSimulator extends HTMLElement {
|
||||||
<div class="info-text">Loan: ${fmtCurrency(totalPrincipal)}</div>
|
<div class="info-text">Loan: ${fmtCurrency(totalPrincipal)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${this.advancedMode ? `
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-title">Tranches</div>
|
<div class="section-title">Tranches</div>
|
||||||
${this._slider('trancheSize', 'Size', c.trancheSize, 1000, 25000, 1000, fmtCurrency)}
|
${this._slider('trancheSize', 'Size', c.trancheSize, 1000, 25000, 1000, fmtCurrency)}
|
||||||
|
|
@ -537,6 +578,7 @@ class FolkMortgageSimulator extends HTMLElement {
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-title">Timeline</div>
|
<div class="section-title">Timeline</div>
|
||||||
|
|
@ -550,7 +592,7 @@ class FolkMortgageSimulator extends HTMLElement {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input type="range" min="0" max="${this.maxMonth}" value="${this.currentMonth}" data-action="month-scrub">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
@ -803,7 +845,7 @@ class FolkMortgageSimulator extends HTMLElement {
|
||||||
if (!lenderNodes.has(lid)) lenderNodes.set(lid, []);
|
if (!lenderNodes.has(lid)) lenderNodes.set(lid, []);
|
||||||
lenderNodes.get(lid)!.push(i);
|
lenderNodes.get(lid)!.push(i);
|
||||||
});
|
});
|
||||||
for (const [, indices] of lenderNodes) {
|
for (const [, indices] of Array.from(lenderNodes)) {
|
||||||
if (indices.length < 2) continue;
|
if (indices.length < 2) continue;
|
||||||
for (let j = 1; j < indices.length; j++) {
|
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]))) {
|
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; }
|
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[] = [];
|
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 active = tranches.filter(t => t.status === 'active');
|
||||||
const repaid = tranches.filter(t => t.status === 'repaid');
|
const repaid = tranches.filter(t => t.status === 'repaid');
|
||||||
const reinvesting = tranches.filter(t => t.reinvestmentRate != null);
|
const reinvesting = tranches.filter(t => t.reinvestmentRate != null);
|
||||||
|
|
@ -1496,14 +1538,29 @@ class FolkMortgageSimulator extends HTMLElement {
|
||||||
// ─── Event handling ───────────────────────────────────
|
// ─── Event handling ───────────────────────────────────
|
||||||
|
|
||||||
private _attachListeners() {
|
private _attachListeners() {
|
||||||
|
if (this._listenersAttached) return;
|
||||||
|
this._listenersAttached = true;
|
||||||
|
|
||||||
this.shadow.addEventListener('click', (e: Event) => {
|
this.shadow.addEventListener('click', (e: Event) => {
|
||||||
const target = (e.target as HTMLElement).closest('[data-action]') as HTMLElement | null;
|
const target = (e.target as HTMLElement).closest('[data-action]') as HTMLElement | null;
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
const action = target.dataset.action;
|
const action = target.dataset.action;
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'view':
|
case 'view': {
|
||||||
this.viewMode = target.dataset.view as ViewMode;
|
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();
|
this.render();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
@ -1515,20 +1572,27 @@ class FolkMortgageSimulator extends HTMLElement {
|
||||||
case 'play-toggle':
|
case 'play-toggle':
|
||||||
if (this.playing) { this._stopPlayback(); this.playing = false; }
|
if (this.playing) { this._stopPlayback(); this.playing = false; }
|
||||||
else this._startPlayback();
|
else this._startPlayback();
|
||||||
this.render();
|
this._updatePlayBtn();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'reset-month':
|
case 'reset-month':
|
||||||
this.currentMonth = 0;
|
this.currentMonth = 0;
|
||||||
this._stopPlayback();
|
this._stopPlayback();
|
||||||
this.playing = false;
|
this.playing = false;
|
||||||
this.render();
|
this._updatePlayBtn();
|
||||||
|
this._updateView();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'speed':
|
case 'speed': {
|
||||||
this.speed = Number(target.dataset.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;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'toggle-variable':
|
case 'toggle-variable':
|
||||||
this._updateConfig({ useVariableTerms: !this.config.useVariableTerms });
|
this._updateConfig({ useVariableTerms: !this.config.useVariableTerms });
|
||||||
|
|
@ -1542,11 +1606,12 @@ class FolkMortgageSimulator extends HTMLElement {
|
||||||
this._updateConfig({ reinvestmentRates: JSON.parse(target.dataset.rates!) });
|
this._updateConfig({ reinvestmentRates: JSON.parse(target.dataset.rates!) });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'select-tranche':
|
case 'select-tranche': {
|
||||||
const id = target.dataset.trancheId!;
|
const id = target.dataset.trancheId!;
|
||||||
this.selectedTrancheId = this.selectedTrancheId === id ? null : id;
|
this.selectedTrancheId = this.selectedTrancheId === id ? null : id;
|
||||||
this.render();
|
this._updateView();
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'select-tier': {
|
case 'select-tier': {
|
||||||
const tierLabel = target.dataset.tierLabel!;
|
const tierLabel = target.dataset.tierLabel!;
|
||||||
|
|
@ -1555,46 +1620,45 @@ class FolkMortgageSimulator extends HTMLElement {
|
||||||
const first = tierTranches.find(t => t.status === 'active') ?? tierTranches[0];
|
const first = tierTranches.find(t => t.status === 'active') ?? tierTranches[0];
|
||||||
if (first) {
|
if (first) {
|
||||||
this.selectedTrancheId = this.selectedTrancheId === first.id ? null : first.id;
|
this.selectedTrancheId = this.selectedTrancheId === first.id ? null : first.id;
|
||||||
this.render();
|
this._updateView();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'close-detail':
|
case 'close-detail':
|
||||||
this.selectedTrancheId = null;
|
this.selectedTrancheId = null;
|
||||||
this.render();
|
this._updateView();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'toggle-sale': {
|
case 'toggle-sale':
|
||||||
// Toggle for-sale in simulation state (local only, visual feedback)
|
this._updateView();
|
||||||
this.render();
|
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
|
|
||||||
case 'grid-filter':
|
case 'grid-filter':
|
||||||
this.gridFilter = target.dataset.filter as any;
|
this.gridFilter = target.dataset.filter as any;
|
||||||
this.render();
|
this._updateView();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'grid-sort':
|
case 'grid-sort':
|
||||||
this.gridSortBy = target.dataset.sort as any;
|
this.gridSortBy = target.dataset.sort as any;
|
||||||
this.render();
|
this._updateView();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'lender-strategy':
|
case 'lender-strategy':
|
||||||
this.lenderStrategy = target.dataset.strategy as any;
|
this.lenderStrategy = target.dataset.strategy as any;
|
||||||
this.render();
|
this._updateView();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'borrower-expand':
|
case 'borrower-expand': {
|
||||||
const idx = Number(target.dataset.idx);
|
const idx = Number(target.dataset.idx);
|
||||||
this.borrowerExpandedIdx = this.borrowerExpandedIdx === idx ? null : idx;
|
this.borrowerExpandedIdx = this.borrowerExpandedIdx === idx ? null : idx;
|
||||||
this.render();
|
this._updateView();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Range inputs
|
// Range inputs — debounced to avoid full DOM rebuilds during drag
|
||||||
this.shadow.addEventListener('input', (e: Event) => {
|
this.shadow.addEventListener('input', (e: Event) => {
|
||||||
const target = e.target as HTMLInputElement;
|
const target = e.target as HTMLInputElement;
|
||||||
if (!target.dataset.action) return;
|
if (!target.dataset.action) return;
|
||||||
|
|
@ -1605,31 +1669,68 @@ class FolkMortgageSimulator extends HTMLElement {
|
||||||
case 'config-slider': {
|
case 'config-slider': {
|
||||||
const key = target.dataset.key as keyof MortgageSimulatorConfig;
|
const key = target.dataset.key as keyof MortgageSimulatorConfig;
|
||||||
const isPercent = target.dataset.percent === 'true';
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case 'tier-alloc': {
|
case 'tier-alloc': {
|
||||||
const tierIdx = Number(target.dataset.tier);
|
const tierIdx = Number(target.dataset.tier);
|
||||||
const newTiers = [...this.config.lendingTiers];
|
const newTiers = [...this.config.lendingTiers];
|
||||||
newTiers[tierIdx] = { ...newTiers[tierIdx], allocation: val / 100 };
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case 'month-scrub':
|
case 'month-scrub':
|
||||||
this.currentMonth = val;
|
this.currentMonth = val;
|
||||||
this.render();
|
this._updateView();
|
||||||
break;
|
break;
|
||||||
case 'lender-investment':
|
case 'lender-investment':
|
||||||
this.lenderInvestment = val;
|
this.lenderInvestment = val;
|
||||||
this.render();
|
this._updateView();
|
||||||
break;
|
break;
|
||||||
case 'borrower-budget':
|
case 'borrower-budget':
|
||||||
this.borrowerBudget = val;
|
this.borrowerBudget = val;
|
||||||
this.render();
|
this._updateView();
|
||||||
break;
|
break;
|
||||||
case 'borrower-down':
|
case 'borrower-down':
|
||||||
this.borrowerDownPayment = val;
|
this.borrowerDownPayment = val;
|
||||||
this.render();
|
this._updateView();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue