diff --git a/frontend/src/components/map/MapWorkspace.tsx b/frontend/src/components/map/MapWorkspace.tsx index a1202d68..b1db9cac 100644 --- a/frontend/src/components/map/MapWorkspace.tsx +++ b/frontend/src/components/map/MapWorkspace.tsx @@ -6,6 +6,7 @@ import { useMapThemeSelectionInsights, type MapThemeQuery } from '../../hooks/us import { useOfficialMapProducts } from '../../hooks/useOfficialMapProducts' import { useTemporalComparison } from '../../hooks/useTemporalComparison' import { isValueRampRasterSource, useMapImageOverlays } from '../../hooks/useMapImageOverlays' +import { useMapRectangleSelection } from '../../hooks/useMapRectangleSelection' import { getDatasetDisplayName, getDatasetSourceDisplayName } from '../../lib/datasetDisplay' import { TemporalTrendChart } from './TemporalTrendChart' import { MunicipalitySearch } from './MunicipalitySearch' @@ -18,7 +19,6 @@ import { formatPerformanceDuration, } from '../../lib/performanceBudget' import { - bboxToInputState, bboxesEqual, copyText, datasetIntersectsSelection, @@ -31,7 +31,6 @@ import { getFeatureCollectionBBox, getFeatureGeometrySummary, isMunicipalityAreaName, - normalizeBboxFromCorners, operationalScopeProjectLabel, parseBboxInput, persistedDatasetSupportsSelection, @@ -318,9 +317,13 @@ export function MapWorkspace({ const [selectedTemporalSeriesKey, setSelectedTemporalSeriesKey] = useState('') const [earlierDatasetId, setEarlierDatasetId] = useState('') const [laterDatasetId, setLaterDatasetId] = useState('') - const [bboxSelectionMode, setBboxSelectionMode] = useState(false) - const [firstSelectionCorner, setFirstSelectionCorner] = useState<[number, number] | null>(null) - const [bboxInput, setBboxInput] = useState(bboxToInputState(mapSelectionBbox)) + const rectangle = useMapRectangleSelection(mapSelectionBbox) + const { + drawing: bboxSelectionMode, + setDrawing: setBboxSelectionMode, + bboxInput, + setBboxInput, + } = rectangle const [fullWorkflowRunning, setFullWorkflowRunning] = useState(false) const [fullWorkflowStatus, setFullWorkflowStatus] = useState('Klaar om de volledige GIS-werkstroom uit te voeren.') const [fullWorkflowError, setFullWorkflowError] = useState(null) @@ -970,7 +973,7 @@ export function MapWorkspace({ }, [activeSelectionResult]) useEffect(() => { - setBboxInput(bboxToInputState(mapSelectionBbox)) + rectangle.showBbox((mapSelectionBbox)) }, [mapSelectionBbox]) useEffect(() => { @@ -1059,7 +1062,7 @@ export function MapWorkspace({ const setSelectionBbox = (bbox: VectorSelectionBBox | null) => { onSetMapSelectionBbox(bbox) - setBboxInput(bboxToInputState(bbox)) + rectangle.showBbox((bbox)) } const areaIdForSelection = (bbox: VectorSelectionBBox | null): string | undefined => ( @@ -1067,7 +1070,7 @@ export function MapWorkspace({ ) const startBboxSelection = () => { - setFirstSelectionCorner(null) + rectangle.reset() clearThemeInsights() clearTemporalComparison() setResultsPanelOpen(false) @@ -1075,14 +1078,10 @@ export function MapWorkspace({ } const handleMapCoordinateSelect = (coordinate: [number, number]) => { - if (!firstSelectionCorner) { - setFirstSelectionCorner(coordinate) - return - } - const bbox = normalizeBboxFromCorners(firstSelectionCorner, coordinate) + // The first click only places a corner; there is no rectangle to act on yet. + const bbox = rectangle.placeCorner(coordinate) + if (!bbox) return setSelectionBbox(bbox) - setFirstSelectionCorner(null) - setBboxSelectionMode(false) clearThemeInsights() clearTemporalComparison() setResultsPanelOpen(false) @@ -1099,9 +1098,8 @@ export function MapWorkspace({ const clearAreaSelection = () => { mapAnalysisRequestSequence.current += 1 setMapAnalysisDurationMs(null) - setBboxSelectionMode(false) - setFirstSelectionCorner(null) - setBboxInput(bboxToInputState(null)) + rectangle.reset() + rectangle.showBbox((null)) setResultsPanelOpen(false) clearThemeInsights() clearTemporalComparison() @@ -1449,8 +1447,7 @@ export function MapWorkspace({ } const handleMapBboxSelect = (bbox: VectorSelectionBBox) => { - setFirstSelectionCorner(null) - setBboxSelectionMode(false) + rectangle.reset() clearThemeInsights() clearTemporalComparison() setResultsPanelOpen(false) @@ -2930,7 +2927,7 @@ export function MapWorkspace({
- {bboxSelectionMode ? (firstSelectionCorner ? 'Klik de tegenoverliggende hoek' : 'Klik de eerste hoek op de kaart') : 'Begrenzing EPSG:4326'} + {bboxSelectionMode ? (rectangle.firstCorner ? 'Klik de tegenoverliggende hoek' : 'Klik de eerste hoek op de kaart') : 'Begrenzing EPSG:4326'} {formatBboxLabel(currentSelectionBbox)}
diff --git a/frontend/src/hooks/useMapRectangleSelection.test.tsx b/frontend/src/hooks/useMapRectangleSelection.test.tsx index 26af52c9..141259e0 100644 --- a/frontend/src/hooks/useMapRectangleSelection.test.tsx +++ b/frontend/src/hooks/useMapRectangleSelection.test.tsx @@ -1,93 +1,84 @@ import { act, renderHook } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } 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. + * Corner tracking lived inline in a 3.300-line component and was never + * exercised. The rule worth pinning is that a first corner produces no + * rectangle at all: acting on it would analyse a zero-width area. */ 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 } +function setup(initial: VectorSelectionBBox | null = null) { + return renderHook(() => useMapRectangleSelection(initial)) } describe('two-click rectangle', () => { - it('records the first corner without touching the results on screen', () => { - const { view, setSelectionBbox, retireStaleResults } = setup() + it('yields no rectangle from the first corner', () => { + const view = setup() - act(() => view.result.current.handleCoordinateSelect([5.0, 51.1])) + let result: VectorSelectionBBox | null = BBOX + act(() => { + result = view.result.current.placeCorner([5.0, 51.1]) + }) + expect(result).toBeNull() 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() + it('yields the normalised rectangle from the second corner', () => { + const view = setup() - act(() => view.result.current.handleCoordinateSelect([5.2, 51.3])) - act(() => view.result.current.handleCoordinateSelect([5.0, 51.1])) + act(() => void view.result.current.placeCorner([5.2, 51.3])) + let result: VectorSelectionBBox | null = null + act(() => { + result = view.result.current.placeCorner([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) + expect(result).not.toBeNull() + expect(result!.min_x).toBeCloseTo(5.0) + expect(result!.min_y).toBeCloseTo(51.1) + expect(result!.max_x).toBeCloseTo(5.2) + expect(result!.max_y).toBeCloseTo(51.3) }) it('leaves no half-finished gesture behind', () => { - const { view } = setup() + const view = setup() - act(() => view.result.current.handleCoordinateSelect([5.2, 51.3])) - act(() => view.result.current.handleCoordinateSelect([5.0, 51.1])) + act(() => void view.result.current.setDrawing(true)) + act(() => void view.result.current.placeCorner([5.2, 51.3])) + act(() => void view.result.current.placeCorner([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() + it('normalises corners placed in any order', () => { + const view = setup() - act(() => view.result.current.handleBboxPreview(BBOX)) + act(() => void view.result.current.placeCorner([5.0, 51.3])) + let result: VectorSelectionBBox | null = null + act(() => { + result = view.result.current.placeCorner([5.2, 51.1]) + }) - 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) + expect(result!.min_x).toBeLessThan(result!.max_x) + expect(result!.min_y).toBeLessThan(result!.max_y) }) }) -describe('typed coordinates', () => { - it('parses a complete rectangle from the coordinate fields', () => { - const { view } = setup() +describe('coordinate fields', () => { + it('starts from the rectangle already on the map', () => { + const view = setup(BBOX) + + expect(view.result.current.typedBbox).toMatchObject({ min_x: 5.0, max_y: 51.3 }) + }) + + it('parses a complete rectangle', () => { + const view = setup() act(() => view.result.current.setBboxInput({ min_x: '5.0', min_y: '51.1', max_x: '5.2', max_y: '51.3' }), @@ -97,22 +88,46 @@ describe('typed coordinates', () => { }) it('reports no rectangle while the fields are incomplete', () => { - const { view } = setup() + const view = setup() act(() => view.result.current.setBboxInput({ min_x: '5.0', min_y: '', max_x: '', max_y: '' })) expect(view.result.current.typedBbox).toBeNull() }) + + it('supports an updater so one field can change at a time', () => { + const view = setup(BBOX) + + act(() => view.result.current.setBboxInput((previous) => ({ ...previous, max_x: '5.4' }))) + + expect(view.result.current.typedBbox).toMatchObject({ min_x: 5.0, max_x: 5.4 }) + }) + + it('shows a rectangle that was set elsewhere', () => { + const view = setup() + + act(() => view.result.current.showBbox(BBOX)) + + expect(view.result.current.typedBbox).toMatchObject({ min_x: 5.0, max_y: 51.3 }) + }) + + it('clears the fields when the selection is cleared', () => { + const view = setup(BBOX) + + act(() => view.result.current.showBbox(null)) + + expect(view.result.current.typedBbox).toBeNull() + }) }) describe('reset', () => { - it('abandons a half-drawn rectangle without committing it', () => { - const { view, setSelectionBbox } = setup() + it('abandons a half-drawn rectangle', () => { + const view = setup() - act(() => view.result.current.handleCoordinateSelect([5.0, 51.1])) + act(() => void view.result.current.placeCorner([5.0, 51.1])) act(() => view.result.current.reset()) expect(view.result.current.firstCorner).toBeNull() - expect(setSelectionBbox).not.toHaveBeenCalled() + expect(view.result.current.drawing).toBe(false) }) }) diff --git a/frontend/src/hooks/useMapRectangleSelection.ts b/frontend/src/hooks/useMapRectangleSelection.ts index 9659ea7e..9fb53316 100644 --- a/frontend/src/hooks/useMapRectangleSelection.ts +++ b/frontend/src/hooks/useMapRectangleSelection.ts @@ -1,84 +1,69 @@ -import { useCallback, useState } from 'react' +import { useCallback, useState, type Dispatch, type SetStateAction } 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 state of drawing a rectangle on the map: whether a gesture is in + * progress, which corner has been placed, and what is typed in the coordinate + * fields. * - * 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. + * Deliberately only the interaction state. What happens *with* a finished + * rectangle — retiring stale results, starting an analysis — belongs to the + * workspace, which owns those. Injecting that here would make the hook depend + * on values declared after it. */ -interface MapRectangleSelectionOptions { - initialBbox: VectorSelectionBBox | null - setSelectionBbox: (bbox: VectorSelectionBBox) => void - /** Everything that describes the *previous* rectangle and must not survive. */ - retireStaleResults: () => void -} +export type BboxInputState = ReturnType export interface MapRectangleSelection { + /** A rectangle gesture is in progress. */ drawing: boolean - setDrawing: (value: boolean) => void + setDrawing: Dispatch> + /** The first click of a two-click rectangle, if one has been placed. */ firstCorner: [number, number] | null - bboxInput: ReturnType - setBboxInput: (value: ReturnType) => void - /** The rectangle currently typed into the coordinate fields, if it parses. */ + bboxInput: BboxInputState + setBboxInput: Dispatch> + /** The rectangle currently in the coordinate fields, if it parses. */ typedBbox: VectorSelectionBBox | null - handleCoordinateSelect: (coordinate: [number, number]) => void - handleBboxPreview: (bbox: VectorSelectionBBox) => void - handleBboxSelect: (bbox: VectorSelectionBBox) => void + /** + * Place a corner. Returns the finished rectangle on the second corner, and + * ``null`` on the first — the caller only acts when a rectangle exists. + */ + placeCorner: (coordinate: [number, number]) => VectorSelectionBBox | null + /** Abandon a half-drawn rectangle and leave drawing mode. */ reset: () => void + /** Show a rectangle in the coordinate fields. */ + showBbox: (bbox: VectorSelectionBBox | null) => void } -export function useMapRectangleSelection({ - initialBbox, - setSelectionBbox, - retireStaleResults, -}: MapRectangleSelectionOptions): MapRectangleSelection { +export function useMapRectangleSelection(initialBbox: VectorSelectionBBox | null): MapRectangleSelection { const [drawing, setDrawing] = useState(false) const [firstCorner, setFirstCorner] = useState<[number, number] | null>(null) - const [bboxInput, setBboxInput] = useState(bboxToInputState(initialBbox)) + 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. + const placeCorner = useCallback( + (coordinate: [number, number]): VectorSelectionBBox | null => { if (!firstCorner) { setFirstCorner(coordinate) - return + return null } - commit(normalizeBboxFromCorners(firstCorner, coordinate)) + setFirstCorner(null) + setDrawing(false) + return normalizeBboxFromCorners(firstCorner, coordinate) }, - [commit, firstCorner], + [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) }, []) + const showBbox = useCallback((bbox: VectorSelectionBBox | null) => { + setBboxInput(bboxToInputState(bbox)) + }, []) + return { drawing, setDrawing, @@ -86,9 +71,8 @@ export function useMapRectangleSelection({ bboxInput, setBboxInput, typedBbox: parseBboxInput(bboxInput), - handleCoordinateSelect, - handleBboxPreview, - handleBboxSelect, + placeCorner, reset, + showBbox, } }