Skip to content

Commit

Permalink
Basic AoIs using URL hash
Browse files Browse the repository at this point in the history
  • Loading branch information
nerik committed Oct 5, 2023
1 parent 2d8b371 commit 530acb1
Show file tree
Hide file tree
Showing 11 changed files with 242 additions and 19 deletions.
34 changes: 34 additions & 0 deletions app/scripts/components/common/map/controls/aoi/index.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof MapboxDraw>[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<MapboxDraw>(
() => 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;
}
37 changes: 37 additions & 0 deletions app/scripts/components/common/map/controls/hooks/use-aois.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
2 changes: 1 addition & 1 deletion app/scripts/components/common/map/controls/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ export function NavigationControl() {

export function ScaleControl() {
return <MapboxGLScaleControl position='bottom-left' />;
}
}
30 changes: 29 additions & 1 deletion app/scripts/components/common/map/mapbox-style-override.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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')};
Expand Down
12 changes: 9 additions & 3 deletions app/scripts/components/common/map/types.d.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
export type OptionalBbox = number[] | undefined | null;

export type AoIFeature = Feature<Polygon> & {
selected: boolean;
id: string;
};
4 changes: 4 additions & 0 deletions app/scripts/components/common/map/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,7 @@ export function resolveConfigFunctions(

return datum;
}

export function toAoIid(drawId: string) {
return drawId.slice(-6);
}
56 changes: 56 additions & 0 deletions app/scripts/components/exploration/atoms/atoms.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { atom } from 'jotai';
import { atomWithHash } from 'jotai-location';
import { Polygon } from 'geojson';
import {
DataMetric,
dataMetrics
} from '../components/analysis-metrics-dropdown';

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<TimelineDataset[]>([]);
Expand Down Expand Up @@ -43,3 +47,55 @@ export const activeAnalysisMetricsAtom = atom<DataMetric[]>(dataMetrics);

// 🛑 Whether or not an analysis is being performed. Temporary!!!
export const isAnalysisAtom = atom<boolean>(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<AoIFeature[]>((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));
});
17 changes: 17 additions & 0 deletions app/scripts/components/exploration/components/map/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -49,6 +51,9 @@ export function ExplorationMap(props: { comparing: boolean }) {
.slice()
.reverse();

const { onUpdate, onDelete, onSelectionChange, features } = useAois();
console.log(features);

return (
<Map id='exploration' projection={projection}>
{/* Map layers */}
Expand Down Expand Up @@ -81,6 +86,18 @@ export function ExplorationMap(props: { comparing: boolean }) {
boundariesOption={boundariesOption}
onOptionChange={onOptionChange}
/>
<DrawControl
displayControlsDefault={false}
controls={{
polygon: true,
trash: true
}}
defaultMode='draw_polygon'
onCreate={onUpdate}
onUpdate={onUpdate}
onDelete={onDelete}
onSelectionChange={onSelectionChange}
/>
{props.comparing && (
// Compare map layers
<Compare>
Expand Down
63 changes: 49 additions & 14 deletions app/scripts/utils/polygon-url.ts
Original file line number Diff line number Diff line change
@@ -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<Polygon> {
const coords = decode(polygon);
return {
type: 'Feature',
properties: {},
geometry: {
type: 'Polygon',
coordinates: [[...coords, coords[0]]]
}
} as Feature<Polygon>;
}

/**
* Decodes a multi polygon string converting it into a FeatureCollection of
Expand All @@ -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<Polygon>;
})
} as FeatureCollection<Polygon>;

Expand All @@ -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.
Expand All @@ -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<AoIFeature[]>((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!;
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down

0 comments on commit 530acb1

Please sign in to comment.