import { lang as Lang } from "/helpers/lang.js";
import { atlasHero, downloadButton } from "/helpers/uiComponents.ojs";
import { patchWindowsCache } from "/helpers/data.js";
import { atlasTOC } from "/helpers/toc.ojs";
import { renderGeoMap } from "/helpers/figures.ojs";
// Data imports
general_translations = await FileAttachment(
"/data/shared/generalTranslations.json",
).json();
vulnerability_translations = await FileAttachment(
"/data/vulnerability_notebook/translations.json",
).json();mapWidth = typeof width !== 'undefined' ? Math.min(width, 625) : 625
mapHeight = 600
// Mutable state for cross-section reactivity - CENTRALIZED ADMIN SELECTIONS
mutable sharedAdmin0 = null
mutable sharedAdmin1 = null
mutable sharedAdmin2 = null
/**
* Update all shared admin selections synchronously across all sections
*
* @param {string|null} admin0 - Admin0 selection value
* @param {string|null} admin1 - Admin1 selection value
* @param {string|null} admin2 - Admin2 selection value
*/
function updateSharedAdminSelections(admin0, admin1, admin2) {
mutable sharedAdmin0 = admin0;
mutable sharedAdmin1 = admin1;
mutable sharedAdmin2 = admin2;
}languages = [
{ key: "en", label: "English", locale: 'en-US' },
{ key: "fr", label: "Français", locale: 'fr-FR' }
]
defaultLangKey = {
const name = "lang";
const list = languages.map((d) => d.key);
const defaultKey = "en";
const queryParam = await Lang.getParamFromList({ name, list });
return queryParam ?? defaultKey;
}
_lang = Lang.lg(masterLanguage.key)
globalSelection = {
return {
label: _lang({en: "Sub-Saharan Africa", fr: "Afrique subsaharienne"}),
labelGeneral: _lang({en: "Africa", fr: "Afrique"}),
}
}adminRegions = {
return {
labels: {
admin0: _lang(vulnerability_translations.country),
admin1: _lang(vulnerability_translations.region),
admin2: _lang(vulnerability_translations.subregion)
}
};
}
// Get current admin selections as reactive object - now using shared state
adminSelections = ({
selectAdmin0: sharedAdmin0,
selectAdmin1: sharedAdmin1,
selectAdmin2: sharedAdmin2
})mutable dataCache = new Map()
/**
* Load data with caching to avoid redundant loads
*
* @param {string} key - Unique cache key for this data
* @param {Function} loader - Async function that returns the data to cache
* @returns {Promise<*>} Cached data if available, otherwise loaded data
*/
async function loadDataWithCache(key, loader) {
if (dataCache.has(key)) {
return dataCache.get(key);
}
try {
const startTime = performance.now();
const data = await loader();
const loadTime = performance.now() - startTime;
dataCache.set(key, data);
return data;
} catch (error) {
console.error(`Error loading ${key}:`, error);
return null;
}
}
/**
* Create DuckDB connection with exposure data parquet file loaded as a view
* Uses caching to avoid redundant connections
*
* @returns {Promise<Object|null>} DuckDB client with exposure_data view, or null on failure
*/
async function createExposureDB() {
return loadDataWithCache('exposureDB', async () => {
try {
const parquetUrl = await FileAttachment(
"/data/vulnerability_notebook/notebook_exposure.parquet",
).url();
const db = await DuckDBClient.of();
await db.query(`
CREATE OR REPLACE VIEW exposure_data AS
SELECT * FROM read_parquet('${parquetUrl}')
`);
return db;
} catch (error) {
console.error("Failed to create DuckDB connection for exposure data:", error);
return null;
}
});
}dataAdmin0 = {
const data = boundaries.admin0.features.map(d => d.properties)
// add a blank value and sort alphabetically
return [null, ...data.map(d => d.admin0_name)].map(d => {
return {label: d == null ? globalSelection.label : d, value: d}
}).sort((a, b) => {
// Keep null/blank value at the top
if (a.value === null) return -1;
if (b.value === null) return 1;
// Sort others alphabetically by label
return a.label.localeCompare(b.label);
})
}
dataAdmin1 = {
// admin 1, filter by 0 - using shared state
let data;
if (sharedAdmin0 && sharedAdmin0 !== "SSA") {
// Filter by selected country
data = boundaries.admin1.features.map(d => d.properties)
.filter(d => d.admin0_name == sharedAdmin0);
} else {
// If no country selected or SSA selected, show empty (just null option)
data = [];
}
// add blank value and sort alphabetically
return [null, ...data.map(d => d.admin1_name)].map(d => {
return {label: d, value: d}
}).sort((a, b) => {
// Keep null/blank value at the top
if (a.value === null) return -1;
if (b.value === null) return 1;
// Sort others alphabetically by label
return a.label.localeCompare(b.label);
})
}
dataAdmin2 = {
let data;
if (sharedAdmin0 && sharedAdmin0 !== "SSA" && sharedAdmin1) {
// Filter by selected country and region
data = boundaries.admin2.features.map(d => d.properties)
.filter(d => d.admin0_name == sharedAdmin0 && d.admin1_name == sharedAdmin1);
} else {
// If no country or region selected, show empty (just null option)
data = [];
}
// add blank value and sort alphabetically
return [null, ...data.map(d => d.admin2_name)].map(d => {
return {label: d, value: d}
}).sort((a, b) => {
// Keep null/blank value at the top
if (a.value === null) return -1;
if (b.value === null) return 1;
// Sort others alphabetically by label
return a.label.localeCompare(b.label);
})
}// Bidirectional synchronization: Update shared state when ANY section's selectors change
// This reactive cell monitors all section admin selectors and updates shared state when any change
// The underscore prefix ensures this cell doesn't display output in the notebook
// Updated to handle cascading: when admin0 changes, admin1 and admin2 reset; when admin1 changes, admin2 resets
_adminUpdate = {
// Check exposure section selectors first (has priority)
if (selectAdmin0?.value !== sharedAdmin0) {
// Admin0 changed - reset admin1 and admin2
updateSharedAdminSelections(selectAdmin0?.value || null, null, null);
} else if (selectAdmin1?.value !== sharedAdmin1) {
// Admin1 changed - reset admin2
updateSharedAdminSelections(sharedAdmin0, selectAdmin1?.value || null, null);
} else if (selectAdmin2?.value !== sharedAdmin2) {
// Admin2 changed
updateSharedAdminSelections(sharedAdmin0, sharedAdmin1, selectAdmin2?.value || null);
}
// Check sensitivity section selectors
else if (sensitivityAdmin0?.value !== sharedAdmin0) {
updateSharedAdminSelections(sensitivityAdmin0?.value || null, null, null);
} else if (sensitivityAdmin1?.value !== sharedAdmin1) {
updateSharedAdminSelections(sharedAdmin0, sensitivityAdmin1?.value || null, null);
} else if (sensitivityAdmin2?.value !== sharedAdmin2) {
updateSharedAdminSelections(sharedAdmin0, sharedAdmin1, sensitivityAdmin2?.value || null);
}
// Check adaptive capacity section selectors
else if (adaptiveCapacityAdmin0?.value !== sharedAdmin0) {
updateSharedAdminSelections(adaptiveCapacityAdmin0?.value || null, null, null);
} else if (adaptiveCapacityAdmin1?.value !== sharedAdmin1) {
updateSharedAdminSelections(sharedAdmin0, adaptiveCapacityAdmin1?.value || null, null);
} else if (adaptiveCapacityAdmin2?.value !== sharedAdmin2) {
updateSharedAdminSelections(sharedAdmin0, sharedAdmin1, adaptiveCapacityAdmin2?.value || null);
}
// Check composite section selectors
else if (compositeAdmin0?.value !== sharedAdmin0) {
updateSharedAdminSelections(compositeAdmin0?.value || null, null, null);
} else if (compositeAdmin1?.value !== sharedAdmin1) {
updateSharedAdminSelections(sharedAdmin0, compositeAdmin1?.value || null, null);
} else if (compositeAdmin2?.value !== sharedAdmin2) {
updateSharedAdminSelections(sharedAdmin0, sharedAdmin1, compositeAdmin2?.value || null);
}
}sensitivityDataAdmin0 = {
const data = boundaries.admin0.features.map(d => d.properties)
// add a blank value and sort alphabetically
return [null, ...data.map(d => d.admin0_name)].map(d => {
return {label: d == null ? globalSelection.label : d, value: d || "SSA"}
}).sort((a, b) => {
// Keep null/blank value at the top
if (a.value === "SSA") return -1;
if (b.value === "SSA") return 1;
// Sort others alphabetically by label
return a.label.localeCompare(b.label);
})
}
sensitivityDataAdmin1 = {
// admin 1, filter by 0
const data = boundaries.admin1.features.map(d => d.properties)
.filter(d => d.admin0_name == sensitivityAdmin0?.value)
// add blank value and sort alphabetically
return [null, ...data.map(d => d.admin1_name)].map(d => {
return {label: d, value: d}
}).sort((a, b) => {
// Keep null/blank value at the top
if (a.value === null) return -1;
if (b.value === null) return 1;
// Sort others alphabetically by label
return a.label.localeCompare(b.label);
})
}
sensitivityDataAdmin2 = {
const data = boundaries.admin2.features.map(d => d.properties)
.filter(d => d.admin0_name == sensitivityAdmin0?.value && d.admin1_name == sensitivityAdmin1?.value)
// add blank value and sort alphabetically
return [null, ...data.map(d => d.admin2_name)].map(d => {
return {label: d, value: d}
}).sort((a, b) => {
// Keep null/blank value at the top
if (a.value === null) return -1;
if (b.value === null) return 1;
// Sort others alphabetically by label
return a.label.localeCompare(b.label);
})
}compositeDataAdmin0 = {
const data = boundaries.admin0.features.map(d => d.properties)
// add a blank value and sort alphabetically
return [null, ...data.map(d => d.admin0_name)].map(d => {
return {label: d == null ? globalSelection.label : d, value: d || "SSA"}
}).sort((a, b) => {
// Keep null/blank value at the top
if (a.value === "SSA") return -1;
if (b.value === "SSA") return 1;
// Sort others alphabetically by label
return a.label.localeCompare(b.label);
})
}
compositeDataAdmin1 = {
// admin 1, filter by 0
const data = boundaries.admin1.features.map(d => d.properties)
// Note: compositeAdmin0 will be available when this function is called from composite section
// add blank value and sort alphabetically
return [null, ...data.map(d => d.admin1_name)].map(d => {
return {label: d, value: d}
}).sort((a, b) => {
// Keep null/blank value at the top
if (a.value === null) return -1;
if (b.value === null) return 1;
// Sort others alphabetically by label
return a.label.localeCompare(b.label);
})
}
compositeDataAdmin2 = {
const data = boundaries.admin2.features.map(d => d.properties)
// Note: compositeAdmin0 and compositeAdmin1 will be available when this function is called from composite section
// add blank value and sort alphabetically
return [null, ...data.map(d => d.admin2_name)].map(d => {
return {label: d, value: d}
}).sort((a, b) => {
// Keep null/blank value at the top
if (a.value === null) return -1;
if (b.value === null) return 1;
// Sort others alphabetically by label
return a.label.localeCompare(b.label);
})
}adaptiveCapacityDataAdmin0 = {
const data = boundaries.admin0.features.map(d => d.properties)
// add a blank value and sort alphabetically
return [null, ...data.map(d => d.admin0_name)].map(d => {
return {label: d == null ? globalSelection.label : d, value: d || "SSA"}
}).sort((a, b) => {
// Keep null/blank value at the top
if (a.value === "SSA") return -1;
if (b.value === "SSA") return 1;
// Sort others alphabetically by label
return a.label.localeCompare(b.label);
})
}
adaptiveCapacityDataAdmin1 = {
// admin 1, filter by 0
const data = boundaries.admin1.features.map(d => d.properties)
.filter(d => d.admin0_name == adaptiveCapacityAdmin0?.value)
// add blank value and sort alphabetically
return [null, ...data.map(d => d.admin1_name)].map(d => {
return {label: d, value: d}
}).sort((a, b) => {
// Keep null/blank value at the top
if (a.value === null) return -1;
if (b.value === null) return 1;
// Sort others alphabetically by label
return a.label.localeCompare(b.label);
})
}
adaptiveCapacityDataAdmin2 = {
const data = boundaries.admin2.features.map(d => d.properties)
.filter(d => d.admin0_name == adaptiveCapacityAdmin0?.value && d.admin1_name == adaptiveCapacityAdmin1?.value)
// add blank value and sort alphabetically
return [null, ...data.map(d => d.admin2_name)].map(d => {
return {label: d, value: d}
}).sort((a, b) => {
// Keep null/blank value at the top
if (a.value === null) return -1;
if (b.value === null) return 1;
// Sort others alphabetically by label
return a.label.localeCompare(b.label);
})
}function bindTabularToGeo({
data = [],
dataBindColumn = "dataBindColumn",
geoData = [],
geoDataBindColumn = "geoDataBindColumn",
}) {
const index = new Map(data.map((d) => [d[dataBindColumn], d]));
const geojson = JSON.parse(JSON.stringify(geoData));
for (const f of geojson.features) {
f.properties.data = index.get(f.properties[geoDataBindColumn]);
}
return geojson;
}
/**
* Get the most granular admin selection from admin level selectors
*
* @param {Object} admin0 - Admin0 selector object with .value property
* @param {Object} admin1 - Admin1 selector object with .value property
* @param {Object} admin2 - Admin2 selector object with .value property
* @param {string} globalLabel - Default label when no selection is made (default: "Sub-Saharan Africa")
* @returns {string} The most specific admin level selected, or globalLabel if none selected
*
* @example
* getAdminSelection(selectAdmin0, selectAdmin1, selectAdmin2)
*/
function getAdminSelection(
admin0,
admin1,
admin2,
globalLabel = "Sub-Saharan Africa",
) {
const a0 = admin0?.value;
const a1 = admin1?.value;
const a2 = admin2?.value;
return a2 ? a2 : a1 ? a1 : a0 ? a0 : globalLabel;
}
// Convenience wrappers for backward compatibility
function getExposureAdminSelection() {
return getAdminSelection(selectAdmin0, selectAdmin1, selectAdmin2);
}
function getSensitivityAdminSelection() {
return getAdminSelection(
sensitivityAdmin0,
sensitivityAdmin1,
sensitivityAdmin2,
);
}// Color scales and themes
/**
* Color scale definitions for visualizations
*/
colorScales = {
return {
range: {
green: ['#E4F5D0', '#015023'],
blue: ['#E8F2FF', '#003E6B'],
brown: ['#FFFDE5', '#A87B00'],
yellowGreen: ['#216729', '#F7D732'], // Reversed: green (low/good) to yellow (high/bad)
orangeRed: ['#F4BB21', '#EC5A47'],
redOrange: ['#FEE0D2', '#CB4335'],
vulnerability: ['#2E8B57', '#FFD700', '#FF6347'], // Low, Medium, High
adaptive: ['#FF6B6B', '#4ECDC4', '#45B7D1'] // Poor, Fair, Good
},
unknown: "#f0f0f0",
noData: "#f0f0f0"
}
}function createLoadingState(message = null) {
return htl.html`
<div style="
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
color: #6b7280;
font-style: italic;
">
<div style="margin-right: 12px;">
<div class="spinner" style="
width: 20px;
height: 20px;
border: 2px solid #e5e7eb;
border-top: 2px solid #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
"></div>
</div>
${message || _lang(vulnerability_translations.loading)}
</div>
<style>
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
`;
}
/**
* Create a "no data available" state UI component
*
* @param {string|null} message - Optional message (defaults to translation)
* @returns {HTMLElement} HTML element with no-data display
*/
function createNoDataState(message = null) {
return htl.html`
<div style="
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
background: #f9fafb;
border: 2px dashed #d1d5db;
border-radius: 8px;
color: #6b7280;
text-align: center;
margin-top: 16px;
">
<div>
<div style="margin-bottom: 8px; font-size: 18px;">📊</div>
<div>${message || _lang(vulnerability_translations.no_data_available)}</div>
</div>
</div>
`;
}function generateInsight(template, replacements) {
return Lang.reduceReplaceTemplateItems(template, replacements);
}
/**
* Create a styled insight display component from text or HTML
* Handles both plain text (with paragraph splitting) and HTML elements
*
* @param {string|HTMLElement} insight - Insight text or HTML element to display
* @returns {HTMLElement} Styled HTML element with insight content
*/
function createInsightDisplay(insight) {
// Handle case where insight is already an HTML element (from htl.html)
if (typeof insight !== "string") {
// If it's already an HTML element, just wrap it in the standard display
return htl.html`
<div style="
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
border-radius: 10px;
margin: 16px 0;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
">
<h4 style="margin: 0 0 12px 0; font-size: 16px; font-weight: 600;">
${_lang(vulnerability_translations.quick_insights)}
</h4>
<div style="margin: 0; font-size: 18px; line-height: 1.5; opacity: 0.95;">
${insight}
</div>
</div>
`;
}
// Split insight into paragraphs based on double newlines and process each
const paragraphs = insight.split("\n\n").filter((p) => p.trim().length > 0);
// Create HTML elements using htl.html for proper rendering
const paragraphElements = paragraphs
.map((paragraph) => {
// Handle special formatting for section headers
if (paragraph.startsWith("📍 Selected Group:")) {
const content = paragraph.replace("📍 Selected Group:", "").trim();
return htl.html`<div style="
background: rgba(255, 255, 255, 0.15);
padding: 12px;
border-radius: 8px;
margin-bottom: 16px;
border-left: 4px solid #ffd700;
">
<div style="font-weight: bold; margin-bottom: 8px;">📍 Selected Group</div>
<div style="font-size: 16px;">${content}</div>
</div>`;
} else if (paragraph.startsWith("Overall Population Analysis:")) {
const content = paragraph
.replace("Overall Population Analysis:", "")
.trim();
return htl.html`<div style="
border-top: 1px solid rgba(255, 255, 255, 0.3);
padding-top: 16px;
margin-bottom: 16px;
">
<div style="font-weight: bold; margin-bottom: 12px;">Overall Population Analysis</div>
<div style="font-size: 16px; line-height: 1.6;">${content}</div>
</div>`;
} else if (paragraph === "---") {
// Skip separator lines
return null;
} else {
// Regular paragraphs
return htl.html`<p style="margin: 0 0 12px 0; font-size: 16px; line-height: 1.6; opacity: 0.95;">${paragraph}</p>`;
}
})
.filter((p) => p !== null);
return htl.html`
<div style="
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
border-radius: 10px;
margin: 16px 0;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
">
<h4 style="margin: 0 0 12px 0; font-size: 16px; font-weight: 600;">
${_lang(vulnerability_translations.quick_insights)}
</h4>
<div style="margin: 0;">
${paragraphElements}
</div>
</div>
`;
}viewof compositeAdmin0 = Inputs.select(dataAdmin0, {
label: adminRegions.labels.admin0,
format: x => x.label,
value: dataAdmin0.find(d => d.value === sharedAdmin0) || (dataAdmin0.length > 0 ? dataAdmin0[0] : null)
})
viewof compositeAdmin1 = Inputs.select(dataAdmin1, {
label: adminRegions.labels.admin1,
format: x => x.label,
value: dataAdmin1.find(d => d.value === sharedAdmin1) || (dataAdmin1.length > 0 ? dataAdmin1[0] : null)
})
viewof compositeAdmin2 = Inputs.select(dataAdmin2, {
label: adminRegions.labels.admin2,
format: x => x.label,
value: dataAdmin2.find(d => d.value === sharedAdmin2) || (dataAdmin2.length > 0 ? dataAdmin2[0] : null)
})// Controls form with exact same HTML structure
compositeControlsForm = htl.html`
<div style="
background: #f8fafc;
border: 2px solid #e2e8f0;
border-radius: 12px;
padding: 24px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.07);
margin: 16px 0;
">
<h3 style="margin: 0 0 20px 0; color: #2d3748; font-size: 1.1rem; font-weight: 600;">
${_lang(vulnerability_translations.geographic_selection)}
</h3>
<div style="
display: flex;
align-items: flex-start;
gap: 20px;
flex-wrap: wrap;
justify-content: space-between;
" class="form-inputs-container">
<div style="flex: 1; min-width: 180px; max-width: 100%;">${viewof compositeAdmin0}</div>
<div style="flex: 1; min-width: 180px; max-width: 100%;">${viewof compositeAdmin1}</div>
<div style="flex: 1; min-width: 180px; max-width: 100%;">${viewof compositeAdmin2}</div>
</div>
</div>
`// Database connection for index calculations
index_db = DuckDBClient.of({
exposure: FileAttachment(
"/data/vulnerability_notebook/notebook_exposure.parquet",
),
urgency: FileAttachment("/data/vulnerability_notebook/urgency_index.parquet"),
enabling: FileAttachment(
"/data/vulnerability_notebook/enabling_vars.parquet",
), // AKA adaptive capacity
});function computeIndex(row) {
const normValues = Object.entries(row)
.filter(
([key, value]) =>
key.endsWith("_norm") && typeof value === "number" && !isNaN(value),
)
.map(([, value]) => value);
if (normValues.length === 0) return null;
const sum = normValues.reduce((a, b) => a + b, 0);
return sum / normValues.length;
}
/**
* Generate SQL WHERE clause for admin level filtering
* Returns clause to filter by admin0 (country level) or admin1 (region level) based on selection
*
* @param {string|null} country_sel - Selected country code (or "SSA" for all countries)
* @param {string|null} admin1_sel - Selected admin1 region (currently unused but kept for consistency)
* @returns {string} SQL WHERE clause fragment
*/
whereAdminNull = (country_sel, admin1_sel) => {
// If all countries selected (SSA) or no selection, return country-level data (admin1_name IS NULL)
// Otherwise, return region-level data (admin1_name IS NOT NULL)
if (country_sel == "SSA" || !country_sel) {
return `AND admin1_name IS NULL`;
} else {
return `AND admin1_name IS NOT NULL`;
}
};// Calculate exposure index
exposure_index = {
// Check if index_db is available before querying
if (!index_db) {
console.error("index_db is not available");
return [];
}
try {
return await index_db.query(`
WITH raw_exposure AS (
SELECT
admin0_name,
admin1_name,
SUM(value) AS total_exposure
FROM exposure
WHERE admin2_name IS NULL
${whereAdminNull(compositeAdmin0.value, compositeAdmin1.value)}
GROUP BY admin0_name, admin1_name
),
stats AS (
SELECT
MIN(total_exposure) AS exposure_min,
MAX(total_exposure) AS exposure_max
FROM raw_exposure
WHERE ISFINITE(total_exposure)
)
SELECT
r.admin0_name,
r.admin1_name,
r.total_exposure,
-- Min-max normalize exposure
(r.total_exposure - s.exposure_min) / NULLIF(s.exposure_max - s.exposure_min, 0) AS exposure_norm
FROM raw_exposure r
CROSS JOIN stats s
WHERE ISFINITE(r.total_exposure)
ORDER BY r.total_exposure DESC;
`);
} catch (error) {
console.error("Failed to calculate exposure index:", error);
return [];
}
}// Calculate enabling index (adaptive capacity)
enabling_index = {
// Check if index_db is available before querying
if (!index_db) {
console.error("index_db is not available");
return [];
}
try {
let resp = await index_db.query(`
SELECT *
FROM enabling
WHERE admin0_name != 'SSA'
${whereAdminNull(compositeAdmin0.value, compositeAdmin1.value)}
`);
return resp.map((row) => ({
...row,
enabling_index: computeIndex(row)
}));
} catch (error) {
console.error("Failed to calculate enabling index:", error);
return [];
}
}// Calculate urgency index
// Index is based on exposure and sensitivity
urgency_index = {
// Check if index_db is available before querying
if (!index_db) {
console.error("index_db is not available");
return [];
}
try {
let gender = "total"; //NOTE: Keeping this as an option in case future updates want gender specific index
// sensitivity data
let sensitivityData = await index_db.query(`
SELECT *
FROM urgency
WHERE admin0_name != 'SSA'
AND gender = '${gender}'
${whereAdminNull(compositeAdmin0.value, compositeAdmin1.value)}
`);
// Check if exposure_index exists and is an array before using
const exposureMap = (exposure_index && Array.isArray(exposure_index))
? Object.fromEntries(
exposure_index.map((d) => {
const a0 = d.admin0_name;
const a1 = d.admin1_name;
const key = a1 ? `${a0}||${a1}` : a0; // use both if admin1 exists, else just admin0
return [key, d.exposure_norm];
})
)
: {};
// merge into urgencyData
const merged = sensitivityData.map((d) => {
const a0 = d.admin0_name;
const a1 = d.admin1_name;
const key = a1 ? `${a0}||${a1}` : a0;
return {
...d,
exposure_norm: exposureMap[key] ?? null
};
});
return merged.map((row) => ({
...row,
urgency_index: computeIndex(row)
}));
} catch (error) {
console.error("Failed to calculate urgency index:", error);
return [];
}
}// Calculate vulnerability index (AKA composite index with ND-gain Methodology)
vulnerabilityIndex = {
const key = (r) => `${r.admin0_name}|${r.admin1_name}`;
const toMap = (arr, k, v) => Object.fromEntries(arr.map((r) => [k(r), v(r)]));
const uMap = toMap(urgency_index, key, (r) => r.urgency_index);
const enMap = toMap(enabling_index, key, (r) => r.enabling_index);
const allKeys = new Set([...Object.keys(enMap), ...Object.keys(uMap)]);
const merged = [...allKeys].map((k) => {
const [admin0_name, admin1_name] = k.split("|");
const en = enMap[k],
u = uMap[k];
return {
admin0_name,
admin1_name,
enabling_index: en ?? null,
urgency_index: u ?? null,
vulnerability:
typeof en === "number" && typeof u === "number"
? 100 - (en - u + 1) * 50
: null
};
});
return merged.filter(d => d.vulnerability !== null);
}// Updated vulnerability index calculation using the Observable approach
componentIndicesPerRegion = {
// Check if vulnerabilityIndex exists and is valid before processing
if (!vulnerabilityIndex || !Array.isArray(vulnerabilityIndex) || vulnerabilityIndex.length === 0) {
return { scores: {}, adminLevel: null, regions: [] };
}
const selections = compositeAdminSelections;
const isNull = (val) => val === null || val === undefined || val === "" || val === "\\N";
// Determine admin level and get unique regions
let adminLevel, regions;
if (!selections.selectAdmin0 || selections.selectAdmin0 === "SSA") {
// Show all countries (admin0 level)
adminLevel = "admin0_name";
regions = [...new Set(vulnerabilityIndex.map(d => d.admin0_name).filter(d => d))];
} else if (!selections.selectAdmin1) {
// Show admin1 within selected country
adminLevel = "admin1_name";
regions = [...new Set(vulnerabilityIndex
.filter(d => d.admin0_name === selections.selectAdmin0 && !isNull(d.admin1_name))
.map(d => d.admin1_name))];
} else if (!selections.selectAdmin2) {
// Show admin2 within selected admin1
adminLevel = "admin2_name";
regions = [...new Set(vulnerabilityIndex
.filter(d => d.admin0_name === selections.selectAdmin0 &&
d.admin1_name === selections.selectAdmin1 &&
!isNull(d.admin2_name))
.map(d => d.admin2_name))];
} else {
// Single admin2 selected
adminLevel = "admin2_name";
regions = [selections.selectAdmin2];
}
// Calculate scores for each region using the new vulnerability index
const regionScores = {};
regions.forEach(region => {
// Find vulnerability data for this region
const vulnData = vulnerabilityIndex.find(d => d[adminLevel] === region);
if (vulnData) {
// Convert vulnerability score (0-100) to 0-1 scale for consistency
const compositeScore = vulnData.vulnerability ? vulnData.vulnerability / 100 : 0;
const enablingScore = vulnData.enabling_index || 0;
const urgencyScore = vulnData.urgency_index || 0;
// Urgency index represents sensitivity (population density, education, poverty)
// Exposure is separate and comes from exposure data
const sensitivityScore = urgencyScore; // Urgency index IS sensitivity
// Get exposure score from exposure data (with null check)
const exposureData = (exposure_index && Array.isArray(exposure_index))
? exposure_index.find(d => d[adminLevel] === region)
: null;
const exposureScore = exposureData ? exposureData.exposure_norm : 0;
// FIXED: Adaptive capacity should be HIGH when enabling is HIGH (not inverted)
// Countries with high enabling factors have HIGH adaptive capacity (good thing)
const adaptiveScore = enablingScore; // High enabling = high adaptive capacity
regionScores[region] = {
exposure: exposureScore,
sensitivity: sensitivityScore,
adaptive_capacity: adaptiveScore,
composite: compositeScore,
// Include raw values for reference
enabling_index: enablingScore,
urgency_index: urgencyScore,
vulnerability_score: vulnData.vulnerability
};
} else {
// No data available for this region
regionScores[region] = { // All null as a default of 0 could be interpreted as a score rather than missing
exposure: null,
sensitivity: null,
adaptive_capacity: null,
composite: null,
enabling_index: null,
urgency_index: null,
vulnerability_score: null
};
}
});
return {
scores: regionScores,
adminLevel: adminLevel,
regions: regions
};
}// Small Multiples/Faceted Maps Visualization with proper loading/no-data states
compositeVulnerabilityMaps = {
// Show loading spinner only if data is still loading (null/undefined)
if ([exposure_index, enabling_index, urgency_index, sensitivityMetadata].some((d) => d === null || d === undefined)) {
return createLoadingState(
_lang({en: "Loading composite vulnerability data...", fr: "Chargement des données de vulnérabilité composite..."})
);
}
// // If data is loaded but empty or processing failed, show no data state
if (
[exposure_index, enabling_index, urgency_index, sensitivityMetadata, componentIndicesPerRegion]
.some((d) => !d) || !componentIndicesPerRegion?.scores
) {
return createNoDataState(_lang(vulnerability_translations.no_data_available));
}
const { scores, adminLevel } = componentIndicesPerRegion;
const selections = compositeAdminSelections;
// Get the appropriate boundary level
let boundaries;
if (adminLevel === "admin0_name") {
boundaries = compositeMapBoundaries.admin0;
} else if (adminLevel === "admin1_name") {
boundaries = {
...compositeMapBoundaries.admin1,
features: compositeMapBoundaries.admin1.features.filter(
d => d.properties.admin0_name === selections.selectAdmin0
)
};
} else {
boundaries = {
...compositeMapBoundaries.admin2,
features: compositeMapBoundaries.admin2.features.filter(
d => d.properties.admin0_name === selections.selectAdmin0 &&
d.properties.admin1_name === selections.selectAdmin1
)
};
}
// Define the components to show (always all components)
const components = [
{ key: "composite", label: _lang({en: "Composite", fr: "Composite"}), scheme: "Reds", isComposite: true },
{ key: "exposure", label: _lang({en: "Exposure", fr: "Exposition"}), scheme: "Oranges", isComposite: false },
{ key: "sensitivity", label: _lang({en: "Sensitivity", fr: "Sensibilité"}), scheme: "Purples", isComposite: false },
{ key: "adaptive_capacity", label: _lang({en: "Adaptive Capacity", fr: "Capacité d'Adaptation"}), scheme: "Greens", isComposite: false }
];
// Calculate subplot dimensions - use full width when available
const totalWidth = typeof width === "number" ? Math.min(1200, width) : 1000;
const totalHeight = 600;
const layoutPadding = 8;
const componentMapGap = 12;
const legendAllowance = 40;
const compositeColumnRatio = 2.2;
const componentColumnRatio = 1;
const columnTotal = compositeColumnRatio + componentColumnRatio;
const compositeMapWidth = Math.floor(totalWidth * (compositeColumnRatio / columnTotal));
const componentMapWidth = Math.floor(totalWidth * (componentColumnRatio / columnTotal));
const componentMapHeight = Math.floor((totalHeight - (componentMapGap * 2)) / 3);
const compositeMapHeight = (componentMapHeight * 3) + (componentMapGap * 2) + (legendAllowance * 2);
const componentDataByKey = new Map();
const buildComponentFeatures = (component) => {
return boundaries.features.map(feature => {
// Get the correct region name based on admin level
let regionName;
if (adminLevel === "admin0_name") {
regionName = feature.properties.admin0_name;
} else if (adminLevel === "admin1_name") {
regionName = feature.properties.admin1_name;
} else if (adminLevel === "admin2_name") {
regionName = feature.properties.admin2_name;
} else {
regionName = feature.properties[adminLevel];
}
const regionScores = scores[regionName] || {
exposure: 0, sensitivity: 0, adaptive_capacity: 0, composite: 0
};
return {
...feature,
properties: {
...feature.properties,
vulnerability: regionScores[component.key] || 0,
regionName: regionName,
// Add individual scores as properties for easier tooltip access
exposureScore: regionScores.exposure || 0,
sensitivityScore: regionScores.sensitivity || 0,
adaptiveCapacityScore: regionScores.adaptive_capacity || 0,
compositeScore: regionScores.composite || 0
}
};
});
};
components.forEach(component => {
componentDataByKey.set(component.key, {
component,
features: buildComponentFeatures(component)
});
});
const createMapFor = (componentKey, mapWidth, mapHeight) => {
const entry = componentDataByKey.get(componentKey);
if (!entry) {
return createNoDataState(_lang(vulnerability_translations.no_data_available));
}
const { component, features: componentFeatures } = entry;
// Color scheme based on component
let colorRange;
if (component.key === "adaptive_capacity") {
colorRange = ["#F7D732", "#216729"]; // Red (low/bad) to Green (high/good) - high adaptive capacity is good
} else {
colorRange = ["#F4BB21", "#EC5A47"]; // Yellow (low) to Red (high) for other components
}
return renderGeoMap({
features: componentFeatures,
width: mapWidth,
height: mapHeight,
valueAccessor: d => d.properties.vulnerability,
color: {
type: "linear",
range: colorRange,
legend: true,
label: component.key === "adaptive_capacity" ?
_lang({en: "Adaptive Capacity (Higher = Better)", fr: "Capacité d'Adaptation"}) :
component.key === "exposure" ?
_lang({en: "Climate Exposure (Higher = Worse)", fr: "Exposition Climatique"}) :
component.key === "sensitivity" ?
_lang({en: "Population Sensitivity (Higher = Worse)", fr: "Sensibilité de la Population"}) :
_lang({en: "Composite Vulnerability (Higher = Worse)", fr: "Vulnérabilité Composite"})
},
tooltip: {
channels: {
country: {
label: _lang({en: "Country", fr: "Pays"}),
value: (d) => d.properties.admin0_name || "Unknown"
},
region: {
label: _lang({en: "Region", fr: "Région"}),
value: (d) => {
if (d.properties.admin2_name) {
return `${d.properties.admin2_name}, ${d.properties.admin1_name}`;
} else if (d.properties.admin1_name) {
return d.properties.admin1_name;
} else {
return d.properties.admin0_name;
}
}
},
component: {
label: _lang({en: "Component", fr: "Composant"}),
value: component.label
},
score: {
label: _lang({en: "Score", fr: "Score"}),
value: (d) => d.properties.vulnerability !== null && d.properties.vulnerability !== undefined ? d.properties.vulnerability.toFixed(3) : "No data"
},
...(component.key === "composite" ? {
exposure: {
label: _lang({en: "Exposure", fr: "Exposition"}),
value: (d) => d.properties.exposureScore !== null && d.properties.exposureScore !== undefined ? d.properties.exposureScore.toFixed(3) : "No data"
},
sensitivity: {
label: _lang({en: "Sensitivity", fr: "Sensibilité"}),
value: (d) => d.properties.sensitivityScore !== null && d.properties.sensitivityScore !== undefined ? d.properties.sensitivityScore.toFixed(3) : "No data"
},
adaptiveCapacity: {
label: _lang({en: "Adaptive Capacity", fr: "Capacité d'Adaptation"}),
value: (d) => d.properties.adaptiveCapacityScore !== null && d.properties.adaptiveCapacityScore !== undefined ? d.properties.adaptiveCapacityScore.toFixed(3) : "No data"
}
} : {})
}
}
});
};
const createMapContainer = (componentKey, mapWidth, mapHeight, isFeatured) => {
const map = createMapFor(componentKey, mapWidth, mapHeight);
const mapContainer = document.createElement("div");
mapContainer.className = isFeatured ? "map featured" : "map";
mapContainer.dataset.componentKey = componentKey;
mapContainer.style.display = "flex";
mapContainer.style.flexDirection = "column";
mapContainer.style.alignItems = "stretch";
mapContainer.style.width = "100%";
const mapWrapper = document.createElement("div");
mapWrapper.style.overflow = "visible";
mapWrapper.append(map);
const mapSvg = map.querySelector ? map.querySelector('svg:nth-of-type(2)') : null; // Map is the second svg of the figure. Might change
if (mapSvg) mapSvg.setAttribute("overflow", "visible");
mapContainer.append(mapWrapper);
return mapContainer;
};
// Individual legends are now integrated with each map
const compositeMapContainer = createMapContainer(
"composite",
compositeMapWidth,
compositeMapHeight,
true
);
const componentMapContainers = components
.filter(component => !component.isComposite)
.map(component => createMapContainer(component.key, componentMapWidth, componentMapHeight, false));
// Return maps in a custom layout with labels and external legend
const container = htl.html`
<div style="
display: flex;
flex-direction: column;
align-items: stretch;
gap: 20px;
width: 100%;
max-width: 100%;
padding: ${layoutPadding}px;
background: white;
border-radius: 8px;
">
<!-- Individual legends are now integrated with each map -->
<!-- Two-column layout: composite map left, components stacked right -->
<div style="
display: grid;
grid-template-columns: minmax(0, ${compositeColumnRatio}fr) minmax(0, ${componentColumnRatio}fr);
gap: ${componentMapGap}px;
width: 100%;
overflow: visible;
">
<div style="
display: flex;
flex-direction: column;
align-items: stretch;
width: 100%;
" data-role="featured">
${compositeMapContainer}
</div>
<div style="
display: flex;
flex-direction: column;
align-items: stretch;
gap: ${componentMapGap}px;
width: 100%;
" data-role="secondary">
${componentMapContainers.map((mapContainer) => mapContainer)}
</div>
</div>
</div>
`;
// Enable double-click swapping between featured and secondary columns
setTimeout(() => {
const featuredColumn = container.querySelector('[data-role="featured"]');
const secondaryColumn = container.querySelector('[data-role="secondary"]');
if (!featuredColumn || !secondaryColumn) return;
const bindMapEvents = () => {
const maps = container.querySelectorAll('.map');
maps.forEach((map) => {
if (map.dataset.bound === "true") return;
map.dataset.bound = "true";
map.addEventListener('dblclick', () => {
if (map.classList.contains('featured')) return;
const currentFeatured = container.querySelector('.map.featured');
if (!currentFeatured) return;
const featuredKey = currentFeatured.dataset.componentKey;
const clickedKey = map.dataset.componentKey;
if (!featuredKey || !clickedKey) return;
const newFeatured = createMapContainer(
clickedKey,
compositeMapWidth,
compositeMapHeight,
true
);
const newSecondary = createMapContainer(
featuredKey,
componentMapWidth,
componentMapHeight,
false
);
featuredColumn.innerHTML = "";
featuredColumn.append(newFeatured);
secondaryColumn.replaceChild(newSecondary, map);
bindMapEvents();
});
});
};
bindMapEvents();
}, 0);
return container;
}// Dynamic Insights following wireframe template
compositeVulnerabilityInsights = {
if (!componentIndicesPerRegion || !componentIndicesPerRegion.scores) {
return createNoDataState();
}
const selections = compositeAdminSelections;
const { scores, adminLevel } = componentIndicesPerRegion;
// Get region name for insight
let regionName;
if (!selections.selectAdmin0 || selections.selectAdmin0 === "SSA") {
regionName = "Sub-Saharan Africa";
} else if (!selections.selectAdmin1) {
regionName = selections.selectAdmin0;
} else if (!selections.selectAdmin2) {
regionName = selections.selectAdmin1;
} else {
regionName = selections.selectAdmin2;
}
// Calculate average scores across all regions for this level
const regions = Object.keys(scores);
const avgScores = {
composite: d3.mean(regions, r => scores[r].composite) || 0,
exposure: d3.mean(regions, r => scores[r].exposure) || 0,
sensitivity: d3.mean(regions, r => scores[r].sensitivity) || 0,
adaptive_capacity: d3.mean(regions, r => scores[r].adaptive_capacity) || 0
};
// Determine vulnerability level
const vulnLevel = avgScores.composite > 0.7 ?
_lang({en: "high", fr: "élevé"}) :
avgScores.composite > 0.4 ?
_lang({en: "moderate", fr: "modéré"}) :
_lang({en: "low", fr: "faible"});
// Identify main drivers
const drivers = [];
if (avgScores.exposure > 0.6) drivers.push(_lang({en: "high exposure", fr: "exposition élevée"}));
if (avgScores.sensitivity > 0.6) drivers.push(_lang({en: "high sensitivity", fr: "sensibilité élevée"}));
if (avgScores.adaptive_capacity < 0.4) drivers.push(_lang({en: "limited adaptive capacity", fr: "capacité d'adaptation limitée"})); // LOW adaptive capacity is bad
// Recommendations based on highest scoring components
const recommendations = [];
if (avgScores.exposure === Math.max(avgScores.exposure, avgScores.sensitivity, avgScores.adaptive_capacity)) {
recommendations.push(
_lang({en: "climate hazard preparedness", fr: "préparation aux dangers climatiques"}),
_lang({en: "resilient infrastructure", fr: "infrastructure résiliente"})
);
}
if (avgScores.sensitivity > 0.5) {
recommendations.push(
_lang({en: "education access", fr: "accès à l'éducation"}),
_lang({en: "poverty reduction programs", fr: "programmes de réduction de la pauvreté"})
);
}
if (avgScores.adaptive_capacity < 0.5) { // LOW adaptive capacity needs improvement
recommendations.push(
_lang({en: "connectivity improvements", fr: "améliorations de la connectivité"}),
_lang({en: "institutional strengthening", fr: "renforcement institutionnel"})
);
}
const driversText = drivers.length > 0 ?
drivers.join(_lang({en: " combined with ", fr: " combiné avec "})) :
_lang({en: "multiple factors", fr: "facteurs multiples"});
const recommendationsText = recommendations.slice(0, 2).join(_lang({en: " and ", fr: " et "})) ||
_lang({en: "comprehensive interventions", fr: "interventions complètes"});
// Create component scores as list items
const componentScores = [
htl.html`<li><strong>${_lang({en: "Exposure", fr: "Exposition"})}</strong>: ${(avgScores.exposure || 0).toFixed(2)}</li>`,
htl.html`<li><strong>${_lang({en: "Sensitivity", fr: "Sensibilité"})}</strong>: ${(avgScores.sensitivity || 0).toFixed(2)}</li>`,
htl.html`<li><strong>${_lang({en: "Adaptive Capacity", fr: "Capacité d'adaptation"})}</strong>: ${(avgScores.adaptive_capacity || 0).toFixed(2)}</li>`
];
const insight = htl.html`<div>
<p>${_lang({
en: `${regionName} has a ${vulnLevel} vulnerability score of ${(avgScores.composite || 0).toFixed(2)}.`,
fr: `${regionName} a un score de vulnérabilité ${vulnLevel} de ${(avgScores.composite || 0).toFixed(2)}.`
})}</p>
<ul>${componentScores}</ul>
<p>${_lang({
en: `${driversText} driving vulnerability. Strengthening ${recommendationsText} could reduce future risks.`,
fr: `${driversText} génèrent la vulnérabilité. Renforcer ${recommendationsText} pourrait réduire les risques futurs.`
})}</p>
</div>`;
return createInsightDisplay(insight);
}// Create download dataset with all composite index and sub-indices
compositeDownloadData = {
if (!componentIndicesPerRegion || !componentIndicesPerRegion.scores) return [];
const { scores, adminLevel } = componentIndicesPerRegion;
const regions = Object.keys(scores);
return regions.map(region => {
const regionScores = scores[region];
return {
[_lang({en: "Region", fr: "Région"})]: region,
[_lang({en: "Composite Vulnerability Score", fr: "Score de Vulnérabilité Composite"})]: regionScores.composite ? regionScores.composite.toFixed(3) : "N/A",
[_lang({en: "Exposure Score", fr: "Score d'Exposition"})]: regionScores.exposure ? regionScores.exposure.toFixed(3) : "N/A",
[_lang({en: "Sensitivity Score", fr: "Score de Sensibilité"})]: regionScores.sensitivity ? regionScores.sensitivity.toFixed(3) : "N/A",
[_lang({en: "Adaptive Capacity Score", fr: "Score de Capacité d'Adaptation"})]: regionScores.adaptive_capacity ? regionScores.adaptive_capacity.toFixed(3) : "N/A",
[_lang({en: "Enabling Index", fr: "Indice d'Enabling"})]: regionScores.enabling_index ? regionScores.enabling_index.toFixed(3) : "N/A",
[_lang({en: "Urgency Index", fr: "Indice d'Urgence"})]: regionScores.urgency_index ? regionScores.urgency_index.toFixed(3) : "N/A",
[_lang({en: "Raw Vulnerability Score", fr: "Score de Vulnérabilité Brut"})]: regionScores.vulnerability_score || "N/A"
};
});
}// Download button for composite index data
viewof compositeDownloadButton = {
if (!compositeDownloadData || compositeDownloadData.length === 0) {
return htl.html`<p>${_lang({en: "No data available for download", fr: "Aucune donnée disponible pour le téléchargement"})}</p>`;
}
const region = _lang({en: "All_Regions", fr: "Toutes_Les_Régions"});
return downloadButton(
compositeDownloadData,
`composite_vulnerability_data_${region}`,
_lang({en: "Download Composite Index Data", fr: "Télécharger les Données de l'Indice Composite"})
);
}viewof selectAdmin0 = Inputs.select(dataAdmin0, {
label: adminRegions.labels.admin0,
format: x => x.label,
value: dataAdmin0.find(d => d.value === sharedAdmin0) || (dataAdmin0.length > 0 ? dataAdmin0[0] : null)
})
viewof selectAdmin1 = Inputs.select(dataAdmin1, {
label: adminRegions.labels.admin1,
format: x => x.label,
value: dataAdmin1.find(d => d.value === sharedAdmin1) || (dataAdmin1.length > 0 ? dataAdmin1[0] : null)
})
viewof selectAdmin2 = Inputs.select(dataAdmin2, {
label: adminRegions.labels.admin2,
format: x => x.label,
value: dataAdmin2.find(d => d.value === sharedAdmin2) || (dataAdmin2.length > 0 ? dataAdmin2[0] : null)
})exposureControlsForm = htl.html`
<div style="
background: #f8fafc;
border: 2px solid #e2e8f0;
border-radius: 12px;
padding: 24px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.07);
margin: 16px 0;
">
<h3 style="margin: 0 0 20px 0; color: #2d3748; font-size: 1.1rem; font-weight: 600;">
${_lang(vulnerability_translations.geographic_selection)}
</h3>
<div style="
display: flex;
align-items: flex-start;
gap: 20px;
flex-wrap: wrap;
justify-content: space-between;
" class="form-inputs-container">
<div style="flex: 1; min-width: 180px; max-width: 100%;">${viewof selectAdmin0}</div>
<div style="flex: 1; min-width: 180px; max-width: 100%;">${viewof selectAdmin1}</div>
<div style="flex: 1; min-width: 180px; max-width: 100%;">${viewof selectAdmin2}</div>
</div>
</div>
`// grab tabular data for choropleth
dataGeoImpact = {
// Check if exposureData exists and is an array before processing
if (!exposureData || !Array.isArray(exposureData)) {
return [];
}
// get data for choropleth map based on choice
const dataSource = exposureData;
let filteredData;
// select different data based on admin selections using shared state
if (sharedAdmin1) {
filteredData = dataSource.filter((d) => {
return d.admin0_name == sharedAdmin0
&& d.admin1_name == sharedAdmin1
&& d.admin2_name // always non-null
});
} else if (sharedAdmin0) {
// admin0 is selected, with no admin1
// get all admin1 data for selected admin0
filteredData = dataSource.filter((d) => {
return d.admin0_name == sharedAdmin0
&& d.admin1_name // always non-null
&& !d.admin2_name // always null
});
} else {
// end case: admin0 is not selected
// get all admin0 data
filteredData = dataSource.filter((d) => {
return !d.admin1_name // always null
&& !d.admin2_name // always null
});
}
// Aggregate data by summing values for each administrative region
if (filteredData.length === 0) return [];
// Determine grouping key based on admin level
// Handle null values explicitly to avoid "null" strings in keys
let groupKey;
if (sharedAdmin1) {
// Group by admin2
groupKey = d => `${d.admin0_name || 'NULL'}|${d.admin1_name || 'NULL'}|${d.admin2_name || 'NULL'}`;
} else if (sharedAdmin0) {
// Group by admin1
groupKey = d => `${d.admin0_name || 'NULL'}|${d.admin1_name || 'NULL'}`;
} else {
// Group by admin0
groupKey = d => d.admin0_name || 'NULL';
}
const groupedData = d3.group(filteredData, groupKey);
return Array.from(groupedData.entries()).map(([key, values]) => {
// Parse the key back to get admin names
let admin0_name, admin1_name, admin2_name;
if (sharedAdmin1) {
[admin0_name, admin1_name, admin2_name] = key.split("|");
} else if (sharedAdmin0) {
[admin0_name, admin1_name] = key.split("|");
} else {
admin0_name = key;
}
// Sum up all exposure values for this region
const totalValue = d3.sum(values, d => d.value || 0);
return {
admin0_name,
admin1_name: admin1_name || null,
admin2_name: admin2_name || null,
value: totalValue
};
});
}// Geographic data binding
exposureMapData = {
if (!dataGeoImpact || dataGeoImpact.length === 0) {
return { features: [] };
}
// Check if boundaries exist before accessing properties
if (!boundaries) {
return { features: [] };
}
// Determine which admin level to show
if (!sharedAdmin0) {
// Show admin0 level
if (!boundaries.admin0 || !boundaries.admin0.features) {
return { features: [] };
}
return bindTabularToGeo({
data: dataGeoImpact,
dataBindColumn: "admin0_name",
geoData: boundaries.admin0,
geoDataBindColumn: "admin0_name"
});
} else if (!sharedAdmin1) {
// Show admin1 level within selected admin0
if (!boundaries.admin1 || !boundaries.admin1.features) {
return { features: [] };
}
const data = dataGeoImpact.map(d => ({
...d,
a1_a0: [d.admin1_name, d.admin0_name].join("_")
}));
const geoData = {
...boundaries.admin1,
features: boundaries.admin1.features.filter(
d => d.properties.admin0_name === sharedAdmin0
)
};
return bindTabularToGeo({
data: data,
dataBindColumn: "admin1_name",
geoData: geoData,
geoDataBindColumn: "admin1_name"
});
} else {
// Show admin2 level within selected admin1
if (!boundaries.admin2 || !boundaries.admin2.features) {
return { features: [] };
}
const data = dataGeoImpact.map(d => ({
...d,
a2_a1_a0: [d.admin2_name, d.admin1_name, d.admin0_name].join("_")
}));
const geoData = {
...boundaries.admin2,
features: boundaries.admin2.features.filter(d =>
d.properties.admin1_name === sharedAdmin1 &&
d.properties.admin0_name === sharedAdmin0
)
};
return bindTabularToGeo({
data: data,
dataBindColumn: "admin2_name",
geoData: geoData,
geoDataBindColumn: "admin2_name"
});
}
}// Visualization with proper loading/no-data states
exposureChoroplethMap = {
// Show loading spinner only if data is still loading (null/undefined)
if (exposureData === null || exposureData === undefined) {
return createLoadingState(_lang({en: "Loading exposure data...", fr: "Chargement des données d'exposition..."}));
}
// If data is loaded but empty, or map data is empty, show no data state
if (!exposureData || exposureData.length === 0 || !exposureMapData || !exposureMapData.features || exposureMapData.features.length === 0) {
return createNoDataState(_lang(vulnerability_translations.no_data_available));
}
const data = exposureMapData;
const plot = renderGeoMap({
features: data.features,
width: mapWidth,
height: mapHeight,
caption: vopNote.caption,
projection: {
type: "azimuthal-equal-area",
domain: data
},
color: {
legend: true,
label: `${_lang({en: "VoP", fr: "VoP"})} (${intDollarUnit})`,
range: colorScales.range.yellowGreen,
unknown: colorScales.unknown,
tickFormat: formatNumCompactShort
},
valueAccessor: (d) => {
const dataColumn = "value";
const fillValue = d.properties.data ? d.properties.data[dataColumn] : null;
return fillValue;
},
tooltip: {
channels: {
country: {
label: "Country",
value: (d) => d.properties.admin0_name
},
name: {
label: "Region",
value: (d) => {
if (d.properties.admin2_name) {
return `${d.properties.admin2_name}, ${d.properties.admin1_name}`;
} else if (d.properties.admin1_name) {
return d.properties.admin1_name;
} else {
return d.properties.admin0_name;
}
}
},
data: {
label: "VoP",
value: (d) => {
const dataColumn = "value";
const data = d.properties.data ? d.properties.data[dataColumn] : undefined;
return data;
}
}
},
format: {
country: true,
name: true,
data: (d) => d ? formatNumCompactShort(d) : "No data"
}
},
extraMarks: [
Plot.geo(
sharedAdmin2
? data.features.filter(d => d.properties.admin2_name === sharedAdmin2)
: [],
{
fill: null,
stroke: "#333",
strokeWidth: 1.5
}
)
]
});
return htl.html`
<!-- <div style=" -->
<!-- border: 1px solid #e5e7eb; -->
<!-- border-radius: 8px; -->
<!-- padding: 20px; -->
<!-- background-color: #fff; -->
<!-- box-shadow: 0 2px 4px rgba(0,0,0,0.1); -->
<!-- margin: 16px 0; -->
<!-- "> -->
${plot}
<!-- </div> -->
`;
}// Download functionality
exposureDownloadButton = {
if (!dataGeoImpact || dataGeoImpact.length === 0) {
return htl.html``;
}
return downloadButton(
dataGeoImpact,
`exposure_data_${getExposureAdminSelection().replace(/\s+/g, '_')}_all_hazard_types`,
_lang(vulnerability_translations.download_data)
);
}// Dynamic insights
exposureInsights = {
if (!dataGeoImpact || dataGeoImpact.length === 0) {
return createNoDataState();
}
// Calculate total exposure value
const totalValue = d3.sum(dataGeoImpact, d => d.value || 0);
const region = getExposureAdminSelection();
const hazardLabel = "Any";
// Get top 3 most exposed commodities
let topCommodities = [];
if (exposureData && exposureData.length > 0) {
// Filter data based on current admin selection
let filteredCommodityData = exposureData;
if (sharedAdmin1) {
filteredCommodityData = exposureData.filter(d =>
d.admin0_name === sharedAdmin0 &&
d.admin1_name === sharedAdmin1 &&
d.admin2_name
);
} else if (sharedAdmin0) {
filteredCommodityData = exposureData.filter(d =>
d.admin0_name === sharedAdmin0 &&
d.admin1_name &&
!d.admin2_name
);
} else {
filteredCommodityData = exposureData.filter(d =>
!d.admin1_name && !d.admin2_name
);
}
// Group by commodity and sum values
const commodityGroups = d3.group(filteredCommodityData, d => d.crop);
const commodityTotals = Array.from(commodityGroups.entries()).map(([crop, values]) => ({
crop: crop,
totalValue: d3.sum(values, d => d.value || 0)
}));
// Sort by total value and get top 3
topCommodities = commodityTotals
.sort((a, b) => b.totalValue - a.totalValue)
.slice(0, 3)
.filter(d => d.totalValue > 0);
}
// Format currency without the "2015 USD" prefix
const formatCleanCurrency = (number) => {
return new Intl.NumberFormat("en-US", {
notation: "compact",
compactDisplay: "short",
style: "currency",
currency: "USD",
}).format(number);
};
const template = _lang(vulnerability_translations.exposure_insight_template);
const replacements = [
{ name: "region", value: region },
{ name: "amount", value: formatCleanCurrency(totalValue) }
];
const insight = generateInsight(template, replacements);
// Add top commodities insight if available
let commoditiesInsight = "";
if (topCommodities.length > 0) {
// Create dynamic text with specific commodity names
const commodityNames = topCommodities
.map(d => d.crop.replace(/-/g, ' '))
.join(", ");
const commoditiesText = _lang({
en: ` The top 3 most exposed commodities are:`,
fr: ` Les 3 produits les plus exposés sont:`
});
// Combine insight text with commodities list as HTML using proper htl.html syntax
const fullInsight = htl.html`
${insight}${commoditiesText}
<ol>
${topCommodities.map(d => htl.html`<li>${d.crop.replace(/-/g, ' ')} (${formatCleanCurrency(d.totalValue)})</li>`)}
</ol>
`;
return createInsightDisplay(fullInsight);
}
return createInsightDisplay(insight);
}viewof sensitivityAdmin0 = Inputs.select(dataAdmin0, {
label: adminRegions.labels.admin0,
format: x => x.label,
value: dataAdmin0.find(d => d.value === sharedAdmin0) || (dataAdmin0.length > 0 ? dataAdmin0[0] : null)
})
viewof sensitivityAdmin1 = Inputs.select(dataAdmin1, {
label: adminRegions.labels.admin1,
format: x => x.label,
value: dataAdmin1.find(d => d.value === sharedAdmin1) || (dataAdmin1.length > 0 ? dataAdmin1[0] : null)
})
viewof sensitivityAdmin2 = Inputs.select(dataAdmin2, {
label: adminRegions.labels.admin2,
format: x => x.label,
value: dataAdmin2.find(d => d.value === sharedAdmin2) || (dataAdmin2.length > 0 ? dataAdmin2[0] : null)
})sensitivityControlsForm = htl.html`
<div style="
background: #f8fafc;
border: 2px solid #e2e8f0;
border-radius: 12px;
padding: 24px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.07);
margin: 16px 0;
">
<h3 style="margin: 0 0 20px 0; color: #2d3748; font-size: 1.1rem; font-weight: 600;">
${_lang(vulnerability_translations.geographic_selection)}
</h3>
<div style="
display: flex;
align-items: flex-start;
gap: 20px;
flex-wrap: wrap;
justify-content: space-between;
" class="form-inputs-container">
<div style="flex: 1; min-width: 180px; max-width: 100%;">${viewof sensitivityAdmin0}</div>
<div style="flex: 1; min-width: 180px; max-width: 100%;">${viewof sensitivityAdmin1}</div>
<div style="flex: 1; min-width: 180px; max-width: 100%;">${viewof sensitivityAdmin2}</div>
</div>
</div>
`// Load data with caching - using parquet via DuckDB
csv_raw = await loadDataWithCache("sensitivityData", async () => {
const db = await DuckDBClient.of({
vulnerability_icicle: FileAttachment(
"/data/vulnerability_notebook/vulnerability_icicledata_updated.parquet",
),
});
const data = await db.query("SELECT * FROM vulnerability_icicle");
// Convert to the expected format: array of arrays [admin0, admin1, admin2, path, value]
// The parquet should have columns: admin0_name, admin1_name, admin2_name, path, value
return data
.filter((d) => d.path != null && d.path !== "")
.map((d) => {
// Convert null values to "NA" string to match CSV format
const admin0 = d.admin0_name || "SSA";
const admin1 = d.admin1_name || "NA";
const admin2 = d.admin2_name || "NA";
return [admin0, admin1, admin2, d.path, d.value || 0];
});
});breadcrumbWidth = 200;
breadcrumbHeight = 35;
targetHeight = 425;
height = narrow ? narrowHeight : targetHeight;
narrowHeight = 600;
narrow = width <= 0;
segmentX = (d) => (narrow ? d.y0 : d.x0);
segmentY = (d) => (narrow ? d.x0 : d.y0);
segmentWidth = (d) => (narrow ? d.y1 - d.y0 : d.x1 - d.x0);
segmentHeight = (d) => (narrow ? d.x1 - d.x0 : d.y1 - d.y0);partition = (data, order = null) =>
d3
.partition()
.padding(1)
.size(narrow ? [height, width] : [width, height])(
d3
.hierarchy(data)
.sum((d) => d.value)
.sort((a, b) => {
if (order) {
// Look up indices, fallback to 999 if not defined
return (order[a.data.name] ?? 999) - (order[b.data.name] ?? 999);
}
return b.value - a.value;
}),
);function buildHierarchy(csv) {
// Helper function that transforms the given CSV into a hierarchical format.
// Skip the population level and start directly with demographic categories
const root = { name: "root", children: [] };
for (let i = 0; i < csv.length; i++) {
const sequence = csv[i][0];
const size = +csv[i][1];
// Skip invalid rows
if (!sequence || isNaN(size)) {
continue;
}
const parts = sequence.split("_");
let currentNode = root;
// Skip the first part (population) and start from the second part
for (let j = 1; j < parts.length; j++) {
const children = currentNode["children"];
const nodeName = parts[j];
let childNode = null;
let foundChild = false;
// Search for existing child with the same name
for (let k = 0; k < children.length; k++) {
if (children[k]["name"] === nodeName) {
childNode = children[k];
foundChild = true;
break;
}
}
// If not found, create a new child node
if (!foundChild) {
childNode = { name: nodeName, children: [] };
children.push(childNode);
}
currentNode = childNode;
// If it's the last part of the sequence, create a leaf node
if (j === parts.length - 1) {
childNode.value = size;
}
}
}
return root;
}csv = {
// Check if csv_raw exists and is an array before processing
if (!csv_raw || !Array.isArray(csv_raw) || csv_raw.length === 0) {
return [];
}
if (!sensitivityAdmin0.value) {
// No admin selection - use SSA data
return csv_raw
.filter(
(item) => item && item[0] === "SSA" && item[1] === "NA" && item[2] === "NA" && item[3]
)
.map((item) => [item[3], item[4]]);
} else if (!sensitivityAdmin1.value) {
return csv_raw
.filter(
(item) => item && item[0] === sensitivityAdmin0.value && item[1] === "NA" && item[2] === "NA" && item[3]
)
.map((item) => [item[3], item[4]]);
} else if (sensitivityAdmin1.value && !sensitivityAdmin2.value) {
return csv_raw
.filter(
(item) => item && item[0] === sensitivityAdmin0.value && item[1] === sensitivityAdmin1.value && item[2] === "NA" && item[3]
)
.map((item) => [item[3], item[4]]);
} else if (sensitivityAdmin1.value !== null && sensitivityAdmin2.value !== null) {
return csv_raw
.filter(
(item) =>
item && item[0] === sensitivityAdmin0.value && item[1] === sensitivityAdmin1.value && item[2] === sensitivityAdmin2.value && item[3]
)
.map((item) => [item[3], item[4]]);
}
return [];
}data = buildHierarchy(csv)
// Create percentage table for insights
icicle_pct_table = {
if (!csv || csv.length === 0) return [];
// A little function to basically calculate the same thing as the breadcrumb but for all the admin region.
// This is what users should see if going to a tabular view and also what they should download. It is much easier to understand and work with.
const summarize_icicle = (csv, icicle_keys = null) => {
const rows = csv.map(([key, val]) => ({
parts: key.split("_").slice(1),
value: +val
}));
const total = d3.sum(rows, (d) => d.value);
const maxDepth = Math.max(...rows.map((r) => r.parts.length));
const levelNames = icicle_keys
? Object.keys(icicle_keys)
: [...Array(maxDepth).keys()].map((i) => `level${i + 1}`);
const lookupName = (dim, key) => {
if (!icicle_keys) return key;
return icicle_keys[dim]?.[key]?.[0] ?? key;
};
const genderTotals = {};
rows.forEach((r) => {
const gender = r.parts[0];
genderTotals[gender] = (genderTotals[gender] || 0) + r.value;
});
// Recursive helper
const recurse = (rows, depth = 0, prefix = []) => {
if (!rows.length) return [];
if (depth >= maxDepth) return [];
return d3
.rollups(
rows,
(v) => d3.sum(v, (d) => d.value),
(d) => d.parts[depth]
)
.flatMap(([k, sum]) => {
const current = [...prefix, k];
const rowObj = {};
levelNames.forEach((col, i) => {
rowObj[col] = current[i] ? lookupName(col, current[i]) : null;
});
rowObj.pct_total = (100 * sum) / total;
// pct relative to gender
const genderKey = prefix[0];
if (genderKey && genderTotals[genderKey]) {
rowObj.pct_of_gender = (100 * sum) / genderTotals[genderKey];
}
return [
rowObj,
...recurse(
rows.filter((r) => r.parts[depth] === k),
depth + 1,
current
)
];
});
};
return recurse(rows);
};
return summarize_icicle(csv, icicleKeys);
}
// Create insight data structure
icicle_insight = {
if (!icicle_pct_table || icicle_pct_table.length === 0) return {};
const round = 1;
// Only sum leaf-level rows (most specific combinations) to avoid double-counting
const leafRows = icicle_pct_table.filter(d => d.gender && d.poverty && d.education);
const sum = (filter) =>
+d3
.sum(leafRows.filter(filter), (d) => d.pct_of_gender) // Total as % of gender not total pop
.toFixed(round);
// Helper function to find single value
const find = (filter) =>
+(icicle_pct_table.find(filter)?.pct_total || 0).toFixed(round);
return {
// Total population by gender
male: +d3.sum(leafRows.filter(d => d.gender === "male"), d => d.pct_total).toFixed(round),
female: +d3.sum(leafRows.filter(d => d.gender === "female"), d => d.pct_total).toFixed(round),
// Education levels by gender (using pct_of_gender)
sec_edu_m: sum((d) => d.gender === "male" && d.education === "primary"),
sec_edu_f: sum((d) => d.gender === "female" && d.education === "primary"),
no_edu_m: sum((d) => d.gender === "male" && d.education === "noprimary"),
no_edu_f: sum((d) => d.gender === "female" && d.education === "noprimary"),
// Poverty levels by gender (using pct_of_gender)
high_poverty_m: sum((d) => d.gender === "male" && d.poverty === "high"),
high_poverty_f: sum((d) => d.gender === "female" && d.poverty === "high"),
low_poverty_m: sum((d) => d.gender === "male" && d.poverty === "low"),
low_poverty_f: sum((d) => d.gender === "female" && d.poverty === "low"),
// Most vulnerable group (high poverty + no education)
most_vuln_m: find(
(d) =>
d.gender === "male" &&
d.poverty === "high" &&
d.education === "noprimary"
),
most_vuln_f: find(
(d) =>
d.gender === "female" &&
d.poverty === "high" &&
d.education === "noprimary"
)
};
}// Generate a string that describes the points of a breadcrumb SVG polygon.
function breadcrumbPoints(d, i) {
const tipWidth = 10;
const points = [];
points.push("0,0");
points.push(`${breadcrumbWidth},0`);
points.push(`${breadcrumbWidth + tipWidth},${breadcrumbHeight / 2}`);
points.push(`${breadcrumbWidth},${breadcrumbHeight}`);
points.push(`0,${breadcrumbHeight}`);
if (i > 0) {
// Leftmost breadcrumb; don't include 6th vertex.
points.push(`${tipWidth},${breadcrumbHeight / 2}`);
}
return points.join(" ");
}icicle_dict = new Object({
gender0: { en: "Male", fr: "" },
gender1: { en: "Female", fr: "" },
poverty1: { en: "Low Poverty", fr: "" },
poverty2: { en: "Moderate Poverty", fr: "" },
poverty3: { en: "High Poverty", fr: "" },
education3: { en: "Secondary Education", fr: "" },
education2: { en: "Primary Education", fr: "" },
education1: { en: "No Primary Education", fr: "" },
});
icicle_color = d3
.scaleOrdinal()
.domain([
"population",
"gender1", // female
"gender0", // male
"poverty1", // low poverty = good // TODO: Check the directionality of this in preprocess code
"poverty2", // mid. poverty = mid
"poverty3", // high poverty = bad
"education3", // high edu. = good
"education2", // mid. edu. = mid
"education1", // low edu = bad
])
.range([
"#a4a4a4", // population - removed
"#59CD90", // female - #59CD90
"#A491D3", // male
"#f4bb21", // low poverty = good
"#fc8a34", // mid. poverty = mid
"#ec5a47", // high poverty = bad
"#f4bb21", // high edu = good
"#fc8a34", // mid. edu = mid
"#ec5a47", // low edu = bad
]);breadcrumb = {
// Only show breadcrumb if there's a sequence to display
// if (!icicle.sequence || icicle.sequence.length === 0) {
// return htl.html`<div style="height: ${breadcrumbHeight}px;"></div>`;
// }
const renderBreadcrumb = () => {
const svg = d3
.create("svg")
.attr("viewBox", `0 0 ${breadcrumbWidth * 4.75} ${breadcrumbHeight}`)
.style("font", "13px sans-serif")
.style("margin", "2px")
.style("display", "block");
const g = svg
.selectAll("g")
.data(icicle.sequence)
.join("g")
.attr("transform", (d, i) => `translate(${i * breadcrumbWidth}, 0)`);
g.append("polygon")
.attr("points", breadcrumbPoints)
.attr("fill", (d) => {
return icicle_color(d.data.name)
})
.attr("stroke", "white");
g.append("text")
.attr("x", (breadcrumbWidth + 10) / 2)
.attr("y", 15)
.attr("dy", "0.35em")
.attr("text-anchor", "middle")
.attr("fill", "white")
.attr("font-size", "12px")
.text((d) => {
const name = d.data.name;
return _lang(icicle_dict[name]);
// Default fallback - capitalize and replace underscores
// return name.charAt(0).toUpperCase() + name.slice(1).replace(/_/g, ' ');
});
// Always show percentage at the end of breadcrumb when there's a sequence
if (icicle.sequence && icicle.sequence.length > 0) {
const percentage = Number(icicle.percentage) || 0;
svg
.append("text")
.text(percentage > 0 ? percentage.toFixed(1) + "%" : "")
.attr("x", (icicle.sequence.length + 0.25) * breadcrumbWidth)
.attr("y", breadcrumbHeight / 2)
.attr("dy", "0.35em")
.attr("text-anchor", "middle")
.attr("font-size", "12px")
.attr("fill", "black")
.attr("font-weight", "bold");
}
return svg.node();
};
return htl.html`
<div style="height: ${breadcrumbHeight + 3}px; overflow: hidden;">
${icicle.sequence && icicle.sequence.length > 0
? renderBreadcrumb(icicle.sequence)
: ""}
</div>
`;
}viewof icicle = {
// Early return for loading state
if (csv_raw === null || csv_raw === undefined || icicleKeys === null || icicleKeys === undefined) {
const loading = createLoadingState(_lang({
en: "Loading sensitivity data...",
fr: "Chargement des données de sensibilité..."
}));
const element = document.createElement('div');
element.innerHTML = loading.outerHTML;
element.value = { sequence: [], percentage: 0.0 };
return element;
}
// Early return for empty data
if (!csv_raw || csv_raw.length === 0 || !icicleKeys) {
const noData = createNoDataState(_lang(vulnerability_translations.no_data_available));
const element = document.createElement('div');
element.innerHTML = noData.outerHTML;
element.value = { sequence: [], percentage: 0.0 };
return element;
}
// Configuration constants
const config = {
gutter: 80,
heightMultiplier: 1.5, // Increase plot height by 50%
layerLabels: ["Gender", "Poverty", "Education"],
order: {
poverty1: 2,
poverty2: 1,
poverty3: 0,
education1: 0,
education2: 1,
education3: 2
},
legend: {
x: 1,
y: 5,
width: 300,
height: 30,
cornerRadius: 10,
itemSpacing: 100,
itemOffset: 25
}
};
const adjustedHeight = height * config.heightMultiplier;
// Create adjusted partition function that uses the taller height
const adjustedPartition = (data, order) => {
// Call the original partition but we'll scale the y-values
const originalRoot = partition(data, order);
// Scale all y-values by the height multiplier
const scaleNode = (node) => {
node.y0 *= config.heightMultiplier;
node.y1 *= config.heightMultiplier;
if (node.children) {
node.children.forEach(scaleNode);
}
};
scaleNode(originalRoot);
return originalRoot;
};
const root = adjustedPartition(data, config.order);
// State management
let frozen = false;
let frozenSequence = [];
// Create SVG element
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width + config.gutter} ${adjustedHeight}`)
.style("font", "12px sans-serif");
const element = svg.node();
element.value = { sequence: [], percentage: 0.0 };
// Main plot group
const plot = svg.append("g")
.attr("transform", `translate(${config.gutter},0)`);
// Background rect
plot.append("rect")
.attr("width", width)
.attr("height", adjustedHeight)
.attr("fill", "none");
// Helper function to determine text color based on background
const getTextColor = (bgColor) => {
return ["#A491D3", "#ec5a47", "#fc8a34"].includes(bgColor) ? "white" : "black";
};
// Helper function to update element value
const updateElementValue = (sequence, percentage) => {
element.value = { sequence, percentage };
element.dispatchEvent(new CustomEvent("input"));
};
// Helper function to update segment opacity
const updateSegmentOpacity = (targetSequence) => {
segment.attr("fill-opacity", node =>
targetSequence.indexOf(node) >= 0 ? 1.0 : 0.3
);
};
// Create segments
const segment = plot.append("g")
.attr("transform", narrow
? `translate(${-root.y1}, 40)`
: `translate(0, ${-root.y1 + 40})`)
.selectAll("rect")
.data(root.descendants().filter(d => d.depth))
.join("rect")
.attr("fill", d => icicle_color(d.data.name))
.attr("x", segmentX)
.attr("y", segmentY)
.attr("width", segmentWidth)
.attr("height", segmentHeight)
.on("mouseenter", (event, d) => {
if (frozen) return;
const sequence = d.ancestors().reverse().slice(1);
updateSegmentOpacity(sequence);
const percentage = (100 * (d.value / root.value)).toPrecision(3);
updateElementValue(sequence, percentage);
})
.on("click", (event, d) => {
if (frozen) {
frozen = false;
return;
}
frozen = true;
frozenSequence = d.ancestors().reverse().slice(1);
updateSegmentOpacity(frozenSequence);
const percentage = (100 * (d.value / root.value)).toPrecision(3);
updateElementValue(frozenSequence, percentage);
event.stopPropagation();
});
// Add layer labels
const depthNodes = d3.rollups(
root.descendants().filter(d => d.depth),
v => v[0],
d => d.depth
).sort((a, b) => d3.ascending(a[0], b[0]));
plot.append("g")
.attr("transform", narrow
? `translate(${-root.y1}, 40)`
: `translate(0, ${-root.y1 + 40})`)
.selectAll("text.layer-label")
.data(depthNodes.map(([depth, rep]) => ({
depth,
y: segmentY(rep) + segmentHeight(rep) / 2
})))
.join("text")
.attr("class", "layer-label")
.attr("x", -12)
.attr("y", d => d.y)
.attr("dy", "0.35em")
.attr("text-anchor", "end")
.attr("font-size", "12px")
.attr("font-weight", "600")
.text(d => config.layerLabels[d.depth - 1] ?? `Layer ${d.depth}`);
// Add percentage labels
plot.append("g")
.attr("transform", narrow
? `translate(${-root.y1}, 40)`
: `translate(0, ${-root.y1 + 40})`)
.selectAll("text")
.data(root.descendants().filter(d => d.depth && d.data.name !== "population"))
.join("text")
.attr("x", d => segmentX(d) + segmentWidth(d) / 2)
.attr("y", d => segmentY(d) + segmentHeight(d) / 2)
.attr("dy", "0.35em")
.attr("text-anchor", "middle")
.attr("font-size", "12px")
.attr("font-weight", "bold")
.attr("fill", d => getTextColor(icicle_color(d.data.name)))
.attr("pointer-events", "none")
.text(d => {
const dataObj = d?.data;
const genderName = dataObj.name.startsWith("gender")
? _lang(icicle_dict[dataObj.name])
: null;
const genderStr = genderName ? `${genderName} - ` : "";
const percentage = (100 * (d.value / root.value)).toFixed(1);
return segmentWidth(d) > 30 && segmentHeight(d) > 15
? `${genderStr}${percentage}%`
: "";
});
// Event handlers
const handleMouseLeave = () => {
if (frozen) return;
segment.attr("fill-opacity", 1);
updateElementValue([], 0.0);
};
const handleClick = () => {
if (frozen) {
frozen = false;
frozenSequence = [];
segment.attr("fill-opacity", 1);
updateElementValue([], 0.0);
}
};
plot.on("mouseleave", handleMouseLeave);
plot.on("click", handleClick);
// Cleanup mechanism
if (element.dispose) element.dispose();
element.dispose = () => {
plot.on("mouseleave", null).on("click", null);
segment.on("mouseenter", null).on("click", null);
};
// Add legend
const colorScale = d3.scaleOrdinal()
.domain(["Better", "Moderate", "Worse"])
.range(["#F4BB21", "#FC8A34", "#EC5A47"]);
svg.append("rect")
.attr("x", config.legend.x)
.attr("y", config.legend.y)
.attr("rx", config.legend.cornerRadius)
.attr("ry", config.legend.cornerRadius)
.attr("width", config.legend.width)
.attr("height", config.legend.height)
.attr("fill", "#fff");
const legend = svg.selectAll(".legend")
.data(colorScale.domain())
.enter()
.append("g")
.attr("class", "legend")
.attr("transform", (d, i) =>
`translate(${i * config.legend.itemSpacing + config.legend.itemOffset}, 11)`);
legend.append("rect")
.attr("x", 0)
.attr("width", 18)
.attr("height", 18)
.style("fill", colorScale);
legend.append("text")
.attr("x", 24)
.attr("y", 9)
.attr("dy", ".35em")
.text(d => d);
return element;
}// Download functionality
sensitivityDownloadButton = {
if (!icicle_pct_table || icicle_pct_table.length === 0) {
return htl.html``;
}
return downloadButton(
icicle_pct_table,
`sensitivity_data_${getSensitivityAdminSelection().replace(/\s+/g, '_')}`,
_lang(vulnerability_translations.download_data)
);
}// Dynamic insights - reactive to icicle selection
sensitivityInsights = {
if (!data || data.length === 0 || !csv || csv.length === 0 || !icicle_insight) {
return createNoDataState();
}
const region = getSensitivityAdminSelection();
const insight = icicle_insight;
// Generate default insights (always shown)
const totalMale = insight.male;
const totalFemale = insight.female;
const mostVulnTotal = (insight.most_vuln_m + insight.most_vuln_f).toFixed(1);
const femalePct = insight.most_vuln_f.toFixed(1);
const malePct = insight.most_vuln_m.toFixed(1);
// Education comparison
const noEduMale = insight.no_edu_m.toFixed(1);
const noEduFemale = insight.no_edu_f.toFixed(1);
// Poverty comparison
const highPovMale = insight.high_poverty_m.toFixed(1);
const highPovFemale = insight.high_poverty_f.toFixed(1);
// Structure the default insights as separate parts for better HTML rendering
const defaultInsightParts = [
`In ${region}, men represent ${totalMale}% and women represent ${totalFemale}% of the population.`,
`Among the most vulnerable group (high poverty + no primary education), ${femalePct}% are female and ${malePct}% are male.`,
`Education gap: ${noEduFemale}% of women vs ${noEduMale}% of men have no primary education.`,
`Poverty gap: ${highPovFemale}% of women vs ${highPovMale}% of men live in high poverty areas.`
];
const defaultInsightText = defaultInsightParts.join('\n\n');
return createInsightDisplay(defaultInsightText);
}viewof adaptiveCapacityAdmin0 = Inputs.select(dataAdmin0, {
label: adminRegions.labels.admin0,
format: x => x.label,
value: dataAdmin0.find(d => d.value === sharedAdmin0) || (dataAdmin0.length > 0 ? dataAdmin0[0] : null)
})
viewof adaptiveCapacityAdmin1 = Inputs.select(dataAdmin1, {
label: adminRegions.labels.admin1,
format: x => x.label,
value: dataAdmin1.find(d => d.value === sharedAdmin1) || (dataAdmin1.length > 0 ? dataAdmin1[0] : null)
})
viewof adaptiveCapacityAdmin2 = Inputs.select(dataAdmin2, {
label: adminRegions.labels.admin2,
format: x => x.label,
value: dataAdmin2.find(d => d.value === sharedAdmin2) || (dataAdmin2.length > 0 ? dataAdmin2[0] : null)
})adaptiveCapacityControlsForm = htl.html`
<div style="
background: #f8fafc;
border: 2px solid #e2e8f0;
border-radius: 12px;
padding: 24px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.07);
margin: 16px 0;
">
<h3 style="margin: 0 0 20px 0; color: #2d3748; font-size: 1.1rem; font-weight: 600;">
${_lang(vulnerability_translations.geographic_selection)}
</h3>
<div style="
display: flex;
align-items: flex-start;
gap: 20px;
flex-wrap: wrap;
justify-content: space-between;
" class="form-inputs-container">
<div style="flex: 1; min-width: 180px; max-width: 100%;">${viewof adaptiveCapacityAdmin0}</div>
<div style="flex: 1; min-width: 180px; max-width: 100%;">${viewof adaptiveCapacityAdmin1}</div>
<div style="flex: 1; min-width: 180px; max-width: 100%;">${viewof adaptiveCapacityAdmin2}</div>
</div>
</div>
`// Load data with caching - using parquet via DuckDB
adaptiveCapacityRawData = await loadDataWithCache(
"adaptiveCapacityData",
async () => {
const db = await DuckDBClient.of({
enabling_vars: FileAttachment(
"/data/vulnerability_notebook/enabling_vars.parquet",
),
});
const data = await db.query("SELECT * FROM enabling_vars");
return data;
},
);// Process the new normalized data structure
adaptiveCapacityData = {
if (!adaptiveCapacityRawData || !Array.isArray(adaptiveCapacityRawData)) {
console.error("adaptiveCapacityRawData is not valid:", adaptiveCapacityRawData);
return [];
}
return adaptiveCapacityRawData.map(d => ({
...d,
// Use the new normalized columns directly (0-1 scale, high = good)
pct_electric_norm: d.pct_electric_norm,
pct_piped_water_norm: d.pct_piped_water_norm,
min_to_cities_norm: d.min_to_cities_norm,
conflict_density_norm: d.conflict_density_norm,
pct_cellphone_norm: d.pct_cellphone_norm,
// Keep original values for tooltips
pct_electric: d.pct_electric,
pct_piped_water: d.pct_piped_water,
min_to_cities: d.min_to_cities,
conflict_density: d.conflict_density,
pct_cellphone: d.pct_cellphone
}))
}// Filter data based on admin selections (same pattern as sensitivity)
filteredAdaptiveCapacityData = {
const selections = adaptiveCapacityAdminSelections;
if (!adaptiveCapacityData || adaptiveCapacityData.length === 0) {
console.error("adaptiveCapacityData is empty or undefined");
return [];
}
let data = adaptiveCapacityData;
if (!selections.selectAdmin0) {
data = data.filter((d) => (d.admin0_name === "SSA" || !d.admin0_name)) // Select just SSA data & do not show regional avg
} else if (!selections.selectAdmin1) {
data = data.filter((d) => (d.admin0_name === selections.selectAdmin0))
} else if (selections.selectAdmin1 && !selections.selectAdmin2) {
data = data.filter((d)=> (d.admin0_name === selections.selectAdmin0 && d.admin1_name === selections.selectAdmin1))
} else if (selections.selectAdmin1 && selections.selectAdmin2) {
data = data.filter((d) => (d.admin0_name === selections.selectAdmin0 && d.admin1_name === selections.selectAdmin1 && d.admin2_name === selections.selectAdmin2))
}
return data
}{
// Check if adaptiveCapacityRawData exists and is an array before processing
if (!adaptiveCapacityRawData || !Array.isArray(adaptiveCapacityRawData)) {
return createNoDataState();
}
let data = adaptiveCapacityRawData;
const selections = adaptiveCapacityAdminSelections;
const isSSA = selections.selectAdmin0 === "SSA" || !selections.selectAdmin0;
// Data for the blue bar - the lowest selected region
let region_data = data.filter(
(d) =>
d.admin0_name === (selections.selectAdmin0 || "SSA") &&
d.admin1_name === selections.selectAdmin1 &&
d.admin2_name === selections.selectAdmin2,
);
let region_name = getAdaptiveCapacityRegion();
// Data for the grey bar which is the parent
// eg. region = AGO so parent = SSA or region = nairobi so parent = kenya
let parent_data = [];
let parent_name = "";
if (!selections.selectAdmin0) {
// Top-level (SSA selected so no parent data is returned)
parent_data = [];
} else if (!selections.selectAdmin1) {
// admin0 selected but no admin1 so parent = SSA
parent_data = data.filter((d) => d.admin0_name === "SSA");
parent_name = "SSA";
} else if (!selections.selectAdmin2) {
// admin1 selected but no admin2 so parent = admin0 selection
parent_data = data.filter(
(d) => d.admin0_name === selections.selectAdmin0 && !d.admin1_name,
);
parent_name = selections.selectAdmin0;
} else {
// admin2 selected so parent = admin1 selected
parent_data = data.filter(
(d) =>
d.admin0_name === selections.selectAdmin0 &&
d.admin1_name === selections.selectAdmin1 &&
!d.admin2_name,
);
parent_name = selections.selectAdmin1;
}
// Define the indicators to display using new normalized data structure
const indicators = [
{
key: "pct_electric_norm",
rawKey: "pct_electric",
label: _lang({ en: "Electricity Access", fr: "Accès à l'électricité" }),
unit: "%",
},
{
key: "pct_piped_water_norm",
rawKey: "pct_piped_water",
label: _lang({ en: "Piped Water Access", fr: "Accès à l'eau courante" }),
unit: "%",
},
{
key: "min_to_cities_norm",
rawKey: "min_to_cities",
label: _lang({
en: "Distance to Market",
fr: "Temps de trajet vers les villes",
}),
unit: " min.",
},
{
key: "pct_cellphone_norm",
rawKey: "pct_cellphone",
label: _lang({
en: "Cellphone Access",
fr: "Accès au téléphone portable",
}),
unit: "%",
},
{
key: "conflict_density_norm",
rawKey: "conflict_density",
label: _lang({ en: "Lack of Conflict", fr: "Densité de conflit" }),
unit: " count/km²",
},
];
const chartData = indicators.map((indicator) => {
// Get normalized value (0-1 scale, high = good)
// Check if region_data has elements before accessing [0]
const region_normalizedValue = region_data.length > 0
? (region_data[0]?.[indicator.key] || null)
: null;
const parent_normalizedValue = parent_data.length > 0
? (parent_data[0]?.[indicator.key] || null)
: null;
// Get original value for tooltips
const region_rawValue = region_data.length > 0
? (region_data[0]?.[indicator.rawKey] || null)
: null;
const parent_rawValue = parent_data.length > 0
? (parent_data[0]?.[indicator.rawKey] || null)
: null;
return {
indicator: indicator.label,
region_normalizedValue, // Use normalized value for chart
region_rawValue, // Keep original for tooltips
parent_normalizedValue,
parent_rawValue,
unit: indicator.unit,
};
});
// const sortedData = chartData.sort((a, b) => b.value - a.value);
// sort alphabetically so consistent between selections to allow easy comparisons
const sortedData = chartData.sort((a, b) => a.indicator.localeCompare(b.indicator));
const maxValue = 1.0; // Technically some values pass this, but in general this should be max
const plot_channels = {
[_lang({en: `${region_name} Value`, fr: "Valeur de la région sélectionnée"})]:
d => d.region_rawValue == null
? null // 0 is meaningful so this stays null
: Number(d.region_rawValue).toFixed(1) + (d.unit || "")
};
if (!isSSA) {
plot_channels[_lang({en: `${parent_name} Value`, fr: "Moyenne régionale"})] =
d => d.parent_rawValue == null
? null // 0 is meaningful so this stays null
: Number(d.parent_rawValue).toFixed(1) + (d.unit || "")
};
const plot = Plot.plot({
width: 1000, // Full width
height: 400,
marginLeft: 160, // Reduced for better space utilization
marginRight: 40, // Reduced for better space utilization
marginTop: 10, // Reduced white space
marginBottom: 40, // Reduced white space
color: {
legend: !isSSA, // Only show legend if not SSA
domain: [
_lang({en: `${region_name} Value`, fr: `${region_name} Value`}),
_lang({en: `${parent_name} Value`, fr: "Moyenne régionale"})
],
range: ["#4a90e2", "#e5e5e5"]
},
x: {
label: _lang({en: "Ability to Adapt", fr: "Niveau de performance"}),
labelAnchor: "center",
labelOffset: 30,
domain: [0, maxValue], // Normalized scale 0-1
tickFormat: (d) => (d === 0 ? _lang({en: "Low", fr: "Faible"}) : d === 1 ? _lang({en: "High", fr: "Élevé"}) : ""),
ticks: [0, 0.5, 1.0],
tickSize: 4,
labelFontSize: 12,
labelFontWeight: "600"
},
y: {
label: null, // Remove y-axis label to save space
tickSize: 0,
grid: true,
// domain: sortedData.map(d => d.indicator) // Only show indicators with data
},
marks: [
// Regional average bars (light gray background) - full width
Plot.barX(sortedData, {
x: "parent_normalizedValue",
y: "indicator",
fill: "#e5e5e5",
opacity: 0.7
}),
// Actual value bars (blue bars) - narrower using inset
Plot.barX(sortedData, {
x: "region_normalizedValue",
y: "indicator",
fill: "#4a90e2",
insetTop: 15, // Make bars narrower
insetBottom: 15,
channels: plot_channels,
tip: {
anchor: "top", // Position above the blue bar
dy: -10, // Offset upward
format: {
x: false
}
}
})
]
});
return plot;
}// Download functionality
adaptiveCapacityDownloadButton = {
if (!filteredAdaptiveCapacityData || filteredAdaptiveCapacityData.length === 0) {
return htl.html``;
}
const region = getAdaptiveCapacityRegion();
return downloadButton(
filteredAdaptiveCapacityData,
`adaptive_capacity_data_${region.replace(/\s+/g, '_')}`,
_lang(vulnerability_translations.download_data)
);
}// Dynamic Insights following wireframe template
adaptiveCapacityInsights = {
if (!filteredAdaptiveCapacityData || filteredAdaptiveCapacityData.length === 0) {
return createNoDataState(); // Use same function as exposure
}
const data = filteredAdaptiveCapacityData[0];
const region = getAdaptiveCapacityRegion();
// Helper function to determine threshold level
const getThresholdLevel = (value, thresholds, isInverse = false) => {
if (isInverse) {
// For inverse indicators (lower is better)
if (value <= thresholds.low[0]) return _lang({en: "high", fr: "élevé"});
if (value <= thresholds.mid[0]) return _lang({en: "moderate", fr: "modéré"});
return _lang({en: "low", fr: "faible"});
} else {
// For normal indicators (higher is better)
if (value >= thresholds.high[0]) return _lang({en: "high", fr: "élevé"});
if (value >= thresholds.mid[0]) return _lang({en: "moderate", fr: "modéré"});
return _lang({en: "low", fr: "faible"});
}
};
// Create indicators using new normalized data structure
const indicators = [
{
key: "pct_electric_norm",
originalKey: "pct_electric",
label: _lang({en: "Electricity Access", fr: "Accès à l'électricité"}),
originalValue: data.pct_electric,
normalizedValue: data.pct_electric_norm,
unit: "%"
},
{
key: "pct_piped_water_norm",
originalKey: "pct_piped_water",
label: _lang({en: "Piped Water Access", fr: "Accès à l'eau courante"}),
originalValue: data.pct_piped_water,
normalizedValue: data.pct_piped_water_norm,
unit: "%"
},
{
key: "min_to_cities_norm",
originalKey: "min_to_cities",
label: _lang({en: "Travel Time to Cities", fr: "Temps de trajet vers les villes"}),
originalValue: data.min_to_cities,
normalizedValue: data.min_to_cities_norm,
unit: " min"
},
{
key: "pct_cellphone_norm",
originalKey: "pct_cellphone",
label: _lang({en: "Cellphone Access", fr: "Accès au téléphone portable"}),
originalValue: data.pct_cellphone,
normalizedValue: data.pct_cellphone_norm,
unit: "%"
},
{
key: "conflict_density_norm",
originalKey: "conflict_density",
label: _lang({en: "Conflict Density", fr: "Densité de conflit"}),
originalValue: data.conflict_density,
normalizedValue: data.conflict_density_norm,
unit: " count/km²"
}
];
// Format indicators with performance levels based on normalized values
const thresholdIndicators = indicators.map(indicator => {
const normalizedValue = typeof indicator.normalizedValue === 'number' ? indicator.normalizedValue : 0;
const originalValue = typeof indicator.originalValue === 'number' ? indicator.originalValue : 0;
// Determine performance level based on normalized value (0-1 scale)
let level;
if (normalizedValue >= 0.8) {
level = _lang({en: "high", fr: "élevé"});
} else if (normalizedValue >= 0.5) {
level = _lang({en: "moderate", fr: "modéré"});
} else {
level = _lang({en: "low", fr: "faible"});
}
const numValue = Number(originalValue) || 0;
const formattedValue = numValue < 1 ? numValue.toFixed(3) : numValue.toFixed(1);
return `${indicator.label}: ${formattedValue}${indicator.unit} (${level})`;
});
// Identify limiting factors based on the exact format specified
const limitingFactors = [];
// Check each indicator for limiting factors using normalized values
indicators.forEach(indicator => {
const normalizedValue = typeof indicator.normalizedValue === 'number' ? indicator.normalizedValue : 0;
const originalValue = typeof indicator.originalValue === 'number' ? indicator.originalValue : 0;
// Determine if this is a limiting factor (low performance)
if (normalizedValue < 0.5) {
const numValue = Number(originalValue) || 0;
let formattedValue;
if (indicator.originalKey === "pct_electric") {
formattedValue = `${numValue.toFixed(1)}%`;
} else if (indicator.originalKey === "min_to_cities") {
formattedValue = `${numValue.toFixed(0)} min`;
} else if (indicator.originalKey === "inet_d_kbps") {
formattedValue = `${(numValue/1000).toFixed(1)} Mbps`;
} else if (indicator.originalKey === "pct_piped_water") {
formattedValue = `${numValue.toFixed(0)}%`;
} else if (indicator.originalKey === "pct_cellphone") {
formattedValue = `${numValue.toFixed(0)}%`;
} else if (indicator.originalKey === "conflict_density") {
formattedValue = `${(numValue * 1000).toFixed(1)} per 1000 km²`;
} else {
formattedValue = `${numValue.toFixed(1)}${indicator.unit}`;
}
limitingFactors.push(`${indicator.label} (${formattedValue})`);
}
});
// Generate insight text in the exact format specified
const limitationLevel = limitingFactors.length > 2 ?
_lang({en: "significantly limited", fr: "significativement limitée"}) :
limitingFactors.length > 0 ?
_lang({en: "limited", fr: "limitée"}) :
_lang({en: "strong", fr: "forte"});
// Format factors as HTML list
const factorsList = limitingFactors.length > 0 ?
limitingFactors.map(factor => htl.html`<li>${factor}</li>`) :
[htl.html`<li>${_lang({en: "Strong infrastructure and services", fr: "Infrastructure et services solides"})}</li>`];
const recommendation = limitingFactors.length > 0 ?
_lang({en: "Addressing these can improve resilience.", fr: "Traiter ces problèmes peut améliorer la résilience."}) :
_lang({en: "Continue strengthening these systems.", fr: "Continuer à renforcer ces systèmes."});
const insight = htl.html`<div>
<p>${_lang({
en: `In ${region}, adaptive capacity is ${limitationLevel} by:`,
fr: `En ${region}, la capacité d'adaptation est ${limitationLevel} par :`
})}</p>
<ul>${factorsList}</ul>
<p>${recommendation}</p>
</div>`;
return createInsightDisplay(insight);
}// About this notebook
aboutSection = htl.html`
<div>
<h3 style="margin: 0 0 16px 0; color: #2d3748;">
${_lang({ en: "About This Notebook", fr: "À propos de ce carnet" })}
</h3>
<p style="margin: 0 0 12px 0; line-height: 1.6;">
${_lang({
en: "This vulnerability assessment notebook helps you understand climate vulnerability through three key dimensions: exposure to climate hazards, sensitivity of populations, and adaptive capacity of communities.",
fr: "Ce carnet d'évaluation de la vulnérabilité vous aide à comprendre la vulnérabilité climatique à travers trois dimensions clés : l'exposition aux dangers climatiques, la sensibilité des populations et la capacité d'adaptation des communautés.",
})}
</p>
<p style="margin: 0 0 12px 0; line-height: 1.6;">
${_lang({
en: "The notebook uses interactive visualizations to explore vulnerability patterns across Sub-Saharan Africa, with data that can be filtered by administrative regions and demographic characteristics.",
fr: "Le carnet utilise des visualisations interactives pour explorer les schémas de vulnérabilité à travers l'Afrique subsaharienne, avec des données qui peuvent être filtrées par régions administratives et caractéristiques démographiques.",
})}
</p>
<p style="margin: 0; line-height: 1.6; font-size: 14px; color: #6b7280;">
${_lang({
en: "For questions or feedback about this notebook, please contact the Adaptation Atlas team.",
fr: "Pour des questions ou commentaires sur ce carnet, veuillez contacter l'équipe de l'Atlas d'adaptation.",
})}
</p>
</div>
`;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";
// Style the language selector for navbar integration
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";
navEnd.parentNode.appendChild(lang_sel);
}
}
}
NavbarLangSelector(languages, masterLanguage)