// tweaks-panel.jsx // Reusable Tweaks shell + form-control helpers. // // Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode, // posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so // individual prototypes don't re-roll it. Ships a consistent set of controls so you // don't hand-draw , segmented radios, steppers, etc. // // Usage (in an HTML file that loads React + Babel): // // const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ // "primaryColor": "#D97757", // "fontSize": 16, // "density": "regular", // "dark": false // }/*EDITMODE-END*/; // // function App() { // const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); // return ( //
// Hello // // // setTweak('fontSize', v)} /> // setTweak('density', v)} /> // // setTweak('primaryColor', v)} /> // setTweak('dark', v)} /> // //
// ); // } // // ───────────────────────────────────────────────────────────────────────────── const __TWEAKS_STYLE = ` .twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px; max-height:calc(100vh - 32px);display:flex;flex-direction:column; background:rgba(250,249,247,.78);color:#29261b; -webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%); border:.5px solid rgba(255,255,255,.6);border-radius:14px; box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18); font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden} .twk-hd{display:flex;align-items:center;justify-content:space-between; padding:10px 8px 10px 14px;cursor:move;user-select:none} .twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em} .twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55); width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1} .twk-x:hover{background:rgba(0,0,0,.06);color:#29261b} .twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px; overflow-y:auto;overflow-x:hidden;min-height:0; scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent} .twk-body::-webkit-scrollbar{width:8px} .twk-body::-webkit-scrollbar-track{background:transparent;margin:2px} .twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px; border:2px solid transparent;background-clip:content-box} .twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25); border:2px solid transparent;background-clip:content-box} .twk-row{display:flex;flex-direction:column;gap:5px} .twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px} .twk-lbl{display:flex;justify-content:space-between;align-items:baseline; color:rgba(41,38,27,.72)} .twk-lbl>span:first-child{font-weight:500} .twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums} .twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase; color:rgba(41,38,27,.45);padding:10px 0 0} .twk-sect:first-child{padding-top:0} .twk-field{appearance:none;width:100%;height:26px;padding:0 8px; border:.5px solid rgba(0,0,0,.1);border-radius:7px; background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none} .twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)} select.twk-field{padding-right:22px; background-image:url("data:image/svg+xml;utf8,"); background-repeat:no-repeat;background-position:right 8px center} .twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0; border-radius:999px;background:rgba(0,0,0,.12);outline:none} .twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none; width:14px;height:14px;border-radius:50%;background:#fff; border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} .twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%; background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default} .twk-seg{position:relative;display:flex;padding:2px;border-radius:8px; background:rgba(0,0,0,.06);user-select:none} .twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px; background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12); transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s} .twk-seg.dragging .twk-seg-thumb{transition:none} .twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0; background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px; border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2; overflow-wrap:anywhere} .twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px; background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0} .twk-toggle[data-on="1"]{background:#34c759} .twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%; background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s} .twk-toggle[data-on="1"] i{transform:translateX(14px)} .twk-num{display:flex;align-items:center;height:26px;padding:0 0 0 8px; border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)} .twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize; user-select:none;padding-right:8px} .twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent; font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0; outline:none;color:inherit;-moz-appearance:textfield} .twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{ -webkit-appearance:none;margin:0} .twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)} .twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px; background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default} .twk-btn:hover{background:rgba(0,0,0,.88)} .twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit} .twk-btn.secondary:hover{background:rgba(0,0,0,.1)} .twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px; border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default; background:transparent;flex-shrink:0} .twk-swatch::-webkit-color-swatch-wrapper{padding:0} .twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px} .twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px} `; // ── useTweaks ─────────────────────────────────────────────────────────────── // Single source of truth for tweak values. setTweak persists via the host // (__edit_mode_set_keys → host rewrites the EDITMODE block on disk). function useTweaks(defaults) { const [values, setValues] = React.useState(defaults); // Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a // useState-style call doesn't write a "[object Object]" key into the persisted // JSON block. const setTweak = React.useCallback((keyOrEdits, val) => { const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null ? keyOrEdits : { [keyOrEdits]: val }; setValues((prev) => ({ ...prev, ...edits })); window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*'); }, []); return [values, setTweak]; } // ── TweaksPanel ───────────────────────────────────────────────────────────── // Floating shell. Registers the protocol listener BEFORE announcing // availability — if the announce ran first, the host's activate could land // before our handler exists and the toolbar toggle would silently no-op. // The close button posts __edit_mode_dismissed so the host's toolbar toggle // flips off in lockstep; the host echoes __deactivate_edit_mode back which // is what actually hides the panel. function TweaksPanel({ title = 'Tweaks', children }) { const [open, setOpen] = React.useState(false); const dragRef = React.useRef(null); const offsetRef = React.useRef({ x: 16, y: 16 }); const PAD = 16; const clampToViewport = React.useCallback(() => { const panel = dragRef.current; if (!panel) return; const w = panel.offsetWidth, h = panel.offsetHeight; const maxRight = Math.max(PAD, window.innerWidth - w - PAD); const maxBottom = Math.max(PAD, window.innerHeight - h - PAD); offsetRef.current = { x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)), y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)), }; panel.style.right = offsetRef.current.x + 'px'; panel.style.bottom = offsetRef.current.y + 'px'; }, []); React.useEffect(() => { if (!open) return; clampToViewport(); if (typeof ResizeObserver === 'undefined') { window.addEventListener('resize', clampToViewport); return () => window.removeEventListener('resize', clampToViewport); } const ro = new ResizeObserver(clampToViewport); ro.observe(document.documentElement); return () => ro.disconnect(); }, [open, clampToViewport]); React.useEffect(() => { const onMsg = (e) => { const t = e?.data?.type; if (t === '__activate_edit_mode') setOpen(true); else if (t === '__deactivate_edit_mode') setOpen(false); }; window.addEventListener('message', onMsg); window.parent.postMessage({ type: '__edit_mode_available' }, '*'); return () => window.removeEventListener('message', onMsg); }, []); const dismiss = () => { setOpen(false); window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*'); }; const onDragStart = (e) => { const panel = dragRef.current; if (!panel) return; const r = panel.getBoundingClientRect(); const sx = e.clientX, sy = e.clientY; const startRight = window.innerWidth - r.right; const startBottom = window.innerHeight - r.bottom; const move = (ev) => { offsetRef.current = { x: startRight - (ev.clientX - sx), y: startBottom - (ev.clientY - sy), }; clampToViewport(); }; const up = () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); }; window.addEventListener('mousemove', move); window.addEventListener('mouseup', up); }; if (!open) return null; return ( <>
{title}
{children}
); } // ── Layout helpers ────────────────────────────────────────────────────────── function TweakSection({ label, children }) { return ( <>
{label}
{children} ); } function TweakRow({ label, value, children, inline = false }) { return (
{label} {value != null && {value}}
{children}
); } // ── Controls ──────────────────────────────────────────────────────────────── function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) { return ( onChange(Number(e.target.value))} /> ); } function TweakToggle({ label, value, onChange }) { return (
{label}
); } function TweakRadio({ label, value, options, onChange }) { const trackRef = React.useRef(null); const [dragging, setDragging] = React.useState(false); const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o })); const idx = Math.max(0, opts.findIndex((o) => o.value === value)); const n = opts.length; // The active value is read by pointer-move handlers attached for the lifetime // of a drag — ref it so a stale closure doesn't fire onChange for every move. const valueRef = React.useRef(value); valueRef.current = value; const segAt = (clientX) => { const r = trackRef.current.getBoundingClientRect(); const inner = r.width - 4; const i = Math.floor(((clientX - r.left - 2) / inner) * n); return opts[Math.max(0, Math.min(n - 1, i))].value; }; const onPointerDown = (e) => { setDragging(true); const v0 = segAt(e.clientX); if (v0 !== valueRef.current) onChange(v0); const move = (ev) => { if (!trackRef.current) return; const v = segAt(ev.clientX); if (v !== valueRef.current) onChange(v); }; const up = () => { setDragging(false); window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); }; window.addEventListener('pointermove', move); window.addEventListener('pointerup', up); }; return (
{opts.map((o) => ( ))}
); } function TweakSelect({ label, value, options, onChange }) { return ( ); } function TweakText({ label, value, placeholder, onChange }) { return ( onChange(e.target.value)} /> ); } function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) { const clamp = (n) => { if (min != null && n < min) return min; if (max != null && n > max) return max; return n; }; const startRef = React.useRef({ x: 0, val: 0 }); const onScrubStart = (e) => { e.preventDefault(); startRef.current = { x: e.clientX, val: value }; const decimals = (String(step).split('.')[1] || '').length; const move = (ev) => { const dx = ev.clientX - startRef.current.x; const raw = startRef.current.val + dx * step; const snapped = Math.round(raw / step) * step; onChange(clamp(Number(snapped.toFixed(decimals)))); }; const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); }; window.addEventListener('pointermove', move); window.addEventListener('pointerup', up); }; return (
{label} onChange(clamp(Number(e.target.value)))} /> {unit && {unit}}
); } function TweakColor({ label, value, onChange }) { return (
{label}
onChange(e.target.value)} />
); } function TweakButton({ label, onClick, secondary = false }) { return ( ); } Object.assign(window, { useTweaks, TweaksPanel, TweakSection, TweakRow, TweakSlider, TweakToggle, TweakRadio, TweakSelect, TweakText, TweakNumber, TweakColor, TweakButton, }); /* ========== Shared utilities & primitives ========== */ const fmt$ = (n, digits = 0) => { if (!isFinite(n) || isNaN(n)) n = 0; return '$' + Math.round(n * Math.pow(10, digits)) / Math.pow(10, digits) .toLocaleString(); }; // Better $ formatter const $ = (n, digits = 0) => { if (!isFinite(n) || isNaN(n)) n = 0; const opts = { minimumFractionDigits: digits, maximumFractionDigits: digits }; return '$' + Math.abs(n).toLocaleString('en-US', opts) * (n < 0 ? -1 : 1) === 0 ? '$' + (0).toLocaleString('en-US', opts) : (n < 0 ? '-$' : '$') + Math.abs(n).toLocaleString('en-US', opts); }; const money = (n, digits = 0) => { if (!isFinite(n) || isNaN(n)) n = 0; const sign = n < 0 ? '-' : ''; const opts = { minimumFractionDigits: digits, maximumFractionDigits: digits }; return sign + '$' + Math.abs(n).toLocaleString('en-US', opts); }; const pct = (n, digits = 2) => { if (!isFinite(n) || isNaN(n)) n = 0; return n.toFixed(digits) + '%'; }; const num = (n, digits = 0) => { if (!isFinite(n) || isNaN(n)) n = 0; return n.toLocaleString('en-US', { minimumFractionDigits: digits, maximumFractionDigits: digits }); }; // ----- Calculator state registry ----- window.__calcState = {}; window.__calcLoadState = {}; function useCalcState(calcId, initialState) { const loaded = window.__calcLoadState[calcId]; const [s, setS] = useState(loaded || initialState); useEffect(() => { if (loaded) delete window.__calcLoadState[calcId]; }, []); useEffect(() => { window.__calcState[calcId] = s; }, [s]); return [s, setS]; } const SAVED_KEY = 'lot-saved-presentations'; function loadSaved() { try { return JSON.parse(localStorage.getItem(SAVED_KEY) || '[]'); } catch { return []; } } function persistSaved(arr) { try { localStorage.setItem(SAVED_KEY, JSON.stringify(arr)); } catch {} // Best-effort cloud sync (debounced inside firebase helper). const u = window.__firebase && window.__firebase.auth && window.__firebase.auth.currentUser; if (u) window.__firebase.savePresentationsCloud(u.uid, arr); } // ----- Branding settings (company logo, headshot, contact info) ----- const BRANDING_KEY = 'lot-branding-settings'; function loadBranding() { try { return JSON.parse(localStorage.getItem(BRANDING_KEY) || '{}'); } catch { return {}; } } function persistBranding(obj) { try { localStorage.setItem(BRANDING_KEY, JSON.stringify(obj)); } catch (e) { console.warn('Branding save failed (likely localStorage quota):', e); return false; } const u = window.__firebase && window.__firebase.auth && window.__firebase.auth.currentUser; if (u) window.__firebase.saveBrandingCloud(u.uid, obj); return true; } // Resize uploaded images client-side so headshots/logos fit in localStorage (~5MB cap). function imageToDataUrl(file, maxDim, mime) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const img = new Image(); img.onload = () => { const scale = Math.min(1, maxDim / Math.max(img.width, img.height)); const w = Math.max(1, Math.round(img.width * scale)); const h = Math.max(1, Math.round(img.height * scale)); const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, w, h); try { resolve(canvas.toDataURL(mime || 'image/png', 0.9)); } catch (e) { reject(e); } }; img.onerror = reject; img.src = reader.result; }; reader.onerror = reject; reader.readAsDataURL(file); }); } window.loadBranding = loadBranding; window.persistBranding = persistBranding; window.imageToDataUrl = imageToDataUrl; // ----- Mortgage math ----- function calcPI(principal, annualRate, years) { const r = (annualRate / 100) / 12; const n = years * 12; if (r === 0) return n > 0 ? principal / n : 0; if (n === 0) return 0; return principal * (r * Math.pow(1 + r, n)) / (Math.pow(1 + r, n) - 1); } function buildAmortization(principal, annualRate, years, extraMonthly = 0, extraYearly = 0, oneTime = 0, oneTimeMonth = 0) { const r = (annualRate / 100) / 12; const n = years * 12; const pi = calcPI(principal, annualRate, years); const rows = []; let balance = principal; let totalInterest = 0; for (let m = 1; m <= n && balance > 0.01; m++) { const interest = balance * r; let principalPay = pi - interest + extraMonthly; if (m === oneTimeMonth) principalPay += oneTime; if (m % 12 === 0) principalPay += extraYearly; if (principalPay > balance) principalPay = balance; balance -= principalPay; totalInterest += interest; rows.push({ month: m, payment: pi + extraMonthly, principal: principalPay, interest, balance, totalInterest }); } return { rows, pi, totalInterest, payoffMonths: rows.length }; } function buildArmAmortization(principal, initialRate, years, armInitial, armCap, armLifeCap, armIndex, armMargin) { const totalMonths = years * 12; const initMonths = armInitial * 12; const maxRate = initialRate + armLifeCap; const rows = []; let balance = principal; let totalInterest = 0; let curRate = initialRate; let pi = calcPI(principal, initialRate, years); for (let m = 1; m <= totalMonths && balance > 0.01; m++) { if (m <= initMonths) { // Initial fixed period — use initial rate curRate = initialRate; } else if ((m - initMonths - 1) % 12 === 0) { // Adjustment point: first month after initial period, then every 12 months const fullyIndexed = armIndex + armMargin; const adjCount = Math.floor((m - initMonths - 1) / 12) + 1; const adjRate = initialRate + Math.min(armCap * adjCount, armLifeCap); curRate = Math.min(adjRate, maxRate, Math.max(fullyIndexed, initialRate)); curRate = Math.min(curRate, maxRate); // Recalculate payment for remaining term at new rate const remainYears = (totalMonths - m + 1) / 12; pi = calcPI(balance, curRate, remainYears); } const mRate = curRate / 100 / 12; const interest = balance * mRate; let principalPay = pi - interest; if (principalPay > balance) principalPay = balance; if (principalPay < 0) principalPay = 0; balance -= principalPay; totalInterest += interest; rows.push({ month: m, payment: pi, principal: principalPay, interest, balance, totalInterest, rate: curRate }); } return { rows, pi: calcPI(principal, initialRate, years), totalInterest, payoffMonths: rows.length }; } // ----- Primitives ----- const { useState, useEffect, useMemo, useRef, useCallback, Fragment } = React; const Field = ({ label, hint, children, row }) => (
{label &&
{label}{hint && {hint}}
} {children}
); const NumInput = ({ value, onChange, prefix, suffix, step = 1, min, max, placeholder }) => { const [local, setLocal] = useState(value); useEffect(() => { setLocal(value); }, [value]); return (
{prefix && {prefix}} { const v = e.target.value; setLocal(v); const n = parseFloat(v.replace(/,/g, '')); if (!isNaN(n)) onChange(n); else if (v === '') onChange(0); }} className={[prefix ? 'input-with-prefix' : '', suffix ? 'input-with-suffix' : ''].join(' ')} /> {suffix && {suffix}}
); }; const Segmented = ({ value, onChange, options, full }) => (
{options.map(o => ( ))}
); const Toggle = ({ value, onChange, children }) => ( ); const KPI = ({ label, value, sub, accent, good, bad }) => (
{label}
{value}
{sub &&
{sub}
}
); const Card = ({ title, sub, action, children, style, className }) => (
{(title || action) && (
{title &&

{title}

} {action}
)} {sub &&
{sub}
} {children}
); const Tip = ({ children }) => (
{children}
); // ----- Charts (custom SVG) ----- const ChartContainer = ({ height = 240, children, padding = {top: 16, right: 16, bottom: 28, left: 64}, onMove, onLeave, tooltip }) => { const ref = useRef(null); const svgRef = useRef(null); const [w, setW] = useState(600); useEffect(() => { if (!ref.current) return; const ro = new ResizeObserver(es => setW(es[0].contentRect.width)); ro.observe(ref.current); return () => ro.disconnect(); }, []); const handleMove = (e) => { if (!onMove || !svgRef.current) return; const rect = svgRef.current.getBoundingClientRect(); onMove(e.clientX - rect.left, e.clientY - rect.top); }; return (
{typeof children === 'function' ? children({w, h: height, padding}) : children} {tooltip && (() => { // Flip tooltip below the point when it would clip the top of the chart. // Also nudge horizontally so the tooltip doesn't get cut off at left/right edges. const flipBelow = tooltip.y < 56; const tx = Math.max(60, Math.min(w - 60, tooltip.x)); const ty = flipBelow ? tooltip.y + 14 : tooltip.y - 10; const yShift = flipBelow ? '0%' : '-100%'; return (
{tooltip.content}
); })()}
); }; // Line / area chart with multiple series + hover tooltip const LineChart = ({ series, height = 260, yFormat = (v) => money(v), xFormat = (v) => v, smooth = true, area = true }) => { const [tt, setTT] = useState(null); const [dims, setDims] = useState(null); return ( { if (!dims) return; const { padding, innerW, xMin, xMax, w, h, series: ss, ys } = dims; if (mx < padding.left || mx > w - padding.right) { setTT(null); return; } const xRatio = (mx - padding.left) / innerW; const xVal = xMin + xRatio * (xMax - xMin); // Snap to nearest x in first series let nearest = ss[0].data[0], best = Infinity; ss[0].data.forEach(p => { const d = Math.abs(p.x - xVal); if (d < best) { best = d; nearest = p; } }); const items = ss.map(s => { const pt = s.data.find(p => p.x === nearest.x) || s.data[0]; return { color: s.color, label: s.label, value: pt.y }; }); setTT({ x: padding.left + ((nearest.x - xMin) / (xMax - xMin || 1)) * innerW, y: Math.min(...items.map(i => ys(i.value))), xLabel: xFormat(nearest.x), items, }); }} onLeave={() => setTT(null)} tooltip={tt && { x: tt.x, y: tt.y, content: (
{tt.xLabel}
{tt.items.map((it, i) => (
{it.label}: {yFormat(it.value)}
))}
) }}> {({w, h, padding}) => { const innerW = w - padding.left - padding.right; const innerH = h - padding.top - padding.bottom; if (innerW <= 0 || !series.length) return null; const allPts = series.flatMap(s => s.data); const xMin = Math.min(...allPts.map(p => p.x)); const xMax = Math.max(...allPts.map(p => p.x)); const yMin = Math.min(0, ...allPts.map(p => p.y)); const yMax = Math.max(...allPts.map(p => p.y), 1); const xs = (x) => padding.left + ((x - xMin) / (xMax - xMin || 1)) * innerW; const ys = (y) => padding.top + innerH - ((y - yMin) / (yMax - yMin || 1)) * innerH; // Stash dims for hover if (!dims || dims.w !== w) setTimeout(() => setDims({ padding, innerW, xMin, xMax, yMin, yMax, w, h, series, ys, xs }), 0); const yTicks = 4; const xTicks = Math.min(6, xMax - xMin); const pathFor = (data) => data.map((p, i) => `${i ? 'L' : 'M'} ${xs(p.x)} ${ys(p.y)}`).join(' '); return ( {Array.from({length: yTicks + 1}).map((_, i) => { const y = padding.top + (innerH * i) / yTicks; const v = yMax - ((yMax - yMin) * i) / yTicks; return ( {yFormat(v)} ); })} {Array.from({length: xTicks + 1}).map((_, i) => { const x = padding.left + (innerW * i) / xTicks; const v = xMin + ((xMax - xMin) * i) / xTicks; return ( {xFormat(Math.round(v))} ); })} {area && series.map((s, idx) => ( ))} {series.map((s, idx) => ( ))} {tt && series.map((s, idx) => { const pt = s.data.find(p => xFormat(p.x) === tt.xLabel) || null; if (!pt) return null; return ; })} {tt && } ); }}
); }; const BarChart = ({ data, height = 240, yFormat = (v) => money(v), color = 'var(--accent)', horizontal = false, showValues = true }) => { const [tt, setTT] = useState(null); const max = data.length ? Math.max(...data.map(d => d.value), 1) : 1; const maxLabel = yFormat(max); const dynLeft = Math.max(48, maxLabel.length * 7 + 12); return ( setTT(null)}> {({w, h, padding}) => { const innerW = w - padding.left - padding.right; const innerH = h - padding.top - padding.bottom; if (!data.length) return null; const max = Math.max(...data.map(d => d.value), 1); const min = Math.min(0, ...data.map(d => d.value)); const bw = innerW / data.length; const ys = (v) => padding.top + innerH - ((v - min) / (max - min || 1)) * innerH; return ( {[0, 0.25, 0.5, 0.75, 1].map((t, i) => { const v = max - (max - min) * t; const y = padding.top + innerH * t; return ( {yFormat(v)} ); })} {data.map((d, i) => { const x = padding.left + bw * i + bw * 0.15; const barW = bw * 0.7; const y0 = ys(0); const y1 = ys(d.value); return ( setTT({ x: x + barW/2, y: Math.min(y0, y1), content:
{d.label}
{yFormat(d.value)}
})} style={{cursor:'pointer'}}> {showValues && Math.abs(y1 - y0) > 14 && ( {yFormat(d.value)} )} {d.label}
); })}
); }}
); }; // Chart.js-powered donut with native hover tooltips (label + value + %). const Donut = ({ data, size = 180, thickness = 28, centerLabel, centerValue }) => { const canvasRef = useRef(null); const chartRef = useRef(null); const total = data.reduce((s, d) => s + Math.max(0, d.value), 0) || 1; // Re-create chart whenever the data shape/values change. JSON.stringify handles // nested {label,value,color} arrays without needing a deep-equal helper. const dataKey = JSON.stringify(data.map(d => [d.label, d.value, d.color])); useEffect(() => { if (!canvasRef.current || !window.Chart) return; if (chartRef.current) { chartRef.current.destroy(); chartRef.current = null; } // Resolve theme colors from CSS vars so the chart picks up light/dark themes. const css = getComputedStyle(document.documentElement); const cssVar = (name, fallback) => (css.getPropertyValue(name).trim() || fallback); // Canvas can't parse `var(--x)` — resolve any var() reference to a real color. const resolveColor = (c) => { if (typeof c !== 'string') return c; const m = c.match(/^var\(\s*(--[\w-]+)\s*(?:,\s*([^)]+))?\)$/); if (!m) return c; return css.getPropertyValue(m[1]).trim() || (m[2] ? m[2].trim() : '#94a3b8'); }; const bgElev = cssVar('--bg-elev', '#ffffff'); const bgSunken = cssVar('--bg-sunken', '#f1f5f9'); const textColor = cssVar('--text', '#0f172a'); // cutout pixel = (size/2 - thickness). Convert to percent for Chart.js. const cutoutPx = Math.max(0, size / 2 - thickness); const cutoutPct = `${Math.round((cutoutPx / (size / 2)) * 100)}%`; // If every value is 0 show a single neutral "ring" so the donut isn't empty. const allZero = data.every(d => Math.max(0, d.value) === 0); const values = allZero ? [1] : data.map(d => Math.max(0, d.value)); const colors = allZero ? [bgSunken] : data.map(d => resolveColor(d.color)); const labels = allZero ? ['No data'] : data.map(d => d.label); chartRef.current = new window.Chart(canvasRef.current, { type: 'doughnut', data: { labels, datasets: [{ data: values, backgroundColor: colors, borderColor: bgElev, borderWidth: 2, hoverOffset: 6, hoverBorderColor: bgElev, }], }, options: { responsive: false, maintainAspectRatio: false, cutout: cutoutPct, animation: { duration: 500, easing: 'easeOutQuart' }, plugins: { legend: { display: false }, tooltip: { enabled: !allZero, backgroundColor: textColor, titleColor: bgElev, bodyColor: bgElev, titleFont: { size: 12, weight: '600', family: 'Inter, system-ui, sans-serif' }, bodyFont: { size: 12, family: 'JetBrains Mono, ui-monospace, monospace' }, padding: 10, cornerRadius: 8, displayColors: true, boxWidth: 8, boxHeight: 8, boxPadding: 4, callbacks: { label: (ctx) => { const v = ctx.parsed; const pct = total ? ((v / total) * 100).toFixed(1) : '0'; const item = data[ctx.dataIndex]; const formatted = item && item.format ? item.format(v) : window.money(v); return ` ${formatted} (${pct}%)`; }, }, }, }, }, }); return () => { if (chartRef.current) { chartRef.current.destroy(); chartRef.current = null; } }; }, [dataKey, size, thickness]); return (
{centerValue && (
{centerValue}
{centerLabel &&
{centerLabel}
}
)}
{data.map((d, i) => (
{d.label} {d.format ? d.format(d.value) : money(d.value)}
))}
); }; const StackedBar = ({ segments, height = 16, format }) => { const total = segments.reduce((s, d) => s + Math.max(0, d.value), 0) || 1; return (
{segments.map((s, i) => (
))}
{segments.map((s, i) => ( {s.label}: {(format||money)(s.value)} ))}
); }; // expose Object.assign(window, { Field, NumInput, Segmented, Toggle, KPI, Card, Tip, LineChart, BarChart, Donut, StackedBar, ChartContainer, calcPI, buildAmortization, money, pct, num, useState, useEffect, useMemo, useRef, useCallback, Fragment, }); /* Icons — minimal stroke icons */ const Icon = ({ name, size = 18 }) => { const props = { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round" }; const paths = { home: <>, grid: <>, bookmark: <>, calc: <>, compare: <>, refresh: <>, badge: <>, bank: <>, plus: <>, receipt: <>, tools: <>, house: <>, trend: <>, chart: <>, table: <>, dscr: <>, settings: <>, save: <>, edit: <>, play: <>, sun: <>, moon: <>, menu: <>, chev: <>, chevR: <>, copy: <>, x: <>, 'third-party-link': <>, download: <>, phone: <>, mail: <>, upload: <>, 'log-out': <>, 'arrow-left': <>, google: <>, lock: <>, }; return {paths[name] || paths.calc}; }; window.Icon = Icon; /* ========== Calculators set 1: Mortgage, Loan Comparison, Refinance, Pre-Approval ========== */ const CHART_COLORS = ['var(--accent)', 'var(--c-violet)', 'var(--c-teal)', 'var(--c-amber)', 'var(--c-rose)', 'var(--c-emerald)']; /* -------- 1. Mortgage Calculator -------- */ function MortgageCalc() { const [s, setS] = useCalcState('mortgage', { homePrice: 500000, downPayment: 100000, downPercent: 20, rate: 6.5, term: 30, rateType: 'Fixed', armInitial: 5, armCap: 2, armLifeCap: 5, armMargin: 2.75, armIndex: 4.5, tax: 3000, ins: 1200, hoa: 0, pmi: 0, downMode: '$', }); const set = (k) => (v) => setS(prev => { const n = { ...prev, [k]: v }; if (k === 'downPayment') n.downPercent = prev.homePrice ? (v / prev.homePrice) * 100 : 0; if (k === 'downPercent') n.downPayment = prev.homePrice * (v / 100); if (k === 'homePrice') n.downPayment = v * (prev.downPercent / 100); return n; }); const loan = Math.max(0, s.homePrice - s.downPayment); const pi = calcPI(loan, s.rate, s.term); const pmi = s.pmi; const monthlyTax = s.tax / 12; const monthlyIns = s.ins / 12; const total = pi + monthlyTax + monthlyIns + s.hoa + pmi; const am = useMemo(() => { if (s.rateType === 'ARM') { return buildArmAmortization(loan, s.rate, s.term, s.armInitial, s.armCap, s.armLifeCap, s.armIndex, s.armMargin); } return buildAmortization(loan, s.rate, s.term); }, [loan, s.rate, s.term, s.rateType, s.armInitial, s.armCap, s.armLifeCap, s.armIndex, s.armMargin]); const totalInterest = am.totalInterest; // Worst case: calculate remaining balance after initial fixed period, then P&I at max rate for remaining term, plus PITI const worst = useMemo(() => { if (s.rateType !== 'ARM') return 0; const maxRate = s.rate + s.armLifeCap; const rInit = s.rate / 100 / 12; let bal = loan; for (let m = 0; m < s.armInitial * 12; m++) { const intM = bal * rInit; bal -= (pi - intM); } const remainTerm = s.term - s.armInitial; const worstPI = calcPI(bal, maxRate, remainTerm); return worstPI + monthlyTax + monthlyIns + s.hoa + pmi; }, [s.rateType, s.rate, s.armLifeCap, s.armInitial, s.term, loan, pi, monthlyTax, monthlyIns, s.hoa, pmi]); // Chart data: balance + cumulative interest const series = useMemo(() => { const yearly = []; for (let y = 0; y <= s.term; y++) { const idx = Math.min(am.rows.length - 1, y * 12 - 1); if (y === 0) yearly.push({ x: 0, balance: loan, interest: 0 }); else if (idx >= 0) yearly.push({ x: y, balance: am.rows[idx].balance, interest: am.rows[idx].totalInterest }); } return [ { color: 'var(--accent)', label: 'Remaining balance', data: yearly.map(d => ({x: d.x, y: d.balance})) }, { color: 'var(--c-amber)', label: 'Cumulative interest', data: yearly.map(d => ({x: d.x, y: d.interest})) }, ]; }, [am, loan, s.term]); return (
{s.rateType === 'ARM' && (
ARM Settings
)}
How This Calculator Works
Monthly Payment (PITI): Combines Principal & Interest with Taxes, Insurance, HOA, and PMI. The amortization curve traces how your balance falls and interest grows — the crossover point is when you've paid as much in interest as you have remaining principal.
{s.rateType === 'ARM' && }
Loan Amount{money(loan)}
Total Interest{money(totalInterest)}
Total P&I{money(loan + totalInterest)}
Total Cost (w/ Tax+Ins){money(total * s.term * 12)}
'Yr ' + v} yFormat={v => '$' + (v/1000).toFixed(0) + 'k'} />
Remaining balance Cumulative interest
); } /* -------- 2. Loan Comparison -------- */ function ComparisonCalc() { const newScenario = (over = {}) => ({ name: 'Scenario', product: 'Conventional', loanType: 'Purchase', rateType: 'Fixed', propertyValue: 500000, downPayment: 100000, downPct: 20, currentBalance: 400000, cashout: 0, armInitial: 5, armCap: 2, armLifeCap: 5, armMargin: 2.75, armIndex: 4.5, lenderFees: 2000, otherClosing: 3000, rate: 6.5, manualAPR: 0, term: 30, points: 0, upfrontMIP: 0, financeMIP: true, tax: 3000, ins: 1200, hoa: 0, mi: 0, appreciation: 3, extra: 0, ...over, }); const loadedComp = window.__calcLoadState['comparison']; const [scenarios, setScenarios] = useState(loadedComp ? loadedComp.scenarios : [ newScenario({name:'Scenario A', downPct: 20, downPayment: 100000, lenderFees: 2000, otherClosing: 6000}), newScenario({name:'Scenario B', rate: 6.25, downPct: 30, downPayment: 150000, otherClosing: 6000}), ]); const [horizon, setHorizon] = useState(loadedComp ? loadedComp.horizon : 15); useEffect(() => { if (loadedComp) delete window.__calcLoadState['comparison']; }, []); useEffect(() => { window.__calcState['comparison'] = { scenarios, horizon }; }, [scenarios, horizon]); const update = (i, k, v) => setScenarios(arr => arr.map((s, idx) => { if (idx !== i) return s; const n = {...s, [k]: v}; if (k === 'downPayment') n.downPct = s.propertyValue ? (v/s.propertyValue)*100 : 0; if (k === 'downPct') n.downPayment = s.propertyValue * (v/100); if (k === 'propertyValue') n.downPayment = v * (s.downPct/100); if (k === 'product' && v === 'VA') n.mi = 0; return n; })); const dup = (i) => setScenarios(arr => arr.length < 4 ? [...arr, {...arr[i], name: arr[i].name + ' copy'}] : arr); const remove = (i) => setScenarios(arr => arr.length > 1 ? arr.filter((_, idx) => idx !== i) : arr); const computed = scenarios.map(s => { const baseLoan = s.loanType === 'Purchase' ? s.propertyValue - s.downPayment : s.currentBalance + s.cashout; const upfront = s.financeMIP ? baseLoan * (s.upfrontMIP/100) : 0; const loan = baseLoan + upfront; const pi = calcPI(loan, s.rate, s.term); const total = pi + s.tax/12 + s.ins/12 + s.hoa + s.mi; const buildScenarioAmortization = () => { if (s.rateType !== 'ARM') return buildAmortization(loan, s.rate, s.term, s.extra); const months = s.term * 12; const initialMonths = Math.min(months, s.armInitial * 12); const initialRate = s.rate / 100; const initialMonthlyRate = initialRate / 12; const initialPI = calcPI(loan, s.rate, s.term); const lifetimeMaxRate = initialRate + (s.armLifeCap / 100); const targetRate = (s.armIndex + s.armMargin) / 100; let activeAnnualRate = initialRate; let activePI = initialPI; let balance = loan; let totalInterest = 0; const rows = []; for (let m = 1; m <= months && balance > 0.01; m++) { if (m > initialMonths && (m - initialMonths) % 12 === 1) { const upper = Math.min(activeAnnualRate + s.armCap / 100, lifetimeMaxRate); const lower = Math.max(activeAnnualRate - s.armCap / 100, initialRate); activeAnnualRate = Math.min(Math.max(targetRate, lower), upper); const remainingMonths = months - m + 1; const r = activeAnnualRate / 12; activePI = balance * (r * Math.pow(1 + r, remainingMonths)) / (Math.pow(1 + r, remainingMonths) - 1); } const interest = balance * (activeAnnualRate / 12); let principalPay = activePI - interest + s.extra; if (principalPay > balance) principalPay = balance; balance -= principalPay; totalInterest += interest; rows.push({ month: m, payment: activePI + s.extra, principal: principalPay, interest, balance: Math.max(0, balance), totalInterest }); } return { rows, pi: initialPI, totalInterest, payoffMonths: rows.length }; }; const am = buildScenarioAmortization(); const calcWorstCaseBalanceAtHorizon = () => { if (s.rateType !== 'ARM') return null; const months = s.term * 12; const initialMonths = Math.min(months, s.armInitial * 12); const horizonMonths = Math.min(months, horizon * 12); let balance = loan; const initialPI = calcPI(loan, s.rate, s.term); const initialMonthlyRate = (s.rate / 100) / 12; for (let m = 1; m <= Math.min(initialMonths, horizonMonths) && balance > 0.01; m++) { const interest = balance * initialMonthlyRate; balance -= Math.min(balance, initialPI - interest + s.extra); } if (horizonMonths > initialMonths && balance > 0.01) { const maxRate = s.rate + s.armLifeCap; // Competitor's net-worth view stress-tests the post-initial ARM balance with a max-rate recast, // while the worst-case payment card separately shows the max-rate remaining-term PITIA payment. const stressedPI = calcPI(balance, maxRate, s.term); const stressedMonthlyRate = (maxRate / 100) / 12; for (let m = initialMonths + 1; m <= horizonMonths && balance > 0.01; m++) { const interest = balance * stressedMonthlyRate; balance -= Math.min(balance, stressedPI - interest + s.extra); } } return Math.max(0, balance); }; const futureValue = s.propertyValue * Math.pow(1 + s.appreciation/100, horizon); const remainingIdx = Math.min(am.rows.length - 1, horizon*12 - 1); const scheduledRemaining = remainingIdx >= 0 ? am.rows[remainingIdx].balance : 0; const stressRemaining = calcWorstCaseBalanceAtHorizon(); const remaining = stressRemaining === null ? scheduledRemaining : stressRemaining; const netWorth = futureValue - remaining; const pointsCost = (s.points/100)*loan; const totalCash = s.loanType === 'Purchase' ? s.downPayment + s.lenderFees + s.otherClosing + pointsCost : s.lenderFees + s.otherClosing + pointsCost - s.cashout; const totalCost = total * s.term * 12; // Worst-case ARM: competitor calculates the max-rate payment after the initial fixed period, // using the remaining balance and remaining term, then adds monthly PITIA. const worstRate = s.rate + s.armLifeCap; let worstPayment = 0; if (s.rateType === 'ARM') { const initialMonths = Math.min(s.armInitial * 12, s.term * 12); const remainingRows = buildAmortization(loan, s.rate, s.term).rows; const remainingBalance = initialMonths > 0 && remainingRows[initialMonths - 1] ? remainingRows[initialMonths - 1].balance : loan; const remainingYears = Math.max(1 / 12, s.term - s.armInitial); worstPayment = calcPI(remainingBalance, worstRate, remainingYears) + s.tax/12 + s.ins/12 + s.hoa + s.mi; } // APR calc via bisection let apr = s.rate; if (s.manualAPR > 0) { apr = s.manualAPR; } else { const totalFees = s.lenderFees + pointsCost; if (totalFees > 0 && loan > 0) { let lo = s.rate * 0.8, hi = s.rate * 2; for (let i = 0; i < 50; i++) { const mid = (lo + hi) / 2; const testPmt = calcPI(loan - totalFees, mid, s.term); if (testPmt < pi) lo = mid; else hi = mid; } apr = (lo + hi) / 2; } } return { s, loan, pi, total, am, futureValue, remaining, netWorth, totalCash, totalCost, apr, pointsCost, worstRate, worstPayment, payoffYears: am.payoffMonths/12 }; }); return (
How This Calculator Works
Monthly Payment: Includes principal, interest, taxes, insurance, HOA, and PMI for a complete side-by-side comparison of each scenario's true monthly cost.
Net Worth Projection: Estimates future home equity by factoring in property appreciation and mortgage paydown over the selected timeline. Use this to see which scenario builds the most wealth long-term.
Net Worth Timeline:
setHorizon(v)} suffix="" style={{width:60}} /> years
Calculate net worth projection at this timeframe {scenarios.length < 4 && ( )}
{scenarios.map((sc, i) => ( dup(i)} remove={() => remove(i)} canRemove={scenarios.length > 1} color={CHART_COLORS[i]} comp={computed[i]} /> ))}
{/* Winner Cards */}
{(() => { const winners = [ {label: 'Lowest Monthly Payment', key: 'total', fmt: v => money(v, 2)}, {label: 'Lowest Cash to Close', key: 'totalCash', fmt: v => money(v)}, {label: 'Lowest Total Cost', key: 'totalInterest', fmt: v => money(v), sub: 'Total interest over loan life'}, ]; return winners.map(w => { const valOf = (c) => w.key === 'totalInterest' ? c.am.totalInterest : c[w.key]; const best = computed.reduce((min, c, i) => valOf(c) < min.val ? {val: valOf(c), idx: i, name: c.s.name} : min, {val: Infinity, idx: 0, name: ''}); return (
{w.label}
{best.name}
{w.fmt(best.val)}
{w.sub &&
{w.sub}
}
); }); })()}
({label: c.s.name, value: c.total, color: CHART_COLORS[i]}))} height={220} /> ({label: c.s.name, value: c.am.totalInterest, color: CHART_COLORS[i]}))} height={220} />
Timeline: setHorizon(+e.target.value)} style={{width: 140}} /> {horizon}y
}> ({label: c.s.name, value: c.netWorth, color: CHART_COLORS[i]}))} height={240} />
{computed.map((c, i) => (
{c.s.name}
Home Value: {money(c.futureValue)}
Remaining Bal: {money(c.remaining)}
Net Worth: {money(c.netWorth)}
))}
{computed.map((c, i) => )} {/* Loan Details */} {[ ['Loan Type', c => c.s.loanType === 'Purchase' ? 'Purchase' : 'Refi'], ['Product', c => c.s.product], ['Rate Type', c => c.s.rateType], ['Home Price / Value', c => money(c.s.propertyValue)], ['Down Payment', c => c.s.loanType === 'Purchase' ? money(c.s.downPayment) + ' (' + c.s.downPct.toFixed(1) + '%)' : 'N/A'], ['Loan Amount', c => money(c.loan)], ['Interest Rate', c => pct(c.s.rate)], ['APR', c => pct(c.apr)], ['Loan Term', c => c.s.term + ' yr'], ].map(([label, fn]) => ( {computed.map((c, i) => )} ))} {/* Monthly Payments */} {[ ['Principal & Interest', c => money(c.pi, 2), {background:'rgba(16,185,129,0.08)'}], ['Property Tax', c => money(c.s.tax/12, 2), null], ['Home Insurance', c => money(c.s.ins/12, 2), null], ['HOA', c => money(c.s.hoa, 2), null], ['Mortgage Insurance', c => money(c.s.mi, 2), null], ['Total Monthly Payment', c => money(c.total, 2), {background:'rgba(16,185,129,0.08)', fontWeight:700}], ].map(([label, fn, style]) => ( {computed.map((c, i) => )} ))} {/* ARM Details - only if any scenario is ARM */} {computed.some(c => c.s.rateType === 'ARM') && ( <> {computed.map((c, i) => )} {computed.map((c, i) => )} )} {/* Costs */} {[ ['Points (%)', c => pct(c.s.points), null], ['Points ($)', c => money(c.pointsCost), null], ['Lender Fees', c => money(c.s.lenderFees), null], ['Other Closing Costs', c => money(c.s.otherClosing), null], ['Cash to Close', c => money(c.totalCash), {background:'rgba(245,158,11,0.08)'}], ].map(([label, fn, style]) => ( {computed.map((c, i) => )} ))} {/* Lifetime */} {[ ['Total Interest', c => money(c.am.totalInterest), null], ['Total Cost', c => money(c.totalCost), {background:'rgba(245,158,11,0.08)'}], ['Extra Payments (/mo)', c => money(c.s.extra), null], ['Payoff', c => c.am.payoffMonths + ' mo (' + c.payoffYears.toFixed(1) + ' yr)', null], ].map(([label, fn, style]) => ( {computed.map((c, i) => )} ))} {/* Net Worth */} {[ ['Home Value', c => money(c.futureValue), null], ['Remaining Balance', c => money(c.remaining), null], ['Net Worth', c => money(c.netWorth), {background:'rgba(16,185,129,0.08)'}], ].map(([label, fn, style]) => ( {computed.map((c, i) => )} ))}
Metric{c.s.name}
Loan Details
{label}{fn(c)}
Monthly Payments
{label}{fn(c)}
ARM Details
Worst-Case Rate{c.s.rateType === 'ARM' ? pct(c.worstRate) : 'N/A'}
Worst-Case Payment{c.s.rateType === 'ARM' ? money(c.worstPayment, 2) : 'N/A'}
Costs
{label}{fn(c)}
Lifetime
{label}{fn(c)}
{'Net Worth (' + horizon + 'yr)'}
{label}{fn(c)}
); } function ScenarioCard({ sc, idx, update, dup, remove, canRemove, color, comp }) { return (
update(idx, 'name', e.target.value)} style={{fontFamily: 'inherit', fontWeight: 700, fontSize: 14, border:'none', background:'transparent', padding: 0, color: 'var(--text)', minWidth: 0, flex: 1}} />
{canRemove && }
update(idx,'loanType',v)} options={[{label:'Purchase',value:'Purchase'},{label:'Refi',value:'Refinance'}]} /> update(idx,'rateType',v)} options={[{label:'Fixed',value:'Fixed'},{label:'ARM',value:'ARM'}]} /> {sc.loanType === 'Purchase' ? ( <> update(idx,'propertyValue',v)} /> update(idx,'downPayment',v)} /> update(idx,'downPct',v)} /> ) : ( <> update(idx,'propertyValue',v)} /> update(idx,'currentBalance',v)} /> update(idx,'cashout',v)} /> )} {sc.rateType === 'ARM' && (
ARM Settings
update(idx,'armInitial',v)} /> update(idx,'armCap',v)} /> update(idx,'armLifeCap',v)} /> update(idx,'armMargin',v)} /> update(idx,'armIndex',v)} />
)} update(idx,'rate',v)} /> update(idx,'term',v)} /> update(idx,'points',v)} /> update(idx,'manualAPR',v)} /> update(idx,'lenderFees',v)} /> update(idx,'otherClosing',v)} /> update(idx,'upfrontMIP',v)} /> update(idx,'mi',v)} /> update(idx,'financeMIP',v)}>Finance MIP into loan update(idx,'tax',v)} /> update(idx,'ins',v)} /> update(idx,'hoa',v)} /> update(idx,'extra',v)} /> update(idx,'appreciation',v)} /> {comp && (
Monthly Payment
{money(comp.total, 2)}
Rate
{pct(comp.s.rate)}
{comp.s.rateType === 'ARM' && (
Worst Case Payment
{money(comp.worstPayment, 2)}
)} {comp.s.rateType === 'ARM' && (
Max Rate
{pct(comp.worstRate)}
)}
APR
{pct(comp.apr)}
Cash to Close
{money(comp.totalCash)}
)}
); } /* -------- 3. Refinance Calculator -------- */ function RefinanceCalc() { const [s, setS] = useCalcState('refinance', { balance: 500000, currRate: 7.0, currPay: 3326, yearsLeft: 28, newRate: 5.5, newTerm: 30, closing: 5000, rollIn: true, cashout: 0, includeTI: false, tax: 3000, ins: 1200, }); const set = k => v => setS(p => ({...p, [k]: v})); const newLoan = s.balance + s.cashout + (s.rollIn ? s.closing : 0); const newPI = calcPI(newLoan, s.newRate, s.newTerm); const ti = (s.tax + s.ins) / 12; const newPay = newPI + (s.includeTI ? ti : 0); const currPayCalc = s.currPay + (s.includeTI ? ti : 0); const monthlySavings = currPayCalc - newPay; const currTotalInterest = (s.currPay * s.yearsLeft * 12) - s.balance; const newAm = buildAmortization(newLoan, s.newRate, s.newTerm); const interestSavings = currTotalInterest - newAm.totalInterest; const breakEven = monthlySavings > 0 ? Math.ceil(s.closing / monthlySavings) : 0; const lifetimeNet = (currPayCalc * s.yearsLeft * 12) - (newPay * s.newTerm * 12); const series = useMemo(() => { const savings = [], costs = []; const maxYears = Math.min(s.newTerm, 10); const steps = maxYears * 2; // every 0.5 years for (let i = 0; i <= steps; i++) { const yr = i * 0.5; const months = yr * 12; savings.push({x: yr, y: monthlySavings * months}); costs.push({x: yr, y: s.closing}); } return [ {color: 'var(--good)', label: 'Cumulative Savings', data: savings}, {color: 'var(--c-amber)', label: 'Closing Costs', data: costs, dashed: true}, ]; }, [monthlySavings, s.closing, s.newTerm]); return (
Include taxes & insurance {s.includeTI && (
)}
Roll closing costs into loan
How This Calculator Works
Monthly Savings: Compares your current monthly payment against the new refinanced payment. The difference is your monthly savings (or increase if shortening the term).
Break-Even Point: Calculates how long it takes for your cumulative monthly savings to offset the closing costs of the refinance. After that point, every dollar saved is pure benefit.
0} bad={monthlySavings < 0} label="Monthly Savings" value={(monthlySavings >= 0 ? '↓ ' : '↑ ') + money(Math.abs(monthlySavings), 2)} sub={monthlySavings >= 0 ? "Lower payment" : "Higher payment"} /> 0 ? breakEven + ' mo' : '—'} sub="Months to recoup" />
v.toFixed(1)} yFormat={v => '$' + (v/1000).toFixed(0) + 'k'} />
Cumulative Savings Closing Costs
{monthlySavings > 0 && breakEven > 0 && (
Break-even in {(breakEven/12).toFixed(1)} years ({breakEven} months). After that, you'll save {money(monthlySavings, 2)} monthly. Totaling {money(lifetimeNet)} in savings over the loan life.
)}
{monthlySavings > 0 && breakEven > 0 ? ( <>You'll save {money(monthlySavings, 2)}/month and break even in {breakEven} months. Over the life of the loan, you'll save {money(lifetimeNet)} after closing costs. ) : ( <>This refinance increases your payment by {money(Math.abs(monthlySavings), 2)}/month. Consider only if the term reduction or cash-out is the goal. )}
Current Refinance
Monthly Payment{money(currPayCalc, 2)}{money(newPay, 2)}
Loan Amount{money(s.balance)}{money(newLoan)}
Total Interest{money(currTotalInterest)}{money(newAm.totalInterest)}
Remaining{s.yearsLeft * 12} mo{s.newTerm * 12} mo
Closing Costs{money(s.closing)}{s.rollIn ? ' (Rolled in)' : ''}
); } /* -------- 4. Pre-Approval -------- */ function PreApprovalCalc() { const [s, setS] = useCalcState('preapproval', { income: 75000, debts: 500, minDown: 20, maxFront: 28, maxBack: 36, rate: 6.5, term: 30, monthlyTax: 250, monthlyIns: 100, }); const set = k => v => setS(p => ({...p, [k]: v})); const monthlyIncome = s.income / 12; const maxHousing = monthlyIncome * (s.maxFront / 100); const maxTotal = monthlyIncome * (s.maxBack / 100) - s.debts; const maxPITI = Math.min(maxHousing, maxTotal); const maxPI = Math.max(0, maxPITI - s.monthlyTax - s.monthlyIns); const r = (s.rate/100)/12; const n = s.term * 12; const maxLoan = r > 0 ? maxPI * (Math.pow(1+r, n) - 1) / (r * Math.pow(1+r, n)) : maxPI * n; const maxPrice = maxLoan / (1 - s.minDown/100); const downAmount = maxPrice * (s.minDown/100); const housingDTI = maxPI > 0 ? (maxPITI / monthlyIncome) * 100 : 0; const totalDTI = maxPI > 0 ? ((maxPITI + s.debts) / monthlyIncome) * 100 : 0; return (
How This Calculator Works
Front-End DTI: Limits your total housing payment (principal, interest, taxes & insurance) to a percentage of your gross monthly income.
Back-End DTI: Limits your total monthly debt obligations (housing + car loans, credit cards, student loans, etc.) to a percentage of your gross monthly income. The calculator uses the more conservative of the two limits.
Principal & Interest{money(maxPI, 2)}
Property Tax{money(s.monthlyTax, 2)}
Insurance{money(s.monthlyIns, 2)}
Total Monthly Payment (PITI){money(maxPITI, 2)}
Housing DTI {pct(housingDTI, 1)}
s.maxFront ? 'var(--bad)' : 'var(--good)'}}>
Max {s.maxFront}%
Total DTI {pct(totalDTI, 1)}
s.maxBack ? 'var(--bad)' : 'var(--good)'}}>
Max {s.maxBack}%
{[ ['Conventional Loan', 'Down: 5-20% • Front DTI: 28% • Back DTI: 36%'], ['FHA Loan', 'Down: 3.5% • Front DTI: 31% • Back DTI: 43%'], ['VA Loan', 'Down: 0% • Front DTI: N/A • Back DTI: 41%'], ['USDA Loan', 'Down: 0% • Front DTI: 29% • Back DTI: 41%'], ].map(([program, detail]) => (
{program}
{detail}
))}
); } Object.assign(window, { MortgageCalc, ComparisonCalc, RefinanceCalc, PreApprovalCalc, CHART_COLORS }); /* ========== Calculators set 2: HELOC, Extra Payment, Closing Costs, Fix & Flip ========== */ /* -------- 5. HELOC -------- */ function HELOCCalc() { const [s, setS] = useCalcState('heloc', { homeValue: 400000, mortgageBalance: 250000, maxLTV: 85, helocAmount: 90000, rate: 8.5, drawYears: 10, repayYears: 20, }); const set = k => v => setS(p => ({...p, [k]: v})); const maxTotal = s.homeValue * (s.maxLTV/100); const available = Math.max(0, maxTotal - s.mortgageBalance); const equity = Math.max(0, s.homeValue - s.mortgageBalance); const equityPct = s.homeValue ? (equity / s.homeValue) * 100 : 0; const drawPay = s.helocAmount * (s.rate/100) / 12; const repayPay = calcPI(s.helocAmount, s.rate, s.repayYears); const drawInterest = drawPay * 12 * s.drawYears; const repayInterest = (repayPay * 12 * s.repayYears) - s.helocAmount; const totalInterest = drawInterest + repayInterest; const maxLoan = Math.max(0, s.homeValue * (s.maxLTV / 100) - s.mortgageBalance); const series = useMemo(() => { const data = []; let bal = s.helocAmount; for (let y = 0; y <= s.drawYears + s.repayYears; y++) { if (y <= s.drawYears) data.push({x: y, payment: drawPay, balance: bal}); else { const idx = (y - s.drawYears) * 12 - 1; const am = buildAmortization(s.helocAmount, s.rate, s.repayYears); bal = idx >= 0 && idx < am.rows.length ? am.rows[idx].balance : 0; data.push({x: y, payment: repayPay, balance: bal}); } } return [{color: 'var(--accent)', label: 'Monthly payment', data: data.map(d => ({x: d.x, y: d.payment}))}]; }, [s, drawPay, repayPay]); return (
How This Calculator Works
Draw Period: During this phase (typically 10 years), you can borrow against your line of credit and make interest-only payments on the amount drawn.
Repayment Period: After the draw period ends, you can no longer borrow and must repay the outstanding balance with fully amortizing principal & interest payments over the remaining term.
'Yr ' + v} yFormat={v => money(v)} area={false} />
HELOC Amount{money(s.helocAmount)}
Interest (Draw){money(drawInterest)}
Interest (Repay){money(repayInterest)}
Total Interest{money(totalInterest)}
Total Paid{money(s.helocAmount + totalInterest)}
  • HELOCs typically have variable interest rates that can change over time
  • Draw period allows access to funds with interest-only payments
  • Repayment period requires full principal + interest payments
  • Your home serves as collateral — failure to repay could result in foreclosure
  • Some HELOCs may have annual fees or closing costs
  • Interest may be tax-deductible if used for home improvements (consult tax advisor)
); } /* -------- 6. Extra Payment -------- */ function ExtraPaymentCalc() { const [s, setS] = useCalcState('extra', { loan: 300000, rate: 6.5, term: 30, extraMonthly: 200, extraYearly: 0, oneTime: 0, oneTimeMonth: 12, }); const set = k => v => setS(p => ({...p, [k]: v})); const original = useMemo(() => buildAmortization(s.loan, s.rate, s.term), [s.loan, s.rate, s.term]); const withExtra = useMemo(() => buildAmortization(s.loan, s.rate, s.term, s.extraMonthly, s.extraYearly, s.oneTime, s.oneTimeMonth), [s]); const interestSaved = original.totalInterest - withExtra.totalInterest; const monthsSaved = original.payoffMonths - withExtra.payoffMonths; const series = useMemo(() => { const orig = [], ext = []; for (let y = 0; y <= s.term; y++) { const i = y*12 - 1; orig.push({x: y, y: i >= 0 && i < original.rows.length ? original.rows[i].balance : (y === 0 ? s.loan : 0)}); ext.push({x: y, y: i >= 0 && i < withExtra.rows.length ? withExtra.rows[i].balance : (y === 0 ? s.loan : 0)}); } return [ {color: 'var(--c-rose)', label: 'Regular', data: orig}, {color: 'var(--good)', label: 'With extra', data: ext}, ]; }, [s, original, withExtra]); // Yearly breakdown table const breakdown = useMemo(() => { const out = []; for (let y = 1; y <= s.term; y++) { const i = y*12 - 1; const reg = i < original.rows.length ? original.rows[i] : null; const ex = i < withExtra.rows.length ? withExtra.rows[i] : null; if (!reg && !ex) break; out.push({ year: y, regBalance: reg ? reg.balance : 0, extBalance: ex ? ex.balance : 0, regInterest: reg ? reg.totalInterest : original.totalInterest, extInterest: ex ? ex.totalInterest : withExtra.totalInterest, }); } return out; }, [original, withExtra, s.term]); return (
Regular Monthly Payment: {money(original.pi, 2)}
How This Calculator Works
Extra Payments: Shows the impact of additional principal payments on your loan payoff timeline and total interest paid. Compare the original amortization schedule against the accelerated payoff to see exactly how much time and money you save.
'Yr ' + v} yFormat={v => '$' + (v/1000).toFixed(0) + 'k'} />
Regular With extra payments
{breakdown.map(b => { const paidOff = b.extBalance <= 0 && b.regBalance > 0; const bigSave = (b.regInterest - b.extInterest) > 5000; return ( ); })}
YearRegular BalanceExtra BalanceInterest Saved
{b.year} {money(b.regBalance)} {b.extBalance <= 0 ? '$0' : money(b.extBalance)} {money(b.regInterest - b.extInterest)}
  • Even small extra payments can significantly reduce interest over time
  • Apply windfalls (bonuses, tax refunds) directly to principal
  • Biweekly payments (half payment every 2 weeks) = 1 extra payment/year
  • Round up payments to nearest $50 or $100 for easy extra contributions
  • Specify "apply to principal" when making extra payments
  • Front-load extra payments early in the loan for maximum impact
  • Consider your opportunity cost — sometimes investing may be better
); } /* -------- 7. Closing Costs -------- */ function ClosingCostsCalc() { const [s, setS] = useCalcState('closing', { homePrice: 350000, downPayment: 70000, loanAmount: 280000, rate: 6.5, taxYr: 4200, insYr: 1200, taxRate: 1.2, fees: { origination: { v: 1, on: true, pctOf: 'loan' }, application: { v: 300, on: true }, underwriting: { v: 500, on: true }, processing: { v: 400, on: true }, appraisal: { v: 500, on: true }, credit: { v: 50, on: true }, inspection: { v: 400, on: true }, survey: { v: 400, on: true }, titleSearch: { v: 300, on: true }, titleIns: { v: 0.5, on: true, pctOf: 'price' }, escrow: { v: 500, on: true }, attorney: { v: 1000, on: true }, recording: { v: 250, on: true }, transfer: { v: 1.0, on: true, pctOf: 'price' }, prepaidInterest: { v: 15, on: true, days: true }, prepaidTax: { v: 3, on: true, months: true }, prepaidIns: { v: 12, on: true, months: true }, pmiRate: { v: 0.55, on: false, pctOf: 'loan' }, pmiMonths: { v: 2, on: false, months: true }, taxReserve: { v: 2, on: true, months: true }, insReserve: { v: 2, on: true, months: true }, }, }); const set = k => v => setS(p => { const n = {...p, [k]: v}; // PMI auto-trigger: on when down < 20% of price if (k === 'downPayment' || k === 'homePrice') { const dp = k === 'downPayment' ? v : p.downPayment; const hp = k === 'homePrice' ? v : p.homePrice; const pmiOn = hp > 0 && (dp / hp) < 0.2; n.fees = {...p.fees, pmiRate: {...p.fees.pmiRate, on: pmiOn}, pmiMonths: {...p.fees.pmiMonths, on: pmiOn}}; } // Tax rate <-> tax/yr sync if (k === 'taxRate') n.taxYr = Math.round(n.homePrice * v / 100); if (k === 'taxYr') n.taxRate = n.homePrice > 0 ? Number((v / n.homePrice * 100).toFixed(4)) : 0; return n; }); const setFee = (key, prop, val) => setS(p => ({...p, fees: {...p.fees, [key]: {...p.fees[key], [prop]: val}}})); const f = s.fees; const calc = (k) => { const fee = f[k]; if (!fee || !fee.on) return 0; if (fee.pctOf === 'loan') return s.loanAmount * (fee.v / 100); if (fee.pctOf === 'price') return s.homePrice * (fee.v / 100); if (fee.days) return (fee.v * s.loanAmount * (s.rate/100) / 365); if (fee.months) { if (k === 'prepaidTax' || k === 'taxReserve') return (s.taxYr / 12) * fee.v; if (k === 'prepaidIns' || k === 'insReserve') return (s.insYr / 12) * fee.v; if (k === 'pmiMonths') return (s.loanAmount * (f.pmiRate.v/100) / 12) * fee.v; } return fee.v; }; const categories = { 'Lender': ['origination','application','underwriting','processing'], 'Third-Party': ['appraisal','credit','inspection','survey'], 'Title & Escrow': ['titleSearch','titleIns','escrow','attorney'], 'Government': ['recording','transfer'], 'Prepaid': ['prepaidInterest','prepaidTax','prepaidIns','pmiRate','pmiMonths'], 'Reserves': ['taxReserve','insReserve'], }; const labels = { origination: 'Origination Fee', application: 'Application Fee', underwriting: 'Underwriting', processing: 'Processing', appraisal: 'Appraisal', credit: 'Credit Report', inspection: 'Inspection', survey: 'Survey', titleSearch: 'Title Search', titleIns: 'Title Insurance', escrow: 'Escrow Fee', attorney: 'Attorney Fee', recording: 'Recording Fees', transfer: 'Transfer Tax', prepaidInterest: 'Prepaid Interest', prepaidTax: 'Prepaid Tax', prepaidIns: 'Prepaid Insurance', pmiRate: 'PMI Rate', pmiMonths: 'PMI Months', taxReserve: 'Tax Reserve', insReserve: 'Insurance Reserve', }; const catTotals = Object.entries(categories).map(([cat, keys]) => ({ label: cat, value: keys.reduce((s2, k) => s2 + calc(k), 0) })); const totalClosing = catTotals.reduce((s2, c) => s2 + c.value, 0); const cashToClose = s.downPayment + totalClosing; const palette = ['var(--accent)','var(--c-violet)','var(--c-teal)','var(--c-amber)','var(--c-rose)','var(--c-emerald)']; return (
{Object.entries(categories).map(([cat, keys]) => (
{cat} {money(catTotals.find(c => c.label === cat).value)}
{keys.map(k => (
setFee(k, 'on', e.target.checked)} /> {labels[k]}
setFee(k, 'v', v)} />
))}
))}
How This Calculator Works
Closing Costs: This tool estimates the fees and expenses you'll pay at closing — including lender fees, third-party services, title & escrow, government taxes, prepaid items, and escrow reserves. Toggle individual fees on or off and adjust amounts to match your specific loan scenario.
Fee Presets: Save your customized fee configurations as reusable presets so you can quickly load them for future estimates without re-entering every amount.
({label: c.label, value: c.value, color: palette[i]}))} /> {Object.entries(categories).flatMap(([cat, keys]) => { const activeKeys = keys.filter(k => f[k].on && calc(k) > 0); if (!activeKeys.length) return []; const catTotal = activeKeys.reduce((s2, k) => s2 + calc(k), 0); const highlight = ['origination','titleIns','transfer','prepaidInterest']; return [ , ...activeKeys.map(k => ( )), ]; })}
ItemAmount
{cat}
{labels[k]} {money(calc(k), 2)}
Subtotal {money(catTotal, 2)}
Total Closing Costs {money(totalClosing, 2)}
Total Cash to Close {money(cashToClose, 2)}
  • These are estimates — actual costs may vary by location and lender
  • Closing costs typically range from 2-5% of the home price
  • Some fees may be negotiable with the seller or lender
  • Ask for a Loan Estimate within 3 days of applying for accurate costs
  • Transfer taxes and recording fees vary significantly by state/county
  • You may be able to roll some closing costs into your loan amount
  • Budget extra for moving costs, furniture, and immediate home needs
); } /* -------- 8. Fix & Flip -------- */ function FixFlipCalc() { const [s, setS] = useCalcState('flip', { purchase: 1550000, rehab: 600000, arv: 2700000, downPct: 25, rate: 10.49, term: 12, holding: 6, underwriting: 1995, attorney: 635, originPoints: 2.5, reserves: 6, other: 0, sellingCostPct: 6, }); const set = k => v => setS(p => ({...p, [k]: v})); const downPayment = s.purchase * (s.downPct / 100); const loanAmount = s.purchase - downPayment + s.rehab; const monthlyInterest = loanAmount * (s.rate/100) / 12; const totalInterest = monthlyInterest * s.holding; const points = loanAmount * (s.originPoints / 100); const reserves = monthlyInterest * s.reserves; const closingCosts = s.underwriting + s.attorney + points + s.other; const cashAtClose = downPayment + closingCosts; const totalLiquid = cashAtClose + reserves; const sellingCosts = s.arv * (s.sellingCostPct/100); const totalCost = s.purchase + s.rehab + closingCosts + totalInterest; const netProfit = s.arv - totalCost; const roi = cashAtClose > 0 ? (netProfit / cashAtClose) * 100 : 0; const ltvARV = s.arv ? (loanAmount / s.arv) * 100 : 0; const ltvAsIs = s.purchase ? (loanAmount / s.purchase) * 100 : 0; const ltc = (s.purchase + s.rehab) ? (loanAmount / (s.purchase + s.rehab)) * 100 : 0; const waterfallData = [ {label: 'ARV', value: s.arv, color: 'var(--good)'}, {label: 'Purchase', value: s.purchase, color: 'var(--c-rose)'}, {label: 'Rehab', value: s.rehab, color: 'var(--c-amber)'}, {label: 'Closing', value: closingCosts, color: 'var(--c-violet)'}, {label: 'Interest', value: totalInterest, color: 'var(--c-teal)'}, {label: 'Selling', value: sellingCosts, color: 'var(--c-blue)'}, {label: 'Profit', value: netProfit, color: netProfit > 0 ? 'var(--good)' : 'var(--bad)'}, ]; return (
Fix & Flip economics. ARV minus project cost (purchase, rehab, closing, and holding interest) = net profit. Selling costs are tracked separately for underwriting review.
0} bad={netProfit < 0} label="Net Profit" value={money(netProfit)} sub={pct(roi, 1) + ' ROI'} />
{/* Project Metrics card */}
Purchase Price{money(s.purchase)}
Rehab Budget{money(s.rehab)}
After Repair Value{money(s.arv)}
Loan to Value (ARV){pct(ltvARV, 2)}
Loan to Value (As-Is){pct(ltvAsIs, 2)}
Loan to Cost (LTC){pct(ltc, 2)}
{/* Loan Summary card */}
Total Loan Amount {money(loanAmount)}
Monthly Payment (IO) {money(monthlyInterest, 2)}
Total Holding Costs ({s.holding}mo) {money(totalInterest)}
Total Cost Basis {money(totalCost)}
{/* Closing Costs card */}
Down Payment{money(downPayment)}
Origination Fee{money(points)}
Underwriting & Attorney{money(s.underwriting + s.attorney)}
Reserves ({s.reserves}mo){money(reserves)}
Other Costs{money(s.other)}
Cash Due at Closing{money(cashAtClose)}
Total Liquid Required{money(totalLiquid)}
{/* Project Economics chart */} 0 ? 'var(--good)' : 'var(--bad)'}, ]} height={260} horizontal />
{/* Selling costs & summary row */}
Selling Costs ({s.sellingCostPct}%)
{money(sellingCosts)}
Total Project Cost
{money(totalCost)}
0 ? 'rgba(16,185,129,0.08)' : 'rgba(239,68,68,0.08)'}}>
Net Profit
0 ? 'var(--good)' : 'var(--bad)'}}>{money(netProfit)}
{pct(roi, 1)} ROI
ARV
{money(s.arv)}
); } Object.assign(window, { HELOCCalc, ExtraPaymentCalc, ClosingCostsCalc, FixFlipCalc }); /* ========== Calculators set 3: Rent vs Buy, Buydown, Amortization, DSCR ========== */ /* -------- 9. Rent vs Buy -------- */ function RentVsBuyCalc() { const [s, setS] = useCalcState('rentbuy', { rent: 2000, rentIncrease: 3, rentersIns: 200, homePrice: 400000, downPayment: 80000, downPct: 20, rate: 6.5, term: 30, taxMo: 400, insMo: 150, hoaMo: 100, closing: 8000, appreciation: 3, horizon: 10, investReturn: 7, maintMo: 250, renterInvest: 0, }); const set = k => v => setS(p => { const n = {...p, [k]: v}; if (k === 'downPayment') n.downPct = p.homePrice ? (v/p.homePrice)*100 : 0; if (k === 'downPct') n.downPayment = p.homePrice * (v/100); if (k === 'homePrice') n.downPayment = v * (p.downPct/100); return n; }); const [tab, setTab] = useState('Rent'); const loan = s.homePrice - s.downPayment; const pi = calcPI(loan, s.rate, s.term); const buyMonthly = pi + s.taxMo + s.insMo + s.hoaMo + s.maintMo; // Year-by-year projection const projection = useMemo(() => { const am = buildAmortization(loan, s.rate, s.term); const out = []; let rentCum = 0, rentInsCum = 0, rentInvest = s.downPayment + s.closing; let buyCum = s.downPayment + s.closing, maintCum = 0; let currRent = s.rent; const mr = s.investReturn / 100 / 12; // monthly investment return for (let y = 0; y <= s.horizon; y++) { // Rent side — competitor uses base * (1+increase)^year for each year, accumulates 0..horizon inclusive const yearRent = s.rent * Math.pow(1 + s.rentIncrease / 100, y); const annualRent = yearRent * 12; rentCum += annualRent; rentInsCum += s.rentersIns; if (y > 0) { // Renter invests: monthly compounding of savings differential + fixed monthly investment const prevYearRent = s.rent * Math.pow(1 + s.rentIncrease / 100, y - 1); const monthlyRenterIns = s.rentersIns / 12; const monthlySavingsDiff = pi + s.taxMo + s.insMo + s.hoaMo + s.maintMo - prevYearRent - monthlyRenterIns; for (let mi = 0; mi < 12; mi++) { rentInvest = rentInvest * (1 + mr) + Math.max(0, monthlySavingsDiff) + s.renterInvest; } } // Buy side const idx = Math.min(am.rows.length - 1, y * 12 - 1); const balance = idx >= 0 ? am.rows[idx].balance : loan; const homeValue = s.homePrice * Math.pow(1 + s.appreciation/100, y); const equity = homeValue - balance; if (y > 0) { buyCum += (pi + s.taxMo + s.insMo + s.hoaMo) * 12; maintCum += s.maintMo * 12; } const rentNet = rentInvest; const buyNet = equity; out.push({ y, rentNet, buyNet, rentCum: rentCum + rentInsCum, buyCum: buyCum + maintCum, equity, homeValue, rentInvest, rentInsCum }); } return out; }, [s, loan, pi]); const final = projection[projection.length - 1]; const winner = final.buyNet > final.rentNet ? 'buy' : 'rent'; const diff = Math.abs(final.buyNet - final.rentNet); const series = [ {color: 'var(--c-rose)', label: 'Renting', data: projection.map(p => ({x: p.y, y: p.rentNet}))}, {color: 'var(--accent)', label: 'Buying', data: projection.map(p => ({x: p.y, y: p.buyNet}))}, ]; const cumSeries = [ {color: 'var(--c-rose)', label: 'Rent costs', data: projection.map(p => ({x: p.y, y: p.rentCum}))}, {color: 'var(--accent)', label: 'Buy costs', data: projection.map(p => ({x: p.y, y: p.buyCum}))}, ]; return (
{tab === 'Rent' && ( )} {tab === 'Buy' && (
)} {tab === 'Other' && ( )}
How This Calculator Works
Renter's Net Worth: Assumes the renter invests the down payment and closing costs upfront. Each month, any savings from lower housing costs (compared to buying) are also invested. All investments grow at the specified investment return rate.
Buyer's Net Worth: Tracks home equity as the property appreciates and the mortgage balance decreases over time.
The Bottom Line
{winner === 'buy' ? 'Buying' : 'Renting'} wins by {money(diff)} over {s.horizon} years.
'Yr ' + v} yFormat={v => '$' + (v/1000).toFixed(0) + 'k'} />
Renting (with invested savings) Buying (home equity)
'Yr ' + v} yFormat={v => '$' + (v/1000).toFixed(0) + 'k'} />
Rent + insurance costs Buy + maintenance costs
{/* Renting column */}

Renting

Monthly Cost {money(s.rent + s.rentersIns/12, 2)}
Total Rent Paid {money(final.rentCum)}
Capital Kept (Down Payment) {money(s.downPayment)}
Capital Kept (Closing Costs) {money(s.closing)}
Net Worth (Investments) {money(final.rentNet)}
{/* Buying column */}

Buying

Monthly Cost {money(buyMonthly, 2)}
Down Payment {money(s.downPayment)}
Closing Costs (sunk) {money(s.closing)}
Total Paid {money(final.buyCum)}
Home Value {money(final.homeValue)}
Net Worth (Equity) {money(final.buyNet)}
); } /* -------- 10. Temporary Buydown -------- */ function BuydownCalc() { const [s, setS] = useCalcState('buydown', { loan: 400000, noteRate: 7, term: 30, type: '2-1', }); const set = k => v => setS(p => ({...p, [k]: v})); const buydownMap = { '3-2-1': [3, 2, 1], '2-1': [2, 1], '1-0': [1], }; const reductions = buydownMap[s.type]; const noteP = calcPI(s.loan, s.noteRate, s.term); const schedule = reductions.map((red, i) => { const yearRate = s.noteRate - red; const yearPay = calcPI(s.loan, yearRate, s.term); const savings = noteP - yearPay; return { year: i + 1, rate: yearRate, payment: yearPay, savings, annual: savings * 12 }; }); const buydownCost = schedule.reduce((sum, r) => sum + r.annual, 0); const cumSavings = []; let acc = 0; for (let m = 1; m <= 36; m++) { const yr = Math.ceil(m / 12); const row = schedule[yr - 1]; if (row) acc += row.savings; cumSavings.push({x: m, y: acc}); } return (
How it works. A {s.type} buydown reduces the rate by [{reductions.join(', ')}]% over the first {reductions.length} year{reductions.length>1?'s':''}, then jumps to the full note rate. The cost is typically paid upfront by the seller, builder, or buyer. {schedule.map(r => ( ))}
YearRateMonthly PaymentMonthly SavingsAnnual Savings
Year {r.year} {pct(r.rate, 3)} {money(r.payment, 2)} {money(r.savings, 2)} {money(r.annual, 0)}
Year {reductions.length + 1}+ {pct(s.noteRate, 3)} {money(noteP, 2)}
({label: `Yr ${r.year}`, value: r.payment, color: CHART_COLORS[i]})) .concat([{label: 'Yr ' + (reductions.length+1) + '+', value: noteP, color: 'var(--c-rose)'}])} /> 'M' + v} yFormat={v => money(v)} />

A temporary buydown reduces the interest rate for the first few years of a mortgage, resulting in lower initial payments.

  • 2-1 Buydown: Rate is 2% lower in year 1, 1% lower in year 2, then the full note rate from year 3 onward.
  • 3-2-1 Buydown: Rate is 3% lower in year 1, 2% lower in year 2, 1% lower in year 3, then full rate.
  • 1-0 Buydown: Rate is 1% lower in year 1 only, then the full note rate from year 2 onward.
  • Who pays? The buydown cost is typically paid upfront by the seller, builder, or buyer as a lump sum at closing.
  • Best for: Borrowers who expect income to increase, or when sellers offer concessions to close a deal.
); } /* -------- 11. Amortization -------- */ function AmortizationCalc() { const [s, setS] = useCalcState('amort', { loan: 750000, rate: 6.0, term: 30, preparedFor: '', address: '', }); const [hoveredBar, setHoveredBar] = useState(null); const set = k => v => setS(p => ({...p, [k]: v})); const am = useMemo(() => buildAmortization(s.loan, s.rate, s.term), [s.loan, s.rate, s.term]); const yearly = useMemo(() => { const out = []; for (let y = 1; y <= s.term; y++) { const start = (y-1)*12, end = y*12; const slice = am.rows.slice(start, end); if (!slice.length) break; const principal = slice.reduce((s2, r) => s2 + r.principal, 0); const interest = slice.reduce((s2, r) => s2 + r.interest, 0); const last = slice[slice.length - 1]; out.push({ year: y, payment: am.pi * 12, principal, interest, balance: last.balance }); } return out; }, [am, s.term]); const stackedData = useMemo(() => { return yearly.map(y => ({ label: y.year, principal: y.principal, interest: y.interest, })); }, [yearly]); return (
set('preparedFor')(e.target.value)} placeholder="Client name" /> set('address')(e.target.value)} placeholder="123 Main St" /> set('loan')(Number(e.target.value))} /> set('rate')(Number(e.target.value))} />
Year {hoveredBar.d.label}
Principal: {money(hoveredBar.d.principal, 0)}
Interest: {money(hoveredBar.d.interest, 0)}
Total: {money(hoveredBar.d.principal + hoveredBar.d.interest, 0)}
)} : null} onLeave={() => setHoveredBar(null)}> {({w, h, padding}) => { const innerW = w - padding.left - padding.right; const innerH = h - padding.top - padding.bottom; if (!stackedData.length) return null; const maxVal = Math.max(...stackedData.map(d => d.principal + d.interest), 1); const bw = innerW / stackedData.length; const labelEvery = Math.max(1, Math.ceil(stackedData.length / 8)); return ( {[0, 0.25, 0.5, 0.75, 1].map((t, i) => { const v = maxVal * (1 - t); const y = padding.top + innerH * t; return ( {'$' + (v/1000).toFixed(0) + 'k'} ); })} {stackedData.map((d, i) => { const x = padding.left + bw * i + bw * 0.1; const barW = bw * 0.8; const intH = (d.interest / maxVal) * innerH; const prinH = (d.principal / maxVal) * innerH; const intY = padding.top + innerH - intH - prinH; const prinY = padding.top + innerH - prinH; return ( setHoveredBar({i, x: x + barW/2, y: intY, d})} onMouseLeave={() => setHoveredBar(null)}> {d.label % labelEvery === 0 && ( {d.label} )} ); })} ); }}
Principal Interest
{yearly.map(y => { const crossover = y.principal >= y.interest; const prevCrossover = y.year > 1 && yearly[y.year - 2] && yearly[y.year - 2].principal < yearly[y.year - 2].interest; const highlight = crossover && prevCrossover; return ( ); })}
YearPaymentPrincipalInterestBalance
{y.year} {money(y.payment, 0)} {money(y.principal, 0)} {money(y.interest, 0)} {money(y.balance, 0)}
); } /* -------- 12. DSCR -------- */ function DSCRCalc() { const [s, setS] = useCalcState('dscr', { address: '', purchase: 450000, downPct: 20, rate: 6.875, term: 30, interestOnly: false, rent: 3500, taxYr: 5400, insYr: 1200, hoaMo: 0, mgmtPct: 0, }); const set = k => v => setS(p => { const n = {...p, [k]: v}; if (k === 'downPayment') n.downPct = p.purchase ? (v/p.purchase)*100 : 0; if (k === 'downPct') {} // handled below return n; }); const downPay = s.purchase * (s.downPct / 100); const loan = s.purchase - downPay; const pi = s.interestOnly ? loan * (s.rate/100)/12 : calcPI(loan, s.rate, s.term); const taxMo = s.taxYr / 12; const insMo = s.insYr / 12; const mgmt = s.rent * (s.mgmtPct / 100); const pitia = pi + taxMo + insMo + s.hoaMo; const totalExpenses = pitia + mgmt; const dscr = pitia > 0 ? s.rent / pitia : 0; const monthlyCashFlow = s.rent - totalExpenses; const noi = (s.rent - taxMo - insMo - s.hoaMo - mgmt) * 12; const capRate = s.purchase ? (noi / s.purchase) * 100 : 0; const dscrColor = dscr >= 1.25 ? 'var(--good)' : dscr >= 1.0 ? 'var(--warn)' : 'var(--bad)'; const dscrLabel = dscr >= 1.5 ? 'Excellent' : dscr >= 1.25 ? 'Good - Most Lenders Accept' : dscr >= 1.0 ? 'Acceptable - Break Even' : dscr >= 0.75 ? 'Below Break Even' : 'Poor - High Risk'; return (
set('address')(e.target.value)} placeholder="Property address" />
set('downPct')(s.purchase ? (v/s.purchase)*100 : 0)} />
{money(loan)}
Interest Only
DSCR Score
{(() => { const ang = (180 - Math.min(dscr / 2.5, 1) * 180) * Math.PI / 180; const nx = 100 + 70 * Math.cos(ang); const ny = 100 - 70 * Math.sin(ang); return ; })()} 0 0.75 1.0 1.25 1.5 2.0 2.5+
{dscr.toFixed(2)}
{dscrLabel}
Target: > 1.0 (Breakeven) {"\u00b7"} > 1.25 (Ideal)
0} bad={monthlyCashFlow < 0} label="Monthly Cash Flow" value={(monthlyCashFlow >= 0 ? '+' : '') + money(monthlyCashFlow, 0)} sub="After all expenses" />
Gross Rent+{money(s.rent, 0)}
Principal & Interest−{money(pi, 2)}
Property Tax−{money(taxMo, 2)}
Insurance−{money(insMo, 2)}
HOA−{money(s.hoaMo, 2)}
Management−{money(mgmt, 2)}
PITIA{money(pitia, 2)}
); } Object.assign(window, { RentVsBuyCalc, BuydownCalc, AmortizationCalc, DSCRCalc }); /* ========== App shell: sidebar, header, dashboard, router ========== */ const NAV_ITEMS = [ { group: 'Overview', items: [ { id: 'dashboard', label: 'Dashboard', icon: 'home' }, { id: 'saved', label: 'Saved Presentations', icon: 'bookmark' }, ]}, { group: 'Tools', items: [ { id: 'mortgage', label: 'Mortgage', icon: 'house', component: 'MortgageCalc' }, { id: 'comparison', label: 'Loan Comparison', icon: 'compare', component: 'ComparisonCalc' }, { id: 'refinance', label: 'Refinance', icon: 'refresh', component: 'RefinanceCalc' }, { id: 'preapproval', label: 'Pre-Approval', icon: 'badge', component: 'PreApprovalCalc' }, { id: 'heloc', label: 'HELOC', icon: 'bank', component: 'HELOCCalc' }, { id: 'extra', label: 'Extra Payment', icon: 'plus', component: 'ExtraPaymentCalc' }, { id: 'closing', label: 'Closing Costs', icon: 'receipt', component: 'ClosingCostsCalc' }, { id: 'flip', label: 'Fix & Flip', icon: 'tools', component: 'FixFlipCalc' }, { id: 'rentbuy', label: 'Rent vs Buy', icon: 'trend', component: 'RentVsBuyCalc' }, { id: 'buydown', label: 'Temp Buydown', icon: 'chart', component: 'BuydownCalc' }, { id: 'amort', label: 'Amortization', icon: 'table', component: 'AmortizationCalc' }, { id: 'dscr', label: 'DSCR', icon: 'dscr', component: 'DSCRCalc' }, ]}, { group: 'Account', items: [ { id: 'settings', label: 'Settings', icon: 'settings' }, ]}, ]; const ALL_TOOLS = NAV_ITEMS[1].items; const TOOL_DESCRIPTIONS = { mortgage: 'Monthly payment breakdown', comparison: 'Compare multiple scenarios', refinance: 'Evaluate refinance savings', preapproval: 'Estimate buying power', heloc: 'Home equity line of credit', extra: 'Pay off loan faster', closing: 'Estimate total closing costs', flip: 'Investment property analysis', rentbuy: 'Compare renting and buying', buydown: 'Rate buydown analysis', amort: 'Full amortization schedule', dscr: 'Debt service coverage ratio', }; const TILE_COLORS = { mortgage: 'linear-gradient(135deg, var(--accent), var(--c-violet))', comparison: 'linear-gradient(135deg, var(--c-violet), var(--c-rose))', refinance: 'linear-gradient(135deg, var(--c-teal), var(--c-blue))', preapproval: 'linear-gradient(135deg, var(--c-amber), var(--c-rose))', heloc: 'linear-gradient(135deg, var(--c-emerald), var(--c-teal))', extra: 'linear-gradient(135deg, var(--c-blue), var(--accent))', closing: 'linear-gradient(135deg, var(--c-rose), var(--c-amber))', flip: 'linear-gradient(135deg, var(--c-amber), var(--c-emerald))', rentbuy: 'linear-gradient(135deg, var(--c-violet), var(--accent))', buydown: 'linear-gradient(135deg, var(--c-emerald), var(--accent))', amort: 'linear-gradient(135deg, var(--c-teal), var(--c-violet))', dscr: 'linear-gradient(135deg, var(--c-rose), var(--c-violet))', }; function Sidebar({ active, setActive, collapsed, setCollapsed, mobileOpen, user, onSignOut, access }) { const initial = (user && (user.displayName || user.email) || '?').trim().charAt(0).toUpperCase(); const displayName = user && (user.displayName || (user.email || '').split('@')[0]) || ''; const [portalBusy, setPortalBusy] = useState(false); const handleManageSub = async () => { const fb = window.__firebase; if (!fb || !user) return; setPortalBusy(true); try { const url = await fb.getPortalUrl(user.uid); window.location.assign(url); } catch (e) { console.error('Portal link failed', e); alert('Could not open subscription management. Please try again.'); setPortalBusy(false); } }; const trialBadge = access && access.reason === 'trial' ? (
setActive('settings')} style={{ margin: '0 12px 8px', padding: '8px 12px', background: 'var(--accent-soft)', border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', fontSize: 11, fontWeight: 600, color: 'var(--accent)', textAlign: 'center', cursor: 'pointer', transition: 'background 0.15s' }} onMouseEnter={e => e.currentTarget.style.background = 'var(--accent-soft)'} onMouseLeave={e => e.currentTarget.style.background = 'var(--accent-soft)'} > {access.trialDaysLeft} day{access.trialDaysLeft !== 1 ? 's' : ''} left in free trial
) : null; return ( ); } function Header({ active, setActive, theme, setTheme, onSave, onPresent, savedCount, onMenuToggle }) { const tool = ALL_TOOLS.find(t => t.id === active); const isCalc = !!tool; const title = isCalc ? tool.label + ' Calculator' : active === 'dashboard' ? 'Dashboard' : active === 'saved' ? 'Saved Presentations' : active === 'settings' ? 'Settings' : 'Loan Officer Tools'; const sub = isCalc ? TOOL_DESCRIPTIONS[active] : active === 'dashboard' ? 'Your mortgage toolkit overview' : active === 'saved' ? `${savedCount} saved item${savedCount === 1 ? '' : 's'}` : active === 'settings' ? 'Customize your branding and appearance' : ''; return (
{onMenuToggle && ( )}

{title}

{sub}
{isCalc && ( )}
); } function useHousingWireNews() { const [news, setNews] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const fetchNews = async () => { try { const res = await fetch('https://api.rss2json.com/v1/api.json?rss_url=' + encodeURIComponent('https://www.housingwire.com/feed/')); const data = await res.json(); if (data.status === 'ok' && data.items) { setNews(data.items.slice(0, 8).map(item => ({ title: item.title, link: item.link, pubDate: item.pubDate, source: 'HousingWire', author: item.author || '', categories: item.categories || [] }))); } else { setError('Failed to load news'); } } catch (e) { setError('Failed to fetch news'); } setLoading(false); }; fetchNews(); const interval = setInterval(fetchNews, 10 * 60 * 1000); return () => clearInterval(interval); }, []); return { news, loading, error }; } function timeAgo(dateStr) { const now = new Date(); const then = new Date(dateStr); const diff = Math.floor((now - then) / 1000); if (diff < 60) return 'just now'; if (diff < 3600) return Math.floor(diff / 60) + ' min ago'; if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'; return Math.floor(diff / 86400) + 'd ago'; } function RatesWidget() { const ref = useRef(null); const [scale, setScale] = useState(1); const [containerW, setContainerW] = useState(654); const [mobile, setMobile] = useState(window.innerWidth <= 768); useEffect(() => { const mq = window.matchMedia('(max-width: 768px)'); const handler = e => setMobile(e.matches); mq.addEventListener('change', handler); return () => mq.removeEventListener('change', handler); }, []); const DESKTOP_W = 818; const desktopScale = 1.5; const desktopNativeW = Math.round(DESKTOP_W / desktopScale); const mobileScale = 1.5; const mobileNativeW = Math.round(containerW / mobileScale); const mobileNativeH = Math.round(800 / mobileScale); const barH = 30; const desktopIframeH = 340; const iframeH = mobile ? 800 : desktopIframeH; const iframeSrc = mobile ? '//widgets.mortgagenewsdaily.com/widget/f/rates?t=expanded&sn=true&sc=true&c=336699&u=&cbu=&w=' + (mobileNativeW - 4) + '&h=' + mobileNativeH : 'https://widgets.mortgagenewsdaily.com/widget/f/rates?t=large&sn=true&c=336699&u=&cbu=&w=' + (desktopNativeW - 4) + '&h=340'; useEffect(() => { if (!ref.current) return; const ro = new ResizeObserver(es => { const w = es[0].contentRect.width; setContainerW(w); setScale(Math.min(1, w / DESKTOP_W)); }); ro.observe(ref.current); return () => ro.disconnect(); }, []); const innerH = iframeH + barH * 2; return (
{mobile ? (