This commit is contained in:
2026-08-05 11:59:44 +08:00
parent 580bf53cc7
commit 59f3cf6823
9 changed files with 331 additions and 163 deletions
+3 -3
View File
@@ -50,11 +50,13 @@ export function Play_Mode({that, field, ...props}) {
export function Input_String({that, field, name, ...props}) {
const [draft, setDraft] = React.useState<number | string | null | undefined>(undefined);
return <Input addonBefore={`${name}`}
placeholder={`请输入${name}`}
value={that[field]}
value={draft !== undefined ? draft : that[field]}
style={{minWidth: 200}}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setDraft(e.target.value);
that[field] = e.target.value;
that.flush();
}}
@@ -134,8 +136,6 @@ export function create_data_feed_options() {
}
export const Psc = {
random_color(): string {
return '#' + Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, '0');
+62 -31
View File
@@ -1,4 +1,4 @@
import enhance from "../core/enhance.tsx";
import enhance from "../core/enhance.tsx";
import {Data_Source_Config} from "./Data_Source_Config.tsx";
import {
Button,
@@ -22,8 +22,8 @@ import {Base_Drawer} from "../Base_Drawer.tsx";
import {Camera_Control_Mode} from "../Map/Camera_Control_Mode.ts";
import {app} from "../App.tsx";
import {default_cesium_graphics_config} from "../Map/Cesium_Render_Settings.tsx";
import {imagery_source_options} from "../Map/Map_Resources.tsx";
import {default_map_view_config, type Map_Tile_View_Config} from "../Map/Map_View.tsx";
import {tile_source_options, type Map_Tile_Type} from "../Map/Map_Resources.tsx";
import {default_map_view_config} from "../Map/Map_View.tsx";
const {Title, Text} = Typography;
@@ -45,6 +45,7 @@ export class Data_Source_Show extends enhance.Base {
drawer = new Base_Drawer()
active_data_source_key = ""
active_tab_key: Settings_Tab = "tiles"
active_tile_type: Map_Tile_Type = "imagery"
number_input_drafts: Record<string, number | null> = {}
number_input_value(key: string, value: number): number | null {
return Object.prototype.hasOwnProperty.call(this.number_input_drafts, key) ? this.number_input_drafts[key] : value;
@@ -65,6 +66,26 @@ export class Data_Source_Show extends enhance.Base {
display_title(mode: Map_Display_Mode) {
return mode === "map3d" ? "3D显示配置" : "2D显示配置";
}
tile_level_range_text(minimum: number | null | undefined, maximum: number | null | undefined): string {
if (minimum === null || minimum === undefined || maximum === null || maximum === undefined) return "未加载";
return minimum === maximum ? String(maximum) : `${minimum}-${maximum}`;
}
displayed_tile_level_text(mode: Map_Display_Mode): string {
if (mode === "map3d") {
const map = app.cesium_map;
const imagery = this.tile_level_range_text(map?.displayed_imagery_minimum_level, map?.displayed_imagery_maximum_level);
const terrain = this.tile_level_range_text(map?.displayed_terrain_minimum_level, map?.displayed_terrain_maximum_level);
return `影像 ${imagery} / 地形 ${terrain}`;
}
const map = app.leaflet_map;
if (!map?.map || !map.map_resources) return "未加载";
const view_level = Math.round(map.map.getZoom());
const resource = map.current_imagery_resource();
const view = map.map2d_tile_view();
const display_maximum = Math.max(resource.minimum_level, Math.min(view.tile_display_maximum_level, 24));
const source_level = Math.min(view_level, resource.maximum_level, display_maximum);
return source_level === view_level ? String(source_level) : `源瓦片 ${source_level}(视图 ${view_level}`;
}
render_display_config(ds: Data_Source, title: string, mode: Map_Display_Mode) {
const style: Data_Source_Map_Display_Data = ds.map_style(mode);
const aircraft_size_key = `${ds.key}:${mode}:aircraft_size`;
@@ -233,44 +254,52 @@ export class Data_Source_Show extends enhance.Base {
map_for_mode(mode: Map_Display_Mode): any {
return mode === "map3d" ? app.cesium_map : app.leaflet_map;
}
tile_view_for_mode(mode: Map_Display_Mode): Map_Tile_View_Config {
const map = this.map_for_mode(mode);
if (mode === "map3d") return map?.map3d_tile_view() || default_map_view_config.map3d;
return map?.map2d_tile_view() || default_map_view_config.map2d;
tile_type_for_mode(mode: Map_Display_Mode): Map_Tile_Type {
return mode === "map3d" ? this.active_tile_type : "imagery";
}
selected_tile_source_key(map: any, tile_type: Map_Tile_Type, options: {value: string, label: string}[]): string {
if (tile_type === "terrain") return map?.current_terrain_key() || options[0]?.value || "";
return map?.current_imagery_key || options[0]?.value || "";
}
change_tile_source(map: any, tile_type: Map_Tile_Type, key: string) {
if (tile_type === "terrain") {
map?.change_terrain_source(key);
}
else {
map?.change_imagery_source(key);
}
this.flush();
}
render_tile_controls(mode: Map_Display_Mode) {
const map = this.map_for_mode(mode);
const tile_view = this.tile_view_for_mode(mode);
const options = imagery_source_options(map?.map_resources || null);
const display_level = map?.tile_display_level_draft !== undefined ? map.tile_display_level_draft : tile_view.tile_display_maximum_level;
const tile_view = mode === "map3d" ? map?.map3d_tile_view() : map?.map2d_tile_view();
const tile_type = this.tile_type_for_mode(mode);
const type_options = mode === "map3d" ? [
{value: "imagery", label: "影像瓦片"},
{value: "terrain", label: "地形瓦片"}
] : [{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 display_level = map?.tile_display_level_draft !== undefined ? map.tile_display_level_draft : tile_view?.tile_display_maximum_level;
return (
<div style={{marginTop: 12, paddingTop: 8, borderTop: "1px solid #eee"}}>
<Title level={5}></Title>
<Space direction="vertical" size={6}>
<Select
style={{width: 220}}
value={map?.current_imagery_key || ""}
options={options}
disabled={!map?.map_resources}
onChange={(key: string) => {
map?.change_imagery_source(key);
value={tile_type}
options={type_options}
onChange={(value: Map_Tile_Type) => {
this.active_tile_type = value;
this.flush();
}}
/>
}}/>
<Select
style={{width: 220}}
value={tile_view.tile_zoom_mode}
options={[
{value: "native", label: "原生瓦片"},
{value: "upscale", label: "放大图片"},
{value: "both", label: "两者都有"}
]}
onChange={(value: Map_Tile_View_Config["tile_zoom_mode"]) => {
map?.change_tile_zoom_mode(value);
this.flush();
}}
/>
<InputNumber addonBefore="显示层级" min={0} max={24}
value={source_key}
options={source_options}
disabled={!source_options.length}
onChange={(key: string) => this.change_tile_source(map, tile_type, key)}/>
{tile_type === "imagery" && <InputNumber addonBefore="显示层级" min={0} max={24}
value={display_level}
onChange={(value: number | null) => {
map?.change_tile_display_maximum_level(value);
@@ -280,7 +309,7 @@ export class Data_Source_Show extends enhance.Base {
map?.blur_tile_display_maximum_level();
this.flush();
}}
style={{width: 220}}/>
style={{width: 220}}/>}
<Button size="small" type="primary" onClick={() => map?.save_current_map_view()}></Button>
</Space>
</div>
@@ -480,6 +509,9 @@ export class Data_Source_Show extends enhance.Base {
const active_ds = enabled_list.find((ds: Data_Source) => ds.key === this.active_data_source_key);
const header = <div>
<h3></h3>
<div style={{marginTop: -6, marginBottom: 8}}>
<Text type="secondary">{this.displayed_tile_level_text(mode)}</Text>
</div>
{this.render_header_tabs(mode, enabled_list)}
</div>;
const scroll_key = this.active_tab_key === "source" ? `Data_Source_Show:source:${this.active_data_source_key}` : `Data_Source_Show:${mode}:${this.active_tab_key}`;
@@ -491,4 +523,3 @@ export class Data_Source_Show extends enhance.Base {
</this.drawer.x>
}
}
+88 -25
View File
@@ -11,6 +11,7 @@ import {AimOutlined, EyeOutlined} from "@ant-design/icons";
import {
imagery_source_for_key,
load_map_resources,
terrain_source_for_key,
url_template_for_y_axis,
type Map_Imagery_Source_Metadata,
type Map_Resources_Metadata,
@@ -149,6 +150,10 @@ export class Cesium_Map extends enhance.Base {
current_imagery_key: string = "";
graphics_config: Cesium_Graphics_Config = {...default_cesium_graphics_config};
tile_display_level_draft: number | null | undefined = undefined;
displayed_imagery_minimum_level: number | null = null;
displayed_imagery_maximum_level: number | null = null;
displayed_terrain_minimum_level: number | null = null;
displayed_terrain_maximum_level: number | null = null;
previous_camera_view: Map_Camera_View | null = null;
graphics_config_listener: ((event: Event) => void) | null = null;
model_config: Map_Model_Config = {...empty_map_model_config};
@@ -355,6 +360,17 @@ export class Cesium_Map extends enhance.Base {
current_imagery_source(): Map_Imagery_Source_Metadata {
return imagery_source_for_key(this.map_resources!, this.current_imagery_key);
}
current_terrain_key(): string {
const key = this.map3d_tile_view().current_terrain_key;
const source = this.map_resources?.terrain_sources.find(item => item.key === key) || this.map_resources?.terrain_sources[0];
return source?.key || key;
}
current_terrain_resource(): Map_Tile_Metadata {
return this.current_terrain_source().resource;
}
current_terrain_source(): Map_Imagery_Source_Metadata {
return terrain_source_for_key(this.map_resources!, this.current_terrain_key());
}
map3d_tile_view(): Map_Tile_View_Config {
return (this.map_view_config || default_map_view_config).map3d;
}
@@ -372,26 +388,28 @@ export class Cesium_Map extends enhance.Base {
credit: resource.credit ? new Cesium.Credit(resource.credit) : undefined
});
}
change_imagery_source(key: string) {
if (!this.viewer || !this.map_resources) return;
const config = this.map_view_config || default_map_view_config;
this.current_imagery_key = key;
this.map_view_config = {...config, current_imagery_key: key, map3d: {...config.map3d, current_imagery_key: key}};
this.refresh_imagery_layer();
this.viewer.scene.requestRender();
this.flush();
}
refresh_imagery_layer() {
if (!this.viewer || !this.map_resources) return;
this.viewer.imageryLayers.removeAll();
this.viewer.imageryLayers.addImageryProvider(this.create_imagery_provider(this.current_imagery_resource()));
if (this.viewer.imageryLayers.length > 0) {
this.viewer.imageryLayers.remove(this.viewer.imageryLayers.get(0), true);
}
change_tile_zoom_mode(mode: "native" | "upscale" | "both") {
if (!this.viewer) return;
this.viewer.imageryLayers.addImageryProvider(this.create_imagery_provider(this.current_imagery_resource()), 0);
}
change_imagery_source(key: string) {
this.current_imagery_key = key;
const config = this.map_view_config || default_map_view_config;
this.map_view_config = {...config, map3d: {...config.map3d, tile_zoom_mode: mode}};
this.map_view_config = {...config, current_imagery_key: key, map3d: {...config.map3d, current_imagery_key: key}};
this.refresh_imagery_layer();
this.viewer.scene.requestRender();
this.viewer?.scene.requestRender();
this.flush();
}
change_terrain_source(key: string) {
const config = this.map_view_config || default_map_view_config;
this.map_view_config = {...config, map3d: {...config.map3d, current_terrain_key: key}};
this.update_terrain_provider();
this.destroy_occlusion_terrain_provider();
this.request_sync_data_sources();
this.viewer?.scene.requestRender();
this.flush();
}
change_tile_display_maximum_level(value: number | null) {
@@ -446,7 +464,7 @@ export class Cesium_Map extends enhance.Base {
...config,
current_imagery_key: this.current_imagery_key,
scene_mode: "3d",
map3d: {...config.map3d, current_imagery_key: this.current_imagery_key},
map3d: {...config.map3d, current_imagery_key: this.current_imagery_key, current_terrain_key: this.current_terrain_key()},
camera: this.current_camera_view()
});
this.current_imagery_key = this.map_view_config.map3d.current_imagery_key;
@@ -480,16 +498,17 @@ export class Cesium_Map extends enhance.Base {
return Cesium.SceneMode.SCENE3D;
}
should_use_terrarium_terrain(): boolean {
return this.scene_mode === "3d" && this.graphics_config.terrainEnabledIn3D && Boolean(this.map_resources);
return this.scene_mode === "3d" && this.graphics_config.terrainEnabledIn3D && Boolean(this.map_resources?.terrain_sources.length);
}
effective_terrain_maximum_level(): number | undefined {
if (!this.map_resources || !this.graphics_config.terrainLimitMaximumLevel) return undefined;
return Math.min(this.graphics_config.terrainMaximumLevel, this.map_resources.terrain.maximum_level);
return Math.min(this.graphics_config.terrainMaximumLevel, this.current_terrain_resource().maximum_level);
}
current_terrain_signature(): string {
if (!this.should_use_terrarium_terrain()) return "ellipsoid";
const terrain = this.current_terrain_resource();
const maximum_level = this.effective_terrain_maximum_level();
return `terrarium:${maximum_level === undefined ? "unlimited" : maximum_level}:${this.graphics_config.terrainCacheTiles}`;
return `terrarium:${this.current_terrain_source().key}:${terrain.minimum_level}:${terrain.maximum_level}:${maximum_level === undefined ? "unlimited" : maximum_level}:${this.graphics_config.terrainCacheTiles}`;
}
create_terrain_provider(): Cesium.TerrainProvider {
this.terrain_provider = null;
@@ -497,7 +516,7 @@ export class Cesium_Map extends enhance.Base {
if (!this.should_use_terrarium_terrain() || !this.map_resources) {
return new Cesium.EllipsoidTerrainProvider();
}
this.terrain_provider = new Terrarium_Terrain_Provider(this.map_resources.terrain, {
this.terrain_provider = new Terrarium_Terrain_Provider(this.current_terrain_resource(), {
maximum_level: this.effective_terrain_maximum_level(),
max_cached_height_tiles: this.graphics_config.terrainCacheTiles
});
@@ -521,7 +540,7 @@ export class Cesium_Map extends enhance.Base {
if (this.occlusion_terrain_provider && this.occlusion_terrain_signature === signature) return this.occlusion_terrain_provider;
this.destroy_occlusion_terrain_provider();
this.occlusion_terrain_signature = signature;
this.occlusion_terrain_provider = new Terrarium_Terrain_Provider(this.map_resources.terrain, {
this.occlusion_terrain_provider = new Terrarium_Terrain_Provider(this.current_terrain_resource(), {
maximum_level: this.effective_terrain_maximum_level(),
max_cached_height_tiles: this.graphics_config.terrainCacheTiles
});
@@ -547,7 +566,7 @@ export class Cesium_Map extends enhance.Base {
return Math.max(1, Math.min(4, cores - 2));
}
effective_los_terrain_level(): number {
const terrain = this.map_resources!.terrain;
const terrain = this.current_terrain_resource();
const maximum_level = this.effective_terrain_maximum_level() ?? terrain.maximum_level;
return Math.max(terrain.minimum_level, Math.min(maximum_level, terrain.maximum_level));
}
@@ -656,7 +675,7 @@ export class Cesium_Map extends enhance.Base {
return this.surface_navigation_reference_entity;
}
surface_navigation_reference_text(): string {
return this.current_terrain_signature() === "ellipsoid" ? "地表导航参考\n椭球地表" : "地表导航参考\nTerrarium地形";
return this.current_terrain_signature() === "ellipsoid" ? "地表导航参考\n椭球地表" : `地表导航参考\n${this.current_terrain_source().name}`;
}
destroy_surface_navigation_reference() {
if (this.surface_navigation_reference_entity) {
@@ -681,6 +700,7 @@ export class Cesium_Map extends enhance.Base {
this.performance_frame_count++;
}
flush_performance_info() {
this.capture_displayed_tile_levels();
const frame_count = this.performance_frame_count;
const frame_ms = frame_count > 1 ? this.performance_frame_time_total / (frame_count - 1) : 0;
this.performance_info = {
@@ -694,6 +714,48 @@ export class Cesium_Map extends enhance.Base {
this.performance_frame_time_total = 0;
this.flush();
}
capture_displayed_tile_levels() {
const globe = this.viewer?.scene.globe as any;
const tiles = globe?._surface?._tilesToRender;
const terrain_levels: number[] = [];
const imagery_levels: number[] = [];
const imagery_layer = this.viewer && this.viewer.imageryLayers.length > 0 ? this.viewer.imageryLayers.get(0) : undefined;
if (Array.isArray(tiles)) {
for (const tile of tiles) {
const terrain_level = Number(tile?.level ?? tile?._level);
if (Number.isFinite(terrain_level)) {
terrain_levels.push(terrain_level);
}
const imagery_items = tile?.data?.imagery ?? tile?._data?.imagery;
if (!Array.isArray(imagery_items)) continue;
for (const item of imagery_items) {
const imagery = item?.readyImagery ?? item?.loadingImagery;
if (!imagery) continue;
const item_layer = imagery.imageryLayer ?? imagery._imageryLayer;
if (imagery_layer && item_layer && item_layer !== imagery_layer) continue;
const imagery_level = Number(imagery.level ?? imagery._level);
if (Number.isFinite(imagery_level)) {
imagery_levels.push(imagery_level);
}
}
}
}
const terrain_minimum = terrain_levels.length > 0 ? Math.min(...terrain_levels) : null;
const terrain_maximum = terrain_levels.length > 0 ? Math.max(...terrain_levels) : null;
const imagery_minimum = imagery_levels.length > 0 ? Math.min(...imagery_levels) : terrain_minimum;
const imagery_maximum = imagery_levels.length > 0 ? Math.max(...imagery_levels) : terrain_maximum;
const changed = terrain_minimum !== this.displayed_terrain_minimum_level ||
terrain_maximum !== this.displayed_terrain_maximum_level ||
imagery_minimum !== this.displayed_imagery_minimum_level ||
imagery_maximum !== this.displayed_imagery_maximum_level;
this.displayed_terrain_minimum_level = terrain_minimum;
this.displayed_terrain_maximum_level = terrain_maximum;
this.displayed_imagery_minimum_level = imagery_minimum;
this.displayed_imagery_maximum_level = imagery_maximum;
if (changed) {
app.leaflet_map?.data_source_show.flush();
}
}
read_js_heap_mb(): number | null {
const memory = (performance as any).memory;
if (!memory || typeof memory.usedJSHeapSize !== "number") return null;
@@ -1270,7 +1332,8 @@ export class Cesium_Map extends enhance.Base {
}
current_terrain_sample_signature(): string {
if (!this.map_resources) return "none";
return `${this.map_resources.terrain.minimum_level}:${this.map_resources.terrain.maximum_level}:${this.effective_terrain_maximum_level() ?? "unlimited"}:${this.graphics_config.terrainCacheTiles}`;
const terrain = this.current_terrain_resource();
return `${this.current_terrain_source().key}:${terrain.minimum_level}:${terrain.maximum_level}:${this.effective_terrain_maximum_level() ?? "unlimited"}:${this.graphics_config.terrainCacheTiles}`;
}
base_station_description(ds: Data_Source) {
const station = ds.base_station;
@@ -1567,7 +1630,7 @@ export class Cesium_Map extends enhance.Base {
}
async test_terrain_occlusion(station_position: Cesium.Cartesian3, aircraft_position: Cesium.Cartesian3): Promise<Terrain_Occlusion_Result> {
if (!this.map_resources) return {obstructed: false};
const terrain = this.map_resources.terrain;
const terrain = this.current_terrain_resource();
if (terrain.encoding !== "terrarium") return {obstructed: false};
const station = Cesium.Cartographic.fromCartesian(station_position);
const aircraft = Cesium.Cartographic.fromCartesian(aircraft_position);
+5 -10
View File
@@ -1,4 +1,4 @@
import "leaflet/dist/leaflet.css"
import "leaflet/dist/leaflet.css"
import L from 'leaflet';
import gcoord from "gcoord"
import React, {useEffect, useState} from 'react';
@@ -51,6 +51,7 @@ export class Leaflet_Map extends enhance.Base {
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;
@@ -106,6 +107,7 @@ export class Leaflet_Map extends enhance.Base {
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(() => {
});
@@ -152,6 +154,7 @@ export class Leaflet_Map extends enhance.Base {
if (this.map) {
this.savedCenter = this.map.getCenter();
this.savedZoom = this.map.getZoom();
this.map.off("zoomend", this.tile_level_listener);
this.map.remove()
}
this.tile_layer = null
@@ -358,16 +361,9 @@ export class Leaflet_Map extends enhance.Base {
this.flush();
}
change_imagery_source(key: string) {
if (!this.map_resources) return;
const config = this.map_view_config || default_map_view_config;
this.current_imagery_key = key;
this.map_view_config = {...config, current_imagery_key: key, map2d: {...config.map2d, current_imagery_key: key}};
this.set_imagery_layer();
this.flush();
}
change_tile_zoom_mode(mode: "native" | "upscale" | "both") {
const config = this.map_view_config || default_map_view_config;
this.map_view_config = {...config, map2d: {...config.map2d, tile_zoom_mode: mode}};
this.map_view_config = {...config, current_imagery_key: key, map2d: {...config.map2d, current_imagery_key: key}};
this.set_imagery_layer();
this.flush();
}
@@ -456,4 +452,3 @@ export class Leaflet_Map extends enhance.Base {
return null;
}
}
+41 -27
View File
@@ -1,4 +1,5 @@
import type {Los_Position, Los_Request, Los_Result, Los_Terrain_Resource} from "./Los_Protocol.ts";
import {bilinear_height, fill_height_gaps_from_nearest, fill_height_gaps_from_parent, is_valid_earth_height, terrarium_height} from "./Terrarium_Height.ts";
interface Worker_Request<T> {
task_id: number
@@ -138,12 +139,15 @@ function cartesian_distance(a: Cartesian, b: Cartesian): number {
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 maximum_level = Math.max(resource.minimum_level, Math.min(level, resource.maximum_level));
for (let sample_level = maximum_level; sample_level >= resource.minimum_level; --sample_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;
if (!heights) continue;
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);
}
return undefined;
}
function position_to_tile(position: Cartographic, level: number): {x: number, y: number} {
const n = Math.pow(2, level);
@@ -168,26 +172,11 @@ function position_to_tile_pixel(position: Cartographic, level: number, tile_size
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 => {
const promise = load_and_complete_height_tile(resource, x, y, level).catch(error => {
tile_cache.delete(key);
throw error;
});
@@ -195,13 +184,31 @@ function load_height_tile(resource: Los_Terrain_Resource, x: number, y: number,
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 load_and_complete_height_tile(resource: Los_Terrain_Resource, x: number, y: number, level: number): Promise<Float32Array | undefined> {
const decoded = await fetch_height_tile(resource, x, y, level);
if (!decoded) return undefined;
if (decoded.invalid_count === 0) return decoded.heights;
if (level > resource.minimum_level) {
const parent = await load_height_tile(resource, Math.floor(x / 2), Math.floor(y / 2), level - 1);
if (parent) {
fill_height_gaps_from_parent(decoded.heights, parent, resource.tile_size, x, y);
}
}
if (!fill_height_gaps_from_nearest(decoded.heights, resource.tile_size)) return undefined;
return decoded.heights;
}
async function decode_terrarium_png(blob: Blob, tile_size: number): Promise<Float32Array> {
type Decoded_Height_Tile = {
heights: Float32Array
invalid_count: number
}
async function fetch_height_tile(resource: Los_Terrain_Resource, x: number, y: number, level: number): Promise<Decoded_Height_Tile | undefined> {
const response = await fetch(tile_url(resource, x, y, level), {cache: "force-cache"});
if (response.status === 404 || response.status === 204) return undefined;
if (!response.ok) throw new Error(`terrain tile request failed: ${response.status}`);
const decoded = await decode_terrarium_png(await response.blob(), resource.tile_size);
return decoded.invalid_count === decoded.heights.length ? undefined : decoded;
}
async function decode_terrarium_png(blob: Blob, tile_size: number): Promise<Decoded_Height_Tile> {
const bitmap = await createImageBitmap(blob);
try {
if (typeof OffscreenCanvas === "undefined") throw new Error("OffscreenCanvas is not available");
@@ -211,10 +218,17 @@ async function decode_terrarium_png(blob: Blob, tile_size: number): Promise<Floa
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;
let invalid_count = 0;
for (let i = 0, j = 0; i < heights.length; ++i, j += 4) {
const height = terrarium_height(image.data[j], image.data[j + 1], image.data[j + 2]);
if (image.data[j + 3] !== 255 || !is_valid_earth_height(height)) {
heights[i] = Number.NaN;
++invalid_count;
continue;
}
return heights;
heights[i] = height;
}
return {heights, invalid_count};
} finally {
bitmap.close();
}
+12 -5
View File
@@ -3,6 +3,7 @@ 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
export type Map_Tile_Type = "imagery" | "terrain"
export type Map_Tile_Metadata = {
url: string
minimum_level: number
@@ -16,7 +17,7 @@ export type Map_Tile_Metadata = {
export type Map_Imagery_Source_Metadata = {
key: string
name: string
tile_type: "imagery" | string
tile_type: Map_Tile_Type | string
request_method: "GET" | string
resource: Map_Tile_Metadata
}
@@ -36,10 +37,16 @@ export function imagery_source_for_key(resources: Map_Resources_Metadata, key: s
if (!source) throw new Error("No imagery source configured");
return source;
}
export function imagery_source_options(resources: Map_Resources_Metadata | null): {value: string, label: string}[] {
return (resources?.imagery_sources || []).map(source => {
return {value: source.key, label: source.name};
});
export function terrain_source_for_key(resources: Map_Resources_Metadata, key: string): Map_Imagery_Source_Metadata {
const source = resources.terrain_sources.find(item => item.key === key) || resources.terrain_sources[0];
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[] {
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) {
return resources ? tile_sources_for_type(resources, tile_type).map(item => ({value: item.key, label: item.name})) : [];
}
export function url_template_for_y_axis(resource: Map_Tile_Metadata): string {
if (resource.y_axis === "tms") {
+4
View File
@@ -11,6 +11,7 @@ export type Map_Camera_View = {
}
export type Map_Tile_View_Config = {
current_imagery_key: string
current_terrain_key: string
tile_zoom_mode: "native" | "upscale" | "both"
tile_display_maximum_level: number
}
@@ -35,11 +36,13 @@ export const default_map_view_config: Map_View_Config = {
scene_mode: "3d",
map2d: {
current_imagery_key: "imagery",
current_terrain_key: "terrain",
tile_zoom_mode: "native",
tile_display_maximum_level: 19
},
map3d: {
current_imagery_key: "imagery",
current_terrain_key: "terrain",
tile_zoom_mode: "native",
tile_display_maximum_level: 19
},
@@ -59,6 +62,7 @@ function tile_zoom_mode_value(value: unknown, fallback: Map_Tile_View_Config["ti
function normalize_tile_view_config(data: Partial<Map_Tile_View_Config> | undefined, fallback: Map_Tile_View_Config): Map_Tile_View_Config {
return {
current_imagery_key: typeof data?.current_imagery_key === "string" ? data.current_imagery_key : fallback.current_imagery_key,
current_terrain_key: typeof data?.current_terrain_key === "string" ? data.current_terrain_key : fallback.current_terrain_key,
tile_zoom_mode: tile_zoom_mode_value(data?.tile_zoom_mode, fallback.tile_zoom_mode),
tile_display_maximum_level: Math.max(0, Math.min(24, number_value(data?.tile_display_maximum_level, fallback.tile_display_maximum_level)))
};
+74 -36
View File
@@ -1,20 +1,25 @@
import * as Cesium from "cesium";
import type {Map_Tile_Metadata} from "./Map_Resources.tsx";
import {bilinear_height, fill_height_gaps_from_nearest, fill_height_gaps_from_parent} from "./Terrarium_Height.ts";
type Cached_Height_Tile = {
promise: Promise<Float32Array | undefined>
}
type Decoded_Height_Tile = {
heights: Float32Array
invalid_count: number
}
type Terrarium_Terrain_Provider_Options = {
maximum_level?: number
max_cached_height_tiles?: number
}
type Pending_Worker_Request = {
resolve: (value: Float32Array | undefined) => void
resolve: (value: Decoded_Height_Tile | null | undefined) => void
reject: (reason?: any) => void
}
type Worker_Response = {
id: number
buffer?: ArrayBuffer
invalid_count?: number
missing?: boolean
aborted?: boolean
error?: string
@@ -23,10 +28,11 @@ export class Terrarium_Terrain_Provider {
provider: Cesium.TerrainProvider
private resource: Map_Tile_Metadata
private height_cache: Map<string, Cached_Height_Tile> = new Map()
private missing_tiles: Set<string> = new Set()
private worker: Worker
private pending_worker_requests: Map<number, Pending_Worker_Request> = new Map()
private next_worker_request_id = 1
private maximum_level?: number
private maximum_level: number
private max_cached_height_tiles: number
private tiling_scheme: Cesium.WebMercatorTilingScheme
private destroyed = false
@@ -38,7 +44,7 @@ export class Terrarium_Terrain_Provider {
throw new Error(`Unsupported terrain encoding: ${resource.encoding}`)
}
this.resource = resource
this.maximum_level = options.maximum_level
this.maximum_level = Math.min(options.maximum_level ?? resource.maximum_level, resource.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"})
@@ -63,40 +69,27 @@ export class Terrarium_Terrain_Provider {
this.resolve_all_worker_requests()
this.worker.terminate()
this.height_cache.clear()
this.missing_tiles.clear()
}
async sample_height(cartographic: Cesium.Cartographic, level?: number): Promise<number | undefined> {
if (this.destroyed) return undefined
const sample_level = this.sample_level(level)
for (let sample_level = this.sample_level(level); sample_level >= this.resource.minimum_level; --sample_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
if (!heights || this.destroyed) continue
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)
return bilinear_height(heights, this.resource.tile_size, x, y)
}
return undefined
}
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
const requested = level ?? this.maximum_level
return Math.max(this.resource.minimum_level, Math.min(requested, this.maximum_level))
}
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)) {
if (this.destroyed || !this.is_level_available(level) || this.missing_tiles.has(this.tile_key(x, y, level))) {
return undefined
}
return this.load_height_tile(x, y, level, request).then(heights => {
@@ -111,12 +104,18 @@ export class Terrarium_Terrain_Provider {
})
}
private load_height_tile(x: number, y: number, level: number, request?: Cesium.Request): Promise<Float32Array | undefined> {
const key = `${level}/${x}/${y}`
if (!this.is_level_available(level)) {
return Promise.resolve(undefined)
}
const key = this.tile_key(x, y, level)
if (this.missing_tiles.has(key)) {
return Promise.resolve(undefined)
}
const cached = this.height_cache.get(key)
if (cached) {
return cached.promise
}
const promise = this.decode_height_tile_in_worker(key, x, y, level, request).catch(error => {
const promise = this.load_and_complete_height_tile(key, x, y, level, request).catch(error => {
this.height_cache.delete(key)
throw error
})
@@ -124,6 +123,30 @@ export class Terrarium_Terrain_Provider {
this.trim_cache()
return promise
}
private async load_and_complete_height_tile(key: string, x: number, y: number, level: number, request?: Cesium.Request): Promise<Float32Array | undefined> {
const decoded = await this.decode_height_tile_in_worker(key, x, y, level, request)
if (decoded === null) {
this.mark_tile_missing(key)
return undefined
}
if (!decoded || this.destroyed) {
return undefined
}
if (decoded.invalid_count === 0) {
return decoded.heights
}
if (level > this.resource.minimum_level) {
const parent = await this.load_height_tile(Math.floor(x / 2), Math.floor(y / 2), level - 1)
if (parent) {
fill_height_gaps_from_parent(decoded.heights, parent, this.resource.tile_size, x, y)
}
}
if (!fill_height_gaps_from_nearest(decoded.heights, this.resource.tile_size)) {
this.mark_tile_missing(key)
return undefined
}
return decoded.heights
}
private trim_cache() {
while (this.height_cache.size > this.max_cached_height_tiles) {
const first = this.height_cache.keys().next().value
@@ -133,7 +156,17 @@ export class Terrarium_Terrain_Provider {
this.height_cache.delete(first)
}
}
private decode_height_tile_in_worker(key: string, x: number, y: number, level: number, request?: Cesium.Request): Promise<Float32Array | undefined> {
private mark_tile_missing(key: string) {
this.missing_tiles.add(key)
while (this.missing_tiles.size > this.max_cached_height_tiles * 4) {
const first = this.missing_tiles.values().next().value
if (first === undefined) {
break
}
this.missing_tiles.delete(first)
}
}
private decode_height_tile_in_worker(key: string, x: number, y: number, level: number, request?: Cesium.Request): Promise<Decoded_Height_Tile | null | undefined> {
const id = this.next_worker_request_id++
return new Promise((resolve, reject) => {
this.pending_worker_requests.set(id, {resolve, reject})
@@ -158,11 +191,15 @@ export class Terrarium_Terrain_Provider {
pending.reject(new Error(response.error))
return
}
if (response.missing || response.aborted || !response.buffer) {
if (response.missing) {
pending.resolve(null)
return
}
if (response.aborted || !response.buffer) {
pending.resolve(undefined)
return
}
pending.resolve(new Float32Array(response.buffer))
pending.resolve({heights: new Float32Array(response.buffer), invalid_count: response.invalid_count || 0})
}
private resolve_all_worker_requests() {
for (const pending of this.pending_worker_requests.values()) {
@@ -177,14 +214,15 @@ export class Terrarium_Terrain_Provider {
this.pending_worker_requests.clear()
}
private get_tile_data_available(x: number, y: number, level: number): boolean | undefined {
if (level < this.resource.minimum_level) return false
if (this.maximum_level !== undefined) return level <= this.maximum_level
if (!this.is_level_available(level)) return false
if (this.missing_tiles.has(this.tile_key(x, y, level))) return false
return undefined
}
private is_level_available(level: number): boolean {
if (level < this.resource.minimum_level) return false
if (this.maximum_level === undefined) return true
return level <= this.maximum_level
return level >= this.resource.minimum_level && level <= this.maximum_level
}
private tile_key(x: number, y: number, level: number): string {
return `${level}/${x}/${y}`
}
private tile_url(x: number, y: number, level: number): string {
const tile_y = this.resource.y_axis === "tms" ? Math.pow(2, level) - 1 - y : y
+24 -8
View File
@@ -1,3 +1,4 @@
import {is_valid_earth_height, terrarium_height} from "./Terrarium_Height.ts";
type Decode_Message = {
type: "decode"
id: number
@@ -13,6 +14,10 @@ type Worker_Context = {
onmessage: ((event: MessageEvent<Worker_Message>) => void) | null
postMessage: (message: any, transfer?: ArrayBuffer[]) => void
}
type Decoded_Terrarium_Tile = {
heights: Float32Array
invalid_count: number
}
const worker_context = self as unknown as Worker_Context;
const controllers: Map<number, AbortController> = new Map();
worker_context.onmessage = (event: MessageEvent<Worker_Message>) => {
@@ -28,16 +33,20 @@ async function decode(message: Decode_Message) {
controllers.set(message.id, controller);
try {
const response = await fetch(message.url, {signal: controller.signal, cache: "force-cache"});
if (response.status === 404) {
if (response.status === 404 || response.status === 204) {
worker_context.postMessage({id: message.id, missing: true});
return;
}
if (!response.ok) {
throw new Error(`terrain tile request failed: ${response.status}`);
}
const heights = await decode_terrarium_png(await response.blob(), message.tile_size);
const buffer = heights.buffer as ArrayBuffer;
worker_context.postMessage({id: message.id, buffer}, [buffer]);
const decoded = await decode_terrarium_png(await response.blob(), message.tile_size);
if (decoded.invalid_count === decoded.heights.length) {
worker_context.postMessage({id: message.id, missing: true});
return;
}
const buffer = decoded.heights.buffer as ArrayBuffer;
worker_context.postMessage({id: message.id, buffer, invalid_count: decoded.invalid_count}, [buffer]);
}
catch (error) {
if (controller.signal.aborted) {
@@ -50,7 +59,7 @@ async function decode(message: Decode_Message) {
controllers.delete(message.id);
}
}
async function decode_terrarium_png(blob: Blob, tile_size: number): Promise<Float32Array> {
async function decode_terrarium_png(blob: Blob, tile_size: number): Promise<Decoded_Terrarium_Tile> {
const bitmap = await createImageBitmap(blob);
try {
if (typeof OffscreenCanvas === "undefined") {
@@ -64,10 +73,17 @@ async function decode_terrarium_png(blob: Blob, tile_size: number): Promise<Floa
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;
let invalid_count = 0;
for (let i = 0, j = 0; i < heights.length; ++i, j += 4) {
const height = terrarium_height(image.data[j], image.data[j + 1], image.data[j + 2]);
if (image.data[j + 3] !== 255 || !is_valid_earth_height(height)) {
heights[i] = Number.NaN;
++invalid_count;
continue;
}
return heights;
heights[i] = height;
}
return {heights, invalid_count};
}
finally {
bitmap.close();