Skip to content

Commit

Permalink
Feature/f 30 implement map with selectable countries (#22)
Browse files Browse the repository at this point in the history
* feat: setup geojson and implement selectable countries

* feat: introduce mapbox as an example

* feat: change hovering to mapbox components

* feat: refactor and add roads

* feat: adjust colors according to Figma

* fix: code style changes

---------

Co-authored-by: marinovl7 <[email protected]>
  • Loading branch information
Lukas0912 and marinovl7 authored Nov 11, 2024
1 parent 158ee22 commit 4335166
Show file tree
Hide file tree
Showing 8 changed files with 462 additions and 69 deletions.
2 changes: 2 additions & 0 deletions .env_template
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
NEXT_PUBLIC_API_URL=https://api.hungermapdata.org/v2
NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=
NEXT_PUBLIC_CHATBOT_API_URL=
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"@nextui-org/tooltip": "^2.0.41",
"@react-aria/ssr": "3.9.4",
"@react-aria/visually-hidden": "3.8.12",
"@react-leaflet/core": "^2.1.0",
"@tanstack/react-query": "^5.59.17",
"clsx": "2.1.1",
"framer-motion": "~11.1.1",
Expand All @@ -47,6 +48,8 @@
"leaflet-defaulticon-compatibility": "^0.1.2",
"leaflet-geosearch": "^4.0.0",
"lucide-react": "^0.454.0",
"mapbox-gl": "^3.7.0",
"mapbox-gl-leaflet": "^0.0.16",
"next": "14.2.10",
"next-themes": "^0.2.1",
"nextui-cli": "^0.3.4",
Expand Down
72 changes: 4 additions & 68 deletions src/components/Map/Map.tsx
Original file line number Diff line number Diff line change
@@ -1,72 +1,12 @@
/* eslint-disable */
import 'leaflet/dist/leaflet.css';

import { Feature, FeatureCollection } from 'geojson';
import { LeafletMouseEvent } from 'leaflet';
import { GeoJSON, MapContainer, TileLayer, ZoomControl } from 'react-leaflet';
import { MapContainer, ZoomControl } from 'react-leaflet';

import { CountryMapData } from '@/domain/entities/country/CountryMapData.ts';
import { MapProps } from '@/domain/props/MapProps';

export default function Map({ countries }: MapProps) {
const countryStyle: L.PathOptions = {
fillColor: 'var(--color-active-countries)',
weight: 0.5,
color: 'var(--color-background)',
fillOpacity: 0.4,
};

const highlightCountry = (event: LeafletMouseEvent) => {
const layer = event.target;
const countryData: CountryMapData = layer.feature as CountryMapData;
if (countryData.properties.interactive) {
layer.setStyle({
fillColor: 'var(--color-hover)',
fillOpacity: 0.8,
});
} else {
layer.getElement().style.cursor = 'grab';
}
};

const resetHighlight = (event: LeafletMouseEvent) => {
const layer = event.target;
const countryData: CountryMapData = layer.feature as CountryMapData;
if (countryData.properties.interactive) {
layer.setStyle(countryStyle);
}
};

const onCountryClick = (event: LeafletMouseEvent) => {
const countryData: CountryMapData = event.target.feature as CountryMapData;
if (countryData.properties.interactive) {
alert(`You clicked on ${countryData.properties.adm0_name}`);
}
};

const onEachCountry = (country: Feature, layer: L.Layer) => {
if ((layer as L.GeoJSON).feature) {
const leafletLayer = layer as L.Path;
leafletLayer.setStyle(countryStyle);
if (!(country as CountryMapData).properties.interactive) {
leafletLayer.setStyle({ fillColor: 'var(--color-inactive-countries)', fillOpacity: 0.85 });
}
leafletLayer.on({
mouseover: highlightCountry,
mouseout: resetHighlight,
click: onCountryClick,
mousedown: () => {
const element = leafletLayer.getElement() as HTMLElement | null;
if (element) element.style.cursor = 'grabbing';
},
mouseup: () => {
const element = leafletLayer.getElement() as HTMLElement | null;
if (element) element.style.cursor = 'grab';
},
});
}
};
import VectorTileLayer from './VectorTileLayer';

export default function Map({ countries, disputedAreas }: MapProps) {
return (
<MapContainer
center={[21.505, -0.09]}
Expand All @@ -81,11 +21,7 @@ export default function Map({ countries }: MapProps) {
zoomControl={false}
style={{ height: '100%', width: '100%', zIndex: 1 }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{countries && <GeoJSON data={countries as FeatureCollection} onEachFeature={onEachCountry} />}
{countries && <VectorTileLayer countries={countries} disputedAreas={disputedAreas} />}
<ZoomControl position="bottomright" />
</MapContainer>
);
Expand Down
34 changes: 34 additions & 0 deletions src/components/Map/VectorTileLayer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import 'mapbox-gl/dist/mapbox-gl.css';

import { LeafletContextInterface, useLeafletContext } from '@react-leaflet/core';
import mapboxgl from 'mapbox-gl'; // eslint-disable-line import/no-webpack-loader-syntax
import { useTheme } from 'next-themes';
import React, { RefObject, useEffect, useRef } from 'react';

import { MapProps } from '@/domain/props/MapProps';
import { MapOperations } from '@/operations/map/MapOperations.ts';

export default function VectorTileLayer({ countries, disputedAreas }: MapProps) {
const { theme } = useTheme();
const context: LeafletContextInterface = useLeafletContext();
const mapContainer: RefObject<HTMLDivElement> = useRef<HTMLDivElement>(null);

mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN as string;

useEffect(() => {
const baseMap: mapboxgl.Map = MapOperations.createMapboxMap(
theme === 'dark',
{ countries, disputedAreas },
mapContainer
);
MapOperations.setMapInteractionFunctionality(baseMap);
MapOperations.synchronizeLeafletMapbox(baseMap, mapContainer, context);

return () => {
baseMap.remove();
context.map.off('move');
};
}, [context, theme]);

return <div ref={mapContainer} style={{ width: '100%', height: '100%', zIndex: 2 }} />;
}
7 changes: 7 additions & 0 deletions src/domain/entities/map/MapColorsType.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export interface MapColorsType {
activeCountries: string;
inactiveCountries: string;
ocean: string;
outline: string;
roads: string;
}
187 changes: 187 additions & 0 deletions src/operations/map/MapOperations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { LeafletContextInterface } from '@react-leaflet/core';
import { FeatureCollection } from 'geojson';
import mapboxgl from 'mapbox-gl';
import { RefObject } from 'react';

import { CountryMapData } from '@/domain/entities/country/CountryMapData.ts';
import { MapColorsType } from '@/domain/entities/map/MapColorsType.ts';
import { MapProps } from '@/domain/props/MapProps';
import { getColors } from '@/styles/MapColors.ts';

export class MapOperations {
static createMapboxMap(
isDark: boolean,
{ countries }: MapProps,
mapContainer: RefObject<HTMLDivElement>
): mapboxgl.Map {
const mapColors: MapColorsType = getColors(isDark);

return new mapboxgl.Map({
container: mapContainer.current as unknown as string | HTMLElement,
style: {
version: 8,
name: 'HungerMap LIVE',
metadata: '{metadata}',
sources: {
countries: {
type: 'geojson',
data: countries as FeatureCollection,
generateId: true,
},
mapboxStreets: {
type: 'vector',
url: 'mapbox://mapbox.mapbox-streets-v8',
},
},
layers: [
{
id: 'ocean',
type: 'background',
paint: {
'background-color': mapColors.ocean,
},
},
{
id: 'country-fills',
type: 'fill',
source: 'countries',
layout: {},
paint: {
'fill-color': [
'case',
['boolean', ['coalesce', ['get', 'interactive'], false]],
mapColors.activeCountries,
mapColors.inactiveCountries,
],
'fill-opacity': ['case', ['boolean', ['feature-state', 'hover'], false], 0.7, 1],
},
},
{
id: 'country-borders',
type: 'line',
source: 'countries',
layout: {},
paint: {
'line-color': mapColors.outline,
'line-width': 0.7,
},
},

{
id: 'mapbox-roads',
type: 'line',
source: 'mapboxStreets',
'source-layer': 'road',
filter: ['in', 'class', 'motorway', 'trunk'],
paint: {
'line-color': mapColors.roads,
'line-width': ['interpolate', ['exponential', 1.5], ['zoom'], 5, 0.5, 18, 10],
},
minzoom: 5,
},
],
},
interactive: false,
});
}

static setMapInteractionFunctionality(baseMap: mapboxgl.Map): void {
let hoveredPolygonId: string | number | undefined;

baseMap.on('mousemove', 'country-fills', (e) => {
if (e.features && e.features.length > 0 && (e.features[0] as unknown as CountryMapData).properties.interactive) {
if (hoveredPolygonId) {
baseMap.setFeatureState({ source: 'countries', id: hoveredPolygonId }, { hover: false });
}
hoveredPolygonId = e.features[0].id;
if (hoveredPolygonId) {
baseMap.setFeatureState({ source: 'countries', id: hoveredPolygonId }, { hover: true });
}
}
});

baseMap.on('mouseleave', 'country-fills', () => {
if (hoveredPolygonId) {
baseMap.setFeatureState({ source: 'countries', id: hoveredPolygonId }, { hover: false });
}
hoveredPolygonId = undefined;
});

let isDragging = false;
baseMap.on('mousedown', () => {
isDragging = false;
});

baseMap.on('mousemove', () => {
isDragging = true;
});

baseMap.on('mouseup', 'country-fills', (e) => {
if (!isDragging && e.features && (e.features[0] as unknown as CountryMapData).properties.interactive) {
alert(`You clicked on ${(e.features[0] as unknown as CountryMapData).properties.adm0_name}`);

Check warning on line 121 in src/operations/map/MapOperations.ts

View workflow job for this annotation

GitHub Actions / lint-and-format

Unexpected alert
}
});
}

static synchronizeLeafletMapbox(
baseMap: mapboxgl.Map,
mapContainer: RefObject<HTMLDivElement>,
context: LeafletContextInterface
): void {
baseMap.dragRotate.disable();

const syncZoom = () => {
baseMap.setZoom(context.map.getZoom() - 1);
baseMap.setMaxZoom(context.map.getMaxZoom() - 1);
baseMap.setMinZoom(context.map.getMinZoom() - 1);
};

const container = context.layerContainer || context.map;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const leafletMap = container.getContainer();
leafletMap.appendChild(mapContainer.current);

baseMap.setZoom(context.map.getZoom());
baseMap.setMaxZoom(context.map.getMaxZoom() - 1);
baseMap.setMinZoom(context.map.getMinZoom() - 1);

const { lat, lng } = context.map.getCenter();
baseMap.setCenter([lng, lat]);
baseMap.setZoom(context.map.getZoom() - 1);

context.map.on('move', () => {
const { lat: moveLat, lng: moveLng } = context.map.getCenter();
baseMap.setCenter([moveLng, moveLat]);
syncZoom();
});

context.map.on('zoom', () => {
const { lat: zoomLat, lng: zoomLng } = context.map.getCenter();
baseMap.setCenter([zoomLng, zoomLat]);
syncZoom();
});

context.map.on('movestart', () => {
const { lat: moveStartLat, lng: moveStartLng } = context.map.getCenter();
baseMap.setCenter([moveStartLng, moveStartLat]);
syncZoom();
});

context.map.on('zoomstart', () => {
syncZoom();
});

context.map.on('moveend', () => {
const { lat: moveEndLat, lng: moveEndLng } = context.map.getCenter();
baseMap.setCenter([moveEndLng, moveEndLat]);
syncZoom();
});

context.map.on('zoomend', () => {
const { lat: zoomEndLat, lng: zoomEndLng } = context.map.getCenter();
baseMap.setCenter([zoomEndLng, zoomEndLat]);
syncZoom();
});
}
}
9 changes: 9 additions & 0 deletions src/styles/MapColors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { MapColorsType } from '@/domain/entities/map/MapColorsType.ts';

export const getColors = (isDark: boolean): MapColorsType => ({
activeCountries: isDark ? '#0e6397' : '#fefeff',
inactiveCountries: isDark ? '#5a819b' : '#e8e8e8',
ocean: isDark ? '#111111' : '#91cccb',
outline: isDark ? '#0e2a3a' : '#306f96',
roads: isDark ? '#404040' : '#808080',
});
Loading

0 comments on commit 4335166

Please sign in to comment.