Mortgage Calculator & Visualizer
Overview
A common question for any amortizing loan: how much faster does the loan get paid off if I add an extra payment each month and at what point do my principal payments start exceeding my interest payments?
This tool answers both, side by side, for any combination of principal, APR, and monthly payment. The visualization makes the crossover point the month where principal payments first exceed interest payments visible at a glance, alongside the projected payoff date.
How to read the chart: monthly principal is shown in bluish-green, monthly interest in vermillion. The optional extra-payment scenario overlays as dashed lines in the same hues. Two vertical markers indicate the crossover month — when principal payments first exceed interest payments — using a blue diamond for the current plan and a purple triangle for the extra-payment plan. Adjust the sliders below to update the calculation.
Try it
#| '!! shinylive warning !!': |
#| shinylive does not work in self-contained HTML documents.
#| Please set `embed-resources: false` in your metadata.
#| standalone: true
#| viewerHeight: 1050
import io
import re
from datetime import datetime
import matplotlib
matplotlib.use("Agg")
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
from dateutil.relativedelta import relativedelta
from shiny import App, render, ui, reactive
def calculate_amortization(principal, apr, monthly_payment):
monthly_rate = apr / 100 / 12
balance = principal
month = 0
principal_payments, interest_payments, balances = [], [], []
while balance > 0:
interest = balance * monthly_rate
if monthly_payment <= interest:
return None
principal_paid = min(monthly_payment - interest, balance)
balance -= principal_paid
month += 1
principal_payments.append(principal_paid)
interest_payments.append(interest)
balances.append(balance)
crossover_month = next(
(i + 1 for i, (p, intr) in enumerate(zip(principal_payments, interest_payments)) if p > intr),
None,
)
start_date = datetime.now()
return {
"months": month,
"payoff_date": start_date + relativedelta(months=month),
"dates": [start_date + relativedelta(months=i) for i in range(month)],
"principal_payments": principal_payments,
"interest_payments": interest_payments,
"balances": balances,
"crossover_date": start_date + relativedelta(months=crossover_month) if crossover_month else None,
"total_interest": sum(interest_payments),
}
app_ui = ui.page_fluid(
ui.layout_columns(
ui.input_slider("principal", "Principal balance ($)",
min=50_000, max=2_000_000, value=1_000_000, step=5_000),
ui.input_slider("apr", "APR (%)",
min=1.0, max=12.0, value=5.875, step=0.125),
ui.input_slider("monthly_payment", "Monthly payment ($)",
min=500, max=15_000, value=7_054, step=50),
ui.input_slider("extra_payment", "Extra monthly ($)",
min=0, max=5_000, value=1_000, step=50),
col_widths=(3, 3, 3, 3),
),
ui.output_ui("plot"),
ui.output_ui("summary"),
)
def server(input, output, session):
@reactive.calc
def schedules():
current = calculate_amortization(input.principal(), input.apr(), input.monthly_payment())
extra = None
if input.extra_payment() > 0:
extra = calculate_amortization(
input.principal(), input.apr(),
input.monthly_payment() + input.extra_payment(),
)
return current, extra
@render.ui
def plot():
current, extra = schedules()
fig, ax = plt.subplots(figsize=(16, 8))
if current is None:
ax.text(0.5, 0.5, "Monthly payment is too low to cover interest.",
ha="center", va="center", fontsize=12)
ax.set_axis_off()
return fig
# Okabe-Ito colorblind-safe palette
PRINCIPAL = "#009E73" # bluish green
INTEREST = "#D55E00" # vermillion
XOVER_CURRENT = "#0072B2" # blue
XOVER_EXTRA = "#CC79A7" # reddish purple
# Trim the final partial-month payment from each series so the lines
# and fills end cleanly at the last "normal" month instead of
# dropping to a sliver in the payoff month.
ax.fill_between(current["dates"][:-1], current["principal_payments"][:-1],
alpha=0.55, label="Principal (current)", color=PRINCIPAL)
ax.fill_between(current["dates"][:-1], current["interest_payments"][:-1],
alpha=0.55, label="Interest (current)", color=INTEREST)
if extra:
ax.plot(extra["dates"][:-1], extra["principal_payments"][:-1],
linestyle="--", linewidth=2.5, color=PRINCIPAL, label="Principal (extra)")
ax.plot(extra["dates"][:-1], extra["interest_payments"][:-1],
linestyle="--", linewidth=2.5, color=INTEREST, label="Interest (extra)")
# Capture y-limits before adding vertical crossover lines so the
# markers can be anchored to the visible chart top/bottom.
ymin, ymax = ax.get_ylim()
if extra and extra.get("crossover_date"):
ax.plot([extra["crossover_date"], extra["crossover_date"]], [ymin, ymax],
color=XOVER_EXTRA, linestyle=":", linewidth=2.5,
marker="^", markersize=12, markevery=[0, 1],
markerfacecolor=XOVER_EXTRA, markeredgecolor="white", markeredgewidth=1.2,
label=f"Crossover w/ extra: {extra['crossover_date'].strftime('%b %Y')}",
zorder=10, clip_on=False)
if current["crossover_date"]:
ax.plot([current["crossover_date"], current["crossover_date"]], [ymin, ymax],
color=XOVER_CURRENT, linestyle="--", linewidth=2.5,
marker="D", markersize=10, markevery=[0, 1],
markerfacecolor=XOVER_CURRENT, markeredgecolor="white", markeredgewidth=1.2,
label=f"Crossover: {current['crossover_date'].strftime('%b %Y')}",
zorder=10, clip_on=False)
ax.set_ylim(ymin, ymax)
ax.set_ylabel("Payment ($)", fontsize=11)
ax.grid(True, alpha=0.3)
ax.xaxis.set_major_locator(mdates.YearLocator(2))
ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %Y"))
ax.tick_params(axis="both", labelsize=10)
plt.setp(ax.xaxis.get_majorticklabels(), rotation=45, ha="right")
ax.legend(loc="lower center", bbox_to_anchor=(0.5, 1.04),
ncol=3, fontsize=11, frameon=False)
plt.tight_layout()
# Render as inline SVG so the chart scales to the container width and
# stays crisp at any zoom level (no PNG rasterization).
buf = io.StringIO()
fig.savefig(buf, format="svg", bbox_inches="tight")
plt.close(fig)
svg = buf.getvalue()
svg = svg[svg.find("<svg"):]
svg = re.sub(
r'<svg([^>]*?)\s+width="[^"]+"\s+height="[^"]+"',
r'<svg\1 style="width:100%;height:auto;display:block;"',
svg,
count=1,
)
return ui.HTML(svg)
@render.ui
def summary():
current, extra = schedules()
if current is None:
return ui.markdown("**Monthly payment must exceed the first month's interest.**")
def years_months(m):
return f"{m // 12} yrs, {m % 12} mos"
rows = [
ui.tags.tr(ui.tags.th(""), ui.tags.th("Current"), ui.tags.th("With extra")),
ui.tags.tr(
ui.tags.td("Payoff date"),
ui.tags.td(current["payoff_date"].strftime("%b %Y")),
ui.tags.td(extra["payoff_date"].strftime("%b %Y") if extra else "—"),
),
ui.tags.tr(
ui.tags.td("Total months"),
ui.tags.td(years_months(current["months"])),
ui.tags.td(years_months(extra["months"]) if extra else "—"),
),
ui.tags.tr(
ui.tags.td("Total interest"),
ui.tags.td(f"${current['total_interest']:,.0f}"),
ui.tags.td(f"${extra['total_interest']:,.0f}" if extra else "—"),
),
]
if extra:
months_saved = current["months"] - extra["months"]
interest_saved = current["total_interest"] - extra["total_interest"]
rows.append(ui.tags.tr(
ui.tags.td(ui.tags.b("Savings")),
ui.tags.td(""),
ui.tags.td(ui.tags.b(f"{years_months(months_saved)}, ${interest_saved:,.0f} interest")),
))
return ui.tags.table(*rows, style="margin-top:1em; width:100%; border-collapse:collapse;")
app = App(app_ui, server)
The first time this page loads, your browser downloads the Python runtime (~10 MB). After that, it stays cached and the tool runs instantly. All calculations happen locally in your browser — nothing is sent to a server.
Approach
The math is a straight monthly amortization loop. For each month:
- Compute interest on the current balance:
interest = balance × (APR / 12). - Apply the remainder of the monthly payment to principal.
- Subtract from balance, append to the running schedule.
Two derived quantities make the visualization useful:
- Crossover month — the first month where principal payment exceeds interest payment. Early in a loan, almost every dollar goes to interest; this date marks the inflection point.
- Comparison delta — running a second amortization with the extra payment added quantifies the time and interest saved.
Accessibility
The chart uses the Okabe-Ito colorblind-safe palette (bluish-green and vermillion as the primary contrast pair, blue and reddish-purple for the crossover markers) so the visual story holds up under deuteranopia, protanopia, and tritanopia. Information is multi-encoded — color, line style (solid vs dashed vs dotted), and shape (diamond vs triangle marker) — so the chart remains readable for users who rely on shape or pattern rather than hue. A descriptive paragraph above the tool summarizes the chart for screen-reader users, and the summary table below provides the same numerical information in non-graphical form. These choices target Section 508 / WCAG 2.1 AA conformance.
Source
This is the exact Python that runs in your browser via Shinylive — the amortization math, the Shiny UI (sliders, plot output, summary table), and the matplotlib chart code with the Okabe-Ito palette and shape-marked crossover lines.
app.py
import io
import re
from datetime import datetime
import matplotlib
matplotlib.use("Agg")
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
from dateutil.relativedelta import relativedelta
from shiny import App, render, ui, reactive
def calculate_amortization(principal, apr, monthly_payment):
monthly_rate = apr / 100 / 12
balance = principal
month = 0
principal_payments, interest_payments, balances = [], [], []
while balance > 0:
interest = balance * monthly_rate
if monthly_payment <= interest:
return None
principal_paid = min(monthly_payment - interest, balance)
balance -= principal_paid
month += 1
principal_payments.append(principal_paid)
interest_payments.append(interest)
balances.append(balance)
crossover_month = next(
(i + 1 for i, (p, intr) in enumerate(zip(principal_payments, interest_payments)) if p > intr),
None,
)
start_date = datetime.now()
return {
"months": month,
"payoff_date": start_date + relativedelta(months=month),
"dates": [start_date + relativedelta(months=i) for i in range(month)],
"principal_payments": principal_payments,
"interest_payments": interest_payments,
"balances": balances,
"crossover_date": start_date + relativedelta(months=crossover_month) if crossover_month else None,
"total_interest": sum(interest_payments),
}
app_ui = ui.page_fluid(
ui.layout_columns(
ui.input_slider("principal", "Principal balance ($)",
min=50_000, max=2_000_000, value=1_000_000, step=5_000),
ui.input_slider("apr", "APR (%)",
min=1.0, max=12.0, value=5.875, step=0.125),
ui.input_slider("monthly_payment", "Monthly payment ($)",
min=500, max=15_000, value=7_054, step=50),
ui.input_slider("extra_payment", "Extra monthly ($)",
min=0, max=5_000, value=1_000, step=50),
col_widths=(3, 3, 3, 3),
),
ui.output_ui("plot"),
ui.output_ui("summary"),
)
def server(input, output, session):
@reactive.calc
def schedules():
current = calculate_amortization(input.principal(), input.apr(), input.monthly_payment())
extra = None
if input.extra_payment() > 0:
extra = calculate_amortization(
input.principal(), input.apr(),
input.monthly_payment() + input.extra_payment(),
)
return current, extra
@render.ui
def plot():
current, extra = schedules()
fig, ax = plt.subplots(figsize=(16, 8))
if current is None:
ax.text(0.5, 0.5, "Monthly payment is too low to cover interest.",
ha="center", va="center", fontsize=12)
ax.set_axis_off()
return fig
# Okabe-Ito colorblind-safe palette
PRINCIPAL = "#009E73" # bluish green
INTEREST = "#D55E00" # vermillion
XOVER_CURRENT = "#0072B2" # blue
XOVER_EXTRA = "#CC79A7" # reddish purple
# Trim the final partial-month payment so the lines and fills end
# cleanly at the last "normal" month instead of dropping to a sliver
# in the payoff month.
ax.fill_between(current["dates"][:-1], current["principal_payments"][:-1],
alpha=0.55, label="Principal (current)", color=PRINCIPAL)
ax.fill_between(current["dates"][:-1], current["interest_payments"][:-1],
alpha=0.55, label="Interest (current)", color=INTEREST)
if extra:
ax.plot(extra["dates"][:-1], extra["principal_payments"][:-1],
linestyle="--", linewidth=2.5, color=PRINCIPAL, label="Principal (extra)")
ax.plot(extra["dates"][:-1], extra["interest_payments"][:-1],
linestyle="--", linewidth=2.5, color=INTEREST, label="Interest (extra)")
# Capture y-limits before adding vertical crossover lines so the
# markers can be anchored to the visible chart top/bottom.
ymin, ymax = ax.get_ylim()
if extra and extra.get("crossover_date"):
ax.plot([extra["crossover_date"], extra["crossover_date"]], [ymin, ymax],
color=XOVER_EXTRA, linestyle=":", linewidth=2.5,
marker="^", markersize=12, markevery=[0, 1],
markerfacecolor=XOVER_EXTRA, markeredgecolor="white", markeredgewidth=1.2,
label=f"Crossover w/ extra: {extra['crossover_date'].strftime('%b %Y')}",
zorder=10, clip_on=False)
if current["crossover_date"]:
ax.plot([current["crossover_date"], current["crossover_date"]], [ymin, ymax],
color=XOVER_CURRENT, linestyle="--", linewidth=2.5,
marker="D", markersize=10, markevery=[0, 1],
markerfacecolor=XOVER_CURRENT, markeredgecolor="white", markeredgewidth=1.2,
label=f"Crossover: {current['crossover_date'].strftime('%b %Y')}",
zorder=10, clip_on=False)
ax.set_ylim(ymin, ymax)
ax.set_ylabel("Payment ($)", fontsize=11)
ax.grid(True, alpha=0.3)
ax.xaxis.set_major_locator(mdates.YearLocator(2))
ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %Y"))
ax.tick_params(axis="both", labelsize=10)
plt.setp(ax.xaxis.get_majorticklabels(), rotation=45, ha="right")
ax.legend(loc="lower center", bbox_to_anchor=(0.5, 1.04),
ncol=3, fontsize=11, frameon=False)
plt.tight_layout()
# Render as inline SVG so the chart scales to the container width and
# stays crisp at any zoom level (no PNG rasterization).
buf = io.StringIO()
fig.savefig(buf, format="svg", bbox_inches="tight")
plt.close(fig)
svg = buf.getvalue()
svg = svg[svg.find("<svg"):]
svg = re.sub(
r'<svg([^>]*?)\s+width="[^"]+"\s+height="[^"]+"',
r'<svg\1 style="width:100%;height:auto;display:block;"',
svg,
count=1,
)
return ui.HTML(svg)
@render.ui
def summary():
current, extra = schedules()
if current is None:
return ui.markdown("**Monthly payment must exceed the first month's interest.**")
def years_months(m):
return f"{m // 12} yrs, {m % 12} mos"
rows = [
ui.tags.tr(ui.tags.th(""), ui.tags.th("Current"), ui.tags.th("With extra")),
ui.tags.tr(
ui.tags.td("Payoff date"),
ui.tags.td(current["payoff_date"].strftime("%b %Y")),
ui.tags.td(extra["payoff_date"].strftime("%b %Y") if extra else "—"),
),
ui.tags.tr(
ui.tags.td("Total months"),
ui.tags.td(years_months(current["months"])),
ui.tags.td(years_months(extra["months"]) if extra else "—"),
),
ui.tags.tr(
ui.tags.td("Total interest"),
ui.tags.td(f"${current['total_interest']:,.0f}"),
ui.tags.td(f"${extra['total_interest']:,.0f}" if extra else "—"),
),
]
if extra:
months_saved = current["months"] - extra["months"]
interest_saved = current["total_interest"] - extra["total_interest"]
rows.append(ui.tags.tr(
ui.tags.td(ui.tags.b("Savings")),
ui.tags.td(""),
ui.tags.td(ui.tags.b(f"{years_months(months_saved)}, ${interest_saved:,.0f} interest")),
))
return ui.tags.table(*rows, style="margin-top:1em; width:100%; border-collapse:collapse;")
app = App(app_ui, server)