Polish workbench workspace usability
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-06-17 20:57:43 +02:00
parent 76f9b49538
commit a3df753732
14 changed files with 751 additions and 296 deletions
+9
View File
@@ -16,6 +16,15 @@
- Kept existing hooks, API contracts, backend behavior, migrations, provider behavior and AI configuration unchanged. - Kept existing hooks, API contracts, backend behavior, migrations, provider behavior and AI configuration unchanged.
- Added static regression coverage for the new shell regions and workspace navigation anchors. - Added static regression coverage for the new shell regions and workspace navigation anchors.
## Sprint 50 Workspace usability polish (2026-06-17)
- Reworked the Data workspace panels into compact operator forms and scan-friendly project/AOI/dataset cards.
- Reworked the Map workspace controls into a layer toolbar with clearer AOI/layer status.
- Reworked Detection Lab and Segmentation Lab into model, run, result and QA blocks instead of raw stacked controls.
- Added responsive card/form styling so nested workspaces do not overflow inside the shell.
- Added static regression coverage for the polished workspace structure.
- No API contracts, migrations, backend behavior, provider fetching or AI model behavior changed.
## Sprint 48 Backend API contract audit (2026-06-17) ## Sprint 48 Backend API contract audit (2026-06-17)
- Added `scripts/audit_api_contracts.py` to compare the active FastAPI route surface with `docs/API_CONTRACTS.md`. - Added `scripts/audit_api_contracts.py` to compare the active FastAPI route surface with `docs/API_CONTRACTS.md`.
+1 -1
View File
@@ -28,7 +28,7 @@ def test_app_wires_map_workbench_component() -> None:
assert "selectedMapFeature" in app assert "selectedMapFeature" in app
assert "<MapWorkspace" in app assert "<MapWorkspace" in app
assert "onSelectMapFeature={setSelectedMapFeature}" in app assert "onSelectMapFeature={setSelectedMapFeature}" in app
assert "Layer visible" in component assert "Active layer" in component
assert "Layer opacity" in component assert "Layer opacity" in component
assert "Feature inspector" in component assert "Feature inspector" in component
assert "onFeatureSelect={onSelectMapFeature}" in component assert "onFeatureSelect={onSelectMapFeature}" in component
@@ -54,7 +54,7 @@ def test_frontend_wires_selected_area_map_overlay_contract() -> None:
assert "areaFeatureCollection" in app assert "areaFeatureCollection" in app
assert "areaFeatureCollection={areaFeatureCollection}" in app assert "areaFeatureCollection={areaFeatureCollection}" in app
assert "areaData={areaFeatureCollection}" in map_workspace assert "areaData={areaFeatureCollection}" in map_workspace
assert "Area visible" in map_workspace assert "AOI" in map_workspace
assert "area-fill" in geomap assert "area-fill" in geomap
assert "area-line" in geomap assert "area-line" in geomap
assert "onSelectMapArea" in area_panel assert "onSelectMapArea" in area_panel
@@ -65,7 +65,7 @@ def test_app_still_wires_dataset_ui_callbacks() -> None:
assert "onRunVectorClip={runVectorClip}" in app assert "onRunVectorClip={runVectorClip}" in app
assert "onRunVectorBuffer={runVectorBuffer}" in app assert "onRunVectorBuffer={runVectorBuffer}" in app
assert "onRunVectorIntersect={() => runVectorIntersect(availableVectorTargets)}" in app assert "onRunVectorIntersect={() => runVectorIntersect(availableVectorTargets)}" in app
assert "<form onSubmit={onUploadDataset}" in dataset_panel assert '<form className="dataset-upload-form" onSubmit={onUploadDataset}' in dataset_panel
assert "onClick={() => onLoadDatasetDetails(selectedProjectId ?? '', dataset)}" in dataset_panel assert "onClick={() => onLoadDatasetDetails(selectedProjectId ?? '', dataset)}" in dataset_panel
assert "onClick={() => onRefreshMetadata(dataset.id)}" in dataset_panel assert "onClick={() => onRefreshMetadata(dataset.id)}" in dataset_panel
assert "onRunRasterInspect={onRunRasterInspect}" in detail_panel assert "onRunRasterInspect={onRunRasterInspect}" in detail_panel
@@ -51,9 +51,10 @@ def test_map_workspace_owns_map_controls_and_feature_inspector_markup() -> None:
).read_text(encoding="utf-8") ).read_text(encoding="utf-8")
assert "GeoMap" in map_workspace assert "GeoMap" in map_workspace
assert "Selected area" in map_workspace assert "map-toolbar" in map_workspace
assert "Area visible" in map_workspace assert "Area" in map_workspace
assert "Layer visible" in map_workspace assert "AOI" in map_workspace
assert "Active layer" in map_workspace
assert "Feature inspector" in map_workspace assert "Feature inspector" in map_workspace
assert "onFeatureSelect={onSelectMapFeature}" in map_workspace assert "onFeatureSelect={onSelectMapFeature}" in map_workspace
assert "fetch(" not in map_workspace assert "fetch(" not in map_workspace
@@ -0,0 +1,52 @@
from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def test_data_workspace_panels_use_operator_friendly_cards_and_forms() -> None:
project_panel = (ROOT / "frontend" / "src" / "components" / "project" / "ProjectPanel.tsx").read_text(
encoding="utf-8"
)
area_panel = (ROOT / "frontend" / "src" / "components" / "project" / "AreaPanel.tsx").read_text(
encoding="utf-8"
)
dataset_panel = (ROOT / "frontend" / "src" / "components" / "datasets" / "DatasetPanel.tsx").read_text(
encoding="utf-8"
)
assert "compact-form" in project_panel
assert "entity-card-active" in project_panel
assert "AOI manager" in area_panel
assert "compact-form" in area_panel
assert "entity-card-active" in area_panel
assert "dataset-upload-form" in dataset_panel
assert "dataset-card" in dataset_panel
assert "status-badge-ready" in dataset_panel
def test_map_and_ai_workspaces_use_task_blocks_not_raw_stacks() -> None:
map_workspace = (ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text(
encoding="utf-8"
)
detection_lab = (ROOT / "frontend" / "src" / "components" / "detection" / "DetectionLab.tsx").read_text(
encoding="utf-8"
)
segmentation_lab = (
ROOT / "frontend" / "src" / "components" / "segmentation" / "SegmentationLab.tsx"
).read_text(encoding="utf-8")
styles = (ROOT / "frontend" / "src" / "styles" / "app.css").read_text(encoding="utf-8")
assert "map-toolbar" in map_workspace
assert "layer-control-card" in map_workspace
assert "Spatial review" in map_workspace
assert "model-list" in detection_lab
assert "lab-block" in detection_lab
assert "lab-form-grid" in detection_lab
assert "model-list" in segmentation_lab
assert "lab-block" in segmentation_lab
assert ".dataset-upload-form" in styles
assert ".map-toolbar" in styles
assert ".workspace-grid-ai .lab-form-grid" in styles
+24
View File
@@ -2067,3 +2067,27 @@ Limitations:
Next recommended pass: Next recommended pass:
- Run full readiness, rebuild/deploy Tower, then perform a live browser smoke through the new workbench navigation. - Run full readiness, rebuild/deploy Tower, then perform a live browser smoke through the new workbench navigation.
## Sprint 50 Workspace usability polish (2026-06-17)
Changed:
- Refined the task-based shell workspaces after the first UI refactor.
- Converted Project, AOI and Dataset panels into compact forms and card-based lists for faster scanning.
- Converted the Map workspace controls into a toolbar with dedicated AOI/layer controls and status.
- Converted Detection Lab and Segmentation Lab into model, run, result and QA blocks.
- Added CSS utilities for entity cards, dataset cards, model cards, lab blocks, primary/secondary actions and responsive nested forms.
- Added regression coverage for the polished workspace structure.
Tested:
- `cd frontend && npm run typecheck`
- `cd frontend && npm run build`
- `cd backend && python -m pytest tests/test_sprint47_workbench_interaction_smoke.py tests/test_sprint49_workbench_shell_refactor.py -q`
Open:
- Run full release readiness and deploy the polish to Tower.
Limitations:
- This pass remains frontend-only. It does not add features, change API contracts, alter migrations, fetch live providers or enable new AI models.
Next recommended pass:
- Deploy to Tower and verify the Data, Map and AI Labs workspaces on `http://192.168.10.150:1202`.
@@ -61,10 +61,21 @@ export function DatasetPanel({
onLoadDatasetDetails, onLoadDatasetDetails,
onRefreshMetadata, onRefreshMetadata,
}: DatasetPanelProps) { }: DatasetPanelProps) {
const readyDatasets = datasets.filter((dataset) => dataset.status === 'ready').length
return ( return (
<section data-testid="dataset-panel"> <section data-testid="dataset-panel">
<div className="panel-title-row">
<div>
<p className="eyebrow">Data catalog</p>
<h2>Datasets</h2> <h2>Datasets</h2>
<form onSubmit={onUploadDataset}> </div>
<span className="count-pill">{readyDatasets}/{datasets.length} ready</span>
</div>
<form className="dataset-upload-form" onSubmit={onUploadDataset}>
<label>
Type
<select <select
value={datasetForm.datasetType} value={datasetForm.datasetType}
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, datasetType: event.target.value }))} onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, datasetType: event.target.value }))}
@@ -73,11 +84,17 @@ export function DatasetPanel({
<option value="geojson">geojson</option> <option value="geojson">geojson</option>
<option value="raster">raster</option> <option value="raster">raster</option>
</select> </select>
</label>
<label>
Source
<input <input
value={datasetForm.source} value={datasetForm.source}
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, source: event.target.value }))} onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, source: event.target.value }))}
placeholder="user_upload" placeholder="user_upload"
/> />
</label>
<label>
AOI
<select <select
value={datasetForm.areaId} value={datasetForm.areaId}
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, areaId: event.target.value }))} onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, areaId: event.target.value }))}
@@ -89,29 +106,46 @@ export function DatasetPanel({
</option> </option>
))} ))}
</select> </select>
</label>
<label className="file-input-label">
File
<input <input
type="file" type="file"
accept=".geojson,.json,.tif,.tiff,.geotiff" accept=".geojson,.json,.tif,.tiff,.geotiff"
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, file: event.target.files?.[0] ?? null }))} onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, file: event.target.files?.[0] ?? null }))}
/> />
<button type="submit" disabled={!selectedProjectId}> </label>
<button className="primary-action" type="submit" disabled={!selectedProjectId}>
Upload dataset Upload dataset
</button> </button>
</form> </form>
{loadingDatasets ? <p>Loading datasets...</p> : null} {loadingDatasets ? <p>Loading datasets...</p> : null}
{datasets.length === 0 ? <p>No datasets yet</p> : null} {datasets.length === 0 ? <p>No datasets yet</p> : null}
<ul> <ul className="dataset-list">
{datasets.map((dataset) => ( {datasets.map((dataset) => (
<li key={dataset.id}> <li className="dataset-card" key={dataset.id}>
<div className="dataset-card-header">
<div>
<strong>{dataset.name}</strong> <strong>{dataset.name}</strong>
<div>type: {dataset.dataset_type}</div> <div className="entity-meta">
<div>status: {dataset.status}</div> <span>{dataset.dataset_type}</span>
<div>readiness: {dataset.status === 'ready' ? 'ready' : dataset.status === 'failed' ? 'failed' : 'pending'}</div> <span>{dataset.dataset_role ?? 'source'}</span>
<div>size: {formatBytes(dataset.size_bytes)}</div> <span>{dataset.status}</span>
<div>features: {dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 'n/a'}</div> </div>
<div>bbox: {formatBounds(dataset.bounds_json ?? dataset.vector_summary?.bounds_json)}</div> </div>
<span className={dataset.status === 'ready' ? 'status-badge status-badge-ready' : 'status-badge'}>
{dataset.status === 'ready' ? 'ready' : dataset.status === 'failed' ? 'failed' : 'pending'}
</span>
</div>
<div className="dataset-metrics">
<span>size: {formatBytes(dataset.size_bytes)}</span>
<span>features: {dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 'n/a'}</span>
<span>bbox: {formatBounds(dataset.bounds_json ?? dataset.vector_summary?.bounds_json)}</span>
</div>
<div className="button-row">
<button <button
className="primary-action"
type="button" type="button"
onClick={() => onLoadDatasetDetails(selectedProjectId ?? '', dataset)} onClick={() => onLoadDatasetDetails(selectedProjectId ?? '', dataset)}
data-testid={`dataset-select-${dataset.id}`} data-testid={`dataset-select-${dataset.id}`}
@@ -119,12 +153,14 @@ export function DatasetPanel({
Select / details Select / details
</button> </button>
<button <button
className="secondary-action"
type="button" type="button"
onClick={() => onRefreshMetadata(dataset.id)} onClick={() => onRefreshMetadata(dataset.id)}
disabled={dataset.dataset_type === 'raster'} disabled={dataset.dataset_type === 'raster'}
> >
Refresh metadata Refresh metadata
</button> </button>
</div>
</li> </li>
))} ))}
</ul> </ul>
@@ -86,28 +86,38 @@ export function DetectionLab({
}: DetectionLabProps): JSX.Element { }: DetectionLabProps): JSX.Element {
return ( return (
<section> <section>
<div className="panel-title-row">
<div>
<p className="eyebrow">Object detection</p>
<h2>Detection Lab</h2> <h2>Detection Lab</h2>
<button type="button" onClick={onLoadModels} disabled={loadingDetectionModels}> </div>
Refresh detection models <button className="secondary-action" type="button" onClick={onLoadModels} disabled={loadingDetectionModels}>
Refresh models
</button> </button>
</div>
{loadingDetectionModels ? <p>Loading detection models...</p> : null} {loadingDetectionModels ? <p>Loading detection models...</p> : null}
{detectionModelError ? <p className="error">{detectionModelError}</p> : null} {detectionModelError ? <p className="error">{detectionModelError}</p> : null}
{detectionModels.length === 0 && !loadingDetectionModels ? <p>No detection models reported by backend</p> : null} {detectionModels.length === 0 && !loadingDetectionModels ? <p>No detection models reported by backend</p> : null}
<ul> <ul className="model-list">
{detectionModels.map((model) => ( {detectionModels.map((model) => (
<li key={model.model_id}> <li className={model.configured ? 'model-card model-card-ready' : 'model-card'} key={model.model_id}>
<strong>{model.display_name}</strong> <strong>{model.display_name}</strong>
<div>model: {model.model_id}</div> <span className={model.configured ? 'status-badge status-badge-ready' : 'status-badge'}>{model.status}</span>
<div>framework: {model.framework}</div> <div className="entity-meta">
<div>task: {model.task_type}</div> <span>{model.model_id}</span>
<div>status: {model.status}</div> <span>{model.framework}</span>
<div>configured: {model.configured ? 'yes' : 'no'}</div> <span>{model.task_type}</span>
<div>classes: {model.supported_classes.join(', ')}</div> </div>
<div>limitation: {model.limitation_message}</div> <p className="muted">classes: {model.supported_classes.join(', ')}</p>
<p className="muted">{model.limitation_message}</p>
</li> </li>
))} ))}
</ul> </ul>
<div> <div className="lab-block">
<h3>Run detection</h3>
<div className="lab-form-grid">
<label>
Raster dataset
<select value={selectedDetectionDatasetId} onChange={(event) => onSelectDataset(event.target.value)}> <select value={selectedDetectionDatasetId} onChange={(event) => onSelectDataset(event.target.value)}>
<option value="">Select raster dataset</option> <option value="">Select raster dataset</option>
{rasterDatasets.map((dataset) => ( {rasterDatasets.map((dataset) => (
@@ -116,6 +126,9 @@ export function DetectionLab({
</option> </option>
))} ))}
</select> </select>
</label>
<label>
Model
<select value={selectedDetectionModelId} onChange={(event) => onSelectModel(event.target.value)}> <select value={selectedDetectionModelId} onChange={(event) => onSelectModel(event.target.value)}>
{detectionModels.map((model) => ( {detectionModels.map((model) => (
<option key={model.model_id} value={model.model_id}> <option key={model.model_id} value={model.model_id}>
@@ -123,6 +136,9 @@ export function DetectionLab({
</option> </option>
))} ))}
</select> </select>
</label>
<label>
Min confidence
<input <input
type="number" type="number"
min="0" min="0"
@@ -131,15 +147,20 @@ export function DetectionLab({
value={detectionConfidenceThreshold} value={detectionConfidenceThreshold}
onChange={(event) => onSetConfidenceThreshold(Number(event.target.value))} onChange={(event) => onSetConfidenceThreshold(Number(event.target.value))}
/> />
</label>
</div>
{selectedDetectionModelId === 'yolo-configured' ? ( {selectedDetectionModelId === 'yolo-configured' ? (
<label>
Tile manifest
<input <input
type="text" type="text"
placeholder="Raster tile manifest path" placeholder="Raster tile manifest path"
value={detectionTileManifestPath} value={detectionTileManifestPath}
onChange={(event) => onSetTileManifestPath(event.target.value)} onChange={(event) => onSetTileManifestPath(event.target.value)}
/> />
</label>
) : null} ) : null}
<button type="button" onClick={onRunDetection} disabled={runningDetection || !selectedProjectId || rasterDatasets.length === 0}> <button className="primary-action" type="button" onClick={onRunDetection} disabled={runningDetection || !selectedProjectId || rasterDatasets.length === 0}>
Run detection Run detection
</button> </button>
</div> </div>
@@ -154,11 +175,16 @@ export function DetectionLab({
{detectionRunResult.error_code ? <p className="error">Code: {detectionRunResult.error_code}</p> : null} {detectionRunResult.error_code ? <p className="error">Code: {detectionRunResult.error_code}</p> : null}
</div> </div>
) : null} ) : null}
<div> <div className="lab-block">
<div className="panel-title-row">
<h3>Detection results</h3> <h3>Detection results</h3>
<button type="button" onClick={onLoadRuns} disabled={!selectedProjectId}> <button className="secondary-action" type="button" onClick={onLoadRuns} disabled={!selectedProjectId}>
Refresh detection runs Refresh runs
</button> </button>
</div>
<div className="lab-form-grid">
<label>
Run
<select value={selectedDetectionRunId} onChange={(event) => onSelectRun(event.target.value)}> <select value={selectedDetectionRunId} onChange={(event) => onSelectRun(event.target.value)}>
<option value="">Select detection run</option> <option value="">Select detection run</option>
{detectionRuns.map((run) => ( {detectionRuns.map((run) => (
@@ -167,12 +193,18 @@ export function DetectionLab({
</option> </option>
))} ))}
</select> </select>
</label>
<label>
Class
<input <input
type="text" type="text"
placeholder="Class filter" placeholder="Class filter"
value={detectionClassFilter} value={detectionClassFilter}
onChange={(event) => onSetClassFilter(event.target.value)} onChange={(event) => onSetClassFilter(event.target.value)}
/> />
</label>
<label>
Min confidence
<input <input
type="number" type="number"
min="0" min="0"
@@ -181,7 +213,9 @@ export function DetectionLab({
value={detectionMinConfidenceFilter} value={detectionMinConfidenceFilter}
onChange={(event) => onSetMinConfidenceFilter(Number(event.target.value))} onChange={(event) => onSetMinConfidenceFilter(Number(event.target.value))}
/> />
<button type="button" onClick={onLoadResults} disabled={!selectedDetectionRunId || loadingDetectionResults}> </label>
</div>
<button className="primary-action" type="button" onClick={onLoadResults} disabled={!selectedDetectionRunId || loadingDetectionResults}>
Load detections Load detections
</button> </button>
{loadingDetectionResults ? <p>Loading detection results...</p> : null} {loadingDetectionResults ? <p>Loading detection results...</p> : null}
@@ -209,8 +243,10 @@ export function DetectionLab({
</table> </table>
) : null} ) : null}
</div> </div>
<div> <div className="lab-block">
<h3>Detection QA</h3> <h3>Detection QA</h3>
<label>
Reference dataset
<select value={detectionReferenceDatasetId} onChange={(event) => onSelectReferenceDataset(event.target.value)}> <select value={detectionReferenceDatasetId} onChange={(event) => onSelectReferenceDataset(event.target.value)}>
<option value="">Select reference dataset</option> <option value="">Select reference dataset</option>
{referenceDatasets.map((dataset) => ( {referenceDatasets.map((dataset) => (
@@ -219,7 +255,8 @@ export function DetectionLab({
</option> </option>
))} ))}
</select> </select>
<button type="button" onClick={onRunQa} disabled={runningDetectionQa || !selectedDetectionRunId || !detectionReferenceDatasetId}> </label>
<button className="primary-action" type="button" onClick={onRunQa} disabled={runningDetectionQa || !selectedDetectionRunId || !detectionReferenceDatasetId}>
Compare detections to reference Compare detections to reference
</button> </button>
{detectionQaError ? <p className="error">{detectionQaError}</p> : null} {detectionQaError ? <p className="error">{detectionQaError}</p> : null}
+23 -14
View File
@@ -44,11 +44,17 @@ export function MapWorkspace({
}: MapWorkspaceProps): JSX.Element { }: MapWorkspaceProps): JSX.Element {
return ( return (
<section data-testid="map-workspace"> <section data-testid="map-workspace">
<div className="panel-title-row">
<div>
<p className="eyebrow">Spatial review</p>
<h2>Map workspace</h2> <h2>Map workspace</h2>
<p>{mapLayerLabel}</p> </div>
<div className="map-controls"> <span className="count-pill">{mapFeatureCollection ? `${mapFeatureCount} features` : 'no layer'}</span>
</div>
<div className="map-toolbar">
<label> <label>
Selected area Area
<select <select
value={selectedMapAreaId} value={selectedMapAreaId}
onChange={(event) => onSelectMapArea(event.target.value)} onChange={(event) => onSelectMapArea(event.target.value)}
@@ -63,6 +69,7 @@ export function MapWorkspace({
))} ))}
</select> </select>
</label> </label>
<div className="layer-control-card">
<label className="checkbox-row"> <label className="checkbox-row">
<input <input
checked={areaLayerVisible} checked={areaLayerVisible}
@@ -71,11 +78,10 @@ export function MapWorkspace({
onChange={(event) => onSetAreaLayerVisible(event.target.checked)} onChange={(event) => onSetAreaLayerVisible(event.target.checked)}
data-testid="map-area-visible" data-testid="map-area-visible"
/> />
Area visible AOI
</label> </label>
<label>
Area opacity
<input <input
aria-label="Area opacity"
disabled={!areaFeatureCollection} disabled={!areaFeatureCollection}
max="0.7" max="0.7"
min="0.05" min="0.05"
@@ -85,7 +91,8 @@ export function MapWorkspace({
onChange={(event) => onSetAreaLayerOpacity(Number(event.target.value))} onChange={(event) => onSetAreaLayerOpacity(Number(event.target.value))}
data-testid="map-area-opacity" data-testid="map-area-opacity"
/> />
</label> </div>
<div className="layer-control-card">
<label className="checkbox-row"> <label className="checkbox-row">
<input <input
checked={mapLayerVisible} checked={mapLayerVisible}
@@ -94,11 +101,10 @@ export function MapWorkspace({
onChange={(event) => onSetMapLayerVisible(event.target.checked)} onChange={(event) => onSetMapLayerVisible(event.target.checked)}
data-testid="map-layer-visible" data-testid="map-layer-visible"
/> />
Layer visible Active layer
</label> </label>
<label>
Layer opacity
<input <input
aria-label="Layer opacity"
disabled={!mapFeatureCollection} disabled={!mapFeatureCollection}
max="1" max="1"
min="0.05" min="0.05"
@@ -108,10 +114,11 @@ export function MapWorkspace({
onChange={(event) => onSetMapLayerOpacity(Number(event.target.value))} onChange={(event) => onSetMapLayerOpacity(Number(event.target.value))}
data-testid="map-layer-opacity" data-testid="map-layer-opacity"
/> />
</label> </div>
<div className="map-status"> <div className="map-status">
{areaFeatureCollection ? `${areaFeatureCount} area loaded` : 'No area loaded'} -{' '} <strong>{mapLayerLabel}</strong>
{mapFeatureCollection ? `${mapFeatureCount} features loaded` : 'No vector layer loaded'} <span>{areaFeatureCollection ? `${areaFeatureCount} AOI loaded` : 'No AOI loaded'}</span>
<span>{mapFeatureCollection ? `${mapFeatureCount} features loaded` : 'No vector/result layer loaded'}</span>
</div> </div>
</div> </div>
<GeoMap <GeoMap
@@ -124,10 +131,12 @@ export function MapWorkspace({
onFeatureSelect={onSelectMapFeature} onFeatureSelect={onSelectMapFeature}
/> />
<div className="feature-inspector"> <div className="feature-inspector">
<div className="panel-title-row">
<h3>Feature inspector</h3> <h3>Feature inspector</h3>
<span className="count-pill">{selectedMapFeature?.geometry?.type ?? 'none'}</span>
</div>
{selectedMapFeature ? ( {selectedMapFeature ? (
<> <>
<p>Geometry: {selectedMapFeature.geometry?.type ?? 'n/a'}</p>
<pre className="job-result">{JSON.stringify(selectedMapFeature.properties ?? {}, null, 2)}</pre> <pre className="job-result">{JSON.stringify(selectedMapFeature.properties ?? {}, null, 2)}</pre>
</> </>
) : ( ) : (
+27 -8
View File
@@ -32,38 +32,57 @@ export function AreaPanel({
}: AreaPanelProps): JSX.Element { }: AreaPanelProps): JSX.Element {
return ( return (
<section data-testid="area-panel"> <section data-testid="area-panel">
<h2>Area manager</h2> <div className="panel-title-row">
<p>{selectedProject ? `Selected project: ${selectedProject.name}` : 'Select a project first'}</p> <div>
<p className="eyebrow">Spatial scope</p>
<h2>AOI manager</h2>
</div>
<span className="count-pill">{areas.length}</span>
</div>
<p className="muted">{selectedProject ? `Project: ${selectedProject.name}` : 'Select a project first'}</p>
<form onSubmit={onCreateArea}> <form className="compact-form" onSubmit={onCreateArea}>
<label>
AOI name
<input <input
value={areaForm.name} value={areaForm.name}
onChange={(event) => onUpdateAreaForm({ ...areaForm, name: event.target.value })} onChange={(event) => onUpdateAreaForm({ ...areaForm, name: event.target.value })}
placeholder="AOI name" placeholder="AOI name"
/> />
</label>
<label>
CRS
<input <input
value={areaForm.crs} value={areaForm.crs}
onChange={(event) => onUpdateAreaForm({ ...areaForm, crs: event.target.value })} onChange={(event) => onUpdateAreaForm({ ...areaForm, crs: event.target.value })}
placeholder="EPSG:4326" placeholder="EPSG:4326"
/> />
</label>
<label>
GeoJSON geometry
<textarea <textarea
value={areaForm.geometry} value={areaForm.geometry}
onChange={(event) => onUpdateAreaForm({ ...areaForm, geometry: event.target.value })} onChange={(event) => onUpdateAreaForm({ ...areaForm, geometry: event.target.value })}
rows={4} rows={4}
/> />
<button type="submit" disabled={!selectedProjectId}> </label>
<button className="primary-action" type="submit" disabled={!selectedProjectId}>
Create area Create area
</button> </button>
</form> </form>
{loadingAreas ? <p>Loading areas...</p> : null} {loadingAreas ? <p>Loading areas...</p> : null}
{areas.length === 0 ? <p>No areas yet</p> : null} {areas.length === 0 ? <p>No areas yet</p> : null}
<ul> <ul className="entity-list">
{areas.map((area) => ( {areas.map((area) => (
<li key={area.id}> <li className={selectedMapAreaId === area.id ? 'entity-card entity-card-active' : 'entity-card'} key={area.id}>
<strong>{area.name}</strong> - {area.area_m2 ? `${area.area_m2.toFixed(2)} m2` : 'n/a'} <strong>{area.name}</strong>
<div>geometry: {area.geometry?.type ?? area.geometry_type ?? 'n/a'}</div> <div className="entity-meta">
<span>{area.area_m2 ? `${area.area_m2.toFixed(2)} m2` : 'n/a'}</span>
<span>{area.geometry?.type ?? area.geometry_type ?? 'n/a'}</span>
</div>
<button <button
className="secondary-action"
type="button" type="button"
onClick={() => onSelectMapArea(area.id)} onClick={() => onSelectMapArea(area.id)}
disabled={!area.geometry} disabled={!area.geometry}
@@ -28,45 +28,64 @@ export function ProjectPanel({
}: ProjectPanelProps): JSX.Element { }: ProjectPanelProps): JSX.Element {
return ( return (
<section data-testid="project-panel"> <section data-testid="project-panel">
<div className="panel-title-row">
<div>
<p className="eyebrow">Context</p>
<h2>Projects</h2> <h2>Projects</h2>
</div>
<span className="count-pill">{projects.length}</span>
</div>
{loadingProjects ? <p>Loading projects...</p> : null} {loadingProjects ? <p>Loading projects...</p> : null}
<form onSubmit={onCreateProject}> <form className="compact-form" onSubmit={onCreateProject}>
<label>
Name
<input <input
value={projectForm.name} value={projectForm.name}
onChange={(event) => onUpdateProjectForm({ ...projectForm, name: event.target.value })} onChange={(event) => onUpdateProjectForm({ ...projectForm, name: event.target.value })}
placeholder="Project name" placeholder="Project name"
/> />
</label>
<label>
Description
<input <input
value={projectForm.description ?? ''} value={projectForm.description ?? ''}
onChange={(event) => onUpdateProjectForm({ ...projectForm, description: event.target.value })} onChange={(event) => onUpdateProjectForm({ ...projectForm, description: event.target.value })}
placeholder="Description" placeholder="Description"
/> />
</label>
<label>
Region
<input <input
value={projectForm.region ?? 'Kempen'} value={projectForm.region ?? 'Kempen'}
onChange={(event) => onUpdateProjectForm({ ...projectForm, region: event.target.value })} onChange={(event) => onUpdateProjectForm({ ...projectForm, region: event.target.value })}
placeholder="Region" placeholder="Region"
/> />
<button type="submit">Create project</button> </label>
<button className="primary-action" type="submit">
Create project
</button>
</form> </form>
<div className="demo-actions"> <div className="demo-actions">
<button type="button" onClick={onLoadDemoWorkflow} disabled={loadingDemoWorkflow} data-testid="load-demo-workflow"> <button className="secondary-action" type="button" onClick={onLoadDemoWorkflow} disabled={loadingDemoWorkflow} data-testid="load-demo-workflow">
{loadingDemoWorkflow ? 'Loading demo...' : 'Load demo workflow'} {loadingDemoWorkflow ? 'Loading demo...' : 'Load demo workflow'}
</button> </button>
{demoWorkflowMessage ? <p>{demoWorkflowMessage}</p> : null} {demoWorkflowMessage ? <p>{demoWorkflowMessage}</p> : null}
</div> </div>
<ul> <ul className="entity-list">
{projects.map((project) => ( {projects.map((project) => (
<li key={project.id}> <li key={project.id}>
<button <button
className={project.id === selectedProjectId ? 'entity-card entity-card-active' : 'entity-card'}
type="button" type="button"
onClick={() => onSelectProject(project.id)} onClick={() => onSelectProject(project.id)}
aria-pressed={project.id === selectedProjectId} aria-pressed={project.id === selectedProjectId}
data-testid={`project-select-${project.id}`} data-testid={`project-select-${project.id}`}
> >
{project.name} <strong>{project.name}</strong>
<span>{project.region || 'No region'}</span>
</button> </button>
</li> </li>
))} ))}
@@ -86,28 +86,38 @@ export function SegmentationLab({
}: SegmentationLabProps): JSX.Element { }: SegmentationLabProps): JSX.Element {
return ( return (
<section> <section>
<div className="panel-title-row">
<div>
<p className="eyebrow">Polygon segmentation</p>
<h2>Segmentation Lab</h2> <h2>Segmentation Lab</h2>
<button type="button" onClick={onLoadModels} disabled={loadingSegmentationModels}> </div>
Refresh segmentation models <button className="secondary-action" type="button" onClick={onLoadModels} disabled={loadingSegmentationModels}>
Refresh models
</button> </button>
</div>
{loadingSegmentationModels ? <p>Loading segmentation models...</p> : null} {loadingSegmentationModels ? <p>Loading segmentation models...</p> : null}
{segmentationModelError ? <p className="error">{segmentationModelError}</p> : null} {segmentationModelError ? <p className="error">{segmentationModelError}</p> : null}
{segmentationModels.length === 0 && !loadingSegmentationModels ? <p>No segmentation models reported by backend</p> : null} {segmentationModels.length === 0 && !loadingSegmentationModels ? <p>No segmentation models reported by backend</p> : null}
<ul> <ul className="model-list">
{segmentationModels.map((model) => ( {segmentationModels.map((model) => (
<li key={model.model_id}> <li className={model.configured ? 'model-card model-card-ready' : 'model-card'} key={model.model_id}>
<strong>{model.display_name}</strong> <strong>{model.display_name}</strong>
<div>model: {model.model_id}</div> <span className={model.configured ? 'status-badge status-badge-ready' : 'status-badge'}>{model.status}</span>
<div>framework: {model.framework}</div> <div className="entity-meta">
<div>task: {model.task_type}</div> <span>{model.model_id}</span>
<div>status: {model.status}</div> <span>{model.framework}</span>
<div>configured: {model.configured ? 'yes' : 'no'}</div> <span>{model.task_type}</span>
<div>classes: {model.supported_classes.join(', ')}</div> </div>
<div>limitation: {model.limitation_message}</div> <p className="muted">classes: {model.supported_classes.join(', ')}</p>
<p className="muted">{model.limitation_message}</p>
</li> </li>
))} ))}
</ul> </ul>
<div> <div className="lab-block">
<h3>Run segmentation</h3>
<div className="lab-form-grid">
<label>
Raster dataset
<select value={selectedSegmentationDatasetId} onChange={(event) => onSelectDataset(event.target.value)}> <select value={selectedSegmentationDatasetId} onChange={(event) => onSelectDataset(event.target.value)}>
<option value="">Select raster dataset</option> <option value="">Select raster dataset</option>
{rasterDatasets.map((dataset) => ( {rasterDatasets.map((dataset) => (
@@ -116,6 +126,9 @@ export function SegmentationLab({
</option> </option>
))} ))}
</select> </select>
</label>
<label>
Model
<select value={selectedSegmentationModelId} onChange={(event) => onSelectModel(event.target.value)}> <select value={selectedSegmentationModelId} onChange={(event) => onSelectModel(event.target.value)}>
{segmentationModels.map((model) => ( {segmentationModels.map((model) => (
<option key={model.model_id} value={model.model_id}> <option key={model.model_id} value={model.model_id}>
@@ -123,6 +136,9 @@ export function SegmentationLab({
</option> </option>
))} ))}
</select> </select>
</label>
<label>
Min confidence
<input <input
type="number" type="number"
min="0" min="0"
@@ -131,7 +147,10 @@ export function SegmentationLab({
value={segmentationConfidenceThreshold} value={segmentationConfidenceThreshold}
onChange={(event) => onSetConfidenceThreshold(Number(event.target.value))} onChange={(event) => onSetConfidenceThreshold(Number(event.target.value))}
/> />
</label>
</div>
<button <button
className="primary-action"
type="button" type="button"
onClick={onRunSegmentation} onClick={onRunSegmentation}
disabled={runningSegmentation || !selectedProjectId || rasterDatasets.length === 0 || !selectedSegmentationModelConfigured} disabled={runningSegmentation || !selectedProjectId || rasterDatasets.length === 0 || !selectedSegmentationModelConfigured}
@@ -153,11 +172,16 @@ export function SegmentationLab({
{segmentationRunResult.error_code ? <p className="error">Code: {segmentationRunResult.error_code}</p> : null} {segmentationRunResult.error_code ? <p className="error">Code: {segmentationRunResult.error_code}</p> : null}
</div> </div>
) : null} ) : null}
<div> <div className="lab-block">
<div className="panel-title-row">
<h3>Segmentation results</h3> <h3>Segmentation results</h3>
<button type="button" onClick={onLoadRuns} disabled={!selectedProjectId}> <button className="secondary-action" type="button" onClick={onLoadRuns} disabled={!selectedProjectId}>
Refresh segmentation runs Refresh runs
</button> </button>
</div>
<div className="lab-form-grid">
<label>
Run
<select value={selectedSegmentationRunId} onChange={(event) => onSelectRun(event.target.value)}> <select value={selectedSegmentationRunId} onChange={(event) => onSelectRun(event.target.value)}>
<option value="">Select segmentation run</option> <option value="">Select segmentation run</option>
{segmentationRuns.map((run) => ( {segmentationRuns.map((run) => (
@@ -166,12 +190,18 @@ export function SegmentationLab({
</option> </option>
))} ))}
</select> </select>
</label>
<label>
Class
<input <input
type="text" type="text"
placeholder="Class filter" placeholder="Class filter"
value={segmentationClassFilter} value={segmentationClassFilter}
onChange={(event) => onSetClassFilter(event.target.value)} onChange={(event) => onSetClassFilter(event.target.value)}
/> />
</label>
<label>
Min confidence
<input <input
type="number" type="number"
min="0" min="0"
@@ -180,7 +210,9 @@ export function SegmentationLab({
value={segmentationMinConfidenceFilter} value={segmentationMinConfidenceFilter}
onChange={(event) => onSetMinConfidenceFilter(Number(event.target.value))} onChange={(event) => onSetMinConfidenceFilter(Number(event.target.value))}
/> />
<button type="button" onClick={onLoadResults} disabled={!selectedSegmentationRunId || loadingSegmentationResults}> </label>
</div>
<button className="primary-action" type="button" onClick={onLoadResults} disabled={!selectedSegmentationRunId || loadingSegmentationResults}>
Load segmentations Load segmentations
</button> </button>
{loadingSegmentationResults ? <p>Loading segmentation results...</p> : null} {loadingSegmentationResults ? <p>Loading segmentation results...</p> : null}
@@ -212,8 +244,10 @@ export function SegmentationLab({
</table> </table>
) : null} ) : null}
</div> </div>
<div> <div className="lab-block">
<h3>Segmentation QA</h3> <h3>Segmentation QA</h3>
<label>
Reference dataset
<select value={segmentationReferenceDatasetId} onChange={(event) => onSelectReferenceDataset(event.target.value)}> <select value={segmentationReferenceDatasetId} onChange={(event) => onSelectReferenceDataset(event.target.value)}>
<option value="">Select reference dataset</option> <option value="">Select reference dataset</option>
{referenceDatasets.map((dataset) => ( {referenceDatasets.map((dataset) => (
@@ -222,7 +256,8 @@ export function SegmentationLab({
</option> </option>
))} ))}
</select> </select>
<button type="button" onClick={onRunQa} disabled={runningSegmentationQa || !selectedSegmentationRunId || !segmentationReferenceDatasetId}> </label>
<button className="primary-action" type="button" onClick={onRunQa} disabled={runningSegmentationQa || !selectedSegmentationRunId || !segmentationReferenceDatasetId}>
Compare segmentations to reference Compare segmentations to reference
</button> </button>
{segmentationQaError ? <p className="error">{segmentationQaError}</p> : null} {segmentationQaError ? <p className="error">{segmentationQaError}</p> : null}
+214
View File
@@ -779,6 +779,210 @@ section th {
min-height: 28rem; min-height: 28rem;
} }
.panel-title-row {
display: flex;
gap: 0.75rem;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.panel-title-row h2,
.panel-title-row h3 {
margin: 0;
}
.count-pill,
.status-badge {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.18rem 0.52rem;
background: #ffffff;
color: #334155;
font-size: 0.74rem;
font-weight: 800;
line-height: 1.2;
}
.status-badge-ready {
border-color: rgba(21, 128, 61, 0.28);
background: #ecfdf3;
color: #166534;
}
.primary-action {
border-color: rgba(15, 118, 110, 0.55);
background: linear-gradient(180deg, #0f766e, #115e59);
color: #ffffff;
}
.primary-action:hover:not(:disabled) {
border-color: #0f766e;
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.18);
}
.secondary-action {
background: #ffffff;
}
.compact-form,
.dataset-upload-form,
.lab-form-grid {
display: grid;
gap: 0.7rem;
}
.dataset-upload-form {
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
align-items: end;
border: 1px solid var(--line);
border-radius: 8px;
padding: 0.85rem;
background: var(--panel-soft);
}
.entity-list,
.dataset-list,
.model-list {
display: grid;
gap: 0.7rem;
padding: 0;
list-style: none;
}
.entity-card,
.dataset-card,
.model-card,
.lab-block,
.layer-control-card {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel-soft);
}
.entity-card {
display: grid;
width: 100%;
gap: 0.25rem;
padding: 0.72rem;
text-align: left;
}
button.entity-card {
min-height: auto;
}
.entity-card-active {
border-color: rgba(15, 118, 110, 0.48);
box-shadow: inset 4px 0 0 var(--accent);
}
.entity-card span,
.entity-meta,
.dataset-metrics {
color: var(--muted);
font-size: 0.84rem;
}
.entity-meta,
.dataset-metrics {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 0.65rem;
margin-top: 0.32rem;
}
.dataset-card,
.model-card,
.lab-block {
padding: 0.85rem;
}
.dataset-card-header,
.model-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: start;
}
.model-card p {
grid-column: 1 / -1;
margin: 0.25rem 0 0;
}
.model-card-ready {
border-color: rgba(21, 128, 61, 0.24);
}
.dataset-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-top: 0.7rem;
}
.button-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.8rem;
}
.button-row button,
.panel-title-row button {
width: auto;
}
.map-toolbar {
display: grid;
grid-template-columns: minmax(12rem, 1fr) minmax(10rem, 0.8fr) minmax(10rem, 0.8fr) minmax(14rem, 1fr);
gap: 0.75rem;
align-items: end;
margin-bottom: 0.85rem;
}
.layer-control-card {
display: grid;
gap: 0.5rem;
padding: 0.58rem 0.65rem;
}
.layer-control-card input[type='range'] {
min-height: auto;
padding: 0;
}
.map-status {
display: grid;
gap: 0.25rem;
border: 1px solid var(--line);
border-radius: 8px;
padding: 0.62rem 0.72rem;
background: var(--panel-soft);
}
.map-status span {
color: var(--muted);
font-size: 0.84rem;
}
.lab-block + .lab-block {
margin-top: 0.85rem;
}
.lab-form-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: end;
}
.workspace-grid-ai .lab-form-grid {
grid-template-columns: 1fr;
}
@media (max-width: 1120px) { @media (max-width: 1120px) {
.workbench-layout { .workbench-layout {
grid-template-columns: 12rem minmax(0, 1fr); grid-template-columns: 12rem minmax(0, 1fr);
@@ -838,6 +1042,16 @@ section th {
.workbench-main .map-container { .workbench-main .map-container {
height: 28rem; height: 28rem;
} }
.dataset-upload-form,
.map-toolbar,
.lab-form-grid {
grid-template-columns: 1fr;
}
.dataset-metrics {
grid-template-columns: 1fr;
}
} }
@media (max-width: 620px) { @media (max-width: 620px) {