Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Countries with very high level of hunger alert #17

Merged
merged 8 commits into from
Nov 10, 2024
8 changes: 7 additions & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import HungerAlertLoader from '@/components/HungerAlert/HungerAlertLoader';
import MapLoader from '@/components/Map/MapLoader';
import container from '@/container';
import { GlobalDataRepository } from '@/domain/repositories/GlobalDataRepository';
Expand All @@ -7,5 +8,10 @@ export default async function Home() {
const countryMapDataPromise = globalRepo.getMapDataForCountries();
const disputedAreasPromise = globalRepo.getDisputedAreas();
const [countryMapData, disputedAreas] = await Promise.all([countryMapDataPromise, disputedAreasPromise]);
return <MapLoader countries={countryMapData} disputedAreas={disputedAreas} />;
return (
<>
<MapLoader countries={countryMapData} disputedAreas={disputedAreas} />
<HungerAlertLoader countryMapData={countryMapData} />
</>
);
}
45 changes: 45 additions & 0 deletions src/components/DataTable/DataTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Pagination, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow } from '@nextui-org/react';
import React, { useMemo, useState } from 'react';

import DataTableProps from '@/domain/props/DataTableProps';

export default function DataTable<T>({ rows, columns }: DataTableProps<T>) {
const [page, setPage] = useState(1);
const rowsPerPage = 10;

const totalPages = Math.ceil(rows.length / rowsPerPage);

const paginatedRows = useMemo(() => {
const start = (page - 1) * rowsPerPage;
const end = start + rowsPerPage;
return rows.slice(start, end);
}, [page, rows]);

return (
<div className="flex-1 flex flex-col justify-between items-center">
<Table removeWrapper>
<TableHeader>
{columns.map((column) => (
<TableColumn key={column.key} className="font-bold">
{column.label}
</TableColumn>
))}
</TableHeader>
<TableBody>
{paginatedRows.map((row, index) => {
const uniqueRowKey = `${index}-${String(row[columns[0].key as keyof T])}`;
return (
<TableRow key={uniqueRowKey}>
{columns.map((column) => (
<TableCell key={column.key}>{String(row[column.key as keyof T])}</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>

<Pagination isCompact showControls page={page} total={totalPages} onChange={(newPage) => setPage(newPage)} />
</div>
);
}
42 changes: 42 additions & 0 deletions src/components/HungerAlert/HungerAlert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
'use client';

import './style.css';

import { useMemo, useState } from 'react';

import DataTable from '@/components/DataTable/DataTable';
import PopupModal from '@/components/PopupModal/PopupModal';
import HungerLevel from '@/domain/entities/HungerLevel';
import HungerAlertProps from '@/domain/props/HungerAlertProps';

import HungerAlertOperations from '../../operations/hungerAlert/HungerAlertOperations.ts';

export default function HungerAlert({ countryMapData }: HungerAlertProps) {
const [isModalOpen, setIsModalOpen] = useState(false);

const toggleModal = () => setIsModalOpen((prevIsModalOpen) => !prevIsModalOpen);

const countriesWithHighHunger: HungerLevel[] = useMemo(
() => HungerAlertOperations.getHungerAlertData(countryMapData),
[countryMapData]
);

return (
<div className="absolute bottom-20 left-20 z-10 cursor-pointer">
<button className={HungerAlertOperations.getPulseClasses()} onClick={toggleModal} type="button">
<p className="text-4xl font-bold mb-2">{countriesWithHighHunger.length}</p>
<p className="text-center font-medium text-sm px-4">Number of Countries with Very High Levels of Hunger</p>
</button>

<PopupModal
isModalOpen={isModalOpen}
toggleModal={toggleModal}
modalTitle="Countries with Very High Levels of Hunger"
modalSize="3xl"
modalHeight="min-h-[75%]"
>
<DataTable rows={countriesWithHighHunger} columns={HungerAlertOperations.getHungerAlertModalColumns()} />
</PopupModal>
</div>
);
}
15 changes: 15 additions & 0 deletions src/components/HungerAlert/HungerAlertLoader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
'use client';

import dynamic from 'next/dynamic';

import HungerAlertSkeleton from '@/components/HungerAlert/HungerAlertSkeleton';
import HungerAlertProps from '@/domain/props/HungerAlertProps';

const LazyHungerAlertLoader = dynamic(() => import('@/components/HungerAlert/HungerAlert'), {
ssr: false,
loading: () => <HungerAlertSkeleton />,
});

export default function HungerAlertLoader(props: HungerAlertProps) {
return <LazyHungerAlertLoader {...props} />;
}
11 changes: 11 additions & 0 deletions src/components/HungerAlert/HungerAlertSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Skeleton } from '@nextui-org/skeleton';

export default function HungerAlertSkeleton() {
return (
<div className="absolute bottom-20 left-20 z-10 cursor-pointer">
<Skeleton className="rounded-full flex flex-col items-center justify-center text-center bg-white dark:bg-content2 relative p-5">
<div className="w-36 h-36" />
</Skeleton>
</div>
);
}
31 changes: 31 additions & 0 deletions src/components/HungerAlert/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
.pulse::before,
.pulse::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 50%;
background: rgba(0, 0, 0, 0.2);
z-index: -1;
animation: pulse-animation 2s infinite ease-out;
}

.pulse::after {
animation-delay: 1s;
}

@keyframes pulse-animation {
0% {
transform: scale(1);
opacity: 0.6;
}
50% {
opacity: 0.3;
}
100% {
transform: scale(1.5);
opacity: 0;
}
}
3 changes: 2 additions & 1 deletion src/components/Map/Map.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable */
import 'leaflet/dist/leaflet.css';

import { Feature, FeatureCollection } from 'geojson';
Expand Down Expand Up @@ -78,7 +79,7 @@ export default function Map({ countries }: MapProps) {
maxZoom={8}
maxBoundsViscosity={1.0}
zoomControl={false}
style={{ height: '100%', width: '100%', zIndex: 40 }}
style={{ height: '100%', width: '100%', zIndex: 1 }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
Expand Down
32 changes: 32 additions & 0 deletions src/components/PopupModal/PopupModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use client';

import { Modal, ModalBody, ModalContent, ModalHeader } from '@nextui-org/react';

import PopupModalProps from '@/domain/props/PopupModalProps';

export default function PopupModal({
isModalOpen,
toggleModal,
modalTitle,
modalSize,
modalHeight,
children,
}: PopupModalProps) {
return (
<Modal
backdrop="opaque"
isOpen={isModalOpen}
onOpenChange={toggleModal}
classNames={{
backdrop: 'bg-gradient-to-t from-zinc-900 to-zinc-900/10 backdrop-opacity-20',
base: `p-10 ${modalHeight}`,
}}
size={modalSize}
>
<ModalContent>
<ModalHeader className="text-3xl font-bold mb-2">{modalTitle}</ModalHeader>
<ModalBody>{children}</ModalBody>
</ModalContent>
</Modal>
);
}
2 changes: 1 addition & 1 deletion src/domain/contexts/SidebarContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ interface SidebarContextType extends SidebarState, SelectedMapTypeState, Selecte
const SidebarContext = createContext<SidebarContextType | undefined>(undefined);

export function SidebarProvider({ children }: { children: ReactNode }) {
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [selectedMapType, setSelectedMapType] = useState<GlobalInsight>(GlobalInsight.FOOD);
const [selectedAlert, setSelectedAlert] = useState<AlertType | null>(AlertType.HUNGER);

Expand Down
4 changes: 4 additions & 0 deletions src/domain/entities/Column.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export default interface Column {
label: string;
key: string;
}
5 changes: 5 additions & 0 deletions src/domain/entities/HungerLevel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default interface HungerLevel {
rank: number;
country: string;
fcs: string;
}
6 changes: 6 additions & 0 deletions src/domain/props/DataTableProps.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import Column from '../entities/Column';

export default interface DataTableProps<T> {
rows: T[];
columns: Column[];
}
5 changes: 5 additions & 0 deletions src/domain/props/HungerAlertProps.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { CountryMapDataWrapper } from '../entities/country/CountryMapData';

export default interface HungerAlertProps {
countryMapData: CountryMapDataWrapper;
}
8 changes: 8 additions & 0 deletions src/domain/props/PopupModalProps.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default interface PopupModalProps {
isModalOpen: boolean;
toggleModal: () => void;
modalTitle: string;
modalSize: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | 'full';
modalHeight: string;
children: React.ReactNode; // modalBody
}
31 changes: 31 additions & 0 deletions src/operations/hungerAlert/HungerAlertOperations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { CountryMapDataWrapper } from '@/domain/entities/country/CountryMapData';
import HungerLevel from '@/domain/entities/HungerLevel';

export default class HungerAlertOperations {
static getHungerAlertData(countryMapData: CountryMapDataWrapper): HungerLevel[] {
try {
return countryMapData.features
.filter(({ properties: { fcs } }) => typeof fcs === 'number' && fcs >= 0.4)
.sort((country1, country2) => (country2.properties.fcs as number) - (country1.properties.fcs as number))
.map(({ properties: { adm0_name: countryName, fcs } }, index) => ({
rank: index + 1,
country: countryName,
fcs: `${Math.floor((fcs as number) * 100)}%`,
}));
} catch {
return [];
}
}

static getHungerAlertModalColumns() {
return [
{ label: 'Rank', key: 'rank' },
{ label: 'Country', key: 'country' },
{ label: 'FCS', key: 'fcs' },
];
}

static getPulseClasses(): string {
return 'pulse w-48 h-48 rounded-full flex flex-col items-center justify-center text-center bg-white dark:bg-content2 relative p-5';
}
}
Loading