From 5c227a788a6da4f4a0f80b8318b230bc874c15d0 Mon Sep 17 00:00:00 2001 From: Erik Escoffier Date: Wed, 4 Oct 2023 14:56:05 +0200 Subject: [PATCH] Basic AoIs --- .../common/map/controls/aoi/index.tsx | 34 +++++++++++ .../common/map/controls/hooks/use-aois.ts | 51 ++++++++++++++++ .../components/common/map/controls/index.tsx | 2 +- .../common/map/mapbox-style-override.ts | 30 +++++++++- .../components/exploration/atoms/atoms.ts | 19 ++++++ .../exploration/components/map/index.tsx | 17 ++++++ app/scripts/utils/polygon-url.ts | 60 ++++++++++++++----- package.json | 1 + yarn.lock | 5 ++ 9 files changed, 203 insertions(+), 16 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..e7ac6111a --- /dev/null +++ b/app/scripts/components/common/map/controls/hooks/use-aois.ts @@ -0,0 +1,51 @@ +import { useAtom } from "jotai"; +import { useCallback } from "react"; +import { aoiFeaturesAtom } from "$components/exploration/atoms/atoms"; + +export default function useAois() { + const [features, setFeatures] = useAtom(aoiFeaturesAtom); + + const onUpdate = useCallback( + (e) => { + let newFeatures = [...features]; + e.features.forEach((f) => { + const existingFeature = newFeatures.find( + (feature) => feature.id === f.id + ); + if (existingFeature) { + existingFeature.geometry = f.geometry; + } else { + newFeatures = [...newFeatures, f]; + } + }); + setFeatures(newFeatures); + }, + [features, setFeatures] + ); + + const onDelete = useCallback( + (e) => { + let newFeatures = [...features]; + e.features.forEach((f) => { + newFeatures = newFeatures.filter((feature) => feature.id !== f.id); + }); + setFeatures(newFeatures); + }, + [features, setFeatures] + ); + + const onSelectionChange = useCallback( + (e) => { + const newFeatures = features.map((feature) => { + if (e.features.find((f) => f.id === feature.id)) { + return { ...feature, selected: true }; + } else { + return { ...feature, selected: false }; + } + }); + setFeatures(newFeatures); + }, + [features, setFeatures] + ); + return { features, onUpdate, onDelete, onSelectionChange }; +} \ No newline at end of file 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/exploration/atoms/atoms.ts b/app/scripts/components/exploration/atoms/atoms.ts index d20be9f22..476b0799f 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 { Feature } from 'geojson'; import { DataMetric, dataMetrics @@ -6,6 +8,7 @@ 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'; // Datasets to show on the timeline and their settings export const timelineDatasetsAtom = atom([]); @@ -43,3 +46,19 @@ export const activeAnalysisMetricsAtom = atom(dataMetrics); // 🛑 Whether or not an analysis is being performed. Temporary!!! export const isAnalysisAtom = atom(false); + +export const aoiHashAtom = atomWithHash('aois','') + +export const aoiFeaturesAtom = atom( + (get) => { + const hash = get(aoiHashAtom); + if (!hash) return []; + return decodeAois(hash); + }, + (get, set, features) => { + const hash = encodeAois(features); + set(aoiHashAtom, hash); + } +); + +// export const aoiFeaturesAtom = atom[]>([]); \ 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..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..e508ce88c 100644 --- a/app/scripts/utils/polygon-url.ts +++ b/app/scripts/utils/polygon-url.ts @@ -1,7 +1,19 @@ -import { FeatureCollection, Polygon } from 'geojson'; +import { Feature, FeatureCollection, Polygon } from 'geojson'; import gjv from 'geojson-validation'; import { decode, encode } from 'google-polyline'; +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 * Polygons. @@ -15,15 +27,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 +37,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 +58,31 @@ 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: Feature[]): string { + const encoded = aois.reduce((acc, aoi) => { + const encodedGeom = encodePolygon(aoi.geometry); + return [...acc, encodedGeom, !!aoi.selected]; + }, []); + return JSON.stringify(encoded); +} + +export function decodeAois(aois: string): Feature[] { + const decoded = JSON.parse(aois) as string[]; + const features = decoded.reduce[]>((acc, current, i) => { + if (i % 2 === 0) { + const decodedFeature = decodeFeature(current); + return [...acc, decodedFeature]; + } else { + const lastFeature = acc[acc.length - 1]; + const newFeature = { ...lastFeature, selected: 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"