Initial GeoIntel V1 foundation
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-06-16 23:36:32 +02:00
commit 6ea3586a3e
605 changed files with 45284 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
__pycache__
*.pyc
.pytest_cache
.vite
.env
View File
+16
View File
@@ -0,0 +1,16 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:1.27-alpine AS runtime
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+189
View File
@@ -0,0 +1,189 @@
# GeoIntel Frontend (Sprint 4)
React + TypeScript + MapLibre foundation for project/area/dataset workflow.
## Scope implemented
- API client layer (`src/services/api`)
- Project and area list/create flows
- Vector and raster dataset upload + metadata display
- MapLibre map with uploaded feature preview
## Sprint 2 additions
- Dataset manager details now shows:
- dataset type
- processing status
- file size
- vector feature count
- vector bounds
- file metadata (original/stored filename, MIME type, SHA256)
- raster metadata preview when available
- Vector inspection and raster metadata endpoint support in API client
- Readiness indicator for uploaded datasets
## Sprint 3 additions
- Dataset detail panel now includes:
- available operations
- operation run actions (`clip`, `buffer`, `intersect`)
- linked job list and status details
- derived dataset navigation from job output
- Raster operation callouts for unavailable processing states
- Vector operation summaries integrated in selected dataset view
## Sprint 4 additions
- Dataset detail panel now includes raster-specific runtime metadata:
- driver, dimensions, band count, bounds, CRS
- storage details (`size_bytes`, `checksum_sha256`)
- Added action buttons for raster operations:
- regenerate/inspect metadata
- generate preview
- generate tiles
- clip by selected area
- Added clear unavailable states for raster ops when backend returns `RASTER_PROCESSING_UNAVAILABLE`
- Added operation/job result visibility for raster runs with derived dataset navigation
## Sprint 5 additions
- Added raster band statistics display in the dataset detail panel (min/max/mean/std/nodata ratio/valid pixel count).
- Added raster reproject workflow in UI (target CRS + resampling) with visible errors for invalid CRS/dependency gaps.
- Added stronger raster tile/clip result context with consistent job status display and derived output links where produced.
- Added tile manifest-aware controls for raster tile generation parameters.
## Sprint 6 additions
- Added spectral index controls in dataset detail panel:
- NDVI with NIR/Red band inputs
- NDWI with NIR/Green band inputs
- NDBI with SWIR/NIR band inputs
- Added job-driven execution for local spectral index operations and result dataset linking.
- Added clear error surfacing for dependency-unavailable index execution (`RASTER_PROCESSING_UNAVAILABLE`).
- Added CRS/bounds/resolution context visibility for raster index source inspection.
## Sprint 7B additions
- Added a lightweight Provider Capabilities panel.
- The panel lists GRB, OSM, manual and fixture provider status, configured state, authority level, supported layers, supported geometry types, query modes and limitation messages.
- GRB and OSM are shown as `not_configured`; the UI does not expose a live import/download action for them.
- Existing dataset, reference and QA/QC UI remains unchanged.
## Sprint 8 additions
- Added a minimal Detection Lab panel.
- The panel lists detection model capabilities and clearly shows configured/not_configured status.
- Users can select a raster dataset, choose a confidence threshold and request a detection run.
- Unavailable model responses are shown honestly with the backend error code/message.
- The UI does not claim real YOLO/PyTorch inference is enabled.
## Sprint 8B additions
- Detection Lab now exposes the `yolo-configured` capability reported by the backend.
- When `yolo-configured` is selected, users can provide an existing raster tile manifest path.
- The UI still does not download models or create fake detections; backend status and error codes remain the source of truth.
## Sprint 8C additions
- Detection Lab now lists detection analysis runs and persisted detections.
- Users can filter detections by class and minimum confidence.
- Selected detection GeoJSON is rendered on the existing MapLibre workbench map.
- Detection QA compares a selected detection run against a reference dataset and displays persisted QA metrics.
- No segmentation UI is introduced in Sprint 8C.
## Sprint 9 additions
- Added a minimal Segmentation Lab panel.
- The panel lists segmentation model capabilities and clearly distinguishes placeholders, fixture/demo mode, SAM placeholder and YOLO-seg placeholder states.
- Users can select a raster dataset, select a configured segmentation model, list segmentation runs and load persisted segmentation results.
- Segmentation results display class, confidence, area, model, tile and mask path fields.
- Selected segmentation GeoJSON is rendered through the existing MapLibre workbench map.
- Segmentation QA compares a selected segmentation run against a reference dataset and displays persisted QA metrics.
- Real SAM/YOLO-seg inference, model downloads and new AI dependencies are not introduced in Sprint 9.
## Sprint 10 maintainability updates
- Split large workbench sections out of `src/App.tsx` without changing UI behavior:
- `src/components/project/ProjectPanel.tsx`
- `src/components/project/AreaPanel.tsx`
- `src/components/providers/ProviderPanel.tsx`
- `src/components/detection/DetectionLab.tsx`
- `src/components/segmentation/SegmentationLab.tsx`
- `App.tsx` still owns shared state orchestration and API calls; extracted components receive the same state and callbacks as props.
- Existing MapLibre overlay behavior, dataset/reference flows, Detection Lab flows and Segmentation Lab flows are unchanged.
## Sprint 15 additions
- Added a Projects panel action to load the explicit offline demo workflow.
- The action calls `POST /api/v1/demo/workflow` and refreshes projects, areas, datasets and run lists.
- Demo data is labelled fixture/demo data and does not represent live GRB/OSM data or AI inference.
## Sprint 16 additions
- Added a QA/QC Results panel that lists persisted project quality checks and metric rows.
- The panel calls `GET /api/v1/projects/{project_id}/quality-checks`.
- Demo workflow loading and QA actions refresh the persisted QA/QC result list.
## Sprint 17 additions
- Added an Export Center panel.
- The panel can create persisted exports for:
- project metadata JSON
- project report HTML
- selected vector dataset GeoJSON
- selected detection run GeoJSON
- selected segmentation run GeoJSON
- Export records are listed from `GET /api/v1/exports/projects/{project_id}/exports`.
- JSON artifact preview uses `GET /api/v1/exports/{export_id}/content`.
- Artifact downloads use `GET /api/v1/exports/{export_id}/download`.
- The UI does not introduce live provider downloads, a report designer or new AI behavior.
- The HTML report is a lightweight artifact built from persisted project, dataset, QA/QC summary and export history state; it is not a PDF/report designer.
## Release hardening updates
- Production builds split application code, React vendor code and MapLibre vendor code into separate chunks.
- The MapLibre chunk is intentionally larger than generic app chunks because it contains the GIS map runtime; the Vite warning threshold is set to keep this known vendor dependency visible without warning on every release build.
## Raster dependency visibility
Raster metadata and raster ops may remain unavailable when backend raster stack is missing. In that case:
- raster uploads are still stored and listed
- status becomes `failed`
- backend returns explicit `RASTER_PROCESSING_UNAVAILABLE` responses for metadata/preview/clip/tile
## Run locally
### Prerequisites
- Node.js 18+
### Install dependencies
```bash
cd frontend
npm install
```
### Run locally
```bash
npm run start
```
### Type check and build
```bash
npm run typecheck
npm run build
```
### Dockerized frontend
```bash
docker compose up --build frontend
```
When using the repository Docker Compose stack, the frontend is published on host port `1202`: `http://localhost:1202`.
The frontend API client uses same-origin requests by default. In Docker Compose, nginx serves the built frontend and reverse proxies `/api` and `/health` to the backend service, so browser clients on LAN hosts do not call their own `localhost:8000`.
## Useful repository scripts
- `bash scripts/frontend_install.sh`
- `bash scripts/frontend_typecheck.sh`
- `bash scripts/frontend_build.sh`
- `bash scripts/frontend_dev.sh`
## Key docs
- `docs/40-build-launch/SPRINT_1_SCOPE_FREEZE.md`
- `docs/API_CONTRACTS.md`
- `docs/REPOSITORY_CONVENTIONS.md`
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GeoIntel Kempen</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /health {
proxy_pass http://backend:8000/health;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+2109
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "geointel-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"start": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"maplibre-gl": "^4.7.1"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.3.2",
"typescript": "^5.5.4",
"vite": "^5.4.1"
}
}
View File
+1763
View File
File diff suppressed because it is too large Load Diff
View File
View File
+116
View File
@@ -0,0 +1,116 @@
import { useEffect, useRef } from 'react'
import maplibregl from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
interface GeoMapProps {
data: GeoJSON.FeatureCollection | null
}
function collectCoordinates(featureCollection: GeoJSON.FeatureCollection): maplibregl.LngLatBoundsLike | null {
const coordinates: [number, number][] = []
const walk = (coords: unknown) => {
if (!Array.isArray(coords)) {
return
}
if (coords.length === 2 && typeof coords[0] === 'number' && typeof coords[1] === 'number') {
coordinates.push([coords[0], coords[1]])
return
}
for (const item of coords) {
walk(item)
}
}
for (const feature of featureCollection.features) {
const geometry = feature.geometry as any
if (geometry && geometry.coordinates) {
walk(geometry.coordinates)
}
}
if (coordinates.length === 0) {
return null
}
const xs = coordinates.map((point) => point[0])
const ys = coordinates.map((point) => point[1])
return [
[Math.min(...xs), Math.min(...ys)],
[Math.max(...xs), Math.max(...ys)],
]
}
function GeoMap({ data }: GeoMapProps): JSX.Element {
const containerRef = useRef<HTMLDivElement | null>(null)
const mapRef = useRef<maplibregl.Map | null>(null)
useEffect(() => {
if (!containerRef.current || mapRef.current) {
return
}
const map = new maplibregl.Map({
container: containerRef.current,
style: import.meta.env.VITE_MAP_STYLE_URL || 'https://demotiles.maplibre.org/style.json',
center: [5.3, 51.3],
zoom: 9,
})
map.addControl(new maplibregl.NavigationControl(), 'top-right')
mapRef.current = map
return () => {
map.remove()
mapRef.current = null
}
}, [])
useEffect(() => {
const map = mapRef.current
if (!map) {
return
}
if (map.getSource('dataset')) {
if (data) {
;(map.getSource('dataset') as maplibregl.GeoJSONSource).setData(data)
} else {
if (map.getLayer('dataset-fill')) {
map.removeLayer('dataset-fill')
}
if (map.getLayer('dataset-line')) {
map.removeLayer('dataset-line')
}
map.removeSource('dataset')
return
}
} else if (data) {
map.addSource('dataset', { type: 'geojson', data })
map.addLayer({
id: 'dataset-fill',
type: 'fill',
source: 'dataset',
paint: { 'fill-color': '#f97316', 'fill-opacity': 0.4 },
})
map.addLayer({
id: 'dataset-line',
type: 'line',
source: 'dataset',
paint: { 'line-color': '#ea580c', 'line-width': 2 },
})
}
if (data) {
const collection = data
if (collection.type === 'FeatureCollection' && collection.features.length > 0) {
const bounds = collectCoordinates(collection)
if (bounds) {
map.fitBounds(bounds, { padding: 40 })
}
}
}
}, [data])
return <div className="map-container" ref={containerRef} />
}
export default GeoMap
@@ -0,0 +1,241 @@
import type {
DatasetCreateResponse,
DetectionModelCapability,
DetectionQaResult,
DetectionRead,
DetectionRunRead,
DetectionRunResponse,
} from '../../types'
interface DetectionLabProps {
detectionModels: DetectionModelCapability[]
loadingDetectionModels: boolean
detectionModelError: string | null
selectedDetectionDatasetId: string
selectedDetectionModelId: string
detectionTileManifestPath: string
detectionConfidenceThreshold: number
runningDetection: boolean
detectionRunResult: DetectionRunResponse | null
detectionRunError: string | null
detectionRuns: DetectionRunRead[]
selectedDetectionRunId: string
detectionItems: DetectionRead[]
detectionClassFilter: string
detectionMinConfidenceFilter: number
loadingDetectionResults: boolean
detectionReferenceDatasetId: string
detectionQaResult: DetectionQaResult | null
detectionQaError: string | null
runningDetectionQa: boolean
selectedProjectId: string | null
rasterDatasets: DatasetCreateResponse[]
referenceDatasets: DatasetCreateResponse[]
onLoadModels: () => void
onSelectDataset: (datasetId: string) => void
onSelectModel: (modelId: string) => void
onSetConfidenceThreshold: (value: number) => void
onSetTileManifestPath: (value: string) => void
onRunDetection: () => void
onLoadRuns: () => void
onSelectRun: (runId: string) => void
onSetClassFilter: (value: string) => void
onSetMinConfidenceFilter: (value: number) => void
onLoadResults: () => void
onSelectReferenceDataset: (datasetId: string) => void
onRunQa: () => void
}
export function DetectionLab({
detectionModels,
loadingDetectionModels,
detectionModelError,
selectedDetectionDatasetId,
selectedDetectionModelId,
detectionTileManifestPath,
detectionConfidenceThreshold,
runningDetection,
detectionRunResult,
detectionRunError,
detectionRuns,
selectedDetectionRunId,
detectionItems,
detectionClassFilter,
detectionMinConfidenceFilter,
loadingDetectionResults,
detectionReferenceDatasetId,
detectionQaResult,
detectionQaError,
runningDetectionQa,
selectedProjectId,
rasterDatasets,
referenceDatasets,
onLoadModels,
onSelectDataset,
onSelectModel,
onSetConfidenceThreshold,
onSetTileManifestPath,
onRunDetection,
onLoadRuns,
onSelectRun,
onSetClassFilter,
onSetMinConfidenceFilter,
onLoadResults,
onSelectReferenceDataset,
onRunQa,
}: DetectionLabProps): JSX.Element {
return (
<section>
<h2>Detection Lab</h2>
<button type="button" onClick={onLoadModels} disabled={loadingDetectionModels}>
Refresh detection models
</button>
{loadingDetectionModels ? <p>Loading detection models...</p> : null}
{detectionModelError ? <p className="error">{detectionModelError}</p> : null}
{detectionModels.length === 0 && !loadingDetectionModels ? <p>No detection models reported by backend</p> : null}
<ul>
{detectionModels.map((model) => (
<li key={model.model_id}>
<strong>{model.display_name}</strong>
<div>model: {model.model_id}</div>
<div>framework: {model.framework}</div>
<div>task: {model.task_type}</div>
<div>status: {model.status}</div>
<div>configured: {model.configured ? 'yes' : 'no'}</div>
<div>classes: {model.supported_classes.join(', ')}</div>
<div>limitation: {model.limitation_message}</div>
</li>
))}
</ul>
<div>
<select value={selectedDetectionDatasetId} onChange={(event) => onSelectDataset(event.target.value)}>
<option value="">Select raster dataset</option>
{rasterDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{dataset.name}
</option>
))}
</select>
<select value={selectedDetectionModelId} onChange={(event) => onSelectModel(event.target.value)}>
{detectionModels.map((model) => (
<option key={model.model_id} value={model.model_id}>
{model.display_name}
</option>
))}
</select>
<input
type="number"
min="0"
max="1"
step="0.05"
value={detectionConfidenceThreshold}
onChange={(event) => onSetConfidenceThreshold(Number(event.target.value))}
/>
{selectedDetectionModelId === 'yolo-configured' ? (
<input
type="text"
placeholder="Raster tile manifest path"
value={detectionTileManifestPath}
onChange={(event) => onSetTileManifestPath(event.target.value)}
/>
) : null}
<button type="button" onClick={onRunDetection} disabled={runningDetection || !selectedProjectId || rasterDatasets.length === 0}>
Run detection
</button>
</div>
{detectionRunError ? <p className="error">{detectionRunError}</p> : null}
{detectionRunResult ? (
<div>
<p>Status: {detectionRunResult.status}</p>
<p>Message: {detectionRunResult.message}</p>
<p>Analysis run: {detectionRunResult.analysis_run_id}</p>
<p>Job: {detectionRunResult.job_id}</p>
<p>Detections: {detectionRunResult.detection_count}</p>
{detectionRunResult.error_code ? <p className="error">Code: {detectionRunResult.error_code}</p> : null}
</div>
) : null}
<div>
<h3>Detection results</h3>
<button type="button" onClick={onLoadRuns} disabled={!selectedProjectId}>
Refresh detection runs
</button>
<select value={selectedDetectionRunId} onChange={(event) => onSelectRun(event.target.value)}>
<option value="">Select detection run</option>
{detectionRuns.map((run) => (
<option key={run.id} value={run.id}>
{run.model_name || 'detection'} - {run.status} - {run.id}
</option>
))}
</select>
<input
type="text"
placeholder="Class filter"
value={detectionClassFilter}
onChange={(event) => onSetClassFilter(event.target.value)}
/>
<input
type="number"
min="0"
max="1"
step="0.05"
value={detectionMinConfidenceFilter}
onChange={(event) => onSetMinConfidenceFilter(Number(event.target.value))}
/>
<button type="button" onClick={onLoadResults} disabled={!selectedDetectionRunId || loadingDetectionResults}>
Load detections
</button>
{loadingDetectionResults ? <p>Loading detection results...</p> : null}
<p>Detections loaded: {detectionItems.length}</p>
{detectionItems.length > 0 ? (
<table>
<thead>
<tr>
<th>Class</th>
<th>Confidence</th>
<th>Model</th>
<th>Source tile</th>
</tr>
</thead>
<tbody>
{detectionItems.map((detection) => (
<tr key={detection.id}>
<td>{detection.class_name}</td>
<td>{detection.confidence.toFixed(2)}</td>
<td>{detection.model_name}</td>
<td>{detection.source_tile_path || 'n/a'}</td>
</tr>
))}
</tbody>
</table>
) : null}
</div>
<div>
<h3>Detection QA</h3>
<select value={detectionReferenceDatasetId} onChange={(event) => onSelectReferenceDataset(event.target.value)}>
<option value="">Select reference dataset</option>
{referenceDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{dataset.name}
</option>
))}
</select>
<button type="button" onClick={onRunQa} disabled={runningDetectionQa || !selectedDetectionRunId || !detectionReferenceDatasetId}>
Compare detections to reference
</button>
{detectionQaError ? <p className="error">{detectionQaError}</p> : null}
{detectionQaResult ? (
<div>
<p>Status: {detectionQaResult.status}</p>
<p>Quality check: {detectionQaResult.quality_check_id}</p>
<p>Precision: {detectionQaResult.precision?.toFixed(3) ?? 'n/a'}</p>
<p>Recall: {detectionQaResult.recall?.toFixed(3) ?? 'n/a'}</p>
<p>F1: {detectionQaResult.f1_score?.toFixed(3) ?? 'n/a'}</p>
<p>Mean IoU: {detectionQaResult.mean_iou?.toFixed(3) ?? 'n/a'}</p>
<p>False positives: {detectionQaResult.false_positives}</p>
<p>False negatives: {detectionQaResult.false_negatives}</p>
</div>
) : null}
</div>
</section>
)
}
@@ -0,0 +1,94 @@
import type { DatasetCreateResponse, ExportCreateResponse, ExportRead } from '../../types'
interface ExportCenterProps {
selectedProjectId: string | null
selectedDataset: DatasetCreateResponse | null
selectedDetectionRunId: string
selectedSegmentationRunId: string
exports: ExportRead[]
latestExport: ExportCreateResponse | null
exportError: string | null
loadingExports: boolean
exporting: boolean
onRefresh: () => void
onExportDataset: () => void
onExportDetectionRun: () => void
onExportSegmentationRun: () => void
onExportProjectMetadata: () => void
onExportProjectReport: () => void
onPreviewContent: (exportId: string) => void
onDownload: (exportId: string) => void
}
function isVectorDatasetType(datasetType: string): boolean {
return datasetType === 'vector' || datasetType === 'geojson'
}
export function ExportCenter({
selectedProjectId,
selectedDataset,
selectedDetectionRunId,
selectedSegmentationRunId,
exports,
latestExport,
exportError,
loadingExports,
exporting,
onRefresh,
onExportDataset,
onExportDetectionRun,
onExportSegmentationRun,
onExportProjectMetadata,
onExportProjectReport,
onPreviewContent,
onDownload,
}: ExportCenterProps): JSX.Element {
const canExportDataset = Boolean(selectedDataset && isVectorDatasetType(selectedDataset.dataset_type))
return (
<section>
<h2>Export Center</h2>
<button type="button" onClick={onRefresh} disabled={!selectedProjectId || loadingExports}>
Refresh exports
</button>
<button type="button" onClick={onExportProjectMetadata} disabled={!selectedProjectId || exporting}>
Export project metadata JSON
</button>
<button type="button" onClick={onExportProjectReport} disabled={!selectedProjectId || exporting}>
Export project report HTML
</button>
<button type="button" onClick={onExportDataset} disabled={!canExportDataset || exporting}>
Export selected vector GeoJSON
</button>
<button type="button" onClick={onExportDetectionRun} disabled={!selectedDetectionRunId || exporting}>
Export selected detection run GeoJSON
</button>
<button type="button" onClick={onExportSegmentationRun} disabled={!selectedSegmentationRunId || exporting}>
Export selected segmentation run GeoJSON
</button>
{exportError ? <p className="error">{exportError}</p> : null}
{latestExport ? (
<p>
Latest export: {latestExport.export_type} {'->'} {latestExport.path}
</p>
) : null}
{exports.length === 0 ? <p>No exports registered yet.</p> : null}
<ul>
{exports.map((item) => (
<li key={item.id}>
<strong>{item.export_type}</strong>
<div>status: {item.status}</div>
<div>path: {item.storage_path}</div>
<div>export id: {item.id}</div>
<button type="button" onClick={() => onPreviewContent(item.id)}>
Preview JSON content
</button>
<button type="button" onClick={() => onDownload(item.id)}>
Download artifact
</button>
</li>
))}
</ul>
</section>
)
}
@@ -0,0 +1,66 @@
import type { FormEvent } from 'react'
import type { AreaRead, ProjectRead } from '../../types'
interface AreaFormState {
name: string
geometry: string
crs: string
}
interface AreaPanelProps {
areas: AreaRead[]
selectedProject: ProjectRead | null
selectedProjectId: string | null
loadingAreas: boolean
areaForm: AreaFormState
onCreateArea: (event: FormEvent<HTMLFormElement>) => void
onUpdateAreaForm: (areaForm: AreaFormState) => void
}
export function AreaPanel({
areas,
selectedProject,
selectedProjectId,
loadingAreas,
areaForm,
onCreateArea,
onUpdateAreaForm,
}: AreaPanelProps): JSX.Element {
return (
<section>
<h2>Area manager</h2>
<p>{selectedProject ? `Selected project: ${selectedProject.name}` : 'Select a project first'}</p>
<form onSubmit={onCreateArea}>
<input
value={areaForm.name}
onChange={(event) => onUpdateAreaForm({ ...areaForm, name: event.target.value })}
placeholder="AOI name"
/>
<input
value={areaForm.crs}
onChange={(event) => onUpdateAreaForm({ ...areaForm, crs: event.target.value })}
placeholder="EPSG:4326"
/>
<textarea
value={areaForm.geometry}
onChange={(event) => onUpdateAreaForm({ ...areaForm, geometry: event.target.value })}
rows={4}
/>
<button type="submit" disabled={!selectedProjectId}>
Create area
</button>
</form>
{loadingAreas ? <p>Loading areas...</p> : null}
{areas.length === 0 ? <p>No areas yet</p> : null}
<ul>
{areas.map((area) => (
<li key={area.id}>
{area.name} · {area.area_m2 ? `${area.area_m2.toFixed(2)}` : 'n/a'}
</li>
))}
</ul>
</section>
)
}
@@ -0,0 +1,76 @@
import type { FormEvent } from 'react'
import type { ProjectCreate, ProjectRead } from '../../types'
interface ProjectPanelProps {
projects: ProjectRead[]
selectedProjectId: string | null
loadingProjects: boolean
projectForm: ProjectCreate
loadingDemoWorkflow: boolean
demoWorkflowMessage: string | null
onCreateProject: (event: FormEvent<HTMLFormElement>) => void
onUpdateProjectForm: (projectForm: ProjectCreate) => void
onSelectProject: (projectId: string) => void
onLoadDemoWorkflow: () => void
}
export function ProjectPanel({
projects,
selectedProjectId,
loadingProjects,
projectForm,
loadingDemoWorkflow,
demoWorkflowMessage,
onCreateProject,
onUpdateProjectForm,
onSelectProject,
onLoadDemoWorkflow,
}: ProjectPanelProps): JSX.Element {
return (
<section>
<h2>Projects</h2>
{loadingProjects ? <p>Loading projects...</p> : null}
<form onSubmit={onCreateProject}>
<input
value={projectForm.name}
onChange={(event) => onUpdateProjectForm({ ...projectForm, name: event.target.value })}
placeholder="Project name"
/>
<input
value={projectForm.description ?? ''}
onChange={(event) => onUpdateProjectForm({ ...projectForm, description: event.target.value })}
placeholder="Description"
/>
<input
value={projectForm.region ?? 'Kempen'}
onChange={(event) => onUpdateProjectForm({ ...projectForm, region: event.target.value })}
placeholder="Region"
/>
<button type="submit">Create project</button>
</form>
<div className="demo-actions">
<button type="button" onClick={onLoadDemoWorkflow} disabled={loadingDemoWorkflow}>
{loadingDemoWorkflow ? 'Loading demo...' : 'Load demo workflow'}
</button>
{demoWorkflowMessage ? <p>{demoWorkflowMessage}</p> : null}
</div>
<ul>
{projects.map((project) => (
<li key={project.id}>
<button
type="button"
onClick={() => onSelectProject(project.id)}
aria-pressed={project.id === selectedProjectId}
>
{project.name}
</button>
</li>
))}
{projects.length === 0 ? <li>No projects yet</li> : null}
</ul>
</section>
)
}
@@ -0,0 +1,47 @@
import type { ProviderCapability } from '../../types'
interface ProviderPanelProps {
providers: ProviderCapability[]
loadingCapabilities: boolean
capabilitiesError: string | null
onRefresh: () => void
}
export function ProviderPanel({
providers,
loadingCapabilities,
capabilitiesError,
onRefresh,
}: ProviderPanelProps): JSX.Element {
return (
<section>
<h2>Provider Capabilities</h2>
<button type="button" onClick={onRefresh} disabled={loadingCapabilities}>
Refresh providers
</button>
{loadingCapabilities ? <p>Loading provider capabilities...</p> : null}
{capabilitiesError ? <p className="error">{capabilitiesError}</p> : null}
{providers.length === 0 && !loadingCapabilities ? <p>No providers reported by backend</p> : null}
<ul>
{providers.map((provider) => (
<li key={provider.provider_name}>
<strong>{provider.display_name}</strong>
<div>provider: {provider.provider_name}</div>
<div>authority: {provider.authority_level}</div>
<div>status: {provider.status}</div>
<div>configured: {provider.configured ? 'yes' : 'no'}</div>
<div>layers: {provider.supported_layers.join(', ')}</div>
<div>geometry: {provider.supported_geometry_types.join(', ')}</div>
<div>query modes: {provider.supported_query_modes.join(', ')}</div>
<div>limitation: {provider.limitation_message}</div>
<div>attribution: {provider.attribution}</div>
<div>license: {provider.license_note}</div>
{!provider.configured && provider.not_configured_reason ? (
<div>reason: {provider.not_configured_reason}</div>
) : null}
</li>
))}
</ul>
</section>
)
}
@@ -0,0 +1,244 @@
import type {
DatasetCreateResponse,
SegmentationModelCapability,
SegmentationQaResult,
SegmentationRead,
SegmentationRunRead,
SegmentationRunResponse,
} from '../../types'
interface SegmentationLabProps {
segmentationModels: SegmentationModelCapability[]
loadingSegmentationModels: boolean
segmentationModelError: string | null
selectedSegmentationDatasetId: string
selectedSegmentationModelId: string
segmentationConfidenceThreshold: number
runningSegmentation: boolean
segmentationRunResult: SegmentationRunResponse | null
segmentationRunError: string | null
segmentationRuns: SegmentationRunRead[]
selectedSegmentationRunId: string
segmentationItems: SegmentationRead[]
segmentationClassFilter: string
segmentationMinConfidenceFilter: number
loadingSegmentationResults: boolean
segmentationReferenceDatasetId: string
segmentationQaResult: SegmentationQaResult | null
segmentationQaError: string | null
runningSegmentationQa: boolean
selectedProjectId: string | null
rasterDatasets: DatasetCreateResponse[]
referenceDatasets: DatasetCreateResponse[]
selectedSegmentationModelConfigured: boolean
selectedSegmentationModelLimitation: string | null
onLoadModels: () => void
onSelectDataset: (datasetId: string) => void
onSelectModel: (modelId: string) => void
onSetConfidenceThreshold: (value: number) => void
onRunSegmentation: () => void
onLoadRuns: () => void
onSelectRun: (runId: string) => void
onSetClassFilter: (value: string) => void
onSetMinConfidenceFilter: (value: number) => void
onLoadResults: () => void
onSelectReferenceDataset: (datasetId: string) => void
onRunQa: () => void
}
export function SegmentationLab({
segmentationModels,
loadingSegmentationModels,
segmentationModelError,
selectedSegmentationDatasetId,
selectedSegmentationModelId,
segmentationConfidenceThreshold,
runningSegmentation,
segmentationRunResult,
segmentationRunError,
segmentationRuns,
selectedSegmentationRunId,
segmentationItems,
segmentationClassFilter,
segmentationMinConfidenceFilter,
loadingSegmentationResults,
segmentationReferenceDatasetId,
segmentationQaResult,
segmentationQaError,
runningSegmentationQa,
selectedProjectId,
rasterDatasets,
referenceDatasets,
selectedSegmentationModelConfigured,
selectedSegmentationModelLimitation,
onLoadModels,
onSelectDataset,
onSelectModel,
onSetConfidenceThreshold,
onRunSegmentation,
onLoadRuns,
onSelectRun,
onSetClassFilter,
onSetMinConfidenceFilter,
onLoadResults,
onSelectReferenceDataset,
onRunQa,
}: SegmentationLabProps): JSX.Element {
return (
<section>
<h2>Segmentation Lab</h2>
<button type="button" onClick={onLoadModels} disabled={loadingSegmentationModels}>
Refresh segmentation models
</button>
{loadingSegmentationModels ? <p>Loading segmentation models...</p> : null}
{segmentationModelError ? <p className="error">{segmentationModelError}</p> : null}
{segmentationModels.length === 0 && !loadingSegmentationModels ? <p>No segmentation models reported by backend</p> : null}
<ul>
{segmentationModels.map((model) => (
<li key={model.model_id}>
<strong>{model.display_name}</strong>
<div>model: {model.model_id}</div>
<div>framework: {model.framework}</div>
<div>task: {model.task_type}</div>
<div>status: {model.status}</div>
<div>configured: {model.configured ? 'yes' : 'no'}</div>
<div>classes: {model.supported_classes.join(', ')}</div>
<div>limitation: {model.limitation_message}</div>
</li>
))}
</ul>
<div>
<select value={selectedSegmentationDatasetId} onChange={(event) => onSelectDataset(event.target.value)}>
<option value="">Select raster dataset</option>
{rasterDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{dataset.name}
</option>
))}
</select>
<select value={selectedSegmentationModelId} onChange={(event) => onSelectModel(event.target.value)}>
{segmentationModels.map((model) => (
<option key={model.model_id} value={model.model_id}>
{model.display_name}
</option>
))}
</select>
<input
type="number"
min="0"
max="1"
step="0.05"
value={segmentationConfidenceThreshold}
onChange={(event) => onSetConfidenceThreshold(Number(event.target.value))}
/>
<button
type="button"
onClick={onRunSegmentation}
disabled={runningSegmentation || !selectedProjectId || rasterDatasets.length === 0 || !selectedSegmentationModelConfigured}
>
Run segmentation
</button>
</div>
{!selectedSegmentationModelConfigured ? (
<p>{selectedSegmentationModelLimitation ?? 'Select a configured segmentation model'}</p>
) : null}
{segmentationRunError ? <p className="error">{segmentationRunError}</p> : null}
{segmentationRunResult ? (
<div>
<p>Status: {segmentationRunResult.status}</p>
<p>Message: {segmentationRunResult.message}</p>
<p>Analysis run: {segmentationRunResult.analysis_run_id}</p>
<p>Job: {segmentationRunResult.job_id}</p>
<p>Segmentations: {segmentationRunResult.segmentation_count}</p>
{segmentationRunResult.error_code ? <p className="error">Code: {segmentationRunResult.error_code}</p> : null}
</div>
) : null}
<div>
<h3>Segmentation results</h3>
<button type="button" onClick={onLoadRuns} disabled={!selectedProjectId}>
Refresh segmentation runs
</button>
<select value={selectedSegmentationRunId} onChange={(event) => onSelectRun(event.target.value)}>
<option value="">Select segmentation run</option>
{segmentationRuns.map((run) => (
<option key={run.id} value={run.id}>
{run.model_name || 'segmentation'} - {run.status} - {run.id}
</option>
))}
</select>
<input
type="text"
placeholder="Class filter"
value={segmentationClassFilter}
onChange={(event) => onSetClassFilter(event.target.value)}
/>
<input
type="number"
min="0"
max="1"
step="0.05"
value={segmentationMinConfidenceFilter}
onChange={(event) => onSetMinConfidenceFilter(Number(event.target.value))}
/>
<button type="button" onClick={onLoadResults} disabled={!selectedSegmentationRunId || loadingSegmentationResults}>
Load segmentations
</button>
{loadingSegmentationResults ? <p>Loading segmentation results...</p> : null}
<p>Segmentations loaded: {segmentationItems.length}</p>
{segmentationItems.length > 0 ? (
<table>
<thead>
<tr>
<th>Class</th>
<th>Confidence</th>
<th>Area m2</th>
<th>Model</th>
<th>Tile</th>
<th>Mask path</th>
</tr>
</thead>
<tbody>
{segmentationItems.map((segmentation) => (
<tr key={segmentation.id}>
<td>{segmentation.class_name}</td>
<td>{segmentation.confidence?.toFixed(2) ?? 'n/a'}</td>
<td>{segmentation.area_m2?.toFixed(2) ?? 'n/a'}</td>
<td>{segmentation.model_name}</td>
<td>{segmentation.source_tile_path || (segmentation.tile_index ?? 'n/a')}</td>
<td>{segmentation.mask_path || 'n/a'}</td>
</tr>
))}
</tbody>
</table>
) : null}
</div>
<div>
<h3>Segmentation QA</h3>
<select value={segmentationReferenceDatasetId} onChange={(event) => onSelectReferenceDataset(event.target.value)}>
<option value="">Select reference dataset</option>
{referenceDatasets.map((dataset) => (
<option key={dataset.id} value={dataset.id}>
{dataset.name}
</option>
))}
</select>
<button type="button" onClick={onRunQa} disabled={runningSegmentationQa || !selectedSegmentationRunId || !segmentationReferenceDatasetId}>
Compare segmentations to reference
</button>
{segmentationQaError ? <p className="error">{segmentationQaError}</p> : null}
{segmentationQaResult ? (
<div>
<p>Status: {segmentationQaResult.status}</p>
<p>Quality check: {segmentationQaResult.quality_check_id}</p>
<p>Precision: {segmentationQaResult.precision?.toFixed(3) ?? 'n/a'}</p>
<p>Recall: {segmentationQaResult.recall?.toFixed(3) ?? 'n/a'}</p>
<p>F1: {segmentationQaResult.f1_score?.toFixed(3) ?? 'n/a'}</p>
<p>Mean IoU: {segmentationQaResult.mean_iou?.toFixed(3) ?? 'n/a'}</p>
<p>False positives: {segmentationQaResult.false_positives}</p>
<p>False negatives: {segmentationQaResult.false_negatives}</p>
</div>
) : null}
</div>
</section>
)
}
View File
View File
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './styles/app.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
View File
View File
+12
View File
@@ -0,0 +1,12 @@
import { apiGet, apiPost, apiPatch } from './client'
import type { AreaCreate, AreaListResponse, AreaRead } from '../../types'
export const areasApi = {
list: (projectId: string): Promise<AreaListResponse> => apiGet<AreaListResponse>(`/api/v1/projects/${projectId}/areas`),
create: (projectId: string, payload: AreaCreate): Promise<AreaRead> =>
apiPost<AreaRead>(`/api/v1/projects/${projectId}/areas`, payload),
get: (projectId: string, areaId: string): Promise<AreaRead> =>
apiGet<AreaRead>(`/api/v1/projects/${projectId}/areas/${areaId}`),
update: (projectId: string, areaId: string, payload: Partial<AreaCreate>): Promise<AreaRead> =>
apiPatch<AreaRead>(`/api/v1/projects/${projectId}/areas/${areaId}`, payload),
}
+66
View File
@@ -0,0 +1,66 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "";
export function apiUrl(path: string): string {
return `${API_BASE_URL}${path}`;
}
export class ApiHttpError extends Error {
readonly code: string;
readonly details?: unknown;
constructor(message: string, code = "REQUEST_ERROR", details?: unknown) {
super(message);
this.name = "ApiHttpError";
this.code = code;
this.details = details;
}
}
async function parseResponse<T>(response: Response): Promise<T> {
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const code = payload?.error?.code ?? "REQUEST_ERROR";
const message = payload?.error?.message ?? `Request failed (${response.status})`;
const details = payload?.error?.details;
throw new ApiHttpError(message, code, details);
}
return payload.data as T;
}
export async function apiGet<T>(path: string): Promise<T> {
const response = await fetch(apiUrl(path));
return parseResponse<T>(response);
}
export async function apiPost<T>(path: string, body?: object): Promise<T> {
const response = await fetch(apiUrl(path), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
return parseResponse<T>(response);
}
export async function apiPatch<T>(path: string, body?: object): Promise<T> {
const response = await fetch(apiUrl(path), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
return parseResponse<T>(response);
}
export async function apiDelete<T>(path: string): Promise<T> {
const response = await fetch(apiUrl(path), {
method: "DELETE",
});
return parseResponse<T>(response);
}
export async function apiMultipart<T>(path: string, form: FormData): Promise<T> {
const response = await fetch(apiUrl(path), {
method: "POST",
body: form,
});
return parseResponse<T>(response);
}
+109
View File
@@ -0,0 +1,109 @@
import { apiGet, apiMultipart, apiPost } from './client'
import type {
DatasetCreateResponse,
DatasetListResponse,
RasterMetadataResponse,
RasterInspectResponse,
RasterStatsResponse,
RasterPreviewResponse,
JobRead,
VectorBBoxResponse,
VectorStatsResponse,
VectorSummary,
RasterNdviRequest,
RasterNdwiRequest,
RasterNdbiRequest,
} from '../../types'
export const datasetsApi = {
list: (projectId: string): Promise<DatasetListResponse> =>
apiGet<DatasetListResponse>(`/api/v1/projects/${projectId}/datasets`),
upload: (
projectId: string,
payload: {
file: File
datasetType: string
source: string
datasetRole: string
sourceName?: string
referenceLayerName?: string
sourceMetadataJson?: string
provenanceMetadataJson?: string
areaId?: string
},
): Promise<DatasetCreateResponse> => {
const form = new FormData()
form.append('file', payload.file)
form.append('dataset_type', payload.datasetType)
form.append('source', payload.source)
form.append('dataset_role', payload.datasetRole)
if (payload.sourceName) {
form.append('source_name', payload.sourceName)
}
if (payload.referenceLayerName) {
form.append('reference_layer_name', payload.referenceLayerName)
}
if (payload.sourceMetadataJson) {
form.append('source_metadata_json', payload.sourceMetadataJson)
}
if (payload.provenanceMetadataJson) {
form.append('provenance_metadata_json', payload.provenanceMetadataJson)
}
if (payload.areaId) {
form.append('area_id', payload.areaId)
}
return apiMultipart<DatasetCreateResponse>(`/api/v1/projects/${projectId}/datasets/upload`, form)
},
refreshMetadata: (projectId: string, datasetId: string): Promise<DatasetCreateResponse> =>
apiPost<DatasetCreateResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/metadata/refresh`, {}),
inspectRaster: (projectId: string, datasetId: string): Promise<RasterInspectResponse> =>
apiGet<RasterInspectResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/raster/inspect`),
rasterStats: (projectId: string, datasetId: string): Promise<RasterStatsResponse> =>
apiGet<RasterStatsResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/raster/stats`),
inspectVector: (projectId: string, datasetId: string): Promise<{ dataset: DatasetCreateResponse; summary: VectorSummary | null; metadata: unknown }> =>
apiGet<{ dataset: DatasetCreateResponse; summary: VectorSummary | null; metadata: unknown }>(
`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/inspect`,
),
vectorBbox: (projectId: string, datasetId: string): Promise<VectorBBoxResponse> =>
apiGet<VectorBBoxResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/bbox`),
vectorSummary: (projectId: string, datasetId: string): Promise<VectorSummary> =>
apiGet<VectorSummary>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/summary`),
vectorStats: (projectId: string, datasetId: string): Promise<VectorStatsResponse> =>
apiGet<VectorStatsResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/stats`),
vectorClip: (projectId: string, datasetId: string, payload: { area_id: string; output_name?: string }) =>
apiPost<JobRead>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/clip`, payload),
vectorBuffer: (projectId: string, datasetId: string, payload: { distance_m: number; dissolve?: boolean; output_name?: string }) =>
apiPost<JobRead>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/buffer`, payload),
vectorIntersect: (projectId: string, datasetId: string, payload: { other_dataset_id: string; output_name?: string }) =>
apiPost<JobRead>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/intersect`, payload),
rasterMetadata: (projectId: string, datasetId: string): Promise<RasterMetadataResponse> =>
apiGet<RasterMetadataResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/raster/metadata`),
rasterInspect: (projectId: string, datasetId: string): Promise<RasterInspectResponse> =>
apiGet<RasterInspectResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/raster/inspect`),
rasterTile: (
projectId: string,
datasetId: string,
payload: { tile_size?: number; overlap?: number; output_name?: string } = {},
): Promise<JobRead> =>
apiPost<JobRead>(`/api/v1/projects/${projectId}/datasets/${datasetId}/raster/tile`, payload),
rasterReproject: (
projectId: string,
datasetId: string,
payload: { target_crs: string; resampling?: string; output_name?: string },
): Promise<JobRead> => apiPost<JobRead>(`/api/v1/projects/${projectId}/datasets/${datasetId}/raster/reproject`, payload),
rasterNdvi: (projectId: string, datasetId: string, payload: RasterNdviRequest): Promise<JobRead> =>
apiPost<JobRead>(`/api/v1/projects/${projectId}/datasets/${datasetId}/raster/indices/ndvi`, payload),
rasterNdwi: (projectId: string, datasetId: string, payload: RasterNdwiRequest): Promise<JobRead> =>
apiPost<JobRead>(`/api/v1/projects/${projectId}/datasets/${datasetId}/raster/indices/ndwi`, payload),
rasterNdbi: (projectId: string, datasetId: string, payload: RasterNdbiRequest): Promise<JobRead> =>
apiPost<JobRead>(`/api/v1/projects/${projectId}/datasets/${datasetId}/raster/indices/ndbi`, payload),
rasterPreview: (projectId: string, datasetId: string): Promise<RasterPreviewResponse> =>
apiGet<RasterPreviewResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/raster/preview`),
rasterClip: (
projectId: string,
datasetId: string,
payload: { area_id: string; output_name?: string },
): Promise<JobRead> => apiPost<JobRead>(`/api/v1/projects/${projectId}/datasets/${datasetId}/raster/clip`, payload),
getContent: (projectId: string, datasetId: string): Promise<GeoJSON.FeatureCollection> =>
apiGet<GeoJSON.FeatureCollection>(`/api/v1/projects/${projectId}/datasets/${datasetId}/content`),
}
+6
View File
@@ -0,0 +1,6 @@
import { apiPost } from './client'
import type { DemoWorkflowResponse } from '../../types'
export const demoApi = {
seedWorkflow: (): Promise<DemoWorkflowResponse> => apiPost<DemoWorkflowResponse>('/api/v1/demo/workflow'),
}
+44
View File
@@ -0,0 +1,44 @@
import { apiGet, apiPost } from './client'
import type {
DetectionListResponse,
DetectionModelsResponse,
DetectionQaRequest,
DetectionQaResult,
DetectionRunListResponse,
DetectionRunRead,
DetectionRunRequest,
DetectionRunResponse,
} from '../../types'
function queryString(params: Record<string, string | number | null | undefined>): string {
const searchParams = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
if (value !== null && value !== undefined && value !== '') {
searchParams.set(key, String(value))
}
})
const query = searchParams.toString()
return query ? `?${query}` : ''
}
export const detectionApi = {
listModels: (): Promise<DetectionModelsResponse> => apiGet<DetectionModelsResponse>('/api/v1/detection/models'),
run: (payload: DetectionRunRequest): Promise<DetectionRunResponse> =>
apiPost<DetectionRunResponse>('/api/v1/detection/run', payload),
listRuns: (params: { project_id?: string | null; dataset_id?: string | null } = {}): Promise<DetectionRunListResponse> =>
apiGet<DetectionRunListResponse>(`/api/v1/detection/runs${queryString(params)}`),
getRun: (analysisRunId: string): Promise<DetectionRunRead> =>
apiGet<DetectionRunRead>(`/api/v1/detection/runs/${analysisRunId}`),
listDetections: (
analysisRunId: string,
params: { dataset_id?: string | null; class_name?: string | null; min_confidence?: number | null } = {},
): Promise<DetectionListResponse> =>
apiGet<DetectionListResponse>(`/api/v1/detection/runs/${analysisRunId}/detections${queryString(params)}`),
getRunGeoJson: (
analysisRunId: string,
params: { class_name?: string | null; min_confidence?: number | null } = {},
): Promise<GeoJSON.FeatureCollection> =>
apiGet<GeoJSON.FeatureCollection>(`/api/v1/detection/runs/${analysisRunId}/geojson${queryString(params)}`),
compareWithReference: (analysisRunId: string, payload: DetectionQaRequest): Promise<DetectionQaResult> =>
apiPost<DetectionQaResult>(`/api/v1/detection/runs/${analysisRunId}/qa/reference`, payload),
}
+21
View File
@@ -0,0 +1,21 @@
import { apiGet, apiPost, apiUrl } from './client'
import type { ExportContentResponse, ExportCreateResponse, ExportKind, ExportListResponse, ExportRead } from '../../types'
export const exportsApi = {
exportGeojson: (
payload: { dataset_id?: string; analysis_run_id?: string; export_kind?: ExportKind; name?: string } | string,
): Promise<ExportCreateResponse> => {
const body = typeof payload === 'string' ? { dataset_id: payload, export_kind: 'dataset' } : payload
return apiPost<ExportCreateResponse>(`/api/v1/exports/geojson`, body)
},
exportProjectMetadata: (projectId: string, name?: string): Promise<ExportCreateResponse> =>
apiPost<ExportCreateResponse>(`/api/v1/exports/metadata`, { project_id: projectId, name }),
exportProjectReport: (projectId: string, name?: string): Promise<ExportCreateResponse> =>
apiPost<ExportCreateResponse>(`/api/v1/exports/report`, { project_id: projectId, name }),
listProjectExports: (projectId: string): Promise<ExportListResponse> =>
apiGet<ExportListResponse>(`/api/v1/exports/projects/${projectId}/exports`),
getExport: (exportId: string): Promise<ExportRead> => apiGet<ExportRead>(`/api/v1/exports/${exportId}`),
getContent: (exportId: string): Promise<ExportContentResponse> =>
apiGet<ExportContentResponse>(`/api/v1/exports/${exportId}/content`),
downloadUrl: (exportId: string): string => apiUrl(`/api/v1/exports/${exportId}/download`),
}
+67
View File
@@ -0,0 +1,67 @@
import { apiGet, apiPost } from './client'
import type {
ProviderCapability,
ProviderCapabilitiesResponse,
ProviderImportResponse,
ProviderLayersResponse,
ProviderStatusResponse,
SystemCapabilitiesResponse,
} from '../../types'
const normalize = (layers: string[] = []) => layers.filter((value) => value.trim().length > 0)
export const externalApi = {
listSystemCapabilities: (): Promise<SystemCapabilitiesResponse> =>
apiGet<SystemCapabilitiesResponse>('/api/v1/system/capabilities'),
listProviders: (): Promise<ProviderCapabilitiesResponse> =>
apiGet<ProviderCapabilitiesResponse>('/api/v1/external/providers'),
listProviderCapabilities: (): Promise<ProviderCapabilitiesResponse> =>
apiGet<ProviderCapabilitiesResponse>('/api/v1/external/providers/capabilities'),
getProvider: (providerName: string): Promise<ProviderCapability> =>
apiGet<ProviderCapability>(`/api/v1/external/providers/${providerName}`),
getProviderLayers: (providerName: string): Promise<ProviderLayersResponse> =>
apiGet<ProviderLayersResponse>(`/api/v1/external/providers/${providerName}/layers`),
getProviderStatus: (providerName: string): Promise<ProviderStatusResponse> =>
apiGet<ProviderStatusResponse>(`/api/v1/external/providers/${providerName}/status`),
requestProviderImport: (providerName: string, payload: {
projectId: string
areaId?: string
layers: string[]
datasetRole?: string
}): Promise<ProviderImportResponse> =>
apiPost<ProviderImportResponse>(`/api/v1/external/providers/${providerName}/import`, {
project_id: payload.projectId,
area_id: payload.areaId ?? null,
layers: normalize(payload.layers),
dataset_role: payload.datasetRole ?? null,
}),
runOsmFetch: (payload: {
projectId: string
areaId?: string
layers: string[]
}): Promise<ProviderFetchResponse> =>
apiPost<ProviderFetchResponse>('/api/v1/external/osm/fetch', {
project_id: payload.projectId,
area_id: payload.areaId ?? null,
layers: normalize(payload.layers),
}),
runGrbFetch: (payload: {
projectId: string
areaId?: string
layers: string[]
}): Promise<ProviderFetchResponse> =>
apiPost<ProviderFetchResponse>('/api/v1/external/grb/fetch', {
project_id: payload.projectId,
area_id: payload.areaId ?? null,
layers: normalize(payload.layers),
}),
}
export interface ProviderFetchResponse {
provider: string
status: string
message: string
requested_layers: string[]
project_id: string
area_id: string | null
}
+10
View File
@@ -0,0 +1,10 @@
export { areasApi } from './areas'
export { datasetsApi } from './datasets'
export { demoApi } from './demo'
export { detectionApi } from './detection'
export { externalApi } from './external'
export { qaApi } from './qa'
export { segmentationApi } from './segmentation'
export { exportsApi } from './exports'
export { jobsApi } from './jobs'
export { projectsApi } from './projects'
+31
View File
@@ -0,0 +1,31 @@
import { apiGet, apiPost } from './client'
import type { JobListResponse, JobRead } from '../../types'
export const jobsApi = {
create: (projectId: string, payload: {
job_type: string
project_id: string
dataset_id?: string | null
input_dataset_id?: string | null
output_dataset_id?: string | null
parameters_json?: Record<string, unknown>
}): Promise<JobRead> =>
apiPost<JobRead>(`/api/v1/projects/${projectId}/jobs`, payload),
list: (projectId: string, options?: { dataset_id?: string; limit?: number; offset?: number }): Promise<JobListResponse> => {
const query = new URLSearchParams()
if (options?.dataset_id) {
query.set('dataset_id', options.dataset_id)
}
if (options?.limit) {
query.set('limit', String(options.limit))
}
if (options?.offset) {
query.set('offset', String(options.offset))
}
const queryPart = query.toString() ? `?${query}` : ''
return apiGet<JobListResponse>(`/api/v1/projects/${projectId}/jobs${queryPart}`)
},
get: (projectId: string, jobId: string): Promise<JobRead> => apiGet<JobRead>(`/api/v1/projects/${projectId}/jobs/${jobId}`),
status: (projectId: string, jobId: string): Promise<{ status: string; error_message?: string | null; started_at?: string | null; finished_at?: string | null; result_json?: Record<string, unknown> | null }> =>
apiGet(`/api/v1/projects/${projectId}/jobs/${jobId}/status`),
}
+12
View File
@@ -0,0 +1,12 @@
import { apiDelete, apiGet, apiPatch, apiPost } from './client'
import type { ProjectCreate, ProjectListResponse, ProjectRead } from '../../types'
export const projectsApi = {
list: (): Promise<ProjectListResponse> => apiGet<ProjectListResponse>('/api/v1/projects'),
create: (payload: ProjectCreate): Promise<ProjectRead> => apiPost<ProjectRead>('/api/v1/projects', payload),
get: (id: string): Promise<ProjectRead> => apiGet<ProjectRead>(`/api/v1/projects/${id}`),
update: (id: string, payload: Partial<ProjectCreate>): Promise<ProjectRead> =>
apiPatch<ProjectRead>(`/api/v1/projects/${id}`, payload),
delete: (id: string): Promise<{ deleted: boolean }> =>
apiDelete<{ deleted: boolean }>(`/api/v1/projects/${id}`),
}
+9
View File
@@ -0,0 +1,9 @@
import { apiGet, apiPost } from './client'
import type { QaComparisonRequest, JobRead, QualityCheckListResponse } from '../../types'
export const qaApi = {
runQa: (payload: QaComparisonRequest): Promise<JobRead> =>
apiPost<JobRead>('/api/v1/qa/detections-vs-reference', payload),
listQualityChecks: (projectId: string): Promise<QualityCheckListResponse> =>
apiGet<QualityCheckListResponse>(`/api/v1/projects/${projectId}/quality-checks`),
}
+44
View File
@@ -0,0 +1,44 @@
import { apiGet, apiPost } from './client'
import type {
SegmentationListResponse,
SegmentationModelsResponse,
SegmentationQaRequest,
SegmentationQaResult,
SegmentationRunListResponse,
SegmentationRunRead,
SegmentationRunRequest,
SegmentationRunResponse,
} from '../../types'
function queryString(params: Record<string, string | number | null | undefined>): string {
const searchParams = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
if (value !== null && value !== undefined && value !== '') {
searchParams.set(key, String(value))
}
})
const query = searchParams.toString()
return query ? `?${query}` : ''
}
export const segmentationApi = {
listModels: (): Promise<SegmentationModelsResponse> => apiGet<SegmentationModelsResponse>('/api/v1/segmentation/models'),
run: (payload: SegmentationRunRequest): Promise<SegmentationRunResponse> =>
apiPost<SegmentationRunResponse>('/api/v1/segmentation/run', payload),
listRuns: (params: { project_id?: string | null; dataset_id?: string | null } = {}): Promise<SegmentationRunListResponse> =>
apiGet<SegmentationRunListResponse>(`/api/v1/segmentation/runs${queryString(params)}`),
getRun: (analysisRunId: string): Promise<SegmentationRunRead> =>
apiGet<SegmentationRunRead>(`/api/v1/segmentation/runs/${analysisRunId}`),
listSegmentations: (
analysisRunId: string,
params: { dataset_id?: string | null; class_name?: string | null; min_confidence?: number | null } = {},
): Promise<SegmentationListResponse> =>
apiGet<SegmentationListResponse>(`/api/v1/segmentation/runs/${analysisRunId}/segmentations${queryString(params)}`),
getRunGeoJson: (
analysisRunId: string,
params: { class_name?: string | null; min_confidence?: number | null } = {},
): Promise<GeoJSON.FeatureCollection> =>
apiGet<GeoJSON.FeatureCollection>(`/api/v1/segmentation/runs/${analysisRunId}/geojson${queryString(params)}`),
compareWithReference: (analysisRunId: string, payload: SegmentationQaRequest): Promise<SegmentationQaResult> =>
apiPost<SegmentationQaResult>(`/api/v1/segmentation/runs/${analysisRunId}/qa/reference`, payload),
}
View File
View File
+87
View File
@@ -0,0 +1,87 @@
@import 'maplibre-gl/dist/maplibre-gl.css';
:root {
--bg: #0f172a;
--panel: #ffffff;
--muted: #475569;
--line: #cbd5e1;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Inter', 'Avenir Next', 'Segoe UI', sans-serif;
color: #0f172a;
background: radial-gradient(circle at top, #1d4ed8 0%, #1e293b 45%, #020617 100%);
}
.app-shell {
min-height: 100vh;
padding: 1rem;
}
.app-shell,
section {
background: color-mix(in srgb, var(--panel) 92%, transparent);
}
.workspace-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem;
}
section {
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.9rem;
}
h1,
h2 {
margin-top: 0;
}
input,
textarea,
button,
select {
width: 100%;
padding: 0.5rem;
margin-top: 0.4rem;
font: inherit;
border: 1px solid #94a3b8;
border-radius: 6px;
}
button {
cursor: pointer;
}
.demo-actions {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--line);
}
ul {
padding-left: 1.1rem;
margin-top: 0.6rem;
}
.error {
color: #7f1d1d;
background: #fee2e2;
padding: 0.6rem;
border-radius: 8px;
}
.map-container {
width: 100%;
height: 460px;
border: 1px solid #94a3b8;
border-radius: 8px;
}
+626
View File
@@ -0,0 +1,626 @@
export interface ApiEnvelope<T> {
data: T
}
export interface ApiError {
code: string
message: string
details: Record<string, unknown> | unknown[]
}
export interface ApiErrorEnvelope {
error: ApiError
}
export interface ProjectRead {
id: string
name: string
description?: string | null
region: string
status: string
created_at?: string | null
updated_at?: string | null
}
export interface ProjectCreate {
name: string
description?: string | null
region?: string
}
export interface ProjectListResponse {
items: ProjectRead[]
total: number
limit: number
offset: number
}
export interface DemoWorkflowResponse {
project_id: string
area_id: string
reference_dataset_id: string
candidate_dataset_id: string
quality_check_id: string
metric_count: number
status: string
message: string
created: boolean
}
export interface AreaRead {
id: string
project_id: string
name: string
original_crs?: string | null
area_m2?: number | null
created_at?: string | null
geometry_type?: string | null
}
export interface AreaCreate {
name: string
geometry: GeoJSON.Polygon | GeoJSON.MultiPolygon
crs?: string
}
export interface AreaListResponse {
items: AreaRead[]
total: number
limit: number
offset: number
}
export interface DatasetCreateResponse {
id: string
name: string
dataset_type: string
source: string
dataset_role?: string
source_name?: string | null
reference_layer_name?: string | null
source_metadata?: Record<string, unknown> | null
provenance_metadata?: Record<string, unknown> | null
imported_at?: string | null
project_id: string
area_id?: string | null
storage_path?: string | null
original_filename?: string | null
stored_filename?: string | null
content_type?: string | null
size_bytes?: number | null
checksum_sha256?: string | null
crs?: string | null
bounds_json?: Record<string, number> | null
metadata_json?: Record<string, unknown> | null
vector_summary?: VectorSummary | null
status: string
derived_from_dataset_id?: string | null
created_at?: string | null
feature_count?: number | null
}
export interface JobRead {
id: string
job_type: string
status: string
project_id: string
dataset_id?: string | null
input_dataset_id?: string | null
output_dataset_id?: string | null
parameters_json: Record<string, unknown>
result_json?: Record<string, unknown> | null
error_message?: string | null
created_at?: string | null
started_at?: string | null
finished_at?: string | null
}
export interface JobListResponse {
items: JobRead[]
total: number
limit: number
offset: number
}
export interface VectorSummary {
feature_count?: number | null
geometry_types?: string[] | null
bounds_json?: Record<string, number> | null
approximate_area_m2?: number | null
crs?: string | null
feature_geometry_count?: number | null
invalid_features?: number | null
crs_assumed?: boolean | null
}
export interface VectorSummaryResponse {
feature_count?: number | null
geometry_types?: string[] | null
bounds_json?: Record<string, number> | null
approximate_area_m2?: number | null
crs?: string | null
feature_geometry_count?: number | null
invalid_features?: number | null
crs_assumed?: boolean | null
}
export interface RasterMetadataResponse {
driver: string
dataset_id?: string
width: number
height: number
band_count: number
crs?: string | null
bounds?: number[]
resolution?: number[]
dtype?: string[]
nodata?: unknown
transform?: string[] | number[] | null
path?: string
size_bytes?: number | null
checksum_sha256?: string | null
operation?: string | null
operation_parameters?: Record<string, unknown> | null
source_dataset_id?: string | null
}
export interface RasterInspectResponse {
dataset_id: string
ready: boolean
metadata: Record<string, unknown>
}
export interface RasterPreviewPayload {
path: string
format: string
width: number | null
height: number | null
}
export interface RasterPreviewResponse {
dataset_id: string
ready: boolean
preview: RasterPreviewPayload
metadata?: Record<string, unknown>
}
export interface RasterBandStats {
band_index: number
dtype: string | null
min: number | null
max: number | null
mean: number | null
std: number | null
nodata_count: number
nodata_ratio: number
valid_pixel_count: number
histogram: number[] | null
histogram_bins: number[] | null
}
export interface RasterStatsResponse {
dataset_id: string
source_dataset_id?: string | null
bands: RasterBandStats[]
generated_at?: string | null
}
export interface RasterNdviRequest {
nir_band: number
red_band: number
output_name?: string
}
export interface RasterNdwiRequest {
green_band: number
nir_band: number
output_name?: string
}
export interface RasterNdbiRequest {
swir_band: number
nir_band: number
output_name?: string
}
export interface RasterTileManifestTile {
path: string
pixel_window: number[]
bounds: number[]
transform: number[]
index: number
}
export interface RasterTileManifest {
tile_set_id: string
source_dataset_id: string
source_raster_id: string
bounds: number[]
tile_size: number
overlap: number
parameters: Record<string, unknown>
created_at: string
tile_paths: string[]
count: number
tiles: RasterTileManifestTile[]
}
export interface RasterTileResponse {
dataset_id: string
ready: boolean
operation: string
tile_set_id: string
tile_size: number
overlap: number
manifest_path: string
count: number
manifest: RasterTileManifest
}
export interface VectorBBoxResponse {
dataset_id: string
bounds_json: Record<string, number> | null
feature_count: number
crs?: string | null
}
export interface VectorStatsResponse {
dataset_id: string
feature_count: number
geometry_type_summary: Record<string, number>
bounds_json: Record<string, number> | null
crs?: string | null
}
export interface DatasetListResponse {
items: DatasetCreateResponse[]
total: number
limit: number
offset: number
}
export interface GeojsonEnvelopeResponse {
data: object
}
export interface ProviderCapability {
provider_name: string
display_name: string
authority_level: 'authoritative' | 'contextual' | 'manual' | 'fixture' | string
supported_layers: string[]
supported_geometry_types: string[]
supported_query_modes: string[]
fetch_signature: string
configured: boolean
status: string
limitation_message: string
attribution: string
license_note: string
not_configured_reason: string | null
}
export interface SystemCapabilitiesResponse {
status: string
service: string
version: string
database: string | null
postgis: boolean
rasterio: boolean
geopandas: boolean
yolo: boolean | string
sam: boolean | string
grb: string
sentinel: string
providers: ProviderCapability[]
}
export interface ProviderCapabilitiesResponse {
providers: ProviderCapability[]
}
export interface ProviderLayersResponse {
provider_name: string
layers: string[]
}
export interface ProviderStatusResponse {
provider_name: string
configured: boolean
status: string
limitation_message: string
}
export interface ProviderImportResponse {
provider_name: string
status: string
message: string
requested_layers: string[]
dataset_id: string | null
dataset_role?: string | null
source_name?: string | null
}
export interface DetectionModelCapability {
model_id: string
display_name: string
framework: string
task_type: string
supported_classes: string[]
configured: boolean
status: string
limitation_message: string
version?: string | null
}
export interface DetectionModelsResponse {
models: DetectionModelCapability[]
}
export interface DetectionRunRequest {
project_id: string
dataset_id: string
model_id: string
confidence_threshold: number
class_filter?: string[] | null
tile_manifest_path?: string | null
parameters_json?: Record<string, unknown>
}
export interface DetectionRunResponse {
analysis_run_id: string
job_id: string
project_id: string
dataset_id: string
model_id: string
status: string
detection_count: number
error_code?: string | null
message: string
}
export interface DetectionRunRead {
id: string
project_id: string
dataset_id?: string | null
job_id?: string | null
analysis_type: string
status: string
model_name?: string | null
model_version?: string | null
parameters_json: Record<string, unknown>
result_json?: Record<string, unknown> | null
error_message?: string | null
created_at?: string | null
started_at?: string | null
finished_at?: string | null
}
export interface DetectionRunListResponse {
items: DetectionRunRead[]
total: number
}
export interface DetectionRead {
id: string
project_id: string
dataset_id?: string | null
analysis_run_id?: string | null
job_id?: string | null
model_name: string
model_version?: string | null
class_name: string
confidence: number
bbox_json?: Record<string, unknown> | null
source_tile_path?: string | null
properties_json?: Record<string, unknown> | null
created_at?: string | null
}
export interface DetectionListResponse {
items: DetectionRead[]
total: number
}
export interface DetectionQaRequest {
reference_dataset_id: string
iou_threshold: number
class_name?: string | null
min_confidence?: number | null
}
export interface DetectionQaResult {
status: string
quality_check_id: string
analysis_run_id: string
reference_dataset_id: string
candidate_feature_count: number
reference_feature_count: number
matches: number
false_positives: number
false_negatives: number
precision?: number | null
recall?: number | null
f1_score?: number | null
mean_iou?: number | null
iou_threshold: number
warnings: string[]
}
export type SegmentationModelCapability = DetectionModelCapability
export interface SegmentationModelsResponse {
models: SegmentationModelCapability[]
}
export interface SegmentationRunRequest {
project_id: string
dataset_id: string
model_id: string
confidence_threshold: number
class_filter?: string[] | null
tile_manifest_path?: string | null
parameters_json?: Record<string, unknown>
}
export interface SegmentationRunResponse {
analysis_run_id: string
job_id: string
project_id: string
dataset_id: string
model_id: string
status: string
segmentation_count: number
error_code?: string | null
message: string
}
export interface SegmentationRunRead {
id: string
project_id: string
dataset_id?: string | null
job_id?: string | null
analysis_type: string
status: string
model_name?: string | null
model_version?: string | null
parameters_json: Record<string, unknown>
result_json?: Record<string, unknown> | null
error_message?: string | null
created_at?: string | null
started_at?: string | null
finished_at?: string | null
}
export interface SegmentationRunListResponse {
items: SegmentationRunRead[]
total: number
}
export interface SegmentationRead {
id: string
project_id: string
dataset_id?: string | null
analysis_run_id?: string | null
job_id?: string | null
model_name: string
model_version?: string | null
class_name: string
confidence?: number | null
bbox_json?: Record<string, unknown> | null
area_m2?: number | null
mask_path?: string | null
source_tile_path?: string | null
tile_index?: number | null
properties_json?: Record<string, unknown> | null
provenance_json?: Record<string, unknown> | null
created_at?: string | null
}
export interface SegmentationListResponse {
items: SegmentationRead[]
total: number
}
export interface SegmentationQaRequest {
reference_dataset_id: string
iou_threshold: number
class_name?: string | null
min_confidence?: number | null
}
export type SegmentationQaResult = DetectionQaResult
export interface QaComparisonRequest {
candidate_dataset_id: string
reference_dataset_id: string
iou_threshold: number
area_id?: string | null
}
export interface QaComparisonResult {
status: string
warnings: string[]
candidate_feature_count: number
reference_feature_count: number
matches: number
false_positives: number
false_negatives: number
precision: number | null
recall: number | null
f1_score: number | null
mean_iou: number | null
iou_threshold: number
unsupported_geometry: boolean
unsupported_geometries: string[]
generated_at: string
}
export interface MetricRead {
id: string
quality_check_id?: string | null
analysis_run_id?: string | null
metric_key: string
metric_value?: number | null
metric_unit?: string | null
label?: string | null
metadata_json?: Record<string, unknown> | null
created_at?: string | null
}
export interface QualityCheckRead {
id: string
project_id: string
job_id?: string | null
analysis_run_id?: string | null
candidate_dataset_id?: string | null
reference_dataset_id: string
check_type: string
status: string
score?: number | null
parameters_json?: Record<string, unknown> | null
findings_json?: Record<string, unknown> | null
created_at?: string | null
completed_at?: string | null
metrics: MetricRead[]
}
export interface QualityCheckListResponse {
items: QualityCheckRead[]
total: number
limit: number
offset: number
}
export type ExportKind = 'dataset' | 'detection_run' | 'segmentation_run'
export interface ExportRead {
id: string
project_id: string
analysis_run_id?: string | null
export_type: string
storage_path: string
metadata_json?: Record<string, unknown> | null
created_at?: string | null
status: string
}
export interface ExportCreateResponse {
export_id: string
path: string
status: string
export_type: string
metadata_json?: Record<string, unknown> | null
}
export interface ExportListResponse {
items: ExportRead[]
total: number
limit: number
offset: number
}
export interface ExportContentResponse {
export_id: string
export_type: string
content: Record<string, unknown>
}
View File
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"allowJs": false,
"noEmit": true,
"strict": true,
"esModuleInterop": true,
"types": ["vite/client"],
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"allowSyntheticDefaultImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*", "src/**/*.tsx", "vite.config.ts"]
}
+34
View File
@@ -0,0 +1,34 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
declare const process: { env: Record<string, string | undefined> }
const apiProxyTarget = process.env.VITE_API_PROXY_TARGET ?? 'http://localhost:8000'
export default defineConfig({
plugins: [react()],
build: {
chunkSizeWarningLimit: 900,
rollupOptions: {
output: {
manualChunks: {
maplibre: ['maplibre-gl'],
vendor: ['react', 'react-dom'],
},
},
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: apiProxyTarget,
changeOrigin: true,
},
'/health': {
target: apiProxyTarget,
changeOrigin: true,
},
},
},
})