@faclon-labs/app-sdk 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -1
- package/dist/SignIn.js +2 -2
- package/dist/config.d.ts +5 -0
- package/dist/config.js +1 -0
- package/dist/map/globe.d.ts +5 -0
- package/dist/map/globe.js +5 -0
- package/dist/map/index.d.ts +21 -0
- package/dist/map/index.js +21 -0
- package/dist/map/source.d.ts +46 -0
- package/dist/map/source.js +54 -0
- package/dist/map/testing.d.ts +5 -0
- package/dist/map/testing.js +5 -0
- package/dist/session.d.ts +1 -1
- package/dist/session.js +1 -1
- package/package.json +55 -5
- package/vendor/SOURCE.txt +1 -0
- package/vendor/map-engine/cesium/camera.d.ts +10 -0
- package/vendor/map-engine/cesium/camera.js +25 -0
- package/vendor/map-engine/cesium/index.d.ts +2 -0
- package/vendor/map-engine/cesium/index.js +2 -0
- package/vendor/map-engine/cesium/renderer.d.ts +149 -0
- package/vendor/map-engine/cesium/renderer.js +837 -0
- package/vendor/map-engine/commands/index.d.ts +2 -0
- package/vendor/map-engine/commands/index.js +2 -0
- package/vendor/map-engine/commands/post-message.d.ts +32 -0
- package/vendor/map-engine/commands/post-message.js +77 -0
- package/vendor/map-engine/commands/webmcp.d.ts +17 -0
- package/vendor/map-engine/commands/webmcp.js +59 -0
- package/vendor/map-engine/core/commands.d.ts +82 -0
- package/vendor/map-engine/core/commands.js +46 -0
- package/vendor/map-engine/core/emitter.d.ts +9 -0
- package/vendor/map-engine/core/emitter.js +38 -0
- package/vendor/map-engine/core/equal.d.ts +2 -0
- package/vendor/map-engine/core/equal.js +28 -0
- package/vendor/map-engine/core/errors.d.ts +20 -0
- package/vendor/map-engine/core/errors.js +34 -0
- package/vendor/map-engine/core/geometry-family.d.ts +3 -0
- package/vendor/map-engine/core/geometry-family.js +13 -0
- package/vendor/map-engine/core/index.d.ts +4 -0
- package/vendor/map-engine/core/index.js +3 -0
- package/vendor/map-engine/core/types.d.ts +709 -0
- package/vendor/map-engine/core/types.js +7 -0
- package/vendor/map-engine/data/annotations.d.ts +3 -0
- package/vendor/map-engine/data/annotations.js +63 -0
- package/vendor/map-engine/data/contracts.d.ts +172 -0
- package/vendor/map-engine/data/contracts.js +12 -0
- package/vendor/map-engine/data/index.d.ts +4 -0
- package/vendor/map-engine/data/index.js +3 -0
- package/vendor/map-engine/data/schema.d.ts +7 -0
- package/vendor/map-engine/data/schema.js +40 -0
- package/vendor/map-engine/geofence/evaluator.d.ts +35 -0
- package/vendor/map-engine/geofence/evaluator.js +172 -0
- package/vendor/map-engine/geofence/history.d.ts +9 -0
- package/vendor/map-engine/geofence/history.js +19 -0
- package/vendor/map-engine/geofence/index.d.ts +2 -0
- package/vendor/map-engine/geofence/index.js +2 -0
- package/vendor/map-engine/index.d.ts +27 -0
- package/vendor/map-engine/index.js +14 -0
- package/vendor/map-engine/layers/index.d.ts +1 -0
- package/vendor/map-engine/layers/index.js +1 -0
- package/vendor/map-engine/layers/layers.d.ts +49 -0
- package/vendor/map-engine/layers/layers.js +316 -0
- package/vendor/map-engine/maplibre/deck-host.d.ts +105 -0
- package/vendor/map-engine/maplibre/deck-host.js +401 -0
- package/vendor/map-engine/maplibre/icons.d.ts +17 -0
- package/vendor/map-engine/maplibre/icons.js +65 -0
- package/vendor/map-engine/maplibre/index.d.ts +3 -0
- package/vendor/map-engine/maplibre/index.js +2 -0
- package/vendor/map-engine/maplibre/map-like.d.ts +56 -0
- package/vendor/map-engine/maplibre/map-like.js +1 -0
- package/vendor/map-engine/maplibre/renderer.d.ts +173 -0
- package/vendor/map-engine/maplibre/renderer.js +889 -0
- package/vendor/map-engine/maplibre/style.d.ts +84 -0
- package/vendor/map-engine/maplibre/style.js +493 -0
- package/vendor/map-engine/present/decorations.d.ts +25 -0
- package/vendor/map-engine/present/decorations.js +115 -0
- package/vendor/map-engine/present/index.d.ts +2 -0
- package/vendor/map-engine/present/index.js +2 -0
- package/vendor/map-engine/present/present.d.ts +47 -0
- package/vendor/map-engine/present/present.js +149 -0
- package/vendor/map-engine/query/index.d.ts +1 -0
- package/vendor/map-engine/query/index.js +1 -0
- package/vendor/map-engine/query/planner.d.ts +26 -0
- package/vendor/map-engine/query/planner.js +47 -0
- package/vendor/map-engine/react/MapView.d.ts +87 -0
- package/vendor/map-engine/react/MapView.js +270 -0
- package/vendor/map-engine/react/context.d.ts +39 -0
- package/vendor/map-engine/react/context.js +94 -0
- package/vendor/map-engine/react/index.d.ts +2 -0
- package/vendor/map-engine/react/index.js +2 -0
- package/vendor/map-engine/react/store.d.ts +19 -0
- package/vendor/map-engine/react/store.js +18 -0
- package/vendor/map-engine/realtime/apply.d.ts +74 -0
- package/vendor/map-engine/realtime/apply.js +155 -0
- package/vendor/map-engine/realtime/index.d.ts +1 -0
- package/vendor/map-engine/realtime/index.js +1 -0
- package/vendor/map-engine/render/index.d.ts +1 -0
- package/vendor/map-engine/render/index.js +1 -0
- package/vendor/map-engine/render/offline.d.ts +8 -0
- package/vendor/map-engine/render/offline.js +47 -0
- package/vendor/map-engine/render/renderer.d.ts +74 -0
- package/vendor/map-engine/render/renderer.js +1 -0
- package/vendor/map-engine/render/terrarium.d.ts +48 -0
- package/vendor/map-engine/render/terrarium.js +110 -0
- package/vendor/map-engine/runtime/drawing.d.ts +28 -0
- package/vendor/map-engine/runtime/drawing.js +58 -0
- package/vendor/map-engine/runtime/index.d.ts +5 -0
- package/vendor/map-engine/runtime/index.js +2 -0
- package/vendor/map-engine/runtime/offline.d.ts +24 -0
- package/vendor/map-engine/runtime/offline.js +37 -0
- package/vendor/map-engine/runtime/overlays.d.ts +25 -0
- package/vendor/map-engine/runtime/overlays.js +65 -0
- package/vendor/map-engine/runtime/runtime.d.ts +4 -0
- package/vendor/map-engine/runtime/runtime.js +2105 -0
- package/vendor/map-engine/runtime/types.d.ts +407 -0
- package/vendor/map-engine/runtime/types.js +1 -0
- package/vendor/map-engine/scene/index.d.ts +1 -0
- package/vendor/map-engine/scene/index.js +1 -0
- package/vendor/map-engine/scene/scene.d.ts +31 -0
- package/vendor/map-engine/scene/scene.js +216 -0
- package/vendor/map-engine/selection/index.d.ts +1 -0
- package/vendor/map-engine/selection/index.js +1 -0
- package/vendor/map-engine/selection/selection.d.ts +12 -0
- package/vendor/map-engine/selection/selection.js +28 -0
- package/vendor/map-engine/spatial/geojson.d.ts +15 -0
- package/vendor/map-engine/spatial/geojson.js +83 -0
- package/vendor/map-engine/spatial/geometry.d.ts +13 -0
- package/vendor/map-engine/spatial/geometry.js +206 -0
- package/vendor/map-engine/spatial/import.d.ts +27 -0
- package/vendor/map-engine/spatial/import.js +96 -0
- package/vendor/map-engine/spatial/index.d.ts +38 -0
- package/vendor/map-engine/spatial/index.js +37 -0
- package/vendor/map-engine/spatial/line-of-sight.d.ts +35 -0
- package/vendor/map-engine/spatial/line-of-sight.js +43 -0
- package/vendor/map-engine/spatial/measure.d.ts +28 -0
- package/vendor/map-engine/spatial/measure.js +90 -0
- package/vendor/map-engine/spatial/operations.d.ts +21 -0
- package/vendor/map-engine/spatial/operations.js +171 -0
- package/vendor/map-engine/spatial/query.d.ts +17 -0
- package/vendor/map-engine/spatial/query.js +88 -0
- package/vendor/map-engine/spatial/spatial-index.d.ts +24 -0
- package/vendor/map-engine/spatial/spatial-index.js +36 -0
- package/vendor/map-engine/spatial/volume-mesh.d.ts +19 -0
- package/vendor/map-engine/spatial/volume-mesh.js +56 -0
- package/vendor/map-engine/temporal/index.d.ts +3 -0
- package/vendor/map-engine/temporal/index.js +3 -0
- package/vendor/map-engine/temporal/journal.d.ts +20 -0
- package/vendor/map-engine/temporal/journal.js +34 -0
- package/vendor/map-engine/temporal/playback.d.ts +41 -0
- package/vendor/map-engine/temporal/playback.js +80 -0
- package/vendor/map-engine/temporal/tracks.d.ts +33 -0
- package/vendor/map-engine/temporal/tracks.js +119 -0
- package/vendor/map-engine/testing/annotation-store.d.ts +24 -0
- package/vendor/map-engine/testing/annotation-store.js +43 -0
- package/vendor/map-engine/testing/contracts.d.ts +41 -0
- package/vendor/map-engine/testing/contracts.js +328 -0
- package/vendor/map-engine/testing/fixtures-monitoring.d.ts +22 -0
- package/vendor/map-engine/testing/fixtures-monitoring.js +103 -0
- package/vendor/map-engine/testing/fixtures.d.ts +18 -0
- package/vendor/map-engine/testing/fixtures.js +28 -0
- package/vendor/map-engine/testing/index.d.ts +7 -0
- package/vendor/map-engine/testing/index.js +7 -0
- package/vendor/map-engine/testing/mock-ontology.d.ts +13 -0
- package/vendor/map-engine/testing/mock-ontology.js +24 -0
- package/vendor/map-engine/testing/mock-provider.d.ts +97 -0
- package/vendor/map-engine/testing/mock-provider.js +369 -0
- package/vendor/map-engine/testing/test-renderer.d.ts +72 -0
- package/vendor/map-engine/testing/test-renderer.js +153 -0
- package/vendor/map-engine/viewport/index.d.ts +1 -0
- package/vendor/map-engine/viewport/index.js +1 -0
- package/vendor/map-engine/viewport/viewport.d.ts +19 -0
- package/vendor/map-engine/viewport/viewport.js +102 -0
- package/vendor/map-provider-uns/client.d.ts +75 -0
- package/vendor/map-provider-uns/client.js +111 -0
- package/vendor/map-provider-uns/index.d.ts +2 -0
- package/vendor/map-provider-uns/index.js +2 -0
- package/vendor/map-provider-uns/provider.d.ts +131 -0
- package/vendor/map-provider-uns/provider.js +561 -0
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
import { tokenFor, volumeOf } from '../layers/layers.js';
|
|
2
|
+
import { spatial } from '../spatial/index.js';
|
|
3
|
+
import { cameraPitch, enginePitch, rangeForZoom, zoomForRange } from './camera.js';
|
|
4
|
+
import { loadHeights, tileUrl } from '../render/terrarium.js';
|
|
5
|
+
export const DEFAULT_CESIUM_THEME = {
|
|
6
|
+
markerColor: '#4CC3FF',
|
|
7
|
+
lineColor: '#C9A66B',
|
|
8
|
+
fillColor: '#4CC3FF',
|
|
9
|
+
trackColor: '#4CC3FF',
|
|
10
|
+
selectionColor: '#FFD43B',
|
|
11
|
+
hoverColor: '#FFFFFF',
|
|
12
|
+
labelColor: '#1f2328',
|
|
13
|
+
labelHalo: '#FFFFFF',
|
|
14
|
+
background: '#0E1512',
|
|
15
|
+
};
|
|
16
|
+
const STEM = '#stem';
|
|
17
|
+
const OUTLINE = '#outline';
|
|
18
|
+
const LABEL = '#label';
|
|
19
|
+
const keyOf = (id) => id.split('#')[0];
|
|
20
|
+
const splitKey = (key) => {
|
|
21
|
+
const i = key.indexOf(':');
|
|
22
|
+
return [key.slice(0, i), key.slice(i + 1)];
|
|
23
|
+
};
|
|
24
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v)) ? Number(v) : undefined);
|
|
25
|
+
/**
|
|
26
|
+
* The layout Cesium needs to fill its container (canvas size, credit placement). Cesium's widgets.css carries the
|
|
27
|
+
* same rules; without it the canvas stays at the browser default 300x150. Zero specificity (`:where`), so an app
|
|
28
|
+
* that loads widgets.css or styles these itself still wins.
|
|
29
|
+
*/
|
|
30
|
+
const BASE_STYLE_ID = 'map-engine-cesium-base';
|
|
31
|
+
const BASE_STYLE = [
|
|
32
|
+
':where(.cesium-viewer),:where(.cesium-viewer-cesiumWidgetContainer),:where(.cesium-widget){position:relative;display:block;width:100%;height:100%;overflow:hidden}',
|
|
33
|
+
':where(.cesium-widget canvas){display:block;width:100%;height:100%;touch-action:none}',
|
|
34
|
+
':where(.cesium-viewer-bottom){position:absolute;bottom:0;left:0;padding-right:5px}',
|
|
35
|
+
':where(.cesium-widget-credits){position:absolute;bottom:0;left:0;font:10px sans-serif;color:#fff;text-shadow:0 0 2px #000}',
|
|
36
|
+
':where(.cesium-viewer .cesium-widget-credits){position:static;display:inline}',
|
|
37
|
+
':where(.cesium-widget-credits a){color:#fff}',
|
|
38
|
+
].join('');
|
|
39
|
+
function ensureBaseStyle() {
|
|
40
|
+
if (typeof document === 'undefined' || document.getElementById(BASE_STYLE_ID))
|
|
41
|
+
return;
|
|
42
|
+
const el = document.createElement('style');
|
|
43
|
+
el.id = BASE_STYLE_ID;
|
|
44
|
+
el.textContent = BASE_STYLE;
|
|
45
|
+
document.head.appendChild(el);
|
|
46
|
+
}
|
|
47
|
+
export class CesiumRenderer {
|
|
48
|
+
options;
|
|
49
|
+
lib = null;
|
|
50
|
+
viewer = null;
|
|
51
|
+
handler = null;
|
|
52
|
+
offs = [];
|
|
53
|
+
gen = 0;
|
|
54
|
+
theme;
|
|
55
|
+
layers = new Map();
|
|
56
|
+
objects = new Map();
|
|
57
|
+
/** Every entity id drawn for an object (shape, stem, outline, parts, label), so removal is complete. */
|
|
58
|
+
drawn = new Map();
|
|
59
|
+
/** How each point object was drawn, apart from where: when only its position changes it is moved, not rebuilt. */
|
|
60
|
+
looks = new Map();
|
|
61
|
+
hoverKey = null;
|
|
62
|
+
fontName;
|
|
63
|
+
terrain = null;
|
|
64
|
+
/** Loaded 3D Tiles by id, with the config they were loaded from. */
|
|
65
|
+
tilesets = new Map();
|
|
66
|
+
warnedEllipsoid = false;
|
|
67
|
+
warnedStyleBasemap = false;
|
|
68
|
+
/** Longest side of each model file once measured (sizeMeters). */
|
|
69
|
+
extents = new Map();
|
|
70
|
+
measuring = new Set();
|
|
71
|
+
/** Ground under volume origins, as drawn (exaggerated metres), by position; filled by one batched terrain sample. */
|
|
72
|
+
grounds = new Map();
|
|
73
|
+
groundQueue = new Map();
|
|
74
|
+
listeners = {
|
|
75
|
+
viewport: new Set(),
|
|
76
|
+
pick: new Set(),
|
|
77
|
+
click: new Set(),
|
|
78
|
+
hover: new Set(),
|
|
79
|
+
error: new Set(),
|
|
80
|
+
move: new Set(),
|
|
81
|
+
};
|
|
82
|
+
/** A true globe: heights, models, extrusions, sensor volumes and 3D Tiles. */
|
|
83
|
+
capabilities = { globe: true, terrain: true, elevation: true, altitude: true, extrusion: true, models: true, volumes: true, tiles3d: true };
|
|
84
|
+
constructor(options = {}) {
|
|
85
|
+
this.options = options;
|
|
86
|
+
this.theme = { ...DEFAULT_CESIUM_THEME, ...options.theme };
|
|
87
|
+
}
|
|
88
|
+
async initialize(container, options) {
|
|
89
|
+
const gen = this.gen;
|
|
90
|
+
if (this.options.baseUrl && typeof window !== 'undefined')
|
|
91
|
+
window.CESIUM_BASE_URL = this.options.baseUrl;
|
|
92
|
+
const lib = this.options.cesium ?? (await import('cesium'));
|
|
93
|
+
if (gen !== this.gen)
|
|
94
|
+
return;
|
|
95
|
+
this.lib = lib;
|
|
96
|
+
lib.Ion.defaultAccessToken = ''; // never Cesium Ion (ADR-027: self-hostable)
|
|
97
|
+
this.fontName = options.basemap.font?.name;
|
|
98
|
+
this.terrain = options.terrain ?? null;
|
|
99
|
+
ensureBaseStyle();
|
|
100
|
+
const viewer = new lib.Viewer(container, {
|
|
101
|
+
baseLayer: this.imageryLayer(options.basemap) ?? false,
|
|
102
|
+
terrainProvider: this.terrainProvider(this.terrain),
|
|
103
|
+
animation: false,
|
|
104
|
+
timeline: false,
|
|
105
|
+
baseLayerPicker: false,
|
|
106
|
+
geocoder: false,
|
|
107
|
+
homeButton: false,
|
|
108
|
+
sceneModePicker: false,
|
|
109
|
+
navigationHelpButton: false,
|
|
110
|
+
fullscreenButton: false,
|
|
111
|
+
infoBox: false,
|
|
112
|
+
selectionIndicator: false,
|
|
113
|
+
scene3DOnly: true,
|
|
114
|
+
});
|
|
115
|
+
this.viewer = viewer;
|
|
116
|
+
viewer.scene.globe.baseColor = lib.Color.fromCssColorString(options.basemap.background ?? this.theme.background);
|
|
117
|
+
viewer.scene.globe.depthTestAgainstTerrain = true;
|
|
118
|
+
// Exaggeration is applied by this renderer (terrain heights and object heights alike), not by Cesium, whose
|
|
119
|
+
// vertical exaggeration treats billboards, polylines and terrain differently.
|
|
120
|
+
viewer.scene.verticalExaggeration = 1;
|
|
121
|
+
this.setViewport(options.viewport);
|
|
122
|
+
this.applyContours();
|
|
123
|
+
this.bindEvents(lib, viewer);
|
|
124
|
+
if (options.tilesets?.length)
|
|
125
|
+
this.setTilesets(options.tilesets);
|
|
126
|
+
}
|
|
127
|
+
setViewport(viewport, options) {
|
|
128
|
+
const lib = this.lib;
|
|
129
|
+
const v = this.viewer;
|
|
130
|
+
if (!lib || !v)
|
|
131
|
+
return;
|
|
132
|
+
const { latitude, longitude } = viewport.center;
|
|
133
|
+
const ground = v.scene.globe.getHeight(lib.Cartographic.fromDegrees(longitude, latitude)) ?? 0;
|
|
134
|
+
const lookPoint = lib.Cartesian3.fromDegrees(longitude, latitude, ground);
|
|
135
|
+
const fovy = v.camera.frustum.fovy ?? Math.PI / 3;
|
|
136
|
+
const range = rangeForZoom(viewport.zoom, latitude, this.getSize().height || 600, fovy);
|
|
137
|
+
const offset = new lib.HeadingPitchRange(lib.Math.toRadians(viewport.bearing ?? 0), lib.Math.toRadians(cameraPitch(viewport.pitch)), range);
|
|
138
|
+
if (options?.animate) {
|
|
139
|
+
v.camera.flyToBoundingSphere(new lib.BoundingSphere(lookPoint, 0), { offset, duration: 0.8 });
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
v.camera.lookAt(lookPoint, offset);
|
|
143
|
+
v.camera.lookAtTransform(lib.Matrix4.IDENTITY); // free the camera again
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
render(scene) {
|
|
147
|
+
if (!this.viewer)
|
|
148
|
+
return;
|
|
149
|
+
this.viewer.entities.removeAll();
|
|
150
|
+
this.objects.clear();
|
|
151
|
+
this.layers.clear();
|
|
152
|
+
for (const l of scene.layers)
|
|
153
|
+
this.layers.set(l.id, structuredClone(l));
|
|
154
|
+
for (const so of scene.objects)
|
|
155
|
+
this.put(so);
|
|
156
|
+
}
|
|
157
|
+
update(patch) {
|
|
158
|
+
if (!this.viewer)
|
|
159
|
+
return;
|
|
160
|
+
// One batch: Cesium recomputes its entity displays once, not per change.
|
|
161
|
+
const entities = this.viewer.entities;
|
|
162
|
+
const rebuild = new Map();
|
|
163
|
+
const gone = new Set(patch.removed ?? []);
|
|
164
|
+
entities.suspendEvents();
|
|
165
|
+
try {
|
|
166
|
+
for (const x of patch.layers ? this.applyLayers(patch.layers) : []) {
|
|
167
|
+
if (typeof x === 'string')
|
|
168
|
+
gone.add(x);
|
|
169
|
+
else
|
|
170
|
+
rebuild.set(x.key, x);
|
|
171
|
+
}
|
|
172
|
+
for (const so of [...(patch.added ?? []), ...(patch.updated ?? [])])
|
|
173
|
+
if (!this.move(so))
|
|
174
|
+
rebuild.set(so.key, so);
|
|
175
|
+
}
|
|
176
|
+
finally {
|
|
177
|
+
entities.resumeEvents();
|
|
178
|
+
}
|
|
179
|
+
this.redraw([...rebuild.values()], [...gone]);
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Draws objects again, in batches. Cesium merges a removal and an add of the same entity id within one batch
|
|
183
|
+
* into "nothing changed", and its visualizers keep drawing the old entity — so all removals go in one batch,
|
|
184
|
+
* which is flushed before the adds.
|
|
185
|
+
*/
|
|
186
|
+
redraw(list, gone = []) {
|
|
187
|
+
if (!list.length && !gone.length)
|
|
188
|
+
return;
|
|
189
|
+
const entities = this.viewer.entities;
|
|
190
|
+
entities.suspendEvents();
|
|
191
|
+
try {
|
|
192
|
+
for (const key of gone)
|
|
193
|
+
this.drop(key);
|
|
194
|
+
for (const so of list)
|
|
195
|
+
this.drop(so.key);
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
entities.resumeEvents();
|
|
199
|
+
}
|
|
200
|
+
entities.suspendEvents();
|
|
201
|
+
try {
|
|
202
|
+
for (const so of list)
|
|
203
|
+
this.draw(so);
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
entities.resumeEvents();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** What a point object looks like apart from its position (layer, selection, properties). */
|
|
210
|
+
lookOf(so) {
|
|
211
|
+
if (so.object.geometry.type !== 'Point')
|
|
212
|
+
return null;
|
|
213
|
+
const layer = this.layers.get(so.layerId);
|
|
214
|
+
return layer ? JSON.stringify([layer, so.selected, so.hovered, so.object.properties ?? null]) : null;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Moves an already drawn point object when only its position changed (the common live update): the entity and
|
|
218
|
+
* its stem get the new position; nothing is rebuilt. False when it must be drawn again.
|
|
219
|
+
*/
|
|
220
|
+
move(so) {
|
|
221
|
+
const lib = this.lib;
|
|
222
|
+
const before = this.looks.get(so.key);
|
|
223
|
+
const look = this.lookOf(so);
|
|
224
|
+
const e = this.viewer.entities.getById(so.key);
|
|
225
|
+
if (!look || look !== before || !e || so.object.geometry.type !== 'Point')
|
|
226
|
+
return false;
|
|
227
|
+
const layer = this.layers.get(so.layerId);
|
|
228
|
+
if (layer.visualization.type === 'volume')
|
|
229
|
+
return false; // placed on the sampled ground: drawn again
|
|
230
|
+
const el = layer.visualization.elevation;
|
|
231
|
+
const { longitude, latitude, altitude } = so.object.geometry.coordinates;
|
|
232
|
+
const h = el ? (el.from === 'property' ? num(so.object.properties?.[el.property]) : altitude) : undefined;
|
|
233
|
+
const position = lib.Cartesian3.fromDegrees(longitude, latitude, h === undefined ? 0 : this.drawnHeight(longitude, latitude, h, el?.reference === 'agl', el?.exaggeration ?? 1));
|
|
234
|
+
e.position = new lib.ConstantPositionProperty(position);
|
|
235
|
+
const stem = this.viewer.entities.getById(so.key + STEM);
|
|
236
|
+
if (stem?.polyline)
|
|
237
|
+
stem.polyline.positions = new lib.ConstantProperty([position, lib.Cartesian3.fromDegrees(longitude, latitude, 0)]);
|
|
238
|
+
this.objects.set(so.key, so);
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
resize() {
|
|
242
|
+
this.viewer?.resize();
|
|
243
|
+
if (this.viewer)
|
|
244
|
+
for (const l of this.listeners.viewport)
|
|
245
|
+
l(this.currentViewport());
|
|
246
|
+
}
|
|
247
|
+
getSize() {
|
|
248
|
+
const c = this.viewer?.scene.canvas;
|
|
249
|
+
return { width: c?.clientWidth ?? 0, height: c?.clientHeight ?? 0 };
|
|
250
|
+
}
|
|
251
|
+
project(position) {
|
|
252
|
+
const lib = this.lib;
|
|
253
|
+
const v = this.viewer;
|
|
254
|
+
if (!lib || !v)
|
|
255
|
+
return null;
|
|
256
|
+
const h = position.altitude ?? v.scene.globe.getHeight(lib.Cartographic.fromDegrees(position.longitude, position.latitude)) ?? 0;
|
|
257
|
+
const p = lib.SceneTransforms.worldToWindowCoordinates(v.scene, lib.Cartesian3.fromDegrees(position.longitude, position.latitude, h));
|
|
258
|
+
return p ? { x: p.x, y: p.y } : null;
|
|
259
|
+
}
|
|
260
|
+
onCameraMove(l) {
|
|
261
|
+
const fn = () => l();
|
|
262
|
+
this.listeners.move.add(fn);
|
|
263
|
+
return () => this.listeners.move.delete(fn);
|
|
264
|
+
}
|
|
265
|
+
async setBasemap(basemap) {
|
|
266
|
+
const v = this.viewer;
|
|
267
|
+
if (!v || !this.lib)
|
|
268
|
+
return;
|
|
269
|
+
this.fontName = basemap.font?.name;
|
|
270
|
+
v.imageryLayers.removeAll();
|
|
271
|
+
const layer = this.imageryLayer(basemap);
|
|
272
|
+
if (layer)
|
|
273
|
+
v.imageryLayers.add(layer);
|
|
274
|
+
v.scene.globe.baseColor = this.lib.Color.fromCssColorString(basemap.background ?? this.theme.background);
|
|
275
|
+
}
|
|
276
|
+
/** Cesium always draws a globe; a flat (mercator) request keeps the globe, which reads the same up close. */
|
|
277
|
+
setProjection(_projection) { }
|
|
278
|
+
setTerrain(terrain) {
|
|
279
|
+
const v = this.viewer;
|
|
280
|
+
if (!v) {
|
|
281
|
+
this.terrain = terrain;
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
this.terrain = terrain;
|
|
285
|
+
this.grounds.clear();
|
|
286
|
+
this.groundQueue.clear();
|
|
287
|
+
v.terrainProvider = this.terrainProvider(terrain);
|
|
288
|
+
this.applyContours();
|
|
289
|
+
// Heights scale with it, and objects on the ground switch between clamped (terrain) and height 0 (none).
|
|
290
|
+
this.redraw([...this.objects.values()]);
|
|
291
|
+
}
|
|
292
|
+
/** 3D Tiles scenery from URLs (no Cesium Ion): added, replaced, shown/hidden and removed by id. */
|
|
293
|
+
setTilesets(tilesets) {
|
|
294
|
+
const lib = this.lib;
|
|
295
|
+
const v = this.viewer;
|
|
296
|
+
if (!lib || !v)
|
|
297
|
+
return;
|
|
298
|
+
const next = new Map(tilesets.map((t) => [t.id, t]));
|
|
299
|
+
for (const [id, held] of [...this.tilesets]) {
|
|
300
|
+
const want = next.get(id);
|
|
301
|
+
if (want && want.url === held.config.url && want.maximumScreenSpaceError === held.config.maximumScreenSpaceError) {
|
|
302
|
+
if (held.tileset)
|
|
303
|
+
held.tileset.show = want.show !== false;
|
|
304
|
+
held.config = structuredClone(want);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (held.tileset)
|
|
308
|
+
v.scene.primitives.remove(held.tileset);
|
|
309
|
+
this.tilesets.delete(id);
|
|
310
|
+
}
|
|
311
|
+
const gen = this.gen;
|
|
312
|
+
for (const t of tilesets) {
|
|
313
|
+
if (this.tilesets.has(t.id))
|
|
314
|
+
continue;
|
|
315
|
+
const entry = { config: structuredClone(t), tileset: null };
|
|
316
|
+
this.tilesets.set(t.id, entry);
|
|
317
|
+
lib.Cesium3DTileset.fromUrl(t.url, { maximumScreenSpaceError: t.maximumScreenSpaceError ?? 16 }).then((tileset) => {
|
|
318
|
+
// Dropped or replaced while loading, or the renderer was released: do not add it.
|
|
319
|
+
if (gen !== this.gen || this.tilesets.get(t.id) !== entry || !this.viewer)
|
|
320
|
+
return tileset.destroy();
|
|
321
|
+
tileset.show = entry.config.show !== false;
|
|
322
|
+
entry.tileset = this.viewer.scene.primitives.add(tileset);
|
|
323
|
+
}, (e) => this.emitError(new Error(`3D Tiles ${t.id} failed to load: ${e instanceof Error ? e.message : String(e)}`)));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
async sampleElevation(positions) {
|
|
327
|
+
const lib = this.lib;
|
|
328
|
+
const v = this.viewer;
|
|
329
|
+
if (!lib || !v || !this.terrain)
|
|
330
|
+
return positions.map(() => null);
|
|
331
|
+
const cartos = positions.map((p) => lib.Cartographic.fromDegrees(p.longitude, p.latitude));
|
|
332
|
+
// A heightmap provider declares no tile availability, so sample at a fixed detail level (≈ 10 m at level 14).
|
|
333
|
+
const out = await lib.sampleTerrain(v.terrainProvider, Math.min(14, this.terrain.maxZoom ?? 14), cartos);
|
|
334
|
+
const k = this.terrain.exaggeration ?? 1; // the terrain is drawn exaggerated; report true metres
|
|
335
|
+
return out.map((c) => (Number.isFinite(c.height) ? c.height / k : null));
|
|
336
|
+
}
|
|
337
|
+
destroy() {
|
|
338
|
+
this.gen++;
|
|
339
|
+
for (const off of this.offs.splice(0))
|
|
340
|
+
off();
|
|
341
|
+
try {
|
|
342
|
+
this.handler?.destroy();
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
// Releasing must always complete.
|
|
346
|
+
}
|
|
347
|
+
try {
|
|
348
|
+
this.viewer?.destroy();
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
// Releasing must always complete.
|
|
352
|
+
}
|
|
353
|
+
this.handler = null;
|
|
354
|
+
this.viewer = null;
|
|
355
|
+
this.tilesets.clear(); // destroyed with the viewer's primitives
|
|
356
|
+
this.layers.clear();
|
|
357
|
+
this.objects.clear();
|
|
358
|
+
for (const set of Object.values(this.listeners))
|
|
359
|
+
set.clear();
|
|
360
|
+
}
|
|
361
|
+
onViewportChange(l) {
|
|
362
|
+
return this.add(this.listeners.viewport, l);
|
|
363
|
+
}
|
|
364
|
+
onPick(l) {
|
|
365
|
+
return this.add(this.listeners.pick, l);
|
|
366
|
+
}
|
|
367
|
+
onMapClick(l) {
|
|
368
|
+
return this.add(this.listeners.click, l);
|
|
369
|
+
}
|
|
370
|
+
onHover(l) {
|
|
371
|
+
return this.add(this.listeners.hover, l);
|
|
372
|
+
}
|
|
373
|
+
onError(l) {
|
|
374
|
+
return this.add(this.listeners.error, l);
|
|
375
|
+
}
|
|
376
|
+
// ---------------------------------------------------------------- environment
|
|
377
|
+
/** The basemap as Cesium imagery: a tile URL works as is; a vector style URL needs a raster fallback. */
|
|
378
|
+
imageryLayer(basemap) {
|
|
379
|
+
const lib = this.lib;
|
|
380
|
+
const url = basemap.kind === 'tiles' ? basemap.tileUrl : basemap.kind === 'style' ? basemap.rasterFallback : undefined;
|
|
381
|
+
if (basemap.kind === 'style' && !basemap.rasterFallback && !this.warnedStyleBasemap) {
|
|
382
|
+
this.warnedStyleBasemap = true;
|
|
383
|
+
this.emitError(new Error('this basemap is a vector style, which the 3D globe cannot draw: give it a `rasterFallback` tile URL'));
|
|
384
|
+
}
|
|
385
|
+
if (!url)
|
|
386
|
+
return undefined;
|
|
387
|
+
const credit = basemap.kind === 'style' ? (basemap.rasterFallbackAttribution ?? basemap.attribution) : basemap.attribution;
|
|
388
|
+
const provider = new lib.UrlTemplateImageryProvider({ url, ...(basemap.maxZoom !== undefined ? { maximumLevel: basemap.maxZoom } : {}), ...(credit ? { credit } : {}) });
|
|
389
|
+
return new lib.ImageryLayer(provider);
|
|
390
|
+
}
|
|
391
|
+
/** Terrain from the configured DEM tiles (same as MapLibre), or a smooth ellipsoid. */
|
|
392
|
+
terrainProvider(terrain) {
|
|
393
|
+
const lib = this.lib;
|
|
394
|
+
if (!terrain)
|
|
395
|
+
return new lib.EllipsoidTerrainProvider();
|
|
396
|
+
const samples = this.options.terrainSamples ?? 65;
|
|
397
|
+
const maxZoom = terrain.maxZoom ?? 15;
|
|
398
|
+
return new lib.CustomHeightmapTerrainProvider({
|
|
399
|
+
width: samples,
|
|
400
|
+
height: samples,
|
|
401
|
+
tilingScheme: new lib.WebMercatorTilingScheme(),
|
|
402
|
+
...(terrain.attribution ? { credit: terrain.attribution } : {}),
|
|
403
|
+
callback: (x, y, level) => {
|
|
404
|
+
if (level > maxZoom)
|
|
405
|
+
return undefined;
|
|
406
|
+
const k = terrain.exaggeration ?? 1;
|
|
407
|
+
return loadHeights(tileUrl(terrain.tileUrl, x, y, level), samples, terrain.encoding).then((h) => {
|
|
408
|
+
const heights = h ?? new Float32Array(samples * samples);
|
|
409
|
+
if (k !== 1)
|
|
410
|
+
for (let i = 0; i < heights.length; i++)
|
|
411
|
+
heights[i] = heights[i] * k;
|
|
412
|
+
return heights;
|
|
413
|
+
});
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
// ---------------------------------------------------------------- objects
|
|
418
|
+
/** Takes the new layer list; returns the objects to draw again and the keys to drop (their layer went away). */
|
|
419
|
+
applyLayers(next) {
|
|
420
|
+
const out = [];
|
|
421
|
+
const changed = new Set();
|
|
422
|
+
const ids = new Set(next.map((l) => l.id));
|
|
423
|
+
for (const id of [...this.layers.keys()])
|
|
424
|
+
if (!ids.has(id))
|
|
425
|
+
changed.add(id);
|
|
426
|
+
for (const l of next)
|
|
427
|
+
if (JSON.stringify(this.layers.get(l.id)) !== JSON.stringify(l))
|
|
428
|
+
changed.add(l.id);
|
|
429
|
+
this.layers.clear();
|
|
430
|
+
for (const l of next)
|
|
431
|
+
this.layers.set(l.id, structuredClone(l));
|
|
432
|
+
// Redraw the objects of layers that changed (style, visibility, order) or went away.
|
|
433
|
+
for (const so of this.objects.values())
|
|
434
|
+
if (changed.has(so.layerId))
|
|
435
|
+
out.push(ids.has(so.layerId) ? so : so.key);
|
|
436
|
+
return out;
|
|
437
|
+
}
|
|
438
|
+
drop(key) {
|
|
439
|
+
const e = this.viewer.entities;
|
|
440
|
+
for (const id of this.drawn.get(key) ?? [])
|
|
441
|
+
e.removeById(id);
|
|
442
|
+
this.drawn.delete(key);
|
|
443
|
+
this.looks.delete(key);
|
|
444
|
+
this.objects.delete(key);
|
|
445
|
+
}
|
|
446
|
+
/** Adds or replaces the entities of one scene object. */
|
|
447
|
+
put(so) {
|
|
448
|
+
this.drop(so.key);
|
|
449
|
+
this.draw(so);
|
|
450
|
+
}
|
|
451
|
+
/** Draws one scene object (nothing of it may be drawn yet). */
|
|
452
|
+
draw(so) {
|
|
453
|
+
const lib = this.lib;
|
|
454
|
+
this.objects.set(so.key, so);
|
|
455
|
+
const ids = [];
|
|
456
|
+
this.drawn.set(so.key, ids);
|
|
457
|
+
const look = this.lookOf(so);
|
|
458
|
+
if (look)
|
|
459
|
+
this.looks.set(so.key, look);
|
|
460
|
+
const viewerEntities = this.viewer.entities;
|
|
461
|
+
const entities = { add: (o) => (ids.push(String(o.id)), viewerEntities.add(o)) };
|
|
462
|
+
const layer = this.layers.get(so.layerId);
|
|
463
|
+
if (!layer || !layer.visible)
|
|
464
|
+
return;
|
|
465
|
+
const vz = layer.visualization;
|
|
466
|
+
const props = so.object.properties;
|
|
467
|
+
const rules = vz.style;
|
|
468
|
+
const color = (fallback) => (so.selected ? lib.Color.fromCssColorString(this.theme.selectionColor) : lib.Color.fromCssColorString(tokenFor(rules, 'color', props, fallback)).withAlpha(tokenFor(rules, 'opacity', props, 1)));
|
|
469
|
+
const g = so.object.geometry;
|
|
470
|
+
const e = vz.elevation;
|
|
471
|
+
const label = vz.label && this.fontName ? vz.label : undefined;
|
|
472
|
+
const labelGraphics = (heightReference, offsetY) => label
|
|
473
|
+
? {
|
|
474
|
+
text: String(props?.[label.property] ?? ''),
|
|
475
|
+
font: `${label.size ?? 12}px "${this.fontName}", sans-serif`,
|
|
476
|
+
fillColor: lib.Color.fromCssColorString(label.color ?? this.theme.labelColor),
|
|
477
|
+
outlineColor: lib.Color.fromCssColorString(label.haloColor ?? this.theme.labelHalo),
|
|
478
|
+
outlineWidth: 3,
|
|
479
|
+
style: lib.LabelStyle.FILL_AND_OUTLINE,
|
|
480
|
+
pixelOffset: new lib.Cartesian2(0, offsetY),
|
|
481
|
+
heightReference,
|
|
482
|
+
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
|
483
|
+
}
|
|
484
|
+
: undefined;
|
|
485
|
+
if (g.type === 'Point' && (vz.type === 'marker' || vz.type === 'heatmap' || vz.type === 'model' || vz.type === 'volume')) {
|
|
486
|
+
const { longitude, latitude, altitude } = g.coordinates;
|
|
487
|
+
const h = e ? (e.from === 'property' ? num(props?.[e.property]) : altitude) : undefined;
|
|
488
|
+
if (e?.reference === 'ellipsoid' && !this.warnedEllipsoid) {
|
|
489
|
+
this.warnedEllipsoid = true;
|
|
490
|
+
this.emitError(new Error(`layer ${layer.id}: ellipsoid heights are drawn as sea-level heights (no geoid model in this renderer; up to ~100 m off)`));
|
|
491
|
+
}
|
|
492
|
+
const relative = e?.reference === 'agl';
|
|
493
|
+
const heightReference = h === undefined ? this.groundReference() : relative ? lib.HeightReference.RELATIVE_TO_GROUND : lib.HeightReference.NONE;
|
|
494
|
+
const position = lib.Cartesian3.fromDegrees(longitude, latitude, h === undefined ? 0 : this.drawnHeight(longitude, latitude, h, relative, e?.exaggeration ?? 1));
|
|
495
|
+
const size = vz.icon?.size ?? 18;
|
|
496
|
+
const image = tokenFor(rules, 'imageUrl', props, vz.icon?.imageUrl ?? '');
|
|
497
|
+
const entity = { id: so.key, position };
|
|
498
|
+
if (vz.type === 'volume') {
|
|
499
|
+
// A partial ellipsoid in the local east-north-up frame: clock angles run counter-clockwise from east, so an
|
|
500
|
+
// azimuth (clockwise from north) becomes 90° − azimuth; cone angles run down from the zenith.
|
|
501
|
+
const vol = volumeOf(vz.volume, props);
|
|
502
|
+
if (vol) {
|
|
503
|
+
const k = this.terrain ? (this.terrain.exaggeration ?? 1) : 1;
|
|
504
|
+
// Cesium lifts a clamped or ground-relative ellipsoid by half its height (it rests on the ground like a
|
|
505
|
+
// ball), so a volume is placed at an absolute height with its origin at the sensor.
|
|
506
|
+
const base = h === undefined ? this.groundHeight(longitude, latitude) : relative ? this.groundHeight(longitude, latitude) + h * k * (e?.exaggeration ?? 1) : this.drawnHeight(longitude, latitude, h, false, e?.exaggeration ?? 1);
|
|
507
|
+
entity.position = lib.Cartesian3.fromDegrees(longitude, latitude, base);
|
|
508
|
+
const r = lib.Math.toRadians;
|
|
509
|
+
const fill = tokenFor(rules, 'color', props, this.theme.fillColor);
|
|
510
|
+
entity.ellipsoid = {
|
|
511
|
+
radii: new lib.Cartesian3(vol.range, vol.range, vol.range * k),
|
|
512
|
+
// Without inner radii Cesium draws only the outer shell (the far end); a tiny one adds the side walls
|
|
513
|
+
// back to the sensor, so the volume reads as a wedge or dome.
|
|
514
|
+
innerRadii: new lib.Cartesian3(1, 1, k),
|
|
515
|
+
minimumClock: vol.shape === 'wedge' ? r(90 - vol.azimuth - vol.beamWidth / 2) : 0,
|
|
516
|
+
maximumClock: vol.shape === 'wedge' ? r(90 - vol.azimuth + vol.beamWidth / 2) : 2 * Math.PI,
|
|
517
|
+
minimumCone: r(90 - vol.maxElevation),
|
|
518
|
+
maximumCone: r(90 - vol.minElevation),
|
|
519
|
+
material: so.selected ? lib.Color.fromCssColorString(this.theme.selectionColor).withAlpha(0.35) : lib.Color.fromCssColorString(fill).withAlpha(0.22),
|
|
520
|
+
outline: true,
|
|
521
|
+
outlineColor: lib.Color.fromCssColorString(fill).withAlpha(0.6),
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
else if (vz.type === 'model') {
|
|
526
|
+
const uri = tokenFor(rules, 'modelUrl', props, vz.model?.url ?? '');
|
|
527
|
+
if (uri) {
|
|
528
|
+
const model = vz.model ?? {};
|
|
529
|
+
const axis = model.axis ?? {};
|
|
530
|
+
const angle = (prop) => (prop ? (num(props?.[prop]) ?? 0) : 0);
|
|
531
|
+
// Cesium stands a glTF file (+Y up) upright itself; its heading 0 points the model's +X north, so the
|
|
532
|
+
// glTF front (+Z) needs −90°.
|
|
533
|
+
const hpr = new lib.HeadingPitchRoll(lib.Math.toRadians(-90 + (axis.yaw ?? 0) + angle(model.headingProperty)), lib.Math.toRadians((axis.pitch ?? 0) + angle(model.pitchProperty)), lib.Math.toRadians((axis.roll ?? 0) + angle(model.rollProperty)));
|
|
534
|
+
entity.orientation = lib.Transforms.headingPitchRollQuaternion(position, hpr);
|
|
535
|
+
const extent = this.extents.get(uri);
|
|
536
|
+
if (model.sizeMeters && extent === undefined)
|
|
537
|
+
this.measure(uri);
|
|
538
|
+
entity.model = { uri, minimumPixelSize: 36, heightReference, scale: model.sizeMeters && extent ? model.sizeMeters / extent : 1, ...(so.selected ? { silhouetteColor: lib.Color.fromCssColorString(this.theme.selectionColor), silhouetteSize: 2 } : {}) };
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
else if (image && h === undefined) {
|
|
542
|
+
// Icons for objects on the ground. Known limit: Cesium billboards at a height were drawn at the ground in
|
|
543
|
+
// our views (points are placed correctly), so objects in the air are drawn as points (coloured by the rules).
|
|
544
|
+
entity.billboard = { image, width: size + (so.selected ? 6 : 0), height: size + (so.selected ? 6 : 0), heightReference, disableDepthTestDistance: Number.POSITIVE_INFINITY };
|
|
545
|
+
}
|
|
546
|
+
else {
|
|
547
|
+
entity.point = {
|
|
548
|
+
// A dot standing in for an icon is a modest 14 px; a plain dot layer uses its own size.
|
|
549
|
+
pixelSize: (image ? 14 : tokenFor(rules, 'size', props, size / 2) * 2) + (so.selected ? 4 : 0),
|
|
550
|
+
color: color(this.theme.markerColor),
|
|
551
|
+
outlineColor: so.selected ? lib.Color.fromCssColorString(this.theme.selectionColor) : lib.Color.fromCssColorString(tokenFor(rules, 'strokeColor', props, '#0B100E')),
|
|
552
|
+
outlineWidth: 1.5,
|
|
553
|
+
heightReference,
|
|
554
|
+
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
const lg = labelGraphics(heightReference, size / 2 + 10);
|
|
558
|
+
if (lg)
|
|
559
|
+
entity.label = lg;
|
|
560
|
+
entities.add(entity);
|
|
561
|
+
// A stem to the ground: a line down to sea level, its lower part hidden by the terrain (depth-tested).
|
|
562
|
+
if (e?.stems && h !== undefined && !relative) {
|
|
563
|
+
entities.add({ id: so.key + STEM, polyline: { positions: [position, lib.Cartesian3.fromDegrees(longitude, latitude, 0)], width: 1.5, material: color(this.theme.markerColor).withAlpha(0.6) } });
|
|
564
|
+
}
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
if (g.type === 'LineString' || g.type === 'MultiLineString') {
|
|
568
|
+
const lines = g.type === 'LineString' ? [g.coordinates] : g.coordinates;
|
|
569
|
+
const lift = e?.exaggeration ?? 1;
|
|
570
|
+
const flat = lines.flat();
|
|
571
|
+
const positions = flat.map((p) => lib.Cartesian3.fromDegrees(p.longitude, p.latitude, e ? this.drawnHeight(p.longitude, p.latitude, e.from === 'property' ? (num(props?.[e.property]) ?? 0) : (p.altitude ?? 0), e.reference === 'agl', lift) : 0));
|
|
572
|
+
entities.add({
|
|
573
|
+
id: so.key,
|
|
574
|
+
polyline: { positions, width: tokenFor(rules, 'size', props, 3) + (so.selected ? 2 : 0), material: color(vz.type === 'track' ? this.theme.trackColor : this.theme.lineColor), clampToGround: !e },
|
|
575
|
+
});
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
if (g.type === 'Polygon' || g.type === 'MultiPolygon' || g.type === 'Circle') {
|
|
579
|
+
const rings = g.type === 'Polygon' ? [g.coordinates] : g.type === 'MultiPolygon' ? g.coordinates : [[circleRing(g.center, g.radiusMeters)]];
|
|
580
|
+
rings.forEach((poly, i) => {
|
|
581
|
+
const [outer, ...holes] = poly;
|
|
582
|
+
const toCart = (ring) => ring.map((p) => lib.Cartesian3.fromDegrees(p.longitude, p.latitude));
|
|
583
|
+
const hierarchy = new lib.PolygonHierarchy(toCart(outer), holes.map((h) => new lib.PolygonHierarchy(toCart(h))));
|
|
584
|
+
const id = i === 0 ? so.key : `${so.key}#part${i}`;
|
|
585
|
+
if (vz.type === 'image') {
|
|
586
|
+
// A picture pinned to the ground (doc 28 G12): the polygon textured, turned to its top edge.
|
|
587
|
+
const raw = vz.image?.urlProperty ? props?.[vz.image.urlProperty] : undefined;
|
|
588
|
+
const url = typeof raw === 'string' && raw ? raw : vz.image?.url;
|
|
589
|
+
if (!url || i > 0)
|
|
590
|
+
return;
|
|
591
|
+
const ring = outer.length > 1 && outer[0].latitude === outer[outer.length - 1].latitude && outer[0].longitude === outer[outer.length - 1].longitude ? outer.slice(0, -1) : outer;
|
|
592
|
+
const turn = ring.length === 4 ? spatial.bearingDegrees(ring[0], ring[1]) - 90 : 0;
|
|
593
|
+
entities.add({ id, polygon: { hierarchy, material: new lib.ImageMaterialProperty({ image: url, transparent: true, color: lib.Color.WHITE.withAlpha(vz.image?.opacity ?? 0.85) }), stRotation: lib.Math.toRadians(-turn), classificationType: lib.ClassificationType.BOTH } });
|
|
594
|
+
if (so.selected || so.hovered)
|
|
595
|
+
entities.add({ id: so.key + OUTLINE, polyline: { positions: toCart([...outer, outer[0]]), width: so.selected ? 3 : 2, material: lib.Color.fromCssColorString(so.selected ? this.theme.selectionColor : this.theme.hoverColor), clampToGround: true } });
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (vz.type === 'extrusion') {
|
|
599
|
+
const x = vz.extrusion ?? {};
|
|
600
|
+
const base = x.baseProperty ? (num(props?.[x.baseProperty]) ?? x.base ?? 0) : (x.base ?? 0);
|
|
601
|
+
const height = x.heightProperty ? (num(props?.[x.heightProperty]) ?? x.height ?? 0) : (x.height ?? 0);
|
|
602
|
+
entities.add({ id, polygon: { hierarchy, height: base, extrudedHeight: base + height, heightReference: lib.HeightReference.RELATIVE_TO_GROUND, extrudedHeightReference: lib.HeightReference.RELATIVE_TO_GROUND, material: color(this.theme.fillColor).withAlpha(0.85) } });
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
entities.add({ id, polygon: { hierarchy, material: color(this.theme.fillColor).withAlpha(so.selected ? 0.32 : tokenFor(rules, 'opacity', props, 0.18)), classificationType: lib.ClassificationType.BOTH } });
|
|
606
|
+
if (i === 0) {
|
|
607
|
+
const stroke = so.selected ? this.theme.selectionColor : tokenFor(rules, 'strokeColor', props, tokenFor(rules, 'color', props, this.theme.fillColor));
|
|
608
|
+
entities.add({ id: so.key + OUTLINE, polyline: { positions: toCart([...outer, outer[0]]), width: so.selected ? 3 : tokenFor(rules, 'strokeWidth', props, 1.5), material: lib.Color.fromCssColorString(stroke), clampToGround: true } });
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
// One label per area, at the same point MapLibre uses (centre, or just inside the top edge).
|
|
612
|
+
const lg = labelGraphics(this.groundReference(), 0);
|
|
613
|
+
if (lg) {
|
|
614
|
+
const at = spatial.labelPoint(g, vz.label?.placement);
|
|
615
|
+
entities.add({ id: so.key + LABEL, position: lib.Cartesian3.fromDegrees(at.longitude, at.latitude), label: { ...lg, ...(vz.label?.placement === 'top' ? { verticalOrigin: lib.VerticalOrigin.TOP } : {}) } });
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* A height as drawn (ADR-028): Cesium's vertical exaggeration scales the terrain but not absolute heights, so heights
|
|
621
|
+
* are scaled the same way to keep an aircraft's true relation to the (exaggerated) ground. `lift` multiplies the
|
|
622
|
+
* height above the ground; `relative` (agl) heights are measured from the ground Cesium places them on.
|
|
623
|
+
*/
|
|
624
|
+
/**
|
|
625
|
+
* How things on the ground are placed: clamped to the terrain when there is one; without terrain the ground is
|
|
626
|
+
* the ellipsoid, so height 0 directly — Cesium updates clamped billboards lazily, and a moving one lagged behind.
|
|
627
|
+
*/
|
|
628
|
+
groundReference() {
|
|
629
|
+
return this.terrain ? this.lib.HeightReference.CLAMP_TO_GROUND : this.lib.HeightReference.NONE;
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Contour lines (doc 28 G11) as Cesium's elevation-contour material on the globe. This renderer exaggerates the
|
|
633
|
+
* terrain itself, so the spacing is exaggerated the same way and the lines still sit on true heights.
|
|
634
|
+
*/
|
|
635
|
+
applyContours() {
|
|
636
|
+
const lib = this.lib;
|
|
637
|
+
const globe = this.viewer?.scene.globe;
|
|
638
|
+
if (!lib || !globe)
|
|
639
|
+
return;
|
|
640
|
+
const c = this.terrain?.contours;
|
|
641
|
+
if (!c) {
|
|
642
|
+
globe.material = undefined;
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
const k = this.terrain?.exaggeration ?? 1;
|
|
646
|
+
globe.material = lib.Material.fromType('ElevationContour', { width: 1.2, spacing: c.interval * k, color: lib.Color.fromCssColorString(c.color ?? '#8A6F4E').withAlpha(0.7) });
|
|
647
|
+
}
|
|
648
|
+
/** The drawn ground height under a point: cached, else the loaded tiles' guess now and a precise sample soon. */
|
|
649
|
+
groundHeight(longitude, latitude) {
|
|
650
|
+
if (!this.terrain || !this.lib || !this.viewer)
|
|
651
|
+
return 0;
|
|
652
|
+
const key = `${longitude},${latitude}`;
|
|
653
|
+
const known = this.grounds.get(key);
|
|
654
|
+
if (known !== undefined)
|
|
655
|
+
return known;
|
|
656
|
+
const carto = this.lib.Cartographic.fromDegrees(longitude, latitude);
|
|
657
|
+
if (!this.groundQueue.size)
|
|
658
|
+
queueMicrotask(() => void this.sampleGrounds());
|
|
659
|
+
this.groundQueue.set(key, carto);
|
|
660
|
+
return this.viewer.scene.globe.getHeight(carto) ?? 0;
|
|
661
|
+
}
|
|
662
|
+
async sampleGrounds() {
|
|
663
|
+
const lib = this.lib;
|
|
664
|
+
const v = this.viewer;
|
|
665
|
+
const terrain = this.terrain;
|
|
666
|
+
const gen = this.gen;
|
|
667
|
+
const batch = [...this.groundQueue];
|
|
668
|
+
this.groundQueue.clear();
|
|
669
|
+
if (!lib || !v || !terrain || !batch.length)
|
|
670
|
+
return;
|
|
671
|
+
let heights;
|
|
672
|
+
try {
|
|
673
|
+
const out = await lib.sampleTerrain(v.terrainProvider, Math.min(14, terrain.maxZoom ?? 14), batch.map(([, c]) => c));
|
|
674
|
+
heights = out.map((c) => (Number.isFinite(c.height) ? c.height : 0)); // exaggerated already, as drawn
|
|
675
|
+
}
|
|
676
|
+
catch (err) {
|
|
677
|
+
this.emitError(new Error(`terrain sample for volumes failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
678
|
+
heights = batch.map(() => 0);
|
|
679
|
+
}
|
|
680
|
+
if (gen !== this.gen || terrain !== this.terrain)
|
|
681
|
+
return;
|
|
682
|
+
batch.forEach(([key], i) => this.grounds.set(key, heights[i]));
|
|
683
|
+
this.redraw([...this.objects.values()].filter((so) => this.layers.get(so.layerId)?.visualization.type === 'volume'));
|
|
684
|
+
}
|
|
685
|
+
drawnHeight(longitude, latitude, height, relative, lift) {
|
|
686
|
+
const k = this.terrain ? (this.terrain.exaggeration ?? 1) : 1;
|
|
687
|
+
if (relative)
|
|
688
|
+
return height * k * lift;
|
|
689
|
+
if (lift === 1)
|
|
690
|
+
return height * k;
|
|
691
|
+
const ground = this.viewer?.scene.globe.getHeight(this.lib.Cartographic.fromDegrees(longitude, latitude)) ?? 0; // already exaggerated
|
|
692
|
+
return ground + (height * k - ground) * lift;
|
|
693
|
+
}
|
|
694
|
+
/** Measures a model file once (its longest side), then redraws the objects that use it (sizeMeters). */
|
|
695
|
+
measure(uri) {
|
|
696
|
+
const lib = this.lib;
|
|
697
|
+
if (this.measuring.has(uri))
|
|
698
|
+
return;
|
|
699
|
+
this.measuring.add(uri);
|
|
700
|
+
const gen = this.gen;
|
|
701
|
+
lib.Model.fromGltfAsync({ url: uri }).then((model) => {
|
|
702
|
+
// Bounds are known once the model is ready (it must be in a scene to finish loading).
|
|
703
|
+
this.viewer.scene.primitives.add(model);
|
|
704
|
+
model.show = false;
|
|
705
|
+
const off = model.readyEvent.addEventListener(() => {
|
|
706
|
+
off();
|
|
707
|
+
// This runs inside Cesium's render loop, where an exception stops the map drawing: report it instead.
|
|
708
|
+
try {
|
|
709
|
+
const size = Math.max(1e-6, model.boundingSphere.radius * 2);
|
|
710
|
+
// The scene owns its primitives: removing the model also destroys it, so read it first.
|
|
711
|
+
const scene = this.viewer?.scene;
|
|
712
|
+
if (scene)
|
|
713
|
+
scene.primitives.remove(model);
|
|
714
|
+
else if (!model.isDestroyed())
|
|
715
|
+
model.destroy();
|
|
716
|
+
if (gen !== this.gen)
|
|
717
|
+
return;
|
|
718
|
+
this.extents.set(uri, size);
|
|
719
|
+
this.redraw([...this.objects.values()].filter((so) => this.layers.get(so.layerId)?.visualization.type === 'model'));
|
|
720
|
+
}
|
|
721
|
+
catch (err) {
|
|
722
|
+
this.emitError(new Error(`model ${uri} could not be measured: ${err instanceof Error ? err.message : String(err)}`));
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
}, (e) => this.emitError(new Error(`model ${uri} failed to load: ${e instanceof Error ? e.message : String(e)}`)));
|
|
726
|
+
}
|
|
727
|
+
// ---------------------------------------------------------------- events
|
|
728
|
+
bindEvents(lib, viewer) {
|
|
729
|
+
const camera = viewer.camera;
|
|
730
|
+
camera.percentageChanged = 0.001;
|
|
731
|
+
this.offs.push(camera.moveEnd.addEventListener(() => {
|
|
732
|
+
const v = this.currentViewport();
|
|
733
|
+
for (const l of this.listeners.viewport)
|
|
734
|
+
l(v);
|
|
735
|
+
}));
|
|
736
|
+
this.offs.push(camera.changed.addEventListener(() => {
|
|
737
|
+
for (const l of this.listeners.move)
|
|
738
|
+
l(undefined);
|
|
739
|
+
}));
|
|
740
|
+
this.offs.push(viewer.scene.renderError.addEventListener((_s, err) => this.emitError(err instanceof Error ? err : new Error(String(err)))));
|
|
741
|
+
const handler = new lib.ScreenSpaceEventHandler(viewer.scene.canvas);
|
|
742
|
+
this.handler = handler;
|
|
743
|
+
handler.setInputAction((ev) => {
|
|
744
|
+
const hit = this.hitTest(ev.position);
|
|
745
|
+
const position = this.groundAt(ev.position);
|
|
746
|
+
if (hit)
|
|
747
|
+
for (const l of this.listeners.pick)
|
|
748
|
+
l({ ...hit, ...(position ? { position } : {}) });
|
|
749
|
+
else if (position)
|
|
750
|
+
for (const l of this.listeners.click)
|
|
751
|
+
l({ position });
|
|
752
|
+
}, lib.ScreenSpaceEventType.LEFT_CLICK);
|
|
753
|
+
handler.setInputAction((ev) => {
|
|
754
|
+
const hit = this.hitTest(ev.endPosition);
|
|
755
|
+
viewer.scene.canvas.style.cursor = hit ? 'pointer' : '';
|
|
756
|
+
const key = hit ? `${hit.layerId}:${hit.objectId}` : null;
|
|
757
|
+
if (key === this.hoverKey)
|
|
758
|
+
return;
|
|
759
|
+
this.hoverKey = key;
|
|
760
|
+
for (const l of this.listeners.hover)
|
|
761
|
+
l(hit);
|
|
762
|
+
}, lib.ScreenSpaceEventType.MOUSE_MOVE);
|
|
763
|
+
}
|
|
764
|
+
hitTest(point) {
|
|
765
|
+
const picked = this.viewer?.scene.pick(point);
|
|
766
|
+
const id = picked?.id?.id;
|
|
767
|
+
if (typeof id !== 'string')
|
|
768
|
+
return null;
|
|
769
|
+
const key = keyOf(id);
|
|
770
|
+
if (!this.objects.has(key))
|
|
771
|
+
return null;
|
|
772
|
+
const [layerId, objectId] = splitKey(key);
|
|
773
|
+
return { layerId, objectId };
|
|
774
|
+
}
|
|
775
|
+
/** The ground (terrain, else ellipsoid) under a screen point. */
|
|
776
|
+
groundAt(point) {
|
|
777
|
+
const lib = this.lib;
|
|
778
|
+
const v = this.viewer;
|
|
779
|
+
const ray = v.camera.getPickRay(point);
|
|
780
|
+
const hit = (ray && v.scene.globe.pick(ray, v.scene)) ?? v.camera.pickEllipsoid(point);
|
|
781
|
+
if (!hit)
|
|
782
|
+
return null;
|
|
783
|
+
const c = lib.Cartographic.fromCartesian(hit);
|
|
784
|
+
return { latitude: lib.Math.toDegrees(c.latitude), longitude: lib.Math.toDegrees(c.longitude) };
|
|
785
|
+
}
|
|
786
|
+
currentViewport() {
|
|
787
|
+
const lib = this.lib;
|
|
788
|
+
const v = this.viewer;
|
|
789
|
+
const camera = v.camera;
|
|
790
|
+
const size = this.getSize();
|
|
791
|
+
const centrePx = new lib.Cartesian2(size.width / 2, size.height / 2);
|
|
792
|
+
const ray = camera.getPickRay(centrePx);
|
|
793
|
+
const lookPoint = (ray && v.scene.globe.pick(ray, v.scene)) ?? camera.pickEllipsoid(centrePx);
|
|
794
|
+
const fovy = camera.frustum.fovy ?? Math.PI / 3;
|
|
795
|
+
let center;
|
|
796
|
+
let zoom;
|
|
797
|
+
if (lookPoint) {
|
|
798
|
+
const c = lib.Cartographic.fromCartesian(lookPoint);
|
|
799
|
+
center = { latitude: lib.Math.toDegrees(c.latitude), longitude: lib.Math.toDegrees(c.longitude) };
|
|
800
|
+
zoom = zoomForRange(lib.Cartesian3.distance(camera.positionWC, lookPoint), center.latitude, size.height || 600, fovy);
|
|
801
|
+
}
|
|
802
|
+
else {
|
|
803
|
+
// Looking above the horizon: the point under the camera, from its height.
|
|
804
|
+
const c = camera.positionCartographic;
|
|
805
|
+
center = { latitude: lib.Math.toDegrees(c.latitude), longitude: lib.Math.toDegrees(c.longitude) };
|
|
806
|
+
zoom = zoomForRange(c.height, center.latitude, size.height || 600, fovy);
|
|
807
|
+
}
|
|
808
|
+
const rect = camera.computeViewRectangle();
|
|
809
|
+
const deg = (r) => lib.Math.toDegrees(r);
|
|
810
|
+
return {
|
|
811
|
+
center,
|
|
812
|
+
zoom,
|
|
813
|
+
bearing: ((deg(camera.heading) % 360) + 360) % 360,
|
|
814
|
+
pitch: enginePitch(deg(camera.pitch)),
|
|
815
|
+
...(rect ? { bounds: { west: deg(rect.west), south: deg(rect.south), east: deg(rect.east), north: deg(rect.north) } } : {}),
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
emitError(error) {
|
|
819
|
+
for (const l of this.listeners.error)
|
|
820
|
+
l(error);
|
|
821
|
+
}
|
|
822
|
+
add(set, l) {
|
|
823
|
+
set.add(l);
|
|
824
|
+
return () => set.delete(l);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
/** A circle as a ring of positions (for areas drawn by Cesium polygons). */
|
|
828
|
+
function circleRing(center, radiusMeters, steps = 64) {
|
|
829
|
+
const out = [];
|
|
830
|
+
const dLat = radiusMeters / 111_320;
|
|
831
|
+
const dLng = radiusMeters / (111_320 * Math.cos((center.latitude * Math.PI) / 180));
|
|
832
|
+
for (let i = 0; i <= steps; i++) {
|
|
833
|
+
const a = (i / steps) * 2 * Math.PI;
|
|
834
|
+
out.push({ latitude: center.latitude + dLat * Math.sin(a), longitude: center.longitude + dLng * Math.cos(a) });
|
|
835
|
+
}
|
|
836
|
+
return out;
|
|
837
|
+
}
|