Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Basic AoIs #690

Merged
merged 10 commits into from
Oct 12, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 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,47 @@
import MapboxDraw from '@mapbox/mapbox-gl-draw';
import { useAtomValue } from 'jotai';
import { useRef } from 'react';
import { useControl } from 'react-map-gl';
import { aoisFeaturesAtom } from '$components/exploration/atoms/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;

export default function DrawControl(props: DrawControlProps) {
const control = useRef<MapboxDraw>();
const aoisFeatures = useAtomValue(aoisFeaturesAtom);

useControl<MapboxDraw>(
() => {
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 null;
}
45 changes: 45 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,45 @@
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' />;
}
}
31 changes: 30 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,33 @@ 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';
nerik marked this conversation as resolved.
Show resolved Hide resolved
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));
});
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an interesting paradigm. You have atoms that just update the main atom. Is this a normal jotai paradigm?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this normal? 😅
They are called derived atoms in Jotai. In my understanding, they extend the concept of selectors that exist in Redux or Recoil or many others, except they are not necessarily read-only (they can be write-only like here or read-write). The main idea is to use the URL hash as the single-source-of-truth, then provide a getter to get an array of AoIs, and setters to manipulate it.
I considered another alternative, which was to have a single read-write derived atom, then do various write operations in React land, but this turned out to be more concise. Happy to discuss if you wanna suggest alternatives.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just wondering about this. It is an interesting approach! :)

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 } = 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
} as any}
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!;
}
danielfdsilva marked this conversation as resolved.
Show resolved Hide resolved
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
Loading
Loading