Skip to content

Instantly share code, notes, and snippets.

@Spacerat
Created April 21, 2026 02:49
Show Gist options
  • Select an option

  • Save Spacerat/99c322b8c75685516f32141c14a1f671 to your computer and use it in GitHub Desktop.

Select an option

Save Spacerat/99c322b8c75685516f32141c14a1f671 to your computer and use it in GitHub Desktop.
The Incremental Benefit-Cost Method: an interactive explainer
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Incremental Benefit-Cost Method</title>
<style>
:root {
--bg: #fafaf9;
--panel: #ffffff;
--ink: #1f2937;
--muted: #6b7280;
--border: #e5e7eb;
--accent: #0d9488;
--accent-soft: #ccfbf1;
--good: #16a34a;
--good-soft: #dcfce7;
--bad: #dc2626;
--bad-soft: #fee2e2;
--warn: #d97706;
--highlight: #fef3c7;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--ink);
line-height: 1.55;
font-size: 16px;
}
main {
max-width: 880px;
margin: 0 auto;
padding: 48px 24px 96px;
}
header {
margin-bottom: 48px;
}
h1 {
font-size: 2.1rem;
margin: 0 0 8px;
letter-spacing: -0.02em;
}
header .tagline {
color: var(--muted);
font-size: 1.05rem;
margin: 0;
}
section {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 28px 32px;
margin-bottom: 28px;
}
section h2 {
margin-top: 0;
margin-bottom: 16px;
font-size: 1.4rem;
letter-spacing: -0.01em;
}
p { margin: 0.6em 0; }
code {
font-family: "SF Mono", Menlo, Consolas, monospace;
background: #f3f4f6;
padding: 1px 6px;
border-radius: 4px;
font-size: 0.92em;
}
.muted { color: var(--muted); }
.rules {
counter-reset: step;
list-style: none;
padding: 0;
margin: 12px 0 0;
}
.rules li {
counter-increment: step;
padding: 10px 0 10px 44px;
position: relative;
border-top: 1px solid var(--border);
}
.rules li:first-child { border-top: none; }
.rules li::before {
content: counter(step);
position: absolute;
left: 0;
top: 10px;
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--accent-soft);
color: var(--accent);
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.9rem;
}
/* hook */
.hook-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 16px;
}
.hook-card {
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
background: #fff;
}
.hook-card h3 { margin: 0 0 8px; font-size: 1rem; }
.hook-card .stat { display: flex; justify-content: space-between; font-size: 0.95rem; padding: 3px 0; }
.hook-card .ratio {
margin-top: 10px;
font-weight: 600;
font-size: 1.2rem;
color: var(--accent);
}
.hook-reveal {
margin-top: 18px;
padding: 14px 16px;
background: var(--highlight);
border-radius: 8px;
display: none;
}
.hook-reveal.shown { display: block; }
.hook-reveal strong { color: var(--warn); }
button.reveal-btn {
margin-top: 16px;
background: var(--accent);
color: white;
border: none;
padding: 9px 18px;
border-radius: 6px;
cursor: pointer;
font-size: 0.95rem;
font-weight: 500;
}
button.reveal-btn:hover { background: #0f766e; }
/* example widget */
.controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 14px 20px;
padding: 14px 16px;
background: #f9fafb;
border: 1px solid var(--border);
border-radius: 8px;
margin: 16px 0;
font-size: 0.94rem;
}
.controls label { display: flex; align-items: center; gap: 6px; }
.toggle {
display: inline-flex;
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
background: white;
}
.toggle button {
background: transparent;
border: none;
padding: 6px 14px;
cursor: pointer;
font-size: 0.9rem;
color: var(--muted);
}
.toggle button.active {
background: var(--accent);
color: white;
}
input[type="number"] {
width: 82px;
padding: 5px 7px;
border: 1px solid var(--border);
border-radius: 5px;
font-family: inherit;
font-size: 0.92rem;
text-align: right;
}
.alt-table {
width: 100%;
border-collapse: collapse;
margin: 10px 0;
font-size: 0.93rem;
}
.alt-table th, .alt-table td {
padding: 10px 8px;
text-align: right;
border-bottom: 1px solid var(--border);
}
.alt-table th {
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
font-size: 0.72rem;
letter-spacing: 0.04em;
}
.alt-table td:first-child, .alt-table th:first-child { text-align: left; }
.alt-table tr.infeasible td:first-child::after {
content: "B/C < 1";
background: var(--bad-soft);
color: var(--bad);
margin-left: 8px;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.72rem;
font-weight: 500;
}
.alt-table tr.winner { background: var(--good-soft); }
.alt-table tr.winner td:first-child::after {
content: "✓ Best choice";
background: var(--good);
color: white;
margin-left: 8px;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.72rem;
font-weight: 500;
}
.bars {
margin: 14px 0 22px;
}
.bar-row {
display: grid;
grid-template-columns: 140px 1fr;
align-items: center;
gap: 10px;
margin: 6px 0;
font-size: 0.87rem;
}
.bar-name { color: var(--muted); text-align: right; }
.bar-track { position: relative; height: 22px; }
.bar-cost {
position: absolute;
left: 0;
top: 2px;
height: 18px;
background: #e5e7eb;
border-radius: 3px;
}
.bar-benefit {
position: absolute;
left: 0;
top: 5px;
height: 12px;
background: var(--accent);
border-radius: 3px;
opacity: 0.85;
}
.bar-legend {
display: flex;
gap: 16px;
font-size: 0.8rem;
color: var(--muted);
margin-top: 8px;
padding-left: 150px;
}
.bar-legend span { display: flex; align-items: center; gap: 6px; }
.bar-legend .swatch {
display: inline-block;
width: 14px;
height: 10px;
border-radius: 2px;
}
.incr-viz {
margin: 12px 0 8px;
}
.incr-viz.hero {
background: #fff;
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px 18px;
margin: 16px 0;
}
.incr-row {
display: grid;
grid-template-columns: 160px 1fr 70px;
align-items: center;
gap: 12px;
margin: 6px 0;
font-size: 0.88rem;
}
.incr-label {
color: var(--muted);
text-align: right;
line-height: 1.2;
}
.incr-bar-track {
background: #f3f4f6;
border-radius: 4px;
height: 22px;
overflow: hidden;
}
.incr-bar {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.incr-bar.cost { background: #9ca3af; }
.incr-bar.benefit { background: var(--accent); }
.incr-value {
font-family: "SF Mono", Menlo, monospace;
font-weight: 600;
font-size: 0.85rem;
color: var(--ink);
}
.incr-caption {
font-size: 0.9rem;
margin-top: 10px;
color: var(--ink);
}
.incr-caption.pass { color: var(--good); font-weight: 500; }
.incr-caption.fail { color: var(--bad); font-weight: 500; }
.condition-control {
margin: 16px 0;
padding: 16px 18px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fff;
}
.condition-control-label {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 0.95rem;
font-weight: 500;
margin-bottom: 12px;
}
.pci-value {
font-family: "SF Mono", Menlo, monospace;
font-weight: 600;
font-size: 1.02rem;
}
.condition-band {
display: inline-block;
padding: 2px 9px;
border-radius: 4px;
font-size: 0.75rem;
margin-left: 8px;
text-transform: capitalize;
color: white;
font-weight: 500;
letter-spacing: 0.02em;
}
.condition-band.excellent { background: #16a34a; }
.condition-band.good { background: #65a30d; }
.condition-band.fair { background: #ca8a04; }
.condition-band.poor { background: #ea580c; }
.condition-band.failed { background: #dc2626; }
.condition-control input[type=range] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 8px;
background: linear-gradient(to right, #dc2626 0%, #ea580c 25%, #ca8a04 50%, #65a30d 75%, #16a34a 100%);
border-radius: 4px;
outline: none;
padding: 0;
margin: 0;
}
.condition-control input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
background: white;
border: 2px solid var(--ink);
border-radius: 50%;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.condition-control input[type=range]::-moz-range-thumb {
width: 20px;
height: 20px;
background: white;
border: 2px solid var(--ink);
border-radius: 50%;
cursor: pointer;
}
.condition-scale {
display: flex;
justify-content: space-between;
font-size: 0.72rem;
color: var(--muted);
margin-top: 6px;
}
.condition-note {
font-size: 0.85rem;
color: var(--muted);
margin: 10px 0 0;
}
.hook-slider-row {
display: grid;
grid-template-columns: 62px 1fr 64px;
align-items: center;
gap: 10px;
margin: 8px 0;
font-size: 0.85rem;
}
.hook-slider-row label {
color: var(--muted);
}
.hook-slider-row input[type=range] {
width: 100%;
accent-color: var(--accent);
cursor: pointer;
}
.hook-val {
font-family: "SF Mono", Menlo, monospace;
font-weight: 600;
font-size: 0.85rem;
text-align: right;
}
.hook-analysis {
margin-top: 18px;
}
.hook-verdict {
font-size: 0.95rem;
margin: 0 0 10px;
}
.benefit-mode {
margin: 16px 0;
padding: 14px 18px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fff;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 14px 22px;
font-size: 0.93rem;
}
.benefit-mode > label,
.benefit-mode .wtp-input {
display: flex;
align-items: center;
gap: 8px;
}
.wtp-input {
font-size: 0.9rem;
}
.wtp-input input[type="number"] {
width: 96px;
}
.discount-explainer {
margin-top: 12px;
}
.disc-rate-control {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 18px;
background: #fff;
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 18px;
font-size: 0.95rem;
}
.disc-rate-control input[type="range"] {
flex: 1;
accent-color: var(--accent);
max-width: 360px;
}
.disc-rate-control .disc-rate-val {
font-family: "SF Mono", Menlo, monospace;
font-weight: 600;
min-width: 50px;
text-align: right;
}
.disc-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
}
@media (max-width: 700px) {
.disc-grid { grid-template-columns: 1fr; }
}
.disc-alt {
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
background: #fff;
}
.disc-alt h3 {
margin: 0 0 4px;
font-size: 1rem;
}
.disc-alt .tagline {
color: var(--muted);
font-size: 0.88rem;
margin: 0 0 10px;
}
.disc-chart {
display: flex;
align-items: flex-end;
gap: 2px;
height: 120px;
border-bottom: 1px solid var(--border);
padding: 0 2px 2px;
position: relative;
}
.disc-bar-col {
flex: 1 1 0;
min-width: 6px;
position: relative;
height: 100%;
}
.disc-bar-nominal {
position: absolute;
left: 2px;
right: 2px;
bottom: 0;
border: 1px dashed #9ca3af;
border-bottom: none;
background: rgba(156, 163, 175, 0.08);
}
.disc-bar-discounted {
position: absolute;
left: 3px;
right: 3px;
bottom: 0;
background: var(--accent);
border-radius: 2px 2px 0 0;
transition: height 0.2s ease;
}
.disc-year-labels {
display: flex;
gap: 2px;
padding: 4px 2px 0;
font-size: 0.65rem;
color: var(--muted);
justify-content: space-between;
}
.disc-year-labels span { flex: 1 1 0; text-align: center; }
.disc-totals {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 12px;
margin-top: 12px;
font-size: 0.88rem;
}
.disc-totals .label { color: var(--muted); }
.disc-totals .val {
font-family: "SF Mono", Menlo, monospace;
font-weight: 500;
text-align: right;
}
.disc-npv-row {
grid-column: 1 / -1;
padding-top: 6px;
border-top: 1px solid var(--border);
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
font-size: 0.95rem;
font-weight: 600;
}
.disc-npv-row .val { font-family: "SF Mono", Menlo, monospace; }
.disc-alt.winner { border-color: var(--good); box-shadow: 0 0 0 2px var(--good-soft); }
.disc-alt.infeasible { opacity: 0.55; }
.disc-verdict {
margin-top: 16px;
padding: 12px 16px;
background: var(--accent-soft);
border-radius: 8px;
font-size: 0.95rem;
text-align: center;
}
.disc-legend {
display: flex;
gap: 14px;
font-size: 0.78rem;
color: var(--muted);
margin-top: 6px;
justify-content: center;
}
.disc-legend span { display: flex; align-items: center; gap: 5px; }
.disc-legend .swatch-outline {
display: inline-block; width: 12px; height: 10px;
border: 1px dashed #9ca3af;
border-bottom: none;
}
.disc-legend .swatch-filled {
display: inline-block; width: 12px; height: 10px;
background: var(--accent); border-radius: 2px 2px 0 0;
}
.walkthrough {
margin-top: 18px;
}
.walkthrough h3 {
font-size: 1.05rem;
margin: 0 0 10px;
}
.step {
border: 1px solid var(--border);
border-left: 4px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
margin: 10px 0;
background: #fff;
font-size: 0.94rem;
}
.step.pass { border-left-color: var(--good); }
.step.fail { border-left-color: var(--bad); }
.step-title {
font-weight: 600;
margin-bottom: 6px;
color: var(--ink);
}
.step .math {
font-family: "SF Mono", Menlo, monospace;
font-size: 0.88rem;
background: #f9fafb;
padding: 8px 10px;
border-radius: 5px;
margin: 8px 0;
color: #374151;
}
.step .verdict {
font-weight: 500;
margin-top: 6px;
}
.step.pass .verdict { color: var(--good); }
.step.fail .verdict { color: var(--bad); }
.final {
margin-top: 14px;
padding: 14px 18px;
background: var(--accent-soft);
border-radius: 8px;
font-size: 0.98rem;
}
.final strong { color: var(--accent); }
.formula-note {
font-size: 0.85rem;
color: var(--muted);
margin-top: 8px;
padding: 10px 14px;
background: #f9fafb;
border-radius: 6px;
}
.formula-note code {
background: white;
border: 1px solid var(--border);
}
footer {
text-align: center;
font-size: 0.85rem;
color: var(--muted);
margin-top: 40px;
}
</style>
</head>
<body>
<main>
<header>
<h1>The Incremental Benefit-Cost Method</h1>
<p class="tagline">A visual walkthrough for choosing between mutually exclusive alternatives.</p>
</header>
<section>
<h2>Why not just pick the highest B/C ratio?</h2>
<p>Here are two mutually exclusive projects — you can only do one. Drag the sliders to change their costs and benefits, and watch which one wins and why.</p>
<div class="hook-grid">
<div class="hook-card">
<h3>Project A</h3>
<div class="hook-slider-row">
<label>Cost</label>
<input type="range" min="0" max="2000" step="10" value="100" data-hook="aCost">
<span class="hook-val" id="hook-aCost-val">$100k</span>
</div>
<div class="hook-slider-row">
<label>Benefit</label>
<input type="range" min="0" max="2000" step="10" value="250" data-hook="aBenefit">
<span class="hook-val" id="hook-aBenefit-val">$250k</span>
</div>
<div class="ratio" id="hook-aRatio">B/C = 2.50</div>
</div>
<div class="hook-card">
<h3>Project B</h3>
<div class="hook-slider-row">
<label>Cost</label>
<input type="range" min="0" max="2000" step="10" value="500" data-hook="bCost">
<span class="hook-val" id="hook-bCost-val">$500k</span>
</div>
<div class="hook-slider-row">
<label>Benefit</label>
<input type="range" min="0" max="2000" step="10" value="900" data-hook="bBenefit">
<span class="hook-val" id="hook-bBenefit-val">$900k</span>
</div>
<div class="ratio" id="hook-bRatio">B/C = 1.80</div>
</div>
</div>
<div class="hook-analysis" id="hook-analysis"></div>
<p class="muted" style="font-size:0.85rem; margin-top:14px;">Try: pushing B's benefit down below $600k — now A wins, because the upgrade no longer pays for itself. Or set both benefits below their costs — neither passes B/C ≥ 1 and the right answer is "do nothing."</p>
</section>
<section>
<h2>The method, in 5 steps</h2>
<ol class="rules">
<li><strong>Drop any alternative with B/C &lt; 1.</strong> If the benefits don't exceed the costs, it's not worth doing at all.</li>
<li><strong>Sort the survivors by cost, cheapest first.</strong></li>
<li><strong>Start with the cheapest as your current champion.</strong></li>
<li><strong>Walk up the list. For each step up in cost, compute</strong> <code>ΔB / ΔC</code>. If that ratio is ≥ 1, the upgrade pays for itself — the challenger becomes the new champion. Otherwise, the current champion stays.</li>
<li><strong>Whoever is champion at the end wins.</strong></li>
</ol>
</section>
<section>
<h2>A caveat: benefits and costs need the same unit</h2>
<p>The "ratio ≥ 1" test in the method above only means <em>"the upgrade pays for itself"</em> when ΔBenefit and ΔCost are measured in the same unit. Cost is always dollars. Benefit needs to be dollars too — or convertible to dollars — for the threshold to carry its economic meaning.</p>
<p>Two common ways to handle this:</p>
<ul>
<li><strong>Monetize everything.</strong> Assign dollar values to avoided maintenance, user time, fuel, crashes, emissions — then benefit is directly in $, the ratio is dimensionless, and the ≥ 1 threshold applies. This is true benefit-cost analysis, but it requires defending every monetization assumption (value of time, value of statistical life, social cost of carbon, …).</li>
<li><strong>Use a physical benefit metric with a willingness-to-pay.</strong> Keep benefit in an intuitive non-monetary unit — for pavement, something like <code>PCI-points × sqyd</code> (how much condition improvement, weighted by how much pavement you improved). To apply the ≥ 1 test you need a <strong>willingness to pay</strong> (WTP) figure: how many dollars is your agency prepared to spend per unit of that benefit? Multiply benefit by WTP and you're back in dollars.</li>
</ul>
<p>Without either, you can still <em>rank</em> options by cost-effectiveness (benefit-units per dollar), but the "is this even worth doing at all?" question has no natural answer. You also lose the "economically optimal budget" concept we discussed — there's no universal threshold to stop at.</p>
<p class="muted" style="font-size:0.9rem;">In Example 1 below, you can toggle between monetized-benefit mode and physical-benefit-plus-WTP mode. Watch what happens when you drop WTP low enough: formerly-good treatments stop clearing the ≥ 1 bar.</p>
</section>
<section>
<h2>Example 1: Pavement alternatives</h2>
<p>You're managing a 1-lane-mile stretch of asphalt road. Four treatments are on the table — ranging from light preservation (crack seal) to full reconstruction. You can only do one. Each has an upfront cost, an annual benefit (avoided maintenance plus reduced vehicle operating costs from a smoother ride), and a service life before the next intervention is needed.</p>
<p class="muted" style="font-size:0.9rem;">Each treatment only delivers its full benefit when the road is in the right condition for it — you don't reconstruct a near-perfect road, and crack sealing can't save a structurally failed one. The PCI (Pavement Condition Index) slider below reflects this: each treatment's benefit scales by how appropriate it is for the current condition.</p>
<p class="muted" style="font-size:0.9rem;">Four things to try: (1) drag the PCI slider from "failed" to "perfect" and watch the winning treatment march from reconstruct → mill & overlay → thin overlay → crack seal → do-nothing. (2) Flip to discounted mode and push the rate to 10% — even at a good PCI, the long-lived treatments stop making sense. (3) Switch benefit units from $ to "PCI · sqyd" and drop WTP below $500 — treatments start failing the feasibility bar because their physical benefit isn't being valued highly enough to clear their costs. (4) Edit any cost/life/peak-benefit number inline.</p>
<div id="pavement-widget"></div>
<p class="muted" style="font-size:0.82rem; margin-top:14px;">Cost and service-life ranges adapted from <a href="https://www.fhwa.dot.gov/pavement/preservation/pubs/perfeval/chap03.cfm" style="color:var(--accent);">FHWA HIF-10-020 Chapter 3</a> (2009 values inflated to mid-2020s dollars) and contemporary state DOT pavement-management guidance. Annual benefit figures are illustrative — in practice they come from a life-cycle cost analysis incorporating traffic volume, roughness progression, and user-cost models.</p>
</section>
<section>
<h2>Example 2: What "discounting" actually does</h2>
<p>The pavement widget above has a raw/discounted toggle. Here's what flipping it actually means — and why it can change the answer.</p>
<p>A dollar of benefit delivered in year 20 isn't worth a dollar today: you could have invested that dollar, inflation eats some of it, and the future is uncertain. So when we compare a lump-sum cost today against a stream of benefits spread over decades, we <em>discount</em> each future year's benefit back to a present value using:</p>
<p class="math" style="font-family:'SF Mono',Menlo,monospace;background:#f9fafb;padding:10px 14px;border-radius:6px;">
PV(cash flow at year n) = cash flow / (1 + r)<sup>n</sup>
</p>
<p>Below are two projects that cost the same $100 today but return benefit on different schedules. Drag the discount rate and watch the bars on the right-hand side (future years) shrink faster than the ones on the left. One project wins at low rates, the other at high rates — because discounting punishes patience.</p>
<div id="discount-widget"></div>
<p class="muted" style="font-size:0.9rem; margin-top:12px;">
Benefit unit here is shown in dollars for simplicity — discounting works the same in any unit as long as your benefit stream is in that unit over time. If you were in the WTP-mode from Example 1, you'd apply WTP to convert units to dollars first, then discount.
</p>
</section>
<section>
<h2>Key takeaways</h2>
<ul>
<li><strong>Highest B/C ratio ≠ best choice.</strong> Small projects often have inflated ratios because their denominators are small. The incremental view is what tells you whether the extra cost of a bigger option is worth it.</li>
<li><strong>Alternatives must be mutually exclusive.</strong> If you can do several at once, this isn't the right tool (you'd use portfolio optimization).</li>
<li><strong>Benefit and cost must be in the same unit.</strong> The "≥ 1" threshold is only meaningful when both sides are in dollars. If benefit is in a physical unit (PCI · sqyd, lane-miles preserved, tons of CO₂ avoided), you need a willingness-to-pay to convert — and WTP is itself a policy choice.</li>
<li><strong>Benefit depends on starting condition.</strong> Benefit is the gap between "with treatment" and "without treatment" trajectories — and that gap is zero on a perfect road or a failed one.</li>
<li><strong>Discount rate matters a lot.</strong> Long-lived investments get punished by high discount rates, because most of their benefit is far in the future. Discounting is how we compare money across time.</li>
<li><strong>Garbage in, garbage out.</strong> The answer is only as good as your cost and benefit estimates. Sensitivity analysis (sweeping the rate, sweeping condition, sweeping WTP) is how you keep yourself honest.</li>
</ul>
</section>
<footer>Built to explain one concept clearly. No tracking, no dependencies.</footer>
</main>
<script>
const pavementAlternatives = [
{ name: "Crack seal", cost: 5, annual: 5, life: 3, effectivenessCurve: [65, 78, 84, 95] },
{ name: "Thin HMA overlay", cost: 120, annual: 20, life: 10, effectivenessCurve: [45, 65, 75, 90] },
{ name: "Mill & overlay", cost: 350, annual: 55, life: 12, effectivenessCurve: [25, 45, 60, 80] },
{ name: "Reconstruct", cost: 900, annual: 100, life: 25, effectivenessCurve: [0, 0, 30, 70] },
];
const discountAlternatives = [
{ key: "a", name: "Alternative A: Quick win", cost: 100, annual: 50, life: 3, tagline: "Pay $100 today, save $50/yr for 3 years." },
{ key: "b", name: "Alternative B: Patient play", cost: 100, annual: 18, life: 15, tagline: "Pay $100 today, save $18/yr for 15 years." },
];
function pvAnnuity(annual, rate, years) {
if (rate === 0) return annual * years;
return annual * (1 - Math.pow(1 + rate, -years)) / rate;
}
function pwLinear(x, [a, b, c, d]) {
const rise = b - a;
const fall = d - c;
if (x <= a) return rise === 0 ? 1 : 0;
if (x <= b) return (x - a) / rise;
if (x <= c) return 1;
if (x <= d) return fall === 0 ? 0 : 1 - (x - c) / fall;
return 0;
}
function effectivenessAt(alt, useCondition, pci) {
if (!useCondition || !alt.effectivenessCurve) return 1;
return pwLinear(pci, alt.effectivenessCurve);
}
function analyze(alts, mode, rate, useCondition, pci, benefitMode, wtp) {
const dollarMultiplier = benefitMode === "effectiveness" ? (wtp / 1000) : 1;
const enriched = alts.map(a => {
const eff = effectivenessAt(a, useCondition, pci);
const effAnnual = a.annual * eff;
const effAnnualDollars = effAnnual * dollarMultiplier;
const benefit = mode === "raw" ? effAnnualDollars * a.life : pvAnnuity(effAnnualDollars, rate, a.life);
return {
...a,
effectivenessPct: eff,
effAnnual,
effAnnualDollars,
benefit,
bcr: a.cost > 0 ? benefit / a.cost : 0,
};
});
const feasible = enriched
.filter(a => a.bcr >= 1)
.sort((a, b) => a.cost - b.cost);
const steps = [];
let champion = feasible[0] || null;
for (let i = 1; i < feasible.length; i++) {
const challenger = feasible[i];
const dB = challenger.benefit - champion.benefit;
const dC = challenger.cost - champion.cost;
const ratio = dC > 0 ? dB / dC : (dB >= 0 ? Infinity : -Infinity);
const pass = ratio >= 1;
steps.push({ champion, challenger, dB, dC, ratio, pass });
if (pass) champion = challenger;
}
return { enriched, feasible, steps, winner: champion };
}
function fmt(n, digits = 1) {
if (!isFinite(n)) return "—";
return n.toFixed(digits);
}
function money(n) {
return "$" + fmt(n, 1) + "k";
}
function conditionBand(pci) {
if (pci >= 85) return "excellent";
if (pci >= 70) return "good";
if (pci >= 55) return "fair";
if (pci >= 40) return "poor";
return "failed";
}
function createWidget(mountEl, alternatives, options = {}) {
const state = {
alts: alternatives.map(a => ({ ...a })),
mode: "raw",
rate: 4,
useCondition: !!options.useCondition,
pci: options.defaultPci != null ? options.defaultPci : 65,
benefitMode: options.benefitMode === "effectiveness" ? "effectiveness" : "dollar",
wtp: options.defaultWtp != null ? options.defaultWtp : 1000,
useBenefitMode: !!options.useBenefitMode,
};
mountEl.innerHTML = "";
let wtpLabelEl, wtpInputEl, benefitModeButtons;
if (state.useBenefitMode) {
const benefitControl = document.createElement("div");
benefitControl.className = "benefit-mode";
benefitControl.innerHTML = `
<label>
<span>Benefit unit:</span>
<div class="toggle">
<button data-bmode="dollar" class="${state.benefitMode === "dollar" ? "active" : ""}">$ (monetized)</button>
<button data-bmode="effectiveness" class="${state.benefitMode === "effectiveness" ? "active" : ""}">PCI · sqyd (physical)</button>
</div>
</label>
<div class="wtp-input" style="display: ${state.benefitMode === "effectiveness" ? "flex" : "none"};">
<label>
Willingness to pay:
<input type="number" class="wtp" min="0" max="10000" step="50" value="${state.wtp}">
<span class="muted">$ per benefit unit</span>
</label>
</div>
`;
mountEl.appendChild(benefitControl);
wtpInputEl = benefitControl.querySelector(".wtp");
benefitModeButtons = benefitControl.querySelectorAll(".toggle button");
benefitModeButtons.forEach(btn => {
btn.addEventListener("click", () => {
benefitModeButtons.forEach(b => b.classList.remove("active"));
btn.classList.add("active");
state.benefitMode = btn.dataset.bmode;
benefitControl.querySelector(".wtp-input").style.display =
state.benefitMode === "effectiveness" ? "flex" : "none";
render();
});
});
wtpInputEl.addEventListener("input", e => {
const v = +e.target.value;
if (isFinite(v) && v >= 0) {
state.wtp = v;
render();
}
});
}
let pciNumEl, conditionBandEl;
if (state.useCondition) {
const conditionControl = document.createElement("div");
conditionControl.className = "condition-control";
conditionControl.innerHTML = `
<div class="condition-control-label">
<span>Current pavement condition</span>
<span class="pci-value">PCI <span class="pci-num">${state.pci}</span><span class="condition-band"></span></span>
</div>
<input type="range" min="0" max="100" step="1" value="${state.pci}" class="pci-slider">
<div class="condition-scale">
<span>0 — failed</span>
<span>50 — fair</span>
<span>100 — perfect</span>
</div>
<p class="condition-note">Each treatment only delivers its full benefit when applied at the right condition. Drag the slider — benefit and the winning choice will shift.</p>
`;
mountEl.appendChild(conditionControl);
pciNumEl = conditionControl.querySelector(".pci-num");
conditionBandEl = conditionControl.querySelector(".condition-band");
conditionControl.querySelector(".pci-slider").addEventListener("input", e => {
state.pci = +e.target.value;
render();
});
}
const controls = document.createElement("div");
controls.className = "controls";
controls.innerHTML = `
<div class="toggle" role="tablist">
<button data-mode="raw" class="active">Raw (undiscounted)</button>
<button data-mode="discounted">Discounted (PV)</button>
</div>
<label class="rate-input" style="display:none;">
Discount rate:
<input type="number" min="0" max="30" step="0.5" value="4">
<span class="muted">%</span>
</label>
`;
mountEl.appendChild(controls);
const formulaNote = document.createElement("div");
formulaNote.className = "formula-note";
formulaNote.style.display = "none";
formulaNote.innerHTML = `
Each alternative's benefit is the present value of its annual benefit stream:
<code>PV = A × [1 − (1 + r)<sup>−n</sup>] / r</code>
where <code>A</code> is the annual benefit, <code>r</code> is the discount rate, and <code>n</code> is the service life.
`;
mountEl.appendChild(formulaNote);
const table = document.createElement("table");
table.className = "alt-table";
mountEl.appendChild(table);
const bars = document.createElement("div");
bars.className = "bars";
mountEl.appendChild(bars);
const walkthrough = document.createElement("div");
walkthrough.className = "walkthrough";
mountEl.appendChild(walkthrough);
function render() {
if (state.useCondition) {
pciNumEl.textContent = state.pci;
const band = conditionBand(state.pci);
conditionBandEl.textContent = band;
conditionBandEl.className = `condition-band ${band}`;
}
const rate = state.rate / 100;
const result = analyze(state.alts, state.mode, rate, state.useCondition, state.pci, state.benefitMode, state.wtp);
const winnerIdx = result.winner
? state.alts.findIndex(a => a.name === result.winner.name)
: -1;
const isPhysical = state.benefitMode === "effectiveness";
const annualUnitLabel = isPhysical ? "units/yr" : "$k/yr";
const annualHeader = state.useCondition
? `Peak annual (${annualUnitLabel})`
: `Annual benefit (${annualUnitLabel})`;
const rows = state.alts.map((a, i) => {
const enriched = result.enriched.find(e => e.name === a.name);
const isWinner = i === winnerIdx;
const infeasible = enriched.bcr < 1;
let effCell = "";
if (state.useCondition) {
const effValue = isPhysical
? `${fmt(enriched.effAnnual, 1)} units/yr → ${money(enriched.effAnnualDollars)}/yr`
: `${money(enriched.effAnnual)}/yr`;
effCell = `<td>${(enriched.effectivenessPct * 100).toFixed(0)}% → ${effValue}</td>`;
}
return `
<tr class="${isWinner ? "winner" : ""} ${infeasible ? "infeasible" : ""}" data-idx="${i}">
<td>${a.name}</td>
<td><input type="number" data-field="cost" data-idx="${i}" value="${a.cost}" min="0" step="1"></td>
<td><input type="number" data-field="annual" data-idx="${i}" value="${a.annual}" min="0" step="1"></td>
<td><input type="number" data-field="life" data-idx="${i}" value="${a.life}" min="1" step="1"></td>
${effCell}
<td>${money(enriched.benefit)}</td>
<td>${fmt(enriched.bcr, 2)}</td>
</tr>
`;
}).join("");
table.innerHTML = `
<thead>
<tr>
<th>Alternative</th>
<th>Cost ($k)</th>
<th>${annualHeader}</th>
<th>Life (yr)</th>
${state.useCondition ? `<th>Effective at current PCI${isPhysical ? " (after WTP)" : ""}</th>` : ""}
<th>Total benefit ($k)</th>
<th>B/C</th>
</tr>
</thead>
<tbody>${rows}</tbody>
`;
const maxCost = Math.max(...result.enriched.map(a => a.cost), 1);
const maxBenefit = Math.max(...result.enriched.map(a => a.benefit), 1);
const scale = Math.max(maxCost, maxBenefit);
bars.innerHTML = `
${result.enriched.map(a => `
<div class="bar-row">
<div class="bar-name">${a.name}</div>
<div class="bar-track">
<div class="bar-cost" style="width: ${(a.cost / scale) * 100}%;"></div>
<div class="bar-benefit" style="width: ${(a.benefit / scale) * 100}%;"></div>
</div>
</div>
`).join("")}
<div class="bar-legend">
<span><span class="swatch" style="background:#e5e7eb;"></span>Cost</span>
<span><span class="swatch" style="background:var(--accent);opacity:0.85;"></span>Benefit</span>
</div>
`;
let walkHTML = "<h3>Step-by-step comparison</h3>";
if (result.feasible.length === 0) {
walkHTML += `<div class="step fail"><div class="step-title">No feasible alternatives</div>
<div class="verdict">Every option has B/C &lt; 1. The best choice is to do nothing.</div></div>`;
} else if (result.feasible.length === 1) {
walkHTML += `<div class="step pass"><div class="step-title">Only one feasible alternative</div>
<div class="verdict">Pick <strong>${result.winner.name}</strong> — it's the only option with B/C ≥ 1.</div></div>`;
} else {
walkHTML += `<p class="muted">Starting with the cheapest feasible alternative (<strong>${result.feasible[0].name}</strong>) as the current champion, we test each step up.</p>`;
walkHTML += result.steps.map((s, i) => {
const maxDelta = Math.max(Math.abs(s.dB), Math.abs(s.dC), 1);
const costPct = Math.max(0, s.dC) / maxDelta * 100;
const benefitPct = Math.max(0, s.dB) / maxDelta * 100;
return `
<div class="step ${s.pass ? "pass" : "fail"}">
<div class="step-title">Step ${i + 1}: ${s.champion.name} → ${s.challenger.name}?</div>
<p class="muted" style="margin:4px 0 10px; font-size:0.9rem;">If you're currently planning to do <strong>${s.champion.name}</strong>, is it worth upgrading to <strong>${s.challenger.name}</strong>?</p>
<div class="incr-viz">
<div class="incr-row">
<div class="incr-label">Extra cost (ΔC)</div>
<div class="incr-bar-track"><div class="incr-bar cost" style="width: ${costPct}%"></div></div>
<div class="incr-value">${money(s.dC)}</div>
</div>
<div class="incr-row">
<div class="incr-label">Extra benefit (ΔB)</div>
<div class="incr-bar-track"><div class="incr-bar benefit" style="width: ${benefitPct}%"></div></div>
<div class="incr-value">${money(s.dB)}</div>
</div>
</div>
<div class="math">
ΔB / ΔC = ${money(s.dB)} / ${money(s.dC)} = <strong>${fmt(s.ratio, 2)}</strong>
</div>
<div class="verdict">
${s.pass
? `✓ Extra benefit (${money(s.dB)}) exceeds extra cost (${money(s.dC)}) — the upgrade pays for itself. <strong>${s.challenger.name}</strong> becomes the new champion.`
: `✗ Extra benefit (${money(s.dB)}) is less than extra cost (${money(s.dC)}) — you'd be paying more than you'd get back. <strong>${s.champion.name}</strong> stays champion.`}
</div>
</div>
`;}).join("");
}
if (result.winner) {
walkHTML += `<div class="final">Final answer: pick <strong>${result.winner.name}</strong>.</div>`;
}
walkthrough.innerHTML = walkHTML;
bindInputs();
}
function bindInputs() {
table.querySelectorAll("input[type=number]").forEach(input => {
input.addEventListener("change", e => {
const idx = +e.target.dataset.idx;
const field = e.target.dataset.field;
const val = +e.target.value;
if (isFinite(val) && val >= 0) {
state.alts[idx][field] = val;
render();
}
});
});
}
controls.querySelectorAll(".toggle button").forEach(btn => {
btn.addEventListener("click", () => {
controls.querySelectorAll(".toggle button").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
state.mode = btn.dataset.mode;
controls.querySelector(".rate-input").style.display = state.mode === "discounted" ? "flex" : "none";
formulaNote.style.display = state.mode === "discounted" ? "block" : "none";
render();
});
});
controls.querySelector(".rate-input input").addEventListener("input", e => {
const v = +e.target.value;
if (isFinite(v) && v >= 0) {
state.rate = v;
render();
}
});
render();
}
function updateHook() {
const aCost = +document.querySelector("[data-hook=aCost]").value;
const aBenefit = +document.querySelector("[data-hook=aBenefit]").value;
const bCost = +document.querySelector("[data-hook=bCost]").value;
const bBenefit = +document.querySelector("[data-hook=bBenefit]").value;
document.getElementById("hook-aCost-val").textContent = "$" + aCost + "k";
document.getElementById("hook-aBenefit-val").textContent = "$" + aBenefit + "k";
document.getElementById("hook-bCost-val").textContent = "$" + bCost + "k";
document.getElementById("hook-bBenefit-val").textContent = "$" + bBenefit + "k";
const aRatio = aCost > 0 ? aBenefit / aCost : 0;
const bRatio = bCost > 0 ? bBenefit / bCost : 0;
document.getElementById("hook-aRatio").textContent = "B/C = " + aRatio.toFixed(2);
document.getElementById("hook-bRatio").textContent = "B/C = " + bRatio.toFixed(2);
const aFeasible = aRatio >= 1;
const bFeasible = bRatio >= 1;
const analysis = document.getElementById("hook-analysis");
let html = "";
if (!aFeasible && !bFeasible) {
html = `<p class="hook-verdict">Neither project clears <code>B/C ≥ 1</code> — benefits don't cover costs for either. <strong>Do nothing.</strong></p>`;
} else if (!aFeasible) {
html = `<p class="hook-verdict">Project A fails the basic test (B/C = ${aRatio.toFixed(2)} &lt; 1). Project B passes. <strong>Project B wins.</strong></p>`;
} else if (!bFeasible) {
html = `<p class="hook-verdict">Project B fails the basic test (B/C = ${bRatio.toFixed(2)} &lt; 1). Project A passes. <strong>Project A wins.</strong></p>`;
} else {
const cheaper = aCost <= bCost ? { c: aCost, b: aBenefit, n: "A" } : { c: bCost, b: bBenefit, n: "B" };
const dearer = aCost <= bCost ? { c: bCost, b: bBenefit, n: "B" } : { c: aCost, b: aBenefit, n: "A" };
const dC = dearer.c - cheaper.c;
const dB = dearer.b - cheaper.b;
const sameCost = dC === 0;
const incRatio = sameCost ? (dB >= 0 ? Infinity : -Infinity) : dB / dC;
const pass = incRatio >= 1;
const maxDelta = Math.max(Math.abs(dC), Math.abs(dB), 1);
const costPct = Math.max(0, dC) / maxDelta * 100;
const benefitPct = Math.max(0, dB) / maxDelta * 100;
let caption;
if (sameCost) {
caption = dB >= 0
? `✓ Same cost, more benefit — <strong>Project ${dearer.n} wins.</strong>`
: `✓ Same cost, less benefit — <strong>Project ${cheaper.n} wins.</strong>`;
} else if (pass) {
caption = `✓ Upgrade pays for itself (ΔB/ΔC = ${incRatio.toFixed(2)}) — <strong>Project ${dearer.n} wins.</strong>`;
} else {
caption = `✗ Upgrade doesn't pay for itself (ΔB/ΔC = ${incRatio.toFixed(2)}) — <strong>Project ${cheaper.n} wins.</strong>`;
}
html = `
<p class="hook-verdict">Both projects clear <code>B/C ≥ 1</code>. Since we can only pick one, the question becomes: <em>is upgrading from the cheaper to the more expensive worth it?</em></p>
<div class="incr-viz hero">
<div class="incr-row">
<div class="incr-label">Extra cost: upgrade ${cheaper.n} → ${dearer.n}</div>
<div class="incr-bar-track"><div class="incr-bar cost" style="width: ${costPct}%"></div></div>
<div class="incr-value">$${dC}k</div>
</div>
<div class="incr-row">
<div class="incr-label">Extra benefit you'd gain</div>
<div class="incr-bar-track"><div class="incr-bar benefit" style="width: ${benefitPct}%"></div></div>
<div class="incr-value">$${dB}k</div>
</div>
<div class="incr-caption ${pass ? "pass" : "fail"}">${caption}</div>
</div>
`;
}
analysis.innerHTML = html;
}
document.querySelectorAll("[data-hook]").forEach(el => {
el.addEventListener("input", updateHook);
});
updateHook();
function createDiscountWidget(mountEl, alts) {
const state = { rate: 5 };
const maxAnnual = Math.max(...alts.map(a => a.annual));
mountEl.innerHTML = `
<div class="disc-rate-control">
<label>Discount rate</label>
<input type="range" min="0" max="20" step="0.5" value="${state.rate}" class="disc-rate-slider">
<span class="disc-rate-val">${state.rate.toFixed(1)}%</span>
</div>
<div class="disc-grid">
${alts.map(a => `
<div class="disc-alt" data-alt="${a.key}">
<h3>${a.name}</h3>
<p class="tagline">${a.tagline}</p>
<div class="disc-chart" data-chart="${a.key}"></div>
<div class="disc-year-labels" data-years="${a.key}"></div>
<div class="disc-legend">
<span><span class="swatch-outline"></span>Nominal cash flow</span>
<span><span class="swatch-filled"></span>Present value</span>
</div>
<div class="disc-totals">
<span class="label">Cost (year 0)</span> <span class="val">$${a.cost.toFixed(0)}</span>
<span class="label">Nominal benefit sum</span> <span class="val" data-nominal="${a.key}"></span>
<span class="label">Present value of benefits</span> <span class="val" data-pv="${a.key}"></span>
<div class="disc-npv-row">
<span>NPV (PV − cost)</span>
<span class="val" data-npv="${a.key}"></span>
</div>
</div>
</div>
`).join("")}
</div>
<div class="disc-verdict" data-verdict></div>
`;
const slider = mountEl.querySelector(".disc-rate-slider");
const rateVal = mountEl.querySelector(".disc-rate-val");
slider.addEventListener("input", e => {
state.rate = +e.target.value;
render();
});
function render() {
rateVal.textContent = state.rate.toFixed(1) + "%";
const r = state.rate / 100;
const npvs = {};
let anyFeasible = false;
alts.forEach(a => {
const chart = mountEl.querySelector(`[data-chart="${a.key}"]`);
const yearsEl = mountEl.querySelector(`[data-years="${a.key}"]`);
const bars = [];
const years = [];
let pvSum = 0;
for (let y = 1; y <= a.life; y++) {
const pv = a.annual / Math.pow(1 + r, y);
pvSum += pv;
const nominalPct = (a.annual / maxAnnual) * 100;
const pvPct = (pv / maxAnnual) * 100;
bars.push(`
<div class="disc-bar-col">
<div class="disc-bar-nominal" style="height: ${nominalPct}%"></div>
<div class="disc-bar-discounted" style="height: ${pvPct}%"></div>
</div>
`);
if (a.life <= 5) {
years.push(`<span>Yr ${y}</span>`);
} else {
if (y === 1 || y === a.life || y === Math.round(a.life / 2)) {
years.push(`<span>Yr ${y}</span>`);
} else {
years.push(`<span></span>`);
}
}
}
chart.innerHTML = bars.join("");
yearsEl.innerHTML = years.join("");
const npv = pvSum - a.cost;
npvs[a.key] = { pvSum, npv, nominal: a.annual * a.life };
mountEl.querySelector(`[data-nominal="${a.key}"]`).textContent = "$" + (a.annual * a.life).toFixed(1);
mountEl.querySelector(`[data-pv="${a.key}"]`).textContent = "$" + pvSum.toFixed(2);
mountEl.querySelector(`[data-npv="${a.key}"]`).textContent = (npv >= 0 ? "$" : "−$") + Math.abs(npv).toFixed(2);
if (npv >= 0) anyFeasible = true;
});
alts.forEach(a => {
const altEl = mountEl.querySelector(`[data-alt="${a.key}"]`);
altEl.classList.remove("winner", "infeasible");
if (npvs[a.key].npv < 0) altEl.classList.add("infeasible");
});
const verdictEl = mountEl.querySelector("[data-verdict]");
if (!anyFeasible) {
verdictEl.innerHTML = `At ${state.rate.toFixed(1)}% discount rate, <strong>neither project clears its cost</strong>. Both have negative NPV — neither is worth doing.`;
} else {
const winner = alts.reduce((best, a) => npvs[a.key].npv > npvs[best.key].npv ? a : best);
const winnerAltEl = mountEl.querySelector(`[data-alt="${winner.key}"]`);
winnerAltEl.classList.add("winner");
const otherKey = alts.find(a => a.key !== winner.key).key;
const margin = npvs[winner.key].npv - npvs[otherKey].npv;
if (margin > 0) {
verdictEl.innerHTML = `At ${state.rate.toFixed(1)}% discount rate, <strong>${winner.name}</strong> wins by $${margin.toFixed(2)} of NPV. ${state.rate < 10 ? "Patient strategies look better when the discount rate is low — future dollars aren't shrunk much." : state.rate > 13 ? "Short-term strategies win at high discount rates because distant dollars are heavily shrunk." : "You're near the crossover point between strategies."}`;
}
}
}
render();
}
createWidget(document.getElementById("pavement-widget"), pavementAlternatives, {
useCondition: true,
defaultPci: 65,
useBenefitMode: true,
benefitMode: "dollar",
defaultWtp: 1000,
});
createDiscountWidget(document.getElementById("discount-widget"), discountAlternatives);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment