Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 134 additions & 68 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@
--low: #56B4E9; /* sky blue */
--mid: #E69F00; /* orange */
--high: #D55E00; /* vermilion */

--risk-low: #56B4E9;
--risk-moderate: #F2C94C;
--risk-high: #D7191C;
}

*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
Expand Down Expand Up @@ -261,6 +265,15 @@
font-size: 0.63rem;
color: var(--muted);
}
@media (max-width: 560px) {
.map-legend {
left: 0.75rem;
right: 0.75rem;
transform: none;
max-width: calc(100% - 1.5rem);
overflow-x: auto;
}
}

/* ── Selected circle pulse ───────────────────────────────────── */
@keyframes circle-pulse {
Expand Down Expand Up @@ -356,19 +369,30 @@
.panel-stat-label { font-size: 0.67rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); }
.panel-stat-value { font-size: 1.05rem; font-weight: 700; color: var(--text); }

/* Panel — probability bars */
/* Panel — risk badges */
.panel-section-label {
font-size: 0.7rem; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.07em; color: var(--muted); margin-bottom: 0.4rem;
}
.panel-prob-row { margin-bottom: 0.8rem; }
.panel-prob-label {
display: flex; justify-content: space-between;
font-size: 0.82rem; color: var(--text); margin-bottom: 0.3rem;
.panel-risk-list {
display: flex;
flex-direction: column;
gap: 0.45rem;
margin-bottom: 1.2rem;
}
.panel-risk-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.55rem 0;
border-bottom: 1px solid var(--border);
}
.panel-risk-row:last-child { border-bottom: none; }
.panel-risk-label {
font-size: 0.82rem;
color: var(--text);
}
.panel-prob-label span:last-child { font-weight: 600; }
.panel-prob-track { height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; }
.panel-prob-fill { height: 100%; border-radius: 3px; transition: width 0.5s ease; }

/* Panel — footer hint */
.panel-hint {
Expand Down Expand Up @@ -410,6 +434,52 @@
margin-bottom: 0.75rem;
}

.risk-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 5.8rem;
padding: 0.28rem 0.62rem;
border: 1px solid var(--risk-border);
border-radius: 999px;
background: var(--risk-bg);
color: var(--risk-text);
font-size: 0.72rem;
font-weight: 700;
line-height: 1;
white-space: nowrap;
}
.risk-low {
--risk-bg: rgba(86, 180, 233, 0.16);
--risk-border: rgba(86, 180, 233, 0.48);
--risk-text: #7DD3FC;
}
.risk-moderate {
--risk-bg: rgba(242, 201, 76, 0.18);
--risk-border: rgba(242, 201, 76, 0.58);
--risk-text: #FFE08A;
}
.risk-high {
--risk-bg: rgba(215, 25, 28, 0.18);
--risk-border: rgba(215, 25, 28, 0.58);
--risk-text: #FF7B72;
}
[data-theme="light"] .risk-low {
--risk-bg: rgba(86, 180, 233, 0.20);
--risk-border: rgba(9, 105, 218, 0.38);
--risk-text: #0969DA;
}
[data-theme="light"] .risk-moderate {
--risk-bg: rgba(242, 201, 76, 0.35);
--risk-border: rgba(143, 99, 0, 0.34);
--risk-text: #6F4E00;
}
[data-theme="light"] .risk-high {
--risk-bg: rgba(215, 25, 28, 0.14);
--risk-border: rgba(207, 34, 46, 0.34);
--risk-text: #CF222E;
}

h2 {
font-size: 1.75rem;
font-weight: 800;
Expand Down Expand Up @@ -498,22 +568,7 @@
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(255,255,255,0.025); }

.prob-bar {
display: flex;
align-items: center;
gap: 0.5rem;
}
.prob-bar-track {
flex: 1;
height: 6px;
background: var(--border);
border-radius: 3px;
overflow: hidden;
}
.prob-bar-fill {
height: 100%;
border-radius: 3px;
}
.risk-cell .risk-badge { min-width: 6.2rem; }

/* About section */
.about-grid {
Expand Down Expand Up @@ -607,7 +662,7 @@

<!-- ── Map subtitle ──────────────────────────────────────────── -->
<div id="map-subtitle">
<span>Circle size = P(&ge;10 cases, 60-day sim). Color = MMR vaccination rate. Select a city to explore its risk.</span>
<span>Circle size = relative simulated chance of observing &ge;10 cases. Color = MMR vaccination rate. Select a city to compare outcomes.</span>
</div>

<!-- ── Map ────────────────────────────────────────────────────── -->
Expand All @@ -617,12 +672,12 @@

<div class="map-legend">
<div class="leg-section">
<span class="leg-title">Size &rarr; P(&ge;10 cases)</span>
<span class="leg-title">Size &rarr; chance of &ge;10 cases</span>
<div class="leg-bubbles">
<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><circle cx="9" cy="9" r="7" fill="#8b949e" fill-opacity="0.82" stroke="rgba(255,255,255,0.4)" stroke-width="1.5"/></svg>
<span>10%</span>
<span>Lower</span>
<svg width="30" height="30" viewBox="0 0 30 30" aria-hidden="true"><circle cx="15" cy="15" r="13" fill="#8b949e" fill-opacity="0.82" stroke="rgba(255,255,255,0.4)" stroke-width="1.5"/></svg>
<span>70%</span>
<span>Higher</span>
</div>
</div>
<div class="leg-sep"></div>
Expand Down Expand Up @@ -721,7 +776,7 @@ <h3>Vaccination Data</h3>
<span class="badge">Data</span>
<h2>City-Level Risk Summary</h2>
<p>
Outbreak probability estimated from the final day (day 60) of 200 independent simulations per city.
Risk labels summarize the probability of observing at least the listed number of cases by day 60.
</p>

<div class="city-table-wrap">
Expand All @@ -732,9 +787,9 @@ <h2>City-Level Risk Summary</h2>
<th>MMR coverage</th>
<th>Population</th>
<th>Seed cases <sup>†</sup></th>
<th>Prob(≥10 cases)</th>
<th>Prob(≥20 cases)</th>
<th>Prob(≥50 cases)</th>
<th>Risk (&ge;10 cases)</th>
<th>Risk (&ge;20 cases)</th>
<th>Risk (&ge;50 cases)</th>
</tr>
</thead>
<tbody id="city-tbody"></tbody>
Expand Down Expand Up @@ -811,15 +866,39 @@ <h3>How to get in touch</h3>
/* CITIES is defined in data.js – auto-generated by docs/generate_data.py
from scenarios/*_outbreak_size.csv, data/mmr.csv, data/population.csv */

/* ── Risk labels for observing cases ────────────────────────── */
function riskLevel(prob) {
if (prob < 0.10) return {
label: 'Low',
className: 'risk-low',
color: getComputedStyle(document.documentElement).getPropertyValue('--risk-low').trim()
};
if (prob <= 0.40) return {
label: 'Moderate',
className: 'risk-moderate',
color: getComputedStyle(document.documentElement).getPropertyValue('--risk-moderate').trim()
};
return {
label: 'High',
className: 'risk-high',
color: getComputedStyle(document.documentElement).getPropertyValue('--risk-high').trim()
};
}

function riskBadgeHtml(prob) {
const risk = riskLevel(prob);
return `<span class="risk-badge ${risk.className}" aria-label="${risk.label} risk">${risk.label}</span>`;
}

/* ── Colour fill by vaccination rate ────────────────────────── */
/* ColorBrewer RdYlBu 4-class diverging scale (Brewer 1994).
Centred at the ~95 % measles herd-immunity threshold (R₀ ≈12–18).
Centred at the ~95 % measles herd-immunity threshold (R0 approx. 12-18).
Red = low coverage / higher risk; Blue = high coverage / lower risk. */
function vaccColor(vacc) {
if (vacc >= 95) return '#2c7bb6'; // blue — at/above herd immunity
if (vacc >= 92) return '#abd9e9'; // lt blue — approaching threshold
if (vacc >= 90) return '#fdae61'; // orange — below threshold
return '#d7191c'; // red — well below threshold
if (vacc >= 95) return '#2c7bb6'; // blue
if (vacc >= 92) return '#abd9e9'; // light blue
if (vacc >= 90) return '#fdae61'; // orange
return '#d7191c'; // red
}

/* ── Radius scaled by prob_10 (min 6 px, max 35 px total) ────── */
Expand Down Expand Up @@ -945,12 +1024,11 @@ <h3>How to get in touch</h3>
new RecenterControl().addTo(map);

/* ── Theme-aware popup (WCAG 2.1: ≥4.5:1 contrast in both modes) ── */
function buildPopupHtml(city, color) {
function buildPopupHtml(city) {
const dark = document.documentElement.getAttribute('data-theme') !== 'light';
const tc = dark ? '#e6edf3' : '#1f2328'; // text colour
const mc = dark ? '#8b949e' : '#57606a'; // muted label
const hr = dark ? '#30363d' : '#d0d7de'; // divider
const pct = v => (v * 100).toFixed(1) + '%';
return `
<div style="font-family:'Inter',sans-serif;min-width:210px;font-size:13px;">
<strong style="font-size:15px;color:${tc}">${city.name}</strong>
Expand All @@ -960,13 +1038,13 @@ <h3>How to get in touch</h3>
<td style="text-align:right;font-weight:600;color:${tc}">${city.vacc}%</td></tr>
<tr><td style="color:${mc};padding:2px 0">Population</td>
<td style="text-align:right;font-weight:600;color:${tc}">${city.pop.toLocaleString()}</td></tr>
<tr><td colspan="2" style="padding-top:6px;color:${mc};font-size:11px;text-transform:uppercase;letter-spacing:.05em">Outbreak probability</td></tr>
<tr><td colspan="2" style="padding-top:6px;color:${mc};font-size:11px;text-transform:uppercase;letter-spacing:.05em">Risk of observing cases</td></tr>
<tr><td style="padding:2px 0;color:${tc}">≥ 10 cases</td>
<td style="text-align:right;font-weight:600;color:${color}">${pct(city.prob_10)}</td></tr>
<td style="text-align:right">${riskBadgeHtml(city.prob_10)}</td></tr>
<tr><td style="padding:2px 0;color:${tc}">≥ 20 cases</td>
<td style="text-align:right;color:${tc}">${pct(city.prob_20)}</td></tr>
<td style="text-align:right">${riskBadgeHtml(city.prob_20)}</td></tr>
<tr><td style="padding:2px 0;color:${tc}">≥ 50 cases</td>
<td style="text-align:right;color:${tc}">${pct(city.prob_50)}</td></tr>
<td style="text-align:right">${riskBadgeHtml(city.prob_50)}</td></tr>
</table>
</div>`;
}
Expand All @@ -980,14 +1058,11 @@ <h3>How to get in touch</h3>
function isPanelVisible() { return window.innerWidth >= PANEL_BP; }

function buildPanelHtml(city, color) {
const pct = v => (v * 100).toFixed(1) + '%';
function probBar(label, v) {
function riskRow(label, v) {
return `
<div class="panel-prob-row">
<div class="panel-prob-label"><span>${label}</span><span>${pct(v)}</span></div>
<div class="panel-prob-track">
<div class="panel-prob-fill" style="width:${v*100}%;background:${color}"></div>
</div>
<div class="panel-risk-row">
<span class="panel-risk-label">${label}</span>
${riskBadgeHtml(v)}
</div>`;
}
return `
Expand All @@ -1005,11 +1080,12 @@ <h2 class="panel-city-name">${city.name}</h2>
<span class="panel-stat-value">${(city.pop / 1e6).toFixed(2)}M</span>
</div>
</div>
<p class="panel-section-label">Outbreak Probability</p>
<p style="font-size:0.75rem;color:var(--muted);margin-bottom:0.85rem;margin-top:-0.2rem;">200 simulations &middot; 60-day window &middot; single seed case</p>
${probBar('&ge;&thinsp;10 cases', city.prob_10)}
${probBar('&ge;&thinsp;20 cases', city.prob_20)}
${probBar('&ge;&thinsp;50 cases', city.prob_50)}
<p class="panel-section-label">Risk of Observing Cases</p>
<div class="panel-risk-list">
${riskRow('&ge;&thinsp;10 cases', city.prob_10)}
${riskRow('&ge;&thinsp;20 cases', city.prob_20)}
${riskRow('&ge;&thinsp;50 cases', city.prob_50)}
</div>
<p class="panel-hint">Click a different city on the map to compare.</p>`;
}

Expand Down Expand Up @@ -1151,25 +1227,15 @@ <h2 class="panel-city-name">${city.name}</h2>
/* ── Populate city table ─────────────────────────────────────── */
const tbody = document.getElementById('city-tbody');
[...CITIES].sort((a, b) => b.prob_10 - a.prob_10).forEach(city => {
const color = vaccColor(city.vacc);
const pct = v => (v * 100).toFixed(1) + '%';
const bar = (v, c) => `
<div class="prob-bar">
<div class="prob-bar-track">
<div class="prob-bar-fill" style="width:${v*100}%;background:${c}"></div>
</div>
<span>${pct(v)}</span>
</div>`;

const tr = document.createElement('tr');
tr.innerHTML = `
<td><strong>${city.name}</strong></td>
<td>${city.vacc}%</td>
<td>${(city.pop / 1e6).toFixed(2)}M</td>
<td>${city.seed_cases}</td>
<td>${bar(city.prob_10, color)}</td>
<td>${bar(city.prob_20, color)}</td>
<td>${bar(city.prob_50, color)}</td>`;
<td class="risk-cell">${riskBadgeHtml(city.prob_10)}</td>
<td class="risk-cell">${riskBadgeHtml(city.prob_20)}</td>
<td class="risk-cell">${riskBadgeHtml(city.prob_50)}</td>`;
tbody.appendChild(tr);
});

Expand Down