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

[PLATFORM] Implementing Map Functionality #1846

Closed
wants to merge 20 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions platform/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"file-saver": "^2.0.5",
"flowbite": "^1.6.3",
"flowbite-react": "^0.3.8",
"fuse.js": "^7.0.0",
"html2canvas": "^1.4.1",
"i18n-iso-countries": "^7.7.0",
"jspdf": "^2.5.1",
Expand Down
Binary file added platform/public/images/map/dd1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
187 changes: 148 additions & 39 deletions platform/src/common/components/Map/AirQoMap.jsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,68 @@
import React, { useEffect, useRef, useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import mapboxgl from 'mapbox-gl';
import LayerIcon from '@/icons/map/layerIcon';
import RefreshIcon from '@/icons/map/refreshIcon';
import ShareIcon from '@/icons/map/shareIcon';
import { CustomGeolocateControl, CustomZoomControl } from './components/MapControls';
import { setCenter, setZoom } from '@/lib/store/services/map/MapSlice';
import LayerModal from './components/LayerModal';
import MapImage from '@/images/map/dd1.png';
import Loader from '@/components/Spinner';

const AirQoMap = ({
latitude = 0.3201412790664193,
longitude = 32.56389785939493,
zoom = 13,
customStyle,
mapboxApiAccessToken,
}) => {
const mapStyles = [
{ url: 'mapbox://styles/mapbox/streets-v11', name: 'Streets', image: MapImage },
// { url: 'mapbox://styles/mapbox/outdoors-v11', name: 'Outdoors' },
{ url: 'mapbox://styles/mapbox/light-v10', name: 'Light', image: MapImage },
{ url: 'mapbox://styles/mapbox/dark-v10', name: 'Dark', image: MapImage },
{ url: 'mapbox://styles/mapbox/satellite-v9', name: 'Satellite', image: MapImage },
// { url: 'mapbox://styles/mapbox/satellite-streets-v11', name: 'Satellite Streets' },
];

const initialState = {
center: {
latitude: 0.3201,
longitude: 32.5638,
},
zoom: 12,
};

const AirQoMap = ({ customStyle, mapboxApiAccessToken, showSideBar }) => {
const dispatch = useDispatch();
const mapContainerRef = useRef(null);
const mapRef = useRef(null);
const dropdownRef = useRef(null);
const [mapStyle, setMapStyle] = useState('mapbox://styles/mapbox/streets-v11');
const [isOpen, setIsOpen] = useState(false);
const [refresh, setRefresh] = useState(false);
const [loading, setLoading] = useState(false);
const urls = new URL(window.location.href);
const urlParams = new URLSearchParams(urls.search);
const mapData = useSelector((state) => state.map);

const mapStyles = [
{ url: 'mapbox://styles/mapbox/streets-v11', name: 'Streets' },
{ url: 'mapbox://styles/mapbox/outdoors-v11', name: 'Outdoors' },
{ url: 'mapbox://styles/mapbox/light-v10', name: 'Light' },
{ url: 'mapbox://styles/mapbox/dark-v10', name: 'Dark' },
{ url: 'mapbox://styles/mapbox/satellite-v9', name: 'Satellite' },
{ url: 'mapbox://styles/mapbox/satellite-streets-v11', name: 'Satellite Streets' },
];
const lat = urlParams.get('lat');
const lng = urlParams.get('lng');
const zm = urlParams.get('zm');

useEffect(() => {
if (mapRef.current && mapData.center.latitude && mapData.center.longitude) {
mapRef.current.flyTo({
center: [mapData.center.longitude, mapData.center.latitude],
zoom: mapData.zoom,
essential: true,
});
}
}, [mapData.center, mapData.zoom]);

// Init map
useEffect(() => {
mapboxgl.accessToken = mapboxApiAccessToken;

const map = new mapboxgl.Map({
container: mapContainerRef.current,
style: mapStyle,
center: [longitude, latitude],
zoom: zoom,
center: [lng || mapData.center.longitude, lat || mapData.center.latitude],
zoom: zm || mapData.zoom,
});

mapRef.current = map;
Expand All @@ -51,15 +78,91 @@ const AirQoMap = ({
const geolocateControl = new CustomGeolocateControl();
map.addControl(geolocateControl, 'bottom-right');
} catch (error) {
console.log('Error adding map controls', error);
console.error('Error adding map controls:', error);
}
});

return () => {
map.remove();
dispatch(setCenter(initialState.center));
dispatch(setZoom(initialState.zoom));
};
}, [mapStyle, mapboxApiAccessToken, refresh]);

// Boundaries for a country
useEffect(() => {
const map = mapRef.current;

if (map) {
setLoading(true);
if (map.getLayer('location-boundaries')) {
map.removeLayer('location-boundaries');
}

if (map.getSource('location-boundaries')) {
map.removeSource('location-boundaries');
}

let queryString = mapData.location.country;
if (mapData.location.city) {
queryString = mapData.location.city + ', ' + queryString;
}

fetch(
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(
queryString,
)}&polygon_geojson=1&format=json`,
)
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then((data) => {
setLoading(false);

if (data && data.length > 0) {
const boundaryData = data[0].geojson;

map.addSource('location-boundaries', {
type: 'geojson',
data: boundaryData,
});

map.addLayer({
id: 'location-boundaries',
type: 'fill',
source: 'location-boundaries',
paint: {
'fill-color': '#0000FF',
'fill-opacity': 0.2,
'fill-outline-color': '#0000FF',
},
});

const { lat, lon } = data[0];
map.flyTo({
center: [lon, lat],
zoom: mapData.location.city && mapData.location.country ? 10 : 5,
});

// Add zoomend event listener
map.on('zoomend', function () {
const zoom = map.getZoom();
// Adjust fill opacity based on zoom level
const opacity = zoom > 10 ? 0 : 0.2;
map.setPaintProperty('location-boundaries', 'fill-opacity', opacity);
});
}
})
.catch((error) => {
console.error('Error fetching location boundaries:', error);
setLoading(false);
});
}
}, [mapData.location]);

// generate code to close dropdown when clicked outside
useEffect(() => {
const handleClickOutside = (event) => {
Expand Down Expand Up @@ -100,8 +203,24 @@ const AirQoMap = ({
};

return (
<>
<div className='relative w-auto h-auto'>
{/* Map */}
<div ref={mapContainerRef} className={customStyle} />
{/* Loader */}
{refresh ||
(loading && (
<div
className={`absolute inset-0 flex items-center justify-center z-40 ${
showSideBar ? 'ml-96' : ''
}`}>
<div className='bg-white w-[70px] h-[70px] flex justify-center items-center rounded-md shadow-md'>
<span className='ml-2'>
<Loader width={32} height={32} />
</span>
</div>
</div>
))}
{/* Map control buttons */}
<div className='absolute top-4 right-0'>
<div className='flex flex-col gap-4'>
<div className='relative'>
Expand All @@ -112,25 +231,15 @@ const AirQoMap = ({
className='inline-flex items-center justify-center w-[50px] h-[50px] mr-2 text-white rounded-full bg-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 shadow-md'>
<LayerIcon />
</button>
{isOpen && (
<div className='origin-top-right absolute right-2 mt-2 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5'>
<div
className='py-1 w-full'
role='menu'
aria-orientation='vertical'
aria-labelledby='options-menu'>
{mapStyles.map((style, index) => (
<button
key={index}
onClick={() => setMapStyle(style.url)}
className='block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900'
role='menuitem'>
{style.name}
</button>
))}
</div>
</div>
)}
<LayerModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
mapStyles={mapStyles}
showSideBar={showSideBar}
onStyleSelect={(style) => {
setMapStyle(style.url);
}}
/>
</div>
</div>
<button
Expand All @@ -147,7 +256,7 @@ const AirQoMap = ({
</button>
</div>
</div>
</>
</div>
);
};

Expand Down
130 changes: 130 additions & 0 deletions platform/src/common/components/Map/components/LayerModal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import React, { useState, useRef, useEffect } from 'react';
import MapImage from '@/images/map/dd1.png';
import Image from 'next/image';

const mapDetails = [
{
name: 'Emoji',
image: MapImage,
},
{
name: 'Heatmap',
image: MapImage,
},
{
name: 'Node',
image: MapImage,
},
{
name: 'Number',
image: MapImage,
},
];

const Option = ({ isSelected, children, onSelect, image }) => (
<button
onClick={onSelect}
className={`flex flex-col items-center space-y-3 ${isSelected ? 'border-blue-500' : ''}`}>
<div
className={`w-14 h-14 relative rounded-md ${
isSelected ? 'border-2 border-blue-500 ring-4 ring-light-blue-100' : ''
}`}>
<Image src={image} alt={children} layout='fill' objectFit='cover' className='rounded-md' />
</div>
<span>{children}</span>
</button>
);

const LayerModal = ({ isOpen, onClose, mapStyles, onStyleSelect, showSideBar }) => {
const [selectedStyle, setSelectedStyle] = useState(mapStyles[0]);
const [selectedMapDetail, setSelectedMapDetail] = useState(mapDetails[0]);
const modalRef = useRef();

const handleApply = () => {
onStyleSelect(selectedStyle);
onClose();
};

const handleSelectStyle = (style) => {
setSelectedStyle(style);
};

const handleSelectDetail = (detail) => {
setSelectedMapDetail(detail);
};

const handleClickOutside = (event) => {
if (modalRef.current && !modalRef.current.contains(event.target)) {
onClose();
}
};

useEffect(() => {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
});

if (!isOpen) return null;

return (
<div className='fixed z-10 inset-0 overflow-y-auto z-50'>
<div className='flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0'>
<div className='fixed inset-0 transition-opacity' aria-hidden='true'>
<div className='absolute inset-0 bg-transparent'></div>
</div>
<span
className='hidden sm:inline-block sm:align-middle sm:h-screen'
aria-hidden='true'></span>
<div
ref={modalRef}
className={`absolute h-auto top-1/2 transform -translate-y-1/2 bg-white rounded-lg overflow-hidden shadow-xl sm:max-w-lg sm:w-full ${
showSideBar
? 'w-[375px] md:max-w-sm md:left-[calc(50%+200px)] transform md:-translate-x-[calc(50%-15px)] lg:-translate-x-[calc(50%-40px)]'
: 'md:left-[56%] transform md:-translate-x-1/2'
}`}>
<div className='p-6 text-left'>
<h3 className='text-lg font-semibold mb-3'>Map Details</h3>
<div className='flex justify-between space-x-2'>
{mapDetails.map((detail) => (
<Option
key={detail.name}
isSelected={detail.name === selectedMapDetail.name}
onSelect={() => handleSelectDetail(detail)}
image={detail.image}>
{detail.name}
</Option>
))}
</div>
<div className='w-full bg-grey-200 h-[2px] my-2' />
<h3 className='text-lg font-semibold mb-3'>Map type</h3>
<div>
<div className='flex justify-between'>
{mapStyles.map((style) => (
<Option
key={style.name}
image={style.image}
isSelected={style.name === selectedStyle.name}
onSelect={() => handleSelectStyle(style)}>
{style.name}
</Option>
))}
</div>
</div>
</div>
<div className='flex justify-end w-full p-6 space-x-4 bg-[#F9FAFB]'>
<button onClick={onClose} className='px-4 py-2 border rounded-md'>
Cancel
</button>
<button onClick={handleApply} className='px-4 py-2 bg-blue-600 rounded-md text-white'>
Apply
</button>
</div>
</div>
</div>
</div>
);
};

export default LayerModal;
Loading
Loading