diff --git a/app/scripts/components/common/map/controls/aoi/atoms.ts b/app/scripts/components/common/map/controls/aoi/atoms.ts index 6034a6b1b..2d49280d9 100644 --- a/app/scripts/components/common/map/controls/aoi/atoms.ts +++ b/app/scripts/components/common/map/controls/aoi/atoms.ts @@ -1,6 +1,6 @@ import { atom } from "jotai"; import { atomWithLocation } from "jotai-location"; -import { Polygon } from "geojson"; +import { Feature, Polygon } from "geojson"; import { AoIFeature } from "../../types"; import { decodeAois, encodeAois } from "$utils/polygon-url"; @@ -28,7 +28,7 @@ export const aoisFeaturesAtom = atom((get) => { // Setter atom to update AOIs geoometries, writing directly to the hash atom. export const aoisUpdateGeometryAtom = atom( null, - (get, set, updates: { id: string; geometry: Polygon }[]) => { + (get, set, updates: Feature[]) => { let newFeatures = [...get(aoisFeaturesAtom)]; updates.forEach(({ id, geometry }) => { const existingFeature = newFeatures.find((feature) => feature.id === id); @@ -37,7 +37,7 @@ export const aoisUpdateGeometryAtom = atom( } else { const newFeature: AoIFeature = { type: 'Feature', - id, + id: id as string, geometry, selected: true, properties: {} diff --git a/app/scripts/components/common/map/controls/aoi/custom-aoi-control.tsx b/app/scripts/components/common/map/controls/aoi/custom-aoi-control.tsx new file mode 100644 index 000000000..0618def36 --- /dev/null +++ b/app/scripts/components/common/map/controls/aoi/custom-aoi-control.tsx @@ -0,0 +1,48 @@ +import React, { useState } from 'react'; +import { Feature, Polygon } from 'geojson'; +import { CollecticonUpload2 } from '@devseed-ui/collecticons'; +import { Button, createButtonStyles } from '@devseed-ui/button'; +import styled from 'styled-components'; +import { themeVal } from '@devseed-ui/theme-provider'; +import useThemedControl from '../hooks/use-themed-control'; +import CustomAoIModal from '../custom-aoi-modal'; + +const SelectorButton = styled(Button)` + &&& { + ${createButtonStyles({ variation: 'primary-fill', fitting: 'skinny' })} + background-color: ${themeVal('color.surface')}; + &:hover { + background-color: ${themeVal('color.surface')}; + } + & path { + fill: ${themeVal('color.base')}; + } + } +`; + +interface CustomAoIProps { + onConfirm: (features: Feature[]) => void; +} + +function CustomAoI({ onConfirm }: CustomAoIProps) { + const [aoiModalRevealed, setAoIModalRevealed] = useState(false); + return ( + <> + setAoIModalRevealed(true)}> + + + setAoIModalRevealed(false)} + /> + + ); +} + +export default function CustomAoIControl(props: CustomAoIProps) { + useThemedControl(() => , { + position: 'top-left' + }); + return null; +} diff --git a/app/scripts/components/common/map/controls/aoi/index.tsx b/app/scripts/components/common/map/controls/aoi/index.tsx index 4e1d968c3..66dd6f471 100644 --- a/app/scripts/components/common/map/controls/aoi/index.tsx +++ b/app/scripts/components/common/map/controls/aoi/index.tsx @@ -1,16 +1,16 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import MapboxDraw from '@mapbox/mapbox-gl-draw'; import { createGlobalStyle } from 'styled-components'; import { useAtomValue } from 'jotai'; import { useRef } from 'react'; import { useControl } from 'react-map-gl'; +import { Feature, Polygon } from 'geojson'; +import useAois from '../hooks/use-aois'; import { aoisFeaturesAtom } from './atoms'; +import { encodeAois } from '$utils/polygon-url'; type DrawControlProps = { - onCreate?: (evt: { features: object[] }) => void; - onUpdate?: (evt: { features: object[]; action: string }) => void; - onDelete?: (evt: { features: object[] }) => void; - onSelectionChange?: (evt: { selectedFeatures: object[] }) => void; + customFeatures: Feature[]; } & MapboxDraw.DrawOptions; const Css = createGlobalStyle` @@ -23,6 +23,19 @@ const Css = createGlobalStyle` export default function DrawControl(props: DrawControlProps) { const control = useRef(); const aoisFeatures = useAtomValue(aoisFeaturesAtom); + const { customFeatures } = props; + + const { onUpdate, onDelete, onSelectionChange } = useAois(); + + const serializedCustomFeatures = encodeAois(customFeatures); + useEffect(() => { + if (!customFeatures.length) return; + control.current?.add({ + type: 'FeatureCollection', + features: customFeatures + }); + // Look at serialized version to only update when the features change + }, [serializedCustomFeatures]); useControl( () => { @@ -30,10 +43,10 @@ export default function DrawControl(props: DrawControlProps) { return control.current; }, ({ map }: { map: any }) => { - map.on('draw.create', props.onCreate); - map.on('draw.update', props.onUpdate); - map.on('draw.delete', props.onDelete); - map.on('draw.selectionchange', props.onSelectionChange); + map.on('draw.create', onUpdate); + map.on('draw.update', onUpdate); + map.on('draw.delete', onDelete); + map.on('draw.selectionchange', onSelectionChange); map.on('load', () => { control.current?.set({ type: 'FeatureCollection', @@ -42,10 +55,10 @@ export default function DrawControl(props: DrawControlProps) { }); }, ({ map }: { map: any }) => { - map.off('draw.create', props.onCreate); - map.off('draw.update', props.onUpdate); - map.off('draw.delete', props.onDelete); - map.off('draw.selectionchange', props.onSelectionChange); + map.off('draw.create', onUpdate); + map.off('draw.update', onUpdate); + map.off('draw.delete', onDelete); + map.off('draw.selectionchange', onSelectionChange); }, { position: 'top-left' diff --git a/app/scripts/components/common/map/controls/custom-aoi-modal.tsx b/app/scripts/components/common/map/controls/custom-aoi-modal.tsx new file mode 100644 index 000000000..b387440e3 --- /dev/null +++ b/app/scripts/components/common/map/controls/custom-aoi-modal.tsx @@ -0,0 +1,220 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import styled from 'styled-components'; +import { Feature, Polygon } from 'geojson'; +import { Modal, ModalHeadline, ModalFooter } from '@devseed-ui/modal'; +import { Heading, Subtitle } from '@devseed-ui/typography'; + +import { Button } from '@devseed-ui/button'; +import { + glsp, + listReset, + themeVal, + visuallyHidden +} from '@devseed-ui/theme-provider'; +import { + CollecticonArrowUp, + CollecticonTickSmall, + CollecticonXmarkSmall, + CollecticonCircleExclamation, + CollecticonCircleTick, + CollecticonCircleInformation +} from '@devseed-ui/collecticons'; +import useCustomAoI, { acceptExtensions } from './hooks/use-custom-aoi'; +import { variableGlsp, variableProseVSpace } from '$styles/variable-utils'; + +const UploadFileModalFooter = styled(ModalFooter)` + display: flex; + justify-content: right; + flex-flow: row nowrap; + gap: ${variableGlsp(0.25)}; +`; + +const ModalBodyInner = styled.div` + display: flex; + flex-flow: column nowrap; + gap: ${variableGlsp()}; +`; + +const UploadFileIntro = styled.div` + display: flex; + flex-flow: column nowrap; + gap: ${variableProseVSpace()}; +`; + +const FileUpload = styled.div` + display: flex; + flex-flow: nowrap; + align-items: center; + gap: ${variableGlsp(0.5)}; + + ${Button} { + flex-shrink: 0; + } + + ${Subtitle} { + overflow-wrap: anywhere; + } +`; + +const FileInput = styled.input` + ${visuallyHidden()} +`; + +const UploadInformation = styled.div` + padding: ${variableGlsp()}; + background: ${themeVal('color.base-50')}; + box-shadow: ${themeVal('boxShadow.inset')}; + border-radius: ${themeVal('shape.rounded')}; +`; + +const UploadListInfo = styled.ul` + ${listReset()} + display: flex; + flex-flow: column nowrap; + gap: ${glsp(0.25)}; + + li { + display: flex; + flex-flow: row nowrap; + gap: ${glsp(0.5)}; + align-items: top; + + > svg { + flex-shrink: 0; + margin-top: ${glsp(0.25)}; + } + } +`; + +const UploadInfoItemSuccess = styled.li` + color: ${themeVal('color.success')}; +`; + +const UploadInfoItemWarnings = styled.li` + color: ${themeVal('color.info')}; +`; + +const UploadInfoItemError = styled.li` + color: ${themeVal('color.danger')}; +`; + +interface CustomAoIModalProps { + revealed: boolean; + onCloseClick: () => void; + onConfirm: (features: Feature[]) => void; +} + +export default function CustomAoIModal({ + revealed, + onCloseClick, + onConfirm +}: CustomAoIModalProps) { + const { + features, + onUploadFile, + uploadFileError, + uploadFileWarnings, + fileInfo, + reset + } = useCustomAoI(); + const fileInputRef = useRef(null); + + const onUploadClick = useCallback(() => { + if (fileInputRef.current) fileInputRef.current.click(); + }, []); + + const onConfirmClick = useCallback(() => { + if (!features) return; + onConfirm(features); + onCloseClick(); + }, [features, onConfirm, onCloseClick]); + + useEffect(() => { + if (revealed) reset(); + }, [revealed, reset]); + + const hasInfo = !!uploadFileWarnings.length || !!features || uploadFileError; + + return ( + ( + +

Upload custom area

+
+ )} + content={ + + +

+ You can upload a zipped shapefile (*.zip) or a GeoJSON file + (*.json, *.geojson) to define a custom area of interest. +

+ + + {fileInfo && ( + + File: {fileInfo.name} ({fileInfo.type}). + + )} + + +
+ + {hasInfo && ( + + + + + {uploadFileWarnings.map((w) => ( + + + {w} + + ))} + {features && ( + + + File uploaded successfully. + + )} + {uploadFileError && ( + + {uploadFileError} + + )} + + + )} +
+ } + renderFooter={() => ( + + + + + )} + /> + ); +} diff --git a/app/scripts/components/common/map/controls/hooks/use-aois.ts b/app/scripts/components/common/map/controls/hooks/use-aois.ts index 3032f5a48..66668ef1f 100644 --- a/app/scripts/components/common/map/controls/hooks/use-aois.ts +++ b/app/scripts/components/common/map/controls/hooks/use-aois.ts @@ -1,22 +1,33 @@ import { useAtomValue, useSetAtom } from 'jotai'; import { useCallback } from 'react'; -import { Polygon } from 'geojson'; +import { Feature, Polygon } from 'geojson'; import { toAoIid } from '../../utils'; -import { aoisDeleteAtom, aoisFeaturesAtom, aoisSetSelectedAtom, aoisUpdateGeometryAtom } from '../aoi/atoms'; +import { + aoisDeleteAtom, + aoisFeaturesAtom, + aoisSetSelectedAtom, + aoisUpdateGeometryAtom +} from '../aoi/atoms'; export default function useAois() { const features = useAtomValue(aoisFeaturesAtom); const aoisUpdateGeometry = useSetAtom(aoisUpdateGeometryAtom); + const update = useCallback( + (features: Feature[]) => { + aoisUpdateGeometry(features); + }, + [aoisUpdateGeometry] + ); const onUpdate = useCallback( (e) => { const updates = e.features.map((f) => ({ id: toAoIid(f.id), geometry: f.geometry as Polygon })); - aoisUpdateGeometry(updates); + update(updates); }, - [aoisUpdateGeometry] + [update] ); const aoiDelete = useSetAtom(aoisDeleteAtom); @@ -36,5 +47,6 @@ export default function useAois() { }, [aoiSetSelected] ); - return { features, onUpdate, onDelete, onSelectionChange }; + + return { features, update, onUpdate, onDelete, onSelectionChange }; } diff --git a/app/scripts/components/common/map/controls/hooks/use-custom-aoi.tsx b/app/scripts/components/common/map/controls/hooks/use-custom-aoi.tsx new file mode 100644 index 000000000..d3e0f194f --- /dev/null +++ b/app/scripts/components/common/map/controls/hooks/use-custom-aoi.tsx @@ -0,0 +1,225 @@ +import { Feature, MultiPolygon, Polygon } from 'geojson'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import shp from 'shpjs'; +import simplify from '@turf/simplify'; + +import { multiPolygonToPolygons } from '../../utils'; +import { round } from '$utils/format'; + +const extensions = ['geojson', 'json', 'zip']; +export const acceptExtensions = extensions.map((ext) => `.${ext}`).join(', '); + +export interface FileInfo { + name: string; + extension: string; + type: 'Shapefile' | 'GeoJSON'; +} + +function getNumPoints(feature: Feature): number { + return feature.geometry.coordinates.reduce((acc, current) => { + return acc + current.length; + }, 0); +} + +function useCustomAoI() { + const [fileInfo, setFileInfo] = useState(null); + const [uploadFileError, setUploadFileError] = useState(null); + const [uploadFileWarnings, setUploadFileWarnings] = useState([]); + const reader = useRef(); + const [features, setFeatures] = useState[] | null>(null); + + useEffect(() => { + reader.current = new FileReader(); + + const setError = (error: string) => { + setUploadFileError(error); + setFeatures(null); + setUploadFileWarnings([]); + }; + + const onLoad = async () => { + if (!reader.current) return; + + let geojson; + if (typeof reader.current.result === 'string') { + const rawGeoJSON = reader.current.result; + if (!rawGeoJSON) { + setError('Error uploading file.'); + return; + } + try { + geojson = JSON.parse(rawGeoJSON as string); + } catch (e) { + setError('Error uploading file: invalid JSON'); + return; + } + } else { + try { + geojson = await shp(reader.current.result); + } catch (e) { + setError(`Error uploading file: invalid Shapefile (${e.message})`); + return; + } + } + + if (!geojson?.features?.length) { + setError('Error uploading file: Invalid GeoJSON'); + return; + } + + let warnings: string[] = []; + + if ( + geojson.features.some( + (feature) => + !['MultiPolygon', 'Polygon'].includes(feature.geometry.type) + ) + ) { + setError( + 'Wrong geometry type. Only polygons or multi polygons are accepted.' + ); + return; + } + + const features: Feature[] = geojson.features.reduce( + (acc, feature: Feature) => { + if (feature.geometry.type === 'MultiPolygon') { + return acc.concat( + multiPolygonToPolygons(feature as Feature) + ); + } + + return acc.concat(feature); + }, + [] + ); + + if (features.length > 200) { + setError('Only files with up to 200 polygons are accepted.'); + return; + } + + // Simplify features; + const originalTotalFeaturePoints = features.reduce( + (acc, f) => acc + getNumPoints(f), + 0 + ); + let numPoints = originalTotalFeaturePoints; + let tolerance = 0.001; + + // Remove holes from polygons as they're not supported. + let polygonHasRings = false; + let simplifiedFeatures = features.map>((feature) => { + if (feature.geometry.coordinates.length > 1) { + polygonHasRings = true; + return { + ...feature, + geometry: { + type: 'Polygon', + coordinates: [feature.geometry.coordinates[0]] + } + }; + } + + return feature; + }); + + if (polygonHasRings) { + warnings = [ + ...warnings, + 'Polygons with rings are not supported and were simplified to remove them' + ]; + } + + // If we allow up to 200 polygons and each polygon needs 4 points, we need + // at least 800, give an additional buffer and we get 1000. + while (numPoints > 1000 && tolerance < 5) { + simplifiedFeatures = simplifiedFeatures.map((feature) => + simplify(feature, { tolerance }) + ); + numPoints = simplifiedFeatures.reduce( + (acc, f) => acc + getNumPoints(f), + 0 + ); + tolerance = Math.min(tolerance * 1.8, 5); + } + + if (originalTotalFeaturePoints !== numPoints) { + warnings = [ + ...warnings, + `The geometry has been simplified (${round( + (1 - numPoints / originalTotalFeaturePoints) * 100 + )} % less).` + ]; + } + + setUploadFileWarnings(warnings); + setUploadFileError(null); + setFeatures( + simplifiedFeatures.map((feat, i) => ({ + id: `${new Date().getTime().toString().slice(-4)}${i}`, + ...feat + })) + ); + }; + + const onError = () => { + setError('Error uploading file'); + }; + + reader.current.addEventListener('load', onLoad); + reader.current.addEventListener('error', onError); + + return () => { + if (!reader.current) return; + reader.current.removeEventListener('load', onLoad); + reader.current.removeEventListener('error', onError); + }; + }, []); + + const onUploadFile = useCallback((event) => { + if (!reader.current) return; + + const file = event.target.files[0]; + if (!file) return; + + const [, extension] = file.name.match(/^.*\.(json|geojson|zip)$/i) ?? []; + + if (!extensions.includes(extension)) { + setUploadFileError( + 'Wrong file type. Only zipped shapefiles and geojson files are accepted.' + ); + return; + } + + setFileInfo({ + name: file.name, + extension, + type: extension === 'zip' ? 'Shapefile' : 'GeoJSON' + }); + + if (extension === 'zip') { + reader.current.readAsArrayBuffer(file); + } else if (extension === 'json' || extension === 'geojson') { + reader.current.readAsText(file); + } + }, []); + + const reset = useCallback(() => { + setFeatures(null); + setUploadFileWarnings([]); + setUploadFileError(null); + setFileInfo(null); + }, []); + + return { + features, + onUploadFile, + uploadFileError, + uploadFileWarnings, + fileInfo, + reset + }; +} + +export default useCustomAoI; diff --git a/app/scripts/components/common/map/utils.ts b/app/scripts/components/common/map/utils.ts index cf960b79d..6ee71bf2e 100644 --- a/app/scripts/components/common/map/utils.ts +++ b/app/scripts/components/common/map/utils.ts @@ -2,6 +2,7 @@ import axios, { Method } from 'axios'; import { Map as MapboxMap } from 'mapbox-gl'; import { MapRef } from 'react-map-gl'; import { endOfDay, startOfDay } from 'date-fns'; +import { Feature, MultiPolygon, Polygon } from 'geojson'; import { DatasetDatumFn, DatasetDatumFnResolverBag, @@ -183,4 +184,30 @@ export function resolveConfigFunctions( export function toAoIid(drawId: string) { return drawId.slice(-6); +} + + +/** + * Converts a MultiPolygon to a Feature Collection of polygons. + * + * @param feature MultiPolygon feature + * + * @see combineFeatureCollection() for opposite + * + * @returns Feature Collection of Polygons + */ +export function multiPolygonToPolygons(feature: Feature) { + const polygons = feature.geometry.coordinates.map( + (coordinates) => + ({ + type: 'Feature', + properties: { ...feature.properties }, + geometry: { + type: 'Polygon', + coordinates: coordinates + } + } as Feature) + ); + + return polygons; } \ No newline at end of file diff --git a/app/scripts/components/exploration/components/map/index.tsx b/app/scripts/components/exploration/components/map/index.tsx index 6d89f6430..12a14ee51 100644 --- a/app/scripts/components/exploration/components/map/index.tsx +++ b/app/scripts/components/exploration/components/map/index.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import { useAtomValue } from 'jotai'; import { addMonths } from 'date-fns'; +import { Feature, Polygon } from 'geojson'; import { useStacMetadataOnDatasets } from '../../hooks/use-stac-metadata-datasets'; import { selectedDateAtom, timelineDatasetsAtom } from '../../atoms/atoms'; @@ -21,6 +22,7 @@ import { projectionDefault } from '$components/common/map/controls/map-options/p import { useBasemap } from '$components/common/map/controls/hooks/use-basemap'; import DrawControl from '$components/common/map/controls/aoi'; import useAois from '$components/common/map/controls/hooks/use-aois'; +import CustomAoIControl from '$components/common/map/controls/aoi/custom-aoi-control'; export function ExplorationMap(props: { comparing: boolean }) { const [projection, setProjection] = useState(projectionDefault); @@ -49,7 +51,15 @@ export function ExplorationMap(props: { comparing: boolean }) { .slice() .reverse(); - const { onUpdate, onDelete, onSelectionChange } = useAois(); + const { update } = useAois(); + + const [customAoIFeatures, setCustomAoIFeatures] = useState< + Feature[] + >([]); + const onCustomAoIConfirm = (features: Feature[]) => { + update(features); + setCustomAoIFeatures(features); + }; return ( @@ -78,11 +88,9 @@ export function ExplorationMap(props: { comparing: boolean }) { trash: true } as any } - onCreate={onUpdate} - onUpdate={onUpdate} - onDelete={onDelete} - onSelectionChange={onSelectionChange} + customFeatures={customAoIFeatures} /> +