extract and test the map rectangle interaction state

Corner tracking for a two-click rectangle lived inline in the component and
was never exercised. The rule worth pinning is that a first corner yields no
rectangle at all: acting on it would analyse a zero-width area.

The hook holds only interaction state — drawing mode, placed corner, the
coordinate fields. What happens with a finished rectangle stays in the
workspace, which owns retiring stale results and starting the analysis.
Injecting that would have made the hook depend on values declared after it,
which is what a first attempt at a wider extraction ran into.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jens
2026-08-22 19:01:45 +02:00
co-authored by Claude Opus 5
parent 9c29577b82
commit 6177eecef5
3 changed files with 132 additions and 136 deletions
+18 -21
View File
@@ -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<string | null>(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({
</span>
</div>
<div className="bbox-select-status">
<span>{bboxSelectionMode ? (firstSelectionCorner ? 'Klik de tegenoverliggende hoek' : 'Klik de eerste hoek op de kaart') : 'Begrenzing EPSG:4326'}</span>
<span>{bboxSelectionMode ? (rectangle.firstCorner ? 'Klik de tegenoverliggende hoek' : 'Klik de eerste hoek op de kaart') : 'Begrenzing EPSG:4326'}</span>
<strong>{formatBboxLabel(currentSelectionBbox)}</strong>
</div>
<div className="bbox-select-grid" aria-label="Coördinaten van de gebiedsselectie">
@@ -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)
})
})
+38 -54
View File
@@ -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<typeof bboxToInputState>
export interface MapRectangleSelection {
/** A rectangle gesture is in progress. */
drawing: boolean
setDrawing: (value: boolean) => void
setDrawing: Dispatch<SetStateAction<boolean>>
/** The first click of a two-click rectangle, if one has been placed. */
firstCorner: [number, number] | null
bboxInput: ReturnType<typeof bboxToInputState>
setBboxInput: (value: ReturnType<typeof bboxToInputState>) => void
/** The rectangle currently typed into the coordinate fields, if it parses. */
bboxInput: BboxInputState
setBboxInput: Dispatch<SetStateAction<BboxInputState>>
/** 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<BboxInputState>(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,
}
}