diff --git a/app/scripts/components/common/map/controls/geocoder.tsx b/app/scripts/components/common/map/controls/geocoder.tsx index 603c0931b..30b343030 100644 --- a/app/scripts/components/common/map/controls/geocoder.tsx +++ b/app/scripts/components/common/map/controls/geocoder.tsx @@ -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(); diff --git a/app/scripts/components/common/map/utils.ts b/app/scripts/components/common/map/utils.ts index 6d5a1cff2..1a542e7af 100644 --- a/app/scripts/components/common/map/utils.ts +++ b/app/scripts/components/common/map/utils.ts @@ -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, @@ -232,3 +233,19 @@ export function multiPolygonToPolygons(feature: Feature) { 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; + } +} + diff --git a/app/scripts/components/common/mapbox/map.tsx b/app/scripts/components/common/mapbox/map.tsx index a6d508f33..435afcbe5 100644 --- a/app/scripts/components/common/mapbox/map.tsx +++ b/app/scripts/components/common/mapbox/map.tsx @@ -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 ?? ''; diff --git a/app/scripts/components/exploration/atoms/timeline.ts b/app/scripts/components/exploration/atoms/timeline.ts index 0e559d424..97237f34c 100644 --- a/app/scripts/components/exploration/atoms/timeline.ts +++ b/app/scripts/components/exploration/atoms/timeline.ts @@ -12,6 +12,11 @@ export const zoomTransformAtom = atom({ k: 1 }); + +// Atom to zoom TOI when analysis is run +type ZoomTOIFunction = (newX: number, newK: number) => void; +export const onTOIZoomAtom = atom(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(undefined); diff --git a/app/scripts/components/exploration/components/map/analysis-message-control.tsx b/app/scripts/components/exploration/components/map/analysis-message-control.tsx index fe2697fa4..5933d15fe 100644 --- a/app/scripts/components/exploration/components/map/analysis-message-control.tsx +++ b/app/scripts/components/exploration/components/map/analysis-message-control.tsx @@ -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' @@ -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 ( ); } else { @@ -117,13 +158,15 @@ export function AnalysisMessage() { selectedFeatures={selectedFeatures} datasetIds={datasetIds} dateLabel={dateLabel} + analysisCallback={analysisCallback} /> ); } } export function AnalysisMessageControl() { - useThemedControl(() => , { position: 'top-left' }); + const { main } = useMaps(); + useThemedControl(() => , { position: 'top-left' }); return null; } @@ -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({ @@ -187,7 +231,7 @@ function MessagesWhileAnalyzing( - + @@ -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. @@ -262,7 +306,7 @@ function MessagesWhileNotAnalyzing(props: MessagesProps) { - + ); @@ -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 ( { - runAnalysis(datasetIds); - }} + onClick={handleClick} > Apply changes @@ -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 ( { - runAnalysis(datasetIds); - }} + onClick={handleClick} > Run analysis diff --git a/app/scripts/components/exploration/components/map/tour-control.tsx b/app/scripts/components/exploration/components/map/tour-control.tsx index 89d96a627..9cad6ae9a 100644 --- a/app/scripts/components/exploration/components/map/tour-control.tsx +++ b/app/scripts/components/exploration/components/map/tour-control.tsx @@ -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); diff --git a/app/scripts/components/exploration/components/timeline/timeline.tsx b/app/scripts/components/exploration/components/timeline/timeline.tsx index 285ff189d..d96050364 100644 --- a/app/scripts/components/exploration/components/timeline/timeline.tsx +++ b/app/scripts/components/exploration/components/timeline/timeline.tsx @@ -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, @@ -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(() => { @@ -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(); @@ -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; diff --git a/app/scripts/components/exploration/hooks/use-toi-zoom.ts b/app/scripts/components/exploration/hooks/use-toi-zoom.ts new file mode 100644 index 000000000..2181f385b --- /dev/null +++ b/app/scripts/components/exploration/hooks/use-toi-zoom.ts @@ -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, interactionRef: React.RefObject) => { + 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 }; +}