diff --git a/memory/2026-05-01.md b/memory/2026-05-01.md index 3efbe31..578760b 100644 --- a/memory/2026-05-01.md +++ b/memory/2026-05-01.md @@ -246,3 +246,27 @@ - 影响范围集中在用户管理模块(`/admin/users` 与 `/api/v1/users*`)。 - 旧调用方不传 `keyword/status` 时行为保持兼容。 - 更新失败错误提示文案仍共用“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% 对应的绝对相机高度不同,属于预期行为。 diff --git a/web/src/components/power-line-cesium-map.tsx b/web/src/components/power-line-cesium-map.tsx index b445713..802bb85 100644 --- a/web/src/components/power-line-cesium-map.tsx +++ b/web/src/components/power-line-cesium-map.tsx @@ -1,7 +1,7 @@ "use client"; 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 { reloadOnceOnChunkError } from "@/lib/chunk-error"; @@ -36,6 +36,11 @@ type RouteViewState = { range: number; }; +type ZoomBounds = { + near: number; + far: number; +}; + declare global { interface Window { CESIUM_BASE_URL?: string; @@ -45,6 +50,10 @@ declare global { const MAP_HEIGHT = 560; const DEFAULT_ALTITUDE_M = 0; 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 RISK_COLOR_BY_LEVEL: Record = { "1": "#22c55e", @@ -138,6 +147,31 @@ function estimateRouteLengthKm(segments: RouteSegment[]): number { }, 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({ lineCode, lineName, @@ -148,10 +182,13 @@ export function PowerLineCesiumMap({ const viewerRef = useRef(null); const cesiumRef = useRef(null); const routeViewRef = useRef(null); + const zoomBoundsRef = useRef(null); + const sliderChangingRef = useRef(false); const [initError, setInitError] = useState(""); const [ready, setReady] = useState(false); const [colorByRisk, setColorByRisk] = useState(true); const [showLabels, setShowLabels] = useState(true); + const [zoomPercent, setZoomPercent] = useState(50); const sortedTowers = useMemo( () => [...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 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 viewer = viewerRef.current; const Cesium = cesiumRef.current; @@ -194,20 +245,23 @@ export function PowerLineCesiumMap({ viewer.camera.flyToBoundingSphere(routeView.boundingSphere, { duration: 0.75, offset: new Cesium.HeadingPitchRange(0, -0.65, routeView.range), + complete: () => { + syncZoomPercentFromCamera(); + }, }); - }, []); + }, [syncZoomPercentFromCamera]); const resolveZoomDistance = useCallback((): number => { const viewer = viewerRef.current; if (!viewer) { - return MIN_CAMERA_RANGE / 3; + return MIN_ZOOM_STEP_RANGE; } const cameraHeight = viewer.camera.positionCartographic?.height; 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; - return Math.max(routeRange * 0.2, 300); + return Math.max(routeRange * 0.2, MIN_ZOOM_STEP_RANGE); }, []); const zoomInRoute = useCallback(() => { @@ -226,6 +280,39 @@ export function PowerLineCesiumMap({ viewer.camera.zoomOut(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(() => { let cancelled = false; @@ -264,6 +351,7 @@ export function PowerLineCesiumMap({ viewer.scene.globe.baseColor = Cesium.Color.fromCssColorString("#0f172a"); viewer.scene.backgroundColor = Cesium.Color.fromCssColorString("#020617"); viewer.scene.screenSpaceCameraController.enableZoom = true; + viewer.camera.changed.addEventListener(syncZoomPercentFromCamera); const creditContainer = viewer.cesiumWidget.creditContainer as HTMLElement | null; if (creditContainer) { creditContainer.style.display = "none"; @@ -288,14 +376,16 @@ export function PowerLineCesiumMap({ return () => { cancelled = true; if (viewerRef.current && !viewerRef.current.isDestroyed()) { + viewerRef.current.camera.changed.removeEventListener(syncZoomPercentFromCamera); viewerRef.current.destroy(); } viewerRef.current = null; cesiumRef.current = null; routeViewRef.current = null; + zoomBoundsRef.current = null; setReady(false); }; - }, []); + }, [syncZoomPercentFromCamera]); useEffect(() => { const viewer = viewerRef.current; @@ -386,6 +476,10 @@ export function PowerLineCesiumMap({ boundingSphere, 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(); }, [ready, towerGeoPoints, routeSegments, colorByRisk, showLabels, focusRoute]); @@ -398,6 +492,7 @@ export function PowerLineCesiumMap({ setShowLabels(event.target.checked)}> 显示塔号 + 缩放比例 {zoomPercent}% 线路走向图:{lineName || "-"}({lineCode || "-"}),有效坐标 {towerGeoPoints.length}/{sortedTowers.length},缺失 {invalidGeoCount}, @@ -408,6 +503,26 @@ export function PowerLineCesiumMap({
+
+ { + if (typeof value === "number") { + handleZoomSliderChange(value); + } + }} + /> +