缩减代码

This commit is contained in:
2026-08-09 21:13:06 +08:00
parent f54c5e1a78
commit 2be6dc84f5
29 changed files with 409 additions and 1591 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default [
{ ignores: ['dist/**', 'wwwroot/**', 'htdocs/**', 'cesium/**', 'node_modules/**'] },
{ ignores: ['dist/**', 'public/**', 'wwwroot/**', 'htdocs/**', 'cesium/**', 'node_modules/**'] },
{
files: ['src/**/*.{ts,tsx}'],
languageOptions: {
+1 -1
View File
@@ -2,7 +2,7 @@ import {DownloadOutlined, UploadOutlined} from "@ant-design/icons"
import {Button, Input, message, Modal, Progress, Select, Space, Upload} from "antd"
import axios from "axios"
import {useMemo, useState} from "react"
export type Archive_Format = {
type Archive_Format = {
label: string
value: string
format: string
+18 -1
View File
@@ -21,6 +21,7 @@ export type Backend_Form_Section = {
descriptor_path?: string
fields?: string[]
layout?: Backend_Field_Layout[]
modes?: string[]
}
export type Backend_Field_Layout = {
@@ -38,6 +39,22 @@ export type Backend_Disabled_Rule = {
}
}
export type Backend_Schema_Node = Backend_Form_Section & {
descriptor_fields?: Backend_Field_Descriptor[]
rules?: Backend_Disabled_Rule[]
sections?: Backend_Schema_Node[]
[key: string]: unknown
}
export function backend_schema_node(value: unknown, ...path: string[]): Backend_Schema_Node | undefined {
let node = value;
for (const key of path) {
if (!node || typeof node !== "object") return undefined;
node = (node as Record<string, unknown>)[key];
}
return node && typeof node === "object" ? node as Backend_Schema_Node : undefined;
}
type Json_Object = Record<string, any>
export function get_path(root: Json_Object, path = ""): any {
@@ -45,7 +62,7 @@ export function get_path(root: Json_Object, path = ""): any {
return path.split(".").reduce((value: any, key) => value?.[key], root);
}
export function fields_at_path(fields: Backend_Field_Descriptor[], path = ""): Backend_Field_Descriptor[] {
function fields_at_path(fields: Backend_Field_Descriptor[], path = ""): Backend_Field_Descriptor[] {
if (!path) return fields;
let current = fields;
for (const name of path.split(".")) {
+32 -98
View File
@@ -5,12 +5,12 @@ import enhance from './core/enhance.tsx';
import {useEffect} from 'react';
import {SettingOutlined} from '@ant-design/icons';
import {Menu, MenuProps, message} from 'antd';
import {Menu, type MenuProps, message} from 'antd';
import {
BrowserRouter,
Navigate,
NavigateFunction,
type NavigateFunction,
Route,
Routes,
useLocation,
@@ -20,7 +20,7 @@ import {
import React from 'react';
import ReactDOM from 'react-dom/client';
import axios from "axios";
import {baseURL} from "./Global.tsx";
import {baseURL} from "./Global.ts";
import {is_cesium_webgl_available, webgl_unavailable_message} from "./Map/WebGL_Support.ts";
import {Data_Source_Config} from "./Data_Source/Data_Source_Config.tsx";
@@ -37,11 +37,11 @@ L.Icon.Default.mergeOptions({
});
class App extends enhance.Base {
leaflet_map: Leaflet_Map | null = null;
leaflet_map: Leaflet_Map;
cesium_map: any = null;
setting: Settings | null = null;
aircraft_list: Aircraft_List | null = null;
data_source_config: Data_Source_Config | null = null;
setting: Settings;
aircraft_list: Aircraft_List;
data_source_config: Data_Source_Config;
// @ts-ignore
navigate: NavigateFunction
@@ -56,7 +56,10 @@ class App extends enhance.Base {
constructor() {
super();
this.initSubComponents();
this.leaflet_map = new Leaflet_Map();
this.aircraft_list = new Aircraft_List();
this.setting = new Settings();
this.data_source_config = new Data_Source_Config(this.on_data_sources_changed);
this.menuItems = [
{
@@ -93,28 +96,12 @@ class App extends enhance.Base {
})
}
initSubComponents = () => {
this.leaflet_map = new Leaflet_Map();
this.aircraft_list = new Aircraft_List();
this.data_source_config = new Data_Source_Config(this.on_data_sources_changed);
// 延迟初始化Settings,避免“初始化前访问”错误
setTimeout(() => {
this.setting = new Settings();
this.flush();
}, 0);
};
on_data_sources_changed = () => {
if (!this.data_source_config) return;
if (this.leaflet_map) {
this.leaflet_map.data_source_config = this.data_source_config;
this.leaflet_map.data_source_show.data_source_config = this.data_source_config;
this.leaflet_map.data_source_show.flush();
this.leaflet_map.flush();
}
this.aircraft_list?.data_sources_changed();
this.leaflet_map.data_source_config = this.data_source_config;
this.leaflet_map.data_source_show.data_source_config = this.data_source_config;
this.leaflet_map.data_source_show.flush();
this.leaflet_map.flush();
this.aircraft_list.data_sources_changed();
this.cesium_map?.request_sync_data_sources?.();
this.flush();
};
@@ -176,18 +163,18 @@ class App extends enhance.Base {
}
render() {
if (!this.leaflet_map || !this.setting || !this.aircraft_list || !this.data_source_config) {
return <div style={{textAlign: 'center', padding: '20px'}}>...</div>;
}
const map2d = <div style={{width: '100%', height: '100%', position: 'relative'}}><this.leaflet_map.x/></div>;
const settings = <this.setting.x/>;
const aircraftList = <div style={{flex: 1}}><this.aircraft_list.x/></div>;
const map3d = (fallback: string) => this.webgl_supported
? <div style={{width: '100%', height: '100%', position: 'relative'}}>
<React.Suspense fallback={<div style={{padding: 20}}>3D地图加载中...</div>}>
<Cesium_Map_View initialSceneMode="3d"/>
</React.Suspense>
</div>
: <Navigate to={fallback} replace/>;
return (
<div style={{
display: 'flex',
flexDirection: 'column',
width: '100vw',
height: '100vh'
}}>
<div style={{display: 'flex', flexDirection: 'column', width: '100vw', height: '100vh'}}>
<div style={{
display: 'flex',
alignItems: 'center',
@@ -223,64 +210,11 @@ class App extends enhance.Base {
</div>
</div>
<Routes>
<Route
path="/map"
element={(
<div style={{width: '100%', height: '100%', position: 'relative'}}>
<this.leaflet_map.x/>
</div>
)}
/>
<Route
path="/ui/map"
element={(
<div style={{width: '100%', height: '100%', position: 'relative'}}>
<this.leaflet_map.x/>
</div>
)}
/>
<Route
path="/settings"
element={(
<this.setting.x/>
)}
/>
<Route
path="/ui/settings"
element={(
<this.setting.x/>
)}
/>
<Route
path="/map3d"
element={(
this.webgl_supported ? <div style={{width: '100%', height: '100%', position: 'relative'}}>
<React.Suspense fallback={<div style={{padding: 20}}>3D地图加载中...</div>}>
<Cesium_Map_View initialSceneMode="3d"/>
</React.Suspense>
</div> : <Navigate to="/map" replace/>
)}
/>
<Route
path="/ui/map3d"
element={(
this.webgl_supported ? <div style={{width: '100%', height: '100%', position: 'relative'}}>
<React.Suspense fallback={<div style={{padding: 20}}>3D地图加载中...</div>}>
<Cesium_Map_View initialSceneMode="3d"/>
</React.Suspense>
</div> : <Navigate to="/ui/map" replace/>
)}
/>
<Route
path="/aircraftlist"
element={(
<div style={{
flex: 1,
}}>
<this.aircraft_list.x/>
</div>
)}
/>
{["/map", "/ui/map"].map(path => <Route key={path} path={path} element={map2d}/>)}
{["/settings", "/ui/settings"].map(path => <Route key={path} path={path} element={settings}/>)}
<Route path="/map3d" element={map3d("/map")}/>
<Route path="/ui/map3d" element={map3d("/ui/map")}/>
{["/aircraftlist", "/ui/aircraftlist"].map(path => <Route key={path} path={path} element={aircraftList}/>)}
<Route path="*" element={<Navigate to="/map"/>}/>
</Routes>
+9 -47
View File
@@ -4,13 +4,7 @@ import {Drawer, Switch} from "antd";
import {PersistentScroll} from "./PersistentScroll.tsx";
export class Base_Drawer extends enhance.Base {
have_open: boolean = false;
on_mount() {
super.on_mount();
}
have_open = false;
toggleOpen = () => {
this.have_open = !this.have_open;
@@ -18,55 +12,23 @@ export class Base_Drawer extends enhance.Base {
};
getSwitchStyle(placement: string): React.CSSProperties {
const common: React.CSSProperties = {
position: "absolute",
zIndex: 9999,
const common: React.CSSProperties = {position: "absolute", zIndex: 9999};
if (placement === "right") return {...common, right: 8, top: 8};
if (placement === "top" || placement === "bottom") return {
...common,
[placement]: 0,
left: "50%",
transform: "translateX(-50%)"
};
switch (placement) {
case "left":
return { ...common, left: 8, top: 8 };
case "right":
return { ...common, right: 8, top: 8 };
case "top":
return {
...common,
top: 0,
left: "50%",
transform: "translateX(-50%)"
};
case "bottom":
return {
...common,
bottom: 0,
left: "50%",
transform: "translateX(-50%)"
};
default:
return { ...common, left: 8, top: 8 };
}
return {...common, left: 8, top: 8};
}
render(props: any): React.JSX.Element {
const content = props.children;
const placement = props.placement || "right";
const header = props.header;
const scrollKey = props.scrollKey || "Base_Drawer";
// 按钮样式自动根据 placement 调整
const switchStyle = this.getSwitchStyle(placement);
return (
<>
<Switch
-11
View File
@@ -1,7 +1,6 @@
import enhance from "../core/enhance.tsx";
import type {Aircraft} from "../Map/Aircraft.tsx";
import type {Base_Station} from "../Map/Base_Station.tsx";
import {Camera_Control_Mode} from "../Map/Camera_Control_Mode.ts";
import {Data_Source_Adminive} from "./Data_Source_Adminive.ts";
import {Data_Source_Map_Runtime} from "./Data_Source_Map_Runtime.ts";
@@ -59,15 +58,6 @@ export class Data_Source extends enhance.Base {
confirm() { return this.adminive.save(); }
refresh_display(mode: Map_Display_Mode) { this.map_runtime.refresh_display(mode); }
locate_base_station() { this.map_runtime.locate_base_station(); }
center_earth_view() { this.map_runtime.center_earth_view(); }
look_straight_down_view() { this.map_runtime.look_straight_down_view(); }
north_up_view() { this.map_runtime.north_up_view(); }
clear_camera_tracking() { this.map_runtime.clear_camera_tracking(); }
restore_previous_camera_view() { this.map_runtime.restore_previous_camera_view(); }
fly_to_global_view() { this.map_runtime.fly_to_global_view(); }
cesium_camera_control_mode(): Camera_Control_Mode { return this.map_runtime.cesium_camera_control_mode(); }
set_cesium_camera_control_mode(mode: Camera_Control_Mode) { this.map_runtime.set_cesium_camera_control_mode(mode); }
refresh() { return this.map_runtime.refresh(); }
manual_track_icao_list(): string[] { return this.map_runtime.manual_track_icao_list(); }
is_manual_tracking_aircraft(icao: string): boolean { return this.map_runtime.is_manual_tracking_aircraft(icao); }
@@ -85,6 +75,5 @@ export class Data_Source extends enhance.Base {
remove_aircraft(icao: string) { this.map_runtime.remove_aircraft(icao); }
refresh_aircraft_range() { this.map_runtime.refresh_aircraft_range(); }
apply_aircraft_delta(change_list: any[], removed_icaos: string[]) { this.map_runtime.apply_aircraft_delta(change_list, removed_icaos); }
apply_aircraft_snapshot(items: any[]) { this.map_runtime.apply_aircraft_snapshot(items); }
apply_track_stream_list(list: any[]) { this.map_runtime.apply_track_stream_list(list); }
}
@@ -1,5 +1,5 @@
import {app} from "../App.tsx";
import {server_url} from "../Global.tsx";
import {server_url} from "../Global.ts";
import type {Aircraft} from "../Map/Aircraft.tsx";
import type {Data_Source} from "./Data_Source.tsx";
+2 -26
View File
@@ -1,10 +1,6 @@
import axios from "axios"
import {Data_Source} from "./Data_Source.tsx"
import type {
Backend_Disabled_Rule,
Backend_Field_Descriptor,
Backend_Form_Section
} from "../Adminive/Backend_Fields.tsx"
import type {Backend_Field_Descriptor, Backend_Schema_Node} from "../Adminive/Backend_Fields.tsx"
type Adminive_Data_Source_Row = {
id: number
@@ -15,26 +11,10 @@ type Adminive_Data_Source_Row = {
[key: string]: unknown
}
export type Data_Source_Sidebar_Schema = {
title?: string
common: Backend_Form_Section
position: Backend_Form_Section
display: Record<"map2d" | "map3d", Backend_Form_Section>
rules?: Backend_Disabled_Rule[]
tiles: Backend_Form_Section & {descriptor_fields: Backend_Field_Descriptor[]}
view3d: {
graphics: Backend_Form_Section & {descriptor_fields: Backend_Field_Descriptor[]}
map_view: Backend_Form_Section & {descriptor_fields: Backend_Field_Descriptor[]}
}
}
export class Data_Source_Config {
private sources: Data_Source[] = []
descriptor_fields: Backend_Field_Descriptor[] = []
config_descriptor_fields: Backend_Field_Descriptor[] = []
sidebar_schema: Data_Source_Sidebar_Schema | null = null
loading = true
error = ""
sidebar_schema: Backend_Schema_Node | null = null
constructor(private readonly on_change?: () => void) {
window.addEventListener("ecap-data-sources-changed", () => void this.refresh())
@@ -67,7 +47,6 @@ export class Data_Source_Config {
if (!descriptor || !Array.isArray(descriptor.fields) || !descriptor.sidebar) {
throw new Error("数据源接口未返回后端字段与右侧栏描述")
}
this.descriptor_fields = descriptor.fields
this.config_descriptor_fields = descriptor.fields.find((field: Backend_Field_Descriptor) => field.name === "config")?.children || []
this.sidebar_schema = descriptor.sidebar
const editable_fields = descriptor.fields
@@ -88,12 +67,9 @@ export class Data_Source_Config {
source.normalize_map_display()
return source
})
this.error = ""
} catch (error) {
this.error = error instanceof Error ? error.message : String(error)
console.error("加载后端数据源模型失败", error)
} finally {
this.loading = false
this.on_change?.()
}
}
+1 -21
View File
@@ -2,10 +2,9 @@ import axios from "axios";
import {message} from "antd";
import L from "leaflet";
import {app} from "../App.tsx";
import {baseURL, darkenColor} from "../Global.tsx";
import {baseURL, darkenColor} from "../Global.ts";
import {Aircraft} from "../Map/Aircraft.tsx";
import {Base_Station} from "../Map/Base_Station.tsx";
import {Camera_Control_Mode} from "../Map/Camera_Control_Mode.ts";
import type {Data_Source, Map_Display_Mode} from "./Data_Source.tsx";
import {data_source_aircraft_stream} from "./Data_Source_Aircraft_Stream.ts";
@@ -44,17 +43,6 @@ export class Data_Source_Map_Runtime {
app.leaflet_map?.map?.setView([this.source.lat, this.source.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.source.key) await this.refresh_base_station_location();
data_source_aircraft_stream.ensure_open();
@@ -222,14 +210,6 @@ export class Data_Source_Map_Runtime {
this.refresh_aircraft_range();
}
apply_aircraft_snapshot(items: any[]) {
const alive = new Set(items.map(item => item.icao));
for (const icao of Array.from(this.aircraft_map.keys())) {
if (!alive.has(icao)) this.remove_aircraft(icao);
}
this.apply_aircraft_items(items);
}
apply_track_stream_list(list: any[]) {
for (const item of list) {
const aircraft = this.aircraft_map.get(item.icao);
+23 -27
View File
@@ -16,9 +16,9 @@ import {Data_Source, 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";
import {app} from "../App.tsx";
import {lightenColor} from "../Global.tsx";
import {lightenColor} from "../Global.ts";
import {tile_source_options, type Map_Tile_Type} from "../Map/Map_Resources.tsx";
import {Backend_Fields} from "../Adminive/Backend_Fields.tsx";
import {backend_schema_node, Backend_Fields} from "../Adminive/Backend_Fields.tsx";
const {Title, Text} = Typography;
@@ -239,7 +239,7 @@ export class Data_Source_Show extends enhance.Base {
] : [{value: "imagery", label: "影像瓦片"}];
const source_options = tile_source_options(map?.map_resources || null, tile_type);
const source_key = this.selected_tile_source_key(map, tile_type, source_options);
const tile_schema = this.data_source_config?.sidebar_schema?.tiles;
const tile_schema = backend_schema_node(this.data_source_config?.sidebar_schema, "tiles");
return (
<div style={{marginTop: 12, paddingTop: 8, borderTop: "1px solid #eee"}}>
<Title level={5}>{tile_schema?.title ?? "瓦片显示"}</Title>
@@ -258,7 +258,7 @@ export class Data_Source_Show extends enhance.Base {
options={source_options}
disabled={!source_options.length}
onChange={(key: string) => this.change_tile_source(map, tile_type, key)}/>
{tile_type === "imagery" && tile_schema &&
{tile_type === "imagery" && tile_schema?.descriptor_fields &&
<Backend_Fields root={tile_view || {}} descriptor_fields={tile_schema.descriptor_fields}
section={{...tile_schema, descriptor_path: ""}}
onChange={(_path, value) => {
@@ -324,15 +324,16 @@ export class Data_Source_Show extends enhance.Base {
const mode = map?.camera_control_mode() || Camera_Control_Mode.Surface_Navigation;
const graphics_config = map?.graphics_config || {};
const map_view_config = map?.map_view_config || {};
const schema = this.data_source_config?.sidebar_schema?.view3d;
const schema = backend_schema_node(this.data_source_config?.sidebar_schema, "view3d");
const graphics_schema = backend_schema_node(schema, "graphics");
const map_view_schema = backend_schema_node(schema, "map_view");
const group_style: React.CSSProperties = {
marginTop: 8
};
const grid_style: React.CSSProperties = {
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(86px, 1fr))",
gap: 6,
maxWidth: 320
gap: 6
};
return (
<div style={{marginTop: 12, paddingTop: 8, borderTop: "1px solid #eee"}}>
@@ -371,20 +372,20 @@ export class Data_Source_Show extends enhance.Base {
</div>
</div>
<Space direction="vertical" size={8} style={{marginTop: 8, width: "100%"}}>
{schema && <>
<Backend_Fields root={graphics_config} descriptor_fields={schema.graphics.descriptor_fields}
section={{...schema.graphics, descriptor_path: ""}}
{graphics_schema?.descriptor_fields ?
<Backend_Fields root={graphics_config} descriptor_fields={graphics_schema.descriptor_fields}
section={{...graphics_schema, descriptor_path: ""}}
onChange={() => {
map?.apply_graphics_config();
this.flush();
}}/>
<Backend_Fields root={map_view_config} descriptor_fields={schema.map_view.descriptor_fields}
section={{...schema.map_view, descriptor_path: ""}}
}}/> : null}
{map_view_schema?.descriptor_fields ?
<Backend_Fields root={map_view_config} descriptor_fields={map_view_schema.descriptor_fields}
section={{...map_view_schema, descriptor_path: ""}}
onChange={() => {
if (map) map.map_view_config = map_view_config;
this.flush();
}}/>
</>}
}}/> : null}
<Space wrap>
<Button size="small" onClick={() => map?.save_current_map_view()}></Button>
<Button size="small"
@@ -397,7 +398,7 @@ export class Data_Source_Show extends enhance.Base {
render_data_source(ds: Data_Source, mode: Map_Display_Mode) {
const config = this.data_source_config;
const schema = config?.sidebar_schema;
const schema = backend_schema_node(config?.sidebar_schema, "source");
if (!config || !schema) {
return <Text type="secondary"></Text>;
}
@@ -408,17 +409,12 @@ export class Data_Source_Show extends enhance.Base {
return (
<div>
<Title level={5}>{schema.title ?? "数据源配置"}{ds.key}</Title>
<Backend_Fields root={ds as any} descriptor_fields={config.config_descriptor_fields}
section={schema.common} rules={schema.rules} onChange={refresh}/>
<div style={{marginTop: 12}}>
<Backend_Fields root={ds as any} descriptor_fields={config.config_descriptor_fields}
section={schema.position} rules={schema.rules} onChange={refresh}/>
</div>
<div style={{marginTop: 18, paddingTop: 12, borderTop: "1px solid #eee"}}>
<Title level={5}>{schema.display[mode].title}</Title>
<Backend_Fields root={ds as any} descriptor_fields={config.config_descriptor_fields}
section={schema.display[mode]} rules={schema.rules} onChange={refresh}/>
</div>
{(schema.sections || []).filter(section => !section.modes || section.modes.includes(mode)).map((section, index) =>
<div key={index} style={index ? {marginTop: 12} : undefined}>
{section.title && <Title level={5}>{section.title}</Title>}
<Backend_Fields root={ds as any} descriptor_fields={config.config_descriptor_fields}
section={section} rules={schema.rules} onChange={refresh}/>
</div>)}
{this.render_aircraft_monitor_config(ds, mode)}
<br/>
<Button type="primary" autoInsertSpace onClick={() => {
+40
View File
@@ -0,0 +1,40 @@
function transform_color(hex_color: string, transform: (channel: number) => number): string {
const hex = hex_color.replace("#", "");
return `#${[0, 2, 4].map(offset => Math.max(0, Math.min(255,
Math.floor(transform(parseInt(hex.slice(offset, offset + 2), 16))))).toString(16).padStart(2, "0")).join("")}`;
}
export function lightenColor(color: string, factor = 0.2): string {
return transform_color(color, channel => channel + (255 - channel) * factor);
}
export function darkenColor(color: string, factor = 0.2): string {
return transform_color(color, channel => channel * (1 - factor));
}
export type Tree_Node = {title: string, key: string, children: Tree_Node[] | null};
export function transformToTreeData(data: Record<string, any>, parent_key = ""): Tree_Node[] {
return Object.entries(data).map(([key, value], index) => {
const node_key = parent_key ? `${parent_key}-${index}` : String(index);
const suffix = value === null ? ": null" : typeof value === "object" ? "" : `: ${value}`;
return {
title: `${key}${suffix}`,
key: node_key,
children: value && typeof value === "object" ? transformToTreeData(value, node_key) : null
};
});
}
export class StateHolder<T> {
constructor(public cur: T, private readonly hook: (old_value: T, new_value: T) => void = () => {}) {}
set(next: T) {
if (next === this.cur) return;
const old = this.cur;
this.cur = next;
this.hook(old, next);
}
}
export const server_url = window.location.host;
export const baseURL = "/api";
-181
View File
@@ -1,181 +0,0 @@
import type {CSSProperties} from "react";
export function lightenColor(hexColor: string, factor: number = 0.2): string {
// 去掉 #
const hex = hexColor.replace('#', '');
// 解析 RGB
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
// 提高每个分量,最多不超过 255
const newR = Math.min(255, Math.floor(r + (255 - r) * factor));
const newG = Math.min(255, Math.floor(g + (255 - g) * factor));
const newB = Math.min(255, Math.floor(b + (255 - b) * factor));
// 转回 hex
const toHex = (n: number) => n.toString(16).padStart(2, '0');
return `#${toHex(newR)}${toHex(newG)}${toHex(newB)}`;
}
export function darkenColor(hexColor: string, factor: number = 0.2): string {
const hex = hexColor.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
const newR = Math.max(0, Math.floor(r * (1 - factor)));
const newG = Math.max(0, Math.floor(g * (1 - factor)));
const newB = Math.max(0, Math.floor(b * (1 - factor)));
const toHex = (n: number) => n.toString(16).padStart(2, '0');
return `#${toHex(newR)}${toHex(newG)}${toHex(newB)}`;
}
export namespace G {
export function to_popup_text(info: any) {
let result = "";
for (let key in info) {
if (info.hasOwnProperty(key)) {
result += `${key}: ${JSON.stringify(info[key])}<br>`; // 使用 <br> 来换行
}
}
return result;
}
export const transformToTreeData = (data: Record<string, any>, parentKey = '') => {
return Object.entries(data).map(([key, value], index) => {
const nodeKey = parentKey ? `${parentKey}-${index}` : `${index}`;
let val: string = ""
if (value === null) {
val = ": null"; // 如果值是 null,显示 ": null"
} else if (typeof value !== 'object' && !Array.isArray(value)) {
val = `: ${value}`; // 其他情况显示值
}
// 判断值是否为 object 或 array 类型,如果是则不显示值
const node = {
title: `${key}${val}`,
key: nodeKey,
children: null
};
if (value && typeof value === 'object') {
node.children = transformToTreeData(value, nodeKey); // 递归处理子对象
}
return node;
});
};
}
export class StateHolder<T> {
old: T;
cur: T;
is_changed: boolean = true;
hook: (oldValue: T, newValue: T) => void; // 钩子函数
// 构造函数:可以接受一个初始值和一个可选的钩子函数
constructor(initial: T, hook?: (oldValue: T, newValue: T) => void) {
this.old = initial;
this.cur = initial;
this.hook = hook || (() => {}); // 如果没有提供钩子函数,默认使用空函数
}
// 更新 cur 值,并触发钩子
set(next: T) {
if (next !== this.cur) {
this.old = this.cur;
this.cur = next;
// 调用钩子函数,传入旧值和新值
this.hook(this.old, this.cur);
this.is_changed = true;
}
}
// 判断当前值是否发生变化
isChanged(): boolean {
if(this.is_changed) return true;
return this.old !== this.cur;
}
}
export const Prefix = (props) => {
return (
<div
style={{
display: 'inline-block',
width: 'auto',
whiteSpace: 'nowrap',
fontSize: 14,
color: '#000',
fontWeight: 'bold'
}}
>
{props.label + "\u00A0\u00A0\u00A0\u00A0"}
</div>);
}
export const setting_style: CSSProperties = {
minWidth: "100%",
width: "max-content",
height: "max-content",
overflowX: "auto",
display: "flex",
flexDirection: "column",
alignItems: "center",
// background: "red",
alignSelf: 'center'
};
export const row_style: CSSProperties = {
display: "flex",
flexDirection: "row",
gap: 8,
width: "max-content", // row 大于父 -> 撑开,触发外层滚动
flexGrow:1,
alignSelf: 'center'
};
export const col_style: CSSProperties = {
display: 'flex', // 关键:尺寸由子元素撑开
flexDirection: 'column',
alignItems: 'center', // 水平居中(column 时是交叉轴)
gap: 8,
width: 'max-content', // 可选:更明确
height: 'max-content', // 可选
// backgroundColor: 'blue',
};
//baseURL: 'http://127.0.0.1:80',
//baseURL: ':80',
//baseURL: `http://${window.location.hostname}:${window.location.port}/api`,
export const server_url = `${window.location.hostname}:${window.location.port}`;
export const baseURL = `http://${server_url}/api`;
export const host = window.location.port ?
`${window.location.hostname}:${window.location.port}` :
`${window.location.hostname}:80`;
+38 -266
View File
@@ -1,163 +1,69 @@
import "leaflet-rotatedmarker"
import L, {CircleMarker, LatLngExpression, LeafletMouseEvent, Marker, MarkerOptions} from 'leaflet';
import {app} from "../App.tsx"
import {baseURL, StateHolder} from "../Global.js";
import {lightenColor} from "../Global.tsx";
import {baseURL, lightenColor, StateHolder} from "../Global.ts";
import axios from "axios";
import {Data_Source} from "../Data_Source/Data_Source.tsx";
import {Aircraft_Model, Aircraft_Status, get_aircraft_angle, orientation_from_item} from "./Aircraft_Model.tsx";
// https://leafletjs.cn/reference.html#marker
// https://leafletjs.cn/reference.html#circlemarker
// https://leafletjs.cn/reference.html#popup
// https://leafletjs.cn/reference.html#domutil
// https://zhuanlan.zhihu.com/p/35713983
// https://github.com/bbecquet/Leaflet.RotatedMarker
class Aircraft_Marker extends Marker {
constructor(latlng: LatLngExpression, options?: MarkerOptions) {
super(latlng, options);
// @ts-ignore
this.setRotationOrigin('center center')
}
toJSON() {
let pos = this.getLatLng();
return {
lat: pos.lat,
lon: pos.lng,
};
}
}
export class Aircraft_Track_Point extends CircleMarker {
private tooltipVisible: boolean = false; // 用来追踪提示框是否通过右键点击显示
private rightClickVisible: boolean = false; // 用来追踪右键点击时提示框是否显示
utc : number
rest
class Aircraft_Track_Point extends CircleMarker {
private pinned = false;
readonly utc: number;
readonly rest: Record<string, unknown>;
constructor(data) {
super(L.latLng(data.lat, data.lon, data.alt), {
constructor(data: Record<string, any>) {
const {lat, lon, alt, utc, ...rest} = data;
super(L.latLng(lat, lon, alt), {
radius: 8,
fillColor: 'red',
fillOpacity: 1,
color: 'none', // 去掉边框
color: 'none',
});
this.utc = data.utc
const rest = {...data};
for (const key of ["lat", "lon", "alt"]) delete rest[key];
this.utc = Number(utc);
this.rest = rest;
this.on('click', function (e : LeafletMouseEvent) {
// 阻止事件冒泡
e.originalEvent.preventDefault();
e.originalEvent.stopPropagation();
const latlng = e.latlng; // 获取点击的坐标
const alt = latlng.alt || "未知"; // 获取高度(如果有的话),默认值为 "未知"
const extraHtml = Object.entries(rest)
.map(([k, v]) => `${k}: ${v}`)
.join('<br>');
const tooltipContent = `
<div>
纬度: ${latlng.lat.toFixed(6)}<br>
经度: ${latlng.lng.toFixed(6)}<br>
高度: ${alt}<br>
${extraHtml ? extraHtml + '<br>' : ''}
</div>
`
// 如果提示框已显示,则隐藏提示框
if (this.rightClickVisible) {
this.on('click', (event: LeafletMouseEvent) => {
event.originalEvent.preventDefault();
event.originalEvent.stopPropagation();
if (this.pinned) {
this.closeTooltip();
this.rightClickVisible = false;
this.pinned = false;
} else {
// 如果提示框未显示,则显示提示框
this.bindTooltip(tooltipContent)
.openTooltip();
this.rightClickVisible = true;
this.show_tooltip(event.latlng, false);
this.pinned = true;
}
});
// 鼠标移入显示信息(此功能可根据需要保留)
this.on('mouseover', function (e) {
if (this.rightClickVisible) return; // 如果是右键点击显示的提示框,则不再显示
const latlng = e.latlng;
const alt = latlng.alt || "未知";
let date = new Date(this.utc * 1000);
let hours = date.getUTCHours();
let minutes = date.getUTCMinutes();
let seconds = date.getUTCSeconds();
let timeString = `${hours}:${minutes}:${seconds}`;
// 奇延迟: ${oddLatencyFormatted}<br>
// 偶延迟: ${evenLatencyFormatted}<br>
const extraHtml = Object.entries(rest)
.map(([k, v]) => `${k}: ${v}`)
.join('<br>');
const tooltipContent = `
<div>
纬度: ${latlng.lat.toFixed(6)}<br>
经度: ${latlng.lng.toFixed(6)}<br>
高度: ${alt} <br>
utc: ${timeString}<br>
${extraHtml ? extraHtml + '<br>' : ''}
</div>
`;
// 如果提示框没有显示,则显示
if (!this.tooltipVisible) {
this.bindTooltip(tooltipContent)
.openTooltip();
this.tooltipVisible = true;
}
this.on('mouseover', (event: LeafletMouseEvent) => {
if (!this.pinned) this.show_tooltip(event.latlng, true);
});
// 鼠标离开时隐藏信息(此功能也可以根据需要保留)
this.on('mouseout', function () {
if (this.rightClickVisible) return; // 如果是右键点击显示的提示框,则不关闭
if (!this.tooltipVisible) return;
this.closeTooltip();
this.tooltipVisible = false;
this.on('mouseout', () => {
if (!this.pinned) this.closeTooltip();
});
}
setColor(color: string) {
this.setStyle({
fillColor: color
});
}
setSize(size: number) {
this.setRadius(size);
private show_tooltip(latlng: L.LatLng, include_time: boolean) {
const lines = [
`纬度: ${latlng.lat.toFixed(6)}`,
`经度: ${latlng.lng.toFixed(6)}`,
`高度: ${latlng.alt ?? "未知"}`,
...(include_time ? [`UTC: ${Number.isFinite(this.utc) ? new Date(this.utc * 1000).toISOString().slice(11, 19) : "未知"}`] : []),
...Object.entries(this.rest).map(([key, value]) => `${key}: ${String(value)}`)
];
const content = document.createElement("div");
content.innerText = lines.join("\n");
content.style.whiteSpace = "pre-line";
this.bindTooltip(content).openTooltip();
}
toJSON() {
let pos = this.getLatLng();
return {
lat: pos.lat,
lon: pos.lng,
alt: pos.alt,
};
}
static fromJSON(json: any) {
let ret = new Aircraft_Track_Point([json.lat, json.lon, json.alt]);
return ret;
}
setColor(color: string) { this.setStyle({fillColor: color}); }
setSize(size: number) { this.setRadius(size); }
}
export class Aircraft {
@@ -174,12 +80,7 @@ export class Aircraft {
angle = new StateHolder<number>(0);
need_refresh(){
return true;
}
generateSVG(color: string, size: number, base_angle : number, s:Aircraft_Status) {
generateSVG(color: string, size: number, base_angle: number) {
return `
<svg xmlns="http://www.w3.org/2000/svg"
style="transform: rotate(${base_angle}deg); transform-origin: 50% 50%;
@@ -190,66 +91,15 @@ export class Aircraft {
<path d="M280-80v-100l120-84v-144L80-280v-120l320-224v-176q0-33 23.5-56.5T480-880q33 0 56.5 23.5T560-800v176l320 224v120L560-408v144l120 84v100l-200-60-200 60Z"/>
</svg>
`.trim();
if (s == Aircraft_Status.Level) {
return `
<svg xmlns="http://www.w3.org/2000/svg"
style="transform: rotate(${base_angle}deg); transform-origin: 50% 50%;
height="${size}"
viewBox="0 -960 960 960"
width="${size}"
fill="${color}">
<path d="M280-80v-100l120-84v-144L80-280v-120l320-224v-176q0-33 23.5-56.5T480-880q33 0 56.5 23.5T560-800v176l320 224v120L560-408v144l120 84v100l-200-60-200 60Z"/>
</svg>
`.trim();
}
else if (s == Aircraft_Status.Descending) {
return `
<svg viewBox="0 0 1024 1024"
xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}"
style="transform: rotate(${base_angle-90}deg); transform-origin: 50% 50%;"
>
<path
d="M277.333333 430.933333l209.066667 55.466667L375.466667 298.666667l76.8-42.666667 153.6 264.533333 209.066666 55.466667-21.333333 85.333333L170.666667 494.933333v-4.266666l34.133333-119.466667 85.333333 21.333333-12.8 38.4zM810.666667 806.4H170.666667v-85.333333h640v85.333333z"
fill="${color}"></path>
</svg>
`.trim();
} else if (s == Aircraft_Status.Ascending) {
return `
<svg
viewBox="0 0 1024 1024"
xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">
style="transform: rotate(${base_angle-90}deg); transform-origin: 50% 50%;"
<path
d="M307.2 490.666667l204.8-55.466667-183.466667-106.666667 42.666667-72.533333 256 149.333333 209.066667-55.466666 17.066666 76.8-238.933333 64-366.933333 98.133333-21.333334-76.8-12.8-42.666667 81.066667-21.333333 12.8 42.666667zM840.533333 768H213.333333v-81.066667h627.2V768z"
fill="${color}"></path>
</svg>
`.trim();
} else if (s == Aircraft_Status.Lost) {
return `
<svg viewBox="0 0 1024 1024"
xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}"
style="transform: rotate(${base_angle}); transform-origin: 50% 50%;"
>
<path
d="M582.544817 579.225505l-154.606497-154.605281-0.660457 0.440304-182.677737-182.678953-44.784568 44.785784 173.72204 173.72204L148.713471 610.777826v66.037169l297.22625-99.091634v184.978997l-99.067308 79.26455v66.037169L512.001216 841.966908l165.127587 66.037169V841.966908l-99.090418-79.26455v-97.312172l150.737412 150.738629 44.787001-44.784568L580.893067 578.674516zM578.038385 412.618884V181.452912s0-66.037169-66.035952-66.037169c-66.061495 0-66.061495 66.037169-66.061495 66.037169v171.599577L716.903386 624.016154l158.385576 52.798841v-66.037169L578.038385 412.618884z"
></path>
</svg>
`.trim();
}
}
set_angle = (angle: number) => {
this.angle.set(angle);
// @ts-ignore
// this.pos_marker.setRotationAngle(this.angle);
this.refresh()
}
get_label_position(size: number): string {
// 角度区间,判断标签应该放在的位置
const halfSize = size / 2;
return `top: ${halfSize + 8}px; left: ${halfSize + 8}px;`;
@@ -259,9 +109,8 @@ export class Aircraft {
let style = this.data_source.map2d_style();
let color = style.color;
let size = app.leaflet_map?.aircraft_icon_size(this) || 50;
let status = this.status.cur;
let rotationDegree = this.angle.cur;
const svg = this.generateSVG(color, size, rotationDegree, status);
const svg = this.generateSVG(color, size, rotationDegree);
const selected = this.data_source.active_aircraft === this;
const outline = selected ? "filter: drop-shadow(1px 0 0 #111) drop-shadow(-1px 0 0 #111) drop-shadow(0 1px 0 #111) drop-shadow(0 -1px 0 #111) drop-shadow(0 0 4px #fff);" : "";
@@ -302,7 +151,6 @@ export class Aircraft {
last_size: number = 0;
is_show_path: boolean = false;
mlat_show: boolean = false
pos
@@ -314,32 +162,7 @@ export class Aircraft {
this.pos_marker.addTo(map);
}
on_un_mount() {
}
show_track(){
//app.
if (this.mlat_show) {
this.track_point_list.forEach(item => {
item.addTo(app.leaflet_map.map)
})
return
}
let active_aircraft = this.data_source.active_aircraft;
if (active_aircraft != this) {
app.leaflet_map.aircraft_click(this);
}
}
hide_track(){
if (this.mlat_show) {
this.track_point_list.forEach(item => {
item.remove()
})
return
}
let active_aircraft = this.data_source.active_aircraft;
if (active_aircraft == this) {
active_aircraft.hide_path();
@@ -352,14 +175,7 @@ export class Aircraft {
constructor(icao: string, ds: Data_Source) {
this.icao = icao
this.data_model = new Aircraft_Model(ds.key, icao)
// this.pos_marker.bindPopup("没有数据也不能删除,否则不弹出!");
this.data_source = ds;
// this.pos_marker.on('popupopen', () => {
// this.show_track()
// });
// this.pos_marker.on('popupclose', () => {
// this.hide_track()
// });
this.pos_marker.on('click', () => {
let active_aircraft = this.data_source.active_aircraft;
if(active_aircraft != this){
@@ -420,33 +236,6 @@ export class Aircraft {
}
}
/*{
"icao": "780FBD",
"ds": "r205_113",
"sta_seq": 19,
"end_seq": 21,
"size": 2,
"list": [
{
"lat": 34.179382,
"lon": 122.403951,
"alt": 9174.480000,
"odd_latency": -601365776,
"even_latency": -3771397588,
"utc": 1768876493,
"idx": 19
},
{
"lat": 34.178839,
"lon": 122.403030,
"alt": 9174.480000,
"odd_latency": -160474061,
"even_latency": -3895432888,
"utc": 1768876494,
"idx": 20
}
]
}*/
apply_track_data(data: any) {
if (!data) {
return []
@@ -470,11 +259,6 @@ export class Aircraft {
return list
}
append_path() {
this.data_source.request_aircraft_stream_update();
return Promise.resolve();
}
append_loaded_path(list: any[]) {
let map = app.leaflet_map?.map;
list.forEach((data) => {
@@ -499,11 +283,6 @@ export class Aircraft {
append_path_response(data: any) {
this.append_loaded_path(this.apply_track_data(data));
}
refresh_monitored_path() {
this.is_show_path = true;
return this.append_path();
}
show_path() {
if (this.is_show_path == true) return;
this.is_show_path = true;
@@ -522,23 +301,16 @@ export class Aircraft {
const map = app.leaflet_map?.map;
const style = this.data_source.map2d_style();
if (style.aircraft_show) {
//console.log("aircraft refresh show")
if (map){
// app.leaflet_map.map.removeLayer(this.pos_marker);
this.pos_marker.addTo(map);
}
} else {
//console.log("aircraft refresh hide")
if (map) {
map.removeLayer(this.pos_marker);
}
this.hide_path();
}
if(this.need_refresh()){
// @ts-ignore
// this.pos_marker.setRotationAngle(this.angle.cur);
this.pos_marker.setIcon(this.get_icon());
}
this.pos_marker.setIcon(this.get_icon());
let light_color = lightenColor(style.color);
for (let i = 0; i < this.track_point_list.length; i++) {
let track_point = this.track_point_list[i];
+65 -139
View File
@@ -1,161 +1,87 @@
import enhance from "../core/enhance.tsx";
import {baseURL, G} from "../Global.tsx";
import {Button, Flex, Menu, MenuProps, message} from "antd";
import enhance from "../core/enhance.tsx";
import {baseURL, transformToTreeData, type Tree_Node} from "../Global.ts";
import {Button, Flex, Menu, type MenuProps, message} from "antd";
import axios from "axios";
import {SettingOutlined} from "@ant-design/icons";
import React from "react";
import {Leaflet_Map} from "./Leaflet_Map.tsx";
import {app} from "../App.tsx";
import {Aircraft} from "./Aircraft.tsx";
import type {Aircraft} from "./Aircraft.tsx";
import {Base_Drawer} from "../Base_Drawer.tsx";
import {PersistentScroll} from "../PersistentScroll.tsx";
import {Refresh} from "../Refresh.tsx";
import {Tree_Show} from "./Tree_Show.tsx";
const information_tabs = {
base_information: "基础",
general_information: "总体",
detailed_information: "详细"
} as const;
type Information_Key = keyof typeof information_tabs;
type Information_View = {tree: Tree_Show, tree_data: Tree_Node[], json: Record<string, any>};
function empty_view(): Information_View {
return {tree: new Tree_Show(), tree_data: [], json: {}};
}
export class Aircraft_Info_Show extends enhance.Base {
active_aircraft: Aircraft = null
par: Leaflet_Map
drawer = new Base_Drawer()
readonly BASE_INFORMATION : string = 'base_information'
readonly GENERAL_INFORMATION : string = 'general_information'
readonly DETAILED_INFORMATION : string = 'detailed_information'
selected_key: string = null
di : Tree_Show = new Tree_Show()
gi : Tree_Show = new Tree_Show()
bi : Tree_Show = new Tree_Show()
detailed_information = G.transformToTreeData({});
general_information = G.transformToTreeData({});
base_information = G.transformToTreeData({});
detailed_information_json: Record<string, any> = {};
general_information_json: Record<string, any> = {};
base_information_json: Record<string, any> = {};
refresh_btn = new Refresh(()=>{
this.refresh(this.selected_key);
})
constructor(p: Leaflet_Map) {
super();
this.par = p
this.selected_key = this.BASE_INFORMATION
active_aircraft: Aircraft | null = null;
drawer = new Base_Drawer();
selected_key: Information_Key = "base_information";
views: Record<Information_Key, Information_View> = {
base_information: empty_view(),
general_information: empty_view(),
detailed_information: empty_view()
};
refresh_btn = new Refresh(() => this.refresh(this.selected_key));
private set_information(key: Information_Key, data: Record<string, any>) {
this.views[key].json = data;
this.views[key].tree_data = transformToTreeData(data);
}
refresh_general_information() {
let as = app.leaflet_map.aircraft_info_show;
axios.post(`${baseURL}/get_general_info`).then((res) => {
as.set_general_information(res.data);
as.flush();
})
}
set_base_information(data: Record<string, any>) {
this.base_information_json = data;
this.base_information = G.transformToTreeData(data);
}
set_general_information(data: Record<string, any>) {
this.general_information_json = data;
this.general_information = G.transformToTreeData(data);
}
set_detailed_information(data: Record<string, any>) {
this.detailed_information_json = data;
this.detailed_information = G.transformToTreeData(data);
}
selected_json_data(): Record<string, any> {
if (this.selected_key === this.BASE_INFORMATION) return this.base_information_json;
if (this.selected_key === this.GENERAL_INFORMATION) return this.general_information_json;
return this.detailed_information_json;
}
copy_selected_json() {
navigator.clipboard.writeText(JSON.stringify(this.selected_json_data(), null, 2)).then(() => {
message.success("JSON已复制");
set_base_information(data: Record<string, any>) { this.set_information("base_information", data); }
set_general_information(data: Record<string, any>) { this.set_information("general_information", data); }
set_detailed_information(data: Record<string, any>) { this.set_information("detailed_information", data); }
refresh(key: Information_Key) {
if (key === "base_information") this.active_aircraft?.refresh_base_information();
else if (key === "detailed_information") this.active_aircraft?.refresh_detailed_information();
else axios.post(`${baseURL}/get_general_info`).then(response => {
this.set_general_information(response.data);
this.flush();
});
}
refresh(value: string) {
if (value === this.BASE_INFORMATION) {
if (this.active_aircraft) {
this.active_aircraft.refresh_base_information();
}
}
else if (value === this.GENERAL_INFORMATION) {
this.refresh_general_information();
}
else if (value === this.DETAILED_INFORMATION) {
if (this.active_aircraft) {
this.active_aircraft.refresh_detailed_information();
}
} else {
console.error("Aircraft_Info_Show refresh 收到不合法的值!", value);
console.trace();
}
}
onClick: MenuProps['onClick'] = (e) => {
if (this.selected_key != e.key) {
this.selected_key = e.key
this.flush();
this.refresh(e.key);
}
onClick: MenuProps["onClick"] = event => {
if (event.key === this.selected_key || !(event.key in information_tabs)) return;
this.selected_key = event.key as Information_Key;
this.flush();
this.refresh(this.selected_key);
};
_open: boolean = true;
use_hook: () => void = () => {
//[this.open, this.setOpen] = useState(true);
copy_selected_json() {
navigator.clipboard.writeText(JSON.stringify(this.views[this.selected_key].json, null, 2)).then(() =>
message.success("JSON已复制"));
}
first_open: boolean = true;
render() {
return (
<this.drawer.x placement="left">
<Flex vertical style={{ height: '100%' }}>
{/* 固定的上半部分 */}
<div style={{ position: 'sticky', top: 0, zIndex: 1, backgroundColor: 'white' }}>
<Menu
onClick={this.onClick}
mode="horizontal"
selectedKeys={[this.selected_key]}
style={{
width: '260px',
}}
items={[
{
label: '基础',
key: this.BASE_INFORMATION,
icon: <SettingOutlined />,
},
{
label: '总体',
key: this.GENERAL_INFORMATION,
icon: <SettingOutlined />,
},
{
label: '详细',
key: this.DETAILED_INFORMATION,
icon: <SettingOutlined />,
},
]}
/>
<Flex gap={8} align="center">
<this.refresh_btn.x></this.refresh_btn.x>
<Button size="small" onClick={() => this.copy_selected_json()}>JSON</Button>
</Flex>
</div>
<PersistentScroll scrollKey={this.selected_key}>
{this.selected_key === this.BASE_INFORMATION && (
<this.bi.x treeData={this.base_information} jsonData={this.base_information_json}></this.bi.x>
)}
{this.selected_key === this.GENERAL_INFORMATION && (
<this.gi.x treeData={this.general_information} jsonData={this.general_information_json}></this.gi.x>
)}
{this.selected_key === this.DETAILED_INFORMATION && (
<this.di.x treeData={this.detailed_information} jsonData={this.detailed_information_json}></this.di.x>
)}
<div style={{ height: '50vh' }}></div> {/* 空白区域,保持布局 */}
</PersistentScroll>
</Flex>
</this.drawer.x>
);
const view = this.views[this.selected_key];
const Tree = view.tree.x;
return <this.drawer.x placement="left">
<Flex vertical style={{height: "100%"}}>
<div style={{position: "sticky", top: 0, zIndex: 1, backgroundColor: "white"}}>
<Menu onClick={this.onClick} mode="horizontal" selectedKeys={[this.selected_key]}
style={{width: 260}}
items={Object.entries(information_tabs).map(([key, label]) => ({
key, label, icon: <SettingOutlined/>
}))}/>
<Flex gap={8} align="center">
<this.refresh_btn.x/>
<Button size="small" onClick={() => this.copy_selected_json()}>JSON</Button>
</Flex>
</div>
<PersistentScroll scrollKey={this.selected_key}>
<Tree treeData={view.tree_data} jsonData={view.json}/>
</PersistentScroll>
</Flex>
</this.drawer.x>;
}
}
+2 -2
View File
@@ -2,7 +2,7 @@ import enhance from "../core/enhance.tsx";
import {Button, Flex, Input, InputNumber, Layout, Select, Space, Table} from "antd";
import React, {HTMLProps} from "react";
import axios from "axios";
import {baseURL, Prefix} from "../Global.tsx";
import {baseURL} from "../Global.ts";
import {app} from "../App.tsx";
import {SearchOutlined} from "@ant-design/icons";
import type {TableColumnType} from "antd";
@@ -245,7 +245,7 @@ export class Aircraft_List extends enhance.Base {
return <Layout style={{height: "100%", minWidth: 0}}>
<Flex gap="middle" vertical style={{width: "100%", minWidth: 0}}>
<Flex gap="middle">
<Select prefix={<Prefix label="数据来源"/>}
<Select prefix={<strong style={{whiteSpace: "nowrap"}}></strong>}
placeholder="请选择数据来源"
value={this.data_source_key}
options={(app.data_source_config?.enabled() ?? []).map(item => ({
+2 -2
View File
@@ -85,7 +85,7 @@ export function orientation_from_item(item: any, fallback: Aircraft_Orientation_
roll: Number(source.roll ?? fallback.roll ?? 0)
}
}
export function resolve_aircraft_status(item: any, previous_alt?: number): Aircraft_Status {
function resolve_aircraft_status(item: any, previous_alt?: number): Aircraft_Status {
const vert_speed = item.vert_speed
if (typeof vert_speed === "number") {
if (vert_speed === 0) return Aircraft_Status.Level
@@ -101,7 +101,7 @@ export function resolve_aircraft_status(item: any, previous_alt?: number): Aircr
}
return Aircraft_Status.Level
}
export function aircraft_status_text(status: Aircraft_Status): string {
function aircraft_status_text(status: Aircraft_Status): string {
return {
[Aircraft_Status.Level]: "平飞",
[Aircraft_Status.Descending]: "下降",
+6 -2
View File
@@ -1,5 +1,5 @@
import L from "leaflet";
import {darkenColor, G} from "../Global.tsx";
import {darkenColor} from "../Global.ts";
import {app} from "../App.tsx";
import {Data_Source} from "../Data_Source/Data_Source.tsx";
@@ -175,7 +175,11 @@ export class Base_Station {
}
set_popup(key: string, d: any) {
this.that.bindPopup("key:" + key + "<br>" + G.to_popup_text(d));
const content = document.createElement("div");
content.innerText = [`key: ${key}`, ...Object.entries(d).map(([name, value]) =>
`${name}: ${JSON.stringify(value)}`)].join("\n");
content.style.whiteSpace = "pre-line";
this.that.bindPopup(content);
}
get_distance_from_base_station(lat: number, lng: number) {
+2 -52
View File
@@ -118,7 +118,6 @@ export class Cesium_Map extends enhance.Base {
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;
@@ -350,41 +349,16 @@ export class Cesium_Map extends enhance.Base {
this.flush();
}
change_tile_display_maximum_level(value: number | null) {
this.tile_display_level_draft = value;
if (value === null) {
this.flush();
return;
}
if (value === null) 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).base_station_fly_to_height_offset_meters;
}
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, base_station_fly_to_height_offset_meters: Math.max(100, value)};
this.flush();
}
set_surface_navigation_reference_visible(value: boolean) {
this.graphics_config = normalize_cesium_graphics_config({...this.graphics_config, surface_navigation_reference_visible: value});
this.apply_graphics_config();
this.flush();
}
set_view_axes_visible(value: boolean) {
this.graphics_config = normalize_cesium_graphics_config({...this.graphics_config, view_axes_visible: value});
this.apply_graphics_config();
this.flush();
}
change_view_axes_panel_size(value: number) {
this.graphics_config = normalize_cesium_graphics_config({...this.graphics_config, view_axes_panel_size: value});
this.flush();
@@ -411,24 +385,6 @@ export class Cesium_Map extends enhance.Base {
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;
@@ -762,9 +718,6 @@ export class Cesium_Map extends enhance.Base {
request_sync_data_sources() {
this.data_source_sync.request();
}
sync_data_sources() {
this.data_source_sync.sync();
}
sync_base_station(ds: Data_Source, alive_base_stations: Set<string>) {
if (!this.viewer) return;
const station = ds.base_station;
@@ -1195,9 +1148,6 @@ export class Cesium_Map extends enhance.Base {
sync_aircraft(key: string, aircraft: Aircraft) {
this.aircraft_layer.sync(key, aircraft);
}
create_aircraft_record(key: string, aircraft: Aircraft, position: Cesium.Cartesian3, orientation: Cesium.Quaternion): Aircraft_Entity_Record {
return this.aircraft_layer.create_record(key, aircraft, position, orientation);
}
ensure_aircraft_track_entities(key: string, aircraft: Aircraft, record: Aircraft_Entity_Record) {
this.aircraft_layer.ensure_track_entities(key, aircraft, record);
}
@@ -1697,7 +1647,7 @@ export class Cesium_Map extends enhance.Base {
type Cesium_Map_View_Props = {
initialSceneMode?: Scene_Mode_Key
}
export function Cesium_Map_View({initialSceneMode = "3d"}: Cesium_Map_View_Props) {
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);
+4 -5
View File
@@ -2,7 +2,6 @@ import React from "react";
import {Button, Card, message, Select, Space, Upload} from "antd";
import {UploadOutlined} from "@ant-design/icons";
import enhance from "../core/enhance.tsx";
import {col_style, row_style, setting_style} from "../Global.tsx";
import {
empty_map_model_config,
load_map_model_config,
@@ -85,7 +84,7 @@ export class Cesium_Model_Settings extends enhance.Base {
if (!get_path(this.config, path)) return null;
return (
<Space key={path} direction="vertical" size={6} style={{width: "100%"}}>
<h3 style={row_style}>{title}</h3>
<h3>{title}</h3>
<Backend_Fields root={this.config} descriptor_fields={fields}
section={{object_path: path, descriptor_path: "", fields: fields.map(field => field.name)}}
onChange={() => this.flush()}/>
@@ -127,9 +126,9 @@ export class Cesium_Model_Settings extends enhance.Base {
render() {
return (
<div style={setting_style}>
<h2 style={row_style}>{this.schema.title ?? "Cesium模型设置"}</h2>
<Space direction="vertical" size={12} style={{...col_style, width: "100%"}}>
<div style={{minWidth: "100%", width: "max-content", overflowX: "auto", display: "flex", flexDirection: "column", alignItems: "center"}}>
<h2>{this.schema.title ?? "Cesium模型设置"}</h2>
<Space direction="vertical" size={12} style={{alignItems: "center", width: "100%"}}>
{(this.schema.groups || []).map(group =>
this.render_model(group.title, group.path, group.upload))}
{(this.schema.collections || []).map(collection => this.render_collection(collection))}
+11 -134
View File
@@ -2,17 +2,14 @@ import "leaflet/dist/leaflet.css"
import L from 'leaflet';
import React from 'react';
import enhance from "../core/enhance.tsx";
import {baseURL, host} from "../Global.js";
import {Aircraft} from "./Aircraft.tsx"
import {Button, Space, message} from "antd";
import axios from "axios";
import {Aircraft_Info_Show} from "./Aircraft_Info_Show.tsx";
import {Data_Source_Config} from "../Data_Source/Data_Source_Config.tsx";
import {Data_Source_Show} from "../Data_Source/Data_Source_Show.tsx";
import {app} from "../App.tsx";
//import {Base_Station} from "./Base_Station.tsx";
import {Data_Source} from "../Data_Source/Data_Source.tsx";
import {
imagery_source_for_key,
@@ -23,59 +20,27 @@ import {
import {default_map_view_config, load_map_view_config, save_map_view_config, type Map_Tile_View_Config, type Map_View_Config} from "./Map_View.tsx";
export class POS {
lat: number;
lon: number;
}
export class Leaflet_Map extends enhance.Base {
// @ts-ignore
map: L.Map = null;
data_source_show: Data_Source_Show = new Data_Source_Show();
// @ts-ignore
aircraft_info_show: Aircraft_Info_Show = new Aircraft_Info_Show(this);
aircraft_info_show: Aircraft_Info_Show = new Aircraft_Info_Show();
data_source_config: Data_Source_Config | null = null
tile_layer: L.TileLayer | null = null
map_resources: Map_Resources_Metadata | null = null
map_view_config: Map_View_Config | null = null
current_imagery_key: string = ""
context_menu: {x: number, y: number, aircraft?: Aircraft, base_station?: Data_Source} | null = null
tile_display_level_draft: number | null | undefined = undefined
tile_level_listener = () => this.data_source_show.flush()
private savedCenter: L.LatLng | null = null;
private savedZoom: number | null = null;
intervalId: number | null = null;
autoRefreshTimer = null; // 用于存储定时器ID
refreshInterval : number = 1; // 用于存储定时器ID
autoRefreshTimer: ReturnType<typeof setInterval> | null = null;
private is_click_on_map_entity(e: L.LeafletMouseEvent): boolean {
const target = e.originalEvent.target;
if (!(target instanceof Element)) {
return false;
}
return Boolean(target.closest([
".leaflet-marker-icon",
".leaflet-interactive",
".leaflet-popup",
".leaflet-tooltip",
".leaflet-control",
].join(",")));
}
private clear_active_aircraft_by_map_click = (e: L.LeafletMouseEvent) => {
console.log(e);
private clear_active_aircraft_by_map_click = () => {
const had_context_menu = this.context_menu !== null;
this.context_menu = null;
// if (this.is_click_on_map_entity(e)) {
// return;
// }
let changed = false;
this.list().forEach((ds: Data_Source) => {
if (!ds.active_aircraft) return;
@@ -96,24 +61,15 @@ export class Leaflet_Map extends enhance.Base {
on_mount: () => void = () => {
console.log("地图加载");
this.map = this.load_map();
this.load_imagery_layer();
this.map.on("click", this.clear_active_aircraft_by_map_click);
this.map.on("zoomend", this.tile_level_listener);
this.map.whenReady(() => {
});
if (this.savedCenter && this.savedZoom) {
if (this.savedCenter && this.savedZoom !== null) {
this.map.setView(this.savedCenter, this.savedZoom);
}
if (this.data_source_config) {
const ds_list = this.data_source_config.all();
ds_list.forEach((ds: Data_Source) => {
if (!ds.enable) return; // ✅ 跳过当前项
this.data_source_config.enabled().forEach((ds: Data_Source) => {
for (const aircraft of ds.aircraftMap.values()) {
aircraft.on_mount();
}
@@ -124,16 +80,8 @@ export class Leaflet_Map extends enhance.Base {
this.autoRefreshTimer = setInterval(() => {
if (this.data_source_config){
const ds_list = this.data_source_config.all();
ds_list.forEach((ds: Data_Source) => {
if (!ds.enable) return; // ✅ 跳过当前项
ds.refresh()
});
}
}, this.refreshInterval * 1000); // 以秒为单位
this.autoRefreshTimer = setInterval(() =>
this.data_source_config?.enabled().forEach(ds => ds.refresh()), 1000);
this.flush();
@@ -143,7 +91,6 @@ export class Leaflet_Map extends enhance.Base {
on_un_mount: () => void = () => {
console.log("地图卸载");
if (this.map) {
this.savedCenter = this.map.getCenter();
this.savedZoom = this.map.getZoom();
@@ -156,7 +103,7 @@ export class Leaflet_Map extends enhance.Base {
map.removeLayer(layer);
});
if (this.autoRefreshTimer) {
clearInterval(this.autoRefreshTimer); // 清除定时器
clearInterval(this.autoRefreshTimer);
this.autoRefreshTimer = null;
}
this.map = null
@@ -219,62 +166,12 @@ export class Leaflet_Map extends enhance.Base {
a.drawer.toggleOpen()
}
if (a.selected_key === a.DETAILED_INFORMATION) {
axios.post(`${baseURL}/get_aircraft_detail_info`, {
data_source_key: that.data_source.key,
icao: that.icao,
}).then(res => {
a.set_detailed_information(res.data);
a.flush();
})
}
if (a.selected_key === a.BASE_INFORMATION) {
axios.post(`${baseURL}/get_aircraft_base_info`, {
data_source_key: that.data_source.key,
icao: that.icao,
}).then(res => {
a.set_base_information(res.data);
a.flush();
})
}
a.refresh(a.selected_key);
}
timer
mlat_source: Data_Source = new Data_Source();
mlat: Map<string, Aircraft> = new Map();
constructor() {
super();
this.mlat_source.key = "mlat_source";
// this.timer = setInterval(() => {
// this.mlat_source.aircraft_show = true;
// axios.post(`${baseURL}/get_mlat_list`, {})
// .then(res => {
//
// //console.log(res.data);
// res.data.forEach(item => {
// //console.log(item);
// let mlat_air = this.mlat.get(item.icao);
// if (!mlat_air) {
// mlat_air = new Aircraft(item.icao, this.mlat_source)
// mlat_air.mlat_show = true;
// this.mlat.set(item.icao, mlat_air);
// }
//
// mlat_air.add_malt_data(item);
//
// });
// })
// .catch(err => {
// console.error("请求出错:", err);
// });
// }, 1000);
}
INIT_EXTENT = L.latLngBounds(
[36.42, 119.98], // 西南角:
[38.40, 122.56] // 东北角:
@@ -284,24 +181,14 @@ export class Leaflet_Map extends enhance.Base {
const MAP_CENTER: [number, number] = [34.3227, 118.5525];
// Leaflet 默认图标路径(你原来需要的话保留)
L.Icon.Default.imagePath = `http://${host}/images/`;
// 1) 创建地图(等价 MapContainer
L.Icon.Default.imagePath = "/images/";
const map = L.map("map", {
center: MAP_CENTER,
zoom: 3,
// minZoom: 3,
// maxZoom: 9,
zoomControl: false,
attributionControl: false,
doubleClickZoom: false,
// preferCanvas: true, // 可选
});
// 3) 等价 MapContent: fitBounds + on_mount
// INIT_EXTENT 必须是 Leaflet 接受的 bounds 格式:
// [[southLat, westLng], [northLat, eastLng]]
map.fitBounds(this.INIT_EXTENT, {
padding: [50, 50],
maxZoom: 10,
@@ -366,22 +253,12 @@ export class Leaflet_Map extends enhance.Base {
this.flush();
}
change_tile_display_maximum_level(value: number | null) {
this.tile_display_level_draft = value;
if (value === null) {
this.flush();
return;
}
if (value === null) return;
const config = this.map_view_config || default_map_view_config;
this.map_view_config = {...config, map2d: {...config.map2d, tile_display_maximum_level: value}};
this.set_imagery_layer();
this.flush();
}
blur_tile_display_maximum_level() {
if (this.tile_display_level_draft !== undefined) {
this.tile_display_level_draft = undefined;
this.flush();
}
}
async save_current_map_view() {
const config = this.map_view_config || default_map_view_config;
this.map_view_config = await save_map_view_config({
+1 -1
View File
@@ -27,6 +27,6 @@ export async function upload_map_model(model_type: string, file: File, aircraft_
return response.data;
}
export function emit_map_model_config(config: Map_Model_Config) {
function emit_map_model_config(config: Map_Model_Config) {
window.dispatchEvent(new CustomEvent<Map_Model_Config>(map_model_config_event, {detail: config}));
}
+3 -3
View File
@@ -1,8 +1,8 @@
import axios from "axios";
export type Map_Tile_Y_Axis = "xyz" | "tms"
export type Map_Tile_Projection = "web_mercator"
export type Map_Tile_Encoding = "terrarium" | string
type Map_Tile_Projection = "web_mercator"
type Map_Tile_Encoding = "terrarium" | string
export type Map_Tile_Type = "imagery" | "terrain"
export type Map_Tile_Metadata = {
url: string
@@ -42,7 +42,7 @@ export function terrain_source_for_key(resources: Map_Resources_Metadata, key: s
if (!source) throw new Error("No terrain source configured");
return source;
}
export function tile_sources_for_type(resources: Map_Resources_Metadata, tile_type: Map_Tile_Type): Map_Imagery_Source_Metadata[] {
function tile_sources_for_type(resources: Map_Resources_Metadata, tile_type: Map_Tile_Type): Map_Imagery_Source_Metadata[] {
return tile_type === "terrain" ? resources.terrain_sources : resources.imagery_sources;
}
export function tile_source_options(resources: Map_Resources_Metadata | null, tile_type: Map_Tile_Type) {
-1
View File
@@ -5,7 +5,6 @@ export type Map_Camera_View = Record<string, number>
export type Map_Tile_View_Config = Record<string, any>
export type Map_View_Config = Record<string, any>
export const default_map_camera_view: Map_Camera_View = {};
export const default_map_view_config: Map_View_Config = {map2d: {}, map3d: {}, camera: {}};
export async function load_map_view_config(): Promise<Map_View_Config> {
+74 -357
View File
@@ -1,404 +1,121 @@
import React from "react";
import React from "react";
import {message, Tree, type TreeProps} from "antd";
import enhance from "../core/enhance.tsx";
import {message, Tree, TreeProps} from "antd";
type TreeData = NonNullable<TreeProps["treeData"]>;
type TreeNode = TreeData[number];
function stop_event(event: React.SyntheticEvent) {
event.preventDefault();
event.stopPropagation();
}
export class Tree_Show extends enhance.Base {
expandedKeys: React.Key[] = [];
selectedKeys: React.Key[] = [];
checkedKeys: TreeProps["checkedKeys"] = [];
private treeData: TreeProps["treeData"] = [];
private jsonData: unknown = null;
private uniq(keys: React.Key[]): React.Key[] {
return Array.from(new Set(keys));
}
private findNode(
nodes: TreeProps["treeData"] | undefined,
key: React.Key
): TreeNode | undefined {
private find_node(nodes: TreeProps["treeData"] | undefined, key: React.Key): TreeNode | undefined {
for (const node of nodes ?? []) {
if (node.key === key) {
return node;
}
const found = this.findNode(
node.children as TreeProps["treeData"] | undefined,
key
);
if (found) {
return found;
}
if (node.key === key) return node;
const found = this.find_node(node.children as TreeProps["treeData"] | undefined, key);
if (found) return found;
}
return undefined;
}
/**
*
*/
private isExpanded(key: React.Key): boolean {
return this.expandedKeys.includes(key);
}
/**
* + key
*/
private collectSelfAndChildrenKeys(node: TreeNode): React.Key[] {
private expandable_keys(node: TreeNode, include_self: boolean): React.Key[] {
const result: React.Key[] = [];
const walk = (current: TreeNode) => {
const walk = (current: TreeNode, include: boolean) => {
const children = current.children as TreeNode[] | undefined;
if (children?.length) {
result.push(current.key);
for (const child of children) {
walk(child);
}
}
if (!children?.length) return;
if (include) result.push(current.key);
for (const child of children) walk(child, true);
};
walk(node);
walk(node, include_self);
return result;
}
/**
* key
*/
private collectChildrenKeys(node: TreeNode): React.Key[] {
const result: React.Key[] = [];
const walk = (children?: TreeNode[]) => {
for (const child of children ?? []) {
const childChildren = child.children as TreeNode[] | undefined;
if (childChildren?.length) {
result.push(child.key);
walk(childChildren);
}
}
};
walk(node.children as TreeNode[] | undefined);
return result;
}
private jsonValue(key: React.Key): unknown {
private json_value(key: React.Key): unknown {
let value = this.jsonData;
for (const part of String(key).split("-")) {
const index = Number(part);
if (Array.isArray(value)) {
value = value[index];
} else if (value && typeof value === "object") {
value = Object.values(value as Record<string, unknown>)[index];
} else {
return value;
}
if (Array.isArray(value)) value = value[index];
else if (value && typeof value === "object") value = Object.values(value as Record<string, unknown>)[index];
else break;
}
return value;
}
private copyJson(key: React.Key, event: React.MouseEvent) {
event.preventDefault();
event.stopPropagation();
navigator.clipboard.writeText(JSON.stringify(this.jsonValue(key), null, 2) ?? "null").then(() => {
private copy_json(key: React.Key, event: React.MouseEvent) {
stop_event(event);
void navigator.clipboard.writeText(JSON.stringify(this.json_value(key), null, 2) ?? "null").then(() => {
message.success("JSON已复制");
});
}
/**
*
* /
*
* expanded key
*
*/
private toggleSelf(key: React.Key, event: React.MouseEvent) {
event.preventDefault();
event.stopPropagation();
const node = this.findNode(this.treeData, key);
if (!node) {
return;
}
const childrenKeys = new Set(this.collectChildrenKeys(node));
if (this.isExpanded(key)) {
/**
*
* expandedKeys
*/
this.expandedKeys = this.expandedKeys.filter(
item => item !== key && !childrenKeys.has(item)
);
} else {
/**
*
* key key
*/
this.expandedKeys = this.uniq([
...this.expandedKeys.filter(item => !childrenKeys.has(item)),
key,
]);
}
private toggle_self(key: React.Key, event: React.MouseEvent) {
stop_event(event);
const node = this.find_node(this.treeData, key);
if (!node) return;
const next = new Set(this.expandedKeys);
for (const descendant of this.expandable_keys(node, false)) next.delete(descendant);
if (next.has(key)) next.delete(key);
else next.add(key);
this.expandedKeys = [...next];
this.flush();
}
/**
*
*/
private isSelfAndChildrenExpanded(key: React.Key): boolean {
const node = this.findNode(this.treeData, key);
if (!node) {
return false;
}
const keys = this.collectSelfAndChildrenKeys(node);
const expandedKeySet = new Set(this.expandedKeys);
return keys.length > 0 && keys.every(item => expandedKeySet.has(item));
}
/**
*
* /
*/
private toggleAllChildren(key: React.Key, event: React.MouseEvent) {
event.preventDefault();
event.stopPropagation();
const node = this.findNode(this.treeData, key);
if (!node) {
return;
}
const keys = this.collectSelfAndChildrenKeys(node);
const keySet = new Set(keys);
const isAllExpanded = keys.every(item => this.expandedKeys.includes(item));
if (isAllExpanded) {
/**
*
* +
*/
this.expandedKeys = this.expandedKeys.filter(
item => !keySet.has(item)
);
} else {
/**
*
* +
*/
this.expandedKeys = this.uniq([
...this.expandedKeys,
...keys,
]);
}
private toggle_tree(key: React.Key, event: React.MouseEvent) {
stop_event(event);
const node = this.find_node(this.treeData, key);
if (!node) return;
const keys = this.expandable_keys(node, true);
const next = new Set(this.expandedKeys);
if (keys.every(item => next.has(item))) keys.forEach(item => next.delete(item));
else keys.forEach(item => next.add(item));
this.expandedKeys = [...next];
this.flush();
}
/**
* switcherIcon
*
*
* span stopPropagation
* 使 antd
*/
private renderSwitcherIcon = (nodeProps: any): React.ReactNode => {
const key: React.Key = nodeProps.eventKey ?? nodeProps.key;
const isLeaf = Boolean(nodeProps.isLeaf);
private button(title: string, text: string, onClick: (event: React.MouseEvent) => void) {
return <button type="button" className="tree-switcher-btn" title={title}
onMouseDown={stop_event} onClick={onClick}>{text}</button>;
}
if (isLeaf) {
return (
<span className="tree-switcher-custom">
<button
type="button"
className="tree-switcher-btn tree-switcher-copy"
title="复制JSON"
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => this.copyJson(key, event)}
>
</button>
</span>
);
private render_switcher = (node: any): React.ReactNode => {
const key: React.Key = node.eventKey ?? node.key;
if (node.isLeaf) {
return <span className="tree-switcher-custom">
{this.button("复制JSON", "⧉", event => this.copy_json(key, event))}
</span>;
}
const expanded = this.isExpanded(key);
const isAllExpanded = this.isSelfAndChildrenExpanded(key);
return (
<span
className="tree-switcher-custom"
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<button
type="button"
className="tree-switcher-btn tree-switcher-self"
title={expanded ? "收起当前节点" : "展开当前节点"}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => this.toggleSelf(key, event)}
>
{expanded ? "▼" : "▶"}
</button>
<button
type="button"
className="tree-switcher-btn tree-switcher-all"
title={isAllExpanded ? "全部收起" : "全部展开"}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => this.toggleAllChildren(key, event)}
>
{isAllExpanded ? "⤴" : "⤵"}
</button>
<button
type="button"
className="tree-switcher-btn tree-switcher-copy"
title="复制JSON"
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => this.copyJson(key, event)}
>
</button>
</span>
);
};
onSelect: TreeProps["onSelect"] = (selectedKeys) => {
this.selectedKeys = selectedKeys;
this.flush();
};
onCheck: TreeProps["onCheck"] = (checkedKeys) => {
this.checkedKeys = checkedKeys;
this.flush();
};
/**
* switcher
*
* onExpand
* 1.
* 2. antd
* 3. props
*/
onExpand: TreeProps["onExpand"] = (expandedKeys) => {
this.expandedKeys = expandedKeys as React.Key[];
this.flush();
const expanded = this.expandedKeys.includes(key);
const branch_keys = this.expandable_keys(this.find_node(this.treeData, key)!, true);
const all_expanded = branch_keys.every(item => this.expandedKeys.includes(item));
return <span className="tree-switcher-custom" onMouseDown={stop_event} onClick={stop_event}>
{this.button(expanded ? "收起当前节点" : "展开当前节点", expanded ? "▼" : "▶",
event => this.toggle_self(key, event))}
{this.button(all_expanded ? "全部收起" : "全部展开", all_expanded ? "⤴" : "⤵",
event => this.toggle_tree(key, event))}
{this.button("复制JSON", "⧉", event => this.copy_json(key, event))}
</span>;
};
render(props: TreeProps & {jsonData?: unknown}): React.JSX.Element {
const {jsonData, ...treeProps} = props;
this.treeData = treeProps.treeData ?? [];
this.jsonData = jsonData ?? null;
return (
<>
<style>
{`
.ant-tree-switcher {
width: 76px !important;
flex: 0 0 76px !important;
display: inline-flex !important;
align-items: center;
justify-content: center;
}
.tree-switcher-custom {
width: 76px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
user-select: none;
}
.tree-switcher-btn {
width: 20px;
height: 20px;
padding: 0;
margin: 0;
border: none;
outline: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
line-height: 20px;
}
.tree-switcher-btn:hover {
background: rgba(0, 0, 0, 0.06);
}
.tree-switcher-self {
font-size: 11px;
}
.tree-switcher-all {
font-size: 13px;
}
.tree-switcher-copy {
font-size: 12px;
}
.tree-switcher-empty {
display: inline-block;
width: 76px;
height: 24px;
}
`}
</style>
<Tree
{...treeProps}
autoExpandParent={false}
expandedKeys={this.expandedKeys}
selectedKeys={this.selectedKeys}
checkedKeys={this.checkedKeys}
switcherIcon={this.renderSwitcherIcon}
onSelect={this.onSelect}
onCheck={this.onCheck}
onExpand={this.onExpand}
/>
</>
);
return <>
<style>{`
.ant-tree-switcher{width:76px!important;flex:0 0 76px!important;display:inline-flex!important;align-items:center;justify-content:center}
.tree-switcher-custom{width:76px;height:24px;display:inline-flex;align-items:center;justify-content:center;gap:4px;user-select:none}
.tree-switcher-btn{width:20px;height:20px;padding:0;margin:0;border:0;background:transparent;border-radius:4px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;font-size:12px;line-height:20px}
.tree-switcher-btn:hover{background:rgba(0,0,0,.06)}
`}</style>
<Tree {...treeProps} autoExpandParent={false} expandedKeys={this.expandedKeys}
switcherIcon={this.render_switcher}
onExpand={keys => { this.expandedKeys = keys as React.Key[]; this.flush(); }}/>
</>;
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ function create_webgl_context(canvas: HTMLCanvasElement, name: WebGL_Context_Nam
return null;
}
}
export function can_create_webgl_context(): boolean {
function can_create_webgl_context(): boolean {
if (typeof document === "undefined") return false;
const canvas = document.createElement("canvas");
const options: WebGL_Context_Options = {alpha: false, antialias: false, depth: true, stencil: false, failIfMajorPerformanceCaveat: true, powerPreference: "high-performance"};
+12 -52
View File
@@ -1,62 +1,22 @@
import React, {useLayoutEffect, useRef} from 'react';
import type {CSSProperties, ReactNode} from "react";
import {useLayoutEffect, useRef} from "react";
type PersistentScrollProps = {
children: React.ReactNode;
/** 用于区分不同内容的 key(tab / 模块) */
children: ReactNode;
scrollKey: string;
/** 可选:监听滚动变化 */
onScrollChange?: (scrollTop: number) => void;
style?: React.CSSProperties;
style?: CSSProperties;
};
/** 组件级滚动缓存(不落地、不跨刷新) */
const scrollCache: Record<string, number> = {};
export const PersistentScroll: React.FC<PersistentScrollProps> = ({
children,
scrollKey,
onScrollChange,
style,
}) => {
export function PersistentScroll({children, scrollKey, style}: PersistentScrollProps) {
const ref = useRef<HTMLDivElement | null>(null);
/** 渲染后恢复滚动位置 */
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const top = scrollCache[scrollKey] ?? 0;
el.scrollTop = top;
if (ref.current) ref.current.scrollTop = scrollCache[scrollKey] ?? 0;
}, [scrollKey]);
/** 记录滚动 */
const handleScroll = () => {
const el = ref.current;
if (!el) return;
scrollCache[scrollKey] = el.scrollTop;
onScrollChange?.(el.scrollTop);
};
return (
<div
ref={ref}
onScroll={handleScroll}
style={{
gap: '8px',
width: '100%',
height: '100%',
overflowY: 'auto',
overflowX: 'auto',
...style,
}}
>
{children}
</div>
);
};
return <div ref={ref}
onScroll={event => scrollCache[scrollKey] = event.currentTarget.scrollTop}
style={{gap: 8, width: "100%", height: "100%", overflow: "auto", ...style}}>
{children}
</div>;
}
+46 -106
View File
@@ -1,140 +1,80 @@
import {Button, Form, Input, Modal, Switch} from "antd";
import enhance from "./core/enhance.tsx";
import {ReloadOutlined, SettingOutlined} from "@ant-design/icons";
import {Button, Form, Input, Modal, Switch} from "antd";
import type {ChangeEvent} from "react";
import enhance from "./core/enhance.tsx";
export class Refresh extends enhance.Base {
isAutoRefresh : boolean = true;
isModalVisible : boolean = false;
refreshInterval : number = 1;
autoRefreshTimer = null; // 用于存储定时器ID
refresh : () => void
isAutoRefresh = true;
isModalVisible = false;
refreshInterval = 1;
autoRefreshTimer: ReturnType<typeof setInterval> | null = null;
constructor(refresh : () => void) {
constructor(readonly refresh: () => void) {
super();
this.refresh = refresh;
}
on_mount() {
this.toggleAutoRefresh(this.isAutoRefresh)
this.toggleAutoRefresh(this.isAutoRefresh);
}
on_un_mount() {
if (this.isAutoRefresh) {
this.stopAutoRefresh();
}
this.stopAutoRefresh();
}
// 切换自动刷新模式
toggleAutoRefresh = (checked) => {
toggleAutoRefresh = (checked: boolean) => {
this.isAutoRefresh = checked;
if (this.isAutoRefresh) {
this.startAutoRefresh();
} else {
this.stopAutoRefresh();
}
this.flush(); // 更新界面
}
if (checked) this.startAutoRefresh();
else this.stopAutoRefresh();
this.flush();
};
// 启动自动刷新定时器
startAutoRefresh = () => {
if (this.autoRefreshTimer) {
clearInterval(this.autoRefreshTimer); // 清除之前的定时器
}
this.stopAutoRefresh();
this.autoRefreshTimer = setInterval(this.refresh, this.refreshInterval * 1000);
};
// 每隔 refreshInterval 秒进行一次刷新
this.autoRefreshTimer = setInterval(() => {
this.refresh(); // 调用手动刷新的方法
}, this.refreshInterval * 1000); // 以秒为单位
}
// 停止自动刷新定时器
stopAutoRefresh = () => {
if (this.autoRefreshTimer) {
clearInterval(this.autoRefreshTimer); // 清除定时器
this.autoRefreshTimer = null;
}
}
if (this.autoRefreshTimer) clearInterval(this.autoRefreshTimer);
this.autoRefreshTimer = null;
};
// 显示设置对话框
showModal = () => {
this.isModalVisible = true;
this.flush(); // 更新界面
this.flush();
};
// 关闭设置对话框
handleCancel = () => {
closeModal = () => {
this.isModalVisible = false;
this.flush(); // 更新界面
this.flush();
};
// 确认设置并关闭对话框
handleOk = () => {
this.isModalVisible = false;
this.flush(); // 更新界面
if (this.isAutoRefresh) {
this.startAutoRefresh(); // 如果启用了自动刷新,启动定时器
} else {
this.stopAutoRefresh(); // 否则停止定时器
}
this.closeModal();
if (this.isAutoRefresh) this.startAutoRefresh();
};
// 输入框值的变化
handleIntervalChange = (e) => {
this.refreshInterval = Number(e.target.value);
this.flush(); // 更新界面
if (this.isAutoRefresh) {
this.startAutoRefresh(); // 如果启用了自动刷新,重新启动定时器
}
handleIntervalChange = (event: ChangeEvent<HTMLInputElement>) => {
this.refreshInterval = Math.max(1, Number(event.target.value) || 1);
if (this.isAutoRefresh) this.startAutoRefresh();
this.flush();
};
render() {
return (
<div>
<Button
type="default"
icon={<ReloadOutlined />}
onClick={() => {
this.refresh(); // 手动刷新
}}
>
</Button>
<Button
type="default"
icon={<SettingOutlined />}
onClick={this.showModal} // 打开设置对话框
>
</Button>
{/* 设置对话框 */}
<Modal
title="设置刷新属性"
open={this.isModalVisible}
onOk={this.handleOk}
onCancel={this.handleCancel}
okText="确定"
cancelText="取消"
>
<Form layout="vertical">
<Form.Item label="自动刷新:">
<Switch
checked={this.isAutoRefresh}
onChange={this.toggleAutoRefresh}
/>
</Form.Item>
<Form.Item label="刷新间隔(秒)">
<Input
type="number"
value={this.refreshInterval}
onChange={this.handleIntervalChange}
min={1}
/>
</Form.Item>
</Form>
</Modal>
</div>
);
return <div>
<Button icon={<ReloadOutlined/>} onClick={this.refresh}/>
<Button icon={<SettingOutlined/>} onClick={this.showModal}/>
<Modal title="设置刷新属性" open={this.isModalVisible} onOk={this.handleOk}
onCancel={this.closeModal} okText="确定" cancelText="取消">
<Form layout="vertical">
<Form.Item label="自动刷新:">
<Switch checked={this.isAutoRefresh} onChange={this.toggleAutoRefresh}/>
</Form.Item>
<Form.Item label="刷新间隔(秒)">
<Input type="number" value={this.refreshInterval} min={1}
onChange={this.handleIntervalChange}/>
</Form.Item>
</Form>
</Modal>
</div>;
}
}
+1 -1
View File
@@ -3,6 +3,6 @@ import {Adminive_Settings} from "./Adminive/Adminive_Settings.tsx"
import {PersistentScroll} from "./PersistentScroll.tsx"
export class Settings extends enhance.Base {
render() {
return <PersistentScroll scrollKey="Settings" style={{gap: "16px"}}><Adminive_Settings /><div style={{height: 500}} /></PersistentScroll>
return <PersistentScroll scrollKey="Settings"><Adminive_Settings/></PersistentScroll>
}
}
+13 -52
View File
@@ -1,72 +1,33 @@
import React, {useEffect, useState} from "react";
let _flush = () => {
}
import {useEffect, useState} from "react";
const idle_flush = () => {};
class Base {
mounted: boolean = false;
flush = _flush
mounted = false;
flush = idle_flush;
on_mount() {}
on_un_mount() {}
use_hook() {}
render(_props?: any) { return <h5>==Base==</h5>; }
before_first_render() {
};
on_mount() {
};
on_un_mount() {
};
// 用于使用自定义hook
use_hook() {
}
// 这些函数不用箭头函数是为了父类可以通过super调用
render(_props?: any) {
return <h5>==Base==</h5>
}
// x是一个函数组件,x代表jsx或者tsx 这个目前只能写成箭头函数不知道为什么
x = (props: any) => {
let [flush, setFlush] = useState(0);
if (this.flush === _flush) {
this.flush = (): void => {
if (flush !== Number.MAX_SAFE_INTEGER) {
// 使用函数式更新来确保每次更新都基于最新的 `prevFlush` 值。
// 这样可以避免由于 React 状态更新是异步的而导致的状态不一致问题。
// `prevFlush` 是当前最新的 `flush` 状态,它会被 React 自动传递给更新函数,确保每次都使用最新的状态值。
// 通过返回 `prevFlush + 1`,更新后的 `flush` 状态将会是前一个值加 1。
setFlush((prevFlush: number): number => prevFlush + 1);
} else {
setFlush(0);
}
};
this.before_first_render();
const [, set_revision] = useState(0);
if (this.flush === idle_flush) {
this.flush = () => set_revision(value => value === Number.MAX_SAFE_INTEGER ? 0 : value + 1);
}
this.use_hook();
useEffect(() => {
this.mounted = true;
this.on_mount();
return () => {
this.mounted = false;
this.on_un_mount();
this.flush = _flush;
this.flush = idle_flush;
};
}, []);
return this.render(props);
}
}
export default {
Base
};
export default {Base};