diff --git a/package.json b/package.json index 7a5bb551..573b4b2e 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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" @@ -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", diff --git a/src/components/Accordions/Accordion.tsx b/src/components/Accordions/Accordion.tsx index e3f3d6cb..41bbb59d 100644 --- a/src/components/Accordions/Accordion.tsx +++ b/src/components/Accordions/Accordion.tsx @@ -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 (
@@ -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={
- {item.title} +
+ {item.title} + {loading && } +
{item.tooltipInfo ? ( {item.iconSrc && info icon} diff --git a/src/components/Legend/LegendContainer.tsx b/src/components/Legend/LegendContainer.tsx index fe162546..5333b755 100644 --- a/src/components/Legend/LegendContainer.tsx +++ b/src/components/Legend/LegendContainer.tsx @@ -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 (
({ title: item.title, iconSrc: '/Images/InfoIcon.svg', diff --git a/src/components/Map/Alerts/AlertContainer.tsx b/src/components/Map/Alerts/AlertContainer.tsx new file mode 100644 index 00000000..257e398d --- /dev/null +++ b/src/components/Map/Alerts/AlertContainer.tsx @@ -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 ; + default: + return null; // TODO: hazard layers + } +} diff --git a/src/components/Map/Alerts/ConflictLayer.tsx b/src/components/Map/Alerts/ConflictLayer.tsx new file mode 100644 index 00000000..bb56bcd3 --- /dev/null +++ b/src/components/Map/Alerts/ConflictLayer.tsx @@ -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 ( + <> +
+ +
+ {!isPending && + data && + (Object.keys(conflictsByType) as ConflictType[]).map((conflictType) => ( + ConflictOperations.createClusterCustomIcon(cluster, conflictType)} + showCoverageOnHover={false} + spiderLegPolylineOptions={{ weight: 0 }} + disableClusteringAtZoom={MAP_MAX_ZOOM} + zoomToBoundsOnClick={false} + maxClusterRadius={60} + spiderfyOnMaxZoom={false} + > + {conflictsByType[conflictType].map((marker) => ( + + ))} + + ))} + + ); +} diff --git a/src/components/Map/Map.tsx b/src/components/Map/Map.tsx index 0fd45f99..dfb1eb65 100644 --- a/src/components/Map/Map.tsx +++ b/src/components/Map/Map.tsx @@ -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) { @@ -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 }} > + {countries && } diff --git a/src/domain/constant/Map.ts b/src/domain/constant/Map.ts new file mode 100644 index 00000000..e74a01ad --- /dev/null +++ b/src/domain/constant/Map.ts @@ -0,0 +1,2 @@ +export const MAP_MAX_ZOOM = 8; +export const MAP_MIN_ZOOM = 3; diff --git a/src/domain/entities/alerts/Conflict.ts b/src/domain/entities/alerts/Conflict.ts index 1d153bbd..e9578f45 100644 --- a/src/domain/entities/alerts/Conflict.ts +++ b/src/domain/entities/alerts/Conflict.ts @@ -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 +>; diff --git a/src/domain/entities/alerts/ConflictTypeMap.ts b/src/domain/entities/alerts/ConflictTypeMap.ts new file mode 100644 index 00000000..c900fee6 --- /dev/null +++ b/src/domain/entities/alerts/ConflictTypeMap.ts @@ -0,0 +1,5 @@ +import { ConflictType } from '@/domain/enums/ConflictType'; + +import { Conflict } from './Conflict'; + +export type ConflictTypeMap = { [K in ConflictType]: Conflict[] }; diff --git a/src/domain/entities/common/Feature.ts b/src/domain/entities/common/Feature.ts index 496772ff..a2ccc48c 100644 --- a/src/domain/entities/common/Feature.ts +++ b/src/domain/entities/common/Feature.ts @@ -1,7 +1,9 @@ +import { LatLngExpression } from 'leaflet'; + import { Geometry } from './Geometry'; -export interface Feature { +export interface Feature { type: string; - geometry: Geometry; + geometry: Geometry; properties: T; } diff --git a/src/domain/entities/common/Geometry.ts b/src/domain/entities/common/Geometry.ts index 82e604d6..1079d5ba 100644 --- a/src/domain/entities/common/Geometry.ts +++ b/src/domain/entities/common/Geometry.ts @@ -1,6 +1,4 @@ -import { LatLngExpression } from 'leaflet'; - -export interface Geometry { +export interface Geometry { type: string; - coordinates: LatLngExpression[][][]; // Maybe a common type is not best idea here, the coordinate arrays seem to have different dephts. + coordinates: T; } diff --git a/src/domain/entities/country/CountryMimiData.ts b/src/domain/entities/country/CountryMimiData.ts index 23f2ced8..36ff1444 100644 --- a/src/domain/entities/country/CountryMimiData.ts +++ b/src/domain/entities/country/CountryMimiData.ts @@ -1,3 +1,5 @@ +import { LatLngExpression } from 'leaflet'; + import { Geometry } from '../common/Geometry'; import { RegionNutritionProperties } from '../region/RegionNutritionProperties'; @@ -10,7 +12,7 @@ export interface CountryMimiData { }; features: { type: string; - geometry: Geometry; + geometry: Geometry; properties: RegionNutritionProperties; id: string; }[]; diff --git a/src/domain/enums/AlertType.ts b/src/domain/enums/AlertType.ts index 30f3396e..7057eb51 100644 --- a/src/domain/enums/AlertType.ts +++ b/src/domain/enums/AlertType.ts @@ -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', } diff --git a/src/domain/props/AccordionProps.tsx b/src/domain/props/AccordionProps.tsx index 13ed209b..a491a07e 100644 --- a/src/domain/props/AccordionProps.tsx +++ b/src/domain/props/AccordionProps.tsx @@ -2,4 +2,5 @@ import { AccordionItemProps } from '../entities/accordions/Accordions'; export interface AccordionsProps { items: AccordionItemProps[]; + loading?: boolean; } diff --git a/src/domain/props/LegendContainerProps.ts b/src/domain/props/LegendContainerProps.ts index aa186d6e..2df6ae03 100644 --- a/src/domain/props/LegendContainerProps.ts +++ b/src/domain/props/LegendContainerProps.ts @@ -3,4 +3,5 @@ import PointLegendContainerItem from './PointLegendContainerItem'; export default interface LegendContainerProps { items: (PointLegendContainerItem | GradientLegendContainerItem)[]; + loading?: boolean; } diff --git a/src/operations/ConflictOperations.ts b/src/operations/ConflictOperations.ts new file mode 100644 index 00000000..63f604af --- /dev/null +++ b/src/operations/ConflictOperations.ts @@ -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: `${cluster.getChildCount()}`, + 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' }, + ], + }, + ]; + } +} diff --git a/src/operations/GeometryOperations.ts b/src/operations/GeometryOperations.ts new file mode 100644 index 00000000..5344d544 --- /dev/null +++ b/src/operations/GeometryOperations.ts @@ -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'; + } +} diff --git a/src/operations/sidebar/SidebarOperations.ts b/src/operations/sidebar/SidebarOperations.ts index 1450186a..c43a9cfb 100644 --- a/src/operations/sidebar/SidebarOperations.ts +++ b/src/operations/sidebar/SidebarOperations.ts @@ -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', }, ]; diff --git a/src/utils/tailwind-util.ts b/src/utils/tailwind-util.ts new file mode 100644 index 00000000..2ee0a864 --- /dev/null +++ b/src/utils/tailwind-util.ts @@ -0,0 +1,11 @@ +/** + * Converts a tailwind color variable to its hex format + * @param colorVariable a TailwindCSS color parameter + * @returns the color in hex format, as a string + */ +export const getTailwindColor = (colorVariable: string) => { + const [hue, saturation, lightness] = getComputedStyle(document.documentElement) + .getPropertyValue(colorVariable) + .split(' '); + return `hsl(${hue}, ${saturation}, ${lightness})`; +}; diff --git a/tailwind.config.js b/tailwind.config.js index 5bb214e4..3abfbee6 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -16,6 +16,12 @@ module.exports = { 'pl-[215px]', 'pl-[179px]', 'z-[9999]', + 'bg-conflictProtest', + 'bg-conflictRiot', + 'bg-conflictBattle', + 'bg-conflictCivil', + 'bg-conflictExplosion', + 'bg-conflictStrategic', ], content: [ './src/components/**/*.{js,ts,jsx,tsx,mdx}', @@ -111,6 +117,12 @@ module.exports = { surfaceGrey: '#B0B0B0', activeCountries: '#82bce0', inactiveCountries: '#a7b3ba', + conflictProtest: '#0d657de6', + conflictRiot: '#c95200e6', + conflictBattle: '#7d0631', + conflictCivil: '#96badc', + conflictExplosion: '#eaaf75', + conflictStrategic: '#bec0c1', }, }, dark: { @@ -140,6 +152,12 @@ module.exports = { surfaceGrey: '#444444', activeCountries: '#115884', inactiveCountries: '#85929b', + conflictProtest: '#0d657de6', + conflictRiot: '#c95200e6', + conflictBattle: '#7d0631', + conflictCivil: '#96badc', + conflictExplosion: '#eaaf75', + conflictStrategic: '#bec0c1', }, }, }, diff --git a/yarn.lock b/yarn.lock index 240b595f..8508778d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1099,7 +1099,7 @@ "@nextui-org/shared-utils" "2.0.8" "@nextui-org/system-rsc" "2.1.6" -"@nextui-org/spinner@2.0.34": +"@nextui-org/spinner@2.0.34", "@nextui-org/spinner@^2.0.34": version "2.0.34" resolved "https://registry.yarnpkg.com/@nextui-org/spinner/-/spinner-2.0.34.tgz#91f1d3db33fa4ceedbd88e00e7b1a10c7833c9b5" integrity sha512-YKw/6xSLhsXU1k22OvYKyWhtJCHzW2bRAiieVSVG5xak3gYwknTds5H9s5uur+oAZVK9AkyAObD19QuZND32Jg== @@ -2828,7 +2828,14 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== -"@types/leaflet@^1.9.14": +"@types/leaflet.markercluster@^1.5.5": + version "1.5.5" + resolved "https://registry.yarnpkg.com/@types/leaflet.markercluster/-/leaflet.markercluster-1.5.5.tgz#e420caf5b9f6300ab4869fc877d08a9eaf200eec" + integrity sha512-TkWOhSHDM1ANxmLi+uK0PjsVcjIKBr8CLV2WoF16dIdeFmC0Cj5P5axkI3C1Xsi4+ht6EU8+BfEbbqEF9icPrg== + dependencies: + "@types/leaflet" "*" + +"@types/leaflet@*", "@types/leaflet@^1.9.14": version "1.9.14" resolved "https://registry.yarnpkg.com/@types/leaflet/-/leaflet-1.9.14.tgz#f008fef281a05e457bbae9f00a82c836af1b9b03" integrity sha512-sx2q6MDJaajwhKeVgPSvqXd8rhNJSTA3tMidQGduZn9S6WBYxDkCpSpV5xXEmSg7Cgdk/5vJGhVF1kMYLzauBg== @@ -4967,6 +4974,11 @@ leaflet-geosearch@^4.0.0: "@googlemaps/js-api-loader" "^1.16.6" leaflet "^1.6.0" +leaflet.markercluster@^1.5.3: + version "1.5.3" + resolved "https://registry.yarnpkg.com/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz#9cdb52a4eab92671832e1ef9899669e80efc4056" + integrity sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA== + leaflet@^1.6.0, leaflet@^1.9.4: version "1.9.4" resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.9.4.tgz#23fae724e282fa25745aff82ca4d394748db7d8d" @@ -5737,6 +5749,13 @@ react-is@^16.13.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== +react-leaflet-cluster@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/react-leaflet-cluster/-/react-leaflet-cluster-2.1.0.tgz#9e5299efb7b16eff75511a47ed4a5d763dcf55b5" + integrity sha512-16X7XQpRThQFC4PH4OpXHimGg19ouWmjxjtpxOeBKpvERSvIRqTx7fvhTwkEPNMFTQ8zTfddz6fRTUmUEQul7g== + dependencies: + leaflet.markercluster "^1.5.3" + react-leaflet@^4.2.1: version "4.2.1" resolved "https://registry.yarnpkg.com/react-leaflet/-/react-leaflet-4.2.1.tgz#c300e9eccaf15cb40757552e181200aa10b94780"