|
/* =================================================================== |
|
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}">×</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 || '?'} · <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} · ${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(); |
|
})(); |