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
+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,
}
}