Skip to content

Commit

Permalink
Feature/f 142 comparison portal (#135)
Browse files Browse the repository at this point in the history
* feat: simple country selection

* feat: add chart and disabled countries

* feat: use single selection component

* feat: improved data fetching

* feat: add alert

* feat: add basic bar chart & fix country selection

* feat: add comparison charts for import dependency, BOT, inflation

* fix: disabled countries

* fix: country selection and number formatting

* feat: refactoring

* feat: remove alert

* feat: add query param support

* fix: accept undefined inflation graphs

* feat: show hint if not enough countries are selected

* fix: remove redundant food security heading

* fix: country selection update

* feat: remove comments

* feat: add missing x-axis sliders for comparisons

* feat: remove countries without data from comparison charts

* fix: reset selectedXAxisRange if data changes

* feat: add hint if no data available

* fix: downgrade @nextui-org/theme again

* fix: do not show NoDataHint while loading

* feat: handle importDependency = null

* feat: add skeleton on load

* feat: add comparison accordion suspense

* feat: add country select suspense & skeleton

* fix: return type

* feat: minor refactoring

* feat: bar chart & fix react warnings

* fix: layout

* fix: add removed code & add comments

* feat: deselect error country

* fix: no scroll in initial page

* feat: deselect error country in url params

* fix: do not auto-expand comparison accordions

* feat: do not show 2 bar charts next to each other on mobile

* fix: remove invalid country after selection

* fix: remove unused code

* fix: remove unused code

* fix: move suspense boundary up

* fix: selectedCountries can be undefined

* fix: add query params hook to update query params and state simutaneously

* feat: disable error country & remove countries without data from selection

* feat: clear selection button

* feat: transparent bg

---------

Co-authored-by: Jakob Schödl <[email protected]>
  • Loading branch information
bohdangarchu and jschoedl authored Dec 18, 2024
1 parent 5272e1e commit 0689a93
Show file tree
Hide file tree
Showing 30 changed files with 1,038 additions and 38 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"dependencies": {
"@internationalized/date": "^3.6.0",
"@nextui-org/accordion": "^2.0.40",
"@nextui-org/alert": "^2.2.7",
"@nextui-org/button": "^2.0.38",
"@nextui-org/card": "^2.0.34",
"@nextui-org/chip": "^2.0.33",
Expand All @@ -38,7 +39,7 @@
"@nextui-org/switch": "^2.0.34",
"@nextui-org/system": "2.2.6",
"@nextui-org/table": "^2.0.40",
"@nextui-org/theme": "2.2.11",
"@nextui-org/theme": "^2.3.0",
"@nextui-org/tooltip": "^2.0.41",
"@react-aria/ssr": "3.9.4",
"@react-aria/visually-hidden": "3.8.12",
Expand Down
20 changes: 20 additions & 0 deletions src/app/comparison-portal/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Metadata } from 'next';

import HungerMapChatbot from '@/components/Chatbot/Chatbot';
import { Topbar } from '@/components/Topbar/Topbar';

export const metadata: Metadata = {
title: 'Comparison Portal',
};

export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen flex flex-col">
<div className="mb-20">
<Topbar />
<HungerMapChatbot />
</div>
<main className="flex flex-col gap-6 lg:gap-10 p-5 lg:p-10 text-content w-full">{children}</main>
</div>
);
}
28 changes: 28 additions & 0 deletions src/app/comparison-portal/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Suspense } from 'react';

import ComparisonAccordionSkeleton from '@/components/ComparisonPortal/ComparisonAccordionSkeleton';
import CountryComparison from '@/components/ComparisonPortal/CountryComparison';
import CountrySelectionSkeleton from '@/components/ComparisonPortal/CountrySelectSkeleton';
import container from '@/container';
import { GlobalDataRepository } from '@/domain/repositories/GlobalDataRepository';

export default async function ComparisonPortal() {
const globalRepo = container.resolve<GlobalDataRepository>('GlobalDataRepository');
const countryMapData = await globalRepo.getMapDataForCountries();
const globalFcsData = await globalRepo.getFcsData();
return (
<div>
<h1>Comparison Portal</h1>
<Suspense
fallback={
<>
<CountrySelectionSkeleton />
<ComparisonAccordionSkeleton />
</>
}
>
<CountryComparison countryMapData={countryMapData} globalFcsData={globalFcsData} />
</Suspense>
</div>
);
}
2 changes: 1 addition & 1 deletion src/components/Charts/CategoricalChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export function CategoricalChart({
// handling the bar and pie chart switch and the theme switch;
useEffect(() => {
setChartOptions(CategoricalChartOperations.getHighChartOptions(data, showPieChart));
}, [showPieChart, theme]);
}, [showPieChart, theme, data]);

const alternativeSwitchButtonProps = disablePieChartSwitch
? undefined
Expand Down
16 changes: 11 additions & 5 deletions src/components/Charts/LineChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import Highcharts from 'highcharts';
import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';

import { ChartContainer } from '@/components/Charts/helpers/ChartContainer';
import { LineChartData } from '@/domain/entities/charts/LineChartData';
Expand Down Expand Up @@ -54,9 +54,15 @@ export function LineChart({
const lineChartOptions: Highcharts.Options | undefined = LineChartOperations.getHighChartOptions(lineChartData);

// the `selectedXAxisRange` saves the to be rendered x-axis range of the chart
// can be changed using the `LinkeChartXAxisSlider` if the param `xAxisSlider==true`
const xAxisLength: number = LineChartOperations.getDistinctXAxisValues(lineChartData).length;
const [selectedXAxisRange, setSelectedXAxisRange] = useState([0, xAxisLength - 1]);
// can be changed using the `LineChartXAxisSlider` if the param `xAxisSlider==true`
const [selectedXAxisRange, setSelectedXAxisRange] = useState([0, 0]);
const xAxisLength = useMemo(() => {
return LineChartOperations.getDistinctXAxisValues(lineChartData).length;
}, [lineChartData]);

useEffect(() => {
setSelectedXAxisRange([0, xAxisLength - 1]);
}, [xAxisLength]);

// controlling if a line or bar chart is rendered; line chart is the default
const [showBarChart, setShowBarChart] = useState(false);
Expand All @@ -75,7 +81,7 @@ export function LineChart({
LineChartOperations.getHighChartOptions(lineChartData, selectedXAxisRange[0], selectedXAxisRange[1])
);
}
}, [showBarChart, theme, selectedXAxisRange]);
}, [showBarChart, theme, selectedXAxisRange, lineChartData]);

// chart slider props - to manipulate the shown x-axis range
const sliderProps = disableXAxisSlider
Expand Down
23 changes: 23 additions & 0 deletions src/components/ComparisonPortal/ComparisonAccordionSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Skeleton } from '@nextui-org/skeleton';
import React from 'react';
import { v4 as uuid } from 'uuid';

export default function ComparisonAccordionSkeleton() {
const N_ITEMS = 5;
return (
<div className="overflow-x-auto rounded-lg shadow-none">
<div className="flex flex-col gap-2 mb-4">
{[...Array(N_ITEMS)].map(() => (
<div
key={uuid()}
className="rounded-medium last:border-b-0 bg-content1 white:bg-white overflow-hidden shadow-md"
>
<Skeleton className="rounded-lg bg-content1 dark:bg-content1">
<div className="h-[69px]" />
</Skeleton>
</div>
))}
</div>
</div>
);
}
31 changes: 31 additions & 0 deletions src/components/ComparisonPortal/CountryComparison.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
'use client';

import { useState } from 'react';

import { useSelectedCountries } from '@/domain/hooks/queryParamsHooks.ts';
import CountryComparisonProps from '@/domain/props/CountryComparisonProps';

import CountryComparisonAccordion from './CountryComparisonAccordion';
import CountrySelection from './CountrySelection';

export default function CountryComparison({ countryMapData, globalFcsData }: CountryComparisonProps) {
const [selectedCountries, setSelectedCountries] = useSelectedCountries(countryMapData);
const [disabledCountryIds, setDisabledCountryIds] = useState<string[]>([]);

return (
<div>
<CountrySelection
countryMapData={countryMapData}
globalFcsData={globalFcsData}
selectedCountries={selectedCountries}
setSelectedCountries={setSelectedCountries}
disabledCountryIds={disabledCountryIds}
/>
<CountryComparisonAccordion
selectedCountries={selectedCountries}
setSelectedCountries={setSelectedCountries}
setDisabledCountryIds={setDisabledCountryIds}
/>
</div>
);
}
74 changes: 74 additions & 0 deletions src/components/ComparisonPortal/CountryComparisonAccordion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
'use client';

import { useMemo } from 'react';

import ComparisonAccordionSkeleton from '@/components/ComparisonPortal/ComparisonAccordionSkeleton';
import { useSnackbar } from '@/domain/contexts/SnackbarContext';
import { useCountryDataListQuery, useCountryIso3DataListQuery } from '@/domain/hooks/countryHooks';
import CountryComparisonAccordionProps from '@/domain/props/CountryComparisonAccordionProps';
import { CountryComparisonOperations } from '@/operations/comparison-portal/CountryComparisonOperations';

import AccordionContainer from '../Accordions/AccordionContainer';

export default function CountryComparisonAccordion({
selectedCountries,
setSelectedCountries,
setDisabledCountryIds,
}: CountryComparisonAccordionProps) {
const { showSnackBar } = useSnackbar();

const countryDataQuery = useCountryDataListQuery(
// selected country ids
selectedCountries?.map((country) => country.properties.adm0_id),
// callback to show snackbar if data not found
(invalidCountryId) => {
if (!selectedCountries) return;
const invalidCountryName = CountryComparisonOperations.getCountryNameById(invalidCountryId, selectedCountries);
CountryComparisonOperations.showDataNotFoundSnackBar(showSnackBar, invalidCountryName);
setSelectedCountries(selectedCountries.filter((country) => country.properties.adm0_id !== invalidCountryId));
setDisabledCountryIds((prevDisabledCountryIds) => [...prevDisabledCountryIds, invalidCountryId.toString()]);
}
);

const countryIso3DataQuery = useCountryIso3DataListQuery(
// selected country iso3 codes
selectedCountries?.map((country) => country.properties.iso3),
// callback to show snackbar if data not found
(countryCode) => {
if (!selectedCountries) return;
const countryName = CountryComparisonOperations.getCountryNameByIso3(countryCode, selectedCountries);
CountryComparisonOperations.showDataNotFoundSnackBar(showSnackBar, countryName);
}
);

const isLoading = useMemo(() => {
return (
countryDataQuery.some((result) => result.isLoading) || countryIso3DataQuery.some((result) => result.isLoading)
);
}, [countryDataQuery, countryIso3DataQuery]);

const { countryDataList, countryIso3DataList } = useMemo(
() => CountryComparisonOperations.getFilteredCountryData(countryDataQuery, countryIso3DataQuery),
[countryDataQuery, countryIso3DataQuery]
);

const accordionItems = useMemo(() => {
if (!selectedCountries) return undefined;
const chartData = CountryComparisonOperations.getChartData(countryDataList, countryIso3DataList, selectedCountries);
const selectedCountryNames = selectedCountries.map((country) => country.properties.adm0_name);
return CountryComparisonOperations.getComparisonAccordionItems(chartData, selectedCountryNames, isLoading);
}, [countryDataList, countryIso3DataList, selectedCountries]);

if (!accordionItems || (countryDataList.length < 2 && isLoading)) return <ComparisonAccordionSkeleton />;

if (countryDataList.length < 2) {
return (
<p className="pb-4">
Select {countryDataList.length === 1 ? 'one additional country' : 'two or more countries'} to start a
comparison.
</p>
);
}

return <AccordionContainer multipleSelectionMode loading={isLoading} items={accordionItems} />;
}
16 changes: 16 additions & 0 deletions src/components/ComparisonPortal/CountrySelectSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Skeleton } from '@nextui-org/skeleton';
import React from 'react';

export default function CountrySelectionSkeleton() {
return (
<div className="pb-4 space-y-6">
<div className="group flex flex-col w-full">
<div className="w-full flex flex-col">
<Skeleton className="relative px-3 gap-3 w-full shadow-sm h-10 min-h-10 rounded-medium">
<div className="inline-flex h-full w-[calc(100%_-_theme(spacing.6))] min-h-4 items-center gap-1.5 box-border" />
</Skeleton>
</div>
</div>
</div>
);
}
80 changes: 80 additions & 0 deletions src/components/ComparisonPortal/CountrySelection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
'use client';

import { Select, SelectItem } from '@nextui-org/react';
import { useMemo } from 'react';

import { CountrySelectionProps } from '@/domain/props/CountrySelectionProps';
import { CountrySelectionOperations } from '@/operations/comparison-portal/CountrySelectionOperations';
import FcsChoroplethOperations from '@/operations/map/FcsChoroplethOperations';

import { CustomButton } from '../Buttons/CustomButton';

export default function CountrySelection({
countryMapData,
globalFcsData,
selectedCountries,
setSelectedCountries,
disabledCountryIds,
}: CountrySelectionProps) {
const selectedKeys = useMemo(
() => selectedCountries?.map((country) => country.properties.adm0_id.toString()),
[selectedCountries]
);

const availableCountries = useMemo(() => {
return countryMapData.features.filter((country) => FcsChoroplethOperations.checkIfActive(country, globalFcsData));
}, [countryMapData, globalFcsData]);

const disabledKeys = useMemo(() => {
if (!selectedCountries) return [];
return availableCountries
.filter(
(country) =>
// if there are already 5 selected countries, disable the rest
selectedCountries.length >= 5 &&
!selectedCountries.find(
(selectedCountry) => selectedCountry.properties.adm0_id === country.properties.adm0_id
)
)
.map((country) => country.properties.adm0_id.toString())
.concat(disabledCountryIds);
}, [selectedCountries, availableCountries, disabledCountryIds]);

return (
<div className="pb-4 flex items-center gap-4">
<Select
placeholder="Select up to 5 countries"
aria-label="Select countries for comparison"
selectionMode="multiple"
onSelectionChange={(keys) =>
CountrySelectionOperations.onSelectionChange(keys, setSelectedCountries, countryMapData)
}
defaultSelectedKeys={selectedKeys}
selectedKeys={selectedKeys}
disabledKeys={disabledKeys}
className="w-full"
variant="faded"
color="primary"
>
{availableCountries
.sort((a, b) => a.properties.adm0_name.localeCompare(b.properties.adm0_name))
.map((country) => (
<SelectItem
key={country.properties.adm0_id.toString()}
aria-label={country.properties.adm0_name}
className="transition-all hover:text-background dark:text-foreground"
>
{country.properties.adm0_name}
</SelectItem>
))}
</Select>
<CustomButton
variant="bordered"
onClick={() => setSelectedCountries([])}
isDisabled={selectedCountries === undefined || selectedCountries.length === 0}
>
Clear
</CustomButton>
</div>
);
}
39 changes: 39 additions & 0 deletions src/components/ComparisonPortal/NoDataHint.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Alert } from '@nextui-org/alert';
import { useEffect, useState } from 'react';

import { isLineChartData } from '@/domain/entities/charts/LineChartData';
import { NoDataHintProps } from '@/domain/props/NoDataHintProps.ts';

export default function NoDataHint({ chartData, selectedCountryNames, isLoading }: NoDataHintProps) {
const [formattedMissingCountryNames, setFormattedMissingCountryNames] = useState<string | null>(null);

useEffect(() => {
if (isLoading) return;

const countryNamesInChart = isLineChartData(chartData)
? chartData.lines.map((line) => line.name)
: chartData.categories.map((category) => category.name);
const missingCountryNames = selectedCountryNames.filter(
(countryName) => !countryNamesInChart.includes(countryName)
);
switch (missingCountryNames.length) {
case 0:
setFormattedMissingCountryNames(null);
break;
case 1:
setFormattedMissingCountryNames(missingCountryNames[0]);
break;
default:
setFormattedMissingCountryNames(
`${missingCountryNames.slice(0, -1).join(', ')} and ${missingCountryNames.slice(-1)}`
);
}
}, [isLoading, chartData, selectedCountryNames]);

return formattedMissingCountryNames ? (
<Alert
description={`No data for ${formattedMissingCountryNames}.`}
classNames={{ mainWrapper: 'justify-center', iconWrapper: 'my-0.5' }}
/>
) : null;
}
5 changes: 5 additions & 0 deletions src/domain/entities/ApiError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export type ApiError = [{ Error: string }, number];

export function isApiError(error: unknown): error is ApiError {
return error instanceof Array && error.length === 2 && typeof error[1] === 'number';
}
4 changes: 2 additions & 2 deletions src/domain/entities/charts/InflationGraphs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import { ChartData } from '../common/ChartData';
export interface InflationGraphs {
type: LineChartDataType.INFLATION_CHARTS;
headline: {
data: ChartData[];
data: ChartData[] | undefined;
};
food: {
data: ChartData[];
data: ChartData[] | undefined;
};
}
Loading

0 comments on commit 0689a93

Please sign in to comment.