import * as Cesium from "cesium";
export interface UAVTrackPoint {
altitude: number;
latitude: number;
longitude: number;
timestamp: string;
isAbnormal?: boolean;
heading: number;
pitchAngle?: number;
rollAngle?: number;
remainingTime?: number;
remainingDistance?: number;
flightSpeed?: number;
totalDistance?: number;
totalTime?: number;
realtimeMonitorItem?: any;
weatherItem?: any;
communicationItem?: any;
}
export enum UAVType {
IMAGE = 'image',
MODEL = 'model',
}
export interface UAVUtilOptions {
uavType?: UAVType;
uavIcon?: string;
uavIconSize?: [number, number];
uavModel?: string;
uavModelScale?: number;
pathColor?: Cesium.Color;
pathWidth?: number;
keyPointIcon?: string;
keyPointSize?: [number, number];
keyPointInterval?: number;
abnormalPointIcon?: string;
abnormalPointSize?: [number, number];
playbackSpeed?: number;
panelOffset?: [number, number];
abnormalPanelOffset?: [number, number];
enableViewModeToggle?: boolean;
onProgress?: (progress: number, currentTime: Cesium.JulianDate) => void;
}
export interface PlaybackProgress {
percentage: number;
currentTime: Cesium.JulianDate;
startTime: Cesium.JulianDate;
endTime: Cesium.JulianDate;
currentPointIndex: number;
totalPoints: number;
}
type WindowManagers = Window & {
_viewer?: Cesium.Viewer;
_objectManager?: {
addObjGroup: (objGroup: string, groups: any[]) => void;
removeObjectsByGroup: (group: string) => void;
};
};
const UVA_ICON = new URL("@/assets/images/flightScenarioRefactoring/uav.png", import.meta.url).href;
const NORMAL_ICON = new URL("@/assets/images/flightScenarioRefactoring/normalNode.png", import.meta.url).href;
const ABNORMAL_ICON = new URL("@/assets/images/flightScenarioRefactoring/warningNode.png", import.meta.url).href;
const DIALOG_BG = new URL("@/assets/images/flightScenarioRefactoring/uavDialogBg.png", import.meta.url).href;
const MODEL_URL = new URL("/data/tb2.glb", import.meta.url).href;
export class UAVUtil {
private viewer: Cesium.Viewer | null = null;
private uavEntity: Cesium.Entity | null = null;
private pathEntity: Cesium.Entity | null = null;
private keyPointEntities: Cesium.Entity[] = [];
private panelEntity: Cesium.Entity | null = null;
private clock: Cesium.Clock | null = null;
private isPlaying = false;
private currentTrackData: UAVTrackPoint[] = [];
private options: Required<UAVUtilOptions>;
private groupName = "uav-track";
private panelContent: { name: string; value: string }[] = [];
private panelTitle = "详情";
private panelElement: HTMLDivElement | null = null;
private startTime: Cesium.JulianDate | null = null;
private endTime: Cesium.JulianDate | null = null;
private progressCallback: ((progress: number, currentTime: Cesium.JulianDate) => void) | null = null;
private cameraUpdateCallback: ((event: any) => void) | null = null;
private isFirstPersonView = false;
private abnormalPointPanels: {
entity: Cesium.Entity;
panel: HTMLDivElement;
updateCallback: (event: any) => void;
}[] = [];
constructor(options?: UAVUtilOptions) {
this.options = {
uavType: UAVType.IMAGE,
uavIcon: UVA_ICON,
uavIconSize: [40, 40],
uavModel: MODEL_URL,
uavModelScale: 10,
pathColor: Cesium.Color.fromBytes(45, 165, 241, 255),
pathWidth: 3,
keyPointIcon: NORMAL_ICON,
keyPointSize: [10, 10],
keyPointInterval: 10,
abnormalPointIcon: ABNORMAL_ICON,
abnormalPointSize: [10, 10],
playbackSpeed: 1,
panelOffset: [-150, -180],
abnormalPanelOffset: [-120, -200],
enableViewModeToggle: false,
onProgress: () => { },
...options,
};
this.cameraUpdateCallback = this.updateCameraPosition.bind(this);
this.initMouseHandler();
}
private getViewer(): Cesium.Viewer | null {
if (!this.viewer) {
this.viewer = (window as WindowManagers)._viewer || null;
}
return this.viewer;
}
private initMouseHandler(): void {
const viewer = this.getViewer();
if (!viewer) return;
const handler = new Cesium.ScreenSpaceEventHandler(
viewer.scene.canvas,
);
handler.setInputAction((movement) => {
const pickedObject = viewer.scene.pick(movement.position);
if (Cesium.defined(pickedObject) && pickedObject.id) {
const entity = pickedObject.id;
if (entity.id && typeof entity.id === 'string' && entity.id.includes('-abnormal-point-')) {
this.showAbnormalPointPanel(entity);
}
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
}
private clearAbnormalPointPanels(): void {
const viewer = this.getViewer();
if (!viewer) return;
this.abnormalPointPanels.forEach(({ panel, updateCallback }) => {
if (panel && panel.parentNode) {
panel.parentNode.removeChild(panel);
}
viewer.scene.postRender.removeEventListener(updateCallback);
});
this.abnormalPointPanels = [];
}
private validateAbnormalPointEntity(entity: Cesium.Entity): { valid: boolean; abnormalPoint?: UAVTrackPoint; index?: number } {
if (!this.currentTrackData || this.currentTrackData.length === 0) {
return { valid: false };
}
const entityId = entity.id as string;
const existingPanelIndex = this.abnormalPointPanels.findIndex(panelInfo => panelInfo.entity.id === entityId);
if (existingPanelIndex !== -1) {
return { valid: false };
}
const match = entityId.match(/-abnormal-point-(\d+)/);
if (!match) {
return { valid: false };
}
const index = parseInt(match[1], 10);
if (isNaN(index) || index < 0 || index >= this.currentTrackData.length) {
return { valid: false };
}
const abnormalPoint = this.currentTrackData[index];
if (!abnormalPoint) {
return { valid: false };
}
return { valid: true, abnormalPoint, index };
}
private generateAbnormalPointPanelContent(abnormalPoint: UAVTrackPoint) {
const { communicationItem, realtimeMonitorItem, weatherItem } = abnormalPoint || {};
const panelContent1 = [
{ name: "电池电压", value: realtimeMonitorItem?.batteryVoltageName },
{ name: "电池电流", value: realtimeMonitorItem?.batteryCurrentName },
{ name: "电池温度", value: realtimeMonitorItem?.batteryTemperatureName },
{ name: "电机转速", value: realtimeMonitorItem?.motorSpeedName },
{ name: "电机温度", value: realtimeMonitorItem?.motorTemperatureStatusName },
{ name: "电机电流", value: realtimeMonitorItem?.motorCurrentName },
];
const panelContent2 = [
{ name: "风速", value: `${weatherItem?.windSpeed || 0}m/s` },
{ name: "风向", value: weatherItem?.windDirection },
{ name: "能见度", value: `${weatherItem?.visibility || 0}km` },
{ name: "降水量", value: `${weatherItem?.precipitation || 0}mm` },
{ name: "温度", value: `${weatherItem?.temperature || 0}℃` },
];
const panelContent3 = [
{ name: "油门指令值", value: communicationItem?.throttleCommand },
{ name: "偏航指令值", value: communicationItem?.yawCommand },
{ name: "横滚指令值", value: communicationItem?.rollCommand },
{ name: "俯仰指令值", value: communicationItem?.pitchCommand },
{ name: "模式开关状态", value: communicationItem?.modeSwitchStatus },
{ name: "功能按键状态", value: communicationItem?.functionButtonStatus },
{ name: "拔杆位置", value: communicationItem?.leverPosition },
{ name: "指令时间戳", value: communicationItem?.commandTimestamp },
{ name: "指令响应延迟", value: communicationItem?.commandResponseDelay },
{ name: "指令执行确认", value: communicationItem?.commandExecutionConfirm },
];
return { panelContent1, panelContent2, panelContent3 };
}
private createContentRow(item: { name: string; value: string }): HTMLDivElement {
const row = document.createElement('div');
row.style.cssText = `
display: flex;
justify-content: space-between;
`;
const label = document.createElement('span');
label.textContent = item.name;
label.style.cssText = `
white-space: nowrap;
`;
const value = document.createElement('span');
value.textContent = item.value;
value.style.cssText = `
margin-left: 12px;
`;
row.appendChild(label);
row.appendChild(value);
return row;
}
private createAbnormalPointPanel(contentData: any, viewer: Cesium.Viewer): HTMLDivElement {
const { panelContent1, panelContent2, panelContent3 } = contentData;
const panelElement = document.createElement('div');
panelElement.className = 'abnormal-point-panel';
panelElement.style.cssText = `
position: fixed;
border-radius: 6px;
background: rgba(5, 20, 40, 0.95);
border: 2px solid rgba(61, 90, 254, 1);
box-shadow: inset 0px 0px 24px rgba(61, 90, 254, 1);
font-size: 14px;
font-weight: 400;
color: rgba(156, 163, 175, 1);
z-index: 1000;
min-width: 300px;
max-width: 500px;
`;
const title = document.createElement('div');
title.innerHTML = `
<div style="
font-weight: bold;
font-size: 16px;
line-height: 36px;
color: rgba(255, 255, 255, 1);
font-family: youshebiaotihei;
background: linear-gradient(270deg, #42c6ff 0%, #ffffff 100%);
background-clip: text;
color: transparent;"
>异常点信息</div>
</div>
`;
title.style.cssText = `
height: 36px;
background: url(${DIALOG_BG});
background-size: 100% 100%;
padding:0 10px 0 27px;
display:flex;
align-items: center;
justify-content: space-between;
gap: 20px;
`;
const closeButton = document.createElement('button');
closeButton.textContent = 'x';
closeButton.style.cssText = `
color: white;
border: none;
cursor: pointer;
font-size: 18px;
font-weight: 400;
line-height: 30px;
`;
closeButton.addEventListener('click', () => {
const index = this.abnormalPointPanels.findIndex(panelInfo => panelInfo.panel === panelElement);
if (index !== -1) {
const { panel, updateCallback } = this.abnormalPointPanels[index];
if (panel && panel.parentNode) {
panel.parentNode.removeChild(panel);
}
viewer.scene.postRender.removeEventListener(updateCallback);
this.abnormalPointPanels.splice(index, 1);
}
});
title.appendChild(closeButton);
panelElement.appendChild(title);
const tabBox = document.createElement('div');
tabBox.style.cssText = `
display: flex;
justify-content: space-between;
gap: 20px;
padding: 10px 20px 5px;
`;
const tab1 = document.createElement('div');
tab1.textContent = '设备运行状态';
tab1.style.cssText = `
cursor: pointer;
border-radius: 6px;
color: rgb(9, 175, 254);
`;
const tab2 = document.createElement('div');
tab2.textContent = '气象数据';
tab2.style.cssText = `
cursor: pointer;
border-radius: 6px;
`;
const tab3 = document.createElement('div');
tab3.textContent = '通信记录';
tab3.style.cssText = `
cursor: pointer;
border-radius: 6px;
`;
tabBox.appendChild(tab1);
tabBox.appendChild(tab2);
tabBox.appendChild(tab3);
panelElement.appendChild(tabBox);
const content1 = document.createElement('div');
content1.style.cssText = `
padding: 12px 20px;
`;
const content2 = document.createElement('div');
content2.style.cssText = `
padding: 12px 20px;
display: none;
`;
const content3 = document.createElement('div');
content3.style.cssText = `
padding: 12px 20px;
display: none;
`;
panelContent1.forEach(item => content1.appendChild(this.createContentRow(item)));
panelContent2.forEach(item => content2.appendChild(this.createContentRow(item)));
panelContent3.forEach(item => content3.appendChild(this.createContentRow(item)));
panelElement.appendChild(content1);
panelElement.appendChild(content2);
panelElement.appendChild(content3);
tab1.addEventListener('click', () => {
content1.style.display = 'block';
content2.style.display = 'none';
content3.style.display = 'none';
tab1.style.color = 'rgb(9, 175, 254)';
tab2.style.color = 'inherit';
tab3.style.color = 'inherit';
});
tab2.addEventListener('click', () => {
content1.style.display = 'none';
content2.style.display = 'block';
content3.style.display = 'none';
tab1.style.color = 'inherit';
tab2.style.color = 'rgb(9, 175, 254)';
tab3.style.color = 'inherit';
});
tab3.addEventListener('click', () => {
content1.style.display = 'none';
content2.style.display = 'none';
content3.style.display = 'block';
tab1.style.color = 'inherit';
tab2.style.color = 'inherit';
tab3.style.color = 'rgb(9, 175, 254)';
});
document.body.appendChild(panelElement);
return panelElement;
}
private setupAbnormalPointPanelPositioning(entity: Cesium.Entity, panelElement: HTMLDivElement, viewer: Cesium.Viewer): (event: any) => void {
const updateCallback = () => {
if (!panelElement) return;
const position = entity.position?.getValue(viewer.clock.currentTime);
if (!position) return;
const windowPosition = Cesium.SceneTransforms.worldToWindowCoordinates(viewer.scene, position);
if (!Cesium.defined(windowPosition)) return;
let panelX = windowPosition.x + this.options.abnormalPanelOffset[0];
let panelY = windowPosition.y + this.options.abnormalPanelOffset[1];
const safetyMargin = 70;
const panelWidth = 400;
const panelHeight = 350;
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
panelX = Math.max(safetyMargin, panelX);
panelX = Math.min(windowWidth - panelWidth - safetyMargin, panelX);
panelY = Math.max(safetyMargin, panelY);
panelY = Math.min(windowHeight - panelHeight - safetyMargin, panelY);
panelElement.style.left = `${panelX}px`;
panelElement.style.top = `${panelY}px`;
};
viewer.scene.postRender.addEventListener(updateCallback);
this.abnormalPointPanels.push({
entity,
panel: panelElement,
updateCallback
});
updateCallback();
return updateCallback;
}
private showAbnormalPointPanel(entity: Cesium.Entity): void {
const validationResult = this.validateAbnormalPointEntity(entity);
if (!validationResult.valid || !validationResult.abnormalPoint) {
return;
}
const { abnormalPoint } = validationResult;
const viewer = this.getViewer();
if (!viewer) {
return;
}
const contentData = this.generateAbnormalPointPanelContent(abnormalPoint);
const panelElement = this.createAbnormalPointPanel(contentData, viewer);
this.setupAbnormalPointPanelPositioning(entity, panelElement, viewer);
}
renderStatic(trackData: UAVTrackPoint[]): void {
this.clear();
this.currentTrackData = trackData;
const viewer = this.getViewer();
if (!viewer || trackData.length === 0) return;
const positions = trackData.map((point) =>
Cesium.Cartesian3.fromDegrees(
point.longitude,
point.latitude,
point.altitude,
),
);
this.renderPath(trackData);
this.renderKeyPoints(trackData);
this.renderUAV(positions[0], trackData[0]);
const shouldUseFlyToTrack = !this.options.enableViewModeToggle || !this.isFirstPersonView;
if (shouldUseFlyToTrack) {
this.flyToTrack(trackData);
} else {
if (this.uavEntity) {
const position = this.uavEntity.position?.getValue(viewer.clock.currentTime);
if (position) {
const camera = viewer.scene.camera;
if (camera) {
this.setupFirstPersonCamera(camera, position);
}
}
}
}
if (this.options.enableViewModeToggle && viewer && this.uavEntity && this.cameraUpdateCallback) {
viewer.scene.postRender.addEventListener(this.cameraUpdateCallback);
}
}
startPlayback(trackData: UAVTrackPoint[]): void {
this.clear();
this.currentTrackData = trackData;
const viewer = this.getViewer();
if (!viewer || trackData.length === 0) return;
this.renderPath(trackData);
this.renderKeyPoints(trackData);
if (!this.options.enableViewModeToggle || !this.isFirstPersonView) {
this.flyToTrack(trackData);
}
this.setupPlayback(trackData);
if (viewer && this.uavEntity && this.cameraUpdateCallback && this.options.enableViewModeToggle) {
viewer.scene.postRender.addEventListener(this.cameraUpdateCallback);
}
}
pausePlayback(): void {
if (this.clock) {
this.clock.shouldAnimate = false;
this.isPlaying = false;
}
}
resumePlayback(): void {
if (this.clock) {
this.clock.shouldAnimate = true;
this.isPlaying = true;
}
const viewer = this.getViewer();
if (viewer && this.cameraUpdateCallback && this.options.enableViewModeToggle) {
try {
viewer.scene.postRender.removeEventListener(this.cameraUpdateCallback);
} catch (e) {
console.error('Error removing camera update callback:', e);
}
viewer.scene.postRender.addEventListener(this.cameraUpdateCallback);
}
}
stopPlayback(): void {
this.pausePlayback();
if (this.clock) {
this.clock.currentTime = this.clock.startTime.clone();
}
}
setPlaybackSpeed(speed: number): void {
this.options.playbackSpeed = speed;
if (this.clock) {
this.clock.multiplier = speed;
}
}
seekToProgress(progress: number): void {
if (!this.clock || !this.startTime || !this.endTime) return;
const clampedProgress = Math.max(0, Math.min(1, progress));
const totalDuration = Cesium.JulianDate.secondsDifference(
this.endTime,
this.startTime,
);
const seekSeconds = totalDuration * clampedProgress;
const seekTime = Cesium.JulianDate.addSeconds(
this.startTime,
seekSeconds,
new Cesium.JulianDate(),
);
this.clock.currentTime = seekTime;
}
seekToTime(time: Cesium.JulianDate): void {
if (!this.clock || !this.startTime || !this.endTime) return;
const clampedTime = this.clampJulianDate(time, this.startTime, this.endTime);
this.clock.currentTime = clampedTime;
}
seekToPointIndex(index: number): void {
if (index < 0 || index >= this.currentTrackData.length) return;
const point = this.currentTrackData[index];
const time = Cesium.JulianDate.fromIso8601(new Date(point.timestamp).toISOString());
this.seekToTime(time);
}
setPanelTitle(title: string): void {
this.panelTitle = title;
if (this.panelElement) {
const titleElement = this.panelElement.querySelector('.uav-panel-title');
if (titleElement) {
titleElement.textContent = title;
}
}
}
updatePanelContent(content: { name: string; value: string }[]): void {
this.panelContent = content;
if (this.uavEntity) {
const viewer = this.getViewer();
if (viewer) {
const position = this.uavEntity.position?.getValue(
viewer.clock.currentTime,
);
if (position) {
this.updatePanel(position);
}
}
}
}
setProgressCallback(callback: (progress: number, currentTime: Cesium.JulianDate) => void): void {
this.progressCallback = callback;
}
getPlaybackProgress(): number {
if (!this.clock || !this.startTime || !this.endTime) return 0;
const totalDuration = Cesium.JulianDate.secondsDifference(
this.endTime,
this.startTime,
);
if (totalDuration <= 0) return 0;
const elapsed = Cesium.JulianDate.secondsDifference(
this.clock.currentTime,
this.startTime,
);
return Math.max(0, Math.min(1, elapsed / totalDuration));
}
getPlaybackProgressDetail(): PlaybackProgress | null {
if (!this.clock || !this.startTime || !this.endTime) return null;
const percentage = this.getPlaybackProgress();
const currentPointIndex = this.findCurrentPointIndex(this.clock.currentTime);
return {
percentage,
currentTime: this.clock.currentTime.clone(),
startTime: this.startTime.clone(),
endTime: this.endTime.clone(),
currentPointIndex,
totalPoints: this.currentTrackData.length,
};
}
clear(): void {
const viewer = this.getViewer();
if (!viewer) return;
if (this.cameraUpdateCallback) {
viewer.scene.postRender.removeEventListener(this.cameraUpdateCallback);
}
if (this.uavEntity) {
viewer.entities.remove(this.uavEntity);
this.uavEntity = null;
}
if (this.pathEntity) {
viewer.entities.remove(this.pathEntity);
this.pathEntity = null;
}
this.keyPointEntities.forEach((entity) => {
viewer.entities.remove(entity);
});
this.keyPointEntities = [];
if (this.panelEntity) {
viewer.entities.remove(this.panelEntity);
this.panelEntity = null;
}
if (this.panelElement) {
this.panelElement.remove();
this.panelElement = null;
}
this.clearAbnormalPointPanels();
if (this.clock) {
this.clock.shouldAnimate = false;
}
this.isPlaying = false;
this.currentTrackData = [];
this.startTime = null;
this.endTime = null;
this.progressCallback = null;
}
private calculateTrackBounds(trackData: UAVTrackPoint[]) {
let minLon = Infinity;
let maxLon = -Infinity;
let minLat = Infinity;
let maxLat = -Infinity;
let sumLon = 0;
let sumLat = 0;
for (const point of trackData) {
minLon = Math.min(minLon, point.longitude);
maxLon = Math.max(maxLon, point.longitude);
minLat = Math.min(minLat, point.latitude);
maxLat = Math.max(maxLat, point.latitude);
sumLon += point.longitude;
sumLat += point.latitude;
}
const centerLon = sumLon / trackData.length;
const centerLat = sumLat / trackData.length;
const lonDistance = (maxLon - minLon) * 111000;
const latDistance = (maxLat - minLat) * 111000;
const maxDistance = Math.max(lonDistance, latDistance);
return { minLon, maxLon, minLat, maxLat, centerLon, centerLat, maxDistance };
}
private calculateTrackCameraPosition(viewer: Cesium.Viewer, centerLon: number, centerLat: number, maxDistance: number): Cesium.Cartesian3 {
const canvasWidth = viewer.scene.canvas.width;
const leftPanelWidth = 400;
const rightPanelWidth = 400;
const availableWidth = canvasWidth - leftPanelWidth - rightPanelWidth;
const screenRatio = availableWidth / canvasWidth;
const cameraHeight = (maxDistance * 1.5 + 1000) / screenRatio;
return Cesium.Cartesian3.fromDegrees(centerLon, centerLat, cameraHeight);
}
private flyToTrack(trackData: UAVTrackPoint[]): void {
const viewer = this.getViewer();
if (!viewer || trackData.length === 0) return;
const { centerLon, centerLat, maxDistance } = this.calculateTrackBounds(trackData);
const cameraPosition = this.calculateTrackCameraPosition(viewer, centerLon, centerLat, maxDistance);
viewer.scene.camera.flyTo({
destination: cameraPosition,
orientation: {
heading: Cesium.Math.toRadians(0),
pitch: Cesium.Math.toRadians(-90),
roll: 0,
},
duration: 2,
});
}
private renderPath(trackData: UAVTrackPoint[]): void {
const viewer = this.getViewer();
if (!viewer || trackData.length < 2) return;
const positions = trackData.map((point) =>
Cesium.Cartesian3.fromDegrees(
point.longitude,
point.latitude,
point.altitude,
),
);
this.pathEntity = viewer.entities.add({
id: `${this.groupName}-path`,
polyline: {
positions: positions,
width: this.options.pathWidth,
material: this.options.pathColor,
clampToGround: false,
},
});
}
private renderKeyPoints(trackData: UAVTrackPoint[]): void {
const viewer = this.getViewer();
if (!viewer) return;
const interval = this.options.keyPointInterval;
for (let i = 0; i < trackData.length; i += interval) {
const point = trackData[i];
const position = Cesium.Cartesian3.fromDegrees(
point.longitude,
point.latitude,
point.altitude,
);
const isAbnormal = point.isAbnormal || false;
const icon = isAbnormal ? this.options.abnormalPointIcon : this.options.keyPointIcon;
const size = isAbnormal ? this.options.abnormalPointSize : this.options.keyPointSize;
const entity = viewer.entities.add({
id: `${this.groupName}-keypoint-${i}`,
position: position,
billboard: {
image: icon,
width: size[0],
height: size[1],
verticalOrigin: Cesium.VerticalOrigin.CENTER,
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
}
});
this.keyPointEntities.push(entity);
}
for (let i = 0; i < trackData.length; i++) {
const point = trackData[i];
if (point.isAbnormal && i % interval !== 0) {
const position = Cesium.Cartesian3.fromDegrees(
point.longitude,
point.latitude,
point.altitude,
);
const entity = viewer.entities.add({
id: `${this.groupName}-abnormal-point-${i}`,
position: position,
billboard: {
image: this.options.abnormalPointIcon,
width: this.options.abnormalPointSize[0],
height: this.options.abnormalPointSize[1],
verticalOrigin: Cesium.VerticalOrigin.CENTER,
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
});
this.keyPointEntities.push(entity);
}
}
}
private renderUAV(
position: Cesium.Cartesian3,
trackPoint?: UAVTrackPoint,
): void {
const viewer = this.getViewer();
if (!viewer) return;
const entityOptions: any = {
id: `${this.groupName}-uav`,
position: position,
};
if (trackPoint) {
let heading = -45;
if (this.currentTrackData && this.currentTrackData.length > 1) {
const currentIndex = this.currentTrackData.findIndex(p => p.timestamp === trackPoint.timestamp);
if (currentIndex > 0) {
const currentPoint = trackPoint;
const prevPoint = this.currentTrackData[currentIndex - 1];
const deltaLon = currentPoint.longitude - prevPoint.longitude;
const deltaLat = currentPoint.latitude - prevPoint.latitude;
heading = Math.atan2(deltaLon, deltaLat) * (180 / Math.PI);
}
}
const orientation = Cesium.Transforms.headingPitchRollQuaternion(
position,
new Cesium.HeadingPitchRoll(
Cesium.Math.toRadians(heading),
0,
0,
),
);
entityOptions.orientation = orientation;
}
if (this.options.uavType === UAVType.MODEL && this.options.uavModel) {
entityOptions.model = {
uri: this.options.uavModel,
scale: this.options.uavModelScale || 0.5,
minimumPixelSize: 64,
maximumScale: 20000,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
};
} else {
entityOptions.billboard = {
image: this.options.uavIcon,
width: this.options.uavIconSize[0],
height: this.options.uavIconSize[1],
verticalOrigin: Cesium.VerticalOrigin.CENTER,
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
};
}
this.uavEntity = viewer.entities.add(entityOptions);
}
private setupPlayback(
trackData: UAVTrackPoint[],
): void {
const viewer = this.getViewer();
if (!viewer) return;
this.startTime = Cesium.JulianDate.fromIso8601(new Date(trackData[0].timestamp).toISOString());
this.endTime = Cesium.JulianDate.fromIso8601(
new Date(trackData[trackData.length - 1].timestamp).toISOString(),
);
this.clock = viewer.clock;
this.clock.startTime = this.startTime.clone();
this.clock.stopTime = this.endTime.clone();
this.clock.currentTime = this.startTime.clone();
this.clock.clockRange = Cesium.ClockRange.CLAMPED;
this.clock.multiplier = this.options.playbackSpeed;
this.clock.shouldAnimate = true;
const timePositions = trackData.map((point) =>
Cesium.Cartesian3.fromDegrees(
point.longitude,
point.latitude,
point.altitude,
),
);
const entityOptions: any = {
id: `${this.groupName}-uav`,
position: new Cesium.SampledPositionProperty(),
};
const sampledPosition = entityOptions.position as Cesium.SampledPositionProperty;
sampledPosition.setInterpolationOptions({
interpolationDegree: 3
});
entityOptions.orientation = new Cesium.VelocityOrientationProperty(
entityOptions.position as Cesium.PositionProperty,
);
if (this.options.uavType === UAVType.MODEL && this.options.uavModel) {
entityOptions.model = {
uri: this.options.uavModel,
scale: this.options.uavModelScale || 0.3,
minimumPixelSize: 32,
maximumScale: 10000,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
};
} else {
entityOptions.billboard = {
image: this.options.uavIcon,
width: this.options.uavIconSize[0],
height: this.options.uavIconSize[1],
verticalOrigin: Cesium.VerticalOrigin.CENTER,
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
};
}
this.uavEntity = viewer.entities.add(entityOptions);
const uavSampledPosition = this.uavEntity.position as Cesium.SampledPositionProperty;
trackData.forEach((point, index) => {
const time = Cesium.JulianDate.fromIso8601(new Date(point.timestamp).toISOString());
const position = timePositions[index];
uavSampledPosition.addSample(time, position);
});
this.setupPanel(viewer);
this.isPlaying = true;
}
private setupPanel(viewer: Cesium.Viewer): void {
this.createPanelElement();
viewer.scene.postRender.addEventListener(() => {
if (!this.isPlaying || !this.uavEntity) return;
const currentTime = viewer.clock.currentTime;
const position = this.uavEntity.position?.getValue(currentTime);
if (position) {
this.updatePanel(position);
this.updateProgress(currentTime);
}
});
}
private updateProgress(currentTime: Cesium.JulianDate): void {
if (!this.startTime || !this.endTime) return;
const totalDuration = Cesium.JulianDate.secondsDifference(
this.endTime,
this.startTime,
);
if (totalDuration <= 0) return;
const elapsed = Cesium.JulianDate.secondsDifference(
currentTime,
this.startTime,
);
const progress = Math.max(0, Math.min(1, elapsed / totalDuration));
if (this.progressCallback) {
this.progressCallback(progress, currentTime);
}
if (this.options.onProgress) {
this.options.onProgress(progress, currentTime);
}
if (progress >= 1 && this.isPlaying) {
this.pausePlayback();
}
}
private createPanelElement(): void {
if (this.panelElement) {
this.panelElement.remove();
}
this.panelElement = document.createElement('div');
this.panelElement.className = 'uav-track-panel';
this.panelElement.style.cssText = `
position: absolute;
border-radius: 6px;
background: rgba(5, 20, 40, 0.95);
border: 2px solid rgba(61, 90, 254, 1);
box-shadow:inset 0px 0px 24px rgba(61, 90, 254, 1);
font-size: 14px;
font-weight: 400;
color: rgba(156, 163, 175, 1);
pointer-events: none;
z-index: 1000;
min-width: 180px;
`;
this.panelElement.innerHTML = `
<div class="uav-panel-title"
style="uav-panel-contentight: 36px;
background: url(${DIALOG_BG});
background-size: 100% 100%;
padding-left:27px;
"
>
<div style="
font-weight: bold;
font-size: 16px;
line-height: 36px;
color: rgba(255, 255, 255, 1);
font-family: youshebiaotihei;
background: linear-gradient(270deg, #42c6ff 0%, #ffffff 100%);
background-clip: text;
color: transparent;"
>${this.panelTitle}</div>
</div>
<div class="uav-panel-content" style="padding: 12px 20px;"></div>
`;
document.body.appendChild(this.panelElement);
}
private updatePanel(position: Cesium.Cartesian3): void {
const viewer = this.getViewer();
if (!viewer || !this.panelElement) return;
const currentTime = viewer.clock.currentTime;
const currentPoint = this.findCurrentTrackPoint(currentTime);
let content = this.panelContent.length > 0 ? this.panelContent : [];
if (currentPoint && content.length === 0) {
content = [
{ name: "总距离", value: `${currentPoint?.totalDistance || 0}m` },
{ name: "总时间", value: `${currentPoint?.totalTime || 0}s` },
{ name: "当前时间", value: `${(currentPoint?.totalTime || 0) - (currentPoint?.remainingTime || 0)}s` },
{ name: "飞行速度", value: `${currentPoint?.flightSpeed || 0}m/s` },
{ name: "高度", value: `${currentPoint?.altitude || 0}m` },
{ name: "航向", value: `${currentPoint?.heading || 0}°` },
{ name: "俯角", value: `${currentPoint?.pitchAngle || 0}°` },
{ name: "仰角", value: `${currentPoint?.rollAngle || 0}°` },
{ name: "剩余时间", value: `${currentPoint?.remainingTime || 0}s` },
{ name: "剩余距离", value: `${currentPoint?.remainingDistance || 0}m` },
];
}
const contentElement = this.panelElement.querySelector('.uav-panel-content');
if (contentElement) {
contentElement.innerHTML = content
.map((item) => `<div style="display: flex; justify-content: space-between;"><span>${item.name}:</span><span style="margin-left: 12px;">${item.value}</span></div>`)
.join('');
}
const canvasPosition = this.wgs84ToWindowCoordinates(viewer, position);
if (canvasPosition) {
let left = canvasPosition.x + this.options.panelOffset[0];
let top = canvasPosition.y + this.options.panelOffset[1] + (this.isFirstPersonView ? - 120 : 0);
const safetyMargin = 70;
const panelWidth = this.panelElement.offsetWidth || 200;
const panelHeight = this.panelElement.offsetHeight || 300;
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
left = Math.max(safetyMargin, left);
left = Math.min(windowWidth - panelWidth - safetyMargin, left);
top = Math.max(safetyMargin, top);
top = Math.min(windowHeight - panelHeight - safetyMargin, top);
this.panelElement.style.left = `${left}px`;
this.panelElement.style.top = `${top}px`;
}
}
private findCurrentTrackPoint(
currentTime: Cesium.JulianDate,
): UAVTrackPoint | null {
if (this.currentTrackData.length === 0) return null;
const currentTimeSeconds = Cesium.JulianDate.toDate(currentTime).getTime();
let closestPoint = this.currentTrackData[0];
let minDiff = Math.abs(
parseInt(closestPoint.timestamp) - currentTimeSeconds,
);
for (const point of this.currentTrackData) {
const diff = Math.abs(parseInt(point.timestamp) - currentTimeSeconds);
if (diff < minDiff) {
minDiff = diff;
closestPoint = point;
}
}
return closestPoint;
}
private findCurrentPointIndex(currentTime: Cesium.JulianDate): number {
if (this.currentTrackData.length === 0) return 0;
const currentTimeSeconds = Cesium.JulianDate.toDate(currentTime).getTime();
let closestIndex = 0;
let minDiff = Math.abs(
parseInt(this.currentTrackData[0].timestamp) - currentTimeSeconds,
);
for (let i = 1; i < this.currentTrackData.length; i++) {
const diff = Math.abs(
parseInt(this.currentTrackData[i].timestamp) - currentTimeSeconds,
);
if (diff < minDiff) {
minDiff = diff;
closestIndex = i;
}
}
return closestIndex;
}
private clampJulianDate(date: Cesium.JulianDate, min: Cesium.JulianDate, max: Cesium.JulianDate): Cesium.JulianDate {
const result = date.clone();
if (Cesium.JulianDate.lessThan(result, min)) {
return min.clone();
}
if (Cesium.JulianDate.greaterThan(result, max)) {
return max.clone();
}
return result;
}
private wgs84ToWindowCoordinates(viewer: Cesium.Viewer, position: Cesium.Cartesian3): Cesium.Cartesian2 | undefined {
const scene = viewer.scene;
const canvasPosition = new Cesium.Cartesian2();
const result = scene.cartesianToCanvasCoordinates(position, canvasPosition);
return result;
}
getUAVPosition(): Cesium.Cartesian3 | null {
const viewer = this.getViewer();
if (!viewer || !this.uavEntity) return null;
return (
this.uavEntity.position?.getValue(viewer.clock.currentTime) || null
);
}
getCurrentTrackPoint(): UAVTrackPoint | null {
const viewer = this.getViewer();
if (!viewer) return null;
return this.findCurrentTrackPoint(viewer.clock.currentTime);
}
isPlaybackActive(): boolean {
return this.isPlaying;
}
toggleViewMode(isFirstPerson: boolean): void {
this.isFirstPersonView = isFirstPerson;
if (!this.options.enableViewModeToggle) return;
const viewer = this.getViewer();
if (!viewer || !this.uavEntity) return;
try {
const currentTime = viewer.clock.currentTime;
const position = this.getUAVPositionAtTime(currentTime);
if (!position || !this.isValidCartesian3(position)) {
console.warn('Invalid UAV position in toggleViewMode');
return;
}
const camera = viewer.scene.camera;
if (!camera) return;
if (isFirstPerson) {
this.setupFirstPersonCamera(camera, position);
this.updatePanel(position);
} else {
const { centerLon, centerLat, maxDistance } = this.calculateTrackBounds(this.currentTrackData);
const cameraPosition = this.calculateTrackCameraPosition(viewer, centerLon, centerLat, maxDistance);
camera.setView({
destination: cameraPosition,
orientation: {
heading: Cesium.Math.toRadians(0),
pitch: Cesium.Math.toRadians(-90),
roll: 0,
}
});
if (position) {
this.updatePanel(position);
}
}
} catch (error) {
console.warn('Error updating camera position in toggleViewMode:', error);
}
if (this.cameraUpdateCallback) {
viewer.scene.postRender.removeEventListener(this.cameraUpdateCallback);
if (this.isPlaying) {
viewer.scene.postRender.addEventListener(this.cameraUpdateCallback);
}
}
}
private setupFirstPersonCamera(camera: Cesium.Camera, position: Cesium.Cartesian3): void {
const followDistance = 80;
const tailTopHeight = 18;
const slightForward = 4;
let uavHeading = -45;
let uavPitch = 0;
const viewer = this.getViewer();
if (viewer) {
const currentTime = viewer.clock.currentTime;
const nextTime = Cesium.JulianDate.addSeconds(currentTime, 0.1, new Cesium.JulianDate());
const nextPosition = this.uavEntity?.position?.getValue(nextTime);
if (nextPosition && this.isValidCartesian3(nextPosition)) {
const velocityDir = Cesium.Cartesian3.normalize(
Cesium.Cartesian3.subtract(nextPosition, position, new Cesium.Cartesian3()),
new Cesium.Cartesian3()
);
const transform = Cesium.Transforms.eastNorthUpToFixedFrame(position);
const inverseTransform = Cesium.Matrix4.inverse(transform, new Cesium.Matrix4());
const localDir = Cesium.Matrix4.multiplyByPointAsVector(
inverseTransform,
velocityDir,
new Cesium.Cartesian3()
);
const heading = Math.atan2(localDir.x, localDir.y);
const pitch = Math.atan2(
localDir.z,
Math.sqrt(localDir.x * localDir.x + localDir.y * localDir.y)
);
uavHeading = Cesium.Math.toDegrees(heading);
uavPitch = Cesium.Math.toDegrees(pitch);
} else if (this.uavEntity && this.uavEntity.orientation) {
const uavOrientation = this.uavEntity.orientation.getValue(currentTime);
if (uavOrientation) {
const hpr = Cesium.HeadingPitchRoll.fromQuaternion(uavOrientation);
uavHeading = Cesium.Math.toDegrees(hpr.heading);
uavPitch = Cesium.Math.toDegrees(hpr.pitch);
}
}
}
const transform = Cesium.Transforms.eastNorthUpToFixedFrame(position);
const cameraOffset = new Cesium.Cartesian3(
0,
-followDistance + slightForward,
tailTopHeight
);
const rotationMatrix = Cesium.Matrix3.fromHeadingPitchRoll(
new Cesium.HeadingPitchRoll(
Cesium.Math.toRadians(uavHeading),
Cesium.Math.toRadians(uavPitch),
0
)
);
const rotatedOffset = Cesium.Matrix3.multiplyByVector(
rotationMatrix,
cameraOffset,
new Cesium.Cartesian3()
);
const cameraPosition = Cesium.Matrix4.multiplyByPoint(
transform,
rotatedOffset,
new Cesium.Cartesian3()
);
const viewPitch = uavPitch - 10;
camera.setView({
destination: cameraPosition,
orientation: {
heading: Cesium.Math.toRadians(uavHeading),
pitch: Cesium.Math.toRadians(viewPitch),
roll: 0
}
});
}
private updateCameraPosition(): void {
if (!this.options.enableViewModeToggle) return;
if (!this.isPlaying) return;
try {
const viewer = this.getViewer();
if (!viewer || !this.uavEntity) return;
if (!this.isSceneValid(viewer)) return;
const currentTime = viewer.clock.currentTime;
const position = this.getUAVPositionAtTime(currentTime);
if (!position || !this.isValidCartesian3(position)) {
console.warn('Invalid UAV position in updateCameraPosition');
return;
}
const camera = viewer.scene.camera;
if (!camera) return;
if (this.isFirstPersonView) {
this.setupFirstPersonCamera(camera, position);
}
} catch (error) {
console.warn('Error in updateCameraPosition:', error);
}
}
private isSceneValid(viewer: Cesium.Viewer): boolean {
if (!viewer.scene || !viewer.scene.camera || !viewer.scene.canvas) return false;
const canvas = viewer.scene.canvas;
return canvas.width > 0 && canvas.height > 0;
}
private getUAVPositionAtTime(currentTime: Cesium.JulianDate): Cesium.Cartesian3 | undefined {
if (!this.uavEntity || !this.uavEntity.position) return undefined;
if (typeof this.uavEntity.position.getValue === 'function') {
const result = this.uavEntity.position.getValue(currentTime);
if (result && typeof result === 'object' && 'x' in result && 'y' in result && 'z' in result) {
return result as Cesium.Cartesian3;
}
return undefined;
}
else if ('value' in this.uavEntity.position) {
const value = this.uavEntity.position.value;
if (value && typeof value === 'object' && 'x' in value && 'y' in value && 'z' in value) {
return value as Cesium.Cartesian3;
}
}
return undefined;
}
private isValidCartesian3(position: Cesium.Cartesian3): boolean {
if (!position) return false;
const hasValidCoordinates = (
typeof position.x === 'number' &&
typeof position.y === 'number' &&
typeof position.z === 'number' &&
!isNaN(position.x) &&
!isNaN(position.y) &&
!isNaN(position.z) &&
isFinite(position.x) &&
isFinite(position.y) &&
isFinite(position.z)
);
return hasValidCoordinates;
}
}
export default UAVUtil;