From 530acb175f98a1a98efa07c2cc374222ddb07b93 Mon Sep 17 00:00:00 2001 From: Erik Escoffier Date: Wed, 4 Oct 2023 14:56:05 +0200 Subject: [PATCH] Basic AoIs using URL hash --- .../common/map/controls/aoi/index.tsx | 34 ++++++++++ .../common/map/controls/hooks/use-aois.ts | 37 +++++++++++ .../components/common/map/controls/index.tsx | 2 +- .../common/map/mapbox-style-override.ts | 30 ++++++++- app/scripts/components/common/map/types.d.ts | 12 +++- app/scripts/components/common/map/utils.ts | 4 ++ .../components/exploration/atoms/atoms.ts | 56 +++++++++++++++++ .../exploration/components/map/index.tsx | 17 +++++ app/scripts/utils/polygon-url.ts | 63 ++++++++++++++----- package.json | 1 + yarn.lock | 5 ++ 11 files changed, 242 insertions(+), 19 deletions(-) create mode 100644 app/scripts/components/common/map/controls/aoi/index.tsx create mode 100644 app/scripts/components/common/map/controls/hooks/use-aois.ts 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..a59276d8f --- /dev/null +++ b/app/scripts/components/common/map/controls/aoi/index.tsx @@ -0,0 +1,34 @@ +import MapboxDraw from '@mapbox/mapbox-gl-draw'; +import { useControl } from 'react-map-gl'; + +// import type { MapRef } from 'react-map-gl'; + +type DrawControlProps = ConstructorParameters[0] & { + onCreate?: (evt: { features: object[] }) => void; + onUpdate?: (evt: { features: object[]; action: string }) => void; + onDelete?: (evt: { features: object[] }) => void; + onSelectionChange?: (evt: { selectedFeatures: object[] }) => void; +}; + +export default function DrawControl(props: DrawControlProps) { + useControl( + () => new MapboxDraw(props), + ({ 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 }: { 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 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..83fe80842 --- /dev/null +++ b/app/scripts/components/common/map/controls/hooks/use-aois.ts @@ -0,0 +1,37 @@ +import { useAtomValue, useSetAtom } from 'jotai'; +import { useCallback } from 'react'; +import { Polygon } from 'geojson'; +import { toAoIid } from '../../utils'; +import { aoisDeleteAtom, aoisFeaturesAtom, aoisSetSelectedAtom, aoisUpdateGeometryAtom } from '$components/exploration/atoms/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/mapbox-style-override.ts b/app/scripts/components/common/map/mapbox-style-override.ts index 00c7067a4..869068990 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,32 @@ 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_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 + })}); + } + } + + + // mapbox-gl-draw_polygon" + /* GEOCODER styles */ .mapboxgl-ctrl.mapboxgl-ctrl-geocoder { background-color: ${themeVal('color.surface')}; 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/atoms/atoms.ts b/app/scripts/components/exploration/atoms/atoms.ts index d20be9f22..672bde717 100644 --- a/app/scripts/components/exploration/atoms/atoms.ts +++ b/app/scripts/components/exploration/atoms/atoms.ts @@ -1,4 +1,6 @@ import { atom } from 'jotai'; +import { atomWithHash } from 'jotai-location'; +import { Polygon } from 'geojson'; import { DataMetric, dataMetrics @@ -6,6 +8,8 @@ import { import { HEADER_COLUMN_WIDTH, RIGHT_AXIS_SPACE } from '../constants'; import { DateRange, TimelineDataset, ZoomTransformPlain } from '../types.d.ts'; +import { decodeAois, encodeAois } from '$utils/polygon-url'; +import { AoIFeature } from '$components/common/map/types'; // Datasets to show on the timeline and their settings export const timelineDatasetsAtom = atom([]); @@ -43,3 +47,55 @@ export const activeAnalysisMetricsAtom = atom(dataMetrics); // 🛑 Whether or not an analysis is being performed. Temporary!!! export const isAnalysisAtom = atom(false); + +// This is the atom acting as a single source of truth for the AOIs. +export const aoisHashAtom = atomWithHash('aois', ''); + +// Getter atom to get AoiS as GeoJSON features from the hash. +export const aoisFeaturesAtom = atom((get) => { + const hash = get(aoisHashAtom); + 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(aoisHashAtom, 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(aoisHashAtom, 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(aoisHashAtom, encodeAois(newFeatures)); +}); diff --git a/app/scripts/components/exploration/components/map/index.tsx b/app/scripts/components/exploration/components/map/index.tsx index d07f2f34e..9b37ff157 100644 --- a/app/scripts/components/exploration/components/map/index.tsx +++ b/app/scripts/components/exploration/components/map/index.tsx @@ -21,6 +21,8 @@ import MapCoordsControl from '$components/common/map/controls/coords'; import MapOptionsControl from '$components/common/map/controls/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, features } = useAois(); + console.log(features); + return ( {/* Map layers */} @@ -81,6 +86,18 @@ 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..ea3d3e6f0 100644 --- a/app/scripts/utils/polygon-url.ts +++ b/app/scripts/utils/polygon-url.ts @@ -1,6 +1,20 @@ -import { FeatureCollection, Polygon } from 'geojson'; +import { Feature, FeatureCollection, Polygon } from 'geojson'; import gjv from 'geojson-validation'; 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 +29,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 +39,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 +60,32 @@ 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 = decoded.reduce((acc, current, i) => { + if (i % 3 === 0) { + const decodedFeature = decodeFeature(current) as AoIFeature; + return [...acc, decodedFeature]; + } else { + const lastFeature = acc[acc.length - 1]; + const prop = i % 3 === 1 ? 'id' : 'selected'; + const newFeature = { ...lastFeature, [prop]: current }; + acc[acc.length - 1] = newFeature; + return acc; + } + }, []); + 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"