Skip to content

Commit

Permalink
conflict alerts display (#27)
Browse files Browse the repository at this point in the history
* feat: old version

* fix: use predefined colors

* fix: add type param to generic type

* fix: requested code style changes

* feat: add legend to conflict layer
  • Loading branch information
Tschonti authored Nov 13, 2024
1 parent 4a0c8dc commit 740d77a
Show file tree
Hide file tree
Showing 21 changed files with 277 additions and 32 deletions.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@nextui-org/react": "^2.4.8",
"@nextui-org/skeleton": "^2.0.32",
"@nextui-org/snippet": "2.0.43",
"@nextui-org/spinner": "^2.0.34",
"@nextui-org/switch": "^2.0.34",
"@nextui-org/system": "2.2.6",
"@nextui-org/table": "^2.0.40",
Expand Down Expand Up @@ -58,6 +59,7 @@
"react": "18.3.1",
"react-dom": "18.3.1",
"react-leaflet": "^4.2.1",
"react-leaflet-cluster": "^2.1.0",
"react-pdf": "^9.1.1",
"tailwind-variants": "0.1.20",
"uuid": "^11.0.3"
Expand All @@ -67,6 +69,7 @@
"@commitlint/config-conventional": "^19.5.0",
"@types/geojson": "^7946.0.14",
"@types/leaflet": "^1.9.14",
"@types/leaflet.markercluster": "^1.5.5",
"@types/node": "20.5.7",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
Expand Down
8 changes: 6 additions & 2 deletions src/components/Accordions/Accordion.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
'use client';

import { Accordion, AccordionItem } from '@nextui-org/accordion';
import { Spinner } from '@nextui-org/spinner';

import { AccordionsProps } from '@/domain/props/AccordionProps';

import { Tooltip } from '../Tooltip/Tooltip';

export default function CustomAccordion({ items }: AccordionsProps) {
export default function CustomAccordion({ items, loading = false }: AccordionsProps) {
return (
<div className="w-full flex justify-start items-start max-w-[600px] overflow-visible overflow-x-auto p-2 rounded-lg">
<Accordion variant="splitted">
Expand All @@ -17,7 +18,10 @@ export default function CustomAccordion({ items }: AccordionsProps) {
className="last:border-b-[none] dark:bg-black white:bg-white overflow-x-auto"
title={
<div className="flex justify-between items-center w-full">
<span>{item.title}</span>
<div className="flex gap-4">
<span>{item.title}</span>
{loading && <Spinner size="sm" />}
</div>
{item.tooltipInfo ? (
<Tooltip text={item.tooltipInfo}>
{item.iconSrc && <img src={item.iconSrc} alt="info icon" className="w-[37px] h-[37px] p-[5.5px]" />}
Expand Down
3 changes: 2 additions & 1 deletion src/components/Legend/LegendContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import CustomAccordion from '../Accordions/Accordion';
import GradientLegend from './GradientLegend';
import PointLegend from './PointLegend';

export default function LegendContainer({ items }: LegendContainerProps) {
export default function LegendContainer({ items, loading = false }: LegendContainerProps) {
return (
<div className="w-[450px]">
<CustomAccordion
loading={loading}
items={items.map((item) => ({
title: item.title,
iconSrc: '/Images/InfoIcon.svg',
Expand Down
15 changes: 15 additions & 0 deletions src/components/Map/Alerts/AlertContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { useSidebar } from '@/domain/contexts/SidebarContext';
import { AlertType } from '@/domain/enums/AlertType';

import { ConflictLayer } from './ConflictLayer';

export function AlertContainer() {
const { selectedAlert } = useSidebar();

switch (selectedAlert) {
case AlertType.CONFLICTS:
return <ConflictLayer />;
default:
return null; // TODO: hazard layers
}
}
50 changes: 50 additions & 0 deletions src/components/Map/Alerts/ConflictLayer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { useMemo } from 'react';
import { CircleMarker } from 'react-leaflet';
import MarkerClusterGroup from 'react-leaflet-cluster';

import LegendContainer from '@/components/Legend/LegendContainer';
import { MAP_MAX_ZOOM } from '@/domain/constant/Map';
import { ConflictType } from '@/domain/enums/ConflictType';
import { useConflictQuery } from '@/domain/hooks/alertHooks';
import ConflictOperations from '@/operations/ConflictOperations';
import GeometryOperations from '@/operations/GeometryOperations';
import { getTailwindColor } from '@/utils/tailwind-util';

export function ConflictLayer() {
const { data, isPending } = useConflictQuery();
const conflictsByType = useMemo(() => ConflictOperations.sortConflictsByType(data), [data]);

return (
<>
<div className="absolute bottom-6 right-8 z-9999">
<LegendContainer loading={isPending || !data} items={ConflictOperations.generateConflictLegend()} />
</div>
{!isPending &&
data &&
(Object.keys(conflictsByType) as ConflictType[]).map((conflictType) => (
<MarkerClusterGroup
key={conflictType}
iconCreateFunction={(cluster) => ConflictOperations.createClusterCustomIcon(cluster, conflictType)}
showCoverageOnHover={false}
spiderLegPolylineOptions={{ weight: 0 }}
disableClusteringAtZoom={MAP_MAX_ZOOM}
zoomToBoundsOnClick={false}
maxClusterRadius={60}
spiderfyOnMaxZoom={false}
>
{conflictsByType[conflictType].map((marker) => (
<CircleMarker
radius={3}
color="white"
fillColor={getTailwindColor(`--nextui-${ConflictOperations.getMarkerColor(conflictType)}`)}
weight={1}
fillOpacity={1}
key={marker.geometry.coordinates.toString()}
center={GeometryOperations.swapCoords(marker.geometry.coordinates)}
/>
))}
</MarkerClusterGroup>
))}
</>
);
}
7 changes: 5 additions & 2 deletions src/components/Map/Map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import 'leaflet/dist/leaflet.css';

import { MapContainer, ZoomControl } from 'react-leaflet';

import { MAP_MAX_ZOOM, MAP_MIN_ZOOM } from '@/domain/constant/Map';
import { MapProps } from '@/domain/props/MapProps';

import { AlertContainer } from './Alerts/AlertContainer';
import VectorTileLayer from './VectorTileLayer';

export default function Map({ countries, disputedAreas }: MapProps) {
Expand All @@ -15,12 +17,13 @@ export default function Map({ countries, disputedAreas }: MapProps) {
[-90, -180],
[90, 180],
]}
minZoom={3}
maxZoom={8}
minZoom={MAP_MIN_ZOOM}
maxZoom={MAP_MAX_ZOOM}
maxBoundsViscosity={1.0}
zoomControl={false}
style={{ height: '100%', width: '100%', zIndex: 1 }}
>
<AlertContainer />
{countries && <VectorTileLayer countries={countries} disputedAreas={disputedAreas} />}
<ZoomControl position="bottomright" />
</MapContainer>
Expand Down
2 changes: 2 additions & 0 deletions src/domain/constant/Map.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const MAP_MAX_ZOOM = 8;
export const MAP_MIN_ZOOM = 3;
13 changes: 9 additions & 4 deletions src/domain/entities/alerts/Conflict.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { LatLngExpression } from 'leaflet';

import { ConflictType } from '@/domain/enums/ConflictType';

import { Feature } from '../common/Feature';

export type Conflict = Feature<{
count: number;
event_type: ConflictType;
}>;
export type Conflict = Feature<
{
count: number;
event_type: ConflictType;
},
LatLngExpression
>;
5 changes: 5 additions & 0 deletions src/domain/entities/alerts/ConflictTypeMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { ConflictType } from '@/domain/enums/ConflictType';

import { Conflict } from './Conflict';

export type ConflictTypeMap = { [K in ConflictType]: Conflict[] };
6 changes: 4 additions & 2 deletions src/domain/entities/common/Feature.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { LatLngExpression } from 'leaflet';

import { Geometry } from './Geometry';

export interface Feature<T> {
export interface Feature<T, U = LatLngExpression[][][]> {
type: string;
geometry: Geometry;
geometry: Geometry<U>;
properties: T;
}
6 changes: 2 additions & 4 deletions src/domain/entities/common/Geometry.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { LatLngExpression } from 'leaflet';

export interface Geometry {
export interface Geometry<T> {
type: string;
coordinates: LatLngExpression[][][]; // Maybe a common type is not best idea here, the coordinate arrays seem to have different dephts.
coordinates: T;
}
4 changes: 3 additions & 1 deletion src/domain/entities/country/CountryMimiData.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { LatLngExpression } from 'leaflet';

import { Geometry } from '../common/Geometry';
import { RegionNutritionProperties } from '../region/RegionNutritionProperties';

Expand All @@ -10,7 +12,7 @@ export interface CountryMimiData {
};
features: {
type: string;
geometry: Geometry;
geometry: Geometry<LatLngExpression[][][]>;
properties: RegionNutritionProperties;
id: string;
}[];
Expand Down
7 changes: 5 additions & 2 deletions src/domain/enums/AlertType.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
export enum AlertType {
HUNGER = 'hunger',
CONFLICTS = 'conflicts',
CONFLICT1 = 'conflict1',
CONFLICT2 = 'conflict2',
HAZARDS = 'hazards',
COVID19 = 'covid19',
FLOODS = 'floods',
DROUGHTS = 'droughts',
EARTHQUAKES = 'earthquakes',
CYCLONES = 'cyclones',
}
1 change: 1 addition & 0 deletions src/domain/props/AccordionProps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ import { AccordionItemProps } from '../entities/accordions/Accordions';

export interface AccordionsProps {
items: AccordionItemProps[];
loading?: boolean;
}
1 change: 1 addition & 0 deletions src/domain/props/LegendContainerProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ import PointLegendContainerItem from './PointLegendContainerItem';

export default interface LegendContainerProps {
items: (PointLegendContainerItem | GradientLegendContainerItem)[];
loading?: boolean;
}
72 changes: 72 additions & 0 deletions src/operations/ConflictOperations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import L, { MarkerCluster } from 'leaflet';

import { Conflict } from '@/domain/entities/alerts/Conflict';
import { ConflictTypeMap } from '@/domain/entities/alerts/ConflictTypeMap';
import { ConflictType } from '@/domain/enums/ConflictType';
import PointLegendContainerItem from '@/domain/props/PointLegendContainerItem';

export default class ConflictOperations {
static getMarkerColor(conflictType: ConflictType): string {
switch (conflictType) {
case ConflictType.PROTESTS:
return 'conflictProtest';
case ConflictType.RIOTS:
return 'conflictRiot';
case ConflictType.BATTLES:
return 'conflictBattle';
case ConflictType.CIVIL_VIOLENCE:
return 'conflictCivil';
case ConflictType.EXPLOSIONS:
return 'conflictExplosion';
default:
return 'conflictStrategic';
}
}

static sortConflictsByType(data?: Conflict[]): ConflictTypeMap {
const result: ConflictTypeMap = {
[ConflictType.BATTLES]: [],
[ConflictType.PROTESTS]: [],
[ConflictType.RIOTS]: [],
[ConflictType.CIVIL_VIOLENCE]: [],
[ConflictType.EXPLOSIONS]: [],
[ConflictType.STRATEGIC]: [],
};
data?.forEach((conflict) => {
result[conflict.properties.event_type].push(conflict);
});
return result;
}

static createClusterCustomIcon(cluster: MarkerCluster, conflictType: ConflictType): L.DivIcon {
return L.divIcon({
html: `<span
style="
width: ${Math.min(Math.floor(cluster.getChildCount() / 5) + 20, 40)}px;
height: ${Math.min(Math.floor(cluster.getChildCount() / 5) + 20, 40)}px;
"
class="bg-${ConflictOperations.getMarkerColor(conflictType)} flex items-center justify-center rounded-full border-white border-1 text-white font-bold"
>${cluster.getChildCount()}</span>`,
className: '',
iconSize: L.point(40, 40, true),
});
}

static generateConflictLegend(): PointLegendContainerItem[] {
return [
{
title: 'Types of conflict',
tooltipInfo:
'All reported violence and conflicts across Africa, the Middle East, South and South East Asia, Eastern and Southeastern Europe and the Balkans.',
records: [
{ label: ConflictType.BATTLES, color: 'conflictBattle' },
{ label: ConflictType.CIVIL_VIOLENCE, color: 'conflictCivil' },
{ label: ConflictType.EXPLOSIONS, color: 'conflictExplosion' },
{ label: ConflictType.RIOTS, color: 'conflictRiot' },
{ label: ConflictType.PROTESTS, color: 'conflictProtest' },
{ label: ConflictType.STRATEGIC, color: 'conflictStrategic' },
],
},
];
}
}
15 changes: 15 additions & 0 deletions src/operations/GeometryOperations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { LatLngExpression, LatLngTuple } from 'leaflet';

export default class GeometryOperations {
static swapCoords(coords: LatLngExpression): LatLngExpression {
if (!GeometryOperations.isLatLngTuple(coords)) {
throw Error('Invlaid coordinate array');
}

return [coords[1], coords[0]];
}

static isLatLngTuple(value: LatLngExpression): value is LatLngTuple {
return Array.isArray(value) && value.length === 2 && typeof value[0] === 'number' && typeof value[1] === 'number';
}
}
39 changes: 27 additions & 12 deletions src/operations/sidebar/SidebarOperations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,26 +41,41 @@ export class SidebarOperations {
icon: '/menu_fcs.png',
},
{
key: AlertType.CONFLICTS,
label: 'Conflicts',
icon: '/menu_conflicts.png',
key: AlertType.HAZARDS,
label: 'Hazards',
icon: '/menu_hazards.png',
subalerts: [
{
key: AlertType.CONFLICT1,
label: 'Conflicts 1',
icon: '/menu_conflicts.png',
key: AlertType.COVID19,
label: 'COVID-19',
icon: '/menu_hazards.png',
},
{
key: AlertType.FLOODS,
label: 'Floods',
icon: '/menu_hazards.png',
},
{
key: AlertType.DROUGHTS,
label: 'Droughts',
icon: '/menu_hazards.png',
},
{
key: AlertType.EARTHQUAKES,
label: 'Earthquakes',
icon: '/menu_hazards.png',
},
{
key: AlertType.CONFLICT2,
label: 'Conflicts 2',
icon: '/menu_conflicts.png',
key: AlertType.CYCLONES,
label: 'Cyclones',
icon: '/menu_hazards.png',
},
],
},
{
key: AlertType.HAZARDS,
label: 'Hazards',
icon: '/menu_hazards.png',
key: AlertType.CONFLICTS,
label: 'Conflicts',
icon: '/menu_conflicts.png',
},
];

Expand Down
Loading

0 comments on commit 740d77a

Please sign in to comment.