Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions src/components/CesiumViewer/effects.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {};

/** 目标图标(dataURL,按 kind+颜色缓存):船/飞机的剪影,配 billboard 使用 */
export const targetIcon = (kind: 'ship' | 'aircraft', css: string) => {
const key = `${kind}:${css}`;
iconCache[key] ??= drawIcon(kind, css);
return iconCache[key];
};
46 changes: 38 additions & 8 deletions src/components/CesiumViewer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
};
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -88,6 +96,8 @@ type UseCesiumViewerOptions = {
*/
export const useCesiumViewer = ({
home = [108, 35, 7_000_000],
entry,
instantEntry = false,
viewerOptions,
onReady,
}: UseCesiumViewerOptions = {}) => {
Expand All @@ -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) 影像层瓦片错误事件(部分版本在重试耗尽后触发);
Expand Down
44 changes: 40 additions & 4 deletions src/components/CesiumViewer/lifecycle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> };
imageryLayers: { get: (index: number) => undefined };
isDestroyed: () => boolean;
destroy: () => void;
Expand All @@ -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<string, unknown> };
imageryLayers = { get: () => undefined };
constructor(container: unknown, options: Record<string, unknown>) {
if (cesium.failInit) throw new Error('WebGL unavailable');
Expand All @@ -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' },
};
});

Expand All @@ -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);
};
Expand All @@ -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,
});
Expand Down Expand Up @@ -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);
Expand All @@ -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]);
Expand All @@ -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 } });
Expand Down
50 changes: 32 additions & 18 deletions src/locales/en-US/map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down
Loading
Loading