Skip to content

Instantly share code, notes, and snippets.

@sergiospagnuolo
Last active April 20, 2026 20:53
Show Gist options
  • Select an option

  • Save sergiospagnuolo/88f2ec6bd1dca54b0d7124f782e80976 to your computer and use it in GitHub Desktop.

Select an option

Save sergiospagnuolo/88f2ec6bd1dca54b0d7124f782e80976 to your computer and use it in GitHub Desktop.
Digital Countries Index

Digital Countries Index

Submission for the Media Party × World Bank Data360 Global Challenge Journalism Relay Project — v0.1


What this is

A single-file dashboard that scores 217 countries on whether the conditions for free digital content to flourish are actually in place — the technical and economic infrastructure (internet, broadband, mobile, GDP, literacy) and the political environment required for content to actually move (electoral democracy and press freedom).

The goal was to answer a question newsrooms keep answering by hand: before reporting on a country's information environment, you need a defensible baseline for what "good conditions" even look like there. That baseline was previously scattered across the World Bank, RSF, and V-Dem, reconciled manually each time. This tool fuses it into a single, transparent, citable score per country, with every weight published and every data gap flagged on the card.

The whole pipeline runs client-side — no server, no API key, no opaque black box. A judge, reader, or newsroom can see exactly what is happening.


Methodological honesty first

We built the index, stress-tested it against equal weights (1/7 each), and published the full rank and score delta for every country. That export is the first thing worth looking at. If most rank deltas are within ±5, the index is robust to the weighting choice. Large deltas identify countries whose position depends significantly on how infrastructure and freedom are weighted relative to each other — and that's useful information regardless of which set of weights you prefer.

The sensitivity CSV is downloadable from the Export & Share section of the dashboard.

Known biases (all documented inside the tool)

Broadband vs. mobile-first markets. The broadband weight (11%) disadvantages mobile-first economies. A country penalized for having high mobile but low fixed-broadband penetration is partially being measured against infrastructure history rather than infrastructure reality. We acknowledge this as the most exposed design choice in the index.

GDP scoring is linear up to the cap. The GDP component maps income linearly from $0 to $50,000 per person. A country at $10,000 scores exactly twice the GDP points of one at $5,000. Diminishing returns set in well before the cap; a log scale would be more accurate but introduces a free parameter requiring separate justification. The linear approach with a hard cap is the more transparent trade-off for v0.1.

Missing data can favour low-transparency states. When an indicator is absent for a country, its weight is redistributed across the indicators that do exist. States with poor statistical infrastructure — often authoritarian or low-capacity governments — may be scored on a smaller, more favorable subset. Score cards show the indicator count (e.g. 4/7 ind.); cards at 5/7 or below are flagged explicitly.


Methodology

Composite score = weighted mean of seven indicators normalized to 0–100, split into two layers.

Infrastructure layer (47.5%) — fetched live from the World Bank Indicators API v2:

Indicator Code Weight Cap
Internet users (% population) IT.NET.USER.ZS 15% 100
Fixed broadband subscriptions (/100) IT.NET.BBND.P2 11% 60
Mobile cellular subscriptions (/100) IT.CEL.SETS.P2 7% 180
GDP per capita (current US$) NY.GDP.PCAP.CD 7% $50,000
Adult literacy rate (ages 15+) SE.ADT.LITR.ZS 7.5% 100

Freedom layer (52.5%) — bundled CSVs (source hosts are CORS-blocked):

Indicator Source Weight Cap
Electoral democracy (v2x_polyarchy × 100) V-Dem v16 (Mar 2026) 30% 100
Press freedom index RSF 2022–2025 22.5% 100

Freedom outweighs infrastructure slightly (52.5 vs 47.5%) because infrastructure is a necessary condition, not a sufficient one. A country with world-class connectivity and no political space to use it is not a place where digital content flourishes. Each freedom indicator is weighted roughly 1.5× its closest infrastructure counterpart. RSF only from 2022 onward — RSF revised its methodology that year; pre-2022 values are not comparable on the same scale.

Score year selection. For each country, the tool picks the year in 2020–2025 with the most indicators reported (ties broken by recency); if nothing in that window, it falls back to the best year in 2010–2025. The anchor year and basis are shown on every card (e.g. 2024 · 7/7 ind.). Year-over-year deltas only appear when both years share the same indicator set, so the movement is real and not a data artifact.

Formula. Score = Σ (normalized_i × weight_i) / Σ weight_i — sum runs only over indicators with non-null data at the score year. Weights are re-proportioned when any are missing, not filled with a synthetic value.


Technical architecture

Single static HTML file — no build step, no npm, no API key. Drop it on any web server and it works.

index.html                      # entire app: HTML + CSS + JS (vanilla, Chart.js v4.4.7 via CDN)
app.js                          # application logic (separated for the gist)
styles.css                      # design system (separated for the gist)
data/rsf-series.csv             # RSF Press Freedom Index, 2022–2025
data/vdem-polyarchy-series.csv  # V-Dem v16 v2x_polyarchy × 100, 2010–2025
  • World Bank data is fetched live via api.worldbank.org/v2/ and cached 24 hours in localStorage under the tdi_ prefix.
  • The quadrant map loads all 217 countries in 5 parallel requests (one per WB indicator) using the bulk country/all/indicator/{id} endpoint, not ~1,000 per-country calls.
  • V-Dem and RSF are bundled as CSVs because both source APIs are either CORS-blocked or not consistently available.
  • No tracking, no accounts, no server-side processing. The full methodology is visible by reading the source.

Running it locally

The app needs a real HTTP server (not file://) so the World Bank API responses cache correctly.

# Python (no install)
python3 -m http.server 8080

# Node
npx serve -l 8080

# PHP
php -S localhost:8080

Open http://localhost:8080/. Any port works.

URL parameter: ?countries=BRA,DEU,NGA,IND,MEX,USA preloads up to 6 ISO3 country codes. Without it, the page seeds itself with 6 random countries so a fresh visit isn't empty.

Clearing the cache: use the Clear Cache button in the Export & Share section to force a fresh World Bank fetch.


Exports

All exports are available from the Export & Share panel inside the tool.

Export Contents
CSV (selected) One row per selected country, all 7 indicators at score year
JSON (selected) Full time-series per indicator, methodology metadata, source attribution
CSV (all countries) Same as above, all 217 loaded countries
JSON (all countries) Full dataset with methodology block
Sensitivity analysis CSV Every country re-scored with equal weights (1/7 each). Columns: Published Score, Published Rank, Equal-Weight Score, Equal-Weight Rank, Rank Delta, Score Delta. See "Methodological honesty" above for how to read this file.
Chart images Quadrant map and trend chart as JPEG (with attribution footer)

About

Built by the Journalism Relay Project — a research and tooling initiative focused on the structural conditions for independent journalism.

Disclaimer: this code was build with the help of AI-powered tools.

/* ===================================================================
Digital Infrastructure Index
Data source: World Bank Data360 API (api.worldbank.org/v2/)
Chart.js v4.4.7 | JRP Design System
=================================================================== */
(function() {
'use strict';
const API_BASE = 'https://api.worldbank.org/v2';
// Composite indicators. `source: 'wb'` are fetched live from the World Bank
// Indicators API. `source: 'vdem'` and `source: 'rsf'` are bundled CSVs,
// loaded once at startup and injected into each country's data under these
// pseudo-codes so the rest of the scoring code can treat them uniformly.
//
// Weight composition (each freedom index weighted ~1.5x its comparable
// infrastructure weight, because freedom matters more for the question
// "can digital content actually flourish here?"):
// Infrastructure (47.5%): Internet 15 + Broadband 11 + Mobile 7 + GDP 7 + Literacy 7.5
// Freedom (52.5%): Democracy 30 + Press 22.5
const INDICATORS = {
'IT.NET.USER.ZS': { name: 'Internet Users (% pop)', weight: 0.150, unit: '%', max: 100, source: 'wb' },
'IT.NET.BBND.P2': { name: 'Fixed Broadband (/100)', weight: 0.110, unit: '/100', max: 60, source: 'wb' },
'IT.CEL.SETS.P2': { name: 'Mobile Subs (/100)', weight: 0.070, unit: '/100', max: 180, source: 'wb' },
'NY.GDP.PCAP.CD': { name: 'GDP per Capita ($)', weight: 0.070, unit: '$', max: 50000, source: 'wb' },
'SE.ADT.LITR.ZS': { name: 'Adult Literacy (%)', weight: 0.075, unit: '%', max: 100, source: 'wb' },
'VDEM.POLYARCHY': { name: 'Democracy (V-Dem)', weight: 0.300, unit: '', max: 100, source: 'vdem' },
'RSF.PRESS': { name: 'Press Freedom (RSF)', weight: 0.225, unit: '', max: 100, source: 'rsf' }
};
const DATE_RANGE = '2010:2024';
const CACHE_TTL = 24 * 60 * 60 * 1000;
// Paul Tol "vibrant" qualitative palette: distinct, colorblind-safe,
// stronger saturation than the design-system colors so thin trend lines
// and tag swatches stay distinguishable.
const CHART_COLORS = ['#0077BB','#EE7733','#009988','#CC3311','#EE3377','#332288'];
const MAX_COUNTRIES = 6;
const REGION_MAP = {
'Sub-Saharan Africa': 'Africa',
'Latin America & Caribbean': 'Americas',
'East Asia & Pacific': 'Asia-Pacific',
'Europe & Central Asia': 'Europe & C. Asia',
'Middle East, North Africa, Afghanistan & Pakistan': 'MENA',
'South Asia': 'South Asia',
'North America': 'North America'
};
let allCountries = [];
let selectedCountries = [];
let countryData = {};
let trendChart = null;
let sortColumn = 'score';
let sortDir = -1;
// Freedom-layer indicators loaded from bundled CSVs. Each is a nested map
// { ISO3: { year: score } } on a 0-100 scale, so it can be plugged into the
// same computeScore pipeline as the World Bank indicators.
let rsfSeries = {}; // RSF Press Freedom (2022-2025, methodology-consistent)
let vdemSeries = {}; // V-Dem electoral democracy v2x_polyarchy × 100 (2010-2025)
// RSF rank lookup for the table/tooltip display; kept separate because it is
// not used in scoring.
let rsfRanks = {}; // { ISO3: { year: rank } }
const $ = id => document.getElementById(id);
const searchInput = $('countrySearch');
const acList = $('autocompleteList');
const selectedTags = $('selectedTags');
const scoreCards = $('scoreCards');
const chartIndicator = $('chartIndicator');
function cacheGet(key) {
try { const item = JSON.parse(localStorage.getItem('tdi_' + key)); if (item && Date.now() - item.ts < CACHE_TTL) return item.data; } catch(e) {} return null;
}
function cacheSet(key, data) {
try { localStorage.setItem('tdi_' + key, JSON.stringify({ ts: Date.now(), data })); } catch(e) {}
}
async function fetchJSON(url) {
const cacheKey = url.replace(/[^a-zA-Z0-9]/g, '_').slice(0, 120);
const cached = cacheGet(cacheKey);
if (cached) return cached;
const res = await fetch(url);
if (!res.ok) throw new Error(`API error: ${res.status}`);
const data = await res.json();
cacheSet(cacheKey, data);
return data;
}
// ----- Freedom-layer data loaders (RSF + V-Dem, bundled CSV time series) ---
// Both CSVs are flat: ISO, year, score. RSF additionally has rank.
// R's write.csv quotes string columns, Python's csv.writer by default does
// not -- this tolerates either. No embedded commas or newlines expected
// inside fields for these files.
function unq(s) {
if (!s) return s;
s = s.trim();
if (s.length >= 2 && s[0] === '"' && s[s.length-1] === '"') return s.slice(1, -1);
return s;
}
async function loadCsvSeries(path, extraCol) {
const res = await fetch(path);
if (!res.ok) throw new Error(`${path} -> ${res.status}`);
const text = await res.text();
const lines = text.split(/\r?\n/).filter(l => l.trim());
const headers = lines[0].split(',').map(unq);
const iIso = headers.indexOf('ISO');
const iYear = headers.indexOf('year');
const iScore = headers.indexOf('score');
const iExtra = extraCol ? headers.indexOf(extraCol) : -1;
const series = {}, extra = {};
for (let i = 1; i < lines.length; i++) {
const c = lines[i].split(',').map(unq);
const iso = c[iIso];
const yr = c[iYear];
const score = parseFloat(c[iScore]);
if (!iso || !yr || !Number.isFinite(score)) continue;
(series[iso] ||= {})[yr] = score;
if (iExtra >= 0) {
const v = parseInt(c[iExtra], 10);
if (Number.isFinite(v)) (extra[iso] ||= {})[yr] = v;
}
}
return { series, extra };
}
async function fetchCountries() {
const data = await fetchJSON(`${API_BASE}/country?format=json&per_page=300`);
if (!data || !data[1]) return [];
// World Bank API returns some region values with trailing whitespace
// (e.g. 'Sub-Saharan Africa ', 'Latin America & Caribbean '). Normalize.
return data[1].filter(c => c.region.value.trim() !== 'Aggregates')
.map(c => ({ id: c.id, name: c.name, region: c.region.value.trim(), incomeLevel: c.incomeLevel.value.trim() }))
.sort((a,b) => a.name.localeCompare(b.name));
}
async function fetchIndicator(countryId, indicatorId) {
const url = `${API_BASE}/country/${countryId}/indicator/${indicatorId}?date=${DATE_RANGE}&format=json&per_page=50`;
const data = await fetchJSON(url);
if (!data || !data[1]) return {};
const result = {};
for (const entry of data[1]) { if (entry.value !== null) result[entry.date] = entry.value; }
return result;
}
async function fetchAllIndicators(countryId) {
// Only hit the World Bank API for wb-sourced indicators. The vdem/rsf
// indicators are loaded once at startup from bundled CSVs and injected
// separately (see loadCountryData).
const wbKeys = Object.keys(INDICATORS).filter(k => INDICATORS[k].source === 'wb');
const results = await Promise.all(wbKeys.map(async ind => [ind, await fetchIndicator(countryId, ind)]));
return Object.fromEntries(results);
}
function normalize(value, indicator) {
const cfg = INDICATORS[indicator];
if (value == null) return null;
return Math.min(100, Math.max(0, (value / cfg.max) * 100));
}
function computeScore(data, year) {
let totalWeight = 0, weightedSum = 0;
for (const [ind, cfg] of Object.entries(INDICATORS)) {
const val = data[ind] && data[ind][year];
if (val != null) { weightedSum += normalize(val, ind) * cfg.weight; totalWeight += cfg.weight; }
}
return totalWeight === 0 ? null : Math.round((weightedSum / totalWeight) * 10) / 10;
}
// Which indicators belong to each layer, for the infrastructure-vs-freedom
// sub-scores plotted on the quadrant map.
const INFRASTRUCTURE_INDICATORS = ['IT.NET.USER.ZS','IT.NET.BBND.P2','IT.CEL.SETS.P2','NY.GDP.PCAP.CD','SE.ADT.LITR.ZS'];
const FREEDOM_INDICATORS = ['VDEM.POLYARCHY','RSF.PRESS'];
function computeLayerScore(data, year, layerKeys) {
let totalWeight = 0, weightedSum = 0;
for (const ind of layerKeys) {
const cfg = INDICATORS[ind];
const val = data[ind] && data[ind][year];
if (val != null) { weightedSum += normalize(val, ind) * cfg.weight; totalWeight += cfg.weight; }
}
return totalWeight === 0 ? null : Math.round((weightedSum / totalWeight) * 10) / 10;
}
// Pick the year that anchors the score. Rule:
// 1. Within 2020-2025, take the year with the highest indicator coverage.
// Ties broken by recency.
// 2. If nothing is reported in that window, fall back to the best year
// in the full 2010-2025 range.
// This keeps the displayed year current while still preferring the fullest
// basis available -- cards display the basis explicitly (e.g. "6/7 ind.").
function getScoreYear(data) {
const indKeys = Object.keys(INDICATORS);
const pickBest = (minYear) => {
let bestYear = null, bestCount = 0;
for (let y = 2025; y >= minYear; y--) {
const yr = String(y);
const count = indKeys.reduce((n, ind) => n + (data[ind] && data[ind][yr] != null ? 1 : 0), 0);
if (count > bestCount) { bestCount = count; bestYear = yr; }
}
return bestYear;
};
return pickBest(2020) || pickBest(2010);
}
function indicatorsAt(data, year) {
if (!year) return [];
return Object.keys(INDICATORS).filter(ind => data[ind] && data[ind][year] != null);
}
function sameBasis(data, yearA, yearB) {
if (!yearA || !yearB) return false;
const a = indicatorsAt(data, yearA).sort().join(',');
const b = indicatorsAt(data, yearB).sort().join(',');
return a === b && a.length > 0;
}
function getLatestValue(yearData) {
if (!yearData) return null;
for (const y of Object.keys(yearData).sort().reverse()) { if (yearData[y] != null) return { year: y, value: yearData[y] }; }
return null;
}
function scoreColor(score) {
if (score == null) return 'var(--mid)';
if (score >= 65) return 'var(--score-low)';
if (score >= 40) return 'var(--score-mid)';
if (score >= 20) return 'var(--score-high)';
return 'var(--score-critical)';
}
function scoreVerdict(score) {
if (score == null) return 'N/A';
if (score >= 65) return 'Strong';
if (score >= 40) return 'Developing';
if (score >= 20) return 'Weak';
return 'Critical';
}
// Autocomplete
function filterCountries(query) {
const q = query.toLowerCase().trim();
if (!q) return [];
let filtered = allCountries.filter(c => c.name.toLowerCase().includes(q) || c.id.toLowerCase().includes(q));
const selIds = new Set(selectedCountries.map(c => c.id));
return filtered.filter(c => !selIds.has(c.id)).slice(0, 12);
}
let acIndex = -1;
function showAutocomplete(items) {
acList.innerHTML = ''; acIndex = -1;
if (!items.length) { acList.classList.remove('visible'); return; }
for (const c of items) {
const div = document.createElement('div');
div.className = 'autocomplete-item';
div.setAttribute('role', 'option');
div.innerHTML = `<span>${c.name} <span style="color:var(--mid)">${c.id}</span></span><span class="region-tag">${c.region.split(',')[0]}</span>`;
div.addEventListener('click', () => selectCountry(c));
acList.appendChild(div);
}
acList.classList.add('visible');
}
searchInput.addEventListener('input', () => showAutocomplete(filterCountries(searchInput.value)));
searchInput.addEventListener('keydown', (e) => {
const items = acList.querySelectorAll('.autocomplete-item');
if (e.key === 'ArrowDown') { e.preventDefault(); acIndex = Math.min(acIndex + 1, items.length - 1); items.forEach((el, i) => el.classList.toggle('highlighted', i === acIndex)); }
else if (e.key === 'ArrowUp') { e.preventDefault(); acIndex = Math.max(acIndex - 1, 0); items.forEach((el, i) => el.classList.toggle('highlighted', i === acIndex)); }
else if (e.key === 'Enter' && acIndex >= 0) { e.preventDefault(); items[acIndex]?.click(); }
else if (e.key === 'Escape') { acList.classList.remove('visible'); }
});
document.addEventListener('click', (e) => { if (!e.target.closest('.search-wrap')) acList.classList.remove('visible'); });
// Country selection
async function selectCountry(country) {
if (selectedCountries.length >= MAX_COUNTRIES) { showToast(`Max ${MAX_COUNTRIES} countries`); return; }
if (selectedCountries.find(c => c.id === country.id)) return;
selectedCountries.push(country);
searchInput.value = ''; acList.classList.remove('visible');
// Any intentional interaction with the country list means the random-default
// guidance is no longer needed.
hideRandomWarning(); dismissSearchPopup();
renderTags(); await loadCountryData(country.id); renderAll(); updateURL();
}
function removeCountry(id) {
selectedCountries = selectedCountries.filter(c => c.id !== id);
delete countryData[id];
hideRandomWarning(); dismissSearchPopup();
renderTags(); renderAll(); updateURL();
}
function renderTags() {
selectedTags.innerHTML = selectedCountries.map((c, i) =>
`<span class="country-tag"><span class="color-swatch" style="background:${CHART_COLORS[i % CHART_COLORS.length]}"></span>${c.name} <button aria-label="Remove ${c.name}" data-id="${c.id}">&times;</button></span>`
).join('');
selectedTags.querySelectorAll('button').forEach(btn => btn.addEventListener('click', () => removeCountry(btn.dataset.id)));
// Update sticky score bar
const bar = $('scoreBarCountries');
if (!selectedCountries.length) {
bar.innerHTML = '';
const lbl = document.querySelector('.score-bar-label'); if (lbl) lbl.style.display = '';
} else {
const lbl2 = document.querySelector('.score-bar-label'); if (lbl2) lbl2.style.display = 'none';
bar.innerHTML = selectedCountries.map((c, i) => {
const d = countryData[c.id];
const yr = d ? getScoreYear(d) : null;
const score = yr ? computeScore(d, yr) : null;
return `<span style="display:flex;align-items:baseline;gap:0.4rem"><span style="color:rgba(212,213,191,0.6)">${c.name}</span> <strong style="font-size:1.1rem">${score != null ? Math.round(score) : '...'}</strong></span>`;
}).join('');
}
}
async function loadCountryData(id) {
if (countryData[id]) return;
try {
const wb = await fetchAllIndicators(id);
// Inject the bundled freedom-layer series so computeScore/getScoreYear
// treat them like any other indicator.
wb['VDEM.POLYARCHY'] = vdemSeries[id] || {};
wb['RSF.PRESS'] = rsfSeries[id] || {};
countryData[id] = wb;
} catch(e) {
const isNetErr = e instanceof TypeError;
if (!isNetErr) console.warn(`Failed to load ${id}:`, e);
if (!isNetErr) showToast(`Error loading ${id}`);
}
}
// Render score cards
function renderScoreCards() {
if (!selectedCountries.length) {
scoreCards.innerHTML = '<div class="empty-state">Search and select countries above to begin</div>';
return;
}
scoreCards.innerHTML = selectedCountries.map((c, i) => {
const data = countryData[c.id];
if (!data) return '';
const year = getScoreYear(data);
const score = year ? computeScore(data, year) : null;
const basis = indicatorsAt(data, year).length;
const totalInd = Object.keys(INDICATORS).length;
const prevYear = year ? String(Number(year) - 1) : null;
const prevScore = prevYear ? computeScore(data, prevYear) : null;
// Only show year-over-year if both years draw on the same indicator set
const trend = (score != null && prevScore != null && sameBasis(data, year, prevYear)) ? (score - prevScore) : null;
const color = scoreColor(score);
const pct = score != null ? score : 0;
// Tiered completeness signal:
// 7/7 → muted (no concern)
// 6/7 → accent-dark but no extra warning (usually just missing literacy for rich countries)
// ≤5/7 → accent-dark + explicit low-data warning on the card
const basisColor = basis < totalInd ? 'var(--accent-dark)' : 'var(--mid)';
const lowData = basis <= 5;
const lowDataNote = lowData
? `<div class="score-card-low-data" title="Only ${basis} of ${totalInd} indicators available — score may not reflect all dimensions">⚠ ${basis}/${totalInd} indicators — interpret with care</div>`
: '';
return `<div class="score-card${lowData ? ' score-card--low-data' : ''}">
<div class="score-card-top">
<span class="score-card-country">${c.name}</span>
<span class="score-card-year">${year || '?'} &middot; <span style="color:${basisColor}" title="Indicators with data at ${year}: ${basis} of ${totalInd}">${basis}/${totalInd} ind.</span></span>
</div>
<div class="score-card-number" style="color:${color}">${score != null ? Math.round(score) : '?'}</div>
<div class="index-meter-mini">
<div class="index-meter-thumb" style="left:${pct}%"></div>
</div>
<div class="score-card-bottom">
<span class="score-card-verdict" style="color:${color};border-color:${color}">${scoreVerdict(score)}</span>
${trend != null ? `<span class="score-card-trend" style="color:${trend >= 0 ? 'var(--score-low)' : 'var(--score-high)'}" title="Year-over-year change, computed only when ${year} and ${prevYear} share the same indicator set">${trend >= 0 ? '+' : ''}${trend.toFixed(1)} yr/yr</span>` : ''}
</div>
${lowDataNote}
</div>`;
}).join('');
renderTags(); // update sticky bar with scores
}
// Render chart
function renderChart() {
const canvas = $('trendChart');
const indicator = chartIndicator.value;
const years = [];
// Stop at 2024 in the chart: V-Dem reports 2025 but World Bank indicators
// typically don't, which would make the 2025 composite a V-Dem-only point.
for (let y = 2010; y <= 2024; y++) years.push(String(y));
const datasets = selectedCountries.map((c, i) => {
const data = countryData[c.id];
if (!data) return null;
const values = years.map(yr => indicator === 'composite' ? computeScore(data, yr) : (data[indicator]?.[yr] ?? null));
return {
label: c.name, data: values,
borderColor: CHART_COLORS[i % CHART_COLORS.length],
backgroundColor: 'transparent',
tension: 0.3, pointRadius: 3, pointHoverRadius: 7,
pointBackgroundColor: CHART_COLORS[i % CHART_COLORS.length],
pointBorderColor: '#fff', pointBorderWidth: 1.5,
borderWidth: 1.5, fill: false, spanGaps: true
};
}).filter(Boolean);
if (trendChart) trendChart.destroy();
const indCfg = INDICATORS[indicator];
const yLabel = indicator === 'composite' ? 'Score (0\u2013100)' : indCfg?.name || '';
trendChart = new Chart(canvas, {
type: 'line',
data: { labels: years, datasets },
options: {
responsive: true, maintainAspectRatio: false,
animation: { duration: 800, easing: 'easeOutQuart' },
plugins: {
legend: {
position: 'top',
labels: { usePointStyle: true, pointStyle: 'rect', padding: 16, font: { family: "'Source Code Pro', monospace", size: 11 }, color: '#7E483A' }
},
tooltip: {
backgroundColor: '#32434D', titleColor: '#D4D5BF', bodyColor: '#D4D5BF',
titleFont: { family: "'Libre Franklin', sans-serif", size: 13, weight: '700' },
bodyFont: { family: "'Source Code Pro', monospace", size: 11 },
padding: 12, cornerRadius: 3, borderColor: '#3E737B', borderWidth: 1,
itemSort: (a, b) => (b.parsed.y ?? -Infinity) - (a.parsed.y ?? -Infinity),
callbacks: {
label: ctx => { const v = ctx.parsed.y; if (v == null) return ''; if (indicator === 'composite') return ` ${ctx.dataset.label}: ${v.toFixed(1)}`; if (indicator === 'NY.GDP.PCAP.CD') return ` ${ctx.dataset.label}: $${v.toLocaleString(undefined,{maximumFractionDigits:0})}`; return ` ${ctx.dataset.label}: ${v.toFixed(1)}${indCfg?.unit||''}`; },
footer: () => ' Source: World Bank Data360 API'
}
}
},
scales: {
x: { grid: { color: 'rgba(62,115,123,0.15)', drawBorder: false }, ticks: { font: { family: "'Source Code Pro', monospace", size: 10 }, color: '#7E483A' } },
y: {
beginAtZero: true,
grid: { color: 'rgba(62,115,123,0.15)', drawBorder: false },
title: { display: true, text: yLabel, font: { family: "'Source Code Pro', monospace", size: 10 }, color: '#7E483A' },
ticks: { font: { family: "'Source Code Pro', monospace", size: 10 }, color: '#7E483A', callback: v => indicator === 'NY.GDP.PCAP.CD' ? '$'+(v/1000).toFixed(0)+'k' : v }
}
},
interaction: { mode: 'index', intersect: false }
}
});
}
// Render table — transposed: indicators as rows, countries as columns
function renderTable() {
const wrap = $('comparisonTableWrap');
if (!selectedCountries.length) {
wrap.innerHTML = '<div class="empty-state">Select countries to compare</div>';
return;
}
const cols = selectedCountries.map(c => {
const d = countryData[c.id] || {};
const year = getScoreYear(d);
const at = ind => (year && d[ind] && d[ind][year] != null) ? d[ind][year] : null;
return {
name: c.name, year,
score: year ? computeScore(d, year) : null,
internet: at('IT.NET.USER.ZS'),
broadband: at('IT.NET.BBND.P2'),
mobile: at('IT.CEL.SETS.P2'),
gdp: at('NY.GDP.PCAP.CD'),
literacy: at('SE.ADT.LITR.ZS'),
democracy: at('VDEM.POLYARCHY'),
press: at('RSF.PRESS')
};
});
const indicators = [
{ label: 'Score', key: 'score', render: (v, c) => v!=null ? `<span style="font-weight:700;color:${scoreColor(v)}">${v.toFixed(1)}</span>` : '--' },
{ label: 'Internet %', key: 'internet', render: v => `<span class="${cellClass(v,60,30)}">${fmt(v,1)}%</span>` },
{ label: 'Broadband /100', key: 'broadband', render: v => `<span class="${cellClass(v,20,5)}">${fmt(v,1)}</span>` },
{ label: 'Mobile /100', key: 'mobile', render: v => `<span class="${cellClass(v,100,50)}">${fmt(v,1)}</span>` },
{ label: 'GDP/Cap', key: 'gdp', render: v => v!=null ? '$'+v.toLocaleString(undefined,{maximumFractionDigits:0}) : '--' },
{ label: 'Literacy %', key: 'literacy', render: v => `<span class="${cellClass(v,90,70)}">${fmt(v,1)}%</span>` },
{ label: 'Democracy', key: 'democracy', render: v => `<span class="${cellClass(v,70,40)}">${fmt(v,1)}</span>` },
{ label: 'Press Freedom', key: 'press', render: v => `<span class="${cellClass(v,70,40)}">${fmt(v,1)}</span>` },
];
const thead = `<thead><tr>
<th></th>
${cols.map(c => `<th>${c.name}<br><span style="font-weight:400;opacity:0.6;font-size:0.8em">${c.year || '--'}</span></th>`).join('')}
</tr></thead>`;
const tbody = `<tbody>${indicators.map(ind => `<tr>
<td style="font-family:'Source Code Pro',monospace;font-size:0.72rem;letter-spacing:0.06em;text-transform:uppercase;color:var(--mid);font-weight:600;white-space:nowrap">${ind.label}</td>
${cols.map(c => `<td class="mono">${ind.render(c[ind.key])}</td>`).join('')}
</tr>`).join('')}</tbody>`;
wrap.innerHTML = `<table>${thead}${tbody}</table>`;
}
function cellClass(val, good, bad) { if (val==null) return ''; if (val>=good) return 'cell-good'; if (val<=bad) return 'cell-bad'; return 'cell-medium'; }
function fmt(v, d) { return v != null ? v.toFixed(d) : '--'; }
// Raw data (collapsible)
function renderRawData() {
const container = $('rawDataContainer');
if (!selectedCountries.length) {
container.innerHTML = '<div class="empty-state">Select countries to view raw data</div>';
return;
}
const years = [];
for (let y = 2025; y >= 2010; y--) years.push(String(y));
const indKeys = Object.keys(INDICATORS);
let html = `<div class="raw-data-expand-all">
<button class="btn btn-ghost btn-sm" id="expandAllRaw">Expand All</button>
<button class="btn btn-ghost btn-sm" id="collapseAllRaw">Collapse All</button>
</div>`;
for (const c of selectedCountries) {
const data = countryData[c.id];
if (!data) continue;
const yr = getScoreYear(data);
const score = yr ? computeScore(data, yr) : null;
const color = scoreColor(score);
const verdict = scoreVerdict(score);
html += `<details class="raw-data-details">
<summary>
<span>${c.name} <span style="font-family:'Source Code Pro',monospace;font-size:0.7rem;color:var(--mid);font-weight:400">${c.id} &middot; ${c.region}</span></span>
<span class="summary-right">
<span class="summary-score" style="color:${color}">${score != null ? score.toFixed(1) : '--'}</span>
<span class="summary-badge" style="color:${color};border-color:${color}">${verdict}</span>
</span>
</summary>
<div class="raw-data-body">
<table>
<thead>
<tr>
<th>Year</th>
${indKeys.map(k => `<th>${INDICATORS[k].name.split('(')[0].trim()}</th>`).join('')}
<th>Score</th>
</tr>
</thead>
<tbody>
${years.map(y => {
const hasAny = indKeys.some(k => data[k] && data[k][y] != null);
if (!hasAny) return '';
const s = computeScore(data, y);
return `<tr>
<td>${y}</td>
${indKeys.map(k => {
const v = data[k] && data[k][y];
if (v == null) return '<td class="no-data">--</td>';
if (k === 'NY.GDP.PCAP.CD') return `<td>$${v.toLocaleString(undefined,{maximumFractionDigits:0})}</td>`;
return `<td>${v.toFixed(1)}${INDICATORS[k].unit === '%' ? '%' : ''}</td>`;
}).join('')}
<td style="font-weight:700;color:${scoreColor(s)}">${s != null ? s.toFixed(1) : '--'}</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>
</details>`;
}
container.innerHTML = html;
// Expand/collapse all buttons
$('expandAllRaw')?.addEventListener('click', () => {
container.querySelectorAll('.raw-data-details').forEach(d => d.open = true);
});
$('collapseAllRaw')?.addEventListener('click', () => {
container.querySelectorAll('.raw-data-details').forEach(d => d.open = false);
});
}
function renderAll() { renderScoreCards(); renderQuadrant(); renderChart(); renderTable(); renderRawData(); }
// Bulk-load all countries for the scatter plot using the WB "all" endpoint.
// 5 requests (one per WB indicator) instead of ~1,000 per-country calls.
// fetchJSON handles 24hr localStorage caching automatically.
async function loadAllCountriesForQuadrant() {
const canvas = $('quadrantChart');
const wrap = canvas?.closest('.quadrant-section');
if (wrap && !wrap.querySelector('.quadrant-loading')) {
const spin = document.createElement('div');
spin.className = 'quadrant-loading';
spin.innerHTML = '<div class="spinner"></div><span>Loading all countries…</span>';
wrap.appendChild(spin);
}
const wbKeys = Object.keys(INDICATORS).filter(k => INDICATORS[k].source === 'wb');
const byCountry = {};
// Fetch all five WB indicators in parallel (they're independent requests).
await Promise.all(wbKeys.map(async key => {
try {
const url = `${API_BASE}/country/all/indicator/${key}?date=${DATE_RANGE}&format=json&per_page=20000`;
const data = await fetchJSON(url);
if (!data || !data[1]) return;
for (const entry of data[1]) {
if (entry.value === null) continue;
const id = entry.countryiso3code;
if (!id) continue;
((byCountry[id] ||= {})[key] ||= {})[entry.date] = entry.value;
}
} catch(e) { console.warn(`Bulk fetch failed for ${key}:`, e); }
}));
for (const [id, indData] of Object.entries(byCountry)) {
if (countryData[id]) continue;
countryData[id] = indData;
countryData[id]['VDEM.POLYARCHY'] = vdemSeries[id] || {};
countryData[id]['RSF.PRESS'] = rsfSeries[id] || {};
}
wrap?.querySelector('.quadrant-loading')?.remove();
renderQuadrant();
}
// Freedom × Access quadrant map.
// Every country in countryData gets plotted as (infrastructure, freedom) at
// its own score year. Selected countries are drawn larger and outlined so the
// user's chosen set stands out against the regional-sample backdrop.
/// ColorBrewer Set1 (8-class) — maximum perceptual divergence across qualitative categories.
// colorbrewer2.org © Cynthia Brewer, Penn State.
const REGION_COLORS = {
'Sub-Saharan Africa': '#e41a1c', // red
'Latin America & Caribbean': '#ff7f00', // orange
'East Asia & Pacific': '#377eb8', // blue
'Europe & Central Asia':'#984ea3', // purple
'Middle East, North Africa, Afghanistan & Pakistan': '#a65628', // brown
'South Asia': '#4daf4a', // green
'North America': '#f781bf', // pink
'Unknown': '#999999'
};
let quadrantChart = null;
const quadrantDividerPlugin = {
id: 'quadrantDividers',
afterDatasetsDraw(chart) {
const { ctx, chartArea, scales: { x, y } } = chart;
if (!x || !y) return;
const midX = x.getPixelForValue(50);
const midY = y.getPixelForValue(50);
ctx.save();
ctx.strokeStyle = 'rgba(126, 72, 58, 0.35)';
ctx.lineWidth = 1;
ctx.setLineDash([5, 4]);
ctx.beginPath(); ctx.moveTo(midX, chartArea.top); ctx.lineTo(midX, chartArea.bottom); ctx.stroke();
ctx.beginPath(); ctx.moveTo(chartArea.left, midY); ctx.lineTo(chartArea.right, midY); ctx.stroke();
// Corner labels — two lines each: freedom state · infrastructure state
ctx.setLineDash([]);
ctx.fillStyle = 'rgba(126, 72, 58, 0.55)';
ctx.font = '600 10px "Source Code Pro", monospace';
const lh = 14; // line height px
const pad = 8;
// top-left: strong freedom, weak infrastructure
ctx.textBaseline = 'top'; ctx.textAlign = 'left';
ctx.fillText('Strong freedom', chartArea.left + pad, chartArea.top + pad);
ctx.fillText('Weak infrastructure', chartArea.left + pad, chartArea.top + pad + lh);
// top-right: strong freedom, strong infrastructure
ctx.textBaseline = 'top'; ctx.textAlign = 'right';
ctx.fillText('Strong freedom', chartArea.right - pad, chartArea.top + pad);
ctx.fillText('Strong infrastructure', chartArea.right - pad, chartArea.top + pad + lh);
// bottom-left: weak freedom, weak infrastructure
ctx.textBaseline = 'bottom'; ctx.textAlign = 'left';
ctx.fillText('Weak infrastructure', chartArea.left + pad, chartArea.bottom - pad);
ctx.fillText('Weak freedom', chartArea.left + pad, chartArea.bottom - pad - lh);
// bottom-right: weak freedom, strong infrastructure
ctx.textBaseline = 'bottom'; ctx.textAlign = 'right';
ctx.fillText('Strong infrastructure', chartArea.right - pad, chartArea.bottom - pad);
ctx.fillText('Weak freedom', chartArea.right - pad, chartArea.bottom - pad - lh);
// Data labels for selected countries — with iterative collision resolution
ctx.font = '600 11px "Source Code Pro", monospace';
const LH = 15, LPAD = 5;
// 1. Collect natural positions — XOFF scales with dot radius
const lbls = [];
for (const ds of chart.data.datasets) {
const radii = ds.pointRadius;
ds.data.forEach((pt, i) => {
if (!pt.isSelected) return;
const px = x.getPixelForValue(pt.x);
const py = y.getPixelForValue(pt.y);
const tw = ctx.measureText(pt.country).width;
const r = Array.isArray(radii) ? (radii[i] ?? 8) : (radii ?? 8);
const xoff = r + 4;
lbls.push({ text: pt.country, dotX: px, dotY: py,
x: px + xoff, y: py, w: tw + LPAD * 2, h: LH });
});
}
// 2. Iteratively push overlapping labels apart (vertical axis only)
for (let iter = 0; iter < 32; iter++) {
let moved = false;
for (let i = 0; i < lbls.length; i++) {
for (let j = i + 1; j < lbls.length; j++) {
const a = lbls[i], b = lbls[j];
const overlapY = Math.abs(a.y - b.y);
const minSep = (a.h + b.h) / 2 + 3;
if (overlapY < minSep) {
const push = (minSep - overlapY) / 2 + 0.5;
if (a.y <= b.y) { a.y -= push; b.y += push; }
else { a.y += push; b.y -= push; }
moved = true;
}
}
}
if (!moved) break;
}
// 3. Draw leader lines then labels
for (const lb of lbls) {
const shifted = Math.abs(lb.y - lb.dotY) > 3;
if (shifted) {
ctx.beginPath();
ctx.strokeStyle = 'rgba(50,67,77,0.35)';
ctx.lineWidth = 0.8;
ctx.setLineDash([2, 3]);
ctx.moveTo(lb.dotX + 8, lb.dotY);
ctx.lineTo(lb.x, lb.y);
ctx.stroke();
ctx.setLineDash([]);
}
ctx.fillStyle = 'rgba(255,255,255,0.88)';
ctx.fillRect(lb.x, lb.y - lb.h / 2, lb.w, lb.h);
ctx.fillStyle = '#32434D';
ctx.textBaseline = 'middle';
ctx.textAlign = 'left';
ctx.fillText(lb.text, lb.x + LPAD, lb.y);
}
ctx.restore();
}
};
function renderQuadrant() {
const canvas = $('quadrantChart');
if (!canvas) return;
const selectedIds = new Set(selectedCountries.map(c => c.id));
// Build per-region datasets so Chart.js produces a proper color-coded legend.
const byRegion = {};
for (const [id, data] of Object.entries(countryData)) {
const yr = getScoreYear(data);
if (!yr) continue;
const infra = computeLayerScore(data, yr, INFRASTRUCTURE_INDICATORS);
const freedom = computeLayerScore(data, yr, FREEDOM_INDICATORS);
if (infra == null || freedom == null) continue;
const country = allCountries.find(c => c.id === id);
if (!country) continue;
const region = country.region || 'Unknown';
const score = computeScore(data, yr);
(byRegion[region] ||= []).push({
x: infra, y: freedom, country: country.name, id, year: yr,
score, isSelected: selectedIds.has(id)
});
}
const datasets = Object.entries(byRegion).map(([region, points]) => {
const color = REGION_COLORS[region] || '#999';
return {
label: REGION_MAP[region] || region,
data: points,
backgroundColor: points.map(p => p.isSelected ? color : color + '28'),
borderColor: points.map(p => p.isSelected ? '#32434D' : color + '28'),
borderWidth: points.map(p => p.isSelected ? 2.5 : 1),
pointRadius: points.map(p => {
const s = p.score ?? 50;
return p.isSelected ? 4 + (s / 100) * 6 : 2 + (s / 100) * 2;
}),
pointHoverRadius: points.map(p => {
const s = p.score ?? 50;
return p.isSelected ? 6 + (s / 100) * 6 : 4 + (s / 100) * 2;
})
};
});
if (quadrantChart) quadrantChart.destroy();
quadrantChart = new Chart(canvas, {
type: 'scatter',
data: { datasets },
plugins: [quadrantDividerPlugin],
options: {
responsive: true, maintainAspectRatio: false,
animation: { duration: 500 },
plugins: {
legend: {
position: 'bottom',
labels: {
usePointStyle: true, pointStyle: 'circle',
padding: 12,
font: { family: "'Source Code Pro', monospace", size: 11 },
color: '#7E483A'
}
},
tooltip: {
backgroundColor: '#32434D', titleColor: '#D4D5BF', bodyColor: '#D4D5BF',
titleFont: { family: "'Libre Franklin', sans-serif", size: 13, weight: '700' },
bodyFont: { family: "'Source Code Pro', monospace", size: 11 },
padding: 10, cornerRadius: 3, borderColor: '#3E737B', borderWidth: 1,
callbacks: {
title: items => items[0]?.raw?.country || '',
label: ctx => {
const r = ctx.raw;
const scoreStr = r.score != null ? r.score.toFixed(1) : 'N/A';
return [`Score: ${scoreStr}`, `Access: ${r.x.toFixed(0)}`, `Freedom: ${r.y.toFixed(0)}`, `(${r.year})`];
}
}
}
},
scales: {
x: {
min: 0, max: 100,
title: { display: true, text: 'Access (infrastructure, 0-100)', font: { family: "'Source Code Pro', monospace", size: 11, weight: '600' }, color: '#7E483A' },
grid: { color: 'rgba(62,115,123,0.1)', drawBorder: false },
ticks: { stepSize: 25, font: { family: "'Source Code Pro', monospace", size: 10 }, color: '#7E483A' }
},
y: {
min: 0, max: 100,
title: { display: true, text: 'Freedom (democracy + press, 0-100)', font: { family: "'Source Code Pro', monospace", size: 11, weight: '600' }, color: '#7E483A' },
grid: { color: 'rgba(62,115,123,0.1)', drawBorder: false },
ticks: { stepSize: 25, font: { family: "'Source Code Pro', monospace", size: 10 }, color: '#7E483A' }
}
}
}
});
}
chartIndicator.addEventListener('change', renderChart);
// Export
$('exportCSV').addEventListener('click', () => {
if (!selectedCountries.length) { showToast('Select countries first'); return; }
let csv = '# Sources: World Bank Indicators API v2 (infrastructure), RSF World Press Freedom Index 2022-2025, V-Dem v16 (v2x_polyarchy x 100).\n# Each row reports values from the same year as the composite score (Score Year column).\nCountry,Score Year,Indicators Used,Internet %,Broadband /100,Mobile /100,GDP/Cap,Literacy %,Democracy,Press,Score\n';
for (const c of selectedCountries) {
const d = countryData[c.id] || {};
const yr = getScoreYear(d);
const at = ind => (yr && d[ind] && d[ind][yr] != null) ? d[ind][yr] : null;
const basis = indicatorsAt(d, yr).length;
const total = Object.keys(INDICATORS).length;
const score = yr ? computeScore(d, yr) : null;
csv += `${c.name},${yr || '--'},${basis}/${total},${fmt(at('IT.NET.USER.ZS'),1)},${fmt(at('IT.NET.BBND.P2'),1)},${fmt(at('IT.CEL.SETS.P2'),1)},${at('NY.GDP.PCAP.CD')?.toFixed(0) ?? '--'},${fmt(at('SE.ADT.LITR.ZS'),1)},${fmt(at('VDEM.POLYARCHY'),1)},${fmt(at('RSF.PRESS'),1)},${score != null ? score.toFixed(1) : '--'}\n`;
}
download('digital-infrastructure-index.csv', csv, 'text/csv'); showToast('CSV downloaded');
});
$('exportJSON').addEventListener('click', () => {
if (!selectedCountries.length) { showToast('Select countries first'); return; }
const obj = {
sources: {
worldBank: 'https://api.worldbank.org/v2/ (live)',
rsf: 'rsf.org/en/index — 2022-2025 editions, bundled CSV',
vdem: 'V-Dem v16 (March 2026), v2x_polyarchy, bundled CSV'
},
generated: new Date().toISOString(),
builtFor: 'Media Party Data360 Global Challenge',
methodology: {
weights: Object.fromEntries(Object.entries(INDICATORS).map(([k,v]) => [k, v.weight])),
normalizationCaps: Object.fromEntries(Object.entries(INDICATORS).map(([k,v]) => [k, v.max])),
indicatorSources: Object.fromEntries(Object.entries(INDICATORS).map(([k,v]) => [k, v.source])),
scoreYearRule: 'Within the 2020-2025 window, pick the year with the highest indicator coverage (ties -> more recent). If nothing in that window, fall back to the latest year in 2010-2025 with the largest count.',
scaleNote: 'Each indicator normalized 0-100 by dividing by its cap (then floored at 0, capped at 100). Composite score is the weighted mean over indicators present at the score year; weights are re-proportioned when any are missing.'
},
countries: selectedCountries.map(c => {
const d = countryData[c.id] || {};
const yr = getScoreYear(d);
return {
id: c.id, name: c.name, region: c.region, incomeLevel: c.incomeLevel,
scoreYear: yr,
indicatorsUsedAtScoreYear: indicatorsAt(d, yr),
score: yr ? computeScore(d, yr) : null,
indicators: Object.fromEntries(Object.keys(INDICATORS).map(ind => [ind, d[ind] || {}]))
};
})
};
download('digital-infrastructure-index.json', JSON.stringify(obj,null,2), 'application/json'); showToast('JSON downloaded');
});
$('shareLink').addEventListener('click', () => { const url=`${location.origin}${location.pathname}?countries=${selectedCountries.map(c=>c.id).join(',')}`; navigator.clipboard.writeText(url).then(()=>showToast('Link copied')).catch(()=>showToast('Copy failed')); });
// Export all loaded countries (regional sample + selections)
function buildAllCountriesList() {
return Object.keys(countryData).map(id => {
const c = allCountries.find(x => x.id === id);
return c ? { ...c } : { id, name: id, region: 'Unknown', incomeLevel: '' };
}).sort((a, b) => a.name.localeCompare(b.name));
}
$('exportAllCSV').addEventListener('click', () => {
const all = buildAllCountriesList();
if (!all.length) { showToast('No data loaded yet'); return; }
let csv = '# Sources: World Bank Indicators API v2 (infrastructure), RSF World Press Freedom Index 2022-2025, V-Dem v16 (v2x_polyarchy x 100).\n# Each row reports values from the same year as the composite score (Score Year column).\nCountry,ISO3,Region,Score Year,Indicators Used,Internet %,Broadband /100,Mobile /100,GDP/Cap,Literacy %,Democracy,Press,Score\n';
for (const c of all) {
const d = countryData[c.id] || {};
const yr = getScoreYear(d);
const at = ind => (yr && d[ind] && d[ind][yr] != null) ? d[ind][yr] : null;
const basis = indicatorsAt(d, yr).length;
const total = Object.keys(INDICATORS).length;
const score = yr ? computeScore(d, yr) : null;
csv += `${c.name},${c.id},${c.region},${yr || '--'},${basis}/${total},${fmt(at('IT.NET.USER.ZS'),1)},${fmt(at('IT.NET.BBND.P2'),1)},${fmt(at('IT.CEL.SETS.P2'),1)},${at('NY.GDP.PCAP.CD')?.toFixed(0) ?? '--'},${fmt(at('SE.ADT.LITR.ZS'),1)},${fmt(at('VDEM.POLYARCHY'),1)},${fmt(at('RSF.PRESS'),1)},${score != null ? score.toFixed(1) : '--'}\n`;
}
download(`digital-countries-index-all-${all.length}.csv`, csv, 'text/csv');
showToast(`CSV downloaded — ${all.length} countries`);
});
$('exportAllJSON').addEventListener('click', () => {
const all = buildAllCountriesList();
if (!all.length) { showToast('No data loaded yet'); return; }
const obj = {
sources: {
worldBank: 'https://api.worldbank.org/v2/ (live)',
rsf: 'rsf.org/en/index — 2022-2025 editions, bundled CSV',
vdem: 'V-Dem v16 (March 2026), v2x_polyarchy, bundled CSV'
},
generated: new Date().toISOString(),
builtFor: 'Media Party Data360 Global Challenge',
totalCountries: all.length,
methodology: {
weights: Object.fromEntries(Object.entries(INDICATORS).map(([k,v]) => [k, v.weight])),
normalizationCaps: Object.fromEntries(Object.entries(INDICATORS).map(([k,v]) => [k, v.max])),
scoreYearRule: 'Within the 2020-2025 window, pick the year with the highest indicator coverage (ties -> more recent). Fall back to 2010-2025 if needed.',
scaleNote: 'Each indicator normalized 0-100. Composite score is the weighted mean over available indicators; weights re-proportioned when any are missing.'
},
countries: all.map(c => {
const d = countryData[c.id] || {};
const yr = getScoreYear(d);
return {
id: c.id, name: c.name, region: c.region, incomeLevel: c.incomeLevel,
scoreYear: yr,
indicatorsUsedAtScoreYear: indicatorsAt(d, yr),
score: yr ? computeScore(d, yr) : null,
indicators: Object.fromEntries(Object.keys(INDICATORS).map(ind => [ind, d[ind] || {}]))
};
})
};
download(`digital-countries-index-all-${all.length}.json`, JSON.stringify(obj,null,2), 'application/json');
showToast(`JSON downloaded — ${all.length} countries`);
});
$('clearCache').addEventListener('click', () => { const keys=[]; for(let i=0;i<localStorage.length;i++){const k=localStorage.key(i);if(k.startsWith('tdi_'))keys.push(k);} keys.forEach(k=>localStorage.removeItem(k)); showToast(`Cleared ${keys.length} items`); });
$('exportSensitivity').addEventListener('click', () => {
const all = buildAllCountriesList();
if (!all.length) { showToast('No data loaded yet'); return; }
const equalWeight = 1 / Object.keys(INDICATORS).length;
// Score every country under both the published weights and equal weights
const rows = all.flatMap(c => {
const d = countryData[c.id] || {};
const yr = getScoreYear(d);
if (!yr) return [];
const actual = computeScore(d, yr);
if (actual == null) return [];
// Equal-weight score: same normalization, uniform 1/7 weight
let ewSum = 0, ewCount = 0;
for (const ind of Object.keys(INDICATORS)) {
const val = d[ind]?.[yr];
if (val != null) { ewSum += normalize(val, ind); ewCount++; }
}
const equalScore = ewCount === 0 ? null : Math.round((ewSum / ewCount) * 10) / 10;
return [{ name: c.name, id: c.id, region: c.region, yr, actual, equalScore, basis: indicatorsAt(d, yr).length }];
});
// Rank both sets (1 = highest score)
const byActual = [...rows].sort((a, b) => b.actual - a.actual);
const byEqual = [...rows].sort((a, b) => b.equalScore - a.equalScore);
byActual.forEach((r, i) => { r.actualRank = i + 1; });
byEqual.forEach((r, i) => { r.equalRank = i + 1; });
// Re-merge ranks back onto the same objects (they share references via rows)
rows.sort((a, b) => a.actualRank - b.actualRank);
let csv = '# Sensitivity analysis: published weights vs. equal weights (1/7 each across all 7 indicators).\n';
csv += '# How to read: if most Rank Delta values are within ±5, the index is robust to the weighting choice.\n';
csv += '# Large deltas identify countries whose rank is sensitive to how infrastructure and freedom are weighted.\n';
csv += '# A large positive Score Delta (equal > published) means that country benefits from freedom being weighted less.\n';
csv += '# Rank delta = equal-weight rank minus published rank. Positive = rises under equal weights.\n';
csv += '# Score delta = equal-weight score minus published score. Positive = scores higher under equal weights.\n';
csv += 'Country,ISO3,Region,Score Year,Indicators Used,Published Score,Published Rank,Equal-Weight Score,Equal-Weight Rank,Rank Delta,Score Delta\n';
for (const r of rows) {
const rankDelta = r.equalRank - r.actualRank;
const scoreDelta = (r.equalScore >= r.actual ? '+' : '') + (r.equalScore - r.actual).toFixed(1);
csv += `${r.name},${r.id},${r.region},${r.yr},${r.basis}/7,${r.actual.toFixed(1)},${r.actualRank},${r.equalScore.toFixed(1)},${r.equalRank},${rankDelta},${scoreDelta}\n`;
}
download(`digital-countries-sensitivity-${rows.length}.csv`, csv, 'text/csv');
showToast(`Sensitivity CSV — ${rows.length} countries`);
});
function download(f, c, t) { const b=new Blob([c],{type:t}); const a=document.createElement('a'); a.href=URL.createObjectURL(b); a.download=f; a.click(); URL.revokeObjectURL(a.href); }
function downloadChartAsJpeg(canvas, filename, title, subtitle) {
const dpr = window.devicePixelRatio || 1;
const W = canvas.width;
const H = canvas.height;
// All layout constants scaled by DPR so they look correct on retina displays
const vPad = Math.round(28 * dpr); // vertical padding (top/bottom margins)
const hPad = Math.round(36 * dpr); // horizontal padding (left/right margins)
const titleH = Math.round(26 * dpr);
const subH = Math.round(20 * dpr);
const gap = Math.round(10 * dpr);
const srcH = Math.round(16 * dpr);
const topH = vPad + titleH + subH + gap;
const botH = srcH + vPad;
const totalW = W + hPad * 2;
const totalH = H + topH + botH;
const out = document.createElement('canvas');
out.width = totalW;
out.height = totalH;
const ctx = out.getContext('2d');
// Paper background for entire image
ctx.fillStyle = '#D4D5BF';
ctx.fillRect(0, 0, totalW, totalH);
// White field only for the chart area
ctx.fillStyle = '#ffffff';
ctx.fillRect(hPad, topH, W, H);
// Title
ctx.fillStyle = '#32434D';
ctx.font = `700 ${Math.round(15 * dpr)}px Arial, sans-serif`;
ctx.textBaseline = 'top';
ctx.textAlign = 'left';
ctx.fillText(title, hPad, vPad);
// Subtitle
ctx.font = `400 ${Math.round(12 * dpr)}px Georgia, serif`;
ctx.fillStyle = '#7E483A';
ctx.fillText(subtitle, hPad, vPad + titleH);
// Chart
ctx.drawImage(canvas, hPad, topH);
// Source line
ctx.font = `400 ${Math.round(10 * dpr)}px "Courier New", monospace`;
ctx.fillStyle = '#7E483A';
ctx.textBaseline = 'top';
ctx.fillText(
'Sources: World Bank Data360 API · V-Dem v16 · RSF World Press Freedom Index · Digital Countries Index',
hPad, topH + H + Math.round(10 * dpr)
);
const a = document.createElement('a');
a.href = out.toDataURL('image/jpeg', 0.92);
a.download = filename;
a.click();
}
$('downloadQuadrant').addEventListener('click', () => {
const canvas = $('quadrantChart');
if (!canvas) return;
downloadChartAsJpeg(
canvas,
'digital-countries-freedom-access-map.jpg',
'Freedom × Access Map',
'Every country plotted by access (infrastructure) vs. freedom (democracy + press) — Digital Countries Index'
);
});
$('downloadTrend').addEventListener('click', () => {
const canvas = $('trendChart');
if (!canvas) return;
const indLabel = $('chartIndicator').options[$('chartIndicator').selectedIndex].text;
downloadChartAsJpeg(
canvas,
'digital-countries-trends.jpg',
`Trend Lines — ${indLabel}`,
'Country scores over 15 years (2010–2024) — Digital Countries Index'
);
});
function updateURL() {
const ids=selectedCountries.map(c=>c.id).join(',');
const url=new URL(location.href);
if(ids)url.searchParams.set('countries',ids);else url.searchParams.delete('countries');
history.replaceState(null,'',url);
}
async function loadFromURL() {
const params=new URLSearchParams(location.search);
const str=params.get('countries');
if(str){ for(const code of str.split(',').filter(Boolean).slice(0,MAX_COUNTRIES)){ const c=allCountries.find(x=>x.id===code.toUpperCase()); if(c&&!selectedCountries.find(x=>x.id===c.id))selectedCountries.push(c); } if(selectedCountries.length){renderTags();await Promise.all(selectedCountries.map(c=>loadCountryData(c.id)));renderAll();} }
}
$('loadDefaults').addEventListener('click', async()=>{
selectedCountries=[];countryData={};
const defaults = ['BRA','MEX','NGA','DEU','IND','USA'];
for(const code of defaults){const c=allCountries.find(x=>x.id===code);if(c)selectedCountries.push(c);}
renderTags();showToast(`Loading ${selectedCountries.length} countries...`);
await Promise.all(selectedCountries.map(c=>loadCountryData(c.id)));
renderAll();updateURL();
});
function showToast(msg){const t=$('toast');t.textContent=msg;t.classList.add('visible');setTimeout(()=>t.classList.remove('visible'),3000);}
// Help modal
function openHelp() {
$('helpModal').classList.add('open');
$('helpOverlay').classList.add('open');
document.body.style.overflow = 'hidden';
}
function closeHelp() {
$('helpModal').classList.remove('open');
$('helpOverlay').classList.remove('open');
document.body.style.overflow = '';
}
$('helpFab').addEventListener('click', openHelp);
$('helpModalClose').addEventListener('click', closeHelp);
$('helpOverlay').addEventListener('click', closeHelp);
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeHelp(); });
// Tab switching
function switchTab(tabName) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === tabName));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.toggle('active', p.id === 'tab-' + tabName));
window.scrollTo(0, 0);
}
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
});
// Nav links: switch to dashboard tab first, then scroll
document.querySelectorAll('.top-nav a.nav-link').forEach(a => {
a.addEventListener('click', e => {
e.preventDefault();
switchTab('dashboard');
setTimeout(() => {
const target = document.querySelector(a.getAttribute('href'));
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 50);
});
});
async function loadRandomDefaults() {
// Pick MAX_COUNTRIES unique countries at random. Bias slightly toward countries
// the World Bank classifies with an income level (excludes a few data-poor
// territories whose region/income fields are empty), so random cards usually
// render with data.
const pool = allCountries.filter(c => c.incomeLevel && c.incomeLevel !== 'Not classified');
const shuffled = [...pool].sort(() => Math.random() - 0.5);
selectedCountries = shuffled.slice(0, MAX_COUNTRIES);
renderTags();
await Promise.all(selectedCountries.map(c => loadCountryData(c.id)));
renderAll();
}
function showRandomWarning() {
const el = $('randomWarning');
if (el) el.style.display = '';
}
function hideRandomWarning() {
const el = $('randomWarning');
if (el) el.style.display = 'none';
}
function showSearchPopup() {
const el = $('searchPopup');
if (!el) return;
el.classList.add('visible');
// Auto-dismiss after 10s so it doesn't linger forever.
const auto = setTimeout(dismissSearchPopup, 10000);
const dismiss = () => { clearTimeout(auto); dismissSearchPopup(); };
searchInput.addEventListener('focus', dismiss, { once: true });
searchInput.addEventListener('input', dismiss, { once: true });
$('searchPopupClose')?.addEventListener('click', dismiss, { once: true });
}
function dismissSearchPopup() {
const el = $('searchPopup');
if (el) el.classList.remove('visible');
}
async function init() {
try {
const [countries, rsfResult, vdemResult] = await Promise.all([
fetchCountries(),
loadCsvSeries('./data/rsf-series.csv', 'rank').catch(e => { console.warn('RSF load failed', e); return { series: {}, extra: {} }; }),
loadCsvSeries('./data/vdem-polyarchy-series.csv').catch(e => { console.warn('V-Dem load failed', e); return { series: {}, extra: {} }; })
]);
allCountries = countries;
rsfSeries = rsfResult.series;
rsfRanks = rsfResult.extra;
vdemSeries = vdemResult.series;
$('lastUpdated').textContent = `${allCountries.length} countries`;
$('footerUpdate').textContent = 'v0.1 · WB data live · V-Dem v16 (Mar 2026) · RSF 2022–2025';
const hasCountryParam = new URLSearchParams(location.search).get('countries');
if (hasCountryParam) {
await loadFromURL();
} else {
// Fresh visit: seed with 6 random countries so the page shows something
// meaningful on first load, and prompt the user to pick their own.
await loadRandomDefaults();
showRandomWarning();
showSearchPopup();
}
$('randomWarningClose')?.addEventListener('click', hideRandomWarning);
loadAllCountriesForQuadrant();
} catch(e) {
console.error('Init error:',e);
$('lastUpdated').textContent='Error';
showToast('Failed to connect');
}
}
init();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment