。。
This commit is contained in:
Binary file not shown.
@@ -9,6 +9,7 @@ import {app} from "../App.tsx";
|
||||
import {Leaflet_Map} from "../Map/Leaflet_Map.tsx";
|
||||
import {Data_Format, Input_Number, Input_Port_Number, Input_String, Play_Mode, Psc, Switch_Bool} from "../A_Global.tsx";
|
||||
import {Data_Source_Show} from "./Data_Source_Show.tsx";
|
||||
import {Camera_Control_Mode} from "../Map/Camera_Control_Mode.ts";
|
||||
import L from "leaflet";
|
||||
import {State_Shower} from "../State_Shower.tsx";
|
||||
|
||||
@@ -106,7 +107,7 @@ export class Data_Source extends enhance.Base {
|
||||
this.refresh_base_station();
|
||||
this.refresh_aircraft_list();
|
||||
} else {
|
||||
app.cesium_map?.sync_data_sources();
|
||||
app.cesium_map?.request_sync_data_sources();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +156,30 @@ export class Data_Source extends enhance.Base {
|
||||
}
|
||||
app.leaflet_map?.map?.setView([this.lat, this.lon], 12);
|
||||
}
|
||||
center_earth_view() {
|
||||
app.cesium_map?.center_earth();
|
||||
}
|
||||
look_straight_down_view() {
|
||||
app.cesium_map?.look_straight_down();
|
||||
}
|
||||
north_up_view() {
|
||||
app.cesium_map?.north_up();
|
||||
}
|
||||
clear_camera_tracking() {
|
||||
app.cesium_map?.clear_camera_tracking();
|
||||
}
|
||||
restore_previous_camera_view() {
|
||||
app.cesium_map?.restore_previous_view();
|
||||
}
|
||||
fly_to_global_view() {
|
||||
app.cesium_map?.fly_to_global_view();
|
||||
}
|
||||
cesium_camera_control_mode(): Camera_Control_Mode {
|
||||
return app.cesium_map?.camera_control_mode() || Camera_Control_Mode.Surface_Navigation;
|
||||
}
|
||||
set_cesium_camera_control_mode(mode: Camera_Control_Mode) {
|
||||
app.cesium_map?.set_camera_control_mode(mode);
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
if (this.key) {
|
||||
@@ -224,7 +249,7 @@ export class Data_Source extends enhance.Base {
|
||||
this.refresh_manual_tracking_views();
|
||||
}
|
||||
refresh_manual_tracking_views() {
|
||||
app.cesium_map?.sync_data_sources();
|
||||
app.cesium_map?.request_sync_data_sources();
|
||||
app.leaflet_map?.data_source_show?.flush();
|
||||
}
|
||||
|
||||
@@ -545,8 +570,7 @@ class Aircraft_Stream_Client {
|
||||
ds.apply_aircraft_delta(Array.isArray(source.change_list) ? source.change_list : [], Array.isArray(source.removed_icaos) ? source.removed_icaos : []);
|
||||
ds.apply_track_stream_list(Array.isArray(source.tracks) ? source.tracks : []);
|
||||
}
|
||||
app.cesium_map?.sync_data_sources();
|
||||
app.leaflet_map?.data_source_show?.flush();
|
||||
app.cesium_map?.request_sync_data_sources();
|
||||
}
|
||||
}
|
||||
const aircraft_stream = new Aircraft_Stream_Client();
|
||||
|
||||
@@ -11,12 +11,14 @@ import {
|
||||
Tabs,
|
||||
Tag,
|
||||
theme,
|
||||
Tooltip,
|
||||
Typography
|
||||
} from "antd";
|
||||
import React from "react";
|
||||
import {generate, green, presetPalettes, red} from '@ant-design/colors';
|
||||
import {Data_Source, type Data_Source_Map_Display_Data, type Map_Display_Mode} from "./Data_Source.tsx";
|
||||
import {Base_Drawer} from "../Base_Drawer.tsx";
|
||||
import {Camera_Control_Mode} from "../Map/Camera_Control_Mode.ts";
|
||||
|
||||
const {Title, Text} = Typography;
|
||||
|
||||
@@ -127,7 +129,7 @@ export class Data_Source_Show extends enhance.Base {
|
||||
refresh();
|
||||
});
|
||||
}}
|
||||
onBlur={() => this.blur_number_input(base_station_size_key)}/> : <InputNumber addonBefore="基站缩放比例" min={0} max={65535} step={0.1} precision={3}
|
||||
onBlur={() => this.blur_number_input(base_station_size_key)}/> : <InputNumber addonBefore="基站显示大小" min={0} max={65535} step={0.1} precision={3}
|
||||
placeholder="请输入缩放比例"
|
||||
value={this.number_input_value(base_station_size_key, style.base_station_scale)}
|
||||
onChange={(value: number | null) => {
|
||||
@@ -202,6 +204,59 @@ export class Data_Source_Show extends enhance.Base {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
render_3d_control_button(label: string, tip: string[], onClick: () => void, active = false) {
|
||||
return (
|
||||
<Tooltip
|
||||
trigger={["hover", "contextMenu"]}
|
||||
title={<div style={{maxWidth: 260}}>
|
||||
<div style={{fontWeight: 700, marginBottom: 4}}>{label}</div>
|
||||
{tip.map((line) => <div key={line}>{line}</div>)}
|
||||
</div>}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
block
|
||||
type={active ? "primary" : "default"}
|
||||
onClick={onClick}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>{label}</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
render_3d_view_controls(ds: Data_Source) {
|
||||
const mode = ds.cesium_camera_control_mode();
|
||||
const grid_style: React.CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(86px, 1fr))",
|
||||
gap: 6,
|
||||
maxWidth: 320
|
||||
};
|
||||
return (
|
||||
<div style={{marginTop: 12, paddingTop: 8, borderTop: "1px solid #eee"}}>
|
||||
<Title level={5}>3D视图控制</Title>
|
||||
<div style={grid_style}>
|
||||
{this.render_3d_control_button("地表导航", ["中心点:鼠标下方地表和当前局部场景。", "这是 Cesium 默认地图操作方式。", "左键拖动按默认规则操作地表,滚轮朝鼠标地表位置缩放。"], () => {
|
||||
ds.set_cesium_camera_control_mode(Camera_Control_Mode.Surface_Navigation);
|
||||
this.flush();
|
||||
}, mode === Camera_Control_Mode.Surface_Navigation)}
|
||||
{this.render_3d_control_button("地心轨道", ["中心点:地球中心 Cartesian3.ZERO。", "左键左右拖动绕地心旋转,上下拖动抬高或压低观察方向。", "滚轮只改变相机到地心距离,朝向始终回到地心。"], () => {
|
||||
ds.set_cesium_camera_control_mode(Camera_Control_Mode.Earth_Orbit);
|
||||
this.flush();
|
||||
}, mode === Camera_Control_Mode.Earth_Orbit)}
|
||||
{this.render_3d_control_button("自由观察", ["中心点:相机当前位置。", "左键左右拖动改变 heading,上下拖动改变 pitch。", "相机位置保持不动,适合固定点观察。"], () => {
|
||||
ds.set_cesium_camera_control_mode(Camera_Control_Mode.Free_Look);
|
||||
this.flush();
|
||||
}, mode === Camera_Control_Mode.Free_Look)}
|
||||
{this.render_3d_control_button("地心居中", ["相机经纬度和高度不变。", "只旋转相机朝向,让地球中心落到屏幕中心。", "会取消当前飞机跟随。"], () => ds.center_earth_view())}
|
||||
{this.render_3d_control_button("垂直俯视", ["相机位置和高度不变。", "朝当前位置地面垂直向下看。", "北方向会回到屏幕上方。"], () => ds.look_straight_down_view())}
|
||||
{this.render_3d_control_button("北向上", ["相机位置和俯仰角不变。", "清除航向偏转和滚转。", "用于把地图方向恢复为北向上。"], () => ds.north_up_view())}
|
||||
{this.render_3d_control_button("取消跟随", ["清除 trackedEntity。", "相机恢复自由控制。", "不会改变当前相机位置。"], () => ds.clear_camera_tracking())}
|
||||
{this.render_3d_control_button("上一视角", ["恢复执行视角按钮前保存的位置和朝向。", "会取消当前飞机跟随。", "没有历史视角时不执行。"], () => ds.restore_previous_camera_view())}
|
||||
{this.render_3d_control_button("全球视图", ["相机移动到能看到完整地球的位置。", "会改变相机高度和位置。", "用于从局部场景快速回到全球范围。"], () => ds.fly_to_global_view())}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
render_data_source(ds: Data_Source, mode: Map_Display_Mode) {
|
||||
const latitude_key = `${ds.key}:latitude`;
|
||||
const longitude_key = `${ds.key}:longitude`;
|
||||
@@ -260,6 +315,7 @@ export class Data_Source_Show extends enhance.Base {
|
||||
onBlur={() => this.blur_number_input(altitude_key)}/>
|
||||
<br/>
|
||||
{this.render_aircraft_monitor_config(ds)}
|
||||
{mode === "map3d" && this.render_3d_view_controls(ds)}
|
||||
<br/>
|
||||
<Button type="primary" autoInsertSpace onClick={() => {
|
||||
ds.confirm();
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum Camera_Control_Mode {
|
||||
Earth_Orbit = "earth_orbit",
|
||||
Surface_Navigation = "surface_navigation",
|
||||
Free_Look = "free_look"
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import * as Cesium from "cesium";
|
||||
import {Camera_Control_Mode} from "./Camera_Control_Mode.ts";
|
||||
|
||||
type Default_Controller_State = {
|
||||
enable_inputs: boolean
|
||||
enable_rotate: boolean
|
||||
enable_translate: boolean
|
||||
enable_zoom: boolean
|
||||
enable_tilt: boolean
|
||||
enable_look: boolean
|
||||
}
|
||||
export class Cesium_Camera_Control {
|
||||
private readonly default_state: Default_Controller_State
|
||||
private handler?: Cesium.ScreenSpaceEventHandler
|
||||
private dragging = false
|
||||
private free_position = new Cesium.Cartesian3()
|
||||
private free_heading = 0
|
||||
private free_pitch = 0
|
||||
private free_roll = 0
|
||||
private mode = Camera_Control_Mode.Surface_Navigation
|
||||
constructor(private readonly viewer: Cesium.Viewer) {
|
||||
const controller = viewer.scene.screenSpaceCameraController;
|
||||
this.default_state = {
|
||||
enable_inputs: controller.enableInputs,
|
||||
enable_rotate: controller.enableRotate,
|
||||
enable_translate: controller.enableTranslate,
|
||||
enable_zoom: controller.enableZoom,
|
||||
enable_tilt: controller.enableTilt,
|
||||
enable_look: controller.enableLook
|
||||
};
|
||||
}
|
||||
set_mode(mode: Camera_Control_Mode) {
|
||||
if (mode !== Camera_Control_Mode.Surface_Navigation && this.viewer.scene.mode !== Cesium.SceneMode.SCENE3D) {
|
||||
mode = Camera_Control_Mode.Surface_Navigation;
|
||||
}
|
||||
this.destroy_handler();
|
||||
this.mode = mode;
|
||||
if (mode === Camera_Control_Mode.Surface_Navigation) {
|
||||
this.enable_surface_navigation();
|
||||
return;
|
||||
}
|
||||
this.disable_default_control();
|
||||
this.viewer.trackedEntity = undefined;
|
||||
this.viewer.camera.cancelFlight();
|
||||
this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
if (mode === Camera_Control_Mode.Earth_Orbit) {
|
||||
this.enable_earth_orbit();
|
||||
} else {
|
||||
this.enable_free_look();
|
||||
}
|
||||
}
|
||||
get_mode(): Camera_Control_Mode {
|
||||
return this.mode;
|
||||
}
|
||||
destroy() {
|
||||
this.destroy_handler();
|
||||
this.restore_default_control();
|
||||
}
|
||||
private enable_surface_navigation() {
|
||||
this.restore_default_control();
|
||||
this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
this.viewer.scene.requestRender();
|
||||
}
|
||||
private enable_earth_orbit() {
|
||||
const canvas = this.viewer.scene.canvas;
|
||||
this.handler = new Cesium.ScreenSpaceEventHandler(canvas);
|
||||
this.orient_to_earth_center();
|
||||
this.handler.setInputAction(() => {
|
||||
this.dragging = true;
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_DOWN);
|
||||
this.handler.setInputAction(() => {
|
||||
this.dragging = false;
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_UP);
|
||||
this.handler.setInputAction((movement: any) => {
|
||||
if (!this.dragging) return;
|
||||
const width = Math.max(1, canvas.clientWidth);
|
||||
const height = Math.max(1, canvas.clientHeight);
|
||||
const dx = (movement.endPosition.x - movement.startPosition.x) / width;
|
||||
const dy = (movement.endPosition.y - movement.startPosition.y) / height;
|
||||
const rotate_speed = Math.PI * 1.5;
|
||||
this.viewer.camera.rotateLeft(dx * rotate_speed);
|
||||
this.viewer.camera.rotateUp(-dy * rotate_speed);
|
||||
this.orient_to_earth_center();
|
||||
this.viewer.scene.requestRender();
|
||||
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
|
||||
this.handler.setInputAction((delta: number) => this.zoom_earth_orbit(delta), Cesium.ScreenSpaceEventType.WHEEL);
|
||||
}
|
||||
private enable_free_look() {
|
||||
const camera = this.viewer.camera;
|
||||
const canvas = this.viewer.scene.canvas;
|
||||
this.free_position = Cesium.Cartesian3.clone(camera.positionWC, this.free_position);
|
||||
this.free_heading = camera.heading;
|
||||
this.free_pitch = camera.pitch;
|
||||
this.free_roll = camera.roll;
|
||||
this.handler = new Cesium.ScreenSpaceEventHandler(canvas);
|
||||
this.handler.setInputAction(() => {
|
||||
this.dragging = true;
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_DOWN);
|
||||
this.handler.setInputAction(() => {
|
||||
this.dragging = false;
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_UP);
|
||||
this.handler.setInputAction((movement: any) => {
|
||||
if (!this.dragging) return;
|
||||
const width = Math.max(1, canvas.clientWidth);
|
||||
const height = Math.max(1, canvas.clientHeight);
|
||||
const dx = (movement.endPosition.x - movement.startPosition.x) / width;
|
||||
const dy = (movement.endPosition.y - movement.startPosition.y) / height;
|
||||
const look_speed = Math.PI * 1.5;
|
||||
this.free_heading = Cesium.Math.zeroToTwoPi(this.free_heading + dx * look_speed);
|
||||
this.free_pitch = Cesium.Math.clamp(this.free_pitch - dy * look_speed, -Cesium.Math.PI_OVER_TWO + 0.001, Cesium.Math.PI_OVER_TWO - 0.001);
|
||||
camera.setView({
|
||||
destination: this.free_position,
|
||||
orientation: {
|
||||
heading: this.free_heading,
|
||||
pitch: this.free_pitch,
|
||||
roll: this.free_roll
|
||||
}
|
||||
});
|
||||
this.viewer.scene.requestRender();
|
||||
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
|
||||
}
|
||||
private zoom_earth_orbit(delta: number) {
|
||||
const camera = this.viewer.camera;
|
||||
const ellipsoid = this.viewer.scene.globe.ellipsoid;
|
||||
const position = camera.positionWC;
|
||||
const radius = Cesium.Cartesian3.magnitude(position);
|
||||
const minimum_radius = ellipsoid.maximumRadius + 1000.0;
|
||||
const maximum_radius = 1000000000.0;
|
||||
const zoom_factor = Math.exp(-delta * 0.001);
|
||||
const next_radius = Cesium.Math.clamp(radius * zoom_factor, minimum_radius, maximum_radius);
|
||||
const radial = Cesium.Cartesian3.normalize(position, new Cesium.Cartesian3());
|
||||
const destination = Cesium.Cartesian3.multiplyByScalar(radial, next_radius, new Cesium.Cartesian3());
|
||||
camera.setView({destination});
|
||||
this.orient_to_earth_center();
|
||||
this.viewer.scene.requestRender();
|
||||
}
|
||||
private orient_to_earth_center() {
|
||||
const camera = this.viewer.camera;
|
||||
const destination = Cesium.Cartesian3.clone(camera.positionWC);
|
||||
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(camera.rightWC, direction), new Cesium.Cartesian3());
|
||||
right = Cesium.Cartesian3.subtract(camera.rightWC, projection, right);
|
||||
}
|
||||
Cesium.Cartesian3.normalize(right, right);
|
||||
const up = Cesium.Cartesian3.normalize(Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3()), new Cesium.Cartesian3());
|
||||
camera.setView({
|
||||
destination,
|
||||
orientation: {
|
||||
direction,
|
||||
up
|
||||
}
|
||||
});
|
||||
}
|
||||
private disable_default_control() {
|
||||
const controller = this.viewer.scene.screenSpaceCameraController;
|
||||
controller.enableRotate = false;
|
||||
controller.enableTranslate = false;
|
||||
controller.enableZoom = false;
|
||||
controller.enableTilt = false;
|
||||
controller.enableLook = false;
|
||||
}
|
||||
private restore_default_control() {
|
||||
const controller = this.viewer.scene.screenSpaceCameraController;
|
||||
controller.enableInputs = this.default_state.enable_inputs;
|
||||
controller.enableRotate = this.default_state.enable_rotate;
|
||||
controller.enableTranslate = this.default_state.enable_translate;
|
||||
controller.enableZoom = this.default_state.enable_zoom;
|
||||
controller.enableTilt = this.default_state.enable_tilt;
|
||||
controller.enableLook = this.default_state.enable_look;
|
||||
}
|
||||
private destroy_handler() {
|
||||
this.dragging = false;
|
||||
if (!this.handler) return;
|
||||
if (!this.handler.isDestroyed()) {
|
||||
this.handler.destroy();
|
||||
}
|
||||
this.handler = undefined;
|
||||
}
|
||||
}
|
||||
+600
-92
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ import {
|
||||
} from "./Map_Models.tsx";
|
||||
|
||||
type Model_Number_Key = keyof Pick<Map_Model_Item_Config, "builtInSize" | "headingOffsetDegrees" | "pitchOffsetDegrees" | "rollOffsetDegrees">
|
||||
type Model_Upload_Type = "aircraft" | "base_station" | "device" | "aircraft_type"
|
||||
export class Cesium_Model_Settings extends enhance.Base {
|
||||
config: Map_Model_Config = {...empty_map_model_config};
|
||||
number_input_drafts: Record<string, number | null> = {};
|
||||
@@ -22,7 +23,7 @@ export class Cesium_Model_Settings extends enhance.Base {
|
||||
this.number_input_drafts = {};
|
||||
this.flush();
|
||||
}
|
||||
async upload(model_type: "aircraft" | "base_station" | "aircraft_type", file: File, aircraft_model_key?: string) {
|
||||
async upload(model_type: Model_Upload_Type, file: File, aircraft_model_key?: string) {
|
||||
if (!file.name.toLowerCase().endsWith(".glb")) {
|
||||
message.error("只支持上传 .glb 模型");
|
||||
return false;
|
||||
@@ -32,9 +33,10 @@ export class Cesium_Model_Settings extends enhance.Base {
|
||||
this.flush();
|
||||
return false;
|
||||
}
|
||||
model_upload_message(model_type: "aircraft" | "base_station" | "aircraft_type"): string {
|
||||
model_upload_message(model_type: Model_Upload_Type): string {
|
||||
if (model_type === "aircraft") return "默认飞机模型已更新";
|
||||
if (model_type === "aircraft_type") return "分类飞机模型已更新";
|
||||
if (model_type === "device") return "设备模型已更新";
|
||||
return "基站模型已更新";
|
||||
}
|
||||
number_input_key(model: Map_Model_Item_Config, key: Model_Number_Key): string {
|
||||
@@ -65,10 +67,10 @@ export class Cesium_Model_Settings extends enhance.Base {
|
||||
message.success("模型配置已保存");
|
||||
this.flush();
|
||||
}
|
||||
render_model_detail(model: Map_Model_Item_Config) {
|
||||
render_model_detail(model: Map_Model_Item_Config, size_label = "内置大小") {
|
||||
return (
|
||||
<Space wrap>
|
||||
<InputNumber addonBefore="内置大小" value={this.number_input_value(model, "builtInSize")} min={0} step={1} precision={2} onChange={(value) => this.set_model_number(model, "builtInSize", value)} onBlur={() => this.blur_model_number(model, "builtInSize")} style={{width: 180}} />
|
||||
<InputNumber addonBefore={size_label} value={this.number_input_value(model, "builtInSize")} min={0} step={0.1} precision={6} onChange={(value) => this.set_model_number(model, "builtInSize", value)} onBlur={() => this.blur_model_number(model, "builtInSize")} style={{width: 210}} />
|
||||
<InputNumber addonBefore="航向偏移" value={this.number_input_value(model, "headingOffsetDegrees")} step={1} precision={2} onChange={(value) => this.set_model_number(model, "headingOffsetDegrees", value)} onBlur={() => this.blur_model_number(model, "headingOffsetDegrees")} style={{width: 180}} />
|
||||
<InputNumber addonBefore="俯仰偏移" value={this.number_input_value(model, "pitchOffsetDegrees")} step={1} precision={2} onChange={(value) => this.set_model_number(model, "pitchOffsetDegrees", value)} onBlur={() => this.blur_model_number(model, "pitchOffsetDegrees")} style={{width: 180}} />
|
||||
<InputNumber addonBefore="横滚偏移" value={this.number_input_value(model, "rollOffsetDegrees")} step={1} precision={2} onChange={(value) => this.set_model_number(model, "rollOffsetDegrees", value)} onBlur={() => this.blur_model_number(model, "rollOffsetDegrees")} style={{width: 180}} />
|
||||
@@ -90,7 +92,7 @@ export class Cesium_Model_Settings extends enhance.Base {
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
render_model_upload(label: string, model: Map_Model_Item_Config, model_type: "aircraft" | "base_station") {
|
||||
render_model_upload(label: string, model: Map_Model_Item_Config, model_type: "aircraft" | "base_station" | "device") {
|
||||
return (
|
||||
<Space direction="vertical" size={4}>
|
||||
<Space wrap>
|
||||
@@ -99,7 +101,7 @@ export class Cesium_Model_Settings extends enhance.Base {
|
||||
<Button icon={<UploadOutlined />}>上传GLB</Button>
|
||||
</Upload>
|
||||
</Space>
|
||||
{this.render_model_detail(model)}
|
||||
{this.render_model_detail(model, model_type === "base_station" ? "模型原始高度" : "内置大小")}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -112,6 +114,8 @@ export class Cesium_Model_Settings extends enhance.Base {
|
||||
{this.render_model_upload("飞机模型", this.config.aircraftModel, "aircraft")}
|
||||
<h3 style={row_style}>基站模型</h3>
|
||||
{this.render_model_upload("基站模型", this.config.baseStationModel, "base_station")}
|
||||
<h3 style={row_style}>设备模型</h3>
|
||||
{this.render_model_upload("设备模型", this.config.deviceModel, "device")}
|
||||
<h3 style={row_style}>按涡流/目标类型选择飞机模型</h3>
|
||||
{aircraft_model_type_options.map(option => this.render_aircraft_type_model(option))}
|
||||
<Button type="primary" onClick={() => this.save()}>保存模型配置</Button>
|
||||
|
||||
@@ -38,6 +38,12 @@ export type Cesium_Graphics_Config = {
|
||||
terrainMaximumLevel: number
|
||||
// 已解码地形高度瓦片缓存数量,越大越占内存但回看更快。
|
||||
terrainCacheTiles: number
|
||||
// 地形遮挡判断开关,基于 Terrarium 高程做基站到飞机的通视分析。
|
||||
terrainOcclusionEnabled: boolean
|
||||
// 通视分析沿 ECEF 视线采样的间距,越小越精细但请求和计算越多。
|
||||
occlusionSampleSpacingMeters: number
|
||||
// 视线相对地形至少需要保留的净空,低于该值视为遮挡。
|
||||
occlusionClearanceMarginMeters: number
|
||||
}
|
||||
export const default_cesium_graphics_config: Cesium_Graphics_Config = {
|
||||
debugShowFramesPerSecond: true,
|
||||
@@ -55,10 +61,13 @@ export const default_cesium_graphics_config: Cesium_Graphics_Config = {
|
||||
terrainExaggeration: 1.0,
|
||||
terrainLimitMaximumLevel: true,
|
||||
terrainMaximumLevel: 15,
|
||||
terrainCacheTiles: 128
|
||||
terrainCacheTiles: 128,
|
||||
terrainOcclusionEnabled: false,
|
||||
occlusionSampleSpacingMeters: 250,
|
||||
occlusionClearanceMarginMeters: 10
|
||||
};
|
||||
export const cesium_graphics_config_event = "ecap_cesium_graphics_config_changed";
|
||||
type Number_Config_Key = "targetFrameRate" | "resolutionScale" | "msaaSamples" | "solarLightIntensity" | "maximumScreenSpaceError" | "terrainExaggeration" | "terrainMaximumLevel" | "terrainCacheTiles"
|
||||
type Number_Config_Key = "targetFrameRate" | "resolutionScale" | "msaaSamples" | "solarLightIntensity" | "maximumScreenSpaceError" | "terrainExaggeration" | "terrainMaximumLevel" | "terrainCacheTiles" | "occlusionSampleSpacingMeters" | "occlusionClearanceMarginMeters"
|
||||
type Boolean_Config_Key = Exclude<keyof Cesium_Graphics_Config, Number_Config_Key>
|
||||
const graphics_presets: Record<"low" | "medium" | "high", Cesium_Graphics_Config> = {
|
||||
low: {...default_cesium_graphics_config},
|
||||
@@ -78,7 +87,10 @@ const graphics_presets: Record<"low" | "medium" | "high", Cesium_Graphics_Config
|
||||
terrainExaggeration: 1.0,
|
||||
terrainLimitMaximumLevel: true,
|
||||
terrainMaximumLevel: 15,
|
||||
terrainCacheTiles: 128
|
||||
terrainCacheTiles: 128,
|
||||
terrainOcclusionEnabled: false,
|
||||
occlusionSampleSpacingMeters: 250,
|
||||
occlusionClearanceMarginMeters: 10
|
||||
},
|
||||
high: {
|
||||
debugShowFramesPerSecond: true,
|
||||
@@ -96,7 +108,10 @@ const graphics_presets: Record<"low" | "medium" | "high", Cesium_Graphics_Config
|
||||
terrainExaggeration: 1.0,
|
||||
terrainLimitMaximumLevel: true,
|
||||
terrainMaximumLevel: 17,
|
||||
terrainCacheTiles: 256
|
||||
terrainCacheTiles: 256,
|
||||
terrainOcclusionEnabled: false,
|
||||
occlusionSampleSpacingMeters: 250,
|
||||
occlusionClearanceMarginMeters: 10
|
||||
}
|
||||
};
|
||||
const graphics_help: Record<keyof Cesium_Graphics_Config, string[]> = {
|
||||
@@ -115,7 +130,10 @@ const graphics_help: Record<keyof Cesium_Graphics_Config, string[]> = {
|
||||
terrainExaggeration: ["控制地形垂直方向的视觉夸张倍率。", "1.0 为真实高度;0.0 压平地形;只改变视觉起伏,不增加高程精度,前端不限制范围。"],
|
||||
terrainLimitMaximumLevel: ["限制 Terrarium 地形继续请求更深层级。", "只限制高程层级,减少无意义子瓦片请求。"],
|
||||
terrainMaximumLevel: ["地形允许加载的最高瓦片层级。", "只有启用地形且限制层级时生效;越高越精细也越耗资源。"],
|
||||
terrainCacheTiles: ["内存中保留的已解码地形瓦片数量。", "越大回看越快但占用更多内存;过小会增加重复请求和解码。"]
|
||||
terrainCacheTiles: ["内存中保留的已解码地形瓦片数量。", "越大回看越快但占用更多内存;过小会增加重复请求和解码。"],
|
||||
terrainOcclusionEnabled: ["基于 Terrarium 高程做基站到飞机的地形通视分析。", "开启后会显示绿色/红色视线和首次遮挡点;只判断地形,不判断建筑、树木或无线电链路。"],
|
||||
occlusionSampleSpacingMeters: ["通视分析沿三维直线的采样间距。", "越小越容易发现狭窄山脊,但请求和计算更多;建议 100 到 500 米。"],
|
||||
occlusionClearanceMarginMeters: ["视线相对地形的最小净空裕量。", "净空小于等于该值时判为遮挡;可用来给高程误差留余量。"]
|
||||
};
|
||||
type Cesium_Graphics_Config_Json = {
|
||||
debug_show_frames_per_second: boolean
|
||||
@@ -134,6 +152,9 @@ type Cesium_Graphics_Config_Json = {
|
||||
terrain_limit_maximum_level: boolean
|
||||
terrain_maximum_level: number
|
||||
terrain_cache_tiles: number
|
||||
terrain_occlusion_enabled: boolean
|
||||
occlusion_sample_spacing_meters: number
|
||||
occlusion_clearance_margin_meters: number
|
||||
}
|
||||
function number_value(value: unknown, fallback: number, min: number, max: number): number {
|
||||
const next = Number(value);
|
||||
@@ -164,7 +185,10 @@ export function normalize_cesium_graphics_config(data: Partial<Cesium_Graphics_C
|
||||
terrainExaggeration: unrestricted_number_value(data.terrainExaggeration, default_cesium_graphics_config.terrainExaggeration),
|
||||
terrainLimitMaximumLevel: boolean_value(data.terrainLimitMaximumLevel, default_cesium_graphics_config.terrainLimitMaximumLevel),
|
||||
terrainMaximumLevel: number_value(data.terrainMaximumLevel, default_cesium_graphics_config.terrainMaximumLevel, 0, 24),
|
||||
terrainCacheTiles: Math.round(number_value(data.terrainCacheTiles, default_cesium_graphics_config.terrainCacheTiles, 16, 2048))
|
||||
terrainCacheTiles: Math.round(number_value(data.terrainCacheTiles, default_cesium_graphics_config.terrainCacheTiles, 16, 2048)),
|
||||
terrainOcclusionEnabled: boolean_value(data.terrainOcclusionEnabled, default_cesium_graphics_config.terrainOcclusionEnabled),
|
||||
occlusionSampleSpacingMeters: number_value(data.occlusionSampleSpacingMeters, default_cesium_graphics_config.occlusionSampleSpacingMeters, 10, 5000),
|
||||
occlusionClearanceMarginMeters: unrestricted_number_value(data.occlusionClearanceMarginMeters, default_cesium_graphics_config.occlusionClearanceMarginMeters)
|
||||
};
|
||||
}
|
||||
function from_server_config(data: Partial<Cesium_Graphics_Config_Json>): Cesium_Graphics_Config {
|
||||
@@ -184,7 +208,10 @@ function from_server_config(data: Partial<Cesium_Graphics_Config_Json>): Cesium_
|
||||
terrainExaggeration: data.terrain_exaggeration,
|
||||
terrainLimitMaximumLevel: data.terrain_limit_maximum_level,
|
||||
terrainMaximumLevel: data.terrain_maximum_level,
|
||||
terrainCacheTiles: data.terrain_cache_tiles
|
||||
terrainCacheTiles: data.terrain_cache_tiles,
|
||||
terrainOcclusionEnabled: data.terrain_occlusion_enabled,
|
||||
occlusionSampleSpacingMeters: data.occlusion_sample_spacing_meters,
|
||||
occlusionClearanceMarginMeters: data.occlusion_clearance_margin_meters
|
||||
});
|
||||
}
|
||||
function to_server_config(config: Cesium_Graphics_Config): Cesium_Graphics_Config_Json {
|
||||
@@ -205,7 +232,10 @@ function to_server_config(config: Cesium_Graphics_Config): Cesium_Graphics_Confi
|
||||
terrain_exaggeration: normalized.terrainExaggeration,
|
||||
terrain_limit_maximum_level: normalized.terrainLimitMaximumLevel,
|
||||
terrain_maximum_level: normalized.terrainMaximumLevel,
|
||||
terrain_cache_tiles: normalized.terrainCacheTiles
|
||||
terrain_cache_tiles: normalized.terrainCacheTiles,
|
||||
terrain_occlusion_enabled: normalized.terrainOcclusionEnabled,
|
||||
occlusion_sample_spacing_meters: normalized.occlusionSampleSpacingMeters,
|
||||
occlusion_clearance_margin_meters: normalized.occlusionClearanceMarginMeters
|
||||
};
|
||||
}
|
||||
export async function load_cesium_graphics_config(): Promise<Cesium_Graphics_Config> {
|
||||
@@ -346,6 +376,9 @@ export class Cesium_Render_Settings extends enhance.Base {
|
||||
{this.render_switch("限制地形层级", "terrainLimitMaximumLevel")}
|
||||
{this.render_number("地形最大层级", "terrainMaximumLevel", 0, 24, 1)}
|
||||
{this.render_number("地形缓存瓦片数", "terrainCacheTiles", 16, 2048, 16)}
|
||||
{this.render_switch("地形遮挡判断", "terrainOcclusionEnabled")}
|
||||
{this.render_number("遮挡采样间距", "occlusionSampleSpacingMeters", 10, 5000, 10)}
|
||||
{this.render_number("遮挡净空裕量", "occlusionClearanceMarginMeters", -1000, 10000, 1)}
|
||||
<Space>
|
||||
<Button onClick={() => this.reset()}>恢复默认画质</Button>
|
||||
<Button type="primary" onClick={() => this.save()}>确定</Button>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type {Map_Tile_Y_Axis} from "./Map_Resources.tsx";
|
||||
|
||||
export type Los_Position = [number, number, number]
|
||||
export type Los_Terrain_Resource = {
|
||||
url: string
|
||||
minimum_level: number
|
||||
maximum_level: number
|
||||
tile_size: number
|
||||
y_axis: Map_Tile_Y_Axis
|
||||
}
|
||||
export type Los_Request = {
|
||||
aircraft_id: string
|
||||
station: Los_Position
|
||||
aircraft: Los_Position
|
||||
terrain: Los_Terrain_Resource
|
||||
terrain_level: number
|
||||
coarse_spacing: number
|
||||
clearance_margin: number
|
||||
}
|
||||
export type Los_Result = {
|
||||
aircraft_id: string
|
||||
obstructed: boolean
|
||||
minimum_clearance?: number
|
||||
obstruction_position?: Los_Position
|
||||
line_height?: number
|
||||
terrain_height?: number
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import type {Los_Position, Los_Request, Los_Result, Los_Terrain_Resource} from "./Los_Protocol.ts";
|
||||
|
||||
interface Worker_Request<T> {
|
||||
task_id: number
|
||||
input: T
|
||||
}
|
||||
interface Worker_Response<T> {
|
||||
task_id: number
|
||||
success: boolean
|
||||
output?: T
|
||||
error?: string
|
||||
}
|
||||
type Cartesian = {
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
}
|
||||
type Cartographic = {
|
||||
longitude: number
|
||||
latitude: number
|
||||
height: number
|
||||
}
|
||||
type Worker_Context = {
|
||||
onmessage: ((event: MessageEvent<Worker_Request<Los_Request>>) => void) | null
|
||||
postMessage: (message: Worker_Response<Los_Result>) => void
|
||||
}
|
||||
const worker_context = self as unknown as Worker_Context;
|
||||
const wgs84_a = 6378137.0;
|
||||
const wgs84_f = 1.0 / 298.257223563;
|
||||
const wgs84_b = wgs84_a * (1.0 - wgs84_f);
|
||||
const wgs84_e2 = 1.0 - (wgs84_b * wgs84_b) / (wgs84_a * wgs84_a);
|
||||
const wgs84_ep2 = (wgs84_a * wgs84_a - wgs84_b * wgs84_b) / (wgs84_b * wgs84_b);
|
||||
const max_mercator_latitude = 85.0511287798066;
|
||||
const tile_cache: Map<string, Promise<Float32Array | undefined>> = new Map();
|
||||
const max_tile_cache_items = 256;
|
||||
worker_context.onmessage = async event => {
|
||||
const request = event.data as Worker_Request<Los_Request>;
|
||||
try {
|
||||
const output = await calculate_los(request.input);
|
||||
worker_context.postMessage({task_id: request.task_id, success: true, output} as Worker_Response<Los_Result>);
|
||||
} catch (error) {
|
||||
worker_context.postMessage({task_id: request.task_id, success: false, error: error instanceof Error ? error.message : String(error)} as Worker_Response<Los_Result>);
|
||||
}
|
||||
};
|
||||
async function calculate_los(request: Los_Request): Promise<Los_Result> {
|
||||
const station = cartographic_to_cartesian(position_to_cartographic(request.station));
|
||||
const aircraft = cartographic_to_cartesian(position_to_cartographic(request.aircraft));
|
||||
const distance = cartesian_distance(station, aircraft);
|
||||
const spacing = Math.max(10, request.coarse_spacing || 250);
|
||||
const segment_count = Math.max(1, Math.min(800, Math.ceil(distance / spacing)));
|
||||
const samples: {position: Cartesian, cartographic: Cartographic}[] = [];
|
||||
for (let i = 1; i < segment_count; i++) {
|
||||
const position = lerp_cartesian(station, aircraft, i / segment_count);
|
||||
samples.push({position, cartographic: cartesian_to_cartographic(position)});
|
||||
}
|
||||
const heights = await Promise.all(samples.map(sample => sample_height(request.terrain, sample.cartographic, request.terrain_level)));
|
||||
let minimum_clearance = Number.POSITIVE_INFINITY;
|
||||
let minimum_index = -1;
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const terrain_height = heights[i];
|
||||
if (terrain_height === undefined) continue;
|
||||
const line_height = samples[i].cartographic.height;
|
||||
const clearance = line_height - terrain_height;
|
||||
if (clearance < minimum_clearance) {
|
||||
minimum_clearance = clearance;
|
||||
minimum_index = i;
|
||||
}
|
||||
if (clearance <= request.clearance_margin) {
|
||||
return {
|
||||
aircraft_id: request.aircraft_id,
|
||||
obstructed: true,
|
||||
minimum_clearance: clearance,
|
||||
obstruction_position: cartographic_to_position(samples[i].cartographic),
|
||||
line_height,
|
||||
terrain_height
|
||||
};
|
||||
}
|
||||
}
|
||||
if (minimum_index >= 0) {
|
||||
return {
|
||||
aircraft_id: request.aircraft_id,
|
||||
obstructed: false,
|
||||
minimum_clearance,
|
||||
line_height: samples[minimum_index].cartographic.height,
|
||||
terrain_height: heights[minimum_index]
|
||||
};
|
||||
}
|
||||
return {
|
||||
aircraft_id: request.aircraft_id,
|
||||
obstructed: false
|
||||
};
|
||||
}
|
||||
function position_to_cartographic(position: Los_Position): Cartographic {
|
||||
return {
|
||||
longitude: degrees_to_radians(position[0]),
|
||||
latitude: degrees_to_radians(position[1]),
|
||||
height: position[2]
|
||||
};
|
||||
}
|
||||
function cartographic_to_position(position: Cartographic): Los_Position {
|
||||
return [radians_to_degrees(position.longitude), radians_to_degrees(position.latitude), position.height];
|
||||
}
|
||||
function cartographic_to_cartesian(position: Cartographic): Cartesian {
|
||||
const sin_lat = Math.sin(position.latitude);
|
||||
const cos_lat = Math.cos(position.latitude);
|
||||
const cos_lon = Math.cos(position.longitude);
|
||||
const sin_lon = Math.sin(position.longitude);
|
||||
const normal = wgs84_a / Math.sqrt(1.0 - wgs84_e2 * sin_lat * sin_lat);
|
||||
return {
|
||||
x: (normal + position.height) * cos_lat * cos_lon,
|
||||
y: (normal + position.height) * cos_lat * sin_lon,
|
||||
z: (normal * (1.0 - wgs84_e2) + position.height) * sin_lat
|
||||
};
|
||||
}
|
||||
function cartesian_to_cartographic(position: Cartesian): Cartographic {
|
||||
const p = Math.sqrt(position.x * position.x + position.y * position.y);
|
||||
const theta = Math.atan2(position.z * wgs84_a, p * wgs84_b);
|
||||
const sin_theta = Math.sin(theta);
|
||||
const cos_theta = Math.cos(theta);
|
||||
const longitude = Math.atan2(position.y, position.x);
|
||||
const latitude = Math.atan2(position.z + wgs84_ep2 * wgs84_b * sin_theta * sin_theta * sin_theta, p - wgs84_e2 * wgs84_a * cos_theta * cos_theta * cos_theta);
|
||||
const sin_lat = Math.sin(latitude);
|
||||
const normal = wgs84_a / Math.sqrt(1.0 - wgs84_e2 * sin_lat * sin_lat);
|
||||
const height = p / Math.cos(latitude) - normal;
|
||||
return {longitude, latitude, height};
|
||||
}
|
||||
function lerp_cartesian(a: Cartesian, b: Cartesian, t: number): Cartesian {
|
||||
return {
|
||||
x: a.x + (b.x - a.x) * t,
|
||||
y: a.y + (b.y - a.y) * t,
|
||||
z: a.z + (b.z - a.z) * t
|
||||
};
|
||||
}
|
||||
function cartesian_distance(a: Cartesian, b: Cartesian): number {
|
||||
const dx = a.x - b.x;
|
||||
const dy = a.y - b.y;
|
||||
const dz = a.z - b.z;
|
||||
return Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
}
|
||||
async function sample_height(resource: Los_Terrain_Resource, position: Cartographic, level: number): Promise<number | undefined> {
|
||||
const sample_level = Math.max(resource.minimum_level, Math.min(level, resource.maximum_level));
|
||||
const tile = position_to_tile(position, sample_level);
|
||||
const heights = await load_height_tile(resource, tile.x, tile.y, sample_level);
|
||||
if (!heights) return undefined;
|
||||
const pixel = position_to_tile_pixel(position, sample_level, resource.tile_size);
|
||||
return bilinear_height(heights, resource.tile_size, pixel.x - tile.x * resource.tile_size, pixel.y - tile.y * resource.tile_size);
|
||||
}
|
||||
function position_to_tile(position: Cartographic, level: number): {x: number, y: number} {
|
||||
const n = Math.pow(2, level);
|
||||
const longitude = radians_to_degrees(position.longitude);
|
||||
const latitude = clamp(radians_to_degrees(position.latitude), -max_mercator_latitude, max_mercator_latitude);
|
||||
const lat_rad = degrees_to_radians(latitude);
|
||||
const x = Math.floor(((longitude + 180.0) / 360.0) * n);
|
||||
const y = Math.floor(((1.0 - Math.log(Math.tan(lat_rad) + 1.0 / Math.cos(lat_rad)) / Math.PI) / 2.0) * n);
|
||||
return {
|
||||
x: clamp_integer(x, 0, n - 1),
|
||||
y: clamp_integer(y, 0, n - 1)
|
||||
};
|
||||
}
|
||||
function position_to_tile_pixel(position: Cartographic, level: number, tile_size: number): {x: number, y: number} {
|
||||
const n = Math.pow(2, level);
|
||||
const longitude = radians_to_degrees(position.longitude);
|
||||
const latitude = clamp(radians_to_degrees(position.latitude), -max_mercator_latitude, max_mercator_latitude);
|
||||
const lat_rad = degrees_to_radians(latitude);
|
||||
const world_size = n * tile_size;
|
||||
return {
|
||||
x: clamp(((longitude + 180.0) / 360.0) * world_size, 0, world_size - 1),
|
||||
y: clamp(((1.0 - Math.log(Math.tan(lat_rad) + 1.0 / Math.cos(lat_rad)) / Math.PI) / 2.0) * world_size, 0, world_size - 1)
|
||||
};
|
||||
}
|
||||
function bilinear_height(heights: Float32Array, size: number, x: number, y: number): number {
|
||||
const x0 = Math.floor(x);
|
||||
const y0 = Math.floor(y);
|
||||
const x1 = Math.min(size - 1, x0 + 1);
|
||||
const y1 = Math.min(size - 1, y0 + 1);
|
||||
const tx = x - x0;
|
||||
const ty = y - y0;
|
||||
const h00 = heights[y0 * size + x0];
|
||||
const h10 = heights[y0 * size + x1];
|
||||
const h01 = heights[y1 * size + x0];
|
||||
const h11 = heights[y1 * size + x1];
|
||||
const h0 = h00 * (1 - tx) + h10 * tx;
|
||||
const h1 = h01 * (1 - tx) + h11 * tx;
|
||||
return h0 * (1 - ty) + h1 * ty;
|
||||
}
|
||||
function load_height_tile(resource: Los_Terrain_Resource, x: number, y: number, level: number): Promise<Float32Array | undefined> {
|
||||
const key = `${resource.url}:${resource.y_axis}:${level}/${x}/${y}`;
|
||||
const cached = tile_cache.get(key);
|
||||
if (cached) return cached;
|
||||
const promise = fetch_height_tile(resource, x, y, level).catch(error => {
|
||||
tile_cache.delete(key);
|
||||
throw error;
|
||||
});
|
||||
tile_cache.set(key, promise);
|
||||
trim_tile_cache();
|
||||
return promise;
|
||||
}
|
||||
async function fetch_height_tile(resource: Los_Terrain_Resource, x: number, y: number, level: number): Promise<Float32Array | undefined> {
|
||||
const response = await fetch(tile_url(resource, x, y, level), {cache: "force-cache"});
|
||||
if (response.status === 404) return undefined;
|
||||
if (!response.ok) throw new Error(`terrain tile request failed: ${response.status}`);
|
||||
return decode_terrarium_png(await response.blob(), resource.tile_size);
|
||||
}
|
||||
async function decode_terrarium_png(blob: Blob, tile_size: number): Promise<Float32Array> {
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
try {
|
||||
if (typeof OffscreenCanvas === "undefined") throw new Error("OffscreenCanvas is not available");
|
||||
const canvas = new OffscreenCanvas(tile_size, tile_size);
|
||||
const context = canvas.getContext("2d", {willReadFrequently: true});
|
||||
if (!context) throw new Error("worker canvas 2d context is not available");
|
||||
context.drawImage(bitmap, 0, 0, tile_size, tile_size);
|
||||
const image = context.getImageData(0, 0, tile_size, tile_size);
|
||||
const heights = new Float32Array(tile_size * tile_size);
|
||||
for (let i = 0, j = 0; i < heights.length; i++, j += 4) {
|
||||
heights[i] = image.data[j] * 256 + image.data[j + 1] + image.data[j + 2] / 256 - 32768;
|
||||
}
|
||||
return heights;
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
}
|
||||
function tile_url(resource: Los_Terrain_Resource, x: number, y: number, level: number): string {
|
||||
const tile_y = resource.y_axis === "tms" ? Math.pow(2, level) - 1 - y : y;
|
||||
return resource.url
|
||||
.replace("{z}", String(level))
|
||||
.replace("{x}", String(x))
|
||||
.replace("{y}", String(tile_y));
|
||||
}
|
||||
function trim_tile_cache() {
|
||||
while (tile_cache.size > max_tile_cache_items) {
|
||||
const first = tile_cache.keys().next().value;
|
||||
if (first === undefined) return;
|
||||
tile_cache.delete(first);
|
||||
}
|
||||
}
|
||||
function degrees_to_radians(value: number): number {
|
||||
return value * Math.PI / 180.0;
|
||||
}
|
||||
function radians_to_degrees(value: number): number {
|
||||
return value * 180.0 / Math.PI;
|
||||
}
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
function clamp_integer(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
export {};
|
||||
@@ -39,6 +39,7 @@ export type Map_Model_Config = {
|
||||
aircraftModel: Map_Model_Item_Config
|
||||
aircraftModels: Record<string, Map_Model_Item_Config>
|
||||
baseStationModel: Map_Model_Item_Config
|
||||
deviceModel: Map_Model_Item_Config
|
||||
}
|
||||
type Map_Model_Item_Config_Json = {
|
||||
url: string
|
||||
@@ -51,6 +52,7 @@ type Map_Model_Config_Json = {
|
||||
aircraft_model: Map_Model_Item_Config_Json
|
||||
aircraft_models?: Record<string, Map_Model_Item_Config_Json>
|
||||
base_station_model: Map_Model_Item_Config_Json
|
||||
device_model: Map_Model_Item_Config_Json
|
||||
}
|
||||
function default_model_item(url: string, builtInSize: number, heading = 0, pitch = 0, roll = 0): Map_Model_Item_Config {
|
||||
return {url, builtInSize, headingOffsetDegrees: heading, pitchOffsetDegrees: pitch, rollOffsetDegrees: roll};
|
||||
@@ -61,7 +63,8 @@ function default_aircraft_models(): Record<string, Map_Model_Item_Config> {
|
||||
export const empty_map_model_config: Map_Model_Config = {
|
||||
aircraftModel: default_model_item("/ui/model/aircraft.glb", 50, -90),
|
||||
aircraftModels: default_aircraft_models(),
|
||||
baseStationModel: default_model_item("/ui/model/base-station.glb", 100)
|
||||
baseStationModel: default_model_item("/ui/model/base-station.glb", 16.635522),
|
||||
deviceModel: default_model_item("/ui/model/device.glb", 80)
|
||||
};
|
||||
export const map_model_config_event = "ecap_map_model_config_changed";
|
||||
function number_or(value: unknown, fallback: number): number {
|
||||
@@ -99,14 +102,16 @@ function from_server_config(data: Partial<Map_Model_Config_Json>): Map_Model_Con
|
||||
return {
|
||||
aircraftModel: from_server_model_item(data.aircraft_model, empty_map_model_config.aircraftModel),
|
||||
aircraftModels: aircraft_models_from_server(data.aircraft_models),
|
||||
baseStationModel: from_server_model_item(data.base_station_model, empty_map_model_config.baseStationModel)
|
||||
baseStationModel: from_server_model_item(data.base_station_model, empty_map_model_config.baseStationModel),
|
||||
deviceModel: from_server_model_item(data.device_model, empty_map_model_config.deviceModel)
|
||||
};
|
||||
}
|
||||
function to_server_config(config: Map_Model_Config): Map_Model_Config_Json {
|
||||
return {
|
||||
aircraft_model: to_server_model_item(config.aircraftModel),
|
||||
aircraft_models: Object.fromEntries(Object.entries(config.aircraftModels).map(([key, value]) => [key, to_server_model_item(value)])),
|
||||
base_station_model: to_server_model_item(config.baseStationModel)
|
||||
base_station_model: to_server_model_item(config.baseStationModel),
|
||||
device_model: to_server_model_item(config.deviceModel)
|
||||
};
|
||||
}
|
||||
export async function load_map_model_config(): Promise<Map_Model_Config> {
|
||||
@@ -119,7 +124,7 @@ export async function save_map_model_config(config: Map_Model_Config): Promise<M
|
||||
emit_map_model_config(saved_config);
|
||||
return saved_config;
|
||||
}
|
||||
export async function upload_map_model(model_type: "aircraft" | "base_station" | "aircraft_type", file: File, aircraft_model_key?: string): Promise<Map_Model_Config> {
|
||||
export async function upload_map_model(model_type: "aircraft" | "base_station" | "device" | "aircraft_type", file: File, aircraft_model_key?: string): Promise<Map_Model_Config> {
|
||||
const form = new FormData();
|
||||
form.append("model_type", model_type);
|
||||
if (aircraft_model_key) form.append("aircraft_model_key", aircraft_model_key);
|
||||
|
||||
@@ -28,6 +28,7 @@ export class Terrarium_Terrain_Provider {
|
||||
private next_worker_request_id = 1
|
||||
private maximum_level?: number
|
||||
private max_cached_height_tiles: number
|
||||
private tiling_scheme: Cesium.WebMercatorTilingScheme
|
||||
private destroyed = false
|
||||
constructor(resource: Map_Tile_Metadata, options: Terrarium_Terrain_Provider_Options = {}) {
|
||||
if (resource.projection !== "web_mercator") {
|
||||
@@ -39,13 +40,14 @@ export class Terrarium_Terrain_Provider {
|
||||
this.resource = resource
|
||||
this.maximum_level = options.maximum_level
|
||||
this.max_cached_height_tiles = options.max_cached_height_tiles || 128
|
||||
this.tiling_scheme = new Cesium.WebMercatorTilingScheme()
|
||||
this.worker = new Worker(new URL("./Terrarium_Terrain_Worker.ts", import.meta.url), {type: "module"})
|
||||
this.worker.onmessage = (event: MessageEvent<Worker_Response>) => this.handle_worker_response(event.data)
|
||||
this.worker.onerror = (event) => this.reject_all_worker_requests(new Error(event.message))
|
||||
const provider = new Cesium.CustomHeightmapTerrainProvider({
|
||||
width: resource.tile_size,
|
||||
height: resource.tile_size,
|
||||
tilingScheme: new Cesium.WebMercatorTilingScheme(),
|
||||
tilingScheme: this.tiling_scheme,
|
||||
callback: () => new Float32Array(resource.tile_size * resource.tile_size)
|
||||
})
|
||||
;(provider as any).requestTileGeometry = (x: number, y: number, level: number, request?: Cesium.Request) => {
|
||||
@@ -62,6 +64,37 @@ export class Terrarium_Terrain_Provider {
|
||||
this.worker.terminate()
|
||||
this.height_cache.clear()
|
||||
}
|
||||
async sample_height(cartographic: Cesium.Cartographic, level?: number): Promise<number | undefined> {
|
||||
if (this.destroyed) return undefined
|
||||
const sample_level = this.sample_level(level)
|
||||
const tile = this.tiling_scheme.positionToTileXY(cartographic, sample_level)
|
||||
const heights = await this.load_height_tile(tile.x, tile.y, sample_level)
|
||||
if (!heights || this.destroyed) return undefined
|
||||
const rectangle = this.tiling_scheme.tileXYToRectangle(tile.x, tile.y, sample_level)
|
||||
const x = Cesium.Math.clamp((cartographic.longitude - rectangle.west) / (rectangle.east - rectangle.west), 0, 1) * (this.resource.tile_size - 1)
|
||||
const y = Cesium.Math.clamp((rectangle.north - cartographic.latitude) / (rectangle.north - rectangle.south), 0, 1) * (this.resource.tile_size - 1)
|
||||
return this.bilinear_height(heights, x, y)
|
||||
}
|
||||
private sample_level(level?: number): number {
|
||||
const requested = level ?? this.maximum_level ?? this.resource.maximum_level
|
||||
return Math.max(this.resource.minimum_level, Math.min(requested, this.maximum_level ?? this.resource.maximum_level))
|
||||
}
|
||||
private bilinear_height(heights: Float32Array, x: number, y: number): number {
|
||||
const size = this.resource.tile_size
|
||||
const x0 = Math.floor(x)
|
||||
const y0 = Math.floor(y)
|
||||
const x1 = Math.min(size - 1, x0 + 1)
|
||||
const y1 = Math.min(size - 1, y0 + 1)
|
||||
const tx = x - x0
|
||||
const ty = y - y0
|
||||
const h00 = heights[y0 * size + x0]
|
||||
const h10 = heights[y0 * size + x1]
|
||||
const h01 = heights[y1 * size + x0]
|
||||
const h11 = heights[y1 * size + x1]
|
||||
const h0 = h00 * (1 - tx) + h10 * tx
|
||||
const h1 = h01 * (1 - tx) + h11 * tx
|
||||
return h0 * (1 - ty) + h1 * ty
|
||||
}
|
||||
private request_tile_geometry(x: number, y: number, level: number, request?: Cesium.Request): Promise<Cesium.TerrainData | undefined> | undefined {
|
||||
if (this.destroyed || !this.is_level_available(level)) {
|
||||
return undefined
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
interface Worker_Request<T> {
|
||||
task_id: number
|
||||
input: T
|
||||
}
|
||||
interface Worker_Response<T> {
|
||||
task_id: number
|
||||
success: boolean
|
||||
output?: T
|
||||
error?: string
|
||||
}
|
||||
interface Pending_Task<T_Input, T_Output> {
|
||||
task_id: number
|
||||
input: T_Input
|
||||
transfer: unknown[]
|
||||
resolve: (output: T_Output) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
interface Worker_Slot<T_Input, T_Output> {
|
||||
worker: Worker
|
||||
task?: Pending_Task<T_Input, T_Output>
|
||||
}
|
||||
export class Worker_Pool<T_Input, T_Output> {
|
||||
private readonly slots: Worker_Slot<T_Input, T_Output>[] = []
|
||||
private readonly queue: Pending_Task<T_Input, T_Output>[] = []
|
||||
private next_task_id = 1
|
||||
private destroyed = false
|
||||
constructor(worker_factory: () => Worker, worker_count: number) {
|
||||
for (let i = 0; i < worker_count; i++) {
|
||||
const slot: Worker_Slot<T_Input, T_Output> = {
|
||||
worker: worker_factory()
|
||||
}
|
||||
slot.worker.onmessage = event => this.handle_message(slot, event.data as Worker_Response<T_Output>)
|
||||
slot.worker.onerror = event => this.handle_error(slot, new Error(event.message))
|
||||
slot.worker.onmessageerror = () => this.handle_error(slot, new Error("Worker message decode failed"))
|
||||
this.slots.push(slot)
|
||||
}
|
||||
}
|
||||
run(input: T_Input, transfer: unknown[] = []): Promise<T_Output> {
|
||||
if (this.destroyed) {
|
||||
return Promise.reject(new Error("Worker pool has been destroyed"))
|
||||
}
|
||||
return new Promise<T_Output>((resolve, reject) => {
|
||||
this.queue.push({
|
||||
task_id: this.next_task_id++,
|
||||
input,
|
||||
transfer,
|
||||
resolve,
|
||||
reject
|
||||
})
|
||||
this.dispatch()
|
||||
})
|
||||
}
|
||||
destroy() {
|
||||
if (this.destroyed) return
|
||||
this.destroyed = true
|
||||
for (const task of this.queue) {
|
||||
task.reject(new Error("Worker pool has been destroyed"))
|
||||
}
|
||||
this.queue.length = 0
|
||||
for (const slot of this.slots) {
|
||||
slot.task?.reject(new Error("Worker pool has been destroyed"))
|
||||
slot.worker.terminate()
|
||||
slot.task = undefined
|
||||
}
|
||||
this.slots.length = 0
|
||||
}
|
||||
private dispatch() {
|
||||
if (this.destroyed) return
|
||||
for (const slot of this.slots) {
|
||||
if (slot.task) continue
|
||||
const task = this.queue.shift()
|
||||
if (!task) return
|
||||
slot.task = task
|
||||
slot.worker.postMessage({task_id: task.task_id, input: task.input} as Worker_Request<T_Input>, task.transfer as any)
|
||||
}
|
||||
}
|
||||
private handle_message(slot: Worker_Slot<T_Input, T_Output>, response: Worker_Response<T_Output>) {
|
||||
const task = slot.task
|
||||
slot.task = undefined
|
||||
if (!task) {
|
||||
this.dispatch()
|
||||
return
|
||||
}
|
||||
if (response.task_id !== task.task_id) {
|
||||
task.reject(new Error("Worker task id mismatch"))
|
||||
} else if (!response.success) {
|
||||
task.reject(new Error(response.error || "Worker task failed"))
|
||||
} else {
|
||||
task.resolve(response.output as T_Output)
|
||||
}
|
||||
this.dispatch()
|
||||
}
|
||||
private handle_error(slot: Worker_Slot<T_Input, T_Output>, error: Error) {
|
||||
const task = slot.task
|
||||
slot.task = undefined
|
||||
task?.reject(error)
|
||||
this.dispatch()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user