Publish curated GeoIntel source snapshot
This commit is contained in:
commit
0aae654d6e
764 files changed
+169154
No files matched your search
@@ -0,0 +1,57 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { LiveAnalysisJourney, resolveLiveAnalysisJourney } from './LiveAnalysisJourney'
|
||||
|
||||
const baseState = {
|
||||
selectionMode: false,
|
||||
hasSelection: false,
|
||||
sourceLoading: false,
|
||||
sourceReady: false,
|
||||
processing: false,
|
||||
validating: false,
|
||||
hasResult: false,
|
||||
verified: false,
|
||||
error: null,
|
||||
}
|
||||
|
||||
describe('resolveLiveAnalysisJourney', () => {
|
||||
it('follows real workflow state without assuming completion', () => {
|
||||
expect(resolveLiveAnalysisJourney(baseState).currentLabel).toBe('Selecteer')
|
||||
expect(resolveLiveAnalysisJourney({ ...baseState, hasSelection: true, sourceLoading: true }).currentLabel).toBe('Bronnen')
|
||||
expect(resolveLiveAnalysisJourney({ ...baseState, hasSelection: true, sourceReady: true, processing: true }).currentLabel).toBe('Verwerk')
|
||||
expect(resolveLiveAnalysisJourney({ ...baseState, hasSelection: true, sourceReady: true, hasResult: true }).currentLabel).toBe('Controleer')
|
||||
expect(resolveLiveAnalysisJourney({ ...baseState, hasSelection: true, sourceReady: true, hasResult: true, verified: true }).status).toBe('complete')
|
||||
})
|
||||
|
||||
it('marks the reached stage as failed', () => {
|
||||
const view = resolveLiveAnalysisJourney({
|
||||
...baseState,
|
||||
hasSelection: true,
|
||||
sourceReady: true,
|
||||
processing: true,
|
||||
error: 'Bron tijdelijk niet beschikbaar.',
|
||||
})
|
||||
expect(view.status).toBe('error')
|
||||
expect(view.steps[2]).toBe('error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('LiveAnalysisJourney', () => {
|
||||
it('announces verified evidence only when verification exists', () => {
|
||||
render(
|
||||
<LiveAnalysisJourney
|
||||
{...baseState}
|
||||
hasSelection
|
||||
sourceReady
|
||||
hasResult
|
||||
verified
|
||||
selectionLabel="Vrije kaartselectie"
|
||||
sourceLabel="Gebouwen"
|
||||
statusMessage="Analyse voltooid"
|
||||
resultLabel="Kwaliteitsbewijs beschikbaar"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getAllByText('Kwaliteitsbewijs beschikbaar')).toHaveLength(2)
|
||||
expect(screen.getByTestId('live-analysis-journey').getAttribute('data-stage')).toBe('controleer')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import { BadgeCheck, Database, MapPinned, ScanSearch, ShieldAlert } from 'lucide-react'
|
||||
|
||||
export type LiveAnalysisStepStatus = 'waiting' | 'active' | 'complete' | 'error'
|
||||
|
||||
export interface LiveAnalysisJourneyState {
|
||||
selectionMode: boolean
|
||||
hasSelection: boolean
|
||||
sourceLoading: boolean
|
||||
sourceReady: boolean
|
||||
processing: boolean
|
||||
validating: boolean
|
||||
hasResult: boolean
|
||||
verified: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface LiveAnalysisJourneyView {
|
||||
currentIndex: number
|
||||
currentLabel: string
|
||||
status: 'idle' | 'running' | 'complete' | 'error'
|
||||
steps: LiveAnalysisStepStatus[]
|
||||
}
|
||||
|
||||
const STEP_LABELS = ['Selecteer', 'Bronnen', 'Verwerk', 'Controleer'] as const
|
||||
|
||||
export function resolveLiveAnalysisJourney(state: LiveAnalysisJourneyState): LiveAnalysisJourneyView {
|
||||
let currentIndex = 0
|
||||
if (state.validating || state.verified || state.hasResult) currentIndex = 3
|
||||
else if (state.processing) currentIndex = 2
|
||||
else if (state.hasSelection) currentIndex = state.sourceLoading || !state.sourceReady ? 1 : 2
|
||||
|
||||
const status: LiveAnalysisJourneyView['status'] = state.error
|
||||
? 'error'
|
||||
: state.verified
|
||||
? 'complete'
|
||||
: state.sourceLoading || state.processing || state.validating
|
||||
? 'running'
|
||||
: 'idle'
|
||||
|
||||
const steps: LiveAnalysisStepStatus[] = [
|
||||
state.hasSelection ? 'complete' : 'active',
|
||||
state.hasSelection && state.sourceReady ? 'complete' : state.hasSelection ? 'active' : 'waiting',
|
||||
state.hasResult ? 'complete' : state.processing || (state.hasSelection && state.sourceReady) ? 'active' : 'waiting',
|
||||
state.verified ? 'complete' : state.validating || state.hasResult ? 'active' : 'waiting',
|
||||
]
|
||||
|
||||
if (state.error) steps[currentIndex] = 'error'
|
||||
|
||||
return { currentIndex, currentLabel: STEP_LABELS[currentIndex], status, steps }
|
||||
}
|
||||
|
||||
interface LiveAnalysisJourneyProps extends LiveAnalysisJourneyState {
|
||||
selectionLabel: string
|
||||
sourceLabel: string
|
||||
statusMessage: string
|
||||
resultLabel: string
|
||||
}
|
||||
|
||||
const steps = [
|
||||
{ label: 'Selecteer', icon: MapPinned },
|
||||
{ label: 'Bronnen', icon: Database },
|
||||
{ label: 'Verwerk', icon: ScanSearch },
|
||||
{ label: 'Controleer', icon: BadgeCheck },
|
||||
] as const
|
||||
|
||||
export function LiveAnalysisJourney({ selectionLabel, sourceLabel, statusMessage, resultLabel, ...state }: LiveAnalysisJourneyProps): JSX.Element {
|
||||
const view = resolveLiveAnalysisJourney(state)
|
||||
const detail = view.currentIndex === 0
|
||||
? state.selectionMode ? 'Teken nu op de kaart' : selectionLabel
|
||||
: view.currentIndex === 1
|
||||
? sourceLabel
|
||||
: view.currentIndex === 3 && state.verified
|
||||
? resultLabel
|
||||
: statusMessage
|
||||
|
||||
return (
|
||||
<div className={`live-analysis-journey live-analysis-journey-${view.status}`} data-testid="live-analysis-journey" data-stage={view.currentLabel.toLocaleLowerCase('nl-BE')} aria-live="polite">
|
||||
<div className="live-analysis-status-card">
|
||||
<span className="live-analysis-kicker"><i aria-hidden="true" /> Live analyse</span>
|
||||
<strong>{state.error ? 'Analyse onderbroken' : view.currentLabel}</strong>
|
||||
<small>{state.error ?? detail}</small>
|
||||
</div>
|
||||
<ol className="live-analysis-steps" aria-label="Voortgang van de gebiedsanalyse">
|
||||
{steps.map(({ label, icon: Icon }, index) => (
|
||||
<li className={`live-analysis-step live-analysis-step-${view.steps[index]}`} key={label}>
|
||||
<span><Icon aria-hidden="true" /></span>
|
||||
<small>{label}</small>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<div className={`live-analysis-proof live-analysis-proof-${state.verified ? 'ready' : 'pending'}`}>
|
||||
{state.error ? <ShieldAlert aria-hidden="true" /> : <BadgeCheck aria-hidden="true" />}
|
||||
<span>{state.verified ? resultLabel : state.hasResult ? 'Resultaat wacht op controle' : 'Controleerbaar resultaat'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,18 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ResultsPanelDismiss } from './MapExplorerView'
|
||||
|
||||
vi.mock('../GeoMap', () => ({ default: () => null }))
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
describe('ResultsPanelDismiss', () => {
|
||||
it('offers an explicit mobile-safe way to close the results drawer', () => {
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(<ResultsPanelDismiss onClose={onClose} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Resultatenpaneel sluiten' }))
|
||||
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,17 @@
|
||||
import { MapAdvancedWorkbench } from './MapAdvancedWorkbench'
|
||||
import { MapExplorerView } from './MapExplorerView'
|
||||
import type { MapWorkspaceProps } from './mapWorkspaceProps'
|
||||
import { useMapWorkspaceViewModel } from './useMapWorkspaceViewModel'
|
||||
|
||||
/**
|
||||
* The map workspace has two render paths over one body of derived state: the
|
||||
* map-first explorer, and the advanced workbench behind it. Both read the same
|
||||
* view model, so the choice between them is all that is left here.
|
||||
*/
|
||||
export function MapWorkspace(props: MapWorkspaceProps): JSX.Element {
|
||||
const view = useMapWorkspaceViewModel(props)
|
||||
|
||||
return view.advancedMode
|
||||
? <MapAdvancedWorkbench props={props} view={view} />
|
||||
: <MapExplorerView props={props} view={view} />
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/dom'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { areasApi } from '../../services/api/areas'
|
||||
import { MunicipalitySearch } from './MunicipalitySearch'
|
||||
import type { AreaRead } from '../../types'
|
||||
|
||||
vi.mock('../../services/api/areas', () => ({
|
||||
areasApi: { searchMunicipalities: vi.fn() },
|
||||
}))
|
||||
|
||||
describe('MunicipalitySearch', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(areasApi.searchMunicipalities).mockResolvedValue({
|
||||
items: [{ niscode: '13025', name: 'Mol', name_nl: 'Mol', name_fr: 'Mol', name_de: 'Mol' }],
|
||||
total: 1,
|
||||
})
|
||||
})
|
||||
afterEach(() => cleanup())
|
||||
|
||||
it('searches the official municipality catalog and activates a result', async () => {
|
||||
const activated = { id: 'mol', project_id: 'project', name: 'Gemeente Mol - NIS 13025' } as AreaRead
|
||||
const onActivate = vi.fn().mockResolvedValue(activated)
|
||||
render(<MunicipalitySearch projectId="project" activeArea={null} onActivate={onActivate} />)
|
||||
|
||||
fireEvent.change(screen.getByTestId('municipality-search-input'), { target: { value: 'Mol' } })
|
||||
await waitFor(() => expect(areasApi.searchMunicipalities).toHaveBeenCalledWith('project', 'Mol'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Mol/ }))
|
||||
await waitFor(() => expect(onActivate).toHaveBeenCalledWith('13025'))
|
||||
})
|
||||
|
||||
it('presents municipality search as optional and keeps free selection visible', () => {
|
||||
render(<MunicipalitySearch projectId="project" activeArea={null} onActivate={vi.fn()} />)
|
||||
expect(screen.getByText('optioneel')).toBeTruthy()
|
||||
expect(screen.getByText('Vrije kaartselectie')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { MapPin, Search, X } from 'lucide-react'
|
||||
import { areasApi } from '../../services/api/areas'
|
||||
import type { AreaRead, MunicipalitySearchItem } from '../../types'
|
||||
|
||||
interface MunicipalitySearchProps {
|
||||
projectId: string | null
|
||||
activeArea: AreaRead | null
|
||||
disabled?: boolean
|
||||
onActivate: (niscode: string) => Promise<AreaRead | null>
|
||||
}
|
||||
|
||||
function areaDisplayName(area: AreaRead | null): string | null {
|
||||
return area?.name.replace(/^Gemeente\s+/i, '').replace(/\s+-\s+NIS\s+\d+$/i, '') ?? null
|
||||
}
|
||||
|
||||
export function MunicipalitySearch({ projectId, activeArea, disabled = false, onActivate }: MunicipalitySearchProps): JSX.Element {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<MunicipalitySearchItem[]>([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [activatingCode, setActivatingCode] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const searchSequence = useRef(0)
|
||||
const activeMunicipality = areaDisplayName(activeArea)
|
||||
|
||||
useEffect(() => {
|
||||
const normalized = query.trim()
|
||||
const requestId = ++searchSequence.current
|
||||
if (!projectId || normalized.length < 2) {
|
||||
setResults([])
|
||||
setSearching(false)
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
setSearching(true)
|
||||
setError(null)
|
||||
const timeout = window.setTimeout(() => {
|
||||
void areasApi.searchMunicipalities(projectId, normalized)
|
||||
.then((response) => {
|
||||
if (requestId === searchSequence.current) setResults(response.items)
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (requestId === searchSequence.current) setError(caught instanceof Error ? caught.message : 'Gemeenten zoeken is niet gelukt')
|
||||
})
|
||||
.finally(() => {
|
||||
if (requestId === searchSequence.current) setSearching(false)
|
||||
})
|
||||
}, 220)
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [projectId, query])
|
||||
|
||||
const activate = async (municipality: MunicipalitySearchItem) => {
|
||||
setActivatingCode(municipality.niscode)
|
||||
setError(null)
|
||||
const area = await onActivate(municipality.niscode)
|
||||
if (area) {
|
||||
setQuery('')
|
||||
setResults([])
|
||||
} else {
|
||||
setError('De officiële gemeentegrens kon niet worden geactiveerd.')
|
||||
}
|
||||
setActivatingCode(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="municipality-search" aria-label="Optioneel een gemeente zoeken">
|
||||
<div className="municipality-search-copy">
|
||||
<span className="municipality-shortcut"><MapPin aria-hidden="true" /> Snelkeuze</span>
|
||||
<div>
|
||||
<strong>Ga naar een gemeente <small>optioneel</small></strong>
|
||||
<p>Zoek een officiële grens, of teken straks vrij op de kaart.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="municipality-search-control">
|
||||
<label>
|
||||
<span className="sr-only">Zoek op gemeentenaam of NIS-code</span>
|
||||
<Search aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Gemeentenaam of NIS-code"
|
||||
disabled={disabled || !projectId}
|
||||
autoComplete="off"
|
||||
data-testid="municipality-search-input"
|
||||
/>
|
||||
{query ? <button type="button" className="municipality-search-clear" onClick={() => setQuery('')} aria-label="Zoekterm wissen"><X aria-hidden="true" /></button> : null}
|
||||
</label>
|
||||
{query.trim().length >= 2 ? (
|
||||
<div className="municipality-search-results" aria-label="Gevonden gemeenten">
|
||||
{searching ? <p role="status">Gemeenten zoeken…</p> : null}
|
||||
{!searching && results.length === 0 && !error ? <p>Geen officiële gemeente gevonden.</p> : null}
|
||||
{results.map((municipality) => (
|
||||
<button key={municipality.niscode} type="button" onClick={() => void activate(municipality)} disabled={activatingCode !== null}>
|
||||
<span><strong>{municipality.name}</strong><small>NIS {municipality.niscode}</small></span>
|
||||
<em>{activatingCode === municipality.niscode ? 'Laden…' : 'Gebruik grens'}</em>
|
||||
</button>
|
||||
))}
|
||||
{error ? <p className="error" role="alert">{error}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="municipality-search-status">
|
||||
<span>Actief werkgebied</span>
|
||||
<strong>{activeMunicipality ?? (activeArea?.name || 'Vrije kaartselectie')}</strong>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { TemporalObservation } from '../../types'
|
||||
|
||||
interface TemporalTrendChartProps {
|
||||
timeline: TemporalObservation[]
|
||||
metricKey: string
|
||||
}
|
||||
|
||||
const WIDTH = 560
|
||||
const HEIGHT = 150
|
||||
const PADDING_X = 34
|
||||
const PADDING_Y = 24
|
||||
|
||||
function formatValue(value: number, unit: string): string {
|
||||
const maximumFractionDigits = unit === 'inwoners' || unit === 'objecten' ? 0 : 2
|
||||
return `${value.toLocaleString('nl-BE', { maximumFractionDigits })} ${unit}`
|
||||
}
|
||||
|
||||
export function TemporalTrendChart({ timeline, metricKey }: TemporalTrendChartProps): JSX.Element | null {
|
||||
const observations = timeline.flatMap((observation) => {
|
||||
const metric = observation.metrics.find((item) => item.metric_key === metricKey)
|
||||
return metric ? [{ observation, metric }] : []
|
||||
})
|
||||
if (observations.length < 2) {
|
||||
return null
|
||||
}
|
||||
|
||||
const values = observations.map((item) => item.metric.value)
|
||||
const minimum = Math.min(...values)
|
||||
const maximum = Math.max(...values)
|
||||
const range = maximum - minimum
|
||||
const innerWidth = WIDTH - PADDING_X * 2
|
||||
const innerHeight = HEIGHT - PADDING_Y * 2
|
||||
const points = observations.map((item, index) => {
|
||||
const x = PADDING_X + (index / (observations.length - 1)) * innerWidth
|
||||
const normalized = range === 0 ? 0.5 : (item.metric.value - minimum) / range
|
||||
const y = HEIGHT - PADDING_Y - normalized * innerHeight
|
||||
return { ...item, x, y }
|
||||
})
|
||||
const pointString = points.map((point) => `${point.x},${point.y}`).join(' ')
|
||||
const label = `${points[0].metric.label}: ${formatValue(points[0].metric.value, points[0].metric.unit)} tot ${formatValue(
|
||||
points[points.length - 1].metric.value,
|
||||
points[points.length - 1].metric.unit,
|
||||
)}`
|
||||
|
||||
return (
|
||||
<div className="geo-temporal-chart" aria-label={label}>
|
||||
<div className="geo-temporal-chart-heading">
|
||||
<div>
|
||||
<span>Volledige tijdreeks</span>
|
||||
<strong>{points[0].metric.label}</strong>
|
||||
</div>
|
||||
<span>{points.length} meetmomenten</span>
|
||||
</div>
|
||||
<svg viewBox={`0 0 ${WIDTH} ${HEIGHT}`} role="img" aria-label={label} preserveAspectRatio="none">
|
||||
<line x1={PADDING_X} x2={WIDTH - PADDING_X} y1={HEIGHT - PADDING_Y} y2={HEIGHT - PADDING_Y} />
|
||||
<polyline points={pointString} />
|
||||
{points.map((point) => (
|
||||
<g key={point.observation.dataset.id}>
|
||||
<circle cx={point.x} cy={point.y} r="4" />
|
||||
<text x={point.x} y={HEIGHT - 6} textAnchor="middle">
|
||||
{new Date(point.observation.dataset.observed_at).getUTCFullYear()}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
<div className="geo-temporal-chart-values">
|
||||
{points.map((point) => (
|
||||
<div key={point.observation.dataset.id}>
|
||||
<span>{new Date(point.observation.dataset.observed_at).getUTCFullYear()}</span>
|
||||
<strong>{formatValue(point.metric.value, point.metric.unit)}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ThemeSearchField } from './ThemeSearchField'
|
||||
|
||||
describe('ThemeSearchField', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('toont wat de gebruiker typt zonder daarop te wachten', () => {
|
||||
vi.useFakeTimers()
|
||||
const onChange = vi.fn()
|
||||
render(<ThemeSearchField waarde="" onChange={onChange} />)
|
||||
const veld = screen.getByRole('searchbox')
|
||||
|
||||
fireEvent.change(veld, { target: { value: 'beb' } })
|
||||
expect((veld as HTMLInputElement).value).toBe('beb')
|
||||
// De bovenliggende werkruimte weet er nog niets van; die hertekende
|
||||
// voorheen bij elke letter.
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('geeft de waarde één keer door wanneer het typen stopt', () => {
|
||||
vi.useFakeTimers()
|
||||
const onChange = vi.fn()
|
||||
render(<ThemeSearchField waarde="" onChange={onChange} />)
|
||||
const veld = screen.getByRole('searchbox')
|
||||
|
||||
for (const tekst of ['b', 'be', 'beb', 'bebo', 'bebou', 'bebouw']) {
|
||||
fireEvent.change(veld, { target: { value: tekst } })
|
||||
vi.advanceTimersByTime(40)
|
||||
}
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(200)
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
expect(onChange).toHaveBeenCalledWith('bebouw')
|
||||
})
|
||||
|
||||
it('volgt een waarde die van buitenaf gewist wordt', () => {
|
||||
const onChange = vi.fn()
|
||||
const { rerender } = render(<ThemeSearchField waarde="bebouw" onChange={onChange} />)
|
||||
expect((screen.getByRole('searchbox') as HTMLInputElement).value).toBe('bebouw')
|
||||
|
||||
rerender(<ThemeSearchField waarde="" onChange={onChange} />)
|
||||
expect((screen.getByRole('searchbox') as HTMLInputElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('houdt een toegankelijke naam', () => {
|
||||
render(<ThemeSearchField waarde="" onChange={vi.fn()} />)
|
||||
expect(screen.getByRole('searchbox', { name: /zoek een thema/i })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
interface ThemeSearchFieldProps {
|
||||
waarde: string
|
||||
onChange: (waarde: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Het zoekveld boven de themalijst, met zijn eigen invoertoestand.
|
||||
*
|
||||
* De filterwaarde zelf leeft in het viewmodel van de kaartwerkruimte, dat de
|
||||
* hele werkruimte voedt. Rechtstreeks doorgeven betekende dat elke toetsaanslag
|
||||
* de volledige boom hertekende, inclusief de kaart: gemeten 33 tot 58 ms per
|
||||
* letter met lege data, en dat loopt op zodra er echt bronnen in staan.
|
||||
*
|
||||
* Wat de gebruiker typt blijft nu hier. Pas als het even stil is gaat de waarde
|
||||
* naar boven. Typen voelt daardoor direct, terwijl de lijst een fractie later
|
||||
* bijtrekt — wat bij zoeken ook het gewenste gedrag is, want filteren op elke
|
||||
* losse letter levert toch geen bruikbaar tussenresultaat.
|
||||
*/
|
||||
const STILTE_MS = 160
|
||||
|
||||
export function ThemeSearchField({ waarde, onChange }: ThemeSearchFieldProps): JSX.Element {
|
||||
const [invoer, setInvoer] = useState(waarde)
|
||||
const onChangeRef = useRef(onChange)
|
||||
onChangeRef.current = onChange
|
||||
|
||||
// Wordt de waarde van buitenaf gewist (bijvoorbeeld bij een andere
|
||||
// werkruimte), dan volgt het veld.
|
||||
useEffect(() => {
|
||||
setInvoer((huidig) => (huidig === waarde ? huidig : waarde))
|
||||
}, [waarde])
|
||||
|
||||
useEffect(() => {
|
||||
if (invoer === waarde) return
|
||||
const teller = window.setTimeout(() => onChangeRef.current(invoer), STILTE_MS)
|
||||
return () => window.clearTimeout(teller)
|
||||
// waarde bewust niet in de lijst: die verandert door onze eigen melding.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [invoer])
|
||||
|
||||
return (
|
||||
<label className="geo-theme-search">
|
||||
<span className="sr-only">Zoek een thema of gegevensbron</span>
|
||||
<Search aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
value={invoer}
|
||||
onChange={(event) => setInvoer(event.target.value)}
|
||||
placeholder="Zoek thema’s"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Kaartsymbologie.
|
||||
*
|
||||
* De kleuren van de kaartlagen stonden als losse hexwaarden door GeoMap.tsx
|
||||
* heen. Het waren framework-standaardkleuren die bij geen enkel token uit het
|
||||
* designsysteem hoorden, en op de donkere operationele ondergrond vielen de
|
||||
* donkere varianten volledig weg.
|
||||
*
|
||||
* Deze waarden horen bij de donkere werkstand en zijn afgestemd op de tokens
|
||||
* in geointel-system.css, sectie "Operationeel donker". Ze staan hier als
|
||||
* letterlijke waarden omdat MapLibre paint-eigenschappen geen CSS-variabelen
|
||||
* kunnen lezen; wijzigt een token, dan wijzigt zijn tegenhanger hier mee.
|
||||
*/
|
||||
|
||||
export const mapSymbology = {
|
||||
/** Officiële grenzen. Komt overeen met --gi-brand-500. */
|
||||
boundary: '#45cfb4',
|
||||
boundaryStrong: '#38c0a5',
|
||||
|
||||
/** Verandering tussen twee meetmomenten. */
|
||||
added: '#4cc48d',
|
||||
removed: '#ec7d76',
|
||||
modified: '#dda45e',
|
||||
unchanged: '#6fb0dd',
|
||||
|
||||
/** Eigen ingeladen data. Oranje blijft de tegenkleur van het teal-merk. */
|
||||
dataFill: '#fb923c',
|
||||
dataLine: '#f97316',
|
||||
|
||||
/** Actieve selectie. Geel leest op elke ondergrond. */
|
||||
selectionFill: '#fde047',
|
||||
selectionLine: '#fbbf24',
|
||||
|
||||
/** Water en bathymetrie. */
|
||||
waterFill: '#38bdf8',
|
||||
waterLine: '#7dd3fc',
|
||||
|
||||
/** Modelresultaten uit beeldanalyse. */
|
||||
detectionFill: '#a78bfa',
|
||||
detectionLine: '#c4b5fd',
|
||||
|
||||
/** Randen van puntsymbolen, tegen elke vulkleur. */
|
||||
pointStroke: '#ffffff',
|
||||
|
||||
/** Terugvalkleur wanneer een feature geen bekende klasse heeft. */
|
||||
fallback: '#94a3b8',
|
||||
} as const
|
||||
|
||||
export type KaartWerkstand = 'dark' | 'light'
|
||||
|
||||
/**
|
||||
* De verf van de ondergrond.
|
||||
*
|
||||
* De tegels zijn consumentenkaarten: rode snelwegen, groene bossen,
|
||||
* POI-drukte. In beide werkstanden worden ze ontkleurd zodat alleen de eigen
|
||||
* data nog kleur draagt; het verschil zit in hoe ver ze gedempt worden.
|
||||
*
|
||||
* De featurekleuren hierboven blijven in beide werkstanden gelijk. Het zijn
|
||||
* middentonen die op een lichte én een donkere ondergrond leesbaar zijn, en
|
||||
* één set houdt de betekenis van een kleur constant wanneer een gebruiker
|
||||
* tussen de twee wisselt.
|
||||
*/
|
||||
export function basemapPaint(werkstand: KaartWerkstand): Record<string, number> {
|
||||
if (werkstand === 'light') {
|
||||
return {
|
||||
'raster-saturation': -0.62,
|
||||
'raster-brightness-min': 0.32,
|
||||
'raster-brightness-max': 1,
|
||||
'raster-contrast': -0.14,
|
||||
'raster-hue-rotate': 140,
|
||||
'raster-opacity': 0.92,
|
||||
}
|
||||
}
|
||||
return {
|
||||
'raster-saturation': -0.78,
|
||||
'raster-brightness-min': 0.03,
|
||||
'raster-brightness-max': 0.44,
|
||||
'raster-contrast': -0.08,
|
||||
'raster-hue-rotate': 140,
|
||||
'raster-opacity': 0.9,
|
||||
}
|
||||
}
|
||||
|
||||
export function basemapGround(werkstand: KaartWerkstand): string {
|
||||
return werkstand === 'light' ? '#eef2f0' : '#0a100e'
|
||||
}
|
||||
|
||||
export function huidigeWerkstand(): KaartWerkstand {
|
||||
if (typeof document === 'undefined') return 'dark'
|
||||
return document.body.dataset.theme === 'light' ? 'light' : 'dark'
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* The map workspace's props.
|
||||
*
|
||||
* Its own module so the view-model hook and both render paths can name the
|
||||
* same shape without importing the component that renders them.
|
||||
*/
|
||||
|
||||
import type { AreaRead, CoverageResolveResponse, DatasetCreateResponse, DetectionQaResult, MapResultExportRequest, MapViewportState, OrthophotoAcquisitionResult, OrthophotoProductRead, ProjectRead, QaComparisonResult, VectorSelectionBBox, VectorSelectionResponse } from '../../types'
|
||||
|
||||
export interface MapWorkspaceProps {
|
||||
readOnly?: boolean
|
||||
managementLocked?: boolean
|
||||
sourceAcquisitionEnabled?: boolean
|
||||
derivedDatasetWritesEnabled?: boolean
|
||||
secondaryResultsContainer?: HTMLElement | null
|
||||
selectedProjectId: string | null
|
||||
projects: ProjectRead[]
|
||||
areas: AreaRead[]
|
||||
selectedMapAreaId: string
|
||||
areaFeatureCollection: GeoJSON.FeatureCollection | null
|
||||
mapFeatureCollection: GeoJSON.FeatureCollection | null
|
||||
qualityEvidenceGeoJson?: GeoJSON.FeatureCollection | null
|
||||
qualityEvidenceFeatureCount?: number
|
||||
qualityEvidenceLoading?: boolean
|
||||
qualityEvidenceError?: string | null
|
||||
qualityEvidenceWarnings?: string[]
|
||||
mapLayerLabel: string
|
||||
mapLayerSourceLabel: string
|
||||
mapLayerProvenance: string
|
||||
mapLayerVisible: boolean
|
||||
mapLayerOpacity: number
|
||||
areaLayerVisible: boolean
|
||||
areaLayerOpacity: number
|
||||
mapFeatureCount: number
|
||||
areaFeatureCount: number
|
||||
viewportVectorEnabled: boolean
|
||||
viewportVectorStatus: string | null
|
||||
viewportVectorTone: 'ready' | 'pending' | 'warning' | 'error'
|
||||
fitMapDataOnChange: boolean
|
||||
mapContentMode: 'dataset' | 'analysis'
|
||||
analysisLayerAvailable: boolean
|
||||
selectedMapFeature: GeoJSON.Feature | null
|
||||
selectedFeature?: GeoJSON.Feature | null
|
||||
mapSelectionBbox: VectorSelectionBBox | null
|
||||
mapSelectionResult: VectorSelectionResponse | null
|
||||
mapSelectionLoading: boolean
|
||||
mapSelectionError: string | null
|
||||
coverage: CoverageResolveResponse | null
|
||||
coverageLoading: boolean
|
||||
coverageError: string | null
|
||||
coverageDurationMs: number | null
|
||||
coverageBudgetExceeded: boolean
|
||||
workspaceLoading: boolean
|
||||
workspaceError: string | null
|
||||
selectionExporting: boolean
|
||||
selectionExportError: string | null
|
||||
latestSelectionExportPath: string | null
|
||||
selectionDatasetSaving: boolean
|
||||
selectionDatasetError: string | null
|
||||
latestSelectionDataset: DatasetCreateResponse | null
|
||||
latestSelectionDatasetName: string | null
|
||||
mapQaReferenceDatasets: DatasetCreateResponse[]
|
||||
selectedMapQaReferenceDatasetId: string
|
||||
mapSelectionQaRunning: boolean
|
||||
mapSelectionQaError: string | null
|
||||
mapSelectionQaResult: QaComparisonResult | null
|
||||
latestMapSelectionQualityCheckId: string | null
|
||||
orthophotoAnalysisStage: 'idle' | 'acquiring' | 'detecting' | 'validating' | 'complete' | 'failed'
|
||||
orthophotoAnalysisStatus: string
|
||||
orthophotoAnalysisError: string | null
|
||||
orthophotoAnalysisRunning: boolean
|
||||
orthophotoAnalysisQuality: DetectionQaResult | null
|
||||
orthophotoAnalysisDetectionCount: number | null
|
||||
orthophotoProducts: OrthophotoProductRead[]
|
||||
selectedOrthophotoProductKey: string
|
||||
orthophotoResult: OrthophotoAcquisitionResult | null
|
||||
orthophotoImageUrl: string | null
|
||||
availableMapDatasets: DatasetCreateResponse[]
|
||||
selectedMapDatasetId: string
|
||||
onSelectMapArea: (areaId: string) => void
|
||||
onActivateMunicipality: (niscode: string) => Promise<AreaRead | null>
|
||||
onSetContextSourceLabel: (label: string | null) => void
|
||||
onSetContextLayerLabel: (label: string | null) => void
|
||||
onOpenDatasetInMap: (dataset: DatasetCreateResponse) => void
|
||||
onSetAreaLayerVisible: (visible: boolean) => void
|
||||
onSetAreaLayerOpacity: (opacity: number) => void
|
||||
onSetMapLayerVisible: (visible: boolean) => void
|
||||
onSetMapLayerOpacity: (opacity: number) => void
|
||||
onSetMapContentMode: (mode: 'dataset' | 'analysis') => void
|
||||
onSelectMapFeature: (feature: GeoJSON.Feature | null) => void
|
||||
onMapViewportChange: (viewport: MapViewportState) => void
|
||||
onSetMapSelectionBbox: (bbox: VectorSelectionBBox | null) => void
|
||||
onRunMapSelectionExtract: (bbox: VectorSelectionBBox, areaId?: string) => Promise<VectorSelectionResponse | null>
|
||||
onClearMapSelectionExtract: () => void
|
||||
onExportMapSelection: (bbox: VectorSelectionBBox, areaId?: string) => Promise<unknown>
|
||||
onPersistMapResult: (payload: MapResultExportRequest) => Promise<unknown>
|
||||
onDeriveMapSelectionDataset: (bbox: VectorSelectionBBox, areaId?: string) => Promise<DatasetCreateResponse | null>
|
||||
onSelectMapQaReferenceDataset: (datasetId: string) => void
|
||||
onRunMapSelectionQa: (candidateDataset?: DatasetCreateResponse | null) => Promise<QaComparisonResult | null>
|
||||
onOpenMapSelectionQualityEvidence: () => void
|
||||
onRunOrthophotoAnalysis: (bbox: VectorSelectionBBox) => Promise<boolean>
|
||||
onSelectOrthophotoProduct: (productKey: string) => void
|
||||
onClearQualityEvidence?: () => void
|
||||
onRefreshProjectData: () => Promise<unknown>
|
||||
onOpenAssistant: () => void
|
||||
onOpenExports: () => void
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
/**
|
||||
* The map workspace's domain layer: which themes exist, which persisted dataset
|
||||
* answers a theme, and how a dataset describes itself to an operator.
|
||||
*
|
||||
* This was ~590 lines at the top of MapWorkspace.tsx, above a 3.200-line
|
||||
* component. None of it is React, all of it is testable on its own, and both
|
||||
* render paths in that component read from it.
|
||||
*/
|
||||
|
||||
import type {
|
||||
CoverageStatus,
|
||||
DatasetCreateResponse,
|
||||
VectorSelectionBBox,
|
||||
} from '../../types'
|
||||
import type { MapThemeAcquisition } from '../../hooks/useMapThemeSelectionInsights'
|
||||
import { getDatasetDisplayName } from '../../lib/datasetDisplay'
|
||||
import {
|
||||
deduplicateTemporalSnapshots,
|
||||
isSelectionBoundedDataset,
|
||||
selectedAreaCoverageZones,
|
||||
selectionAnalysisScale,
|
||||
selectionDimensions,
|
||||
temporalDatasetAreaMatch,
|
||||
} from './mapWorkspaceUtils'
|
||||
|
||||
export const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson'
|
||||
export const DEFAULT_AREA_SELECTION_FILENAME = 'area-selection.geojson'
|
||||
export const EMPTY_TEMPORAL_SERIES: DatasetCreateResponse[] = []
|
||||
|
||||
export type DataThemeId =
|
||||
| 'administrative'
|
||||
| 'buildings'
|
||||
| 'land_cover'
|
||||
| 'space_occupation'
|
||||
| 'open_space'
|
||||
| 'population'
|
||||
| 'forest'
|
||||
| 'nature_value'
|
||||
| 'agriculture'
|
||||
| 'soil'
|
||||
| 'water'
|
||||
| 'bathymetry'
|
||||
| 'flood_hazard'
|
||||
| 'elevation'
|
||||
| 'accessibility'
|
||||
| 'services'
|
||||
| 'roads'
|
||||
| 'parcels'
|
||||
| 'maritime_planning'
|
||||
| 'marine_environment'
|
||||
|
||||
export interface DataTheme {
|
||||
id: DataThemeId
|
||||
label: string
|
||||
shortLabel: string
|
||||
description: string
|
||||
tokens: string[]
|
||||
}
|
||||
|
||||
export interface TemporalSeriesGroup {
|
||||
key: string
|
||||
label: string
|
||||
items: DatasetCreateResponse[]
|
||||
}
|
||||
|
||||
export interface OnDemandMapProduct extends MapThemeAcquisition {
|
||||
theme: DataThemeId
|
||||
availabilityLabel: string
|
||||
attribution: string
|
||||
limitationMessage: string
|
||||
coverageZones: string[]
|
||||
}
|
||||
|
||||
export interface PlannedOnDemandMapProduct extends OnDemandMapProduct {
|
||||
acquisitionBboxes: VectorSelectionBBox[]
|
||||
}
|
||||
|
||||
export function productSupportsSelection(product: OnDemandMapProduct, bbox: VectorSelectionBBox): boolean {
|
||||
const scale = selectionAnalysisScale(bbox)
|
||||
if (scale === 'overview') return true
|
||||
const dimensions = selectionDimensions(bbox)
|
||||
if (product.kind === 'dhmv' || product.kind === 'spw_terrain' || product.kind === 'flood_hazard') {
|
||||
return dimensions.areaSquareMetres <= 280_000_000
|
||||
}
|
||||
if (product.kind === 'thematic_raster' || product.kind === 'walous') {
|
||||
return dimensions.widthMetres <= 50_000
|
||||
&& dimensions.heightMetres <= 50_000
|
||||
&& dimensions.areaSquareMetres <= 2_800_000_000
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export const DATA_THEMES: DataTheme[] = [
|
||||
{
|
||||
id: 'administrative',
|
||||
label: 'Bestuurlijke indeling',
|
||||
shortLabel: 'Bestuursgebieden',
|
||||
description: 'Officiële lands-, gewest-, provincie- en gemeentegrenzen van het NGI.',
|
||||
tokens: ['administrative', 'adminvector', 'belgium_land_boundary', 'belgium_regions', 'belgium_provinces', 'belgium_municipalities'],
|
||||
},
|
||||
{
|
||||
id: 'buildings',
|
||||
label: 'Bebouwing',
|
||||
shortLabel: 'Gebouwen',
|
||||
description: 'Gebouwen en gebouwcontouren uit GRB of een andere persistente bron.',
|
||||
tokens: ['buildings', 'building', 'gebouwen', 'gebouw', 'bebouwing', 'gbg'],
|
||||
},
|
||||
{
|
||||
id: 'land_cover',
|
||||
label: 'Landbedekking',
|
||||
shortLabel: 'Landbedekking',
|
||||
description: 'Fysieke en biologische bodembedekking uit een officieel regionaal classificatieraster.',
|
||||
tokens: ['land_cover', 'land_cover_use', 'landbedekking', 'walous', 'occupation du sol'],
|
||||
},
|
||||
{
|
||||
id: 'space_occupation',
|
||||
label: 'Ruimtebeslag',
|
||||
shortLabel: 'Ruimtebeslag',
|
||||
description: 'Officiële 10 m-beleidskaart van ruimte ingenomen door wonen, economie, infrastructuur en recreatie.',
|
||||
tokens: ['space_occupation', 'ruimtebeslag', 'ruibes'],
|
||||
},
|
||||
{
|
||||
id: 'open_space',
|
||||
label: 'Open ruimte',
|
||||
shortLabel: 'Open ruimte',
|
||||
description: 'Officiële 10 m-beleidskaart van open ruimte buiten kernen en ruimtebeslag.',
|
||||
tokens: ['open_space', 'open ruimte', 'openruimte'],
|
||||
},
|
||||
{
|
||||
id: 'population',
|
||||
label: 'Bevolking',
|
||||
shortLabel: 'Inwoners',
|
||||
description: 'Bevolkingscijfers of statistische raster- en vectorzones.',
|
||||
tokens: ['population', 'bevolking', 'inwoners', 'inhabitants', 'census'],
|
||||
},
|
||||
{
|
||||
id: 'forest',
|
||||
label: 'Bos & groen',
|
||||
shortLabel: 'Bos',
|
||||
description: 'Bos, natuur en groenbedekking uit een ingeladen vectorbron.',
|
||||
tokens: ['forest', 'forestry', 'woodland', 'bos', 'groen', 'vegetation'],
|
||||
},
|
||||
{
|
||||
id: 'nature_value',
|
||||
label: 'Natuurwaarde',
|
||||
shortLabel: 'BWK-oppervlakte',
|
||||
description: 'Biologische waardering, Natura 2000-habitat en regionaal belangrijke biotopen uit de BWK.',
|
||||
tokens: ['nature_value', 'nature value', 'natuurwaarde', 'bwk', 'natura2000', 'natura 2000', 'biodiversity'],
|
||||
},
|
||||
{
|
||||
id: 'agriculture',
|
||||
label: 'Landbouw',
|
||||
shortLabel: 'Landbouwgebruik',
|
||||
description: 'Jaarlijkse officiële landbouwgebruikspercelen en hoofdteeltgroepen.',
|
||||
tokens: ['agriculture', 'agricultural', 'landbouw', 'landbouwgebruik', 'agpa'],
|
||||
},
|
||||
{
|
||||
id: 'soil',
|
||||
label: 'Bodem',
|
||||
shortLabel: 'Bodemkaart',
|
||||
description: 'Officiele bodemkartering met bodemtype, textuur en drainageklasse waar de geselecteerde zone door een gekoppelde bron wordt gedekt.',
|
||||
tokens: ['soil', 'bodem', 'bodemkaart', 'bodemtype', 'dov_soil_map'],
|
||||
},
|
||||
{
|
||||
id: 'water',
|
||||
label: 'Water',
|
||||
shortLabel: 'Water',
|
||||
description: 'Waterlopen, grachten, kanalen en wateroppervlakken.',
|
||||
tokens: ['waterways', 'waterway', 'water', 'hydro', 'river', 'stream', 'canal', 'waterloop'],
|
||||
},
|
||||
{
|
||||
id: 'bathymetry',
|
||||
label: 'Waterbodem',
|
||||
shortLabel: 'Dwarsprofielen',
|
||||
description: 'Officiële historische VHA-dwarsprofielen met meetvelden en brondocumenten.',
|
||||
tokens: ['bathymetry', 'bathymetry_profiles', 'dwarsprofielen', 'waterbodem'],
|
||||
},
|
||||
{
|
||||
id: 'flood_hazard',
|
||||
label: 'Overstroming',
|
||||
shortLabel: 'Overstroomd oppervlak',
|
||||
description: 'Gemodelleerde maximale waterdiepte per VMM-kans- en klimaatscenario.',
|
||||
tokens: ['flood_hazard', 'flood depth', 'flood_depth', 'overstroming', 'waterdiepte'],
|
||||
},
|
||||
{
|
||||
id: 'elevation',
|
||||
label: 'Hoogte & reliëf',
|
||||
shortLabel: 'Hoogte',
|
||||
description: 'Maaiveld- of oppervlaktehoogte, reliëf en helling uit DHMV II.',
|
||||
tokens: ['dhmv', 'elevation', 'height', 'hoogte', 'terrain', 'surface', 'dtm', 'dsm', 'reliëf'],
|
||||
},
|
||||
{
|
||||
id: 'accessibility',
|
||||
label: 'Bereikbaarheid',
|
||||
shortLabel: 'Knooppuntwaarde',
|
||||
description: 'Knooppuntwaarde van collectief vervoer per hectare voor referentiejaar 2022.',
|
||||
tokens: ['accessibility', 'bereikbaarheid', 'knooppuntwaarde', 'knptw'],
|
||||
},
|
||||
{
|
||||
id: 'services',
|
||||
label: 'Voorzieningen',
|
||||
shortLabel: 'Voorzieningenniveau',
|
||||
description: 'Genormaliseerde nabijheid van basis-, regionale en metropolitane voorzieningen in 2022.',
|
||||
tokens: ['services', 'voorzieningen', 'voorzieningenniveau', 'totvznv'],
|
||||
},
|
||||
{
|
||||
id: 'roads',
|
||||
label: 'Wegen',
|
||||
shortLabel: 'Wegen',
|
||||
description: 'Wegen en wegsegmenten uit een persistente bron.',
|
||||
tokens: ['roads', 'road', 'wegen', 'wegsegment', 'street'],
|
||||
},
|
||||
{
|
||||
id: 'parcels',
|
||||
label: 'Percelen',
|
||||
shortLabel: 'Percelen',
|
||||
description: 'Kadastrale of administratieve perceelcontouren.',
|
||||
tokens: ['parcels', 'parcel', 'percelen', 'perceel', 'cadastre', 'kadaster'],
|
||||
},
|
||||
{
|
||||
id: 'maritime_planning',
|
||||
label: 'Maritieme planning',
|
||||
shortLabel: 'Plan- en gebruikszones',
|
||||
description: 'Officiële gebruiks- en beschermingszones uit het Belgisch Marien Ruimtelijk Plan 2026-2034.',
|
||||
tokens: ['maritime_planning', 'marine_spatial_plan', 'rbins_msp', 'bmsp', 'imsp26'],
|
||||
},
|
||||
{
|
||||
id: 'marine_environment',
|
||||
label: 'Mariene rapportagezones',
|
||||
shortLabel: 'Zeegebieden',
|
||||
description: 'Officiële juridische en mariene rapportagegebieden voor het Belgische deel van de Noordzee.',
|
||||
tokens: ['marine_environment', 'marine_legal_scopes', 'marine_reporting_units', 'rbins_marine_reporting'],
|
||||
},
|
||||
]
|
||||
|
||||
export const COVERAGE_THEME_BY_MAP_THEME: Record<DataThemeId, string> = {
|
||||
administrative: 'admin',
|
||||
buildings: 'buildings',
|
||||
land_cover: 'land_cover_use',
|
||||
space_occupation: 'land_cover_use',
|
||||
open_space: 'land_cover_use',
|
||||
population: 'population',
|
||||
forest: 'land_cover_use',
|
||||
nature_value: 'nature',
|
||||
agriculture: 'land_cover_use',
|
||||
soil: 'soil',
|
||||
water: 'surface_water',
|
||||
bathymetry: 'bathymetry',
|
||||
flood_hazard: 'flood_climate',
|
||||
elevation: 'elevation',
|
||||
accessibility: 'roads',
|
||||
services: 'population',
|
||||
roads: 'roads',
|
||||
parcels: 'parcels',
|
||||
maritime_planning: 'maritime_planning',
|
||||
marine_environment: 'marine_environment',
|
||||
}
|
||||
|
||||
export function coverageStatusLabel(status: CoverageStatus): string {
|
||||
const labels: Record<CoverageStatus, string> = {
|
||||
operational: 'Beschikbaar',
|
||||
partial: 'Gedeeltelijk',
|
||||
not_configured: 'Niet gekoppeld',
|
||||
unsupported: 'Niet ondersteund',
|
||||
}
|
||||
return labels[status]
|
||||
}
|
||||
|
||||
export function coverageZoneLabel(zone: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
belgium: 'België',
|
||||
flanders: 'Vlaanderen',
|
||||
wallonia: 'Wallonie',
|
||||
brussels: 'Brussel',
|
||||
belgian_north_sea: 'Belgische Noordzee',
|
||||
territorial_sea: 'Territoriale zee',
|
||||
exclusive_economic_zone: 'EEZ',
|
||||
continental_shelf: 'Continentaal plat',
|
||||
}
|
||||
return labels[zone] ?? zone
|
||||
}
|
||||
|
||||
export const DATA_THEME_MAP_STYLES: Record<DataThemeId, { fill: string; line: string }> = {
|
||||
administrative: { fill: '#5f6f7f', line: '#344554' },
|
||||
buildings: { fill: '#d45f3d', line: '#9f3e24' },
|
||||
land_cover: { fill: '#4f7b4f', line: '#315c39' },
|
||||
space_occupation: { fill: '#be3e33', line: '#8f2c24' },
|
||||
open_space: { fill: '#267a46', line: '#175c32' },
|
||||
population: { fill: '#7559a6', line: '#5b3f88' },
|
||||
forest: { fill: '#347950', line: '#225f3b' },
|
||||
nature_value: { fill: '#9a4f64', line: '#74364a' },
|
||||
agriculture: { fill: '#7b8f32', line: '#53671d' },
|
||||
soil: { fill: '#9a7040', line: '#6f4c27' },
|
||||
water: { fill: '#2676a8', line: '#155b85' },
|
||||
bathymetry: { fill: '#0e7490', line: '#164e63' },
|
||||
flood_hazard: { fill: '#1597c2', line: '#075985' },
|
||||
elevation: { fill: '#a57a4b', line: '#315f59' },
|
||||
accessibility: { fill: '#0f766e', line: '#115e59' },
|
||||
services: { fill: '#b66d16', line: '#854d0e' },
|
||||
roads: { fill: '#6b7280', line: '#4b5563' },
|
||||
parcels: { fill: '#a7792f', line: '#7d571f' },
|
||||
maritime_planning: { fill: '#2f7f8f', line: '#145d6a' },
|
||||
marine_environment: { fill: '#3475a3', line: '#1c557d' },
|
||||
}
|
||||
|
||||
export function datasetSearchText(dataset: DatasetCreateResponse): string {
|
||||
return [
|
||||
dataset.name,
|
||||
dataset.original_filename,
|
||||
dataset.source,
|
||||
dataset.source_name,
|
||||
dataset.reference_layer_name,
|
||||
dataset.metadata_json?.['layer_name'],
|
||||
dataset.source_metadata?.['layer_name'],
|
||||
dataset.source_metadata?.['theme'],
|
||||
dataset.source_metadata?.['product_display_name'],
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
export function datasetMatchesTheme(dataset: DatasetCreateResponse, theme: DataTheme): boolean {
|
||||
// Governed raster products have one unambiguous semantic theme. Matching
|
||||
// them by generic substrings (for example "water" in "waterdiepte") would
|
||||
// make a flood scenario replace the permanent surface-water layer.
|
||||
if (dataset.source_name === 'vmm_flood_hazard') {
|
||||
return theme.id === 'flood_hazard'
|
||||
}
|
||||
if (dataset.source_name === 'vmm_vha_bathymetry_profiles') {
|
||||
return theme.id === 'bathymetry'
|
||||
}
|
||||
if (dataset.source_name === 'spw_bathymetry') {
|
||||
return theme.id === 'bathymetry'
|
||||
}
|
||||
if (['digitaal_vlaanderen_dhmv', 'spw_terrain'].includes(dataset.source_name ?? '')) {
|
||||
return theme.id === 'elevation'
|
||||
}
|
||||
if (dataset.source_name === 'department_omgeving_thematic_raster') {
|
||||
return dataset.source_metadata?.['theme'] === theme.id
|
||||
}
|
||||
if (dataset.source_name === 'spw_walous_land_cover') {
|
||||
return theme.id === 'land_cover'
|
||||
}
|
||||
if (dataset.source_name === 'dov_soil_map') {
|
||||
return theme.id === 'soil'
|
||||
}
|
||||
if (dataset.source_name === 'ngi_adminvector') {
|
||||
return theme.id === 'administrative'
|
||||
}
|
||||
if (dataset.source_name === 'rbins_msp_2026') {
|
||||
return theme.id === 'maritime_planning'
|
||||
}
|
||||
if (dataset.source_name === 'rbins_marine_reporting_units') {
|
||||
return theme.id === 'marine_environment'
|
||||
}
|
||||
const searchText = datasetSearchText(dataset)
|
||||
return theme.tokens.some((token) => searchText.includes(token))
|
||||
}
|
||||
|
||||
export function isPartitionedRaster(dataset: DatasetCreateResponse | null | undefined): boolean {
|
||||
return Boolean(
|
||||
dataset?.dataset_type === 'raster'
|
||||
&& ['digitaal_vlaanderen_dhmv', 'spw_terrain', 'vmm_flood_hazard'].includes(dataset.source_name ?? ''),
|
||||
)
|
||||
}
|
||||
|
||||
export function isPartitionedBathymetry(dataset: DatasetCreateResponse | null | undefined): boolean {
|
||||
return Boolean(
|
||||
dataset?.source_name === 'vmm_vha_bathymetry_profiles'
|
||||
&& dataset.source_metadata?.['regional_partitions_complete'] === true,
|
||||
)
|
||||
}
|
||||
|
||||
export function datasetProductKey(dataset: DatasetCreateResponse): string {
|
||||
return String(dataset.source_metadata?.['product_key'] ?? '')
|
||||
}
|
||||
|
||||
export function datasetCoversSelectedArea(
|
||||
dataset: DatasetCreateResponse,
|
||||
selectedAreaId: string | null,
|
||||
selectedAreaName: string | null | undefined,
|
||||
regionalScope = false,
|
||||
): boolean {
|
||||
if (isSelectionBoundedDataset(dataset.source_metadata)) {
|
||||
return false
|
||||
}
|
||||
const selectedZones = selectedAreaCoverageZones(selectedAreaName)
|
||||
const configuredZones = dataset.source_metadata?.['coverage_zones']
|
||||
if (selectedZones && Array.isArray(configuredZones) && configuredZones.length > 0) {
|
||||
const datasetZones = configuredZones.map((zone) => String(zone))
|
||||
if (!selectedZones.some((zone) => datasetZones.includes(zone))) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const coverageScope = String(dataset.source_metadata?.['coverage_scope'] ?? '')
|
||||
if (isPartitionedBathymetry(dataset)) {
|
||||
return regionalScope
|
||||
? true
|
||||
: Boolean(selectedAreaId) && dataset.area_id === selectedAreaId
|
||||
}
|
||||
if (coverageScope !== 'municipality' || !dataset.area_id) {
|
||||
return true
|
||||
}
|
||||
if (regionalScope) {
|
||||
return true
|
||||
}
|
||||
return Boolean(selectedAreaId) && dataset.area_id === selectedAreaId
|
||||
}
|
||||
|
||||
export function rasterPartitionsForDataset(
|
||||
datasets: DatasetCreateResponse[],
|
||||
representative: DatasetCreateResponse | null,
|
||||
selectedAreaId: string | null,
|
||||
selectedAreaName: string | null | undefined,
|
||||
regionalScope: boolean,
|
||||
): DatasetCreateResponse[] {
|
||||
if (!representative) {
|
||||
return []
|
||||
}
|
||||
if (!regionalScope || (!isPartitionedRaster(representative) && !isPartitionedBathymetry(representative))) {
|
||||
return [representative]
|
||||
}
|
||||
const productKey = datasetProductKey(representative)
|
||||
const manifestSha256 = String(representative.source_metadata?.['partition_manifest_sha256'] ?? '')
|
||||
return datasets
|
||||
.filter(
|
||||
(dataset) =>
|
||||
dataset.source_name === representative.source_name
|
||||
&& (
|
||||
isPartitionedBathymetry(representative)
|
||||
? String(dataset.source_metadata?.['partition_manifest_sha256'] ?? '') === manifestSha256
|
||||
: datasetProductKey(dataset) === productKey
|
||||
)
|
||||
&& datasetCoversSelectedArea(dataset, selectedAreaId, selectedAreaName, true),
|
||||
)
|
||||
.sort((left, right) => String(left.area_id ?? '').localeCompare(String(right.area_id ?? '')))
|
||||
}
|
||||
|
||||
export function pickThemeDataset(
|
||||
datasets: DatasetCreateResponse[],
|
||||
theme: DataTheme,
|
||||
selectedAreaId: string | null,
|
||||
selectedAreaName: string | null | undefined,
|
||||
regionalScope = false,
|
||||
): DatasetCreateResponse | null {
|
||||
const candidates = datasets.filter(
|
||||
(dataset) =>
|
||||
datasetMatchesTheme(dataset, theme)
|
||||
&& datasetCoversSelectedArea(dataset, selectedAreaId, selectedAreaName, regionalScope),
|
||||
)
|
||||
candidates.sort((left, right) => {
|
||||
const priorityScore = (dataset: DatasetCreateResponse) =>
|
||||
(dataset.area_id && dataset.area_id === selectedAreaId ? 10_000_000 : 0) +
|
||||
(dataset.reference_layer_name && theme.tokens.includes(dataset.reference_layer_name.toLowerCase()) ? 1_000_000 : 0) +
|
||||
(dataset.source_name === 'grb' ? 100_000 : 0) +
|
||||
(dataset.source_name === 'department_omgeving_land_use' ? 90_000 : 0) +
|
||||
(dataset.source_name === 'inbo_bwk_natura2000' ? 95_000 : 0) +
|
||||
(dataset.source_name === 'agentschap_landbouw_zeevisserij_agricultural_parcels' ? 98_000 : 0) +
|
||||
(dataset.source_name === 'department_omgeving_thematic_raster' ? 5_000_000 : 0) +
|
||||
(dataset.source_name === 'spw_walous_land_cover' ? 5_000_000 : 0) +
|
||||
(dataset.source_name === 'digitaal_vlaanderen_buildings_addresses_register' ? 5_000_000 : 0) +
|
||||
(dataset.source_name === 'digitaal_vlaanderen_dhmv' ? 5_000_000 : 0) +
|
||||
(dataset.source_name === 'spw_terrain' ? 5_000_000 : 0) +
|
||||
(dataset.source_name === 'vmm_flood_hazard' ? 5_000_000 : 0) +
|
||||
(dataset.source_name === 'vmm_vha_bathymetry_profiles' ? 5_000_000 : 0) +
|
||||
(dataset.source_name === 'spw_bathymetry' ? 5_100_000 : 0) +
|
||||
(dataset.source_metadata?.['product_key'] === 'dtm_1m' ? 1_000_000 : 0) +
|
||||
(dataset.source_metadata?.['product_key'] === 'pluviaal_current_t100' ? 1_000_000 : 0) +
|
||||
(dataset.dataset_role === 'reference' ? 10_000 : 0)
|
||||
const priorityDifference = priorityScore(right) - priorityScore(left)
|
||||
if (priorityDifference !== 0) return priorityDifference
|
||||
|
||||
const observedAtDifference = new Date(right.observed_at ?? 0).getTime() - new Date(left.observed_at ?? 0).getTime()
|
||||
if (observedAtDifference !== 0) return observedAtDifference
|
||||
|
||||
const importedAtDifference = new Date(right.imported_at ?? 0).getTime() - new Date(left.imported_at ?? 0).getTime()
|
||||
if (importedAtDifference !== 0) return importedAtDifference
|
||||
|
||||
return (right.feature_count ?? right.vector_summary?.feature_count ?? 0)
|
||||
- (left.feature_count ?? left.vector_summary?.feature_count ?? 0)
|
||||
})
|
||||
return candidates[0] ?? null
|
||||
}
|
||||
|
||||
export function themeIdForDataset(dataset: DatasetCreateResponse | null): DataThemeId | null {
|
||||
return dataset
|
||||
? DATA_THEMES.find((theme) => datasetMatchesTheme(dataset, theme))?.id ?? null
|
||||
: null
|
||||
}
|
||||
|
||||
export function temporalSeriesLabel(items: DatasetCreateResponse[]): string {
|
||||
const configuredLabel = items.find((item) => typeof item.source_metadata?.['temporal_series_label'] === 'string')
|
||||
?.source_metadata?.['temporal_series_label']
|
||||
if (typeof configuredLabel === 'string' && configuredLabel.trim()) {
|
||||
return configuredLabel
|
||||
}
|
||||
const first = items[0]
|
||||
const source = first ? getDatasetDisplayName(first) : 'Tijdreeks'
|
||||
const range = temporalRangeLabel(items)
|
||||
return range ? `${source} (${range})` : source
|
||||
}
|
||||
|
||||
export function listThemeTemporalSeries(
|
||||
datasets: DatasetCreateResponse[],
|
||||
theme: DataTheme,
|
||||
selectedAreaId: string | null,
|
||||
): TemporalSeriesGroup[] {
|
||||
const groups = new Map<string, DatasetCreateResponse[]>()
|
||||
for (const dataset of datasets) {
|
||||
if (!datasetMatchesTheme(dataset, theme) || !dataset.temporal_series_key || !dataset.observed_at) {
|
||||
continue
|
||||
}
|
||||
if (temporalDatasetAreaMatch(dataset, selectedAreaId) === 'other') {
|
||||
continue
|
||||
}
|
||||
const items = groups.get(dataset.temporal_series_key) ?? []
|
||||
items.push(dataset)
|
||||
groups.set(dataset.temporal_series_key, items)
|
||||
}
|
||||
const series = Array.from(groups.entries())
|
||||
.map(([key, items]) => {
|
||||
const ordered = deduplicateTemporalSnapshots(items)
|
||||
return { key, label: temporalSeriesLabel(ordered), items: ordered }
|
||||
})
|
||||
.filter((group) => group.items.length >= 2)
|
||||
|
||||
const sourcesWithExactAreaSeries = new Set(
|
||||
series
|
||||
.filter((group) => group.items.some((item) => temporalDatasetAreaMatch(item, selectedAreaId) === 'exact'))
|
||||
.map((group) => group.items[0]?.source_name)
|
||||
.filter(Boolean),
|
||||
)
|
||||
|
||||
return series
|
||||
.filter((group) => {
|
||||
const sourceName = group.items[0]?.source_name
|
||||
if (!sourceName || !sourcesWithExactAreaSeries.has(sourceName)) return true
|
||||
return group.items.some((item) => temporalDatasetAreaMatch(item, selectedAreaId) === 'exact')
|
||||
})
|
||||
.sort((left, right) => {
|
||||
if (right.items.length !== left.items.length) {
|
||||
return right.items.length - left.items.length
|
||||
}
|
||||
const latest = (group: TemporalSeriesGroup) => Math.max(...group.items.map((item) => new Date(item.observed_at ?? 0).getTime()))
|
||||
return latest(right) - latest(left)
|
||||
})
|
||||
}
|
||||
|
||||
export function formatObservationDate(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return 'Geen peildatum'
|
||||
}
|
||||
return new Intl.DateTimeFormat('nl-BE', { year: 'numeric', month: 'short', day: 'numeric' }).format(new Date(value))
|
||||
}
|
||||
|
||||
export function temporalRangeLabel(items: DatasetCreateResponse[]): string | null {
|
||||
const firstValue = items[0]?.observed_at
|
||||
const lastValue = items[items.length - 1]?.observed_at
|
||||
if (!firstValue || !lastValue) {
|
||||
return null
|
||||
}
|
||||
const first = new Date(firstValue)
|
||||
const last = new Date(lastValue)
|
||||
if (first.getTime() === last.getTime()) {
|
||||
return formatObservationDate(firstValue)
|
||||
}
|
||||
if (first.getUTCFullYear() !== last.getUTCFullYear()) {
|
||||
return `${first.getUTCFullYear()}-${last.getUTCFullYear()}`
|
||||
}
|
||||
if (first.getUTCMonth() === last.getUTCMonth()) {
|
||||
const monthAndYear = new Intl.DateTimeFormat('nl-BE', { year: 'numeric', month: 'short' }).format(last)
|
||||
return `${first.getUTCDate()}-${last.getUTCDate()} ${monthAndYear}`
|
||||
}
|
||||
return `${formatObservationDate(firstValue)} - ${formatObservationDate(lastValue)}`
|
||||
}
|
||||
|
||||
export function formatDatasetObservation(dataset: DatasetCreateResponse): string {
|
||||
if (dataset.source_name === 'vmm_flood_hazard') {
|
||||
return `scenario ${String(dataset.source_metadata?.['climate_context'] ?? '')} · ${String(dataset.source_metadata?.['probability_class'] ?? '')}`
|
||||
}
|
||||
if (dataset.source_name === 'vmm_vha_bathymetry_profiles') {
|
||||
const firstMeasurement = String(dataset.source_metadata?.['measurement_date_min'] ?? '').slice(0, 4)
|
||||
const lastMeasurement = String(dataset.source_metadata?.['measurement_date_max'] ?? '').slice(0, 4)
|
||||
return firstMeasurement && lastMeasurement
|
||||
? `historische profielen ${firstMeasurement}-${lastMeasurement}`
|
||||
: 'historische profielmetingen'
|
||||
}
|
||||
if (dataset.source_name === 'spw_bathymetry') {
|
||||
return `samengestelde waterbodemmeting ${String(dataset.source_metadata?.['survey_period'] ?? '2019-2022')} · mDNG`
|
||||
}
|
||||
if (dataset.source_name === 'department_omgeving_thematic_raster') {
|
||||
const observationYear = Number(dataset.source_metadata?.['observation_year'])
|
||||
if (Number.isFinite(observationYear)) {
|
||||
return `referentiejaar ${observationYear}`
|
||||
}
|
||||
}
|
||||
if (dataset.source_name === 'spw_walous_land_cover') {
|
||||
const observationYear = Number(dataset.source_metadata?.['observation_year'])
|
||||
return Number.isFinite(observationYear) ? `WALOUS referentiejaar ${observationYear}` : 'WALOUS landbedekking'
|
||||
}
|
||||
const period = dataset.source_metadata?.['acquisition_period']
|
||||
if (typeof period === 'string' && period.trim()) {
|
||||
return `opnameperiode ${period}`
|
||||
}
|
||||
return formatObservationDate(dataset.observed_at)
|
||||
}
|
||||
|
||||
export function floodScenarioLabel(dataset: DatasetCreateResponse): string {
|
||||
const configured = dataset.source_metadata?.['product_display_name']
|
||||
return typeof configured === 'string' && configured.trim() ? configured : getDatasetDisplayName(dataset)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
bboxesEqual,
|
||||
datasetIntersectsSelection,
|
||||
deduplicateTemporalSnapshots,
|
||||
isMunicipalityAreaName,
|
||||
isSelectionBoundedDataset,
|
||||
normalizeBboxFromCorners,
|
||||
parseBboxInput,
|
||||
persistedDatasetSupportsSelection,
|
||||
productCoversZones,
|
||||
resultMetricLabel,
|
||||
selectedAreaCoverageZones,
|
||||
selectionAnalysisScale,
|
||||
selectionAreaSquareMetres,
|
||||
selectionDimensions,
|
||||
selectionFeatureLimit,
|
||||
splitSelectionBbox,
|
||||
temporalDatasetAreaMatch,
|
||||
} from './mapWorkspaceUtils'
|
||||
import type { VectorSelectionBBox, VectorSelectionResponse } from '../../types'
|
||||
|
||||
describe('map workspace selection guards', () => {
|
||||
it('normalizes drag corners into an EPSG:4326 bbox', () => {
|
||||
expect(normalizeBboxFromCorners([5.2, 51.3], [4.8, 50.9])).toEqual({
|
||||
min_x: 4.8,
|
||||
min_y: 50.9,
|
||||
max_x: 5.2,
|
||||
max_y: 51.3,
|
||||
crs: 'EPSG:4326',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects empty, inverted and degenerate manual selections', () => {
|
||||
expect(parseBboxInput({ min_x: '', min_y: '', max_x: '', max_y: '' })).toBeNull()
|
||||
expect(parseBboxInput({ min_x: '5', min_y: '51', max_x: '4', max_y: '52' })).toBeNull()
|
||||
expect(parseBboxInput({ min_x: '4', min_y: '51', max_x: '4', max_y: '52' })).toBeNull()
|
||||
})
|
||||
|
||||
it('treats sub-nanodegree bbox drift as the same persisted selection', () => {
|
||||
const bbox = normalizeBboxFromCorners([4.98, 51.15], [5.02, 51.19])
|
||||
expect(bboxesEqual(bbox, { ...bbox, max_x: bbox.max_x + 1e-10 })).toBe(true)
|
||||
expect(bboxesEqual(bbox, { ...bbox, max_x: bbox.max_x + 1e-5 })).toBe(false)
|
||||
})
|
||||
|
||||
it('computes a positive bounded selection area and renders governed metrics', () => {
|
||||
const bbox = normalizeBboxFromCorners([5.0, 51.0], [5.01, 51.01])
|
||||
expect(selectionAreaSquareMetres(bbox)).toBeGreaterThan(700_000)
|
||||
expect(selectionAreaSquareMetres(bbox)).toBeLessThan(900_000)
|
||||
|
||||
const result = {
|
||||
feature_count: 12,
|
||||
truncated: false,
|
||||
summary: {
|
||||
metric_key: 'forest_area',
|
||||
metric_value: 14.236,
|
||||
metric_unit: 'ha',
|
||||
},
|
||||
} as unknown as VectorSelectionResponse
|
||||
expect(resultMetricLabel(result)).toBe('14,24 ha')
|
||||
})
|
||||
|
||||
it('maps national work areas to regional provider zones without merging sources', () => {
|
||||
expect(selectedAreaCoverageZones('Belgium land')).toEqual([
|
||||
'belgium',
|
||||
'flanders',
|
||||
'wallonia',
|
||||
'brussels',
|
||||
])
|
||||
expect(selectedAreaCoverageZones('RC Ardennes inland')).toEqual(['wallonia'])
|
||||
expect(selectedAreaCoverageZones('Brussels-Capital Region')).toEqual(['brussels'])
|
||||
expect(selectedAreaCoverageZones('Language boundary')).toEqual(['flanders', 'wallonia'])
|
||||
expect(productCoversZones(['wallonia'], ['flanders', 'wallonia'])).toBe(true)
|
||||
expect(productCoversZones(['brussels'], ['wallonia'])).toBe(false)
|
||||
})
|
||||
|
||||
it('recognizes Dutch and release-golden municipality areas as local scope', () => {
|
||||
expect(isMunicipalityAreaName('Gemeente Mol')).toBe(true)
|
||||
expect(isMunicipalityAreaName('RC Golden - Mol municipality')).toBe(true)
|
||||
expect(isMunicipalityAreaName('Flanders')).toBe(false)
|
||||
expect(isMunicipalityAreaName('RC Golden - Kempen region')).toBe(false)
|
||||
})
|
||||
|
||||
it('distinguishes selection-bounded acquisitions from reusable regional datasets', () => {
|
||||
expect(isSelectionBoundedDataset({
|
||||
geometry_clipped_to_selection: true,
|
||||
bbox_epsg4326: [4.55, 50.58, 4.56, 50.59],
|
||||
})).toBe(true)
|
||||
expect(isSelectionBoundedDataset({
|
||||
coverage_scope: 'bounded_selection',
|
||||
bbox_epsg4326: [5.1, 51.17, 5.11, 51.18],
|
||||
})).toBe(true)
|
||||
expect(isSelectionBoundedDataset({
|
||||
geometry_clipped_to_selection: false,
|
||||
bbox_epsg4326: [2.5, 49.5, 6.4, 51.6],
|
||||
})).toBe(false)
|
||||
expect(isSelectionBoundedDataset({
|
||||
geometry_clipped_to_selection: true,
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('distinguishes exact, unscoped and foreign temporal work-area snapshots', () => {
|
||||
expect(temporalDatasetAreaMatch({ area_id: 'wallonia-golden' }, 'wallonia-golden')).toBe('exact')
|
||||
expect(temporalDatasetAreaMatch({ area_id: 'other-area' }, 'wallonia-golden')).toBe('other')
|
||||
expect(temporalDatasetAreaMatch({ area_id: null }, 'wallonia-golden')).toBe('unscoped')
|
||||
})
|
||||
|
||||
it('classifies local, regional and overview selections before provider calls', () => {
|
||||
const bbox = (widthDegrees: number, heightDegrees: number): VectorSelectionBBox => ({
|
||||
min_x: 5,
|
||||
min_y: 51,
|
||||
max_x: 5 + widthDegrees,
|
||||
max_y: 51 + heightDegrees,
|
||||
crs: 'EPSG:4326',
|
||||
})
|
||||
expect(selectionAnalysisScale(bbox(0.1, 0.1))).toBe('detail')
|
||||
expect(selectionAnalysisScale(bbox(0.35, 0.25))).toBe('regional')
|
||||
expect(selectionAnalysisScale(bbox(1, 1))).toBe('overview')
|
||||
})
|
||||
|
||||
it('splits regional selections into provider-safe tiles without changing the outer bounds', () => {
|
||||
const selection: VectorSelectionBBox = {
|
||||
min_x: 5,
|
||||
min_y: 51,
|
||||
max_x: 5.5,
|
||||
max_y: 51.35,
|
||||
crs: 'EPSG:4326',
|
||||
}
|
||||
const tiles = splitSelectionBbox(selection)
|
||||
expect(tiles.length).toBeGreaterThan(1)
|
||||
expect(Math.min(...tiles.map((tile) => tile.min_x))).toBe(selection.min_x)
|
||||
expect(Math.min(...tiles.map((tile) => tile.min_y))).toBe(selection.min_y)
|
||||
expect(Math.max(...tiles.map((tile) => tile.max_x))).toBe(selection.max_x)
|
||||
expect(Math.max(...tiles.map((tile) => tile.max_y))).toBe(selection.max_y)
|
||||
for (const tile of tiles) {
|
||||
const dimensions = selectionDimensions(tile)
|
||||
expect(dimensions.widthMetres).toBeLessThanOrEqual(18_100)
|
||||
expect(dimensions.heightMetres).toBeLessThanOrEqual(18_100)
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses an unbounded detail fan-out', () => {
|
||||
const selection: VectorSelectionBBox = {
|
||||
min_x: 4,
|
||||
min_y: 50,
|
||||
max_x: 6,
|
||||
max_y: 52,
|
||||
crs: 'EPSG:4326',
|
||||
}
|
||||
expect(() => splitSelectionBbox(selection)).toThrow('detailpartities')
|
||||
})
|
||||
|
||||
it('keeps persisted high-resolution rasters out of overview selections', () => {
|
||||
const overview: VectorSelectionBBox = {
|
||||
min_x: 2.5,
|
||||
min_y: 49.5,
|
||||
max_x: 6.4,
|
||||
max_y: 51.5,
|
||||
crs: 'EPSG:4326',
|
||||
}
|
||||
expect(persistedDatasetSupportsSelection(
|
||||
{ dataset_type: 'raster', source_name: 'digitaal_vlaanderen_dhmv' },
|
||||
overview,
|
||||
)).toBe(false)
|
||||
expect(persistedDatasetSupportsSelection(
|
||||
{ dataset_type: 'vector', source_name: 'statbel' },
|
||||
overview,
|
||||
)).toBe(true)
|
||||
expect(selectionFeatureLimit(overview)).toBe(25)
|
||||
expect(selectionFeatureLimit({ ...overview, min_x: 5, max_x: 5.1, min_y: 51, max_y: 51.1 })).toBe(1000)
|
||||
})
|
||||
|
||||
it('uses persisted raster partitions only where their recorded bounds overlap', () => {
|
||||
const molPartition = { source_metadata: { bbox_epsg4326: [5.03, 51.15, 5.24, 51.32] } }
|
||||
expect(datasetIntersectsSelection(molPartition, {
|
||||
min_x: 5.08,
|
||||
min_y: 51.17,
|
||||
max_x: 5.12,
|
||||
max_y: 51.2,
|
||||
crs: 'EPSG:4326',
|
||||
})).toBe(true)
|
||||
expect(datasetIntersectsSelection(molPartition, {
|
||||
min_x: 4.3,
|
||||
min_y: 50.8,
|
||||
max_x: 4.4,
|
||||
max_y: 50.9,
|
||||
crs: 'EPSG:4326',
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('counts one canonical temporal snapshot per official observation date', () => {
|
||||
const snapshots = deduplicateTemporalSnapshots([
|
||||
{ id: 'old-2025', observed_at: '2025-12-31T23:59:59Z', imported_at: '2026-07-19T00:00:00Z' },
|
||||
{ id: 'new-2025', observed_at: '2025-12-31T23:59:59Z', imported_at: '2026-07-21T00:00:00Z' },
|
||||
{ id: 'year-2022', observed_at: '2022-12-31T23:59:59Z', imported_at: '2026-07-21T00:00:00Z' },
|
||||
])
|
||||
expect(snapshots.map((dataset) => dataset.id)).toEqual(['year-2022', 'new-2025'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,438 @@
|
||||
import { featureCollectionBounds } from '../../lib/geojsonBounds'
|
||||
import type {
|
||||
DatasetCreateResponse,
|
||||
ProjectRead,
|
||||
VectorSelectionBBox,
|
||||
VectorSelectionMetric,
|
||||
VectorSelectionResponse,
|
||||
} from '../../types'
|
||||
import {
|
||||
FLANDERS_WORKSPACE_LABEL,
|
||||
FLANDERS_WORKSPACE_PROJECT_NAME,
|
||||
} from '../../config/primaryFocus'
|
||||
|
||||
const MOL_PROJECT_NAME = 'Mol Municipality Workbench'
|
||||
const KEMPEN_PROJECT_NAME = 'Kempen Regional Workbench'
|
||||
|
||||
export function selectedAreaCoverageZones(areaName: string | null | undefined): string[] | null {
|
||||
const normalized = String(areaName ?? '').toLowerCase()
|
||||
if (!normalized) return null
|
||||
if (normalized.includes('coast land-sea')) return ['flanders', 'belgian_north_sea']
|
||||
if (normalized.includes('language boundary')) return ['flanders', 'wallonia']
|
||||
if (normalized.includes('north sea')) {
|
||||
return ['belgian_north_sea', 'territorial_sea', 'exclusive_economic_zone', 'continental_shelf']
|
||||
}
|
||||
if (normalized.includes('territorial sea')) return ['territorial_sea']
|
||||
if (normalized.includes('exclusive economic zone')) return ['exclusive_economic_zone']
|
||||
if (normalized.includes('continental shelf')) return ['continental_shelf']
|
||||
if (normalized.includes('brussels')) return ['brussels']
|
||||
if (normalized.includes('wallonia') || normalized.includes('ardennes')) return ['wallonia']
|
||||
if (normalized.includes('flanders') || normalized.includes('mol') || normalized.includes('kempen')) return ['flanders']
|
||||
if (normalized.includes('belgium land')) return ['belgium', 'flanders', 'wallonia', 'brussels']
|
||||
return null
|
||||
}
|
||||
|
||||
export function isMunicipalityAreaName(areaName: string | null | undefined): boolean {
|
||||
const normalized = String(areaName ?? '').trim()
|
||||
return /^Gemeente\s/i.test(normalized) || /\bmunicipality$/i.test(normalized)
|
||||
}
|
||||
|
||||
export function productCoversZones(productZones: string[], selectedZones: string[] | null): boolean {
|
||||
return selectedZones === null || selectedZones.some((zone) => productZones.includes(zone))
|
||||
}
|
||||
|
||||
export function isSelectionBoundedDataset(
|
||||
sourceMetadata: Record<string, unknown> | null | undefined,
|
||||
): boolean {
|
||||
const boundedScope = sourceMetadata?.['geometry_clipped_to_selection'] === true
|
||||
|| sourceMetadata?.['coverage_scope'] === 'bounded_selection'
|
||||
return boundedScope
|
||||
&& Array.isArray(sourceMetadata?.['bbox_epsg4326'])
|
||||
&& sourceMetadata['bbox_epsg4326'].length === 4
|
||||
}
|
||||
|
||||
export type TemporalAreaMatch = 'exact' | 'unscoped' | 'other'
|
||||
|
||||
export function temporalDatasetAreaMatch(
|
||||
dataset: Pick<DatasetCreateResponse, 'area_id'>,
|
||||
selectedAreaId: string | null,
|
||||
): TemporalAreaMatch {
|
||||
if (!selectedAreaId || !dataset.area_id) return 'unscoped'
|
||||
return dataset.area_id === selectedAreaId ? 'exact' : 'other'
|
||||
}
|
||||
|
||||
export function operationalScopeProjectLabel(project: ProjectRead): string {
|
||||
if (project.name === MOL_PROJECT_NAME) {
|
||||
return 'Mol'
|
||||
}
|
||||
if (project.name === KEMPEN_PROJECT_NAME) {
|
||||
return 'Kempen (28 gemeenten)'
|
||||
}
|
||||
if (project.name === FLANDERS_WORKSPACE_PROJECT_NAME) {
|
||||
return FLANDERS_WORKSPACE_LABEL
|
||||
}
|
||||
return project.name
|
||||
}
|
||||
|
||||
export function selectionAreaSquareMetres(bbox: VectorSelectionBBox | null): number | null {
|
||||
if (!bbox) {
|
||||
return null
|
||||
}
|
||||
const middleLatitudeRadians = ((bbox.min_y + bbox.max_y) / 2) * (Math.PI / 180)
|
||||
const widthMetres = (bbox.max_x - bbox.min_x) * 111_320 * Math.cos(middleLatitudeRadians)
|
||||
const heightMetres = (bbox.max_y - bbox.min_y) * 110_574
|
||||
return Math.max(0, widthMetres * heightMetres)
|
||||
}
|
||||
|
||||
export interface SelectionDimensions {
|
||||
widthMetres: number
|
||||
heightMetres: number
|
||||
areaSquareMetres: number
|
||||
}
|
||||
|
||||
export type SelectionAnalysisScale = 'detail' | 'regional' | 'overview'
|
||||
|
||||
export function selectionDimensions(bbox: VectorSelectionBBox): SelectionDimensions {
|
||||
const middleLatitudeRadians = ((bbox.min_y + bbox.max_y) / 2) * (Math.PI / 180)
|
||||
const widthMetres = Math.max(0, (bbox.max_x - bbox.min_x) * 111_320 * Math.cos(middleLatitudeRadians))
|
||||
const heightMetres = Math.max(0, (bbox.max_y - bbox.min_y) * 110_574)
|
||||
return {
|
||||
widthMetres,
|
||||
heightMetres,
|
||||
areaSquareMetres: widthMetres * heightMetres,
|
||||
}
|
||||
}
|
||||
|
||||
export function selectionAnalysisScale(bbox: VectorSelectionBBox): SelectionAnalysisScale {
|
||||
const { widthMetres, heightMetres } = selectionDimensions(bbox)
|
||||
const longestSide = Math.max(widthMetres, heightMetres)
|
||||
if (longestSide <= 20_000) return 'detail'
|
||||
if (longestSide <= 50_000) return 'regional'
|
||||
return 'overview'
|
||||
}
|
||||
|
||||
export function persistedDatasetSupportsSelection(
|
||||
dataset: { dataset_type: string; source_name?: string | null },
|
||||
bbox: VectorSelectionBBox,
|
||||
): boolean {
|
||||
if (dataset.dataset_type !== 'raster') return true
|
||||
const scale = selectionAnalysisScale(bbox)
|
||||
if (scale === 'overview') return false
|
||||
const dimensions = selectionDimensions(bbox)
|
||||
if (dataset.source_name === 'digitaal_vlaanderen_dhmv' || dataset.source_name === 'spw_terrain' || dataset.source_name === 'vmm_flood_hazard') {
|
||||
return dimensions.areaSquareMetres <= 280_000_000
|
||||
}
|
||||
if (dataset.source_name === 'department_omgeving_thematic_raster') {
|
||||
return dimensions.areaSquareMetres <= 2_800_000_000
|
||||
}
|
||||
return scale === 'detail'
|
||||
}
|
||||
|
||||
export function datasetIntersectsSelection(
|
||||
dataset: { source_metadata?: Record<string, unknown> | null },
|
||||
bbox: VectorSelectionBBox,
|
||||
): boolean {
|
||||
const bounds = dataset.source_metadata?.['bbox_epsg4326']
|
||||
if (!Array.isArray(bounds) || bounds.length !== 4) {
|
||||
return true
|
||||
}
|
||||
const [minX, minY, maxX, maxY] = bounds.map(Number)
|
||||
if (![minX, minY, maxX, maxY].every(Number.isFinite)) {
|
||||
return true
|
||||
}
|
||||
return !(
|
||||
bbox.max_x < minX
|
||||
|| bbox.min_x > maxX
|
||||
|| bbox.max_y < minY
|
||||
|| bbox.min_y > maxY
|
||||
)
|
||||
}
|
||||
|
||||
export function deduplicateTemporalSnapshots<
|
||||
T extends {
|
||||
id: string
|
||||
observed_at?: string | null
|
||||
imported_at?: string | null
|
||||
created_at?: string | null
|
||||
},
|
||||
>(datasets: T[]): T[] {
|
||||
const byObservation = new Map<string, T>()
|
||||
for (const dataset of datasets) {
|
||||
if (!dataset.observed_at) continue
|
||||
const current = byObservation.get(dataset.observed_at)
|
||||
const recency = new Date(dataset.imported_at ?? dataset.created_at ?? 0).getTime()
|
||||
const currentRecency = new Date(current?.imported_at ?? current?.created_at ?? 0).getTime()
|
||||
if (!current || recency > currentRecency || (recency === currentRecency && dataset.id > current.id)) {
|
||||
byObservation.set(dataset.observed_at, dataset)
|
||||
}
|
||||
}
|
||||
return Array.from(byObservation.values()).sort(
|
||||
(left, right) => new Date(left.observed_at ?? 0).getTime() - new Date(right.observed_at ?? 0).getTime(),
|
||||
)
|
||||
}
|
||||
|
||||
export function selectionFeatureLimit(bbox: VectorSelectionBBox): number {
|
||||
const scale = selectionAnalysisScale(bbox)
|
||||
if (scale === 'overview') return 25
|
||||
if (scale === 'regional') return 250
|
||||
return 1000
|
||||
}
|
||||
|
||||
export function splitSelectionBbox(
|
||||
bbox: VectorSelectionBBox,
|
||||
maxTileSideMetres = 18_000,
|
||||
maxTiles = 16,
|
||||
): VectorSelectionBBox[] {
|
||||
const { widthMetres, heightMetres } = selectionDimensions(bbox)
|
||||
const columns = Math.max(1, Math.ceil(widthMetres / maxTileSideMetres))
|
||||
const rows = Math.max(1, Math.ceil(heightMetres / maxTileSideMetres))
|
||||
if (columns * rows > maxTiles) {
|
||||
throw new Error(
|
||||
`De selectie vereist ${columns * rows} detailpartities; maximaal ${maxTiles} zijn toegestaan.`,
|
||||
)
|
||||
}
|
||||
const longitudeStep = (bbox.max_x - bbox.min_x) / columns
|
||||
const latitudeStep = (bbox.max_y - bbox.min_y) / rows
|
||||
const tiles: VectorSelectionBBox[] = []
|
||||
for (let row = 0; row < rows; row += 1) {
|
||||
for (let column = 0; column < columns; column += 1) {
|
||||
tiles.push({
|
||||
min_x: bbox.min_x + longitudeStep * column,
|
||||
min_y: bbox.min_y + latitudeStep * row,
|
||||
max_x: column === columns - 1 ? bbox.max_x : bbox.min_x + longitudeStep * (column + 1),
|
||||
max_y: row === rows - 1 ? bbox.max_y : bbox.min_y + latitudeStep * (row + 1),
|
||||
crs: 'EPSG:4326',
|
||||
})
|
||||
}
|
||||
}
|
||||
return tiles
|
||||
}
|
||||
|
||||
export function bboxesEqual(left: VectorSelectionBBox | null, right: VectorSelectionBBox | null): boolean {
|
||||
if (!left || !right) {
|
||||
return false
|
||||
}
|
||||
const tolerance = 1e-9
|
||||
return (
|
||||
Math.abs(left.min_x - right.min_x) < tolerance
|
||||
&& Math.abs(left.min_y - right.min_y) < tolerance
|
||||
&& Math.abs(left.max_x - right.max_x) < tolerance
|
||||
&& Math.abs(left.max_y - right.max_y) < tolerance
|
||||
)
|
||||
}
|
||||
|
||||
export function formatArea(areaSquareMetres: number | null): string {
|
||||
if (areaSquareMetres === null) {
|
||||
return 'Nog niet geselecteerd'
|
||||
}
|
||||
if (areaSquareMetres >= 1_000_000) {
|
||||
return `${(areaSquareMetres / 1_000_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} km2`
|
||||
}
|
||||
return `${(areaSquareMetres / 10_000).toLocaleString('nl-BE', { maximumFractionDigits: 2 })} ha`
|
||||
}
|
||||
|
||||
export function resultCountLabel(result: VectorSelectionResponse): string {
|
||||
const total = result.total_feature_count ?? result.feature_count
|
||||
return result.truncated && result.total_feature_count == null
|
||||
? `${result.feature_count.toLocaleString('nl-BE')}+`
|
||||
: total.toLocaleString('nl-BE')
|
||||
}
|
||||
|
||||
export function resultMetricLabel(result: VectorSelectionResponse): string {
|
||||
if (!result.summary) {
|
||||
return resultCountLabel(result)
|
||||
}
|
||||
const maximumFractionDigits = result.summary.metric_unit === 'inwoners' ? 0 : 2
|
||||
return `${result.summary.metric_value.toLocaleString('nl-BE', { maximumFractionDigits })} ${result.summary.metric_unit}`
|
||||
}
|
||||
|
||||
export function selectionMetricLabel(metric: VectorSelectionMetric): string {
|
||||
const maximumFractionDigits = metric.metric_unit === 'inwoners' || metric.metric_unit === 'objecten' ? 0 : 2
|
||||
return `${metric.metric_value.toLocaleString('nl-BE', { maximumFractionDigits })} ${metric.metric_unit}`
|
||||
}
|
||||
|
||||
export function formatTemporalMetric(value: number, unit: string): string {
|
||||
const maximumFractionDigits = unit === 'inwoners' || unit === 'objecten' ? 0 : 2
|
||||
return `${value.toLocaleString('nl-BE', { maximumFractionDigits })} ${unit}`
|
||||
}
|
||||
|
||||
export function readablePropertyName(value: string): string {
|
||||
return value.replace(/_/g, ' ').replace(/\b\w/g, (character) => character.toUpperCase())
|
||||
}
|
||||
|
||||
function collectGeometryPoints(geometry: GeoJSON.Geometry | null | undefined): Array<[number, number]> {
|
||||
const points: Array<[number, number]> = []
|
||||
const walk = (coords: unknown) => {
|
||||
if (!Array.isArray(coords)) {
|
||||
return
|
||||
}
|
||||
if (coords.length >= 2 && typeof coords[0] === 'number' && typeof coords[1] === 'number') {
|
||||
points.push([coords[0], coords[1]])
|
||||
return
|
||||
}
|
||||
for (const item of coords) {
|
||||
walk(item)
|
||||
}
|
||||
}
|
||||
|
||||
if ('coordinates' in (geometry ?? {})) {
|
||||
walk((geometry as GeoJSON.Geometry & { coordinates: unknown }).coordinates)
|
||||
}
|
||||
|
||||
return points
|
||||
}
|
||||
|
||||
function formatCoordinate(value: number): string {
|
||||
return Number.isFinite(value) ? value.toFixed(6) : 'n.v.t.'
|
||||
}
|
||||
|
||||
export function getFeatureGeometrySummary(feature: GeoJSON.Feature | null) {
|
||||
const points = collectGeometryPoints(feature?.geometry)
|
||||
if (!feature?.geometry || points.length === 0) {
|
||||
return {
|
||||
bboxLabel: 'n.v.t.',
|
||||
coordinateCount: 0,
|
||||
geometryType: feature?.geometry?.type ?? 'geen',
|
||||
}
|
||||
}
|
||||
|
||||
const xs = points.map((point) => point[0])
|
||||
const ys = points.map((point) => point[1])
|
||||
const bboxLabel = `${formatCoordinate(Math.min(...xs))}, ${formatCoordinate(Math.min(...ys))} -> ${formatCoordinate(
|
||||
Math.max(...xs),
|
||||
)}, ${formatCoordinate(Math.max(...ys))}`
|
||||
|
||||
return {
|
||||
bboxLabel,
|
||||
coordinateCount: points.length,
|
||||
geometryType: feature.geometry.type,
|
||||
}
|
||||
}
|
||||
|
||||
export function getFeatureCollectionBBox(collection: GeoJSON.FeatureCollection | null): VectorSelectionBBox | null {
|
||||
const bounds = featureCollectionBounds(collection)
|
||||
if (!bounds) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
min_x: bounds.minX,
|
||||
min_y: bounds.minY,
|
||||
max_x: bounds.maxX,
|
||||
max_y: bounds.maxY,
|
||||
crs: 'EPSG:4326',
|
||||
}
|
||||
}
|
||||
|
||||
export function getFeatureBBox(feature: GeoJSON.Feature | null): VectorSelectionBBox | null {
|
||||
const points = collectGeometryPoints(feature?.geometry)
|
||||
if (points.length === 0) {
|
||||
return null
|
||||
}
|
||||
const xs = points.map((point) => point[0])
|
||||
const ys = points.map((point) => point[1])
|
||||
return {
|
||||
min_x: Math.min(...xs),
|
||||
min_y: Math.min(...ys),
|
||||
max_x: Math.max(...xs),
|
||||
max_y: Math.max(...ys),
|
||||
crs: 'EPSG:4326',
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeBboxFromCorners(
|
||||
first: [number, number],
|
||||
second: [number, number],
|
||||
): VectorSelectionBBox {
|
||||
return {
|
||||
min_x: Math.min(first[0], second[0]),
|
||||
min_y: Math.min(first[1], second[1]),
|
||||
max_x: Math.max(first[0], second[0]),
|
||||
max_y: Math.max(first[1], second[1]),
|
||||
crs: 'EPSG:4326',
|
||||
}
|
||||
}
|
||||
|
||||
export function formatBboxLabel(bbox: VectorSelectionBBox | null): string {
|
||||
if (!bbox) {
|
||||
return 'n.v.t.'
|
||||
}
|
||||
return `${formatCoordinate(bbox.min_x)}, ${formatCoordinate(bbox.min_y)} -> ${formatCoordinate(bbox.max_x)}, ${formatCoordinate(bbox.max_y)}`
|
||||
}
|
||||
|
||||
export function formatPercentage(value: number | null | undefined): string {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
? `${(value * 100).toLocaleString('nl-BE', { maximumFractionDigits: 1 })}%`
|
||||
: 'n.v.t.'
|
||||
}
|
||||
|
||||
export function bboxToInputState(bbox: VectorSelectionBBox | null) {
|
||||
return {
|
||||
min_x: bbox ? String(bbox.min_x) : '',
|
||||
min_y: bbox ? String(bbox.min_y) : '',
|
||||
max_x: bbox ? String(bbox.max_x) : '',
|
||||
max_y: bbox ? String(bbox.max_y) : '',
|
||||
}
|
||||
}
|
||||
|
||||
export function parseBboxInput(input: ReturnType<typeof bboxToInputState>): VectorSelectionBBox | null {
|
||||
const min_x = Number(input.min_x)
|
||||
const min_y = Number(input.min_y)
|
||||
const max_x = Number(input.max_x)
|
||||
const max_y = Number(input.max_y)
|
||||
if (![min_x, min_y, max_x, max_y].every(Number.isFinite) || min_x >= max_x || min_y >= max_y) {
|
||||
return null
|
||||
}
|
||||
return { min_x, min_y, max_x, max_y, crs: 'EPSG:4326' }
|
||||
}
|
||||
|
||||
export function selectedFeatureCollection(feature: GeoJSON.Feature): GeoJSON.FeatureCollection {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: [feature],
|
||||
}
|
||||
}
|
||||
|
||||
export function safeFileStem(value: unknown): string {
|
||||
const stem = String(value ?? 'selected-feature')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
return stem || 'selected-feature'
|
||||
}
|
||||
|
||||
function fallbackCopyText(text: string): void {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
textarea.setAttribute('readonly', 'true')
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.left = '-9999px'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
|
||||
export function copyText(text: string): void {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
void navigator.clipboard.writeText(text).catch(() => fallbackCopyText(text))
|
||||
return
|
||||
}
|
||||
fallbackCopyText(text)
|
||||
}
|
||||
|
||||
export function downloadJsonFile(
|
||||
filename: string,
|
||||
payload: unknown,
|
||||
contentType = 'application/json',
|
||||
): void {
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: contentType })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user