@tangram.gl/tangram-layers 0.0.0-stage → 0.0.1

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.
Files changed (43) hide show
  1. package/README.md +36 -2
  2. package/dist/experimental/projected-basemaps.js +1310 -0
  3. package/dist/experimental/projected-basemaps.js.map +6 -0
  4. package/dist/experimental/terrain.js +379 -0
  5. package/dist/experimental/terrain.js.map +6 -0
  6. package/dist/experimental/webxr.js +1423 -0
  7. package/dist/experimental/webxr.js.map +6 -0
  8. package/dist/index.js +756 -0
  9. package/dist/index.js.map +6 -0
  10. package/dist/types/experimental/projected-basemaps.d.ts +120 -0
  11. package/dist/types/experimental/projected-detail.d.ts +33 -0
  12. package/dist/types/experimental/projected-navigation.d.ts +117 -0
  13. package/dist/types/experimental/terrain-display.d.ts +124 -0
  14. package/dist/types/experimental/terrain.d.ts +131 -0
  15. package/dist/types/first_person_view_adapter.d.ts +29 -0
  16. package/dist/types/globe_view_adapter.d.ts +33 -0
  17. package/dist/types/index.d.ts +84 -0
  18. package/dist/types/tangram-layer-lifecycle.d.ts +132 -0
  19. package/dist/types/tangram-layer.d.ts +157 -0
  20. package/dist/types/view_adapter_types.d.ts +42 -0
  21. package/dist/types/web_mercator_view_adapter.d.ts +77 -0
  22. package/package.json +80 -3
  23. package/src/experimental/projected-basemaps.ts +301 -0
  24. package/src/experimental/projected-detail.ts +116 -0
  25. package/src/experimental/projected-navigation.ts +323 -0
  26. package/src/experimental/terrain-display.ts +243 -0
  27. package/src/experimental/terrain.ts +240 -0
  28. package/src/experimental/webxr/grabbing.ts +166 -0
  29. package/src/experimental/webxr/index.d.ts +77 -0
  30. package/src/experimental/webxr/index.ts +72 -0
  31. package/src/experimental/webxr/interaction.ts +221 -0
  32. package/src/experimental/webxr/picking.ts +119 -0
  33. package/src/experimental/webxr/presentation.ts +457 -0
  34. package/src/experimental/webxr/projection.ts +371 -0
  35. package/src/experimental/webxr/types.ts +221 -0
  36. package/src/experimental/webxr/views.ts +190 -0
  37. package/src/first_person_view_adapter.ts +107 -0
  38. package/src/globe_view_adapter.ts +103 -0
  39. package/src/index.ts +64 -0
  40. package/src/tangram-layer-lifecycle.ts +130 -0
  41. package/src/tangram-layer.ts +679 -0
  42. package/src/view_adapter_types.ts +40 -0
  43. package/src/web_mercator_view_adapter.ts +185 -0
@@ -0,0 +1,323 @@
1
+ // tangram-layers
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) vis.gl contributors
4
+
5
+ import {getProjectedCoordinateOptions, PROJECTED_COMMON_SCALE, countProjectedTileCoordinates,
6
+ normalizeProjectedBasemapOptions, ProjectionBatchExecutor} from '@tangram.gl/tangram-renderer/core';
7
+ import type {ProjectionEngine, ProjectionEngineTransform, ProjectedBasemapOptions} from '@tangram.gl/tangram-renderer/core';
8
+ import type {ProjectionExecutionOptions, ProjectionExecutionStatistics} from '@tangram.gl/tangram-renderer/core';
9
+
10
+ /** Cancellation for one coordinate request; compiled transforms remain shared and reusable. */
11
+ export interface ProjectedNavigationRequestOptions {
12
+ /** Abort this request without disposing the navigation helper or caller-owned factory. */
13
+ signal?: AbortSignal;
14
+ }
15
+
16
+ /** Supported fixed projection, not an arbitrary target CRS. */
17
+ export type ProjectedBasemapType = ProjectedBasemapOptions['type'];
18
+ /** Ordered single-world west, south, east and north in degrees. */
19
+ export type ProjectedGeographicBounds = readonly [number, number, number, number];
20
+ /** Geographic longitude and latitude in degrees. */
21
+ export type ProjectedGeographicPosition = readonly [number, number];
22
+
23
+ /** Public deck orthographic methods; screen positions use top-left CSS pixels. */
24
+ export interface ProjectedNavigationViewport {
25
+ /** Must be false; geographic deck cameras have a different common-space contract. */
26
+ isGeospatial: boolean;
27
+ /** Canvas width in CSS pixels. */
28
+ width: number;
29
+ /** Canvas height in CSS pixels. */
30
+ height: number;
31
+ /** Public deck viewport discriminator. */
32
+ constructor: {displayName?: string; name?: string};
33
+ /** Map common-space positions to screen pixels. */
34
+ project(position: number[]): number[];
35
+ /** Map screen pixels to the projected ground plane. */
36
+ unproject(position: number[], options?: {targetZ?: number}): number[];
37
+ }
38
+
39
+ /** Orthographic state returned by the geographic fit operation. */
40
+ export interface ProjectedFitViewState {
41
+ /** Center of the sampled projected extent, in common units. */
42
+ target: [number, number, number];
43
+ /** Log2 CSS pixels per common unit. */
44
+ zoom: number;
45
+ }
46
+
47
+ /** Optional CSS padding and finite zoom range for sampled geographic fitting. */
48
+ export interface ProjectedFitOptions {
49
+ /** Padding on every side in CSS pixels; defaults to 24. */
50
+ padding?: number;
51
+ /** Minimum orthographic zoom; defaults to -10. */
52
+ minZoom?: number;
53
+ /** Maximum orthographic zoom; defaults to 10. */
54
+ maxZoom?: number;
55
+ }
56
+
57
+ /** Camera loading result, always restricted to the caller's single-world source region. */
58
+ export interface ProjectedCameraCoverage {
59
+ /** Conservative geographic envelope, or null when the ground rectangle misses the source domain. */
60
+ bounds: ProjectedGeographicBounds | null;
61
+ /** True only when an unsupported coverage contract requires a complete source region. */
62
+ domainFallback: boolean;
63
+ }
64
+
65
+ /** Geographic focus retained while changing the worker's projection. */
66
+ export interface ProjectedFocusTransition {
67
+ /** New common-space target with the existing orthographic zoom. */
68
+ viewState: ProjectedFitViewState;
69
+ /** Geographic focus used for the new target. */
70
+ focus: ProjectedGeographicPosition;
71
+ /** True when the destination's smaller domain requires clamping the focus. */
72
+ clamped: boolean;
73
+ /** True when the old target has no valid geographic inverse. */
74
+ domainFallback: boolean;
75
+ }
76
+
77
+ /** Geographic domain shared by navigation and the existing projected preview. */
78
+ export function getProjectedGeographicBounds(type: ProjectedBasemapType): ProjectedGeographicBounds {
79
+ normalizeProjectedBasemapOptions({type});
80
+ return type === 'albers' ? [-170, 5, -40, 75] : [-180, -85.0511287798066, 180, 85.0511287798066];
81
+ }
82
+
83
+ /** Reject geographic, perspective or invalid-sized viewports before interpreting their methods. */
84
+ export function validateProjectedNavigationViewport(viewport: ProjectedNavigationViewport): void {
85
+ if (viewport.isGeospatial !== false ||
86
+ (viewport.constructor.displayName !== 'OrthographicViewport' && viewport.constructor.name !== 'OrthographicViewport') ||
87
+ ![viewport.width, viewport.height].every(value => Number.isFinite(value) && value > 0)) {
88
+ throw new Error('Projected navigation requires a finite deck.gl OrthographicViewport');
89
+ }
90
+ }
91
+
92
+ /** Validate a source rectangle against both the tile world and the projection's current domain. */
93
+ export function validateProjectedGeographicBounds(bounds: ProjectedGeographicBounds, type: ProjectedBasemapType): void {
94
+ countProjectedTileCoordinates(bounds, 0);
95
+ const domain = getProjectedGeographicBounds(type);
96
+ if (bounds[0] < domain[0] || bounds[1] < domain[1] || bounds[2] > domain[2] || bounds[3] > domain[3]) {
97
+ throw new Error('Projected navigation bounds must remain within the projection domain');
98
+ }
99
+ }
100
+
101
+ /** Caller-owned engine navigation; no projection kernels or deck cameras enter renderer core. */
102
+ export class ProjectedBasemapNavigation {
103
+ /** Independent compiled forward/reverse CRS pairs, including lazy factory initialization. */
104
+ private readonly transforms = new Map<string, Promise<ProjectionEngineTransform>>();
105
+ /** Prevent late asynchronous work from publishing after example teardown. */
106
+ private disposed = false;
107
+ /** Bounded host kernel calls shared by forward and reverse requests. */
108
+ private readonly executor: ProjectionBatchExecutor;
109
+
110
+ /** Retain a factory without taking ownership of its registrations, grids or lifetime. */
111
+ constructor(private readonly engine: ProjectionEngine, options: ProjectionExecutionOptions = {}) {
112
+ if (!engine || typeof engine.createProjection !== 'function' || typeof engine.createProjectionAsync !== 'function') {
113
+ throw new Error('Projected navigation requires a ProjectionEngine factory');
114
+ }
115
+ this.executor = new ProjectionBatchExecutor(options);
116
+ }
117
+
118
+ /** Project a detached longitude/latitude batch into north-positive common coordinates. */
119
+ async projectPositions(coordinates: Float64Array, type: ProjectedBasemapType,
120
+ options: ProjectedNavigationRequestOptions = {}): Promise<Float64Array> {
121
+ const domain = getProjectedGeographicBounds(type);
122
+ if (!(coordinates instanceof Float64Array) || coordinates.length % 2 !== 0) {
123
+ throw new Error('Projected navigation requires finite geographic pairs inside the projection domain');
124
+ }
125
+ if (!coordinates.every((value, index) => Number.isFinite(value) && value >= domain[index % 2] && value <= domain[index % 2 + 2])) {
126
+ throw new Error('Projected navigation requires finite geographic pairs inside the projection domain');
127
+ }
128
+ return this.executor.execute(coordinates, () => this.getTransform(type, false),
129
+ {signal: options.signal, scale: PROJECTED_COMMON_SCALE});
130
+ }
131
+
132
+ /** Project a geographic focus point without mutating the supplied pair. */
133
+ async projectPosition(position: ProjectedGeographicPosition, type: ProjectedBasemapType,
134
+ options: ProjectedNavigationRequestOptions = {}): Promise<[number, number, number]> {
135
+ if (position.length !== 2) throw new Error('Projected focus requires a longitude/latitude pair');
136
+ const result = await this.projectPositions(new Float64Array(position), type, options);
137
+ return [result[0], result[1], 0];
138
+ }
139
+
140
+ /** Invert ground; finite outside-domain/folded results return null. Engine errors propagate. */
141
+ async unprojectPosition(position: readonly [number, number], type: ProjectedBasemapType,
142
+ options: ProjectedNavigationRequestOptions = {}): Promise<[number, number] | null> {
143
+ if (position.length !== 2 || !position.every(Number.isFinite)) throw new Error('Projected inverse requires a finite common pair');
144
+ const point: [number, number] = [position[0], position[1]];
145
+ const requestOptions = {signal: options.signal};
146
+ const result = await this.executor.execute(new Float64Array(point.map(value => value / PROJECTED_COMMON_SCALE)),
147
+ () => this.getTransform(type, true), {...requestOptions, allowNonfinite: true});
148
+ const domain = getProjectedGeographicBounds(type);
149
+ if (!result.every((value, index) => Number.isFinite(value) && value >= domain[index] - 1e-7 && value <= domain[index + 2] + 1e-7)) return null;
150
+ const geographic: [number, number] = [Math.max(domain[0], Math.min(domain[2], result[0])),
151
+ Math.max(domain[1], Math.min(domain[3], result[1]))];
152
+ // Some inverse kernels wrap or fold outside the drawn map. Require a forward round trip.
153
+ const restored = await this.projectPosition(geographic, type, requestOptions);
154
+ if (Math.hypot(restored[0] - point[0], restored[1] - point[1]) <= 1e-5) return geographic;
155
+ // The two source-world seam edges are distinct, even if an inverse kernel wraps one onto the other.
156
+ if (domain[0] === -180 && domain[2] === 180 && Math.abs(Math.abs(geographic[0]) - 180) <= 1e-7) {
157
+ const opposite: [number, number] = [geographic[0] < 0 ? 180 : -180, geographic[1]];
158
+ const alternative = await this.projectPosition(opposite, type, requestOptions);
159
+ if (Math.hypot(alternative[0] - point[0], alternative[1] - point[1]) <= 1e-5) return opposite;
160
+ }
161
+ return null;
162
+ }
163
+
164
+ /** Probe geographic coordinates on ground, not a rendered feature or depth/picking buffer. */
165
+ async unprojectScreenPosition(viewport: ProjectedNavigationViewport, pixel: readonly [number, number],
166
+ type: ProjectedBasemapType, options: ProjectedNavigationRequestOptions = {}): Promise<[number, number] | null> {
167
+ validateProjectedNavigationViewport(viewport);
168
+ if (pixel.length !== 2 || !pixel.every(Number.isFinite)) throw new Error('Projected probe requires a finite screen pair');
169
+ if (pixel[0] < 0 || pixel[1] < 0 || pixel[0] > viewport.width || pixel[1] > viewport.height) return null;
170
+ const position = viewport.unproject([...pixel], {targetZ: 0});
171
+ return this.unprojectPosition([position[0], position[1]], type, options);
172
+ }
173
+
174
+ /** Bound the viewport's ground rectangle using the fixed projections' separable latitude and longitude scales.
175
+ * Equal Earth's longitude factor decreases with absolute latitude, so both latitude endpoints bound its envelope.
176
+ * Albers uses radial/angular interval bounds about the conic apex, not sparse inverse samples.
177
+ * This does not support wrapped worlds or arbitrary custom projection kernels.
178
+ */
179
+ async getCameraCoverage(viewport: ProjectedNavigationViewport, type: ProjectedBasemapType,
180
+ sourceBounds: ProjectedGeographicBounds = getProjectedGeographicBounds(type)): Promise<ProjectedCameraCoverage> {
181
+ if (this.disposed) throw new Error('Projected navigation is disposed');
182
+ validateProjectedNavigationViewport(viewport);
183
+ validateProjectedGeographicBounds(sourceBounds, type);
184
+ const region: [number, number, number, number] = [...sourceBounds];
185
+ const corners = [[0, 0], [viewport.width, 0], [0, viewport.height], [viewport.width, viewport.height]]
186
+ .map(pixel => viewport.unproject(pixel, {targetZ: 0}));
187
+ if (!corners.every(point => point.length >= 2 && point.slice(0, 2).every(Number.isFinite))) {
188
+ throw new Error('Projected coverage requires finite ground corners');
189
+ }
190
+ if (type === 'albers') return this.getAlbersCoverage(corners, region);
191
+ const minimumX = Math.min(...corners.map(point => point[0]));
192
+ const maximumX = Math.max(...corners.map(point => point[0]));
193
+ const minimumY = Math.min(...corners.map(point => point[1]));
194
+ const maximumY = Math.max(...corners.map(point => point[1]));
195
+ const southernEdge = await this.projectPosition([0, region[1]], type);
196
+ const northernEdge = await this.projectPosition([0, region[3]], type);
197
+ if (maximumY < southernEdge[1] || minimumY > northernEdge[1]) return {bounds: null, domainFallback: false};
198
+ const south = await this.unprojectPosition([0, Math.max(minimumY, southernEdge[1])], type);
199
+ const north = await this.unprojectPosition([0, Math.min(maximumY, northernEdge[1])], type);
200
+ if (!south || !north) throw new Error('Projection engine could not invert the bounded latitude envelope');
201
+ const southernScale = (await this.projectPosition([180, south[1]], type))[0] / 180;
202
+ const northernScale = (await this.projectPosition([180, north[1]], type))[0] / 180;
203
+ if (![southernScale, northernScale].every(value => Number.isFinite(value) && value > 0)) {
204
+ throw new Error('Projected coverage requires a positive longitude scale');
205
+ }
206
+ // The equator supplies the largest Equal Earth scale if the rectangle crosses it.
207
+ const scales = [southernScale, northernScale];
208
+ if (south[1] < 0 && north[1] > 0) scales.push((await this.projectPosition([180, 0], type))[0] / 180);
209
+ const west = Math.max(region[0], Math.min(...scales.map(scale => minimumX / scale)));
210
+ const east = Math.min(region[2], Math.max(...scales.map(scale => maximumX / scale)));
211
+ if (west > east) return {bounds: null, domainFallback: false};
212
+ // An outward numerical margin protects exact tile edges without introducing an extra wrapped world.
213
+ const margin = 1e-7;
214
+ return {bounds: [Math.max(region[0], west - margin), Math.max(region[1], south[1] - margin),
215
+ Math.min(region[2], east + margin), Math.min(region[3], north[1] + margin)], domainFallback: false};
216
+ }
217
+
218
+ /** Bound the fixed northern Albers cone: radius decreases with latitude, angle increases with longitude. */
219
+ private async getAlbersCoverage(corners: number[][], region: ProjectedGeographicBounds): Promise<ProjectedCameraCoverage> {
220
+ // Recover the circle center from the caller's forward kernel, keeping false origins and common units consistent.
221
+ const reference = await this.projectPositions(new Float64Array([-96, 5, -86, 5, -96, 75]), 'albers');
222
+ const apex = (reference[2] ** 2 + reference[3] ** 2 - reference[1] ** 2) / (2 * (reference[3] - reference[1]));
223
+ const cone = (Math.sin(29.5 * Math.PI / 180) + Math.sin(45.5 * Math.PI / 180)) / 2;
224
+ if (!Number.isFinite(apex) || apex <= reference[5]) throw new Error('Projected coverage requires a northern Albers cone');
225
+ const south = await this.projectPosition([-96, region[1]], 'albers');
226
+ const north = await this.projectPosition([-96, region[3]], 'albers');
227
+ const minimumRadius = apex - north[1], maximumRadius = apex - south[1];
228
+ const firstAngle = (region[0] + 96) * Math.PI / 180 * cone;
229
+ const lastAngle = (region[2] + 96) * Math.PI / 180 * cone;
230
+ const angles = [firstAngle, lastAngle];
231
+ if (firstAngle <= 0 && lastAngle >= 0) angles.push(0);
232
+ const minimumCosine = Math.min(...angles.map(Math.cos));
233
+ const maximumCosine = Math.max(...angles.map(Math.cos));
234
+ const x = [Math.min(...corners.map(point => point[0])), Math.max(...corners.map(point => point[0]))];
235
+ const down = [Math.max(minimumRadius * minimumCosine, apex - Math.max(...corners.map(point => point[1]))),
236
+ Math.min(maximumRadius * maximumCosine, apex - Math.min(...corners.map(point => point[1])))];
237
+ if (down[0] > down[1]) return {bounds: null, domainFallback: false};
238
+ const closestX = Math.max(x[0], Math.min(x[1], 0));
239
+ const nearRadius = Math.max(minimumRadius, Math.hypot(closestX, down[0]));
240
+ const farRadius = Math.min(maximumRadius, Math.max(...x.map(value => Math.hypot(value, down[1]))));
241
+ const longitude = x.flatMap(value => down.map(distance => Math.atan2(value, distance) / cone * 180 / Math.PI - 96));
242
+ const west = Math.max(region[0], Math.min(...longitude)), east = Math.min(region[2], Math.max(...longitude));
243
+ if (nearRadius > farRadius || west > east) return {bounds: null, domainFallback: false};
244
+ const southern = await this.unprojectPosition([0, apex - farRadius], 'albers');
245
+ const northern = await this.unprojectPosition([0, apex - nearRadius], 'albers');
246
+ if (!southern || !northern) throw new Error('Projection engine could not invert the Albers radius envelope');
247
+ const margin = 1e-7;
248
+ return {bounds: [Math.max(region[0], west - margin), Math.max(region[1], southern[1] - margin),
249
+ Math.min(region[2], east + margin), Math.min(region[3], northern[1] + margin)], domainFallback: false};
250
+ }
251
+
252
+ /** Preserve geographic focus and numeric zoom; clamp to a smaller destination domain, never mutate the input.
253
+ * A finite outside-domain target falls back to the destination domain center. Engine errors still propagate.
254
+ * Keeping zoom does not imply preserving local ground scale across differently distorted projections.
255
+ */
256
+ async reprojectViewState(state: ProjectedFitViewState, from: ProjectedBasemapType,
257
+ to: ProjectedBasemapType): Promise<ProjectedFocusTransition> {
258
+ if (state.target.length !== 3 || ![...state.target, state.zoom].every(Number.isFinite)) {
259
+ throw new Error('Projection transition requires a finite target and zoom');
260
+ }
261
+ const point: [number, number] = [state.target[0], state.target[1]];
262
+ const zoom = state.zoom;
263
+ const domain = getProjectedGeographicBounds(to);
264
+ const previous = await this.unprojectPosition(point, from);
265
+ const focus: [number, number] = previous ? [Math.max(domain[0], Math.min(domain[2], previous[0])),
266
+ Math.max(domain[1], Math.min(domain[3], previous[1]))] : [(domain[0] + domain[2]) / 2, (domain[1] + domain[3]) / 2];
267
+ return {viewState: {target: await this.projectPosition(focus, to), zoom}, focus,
268
+ clamped: Boolean(previous && (focus[0] !== previous[0] || focus[1] !== previous[1])), domainFallback: previous === null};
269
+ }
270
+
271
+ /** Fit a sampled geographic extent; this is navigation, not a conservative visibility proof. */
272
+ async fitBounds(bounds: ProjectedGeographicBounds, dimensions: {width: number; height: number},
273
+ type: ProjectedBasemapType, options: ProjectedFitOptions = {}): Promise<ProjectedFitViewState> {
274
+ validateProjectedGeographicBounds(bounds, type);
275
+ const {padding = 24, minZoom = -10, maxZoom = 10} = options;
276
+ if (![dimensions.width, dimensions.height, padding, minZoom, maxZoom].every(Number.isFinite) ||
277
+ padding < 0 || dimensions.width <= 2 * padding || dimensions.height <= 2 * padding || minZoom > maxZoom) {
278
+ throw new Error('Projected fit requires usable CSS dimensions, padding and an ordered zoom range');
279
+ }
280
+ const coordinates: number[] = [];
281
+ for (let column = 0; column <= 32; column++) for (let row = 0; row <= 32; row++) {
282
+ coordinates.push(bounds[0] + (bounds[2] - bounds[0]) * column / 32,
283
+ bounds[1] + (bounds[3] - bounds[1]) * row / 32);
284
+ }
285
+ const positions = await this.projectPositions(new Float64Array(coordinates), type);
286
+ let west = Infinity, south = Infinity, east = -Infinity, north = -Infinity;
287
+ for (let index = 0; index < positions.length; index += 2) {
288
+ west = Math.min(west, positions[index]); east = Math.max(east, positions[index]);
289
+ south = Math.min(south, positions[index + 1]); north = Math.max(north, positions[index + 1]);
290
+ }
291
+ const zoom = Math.log2(Math.min((dimensions.width - 2 * padding) / Math.max(east - west, 1e-9),
292
+ (dimensions.height - 2 * padding) / Math.max(north - south, 1e-9)));
293
+ return {target: [(west + east) / 2, (south + north) / 2, 0], zoom: Math.max(minZoom, Math.min(maxZoom, zoom))};
294
+ }
295
+
296
+ /** Release navigation-owned caches; leave the shared engine usable by the renderer. */
297
+ dispose(): void {
298
+ this.disposed = true;
299
+ this.executor.dispose();
300
+ this.transforms.clear();
301
+ }
302
+
303
+ /** Detached host execution work, independent of decoded/GPU cache residency. */
304
+ getExecutionStatistics(): ProjectionExecutionStatistics {
305
+ return this.executor.getStatistics();
306
+ }
307
+
308
+ /** Share concurrent compilation, allow retry after failure and reject late results on teardown. */
309
+ private async getTransform(type: ProjectedBasemapType, reverse: boolean): Promise<ProjectionEngineTransform> {
310
+ if (this.disposed) throw new Error('Projected navigation is disposed');
311
+ const options = getProjectedCoordinateOptions(type);
312
+ const key = `${type}:${reverse}`;
313
+ let pending = this.transforms.get(key);
314
+ if (!pending) {
315
+ pending = Promise.resolve().then(() => this.engine.createProjectionAsync(reverse ? {from: options.to, to: options.from} : options));
316
+ this.transforms.set(key, pending);
317
+ pending.catch(() => {if (this.transforms.get(key) === pending) this.transforms.delete(key);});
318
+ }
319
+ const transform = await pending;
320
+ if (this.disposed) throw new Error('Projected navigation is disposed');
321
+ return transform;
322
+ }
323
+ }
@@ -0,0 +1,243 @@
1
+ // tangram-layers
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) vis.gl contributors
4
+
5
+ import {TerrainMeshSurface} from '@tangram.gl/tangram-renderer/core';
6
+ import {assertGlobeTerrainPreparationSnapshot, getPreparedGlobeTerrainTemplate, getGlobeTerrainExaggeration, scaleGlobeTerrainMeshSteps,
7
+ type GlobeTerrainMesh, type GlobeTerrainMeshTemplate, type GlobeTerrainPreparationSnapshot, type TerrainHeightGrid,
8
+ type TerrainResourceBudget, type TerrainResourceLease} from '@tangram.gl/tangram-renderer/map-logic';
9
+
10
+ /** Packed attributes shared with deck.gl; raw spherical/physical arrays are copied into the query owner. */
11
+ export function getTerrainRenderMesh(mesh: GlobeTerrainMesh) {
12
+ return {attributes: {POSITION: {size: 3, value: mesh.geographicPositions}, COLOR_0: {size: 3, value: mesh.colors}}, indices: {size: 1, value: mesh.indices}};
13
+ }
14
+
15
+ /** Internal complete draw/query tile; never expose partial work or mutable mesh buffers through the package API. */
16
+ export interface GlobeTerrainDisplayTile {
17
+ grid: TerrainHeightGrid;
18
+ template: GlobeTerrainMeshTemplate;
19
+ exaggeration: number;
20
+ mesh: ReturnType<typeof getTerrainRenderMesh>;
21
+ surface: TerrainMeshSurface;
22
+ queryLimit: number;
23
+ }
24
+
25
+ /** Opaque complete display/query generation; only this library can produce usable handles. */
26
+ export interface GlobeTerrainDisplaySnapshot {
27
+ /** Completed physical sampling/topology generation used by every tile. */
28
+ readonly terrain: GlobeTerrainPreparationSnapshot;
29
+ /** Actual display multiplier; physical height queries remain unexaggerated. */
30
+ readonly exaggeration: number;
31
+ /** Retained display/query geometry arrays, excluding templates, scratch and acceleration. */
32
+ readonly byteLength: number;
33
+ /** Controller's current-plus-replacement geometry cap, which must match the consuming layer. */
34
+ readonly maxDisplayBytes: number;
35
+ /** Current lazy acceleration cap, which must match the consuming layer. */
36
+ readonly maxQueryIndexBytes: number;
37
+ }
38
+
39
+ /** Detached work/retention diagnostics; elapsed CPU work is not whole-frame or GPU time. */
40
+ export interface GlobeTerrainDisplayStatistics {
41
+ /** Tiles still awaiting display preparation. */
42
+ pending: number;
43
+ /** Unique current/staged display/query geometry arrays. */
44
+ retainedBytes: number;
45
+ /** Current-plus-replacement geometry cap. */
46
+ byteLimit: number;
47
+ /** Full current-plus-new geometry requirement from preflight; zero until the request is advanced. */
48
+ requiredPeakBytes: number;
49
+ /** Allocated current/staged acceleration arrays, counting shared surfaces once. */
50
+ queryIndexBytes: number;
51
+ /** New tile indexes warmed before publication; insufficient peak space defers lazy acceleration. */
52
+ warmedIndexes: number;
53
+ /** New tile indexes left lazy because current-plus-staged acceleration would exceed the cap. */
54
+ deferredIndexes: number;
55
+ /** New tiles in the current request. */
56
+ prepared: number;
57
+ /** Reused display/query tiles in the current request. */
58
+ reused: number;
59
+ /** Completed atomically published generations. */
60
+ committed: number;
61
+ /** Requests replaced before completion. */
62
+ cancelled: number;
63
+ /** Work slices in the current request. */
64
+ slices: number;
65
+ /** Last slice elapsed time, including any indivisible query snapshot copy. */
66
+ lastSliceMs: number;
67
+ /** Largest slice elapsed time in the current request. */
68
+ maxSliceMs: number;
69
+ /** Sum of slice elapsed times, excluding host yields. */
70
+ workMs: number;
71
+ /** Last admission/scaling failure; unchanged requests do not automatically retry. */
72
+ error?: string;
73
+ }
74
+
75
+ const displays = new WeakMap<GlobeTerrainDisplaySnapshot, readonly GlobeTerrainDisplayTile[]>();
76
+
77
+ /** Internal layer boundary: resolve only complete library-owned display/query generations. */
78
+ export function resolveGlobeTerrainDisplay(snapshot: GlobeTerrainDisplaySnapshot): readonly GlobeTerrainDisplayTile[] {
79
+ const tiles = displays.get(snapshot);
80
+ if (!tiles) throw new Error('Globe terrain display requires a completed library snapshot');
81
+ return tiles;
82
+ }
83
+
84
+ /** Exact retained render attributes/topology plus the one owned query geometry snapshot. */
85
+ function displayBytes(template: GlobeTerrainMeshTemplate): number {return template.vertexCount * 56 + template.triangleCount * 24;}
86
+
87
+ /** Host-driven cooperative rescaling and query snapshots; owns no camera, device, source or animation loop. */
88
+ export class GlobeTerrainDisplayPreparation {
89
+ private current?: GlobeTerrainDisplaySnapshot;
90
+ private target?: {terrain: GlobeTerrainPreparationSnapshot; exaggeration: number};
91
+ private iterator?: Generator<void, void, void>;
92
+ private staged: GlobeTerrainDisplayTile[] = [];
93
+ private disposed = false;
94
+ private readonly maxDisplayBytes: number;
95
+ private readonly maxQueryIndexBytes: number;
96
+ private readonly statistics: Omit<GlobeTerrainDisplayStatistics, 'retainedBytes' | 'queryIndexBytes'>;
97
+ /** Registered current/staged geometry only; query acceleration and external snapshots are separate owners. */
98
+ private readonly leases = new Map<GlobeTerrainDisplayTile, readonly TerrainResourceLease[]>();
99
+ /** Shared registered-allocation pool, without owning the pool or other clients. */
100
+ private readonly resourceBudget?: TerrainResourceBudget;
101
+
102
+ /** Budget retained geometry and lazy acceleration separately; scratch, templates and GPU memory are excluded. */
103
+ constructor(options: {
104
+ /** Current plus replacement display/query geometry cap; defaults to 128 MiB. */
105
+ maxDisplayBytes?: number;
106
+ /** Current lazy query hierarchy cap; defaults to 64 MiB. */
107
+ maxQueryIndexBytes?: number;
108
+ /** Shared registered-allocation budget for retained display/query geometry, excluding lazy indexes and scratch. */
109
+ resourceBudget?: TerrainResourceBudget;
110
+ } = {}) {
111
+ this.maxDisplayBytes = options.maxDisplayBytes ?? 128 * 1024 * 1024;
112
+ this.maxQueryIndexBytes = options.maxQueryIndexBytes ?? 64 * 1024 * 1024;
113
+ this.resourceBudget = options.resourceBudget;
114
+ for (const limit of [this.maxDisplayBytes, this.maxQueryIndexBytes]) if (!Number.isSafeInteger(limit) || limit < 0) throw new Error('Globe terrain byte limits must be non-negative integers');
115
+ this.statistics = {pending: 0, byteLimit: this.maxDisplayBytes, requiredPeakBytes: 0, warmedIndexes: 0, deferredIndexes: 0, prepared: 0, reused: 0, committed: 0, cancelled: 0, slices: 0, lastSliceMs: 0, maxSliceMs: 0, workMs: 0};
116
+ }
117
+
118
+ /** Latest complete source generation and multiplier win; unchanged or failed requests do not restart. */
119
+ request(terrain: GlobeTerrainPreparationSnapshot, exaggeration: number): boolean {
120
+ if (this.disposed) throw new Error('Globe terrain display preparation is disposed');
121
+ assertGlobeTerrainPreparationSnapshot(terrain); getGlobeTerrainExaggeration(0, exaggeration);
122
+ if (this.target?.terrain === terrain && this.target.exaggeration === exaggeration) return false;
123
+ this.cancel(); this.target = {terrain, exaggeration};
124
+ Object.assign(this.statistics, {pending: terrain.grids.length, requiredPeakBytes: 0, warmedIndexes: 0, deferredIndexes: 0, prepared: 0, reused: 0, slices: 0, lastSliceMs: 0, maxSliceMs: 0, workMs: 0, error: undefined});
125
+ this.iterator = this.prepare(terrain, exaggeration);
126
+ return true;
127
+ }
128
+
129
+ /** Advance at least one chunk; query-snapshot construction remains an indivisible per-tile step. */
130
+ advance(budgetMs = 4, now: () => number = () => performance.now()): boolean {
131
+ if (!Number.isFinite(budgetMs) || budgetMs <= 0) throw new Error('Display slice target must be positive');
132
+ if (!this.iterator) return false;
133
+ const started = now(); let committed = false;
134
+ try {
135
+ do {
136
+ const result = this.iterator.next();
137
+ if (result.done) {this.iterator = undefined; committed = true; break;}
138
+ } while (now() - started < budgetMs);
139
+ } catch (error) {
140
+ this.iterator?.return(); this.iterator = undefined; this.staged = []; this.statistics.pending = 0;
141
+ this.releaseUnusedLeases();
142
+ this.statistics.error = error instanceof Error ? error.message : String(error);
143
+ }
144
+ const elapsed = Math.max(0, now() - started);
145
+ this.statistics.slices++; this.statistics.lastSliceMs = elapsed; this.statistics.maxSliceMs = Math.max(this.statistics.maxSliceMs, elapsed); this.statistics.workMs += elapsed;
146
+ return committed;
147
+ }
148
+
149
+ /** Explicitly retry a failed request while retaining the displayed generation. */
150
+ retry(): boolean {
151
+ if (!this.statistics.error || !this.target || this.disposed) return false;
152
+ const {terrain, exaggeration} = this.target; this.target = undefined;
153
+ return this.request(terrain, exaggeration);
154
+ }
155
+
156
+ /** Complete generation only; drawing and picking always adopt the same geometry. */
157
+ getSnapshot(): GlobeTerrainDisplaySnapshot | undefined {return this.current;}
158
+
159
+ /** Detached exact array retention and cooperative-work counters. */
160
+ getStatistics(): GlobeTerrainDisplayStatistics {
161
+ const tiles = this.getOwnedTiles();
162
+ return {...this.statistics, retainedBytes: tiles.reduce((sum, tile) => sum + displayBytes(tile.template), 0), queryIndexBytes: tiles.reduce((sum, tile) => sum + tile.surface.getStatistics().queryIndexBytes, 0)};
163
+ }
164
+
165
+ /** True while work awaits the next host slice. */
166
+ isPending(): boolean {return Boolean(this.iterator);}
167
+
168
+ /** Cancel staged rescaling and release its reservations while keeping the displayed generation. */
169
+ cancel(): boolean {
170
+ const pending = Boolean(this.iterator);
171
+ if (pending) this.statistics.cancelled++;
172
+ this.iterator?.return(); this.iterator = undefined; this.staged = []; this.target = undefined; this.statistics.pending = 0;
173
+ this.releaseUnusedLeases();
174
+ return pending;
175
+ }
176
+
177
+ /** Close staged generators and release owned references; external snapshots remain the caller's responsibility. */
178
+ destroy(): void {
179
+ this.cancel(); this.current = undefined; this.releaseUnusedLeases(); this.disposed = true;
180
+ }
181
+
182
+ /** Count shared tile handles once, including already allocated lazy query indexes. */
183
+ private getOwnedTiles(): GlobeTerrainDisplayTile[] {return [...new Set([...(this.current ? resolveGlobeTerrainDisplay(this.current) : []), ...this.staged])];}
184
+
185
+ /** External snapshots are not owned by this preparation object and require their own lifetime accounting. */
186
+ private releaseUnusedLeases(): void {
187
+ const retained = new Set(this.getOwnedTiles());
188
+ for (const [tile, leases] of this.leases) if (!retained.has(tile)) {for (const lease of leases) lease.release(); this.leases.delete(tile);}
189
+ }
190
+
191
+ /** Preflight a whole replacement before scaling; reuse compatible complete tiles and commit them together. */
192
+ private *prepare(terrain: GlobeTerrainPreparationSnapshot, exaggeration: number): Generator<void, void, void> {
193
+ const previous = new Map((this.current ? resolveGlobeTerrainDisplay(this.current) : []).map(tile => [tile.template, tile]));
194
+ const queryLimit = Math.floor(this.maxQueryIndexBytes / Math.max(1, terrain.grids.length));
195
+ const candidates = terrain.grids.map(grid => {
196
+ const template = getPreparedGlobeTerrainTemplate(terrain, grid), tile = previous.get(template);
197
+ const required = tile?.surface.getStatistics().requiredQueryIndexBytes ?? 0;
198
+ const retained = tile?.exaggeration === exaggeration && (required <= tile.queryLimit) === (required <= queryLimit) ? tile : undefined;
199
+ return {grid, template, retained};
200
+ });
201
+ const currentBytes = this.current?.byteLength ?? 0, newBytes = candidates.reduce((sum, tile) => sum + (tile.retained ? 0 : displayBytes(tile.template)), 0);
202
+ this.statistics.requiredPeakBytes = currentBytes + newBytes;
203
+ if (newBytes > 0 && currentBytes + newBytes > this.maxDisplayBytes) throw new Error('Globe terrain display-byte limit exceeded; previous terrain retained');
204
+ const reservations = this.resourceBudget?.reserveAll(candidates.filter(tile => !tile.retained).flatMap(tile => [
205
+ {domain: 'retained' as const, bytes: tile.template.byteLength, identity: tile.template},
206
+ {domain: 'retained' as const, bytes: displayBytes(tile.template)}])) ??
207
+ (this.resourceBudget ? null : []);
208
+ if (!reservations) throw new Error('Globe terrain display shared resource budget exceeded; previous terrain retained');
209
+ let reservationIndex = 0;
210
+ try {
211
+ for (const candidate of candidates) {
212
+ if (candidate.retained) {this.staged.push(candidate.retained); this.statistics.reused++;}
213
+ else {
214
+ let scaled: GlobeTerrainMesh | undefined = yield* scaleGlobeTerrainMeshSteps(candidate.template, exaggeration);
215
+ const surface = new TerrainMeshSurface({projection: 'globe', positions: scaled.positions, indices: scaled.indices, physicalHeights: scaled.physicalHeights, cullBackfaces: true, maxQueryIndexBytes: queryLimit});
216
+ const tile = {grid: candidate.grid, template: candidate.template, exaggeration, mesh: getTerrainRenderMesh(scaled), surface, queryLimit};
217
+ const tileReservations = reservations.slice(reservationIndex, reservationIndex + 2); reservationIndex += 2;
218
+ if (tileReservations.length) this.leases.set(tile, tileReservations);
219
+ this.staged.push(tile);
220
+ scaled = undefined; this.statistics.prepared++;
221
+ const required = surface.getStatistics().requiredQueryIndexBytes;
222
+ if (required > 0 && required <= queryLimit) {
223
+ // Reserve even cold current indexes: an input event may build them between host slices.
224
+ const currentTiles = new Set(this.current ? resolveGlobeTerrainDisplay(this.current) : []);
225
+ const reserved = this.getOwnedTiles().reduce((sum, tile) => {
226
+ const statistics = tile.surface.getStatistics();
227
+ return sum + (currentTiles.has(tile) && statistics.requiredQueryIndexBytes <= tile.queryLimit ? statistics.requiredQueryIndexBytes : statistics.queryIndexBytes);
228
+ }, 0);
229
+ if (reserved + required <= this.maxQueryIndexBytes) {
230
+ yield* surface.prepareQueryIndexSteps(); this.statistics.warmedIndexes++;
231
+ } else this.statistics.deferredIndexes++;
232
+ }
233
+ }
234
+ this.statistics.pending--; yield;
235
+ }
236
+ const snapshot = Object.freeze({terrain, exaggeration, byteLength: this.staged.reduce((sum, tile) => sum + displayBytes(tile.template), 0), maxDisplayBytes: this.maxDisplayBytes, maxQueryIndexBytes: this.maxQueryIndexBytes});
237
+ displays.set(snapshot, this.staged); this.current = snapshot; this.staged = []; this.statistics.committed++; this.releaseUnusedLeases();
238
+ } finally {
239
+ const adopted = new Set([...this.leases.values()].flat());
240
+ for (const reservation of reservations) if (!adopted.has(reservation)) reservation.release();
241
+ }
242
+ }
243
+ }