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/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/Direction.tsx b/src/pages/Map/Cesium/Direction.tsx index cb1a2e8..f1ccd16 100644 --- a/src/pages/Map/Cesium/Direction.tsx +++ b/src/pages/Map/Cesium/Direction.tsx @@ -1,134 +1,268 @@ import { CesiumStage, useCesiumViewer } from '@/components/CesiumViewer'; +import { addScanRing, targetIcon } from '@/components/CesiumViewer/effects'; import DemoPage from '@/components/DemoPage'; -import { useApi } from '@/hooks/useApi'; -import { circle, destination } from '@/utils/MapCompute/geodesy'; -import { AimOutlined, CalculatorOutlined, ClearOutlined, RadarChartOutlined } from '@ant-design/icons'; +import { PauseCircleOutlined, PlayCircleOutlined } from '@ant-design/icons'; import { useIntl } from '@umijs/max'; -import { Alert, Badge, Button, Card, Space } from 'antd'; +import { Alert, Badge, Button, Card, Segmented, Space } from 'antd'; import * as Cesium from 'cesium'; -import { useEffect, useMemo, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { + CAPABILITIES, + CAPABILITY_COLOR, + capabilityStats, + envelopeRing, + isIntercepted, + STATION, + sweepAngle, + targetPosition, + TARGETS, + type SimTarget, +} from './capabilityModel'; -type EnvelopePoint = { azimuth: number; distanceKm: number; longitude: number; latitude: number }; -type Envelopes = { origin: [number, number] } & Record; -type Capability = 'intercept' | 'location' | 'demodulation'; +type Mode = 'domes' | 'sweep' | 'both'; -const CAPABILITIES: Capability[] = ['location', 'intercept', 'demodulation']; -const COLOR: Record = { intercept: '#ff4d4f', location: '#1677ff', demodulation: '#52c41a' }; - -const loadEnvelopes = async (): Promise => { - const response = await fetch(`${PUBLIC_PATH}data/cesium/envelopes.json`); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return (await response.json()) as Envelopes; +const TIME = { elapsed: 0, last: performance.now() }; +const tick = (playing: boolean) => { + const now = performance.now(); + if (playing) TIME.elapsed += (now - TIME.last) / 1000; + TIME.last = now; + return TIME.elapsed; }; -const hierarchy = (points: [number, number][]) => - new Cesium.PolygonHierarchy(Cesium.Cartesian3.fromDegreesArray(points.flat())); - -/** 实测包络:直接用数据里的经纬度连成多边形 */ -const addMeasured = (layer: Cesium.CustomDataSource, data: Envelopes) => - CAPABILITIES.forEach((key) => - layer.entities.add({ - polygon: { - hierarchy: hierarchy(data[key].map((p) => [p.longitude, p.latitude])), - material: Cesium.Color.fromCssColorString(COLOR[key]).withAlpha(0.25), - outline: true, - outlineColor: Cesium.Color.fromCssColorString(COLOR[key]), - }, - }), - ); - +/** + * 监测站能力页:天线方向图模型(capabilityModel.ts)驱动—— + * 三种能力的穹顶随传播条件「呼吸」,测向波束持续旋转扫描, + * 绕站目标实时判定「此刻是否在截获包络内」。数据全部程序生成。 + */ export default function Direction() { const intl = useIntl(); const t = (id: string, values?: Record) => intl.formatMessage({ id }, values); - const { data } = useApi(loadEnvelopes); + const [mode, setMode] = useState('both'); + const [playing, setPlaying] = useState(true); + const [picked, setPicked] = useState(); const layerRef = useRef(null); - const { containerRef, viewer, error, basemapFailed, retry } = useCesiumViewer({ home: [116.52, 30.39, 1_200_000] }); - - useEffect(() => { - if (!viewer || !data) return undefined; - const layer = new Cesium.CustomDataSource('direction'); - viewer.dataSources.add(layer); - layerRef.current = layer; - // 监测点标记 - viewer.entities.add({ - position: Cesium.Cartesian3.fromDegrees(...data.origin), - point: { pixelSize: 10, color: Cesium.Color.ORANGE, outlineColor: Cesium.Color.WHITE, outlineWidth: 2 }, - }); - // 进入页面直接画出实测包络(初始视角已框住),其余两种作为对比按需叠加 - addMeasured(layer, data); - return () => { - if (!viewer.isDestroyed()) viewer.dataSources.remove(layer, true); - }; - }, [viewer, data]); - - const stats = useMemo( - () => - data - ? CAPABILITIES.map((key) => { - const distances = data[key].map((p) => p.distanceKm); - return { - key, - max: Math.max(...distances).toFixed(0), - min: Math.min(...distances).toFixed(0), - avg: (distances.reduce((s, d) => s + d, 0) / distances.length).toFixed(0), - }; - }) - : [], - [data], - ); + const domesRef = useRef([]); + const sweepRef = useRef(null); + const targetsRef = useRef>(new Map()); + // 回放时钟的原点:目标位置的采样与时间推算都基于它 + const epoch = useRef(Cesium.JulianDate.now()).current; - const flyToLayer = () => { - if (viewer && layerRef.current) viewer.flyTo(layerRef.current, { duration: 1.2 }); + // 测向波束用「静态圆形 + 旋转纹理」实现(社区雷达扫描的经典做法): + // 逐帧重建 polygon hierarchy 的动态多边形会产生空批次包围球(NaN),渲染循环停摆 + const sweepCanvas = useRef(null); + const renderSweep = (angle: number) => { + const canvas = sweepCanvas.current; + const ctx = canvas?.getContext('2d'); + if (!canvas || !ctx) return; + const size = canvas.width; + ctx.clearRect(0, 0, size, size); + const center = size / 2; + const radius = center - 2; + // 波束后方 100° 的余辉:越靠近前缘越亮 + const wedges = 40; + for (let i = 0; i < wedges; i++) { + const start = angle - (i + 1) * (100 / wedges); + ctx.beginPath(); + ctx.moveTo(center, center); + ctx.arc(center, center, radius, ((start - 90) * Math.PI) / 180, ((start + 100 / wedges - 90) * Math.PI) / 180); + ctx.closePath(); + const alpha = 0.34 * (1 - i / wedges) ** 1.6; + ctx.fillStyle = `rgba(64, 140, 255, ${alpha.toFixed(3)})`; + ctx.fill(); + } + // 前缘 + ctx.beginPath(); + ctx.moveTo(center, center); + ctx.lineTo( + center + radius * Math.cos(((angle - 90) * Math.PI) / 180), + center + radius * Math.sin(((angle - 90) * Math.PI) / 180), + ); + ctx.strokeStyle = 'rgba(120, 180, 255, 0.9)'; + ctx.lineWidth = 2; + ctx.stroke(); }; - const drawMeasured = () => { - if (!data || !layerRef.current) return; - addMeasured(layerRef.current, data); - flyToLayer(); - }; + const { containerRef, viewer, error, basemapFailed, retry } = useCesiumViewer({ + // 定妆镜头:正俯视(环形对称构图天然适合雷达视角),高度按最大能力环直径(~500km)留边 + home: [STATION.lng, STATION.lat, 780_000], + onReady: (instance) => { + const layer = new Cesium.CustomDataSource('capability'); + instance.dataSources.add(layer); + layerRef.current = layer; - /** 计算包络:只用「方位 + 距离」经大圆公式求终点,画成虚线,应与实测包络重合 */ - const drawComputed = () => { - if (!data || !layerRef.current) return; - CAPABILITIES.forEach((key) => { - const points = data[key].map((p) => destination(...data.origin, p.azimuth, p.distanceKm)); - layerRef.current!.entities.add({ - polyline: { - positions: Cesium.Cartesian3.fromDegreesArray([...points, points[0]].flat()), - width: 2, - material: new Cesium.PolylineDashMaterialProperty({ color: Cesium.Color.fromCssColorString(COLOR[key]) }), - clampToGround: true, + // 监测站:标记 + 心跳扫描 + instance.entities.add({ + position: Cesium.Cartesian3.fromDegrees(STATION.lng, STATION.lat), + point: { pixelSize: 10, color: Cesium.Color.ORANGE, outlineColor: Cesium.Color.WHITE, outlineWidth: 2 }, + label: { + text: t('cesium.direction.station'), + font: '12px sans-serif', + pixelOffset: new Cesium.Cartesian2(0, -18), + fillColor: Cesium.Color.WHITE, + showBackground: true, + backgroundColor: Cesium.Color.BLACK.withAlpha(0.55), + }, + }); + addScanRing(instance, { lon: STATION.lng, lat: STATION.lat, color: '#fa8c16', maxRadius: 280_000, duration: 4 }); + + // 测向波束:静态圆几何 + canvas 余辉纹理(100ms 重绘) + const canvas = document.createElement('canvas'); + canvas.width = 256; + canvas.height = 256; + sweepCanvas.current = canvas; + renderSweep(0); + sweepRef.current = layer.entities.add({ + position: Cesium.Cartesian3.fromDegrees(STATION.lng, STATION.lat), + ellipse: { + semiMajorAxis: 240_000, + semiMinorAxis: 240_000, + material: new Cesium.ImageMaterialProperty({ image: canvas, transparent: true }), + }, + }); + + // 绕站目标:位置用 SampledPositionProperty 预展开 20 分钟循环(与 Situation 同模式); + // 「是否被截获」在 onTick 里翻转样式 + const loop = 20 * 60; + instance.clock.shouldAnimate = true; + TARGETS.forEach((target) => { + const position = new Cesium.SampledPositionProperty(); + for (let s = 0; s <= loop; s += 10) { + const p = targetPosition(target, s); + position.addSample( + Cesium.JulianDate.addSeconds(epoch, s, new Cesium.JulianDate()), + Cesium.Cartesian3.fromDegrees(p.longitude, p.latitude, p.altitude), + ); + } + const entity = layer.entities.add({ + id: target.id, + position, + billboard: { + image: targetIcon(target.kind, '#8c8c8c'), + width: target.kind === 'aircraft' ? 28 : 24, + height: target.kind === 'aircraft' ? 28 : 24, + color: Cesium.Color.fromCssColorString('#8c8c8c'), + }, + label: { + text: target.id, + font: '11px sans-serif', + pixelOffset: new Cesium.Cartesian2(0, -22), + fillColor: Cesium.Color.WHITE.withAlpha(0.75), + showBackground: true, + backgroundColor: Cesium.Color.BLACK.withAlpha(0.45), + }, + }); + targetsRef.current.set(target.id, entity); + }); + + // 点击目标查看详情 + const handler = new Cesium.ScreenSpaceEventHandler(instance.scene.canvas); + handler.setInputAction((click: { position: Cesium.Cartesian2 }) => { + const entity = instance.scene.pick(click.position)?.id as Cesium.Entity | undefined; + setPicked(TARGETS.find((target) => target.id === entity?.id)); + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + return () => { + handler.destroy(); + if (!instance.isDestroyed()) instance.dataSources.remove(layer, true); + }; + }, + }); + + // 波束旋转:100ms 重绘纹理并换材质;暂停即冻结 + useEffect(() => { + if (!viewer || !sweepCanvas.current || !sweepRef.current?.ellipse) return undefined; + const canvas = sweepCanvas.current; + const ellipse = sweepRef.current.ellipse; + const timer = window.setInterval(() => { + renderSweep(((sweepAngle(TIME.elapsed) * 180) / Math.PI) % 360); + ellipse.material = new Cesium.ImageMaterialProperty({ image: canvas, transparent: true }); + }, 100); + return () => window.clearInterval(timer); + }, [viewer]); + + /** 能力环(平面):贴地多边形承载方位差异(方向瓣 + 地形阴影)。 + * 本页刻意不用任何挤出/墙体高度:实测该引擎版本的大尺寸挤出几何(polygon/ellipse 挤出、 + * 密环变高墙)在几何 worker 里非确定性崩溃(splitLongitude 属性数不匹配 / NaN 包围球 / + * 法线 NaN),渲染循环停摆;挤出数据可视化由 GeoHash 的挤出矩形(稳定)承担 */ + useEffect(() => { + if (!viewer || !layerRef.current) return undefined; + const layer = layerRef.current!; + domesRef.current = CAPABILITIES.map((capability) => { + const ring = [...envelopeRing(capability, 0), ...envelopeRing(capability, 0).slice(0, 1)]; + return layer.entities.add({ + polygon: { + hierarchy: Cesium.Cartesian3.fromDegreesArray(ring.map((p) => [p.longitude, p.latitude]).flat()), + material: Cesium.Color.fromCssColorString(CAPABILITY_COLOR[capability]).withAlpha(0.12), + outline: true, + outlineColor: Cesium.Color.fromCssColorString(CAPABILITY_COLOR[capability]).withAlpha(0.85), }, }); }); - flyToLayer(); - }; + return () => { + domesRef.current.forEach((entity) => layer.entities.remove(entity)); + domesRef.current = []; + }; + }, [viewer]); - const drawCircle = () => { - if (!data || !layerRef.current) return; - layerRef.current.entities.add({ - polygon: { - hierarchy: hierarchy(circle(...data.origin, 100)), - material: Cesium.Color.WHITE.withAlpha(0.08), - outline: true, - outlineColor: Cesium.Color.WHITE, - }, + /** 心跳:推波束纹理时间;目标位置跟随 Cesium 时钟,检测判定用同一时间源 */ + useEffect(() => { + if (!viewer || viewer.isDestroyed()) return undefined; + const onTick = () => { + tick(playing); + const seconds = Cesium.JulianDate.secondsDifference(viewer.clock.currentTime, epoch); + TARGETS.forEach((target) => { + const entity = targetsRef.current.get(target.id); + if (!entity) return; + const detected = isIntercepted(target, seconds); + const next = Cesium.Color.fromCssColorString(detected ? '#52c41a' : '#8c8c8c'); + if ( + entity.billboard && + !Cesium.Color.equals(entity.billboard.color?.getValue(viewer.clock.currentTime), next) + ) { + entity.billboard.color = new Cesium.ConstantProperty(next); + if (entity.label) { + entity.label.text = new Cesium.ConstantProperty( + detected ? `${target.id} · ${t('cesium.direction.detected')}` : target.id, + ); + entity.label.fillColor = new Cesium.ConstantProperty( + detected ? Cesium.Color.fromCssColorString('#95de64') : Cesium.Color.WHITE.withAlpha(0.75), + ); + } + } + }); + }; + viewer.clock.onTick.addEventListener(onTick); + return () => { + if (!viewer.isDestroyed()) viewer.clock.onTick.removeEventListener(onTick); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [viewer, playing]); + + // 暂停同时冻结目标(位置跟随 Cesium 时钟) + useEffect(() => { + if (viewer) viewer.clock.shouldAnimate = playing; + }, [viewer, playing]); + + // 模式切换:穹顶与波束各自显隐 + useEffect(() => { + domesRef.current.forEach((entity) => { + entity.show = mode !== 'sweep'; }); - flyToLayer(); - }; + if (sweepRef.current) sweepRef.current.show = mode !== 'domes'; + }, [mode, viewer]); + const stats = CAPABILITIES.map((capability) => ({ capability, ...capabilityStats(capability) })); const legend = ( {stats.map((row) => ( ))} @@ -139,25 +273,41 @@ export default function Direction() { return ( - - - - } > - + 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/Heatmap.tsx b/src/pages/Map/Cesium/Heatmap.tsx index 1deb1b3..e45f56e 100644 --- a/src/pages/Map/Cesium/Heatmap.tsx +++ b/src/pages/Map/Cesium/Heatmap.tsx @@ -1,159 +1,141 @@ import { CesiumStage, useCesiumViewer } from '@/components/CesiumViewer'; +import { addScanRing } from '@/components/CesiumViewer/effects'; import DemoPage from '@/components/DemoPage'; -import { ClearOutlined, DotChartOutlined, HeatMapOutlined } from '@ant-design/icons'; +import { PauseCircleOutlined, PlayCircleOutlined } from '@ant-design/icons'; import { useIntl } from '@umijs/max'; -import { Alert, Button, Card, Spin } from 'antd'; +import { Alert, Button, Card, Segmented } from 'antd'; import * as Cesium from 'cesium'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { coverageRadiusKm, REGION, renderHeatCanvas, strengthColor, transmitters } from './fieldModel'; -type Sample = { longitude: number; latitude: number; fieldStrength: number }; -type FieldStrengthData = { - /** 服务端渲染好的热力图 PNG(base64),覆盖 coverDiagramParams 描述的经纬度范围 */ - diagramPngStream: string; - coverageData: { arrayResult: Sample[][]; maxStrength: number; minStrength: number }; -}; +type Mode = 'heat' | 'shells' | 'both'; -/** 场强分级色:与图例一致 */ -const STRENGTH_STOPS: [number, string][] = [ - [25, '#2f54eb'], - [40, '#52c41a'], - [60, '#fadb14'], - [75, '#fa8c16'], - [Infinity, '#f5222d'], +const SHELLS: { threshold: number; alpha: number }[] = [ + { threshold: 60, alpha: 0.1 }, + { threshold: 45, alpha: 0.06 }, ]; -const strengthColor = (value: number) => STRENGTH_STOPS.find(([limit]) => value <= limit)![1]; - -let dataPromise: Promise | undefined; -/** 约 1.6 MB,首次使用时才拉取并缓存(失败允许重试) */ -const loadData = () => { - dataPromise ??= fetch(`${PUBLIC_PATH}data/cesium/field-strength.json`) - .then((response) => { - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return response.json() as Promise; - }) - .catch((error) => { - dataPromise = undefined; - throw error; - }); - return dataPromise; -}; - -const bounds = (samples: Sample[]) => { - const lngs = samples.map((s) => s.longitude); - const lats = samples.map((s) => s.latitude); - return Cesium.Rectangle.fromDegrees(Math.min(...lngs), Math.min(...lats), Math.max(...lngs), Math.max(...lats)); -}; +/** + * 场强页:同一套传播模型(fieldModel.ts)驱动两种互补的表示—— + * 动态热力(canvas → 贴图,随时间呼吸)与覆盖壳层(60/45 dB 等场强覆盖体)。 + * 数据全部程序生成,不依赖静态 JSON;发射机持续扫描,构成页面的「心跳」。 + */ export default function Heatmap() { const intl = useIntl(); const t = (id: string, values?: Record) => intl.formatMessage({ id }, values); - const [loading, setLoading] = useState(false); - const [tooltip, setTooltip] = useState<{ x: number; y: number; value: number }>(); - const pointsRef = useRef(null); - const textureRef = useRef(null); + const [mode, setMode] = useState('both'); + const [playing, setPlaying] = useState(true); + const heatRef = useRef(null); + const canvasRef = useRef(null); + const layerRef = useRef(null); + const timeRef = useRef({ elapsed: 0, last: performance.now() }); const { containerRef, viewer, error, basemapFailed, retry } = useCesiumViewer({ - home: [106.64, 29.67, 60_000], + home: [106.64, 29.67, 78_000], + entry: { pitch: Cesium.Math.toRadians(-30) }, onReady: (instance) => { - // 悬停读取场强:点图元的 id 挂的是采样点本身 - const handler = new Cesium.ScreenSpaceEventHandler(instance.scene.canvas); - handler.setInputAction((movement: Cesium.ScreenSpaceEventHandler.MotionEvent) => { - const picked = instance.scene.pick(movement.endPosition); - const sample = picked?.id as Sample | undefined; - setTooltip( - sample && typeof sample.fieldStrength === 'number' - ? { - x: movement.endPosition.x, - y: movement.endPosition.y, - value: Math.round(sample.fieldStrength * 10) / 10, - } - : undefined, - ); - }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); - return () => handler.destroy(); - }, - }); + const layer = new Cesium.CustomDataSource('field'); + instance.dataSources.add(layer); + layerRef.current = layer; - const withData = useCallback( - async (draw: (data: FieldStrengthData) => void) => { - setLoading(true); - try { - const data = await loadData(); - if (viewer && !viewer.isDestroyed()) draw(data); - } finally { - setLoading(false); - } - }, - [viewer], - ); + // 热力贴图层:一个覆盖全区域的矩形,材质每 200ms 换一张新画的 canvas + const canvas = document.createElement('canvas'); + canvas.width = 520; + canvas.height = 340; + canvasRef.current = canvas; + renderHeatCanvas(canvas, 0); + heatRef.current = instance.entities.add({ + rectangle: { + coordinates: Cesium.Rectangle.fromDegrees(REGION.west, REGION.south, REGION.east, REGION.north), + material: new Cesium.ImageMaterialProperty({ image: canvas, transparent: true }), + }, + }); - /** 热力图纹理:base64 PNG 直接作为矩形材质铺在测试区域上 */ - const drawTexture = useCallback( - () => - withData((data) => { - if (textureRef.current) viewer!.entities.remove(textureRef.current); - const rectangle = bounds(data.coverageData.arrayResult.flat()); - textureRef.current = viewer!.entities.add({ - rectangle: { - coordinates: rectangle, - material: new Cesium.ImageMaterialProperty({ - image: `data:image/png;base64,${data.diagramPngStream}`, - transparent: true, - }), + // 发射机:标记 + 各自的覆盖壳层 + 持续扫描 + transmitters().forEach((tx) => { + layer.entities.add({ + position: Cesium.Cartesian3.fromDegrees(tx.lng, tx.lat), + point: { + pixelSize: 9, + color: Cesium.Color.WHITE, + outlineColor: Cesium.Color.fromCssColorString('#1677ff'), + outlineWidth: 2, + }, + label: { + text: tx.id, + font: '12px sans-serif', + pixelOffset: new Cesium.Cartesian2(0, -16), + fillColor: Cesium.Color.WHITE, + showBackground: true, + backgroundColor: Cesium.Color.BLACK.withAlpha(0.55), }, }); - viewer!.camera.flyTo({ destination: rectangle, duration: 1.2 }); - }), - [withData, viewer], - ); - - /** 点云:PointPrimitiveCollection 批量渲染 5 千多个采样点(Entity 方式会卡住主线程) */ - const drawPoints = useCallback( - () => - withData((data) => { - if (pointsRef.current) viewer!.scene.primitives.remove(pointsRef.current); - const samples = data.coverageData.arrayResult.flat(); - const points = viewer!.scene.primitives.add( - new Cesium.PointPrimitiveCollection(), - ) as Cesium.PointPrimitiveCollection; - samples.forEach((sample) => - points.add({ - id: sample, - position: Cesium.Cartesian3.fromDegrees(sample.longitude, sample.latitude), - pixelSize: 6, - color: Cesium.Color.fromCssColorString(strengthColor(sample.fieldStrength)).withAlpha(0.75), - }), - ); - pointsRef.current = points; - viewer!.camera.flyTo({ destination: bounds(samples), duration: 1.2 }); - }), - [withData, viewer], - ); + SHELLS.forEach(({ threshold, alpha }) => { + const r = coverageRadiusKm(tx.power, threshold) * 1000; + layer.entities.add({ + position: Cesium.Cartesian3.fromDegrees(tx.lng, tx.lat), + ellipsoid: { + radii: new Cesium.Cartesian3(r, r, r * 0.32), + material: Cesium.Color.fromCssColorString(strengthColor(threshold + 10)).withAlpha(alpha), + slicePartitions: 48, + stackPartitions: 32, + }, + }); + }); + addScanRing(instance, { + lon: tx.lng, + lat: tx.lat, + color: '#1677ff', + maxRadius: coverageRadiusKm(tx.power, 55) * 1000, + duration: 3.4, + }); + }); + return () => { + if (!instance.isDestroyed()) instance.dataSources.remove(layer, true); + }; + }, + }); - // 进入页面直接铺上热力图(首次拉取约 1.6 MB,期间有加载提示),不让用户面对一张空地图去找按钮 - const autoDrawn = useRef(false); + // 热力呼吸:暂停即冻结(elapsed 不再前进),200ms 重绘一次 canvas 并换材质 useEffect(() => { - if (!viewer || autoDrawn.current) return; - autoDrawn.current = true; - void drawTexture(); - }, [drawTexture, viewer]); + if (!viewer || !canvasRef.current || !heatRef.current?.rectangle) return undefined; + const canvas = canvasRef.current; + const rectangle = heatRef.current.rectangle; + const timer = window.setInterval(() => { + const now = performance.now(); + const state = timeRef.current; + if (playing) state.elapsed += (now - state.last) / 1000; + state.last = now; + renderHeatCanvas(canvas, state.elapsed); + rectangle.material = new Cesium.ImageMaterialProperty({ image: canvas, transparent: true }); + }, 200); + return () => window.clearInterval(timer); + }, [viewer, playing]); - const clear = useCallback(() => { - if (!viewer) return; - if (textureRef.current) viewer.entities.remove(textureRef.current); - if (pointsRef.current) viewer.scene.primitives.remove(pointsRef.current); - textureRef.current = null; - pointsRef.current = null; - }, [viewer]); + // 模式切换:热力矩形与壳层/扫描各自显隐 + useEffect(() => { + if (!viewer || viewer.isDestroyed()) return; + const heat = heatRef.current; + if (heat) heat.show = mode !== 'shells'; + const layer = layerRef.current; + if (layer) layer.entities.show = mode !== 'heat'; + }, [viewer, mode]); + const stops: [number, string][] = [ + [25, '#2f54eb'], + [40, '#52c41a'], + [60, '#fadb14'], + [75, '#fa8c16'], + [Infinity, '#f5222d'], + ]; const legend = (
{t('cesium.heatmap.legend')}
- {STRENGTH_STOPS.map(([limit, color], index) => ( + {stops.map(([limit, color], index) => (
- {index === STRENGTH_STOPS.length - 1 ? `> ${STRENGTH_STOPS[index - 1][0]}` : `≤ ${limit}`} + {index === stops.length - 1 ? `> ${stops[index - 1][0]}` : `≤ ${limit}`}
))}
@@ -163,43 +145,37 @@ export default function Heatmap() { return ( - - - } > - -
- - {tooltip && ( -
- {t('cesium.heatmap.tooltip', { value: tooltip.value })} -
- )} -
-
+
); 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={ <> +