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,70 +61,106 @@ 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">
<h2>Datasets</h2> <div className="panel-title-row">
<form onSubmit={onUploadDataset}> <div>
<select <p className="eyebrow">Data catalog</p>
value={datasetForm.datasetType} <h2>Datasets</h2>
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, datasetType: event.target.value }))} </div>
> <span className="count-pill">{readyDatasets}/{datasets.length} ready</span>
<option value="vector">vector</option> </div>
<option value="geojson">geojson</option>
<option value="raster">raster</option> <form className="dataset-upload-form" onSubmit={onUploadDataset}>
</select> <label>
<input Type
value={datasetForm.source} <select
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, source: event.target.value }))} value={datasetForm.datasetType}
placeholder="user_upload" onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, datasetType: event.target.value }))}
/> >
<select <option value="vector">vector</option>
value={datasetForm.areaId} <option value="geojson">geojson</option>
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, areaId: event.target.value }))} <option value="raster">raster</option>
> </select>
<option value="">No area</option> </label>
{areas.map((area) => ( <label>
<option key={area.id} value={area.id}> Source
{area.name} <input
</option> value={datasetForm.source}
))} onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, source: event.target.value }))}
</select> placeholder="user_upload"
<input />
type="file" </label>
accept=".geojson,.json,.tif,.tiff,.geotiff" <label>
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, file: event.target.files?.[0] ?? null }))} AOI
/> <select
<button type="submit" disabled={!selectedProjectId}> value={datasetForm.areaId}
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, areaId: event.target.value }))}
>
<option value="">No area</option>
{areas.map((area) => (
<option key={area.id} value={area.id}>
{area.name}
</option>
))}
</select>
</label>
<label className="file-input-label">
File
<input
type="file"
accept=".geojson,.json,.tif,.tiff,.geotiff"
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, file: event.target.files?.[0] ?? null }))}
/>
</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}>
<strong>{dataset.name}</strong> <div className="dataset-card-header">
<div>type: {dataset.dataset_type}</div> <div>
<div>status: {dataset.status}</div> <strong>{dataset.name}</strong>
<div>readiness: {dataset.status === 'ready' ? 'ready' : dataset.status === 'failed' ? 'failed' : 'pending'}</div> <div className="entity-meta">
<div>size: {formatBytes(dataset.size_bytes)}</div> <span>{dataset.dataset_type}</span>
<div>features: {dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 'n/a'}</div> <span>{dataset.dataset_role ?? 'source'}</span>
<div>bbox: {formatBounds(dataset.bounds_json ?? dataset.vector_summary?.bounds_json)}</div> <span>{dataset.status}</span>
<button </div>
type="button" </div>
onClick={() => onLoadDatasetDetails(selectedProjectId ?? '', dataset)} <span className={dataset.status === 'ready' ? 'status-badge status-badge-ready' : 'status-badge'}>
data-testid={`dataset-select-${dataset.id}`} {dataset.status === 'ready' ? 'ready' : dataset.status === 'failed' ? 'failed' : 'pending'}
> </span>
Select / details </div>
</button> <div className="dataset-metrics">
<button <span>size: {formatBytes(dataset.size_bytes)}</span>
type="button" <span>features: {dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 'n/a'}</span>
onClick={() => onRefreshMetadata(dataset.id)} <span>bbox: {formatBounds(dataset.bounds_json ?? dataset.vector_summary?.bounds_json)}</span>
disabled={dataset.dataset_type === 'raster'} </div>
> <div className="button-row">
Refresh metadata <button
</button> className="primary-action"
type="button"
onClick={() => onLoadDatasetDetails(selectedProjectId ?? '', dataset)}
data-testid={`dataset-select-${dataset.id}`}
>
Select / details
</button>
<button
className="secondary-action"
type="button"
onClick={() => onRefreshMetadata(dataset.id)}
disabled={dataset.dataset_type === 'raster'}
>
Refresh metadata
</button>
</div>
</li> </li>
))} ))}
</ul> </ul>
@@ -86,60 +86,81 @@ export function DetectionLab({
}: DetectionLabProps): JSX.Element { }: DetectionLabProps): JSX.Element {
return ( return (
<section> <section>
<h2>Detection Lab</h2> <div className="panel-title-row">
<button type="button" onClick={onLoadModels} disabled={loadingDetectionModels}> <div>
Refresh detection models <p className="eyebrow">Object detection</p>
</button> <h2>Detection Lab</h2>
</div>
<button className="secondary-action" type="button" onClick={onLoadModels} disabled={loadingDetectionModels}>
Refresh models
</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">
<select value={selectedDetectionDatasetId} onChange={(event) => onSelectDataset(event.target.value)}> <h3>Run detection</h3>
<option value="">Select raster dataset</option> <div className="lab-form-grid">
{rasterDatasets.map((dataset) => ( <label>
<option key={dataset.id} value={dataset.id}> Raster dataset
{dataset.name} <select value={selectedDetectionDatasetId} onChange={(event) => onSelectDataset(event.target.value)}>
</option> <option value="">Select raster dataset</option>
))} {rasterDatasets.map((dataset) => (
</select> <option key={dataset.id} value={dataset.id}>
<select value={selectedDetectionModelId} onChange={(event) => onSelectModel(event.target.value)}> {dataset.name}
{detectionModels.map((model) => ( </option>
<option key={model.model_id} value={model.model_id}> ))}
{model.display_name} </select>
</option> </label>
))} <label>
</select> Model
<input <select value={selectedDetectionModelId} onChange={(event) => onSelectModel(event.target.value)}>
type="number" {detectionModels.map((model) => (
min="0" <option key={model.model_id} value={model.model_id}>
max="1" {model.display_name}
step="0.05" </option>
value={detectionConfidenceThreshold} ))}
onChange={(event) => onSetConfidenceThreshold(Number(event.target.value))} </select>
/> </label>
<label>
Min confidence
<input
type="number"
min="0"
max="1"
step="0.05"
value={detectionConfidenceThreshold}
onChange={(event) => onSetConfidenceThreshold(Number(event.target.value))}
/>
</label>
</div>
{selectedDetectionModelId === 'yolo-configured' ? ( {selectedDetectionModelId === 'yolo-configured' ? (
<input <label>
type="text" Tile manifest
placeholder="Raster tile manifest path" <input
value={detectionTileManifestPath} type="text"
onChange={(event) => onSetTileManifestPath(event.target.value)} placeholder="Raster tile manifest path"
/> value={detectionTileManifestPath}
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,34 +175,47 @@ 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">
<h3>Detection results</h3> <div className="panel-title-row">
<button type="button" onClick={onLoadRuns} disabled={!selectedProjectId}> <h3>Detection results</h3>
Refresh detection runs <button className="secondary-action" type="button" onClick={onLoadRuns} disabled={!selectedProjectId}>
</button> Refresh runs
<select value={selectedDetectionRunId} onChange={(event) => onSelectRun(event.target.value)}> </button>
<option value="">Select detection run</option> </div>
{detectionRuns.map((run) => ( <div className="lab-form-grid">
<option key={run.id} value={run.id}> <label>
{run.model_name || 'detection'} - {run.status} - {run.id} Run
</option> <select value={selectedDetectionRunId} onChange={(event) => onSelectRun(event.target.value)}>
))} <option value="">Select detection run</option>
</select> {detectionRuns.map((run) => (
<input <option key={run.id} value={run.id}>
type="text" {run.model_name || 'detection'} - {run.status} - {run.id}
placeholder="Class filter" </option>
value={detectionClassFilter} ))}
onChange={(event) => onSetClassFilter(event.target.value)} </select>
/> </label>
<input <label>
type="number" Class
min="0" <input
max="1" type="text"
step="0.05" placeholder="Class filter"
value={detectionMinConfidenceFilter} value={detectionClassFilter}
onChange={(event) => onSetMinConfidenceFilter(Number(event.target.value))} onChange={(event) => onSetClassFilter(event.target.value)}
/> />
<button type="button" onClick={onLoadResults} disabled={!selectedDetectionRunId || loadingDetectionResults}> </label>
<label>
Min confidence
<input
type="number"
min="0"
max="1"
step="0.05"
value={detectionMinConfidenceFilter}
onChange={(event) => onSetMinConfidenceFilter(Number(event.target.value))}
/>
</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,17 +243,20 @@ export function DetectionLab({
</table> </table>
) : null} ) : null}
</div> </div>
<div> <div className="lab-block">
<h3>Detection QA</h3> <h3>Detection QA</h3>
<select value={detectionReferenceDatasetId} onChange={(event) => onSelectReferenceDataset(event.target.value)}> <label>
<option value="">Select reference dataset</option> Reference dataset
{referenceDatasets.map((dataset) => ( <select value={detectionReferenceDatasetId} onChange={(event) => onSelectReferenceDataset(event.target.value)}>
<option key={dataset.id} value={dataset.id}> <option value="">Select reference dataset</option>
{dataset.name} {referenceDatasets.map((dataset) => (
</option> <option key={dataset.id} value={dataset.id}>
))} {dataset.name}
</select> </option>
<button type="button" onClick={onRunQa} disabled={runningDetectionQa || !selectedDetectionRunId || !detectionReferenceDatasetId}> ))}
</select>
</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}
+43 -34
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">
<h2>Map workspace</h2> <div className="panel-title-row">
<p>{mapLayerLabel}</p> <div>
<div className="map-controls"> <p className="eyebrow">Spatial review</p>
<h2>Map workspace</h2>
</div>
<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,19 +69,19 @@ export function MapWorkspace({
))} ))}
</select> </select>
</label> </label>
<label className="checkbox-row"> <div className="layer-control-card">
<input <label className="checkbox-row">
checked={areaLayerVisible} <input
disabled={!areaFeatureCollection} checked={areaLayerVisible}
type="checkbox" disabled={!areaFeatureCollection}
onChange={(event) => onSetAreaLayerVisible(event.target.checked)} type="checkbox"
data-testid="map-area-visible" onChange={(event) => onSetAreaLayerVisible(event.target.checked)}
/> data-testid="map-area-visible"
Area visible />
</label> AOI
<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,20 +91,20 @@ 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>
<label className="checkbox-row"> <div className="layer-control-card">
<input <label className="checkbox-row">
checked={mapLayerVisible} <input
disabled={!mapFeatureCollection} checked={mapLayerVisible}
type="checkbox" disabled={!mapFeatureCollection}
onChange={(event) => onSetMapLayerVisible(event.target.checked)} type="checkbox"
data-testid="map-layer-visible" onChange={(event) => onSetMapLayerVisible(event.target.checked)}
/> data-testid="map-layer-visible"
Layer visible />
</label> Active layer
<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">
<h3>Feature inspector</h3> <div className="panel-title-row">
<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>
</> </>
) : ( ) : (
+42 -23
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}>
<input <label>
value={areaForm.name} AOI name
onChange={(event) => onUpdateAreaForm({ ...areaForm, name: event.target.value })} <input
placeholder="AOI name" value={areaForm.name}
/> onChange={(event) => onUpdateAreaForm({ ...areaForm, name: event.target.value })}
<input placeholder="AOI name"
value={areaForm.crs} />
onChange={(event) => onUpdateAreaForm({ ...areaForm, crs: event.target.value })} </label>
placeholder="EPSG:4326" <label>
/> CRS
<textarea <input
value={areaForm.geometry} value={areaForm.crs}
onChange={(event) => onUpdateAreaForm({ ...areaForm, geometry: event.target.value })} onChange={(event) => onUpdateAreaForm({ ...areaForm, crs: event.target.value })}
rows={4} placeholder="EPSG:4326"
/> />
<button type="submit" disabled={!selectedProjectId}> </label>
<label>
GeoJSON geometry
<textarea
value={areaForm.geometry}
onChange={(event) => onUpdateAreaForm({ ...areaForm, geometry: event.target.value })}
rows={4}
/>
</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">
<h2>Projects</h2> <div className="panel-title-row">
<div>
<p className="eyebrow">Context</p>
<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}>
<input <label>
value={projectForm.name} Name
onChange={(event) => onUpdateProjectForm({ ...projectForm, name: event.target.value })} <input
placeholder="Project name" value={projectForm.name}
/> onChange={(event) => onUpdateProjectForm({ ...projectForm, name: event.target.value })}
<input placeholder="Project name"
value={projectForm.description ?? ''} />
onChange={(event) => onUpdateProjectForm({ ...projectForm, description: event.target.value })} </label>
placeholder="Description" <label>
/> Description
<input <input
value={projectForm.region ?? 'Kempen'} value={projectForm.description ?? ''}
onChange={(event) => onUpdateProjectForm({ ...projectForm, region: event.target.value })} onChange={(event) => onUpdateProjectForm({ ...projectForm, description: event.target.value })}
placeholder="Region" placeholder="Description"
/> />
<button type="submit">Create project</button> </label>
<label>
Region
<input
value={projectForm.region ?? 'Kempen'}
onChange={(event) => onUpdateProjectForm({ ...projectForm, region: event.target.value })}
placeholder="Region"
/>
</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,52 +86,71 @@ export function SegmentationLab({
}: SegmentationLabProps): JSX.Element { }: SegmentationLabProps): JSX.Element {
return ( return (
<section> <section>
<h2>Segmentation Lab</h2> <div className="panel-title-row">
<button type="button" onClick={onLoadModels} disabled={loadingSegmentationModels}> <div>
Refresh segmentation models <p className="eyebrow">Polygon segmentation</p>
</button> <h2>Segmentation Lab</h2>
</div>
<button className="secondary-action" type="button" onClick={onLoadModels} disabled={loadingSegmentationModels}>
Refresh models
</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">
<select value={selectedSegmentationDatasetId} onChange={(event) => onSelectDataset(event.target.value)}> <h3>Run segmentation</h3>
<option value="">Select raster dataset</option> <div className="lab-form-grid">
{rasterDatasets.map((dataset) => ( <label>
<option key={dataset.id} value={dataset.id}> Raster dataset
{dataset.name} <select value={selectedSegmentationDatasetId} onChange={(event) => onSelectDataset(event.target.value)}>
</option> <option value="">Select raster dataset</option>
))} {rasterDatasets.map((dataset) => (
</select> <option key={dataset.id} value={dataset.id}>
<select value={selectedSegmentationModelId} onChange={(event) => onSelectModel(event.target.value)}> {dataset.name}
{segmentationModels.map((model) => ( </option>
<option key={model.model_id} value={model.model_id}> ))}
{model.display_name} </select>
</option> </label>
))} <label>
</select> Model
<input <select value={selectedSegmentationModelId} onChange={(event) => onSelectModel(event.target.value)}>
type="number" {segmentationModels.map((model) => (
min="0" <option key={model.model_id} value={model.model_id}>
max="1" {model.display_name}
step="0.05" </option>
value={segmentationConfidenceThreshold} ))}
onChange={(event) => onSetConfidenceThreshold(Number(event.target.value))} </select>
/> </label>
<label>
Min confidence
<input
type="number"
min="0"
max="1"
step="0.05"
value={segmentationConfidenceThreshold}
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,34 +172,47 @@ 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">
<h3>Segmentation results</h3> <div className="panel-title-row">
<button type="button" onClick={onLoadRuns} disabled={!selectedProjectId}> <h3>Segmentation results</h3>
Refresh segmentation runs <button className="secondary-action" type="button" onClick={onLoadRuns} disabled={!selectedProjectId}>
</button> Refresh runs
<select value={selectedSegmentationRunId} onChange={(event) => onSelectRun(event.target.value)}> </button>
<option value="">Select segmentation run</option> </div>
{segmentationRuns.map((run) => ( <div className="lab-form-grid">
<option key={run.id} value={run.id}> <label>
{run.model_name || 'segmentation'} - {run.status} - {run.id} Run
</option> <select value={selectedSegmentationRunId} onChange={(event) => onSelectRun(event.target.value)}>
))} <option value="">Select segmentation run</option>
</select> {segmentationRuns.map((run) => (
<input <option key={run.id} value={run.id}>
type="text" {run.model_name || 'segmentation'} - {run.status} - {run.id}
placeholder="Class filter" </option>
value={segmentationClassFilter} ))}
onChange={(event) => onSetClassFilter(event.target.value)} </select>
/> </label>
<input <label>
type="number" Class
min="0" <input
max="1" type="text"
step="0.05" placeholder="Class filter"
value={segmentationMinConfidenceFilter} value={segmentationClassFilter}
onChange={(event) => onSetMinConfidenceFilter(Number(event.target.value))} onChange={(event) => onSetClassFilter(event.target.value)}
/> />
<button type="button" onClick={onLoadResults} disabled={!selectedSegmentationRunId || loadingSegmentationResults}> </label>
<label>
Min confidence
<input
type="number"
min="0"
max="1"
step="0.05"
value={segmentationMinConfidenceFilter}
onChange={(event) => onSetMinConfidenceFilter(Number(event.target.value))}
/>
</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,17 +244,20 @@ export function SegmentationLab({
</table> </table>
) : null} ) : null}
</div> </div>
<div> <div className="lab-block">
<h3>Segmentation QA</h3> <h3>Segmentation QA</h3>
<select value={segmentationReferenceDatasetId} onChange={(event) => onSelectReferenceDataset(event.target.value)}> <label>
<option value="">Select reference dataset</option> Reference dataset
{referenceDatasets.map((dataset) => ( <select value={segmentationReferenceDatasetId} onChange={(event) => onSelectReferenceDataset(event.target.value)}>
<option key={dataset.id} value={dataset.id}> <option value="">Select reference dataset</option>
{dataset.name} {referenceDatasets.map((dataset) => (
</option> <option key={dataset.id} value={dataset.id}>
))} {dataset.name}
</select> </option>
<button type="button" onClick={onRunQa} disabled={runningSegmentationQa || !selectedSegmentationRunId || !segmentationReferenceDatasetId}> ))}
</select>
</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) {