@@ -246,3 +246,27 @@
|
|||||||
- 影响范围集中在用户管理模块(`/admin/users` 与 `/api/v1/users*`)。
|
- 影响范围集中在用户管理模块(`/admin/users` 与 `/api/v1/users*`)。
|
||||||
- 旧调用方不传 `keyword/status` 时行为保持兼容。
|
- 旧调用方不传 `keyword/status` 时行为保持兼容。
|
||||||
- 更新失败错误提示文案仍共用“not found or email/username exists”,后续如需更精确错误码可再拆分。
|
- 更新失败错误提示文案仍共用“not found or email/username exists”,后续如需更精确错误码可再拆分。
|
||||||
|
|
||||||
|
## Work Log - 线路管理分布图加入缩放 Slider 与比例显示(2026-05-01)
|
||||||
|
|
||||||
|
- 背景:
|
||||||
|
- Issue `FL-131` 要求“线路管理页面分布图优化:加入 slider 显示缩放比例”。
|
||||||
|
|
||||||
|
- 本次改动(最小闭环):
|
||||||
|
- 文件:`web/src/components/power-line-cesium-map.tsx`
|
||||||
|
- 新增地图缩放 `Slider`(竖向),置于已有 `+/-/居中` 控件上方。
|
||||||
|
- 新增“缩放比例 xx%”文本展示。
|
||||||
|
- 新增缩放比例与相机高度的双向映射函数(对数映射),解决不同尺度下线性感知不均问题。
|
||||||
|
- 监听 `viewer.camera.changed`,实现鼠标滚轮/按钮缩放时比例实时同步。
|
||||||
|
- 拖动 Slider 时调用相机 `flyTo` 调整高度,保持当前位置不变,仅修改缩放。
|
||||||
|
- 新增防抖动状态控制(`sliderChangingRef`)与无效 setState 保护,减少高频相机事件导致的重复渲染。
|
||||||
|
|
||||||
|
- 验证:
|
||||||
|
- 本次遵循任务约束,未执行编译/安装。
|
||||||
|
- 通过代码走读确认:
|
||||||
|
- `+/-` 按钮、鼠标滚轮、居中重置都会同步刷新缩放比例。
|
||||||
|
- Slider 拖动可直接驱动地图缩放。
|
||||||
|
|
||||||
|
- 风险与影响:
|
||||||
|
- 影响范围限定在线路管理分布图前端组件,不涉及后端接口与数据结构。
|
||||||
|
- 缩放比例为相对值(基于当前线路包围球动态计算),不同线路之间 100%/0% 对应的绝对相机高度不同,属于预期行为。
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AimOutlined, MinusOutlined, PlusOutlined } from "@ant-design/icons";
|
import { AimOutlined, MinusOutlined, PlusOutlined } from "@ant-design/icons";
|
||||||
import { Alert, Button, Checkbox, Empty, Spin, Typography } from "antd";
|
import { Alert, Button, Checkbox, Empty, Slider, Spin, Typography } from "antd";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { reloadOnceOnChunkError } from "@/lib/chunk-error";
|
import { reloadOnceOnChunkError } from "@/lib/chunk-error";
|
||||||
@@ -36,6 +36,11 @@ type RouteViewState = {
|
|||||||
range: number;
|
range: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ZoomBounds = {
|
||||||
|
near: number;
|
||||||
|
far: number;
|
||||||
|
};
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
CESIUM_BASE_URL?: string;
|
CESIUM_BASE_URL?: string;
|
||||||
@@ -45,6 +50,10 @@ declare global {
|
|||||||
const MAP_HEIGHT = 560;
|
const MAP_HEIGHT = 560;
|
||||||
const DEFAULT_ALTITUDE_M = 0;
|
const DEFAULT_ALTITUDE_M = 0;
|
||||||
const MIN_CAMERA_RANGE = 1500;
|
const MIN_CAMERA_RANGE = 1500;
|
||||||
|
const MIN_ZOOM_STEP_RANGE = 300;
|
||||||
|
const ZOOM_PERCENT_MAX = 100;
|
||||||
|
const ZOOM_MIN_FACTOR = 0.22;
|
||||||
|
const ZOOM_MAX_FACTOR = 2.8;
|
||||||
const DEFAULT_POINT_COLOR = "#38bdf8";
|
const DEFAULT_POINT_COLOR = "#38bdf8";
|
||||||
const RISK_COLOR_BY_LEVEL: Record<string, string> = {
|
const RISK_COLOR_BY_LEVEL: Record<string, string> = {
|
||||||
"1": "#22c55e",
|
"1": "#22c55e",
|
||||||
@@ -138,6 +147,31 @@ function estimateRouteLengthKm(segments: RouteSegment[]): number {
|
|||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rangeToZoomPercent(range: number, bounds: ZoomBounds): number {
|
||||||
|
const safeNear = Math.max(bounds.near, 1);
|
||||||
|
const safeFar = Math.max(bounds.far, safeNear + 1);
|
||||||
|
const safeRange = clamp(range, safeNear, safeFar);
|
||||||
|
const minLog = Math.log(safeNear);
|
||||||
|
const maxLog = Math.log(safeFar);
|
||||||
|
const valueLog = Math.log(safeRange);
|
||||||
|
const ratio = (valueLog - minLog) / (maxLog - minLog);
|
||||||
|
return Math.round((1 - ratio) * ZOOM_PERCENT_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomPercentToRange(percent: number, bounds: ZoomBounds): number {
|
||||||
|
const safeNear = Math.max(bounds.near, 1);
|
||||||
|
const safeFar = Math.max(bounds.far, safeNear + 1);
|
||||||
|
const ratio = clamp(percent, 0, ZOOM_PERCENT_MAX) / ZOOM_PERCENT_MAX;
|
||||||
|
const minLog = Math.log(safeNear);
|
||||||
|
const maxLog = Math.log(safeFar);
|
||||||
|
const valueLog = maxLog - ratio * (maxLog - minLog);
|
||||||
|
return Math.exp(valueLog);
|
||||||
|
}
|
||||||
|
|
||||||
export function PowerLineCesiumMap({
|
export function PowerLineCesiumMap({
|
||||||
lineCode,
|
lineCode,
|
||||||
lineName,
|
lineName,
|
||||||
@@ -148,10 +182,13 @@ export function PowerLineCesiumMap({
|
|||||||
const viewerRef = useRef<import("cesium").Viewer | null>(null);
|
const viewerRef = useRef<import("cesium").Viewer | null>(null);
|
||||||
const cesiumRef = useRef<CesiumNamespace | null>(null);
|
const cesiumRef = useRef<CesiumNamespace | null>(null);
|
||||||
const routeViewRef = useRef<RouteViewState | null>(null);
|
const routeViewRef = useRef<RouteViewState | null>(null);
|
||||||
|
const zoomBoundsRef = useRef<ZoomBounds | null>(null);
|
||||||
|
const sliderChangingRef = useRef(false);
|
||||||
const [initError, setInitError] = useState("");
|
const [initError, setInitError] = useState("");
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [colorByRisk, setColorByRisk] = useState(true);
|
const [colorByRisk, setColorByRisk] = useState(true);
|
||||||
const [showLabels, setShowLabels] = useState(true);
|
const [showLabels, setShowLabels] = useState(true);
|
||||||
|
const [zoomPercent, setZoomPercent] = useState(50);
|
||||||
|
|
||||||
const sortedTowers = useMemo(
|
const sortedTowers = useMemo(
|
||||||
() => [...towers].sort((a, b) => a.seq_no - b.seq_no),
|
() => [...towers].sort((a, b) => a.seq_no - b.seq_no),
|
||||||
@@ -184,6 +221,20 @@ export function PowerLineCesiumMap({
|
|||||||
const invalidGeoCount = Math.max(sortedTowers.length - towerGeoPoints.length, 0);
|
const invalidGeoCount = Math.max(sortedTowers.length - towerGeoPoints.length, 0);
|
||||||
const controlsDisabled = !ready || towerGeoPoints.length === 0;
|
const controlsDisabled = !ready || towerGeoPoints.length === 0;
|
||||||
|
|
||||||
|
const syncZoomPercentFromCamera = useCallback(() => {
|
||||||
|
const viewer = viewerRef.current;
|
||||||
|
const bounds = zoomBoundsRef.current;
|
||||||
|
if (!viewer || !bounds || sliderChangingRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cameraHeight = viewer.camera.positionCartographic?.height;
|
||||||
|
if (!Number.isFinite(cameraHeight)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextPercent = rangeToZoomPercent(Number(cameraHeight), bounds);
|
||||||
|
setZoomPercent((previous) => (previous === nextPercent ? previous : nextPercent));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const focusRoute = useCallback(() => {
|
const focusRoute = useCallback(() => {
|
||||||
const viewer = viewerRef.current;
|
const viewer = viewerRef.current;
|
||||||
const Cesium = cesiumRef.current;
|
const Cesium = cesiumRef.current;
|
||||||
@@ -194,20 +245,23 @@ export function PowerLineCesiumMap({
|
|||||||
viewer.camera.flyToBoundingSphere(routeView.boundingSphere, {
|
viewer.camera.flyToBoundingSphere(routeView.boundingSphere, {
|
||||||
duration: 0.75,
|
duration: 0.75,
|
||||||
offset: new Cesium.HeadingPitchRange(0, -0.65, routeView.range),
|
offset: new Cesium.HeadingPitchRange(0, -0.65, routeView.range),
|
||||||
|
complete: () => {
|
||||||
|
syncZoomPercentFromCamera();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}, []);
|
}, [syncZoomPercentFromCamera]);
|
||||||
|
|
||||||
const resolveZoomDistance = useCallback((): number => {
|
const resolveZoomDistance = useCallback((): number => {
|
||||||
const viewer = viewerRef.current;
|
const viewer = viewerRef.current;
|
||||||
if (!viewer) {
|
if (!viewer) {
|
||||||
return MIN_CAMERA_RANGE / 3;
|
return MIN_ZOOM_STEP_RANGE;
|
||||||
}
|
}
|
||||||
const cameraHeight = viewer.camera.positionCartographic?.height;
|
const cameraHeight = viewer.camera.positionCartographic?.height;
|
||||||
if (Number.isFinite(cameraHeight)) {
|
if (Number.isFinite(cameraHeight)) {
|
||||||
return Math.max(Number(cameraHeight) * 0.25, 300);
|
return Math.max(Number(cameraHeight) * 0.25, MIN_ZOOM_STEP_RANGE);
|
||||||
}
|
}
|
||||||
const routeRange = routeViewRef.current?.range ?? MIN_CAMERA_RANGE;
|
const routeRange = routeViewRef.current?.range ?? MIN_CAMERA_RANGE;
|
||||||
return Math.max(routeRange * 0.2, 300);
|
return Math.max(routeRange * 0.2, MIN_ZOOM_STEP_RANGE);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const zoomInRoute = useCallback(() => {
|
const zoomInRoute = useCallback(() => {
|
||||||
@@ -226,6 +280,39 @@ export function PowerLineCesiumMap({
|
|||||||
viewer.camera.zoomOut(resolveZoomDistance());
|
viewer.camera.zoomOut(resolveZoomDistance());
|
||||||
}, [resolveZoomDistance]);
|
}, [resolveZoomDistance]);
|
||||||
|
|
||||||
|
const handleZoomSliderChange = useCallback((value: number) => {
|
||||||
|
const viewer = viewerRef.current;
|
||||||
|
const Cesium = cesiumRef.current;
|
||||||
|
const bounds = zoomBoundsRef.current;
|
||||||
|
if (!viewer || !Cesium || !bounds) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sliderChangingRef.current = true;
|
||||||
|
setZoomPercent(value);
|
||||||
|
const nextHeight = zoomPercentToRange(value, bounds);
|
||||||
|
const current = viewer.camera.positionCartographic;
|
||||||
|
if (!current) {
|
||||||
|
sliderChangingRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
viewer.camera.flyTo({
|
||||||
|
destination: Cesium.Cartesian3.fromRadians(
|
||||||
|
current.longitude,
|
||||||
|
current.latitude,
|
||||||
|
nextHeight,
|
||||||
|
),
|
||||||
|
duration: 0.18,
|
||||||
|
complete: () => {
|
||||||
|
sliderChangingRef.current = false;
|
||||||
|
syncZoomPercentFromCamera();
|
||||||
|
},
|
||||||
|
cancel: () => {
|
||||||
|
sliderChangingRef.current = false;
|
||||||
|
syncZoomPercentFromCamera();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [syncZoomPercentFromCamera]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -264,6 +351,7 @@ export function PowerLineCesiumMap({
|
|||||||
viewer.scene.globe.baseColor = Cesium.Color.fromCssColorString("#0f172a");
|
viewer.scene.globe.baseColor = Cesium.Color.fromCssColorString("#0f172a");
|
||||||
viewer.scene.backgroundColor = Cesium.Color.fromCssColorString("#020617");
|
viewer.scene.backgroundColor = Cesium.Color.fromCssColorString("#020617");
|
||||||
viewer.scene.screenSpaceCameraController.enableZoom = true;
|
viewer.scene.screenSpaceCameraController.enableZoom = true;
|
||||||
|
viewer.camera.changed.addEventListener(syncZoomPercentFromCamera);
|
||||||
const creditContainer = viewer.cesiumWidget.creditContainer as HTMLElement | null;
|
const creditContainer = viewer.cesiumWidget.creditContainer as HTMLElement | null;
|
||||||
if (creditContainer) {
|
if (creditContainer) {
|
||||||
creditContainer.style.display = "none";
|
creditContainer.style.display = "none";
|
||||||
@@ -288,14 +376,16 @@ export function PowerLineCesiumMap({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (viewerRef.current && !viewerRef.current.isDestroyed()) {
|
if (viewerRef.current && !viewerRef.current.isDestroyed()) {
|
||||||
|
viewerRef.current.camera.changed.removeEventListener(syncZoomPercentFromCamera);
|
||||||
viewerRef.current.destroy();
|
viewerRef.current.destroy();
|
||||||
}
|
}
|
||||||
viewerRef.current = null;
|
viewerRef.current = null;
|
||||||
cesiumRef.current = null;
|
cesiumRef.current = null;
|
||||||
routeViewRef.current = null;
|
routeViewRef.current = null;
|
||||||
|
zoomBoundsRef.current = null;
|
||||||
setReady(false);
|
setReady(false);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [syncZoomPercentFromCamera]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const viewer = viewerRef.current;
|
const viewer = viewerRef.current;
|
||||||
@@ -386,6 +476,10 @@ export function PowerLineCesiumMap({
|
|||||||
boundingSphere,
|
boundingSphere,
|
||||||
range,
|
range,
|
||||||
};
|
};
|
||||||
|
zoomBoundsRef.current = {
|
||||||
|
near: Math.max(range * ZOOM_MIN_FACTOR, MIN_ZOOM_STEP_RANGE),
|
||||||
|
far: Math.max(range * ZOOM_MAX_FACTOR, MIN_CAMERA_RANGE),
|
||||||
|
};
|
||||||
focusRoute();
|
focusRoute();
|
||||||
}, [ready, towerGeoPoints, routeSegments, colorByRisk, showLabels, focusRoute]);
|
}, [ready, towerGeoPoints, routeSegments, colorByRisk, showLabels, focusRoute]);
|
||||||
|
|
||||||
@@ -398,6 +492,7 @@ export function PowerLineCesiumMap({
|
|||||||
<Checkbox checked={showLabels} onChange={(event) => setShowLabels(event.target.checked)}>
|
<Checkbox checked={showLabels} onChange={(event) => setShowLabels(event.target.checked)}>
|
||||||
显示塔号
|
显示塔号
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
|
<Typography.Text type="secondary">缩放比例 {zoomPercent}%</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
线路走向图:{lineName || "-"}({lineCode || "-"}),有效坐标 {towerGeoPoints.length}/{sortedTowers.length},缺失 {invalidGeoCount},
|
线路走向图:{lineName || "-"}({lineCode || "-"}),有效坐标 {towerGeoPoints.length}/{sortedTowers.length},缺失 {invalidGeoCount},
|
||||||
@@ -408,6 +503,26 @@ export function PowerLineCesiumMap({
|
|||||||
<div className="relative overflow-hidden rounded-lg border border-slate-200 bg-slate-900/90" style={{ height: MAP_HEIGHT }}>
|
<div className="relative overflow-hidden rounded-lg border border-slate-200 bg-slate-900/90" style={{ height: MAP_HEIGHT }}>
|
||||||
<div ref={containerRef} className="h-full w-full" />
|
<div ref={containerRef} className="h-full w-full" />
|
||||||
<div className="absolute right-3 top-3 z-10 flex flex-col gap-2 rounded-md border border-slate-700/80 bg-slate-950/70 p-1.5 shadow-lg backdrop-blur-sm">
|
<div className="absolute right-3 top-3 z-10 flex flex-col gap-2 rounded-md border border-slate-700/80 bg-slate-950/70 p-1.5 shadow-lg backdrop-blur-sm">
|
||||||
|
<div className="px-1">
|
||||||
|
<Slider
|
||||||
|
vertical
|
||||||
|
min={0}
|
||||||
|
max={ZOOM_PERCENT_MAX}
|
||||||
|
value={zoomPercent}
|
||||||
|
tooltip={{ open: false }}
|
||||||
|
styles={{
|
||||||
|
rail: { backgroundColor: "rgba(148, 163, 184, 0.35)" },
|
||||||
|
track: { backgroundColor: "rgba(56, 189, 248, 0.9)" },
|
||||||
|
handle: { borderColor: "#38bdf8", backgroundColor: "#e0f2fe" },
|
||||||
|
}}
|
||||||
|
disabled={controlsDisabled}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (typeof value === "number") {
|
||||||
|
handleZoomSliderChange(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
shape="circle"
|
shape="circle"
|
||||||
|
|||||||
Reference in New Issue
Block a user