Files
eacp_webapp/src/Map/Cesium_Map.tsx
T
2026-08-09 14:13:34 +08:00

2526 lines
128 KiB
TypeScript

import "cesium/Build/Cesium/Widgets/widgets.css";
import * as Cesium from "cesium";
import React from "react";
import enhance from "../core/enhance.tsx";
import {app} from "../App.tsx";
import {Data_Source} from "../Data_Source/Data_Source.tsx";
import {Aircraft} from "./Aircraft.tsx";
import {Aircraft_Model, Aircraft_Track_Point_Model} from "./Aircraft_Model.tsx";
import {Button, InputNumber, message, Space, Switch, Tooltip} from "antd";
import {AimOutlined, EyeOutlined} from "@ant-design/icons";
import {
imagery_source_for_key,
load_map_resources,
terrain_source_for_key,
url_template_for_y_axis,
type Map_Imagery_Source_Metadata,
type Map_Resources_Metadata,
type Map_Tile_Metadata
} from "./Map_Resources.tsx";
import {Terrarium_Terrain_Provider} from "./Terrarium_Terrain_Provider.tsx";
import {
cesium_graphics_config_event,
default_cesium_graphics_config,
load_cesium_graphics_config,
normalize_cesium_graphics_config,
save_cesium_graphics_config,
type Cesium_Graphics_Config,
type Cesium_Panel_Position
} from "./Cesium_Graphics_Config.ts";
import {
default_map_view_config,
load_map_view_config,
save_map_view_config,
type Map_Camera_View,
type Map_Tile_View_Config,
type Map_View_Config,
type Scene_Mode_Key
} from "./Map_View.tsx";
import {
empty_map_model_config,
load_map_model_config,
map_model_config_event,
type Map_Model_Item_Config,
type Map_Model_Config
} from "./Map_Models.tsx";
import {mark_cesium_webgl_unavailable, webgl_unavailable_message} from "./WebGL_Support.ts";
import {Worker_Pool} from "./Worker_Pool.ts";
import type {Los_Request, Los_Result} from "./Los_Protocol.ts";
import {Cesium_Camera_Control} from "./Cesium_Camera_Control.ts";
import {Camera_Control_Mode} from "./Camera_Control_Mode.ts";
type Aircraft_Entity_Record = {
aircraft: Cesium.Entity
track?: Cesium.Entity
occlusion_line?: Cesium.Entity
occlusion_marker?: Cesium.Entity
occlusion_key: string
occlusion_request_id: number
occlusion_result: Terrain_Occlusion_Result | null
point_collection?: Cesium.PointPrimitiveCollection
point_records: Cesium_Track_Point_Record[]
point_start_index: number
track_positions: Cesium.Cartesian3[]
track_positions_key: string
}
type Cesium_Track_Point_Record = {
primitive?: Cesium.PointPrimitive
entity?: Cesium.Entity
}
type Track_Point_Pick_Record = {
ecap_track_point: Aircraft_Track_Point_Model
ecap_track_key: string
ecap_track_description: string
}
type Terrain_Occlusion_Result = {
obstructed: boolean
position?: Cesium.Cartesian3
line_height?: number
terrain_height?: number
clearance?: number
}
type Base_Station_Entity_Record = {
model: Cesium.Entity
device: Cesium.Entity
label: Cesium.Entity
range_sphere: Cesium.Entity[]
range_geometry_key: string
range_ring: Cesium.Entity
range_marker: Cesium.Entity
}
type Aircraft_Position = {
lon: number
lat: number
alt: number
}
type Aircraft_Sync_Task = {
key: string
aircraft: Aircraft
}
type Performance_Info = {
fps: number
frame_ms: number
js_heap_mb: number | null
gpu_renderer: string
tile_state: string
}
type Cesium_Context_Menu = {
x: number
y: number
aircraft?: Aircraft
base_station?: Data_Source
}
type View_Axis_Point = {
key: string
axis: "x" | "y" | "z"
sign: 1 | -1
label: string
color: string
x: number
y: number
depth: number
}
type Graphics_Panel_Key = "performance" | "viewAxes"
const track_point_model_uri = "/ui/model/track-point.glb";
const base_station_range_ring_count = 6;
const base_station_range_meridian_count = 12;
const base_station_range_segment_count = 96;
const base_station_range_boundary_sample_count = 72;
const base_station_range_ground_clearance = 2;
const default_camera_rectangle = Cesium.Rectangle.fromDegrees(119.98, 36.42, 122.56, 38.40);
const base_station_model_bottom_y = 0.19261999428272247;
const device_model_bottom_y = 4.06771041452885;
export class Cesium_Map extends enhance.Base {
viewer: Cesium.Viewer | null = null;
container_id = "cesium-map";
refresh_timer: number | null = null;
entity_map: Map<string, Aircraft_Entity_Record> = new Map();
base_station_entity_map: Map<string, Base_Station_Entity_Record> = new Map();
selected_aircraft: Aircraft | null = null;
tracking: boolean = false;
mounted: boolean = false;
map_resources: Map_Resources_Metadata | null = null;
terrain_provider: Terrarium_Terrain_Provider | null = null;
occlusion_terrain_provider: Terrarium_Terrain_Provider | null = null;
occlusion_terrain_signature: string = "";
los_worker_pool: Worker_Pool<Los_Request, Los_Result> | null = null;
camera_control: Cesium_Camera_Control | null = null;
map_view_config: Map_View_Config | null = null;
scene_mode: Scene_Mode_Key = "3d";
current_imagery_key: string = "";
graphics_config: Cesium_Graphics_Config = {...default_cesium_graphics_config};
tile_display_level_draft: number | null | undefined = undefined;
displayed_imagery_minimum_level: number | null = null;
displayed_imagery_maximum_level: number | null = null;
displayed_terrain_minimum_level: number | null = null;
displayed_terrain_maximum_level: number | null = null;
previous_camera_view: Map_Camera_View | null = null;
graphics_config_listener: ((event: Event) => void) | null = null;
model_config: Map_Model_Config = {...empty_map_model_config};
model_config_listener: ((event: Event) => void) | null = null;
terrain_signature: string = "";
performance_timer: number | null = null;
sync_request_timer: number | null = null;
sync_queue_timer: number | null = null;
sync_generation = 0;
sync_aircraft_tasks: Aircraft_Sync_Task[] = [];
refreshing_data_sources = false;
pre_render_listener: ((scene: Cesium.Scene, time: Cesium.JulianDate) => void) | null = null;
post_render_listener: (() => void) | null = null;
performance_frame_count = 0;
performance_frame_time_total = 0;
performance_last_render_time = 0;
performance_info: Performance_Info = {fps: 0, frame_ms: 0, js_heap_mb: null, gpu_renderer: "", tile_state: ""};
context_menu: Cesium_Context_Menu | null = null;
track_point_selection_entity: Cesium.Entity | null = null;
prevent_context_menu_listener: ((event: Event) => void) | null = null;
base_station_ground_heights: Map<string, {key: string, height: number}> = new Map();
base_station_ground_height_requests: Set<string> = new Set();
surface_navigation_reference_entity: Cesium.Entity | null = null;
surface_navigation_reference_position_value: Cesium.Cartesian3 | null = null;
view_axes_pointer_move_listener: ((event: PointerEvent) => void) | null = null;
view_axes_pointer_up_listener: ((event: PointerEvent) => void) | null = null;
view_axes_dragging = false;
view_axes_drag_last_x = 0;
view_axes_drag_last_y = 0;
view_axes_drag_total = 0;
view_axes_ignore_next_click = false;
performance_panel_open = true;
view_axes_panel_open = true;
graphics_panel_pointer_move_listener: ((event: PointerEvent) => void) | null = null;
graphics_panel_pointer_up_listener: ((event: PointerEvent) => void) | null = null;
graphics_panel_drag_key: Graphics_Panel_Key | null = null;
graphics_panel_drag_last_x = 0;
graphics_panel_drag_last_y = 0;
max_track_points_per_aircraft = 1000;
constructor(scene_mode: Scene_Mode_Key = "3d") {
super();
this.scene_mode = scene_mode;
}
on_mount() {
this.mounted = true;
app.cesium_map = this;
this.graphics_config_listener = (event: Event) => {
const custom_event = event as CustomEvent<Cesium_Graphics_Config>;
this.graphics_config = normalize_cesium_graphics_config(custom_event.detail);
this.apply_graphics_config();
this.update_terrain_provider();
this.destroy_occlusion_terrain_provider();
this.destroy_los_worker_pool();
this.clear_aircraft_occlusion_results();
this.flush();
};
window.addEventListener(cesium_graphics_config_event, this.graphics_config_listener);
this.model_config_listener = (event: Event) => {
const custom_event = event as CustomEvent<Map_Model_Config>;
this.model_config = custom_event.detail;
this.apply_model_config();
this.flush();
};
window.addEventListener(map_model_config_event, this.model_config_listener);
this.load_map();
this.refresh_timer = window.setInterval(() => {
this.refresh_data_sources();
}, 1000);
this.refresh_data_sources();
}
on_un_mount() {
this.mounted = false;
if (app.cesium_map === this) {
app.cesium_map = null;
}
if (this.refresh_timer) {
window.clearInterval(this.refresh_timer);
this.refresh_timer = null;
}
if (this.graphics_config_listener) {
window.removeEventListener(cesium_graphics_config_event, this.graphics_config_listener);
this.graphics_config_listener = null;
}
if (this.model_config_listener) {
window.removeEventListener(map_model_config_event, this.model_config_listener);
this.model_config_listener = null;
}
if (this.performance_timer) {
window.clearInterval(this.performance_timer);
this.performance_timer = null;
}
if (this.sync_request_timer) {
window.clearTimeout(this.sync_request_timer);
this.sync_request_timer = null;
}
if (this.sync_queue_timer) {
window.clearTimeout(this.sync_queue_timer);
this.sync_queue_timer = null;
}
this.sync_aircraft_tasks = [];
this.sync_generation++;
if (this.viewer && this.post_render_listener) {
this.viewer.scene.postRender.removeEventListener(this.post_render_listener);
this.post_render_listener = null;
}
if (this.viewer && this.pre_render_listener) {
this.viewer.scene.preRender.removeEventListener(this.pre_render_listener);
this.pre_render_listener = null;
}
if (this.viewer && this.prevent_context_menu_listener) {
this.viewer.canvas.removeEventListener("contextmenu", this.prevent_context_menu_listener);
this.prevent_context_menu_listener = null;
}
this.remove_view_axes_drag_listeners();
this.remove_graphics_panel_drag_listeners();
this.destroy_surface_navigation_reference();
this.camera_control?.destroy();
this.camera_control = null;
this.entity_map.clear();
this.base_station_entity_map.clear();
this.terrain_provider?.destroy();
this.terrain_provider = null;
this.occlusion_terrain_provider?.destroy();
this.occlusion_terrain_provider = null;
this.destroy_los_worker_pool();
if (this.viewer) {
this.viewer.destroy();
this.viewer = null;
}
}
list(): Data_Source[] {
if (!app.setting) return [];
return app.setting.data_source_config.list.list;
}
async load_map() {
const [resources, graphics_config, view_config, model_config] = await Promise.all([load_map_resources(), load_cesium_graphics_config(), load_map_view_config(), load_map_model_config()]);
if (!this.mounted) return;
this.map_resources = resources;
this.graphics_config = graphics_config;
this.map_view_config = view_config;
this.model_config = model_config;
this.scene_mode = "3d";
this.current_imagery_key = view_config.map3d.current_imagery_key || resources.current_imagery_key;
Cesium.Ion.defaultAccessToken = "";
const imagery_provider = this.create_imagery_provider(this.current_imagery_resource());
const terrain_provider = this.create_terrain_provider();
let viewer: Cesium.Viewer;
try {
viewer = new Cesium.Viewer(this.container_id, {
baseLayerPicker: false,
geocoder: false,
homeButton: false,
sceneModePicker: false,
timeline: false,
animation: false,
navigationHelpButton: false,
fullscreenButton: false,
infoBox: true,
selectionIndicator: false,
sceneMode: this.to_cesium_scene_mode(this.scene_mode),
baseLayer: new Cesium.ImageryLayer(imagery_provider),
terrainProvider: terrain_provider,
requestRenderMode: true,
maximumRenderTimeChange: Number.POSITIVE_INFINITY
});
}
catch (error) {
this.handle_webgl_failure(error);
return;
}
this.viewer = viewer;
this.camera_control = new Cesium_Camera_Control(this.viewer);
this.viewer.scene.globe.depthTestAgainstTerrain = false;
this.apply_graphics_config();
this.restore_camera_view();
this.viewer.screenSpaceEventHandler.setInputAction((movement: any) => {
this.handle_click(movement.position);
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
this.viewer.screenSpaceEventHandler.setInputAction((movement: any) => {
this.handle_right_click(movement.position);
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK);
this.prevent_context_menu_listener = (event: Event) => event.preventDefault();
this.viewer.canvas.addEventListener("contextmenu", this.prevent_context_menu_listener);
this.viewer.screenSpaceEventHandler.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
this.start_performance_monitor();
this.start_debug_visuals();
this.request_sync_data_sources();
}
handle_webgl_failure(error: unknown) {
this.terrain_provider?.destroy();
this.terrain_provider = null;
this.viewer = null;
mark_cesium_webgl_unavailable();
console.error("Cesium WebGL initialization failed", error);
message.error(webgl_unavailable_message);
window.setTimeout(() => {
window.location.replace(window.location.pathname.startsWith("/ui/") ? "/ui/map" : "/map");
}, 0);
}
current_imagery_resource(): Map_Tile_Metadata {
const source = this.current_imagery_source();
return source.resource;
}
current_imagery_source(): Map_Imagery_Source_Metadata {
return imagery_source_for_key(this.map_resources!, this.current_imagery_key);
}
current_terrain_key(): string {
const key = this.map3d_tile_view().current_terrain_key;
const source = this.map_resources?.terrain_sources.find(item => item.key === key) || this.map_resources?.terrain_sources[0];
return source?.key || key;
}
current_terrain_resource(): Map_Tile_Metadata {
return this.current_terrain_source().resource;
}
current_terrain_source(): Map_Imagery_Source_Metadata {
return terrain_source_for_key(this.map_resources!, this.current_terrain_key());
}
map3d_tile_view(): Map_Tile_View_Config {
return (this.map_view_config || default_map_view_config).map3d;
}
create_imagery_provider(resource: Map_Tile_Metadata): Cesium.UrlTemplateImageryProvider {
const view_config = this.map3d_tile_view();
const display_maximum_level = Math.max(resource.minimum_level, Math.min(view_config.tile_display_maximum_level, 24));
const maximum_level = view_config.tile_zoom_mode === "upscale" ? resource.maximum_level : Math.min(resource.maximum_level, display_maximum_level);
return new Cesium.UrlTemplateImageryProvider({
url: url_template_for_y_axis(resource),
minimumLevel: 0,
maximumLevel: maximum_level,
tileWidth: resource.tile_size,
tileHeight: resource.tile_size,
tilingScheme: new Cesium.WebMercatorTilingScheme(),
credit: resource.credit ? new Cesium.Credit(resource.credit) : undefined
});
}
refresh_imagery_layer() {
if (!this.viewer || !this.map_resources) return;
if (this.viewer.imageryLayers.length > 0) {
this.viewer.imageryLayers.remove(this.viewer.imageryLayers.get(0), true);
}
this.viewer.imageryLayers.addImageryProvider(this.create_imagery_provider(this.current_imagery_resource()), 0);
}
change_imagery_source(key: string) {
this.current_imagery_key = key;
const config = this.map_view_config || default_map_view_config;
this.map_view_config = {...config, current_imagery_key: key, map3d: {...config.map3d, current_imagery_key: key}};
this.refresh_imagery_layer();
this.viewer?.scene.requestRender();
this.flush();
}
change_terrain_source(key: string) {
const config = this.map_view_config || default_map_view_config;
this.map_view_config = {...config, map3d: {...config.map3d, current_terrain_key: key}};
this.update_terrain_provider();
this.destroy_occlusion_terrain_provider();
this.request_sync_data_sources();
this.viewer?.scene.requestRender();
this.flush();
}
change_tile_display_maximum_level(value: number | null) {
this.tile_display_level_draft = value;
if (value === null) {
this.flush();
return;
}
const config = this.map_view_config || default_map_view_config;
this.map_view_config = {...config, map3d: {...config.map3d, tile_display_maximum_level: value}};
this.refresh_imagery_layer();
this.viewer?.scene.requestRender();
this.flush();
}
blur_tile_display_maximum_level() {
if (this.tile_display_level_draft !== undefined) {
this.tile_display_level_draft = undefined;
this.flush();
}
}
base_station_fly_to_height_offset_meters(): number {
return (this.map_view_config || default_map_view_config).baseStationFlyToHeightOffsetMeters;
}
change_base_station_fly_to_height_offset_meters(value: number) {
const config = this.map_view_config || default_map_view_config;
this.map_view_config = {...config, baseStationFlyToHeightOffsetMeters: Math.max(100, value)};
this.flush();
}
set_surface_navigation_reference_visible(value: boolean) {
this.graphics_config = normalize_cesium_graphics_config({...this.graphics_config, surfaceNavigationReferenceVisible: value});
this.apply_graphics_config();
this.flush();
}
set_view_axes_visible(value: boolean) {
this.graphics_config = normalize_cesium_graphics_config({...this.graphics_config, viewAxesVisible: value});
this.apply_graphics_config();
this.flush();
}
change_view_axes_panel_size(value: number) {
this.graphics_config = normalize_cesium_graphics_config({...this.graphics_config, viewAxesPanelSize: value});
this.flush();
}
async save_current_cesium_graphics_config() {
this.graphics_config = await save_cesium_graphics_config(this.graphics_config);
message.success("3D调试显示已保存");
this.flush();
}
async save_current_map_view() {
if (!this.viewer) return;
const config = this.map_view_config || default_map_view_config;
this.map_view_config = await save_map_view_config({
...config,
current_imagery_key: this.current_imagery_key,
scene_mode: "3d",
map3d: {...config.map3d, current_imagery_key: this.current_imagery_key, current_terrain_key: this.current_terrain_key()},
camera: this.current_camera_view()
});
this.current_imagery_key = this.map_view_config.map3d.current_imagery_key;
this.scene_mode = "3d";
this.refresh_imagery_layer();
this.viewer.scene.requestRender();
message.success("地图视图已保存");
this.flush();
}
change_scene_mode(mode: Scene_Mode_Key) {
if (!this.viewer) return;
this.scene_mode = mode;
if (mode !== "3d") {
this.destroy_surface_navigation_reference();
}
this.update_terrain_provider();
if (mode === "2d") {
this.viewer.scene.morphTo2D(0.5);
}
else if (mode === "2.5d") {
this.viewer.scene.morphToColumbusView(0.5);
}
else {
this.viewer.scene.morphTo3D(0.5);
}
this.flush();
}
to_cesium_scene_mode(mode: Scene_Mode_Key): Cesium.SceneMode {
if (mode === "2d") return Cesium.SceneMode.SCENE2D;
if (mode === "2.5d") return Cesium.SceneMode.COLUMBUS_VIEW;
return Cesium.SceneMode.SCENE3D;
}
should_use_terrarium_terrain(): boolean {
return this.scene_mode === "3d" && this.graphics_config.terrainEnabledIn3D && Boolean(this.map_resources?.terrain_sources.length);
}
effective_terrain_maximum_level(): number | undefined {
if (!this.map_resources || !this.graphics_config.terrainLimitMaximumLevel) return undefined;
return Math.min(this.graphics_config.terrainMaximumLevel, this.current_terrain_resource().maximum_level);
}
current_terrain_signature(): string {
if (!this.should_use_terrarium_terrain()) return "ellipsoid";
const terrain = this.current_terrain_resource();
const maximum_level = this.effective_terrain_maximum_level();
return `terrarium:${this.current_terrain_source().key}:${terrain.minimum_level}:${terrain.maximum_level}:${maximum_level === undefined ? "unlimited" : maximum_level}:${this.graphics_config.terrainCacheTiles}`;
}
create_terrain_provider(): Cesium.TerrainProvider {
this.terrain_provider = null;
this.terrain_signature = this.current_terrain_signature();
if (!this.should_use_terrarium_terrain() || !this.map_resources) {
return new Cesium.EllipsoidTerrainProvider();
}
this.terrain_provider = new Terrarium_Terrain_Provider(this.current_terrain_resource(), {
maximum_level: this.effective_terrain_maximum_level(),
max_cached_height_tiles: this.graphics_config.terrainCacheTiles
});
return this.terrain_provider.provider;
}
update_terrain_provider() {
if (!this.viewer || !this.map_resources) return;
const signature = this.current_terrain_signature();
if (signature === this.terrain_signature) return;
const old_terrain_provider = this.terrain_provider;
this.viewer.terrainProvider = this.create_terrain_provider();
if (old_terrain_provider && old_terrain_provider !== this.terrain_provider) {
old_terrain_provider.destroy();
}
this.viewer.scene.requestRender();
}
terrain_sampler(): Terrarium_Terrain_Provider | null {
if (this.terrain_provider) return this.terrain_provider;
if (!this.map_resources) return null;
const signature = this.current_terrain_sample_signature();
if (this.occlusion_terrain_provider && this.occlusion_terrain_signature === signature) return this.occlusion_terrain_provider;
this.destroy_occlusion_terrain_provider();
this.occlusion_terrain_signature = signature;
this.occlusion_terrain_provider = new Terrarium_Terrain_Provider(this.current_terrain_resource(), {
maximum_level: this.effective_terrain_maximum_level(),
max_cached_height_tiles: this.graphics_config.terrainCacheTiles
});
return this.occlusion_terrain_provider;
}
destroy_occlusion_terrain_provider() {
this.occlusion_terrain_provider?.destroy();
this.occlusion_terrain_provider = null;
this.occlusion_terrain_signature = "";
}
los_worker_pool_instance(): Worker_Pool<Los_Request, Los_Result> {
if (!this.los_worker_pool) {
this.los_worker_pool = new Worker_Pool<Los_Request, Los_Result>(() => new Worker(new URL("./Los_Worker.ts", import.meta.url), {type: "module"}), this.los_worker_count());
}
return this.los_worker_pool;
}
destroy_los_worker_pool() {
this.los_worker_pool?.destroy();
this.los_worker_pool = null;
}
los_worker_count(): number {
const cores = typeof navigator === "undefined" ? 4 : navigator.hardwareConcurrency || 4;
return Math.max(1, Math.min(4, cores - 2));
}
effective_los_terrain_level(): number {
const terrain = this.current_terrain_resource();
const maximum_level = this.effective_terrain_maximum_level() ?? terrain.maximum_level;
return Math.max(terrain.minimum_level, Math.min(maximum_level, terrain.maximum_level));
}
apply_graphics_config() {
if (!this.viewer) return;
const config = this.graphics_config;
this.viewer.scene.debugShowFramesPerSecond = false;
this.viewer.targetFrameRate = config.targetFrameRate;
this.viewer.useBrowserRecommendedResolution = config.useBrowserRecommendedResolution;
this.viewer.resolutionScale = config.resolutionScale;
this.viewer.scene.msaaSamples = config.msaaSamples;
this.viewer.scene.postProcessStages.fxaa.enabled = config.fxaa;
this.viewer.shadows = config.shadows;
this.viewer.scene.globe.enableLighting = config.enableLighting || config.solarLighting;
this.viewer.scene.globe.dynamicAtmosphereLighting = config.solarLighting;
this.viewer.scene.globe.dynamicAtmosphereLightingFromSun = config.solarLighting;
this.viewer.scene.globe.atmosphereLightIntensity = config.solarLightIntensity;
this.viewer.scene.light = new Cesium.SunLight({intensity: config.solarLightIntensity});
this.viewer.clock.clockStep = config.solarLighting ? Cesium.ClockStep.SYSTEM_CLOCK : Cesium.ClockStep.SYSTEM_CLOCK_MULTIPLIER;
this.viewer.clock.shouldAnimate = config.solarLighting;
this.viewer.scene.maximumRenderTimeChange = config.solarLighting ? 60 : Number.POSITIVE_INFINITY;
this.viewer.scene.globe.maximumScreenSpaceError = config.maximumScreenSpaceError;
this.viewer.scene.verticalExaggeration = config.terrainExaggeration;
this.viewer.scene.verticalExaggerationRelativeHeight = 0.0;
this.viewer.scene.requestRender();
}
apply_model_config() {
for (const record of this.entity_map.values()) {
if (record.aircraft.model) {
const aircraft = (record.aircraft as any).ecap_aircraft as Aircraft | undefined;
record.aircraft.model.uri = new Cesium.ConstantProperty(aircraft ? this.aircraft_model_item(aircraft).url : this.model_config.aircraftModel.url);
}
}
for (const record of this.base_station_entity_map.values()) {
if (record.model.model) {
record.model.model.uri = new Cesium.ConstantProperty(this.model_config.baseStationModel.url);
}
if (record.device.model) {
record.device.model.uri = new Cesium.ConstantProperty(this.model_config.deviceModel.url);
}
}
this.request_sync_data_sources();
this.viewer?.scene.requestRender();
}
start_debug_visuals() {
if (!this.viewer || this.pre_render_listener) return;
this.pre_render_listener = (scene: Cesium.Scene) => this.update_debug_visuals(scene);
this.viewer.scene.preRender.addEventListener(this.pre_render_listener);
}
update_debug_visuals(scene: Cesium.Scene) {
this.sync_surface_navigation_reference(scene);
if (this.graphics_config.viewAxesVisible && this.view_axes_panel_open) this.flush();
}
sync_surface_navigation_reference(scene: Cesium.Scene) {
if (!this.viewer || !this.graphics_config.surfaceNavigationReferenceVisible || this.camera_control_mode() !== Camera_Control_Mode.Surface_Navigation || this.scene_mode !== "3d" || scene.mode !== Cesium.SceneMode.SCENE3D) {
this.destroy_surface_navigation_reference();
return;
}
const position = this.surface_navigation_reference_position_value;
if (!position) {
if (this.surface_navigation_reference_entity) this.surface_navigation_reference_entity.show = false;
return;
}
const entity = this.ensure_surface_navigation_reference_entity();
entity.show = true;
entity.position = new Cesium.ConstantPositionProperty(position);
if (entity.label) {
entity.label.text = new Cesium.ConstantProperty(this.surface_navigation_reference_text());
}
}
set_surface_navigation_reference_from_screen(screen_position: Cesium.Cartesian2): boolean {
if (!this.viewer || !this.graphics_config.surfaceNavigationReferenceVisible || this.camera_control_mode() !== Camera_Control_Mode.Surface_Navigation) return false;
const ray = this.viewer.camera.getPickRay(screen_position);
const position = ray ? this.viewer.scene.globe.pick(ray, this.viewer.scene) : undefined;
if (!position) return false;
this.surface_navigation_reference_position_value = position;
this.sync_surface_navigation_reference(this.viewer.scene);
this.viewer.scene.requestRender();
this.flush();
return true;
}
ensure_surface_navigation_reference_entity(): Cesium.Entity {
if (this.surface_navigation_reference_entity) return this.surface_navigation_reference_entity;
this.surface_navigation_reference_entity = this.viewer!.entities.add({
id: "ecap:surface-navigation-reference",
name: "地表导航参考点",
point: {
pixelSize: 9,
color: Cesium.Color.CYAN,
outlineColor: Cesium.Color.BLACK,
outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY
},
label: {
text: this.surface_navigation_reference_text(),
font: "12px sans-serif",
pixelOffset: new Cesium.Cartesian2(0, -18),
fillColor: Cesium.Color.CYAN,
outlineColor: Cesium.Color.BLACK,
outlineWidth: 2,
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
disableDepthTestDistance: Number.POSITIVE_INFINITY
}
});
(this.surface_navigation_reference_entity as any).ecap_ignore_pick = true;
return this.surface_navigation_reference_entity;
}
surface_navigation_reference_text(): string {
return this.current_terrain_signature() === "ellipsoid" ? "地表导航参考\n椭球地表" : `地表导航参考\n${this.current_terrain_source().name}`;
}
destroy_surface_navigation_reference() {
if (this.surface_navigation_reference_entity) {
this.viewer?.entities.remove(this.surface_navigation_reference_entity);
this.surface_navigation_reference_entity = null;
}
this.surface_navigation_reference_position_value = null;
}
start_performance_monitor() {
if (!this.viewer || this.performance_timer) return;
this.performance_info.gpu_renderer = this.read_gpu_renderer();
this.post_render_listener = () => this.capture_performance_frame();
this.viewer.scene.postRender.addEventListener(this.post_render_listener);
this.performance_timer = window.setInterval(() => this.flush_performance_info(), 1000);
}
capture_performance_frame() {
const now = performance.now();
if (this.performance_last_render_time > 0) {
this.performance_frame_time_total += now - this.performance_last_render_time;
}
this.performance_last_render_time = now;
this.performance_frame_count++;
}
flush_performance_info() {
this.capture_displayed_tile_levels();
const frame_count = this.performance_frame_count;
const frame_ms = frame_count > 1 ? this.performance_frame_time_total / (frame_count - 1) : 0;
this.performance_info = {
fps: frame_count,
frame_ms,
js_heap_mb: this.read_js_heap_mb(),
gpu_renderer: this.performance_info.gpu_renderer,
tile_state: this.viewer?.scene.globe.tilesLoaded ? "loaded" : "loading"
};
this.performance_frame_count = 0;
this.performance_frame_time_total = 0;
this.flush();
}
capture_displayed_tile_levels() {
const globe = this.viewer?.scene.globe as any;
const tiles = globe?._surface?._tilesToRender;
const terrain_levels: number[] = [];
const imagery_levels: number[] = [];
const imagery_layer = this.viewer && this.viewer.imageryLayers.length > 0 ? this.viewer.imageryLayers.get(0) : undefined;
if (Array.isArray(tiles)) {
for (const tile of tiles) {
const terrain_level = Number(tile?.level ?? tile?._level);
if (Number.isFinite(terrain_level)) {
terrain_levels.push(terrain_level);
}
const imagery_items = tile?.data?.imagery ?? tile?._data?.imagery;
if (!Array.isArray(imagery_items)) continue;
for (const item of imagery_items) {
const imagery = item?.readyImagery ?? item?.loadingImagery;
if (!imagery) continue;
const item_layer = imagery.imageryLayer ?? imagery._imageryLayer;
if (imagery_layer && item_layer && item_layer !== imagery_layer) continue;
const imagery_level = Number(imagery.level ?? imagery._level);
if (Number.isFinite(imagery_level)) {
imagery_levels.push(imagery_level);
}
}
}
}
const terrain_minimum = terrain_levels.length > 0 ? Math.min(...terrain_levels) : null;
const terrain_maximum = terrain_levels.length > 0 ? Math.max(...terrain_levels) : null;
const imagery_minimum = imagery_levels.length > 0 ? Math.min(...imagery_levels) : terrain_minimum;
const imagery_maximum = imagery_levels.length > 0 ? Math.max(...imagery_levels) : terrain_maximum;
const changed = terrain_minimum !== this.displayed_terrain_minimum_level ||
terrain_maximum !== this.displayed_terrain_maximum_level ||
imagery_minimum !== this.displayed_imagery_minimum_level ||
imagery_maximum !== this.displayed_imagery_maximum_level;
this.displayed_terrain_minimum_level = terrain_minimum;
this.displayed_terrain_maximum_level = terrain_maximum;
this.displayed_imagery_minimum_level = imagery_minimum;
this.displayed_imagery_maximum_level = imagery_maximum;
if (changed) {
app.leaflet_map?.data_source_show.flush();
}
}
read_js_heap_mb(): number | null {
const memory = (performance as any).memory;
if (!memory || typeof memory.usedJSHeapSize !== "number") return null;
return memory.usedJSHeapSize / 1024 / 1024;
}
read_gpu_renderer(): string {
if (!this.viewer) return "";
const canvas = this.viewer.canvas;
const gl = (canvas.getContext("webgl2") || canvas.getContext("webgl")) as any;
if (!gl) return "";
const debug = gl.getExtension("WEBGL_debug_renderer_info");
if (debug) {
return String(gl.getParameter(debug.UNMASKED_RENDERER_WEBGL));
}
return String(gl.getParameter(gl.RENDERER));
}
apply_constant_screen_model_size(model: Cesium.ModelGraphics, pixel_size: number, physical_scale: number) {
model.scale = new Cesium.ConstantProperty(physical_scale);
model.minimumPixelSize = new Cesium.ConstantProperty(pixel_size);
model.maximumScale = new Cesium.ConstantProperty(1000000);
}
current_camera_view(): Map_Camera_View {
const camera = this.viewer!.camera;
const position = camera.positionCartographic;
return {
longitude: Cesium.Math.toDegrees(position.longitude),
latitude: Cesium.Math.toDegrees(position.latitude),
height: position.height,
heading: Cesium.Math.toDegrees(camera.heading),
pitch: Cesium.Math.toDegrees(camera.pitch),
roll: Cesium.Math.toDegrees(camera.roll)
};
}
save_previous_camera_view() {
if (!this.viewer) return;
this.previous_camera_view = this.current_camera_view();
}
clear_camera_tracking() {
if (!this.viewer) return;
this.viewer.trackedEntity = undefined;
this.tracking = false;
this.flush();
}
set_camera_control_mode(mode: Camera_Control_Mode) {
if (!this.camera_control) return;
this.clear_camera_tracking();
this.camera_control.set_mode(mode);
if (mode !== Camera_Control_Mode.Surface_Navigation) {
this.destroy_surface_navigation_reference();
}
this.flush();
}
camera_control_mode(): Camera_Control_Mode {
return this.camera_control?.get_mode() || Camera_Control_Mode.Surface_Navigation;
}
center_earth(duration = 0.8) {
if (!this.viewer || this.viewer.scene.mode !== Cesium.SceneMode.SCENE3D) return;
this.save_previous_camera_view();
const camera = this.viewer.camera;
const destination = Cesium.Cartesian3.clone(camera.positionWC);
const current_right = Cesium.Cartesian3.clone(camera.rightWC);
const direction = Cesium.Cartesian3.normalize(Cesium.Cartesian3.negate(destination, new Cesium.Cartesian3()), new Cesium.Cartesian3());
let right = Cesium.Cartesian3.cross(direction, Cesium.Cartesian3.UNIT_Z, new Cesium.Cartesian3());
if (Cesium.Cartesian3.magnitudeSquared(right) < Cesium.Math.EPSILON12) {
const projection = Cesium.Cartesian3.multiplyByScalar(direction, Cesium.Cartesian3.dot(current_right, direction), new Cesium.Cartesian3());
right = Cesium.Cartesian3.subtract(current_right, projection, right);
}
Cesium.Cartesian3.normalize(right, right);
const up = Cesium.Cartesian3.normalize(Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3()), new Cesium.Cartesian3());
this.clear_camera_tracking();
camera.cancelFlight();
camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
camera.flyTo({destination, orientation: {direction, up}, duration});
}
look_straight_down(duration = 0.8) {
if (!this.viewer || this.viewer.scene.mode !== Cesium.SceneMode.SCENE3D) return;
this.save_previous_camera_view();
const camera = this.viewer.camera;
const destination = Cesium.Cartesian3.clone(camera.positionWC);
this.clear_camera_tracking();
camera.cancelFlight();
camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
camera.flyTo({
destination,
orientation: {
heading: 0,
pitch: -Cesium.Math.PI_OVER_TWO,
roll: 0
},
duration
});
}
north_up(duration = 0.8) {
if (!this.viewer || this.viewer.scene.mode !== Cesium.SceneMode.SCENE3D) return;
this.save_previous_camera_view();
const camera = this.viewer.camera;
const destination = Cesium.Cartesian3.clone(camera.positionWC);
const pitch = camera.pitch;
this.clear_camera_tracking();
camera.cancelFlight();
camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
camera.flyTo({destination, orientation: {heading: 0, pitch, roll: 0}, duration});
}
restore_previous_view(duration = 0.8) {
if (!this.viewer || !this.previous_camera_view) return;
const camera = this.previous_camera_view;
this.clear_camera_tracking();
this.viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(camera.longitude, camera.latitude, camera.height),
orientation: {
heading: Cesium.Math.toRadians(camera.heading),
pitch: Cesium.Math.toRadians(camera.pitch),
roll: Cesium.Math.toRadians(camera.roll)
},
duration
});
}
fly_to_global_view(duration = 0.8) {
if (!this.viewer) return;
this.save_previous_camera_view();
this.clear_camera_tracking();
this.viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(0, 0, 25000000),
orientation: {
heading: 0,
pitch: -Cesium.Math.PI_OVER_TWO,
roll: 0
},
duration
});
}
restore_camera_view() {
if (!this.viewer) return;
const camera = this.map_view_config?.camera;
if (camera) {
this.viewer.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(camera.longitude, camera.latitude, camera.height),
orientation: {
heading: Cesium.Math.toRadians(camera.heading),
pitch: Cesium.Math.toRadians(camera.pitch),
roll: Cesium.Math.toRadians(camera.roll)
}
});
return;
}
this.viewer.camera.flyTo({destination: default_camera_rectangle});
}
async refresh_data_sources() {
if (!this.viewer || this.refreshing_data_sources) return;
this.refreshing_data_sources = true;
try {
for (const ds of this.list()) {
if (!ds.enable) continue;
await ds.refresh();
}
} finally {
this.refreshing_data_sources = false;
}
this.request_sync_data_sources();
}
request_sync_data_sources() {
if (!this.viewer || this.sync_request_timer) return;
this.sync_request_timer = window.setTimeout(() => {
this.sync_request_timer = null;
this.sync_data_sources();
}, 16);
}
sync_data_sources() {
if (!this.viewer) return;
if (this.sync_queue_timer) {
window.clearTimeout(this.sync_queue_timer);
this.sync_queue_timer = null;
}
const generation = ++this.sync_generation;
const alive = new Set<string>();
const alive_base_stations = new Set<string>();
const tasks: Aircraft_Sync_Task[] = [];
for (const ds of this.list()) {
if (!ds.enable) continue;
this.sync_base_station(ds, alive_base_stations);
if (!ds.map3d_style().aircraft_show) continue;
for (const [icao, aircraft] of ds.aircraftMap) {
const key = this.entity_key(ds.key, icao);
alive.add(key);
tasks.push({key, aircraft});
}
}
this.sync_aircraft_tasks = tasks;
for (const [key, record] of this.entity_map) {
if (alive.has(key)) continue;
this.remove_aircraft_record(record);
this.entity_map.delete(key);
if (this.selected_aircraft && key === this.entity_key(this.selected_aircraft.data_source.key, this.selected_aircraft.icao)) {
this.clear_selected_aircraft();
}
}
for (const [key, record] of this.base_station_entity_map) {
if (alive_base_stations.has(key)) continue;
this.remove_base_station_record(record);
this.base_station_entity_map.delete(key);
}
this.process_aircraft_sync_queue(generation);
}
process_aircraft_sync_queue(generation: number) {
if (!this.viewer || generation !== this.sync_generation) return;
const start = performance.now();
let count = 0;
while (this.sync_aircraft_tasks.length > 0 && count < 10 && (count === 0 || performance.now() - start < 8)) {
const task = this.sync_aircraft_tasks.shift()!;
this.sync_aircraft(task.key, task.aircraft);
count++;
}
if (this.sync_aircraft_tasks.length > 0) {
this.sync_queue_timer = window.setTimeout(() => {
this.sync_queue_timer = null;
this.process_aircraft_sync_queue(generation);
}, 180);
return;
}
this.viewer.scene.requestRender();
}
sync_base_station(ds: Data_Source, alive_base_stations: Set<string>) {
if (!this.viewer) return;
const station = ds.base_station;
const key = ds.key;
const style = ds.map3d_style();
if (!style.base_station_show || !ds.base_station_has_valid_position || !station.pos) {
const old_record = this.base_station_entity_map.get(key);
if (old_record) {
this.remove_base_station_record(old_record);
this.base_station_entity_map.delete(key);
}
return;
}
alive_base_stations.add(key);
const heights = this.base_station_heights(ds);
const ground_position = Cesium.Cartesian3.fromDegrees(station.pos.lng, station.pos.lat, heights.ground_height);
const device_position = Cesium.Cartesian3.fromDegrees(station.pos.lng, station.pos.lat, heights.device_height);
const orientation = this.base_station_orientation(ground_position);
const device_orientation = this.model_orientation(device_position, this.model_config.deviceModel);
const color = Cesium.Color.fromCssColorString(style.base_station_color || style.color);
let record = this.base_station_entity_map.get(key);
if (!record) {
record = this.create_base_station_record(ds, ground_position, orientation, device_position, device_orientation, color);
this.base_station_entity_map.set(key, record);
} else {
record.model.position = new Cesium.ConstantPositionProperty(ground_position);
record.model.orientation = new Cesium.ConstantProperty(orientation);
record.model.description = new Cesium.ConstantProperty(this.base_station_visual_description(ds));
record.device.position = new Cesium.ConstantPositionProperty(device_position);
record.device.orientation = new Cesium.ConstantProperty(device_orientation);
record.device.description = new Cesium.ConstantProperty(this.device_description(ds));
record.label.position = new Cesium.ConstantPositionProperty(device_position);
record.label.description = new Cesium.ConstantProperty(this.base_station_description(ds));
}
this.apply_base_station_style(record, ds, color);
}
create_base_station_record(ds: Data_Source, ground_position: Cesium.Cartesian3, orientation: Cesium.Quaternion, device_position: Cesium.Cartesian3, device_orientation: Cesium.Quaternion, color: Cesium.Color): Base_Station_Entity_Record {
const model = this.viewer!.entities.add({
id: `${ds.key}:base-station`,
name: `${ds.key} 基站`,
position: ground_position,
orientation,
model: {
uri: this.model_config.baseStationModel.url,
scale: this.base_station_physical_scale(ds),
minimumPixelSize: 0,
nodeTransformations: this.base_station_node_transformations(ds),
enableVerticalExaggeration: false
},
description: this.base_station_visual_description(ds)
});
const device = this.viewer!.entities.add({
id: `${ds.key}:device`,
name: `${ds.key} 设备`,
position: device_position,
orientation: device_orientation,
model: {
uri: this.model_config.deviceModel.url,
minimumPixelSize: this.device_display_size(ds),
maximumScale: 500,
nodeTransformations: this.device_node_transformations(),
enableVerticalExaggeration: false
},
description: this.device_description(ds)
});
const label = this.viewer!.entities.add({
id: `${ds.key}:base-station-label`,
name: `${ds.key} 基站名称`,
position: device_position,
label: {
text: ds.key,
font: "13px sans-serif",
pixelOffset: new Cesium.Cartesian2(0, -28),
fillColor: color,
outlineColor: Cesium.Color.BLACK,
outlineWidth: 2,
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
disableDepthTestDistance: Number.POSITIVE_INFINITY
},
description: this.base_station_description(ds)
});
const range_sphere = this.create_base_station_range_entities(ds, device_position, color);
const range_ring = this.viewer!.entities.add({
id: `${ds.key}:base-station-range-ring`,
name: `${ds.key} 地表交线`,
show: false,
polyline: {
positions: [ground_position, ground_position],
width: 2,
material: color.withAlpha(0.95),
arcType: Cesium.ArcType.NONE
}
});
const range_marker = this.viewer!.entities.add({
id: `${ds.key}:base-station-range-marker`,
name: `${ds.key} 当前最远飞机`,
show: false,
position: device_position,
point: {
pixelSize: 10,
color: Cesium.Color.ORANGE,
outlineColor: Cesium.Color.BLACK,
outlineWidth: 2
},
label: {
text: "",
font: "13px sans-serif",
pixelOffset: new Cesium.Cartesian2(0, -18),
fillColor: Cesium.Color.ORANGE,
outlineColor: Cesium.Color.BLACK,
outlineWidth: 2,
style: Cesium.LabelStyle.FILL_AND_OUTLINE
},
description: this.base_station_description(ds)
});
(model as any).ecap_base_station = ds;
(device as any).ecap_base_station = ds;
(label as any).ecap_ignore_pick = true;
for (const entity of range_sphere) {
(entity as any).ecap_ignore_pick = true;
}
(range_ring as any).ecap_ignore_pick = true;
(range_marker as any).ecap_ignore_pick = true;
return {model, device, label, range_sphere, range_geometry_key: "", range_ring, range_marker};
}
create_base_station_range_entities(ds: Data_Source, position: Cesium.Cartesian3, color: Cesium.Color): Cesium.Entity[] {
const entities: Cesium.Entity[] = [];
const placeholder = [position, position];
for (let index = 0; index < base_station_range_ring_count; index++) {
entities.push(this.viewer!.entities.add({
id: `${ds.key}:base-station-range-sphere-ring-${index}`,
name: `${ds.key} 探测范围纬线`,
show: false,
polyline: {
positions: placeholder,
width: 1,
material: color.withAlpha(0.75),
arcType: Cesium.ArcType.NONE
}
}));
}
for (let index = 0; index < base_station_range_meridian_count; index++) {
entities.push(this.viewer!.entities.add({
id: `${ds.key}:base-station-range-sphere-meridian-${index}`,
name: `${ds.key} 探测范围经线`,
show: false,
polyline: {
positions: placeholder,
width: 1,
material: color.withAlpha(0.75),
arcType: Cesium.ArcType.NONE
}
}));
}
return entities;
}
apply_base_station_style(record: Base_Station_Entity_Record, ds: Data_Source, color: Cesium.Color) {
const station = ds.base_station;
const radius = Math.max(0, station.current_max_distance || 0);
const style = ds.map3d_style();
if (record.model.model) {
record.model.model.scale = new Cesium.ConstantProperty(this.base_station_physical_scale(ds));
record.model.model.minimumPixelSize = new Cesium.ConstantProperty(0);
record.model.model.nodeTransformations = new Cesium.PropertyBag(this.base_station_node_transformations(ds));
this.keep_model_original_color(record.model.model);
record.model.model.enableVerticalExaggeration = new Cesium.ConstantProperty(false);
}
if (record.device.model) {
if (style.constant_screen_size) {
this.apply_constant_screen_model_size(record.device.model, this.device_display_size(ds), this.device_physical_scale(ds));
} else {
record.device.model.scale = new Cesium.ConstantProperty(this.device_physical_scale(ds));
record.device.model.minimumPixelSize = new Cesium.ConstantProperty(0);
record.device.model.maximumScale = new Cesium.ConstantProperty(500);
}
record.device.model.nodeTransformations = new Cesium.PropertyBag(this.device_node_transformations());
this.keep_model_original_color(record.device.model);
record.device.model.enableVerticalExaggeration = new Cesium.ConstantProperty(false);
}
if (record.label.label) {
record.label.label.text = new Cesium.ConstantProperty(ds.key);
record.label.label.fillColor = new Cesium.ConstantProperty(color);
}
this.update_base_station_range_geometry(record, ds, color, radius);
record.range_marker.show = Boolean(station.current_max_plane_pos && radius > 0);
if (station.current_max_plane_pos) {
record.range_marker.position = new Cesium.ConstantPositionProperty(Cesium.Cartesian3.fromDegrees(station.current_max_plane_pos.lng, station.current_max_plane_pos.lat, this.base_station_heights(ds).station_height));
record.range_marker.description = new Cesium.ConstantProperty(this.base_station_description(ds));
}
if (record.range_marker.label) {
record.range_marker.label.text = new Cesium.ConstantProperty(station.current_max_aircraft_icao ? `最远 ${station.current_max_aircraft_icao} ${(radius / 1000).toFixed(2)} km` : `最远 ${(radius / 1000).toFixed(2)} km`);
}
}
update_base_station_range_geometry(record: Base_Station_Entity_Record, ds: Data_Source, color: Cesium.Color, radius: number) {
const station = ds.base_station;
const show = radius > 0 && Boolean(station.pos);
for (const entity of record.range_sphere) {
entity.show = show;
}
record.range_ring.show = show;
if (!show) {
record.range_geometry_key = "";
return;
}
const key = this.base_station_range_geometry_key(ds, radius);
if (record.range_geometry_key === key) return;
const heights = this.base_station_heights(ds);
const geometry = this.base_station_range_cap_geometry(station.pos!.lng, station.pos!.lat, heights.station_height, heights.ground_height, radius);
const material = new Cesium.ColorMaterialProperty(color.withAlpha(0.75));
for (let index = 0; index < record.range_sphere.length; index++) {
const polyline = record.range_sphere[index].polyline;
if (!polyline) continue;
polyline.positions = new Cesium.ConstantProperty(geometry.cap_positions[index]);
polyline.material = material;
polyline.width = new Cesium.ConstantProperty(1);
}
if (record.range_ring.polyline) {
record.range_ring.polyline.positions = new Cesium.ConstantProperty(geometry.ground_ring_positions);
record.range_ring.polyline.material = new Cesium.ColorMaterialProperty(color.withAlpha(0.95));
record.range_ring.polyline.width = new Cesium.ConstantProperty(2);
}
record.range_geometry_key = key;
}
base_station_range_geometry_key(ds: Data_Source, radius: number): string {
const pos = ds.base_station.pos!;
const style = ds.map3d_style();
const heights = this.base_station_heights(ds);
return `${pos.lng.toFixed(7)}:${pos.lat.toFixed(7)}:${heights.station_height.toFixed(2)}:${heights.ground_height.toFixed(2)}:${radius.toFixed(2)}:${style.base_station_color || style.color}`;
}
base_station_range_cap_geometry(lng: number, lat: number, station_height: number, ground_height: number, ground_radius: number): {cap_positions: Cesium.Cartesian3[][], ground_ring_positions: Cesium.Cartesian3[]} {
const center = Cesium.Cartesian3.fromDegrees(lng, lat, station_height);
const transform = Cesium.Transforms.eastNorthUpToFixedFrame(center);
const height = Math.max(0, station_height - ground_height);
const sphere_radius = Math.sqrt(ground_radius * ground_radius + height * height);
const boundary_thetas: number[] = [];
let theta_max = Math.PI;
for (let index = 0; index < base_station_range_boundary_sample_count; index++) {
const phi = Cesium.Math.TWO_PI * index / base_station_range_boundary_sample_count;
const theta = this.base_station_range_boundary_theta(transform, sphere_radius, ground_height, phi);
boundary_thetas.push(theta);
theta_max = Math.min(theta_max, theta);
}
const cap_positions: Cesium.Cartesian3[][] = [];
const ground_ring_positions: Cesium.Cartesian3[] = [];
for (let index = 1; index <= base_station_range_ring_count; index++) {
const theta = theta_max * index / base_station_range_ring_count;
cap_positions.push(this.base_station_range_ring_positions(transform, sphere_radius, theta));
}
for (let index = 0; index < base_station_range_meridian_count; index++) {
const phi = Cesium.Math.TWO_PI * index / base_station_range_meridian_count;
const boundary_index = Math.round(index * base_station_range_boundary_sample_count / base_station_range_meridian_count) % base_station_range_boundary_sample_count;
cap_positions.push(this.base_station_range_meridian_positions(transform, sphere_radius, boundary_thetas[boundary_index], phi));
}
for (let index = 0; index <= base_station_range_boundary_sample_count; index++) {
const boundary_index = index % base_station_range_boundary_sample_count;
const phi = Cesium.Math.TWO_PI * boundary_index / base_station_range_boundary_sample_count;
ground_ring_positions.push(this.base_station_range_surface_position(transform, sphere_radius, ground_height, boundary_thetas[boundary_index], phi));
}
return {cap_positions, ground_ring_positions};
}
base_station_range_boundary_theta(transform: Cesium.Matrix4, sphere_radius: number, ground_height: number, phi: number): number {
const bottom_height = Cesium.Cartographic.fromCartesian(this.base_station_range_sphere_position(transform, sphere_radius, Math.PI, phi)).height;
if (bottom_height >= ground_height + base_station_range_ground_clearance) return Math.PI;
let low = 0;
let high = Math.PI;
for (let index = 0; index < 32; index++) {
const mid = (low + high) * 0.5;
const height = Cesium.Cartographic.fromCartesian(this.base_station_range_sphere_position(transform, sphere_radius, mid, phi)).height;
if (height >= ground_height + base_station_range_ground_clearance) {
low = mid;
} else {
high = mid;
}
}
return low;
}
base_station_range_ring_positions(transform: Cesium.Matrix4, sphere_radius: number, theta: number): Cesium.Cartesian3[] {
const positions: Cesium.Cartesian3[] = [];
const sin_theta = Math.sin(theta);
const z = sphere_radius * Math.cos(theta);
for (let index = 0; index <= base_station_range_segment_count; index++) {
const phi = Cesium.Math.TWO_PI * index / base_station_range_segment_count;
positions.push(this.base_station_range_world_position(transform, sphere_radius * sin_theta * Math.cos(phi), sphere_radius * sin_theta * Math.sin(phi), z));
}
return positions;
}
base_station_range_meridian_positions(transform: Cesium.Matrix4, sphere_radius: number, theta_max: number, phi: number): Cesium.Cartesian3[] {
const positions: Cesium.Cartesian3[] = [];
for (let index = 0; index <= base_station_range_segment_count; index++) {
const theta = theta_max * index / base_station_range_segment_count;
const sin_theta = Math.sin(theta);
positions.push(this.base_station_range_world_position(transform, sphere_radius * sin_theta * Math.cos(phi), sphere_radius * sin_theta * Math.sin(phi), sphere_radius * Math.cos(theta)));
}
return positions;
}
base_station_range_surface_position(transform: Cesium.Matrix4, sphere_radius: number, ground_height: number, theta: number, phi: number): Cesium.Cartesian3 {
const point = this.base_station_range_sphere_position(transform, sphere_radius, theta, phi);
const cartographic = Cesium.Cartographic.fromCartesian(point);
return Cesium.Cartesian3.fromRadians(cartographic.longitude, cartographic.latitude, ground_height + base_station_range_ground_clearance);
}
base_station_range_sphere_position(transform: Cesium.Matrix4, sphere_radius: number, theta: number, phi: number): Cesium.Cartesian3 {
const sin_theta = Math.sin(theta);
return this.base_station_range_world_position(transform, sphere_radius * sin_theta * Math.cos(phi), sphere_radius * sin_theta * Math.sin(phi), sphere_radius * Math.cos(theta));
}
base_station_range_world_position(transform: Cesium.Matrix4, x: number, y: number, z: number): Cesium.Cartesian3 {
return Cesium.Matrix4.multiplyByPoint(transform, new Cesium.Cartesian3(x, y, z), new Cesium.Cartesian3());
}
remove_base_station_record(record: Base_Station_Entity_Record) {
this.viewer?.entities.remove(record.model);
this.viewer?.entities.remove(record.device);
this.viewer?.entities.remove(record.label);
for (const entity of record.range_sphere) {
this.viewer?.entities.remove(entity);
}
this.viewer?.entities.remove(record.range_ring);
this.viewer?.entities.remove(record.range_marker);
}
base_station_heights(ds: Data_Source): {ground_height: number, station_height: number, device_height: number, antenna_height: number} {
const ground_height = this.base_station_ground_height(ds);
const configured_height = Number.isFinite(ds.alt) ? ds.alt : ground_height;
const station_height = Math.max(ground_height, configured_height);
return {
ground_height,
station_height,
device_height: station_height,
antenna_height: station_height - ground_height
};
}
base_station_ground_height(ds: Data_Source): number {
const key = this.base_station_ground_height_key(ds);
const cached = this.base_station_ground_heights.get(ds.key);
this.schedule_base_station_ground_height(ds, key);
return cached && cached.key === key ? cached.height : 0;
}
base_station_ground_height_key(ds: Data_Source): string {
const pos = ds.base_station.pos;
const terrain_key = this.current_terrain_sample_signature();
return `${pos?.lng.toFixed(7) || ""}:${pos?.lat.toFixed(7) || ""}:${terrain_key}`;
}
schedule_base_station_ground_height(ds: Data_Source, key: string) {
const station = ds.base_station;
if (!station.pos || this.base_station_ground_height_requests.has(`${ds.key}:${key}`)) return;
const sampler = this.terrain_sampler();
if (!sampler) return;
const request_key = `${ds.key}:${key}`;
this.base_station_ground_height_requests.add(request_key);
sampler.sample_height(Cesium.Cartographic.fromDegrees(station.pos.lng, station.pos.lat)).then(height => {
this.base_station_ground_height_requests.delete(request_key);
if (height === undefined) return;
this.base_station_ground_heights.set(ds.key, {key, height});
this.request_sync_data_sources();
}).catch(() => this.base_station_ground_height_requests.delete(request_key));
}
current_terrain_sample_signature(): string {
if (!this.map_resources) return "none";
const terrain = this.current_terrain_resource();
return `${this.current_terrain_source().key}:${terrain.minimum_level}:${terrain.maximum_level}:${this.effective_terrain_maximum_level() ?? "unlimited"}:${this.graphics_config.terrainCacheTiles}`;
}
base_station_description(ds: Data_Source) {
const station = ds.base_station;
const heights = this.base_station_heights(ds);
return [
`数据源: ${ds.key}`,
`纬度: ${station.pos ? station.pos.lat.toFixed(6) : ""}`,
`经度: ${station.pos ? station.pos.lng.toFixed(6) : ""}`,
`设置高度: ${ds.alt || 0}`,
`地形高程: ${heights.ground_height.toFixed(1)} m`,
`基站架设高度: ${heights.antenna_height.toFixed(1)} m`,
`有效位置: ${ds.base_station_has_valid_position ? "是" : "否"}`,
`理论探测范围: ${(ds.base_station_adsb_range_meters / 1000).toFixed(2)} km`,
`理论目标高度: ${ds.base_station_adsb_target_altitude_meters.toFixed(0)} m`,
`当前最远飞机: ${station.current_max_aircraft_icao || ""}`,
`当前最远距离: ${(station.current_max_distance / 1000).toFixed(2)} km`,
`保持最大距离: ${(station.maxDistance / 1000).toFixed(2)} km`
].join("<br>");
}
base_station_visual_description(ds: Data_Source) {
return [
`数据源: ${ds.key}`,
"用途: 基站底座显示",
"完整基站信息绑定在设备模型上"
].join("<br>");
}
device_description(ds: Data_Source) {
const station = ds.base_station;
const heights = this.base_station_heights(ds);
return [
`数据源: ${ds.key}`,
`设备纬度: ${station.pos ? station.pos.lat.toFixed(6) : ""}`,
`设备经度: ${station.pos ? station.pos.lng.toFixed(6) : ""}`,
`设备高度: ${heights.device_height.toFixed(1)} m`,
this.base_station_description(ds)
].join("<br>");
}
base_station_physical_scale(ds: Data_Source): number {
return Math.max(0.000001, this.base_station_heights(ds).antenna_height / Math.max(0.000001, this.model_config.baseStationModel.builtInSize));
}
base_station_node_transformations(ds: Data_Source): {[key: string]: Cesium.TranslationRotationScale} {
const horizontal_scale = Math.max(0.000001, ds.map3d_style().base_station_scale || 1);
return {
Node: new Cesium.TranslationRotationScale(new Cesium.Cartesian3(0, -base_station_model_bottom_y, 0), Cesium.Quaternion.IDENTITY, new Cesium.Cartesian3(horizontal_scale, 1, horizontal_scale))
};
}
device_node_transformations(): {[key: string]: Cesium.TranslationRotationScale} {
return {
RootNode: new Cesium.TranslationRotationScale(new Cesium.Cartesian3(0, -device_model_bottom_y, 0), Cesium.Quaternion.IDENTITY, new Cesium.Cartesian3(1, 1, 1))
};
}
device_display_size(ds: Data_Source): number {
return Math.max(1, this.model_config.deviceModel.builtInSize * (ds.map3d_style().base_station_scale || 1));
}
device_physical_scale(ds: Data_Source): number {
return this.model_config.deviceModel.builtInSize * (ds.map3d_style().base_station_scale || 1) * 0.000001;
}
base_station_orientation(position: Cesium.Cartesian3): Cesium.Quaternion {
return this.model_orientation(position, this.model_config.baseStationModel);
}
model_orientation(position: Cesium.Cartesian3, model: Map_Model_Item_Config): Cesium.Quaternion {
return Cesium.Transforms.headingPitchRollQuaternion(
position,
new Cesium.HeadingPitchRoll(
Cesium.Math.toRadians(model.headingOffsetDegrees),
Cesium.Math.toRadians(model.pitchOffsetDegrees),
Cesium.Math.toRadians(model.rollOffsetDegrees)
)
);
}
sync_aircraft(key: string, aircraft: Aircraft) {
if (!this.viewer) return;
const model = aircraft.data_model;
let record = this.entity_map.get(key);
const aircraft_position = this.aircraft_position(aircraft);
const position = Cesium.Cartesian3.fromDegrees(aircraft_position.lon, aircraft_position.lat, aircraft_position.alt);
const orientation = this.aircraft_orientation(aircraft, position);
if (!record) {
record = this.create_aircraft_record(key, aircraft, position, orientation);
this.entity_map.set(key, record);
} else {
record.aircraft.position = new Cesium.ConstantPositionProperty(position);
record.aircraft.orientation = new Cesium.ConstantProperty(orientation);
record.aircraft.description = new Cesium.ConstantProperty(this.aircraft_description(model));
}
this.apply_aircraft_style(record, aircraft);
if (this.should_show_aircraft_track(key, aircraft)) {
this.sync_track_points(key, aircraft, record);
} else {
this.hide_track(record);
}
this.update_aircraft_occlusion(key, aircraft, record, position);
}
create_aircraft_record(key: string, aircraft: Aircraft, position: Cesium.Cartesian3, orientation: Cesium.Quaternion): Aircraft_Entity_Record {
const model = aircraft.data_model;
const track_positions: Cesium.Cartesian3[] = [];
const aircraft_entity = this.viewer!.entities.add({
id: `${key}:aircraft`,
name: model.icao,
position,
orientation,
model: {
uri: this.aircraft_model_item(aircraft).url,
minimumPixelSize: this.aircraft_display_size(aircraft),
maximumScale: 500,
enableVerticalExaggeration: false
},
label: {
show: this.aircraft_label_enabled(aircraft),
text: this.aircraft_label_text(aircraft),
font: "13px sans-serif",
pixelOffset: new Cesium.Cartesian2(0, -28),
fillColor: this.aircraft_color(aircraft),
outlineColor: Cesium.Color.BLACK,
outlineWidth: 2,
style: Cesium.LabelStyle.FILL_AND_OUTLINE
},
description: this.aircraft_description(model)
});
(aircraft_entity as any).ecap_aircraft = aircraft;
return {aircraft: aircraft_entity, occlusion_key: "", occlusion_request_id: 0, occlusion_result: null, point_records: [], point_start_index: 0, track_positions, track_positions_key: ""};
}
ensure_aircraft_track_entities(key: string, aircraft: Aircraft, record: Aircraft_Entity_Record) {
if (!record.track) {
record.track = this.viewer!.entities.add({
id: `${key}:track`,
show: false,
polyline: {
positions: new Cesium.CallbackProperty(() => record.track_positions, true),
width: 2,
material: Cesium.Color.fromCssColorString(aircraft.data_source.map3d_style().color).withAlpha(0.8)
}
});
}
if (!record.point_collection) {
record.point_collection = this.viewer!.scene.primitives.add(new Cesium.PointPrimitiveCollection()) as Cesium.PointPrimitiveCollection;
record.point_collection.show = false;
}
}
ensure_aircraft_occlusion_entities(key: string, aircraft: Aircraft, record: Aircraft_Entity_Record, position: Cesium.Cartesian3) {
if (!record.occlusion_line) {
record.occlusion_line = this.viewer!.entities.add({
id: `${key}:occlusion-line`,
name: `${aircraft.icao} 地形通视线`,
show: false,
polyline: {
positions: [position, position],
width: 2,
material: Cesium.Color.YELLOW.withAlpha(0.85),
arcType: Cesium.ArcType.NONE
}
});
(record.occlusion_line as any).ecap_ignore_pick = true;
}
if (!record.occlusion_marker) {
record.occlusion_marker = this.viewer!.entities.add({
id: `${key}:occlusion-marker`,
name: `${aircraft.icao} 首次遮挡点`,
show: false,
point: {
pixelSize: 9,
color: Cesium.Color.RED,
outlineColor: Cesium.Color.BLACK,
outlineWidth: 2
},
label: {
text: "遮挡",
font: "12px sans-serif",
pixelOffset: new Cesium.Cartesian2(0, -16),
fillColor: Cesium.Color.RED,
outlineColor: Cesium.Color.BLACK,
outlineWidth: 2,
style: Cesium.LabelStyle.FILL_AND_OUTLINE
}
});
(record.occlusion_marker as any).ecap_ignore_pick = true;
}
}
remove_aircraft_record(record: Aircraft_Entity_Record) {
this.viewer!.entities.remove(record.aircraft);
if (record.track) this.viewer!.entities.remove(record.track);
if (record.occlusion_line) this.viewer!.entities.remove(record.occlusion_line);
if (record.occlusion_marker) this.viewer!.entities.remove(record.occlusion_marker);
this.clear_track_point_records(record);
if (record.point_collection) this.viewer!.scene.primitives.remove(record.point_collection);
}
clear_aircraft_occlusion_results() {
for (const record of this.entity_map.values()) {
this.hide_aircraft_occlusion(record);
}
}
hide_aircraft_occlusion(record: Aircraft_Entity_Record) {
if (!record.occlusion_key && !record.occlusion_result && !record.occlusion_line?.show && !record.occlusion_marker?.show) return;
record.occlusion_key = "";
record.occlusion_result = null;
record.occlusion_request_id++;
if (record.occlusion_line) record.occlusion_line.show = false;
if (record.occlusion_marker) record.occlusion_marker.show = false;
if (record.aircraft.model) {
const aircraft = (record.aircraft as any).ecap_aircraft as Aircraft | undefined;
if (aircraft) this.apply_aircraft_style(record, aircraft);
else record.aircraft.model.silhouetteSize = new Cesium.ConstantProperty(0);
}
}
update_aircraft_occlusion(key: string, aircraft: Aircraft, record: Aircraft_Entity_Record, aircraft_position: Cesium.Cartesian3) {
if (!this.should_check_aircraft_occlusion(aircraft)) {
this.hide_aircraft_occlusion(record);
return;
}
const station_position = this.aircraft_station_position(aircraft);
if (!station_position) {
this.hide_aircraft_occlusion(record);
return;
}
this.ensure_aircraft_occlusion_entities(key, aircraft, record, aircraft_position);
const next_key = this.aircraft_occlusion_key(aircraft, station_position, aircraft_position);
if (record.occlusion_key !== next_key) {
record.occlusion_key = next_key;
record.occlusion_result = null;
record.occlusion_request_id++;
const request_id = record.occlusion_request_id;
this.test_terrain_occlusion(station_position, aircraft_position).then(result => {
if (record.occlusion_request_id !== request_id || record.occlusion_key !== next_key) return;
record.occlusion_result = result;
this.apply_aircraft_occlusion_visual(aircraft, record, station_position, aircraft_position);
}).catch(() => {
if (record.occlusion_request_id !== request_id || record.occlusion_key !== next_key) return;
record.occlusion_result = {obstructed: false};
this.apply_aircraft_occlusion_visual(aircraft, record, station_position, aircraft_position);
});
}
this.apply_aircraft_occlusion_visual(aircraft, record, station_position, aircraft_position);
}
should_check_aircraft_occlusion(aircraft: Aircraft): boolean {
if (!this.graphics_config.terrainOcclusionEnabled) return false;
if (this.selected_aircraft === aircraft) return true;
return aircraft.data_source.is_manual_tracking_aircraft(aircraft.icao);
}
aircraft_station_position(aircraft: Aircraft): Cesium.Cartesian3 | null {
const ds = aircraft.data_source;
const station = ds.base_station;
if (!ds.base_station_has_valid_position || !station.pos) return null;
const heights = this.base_station_heights(ds);
return Cesium.Cartesian3.fromDegrees(station.pos.lng, station.pos.lat, heights.station_height);
}
aircraft_occlusion_key(aircraft: Aircraft, station_position: Cesium.Cartesian3, aircraft_position: Cesium.Cartesian3): string {
const station = Cesium.Cartographic.fromCartesian(station_position);
const target = Cesium.Cartographic.fromCartesian(aircraft_position);
return [
aircraft.data_source.key,
aircraft.icao,
station.longitude.toFixed(8),
station.latitude.toFixed(8),
station.height.toFixed(2),
target.longitude.toFixed(8),
target.latitude.toFixed(8),
target.height.toFixed(2),
this.current_terrain_sample_signature(),
this.graphics_config.occlusionSampleSpacingMeters,
this.graphics_config.occlusionClearanceMarginMeters
].join(":");
}
apply_aircraft_occlusion_visual(aircraft: Aircraft, record: Aircraft_Entity_Record, station_position: Cesium.Cartesian3, aircraft_position: Cesium.Cartesian3) {
const result = record.occlusion_result;
const color = !result ? Cesium.Color.YELLOW : result.obstructed ? Cesium.Color.RED : Cesium.Color.LIME;
if (record.occlusion_line) {
record.occlusion_line.show = true;
record.occlusion_line.description = new Cesium.ConstantProperty(this.aircraft_occlusion_description(aircraft, result));
if (record.occlusion_line.polyline) {
record.occlusion_line.polyline.positions = new Cesium.ConstantProperty([station_position, aircraft_position]);
record.occlusion_line.polyline.material = new Cesium.ColorMaterialProperty(color.withAlpha(0.85));
}
}
if (record.occlusion_marker) record.occlusion_marker.show = Boolean(result?.obstructed && result.position);
if (result?.position && record.occlusion_marker) {
record.occlusion_marker.position = new Cesium.ConstantPositionProperty(result.position);
record.occlusion_marker.description = new Cesium.ConstantProperty(this.aircraft_occlusion_description(aircraft, result));
}
if (record.aircraft.model) {
const selected = this.is_selected_aircraft(this.entity_key(aircraft.data_source.key, aircraft.icao));
record.aircraft.model.silhouetteColor = new Cesium.ConstantProperty(selected ? this.aircraft_contrast_color(aircraft) : color);
record.aircraft.model.silhouetteSize = new Cesium.ConstantProperty(selected ? 6 : result ? result.obstructed ? 2 : 0 : 1);
}
const model = aircraft.data_model;
record.aircraft.description = new Cesium.ConstantProperty(`${this.aircraft_description(model)}<br>${this.aircraft_occlusion_description(aircraft, result)}`);
this.viewer?.scene.requestRender();
}
aircraft_occlusion_description(aircraft: Aircraft, result: Terrain_Occlusion_Result | null) {
if (!this.graphics_config.terrainOcclusionEnabled) return "地形遮挡判断: 关闭";
if (!result) return "地形遮挡判断: 计算中";
return [
`地形遮挡判断: ${result.obstructed ? "遮挡" : "无遮挡"}`,
result.clearance === undefined ? "" : `最低净空: ${result.clearance.toFixed(1)} m`,
result.line_height === undefined ? "" : `视线高度: ${result.line_height.toFixed(1)} m`,
result.terrain_height === undefined ? "" : `地形高度: ${result.terrain_height.toFixed(1)} m`
].filter(Boolean).join("<br>");
}
async test_terrain_occlusion(station_position: Cesium.Cartesian3, aircraft_position: Cesium.Cartesian3): Promise<Terrain_Occlusion_Result> {
if (!this.map_resources) return {obstructed: false};
const terrain = this.current_terrain_resource();
if (terrain.encoding !== "terrarium") return {obstructed: false};
const station = Cesium.Cartographic.fromCartesian(station_position);
const aircraft = Cesium.Cartographic.fromCartesian(aircraft_position);
const request: Los_Request = {
aircraft_id: "",
station: [Cesium.Math.toDegrees(station.longitude), Cesium.Math.toDegrees(station.latitude), station.height],
aircraft: [Cesium.Math.toDegrees(aircraft.longitude), Cesium.Math.toDegrees(aircraft.latitude), aircraft.height],
terrain: {
url: terrain.url,
minimum_level: terrain.minimum_level,
maximum_level: terrain.maximum_level,
tile_size: terrain.tile_size,
y_axis: terrain.y_axis
},
terrain_level: this.effective_los_terrain_level(),
coarse_spacing: this.graphics_config.occlusionSampleSpacingMeters,
clearance_margin: this.graphics_config.occlusionClearanceMarginMeters
};
const result = await this.los_worker_pool_instance().run(request);
return {
obstructed: result.obstructed,
position: result.obstruction_position ? Cesium.Cartesian3.fromDegrees(result.obstruction_position[0], result.obstruction_position[1], result.obstruction_position[2]) : undefined,
line_height: result.line_height,
terrain_height: result.terrain_height,
clearance: result.minimum_clearance
};
}
aircraft_model_item(aircraft: Aircraft): Map_Model_Item_Config {
const key = aircraft.data_model.vortexTypeKey;
return (key && this.model_config.aircraftModels[key]) || this.model_config.aircraftModel;
}
aircraft_display_size(aircraft: Aircraft): number {
return Math.max(1, this.aircraft_model_item(aircraft).builtInSize * (aircraft.data_source.map3d_style().aircraft_scale || 1));
}
aircraft_physical_scale(aircraft: Aircraft): number {
return this.aircraft_model_item(aircraft).builtInSize * (aircraft.data_source.map3d_style().aircraft_scale || 1) * 0.000001;
}
aircraft_color(aircraft: Aircraft): Cesium.Color {
return Cesium.Color.fromCssColorString(aircraft.data_source.map3d_style().color);
}
aircraft_position(aircraft: Aircraft): Aircraft_Position {
const points = aircraft.data_model.track_points;
if (points.length > 0) {
const point = points[points.length - 1];
return {lon: point.lon, lat: point.lat, alt: point.alt};
}
const model = aircraft.data_model;
return {lon: model.lon, lat: model.lat, alt: model.alt};
}
aircraft_heading(aircraft: Aircraft): number {
return aircraft.data_model.trackOrientation.heading;
}
aircraft_pitch(aircraft: Aircraft): number {
return aircraft.data_model.trackOrientation.pitch || 0;
}
aircraft_roll(aircraft: Aircraft): number {
return aircraft.data_model.trackOrientation.roll || 0;
}
aircraft_orientation(aircraft: Aircraft, position: Cesium.Cartesian3): Cesium.Quaternion {
const model = this.aircraft_model_item(aircraft);
return Cesium.Transforms.headingPitchRollQuaternion(
position,
new Cesium.HeadingPitchRoll(
Cesium.Math.toRadians(this.aircraft_heading(aircraft) + model.headingOffsetDegrees),
Cesium.Math.toRadians(this.aircraft_pitch(aircraft) + model.pitchOffsetDegrees),
Cesium.Math.toRadians(this.aircraft_roll(aircraft) + model.rollOffsetDegrees)
)
);
}
keep_model_original_color(model: Cesium.ModelGraphics) {
(model as any).color = undefined;
(model as any).colorBlendMode = undefined;
(model as any).colorBlendAmount = undefined;
}
apply_aircraft_style(record: Aircraft_Entity_Record, aircraft: Aircraft) {
const color = this.aircraft_color(aircraft);
const selected = this.is_selected_aircraft(this.entity_key(aircraft.data_source.key, aircraft.icao));
const contrast_color = this.aircraft_contrast_color(aircraft);
const style = aircraft.data_source.map3d_style();
if (record.aircraft.model) {
if (style.constant_screen_size) {
this.apply_constant_screen_model_size(record.aircraft.model, this.aircraft_display_size(aircraft), this.aircraft_physical_scale(aircraft));
} else {
record.aircraft.model.scale = new Cesium.ConstantProperty(this.aircraft_physical_scale(aircraft));
record.aircraft.model.minimumPixelSize = new Cesium.ConstantProperty(0);
record.aircraft.model.maximumScale = new Cesium.ConstantProperty(500);
}
this.keep_model_original_color(record.aircraft.model);
record.aircraft.model.enableVerticalExaggeration = new Cesium.ConstantProperty(false);
record.aircraft.model.uri = new Cesium.ConstantProperty(this.aircraft_model_item(aircraft).url);
(record.aircraft.model as any).silhouetteColor = new Cesium.ConstantProperty(contrast_color);
(record.aircraft.model as any).silhouetteSize = new Cesium.ConstantProperty(selected ? 6 : 0);
}
if (record.aircraft.label) {
record.aircraft.label.show = new Cesium.ConstantProperty(this.aircraft_label_enabled(aircraft));
record.aircraft.label.text = new Cesium.ConstantProperty(this.aircraft_label_text(aircraft));
record.aircraft.label.fillColor = new Cesium.ConstantProperty(color);
record.aircraft.label.backgroundColor = new Cesium.ConstantProperty(contrast_color.withAlpha(0.72));
record.aircraft.label.backgroundPadding = new Cesium.ConstantProperty(new Cesium.Cartesian2(6, 4));
record.aircraft.label.showBackground = new Cesium.ConstantProperty(selected);
}
if (record.track?.polyline) {
record.track.polyline.material = new Cesium.ColorMaterialProperty(color.withAlpha(0.8));
}
this.apply_track_point_style(record, aircraft);
}
aircraft_contrast_color(aircraft: Aircraft): Cesium.Color {
const color = this.aircraft_color(aircraft);
const luminance = color.red * 0.299 + color.green * 0.587 + color.blue * 0.114;
return luminance > 0.55 ? Cesium.Color.BLACK : Cesium.Color.WHITE;
}
apply_track_point_style(record: Aircraft_Entity_Record, aircraft: Aircraft) {
const style = aircraft.data_source.map3d_style();
const color = Cesium.Color.fromCssColorString(style.track_point_color || style.color).withAlpha(0.9);
const pixel_size = this.track_point_pixel_size(aircraft);
for (const point of record.point_records) {
if (point.primitive) {
point.primitive.color = color;
point.primitive.pixelSize = pixel_size;
}
if (point.entity?.model) {
point.entity.model.scale = new Cesium.ConstantProperty(this.track_point_model_scale(aircraft));
}
}
}
track_point_pixel_size(aircraft: Aircraft): number {
return Math.max(1, aircraft.data_source.map3d_style().track_point_pixel_size || 8);
}
track_point_model_scale(aircraft: Aircraft): number {
return this.track_point_pixel_size(aircraft) * 2;
}
aircraft_label_enabled(aircraft: Aircraft): boolean {
const source = aircraft.data_source.map3d_style();
return source.show_icao || source.show_call_sign || source.show_fly_status;
}
should_show_aircraft_track(key: string, aircraft: Aircraft): boolean {
return this.is_selected_aircraft(key) || aircraft.data_source.is_aircraft_track_monitored(aircraft.icao);
}
aircraft_label_text(aircraft: Aircraft): string {
const lines: string[] = [];
const source = aircraft.data_source.map3d_style();
const model = aircraft.data_model;
if (source.show_icao) lines.push(model.icao);
if (source.show_call_sign && model.call_sign) lines.push(model.call_sign);
if (source.show_fly_status && model.fly_status) lines.push(`${model.fly_status} ${model.heading.toFixed(0)}°`);
return lines.join("\n");
}
sync_track_points(key: string, aircraft: Aircraft, record: Aircraft_Entity_Record) {
this.ensure_aircraft_track_entities(key, aircraft, record);
const points = aircraft.data_model.track_points;
if (points.length < record.point_start_index + record.point_records.length) {
this.clear_track_point_records(record);
record.point_start_index = 0;
}
const next_start = Math.max(0, points.length - this.max_track_points_per_aircraft);
if (next_start > record.point_start_index) {
const remove_count = Math.min(next_start - record.point_start_index, record.point_records.length);
for (const item of record.point_records.splice(0, remove_count)) {
this.remove_track_point_record(record, item);
}
record.point_start_index += remove_count;
}
const next_index = record.point_start_index + record.point_records.length;
for (let i = next_index; i < points.length; i++) {
record.point_records.push(this.create_track_point_record(record, key, aircraft, points[i], i));
}
const displayed_points = points.slice(record.point_start_index);
const aircraft_position = this.aircraft_position(aircraft);
const track_positions_key = `${record.point_start_index}:${points.length}:${aircraft_position.lon}:${aircraft_position.lat}:${aircraft_position.alt}`;
if (record.track_positions_key !== track_positions_key) {
record.track_positions.length = 0;
for (const point of displayed_points) {
record.track_positions.push(Cesium.Cartesian3.fromDegrees(point.lon, point.lat, point.alt));
}
const last_point = displayed_points[displayed_points.length - 1];
if (!last_point || !this.same_position(last_point, aircraft_position)) {
record.track_positions.push(Cesium.Cartesian3.fromDegrees(aircraft_position.lon, aircraft_position.lat, aircraft_position.alt));
}
record.track_positions_key = track_positions_key;
}
this.show_track(record);
}
clear_track_point_records(record: Aircraft_Entity_Record) {
for (const point of record.point_records) {
this.remove_track_point_record(record, point);
}
record.point_records = [];
}
remove_track_point_record(record: Aircraft_Entity_Record, point: Cesium_Track_Point_Record) {
if (point.primitive && record.point_collection) {
record.point_collection.remove(point.primitive);
}
if (point.entity) {
this.viewer!.entities.remove(point.entity);
}
}
same_position(left: Aircraft_Position, right: Aircraft_Position): boolean {
return left.lon === right.lon && left.lat === right.lat && left.alt === right.alt;
}
create_track_point_record(record: Aircraft_Entity_Record, key: string, aircraft: Aircraft, point: Aircraft_Track_Point_Model, index: number): Cesium_Track_Point_Record {
if (this.is_special_track_point(point)) {
return {entity: this.create_track_point_entity(key, aircraft, point, index)};
}
const style = aircraft.data_source.map3d_style();
const id: Track_Point_Pick_Record = {
ecap_track_point: point,
ecap_track_key: key,
ecap_track_description: this.track_point_description(point)
};
const primitive = record.point_collection!.add({
position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat, point.alt),
pixelSize: this.track_point_pixel_size(aircraft),
color: Cesium.Color.fromCssColorString(style.track_point_color || style.color).withAlpha(0.9),
outlineColor: Cesium.Color.BLACK,
outlineWidth: 1,
id
});
return {primitive};
}
create_track_point_entity(key: string, aircraft: Aircraft, point: Aircraft_Track_Point_Model, index: number): Cesium.Entity {
const base = {
id: `${key}:point:${index}`,
name: `${aircraft.icao} ${index}`,
position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat, point.alt),
description: this.track_point_description(point)
};
const entity = this.viewer!.entities.add({
...base,
model: {
uri: track_point_model_uri,
scale: this.track_point_model_scale(aircraft)
}
});
(entity as any).ecap_track_point = point;
(entity as any).ecap_track_key = key;
return entity;
}
is_selected_aircraft(key: string): boolean {
if (!this.selected_aircraft) return false;
return key === this.entity_key(this.selected_aircraft.data_source.key, this.selected_aircraft.icao);
}
hide_track(record: Aircraft_Entity_Record) {
if (record.track) record.track.show = false;
if (record.point_collection) record.point_collection.show = false;
for (const point of record.point_records) {
if (point.entity) {
point.entity.show = false;
}
}
}
hide_all_tracks() {
for (const record of this.entity_map.values()) {
if (!this.record_is_track_monitored(record)) {
this.hide_track(record);
}
}
}
show_track(record: Aircraft_Entity_Record) {
if (record.track) record.track.show = true;
if (record.point_collection) record.point_collection.show = true;
for (const point of record.point_records) {
if (point.entity) {
point.entity.show = true;
}
}
}
record_is_track_monitored(record: Aircraft_Entity_Record): boolean {
const aircraft = (record.aircraft as any).ecap_aircraft as Aircraft | undefined;
return Boolean(aircraft && aircraft.data_source.is_aircraft_track_monitored(aircraft.icao));
}
is_special_track_point(point: Aircraft_Track_Point_Model): boolean {
const properties = point.properties || {};
return Boolean(properties.alarm || properties.alert || properties.warning || properties.is_alarm || properties.event_type);
}
pick_interactive_target(position: Cesium.Cartesian2): Cesium.Entity | Track_Point_Pick_Record | null {
if (!this.viewer) return null;
const picked_list = this.viewer.scene.drillPick(position, 16);
for (const picked of picked_list) {
const target = picked.id as Cesium.Entity | Track_Point_Pick_Record | undefined;
if (!target || (target as any).ecap_ignore_pick) {
continue;
}
if ((target as any).ecap_aircraft || (target as any).ecap_base_station || (target as any).ecap_track_point) {
return target;
}
}
return null;
}
handle_right_click(position: Cesium.Cartesian2) {
if (!this.viewer) return;
const target = this.pick_interactive_target(position);
const aircraft = target ? (target as any).ecap_aircraft as Aircraft | undefined : undefined;
const base_station = target ? (target as any).ecap_base_station as Data_Source | undefined : undefined;
if (aircraft) {
this.context_menu = {x: position.x, y: position.y, aircraft};
this.flush();
return;
}
if (base_station) {
this.context_menu = {x: position.x, y: position.y, base_station};
this.flush();
return;
}
this.context_menu = null;
this.flush();
}
handle_click(position: Cesium.Cartesian2) {
if (!this.viewer) return;
this.context_menu = null;
const target = this.pick_interactive_target(position);
if (!target) {
this.set_surface_navigation_reference_from_screen(position);
this.clear_selected_aircraft();
return;
}
const aircraft = (target as any).ecap_aircraft as Aircraft | undefined;
if (aircraft) {
this.select_aircraft(aircraft);
return;
}
const track_point = (target as any).ecap_track_point as Aircraft_Track_Point_Model | undefined;
if (track_point) {
this.select_track_point(target);
return;
}
const base_station = (target as any).ecap_base_station as Data_Source | undefined;
if (base_station) {
this.select_base_station(base_station);
return;
}
this.clear_selected_aircraft();
}
select_base_station(ds: Data_Source) {
if (!this.viewer) return;
this.clear_selected_aircraft();
this.viewer.trackedEntity = undefined;
const record = this.base_station_entity_map.get(ds.key);
this.viewer.selectedEntity = record?.label || record?.model;
this.viewer.scene.requestRender();
}
select_track_point(target: Cesium.Entity | Track_Point_Pick_Record) {
if (!this.viewer) return;
const primitive_track_point = target as Track_Point_Pick_Record;
if (primitive_track_point.ecap_track_description) {
if (!this.track_point_selection_entity) {
this.track_point_selection_entity = this.viewer.entities.add({id: "ecap:selected-track-point", show: false});
}
this.track_point_selection_entity.name = "轨迹点";
this.track_point_selection_entity.position = new Cesium.ConstantPositionProperty(Cesium.Cartesian3.fromDegrees(primitive_track_point.ecap_track_point.lon, primitive_track_point.ecap_track_point.lat, primitive_track_point.ecap_track_point.alt));
this.track_point_selection_entity.description = new Cesium.ConstantProperty(primitive_track_point.ecap_track_description);
this.viewer.selectedEntity = this.track_point_selection_entity;
} else {
this.viewer.selectedEntity = target as Cesium.Entity;
}
this.viewer.scene.requestRender();
}
select_aircraft(aircraft: Aircraft) {
const key = this.entity_key(aircraft.data_source.key, aircraft.icao);
let old_aircraft: Aircraft | null = null;
if (this.selected_aircraft) {
const old_key = this.entity_key(this.selected_aircraft.data_source.key, this.selected_aircraft.icao);
const old_record = this.entity_map.get(old_key);
old_aircraft = this.selected_aircraft;
if (old_record && old_key !== key && !this.selected_aircraft.data_source.is_aircraft_track_monitored(this.selected_aircraft.icao)) {
this.hide_track(old_record);
}
}
this.selected_aircraft = aircraft;
if (this.viewer) {
this.viewer.trackedEntity = undefined;
this.viewer.selectedEntity = undefined;
this.viewer.scene.requestRender();
}
this.tracking = false;
const info = app.leaflet_map!.aircraft_info_show;
info.active_aircraft = aircraft;
if (info.drawer.have_open === false) {
info.drawer.toggleOpen();
}
aircraft.refresh_base_information();
aircraft.is_show_path = true;
aircraft.data_source.request_aircraft_stream_update();
if (old_aircraft && old_aircraft !== aircraft) {
const old_record = this.entity_map.get(this.entity_key(old_aircraft.data_source.key, old_aircraft.icao));
if (old_record) this.apply_aircraft_style(old_record, old_aircraft);
}
this.sync_aircraft(key, aircraft);
this.flush();
}
clear_selected_aircraft() {
const old_aircraft = this.selected_aircraft;
if (this.selected_aircraft) {
const record = this.entity_map.get(this.entity_key(this.selected_aircraft.data_source.key, this.selected_aircraft.icao));
if (record && !this.selected_aircraft.data_source.is_aircraft_track_monitored(this.selected_aircraft.icao)) {
this.hide_track(record);
}
} else {
this.hide_all_tracks();
}
if (this.viewer) {
this.viewer.trackedEntity = undefined;
this.viewer.selectedEntity = undefined;
this.viewer.scene.requestRender();
}
this.selected_aircraft = null;
if (old_aircraft) {
const record = this.entity_map.get(this.entity_key(old_aircraft.data_source.key, old_aircraft.icao));
if (record) this.apply_aircraft_style(record, old_aircraft);
}
this.tracking = false;
const info = app.leaflet_map?.aircraft_info_show;
if (info) {
info.active_aircraft = null;
info.flush();
}
this.flush();
}
toggle_manual_tracking_aircraft(aircraft: Aircraft) {
const next_enabled = !aircraft.data_source.is_manual_tracking_aircraft(aircraft.icao);
this.context_menu = null;
aircraft.data_source.set_manual_tracking_aircraft(aircraft.icao, next_enabled).then(() => {
this.request_sync_data_sources();
this.flush();
});
this.flush();
}
monitor_all_aircraft(ds: Data_Source) {
this.context_menu = null;
ds.set_monitor_all_aircraft_mode(true).then(() => {
this.request_sync_data_sources();
this.flush();
});
this.flush();
}
clear_manual_tracking(ds: Data_Source) {
this.context_menu = null;
ds.clear_all_aircraft_tracking();
this.request_sync_data_sources();
this.flush();
}
locate_selected() {
if (!this.viewer || !this.selected_aircraft) return;
const camera = this.viewer.camera;
const camera_position = camera.positionCartographic;
const aircraft_position = this.aircraft_position(this.selected_aircraft);
camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(aircraft_position.lon, aircraft_position.lat, camera_position.height),
orientation: {
heading: camera.heading,
pitch: camera.pitch,
roll: camera.roll
},
duration: 0.35
});
}
follow_selected() {
if (!this.viewer || !this.selected_aircraft) return;
const record = this.entity_map.get(this.entity_key(this.selected_aircraft.data_source.key, this.selected_aircraft.icao));
if (record) {
this.viewer.trackedEntity = record.aircraft;
this.tracking = true;
this.flush();
}
}
fly_to_base_station(ds: Data_Source) {
if (!this.viewer || !ds.base_station_has_valid_position) return;
const heights = this.base_station_heights(ds);
const height_offset = this.base_station_fly_to_height_offset_meters();
const center = Cesium.Cartesian3.fromDegrees(ds.lon, ds.lat, (heights.ground_height + heights.station_height) * 0.5);
const frame = Cesium.Transforms.eastNorthUpToFixedFrame(center);
const rotation = Cesium.Matrix4.getMatrix3(frame, new Cesium.Matrix3());
const local_offset = new Cesium.Cartesian3(0, -height_offset * 0.75, height_offset);
const world_offset = Cesium.Matrix3.multiplyByVector(rotation, local_offset, new Cesium.Cartesian3());
const destination = Cesium.Cartesian3.add(center, world_offset, new Cesium.Cartesian3());
const direction = Cesium.Cartesian3.normalize(Cesium.Cartesian3.subtract(center, destination, new Cesium.Cartesian3()), new Cesium.Cartesian3());
const local_up = Cesium.Matrix3.multiplyByVector(rotation, Cesium.Cartesian3.UNIT_Z, new Cesium.Cartesian3());
const up_projection = Cesium.Cartesian3.multiplyByScalar(direction, Cesium.Cartesian3.dot(local_up, direction), new Cesium.Cartesian3());
const up = Cesium.Cartesian3.normalize(Cesium.Cartesian3.subtract(local_up, up_projection, new Cesium.Cartesian3()), new Cesium.Cartesian3());
this.clear_camera_tracking();
this.viewer.camera.flyTo({
destination,
orientation: {direction, up}
});
}
entity_key(data_source_key: string, icao: string) {
return `${data_source_key}:${icao}`;
}
aircraft_description(model: Aircraft_Model) {
return [
`ICAO: ${model.icao}`,
`航班号: ${model.call_sign || ""}`,
`状态: ${model.fly_status || ""}`,
`纬度: ${model.lat.toFixed(6)}`,
`经度: ${model.lon.toFixed(6)}`,
`高度: ${model.alt}`,
`航向: ${model.heading.toFixed(1)}°`,
`涡流/目标类型: ${model.vortexTypeLabel || model.vortexTypeKey || ""}`,
`航迹俯仰: ${model.trackOrientation.pitch.toFixed(1)}°`,
`航迹横滚: ${model.trackOrientation.roll.toFixed(1)}°`
].join("<br>");
}
track_point_description(point: Aircraft_Track_Point_Model) {
const extra = Object.entries(point.properties).map(([key, value]) => `${key}: ${value}`).join("<br>");
return [
`时间: ${point.timestamp || ""}`,
`纬度: ${point.lat.toFixed(6)}`,
`经度: ${point.lon.toFixed(6)}`,
`高度: ${point.alt}`,
extra
].filter(Boolean).join("<br>");
}
render_context_menu() {
const menu = this.context_menu;
if (!menu) return null;
const stop = (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
};
if (menu.aircraft) {
const aircraft = menu.aircraft;
const tracking = aircraft.data_source.is_manual_tracking_aircraft(aircraft.icao);
return (
<div onMouseDown={stop} onClick={stop} style={{position: "absolute", left: menu.x, top: menu.y, zIndex: 4, minWidth: 150, padding: 6, borderRadius: 4, background: "rgba(28, 28, 28, 0.92)", boxShadow: "0 4px 14px rgba(0, 0, 0, 0.24)", color: "#fff"}}>
<div style={{padding: "4px 8px", fontWeight: 700}}>{aircraft.icao}</div>
<Button block size="small" onClick={() => this.toggle_manual_tracking_aircraft(aircraft)}>{tracking ? "取消持久跟踪" : "持久跟踪飞机"}</Button>
</div>
);
}
if (menu.base_station) {
const ds = menu.base_station;
return (
<div onMouseDown={stop} onClick={stop} style={{position: "absolute", left: menu.x, top: menu.y, zIndex: 4, minWidth: 170, padding: 6, borderRadius: 4, background: "rgba(28, 28, 28, 0.92)", boxShadow: "0 4px 14px rgba(0, 0, 0, 0.24)", color: "#fff"}}>
<div style={{padding: "4px 8px", fontWeight: 700}}>{ds.key}</div>
<Space direction="vertical" size={4} style={{width: "100%"}}>
<Button block size="small" onClick={() => this.monitor_all_aircraft(ds)}>{ds.monitor_all_aircraft_mode ? "已监控所有飞机" : "监控所有飞机"}</Button>
<Button block size="small" onClick={() => this.clear_manual_tracking(ds)}></Button>
</Space>
</div>
);
}
return null;
}
graphics_panel_position(key: Graphics_Panel_Key): Cesium_Panel_Position {
const position = key === "performance" ? this.graphics_config.performancePanelPosition : this.graphics_config.viewAxesPanelPosition;
return this.clamp_graphics_panel_position(position);
}
change_graphics_panel_position(key: Graphics_Panel_Key, position: Cesium_Panel_Position) {
const next_position = this.clamp_graphics_panel_position(position);
if (key === "performance") {
this.graphics_config = normalize_cesium_graphics_config({...this.graphics_config, performancePanelPosition: next_position});
}
else {
this.graphics_config = normalize_cesium_graphics_config({...this.graphics_config, viewAxesPanelPosition: next_position});
}
this.flush();
}
clamp_graphics_panel_position(position: Cesium_Panel_Position): Cesium_Panel_Position {
const width = typeof window === "undefined" ? 1920 : window.innerWidth;
const height = typeof window === "undefined" ? 1080 : window.innerHeight;
return {
x: Math.max(0, Math.min(width - 32, position.x)),
y: Math.max(0, Math.min(height - 32, position.y))
};
}
start_graphics_panel_drag(event: React.PointerEvent, key: Graphics_Panel_Key) {
event.preventDefault();
event.stopPropagation();
this.graphics_panel_drag_key = key;
this.graphics_panel_drag_last_x = event.clientX;
this.graphics_panel_drag_last_y = event.clientY;
this.add_graphics_panel_drag_listeners();
}
add_graphics_panel_drag_listeners() {
this.graphics_panel_pointer_move_listener ||= (event: PointerEvent) => this.handle_graphics_panel_pointer_move(event);
this.graphics_panel_pointer_up_listener ||= (event: PointerEvent) => this.stop_graphics_panel_drag(event);
window.addEventListener("pointermove", this.graphics_panel_pointer_move_listener);
window.addEventListener("pointerup", this.graphics_panel_pointer_up_listener);
window.addEventListener("pointercancel", this.graphics_panel_pointer_up_listener);
}
remove_graphics_panel_drag_listeners() {
if (this.graphics_panel_pointer_move_listener) {
window.removeEventListener("pointermove", this.graphics_panel_pointer_move_listener);
}
if (this.graphics_panel_pointer_up_listener) {
window.removeEventListener("pointerup", this.graphics_panel_pointer_up_listener);
window.removeEventListener("pointercancel", this.graphics_panel_pointer_up_listener);
}
}
handle_graphics_panel_pointer_move(event: PointerEvent) {
if (!this.graphics_panel_drag_key) return;
event.preventDefault();
const dx = event.clientX - this.graphics_panel_drag_last_x;
const dy = event.clientY - this.graphics_panel_drag_last_y;
const position = this.graphics_panel_position(this.graphics_panel_drag_key);
this.graphics_panel_drag_last_x = event.clientX;
this.graphics_panel_drag_last_y = event.clientY;
this.change_graphics_panel_position(this.graphics_panel_drag_key, {x: position.x + dx, y: position.y + dy});
}
stop_graphics_panel_drag(event: PointerEvent) {
event.preventDefault();
this.graphics_panel_drag_key = null;
this.remove_graphics_panel_drag_listeners();
}
save_graphics_panel_layout() {
this.graphics_config = normalize_cesium_graphics_config({
...this.graphics_config,
performancePanelPosition: this.graphics_panel_position("performance"),
viewAxesPanelPosition: this.graphics_panel_position("viewAxes")
});
this.save_current_cesium_graphics_config();
}
graphics_panel_header(key: Graphics_Panel_Key, label: string, open: boolean, on_open_change: (value: boolean) => void) {
if (!open) {
return <Switch size="small" value={open} onChange={(value) => {
on_open_change(value);
this.flush();
}}/>;
}
return (
<div style={{display: "flex", gap: 6, alignItems: "center", padding: 4, borderRadius: 4, background: "rgba(30, 30, 30, 0.72)", color: "#fff", fontSize: 12}}>
<span onPointerDown={(event) => this.start_graphics_panel_drag(event, key)} style={{cursor: "move", fontWeight: 700, userSelect: "none"}}>{label}</span>
<Switch size="small" value={open} onChange={(value) => {
on_open_change(value);
this.flush();
}}/>
{key === "viewAxes" && <>
<span style={{userSelect: "none"}}></span>
<InputNumber size="small" min={48} max={240} step={4} value={this.graphics_config.viewAxesPanelSize} onPointerDown={(event) => event.stopPropagation()} onChange={(value: number | null) => {
if (value !== null) this.change_view_axes_panel_size(value);
}} style={{width: 76}}/>
</>}
<Button size="small" onPointerDown={(event) => event.stopPropagation()} onClick={() => this.save_graphics_panel_layout()}></Button>
</div>
);
}
render_performance_panel() {
if (!this.graphics_config.debugShowFramesPerSecond) return null;
const info = this.performance_info;
const position = this.graphics_panel_position("performance");
return (
<div style={{position: "absolute", left: position.x, top: position.y, zIndex: 2, pointerEvents: "auto"}}>
{this.graphics_panel_header("performance", "性能", this.performance_panel_open, (value) => this.performance_panel_open = value)}
{this.performance_panel_open && <div style={{minWidth: 170, maxWidth: 260, marginTop: 6, padding: "8px 10px", borderRadius: 4, background: "rgba(30, 30, 30, 0.72)", color: "#f6f6f6", fontSize: 12, lineHeight: 1.45, pointerEvents: "none"}}>
<div style={{fontWeight: 700, color: "#fff000"}}>{`FPS ${info.fps}`}</div>
<div>{`Frame ${info.frame_ms.toFixed(1)} ms`}</div>
<div>{`JS堆 ${info.js_heap_mb === null ? "N/A" : `${info.js_heap_mb.toFixed(1)} MB`}`}</div>
<div>{`Tiles ${info.tile_state || "N/A"}`}</div>
<div style={{color: "#ff8a33", wordBreak: "break-word"}}>{`GPU ${info.gpu_renderer || "N/A"}`}</div>
</div>}
</div>
);
}
view_axes_visible(): boolean {
return Boolean(this.viewer && this.graphics_config.viewAxesVisible && this.scene_mode === "3d" && this.viewer.scene.mode === Cesium.SceneMode.SCENE3D);
}
view_axes_navigation_center(): Cesium.Cartesian3 {
if (this.surface_navigation_reference_position_value) return this.surface_navigation_reference_position_value;
const viewer = this.viewer!;
const canvas = viewer.scene.canvas;
const screen_position = new Cesium.Cartesian2(canvas.clientWidth * 0.5, canvas.clientHeight * 0.5);
const ray = viewer.camera.getPickRay(screen_position);
const position = ray ? viewer.scene.globe.pick(ray, viewer.scene) : undefined;
return position || viewer.camera.positionWC;
}
view_axis_local_unit(axis: "x" | "y" | "z"): Cesium.Cartesian3 {
if (axis === "x") return Cesium.Cartesian3.UNIT_X;
if (axis === "y") return Cesium.Cartesian3.UNIT_Y;
return Cesium.Cartesian3.UNIT_Z;
}
view_axis_world_unit(center: Cesium.Cartesian3, axis: "x" | "y" | "z"): Cesium.Cartesian3 {
const frame = Cesium.Transforms.eastNorthUpToFixedFrame(center);
const rotation = Cesium.Matrix4.getMatrix3(frame, new Cesium.Matrix3());
return Cesium.Cartesian3.normalize(Cesium.Matrix3.multiplyByVector(rotation, this.view_axis_local_unit(axis), new Cesium.Cartesian3()), new Cesium.Cartesian3());
}
view_axis_point(axis: "x" | "y" | "z", sign: 1 | -1, label: string, color: string, rotation: Cesium.Matrix3): View_Axis_Point {
const world_axis = Cesium.Matrix3.multiplyByVector(rotation, this.view_axis_local_unit(axis), new Cesium.Cartesian3());
Cesium.Cartesian3.multiplyByScalar(world_axis, sign, world_axis);
const camera = this.viewer!.camera;
return {
key: `${axis}:${sign}`,
axis,
sign,
label,
color,
x: Cesium.Cartesian3.dot(world_axis, camera.rightWC),
y: -Cesium.Cartesian3.dot(world_axis, camera.upWC),
depth: Cesium.Cartesian3.dot(world_axis, camera.directionWC)
};
}
view_axes_points(): View_Axis_Point[] {
const frame = Cesium.Transforms.eastNorthUpToFixedFrame(this.view_axes_navigation_center());
const rotation = Cesium.Matrix4.getMatrix3(frame, new Cesium.Matrix3());
return [
this.view_axis_point("x", 1, "X", "#ff3b30", rotation),
this.view_axis_point("x", -1, "", "#ff3b30", rotation),
this.view_axis_point("y", 1, "Y", "#52c41a", rotation),
this.view_axis_point("y", -1, "", "#52c41a", rotation),
this.view_axis_point("z", 1, "Z", "#1677ff", rotation),
this.view_axis_point("z", -1, "", "#1677ff", rotation)
];
}
view_axis_svg_position(axis: View_Axis_Point, center: number, radius: number): {x: number, y: number} {
return {x: center + axis.x * radius, y: center + axis.y * radius};
}
view_axes_line(points: View_Axis_Point[], axis: "x" | "y" | "z", center: number, radius: number, stroke_width: number) {
const positive = points.find((item) => item.axis === axis && item.sign === 1)!;
const negative = points.find((item) => item.axis === axis && item.sign === -1)!;
const start = this.view_axis_svg_position(negative, center, radius);
const end = this.view_axis_svg_position(positive, center, radius);
return <line key={axis} x1={start.x} y1={start.y} x2={end.x} y2={end.y} stroke={positive.color} strokeWidth={stroke_width} strokeLinecap="round" opacity={0.72}/>;
}
handle_view_axis_click(event: React.MouseEvent<SVGGElement>, axis: "x" | "y" | "z", sign: 1 | -1) {
event.preventDefault();
event.stopPropagation();
if (this.view_axes_ignore_next_click) {
this.view_axes_ignore_next_click = false;
return;
}
this.fly_to_view_axis(axis, sign);
}
fly_to_view_axis(axis: "x" | "y" | "z", sign: 1 | -1) {
if (!this.viewer) return;
this.clear_camera_tracking();
const center = this.view_axes_navigation_center();
const world_axis = this.view_axis_world_unit(center, axis);
Cesium.Cartesian3.multiplyByScalar(world_axis, sign, world_axis);
const range = Math.max(100, Cesium.Cartesian3.distance(this.viewer.camera.positionWC, center));
const destination = Cesium.Cartesian3.add(center, Cesium.Cartesian3.multiplyByScalar(world_axis, range, new Cesium.Cartesian3()), new Cesium.Cartesian3());
const direction = Cesium.Cartesian3.negate(world_axis, new Cesium.Cartesian3());
const up_axis = axis === "z" ? "y" : "z";
const local_up = this.view_axis_world_unit(center, up_axis);
const up_projection = Cesium.Cartesian3.multiplyByScalar(direction, Cesium.Cartesian3.dot(local_up, direction), new Cesium.Cartesian3());
const up = Cesium.Cartesian3.normalize(Cesium.Cartesian3.subtract(local_up, up_projection, new Cesium.Cartesian3()), new Cesium.Cartesian3());
this.viewer.camera.flyTo({destination, orientation: {direction, up}, duration: 0.35});
this.viewer.scene.requestRender();
this.flush();
}
start_view_axes_drag(event: React.PointerEvent<SVGSVGElement>) {
event.preventDefault();
event.stopPropagation();
this.clear_camera_tracking();
this.view_axes_dragging = true;
this.view_axes_drag_last_x = event.clientX;
this.view_axes_drag_last_y = event.clientY;
this.view_axes_drag_total = 0;
this.add_view_axes_drag_listeners();
this.flush();
}
add_view_axes_drag_listeners() {
this.view_axes_pointer_move_listener ||= (event: PointerEvent) => this.handle_view_axes_pointer_move(event);
this.view_axes_pointer_up_listener ||= (event: PointerEvent) => this.stop_view_axes_drag(event);
window.addEventListener("pointermove", this.view_axes_pointer_move_listener);
window.addEventListener("pointerup", this.view_axes_pointer_up_listener);
window.addEventListener("pointercancel", this.view_axes_pointer_up_listener);
}
remove_view_axes_drag_listeners() {
if (this.view_axes_pointer_move_listener) {
window.removeEventListener("pointermove", this.view_axes_pointer_move_listener);
}
if (this.view_axes_pointer_up_listener) {
window.removeEventListener("pointerup", this.view_axes_pointer_up_listener);
window.removeEventListener("pointercancel", this.view_axes_pointer_up_listener);
}
}
handle_view_axes_pointer_move(event: PointerEvent) {
if (!this.view_axes_dragging) return;
event.preventDefault();
const dx = event.clientX - this.view_axes_drag_last_x;
const dy = event.clientY - this.view_axes_drag_last_y;
this.view_axes_drag_last_x = event.clientX;
this.view_axes_drag_last_y = event.clientY;
this.view_axes_drag_total += Math.abs(dx) + Math.abs(dy);
this.rotate_view_axes_camera(dx, dy);
}
stop_view_axes_drag(event: PointerEvent) {
event.preventDefault();
this.view_axes_dragging = false;
this.view_axes_ignore_next_click = this.view_axes_drag_total > 4;
if (this.view_axes_ignore_next_click) {
window.setTimeout(() => {
this.view_axes_ignore_next_click = false;
}, 180);
}
this.remove_view_axes_drag_listeners();
this.flush();
}
rotate_vector_around_axis(vector: Cesium.Cartesian3, axis: Cesium.Cartesian3, angle: number): Cesium.Cartesian3 {
const quaternion = Cesium.Quaternion.fromAxisAngle(axis, angle, new Cesium.Quaternion());
const matrix = Cesium.Matrix3.fromQuaternion(quaternion, new Cesium.Matrix3());
return Cesium.Matrix3.multiplyByVector(matrix, vector, new Cesium.Cartesian3());
}
rotate_view_axes_camera(dx: number, dy: number) {
if (!this.viewer) return;
const camera = this.viewer.camera;
const center = this.view_axes_navigation_center();
const offset = Cesium.Cartesian3.subtract(camera.positionWC, center, new Cesium.Cartesian3());
if (Cesium.Cartesian3.magnitude(offset) < 1) return;
const local_up = this.view_axis_world_unit(center, "z");
const yaw = -dx * 0.008;
const pitch = -dy * 0.008;
const yaw_offset = this.rotate_vector_around_axis(offset, local_up, yaw);
const yaw_up = this.rotate_vector_around_axis(camera.upWC, local_up, yaw);
const yaw_right = this.rotate_vector_around_axis(camera.rightWC, local_up, yaw);
const next_offset = this.rotate_vector_around_axis(yaw_offset, yaw_right, pitch);
const next_up = this.rotate_vector_around_axis(yaw_up, yaw_right, pitch);
const destination = Cesium.Cartesian3.add(center, next_offset, new Cesium.Cartesian3());
const direction = Cesium.Cartesian3.normalize(Cesium.Cartesian3.subtract(center, destination, new Cesium.Cartesian3()), new Cesium.Cartesian3());
const up_projection = Cesium.Cartesian3.multiplyByScalar(direction, Cesium.Cartesian3.dot(next_up, direction), new Cesium.Cartesian3());
const up = Cesium.Cartesian3.normalize(Cesium.Cartesian3.subtract(next_up, up_projection, new Cesium.Cartesian3()), new Cesium.Cartesian3());
camera.setView({destination, orientation: {direction, up}});
this.viewer.scene.requestRender();
this.flush();
}
render_view_axes() {
if (!this.view_axes_visible()) return null;
const position = this.graphics_panel_position("viewAxes");
if (!this.view_axes_panel_open) {
return (
<div style={{position: "absolute", left: position.x, top: position.y, zIndex: 3, pointerEvents: "auto"}}>
{this.graphics_panel_header("viewAxes", "ViewCube", this.view_axes_panel_open, (value) => this.view_axes_panel_open = value)}
</div>
);
}
const size = this.graphics_config.viewAxesPanelSize;
const center = size * 0.5;
const radius = size * 0.31;
const stroke_width = Math.max(1.2, size * 0.021);
const center_radius = Math.max(2.5, size * 0.042);
const label_font_size = Math.max(8, size * 0.107);
const points = this.view_axes_points();
const sorted_points = [...points].sort((left, right) => right.depth - left.depth);
return (
<div style={{position: "absolute", left: position.x, top: position.y, zIndex: 3, display: "flex", flexDirection: "column", alignItems: "center", pointerEvents: "auto"}}>
{this.graphics_panel_header("viewAxes", "ViewCube", this.view_axes_panel_open, (value) => this.view_axes_panel_open = value)}
{this.view_axes_panel_open && <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} onPointerDown={(event) => this.start_view_axes_drag(event)} style={{marginTop: 6, pointerEvents: "auto", touchAction: "none", cursor: this.view_axes_dragging ? "grabbing" : "grab"}}>
{(["x", "y", "z"] as const).map((axis) => this.view_axes_line(points, axis, center, radius, stroke_width))}
<circle cx={center} cy={center} r={center_radius} fill="rgba(255, 255, 255, 0.78)"/>
{sorted_points.map((axis) => {
const position = this.view_axis_svg_position(axis, center, radius);
const point_radius = axis.sign === 1 ? size * 0.083 : size * 0.066;
const opacity = 0.48 + Math.max(0, -axis.depth) * 0.46;
return (
<g key={axis.key} opacity={opacity} onClick={(event) => this.handle_view_axis_click(event, axis.axis, axis.sign)} style={{cursor: "pointer"}}>
<circle cx={position.x} cy={position.y} r={point_radius} fill={axis.color}/>
{axis.label ? <text x={position.x} y={position.y + label_font_size * 0.39} textAnchor="middle" fontSize={label_font_size} fontWeight={700} fill="#111" pointerEvents="none">{axis.label}</text> : null}
</g>
);
})}
</svg>}
</div>
);
}
render(props: any) {
const AircraftInfo = app.leaflet_map!.aircraft_info_show.x;
const DataSourceShow = app.leaflet_map!.data_source_show.x;
return (
<div style={{width: "100%", height: "100%", position: "relative"}}>
<AircraftInfo></AircraftInfo>
<DataSourceShow mapDisplayMode="map3d"></DataSourceShow>
<div id={this.container_id} style={{width: "100%", height: "100%"}}/>
<div style={{position: "absolute", left: 64, top: 16, zIndex: 2}}>
<Space.Compact>
<Tooltip title="定位选中飞机">
<Button icon={<AimOutlined />} onClick={() => this.locate_selected()} disabled={!this.selected_aircraft}/>
</Tooltip>
<Tooltip title="跟随选中飞机">
<Button icon={<EyeOutlined />} onClick={() => this.follow_selected()} disabled={!this.selected_aircraft}/>
</Tooltip>
</Space.Compact>
</div>
{this.render_performance_panel()}
{this.render_view_axes()}
{this.render_context_menu()}
</div>
);
}
}
type Cesium_Map_View_Props = {
initialSceneMode?: Scene_Mode_Key
}
export function Cesium_Map_View({initialSceneMode = "3d"}: Cesium_Map_View_Props) {
const map_ref = React.useRef<Cesium_Map | null>(null);
if (!map_ref.current) {
map_ref.current = new Cesium_Map(initialSceneMode);
}
const Map_Component = map_ref.current.x;
return <Map_Component />;
}
export default Cesium_Map_View;