The Africa Agriculture Adaptation Atlas The Africa Agriculture Adaptation Atlas
  • Documentation
  • Report an Issue
import {
  atlasHero,
  downloadButton,
  multiLineText,
  loaderDiv,
} from "/helpers/uiComponents.ojs";

import { filterableDataTable as dataTable } from "/components/atlasTable.ojs";

import { cleanAdminInput_SQL, patchWindowsCache } from "/helpers/data.js";

import { enhancedMultiSelect } from "/helpers/enhancedMultiSelect.ojs";

import { atlasTOC } from "/helpers/toc.ojs";

atlasHero(nbTitle, "../../images/default_crop.webp");
atlasTOC({
  skip: ["notebook-title", "appendix", "source-code"],
  heading: `<b>${Lang.toSentenceCase(_lang(general_translations.toc))}</b>`,
});

md`${_lang(nbText.sections.intro.text)}`; // wrapped in md for lists to work

section1A0 = renderA0Multi({ maxSelections: 2, requireAtLeastOne: true });
section1A1 = renderA1Multi({ maxSelections: 3 });
inputTemplate()([section1A0, section1A1]);
povBar_keyFacts();
gdpBar_keyFacts();
areaBar_keyFacts();

createCountryInsights([agInsight, povInsight]);
viewof prod_type = Inputs.select([null, ...Object.keys(exposure_groups)], {
  format: (d) => _lang(cropTranslations[d]) || "All Commodities",
  value: null,
  label: "Production Type",
});

exposureBars_keyFacts();

createCountryInsights([exposureInsight]);

section2A0 = renderA0Multi({ maxSelections: 2, requireAtLeastOne: true });
section2A1 = renderA1Multi({ maxSelections: 3 });
inputTemplate()([section2A0, section2A1]);
climateForm();
viewof viewRecentChanges = Inputs.radio(
  ["divergingBar", "warmingStripes", "table"],
  {
    label: "View Type",
    value: "divergingBar",
  }
);
loaderDiv("plotRecentChanges");
{
  renderToDiv("plotRecentChanges", () => {
    if (viewRecentChanges === "divergingBar") return barplot_recentChanges();
    if (viewRecentChanges === "warmingStripes") {
      return warmingStripes_recentChanges();
    }
    return dataTable(recentChanges_plotData, {
      format: {
        year: (d) => d,
      },
      header: {
        iso3: "ISO3",
        admin0_name: "Country",
        admin1_name: "Admin 1",
        season: "Season",
        year: "Year",
        hazard: "Hazard",
        mean: "Mean",
        mean_anomaly: "Mean Anomaly",
      },
      columns: [
        "iso3",
        "admin0_name",
        "admin1_name",
        "season",
        "year",
        "hazard",
        "mean",
        "mean_anomaly",
      ],
    });
  });
}

seasonInsight();
createCountryInsights([climateInsight]);

section3A0 = renderA0Multi({ maxSelections: 2, requireAtLeastOne: true });
section3A1 = renderA1Multi({ maxSelections: 3 });
inputTemplate()([section3A0, section3A1]);
climateForm();
scenarioForm();
viewof viewFutureChanges = Inputs.radio(["plot", "table"], {
  label: "View Type",
  value: "plot",
});
loaderDiv("plotFutureProjections");
{
  renderToDiv("plotFutureProjections", () => {
    if (viewFutureChanges === "plot") return timeseries_futureProjections();
    return dataTable(futureProjections_plotData);
  });
}

seasonInsight();
createCountryInsights([climateProjectionInsight]);

section4A0 = renderA0Multi({ maxSelections: 2, requireAtLeastOne: true });
section4A1 = renderA1Multi({ maxSelections: 3 });
inputTemplate()([section4A0, section4A1]);
climateForm();
scenarioForm();
viewof viewExtremeEvents = Inputs.radio(["plot", "table"], {
  label: "View Type",
  value: "plot",
});
loaderDiv("plotExtremeEvents");
{
  renderToDiv("plotExtremeEvents", () => {
    if (viewExtremeEvents === "plot") return bars_extremeEvents();
    return dataTable(extremeEvents_plotData);
  });
}

seasonInsight();
createCountryInsights([extremeEventsInsight]);

section5A0 = renderA0Multi({ maxSelections: 2, requireAtLeastOne: true });
section5A1 = renderA1Multi({ maxSelections: 3 });
inputTemplate()([section5A0, section5A1]);
scenarioForm();
Inputs.bind(
  Inputs.select([null, ...Object.keys(exposure_groups)], {
  format: (d) => d || "All Commodities",
    value: null,
    label: "Production Type",
  }), 
  viewof prod_type
);
loaderDiv("plotHazardExposure");
{
  renderToDiv("plotHazardExposure", stackbars_hazardExposure);
}

Data Sources

Methods

Source code

Text and Language Translations

import { lang as Lang } from "/helpers/lang.js";

general_translations = await FileAttachment(
  "/data/shared/generalTranslations.json",
).json();

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)

viewof masterLanguage = Inputs.radio(languages, {
  label: "Main language toggle",
  format: (d) => d.key,
  value: languages.find((x) => x.key === defaultLangKey),
})
nbTitle = _lang({
  en: "Formulate A Climate Rationale",
  fr: "Formuler une justification climatique",
});

nbText = await FileAttachment("/data/climateRationale/nbText.json").json();

heading1 = _lang(general_translations.overview);
heading2 = _lang(nbText.sections.keyFacts.title);
heading3 = _lang(nbText.sections.recentChanges.title);
heading4 = _lang(nbText.sections.futureProjections.title);
heading5 = _lang({ en: "Extreme Events", fr: "Evenements extremes" }); //nbText.sections.extremeEvents.title
heading6 = _lang(nbText.sections.hazardExposure.title);
summary = _lang(general_translations.summary);
appendix = _lang(general_translations.appendix);
methodsData = _lang(general_translations.methodsData);
adminNames = await FileAttachment("/data/shared/atlas_countries.json").json();

iso3ToTranslation = Object.fromEntries(
  adminNames.map(({ iso3c, translation }) => [iso3c, translation]),
);

General

formatters

import {
  inputTemplate,
  formatNumCompactShort,
  formatUSD,
  generateDB,
  wrapTickLabel,
} from "/helpers/std.ojs";
sqlWhereNull = ({ value, field } = {}) => {
  return value === null ? `and ${field} is null` : `and ${field} = '${value}'`;
};

sqlList = (values = []) => `('${values.join("', '")}')`;

withAdminName = (rows = []) =>
  rows.map((row) => ({
    ...row,
    adminName: row.admin1_name ? `${row.admin1_name} (${row.iso3})` : row.iso3,
  }));

renderToDiv = (id, render) => {
  const div = document.getElementById(id);
  const viz = render();
  div.replaceChildren(viz);
};

Styling

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)

Data

Database

data_obj = {
  const obj = await FileAttachment("/data/climateRationale/nbData.json").json();
  return obj.data
}

db ={
  const _cleaned = data_obj.map(item => ({
    ...item,
    s3_path: patchWindowsCache(item.s3_path)
  }));
  return await generateDB(
    // Future projection data is big, so split into different database
    _cleaned.filter((d) => !d.sections.includes("futureProjections")),
  );
}
dbFutureHive = {
    const fut = data_obj.filter((d) => d.sections.includes("futureProjections"));
    const _db = await DuckDBClient.of();
    await _db.query(`SET enable_object_cache = true;`)
    await _db.query(`SET enable_http_metadata_cache = true;`)
    const paths = fut.map((d) => {
      let p
      // if (d.local_path) {
      //   p = "http://localhost:4040" + d.local_path
      // } else {
      p = d.s3_path
      // }
      return `'${patchWindowsCache(p)}'`

    }) // `'${||d.s3_path}'
    await _db.query(`
      CREATE VIEW futureProjections as
        SELECT *, period as timeperiod
        FROM parquet_scan([
          ${paths.join(", ")}
        ],filename=true, hive_partitioning = 1)
    `)
  return _db
}

Sections

data - keyFacts

gdp_plotData = {
  const resp = await db.query(`
    SELECT iso3,
      sector,
      year,
      gdp_usd2015,
      ROUND(
          gdp_usd2015
            / SUM(CASE WHEN sector != 'total' THEN gdp_usd2015 END)
              OVER (PARTITION BY iso3, year),
          4
        ) * 100 AS pct_of_total
    FROM a0_gdp
    WHERE iso3 in ${sqlList(admin0Iso3)}
    AND sector != 'total'
    AND year = 2022
  `)
  return resp
}

landuse_plotData = {
  const resp = await db.query(`
    SELECT iso3,
      sub_group,
      "group",
      year,
      ha,
      ROUND(
         ha / SUM(ha) OVER (PARTITION BY iso3, year),
         4
        ) * 100 as ha_pct
    FROM a0_landuse
    WHERE iso3 in ${sqlList(admin0Iso3)}
    AND year = 2021
  `)
  return resp
}
pov_plotData = {
  const resp = await db.query(`
    SELECT 
      p.iso3,
      p.admin0_name,
      p.admin1_name,
      g.group_name AS group,
      ROUND(
        CASE 
          WHEN g.group_name = 'in_poverty' THEN p.poor420_ln * 100
          ELSE 100 - p.poor420_ln * 100
        END,
        2
      ) AS pov_rate,
    FROM poverty p
    CROSS JOIN (
      VALUES 
        ('in_poverty'),
        ('not_poverty')
    ) AS g(group_name)
    WHERE 1=1
    AND iso3 in ${sqlList(admin0Iso3)}
    AND (
     admin1_name in ${sqlList(admin1NamesClean)}
     OR admin1_name IS NULL
    )
  `);

  return withAdminName(resp)
}
exposure_plotData = {
  const resp = await db.query(`
  SELECT
    iso3,
    admin1_name,
    crop,
    value
  FROM exposure
    WHERE 1=1
    AND iso3 in ${sqlList(admin0Iso3)}
    AND (
     admin1_name in ${sqlList(admin1NamesClean)}
     OR admin1_name IS NULL
    )
  `);
  const cropData = resp.map((row) => {
    const category = cropCategoryMap.get(row.crop) || "other";
    return { ...row, category };
  });

  return withAdminName(cropData)
}

exposureMap = new Map(
  exposure_plotData.map(d => [
    `${d.iso3}|${d.admin1_name}|${d.crop}`,
    d.value
  ])
);

exposure_plotData
mapSpamCrops = await FileAttachment("/data/shared/MapSpamCrops.json").json();

cropTranslations = Object.fromEntries(
  Object.entries(mapSpamCrops).flatMap(([category, { label, items }]) => [
    [category, label],
    ...items.map(({ id, label }) => [id, label]),
  ]),
);

exposure_groups = Object.fromEntries(
  Object.entries(mapSpamCrops).map(([category, data]) => [
    category,
    data.items.map((item) => item.id),
  ]),
);

cropCategoryMap = new Map(
  Object.entries(exposure_groups).flatMap(([category, crops]) =>
    crops.map((crop) => [crop, category]),
  ),
);

data - recentChanges

recentChanges_data = {
  const _season = seasonSelect.season;
  // const _variable = climateVarSelect.id;

  const resp = await db.query(`
    SELECT
      iso3,
      admin0_name,
      admin1_name,
      season,
      year,
      hazard,
      mean,
      mean_anomaly
      FROM historic_climate_timeseries
      WHERE 1=1
      AND iso3 in ${sqlList(admin0Iso3)}
      AND (
       admin1_name in ${sqlList(admin1Names)}
       OR admin1_name IS NULL
      )
      ${sqlWhereNull({ field: "season", value: _season })}
  `);
  return withAdminName(resp);
}

recentChanges_plotData = recentChanges_data.filter((d) => d.hazard === climateVarSelect.id)

data - futureProjections

futureProjections_data = {
  const _season = seasonSelect.season;

  const resp = await dbFutureHive.query(`
  SELECT
    iso3,
    admin0_name,
    admin1_name,
    season,
    scenario,
    year,
    timeperiod,
    hazard,
    mean,
    mean_anomaly,
    max,
    max_anomaly,
    min,
    min_anomaly
  FROM futureProjections
    where 1=1
    AND iso3 in ${sqlList(admin0Iso3)}
    AND (
     admin1_name in ${sqlList(admin1Names)}
     OR admin1_name IS NULL
    )
    ${sqlWhereNull({ field: "season", value: _season })}
    AND timeperiod = '${futurePeriodSelect}'
    AND scenario in ${sqlList(futureScenarioSelect.map((d) => d.toLowerCase()))}
  `);

  return withAdminName(resp);
}

futureProjections_data;
futureProjections_plotData = futureProjections_data.filter((d) => d.hazard === climateVarSelect.id)

data - extremeEvents

function addZScores(data, groupKeys, field = "mean") {
  const groups = d3.group(data, (d) => groupKeys.map((k) => d[k]).join("|"));

  const result = [];

  for (const [_, records] of groups) {
    const mean = d3.mean(records, (d) => d[field]);
    const std = d3.deviation(records, (d) => d[field]);

    for (const d of records) {
      result.push({
        ...d,
        z: std === 0 ? 0 : (d[field] - mean) / std,
      });
    }
  }

  return result;
}

zThresholds = new Object({
  extreme_low: (z) => z <= -2,
  unusual_low: (z) => z > -2 && z <= -1,
  unusual_high: (z) => z >= 1 && z < 2,
  extreme_high: (z) => z >= 2,
});

function classifyZ(z) {
  return Object.keys(zThresholds).find((k) => zThresholds[k](z)) || null;
}

function aggregateEvents(data, scenario_name) {
  const groups = new Map();

  for (const d of data) {
    const key = `${d.iso3}|${d.admin0_name}|${d.admin1_name}|${d.hazard}|${d.season}|${d.scenario}`;

    if (!groups.has(key)) {
      groups.set(key, {
        iso3: d.iso3,
        admin0: d.admin0_name,
        admin1: d.admin1_name,
        adminName: d.adminName,
        hazard: d.hazard,
        scenario: d.scenario || scenario_name,
        // count number in each class from thresholds
        ...Object.fromEntries(Object.keys(zThresholds).map((k) => [k, 0])),
      });
    }

    const group = groups.get(key);
    const category = classifyZ(d.z);

    if (category) group[category] += 1;
  }

  return Array.from(groups.values());
}

extremeEvents_plotData = {
  const historicZ = addZScores(recentChanges_plotData, [
    "iso3",
    "admin0_name",
    "admin1_name",
    "season",
    "hazard",
  ])
  const historicData = aggregateEvents(historicZ, "historical")
  
  const futureZ = addZScores(futureProjections_plotData, [
    "iso3",
    "admin0_name",
    "admin1_name",
    "season",
    "hazard",
    "scenario",
  ])
  const futureData = aggregateEvents(futureZ)
  
  const data = [...historicData, ...futureData]

  const longData = data.flatMap(d =>
    Object.entries({
      extreme_low: d.extreme_low,
      unusual_low: d.unusual_low,
      unusual_high: d.unusual_high,
      extreme_high: d.extreme_high
    }).map(([category, value]) => ({
      iso3: d.iso3,
      admin0_name: d.admin0,
      admin1_name: d.admin1,
      scenario: d.scenario,
      adminName: d.adminName,
      hazard: d.hazard,
      category,
      num_events: value
    }))
  );
  return longData
}

data - hazardExposure

hazardExposure_plotData = {
  const resp = await db.query(`
  SELECT
    iso3,
    admin0_name,
    admin1_name,
    crop,
    hazard,
    timeframe,
    scenario,
    value
    FROM hazard_exposure
    where 1=1
    AND admin2_name IS NULL
    AND hazard_vars in ('NDWS+NTx35+NDWL0', 'NDWS+THI-max+NDWL0')
    AND exposure_unit = 'nominal-usd-2021'
    AND crop != 'generic-crop'
    AND iso3 in ${sqlList(admin0Iso3)}
    AND (
     admin1_name in ${sqlList(admin1Names)}
     OR admin1_name IS NULL
    )
    AND timeframe in ('1995-2014', '${futurePeriodSelect}')
    AND scenario in ${sqlList(['historic', ...futureScenarioSelect.map((d) => d.toLowerCase())])}
  `);

  // Add in total production not exposed
  // const merged = data.map(d => ({
  //   ...d,
  //   total_value: exposureMap.get(`${d.iso3}|${d.admin1_name}|${d.crop}`) ?? null
  // }));
  // return merged
  return withAdminName(resp)
}

hazardExposure_plotData;

Global Selectors

Admin Regions

adminDB = await DuckDBClient.of({
  admin_lookup: await FileAttachment(
    "/data/shared/gaul24_africa_lookup.parquet",
  ),
});
countries = {
  const country_list = await FileAttachment("/data/shared/atlas_countries.json").json();
  const filteredCountries = country_list
    .filter((c) => c.include && c.iso3c !== "SDN")
    .map(({ include, ...rest }) => rest);
  return filteredCountries;
};

viewof admin0Select = Inputs.input(Object.values(countries).slice(0, 1))

renderA0Multi = ({maxSelections = Infinity, requireAtLeastOne = false} = {}) => {
    const _admin0 = Inputs.select(countries, {
        //NOTE: _lang & general_translations are defined in 
        // components/_lang.qmd
        //Both must be loaded into a notebook for this to work.
        label: _lang(general_translations.country),
        format: (d) => _lang(d.translation),
        // format: (d) => d.iso3c,
        multiple: true
      }
    );
    // Adds the additional features to the multi-select
    enhancedMultiSelect(_admin0, { maxSelections, requireAtLeastOne })
    // Bind to the main global multi-select for updates across NB
    return Inputs.bind(_admin0, viewof admin0Select)
}
allAdmin1 = {
 const resp = await adminDB.query(`
    SELECT DISTINCT admin0_name, admin1_name, iso3
    FROM admin_lookup
    WHERE iso3 in ('${countries.map((d) => d.iso3c).join("', '")}')
  `);

  return resp;
}

dataAdmin1 = allAdmin1.filter(d =>
  admin0Select.some(a0 => a0.iso3c === d.iso3)
);

viewof admin1Select = Inputs.input([])

renderA1Multi = ({maxSelections = Infinity, requireAtLeastOne = false} = {}) => {
    const _admin1 = Inputs.select(
      dataAdmin1,
      {
        label: _lang(general_translations.region),
        format: (d) => `${d.admin1_name || "Full Country"} (${d.iso3})`,
        multiple: true,
      }
    )
    enhancedMultiSelect(_admin1, { maxSelections, requireAtLeastOne })
    return Inputs.bind(_admin1, viewof admin1Select)
}
dataAdmin2 = await adminDB.query(`
  SELECT DISTINCT admin0_name, admin1_name, admin2_name, iso3
  FROM admin_lookup
  WHERE iso3 in ('${admin0Select.map((d) => d.iso3c).join("', '")}')
  AND admin1_name in ('${admin1Select.map((d) => d.admin1_name).join("', '")}')
`)

viewof admin2Select = Inputs.input([])

renderA2Multi = ({maxSelections = Infinity, requireAtLeastOne = false} = {}) => {
    const _admin2 = Inputs.select(
      dataAdmin2,
      {
        label: _lang(general_translations.district),
        format: (d) => `${d.admin2_name} (${d.iso3})`,
        multiple: true
      }
    )
    enhancedMultiSelect(_admin2, { maxSelections, requireAtLeastOne })
    return Inputs.bind(_admin2, viewof admin2Select)
}
function setSelectorValue(input, value) {
  input.value = value;
  input.dispatchEvent(new Event("input", { bubbles: true }));
}

{
  // If nothing selected in admin1, nothing to validate
  if (!admin1Select.length) return;

  // Set of iso3 codes from selected admin1 (a1)
  const selectedA1Iso3 = new Set(admin1Select.map(d => d.iso3));

  // If all iso3 codes in a0 are not in selectedA1Iso3
  const a1NoMatch = admin0Select.every(obj => !selectedA1Iso3.has(obj.iso3c));

  if (a1NoMatch) {
    // Clear admin1 selection since it conflicts with the selected admin0
    setSelectorValue(viewof admin1Select, []);
    return;
  }

  //TODO: This probably needs expanded to admin2 as well
}
admin0Iso3 = admin0Select.map((d) => d.iso3c);
admin1Names = admin1Select.map((d) => d.admin1_name);
admin1NamesClean = admin1Select.map((d) => cleanAdminInput_SQL(d.admin1_name));

Climate data

seasons = new Array(
  { season: "annual", season_rank: null, season_string: "Annual Average" },
  { season: "JFM", season_rank: 0, season_string: "Jan-Feb-Mar" },
  { season: "FMA", season_rank: 1, season_string: "Feb-Mar-Apr" },
  { season: "MAM", season_rank: 2, season_string: "Mar-Apr-May" },
  { season: "AMJ", season_rank: 3, season_string: "Apr-May-Jun" },
  { season: "MJJ", season_rank: 4, season_string: "May-Jun-Jul" },
  { season: "JJA", season_rank: 5, season_string: "Jun-Jul-Aug" },
  { season: "JAS", season_rank: 6, season_string: "Jul-Aug-Sep" },
  { season: "ASO", season_rank: 7, season_string: "Aug-Sep-Oct" },
  { season: "SON", season_rank: 8, season_string: "Sep-Oct-Nov" },
  { season: "OND", season_rank: 9, season_string: "Oct-Nov-Dec" },
  { season: "NDJ", season_rank: 10, season_string: "Nov-Dec-Jan" },
  { season: "DJF", season_rank: 11, season_string: "Dec-Jan-Feb" },
);

viewof seasonSelect = Inputs.select(seasons, {
  format: (d) => d.season,
});
hazards_obj = {
  const input = general_translations.hazardVariables;

  const hazard_lookup = new Object({
    "ndwl0": "NDWL0",
    "ndws": "NDWS",
    "ntx35": "NTx35",
    "ntx40": "NTx40",
    "ptot": "PTOT",
    "tavg": "TAVG",
    "thi": "THI-max",
    "tmax": "TMAX",
    "hsh": "HSH-max"
  })
  return Object.entries(input).map(([id, data]) => ({
    id: hazard_lookup[id],
    ...data
  }));
}

viewof climateVarSelect = Inputs.select(hazards_obj, {format: (d) => d.id});
climateForm = () => {
  let season = Inputs.bind(
    Inputs.select(seasons, {
      format: (d) => d.season_string,
      label: _lang(general_translations.season),
    }),
    viewof seasonSelect
  );

  const climateVar = Inputs.bind(
    Inputs.select(hazards_obj, {
      format: (d) => _lang(d.name),
      label: _lang(general_translations.climateVar),
    }),
    viewof climateVarSelect
  )

  return Inputs.form(
    [
      season,
      climateVar
    ],
    {
      template: inputTemplate()
    }
  )
};

climateForm();
futurePeriods = [
  "2021-2040",
  "2041-2060",
  "2061-2081",
  "2080-2100"
]

viewof futurePeriodSelect = Inputs.input(futurePeriods[0]);

scenarioColors = new Object({
  SSP126: "#4FB5B7",
  SSP245: "#F4BB21",
  SSP370: "#EE624F",
  SSP585: "#B34E65",
});

scenarioNames = Object.keys(scenarioColors);

viewof futureScenarioSelect = Inputs.input([scenarioNames[1], scenarioNames[3]]);
scenarioForm = () => {
  const futurePeriod = Inputs.bind(
    Inputs.select(futurePeriods, {
      label: _lang(general_translations.timeperiod),
    }),
    viewof futurePeriodSelect
  );

  const futureScenario = Inputs.bind(
    Inputs.checkbox(scenarioNames, {
      label: _lang(general_translations.scenario),
    }),
    viewof futureScenarioSelect
  );

  return Inputs.form(
    [futurePeriod, futureScenario],
    {
      template: inputTemplate()
    }
  );

}

Quick Insights

createCountryInsights = (insightFunctions) => {
  const country_insights = admin0Iso3
    .map((c) => {
      const countryName = `**${_lang(iso3ToTranslation[c])}**`;
      const insights = insightFunctions
        .map((fn) => fn(c))
        .filter(Boolean)
        .join("  \n\n");
      return `${countryName}  \n${insights}`;
    })
    .join("\n\n");

  return md`${country_insights}`;
};

seasonInsight = () => {
  const seasonTemplate = _lang(
    nbText.sections.recentChanges.quickInsight.season,
  );
  const seasonInsight = Lang.reduceReplaceTemplateItems(seasonTemplate, [
    {
      name: "season",
      value: seasonSelect.season_string,
    },
  ]);
  return md`_${seasonInsight}_`;
};

Insights - Key Facts

povInsight = (iso3) => {
  const insightBase = nbText.sections.keyFacts.quickInsight.poverty;
  const countryTemplate = _lang(insightBase.country);
  const directions = nbText.general.direction;
  const country = _lang(iso3ToTranslation[iso3]);

  const _pov = pov_plotData.filter(
    (d) => d.iso3 === iso3 && d.group === "in_poverty",
  );

  const _countryPov = _pov.find((d) => !d.admin1_name)?.pov_rate;
  const _adminPov = _pov.filter((d) => d.admin1_name);

  const _countryInsight = Lang.reduceReplaceTemplateItems(countryTemplate, [
    { name: "country", value: country },
    { name: "povPct", value: _countryPov.toFixed(1) },
  ]);

  const _adminInsight =
    _adminPov.length > 0
      ? _adminPov
          .map((d) => {
            const direction =
              d.pov_rate > _countryPov ? directions.greater : directions.less;
            const adminTemplate = _lang(insightBase.admin);
            return Lang.reduceReplaceTemplateItems(adminTemplate, [
              { name: "admin", value: d.admin1_name },
              { name: "povPct", value: d.pov_rate.toFixed(1) },
              { name: "direction", value: _lang(direction) },
            ]);
          })
          .join("\n")
      : "";

  return _adminInsight
    ? `${_countryInsight}\n${_adminInsight}`
    : _countryInsight;
};

agInsight = (iso3) => {
  const _agGDP = gdp_plotData
    .filter((d) => d.iso3 === iso3 && d.sector === "agriculture")
    .reduce(
      (acc, d) => ({
        pct: acc.pct + d.pct_of_total,
        total: acc.total + d.gdp_usd2015,
      }),
      { pct: 0, total: 0 },
    );

  const _agLandUse = landuse_plotData
    .filter((row) => row.iso3 === iso3 && row.group === "Agriculture")
    .reduce(
      (acc, row) => ({
        pct: acc.pct + row.ha_pct,
        total: acc.total + row.ha,
      }),
      { pct: 0, total: 0 },
    );

  const country = _lang(iso3ToTranslation[iso3]);
  const template = _lang(
    nbText.sections.keyFacts.quickInsight.gdpLandUse.country,
  );
  const items = [
    { name: "country", value: country },
    { name: "pctAgGDP", value: _agGDP.pct.toFixed(2) },
    { name: "agGDP", value: formatUSD()(_agGDP.total) },
    { name: "pctAgLandUse", value: _agLandUse.pct.toFixed(2) },
    { name: "LandUse", value: formatNumCompactShort()(_agLandUse.total) },
  ];
  return Lang.reduceReplaceTemplateItems(template, items);
};
exposureInsight = (iso3) => {
  const insightBase = nbText.sections.keyFacts.quickInsight.production;
  const allTemplate = _lang(insightBase.allAdmins);
  const template = _lang(insightBase.topAdmin);
  const directions = nbText.general.direction;
  const country = _lang(iso3ToTranslation[iso3]);

  const getTopCategory = (data) => {
    const categoryTotals = data.reduce((acc, d) => {
      acc[d.category] = (acc[d.category] || 0) + d.value;
      return acc;
    }, {});

    return Object.entries(categoryTotals).reduce(
      (max, [category, total]) =>
        total > max.total ? { category, total } : max,
      { category: null, total: 0 },
    );
  };

  const createAllInsight = (data, adminName) => {
    const topGroup = getTopCategory(data);
    const topCrop = data.reduce((max, d) => (d.value > max.value ? d : max), {
      crop: null,
      value: 0,
    });

    return Lang.reduceReplaceTemplateItems(allTemplate, [
      { name: "admin", value: adminName },
      { name: "group", value: _lang(cropTranslations[topGroup.category]) },
      { name: "groupValue", value: formatUSD()(topGroup.total) },
      { name: "topCommodity", value: _lang(cropTranslations[topCrop.crop]) },
      { name: "topValue", value: formatUSD()(topCrop.value) },
    ]);
  };

  const _countryData = exposure_plotData.filter(
    (d) => d.iso3 === iso3 && !d.admin1_name,
  );
  const _countryInsight = createAllInsight(_countryData, country);

  const _adminData = exposure_plotData.filter(
    (d) => d.iso3 === iso3 && d.admin1_name,
  );

  const _adminInsights =
    _adminData.length > 0
      ? [...new Set(_adminData.map((d) => d.admin1_name))]
          .map((admin1Name) => {
            const admin1Data = _adminData.filter(
              (d) => d.admin1_name === admin1Name,
            );
            return `- ${createAllInsight(admin1Data, admin1Name, allTemplate)}`;
          })
          .join("\n")
      : "";

  return _adminInsights
    ? `${_countryInsight}\n${_adminInsights}`
    : _countryInsight;
};

Insights - recentChanges

climateInsight = (iso3) => {
  const climateTemplates = {
    temperature: _lang(nbText.sections.recentChanges.quickInsight.temp),
    precipitation: _lang(nbText.sections.recentChanges.quickInsight.precip),
  };
  const climateData = recentChanges_data;
  const country = _lang(iso3ToTranslation[iso3]);

  const countryData = climateData.filter(
    (d) => d.iso3 === iso3 && !d.admin1_name,
  );

  const adminData = climateData.filter((d) => d.iso3 === iso3 && d.admin1_name);

  const buildInsight = (data, adminName) => {
    const tempData = data.filter((d) => d.hazard === "TAVG");
    const precipData = data.filter((d) => d.hazard === "PTOT");

    const tempTrend = climateDataTools.getTrend(tempData);
    const precipTrend = climateDataTools.getTrend(precipData);

    const parts = [];

    if (tempTrend) {
      parts.push(
        Lang.reduceReplaceTemplateItems(climateTemplates.temperature, [
          { name: "admin", value: adminName },
          {
            name: "tempChange",
            value: Math.abs(tempTrend.totalChange).toFixed(2),
          },
          { name: "startYear", value: tempTrend.startYear },
          { name: "endYear", value: tempTrend.endYear },
          {
            name: "tempPerDecade",
            value: Math.abs(tempTrend.perDecade).toFixed(2),
          },
        ]),
      );
    }

    if (precipTrend) {
      parts.push(
        Lang.reduceReplaceTemplateItems(climateTemplates.precipitation, [
          { name: "admin", value: adminName },
          {
            name: "precipPerDecade",
            value: Math.abs(precipTrend.perDecade).toFixed(1),
          },
          {
            name: "precipDirection",
            value:
              precipTrend.perDecade < 0
                ? _lang(general_translations.less)
                : _lang(general_translations.more),
          },
        ]),
      );
    }

    return parts.join(" ");
  };

  const countryInsight = buildInsight(countryData, country);

  const adminInsights =
    adminData.length > 0
      ? [...new Set(adminData.map((d) => d.admin1_name))]
          .map((admin1Name) => {
            const admin1Data = adminData.filter(
              (d) => d.admin1_name === admin1Name,
            );
            return `- ${buildInsight(admin1Data, admin1Name)}`;
          })
          .join("\n")
      : "";

  return adminInsights
    ? `${countryInsight}\n\n${adminInsights}`
    : countryInsight;
};
extremeEventsInsight = (iso3) => {
  const insightBase = nbText.sections.extremeEvents.quickInsight;
  const countryTemplate = _lang(insightBase.country);
  const adminTemplate = _lang(insightBase.admin);
  const country = _lang(iso3ToTranslation[iso3]);

  const rank = {
    ssp126: 1,
    ssp245: 2,
    ssp370: 3,
    ssp585: 4,
  };

  const toScenarioSummary = (rows) =>
    rows.reduce((acc, d) => {
      if (!acc[d.scenario]) {
        acc[d.scenario] = {
          unusual_low: 0,
          extreme_low: 0,
          unusual_high: 0,
          extreme_high: 0,
        };
      }
      acc[d.scenario][d.category] += d.num_events;
      return acc;
    }, {});

  const buildInsight = (rows, adminName, template) => {
    if (!rows.length) return null;

    const byScenario = toScenarioSummary(rows);
    const historical = byScenario.historical;
    if (!historical) return null;

    const futureScenario = Object.keys(byScenario)
      .filter((s) => s !== "historical")
      .sort((a, b) => (rank[b] || 0) - (rank[a] || 0))[0];

    if (!futureScenario) return null;

    const future = byScenario[futureScenario];

    return Lang.reduceReplaceTemplateItems(template, [
      { name: "admin", value: adminName },
      { name: "scenario", value: futureScenario.toUpperCase() },
      { name: "extremeHighFuture", value: future.extreme_high },
      { name: "extremeLowFuture", value: future.extreme_low },
      { name: "extremeHighHistorical", value: historical.extreme_high },
      { name: "extremeLowHistorical", value: historical.extreme_low },
    ]);
  };

  const countryRows = extremeEvents_plotData.filter(
    (d) => d.iso3 === iso3 && !d.admin1_name,
  );
  const countryInsight = buildInsight(countryRows, country, countryTemplate);

  const adminRows = extremeEvents_plotData.filter(
    (d) => d.iso3 === iso3 && d.admin1_name,
  );
  const adminInsights = [...new Set(adminRows.map((d) => d.admin1_name))]
    .sort((a, b) => a.localeCompare(b))
    .map((adminName) => {
      const rows = adminRows.filter((d) => d.admin1_name === adminName);
      return buildInsight(rows, adminName, adminTemplate);
    })
    .filter(Boolean)
    .join("\n");

  if (countryInsight && adminInsights)
    return `${countryInsight}\n${adminInsights}`;
  return countryInsight || adminInsights || null;
};
climateDataTools = {
  const getTrend = (data) => {
    if (data.length < 2) return null;

    const sorted = [...data].sort((a, b) => a.year - b.year);
    const first = sorted[0];
    const last = sorted[sorted.length - 1];

    const years = last.year - first.year;
    const totalChange = last.mean - first.mean;
    const perDecade = (totalChange / years) * 10;

    return {
      startYear: first.year,
      endYear: last.year,
      totalChange,
      perDecade,
    };
  };
  return {
    getTrend
  }
}

Insights - futureProjections

climateProjectionInsight = (iso3) => {
  /* ================================
   * Templates
   * ================================ */

  const temperatureTemplate =
    ":::admin::: is projected to warm by :::tempChange:::°C between :::startYear::: and :::endYear::: under a :::scenarioLabel::: scenario (:::scenario:::), corresponding to :::tempPerDecade:::°C per decade. :::tempComparison::: Model uncertainty remains substantial, with warming projections ranging from :::minAnomaly:::°C (coolest) to :::maxAnomaly:::°C (warmest).";

  const precipitationTemplate =
    "Precipitation projections under :::scenario::: indicate a change of :::precipChange::: mm per year between :::startYear::: and :::endYear::: on average across :::admin:::. :::precipComparison::: Precipitation uncertainty remains, with projections ranging from :::minPrecip::: to :::maxPrecip::: mm per year across models.";

  const tempComparisonTemplate =
    "By :::midYear:::, projected warming under :::compareScenario::: is :::diffValue:::°C :::diffDirection::: than under :::baseScenario:::.";

  const precipComparisonTemplate =
    "Compared to :::baseScenario:::, precipitation change under :::compareScenario::: differs by :::precipDiff::: mm per year by :::midYear:::.";

  const adminSummaryTemplate =
    "**:::admin:::**: +:::tempChange:::°C (:::tempPerDecade:::°C/decade), :::compareHigh:::–:::compareLow::: difference: :::tempDiff:::°C; precipitation: :::precipChange::: mm/yr.";

  /* ================================
   * Helpers
   * ================================ */

  const scenarioLabels = {
    ssp126: "very stringent mitigation",
    ssp245: "moderately stringent mitigation",
    ssp370: "high-emissions",
  };

  const getMidYear = (timeperiod) => {
    const [start, end] = timeperiod.split("-").map(Number);
    return Math.round((start + end) / 2);
  };

  const selectScenarioComparison = (scenarios) => {
    if (scenarios.length === 2) return scenarios;

    if (scenarios.includes("ssp245") && scenarios.includes("ssp370")) {
      return ["ssp245", "ssp370"];
    }

    if (scenarios.includes("ssp245") && scenarios.includes("ssp585")) {
      return ["ssp245", "ssp585"];
    }

    // fallback: lowest vs highest forcing
    const sorted = [...scenarios].sort();
    return [sorted[0], sorted[sorted.length - 1]];
  };

  const buildTempComparison = ({ comparePair, byScenario }) => {
    if (!comparePair) return "";

    const [base, compare] = comparePair;
    const diff =
      byScenario[compare][0].mean_anomaly - byScenario[base][0].mean_anomaly;

    return Lang.reduceReplaceTemplateItems(tempComparisonTemplate, [
      { name: "midYear", value: getMidYear(byScenario[base][0].timeperiod) },
      { name: "compareScenario", value: compare.toUpperCase() },
      { name: "baseScenario", value: base.toUpperCase() },
      { name: "diffValue", value: Math.abs(diff).toFixed(2) },
      { name: "diffDirection", value: diff > 0 ? "higher" : "lower" },
    ]);
  };

  const buildPrecipComparison = ({ comparePair, byScenario }) => {
    if (!comparePair) return "";

    const [base, compare] = comparePair;

    const baseTrend = climateDataTools.getTrend(
      byScenario[base].filter((d) => d.hazard === "PTOT"),
    );
    const compTrend = climateDataTools.getTrend(
      byScenario[compare].filter((d) => d.hazard === "PTOT"),
    );

    if (!baseTrend || !compTrend) return "";

    return Lang.reduceReplaceTemplateItems(precipComparisonTemplate, [
      { name: "baseScenario", value: base.toUpperCase() },
      { name: "compareScenario", value: compare.toUpperCase() },
      {
        name: "precipDiff",
        value: (compTrend.perDecade - baseTrend.perDecade).toFixed(1),
      },
      {
        name: "midYear",
        value: getMidYear(byScenario[base][0].timeperiod),
      },
    ]);
  };

  /* ================================
   * Data prep
   * ================================ */

  const data = futureProjections_data;
  const country = _lang(iso3ToTranslation[iso3]);

  const countryData = data.filter((d) => d.iso3 === iso3 && !d.admin1_name);
  const adminData = data.filter((d) => d.iso3 === iso3 && d.admin1_name);

  /* ================================
   * Country-level narrative
   * ================================ */

  const buildCountryInsight = (rows, adminName) => {
    if (!rows.length) return null;

    // group by scenario
    const byScenario = rows.reduce((acc, d) => {
      acc[d.scenario] = acc[d.scenario] || [];
      acc[d.scenario].push(d);
      return acc;
    }, {});

    const scenarios = Object.keys(byScenario);
    if (!scenarios.length) return null;

    // comparison logic (shared everywhere)
    const comparePair =
      scenarios.length >= 2 ? selectScenarioComparison(scenarios) : null;

    // primary scenario = higher forcing if comparison exists
    const primaryScenario = comparePair ? comparePair[1] : scenarios[0];

    /* ================================
     * Temperature paragraph
     * ================================ */

    const tempData = byScenario[primaryScenario].filter(
      (d) => d.hazard === "HSH-max",
    );
    const tempTrend = climateDataTools.getTrend(tempData);
    if (!tempTrend) return null;

    const tempComparison = buildTempComparison({ comparePair, byScenario });

    const uTemp = byScenario[comparePair ? comparePair[1] : primaryScenario][0];

    const temperatureParagraph = Lang.reduceReplaceTemplateItems(
      temperatureTemplate,
      [
        { name: "admin", value: adminName },
        { name: "scenario", value: primaryScenario.toUpperCase() },
        {
          name: "scenarioLabel",
          value: scenarioLabels[primaryScenario] || "climate",
        },
        {
          name: "tempChange",
          value: Math.abs(tempTrend.totalChange).toFixed(2),
        },
        { name: "startYear", value: tempTrend.startYear },
        { name: "endYear", value: tempTrend.endYear },
        {
          name: "tempPerDecade",
          value: Math.abs(tempTrend.perDecade).toFixed(2),
        },
        { name: "tempComparison", value: tempComparison },
        {
          name: "minAnomaly",
          value: Math.abs(uTemp.min_anomaly).toFixed(2),
        },
        {
          name: "maxAnomaly",
          value: Math.abs(uTemp.max_anomaly).toFixed(2),
        },
      ],
    );

    /* ================================
     * Precipitation paragraph
     * ================================ */

    const precipData = byScenario[primaryScenario].filter(
      (d) => d.hazard === "PTOT",
    );
    const precipTrend = climateDataTools.getTrend(precipData);

    let precipitationParagraph = null;

    if (precipTrend) {
      const precipComparison = buildPrecipComparison({
        comparePair,
        byScenario,
      });

      const uPrecipData = byScenario[
        comparePair ? comparePair[1] : primaryScenario
      ].filter((d) => d.hazard === "PTOT");

      precipitationParagraph = Lang.reduceReplaceTemplateItems(
        precipitationTemplate,
        [
          { name: "admin", value: adminName },
          { name: "scenario", value: primaryScenario.toUpperCase() },
          {
            name: "precipChange",
            value: precipTrend.perDecade.toFixed(1),
          },
          { name: "startYear", value: precipTrend.startYear },
          { name: "endYear", value: precipTrend.endYear },
          { name: "precipComparison", value: precipComparison },
          {
            name: "minPrecip",
            value: Math.min(...uPrecipData.map((d) => d.mean)).toFixed(1),
          },
          {
            name: "maxPrecip",
            value: Math.max(...uPrecipData.map((d) => d.mean)).toFixed(1),
          },
        ],
      );
    }

    /* ================================
     * Final output (2 paragraphs max)
     * ================================ */

    return precipitationParagraph
      ? `${temperatureParagraph}\n\n${precipitationParagraph}`
      : temperatureParagraph;
  };
  /* ================================
   * Admin-level compact summaries
   * ================================ */

  const buildAdminSummary = (rows, adminName) => {
    if (!rows.length) return null;

    const byScenario = rows.reduce((acc, d) => {
      acc[d.scenario] = acc[d.scenario] || [];
      acc[d.scenario].push(d);
      return acc;
    }, {});

    const scenarios = Object.keys(byScenario);
    const comparePair =
      scenarios.length >= 2 ? selectScenarioComparison(scenarios) : null;

    const primaryScenario = comparePair
      ? comparePair[1] // higher forcing
      : scenarios[0];

    // temperature trend
    const tempTrend = climateDataTools.getTrend(
      byScenario[primaryScenario].filter((d) => d.hazard === "HSH-max"),
    );
    if (!tempTrend) return null;

    // precipitation trend (optional)
    const precipTrend = climateDataTools.getTrend(
      byScenario[primaryScenario].filter((d) => d.hazard === "PTOT"),
    );

    // temperature difference (mean anomaly only)
    let tempDiff = "n/a";
    if (comparePair) {
      const [base, compare] = comparePair;
      const baseVal = byScenario[base][0].mean_anomaly;
      const compVal = byScenario[compare][0].mean_anomaly;
      tempDiff = Math.abs(compVal - baseVal).toFixed(2);
    }

    const [compareLow, compareHigh] = comparePair ?? [];

    return Lang.reduceReplaceTemplateItems(adminSummaryTemplate, [
      { name: "admin", value: adminName },
      {
        name: "tempChange",
        value: Math.abs(tempTrend.totalChange).toFixed(2),
      },
      {
        name: "tempPerDecade",
        value: Math.abs(tempTrend.perDecade).toFixed(2),
      },
      { name: "tempDiff", value: tempDiff },
      { name: "compareLow", value: compareLow?.toUpperCase() || "—" },
      { name: "compareHigh", value: compareHigh?.toUpperCase() || "—" },
      {
        name: "precipChange",
        value: precipTrend ? precipTrend.perDecade.toFixed(1) : "n/a",
      },
    ]);
  };

  /* ================================
   * Assemble output
   * ================================ */

  const countryInsight = buildCountryInsight(countryData, country);

  const adminInsights =
    adminData.length > 0
      ? [
          "**Subnational patterns**",
          ...[...new Set(adminData.map((d) => d.admin1_name))].map(
            (admin1Name) => {
              const rows = adminData.filter(
                (d) => d.admin1_name === admin1Name,
              );
              return `- ${buildAdminSummary(rows, admin1Name)}`;
            },
          ),
        ].join("\n")
      : "";

  return adminInsights
    ? `${countryInsight}\n\n${adminInsights}`
    : countryInsight;
};

Figures

Figures - Key facts

exposureBars_keyFacts = function () {
  const dataCategories = exposure_plotData;

  let _data;
  // If no prod_type, aggregate by category
  if (!prod_type) {
    _data = Array.from(
      d3.rollup(
        dataCategories,
        (v) => ({
          value: d3.sum(v, (d) => d.value),
          iso3: v[0].iso3,
          admin1_name: v[0].admin1_name,
        }),
        (d) => d.adminName,
        (d) => d.category,
      ),
      ([adminName, catMap]) =>
        Array.from(catMap, ([category, obj]) => ({
          adminName,
          crop: category,
          value: obj.value,
          iso3: obj.iso3,
          admin1_name: obj.admin1_name,
        })),
    ).flat();
  } else {
    _data = dataCategories.filter((d) => d.category === prod_type);
  }

  if (_data.length === 0) {
    //TODO: make this a function
    return html`
      <div style="
        width: ${width}px;
        height: 400px;
        display: flex;
        align-items: center;
        justify-content: center;
        background: #f5f5f5;
        border-radius: 4px;
        color: #666;
        font-size: 16px;
      ">
        No data available for the plot
      </div>
    `;
  }

  return Plot.plot({
    width,
    height: 400,
    marginLeft: 70,
    marginBottom: 50,
    marginTop: 30,
    subtitle: "Agricultural Production",
    caption: multiLineText(
      [
        "Data is from 2020, measured in 2021 US dollars",
        "Source: MapSpam 2020 SSA Adaptation Atlas",
      ],
      "atlasFigCaption",
    ),

    x: {
      axis: "bottom",
      grid: true,
      label: "Value of Production",
      tickFormat: formatUSD(),
    },

    y: {
      label: null,
      tickFormat: (d) => wrapTickLabel(_lang(cropTranslations[d])),
    },

    facet: {
      data: _data,
      x: "adminName",
      label: null,
    },

    color: {
      type: "linear",
      range: ["#F7D732", "#216729"],
      domain: d3.extent(_data, (d) => d.value),
    },

    marks: [
      Plot.barX(_data, {
        x: "value", // summed value
        y: "crop",
        fill: "value", // color by total
        sort: { y: "x", reverse: true },
        channels: {
          crop: {
            label: "Crop",
            value: "crop",
          },
          a1: {
            label: "Region",
            value: "admin1_name",
          },
          a0: {
            label: "Country",
            value: (d) => _lang(iso3ToTranslation[d.iso3]),
          },
        },
        tip: {
          format: {
            a0: true,
            a1: true,
            crop: (d) => _lang(cropTranslations[d]),
            // x: formatUSD(),
            fill: false,
            y: false,
            fx: false,
          },
        },
      }),
    ],
  });
};
function createStackedBarChart({
  data,
  // Required field mappings
  xField = "percentage", // Field for bar width
  yField = "admin0_name", // Field for faceting/grouping
  fillField = "sector", // Field for coloring segments

  // Chart dimensions
  width,
  heightPerRow = 100,

  // Color configuration
  fillColors = {},
  fillLabelFormatter = (d) => d,

  // Labels and text
  title = "",
  caption = "",

  // Text formatting
  textFormatter = (d) => `${d[xField].toFixed(1)}%`,

  // Tooltip configuration
  channels = {}, // Custom channels for tooltips
  tooltipFormat = {}, // Which fields to show in tooltip
}) {
  const _data = data.filter((d) => d[xField] != null && d[fillField] != null);

  // Calculate number of unique groups for height
  const uniqueGroups = new Set(_data.map((d) => d[yField]));
  const numGroups = uniqueGroups.size;

  // Setup color scale
  const dataCategories = new Set(_data.map((d) => d[fillField]));
  const fillDomain = Object.keys(fillColors).filter((k) =>
    dataCategories.has(k),
  );
  const fillRange = fillDomain.map((d) => fillColors[d]);
  const colorScale = d3.scaleOrdinal().domain(fillDomain).range(fillRange);

  const marks = [
    Plot.barX(
      _data,
      Plot.stackX({
        x: xField,
        y: yField,
        fill: (d) => d[fillField],
        channels: channels,
        tip: {
          dy: 6,
          format: {
            fill: false,
            x: false,
            y: false,
            ...tooltipFormat,
          },
        },
      }),
    ),
    Plot.textX(
      _data.filter((d) => d[xField] > 3), //Assume labels under 1% are too small to show well
      Plot.stackX({
        x: xField,
        y: yField,
        text: textFormatter,
        fill: "#fff",
        fontWeight: "bold",
      }),
    ),
  ];

  return Plot.plot({
    style: {
      overflow: "visible",
    },
    subtitle: title,
    caption,
    width,
    height: heightPerRow * numGroups,
    x: {
      axis: null,
    },
    y: {
      label: null,
      tickSize: 0,
    },
    color: {
      domain: fillDomain,
      range: fillRange,
      legend: true,
      label: fillLabelFormatter,
    },
    marks,
  });
}
gdpBar_keyFacts = () => {
  return createStackedBarChart({
    data: gdp_plotData.filter((d) => d.sector !== "total"),
    xField: "pct_of_total",
    yField: "iso3",
    fillField: "sector",
    width: width,
    fillColors: {
      agriculture: "#74B95A",
      industry: "#8C3E5F",
      manufacturing: "#B34E65",
      services: "#523D4E",
    },
    fillLabelFormatter: (d) => (d) => d.toUpperCase(),
    title: "Country GDP by Sector",
    caption: multiLineText(
      ["Data is from 2022, measured in 2015 US dollars", "Source: FAOSTAT"],
      "atlasFigCaption",
    ),
    channels: {
      pct: {
        value: "pct_of_total",
        label: "% of GDP",
      },
      group: {
        value: "sector",
        label: "Sector",
      },
      admin0: {
        value: (d) => _lang(iso3ToTranslation[d.iso3]),
        label: "Country",
      },
    },
    tip: {
      pct: true,
      group: true,
      admin0: true,
    },
  });
};

areaBar_keyFacts = () => {
  return createStackedBarChart({
    data: landuse_plotData.filter((d) => d.sector !== "total"),
    xField: "ha_pct",
    yField: "iso3",
    fillField: "sub_group",
    width: width,
    fillColors: {
      "Permanent meadows and pastures": "#E8C654",
      Cropland: "#E0A810",
      "Naturally regenerating forest": "#4C843A",
      "Planted Forest": "#8DB878",
      "Other land": "#955D7C",
      "Land used for aquaculture": "#4FB5B7",
    },
    fillLabelFormatter: (d) => (d) => d.toUpperCase(),
    title: "Country Land Use by Sector",
    caption: multiLineText(
      ["Data is based on 2021 values", "Source: FAOSTAT"],
      "atlasFigCaption",
    ),
    channels: {
      pct: {
        value: "ha_pct",
        label: "% of area",
      },
      group: {
        value: "sub_group",
        label: "Group",
      },
      admin0: {
        value: (d) => _lang(iso3ToTranslation[d.iso3]),
        label: "Country",
      },
    },
    tip: {
      pct: true,
      group: true,
      admin0: true,
    },
  });
};
povBar_keyFacts = () => {
  return createStackedBarChart({
    data: pov_plotData,
    xField: "pov_rate",
    yField: "adminName",
    fillField: "group",
    width: width,
    fillColors: {
      in_poverty: "#EC5A47",
      not_poverty: "#F4BB21",
    },
    fillLabelFormatter: (d) => (d) => d.toUpperCase(),
    title: "Poverty Rates",
    caption: multiLineText(
      [
        "Data uses purchasing power parity (PPP) values for 2023. Poverty threshold is $4.20 USD/day.",
        "Source: World Bank GSAP 2025.",
      ],
      "atlasFigCaption",
    ),
    channels: {
      rate: {
        value: (d) => d.pov_rate + "%",
        label: "% of populatio,n",
      },
      group: {
        value: "group",
        label: "Group",
      },
      admin0: {
        value: (d) => _lang(iso3ToTranslation[d.iso3]),
        label: "Country",
      },
    },
    tip: {
      rate: true,
      group: true,
      admin0: true,
    },
  });
};

Recent changes

barplot_recentChanges = () => {
  const _data = recentChanges_plotData;
  const _season = seasonSelect.season_string;
  const _variableName = _lang(climateVarSelect.name);
  const _variableAxis = _lang(climateVarSelect.labelAxis);
  const _admin1 = admin1Select.admin1_name;
  const _admin0 = _lang(admin0Select.translation);

  const _mainTitle = `${_variableName} (${_lang(general_translations.anomaly)})`;

  const _reverse = ["PTOT"].includes(climateVarSelect.id);

  // 1. Determine the min and max for consistent axis scaling
  const maxAnomaly = Math.max(..._data.map((d) => d.mean_anomaly));
  const minAnomaly = Math.min(..._data.map((d) => d.mean_anomaly));
  const absMax = Math.max(Math.abs(minAnomaly), Math.abs(maxAnomaly));

  const chart = Plot.plot({
    // --- General Configuration ---
    width,
    height: 400,
    marginTop: 30,
    title: _mainTitle,
    subtitle: `Season: ${_season}`,

    // --- Axis Scales ---
    x: {
      label: "Year",
      tickFormat: "d",
    },
    y: {
      // Ensure the Y-axis is also symmetric around 0 for visual balance
      domain: [-absMax, absMax],
      grid: true,
      label: _variableAxis,
    },
    facet: { data: _data, y: "adminName", label: null },

    // --- Color Scale ---
    color: {
      scheme: "BuRd",
      type: "diverging",
      reverse: _reverse,
      legend: true,
      label: `${_variableAxis} (${_lang(general_translations.anomaly)})`,
    },
    // --- Marks ---
    marks: [
      // A single bar mark is used, with 'fill' set to the anomaly value
      Plot.barY(_data, {
        x: "year",
        y: "mean_anomaly",
        // The color scale defined above will automatically color and grade the bars
        fill: "mean_anomaly",
        tip: true,
        channels: {
          Admin: "adminName",
        },
        tip: {
          format: {
            y: false,
            fy: false,
            Admin: true,
            x: (d) => d,
            color: true,
          },
        },
      }),

      // Add a black line at y=0 for a clear baseline
      Plot.ruleY([0], { stroke: "black", strokeWidth: 1.5 }),
    ],
  });
  return chart;
};
warmingStripes_recentChanges = () => {
  const _data = recentChanges_plotData;
  const _season = seasonSelect.season_string;
  const _variableName = _lang(climateVarSelect.name);
  const _variableAxis = _lang(climateVarSelect.labelAxis);
  const _admin1 = admin1Select.admin1_name;
  const _admin0 = _lang(admin0Select.translation);
  const _showAnomaly = true;
  const _anomalyText = _showAnomaly
    ? `(${_lang(general_translations.anomaly)})`
    : "";

  const _mainTitle = `${_variableName}${_anomalyText}`;
  const plotValue = _showAnomaly ? "mean_anomaly" : "mean";
  const lineColor = "#000";

  const yMax = d3.max(_data, (d) => d[plotValue]);
  const yMin = d3.min(_data, (d) => d[plotValue]);
  const yExtent = Math.abs(yMax - yMin);
  const yBufferPerc = 0.05;
  const yBuffer = yExtent * yBufferPerc;
  const yDomain = [yMin - yBuffer, yMax + yBuffer];
  const _reverse = ["PTOT"].includes(climateVarSelect.id);

  const warmingStripes = Plot.plot({
    width,
    height: 350,
    title: _mainTitle,
    subtitle: `Season: ${_season}`,
    // caption: caption,
    x: {
      label: "Year",
      tickFormat: "d",
      padding: 0,
    },
    y: {
      // axis: ws_iToggleLine ? "left" : null,
      label: `${_variableAxis} ${_anomalyText}`,
      domain: yDomain,
    },
    facet: { data: _data, y: "adminName", label: null },
    color: {
      scheme: _showAnomaly ? "BuRd" : "Reds",
      legend: true,
      label: _variableAxis,
      reverse: _reverse,
    },
    marks: [
      // stripes
      Plot.barY(_data, {
        x: "year",
        y1: yDomain[0],
        y2: yDomain[1],
        fill: (d) => d[plotValue],
      }),
      // zero line
      Plot.ruleY(_showAnomaly ? [0] : [], {
        strokeDasharray: [5],
        stroke: lineColor,
      }),
      // line
      Plot.line(_data, {
        x: "year",
        y: (d) => d[plotValue],
        stroke: lineColor,
      }),
      // dots
      Plot.dot(_data, {
        x: "year",
        y: (d) => d[plotValue],
        fill: lineColor,
      }),
      // tooltip
      Plot.tip(
        _data,
        Plot.pointerX({
          x: "year",
          y: (d) => yMin,
          tip: true,
          channels: {
            Admin: "adminName",
            varMean: {
              label: _variableAxis,
              value: (d) => d.mean,
            },
            varMeanAnomaly: {
              label: _lang(general_translations.anomaly),
              value: (d) => d.mean_anomaly,
            },
          },
          format: {
            y: false,
            fy: false,
            Admin: true,
            x: (d) => d,
            varMean: true,
            varMeanAnomaly: true,
          },
          lineWidth: Infinity,
        }),
      ),
    ],
  });

  return warmingStripes;
};

futureProjections

timeseries_futureProjections = () => {
  const _data = futureProjections_plotData;
  const _season = seasonSelect.season_string;
  const _variableName = _lang(climateVarSelect.name);
  const _variableAxis = _lang(climateVarSelect.labelAxis);
  const _admin1 = admin1Select.admin1_name;
  const _admin0 = _lang(admin0Select.translation);
  const _showAnomaly = true;
  const _anomalyText = _showAnomaly
    ? ` (${_lang(general_translations.anomaly)})`
    : "";

  const _mainTitle = `${_variableName}${_anomalyText}`;
  const plotValue_mean = _showAnomaly ? "mean_anomaly" : "mean";
  const plotValue_max = _showAnomaly ? "max_anomaly" : "max";
  const plotValue_min = _showAnomaly ? "min_anomaly" : "min";

  const formatScenario = (d) => d?.toUpperCase();
  const scenariosInData = [
    ...new Set(_data.map((d) => formatScenario(d.scenario))),
  ];

  return Plot.plot({
    width,
    title: _mainTitle,
    // caption: caption,
    x: {
      tickFormat: "d",
      label: null,
    },
    y: {
      axis: "right",
      grid: true,
      label: _variableAxis,
      tickSize: 0,
    },
    facet: { data: _data, y: "adminName", label: null },
    color: {
      domain: scenariosInData,
      range: scenariosInData.map((s) => scenarioColors[s]),
      legend: true,
    },
    marks: [
      Plot.ruleY(_showAnomaly ? [0] : [], {
        strokeDasharray: [5],
      }),
      Plot.line(_data, {
        x: "year",
        y: plotValue_mean,
        stroke: (d) => formatScenario(d.scenario),
      }),
      Plot.areaY(_data, {
        x: "year",
        y1: plotValue_max,
        y2: plotValue_min,
        fill: (d) => formatScenario(d.scenario),
        fillOpacity: 0.2,
      }),
      Plot.dot(_data, {
        x: "year",
        y: plotValue_mean,
        fill: (d) => formatScenario(d.scenario),
        // tip: true
        tip: {
          channels: {
            scenario: {
              label: _lang(general_translations.scenario),
              value: (d) => formatScenario(d.scenario),
            },
            year: {
              label: _lang(general_translations.year),
              value: (d) => d3.format("d")(d.year),
            },
            Admin: "adminName",
            varMean: {
              label: _variableAxis,
              value: (d) => d.mean,
            },
            varMeanAnomaly: {
              label: _lang(general_translations.anomaly),
              value: (d) => d.mean_anomaly,
            },
          },
          format: {
            Admin: true,
            scenario: true,
            year: true,
            varMean: true,
            varMeanAnomaly: true,
            // value: d => `${d} ${plotField.unit}`,
            x: false,
            y: false,
            fy: false,
            fill: false,
          },
        },
      }),
      Plot.linearRegressionY(false ? _data : [], {
        x: "year",
        y: plotValue_mean,
        stroke: (d) => formatScenario(d.scenario),
        strokeDasharray: [5],
        fill: null,
      }),
    ],
  });
};

extremeEvents

bars_extremeEvents = () => {
  const categories = [
    "extreme_low",
    "unusual_low",
    "unusual_high",
    "extreme_high",
  ];

  const interp = (t) => d3.interpolateRdBu(1 - t);
  const colors = categories.map((_, i) => interp(i / (categories.length - 1)));

  const _data = extremeEvents_plotData;

  return Plot.plot({
    width,
    height: 500,
    marginLeft: 60,
    marginBottom: 60,

    facet: {
      data: _data,
    },

    fy: {
      label: null,
    },

    x: {
      label: "Scenario",
    },

    fx: {
      domain: categories,
      label: "Category",
    },

    y: {
      label: "Number of events",
      grid: true,
    },

    color: {
      legend: true,
      domain: categories,
      range: colors,
      label: "Category",
    },

    marks: [
      Plot.barY(_data, {
        x: "scenario",
        fx: "category",
        fy: "adminName",
        y: "num_events",
        fill: "category",
        tip: true,
      }),
    ],
  });
};

// bars_extremeEvents = () => {
//   const categories = [
//     "extreme_low",
//     "unusual_low",
//     "unusual_high",
//     "extreme_high",
//   ];
//   const interp = (t) => d3.interpolateRdBu(1 - t);
//   const colors = categories.map((_, i) => interp(i / (categories.length - 1)));
//   const colorScale = d3.scaleOrdinal(categories, colors);
//   const _data = extremeEvents_plotData;
//
//   return Plot.plot({
//     width,
//     height: 420,
//     marginLeft: 60,
//     marginBottom: 50,
//     facet: {
//       data: _data,
//       x: "adminName",
//     },
//     x: {
//       domain: ["historical", "ssp245", "ssp585"],
//     },
//     color: {
//       legend: true,
//       domain: categories,
//       range: colors,
//     },
//     marks: [
//       Plot.barY(_data, {
//         x: "scenario",
//         y: "num_events",
//         fill: (d) => colorScale(d.category),
//         tip: true,
//       }),
//     ],
//   });
// };

hazardExposure

stackbars_hazardExposure = () => {
  // --- Assign category to each crop (same as first chart) ---
  const dataWithCategory = hazardExposure_plotData.map((row) => {
    const category = cropCategoryMap.get(row.crop) || "other";
    if (category === "other") console.log(row.crop);

    return { ...row, category };
  });

  // --- Filter to only needed timeframe/scenario AND remove "any" ---
  let baseFiltered = dataWithCategory.filter(
    (d) =>
      d.hazard !== "any" &&
      ["1995-2014", "2021-2040"].includes(d.timeframe) &&
      ["historic", "ssp245", "ssp585"].includes(d.scenario),
  );

  // --- Apply prod_type logic (same as first chart) ---
  let dataForAggregation;
  if (!prod_type) {
    const grouped = d3.rollup(
      baseFiltered,
      (v) => ({
        value: d3.sum(v, (d) => d.value),
        iso3: v[0].iso3,
        admin1_name: v[0].admin1_name,
      }),
      (d) =>
        `${d.adminName}|${d.category}|${d.timeframe}|${d.scenario}|${d.hazard}`,
    );

    dataForAggregation = Array.from(grouped, ([key, data]) => {
      const [adminName, category, timeframe, scenario, hazard] = key.split("|");
      return {
        adminName,
        iso3: data.iso3,
        admin1_name: data.admin1_name,
        crop: category,
        timeframe,
        scenario,
        hazard,
        value: data.value,
      };
    });
  } else {
    // Filter to only the selected category
    dataForAggregation = baseFiltered.filter((d) => d.category === prod_type);
  }

  // --- 5. Hazards stacked in defined order ---
  const _hazards = [
    "wet",
    "dry",
    "heat",
    "dry+heat",
    "dry+wet",
    "heat+wet",
    "heat+wet+dry",
  ];

  if (dataForAggregation.length === 0) {
    return html`
      <div style="
        width: ${width}px;
        height: 400px;
        display: flex;
        align-items: center;
        justify-content: center;
        background: #f5f5f5;
        border-radius: 4px;
        color: #666;
        font-size: 16px;
      ">
        No data available for the plot
      </div>
    `;
  }

  // --- 6. Final hazard plot by category (or filtered category) ---
  return Plot.plot({
    width,
    height: 400,
    marginLeft: 100,

    color: {
      legend: true,
      range: [
        "#4FB5B7",
        "#FCC42C",
        "#FC8A34",
        "#EE624F",
        "#B34E65",
        "#8C3E5F",
        "#523D4E",
        "#EFEFEF",
      ],
      domain: _hazards,
    },

    x: {
      ticks: 1,
      axis: "top",
      grid: true,
      label: "Value",
      tickFormat: formatUSD(),
    },

    y: {
      label: null,
      tickFormat: (d) => wrapTickLabel(_lang(cropTranslations[d])),
    },

    facet: {
      data: dataForAggregation,
      x: "scenario",
      y: "adminName",
      label: null,
    },

    marks: [
      Plot.barX(
        dataForAggregation,
        Plot.stackX(
          { order: _hazards },
          {
            x: "value",
            y: "crop",
            fill: "hazard",
            stroke: "#fff",
            strokeWidth: 0.25,
            channels: {
              crop: {
                label: "Crop",
                value: (d) => _lang(cropTranslations[d.crop]),
              },
              a1: {
                label: "Region",
                value: "admin1_name",
              },
              a0: {
                label: "Country",
                value: (d) => _lang(iso3ToTranslation[d.iso3]),
              },
              scenario: {
                lable: "Scenario",
                value: (d) => d.scenario,
              },
            },
            tip: {
              format: {
                a0: true,
                a1: true,
                crop: true,
                x: formatUSD(),
                fill: false,
                y: false,
                fy: false,
                fx: false,
              },
            },
          },
        ),
      ),
    ],
  });
};