Extract dataset presentational components
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 04:04:20 +02:00
parent 57e9d44e85
commit 1cbb356220
11 changed files with 888 additions and 385 deletions
+8
View File
@@ -7,6 +7,14 @@
# Changelog
## Sprint 29 dataset component decomposition (2026-06-17)
- Moved dataset upload/list UI into `DatasetPanel`.
- Moved dataset details and job list UI into `DatasetDetailPanel`.
- Split raster and vector controls into `RasterControls` and `VectorControls`.
- Added regression coverage to verify `App.tsx` wires the new presentational dataset components without taking dataset markup back inline.
- No API contracts, backend behavior, migrations, product features, provider fetching, AI behavior or UI redesign were introduced.
## Sprint 28 dataset workflow hook hardening (2026-06-17)
- Moved dataset selection, upload, detail loading, dataset jobs and raster/vector operation orchestration from `App.tsx` into `useDatasetWorkflow`.
@@ -41,19 +41,32 @@ def test_dataset_workflow_hook_owns_dataset_api_calls() -> None:
def test_app_still_wires_dataset_ui_callbacks() -> None:
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
dataset_panel = (ROOT / "frontend" / "src" / "components" / "datasets" / "DatasetPanel.tsx").read_text(
encoding="utf-8"
)
detail_panel = (ROOT / "frontend" / "src" / "components" / "datasets" / "DatasetDetailPanel.tsx").read_text(
encoding="utf-8"
)
assert "<form onSubmit={uploadDataset}" in app
assert "onClick={() => loadDatasetDetails(selectedProjectId ?? '', dataset)}" in app
assert "onClick={() => refreshMetadata(dataset.id)}" in app
assert "onClick={runRasterInspect}" in app
assert "onClick={runRasterPreview}" in app
assert "onClick={runRasterStats}" in app
assert "onClick={runRasterReproject}" in app
assert "onClick={runRasterClip}" in app
assert "onClick={runRasterTile}" in app
assert "onClick={runRasterNdvi}" in app
assert "onClick={runRasterNdwi}" in app
assert "onClick={runRasterNdbi}" in app
assert "onClick={runVectorClip}" in app
assert "onClick={runVectorBuffer}" in app
assert "onClick={() => runVectorIntersect(availableVectorTargets)}" in app
assert "<DatasetPanel" in app
assert "onUploadDataset={uploadDataset}" in app
assert "onLoadDatasetDetails={loadDatasetDetails}" in app
assert "onRefreshMetadata={refreshMetadata}" in app
assert "<DatasetDetailPanel" in app
assert "onRunRasterInspect={runRasterInspect}" in app
assert "onRunRasterPreview={runRasterPreview}" in app
assert "onRunRasterStats={runRasterStats}" in app
assert "onRunRasterReproject={runRasterReproject}" in app
assert "onRunRasterClip={runRasterClip}" in app
assert "onRunRasterTile={runRasterTile}" in app
assert "onRunRasterNdvi={runRasterNdvi}" in app
assert "onRunRasterNdwi={runRasterNdwi}" in app
assert "onRunRasterNdbi={runRasterNdbi}" in app
assert "onRunVectorClip={runVectorClip}" in app
assert "onRunVectorBuffer={runVectorBuffer}" in app
assert "onRunVectorIntersect={() => runVectorIntersect(availableVectorTargets)}" in app
assert "<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
assert "onRunVectorIntersect={onRunVectorIntersect}" in detail_panel
@@ -0,0 +1,49 @@
from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def test_app_uses_dataset_presentational_components() -> None:
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
assert "from './components/datasets/DatasetPanel'" in app
assert "from './components/datasets/DatasetDetailPanel'" in app
assert "<DatasetPanel" in app
assert "<DatasetDetailPanel" in app
assert "<form onSubmit={uploadDataset}" not in app
assert "<h3>Raster operations</h3>" not in app
assert "<h3>Vector operations</h3>" not in app
def test_dataset_panel_owns_upload_and_list_markup() -> None:
panel = (ROOT / "frontend" / "src" / "components" / "datasets" / "DatasetPanel.tsx").read_text(encoding="utf-8")
assert "Upload dataset" in panel
assert "Refresh metadata" in panel
assert "Select / details" in panel
assert "onLoadDatasetDetails" in panel
assert "onRefreshMetadata" in panel
def test_dataset_detail_panel_composes_raster_and_vector_controls() -> None:
detail_panel = (ROOT / "frontend" / "src" / "components" / "datasets" / "DatasetDetailPanel.tsx").read_text(
encoding="utf-8"
)
raster_controls = (ROOT / "frontend" / "src" / "components" / "datasets" / "RasterControls.tsx").read_text(
encoding="utf-8"
)
vector_controls = (ROOT / "frontend" / "src" / "components" / "datasets" / "VectorControls.tsx").read_text(
encoding="utf-8"
)
assert "<RasterControls" in detail_panel
assert "<VectorControls" in detail_panel
assert "Jobs" in detail_panel
assert "Raster operations" in raster_controls
assert "Compute NDVI" in raster_controls
assert "Generate tiles" in raster_controls
assert "Vector operations" in vector_controls
assert "Run intersect" in vector_controls
+24
View File
@@ -1,3 +1,27 @@
## Sprint 29 dataset component decomposition (2026-06-17)
Changed:
- Moved dataset upload/list rendering from `frontend/src/App.tsx` into `frontend/src/components/datasets/DatasetPanel.tsx`.
- Moved dataset detail and job-list rendering into `frontend/src/components/datasets/DatasetDetailPanel.tsx`.
- Split raster controls and vector controls into `frontend/src/components/datasets/RasterControls.tsx` and `frontend/src/components/datasets/VectorControls.tsx`.
- Updated Sprint 28 regression tests for the new component boundary and added Sprint 29 component wiring tests.
- Updated frontend README, TODO and changelog docs.
Validation:
- `python -m pytest backend/tests/test_sprint29_dataset_components.py backend/tests/test_sprint28_dataset_workflow_hook.py backend/tests/test_sprint27_frontend_workflow_hooks.py` passed: 10 tests.
- `python -m compileall backend/app` passed.
- `cd backend && python -m pytest` passed: 170 tests.
- `cd frontend && npm run typecheck` passed.
- `cd frontend && npm run build` passed.
- `bash scripts/run_readiness_check.sh` passed: 170 backend tests, frontend typecheck/build, Alembic head check and script syntax checks.
- `cd backend && python -m alembic heads` passed: single head `202606120900`.
- `cd backend && python -m alembic upgrade head --sql` passed.
- `bash -n scripts/live_migration_smoke.sh` passed.
Notes:
- No API contracts, backend behavior, migrations, product features, provider fetching, AI behavior or UI redesign changed.
- Next maintainability pass should split change detection, QA/QC results and map workspace controls into focused presentational components.
## Sprint 28 dataset workflow hook hardening (2026-06-17)
Changed:
+2 -1
View File
@@ -39,7 +39,8 @@ This file now starts with the current implementation status. Older preparation/b
- [x] Detection and segmentation workflow hook extraction beyond Sprint 10.
- [x] Export and QA/QC workflow hook extraction beyond Sprint 10.
- [x] Dataset, raster and vector workflow hook extraction beyond Sprint 10.
- [ ] Further frontend component decomposition for dataset detail, raster controls and vector controls.
- [x] Dataset detail, raster controls and vector controls component decomposition.
- [ ] Further frontend component decomposition for change detection, QA/QC results and map workspace controls.
## Sprint 8 status
+7
View File
@@ -190,6 +190,13 @@ React + TypeScript + MapLibre foundation for project/area/dataset workflow.
- Project-scoped dataset listing remains in `App.tsx` because it is still part of the shared project/area load boundary.
- Dataset details, raster controls, vector controls and job list behavior are unchanged; the UI still receives the same callbacks and state.
## Sprint 29 maintainability updates
- Dataset upload/list rendering moved into `src/components/datasets/DatasetPanel.tsx`.
- Dataset details and job-list rendering moved into `src/components/datasets/DatasetDetailPanel.tsx`.
- Raster controls and vector controls now live in `src/components/datasets/RasterControls.tsx` and `src/components/datasets/VectorControls.tsx`.
- `App.tsx` still owns cross-module orchestration and passes the same `useDatasetWorkflow` state/actions into these presentational components.
## Release hardening updates
- Production builds split application code, React vendor code and MapLibre vendor code into separate chunks.
+68 -369
View File
@@ -6,6 +6,8 @@ import { datasetsApi } from './services/api/datasets'
import { projectsApi } from './services/api/projects'
import { analysisApi, demoApi, externalApi } from './services/api'
import { ChangeDetectionPanel } from './components/analysis/ChangeDetectionPanel'
import { DatasetDetailPanel } from './components/datasets/DatasetDetailPanel'
import { DatasetPanel } from './components/datasets/DatasetPanel'
import { DetectionLab } from './components/detection/DetectionLab'
import { ExportCenter } from './components/exports/ExportCenter'
import { AreaPanel } from './components/project/AreaPanel'
@@ -36,31 +38,6 @@ function isVectorDatasetType(datasetType: string): boolean {
return datasetType === 'vector' || datasetType === 'geojson'
}
function formatBytes(value: number | null | undefined): string {
if (!value && value !== 0) {
return 'n/a'
}
const units = ['B', 'KB', 'MB', 'GB']
let size = value
let index = 0
while (size >= 1024 && index < units.length - 1) {
size /= 1024
index += 1
}
return `${size.toFixed(1)} ${units[index]}`
}
function formatBounds(bounds: Record<string, number> | null | undefined): string {
if (!bounds) {
return 'n/a'
}
const keys = ['min_x', 'min_y', 'max_x', 'max_y']
if (!keys.every((key) => key in bounds)) {
return 'n/a'
}
return `${bounds.min_x?.toFixed(4)}, ${bounds.min_y?.toFixed(4)} -> ${bounds.max_x?.toFixed(4)}, ${bounds.max_y?.toFixed(4)}`
}
function App(): JSX.Element {
const [projects, setProjects] = useState<ProjectRead[]>([])
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null)
@@ -368,14 +345,6 @@ function App(): JSX.Element {
}, [changeDetectionResult, datasetContent, detectionGeoJson, segmentationGeoJson, selectedDataset])
const mapFeatureCount = mapFeatureCollection?.features.length ?? 0
const areaFeatureCount = areaFeatureCollection?.features.length ?? 0
const formatRasterBounds = (bounds: number[] | undefined | null): string => {
if (!bounds || bounds.length < 4) {
return 'n/a'
}
const [minX, minY, maxX, maxY] = bounds
return `${minX.toFixed(4)}, ${minY.toFixed(4)} -> ${maxX.toFixed(4)}, ${maxY.toFixed(4)}`
}
const loadProjects = async () => {
setLoadingProjects(true)
setErrorMessage(null)
@@ -798,344 +767,74 @@ function App(): JSX.Element {
</section>
) : null}
<section>
<h2>Datasets</h2>
<form onSubmit={uploadDataset}>
<select
value={datasetForm.datasetType}
onChange={(event) => setDatasetForm((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) => setDatasetForm((previous) => ({ ...previous, source: event.target.value }))}
placeholder="user_upload"
/>
<select
value={datasetForm.areaId}
onChange={(event) => setDatasetForm((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) => setDatasetForm((previous) => ({ ...previous, file: event.target.files?.[0] ?? null }))}
/>
<button type="submit" disabled={!selectedProjectId}>
Upload dataset
</button>
</form>
{loadingDatasets ? <p>Loading datasets...</p> : null}
{datasets.length === 0 ? <p>No datasets yet</p> : null}
<ul>
{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={() => loadDatasetDetails(selectedProjectId ?? '', dataset)}>
Select / details
</button>
<button
type="button"
onClick={() => refreshMetadata(dataset.id)}
disabled={dataset.dataset_type === 'raster'}
>
Refresh metadata
</button>
</li>
))}
</ul>
</section>
<DatasetPanel
selectedProjectId={selectedProjectId}
areas={areas}
datasets={datasets}
datasetForm={datasetForm}
loadingDatasets={loadingDatasets}
onDatasetFormChange={setDatasetForm}
onUploadDataset={uploadDataset}
onLoadDatasetDetails={loadDatasetDetails}
onRefreshMetadata={refreshMetadata}
/>
</main>
<section>
<h2>Dataset details</h2>
{selectedDatasetId ? <p>Selected dataset: {selectedDatasetId}</p> : <p>No dataset selected</p>}
{selectedDataset ? (
<div>
<p>
<strong>{selectedDataset.name}</strong>
</p>
<p>Type: {selectedDataset.dataset_type}</p>
<p>Status: {selectedDataset.status}</p>
<p>Original file: {selectedDataset.original_filename ?? 'n/a'}</p>
<p>Stored file: {selectedDataset.stored_filename ?? 'n/a'}</p>
<p>Content type: {selectedDataset.content_type ?? 'n/a'}</p>
<p>File size: {formatBytes(selectedDataset.size_bytes)}</p>
<p>SHA256: {selectedDataset.checksum_sha256 ?? 'n/a'}</p>
<p>Feature count: {selectedDatasetSummary?.feature_count ?? selectedDataset.feature_count ?? 'n/a'}</p>
<p>BBox: {formatBounds(selectedDatasetSummary?.bounds_json ?? selectedDataset.bounds_json)}</p>
{selectedDataset.dataset_type === 'raster' ? (
<div>
<p>Raster driver: {selectedRasterMetadata?.driver ?? 'n/a'}</p>
<p>Raster size: {selectedRasterMetadata ? `${selectedRasterMetadata.width} x ${selectedRasterMetadata.height}` : 'n/a'}</p>
<p>Raster checksum: {selectedRasterMetadata?.checksum_sha256 ?? 'n/a'}</p>
<p>
Profile: CRS {selectedRasterMetadata?.crs ?? 'n/a'} | bands {selectedRasterMetadata?.band_count ?? 'n/a'} | dtype {
(selectedRasterMetadata?.dtype as string[] | undefined)?.join(', ') ?? 'n/a'}
</p>
<p>Bounds: {formatRasterBounds(selectedRasterMetadata?.bounds)}</p>
<p>Resolution: {selectedRasterMetadata?.resolution ? selectedRasterMetadata.resolution.join(', ') : 'n/a'}</p>
{rasterUnavailableMessage ? <p className="error">Raster unavailable: {rasterUnavailableMessage}</p> : null}
<h3>Raster operations</h3>
<p>Available operations: inspect, stats, reproject, preview, clip by selected area, tile generation.</p>
<p>Preview: {rasterPreview?.preview.path ?? 'not generated'}</p>
<p>Preview size: {rasterPreview?.preview.width ?? 'n/a'} x {rasterPreview?.preview.height ?? 'n/a'}</p>
<button type="button" onClick={runRasterInspect}>
Inspect raster metadata
</button>
<button type="button" onClick={runRasterPreview} disabled={!selectedDatasetId}>
Generate preview
</button>
<button type="button" onClick={runRasterStats}>
Compute band statistics
</button>
{selectedRasterStats ? (
<div>
<h4>Band statistics</h4>
<p>Generated: {selectedRasterStats.generated_at ?? 'n/a'}</p>
<ul>
{selectedRasterStats.bands.map((band) => (
<li key={band.band_index}>
Band {band.band_index}: min {band.min ?? 'n/a'}, max {band.max ?? 'n/a'}, mean {band.mean ?? 'n/a'}, std {band.std ?? 'n/a'},
valid {band.valid_pixel_count}, nodata ratio {(band.nodata_ratio * 100).toFixed(2)}%, dtype {band.dtype ?? 'n/a'}
</li>
))}
</ul>
</div>
) : null}
<div>
<label>
Reproject CRS
<input
value={rasterReprojectCrs}
onChange={(event) => setRasterReprojectCrs(event.target.value)}
placeholder="EPSG:31370"
/>
</label>
<label>
Resampling
<select value={rasterReprojectResampling} onChange={(event) => setRasterReprojectResampling(event.target.value)}>
<option value="nearest">nearest</option>
<option value="bilinear">bilinear</option>
<option value="cubic">cubic</option>
</select>
</label>
<button type="button" onClick={runRasterReproject}>
Reproject raster
</button>
</div>
<div>
<label>
Clip area
<select value={selectedClipAreaId} onChange={(event) => setSelectedClipAreaId(event.target.value)}>
{areas.map((area) => (
<option key={area.id} value={area.id}>
{area.name}
</option>
))}
</select>
</label>
<button type="button" onClick={runRasterClip} disabled={areas.length === 0}>
Clip raster by area
</button>
{areas.length === 0 ? <p className="error">Create an area before raster clipping.</p> : null}
</div>
<div>
<label>
Tile size
<input
type="number"
min={1}
value={rasterTileSize}
onChange={(event) => setRasterTileSize(Number(event.target.value))}
/>
</label>
<label>
Overlap
<input
type="number"
min={0}
value={rasterTileOverlap}
onChange={(event) => setRasterTileOverlap(Number(event.target.value))}
/>
</label>
<label>
Tile basename
<input
value={rasterTileOutputName}
onChange={(event) => setRasterTileOutputName(event.target.value)}
placeholder="optional"
/>
</label>
<button type="button" onClick={runRasterTile} disabled={!isRasterTileInputValid}>
Generate tiles
</button>
{!isRasterTileInputValid ? (
<p className="error">
Tile size must be {'>'} 0 and overlap must be {'>='} 0 and smaller than tile size.
</p>
) : null}
</div>
<h4>Spectral indices</h4>
<div>
<p>Use available band indexes from the raster file (1-based).</p>
<div>
<p>NDVI</p>
<label>
Nir band
<input
type="number"
min={1}
value={ndviNirBand}
onChange={(event) => setNdviNirBand(Number(event.target.value))}
/>
</label>
<label>
Red band
<input
type="number"
min={1}
value={ndviRedBand}
onChange={(event) => setNdviRedBand(Number(event.target.value))}
/>
</label>
<button type="button" onClick={runRasterNdvi}>
Compute NDVI
</button>
</div>
<div>
<p>NDWI</p>
<label>
Nir band
<input
type="number"
min={1}
value={ndwiNirBand}
onChange={(event) => setNdwiNirBand(Number(event.target.value))}
/>
</label>
<label>
Green band
<input
type="number"
min={1}
value={ndwiGreenBand}
onChange={(event) => setNdwiGreenBand(Number(event.target.value))}
/>
</label>
<button type="button" onClick={runRasterNdwi}>
Compute NDWI
</button>
</div>
<div>
<p>NDBI</p>
<label>
Swir band
<input
type="number"
min={1}
value={ndbiSwirBand}
onChange={(event) => setNdbiSwirBand(Number(event.target.value))}
/>
</label>
<label>
Nir band
<input
type="number"
min={1}
value={ndbiNirBand}
onChange={(event) => setNdbiNirBand(Number(event.target.value))}
/>
</label>
<button type="button" onClick={runRasterNdbi}>
Compute NDBI
</button>
</div>
</div>
</div>
) : null}
{isVectorDatasetType(selectedDataset.dataset_type) ? (
<div>
<h3>Vector operations</h3>
<div>
<label>
Clip area
<select value={selectedClipAreaId} onChange={(event) => setSelectedClipAreaId(event.target.value)}>
{areas.map((area) => (
<option key={area.id} value={area.id}>
{area.name}
</option>
))}
</select>
</label>
<button type="button" onClick={runVectorClip} disabled={areas.length === 0}>
Run clip
</button>
</div>
<button type="button" onClick={runVectorBuffer}>
Run buffer (25m)
</button>
<div>
<label>
Intersect target
<select value={selectedIntersectTargetId} onChange={(event) => setSelectedIntersectTargetId(event.target.value)}>
<option value="">auto first vector</option>
{availableVectorTargets.map((target) => (
<option key={target.id} value={target.id}>
{target.name}
</option>
))}
</select>
</label>
<button type="button" onClick={() => runVectorIntersect(availableVectorTargets)}>
Run intersect
</button>
</div>
</div>
) : null}
<h3>Jobs</h3>
{jobs.length === 0 ? <p>No jobs yet.</p> : null}
<ul>
{jobs.map((job) => (
<li key={job.id}>
<div>
{job.job_type} · {job.status}
</div>
{job.result_json ? (
<pre className="job-result">{JSON.stringify(job.result_json, null, 2)}</pre>
) : null}
{job.error_message ? <div className="error">error: {job.error_message}</div> : null}
{job.result_json?.output_dataset_id ? (
<button type="button" onClick={() => pickDerivedDataset(String(job.result_json?.output_dataset_id))}>
open derived dataset
</button>
) : null}
</li>
))}
</ul>
</div>
) : null}
{loadingDatasetDetails ? <p>Loading dataset details...</p> : null}
{datasetDetailError ? <p className="error">Dataset detail error: {datasetDetailError}</p> : null}
</section>
<DatasetDetailPanel
areas={areas}
availableVectorTargets={availableVectorTargets}
selectedDatasetId={selectedDatasetId}
selectedDataset={selectedDataset}
selectedDatasetSummary={selectedDatasetSummary}
selectedRasterMetadata={selectedRasterMetadata}
selectedRasterStats={selectedRasterStats}
rasterPreview={rasterPreview}
rasterUnavailableMessage={rasterUnavailableMessage}
selectedClipAreaId={selectedClipAreaId}
selectedIntersectTargetId={selectedIntersectTargetId}
rasterTileSize={rasterTileSize}
rasterTileOverlap={rasterTileOverlap}
rasterTileOutputName={rasterTileOutputName}
rasterReprojectCrs={rasterReprojectCrs}
rasterReprojectResampling={rasterReprojectResampling}
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}
/>
<section>
<h2>Map workspace</h2>
@@ -0,0 +1,252 @@
import type {
AreaRead,
DatasetCreateResponse,
JobRead,
RasterMetadataResponse,
RasterPreviewResponse,
RasterStatsResponse,
VectorSummary,
} from '../../types'
import { RasterControls } from './RasterControls'
import { VectorControls } from './VectorControls'
interface DatasetDetailPanelProps {
areas: AreaRead[]
availableVectorTargets: DatasetCreateResponse[]
selectedDatasetId: string | null
selectedDataset: DatasetCreateResponse | null
selectedDatasetSummary: VectorSummary | null
selectedRasterMetadata: RasterMetadataResponse | null
selectedRasterStats: RasterStatsResponse | null
rasterPreview: RasterPreviewResponse | null
rasterUnavailableMessage: string | null
selectedClipAreaId: string
selectedIntersectTargetId: string
rasterTileSize: number
rasterTileOverlap: number
rasterTileOutputName: string
rasterReprojectCrs: string
rasterReprojectResampling: string
ndviNirBand: number
ndviRedBand: number
ndwiGreenBand: number
ndwiNirBand: number
ndbiSwirBand: number
ndbiNirBand: number
isRasterTileInputValid: boolean
jobs: JobRead[]
loadingDatasetDetails: boolean
datasetDetailError: string | null
isVectorDatasetType: (datasetType: string) => boolean
onSetSelectedClipAreaId: (value: string) => void
onSetSelectedIntersectTargetId: (value: string) => void
onSetRasterTileSize: (value: number) => void
onSetRasterTileOverlap: (value: number) => void
onSetRasterTileOutputName: (value: string) => void
onSetRasterReprojectCrs: (value: string) => void
onSetRasterReprojectResampling: (value: string) => void
onSetNdviNirBand: (value: number) => void
onSetNdviRedBand: (value: number) => void
onSetNdwiGreenBand: (value: number) => void
onSetNdwiNirBand: (value: number) => void
onSetNdbiSwirBand: (value: number) => void
onSetNdbiNirBand: (value: number) => void
onRunRasterInspect: () => void
onRunRasterPreview: () => void
onRunRasterStats: () => void
onRunRasterReproject: () => void
onRunRasterClip: () => void
onRunRasterTile: () => void
onRunRasterNdvi: () => void
onRunRasterNdwi: () => void
onRunRasterNdbi: () => void
onRunVectorClip: () => void
onRunVectorBuffer: () => void
onRunVectorIntersect: () => void
onPickDerivedDataset: (datasetId: string) => void
}
function formatBytes(value: number | null | undefined): string {
if (!value && value !== 0) {
return 'n/a'
}
const units = ['B', 'KB', 'MB', 'GB']
let size = value
let index = 0
while (size >= 1024 && index < units.length - 1) {
size /= 1024
index += 1
}
return `${size.toFixed(1)} ${units[index]}`
}
function formatBounds(bounds: Record<string, number> | null | undefined): string {
if (!bounds) {
return 'n/a'
}
const keys = ['min_x', 'min_y', 'max_x', 'max_y']
if (!keys.every((key) => key in bounds)) {
return 'n/a'
}
return `${bounds.min_x?.toFixed(4)}, ${bounds.min_y?.toFixed(4)} -> ${bounds.max_x?.toFixed(4)}, ${bounds.max_y?.toFixed(4)}`
}
export function DatasetDetailPanel({
areas,
availableVectorTargets,
selectedDatasetId,
selectedDataset,
selectedDatasetSummary,
selectedRasterMetadata,
selectedRasterStats,
rasterPreview,
rasterUnavailableMessage,
selectedClipAreaId,
selectedIntersectTargetId,
rasterTileSize,
rasterTileOverlap,
rasterTileOutputName,
rasterReprojectCrs,
rasterReprojectResampling,
ndviNirBand,
ndviRedBand,
ndwiGreenBand,
ndwiNirBand,
ndbiSwirBand,
ndbiNirBand,
isRasterTileInputValid,
jobs,
loadingDatasetDetails,
datasetDetailError,
isVectorDatasetType,
onSetSelectedClipAreaId,
onSetSelectedIntersectTargetId,
onSetRasterTileSize,
onSetRasterTileOverlap,
onSetRasterTileOutputName,
onSetRasterReprojectCrs,
onSetRasterReprojectResampling,
onSetNdviNirBand,
onSetNdviRedBand,
onSetNdwiGreenBand,
onSetNdwiNirBand,
onSetNdbiSwirBand,
onSetNdbiNirBand,
onRunRasterInspect,
onRunRasterPreview,
onRunRasterStats,
onRunRasterReproject,
onRunRasterClip,
onRunRasterTile,
onRunRasterNdvi,
onRunRasterNdwi,
onRunRasterNdbi,
onRunVectorClip,
onRunVectorBuffer,
onRunVectorIntersect,
onPickDerivedDataset,
}: DatasetDetailPanelProps) {
return (
<section>
<h2>Dataset details</h2>
{selectedDatasetId ? <p>Selected dataset: {selectedDatasetId}</p> : <p>No dataset selected</p>}
{selectedDataset ? (
<div>
<p>
<strong>{selectedDataset.name}</strong>
</p>
<p>Type: {selectedDataset.dataset_type}</p>
<p>Status: {selectedDataset.status}</p>
<p>Original file: {selectedDataset.original_filename ?? 'n/a'}</p>
<p>Stored file: {selectedDataset.stored_filename ?? 'n/a'}</p>
<p>Content type: {selectedDataset.content_type ?? 'n/a'}</p>
<p>File size: {formatBytes(selectedDataset.size_bytes)}</p>
<p>SHA256: {selectedDataset.checksum_sha256 ?? 'n/a'}</p>
<p>Feature count: {selectedDatasetSummary?.feature_count ?? selectedDataset.feature_count ?? 'n/a'}</p>
<p>BBox: {formatBounds(selectedDatasetSummary?.bounds_json ?? selectedDataset.bounds_json)}</p>
{selectedDataset.dataset_type === 'raster' ? (
<RasterControls
areas={areas}
selectedDatasetId={selectedDatasetId}
selectedRasterMetadata={selectedRasterMetadata}
selectedRasterStats={selectedRasterStats}
rasterPreview={rasterPreview}
rasterUnavailableMessage={rasterUnavailableMessage}
selectedClipAreaId={selectedClipAreaId}
rasterTileSize={rasterTileSize}
rasterTileOverlap={rasterTileOverlap}
rasterTileOutputName={rasterTileOutputName}
rasterReprojectCrs={rasterReprojectCrs}
rasterReprojectResampling={rasterReprojectResampling}
ndviNirBand={ndviNirBand}
ndviRedBand={ndviRedBand}
ndwiGreenBand={ndwiGreenBand}
ndwiNirBand={ndwiNirBand}
ndbiSwirBand={ndbiSwirBand}
ndbiNirBand={ndbiNirBand}
isRasterTileInputValid={isRasterTileInputValid}
onSetSelectedClipAreaId={onSetSelectedClipAreaId}
onSetRasterTileSize={onSetRasterTileSize}
onSetRasterTileOverlap={onSetRasterTileOverlap}
onSetRasterTileOutputName={onSetRasterTileOutputName}
onSetRasterReprojectCrs={onSetRasterReprojectCrs}
onSetRasterReprojectResampling={onSetRasterReprojectResampling}
onSetNdviNirBand={onSetNdviNirBand}
onSetNdviRedBand={onSetNdviRedBand}
onSetNdwiGreenBand={onSetNdwiGreenBand}
onSetNdwiNirBand={onSetNdwiNirBand}
onSetNdbiSwirBand={onSetNdbiSwirBand}
onSetNdbiNirBand={onSetNdbiNirBand}
onRunRasterInspect={onRunRasterInspect}
onRunRasterPreview={onRunRasterPreview}
onRunRasterStats={onRunRasterStats}
onRunRasterReproject={onRunRasterReproject}
onRunRasterClip={onRunRasterClip}
onRunRasterTile={onRunRasterTile}
onRunRasterNdvi={onRunRasterNdvi}
onRunRasterNdwi={onRunRasterNdwi}
onRunRasterNdbi={onRunRasterNdbi}
/>
) : null}
{isVectorDatasetType(selectedDataset.dataset_type) ? (
<VectorControls
areas={areas}
availableVectorTargets={availableVectorTargets}
selectedClipAreaId={selectedClipAreaId}
selectedIntersectTargetId={selectedIntersectTargetId}
onSetSelectedClipAreaId={onSetSelectedClipAreaId}
onSetSelectedIntersectTargetId={onSetSelectedIntersectTargetId}
onRunVectorClip={onRunVectorClip}
onRunVectorBuffer={onRunVectorBuffer}
onRunVectorIntersect={onRunVectorIntersect}
/>
) : null}
<h3>Jobs</h3>
{jobs.length === 0 ? <p>No jobs yet.</p> : null}
<ul>
{jobs.map((job) => (
<li key={job.id}>
<div>
{job.job_type} - {job.status}
</div>
{job.result_json ? (
<pre className="job-result">{JSON.stringify(job.result_json, null, 2)}</pre>
) : null}
{job.error_message ? <div className="error">error: {job.error_message}</div> : null}
{job.result_json?.output_dataset_id ? (
<button type="button" onClick={() => onPickDerivedDataset(String(job.result_json?.output_dataset_id))}>
open derived dataset
</button>
) : null}
</li>
))}
</ul>
</div>
) : null}
{loadingDatasetDetails ? <p>Loading dataset details...</p> : null}
{datasetDetailError ? <p className="error">Dataset detail error: {datasetDetailError}</p> : null}
</section>
)
}
@@ -0,0 +1,129 @@
import type { Dispatch, FormEvent, SetStateAction } from 'react'
import type { AreaRead, DatasetCreateResponse } from '../../types'
interface DatasetFormState {
datasetType: string
source: string
datasetRole: string
sourceName: string
referenceLayerName: string
sourceMetadataJson: string
provenanceMetadataJson: string
areaId: string
file: File | null
}
interface DatasetPanelProps {
selectedProjectId: string | null
areas: AreaRead[]
datasets: DatasetCreateResponse[]
datasetForm: DatasetFormState
loadingDatasets: boolean
onDatasetFormChange: Dispatch<SetStateAction<DatasetFormState>>
onUploadDataset: (event: FormEvent) => void
onLoadDatasetDetails: (projectId: string, dataset: DatasetCreateResponse) => void
onRefreshMetadata: (datasetId: string) => void
}
function formatBytes(value: number | null | undefined): string {
if (!value && value !== 0) {
return 'n/a'
}
const units = ['B', 'KB', 'MB', 'GB']
let size = value
let index = 0
while (size >= 1024 && index < units.length - 1) {
size /= 1024
index += 1
}
return `${size.toFixed(1)} ${units[index]}`
}
function formatBounds(bounds: Record<string, number> | null | undefined): string {
if (!bounds) {
return 'n/a'
}
const keys = ['min_x', 'min_y', 'max_x', 'max_y']
if (!keys.every((key) => key in bounds)) {
return 'n/a'
}
return `${bounds.min_x?.toFixed(4)}, ${bounds.min_y?.toFixed(4)} -> ${bounds.max_x?.toFixed(4)}, ${bounds.max_y?.toFixed(4)}`
}
export function DatasetPanel({
selectedProjectId,
areas,
datasets,
datasetForm,
loadingDatasets,
onDatasetFormChange,
onUploadDataset,
onLoadDatasetDetails,
onRefreshMetadata,
}: DatasetPanelProps) {
return (
<section>
<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}>
Upload dataset
</button>
</form>
{loadingDatasets ? <p>Loading datasets...</p> : null}
{datasets.length === 0 ? <p>No datasets yet</p> : null}
<ul>
{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)}>
Select / details
</button>
<button
type="button"
onClick={() => onRefreshMetadata(dataset.id)}
disabled={dataset.dataset_type === 'raster'}
>
Refresh metadata
</button>
</li>
))}
</ul>
</section>
)
}
@@ -0,0 +1,256 @@
import type { AreaRead, RasterMetadataResponse, RasterPreviewResponse, RasterStatsResponse } from '../../types'
interface RasterControlsProps {
areas: AreaRead[]
selectedDatasetId: string | null
selectedRasterMetadata: RasterMetadataResponse | null
selectedRasterStats: RasterStatsResponse | null
rasterPreview: RasterPreviewResponse | null
rasterUnavailableMessage: string | null
selectedClipAreaId: string
rasterTileSize: number
rasterTileOverlap: number
rasterTileOutputName: string
rasterReprojectCrs: string
rasterReprojectResampling: string
ndviNirBand: number
ndviRedBand: number
ndwiGreenBand: number
ndwiNirBand: number
ndbiSwirBand: number
ndbiNirBand: number
isRasterTileInputValid: boolean
onSetSelectedClipAreaId: (value: string) => void
onSetRasterTileSize: (value: number) => void
onSetRasterTileOverlap: (value: number) => void
onSetRasterTileOutputName: (value: string) => void
onSetRasterReprojectCrs: (value: string) => void
onSetRasterReprojectResampling: (value: string) => void
onSetNdviNirBand: (value: number) => void
onSetNdviRedBand: (value: number) => void
onSetNdwiGreenBand: (value: number) => void
onSetNdwiNirBand: (value: number) => void
onSetNdbiSwirBand: (value: number) => void
onSetNdbiNirBand: (value: number) => void
onRunRasterInspect: () => void
onRunRasterPreview: () => void
onRunRasterStats: () => void
onRunRasterReproject: () => void
onRunRasterClip: () => void
onRunRasterTile: () => void
onRunRasterNdvi: () => void
onRunRasterNdwi: () => void
onRunRasterNdbi: () => void
}
function formatRasterBounds(bounds: number[] | undefined | null): string {
if (!bounds || bounds.length < 4) {
return 'n/a'
}
const [minX, minY, maxX, maxY] = bounds
return `${minX.toFixed(4)}, ${minY.toFixed(4)} -> ${maxX.toFixed(4)}, ${maxY.toFixed(4)}`
}
export function RasterControls({
areas,
selectedDatasetId,
selectedRasterMetadata,
selectedRasterStats,
rasterPreview,
rasterUnavailableMessage,
selectedClipAreaId,
rasterTileSize,
rasterTileOverlap,
rasterTileOutputName,
rasterReprojectCrs,
rasterReprojectResampling,
ndviNirBand,
ndviRedBand,
ndwiGreenBand,
ndwiNirBand,
ndbiSwirBand,
ndbiNirBand,
isRasterTileInputValid,
onSetSelectedClipAreaId,
onSetRasterTileSize,
onSetRasterTileOverlap,
onSetRasterTileOutputName,
onSetRasterReprojectCrs,
onSetRasterReprojectResampling,
onSetNdviNirBand,
onSetNdviRedBand,
onSetNdwiGreenBand,
onSetNdwiNirBand,
onSetNdbiSwirBand,
onSetNdbiNirBand,
onRunRasterInspect,
onRunRasterPreview,
onRunRasterStats,
onRunRasterReproject,
onRunRasterClip,
onRunRasterTile,
onRunRasterNdvi,
onRunRasterNdwi,
onRunRasterNdbi,
}: RasterControlsProps) {
return (
<div>
<p>Raster driver: {selectedRasterMetadata?.driver ?? 'n/a'}</p>
<p>Raster size: {selectedRasterMetadata ? `${selectedRasterMetadata.width} x ${selectedRasterMetadata.height}` : 'n/a'}</p>
<p>Raster checksum: {selectedRasterMetadata?.checksum_sha256 ?? 'n/a'}</p>
<p>
Profile: CRS {selectedRasterMetadata?.crs ?? 'n/a'} | bands {selectedRasterMetadata?.band_count ?? 'n/a'} | dtype {
(selectedRasterMetadata?.dtype as string[] | undefined)?.join(', ') ?? 'n/a'}
</p>
<p>Bounds: {formatRasterBounds(selectedRasterMetadata?.bounds)}</p>
<p>Resolution: {selectedRasterMetadata?.resolution ? selectedRasterMetadata.resolution.join(', ') : 'n/a'}</p>
{rasterUnavailableMessage ? <p className="error">Raster unavailable: {rasterUnavailableMessage}</p> : null}
<h3>Raster operations</h3>
<p>Available operations: inspect, stats, reproject, preview, clip by selected area, tile generation.</p>
<p>Preview: {rasterPreview?.preview.path ?? 'not generated'}</p>
<p>Preview size: {rasterPreview?.preview.width ?? 'n/a'} x {rasterPreview?.preview.height ?? 'n/a'}</p>
<button type="button" onClick={onRunRasterInspect}>
Inspect raster metadata
</button>
<button type="button" onClick={onRunRasterPreview} disabled={!selectedDatasetId}>
Generate preview
</button>
<button type="button" onClick={onRunRasterStats}>
Compute band statistics
</button>
{selectedRasterStats ? (
<div>
<h4>Band statistics</h4>
<p>Generated: {selectedRasterStats.generated_at ?? 'n/a'}</p>
<ul>
{selectedRasterStats.bands.map((band) => (
<li key={band.band_index}>
Band {band.band_index}: min {band.min ?? 'n/a'}, max {band.max ?? 'n/a'}, mean {band.mean ?? 'n/a'}, std {band.std ?? 'n/a'},
valid {band.valid_pixel_count}, nodata ratio {(band.nodata_ratio * 100).toFixed(2)}%, dtype {band.dtype ?? 'n/a'}
</li>
))}
</ul>
</div>
) : null}
<div>
<label>
Reproject CRS
<input
value={rasterReprojectCrs}
onChange={(event) => onSetRasterReprojectCrs(event.target.value)}
placeholder="EPSG:31370"
/>
</label>
<label>
Resampling
<select value={rasterReprojectResampling} onChange={(event) => onSetRasterReprojectResampling(event.target.value)}>
<option value="nearest">nearest</option>
<option value="bilinear">bilinear</option>
<option value="cubic">cubic</option>
</select>
</label>
<button type="button" onClick={onRunRasterReproject}>
Reproject raster
</button>
</div>
<div>
<label>
Clip area
<select value={selectedClipAreaId} onChange={(event) => onSetSelectedClipAreaId(event.target.value)}>
{areas.map((area) => (
<option key={area.id} value={area.id}>
{area.name}
</option>
))}
</select>
</label>
<button type="button" onClick={onRunRasterClip} disabled={areas.length === 0}>
Clip raster by area
</button>
{areas.length === 0 ? <p className="error">Create an area before raster clipping.</p> : null}
</div>
<div>
<label>
Tile size
<input
type="number"
min={1}
value={rasterTileSize}
onChange={(event) => onSetRasterTileSize(Number(event.target.value))}
/>
</label>
<label>
Overlap
<input
type="number"
min={0}
value={rasterTileOverlap}
onChange={(event) => onSetRasterTileOverlap(Number(event.target.value))}
/>
</label>
<label>
Tile basename
<input
value={rasterTileOutputName}
onChange={(event) => onSetRasterTileOutputName(event.target.value)}
placeholder="optional"
/>
</label>
<button type="button" onClick={onRunRasterTile} disabled={!isRasterTileInputValid}>
Generate tiles
</button>
{!isRasterTileInputValid ? (
<p className="error">
Tile size must be {'>'} 0 and overlap must be {'>='} 0 and smaller than tile size.
</p>
) : null}
</div>
<h4>Spectral indices</h4>
<div>
<p>Use available band indexes from the raster file (1-based).</p>
<div>
<p>NDVI</p>
<label>
Nir band
<input type="number" min={1} value={ndviNirBand} onChange={(event) => onSetNdviNirBand(Number(event.target.value))} />
</label>
<label>
Red band
<input type="number" min={1} value={ndviRedBand} onChange={(event) => onSetNdviRedBand(Number(event.target.value))} />
</label>
<button type="button" onClick={onRunRasterNdvi}>
Compute NDVI
</button>
</div>
<div>
<p>NDWI</p>
<label>
Nir band
<input type="number" min={1} value={ndwiNirBand} onChange={(event) => onSetNdwiNirBand(Number(event.target.value))} />
</label>
<label>
Green band
<input type="number" min={1} value={ndwiGreenBand} onChange={(event) => onSetNdwiGreenBand(Number(event.target.value))} />
</label>
<button type="button" onClick={onRunRasterNdwi}>
Compute NDWI
</button>
</div>
<div>
<p>NDBI</p>
<label>
Swir band
<input type="number" min={1} value={ndbiSwirBand} onChange={(event) => onSetNdbiSwirBand(Number(event.target.value))} />
</label>
<label>
Nir band
<input type="number" min={1} value={ndbiNirBand} onChange={(event) => onSetNdbiNirBand(Number(event.target.value))} />
</label>
<button type="button" onClick={onRunRasterNdbi}>
Compute NDBI
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,65 @@
import type { AreaRead, DatasetCreateResponse } from '../../types'
interface VectorControlsProps {
areas: AreaRead[]
availableVectorTargets: DatasetCreateResponse[]
selectedClipAreaId: string
selectedIntersectTargetId: string
onSetSelectedClipAreaId: (value: string) => void
onSetSelectedIntersectTargetId: (value: string) => void
onRunVectorClip: () => void
onRunVectorBuffer: () => void
onRunVectorIntersect: () => void
}
export function VectorControls({
areas,
availableVectorTargets,
selectedClipAreaId,
selectedIntersectTargetId,
onSetSelectedClipAreaId,
onSetSelectedIntersectTargetId,
onRunVectorClip,
onRunVectorBuffer,
onRunVectorIntersect,
}: VectorControlsProps) {
return (
<div>
<h3>Vector operations</h3>
<div>
<label>
Clip area
<select value={selectedClipAreaId} onChange={(event) => onSetSelectedClipAreaId(event.target.value)}>
{areas.map((area) => (
<option key={area.id} value={area.id}>
{area.name}
</option>
))}
</select>
</label>
<button type="button" onClick={onRunVectorClip} disabled={areas.length === 0}>
Run clip
</button>
</div>
<button type="button" onClick={onRunVectorBuffer}>
Run buffer (25m)
</button>
<div>
<label>
Intersect target
<select value={selectedIntersectTargetId} onChange={(event) => onSetSelectedIntersectTargetId(event.target.value)}>
<option value="">auto first vector</option>
{availableVectorTargets.map((target) => (
<option key={target.id} value={target.id}>
{target.name}
</option>
))}
</select>
</label>
<button type="button" onClick={onRunVectorIntersect}>
Run intersect
</button>
</div>
</div>
)
}