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:
Jens
2026-08-23 15:00:57 +02:00
co-authored by Claude Opus 5
parent 663855f8a4
commit 70157400d9
16 changed files with 2202 additions and 1532 deletions
+69 -30
View File
@@ -5,6 +5,7 @@ import 'maplibre-gl/dist/maplibre-gl.css'
import { NATIONAL_MAP_CENTER, NATIONAL_MAP_ZOOM } from '../config/primaryFocus'
import { featureCollectionBounds } from '../lib/geojsonBounds'
import type { MapImageOverlay, MapViewportState, VectorSelectionBBox } from '../types'
import { basemapGround, basemapPaint, huidigeWerkstand, mapSymbology } from './map/mapSymbology'
interface GeoMapProps {
data: GeoJSON.FeatureCollection | null
@@ -46,10 +47,23 @@ const DEFAULT_ROAD_BASEMAP_STYLE: maplibregl.StyleSpecification = {
},
},
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',
type: 'raster',
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 [
'case',
['==', ['get', 'layer_type'], 'municipality_boundary'],
'#0f766e',
mapSymbology.boundary,
[
'match',
['get', 'change_type'],
'added',
'#16a34a',
mapSymbology.added,
'removed',
'#dc2626',
mapSymbology.removed,
'modified',
'#d97706',
mapSymbology.modified,
'unchanged',
'#2563eb',
mapSymbology.unchanged,
fallbackColor,
],
]
@@ -79,18 +93,18 @@ function datasetLineColor(fallbackColor: string): ExpressionSpecification {
return [
'case',
['==', ['get', 'layer_type'], 'municipality_boundary'],
'#0f5f59',
mapSymbology.boundaryStrong,
[
'match',
['get', 'change_type'],
'added',
'#15803d',
mapSymbology.added,
'removed',
'#b91c1c',
mapSymbology.removed,
'modified',
'#b45309',
mapSymbology.modified,
'unchanged',
'#1d4ed8',
mapSymbology.unchanged,
fallbackColor,
],
]
@@ -144,8 +158,8 @@ function bboxToFeatureCollection(
function GeoMap({
data,
dataFillColor = '#f97316',
dataLineColor = '#ea580c',
dataFillColor = mapSymbology.dataFill,
dataLineColor = mapSymbology.dataLine,
areaData = null,
selectedFeature = null,
selectionData = null,
@@ -236,6 +250,27 @@ function GeoMap({
}
}, [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(() => {
if (!containerRef.current || mapRef.current) {
return
@@ -491,7 +526,7 @@ function GeoMap({
type: 'fill',
source: 'area',
paint: {
'fill-color': '#0f766e',
'fill-color': mapSymbology.boundary,
'fill-opacity': 0.18,
},
},
@@ -503,7 +538,7 @@ function GeoMap({
type: 'line',
source: 'area',
paint: {
'line-color': '#0f766e',
'line-color': mapSymbology.boundary,
'line-width': 3,
'line-dasharray': [2, 1],
},
@@ -576,7 +611,7 @@ function GeoMap({
source: 'selected-feature',
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon'], true, false],
paint: {
'fill-color': '#fde047',
'fill-color': mapSymbology.selectionFill,
'fill-opacity': 0.32,
},
})
@@ -586,7 +621,7 @@ function GeoMap({
source: 'selected-feature',
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false],
paint: {
'line-color': '#854d0e',
'line-color': mapSymbology.selectionLine,
'line-width': 4,
},
})
@@ -596,9 +631,9 @@ function GeoMap({
source: 'selected-feature',
filter: ['match', ['geometry-type'], ['Point', 'MultiPoint'], true, false],
paint: {
'circle-color': '#fde047',
'circle-color': mapSymbology.selectionFill,
'circle-radius': 7,
'circle-stroke-color': '#854d0e',
'circle-stroke-color': mapSymbology.selectionLine,
'circle-stroke-width': 2,
},
})
@@ -620,7 +655,7 @@ function GeoMap({
type: 'fill',
source: 'selection-bbox',
paint: {
'fill-color': '#38bdf8',
'fill-color': mapSymbology.waterFill,
'fill-opacity': 0.12,
},
})
@@ -629,7 +664,7 @@ function GeoMap({
type: 'line',
source: 'selection-bbox',
paint: {
'line-color': '#0369a1',
'line-color': mapSymbology.waterLine,
'line-width': 2,
'line-dasharray': [2, 1],
},
@@ -656,7 +691,7 @@ function GeoMap({
source: 'selection-result',
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon'], true, false],
paint: {
'fill-color': '#7c3aed',
'fill-color': mapSymbology.detectionFill,
'fill-opacity': 0.24,
},
})
@@ -666,7 +701,7 @@ function GeoMap({
source: 'selection-result',
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false],
paint: {
'line-color': '#5b21b6',
'line-color': mapSymbology.detectionLine,
'line-width': 3,
},
})
@@ -676,9 +711,9 @@ function GeoMap({
source: 'selection-result',
filter: ['match', ['geometry-type'], ['Point', 'MultiPoint'], true, false],
paint: {
'circle-color': '#7c3aed',
'circle-color': mapSymbology.detectionFill,
'circle-radius': 6,
'circle-stroke-color': '#ffffff',
'circle-stroke-color': mapSymbology.pointStroke,
'circle-stroke-width': 2,
},
})
@@ -701,14 +736,14 @@ function GeoMap({
'match',
['get', 'qa_evidence_role'],
'match_candidate',
'#2563eb',
mapSymbology.unchanged,
'match_reference',
'#0f766e',
mapSymbology.boundary,
'false_positive',
'#dc2626',
mapSymbology.removed,
'false_negative',
'#d97706',
'#475569',
mapSymbology.modified,
mapSymbology.fallback,
] as ExpressionSpecification
map.addLayer({
id: 'qa-evidence-fill',
@@ -746,7 +781,7 @@ function GeoMap({
paint: {
'circle-color': evidenceColor,
'circle-radius': 7,
'circle-stroke-color': '#ffffff',
'circle-stroke-color': mapSymbology.pointStroke,
'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
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { BadgeCheck, Database, Download, MapPinned, ScanSearch } from 'lucide-react'
import '../../styles/landing-project-story.css'
import { useDeferredBackground } from '../../hooks/useDeferredBackground'
const stages = [
{
@@ -46,6 +47,7 @@ const stages = [
] as const
export function LandingProjectStory(): JSX.Element {
const kaartRef = useDeferredBackground<HTMLDivElement>()
const [activeIndex, setActiveIndex] = useState(0)
const activeStage = stages[activeIndex]
@@ -104,7 +106,7 @@ export function LandingProjectStory(): JSX.Element {
</div>
<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">
<defs>
<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 { MapWorkspaceViewModel } from './useMapWorkspaceViewModel'
import { ThemeSearchField } from './ThemeSearchField'
interface MapExplorerViewProps {
props: MapWorkspaceProps
@@ -369,16 +370,7 @@ export function MapExplorerView({ props, view }: MapExplorerViewProps): JSX.Elem
: 'Zoek optioneel een gemeente of teken vrij op de kaart'}
</small>
</div>
<label className="geo-theme-search">
<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>
<ThemeSearchField waarde={themeFilter} onChange={setThemeFilter} />
<div className="geo-theme-list">
{visibleThemes.map((theme) => {
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 boven­liggende 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>
)
}