Created
April 21, 2026 02:49
-
-
Save Spacerat/99c322b8c75685516f32141c14a1f671 to your computer and use it in GitHub Desktop.
The Incremental Benefit-Cost Method: an interactive explainer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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 < 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 < 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)} < 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)} < 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