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.
|
||||
- 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`.
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user