feat:[FL-182][高程数据预览优化]
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -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 时会显示椭球底面参考点和状态提示。
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user