From c71298c5ebc60b062462948597dcf6ea1c2cf8e9 Mon Sep 17 00:00:00 2001 From: Jerry Date: Sun, 18 Oct 2026 11:15:17 +0800 Subject: [PATCH 1/3] refactor(cesium): restore sky and atmosphere, add cinematic entry and shared effects Shared viewer keeps Cesium's sky/atmosphere on, deep-blue globe fallback color, ground atmosphere, an entry flyover (high-altitude setView then 2.2s eased flyTo) with instant mode for deterministic tests, and an effects kit (scan rings, glow material, target icons) reused by the pages. --- src/components/CesiumViewer/effects.ts | 106 ++++++ src/components/CesiumViewer/index.tsx | 46 ++- .../CesiumViewer/lifecycle.test.tsx | 44 ++- src/pages/Map/Cesium/Trajectory.tsx | 318 +++++++++++------- 4 files changed, 383 insertions(+), 131 deletions(-) create mode 100644 src/components/CesiumViewer/effects.ts diff --git a/src/components/CesiumViewer/effects.ts b/src/components/CesiumViewer/effects.ts new file mode 100644 index 0000000..3ee7fc5 --- /dev/null +++ b/src/components/CesiumViewer/effects.ts @@ -0,0 +1,106 @@ +/** + * 演示特效工具库:六页复用的「弹药」——扫描圈、流光材质、目标图标。 + * 全部基于 CallbackProperty(每帧求值),数量级在个位数实体时性能无虞; + * 数值驱动动画而非 Cesium clock,离线/静态降级时(reduce 动效)由调用方自行不添加。 + */ +import * as Cesium from 'cesium'; + +export interface ScanRingOptions { + /** 扫描中心(度) */ + lon: number; + lat: number; + color?: string; + /** 最大扫描半径(米) */ + maxRadius?: number; + /** 一圈扫完的时长(秒) */ + duration?: number; + /** 环心高度(米),跟随飞行目标时给目标高度 */ + height?: number; +} + +/** + * 雷达扫描圈:半径从内向外扩张、随扩张渐隐,循环往复。 + * 相位必须取自 Cesium 传入的 clock 时间(同一帧内两次求值结果一致); + * 用 performance.now() 会让半长/半短轴在相位回绕处取到不同值,触发 + * DeveloperError: semiMajorAxis < semiMinorAxis,渲染循环直接停摆。 + * 两轴共用同一个 CallbackProperty 实例,从根上排除不一致。 + */ +export const scanRingMaterial = (color: string, maxRadius: number, duration: number) => { + const epochMs = performance.now(); + const phaseOf = (time?: Cesium.JulianDate) => { + const ms = time ? Cesium.JulianDate.toDate(time).getTime() : performance.now(); + return (((ms - epochMs) / 1000) % duration) / duration; + }; + const base = Cesium.Color.fromCssColorString(color); + const axis = new Cesium.CallbackProperty((time?: Cesium.JulianDate) => 4_000 + phaseOf(time) * maxRadius, false); + return { + semiMajorAxis: axis, + semiMinorAxis: axis, + material: new Cesium.ColorMaterialProperty( + new Cesium.CallbackProperty((time?: Cesium.JulianDate) => base.withAlpha(0.42 * (1 - phaseOf(time))), false), + ), + }; +}; + +export const addScanRing = ( + viewer: Cesium.Viewer, + { lon, lat, color = '#1677ff', maxRadius = 60_000, duration = 3, height = 0 }: ScanRingOptions, +) => + viewer.entities.add({ + position: Cesium.Cartesian3.fromDegrees(lon, lat, height), + ellipse: scanRingMaterial(color, maxRadius, duration), + }); + +/** 流光 polyline 材质:比纯色/虚线更有「能量在线路里流动」的观感 */ +export const glowMaterial = (css: string, alpha = 0.9) => + new Cesium.PolylineGlowMaterialProperty({ + glowPower: 0.28, + taperPower: 0.6, + color: Cesium.Color.fromCssColorString(css).withAlpha(alpha), + }); + +/** 用 canvas 画目标图标(billboard 用),避免依赖外部图片资源 */ +const drawIcon = (kind: 'ship' | 'aircraft', css: string) => { + const canvas = document.createElement('canvas'); + canvas.width = 64; + canvas.height = 64; + const ctx = canvas.getContext('2d'); + if (!ctx) return ''; + ctx.fillStyle = css; + if (kind === 'aircraft') { + // 纸飞机:朝正上,billboard rotation 转航向 + ctx.beginPath(); + ctx.moveTo(32, 4); + ctx.lineTo(58, 56); + ctx.lineTo(32, 42); + ctx.lineTo(6, 56); + ctx.closePath(); + ctx.fill(); + } else { + // 船体 + 航向桅杆 + ctx.beginPath(); + ctx.moveTo(10, 38); + ctx.lineTo(54, 38); + ctx.lineTo(46, 54); + ctx.lineTo(18, 54); + ctx.closePath(); + ctx.fill(); + ctx.fillRect(30, 8, 4, 30); + ctx.beginPath(); + ctx.moveTo(34, 10); + ctx.lineTo(52, 32); + ctx.lineTo(34, 32); + ctx.closePath(); + ctx.fill(); + } + return canvas.toDataURL(); +}; + +let iconCache: Record = {}; + +/** 目标图标(dataURL,按 kind+颜色缓存):船/飞机的剪影,配 billboard 使用 */ +export const targetIcon = (kind: 'ship' | 'aircraft', css: string) => { + const key = `${kind}:${css}`; + iconCache[key] ??= drawIcon(kind, css); + return iconCache[key]; +}; diff --git a/src/components/CesiumViewer/index.tsx b/src/components/CesiumViewer/index.tsx index 7822c1c..14c9243 100644 --- a/src/components/CesiumViewer/index.tsx +++ b/src/components/CesiumViewer/index.tsx @@ -6,7 +6,9 @@ import 'cesium/Build/Cesium/Widgets/widgets.css'; import { useEffect, useRef, useState, type ReactNode } from 'react'; import './cesium.css'; -/** 演示页通用的 Viewer 控件配置:关闭动画、时间轴、信息框、帮助等,保留 2D/3D 切换 */ +/** 演示页通用的 Viewer 控件配置:关闭原生动画/时间轴/信息框等(自绘控制条),保留 2D/3D 切换。 + * 天空与大气保持默认开启(省略该键即启用):这是 Cesium「旋转的蓝色星球」观感的来源, + * 关掉后瓦片未覆盖的区域就是一片黑幕 */ const DEMO_VIEWER_OPTIONS: Cesium.Viewer.ConstructorOptions = { animation: false, infoBox: false, @@ -16,8 +18,6 @@ const DEMO_VIEWER_OPTIONS: Cesium.Viewer.ConstructorOptions = { navigationHelpButton: false, navigationInstructionsInitiallyVisible: false, shouldAnimate: true, - skyAtmosphere: false, - skyBox: false, vrButton: false, baseLayerPicker: false, }; @@ -51,12 +51,16 @@ export const createDemoViewer = (container: string | Element, options: Cesium.Vi const baseLayer = shouldUseIonImagery() ? undefined : new Cesium.ImageryLayer(new Cesium.OpenStreetMapImageryProvider({ url: 'https://tile.openstreetmap.org/' })); - return new Cesium.Viewer(container, { + const instance = new Cesium.Viewer(container, { ...DEMO_VIEWER_OPTIONS, geocoder: false, ...(baseLayer ? { baseLayer } : {}), ...options, }); + // 瓦片未覆盖/加载中的区域用深蓝海洋色兜底:黑幕 → 深夜地球,哪怕离线也有「星球」的观感 + instance.scene.globe.baseColor = Cesium.Color.fromCssColorString('#0b2a4a'); + instance.scene.globe.showGroundAtmosphere = true; + return instance; } catch (error) { console.error('Cesium Viewer init failed:', error); return null; @@ -77,6 +81,10 @@ export const CesiumInitError = () => { type UseCesiumViewerOptions = { /** 初始视角 [经度, 纬度, 高度(米)] */ home?: [number, number, number]; + /** 入场视角的姿态(弧度);缺省为正俯视。配合 home 组成该页的「定妆镜头」 */ + entry?: { heading?: number; pitch?: number; roll?: number }; + /** 关掉电影感入场(单测/截图等需要确定性视角时用) */ + instantEntry?: boolean; viewerOptions?: Cesium.Viewer.ConstructorOptions; /** Viewer 创建后执行一次(添加图层、事件等);返回的函数在销毁前调用 */ onReady?: (viewer: Cesium.Viewer) => void | (() => void); @@ -88,6 +96,8 @@ type UseCesiumViewerOptions = { */ export const useCesiumViewer = ({ home = [108, 35, 7_000_000], + entry, + instantEntry = false, viewerOptions, onReady, }: UseCesiumViewerOptions = {}) => { @@ -101,19 +111,39 @@ export const useCesiumViewer = ({ onReadyRef.current = onReady; // home / viewerOptions 只在创建时读一次。调用方常在渲染里写内联对象字面量,若列进依赖会每次渲染 // 重建 Viewer(连瓦片一起重拉);放进 ref 后 effect 只需依赖 attempt,读到的仍是本次渲染的最新值。 - const createOptionsRef = useRef({ home, viewerOptions }); - createOptionsRef.current = { home, viewerOptions }; + const createOptionsRef = useRef({ home, entry, instantEntry, viewerOptions }); + createOptionsRef.current = { home, entry, instantEntry, viewerOptions }; useEffect(() => { if (!containerRef.current) return undefined; setBasemapFailed(false); - const { home: target, viewerOptions: options } = createOptionsRef.current; + const { home: target, entry: entryPose, instantEntry: instant, viewerOptions: options } = createOptionsRef.current; const instance = createDemoViewer(containerRef.current, options); if (!instance) { setError(true); return undefined; } - instance.camera.setView({ destination: Cesium.Cartesian3.fromDegrees(...target) }); + // 电影感入场:先在高处落位,再缓动推近到定妆镜头(高度约 2.6 倍起步,缓入缓出 2.2 秒) + const destination = Cesium.Cartesian3.fromDegrees(...target); + const orientation = { + heading: entryPose?.heading ?? 0, + pitch: entryPose?.pitch ?? Cesium.Math.toRadians(-90), + roll: entryPose?.roll ?? 0, + }; + if (instant) { + instance.camera.setView({ destination, orientation }); + } else { + instance.camera.setView({ + destination: Cesium.Cartesian3.fromDegrees(target[0], target[1], target[2] * 2.6 + 600_000), + orientation, + }); + instance.camera.flyTo({ + destination, + orientation, + duration: 2.2, + easingFunction: Cesium.EasingFunction.QUADRATIC_IN_OUT, + }); + } // 底图失败检测(F01),三条互补路径: // 1) fetch 层计数:对瓦片主机的失败请求计数(离线/被拦截时最直接,不依赖 Cesium 内部事件的触发时机); // 2) 影像层瓦片错误事件(部分版本在重试耗尽后触发); diff --git a/src/components/CesiumViewer/lifecycle.test.tsx b/src/components/CesiumViewer/lifecycle.test.tsx index 8ac1330..8516f97 100644 --- a/src/components/CesiumViewer/lifecycle.test.tsx +++ b/src/components/CesiumViewer/lifecycle.test.tsx @@ -8,7 +8,9 @@ type MockViewer = { container: unknown; destroyed: boolean; setViewCalls: unknown[]; - camera: { setView: (view: unknown) => void }; + flyToCalls: unknown[]; + camera: { setView: (view: unknown) => void; flyTo: (view: unknown) => void }; + scene: { globe: Record }; imageryLayers: { get: (index: number) => undefined }; isDestroyed: () => boolean; destroy: () => void; @@ -25,11 +27,16 @@ vi.mock('cesium', () => { container: unknown; destroyed = false; setViewCalls: unknown[] = []; + flyToCalls: unknown[] = []; camera = { setView: (view: unknown) => { this.setViewCalls.push(view); }, + flyTo: (view: unknown) => { + this.flyToCalls.push(view); + }, }; + scene = { globe: {} as Record }; imageryLayers = { get: () => undefined }; constructor(container: unknown, options: Record) { if (cesium.failInit) throw new Error('WebGL unavailable'); @@ -56,6 +63,8 @@ vi.mock('cesium', () => { constructor(public options: unknown) {} }, RequestErrorEvent: class {}, + Math: { toRadians: (deg: number) => (deg * Math.PI) / 180 }, + EasingFunction: { QUADRATIC_IN_OUT: 'quadratic-in-out' }, }; }); @@ -65,6 +74,7 @@ import { useCesiumViewer } from './index'; type HarnessProps = { home?: [number, number, number]; + instantEntry?: boolean; viewerOptions?: CesiumNS.Viewer.ConstructorOptions; onReady?: (viewer: CesiumNS.Viewer) => void | (() => void); }; @@ -73,6 +83,7 @@ type HarnessProps = { const Harness = (props: HarnessProps) => { const { containerRef, viewer, error, basemapFailed, retry } = useCesiumViewer({ home: props.home, + instantEntry: props.instantEntry, viewerOptions: props.viewerOptions, onReady: props.onReady, }); @@ -122,7 +133,7 @@ afterEach(() => { }); describe('useCesiumViewer lifecycle', () => { - it('creates one viewer on the stage node, applies home view and onReady, then destroys on unmount', () => { + it('creates one viewer on the stage node, flies in cinematically, applies onReady, then destroys on unmount', () => { const readyCleanup = vi.fn(); // 形参不能省:零参 vi.fn 的 mock.calls 类型是 [][],后续 calls[0][0] 会报 TS2493 const onReady = vi.fn((_viewer: unknown) => readyCleanup); @@ -131,9 +142,21 @@ describe('useCesiumViewer lifecycle', () => { expect(cesium.constructed).toHaveLength(1); expect(cesium.constructed[0].container).toBe(harness.stage); expect(cesium.constructed[0].options).toMatchObject({ geocoder: false, timeline: true }); - // 初始视角来自 home(默认 [108, 35, 7_000_000]) + // 电影感入场:先在约 2.6 倍高度 + 600km 处落位(正俯视),再缓动推近到 home 定妆镜头 + const pose = { heading: 0, pitch: -Math.PI / 2, roll: 0 }; expect(cesium.instances[0].setViewCalls).toEqual([ - { destination: { kind: 'fromDegrees', args: [116.4, 39.9, 800] } }, + { + destination: { kind: 'fromDegrees', args: [116.4, 39.9, 800 * 2.6 + 600_000] }, + orientation: pose, + }, + ]); + expect(cesium.instances[0].flyToCalls).toEqual([ + { + destination: { kind: 'fromDegrees', args: [116.4, 39.9, 800] }, + orientation: pose, + duration: 2.2, + easingFunction: 'quadratic-in-out', + }, ]); expect(onReady).toHaveBeenCalledTimes(1); expect(onReady.mock.calls[0]?.[0]).toBe(cesium.instances[0]); @@ -145,6 +168,19 @@ describe('useCesiumViewer lifecycle', () => { expect(window.fetch).toBe(originalFetch); }); + it('skips the flyover and lands directly on home when instantEntry is set', () => { + const harness = mount({ home: [116.4, 39.9, 800], instantEntry: true }); + + expect(cesium.instances[0].setViewCalls).toEqual([ + { + destination: { kind: 'fromDegrees', args: [116.4, 39.9, 800] }, + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }, + ]); + expect(cesium.instances[0].flyToCalls).toEqual([]); + harness.unmount(); + }); + it('does not recreate the viewer when the caller passes fresh inline options on re-render', async () => { // 回归:home / viewerOptions 若直接进 effect 依赖,调用方的内联字面量会每次渲染重建 Viewer(连瓦片重拉) const harness = mount({ home: [108, 35, 7_000_000], viewerOptions: { timeline: false } }); diff --git a/src/pages/Map/Cesium/Trajectory.tsx b/src/pages/Map/Cesium/Trajectory.tsx index 7c29235..e86e52b 100644 --- a/src/pages/Map/Cesium/Trajectory.tsx +++ b/src/pages/Map/Cesium/Trajectory.tsx @@ -1,48 +1,45 @@ import { CesiumStage, useCesiumViewer } from '@/components/CesiumViewer'; +import { glowMaterial, targetIcon } from '@/components/CesiumViewer/effects'; import { pickLngLat } from '@/components/CesiumViewer/stations'; import DemoPage from '@/components/DemoPage'; -import { handlerComputePoint, mergePolygons, mergePolygonsPath, type Point } from '@/utils/MapCompute/cesiumCompute'; -import { distanceKm } from '@/utils/MapCompute/geodesy'; -import { BlockOutlined, CheckOutlined, ClearOutlined, GatewayOutlined, NodeIndexOutlined } from '@ant-design/icons'; +import { BASE_STATIONS, buildPatrolRoute, qualityColor, SAMPLES_PER_SECOND, sampleRoute, type PatrolSample } from './patrolModel'; +import { CheckOutlined, NodeIndexOutlined, PauseCircleOutlined, PlayCircleOutlined, RedoOutlined } from '@ant-design/icons'; import { useIntl } from '@umijs/max'; -import { Alert, App, Button, Card } from 'antd'; +import { Alert, App, Button, Card, Segmented } from 'antd'; import * as Cesium from 'cesium'; import { useCallback, useEffect, useRef, useState } from 'react'; -const SAMPLE_METERS = 1000; -const MAX_SAMPLES = 2000; - -type Envelope = { longitude: number; latitude: number }[]; - -const fetchJson = async (path: string) => { - const response = await fetch(`${PUBLIC_PATH}${path}`); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return (await response.json()) as T; -}; - -const toPositions = (points: Point[]) => - Cesium.Cartesian3.fromDegreesArray(points.flatMap((p) => [p.longitude, p.latitude])); - +/** + * 巡检任务页:飞机沿任务线循环飞行(SampledPositionProperty 回放), + * 身后拖出一条**随进度生长的覆盖走廊**(corridor 的 positions 是 CallbackProperty), + * 沿途按覆盖质量铺采样点(绿/黄/红)。也支持手绘自定义巡检线后同样的回放。 + * 数据层见 patrolModel.ts(程序生成,不依赖静态 JSON)。 + */ export default function Trajectory() { const intl = useIntl(); const t = (id: string, values?: Record) => intl.formatMessage({ id }, values); const { message } = App.useApp(); const [drawing, setDrawing] = useState(false); - const [result, setResult] = useState(); - const [polygonCount, setPolygonCount] = useState(214); - const waypointsRef = useRef([]); + const [playing, setPlaying] = useState(true); + const [speed, setSpeed] = useState(2); + const [hasPlayback, setHasPlayback] = useState(false); const drawingRef = useRef(false); + const waypointsRef = useRef<{ longitude: number; latitude: number }[]>([]); const layerRef = useRef(null); - const samplesRef = useRef(null); + const samplesRef = useRef([]); + const positionsRef = useRef([]); + const dotsRef = useRef(null); + const dotsDoneRef = useRef(0); + const startRef = useRef(null); const { containerRef, viewer, error, basemapFailed, retry } = useCesiumViewer({ - // 初始视角对准三种能力包络(进入页面即自动合并展示);覆盖多边形在东北,点按钮时再飞过去 - home: [116.52, 30.39, 1_200_000], + home: [116.65, 30.5, 260_000], + entry: { pitch: Cesium.Math.toRadians(-38) }, onReady: (instance) => { - const layer = new Cesium.CustomDataSource('routes'); + const layer = new Cesium.CustomDataSource('patrol'); instance.dataSources.add(layer); layerRef.current = layer; - // 画线模式下每次点击追加一个途经点;实时线段用 CallbackProperty 跟随数组 + // 画线模式下点击追加航路点;实时连线用 CallbackProperty 跟随数组 const handler = new Cesium.ScreenSpaceEventHandler(instance.scene.canvas); handler.setInputAction((click: { position: Cesium.Cartesian2 }) => { if (!drawingRef.current) return; @@ -58,119 +55,181 @@ export default function Trajectory() { }, }); - const clear = useCallback(() => { + /** 回放推进:覆盖走廊 positions 只取当前时刻之前的采样(生长效果),沿途铺质量采样点 */ + useEffect(() => { + if (!viewer || viewer.isDestroyed()) return undefined; + const onTick = () => { + const start = startRef.current; + if (!start || !samplesRef.current.length) return; + const elapsed = Cesium.JulianDate.secondsDifference(viewer.clock.currentTime, start); + const progress = Math.min( + samplesRef.current.length, + Math.max(2, Math.floor(elapsed * SAMPLES_PER_SECOND * speed)), + ); + positionsRef.current = positionsRef.current.slice(0, progress); + const dots = dotsRef.current; + if (dots) { + while (dotsDoneRef.current < progress) { + const sample = samplesRef.current[dotsDoneRef.current]; + if (dotsDoneRef.current % 5 === 0) { + dots.add({ + position: Cesium.Cartesian3.fromDegrees(sample.lng, sample.lat), + pixelSize: 5, + color: Cesium.Color.fromCssColorString(qualityColor(sample.quality)).withAlpha(0.9), + }); + } + dotsDoneRef.current += 1; + } + } + }; + viewer.clock.onTick.addEventListener(onTick); + return () => { + if (!viewer.isDestroyed()) viewer.clock.onTick.removeEventListener(onTick); + }; + }, [viewer, speed]); + + useEffect(() => { + if (viewer) viewer.clock.shouldAnimate = playing; + }, [viewer, playing]); + + useEffect(() => { + if (viewer) viewer.clock.multiplier = speed; + }, [viewer, speed]); + + /** 搭建一段回放:时钟 + 巡检机 + 生长走廊 + 质量采样点容器 */ + const playRoute = useCallback( + (samples: PatrolSample[]) => { + const instance = viewer!; + samplesRef.current = samples; + positionsRef.current = samples.map((p) => Cesium.Cartesian3.fromDegrees(p.lng, p.lat)); + dotsDoneRef.current = 0; + if (dotsRef.current) instance.scene.primitives.remove(dotsRef.current); + dotsRef.current = instance.scene.primitives.add(new Cesium.PointPrimitiveCollection()); + const start = Cesium.JulianDate.now(); + startRef.current = start.clone(); + const total = samples.length / SAMPLES_PER_SECOND; + Object.assign(instance.clock, { + startTime: start.clone(), + stopTime: Cesium.JulianDate.addSeconds(start, total, new Cesium.JulianDate()), + currentTime: start.clone(), + clockRange: Cesium.ClockRange.LOOP_STOP, + shouldAnimate: playing, + multiplier: speed, + }); + const position = new Cesium.SampledPositionProperty(); + samples.forEach((p, i) => + position.addSample( + Cesium.JulianDate.addSeconds(start, i / SAMPLES_PER_SECOND, new Cesium.JulianDate()), + Cesium.Cartesian3.fromDegrees(p.lng, p.lat, p.alt), + ), + ); + instance.entities.add({ + position, + billboard: { image: targetIcon('aircraft', '#ffd666'), width: 30, height: 30 }, + path: { leadTime: 0, trailTime: 25, width: 3, material: glowMaterial('#fadb14', 0.9) }, + }); + // 覆盖走廊:positions 只取当前进度以内的采样(onTick 里截断 positionsRef) + instance.entities.add({ + corridor: { + positions: new Cesium.CallbackProperty(() => positionsRef.current, false), + width: 2600, + cornerType: Cesium.CornerType.ROUNDED, + material: glowMaterial('#36cfc9', 0.55), + }, + }); + setHasPlayback(true); + instance.camera.flyTo({ + destination: Cesium.Cartesian3.fromDegrees(samples[0].lng, samples[0].lat - 0.28, 120_000), + orientation: { heading: 0, pitch: Cesium.Math.toRadians(-38), roll: 0 }, + duration: 1.8, + }); + }, + [viewer, playing, speed], + ); + + // 进页自动回放内置巡检任务(程序生成,确定性) + const autoPlayed = useRef(false); + useEffect(() => { + if (!viewer || autoPlayed.current) return; + autoPlayed.current = true; + playRoute(buildPatrolRoute()); + }, [playRoute, viewer]); + + const clearPlayback = useCallback(() => { + if (viewer && !viewer.isDestroyed()) { + viewer.entities.removeAll(); + if (dotsRef.current) viewer.scene.primitives.remove(dotsRef.current); + } + dotsRef.current = null; layerRef.current?.entities.removeAll(); - if (viewer && samplesRef.current) viewer.scene.primitives.remove(samplesRef.current); - samplesRef.current = null; - waypointsRef.current = []; - setResult(undefined); + samplesRef.current = []; + positionsRef.current = []; + startRef.current = null; + setHasPlayback(false); }, [viewer]); const startDrawing = () => { - clear(); + clearPlayback(); drawingRef.current = true; setDrawing(true); (viewer!.container as HTMLElement).style.cursor = 'crosshair'; layerRef.current!.entities.add({ polyline: { - positions: new Cesium.CallbackProperty(() => toPositions(waypointsRef.current), false), + positions: new Cesium.CallbackProperty( + () => Cesium.Cartesian3.fromDegreesArray(waypointsRef.current.flatMap((p) => [p.longitude, p.latitude])), + false, + ), width: 3, - material: Cesium.Color.YELLOW, + material: glowMaterial('#fadb14', 0.9), clampToGround: true, }, }); }; - /** 完成:按每公里插值生成采样点(巡检时每个点对应一次测量) */ const finishDrawing = () => { drawingRef.current = false; setDrawing(false); (viewer!.container as HTMLElement).style.cursor = ''; const waypoints = waypointsRef.current; - if (waypoints.length < 2) return; - const samples = handlerComputePoint(waypoints, SAMPLE_METERS); - if (samples.length > MAX_SAMPLES) { - message.warning(t('cesium.route.tooLong', { max: MAX_SAMPLES })); + if (waypoints.length < 2) { + message.warning(t('cesium.route.tooShort')); return; } - const points = viewer!.scene.primitives.add( - new Cesium.PointPrimitiveCollection(), - ) as Cesium.PointPrimitiveCollection; - samples.forEach((p) => - points.add({ - position: Cesium.Cartesian3.fromDegrees(p.longitude, p.latitude), - pixelSize: 4, - color: Cesium.Color.CYAN, - }), - ); - samplesRef.current = points; - const length = waypoints - .slice(1) - .reduce( - (sum, p, i) => sum + distanceKm([waypoints[i].longitude, waypoints[i].latitude], [p.longitude, p.latitude]), - 0, - ); - setResult(t('cesium.route.result', { length: length.toFixed(1), count: samples.length })); + layerRef.current?.entities.removeAll(); + playRoute(sampleRoute(waypoints)); }; - /** 三种能力包络的并集(turf union) */ - const mergeEnvelopes = useCallback(async () => { - if (!viewer) return; - clear(); - const data = - await fetchJson>('data/cesium/envelopes.json'); - const polygons = [data.intercept, data.location, data.demodulation].map((ring) => - ring.map((p) => ({ longitude: p.longitude, latitude: p.latitude })), - ); - polygons.forEach((ring) => - layerRef.current!.entities.add({ - polygon: { - hierarchy: toPositions(ring), - material: Cesium.Color.WHITE.withAlpha(0.1), - outline: true, - outlineColor: Cesium.Color.WHITE, - }, - }), - ); - mergePolygons(polygons).forEach((ring) => - layerRef.current!.entities.add({ - polygon: { hierarchy: toPositions(ring), material: Cesium.Color.GOLD.withAlpha(0.35), height: 20_000 }, - }), - ); - if (!viewer.isDestroyed()) void viewer.flyTo(layerRef.current!, { duration: 1.2 }); - }, [clear, viewer]); - - // 进入页面即展示三种包络的合并结果(数据约 20 KB);画路线、覆盖合并按需点按钮 - const autoDrawn = useRef(false); - useEffect(() => { - if (!viewer || autoDrawn.current) return; - autoDrawn.current = true; - void mergeEnvelopes(); - }, [mergeEnvelopes, viewer]); - - /** 200+ 个覆盖多边形(东北一次路测的分片覆盖):先取凸包再合并成一个多边形,或逐个显示对比 */ - const drawCoverage = async (merge: boolean) => { - clear(); - const polygons = await fetchJson('data/cesium/coverage-polygons.json'); - setPolygonCount(polygons.length); - if (merge) { - layerRef.current!.entities.add({ - polygon: { hierarchy: toPositions(mergePolygonsPath(polygons)), material: Cesium.Color.RED.withAlpha(0.4) }, - }); - } else { - polygons.forEach((ring, index) => - layerRef.current!.entities.add({ - polygon: { hierarchy: toPositions(ring), material: Cesium.Color.RED.withAlpha(0.15), height: index * 100 }, - }), - ); + const replay = () => { + if (viewer && startRef.current) { + positionsRef.current = []; + dotsDoneRef.current = 0; + if (dotsRef.current) dotsRef.current.removeAll(); + viewer.clock.currentTime = startRef.current.clone(); } - viewer!.flyTo(layerRef.current!, { duration: 1.2 }); }; + const stations = BASE_STATIONS.map(([lng, lat]) => ({ lng, lat })); + const legend = ( + +
+ {[ + ['#52c41a', 'cesium.route.good'], + ['#fadb14', 'cesium.route.fair'], + ['#f5222d', 'cesium.route.poor'], + ].map(([color, key]) => ( + + + {t(key)} + + ))} +
+
+ ); + return ( {drawing ? ( @@ -178,22 +237,34 @@ export default function Trajectory() { {t('cesium.route.finish')} ) : ( - )} - - - - + {!drawing && ( + setSpeed(value as number)} + options={[ + { value: 1, label: '×1' }, + { value: 2, label: '×2' }, + { value: 4, label: '×4' }, + ]} + /> + )} } > @@ -201,7 +272,15 @@ export default function Trajectory() { type={drawing ? 'warning' : 'info'} showIcon className="mb-4" - title={drawing ? t('cesium.route.drawing') : (result ?? t('cesium.route.hint'))} + title={ + drawing + ? t('cesium.route.drawing') + : t('cesium.route.summary', { + count: samplesRef.current.length, + good: samplesRef.current.filter((s) => s.quality > 0.66).length, + stations: stations.length, + }) + } /> From 5d21aed41095626cdb4da5b6919742d85f71879a Mon Sep 17 00:00:00 2001 From: Jerry Date: Sun, 18 Oct 2026 11:16:09 +0800 Subject: [PATCH 2/3] refactor(cesium): geohash auto-aggregation columns and situation target tracking GeoHash aggregates on entry and recalculates on precision change with extruded columns encoding density; Situation gains billboard targets with heading, sea wall, target lock via tracked entity and a following scan ring. --- src/locales/en-US/map.ts | 50 +++++++----- src/locales/zh-CN/map.ts | 49 +++++++----- src/pages/Map/Cesium/GeoHash.tsx | 46 +++++++++-- src/pages/Map/Cesium/Situation.tsx | 119 ++++++++++++++++++++++++----- 4 files changed, 206 insertions(+), 58 deletions(-) diff --git a/src/locales/en-US/map.ts b/src/locales/en-US/map.ts index 7788df3..58ed68c 100644 --- a/src/locales/en-US/map.ts +++ b/src/locales/en-US/map.ts @@ -19,34 +19,44 @@ export default { 'cesium.geohash.cells': '{cells} cells; the busiest has {max} stations', 'cesium.geohash.cell': 'Cell {hash}: {count} stations', 'cesium.geohash.centerResult': 'Center {lng}, {lat} → {hash}', + 'cesium.geohash.viewTop': 'Top-down', + 'cesium.geohash.viewTilt': 'Tilted', 'cesium.geohash.hint': 'GeoHash encodes a location as a string; shared prefixes mean nearby cells. Precision 3 ≈ 156 km cells, 4 ≈ 39 km.', - 'cesium.direction.measured': 'Measured envelopes (lon/lat)', - 'cesium.direction.computed': 'Computed from bearing & range', - 'cesium.direction.circle': '100 km circle', + 'cesium.direction.modeDomes': 'Capability domes', + 'cesium.direction.modeSweep': 'DF sweep', + 'cesium.direction.modeBoth': 'Combined', + 'cesium.direction.play': 'Play', + 'cesium.direction.pause': 'Pause', + 'cesium.direction.station': 'Monitoring station', + 'cesium.direction.detected': 'intercepted', 'cesium.direction.intercept': 'Intercept', 'cesium.direction.location': 'Location', 'cesium.direction.demodulation': 'Demodulation', 'cesium.direction.stats': '{name}: max {max} km, min {min} km, avg {avg} km', + 'cesium.direction.target': '{name} · orbit radius {radius} km · ground speed ≈ {speed} km/h', 'cesium.direction.hint': - 'Intercept, location and demodulation ranges of a field site by bearing. The measured envelopes should match the ones computed with the great-circle formula (dashed).', - 'cesium.heatmap.texture': 'Heat map', - 'cesium.heatmap.points': 'Point cloud', - 'cesium.heatmap.loading': 'Loading field-strength data (about 1.6 MB)…', - 'cesium.heatmap.tooltip': 'Field strength {value} dBμV/m', + 'Computed live from an antenna pattern: range = base × directional lobes × terrain shadowing × slowly drifting propagation (the domes breathe). Orbiting targets are checked against the intercept envelope in real time; click one for details.', + 'cesium.heatmap.modeHeat': 'Dynamic heat', + 'cesium.heatmap.modeShells': 'Coverage shells', + 'cesium.heatmap.modeBoth': 'Combined', + 'cesium.heatmap.play': 'Play', + 'cesium.heatmap.pause': 'Pause', 'cesium.heatmap.legend': 'Field strength (dBμV/m)', 'cesium.heatmap.hint': - 'A coverage test in central Chongqing: the texture shows the overall picture, the point cloud lets you hover each sample.', - 'cesium.route.draw': 'Plan an inspection route', + 'A propagation model of four transmitters, computed live: multipath gains drift slowly so the heat "breathes"; the shells are 60/45 dB iso-strength coverage volumes with model-solved radii.', + 'cesium.route.draw': 'Draw a route', 'cesium.route.finish': 'Finish', - 'cesium.route.drawing': 'Click waypoints on the map; finishing samples a point every kilometer', - 'cesium.route.result': 'Route is {length} km long with {count} sample points', - 'cesium.route.tooLong': 'The route is too long (more than {max} samples). Please shorten it', - 'cesium.route.merge': 'Merge the three envelopes', - 'cesium.route.mergeMany': 'Merge {count} coverage polygons', - 'cesium.route.showMany': 'Show one by one (no merge)', - 'cesium.route.hint': - 'Coverage merging uses turf polygon union; many polygons are reduced to a convex hull first, turning 200+ entities into one.', + 'cesium.route.play': 'Play', + 'cesium.route.pause': 'Pause', + 'cesium.route.replay': 'Replay', + 'cesium.route.drawing': 'Click waypoints on the map; finishing replays them through the same pipeline', + 'cesium.route.tooShort': 'At least 2 waypoints are required', + 'cesium.route.summary': + 'Current mission has {count} samples, {good} with good coverage; quality references {stations} ground stations along the line', + 'cesium.route.good': 'Good coverage', + 'cesium.route.fair': 'Fair coverage', + 'cesium.route.poor': 'Poor coverage', 'cesium.select.box': 'Box select stations', 'cesium.select.measure': 'Measure distance', 'cesium.select.boxHint': 'Click twice on the map to set two opposite corners', @@ -55,6 +65,10 @@ export default { 'cesium.select.distance': 'Straight-line distance {distance} km', 'cesium.situation.speed': 'Speed', 'cesium.situation.trails': 'Show tracks', + 'cesium.situation.lock': 'Lock target', + 'cesium.situation.unlock': 'Release', + 'cesium.situation.play': 'Play', + 'cesium.situation.pause': 'Pause', 'cesium.situation.ship': 'Ship', 'cesium.situation.aircraft': 'Aircraft', 'cesium.situation.area': 'Area of interest', diff --git a/src/locales/zh-CN/map.ts b/src/locales/zh-CN/map.ts index 70f5c1b..4ec23b1 100644 --- a/src/locales/zh-CN/map.ts +++ b/src/locales/zh-CN/map.ts @@ -19,31 +19,42 @@ export default { 'cesium.geohash.cells': '{cells} 个网格,最多的网格有 {max} 个站点', 'cesium.geohash.cell': '网格 {hash}:{count} 个站点', 'cesium.geohash.centerResult': '中心点 {lng}, {lat} → {hash}', + 'cesium.geohash.viewTop': '俯视', + 'cesium.geohash.viewTilt': '侧视', 'cesium.geohash.hint': 'GeoHash 把经纬度编码成字符串,前缀相同即地理相邻:精度 3 约 156 km 网格,精度 4 约 39 km。', - 'cesium.direction.measured': '实测包络(经纬度)', - 'cesium.direction.computed': '按方位与距离计算', - 'cesium.direction.circle': '100 km 等距圈', + 'cesium.direction.modeDomes': '能力穹顶', + 'cesium.direction.modeSweep': '测向波束', + 'cesium.direction.modeBoth': '叠加', + 'cesium.direction.play': '播放', + 'cesium.direction.pause': '暂停', + 'cesium.direction.station': '监测站', + 'cesium.direction.detected': '已截获', 'cesium.direction.intercept': '截获', 'cesium.direction.location': '定位', 'cesium.direction.demodulation': '解调', 'cesium.direction.stats': '{name}:最远 {max} km,最近 {min} km,平均 {avg} km', + 'cesium.direction.target': '{name} · 轨道半径 {radius} km · 地速约 {speed} km/h', 'cesium.direction.hint': - '外场监测点对各方位的截获、定位、解调距离。实测包络与按「方位 + 距离」大圆公式算出的包络应当重合(虚线)。', - 'cesium.heatmap.texture': '场强热力图', - 'cesium.heatmap.points': '场强点云', - 'cesium.heatmap.loading': '正在加载场强数据(约 1.6 MB)…', - 'cesium.heatmap.tooltip': '场强 {value} dBμV/m', + '天线方向图实时计算:能力距离 = 基距 × 方向性瓣 × 地形遮蔽 × 缓变的传播条件(穹顶会呼吸);绕站目标实时判定是否处于截获包络内,点击查看详情。', + 'cesium.heatmap.modeHeat': '动态热力', + 'cesium.heatmap.modeShells': '覆盖壳层', + 'cesium.heatmap.modeBoth': '叠加', + 'cesium.heatmap.play': '播放', + 'cesium.heatmap.pause': '暂停', 'cesium.heatmap.legend': '场强(dBμV/m)', - 'cesium.heatmap.hint': '重庆城区一次覆盖测试:纹理贴图适合整体观察,点云可悬停读取每个采样点。', - 'cesium.route.draw': '规划巡检路线', + 'cesium.heatmap.hint': + '四台发射机的传播模型实时计算:多径增益随时间缓变,热力随之「呼吸」;壳层是 60/45 dB 等场强覆盖体,半径由模型反解。', + 'cesium.route.draw': '手绘巡检线', 'cesium.route.finish': '完成', - 'cesium.route.drawing': '在地图上依次点击途经点,完成后按每公里生成采样点', - 'cesium.route.result': '路线全长 {length} km,生成 {count} 个采样点', - 'cesium.route.tooLong': '路线过长(超过 {max} 个采样点),请缩短', - 'cesium.route.merge': '合并三种能力包络', - 'cesium.route.mergeMany': '合并 {count} 个覆盖多边形', - 'cesium.route.showMany': '逐个显示(不合并)', - 'cesium.route.hint': '覆盖合并使用 turf 做多边形并集;大量多边形先取凸包再合并,渲染从 200+ 个实体降到 1 个。', + 'cesium.route.play': '播放', + 'cesium.route.pause': '暂停', + 'cesium.route.replay': '重播', + 'cesium.route.drawing': '在地图上依次点击航路点,完成后按同一条流水线回放', + 'cesium.route.tooShort': '至少需要 2 个航路点', + 'cesium.route.summary': '当前任务 {count} 个采样点,其中覆盖良好 {good} 个;质量参照沿线 {stations} 个地面基站', + 'cesium.route.good': '覆盖良好', + 'cesium.route.fair': '覆盖一般', + 'cesium.route.poor': '覆盖较差', 'cesium.select.box': '框选站点', 'cesium.select.measure': '测量距离', 'cesium.select.boxHint': '在地图上点击两次确定矩形的两个对角', @@ -52,6 +63,10 @@ export default { 'cesium.select.distance': '直线距离 {distance} km', 'cesium.situation.speed': '播放速度', 'cesium.situation.trails': '显示航迹', + 'cesium.situation.lock': '锁定目标', + 'cesium.situation.unlock': '解锁', + 'cesium.situation.play': '播放', + 'cesium.situation.pause': '暂停', 'cesium.situation.ship': '船舶', 'cesium.situation.aircraft': '航空器', 'cesium.situation.area': '关注海域', diff --git a/src/pages/Map/Cesium/GeoHash.tsx b/src/pages/Map/Cesium/GeoHash.tsx index 0de0583..6293e10 100644 --- a/src/pages/Map/Cesium/GeoHash.tsx +++ b/src/pages/Map/Cesium/GeoHash.tsx @@ -6,14 +6,17 @@ import { listAllDevices } from '@/services/ops'; import { centerGeoHash, geohashBounds } from '@/utils/MapCompute/geoHash'; import { AimOutlined, AppstoreOutlined, ClearOutlined } from '@ant-design/icons'; import { useIntl } from '@umijs/max'; -import { Alert, Button, Card, InputNumber, Listy, Space, Tag, Typography } from 'antd'; +import { Alert, Button, Card, InputNumber, Listy, Segmented, Space, Tag, Typography } from 'antd'; import * as Cesium from 'cesium'; import { useEffect, useRef, useState } from 'react'; type Cell = { hash: string; count: number }; -/** 计数 → 颜色:由浅蓝到深红,站点越多越「热」 */ -const cellColor = (ratio: number) => Cesium.Color.fromHsl(0.6 - 0.6 * ratio, 0.85, 0.5, 0.25 + 0.45 * ratio); +/** 计数 → 颜色:由浅蓝到深红,站点越多越「热」(深蓝星球底上要压得住,饱和度与下限 alpha 都偏高) */ +const cellColor = (ratio: number) => Cesium.Color.fromHsl(0.6 - 0.6 * ratio, 0.9, 0.56, 0.4 + 0.45 * ratio); + +/** 计数 → 挤出高度:把「格子里的站点数」变成看得见的立体柱(数据长在 3D 上) */ +const cellHeight = (ratio: number) => 12_000 + ratio * 320_000; export default function GeoHashPage() { const intl = useIntl(); @@ -23,12 +26,13 @@ export default function GeoHashPage() { const [precision, setPrecision] = useState(3); const [cells, setCells] = useState([]); const [center, setCenter] = useState(); + const [tilted, setTilted] = useState(false); const layerRef = useRef(null); // 站点点位:设备数据与 viewer 都就绪后画一次 useEffect(() => { if (!viewer || !devices) return undefined; - const points = addStationPoints(viewer, devices, 5); + const points = addStationPoints(viewer, devices, 7); const layer = new Cesium.CustomDataSource('geohash'); viewer.dataSources.add(layer); layerRef.current = layer; @@ -39,13 +43,15 @@ export default function GeoHashPage() { }; }, [viewer, devices]); - const drawCell = (hash: string, color: Cesium.Color, label?: string) => { + const drawCell = (hash: string, color: Cesium.Color, label?: string, ratio?: number) => { const b = geohashBounds(hash); if (!b) return undefined; return layerRef.current?.entities.add({ rectangle: { coordinates: Cesium.Rectangle.fromDegrees(b.longitudeMin, b.latitudeMin, b.longitudeMax, b.latitudeMax), material: color, + // 有占比就挤出成 3D 柱:高度即数据(中心格定位那类临时高亮仍是贴地面) + ...(ratio !== undefined ? { height: 0, extrudedHeight: cellHeight(ratio) } : {}), outline: true, outlineColor: Cesium.Color.WHITE.withAlpha(0.6), }, @@ -69,11 +75,31 @@ export default function GeoHashPage() { }); const list = [...counts.entries()].map(([hash, count]) => ({ hash, count })).sort((a, b) => b.count - a.count); const max = list[0]?.count ?? 1; - list.forEach((cell) => drawCell(cell.hash, cellColor(cell.count / max), String(cell.count))); + list.forEach((cell) => drawCell(cell.hash, cellColor(cell.count / max), String(cell.count), cell.count / max)); setCells(list); setCenter(undefined); }; + // 进页自动聚合、精度变化自动重算:演示一进来就是「密度立方」,不用先找按钮 + useEffect(() => { + if (!viewer || !devices || !layerRef.current) return; + aggregate(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [viewer, devices, precision]); + + /** 俯视 ↔ 侧视:侧视才能看出挤出高度的数据差异,这是这页的「定妆镜头」 */ + const flyView = (next: boolean) => { + setTilted(next); + if (!viewer) return; + viewer.camera.flyTo({ + destination: next + ? Cesium.Cartesian3.fromDegrees(104, 24.5, 3_400_000) + : Cesium.Cartesian3.fromDegrees(108, 35, 7_000_000), + orientation: { heading: 0, pitch: Cesium.Math.toRadians(next ? -36 : -90), roll: 0 }, + duration: 1.8, + }); + }; + const locateCenter = () => { if (!viewer || !layerRef.current) return; const rect = viewer.camera.computeViewRectangle(); @@ -99,6 +125,14 @@ export default function GeoHashPage() { source="src/pages/Map/Cesium/GeoHash.tsx" extra={ <> + flyView(value === 'tilt')} + options={[ + { value: 'top', label: t('cesium.geohash.viewTop') }, + { value: 'tilt', label: t('cesium.geohash.viewTilt') }, + ]} + /> v && setPrecision(v)} className="w-16" /> diff --git a/src/pages/Map/Cesium/Situation.tsx b/src/pages/Map/Cesium/Situation.tsx index 2997fa0..61398b2 100644 --- a/src/pages/Map/Cesium/Situation.tsx +++ b/src/pages/Map/Cesium/Situation.tsx @@ -1,8 +1,10 @@ import { CesiumStage, useCesiumViewer } from '@/components/CesiumViewer'; +import { scanRingMaterial, targetIcon } from '@/components/CesiumViewer/effects'; import DemoPage from '@/components/DemoPage'; import { distanceKm } from '@/utils/MapCompute/geodesy'; +import { PauseCircleOutlined, PlayCircleOutlined, UnlockOutlined } from '@ant-design/icons'; import { useIntl } from '@umijs/max'; -import { Alert, Card, Segmented, Space, Switch, Typography } from 'antd'; +import { Alert, Button, Card, Segmented, Select, Space, Switch, Typography } from 'antd'; import * as Cesium from 'cesium'; import CesiumNavigation from 'cesium-navigation-es6'; import { useEffect, useRef, useState } from 'react'; @@ -110,6 +112,7 @@ const KIND_COLOR: Record = { ship: Cesium.Color.fromCssColorString('#13c2c2'), aircraft: Cesium.Color.fromCssColorString('#fa8c16'), }; +const KIND_CSS: Record = { ship: '#13c2c2', aircraft: '#fa8c16' }; /** 按航速把航线点换成时间样本:往返运动,时长覆盖整个演示时段 */ const buildPosition = (target: Target, start: Cesium.JulianDate, totalSeconds: number) => { @@ -132,22 +135,39 @@ const buildPosition = (target: Target, start: Cesium.JulianDate, totalSeconds: n const DURATION_SECONDS = 12 * 3600; +/** 由位置属性求航向(弧度,顺时针自北),转成 billboard 的 rotation(逆时针)——图标始终指着前进方向 */ +const headingRotation = (viewer: Cesium.Viewer, position: Cesium.PositionProperty, sampleSeconds = 180) => + new Cesium.CallbackProperty(() => { + const now = viewer.clock.currentTime; + const p1 = position.getValue(now); + const p2 = position.getValue(Cesium.JulianDate.addSeconds(now, sampleSeconds, new Cesium.JulianDate())); + if (!p1 || !p2) return 0; + const c1 = viewer.scene.globe.ellipsoid.cartesianToCartographic(p1); + const c2 = viewer.scene.globe.ellipsoid.cartesianToCartographic(p2); + const heading = Math.atan2( + (c2.longitude - c1.longitude) * Math.cos((c1.latitude + c2.latitude) / 2), + c2.latitude - c1.latitude, + ); + return -heading; + }, false); + export default function Situation() { const intl = useIntl(); const t = (id: string, values?: Record) => intl.formatMessage({ id }, values); const [speed, setSpeed] = useState(120); const [trails, setTrails] = useState(true); + const [playing, setPlaying] = useState(true); const [picked, setPicked] = useState(); + const [locked, setLocked] = useState(); const entitiesRef = useRef([]); + const scanRef = useRef(undefined); const { containerRef, viewer, error, basemapFailed, retry } = useCesiumViewer({ - home: [124.4, 29.2, 1_300_000], + // 定妆镜头:黄海南部斜视角,入场从高空推近 + home: [124.4, 27.6, 1_100_000], + entry: { pitch: Cesium.Math.toRadians(-42) }, onReady: (instance) => { new CesiumNavigation(instance, { enableCompass: true, enableZoomControls: true, enableDistanceLegend: true }); - instance.camera.setView({ - destination: Cesium.Cartesian3.fromDegrees(124.4, 26.8, 900_000), - orientation: { heading: 0, pitch: Cesium.Math.toRadians(-45), roll: 0 }, - }); const start = Cesium.JulianDate.now(); const stop = Cesium.JulianDate.addSeconds(start, DURATION_SECONDS, new Cesium.JulianDate()); Object.assign(instance.clock, { @@ -158,6 +178,7 @@ export default function Situation() { shouldAnimate: true, }); + // 关注海域:贴地面 + 立起 80km 的半透明围栏,把「区域感」从平面变成立体 instance.entities.add({ polygon: { hierarchy: Cesium.Cartesian3.fromDegreesArray(AREA.flat()), @@ -166,21 +187,33 @@ export default function Situation() { outlineColor: Cesium.Color.DODGERBLUE, }, }); + instance.entities.add({ + wall: { + positions: Cesium.Cartesian3.fromDegreesArray([...AREA, AREA[0]].flat()), + maximumHeights: [...AREA, AREA[0]].map(() => 80_000), + minimumHeights: [...AREA, AREA[0]].map(() => 0), + material: Cesium.Color.DODGERBLUE.withAlpha(0.1), + }, + }); - entitiesRef.current = TARGETS.map((target) => - instance.entities.add({ + entitiesRef.current = TARGETS.map((target) => { + const position = buildPosition(target, start, DURATION_SECONDS); + return instance.entities.add({ id: target.id, - position: buildPosition(target, start, DURATION_SECONDS), - point: { - pixelSize: target.kind === 'aircraft' ? 11 : 9, - color: KIND_COLOR[target.kind], - outlineColor: Cesium.Color.WHITE, - outlineWidth: 2, + position, + // 剪影图标 + 航向朝向:比 9px 圆点像「被指挥的目标」得多 + billboard: { + image: targetIcon(target.kind, KIND_CSS[target.kind]), + width: target.kind === 'aircraft' ? 30 : 26, + height: target.kind === 'aircraft' ? 30 : 26, + alignedAxis: Cesium.Cartesian3.UNIT_Z, + rotation: headingRotation(instance, position), + verticalOrigin: Cesium.VerticalOrigin.CENTER, }, label: { text: target.id, font: '12px sans-serif', - pixelOffset: new Cesium.Cartesian2(0, -18), + pixelOffset: new Cesium.Cartesian2(0, -24), fillColor: Cesium.Color.WHITE, showBackground: true, backgroundColor: Cesium.Color.BLACK.withAlpha(0.55), @@ -191,8 +224,8 @@ export default function Situation() { width: 2, material: KIND_COLOR[target.kind].withAlpha(0.7), }, - }), - ); + }); + }); const handler = new Cesium.ScreenSpaceEventHandler(instance.scene.canvas); handler.setInputAction((click: { position: Cesium.Cartesian2 }) => { @@ -207,12 +240,40 @@ export default function Situation() { if (viewer) viewer.clock.multiplier = speed; }, [viewer, speed]); + useEffect(() => { + if (viewer) viewer.clock.shouldAnimate = playing; + }, [viewer, playing]); + useEffect(() => { entitiesRef.current.forEach((entity) => { if (entity.path) entity.path.show = new Cesium.ConstantProperty(trails); }); }, [trails, viewer]); + /** 锁定目标:相机切换为跟踪视角,目标脚下出现循环扩张的扫描圈;解锁回到区域俯瞰 */ + useEffect(() => { + if (!viewer || viewer.isDestroyed()) return undefined; + if (scanRef.current) { + viewer.entities.remove(scanRef.current); + scanRef.current = undefined; + } + const entity = entitiesRef.current.find((e) => e.id === locked); + if (!entity) { + viewer.trackedEntity = undefined; + return undefined; + } + viewer.trackedEntity = entity; + const target = TARGETS.find((tg) => tg.id === locked); + const kind = target?.kind ?? 'ship'; + scanRef.current = viewer.entities.add({ + position: new Cesium.CallbackPositionProperty(() => entity.position?.getValue(viewer.clock.currentTime), false), + ellipse: scanRingMaterial(KIND_CSS[kind], kind === 'aircraft' ? 160_000 : 60_000, 3.2), + }); + return () => { + if (scanRef.current && !viewer.isDestroyed()) viewer.entities.remove(scanRef.current); + }; + }, [viewer, locked]); + const kindLabel = (kind: Kind) => t(`cesium.situation.${kind}`); const legend = ( @@ -241,6 +302,30 @@ export default function Situation() { source="src/pages/Map/Cesium/Situation.tsx" extra={ <> +