// ============================================================================
// CIS READINESS NOTEBOOK - MAIN FILE
// ============================================================================
// This is the main entry point for the Climate Information Services (CIS)
// Readiness notebook. It orchestrates:
//
// 1. Translations & i18n: Loads translations and sets up language switching
// 2. UI Components: Imports shared components (hero, buttons, multiselect)
// 3. Synced Selectors: Defines the createSyncedAdminSelector function used
// by all sections to keep admin selections synchronized
// 4. Sections: Includes the three analysis sections via Quarto {{< include >}}
//
// File Structure:
// - notebook.qmd (this file) - orchestration & shared definitions
// - _cis_readiness_index.qmd - Section 1: CIS Readiness components
// - _cis_hazard_intersection.qmd - Section 2: Readiness × Hazard bivariate
// - _cis_implementation.qmd - Section 3: Readiness × Access bivariate
// ============================================================================
cis = await FileAttachment("/data/cis/translations.json").json();import { atlasHero, downloadButton } from "/helpers/uiComponents.ojs";
import { atlasTOC } from "/helpers/toc.ojs";
import { filterableDataTable } from "/helpers/atlasTable.ojs";
import { inputTemplate } from "/helpers/std.ojs";
import {
makeBivariateLegend,
makeChoropleth,
mergeDataToBoundaries,
} from "/helpers/plots.ojs";
import {
toSqlInList,
toSqlString,
sqlAdminQuery,
sqlAdminQuerySpecific,
} from "/helpers/sql.js";import {
renderA0Multi,
renderA1Multi,
renderA2Multi,
admin0Select,
admin1Select,
admin2Select,
} from "/components/_adminSelectorsMulti.ojs";
selectedAdmin0 = admin0Select.map((d) => d?.admin0_name);
selectedAdmin1 = admin1Select.map((d) => d?.admin1_name);
selectedAdmin2 = admin2Select.map((d) => d?.admin2_name);import { lang as Lang } from "/helpers/lang.js"
general_translations = await FileAttachment("/data/shared/generalTranslations.json").json()
// Supported languages with locale codes for formatting
languages = [
{ key: "en", label: "English", locale: 'en-US' },
{ key: "fr", label: "Français", locale: 'fr-FR' }
]
// Determine default language from URL param or fallback to English
defaultLangKey = {
const name = "lang";
const list = languages.map((d) => d.key);
const defaultKey = "en";
const queryParam = await Lang.getParamFromList({ name, list });
return queryParam ?? defaultKey;
}
// Hidden radio input that serves as the source of truth for language
// Other components bind to this via Inputs.bind()
viewof masterLanguage = Inputs.input(languages.find((x) => x.key === defaultLangKey))
// Translation helper function:
// Usage: _lang({ en: "Hello", fr: "Bonjour" }) => returns "Hello" or "Bonjour"
// based on current masterLanguage.key value
_lang = Lang.lg(masterLanguage.key)// ============================================================================
// SECTION 1: CIS READINESS INDEX ANALYSIS
// ============================================================================
// This file contains the core analysis of Climate Information Services (CIS)
// readiness across Sub-Saharan Africa. It loads CIS indicator data, calculates
// Africa-wide tercile classifications, and provides interactive visualizations
// for weather station density, cloud coverage, satellite agreement, and
// forecast skill at multiple administrative levels (Admin 0/1/2).
// ============================================================================
section1Title = _lang(cis.section1Title);thresholds = await FileAttachment("/data/cis/thresholds.json").json();
thresholdIndicatorByField = new Object({
wstation_density: "WeatherStation_idx",
"cloud-coverage_meanannual": "OpticalAvailability_idx",
"cv-precipitation_agreement": "Precipitation variability agreement",
"short-term_frcst_skill": "ShortTerm_idx",
seasonal_frcst_skill: "LongTerm_idx",
cis_readiness_index: "cis_dfci",
});
thresholdAdminLevel = (rowOrLevel = currentAdminLevel) => {
if (typeof rowOrLevel === "number") return `admin${rowOrLevel}`;
if (rowOrLevel?.admin2_name != null) return "admin2";
if (rowOrLevel?.admin1_name != null) return "admin1";
return "admin0";
};
thresholdRules = (field, rowOrLevel = currentAdminLevel) =>
thresholds[thresholdAdminLevel(rowOrLevel)].indicators[
thresholdIndicatorByField[field]
];
classifyThreshold = (field, value, rowOrLevel = currentAdminLevel) => {
const numericValue = Number(value);
if (value == null || Number.isNaN(numericValue)) return "No data";
const rules = thresholdRules(field, rowOrLevel);
const match = rules.find((rule, index) =>
"value" in rule
? numericValue === rule.value
: numericValue >= rule.min &&
(index === rules.length - 1
? numericValue <= rule.max
: numericValue < rule.max),
);
return match?.category ?? "No data";
};
classificationBand = (category) => {
const label = String(category).toLowerCase();
if (label.includes("weak") || label.includes("low")) return "low";
if (label.includes("strong") || label.includes("high")) return "high";
return "moderate";
};
classificationScale = (field, colors) => ({
type: "categorical",
domain: thresholdRules(field).map((rule) => rule.category),
range: colors,
label: "Classification",
});/**
* Interpret forecast skill based on both short-term and long-term RPSS scores
* RPSS (Ranked Probability Skill Score) measures forecast quality:
* > 0.6 = Good/reliable forecasts
* < 0.4 = Poor/unreliable forecasts
*
* The delta (difference) between short and long term indicates skill decay:
* Large positive delta = Rapid skill loss at longer lead times
*
* @param {number} shortTerm - Short-term (1-2 month) forecast RPSS
* @param {number} longTerm - Long-term (11-12 month) forecast RPSS
* @returns {object} Interpretation text and CIS implication
*/
classifyForecastSkill = (shortTerm, longTerm) => {
if (shortTerm == null || longTerm == null)
return { interpretation: "No data", implication: "Insufficient data" };
const delta = shortTerm - longTerm;
// Case 1: Both horizons perform well - ideal for CIS deployment
if (shortTerm > 0.6 && longTerm > 0.6) {
return {
interpretation: "Reliable forecasts at both horizons",
implication: "Scale CIS for advisories + finance",
};
}
// Case 2: Good short-term but poor long-term - limit to operational use
else if (shortTerm > 0.6 && longTerm < 0.4 && delta > 0.2) {
return {
interpretation: "Rapid loss of skill",
implication: "Limit use to short-term planning",
};
}
// Case 3: Both horizons perform poorly - use historical averages instead
else if (shortTerm < 0.4 && longTerm < 0.4) {
return {
interpretation: "Weak predictability",
implication: "Use climatology / caution",
};
}
// Case 4: Mixed results - blend forecasts with historical data
else {
return {
interpretation: "Uncertain but usable",
implication: "Blend with historical averages",
};
}
};currentAdminLevel = {
if (selectedAdmin0.length === 0) return 0; // No country selected → show all
if (selectedAdmin1.length === 0) return 1; // Country selected, no region → admin1
if (selectedAdmin2.length === 0) return 2; // Region selected, no district → admin2
return 2; // Districts selected → still admin2, but with highlighting
}
filteredData = {
const adminWhere = sqlAdminQuery(selectedAdmin0, selectedAdmin1);
const rows = await cisDB.query(`
SELECT *
FROM cis_data
WHERE ${adminWhere}
`);
return rows;
}
// Track which Admin2 regions are selected for highlighting on maps
highlightedAdmin2 = selectedAdmin2.length > 0 ? selectedAdmin2 : []// Breadcrumb navigation
breadcrumb = {
const parts = [];
// Show SSA if no admin0 selected
if (selectedAdmin0.length === 0) {
parts.push('Sub-Saharan Africa');
} else {
if (selectedAdmin0.length === 1) parts.push(selectedAdmin0[0]);
else parts.push(`${selectedAdmin0.length} countries`);
}
if (selectedAdmin1.length === 1) parts.push(selectedAdmin1[0]);
else if (selectedAdmin1.length > 1) parts.push(`${selectedAdmin1.length} admin1 regions`);
if (selectedAdmin2.length === 1) parts.push(selectedAdmin2[0]);
else if (selectedAdmin2.length > 1) parts.push(`${selectedAdmin2.length} admin2 regions (highlighted)`);
return parts.join(' → ');
}// Prepare data for Section 1.1 maps
s11MapData = {
const boundaries = currentAdminLevel === 2 ? admin2Boundaries :
currentAdminLevel === 1 ? admin1Boundaries :
admin0Boundaries;
const gaulCodeField = currentAdminLevel === 2 ? 'gaul2_code' :
currentAdminLevel === 1 ? 'gaul1_code' :
'gaul0_code';
// Filter boundaries based on current selection
// Instead of filtering by property names (which might not exist), filter by matching with filteredData
let filteredBoundaries = boundaries;
if (currentAdminLevel === 2) {
// When showing admin2, only include boundaries that match our filtered data
// This ensures we only show boundaries for selected admin1 regions
const filteredGaulCodes = new Set(filteredData.map(d => d[gaulCodeField]).filter(c => c != null));
filteredBoundaries = {
...boundaries,
features: boundaries.features.filter(f => {
const gaulCode = f.properties[gaulCodeField];
// Include if it matches filtered data OR if it's in the selected admin0/admin1
const matchesData = filteredGaulCodes.has(gaulCode);
const matchesAdmin0 = selectedAdmin0.length === 0 || (f.properties.admin0_name && selectedAdmin0.includes(f.properties.admin0_name));
const matchesAdmin1 = selectedAdmin1.length === 0 || (f.properties.admin1_name && selectedAdmin1.includes(f.properties.admin1_name));
return matchesData || (matchesAdmin0 && matchesAdmin1);
})
};
} else if (currentAdminLevel === 1) {
// When showing admin1, filter by admin0 and match with filtered data
const filteredGaulCodes = new Set(filteredData.map(d => d[gaulCodeField]).filter(c => c != null));
filteredBoundaries = {
...boundaries,
features: boundaries.features.filter(f => {
const gaulCode = f.properties[gaulCodeField];
const matchesData = filteredGaulCodes.has(gaulCode);
const matchesAdmin0 = selectedAdmin0.length === 0 || (f.properties.admin0_name && selectedAdmin0.includes(f.properties.admin0_name));
return matchesData || matchesAdmin0;
})
};
}
const joinKey = currentAdminLevel === 2
? (d) => `${d.admin0_name}|${d.admin1_name}|${d.admin2_name}`
: currentAdminLevel === 1
? (d) => `${d.admin0_name}|${d.admin1_name}`
: (d) => `${d.admin0_name}`;
const geoData = mergeDataToBoundaries({
boundaries: filteredBoundaries,
data: filteredData,
boundaryKey: (feature) => joinKey(feature.properties),
dataKey: (row) => joinKey(row)
});
const features = geoData.features.map(feature => {
const dataRow = feature.properties.data;
// Pre-compute region name for faster tooltip rendering
const regionName = currentAdminLevel === 0
? feature.properties.admin0_name
: currentAdminLevel === 1
? `${feature.properties.admin1_name}, ${feature.properties.admin0_name}`
: `${feature.properties.admin2_name}, ${feature.properties.admin1_name}, ${feature.properties.admin0_name}`;
// Check if this admin2 region is highlighted
const isHighlighted = currentAdminLevel === 2 &&
highlightedAdmin2.length > 0 &&
highlightedAdmin2.includes(feature.properties.admin2_name);
return {
...feature,
properties: {
...feature.properties,
regionName: regionName,
weatherStation: dataRow?.["wstation_density"],
cloudCover: dataRow?.["cloud-coverage_meanannual"],
weatherStationClass: dataRow ? classifyThreshold("wstation_density", dataRow["wstation_density"], dataRow) : "No data",
cloudCoverClass: dataRow ? classifyThreshold("cloud-coverage_meanannual", dataRow["cloud-coverage_meanannual"], dataRow) : "No data",
isHighlighted: isHighlighted
}
};
});
// Only filter out features without data if we have some features with data
// This prevents removing all features if matching fails
const featuresWithData = features.filter(f => f.properties.weatherStation != null || f.properties.cloudCover != null);
// If we have features with data, use those; otherwise, show all features (they'll show as "No data")
return { type: "FeatureCollection", features: featuresWithData.length > 0 ? featuresWithData : features };
}// Section 1.1 Maps
{
if (s11ViewType === "Map") {
//TODO: see if this can be removed
const height = 600;
const useRaw = s11MapMode === "Raw values";
const highlightConfig = {
filter: (d) => d.properties.isHighlighted,
stroke: "#000",
strokeWidth: 2.5
};
return htl.html`
<div style="display: flex; gap: 20px;">
<div style="flex: 1;">
<h4>${_lang(cis.weatherStationLabel)}</h4>
${makeChoropleth({
features: s11MapData,
width: width / 2,
fill: useRaw
? d => d.properties.weatherStation
: d => d.properties.weatherStationClass,
color: useRaw ? {
type: "linear",
domain: d3.extent(s11MapData.features, d => d.properties.weatherStation),
scheme: "YlGnBu",
label: "Density"
} : classificationScale("wstation_density", ["#fee5d9", "#fcae91", "#fb6a4a"]),
channels: {
region: {
//TODO: replace this with country, admin1, admin2 (also better titles and translations)
label: "Region",
value: (d) => d.properties.regionName
},
weatherStation: {
label: "Weather Station",
value: (d) => d.properties.weatherStation != null
? d.properties.weatherStation.toFixed(3)
: "N/A"
},
class: {
label: "Class",
value: (d) => d.properties.weatherStationClass
}
},
tooltip: {
region: true,
weatherStation: true,
class: true
},
adminHighlight: highlightConfig
})}
</div>
<div style="flex: 1;">
<h4>${_lang(cis.cloudCoverLabel)}</h4>
${makeChoropleth({
features: s11MapData,
width: width / 2,
height,
fill: useRaw
? d => d.properties.cloudCover
: d => d.properties.cloudCoverClass,
color: useRaw ? {
type: "linear",
domain: d3.extent(s11MapData.features, d => d.properties.cloudCover),
scheme: "YlGnBu",
label: "Coverage"
} : classificationScale("cloud-coverage_meanannual", ["#fee5d9", "#fcae91", "#fb6a4a"]),
channels: {
region: {
label: "Region",
value: (d) => d.properties.regionName
},
cloudCover: {
label: "Cloud Cover",
value: (d) => d.properties.cloudCover != null
? d.properties.cloudCover.toFixed(3)
: "N/A"
},
class: {
label: "Class",
value: (d) => d.properties.cloudCoverClass
}
},
tooltip: {
region: true,
cloudCover: true,
class: true
},
adminHighlight: highlightConfig
})}
</div>
</div>`;
} else {
// Table view
const tableData = filteredData.map(d => {
const adminName = currentAdminLevel === 0
? d.admin0_name
: currentAdminLevel === 1
? `${d.admin1_name}, ${d.admin0_name}`
: `${d.admin2_name}, ${d.admin1_name}`;
return {
"Admin Name": adminName,
"Weather Station (Raw)": d["wstation_density"]?.toFixed(3) || "N/A",
"Weather Station (Class)": classifyThreshold("wstation_density", d["wstation_density"], d),
"Cloud Cover (Raw)": d["cloud-coverage_meanannual"]?.toFixed(3) || "N/A",
"Cloud Cover (Class)": classifyThreshold("cloud-coverage_meanannual", d["cloud-coverage_meanannual"], d)
};
});
return filterableDataTable(tableData, {
sort: "Admin Name",
});
}
}// Download button for Section 1.1
downloadButton(
filteredData.map((d) => {
const adminName =
currentAdminLevel === 0
? d.admin0_name
: currentAdminLevel === 1
? `${d.admin1_name}, ${d.admin0_name}`
: `${d.admin2_name}, ${d.admin1_name}`;
return {
"Weather Station Density": d["wstation_density"],
"Weather Station Class": classifyThreshold(
"wstation_density",
d["wstation_density"],
d,
),
"Cloud Coverage": d["cloud-coverage_meanannual"],
"Cloud Coverage Class": classifyThreshold(
"cloud-coverage_meanannual",
d["cloud-coverage_meanannual"],
d,
),
};
}),
"cis_observation_capacity",
_lang(cis.downloadLabel),
);// Section 1.1 Dynamic Insight
s11Insight = {
// If admin2 regions are highlighted, only use those for insights
const insightData = highlightedAdmin2.length > 0
? filteredData.filter(d => highlightedAdmin2.includes(d.admin2_name))
: filteredData;
const avgWeatherStation = d3.mean(insightData, d => d["wstation_density"]);
const avgCloudCover = d3.mean(insightData, d => d["cloud-coverage_meanannual"]);
const wsClass = classificationBand(classifyThreshold("wstation_density", avgWeatherStation));
const ccClass = classificationBand(classifyThreshold("cloud-coverage_meanannual", avgCloudCover));
const capacity = (wsClass === "high" && ccClass === "high") ? "high" :
(wsClass === "low" || ccClass === "low") ? "low" : "moderate";
return _lang({
en: `In ${breadcrumb}, ground-station density is ${avgWeatherStation?.toFixed(3)} and clear-sky fraction ${avgCloudCover?.toFixed(3)}, indicating ${capacity} capacity for both in-situ and optical observation. Classification is relative to Sub-Saharan Africa.`,
fr: `Dans ${breadcrumb}, la densité des stations au sol est ${avgWeatherStation?.toFixed(3)} et la fraction de ciel clair ${avgCloudCover?.toFixed(3)}, indiquant une capacité ${capacity} pour l'observation in-situ et optique. La classification est relative à l'Afrique subsaharienne.`
});
}// Prepare data for Section 1.2 map
s12MapData = {
const boundaries = currentAdminLevel === 2 ? admin2Boundaries :
currentAdminLevel === 1 ? admin1Boundaries :
admin0Boundaries;
const gaulCodeField = currentAdminLevel === 2 ? 'gaul2_code' :
currentAdminLevel === 1 ? 'gaul1_code' :
'gaul0_code';
// Filter boundaries based on current selection
// Instead of filtering by property names, filter by matching with filteredData
let filteredBoundaries = boundaries; //TODO: This occurs multiple times, clean up/reuse
if (currentAdminLevel === 2) {
// When showing admin2, only include boundaries that match our filtered data
const filteredGaulCodes = new Set(filteredData.map(d => d[gaulCodeField]).filter(c => c != null));
filteredBoundaries = {
...boundaries,
features: boundaries.features.filter(f => {
const gaulCode = f.properties[gaulCodeField];
const matchesData = filteredGaulCodes.has(gaulCode);
const matchesAdmin0 = selectedAdmin0.length === 0 || (f.properties.admin0_name && selectedAdmin0.includes(f.properties.admin0_name));
const matchesAdmin1 = selectedAdmin1.length === 0 || (f.properties.admin1_name && selectedAdmin1.includes(f.properties.admin1_name));
return matchesData || (matchesAdmin0 && matchesAdmin1);
})
};
} else if (currentAdminLevel === 1) {
// When showing admin1, filter by admin0 and match with filtered data
const filteredGaulCodes = new Set(filteredData.map(d => d[gaulCodeField]).filter(c => c != null));
filteredBoundaries = {
...boundaries,
features: boundaries.features.filter(f => {
const gaulCode = f.properties[gaulCodeField];
const matchesData = filteredGaulCodes.has(gaulCode);
const matchesAdmin0 = selectedAdmin0.length === 0 || (f.properties.admin0_name && selectedAdmin0.includes(f.properties.admin0_name));
return matchesData || matchesAdmin0;
})
};
}
const joinKey = currentAdminLevel === 2
? (d) => `${d.admin0_name}|${d.admin1_name}|${d.admin2_name}`
: currentAdminLevel === 1
? (d) => `${d.admin0_name}|${d.admin1_name}`
: (d) => `${d.admin0_name}`;
const geoData = mergeDataToBoundaries({
boundaries: filteredBoundaries,
data: filteredData,
boundaryKey: (feature) => joinKey(feature.properties),
dataKey: (row) => joinKey(row)
});
const features = geoData.features.map(feature => {
const dataRow = feature.properties.data;
const precipValue = dataRow ? parseFloat(dataRow["cv-precipitation_agreement"]) : null;
// Check if this admin2 region is highlighted
const isHighlighted = currentAdminLevel === 2 &&
highlightedAdmin2.length > 0 &&
highlightedAdmin2.includes(feature.properties.admin2_name);
return {
...feature,
properties: {
...feature.properties,
precipAgreement: precipValue,
precipAgreementClass: classifyThreshold("cv-precipitation_agreement", precipValue, dataRow),
isHighlighted: isHighlighted
}
};
}).filter(f => f.properties.precipAgreement != null);
return { type: "FeatureCollection", features };
}// Section 1.2 Map/Table
{
if (s12ViewType === "Map") {
const useRaw = s12MapMode === "Raw values";
const highlightConfig = {
filter: (d) => d.properties.isHighlighted,
stroke: "#000",
strokeWidth: 2.5
};
return makeChoropleth({
features: s12MapData,
width: width,
height: 600,
fill: useRaw ? d => d.properties.precipAgreement : d => d.properties.precipAgreementClass,
color: useRaw ? {
type: "linear",
domain: d3.extent(s12MapData.features, d => d.properties.precipAgreement),
scheme: "Viridis",
label: "Agreement"
} : classificationScale("cv-precipitation_agreement", ["#fff5eb", "#fdd0a2", "#fdae6b", "#e6550d", "#a63603"]),
channels: {
region: {
label: "Region",
value: (d) => {
const regionName = d.properties.admin2_name || d.properties.admin1_name || d.properties.admin0_name;
const country = d.properties.admin0_name;
return regionName === country ? regionName : `${regionName}, ${country}`;
}
},
satelliteAgreement: {
label: "Satellite Agreement",
value: (d) => d.properties.precipAgreement != null
? d.properties.precipAgreement.toFixed(1)
: "N/A"
},
class: {
label: "Class",
value: (d) => d.properties.precipAgreementClass
}
},
tooltip: {
region: true,
satelliteAgreement: true,
class: true
},
adminHighlight: highlightConfig
});
} else {
const tableData = filteredData.map(d => {
const adminName = currentAdminLevel === 0
? d.admin0_name
: currentAdminLevel === 1
? `${d.admin1_name}, ${d.admin0_name}`
: `${d.admin2_name}, ${d.admin1_name}`;
return {
"Admin Name": adminName,
"Satellite Agreement (Raw)": parseFloat(d["cv-precipitation_agreement"])?.toFixed(1) || "N/A",
"Satellite Agreement (Class)": classifyThreshold("cv-precipitation_agreement", d["cv-precipitation_agreement"], d)
};
});
return filterableDataTable(tableData, {
sort: "Admin Name",
});
}
}// Download button for Section 1.2
downloadButton(
filteredData.map((d) => {
const adminName =
currentAdminLevel === 0
? d.admin0_name
: currentAdminLevel === 1
? `${d.admin1_name}, ${d.admin0_name}`
: `${d.admin2_name}, ${d.admin1_name}`;
return {
"Admin Name": adminName,
"Satellite Agreement": parseFloat(d["cv-precipitation_agreement"]),
Classification: classifyThreshold(
"cv-precipitation_agreement",
d["cv-precipitation_agreement"],
d,
),
};
}),
"cis_precipitation_agreement",
_lang(cis.downloadLabel),
);// Section 1.2 Dynamic Insight
s12Insight = {
// If admin2 regions are highlighted, only use those for insights
const insightData = highlightedAdmin2.length > 0
? filteredData.filter(d => highlightedAdmin2.includes(d.admin2_name))
: filteredData;
const avgAgreement = d3.mean(insightData, d => parseFloat(d["cv-precipitation_agreement"]));
let agreementLevel = "no";
if (avgAgreement >= 3) agreementLevel = "high";
else if (avgAgreement >= 1) agreementLevel = "partial";
return _lang({
en: `In ${breadcrumb}, consistency across satellite data sources is ${avgAgreement?.toFixed(2)} indicating ${agreementLevel} agreement.`,
fr: `Dans ${breadcrumb}, la cohérence entre les sources de données satellites est ${avgAgreement?.toFixed(2)} indiquant un accord ${agreementLevel}.`
});
}viewof s13ViewType = Inputs.radio(["Map", "Chart", "Table"], {
label: _lang(cis.viewTypeLabel),
value: "Map"
})
viewof s13SortBy = {
// Only show sort-by when Chart or Table view is selected
if (s13ViewType === "Map") {
const input = Inputs.radio(["Short-term skill", "Long-term skill", "Difference (Δ)"], {
label: _lang(cis.sortByLabel),
value: "Short-term skill"
});
input.style.display = "none";
return input;
}
return Inputs.radio(["Short-term skill", "Long-term skill", "Difference (Δ)"], {
label: _lang(cis.sortByLabel),
value: "Short-term skill"
});
}// Section 1.3 Map Data Preparation
s13MapData = {
const boundaries = currentAdminLevel === 2 ? admin2Boundaries :
currentAdminLevel === 1 ? admin1Boundaries :
admin0Boundaries;
const gaulCodeField = currentAdminLevel === 2 ? 'gaul2_code' :
currentAdminLevel === 1 ? 'gaul1_code' :
'gaul0_code';
// Filter boundaries based on current selection
let filteredBoundaries = boundaries;
if (currentAdminLevel === 2) {
const filteredGaulCodes = new Set(filteredData.map(d => d[gaulCodeField]).filter(c => c != null));
filteredBoundaries = {
...boundaries,
features: boundaries.features.filter(f => {
const gaulCode = f.properties[gaulCodeField];
const matchesData = filteredGaulCodes.has(gaulCode);
const matchesAdmin0 = selectedAdmin0.length === 0 || (f.properties.admin0_name && selectedAdmin0.includes(f.properties.admin0_name));
const matchesAdmin1 = selectedAdmin1.length === 0 || (f.properties.admin1_name && selectedAdmin1.includes(f.properties.admin1_name));
return matchesData || (matchesAdmin0 && matchesAdmin1);
})
};
} else if (currentAdminLevel === 1) {
const filteredGaulCodes = new Set(filteredData.map(d => d[gaulCodeField]).filter(c => c != null));
filteredBoundaries = {
...boundaries,
features: boundaries.features.filter(f => {
const gaulCode = f.properties[gaulCodeField];
const matchesData = filteredGaulCodes.has(gaulCode);
const matchesAdmin0 = selectedAdmin0.length === 0 || (f.properties.admin0_name && selectedAdmin0.includes(f.properties.admin0_name));
return matchesData || matchesAdmin0;
})
};
}
const joinKey = currentAdminLevel === 2
? (d) => `${d.admin0_name}|${d.admin1_name}|${d.admin2_name}`
: currentAdminLevel === 1
? (d) => `${d.admin0_name}|${d.admin1_name}`
: (d) => `${d.admin0_name}`;
const geoData = mergeDataToBoundaries({
boundaries: filteredBoundaries,
data: filteredData,
boundaryKey: (feature) => joinKey(feature.properties),
dataKey: (row) => joinKey(row)
});
const features = geoData.features.map(feature => {
const dataRow = feature.properties.data;
const shortTerm = dataRow ? dataRow["short-term_frcst_skill"] : null;
const longTerm = dataRow ? dataRow["seasonal_frcst_skill"] : null;
const delta = (shortTerm != null && longTerm != null) ? longTerm - shortTerm : null;
// Check if this admin2 region is highlighted
const isHighlighted = currentAdminLevel === 2 &&
highlightedAdmin2.length > 0 &&
highlightedAdmin2.includes(feature.properties.admin2_name);
return {
...feature,
properties: {
...feature.properties,
shortTerm: shortTerm,
longTerm: longTerm,
delta: delta,
isHighlighted: isHighlighted
}
};
}).filter(f => f.properties.shortTerm != null || f.properties.longTerm != null);
return { type: "FeatureCollection", features };
}// Prepare data for Section 1.3
s13Data = {
const data = filteredData.map(d => {
const shortTerm = d["short-term_frcst_skill"];
const longTerm = d["seasonal_frcst_skill"];
const classification = classifyForecastSkill(shortTerm, longTerm);
const adminName = currentAdminLevel === 0
? d.admin0_name
: currentAdminLevel === 1
? `${d.admin1_name}, ${d.admin0_name}`
: `${d.admin2_name}, ${d.admin1_name}`;
return {
admin: adminName,
shortTerm: shortTerm,
longTerm: longTerm,
delta: shortTerm != null && longTerm != null ? longTerm - shortTerm : null,
interpretation: classification.interpretation,
implication: classification.implication
};
}).filter(d => d.shortTerm != null || d.longTerm != null);
// Sort data
if (s13SortBy === "Short-term skill") { //TODO: We can use bulit in OJS plot fn instead
data.sort((a, b) => (b.shortTerm || 0) - (a.shortTerm || 0));
} else if (s13SortBy === "Long-term skill") {
data.sort((a, b) => (b.longTerm || 0) - (a.longTerm || 0));
} else {
// Sort by delta - most positive to most negative
data.sort((a, b) => (b.delta || 0) - (a.delta || 0));
}
return data;
}// Section 1.3 Map/Chart/Table
{
if (s13ViewType === "Map") {
const highlightConfig = {
filter: (d) => d.properties.isHighlighted,
stroke: "#000",
strokeWidth: 2.5
};
const regionChannel = {
label: "Region",
value: (d) => {
const regionName = d.properties.admin2_name || d.properties.admin1_name || d.properties.admin0_name;
const country = d.properties.admin0_name;
return regionName === country ? regionName : `${regionName}, ${country}`;
}
};
const shortTermMap = makeChoropleth({
features: s13MapData,
width: 400,
height: 450,
fill: d => d.properties.shortTerm,
color: {
type: "linear",
domain: [0, 1],
scheme: "RdYlGn",
label: "RPSS"
},
channels: {
region: regionChannel,
shortTerm: {
label: "Short-term",
value: (d) => d.properties.shortTerm != null ? d.properties.shortTerm.toFixed(3) : "N/A"
}
},
tooltip: {
region: true,
shortTerm: true
},
adminHighlight: highlightConfig
});
const longTermMap = makeChoropleth({
features: s13MapData,
width: 400,
height: 450,
fill: d => d.properties.longTerm,
color: {
type: "linear",
domain: [0, 1],
scheme: "RdYlGn",
label: "RPSS"
},
channels: {
region: regionChannel,
longTerm: {
label: "Long-term",
value: (d) => d.properties.longTerm != null ? d.properties.longTerm.toFixed(3) : "N/A"
}
},
tooltip: {
region: true,
longTerm: true
},
adminHighlight: highlightConfig
});
const deltaMap = makeChoropleth({
features: s13MapData,
width: 400,
height: 450,
fill: d => d.properties.delta,
color: {
type: "diverging",
domain: [-0.5, 0.5],
scheme: "RdBu",
label: "Δ (Long − Short)"
},
channels: {
region: regionChannel,
delta: {
label: "Difference",
value: (d) => d.properties.delta != null ? d.properties.delta.toFixed(3) : "N/A"
}
},
tooltip: {
region: true,
delta: true
},
adminHighlight: highlightConfig
});
return htl.html`<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 20px; margin-top: 20px;">
<div style="text-align: center;">
<h4 style="margin-bottom: 10px; font-size: 14px;">Short-term Forecast Skill (1–2 mo)</h4>
${shortTermMap}
</div>
<div style="text-align: center;">
<h4 style="margin-bottom: 10px; font-size: 14px;">Long-term Forecast Skill (11–12 mo)</h4>
${longTermMap}
</div>
<div style="text-align: center;">
<h4 style="margin-bottom: 10px; font-size: 14px;">Difference (Long − Short)</h4>
${deltaMap}
</div>
</div>`;
} else if (s13ViewType === "Chart") {
// Prepare data in the format expected by the chart
const data = s13Data.map(d => {
const short = d.shortTerm != null ? parseFloat(d.shortTerm) : null;
const long = d.longTerm != null ? parseFloat(d.longTerm) : null;
const delta = short != null && long != null ? long - short : null;
// Calculate sort value based on selected sort mode
let sortValue = 0; //TODO: sorting again....
if (s13SortBy === "Short-term skill") {
sortValue = short || 0;
} else if (s13SortBy === "Long-term skill") {
sortValue = long || 0;
} else {
// Sort by delta (preserving sign)
sortValue = delta || 0;
}
return {
country: d.admin,
short: short,
long: long,
delta: delta,
sortValue: sortValue
};
}).filter(d => d.short != null || d.long != null);
// Sort data based on selected mode
const sorted = data.sort((a, b) => {
if (s13SortBy === "Short-term skill") {
return (b.short || 0) - (a.short || 0);
} else if (s13SortBy === "Long-term skill") {
return (b.long || 0) - (a.long || 0);
} else {
// Sort by delta - most positive to most negative
return (b.delta || 0) - (a.delta || 0);
}
});
// Flatten data: each geography gets 3 entries (Short-term, Long-term, Delta)
const longData = sorted.flatMap((d) => [
{
country: d.country,
metric: "Short-term RPSS (1–2 mo)",
value: d.short
},
{
country: d.country,
metric: "Long-term RPSS (11–12 mo)",
value: d.long
},
{
country: d.country,
metric: "Δ (Long − Short)",
value: d.delta
}
]).filter(d => d.value != null);
// Calculate responsive height based on number of geographies
const geographyCount = sorted.length;
// Height is dynamic: 20px per region to prevent label overlap, min 400px
const height = Math.max(400, geographyCount * 20);
// Get the ordered list of countries from sorted data
const countryOrder = sorted.map(d => d.country);
// Calculate margin based on longest geography name
const maxNameLength = Math.max(...countryOrder.map(name => name.length), 0);
const marginLeft = Math.max(200, maxNameLength * 8); // At least 200px, or 8px per character
// Wrap chart in scrollable container to handle many regions
const chart = Plot.plot({
marginLeft: marginLeft,
marginTop: 40,
marginBottom: 60,
width: width,
height: height,
y: { axis: null },
x: {
grid: true,
axis: "top",
label: "RPSS or Δ"
},
fy: {
padding: 0, // Set padding to 0 to reduce spacing between bars
label: null,
domain: countryOrder // Use pre-sorted order to maintain sort
},
color: {
domain: [
"Short-term RPSS (1–2 mo)",
"Long-term RPSS (11–12 mo)",
"Δ (Long − Short)"
],
range: ["#4477AA", "#EE6677", "#228833"],
legend: true
},
marks: [
// X-axis at bottom
Plot.axisX({
anchor: "bottom",
label: "RPSS or Δ"
}),
Plot.barX(longData, {
x: "value",
y: "metric",
fill: "metric",
fy: "country",
sort: {
y: null,
color: null
},
tip: true
}),
Plot.ruleX([0], { stroke: "black" })
]
});
// Return chart in scrollable container with fixed max-height
return htl.html`<div style="max-height: 600px; overflow-y: auto; border: 1px solid #eee; border-radius: 4px; padding: 10px;">
${chart}
</div>`;
} else {
const tableData = s13Data.map(d => ({
"Admin Name": d.admin,
"Short-term": d.shortTerm?.toFixed(3) || "N/A",
"Long-term": d.longTerm?.toFixed(3) || "N/A",
"Delta": d.delta?.toFixed(3) || "N/A",
"Interpretation": d.interpretation,
"CIS Implication": d.implication
}));
return filterableDataTable(tableData, {
rows: 20
});
}
}// Section 1.3 Dynamic Insight
s13Insight = {
// If admin2 regions are highlighted, only use those for insights
const insightData = highlightedAdmin2.length > 0
? filteredData.filter(d => highlightedAdmin2.includes(d.admin2_name))
: filteredData;
const avgShortTerm = d3.mean(insightData, d => d["short-term_frcst_skill"]);
const avgLongTerm = d3.mean(insightData, d => d["seasonal_frcst_skill"]);
const classification = classifyForecastSkill(avgShortTerm, avgLongTerm);
return _lang({
en: `In ${breadcrumb}, short-term forecast skill averages ${avgShortTerm?.toFixed(3)} while long-term skill drops to ${avgLongTerm?.toFixed(3)}. Predictability is ${classification.interpretation.toLowerCase()}.`,
fr: `Dans ${breadcrumb}, la compétence de prévision à court terme est en moyenne ${avgShortTerm?.toFixed(3)} tandis que la compétence à long terme chute à ${avgLongTerm?.toFixed(3)}. La prévisibilité est ${classification.interpretation.toLowerCase()}.`
});
}// Prepare data for Section 1.4
s14MapData = {
const boundaries = currentAdminLevel === 2 ? admin2Boundaries :
currentAdminLevel === 1 ? admin1Boundaries :
admin0Boundaries;
const gaulCodeField = currentAdminLevel === 2 ? 'gaul2_code' :
currentAdminLevel === 1 ? 'gaul1_code' :
'gaul0_code';
// Filter boundaries based on current selection
// Instead of filtering by property names, filter by matching with filteredData
let filteredBoundaries = boundaries; //TODO: Same boundary filtering again...
if (currentAdminLevel === 2) {
// When showing admin2, only include boundaries that match our filtered data
const filteredGaulCodes = new Set(filteredData.map(d => d[gaulCodeField]).filter(c => c != null));
filteredBoundaries = {
...boundaries,
features: boundaries.features.filter(f => {
const gaulCode = f.properties[gaulCodeField];
const matchesData = filteredGaulCodes.has(gaulCode);
const matchesAdmin0 = selectedAdmin0.length === 0 || (f.properties.admin0_name && selectedAdmin0.includes(f.properties.admin0_name));
const matchesAdmin1 = selectedAdmin1.length === 0 || (f.properties.admin1_name && selectedAdmin1.includes(f.properties.admin1_name));
return matchesData || (matchesAdmin0 && matchesAdmin1);
})
};
} else if (currentAdminLevel === 1) {
// When showing admin1, filter by admin0 and match with filtered data
const filteredGaulCodes = new Set(filteredData.map(d => d[gaulCodeField]).filter(c => c != null));
filteredBoundaries = {
...boundaries,
features: boundaries.features.filter(f => {
const gaulCode = f.properties[gaulCodeField];
const matchesData = filteredGaulCodes.has(gaulCode);
const matchesAdmin0 = selectedAdmin0.length === 0 || (f.properties.admin0_name && selectedAdmin0.includes(f.properties.admin0_name));
return matchesData || matchesAdmin0;
})
};
}
const joinKey = currentAdminLevel === 2
? (d) => `${d.admin0_name}|${d.admin1_name}|${d.admin2_name}`
: currentAdminLevel === 1
? (d) => `${d.admin0_name}|${d.admin1_name}`
: (d) => `${d.admin0_name}`;
const geoData = mergeDataToBoundaries({
boundaries: filteredBoundaries,
data: filteredData,
boundaryKey: (feature) => joinKey(feature.properties),
dataKey: (row) => joinKey(row)
});
const features = geoData.features.map(feature => {
const dataRow = feature.properties.data;
// Check if this admin2 region is highlighted
const isHighlighted = currentAdminLevel === 2 &&
highlightedAdmin2.length > 0 &&
highlightedAdmin2.includes(feature.properties.admin2_name);
return {
...feature,
properties: {
...feature.properties,
cisReadiness: dataRow?.["cis_readiness_index"],
cisReadinessClass: dataRow ? classifyThreshold("cis_readiness_index", dataRow["cis_readiness_index"], dataRow) : "No data",
weatherStation: dataRow?.["wstation_density"],
weatherStationClass: dataRow ? classifyThreshold("wstation_density", dataRow["wstation_density"], dataRow) : "No data",
cloudCover: dataRow?.["cloud-coverage_meanannual"],
cloudCoverClass: dataRow ? classifyThreshold("cloud-coverage_meanannual", dataRow["cloud-coverage_meanannual"], dataRow) : "No data",
precipAgreement: dataRow ? parseFloat(dataRow["cv-precipitation_agreement"]) : null,
precipAgreementClass: dataRow ? classifyThreshold("cv-precipitation_agreement", dataRow["cv-precipitation_agreement"], dataRow) : "No data",
shortTerm: dataRow?.["short-term_frcst_skill"],
longTerm: dataRow?.["seasonal_frcst_skill"],
isHighlighted: isHighlighted
}
};
}).filter(f => f.properties.cisReadiness != null);
return { type: "FeatureCollection", features };
}// Section 1.4 Visualization
{
if (s14ViewType === "Map") {
return makeChoropleth({
features: s14MapData,
width: width,
height: 600,
fill: d => d.properties.cisReadiness,
color: {
type: "linear",
domain: d3.extent(s14MapData.features, d => d.properties.cisReadiness),
scheme: "RdYlGn",
label: "CIS Readiness Index"
},
channels: {
region: {
label: "Region",
value: (d) => d.properties.admin0_name || d.properties.admin1_name || d.properties.admin2_name
},
cisReadiness: {
label: "CIS Readiness",
value: (d) => d.properties.cisReadiness != null
? `${d.properties.cisReadiness.toFixed(3)} (Relative to SSA)`
: "N/A"
},
class: {
label: "Classification",
value: (d) => d.properties.cisReadinessClass
},
weatherStation: {
label: "Weather Stn",
value: (d) => d.properties.weatherStationClass
},
cloudCover: {
label: "Cloud Cover",
value: (d) => d.properties.cloudCoverClass
},
precipAgreement: {
label: "Precip Agree",
value: (d) => d.properties.precipAgreementClass
}
},
tooltip: {
region: true,
cisReadiness: true,
class: true,
weatherStation: true,
cloudCover: true,
precipAgreement: true
},
baseMarkOptions: {
fillOpacity: 1
},
adminHighlight: {
filter: (d) => d.properties.isHighlighted,
stroke: "#000",
strokeWidth: 2.5
}
});
} else if (s14ViewType === "Table") {
const tableData = filteredData
.filter(d => d["cis_readiness_index"] != null)
.map(d => {
const adminName = currentAdminLevel === 0
? d.admin0_name
: currentAdminLevel === 1
? `${d.admin1_name}, ${d.admin0_name}`
: `${d.admin2_name}, ${d.admin1_name}`;
return {
"Admin Name": adminName, //TODO: Admin name again
"CIS Readiness (Raw)": d["cis_readiness_index"]?.toFixed(3) || "N/A",
"CIS Readiness (Class)": classifyThreshold("cis_readiness_index", d["cis_readiness_index"], d),
"Weather Stn (Raw)": d["wstation_density"]?.toFixed(3) || "N/A",
"Weather Stn (Class)": classifyThreshold("wstation_density", d["wstation_density"], d),
"Cloud Cover (Raw)": d["cloud-coverage_meanannual"]?.toFixed(3) || "N/A",
"Cloud Cover (Class)": classifyThreshold("cloud-coverage_meanannual", d["cloud-coverage_meanannual"], d),
"Precip Agree (Raw)": d["cv-precipitation_agreement"]?.toFixed(1) || "N/A",
"Precip Agree (Class)": classifyThreshold("cv-precipitation_agreement", d["cv-precipitation_agreement"], d),
"Short-term (Raw)": d["short-term_frcst_skill"]?.toFixed(3) || "N/A",
"Long-term (Raw)": d["seasonal_frcst_skill"]?.toFixed(3) || "N/A"
};
});
return filterableDataTable(tableData, { // TODO: Why this tabe go ... when rest horizontal scroll
sort: "CIS Readiness (Raw)",
reverse: true,
})
} else {
const heatmapData = filteredData
.filter(d => d["cis_readiness_index"] != null)
.map(d => {
const adminName = currentAdminLevel === 0
? d.admin0_name
: currentAdminLevel === 1
? `${d.admin1_name}, ${d.admin0_name}`
: `${d.admin2_name}, ${d.admin1_name}`;
return {
country: adminName,
"Weather Stations": d["wstation_density"],
"Cloud Cover": d["cloud-coverage_meanannual"],
"Precip Agreement": parseFloat(d["cv-precipitation_agreement"]) / 4, // Normalize to 0-1
"Short-term Forecast": d["short-term_frcst_skill"],
"Long-term Forecast": d["seasonal_frcst_skill"],
"CIS Readiness": d["cis_readiness_index"]
};
});
// Sort by CIS Readiness (descending)
heatmapData.sort((a, b) => (b["CIS Readiness"] || 0) - (a["CIS Readiness"] || 0));
const sortedCountries = heatmapData.map(d => d.country);
const indicators = ["Weather Stations", "Cloud Cover", "Precip Agreement", "Short-term Forecast", "Long-term Forecast", "CIS Readiness"];
// Calculate responsive dimensions
const geographyCount = heatmapData.length;
const cellHeight = 25;
const height = Math.max(400, geographyCount * cellHeight + 120); // Extra space for legend at bottom
const maxNameLength = Math.max(...sortedCountries.map(name => name.length), 0);
const marginLeft = Math.max(180, maxNameLength * 7);
// Flatten data for heatmap (long format)
const longData = heatmapData.flatMap(row =>
indicators.map(indicator => ({
country: row.country,
indicator: indicator,
value: row[indicator]
}))
).filter(d => d.value != null);
// Use Observable Plot.cell for the heatmap
return htl.html`<div style="max-height: 700px; overflow-y: auto; border: 1px solid #eee; border-radius: 4px; padding: 10px;">
${Plot.plot({
width: width,
height: height,
marginLeft: marginLeft,
marginTop: 80, // More space for rotated axis labels
marginBottom: 120, // Increased bottom margin as requested
marginRight: 20,
x: {
label: null,
tickRotate: -45,
domain: indicators
},
y: {
label: null,
domain: sortedCountries
},
color: {
type: "linear",
scheme: "RdYlBu",
reverse: true, // Red = high, Blue = low
legend: true,
label: "Value"
},
marks: [
Plot.cell(longData, {
x: "indicator",
y: "country",
fill: "value",
tip: {
fontSize: 14,
format: {
fill: d => d != null ? d.toFixed(3) : "N/A"
}
}
}),
Plot.text(longData, {
x: "indicator",
y: "country",
text: d => d.value != null ? d.value.toFixed(2) : "",
fill: d => d.value > 0.5 ? "white" : "black",
fontSize: 9
})
]
})}
</div>`;
}
}// Download button for Section 1.4
downloadButton(
filteredData.map((d) => {
const adminName =
currentAdminLevel === 0
? d.admin0_name
: currentAdminLevel === 1
? `${d.admin1_name}, ${d.admin0_name}`
: `${d.admin2_name}, ${d.admin1_name}`;
return {
"Admin Name": adminName,
"CIS Readiness Index": d["cis_readiness_index"],
"Weather Station Density": d["wstation_density"],
"Cloud Coverage": d["cloud-coverage_meanannual"],
"Precipitation Agreement": parseFloat(d["cv-precipitation_agreement"]),
"Short-term Forecast Skill": d["short-term_frcst_skill"],
"Long-term Forecast Skill": d["seasonal_frcst_skill"],
};
}),
"cis_readiness_index",
_lang(cis.downloadLabel),
);// Section 1.4 Dynamic Insight
s14Insight = {
// If admin2 regions are highlighted, only use those for insights
const insightData = highlightedAdmin2.length > 0
? filteredData.filter(d => highlightedAdmin2.includes(d.admin2_name))
: filteredData;
const avgReadiness = d3.mean(insightData, d => d["cis_readiness_index"]);
const readinessClass = classifyThreshold("cis_readiness_index", avgReadiness).toLowerCase();
return _lang({
en: `In ${breadcrumb}, the overall CIS Readiness Index averages ${avgReadiness?.toFixed(3)}, classified as ${readinessClass} relative to Sub-Saharan Africa.`,
fr: `Dans ${breadcrumb}, l'indice global de préparation CIS est en moyenne ${avgReadiness?.toFixed(3)}, classé comme ${readinessClass} par rapport à l'Afrique subsaharienne.`
});
}hazardDB = await DuckDBClient.of({
hazard_data: FileAttachment("/data/cis/nexgddpHazards_monthMean_jagermeyr_rounded.parquet")
});
// Query hazard data with SQL-side admin filtering
hazardData = {
const hazards = ["NDWS-mean", "NDWL0-mean"];
const adminWhere = sqlAdminQuery(selectedAdmin0, selectedAdmin1);
//TODO: I think this should be filtering, so hazard can be based on selection
return await hazardDB.query(`
SELECT
iso3,
admin0_name,
admin1_name,
admin2_name,
hazard,
value
FROM hazard_data
WHERE hazard IN (${toSqlInList(hazards)})
AND ${adminWhere}
`);
}
hazardPivoted = {
// TODO: why/is this needed?
// Create a composite key for each unique region (all admin levels)
const grouped = d3.group(hazardData, d => `${d.iso3}_${d.admin0_name}_${d.admin1_name}_${d.admin2_name}`);
return Array.from(grouped.values()).map(group => {
const base = group[0]; // Get region identifiers from first row
const drought = group.find(d => d.hazard === "NDWS-mean");
const waterlogging = group.find(d => d.hazard === "NDWL0-mean");
return {
iso3: base.iso3,
admin0_name: base.admin0_name,
admin1_name: base.admin1_name,
admin2_name: base.admin2_name,
drought_ndws: drought ? parseFloat(drought.value) : null,
waterlogging_ndwl0: waterlogging ? parseFloat(waterlogging.value) : null
};
});
}
combinedData = filteredData.map(cis => {
// Find matching hazard row by exact admin name match
const hazard = hazardPivoted.find(h =>
h.iso3 === cis.iso3 &&
h.admin0_name === cis.admin0_name &&
h.admin1_name === cis.admin1_name &&
h.admin2_name === cis.admin2_name
);
return {
...cis,
drought_ndws: hazard?.drought_ndws || null,
waterlogging_ndwl0: hazard?.waterlogging_ndwl0 || null
};
}).filter(d => d.drought_ndws !== null || d.waterlogging_ndwl0 !== null);
// ============================================================================
// BIVARIATE CLASSIFICATION FUNCTION
// ============================================================================
// Classifies regions into a 3×3 bivariate grid based on:
// - CIS Readiness: Low/Moderate/High (using thresholds.json)
// - Hazard Severity: Low/Moderate/High (using hazard-specific thresholds)
//
// Hazard thresholds are based on climate science conventions: //TODO: @BRAYDEN Check the thresholds ASAP. Smells like GPT bs.
// - Drought (NDWS): <15 days = Low, 15-20 = Moderate, >20 = High //Important: this gets replaced with Haz X Exposure data anyway
// - Waterlogging (NDWL0): <2 days = Low, 2-5 = Moderate, >5 = High
//
// The resulting bivariateClass (e.g., "High × High") drives map coloring.
// ============================================================================
function bivariateClassify(readiness, hazard, hazardType, readinessLevel = currentAdminLevel) {
const readinessClass = classifyThreshold(
"cis_readiness_index",
readiness,
readinessLevel,
);
// Classify hazard using hazard-type-specific thresholds
let hazardClass;
if (hazardType === "NDWS-mean") {
if (hazard < 15) hazardClass = "Low";
else if (hazard < 20) hazardClass = "Moderate";
else hazardClass = "High";
} else if (hazardType === "NDWL0-mean") {
if (hazard < 2) hazardClass = "Low";
else if (hazard < 5) hazardClass = "Moderate";
else hazardClass = "High";
}
return {
readinessClass,
hazardClass,
bivariateClass: `${readinessClass} × ${hazardClass}`
};
}
bivariateColors = {
return {
"Low × Low": "#e8e8e8", // Light gray - lowest priority
"Low × Moderate": "#b8d6be",
"Low × High": "#6c9a8b", // Teal-green - high need, low capacity (GAP)
"Moderate × Low": "#c8b2d6",
"Moderate × Moderate": "#9c9eba",
"Moderate × High": "#627a8e",
"High × Low": "#b8a0c2",
"High × Moderate": "#8b7fa8",
"High × High": "#5a5f8f" // Dark purple - OPTIMAL (priority for CIS)
};
}viewof s2HazardType = Inputs.radio(
["drought", "waterlogging"],
{
label: _lang(cis.s2HazardSelector),
value: "drought",
format: d => d === "drought" ? _lang(cis.s2HazardDrought) : _lang(cis.s2HazardWaterlogging)
}
);
viewof s2ViewToggle = Inputs.radio(
["map", "table"],
{
label: _lang(cis.s2ViewToggle),
value: "map",
format: d => d === "map" ? _lang(cis.s2ViewMap) : _lang(cis.s2ViewTable)
}
);s2ProcessedData = {
const hazardColumn = s2HazardType === "drought" ? "drought_ndws" : "waterlogging_ndwl0";
const hazardTypeCode = s2HazardType === "drought" ? "NDWS-mean" : "NDWL0-mean";
return combinedData
.filter(d => d.cis_readiness_index != null && d[hazardColumn] !== null)
.map(d => {
const classified = bivariateClassify(
d.cis_readiness_index,
d[hazardColumn],
hazardTypeCode,
d
);
return {
...d,
hazardValue: d[hazardColumn],
...classified
};
});
}
// Prepare map data at the appropriate admin level
s2MapData = {
const level = currentAdminLevel; //TODO: This filtering has to go / be re-used across notebook
if (level === 0) {
// Group by admin0
const grouped = d3.group(s2ProcessedData, d => d.gaul0_code);
return Array.from(grouped.values()).map(group => {
const first = group[0];
const avgReadiness = d3.mean(group, d => d.cis_readiness_index);
const avgHazard = d3.mean(group, d => d.hazardValue);
const hazardTypeCode = s2HazardType === "drought" ? "NDWS-mean" : "NDWL0-mean";
const classified = bivariateClassify(avgReadiness, avgHazard, hazardTypeCode, 0);
return {
admin0_name: first.admin0_name,
iso3: first.iso3,
gaul0_code: first.gaul0_code,
cis_readiness_index: avgReadiness,
hazardValue: avgHazard,
...classified
};
});
} else if (level === 1) {
// Group by admin1
const grouped = d3.group(s2ProcessedData, d => d.gaul1_code);
return Array.from(grouped.values()).map(group => {
const first = group[0];
const avgReadiness = d3.mean(group, d => d.cis_readiness_index);
const avgHazard = d3.mean(group, d => d.hazardValue);
const hazardTypeCode = s2HazardType === "drought" ? "NDWS-mean" : "NDWL0-mean";
const classified = bivariateClassify(avgReadiness, avgHazard, hazardTypeCode, 1);
return {
admin0_name: first.admin0_name,
admin1_name: first.admin1_name,
iso3: first.iso3,
gaul0_code: first.gaul0_code,
gaul1_code: first.gaul1_code,
cis_readiness_index: avgReadiness,
hazardValue: avgHazard,
...classified
};
});
} else {
// admin2 - use as is
return s2ProcessedData;
}
}
// Load appropriate boundaries based on Section 1's admin level
s2Boundaries = {
if (currentAdminLevel === 0) {
return admin0Boundaries;
} else if (currentAdminLevel === 1) {
return admin1Boundaries;
} else {
return admin2Boundaries;
}
}
// Join boundaries with data
s2GeoData = {
const gaulCodeField = currentAdminLevel === 0 ? 'gaul0_code' :
currentAdminLevel === 1 ? 'gaul1_code' :
'gaul2_code';
// Filter boundaries based on current selection - show all regions, not just those with data
let filteredBoundaries = s2Boundaries; //TODO: Same admin filtering again
if (currentAdminLevel === 2) {
// When showing admin2, filter by admin selection (but keep all regions, not just those with data)
filteredBoundaries = {
...s2Boundaries,
features: s2Boundaries.features.filter(f => {
const matchesAdmin0 = selectedAdmin0.length === 0 || (f.properties.admin0_name && selectedAdmin0.includes(f.properties.admin0_name));
const matchesAdmin1 = selectedAdmin1.length === 0 || (f.properties.admin1_name && selectedAdmin1.includes(f.properties.admin1_name));
return matchesAdmin0 && matchesAdmin1;
})
};
} else if (currentAdminLevel === 1) {
// When showing admin1, filter by admin0 selection
filteredBoundaries = {
...s2Boundaries,
features: s2Boundaries.features.filter(f => {
const matchesAdmin0 = selectedAdmin0.length === 0 || (f.properties.admin0_name && selectedAdmin0.includes(f.properties.admin0_name));
return matchesAdmin0;
})
};
}
const features = filteredBoundaries.features.map(feature => {
const gaulCode = feature.properties[gaulCodeField];
let match = s2MapData.find(d => d[gaulCodeField] === gaulCode);
// Fallback: try matching by admin names if GAUL code fails
if (!match && currentAdminLevel === 2) {
match = s2MapData.find(d =>
d.admin0_name === feature.properties.admin0_name &&
d.admin1_name === feature.properties.admin1_name &&
d.admin2_name === feature.properties.admin2_name
);
} else if (!match && currentAdminLevel === 1) {
match = s2MapData.find(d =>
d.admin0_name === feature.properties.admin0_name &&
d.admin1_name === feature.properties.admin1_name
);
}
// Check if this admin2 region is highlighted
const isHighlighted = currentAdminLevel === 2 &&
highlightedAdmin2.length > 0 &&
highlightedAdmin2.includes(feature.properties.admin2_name);
return {
type: "Feature",
geometry: feature.geometry,
properties: {
...feature.properties,
...(match || {}),
isHighlighted: isHighlighted,
hasData: !!match
}
};
}); // Show all features, fade those without data
return { type: "FeatureCollection", features };
}s2DataIndex = {
const index = new Map();
// Index current level data
s2MapData.forEach(d => {
const admin0 = d.admin0_name;
const admin1 = d.admin1_name || null;
if (!index.has(admin0)) {
index.set(admin0, new Map());
}
index.get(admin0).set(admin1, d);
});
return index;
}
// Render map or table based on toggle
s2BivariateMap = {
if (s2ViewToggle !== "map") return null;
const hazardName = s2HazardType === "drought" ? "Drought (Dry Spell Days)" : "Waterlogging (Days)";
return makeChoropleth({
features: s2GeoData,
width: 775,
height: 610,
projection: "mercator",
fill: d => d.properties.hasData ? d.properties.bivariateClass : "No data",
color: {
type: "categorical",
legend: false,
domain: [
"Low × Low",
"Low × Moderate",
"Low × High",
"Moderate × Low",
"Moderate × Moderate",
"Moderate × High",
"High × Low",
"High × Moderate",
"High × High",
"No data"
],
range: [
bivariateColors["Low × Low"],
bivariateColors["Low × Moderate"],
bivariateColors["Low × High"],
bivariateColors["Moderate × Low"],
bivariateColors["Moderate × Moderate"],
bivariateColors["Moderate × High"],
bivariateColors["High × Low"],
bivariateColors["High × Moderate"],
bivariateColors["High × High"],
"#e8e8e8"
],
label: "Bivariate Class"
},
channels: {
region: {
label: "Region",
value: (d) => currentAdminLevel === 0
? d.properties.admin0_name
: currentAdminLevel === 1
? `${d.properties.admin1_name}, ${d.properties.admin0_name}`
: `${d.properties.admin2_name}, ${d.properties.admin1_name}`
},
readiness: {
label: "Readiness (0-1)",
value: (d) => d.properties.cis_readiness_index != null
? d.properties.cis_readiness_index.toFixed(2)
: "N/A"
},
hazard: {
label: hazardName,
value: (d) => d.properties.hazardValue != null
? d.properties.hazardValue.toFixed(1)
: "N/A"
},
bivariateClass: {
label: "Bivariate class",
value: (d) => d.properties.bivariateClass || "N/A"
}
},
tooltip: {
region: true,
readiness: true,
hazard: true,
bivariateClass: true
},
baseMarkOptions: {
fillOpacity: d => d.properties.hasData ? 1 : 0.3
},
adminHighlight: {
filter: d => d.properties.isHighlighted,
stroke: "#000",
strokeWidth: 2.5
}
});
}
// Render map or table based on toggle
{
if (s2ViewToggle === "map") {
const hazardLegendLabel = s2HazardType === "drought" ? "Drought Severity" : "Waterlogging Severity";
return html`
<div style="display: flex; align-items: center; gap: 18px; flex-wrap: wrap;">
<div style="flex: 0 0 auto; display: flex; justify-content: center;">
${makeBivariateLegend({
colors: bivariateColors,
xLabel: "CIS Readiness",
yLabel: hazardLegendLabel,
width: 220,
height: 210,
fontSize: 14
})}
</div>
<div style="flex: 1 1 640px; min-width: 0;">
${s2BivariateMap}
</div>
</div>
`;
} else {
// Table view
const hazardName = s2HazardType === "drought" ? "Drought (Days)" : "Waterlogging (Days)";
const tableData = s2MapData.map(d => {
const adminName = currentAdminLevel === 0
? d.admin0_name
: currentAdminLevel === 1
? `${d.admin1_name}, ${d.admin0_name}`
: `${d.admin2_name}, ${d.admin1_name}`;
let priorityLevel;
if (d.bivariateClass === "High × High") priorityLevel = "Critical";
else if (d.bivariateClass.includes("High") && d.bivariateClass.includes("Moderate")) priorityLevel = "Moderate";
else if (d.bivariateClass.includes("High")) priorityLevel = "Moderate";
else priorityLevel = "Low";
return {
adminName: adminName,
readinessRaw: d.cis_readiness_index,
readinessClass: d.readinessClass,
hazardRaw: d.hazardValue,
hazardClass: d.hazardClass,
bivariateClass: d.bivariateClass,
priorityLevel: priorityLevel
};
});
return filterableDataTable(tableData, {
columns: [
"adminName",
"readinessRaw",
"readinessClass",
"hazardRaw",
"hazardClass",
"priorityLevel"
],
header: {
adminName: "Admin Name",
readinessRaw: "CIS Readiness",
readinessClass: "Readiness Class",
hazardRaw: hazardName,
hazardClass: "Hazard Class",
priorityLevel: "Priority Level"
},
format: {
readinessRaw: d => d.toFixed(2),
hazardRaw: d => d.toFixed(1)
},
sort: "priorityLevel",
reverse: true,
});
}
}// Download button for Section 2
downloadButton(
s2MapData.map((d) => {
const adminName =
currentAdminLevel === 0
? d.admin0_name
: currentAdminLevel === 1
? `${d.admin1_name}, ${d.admin0_name}`
: `${d.admin2_name}, ${d.admin1_name}`;
const hazardName =
s2HazardType === "drought" ? "Drought_Days" : "Waterlogging_Days";
return {
"Admin Level":
currentAdminLevel === 0 ? "0" : currentAdminLevel === 1 ? "1" : "2",
"Admin Name": adminName,
"CIS Readiness Raw": d.cis_readiness_index?.toFixed(3) || "N/A",
"CIS Readiness Class": d.readinessClass,
[hazardName + " Raw"]: d.hazardValue?.toFixed(1) || "N/A",
[hazardName + " Class"]: d.hazardClass,
"Bivariate Category": d.bivariateClass,
};
}),
`cis_hazard_intersection_${s2HazardType}_admin${currentAdminLevel}`,
_lang(cis.downloadLabel),
);Dynamic Insights
s2Insights = {
const hazardName = s2HazardType === "drought" ? "Drought" : "Waterlogging";
const selectedRegion = breadcrumb; // Use the breadcrumb from Section 1
// If admin2 regions are highlighted, only use those for insights
const insightData = highlightedAdmin2.length > 0
? s2MapData.filter(d => highlightedAdmin2.includes(d.admin2_name))
: s2MapData;
// Insight 1: Opportunity (High × High zones)
const highHazardHighReadiness = insightData.filter(d => d.bivariateClass === "High × High");
const percentHighHigh = insightData.length > 0
? (highHazardHighReadiness.length / insightData.length * 100).toFixed(1)
: 0;
const insight1 = `In ${selectedRegion}, **${percentHighHigh}%** of areas fall in High-Readiness & High-${hazardName} zones, indicating strong potential for CIS as an adaptation measure.`;
// Insight 2: Gap (High hazard with Low readiness)
const highHazardAreas = insightData.filter(d => d.hazardClass === "High");
const highHazardLowReadiness = highHazardAreas.filter(d => d.readinessClass === "Low");
const percentGap = highHazardAreas.length > 0
? (highHazardLowReadiness.length / highHazardAreas.length * 100).toFixed(1)
: 0;
const insight2 = `**${percentGap}%** of High-${hazardName} areas have Low CIS Readiness—priority gaps for station and forecast investment.`;
return html`
<div style="background: #f8f9fa; padding: 20px; border-radius: 8px; margin-top: 20px;">
<h4 style="margin-top: 0;">Key Insights</h4>
<p style="margin: 10px 0;"><strong>Opportunity:</strong> ${md([insight1])}</p>
<p style="margin: 10px 0;"><strong>Investment Gap:</strong> ${md([insight2])}</p>
</div>
`;
}s3AccessDB = await DuckDBClient.of({
access_data: FileAttachment("/data/cis/CIS_access.parquet")
});
// Query access data with SQL-side admin filtering (shared helper from Section 2)
s3AccessDataRaw = {
const adminWhere = sqlAdminQuery(selectedAdmin0, selectedAdmin1);
return await s3AccessDB.query(`
SELECT *
FROM access_data
WHERE ${adminWhere}
`);
}
// Load CIS readiness at the same admin level as the current selection.
s3CisReadiness = {
const adminWhere = sqlAdminQuery(selectedAdmin0, selectedAdmin1);
const rows = await cisDB.query(`
SELECT *
FROM cis_data
WHERE ${adminWhere}
`);
return rows.map((row) => {
return {
admin0_name: row.admin0_name,
admin1_name: row.admin1_name,
admin2_name: row.admin2_name,
iso3: row.iso3,
gaul0_code: row.gaul0_code,
gaul1_code: row.gaul1_code,
gaul2_code: row.gaul2_code,
cis_readiness_index: row.cis_readiness_index,
};
});
}
section3Data = {
return s3AccessDataRaw.map(access => {
// Match CIS readiness at the same admin level as the access row.
const cis = s3CisReadiness.find(a =>
a.admin0_name === access.admin0_name && a.admin1_name === access.admin1_name
);
if (!cis) return null;
// Calculate combined access: average of available indicators
// This provides a single "overall access" metric
const combined_access = (() => {
const values = [access.tv, access.internet, access.cellphone]
.filter(v => v !== null && !isNaN(v));
return values.length > 0
? values.reduce((a, b) => a + b, 0) / values.length
: null;
})();
return {
admin0_name: access.admin0_name,
admin1_name: access.admin1_name,
admin2_name: access.admin2_name,
iso3: access.iso3,
gaul0_code: access.gaul0_code,
gaul1_code: access.gaul1_code,
gaul2_code: access.gaul2_code,
cis_readiness_index: cis.cis_readiness_index,
// Raw access percentages (0-100 scale)
tv_pct: access.tv,
internet_pct: access.internet,
cellphone_pct: access.cellphone,
combined_pct: combined_access,
// Normalized access values (0-1 scale, for comparison with CIS readiness)
tv_norm: access.tv !== null ? access.tv / 100 : null,
internet_norm: access.internet !== null ? access.internet / 100 : null,
cellphone_norm: access.cellphone !== null ? access.cellphone / 100 : null,
combined_norm: combined_access !== null ? combined_access / 100 : null
};
}).filter(d => d !== null);
}// Access Type Selector
viewof s3AccessType = Inputs.radio(
["combined", "internet", "tv", "cellphone"],
{
label: _lang(cis.s3AccessSelector),
value: "combined",
format: x => x === "combined" ? _lang(cis.s3AccessCombined) :
x === "internet" ? _lang(cis.s3AccessInternet) :
x === "tv" ? _lang(cis.s3AccessTV) :
_lang(cis.s3AccessCellphone)
}
)viewof s3ViewType = Inputs.radio(
["map", "dumbbell", "scatter", "table"],
{
label: _lang(cis.s3ViewToggle),
value: "map",
format: x => x === "map" ? _lang(cis.s3ViewMap) :
x === "dumbbell" ? _lang(cis.s3ViewDumbbell) :
x === "scatter" ? _lang(cis.s3ViewScatter) :
_lang(cis.s3ViewTable)
}
)
// Map Type Toggle (Only shown when view type is map)
viewof s3MapType = {
if (s3ViewType !== "map") {
const input = Inputs.radio(["bivariate"], {label: "Map Type", value: "bivariate"});
input.style.display = "none";
return input;
}
return Inputs.radio(["bivariate", "univariate"], {
label: "Map Type",
value: "bivariate",
format: x => x === "bivariate" ? "Bivariate (Readiness × Access)" : "Access Distribution"
});
}// Sort By Selector - only shown when view type is not "map"
viewof s3SortBy = {
if (s3ViewType === "map") {
// Return a hidden selector that maintains state but isn't displayed
const input = Inputs.select(
["readiness", "combined", "internet", "tv", "cellphone"],
{
label: _lang(cis.s3SortBy),
value: "readiness"
}
);
input.style.display = "none";
return input;
}
return Inputs.select(
["readiness", "combined", "internet", "tv", "cellphone"],
{
label: _lang(cis.s3SortBy),
value: "readiness",
format: x => x === "readiness" ? _lang(cis.s3SortReadiness) :
x === "combined" ? _lang(cis.s3AccessCombined) :
x === "internet" ? _lang(cis.s3AccessInternet) :
x === "tv" ? _lang(cis.s3AccessTV) :
_lang(cis.s3AccessCellphone)
}
);
}s3CurrentAccessField =
s3AccessType === "combined"
? "combined_norm"
: s3AccessType === "internet"
? "internet_norm"
: s3AccessType === "tv"
? "tv_norm"
: "cellphone_norm";
// Helper to get current access label
s3CurrentAccessLabel =
s3AccessType === "combined"
? _lang(cis.s3AccessCombined)
: s3AccessType === "internet"
? _lang(cis.s3AccessInternet)
: s3AccessType === "tv"
? _lang(cis.s3AccessTV)
: _lang(cis.s3AccessCellphone);
// Helper to get current access raw field
s3CurrentAccessRawField =
s3AccessType === "combined"
? "combined_pct"
: s3AccessType === "internet"
? "internet_pct"
: s3AccessType === "tv"
? "tv_pct"
: "cellphone_pct";s3AccessCutoffs = {
// Extract values for the currently selected access type
const values = section3Data
.map(d => d[s3CurrentAccessField])
.filter(v => v !== null && !isNaN(v))
.sort((a, b) => a - b);
if (values.length === 0) {
return { low: 0.33, moderate: 0.67 };
}
// Calculate tercile cutoffs (33.3% and 66.7% quantiles)
return {
low: d3.quantile(values, 0.333),
moderate: d3.quantile(values, 0.667)
};
}classifyS3Readiness = (value, rowOrLevel = currentAdminLevel) =>
classifyThreshold("cis_readiness_index", value, rowOrLevel);
function bivariateClassifyAccess(readiness, access, accessCutoffs, rowOrLevel = currentAdminLevel) {
const readinessClass = classifyS3Readiness(readiness, rowOrLevel);
// Classify access level (using dynamic tercile cutoffs)
let accessClass;
if (access === null || isNaN(access)) accessClass = "N/A";
else if (access < accessCutoffs.low) accessClass = "Low";
else if (access < accessCutoffs.moderate) accessClass = "Moderate";
else accessClass = "High";
return {
readinessClass,
accessClass,
bivariateClass: accessClass === "N/A" ? "N/A" : `${readinessClass} × ${accessClass}`
};
}
// Bivariate color palette (reused from Section 2 for consistency)
s3BivariateColors = {
return {
"Low × Low": "#e8e8e8", // Low priority
"Low × Moderate": "#b8d6be",
"Low × High": "#6c9a8b", // High access but low readiness
"Moderate × Low": "#c8b2d6",
"Moderate × Moderate": "#9c9eba",
"Moderate × High": "#627a8e",
"High × Low": "#b8a0c2", // High readiness but low access (capacity gap)
"High × Moderate": "#8b7fa8",
"High × High": "#5a5f8f", // OPTIMAL - scale CIS here
"N/A": "#cccccc" // No access data available
};
}// Process data with bivariate classification
s3ProcessedData = {
return section3Data.map(d => {
const accessValue = d[s3CurrentAccessField];
const classification = bivariateClassifyAccess(
d.cis_readiness_index,
accessValue,
s3AccessCutoffs,
d
);
return {
...d,
accessValue,
...classification,
gap: accessValue !== null && !isNaN(accessValue) ? accessValue - d.cis_readiness_index : null
};
});
}// Sort data based on selected metric
s3SortedData = {
if (!s3ProcessedData || s3ProcessedData.length === 0) {
return [];
}
const data = [...s3ProcessedData];
if (s3SortBy === "readiness") {
return data.sort((a, b) => (b.cis_readiness_index || 0) - (a.cis_readiness_index || 0));
} else if (s3SortBy === "combined") {
return data.sort((a, b) => (b.combined_norm || 0) - (a.combined_norm || 0));
} else if (s3SortBy === "internet") {
return data.sort((a, b) => (b.internet_norm || 0) - (a.internet_norm || 0));
} else if (s3SortBy === "tv") {
return data.sort((a, b) => (b.tv_norm || 0) - (a.tv_norm || 0));
} else if (s3SortBy === "cellphone") {
return data.sort((a, b) => (b.cellphone_norm || 0) - (a.cellphone_norm || 0));
}
return data;
}// Prepare geographic data for map - dynamically show admin levels based on selection
// Use the master admin selectors (selectedAdmin0, etc.) for consistency with other sections
s3GeoData = {
if (!s3ProcessedData || s3ProcessedData.length === 0) {
return { type: "FeatureCollection", features: [] };
}
// Use master admin selections (same as Section 1 and 2)
const selectedCountries = selectedAdmin0 || [];
const selectedAdmin1s = selectedAdmin1 || [];
const selectedAdmin2s = selectedAdmin2 || [];
let boundaries = null;
let levelKey = "admin0_name";
let gaul_key = "gaul0_code";
// Determine which admin level to show based on currentAdminLevel from Section 1
// Level 0: No admin0 selected - show all admin0
// Level 1: Admin0 selected but no admin1 - show admin1 within selected countries
// Level 2: Admin1 selected - show admin2 within selected admin1s
if (currentAdminLevel === 2 && admin2Boundaries) {
boundaries = admin2Boundaries;
levelKey = "admin2_name";
gaul_key = "gaul2_code";
} else if (currentAdminLevel === 1 && admin1Boundaries) {
boundaries = admin1Boundaries;
levelKey = "admin1_name";
gaul_key = "gaul1_code";
} else if (admin0Boundaries) {
boundaries = admin0Boundaries;
levelKey = "admin0_name";
gaul_key = "gaul0_code";
}
if (!boundaries) {
return { type: "FeatureCollection", features: [] };
}
// Filter boundaries based on current selection
let filteredBoundaries = boundaries;
if (currentAdminLevel === 2) {
// Show only admin2 within selected admin1s
filteredBoundaries = {
...boundaries,
features: boundaries.features.filter(f =>
selectedCountries.includes(f.properties.admin0_name) &&
selectedAdmin1s.includes(f.properties.admin1_name)
)
};
} else if (currentAdminLevel === 1) {
// Show only admin1 within selected countries
filteredBoundaries = {
...boundaries,
features: boundaries.features.filter(f =>
selectedCountries.includes(f.properties.admin0_name)
)
};
}
// Level 0: show all countries (no filtering needed)
// Map data to boundaries
const features = filteredBoundaries.features.map(feature => {
// Find the parent admin0 for this feature (data is at country level)
const admin0key = feature.properties.admin0_name;
const admin1key = feature.properties.admin1_name;
const countryData = s3ProcessedData.find(d => d.admin0_name === admin0key && d.admin1_name === admin1key);
if (!countryData) return null;
// Check if this region is highlighted (for admin2 level)
const isHighlighted = currentAdminLevel === 2 &&
selectedAdmin2s.length > 0 &&
selectedAdmin2s.includes(feature.properties.admin2_name);
return {
type: "Feature",
geometry: feature.geometry,
properties: {
...feature.properties,
...countryData,
displayName: feature.properties[levelKey] || feature.properties.admin0_name,
isHighlighted: isHighlighted
}
};
}).filter(f => f !== null);
return {
type: "FeatureCollection",
features
};
}Visualization
function renderS3MapDisplay() {
if (s3ViewType !== "map") return null;
if (s3MapType === "univariate") {
return createS3AccessMap();
} else {
return html`<div style="display: flex; align-items: center; gap: 18px; flex-wrap: wrap;">
<div style="flex: 0 0 auto; display: flex; justify-content: center;">
${makeBivariateLegend({
colors: s3BivariateColors,
xLabel: "CIS Readiness",
yLabel:
s3AccessType === "combined"
? "Combined Access"
: s3CurrentAccessLabel,
width: 220,
height: 210,
fontSize: 14,
})}
</div>
<div style="flex: 1 1 640px; min-width: 0;">
${createS3BivariateMap()}
</div>
</div>`;
}
}
// Access Map (Univariate)
function createS3AccessMap() {
return makeChoropleth({
features: s3GeoData,
width: 775,
height: 610,
projection: "mercator",
fill: (d) => d.properties.accessValue,
color: {
type: "linear",
domain: [0, 1],
scheme: "Blues",
label: s3CurrentAccessLabel,
},
channels: {
region: {
label:
currentAdminLevel === 2
? "District"
: currentAdminLevel === 1
? "Region"
: "Country",
value: (d) =>
d.properties.displayName || d.properties.admin0_name || "N/A",
},
access: {
label: s3CurrentAccessLabel,
value: (d) =>
d.properties.accessValue != null
? `${(d.properties.accessValue * 100).toFixed(1)}%`
: "N/A",
},
},
tooltip: {
region: true,
access: true,
},
adminHighlight: {
filter: (d) => d.properties.isHighlighted,
stroke: "#000",
strokeWidth: 2.5,
},
});
}
// Section 3 bivariate map with diamond legend (matching Section 2 style)
function createS3BivariateMap() {
if (s3ViewType !== "map") return null;
return makeChoropleth({
features: s3GeoData,
width: 775,
height: 610,
fill: (d) => d.properties.bivariateClass,
color: {
type: "categorical",
legend: false,
domain: [
"Low × Low",
"Low × Moderate",
"Low × High",
"Moderate × Low",
"Moderate × Moderate",
"Moderate × High",
"High × Low",
"High × Moderate",
"High × High",
],
range: [
s3BivariateColors["Low × Low"],
s3BivariateColors["Low × Moderate"],
s3BivariateColors["Low × High"],
s3BivariateColors["Moderate × Low"],
s3BivariateColors["Moderate × Moderate"],
s3BivariateColors["Moderate × High"],
s3BivariateColors["High × Low"],
s3BivariateColors["High × Moderate"],
s3BivariateColors["High × High"],
],
label: "Bivariate Class",
},
channels: {
region: {
label:
currentAdminLevel === 2
? "District"
: currentAdminLevel === 1
? "Region"
: "Country",
value: (d) =>
d.properties.displayName || d.properties.admin0_name || "N/A",
},
readiness: {
label: _lang(cis.s3ReadinessLabel),
value: (d) =>
d.properties.cis_readiness_index != null
? d.properties.cis_readiness_index.toFixed(2)
: "N/A",
},
readinessClass: {
label: "Readiness Class",
value: (d) => d.properties.readinessClass || "N/A",
},
access: {
label: s3CurrentAccessLabel,
value: (d) =>
d.properties.accessValue != null
? `${(d.properties.accessValue * 100).toFixed(1)}%`
: "N/A",
},
accessClass: {
label: "Access Class",
value: (d) => d.properties.accessClass || "N/A",
},
category: {
label: "Category",
value: (d) => d.properties.bivariateClass || "N/A",
},
},
tooltip: {
region: true,
readiness: true,
readinessClass: true,
access: true,
accessClass: true,
category: true,
},
adminHighlight: {
filter: (d) => d.properties.isHighlighted,
stroke: "#000",
strokeWidth: 2.5,
},
});
}function renderS3DumbbellLegend() {
const items = [
{symbol: "●", color: "#377eb8", label: _lang(cis.s3ReadinessLabel)},
{symbol: "●", color: "#ff7f00", label: s3CurrentAccessLabel},
{symbol: "━", color: "#4daf4a", label: _lang(cis.s3PositiveGap)},
{symbol: "━", color: "#e41a1c", label: _lang(cis.s3NegativeGap)},
{symbol: "━", color: "#999999", label: "Similar values"}
];
return html`<div style="margin-bottom: 10px; padding: 10px; background: #f8f9fa; border-radius: 5px;">
<div style="display: flex; gap: 20px; align-items: center; font-size: 12px;">
${items.map(({symbol, color, label}) => html`
<div><span style="color: ${color};">${symbol}</span> ${label}</div>
`)}
</div>
</div>`;
}
function makeS3DumbbellTip() {
return {
fontSize: 14,
channels: {
country: { label: "Country", value: d => d.admin0_name },
readiness: { label: _lang(cis.s3ReadinessLabel), value: d => d.cis_readiness_index },
access: { label: s3CurrentAccessLabel, value: d => d.accessValue },
gap: { label: _lang(cis.s3GapLabel), value: d => d.gap }
},
format: {
country: true,
x: false,
y: false,
readiness: d => d != null ? d.toFixed(3) : "N/A",
access: d => d != null ? `${(d * 100).toFixed(1)}%` : "N/A",
gap: (d) => {
if (d == null || isNaN(d)) return "N/A";
const label = d > 0 ? _lang(cis.s3PositiveGap) : _lang(cis.s3NegativeGap)
return `${d.toFixed(3)} (${label})`;
}
}
};
}
function renderS3DumbbellPlot(data) {
const chartHeight = Math.max(600, data.length * 25);
const dataWithAccess = data.filter(d => d.accessValue !== null && !isNaN(d.accessValue));
const xDomain = d3.extent([
...data.map(d => d.cis_readiness_index || 0),
...data.map(d => d.accessValue || 0)
]);
const tip = makeS3DumbbellTip();
return Plot.plot({
width: 1200,
height: chartHeight,
marginLeft: 150,
marginRight: 50,
marginTop: 80,
marginBottom: 80,
x: {
domain: xDomain,
label: "Value",
grid: true,
axis: "top"
},
y: {
label: null,
domain: data.map(d => d.admin0_name)
},
marks: [
Plot.axisX({anchor: "bottom", label: "Value"}),
Plot.link(dataWithAccess, {
x1: "cis_readiness_index",
x2: "accessValue",
y1: "admin0_name",
y2: "admin0_name",
stroke: (d) => {
if (d.gap == null || isNaN(d.gap)) return "#999999";
return d.gap > 0.05 ? "#4daf4a" : d.gap < -0.05 ? "#e41a1c" : "#999999";
},
strokeWidth: 2
}),
Plot.dot(data, {
x: "cis_readiness_index",
y: "admin0_name",
fill: "#377eb8",
r: 5,
tip: {
...tip,
format: {
...tip.format,
x: d => `Readiness: ${d != null ? d.toFixed(3) : "N/A"}`
}
}
}),
Plot.dot(dataWithAccess, {
x: "accessValue",
y: "admin0_name",
fill: "#ff7f00",
r: 5,
stroke: "#ff7f00",
strokeWidth: 2,
tip
})
]
});
}
function renderS3DumbbellView() {
return html`<div style="width: 100%; overflow-x: auto;">
${renderS3DumbbellLegend()}
${renderS3DumbbellPlot(s3SortedData)}
${renderS3DumbbellLegend()}
<div style="margin-top: 10px;">
${s3DownloadButton}
</div>
</div>`;
}
function getS3ScatterFacetData() {
const facetData = [];
s3ProcessedData.forEach(d => {
if (d.combined_norm != null && !isNaN(d.combined_norm)) {
facetData.push({
...d,
accessType: _lang(cis.s3AccessCombined),
accessValue: d.combined_norm,
accessKey: "combined"
});
}
if (d.internet_norm != null && !isNaN(d.internet_norm)) {
facetData.push({
...d,
accessType: _lang(cis.s3AccessInternet),
accessValue: d.internet_norm,
accessKey: "internet"
});
}
if (d.tv_norm != null && !isNaN(d.tv_norm)) {
facetData.push({
...d,
accessType: _lang(cis.s3AccessTV),
accessValue: d.tv_norm,
accessKey: "tv"
});
}
if (d.cellphone_norm != null && !isNaN(d.cellphone_norm)) {
facetData.push({
...d,
accessType: _lang(cis.s3AccessCellphone),
accessValue: d.cellphone_norm,
accessKey: "cellphone"
});
}
});
return facetData;
}
function getS3ScatterAccessTypes() {
return [
_lang(cis.s3AccessCombined),
_lang(cis.s3AccessInternet),
_lang(cis.s3AccessTV),
_lang(cis.s3AccessCellphone)
];
}
function getS3ScatterFacetLayout(accessTypes) {
const columnCount = 2;
const accessIndex = new Map(accessTypes.map((key, i) => [key, i]));
return {
getFx: key => accessIndex.get(key) % columnCount,
getFy: key => Math.floor(accessIndex.get(key) / columnCount)
};
}
function getS3ScatterPointColor(d) {
const readinessClass = classifyS3Readiness(d.cis_readiness_index, d);
const accessClass =
d.accessValue >= s3AccessCutoffs.moderate
? "High"
: d.accessValue >= s3AccessCutoffs.low
? "Moderate"
: "Low";
return s3BivariateColors[`${readinessClass} × ${accessClass}`] || "#cccccc";
}
function renderS3ScatterNote() {
return html`<div style="margin-bottom: 15px; padding: 10px; background: #f8f9fa; border-radius: 5px;">
<div style="font-size: 12px;">
<strong>${_lang(cis.s3ParityLine)}:</strong> Points above the line indicate access exceeds readiness; points below indicate readiness exceeds access.
</div>
</div>`;
}
function renderS3ScatterPlot(facetData) {
const accessTypes = getS3ScatterAccessTypes();
const {getFx, getFy} = getS3ScatterFacetLayout(accessTypes);
const xExtent = d3.extent(facetData, d => d.cis_readiness_index);
const xDomain = (xExtent[0] == null || xExtent[1] == null) ? [0, 1] : xExtent;
return Plot.plot({
width: 900,
height: 700,
marginTop: 40,
marginRight: 40,
marginBottom: 60,
marginLeft: 60,
grid: true,
x: {
domain: xDomain,
label: `${_lang(cis.s3ReadinessLabel)} →`
},
y: {
label: "Access →"
},
fx: {
label: null,
padding: 0.08,
tickFormat: null
},
fy: {
label: null,
padding: 0.08,
tickFormat: null
},
marks: [
...accessTypes.map(accessType =>
Plot.line([[0, 0], [1, 1]], {
stroke: "#999",
strokeWidth: 1.5,
strokeDasharray: "4,4",
fx: getFx(accessType),
fy: getFy(accessType)
})
),
Plot.text(accessTypes, {
fx: d => getFx(d),
fy: d => getFy(d),
text: d => d,
frameAnchor: "top-left",
dx: 6,
dy: 6,
fontWeight: "bold",
fontSize: 11
}),
Plot.frame(),
Plot.dot(facetData, {
x: "cis_readiness_index",
y: "accessValue",
fx: d => getFx(d.accessType),
fy: d => getFy(d.accessType),
fill: getS3ScatterPointColor,
stroke: "#333",
strokeWidth: 0.5,
r: 4
}),
Plot.tip(
facetData,
Plot.pointer({
fontSize: 14,
x: "cis_readiness_index",
y: "accessValue",
fx: d => getFx(d.accessType),
fy: d => getFy(d.accessType),
channels: {
country: {
label: "Country",
value: d => d.admin0_name
},
readiness: {
label: _lang(cis.s3ReadinessLabel),
value: d => d.cis_readiness_index
},
access: {
label: "Access",
value: d => d.accessValue
}
},
format: {
country: true,
readiness: d => d != null ? d.toFixed(3) : "N/A",
access: d => d != null ? `${(d * 100).toFixed(1)}%` : "N/A",
fx: false,
fy: false
}
})
)
]
});
}
function renderS3ScatterView() {
const facetData = getS3ScatterFacetData();
return html`<div style="width: 100%;">
<div style="overflow-x: auto;">
${renderS3ScatterNote()}
${renderS3ScatterPlot(facetData)}
</div>
<div style="margin-top: 10px;">${s3DownloadButton}</div>
</div>`;
}
function renderS3TableView() {
return filterableDataTable(s3SortedData, {
columns: [
"admin0_name",
"iso3",
"cis_readiness_index",
"readinessClass",
"tv_pct",
"tv_norm",
"internet_pct",
"internet_norm",
"cellphone_pct",
"cellphone_norm",
"combined_pct",
"combined_norm",
"gap"
],
header: {
admin0_name: "Country",
iso3: "ISO3",
cis_readiness_index: "CIS Readiness (Raw)",
readinessClass: "CIS Class",
tv_pct: "TV Access (%)",
tv_norm: "TV",
internet_pct: "Internet (%)",
internet_norm: "Internet",
cellphone_pct: "Cellphone (%)",
cellphone_norm: "Cellphone",
combined_pct: "Combined (%)",
combined_norm: "Combined",
gap: "Gap"
},
format: {
cis_readiness_index: d => d?.toFixed(3) || "N/A",
tv_pct: d => d !== null ? d.toFixed(1) : "N/A",
tv_norm: d => d !== null ? d.toFixed(3) : "N/A",
internet_pct: d => d !== null ? d.toFixed(1) : "N/A",
internet_norm: d => d !== null ? d.toFixed(3) : "N/A",
cellphone_pct: d => d !== null ? d.toFixed(1) : "N/A",
cellphone_norm: d => d !== null ? d.toFixed(3) : "N/A",
combined_pct: d => d !== null ? d.toFixed(1) : "N/A",
combined_norm: d => d !== null ? d.toFixed(3) : "N/A",
gap: d => d !== null ? d.toFixed(3) : "N/A"
},
width: {
admin0_name: 150,
iso3: 60
}
});
}
// Render based on view type
{
if (s3ViewType === "map") {
return html`<div style="width: 100%; overflow-x: auto;">
${renderS3MapDisplay()}
</div>`;
} else if (s3ViewType === "dumbbell") {
return renderS3DumbbellView();
} else if (s3ViewType === "scatter") {
return renderS3ScatterView();
}
return renderS3TableView();
}// Define Download button separately to be used in multiple places
s3DownloadButton = {
// Helper function to safely format numbers
const safeFormat = (value, decimals) => {
if (value == null || isNaN(value)) return "N/A";
return value.toFixed(decimals);
};
return downloadButton(
s3SortedData.map(d => ({
"Country": d.admin0_name || "N/A",
"ISO3": d.iso3 || "N/A",
"CIS Readiness Raw": safeFormat(d.cis_readiness_index, 3),
"CIS Readiness Class": d.readinessClass || "N/A",
"TV Access %": safeFormat(d.tv_pct, 1),
"TV Access": safeFormat(d.tv_norm, 3),
"Internet Access %": safeFormat(d.internet_pct, 1),
"Internet Access": safeFormat(d.internet_norm, 3),
"Cellphone Access %": safeFormat(d.cellphone_pct, 1),
"Cellphone Access": safeFormat(d.cellphone_norm, 3),
"Combined Access %": safeFormat(d.combined_pct, 1),
"Combined Access": safeFormat(d.combined_norm, 3),
"Gap (Access - Readiness)": safeFormat(d.gap, 3),
"Bivariate Category": d.bivariateClass || "N/A"
})),
`cis_implementation_${s3AccessType}_access`,
_lang(cis.downloadLabel)
);
}Dynamic Insights
s3Insights = {
const avgReadiness = d3.mean(section3Data, d => d.cis_readiness_index);
const avgAccess = d3.mean(
section3Data.filter(d => d[s3CurrentAccessField] !== null && !isNaN(d[s3CurrentAccessField])),
d => d[s3CurrentAccessField]
);
// Find countries with high readiness but low access (capacity-access gap)
const capacityGaps = s3ProcessedData.filter(d =>
d.cis_readiness_index > 0.6 && d.accessValue !== null && !isNaN(d.accessValue) && d.accessValue < 0.4
);
// Find optimal zones (high readiness + high access)
const optimalZones = s3ProcessedData.filter(d =>
d.bivariateClass === "High × High"
);
// Use breadcrumb from master admin selectors for consistent region naming
// breadcrumb is defined in _cis_readiness_index.qmd and reflects the current admin selection
const selectedRegion = breadcrumb || "Sub-Saharan Africa";
const insight1 = `In ${selectedRegion}, average CIS Readiness is **${avgReadiness.toFixed(2)}** while ${s3CurrentAccessLabel} averages **${(avgAccess * 100).toFixed(1)}%**.`;
const insight2 = capacityGaps.length > 0
? `Countries such as **${capacityGaps.slice(0, 2).map(d => d.admin0_name).join(' and ')}** show strong technical capacity but limited digital reach, highlighting the need for investment in ICT and communication channels to deliver CIS to users.`
: 'Most countries show balanced capacity and access.';
const insight3 = optimalZones.length > 0
? `**${optimalZones.length} countries** (${(optimalZones.length / s3ProcessedData.length * 100).toFixed(1)}%) fall in the optimal zone with both high CIS readiness and high ${s3CurrentAccessLabel.toLowerCase()}, representing prime opportunities for scaling climate services.`
: `No countries currently fall in the optimal zone for ${s3CurrentAccessLabel.toLowerCase()}.`;
return html`
<div style="background: #f8f9fa; padding: 20px; border-radius: 8px; margin-top: 20px;">
<h4 style="margin-top: 0;">Key Insights</h4>
<p style="margin: 10px 0;">${md([insight1])}</p>
<p style="margin: 10px 0;"><strong>${_lang(cis.s3CapacityGap)}:</strong> ${md([insight2])}</p>
<p style="margin: 10px 0;"><strong>Optimal Zones:</strong> ${md([insight3])}</p>
</div>
`;
}Lead Developer: Johnson Mwakazi
Source code
function NavbarLangSelector(language_obj, masterLanguage) {
let navEnd = document.querySelector(".navbar-nav.ms-auto .nav-item.compact");
if (navEnd) {
let existingLangSelector = document.getElementById("nav-lang-selector");
if (!existingLangSelector) {
let lang_sel = Inputs.bind(
Inputs.radio(language_obj, {
label: "",
format: (d) => d.label
}),
viewof masterLanguage
);
lang_sel.id = "nav-lang-selector";
// Hack the css together for the observable inputs
lang_sel.style.display = "flex";
lang_sel.style.alignItems = "center";
lang_sel.style.marginLeft = "10px";
let lang_div = lang_sel.querySelector("div");
lang_div.style.display = "flex";
lang_div.style.flexDirection = "column";
// Insert the new item after the GitHub icon and other elements
navEnd.parentNode.appendChild(lang_sel);
}
}
}
NavbarLangSelector(languages, masterLanguage)