diff --git a/src/frontend/package.json b/src/frontend/package.json index a09d84f9..f1ff070f 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -2,6 +2,7 @@ "dependencies": { "@mapbox/mapbox-gl-draw": "^1.4.2", "@mapbox/mapbox-gl-draw-static-mode": "^1.0.1", + "mapbox-gl-draw-cut-line-mode": "^1.2.0", "@radix-ui/react-popover": "^1.0.6", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-switch": "^1.0.3", diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 1220cd19..ebb53b3a 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -47,7 +47,12 @@ export default function App() { }; // add routes where you dont want navigation bar - const routesWithoutNavbar = ['/', '/login', '/forgot-password']; + const routesWithoutNavbar = [ + '/', + '/login', + '/forgot-password', + '/user-profile', + ]; return ( <> diff --git a/src/frontend/src/assets/images/GFDRR-logo.png b/src/frontend/src/assets/images/GFDRR-logo.png new file mode 100644 index 00000000..05096b8f Binary files /dev/null and b/src/frontend/src/assets/images/GFDRR-logo.png differ diff --git a/src/frontend/src/assets/images/navigation-image.png b/src/frontend/src/assets/images/navigation-image.png new file mode 100644 index 00000000..e83355af Binary files /dev/null and b/src/frontend/src/assets/images/navigation-image.png differ diff --git a/src/frontend/src/components/CreateProject/CreateProjectHeader/index.tsx b/src/frontend/src/components/CreateProject/CreateProjectHeader/index.tsx index 3deefdaa..8f8a3da9 100644 --- a/src/frontend/src/components/CreateProject/CreateProjectHeader/index.tsx +++ b/src/frontend/src/components/CreateProject/CreateProjectHeader/index.tsx @@ -1,12 +1,16 @@ -import { useNavigate } from 'react-router-dom'; +import { useTypedDispatch } from '@Store/hooks'; import { FlexRow } from '@Components/common/Layouts'; import Icon from '@Components/common/Icon'; +import { toggleModal } from '@Store/actions/common'; export default function CreateProjectHeader() { - const navigate = useNavigate(); + const dispatch = useTypedDispatch(); return ( - navigate('/projects')} /> + dispatch(toggleModal('quit-create-project'))} + />
Project /
 Add Project
diff --git a/src/frontend/src/components/CreateProject/CreateprojectLayout/index.tsx b/src/frontend/src/components/CreateProject/CreateprojectLayout/index.tsx index c1dde5a4..81d319fb 100644 --- a/src/frontend/src/components/CreateProject/CreateprojectLayout/index.tsx +++ b/src/frontend/src/components/CreateProject/CreateprojectLayout/index.tsx @@ -164,7 +164,7 @@ export default function CreateprojectLayout() { }; return ( -
+
{/* description */}
diff --git a/src/frontend/src/components/CreateProject/ExitCreateProjectModal/index.tsx b/src/frontend/src/components/CreateProject/ExitCreateProjectModal/index.tsx new file mode 100644 index 00000000..c5be7a65 --- /dev/null +++ b/src/frontend/src/components/CreateProject/ExitCreateProjectModal/index.tsx @@ -0,0 +1,37 @@ +import { useTypedDispatch } from '@Store/hooks'; +import { useNavigate } from 'react-router-dom'; +import { FlexRow } from '@Components/common/Layouts'; +import { Button } from '@Components/RadixComponents/Button'; +import { toggleModal } from '@Store/actions/common'; + +export default function ExitCreateProjectModal() { + const dispatch = useTypedDispatch(); + const navigate = useNavigate(); + + return ( +
+

+ This page has some unsaved changes, are you sure you want to leave this + page? +

+ + + + +
+ ); +} diff --git a/src/frontend/src/components/CreateProject/FormContents/Contributions/index.tsx b/src/frontend/src/components/CreateProject/FormContents/Contributions/index.tsx index 68a2ccb0..d941d23f 100644 --- a/src/frontend/src/components/CreateProject/FormContents/Contributions/index.tsx +++ b/src/frontend/src/components/CreateProject/FormContents/Contributions/index.tsx @@ -1,29 +1,29 @@ -import { useTypedDispatch, useTypedSelector } from '@Store/hooks'; +// import { useTypedDispatch, useTypedSelector } from '@Store/hooks'; import { FlexColumn } from '@Components/common/Layouts'; import { FormControl, Label, Input } from '@Components/common/FormUI'; -import RadioButton from '@Components/common/RadioButton'; +// import RadioButton from '@Components/common/RadioButton'; import ErrorMessage from '@Components/common/ErrorMessage'; import { UseFormPropsType } from '@Components/common/FormUI/types'; -import { setCreateProjectState } from '@Store/actions/createproject'; -import { contributionsOptions } from '@Constants/createProject'; +// import { setCreateProjectState } from '@Store/actions/createproject'; +// import { contributionsOptions } from '@Constants/createProject'; export default function Conditions({ formProps, }: { formProps: UseFormPropsType; }) { - const dispatch = useTypedDispatch(); + // const dispatch = useTypedDispatch(); const { register, errors } = formProps; - const contributionsOption = useTypedSelector( - state => state.createproject.contributionsOption, - ); + // const contributionsOption = useTypedSelector( + // state => state.createproject.contributionsOption, + // ); return ( - + - + /> */}
- + void; +}) { + const dispatch = useTypedDispatch(); -export default function MapSection() { const { map, isMapLoaded } = useMapLibreGLMap({ mapOptions: { zoom: 5, @@ -19,18 +29,42 @@ export default function MapSection() { disableRotation: true, }); - const uploadedProjectArea = useTypedSelector( - state => state.createproject.uploadedProjectArea, + const drawProjectAreaEnable = useTypedSelector( + state => state.createproject.drawProjectAreaEnable, + ); + const drawNoFlyZoneEnable = useTypedSelector( + state => state.createproject.drawNoFlyZoneEnable, ); - const uploadedNoFlyZone = useTypedSelector( - state => state.createproject.uploadedNoFlyZone, + + const handleDrawEnd = (geojson: GeojsonType | null) => { + if (drawProjectAreaEnable) { + dispatch(setCreateProjectState({ drawnProjectArea: geojson })); + } + dispatch(setCreateProjectState({ drawnNoFlyZone: geojson })); + }; + + const { resetDraw } = useDrawTool({ + map, + enable: drawProjectAreaEnable || drawNoFlyZoneEnable, + drawMode: 'draw_polygon', + styles: drawStyles, + onDrawEnd: handleDrawEnd, + }); + + useEffect(() => { + onResetButtonClick(resetDraw); + }, [onResetButtonClick, resetDraw]); + + const projectArea = useTypedSelector( + state => state.createproject.projectArea, ); + const noFlyZone = useTypedSelector(state => state.createproject.noFlyZone); useEffect(() => { - if (!uploadedProjectArea) return; - const bbox = getBbox(uploadedProjectArea as FeatureCollection); + if (!projectArea) return; + const bbox = getBbox(projectArea as FeatureCollection); map?.fitBounds(bbox as LngLatBoundsLike, { padding: 25 }); - }, [map, uploadedProjectArea]); + }, [map, projectArea]); return ( state.createproject.uploadedProjectArea, - ); - const uploadedNoFlyZone = useTypedSelector( - state => state.createproject.uploadedNoFlyZone, + const [resetDrawTool, setResetDrawTool] = useState void)>(null); + + const projectArea = useTypedSelector( + state => state.createproject.projectArea, ); + const noFlyZone = useTypedSelector(state => state.createproject.noFlyZone); const isNoflyzonePresent = useTypedSelector( state => state.createproject.isNoflyzonePresent, ); + const drawProjectAreaEnable = useTypedSelector( + state => state.createproject.drawProjectAreaEnable, + ); + const drawNoFlyZoneEnable = useTypedSelector( + state => state.createproject.drawNoFlyZoneEnable, + ); + const drawnProjectArea = useTypedSelector( + state => state.createproject.drawnProjectArea, + ); + const drawnNoFlyZone = useTypedSelector( + state => state.createproject.drawnNoFlyZone, + ); + + const handleResetButtonClick = useCallback((resetFunction: any) => { + setResetDrawTool(() => resetFunction); + }, []); + + const handleDrawProjectAreaClick = () => { + if (!drawProjectAreaEnable) { + dispatch(setCreateProjectState({ drawProjectAreaEnable: true })); + return; + } + const drawnArea = + drawnProjectArea && area(drawnProjectArea as FeatureCollection); + if (drawnArea && drawnArea > 1000000) { + toast.error('Drawn Area should not exceed 100km²'); + dispatch( + setCreateProjectState({ + drawProjectAreaEnable: false, + drawnProjectArea: null, + }), + ); + // @ts-ignore + resetDrawTool(); + return; + } + dispatch( + setCreateProjectState({ + projectArea: drawnProjectArea, + drawProjectAreaEnable: false, + }), + ); + setValue('outline_geojson', drawnProjectArea); + if (resetDrawTool) { + resetDrawTool(); + } + }; + + const handleDrawNoFlyZoneClick = () => { + if (!drawNoFlyZoneEnable) { + dispatch(setCreateProjectState({ drawNoFlyZoneEnable: true })); + return; + } + const drawnNoFlyZoneArea = + drawnProjectArea && area(drawnNoFlyZone as FeatureCollection); + if (drawnNoFlyZoneArea && drawnNoFlyZoneArea > 1000000) { + toast.error('Drawn Area should not exceed 100km²'); + dispatch( + setCreateProjectState({ + drawNoFlyZoneEnable: false, + drawnNoFlyZone: null, + }), + ); + // @ts-ignore + resetDrawTool(); + return; + } + dispatch( + setCreateProjectState({ + noFlyZone: drawnNoFlyZone, + drawNoFlyZoneEnable: false, + }), + ); + setValue('outline_no_fly_zones', drawnNoFlyZone); + if (resetDrawTool) { + resetDrawTool(); + } + }; - const projectArea = - uploadedProjectArea && area(uploadedProjectArea as FeatureCollection); - const noFlyZoneArea = - uploadedNoFlyZone && area(uploadedNoFlyZone as FeatureCollection); + const totalProjectArea = + projectArea && area(projectArea as FeatureCollection); + const noFlyZoneArea = noFlyZone && area(noFlyZone as FeatureCollection); const handleProjectAreaFileChange = (file: Record[]) => { if (!file) return; @@ -46,9 +129,7 @@ export default function DefineAOI({ geojson.then(z => { if (typeof z === 'object' && !Array.isArray(z) && z !== null) { const convertedGeojson = flatten(z); - dispatch( - setCreateProjectState({ uploadedProjectArea: convertedGeojson }), - ); + dispatch(setCreateProjectState({ projectArea: convertedGeojson })); setValue('outline_geojson', convertedGeojson); } }); @@ -65,9 +146,7 @@ export default function DefineAOI({ geojson.then(z => { if (typeof z === 'object' && !Array.isArray(z) && z !== null) { const convertedGeojson = flatten(z); - dispatch( - setCreateProjectState({ uploadedNoFlyZone: convertedGeojson }), - ); + dispatch(setCreateProjectState({ noFlyZone: convertedGeojson })); setValue('outline_no_fly_zones', convertedGeojson); } }); @@ -82,45 +161,68 @@ export default function DefineAOI({
-

Project Area

- {!uploadedProjectArea ? ( +

+ Project Area * +

+ {!projectArea ? ( <> - - -
- or -
+ + + {drawnProjectArea && ( + { + dispatch( + setCreateProjectState({ drawnProjectArea: null }), + ); + if (resetDrawTool) { + resetDrawTool(); + } + }} + /> + )} - - ( - + +
+ or +
+
+ + ( + + )} /> - )} - /> - - + +
+ + )} ) : ( <> @@ -129,15 +231,13 @@ export default function DefineAOI({ className="naxatw-mt-2 naxatw-border naxatw-border-red naxatw-text-red" rightIcon="restart_alt" onClick={() => { - dispatch( - setCreateProjectState({ uploadedProjectArea: null }), - ); + dispatch(resetUploadedAndDrawnAreas()); }} > Reset Project Area

- Total Area: {Math.trunc(projectArea as number)} m2 + Total Area: {Math.trunc(totalProjectArea as number)} m²

{isNoflyzonePresent === 'yes' && (
- {uploadedNoFlyZone ? ( + {noFlyZone ? ( <> - -
- or -
+ + + {drawnNoFlyZone && ( + { + dispatch( + setCreateProjectState({ + drawnNoFlyZone: null, + }), + ); + if (resetDrawTool) { + resetDrawTool(); + } + }} + /> + )} - - ( - + +
+ or +
+
+ + ( + + )} /> - )} - /> - +
+ + )} )}
@@ -214,7 +339,7 @@ export default function DefineAOI({ )}
- +
diff --git a/src/frontend/src/components/CreateProject/FormContents/GenerateTask/MapSection/index.tsx b/src/frontend/src/components/CreateProject/FormContents/GenerateTask/MapSection/index.tsx index d80266a2..44d192e6 100644 --- a/src/frontend/src/components/CreateProject/FormContents/GenerateTask/MapSection/index.tsx +++ b/src/frontend/src/components/CreateProject/FormContents/GenerateTask/MapSection/index.tsx @@ -19,18 +19,18 @@ export default function MapSection() { disableRotation: true, }); - const uploadedProjectArea = useTypedSelector( - state => state.createproject.uploadedProjectArea, + const projectArea = useTypedSelector( + state => state.createproject.projectArea, ); const splitGeojson = useTypedSelector( state => state.createproject.splitGeojson, ); useEffect(() => { - if (!uploadedProjectArea) return; - const bbox = getBbox(uploadedProjectArea as FeatureCollection); + if (!projectArea) return; + const bbox = getBbox(projectArea as FeatureCollection); map?.fitBounds(bbox as LngLatBoundsLike, { padding: 25 }); - }, [map, uploadedProjectArea]); + }, [map, projectArea]); return ( state.createproject.uploadedProjectArea, + const projectArea = useTypedSelector( + state => state.createproject.projectArea, ); const geojsonFile = - !!uploadedProjectArea && convertGeojsonToFile(uploadedProjectArea); + !!projectArea && convertGeojsonToFile(projectArea as Record); const payload = prepareFormData({ project_geojson: geojsonFile, dimension }); @@ -40,7 +40,7 @@ export default function GenerateTask({ formProps }: { formProps: any }) {
- + { - if (!uploadedProjectArea) return; + if (!projectArea) return; mutate(payload); }} > diff --git a/src/frontend/src/components/GoogleAuth/index.tsx b/src/frontend/src/components/GoogleAuth/index.tsx index 0d8c38b4..7d9d60a7 100644 --- a/src/frontend/src/components/GoogleAuth/index.tsx +++ b/src/frontend/src/components/GoogleAuth/index.tsx @@ -29,6 +29,7 @@ function GoogleAuth() { const response = await fetch(callbackUrl, { credentials: 'include' }); const token = await response.json(); localStorage.setItem('token', token.access_token); + localStorage.setItem('refresh', token.refresh_token); // fetch user details const response2 = await fetch(userDetailsUrl, { diff --git a/src/frontend/src/components/IndividualProject/MapSection/index.tsx b/src/frontend/src/components/IndividualProject/MapSection/index.tsx index 247586f8..615c2c67 100644 --- a/src/frontend/src/components/IndividualProject/MapSection/index.tsx +++ b/src/frontend/src/components/IndividualProject/MapSection/index.tsx @@ -43,6 +43,7 @@ export default function MapSection() { id={singleTask.id} visibleOnMap={!!singleTask?.outline_geojson} geojson={singleTask?.outline_geojson} + interactions={['feature']} layerOptions={{ type: 'fill', paint: { diff --git a/src/frontend/src/components/LandingPage/ClientsAndPartners/index.tsx b/src/frontend/src/components/LandingPage/ClientsAndPartners/index.tsx index 8e05d2a1..291ddce0 100644 --- a/src/frontend/src/components/LandingPage/ClientsAndPartners/index.tsx +++ b/src/frontend/src/components/LandingPage/ClientsAndPartners/index.tsx @@ -2,6 +2,8 @@ import Image from '@Components/RadixComponents/Image'; import { motion } from 'framer-motion'; import worldBankLogo from '@Assets/images/LandingPage/WorldbankLogo.png'; import { fadeUpVariant } from '@Constants/animations'; +import gfdrrLogo from '@Assets/images/GFDRR-logo.png'; +import { FlexRow } from '@Components/common/Layouts'; export default function ClientAndPartners() { return ( @@ -25,7 +27,10 @@ export default function ClientAndPartners() { transition={{ duration: 0.7 }} viewport={{ once: true }} > - world bank logo + + world bank logo + gfdrrLogo +
diff --git a/src/frontend/src/components/common/MapLibreComponents/Layers/VectorLayer.ts b/src/frontend/src/components/common/MapLibreComponents/Layers/VectorLayer.ts index b5098b37..d752c670 100644 --- a/src/frontend/src/components/common/MapLibreComponents/Layers/VectorLayer.ts +++ b/src/frontend/src/components/common/MapLibreComponents/Layers/VectorLayer.ts @@ -1,4 +1,6 @@ -import { useEffect, useMemo } from 'react'; +/* eslint-disable no-param-reassign */ +import { useEffect, useMemo, useRef } from 'react'; +import { MapMouseEvent } from 'maplibre-gl'; import { IVectorLayer } from '../types'; export default function VectorLayer({ @@ -6,10 +8,17 @@ export default function VectorLayer({ id, geojson, isMapLoaded, + interactions = [], layerOptions, + onFeatureSelect, visibleOnMap = true, }: IVectorLayer) { const sourceId = useMemo(() => id.toString(), [id]); + const hasInteractions = useRef(false); + + useEffect(() => { + hasInteractions.current = !!interactions.length; + }, [interactions]); useEffect(() => { if (!map || !isMapLoaded || !geojson) return; @@ -38,6 +47,42 @@ export default function VectorLayer({ } }, [map, isMapLoaded, visibleOnMap, sourceId, geojson]); // eslint-disable-line + // change cursor to pointer on feature hover + useEffect(() => { + if (!map) return () => {}; + function onMouseOver() { + if (!map || !hasInteractions.current) return; + map.getCanvas().style.cursor = 'pointer'; + } + function onMouseLeave() { + if (!map || !hasInteractions.current) return; + map.getCanvas().style.cursor = ''; + } + map.on('mouseover', sourceId, onMouseOver); + map.on('mouseleave', sourceId, onMouseLeave); + // remove event handlers on unmount + return () => { + map.off('mouseover', onMouseOver); + map.off('mouseleave', onMouseLeave); + }; + }, [map, sourceId]); + + // add select interaction & return properties on feature select + useEffect(() => { + if (!map || !interactions.includes('feature')) return () => {}; + function handleSelectInteraction(event: MapMouseEvent) { + if (!map) return; + map.getCanvas().style.cursor = 'pointer'; + // @ts-ignore + const { features } = event; + if (!features?.length) return; + const { properties, layer } = features[0]; + onFeatureSelect?.({ ...properties, layer: layer?.id }); + } + map.on('click', sourceId, handleSelectInteraction); + return () => map.off('click', sourceId, handleSelectInteraction); + }, [map, interactions, sourceId, onFeatureSelect]); + useEffect( () => () => { if (map?.getSource(sourceId)) { diff --git a/src/frontend/src/components/common/MapLibreComponents/helpers/reverseLineString.ts b/src/frontend/src/components/common/MapLibreComponents/helpers/reverseLineString.ts new file mode 100644 index 00000000..5e799a72 --- /dev/null +++ b/src/frontend/src/components/common/MapLibreComponents/helpers/reverseLineString.ts @@ -0,0 +1,17 @@ +export default function reverseLineString(geojson: any) { + const geometry = geojson?.features ? geojson.features[0].geometry : geojson; + if (!geometry) return geojson; + return { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: {}, + geometry: { + ...geometry, + coordinates: [...geometry.coordinates].reverse(), + }, + }, + ], + }; +} diff --git a/src/frontend/src/components/common/MapLibreComponents/types/index.ts b/src/frontend/src/components/common/MapLibreComponents/types/index.ts index 32d52417..3250e895 100644 --- a/src/frontend/src/components/common/MapLibreComponents/types/index.ts +++ b/src/frontend/src/components/common/MapLibreComponents/types/index.ts @@ -42,7 +42,9 @@ export interface ILayer { export type GeojsonType = GeoJsonTypes | FeatureCollection | Feature; export interface IVectorLayer extends ILayer { - geojson: GeojsonType; + geojson: GeojsonType | null; + interactions?: string[]; + onFeatureSelect?: (properties: Record) => void; } type InteractionsType = 'hover' | 'select'; diff --git a/src/frontend/src/components/common/MapLibreComponents/useDrawTool/index.ts b/src/frontend/src/components/common/MapLibreComponents/useDrawTool/index.ts index 6bc2bb34..9c262a77 100644 --- a/src/frontend/src/components/common/MapLibreComponents/useDrawTool/index.ts +++ b/src/frontend/src/components/common/MapLibreComponents/useDrawTool/index.ts @@ -1,12 +1,28 @@ -import { useCallback, useEffect, useMemo } from 'react'; +/* eslint-disable no-param-reassign */ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Popup } from 'maplibre-gl'; import MapboxDraw from '@mapbox/mapbox-gl-draw'; import StaticMode from '@mapbox/mapbox-gl-draw-static-mode'; +import CutLineMode from 'mapbox-gl-draw-cut-line-mode'; import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css'; +import DirectionArrow from '@Assets/images/navigation-image.png'; import { DrawModeTypes, IUseDrawToolProps } from '../types'; +import reverseLineString from '../helpers/reverseLineString'; const { modes } = MapboxDraw; // @ts-ignore modes.static = StaticMode; +// @ts-ignore +modes.cut_line = CutLineMode; + +const popup = new Popup({ + closeButton: false, + closeOnClick: false, + className: 'map-tooltip', + offset: 12, +}); + +const lineStringTypes = ['LineString', 'MultiLineString']; export default function useDrawTool({ map, @@ -16,32 +32,41 @@ export default function useDrawTool({ geojson, onDrawEnd, }: IUseDrawToolProps) { + const [isFeatureSelected, setIsFeatureSelected] = useState(false); + const [isDrawLayerAdded, setIsDrawLayerAdded] = useState(false); + const [drawStates, setDrawStates] = useState([]); + const [redoStates, setRedoStates] = useState([]); + + // create draw instance const draw = useMemo( () => new MapboxDraw({ displayControlsDefault: false, - // controls: { - // polygon: true, - // trash: true, - // }, styles, defaultMode: 'draw_polygon', // @ts-ignore modes, + drawControl: true, }), [], // eslint-disable-line ); + // check if draw layer is added to map useEffect(() => { - if (!map) return; - if (!enable || !drawMode) { - // @ts-ignore - if (map.hasControl(draw)) { - // @ts-ignore - map.removeControl(draw); - } - return; + if (!map) return () => {}; + function handleSourceDataAdd(e: any) { + if (e.sourceId !== 'mapbox-gl-draw-cold') return; + setIsDrawLayerAdded(true); } + map.on('sourcedata', handleSourceDataAdd); + return () => { + map.off('sourcedata', handleSourceDataAdd); + }; + }, [map]); + + // add control to map & geojson to draw + useEffect(() => { + if (!map || !enable || !drawMode) return; // @ts-ignore if (!map.hasControl(draw)) { // @ts-ignore @@ -52,26 +77,17 @@ export default function useDrawTool({ if (geojson) { // @ts-ignore draw.add(geojson); - draw.changeMode('static'); } } }, [map, draw, enable, drawMode, geojson]); - // useEffect(() => { - // if (!enable || !drawMode) return; - // // @ts-ignore - // if (map?.hasControl(draw)) { - // // @ts-ignore - // draw.changeMode(drawMode); - // } - // }, [map, enable, draw, drawMode]); - + // draw event listener useEffect(() => { - if (!map) return () => {}; + if (!map || !enable) return () => {}; function handleDrawEnd() { const data = draw.getAll(); onDrawEnd(data); - // draw.changeMode('static'); + setDrawStates(prev => [...prev, data]); } map.on('draw.create', handleDrawEnd); map.on('draw.delete', handleDrawEnd); @@ -83,12 +99,155 @@ export default function useDrawTool({ map.off('draw.update', handleDrawEnd); map.off('draw.resetDraw', handleDrawEnd); }; - }, [map, draw, onDrawEnd]); + }, [map, draw, enable, onDrawEnd]); + + useEffect(() => { + if (!map || !enable) return () => {}; + function handleDrawEnd() { + const selectedIds = draw.getSelectedIds(); + setIsFeatureSelected(!!selectedIds.length); + } + map.on('draw.selectionchange', handleDrawEnd); + return () => { + map.off('draw.selectionchange', handleDrawEnd); + }; + }, [map, enable, draw]); + + // add start/end circle marker to lineStringTypes + useEffect(() => { + if (!map || !geojson || !enable || !isDrawLayerAdded || isFeatureSelected) + return () => {}; + const featureCollection = draw.getAll(); + const { geometry } = featureCollection.features[0]; + if (!lineStringTypes.includes(geometry.type)) return () => {}; + // @ts-ignore + const coordinates = featureCollection.features[0].geometry?.coordinates; + const firstCoords = coordinates[0]; + const lastCoords = coordinates[coordinates.length - 1]; + map.addSource('line-start-point', { + type: 'geojson', + data: { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: firstCoords, + }, + }, + }); + map.addSource('line-end-point', { + type: 'geojson', + data: { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: lastCoords, + }, + }, + }); + map.addLayer({ + id: 'line-start-point', + type: 'circle', + source: 'line-start-point', + paint: { + 'circle-radius': 6, + 'circle-color': '#0088F8', + }, + }); + map.addLayer({ + id: 'line-end-point', + type: 'circle', + source: 'line-end-point', + paint: { + 'circle-radius': 6, + 'circle-color': '#e55e5e', + }, + }); + return () => { + map.removeLayer('line-start-point'); + map.removeLayer('line-end-point'); + map.removeSource('line-start-point'); + map.removeSource('line-end-point'); + }; + }, [map, draw, geojson, enable, isDrawLayerAdded, isFeatureSelected]); + + // add direction arrow to lineStringTypes + useEffect(() => { + if (!map || !enable || !geojson || !isDrawLayerAdded || isFeatureSelected) + return () => {}; + const featureCollection = draw.getAll(); + const { geometry } = featureCollection.features[0]; + if (!lineStringTypes.includes(geometry.type)) return () => {}; + map.loadImage(DirectionArrow, (err, image) => { + if (err) return; + if (map.getLayer('arrowId')) return; + // @ts-ignore + map.addImage('arrow', image); + map.addLayer({ + id: 'arrowId', + type: 'symbol', + source: 'mapbox-gl-draw-cold', + layout: { + 'symbol-placement': 'line', + 'symbol-spacing': 100, + 'icon-allow-overlap': false, + 'icon-image': 'arrow', + 'icon-size': 0.5, + visibility: 'visible', + 'icon-rotate': 90, + }, + }); + }); + return () => { + if (map.getLayer('arrowId')) { + map.removeImage('arrow'); + map.removeLayer('arrowId'); + } + }; + }, [map, draw, geojson, enable, isDrawLayerAdded, isFeatureSelected]); + + // add tooltip before draw start + useEffect(() => { + if (!map || !drawMode?.includes('draw') || isDrawLayerAdded) + return () => {}; + const handleMouseMove = (e: any) => { + map.getCanvas().style.cursor = 'crosshair'; + const description = 'Click to start drawing shape'; + popup.setLngLat(e.lngLat).setHTML(description).addTo(map); + }; + map.on('mousemove', handleMouseMove); + return () => { + map.off('mousemove', handleMouseMove); + map.getCanvas().style.cursor = ''; + popup.remove(); + }; + }, [map, drawMode, isDrawLayerAdded]); + + // remove draw control on unmount + useEffect(() => { + if (!map) return () => {}; + return () => { + // @ts-ignore + if (map.hasControl(draw)) { + // @ts-ignore + map.removeControl(draw); + setIsDrawLayerAdded(false); + setIsFeatureSelected(false); + setDrawStates([]); + setRedoStates([]); + } + }; + }, [map, draw, enable, drawMode, geojson]); + // reset draw function const resetDraw = useCallback(() => { if (!map) return; // @ts-ignore if (map.hasControl(draw)) { + // remove arrow layer before removing control + if (map.getLayer('arrowId')) { + map.removeImage('arrow'); + map.removeLayer('arrowId'); + } // @ts-ignore map.removeControl(draw); } @@ -99,15 +258,18 @@ export default function useDrawTool({ // @ts-ignore if (geojson) { draw.changeMode('static'); + // setIsDrawLayerAdded(true); } else { // @ts-ignore draw.changeMode(drawMode); } } onDrawEnd(null); - // draw.changeMode('static'); + setDrawStates([]); + setIsDrawLayerAdded(false); }, [map, draw, drawMode, geojson]); // eslint-disable-line + // set draw mode const setDrawMode = useCallback( (mode: DrawModeTypes) => { if (!map || !enable || !mode) { @@ -133,5 +295,88 @@ export default function useDrawTool({ [map, draw, enable, geojson, drawMode], ); - return { draw, resetDraw, setDrawMode }; + // console.log(geojson, 'geojson'); + + // Function to undo the last drawn coordinate + const undo = useCallback(() => { + if (drawStates.length <= 1) { + const lastLine = drawStates[drawStates.length - 1]; + if (lastLine) { + const { coordinates } = lastLine.features[0].geometry; + if (coordinates.length > 1) { + const updatedCoordinates = coordinates.slice(0, -1); // Remove the last coordinate + const updatedLine = { + ...lastLine, + features: [ + { + ...lastLine.features[0], + geometry: { + ...lastLine.features[0].geometry, + coordinates: updatedCoordinates, + }, + }, + ], + }; + setRedoStates([...redoStates, lastLine]); // Track the undone state for redo + setDrawStates(prev => [...prev.slice(0, -1), updatedLine]); // Update the line history with the modified line + draw.delete(lastLine.features[0].id); // Delete the last drawn line from the map + draw.add(updatedLine); // Add the updated line back to the map + onDrawEnd(updatedLine); + } else { + setRedoStates([...redoStates, lastLine]); // Track the undone state for redo + setDrawStates(prev => prev.slice(0, -1)); // Remove the line from the line history + draw.delete(lastLine.features[0].id); // Delete the last drawn line from the map + } + } + } else { + const nextStates = drawStates.slice(0, -1); + setRedoStates([...redoStates, drawStates[drawStates.length - 1]]); // Track the undone state for redo + if (drawStates.length >= 1) { + draw.deleteAll(); + } + const currentState = nextStates[nextStates.length - 1]; + draw.add(currentState); + onDrawEnd(currentState); + setDrawStates(nextStates); + } + }, [drawStates, draw, onDrawEnd, redoStates]); + + const redo = useCallback(() => { + const nextState = redoStates[redoStates.length - 1]; + if (nextState) { + setDrawStates(prev => [...prev, nextState]); // Add the next state to drawStates + setRedoStates(prev => prev.slice(0, -1)); // Remove the next state from redoStates + draw.deleteAll(); + draw.add(nextState); + onDrawEnd(nextState); + } + }, [redoStates, draw, onDrawEnd]); + + // useEffect to handle undo and redo events + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'z' && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + undo(); + } else if (e.key === 'y' && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + redo(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [undo, redo]); + + // reverse line geometry + const reverseLineGeometry = useCallback(() => { + const reversedLineString = reverseLineString(draw.getAll()); + draw.set(reversedLineString); + onDrawEnd(reversedLineString); + setIsFeatureSelected(false); + setIsDrawLayerAdded(false); + }, [draw, onDrawEnd]); + + return { draw, resetDraw, setDrawMode, reverseLineGeometry, undo, redo }; } diff --git a/src/frontend/src/constants/modalContents.tsx b/src/frontend/src/constants/modalContents.tsx index 9db7ffd0..76cd412a 100644 --- a/src/frontend/src/constants/modalContents.tsx +++ b/src/frontend/src/constants/modalContents.tsx @@ -1,6 +1,10 @@ import { ReactElement } from 'react'; +import ExitCreateProjectModal from '@Components/CreateProject/ExitCreateProjectModal'; -export type ModalContentsType = 'sign-up-success' | null; +export type ModalContentsType = + | 'sign-up-success' + | 'quit-create-project' + | null; export type PromptDialogContentsType = 'delete-layer' | null; type ModalReturnType = { @@ -17,6 +21,11 @@ export function getModalContent(content: ModalContentsType): ModalReturnType { title: '', content: <>, }; + case 'quit-create-project': + return { + title: 'Unsaved Changes!', + content: , + }; default: return { title: '', diff --git a/src/frontend/src/react-app-env.d.ts b/src/frontend/src/react-app-env.d.ts index 1b4a201b..b2d3851d 100644 --- a/src/frontend/src/react-app-env.d.ts +++ b/src/frontend/src/react-app-env.d.ts @@ -4,3 +4,4 @@ declare module '*.jpeg'; declare module '*.jpg'; declare module 'uuid'; declare module '@mapbox/mapbox-gl-draw-static-mode'; +declare module 'mapbox-gl-draw-cut-line-mode'; diff --git a/src/frontend/src/store/actions/createproject.ts b/src/frontend/src/store/actions/createproject.ts index afac1353..78a71935 100644 --- a/src/frontend/src/store/actions/createproject.ts +++ b/src/frontend/src/store/actions/createproject.ts @@ -1,4 +1,5 @@ /* eslint-disable import/prefer-default-export */ import { createProjectSlice } from '@Store/slices/createproject'; -export const { setCreateProjectState } = createProjectSlice.actions; +export const { setCreateProjectState, resetUploadedAndDrawnAreas } = + createProjectSlice.actions; diff --git a/src/frontend/src/store/slices/createproject.ts b/src/frontend/src/store/slices/createproject.ts index d8c61ab4..794feb39 100644 --- a/src/frontend/src/store/slices/createproject.ts +++ b/src/frontend/src/store/slices/createproject.ts @@ -1,3 +1,4 @@ +import { GeojsonType } from '@Components/common/MapLibreComponents/types'; import { createSlice } from '@reduxjs/toolkit'; import type { CaseReducer, PayloadAction } from '@reduxjs/toolkit'; import persist from '@Store/persist'; @@ -9,8 +10,12 @@ export interface CreateProjectState { contributionsOption: 'public' | 'invite_with_email'; generateTaskOption: 'divide_hexagon' | 'divide_rectangle'; isNoflyzonePresent: 'yes' | 'no'; - uploadedProjectArea: Record | null; - uploadedNoFlyZone: Record | null; + projectArea: GeojsonType | null; + noFlyZone: GeojsonType | null; + drawProjectAreaEnable: boolean; + drawNoFlyZoneEnable: boolean; + drawnProjectArea: GeojsonType | null; + drawnNoFlyZone: GeojsonType | null; splitGeojson: Record | null; isTerrainFollow: string; } @@ -22,25 +27,41 @@ const initialState: CreateProjectState = { contributionsOption: 'public', generateTaskOption: 'divide_rectangle', isNoflyzonePresent: 'no', - uploadedProjectArea: null, - uploadedNoFlyZone: null, + projectArea: null, + noFlyZone: null, + drawProjectAreaEnable: false, + drawNoFlyZoneEnable: false, + drawnProjectArea: null, + drawnNoFlyZone: null, splitGeojson: null, isTerrainFollow: 'flat', }; const setCreateProjectState: CaseReducer< CreateProjectState, - PayloadAction>> + PayloadAction> > = (state, action) => ({ ...state, ...action.payload, }); +const resetUploadedAndDrawnAreas: CaseReducer = state => ({ + ...state, + isNoflyzonePresent: initialState.isNoflyzonePresent, + projectArea: initialState.projectArea, + noFlyZone: initialState.noFlyZone, + drawProjectAreaEnable: initialState.drawProjectAreaEnable, + drawNoFlyZoneEnable: initialState.drawNoFlyZoneEnable, + drawnProjectArea: initialState.drawnProjectArea, + drawnNoFlyZone: initialState.drawnNoFlyZone, +}); + const createProjectSlice = createSlice({ name: 'create project', initialState, reducers: { setCreateProjectState, + resetUploadedAndDrawnAreas, }, });