Skip to content

Commit

Permalink
Zoom in AOI when analysis is run (#906)
Browse files Browse the repository at this point in the history
**Related Ticket:** #857 
### Description of Changes
Zoom aoi when analysis gets run (note that this PR only handles AOI, not
TOI)

### Notes & Questions About Changes

Mapbox has fitBonds problem with some projections:
mapbox/mapbox-gl-js#12565 Because of this
issue, we have to do manual centering which might not look very smooth.
  • Loading branch information
hanbyul-here authored Apr 3, 2024
2 parents 694a665 + 86bcb09 commit f9b4105
Show file tree
Hide file tree
Showing 8 changed files with 140 additions and 41 deletions.
18 changes: 2 additions & 16 deletions app/scripts/components/common/map/controls/geocoder.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,11 @@
import { useCallback } from 'react';
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';

import { useControl } from 'react-map-gl';

export function getZoomFromBbox(bbox: [number, number, number, number]): number {
const latMax = Math.max(bbox[3], bbox[1]);
const lngMax = Math.max(bbox[2], bbox[0]);
const latMin = Math.min(bbox[3], bbox[1]);
const lngMin = Math.min(bbox[2], bbox[0]);
const maxDiff = Math.max(latMax - latMin, lngMax - lngMin);
if (maxDiff < 360 / Math.pow(2, 20)) {
return 21;
} else {
const zoomLevel = Math.floor(-1*( (Math.log(maxDiff)/Math.log(2)) - (Math.log(360)/Math.log(2))));
if (zoomLevel < 1) return 1;
else return zoomLevel;
}
}
import { useControl } from 'react-map-gl';
import { getZoomFromBbox } from '$components/common/map/utils';

export default function GeocoderControl() {

const handleGeocoderResult = useCallback((map, geocoder) => ({ result }) => {
geocoder.clear();
geocoder._inputEl.blur();
Expand Down
17 changes: 17 additions & 0 deletions app/scripts/components/common/map/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Map as MapboxMap } from 'mapbox-gl';
import { MapRef } from 'react-map-gl';
import { endOfDay, startOfDay } from 'date-fns';
import { Feature, MultiPolygon, Polygon } from 'geojson';
import { BBox } from "@turf/helpers";
import {
DatasetDatumFn,
DatasetDatumFnResolverBag,
Expand Down Expand Up @@ -232,3 +233,19 @@ export function multiPolygonToPolygons(feature: Feature<MultiPolygon>) {

return polygons;
}

export function getZoomFromBbox(bbox: BBox): number {
const latMax = Math.max(bbox[3], bbox[1]);
const lngMax = Math.max(bbox[2], bbox[0]);
const latMin = Math.min(bbox[3], bbox[1]);
const lngMin = Math.min(bbox[2], bbox[0]);
const maxDiff = Math.max(latMax - latMin, lngMax - lngMin);
if (maxDiff < 360 / Math.pow(2, 20)) {
return 21;
} else {
const zoomLevel = Math.floor(-1*( (Math.log(maxDiff)/Math.log(2)) - (Math.log(360)/Math.log(2))));
if (zoomLevel < 1) return 1;
else return zoomLevel;
}
}

2 changes: 1 addition & 1 deletion app/scripts/components/common/mapbox/map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import MapCoords from './map-coords';

import { useMapStyle } from './layers/styles';
import { BasemapId, Option } from './map-options/basemaps';
import { getZoomFromBbox } from '$components/common/map/controls/geocoder';
import { getZoomFromBbox } from '$components/common/map/utils';
import { round } from '$utils/format';

mapboxgl.accessToken = process.env.MAPBOX_TOKEN ?? '';
Expand Down
5 changes: 5 additions & 0 deletions app/scripts/components/exploration/atoms/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ export const zoomTransformAtom = atom<ZoomTransformPlain>({
k: 1
});


// Atom to zoom TOI when analysis is run
type ZoomTOIFunction = (newX: number, newK: number) => void;
export const onTOIZoomAtom = atom<ZoomTOIFunction | null>(null);

// Width of the whole timeline item. Set via a size observer and then used to
// compute the different element sizes.
export const timelineWidthAtom = atom<number | undefined>(undefined);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,33 @@
import React, { useEffect } from 'react';
import React, { useCallback, useEffect } from 'react';
import { useAtomValue } from 'jotai';
import styled, { css } from 'styled-components';
import { MapRef } from 'react-map-gl';
import { glsp, themeVal } from '@devseed-ui/theme-provider';
import { Button, createButtonStyles } from '@devseed-ui/button';
import bbox from '@turf/bbox';
import {
CollecticonChartLine,
CollecticonCircleInformation
} from '@devseed-ui/collecticons';

import { timelineDatasetsAtom } from '../../atoms/datasets';
import { selectedIntervalAtom } from '../../atoms/dates';

import { useScales } from '../../hooks/scales-hooks';
import useMaps from '$components/common/map/hooks/use-maps';
import { useOnTOIZoom } from '$components/exploration/hooks/use-toi-zoom';
import {
timelineWidthAtom
} from '$components/exploration/atoms/timeline';

import useAois from '$components/common/map/controls/hooks/use-aois';
import { calcFeatCollArea } from '$components/common/aoi/utils';
import { formatDateRange } from '$utils/date';
import { useAnalysisController } from '$components/exploration/hooks/use-analysis-data-request';
import useThemedControl from '$components/common/map/controls/hooks/use-themed-control';
import { getZoomFromBbox } from '$components/common/map/utils';
import { AoIFeature } from '$components/common/map/types';
import { ShortcutCode } from '$styles/shortcut-code';
import { RIGHT_AXIS_SPACE, HEADER_COLUMN_WIDTH } from '$components/exploration/constants';

const AnalysisMessageWrapper = styled.div.attrs({
'data-tour': 'analysis-message'
Expand Down Expand Up @@ -81,25 +91,55 @@ const MessageControls = styled.div`
gap: ${glsp(0.5)};
`;

export function AnalysisMessage() {
export function AnalysisMessage({ mainMap }: { mainMap: MapRef | undefined }) {
const { isObsolete, setObsolete, isAnalyzing } = useAnalysisController();

const datasets = useAtomValue(timelineDatasetsAtom);
const datasetIds = datasets.map((d) => d.data.id);

const timelineWidth = useAtomValue(timelineWidthAtom);
const { main } = useScales();
const { onTOIZoom } = useOnTOIZoom();

const { features } = useAois();
const selectedInterval = useAtomValue(selectedIntervalAtom);

const dateLabel =
selectedInterval &&
formatDateRange(selectedInterval.start, selectedInterval.end);

const selectedFeatures = features.filter((f) => f.selected);

useEffect(() => {
// Set the analysis as obsolete when the selected features change.
setObsolete();
}, [setObsolete, features]);



const analysisCallback = useCallback(() => {
// Fit AOI
const bboxToFit = bbox({
type: 'FeatureCollection',
features: selectedFeatures
});
const zoom = bboxToFit? getZoomFromBbox(bboxToFit): 14;
mainMap?.flyTo({
center:[ (bboxToFit[2] + bboxToFit[0])/2, (bboxToFit[3] + bboxToFit[1])/2],
zoom
});

// Fit TOI
if (!main || !timelineWidth || !selectedInterval?.start ) return;

const widthToFit = (timelineWidth - RIGHT_AXIS_SPACE - HEADER_COLUMN_WIDTH) * 0.9;
const startPoint = 0;
const new_k = widthToFit/(main(selectedInterval.end) - main(selectedInterval.start));
const new_x = startPoint - new_k * main(selectedInterval.start);

onTOIZoom(new_x, new_k);

}, [selectedFeatures, mainMap, main, timelineWidth, onTOIZoom, selectedInterval]);

if (isAnalyzing) {
return (
<MessagesWhileAnalyzing
Expand All @@ -108,6 +148,7 @@ export function AnalysisMessage() {
selectedFeatures={selectedFeatures}
datasetIds={datasetIds}
dateLabel={dateLabel}
analysisCallback={analysisCallback}
/>
);
} else {
Expand All @@ -117,13 +158,15 @@ export function AnalysisMessage() {
selectedFeatures={selectedFeatures}
datasetIds={datasetIds}
dateLabel={dateLabel}
analysisCallback={analysisCallback}
/>
);
}
}

export function AnalysisMessageControl() {
useThemedControl(() => <AnalysisMessage />, { position: 'top-left' });
const { main } = useMaps();
useThemedControl(() => <AnalysisMessage mainMap={main} />, { position: 'top-left' });

return null;
}
Expand All @@ -134,12 +177,13 @@ interface MessagesProps {
selectedFeatures: AoIFeature[];
datasetIds: string[];
dateLabel: string | null;
analysisCallback: () => void;
}

function MessagesWhileAnalyzing(
props: MessagesProps & { isObsolete: boolean }
) {
const { isObsolete, features, selectedFeatures, datasetIds, dateLabel } =
const { isObsolete, features, selectedFeatures, datasetIds, dateLabel, analysisCallback } =
props;

const area = calcFeatCollArea({
Expand Down Expand Up @@ -187,7 +231,7 @@ function MessagesWhileAnalyzing(
</MessageContent>
</AnalysisMessageInner>
<MessageControls>
<ButtonObsolete datasetIds={datasetIds} />
<ButtonObsolete datasetIds={datasetIds} analysisCallback={analysisCallback} />
<ButtonExit />
</MessageControls>
</AnalysisMessageWrapper>
Expand Down Expand Up @@ -233,7 +277,7 @@ function MessagesWhileAnalyzing(
}

function MessagesWhileNotAnalyzing(props: MessagesProps) {
const { features, selectedFeatures, datasetIds, dateLabel } = props;
const { features, selectedFeatures, datasetIds, dateLabel, analysisCallback } = props;

if (selectedFeatures.length) {
// Not analyzing, but there are selected features.
Expand Down Expand Up @@ -262,7 +306,7 @@ function MessagesWhileNotAnalyzing(props: MessagesProps) {
</MessageContent>
</AnalysisMessageInner>
<MessageControls>
<ButtonAnalyze datasetIds={datasetIds} />
<ButtonAnalyze analysisCallback={analysisCallback} datasetIds={datasetIds} />
</MessageControls>
</AnalysisMessageWrapper>
);
Expand Down Expand Up @@ -327,17 +371,20 @@ const Btn = styled(Button)`
}
`;

function ButtonObsolete(props: { datasetIds: string[] }) {
const { datasetIds } = props;
function ButtonObsolete(props: { datasetIds: string[], analysisCallback: () => void }) {
const { datasetIds, analysisCallback } = props;
const { runAnalysis } = useAnalysisController();

const handleClick = useCallback(() => {
runAnalysis(datasetIds);
analysisCallback();
},[datasetIds, analysisCallback, runAnalysis]);

return (
<Btn
variation='primary-fill'
size='small'
onClick={() => {
runAnalysis(datasetIds);
}}
onClick={handleClick}
>
Apply changes
</Btn>
Expand All @@ -359,17 +406,20 @@ function ButtonExit() {
);
}

function ButtonAnalyze(props: { datasetIds: string[] }) {
const { datasetIds } = props;
function ButtonAnalyze(props: { datasetIds: string[], analysisCallback: () => void }) {
const { datasetIds, analysisCallback } = props;
const { runAnalysis } = useAnalysisController();

const handleClick = useCallback(() => {
runAnalysis(datasetIds);
analysisCallback();
},[datasetIds, runAnalysis, analysisCallback]);

return (
<Btn
variation='primary-fill'
size='small'
onClick={() => {
runAnalysis(datasetIds);
}}
onClick={handleClick}
>
Run analysis
</Btn>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ export function ShowTourControl() {
const { setIsOpen, setCurrentStep, setSteps } = useTour();
const datasets = useAtomValue(timelineDatasetsAtom);
const disabled = datasets.length === 0;

const reopenTour = useCallback(() => {
setCurrentStep(0);
setSteps?.(introTourSteps);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
useScaleFactors,
useScales
} from '$components/exploration/hooks/scales-hooks';
import { useOnTOIZoom } from '$components/exploration/hooks/use-toi-zoom';
import {
TimelineDatasetStatus,
TimelineDatasetSuccess,
Expand Down Expand Up @@ -200,7 +201,7 @@ export default function Timeline(props: TimelineProps) {
const [selectedInterval, setSelectedInterval] = useAtom(selectedIntervalAtom);

const { setObsolete, runAnalysis, isAnalyzing } = useAnalysisController();

const [zoomTransform, setZoomTransform] = useAtom(zoomTransformAtom);
const { features } = useAois();

useEffect(() => {
Expand All @@ -216,8 +217,6 @@ export default function Timeline(props: TimelineProps) {
[width]
);

const [zoomTransform, setZoomTransform] = useAtom(zoomTransformAtom);

// Calculate min and max scale factors, such has each day has a minimum of 2px
// and a maximum of 100px.
const { k0, k1 } = useScaleFactors();
Expand Down Expand Up @@ -371,7 +370,17 @@ export default function Timeline(props: TimelineProps) {
applyTransform(zoomBehavior, select(interactionRef.current), 0, 0, k0);
}, [prevSuccessDatasetsCount, successDatasetsCount, k0, zoomBehavior]);

const onControlsZoom = useCallback(
const { initializeTOIZoom } = useOnTOIZoom();

useEffect(() => {
// Set TOIZoom functionality in atom so it can be used in analysis component
// Ensure zoomBehavior and interactionRef are defined before initializing
if (zoomBehavior && interactionRef.current) {
initializeTOIZoom(zoomBehavior, interactionRef);
}
}, [initializeTOIZoom, zoomBehavior, interactionRef]);

const onControlsZoom = useCallback(
(zoomV) => {
if (!interactionRef.current || !xMain || !xScaled || !selectedDay) return;

Expand Down
33 changes: 33 additions & 0 deletions app/scripts/components/exploration/hooks/use-toi-zoom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { useCallback } from 'react';
import { useAtom } from 'jotai';
import { select, ZoomBehavior } from 'd3';
import { onTOIZoomAtom } from '../atoms/timeline';
import { applyTransform } from '$components/exploration/components/timeline/timeline-utils';

export function useOnTOIZoom() {
const [onTOIZoom, setOnTOIZoom] = useAtom(onTOIZoomAtom);

const initialize = useCallback((zoomBehavior: ZoomBehavior<Element, unknown>, interactionRef: React.RefObject<HTMLElement>) => {
setOnTOIZoom(() => (newX: number, newK: number) => {
if (!newX || !newK) return;
const { current: interactionElement } = interactionRef;
if (!interactionElement) return;

applyTransform(
zoomBehavior,
select(interactionElement),
newX,
0,
newK
);
});
}, [setOnTOIZoom]);

const safeOnTOIZoom = (newX: number, newK: number) => {
if (onTOIZoom) {
onTOIZoom(newX, newK);
}
};

return { initializeTOIZoom: initialize, onTOIZoom: safeOnTOIZoom };
}

0 comments on commit f9b4105

Please sign in to comment.