Add V1 selected area map overlay
This commit is contained in:
@@ -7,6 +7,14 @@
|
|||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Sprint 20 selected area map overlay (2026-06-17)
|
||||||
|
|
||||||
|
- Added persisted AOI GeoJSON to area API responses without changing the database schema.
|
||||||
|
- Added a dedicated MapLibre area overlay layer with visibility and opacity controls.
|
||||||
|
- Added area list actions to choose which AOI is shown on the map.
|
||||||
|
- Added regression tests for area GeoJSON serialization and frontend map overlay wiring.
|
||||||
|
- No migrations, provider downloads, AI inference, new dependencies or API route renames were introduced.
|
||||||
|
|
||||||
## Sprint 19 V1 map workbench controls (2026-06-17)
|
## Sprint 19 V1 map workbench controls (2026-06-17)
|
||||||
|
|
||||||
- Added MapLibre layer visibility and opacity controls for the active GeoJSON workbench layer.
|
- Added MapLibre layer visibility and opacity controls for the active GeoJSON workbench layer.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models import Area
|
from app.models import Area
|
||||||
from app.schemas.area import AreaCreate, AreaRead, AreaUpdate
|
from app.schemas.area import AreaCreate, AreaUpdate
|
||||||
from app.services.area_service import AreaService
|
from app.services.area_service import AreaService
|
||||||
from app.utils.response import envelope
|
from app.utils.response import envelope
|
||||||
|
|
||||||
@@ -23,13 +23,13 @@ def list_areas(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
areas, total = AreaService.list_areas(db, project_id=project_id, limit=limit, offset=offset)
|
areas, total = AreaService.list_areas(db, project_id=project_id, limit=limit, offset=offset)
|
||||||
return envelope({"items": [AreaRead.model_validate(area).model_dump() for area in areas], "total": total, "limit": limit, "offset": offset})
|
return envelope({"items": [AreaService.serialize_area(area) for area in areas], "total": total, "limit": limit, "offset": offset})
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201, response_model=dict)
|
@router.post("", status_code=201, response_model=dict)
|
||||||
def create_area(project_id: UUID, payload: AreaCreate, db: Session = Depends(get_db)):
|
def create_area(project_id: UUID, payload: AreaCreate, db: Session = Depends(get_db)):
|
||||||
area = AreaService.create_area(db, project_id, payload)
|
area = AreaService.create_area(db, project_id, payload)
|
||||||
return envelope(AreaRead.model_validate(area).model_dump())
|
return envelope(AreaService.serialize_area(area))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{area_id}", response_model=dict)
|
@router.get("/{area_id}", response_model=dict)
|
||||||
@@ -41,7 +41,7 @@ def get_area(
|
|||||||
area = AreaService.get_area(db, area_id)
|
area = AreaService.get_area(db, area_id)
|
||||||
if area.project_id != project_id:
|
if area.project_id != project_id:
|
||||||
raise HTTPException(status_code=404, detail="Area not found")
|
raise HTTPException(status_code=404, detail="Area not found")
|
||||||
return envelope(AreaRead.model_validate(area).model_dump())
|
return envelope(AreaService.serialize_area(area))
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{area_id}", response_model=dict)
|
@router.patch("/{area_id}", response_model=dict)
|
||||||
@@ -55,4 +55,4 @@ def update_area(
|
|||||||
if not existing or existing.project_id != project_id:
|
if not existing or existing.project_id != project_id:
|
||||||
raise HTTPException(status_code=404, detail="Area not found")
|
raise HTTPException(status_code=404, detail="Area not found")
|
||||||
area = AreaService.update_area(db, area_id, payload)
|
area = AreaService.update_area(db, area_id, payload)
|
||||||
return envelope(AreaRead.model_validate(area).model_dump())
|
return envelope(AreaService.serialize_area(area))
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ from __future__ import annotations
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from geoalchemy2.shape import from_shape
|
from geoalchemy2.shape import from_shape, to_shape
|
||||||
|
from shapely.geometry import mapping
|
||||||
|
|
||||||
from app.core.errors import AppError
|
from app.core.errors import AppError
|
||||||
from app.models import Area, Project
|
from app.models import Area, Project
|
||||||
@@ -13,7 +14,13 @@ from app.utils.geometry import area_m2, geometry_bbox_polygon, normalize_to_mult
|
|||||||
|
|
||||||
class AreaService:
|
class AreaService:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def list_areas(db: Session, project_id: uuid.UUID, limit: int = 50, offset: int = 0) -> tuple[list[AreaRead], int]:
|
def serialize_area(area: Area) -> dict:
|
||||||
|
payload = AreaRead.model_validate(area).model_dump()
|
||||||
|
payload["geometry"] = mapping(to_shape(area.geometry)) if area.geometry else None
|
||||||
|
return payload
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def list_areas(db: Session, project_id: uuid.UUID, limit: int = 50, offset: int = 0) -> tuple[list[Area], int]:
|
||||||
total = db.query(Area).filter(Area.project_id == project_id).count()
|
total = db.query(Area).filter(Area.project_id == project_id).count()
|
||||||
areas = (
|
areas = (
|
||||||
db.query(Area)
|
db.query(Area)
|
||||||
@@ -23,10 +30,10 @@ class AreaService:
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return [AreaRead.model_validate(area) for area in areas], total
|
return areas, total
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create_area(db: Session, project_id: uuid.UUID, payload: AreaCreate) -> AreaRead:
|
def create_area(db: Session, project_id: uuid.UUID, payload: AreaCreate) -> Area:
|
||||||
if not db.get(Project, project_id):
|
if not db.get(Project, project_id):
|
||||||
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
|
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
|
||||||
|
|
||||||
@@ -46,17 +53,17 @@ class AreaService:
|
|||||||
db.add(area)
|
db.add(area)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(area)
|
db.refresh(area)
|
||||||
return AreaRead.model_validate(area)
|
return area
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_area(db: Session, area_id: uuid.UUID) -> AreaRead:
|
def get_area(db: Session, area_id: uuid.UUID) -> Area:
|
||||||
area = db.get(Area, area_id)
|
area = db.get(Area, area_id)
|
||||||
if not area:
|
if not area:
|
||||||
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
|
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
|
||||||
return AreaRead.model_validate(area)
|
return area
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def update_area(db: Session, area_id: uuid.UUID, payload: AreaUpdate) -> AreaRead:
|
def update_area(db: Session, area_id: uuid.UUID, payload: AreaUpdate) -> Area:
|
||||||
area = db.get(Area, area_id)
|
area = db.get(Area, area_id)
|
||||||
if not area:
|
if not area:
|
||||||
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
|
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
|
||||||
@@ -74,4 +81,4 @@ class AreaService:
|
|||||||
db.add(area)
|
db.add(area)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(area)
|
db.refresh(area)
|
||||||
return AreaRead.model_validate(area)
|
return area
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from geoalchemy2.shape import from_shape
|
||||||
|
from shapely.geometry import MultiPolygon, Polygon
|
||||||
|
|
||||||
|
from app.models import Area
|
||||||
|
from app.services.area_service import AreaService
|
||||||
|
|
||||||
|
|
||||||
|
def test_area_serializer_exposes_geojson_geometry_for_map_overlay() -> None:
|
||||||
|
project_id = uuid4()
|
||||||
|
area = Area(
|
||||||
|
id=uuid4(),
|
||||||
|
project_id=project_id,
|
||||||
|
name="Map AOI",
|
||||||
|
original_crs="EPSG:4326",
|
||||||
|
area_m2=100.0,
|
||||||
|
geometry=from_shape(
|
||||||
|
MultiPolygon(
|
||||||
|
[
|
||||||
|
Polygon(
|
||||||
|
[
|
||||||
|
(4.35, 51.28),
|
||||||
|
(4.36, 51.28),
|
||||||
|
(4.36, 51.29),
|
||||||
|
(4.35, 51.29),
|
||||||
|
(4.35, 51.28),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
srid=4326,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = AreaService.serialize_area(area)
|
||||||
|
|
||||||
|
assert payload["id"] == area.id
|
||||||
|
assert payload["project_id"] == project_id
|
||||||
|
assert payload["geometry"]["type"] == "MultiPolygon"
|
||||||
|
assert payload["geometry"]["coordinates"][0][0][0] == (4.35, 51.28)
|
||||||
|
|
||||||
|
|
||||||
|
def test_frontend_wires_selected_area_map_overlay_contract() -> None:
|
||||||
|
root = __import__("pathlib").Path(__file__).resolve().parents[2]
|
||||||
|
app = (root / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
|
||||||
|
geomap = (root / "frontend" / "src" / "components" / "GeoMap.tsx").read_text(encoding="utf-8")
|
||||||
|
area_panel = (root / "frontend" / "src" / "components" / "project" / "AreaPanel.tsx").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "selectedMapAreaId" in app
|
||||||
|
assert "areaFeatureCollection" in app
|
||||||
|
assert "areaData={areaFeatureCollection}" in app
|
||||||
|
assert "Area visible" in app
|
||||||
|
assert "area-fill" in geomap
|
||||||
|
assert "area-line" in geomap
|
||||||
|
assert "onSelectMapArea" in area_panel
|
||||||
|
assert "Show on map" in area_panel
|
||||||
+18
-1
@@ -122,7 +122,24 @@ Soft-delete in V1 preferred. Hard-delete only if storage cleanup is also impleme
|
|||||||
|
|
||||||
### GET `/api/v1/projects/{project_id}/areas`
|
### GET `/api/v1/projects/{project_id}/areas`
|
||||||
|
|
||||||
Returns areas for a project.
|
Returns areas for a project. Area responses include persisted AOI geometry as
|
||||||
|
GeoJSON so the frontend can display the selected area in the map workbench.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"project_id": "uuid",
|
||||||
|
"name": "Geel Centrum AOI",
|
||||||
|
"original_crs": "EPSG:4326",
|
||||||
|
"area_m2": 1234.5,
|
||||||
|
"created_at": "timestamp",
|
||||||
|
"geometry_type": null,
|
||||||
|
"geometry": {
|
||||||
|
"type": "MultiPolygon",
|
||||||
|
"coordinates": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### POST `/api/v1/projects/{project_id}/areas`
|
### POST `/api/v1/projects/{project_id}/areas`
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,19 @@
|
|||||||
|
## Sprint 20 V1 selected area map overlay (2026-06-17)
|
||||||
|
|
||||||
|
Changed:
|
||||||
|
- Added GeoJSON geometry serialization for project areas so persisted AOIs can be displayed by the map workbench.
|
||||||
|
- Added a dedicated MapLibre area overlay layer with separate visibility and opacity controls.
|
||||||
|
- Added area list actions and map workspace controls to select the active AOI.
|
||||||
|
- Updated API/frontend docs, changelog and TODO status for selected area display.
|
||||||
|
|
||||||
|
Tested:
|
||||||
|
- Pending validation in this pass: backend compile, backend pytest, readiness, frontend typecheck/build, Alembic checks and deploy/browser smoke.
|
||||||
|
|
||||||
|
Known limitations:
|
||||||
|
- Area geometry is displayed as a simple filled/outlined GeoJSON overlay; no drawing/editing workflow is introduced in this pass.
|
||||||
|
|
||||||
|
Next recommended pass:
|
||||||
|
- Add a small V1 workflow polish pass for richer dataset/area empty states and a fixture-driven end-to-end browser smoke once the new build is deployed.
|
||||||
## Sprint 19 V1 map workbench controls (2026-06-17)
|
## Sprint 19 V1 map workbench controls (2026-06-17)
|
||||||
|
|
||||||
Changed:
|
Changed:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ This file now starts with the current implementation status. Older preparation/b
|
|||||||
- [x] Backend FastAPI foundation, health endpoint and service structure.
|
- [x] Backend FastAPI foundation, health endpoint and service structure.
|
||||||
- [x] React/TypeScript frontend foundation and MapLibre workbench.
|
- [x] React/TypeScript frontend foundation and MapLibre workbench.
|
||||||
- [x] Map layer visibility, opacity and feature property inspection.
|
- [x] Map layer visibility, opacity and feature property inspection.
|
||||||
|
- [x] Selected project area/AOI map overlay with visibility and opacity controls.
|
||||||
- [x] SQLAlchemy/PostGIS ORM models and Alembic migration chain through Sprint 9.
|
- [x] SQLAlchemy/PostGIS ORM models and Alembic migration chain through Sprint 9.
|
||||||
- [x] Dataset upload, storage metadata and vector feature persistence.
|
- [x] Dataset upload, storage metadata and vector feature persistence.
|
||||||
- [x] Raster metadata and raster operation service boundaries.
|
- [x] Raster metadata and raster operation service boundaries.
|
||||||
@@ -89,6 +90,7 @@ This file now starts with the current implementation status. Older preparation/b
|
|||||||
- [x] Map Workbench basis
|
- [x] Map Workbench basis
|
||||||
- [x] Map layer controls
|
- [x] Map layer controls
|
||||||
- [x] Feature property inspector
|
- [x] Feature property inspector
|
||||||
|
- [x] Selected area display
|
||||||
|
|
||||||
## 4. Project & Area API
|
## 4. Project & Area API
|
||||||
|
|
||||||
|
|||||||
@@ -144,6 +144,13 @@ React + TypeScript + MapLibre foundation for project/area/dataset workflow.
|
|||||||
- Added click-to-inspect feature properties from the active MapLibre layer.
|
- Added click-to-inspect feature properties from the active MapLibre layer.
|
||||||
- Updated the app header to the V1 workbench identity instead of an old sprint label.
|
- Updated the app header to the V1 workbench identity instead of an old sprint label.
|
||||||
|
|
||||||
|
## Sprint 20 additions
|
||||||
|
|
||||||
|
- Area API responses now include persisted AOI GeoJSON for map display.
|
||||||
|
- The Map Workbench renders the selected project area as a dedicated MapLibre GeoJSON layer.
|
||||||
|
- Added area visibility and opacity controls alongside the existing active vector/result layer controls.
|
||||||
|
- Area list items can select which AOI is shown on the map.
|
||||||
|
|
||||||
## Release hardening updates
|
## Release hardening updates
|
||||||
|
|
||||||
- Production builds split application code, React vendor code and MapLibre vendor code into separate chunks.
|
- Production builds split application code, React vendor code and MapLibre vendor code into separate chunks.
|
||||||
|
|||||||
+81
-2
@@ -96,6 +96,9 @@ function App(): JSX.Element {
|
|||||||
const [datasetContent, setDatasetContent] = useState<GeoJSON.FeatureCollection | null>(null)
|
const [datasetContent, setDatasetContent] = useState<GeoJSON.FeatureCollection | null>(null)
|
||||||
const [mapLayerVisible, setMapLayerVisible] = useState(true)
|
const [mapLayerVisible, setMapLayerVisible] = useState(true)
|
||||||
const [mapLayerOpacity, setMapLayerOpacity] = useState(0.4)
|
const [mapLayerOpacity, setMapLayerOpacity] = useState(0.4)
|
||||||
|
const [selectedMapAreaId, setSelectedMapAreaId] = useState('')
|
||||||
|
const [areaLayerVisible, setAreaLayerVisible] = useState(true)
|
||||||
|
const [areaLayerOpacity, setAreaLayerOpacity] = useState(0.18)
|
||||||
const [selectedMapFeature, setSelectedMapFeature] = useState<GeoJSON.Feature | null>(null)
|
const [selectedMapFeature, setSelectedMapFeature] = useState<GeoJSON.Feature | null>(null)
|
||||||
const [jobs, setJobs] = useState<JobRead[]>([])
|
const [jobs, setJobs] = useState<JobRead[]>([])
|
||||||
const [providerCapabilities, setProviderCapabilities] = useState<ProviderCapability[]>([])
|
const [providerCapabilities, setProviderCapabilities] = useState<ProviderCapability[]>([])
|
||||||
@@ -243,6 +246,31 @@ function App(): JSX.Element {
|
|||||||
() => segmentationModels.find((model) => model.model_id === selectedSegmentationModelId) ?? null,
|
() => segmentationModels.find((model) => model.model_id === selectedSegmentationModelId) ?? null,
|
||||||
[segmentationModels, selectedSegmentationModelId],
|
[segmentationModels, selectedSegmentationModelId],
|
||||||
)
|
)
|
||||||
|
const selectedMapArea = useMemo(
|
||||||
|
() => areas.find((area) => area.id === selectedMapAreaId) ?? null,
|
||||||
|
[areas, selectedMapAreaId],
|
||||||
|
)
|
||||||
|
const areaFeatureCollection = useMemo<GeoJSON.FeatureCollection | null>(() => {
|
||||||
|
if (!selectedMapArea?.geometry) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: selectedMapArea.geometry,
|
||||||
|
properties: {
|
||||||
|
layer_type: 'project_area',
|
||||||
|
area_id: selectedMapArea.id,
|
||||||
|
name: selectedMapArea.name,
|
||||||
|
area_m2: selectedMapArea.area_m2 ?? null,
|
||||||
|
original_crs: selectedMapArea.original_crs ?? null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}, [selectedMapArea])
|
||||||
const mapFeatureCollection = useMemo(
|
const mapFeatureCollection = useMemo(
|
||||||
() => changeDetectionResult?.geojson ?? segmentationGeoJson ?? detectionGeoJson ?? datasetContent,
|
() => changeDetectionResult?.geojson ?? segmentationGeoJson ?? detectionGeoJson ?? datasetContent,
|
||||||
[changeDetectionResult, segmentationGeoJson, detectionGeoJson, datasetContent],
|
[changeDetectionResult, segmentationGeoJson, detectionGeoJson, datasetContent],
|
||||||
@@ -263,6 +291,7 @@ function App(): JSX.Element {
|
|||||||
return 'No active vector layer'
|
return 'No active vector layer'
|
||||||
}, [changeDetectionResult, datasetContent, detectionGeoJson, segmentationGeoJson, selectedDataset])
|
}, [changeDetectionResult, datasetContent, detectionGeoJson, segmentationGeoJson, selectedDataset])
|
||||||
const mapFeatureCount = mapFeatureCollection?.features.length ?? 0
|
const mapFeatureCount = mapFeatureCollection?.features.length ?? 0
|
||||||
|
const areaFeatureCount = areaFeatureCollection?.features.length ?? 0
|
||||||
const isRasterTileInputValid = useMemo(
|
const isRasterTileInputValid = useMemo(
|
||||||
() => rasterTileSize > 0 && rasterTileOverlap >= 0 && rasterTileOverlap < rasterTileSize,
|
() => rasterTileSize > 0 && rasterTileOverlap >= 0 && rasterTileOverlap < rasterTileSize,
|
||||||
[rasterTileSize, rasterTileOverlap],
|
[rasterTileSize, rasterTileOverlap],
|
||||||
@@ -313,6 +342,11 @@ function App(): JSX.Element {
|
|||||||
if (!selectedClipAreaId && areaResponse.items.length > 0) {
|
if (!selectedClipAreaId && areaResponse.items.length > 0) {
|
||||||
setSelectedClipAreaId(areaResponse.items[0].id)
|
setSelectedClipAreaId(areaResponse.items[0].id)
|
||||||
}
|
}
|
||||||
|
if (areaResponse.items.length === 0) {
|
||||||
|
setSelectedMapAreaId('')
|
||||||
|
} else if (!selectedMapAreaId || !areaResponse.items.some((area) => area.id === selectedMapAreaId)) {
|
||||||
|
setSelectedMapAreaId(areaResponse.items[0].id)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setErrorMessage(error instanceof Error ? error.message : 'Failed to load project data')
|
setErrorMessage(error instanceof Error ? error.message : 'Failed to load project data')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -645,7 +679,7 @@ function App(): JSX.Element {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedMapFeature(null)
|
setSelectedMapFeature(null)
|
||||||
}, [mapFeatureCollection])
|
}, [mapFeatureCollection, areaFeatureCollection])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedProjectId) {
|
if (!selectedProjectId) {
|
||||||
@@ -653,6 +687,7 @@ function App(): JSX.Element {
|
|||||||
setDatasets([])
|
setDatasets([])
|
||||||
setSelectedDatasetId(null)
|
setSelectedDatasetId(null)
|
||||||
setSelectedDataset(null)
|
setSelectedDataset(null)
|
||||||
|
setSelectedMapAreaId('')
|
||||||
setSelectedDatasetSummary(null)
|
setSelectedDatasetSummary(null)
|
||||||
setSelectedRasterMetadata(null)
|
setSelectedRasterMetadata(null)
|
||||||
setSelectedRasterStats(null)
|
setSelectedRasterStats(null)
|
||||||
@@ -716,6 +751,7 @@ function App(): JSX.Element {
|
|||||||
const result = await demoApi.seedWorkflow()
|
const result = await demoApi.seedWorkflow()
|
||||||
setSelectedProjectId(result.project_id)
|
setSelectedProjectId(result.project_id)
|
||||||
setSelectedDatasetId(result.candidate_dataset_id)
|
setSelectedDatasetId(result.candidate_dataset_id)
|
||||||
|
setSelectedMapAreaId(result.area_id)
|
||||||
setQaCandidateDatasetId(result.candidate_dataset_id)
|
setQaCandidateDatasetId(result.candidate_dataset_id)
|
||||||
setQaReferenceDatasetId(result.reference_dataset_id)
|
setQaReferenceDatasetId(result.reference_dataset_id)
|
||||||
setQaAreaId(result.area_id)
|
setQaAreaId(result.area_id)
|
||||||
@@ -751,11 +787,12 @@ function App(): JSX.Element {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await areasApi.create(selectedProjectId, {
|
const createdArea = await areasApi.create(selectedProjectId, {
|
||||||
name: areaForm.name,
|
name: areaForm.name,
|
||||||
crs: areaForm.crs,
|
crs: areaForm.crs,
|
||||||
geometry,
|
geometry,
|
||||||
})
|
})
|
||||||
|
setSelectedMapAreaId(createdArea.id)
|
||||||
await loadProjectData(selectedProjectId)
|
await loadProjectData(selectedProjectId)
|
||||||
setAreaForm((previous) => ({ ...previous, name: '' }))
|
setAreaForm((previous) => ({ ...previous, name: '' }))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1349,8 +1386,10 @@ function App(): JSX.Element {
|
|||||||
selectedProjectId={selectedProjectId}
|
selectedProjectId={selectedProjectId}
|
||||||
loadingAreas={loadingAreas}
|
loadingAreas={loadingAreas}
|
||||||
areaForm={areaForm}
|
areaForm={areaForm}
|
||||||
|
selectedMapAreaId={selectedMapAreaId}
|
||||||
onCreateArea={createArea}
|
onCreateArea={createArea}
|
||||||
onUpdateAreaForm={setAreaForm}
|
onUpdateAreaForm={setAreaForm}
|
||||||
|
onSelectMapArea={setSelectedMapAreaId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ProviderPanel
|
<ProviderPanel
|
||||||
@@ -1851,6 +1890,42 @@ function App(): JSX.Element {
|
|||||||
<h2>Map workspace</h2>
|
<h2>Map workspace</h2>
|
||||||
<p>{mapLayerLabel}</p>
|
<p>{mapLayerLabel}</p>
|
||||||
<div className="map-controls">
|
<div className="map-controls">
|
||||||
|
<label>
|
||||||
|
Selected area
|
||||||
|
<select
|
||||||
|
value={selectedMapAreaId}
|
||||||
|
onChange={(event) => setSelectedMapAreaId(event.target.value)}
|
||||||
|
disabled={areas.length === 0}
|
||||||
|
>
|
||||||
|
<option value="">No area</option>
|
||||||
|
{areas.map((area) => (
|
||||||
|
<option key={area.id} value={area.id}>
|
||||||
|
{area.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="checkbox-row">
|
||||||
|
<input
|
||||||
|
checked={areaLayerVisible}
|
||||||
|
disabled={!areaFeatureCollection}
|
||||||
|
type="checkbox"
|
||||||
|
onChange={(event) => setAreaLayerVisible(event.target.checked)}
|
||||||
|
/>
|
||||||
|
Area visible
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Area opacity
|
||||||
|
<input
|
||||||
|
disabled={!areaFeatureCollection}
|
||||||
|
max="0.7"
|
||||||
|
min="0.05"
|
||||||
|
step="0.05"
|
||||||
|
type="range"
|
||||||
|
value={areaLayerOpacity}
|
||||||
|
onChange={(event) => setAreaLayerOpacity(Number(event.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<label className="checkbox-row">
|
<label className="checkbox-row">
|
||||||
<input
|
<input
|
||||||
checked={mapLayerVisible}
|
checked={mapLayerVisible}
|
||||||
@@ -1873,13 +1948,17 @@ function App(): JSX.Element {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="map-status">
|
<div className="map-status">
|
||||||
|
{areaFeatureCollection ? `${areaFeatureCount} area loaded` : 'No area loaded'} -{' '}
|
||||||
{mapFeatureCollection ? `${mapFeatureCount} features loaded` : 'No vector layer loaded'}
|
{mapFeatureCollection ? `${mapFeatureCount} features loaded` : 'No vector layer loaded'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<GeoMap
|
<GeoMap
|
||||||
data={mapFeatureCollection}
|
data={mapFeatureCollection}
|
||||||
|
areaData={areaFeatureCollection}
|
||||||
visible={mapLayerVisible}
|
visible={mapLayerVisible}
|
||||||
opacity={mapLayerOpacity}
|
opacity={mapLayerOpacity}
|
||||||
|
areaVisible={areaLayerVisible}
|
||||||
|
areaOpacity={areaLayerOpacity}
|
||||||
onFeatureSelect={setSelectedMapFeature}
|
onFeatureSelect={setSelectedMapFeature}
|
||||||
/>
|
/>
|
||||||
<div className="feature-inspector">
|
<div className="feature-inspector">
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import 'maplibre-gl/dist/maplibre-gl.css'
|
|||||||
|
|
||||||
interface GeoMapProps {
|
interface GeoMapProps {
|
||||||
data: GeoJSON.FeatureCollection | null
|
data: GeoJSON.FeatureCollection | null
|
||||||
|
areaData?: GeoJSON.FeatureCollection | null
|
||||||
visible?: boolean
|
visible?: boolean
|
||||||
opacity?: number
|
opacity?: number
|
||||||
|
areaVisible?: boolean
|
||||||
|
areaOpacity?: number
|
||||||
onFeatureSelect?: (feature: GeoJSON.Feature | null) => void
|
onFeatureSelect?: (feature: GeoJSON.Feature | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +46,20 @@ function collectCoordinates(featureCollection: GeoJSON.FeatureCollection): mapli
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
function GeoMap({ data, visible = true, opacity = 0.4, onFeatureSelect }: GeoMapProps): JSX.Element {
|
function mergeFeatureCollections(collections: Array<GeoJSON.FeatureCollection | null | undefined>): GeoJSON.FeatureCollection | null {
|
||||||
|
const features = collections.flatMap((collection) => collection?.features ?? [])
|
||||||
|
return features.length > 0 ? { type: 'FeatureCollection', features } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function GeoMap({
|
||||||
|
data,
|
||||||
|
areaData = null,
|
||||||
|
visible = true,
|
||||||
|
opacity = 0.4,
|
||||||
|
areaVisible = true,
|
||||||
|
areaOpacity = 0.18,
|
||||||
|
onFeatureSelect,
|
||||||
|
}: GeoMapProps): JSX.Element {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||||
const mapRef = useRef<maplibregl.Map | null>(null)
|
const mapRef = useRef<maplibregl.Map | null>(null)
|
||||||
const onFeatureSelectRef = useRef<GeoMapProps['onFeatureSelect']>(onFeatureSelect)
|
const onFeatureSelectRef = useRef<GeoMapProps['onFeatureSelect']>(onFeatureSelect)
|
||||||
@@ -65,12 +81,13 @@ function GeoMap({ data, visible = true, opacity = 0.4, onFeatureSelect }: GeoMap
|
|||||||
})
|
})
|
||||||
map.addControl(new maplibregl.NavigationControl(), 'top-right')
|
map.addControl(new maplibregl.NavigationControl(), 'top-right')
|
||||||
map.on('click', (event) => {
|
map.on('click', (event) => {
|
||||||
if (!map.getLayer('dataset-fill') || !map.getLayer('dataset-line')) {
|
const layers = ['dataset-fill', 'dataset-line', 'area-fill', 'area-line'].filter((layerId) => map.getLayer(layerId))
|
||||||
|
if (layers.length === 0) {
|
||||||
onFeatureSelectRef.current?.(null)
|
onFeatureSelectRef.current?.(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const features = map.queryRenderedFeatures(event.point, {
|
const features = map.queryRenderedFeatures(event.point, {
|
||||||
layers: ['dataset-fill', 'dataset-line'],
|
layers,
|
||||||
})
|
})
|
||||||
if (features.length === 0) {
|
if (features.length === 0) {
|
||||||
onFeatureSelectRef.current?.(null)
|
onFeatureSelectRef.current?.(null)
|
||||||
@@ -159,6 +176,63 @@ function GeoMap({ data, visible = true, opacity = 0.4, onFeatureSelect }: GeoMap
|
|||||||
}
|
}
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current
|
||||||
|
if (!map) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map.getSource('area')) {
|
||||||
|
if (areaData) {
|
||||||
|
;(map.getSource('area') as maplibregl.GeoJSONSource).setData(areaData)
|
||||||
|
} else {
|
||||||
|
if (map.getLayer('area-fill')) {
|
||||||
|
map.removeLayer('area-fill')
|
||||||
|
}
|
||||||
|
if (map.getLayer('area-line')) {
|
||||||
|
map.removeLayer('area-line')
|
||||||
|
}
|
||||||
|
map.removeSource('area')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if (areaData) {
|
||||||
|
map.addSource('area', { type: 'geojson', data: areaData })
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: 'area-fill',
|
||||||
|
type: 'fill',
|
||||||
|
source: 'area',
|
||||||
|
paint: {
|
||||||
|
'fill-color': '#0f766e',
|
||||||
|
'fill-opacity': 0.18,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
map.getLayer('dataset-fill') ? 'dataset-fill' : undefined,
|
||||||
|
)
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: 'area-line',
|
||||||
|
type: 'line',
|
||||||
|
source: 'area',
|
||||||
|
paint: {
|
||||||
|
'line-color': '#0f766e',
|
||||||
|
'line-width': 3,
|
||||||
|
'line-dasharray': [2, 1],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
map.getLayer('dataset-line') ? 'dataset-line' : undefined,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeCollection = mergeFeatureCollections([areaData, data])
|
||||||
|
if (activeCollection) {
|
||||||
|
const bounds = collectCoordinates(activeCollection)
|
||||||
|
if (bounds) {
|
||||||
|
map.fitBounds(bounds, { padding: 40 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [areaData, data])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current
|
const map = mapRef.current
|
||||||
if (!map) {
|
if (!map) {
|
||||||
@@ -175,6 +249,22 @@ function GeoMap({ data, visible = true, opacity = 0.4, onFeatureSelect }: GeoMap
|
|||||||
}
|
}
|
||||||
}, [visible, opacity, data])
|
}, [visible, opacity, data])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current
|
||||||
|
if (!map) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const visibility = areaVisible ? 'visible' : 'none'
|
||||||
|
if (map.getLayer('area-fill')) {
|
||||||
|
map.setLayoutProperty('area-fill', 'visibility', visibility)
|
||||||
|
map.setPaintProperty('area-fill', 'fill-opacity', areaOpacity)
|
||||||
|
}
|
||||||
|
if (map.getLayer('area-line')) {
|
||||||
|
map.setLayoutProperty('area-line', 'visibility', visibility)
|
||||||
|
map.setPaintProperty('area-line', 'line-opacity', areaVisible ? 1 : 0)
|
||||||
|
}
|
||||||
|
}, [areaVisible, areaOpacity, areaData])
|
||||||
|
|
||||||
return <div className="map-container" ref={containerRef} />
|
return <div className="map-container" ref={containerRef} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ interface AreaPanelProps {
|
|||||||
selectedProjectId: string | null
|
selectedProjectId: string | null
|
||||||
loadingAreas: boolean
|
loadingAreas: boolean
|
||||||
areaForm: AreaFormState
|
areaForm: AreaFormState
|
||||||
|
selectedMapAreaId: string
|
||||||
onCreateArea: (event: FormEvent<HTMLFormElement>) => void
|
onCreateArea: (event: FormEvent<HTMLFormElement>) => void
|
||||||
onUpdateAreaForm: (areaForm: AreaFormState) => void
|
onUpdateAreaForm: (areaForm: AreaFormState) => void
|
||||||
|
onSelectMapArea: (areaId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AreaPanel({
|
export function AreaPanel({
|
||||||
@@ -23,8 +25,10 @@ export function AreaPanel({
|
|||||||
selectedProjectId,
|
selectedProjectId,
|
||||||
loadingAreas,
|
loadingAreas,
|
||||||
areaForm,
|
areaForm,
|
||||||
|
selectedMapAreaId,
|
||||||
onCreateArea,
|
onCreateArea,
|
||||||
onUpdateAreaForm,
|
onUpdateAreaForm,
|
||||||
|
onSelectMapArea,
|
||||||
}: AreaPanelProps): JSX.Element {
|
}: AreaPanelProps): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
@@ -57,7 +61,11 @@ export function AreaPanel({
|
|||||||
<ul>
|
<ul>
|
||||||
{areas.map((area) => (
|
{areas.map((area) => (
|
||||||
<li key={area.id}>
|
<li key={area.id}>
|
||||||
{area.name} · {area.area_m2 ? `${area.area_m2.toFixed(2)} m²` : 'n/a'}
|
<strong>{area.name}</strong> - {area.area_m2 ? `${area.area_m2.toFixed(2)} m2` : 'n/a'}
|
||||||
|
<div>geometry: {area.geometry?.type ?? area.geometry_type ?? 'n/a'}</div>
|
||||||
|
<button type="button" onClick={() => onSelectMapArea(area.id)} disabled={!area.geometry}>
|
||||||
|
{selectedMapAreaId === area.id ? 'Shown on map' : 'Show on map'}
|
||||||
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export interface AreaRead {
|
|||||||
area_m2?: number | null
|
area_m2?: number | null
|
||||||
created_at?: string | null
|
created_at?: string | null
|
||||||
geometry_type?: string | null
|
geometry_type?: string | null
|
||||||
|
geometry?: GeoJSON.Polygon | GeoJSON.MultiPolygon | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AreaCreate {
|
export interface AreaCreate {
|
||||||
|
|||||||
Reference in New Issue
Block a user