Breng de opstartkosten terug en geef de werkstand een schakelaar
De inlogpagina haalde 1,46 MB over de lijn, waaronder de volledige kaartmotor van 213 kB terwijl er geen kaart staat. De werkbank is nu een aparte brok die pas na aanmelden geladen wordt: de eerste JS-bundel gaat van 476 naar 30 kB, en met de beeldwerk uit een eerdere commit komt de inlogpagina op 344 kB. Bij het opstarten gingen 27 verzoeken de deur uit in drie golven. Alles werd vooraf opgehaald, ook voor werkbladen die de gebruiker misschien nooit opent, en twee daarvan werden dubbel opgevraagd door effecten die negentig milliseconde na elkaar liepen. Gelijktijdige en vlak opeenvolgende identieke GET-verzoeken delen nu één belofte, en elk werkblad haalt zijn eigen gegevens pas op wanneer het geopend wordt. Nu zestien verzoeken. Typen in het themazoekveld kostte 53 ms mediaan met uitschieters tot 127 ms, omdat die filterwaarde in het viewmodel van de hele werkruimte leefde. Het veld is een eigen component geworden dat zijn invoer zelf bijhoudt. React.memo om de kaart is geprobeerd en weer verwijderd: van de negentien props worden er te veel per render opnieuw gemaakt, dus de vergelijking sloeg nooit over. Verder: donker blijft de standaard maar er is nu een weg terug naar licht, de kaartondergrond schakelt mee zonder de datalagen te herbouwen, de onderste hero-afbeelding wordt pas opgehaald als hij in beeld komt, en de werkbank heeft een h1 zodat schermlezers een ankerpunt hebben. De tests op de bootstrap legden het oude gedrag vast en toetsen nu het nieuwe, van drie naar zeven gevallen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+16
-1458
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import 'maplibre-gl/dist/maplibre-gl.css'
|
|||||||
import { NATIONAL_MAP_CENTER, NATIONAL_MAP_ZOOM } from '../config/primaryFocus'
|
import { NATIONAL_MAP_CENTER, NATIONAL_MAP_ZOOM } from '../config/primaryFocus'
|
||||||
import { featureCollectionBounds } from '../lib/geojsonBounds'
|
import { featureCollectionBounds } from '../lib/geojsonBounds'
|
||||||
import type { MapImageOverlay, MapViewportState, VectorSelectionBBox } from '../types'
|
import type { MapImageOverlay, MapViewportState, VectorSelectionBBox } from '../types'
|
||||||
|
import { basemapGround, basemapPaint, huidigeWerkstand, mapSymbology } from './map/mapSymbology'
|
||||||
|
|
||||||
interface GeoMapProps {
|
interface GeoMapProps {
|
||||||
data: GeoJSON.FeatureCollection | null
|
data: GeoJSON.FeatureCollection | null
|
||||||
@@ -46,10 +47,23 @@ const DEFAULT_ROAD_BASEMAP_STYLE: maplibregl.StyleSpecification = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
layers: [
|
layers: [
|
||||||
|
{
|
||||||
|
// Grondtoon onder de tegels. Zonder deze laag flitst er wit tussen
|
||||||
|
// tegels die nog niet geladen zijn.
|
||||||
|
id: 'basemap-ground',
|
||||||
|
type: 'background',
|
||||||
|
paint: { 'background-color': basemapGround(huidigeWerkstand()) },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'osm-standard',
|
id: 'osm-standard',
|
||||||
type: 'raster',
|
type: 'raster',
|
||||||
source: 'osm-standard',
|
source: 'osm-standard',
|
||||||
|
// De tegel wordt ontkleurd en gedempt tot een operationele ondergrond,
|
||||||
|
// zodat alleen de eigen data nog kleur draagt. Geen andere tegelbron en
|
||||||
|
// geen sleutel nodig; wie een echte vectorstijl heeft zet die via
|
||||||
|
// VITE_MAP_STYLE_URL en omzeilt dit blok volledig. Zie basemapPaint voor
|
||||||
|
// het verschil tussen de twee werkstanden.
|
||||||
|
paint: basemapPaint(huidigeWerkstand()),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -58,18 +72,18 @@ function datasetFillColor(fallbackColor: string): ExpressionSpecification {
|
|||||||
return [
|
return [
|
||||||
'case',
|
'case',
|
||||||
['==', ['get', 'layer_type'], 'municipality_boundary'],
|
['==', ['get', 'layer_type'], 'municipality_boundary'],
|
||||||
'#0f766e',
|
mapSymbology.boundary,
|
||||||
[
|
[
|
||||||
'match',
|
'match',
|
||||||
['get', 'change_type'],
|
['get', 'change_type'],
|
||||||
'added',
|
'added',
|
||||||
'#16a34a',
|
mapSymbology.added,
|
||||||
'removed',
|
'removed',
|
||||||
'#dc2626',
|
mapSymbology.removed,
|
||||||
'modified',
|
'modified',
|
||||||
'#d97706',
|
mapSymbology.modified,
|
||||||
'unchanged',
|
'unchanged',
|
||||||
'#2563eb',
|
mapSymbology.unchanged,
|
||||||
fallbackColor,
|
fallbackColor,
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
@@ -79,18 +93,18 @@ function datasetLineColor(fallbackColor: string): ExpressionSpecification {
|
|||||||
return [
|
return [
|
||||||
'case',
|
'case',
|
||||||
['==', ['get', 'layer_type'], 'municipality_boundary'],
|
['==', ['get', 'layer_type'], 'municipality_boundary'],
|
||||||
'#0f5f59',
|
mapSymbology.boundaryStrong,
|
||||||
[
|
[
|
||||||
'match',
|
'match',
|
||||||
['get', 'change_type'],
|
['get', 'change_type'],
|
||||||
'added',
|
'added',
|
||||||
'#15803d',
|
mapSymbology.added,
|
||||||
'removed',
|
'removed',
|
||||||
'#b91c1c',
|
mapSymbology.removed,
|
||||||
'modified',
|
'modified',
|
||||||
'#b45309',
|
mapSymbology.modified,
|
||||||
'unchanged',
|
'unchanged',
|
||||||
'#1d4ed8',
|
mapSymbology.unchanged,
|
||||||
fallbackColor,
|
fallbackColor,
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
@@ -144,8 +158,8 @@ function bboxToFeatureCollection(
|
|||||||
|
|
||||||
function GeoMap({
|
function GeoMap({
|
||||||
data,
|
data,
|
||||||
dataFillColor = '#f97316',
|
dataFillColor = mapSymbology.dataFill,
|
||||||
dataLineColor = '#ea580c',
|
dataLineColor = mapSymbology.dataLine,
|
||||||
areaData = null,
|
areaData = null,
|
||||||
selectedFeature = null,
|
selectedFeature = null,
|
||||||
selectionData = null,
|
selectionData = null,
|
||||||
@@ -236,6 +250,27 @@ function GeoMap({
|
|||||||
}
|
}
|
||||||
}, [bboxSelectionMode])
|
}, [bboxSelectionMode])
|
||||||
|
|
||||||
|
// De kaart wordt eenmalig opgebouwd, dus bij het wisselen van werkstand moet
|
||||||
|
// alleen de verf van de ondergrond mee. setPaintProperty laat alle datalagen
|
||||||
|
// ongemoeid; een volledige setStyle zou ze opnieuw moeten opbouwen.
|
||||||
|
useEffect(() => {
|
||||||
|
const pasAan = () => {
|
||||||
|
const map = mapRef.current
|
||||||
|
if (!map || !map.isStyleLoaded()) return
|
||||||
|
const werkstand = huidigeWerkstand()
|
||||||
|
if (!map.getLayer('osm-standard')) return
|
||||||
|
for (const [naam, waarde] of Object.entries(basemapPaint(werkstand))) {
|
||||||
|
map.setPaintProperty('osm-standard', naam as never, waarde as never)
|
||||||
|
}
|
||||||
|
if (map.getLayer('basemap-ground')) {
|
||||||
|
map.setPaintProperty('basemap-ground', 'background-color', basemapGround(werkstand))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const waarnemer = new MutationObserver(pasAan)
|
||||||
|
waarnemer.observe(document.body, { attributes: true, attributeFilter: ['data-theme'] })
|
||||||
|
return () => waarnemer.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current || mapRef.current) {
|
if (!containerRef.current || mapRef.current) {
|
||||||
return
|
return
|
||||||
@@ -491,7 +526,7 @@ function GeoMap({
|
|||||||
type: 'fill',
|
type: 'fill',
|
||||||
source: 'area',
|
source: 'area',
|
||||||
paint: {
|
paint: {
|
||||||
'fill-color': '#0f766e',
|
'fill-color': mapSymbology.boundary,
|
||||||
'fill-opacity': 0.18,
|
'fill-opacity': 0.18,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -503,7 +538,7 @@ function GeoMap({
|
|||||||
type: 'line',
|
type: 'line',
|
||||||
source: 'area',
|
source: 'area',
|
||||||
paint: {
|
paint: {
|
||||||
'line-color': '#0f766e',
|
'line-color': mapSymbology.boundary,
|
||||||
'line-width': 3,
|
'line-width': 3,
|
||||||
'line-dasharray': [2, 1],
|
'line-dasharray': [2, 1],
|
||||||
},
|
},
|
||||||
@@ -576,7 +611,7 @@ function GeoMap({
|
|||||||
source: 'selected-feature',
|
source: 'selected-feature',
|
||||||
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon'], true, false],
|
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon'], true, false],
|
||||||
paint: {
|
paint: {
|
||||||
'fill-color': '#fde047',
|
'fill-color': mapSymbology.selectionFill,
|
||||||
'fill-opacity': 0.32,
|
'fill-opacity': 0.32,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -586,7 +621,7 @@ function GeoMap({
|
|||||||
source: 'selected-feature',
|
source: 'selected-feature',
|
||||||
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false],
|
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false],
|
||||||
paint: {
|
paint: {
|
||||||
'line-color': '#854d0e',
|
'line-color': mapSymbology.selectionLine,
|
||||||
'line-width': 4,
|
'line-width': 4,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -596,9 +631,9 @@ function GeoMap({
|
|||||||
source: 'selected-feature',
|
source: 'selected-feature',
|
||||||
filter: ['match', ['geometry-type'], ['Point', 'MultiPoint'], true, false],
|
filter: ['match', ['geometry-type'], ['Point', 'MultiPoint'], true, false],
|
||||||
paint: {
|
paint: {
|
||||||
'circle-color': '#fde047',
|
'circle-color': mapSymbology.selectionFill,
|
||||||
'circle-radius': 7,
|
'circle-radius': 7,
|
||||||
'circle-stroke-color': '#854d0e',
|
'circle-stroke-color': mapSymbology.selectionLine,
|
||||||
'circle-stroke-width': 2,
|
'circle-stroke-width': 2,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -620,7 +655,7 @@ function GeoMap({
|
|||||||
type: 'fill',
|
type: 'fill',
|
||||||
source: 'selection-bbox',
|
source: 'selection-bbox',
|
||||||
paint: {
|
paint: {
|
||||||
'fill-color': '#38bdf8',
|
'fill-color': mapSymbology.waterFill,
|
||||||
'fill-opacity': 0.12,
|
'fill-opacity': 0.12,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -629,7 +664,7 @@ function GeoMap({
|
|||||||
type: 'line',
|
type: 'line',
|
||||||
source: 'selection-bbox',
|
source: 'selection-bbox',
|
||||||
paint: {
|
paint: {
|
||||||
'line-color': '#0369a1',
|
'line-color': mapSymbology.waterLine,
|
||||||
'line-width': 2,
|
'line-width': 2,
|
||||||
'line-dasharray': [2, 1],
|
'line-dasharray': [2, 1],
|
||||||
},
|
},
|
||||||
@@ -656,7 +691,7 @@ function GeoMap({
|
|||||||
source: 'selection-result',
|
source: 'selection-result',
|
||||||
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon'], true, false],
|
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon'], true, false],
|
||||||
paint: {
|
paint: {
|
||||||
'fill-color': '#7c3aed',
|
'fill-color': mapSymbology.detectionFill,
|
||||||
'fill-opacity': 0.24,
|
'fill-opacity': 0.24,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -666,7 +701,7 @@ function GeoMap({
|
|||||||
source: 'selection-result',
|
source: 'selection-result',
|
||||||
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false],
|
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false],
|
||||||
paint: {
|
paint: {
|
||||||
'line-color': '#5b21b6',
|
'line-color': mapSymbology.detectionLine,
|
||||||
'line-width': 3,
|
'line-width': 3,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -676,9 +711,9 @@ function GeoMap({
|
|||||||
source: 'selection-result',
|
source: 'selection-result',
|
||||||
filter: ['match', ['geometry-type'], ['Point', 'MultiPoint'], true, false],
|
filter: ['match', ['geometry-type'], ['Point', 'MultiPoint'], true, false],
|
||||||
paint: {
|
paint: {
|
||||||
'circle-color': '#7c3aed',
|
'circle-color': mapSymbology.detectionFill,
|
||||||
'circle-radius': 6,
|
'circle-radius': 6,
|
||||||
'circle-stroke-color': '#ffffff',
|
'circle-stroke-color': mapSymbology.pointStroke,
|
||||||
'circle-stroke-width': 2,
|
'circle-stroke-width': 2,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -701,14 +736,14 @@ function GeoMap({
|
|||||||
'match',
|
'match',
|
||||||
['get', 'qa_evidence_role'],
|
['get', 'qa_evidence_role'],
|
||||||
'match_candidate',
|
'match_candidate',
|
||||||
'#2563eb',
|
mapSymbology.unchanged,
|
||||||
'match_reference',
|
'match_reference',
|
||||||
'#0f766e',
|
mapSymbology.boundary,
|
||||||
'false_positive',
|
'false_positive',
|
||||||
'#dc2626',
|
mapSymbology.removed,
|
||||||
'false_negative',
|
'false_negative',
|
||||||
'#d97706',
|
mapSymbology.modified,
|
||||||
'#475569',
|
mapSymbology.fallback,
|
||||||
] as ExpressionSpecification
|
] as ExpressionSpecification
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
id: 'qa-evidence-fill',
|
id: 'qa-evidence-fill',
|
||||||
@@ -746,7 +781,7 @@ function GeoMap({
|
|||||||
paint: {
|
paint: {
|
||||||
'circle-color': evidenceColor,
|
'circle-color': evidenceColor,
|
||||||
'circle-radius': 7,
|
'circle-radius': 7,
|
||||||
'circle-stroke-color': '#ffffff',
|
'circle-stroke-color': mapSymbology.pointStroke,
|
||||||
'circle-stroke-width': 2,
|
'circle-stroke-width': 2,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -762,4 +797,8 @@ function GeoMap({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Geen React.memo hier. Het is geprobeerd en het scheelde niets: van de
|
||||||
|
// negentien props worden er te veel per render opnieuw gemaakt, dus de
|
||||||
|
// vergelijking slaat nooit over. Zinvol wordt dat pas wanneer die props
|
||||||
|
// gestabiliseerd zijn; tot die tijd is het schijnzekerheid.
|
||||||
export default GeoMap
|
export default GeoMap
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { BadgeCheck, Database, Download, MapPinned, ScanSearch } from 'lucide-react'
|
import { BadgeCheck, Database, Download, MapPinned, ScanSearch } from 'lucide-react'
|
||||||
import '../../styles/landing-project-story.css'
|
import '../../styles/landing-project-story.css'
|
||||||
|
import { useDeferredBackground } from '../../hooks/useDeferredBackground'
|
||||||
|
|
||||||
const stages = [
|
const stages = [
|
||||||
{
|
{
|
||||||
@@ -46,6 +47,7 @@ const stages = [
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
export function LandingProjectStory(): JSX.Element {
|
export function LandingProjectStory(): JSX.Element {
|
||||||
|
const kaartRef = useDeferredBackground<HTMLDivElement>()
|
||||||
const [activeIndex, setActiveIndex] = useState(0)
|
const [activeIndex, setActiveIndex] = useState(0)
|
||||||
const activeStage = stages[activeIndex]
|
const activeStage = stages[activeIndex]
|
||||||
|
|
||||||
@@ -104,7 +106,7 @@ export function LandingProjectStory(): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`landing-story-visual is-stage-${activeIndex + 1}`} aria-hidden="true">
|
<div className={`landing-story-visual is-stage-${activeIndex + 1}`} aria-hidden="true">
|
||||||
<div className="landing-story-map" />
|
<div className="landing-story-map" ref={kaartRef} />
|
||||||
<svg viewBox="0 0 720 610" role="presentation">
|
<svg viewBox="0 0 720 610" role="presentation">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="story-area" x1="0" y1="0" x2="1" y2="1">
|
<linearGradient id="story-area" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { datasetCoversSelectedArea, datasetProductKey, floodScenarioLabel, forma
|
|||||||
import type { MapWorkspaceProps } from './mapWorkspaceProps'
|
import type { MapWorkspaceProps } from './mapWorkspaceProps'
|
||||||
|
|
||||||
import type { MapWorkspaceViewModel } from './useMapWorkspaceViewModel'
|
import type { MapWorkspaceViewModel } from './useMapWorkspaceViewModel'
|
||||||
|
import { ThemeSearchField } from './ThemeSearchField'
|
||||||
|
|
||||||
interface MapExplorerViewProps {
|
interface MapExplorerViewProps {
|
||||||
props: MapWorkspaceProps
|
props: MapWorkspaceProps
|
||||||
@@ -369,16 +370,7 @@ export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Elem
|
|||||||
: 'Zoek optioneel een gemeente of teken vrij op de kaart'}
|
: 'Zoek optioneel een gemeente of teken vrij op de kaart'}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<label className="geo-theme-search">
|
<ThemeSearchField waarde={themeFilter} onChange={setThemeFilter} />
|
||||||
<span className="sr-only">Zoek een thema of gegevensbron</span>
|
|
||||||
<Search aria-hidden="true" />
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
value={themeFilter}
|
|
||||||
onChange={(event) => setThemeFilter(event.target.value)}
|
|
||||||
placeholder="Zoek thema’s"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="geo-theme-list">
|
<div className="geo-theme-list">
|
||||||
{visibleThemes.map((theme) => {
|
{visibleThemes.map((theme) => {
|
||||||
const dataset = themeDatasetMap[theme.id]
|
const dataset = themeDatasetMap[theme.id]
|
||||||
|
|||||||
@@ -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,51 @@
|
|||||||
|
import { cleanup, render } from '@testing-library/react'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { useDeferredBackground } from './useDeferredBackground'
|
||||||
|
|
||||||
|
function Blok(): JSX.Element {
|
||||||
|
const ref = useDeferredBackground<HTMLDivElement>()
|
||||||
|
return <div data-testid="blok" ref={ref} />
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useDeferredBackground', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('zet de achtergrond pas wanneer het blok in beeld komt', () => {
|
||||||
|
type Terugroep = (invoeren: { isIntersecting: boolean }[]) => void
|
||||||
|
const waarnemers: { melden: Terugroep }[] = []
|
||||||
|
const disconnect = vi.fn()
|
||||||
|
vi.stubGlobal('IntersectionObserver', class {
|
||||||
|
melden: Terugroep
|
||||||
|
constructor(terugroep: Terugroep) {
|
||||||
|
this.melden = terugroep
|
||||||
|
waarnemers.push(this)
|
||||||
|
}
|
||||||
|
observe() {}
|
||||||
|
disconnect = disconnect
|
||||||
|
})
|
||||||
|
const meldIntersectie = (invoeren: { isIntersecting: boolean }[]) => {
|
||||||
|
for (const w of waarnemers) w.melden(invoeren)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { getByTestId } = render(<Blok />)
|
||||||
|
expect(getByTestId('blok').getAttribute('data-achtergrond')).toBeNull()
|
||||||
|
|
||||||
|
meldIntersectie([{ isIntersecting: false }])
|
||||||
|
expect(getByTestId('blok').getAttribute('data-achtergrond')).toBeNull()
|
||||||
|
|
||||||
|
meldIntersectie([{ isIntersecting: true }])
|
||||||
|
expect(getByTestId('blok').getAttribute('data-achtergrond')).toBe('geladen')
|
||||||
|
// Eenmaal geladen hoeft er niet verder gekeken te worden.
|
||||||
|
expect(disconnect).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('zet de achtergrond meteen wanneer de browser niet kan waarnemen', () => {
|
||||||
|
vi.stubGlobal('IntersectionObserver', undefined)
|
||||||
|
const { getByTestId } = render(<Blok />)
|
||||||
|
// Liever een afbeelding te vroeg dan een leeg vlak dat nooit vult.
|
||||||
|
expect(getByTestId('blok').getAttribute('data-achtergrond')).toBe('geladen')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stelt een achtergrondafbeelding uit tot het element in beeld komt.
|
||||||
|
*
|
||||||
|
* Achtergronden in CSS kennen geen loading="lazy": zodra de regel matcht, gaat
|
||||||
|
* het verzoek eruit. Op de landingspagina betekende dat drie afbeeldingen van
|
||||||
|
* samen 413 kB bij het openen, terwijl twee ervan pas ver onder de vouw staan.
|
||||||
|
*
|
||||||
|
* Het element krijgt hier pas het attribuut data-achtergrond="geladen" wanneer
|
||||||
|
* het in de buurt van het scherm komt; de CSS hangt de url aan dat attribuut.
|
||||||
|
* Zonder IntersectionObserver — of met beperkte beweging — wordt de afbeelding
|
||||||
|
* meteen gezet, zodat er nooit een leeg vlak achterblijft.
|
||||||
|
*/
|
||||||
|
export function useDeferredBackground<T extends HTMLElement>(): React.RefObject<T> {
|
||||||
|
const ref = useRef<T>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = ref.current
|
||||||
|
if (!element) return
|
||||||
|
|
||||||
|
const toon = () => element.setAttribute('data-achtergrond', 'geladen')
|
||||||
|
|
||||||
|
if (typeof IntersectionObserver === 'undefined') {
|
||||||
|
toon()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const waarnemer = new IntersectionObserver(
|
||||||
|
(invoeren) => {
|
||||||
|
if (invoeren.some((invoer) => invoer.isIntersecting)) {
|
||||||
|
toon()
|
||||||
|
waarnemer.disconnect()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Ruim voor de vouw beginnen, zodat de afbeelding er staat voordat de
|
||||||
|
// gebruiker hem bereikt.
|
||||||
|
{ rootMargin: '600px' },
|
||||||
|
)
|
||||||
|
waarnemer.observe(element)
|
||||||
|
return () => waarnemer.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return ref
|
||||||
|
}
|
||||||
@@ -7,8 +7,9 @@ function action() {
|
|||||||
return vi.fn().mockResolvedValue(undefined)
|
return vi.fn().mockResolvedValue(undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
function options(selectedProjectId: string | null) {
|
function options(selectedProjectId: string | null, activeWorkspace = 'map') {
|
||||||
return {
|
return {
|
||||||
|
activeWorkspace,
|
||||||
selectedProjectId,
|
selectedProjectId,
|
||||||
selectedDetectionRunId: '',
|
selectedDetectionRunId: '',
|
||||||
detectionClassFilter: '',
|
detectionClassFilter: '',
|
||||||
@@ -36,20 +37,17 @@ function options(selectedProjectId: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('useWorkbenchBootstrap', () => {
|
describe('useWorkbenchBootstrap', () => {
|
||||||
it('loads global capabilities and clears project-owned state without a project', async () => {
|
it('haalt zonder project alleen de werkruimtes op en wist projectgebonden state', async () => {
|
||||||
const state = options(null)
|
const state = options(null)
|
||||||
renderHook(() => useWorkbenchBootstrap(state))
|
renderHook(() => useWorkbenchBootstrap(state))
|
||||||
|
|
||||||
await waitFor(() => expect(state.loadProjects).toHaveBeenCalledOnce())
|
await waitFor(() => expect(state.loadProjects).toHaveBeenCalledOnce())
|
||||||
expect(state.loadCapabilities).toHaveBeenCalledOnce()
|
|
||||||
expect(state.loadDetectionModels).toHaveBeenCalledOnce()
|
|
||||||
expect(state.loadSegmentationModels).toHaveBeenCalledOnce()
|
|
||||||
expect(state.resetProjectData).toHaveBeenCalledOnce()
|
expect(state.resetProjectData).toHaveBeenCalledOnce()
|
||||||
expect(state.resetDatasetForProject).toHaveBeenCalledOnce()
|
expect(state.resetDatasetForProject).toHaveBeenCalledOnce()
|
||||||
expect(state.loadProjectData).not.toHaveBeenCalled()
|
expect(state.loadProjectData).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('resets stale state before loading every project-owned collection', async () => {
|
it('laadt op het kaartwerkblad de gebieden en bronnen, en verder niets', async () => {
|
||||||
const state = options('project-1')
|
const state = options('project-1')
|
||||||
renderHook(() => useWorkbenchBootstrap(state))
|
renderHook(() => useWorkbenchBootstrap(state))
|
||||||
|
|
||||||
@@ -59,25 +57,76 @@ describe('useWorkbenchBootstrap', () => {
|
|||||||
expect(state.resetDetectionForProject).toHaveBeenCalledOnce()
|
expect(state.resetDetectionForProject).toHaveBeenCalledOnce()
|
||||||
expect(state.resetSegmentationForProject).toHaveBeenCalledOnce()
|
expect(state.resetSegmentationForProject).toHaveBeenCalledOnce()
|
||||||
expect(state.resetExportsForProject).toHaveBeenCalledOnce()
|
expect(state.resetExportsForProject).toHaveBeenCalledOnce()
|
||||||
expect(state.loadDetectionRuns).toHaveBeenCalledWith('project-1')
|
|
||||||
expect(state.loadSegmentationRuns).toHaveBeenCalledWith('project-1')
|
// Alles wat bij een ander werkblad hoort blijft liggen tot dat werkblad
|
||||||
expect(state.loadQualityChecks).toHaveBeenCalledWith('project-1')
|
// geopend wordt. Voorheen ging dit alles bij het opstarten de deur uit,
|
||||||
expect(state.loadExports).toHaveBeenCalledWith('project-1')
|
// ook voor werkbladen die de gebruiker nooit opende.
|
||||||
|
expect(state.loadCapabilities).not.toHaveBeenCalled()
|
||||||
|
expect(state.loadDetectionModels).not.toHaveBeenCalled()
|
||||||
|
expect(state.loadSegmentationModels).not.toHaveBeenCalled()
|
||||||
|
expect(state.loadDetectionRuns).not.toHaveBeenCalled()
|
||||||
|
expect(state.loadSegmentationRuns).not.toHaveBeenCalled()
|
||||||
|
expect(state.loadQualityChecks).not.toHaveBeenCalled()
|
||||||
|
expect(state.loadExports).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('loads the same analysis catalog and project results for a guest', async () => {
|
it('haalt de beeldanalysegegevens op zodra dat werkblad open staat', async () => {
|
||||||
const state = { ...options('project-1'), restrictedMode: true }
|
const state = options('project-1', 'ai')
|
||||||
renderHook(() => useWorkbenchBootstrap(state))
|
renderHook(() => useWorkbenchBootstrap(state))
|
||||||
|
|
||||||
await waitFor(() => expect(state.loadProjectData).toHaveBeenCalledWith('project-1'))
|
await waitFor(() => expect(state.loadDetectionModels).toHaveBeenCalledOnce())
|
||||||
expect(state.loadCapabilities).toHaveBeenCalledOnce()
|
|
||||||
expect(state.loadQualityChecks).toHaveBeenCalledWith('project-1')
|
|
||||||
expect(state.loadDetectionModels).toHaveBeenCalledOnce()
|
|
||||||
expect(state.loadSegmentationModels).toHaveBeenCalledOnce()
|
expect(state.loadSegmentationModels).toHaveBeenCalledOnce()
|
||||||
expect(state.loadDetectionRuns).toHaveBeenCalledWith('project-1')
|
expect(state.loadDetectionRuns).toHaveBeenCalledWith('project-1')
|
||||||
expect(state.loadSegmentationRuns).toHaveBeenCalledWith('project-1')
|
expect(state.loadSegmentationRuns).toHaveBeenCalledWith('project-1')
|
||||||
expect(state.loadExports).toHaveBeenCalledWith('project-1')
|
|
||||||
expect(state.loadDetectionResults).toHaveBeenCalledOnce()
|
expect(state.loadDetectionResults).toHaveBeenCalledOnce()
|
||||||
expect(state.loadSegmentationResults).toHaveBeenCalledOnce()
|
expect(state.loadSegmentationResults).toHaveBeenCalledOnce()
|
||||||
|
expect(state.loadExports).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('haalt kwaliteit, downloads en bronkoppelingen op hun eigen werkblad', async () => {
|
||||||
|
const kwaliteit = options('project-1', 'analysis')
|
||||||
|
renderHook(() => useWorkbenchBootstrap(kwaliteit))
|
||||||
|
await waitFor(() => expect(kwaliteit.loadQualityChecks).toHaveBeenCalledWith('project-1'))
|
||||||
|
|
||||||
|
const downloads = options('project-1', 'exports')
|
||||||
|
renderHook(() => useWorkbenchBootstrap(downloads))
|
||||||
|
await waitFor(() => expect(downloads.loadExports).toHaveBeenCalledWith('project-1'))
|
||||||
|
|
||||||
|
const systeem = options('project-1', 'system')
|
||||||
|
renderHook(() => useWorkbenchBootstrap(systeem))
|
||||||
|
await waitFor(() => expect(systeem.loadCapabilities).toHaveBeenCalledOnce())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blijft geladen wanneer de gebruiker terugkeert naar de kaart', async () => {
|
||||||
|
const state = options('project-1', 'ai')
|
||||||
|
const { rerender } = renderHook((props: { werkblad: string }) =>
|
||||||
|
useWorkbenchBootstrap({ ...state, activeWorkspace: props.werkblad }), {
|
||||||
|
initialProps: { werkblad: 'ai' },
|
||||||
|
})
|
||||||
|
await waitFor(() => expect(state.loadDetectionRuns).toHaveBeenCalledWith('project-1'))
|
||||||
|
const naEerste = state.loadDetectionRuns.mock.calls.length
|
||||||
|
|
||||||
|
rerender({ werkblad: 'map' })
|
||||||
|
// Een bezocht werkblad blijft bijgewerkt worden; het wordt niet opnieuw
|
||||||
|
// dichtgezet zodra de gebruiker wegklikt.
|
||||||
|
expect(state.loadDetectionRuns.mock.calls.length).toBeGreaterThanOrEqual(naEerste)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('meldt een mislukte laadactie in plaats van haar weg te slikken', async () => {
|
||||||
|
const state = options('project-1')
|
||||||
|
state.loadProjectData = vi.fn().mockRejectedValue(new Error('netwerk weg'))
|
||||||
|
const gemeld: string[] = []
|
||||||
|
renderHook(() => useWorkbenchBootstrap({ ...state, onLoadError: (onderdeel) => gemeld.push(onderdeel) }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(gemeld).toContain('gebieden en bronnen'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('laadt voor een gast hetzelfde als voor een operator', async () => {
|
||||||
|
const state = { ...options('project-1', 'ai'), restrictedMode: true }
|
||||||
|
renderHook(() => useWorkbenchBootstrap(state))
|
||||||
|
|
||||||
|
await waitFor(() => expect(state.loadProjectData).toHaveBeenCalledWith('project-1'))
|
||||||
|
expect(state.loadDetectionModels).toHaveBeenCalledOnce()
|
||||||
|
expect(state.loadDetectionRuns).toHaveBeenCalledWith('project-1')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
type AsyncAction = () => Promise<unknown>
|
type AsyncAction = () => Promise<unknown>
|
||||||
type ProjectAction = (projectId: string) => Promise<unknown>
|
type ProjectAction = (projectId: string) => Promise<unknown>
|
||||||
|
|
||||||
interface WorkbenchBootstrapOptions {
|
interface WorkbenchBootstrapOptions {
|
||||||
restrictedMode?: boolean
|
restrictedMode?: boolean
|
||||||
|
/** Het werkblad dat nu open staat. Bepaalt wat er geladen mag worden. */
|
||||||
|
activeWorkspace?: string
|
||||||
|
/** Meldt een mislukte laadactie, zodat de gebruiker het verschil ziet tussen
|
||||||
|
"er is niets" en "het is niet gelukt". */
|
||||||
|
onLoadError?: (onderdeel: string, fout: unknown) => void
|
||||||
selectedProjectId: string | null
|
selectedProjectId: string | null
|
||||||
selectedDetectionRunId: string
|
selectedDetectionRunId: string
|
||||||
detectionClassFilter: string
|
detectionClassFilter: string
|
||||||
@@ -32,6 +37,8 @@ interface WorkbenchBootstrapOptions {
|
|||||||
|
|
||||||
export function useWorkbenchBootstrap({
|
export function useWorkbenchBootstrap({
|
||||||
restrictedMode = false,
|
restrictedMode = false,
|
||||||
|
activeWorkspace = 'map',
|
||||||
|
onLoadError,
|
||||||
selectedProjectId,
|
selectedProjectId,
|
||||||
selectedDetectionRunId,
|
selectedDetectionRunId,
|
||||||
detectionClassFilter,
|
detectionClassFilter,
|
||||||
@@ -56,13 +63,39 @@ export function useWorkbenchBootstrap({
|
|||||||
resetSegmentationForProject,
|
resetSegmentationForProject,
|
||||||
resetExportsForProject,
|
resetExportsForProject,
|
||||||
}: WorkbenchBootstrapOptions): void {
|
}: WorkbenchBootstrapOptions): void {
|
||||||
|
const foutMelder = useRef(onLoadError)
|
||||||
|
foutMelder.current = onLoadError
|
||||||
|
|
||||||
|
// Een mislukte laadactie werd overal met .catch(() => null) weggeslikt. De
|
||||||
|
// gebruiker zag dan "Nog geen bronnen beschikbaar", precies hetzelfde scherm
|
||||||
|
// als wanneer er echt niets is. Nu wordt de fout doorgegeven.
|
||||||
|
const meld = (onderdeel: string) => (fout: unknown) => {
|
||||||
|
foutMelder.current?.(onderdeel, fout)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Welke werkbladen welke gegevens nodig hebben. Alles werd voorheen bij het
|
||||||
|
// opstarten opgehaald, ook voor werkbladen die de gebruiker nooit opent; dat
|
||||||
|
// waren 27 verzoeken in drie golven voordat de kaart bruikbaar was.
|
||||||
|
const bezocht = useRef(new Set<string>())
|
||||||
|
bezocht.current.add(activeWorkspace)
|
||||||
|
const geopend = (werkblad: string): boolean => bezocht.current.has(werkblad)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadProjects().catch(() => null)
|
loadProjects().catch(meld('werkruimtes'))
|
||||||
loadCapabilities().catch(() => null)
|
|
||||||
loadDetectionModels().catch(() => null)
|
|
||||||
loadSegmentationModels().catch(() => null)
|
|
||||||
}, [restrictedMode])
|
}, [restrictedMode])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!geopend('system')) return
|
||||||
|
loadCapabilities().catch(meld('bronkoppelingen'))
|
||||||
|
}, [restrictedMode, activeWorkspace])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!geopend('ai')) return
|
||||||
|
loadDetectionModels().catch(meld('detectiemodellen'))
|
||||||
|
loadSegmentationModels().catch(meld('segmentatiemodellen'))
|
||||||
|
}, [restrictedMode, activeWorkspace])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedProjectId) {
|
if (!selectedProjectId) {
|
||||||
resetProjectData()
|
resetProjectData()
|
||||||
@@ -77,18 +110,32 @@ export function useWorkbenchBootstrap({
|
|||||||
resetDetectionForProject()
|
resetDetectionForProject()
|
||||||
resetSegmentationForProject()
|
resetSegmentationForProject()
|
||||||
resetExportsForProject()
|
resetExportsForProject()
|
||||||
loadProjectData(selectedProjectId).catch(() => null)
|
loadProjectData(selectedProjectId).catch(meld('gebieden en bronnen'))
|
||||||
loadQualityChecks(selectedProjectId).catch(() => null)
|
|
||||||
loadDetectionRuns(selectedProjectId).catch(() => null)
|
|
||||||
loadSegmentationRuns(selectedProjectId).catch(() => null)
|
|
||||||
loadExports(selectedProjectId).catch(() => null)
|
|
||||||
}, [restrictedMode, selectedProjectId])
|
}, [restrictedMode, selectedProjectId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadDetectionResults().catch(() => null)
|
if (!selectedProjectId || !geopend('analysis')) return
|
||||||
}, [restrictedMode, selectedDetectionRunId, detectionClassFilter, detectionMinConfidenceFilter])
|
loadQualityChecks(selectedProjectId).catch(meld('kwaliteitscontroles'))
|
||||||
|
}, [restrictedMode, selectedProjectId, activeWorkspace])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSegmentationResults().catch(() => null)
|
if (!selectedProjectId || !geopend('ai')) return
|
||||||
}, [restrictedMode, selectedSegmentationRunId, segmentationClassFilter, segmentationMinConfidenceFilter])
|
loadDetectionRuns(selectedProjectId).catch(meld('detectieruns'))
|
||||||
|
loadSegmentationRuns(selectedProjectId).catch(meld('segmentatieruns'))
|
||||||
|
}, [restrictedMode, selectedProjectId, activeWorkspace])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedProjectId || !geopend('exports')) return
|
||||||
|
loadExports(selectedProjectId).catch(meld('downloads'))
|
||||||
|
}, [restrictedMode, selectedProjectId, activeWorkspace])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!geopend('ai')) return
|
||||||
|
loadDetectionResults().catch(meld('detectieresultaten'))
|
||||||
|
}, [restrictedMode, activeWorkspace, selectedDetectionRunId, detectionClassFilter, detectionMinConfidenceFilter])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!geopend('ai')) return
|
||||||
|
loadSegmentationResults().catch(meld('segmentatieresultaten'))
|
||||||
|
}, [restrictedMode, activeWorkspace, selectedSegmentationRunId, segmentationClassFilter, segmentationMinConfidenceFilter])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { act, renderHook } from '@testing-library/react'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { useWorkbenchTheme } from './useWorkbenchTheme'
|
||||||
|
|
||||||
|
const SLEUTEL = 'geointel.workbench-theme.v1'
|
||||||
|
|
||||||
|
describe('useWorkbenchTheme', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.clear()
|
||||||
|
delete document.body.dataset.theme
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
window.localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('begint donker, ook wanneer het besturingssysteem licht meldt', () => {
|
||||||
|
// Browsers melden standaard 'light', ook als de gebruiker nooit iets
|
||||||
|
// instelde. Daarop afgaan zou vrijwel iedereen in de lichte werkstand
|
||||||
|
// zetten terwijl donker de gekozen richting is.
|
||||||
|
const { result } = renderHook(() => useWorkbenchTheme())
|
||||||
|
expect(result.current.theme).toBe('dark')
|
||||||
|
expect(document.body.dataset.theme).toBe('dark')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('schakelt om en zet het attribuut op body', () => {
|
||||||
|
const { result } = renderHook(() => useWorkbenchTheme())
|
||||||
|
act(() => result.current.toggleTheme())
|
||||||
|
expect(result.current.theme).toBe('light')
|
||||||
|
expect(document.body.dataset.theme).toBe('light')
|
||||||
|
|
||||||
|
act(() => result.current.toggleTheme())
|
||||||
|
expect(result.current.theme).toBe('dark')
|
||||||
|
expect(document.body.dataset.theme).toBe('dark')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('onthoudt de keuze voor een volgende sessie', () => {
|
||||||
|
const eerste = renderHook(() => useWorkbenchTheme())
|
||||||
|
act(() => eerste.result.current.toggleTheme())
|
||||||
|
eerste.unmount()
|
||||||
|
|
||||||
|
expect(window.localStorage.getItem(SLEUTEL)).toBe('light')
|
||||||
|
|
||||||
|
const tweede = renderHook(() => useWorkbenchTheme())
|
||||||
|
expect(tweede.result.current.theme).toBe('light')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blijft werken wanneer opslag geblokkeerd is', () => {
|
||||||
|
const origineel = window.localStorage.setItem
|
||||||
|
window.localStorage.setItem = () => {
|
||||||
|
throw new Error('opslag geweigerd')
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { result } = renderHook(() => useWorkbenchTheme())
|
||||||
|
act(() => result.current.toggleTheme())
|
||||||
|
// Niet kunnen bewaren mag het omschakelen niet blokkeren.
|
||||||
|
expect(result.current.theme).toBe('light')
|
||||||
|
} finally {
|
||||||
|
window.localStorage.setItem = origineel
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export type WorkbenchTheme = 'dark' | 'light'
|
||||||
|
|
||||||
|
const OPSLAGSLEUTEL = 'geointel.workbench-theme.v1'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* De werkstand van de werkbank.
|
||||||
|
*
|
||||||
|
* Donker is de standaard: de werkbank staat tegen luchtbeelden en
|
||||||
|
* satellietdata aan, en een lichte schil daarnaast laat de kaart altijd
|
||||||
|
* verliezen. Maar donker-alleen is geen antwoord voor wie op een fel verlichte
|
||||||
|
* locatie werkt of een scherm deelt met een beamer, dus de keuze blijft.
|
||||||
|
*
|
||||||
|
* De keuze wordt per browser bewaard. Wie niets kiest krijgt donker. Het
|
||||||
|
* voorkeurssignaal van het besturingssysteem telt bewust niet mee: browsers
|
||||||
|
* melden standaard 'light', ook als de gebruiker nooit iets ingesteld heeft,
|
||||||
|
* en dan zou vrijwel iedereen in de lichte werkstand landen terwijl donker de
|
||||||
|
* gekozen richting van dit product is. Licht is een bewuste keuze, geen
|
||||||
|
* gevolg van een standaardwaarde elders.
|
||||||
|
*/
|
||||||
|
function beginwaarde(): WorkbenchTheme {
|
||||||
|
if (typeof window === 'undefined') return 'dark'
|
||||||
|
try {
|
||||||
|
const bewaard = window.localStorage.getItem(OPSLAGSLEUTEL)
|
||||||
|
if (bewaard === 'light' || bewaard === 'dark') return bewaard
|
||||||
|
} catch {
|
||||||
|
// Privémodus of geblokkeerde opslag: dan geldt gewoon de standaard.
|
||||||
|
}
|
||||||
|
return 'dark'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkbenchTheme(): { theme: WorkbenchTheme; toggleTheme: () => void } {
|
||||||
|
const [theme, setTheme] = useState<WorkbenchTheme>(beginwaarde)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.dataset.theme = theme
|
||||||
|
return () => {
|
||||||
|
delete document.body.dataset.theme
|
||||||
|
}
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
|
const toggleTheme = useCallback(() => {
|
||||||
|
setTheme((huidig) => {
|
||||||
|
const volgend: WorkbenchTheme = huidig === 'dark' ? 'light' : 'dark'
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(OPSLAGSLEUTEL, volgend)
|
||||||
|
} catch {
|
||||||
|
// Niet kunnen bewaren mag het omschakelen niet blokkeren.
|
||||||
|
}
|
||||||
|
return volgend
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { theme, toggleTheme }
|
||||||
|
}
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
// Elk geladen gewicht komt overeen met een stap uit de schaal in
|
||||||
|
// geointel-system.css. Gewichten die niet geladen zijn vallen terug op het
|
||||||
|
// dichtstbijzijnde gezicht, waardoor negen gedeclareerde gewichten er twee
|
||||||
|
// worden en de hiërarchie verdwijnt.
|
||||||
|
import '@fontsource/manrope/latin-600.css'
|
||||||
|
import '@fontsource/manrope/latin-700.css'
|
||||||
|
import '@fontsource/public-sans/latin-400.css'
|
||||||
|
import '@fontsource/public-sans/latin-500.css'
|
||||||
|
import '@fontsource/public-sans/latin-600.css'
|
||||||
|
import '@fontsource/public-sans/latin-700.css'
|
||||||
import './styles/app.css'
|
import './styles/app.css'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { apiGet, apiPost } from './client'
|
||||||
|
|
||||||
|
function antwoord(data: unknown) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ data }),
|
||||||
|
} as unknown as Response
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('apiGet deelt verzoeken', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.runOnlyPendingTimers()
|
||||||
|
vi.useRealTimers()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stuurt één verzoek wanneer drie aanroepers tegelijk hetzelfde vragen', async () => {
|
||||||
|
const fetchSpy = vi.fn().mockResolvedValue(antwoord({ items: [1] }))
|
||||||
|
vi.stubGlobal('fetch', fetchSpy)
|
||||||
|
|
||||||
|
const [a, b, c] = await Promise.all([
|
||||||
|
apiGet<{ items: number[] }>('/api/v1/projects'),
|
||||||
|
apiGet<{ items: number[] }>('/api/v1/projects'),
|
||||||
|
apiGet<{ items: number[] }>('/api/v1/projects'),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||||
|
expect(a).toEqual({ items: [1] })
|
||||||
|
expect(b).toBe(a)
|
||||||
|
expect(c).toBe(a)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deelt ook wanneer het tweede verzoek kort ná het eerste komt', async () => {
|
||||||
|
// Dit was het echte geval bij het opstarten: twee effecten die ongeveer
|
||||||
|
// negentig milliseconde na elkaar dezelfde gegevens opvroegen, dus de
|
||||||
|
// eerste was al klaar voordat de tweede begon.
|
||||||
|
const fetchSpy = vi.fn().mockResolvedValue(antwoord({ items: [] }))
|
||||||
|
vi.stubGlobal('fetch', fetchSpy)
|
||||||
|
|
||||||
|
await apiGet('/api/v1/projects/p-1/datasets')
|
||||||
|
await vi.advanceTimersByTimeAsync(90)
|
||||||
|
await apiGet('/api/v1/projects/p-1/datasets')
|
||||||
|
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('haalt opnieuw op zodra het deelvenster voorbij is', async () => {
|
||||||
|
const fetchSpy = vi.fn().mockResolvedValue(antwoord({ items: [] }))
|
||||||
|
vi.stubGlobal('fetch', fetchSpy)
|
||||||
|
|
||||||
|
await apiGet('/api/v1/projects/p-1/areas')
|
||||||
|
await vi.advanceTimersByTimeAsync(400)
|
||||||
|
await apiGet('/api/v1/projects/p-1/areas')
|
||||||
|
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('houdt verschillende paden uit elkaar', async () => {
|
||||||
|
const fetchSpy = vi.fn().mockResolvedValue(antwoord({ items: [] }))
|
||||||
|
vi.stubGlobal('fetch', fetchSpy)
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
apiGet('/api/v1/projects'),
|
||||||
|
apiGet('/api/v1/projects?name=Kempen&limit=1'),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deelt geen POST — twee keer versturen is een andere handeling', async () => {
|
||||||
|
const fetchSpy = vi.fn().mockResolvedValue(antwoord({ ok: true }))
|
||||||
|
vi.stubGlobal('fetch', fetchSpy)
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
apiPost('/api/v1/projects', { name: 'a' }),
|
||||||
|
apiPost('/api/v1/projects', { name: 'a' }),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('laat een mislukt verzoek niet in de tabel achter', async () => {
|
||||||
|
const fetchSpy = vi.fn()
|
||||||
|
.mockRejectedValueOnce(new Error('netwerk weg'))
|
||||||
|
.mockResolvedValue(antwoord({ items: [] }))
|
||||||
|
vi.stubGlobal('fetch', fetchSpy)
|
||||||
|
|
||||||
|
await expect(apiGet('/api/v1/detection/models')).rejects.toThrow('netwerk weg')
|
||||||
|
await vi.advanceTimersByTimeAsync(400)
|
||||||
|
await expect(apiGet('/api/v1/detection/models')).resolves.toEqual({ items: [] })
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -31,9 +31,48 @@ async function parseResponse<T>(response: Response): Promise<T> {
|
|||||||
return payload.data as T;
|
return payload.data as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gelijktijdige identieke GET-verzoeken worden gedeeld.
|
||||||
|
*
|
||||||
|
* Bij het opstarten vroegen drie verschillende plekken tegelijk om
|
||||||
|
* /api/v1/projects, en areas en datasets elk twee keer — 27 verzoeken in totaal
|
||||||
|
* waarvan vijf overbodig. Elke aanroeper hier krijgt dezelfde belofte zolang
|
||||||
|
* het verzoek onderweg is; zodra het klaar is verdwijnt het uit de tabel, dus
|
||||||
|
* er wordt niets gecachet en een volgende aanroep haalt gewoon opnieuw op.
|
||||||
|
*
|
||||||
|
* Alleen GET. Een POST twee keer versturen is een andere handeling en mag
|
||||||
|
* nooit stilzwijgend samengevoegd worden.
|
||||||
|
*/
|
||||||
|
const lopendeGets = new Map<string, Promise<unknown>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hoe lang een afgerond GET-verzoek nog gedeeld wordt.
|
||||||
|
*
|
||||||
|
* Puur gelijktijdige verzoeken samenvoegen was niet genoeg: bij het opstarten
|
||||||
|
* vroegen twee verschillende effecten dezelfde gebieden en bronnen op met
|
||||||
|
* ongeveer negentig milliseconde ertussen, dus was de eerste al klaar voordat
|
||||||
|
* de tweede begon. Dit venster is kort genoeg om nooit verouderde gegevens te
|
||||||
|
* tonen — een gebruiker die op "Vernieuwen" drukt zit er ruim boven — en lang
|
||||||
|
* genoeg om de opstartcascade op te vangen.
|
||||||
|
*/
|
||||||
|
const DEEL_VENSTER_MS = 300;
|
||||||
|
|
||||||
export async function apiGet<T>(path: string): Promise<T> {
|
export async function apiGet<T>(path: string): Promise<T> {
|
||||||
const response = await fetch(apiUrl(path), { credentials: "same-origin" });
|
const lopend = lopendeGets.get(path);
|
||||||
return parseResponse<T>(response);
|
if (lopend) {
|
||||||
|
return lopend as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const verzoek = fetch(apiUrl(path), { credentials: "same-origin" })
|
||||||
|
.then((response) => parseResponse<T>(response))
|
||||||
|
.finally(() => {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
lopendeGets.delete(path);
|
||||||
|
}, DEEL_VENSTER_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
lopendeGets.set(path, verzoek);
|
||||||
|
return verzoek;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiPost<T>(path: string, body?: object): Promise<T> {
|
export async function apiPost<T>(path: string, body?: object): Promise<T> {
|
||||||
|
|||||||
Reference in New Issue
Block a user