Add map area selection extraction
This commit is contained in:
@@ -7,6 +7,15 @@
|
|||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Sprint 106 Map area selection extract (2026-06-25)
|
||||||
|
|
||||||
|
- Added a read-only bbox selection endpoint for vector datasets: `POST /api/v1/projects/{project_id}/datasets/{dataset_id}/vector/select`.
|
||||||
|
- The endpoint queries persisted PostGIS `vector_features` and returns a canonical-envelope GeoJSON FeatureCollection with selection bbox, feature count, limit and truncation state.
|
||||||
|
- Added Map workspace area selection with two-click bbox drawing, manual EPSG:4326 bbox inputs, selected-feature/AOI/layer bbox shortcuts and client-side GeoJSON download/copy.
|
||||||
|
- Added MapLibre overlays for the active bbox and extracted selection result.
|
||||||
|
- Added regression coverage for backend selection behavior, route envelope and frontend wiring.
|
||||||
|
- No migrations, provider fetching, AI behavior, real model dependencies or new product domains were introduced.
|
||||||
|
|
||||||
## Sprint 105 Map feature extract (2026-06-25)
|
## Sprint 105 Map feature extract (2026-06-25)
|
||||||
|
|
||||||
- Added a `Selection & extract` panel to the Map workspace for clicked map features.
|
- Added a `Selection & extract` panel to the Map workspace for clicked map features.
|
||||||
|
|||||||
@@ -574,6 +574,14 @@ history, known limitations and print-friendly CSS.
|
|||||||
This remains a simple HTML export. It does not add a PDF designer, report
|
This remains a simple HTML export. It does not add a PDF designer, report
|
||||||
builder, live provider fetching or new analysis behavior.
|
builder, live provider fetching or new analysis behavior.
|
||||||
|
|
||||||
|
## Vector area selection
|
||||||
|
|
||||||
|
`POST /api/v1/projects/{project_id}/datasets/{dataset_id}/vector/select` runs a
|
||||||
|
read-only EPSG:4326 bbox query against persisted PostGIS `vector_features` and
|
||||||
|
returns a canonical-envelope GeoJSON FeatureCollection. It is intended for the
|
||||||
|
Map workspace area-extract flow and does not create derived datasets or export
|
||||||
|
records.
|
||||||
|
|
||||||
## Helpful repository scripts
|
## Helpful repository scripts
|
||||||
|
|
||||||
- `bash scripts/backend_install.sh`
|
- `bash scripts/backend_install.sh`
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ from app.schemas import (
|
|||||||
VectorBufferRequest,
|
VectorBufferRequest,
|
||||||
VectorClipRequest,
|
VectorClipRequest,
|
||||||
VectorIntersectRequest,
|
VectorIntersectRequest,
|
||||||
|
VectorSelectionBBox,
|
||||||
|
VectorSelectionRequest,
|
||||||
|
VectorSelectionResponse,
|
||||||
)
|
)
|
||||||
from app.schemas.job import JobCreate
|
from app.schemas.job import JobCreate
|
||||||
from app.schemas.dataset import DatasetCreateResponse
|
from app.schemas.dataset import DatasetCreateResponse
|
||||||
@@ -31,6 +34,7 @@ from app.schemas.operations import VectorOperationResult
|
|||||||
from app.services.job_service import JobService
|
from app.services.job_service import JobService
|
||||||
from app.services.raster_operations_service import RasterOperationsService
|
from app.services.raster_operations_service import RasterOperationsService
|
||||||
from app.services.vector_operations_service import VectorOperationsService
|
from app.services.vector_operations_service import VectorOperationsService
|
||||||
|
from app.services.vector_feature_service import VectorFeatureService
|
||||||
from app.services.dataset_service import DatasetService
|
from app.services.dataset_service import DatasetService
|
||||||
from app.utils.response import envelope
|
from app.utils.response import envelope
|
||||||
|
|
||||||
@@ -180,6 +184,27 @@ def vector_stats(
|
|||||||
return envelope(VectorOperationsService.stats(db, dataset_id))
|
return envelope(VectorOperationsService.stats(db, dataset_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/datasets/{dataset_id}/vector/select", response_model=dict)
|
||||||
|
def select_vector_features(
|
||||||
|
project_id: UUID,
|
||||||
|
dataset_id: UUID,
|
||||||
|
payload: VectorSelectionRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
dataset = DatasetService.get_dataset(db, dataset_id)
|
||||||
|
if dataset.project_id != project_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Dataset not found")
|
||||||
|
if dataset.dataset_type not in {"vector", "geojson"}:
|
||||||
|
raise AppError(code="DATASET_NOT_VECTOR", message="Area selection requires a vector dataset", status_code=400)
|
||||||
|
result = VectorFeatureService.select_features_by_bbox(
|
||||||
|
db,
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
bbox=payload.bbox.model_dump(),
|
||||||
|
limit=payload.limit,
|
||||||
|
)
|
||||||
|
return envelope(VectorSelectionResponse(**result).model_dump())
|
||||||
|
|
||||||
|
|
||||||
@router.post("/datasets/{dataset_id}/vector/clip", status_code=201, response_model=dict)
|
@router.post("/datasets/{dataset_id}/vector/clip", status_code=201, response_model=dict)
|
||||||
def clip_vector_dataset(
|
def clip_vector_dataset(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ from .operations import (
|
|||||||
VectorIntersectRequest,
|
VectorIntersectRequest,
|
||||||
VectorOperationRequest,
|
VectorOperationRequest,
|
||||||
VectorOperationResult,
|
VectorOperationResult,
|
||||||
|
VectorSelectionBBox,
|
||||||
|
VectorSelectionRequest,
|
||||||
|
VectorSelectionResponse,
|
||||||
VectorStatsRequest,
|
VectorStatsRequest,
|
||||||
VectorStatsResponse,
|
VectorStatsResponse,
|
||||||
)
|
)
|
||||||
@@ -122,6 +125,9 @@ __all__ = [
|
|||||||
"VectorIntersectRequest",
|
"VectorIntersectRequest",
|
||||||
"VectorOperationRequest",
|
"VectorOperationRequest",
|
||||||
"VectorOperationResult",
|
"VectorOperationResult",
|
||||||
|
"VectorSelectionBBox",
|
||||||
|
"VectorSelectionRequest",
|
||||||
|
"VectorSelectionResponse",
|
||||||
"RasterClipRequest",
|
"RasterClipRequest",
|
||||||
"RasterStatsResponse",
|
"RasterStatsResponse",
|
||||||
"RasterReprojectRequest",
|
"RasterReprojectRequest",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
class VectorOperationResult(BaseModel):
|
class VectorOperationResult(BaseModel):
|
||||||
@@ -191,3 +191,31 @@ class VectorStatsResponse(BaseModel):
|
|||||||
geometry_type_summary: dict[str, int]
|
geometry_type_summary: dict[str, int]
|
||||||
bounds_json: dict | None
|
bounds_json: dict | None
|
||||||
crs: str | None = None
|
crs: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class VectorSelectionBBox(BaseModel):
|
||||||
|
min_x: float
|
||||||
|
min_y: float
|
||||||
|
max_x: float
|
||||||
|
max_y: float
|
||||||
|
crs: str = "EPSG:4326"
|
||||||
|
|
||||||
|
@field_validator("crs")
|
||||||
|
@classmethod
|
||||||
|
def validate_crs(cls, value: str) -> str:
|
||||||
|
if value.upper() != "EPSG:4326":
|
||||||
|
raise ValueError("Only EPSG:4326 bbox selection is supported")
|
||||||
|
return "EPSG:4326"
|
||||||
|
|
||||||
|
|
||||||
|
class VectorSelectionRequest(BaseModel):
|
||||||
|
bbox: VectorSelectionBBox
|
||||||
|
limit: int = Field(default=100, ge=1, le=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class VectorSelectionResponse(BaseModel):
|
||||||
|
selection_bbox: VectorSelectionBBox
|
||||||
|
feature_count: int
|
||||||
|
limit: int
|
||||||
|
truncated: bool
|
||||||
|
geojson: dict
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
from geoalchemy2.functions import ST_Intersects, ST_MakeEnvelope
|
||||||
from geoalchemy2.shape import from_shape
|
from geoalchemy2.shape import from_shape
|
||||||
|
from geoalchemy2.shape import to_shape
|
||||||
|
from shapely.geometry import mapping
|
||||||
from shapely.geometry import shape
|
from shapely.geometry import shape
|
||||||
from shapely.validation import make_valid
|
from shapely.validation import make_valid
|
||||||
|
|
||||||
@@ -12,6 +15,117 @@ from app.models import VectorFeature
|
|||||||
|
|
||||||
|
|
||||||
class VectorFeatureService:
|
class VectorFeatureService:
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_selection_bbox(bbox: dict[str, Any]) -> dict[str, float | str]:
|
||||||
|
try:
|
||||||
|
min_x = float(bbox["min_x"])
|
||||||
|
min_y = float(bbox["min_y"])
|
||||||
|
max_x = float(bbox["max_x"])
|
||||||
|
max_y = float(bbox["max_y"])
|
||||||
|
except (KeyError, TypeError, ValueError) as exc:
|
||||||
|
raise AppError(
|
||||||
|
code="INVALID_SELECTION_BBOX",
|
||||||
|
message="Selection bbox must include numeric min_x, min_y, max_x and max_y values",
|
||||||
|
status_code=400,
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
crs = str(bbox.get("crs") or "EPSG:4326").upper()
|
||||||
|
if crs != "EPSG:4326":
|
||||||
|
raise AppError(
|
||||||
|
code="UNSUPPORTED_SELECTION_CRS",
|
||||||
|
message="Map selection currently supports EPSG:4326 bbox coordinates only",
|
||||||
|
details={"crs": crs},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
if min_x >= max_x or min_y >= max_y:
|
||||||
|
raise AppError(
|
||||||
|
code="INVALID_SELECTION_BBOX",
|
||||||
|
message="Selection bbox must have min_x < max_x and min_y < max_y",
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
if min_x < -180 or max_x > 180 or min_y < -90 or max_y > 90:
|
||||||
|
raise AppError(
|
||||||
|
code="INVALID_SELECTION_BBOX",
|
||||||
|
message="Selection bbox is outside EPSG:4326 longitude/latitude bounds",
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"min_x": min_x, "min_y": min_y, "max_x": max_x, "max_y": max_y, "crs": "EPSG:4326"}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _row_to_geojson_feature(row: VectorFeature) -> dict[str, Any]:
|
||||||
|
geometry_value = row.geometry
|
||||||
|
try:
|
||||||
|
geometry = geometry_value if hasattr(geometry_value, "__geo_interface__") else to_shape(geometry_value)
|
||||||
|
except Exception as exc:
|
||||||
|
raise AppError(
|
||||||
|
code="INVALID_VECTOR_FEATURE_GEOMETRY",
|
||||||
|
message="Persisted vector feature geometry could not be converted to GeoJSON",
|
||||||
|
details={"vector_feature_id": str(row.id)},
|
||||||
|
status_code=500,
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
properties = dict(row.properties_json or {})
|
||||||
|
properties.update(
|
||||||
|
{
|
||||||
|
"vector_feature_id": str(row.id),
|
||||||
|
"dataset_id": str(row.dataset_id),
|
||||||
|
"source_feature_id": row.source_feature_id,
|
||||||
|
"feature_class": row.feature_class,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "Feature",
|
||||||
|
"id": str(row.id),
|
||||||
|
"geometry": mapping(geometry),
|
||||||
|
"properties": properties,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def select_features_by_bbox(
|
||||||
|
db,
|
||||||
|
dataset_id: UUID,
|
||||||
|
bbox: dict[str, Any],
|
||||||
|
limit: int = 100,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
normalized_bbox = VectorFeatureService._normalize_selection_bbox(bbox)
|
||||||
|
safe_limit = max(1, min(int(limit), 1000))
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
db.query(VectorFeature)
|
||||||
|
.filter(VectorFeature.dataset_id == dataset_id)
|
||||||
|
.filter(
|
||||||
|
ST_Intersects(
|
||||||
|
VectorFeature.geometry,
|
||||||
|
ST_MakeEnvelope(
|
||||||
|
normalized_bbox["min_x"],
|
||||||
|
normalized_bbox["min_y"],
|
||||||
|
normalized_bbox["max_x"],
|
||||||
|
normalized_bbox["max_y"],
|
||||||
|
4326,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(VectorFeature.created_at.asc())
|
||||||
|
.limit(safe_limit + 1)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
truncated = len(rows) > safe_limit
|
||||||
|
selected_rows = rows[:safe_limit]
|
||||||
|
features = [VectorFeatureService._row_to_geojson_feature(row) for row in selected_rows]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"selection_bbox": normalized_bbox,
|
||||||
|
"feature_count": len(features),
|
||||||
|
"limit": safe_limit,
|
||||||
|
"truncated": truncated,
|
||||||
|
"geojson": {
|
||||||
|
"type": "FeatureCollection",
|
||||||
|
"features": features,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def persist_geojson_features(
|
def persist_geojson_features(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from geoalchemy2.shape import from_shape
|
||||||
|
from shapely.geometry import Polygon
|
||||||
|
|
||||||
|
from app.core.errors import AppError
|
||||||
|
from app.models import Dataset, VectorFeature
|
||||||
|
from app.services.vector_feature_service import VectorFeatureService
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeQuery:
|
||||||
|
def __init__(self, rows: list[VectorFeature]) -> None:
|
||||||
|
self.rows = rows
|
||||||
|
self.limit_value: int | None = None
|
||||||
|
|
||||||
|
def filter(self, *args, **kwargs): # noqa: ANN002, ANN003
|
||||||
|
return self
|
||||||
|
|
||||||
|
def order_by(self, *args, **kwargs): # noqa: ANN002, ANN003
|
||||||
|
return self
|
||||||
|
|
||||||
|
def limit(self, value: int):
|
||||||
|
self.limit_value = value
|
||||||
|
return self
|
||||||
|
|
||||||
|
def all(self) -> list[VectorFeature]:
|
||||||
|
if self.limit_value is None:
|
||||||
|
return self.rows
|
||||||
|
return self.rows[: self.limit_value]
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSession:
|
||||||
|
def __init__(self, rows: list[VectorFeature]) -> None:
|
||||||
|
self.rows = rows
|
||||||
|
|
||||||
|
def query(self, model): # noqa: ANN001
|
||||||
|
assert model is VectorFeature
|
||||||
|
return _FakeQuery(self.rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _feature_row(dataset_id: uuid.UUID, *, source_feature_id: str, name: str) -> VectorFeature:
|
||||||
|
return VectorFeature(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
feature_class="parcel",
|
||||||
|
source_feature_id=source_feature_id,
|
||||||
|
properties_json={"name": name},
|
||||||
|
geometry=from_shape(
|
||||||
|
Polygon(
|
||||||
|
[
|
||||||
|
(5.0, 51.0),
|
||||||
|
(5.001, 51.0),
|
||||||
|
(5.001, 51.001),
|
||||||
|
(5.0, 51.001),
|
||||||
|
(5.0, 51.0),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
srid=4326,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_vector_feature_service_extracts_bbox_geojson_from_persisted_rows() -> None:
|
||||||
|
dataset_id = uuid.uuid4()
|
||||||
|
rows = [_feature_row(dataset_id, source_feature_id="src-1", name="Test parcel")]
|
||||||
|
|
||||||
|
result = VectorFeatureService.select_features_by_bbox(
|
||||||
|
_FakeSession(rows),
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
bbox={"min_x": 4.9, "min_y": 50.9, "max_x": 5.2, "max_y": 51.2, "crs": "EPSG:4326"},
|
||||||
|
limit=25,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["feature_count"] == 1
|
||||||
|
assert result["truncated"] is False
|
||||||
|
assert result["geojson"]["type"] == "FeatureCollection"
|
||||||
|
feature = result["geojson"]["features"][0]
|
||||||
|
assert feature["type"] == "Feature"
|
||||||
|
assert feature["properties"]["vector_feature_id"] == str(rows[0].id)
|
||||||
|
assert feature["properties"]["source_feature_id"] == "src-1"
|
||||||
|
assert feature["properties"]["feature_class"] == "parcel"
|
||||||
|
assert feature["properties"]["name"] == "Test parcel"
|
||||||
|
assert feature["geometry"]["type"] == "Polygon"
|
||||||
|
|
||||||
|
|
||||||
|
def test_vector_feature_service_rejects_invalid_bbox() -> None:
|
||||||
|
try:
|
||||||
|
VectorFeatureService.select_features_by_bbox(
|
||||||
|
_FakeSession([]),
|
||||||
|
dataset_id=uuid.uuid4(),
|
||||||
|
bbox={"min_x": 5.2, "min_y": 50.9, "max_x": 5.0, "max_y": 51.2, "crs": "EPSG:4326"},
|
||||||
|
)
|
||||||
|
except AppError as exc:
|
||||||
|
assert exc.code == "INVALID_SELECTION_BBOX"
|
||||||
|
else: # pragma: no cover
|
||||||
|
raise AssertionError("Expected INVALID_SELECTION_BBOX")
|
||||||
|
|
||||||
|
|
||||||
|
def test_vector_select_route_is_project_scoped_and_enveloped(monkeypatch) -> None:
|
||||||
|
from app.api.routes import datasets as dataset_routes
|
||||||
|
|
||||||
|
project_id = uuid.uuid4()
|
||||||
|
dataset_id = uuid.uuid4()
|
||||||
|
dataset = Dataset(id=dataset_id, project_id=project_id, dataset_type="vector", source="fixture", name="Vector")
|
||||||
|
expected_payload = {
|
||||||
|
"selection_bbox": {"min_x": 4.9, "min_y": 50.9, "max_x": 5.2, "max_y": 51.2, "crs": "EPSG:4326"},
|
||||||
|
"feature_count": 0,
|
||||||
|
"limit": 100,
|
||||||
|
"truncated": False,
|
||||||
|
"geojson": {"type": "FeatureCollection", "features": []},
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(dataset_routes.DatasetService, "get_dataset", lambda db, selected_id: dataset)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
dataset_routes.VectorFeatureService,
|
||||||
|
"select_features_by_bbox",
|
||||||
|
lambda db, dataset_id, bbox, limit=100: expected_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = dataset_routes.select_vector_features(
|
||||||
|
project_id=project_id,
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
payload=dataset_routes.VectorSelectionRequest(
|
||||||
|
bbox=dataset_routes.VectorSelectionBBox(min_x=4.9, min_y=50.9, max_x=5.2, max_y=51.2),
|
||||||
|
limit=100,
|
||||||
|
),
|
||||||
|
db=SimpleNamespace(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response == {"data": expected_payload}
|
||||||
|
|
||||||
|
|
||||||
|
def test_vector_select_route_rejects_non_vector_dataset(monkeypatch) -> None:
|
||||||
|
from app.api.routes import datasets as dataset_routes
|
||||||
|
|
||||||
|
project_id = uuid.uuid4()
|
||||||
|
dataset_id = uuid.uuid4()
|
||||||
|
dataset = Dataset(id=dataset_id, project_id=project_id, dataset_type="raster", source="fixture", name="Raster")
|
||||||
|
monkeypatch.setattr(dataset_routes.DatasetService, "get_dataset", lambda db, selected_id: dataset)
|
||||||
|
|
||||||
|
try:
|
||||||
|
dataset_routes.select_vector_features(
|
||||||
|
project_id=project_id,
|
||||||
|
dataset_id=dataset_id,
|
||||||
|
payload=dataset_routes.VectorSelectionRequest(
|
||||||
|
bbox=dataset_routes.VectorSelectionBBox(min_x=4.9, min_y=50.9, max_x=5.2, max_y=51.2),
|
||||||
|
limit=100,
|
||||||
|
),
|
||||||
|
db=SimpleNamespace(),
|
||||||
|
)
|
||||||
|
except AppError as exc:
|
||||||
|
assert exc.code == "DATASET_NOT_VECTOR"
|
||||||
|
else: # pragma: no cover
|
||||||
|
raise AssertionError("Expected DATASET_NOT_VECTOR")
|
||||||
|
|
||||||
|
|
||||||
|
def test_frontend_exposes_map_bbox_selection_contracts() -> None:
|
||||||
|
api_client = (ROOT / "frontend" / "src" / "services" / "api" / "datasets.ts").read_text(encoding="utf-8")
|
||||||
|
map_workspace = (ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text(encoding="utf-8")
|
||||||
|
geomap = (ROOT / "frontend" / "src" / "components" / "GeoMap.tsx").read_text(encoding="utf-8")
|
||||||
|
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "selectVectorFeatures" in api_client
|
||||||
|
assert "Area selection" in map_workspace
|
||||||
|
assert "Start map bbox" in map_workspace
|
||||||
|
assert "Run area extract" in map_workspace
|
||||||
|
assert "Download area GeoJSON" in map_workspace
|
||||||
|
assert "bboxSelectionMode" in geomap
|
||||||
|
assert "selection-bbox" in geomap
|
||||||
|
assert "selection-result" in geomap
|
||||||
|
assert "useMapSelectionExtract" in app
|
||||||
@@ -357,6 +357,55 @@ Return vector stats (feature counts and geometry summary).
|
|||||||
|
|
||||||
Return vector bounds and feature count.
|
Return vector bounds and feature count.
|
||||||
|
|
||||||
|
### POST `/api/v1/projects/{project_id}/datasets/{dataset_id}/vector/select`
|
||||||
|
|
||||||
|
Read-only spatial selection over persisted `vector_features`.
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"bbox": {
|
||||||
|
"min_x": 5.0,
|
||||||
|
"min_y": 51.0,
|
||||||
|
"max_x": 5.1,
|
||||||
|
"max_y": 51.1,
|
||||||
|
"crs": "EPSG:4326"
|
||||||
|
},
|
||||||
|
"limit": 250
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"selection_bbox": {
|
||||||
|
"min_x": 5.0,
|
||||||
|
"min_y": 51.0,
|
||||||
|
"max_x": 5.1,
|
||||||
|
"max_y": 51.1,
|
||||||
|
"crs": "EPSG:4326"
|
||||||
|
},
|
||||||
|
"feature_count": 2,
|
||||||
|
"limit": 250,
|
||||||
|
"truncated": false,
|
||||||
|
"geojson": {
|
||||||
|
"type": "FeatureCollection",
|
||||||
|
"features": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Only vector/GeoJSON datasets are supported.
|
||||||
|
- Coordinates are EPSG:4326 longitude/latitude.
|
||||||
|
- Results are generated from persisted PostGIS `vector_features`, not from client-side map data.
|
||||||
|
- The response is capped by `limit` and returns `truncated=true` when more matching rows exist.
|
||||||
|
|
||||||
### GET `/api/v1/projects/{project_id}/datasets/{dataset_id}/content`
|
### GET `/api/v1/projects/{project_id}/datasets/{dataset_id}/content`
|
||||||
|
|
||||||
Returns stored vector dataset content through the canonical API envelope.
|
Returns stored vector dataset content through the canonical API envelope.
|
||||||
|
|||||||
@@ -1,3 +1,35 @@
|
|||||||
|
## Sprint 106 Map area selection extract (2026-06-25)
|
||||||
|
|
||||||
|
Changed:
|
||||||
|
- Added `POST /api/v1/projects/{project_id}/datasets/{dataset_id}/vector/select` for read-only bbox selection over persisted PostGIS `vector_features`.
|
||||||
|
- Added `VectorSelectionBBox`, `VectorSelectionRequest` and `VectorSelectionResponse` schemas and exported them through the backend schema module.
|
||||||
|
- Added `VectorFeatureService.select_features_by_bbox`, including EPSG:4326 bbox validation, feature limit capping, PostGIS `ST_Intersects` query and GeoJSON FeatureCollection conversion from persisted geometries.
|
||||||
|
- Added frontend `selectVectorFeatures` API client support and `useMapSelectionExtract`.
|
||||||
|
- Extended the Map workspace with an `Area selection` panel, two-click map bbox selection, manual bbox inputs, selected-feature/AOI/active-layer bbox shortcuts, area GeoJSON download/copy actions and a compact selected-feature table.
|
||||||
|
- Extended `GeoMap` with `selection-bbox` and `selection-result` MapLibre GeoJSON overlays.
|
||||||
|
- Updated `docs/API_CONTRACTS.md`, `frontend/README.md`, `backend/README.md`, `CHANGELOG.md` and `docs/TODO.md`.
|
||||||
|
- Added regression coverage in `backend/tests/test_sprint106_map_bbox_extract.py`.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- RED: `python -m pytest backend\tests\test_sprint106_map_bbox_extract.py -q` failed before implementation because the vector selection service, route and frontend contracts were absent.
|
||||||
|
- `python -m pytest backend\tests\test_sprint106_map_bbox_extract.py -q` passed: 5 tests.
|
||||||
|
- `python -m compileall backend/app` passed.
|
||||||
|
- `cd backend && python -m pytest -q` passed: 338 tests.
|
||||||
|
- `cd frontend && npm run typecheck` passed.
|
||||||
|
- `cd frontend && npm run build` passed.
|
||||||
|
- `bash scripts/run_readiness_check.sh` passed.
|
||||||
|
- `cd backend && python -m alembic heads` passed: `202606120900 (head)`.
|
||||||
|
- `cd backend && python -m alembic upgrade head --sql` passed.
|
||||||
|
- `bash -n scripts/live_migration_smoke.sh` passed.
|
||||||
|
|
||||||
|
Limitations:
|
||||||
|
- Selection shape is bbox-only in EPSG:4326. Polygon/lasso selection remains future work.
|
||||||
|
- The endpoint is read-only and does not create exports or derived datasets; operators can download the selected GeoJSON client-side.
|
||||||
|
- No migrations, live provider fetching, AI dependency, real model behavior or new product domain were added.
|
||||||
|
|
||||||
|
Next recommended pass:
|
||||||
|
- Add a browser/live smoke around the area selection panel after deploy, then consider export-center handoff for persisted selection artifacts if V1 needs server-side audit retention.
|
||||||
|
|
||||||
## Sprint 105 Map feature extract (2026-06-25)
|
## Sprint 105 Map feature extract (2026-06-25)
|
||||||
|
|
||||||
Changed:
|
Changed:
|
||||||
|
|||||||
@@ -372,3 +372,4 @@ This file now starts with the current implementation status. Older preparation/b
|
|||||||
- [x] Make raster tile handoff to Detection Lab auto-select the configured YOLO run form.
|
- [x] Make raster tile handoff to Detection Lab auto-select the configured YOLO run form.
|
||||||
- [x] Add AI Lab run-readiness checks for Detection and Segmentation before job submission.
|
- [x] Add AI Lab run-readiness checks for Detection and Segmentation before job submission.
|
||||||
- [x] Add AI Lab action guardrails so explicit fixture models are not exposed as normal operator runs.
|
- [x] Add AI Lab action guardrails so explicit fixture models are not exposed as normal operator runs.
|
||||||
|
- [x] Add persisted vector area selection from the Map workspace with bbox extract and GeoJSON download.
|
||||||
|
|||||||
@@ -277,6 +277,7 @@ AI Lab run controls explicitly explain when no raster dataset is available, inst
|
|||||||
- Mobile workbench navigation uses horizontal rails for the primary nav and command bar, avoiding a tall menu stack before the active workspace content.
|
- Mobile workbench navigation uses horizontal rails for the primary nav and command bar, avoiding a tall menu stack before the active workspace content.
|
||||||
- The Map workspace shows active layer source/provenance/draw-state context and selected-feature property chips before the raw JSON inspector.
|
- The Map workspace shows active layer source/provenance/draw-state context and selected-feature property chips before the raw JSON inspector.
|
||||||
- When the Map workspace has no active result layer, it lists ready vector/GeoJSON datasets as direct quick actions so populated demo projects can jump straight from the empty state to map inspection.
|
- When the Map workspace has no active result layer, it lists ready vector/GeoJSON datasets as direct quick actions so populated demo projects can jump straight from the empty state to map inspection.
|
||||||
|
- The Map workspace can extract persisted vector features by area: open a ready vector dataset, use `Start map bbox` and click two map corners or enter EPSG:4326 bbox values, then run `Run area extract` to query backend `vector_features`. Results are highlighted on the map and can be downloaded as GeoJSON.
|
||||||
- The Data catalog shows a compact selected/reference/candidate/source summary and scan-friendly badges. Persisted `reference` datasets are shown as Reference, non-reference vector/GeoJSON layers are shown as QA Candidates for workbench scanning, and raster/other uploads remain Source.
|
- The Data catalog shows a compact selected/reference/candidate/source summary and scan-friendly badges. Persisted `reference` datasets are shown as Reference, non-reference vector/GeoJSON layers are shown as QA Candidates for workbench scanning, and raster/other uploads remain Source.
|
||||||
- Dataset cards explain the recommended next action and use compact two-line action buttons for inspect, map, export/QA and metadata refresh. Disabled actions keep a visible reason, such as `Vector/GeoJSON only`.
|
- Dataset cards explain the recommended next action and use compact two-line action buttons for inspect, map, export/QA and metadata refresh. Disabled actions keep a visible reason, such as `Vector/GeoJSON only`.
|
||||||
- Raster controls show the latest generated tile manifest path from persisted `raster.tile` jobs and can hand that path directly to Detection Lab or Segmentation Lab with the selected raster dataset.
|
- Raster controls show the latest generated tile manifest path from persisted `raster.tile` jobs and can hand that path directly to Detection Lab or Segmentation Lab with the selected raster dataset.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { useDetectionWorkflow } from './hooks/useDetectionWorkflow'
|
|||||||
import { useDatasetWorkflow } from './hooks/useDatasetWorkflow'
|
import { useDatasetWorkflow } from './hooks/useDatasetWorkflow'
|
||||||
import { useExportWorkflow } from './hooks/useExportWorkflow'
|
import { useExportWorkflow } from './hooks/useExportWorkflow'
|
||||||
import { useMapWorkspaceState } from './hooks/useMapWorkspaceState'
|
import { useMapWorkspaceState } from './hooks/useMapWorkspaceState'
|
||||||
|
import { useMapSelectionExtract } from './hooks/useMapSelectionExtract'
|
||||||
import { useProviderCapabilities } from './hooks/useProviderCapabilities'
|
import { useProviderCapabilities } from './hooks/useProviderCapabilities'
|
||||||
import { useProjectWorkspace } from './hooks/useProjectWorkspace'
|
import { useProjectWorkspace } from './hooks/useProjectWorkspace'
|
||||||
import { useQualityWorkflow } from './hooks/useQualityWorkflow'
|
import { useQualityWorkflow } from './hooks/useQualityWorkflow'
|
||||||
@@ -367,6 +368,19 @@ function App(): JSX.Element {
|
|||||||
datasetContent,
|
datasetContent,
|
||||||
selectedDataset,
|
selectedDataset,
|
||||||
})
|
})
|
||||||
|
const {
|
||||||
|
mapSelectionBbox,
|
||||||
|
mapSelectionResult,
|
||||||
|
mapSelectionLoading,
|
||||||
|
mapSelectionError,
|
||||||
|
runMapSelectionExtract,
|
||||||
|
resetMapSelectionExtract,
|
||||||
|
setMapSelectionBbox,
|
||||||
|
} = useMapSelectionExtract({
|
||||||
|
selectedProjectId,
|
||||||
|
selectedDataset,
|
||||||
|
isVectorDatasetType,
|
||||||
|
})
|
||||||
const {
|
const {
|
||||||
loadingDemoWorkflow,
|
loadingDemoWorkflow,
|
||||||
demoWorkflowMessage,
|
demoWorkflowMessage,
|
||||||
@@ -784,6 +798,10 @@ function App(): JSX.Element {
|
|||||||
mapFeatureCount={mapFeatureCount}
|
mapFeatureCount={mapFeatureCount}
|
||||||
areaFeatureCount={areaFeatureCount}
|
areaFeatureCount={areaFeatureCount}
|
||||||
selectedMapFeature={selectedMapFeature}
|
selectedMapFeature={selectedMapFeature}
|
||||||
|
mapSelectionBbox={mapSelectionBbox}
|
||||||
|
mapSelectionResult={mapSelectionResult}
|
||||||
|
mapSelectionLoading={mapSelectionLoading}
|
||||||
|
mapSelectionError={mapSelectionError}
|
||||||
availableMapDatasets={availableMapDatasets}
|
availableMapDatasets={availableMapDatasets}
|
||||||
selectedFeature={selectedMapFeature}
|
selectedFeature={selectedMapFeature}
|
||||||
onSelectMapArea={setSelectedMapAreaId}
|
onSelectMapArea={setSelectedMapAreaId}
|
||||||
@@ -793,6 +811,9 @@ function App(): JSX.Element {
|
|||||||
onSetMapLayerVisible={setMapLayerVisible}
|
onSetMapLayerVisible={setMapLayerVisible}
|
||||||
onSetMapLayerOpacity={setMapLayerOpacity}
|
onSetMapLayerOpacity={setMapLayerOpacity}
|
||||||
onSelectMapFeature={setSelectedMapFeature}
|
onSelectMapFeature={setSelectedMapFeature}
|
||||||
|
onSetMapSelectionBbox={setMapSelectionBbox}
|
||||||
|
onRunMapSelectionExtract={runMapSelectionExtract}
|
||||||
|
onClearMapSelectionExtract={resetMapSelectionExtract}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ interface GeoMapProps {
|
|||||||
data: GeoJSON.FeatureCollection | null
|
data: GeoJSON.FeatureCollection | null
|
||||||
areaData?: GeoJSON.FeatureCollection | null
|
areaData?: GeoJSON.FeatureCollection | null
|
||||||
selectedFeature?: GeoJSON.Feature | null
|
selectedFeature?: GeoJSON.Feature | null
|
||||||
|
selectionData?: GeoJSON.FeatureCollection | null
|
||||||
|
selectionBbox?: { min_x: number; min_y: number; max_x: number; max_y: number } | null
|
||||||
|
bboxSelectionMode?: boolean
|
||||||
visible?: boolean
|
visible?: boolean
|
||||||
opacity?: number
|
opacity?: number
|
||||||
areaVisible?: boolean
|
areaVisible?: boolean
|
||||||
areaOpacity?: number
|
areaOpacity?: number
|
||||||
onFeatureSelect?: (feature: GeoJSON.Feature | null) => void
|
onFeatureSelect?: (feature: GeoJSON.Feature | null) => void
|
||||||
|
onMapCoordinateSelect?: (coordinate: [number, number]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_FEATURE_COLLECTION: GeoJSON.FeatureCollection = {
|
const EMPTY_FEATURE_COLLECTION: GeoJSON.FeatureCollection = {
|
||||||
@@ -57,25 +61,73 @@ function mergeFeatureCollections(collections: Array<GeoJSON.FeatureCollection |
|
|||||||
return features.length > 0 ? { type: 'FeatureCollection', features } : null
|
return features.length > 0 ? { type: 'FeatureCollection', features } : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bboxToFeatureCollection(
|
||||||
|
bbox: { min_x: number; min_y: number; max_x: number; max_y: number } | null | undefined,
|
||||||
|
): GeoJSON.FeatureCollection {
|
||||||
|
if (!bbox) {
|
||||||
|
return EMPTY_FEATURE_COLLECTION
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'Polygon',
|
||||||
|
coordinates: [
|
||||||
|
[
|
||||||
|
[bbox.min_x, bbox.min_y],
|
||||||
|
[bbox.max_x, bbox.min_y],
|
||||||
|
[bbox.max_x, bbox.max_y],
|
||||||
|
[bbox.min_x, bbox.max_y],
|
||||||
|
[bbox.min_x, bbox.min_y],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
layer_type: 'selection_bbox',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function GeoMap({
|
function GeoMap({
|
||||||
data,
|
data,
|
||||||
areaData = null,
|
areaData = null,
|
||||||
selectedFeature = null,
|
selectedFeature = null,
|
||||||
|
selectionData = null,
|
||||||
|
selectionBbox = null,
|
||||||
|
bboxSelectionMode = false,
|
||||||
visible = true,
|
visible = true,
|
||||||
opacity = 0.4,
|
opacity = 0.4,
|
||||||
areaVisible = true,
|
areaVisible = true,
|
||||||
areaOpacity = 0.18,
|
areaOpacity = 0.18,
|
||||||
onFeatureSelect,
|
onFeatureSelect,
|
||||||
|
onMapCoordinateSelect,
|
||||||
}: GeoMapProps): JSX.Element {
|
}: 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)
|
||||||
|
const onMapCoordinateSelectRef = useRef<GeoMapProps['onMapCoordinateSelect']>(onMapCoordinateSelect)
|
||||||
|
const bboxSelectionModeRef = useRef(bboxSelectionMode)
|
||||||
const [mapStyleReady, setMapStyleReady] = useState(false)
|
const [mapStyleReady, setMapStyleReady] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onFeatureSelectRef.current = onFeatureSelect
|
onFeatureSelectRef.current = onFeatureSelect
|
||||||
}, [onFeatureSelect])
|
}, [onFeatureSelect])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onMapCoordinateSelectRef.current = onMapCoordinateSelect
|
||||||
|
}, [onMapCoordinateSelect])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bboxSelectionModeRef.current = bboxSelectionMode
|
||||||
|
if (mapRef.current) {
|
||||||
|
mapRef.current.getCanvas().style.cursor = bboxSelectionMode ? 'crosshair' : ''
|
||||||
|
}
|
||||||
|
}, [bboxSelectionMode])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current || mapRef.current) {
|
if (!containerRef.current || mapRef.current) {
|
||||||
return
|
return
|
||||||
@@ -92,6 +144,10 @@ function GeoMap({
|
|||||||
setMapStyleReady(true)
|
setMapStyleReady(true)
|
||||||
})
|
})
|
||||||
map.on('click', (event) => {
|
map.on('click', (event) => {
|
||||||
|
if (bboxSelectionModeRef.current) {
|
||||||
|
onMapCoordinateSelectRef.current?.([event.lngLat.lng, event.lngLat.lat])
|
||||||
|
return
|
||||||
|
}
|
||||||
const layers = ['dataset-fill', 'dataset-line', 'area-fill', 'area-line'].filter((layerId) => map.getLayer(layerId))
|
const layers = ['dataset-fill', 'dataset-line', 'area-fill', 'area-line'].filter((layerId) => map.getLayer(layerId))
|
||||||
if (layers.length === 0) {
|
if (layers.length === 0) {
|
||||||
onFeatureSelectRef.current?.(null)
|
onFeatureSelectRef.current?.(null)
|
||||||
@@ -327,6 +383,86 @@ function GeoMap({
|
|||||||
})
|
})
|
||||||
}, [selectedFeature, mapStyleReady])
|
}, [selectedFeature, mapStyleReady])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current
|
||||||
|
if (!map || !mapStyleReady || !map.isStyleLoaded()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const bboxCollection = bboxToFeatureCollection(selectionBbox)
|
||||||
|
if (map.getSource('selection-bbox')) {
|
||||||
|
;(map.getSource('selection-bbox') as maplibregl.GeoJSONSource).setData(bboxCollection)
|
||||||
|
} else {
|
||||||
|
map.addSource('selection-bbox', { type: 'geojson', data: bboxCollection })
|
||||||
|
map.addLayer({
|
||||||
|
id: 'selection-bbox-fill',
|
||||||
|
type: 'fill',
|
||||||
|
source: 'selection-bbox',
|
||||||
|
paint: {
|
||||||
|
'fill-color': '#38bdf8',
|
||||||
|
'fill-opacity': 0.12,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
map.addLayer({
|
||||||
|
id: 'selection-bbox-line',
|
||||||
|
type: 'line',
|
||||||
|
source: 'selection-bbox',
|
||||||
|
paint: {
|
||||||
|
'line-color': '#0369a1',
|
||||||
|
'line-width': 2,
|
||||||
|
'line-dasharray': [2, 1],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [selectionBbox, mapStyleReady])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current
|
||||||
|
if (!map || !mapStyleReady || !map.isStyleLoaded()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultCollection = selectionData ?? EMPTY_FEATURE_COLLECTION
|
||||||
|
if (map.getSource('selection-result')) {
|
||||||
|
;(map.getSource('selection-result') as maplibregl.GeoJSONSource).setData(resultCollection)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
map.addSource('selection-result', { type: 'geojson', data: resultCollection })
|
||||||
|
map.addLayer({
|
||||||
|
id: 'selection-result-fill',
|
||||||
|
type: 'fill',
|
||||||
|
source: 'selection-result',
|
||||||
|
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon'], true, false],
|
||||||
|
paint: {
|
||||||
|
'fill-color': '#7c3aed',
|
||||||
|
'fill-opacity': 0.24,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
map.addLayer({
|
||||||
|
id: 'selection-result-line',
|
||||||
|
type: 'line',
|
||||||
|
source: 'selection-result',
|
||||||
|
filter: ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false],
|
||||||
|
paint: {
|
||||||
|
'line-color': '#5b21b6',
|
||||||
|
'line-width': 3,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
map.addLayer({
|
||||||
|
id: 'selection-result-circle',
|
||||||
|
type: 'circle',
|
||||||
|
source: 'selection-result',
|
||||||
|
filter: ['match', ['geometry-type'], ['Point', 'MultiPoint'], true, false],
|
||||||
|
paint: {
|
||||||
|
'circle-color': '#7c3aed',
|
||||||
|
'circle-radius': 6,
|
||||||
|
'circle-stroke-color': '#ffffff',
|
||||||
|
'circle-stroke-width': 2,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [selectionData, mapStyleReady])
|
||||||
|
|
||||||
return <div className="map-container" ref={containerRef} />
|
return <div className="map-container" ref={containerRef} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
import GeoMap from '../GeoMap'
|
import GeoMap from '../GeoMap'
|
||||||
import type { AreaRead, DatasetCreateResponse } from '../../types'
|
import type { AreaRead, DatasetCreateResponse, VectorSelectionBBox, VectorSelectionResponse } from '../../types'
|
||||||
|
|
||||||
const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson'
|
const DEFAULT_SELECTED_FEATURE_FILENAME = 'selected-feature.geojson'
|
||||||
|
const DEFAULT_AREA_SELECTION_FILENAME = 'area-selection.geojson'
|
||||||
|
|
||||||
function collectGeometryPoints(geometry: GeoJSON.Geometry | null | undefined): Array<[number, number]> {
|
function collectGeometryPoints(geometry: GeoJSON.Geometry | null | undefined): Array<[number, number]> {
|
||||||
const points: Array<[number, number]> = []
|
const points: Array<[number, number]> = []
|
||||||
@@ -52,6 +54,75 @@ function getFeatureGeometrySummary(feature: GeoJSON.Feature | null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFeatureCollectionBBox(collection: GeoJSON.FeatureCollection | null): VectorSelectionBBox | null {
|
||||||
|
const points = collection?.features.flatMap((feature) => collectGeometryPoints(feature.geometry)) ?? []
|
||||||
|
if (points.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const xs = points.map((point) => point[0])
|
||||||
|
const ys = points.map((point) => point[1])
|
||||||
|
return {
|
||||||
|
min_x: Math.min(...xs),
|
||||||
|
min_y: Math.min(...ys),
|
||||||
|
max_x: Math.max(...xs),
|
||||||
|
max_y: Math.max(...ys),
|
||||||
|
crs: 'EPSG:4326',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFeatureBBox(feature: GeoJSON.Feature | null): VectorSelectionBBox | null {
|
||||||
|
const points = collectGeometryPoints(feature?.geometry)
|
||||||
|
if (points.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const xs = points.map((point) => point[0])
|
||||||
|
const ys = points.map((point) => point[1])
|
||||||
|
return {
|
||||||
|
min_x: Math.min(...xs),
|
||||||
|
min_y: Math.min(...ys),
|
||||||
|
max_x: Math.max(...xs),
|
||||||
|
max_y: Math.max(...ys),
|
||||||
|
crs: 'EPSG:4326',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBboxFromCorners(first: [number, number], second: [number, number]): VectorSelectionBBox {
|
||||||
|
return {
|
||||||
|
min_x: Math.min(first[0], second[0]),
|
||||||
|
min_y: Math.min(first[1], second[1]),
|
||||||
|
max_x: Math.max(first[0], second[0]),
|
||||||
|
max_y: Math.max(first[1], second[1]),
|
||||||
|
crs: 'EPSG:4326',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBboxLabel(bbox: VectorSelectionBBox | null): string {
|
||||||
|
if (!bbox) {
|
||||||
|
return 'n/a'
|
||||||
|
}
|
||||||
|
return `${formatCoordinate(bbox.min_x)}, ${formatCoordinate(bbox.min_y)} -> ${formatCoordinate(bbox.max_x)}, ${formatCoordinate(bbox.max_y)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function bboxToInputState(bbox: VectorSelectionBBox | null) {
|
||||||
|
return {
|
||||||
|
min_x: bbox ? String(bbox.min_x) : '',
|
||||||
|
min_y: bbox ? String(bbox.min_y) : '',
|
||||||
|
max_x: bbox ? String(bbox.max_x) : '',
|
||||||
|
max_y: bbox ? String(bbox.max_y) : '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBboxInput(input: ReturnType<typeof bboxToInputState>): VectorSelectionBBox | null {
|
||||||
|
const min_x = Number(input.min_x)
|
||||||
|
const min_y = Number(input.min_y)
|
||||||
|
const max_x = Number(input.max_x)
|
||||||
|
const max_y = Number(input.max_y)
|
||||||
|
if (![min_x, min_y, max_x, max_y].every(Number.isFinite) || min_x >= max_x || min_y >= max_y) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return { min_x, min_y, max_x, max_y, crs: 'EPSG:4326' }
|
||||||
|
}
|
||||||
|
|
||||||
function selectedFeatureCollection(feature: GeoJSON.Feature): GeoJSON.FeatureCollection {
|
function selectedFeatureCollection(feature: GeoJSON.Feature): GeoJSON.FeatureCollection {
|
||||||
return {
|
return {
|
||||||
type: 'FeatureCollection',
|
type: 'FeatureCollection',
|
||||||
@@ -116,6 +187,10 @@ interface MapWorkspaceProps {
|
|||||||
areaFeatureCount: number
|
areaFeatureCount: number
|
||||||
selectedMapFeature: GeoJSON.Feature | null
|
selectedMapFeature: GeoJSON.Feature | null
|
||||||
selectedFeature?: GeoJSON.Feature | null
|
selectedFeature?: GeoJSON.Feature | null
|
||||||
|
mapSelectionBbox: VectorSelectionBBox | null
|
||||||
|
mapSelectionResult: VectorSelectionResponse | null
|
||||||
|
mapSelectionLoading: boolean
|
||||||
|
mapSelectionError: string | null
|
||||||
availableMapDatasets: DatasetCreateResponse[]
|
availableMapDatasets: DatasetCreateResponse[]
|
||||||
onSelectMapArea: (areaId: string) => void
|
onSelectMapArea: (areaId: string) => void
|
||||||
onOpenDatasetInMap: (dataset: DatasetCreateResponse) => void
|
onOpenDatasetInMap: (dataset: DatasetCreateResponse) => void
|
||||||
@@ -124,6 +199,9 @@ interface MapWorkspaceProps {
|
|||||||
onSetMapLayerVisible: (visible: boolean) => void
|
onSetMapLayerVisible: (visible: boolean) => void
|
||||||
onSetMapLayerOpacity: (opacity: number) => void
|
onSetMapLayerOpacity: (opacity: number) => void
|
||||||
onSelectMapFeature: (feature: GeoJSON.Feature | null) => void
|
onSelectMapFeature: (feature: GeoJSON.Feature | null) => void
|
||||||
|
onSetMapSelectionBbox: (bbox: VectorSelectionBBox | null) => void
|
||||||
|
onRunMapSelectionExtract: (bbox: VectorSelectionBBox) => void
|
||||||
|
onClearMapSelectionExtract: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MapWorkspace({
|
export function MapWorkspace({
|
||||||
@@ -142,6 +220,10 @@ export function MapWorkspace({
|
|||||||
areaFeatureCount,
|
areaFeatureCount,
|
||||||
selectedMapFeature,
|
selectedMapFeature,
|
||||||
selectedFeature = selectedMapFeature,
|
selectedFeature = selectedMapFeature,
|
||||||
|
mapSelectionBbox,
|
||||||
|
mapSelectionResult,
|
||||||
|
mapSelectionLoading,
|
||||||
|
mapSelectionError,
|
||||||
availableMapDatasets,
|
availableMapDatasets,
|
||||||
onSelectMapArea,
|
onSelectMapArea,
|
||||||
onOpenDatasetInMap,
|
onOpenDatasetInMap,
|
||||||
@@ -150,7 +232,13 @@ export function MapWorkspace({
|
|||||||
onSetMapLayerVisible,
|
onSetMapLayerVisible,
|
||||||
onSetMapLayerOpacity,
|
onSetMapLayerOpacity,
|
||||||
onSelectMapFeature,
|
onSelectMapFeature,
|
||||||
|
onSetMapSelectionBbox,
|
||||||
|
onRunMapSelectionExtract,
|
||||||
|
onClearMapSelectionExtract,
|
||||||
}: MapWorkspaceProps): JSX.Element {
|
}: MapWorkspaceProps): JSX.Element {
|
||||||
|
const [bboxSelectionMode, setBboxSelectionMode] = useState(false)
|
||||||
|
const [firstSelectionCorner, setFirstSelectionCorner] = useState<[number, number] | null>(null)
|
||||||
|
const [bboxInput, setBboxInput] = useState(bboxToInputState(mapSelectionBbox))
|
||||||
const selectedMapArea = areas.find((area) => area.id === selectedMapAreaId)
|
const selectedMapArea = areas.find((area) => area.id === selectedMapAreaId)
|
||||||
const featureProperties = selectedMapFeature?.properties ?? null
|
const featureProperties = selectedMapFeature?.properties ?? null
|
||||||
const featureSummaryEntries = featureProperties
|
const featureSummaryEntries = featureProperties
|
||||||
@@ -161,11 +249,21 @@ export function MapWorkspace({
|
|||||||
const featureExtractionEntries = featureProperties ? Object.entries(featureProperties).slice(0, 48) : []
|
const featureExtractionEntries = featureProperties ? Object.entries(featureProperties).slice(0, 48) : []
|
||||||
const featureGeometrySummary = getFeatureGeometrySummary(selectedMapFeature)
|
const featureGeometrySummary = getFeatureGeometrySummary(selectedMapFeature)
|
||||||
const selectedFeatureGeoJson = selectedMapFeature ? selectedFeatureCollection(selectedMapFeature) : null
|
const selectedFeatureGeoJson = selectedMapFeature ? selectedFeatureCollection(selectedMapFeature) : null
|
||||||
|
const selectedFeatureBbox = getFeatureBBox(selectedMapFeature)
|
||||||
|
const activeLayerBbox = getFeatureCollectionBBox(mapFeatureCollection)
|
||||||
|
const selectedAreaBbox = getFeatureCollectionBBox(areaFeatureCollection)
|
||||||
|
const currentSelectionBbox = parseBboxInput(bboxInput)
|
||||||
|
const areaSelectionFeatures = mapSelectionResult?.geojson.features ?? []
|
||||||
|
const areaSelectionPreviewFeatures = areaSelectionFeatures.slice(0, 12)
|
||||||
const selectedFeatureStem = safeFileStem(
|
const selectedFeatureStem = safeFileStem(
|
||||||
featureProperties?.['name'] ?? featureProperties?.['id'] ?? featureProperties?.['source_feature_id'] ?? 'selected-feature',
|
featureProperties?.['name'] ?? featureProperties?.['id'] ?? featureProperties?.['source_feature_id'] ?? 'selected-feature',
|
||||||
)
|
)
|
||||||
const selectedFeatureFilename = selectedFeatureStem === 'selected-feature' ? DEFAULT_SELECTED_FEATURE_FILENAME : `${selectedFeatureStem}.geojson`
|
const selectedFeatureFilename = selectedFeatureStem === 'selected-feature' ? DEFAULT_SELECTED_FEATURE_FILENAME : `${selectedFeatureStem}.geojson`
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBboxInput(bboxToInputState(mapSelectionBbox))
|
||||||
|
}, [mapSelectionBbox])
|
||||||
|
|
||||||
const downloadSelectedMapFeature = () => {
|
const downloadSelectedMapFeature = () => {
|
||||||
if (!selectedFeatureGeoJson) {
|
if (!selectedFeatureGeoJson) {
|
||||||
return
|
return
|
||||||
@@ -177,6 +275,53 @@ export function MapWorkspace({
|
|||||||
copyText(JSON.stringify(featureProperties ?? {}, null, 2))
|
copyText(JSON.stringify(featureProperties ?? {}, null, 2))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setSelectionBbox = (bbox: VectorSelectionBBox | null) => {
|
||||||
|
onSetMapSelectionBbox(bbox)
|
||||||
|
setBboxInput(bboxToInputState(bbox))
|
||||||
|
}
|
||||||
|
|
||||||
|
const startBboxSelection = () => {
|
||||||
|
setFirstSelectionCorner(null)
|
||||||
|
setBboxSelectionMode(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMapCoordinateSelect = (coordinate: [number, number]) => {
|
||||||
|
if (!firstSelectionCorner) {
|
||||||
|
setFirstSelectionCorner(coordinate)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const bbox = normalizeBboxFromCorners(firstSelectionCorner, coordinate)
|
||||||
|
setSelectionBbox(bbox)
|
||||||
|
setFirstSelectionCorner(null)
|
||||||
|
setBboxSelectionMode(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const runAreaExtract = () => {
|
||||||
|
const bbox = parseBboxInput(bboxInput)
|
||||||
|
if (!bbox) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onRunMapSelectionExtract(bbox)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearAreaSelection = () => {
|
||||||
|
setBboxSelectionMode(false)
|
||||||
|
setFirstSelectionCorner(null)
|
||||||
|
setBboxInput(bboxToInputState(null))
|
||||||
|
onClearMapSelectionExtract()
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadAreaSelection = () => {
|
||||||
|
if (!mapSelectionResult) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
downloadJsonFile(DEFAULT_AREA_SELECTION_FILENAME, mapSelectionResult.geojson)
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyAreaSelection = () => {
|
||||||
|
copyText(JSON.stringify(mapSelectionResult?.geojson ?? { type: 'FeatureCollection', features: [] }, null, 2))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="map-workspace-shell" data-testid="map-workspace">
|
<section className="map-workspace-shell" data-testid="map-workspace">
|
||||||
<div className="panel-title-row">
|
<div className="panel-title-row">
|
||||||
@@ -318,15 +463,158 @@ export function MapWorkspace({
|
|||||||
data={mapFeatureCollection}
|
data={mapFeatureCollection}
|
||||||
areaData={areaFeatureCollection}
|
areaData={areaFeatureCollection}
|
||||||
selectedFeature={selectedFeature}
|
selectedFeature={selectedFeature}
|
||||||
|
selectionData={mapSelectionResult?.geojson ?? null}
|
||||||
|
selectionBbox={mapSelectionBbox}
|
||||||
|
bboxSelectionMode={bboxSelectionMode}
|
||||||
visible={mapLayerVisible}
|
visible={mapLayerVisible}
|
||||||
opacity={mapLayerOpacity}
|
opacity={mapLayerOpacity}
|
||||||
areaVisible={areaLayerVisible}
|
areaVisible={areaLayerVisible}
|
||||||
areaOpacity={areaLayerOpacity}
|
areaOpacity={areaLayerOpacity}
|
||||||
onFeatureSelect={onSelectMapFeature}
|
onFeatureSelect={onSelectMapFeature}
|
||||||
|
onMapCoordinateSelect={handleMapCoordinateSelect}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="map-inspection-surface">
|
<div className="map-inspection-surface">
|
||||||
|
<div className="bbox-select-surface" aria-label="Area selection and extract">
|
||||||
|
<div className="panel-title-row">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Persisted vector query</p>
|
||||||
|
<h3>Area selection</h3>
|
||||||
|
</div>
|
||||||
|
<span className="count-pill">
|
||||||
|
{mapSelectionResult ? `${mapSelectionResult.feature_count} selected` : bboxSelectionMode ? 'selecting' : 'ready'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bbox-select-status">
|
||||||
|
<span>{bboxSelectionMode ? (firstSelectionCorner ? 'Click the opposite corner' : 'Click the first corner on the map') : 'BBox EPSG:4326'}</span>
|
||||||
|
<strong>{formatBboxLabel(currentSelectionBbox)}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="bbox-select-grid" aria-label="Selection bbox inputs">
|
||||||
|
<label>
|
||||||
|
Min lon
|
||||||
|
<input
|
||||||
|
inputMode="decimal"
|
||||||
|
value={bboxInput.min_x}
|
||||||
|
onChange={(event) => setBboxInput((previous) => ({ ...previous, min_x: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Min lat
|
||||||
|
<input
|
||||||
|
inputMode="decimal"
|
||||||
|
value={bboxInput.min_y}
|
||||||
|
onChange={(event) => setBboxInput((previous) => ({ ...previous, min_y: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Max lon
|
||||||
|
<input
|
||||||
|
inputMode="decimal"
|
||||||
|
value={bboxInput.max_x}
|
||||||
|
onChange={(event) => setBboxInput((previous) => ({ ...previous, max_x: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Max lat
|
||||||
|
<input
|
||||||
|
inputMode="decimal"
|
||||||
|
value={bboxInput.max_y}
|
||||||
|
onChange={(event) => setBboxInput((previous) => ({ ...previous, max_y: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="bbox-select-actions">
|
||||||
|
<button className="primary-action" type="button" onClick={startBboxSelection}>
|
||||||
|
Start map bbox
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="secondary-action"
|
||||||
|
disabled={!selectedFeatureBbox}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectionBbox(selectedFeatureBbox)}
|
||||||
|
>
|
||||||
|
Use feature bbox
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="secondary-action"
|
||||||
|
disabled={!selectedAreaBbox}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectionBbox(selectedAreaBbox)}
|
||||||
|
>
|
||||||
|
Use AOI bbox
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="secondary-action"
|
||||||
|
disabled={!activeLayerBbox}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectionBbox(activeLayerBbox)}
|
||||||
|
>
|
||||||
|
Use layer bbox
|
||||||
|
</button>
|
||||||
|
<button className="primary-action" disabled={!currentSelectionBbox || mapSelectionLoading} type="button" onClick={runAreaExtract}>
|
||||||
|
{mapSelectionLoading ? 'Extracting...' : 'Run area extract'}
|
||||||
|
</button>
|
||||||
|
<button className="secondary-action" type="button" onClick={clearAreaSelection}>
|
||||||
|
Clear area
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{mapSelectionError ? <p className="error">{mapSelectionError}</p> : null}
|
||||||
|
{mapSelectionResult ? (
|
||||||
|
<div className="bbox-selection-result" aria-label="Area selection result">
|
||||||
|
<div className="feature-extract-grid">
|
||||||
|
<div>
|
||||||
|
<span>Features</span>
|
||||||
|
<strong>{mapSelectionResult.feature_count}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Limit</span>
|
||||||
|
<strong>{mapSelectionResult.limit}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Truncated</span>
|
||||||
|
<strong>{mapSelectionResult.truncated ? 'yes' : 'no'}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Source</span>
|
||||||
|
<strong>vector_features</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="feature-extract-actions">
|
||||||
|
<button className="primary-action" type="button" onClick={downloadAreaSelection}>
|
||||||
|
Download area GeoJSON
|
||||||
|
</button>
|
||||||
|
<button className="secondary-action" type="button" onClick={copyAreaSelection}>
|
||||||
|
Copy area GeoJSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{areaSelectionPreviewFeatures.length > 0 ? (
|
||||||
|
<div className="table-scroll feature-property-table" aria-label="Area selection feature table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Feature</th>
|
||||||
|
<th>Class</th>
|
||||||
|
<th>Source id</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{areaSelectionPreviewFeatures.map((feature, index) => (
|
||||||
|
<tr key={String(feature.id ?? index)}>
|
||||||
|
<td>{String(feature.properties?.['name'] ?? feature.properties?.['vector_feature_id'] ?? feature.id ?? index + 1)}</td>
|
||||||
|
<td>{String(feature.properties?.['feature_class'] ?? 'n/a')}</td>
|
||||||
|
<td>{String(feature.properties?.['source_feature_id'] ?? 'n/a')}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="muted">No persisted vector features intersect this selection.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<div className="feature-extract-surface" aria-label="Selection and feature extract">
|
<div className="feature-extract-surface" aria-label="Selection and feature extract">
|
||||||
<div className="panel-title-row">
|
<div className="panel-title-row">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { datasetsApi } from '../services/api'
|
||||||
|
import { formatError } from '../lib/formatError'
|
||||||
|
import type { DatasetCreateResponse, VectorSelectionBBox, VectorSelectionResponse } from '../types'
|
||||||
|
|
||||||
|
interface MapSelectionExtractOptions {
|
||||||
|
selectedProjectId: string | null
|
||||||
|
selectedDataset: DatasetCreateResponse | null
|
||||||
|
isVectorDatasetType: (datasetType: string) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMapSelectionExtract({
|
||||||
|
selectedProjectId,
|
||||||
|
selectedDataset,
|
||||||
|
isVectorDatasetType,
|
||||||
|
}: MapSelectionExtractOptions) {
|
||||||
|
const [mapSelectionBbox, setMapSelectionBbox] = useState<VectorSelectionBBox | null>(null)
|
||||||
|
const [mapSelectionResult, setMapSelectionResult] = useState<VectorSelectionResponse | null>(null)
|
||||||
|
const [mapSelectionLoading, setMapSelectionLoading] = useState(false)
|
||||||
|
const [mapSelectionError, setMapSelectionError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMapSelectionBbox(null)
|
||||||
|
setMapSelectionResult(null)
|
||||||
|
setMapSelectionError(null)
|
||||||
|
}, [selectedProjectId, selectedDataset?.id])
|
||||||
|
|
||||||
|
const runMapSelectionExtract = async (bbox: VectorSelectionBBox) => {
|
||||||
|
if (!selectedProjectId || !selectedDataset) {
|
||||||
|
setMapSelectionError('Open a vector dataset before extracting a map area.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isVectorDatasetType(selectedDataset.dataset_type)) {
|
||||||
|
setMapSelectionError('Area extraction requires an active vector dataset.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setMapSelectionLoading(true)
|
||||||
|
setMapSelectionError(null)
|
||||||
|
setMapSelectionBbox(bbox)
|
||||||
|
try {
|
||||||
|
const response = await datasetsApi.selectVectorFeatures(selectedProjectId, selectedDataset.id, {
|
||||||
|
bbox: { ...bbox, crs: 'EPSG:4326' },
|
||||||
|
limit: 250,
|
||||||
|
})
|
||||||
|
setMapSelectionResult(response)
|
||||||
|
} catch (error) {
|
||||||
|
setMapSelectionResult(null)
|
||||||
|
setMapSelectionError(formatError(error, 'Area extraction failed'))
|
||||||
|
} finally {
|
||||||
|
setMapSelectionLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetMapSelectionExtract = () => {
|
||||||
|
setMapSelectionBbox(null)
|
||||||
|
setMapSelectionResult(null)
|
||||||
|
setMapSelectionError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mapSelectionBbox,
|
||||||
|
mapSelectionResult,
|
||||||
|
mapSelectionLoading,
|
||||||
|
mapSelectionError,
|
||||||
|
runMapSelectionExtract,
|
||||||
|
resetMapSelectionExtract,
|
||||||
|
setMapSelectionBbox,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import type {
|
|||||||
JobRead,
|
JobRead,
|
||||||
VectorBBoxResponse,
|
VectorBBoxResponse,
|
||||||
VectorStatsResponse,
|
VectorStatsResponse,
|
||||||
|
VectorSelectionRequest,
|
||||||
|
VectorSelectionResponse,
|
||||||
VectorSummary,
|
VectorSummary,
|
||||||
RasterNdviRequest,
|
RasterNdviRequest,
|
||||||
RasterNdwiRequest,
|
RasterNdwiRequest,
|
||||||
@@ -70,6 +72,8 @@ export const datasetsApi = {
|
|||||||
apiGet<VectorSummary>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/summary`),
|
apiGet<VectorSummary>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/summary`),
|
||||||
vectorStats: (projectId: string, datasetId: string): Promise<VectorStatsResponse> =>
|
vectorStats: (projectId: string, datasetId: string): Promise<VectorStatsResponse> =>
|
||||||
apiGet<VectorStatsResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/stats`),
|
apiGet<VectorStatsResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/stats`),
|
||||||
|
selectVectorFeatures: (projectId: string, datasetId: string, payload: VectorSelectionRequest): Promise<VectorSelectionResponse> =>
|
||||||
|
apiPost<VectorSelectionResponse>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/select`, payload),
|
||||||
vectorClip: (projectId: string, datasetId: string, payload: { area_id: string; output_name?: string }) =>
|
vectorClip: (projectId: string, datasetId: string, payload: { area_id: string; output_name?: string }) =>
|
||||||
apiPost<JobRead>(`/api/v1/projects/${projectId}/datasets/${datasetId}/vector/clip`, payload),
|
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 }) =>
|
vectorBuffer: (projectId: string, datasetId: string, payload: { distance_m: number; dissolve?: boolean; output_name?: string }) =>
|
||||||
|
|||||||
@@ -2599,6 +2599,76 @@ button.entity-card {
|
|||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bbox-select-surface {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.72rem;
|
||||||
|
min-width: 0;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
border: 1px solid rgba(3, 105, 161, 0.24);
|
||||||
|
border-left: 4px solid #0369a1;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.72rem;
|
||||||
|
background: linear-gradient(180deg, #ffffff, #f6fbff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbox-select-surface .panel-title-row {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbox-select-status {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.22rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 0.58rem 0.66rem;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbox-select-status span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 850;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbox-select-status strong {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbox-select-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbox-select-grid label {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbox-select-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbox-select-actions button {
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbox-selection-result {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.64rem;
|
||||||
|
min-width: 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding-top: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
.feature-extract-surface {
|
.feature-extract-surface {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.7rem;
|
gap: 0.7rem;
|
||||||
|
|||||||
@@ -274,6 +274,27 @@ export interface VectorStatsResponse {
|
|||||||
crs?: string | null
|
crs?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VectorSelectionBBox {
|
||||||
|
min_x: number
|
||||||
|
min_y: number
|
||||||
|
max_x: number
|
||||||
|
max_y: number
|
||||||
|
crs?: 'EPSG:4326'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VectorSelectionRequest {
|
||||||
|
bbox: VectorSelectionBBox
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VectorSelectionResponse {
|
||||||
|
selection_bbox: VectorSelectionBBox
|
||||||
|
feature_count: number
|
||||||
|
limit: number
|
||||||
|
truncated: boolean
|
||||||
|
geojson: GeoJSON.FeatureCollection
|
||||||
|
}
|
||||||
|
|
||||||
export interface DatasetListResponse {
|
export interface DatasetListResponse {
|
||||||
items: DatasetCreateResponse[]
|
items: DatasetCreateResponse[]
|
||||||
total: number
|
total: number
|
||||||
|
|||||||
Reference in New Issue
Block a user