[fix/feat]:[FL-74][高程数据支持DEM地形瓦片预览和线路地形图]
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -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 转换)。
|
||||
- 栅格实现口径:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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,需要重新评估生成器和前端兼容层。
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user