diff --git a/app/scripts/components/common/map/controls/aoi/atoms.ts b/app/scripts/components/common/map/controls/aoi/atoms.ts new file mode 100644 index 000000000..6034a6b1b --- /dev/null +++ b/app/scripts/components/common/map/controls/aoi/atoms.ts @@ -0,0 +1,68 @@ +import { atom } from "jotai"; +import { atomWithLocation } from "jotai-location"; +import { Polygon } from "geojson"; +import { AoIFeature } from "../../types"; +import { decodeAois, encodeAois } from "$utils/polygon-url"; + +// This is the atom acting as a single source of truth for the AOIs. +export const aoisAtom = atomWithLocation(); + +const aoisSerialized = atom( + (get) => get(aoisAtom).searchParams?.get("aois"), + (get, set, aois) => { + set(aoisAtom, (prev) => ({ + ...prev, + searchParams: new URLSearchParams([["aois", aois as string]]) + })); + } +); + + +// Getter atom to get AoiS as GeoJSON features from the hash. +export const aoisFeaturesAtom = atom((get) => { + const hash = get(aoisSerialized); + if (!hash) return []; + return decodeAois(hash); +}); + +// Setter atom to update AOIs geoometries, writing directly to the hash atom. +export const aoisUpdateGeometryAtom = atom( + null, + (get, set, updates: { id: string; geometry: Polygon }[]) => { + let newFeatures = [...get(aoisFeaturesAtom)]; + updates.forEach(({ id, geometry }) => { + const existingFeature = newFeatures.find((feature) => feature.id === id); + if (existingFeature) { + existingFeature.geometry = geometry; + } else { + const newFeature: AoIFeature = { + type: 'Feature', + id, + geometry, + selected: true, + properties: {} + }; + newFeatures = [...newFeatures, newFeature]; + } + }); + set(aoisSerialized, encodeAois(newFeatures)); + } +); + +// Setter atom to update AOIs selected state, writing directly to the hash atom. +export const aoisSetSelectedAtom = atom(null, (get, set, ids: string[]) => { + const features = get(aoisFeaturesAtom); + const newFeatures = features.map((feature) => { + return { ...feature, selected: ids.includes(feature.id as string) }; + }); + set(aoisSerialized, encodeAois(newFeatures)); +}); + +// Setter atom to delete AOIs, writing directly to the hash atom. +export const aoisDeleteAtom = atom(null, (get, set, ids: string[]) => { + const features = get(aoisFeaturesAtom); + const newFeatures = features.filter( + (feature) => !ids.includes(feature.id as string) + ); + set(aoisSerialized, encodeAois(newFeatures)); +}); diff --git a/app/scripts/components/common/map/controls/aoi/index.tsx b/app/scripts/components/common/map/controls/aoi/index.tsx new file mode 100644 index 000000000..4e1d968c3 --- /dev/null +++ b/app/scripts/components/common/map/controls/aoi/index.tsx @@ -0,0 +1,56 @@ +import React 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 { aoisFeaturesAtom } from './atoms'; + +type DrawControlProps = { + onCreate?: (evt: { features: object[] }) => void; + onUpdate?: (evt: { features: object[]; action: string }) => void; + onDelete?: (evt: { features: object[] }) => void; + onSelectionChange?: (evt: { selectedFeatures: object[] }) => void; +} & MapboxDraw.DrawOptions; + +const Css = createGlobalStyle` +.mapbox-gl-draw_trash { + opacity: .5; + pointer-events: none !important; +} +`; + +export default function DrawControl(props: DrawControlProps) { + const control = useRef(); + const aoisFeatures = useAtomValue(aoisFeaturesAtom); + + useControl( + () => { + control.current = new MapboxDraw(props); + 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('load', () => { + control.current?.set({ + type: 'FeatureCollection', + features: aoisFeatures + }); + }); + }, + ({ 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); + }, + { + position: 'top-left' + } + ); + + return aoisFeatures.length ? null : ; +} diff --git a/app/scripts/components/common/map/controls/hooks/use-aois.ts b/app/scripts/components/common/map/controls/hooks/use-aois.ts new file mode 100644 index 000000000..3032f5a48 --- /dev/null +++ b/app/scripts/components/common/map/controls/hooks/use-aois.ts @@ -0,0 +1,40 @@ +import { useAtomValue, useSetAtom } from 'jotai'; +import { useCallback } from 'react'; +import { Polygon } from 'geojson'; +import { toAoIid } from '../../utils'; +import { aoisDeleteAtom, aoisFeaturesAtom, aoisSetSelectedAtom, aoisUpdateGeometryAtom } from '../aoi/atoms'; + +export default function useAois() { + const features = useAtomValue(aoisFeaturesAtom); + + const aoisUpdateGeometry = useSetAtom(aoisUpdateGeometryAtom); + const onUpdate = useCallback( + (e) => { + const updates = e.features.map((f) => ({ + id: toAoIid(f.id), + geometry: f.geometry as Polygon + })); + aoisUpdateGeometry(updates); + }, + [aoisUpdateGeometry] + ); + + const aoiDelete = useSetAtom(aoisDeleteAtom); + const onDelete = useCallback( + (e) => { + const selectedIds = e.features.map((f) => toAoIid(f.id)); + aoiDelete(selectedIds); + }, + [aoiDelete] + ); + + const aoiSetSelected = useSetAtom(aoisSetSelectedAtom); + const onSelectionChange = useCallback( + (e) => { + const selectedIds = e.features.map((f) => toAoIid(f.id)); + aoiSetSelected(selectedIds); + }, + [aoiSetSelected] + ); + return { features, onUpdate, onDelete, onSelectionChange }; +} diff --git a/app/scripts/components/common/map/controls/index.tsx b/app/scripts/components/common/map/controls/index.tsx index e5d766236..563ff3314 100644 --- a/app/scripts/components/common/map/controls/index.tsx +++ b/app/scripts/components/common/map/controls/index.tsx @@ -10,4 +10,4 @@ export function NavigationControl() { export function ScaleControl() { return ; -} \ No newline at end of file +} diff --git a/app/scripts/components/common/map/controls/options.tsx b/app/scripts/components/common/map/controls/map-options/index.tsx similarity index 96% rename from app/scripts/components/common/map/controls/options.tsx rename to app/scripts/components/common/map/controls/map-options/index.tsx index e332f2ae0..6d93d2224 100644 --- a/app/scripts/components/common/map/controls/options.tsx +++ b/app/scripts/components/common/map/controls/map-options/index.tsx @@ -12,15 +12,15 @@ import { Button, createButtonStyles } from '@devseed-ui/button'; import { FormSwitch } from '@devseed-ui/form'; import { Subtitle } from '@devseed-ui/typography'; +import useThemedControl from '../hooks/use-themed-control'; import { ProjectionItemConic, ProjectionItemCustom, ProjectionItemSimple -} from './map-options/projection-items'; -import { MapOptionsProps } from './map-options/types'; -import { projectionsList } from './map-options/projections'; -import { BASEMAP_STYLES } from './map-options/basemap'; -import useThemedControl from './hooks/use-themed-control'; +} from './projection-items'; +import { MapOptionsProps } from './types'; +import { projectionsList } from './projections'; +import { BASEMAP_STYLES } from './basemap'; import { ShadowScrollbarImproved as ShadowScrollbar } from '$components/common/shadow-scrollbar-improved'; const DropHeader = styled.div` diff --git a/app/scripts/components/common/map/mapbox-style-override.ts b/app/scripts/components/common/map/mapbox-style-override.ts index 00c7067a4..a6cf2ff0c 100644 --- a/app/scripts/components/common/map/mapbox-style-override.ts +++ b/app/scripts/components/common/map/mapbox-style-override.ts @@ -8,7 +8,9 @@ import { CollecticonPlusSmall, CollecticonMinusSmall, CollecticonMagnifierLeft, - CollecticonXmarkSmall + CollecticonXmarkSmall, + CollecticonPencil, + CollecticonTrashBin } from '@devseed-ui/collecticons'; import { glsp, themeVal } from '@devseed-ui/theme-provider'; import { variableGlsp } from '$styles/variable-utils'; @@ -178,6 +180,34 @@ const MapboxStyleOverride = css` background-color: ${themeVal('color.base-400a')}; } + .mapbox-gl-draw_ctrl-draw-btn { + ${createButtonStyles({ variation: 'primary-fill', fitting: 'skinny' })} + } + + .mapbox-gl-draw_ctrl-draw-btn.active { + background-color: ${themeVal('color.base-400a')}; + } + + .mapbox-gl-draw_ctrl-draw-btn:not(:disabled):hover { + background-color: ${themeVal('color.base-400a')}; + } + + .mapbox-gl-draw_polygon.mapbox-gl-draw_polygon::before { + background-image: url(${({ theme }) => + iconDataURI(CollecticonPencil, { + color: theme.color?.surface + })}); + } + } + + .mapbox-gl-draw_trash.mapbox-gl-draw_trash::before { + background-image: url(${({ theme }) => + iconDataURI(CollecticonTrashBin, { + color: theme.color?.surface + })}); + } + } + /* GEOCODER styles */ .mapboxgl-ctrl.mapboxgl-ctrl-geocoder { background-color: ${themeVal('color.surface')}; diff --git a/app/scripts/components/common/map/maps.tsx b/app/scripts/components/common/map/maps.tsx index b3864132a..b1a2cfbfe 100644 --- a/app/scripts/components/common/map/maps.tsx +++ b/app/scripts/components/common/map/maps.tsx @@ -41,6 +41,16 @@ const MapsContainer = styled.div` .mapboxgl-map { position: absolute !important; inset: 0; + + &.mouse-add .mapboxgl-canvas-container { + cursor: crosshair; + } + &.mouse-pointer .mapboxgl-canvas-container { + cursor: pointer; + } + &.mouse-move .mapboxgl-canvas-container { + cursor: move; + } } .mapboxgl-compare .compare-swiper-vertical { diff --git a/app/scripts/components/common/map/types.d.ts b/app/scripts/components/common/map/types.d.ts index 89ae4cd77..b6a54f80b 100644 --- a/app/scripts/components/common/map/types.d.ts +++ b/app/scripts/components/common/map/types.d.ts @@ -1,4 +1,5 @@ -import { AnyLayer, AnySourceImpl } from "mapbox-gl"; +import { Feature, Polygon } from 'geojson'; +import { AnyLayer, AnySourceImpl } from 'mapbox-gl'; export interface ExtendedMetadata { layerOrderPosition?: LayerOrderPosition; @@ -29,10 +30,15 @@ export type LayerOrderPosition = | 'vector' | 'basemap-foreground'; -export type MapId = 'main' | 'compared' +export type MapId = 'main' | 'compared'; export interface StacFeature { bbox: [number, number, number, number]; } -export type OptionalBbox = number[] | undefined | null; \ No newline at end of file +export type OptionalBbox = number[] | undefined | null; + +export type AoIFeature = Feature & { + selected: boolean; + id: string; +}; diff --git a/app/scripts/components/common/map/utils.ts b/app/scripts/components/common/map/utils.ts index 4b70ecc96..498d65a8a 100644 --- a/app/scripts/components/common/map/utils.ts +++ b/app/scripts/components/common/map/utils.ts @@ -180,3 +180,7 @@ export function resolveConfigFunctions( return datum; } + +export function toAoIid(drawId: string) { + return drawId.slice(-6); +} \ 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 d07f2f34e..bed9d0610 100644 --- a/app/scripts/components/exploration/components/map/index.tsx +++ b/app/scripts/components/exploration/components/map/index.tsx @@ -18,9 +18,11 @@ import { ScaleControl } from '$components/common/map/controls'; import MapCoordsControl from '$components/common/map/controls/coords'; -import MapOptionsControl from '$components/common/map/controls/options'; +import MapOptionsControl from '$components/common/map/controls/map-options'; import { projectionDefault } from '$components/common/map/controls/map-options/projections'; 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'; export function ExplorationMap(props: { comparing: boolean }) { const [projection, setProjection] = useState(projectionDefault); @@ -49,6 +51,9 @@ export function ExplorationMap(props: { comparing: boolean }) { .slice() .reverse(); + const { onUpdate, onDelete, onSelectionChange } = useAois(); + // console.log(features); + return ( {/* Map layers */} @@ -81,6 +86,17 @@ export function ExplorationMap(props: { comparing: boolean }) { boundariesOption={boundariesOption} onOptionChange={onOptionChange} /> + {props.comparing && ( // Compare map layers diff --git a/app/scripts/utils/polygon-url.ts b/app/scripts/utils/polygon-url.ts index cb1e41faf..dc7e9c01e 100644 --- a/app/scripts/utils/polygon-url.ts +++ b/app/scripts/utils/polygon-url.ts @@ -1,6 +1,21 @@ -import { FeatureCollection, Polygon } from 'geojson'; +import { Feature, FeatureCollection, Polygon } from 'geojson'; import gjv from 'geojson-validation'; +import { chunk } from 'lodash'; import { decode, encode } from 'google-polyline'; +import { AoIFeature } from '$components/common/map/types'; +import { toAoIid } from '$components/common/map/utils'; + +function decodeFeature(polygon: string): Feature { + const coords = decode(polygon); + return { + type: 'Feature', + properties: {}, + geometry: { + type: 'Polygon', + coordinates: [[...coords, coords[0]]] + } + } as Feature; +} /** * Decodes a multi polygon string converting it into a FeatureCollection of @@ -15,15 +30,7 @@ export function polygonUrlDecode(polygonStr: string) { const geojson = { type: 'FeatureCollection', features: polygonStr.split(';').map((polygon) => { - const coords = decode(polygon); - return { - type: 'Feature', - properties: {}, - geometry: { - type: 'Polygon', - coordinates: [[...coords, coords[0]]] - } - }; + return decodeFeature(polygon) as Feature; }) } as FeatureCollection; @@ -33,6 +40,13 @@ export function polygonUrlDecode(polygonStr: string) { }; } +function encodePolygon(polygon: Polygon) { + const points = polygon.coordinates[0] + // Remove last coordinate since it is repeated. + .slice(0, -1); + return encode(points); +} + /** * Converts a FeatureCollection of Polygons into a url string. * Removes the last point of the polygon as it is the same as the first. @@ -47,10 +61,25 @@ export function polygonUrlEncode( ) { return featureCollection.features .map((feature) => { - const points = feature.geometry.coordinates[0] - // Remove last coordinate since it is repeated. - .slice(0, -1); - return encode(points); + return encodePolygon(feature.geometry); }) .join(';'); } + +export function encodeAois(aois: AoIFeature[]): string { + const encoded = aois.reduce((acc, aoi) => { + const encodedGeom = encodePolygon(aoi.geometry); + return [...acc, encodedGeom, toAoIid(aoi.id), !!aoi.selected]; + }, []); + return JSON.stringify(encoded); +} + +export function decodeAois(aois: string): AoIFeature[] { + const decoded = JSON.parse(aois) as string[]; + const features: AoIFeature[] = chunk(decoded, 3).map((data) => { + const [polygon, id, selected] = data; + const decodedFeature = decodeFeature(polygon) as AoIFeature; + return { ...decodedFeature, id, selected }; + }); + return features!; +} diff --git a/package.json b/package.json index 1dfb39a01..9b3a59ada 100644 --- a/package.json +++ b/package.json @@ -143,6 +143,7 @@ "intersection-observer": "^0.12.0", "jest-environment-jsdom": "^28.1.3", "jotai": "^2.2.3", + "jotai-location": "^0.5.1", "jotai-optics": "^0.3.1", "js-yaml": "^4.1.0", "lodash": "^4.17.21", diff --git a/yarn.lock b/yarn.lock index ea7bcb271..c85640577 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8308,6 +8308,11 @@ jest@^28.1.3: import-local "^3.0.2" jest-cli "^28.1.3" +jotai-location@^0.5.1: + version "0.5.1" + resolved "http://verdaccio.ds.io:4873/jotai-location/-/jotai-location-0.5.1.tgz#1a08b683cd7823ce57f7fef8b98335f1ce5c7105" + integrity sha512-6b34X6PpUaXmHCcyxdMFUHgRLUEp+SFHq9UxHbg5HxHC1LddVyVZbPJI+P15+SOQJcUTH3KrsIeKmeLko+Vw/A== + jotai-optics@^0.3.1: version "0.3.1" resolved "http://verdaccio.ds.io:4873/jotai-optics/-/jotai-optics-0.3.1.tgz#7ff38470551429460cc41d9cd1320193665354e0"