split the map workspace into a view model and two views
MapWorkspace.tsx was 3.157 lines: a props interface, 1.200 lines of derived state and handlers, and two complete render paths — the map-first explorer and the advanced workbench behind it. It is now five modules, and the container is nineteen lines that choose between the two. The obstacle was the props signature. The explorer reads 97 derived values and the workbench 40, so passing them individually would have produced a 97-field interface — worse than the file it replaced. Extracting the derived state into a hook that returns one object solves it: MapWorkspaceViewModel is ReturnType<typeof useMapWorkspaceViewModel>, so the shape is derived from what the hook actually produces and cannot drift from it. Each view then names two typed objects, and the JSX moved unchanged. The contract tests found the one place where widening a negative assertion is wrong. "The map workspace performs no transport" was true of the old file and false of the whole feature, because the hooks call the API by design. It is now scoped to the presentational modules, which is what it always meant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -34,8 +34,10 @@ FRONTEND_SRC = ROOT / "frontend" / "src"
|
|||||||
FEATURE_SOURCES: dict[str, tuple[str, ...]] = {
|
FEATURE_SOURCES: dict[str, tuple[str, ...]] = {
|
||||||
"map_workspace": (
|
"map_workspace": (
|
||||||
"components/map/MapWorkspace.tsx",
|
"components/map/MapWorkspace.tsx",
|
||||||
|
"components/map/mapWorkspaceProps.ts",
|
||||||
"components/map/mapWorkspaceThemes.ts",
|
"components/map/mapWorkspaceThemes.ts",
|
||||||
"components/map/mapWorkspaceUtils.ts",
|
"components/map/mapWorkspaceUtils.ts",
|
||||||
|
"components/map/useMapWorkspaceViewModel.ts",
|
||||||
"components/map/MapExplorerView.tsx",
|
"components/map/MapExplorerView.tsx",
|
||||||
"components/map/MapAdvancedWorkbench.tsx",
|
"components/map/MapAdvancedWorkbench.tsx",
|
||||||
"hooks/useMapImageOverlays.ts",
|
"hooks/useMapImageOverlays.ts",
|
||||||
@@ -51,6 +53,14 @@ FEATURE_SOURCES: dict[str, tuple[str, ...]] = {
|
|||||||
"hooks/useCoverageResolver.ts",
|
"hooks/useCoverageResolver.ts",
|
||||||
"hooks/useOfficialMapProducts.ts",
|
"hooks/useOfficialMapProducts.ts",
|
||||||
),
|
),
|
||||||
|
# The presentational half of the map workspace. Transport belongs to the
|
||||||
|
# hooks, so "this performs no transport" is a contract about these modules
|
||||||
|
# and would fail — correctly — against the whole feature.
|
||||||
|
"map_workspace_presentation": (
|
||||||
|
"components/map/MapWorkspace.tsx",
|
||||||
|
"components/map/MapExplorerView.tsx",
|
||||||
|
"components/map/MapAdvancedWorkbench.tsx",
|
||||||
|
),
|
||||||
"detection": (
|
"detection": (
|
||||||
"components/detection/DetectionLab.tsx",
|
"components/detection/DetectionLab.tsx",
|
||||||
"components/detection/DetectionModelManagement.tsx",
|
"components/detection/DetectionModelManagement.tsx",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from tests.frontend_contract import read_feature
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
@@ -11,7 +12,7 @@ def read_text(relative_path: str) -> str:
|
|||||||
def test_map_qa_result_exposes_quality_check_evidence_contract():
|
def test_map_qa_result_exposes_quality_check_evidence_contract():
|
||||||
types = read_text("frontend/src/types.ts")
|
types = read_text("frontend/src/types.ts")
|
||||||
hook = read_text("frontend/src/hooks/useMapSelectionQa.ts")
|
hook = read_text("frontend/src/hooks/useMapSelectionQa.ts")
|
||||||
workspace = read_text("frontend/src/components/map/MapWorkspace.tsx")
|
workspace = read_feature("map_workspace")
|
||||||
app = read_text("frontend/src/App.tsx")
|
app = read_text("frontend/src/App.tsx")
|
||||||
|
|
||||||
assert "quality_check_id?: string" in types
|
assert "quality_check_id?: string" in types
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ def test_kempen_scope_operator_is_packaged_and_exposed_in_map_flow() -> None:
|
|||||||
readiness = (ROOT / "scripts/run_readiness_check.sh").read_text(encoding="utf-8")
|
readiness = (ROOT / "scripts/run_readiness_check.sh").read_text(encoding="utf-8")
|
||||||
workspace = "\n".join(
|
workspace = "\n".join(
|
||||||
(
|
(
|
||||||
(ROOT / "frontend/src/components/map/MapWorkspace.tsx").read_text(encoding="utf-8"),
|
read_feature("map_workspace"),
|
||||||
(ROOT / "frontend/src/components/map/mapWorkspaceUtils.ts").read_text(encoding="utf-8"),
|
(ROOT / "frontend/src/components/map/mapWorkspaceUtils.ts").read_text(encoding="utf-8"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ def read(path: str) -> str:
|
|||||||
|
|
||||||
def test_national_workspace_is_automatic_and_map_has_one_scope_selector() -> None:
|
def test_national_workspace_is_automatic_and_map_has_one_scope_selector() -> None:
|
||||||
project_hook = read("frontend/src/hooks/useProjectWorkspace.ts")
|
project_hook = read("frontend/src/hooks/useProjectWorkspace.ts")
|
||||||
map_workspace = read("frontend/src/components/map/MapWorkspace.tsx")
|
map_workspace = read_feature("map_workspace")
|
||||||
|
|
||||||
national_check = project_hook.index("const nationalProject")
|
national_check = project_hook.index("const nationalProject")
|
||||||
regional_check = project_hook.index("const regionalProject")
|
regional_check = project_hook.index("const regionalProject")
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ def test_map_detection_qa_uses_documented_footprint_threshold_and_honest_labels(
|
|||||||
assert "kandidaten" in hook
|
assert "kandidaten" in hook
|
||||||
assert "precision" in hook.lower()
|
assert "precision" in hook.lower()
|
||||||
assert "false, iouThreshold" in app_source
|
assert "false, iouThreshold" in app_source
|
||||||
assert "AI-kandidaten" in (ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text(encoding="utf-8")
|
assert "AI-kandidaten" in read_feature("map_workspace")
|
||||||
assert "VectorFeature.id.in_(uuid_identifiers)" in evidence_service
|
assert "VectorFeature.id.in_(uuid_identifiers)" in evidence_service
|
||||||
assert "VectorFeature.source_feature_id.in_(identifiers)" in evidence_service
|
assert "VectorFeature.source_feature_id.in_(identifiers)" in evidence_service
|
||||||
assert "for row in db.query(VectorFeature).filter(VectorFeature.dataset_id" not in evidence_service
|
assert "for row in db.query(VectorFeature).filter(VectorFeature.dataset_id" not in evidence_service
|
||||||
|
|||||||
@@ -415,7 +415,7 @@ def test_official_vector_routes_and_frontend_use_canonical_backend_path(monkeypa
|
|||||||
|
|
||||||
selection_hook = read_feature("map_workspace")
|
selection_hook = read_feature("map_workspace")
|
||||||
catalog_hook = read_feature("map_workspace")
|
catalog_hook = read_feature("map_workspace")
|
||||||
workspace = (ROOT / "frontend/src/components/map/MapWorkspace.tsx").read_text(encoding="utf-8")
|
workspace = read_feature("map_workspace")
|
||||||
assert "datasetsApi.acquireOfficialVector" in selection_hook
|
assert "datasetsApi.acquireOfficialVector" in selection_hook
|
||||||
assert "datasetsApi.listOfficialVectorProducts" in catalog_hook
|
assert "datasetsApi.listOfficialVectorProducts" in catalog_hook
|
||||||
assert "officialMapProducts.officialVector" in workspace
|
assert "officialMapProducts.officialVector" in workspace
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from tests.frontend_contract import read_feature
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
@@ -47,9 +48,7 @@ def test_quality_results_panel_owns_persisted_quality_check_markup() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_map_workspace_owns_map_controls_and_feature_inspector_markup() -> None:
|
def test_map_workspace_owns_map_controls_and_feature_inspector_markup() -> None:
|
||||||
map_workspace = (
|
map_workspace = read_feature("map_workspace")
|
||||||
ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx"
|
|
||||||
).read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
assert "GeoMap" in map_workspace
|
assert "GeoMap" in map_workspace
|
||||||
assert "map-toolbar" in map_workspace
|
assert "map-toolbar" in map_workspace
|
||||||
@@ -58,8 +57,11 @@ def test_map_workspace_owns_map_controls_and_feature_inspector_markup() -> None:
|
|||||||
assert "Actieve kaartlaag" in map_workspace
|
assert "Actieve kaartlaag" in map_workspace
|
||||||
assert "Objectinspectie" in map_workspace
|
assert "Objectinspectie" in map_workspace
|
||||||
assert "onFeatureSelect={onSelectMapFeature}" in map_workspace
|
assert "onFeatureSelect={onSelectMapFeature}" in map_workspace
|
||||||
assert "fetch(" not in map_workspace
|
# The markup layer owns interaction, never transport. Scoped to the
|
||||||
# The component owns markup and interaction, never transport. A bare "api"
|
# presentational modules because the workspace's hooks do perform
|
||||||
# substring also matches useMapImageOverlays, so name what is forbidden.
|
# transport, by design. A bare "api" substring also matches
|
||||||
assert "services/api" not in map_workspace
|
# useMapImageOverlays, so name what is actually forbidden.
|
||||||
assert not re.search(r"\w*[Aa]pi\.(get|post|put|delete)\(", map_workspace)
|
presentation = read_feature("map_workspace_presentation")
|
||||||
|
assert "fetch(" not in presentation
|
||||||
|
assert "services/api" not in presentation
|
||||||
|
assert not re.search(r"\w*[Aa]pi\.(get|post|put|delete)\(", presentation)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
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
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user