Extract dataset presentational components
This commit is contained in:
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user