feat:[FL-182][高程数据预览优化]

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
chengkai3
2026-06-23 16:47:37 +08:00
parent 7ef266e4a0
commit ae8a2cb9b6
5 changed files with 162 additions and 26 deletions
+22
View File
@@ -0,0 +1,22 @@
# Work Log - 高程数据预览对比(FL-182)
- 背景:
- 高程数据预览需要同时提供格栅和地形两种方式,便于在同一预览窗口内对比查看。
- 本次处理:
- 高程预览组件新增 `previewMode`,支持 `grid` 固定显示采样格网/点位、`terrain` 固定加载三维地形瓦片、`auto` 保持原有自动回退行为。
- 高程数据预览弹窗改为左右两栏,对同一份预览数据同时渲染“格栅预览”和“地形预览”。
- 抽离并测试预览模式判定逻辑,避免格栅模式误加载地形或地形模式误回退为格栅。
- 验证:
- 基线:`npm --workspace web exec eslint src/app/admin/elevation-records/page.tsx src/components/elevation-preview-cesium-map.tsx` 通过,存在既有 `react-hooks/exhaustive-deps` warning。
- 基线:`npm --workspace web exec tsc --noEmit --pretty false` 通过。
- 基线:`npm --workspace web exec node --experimental-strip-types src/lib/elevation-terrain.test.js` 通过,3 passed,存在既有 Node module type warning。
- 修改后:`npm --workspace web exec eslint src/app/admin/elevation-records/page.tsx src/components/elevation-preview-cesium-map.tsx src/lib/elevation-terrain.ts src/lib/elevation-terrain.test.js` 通过,仍仅有上述既有 warning。
- 修改后:`npm --workspace web exec tsc --noEmit --pretty false` 通过。
- 修改后:`npm --workspace web exec node --experimental-strip-types src/lib/elevation-terrain.test.js` 通过,4 passed,存在既有 Node module type warning。
- 修改后:`git diff --check` 通过。
- 风险与关注点:
- 改动仅影响高程数据预览前端展示和模式判定,不改变 `/api/v1/elevation/records/{id}/preview` 请求/响应字段。
- 地形预览依赖已有地形瓦片状态;未 ready 时会显示椭球底面参考点和状态提示。
+45 -14
View File
@@ -1062,21 +1062,52 @@ export default function ElevationRecordsPage() {
<Descriptions.Item label="格式">{previewData.record?.file_format}</Descriptions.Item>
<Descriptions.Item label="总点数">{previewData.total_points?.toLocaleString()}</Descriptions.Item>
<Descriptions.Item label="采样点数">{previewData.sampled_points?.toLocaleString()}</Descriptions.Item>
<Descriptions.Item label="预览数据">{previewData.preview_mode === "terrain_grid" ? "格栅" : "点云"}</Descriptions.Item>
<Descriptions.Item label="地形状态">{previewData.record?.terrain_status}</Descriptions.Item>
</Descriptions>
<div style={{ height: 600 }}>
<ElevationPreviewCesiumMap
dataset={{
id: previewData.record.id,
name: previewData.record.file_name,
terrain_status: previewData.record.terrain_status,
terrain_url_template: previewData.record.terrain_url_template,
terrain_bounds: previewData.record.terrain_bounds,
terrain_metadata: previewData.record.terrain_metadata,
}}
accessToken={getAccessToken()}
points={previewData.points}
cells={previewData.cells}
/>
<div className="grid gap-4 xl:grid-cols-2">
<div className="min-w-0 rounded-md border border-slate-200 p-3">
<div className="mb-3 flex items-center justify-between gap-3">
<Typography.Text strong></Typography.Text>
<Tag color="blue">{previewData.preview_mode === "terrain_grid" ? "格栅数据" : "点位数据"}</Tag>
</div>
<ElevationPreviewCesiumMap
dataset={{
id: previewData.record.id,
name: previewData.record.file_name,
terrain_status: previewData.record.terrain_status,
terrain_url_template: previewData.record.terrain_url_template,
terrain_bounds: previewData.record.terrain_bounds,
terrain_metadata: previewData.record.terrain_metadata,
}}
accessToken={getAccessToken()}
points={previewData.points}
cells={previewData.cells}
previewMode="grid"
height={430}
/>
</div>
<div className="min-w-0 rounded-md border border-slate-200 p-3">
<div className="mb-3 flex items-center justify-between gap-3">
<Typography.Text strong></Typography.Text>
<Tag color={statusTagColor(previewData.record.terrain_status)}>{previewData.record.terrain_status}</Tag>
</div>
<ElevationPreviewCesiumMap
dataset={{
id: previewData.record.id,
name: previewData.record.file_name,
terrain_status: previewData.record.terrain_status,
terrain_url_template: previewData.record.terrain_url_template,
terrain_bounds: previewData.record.terrain_bounds,
terrain_metadata: previewData.record.terrain_metadata,
}}
accessToken={getAccessToken()}
points={previewData.points}
cells={previewData.cells}
previewMode="terrain"
height={430}
/>
</div>
</div>
</div>
)}
@@ -6,7 +6,13 @@ 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 { getElevationTerrainLayerUrl, getElevationTerrainRenderState } from "@/lib/elevation-terrain";
import {
getElevationTerrainLayerUrl,
getElevationTerrainRenderState,
shouldDrawElevationGridOverlay,
shouldUseElevationTerrainTiles,
type ElevationPreviewDisplayMode,
} from "@/lib/elevation-terrain";
import type { ElevationDatasetPreviewCell, ElevationDatasetPreviewPoint, ElevationDatasetSummary } from "@/types/auth";
type ElevationPreviewCesiumMapProps = {
@@ -18,6 +24,8 @@ type ElevationPreviewCesiumMapProps = {
points: ElevationDatasetPreviewPoint[];
cells?: ElevationDatasetPreviewCell[];
loading?: boolean;
previewMode?: ElevationPreviewDisplayMode;
height?: number | string;
};
type CesiumNamespace = typeof import("cesium");
@@ -59,6 +67,8 @@ export function ElevationPreviewCesiumMap({
points,
cells = [],
loading = false,
previewMode = "auto",
height = 520,
}: ElevationPreviewCesiumMapProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const viewerRef = useRef<import("cesium").Viewer | null>(null);
@@ -72,6 +82,28 @@ export function ElevationPreviewCesiumMap({
() => (dataset ? getElevationTerrainRenderState(dataset) : "fallback"),
[dataset],
);
const shouldLoadTerrain = useMemo(
() => shouldUseElevationTerrainTiles(previewMode, terrainRenderState),
[previewMode, terrainRenderState],
);
const previewHint = useMemo(() => {
if (previewMode === "grid") {
return "格栅模式固定显示采样格网/点位,颜色由蓝到红表示高程由低到高。";
}
if (previewMode === "terrain") {
return "地形模式加载真实三维瓦片,并保留少量参考点用于定位。";
}
return "颜色由蓝到红表示高程由低到高;地形瓦片就绪时优先加载真实三维地形,失败时自动回退到现有色带/点位预览。";
}, [previewMode]);
const terrainErrorMessage = useMemo(() => {
if (!terrainError) {
return "";
}
if (previewMode === "terrain") {
return `地形瓦片加载失败,已切换到椭球底面参考点:${terrainError}`;
}
return `地形瓦片加载失败,已回退到抽样预览:${terrainError}`;
}, [previewMode, terrainError]);
const safePoints = useMemo(
() =>
@@ -227,7 +259,7 @@ export function ElevationPreviewCesiumMap({
// Remove all existing imagery layers first
viewer.imageryLayers.removeAll();
if (terrainRenderState !== "ready" || !dataset) {
if (!shouldLoadTerrain || !dataset) {
viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider();
viewer.scene.globe.depthTestAgainstTerrain = false;
return;
@@ -293,7 +325,7 @@ export function ElevationPreviewCesiumMap({
viewer.imageryLayers.remove(addedImageryLayer, false);
}
};
}, [accessToken, dataset, ready, terrainRenderState]);
}, [accessToken, dataset, ready, shouldLoadTerrain]);
useEffect(() => {
const viewer = viewerRef.current;
@@ -309,8 +341,8 @@ export function ElevationPreviewCesiumMap({
}
const positions: import("cesium").Cartesian3[] = [];
const shouldDrawFallbackOverlay = terrainRenderState !== "ready" || !!terrainError;
if (shouldDrawFallbackOverlay && safeCells.length > 0) {
const shouldDrawGridOverlay = shouldDrawElevationGridOverlay(previewMode, terrainRenderState, !!terrainError);
if (shouldDrawGridOverlay && 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;
@@ -343,7 +375,7 @@ export function ElevationPreviewCesiumMap({
`,
});
}
} else if (shouldDrawFallbackOverlay) {
} else if (shouldDrawGridOverlay) {
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);
@@ -372,7 +404,7 @@ export function ElevationPreviewCesiumMap({
}
// When terrain is ready but no overlay is drawn, add sample reference points for visibility
if (!shouldDrawFallbackOverlay && (safeCells.length > 0 || safePoints.length > 0)) {
if (!shouldDrawGridOverlay && (safeCells.length > 0 || safePoints.length > 0)) {
const referencePoints = safeCells.length > 0
? safeCells.slice(0, 10).map(cell => ({
lon: (cell.min_longitude + cell.max_longitude) / 2,
@@ -456,7 +488,7 @@ export function ElevationPreviewCesiumMap({
offset: new Cesium.HeadingPitchRange(0, -1.0, Math.max(1200, boundingSphere.radius * 2.0)),
});
}
}, [altitudeRange.max, altitudeRange.min, dataset, ready, safeCells, safePoints, terrainError, terrainRenderState]);
}, [altitudeRange.max, altitudeRange.min, dataset, previewMode, ready, safeCells, safePoints, terrainError, terrainRenderState]);
if (error) {
return (
@@ -475,16 +507,27 @@ export function ElevationPreviewCesiumMap({
return (
<div className="space-y-2">
<div className="text-xs text-slate-500">
5退/
{previewHint}
</div>
{previewMode === "terrain" && terrainRenderState !== "ready" ? (
<Alert
type={terrainRenderState === "failed" ? "warning" : "info"}
showIcon
message={`地形瓦片状态:${terrainRenderState},暂以椭球底面显示参考点。`}
/>
) : null}
{terrainError ? (
<Alert
type="warning"
showIcon
message={`地形瓦片加载失败,已回退到抽样预览:${terrainError}`}
message={terrainErrorMessage}
/>
) : null}
<div ref={containerRef} className="h-[520px] w-full overflow-hidden rounded-md border border-slate-200 bg-slate-100" />
<div
ref={containerRef}
className="w-full overflow-hidden rounded-md border border-slate-200 bg-slate-100"
style={{ height }}
/>
{pointerInfo ? <div className="text-xs text-slate-500">{pointerInfo}</div> : null}
{loading && (
<div className="flex items-center gap-2 text-xs text-slate-500">
+19 -1
View File
@@ -1,7 +1,12 @@
import assert from "node:assert/strict";
import test from "node:test";
import { countLineTowersOutsideTerrainBounds, getElevationTerrainRenderState } from "./elevation-terrain.ts";
import {
countLineTowersOutsideTerrainBounds,
getElevationTerrainRenderState,
shouldDrawElevationGridOverlay,
shouldUseElevationTerrainTiles,
} from "./elevation-terrain.ts";
test("getElevationTerrainRenderState reports ready only when terrain url is available", () => {
assert.equal(
@@ -33,3 +38,16 @@ test("countLineTowersOutsideTerrainBounds ignores towers without coordinates and
assert.equal(countLineTowersOutsideTerrainBounds(towers, bounds), 2);
assert.equal(countLineTowersOutsideTerrainBounds(towers, null), 0);
});
test("elevation preview display mode separates grid and terrain rendering", () => {
assert.equal(shouldUseElevationTerrainTiles("grid", "ready"), false);
assert.equal(shouldUseElevationTerrainTiles("terrain", "ready"), true);
assert.equal(shouldUseElevationTerrainTiles("auto", "ready"), true);
assert.equal(shouldUseElevationTerrainTiles("terrain", "processing"), false);
assert.equal(shouldDrawElevationGridOverlay("grid", "ready", false), true);
assert.equal(shouldDrawElevationGridOverlay("terrain", "failed", true), false);
assert.equal(shouldDrawElevationGridOverlay("auto", "ready", false), false);
assert.equal(shouldDrawElevationGridOverlay("auto", "ready", true), true);
assert.equal(shouldDrawElevationGridOverlay("auto", "processing", false), true);
});
+22
View File
@@ -1,6 +1,7 @@
import type { ElevationDatasetSummary, ElevationTerrainBounds, LineTowerSummary } from "@/types/auth";
export type ElevationTerrainRenderState = "ready" | "processing" | "failed" | "fallback";
export type ElevationPreviewDisplayMode = "auto" | "grid" | "terrain";
export function getElevationTerrainRenderState(dataset: Pick<
ElevationDatasetSummary,
@@ -62,3 +63,24 @@ export function getElevationTerrainLayerUrl(dataset: Pick<
}
return `/api/v1/elevation/datasets/${dataset.id}/terrain`;
}
export function shouldUseElevationTerrainTiles(
previewMode: ElevationPreviewDisplayMode,
terrainRenderState: ElevationTerrainRenderState,
): boolean {
return previewMode !== "grid" && terrainRenderState === "ready";
}
export function shouldDrawElevationGridOverlay(
previewMode: ElevationPreviewDisplayMode,
terrainRenderState: ElevationTerrainRenderState,
hasTerrainError: boolean,
): boolean {
if (previewMode === "grid") {
return true;
}
if (previewMode === "terrain") {
return false;
}
return terrainRenderState !== "ready" || hasTerrainError;
}