diff --git a/backend/tests/frontend_contract.py b/backend/tests/frontend_contract.py index 565bf360..bcc9ed91 100644 --- a/backend/tests/frontend_contract.py +++ b/backend/tests/frontend_contract.py @@ -30,6 +30,8 @@ MAP_WORKSPACE_SOURCES = ( "components/map/MapWorkspace.tsx", "components/map/mapWorkspaceThemes.ts", "components/map/mapWorkspaceUtils.ts", + "hooks/useMapImageOverlays.ts", + "hooks/useMapRectangleSelection.ts", "components/map/MapExplorerView.tsx", "components/map/MapAdvancedWorkbench.tsx", ) diff --git a/backend/tests/test_sprint30_workbench_components.py b/backend/tests/test_sprint30_workbench_components.py index f9a350ee..c35e1561 100644 --- a/backend/tests/test_sprint30_workbench_components.py +++ b/backend/tests/test_sprint30_workbench_components.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from pathlib import Path @@ -58,4 +59,7 @@ def test_map_workspace_owns_map_controls_and_feature_inspector_markup() -> None: assert "Objectinspectie" in map_workspace assert "onFeatureSelect={onSelectMapFeature}" in map_workspace assert "fetch(" not in map_workspace - assert "api" not in map_workspace.lower() + # The component owns markup and interaction, never transport. A bare "api" + # substring also matches useMapImageOverlays, so name what is forbidden. + assert "services/api" not in map_workspace + assert not re.search(r"\w*[Aa]pi\.(get|post|put|delete)\(", map_workspace) diff --git a/frontend/src/components/map/MapWorkspace.tsx b/frontend/src/components/map/MapWorkspace.tsx index 904cf098..a1202d68 100644 --- a/frontend/src/components/map/MapWorkspace.tsx +++ b/frontend/src/components/map/MapWorkspace.tsx @@ -2,18 +2,15 @@ import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } import { BoxSelect, ChevronLeft, ChevronRight, MapPinned, Play, Search, SlidersHorizontal, Trash2 } from 'lucide-react' import GeoMap from '../GeoMap' import type { AreaRead, CoverageResolveResponse, CoverageStatus, DatasetCreateResponse, DetectionQaResult, MapResultExportRequest, MapViewportState, OrthophotoAcquisitionResult, OrthophotoProductRead, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionMetric, VectorSelectionResponse } from '../../types' -import { useMapThemeSelectionInsights, type MapThemeAcquisition, type MapThemeQuery } from '../../hooks/useMapThemeSelectionInsights' +import { useMapThemeSelectionInsights, type MapThemeQuery } from '../../hooks/useMapThemeSelectionInsights' import { useOfficialMapProducts } from '../../hooks/useOfficialMapProducts' import { useTemporalComparison } from '../../hooks/useTemporalComparison' +import { isValueRampRasterSource, useMapImageOverlays } from '../../hooks/useMapImageOverlays' import { getDatasetDisplayName, getDatasetSourceDisplayName } from '../../lib/datasetDisplay' import { TemporalTrendChart } from './TemporalTrendChart' import { MunicipalitySearch } from './MunicipalitySearch' import { LiveAnalysisJourney } from './LiveAnalysisJourney' import { SecondaryDisplayTarget } from '../shell/SecondaryDisplay' -import { terrainImageUrl } from '../../lib/terrainImage' -import { floodHazardImageUrl } from '../../lib/floodHazardImage' -import { thematicRasterImageUrl, walousRasterImageUrl } from '../../lib/thematicRaster' -import { bathymetryRasterImageUrl } from '../../lib/bathymetryRaster' import { FLANDERS_WORKSPACE_PROJECT_NAME } from '../../config/primaryFocus' import { MAP_ANALYSIS_BUDGET_MS, @@ -802,102 +799,16 @@ export function MapWorkspace({ themeDatasetMap, ]) - const terrainImageOverlays = useMemo( - () => - activeTheme.id === 'elevation' && selectedProjectId - ? activeThemePartitions.flatMap((dataset) => { - const bounds = dataset.source_metadata?.['bbox_epsg4326'] - return ['digitaal_vlaanderen_dhmv', 'spw_terrain'].includes(dataset.source_name ?? '') - && Array.isArray(bounds) - && bounds.length === 4 - ? [{ - url: terrainImageUrl(selectedProjectId, dataset.id), - bbox: bounds.map(Number) as [number, number, number, number], - label: getDatasetDisplayName(dataset), - opacity: 0.82, - }] - : [] - }) - : [], - [activeTheme.id, activeThemePartitions, selectedProjectId], - ) - const floodHazardImageOverlays = useMemo( - () => - activeTheme.id === 'flood_hazard' && selectedProjectId - ? activeThemePartitions.flatMap((dataset) => { - const bounds = dataset.source_metadata?.['bbox_epsg4326'] - return dataset.source_name === 'vmm_flood_hazard' - && Array.isArray(bounds) - && bounds.length === 4 - ? [{ - url: floodHazardImageUrl(selectedProjectId, dataset.id), - bbox: bounds.map(Number) as [number, number, number, number], - label: floodScenarioLabel(dataset), - opacity: 0.82, - }] - : [] - }) - : [], - [activeTheme.id, activeThemePartitions, selectedProjectId], - ) - const thematicRasterBounds = activeThemeDataset?.source_name === 'department_omgeving_thematic_raster' - ? activeThemeDataset.source_metadata?.['bbox_epsg4326'] - : null - const thematicRasterImageOverlays = useMemo( - () => activeThemeDataset?.source_name === 'department_omgeving_thematic_raster' && selectedProjectId && Array.isArray(thematicRasterBounds) && thematicRasterBounds.length === 4 - ? [{ - url: thematicRasterImageUrl(selectedProjectId, activeThemeDataset.id), - bbox: thematicRasterBounds.map(Number) as [number, number, number, number], - label: getDatasetDisplayName(activeThemeDataset), - opacity: 0.78, - }] - : [], - [activeThemeDataset, selectedProjectId, thematicRasterBounds], - ) - const walousRasterBounds = activeThemeDataset?.source_name === 'spw_walous_land_cover' - ? activeThemeDataset.source_metadata?.['bbox_epsg4326'] - : null - const walousRasterImageOverlays = useMemo( - () => activeThemeDataset?.source_name === 'spw_walous_land_cover' && selectedProjectId && Array.isArray(walousRasterBounds) && walousRasterBounds.length === 4 - ? [{ - url: walousRasterImageUrl(selectedProjectId, activeThemeDataset.id), - bbox: walousRasterBounds.map(Number) as [number, number, number, number], - label: getDatasetDisplayName(activeThemeDataset), - opacity: 0.82, - }] - : [], - [activeThemeDataset, selectedProjectId, walousRasterBounds], - ) - const bathymetryRasterBounds = activeThemeDataset?.source_name === 'spw_bathymetry' - ? activeThemeDataset.source_metadata?.['bbox_epsg4326'] - : null - const bathymetryRasterImageOverlays = useMemo( - () => activeThemeDataset?.source_name === 'spw_bathymetry' && selectedProjectId && Array.isArray(bathymetryRasterBounds) && bathymetryRasterBounds.length === 4 - ? [{ - url: bathymetryRasterImageUrl(selectedProjectId, activeThemeDataset.id), - bbox: bathymetryRasterBounds.map(Number) as [number, number, number, number], - label: 'Waterbodemhoogte in mDNG', - opacity: 0.86, - }] - : [], - [activeThemeDataset, bathymetryRasterBounds, selectedProjectId], - ) const thematicLegendMin = String(activeThemeDataset?.source_metadata?.['legend_min_label'] ?? 'Lagere waarde') const thematicLegendMax = String(activeThemeDataset?.source_metadata?.['legend_max_label'] ?? 'Hogere waarde') - const activeImageOverlays = useMemo( - () => bathymetryRasterImageOverlays.length > 0 - ? bathymetryRasterImageOverlays - : walousRasterImageOverlays.length > 0 - ? walousRasterImageOverlays - : thematicRasterImageOverlays.length > 0 - ? thematicRasterImageOverlays - : floodHazardImageOverlays.length > 0 - ? floodHazardImageOverlays - : terrainImageOverlays.length > 0 - ? terrainImageOverlays - : orthophotoImageOverlay ? [orthophotoImageOverlay] : [], - [bathymetryRasterImageOverlays, floodHazardImageOverlays, orthophotoImageOverlay, terrainImageOverlays, thematicRasterImageOverlays, walousRasterImageOverlays], - ) + const activeImageOverlays = useMapImageOverlays({ + selectedProjectId, + activeThemeId: activeTheme.id, + activeThemeDataset, + activeThemePartitions, + orthophotoImageOverlay, + floodScenarioLabel, + }) const municipalityAreaCount = areas.filter((area) => /^Gemeente\s/i.test(area.name)).length const themeTemporalSeriesMap = useMemo( () => @@ -2090,7 +2001,7 @@ export function MapWorkspace({ />
Werkgebied - {thematicRasterImageOverlays.length > 0 || walousRasterImageOverlays.length > 0 ? ( + {isValueRampRasterSource(activeThemeDataset) && activeImageOverlays.length > 0 ? ( {thematicLegendMin} → {thematicLegendMax} diff --git a/frontend/src/hooks/useMapImageOverlays.test.ts b/frontend/src/hooks/useMapImageOverlays.test.ts new file mode 100644 index 00000000..15b1636e --- /dev/null +++ b/frontend/src/hooks/useMapImageOverlays.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' + +import { buildRasterOverlays, pickActiveOverlays } from './useMapImageOverlays' +import type { DatasetCreateResponse } from '../types' + +/** + * Five near-identical useMemo blocks in MapWorkspace each filtered partitions by + * source name, read `bbox_epsg4326`, and built the same overlay shape. Collapsing + * them into one builder makes the rule testable and the differences — which + * source, which label, which opacity — visible instead of buried in repetition. + */ + +function dataset(overrides: Partial = {}): DatasetCreateResponse { + return { + id: 'dataset-1', + project_id: 'project-1', + name: 'dhmv.tif', + dataset_type: 'raster', + source: 'digitaal_vlaanderen_dhmv', + source_name: 'digitaal_vlaanderen_dhmv', + status: 'ready', + source_metadata: { bbox_epsg4326: [4.0, 51.0, 4.1, 51.1] }, + ...overrides, + } as DatasetCreateResponse +} + +describe('buildRasterOverlays', () => { + it('builds an overlay for each matching partition', () => { + const overlays = buildRasterOverlays({ + datasets: [dataset(), dataset({ id: 'dataset-2' })], + sourceNames: ['digitaal_vlaanderen_dhmv'], + opacity: 0.82, + url: (id) => `/terrain/${id}`, + label: () => 'Hoogtemodel', + }) + + expect(overlays.map((item) => item.url)).toEqual(['/terrain/dataset-1', '/terrain/dataset-2']) + expect(overlays[0].bbox).toEqual([4.0, 51.0, 4.1, 51.1]) + expect(overlays[0].opacity).toBe(0.82) + }) + + it('skips a dataset from another source', () => { + const overlays = buildRasterOverlays({ + datasets: [dataset({ source_name: 'vmm_flood_hazard' })], + sourceNames: ['digitaal_vlaanderen_dhmv'], + opacity: 0.82, + url: (id) => `/terrain/${id}`, + label: () => 'Hoogtemodel', + }) + + expect(overlays).toEqual([]) + }) + + it('skips a dataset without usable bounds rather than drawing it wrong', () => { + const cases = [ + dataset({ source_metadata: {} }), + dataset({ source_metadata: { bbox_epsg4326: [4.0, 51.0] } }), + dataset({ source_metadata: { bbox_epsg4326: 'not-an-array' } }), + ] + + for (const item of cases) { + expect( + buildRasterOverlays({ + datasets: [item], + sourceNames: ['digitaal_vlaanderen_dhmv'], + opacity: 0.82, + url: (id) => `/terrain/${id}`, + label: () => 'Hoogtemodel', + }), + ).toEqual([]) + } + }) + + it('accepts several source names for one theme', () => { + const overlays = buildRasterOverlays({ + datasets: [dataset({ source_name: 'spw_terrain' })], + sourceNames: ['digitaal_vlaanderen_dhmv', 'spw_terrain'], + opacity: 0.82, + url: (id) => `/terrain/${id}`, + label: () => 'Terrein', + }) + + expect(overlays).toHaveLength(1) + }) + + it('takes the label from the dataset so a scenario can name itself', () => { + const overlays = buildRasterOverlays({ + datasets: [dataset({ source_name: 'vmm_flood_hazard', name: 'T100' })], + sourceNames: ['vmm_flood_hazard'], + opacity: 0.82, + url: (id) => `/flood/${id}`, + label: (item) => `Scenario ${item.name}`, + }) + + expect(overlays[0].label).toBe('Scenario T100') + }) +}) + +describe('pickActiveOverlays', () => { + const overlay = (label: string) => ({ url: `/${label}`, bbox: [0, 0, 1, 1] as [number, number, number, number], label, opacity: 1 }) + + it('draws the most specific raster the theme resolved to', () => { + const active = pickActiveOverlays({ + candidates: [[], [overlay('walous')], [overlay('terrain')]], + fallback: overlay('orthophoto'), + }) + + expect(active.map((item) => item.label)).toEqual(['walous']) + }) + + it('falls back to the orthophoto when no raster theme is active', () => { + const active = pickActiveOverlays({ candidates: [[], []], fallback: overlay('orthophoto') }) + + expect(active.map((item) => item.label)).toEqual(['orthophoto']) + }) + + it('draws nothing when there is neither a raster nor an orthophoto', () => { + expect(pickActiveOverlays({ candidates: [[], []], fallback: null })).toEqual([]) + }) +}) diff --git a/frontend/src/hooks/useMapImageOverlays.ts b/frontend/src/hooks/useMapImageOverlays.ts new file mode 100644 index 00000000..4326002d --- /dev/null +++ b/frontend/src/hooks/useMapImageOverlays.ts @@ -0,0 +1,157 @@ +import { useMemo } from 'react' + +import type { DatasetCreateResponse } from '../types' +import { getDatasetDisplayName } from '../lib/datasetDisplay' +import { terrainImageUrl } from '../lib/terrainImage' +import { floodHazardImageUrl } from '../lib/floodHazardImage' +import { thematicRasterImageUrl, walousRasterImageUrl } from '../lib/thematicRaster' +import { bathymetryRasterImageUrl } from '../lib/bathymetryRaster' + +/** + * Which raster image the map draws underneath the active theme. + * + * MapWorkspace held five near-identical useMemo blocks for this — terrain, + * flood depth, thematic raster, WALOUS land cover and bathymetry — each + * filtering partitions by source name, reading `bbox_epsg4326` and assembling + * the same overlay shape. One builder makes the rule testable and leaves only + * the parts that genuinely differ per theme: the source, the label and the + * opacity. + */ + +export interface MapImageOverlay { + url: string + bbox: [number, number, number, number] + label: string + opacity: number +} + +interface RasterOverlayRequest { + datasets: DatasetCreateResponse[] + sourceNames: string[] + opacity: number + url: (datasetId: string) => string + label: (dataset: DatasetCreateResponse) => string +} + +/** A bbox is only usable if the source actually published four numbers for it. */ +function overlayBounds(dataset: DatasetCreateResponse): [number, number, number, number] | null { + const bounds = dataset.source_metadata?.['bbox_epsg4326'] + if (!Array.isArray(bounds) || bounds.length !== 4) return null + const numbers = bounds.map(Number) + return numbers.some((value) => !Number.isFinite(value)) + ? null + : (numbers as [number, number, number, number]) +} + +export function buildRasterOverlays({ + datasets, + sourceNames, + opacity, + url, + label, +}: RasterOverlayRequest): MapImageOverlay[] { + return datasets.flatMap((dataset) => { + if (!sourceNames.includes(dataset.source_name ?? '')) return [] + const bbox = overlayBounds(dataset) + // A raster we cannot place is not drawn at all: an overlay in the wrong + // spot is worse than no overlay. + if (!bbox) return [] + return [{ url: url(dataset.id), bbox, label: label(dataset), opacity }] + }) +} + +/** The first non-empty raster wins; the orthophoto is the base layer. */ +export function pickActiveOverlays({ + candidates, + fallback, +}: { + candidates: MapImageOverlay[][] + fallback: MapImageOverlay | null +}): MapImageOverlay[] { + const active = candidates.find((item) => item.length > 0) + if (active) return active + return fallback ? [fallback] : [] +} + +/** Sources drawn as a continuous value ramp, which is what the legend explains. */ +const VALUE_RAMP_SOURCES = ['department_omgeving_thematic_raster', 'spw_walous_land_cover'] + +export function isValueRampRasterSource(dataset: DatasetCreateResponse | null | undefined): boolean { + return VALUE_RAMP_SOURCES.includes(dataset?.source_name ?? '') +} + +interface MapImageOverlayOptions { + selectedProjectId: string | null + activeThemeId: string + activeThemeDataset: DatasetCreateResponse | null | undefined + activeThemePartitions: DatasetCreateResponse[] + orthophotoImageOverlay: MapImageOverlay | null + floodScenarioLabel: (dataset: DatasetCreateResponse) => string +} + +export function useMapImageOverlays({ + selectedProjectId, + activeThemeId, + activeThemeDataset, + activeThemePartitions, + orthophotoImageOverlay, + floodScenarioLabel, +}: MapImageOverlayOptions): MapImageOverlay[] { + return useMemo(() => { + if (!selectedProjectId) return orthophotoImageOverlay ? [orthophotoImageOverlay] : [] + + const singleDataset = activeThemeDataset ? [activeThemeDataset] : [] + // Ordered most specific first: a theme that resolved to a dedicated raster + // product should not be drawn as generic terrain underneath it. + const candidates = [ + buildRasterOverlays({ + datasets: singleDataset, + sourceNames: ['spw_bathymetry'], + opacity: 0.86, + url: (id) => bathymetryRasterImageUrl(selectedProjectId, id), + label: () => 'Waterbodemhoogte in mDNG', + }), + buildRasterOverlays({ + datasets: singleDataset, + sourceNames: ['spw_walous_land_cover'], + opacity: 0.82, + url: (id) => walousRasterImageUrl(selectedProjectId, id), + label: getDatasetDisplayName, + }), + buildRasterOverlays({ + datasets: singleDataset, + sourceNames: ['department_omgeving_thematic_raster'], + opacity: 0.78, + url: (id) => thematicRasterImageUrl(selectedProjectId, id), + label: getDatasetDisplayName, + }), + activeThemeId === 'flood_hazard' + ? buildRasterOverlays({ + datasets: activeThemePartitions, + sourceNames: ['vmm_flood_hazard'], + opacity: 0.82, + url: (id) => floodHazardImageUrl(selectedProjectId, id), + label: floodScenarioLabel, + }) + : [], + activeThemeId === 'elevation' + ? buildRasterOverlays({ + datasets: activeThemePartitions, + sourceNames: ['digitaal_vlaanderen_dhmv', 'spw_terrain'], + opacity: 0.82, + url: (id) => terrainImageUrl(selectedProjectId, id), + label: getDatasetDisplayName, + }) + : [], + ] + + return pickActiveOverlays({ candidates, fallback: orthophotoImageOverlay }) + }, [ + activeThemeDataset, + activeThemeId, + activeThemePartitions, + floodScenarioLabel, + orthophotoImageOverlay, + selectedProjectId, + ]) +} diff --git a/frontend/src/hooks/useMapRectangleSelection.test.tsx b/frontend/src/hooks/useMapRectangleSelection.test.tsx new file mode 100644 index 00000000..26af52c9 --- /dev/null +++ b/frontend/src/hooks/useMapRectangleSelection.test.tsx @@ -0,0 +1,118 @@ +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import { useMapRectangleSelection } from './useMapRectangleSelection' +import type { VectorSelectionBBox } from '../types' + +/** + * The rule under test is not the geometry but the clearing: results from the + * previous rectangle must be retired before a new one is drawn, or an operator + * reads the old area's numbers as belonging to the new selection. + */ + +const BBOX: VectorSelectionBBox = { min_x: 5.0, min_y: 51.1, max_x: 5.2, max_y: 51.3, crs: 'EPSG:4326' } + +function setup() { + const setSelectionBbox = vi.fn() + const retireStaleResults = vi.fn() + const view = renderHook(() => + useMapRectangleSelection({ initialBbox: null, setSelectionBbox, retireStaleResults }), + ) + return { view, setSelectionBbox, retireStaleResults } +} + +describe('two-click rectangle', () => { + it('records the first corner without touching the results on screen', () => { + const { view, setSelectionBbox, retireStaleResults } = setup() + + act(() => view.result.current.handleCoordinateSelect([5.0, 51.1])) + + expect(view.result.current.firstCorner).toEqual([5.0, 51.1]) + expect(setSelectionBbox).not.toHaveBeenCalled() + expect(retireStaleResults).not.toHaveBeenCalled() + }) + + it('commits on the second corner and retires the previous results first', () => { + const { view, setSelectionBbox, retireStaleResults } = setup() + + act(() => view.result.current.handleCoordinateSelect([5.2, 51.3])) + act(() => view.result.current.handleCoordinateSelect([5.0, 51.1])) + + expect(retireStaleResults).toHaveBeenCalledTimes(1) + expect(setSelectionBbox).toHaveBeenCalledTimes(1) + const committed = setSelectionBbox.mock.calls[0][0] + expect(committed.min_x).toBeCloseTo(5.0) + expect(committed.max_y).toBeCloseTo(51.3) + }) + + it('leaves no half-finished gesture behind', () => { + const { view } = setup() + + act(() => view.result.current.handleCoordinateSelect([5.2, 51.3])) + act(() => view.result.current.handleCoordinateSelect([5.0, 51.1])) + + expect(view.result.current.firstCorner).toBeNull() + expect(view.result.current.drawing).toBe(false) + }) +}) + +describe('drag rectangle', () => { + it('moves the rectangle during the drag without retiring anything', () => { + const { view, setSelectionBbox, retireStaleResults } = setup() + + act(() => view.result.current.handleBboxPreview(BBOX)) + + expect(setSelectionBbox).toHaveBeenCalledWith(BBOX) + expect(retireStaleResults).not.toHaveBeenCalled() + }) + + it('retires the previous results when the drag is released', () => { + const { view, setSelectionBbox, retireStaleResults } = setup() + + act(() => view.result.current.handleBboxSelect(BBOX)) + + expect(retireStaleResults).toHaveBeenCalledTimes(1) + expect(setSelectionBbox).toHaveBeenCalledWith(BBOX) + }) + + it('ends the drawing mode on release', () => { + const { view } = setup() + + act(() => view.result.current.setDrawing(true)) + act(() => view.result.current.handleBboxSelect(BBOX)) + + expect(view.result.current.drawing).toBe(false) + }) +}) + +describe('typed coordinates', () => { + it('parses a complete rectangle from the coordinate fields', () => { + const { view } = setup() + + act(() => + view.result.current.setBboxInput({ min_x: '5.0', min_y: '51.1', max_x: '5.2', max_y: '51.3' }), + ) + + expect(view.result.current.typedBbox).toMatchObject({ min_x: 5.0, max_y: 51.3 }) + }) + + it('reports no rectangle while the fields are incomplete', () => { + const { view } = setup() + + act(() => view.result.current.setBboxInput({ min_x: '5.0', min_y: '', max_x: '', max_y: '' })) + + expect(view.result.current.typedBbox).toBeNull() + }) +}) + +describe('reset', () => { + it('abandons a half-drawn rectangle without committing it', () => { + const { view, setSelectionBbox } = setup() + + act(() => view.result.current.handleCoordinateSelect([5.0, 51.1])) + act(() => view.result.current.reset()) + + expect(view.result.current.firstCorner).toBeNull() + expect(setSelectionBbox).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/hooks/useMapRectangleSelection.ts b/frontend/src/hooks/useMapRectangleSelection.ts new file mode 100644 index 00000000..9659ea7e --- /dev/null +++ b/frontend/src/hooks/useMapRectangleSelection.ts @@ -0,0 +1,94 @@ +import { useCallback, useState } from 'react' + +import type { VectorSelectionBBox } from '../types' +import { bboxToInputState, normalizeBboxFromCorners, parseBboxInput } from '../components/map/mapWorkspaceUtils' + +/** + * Drawing a rectangle on the map, by drag or by two clicks. + * + * The rule that matters here is not the geometry but the clearing: a new + * selection must retire the previous theme insights and temporal comparison + * before anything is drawn, so results from the old rectangle can never be read + * as belonging to the new one. + */ + +interface MapRectangleSelectionOptions { + initialBbox: VectorSelectionBBox | null + setSelectionBbox: (bbox: VectorSelectionBBox) => void + /** Everything that describes the *previous* rectangle and must not survive. */ + retireStaleResults: () => void +} + +export interface MapRectangleSelection { + drawing: boolean + setDrawing: (value: boolean) => void + firstCorner: [number, number] | null + bboxInput: ReturnType + setBboxInput: (value: ReturnType) => void + /** The rectangle currently typed into the coordinate fields, if it parses. */ + typedBbox: VectorSelectionBBox | null + handleCoordinateSelect: (coordinate: [number, number]) => void + handleBboxPreview: (bbox: VectorSelectionBBox) => void + handleBboxSelect: (bbox: VectorSelectionBBox) => void + reset: () => void +} + +export function useMapRectangleSelection({ + initialBbox, + setSelectionBbox, + retireStaleResults, +}: MapRectangleSelectionOptions): MapRectangleSelection { + const [drawing, setDrawing] = useState(false) + const [firstCorner, setFirstCorner] = useState<[number, number] | null>(null) + const [bboxInput, setBboxInput] = useState(bboxToInputState(initialBbox)) + + const commit = useCallback( + (bbox: VectorSelectionBBox) => { + setFirstCorner(null) + setDrawing(false) + retireStaleResults() + setSelectionBbox(bbox) + }, + [retireStaleResults, setSelectionBbox], + ) + + const handleCoordinateSelect = useCallback( + (coordinate: [number, number]) => { + // Two clicks make a rectangle: the first only records a corner, and must + // not disturb the results that are still on screen. + if (!firstCorner) { + setFirstCorner(coordinate) + return + } + commit(normalizeBboxFromCorners(firstCorner, coordinate)) + }, + [commit, firstCorner], + ) + + // A drag preview moves the rectangle without ending the gesture, so nothing + // is retired until the drag is released. + const handleBboxPreview = useCallback( + (bbox: VectorSelectionBBox) => setSelectionBbox(bbox), + [setSelectionBbox], + ) + + const handleBboxSelect = useCallback((bbox: VectorSelectionBBox) => commit(bbox), [commit]) + + const reset = useCallback(() => { + setFirstCorner(null) + setDrawing(false) + }, []) + + return { + drawing, + setDrawing, + firstCorner, + bboxInput, + setBboxInput, + typedBbox: parseBboxInput(bboxInput), + handleCoordinateSelect, + handleBboxPreview, + handleBboxSelect, + reset, + } +}