[fix/feat]:[FL-74][高程数据支持DEM地形瓦片预览和线路地形图]

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
chengkai3
2026-06-10 00:26:09 +08:00
parent 640a262412
commit 2ad2405cd3
18 changed files with 1932 additions and 28 deletions
+15
View File
@@ -283,6 +283,21 @@
- 一期采样策略为 CSV 点集最近邻(非栅格插值),用于先打通管理与回填闭环;默认推荐回填模式 `fill_null_only`,避免覆盖人工高程。
- 高频通知 topic 为 `admin.elevation`,线路联动通知沿用 `admin.power-lines`
- 高程数据文件格式已扩展为:`csv/img/tif/tiff`
## DEM 地形瓦片口径(2026-06-09
- 高程数据集在“分析成功”后会自动尝试派发 DEM 地形瓦片构建任务;管理端同时保留手动 `POST /api/v1/elevation/datasets/{id}/terrain/build` 触发入口,用于重试或重建。
- 地形瓦片产物采用 Cesium `heightmap-1.0` 规范,落盘到数据集目录下的 `terrain/`
- `terrain/layer.json`
- `terrain/{z}/{x}/{y}.terrain`
- 当前服务口径:
- `GET /api/v1/elevation/datasets/{id}/terrain/layer.json`
- `GET /api/v1/elevation/datasets/{id}/terrain/{z}/{x}/{y}.terrain`
- 前端加载 Cesium DEM 时,必须通过 `Cesium.Resource` 显式附带 `Authorization: Bearer <token>`;不能假设仅靠 refresh cookie 即可访问地形接口。
- 前端地形地址生成统一复用 `web/src/lib/elevation-terrain.ts`
- `getElevationTerrainRenderState()`
- `getElevationTerrainLayerUrl()`
- `/admin/elevation` 负责展示地形状态、手动触发构建和预览地图加载真实地形;`/admin/power-lines` 负责选择已接入的 DEM 数据集和垂直夸张倍数,不在页面层重复实现地形地址或状态判断逻辑。
- `csv`:继续使用点集最近邻采样。
- `img/tif/tiff`:使用栅格像元采样(按杆塔经纬度取值,必要时自动做 CRS 转换)。
- 栅格实现口径:
+48 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile, status
from sqlalchemy.orm import Session
from ...core.database import get_db
@@ -19,6 +19,9 @@ from ...schemas.elevation import (
ElevationDatasetListResponse,
ElevationDatasetPreviewResponse,
ElevationDatasetSummary,
ElevationDatasetTerrainBuildResponse,
ElevationDatasetTerrainTaskStatusResponse,
ElevationTerrainLayerResponse,
ElevationDatasetUpdateRequest,
)
from ...services.elevation_service import (
@@ -26,6 +29,9 @@ from ...services.elevation_service import (
create_dataset,
delete_dataset,
get_dataset_analysis_task_status,
get_dataset_terrain_layer,
get_dataset_terrain_task_status,
get_dataset_terrain_tile,
get_job_by_id,
import_dataset_data_files,
import_datasets_from_csv,
@@ -34,6 +40,7 @@ from ...services.elevation_service import (
list_jobs,
preview_dataset,
queue_dataset_analysis,
queue_dataset_terrain_build,
serialize_job,
update_dataset,
)
@@ -129,6 +136,15 @@ def analyze_elevation_dataset(
return queue_dataset_analysis(db, dataset_id=dataset_id, actor=current_user.user)
@router.post("/datasets/{dataset_id}/terrain/build", response_model=ElevationDatasetTerrainBuildResponse)
def build_elevation_dataset_terrain(
dataset_id: str,
current_user: CurrentUser = Depends(require_permission("elevation.manage")),
db: Session = Depends(get_db),
) -> ElevationDatasetTerrainBuildResponse:
return queue_dataset_terrain_build(db, dataset_id=dataset_id, actor=current_user.user)
@router.get("/datasets/{dataset_id}/preview", response_model=ElevationDatasetPreviewResponse)
def preview_elevation_dataset(
dataset_id: str,
@@ -161,6 +177,37 @@ def get_elevation_dataset_analysis_task_status(
return get_dataset_analysis_task_status(db, dataset_id=dataset_id)
@router.get("/datasets/{dataset_id}/terrain/status", response_model=ElevationDatasetTerrainTaskStatusResponse)
def get_elevation_dataset_terrain_status(
dataset_id: str,
_: CurrentUser = Depends(require_any_permission("elevation.read", "elevation.manage")),
db: Session = Depends(get_db),
) -> ElevationDatasetTerrainTaskStatusResponse:
return get_dataset_terrain_task_status(db, dataset_id=dataset_id)
@router.get("/datasets/{dataset_id}/terrain/layer.json", response_model=ElevationTerrainLayerResponse)
def get_elevation_dataset_terrain_layer(
dataset_id: str,
_: CurrentUser = Depends(require_any_permission("elevation.read", "elevation.manage")),
db: Session = Depends(get_db),
) -> ElevationTerrainLayerResponse:
return get_dataset_terrain_layer(db, dataset_id=dataset_id)
@router.get("/datasets/{dataset_id}/terrain/{z}/{x}/{y}.terrain")
def get_elevation_dataset_terrain_tile_endpoint(
dataset_id: str,
z: int,
x: int,
y: int,
_: CurrentUser = Depends(require_any_permission("elevation.read", "elevation.manage")),
db: Session = Depends(get_db),
) -> Response:
content = get_dataset_terrain_tile(db, dataset_id=dataset_id, z=z, x=x, y=y)
return Response(content=content, media_type="application/octet-stream")
@router.get("/jobs", response_model=ElevationApplyJobListResponse)
def get_elevation_jobs(
line_id: str | None = Query(default=None),
+87
View File
@@ -276,6 +276,93 @@ def _ensure_elevation_dataset_column_compatibility() -> None:
"Detected missing elevation_dataset.analysis_finished_at; added nullable analysis finish time column.",
)
if "terrain_status" not in column_names:
connection.execute(
text("ALTER TABLE elevation_dataset ADD COLUMN IF NOT EXISTS terrain_status VARCHAR(32)"),
)
connection.execute(
text(
"""
UPDATE elevation_dataset
SET terrain_status = CASE
WHEN lower(coalesce(file_format, '')) IN ('img', 'tif', 'tiff') THEN 'pending'
ELSE 'not_supported'
END
WHERE terrain_status IS NULL
"""
),
)
connection.execute(
text("ALTER TABLE elevation_dataset ALTER COLUMN terrain_status SET NOT NULL"),
)
logger.warning(
"Detected missing elevation_dataset.terrain_status; added and backfilled by file format.",
)
if "terrain_task_id" not in column_names:
connection.execute(
text("ALTER TABLE elevation_dataset ADD COLUMN IF NOT EXISTS terrain_task_id VARCHAR(128)"),
)
logger.warning(
"Detected missing elevation_dataset.terrain_task_id; added nullable terrain task id column.",
)
if "terrain_error_message" not in column_names:
connection.execute(
text("ALTER TABLE elevation_dataset ADD COLUMN IF NOT EXISTS terrain_error_message TEXT"),
)
logger.warning(
"Detected missing elevation_dataset.terrain_error_message; added nullable terrain error column.",
)
if "terrain_root_path" not in column_names:
connection.execute(
text("ALTER TABLE elevation_dataset ADD COLUMN IF NOT EXISTS terrain_root_path VARCHAR(2048)"),
)
logger.warning(
"Detected missing elevation_dataset.terrain_root_path; added nullable terrain root path column.",
)
if "terrain_url_template" not in column_names:
connection.execute(
text("ALTER TABLE elevation_dataset ADD COLUMN IF NOT EXISTS terrain_url_template VARCHAR(2048)"),
)
logger.warning(
"Detected missing elevation_dataset.terrain_url_template; added nullable terrain URL template column.",
)
if "terrain_min_zoom" not in column_names:
connection.execute(
text("ALTER TABLE elevation_dataset ADD COLUMN IF NOT EXISTS terrain_min_zoom INTEGER"),
)
logger.warning(
"Detected missing elevation_dataset.terrain_min_zoom; added nullable terrain min zoom column.",
)
if "terrain_max_zoom" not in column_names:
connection.execute(
text("ALTER TABLE elevation_dataset ADD COLUMN IF NOT EXISTS terrain_max_zoom INTEGER"),
)
logger.warning(
"Detected missing elevation_dataset.terrain_max_zoom; added nullable terrain max zoom column.",
)
if "terrain_bounds" not in column_names:
connection.execute(
text("ALTER TABLE elevation_dataset ADD COLUMN IF NOT EXISTS terrain_bounds JSON"),
)
logger.warning(
"Detected missing elevation_dataset.terrain_bounds; added nullable terrain bounds column.",
)
if "terrain_metadata" not in column_names:
connection.execute(
text("ALTER TABLE elevation_dataset ADD COLUMN IF NOT EXISTS terrain_metadata JSON"),
)
logger.warning(
"Detected missing elevation_dataset.terrain_metadata; added nullable terrain metadata column.",
)
def _ensure_atp_simulation_run_column_compatibility() -> None:
if not database_url.startswith("postgresql"):
+11 -2
View File
@@ -1,10 +1,10 @@
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from uuid import uuid4
from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, String, Text
from sqlalchemy import JSON, DateTime, Float, ForeignKey, Index, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ..core.database import Base
@@ -42,6 +42,15 @@ class ElevationDataset(Base):
analysis_error_message: Mapped[str | None] = mapped_column(Text)
analysis_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
analysis_finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
terrain_status: Mapped[str] = mapped_column(String(32), default="not_supported", index=True)
terrain_task_id: Mapped[str | None] = mapped_column(String(128), index=True)
terrain_error_message: Mapped[str | None] = mapped_column(Text)
terrain_root_path: Mapped[str | None] = mapped_column(String(2048))
terrain_url_template: Mapped[str | None] = mapped_column(String(2048))
terrain_min_zoom: Mapped[int | None] = mapped_column(Integer)
terrain_max_zoom: Mapped[int | None] = mapped_column(Integer)
terrain_bounds: Mapped[dict[str, Any] | None] = mapped_column(JSON)
terrain_metadata: Mapped[dict[str, Any] | None] = mapped_column(JSON)
sample_count: Mapped[int] = mapped_column(Integer, default=0)
bbox_min_lon: Mapped[float | None] = mapped_column(Float)
bbox_max_lon: Mapped[float | None] = mapped_column(Float)
+45 -1
View File
@@ -1,13 +1,14 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from typing import Any, Literal
from pydantic import BaseModel, Field
ElevationDatasetStatus = Literal["active", "disabled"]
ElevationDatasetUsageStatus = Literal["idle", "in_use"]
ElevationDatasetTerrainStatus = Literal["pending", "processing", "ready", "failed", "not_supported"]
ElevationApplyMode = Literal["fill_null_only", "overwrite_all"]
ElevationApplyJobStatus = Literal["pending", "running", "success", "failed"]
@@ -34,6 +35,15 @@ class ElevationDatasetSummary(BaseModel):
analysis_error_message: str | None = None
analysis_started_at: datetime | None = None
analysis_finished_at: datetime | None = None
terrain_status: ElevationDatasetTerrainStatus = "not_supported"
terrain_task_id: str | None = None
terrain_error_message: str | None = None
terrain_root_path: str | None = None
terrain_url_template: str | None = None
terrain_min_zoom: int | None = None
terrain_max_zoom: int | None = None
terrain_bounds: dict[str, Any] | None = None
terrain_metadata: dict[str, Any] | None = None
notes: str | None = None
create_date: datetime
create_user: str | None = None
@@ -81,6 +91,14 @@ class ElevationDatasetAnalyzeResponse(BaseModel):
warnings: list[str] = Field(default_factory=list)
class ElevationDatasetTerrainBuildResponse(BaseModel):
dataset: ElevationDatasetSummary
task_id: str | None = None
queued: bool = True
detail: str | None = None
warnings: list[str] = Field(default_factory=list)
class ElevationDatasetPreviewPoint(BaseModel):
longitude: float
latitude: float
@@ -173,6 +191,32 @@ class ElevationDatasetAnalysisTaskStatusResponse(BaseModel):
update_date: datetime | None = None
class ElevationDatasetTerrainTaskStatusResponse(BaseModel):
dataset_id: str
dataset_code: str
task_id: str | None = None
status: Literal["queued", "running", "success", "failed", "unknown", "not_found"] = "not_found"
detail: str | None = None
terrain_url_template: str | None = None
terrain_min_zoom: int | None = None
terrain_max_zoom: int | None = None
update_date: datetime | None = None
class ElevationTerrainLayerResponse(BaseModel):
tilejson: str = "2.1.0"
format: str = "heightmap-1.0"
version: str = "1.0.0"
scheme: Literal["tms"] = "tms"
projection: Literal["EPSG:4326"] = "EPSG:4326"
tiles: list[str]
maxzoom: int
extensions: list[str] = Field(default_factory=list)
attribution: str | None = None
bounds: list[float] | None = None
available: list[list[dict[str, int]]] | None = None
class ElevationApplyJobSummary(BaseModel):
id: str
line_id: str
+704
View File
@@ -3,6 +3,8 @@ from __future__ import annotations
import asyncio
import csv
import io
import json
import math
import mimetypes
import zipfile
from dataclasses import dataclass
@@ -29,6 +31,9 @@ from ..schemas.elevation import (
ElevationDatasetAnalyzeResponse,
ElevationDatasetBatchImportResponse,
ElevationDatasetDataImportResponse,
ElevationDatasetTerrainBuildResponse,
ElevationDatasetTerrainTaskStatusResponse,
ElevationTerrainLayerResponse,
ElevationDatasetPreviewCell,
ElevationDatasetPreviewDiagnostics,
ElevationDatasetCreateRequest,
@@ -61,6 +66,22 @@ RASTER_FILE_FORMATS = {"img", "tif", "tiff"}
IMPORTABLE_ELEVATION_EXTENSIONS = set(ELEVATION_FILE_EXT_FORMAT_MAP.keys())
IMPORTABLE_ARCHIVE_EXTENSIONS = {".zip"}
MAX_SAMPLE_COUNT_INT = 2_147_483_647
TERRAIN_TILE_SIZE = 65
TERRAIN_ROOT_DIRNAME = "terrain"
TERRAIN_TILE_VERSION = "1.0.0"
TERRAIN_TILE_FORMAT = "heightmap-1.0"
TERRAIN_TILE_PROJECTION = "EPSG:4326"
TERRAIN_DEFAULT_MIN_ZOOM = 0
TERRAIN_DEFAULT_MAX_ZOOM = 6
TERRAIN_MAX_ALLOWED_ZOOM = 10
TERRAIN_CHILD_MASK_SW = 1
TERRAIN_CHILD_MASK_SE = 2
TERRAIN_CHILD_MASK_NW = 4
TERRAIN_CHILD_MASK_NE = 8
TERRAIN_WATER_MASK_ALL_LAND = b"\x00"
TERRAIN_CONTENT_TYPE = "application/octet-stream"
TERRAIN_SUPPORTED_DATASET_FORMATS = RASTER_FILE_FORMATS
TERRAIN_BUILD_TOOL = "builtin-heightmap-1.0"
@dataclass
@@ -105,6 +126,14 @@ class ElevationDatasetBatchImportStats:
self.items = []
@dataclass
class _TerrainBuildArtifacts:
min_zoom: int
max_zoom: int
bounds: dict[str, float]
metadata: dict[str, Any]
def serialize_dataset(item: ElevationDataset) -> ElevationDatasetSummary:
return ElevationDatasetSummary(
id=item.id,
@@ -128,6 +157,15 @@ def serialize_dataset(item: ElevationDataset) -> ElevationDatasetSummary:
analysis_error_message=item.analysis_error_message,
analysis_started_at=item.analysis_started_at,
analysis_finished_at=item.analysis_finished_at,
terrain_status=item.terrain_status, # type: ignore[arg-type]
terrain_task_id=item.terrain_task_id,
terrain_error_message=item.terrain_error_message,
terrain_root_path=item.terrain_root_path,
terrain_url_template=item.terrain_url_template,
terrain_min_zoom=item.terrain_min_zoom,
terrain_max_zoom=item.terrain_max_zoom,
terrain_bounds=item.terrain_bounds,
terrain_metadata=item.terrain_metadata,
notes=item.notes,
create_date=item.create_date,
create_user=item.create_user,
@@ -216,6 +254,60 @@ def get_dataset_by_code(db: Session, code: str) -> ElevationDataset | None:
).scalar_one_or_none()
def _supports_terrain_build(dataset: ElevationDataset) -> bool:
return _resolve_dataset_file_format(dataset) in TERRAIN_SUPPORTED_DATASET_FORMATS
def _default_terrain_status_for_format(file_format: str) -> str:
return "pending" if file_format in TERRAIN_SUPPORTED_DATASET_FORMATS else "not_supported"
def _sync_dataset_terrain_support(dataset: ElevationDataset) -> None:
file_format = _resolve_dataset_file_format(dataset)
if file_format in TERRAIN_SUPPORTED_DATASET_FORMATS:
if dataset.terrain_status == "not_supported":
dataset.terrain_status = "pending"
return
dataset.terrain_status = "not_supported"
dataset.terrain_task_id = None
dataset.terrain_error_message = None
dataset.terrain_root_path = None
dataset.terrain_url_template = None
dataset.terrain_min_zoom = None
dataset.terrain_max_zoom = None
dataset.terrain_bounds = None
dataset.terrain_metadata = None
def _resolve_dataset_terrain_dir(dataset_code: str) -> str:
return join_virtual_path(_resolve_dataset_dir(dataset_code), TERRAIN_ROOT_DIRNAME)
def _resolve_dataset_terrain_tile_path(*, dataset_code: str, z: int, x: int, y: int) -> str:
return join_virtual_path(join_virtual_path(join_virtual_path(_resolve_dataset_terrain_dir(dataset_code), str(z)), str(x)), f"{y}.terrain")
def _build_dataset_terrain_url_template(dataset_id: str) -> str:
return f"/api/v1/elevation/datasets/{dataset_id}/terrain/{{z}}/{{x}}/{{y}}.terrain?v={TERRAIN_TILE_VERSION}"
def _map_dataset_task_status(status_value: str | None) -> str:
status_map = {
"queued": "queued",
"pending": "queued",
"running": "running",
"processing": "running",
"success": "success",
"ready": "success",
"failed": "failed",
"unknown": "unknown",
"not_started": "not_found",
"not_supported": "not_found",
}
return status_map.get(status_value or "", "unknown")
def list_dataset_files(
db: Session,
*,
@@ -307,6 +399,7 @@ def create_dataset(
resolution_m=payload.resolution_m,
status="active",
usage_status="idle",
terrain_status=_default_terrain_status_for_format(file_format),
notes=_normalize_str(payload.notes),
create_date=now,
create_user=actor.id,
@@ -488,6 +581,15 @@ def import_dataset_data_files(
dataset.file_path = preferred_file_path
dataset.file_format = _detect_file_format(preferred_file_path)
dataset.usage_status = "idle"
dataset.terrain_status = _default_terrain_status_for_format(dataset.file_format)
dataset.terrain_task_id = None
dataset.terrain_error_message = None
dataset.terrain_root_path = None
dataset.terrain_url_template = None
dataset.terrain_min_zoom = None
dataset.terrain_max_zoom = None
dataset.terrain_bounds = None
dataset.terrain_metadata = None
dataset.update_user = actor.id
dataset.update_date = utcnow()
db.commit()
@@ -570,6 +672,172 @@ def get_dataset_analysis_task_status(
)
def get_dataset_terrain_task_status(
db: Session,
*,
dataset_id: str,
) -> ElevationDatasetTerrainTaskStatusResponse:
dataset = get_dataset_by_id(db, dataset_id)
if not dataset:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="高程数据集不存在")
mapped_status = _map_dataset_task_status(dataset.terrain_status)
detail = dataset.terrain_error_message
if detail is None:
if mapped_status == "queued":
detail = "地形瓦片任务已提交,等待执行。"
elif mapped_status == "running":
detail = "地形瓦片生成中。"
elif mapped_status == "success":
detail = "地形瓦片已就绪。"
elif dataset.terrain_status == "not_supported":
detail = "当前数据集格式不支持地形瓦片生成。"
return ElevationDatasetTerrainTaskStatusResponse(
dataset_id=dataset.id,
dataset_code=dataset.code,
task_id=dataset.terrain_task_id,
status=mapped_status, # type: ignore[arg-type]
detail=detail,
terrain_url_template=dataset.terrain_url_template,
terrain_min_zoom=dataset.terrain_min_zoom,
terrain_max_zoom=dataset.terrain_max_zoom,
update_date=dataset.update_date,
)
def queue_dataset_terrain_build(
db: Session,
*,
dataset_id: str,
actor: User,
) -> ElevationDatasetTerrainBuildResponse:
item = get_dataset_by_id(db, dataset_id)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="高程数据集不存在")
if item.status != "active":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="高程数据集未启用")
if not _supports_terrain_build(item):
item.terrain_status = "not_supported"
item.terrain_task_id = None
item.terrain_error_message = None
item.update_user = actor.id
item.update_date = utcnow()
db.commit()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前数据集格式不支持地形瓦片生成")
if item.terrain_status == "processing" or (item.terrain_status == "pending" and item.terrain_task_id):
return ElevationDatasetTerrainBuildResponse(
dataset=serialize_dataset(item),
task_id=item.terrain_task_id,
queued=False,
detail="地形瓦片任务已存在,无需重复提交。",
warnings=[],
)
item.terrain_status = "pending"
item.terrain_error_message = None
item.terrain_root_path = None
item.terrain_url_template = None
item.terrain_min_zoom = None
item.terrain_max_zoom = None
item.terrain_bounds = None
item.terrain_metadata = None
item.update_user = actor.id
item.update_date = utcnow()
db.commit()
try:
task = _dispatch_elevation_dataset_terrain_task(dataset_id=item.id, actor_user_id=actor.id)
except Exception as exc:
item.terrain_status = "failed"
item.terrain_error_message = str(exc)
item.terrain_task_id = None
item.update_user = actor.id
item.update_date = utcnow()
db.commit()
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"地形瓦片任务派发失败: {exc}") from exc
item.terrain_task_id = str(task.id)
item.update_user = actor.id
item.update_date = utcnow()
db.commit()
saved = get_dataset_by_id(db, dataset_id)
if not saved:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="地形瓦片任务保存失败")
_publish_elevation_change(
"elevation.dataset.terrain.queued",
{"action": "dataset_terrain_queued", "dataset_id": saved.id, "task_id": saved.terrain_task_id},
)
return ElevationDatasetTerrainBuildResponse(
dataset=serialize_dataset(saved),
task_id=saved.terrain_task_id,
queued=True,
detail="地形瓦片任务已提交,等待执行。",
warnings=[],
)
def get_dataset_terrain_layer(
db: Session,
*,
dataset_id: str,
) -> ElevationTerrainLayerResponse:
dataset = get_dataset_by_id(db, dataset_id)
if not dataset:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="高程数据集不存在")
if dataset.terrain_status != "ready" or not dataset.terrain_root_path:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="地形瓦片尚未就绪")
mount = _require_mount(db, dataset.mount_code)
driver = _build_driver_or_400(mount)
layer_path = _resolve_dataset_terrain_layer_path(dataset.code)
try:
payload = driver.read_file(layer_path)
except StoragePathNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="地形 layer.json 不存在") from exc
except StorageInvalidPathError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
except StorageDriverError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
try:
data = json.loads(payload.content.decode("utf-8"))
except Exception as exc:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="地形 layer.json 解析失败") from exc
return ElevationTerrainLayerResponse.model_validate(data)
def get_dataset_terrain_tile(
db: Session,
*,
dataset_id: str,
z: int,
x: int,
y: int,
) -> bytes:
dataset = get_dataset_by_id(db, dataset_id)
if not dataset:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="高程数据集不存在")
if dataset.terrain_status != "ready" or not dataset.terrain_root_path:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="地形瓦片尚未就绪")
mount = _require_mount(db, dataset.mount_code)
driver = _build_driver_or_400(mount)
tile_path = _resolve_dataset_terrain_tile_path(dataset_code=dataset.code, z=z, x=x, y=y)
try:
payload = driver.read_file(tile_path)
except StoragePathNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="地形瓦片不存在") from exc
except StorageInvalidPathError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
except StorageDriverError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
return payload.content
def update_dataset(
db: Session,
dataset_id: str,
@@ -593,6 +861,7 @@ def update_dataset(
if "notes" in update_data:
item.notes = _normalize_str(update_data["notes"])
_sync_dataset_terrain_support(item)
item.update_user = actor.id
item.update_date = utcnow()
db.commit()
@@ -676,6 +945,8 @@ def analyze_dataset(
item.bbox_max_lon = stats["bbox_max_lon"]
item.bbox_min_lat = stats["bbox_min_lat"]
item.bbox_max_lat = stats["bbox_max_lat"]
if _supports_terrain_build(item) and item.terrain_status == "not_supported":
item.terrain_status = "pending"
item.analysis_status = "success"
item.analysis_error_message = None
item.analysis_finished_at = utcnow()
@@ -691,6 +962,7 @@ def analyze_dataset(
"elevation.dataset.analyzed",
{"action": "dataset_analyzed", "dataset_id": saved.id},
)
_queue_dataset_terrain_build_after_analysis(db, dataset=saved, actor_user_id=actor.id)
return ElevationDatasetAnalyzeResponse(
dataset=serialize_dataset(saved),
task_id=saved.analysis_task_id,
@@ -932,6 +1204,12 @@ def _dispatch_elevation_dataset_analysis_task(*, dataset_id: str, actor_user_id:
return analyze_elevation_dataset_job.delay(dataset_id, actor_user_id)
def _dispatch_elevation_dataset_terrain_task(*, dataset_id: str, actor_user_id: str | None):
from ..tasks.elevation_tasks import build_elevation_dataset_terrain_job
return build_elevation_dataset_terrain_job.delay(dataset_id, actor_user_id)
def execute_apply_job(job_id: str) -> None:
db = SessionLocal()
try:
@@ -1117,6 +1395,105 @@ def execute_dataset_analysis_job(*, dataset_id: str, actor_user_id: str | None)
db.close()
def execute_dataset_terrain_build_job(*, dataset_id: str, actor_user_id: str | None) -> None:
db = SessionLocal()
try:
item = get_dataset_by_id(db, dataset_id)
if not item:
return
if not _supports_terrain_build(item):
item.terrain_status = "not_supported"
item.terrain_task_id = None
item.terrain_error_message = None
item.update_date = utcnow()
db.commit()
return
item.terrain_status = "processing"
item.terrain_error_message = None
item.update_date = utcnow()
db.commit()
_publish_elevation_change(
"elevation.dataset.terrain.running",
{"action": "dataset_terrain_running", "dataset_id": item.id},
)
actor = db.execute(select(User).where(User.id == actor_user_id)).scalar_one_or_none() if actor_user_id else None
if actor is None:
actor = db.execute(select(User).where(User.status == "active").order_by(User.id.asc())).scalars().first()
if actor is None:
item.terrain_status = "failed"
item.terrain_error_message = "未找到可用用户执行地形瓦片生成"
item.update_date = utcnow()
db.commit()
_publish_elevation_change(
"elevation.dataset.terrain.failed",
{"action": "dataset_terrain_failed", "dataset_id": item.id},
)
return
artifacts = _build_dataset_terrain_tiles(db, item)
saved = get_dataset_by_id(db, dataset_id)
if saved is None:
return
saved.terrain_status = "ready"
saved.terrain_error_message = None
saved.terrain_root_path = _resolve_dataset_terrain_dir(saved.code)
saved.terrain_url_template = _build_dataset_terrain_url_template(saved.id)
saved.terrain_min_zoom = artifacts.min_zoom
saved.terrain_max_zoom = artifacts.max_zoom
saved.terrain_bounds = artifacts.bounds
saved.terrain_metadata = artifacts.metadata
saved.update_date = utcnow()
saved.update_user = actor.id
db.commit()
_publish_elevation_change(
"elevation.dataset.terrain.ready",
{"action": "dataset_terrain_ready", "dataset_id": saved.id},
)
except Exception as exc:
failed = get_dataset_by_id(db, dataset_id)
if failed is not None:
failed.terrain_status = "failed"
failed.terrain_error_message = str(exc)
failed.update_date = utcnow()
db.commit()
_publish_elevation_change(
"elevation.dataset.terrain.failed",
{"action": "dataset_terrain_failed", "dataset_id": failed.id},
)
raise
finally:
db.close()
def _queue_dataset_terrain_build_after_analysis(db: Session, *, dataset: ElevationDataset, actor_user_id: str | None) -> None:
if not _supports_terrain_build(dataset):
return
if dataset.terrain_status in {"processing", "ready"}:
return
if dataset.terrain_status == "pending" and dataset.terrain_task_id:
return
dataset.terrain_status = "pending"
dataset.terrain_error_message = None
dataset.update_date = utcnow()
db.commit()
try:
task = _dispatch_elevation_dataset_terrain_task(dataset_id=dataset.id, actor_user_id=actor_user_id)
dataset.terrain_task_id = str(task.id)
dataset.update_date = utcnow()
db.commit()
_publish_elevation_change(
"elevation.dataset.terrain.queued",
{"action": "dataset_terrain_queued", "dataset_id": dataset.id, "task_id": dataset.terrain_task_id},
)
except Exception as exc:
dataset.terrain_status = "failed"
dataset.terrain_error_message = str(exc)
dataset.update_date = utcnow()
db.commit()
def _ensure_dataset_file_exists(db: Session, *, mount_code: str, file_path: str) -> None:
mount = _require_mount(db, mount_code.strip())
driver = _build_driver_or_400(mount)
@@ -1152,6 +1529,10 @@ def _resolve_dataset_file_path(*, dataset_code: str, filename: str | None) -> st
return join_virtual_path(dataset_dir, normalized_name)
def _resolve_dataset_terrain_layer_path(dataset_code: str) -> str:
return join_virtual_path(_resolve_dataset_terrain_dir(dataset_code), "layer.json")
def _normalize_dataset_code(value: str) -> str:
code = value.strip()
if not code:
@@ -1839,6 +2220,329 @@ def _compute_raster_stats(
)
def _compute_raster_wgs84_bounds(*, rasterio: Any, src: Any) -> dict[str, float]:
source_bounds = src.bounds
if src.crs is None or str(src.crs) in {"EPSG:4326", "OGC:CRS84"}:
return _clamp_bounds_to_wgs84(
{
"west": float(source_bounds.left),
"south": float(source_bounds.bottom),
"east": float(source_bounds.right),
"north": float(source_bounds.top),
}
)
xs, ys = rasterio.warp.transform(
src.crs,
"EPSG:4326",
[float(source_bounds.left), float(source_bounds.right), float(source_bounds.left), float(source_bounds.right)],
[float(source_bounds.bottom), float(source_bounds.bottom), float(source_bounds.top), float(source_bounds.top)],
)
return _clamp_bounds_to_wgs84(
{
"west": float(min(xs)),
"south": float(min(ys)),
"east": float(max(xs)),
"north": float(max(ys)),
}
)
def _clamp_bounds_to_wgs84(bounds: dict[str, float]) -> dict[str, float]:
west = max(-180.0, min(180.0, float(bounds["west"])))
east = max(-180.0, min(180.0, float(bounds["east"])))
south = max(-90.0, min(90.0, float(bounds["south"])))
north = max(-90.0, min(90.0, float(bounds["north"])))
if east <= west:
east = min(180.0, west + 1e-6)
if north <= south:
north = min(90.0, south + 1e-6)
return {
"west": west,
"south": south,
"east": east,
"north": north,
}
def _resolve_terrain_zoom_limits(*, bounds: dict[str, float], resolution_m: float | None) -> tuple[int, int]:
min_zoom = TERRAIN_DEFAULT_MIN_ZOOM
if resolution_m is None or resolution_m <= 0:
base_max_zoom = TERRAIN_DEFAULT_MAX_ZOOM
else:
meters_per_sample = max(float(resolution_m), 1.0)
numerator = 180.0 * 111_320.0
denominator = max((TERRAIN_TILE_SIZE - 1) * meters_per_sample, 1.0)
base_max_zoom = int(math.floor(math.log2(max(numerator / denominator, 1.0))))
base_max_zoom = max(TERRAIN_DEFAULT_MIN_ZOOM, min(TERRAIN_MAX_ALLOWED_ZOOM, base_max_zoom))
max_zoom = max(min_zoom, base_max_zoom)
while max_zoom > min_zoom:
availability = _build_terrain_available_ranges(bounds=bounds, max_zoom=max_zoom)
tile_count = sum((tile_range["endX"] - tile_range["startX"] + 1) * (tile_range["endY"] - tile_range["startY"] + 1) for ranges in availability.values() for tile_range in ranges)
if tile_count <= 512:
break
max_zoom -= 1
return min_zoom, max_zoom
def _tile_counts(level: int) -> tuple[int, int]:
return 1 << (level + 1), 1 << level
def _tile_span_degrees(level: int) -> float:
return 180.0 / float(1 << level)
def _build_terrain_available_ranges(*, bounds: dict[str, float], max_zoom: int) -> dict[int, list[dict[str, int]]]:
ranges: dict[int, list[dict[str, int]]] = {
0: [{"startX": 0, "endX": 1, "startY": 0, "endY": 0}],
}
epsilon = 1e-9
for level in range(1, max_zoom + 1):
x_count, y_count = _tile_counts(level)
span = _tile_span_degrees(level)
start_x = int(math.floor((bounds["west"] + 180.0) / span))
end_x = int(math.floor((bounds["east"] + 180.0 - epsilon) / span))
start_y = int(math.floor((bounds["south"] + 90.0) / span))
end_y = int(math.floor((bounds["north"] + 90.0 - epsilon) / span))
start_x = max(0, min(x_count - 1, start_x))
end_x = max(0, min(x_count - 1, end_x))
start_y = max(0, min(y_count - 1, start_y))
end_y = max(0, min(y_count - 1, end_y))
if end_x < start_x or end_y < start_y:
continue
ranges[level] = [{
"startX": start_x,
"endX": end_x,
"startY": start_y,
"endY": end_y,
}]
return ranges
def _tile_bounds_from_tms(level: int, x: int, y: int) -> dict[str, float]:
span = _tile_span_degrees(level)
west = -180.0 + x * span
east = west + span
south = -90.0 + y * span
north = south + span
return {
"west": west,
"south": south,
"east": east,
"north": north,
}
def _tile_intersects_bounds(tile_bounds: dict[str, float], bounds: dict[str, float]) -> bool:
return not (
tile_bounds["east"] <= bounds["west"]
or tile_bounds["west"] >= bounds["east"]
or tile_bounds["north"] <= bounds["south"]
or tile_bounds["south"] >= bounds["north"]
)
def _range_contains_tile(tile_range: dict[str, int], *, x: int, y: int) -> bool:
return tile_range["startX"] <= x <= tile_range["endX"] and tile_range["startY"] <= y <= tile_range["endY"]
def _build_terrain_child_mask(*, availability: dict[int, list[dict[str, int]]], level: int, x: int, y: int, max_zoom: int) -> int:
if level >= max_zoom:
return 0
next_ranges = availability.get(level + 1, [])
if not next_ranges:
return 0
mask = 0
children = (
(TERRAIN_CHILD_MASK_SW, 2 * x, 2 * y),
(TERRAIN_CHILD_MASK_SE, 2 * x + 1, 2 * y),
(TERRAIN_CHILD_MASK_NW, 2 * x, 2 * y + 1),
(TERRAIN_CHILD_MASK_NE, 2 * x + 1, 2 * y + 1),
)
for bit, child_x, child_y in children:
if any(_range_contains_tile(tile_range, x=child_x, y=child_y) for tile_range in next_ranges):
mask |= bit
return mask
def _ensure_virtual_directory(driver: Any, path: str) -> None:
try:
driver.ensure_directory(path)
except StorageInvalidPathError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
except StorageDriverError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
def _delete_virtual_directory_if_exists(driver: Any, path: str) -> None:
try:
driver.delete_path(path, is_dir=True, recursive=True)
except StoragePathNotFoundError:
return
except StorageInvalidPathError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
except StorageDriverError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
def _write_virtual_file(driver: Any, *, path: str, content: bytes, content_type: str | None) -> None:
parent_path = str(Path(path).parent).replace("\\", "/")
if not parent_path.startswith("/"):
parent_path = f"/{parent_path}"
_ensure_virtual_directory(driver, parent_path)
try:
driver.write_file(path, content=content, content_type=content_type)
except StorageInvalidPathError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
except StorageDriverError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
def _build_dataset_terrain_tiles(db: Session, dataset: ElevationDataset) -> _TerrainBuildArtifacts:
mount = _require_mount(db, dataset.mount_code)
driver = _build_driver_or_400(mount)
terrain_dir = _resolve_dataset_terrain_dir(dataset.code)
_delete_virtual_directory_if_exists(driver, terrain_dir)
_ensure_virtual_directory(driver, terrain_dir)
with _open_raster_dataset(db, dataset) as opened:
rasterio = opened.rasterio
src = opened.dataset
bounds = _compute_raster_wgs84_bounds(rasterio=rasterio, src=src)
min_zoom, max_zoom = _resolve_terrain_zoom_limits(bounds=bounds, resolution_m=dataset.resolution_m)
availability = _build_terrain_available_ranges(bounds=bounds, max_zoom=max_zoom)
tile_count = 0
for level in range(min_zoom, max_zoom + 1):
ranges = availability.get(level, [])
for tile_range in ranges:
for tile_x in range(tile_range["startX"], tile_range["endX"] + 1):
for tile_y in range(tile_range["startY"], tile_range["endY"] + 1):
tile_count += 1
tile_bounds = _tile_bounds_from_tms(level, tile_x, tile_y)
child_mask = _build_terrain_child_mask(
availability=availability,
level=level,
x=tile_x,
y=tile_y,
max_zoom=max_zoom,
)
tile_bytes = _generate_heightmap_tile_bytes(
rasterio=rasterio,
src=src,
tile_bounds=tile_bounds,
child_mask=child_mask,
is_blank_root=level == 0 and not _tile_intersects_bounds(tile_bounds, bounds),
)
tile_path = _resolve_dataset_terrain_tile_path(dataset_code=dataset.code, z=level, x=tile_x, y=tile_y)
_write_virtual_file(driver, path=tile_path, content=tile_bytes, content_type=TERRAIN_CONTENT_TYPE)
layer_payload = ElevationTerrainLayerResponse(
tiles=[f"{{z}}/{{x}}/{{y}}.terrain?v={TERRAIN_TILE_VERSION}"],
maxzoom=max_zoom,
attribution=f"{dataset.code} {dataset.name}",
bounds=[bounds["west"], bounds["south"], bounds["east"], bounds["north"]],
available=[availability.get(level, []) for level in range(0, max_zoom + 1)],
).model_dump(mode="json")
_write_virtual_file(
driver,
path=_resolve_dataset_terrain_layer_path(dataset.code),
content=json.dumps(layer_payload, ensure_ascii=True, separators=(",", ":")).encode("utf-8"),
content_type="application/json",
)
metadata = {
"tool": TERRAIN_BUILD_TOOL,
"format": TERRAIN_TILE_FORMAT,
"projection": TERRAIN_TILE_PROJECTION,
"tile_size": TERRAIN_TILE_SIZE,
"generated_at": utcnow().isoformat(),
"source_crs": str(src.crs) if src.crs is not None else None,
"source_nodata": src.nodatavals[0] if src.nodatavals else None,
"resolution_m": dataset.resolution_m,
"tile_count": tile_count,
"layer_url": f"/api/v1/elevation/datasets/{dataset.id}/terrain/layer.json",
"terrain_url_template": _build_dataset_terrain_url_template(dataset.id),
}
return _TerrainBuildArtifacts(
min_zoom=min_zoom,
max_zoom=max_zoom,
bounds=bounds,
metadata=metadata,
)
def _generate_heightmap_tile_bytes(
*,
rasterio: Any,
src: Any,
tile_bounds: dict[str, float],
child_mask: int,
is_blank_root: bool,
) -> bytes:
import numpy as np
if is_blank_root:
heights = np.full((TERRAIN_TILE_SIZE, TERRAIN_TILE_SIZE), 0.0, dtype="float32")
else:
destination = np.full((TERRAIN_TILE_SIZE, TERRAIN_TILE_SIZE), np.nan, dtype="float32")
destination_transform = rasterio.transform.from_bounds(
tile_bounds["west"],
tile_bounds["south"],
tile_bounds["east"],
tile_bounds["north"],
TERRAIN_TILE_SIZE,
TERRAIN_TILE_SIZE,
)
band_nodata = src.nodatavals[0] if src.nodatavals else None
rasterio.warp.reproject(
source=rasterio.band(src, 1),
destination=destination,
src_transform=src.transform,
src_crs=src.crs,
src_nodata=band_nodata,
dst_transform=destination_transform,
dst_crs="EPSG:4326",
dst_nodata=np.nan,
resampling=rasterio.warp.Resampling.bilinear,
)
if np.isnan(destination).any():
nearest = np.full((TERRAIN_TILE_SIZE, TERRAIN_TILE_SIZE), np.nan, dtype="float32")
rasterio.warp.reproject(
source=rasterio.band(src, 1),
destination=nearest,
src_transform=src.transform,
src_crs=src.crs,
src_nodata=band_nodata,
dst_transform=destination_transform,
dst_crs="EPSG:4326",
dst_nodata=np.nan,
resampling=rasterio.warp.Resampling.nearest,
)
destination = np.where(np.isnan(destination), nearest, destination)
heights = np.nan_to_num(destination, nan=0.0).astype("float32")
encoded = _encode_heightmap_array(heights)
payload = bytearray(encoded.tobytes(order="C"))
payload.extend(bytes([child_mask]))
payload.extend(TERRAIN_WATER_MASK_ALL_LAND)
return bytes(payload)
def _encode_heightmap_array(heights: Any) -> Any:
import numpy as np
encoded = np.rint((heights + 1000.0) * 5.0)
encoded = np.clip(encoded, 0.0, float(256 * 256 - 1))
return encoded.astype("<u2")
def _build_raster_preview(
db: Session,
*,
+7 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from ..core.celery_app import celery_app
from ..services.elevation_service import execute_apply_job, execute_dataset_analysis_job
from ..services.elevation_service import execute_apply_job, execute_dataset_analysis_job, execute_dataset_terrain_build_job
@celery_app.task(name="app.tasks.elevation_tasks.apply_elevation_for_line_job")
@@ -14,3 +14,9 @@ def apply_elevation_for_line_job(job_id: str) -> dict[str, str]:
def analyze_elevation_dataset_job(dataset_id: str, actor_user_id: str | None) -> dict[str, str]:
execute_dataset_analysis_job(dataset_id=dataset_id, actor_user_id=actor_user_id)
return {"dataset_id": dataset_id, "status": "done"}
@celery_app.task(name="app.tasks.elevation_tasks.build_elevation_dataset_terrain_job")
def build_elevation_dataset_terrain_job(dataset_id: str, actor_user_id: str | None) -> dict[str, str]:
execute_dataset_terrain_build_job(dataset_id=dataset_id, actor_user_id=actor_user_id)
return {"dataset_id": dataset_id, "status": "done"}
+46
View File
@@ -110,6 +110,7 @@ def test_queue_dataset_analysis_reuses_existing_running_task(monkeypatch) -> Non
assert first.queued is True
assert first.task_id == "elev-task-1"
assert first.dataset.analysis_status == "queued"
assert first.dataset.terrain_status == "not_supported"
second = elevation_service.queue_dataset_analysis(session, dataset_id=dataset.id, actor=actor)
assert second.queued is False
@@ -119,6 +120,51 @@ def test_queue_dataset_analysis_reuses_existing_running_task(monkeypatch) -> Non
session.close()
def test_queue_dataset_terrain_build_reuses_existing_running_task(monkeypatch) -> None:
testing_session = _build_sessionmaker(ElevationDataset.__table__)
session: Session = testing_session()
try:
dataset = ElevationDataset(
code="ELEV-TERRAIN-001",
name="样例地形集",
file_format="tif",
mount_code="default",
dataset_dir="/elevation/datasets/ELEV-TERRAIN-001",
file_path="/elevation/datasets/ELEV-TERRAIN-001/data.tif",
status="active",
usage_status="idle",
terrain_status="pending",
)
session.add(dataset)
session.commit()
actor = User(
id="actor-1",
email="actor@example.com",
username="actor",
password_hash="hashed",
status="active",
)
monkeypatch.setattr(
elevation_service,
"_dispatch_elevation_dataset_terrain_task",
lambda **_: SimpleNamespace(id="terrain-task-1"),
)
first = elevation_service.queue_dataset_terrain_build(session, dataset_id=dataset.id, actor=actor)
assert first.queued is True
assert first.task_id == "terrain-task-1"
assert first.dataset.terrain_status == "pending"
second = elevation_service.queue_dataset_terrain_build(session, dataset_id=dataset.id, actor=actor)
assert second.queued is False
assert second.task_id == "terrain-task-1"
assert second.detail == "地形瓦片任务已存在,无需重复提交。"
finally:
session.close()
def test_wine_create_run_queues_task_and_worker_records_failure(monkeypatch) -> None:
testing_session = _build_sessionmaker(WineRun.__table__)
session: Session = testing_session()
+129
View File
@@ -0,0 +1,129 @@
from __future__ import annotations
import json
from types import SimpleNamespace
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.core.database import Base
from app.models.elevation import ElevationDataset
from app.services import elevation_service
def _build_session() -> Session:
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(bind=engine, tables=[ElevationDataset.__table__])
testing_session = sessionmaker(bind=engine, autocommit=False, autoflush=False, expire_on_commit=False)
return testing_session()
def test_generate_blank_heightmap_tile_has_expected_binary_layout() -> None:
tile_bytes = elevation_service._generate_heightmap_tile_bytes(
rasterio=None,
src=None,
tile_bounds={"west": -180.0, "south": -90.0, "east": 0.0, "north": 90.0},
child_mask=5,
is_blank_root=True,
)
expected_height_bytes = elevation_service.TERRAIN_TILE_SIZE * elevation_service.TERRAIN_TILE_SIZE * 2
assert len(tile_bytes) == expected_height_bytes + 1 + 1
assert tile_bytes[expected_height_bytes] == 5
assert tile_bytes[-1] == 0
def test_build_terrain_available_ranges_and_child_mask() -> None:
bounds = {"west": 110.0, "south": 20.0, "east": 122.0, "north": 32.0}
availability = elevation_service._build_terrain_available_ranges(bounds=bounds, max_zoom=2)
assert availability[0] == [{"startX": 0, "endX": 1, "startY": 0, "endY": 0}]
assert 1 in availability
assert 2 in availability
tile_range = availability[1][0]
child_mask = elevation_service._build_terrain_child_mask(
availability=availability,
level=1,
x=tile_range["startX"],
y=tile_range["startY"],
max_zoom=2,
)
assert child_mask > 0
def test_get_dataset_terrain_task_status_reports_ready_detail() -> None:
session = _build_session()
try:
dataset = ElevationDataset(
code="ELEV-READY-001",
name="已就绪地形",
file_format="tif",
mount_code="default",
dataset_dir="/elevation/datasets/ELEV-READY-001",
file_path="/elevation/datasets/ELEV-READY-001/data.tif",
status="active",
usage_status="idle",
terrain_status="ready",
terrain_task_id="terrain-task-1",
terrain_url_template="/api/v1/elevation/datasets/ds/terrain/{z}/{x}/{y}.terrain",
terrain_min_zoom=0,
terrain_max_zoom=4,
)
session.add(dataset)
session.commit()
payload = elevation_service.get_dataset_terrain_task_status(session, dataset_id=dataset.id)
assert payload.status == "success"
assert payload.detail == "地形瓦片已就绪。"
assert payload.terrain_url_template == dataset.terrain_url_template
assert payload.terrain_max_zoom == 4
finally:
session.close()
def test_get_dataset_terrain_layer_reads_driver_payload(monkeypatch) -> None:
session = _build_session()
try:
dataset = ElevationDataset(
code="ELEV-LAYER-001",
name="地形图层",
file_format="tif",
mount_code="default",
dataset_dir="/elevation/datasets/ELEV-LAYER-001",
file_path="/elevation/datasets/ELEV-LAYER-001/data.tif",
status="active",
usage_status="idle",
terrain_status="ready",
terrain_root_path="/elevation/datasets/ELEV-LAYER-001/terrain",
)
session.add(dataset)
session.commit()
layer_json = {
"tilejson": "2.1.0",
"format": "heightmap-1.0",
"version": "1.0.0",
"scheme": "tms",
"projection": "EPSG:4326",
"tiles": ["{z}/{x}/{y}.terrain?v=1.0.0"],
"maxzoom": 4,
"bounds": [110.0, 20.0, 122.0, 32.0],
"available": [[{"startX": 0, "endX": 1, "startY": 0, "endY": 0}]],
}
monkeypatch.setattr(elevation_service, "_require_mount", lambda *_args, **_kwargs: SimpleNamespace())
monkeypatch.setattr(
elevation_service,
"_build_driver_or_400",
lambda *_args, **_kwargs: SimpleNamespace(
read_file=lambda path: SimpleNamespace(path=path, content=json.dumps(layer_json).encode("utf-8")),
),
)
payload = elevation_service.get_dataset_terrain_layer(session, dataset_id=dataset.id)
assert payload.format == "heightmap-1.0"
assert payload.maxzoom == 4
assert payload.bounds == [110.0, 20.0, 122.0, 32.0]
finally:
session.close()
+81
View File
@@ -223,3 +223,84 @@
- 风险与关注点:
- 仅影响 `/admin/workers` 页面首屏展示,不涉及 Worker 数据接口、抽屉详情、权限或轮询逻辑。
## Work Log - 高程数据支持 DEM 地形瓦片预览与线路地形图(2026-06-09)
- 背景:
- Issue `FL-74` 要求高程数据除已有样本预览与线路高程回填外,还要支持:
- 将 DEM 栅格数据生成可供 Cesium 直接消费的地形瓦片;
- 在高程管理页预览真实 DEM 地形;
- 在线路管理页基于选定 DEM 显示线路地形图,并暴露地形状态与必要提示。
- 现状是:
- 后端已有高程数据集、分析任务和线路高程回填链路;
- 前端已有 Cesium 预览组件,但仍以点云/网格覆层为主,没有接入真正的 Cesium terrain provider。
- 本次改动:
- 后端:
- `api/app/models/elevation.py`
-`ElevationDataset` 增加 `terrain_status/task/error/root/url_template/min_zoom/max_zoom/bounds/metadata` 等字段。
- `api/app/core/database.py`
- 扩展兼容列自动补齐逻辑,为已有实例增量补地形字段,并对历史 raster/csv 数据做默认状态回填。
- `api/app/services/elevation_service.py`
- 新增 DEM 地形瓦片构建主链路:
- 栅格边界转换到 WGS84
- 计算 TMS 瓦片层级与范围;
- 生成 Cesium `heightmap-1.0` 瓦片二进制与 `layer.json`
- 分析成功后自动派发地形构建任务;
- 提供地形状态查询、手动构建、layer/tile 读取服务。
- `api/app/tasks/elevation_tasks.py`
- 新增 terrain build Celery task。
- `api/app/api/v1/elevation.py`
- 新增接口:
- `POST /api/v1/elevation/datasets/{id}/terrain/build`
- `GET /api/v1/elevation/datasets/{id}/terrain/status`
- `GET /api/v1/elevation/datasets/{id}/terrain/layer.json`
- `GET /api/v1/elevation/datasets/{id}/terrain/{z}/{x}/{y}.terrain`
- `api/tests/test_elevation_terrain_service.py`
- 补充地形瓦片编码、状态查询与 layer 读取测试。
- `api/tests/test_async_dispatch_services.py`
- 补充 terrain task 复用与不支持格式状态断言。
- 前端:
- `web/src/components/elevation-preview-cesium-map.tsx`
- 支持真实 terrain provider、指针高程读数、terrain failure 回退提示和数据集范围框。
- `web/src/components/power-line-cesium-map.tsx`
- 支持选定 DEM 地形、垂直夸张、塔位越界告警、地形加载失败回退和塔位高程来源展示。
- `web/src/app/admin/elevation/page.tsx`
- 增加地形状态列、地形层级列、地形状态弹窗、手动生成/重试地形入口;
- 高程预览弹窗传入 dataset + access token,使预览地图优先加载真实 DEM。
- `web/src/app/admin/power-lines/page.tsx`
- 增加 DEM 数据集选择与垂直夸张选择;
- 线路地图传入 terrain dataset + access token + exaggeration
- 增加 DEM 状态/覆盖范围提示。
- `web/src/lib/elevation-terrain.ts`
- 抽出地形状态判定、layer 地址解析、塔位越界统计。
- `web/src/lib/elevation-terrain.test.js`
- 补充地形状态与越界统计测试。
- `web/src/lib/power-line-route.ts`
- 扩展塔位高程溯源字段解析,用于地图弹窗展示。
- `web/src/types/auth.ts`
- 补齐 terrain 相关类型。
- 验证:
- 基线:
- `UV_CACHE_DIR=/tmp/uv-cache uv run --with-requirements requirements.txt --with pytest --no-project env PYTHONPATH=. python -m pytest tests/test_async_dispatch_services.py tests/test_line_preparation_flow.py tests/test_tower_profile_migration.py`
- `10 passed, 1 warning`
- 修改后:
- `python3 -m py_compile app/api/v1/elevation.py app/tasks/elevation_tasks.py app/services/elevation_service.py app/schemas/elevation.py app/models/elevation.py app/core/database.py tests/test_async_dispatch_services.py tests/test_elevation_terrain_service.py`
- 通过
- `UV_CACHE_DIR=/tmp/uv-cache uv run --with-requirements requirements.txt --with pytest --no-project env PYTHONPATH=. python -m pytest tests/test_async_dispatch_services.py tests/test_line_preparation_flow.py tests/test_tower_profile_migration.py tests/test_elevation_terrain_service.py`
- `15 passed, 1 warning`
- `node --test web/src/lib/elevation-terrain.test.js web/src/lib/power-line-route.test.js`
- `6 passed`
- `cd web && NPM_CONFIG_CACHE=/tmp/npm-cache ./node_modules/.bin/eslint ./src/components/elevation-preview-cesium-map.tsx ./src/components/power-line-cesium-map.tsx ./src/app/admin/elevation/page.tsx ./src/app/admin/power-lines/page.tsx ./src/lib/elevation-terrain.ts ./src/lib/elevation-terrain.test.js ./src/lib/power-line-route.ts --max-warnings=0`
- 通过
- `cd web && NPM_CONFIG_CACHE=/tmp/npm-cache ./node_modules/.bin/tsc --noEmit`
- 通过
- `cd web && NPM_CONFIG_CACHE=/tmp/npm-cache npm run build`
- 通过
- `git diff --check`
- 通过
- 风险与关注点:
- 真实 Cesium 地形请求当前依赖 Bearer token 头传递,不能退化为 cookie-only 访问;后续若调整认证中间件,需要一并回归 terrain provider 链路。
- 当前生成格式固定为 `heightmap-1.0` + `EPSG:4326` + `TMS`;若未来要切到 quantized-mesh,需要重新评估生成器和前端兼容层。
+228 -9
View File
@@ -5,6 +5,7 @@ import { useCallback, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Alert,
Button,
Descriptions,
Empty,
Form,
@@ -42,6 +43,8 @@ import type {
ElevationDatasetListResponse,
ElevationDatasetPreviewResponse,
ElevationDatasetSummary,
ElevationDatasetTerrainBuildResponse,
ElevationDatasetTerrainTaskStatusResponse,
LineListResponse,
LineSummary,
} from "@/types/auth";
@@ -88,6 +91,29 @@ function applyModeLabel(mode: string): string {
return mode;
}
function terrainStatusTagColor(status: string): string {
if (status === "ready") return "green";
if (status === "processing") return "blue";
if (status === "pending") return "orange";
if (status === "failed") return "red";
return "default";
}
function terrainStatusLabel(status: string): string {
if (status === "ready") return "已就绪";
if (status === "processing") return "生成中";
if (status === "pending") return "待生成";
if (status === "failed") return "生成失败";
if (status === "not_supported") return "格式不支持";
return status || "-";
}
function terrainBuildActionLabel(status: string): string {
if (status === "ready") return "重建地形";
if (status === "failed") return "重试地形";
return "生成地形";
}
function formatDate(value: string | null | undefined): string {
if (!value) return "-";
return new Date(value).toLocaleString();
@@ -155,6 +181,8 @@ export default function AdminElevationPage() {
const [analysisModalOpen, setAnalysisModalOpen] = useState(false);
const [analysisDataset, setAnalysisDataset] = useState<ElevationDatasetSummary | null>(null);
const [terrainModalOpen, setTerrainModalOpen] = useState(false);
const [terrainDataset, setTerrainDataset] = useState<ElevationDatasetSummary | null>(null);
const [datasetForm] = Form.useForm<DatasetFormValues>();
const [applyForm] = Form.useForm<ApplyFormValues>();
@@ -441,6 +469,30 @@ export default function AdminElevationPage() {
},
});
const terrainBuildMutation = useMutation({
mutationFn: async (datasetId: string) => {
const response = await fetchWithAuth(`/api/v1/elevation/datasets/${datasetId}/terrain/build`, {
method: "POST",
});
if (!response.ok) {
throw new Error(await readApiError(response));
}
return (await response.json()) as ElevationDatasetTerrainBuildResponse;
},
onSuccess: async (payload) => {
setError("");
setTerrainDataset(payload.dataset);
setPreviewDataset((current) => (current?.id === payload.dataset.id ? payload.dataset : current));
messageApi.success(payload.detail || (payload.queued ? "地形瓦片任务已提交" : "地形瓦片状态已刷新"));
await refreshElevationData();
},
onError: (candidate) => {
const nextError = candidate instanceof Error ? candidate.message : "提交地形瓦片任务失败";
setError(nextError);
messageApi.error(nextError);
},
});
const analysisStatusQuery = useQuery({
queryKey: ["/api/v1/elevation/datasets/analysis-task", analysisDataset?.id],
enabled: !!analysisDataset,
@@ -455,9 +507,35 @@ export default function AdminElevationPage() {
staleTime: 0,
});
const terrainStatusQuery = useQuery({
queryKey: ["/api/v1/elevation/datasets/terrain-status", terrainDataset?.id],
enabled: !!terrainDataset && terrainModalOpen,
queryFn: async () => {
const response = await fetchWithAuth(`/api/v1/elevation/datasets/${terrainDataset?.id}/terrain/status`);
if (!response.ok) {
throw new Error(await readApiError(response));
}
return (await response.json()) as ElevationDatasetTerrainTaskStatusResponse;
},
refetchInterval: terrainModalOpen ? 3000 : false,
staleTime: 0,
});
const datasets = useMemo(() => datasetsQuery.data?.items ?? [], [datasetsQuery.data?.items]);
const jobs = jobsQuery.data?.items ?? [];
const lines = useMemo(() => linesQuery.data?.items ?? [], [linesQuery.data?.items]);
const currentPreviewDataset = useMemo(
() => (previewDataset ? datasets.find((item) => item.id === previewDataset.id) ?? previewDataset : null),
[datasets, previewDataset],
);
const currentAnalysisDataset = useMemo(
() => (analysisDataset ? datasets.find((item) => item.id === analysisDataset.id) ?? analysisDataset : null),
[analysisDataset, datasets],
);
const currentTerrainDataset = useMemo(
() => (terrainDataset ? datasets.find((item) => item.id === terrainDataset.id) ?? terrainDataset : null),
[datasets, terrainDataset],
);
const lineOptions = useMemo(
() =>
@@ -540,6 +618,21 @@ export default function AdminElevationPage() {
return <Tag color={colorMap[value] || "default"}>{value}</Tag>;
},
},
{
title: "地形状态",
dataIndex: "terrain_status",
width: 120,
render: (value: string) => <Tag color={terrainStatusTagColor(value)}>{terrainStatusLabel(value)}</Tag>,
},
{
title: "地形层级",
key: "terrainZoom",
width: 120,
render: (_, row) =>
row.terrain_min_zoom !== null && row.terrain_max_zoom !== null
? `${row.terrain_min_zoom} - ${row.terrain_max_zoom}`
: "-",
},
{
title: "使用状态",
dataIndex: "usage_status",
@@ -566,7 +659,7 @@ export default function AdminElevationPage() {
title: "操作",
key: "actions",
fixed: "right",
width: 280,
width: 380,
render: (_, row) => (
<Space size="small" wrap>
<Typography.Link
@@ -617,6 +710,37 @@ export default function AdminElevationPage() {
>
</Typography.Link>
<Typography.Link
onClick={() => {
setTerrainDataset(row);
setTerrainModalOpen(true);
}}
>
</Typography.Link>
<Typography.Link
disabled={
!canManage
|| terrainBuildMutation.isPending
|| row.terrain_status === "not_supported"
|| row.status !== "active"
}
onClick={() => {
if (
!canManage
|| terrainBuildMutation.isPending
|| row.terrain_status === "not_supported"
|| row.status !== "active"
) {
return;
}
setTerrainDataset(row);
setTerrainModalOpen(true);
terrainBuildMutation.mutate(row.id);
}}
>
{terrainBuildActionLabel(row.terrain_status)}
</Typography.Link>
<Typography.Link
disabled={!canManage || datasetDataImportMutation.isPending}
onClick={() => {
@@ -661,6 +785,7 @@ export default function AdminElevationPage() {
datasetFilesMutation,
fetchWithAuth,
messageApi,
terrainBuildMutation,
],
);
@@ -729,7 +854,7 @@ export default function AdminElevationPage() {
);
}
const datasetTableScrollX = 2200;
const datasetTableScrollX = 2520;
return (
<div className="space-y-6">
@@ -796,7 +921,7 @@ export default function AdminElevationPage() {
</Card>
<Modal
title={previewDataset ? `高程预览:${previewDataset.code}` : "高程预览"}
title={currentPreviewDataset ? `高程预览:${currentPreviewDataset.code}` : "高程预览"}
open={previewModalOpen}
width={1040}
footer={null}
@@ -807,15 +932,15 @@ export default function AdminElevationPage() {
setPreviewLoading(false);
}}
>
{previewDataset && (
{currentPreviewDataset && (
<div className="space-y-3">
<Alert
type="info"
showIcon
message={`数据集:${previewDataset.name}${previewDataset.file_format.toUpperCase()}`}
message={`数据集:${currentPreviewDataset.name}${currentPreviewDataset.file_format.toUpperCase()}`}
description={previewData
? `预览模式:${previewData.preview_mode === "terrain_grid" ? "地形网格" : "点云"};总样本 ${previewData.total_points},当前展示 ${previewData.sampled_points}`
: "正在加载预览数据..."}
? `预览模式:${previewData.preview_mode === "terrain_grid" ? "地形网格" : "点云"};总样本 ${previewData.total_points},当前展示 ${previewData.sampled_points};地形状态 ${terrainStatusLabel(currentPreviewDataset.terrain_status)}`
: `正在加载预览数据... 当前地形状态:${terrainStatusLabel(currentPreviewDataset.terrain_status)}`}
/>
{previewData && previewData.warnings.length > 0 && (
<Alert
@@ -888,6 +1013,8 @@ export default function AdminElevationPage() {
</Card>
)}
<ElevationPreviewCesiumMap
dataset={currentPreviewDataset}
accessToken={getAccessToken()}
points={previewData?.points ?? []}
cells={previewData?.cells ?? []}
loading={previewLoading}
@@ -1022,7 +1149,7 @@ export default function AdminElevationPage() {
</Modal>
<Modal
title={analysisDataset ? `分析进度:${analysisDataset.code}` : "分析进度"}
title={currentAnalysisDataset ? `分析进度:${currentAnalysisDataset.code}` : "分析进度"}
open={analysisModalOpen}
footer={null}
onCancel={() => {
@@ -1030,7 +1157,7 @@ export default function AdminElevationPage() {
setAnalysisDataset(null);
}}
>
{analysisDataset && (
{currentAnalysisDataset && (
<div className="space-y-3">
{analysisStatusQuery.isLoading ? (
<div className="flex min-h-[180px] items-center justify-center">
@@ -1068,6 +1195,98 @@ export default function AdminElevationPage() {
)}
</Modal>
<Modal
title={currentTerrainDataset ? `地形状态:${currentTerrainDataset.code}` : "地形状态"}
open={terrainModalOpen}
footer={null}
onCancel={() => {
setTerrainModalOpen(false);
setTerrainDataset(null);
}}
>
{currentTerrainDataset && (
<div className="space-y-3">
<Alert
type={
currentTerrainDataset.terrain_status === "failed"
? "error"
: currentTerrainDataset.terrain_status === "ready"
? "success"
: "info"
}
showIcon
message={`数据集:${currentTerrainDataset.name}`}
description={currentTerrainDataset.terrain_error_message || `当前地形状态:${terrainStatusLabel(currentTerrainDataset.terrain_status)}`}
/>
<Space wrap>
<Button
type="primary"
disabled={
!canManage
|| terrainBuildMutation.isPending
|| currentTerrainDataset.terrain_status === "not_supported"
|| currentTerrainDataset.status !== "active"
}
loading={terrainBuildMutation.isPending}
onClick={() => {
terrainBuildMutation.mutate(currentTerrainDataset.id);
}}
>
{terrainBuildActionLabel(currentTerrainDataset.terrain_status)}
</Button>
<Button
onClick={() => {
void terrainStatusQuery.refetch();
}}
>
</Button>
</Space>
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="地形状态">
<Tag color={terrainStatusTagColor(currentTerrainDataset.terrain_status)}>
{terrainStatusLabel(currentTerrainDataset.terrain_status)}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="任务状态">
<Tag color={statusTagColor(terrainStatusQuery.data?.status || "default")}>
{terrainStatusQuery.data?.status || "-"}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Task ID">{terrainStatusQuery.data?.task_id || currentTerrainDataset.terrain_task_id || "-"}</Descriptions.Item>
<Descriptions.Item label="地形层级">
{terrainStatusQuery.data?.terrain_min_zoom ?? currentTerrainDataset.terrain_min_zoom ?? "-"}
{" ~ "}
{terrainStatusQuery.data?.terrain_max_zoom ?? currentTerrainDataset.terrain_max_zoom ?? "-"}
</Descriptions.Item>
<Descriptions.Item label="地形模板">
{terrainStatusQuery.data?.terrain_url_template || currentTerrainDataset.terrain_url_template || "-"}
</Descriptions.Item>
<Descriptions.Item label="更新时间">
{formatDate(terrainStatusQuery.data?.update_date || currentTerrainDataset.update_date)}
</Descriptions.Item>
</Descriptions>
{terrainStatusQuery.isLoading ? (
<div className="flex min-h-[120px] items-center justify-center">
<Spin tip="地形状态加载中..." />
</div>
) : terrainStatusQuery.error ? (
<Alert
type="error"
showIcon
message={terrainStatusQuery.error instanceof Error ? terrainStatusQuery.error.message : "地形状态加载失败"}
/>
) : terrainStatusQuery.data?.detail ? (
<Alert
type={terrainStatusQuery.data.status === "failed" ? "error" : "info"}
showIcon
message={terrainStatusQuery.data.detail}
/>
) : null}
</div>
)}
</Modal>
<Modal
title="新建高程数据集"
open={datasetModalOpen}
+117 -2
View File
@@ -31,6 +31,8 @@ import { useToastFeedback } from "@/hooks/use-toast-feedback";
import { useTopicSubscription } from "@/hooks/use-topic-subscription";
import { readApiError } from "@/lib/api";
import type {
ElevationDatasetListResponse,
ElevationDatasetSummary,
LineListResponse,
LineSummary,
LineTowerImportResponse,
@@ -156,6 +158,11 @@ const POWER_LINES_STATUS_ESTIMATE_HEIGHT = 34;
const POWER_LINES_MAP_HEADER_ESTIMATE_HEIGHT = 112;
const POWER_LINES_MAP_MIN_HEIGHT = 240;
const POWER_LINES_TABLE_MIN_SCROLL_Y = 180;
const TERRAIN_EXAGGERATION_OPTIONS = [
{ label: "1.0x", value: 1 },
{ label: "1.5x", value: 1.5 },
{ label: "2.0x", value: 2 },
] as const;
const EMPTY_LINE_FORM: LineFormValues = {
name: "",
@@ -517,8 +524,17 @@ function validateStructuredGeometry(
return null;
}
function terrainStatusLabel(status: ElevationDatasetSummary["terrain_status"]): string {
if (status === "ready") return "已就绪";
if (status === "processing") return "生成中";
if (status === "pending") return "待生成";
if (status === "failed") return "生成失败";
if (status === "not_supported") return "格式不支持";
return status || "-";
}
export default function AdminPowerLinesPage() {
const { user, initializing, fetchWithAuth, hasPermission } = useAuth();
const { user, initializing, fetchWithAuth, getAccessToken, hasPermission } = useAuth();
const queryClient = useQueryClient();
const { message: messageApi } = App.useApp();
const importInputRef = useRef<HTMLInputElement | null>(null);
@@ -546,6 +562,8 @@ export default function AdminPowerLinesPage() {
const [editingTower, setEditingTower] = useState<LineTowerSummary | null>(null);
const [editingTowerProfileTower, setEditingTowerProfileTower] = useState<LineTowerSummary | null>(null);
const [towerViewMode, setTowerViewMode] = useState<"table" | "map">("map");
const [selectedTerrainDatasetId, setSelectedTerrainDatasetId] = useState<string | null | undefined>(undefined);
const [terrainExaggeration, setTerrainExaggeration] = useState<number>(1.5);
const [error, setError] = useState("");
const [panelBodyHeight, setPanelBodyHeight] = useState(POWER_LINES_PANEL_MIN_HEIGHT);
@@ -553,6 +571,7 @@ export default function AdminPowerLinesPage() {
const canLineManage = hasPermission("line.manage");
const canTowerRead = hasPermission("tower.read") || hasPermission("tower.manage");
const canTowerManage = hasPermission("tower.manage");
const canElevationRead = hasPermission("elevation.read") || hasPermission("elevation.manage");
const canRead = canLineRead || canTowerRead;
const lineListPath = useMemo(() => {
@@ -645,6 +664,18 @@ export default function AdminPowerLinesPage() {
},
});
const elevationDatasetsQuery = useQuery({
queryKey: ["/api/v1/elevation/datasets"],
enabled: !!user && canElevationRead,
queryFn: async () => {
const response = await fetchWithAuth("/api/v1/elevation/datasets");
if (!response.ok) {
throw new Error(await readApiError(response));
}
return (await response.json()) as ElevationDatasetListResponse;
},
});
const towerProfileQuery = useQuery({
queryKey: ["tower-profile", editingTowerProfileTower?.id],
enabled: !!user && !!editingTowerProfileTower && towerProfileModalOpen && canTowerRead,
@@ -658,9 +689,10 @@ export default function AdminPowerLinesPage() {
});
const lineError = linesQuery.error instanceof Error ? linesQuery.error.message : "";
const towerError = towersQuery.error instanceof Error ? towersQuery.error.message : "";
const elevationDatasetError = elevationDatasetsQuery.error instanceof Error ? elevationDatasetsQuery.error.message : "";
useToastFeedback({
errorMessage: error || lineError || towerError,
errorMessage: error || lineError || towerError || elevationDatasetError,
clearError: () => setError(""),
});
@@ -772,6 +804,9 @@ export default function AdminPowerLinesPage() {
void refreshLines();
void refreshTowers();
}, [refreshLines, refreshTowers]));
useTopicSubscription("admin.elevation", useCallback(() => {
void queryClient.invalidateQueries({ queryKey: ["/api/v1/elevation/datasets"] });
}, [queryClient]));
useTopicSubscription("admin.tower-models", useCallback(() => {
void queryClient.invalidateQueries({ queryKey: ["/api/v1/tower-models/selector"] });
}, [queryClient]));
@@ -779,6 +814,44 @@ export default function AdminPowerLinesPage() {
const lines = useMemo(() => linesQuery.data?.items ?? [], [linesQuery.data?.items]);
const towers = useMemo(() => towersQuery.data?.items ?? [], [towersQuery.data?.items]);
const towerModels = useMemo(() => towerModelOptionsQuery.data ?? [], [towerModelOptionsQuery.data]);
const elevationDatasets = useMemo(() => elevationDatasetsQuery.data?.items ?? [], [elevationDatasetsQuery.data?.items]);
const selectableTerrainDatasets = useMemo(
() =>
elevationDatasets.filter(
(item) => item.status === "active" && item.terrain_status !== "not_supported",
),
[elevationDatasets],
);
const preferredTerrainDatasetId = useMemo(
() =>
selectableTerrainDatasets.find((item) => item.terrain_status === "ready")?.id
?? selectableTerrainDatasets[0]?.id
?? null,
[selectableTerrainDatasets],
);
const effectiveTerrainDatasetId = useMemo(() => {
if (selectedTerrainDatasetId === undefined) {
return preferredTerrainDatasetId;
}
if (selectedTerrainDatasetId === null) {
return null;
}
return selectableTerrainDatasets.some((item) => item.id === selectedTerrainDatasetId)
? selectedTerrainDatasetId
: preferredTerrainDatasetId;
}, [preferredTerrainDatasetId, selectableTerrainDatasets, selectedTerrainDatasetId]);
const selectedTerrainDataset = useMemo(
() => selectableTerrainDatasets.find((item) => item.id === effectiveTerrainDatasetId) ?? null,
[effectiveTerrainDatasetId, selectableTerrainDatasets],
);
const terrainDatasetOptions = useMemo(
() =>
selectableTerrainDatasets.map((item) => ({
value: item.id,
label: `${item.code} - ${item.name}${terrainStatusLabel(item.terrain_status)}`,
})),
[selectableTerrainDatasets],
);
const towerModelOptions = towerModels.map((item) => ({ value: item.code, label: `${item.code} - ${item.name}` }));
const effectiveSelectedLineId = useMemo(() => {
if (selectedLineTouched) {
@@ -1384,6 +1457,25 @@ export default function AdminPowerLinesPage() {
);
const mapHeight = Math.max(POWER_LINES_MAP_MIN_HEIGHT, rightContentHeight - 32);
const towerTableScrollY = Math.max(POWER_LINES_TABLE_MIN_SCROLL_Y, rightContentHeight - 54);
const terrainSelectionHint = useMemo(() => {
if (!canElevationRead) {
return "当前账号没有高程数据读取权限,线路分布图将使用椭球地表。";
}
if (!selectedTerrainDataset) {
return "未选择 DEM 数据集,线路分布图将使用椭球地表。";
}
if (selectedTerrainDataset.terrain_status === "ready") {
const bounds = selectedTerrainDataset.terrain_bounds;
const boundsText = bounds
? `范围 ${bounds.west.toFixed(4)}, ${bounds.south.toFixed(4)} ~ ${bounds.east.toFixed(4)}, ${bounds.north.toFixed(4)}`
: "范围待同步";
return `当前 DEM${selectedTerrainDataset.code},地形已就绪,${boundsText}`;
}
if (selectedTerrainDataset.terrain_status === "failed") {
return `当前 DEM${selectedTerrainDataset.code},地形生成失败,将回退到椭球地表。`;
}
return `当前 DEM${selectedTerrainDataset.code},地形状态为${terrainStatusLabel(selectedTerrainDataset.terrain_status)},就绪前将回退到椭球地表。`;
}, [canElevationRead, selectedTerrainDataset]);
if (initializing || linesQuery.isLoading) {
return <AdminPageLoading tip="加载线路数据中..." minHeightClassName="min-h-[280px]" />;
}
@@ -1520,6 +1612,26 @@ export default function AdminPowerLinesPage() {
placeholder="按风险等级筛选"
/>
</div>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_180px]">
<Select
allowClear
placeholder={canElevationRead ? "选择 DEM 地形数据集" : "无高程数据权限"}
value={effectiveTerrainDatasetId ?? undefined}
options={terrainDatasetOptions}
disabled={!canElevationRead || terrainDatasetOptions.length === 0}
onChange={(value) => setSelectedTerrainDatasetId(value ?? null)}
/>
<Select
value={terrainExaggeration}
options={[...TERRAIN_EXAGGERATION_OPTIONS]}
onChange={(value) => setTerrainExaggeration(value)}
/>
</div>
<Alert
type={selectedTerrainDataset?.terrain_status === "ready" ? "success" : "info"}
showIcon
message={terrainSelectionHint}
/>
<div className="relative overflow-y-auto" style={{ height: rightContentHeight }}>
<div
aria-hidden={towerViewMode !== "map"}
@@ -1533,6 +1645,9 @@ export default function AdminPowerLinesPage() {
lineCode={selectedLine.code}
lineName={selectedLine.name}
towers={towers}
terrainDataset={selectedTerrainDataset}
accessToken={getAccessToken()}
exaggeration={terrainExaggeration}
loading={towersQuery.isFetching}
height={mapHeight}
/>
@@ -3,11 +3,18 @@
import { Alert, Empty, Spin } from "antd";
import { useEffect, useMemo, useRef, useState } from "react";
import { getApiBaseUrl } from "@/lib/api";
import { withBasePath } from "@/lib/base-path";
import { reloadOnceOnChunkError } from "@/lib/chunk-error";
import type { ElevationDatasetPreviewCell, ElevationDatasetPreviewPoint } from "@/types/auth";
import { getElevationTerrainLayerUrl, getElevationTerrainRenderState } from "@/lib/elevation-terrain";
import type { ElevationDatasetPreviewCell, ElevationDatasetPreviewPoint, ElevationDatasetSummary } from "@/types/auth";
type ElevationPreviewCesiumMapProps = {
dataset?: Pick<
ElevationDatasetSummary,
"id" | "name" | "terrain_status" | "terrain_url_template" | "terrain_bounds" | "terrain_metadata"
> | null;
accessToken?: string | null;
points: ElevationDatasetPreviewPoint[];
cells?: ElevationDatasetPreviewCell[];
loading?: boolean;
@@ -47,6 +54,8 @@ function formatErrorMessage(candidate: unknown): string {
}
export function ElevationPreviewCesiumMap({
dataset = null,
accessToken = null,
points,
cells = [],
loading = false,
@@ -56,6 +65,13 @@ export function ElevationPreviewCesiumMap({
const cesiumRef = useRef<CesiumNamespace | null>(null);
const [ready, setReady] = useState(false);
const [error, setError] = useState("");
const [terrainError, setTerrainError] = useState("");
const [pointerInfo, setPointerInfo] = useState("");
const terrainRenderState = useMemo(
() => (dataset ? getElevationTerrainRenderState(dataset) : "fallback"),
[dataset],
);
const safePoints = useMemo(
() =>
@@ -133,10 +149,31 @@ export function ElevationPreviewCesiumMap({
viewer.scene.globe.showGroundAtmosphere = false;
viewer.scene.globe.baseColor = Cesium.Color.fromCssColorString("#0f172a");
viewer.scene.backgroundColor = Cesium.Color.fromCssColorString("#020617");
viewer.scene.screenSpaceCameraController.enableCollisionDetection = true;
const creditContainer = viewer.cesiumWidget.creditContainer as HTMLElement | null;
if (creditContainer) {
creditContainer.style.display = "none";
}
viewer.screenSpaceEventHandler.setInputAction((movement: { endPosition?: import("cesium").Cartesian2 }) => {
if (!movement.endPosition) {
setPointerInfo("");
return;
}
const ray = viewer.camera.getPickRay(movement.endPosition);
const cartesian = ray
? viewer.scene.globe.pick(ray, viewer.scene)
: viewer.camera.pickEllipsoid(movement.endPosition, viewer.scene.globe.ellipsoid);
if (!cartesian) {
setPointerInfo("");
return;
}
const cartographic = Cesium.Cartographic.fromCartesian(cartesian);
const lon = Cesium.Math.toDegrees(cartographic.longitude);
const lat = Cesium.Math.toDegrees(cartographic.latitude);
const height = viewer.scene.globe.getHeight(cartographic) ?? cartographic.height ?? 0;
const datasetName = dataset?.name ? ` | ${dataset.name}` : "";
setPointerInfo(`${lon.toFixed(5)}, ${lat.toFixed(5)} | ${height.toFixed(2)} m${datasetName}`);
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
viewerRef.current = viewer;
cesiumRef.current = Cesium;
@@ -159,8 +196,60 @@ export function ElevationPreviewCesiumMap({
viewerRef.current = null;
cesiumRef.current = null;
setReady(false);
setPointerInfo("");
};
}, []);
}, [dataset?.name]);
useEffect(() => {
let cancelled = false;
async function updateTerrain() {
const viewer = viewerRef.current;
const Cesium = cesiumRef.current;
if (!viewer || !Cesium || !ready) {
return;
}
setTerrainError("");
viewer.scene.verticalExaggeration = 1.0;
viewer.scene.verticalExaggerationRelativeHeight = 0.0;
if (terrainRenderState !== "ready" || !dataset) {
viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider();
viewer.scene.globe.depthTestAgainstTerrain = false;
return;
}
try {
const layerBaseUrl = `${getApiBaseUrl()}${getElevationTerrainLayerUrl(dataset)}`;
const resource = new Cesium.Resource({
url: layerBaseUrl,
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
});
const terrainProvider = await Cesium.CesiumTerrainProvider.fromUrl(resource, {
requestMetadata: false,
requestWaterMask: false,
requestVertexNormals: false,
});
if (cancelled) {
return;
}
viewer.terrainProvider = terrainProvider;
viewer.scene.globe.depthTestAgainstTerrain = true;
} catch (candidate) {
if (!cancelled) {
viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider();
viewer.scene.globe.depthTestAgainstTerrain = false;
setTerrainError(formatErrorMessage(candidate));
}
}
}
void updateTerrain();
return () => {
cancelled = true;
};
}, [accessToken, dataset, ready, terrainRenderState]);
useEffect(() => {
const viewer = viewerRef.current;
@@ -170,11 +259,14 @@ export function ElevationPreviewCesiumMap({
}
viewer.entities.removeAll();
if (safeCells.length === 0 && safePoints.length === 0) {
return;
if (!dataset?.terrain_bounds) {
return;
}
}
const positions: import("cesium").Cartesian3[] = [];
if (safeCells.length > 0) {
const shouldDrawFallbackOverlay = terrainRenderState !== "ready" || !!terrainError;
if (shouldDrawFallbackOverlay && safeCells.length > 0) {
for (let index = 0; index < safeCells.length; index += 1) {
const cell = safeCells[index];
const centerLon = (cell.min_longitude + cell.max_longitude) / 2;
@@ -207,7 +299,7 @@ export function ElevationPreviewCesiumMap({
`,
});
}
} else {
} else if (shouldDrawFallbackOverlay) {
for (let index = 0; index < safePoints.length; index += 1) {
const point = safePoints[index];
const position = Cesium.Cartesian3.fromDegrees(point.longitude, point.latitude, point.altitude_m);
@@ -235,6 +327,34 @@ export function ElevationPreviewCesiumMap({
}
}
const terrainBounds = dataset?.terrain_bounds;
if (terrainBounds) {
const centerLon = (terrainBounds.west + terrainBounds.east) / 2;
const centerLat = (terrainBounds.south + terrainBounds.north) / 2;
positions.push(Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0));
viewer.entities.add({
id: "elevation-dataset-bounds",
rectangle: {
coordinates: Cesium.Rectangle.fromDegrees(
terrainBounds.west,
terrainBounds.south,
terrainBounds.east,
terrainBounds.north,
),
material: Cesium.Color.WHITE.withAlpha(0.02),
outline: true,
outlineColor: Cesium.Color.fromCssColorString("#f8fafc"),
height: 0,
},
description: `
<div style="line-height:1.7;">
<div><strong></strong>${dataset?.name ?? "-"}</div>
<div><strong></strong>${terrainBounds.west.toFixed(4)}, ${terrainBounds.south.toFixed(4)} ~ ${terrainBounds.east.toFixed(4)}, ${terrainBounds.north.toFixed(4)}</div>
</div>
`,
});
}
if (positions.length > 0) {
const boundingSphere = Cesium.BoundingSphere.fromPoints(positions);
void viewer.camera.flyToBoundingSphere(boundingSphere, {
@@ -242,7 +362,7 @@ export function ElevationPreviewCesiumMap({
offset: new Cesium.HeadingPitchRange(0, -0.6, Math.max(1200, boundingSphere.radius * 2.4)),
});
}
}, [altitudeRange.max, altitudeRange.min, ready, safeCells, safePoints]);
}, [altitudeRange.max, altitudeRange.min, dataset, ready, safeCells, safePoints, terrainError, terrainRenderState]);
if (error) {
return (
@@ -261,9 +381,17 @@ export function ElevationPreviewCesiumMap({
return (
<div className="space-y-2">
<div className="text-xs text-slate-500">
退/
</div>
{terrainError ? (
<Alert
type="warning"
showIcon
message={`地形瓦片加载失败,已回退到抽样预览:${terrainError}`}
/>
) : null}
<div ref={containerRef} className="h-[520px] w-full overflow-hidden rounded-md border border-slate-200 bg-slate-100" />
{pointerInfo ? <div className="text-xs text-slate-500">{pointerInfo}</div> : null}
{loading && (
<div className="flex items-center gap-2 text-xs text-slate-500">
<Spin size="small" />
+133 -5
View File
@@ -4,20 +4,32 @@ import { AimOutlined, MinusOutlined, PlusOutlined } from "@ant-design/icons";
import { Alert, Button, Checkbox, Empty, Spin, Typography } from "antd";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { getApiBaseUrl } from "@/lib/api";
import { withBasePath } from "@/lib/base-path";
import { reloadOnceOnChunkError } from "@/lib/chunk-error";
import {
countLineTowersOutsideTerrainBounds,
getElevationTerrainLayerUrl,
getElevationTerrainRenderState,
} from "@/lib/elevation-terrain";
import {
buildRouteSegments,
collectTowerGeoPoints,
type RouteSegment,
type TowerGeoPoint,
} from "@/lib/power-line-route";
import type { LineTowerSummary } from "@/types/auth";
import type { ElevationDatasetSummary, LineTowerSummary } from "@/types/auth";
type PowerLineCesiumMapProps = {
lineCode?: string;
lineName?: string;
towers: LineTowerSummary[];
terrainDataset?: Pick<
ElevationDatasetSummary,
"id" | "name" | "terrain_status" | "terrain_url_template" | "terrain_bounds" | "terrain_metadata"
> | null;
accessToken?: string | null;
exaggeration?: number;
loading?: boolean;
height?: number;
};
@@ -90,6 +102,9 @@ export function PowerLineCesiumMap({
lineCode,
lineName,
towers,
terrainDataset = null,
accessToken = null,
exaggeration = 1,
loading = false,
height = DEFAULT_MAP_HEIGHT,
}: PowerLineCesiumMapProps) {
@@ -99,8 +114,18 @@ export function PowerLineCesiumMap({
const routeViewRef = useRef<RouteViewState | null>(null);
const [initError, setInitError] = useState("");
const [ready, setReady] = useState(false);
const [terrainError, setTerrainError] = useState("");
const [pointerInfo, setPointerInfo] = useState("");
const [colorByRisk, setColorByRisk] = useState(true);
const [showLabels, setShowLabels] = useState(true);
const terrainRenderState = useMemo(
() => (terrainDataset ? getElevationTerrainRenderState(terrainDataset) : "fallback"),
[terrainDataset],
);
const towersOutsideTerrainBounds = useMemo(
() => countLineTowersOutsideTerrainBounds(towers, terrainDataset?.terrain_bounds ?? null),
[terrainDataset?.terrain_bounds, towers],
);
const sortedTowers = useMemo(
() => [...towers].sort((a, b) => a.seq_no - b.seq_no),
@@ -188,10 +213,10 @@ export function PowerLineCesiumMap({
fullscreenButton: false,
geocoder: false,
homeButton: false,
infoBox: false,
infoBox: true,
navigationHelpButton: false,
sceneModePicker: false,
selectionIndicator: false,
selectionIndicator: true,
skyBox: false,
skyAtmosphere: false,
timeline: false,
@@ -203,10 +228,31 @@ export function PowerLineCesiumMap({
viewer.scene.globe.baseColor = Cesium.Color.fromCssColorString("#0f172a");
viewer.scene.backgroundColor = Cesium.Color.fromCssColorString("#020617");
viewer.scene.screenSpaceCameraController.enableZoom = true;
viewer.scene.screenSpaceCameraController.enableCollisionDetection = true;
const creditContainer = viewer.cesiumWidget.creditContainer as HTMLElement | null;
if (creditContainer) {
creditContainer.style.display = "none";
}
viewer.screenSpaceEventHandler.setInputAction((movement: { endPosition?: import("cesium").Cartesian2 }) => {
if (!movement.endPosition) {
setPointerInfo("");
return;
}
const ray = viewer.camera.getPickRay(movement.endPosition);
const cartesian = ray
? viewer.scene.globe.pick(ray, viewer.scene)
: viewer.camera.pickEllipsoid(movement.endPosition, viewer.scene.globe.ellipsoid);
if (!cartesian) {
setPointerInfo("");
return;
}
const cartographic = Cesium.Cartographic.fromCartesian(cartesian);
const lon = Cesium.Math.toDegrees(cartographic.longitude);
const lat = Cesium.Math.toDegrees(cartographic.latitude);
const ground = viewer.scene.globe.getHeight(cartographic) ?? cartographic.height ?? 0;
const datasetName = terrainDataset?.name ? ` | ${terrainDataset.name}` : "";
setPointerInfo(`${lon.toFixed(5)}, ${lat.toFixed(5)} | ${ground.toFixed(2)} m${datasetName}`);
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
viewerRef.current = viewer;
setInitError("");
@@ -233,8 +279,64 @@ export function PowerLineCesiumMap({
cesiumRef.current = null;
routeViewRef.current = null;
setReady(false);
setPointerInfo("");
};
}, []);
}, [terrainDataset?.name]);
useEffect(() => {
let cancelled = false;
async function updateTerrain() {
const viewer = viewerRef.current;
const Cesium = cesiumRef.current;
if (!viewer || !Cesium || !ready) {
return;
}
setTerrainError("");
viewer.scene.verticalExaggeration = exaggeration;
viewer.scene.verticalExaggerationRelativeHeight = 0.0;
if (terrainRenderState !== "ready" || !terrainDataset) {
viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider();
viewer.scene.globe.depthTestAgainstTerrain = false;
return;
}
try {
const terrainUrl = getElevationTerrainLayerUrl(terrainDataset);
if (!terrainUrl) {
viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider();
return;
}
const resource = new Cesium.Resource({
url: `${getApiBaseUrl()}${terrainUrl}`,
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined,
});
const terrainProvider = await Cesium.CesiumTerrainProvider.fromUrl(resource, {
requestMetadata: false,
requestWaterMask: false,
requestVertexNormals: false,
});
if (cancelled) {
return;
}
viewer.terrainProvider = terrainProvider;
viewer.scene.globe.depthTestAgainstTerrain = true;
} catch (error) {
if (!cancelled) {
viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider();
viewer.scene.globe.depthTestAgainstTerrain = false;
setTerrainError(formatErrorMessage(error));
}
}
}
void updateTerrain();
return () => {
cancelled = true;
};
}, [accessToken, exaggeration, ready, terrainDataset, terrainRenderState]);
useEffect(() => {
const viewer = viewerRef.current;
@@ -307,13 +409,17 @@ export function PowerLineCesiumMap({
disableDepthTestDistance: Number.POSITIVE_INFINITY,
}
: undefined,
description: `
description: `
<div style="line-height:1.7;">
<div><strong></strong>${tower.towerNo}</div>
<div><strong></strong>${tower.seqNo}</div>
<div><strong></strong>${tower.longitude.toFixed(6)}, ${tower.latitude.toFixed(6)}</div>
<div><strong></strong>${tower.altitudeM.toFixed(2)} m</div>
<div><strong></strong>${tower.terrain ?? "-"}</div>
<div><strong></strong>${tower.riskLevel ?? "-"}</div>
<div><strong></strong>${tower.elevationPrepared ? "已回填" : "未回填"}</div>
<div><strong></strong>${tower.elevationDatasetCode ?? "-"}</div>
<div><strong></strong>${tower.elevationSampleMethod ?? "-"}</div>
</div>
`,
});
@@ -330,6 +436,27 @@ export function PowerLineCesiumMap({
return (
<div className="space-y-3">
{terrainDataset ? (
<Typography.Text type="secondary">
{terrainDataset.name} {terrainDataset.terrain_status}
{terrainDataset.terrain_status === "ready" ? `,夸张 ${exaggeration.toFixed(1)}x` : ",未启用真实地形"}
{towersOutsideTerrainBounds > 0 ? `,超出范围杆塔 ${towersOutsideTerrainBounds}` : ""}
</Typography.Text>
) : null}
{terrainDataset && towersOutsideTerrainBounds > 0 ? (
<Alert
type="warning"
showIcon
message={`${towersOutsideTerrainBounds} 个杆塔超出所选 DEM 覆盖范围`}
/>
) : null}
{terrainError ? (
<Alert
type="warning"
showIcon
message={`地形瓦片加载失败,已回退到平面模式:${terrainError}`}
/>
) : null}
<div className="flex flex-wrap items-center gap-3">
<Checkbox checked={colorByRisk} onChange={(event) => setColorByRisk(event.target.checked)}>
@@ -384,6 +511,7 @@ export function PowerLineCesiumMap({
</div>
) : null}
</div>
{pointerInfo ? <div className="text-xs text-slate-500">{pointerInfo}</div> : null}
{towerGeoPoints.length === 0 ? (
<Empty
+35
View File
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import test from "node:test";
import { countLineTowersOutsideTerrainBounds, getElevationTerrainRenderState } from "./elevation-terrain.ts";
test("getElevationTerrainRenderState reports ready only when terrain url is available", () => {
assert.equal(
getElevationTerrainRenderState({ terrain_status: "ready", terrain_url_template: "/api/v1/elevation/ds/terrain/{z}/{x}/{y}.terrain" }),
"ready",
);
assert.equal(
getElevationTerrainRenderState({ terrain_status: "ready", terrain_url_template: null }),
"fallback",
);
});
test("getElevationTerrainRenderState covers processing failed and fallback states", () => {
assert.equal(getElevationTerrainRenderState({ terrain_status: "pending", terrain_url_template: null }), "processing");
assert.equal(getElevationTerrainRenderState({ terrain_status: "processing", terrain_url_template: null }), "processing");
assert.equal(getElevationTerrainRenderState({ terrain_status: "failed", terrain_url_template: null }), "failed");
assert.equal(getElevationTerrainRenderState({ terrain_status: "not_supported", terrain_url_template: null }), "fallback");
});
test("countLineTowersOutsideTerrainBounds ignores towers without coordinates and counts out-of-range towers", () => {
const towers = [
{ longitude: 120.1, latitude: 30.1 },
{ longitude: 121.8, latitude: 30.2 },
{ longitude: null, latitude: 30.3 },
{ longitude: 120.3, latitude: 31.6 },
];
const bounds = { west: 120.0, south: 30.0, east: 121.0, north: 31.0 };
assert.equal(countLineTowersOutsideTerrainBounds(towers, bounds), 2);
assert.equal(countLineTowersOutsideTerrainBounds(towers, null), 0);
});
+57
View File
@@ -0,0 +1,57 @@
import type { ElevationDatasetSummary, ElevationTerrainBounds, LineTowerSummary } from "@/types/auth";
export type ElevationTerrainRenderState = "ready" | "processing" | "failed" | "fallback";
export function getElevationTerrainRenderState(dataset: Pick<
ElevationDatasetSummary,
"terrain_status" | "terrain_url_template"
>): ElevationTerrainRenderState {
if (dataset.terrain_status === "ready" && dataset.terrain_url_template) {
return "ready";
}
if (dataset.terrain_status === "pending" || dataset.terrain_status === "processing") {
return "processing";
}
if (dataset.terrain_status === "failed") {
return "failed";
}
return "fallback";
}
export function countLineTowersOutsideTerrainBounds(
towers: Pick<LineTowerSummary, "longitude" | "latitude">[],
bounds: ElevationTerrainBounds | null,
): number {
if (!bounds) {
return 0;
}
let count = 0;
for (const tower of towers) {
if (tower.longitude === null || tower.latitude === null) {
continue;
}
if (
tower.longitude < bounds.west
|| tower.longitude > bounds.east
|| tower.latitude < bounds.south
|| tower.latitude > bounds.north
) {
count += 1;
}
}
return count;
}
export function getElevationTerrainLayerUrl(dataset: Pick<
ElevationDatasetSummary,
"id" | "terrain_metadata"
> | null): string | null {
if (!dataset) {
return null;
}
const candidate = dataset.terrain_metadata?.layer_url;
if (typeof candidate === "string" && candidate.trim()) {
return candidate.trim().replace(/\/layer\.json$/, "");
}
return `/api/v1/elevation/datasets/${dataset.id}/terrain`;
}
+17
View File
@@ -5,7 +5,9 @@ export type RouteTowerInput = {
longitude: number | null;
latitude: number | null;
altitude_m: number | null;
terrain?: string | null;
risk_level: string | null;
raw_extra_json?: Record<string, unknown>;
};
export type TowerGeoPoint = {
@@ -15,7 +17,11 @@ export type TowerGeoPoint = {
longitude: number;
latitude: number;
altitudeM: number;
terrain: string | null;
riskLevel: string | null;
elevationPrepared: boolean;
elevationDatasetCode: string | null;
elevationSampleMethod: string | null;
};
export type RouteSegment = {
@@ -34,6 +40,13 @@ export function hasValidGeo(tower: Pick<RouteTowerInput, "longitude" | "latitude
}
function toTowerGeoPoint(tower: RouteTowerInput): TowerGeoPoint {
const rawExtra = tower.raw_extra_json ?? {};
const elevation = rawExtra && typeof rawExtra === "object" && !Array.isArray(rawExtra)
? rawExtra.elevation
: null;
const elevationRecord = elevation && typeof elevation === "object" && !Array.isArray(elevation)
? elevation as Record<string, unknown>
: null;
return {
id: tower.id,
seqNo: tower.seq_no,
@@ -41,7 +54,11 @@ function toTowerGeoPoint(tower: RouteTowerInput): TowerGeoPoint {
longitude: tower.longitude ?? 0,
latitude: tower.latitude ?? 0,
altitudeM: tower.altitude_m ?? DEFAULT_ALTITUDE_M,
terrain: tower.terrain ?? null,
riskLevel: tower.risk_level,
elevationPrepared: !!elevationRecord,
elevationDatasetCode: typeof elevationRecord?.dataset_code === "string" ? elevationRecord.dataset_code : null,
elevationSampleMethod: typeof elevationRecord?.sample_method === "string" ? elevationRecord.sample_method : null,
};
}
+37
View File
@@ -204,9 +204,17 @@ export type FileOperationResponse = {
export type ElevationDatasetStatus = "active" | "disabled";
export type ElevationDatasetUsageStatus = "idle" | "in_use";
export type ElevationDatasetTerrainStatus = "pending" | "processing" | "ready" | "failed" | "not_supported";
export type ElevationApplyMode = "fill_null_only" | "overwrite_all";
export type ElevationApplyJobStatus = "pending" | "running" | "success" | "failed";
export type ElevationTerrainBounds = {
west: number;
south: number;
east: number;
north: number;
};
export type ElevationDatasetSummary = {
id: string;
code: string;
@@ -229,6 +237,15 @@ export type ElevationDatasetSummary = {
analysis_error_message: string | null;
analysis_started_at: string | null;
analysis_finished_at: string | null;
terrain_status: ElevationDatasetTerrainStatus;
terrain_task_id: string | null;
terrain_error_message: string | null;
terrain_root_path: string | null;
terrain_url_template: string | null;
terrain_min_zoom: number | null;
terrain_max_zoom: number | null;
terrain_bounds: ElevationTerrainBounds | null;
terrain_metadata: Record<string, unknown> | null;
notes: string | null;
create_date: string;
create_user: string | null;
@@ -249,6 +266,14 @@ export type ElevationDatasetAnalyzeResponse = {
warnings: string[];
};
export type ElevationDatasetTerrainBuildResponse = {
dataset: ElevationDatasetSummary;
task_id: string | null;
queued: boolean;
detail: string | null;
warnings: string[];
};
export type TowerModelSummary = {
id: string;
code: string;
@@ -353,6 +378,18 @@ export type ElevationDatasetAnalysisTaskStatusResponse = {
update_date: string | null;
};
export type ElevationDatasetTerrainTaskStatusResponse = {
dataset_id: string;
dataset_code: string;
task_id: string | null;
status: ElevationDatasetAnalysisTaskStatus;
detail: string | null;
terrain_url_template: string | null;
terrain_min_zoom: number | null;
terrain_max_zoom: number | null;
update_date: string | null;
};
export type ElevationDatasetPreviewPoint = {
longitude: number;
latitude: number;