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:
@@ -6,6 +6,7 @@ import { useMapThemeSelectionInsights, type MapThemeQuery } from '../../hooks/us
|
|||||||
import { useOfficialMapProducts } from '../../hooks/useOfficialMapProducts'
|
import { useOfficialMapProducts } from '../../hooks/useOfficialMapProducts'
|
||||||
import { useTemporalComparison } from '../../hooks/useTemporalComparison'
|
import { useTemporalComparison } from '../../hooks/useTemporalComparison'
|
||||||
import { isValueRampRasterSource, useMapImageOverlays } from '../../hooks/useMapImageOverlays'
|
import { isValueRampRasterSource, useMapImageOverlays } from '../../hooks/useMapImageOverlays'
|
||||||
|
import { useMapRectangleSelection } from '../../hooks/useMapRectangleSelection'
|
||||||
import { getDatasetDisplayName, getDatasetSourceDisplayName } from '../../lib/datasetDisplay'
|
import { getDatasetDisplayName, getDatasetSourceDisplayName } from '../../lib/datasetDisplay'
|
||||||
import { TemporalTrendChart } from './TemporalTrendChart'
|
import { TemporalTrendChart } from './TemporalTrendChart'
|
||||||
import { MunicipalitySearch } from './MunicipalitySearch'
|
import { MunicipalitySearch } from './MunicipalitySearch'
|
||||||
@@ -18,7 +19,6 @@ import {
|
|||||||
formatPerformanceDuration,
|
formatPerformanceDuration,
|
||||||
} from '../../lib/performanceBudget'
|
} from '../../lib/performanceBudget'
|
||||||
import {
|
import {
|
||||||
bboxToInputState,
|
|
||||||
bboxesEqual,
|
bboxesEqual,
|
||||||
copyText,
|
copyText,
|
||||||
datasetIntersectsSelection,
|
datasetIntersectsSelection,
|
||||||
@@ -31,7 +31,6 @@ import {
|
|||||||
getFeatureCollectionBBox,
|
getFeatureCollectionBBox,
|
||||||
getFeatureGeometrySummary,
|
getFeatureGeometrySummary,
|
||||||
isMunicipalityAreaName,
|
isMunicipalityAreaName,
|
||||||
normalizeBboxFromCorners,
|
|
||||||
operationalScopeProjectLabel,
|
operationalScopeProjectLabel,
|
||||||
parseBboxInput,
|
parseBboxInput,
|
||||||
persistedDatasetSupportsSelection,
|
persistedDatasetSupportsSelection,
|
||||||
@@ -318,9 +317,13 @@ export function MapWorkspace({
|
|||||||
const [selectedTemporalSeriesKey, setSelectedTemporalSeriesKey] = useState('')
|
const [selectedTemporalSeriesKey, setSelectedTemporalSeriesKey] = useState('')
|
||||||
const [earlierDatasetId, setEarlierDatasetId] = useState('')
|
const [earlierDatasetId, setEarlierDatasetId] = useState('')
|
||||||
const [laterDatasetId, setLaterDatasetId] = useState('')
|
const [laterDatasetId, setLaterDatasetId] = useState('')
|
||||||
const [bboxSelectionMode, setBboxSelectionMode] = useState(false)
|
const rectangle = useMapRectangleSelection(mapSelectionBbox)
|
||||||
const [firstSelectionCorner, setFirstSelectionCorner] = useState<[number, number] | null>(null)
|
const {
|
||||||
const [bboxInput, setBboxInput] = useState(bboxToInputState(mapSelectionBbox))
|
drawing: bboxSelectionMode,
|
||||||
|
setDrawing: setBboxSelectionMode,
|
||||||
|
bboxInput,
|
||||||
|
setBboxInput,
|
||||||
|
} = rectangle
|
||||||
const [fullWorkflowRunning, setFullWorkflowRunning] = useState(false)
|
const [fullWorkflowRunning, setFullWorkflowRunning] = useState(false)
|
||||||
const [fullWorkflowStatus, setFullWorkflowStatus] = useState('Klaar om de volledige GIS-werkstroom uit te voeren.')
|
const [fullWorkflowStatus, setFullWorkflowStatus] = useState('Klaar om de volledige GIS-werkstroom uit te voeren.')
|
||||||
const [fullWorkflowError, setFullWorkflowError] = useState<string | null>(null)
|
const [fullWorkflowError, setFullWorkflowError] = useState<string | null>(null)
|
||||||
@@ -970,7 +973,7 @@ export function MapWorkspace({
|
|||||||
}, [activeSelectionResult])
|
}, [activeSelectionResult])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBboxInput(bboxToInputState(mapSelectionBbox))
|
rectangle.showBbox((mapSelectionBbox))
|
||||||
}, [mapSelectionBbox])
|
}, [mapSelectionBbox])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1059,7 +1062,7 @@ export function MapWorkspace({
|
|||||||
|
|
||||||
const setSelectionBbox = (bbox: VectorSelectionBBox | null) => {
|
const setSelectionBbox = (bbox: VectorSelectionBBox | null) => {
|
||||||
onSetMapSelectionBbox(bbox)
|
onSetMapSelectionBbox(bbox)
|
||||||
setBboxInput(bboxToInputState(bbox))
|
rectangle.showBbox((bbox))
|
||||||
}
|
}
|
||||||
|
|
||||||
const areaIdForSelection = (bbox: VectorSelectionBBox | null): string | undefined => (
|
const areaIdForSelection = (bbox: VectorSelectionBBox | null): string | undefined => (
|
||||||
@@ -1067,7 +1070,7 @@ export function MapWorkspace({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const startBboxSelection = () => {
|
const startBboxSelection = () => {
|
||||||
setFirstSelectionCorner(null)
|
rectangle.reset()
|
||||||
clearThemeInsights()
|
clearThemeInsights()
|
||||||
clearTemporalComparison()
|
clearTemporalComparison()
|
||||||
setResultsPanelOpen(false)
|
setResultsPanelOpen(false)
|
||||||
@@ -1075,14 +1078,10 @@ export function MapWorkspace({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleMapCoordinateSelect = (coordinate: [number, number]) => {
|
const handleMapCoordinateSelect = (coordinate: [number, number]) => {
|
||||||
if (!firstSelectionCorner) {
|
// The first click only places a corner; there is no rectangle to act on yet.
|
||||||
setFirstSelectionCorner(coordinate)
|
const bbox = rectangle.placeCorner(coordinate)
|
||||||
return
|
if (!bbox) return
|
||||||
}
|
|
||||||
const bbox = normalizeBboxFromCorners(firstSelectionCorner, coordinate)
|
|
||||||
setSelectionBbox(bbox)
|
setSelectionBbox(bbox)
|
||||||
setFirstSelectionCorner(null)
|
|
||||||
setBboxSelectionMode(false)
|
|
||||||
clearThemeInsights()
|
clearThemeInsights()
|
||||||
clearTemporalComparison()
|
clearTemporalComparison()
|
||||||
setResultsPanelOpen(false)
|
setResultsPanelOpen(false)
|
||||||
@@ -1099,9 +1098,8 @@ export function MapWorkspace({
|
|||||||
const clearAreaSelection = () => {
|
const clearAreaSelection = () => {
|
||||||
mapAnalysisRequestSequence.current += 1
|
mapAnalysisRequestSequence.current += 1
|
||||||
setMapAnalysisDurationMs(null)
|
setMapAnalysisDurationMs(null)
|
||||||
setBboxSelectionMode(false)
|
rectangle.reset()
|
||||||
setFirstSelectionCorner(null)
|
rectangle.showBbox((null))
|
||||||
setBboxInput(bboxToInputState(null))
|
|
||||||
setResultsPanelOpen(false)
|
setResultsPanelOpen(false)
|
||||||
clearThemeInsights()
|
clearThemeInsights()
|
||||||
clearTemporalComparison()
|
clearTemporalComparison()
|
||||||
@@ -1449,8 +1447,7 @@ export function MapWorkspace({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleMapBboxSelect = (bbox: VectorSelectionBBox) => {
|
const handleMapBboxSelect = (bbox: VectorSelectionBBox) => {
|
||||||
setFirstSelectionCorner(null)
|
rectangle.reset()
|
||||||
setBboxSelectionMode(false)
|
|
||||||
clearThemeInsights()
|
clearThemeInsights()
|
||||||
clearTemporalComparison()
|
clearTemporalComparison()
|
||||||
setResultsPanelOpen(false)
|
setResultsPanelOpen(false)
|
||||||
@@ -2930,7 +2927,7 @@ export function MapWorkspace({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bbox-select-status">
|
<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>
|
<strong>{formatBboxLabel(currentSelectionBbox)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="bbox-select-grid" aria-label="Coördinaten van de gebiedsselectie">
|
<div className="bbox-select-grid" aria-label="Coördinaten van de gebiedsselectie">
|
||||||
|
|||||||
@@ -1,93 +1,84 @@
|
|||||||
import { act, renderHook } from '@testing-library/react'
|
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 { useMapRectangleSelection } from './useMapRectangleSelection'
|
||||||
import type { VectorSelectionBBox } from '../types'
|
import type { VectorSelectionBBox } from '../types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The rule under test is not the geometry but the clearing: results from the
|
* Corner tracking lived inline in a 3.300-line component and was never
|
||||||
* previous rectangle must be retired before a new one is drawn, or an operator
|
* exercised. The rule worth pinning is that a first corner produces no
|
||||||
* reads the old area's numbers as belonging to the new selection.
|
* 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' }
|
const BBOX: VectorSelectionBBox = { min_x: 5.0, min_y: 51.1, max_x: 5.2, max_y: 51.3, crs: 'EPSG:4326' }
|
||||||
|
|
||||||
function setup() {
|
function setup(initial: VectorSelectionBBox | null = null) {
|
||||||
const setSelectionBbox = vi.fn()
|
return renderHook(() => useMapRectangleSelection(initial))
|
||||||
const retireStaleResults = vi.fn()
|
|
||||||
const view = renderHook(() =>
|
|
||||||
useMapRectangleSelection({ initialBbox: null, setSelectionBbox, retireStaleResults }),
|
|
||||||
)
|
|
||||||
return { view, setSelectionBbox, retireStaleResults }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('two-click rectangle', () => {
|
describe('two-click rectangle', () => {
|
||||||
it('records the first corner without touching the results on screen', () => {
|
it('yields no rectangle from the first corner', () => {
|
||||||
const { view, setSelectionBbox, retireStaleResults } = setup()
|
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(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', () => {
|
it('yields the normalised rectangle from the second corner', () => {
|
||||||
const { view, setSelectionBbox, retireStaleResults } = setup()
|
const view = setup()
|
||||||
|
|
||||||
act(() => view.result.current.handleCoordinateSelect([5.2, 51.3]))
|
act(() => void view.result.current.placeCorner([5.2, 51.3]))
|
||||||
act(() => view.result.current.handleCoordinateSelect([5.0, 51.1]))
|
let result: VectorSelectionBBox | null = null
|
||||||
|
act(() => {
|
||||||
|
result = view.result.current.placeCorner([5.0, 51.1])
|
||||||
|
})
|
||||||
|
|
||||||
expect(retireStaleResults).toHaveBeenCalledTimes(1)
|
expect(result).not.toBeNull()
|
||||||
expect(setSelectionBbox).toHaveBeenCalledTimes(1)
|
expect(result!.min_x).toBeCloseTo(5.0)
|
||||||
const committed = setSelectionBbox.mock.calls[0][0]
|
expect(result!.min_y).toBeCloseTo(51.1)
|
||||||
expect(committed.min_x).toBeCloseTo(5.0)
|
expect(result!.max_x).toBeCloseTo(5.2)
|
||||||
expect(committed.max_y).toBeCloseTo(51.3)
|
expect(result!.max_y).toBeCloseTo(51.3)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('leaves no half-finished gesture behind', () => {
|
it('leaves no half-finished gesture behind', () => {
|
||||||
const { view } = setup()
|
const view = setup()
|
||||||
|
|
||||||
act(() => view.result.current.handleCoordinateSelect([5.2, 51.3]))
|
act(() => void view.result.current.setDrawing(true))
|
||||||
act(() => view.result.current.handleCoordinateSelect([5.0, 51.1]))
|
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.firstCorner).toBeNull()
|
||||||
expect(view.result.current.drawing).toBe(false)
|
expect(view.result.current.drawing).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
describe('drag rectangle', () => {
|
it('normalises corners placed in any order', () => {
|
||||||
it('moves the rectangle during the drag without retiring anything', () => {
|
const view = setup()
|
||||||
const { view, setSelectionBbox, retireStaleResults } = 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(result!.min_x).toBeLessThan(result!.max_x)
|
||||||
expect(retireStaleResults).not.toHaveBeenCalled()
|
expect(result!.min_y).toBeLessThan(result!.max_y)
|
||||||
})
|
|
||||||
|
|
||||||
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', () => {
|
describe('coordinate fields', () => {
|
||||||
it('parses a complete rectangle from the coordinate fields', () => {
|
it('starts from the rectangle already on the map', () => {
|
||||||
const { view } = setup()
|
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(() =>
|
act(() =>
|
||||||
view.result.current.setBboxInput({ min_x: '5.0', min_y: '51.1', max_x: '5.2', max_y: '51.3' }),
|
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', () => {
|
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: '' }))
|
act(() => view.result.current.setBboxInput({ min_x: '5.0', min_y: '', max_x: '', max_y: '' }))
|
||||||
|
|
||||||
expect(view.result.current.typedBbox).toBeNull()
|
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', () => {
|
describe('reset', () => {
|
||||||
it('abandons a half-drawn rectangle without committing it', () => {
|
it('abandons a half-drawn rectangle', () => {
|
||||||
const { view, setSelectionBbox } = setup()
|
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())
|
act(() => view.result.current.reset())
|
||||||
|
|
||||||
expect(view.result.current.firstCorner).toBeNull()
|
expect(view.result.current.firstCorner).toBeNull()
|
||||||
expect(setSelectionBbox).not.toHaveBeenCalled()
|
expect(view.result.current.drawing).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,84 +1,69 @@
|
|||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useState, type Dispatch, type SetStateAction } from 'react'
|
||||||
|
|
||||||
import type { VectorSelectionBBox } from '../types'
|
import type { VectorSelectionBBox } from '../types'
|
||||||
import { bboxToInputState, normalizeBboxFromCorners, parseBboxInput } from '../components/map/mapWorkspaceUtils'
|
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
|
* Deliberately only the interaction state. What happens *with* a finished
|
||||||
* selection must retire the previous theme insights and temporal comparison
|
* rectangle — retiring stale results, starting an analysis — belongs to the
|
||||||
* before anything is drawn, so results from the old rectangle can never be read
|
* workspace, which owns those. Injecting that here would make the hook depend
|
||||||
* as belonging to the new one.
|
* on values declared after it.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface MapRectangleSelectionOptions {
|
export type BboxInputState = ReturnType<typeof bboxToInputState>
|
||||||
initialBbox: VectorSelectionBBox | null
|
|
||||||
setSelectionBbox: (bbox: VectorSelectionBBox) => void
|
|
||||||
/** Everything that describes the *previous* rectangle and must not survive. */
|
|
||||||
retireStaleResults: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MapRectangleSelection {
|
export interface MapRectangleSelection {
|
||||||
|
/** A rectangle gesture is in progress. */
|
||||||
drawing: boolean
|
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
|
firstCorner: [number, number] | null
|
||||||
bboxInput: ReturnType<typeof bboxToInputState>
|
bboxInput: BboxInputState
|
||||||
setBboxInput: (value: ReturnType<typeof bboxToInputState>) => void
|
setBboxInput: Dispatch<SetStateAction<BboxInputState>>
|
||||||
/** The rectangle currently typed into the coordinate fields, if it parses. */
|
/** The rectangle currently in the coordinate fields, if it parses. */
|
||||||
typedBbox: VectorSelectionBBox | null
|
typedBbox: VectorSelectionBBox | null
|
||||||
handleCoordinateSelect: (coordinate: [number, number]) => void
|
/**
|
||||||
handleBboxPreview: (bbox: VectorSelectionBBox) => void
|
* Place a corner. Returns the finished rectangle on the second corner, and
|
||||||
handleBboxSelect: (bbox: VectorSelectionBBox) => void
|
* ``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
|
reset: () => void
|
||||||
|
/** Show a rectangle in the coordinate fields. */
|
||||||
|
showBbox: (bbox: VectorSelectionBBox | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMapRectangleSelection({
|
export function useMapRectangleSelection(initialBbox: VectorSelectionBBox | null): MapRectangleSelection {
|
||||||
initialBbox,
|
|
||||||
setSelectionBbox,
|
|
||||||
retireStaleResults,
|
|
||||||
}: MapRectangleSelectionOptions): MapRectangleSelection {
|
|
||||||
const [drawing, setDrawing] = useState(false)
|
const [drawing, setDrawing] = useState(false)
|
||||||
const [firstCorner, setFirstCorner] = useState<[number, number] | null>(null)
|
const [firstCorner, setFirstCorner] = useState<[number, number] | null>(null)
|
||||||
const [bboxInput, setBboxInput] = useState(bboxToInputState(initialBbox))
|
const [bboxInput, setBboxInput] = useState<BboxInputState>(bboxToInputState(initialBbox))
|
||||||
|
|
||||||
const commit = useCallback(
|
const placeCorner = useCallback(
|
||||||
(bbox: VectorSelectionBBox) => {
|
(coordinate: [number, number]): VectorSelectionBBox | null => {
|
||||||
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) {
|
if (!firstCorner) {
|
||||||
setFirstCorner(coordinate)
|
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(() => {
|
const reset = useCallback(() => {
|
||||||
setFirstCorner(null)
|
setFirstCorner(null)
|
||||||
setDrawing(false)
|
setDrawing(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const showBbox = useCallback((bbox: VectorSelectionBBox | null) => {
|
||||||
|
setBboxInput(bboxToInputState(bbox))
|
||||||
|
}, [])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
drawing,
|
drawing,
|
||||||
setDrawing,
|
setDrawing,
|
||||||
@@ -86,9 +71,8 @@ export function useMapRectangleSelection({
|
|||||||
bboxInput,
|
bboxInput,
|
||||||
setBboxInput,
|
setBboxInput,
|
||||||
typedBbox: parseBboxInput(bboxInput),
|
typedBbox: parseBboxInput(bboxInput),
|
||||||
handleCoordinateSelect,
|
placeCorner,
|
||||||
handleBboxPreview,
|
|
||||||
handleBboxSelect,
|
|
||||||
reset,
|
reset,
|
||||||
|
showBbox,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user