Refactor workbench into task-based UI shell
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-06-17 20:39:06 +02:00
parent ea9fc3267a
commit ee2929ae83
9 changed files with 816 additions and 257 deletions
+9
View File
@@ -7,6 +7,15 @@
# Changelog # Changelog
## Sprint 49 Workbench shell UI refactor (2026-06-17)
- Replaced the one-page workbench panel stack with a task-based UI shell.
- Added primary workspaces for Overview, Data, Map, QA/QC, AI Labs, Exports and System.
- Added a persistent top context bar for active project, AOI, dataset and layer state.
- Moved dataset details into a persistent right-side inspector instead of leaving them below the full workflow.
- 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 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`.
@@ -18,15 +18,18 @@ def test_app_uses_quality_and_map_presentational_components() -> None:
assert "<GeoMap" not in app assert "<GeoMap" not in app
def test_map_workspace_is_promoted_before_dense_workflow_grid() -> None: def test_map_workspace_lives_in_task_based_workbench_shell() -> None:
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8") app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
styles = (ROOT / "frontend" / "src" / "styles" / "app.css").read_text(encoding="utf-8") styles = (ROOT / "frontend" / "src" / "styles" / "app.css").read_text(encoding="utf-8")
assert app.index("<MapWorkspace") < app.index('<main className="workspace-grid">') assert "activeWorkspace === 'map'" in app
assert "grid-template-columns: repeat(12, minmax(0, 1fr));" in styles assert app.index("activeWorkspace === 'map'") < app.index("<MapWorkspace")
assert ".workspace-grid > section" in styles assert 'className="workbench-main"' in app
assert "max-height: 42rem;" in styles assert 'className="workbench-inspector"' in app
assert "overflow: auto;" in styles assert ".workbench-layout" in styles
assert ".workbench-sidebar" in styles
assert ".workbench-main" in styles
assert ".workbench-inspector" in styles
def test_quality_results_panel_owns_persisted_quality_check_markup() -> None: def test_quality_results_panel_owns_persisted_quality_check_markup() -> None:
@@ -35,10 +35,10 @@ def test_app_entrypoint_has_clean_encoding_and_react_imports() -> None:
app = app_path.read_text(encoding="utf-8") app = app_path.read_text(encoding="utf-8")
assert not app_bytes.startswith(b"\xef\xbb\xbf") assert not app_bytes.startswith(b"\xef\xbb\xbf")
assert "import { useMemo } from 'react'" in app assert "import { useMemo, useState } from 'react'" in app
assert "FormEvent" not in app assert "FormEvent" not in app
assert "useEffect" not in app assert "useEffect" not in app
assert "useState" not in app assert "const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceKey>('overview')" in app
def test_demo_workflow_hook_owns_demo_api_and_cross_module_selection() -> None: def test_demo_workflow_hook_owns_demo_api_and_cross_module_selection() -> None:
@@ -0,0 +1,38 @@
from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def test_app_uses_task_based_workbench_shell() -> None:
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
assert "type WorkspaceKey" in app
assert "workspaceNavItems" in app
assert 'data-testid={`workspace-nav-${item.key}`}' in app
assert 'className="workbench-layout"' in app
assert 'className="workbench-sidebar"' in app
assert 'className="workbench-main"' in app
assert 'className="workbench-inspector"' in app
assert "activeWorkspace === 'data'" in app
assert "activeWorkspace === 'map'" in app
assert "activeWorkspace === 'analysis'" in app
assert "activeWorkspace === 'ai'" in app
assert "activeWorkspace === 'exports'" in app
assert "activeWorkspace === 'system'" in app
def test_workbench_shell_css_provides_navigation_main_and_inspector_regions() -> None:
css = (ROOT / "frontend" / "src" / "styles" / "app.css").read_text(encoding="utf-8")
assert ".workbench-topbar" in css
assert ".context-bar" in css
assert ".workbench-layout" in css
assert ".workbench-sidebar" in css
assert ".nav-item-active" in css
assert ".workbench-main" in css
assert ".workbench-inspector" in css
assert ".workspace-grid-data" in css
assert ".workspace-grid-ai" in css
+25
View File
@@ -2042,3 +2042,28 @@ Limitations:
Next recommended pass: Next recommended pass:
- Run release readiness, deploy Tower and decide whether to manually acknowledge the current collation version. - Run release readiness, deploy Tower and decide whether to manually acknowledge the current collation version.
## Sprint 49 Workbench shell UI refactor (2026-06-17)
Changed:
- Audited the live workbench UI and confirmed the main usability issue was information architecture: all V1 workflows were mounted as one long vertical panel stack.
- Refactored the frontend into a task-based workbench shell with Overview, Data, Map, QA/QC, AI Labs, Exports and System workspaces.
- Added a persistent top context bar for active project, AOI, dataset and layer state.
- Moved selected dataset details into a persistent right-side inspector while keeping the same dataset/raster/vector operation callbacks.
- Added stable primary navigation test anchors.
- Documented the new frontend shell structure and updated the changelog.
Tested:
- `cd frontend && npm run typecheck`
- `cd frontend && npm run build`
- Local Vite visual audit on `http://127.0.0.1:5175`
Open:
- Local Vite visual audit shows `Request failed (404)` when no local backend/proxy target is available. Docker/nginx same-origin proxy behavior remains the production path.
- The next pass should run full release readiness and deploy to Tower for browser-facing verification on `http://192.168.10.150:1202`.
Limitations:
- This pass intentionally changes UI structure only. It does not add product capabilities, alter API contracts, change migrations, fetch live providers or enable new AI models.
Next recommended pass:
- Run full readiness, rebuild/deploy Tower, then perform a live browser smoke through the new workbench navigation.
+7
View File
@@ -312,3 +312,10 @@ This file now starts with the current implementation status. Older preparation/b
- [x] Add implementation ticket index and tickets. - [x] Add implementation ticket index and tickets.
- [x] Add API example payloads. - [x] Add API example payloads.
- [x] Add final pre-Codex checklist. - [x] Add final pre-Codex checklist.
# Current Workbench UI
- [x] Replace the one-page workflow panel stack with a task-based workbench shell.
- [x] Add persistent project/AOI/dataset/layer context.
- [x] Move selected dataset details into a persistent inspector.
- [ ] Deploy the shell refactor to Tower and run live browser smoke on port 1202.
+12 -1
View File
@@ -2,7 +2,7 @@
React + TypeScript + MapLibre foundation for project/area/dataset workflow. React + TypeScript + MapLibre foundation for project/area/dataset workflow.
The workbench is intentionally map-first: `MapWorkspace` is promoted above the dense workflow grid so GIS context stays visible before lower-detail provider, AI, QA and export panels. Long workflow panels are scroll-contained by CSS rather than stretching every grid row. The workbench now uses a task-based shell instead of a single long panel stack. `App.tsx` still owns shared orchestration state, but the UI is organized into Overview, Data, Map, QA/QC, AI Labs, Exports and System workspaces with a persistent top context bar and right-side dataset inspector.
## Scope implemented ## Scope implemented
- API client layer (`src/services/api`) - API client layer (`src/services/api`)
@@ -220,6 +220,17 @@ The workbench is intentionally map-first: `MapWorkspace` is promoted above the d
- Offline demo workflow orchestration lives in `src/hooks/useDemoWorkflow.ts`, because it coordinates project, dataset, map, QA/QC, detection, segmentation and export state after the backend fixture seed. - Offline demo workflow orchestration lives in `src/hooks/useDemoWorkflow.ts`, because it coordinates project, dataset, map, QA/QC, detection, segmentation and export state after the backend fixture seed.
- Workbench bootstrap and reload effects live in `src/hooks/useWorkbenchBootstrap.ts`, keeping `App.tsx` focused on composing hooks into panels. - Workbench bootstrap and reload effects live in `src/hooks/useWorkbenchBootstrap.ts`, keeping `App.tsx` focused on composing hooks into panels.
## Workbench shell refactor
- The primary UI is organized around `workspaceNavItems` in `src/App.tsx`.
- The shell regions are:
- `workbench-topbar`: active project, AOI, dataset and layer context.
- `workbench-sidebar`: primary navigation between workflow workspaces.
- `workbench-main`: one active workflow at a time.
- `workbench-inspector`: persistent selected dataset details and raster/vector operation controls.
- Existing API calls, hooks, MapLibre rendering and QA/AI/export flows are unchanged.
- Stable navigation test anchors use `data-testid="workspace-nav-{workspace}"`.
## Raster dependency visibility ## Raster dependency visibility
Raster metadata and raster ops may remain unavailable when backend raster stack is missing. In that case: Raster metadata and raster ops may remain unavailable when backend raster stack is missing. In that case:
+362 -248
View File
@@ -1,4 +1,4 @@
import { useMemo } from 'react' import { useMemo, useState } from 'react'
import './styles/app.css' import './styles/app.css'
import { ChangeDetectionPanel } from './components/analysis/ChangeDetectionPanel' import { ChangeDetectionPanel } from './components/analysis/ChangeDetectionPanel'
import { DatasetDetailPanel } from './components/datasets/DatasetDetailPanel' import { DatasetDetailPanel } from './components/datasets/DatasetDetailPanel'
@@ -30,7 +30,20 @@ function isVectorDatasetType(datasetType: string): boolean {
return datasetType === 'vector' || datasetType === 'geojson' return datasetType === 'vector' || datasetType === 'geojson'
} }
type WorkspaceKey = 'overview' | 'data' | 'map' | 'analysis' | 'ai' | 'exports' | 'system'
const workspaceNavItems: Array<{ key: WorkspaceKey; label: string; description: string }> = [
{ key: 'overview', label: 'Overview', description: 'V1 readiness and next action' },
{ key: 'data', label: 'Data', description: 'Projects, AOI and datasets' },
{ key: 'map', label: 'Map', description: 'Layer review and inspection' },
{ key: 'analysis', label: 'QA/QC', description: 'Quality checks and change detection' },
{ key: 'ai', label: 'AI Labs', description: 'Detection and segmentation' },
{ key: 'exports', label: 'Exports', description: 'Artifacts and downloads' },
{ key: 'system', label: 'System', description: 'Provider capabilities' },
]
function App(): JSX.Element { function App(): JSX.Element {
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceKey>('overview')
const { const {
projects, projects,
selectedProject, selectedProject,
@@ -370,269 +383,370 @@ function App(): JSX.Element {
resetExportsForProject, resetExportsForProject,
}) })
const selectedArea = areas.find((area) => area.id === selectedMapAreaId) ?? null
const activeWorkspaceItem = workspaceNavItems.find((item) => item.key === activeWorkspace) ?? workspaceNavItems[0]
return ( return (
<div className="app-shell"> <div className="app-shell workbench-shell">
<header> <header className="workbench-topbar">
<h1>GeoIntel Kempen V1 Workbench</h1> <div className="brand-block">
<p>Raster/vector workbench with QA/QC, exports, change detection and AI foundation layers.</p> <p className="eyebrow">GeoAI Workbench</p>
<h1>GeoIntel Kempen</h1>
</div>
<div className="context-bar" aria-label="Active workbench context">
<div>
<span>Project</span>
<strong>{selectedProject?.name ?? 'No project'}</strong>
</div>
<div>
<span>AOI</span>
<strong>{selectedArea?.name ?? (areas.length > 0 ? 'Select area' : 'No AOI')}</strong>
</div>
<div>
<span>Dataset</span>
<strong>{selectedDataset?.name ?? (datasets.length > 0 ? 'Select dataset' : 'No dataset')}</strong>
</div>
<div>
<span>Layer</span>
<strong>{mapFeatureCollection ? `${mapFeatureCount} features` : 'No active layer'}</strong>
</div>
</div>
</header> </header>
{errorMessage ? <p className="error">{errorMessage}</p> : null} {errorMessage ? <p className="error">{errorMessage}</p> : null}
<WorkbenchStatusStrip <div className="workbench-layout">
selectedProject={selectedProject} <aside className="workbench-sidebar" aria-label="Workbench navigation">
areas={areas} <nav>
datasets={datasets} {workspaceNavItems.map((item) => (
qualityChecks={qualityChecks} <button
exports={exports} key={item.key}
activeLayerFeatureCount={mapFeatureCount} type="button"
selectedAreaHasGeometry={Boolean(areaFeatureCollection)} className={item.key === activeWorkspace ? 'nav-item nav-item-active' : 'nav-item'}
/> onClick={() => setActiveWorkspace(item.key)}
aria-current={item.key === activeWorkspace ? 'page' : undefined}
data-testid={`workspace-nav-${item.key}`}
>
<span>{item.label}</span>
<small>{item.description}</small>
</button>
))}
</nav>
</aside>
<MapWorkspace <main className="workbench-main">
areas={areas} <div className="workspace-heading">
selectedMapAreaId={selectedMapAreaId} <div>
areaFeatureCollection={areaFeatureCollection} <p className="eyebrow">Workspace</p>
mapFeatureCollection={mapFeatureCollection} <h2>{activeWorkspaceItem.label}</h2>
mapLayerLabel={mapLayerLabel} </div>
mapLayerVisible={mapLayerVisible} <p>{activeWorkspaceItem.description}</p>
mapLayerOpacity={mapLayerOpacity} </div>
areaLayerVisible={areaLayerVisible}
areaLayerOpacity={areaLayerOpacity}
mapFeatureCount={mapFeatureCount}
areaFeatureCount={areaFeatureCount}
selectedMapFeature={selectedMapFeature}
onSelectMapArea={setSelectedMapAreaId}
onSetAreaLayerVisible={setAreaLayerVisible}
onSetAreaLayerOpacity={setAreaLayerOpacity}
onSetMapLayerVisible={setMapLayerVisible}
onSetMapLayerOpacity={setMapLayerOpacity}
onSelectMapFeature={setSelectedMapFeature}
/>
<main className="workspace-grid"> {activeWorkspace === 'overview' ? (
<ProjectPanel <div className="workspace-stack">
projects={projects} <WorkbenchStatusStrip
selectedProjectId={selectedProjectId} selectedProject={selectedProject}
loadingProjects={loadingProjects} areas={areas}
projectForm={projectForm} datasets={datasets}
loadingDemoWorkflow={loadingDemoWorkflow} qualityChecks={qualityChecks}
demoWorkflowMessage={demoWorkflowMessage} exports={exports}
onCreateProject={createProject} activeLayerFeatureCount={mapFeatureCount}
onUpdateProjectForm={setProjectForm} selectedAreaHasGeometry={Boolean(areaFeatureCollection)}
onSelectProject={setSelectedProjectId} />
onLoadDemoWorkflow={loadDemoWorkflow} <section className="overview-actions">
/> <div>
<p className="eyebrow">Recommended flow</p>
<h2>Build the project, load data, review on map, validate, export.</h2>
<p className="muted">
Keep the daily workflow focused: prepare data first, inspect it spatially, then run QA/QC or AI lab tasks only when the
required datasets are present.
</p>
</div>
<div className="quick-action-grid">
<button type="button" onClick={() => setActiveWorkspace('data')}>
Open data setup
</button>
<button type="button" onClick={() => setActiveWorkspace('map')}>
Inspect map
</button>
<button type="button" onClick={() => setActiveWorkspace('analysis')}>
Review QA/QC
</button>
<button type="button" onClick={() => setActiveWorkspace('exports')}>
Manage exports
</button>
</div>
</section>
</div>
) : null}
<AreaPanel {activeWorkspace === 'data' ? (
areas={areas} <div className="workspace-grid workspace-grid-data">
selectedProject={selectedProject} <ProjectPanel
selectedProjectId={selectedProjectId} projects={projects}
loadingAreas={loadingAreas} selectedProjectId={selectedProjectId}
areaForm={areaForm} loadingProjects={loadingProjects}
selectedMapAreaId={selectedMapAreaId} projectForm={projectForm}
onCreateArea={createArea} loadingDemoWorkflow={loadingDemoWorkflow}
onUpdateAreaForm={setAreaForm} demoWorkflowMessage={demoWorkflowMessage}
onSelectMapArea={setSelectedMapAreaId} onCreateProject={createProject}
/> onUpdateProjectForm={setProjectForm}
onSelectProject={setSelectedProjectId}
onLoadDemoWorkflow={loadDemoWorkflow}
/>
<DatasetPanel <AreaPanel
selectedProjectId={selectedProjectId} areas={areas}
areas={areas} selectedProject={selectedProject}
datasets={datasets} selectedProjectId={selectedProjectId}
datasetForm={datasetForm} loadingAreas={loadingAreas}
loadingDatasets={loadingDatasets} areaForm={areaForm}
onDatasetFormChange={setDatasetForm} selectedMapAreaId={selectedMapAreaId}
onUploadDataset={uploadDataset} onCreateArea={createArea}
onLoadDatasetDetails={loadDatasetDetails} onUpdateAreaForm={setAreaForm}
onRefreshMetadata={refreshMetadata} onSelectMapArea={setSelectedMapAreaId}
/> />
<ProviderPanel <DatasetPanel
providers={providers} selectedProjectId={selectedProjectId}
loadingCapabilities={loadingCapabilities} areas={areas}
capabilitiesError={capabilitiesError} datasets={datasets}
onRefresh={loadCapabilities} datasetForm={datasetForm}
/> loadingDatasets={loadingDatasets}
onDatasetFormChange={setDatasetForm}
onUploadDataset={uploadDataset}
onLoadDatasetDetails={loadDatasetDetails}
onRefreshMetadata={refreshMetadata}
/>
</div>
) : null}
<ChangeDetectionPanel {activeWorkspace === 'map' ? (
vectorDatasets={availableVectorDatasets} <MapWorkspace
sourceDatasetId={changeSourceDatasetId} areas={areas}
targetDatasetId={changeTargetDatasetId} selectedMapAreaId={selectedMapAreaId}
iouThreshold={changeIouThreshold} areaFeatureCollection={areaFeatureCollection}
includeUnchanged={changeIncludeUnchanged} mapFeatureCollection={mapFeatureCollection}
running={runningChangeDetection} mapLayerLabel={mapLayerLabel}
result={changeDetectionResult} mapLayerVisible={mapLayerVisible}
error={changeDetectionError} mapLayerOpacity={mapLayerOpacity}
onSourceDatasetChange={setChangeSourceDatasetId} areaLayerVisible={areaLayerVisible}
onTargetDatasetChange={setChangeTargetDatasetId} areaLayerOpacity={areaLayerOpacity}
onIouThresholdChange={setChangeIouThreshold} mapFeatureCount={mapFeatureCount}
onIncludeUnchangedChange={setChangeIncludeUnchanged} areaFeatureCount={areaFeatureCount}
onRun={runChangeDetection} selectedMapFeature={selectedMapFeature}
/> onSelectMapArea={setSelectedMapAreaId}
onSetAreaLayerVisible={setAreaLayerVisible}
onSetAreaLayerOpacity={setAreaLayerOpacity}
onSetMapLayerVisible={setMapLayerVisible}
onSetMapLayerOpacity={setMapLayerOpacity}
onSelectMapFeature={setSelectedMapFeature}
/>
) : null}
<DetectionLab {activeWorkspace === 'analysis' ? (
detectionModels={detectionModels} <div className="workspace-grid workspace-grid-analysis">
loadingDetectionModels={loadingDetectionModels} <ChangeDetectionPanel
detectionModelError={detectionModelError} vectorDatasets={availableVectorDatasets}
selectedDetectionDatasetId={selectedDetectionDatasetId} sourceDatasetId={changeSourceDatasetId}
selectedDetectionModelId={selectedDetectionModelId} targetDatasetId={changeTargetDatasetId}
detectionTileManifestPath={detectionTileManifestPath} iouThreshold={changeIouThreshold}
detectionConfidenceThreshold={detectionConfidenceThreshold} includeUnchanged={changeIncludeUnchanged}
runningDetection={runningDetection} running={runningChangeDetection}
detectionRunResult={detectionRunResult} result={changeDetectionResult}
detectionRunError={detectionRunError} error={changeDetectionError}
detectionRuns={detectionRuns} onSourceDatasetChange={setChangeSourceDatasetId}
selectedDetectionRunId={selectedDetectionRunId} onTargetDatasetChange={setChangeTargetDatasetId}
detectionItems={detectionItems} onIouThresholdChange={setChangeIouThreshold}
detectionClassFilter={detectionClassFilter} onIncludeUnchangedChange={setChangeIncludeUnchanged}
detectionMinConfidenceFilter={detectionMinConfidenceFilter} onRun={runChangeDetection}
loadingDetectionResults={loadingDetectionResults} />
detectionReferenceDatasetId={detectionReferenceDatasetId}
detectionQaResult={detectionQaResult}
detectionQaError={detectionQaError}
runningDetectionQa={runningDetectionQa}
selectedProjectId={selectedProjectId}
rasterDatasets={rasterDatasets}
referenceDatasets={referenceDatasets}
onLoadModels={loadDetectionModels}
onSelectDataset={setSelectedDetectionDatasetId}
onSelectModel={setSelectedDetectionModelId}
onSetConfidenceThreshold={setDetectionConfidenceThreshold}
onSetTileManifestPath={setDetectionTileManifestPath}
onRunDetection={runDetection}
onLoadRuns={() => loadDetectionRuns()}
onSelectRun={setSelectedDetectionRunId}
onSetClassFilter={setDetectionClassFilter}
onSetMinConfidenceFilter={setDetectionMinConfidenceFilter}
onLoadResults={() => loadDetectionResults()}
onSelectReferenceDataset={setDetectionReferenceDatasetId}
onRunQa={runDetectionQa}
/>
<SegmentationLab <QualityResultsPanel
segmentationModels={segmentationModels} selectedProjectId={selectedProjectId}
loadingSegmentationModels={loadingSegmentationModels} qualityChecks={qualityChecks}
segmentationModelError={segmentationModelError} qualityChecksError={qualityChecksError}
selectedSegmentationDatasetId={selectedSegmentationDatasetId} onRefresh={() => loadQualityChecks()}
selectedSegmentationModelId={selectedSegmentationModelId} />
segmentationConfidenceThreshold={segmentationConfidenceThreshold} </div>
runningSegmentation={runningSegmentation} ) : null}
segmentationRunResult={segmentationRunResult}
segmentationRunError={segmentationRunError}
segmentationRuns={segmentationRuns}
selectedSegmentationRunId={selectedSegmentationRunId}
segmentationItems={segmentationItems}
segmentationClassFilter={segmentationClassFilter}
segmentationMinConfidenceFilter={segmentationMinConfidenceFilter}
loadingSegmentationResults={loadingSegmentationResults}
segmentationReferenceDatasetId={segmentationReferenceDatasetId}
segmentationQaResult={segmentationQaResult}
segmentationQaError={segmentationQaError}
runningSegmentationQa={runningSegmentationQa}
selectedProjectId={selectedProjectId}
rasterDatasets={rasterDatasets}
referenceDatasets={referenceDatasets}
selectedSegmentationModelConfigured={Boolean(selectedSegmentationModel?.configured)}
selectedSegmentationModelLimitation={selectedSegmentationModel?.limitation_message ?? null}
onLoadModels={loadSegmentationModels}
onSelectDataset={setSelectedSegmentationDatasetId}
onSelectModel={setSelectedSegmentationModelId}
onSetConfidenceThreshold={setSegmentationConfidenceThreshold}
onRunSegmentation={runSegmentation}
onLoadRuns={() => loadSegmentationRuns()}
onSelectRun={setSelectedSegmentationRunId}
onSetClassFilter={setSegmentationClassFilter}
onSetMinConfidenceFilter={setSegmentationMinConfidenceFilter}
onLoadResults={() => loadSegmentationResults()}
onSelectReferenceDataset={setSegmentationReferenceDatasetId}
onRunQa={runSegmentationQa}
/>
<QualityResultsPanel {activeWorkspace === 'ai' ? (
selectedProjectId={selectedProjectId} <div className="workspace-grid workspace-grid-ai">
qualityChecks={qualityChecks} <DetectionLab
qualityChecksError={qualityChecksError} detectionModels={detectionModels}
onRefresh={() => loadQualityChecks()} loadingDetectionModels={loadingDetectionModels}
/> detectionModelError={detectionModelError}
selectedDetectionDatasetId={selectedDetectionDatasetId}
selectedDetectionModelId={selectedDetectionModelId}
detectionTileManifestPath={detectionTileManifestPath}
detectionConfidenceThreshold={detectionConfidenceThreshold}
runningDetection={runningDetection}
detectionRunResult={detectionRunResult}
detectionRunError={detectionRunError}
detectionRuns={detectionRuns}
selectedDetectionRunId={selectedDetectionRunId}
detectionItems={detectionItems}
detectionClassFilter={detectionClassFilter}
detectionMinConfidenceFilter={detectionMinConfidenceFilter}
loadingDetectionResults={loadingDetectionResults}
detectionReferenceDatasetId={detectionReferenceDatasetId}
detectionQaResult={detectionQaResult}
detectionQaError={detectionQaError}
runningDetectionQa={runningDetectionQa}
selectedProjectId={selectedProjectId}
rasterDatasets={rasterDatasets}
referenceDatasets={referenceDatasets}
onLoadModels={loadDetectionModels}
onSelectDataset={setSelectedDetectionDatasetId}
onSelectModel={setSelectedDetectionModelId}
onSetConfidenceThreshold={setDetectionConfidenceThreshold}
onSetTileManifestPath={setDetectionTileManifestPath}
onRunDetection={runDetection}
onLoadRuns={() => loadDetectionRuns()}
onSelectRun={setSelectedDetectionRunId}
onSetClassFilter={setDetectionClassFilter}
onSetMinConfidenceFilter={setDetectionMinConfidenceFilter}
onLoadResults={() => loadDetectionResults()}
onSelectReferenceDataset={setDetectionReferenceDatasetId}
onRunQa={runDetectionQa}
/>
<ExportCenter <SegmentationLab
selectedProjectId={selectedProjectId} segmentationModels={segmentationModels}
selectedDataset={selectedDataset} loadingSegmentationModels={loadingSegmentationModels}
selectedDetectionRunId={selectedDetectionRunId} segmentationModelError={segmentationModelError}
selectedSegmentationRunId={selectedSegmentationRunId} selectedSegmentationDatasetId={selectedSegmentationDatasetId}
exports={exports} selectedSegmentationModelId={selectedSegmentationModelId}
latestExport={latestExport} segmentationConfidenceThreshold={segmentationConfidenceThreshold}
exportError={exportError} runningSegmentation={runningSegmentation}
loadingExports={loadingExports} segmentationRunResult={segmentationRunResult}
exporting={exporting} segmentationRunError={segmentationRunError}
onRefresh={() => loadExports()} segmentationRuns={segmentationRuns}
onExportDataset={exportSelectedDatasetGeoJson} selectedSegmentationRunId={selectedSegmentationRunId}
onExportDetectionRun={exportSelectedDetectionRunGeoJson} segmentationItems={segmentationItems}
onExportSegmentationRun={exportSelectedSegmentationRunGeoJson} segmentationClassFilter={segmentationClassFilter}
onExportProjectMetadata={exportProjectMetadata} segmentationMinConfidenceFilter={segmentationMinConfidenceFilter}
onExportProjectReport={exportProjectReport} loadingSegmentationResults={loadingSegmentationResults}
onPreviewContent={previewExportContent} segmentationReferenceDatasetId={segmentationReferenceDatasetId}
onDownload={downloadExportArtifact} segmentationQaResult={segmentationQaResult}
/> segmentationQaError={segmentationQaError}
<ExportPreview content={exportPreview} /> runningSegmentationQa={runningSegmentationQa}
</main> selectedProjectId={selectedProjectId}
rasterDatasets={rasterDatasets}
referenceDatasets={referenceDatasets}
selectedSegmentationModelConfigured={Boolean(selectedSegmentationModel?.configured)}
selectedSegmentationModelLimitation={selectedSegmentationModel?.limitation_message ?? null}
onLoadModels={loadSegmentationModels}
onSelectDataset={setSelectedSegmentationDatasetId}
onSelectModel={setSelectedSegmentationModelId}
onSetConfidenceThreshold={setSegmentationConfidenceThreshold}
onRunSegmentation={runSegmentation}
onLoadRuns={() => loadSegmentationRuns()}
onSelectRun={setSelectedSegmentationRunId}
onSetClassFilter={setSegmentationClassFilter}
onSetMinConfidenceFilter={setSegmentationMinConfidenceFilter}
onLoadResults={() => loadSegmentationResults()}
onSelectReferenceDataset={setSegmentationReferenceDatasetId}
onRunQa={runSegmentationQa}
/>
</div>
) : null}
<DatasetDetailPanel {activeWorkspace === 'exports' ? (
areas={areas} <div className="workspace-grid workspace-grid-exports">
availableVectorTargets={availableVectorTargets} <ExportCenter
selectedDatasetId={selectedDatasetId} selectedProjectId={selectedProjectId}
selectedDataset={selectedDataset} selectedDataset={selectedDataset}
selectedDatasetSummary={selectedDatasetSummary} selectedDetectionRunId={selectedDetectionRunId}
selectedRasterMetadata={selectedRasterMetadata} selectedSegmentationRunId={selectedSegmentationRunId}
selectedRasterStats={selectedRasterStats} exports={exports}
rasterPreview={rasterPreview} latestExport={latestExport}
rasterUnavailableMessage={rasterUnavailableMessage} exportError={exportError}
selectedClipAreaId={selectedClipAreaId} loadingExports={loadingExports}
selectedIntersectTargetId={selectedIntersectTargetId} exporting={exporting}
rasterTileSize={rasterTileSize} onRefresh={() => loadExports()}
rasterTileOverlap={rasterTileOverlap} onExportDataset={exportSelectedDatasetGeoJson}
rasterTileOutputName={rasterTileOutputName} onExportDetectionRun={exportSelectedDetectionRunGeoJson}
rasterReprojectCrs={rasterReprojectCrs} onExportSegmentationRun={exportSelectedSegmentationRunGeoJson}
rasterReprojectResampling={rasterReprojectResampling} onExportProjectMetadata={exportProjectMetadata}
ndviNirBand={ndviNirBand} onExportProjectReport={exportProjectReport}
ndviRedBand={ndviRedBand} onPreviewContent={previewExportContent}
ndwiGreenBand={ndwiGreenBand} onDownload={downloadExportArtifact}
ndwiNirBand={ndwiNirBand} />
ndbiSwirBand={ndbiSwirBand} <ExportPreview content={exportPreview} />
ndbiNirBand={ndbiNirBand} </div>
isRasterTileInputValid={isRasterTileInputValid} ) : null}
jobs={jobs}
loadingDatasetDetails={loadingDatasetDetails} {activeWorkspace === 'system' ? (
datasetDetailError={datasetDetailError} <ProviderPanel
isVectorDatasetType={isVectorDatasetType} providers={providers}
onSetSelectedClipAreaId={setSelectedClipAreaId} loadingCapabilities={loadingCapabilities}
onSetSelectedIntersectTargetId={setSelectedIntersectTargetId} capabilitiesError={capabilitiesError}
onSetRasterTileSize={setRasterTileSize} onRefresh={loadCapabilities}
onSetRasterTileOverlap={setRasterTileOverlap} />
onSetRasterTileOutputName={setRasterTileOutputName} ) : null}
onSetRasterReprojectCrs={setRasterReprojectCrs} </main>
onSetRasterReprojectResampling={setRasterReprojectResampling}
onSetNdviNirBand={setNdviNirBand} <aside className="workbench-inspector" aria-label="Current selection inspector">
onSetNdviRedBand={setNdviRedBand} <DatasetDetailPanel
onSetNdwiGreenBand={setNdwiGreenBand} areas={areas}
onSetNdwiNirBand={setNdwiNirBand} availableVectorTargets={availableVectorTargets}
onSetNdbiSwirBand={setNdbiSwirBand} selectedDatasetId={selectedDatasetId}
onSetNdbiNirBand={setNdbiNirBand} selectedDataset={selectedDataset}
onRunRasterInspect={runRasterInspect} selectedDatasetSummary={selectedDatasetSummary}
onRunRasterPreview={runRasterPreview} selectedRasterMetadata={selectedRasterMetadata}
onRunRasterStats={runRasterStats} selectedRasterStats={selectedRasterStats}
onRunRasterReproject={runRasterReproject} rasterPreview={rasterPreview}
onRunRasterClip={runRasterClip} rasterUnavailableMessage={rasterUnavailableMessage}
onRunRasterTile={runRasterTile} selectedClipAreaId={selectedClipAreaId}
onRunRasterNdvi={runRasterNdvi} selectedIntersectTargetId={selectedIntersectTargetId}
onRunRasterNdwi={runRasterNdwi} rasterTileSize={rasterTileSize}
onRunRasterNdbi={runRasterNdbi} rasterTileOverlap={rasterTileOverlap}
onRunVectorClip={runVectorClip} rasterTileOutputName={rasterTileOutputName}
onRunVectorBuffer={runVectorBuffer} rasterReprojectCrs={rasterReprojectCrs}
onRunVectorIntersect={() => runVectorIntersect(availableVectorTargets)} rasterReprojectResampling={rasterReprojectResampling}
onPickDerivedDataset={pickDerivedDataset} ndviNirBand={ndviNirBand}
/> ndviRedBand={ndviRedBand}
ndwiGreenBand={ndwiGreenBand}
ndwiNirBand={ndwiNirBand}
ndbiSwirBand={ndbiSwirBand}
ndbiNirBand={ndbiNirBand}
isRasterTileInputValid={isRasterTileInputValid}
jobs={jobs}
loadingDatasetDetails={loadingDatasetDetails}
datasetDetailError={datasetDetailError}
isVectorDatasetType={isVectorDatasetType}
onSetSelectedClipAreaId={setSelectedClipAreaId}
onSetSelectedIntersectTargetId={setSelectedIntersectTargetId}
onSetRasterTileSize={setRasterTileSize}
onSetRasterTileOverlap={setRasterTileOverlap}
onSetRasterTileOutputName={setRasterTileOutputName}
onSetRasterReprojectCrs={setRasterReprojectCrs}
onSetRasterReprojectResampling={setRasterReprojectResampling}
onSetNdviNirBand={setNdviNirBand}
onSetNdviRedBand={setNdviRedBand}
onSetNdwiGreenBand={setNdwiGreenBand}
onSetNdwiNirBand={setNdwiNirBand}
onSetNdbiSwirBand={setNdbiSwirBand}
onSetNdbiNirBand={setNdbiNirBand}
onRunRasterInspect={runRasterInspect}
onRunRasterPreview={runRasterPreview}
onRunRasterStats={runRasterStats}
onRunRasterReproject={runRasterReproject}
onRunRasterClip={runRasterClip}
onRunRasterTile={runRasterTile}
onRunRasterNdvi={runRasterNdvi}
onRunRasterNdwi={runRasterNdwi}
onRunRasterNdbi={runRasterNdbi}
onRunVectorClip={runVectorClip}
onRunVectorBuffer={runVectorBuffer}
onRunVectorIntersect={() => runVectorIntersect(availableVectorTargets)}
onPickDerivedDataset={pickDerivedDataset}
/>
</aside>
</div>
</div> </div>
) )
} }
+352
View File
@@ -506,3 +506,355 @@ section li strong + div {
margin-top: 0.6rem; margin-top: 0.6rem;
} }
} }
.workbench-shell {
width: 100%;
max-width: none;
padding: 0;
background: #eef4f1;
}
.workbench-topbar {
position: sticky;
top: 0;
z-index: 20;
display: grid;
grid-template-columns: minmax(16rem, 26rem) minmax(0, 1fr);
gap: 1rem;
align-items: center;
border-bottom: 1px solid var(--line);
padding: 0.85rem 1rem;
background: rgba(248, 251, 249, 0.96);
backdrop-filter: blur(12px);
}
.brand-block h1 {
font-size: 1.45rem;
line-height: 1.1;
}
.context-bar {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.65rem;
}
.context-bar > div {
min-height: 4.2rem;
border: 1px solid var(--line);
border-radius: 8px;
padding: 0.62rem 0.72rem;
background: #ffffff;
}
.context-bar span {
display: block;
color: var(--muted);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.context-bar strong {
display: block;
margin-top: 0.3rem;
font-size: 0.92rem;
line-height: 1.25;
}
.workbench-layout {
display: grid;
grid-template-columns: 14rem minmax(0, 1fr) 24rem;
gap: 0;
height: calc(100vh - 5.95rem);
min-height: 42rem;
}
.workbench-sidebar,
.workbench-inspector {
min-height: 0;
overflow: auto;
border-color: var(--line);
background: #f8fbf9;
}
.workbench-sidebar {
border-right: 1px solid var(--line);
padding: 0.85rem;
}
.workbench-sidebar nav {
display: grid;
gap: 0.45rem;
}
.nav-item {
display: block;
width: 100%;
min-height: 4.2rem;
border-color: transparent;
padding: 0.66rem 0.72rem;
background: transparent;
box-shadow: none;
text-align: left;
}
.nav-item:hover:not(:disabled) {
background: #ffffff;
}
.nav-item span,
.nav-item small {
display: block;
}
.nav-item span {
font-size: 0.96rem;
}
.nav-item small {
margin-top: 0.22rem;
color: var(--muted);
font-size: 0.77rem;
font-weight: 550;
line-height: 1.3;
}
.nav-item-active {
border-color: rgba(15, 118, 110, 0.34);
background: #ffffff;
box-shadow: inset 4px 0 0 var(--accent);
}
.workbench-main {
min-width: 0;
min-height: 0;
overflow: auto;
padding: 1rem;
}
.workbench-inspector {
border-left: 1px solid var(--line);
padding: 1rem;
}
.workbench-inspector > section {
border: 0;
padding: 0;
background: transparent;
box-shadow: none;
}
.workspace-heading {
display: flex;
gap: 1rem;
align-items: end;
justify-content: space-between;
margin-bottom: 1rem;
border: 1px solid var(--line);
border-radius: 8px;
padding: 0.85rem 1rem;
background: #ffffff;
}
.workspace-heading h2 {
margin: 0;
}
.workspace-heading p:last-child {
max-width: 32rem;
margin: 0;
color: var(--muted);
text-align: right;
}
.workspace-stack {
display: grid;
gap: 1rem;
}
.workspace-grid {
margin-top: 0;
}
.workspace-grid-data {
grid-template-columns: minmax(16rem, 0.75fr) minmax(18rem, 0.95fr) minmax(22rem, 1.35fr);
}
.workspace-grid-analysis,
.workspace-grid-ai,
.workspace-grid-exports {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.workspace-grid-data > section,
.workspace-grid-analysis > section,
.workspace-grid-ai > section,
.workspace-grid-exports > section,
.workspace-grid-data > section:nth-child(n),
.workspace-grid-analysis > section:nth-child(n),
.workspace-grid-ai > section:nth-child(n),
.workspace-grid-exports > section:nth-child(n) {
grid-column: auto;
max-height: none;
overflow: visible;
}
.workspace-grid-ai > section {
align-self: stretch;
}
.overview-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(16rem, 24rem);
gap: 1rem;
align-items: center;
}
.overview-actions h2 {
max-width: 42rem;
margin-bottom: 0.5rem;
}
.quick-action-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
}
.quick-action-grid button {
min-height: 4.25rem;
}
section form,
section > div:not(.map-controls):not(.feature-inspector):not(.quick-action-grid) {
display: grid;
gap: 0.55rem;
}
section button + button {
margin-top: 0.45rem;
}
section table {
width: 100%;
margin-top: 0.75rem;
border-collapse: collapse;
font-size: 0.86rem;
}
section th,
section td {
border-bottom: 1px solid var(--line);
padding: 0.5rem;
text-align: left;
vertical-align: top;
}
section th {
color: var(--muted);
font-size: 0.72rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.workbench-inspector .job-result,
.workbench-inspector pre {
max-height: 16rem;
}
.workbench-inspector h2 {
position: sticky;
top: 0;
z-index: 2;
margin: 0 -1rem 0.8rem;
border-bottom: 1px solid var(--line);
padding: 0.35rem 1rem 0.75rem;
background: #f8fbf9;
}
.workbench-main .map-container {
height: calc(100vh - 23rem);
min-height: 28rem;
}
@media (max-width: 1280px) {
.workbench-layout {
grid-template-columns: 12rem minmax(0, 1fr);
}
.workbench-inspector {
grid-column: 1 / -1;
border-top: 1px solid var(--line);
border-left: 0;
}
}
@media (max-width: 980px) {
.workbench-topbar,
.workbench-layout,
.workspace-grid-data,
.workspace-grid-analysis,
.workspace-grid-ai,
.workspace-grid-exports,
.overview-actions {
grid-template-columns: 1fr;
}
.workbench-layout {
height: auto;
}
.workbench-sidebar {
position: sticky;
top: 5.8rem;
z-index: 10;
border-right: 0;
border-bottom: 1px solid var(--line);
}
.workbench-sidebar nav,
.context-bar,
.quick-action-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.nav-item {
min-height: 3.45rem;
}
.workspace-heading,
.status-strip-header {
display: block;
}
.workspace-heading p:last-child,
.status-next-action {
margin-top: 0.45rem;
text-align: left;
}
.workbench-main .map-container {
height: 28rem;
}
}
@media (max-width: 620px) {
.workbench-topbar,
.workbench-main,
.workbench-inspector,
.workbench-sidebar {
padding: 0.75rem;
}
.context-bar,
.workbench-sidebar nav,
.quick-action-grid {
grid-template-columns: 1fr;
}
.status-strip-grid {
grid-template-columns: 1fr;
}
}