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
+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>