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

Zoom in AOI when analysis is run #906

Merged
merged 8 commits into from
Apr 3, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
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 };
}
Loading