From 489e3424d0c22acce1a26f4fe161f5bf56db72a8 Mon Sep 17 00:00:00 2001 From: SujalKamate Date: Mon, 15 Jun 2026 23:55:05 +0530 Subject: [PATCH] feat: add EMI Loan Planner --- Projects/EMI Loan Planner/README.md | 53 ++ Projects/EMI Loan Planner/index.html | 239 ++++++++ Projects/EMI Loan Planner/project.json | 17 + Projects/EMI Loan Planner/script.js | 717 ++++++++++++++++++++++++ Projects/EMI Loan Planner/style.css | 623 ++++++++++++++++++++ Projects/EMI Loan Planner/thumbnail.svg | 82 +++ 6 files changed, 1731 insertions(+) create mode 100644 Projects/EMI Loan Planner/README.md create mode 100644 Projects/EMI Loan Planner/index.html create mode 100644 Projects/EMI Loan Planner/project.json create mode 100644 Projects/EMI Loan Planner/script.js create mode 100644 Projects/EMI Loan Planner/style.css create mode 100644 Projects/EMI Loan Planner/thumbnail.svg diff --git a/Projects/EMI Loan Planner/README.md b/Projects/EMI Loan Planner/README.md new file mode 100644 index 00000000..1fbf4c1f --- /dev/null +++ b/Projects/EMI Loan Planner/README.md @@ -0,0 +1,53 @@ +# EMI Loan Planner + +An interactive personal finance utility and loan amortization planner. It calculates Equated Monthly Installments (EMI), models standard and custom prepayments (monthly, annual, and one-time), projects interest and tenure savings, and draws canvas-based visuals. + +--- + +## Amortization Mathematics & Prepayments + +The calculator runs dual simulations (a baseline without prepayments vs. an active plan with prepayments) to evaluate savings. + +### 1. Equated Monthly Installment (EMI) +Calculated using the standard compounding annuity formula: + +$$EMI = P \times r \times \frac{(1 + r)^n}{(1 + r)^n - 1}$$ + +Where: +- $P$: Loan principal amount. +- $r$: Monthly interest rate ($R_{\text{annual}} / 12 / 100$). +- $n$: Loan tenure in months ($\text{Years} \times 12$). + +### 2. Month-by-Month Amortization Schedule +For each month $m = 1, 2, ...$: +- **Interest Portion**: + $$Interest_m = Balance_{m-1} \times r$$ +- **Principal Portion**: + $$Principal\_Paid_m = \min(EMI - Interest_m, Balance_{m-1})$$ +- **Extra Prepayment ($PP_m$)**: Any extra principal prepaid at month $m$. +- **Ending Balance**: + $$Balance_m = Balance_{m-1} - Principal\_Paid_m - PP_m$$ + +The loop terminates when $Balance_m \le 0$ or when the max term is reached. + +### 3. Savings Calculations +- **Interest Saved**: + $$\text{Interest Saved} = \sum Interest_{\text{baseline}} - \sum Interest_{\text{active}}$$ +- **Tenure Saved**: + $$\text{Tenure Saved} = Tenure_{\text{baseline}} - Tenure_{\text{active}} \quad (\text{months})$$ + +--- + +## Prepayment Modes Supported + +1. **Monthly Prepayment**: An extra fixed amount added to the principal repayment every month. +2. **Annual Prepayment**: An extra lump sum paid at the end of each year (every 12th month). +3. **One-Time Prepayment**: A single lump sum payment credited at a specific month/year. + +--- + +## Standalone Execution + +1. Clone or download this directory. +2. Open `index.html` in any modern web browser to launch. +3. No server or internet connection required. diff --git a/Projects/EMI Loan Planner/index.html b/Projects/EMI Loan Planner/index.html new file mode 100644 index 00000000..565456d4 --- /dev/null +++ b/Projects/EMI Loan Planner/index.html @@ -0,0 +1,239 @@ + + + + + + EMI Loan Planner - Dynamic Amortization Workspace + + + + + + + +
+ +
+
+ +
+

EMI LOAN PLANNER PRO

+

AMORTIZATION SCHEDULER & PREPAYMENT SAVINGS ENGINE

+
+
+ + +
+ + +
+
+ +
+ + + + +
+ +
+
+ MONTHLY EMI PAYMENT + $0 + Baseline EMI: $0 +
+
+ TOTAL INTEREST PAYABLE + $0 + 0% of total payment +
+
+ TOTAL INTEREST SAVED + $0 + Total Prepayments: $0 +
+
+ TENURE TIME SAVED + 0 Months + Paid in 0 years +
+
+ + +
+
+

TOTAL LOAN COST DIVISION

+
+ +
+
+
Principal: 0.0%
+
Interest: 0.0%
+
Prepayments: 0.0%
+
+
+ +
+

AMORTIZATION TIMELINE & BALANCE CURVE

+
+ +
+
+
+
+
+ + +
+
+

LOAN AMORTIZATION SCHEDULE

+
+
+ VIEW BY: + + +
+ +
+
+ +
+ + + + + + + + + + + + + + + +
YearStarting BalanceEMI PaidPrepaymentPrincipal PaidInterest PaidEnding Balance
+
+
+
+ + + + diff --git a/Projects/EMI Loan Planner/project.json b/Projects/EMI Loan Planner/project.json new file mode 100644 index 00000000..3248e722 --- /dev/null +++ b/Projects/EMI Loan Planner/project.json @@ -0,0 +1,17 @@ +{ + "title": "EMI Loan Planner", + "description": "A premium personal finance workspace to calculate EMI, simulate regular/one-time prepayments, track interest savings, and export amortization schedules.", + "entry": "index.html", + "tags": [ + "finance", + "calculator", + "loan-planner", + "data-visualization", + "canvas" + ], + "author": { + "name": "Sujal Kamate", + "github": "SujalKamate" + }, + "thumbnail": "thumbnail.svg" +} diff --git a/Projects/EMI Loan Planner/script.js b/Projects/EMI Loan Planner/script.js new file mode 100644 index 00000000..f438c250 --- /dev/null +++ b/Projects/EMI Loan Planner/script.js @@ -0,0 +1,717 @@ +/** + * EMI LOAN PLANNER + * Core Logic: Monthly Compounding, Extra Prepayments, Custom Charting & Amortization Scheduling + */ + +// --- Global Simulation State --- +const state = { + loanAmount: 250000, + interestRate: 6.5, + tenure: 30, // years + + // Prepayment sliders + monthlyPrepay: 0, + annualPrepay: 0, + onetimePrepay: 0, + onetimeMonth: 12, + + // Table view mode + viewMode: 'YEARLY', // 'YEARLY' or 'MONTHLY' + + // Calculations + emi: 0, + baselineInterest: 0, + activeInterest: 0, + savedInterest: 0, + baselineMonths: 360, + activeMonths: 360, + savedTenureMonths: 0, + totalPrepayments: 0, + + // Schedule matrices + monthlySchedule: [], + yearlySchedule: [], + + // Canvas + donutCanvas: null, + donutCtx: null, + areaCanvas: null, + areaCtx: null, + + // Hover indices + hoverTimeIndex: -1 // year or month index depending on viewMode +}; + +// Preset Profiles +const presets = { + home: { loanAmount: 250000, interestRate: 6.5, tenure: 30 }, + car: { loanAmount: 30000, interestRate: 5.0, tenure: 5 }, + student: { loanAmount: 50000, interestRate: 4.5, tenure: 10 }, + personal: { loanAmount: 15000, interestRate: 12.0, tenure: 3 } +}; + +// --- Initialization --- +document.addEventListener('DOMContentLoaded', () => { + initDOMReferences(); + setupSyncListeners(); + setupCanvasElements(); + loadPresetValues('home'); // load default mortgage preset + recalculateAll(); +}); + +function initDOMReferences() { + // Preset Select + document.getElementById('presetSelect').addEventListener('change', (e) => { + if (e.target.value !== 'custom') { + loadPresetValues(e.target.value); + recalculateAll(); + } + }); + + // Table View mode buttons + const viewAnnual = document.getElementById('viewAnnualBtn'); + const viewMonthly = document.getElementById('viewMonthlyBtn'); + + viewAnnual.addEventListener('click', () => { + if (state.viewMode === 'YEARLY') return; + state.viewMode = 'YEARLY'; + viewAnnual.classList.add('active'); + viewMonthly.classList.remove('active'); + document.getElementById('colTimeHeader').innerText = 'Year'; + buildAmortizationTable(); + drawAreaChart(); + }); + + viewMonthly.addEventListener('click', () => { + if (state.viewMode === 'MONTHLY') return; + state.viewMode = 'MONTHLY'; + viewMonthly.classList.add('active'); + viewAnnual.classList.remove('active'); + document.getElementById('colTimeHeader').innerText = 'Month'; + buildAmortizationTable(); + drawAreaChart(); + }); + + // Export CSV + document.getElementById('downloadCsvBtn').addEventListener('click', exportCSV); + + // Area Chart Interactivity + const areaChart = document.getElementById('areaChart'); + areaChart.addEventListener('mousemove', handleChartHover); + areaChart.addEventListener('mouseleave', clearChartHover); +} + +function setupCanvasElements() { + state.donutCanvas = document.getElementById('donutChart'); + state.donutCtx = state.donutCanvas.getContext('2d'); + + state.areaCanvas = document.getElementById('areaChart'); + state.areaCtx = state.areaCanvas.getContext('2d'); +} + +// Synchronize sliders and numeric input boxes +function setupSyncListeners() { + const syncWidgets = (sliderId, boxId, stateKey, isFloat = false) => { + const slider = document.getElementById(sliderId); + const box = document.getElementById(boxId); + + slider.addEventListener('input', (e) => { + const val = isFloat ? parseFloat(e.target.value) : parseInt(e.target.value); + state[stateKey] = val; + box.value = val; + + document.getElementById('presetSelect').value = 'custom'; + recalculateAll(); + }); + + box.addEventListener('input', (e) => { + let val = isFloat ? parseFloat(e.target.value) : parseInt(e.target.value); + if (isNaN(val) || val < 0) val = 0; + state[stateKey] = val; + slider.value = Math.min(slider.max, val); // bound check slider + + document.getElementById('presetSelect').value = 'custom'; + recalculateAll(); + }); + }; + + syncWidgets('inputLoanAmount', 'boxLoanAmount'); + syncWidgets('inputInterestRate', 'boxInterestRate', 'interestRate', true); + syncWidgets('inputTenure', 'boxTenure'); + + // Prepayments + syncWidgets('inputMonthlyPrepay', 'boxMonthlyPrepay'); + syncWidgets('inputAnnualPrepay', 'boxAnnualPrepay'); + syncWidgets('inputOnetimePrepay', 'boxOnetimePrepay'); + syncWidgets('inputOnetimeMonth', 'boxOnetimeMonth'); +} + +function loadPresetValues(name) { + const p = presets[name]; + if (!p) return; + + state.loanAmount = p.loanAmount; + state.interestRate = p.interestRate; + state.tenure = p.tenure; + + // Sync inputs + const syncValues = (sliderId, boxId, val) => { + document.getElementById(sliderId).value = val; + document.getElementById(boxId).value = val; + }; + + syncValues('inputLoanAmount', 'boxLoanAmount', p.loanAmount); + syncValues('inputInterestRate', 'boxInterestRate', p.interestRate); + syncValues('inputTenure', 'boxTenure', p.tenure); + + // Set prepayments back to 0 by default when a preset is loaded + state.monthlyPrepay = 0; + state.annualPrepay = 0; + state.onetimePrepay = 0; + state.onetimeMonth = 12; + + syncValues('inputMonthlyPrepay', 'boxMonthlyPrepay', 0); + syncValues('inputAnnualPrepay', 'boxAnnualPrepay', 0); + syncValues('inputOnetimePrepay', 'boxOnetimePrepay', 0); + syncValues('inputOnetimeMonth', 'boxOnetimeMonth', 12); +} + +// --- Compounding Calculation Engine --- + +function recalculateAll() { + const monthlyRate = state.interestRate / 12 / 100; + const totalMonths = state.tenure * 12; + + // 1. Calculate base EMI payment + if (monthlyRate === 0) { + state.emi = state.loanAmount / totalMonths; + } else { + state.emi = state.loanAmount * monthlyRate * Math.pow(1 + monthlyRate, totalMonths) / (Math.pow(1 + monthlyRate, totalMonths) - 1); + } + + // 2. Perform Baseline Run (No Prepayments) + runBaselineSimulation(monthlyRate, totalMonths); + + // 3. Perform Active Run (With Prepayments) + runActiveSimulation(monthlyRate, totalMonths); + + // 4. Summarize yearly matrix + buildYearlySummary(); + + // 5. Update Indicators + updateUI(); +} + +function runBaselineSimulation(r, totalMonths) { + let balance = state.loanAmount; + let accumulatedInterest = 0; + let monthsCount = 0; + + for (let m = 1; m <= totalMonths; m++) { + const interest = balance * r; + const principal = Math.min(state.emi - interest, balance); + + accumulatedInterest += interest; + balance -= principal; + monthsCount++; + + if (balance <= 0) break; + } + + state.baselineInterest = accumulatedInterest; + state.baselineMonths = monthsCount; +} + +function runActiveSimulation(r, totalMonths) { + state.monthlySchedule = []; + state.totalPrepayments = 0; + + let balance = state.loanAmount; + let accumulatedInterest = 0; + let monthsCount = 0; + + for (let m = 1; m <= totalMonths; m++) { + const startingBalance = balance; + const interest = balance * r; + const basePrincipal = Math.min(state.emi - interest, balance); + + let prepayment = 0; + + // Evaluate extra prepayments + if (balance - basePrincipal > 0) { + // Monthly extra + prepayment += Math.min(state.monthlyPrepay, balance - basePrincipal); + + // Annual extra (ends of year) + if (m % 12 === 0) { + prepayment += Math.min(state.annualPrepay, balance - basePrincipal - prepayment); + } + + // One-time extra + if (m === state.onetimeMonth) { + prepayment += Math.min(state.onetimePrepay, balance - basePrincipal - prepayment); + } + } + + const principalPaid = basePrincipal + prepayment; + const endingBalance = Math.max(0, balance - principalPaid); + + accumulatedInterest += interest; + state.totalPrepayments += prepayment; + + state.monthlySchedule.push({ + month: m, + startingBalance: startingBalance, + emiPaid: basePrincipal + interest, + prepayment: prepayment, + principalPaid: principalPaid, + interestPaid: interest, + endingBalance: endingBalance + }); + + balance = endingBalance; + monthsCount++; + + if (balance <= 0) break; + } + + state.activeInterest = accumulatedInterest; + state.activeMonths = monthsCount; + + // Calculate final savings + state.savedInterest = Math.max(0, state.baselineInterest - state.activeInterest); + state.savedTenureMonths = Math.max(0, state.baselineMonths - state.activeMonths); +} + +function buildYearlySummary() { + state.yearlySchedule = []; + + let yearNum = 1; + let yearPrincipal = 0; + let yearInterest = 0; + let yearPrepay = 0; + let yearEmi = 0; + let startingBalance = state.loanAmount; + + state.monthlySchedule.forEach((row, idx) => { + yearPrincipal += row.principalPaid; + yearInterest += row.interestPaid; + yearPrepay += row.prepayment; + yearEmi += row.emiPaid; + + // End of year or last record + if (row.month % 12 === 0 || idx === state.monthlySchedule.length - 1) { + state.yearlySchedule.push({ + year: yearNum, + startingBalance: startingBalance, + emiPaid: yearEmi, + prepayment: yearPrepay, + principalPaid: yearPrincipal, + interestPaid: yearInterest, + endingBalance: row.endingBalance + }); + + // Reset + yearNum++; + yearPrincipal = 0; + yearInterest = 0; + yearPrepay = 0; + yearEmi = 0; + startingBalance = row.endingBalance; + } + }); +} + +// --- UI Indicators --- + +function updateUI() { + const formatCurrency = (val) => '$' + Math.round(val).toLocaleString(); + + // Primary KPIs + document.getElementById('kpiEmi').innerText = formatCurrency(state.emi); + document.getElementById('kpiBaseEmi').innerText = `Baseline EMI: ${formatCurrency(state.emi)}`; + + document.getElementById('kpiInterest').innerText = formatCurrency(state.activeInterest); + const totalPaid = state.loanAmount + state.activeInterest + state.totalPrepayments; + const interestPct = totalPaid > 0 ? (state.activeInterest / totalPaid * 100) : 0; + document.getElementById('kpiInterestPct').innerText = `${interestPct.toFixed(1)}% of total payment`; + + document.getElementById('kpiSavedInterest').innerText = formatCurrency(state.savedInterest); + document.getElementById('kpiPrepaymentTotal').innerText = `Total Prepayments: ${formatCurrency(state.totalPrepayments)}`; + + // Tenure saved + const yearsSaved = Math.floor(state.savedTenureMonths / 12); + const monthsSaved = state.savedTenureMonths % 12; + let savedString = ''; + if (yearsSaved > 0) savedString += `${yearsSaved} Yr `; + savedString += `${monthsSaved} Mo`; + document.getElementById('kpiSavedTenure').innerText = state.savedTenureMonths > 0 ? savedString : '0 Months'; + + const activeY = (state.activeMonths / 12).toFixed(1); + document.getElementById('kpiActiveTenure').innerText = `Paid in ${activeY} years`; + + // Donut Percentages + const principalPct = totalPaid > 0 ? (state.loanAmount / totalPaid * 100) : 0; + const prepayPct = totalPaid > 0 ? (state.totalPrepayments / totalPaid * 100) : 0; + + document.getElementById('legendPctPrincipal').innerText = principalPct.toFixed(1) + '%'; + document.getElementById('legendPctInterest').innerText = interestPct.toFixed(1) + '%'; + document.getElementById('legendPctPrepay').innerText = prepayPct.toFixed(1) + '%'; + + // Build table view + buildAmortizationTable(); + + // Draw Charts + drawDonutChart(principalPct, interestPct, prepayPct); + drawAreaChart(); +} + +function buildAmortizationTable() { + const body = document.getElementById('tableBody'); + body.innerHTML = ''; + + const formatCurrency = (val) => '$' + Math.round(val).toLocaleString(); + const schedule = state.viewMode === 'YEARLY' ? state.yearlySchedule : state.monthlySchedule; + + schedule.forEach(row => { + const tr = document.createElement('tr'); + const label = state.viewMode === 'YEARLY' ? `Year ${row.year}` : `Month ${row.month}`; + tr.innerHTML = ` + ${label} + ${formatCurrency(row.startingBalance)} + ${formatCurrency(row.emiPaid)} + ${formatCurrency(row.prepayment)} + ${formatCurrency(row.principalPaid)} + ${formatCurrency(row.interestPaid)} + ${formatCurrency(row.endingBalance)} + `; + body.appendChild(tr); + }); +} + +// --- Canvas Charts drawing --- + +function drawDonutChart(principalPct, interestPct, prepayPct) { + const ctx = state.donutCtx; + const w = state.donutCanvas.width; + const h = state.donutCanvas.height; + + ctx.clearRect(0, 0, w, h); + + const cx = w / 2; + const cy = h / 2; + const radius = 80; + const thickness = 22; + + const startAng = -Math.PI / 2; + const principalRad = (principalPct / 100) * (Math.PI * 2); + const interestRad = (interestPct / 100) * (Math.PI * 2); + const prepayRad = (prepayPct / 100) * (Math.PI * 2); + + // 1. Principal Segment (Indigo Blue) + ctx.strokeStyle = '#3d5afe'; + ctx.lineWidth = thickness; + ctx.beginPath(); + ctx.arc(cx, cy, radius, startAng, startAng + principalRad); + ctx.stroke(); + + // 2. Interest Segment (Gold Amber) + ctx.strokeStyle = '#ffb300'; + ctx.lineWidth = thickness; + ctx.beginPath(); + ctx.arc(cx, cy, radius, startAng + principalRad, startAng + principalRad + interestRad); + ctx.stroke(); + + // 3. Prepayments Segment (Cyan) + if (prepayRad > 0) { + ctx.strokeStyle = '#00e5ff'; + ctx.lineWidth = thickness; + ctx.beginPath(); + ctx.arc(cx, cy, radius, startAng + principalRad + interestRad, startAng + Math.PI * 2); + ctx.stroke(); + } + + // Text center values + ctx.fillStyle = '#f8fafc'; + ctx.font = 'bold 15px Outfit'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + const totalCost = state.loanAmount + state.activeInterest + state.totalPrepayments; + ctx.fillText('$' + Math.round(totalCost / 1000).toLocaleString() + 'k', cx, cy - 8); + + ctx.fillStyle = '#94a3b8'; + ctx.font = '8px Outfit'; + ctx.fillText('TOTAL COST OF LOAN', cx, cy + 12); +} + +function drawAreaChart() { + const ctx = state.areaCtx; + const w = state.areaCanvas.width; + const h = state.areaCanvas.height; + + ctx.clearRect(0, 0, w, h); + + const paddingLeft = 60; + const paddingRight = 20; + const paddingTop = 20; + const paddingBottom = 30; + + const chartW = w - paddingLeft - paddingRight; + const chartH = h - paddingTop - paddingBottom; + + const schedule = state.viewMode === 'YEARLY' ? state.yearlySchedule : state.monthlySchedule; + const size = schedule.length; + if (size === 0) return; + + const totalTerms = state.viewMode === 'YEARLY' ? state.tenure : state.tenure * 12; + const maxVal = state.loanAmount; + + const getX = (t) => paddingLeft + (t / totalTerms) * chartW; + const getY = (v) => h - paddingBottom - (v / maxVal) * chartH; + + // Grid Lines + ctx.strokeStyle = 'rgba(255,255,255,0.03)'; + ctx.lineWidth = 1; + for (let i = 1; i <= 4; i++) { + const val = (maxVal / 4) * i; + const y = getY(val); + ctx.beginPath(); + ctx.moveTo(paddingLeft, y); + ctx.lineTo(w - paddingRight, y); + ctx.stroke(); + + ctx.fillStyle = '#64748b'; + ctx.font = '9.5px monospace'; + ctx.textAlign = 'right'; + ctx.fillText('$' + Math.round(val / 1000).toLocaleString() + 'k', paddingLeft - 8, y + 3); + } + + // X-axis Markers + const step = Math.max(1, Math.round(totalTerms / 5)); + for (let t = 0; t <= totalTerms; t += step) { + const x = getX(t); + ctx.beginPath(); + ctx.moveTo(x, paddingTop); + ctx.lineTo(x, h - paddingBottom); + ctx.stroke(); + + ctx.fillStyle = '#94a3b8'; + ctx.font = '10px Outfit'; + ctx.textAlign = 'center'; + const label = state.viewMode === 'YEARLY' ? `Yr ${t}` : `Mo ${t}`; + ctx.fillText(label, x, h - paddingBottom + 16); + } + + // Draw Area for Declining Balance (Active Prepayment curve) + // Fill under active balance + const activePoints = [{ x: getX(0), y: getY(state.loanAmount) }]; + schedule.forEach(row => { + const idx = state.viewMode === 'YEARLY' ? row.year : row.month; + activePoints.push({ x: getX(idx), y: getY(row.endingBalance) }); + }); + + ctx.fillStyle = 'rgba(61, 90, 254, 0.15)'; + ctx.beginPath(); + ctx.moveTo(getX(0), getY(0)); + activePoints.forEach(pt => ctx.lineTo(pt.x, pt.y)); + ctx.lineTo(activePoints[activePoints.length - 1].x, getY(0)); + ctx.closePath(); + ctx.fill(); + + // Draw Stroke line curves + // 1. Baseline curve (No prepayment - dashed gray) + ctx.strokeStyle = '#475569'; + ctx.lineWidth = 1.5; + ctx.setLineDash([3, 3]); + ctx.beginPath(); + ctx.moveTo(getX(0), getY(state.loanAmount)); + + // Calculate simple baseline curve coordinates + const r = state.interestRate / 12 / 100; + const baseTotalMonths = state.tenure * 12; + let baseB = state.loanAmount; + + if (state.viewMode === 'YEARLY') { + for (let y = 1; y <= state.tenure; y++) { + for (let m = 1; m <= 12; m++) { + const interest = baseB * r; + const principal = Math.min(state.emi - interest, baseB); + baseB = Math.max(0, baseB - principal); + } + ctx.lineTo(getX(y), getY(baseB)); + } + } else { + for (let m = 1; m <= baseTotalMonths; m++) { + const interest = baseB * r; + const principal = Math.min(state.emi - interest, baseB); + baseB = Math.max(0, baseB - principal); + ctx.lineTo(getX(m), getY(baseB)); + } + } + ctx.stroke(); + ctx.setLineDash([]); // clear dash + + // 2. Active prepayment curve (Solid Indigo Blue) + ctx.strokeStyle = '#3d5afe'; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.moveTo(activePoints[0].x, activePoints[0].y); + for (let i = 1; i < activePoints.length; i++) { + ctx.lineTo(activePoints[i].x, activePoints[i].y); + } + ctx.stroke(); + + // Border bounds + ctx.strokeStyle = 'rgba(63, 81, 181, 0.15)'; + ctx.lineWidth = 1; + ctx.strokeRect(paddingLeft, paddingTop, chartW, chartH); + + // If hovered crosshair is active + if (state.hoverTimeIndex !== -1 && state.hoverTimeIndex <= totalTerms) { + drawHoverLine(getX, getY, paddingLeft, paddingTop, chartH, totalTerms); + } +} + +// --- Hover Details Line crosshairs --- + +function handleChartHover(e) { + const rect = state.areaCanvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const w = state.areaCanvas.width; + + const paddingLeft = 60; + const paddingRight = 20; + const chartW = w - paddingLeft - paddingRight; + + if (mx < paddingLeft || mx > w - paddingRight || state.monthlySchedule.length === 0) { + clearChartHover(); + return; + } + + const totalTerms = state.viewMode === 'YEARLY' ? state.tenure : state.tenure * 12; + const relativeX = (mx - paddingLeft) / chartW; + const timeHover = Math.round(relativeX * totalTerms); + + if (timeHover !== state.hoverTimeIndex) { + state.hoverTimeIndex = timeHover; + drawAreaChart(); + } +} + +function drawHoverLine(getX, getY, paddingLeft, paddingTop, chartH, totalTerms) { + const ctx = state.areaCtx; + const w = state.areaCanvas.width; + const tIdx = state.hoverTimeIndex; + + const x = getX(tIdx); + + // vertical line + ctx.strokeStyle = 'rgba(255, 255, 255, 0.25)'; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + ctx.beginPath(); + ctx.moveTo(x, paddingTop); + ctx.lineTo(x, paddingTop + chartH); + ctx.stroke(); + ctx.setLineDash([]); + + // Fetch projection record for this index + const schedule = state.viewMode === 'YEARLY' ? state.yearlySchedule : state.monthlySchedule; + const key = state.viewMode === 'YEARLY' ? 'year' : 'month'; + + let record = schedule.find(r => r[key] === tIdx); + let balance = state.loanAmount; + let principalPaid = 0; + let interestPaid = 0; + let prepayment = 0; + + if (tIdx > 0) { + if (record) { + balance = record.endingBalance; + principalPaid = record.principalPaid; + interestPaid = record.interestPaid; + prepayment = record.prepayment; + } else { + // Loan already paid off by this time + balance = 0; + } + } + + // Draw dots + ctx.fillStyle = '#3d5afe'; + ctx.beginPath(); + ctx.arc(x, getY(balance), 4.5, 0, Math.PI * 2); + ctx.fill(); + + // Tooltip Box + const formatCurrency = (val) => '$' + Math.round(val).toLocaleString(); + + ctx.fillStyle = 'rgba(9, 14, 23, 0.95)'; + ctx.strokeStyle = 'var(--color-border)'; + ctx.lineWidth = 1.5; + + const boxW = 125; + const boxH = 68; + const boxX = x > w / 2 ? x - boxW - 10 : x + 10; + const boxY = paddingTop + 10; + + ctx.fillRect(boxX, boxY, boxW, boxH); + ctx.strokeRect(boxX, boxY, boxW, boxH); + + ctx.font = 'bold 8.5px monospace'; + ctx.fillStyle = 'var(--color-primary)'; + ctx.textAlign = 'left'; + + const label = state.viewMode === 'YEARLY' ? `YEAR ${tIdx}` : `MONTH ${tIdx}`; + ctx.fillText(label, boxX + 8, boxY + 12); + + ctx.font = '8px monospace'; + ctx.fillStyle = 'var(--color-text-secondary)'; + ctx.fillText(`Bal Remaining: ${formatCurrency(balance)}`, boxX + 8, boxY + 24); + + if (record) { + ctx.fillStyle = 'var(--color-primary)'; + ctx.fillText(`Principal Paid: ${formatCurrency(principalPaid)}`, boxX + 8, boxY + 35); + ctx.fillStyle = 'var(--color-interest)'; + ctx.fillText(`Interest Paid: ${formatCurrency(interestPaid)}`, boxX + 8, boxY + 46); + ctx.fillStyle = 'var(--color-prepay)'; + ctx.fillText(`Prepayment: ${formatCurrency(prepayment)}`, boxX + 8, boxY + 57); + } else { + ctx.fillStyle = 'var(--color-savings)'; + ctx.fillText('LOAN FULLY PAID OFF', boxX + 8, boxY + 35); + } +} + +function clearChartHover() { + state.hoverTimeIndex = -1; + drawAreaChart(); +} + +// --- CSV Exporter --- + +function exportCSV() { + if (state.monthlySchedule.length === 0) return; + + let csv = 'Payment Month,Starting Balance,Regular EMI Paid,Extra Prepayment,Principal Component,Interest Component,Ending Balance\n'; + + state.monthlySchedule.forEach(row => { + csv += `${row.month},`; + csv += `${Math.round(row.startingBalance)},`; + csv += `${Math.round(row.emiPaid)},`; + csv += `${Math.round(row.prepayment)},`; + csv += `${Math.round(row.principalPaid)},`; + csv += `${Math.round(row.interestPaid)},`; + csv += `${Math.round(row.endingBalance)}\n`; + }); + + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.setAttribute('href', url); + link.setAttribute('download', `loan_amortization_schedule_${state.tenure}_years.csv`); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} diff --git a/Projects/EMI Loan Planner/style.css b/Projects/EMI Loan Planner/style.css new file mode 100644 index 00000000..0d04fb94 --- /dev/null +++ b/Projects/EMI Loan Planner/style.css @@ -0,0 +1,623 @@ +/* Style Sheet: EMI Loan Planner */ + +:root { + /* Colors */ + --color-bg-dark: #080a0f; + --color-bg-panel: rgba(14, 21, 33, 0.82); + --color-bg-element: rgba(22, 34, 52, 0.9); + --color-border: rgba(63, 81, 181, 0.15); + --color-border-active: rgba(63, 81, 181, 0.45); + + --color-text-primary: #f8fafc; + --color-text-secondary: #94a3b8; + --color-text-muted: #475569; + + /* Financial Layout Theme Colors */ + --color-primary: #3d5afe; /* Principal - Electric Indigo/Blue */ + --color-primary-dim: rgba(61, 90, 254, 0.15); + + --color-interest: #ffb300; /* Interest portion - Gold/Amber */ + --color-interest-dim: rgba(255, 179, 0, 0.15); + + --color-prepay: #00e5ff; /* Prepayments - Cyan */ + + --color-savings: #00e676; /* Savings - Hazard Green */ + --color-savings-dim: rgba(0, 230, 118, 0.15); + + /* Fonts */ + --font-title: 'Outfit', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-body: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + + /* Layout */ + --radius-lg: 12px; + --radius-md: 8px; + --radius-sm: 4px; + --shadow-neon: 0 0 15px rgba(61, 90, 254, 0.15); +} + +/* Base Settings */ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + background-color: var(--color-bg-dark); + color: var(--color-text-primary); + font-family: var(--font-body); + font-size: 14px; + line-height: 1.5; + min-height: 100vh; + padding: 20px; + position: relative; + overflow-x: hidden; +} + +/* Grid pattern behind elements */ +body::before { + content: ""; + position: absolute; + inset: 0; + background-image: + linear-gradient(rgba(61, 90, 254, 0.02) 1px, transparent 1px), + linear-gradient(90deg, rgba(61, 90, 254, 0.02) 1px, transparent 1px); + background-size: 30px 30px; + z-index: 0; + pointer-events: none; +} + +.app-container { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + max-width: 1500px; + margin: 0 auto; + gap: 16px; +} + +/* Header styling */ +.dashboard-header { + background: var(--color-bg-panel); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 16px 24px; + display: flex; + justify-content: space-between; + align-items: center; + backdrop-filter: blur(12px); + box-shadow: var(--shadow-neon); +} + +.header-logo-title { + display: flex; + align-items: center; + gap: 16px; +} + +.loan-logo { + width: 42px; + height: 42px; + animation: pulse-glow 3s infinite ease-in-out; +} + +.logo-svg { + width: 100%; + height: 100%; +} + +.title-meta h1 { + font-family: var(--font-title); + font-weight: 800; + font-size: 20px; + letter-spacing: 1.5px; + color: var(--color-text-primary); + display: flex; + align-items: center; + gap: 8px; +} + +.pro-badge { + background: linear-gradient(135deg, var(--color-primary), var(--color-prepay)); + color: #020617; + font-size: 11px; + font-weight: 900; + padding: 2px 6px; + border-radius: 4px; + letter-spacing: 0.5px; + box-shadow: 0 0 8px var(--color-primary); +} + +.subtitle { + font-size: 10px; + font-weight: 600; + letter-spacing: 2px; + color: var(--color-text-secondary); +} + +/* Preset selector dropdown */ +.preset-selector { + display: flex; + flex-direction: column; + gap: 4px; +} + +.preset-selector label { + font-size: 9px; + font-weight: 700; + letter-spacing: 1px; + color: var(--color-text-secondary); +} + +.styled-select { + background: var(--color-bg-element); + color: var(--color-text-primary); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: 8px 12px; + font-family: var(--font-body); + font-weight: 500; + outline: none; + cursor: pointer; + transition: border-color 0.3s ease; +} + +.styled-select:hover { + border-color: var(--color-primary); +} + +/* Dashboard Columns Grid Layout */ +.dashboard-grid { + display: grid; + grid-template-columns: 360px 1fr; + gap: 16px; +} + +/* Panel Containers */ +.panel { + background: var(--color-bg-panel); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + display: flex; + flex-direction: column; + overflow: hidden; + backdrop-filter: blur(12px); + box-shadow: 0 4px 30px rgba(0, 0, 0, 0.4); +} + +.panel-header { + padding: 14px 20px; + background: rgba(4, 8, 15, 0.85); + border-bottom: 1px solid var(--color-border); + display: flex; + justify-content: space-between; + align-items: center; +} + +.panel-header h2 { + font-family: var(--font-title); + font-weight: 600; + font-size: 13px; + letter-spacing: 1.5px; + color: var(--color-text-secondary); +} + +.scrollable-content { + padding: 20px; + display: flex; + flex-direction: column; + gap: 20px; + max-height: 520px; + overflow-y: auto; +} + +.scrollable-content::-webkit-scrollbar { + width: 6px; +} + +.scrollable-content::-webkit-scrollbar-track { + background: rgba(2, 6, 12, 0.2); +} + +.scrollable-content::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: 3px; +} + +/* Control groups sliders */ +.control-group { + display: flex; + flex-direction: column; + gap: 14px; + border-bottom: 1px dashed rgba(255, 255, 255, 0.03); + padding-bottom: 18px; +} + +.control-group:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.control-group h3 { + font-family: var(--font-title); + font-weight: 700; + font-size: 11px; + letter-spacing: 1px; + color: var(--color-text-primary); + display: flex; + align-items: center; + gap: 6px; +} + +.group-icon { + font-size: 12px; +} + +.slider-wrapper { + display: flex; + flex-direction: column; + gap: 6px; +} + +.slider-labels { + display: flex; + justify-content: space-between; + align-items: center; +} + +.slider-labels label { + font-size: 12.5px; + font-weight: 500; + color: var(--color-text-secondary); +} + +.input-val-container { + display: flex; + align-items: center; + background: var(--color-bg-element); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: 2px 6px; + gap: 4px; +} + +.input-val-container span { + font-size: 11px; + font-weight: 600; + color: var(--color-primary); +} + +.num-box { + width: 70px; + background: transparent; + border: none; + color: var(--color-text-primary); + font-family: monospace; + font-size: 12.5px; + font-weight: 700; + text-align: right; + outline: none; +} + +.num-box::-webkit-inner-spin-button, +.num-box::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + +input[type="range"] { + -webkit-appearance: none; + width: 100%; + height: 5px; + background: rgba(255,255,255,0.05); + border-radius: 3px; + outline: none; +} + +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 13px; + height: 13px; + border-radius: 50%; + background: var(--color-primary); + cursor: pointer; + box-shadow: 0 0 6px var(--color-primary); + transition: transform 0.1s ease; +} + +input[type="range"]::-webkit-slider-thumb:hover { + transform: scale(1.3); +} + +.slider-description { + font-size: 9.5px; + color: var(--color-text-muted); + line-height: 1.3; +} + +/* Center Panel: Telemetry Dashboard */ +.workspace-container { + padding: 16px; + gap: 16px; +} + +.telemetry-deck { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 14px; +} + +.kpi-card { + background: var(--color-bg-element); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: 14px; + display: flex; + flex-direction: column; + gap: 4px; + transition: all 0.3s ease; +} + +.kpi-card:hover { + border-color: var(--color-border-active); + box-shadow: var(--shadow-neon); +} + +.kpi-label { + font-size: 9px; + font-weight: 700; + letter-spacing: 0.5px; + color: var(--color-text-secondary); +} + +.kpi-value { + font-family: 'Outfit', sans-serif; + font-weight: 800; + font-size: 20px; +} + +.kpi-value.primary { color: var(--color-primary); } +.kpi-value.interest { color: var(--color-interest); } +.kpi-value.savings { color: var(--color-savings); } +.kpi-value.tenure { color: var(--color-prepay); } + +.kpi-sub { + font-size: 9.5px; + color: var(--color-text-muted); +} + +/* Visualization Workspace */ +.visualization-workspace { + display: grid; + grid-template-columns: 240px 1fr; + gap: 16px; + flex: 1; + min-height: 300px; +} + +.chart-container { + background: rgba(4, 8, 15, 0.4); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: 14px; + display: flex; + flex-direction: column; + align-items: center; +} + +.chart-container h3 { + font-family: var(--font-title); + font-size: 10.5px; + letter-spacing: 1px; + color: var(--color-text-secondary); + align-self: flex-start; + margin-bottom: 12px; +} + +.canvas-wrapper { + width: 100%; + display: flex; + justify-content: center; + align-items: center; + flex: 1; +} + +.canvas-wrapper canvas { + max-width: 100%; + display: block; +} + +/* Legend items for donut chart */ +.donut-legend { + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; + padding-top: 10px; + border-top: 1px solid rgba(255,255,255,0.03); +} + +.legend-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + color: var(--color-text-secondary); +} + +.legend-item .bullet { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} + +.legend-item.principal .bullet { background: var(--color-primary); box-shadow: 0 0 6px var(--color-primary); } +.legend-item.interest .bullet { background: var(--color-interest); box-shadow: 0 0 6px var(--color-interest); } +.legend-item.prepay .bullet { background: var(--color-prepay); box-shadow: 0 0 6px var(--color-prepay); } +.legend-item span { + font-family: monospace; + font-weight: 700; + margin-left: auto; +} + +/* Bottom Panel: Amortization schedule */ +.projections-container { + flex: 1; + max-height: 400px; +} + +.table-actions { + display: flex; + align-items: center; + gap: 16px; +} + +/* View toggle switch */ +.toggle-view-wrapper { + display: flex; + align-items: center; + background: rgba(2, 6, 12, 0.4); + padding: 3px; + border-radius: var(--radius-sm); + border: 1px solid var(--color-border); +} + +.view-label { + font-size: 9px; + font-weight: 800; + color: var(--color-text-secondary); + margin-right: 6px; + margin-left: 4px; +} + +.toggle-view-btn { + background: transparent; + border: none; + color: var(--color-text-secondary); + font-family: var(--font-title); + font-weight: 700; + font-size: 10px; + padding: 4px 10px; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; +} + +.toggle-view-btn.active { + background: var(--color-primary); + color: #020617; + box-shadow: 0 0 6px var(--color-primary); +} + +.styled-btn { + background: var(--color-savings); + color: #020617; + border: 1px solid var(--color-savings); + border-radius: var(--radius-md); + padding: 8px 16px; + font-family: var(--font-title); + font-weight: 700; + font-size: 11.5px; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + transition: all 0.25s ease; + box-shadow: 0 0 8px rgba(0, 230, 118, 0.2); +} + +.styled-btn:hover { + background: #020617; + color: var(--color-savings); + box-shadow: 0 0 12px rgba(0, 230, 118, 0.4); +} + +.table-wrapper { + overflow-y: auto; + max-height: 280px; +} + +.table-wrapper::-webkit-scrollbar { + width: 6px; +} + +.table-wrapper::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: 3px; +} + +table { + width: 100%; + border-collapse: collapse; + text-align: left; + font-size: 12px; +} + +thead th { + position: sticky; + top: 0; + background: #090e15; + color: var(--color-text-secondary); + font-family: var(--font-title); + font-weight: 700; + padding: 12px 16px; + border-bottom: 1px solid var(--color-border); + z-index: 2; +} + +tbody tr { + border-bottom: 1px solid rgba(255, 255, 255, 0.02); + transition: background 0.2s ease; +} + +tbody tr:hover { + background: rgba(255, 255, 255, 0.02); +} + +tbody td { + padding: 10px 16px; + color: var(--color-text-primary); + font-family: monospace; +} + +tbody td:nth-child(2), +tbody td:nth-child(3), +tbody td:nth-child(4), +tbody td:nth-child(5), +tbody td:nth-child(6), +tbody td:nth-child(7) { + font-weight: 600; +} + +tbody td:first-child { + font-family: var(--font-body); + font-weight: 600; + color: var(--color-text-secondary); +} + +tbody td:nth-child(3) { color: var(--color-text-primary); } +tbody td:nth-child(4) { color: var(--color-prepay); } +tbody td:nth-child(5) { color: var(--color-primary); } +tbody td:nth-child(6) { color: var(--color-interest); } +tbody td:nth-child(7) { color: var(--color-text-primary); } + +/* Keyframe animations */ +@keyframes pulse-glow { + 0% { transform: scale(1); filter: drop-shadow(0 0 1px rgba(61, 90, 254, 0.3)); } + 50% { transform: scale(1.05); filter: drop-shadow(0 0 6px rgba(61, 90, 254, 0.5)); } + 100% { transform: scale(1); filter: drop-shadow(0 0 1px rgba(61, 90, 254, 0.3)); } +} + +/* Responsiveness */ +@media (max-width: 1000px) { + .dashboard-grid { + grid-template-columns: 1fr; + } + + .visualization-workspace { + grid-template-columns: 1fr; + } +} diff --git a/Projects/EMI Loan Planner/thumbnail.svg b/Projects/EMI Loan Planner/thumbnail.svg new file mode 100644 index 00000000..1627aaa6 --- /dev/null +++ b/Projects/EMI Loan Planner/thumbnail.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MONTHLY EMI + $1,755 + + + INTEREST SAVED + $73,420 + + + + EMI LOAN PLANNER + + + + + + + +