diff --git a/src/app/comparison-portal/layout.tsx b/src/app/comparison-portal/layout.tsx
index 24d9aa18..9f3806af 100644
--- a/src/app/comparison-portal/layout.tsx
+++ b/src/app/comparison-portal/layout.tsx
@@ -10,12 +10,12 @@ export const metadata: Metadata = {
template: `%s - ${siteConfig.name}`,
},
description:
- 'Compare real-time global hunger data across different countries and regions. Obtain food insecurity statistics from the WFP Hunger Map Comparison Portal, tailored to various time zones. A valuable resource for humanitarian efforts and research.',
+ 'Compare real-time global hunger data across different countries and regions. A valuable resource for humanitarian efforts and research.',
keywords: siteConfig.keywords,
openGraph: {
title: `Comparison Portal - ${siteConfig.name}`,
description:
- 'Compare real-time global hunger data across different countries and regions. Obtain food insecurity statistics from the WFP Hunger Map Comparison Portal, tailored to various time zones. Essential for humanitarian aid and research.',
+ 'Compare real-time global hunger data across different countries and regions. Essential for humanitarian aid and research.',
url: `${siteConfig.domain}/comparison-portal`,
images: [
{
@@ -31,7 +31,7 @@ export const metadata: Metadata = {
card: 'summary_large_image',
title: `Comparison Portal - ${siteConfig.name}`,
description:
- 'Access comparable global hunger data from the WFP Hunger Map Comparison Portal, tailored to different countries and time zones.',
+ 'Access comparable global hunger data from the WFP Hunger Map Comparison Portal, tailored to different countries and regions.',
images: [
{
url: '/Images/Comparison-preview.png',
diff --git a/src/app/comparison-portal/loading.tsx b/src/app/comparison-portal/loading.tsx
new file mode 100644
index 00000000..0afb1abf
--- /dev/null
+++ b/src/app/comparison-portal/loading.tsx
@@ -0,0 +1,12 @@
+import ComparisonAccordionSkeleton from '@/components/ComparisonPortal/ComparisonAccordionSkeleton';
+import SelectionSkeleton from '@/components/ComparisonPortal/CountrySelectSkeleton';
+
+export default function Loading() {
+ return (
+ <>
+
Comparison Portal
+
+
+ >
+ );
+}
diff --git a/src/app/comparison-portal/page.tsx b/src/app/comparison-portal/page.tsx
index 00d5e41b..261df506 100644
--- a/src/app/comparison-portal/page.tsx
+++ b/src/app/comparison-portal/page.tsx
@@ -1,28 +1,15 @@
-import { Suspense } from 'react';
-
-import ComparisonAccordionSkeleton from '@/components/ComparisonPortal/ComparisonAccordionSkeleton';
-import CountryComparison from '@/components/ComparisonPortal/CountryComparison';
-import CountrySelectionSkeleton from '@/components/ComparisonPortal/CountrySelectSkeleton';
+import ComparisonPortal from '@/components/ComparisonPortal/CountryComparison';
import container from '@/container';
import { GlobalDataRepository } from '@/domain/repositories/GlobalDataRepository';
-export default async function ComparisonPortal() {
+export default async function Page() {
const globalRepo = container.resolve('GlobalDataRepository');
const countryMapData = await globalRepo.getMapDataForCountries();
const globalFcsData = await globalRepo.getFcsData();
return (
-
+ <>
Comparison Portal
-
-
-
- >
- }
- >
-
-
-
+
+ >
);
}
diff --git a/src/components/ComparisonPortal/ComparisonAccordionSkeleton.tsx b/src/components/ComparisonPortal/ComparisonAccordionSkeleton.tsx
index 4bf61259..31e758fb 100644
--- a/src/components/ComparisonPortal/ComparisonAccordionSkeleton.tsx
+++ b/src/components/ComparisonPortal/ComparisonAccordionSkeleton.tsx
@@ -4,14 +4,13 @@ import { v4 as uuid } from 'uuid';
/**
* A skeleton component for the ComparisonAccordion component.
- * @returns {JSX.Element} The ComparisonAccordionSkeleton component
+ * @param {number} nItems Number of accordion items for the skeleton.
*/
-export default function ComparisonAccordionSkeleton(): JSX.Element {
- const N_ITEMS = 5;
+export default function ComparisonAccordionSkeleton({ nItems }: { nItems: number }): JSX.Element {
return (
);
}
diff --git a/src/components/ComparisonPortal/CountryComparisonAccordion.tsx b/src/components/ComparisonPortal/CountryComparisonAccordion.tsx
index 39614045..368e6ecc 100644
--- a/src/components/ComparisonPortal/CountryComparisonAccordion.tsx
+++ b/src/components/ComparisonPortal/CountryComparisonAccordion.tsx
@@ -1,5 +1,3 @@
-'use client';
-
import { useMemo } from 'react';
import ComparisonAccordionSkeleton from '@/components/ComparisonPortal/ComparisonAccordionSkeleton';
@@ -68,7 +66,7 @@ export default function CountryComparisonAccordion({
return CountryComparisonOperations.getComparisonAccordionItems(chartData, selectedCountryNames, isLoading);
}, [countryDataList, countryIso3DataList, selectedCountries]);
- if (!accordionItems || (countryDataList.length < 2 && isLoading)) return ;
+ if (!accordionItems || (countryDataList.length < 2 && isLoading)) return ;
if (countryDataList.length < 2) {
return (
diff --git a/src/components/ComparisonPortal/CountrySelectSkeleton.tsx b/src/components/ComparisonPortal/CountrySelectSkeleton.tsx
index 2bae650c..22ad2943 100644
--- a/src/components/ComparisonPortal/CountrySelectSkeleton.tsx
+++ b/src/components/ComparisonPortal/CountrySelectSkeleton.tsx
@@ -2,12 +2,12 @@ import { Skeleton } from '@nextui-org/skeleton';
import React from 'react';
/**
- * A skeleton component for the CountrySelection component.
- * @returns {JSX.Element} The CountrySelectionSkeleton component
+ * A skeleton for the Select component.
+ * @returns {JSX.Element}
*/
-export default function CountrySelectionSkeleton(): JSX.Element {
+export default function SelectionSkeleton(): JSX.Element {
return (
-
+
diff --git a/src/components/ComparisonPortal/CountrySelection.tsx b/src/components/ComparisonPortal/CountrySelection.tsx
index 2a36488a..d480a272 100644
--- a/src/components/ComparisonPortal/CountrySelection.tsx
+++ b/src/components/ComparisonPortal/CountrySelection.tsx
@@ -1,5 +1,3 @@
-'use client';
-
import { Select, SelectItem } from '@nextui-org/react';
import { useMemo } from 'react';
@@ -29,6 +27,7 @@ export default function CountrySelection({
setSelectedCountries,
disabledCountryIds,
}: CountrySelectionProps): JSX.Element {
+ const COUNTRY_LIMIT = 5;
const selectedKeys = useMemo(
() => selectedCountries?.map((country) => country.properties.adm0_id.toString()),
[selectedCountries]
@@ -43,8 +42,7 @@ export default function CountrySelection({
return availableCountries
.filter(
(country) =>
- // if there are already 5 selected countries, disable the rest
- selectedCountries.length >= 5 &&
+ selectedCountries.length >= COUNTRY_LIMIT &&
!selectedCountries.find(
(selectedCountry) => selectedCountry.properties.adm0_id === country.properties.adm0_id
)
diff --git a/src/components/ComparisonPortal/NoDataHint.tsx b/src/components/ComparisonPortal/NoDataHint.tsx
index 16056f4f..ae3495e0 100644
--- a/src/components/ComparisonPortal/NoDataHint.tsx
+++ b/src/components/ComparisonPortal/NoDataHint.tsx
@@ -5,54 +5,54 @@ import { isContinuousChartData } from '@/domain/entities/charts/ContinuousChartD
import { NoDataHintProps } from '@/domain/props/NoDataHintProps.ts';
/**
- * Displays an alert when there is one or more selected countries that are not present in the chart.
+ * Displays an alert when there is one or more selected chart categories (i.e. countries or regions) that are not present in the chart.
* @param {NoDataHintProps} props Props for the NoDataHint component
* @param {ContinuousChartData | CategoricalChartData} props.chartData Chart data
- * @param {string[]} props.selectedCountryNames Selected country names
+ * @param {string[]} props.requestedChartCategories Selected country names
* @param {boolean} props.isLoading Whether the data is loading
* @returns {JSX.Element | null} The NoDataHint component if there is missing data, otherwise null
*/
export default function NoDataHint({
chartData,
- selectedCountryNames,
- isLoading,
+ requestedChartCategories,
+ isLoading = false,
}: NoDataHintProps): JSX.Element | null {
- const [formattedMissingCountryNames, setFormattedMissingCountryNames] = useState(null);
+ const [formattedMissingCategories, setFormattedMissingCategories] = useState(null);
useEffect(() => {
if (isLoading) return;
- const countryNamesInChart = isContinuousChartData(chartData)
+ const actualChartCategories = isContinuousChartData(chartData)
? chartData.lines.map((line) => line.name)
: chartData.categories.map((category) => category.name);
- const missingCountryNames = selectedCountryNames.filter(
- (countryName) => !countryNamesInChart.includes(countryName)
+ const missingChartCategories = requestedChartCategories.filter(
+ (category) => !actualChartCategories.includes(category)
);
// if there is no data for at least one country we do not show the warnings
// cause the chart components will display a "no data available" message
- if (missingCountryNames.length === selectedCountryNames.length) {
- setFormattedMissingCountryNames(null);
+ if (missingChartCategories.length === requestedChartCategories.length) {
+ setFormattedMissingCategories(null);
return;
}
- switch (missingCountryNames.length) {
+ switch (missingChartCategories.length) {
case 0:
- setFormattedMissingCountryNames(null);
+ setFormattedMissingCategories(null);
break;
case 1:
- setFormattedMissingCountryNames(missingCountryNames[0]);
+ setFormattedMissingCategories(missingChartCategories[0]);
break;
default:
- setFormattedMissingCountryNames(
- `${missingCountryNames.slice(0, -1).join(', ')} and ${missingCountryNames.slice(-1)}`
+ setFormattedMissingCategories(
+ `${missingChartCategories.slice(0, -1).join(', ')} and ${missingChartCategories.slice(-1)}`
);
}
- }, [isLoading, chartData, selectedCountryNames]);
+ }, [isLoading, chartData, requestedChartCategories]);
- return formattedMissingCountryNames ? (
+ return formattedMissingCategories ? (
) : null;
diff --git a/src/components/ComparisonPortal/RegionComparisonAccordion.tsx b/src/components/ComparisonPortal/RegionComparisonAccordion.tsx
new file mode 100644
index 00000000..40ece658
--- /dev/null
+++ b/src/components/ComparisonPortal/RegionComparisonAccordion.tsx
@@ -0,0 +1,44 @@
+'use client';
+
+import { useMemo, useState } from 'react';
+
+import ComparisonAccordionSkeleton from '@/components/ComparisonPortal/ComparisonAccordionSkeleton';
+import { useRegionDataQuery } from '@/domain/hooks/countryHooks';
+import { RegionComparisonAccordionProps } from '@/domain/props/RegionComparisonAccordionProps';
+import { RegionComparisonOperations } from '@/operations/comparison-portal/RegionComparisonOperations';
+
+import AccordionContainer from '../Accordions/AccordionContainer';
+
+/**
+ * The `CountryComparisonAccordion` component displays comparison accordion for selected regions.
+ * Once a country is selected, it fetches all its regions once using the respective hook.
+ * @param {string[] | 'all'} selectedRegions
+ * @param {string | undefined} selectedRegionComparisonCountry
+ */
+export default function RegionComparisonAccordion({
+ selectedRegions,
+ selectedRegionComparisonCountry,
+}: RegionComparisonAccordionProps) {
+ const { data: regionData, isLoading } = useRegionDataQuery(Number(selectedRegionComparisonCountry));
+
+ // TODO (F-254): Toggle this within the chart options. If the pie chart is selected, switch to false and hide the toggle button.
+ const [showRelativeNumbers] = useState(false);
+
+ const accordionItems = useMemo(() => {
+ if (!regionData) return [];
+ const chartData = RegionComparisonOperations.getChartData(regionData, selectedRegions, showRelativeNumbers);
+ return RegionComparisonOperations.getComparisonAccordionItems(chartData, selectedRegions, regionData.features);
+ }, [regionData, selectedRegions, showRelativeNumbers]);
+
+ if (!accordionItems || isLoading) return ;
+
+ if (selectedRegions.length < 2) {
+ return (
+
+ Select {selectedRegions.length === 1 ? 'one additional region' : 'two or more regions'} to start a comparison.
+
+ );
+ }
+
+ return ;
+}
diff --git a/src/components/ComparisonPortal/RegionSelection.tsx b/src/components/ComparisonPortal/RegionSelection.tsx
new file mode 100644
index 00000000..1e980749
--- /dev/null
+++ b/src/components/ComparisonPortal/RegionSelection.tsx
@@ -0,0 +1,130 @@
+import { Select, SelectItem } from '@nextui-org/react';
+import { useEffect, useMemo } from 'react';
+
+import { CustomButton } from '@/components/Buttons/CustomButton';
+import { useSnackbar } from '@/domain/contexts/SnackbarContext';
+import { SNACKBAR_SHORT_DURATION } from '@/domain/entities/snackbar/Snackbar';
+import { SnackbarPosition, SnackbarStatus } from '@/domain/enums/Snackbar';
+import { useRegionDataQuery } from '@/domain/hooks/countryHooks';
+import { RegionSelectionProps } from '@/domain/props/RegionSelectionProps';
+import { CountryComparisonOperations } from '@/operations/comparison-portal/CountryComparisonOperations';
+import { RegionSelectionOperations } from '@/operations/comparison-portal/RegionSelectionOperations';
+import FcsChoroplethOperations from '@/operations/map/FcsChoroplethOperations';
+
+import SelectionSkeleton from './CountrySelectSkeleton';
+
+/**
+ * A Select component that allows users to select a single country and an unlimited number of its regions.
+ * @param {CountryMapDataWrapper} countryMapData Map polygons and hazards of all countries
+ * @param {GlobalFcsData} globalFcsData FCS data of all countries
+ * @param {string | undefined} selectedRegionComparisonCountry Country that is used for region comparison
+ * @param {(country: (string | undefined)) => void} setSelectedRegionComparisonCountry Setter for the country that is used for region comparison
+ * @param {string[] | 'all'} selectedRegions IDs of the Regions that are compared against each other
+ * @param {(regions: (string[] | 'all'), nAvailableRegions?: number) => void} setSelectedRegions Setter for the IDs of the Regions that are compared against each other
+ */
+export default function RegionSelection({
+ countryMapData,
+ globalFcsData,
+ selectedRegionComparisonCountry,
+ setSelectedRegionComparisonCountry,
+ selectedRegions,
+ setSelectedRegions,
+}: RegionSelectionProps): JSX.Element {
+ const { data: regionData, isLoading, error } = useRegionDataQuery(Number(selectedRegionComparisonCountry));
+ const nAvailableRegions = regionData?.features.length;
+
+ const { showSnackBar } = useSnackbar();
+ const availableCountries = useMemo(() => {
+ return countryMapData.features.filter((country) => FcsChoroplethOperations.checkIfActive(country, globalFcsData));
+ }, [countryMapData, globalFcsData]);
+
+ useEffect(() => {
+ if (error) {
+ const errorCountryName = CountryComparisonOperations.getCountryNameById(
+ Number(selectedRegionComparisonCountry),
+ countryMapData.features
+ );
+ showSnackBar({
+ message: `Error fetching region data for ${errorCountryName}`,
+ status: SnackbarStatus.Error,
+ position: SnackbarPosition.BottomMiddle,
+ duration: SNACKBAR_SHORT_DURATION,
+ });
+ }
+ }, [error]);
+
+ return (
+
+ );
+}
diff --git a/src/components/InfoPopover/InfoPopover.tsx b/src/components/InfoPopover/InfoPopover.tsx
index 3d621f61..ecde0b6c 100644
--- a/src/components/InfoPopover/InfoPopover.tsx
+++ b/src/components/InfoPopover/InfoPopover.tsx
@@ -15,7 +15,7 @@ export function InfoPopover({
return (
-
diff --git a/src/components/Legend/DataSourcePopover.tsx b/src/components/Legend/DataSourcePopover.tsx
index 18693455..cad77fcd 100644
--- a/src/components/Legend/DataSourcePopover.tsx
+++ b/src/components/Legend/DataSourcePopover.tsx
@@ -5,7 +5,14 @@ import descriptions from '@/domain/constant/dataSources/dataSourceDescriptions';
import DataSourceDescription from '@/domain/entities/dataSources/DataSourceDescription';
import { prettifyURL } from '@/utils/formatting';
-// pass an array to show 1+ datasources with their title
+/**
+ * Renders the popover content for a single or multiple data sources.
+ * * Pass a `string` to render the descriptions of the respective data source without a heading.
+ * * Pass a `string[]` to render descriptions of all given data sources, preceded by headings and seperated with `` elements.
+ * * If you want to add a heading to a single data source, pass it as one-element array.
+ *
+ * @param {keyof typeof descriptions | (keyof typeof descriptions)[]} dataSourceKeys The keys of the data sources to render a description for.
+ */
export function DataSourcePopover({
dataSourceKeys,
}: {
@@ -15,11 +22,11 @@ export function DataSourcePopover({
return (
<>
{dataSourceKeys.map((dataSourceKey, index) => (
- <>
+
{index ? : null}
{descriptions[dataSourceKey]?.title}
- >
+
))}
>
);
diff --git a/src/components/PopupModal/PopupModal.tsx b/src/components/PopupModal/PopupModal.tsx
index 6a688f5b..1c4fc236 100644
--- a/src/components/PopupModal/PopupModal.tsx
+++ b/src/components/PopupModal/PopupModal.tsx
@@ -1,5 +1,3 @@
-'use client';
-
import { Modal, ModalBody, ModalContent, ModalHeader } from '@nextui-org/modal';
import PopupModalProps from '@/domain/props/PopupModalProps';
diff --git a/src/domain/entities/comparison/RegionComparisonChartData.ts b/src/domain/entities/comparison/RegionComparisonChartData.ts
new file mode 100644
index 00000000..e97f204b
--- /dev/null
+++ b/src/domain/entities/comparison/RegionComparisonChartData.ts
@@ -0,0 +1,9 @@
+import { ContinuousChartData } from '@/domain/entities/charts/ContinuousChartData';
+
+import { CategoricalChartData } from '../charts/CategoricalChartData';
+
+export interface RegionComparisonChartData {
+ fcsBarChartData?: CategoricalChartData;
+ rcsiBarChartData?: CategoricalChartData;
+ fcsGraphData?: ContinuousChartData;
+}
diff --git a/src/domain/entities/country/AdditionalCountryData.ts b/src/domain/entities/country/AdditionalCountryData.ts
index c6415ba7..8d53fc88 100644
--- a/src/domain/entities/country/AdditionalCountryData.ts
+++ b/src/domain/entities/country/AdditionalCountryData.ts
@@ -8,6 +8,6 @@ export interface AdditionalCountryData {
};
// One region of the country
features: (Feature & {
- id: string;
+ id?: string;
})[];
}
diff --git a/src/domain/entities/country/CountryMapData.ts b/src/domain/entities/country/CountryMapData.ts
index 62796ba9..7e7f66bb 100644
--- a/src/domain/entities/country/CountryMapData.ts
+++ b/src/domain/entities/country/CountryMapData.ts
@@ -23,6 +23,9 @@ export type CountryProps = {
dataType?: string;
};
+/**
+ * Stores polygons for countries, as well as whether any alerts are active in the country.
+ */
export type CountryMapData = Feature;
export interface CountryMapDataWrapper {
type: string;
diff --git a/src/domain/entities/region/RegionFcs.ts b/src/domain/entities/region/RegionFcs.ts
index b221fdb8..849eea31 100644
--- a/src/domain/entities/region/RegionFcs.ts
+++ b/src/domain/entities/region/RegionFcs.ts
@@ -1,15 +1,21 @@
/**
- * This is displayed on the tooltip when you hover on a region
+ * Information on people with insufficient food consumption.
*/
export interface RegionFcs {
- score: number;
- scoreLow: number;
- scoreHigh: number;
- ratio: number;
- ratioLow: number;
- ratioHigh: number;
- people: number;
- peopleLow: number;
- peopleHigh: number;
+ // FCS Score = share of people with insufficient food consumption (0-1)
+ score: number | null;
+ scoreLow: number | null;
+ scoreHigh: number | null;
+
+ // Percentage of people with insufficient food consumption (0-100, 2 decimals)
+ ratio: number | null;
+ ratioLow: number | null;
+ ratioHigh: number | null;
+
+ // Number of people with insufficient food consumption (in millions)
+ people: number | null;
+ peopleLow: number | null;
+ peopleHigh: number | null;
+
dataType: string;
}
diff --git a/src/domain/entities/region/RegionProperties.ts b/src/domain/entities/region/RegionProperties.ts
index 62ee1891..3cb5c9ed 100644
--- a/src/domain/entities/region/RegionProperties.ts
+++ b/src/domain/entities/region/RegionProperties.ts
@@ -5,8 +5,8 @@ import { RegionFcs } from './RegionFcs';
import { RegionRcsi } from './RegionRcs';
export interface RegionProperties extends CommonRegionProperties {
- fcs: RegionFcs;
+ fcs: RegionFcs | null;
centroid: Coordinate;
- fcsGraph: RegionFcsChartData[]; // seems to be unsused in current implementation, but this is the biggest chunk of the data
- rcsi: RegionRcsi; // also not shown
+ fcsGraph: RegionFcsChartData[] | null;
+ rcsi: RegionRcsi | null;
}
diff --git a/src/domain/entities/region/RegionRcs.ts b/src/domain/entities/region/RegionRcs.ts
index 7ffd054b..4d4086a6 100644
--- a/src/domain/entities/region/RegionRcs.ts
+++ b/src/domain/entities/region/RegionRcs.ts
@@ -1,8 +1,14 @@
+/**
+ * Information on people with crisis or above crisis food-based coping.
+ */
export interface RegionRcsi {
- ratio: number;
- ratioLow: number;
- ratioHigh: number;
- people: number;
- peopleLow: number;
- peopleHigh: number;
+ // Percentage of people crisis or above crisis food-based coping (0-100, 2 decimals)
+ ratio: number | null;
+ ratioLow: number | null;
+ ratioHigh: number | null;
+
+ // Number of people with crisis or above crisis food-based coping (in Millions)
+ people: number | null;
+ peopleLow: number | null;
+ peopleHigh: number | null;
}
diff --git a/src/domain/hooks/countryHooks.ts b/src/domain/hooks/countryHooks.ts
index 4dfa6534..1473ae75 100644
--- a/src/domain/hooks/countryHooks.ts
+++ b/src/domain/hooks/countryHooks.ts
@@ -65,6 +65,11 @@ export const useCountryDataListQuery = (
cachedQueryClient
);
+/**
+ * Query that fetches data on country regions, such as their coordinates, current FCS and rCSI values and historic FCS trends.
+ * @param {number} countryId `adm0_id` of the country to run the query for.
+ * @returns `{data: AdditionalCountryData, isLoading: boolean, error: Error | null}`
+ */
export const useRegionDataQuery = (countryId: number) =>
useQuery(
{
@@ -77,6 +82,7 @@ export const useRegionDataQuery = (countryId: number) =>
return res as AdditionalCountryData;
},
retry: false,
+ enabled: !Number.isNaN(countryId),
},
cachedQueryClient
);
diff --git a/src/domain/hooks/queryParamsHooks.ts b/src/domain/hooks/queryParamsHooks.ts
index 0ec2fbe5..62d7e6b0 100644
--- a/src/domain/hooks/queryParamsHooks.ts
+++ b/src/domain/hooks/queryParamsHooks.ts
@@ -3,39 +3,141 @@ import { useEffect, useState } from 'react';
import { CountryMapData, CountryMapDataWrapper } from '@/domain/entities/country/CountryMapData.ts';
+const pushRoute = (pathname: string, searchParams: URLSearchParams) => {
+ // use browser history API instead of next.js router to avoid refetching
+ if (typeof window !== 'undefined') {
+ window.history.pushState({}, '', `${pathname}?${searchParams.toString()}`);
+ }
+};
+
/**
* Return a state that is synchronized with the `countries` query param.
* Whereas the returned state value and update function work with arrays of `CountryMapData`, the query param is using the `adm0_id`.
*
- * Note: It is assumed that there is only one relevant query param, any others will be erased on change.
+ * @param {CountryMapDataWrapper} countryMapData Polygon and alert data for all selected countries
+ * @return `[selectedCountries, setSelectedCountries]` similar to a `useState` call
*/
export const useSelectedCountries = (countryMapData: CountryMapDataWrapper) => {
const PARAM_NAME = 'countries';
-
- const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [selectedCountries, setSelectedCountries] = useState(undefined);
- // get state values from query params
useEffect(() => {
- const searchParamCountryCodes = searchParams.get(PARAM_NAME)?.split(',') ?? [];
- const newSelectedCountries = countryMapData.features.filter((availableCountry) =>
- searchParamCountryCodes.includes(availableCountry.properties.adm0_id.toString())
+ const countryIds = searchParams.get(PARAM_NAME)?.split(',') ?? [];
+ const countries = countryMapData.features.filter((country) =>
+ countryIds.includes(country.properties.adm0_id.toString())
);
- setSelectedCountries(newSelectedCountries);
+ setSelectedCountries(countries);
}, [searchParams]);
- // update state and query params with new value
- const setSelectedCountriesFn = (newValue: CountryMapData[] | undefined) => {
- setSelectedCountries(newValue);
- const selectedCountryIds = newValue?.map((country) => country.properties.adm0_id) ?? [];
- router.push(`${pathname}?${PARAM_NAME}=${selectedCountryIds.join(',')}`);
+ const setSelectedCountriesFn = (countries: CountryMapData[] | undefined) => {
+ setSelectedCountries(countries);
+ const countryIds = countries?.map((c) => c.properties.adm0_id) ?? [];
+ const updatedParams = new URLSearchParams(searchParams.toString());
+ if (countryIds.length > 0) {
+ updatedParams.set(PARAM_NAME, countryIds.join(','));
+ } else {
+ updatedParams.delete(PARAM_NAME);
+ }
+ pushRoute(pathname, updatedParams);
};
return [selectedCountries, setSelectedCountriesFn] as const;
};
+/**
+ * Return a state that is synchronized with the `tab` query param.
+ *
+ * @return `[selectedTab, setSelectedTab]` similar to a `useState` call
+ */
+export const useSelectedTab = () => {
+ const PARAM_NAME = 'tab';
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const [selectedTab, setSelectedTab] = useState(() => {
+ return searchParams.get(PARAM_NAME) ?? 'country';
+ });
+
+ useEffect(() => {
+ setSelectedTab(searchParams.get(PARAM_NAME) ?? 'country');
+ }, [searchParams]);
+
+ const setSelectedTabFn = (tab: string) => {
+ const updatedParams = new URLSearchParams(searchParams.toString());
+ if (tab) {
+ updatedParams.set(PARAM_NAME, tab);
+ } else {
+ updatedParams.delete(PARAM_NAME);
+ }
+ pushRoute(pathname, updatedParams);
+ };
+
+ return [selectedTab, setSelectedTabFn] as const;
+};
+
+/**
+ * Return a state that is synchronized with the `regions` and `regionComparisonCountry query params.
+ * Regions are stored as array of their IDs (converted to strings) or a single string `'all'`.
+ * The country is stored as `string` of its `adm0_id`.
+ *
+ * @return `{selectedRegions, setSelectedRegions, selectedRegionComparisonCountry, setSelectedRegionComparisonCountry}` similar to `useState` calls.
+ */
+export const useSelectedRegions = () => {
+ const REGION_PARAM = 'regions';
+ const COMPARISON_COUNTRY_PARAM = 'regionComparisonCountry';
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+
+ const [selectedRegions, setSelectedRegions] = useState([]);
+ const [selectedRegionComparisonCountry, setSelectedRegionComparisonCountry] = useState(undefined);
+
+ useEffect(() => {
+ const regionParam = searchParams.get(REGION_PARAM);
+ if (regionParam === 'all') setSelectedRegions('all');
+ else setSelectedRegions(regionParam?.split(',') ?? []);
+
+ setSelectedRegionComparisonCountry(searchParams.get(COMPARISON_COUNTRY_PARAM) ?? undefined);
+ }, [searchParams]);
+
+ const setSelectedRegionsFn = (regions: string[] | 'all', nAvailableRegions: number | undefined) => {
+ // Use 'all' instead of the array if possible to have a cleaner query param.
+ // eslint-disable-next-line no-param-reassign
+ if (regions.length === nAvailableRegions) regions = 'all';
+
+ setSelectedRegions(regions);
+ const updatedParams = new URLSearchParams(searchParams.toString());
+ if (regions === 'all') {
+ updatedParams.set(REGION_PARAM, 'all');
+ } else if (regions.length > 0) {
+ updatedParams.set(REGION_PARAM, regions.join(','));
+ } else {
+ updatedParams.delete(REGION_PARAM);
+ }
+ pushRoute(pathname, updatedParams);
+ };
+
+ const setSelectedRegionComparisonCountryFn = (comparisonCountry: string | undefined) => {
+ setSelectedRegionComparisonCountry(comparisonCountry);
+ setSelectedRegions([]);
+ const updatedParams = new URLSearchParams(searchParams.toString());
+ if (comparisonCountry) {
+ updatedParams.set(COMPARISON_COUNTRY_PARAM, comparisonCountry);
+ updatedParams.delete(REGION_PARAM);
+ } else {
+ updatedParams.delete(COMPARISON_COUNTRY_PARAM);
+ }
+ pushRoute(pathname, updatedParams);
+ };
+
+ return {
+ selectedRegions,
+ setSelectedRegions: setSelectedRegionsFn,
+ selectedRegionComparisonCountry,
+ setSelectedRegionComparisonCountry: setSelectedRegionComparisonCountryFn,
+ };
+};
+
/**
* Return a delayed version of `input` that only changes after `input` has been constant for `msDelay` Milliseconds.
* This is useful for not triggering an event while a user is typing.
diff --git a/src/domain/props/NoDataHintProps.ts b/src/domain/props/NoDataHintProps.ts
index 2cb139df..8ae9649a 100644
--- a/src/domain/props/NoDataHintProps.ts
+++ b/src/domain/props/NoDataHintProps.ts
@@ -4,6 +4,6 @@ import { CategoricalChartData } from '../entities/charts/CategoricalChartData';
export interface NoDataHintProps {
chartData: ContinuousChartData | CategoricalChartData;
- selectedCountryNames: string[];
- isLoading: boolean;
+ requestedChartCategories: string[];
+ isLoading?: boolean;
}
diff --git a/src/domain/props/RegionComparisonAccordionProps.ts b/src/domain/props/RegionComparisonAccordionProps.ts
new file mode 100644
index 00000000..e1af73c1
--- /dev/null
+++ b/src/domain/props/RegionComparisonAccordionProps.ts
@@ -0,0 +1,4 @@
+export interface RegionComparisonAccordionProps {
+ selectedRegionComparisonCountry: string | undefined;
+ selectedRegions: string[] | 'all';
+}
diff --git a/src/domain/props/RegionSelectionProps.ts b/src/domain/props/RegionSelectionProps.ts
new file mode 100644
index 00000000..5d3c8e7a
--- /dev/null
+++ b/src/domain/props/RegionSelectionProps.ts
@@ -0,0 +1,11 @@
+import { GlobalFcsData } from '../entities/country/CountryFcsData';
+import { CountryMapDataWrapper } from '../entities/country/CountryMapData';
+
+export interface RegionSelectionProps {
+ countryMapData: CountryMapDataWrapper;
+ globalFcsData: GlobalFcsData;
+ selectedRegionComparisonCountry: string | undefined;
+ setSelectedRegionComparisonCountry: (country: string | undefined) => void;
+ selectedRegions: string[] | 'all';
+ setSelectedRegions: (regions: string[] | 'all', nAvailableRegions?: number) => void;
+}
diff --git a/src/operations/comparison-portal/CountryComparisonOperations.tsx b/src/operations/comparison-portal/CountryComparisonOperations.tsx
index 3ad18f07..669fe4c1 100644
--- a/src/operations/comparison-portal/CountryComparisonOperations.tsx
+++ b/src/operations/comparison-portal/CountryComparisonOperations.tsx
@@ -23,128 +23,6 @@ import { SnackbarProps } from '@/domain/props/SnackbarProps';
import { formatToMillion } from '@/utils/formatting.ts';
export class CountryComparisonOperations {
- static getFcsChartData(countryDataList: CountryDataRecord[], countryMapData: CountryMapData[]): ContinuousChartData {
- return this.chartWithoutEmptyLines({
- type: ContinuousChartDataType.LINE_CHART_DATA,
- xAxisType: 'datetime',
- yAxisLabel: 'Mill',
- lines: countryDataList.map((countryData) => ({
- name: this.getCountryNameById(countryData.id, countryMapData),
- showRange: true,
- dataPoints: countryData.fcsGraph.map((fcsChartData) => ({
- x: new Date(fcsChartData.x).getTime(),
- y: formatToMillion(fcsChartData.fcs),
- yRangeMin: formatToMillion(fcsChartData.fcsLow),
- yRangeMax: formatToMillion(fcsChartData.fcsHigh),
- })),
- })),
- });
- }
-
- static getRcsiChartData(countryDataList: CountryDataRecord[], countryMapData: CountryMapData[]): ContinuousChartData {
- return this.chartWithoutEmptyLines({
- type: ContinuousChartDataType.LINE_CHART_DATA,
- xAxisType: 'datetime',
- yAxisLabel: 'Mill',
- lines: countryDataList.map((countryData) => ({
- name: this.getCountryNameById(countryData.id, countryMapData),
- showRange: true,
- dataPoints: countryData.rcsiGraph
- .filter((rcsiChartData) => rcsiChartData.rcsi !== null)
- .map((rcsiChartData) => ({
- x: new Date(rcsiChartData.x).getTime(),
- y: formatToMillion(rcsiChartData.rcsi),
- yRangeMin: formatToMillion(rcsiChartData.rcsiLow),
- yRangeMax: formatToMillion(rcsiChartData.rcsiHigh),
- })),
- })),
- });
- }
-
- static getPopulationBarChartData(
- countryDataList: CountryDataRecord[],
- countryMapData: CountryMapData[]
- ): CategoricalChartData {
- return {
- yAxisLabel: 'Mill',
- categories: countryDataList.map((countryData) => ({
- name: this.getCountryNameById(countryData.id, countryMapData),
- dataPoint: {
- y: countryData.population,
- },
- })),
- };
- }
-
- static getFoodSecurityBarChartData(
- countryDataList: CountryDataRecord[],
- countryMapData: CountryMapData[]
- ): CategoricalChartData {
- return {
- yAxisLabel: 'Mill',
- categories: countryDataList.map((countryData) => ({
- name: this.getCountryNameById(countryData.id, countryMapData),
- dataPoint: {
- y: countryData.fcs,
- },
- })),
- };
- }
-
- static getImportDependencyBarChartData(
- countryDataList: CountryDataRecord[],
- selectedCountries: CountryMapData[]
- ): CategoricalChartData {
- return {
- yAxisLabel: '% of Cereals',
- categories: countryDataList
- .filter((countryData) => countryData.importDependency !== null)
- .map((countryData) => ({
- name: this.getCountryNameById(countryData.id, selectedCountries),
- dataPoint: {
- y: countryData.importDependency!,
- },
- })),
- };
- }
-
- static getBalanceOfTradeData(
- countryIso3DataList: CountryIso3DataRecord[],
- selectedCountries: CountryMapData[]
- ): ContinuousChartData {
- return this.chartWithoutEmptyLines({
- type: ContinuousChartDataType.LINE_CHART_DATA,
- xAxisType: 'datetime',
- yAxisLabel: 'Mill USD',
- lines: countryIso3DataList.map((countryIso3Data) => ({
- name: this.getCountryNameByIso3(countryIso3Data.id, selectedCountries),
- dataPoints: countryIso3Data.balanceOfTradeGraph.data.map((p) => {
- return { x: new Date(p.x).getTime(), y: formatToMillion(p.y) };
- }),
- })),
- });
- }
-
- static getInflationData(
- countryIso3DataList: CountryIso3DataRecord[],
- selectedCountries: CountryMapData[],
- type: 'headline' | 'food'
- ): ContinuousChartData {
- return this.chartWithoutEmptyLines({
- type: ContinuousChartDataType.LINE_CHART_DATA,
- xAxisType: 'datetime',
- yAxisLabel: 'Rate in %',
- lines: countryIso3DataList
- .filter((countryIso3Data) => countryIso3Data.inflationGraphs[type].data !== undefined)
- .map((countryIso3Data) => ({
- name: this.getCountryNameByIso3(countryIso3Data.id, selectedCountries),
- dataPoints: (countryIso3Data.inflationGraphs[type].data as ChartData[]).map((p) => {
- return { x: new Date(p.x).getTime(), y: p.y };
- }),
- })),
- });
- }
-
static getCountryNameById(id: number, countryMapData: CountryMapData[]): string {
return countryMapData.find((country) => country.properties.adm0_id === id)?.properties.adm0_name || '';
}
@@ -153,13 +31,6 @@ export class CountryComparisonOperations {
return countryMapData.find((country) => country.properties.iso3 === iso3)?.properties.adm0_name || '';
}
- static chartWithoutEmptyLines(chart: ContinuousChartData): ContinuousChartData {
- return {
- ...chart,
- lines: chart.lines.filter((line) => line.dataPoints.length > 0),
- };
- }
-
static getChartData(
countryDataList: CountryDataRecord[],
countryIso3DataList: CountryIso3DataRecord[],
@@ -229,7 +100,7 @@ export class CountryComparisonOperations {
): AccordionItemProps[] {
return [
{
- title: 'Food Security',
+ title: 'Current Food Security',
infoIcon: ,
popoverInfo: ,
content: (
@@ -260,7 +131,7 @@ export class CountryComparisonOperations {
{fcsChartData && (
<>
>
@@ -278,7 +149,7 @@ export class CountryComparisonOperations {
{rcsiChartData && (
<>
>
@@ -306,7 +177,7 @@ export class CountryComparisonOperations {
>
@@ -325,7 +196,7 @@ export class CountryComparisonOperations {
>
@@ -351,7 +222,7 @@ export class CountryComparisonOperations {
/>
>
@@ -368,7 +239,7 @@ export class CountryComparisonOperations {
/>
>
@@ -378,4 +249,139 @@ export class CountryComparisonOperations {
},
];
}
+
+ private static getFcsChartData(
+ countryDataList: CountryDataRecord[],
+ countryMapData: CountryMapData[]
+ ): ContinuousChartData {
+ return this.chartWithoutEmptyLines({
+ type: ContinuousChartDataType.LINE_CHART_DATA,
+ xAxisType: 'datetime',
+ yAxisLabel: 'Mill',
+ lines: countryDataList.map((countryData) => ({
+ name: this.getCountryNameById(countryData.id, countryMapData),
+ showRange: true,
+ dataPoints: countryData.fcsGraph.map((fcsChartData) => ({
+ x: new Date(fcsChartData.x).getTime(),
+ y: formatToMillion(fcsChartData.fcs),
+ yRangeMin: formatToMillion(fcsChartData.fcsLow),
+ yRangeMax: formatToMillion(fcsChartData.fcsHigh),
+ })),
+ })),
+ });
+ }
+
+ private static getRcsiChartData(
+ countryDataList: CountryDataRecord[],
+ countryMapData: CountryMapData[]
+ ): ContinuousChartData {
+ return this.chartWithoutEmptyLines({
+ type: ContinuousChartDataType.LINE_CHART_DATA,
+ xAxisType: 'datetime',
+ yAxisLabel: 'Mill',
+ lines: countryDataList.map((countryData) => ({
+ name: this.getCountryNameById(countryData.id, countryMapData),
+ showRange: true,
+ dataPoints: countryData.rcsiGraph
+ .filter((rcsiChartData) => rcsiChartData.rcsi !== null)
+ .map((rcsiChartData) => ({
+ x: new Date(rcsiChartData.x).getTime(),
+ y: formatToMillion(rcsiChartData.rcsi),
+ yRangeMin: formatToMillion(rcsiChartData.rcsiLow),
+ yRangeMax: formatToMillion(rcsiChartData.rcsiHigh),
+ })),
+ })),
+ });
+ }
+
+ private static getPopulationBarChartData(
+ countryDataList: CountryDataRecord[],
+ countryMapData: CountryMapData[]
+ ): CategoricalChartData {
+ return {
+ yAxisLabel: 'Mill',
+ categories: countryDataList.map((countryData) => ({
+ name: this.getCountryNameById(countryData.id, countryMapData),
+ dataPoint: {
+ y: countryData.population,
+ },
+ })),
+ };
+ }
+
+ private static getFoodSecurityBarChartData(
+ countryDataList: CountryDataRecord[],
+ countryMapData: CountryMapData[]
+ ): CategoricalChartData {
+ return {
+ yAxisLabel: 'Mill',
+ categories: countryDataList.map((countryData) => ({
+ name: this.getCountryNameById(countryData.id, countryMapData),
+ dataPoint: {
+ y: countryData.fcs,
+ },
+ })),
+ };
+ }
+
+ private static getImportDependencyBarChartData(
+ countryDataList: CountryDataRecord[],
+ selectedCountries: CountryMapData[]
+ ): CategoricalChartData {
+ return {
+ yAxisLabel: '% of Cereals',
+ categories: countryDataList
+ .filter((countryData) => countryData.importDependency !== null)
+ .map((countryData) => ({
+ name: this.getCountryNameById(countryData.id, selectedCountries),
+ dataPoint: {
+ y: countryData.importDependency!,
+ },
+ })),
+ };
+ }
+
+ private static getBalanceOfTradeData(
+ countryIso3DataList: CountryIso3DataRecord[],
+ selectedCountries: CountryMapData[]
+ ): ContinuousChartData {
+ return this.chartWithoutEmptyLines({
+ type: ContinuousChartDataType.LINE_CHART_DATA,
+ xAxisType: 'datetime',
+ yAxisLabel: 'Mill USD',
+ lines: countryIso3DataList.map((countryIso3Data) => ({
+ name: this.getCountryNameByIso3(countryIso3Data.id, selectedCountries),
+ dataPoints: countryIso3Data.balanceOfTradeGraph.data.map((p) => {
+ return { x: new Date(p.x).getTime(), y: formatToMillion(p.y) };
+ }),
+ })),
+ });
+ }
+
+ private static getInflationData(
+ countryIso3DataList: CountryIso3DataRecord[],
+ selectedCountries: CountryMapData[],
+ type: 'headline' | 'food'
+ ): ContinuousChartData {
+ return this.chartWithoutEmptyLines({
+ type: ContinuousChartDataType.LINE_CHART_DATA,
+ xAxisType: 'datetime',
+ yAxisLabel: 'Rate in %',
+ lines: countryIso3DataList
+ .filter((countryIso3Data) => countryIso3Data.inflationGraphs[type].data !== undefined)
+ .map((countryIso3Data) => ({
+ name: this.getCountryNameByIso3(countryIso3Data.id, selectedCountries),
+ dataPoints: (countryIso3Data.inflationGraphs[type].data as ChartData[]).map((p) => {
+ return { x: new Date(p.x).getTime(), y: p.y };
+ }),
+ })),
+ });
+ }
+
+ static chartWithoutEmptyLines(chart: ContinuousChartData): ContinuousChartData {
+ return {
+ ...chart,
+ lines: chart.lines.filter((line) => line.dataPoints.length > 0),
+ };
+ }
}
diff --git a/src/operations/comparison-portal/RegionComparisonOperations.tsx b/src/operations/comparison-portal/RegionComparisonOperations.tsx
new file mode 100644
index 00000000..1ffcf4a0
--- /dev/null
+++ b/src/operations/comparison-portal/RegionComparisonOperations.tsx
@@ -0,0 +1,143 @@
+import clsx from 'clsx';
+
+import { CategoricalChart } from '@/components/Charts/CategoricalChart';
+import { ContinuousChart } from '@/components/Charts/ContinuousChart';
+import NoDataHint from '@/components/ComparisonPortal/NoDataHint';
+import CustomInfoCircle from '@/components/CustomInfoCircle/CustomInfoCircle';
+import { DataSourcePopover } from '@/components/Legend/DataSourcePopover';
+import descriptions from '@/domain/constant/dataSources/dataSourceDescriptions';
+import { AccordionItemProps } from '@/domain/entities/accordions/Accordions';
+import { CategoricalChartData } from '@/domain/entities/charts/CategoricalChartData';
+import { ContinuousChartData } from '@/domain/entities/charts/ContinuousChartData';
+import { Feature } from '@/domain/entities/common/Feature';
+import { RegionComparisonChartData } from '@/domain/entities/comparison/RegionComparisonChartData';
+import { AdditionalCountryData } from '@/domain/entities/country/AdditionalCountryData';
+import { RegionProperties } from '@/domain/entities/region/RegionProperties';
+import { ContinuousChartDataType } from '@/domain/enums/ContinuousChartDataType';
+import { CountryComparisonOperations } from '@/operations/comparison-portal/CountryComparisonOperations';
+import { formatToMillion } from '@/utils/formatting';
+
+export class RegionComparisonOperations {
+ static getComparisonAccordionItems(
+ { fcsBarChartData, rcsiBarChartData, fcsGraphData }: RegionComparisonChartData,
+ selectedRegions: string[] | 'all',
+ regionFeatures: (Feature & { id?: string })[]
+ ): AccordionItemProps[] {
+ const selectedRegionFeatures =
+ selectedRegions === 'all'
+ ? regionFeatures
+ : regionFeatures.filter(
+ (regionFeature) => regionFeature.id && selectedRegions.includes(regionFeature.id?.toString())
+ );
+ const selectedRegionNames = selectedRegionFeatures.map((regionFeature) => regionFeature.properties.Name);
+ return [
+ {
+ title: 'Current Food Security',
+ infoIcon: ,
+ popoverInfo: ,
+ content: (
+