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.
- 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)
- 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 "<MapWorkspace" in app
assert "onSelectMapFeature={setSelectedMapFeature}" in app
assert "Layer visible" in component
assert "Active layer" in component
assert "Layer opacity" in component
assert "Feature inspector" 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={areaFeatureCollection}" in app
assert "areaData={areaFeatureCollection}" in map_workspace
assert "Area visible" in map_workspace
assert "AOI" in map_workspace
assert "area-fill" in geomap
assert "area-line" in geomap
assert "onSelectMapArea" in area_panel
@@ -65,7 +65,7 @@ def test_app_still_wires_dataset_ui_callbacks() -> None:
assert "onRunVectorClip={runVectorClip}" in app
assert "onRunVectorBuffer={runVectorBuffer}" 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={() => onRefreshMetadata(dataset.id)}" in dataset_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")
assert "GeoMap" in map_workspace
assert "Selected area" in map_workspace
assert "Area visible" in map_workspace
assert "Layer visible" in map_workspace
assert "map-toolbar" in map_workspace
assert "Area" in map_workspace
assert "AOI" in map_workspace
assert "Active layer" in map_workspace
assert "Feature inspector" in map_workspace
assert "onFeatureSelect={onSelectMapFeature}" 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:
- 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,
onRefreshMetadata,
}: DatasetPanelProps) {
const readyDatasets = datasets.filter((dataset) => dataset.status === 'ready').length
return (
<section data-testid="dataset-panel">
<h2>Datasets</h2>
<form onSubmit={onUploadDataset}>
<select
value={datasetForm.datasetType}
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, datasetType: event.target.value }))}
>
<option value="vector">vector</option>
<option value="geojson">geojson</option>
<option value="raster">raster</option>
</select>
<input
value={datasetForm.source}
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, source: event.target.value }))}
placeholder="user_upload"
/>
<select
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>
<input
type="file"
accept=".geojson,.json,.tif,.tiff,.geotiff"
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, file: event.target.files?.[0] ?? null }))}
/>
<button type="submit" disabled={!selectedProjectId}>
<div className="panel-title-row">
<div>
<p className="eyebrow">Data catalog</p>
<h2>Datasets</h2>
</div>
<span className="count-pill">{readyDatasets}/{datasets.length} ready</span>
</div>
<form className="dataset-upload-form" onSubmit={onUploadDataset}>
<label>
Type
<select
value={datasetForm.datasetType}
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, datasetType: event.target.value }))}
>
<option value="vector">vector</option>
<option value="geojson">geojson</option>
<option value="raster">raster</option>
</select>
</label>
<label>
Source
<input
value={datasetForm.source}
onChange={(event) => onDatasetFormChange((previous) => ({ ...previous, source: event.target.value }))}
placeholder="user_upload"
/>
</label>
<label>
AOI
<select
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
</button>
</form>
{loadingDatasets ? <p>Loading datasets...</p> : null}
{datasets.length === 0 ? <p>No datasets yet</p> : null}
<ul>
<ul className="dataset-list">
{datasets.map((dataset) => (
<li key={dataset.id}>
<strong>{dataset.name}</strong>
<div>type: {dataset.dataset_type}</div>
<div>status: {dataset.status}</div>
<div>readiness: {dataset.status === 'ready' ? 'ready' : dataset.status === 'failed' ? 'failed' : 'pending'}</div>
<div>size: {formatBytes(dataset.size_bytes)}</div>
<div>features: {dataset.feature_count ?? dataset.vector_summary?.feature_count ?? 'n/a'}</div>
<div>bbox: {formatBounds(dataset.bounds_json ?? dataset.vector_summary?.bounds_json)}</div>
<button
type="button"
onClick={() => onLoadDatasetDetails(selectedProjectId ?? '', dataset)}
data-testid={`dataset-select-${dataset.id}`}
>
Select / details
</button>
<button
type="button"
onClick={() => onRefreshMetadata(dataset.id)}
disabled={dataset.dataset_type === 'raster'}
>
Refresh metadata
</button>
<li className="dataset-card" key={dataset.id}>
<div className="dataset-card-header">
<div>
<strong>{dataset.name}</strong>
<div className="entity-meta">
<span>{dataset.dataset_type}</span>
<span>{dataset.dataset_role ?? 'source'}</span>
<span>{dataset.status}</span>
</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
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>
))}
</ul>
@@ -86,60 +86,81 @@ export function DetectionLab({
}: DetectionLabProps): JSX.Element {
return (
<section>
<h2>Detection Lab</h2>
<button type="button" onClick={onLoadModels} disabled={loadingDetectionModels}>
Refresh detection models
</button>
<div className="panel-title-row">
<div>
<p className="eyebrow">Object detection</p>
<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}
{detectionModelError ? <p className="error">{detectionModelError}</p> : null}
{detectionModels.length === 0 && !loadingDetectionModels ? <p>No detection models reported by backend</p> : null}
<ul>
<ul className="model-list">
{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>
<div>model: {model.model_id}</div>
<div>framework: {model.framework}</div>
<div>task: {model.task_type}</div>
<div>status: {model.status}</div>
<div>configured: {model.configured ? 'yes' : 'no'}</div>
<div>classes: {model.supported_classes.join(', ')}</div>
<div>limitation: {model.limitation_message}</div>
<span className={model.configured ? 'status-badge status-badge-ready' : 'status-badge'}>{model.status}</span>
<div className="entity-meta">
<span>{model.model_id}</span>
<span>{model.framework}</span>
<span>{model.task_type}</span>
</div>
<p className="muted">classes: {model.supported_classes.join(', ')}</p>
<p className="muted">{model.limitation_message}</p>
</li>
))}
</ul>
<div>
<select value={selectedDetectionDatasetId} onChange={(event) => onSelectDataset(event.target.value)}>
<option value="">Select raster dataset</option>
{rasterDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{dataset.name}
</option>
))}
</select>
<select value={selectedDetectionModelId} onChange={(event) => onSelectModel(event.target.value)}>
{detectionModels.map((model) => (
<option key={model.model_id} value={model.model_id}>
{model.display_name}
</option>
))}
</select>
<input
type="number"
min="0"
max="1"
step="0.05"
value={detectionConfidenceThreshold}
onChange={(event) => onSetConfidenceThreshold(Number(event.target.value))}
/>
<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)}>
<option value="">Select raster dataset</option>
{rasterDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{dataset.name}
</option>
))}
</select>
</label>
<label>
Model
<select value={selectedDetectionModelId} onChange={(event) => onSelectModel(event.target.value)}>
{detectionModels.map((model) => (
<option key={model.model_id} value={model.model_id}>
{model.display_name}
</option>
))}
</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' ? (
<input
type="text"
placeholder="Raster tile manifest path"
value={detectionTileManifestPath}
onChange={(event) => onSetTileManifestPath(event.target.value)}
/>
<label>
Tile manifest
<input
type="text"
placeholder="Raster tile manifest path"
value={detectionTileManifestPath}
onChange={(event) => onSetTileManifestPath(event.target.value)}
/>
</label>
) : 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
</button>
</div>
@@ -154,34 +175,47 @@ export function DetectionLab({
{detectionRunResult.error_code ? <p className="error">Code: {detectionRunResult.error_code}</p> : null}
</div>
) : null}
<div>
<h3>Detection results</h3>
<button type="button" onClick={onLoadRuns} disabled={!selectedProjectId}>
Refresh detection runs
</button>
<select value={selectedDetectionRunId} onChange={(event) => onSelectRun(event.target.value)}>
<option value="">Select detection run</option>
{detectionRuns.map((run) => (
<option key={run.id} value={run.id}>
{run.model_name || 'detection'} - {run.status} - {run.id}
</option>
))}
</select>
<input
type="text"
placeholder="Class filter"
value={detectionClassFilter}
onChange={(event) => onSetClassFilter(event.target.value)}
/>
<input
type="number"
min="0"
max="1"
step="0.05"
value={detectionMinConfidenceFilter}
onChange={(event) => onSetMinConfidenceFilter(Number(event.target.value))}
/>
<button type="button" onClick={onLoadResults} disabled={!selectedDetectionRunId || loadingDetectionResults}>
<div className="lab-block">
<div className="panel-title-row">
<h3>Detection results</h3>
<button className="secondary-action" type="button" onClick={onLoadRuns} disabled={!selectedProjectId}>
Refresh runs
</button>
</div>
<div className="lab-form-grid">
<label>
Run
<select value={selectedDetectionRunId} onChange={(event) => onSelectRun(event.target.value)}>
<option value="">Select detection run</option>
{detectionRuns.map((run) => (
<option key={run.id} value={run.id}>
{run.model_name || 'detection'} - {run.status} - {run.id}
</option>
))}
</select>
</label>
<label>
Class
<input
type="text"
placeholder="Class filter"
value={detectionClassFilter}
onChange={(event) => onSetClassFilter(event.target.value)}
/>
</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
</button>
{loadingDetectionResults ? <p>Loading detection results...</p> : null}
@@ -209,17 +243,20 @@ export function DetectionLab({
</table>
) : null}
</div>
<div>
<div className="lab-block">
<h3>Detection QA</h3>
<select value={detectionReferenceDatasetId} onChange={(event) => onSelectReferenceDataset(event.target.value)}>
<option value="">Select reference dataset</option>
{referenceDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{dataset.name}
</option>
))}
</select>
<button type="button" onClick={onRunQa} disabled={runningDetectionQa || !selectedDetectionRunId || !detectionReferenceDatasetId}>
<label>
Reference dataset
<select value={detectionReferenceDatasetId} onChange={(event) => onSelectReferenceDataset(event.target.value)}>
<option value="">Select reference dataset</option>
{referenceDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{dataset.name}
</option>
))}
</select>
</label>
<button className="primary-action" type="button" onClick={onRunQa} disabled={runningDetectionQa || !selectedDetectionRunId || !detectionReferenceDatasetId}>
Compare detections to reference
</button>
{detectionQaError ? <p className="error">{detectionQaError}</p> : null}
+43 -34
View File
@@ -44,11 +44,17 @@ export function MapWorkspace({
}: MapWorkspaceProps): JSX.Element {
return (
<section data-testid="map-workspace">
<h2>Map workspace</h2>
<p>{mapLayerLabel}</p>
<div className="map-controls">
<div className="panel-title-row">
<div>
<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>
Selected area
Area
<select
value={selectedMapAreaId}
onChange={(event) => onSelectMapArea(event.target.value)}
@@ -63,19 +69,19 @@ export function MapWorkspace({
))}
</select>
</label>
<label className="checkbox-row">
<input
checked={areaLayerVisible}
disabled={!areaFeatureCollection}
type="checkbox"
onChange={(event) => onSetAreaLayerVisible(event.target.checked)}
data-testid="map-area-visible"
/>
Area visible
</label>
<label>
Area opacity
<div className="layer-control-card">
<label className="checkbox-row">
<input
checked={areaLayerVisible}
disabled={!areaFeatureCollection}
type="checkbox"
onChange={(event) => onSetAreaLayerVisible(event.target.checked)}
data-testid="map-area-visible"
/>
AOI
</label>
<input
aria-label="Area opacity"
disabled={!areaFeatureCollection}
max="0.7"
min="0.05"
@@ -85,20 +91,20 @@ export function MapWorkspace({
onChange={(event) => onSetAreaLayerOpacity(Number(event.target.value))}
data-testid="map-area-opacity"
/>
</label>
<label className="checkbox-row">
<input
checked={mapLayerVisible}
disabled={!mapFeatureCollection}
type="checkbox"
onChange={(event) => onSetMapLayerVisible(event.target.checked)}
data-testid="map-layer-visible"
/>
Layer visible
</label>
<label>
Layer opacity
</div>
<div className="layer-control-card">
<label className="checkbox-row">
<input
checked={mapLayerVisible}
disabled={!mapFeatureCollection}
type="checkbox"
onChange={(event) => onSetMapLayerVisible(event.target.checked)}
data-testid="map-layer-visible"
/>
Active layer
</label>
<input
aria-label="Layer opacity"
disabled={!mapFeatureCollection}
max="1"
min="0.05"
@@ -108,10 +114,11 @@ export function MapWorkspace({
onChange={(event) => onSetMapLayerOpacity(Number(event.target.value))}
data-testid="map-layer-opacity"
/>
</label>
</div>
<div className="map-status">
{areaFeatureCollection ? `${areaFeatureCount} area loaded` : 'No area loaded'} -{' '}
{mapFeatureCollection ? `${mapFeatureCount} features loaded` : 'No vector layer loaded'}
<strong>{mapLayerLabel}</strong>
<span>{areaFeatureCollection ? `${areaFeatureCount} AOI loaded` : 'No AOI loaded'}</span>
<span>{mapFeatureCollection ? `${mapFeatureCount} features loaded` : 'No vector/result layer loaded'}</span>
</div>
</div>
<GeoMap
@@ -124,10 +131,12 @@ export function MapWorkspace({
onFeatureSelect={onSelectMapFeature}
/>
<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 ? (
<>
<p>Geometry: {selectedMapFeature.geometry?.type ?? 'n/a'}</p>
<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 {
return (
<section data-testid="area-panel">
<h2>Area manager</h2>
<p>{selectedProject ? `Selected project: ${selectedProject.name}` : 'Select a project first'}</p>
<div className="panel-title-row">
<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}>
<input
value={areaForm.name}
onChange={(event) => onUpdateAreaForm({ ...areaForm, name: event.target.value })}
placeholder="AOI name"
/>
<input
value={areaForm.crs}
onChange={(event) => onUpdateAreaForm({ ...areaForm, crs: event.target.value })}
placeholder="EPSG:4326"
/>
<textarea
value={areaForm.geometry}
onChange={(event) => onUpdateAreaForm({ ...areaForm, geometry: event.target.value })}
rows={4}
/>
<button type="submit" disabled={!selectedProjectId}>
<form className="compact-form" onSubmit={onCreateArea}>
<label>
AOI name
<input
value={areaForm.name}
onChange={(event) => onUpdateAreaForm({ ...areaForm, name: event.target.value })}
placeholder="AOI name"
/>
</label>
<label>
CRS
<input
value={areaForm.crs}
onChange={(event) => onUpdateAreaForm({ ...areaForm, crs: event.target.value })}
placeholder="EPSG:4326"
/>
</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
</button>
</form>
{loadingAreas ? <p>Loading areas...</p> : null}
{areas.length === 0 ? <p>No areas yet</p> : null}
<ul>
<ul className="entity-list">
{areas.map((area) => (
<li key={area.id}>
<strong>{area.name}</strong> - {area.area_m2 ? `${area.area_m2.toFixed(2)} m2` : 'n/a'}
<div>geometry: {area.geometry?.type ?? area.geometry_type ?? 'n/a'}</div>
<li className={selectedMapAreaId === area.id ? 'entity-card entity-card-active' : 'entity-card'} key={area.id}>
<strong>{area.name}</strong>
<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
className="secondary-action"
type="button"
onClick={() => onSelectMapArea(area.id)}
disabled={!area.geometry}
@@ -28,45 +28,64 @@ export function ProjectPanel({
}: ProjectPanelProps): JSX.Element {
return (
<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}
<form onSubmit={onCreateProject}>
<input
value={projectForm.name}
onChange={(event) => onUpdateProjectForm({ ...projectForm, name: event.target.value })}
placeholder="Project name"
/>
<input
value={projectForm.description ?? ''}
onChange={(event) => onUpdateProjectForm({ ...projectForm, description: event.target.value })}
placeholder="Description"
/>
<input
value={projectForm.region ?? 'Kempen'}
onChange={(event) => onUpdateProjectForm({ ...projectForm, region: event.target.value })}
placeholder="Region"
/>
<button type="submit">Create project</button>
<form className="compact-form" onSubmit={onCreateProject}>
<label>
Name
<input
value={projectForm.name}
onChange={(event) => onUpdateProjectForm({ ...projectForm, name: event.target.value })}
placeholder="Project name"
/>
</label>
<label>
Description
<input
value={projectForm.description ?? ''}
onChange={(event) => onUpdateProjectForm({ ...projectForm, description: event.target.value })}
placeholder="Description"
/>
</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>
<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'}
</button>
{demoWorkflowMessage ? <p>{demoWorkflowMessage}</p> : null}
</div>
<ul>
<ul className="entity-list">
{projects.map((project) => (
<li key={project.id}>
<button
className={project.id === selectedProjectId ? 'entity-card entity-card-active' : 'entity-card'}
type="button"
onClick={() => onSelectProject(project.id)}
aria-pressed={project.id === selectedProjectId}
data-testid={`project-select-${project.id}`}
>
{project.name}
<strong>{project.name}</strong>
<span>{project.region || 'No region'}</span>
</button>
</li>
))}
@@ -86,52 +86,71 @@ export function SegmentationLab({
}: SegmentationLabProps): JSX.Element {
return (
<section>
<h2>Segmentation Lab</h2>
<button type="button" onClick={onLoadModels} disabled={loadingSegmentationModels}>
Refresh segmentation models
</button>
<div className="panel-title-row">
<div>
<p className="eyebrow">Polygon segmentation</p>
<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}
{segmentationModelError ? <p className="error">{segmentationModelError}</p> : null}
{segmentationModels.length === 0 && !loadingSegmentationModels ? <p>No segmentation models reported by backend</p> : null}
<ul>
<ul className="model-list">
{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>
<div>model: {model.model_id}</div>
<div>framework: {model.framework}</div>
<div>task: {model.task_type}</div>
<div>status: {model.status}</div>
<div>configured: {model.configured ? 'yes' : 'no'}</div>
<div>classes: {model.supported_classes.join(', ')}</div>
<div>limitation: {model.limitation_message}</div>
<span className={model.configured ? 'status-badge status-badge-ready' : 'status-badge'}>{model.status}</span>
<div className="entity-meta">
<span>{model.model_id}</span>
<span>{model.framework}</span>
<span>{model.task_type}</span>
</div>
<p className="muted">classes: {model.supported_classes.join(', ')}</p>
<p className="muted">{model.limitation_message}</p>
</li>
))}
</ul>
<div>
<select value={selectedSegmentationDatasetId} onChange={(event) => onSelectDataset(event.target.value)}>
<option value="">Select raster dataset</option>
{rasterDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{dataset.name}
</option>
))}
</select>
<select value={selectedSegmentationModelId} onChange={(event) => onSelectModel(event.target.value)}>
{segmentationModels.map((model) => (
<option key={model.model_id} value={model.model_id}>
{model.display_name}
</option>
))}
</select>
<input
type="number"
min="0"
max="1"
step="0.05"
value={segmentationConfidenceThreshold}
onChange={(event) => onSetConfidenceThreshold(Number(event.target.value))}
/>
<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)}>
<option value="">Select raster dataset</option>
{rasterDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{dataset.name}
</option>
))}
</select>
</label>
<label>
Model
<select value={selectedSegmentationModelId} onChange={(event) => onSelectModel(event.target.value)}>
{segmentationModels.map((model) => (
<option key={model.model_id} value={model.model_id}>
{model.display_name}
</option>
))}
</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
className="primary-action"
type="button"
onClick={onRunSegmentation}
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}
</div>
) : null}
<div>
<h3>Segmentation results</h3>
<button type="button" onClick={onLoadRuns} disabled={!selectedProjectId}>
Refresh segmentation runs
</button>
<select value={selectedSegmentationRunId} onChange={(event) => onSelectRun(event.target.value)}>
<option value="">Select segmentation run</option>
{segmentationRuns.map((run) => (
<option key={run.id} value={run.id}>
{run.model_name || 'segmentation'} - {run.status} - {run.id}
</option>
))}
</select>
<input
type="text"
placeholder="Class filter"
value={segmentationClassFilter}
onChange={(event) => onSetClassFilter(event.target.value)}
/>
<input
type="number"
min="0"
max="1"
step="0.05"
value={segmentationMinConfidenceFilter}
onChange={(event) => onSetMinConfidenceFilter(Number(event.target.value))}
/>
<button type="button" onClick={onLoadResults} disabled={!selectedSegmentationRunId || loadingSegmentationResults}>
<div className="lab-block">
<div className="panel-title-row">
<h3>Segmentation results</h3>
<button className="secondary-action" type="button" onClick={onLoadRuns} disabled={!selectedProjectId}>
Refresh runs
</button>
</div>
<div className="lab-form-grid">
<label>
Run
<select value={selectedSegmentationRunId} onChange={(event) => onSelectRun(event.target.value)}>
<option value="">Select segmentation run</option>
{segmentationRuns.map((run) => (
<option key={run.id} value={run.id}>
{run.model_name || 'segmentation'} - {run.status} - {run.id}
</option>
))}
</select>
</label>
<label>
Class
<input
type="text"
placeholder="Class filter"
value={segmentationClassFilter}
onChange={(event) => onSetClassFilter(event.target.value)}
/>
</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
</button>
{loadingSegmentationResults ? <p>Loading segmentation results...</p> : null}
@@ -212,17 +244,20 @@ export function SegmentationLab({
</table>
) : null}
</div>
<div>
<div className="lab-block">
<h3>Segmentation QA</h3>
<select value={segmentationReferenceDatasetId} onChange={(event) => onSelectReferenceDataset(event.target.value)}>
<option value="">Select reference dataset</option>
{referenceDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{dataset.name}
</option>
))}
</select>
<button type="button" onClick={onRunQa} disabled={runningSegmentationQa || !selectedSegmentationRunId || !segmentationReferenceDatasetId}>
<label>
Reference dataset
<select value={segmentationReferenceDatasetId} onChange={(event) => onSelectReferenceDataset(event.target.value)}>
<option value="">Select reference dataset</option>
{referenceDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{dataset.name}
</option>
))}
</select>
</label>
<button className="primary-action" type="button" onClick={onRunQa} disabled={runningSegmentationQa || !selectedSegmentationRunId || !segmentationReferenceDatasetId}>
Compare segmentations to reference
</button>
{segmentationQaError ? <p className="error">{segmentationQaError}</p> : null}
+214
View File
@@ -779,6 +779,210 @@ section th {
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) {
.workbench-layout {
grid-template-columns: 12rem minmax(0, 1fr);
@@ -838,6 +1042,16 @@ section th {
.workbench-main .map-container {
height: 28rem;
}
.dataset-upload-form,
.map-toolbar,
.lab-form-grid {
grid-template-columns: 1fr;
}
.dataset-metrics {
grid-template-columns: 1fr;
}
}
@media (max-width: 620px) {