From a9c91a710e084fc2d50c946af492741cde8d51cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Gonz=C3=A1lez=20Mu=C3=B1oz?= Date: Wed, 29 Nov 2023 16:44:07 +0100 Subject: [PATCH] flow for drawing and analysis --- .../src/components/map/draw-controls/hooks.ts | 10 +- .../components/map/draw-controls/index.tsx | 66 ------------ frontend/src/components/map/drawing/index.tsx | 47 -------- frontend/src/components/map/index.tsx | 2 - .../sidebar-content/analysis/index.tsx | 39 ------- .../sidebar-content/drawing/index.tsx | 102 ------------------ .../src/components/sidebar-content/index.tsx | 35 ------ .../sidebar-content/overview/index.tsx | 28 ----- .../map/content/map/analysis/index.tsx | 50 +++++++++ .../map/content/map/draw-controls/index.tsx | 86 +++++++++++++++ .../src/containers/map/content/map/index.tsx | 20 ++-- .../map/content/map/layers-toolbox/index.tsx | 2 +- frontend/src/containers/map/sidebar/index.tsx | 34 +++++- frontend/src/containers/map/store.ts | 17 ++- .../helpers.ts => lib/utils/file-upload.ts} | 0 15 files changed, 197 insertions(+), 341 deletions(-) delete mode 100644 frontend/src/components/map/draw-controls/index.tsx delete mode 100644 frontend/src/components/map/drawing/index.tsx delete mode 100644 frontend/src/components/sidebar-content/analysis/index.tsx delete mode 100644 frontend/src/components/sidebar-content/drawing/index.tsx delete mode 100644 frontend/src/components/sidebar-content/index.tsx delete mode 100644 frontend/src/components/sidebar-content/overview/index.tsx create mode 100644 frontend/src/containers/map/content/map/analysis/index.tsx create mode 100644 frontend/src/containers/map/content/map/draw-controls/index.tsx rename frontend/src/{components/sidebar-content/drawing/helpers.ts => lib/utils/file-upload.ts} (100%) diff --git a/frontend/src/components/map/draw-controls/hooks.ts b/frontend/src/components/map/draw-controls/hooks.ts index 5d8be105..1c22c3d9 100644 --- a/frontend/src/components/map/draw-controls/hooks.ts +++ b/frontend/src/components/map/draw-controls/hooks.ts @@ -24,7 +24,7 @@ export const DRAW_STYLES: ( 'line-join': 'round', }, paint: { - 'line-color': '#000', + 'line-color': '#02B07C', 'line-width': 2, }, }, @@ -34,8 +34,8 @@ export const DRAW_STYLES: ( type: 'fill', filter: ['all', ['==', '$type', 'Polygon'], ['!=', 'mode', 'static']], paint: { - 'fill-color': '#000', - 'fill-outline-color': '#000', + 'fill-color': '#02B07C', + 'fill-outline-color': '#02B07C', 'fill-opacity': 0.2, }, }, @@ -60,7 +60,7 @@ export const DRAW_STYLES: ( 'line-join': 'round', }, paint: { - 'line-color': '#000', + 'line-color': '#02B07C', 'line-width': 2, }, }, @@ -71,7 +71,7 @@ export const DRAW_STYLES: ( filter: ['all', ['!=', 'mode', 'static']], paint: { 'circle-radius': 6, - 'circle-color': '#000', + 'circle-color': '#02B07C', }, }, // vertex point halos diff --git a/frontend/src/components/map/draw-controls/index.tsx b/frontend/src/components/map/draw-controls/index.tsx deleted file mode 100644 index d5cb76ae..00000000 --- a/frontend/src/components/map/draw-controls/index.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { FC, useCallback, useMemo, useState } from 'react'; - -import { useAtom } from 'jotai'; -import { Trash2 } from 'lucide-react'; - -import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css'; -import { Button } from '@/components/ui/button'; -import { drawStateAtom } from '@/containers/map/store'; - -import { useMapboxDraw, UseMapboxDrawProps } from './hooks'; - -const DrawControls: FC = () => { - const [startedDrawing, setStartedDrawing] = useState(false); - const [drawState, setDrawState] = useAtom(drawStateAtom); - - const onCreate: UseMapboxDrawProps['onCreate'] = useCallback( - ({ features }) => { - setDrawState({ active: false, feature: features[0] }); - setStartedDrawing(false); - }, - [setDrawState] - ); - - const onClick: UseMapboxDrawProps['onClick'] = useCallback( - () => setStartedDrawing(true), - [setStartedDrawing] - ); - - const useMapboxDrawProps = useMemo( - () => ({ - enabled: drawState.active, - onCreate, - onClick, - }), - [drawState.active, onClick, onCreate] - ); - - const draw = useMapboxDraw(useMapboxDrawProps); - - if (!drawState.active) { - return null; - } - - return ( -
-

- {!startedDrawing && 'Start drawing'} - {startedDrawing && 'Close the shape to analyze'} -

-
- -
- ); -}; - -export default DrawControls; diff --git a/frontend/src/components/map/drawing/index.tsx b/frontend/src/components/map/drawing/index.tsx deleted file mode 100644 index 1b8a4abc..00000000 --- a/frontend/src/components/map/drawing/index.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { FC, useEffect } from 'react'; - -import { Source, Layer, LngLatBoundsLike, useMap } from 'react-map-gl'; - -import { bbox } from '@turf/turf'; -import { useAtomValue } from 'jotai'; - -import { drawStateAtom } from '@/containers/map/store'; - -import { DRAW_STYLES } from '../draw-controls/hooks'; - -const Drawing: FC = () => { - const { current: map } = useMap(); - const { active, feature } = useAtomValue(drawStateAtom); - - useEffect(() => { - if (map && feature) { - const geojsonBbox = bbox(feature); - - map.fitBounds(geojsonBbox as LngLatBoundsLike, { - animate: true, - padding: 20, - }); - } - }, [map, feature]); - - if (active || !feature) { - return null; - } - - return ( - - {DRAW_STYLES.filter((layer) => layer.type !== 'circle').map((layer) => ( - - ))} - - ); -}; - -export default Drawing; diff --git a/frontend/src/components/map/index.tsx b/frontend/src/components/map/index.tsx index 60d5875e..12105447 100644 --- a/frontend/src/components/map/index.tsx +++ b/frontend/src/components/map/index.tsx @@ -145,5 +145,3 @@ export const Map: FC = ({ export default Map; export { default as ZoomControls } from './zoom-controls'; export { default as Attributions } from './attributions'; -export { default as DrawControls } from './draw-controls'; -export { default as Drawing } from './drawing'; diff --git a/frontend/src/components/sidebar-content/analysis/index.tsx b/frontend/src/components/sidebar-content/analysis/index.tsx deleted file mode 100644 index ec723ffc..00000000 --- a/frontend/src/components/sidebar-content/analysis/index.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { useSetAtom } from 'jotai'; - -import { Button } from '@/components/ui/button'; -import { drawStateAtom } from '@/containers/map/store'; - -const AnalysisStateContent = { - Content: () => { - const setDrawState = useSetAtom(drawStateAtom); - - return ( - <> - -

Custom Area

-

Status last updated: August 2023

- - ); - }, - Footer: () => { - const setDrawState = useSetAtom(drawStateAtom); - - return ( - - ); - }, -}; - -export default AnalysisStateContent; diff --git a/frontend/src/components/sidebar-content/drawing/index.tsx b/frontend/src/components/sidebar-content/drawing/index.tsx deleted file mode 100644 index 6c996f55..00000000 --- a/frontend/src/components/sidebar-content/drawing/index.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { ChangeEvent, ChangeEventHandler, useCallback, useState } from 'react'; - -import { useAtom, useSetAtom } from 'jotai'; -import { AlertCircle } from 'lucide-react'; - -import { Alert, AlertDescription } from '@/components/ui/alert'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { drawStateAtom } from '@/containers/map/store'; - -import { convertFilesToGeojson, supportedFileformats } from './helpers'; - -const DrawingStateContent = { - Content: () => { - const setDrawState = useSetAtom(drawStateAtom); - const [errorMessage, setErrorMessage] = useState(null); - - const onChange: ChangeEventHandler = useCallback( - (e) => { - const handler = async (e: ChangeEvent) => { - const { files } = e.currentTarget; - - try { - const geojson = await convertFilesToGeojson(Array.from(files)); - setErrorMessage(null); - setDrawState({ active: false, feature: geojson }); - } catch (errorMessage) { - setErrorMessage(errorMessage as string); - } - }; - - void handler(e); - }, - [setDrawState] - ); - - return ( - <> -

Analyze an area

-

- Use the drawing tool to draw an area on the map or{' '} - upload a geometry. -

- `.${ext}`).join(',')} - aria-label="Upload a geometry" - aria-describedby="upload-notes upload-error" - className="mt-8" - onChange={onChange} - /> - {!!errorMessage && ( - - - {errorMessage} - - )} -
-

The following formats are accepted:

-
-
Shapefile
-
- 3 mandatory files: .shp,{' '} - .shx and{' '} - .dbf -
2 optional files: .prj (recommended) and{' '} - .cfg -
-
KML
-
- 1 .kml file -
-
KMZ
-
- 1 .kmz file -
-
-

- Please note that points are not accepted and that only the first area in the file(s) is - considered. -

-
- - ); - }, - Footer: () => { - const [drawState, setDrawState] = useAtom(drawStateAtom); - - return ( - - ); - }, -}; - -export default DrawingStateContent; diff --git a/frontend/src/components/sidebar-content/index.tsx b/frontend/src/components/sidebar-content/index.tsx deleted file mode 100644 index b8e1c09d..00000000 --- a/frontend/src/components/sidebar-content/index.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { FC } from 'react'; - -import { useAtomValue } from 'jotai'; - -import { drawStateAtom } from '@/containers/map/store'; - -import AnalysisStateContent from './analysis'; -import DrawingStateContent from './drawing'; -import OverviewStateContent from './overview'; - -const SidebarContent: FC = () => { - const drawState = useAtomValue(drawStateAtom); - - let content: { Content: FC; Footer: FC }; - if (drawState.active) { - content = DrawingStateContent; - } else if (drawState.feature) { - content = AnalysisStateContent; - } else { - content = OverviewStateContent; - } - - return ( -
-
- -
-
- -
-
- ); -}; - -export default SidebarContent; diff --git a/frontend/src/components/sidebar-content/overview/index.tsx b/frontend/src/components/sidebar-content/overview/index.tsx deleted file mode 100644 index a087d70e..00000000 --- a/frontend/src/components/sidebar-content/overview/index.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { useAtom } from 'jotai'; - -import { Button } from '@/components/ui/button'; -import { drawStateAtom } from '@/containers/map/store'; - -const OverviewStateContent = { - Content: () => ( - <> -

Worldwide

-

Status last updated: August 2023

- - ), - Footer: () => { - const [drawState, setDrawState] = useAtom(drawStateAtom); - - return ( - - ); - }, -}; - -export default OverviewStateContent; diff --git a/frontend/src/containers/map/content/map/analysis/index.tsx b/frontend/src/containers/map/content/map/analysis/index.tsx new file mode 100644 index 00000000..28e4f6b2 --- /dev/null +++ b/frontend/src/containers/map/content/map/analysis/index.tsx @@ -0,0 +1,50 @@ +import { useEffect } from 'react'; + +import { useQuery } from '@tanstack/react-query'; +// import axios from 'axios'; +// import { Feature } from 'geojson'; +import { useAtomValue, useSetAtom } from 'jotai'; + +import { analysisAtom, drawStateAtom } from '@/containers/map/store'; + +const fetchAnalysis = async () => { + // todo: prepare feature for analysis + // return axios.post('https://analysis.skytruth.org', feature); + + return new Promise((resolve) => { + setTimeout(() => { + resolve({ + data: { + locations_area: [{ FRA: 23856, USA: 502367 }], + total_area: 736000, + }, + }); + }, 2500); + }); +}; + +const Analysis = () => { + const { feature } = useAtomValue(drawStateAtom); + const setAnalysisState = useSetAtom(analysisAtom); + + const { isFetching, isSuccess, data, isError } = useQuery( + ['analysis', feature], + () => fetchAnalysis(), + { + enabled: Boolean(feature), + } + ); + + useEffect(() => { + setAnalysisState((prevState) => ({ + ...prevState, + ...(isSuccess && { status: 'success', data }), + ...(isFetching && { status: 'running' }), + ...(isError && { status: 'error' }), + })); + }, [setAnalysisState, isFetching, isSuccess, data, isError]); + + return null; +}; + +export default Analysis; diff --git a/frontend/src/containers/map/content/map/draw-controls/index.tsx b/frontend/src/containers/map/content/map/draw-controls/index.tsx new file mode 100644 index 00000000..15138672 --- /dev/null +++ b/frontend/src/containers/map/content/map/draw-controls/index.tsx @@ -0,0 +1,86 @@ +import { FC, useCallback, useEffect, useMemo } from 'react'; + +import { Layer, LngLatBoundsLike, useMap, Source } from 'react-map-gl'; + +import { bbox } from '@turf/turf'; +import { useAtom, useAtomValue } from 'jotai'; + +import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css'; +import { + DRAW_STYLES, + useMapboxDraw, + UseMapboxDrawProps, +} from '@/components/map/draw-controls/hooks'; +import { drawStateAtom, sidebarAtom } from '@/containers/map/store'; + +const DrawControls: FC = () => { + const [{ active, feature }, setDrawState] = useAtom(drawStateAtom); + const isSidebarOpen = useAtomValue(sidebarAtom); + const { current: map } = useMap(); + + const onCreate: UseMapboxDrawProps['onCreate'] = useCallback( + ({ features }) => { + setDrawState((prevState) => ({ + ...prevState, + status: 'success', + feature: features[0], + })); + }, + [setDrawState] + ); + + const onClick: UseMapboxDrawProps['onClick'] = useCallback(() => { + setDrawState((prevState) => ({ + ...prevState, + status: 'drawing', + })); + }, [setDrawState]); + + const useMapboxDrawProps = useMemo( + () => ({ + enabled: active, + onCreate, + onClick, + }), + [active, onClick, onCreate] + ); + + useMapboxDraw(useMapboxDrawProps); + + useEffect(() => { + if (map && feature) { + const geojsonBbox = bbox(feature); + + map.fitBounds(geojsonBbox as LngLatBoundsLike, { + animate: true, + padding: { + top: 20, + right: 20, + bottom: 20, + left: isSidebarOpen ? 430 : 0, + }, + }); + } + }, [map, feature, isSidebarOpen]); + + if (active || !feature) { + return null; + } + + return ( + + {DRAW_STYLES.filter((layer) => layer.type !== 'circle').map((layer) => ( + + ))} + + ); +}; + +export default DrawControls; diff --git a/frontend/src/containers/map/content/map/index.tsx b/frontend/src/containers/map/content/map/index.tsx index 74f0943d..5e03a41f 100644 --- a/frontend/src/containers/map/content/map/index.tsx +++ b/frontend/src/containers/map/content/map/index.tsx @@ -8,8 +8,10 @@ import { useParams } from 'next/navigation'; import { useQueryClient } from '@tanstack/react-query'; import { useAtomValue, useSetAtom } from 'jotai'; -import Map, { ZoomControls, Attributions, DrawControls, Drawing } from '@/components/map'; +import Map, { ZoomControls, Attributions } from '@/components/map'; import { DEFAULT_VIEW_STATE } from '@/components/map/constants'; +import Analysis from '@/containers/map/content/map/analysis'; +import DrawControls from '@/containers/map/content/map/draw-controls'; import LabelsManager from '@/containers/map/content/map/labels-manager'; import LayersToolbox from '@/containers/map/content/map/layers-toolbox'; import Popup from '@/containers/map/content/map/popup'; @@ -22,7 +24,6 @@ import { layersInteractiveIdsAtom, popupAtom, } from '@/containers/map/store'; -import { cn } from '@/lib/classnames'; import { useGetLayers } from '@/types/generated/layer'; import { LocationGroupsDataItemAttributes } from '@/types/generated/strapi.schemas'; import { LayerTyped } from '@/types/layers'; @@ -79,6 +80,8 @@ const MainMap: React.FC = () => { const handleMapClick = useCallback( (e: Parameters['onClick']>[0]) => { + if (drawState.active) return null; + if ( layersInteractive.length && layersInteractiveData.some((l) => { @@ -90,7 +93,7 @@ const MainMap: React.FC = () => { setPopup(p); } }, - [layersInteractive, layersInteractiveData, setPopup] + [layersInteractive, layersInteractiveData, setPopup, drawState] ); const handleMouseMove = useCallback( @@ -124,7 +127,6 @@ const MainMap: React.FC = () => { const handleMouseLeave = useCallback(() => { if (hoveredPolygonId.current !== null) { map.setFeatureState( - // ? not a fan of harcoding the sources here, but there is no other way to find out the source { source: hoveredPolygonId.current.source, id: hoveredPolygonId.current.id, @@ -191,19 +193,13 @@ const MainMap: React.FC = () => { attributionControl={false} > <> -
- -
+ - + diff --git a/frontend/src/containers/map/content/map/layers-toolbox/index.tsx b/frontend/src/containers/map/content/map/layers-toolbox/index.tsx index 5f6a6f90..574037e7 100644 --- a/frontend/src/containers/map/content/map/layers-toolbox/index.tsx +++ b/frontend/src/containers/map/content/map/layers-toolbox/index.tsx @@ -9,7 +9,7 @@ import Legend from './legend'; const TABS_TRIGGER_CLASSES = 'group flex flex-1 items-center space-x-1 rounded-none border border-b-0 border-black py-3 px-6 font-mono text-sm font-bold uppercase leading-none text-black last:border-l-0 data-[state=active]:bg-orange'; -const TABS_ICONS_CLASSES = 'w-5 h-5 -translate-y-[2px]'; +const TABS_ICONS_CLASSES = 'w-6 h-6 -translate-y-[2px]'; const LayersToolbox = (): JSX.Element => { const [activeLayers] = useSyncMapLayers(); diff --git a/frontend/src/containers/map/sidebar/index.tsx b/frontend/src/containers/map/sidebar/index.tsx index 1f60ab01..dfbd395d 100644 --- a/frontend/src/containers/map/sidebar/index.tsx +++ b/frontend/src/containers/map/sidebar/index.tsx @@ -1,12 +1,14 @@ +import { useCallback } from 'react'; + import { useRouter } from 'next/router'; import { useQueryClient } from '@tanstack/react-query'; import { useAtom } from 'jotai'; -import { ChevronLeft } from 'lucide-react'; +import { LuChevronLeft, LuChevronRight } from 'react-icons/lu'; import { Button } from '@/components/ui/button'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; -import { sidebarAtom } from '@/containers/map/store'; +import { sidebarAtom, drawStateAtom } from '@/containers/map/store'; import { cn } from '@/lib/classnames'; import { LocationGroupsDataItemAttributes } from '@/types/generated/strapi.schemas'; @@ -29,6 +31,14 @@ const MapSidebar: React.FC = () => { ]); const [isSidebarOpen, setSidebarOpen] = useAtom(sidebarAtom); + const [{ active: isDrawingActive }, setDrawState] = useAtom(drawStateAtom); + + const onClickDrawing = useCallback(() => { + setDrawState((prevState) => ({ + ...prevState, + active: true, + })); + }, [setDrawState]); return ( { open={isSidebarOpen} onOpenChange={setSidebarOpen} > + {process.env.NEXT_PUBLIC_FEATURE_FLAG_ANALYSIS === 'true' && !isDrawingActive && ( + + )} diff --git a/frontend/src/containers/map/store.ts b/frontend/src/containers/map/store.ts index 8391653c..27a57948 100644 --- a/frontend/src/containers/map/store.ts +++ b/frontend/src/containers/map/store.ts @@ -2,6 +2,7 @@ import { MapLayerMouseEvent } from 'react-map-gl'; import { Feature } from 'geojson'; import { atom } from 'jotai'; +import { atomWithReset } from 'jotai/utils'; import { CustomMapProps } from '@/components/map/types'; import { LayerResponseDataObject } from '@/types/generated/strapi.schemas'; @@ -13,7 +14,21 @@ export const layersInteractiveAtom = atom([]); export const layersInteractiveIdsAtom = atom([]); export const bboxLocation = atom(null); export const popupAtom = atom>({}); -export const drawStateAtom = atom<{ active: boolean; feature: Feature }>({ +export const drawStateAtom = atomWithReset<{ + active: boolean; + status: 'idle' | 'drawing' | 'success'; + feature: Feature; +}>({ active: false, + status: 'idle', feature: null, }); + +// ? analysis state +export const analysisAtom = atomWithReset<{ + status: 'idle' | 'running' | 'success' | 'error'; + data: unknown; +}>({ + status: 'idle', + data: null, +}); diff --git a/frontend/src/components/sidebar-content/drawing/helpers.ts b/frontend/src/lib/utils/file-upload.ts similarity index 100% rename from frontend/src/components/sidebar-content/drawing/helpers.ts rename to frontend/src/lib/utils/file-upload.ts