Polish workbench workspace usability
This commit is contained in:
@@ -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`.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user