@tangram.gl/tangram-layers 0.0.0-stage → 0.0.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.
Files changed (43) hide show
  1. package/README.md +35 -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,301 @@
1
+ // tangram-layers
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) vis.gl contributors
4
+
5
+ import {Layer} from '@deck.gl/core';
6
+ import {Matrix4} from '@math.gl/core';
7
+ import {HostFrame, Renderer, normalizeProjectedBasemapOptions, countProjectedTileCoordinates, getProjectedRoadUnit, validateProjectedLights} from '@tangram.gl/tangram-renderer/core';
8
+ import type {HostFrameOptions, HostTileResourceOptions, ProjectedBasemapOptions} from '@tangram.gl/tangram-renderer/core';
9
+ import createTangramLayerClass from '../tangram-layer.js';
10
+
11
+ export {ProjectedBasemapNavigation, getProjectedGeographicBounds} from './projected-navigation';
12
+ export type {ProjectedNavigationViewport, ProjectedBasemapType, ProjectedGeographicBounds,
13
+ ProjectedGeographicPosition, ProjectedFitOptions, ProjectedFitViewState,
14
+ ProjectedCameraCoverage, ProjectedFocusTransition, ProjectedNavigationRequestOptions} from './projected-navigation';
15
+ export {selectProjectedTileDetail} from './projected-detail';
16
+ export type {ProjectedTileDetailOptions, ProjectedTileDetail} from './projected-detail';
17
+
18
+ /** The public, non-geospatial OrthographicViewport camera consumed by the adapter. */
19
+ export interface ProjectedViewport {
20
+ /** Viewports must use Cartesian common-space positions, not automatic Mercator offsets. */
21
+ isGeospatial: boolean;
22
+ /** Viewport width in CSS pixels. */
23
+ width: number;
24
+ /** Viewport height in CSS pixels. */
25
+ height: number;
26
+ /** World-to-camera matrix supplied by deck.gl. */
27
+ viewMatrix: ArrayLike<number>;
28
+ /** Camera-to-clip matrix supplied by deck.gl. */
29
+ projectionMatrix: ArrayLike<number>;
30
+ /** Explicit deck.gl orthographic contract; perspective viewports are rejected. */
31
+ constructor: {displayName?: string; name?: string};
32
+ }
33
+
34
+ /** Explicit geographic coverage and source detail; viewport zoom is not geographic style zoom. */
35
+ export interface ProjectedViewOptions {
36
+ /** Must match the scene's worker projection; Albers initially uses a North American footprint. */
37
+ projection: ProjectedBasemapOptions;
38
+ /** Single-world west/south/east/north degrees; no poles or arbitrary-cut meridians. */
39
+ visibleBounds: readonly [number, number, number, number];
40
+ /** Source data detail in [0, 6], deliberately independent of OrthographicView zoom. */
41
+ tileZoom: number;
42
+ /** Styling level in [0, 22], no lower than data detail; defaults to tileZoom. */
43
+ styleZoom?: number;
44
+ /** Optional positive per-source candidate limit checked before any tile allocation. */
45
+ maxTiles?: number;
46
+ /** Shared worker concurrency and completed off-screen mesh cache limits. */
47
+ tileResources?: HostTileResourceOptions;
48
+ }
49
+
50
+ /** Supply a common-space orthographic camera without claiming its matrices contain EPSG:3857 meters. */
51
+ export function getProjectedViewFrame(viewport: ProjectedViewport, options: ProjectedViewOptions,
52
+ dimensions = {width: viewport.width, height: viewport.height}): HostFrameOptions {
53
+ if (viewport.isGeospatial !== false ||
54
+ (viewport.constructor.displayName !== 'OrthographicViewport' && viewport.constructor.name !== 'OrthographicViewport')) {
55
+ throw new Error('CPU-projected basemaps require deck.gl OrthographicView');
56
+ }
57
+ const cameraProjection = new Matrix4(Array.from(viewport.projectionMatrix)).multiplyRight(Array.from(viewport.viewMatrix));
58
+ const [west, south, east, north] = options.visibleBounds;
59
+ const projection = normalizeProjectedBasemapOptions(options.projection);
60
+ const styleZoom = options.styleZoom ?? options.tileZoom;
61
+ if (!Number.isSafeInteger(styleZoom) || styleZoom < 0 || styleZoom > 22) {
62
+ throw new Error('Projected styleZoom must be an integer in [0, 22]');
63
+ }
64
+ if (options.maxTiles !== undefined && (!Number.isSafeInteger(options.maxTiles) || options.maxTiles < 1)) {
65
+ throw new Error('Projected maxTiles must be a positive safe integer');
66
+ }
67
+ if (projection.type === 'albers' && (west < -170 || east > -40 || south < 5 || north > 75)) {
68
+ throw new Error('Initial Albers coverage must remain within [-170, 5, -40, 75] degrees');
69
+ }
70
+ const frame: HostFrameOptions = {
71
+ viewport: dimensions,
72
+ projection: {type: 'projected', visibleBounds: options.visibleBounds},
73
+ geographicAnchor: {longitude: (west + east) / 2, latitude: (south + north) / 2, zoom: styleZoom},
74
+ tileZoom: options.tileZoom,
75
+ tileBuffer: 0,
76
+ ...(options.tileResources === undefined ? {} : {tileResources: options.tileResources}),
77
+ renderViews: [{id: 'projected', camera: {view: new Float64Array(new Matrix4()),
78
+ projection: new Float64Array(cameraProjection), position: [0, 0, 1]}}]
79
+ };
80
+ // Validate before any renderer frame or tile state is modified.
81
+ new HostFrame(frame);
82
+ if (options.maxTiles !== undefined) {
83
+ const count = countProjectedTileCoordinates(options.visibleBounds, options.tileZoom);
84
+ if (count > options.maxTiles) {
85
+ throw new Error(`Projected footprint requires ${count} tiles per source, exceeding maxTiles ${options.maxTiles}`);
86
+ }
87
+ }
88
+ return frame;
89
+ }
90
+
91
+ /** Add the isolated projection worker to an inline scene without mutating its sources or style definitions. */
92
+ export function createProjectedBasemapScene(scene: Record<string, unknown>, projection: ProjectedBasemapOptions,
93
+ workerUrl: string): Record<string, unknown> {
94
+ const options = normalizeProjectedBasemapOptions(projection);
95
+ validateProjectedLights(scene.lights);
96
+ const url = new URL(workerUrl);
97
+ if (!['http:', 'https:', 'blob:'].includes(url.protocol)) throw new Error('Projection worker requires an HTTP(S) or Blob URL');
98
+ if (scene.import !== undefined) throw new Error('Initial projected basemaps require a self-contained inline scene');
99
+ const styles = readRecord(scene.styles ?? {}, 'styles');
100
+ for (const value of Object.values(styles)) {
101
+ const style = readRecord(value, 'style');
102
+ if (!['polygons', 'raster', 'lines', 'points', 'text'].includes(String(style.base)) || style.mix !== undefined ||
103
+ style.shaders !== undefined || (style.lighting !== undefined && style.lighting !== false &&
104
+ (!['polygons', 'raster'].includes(String(style.base)) || !['vertex', 'fragment'].includes(String(style.lighting))))) {
105
+ throw new Error('Projected styles require ground/annotation bases or vertex/fragment-lit surfaces without mixins or shaders');
106
+ }
107
+ if (style.material !== undefined) {
108
+ const material = readRecord(style.material, 'material');
109
+ if (material.normal !== undefined || (material.specular !== undefined && material.specular !== 0)) {
110
+ throw new Error('Projected lighting supports diffuse materials without normal maps or specular terms');
111
+ }
112
+ }
113
+ if (style.draw !== undefined) validateFlatDraw(readRecord(style.draw, 'style draw defaults'),
114
+ Boolean(options.allowElevation && ['polygons', 'raster', 'points', 'text'].includes(String(style.base))));
115
+ if (style.draw !== undefined && ['points', 'text'].includes(String(style.base))) {
116
+ validateAnnotationDraw(readRecord(style.draw, 'annotation defaults'));
117
+ }
118
+ if (style.base === 'raster' && style.draw !== undefined && readRecord(style.draw, 'raster defaults').interactive === true) {
119
+ throw new Error('Raster imagery does not expose selectable source features');
120
+ }
121
+ if (style.base === 'lines') {
122
+ validateRoadDraw(style, false);
123
+ if (style.draw !== undefined) validateRoadDraw(readRecord(style.draw, 'style draw defaults'), false);
124
+ }
125
+ }
126
+ for (const layer of Object.values(readRecord(scene.layers ?? {}, 'layers'))) {
127
+ validateProjectedDraws(readRecord(layer, 'root layer'), styles, {}, Boolean(options.allowElevation));
128
+ }
129
+ const settings = scene.scene;
130
+ if (settings !== undefined && (!settings || typeof settings !== 'object' || Array.isArray(settings))) {
131
+ throw new Error('Projected scene settings must be an object');
132
+ }
133
+ const scripts = settings && 'scripts' in settings ? settings.scripts : [];
134
+ if (!Array.isArray(scripts) || !scripts.every(script => typeof script === 'string')) {
135
+ throw new Error('Projected scene scripts must be an array of URLs');
136
+ }
137
+ return {...scene, scene: {...settings, scripts: [...new Set([...scripts, url.href])], cpu_projection: options}};
138
+ }
139
+
140
+ /** Reject unsupported authored features before any worker starts or a partial map can appear. */
141
+ function validateProjectedDraws(node: Record<string, unknown>, styles: Record<string, unknown>,
142
+ inheritedDraws: Record<string, Record<string, unknown>> = {}, allowElevation = false): void {
143
+ const effectiveDraws = {...inheritedDraws};
144
+ if (node.draw !== undefined) {
145
+ for (const [name, value] of Object.entries(readRecord(node.draw, 'draw'))) {
146
+ const draw = mergeAnnotationDraws(effectiveDraws[name] ?? {}, readRecord(value, 'draw style'));
147
+ effectiveDraws[name] = draw;
148
+ const styleName = String(draw.style ?? name);
149
+ const style = styleName in styles ? readRecord(styles[styleName], 'draw style definition') : {base: styleName};
150
+ if (!['polygons', 'raster', 'lines', 'points', 'text'].includes(String(style.base))) {
151
+ throw new Error('Projected basemaps require supported surface, road or annotation bases');
152
+ }
153
+ validateFlatDraw(draw, allowElevation && ['polygons', 'raster', 'points', 'text'].includes(String(style.base)));
154
+ if (style.base === 'raster' && draw.interactive === true) throw new Error('Raster imagery does not expose selectable source features');
155
+ if (style.base === 'points' || style.base === 'text') {
156
+ const defaults = style.draw === undefined ? {} : readRecord(style.draw, 'annotation defaults');
157
+ const annotation = mergeAnnotationDraws(defaults, draw);
158
+ validateFlatDraw(annotation, allowElevation);
159
+ validateAnnotationDraw(annotation);
160
+ if (annotation.collide !== undefined && typeof annotation.collide !== 'boolean') throw new Error('Projected collide must be boolean');
161
+ if (annotation.text !== undefined) {
162
+ const text = readRecord(annotation.text, 'attached text');
163
+ if (text.collide !== undefined && typeof text.collide !== 'boolean') throw new Error('Projected collide must be boolean');
164
+ validateFlatDraw(text);
165
+ }
166
+ }
167
+ if (style.base === 'lines') {
168
+ const defaults = style.draw === undefined ? {} : readRecord(style.draw, 'style draw defaults');
169
+ validateRoadDraw({...defaults, ...draw}, true);
170
+ }
171
+ }
172
+ }
173
+ for (const [key, value] of Object.entries(node)) {
174
+ // Match Tangram layer parsing: configuration records are not child layers.
175
+ if (!['filter', 'draw', 'visible', 'enabled', 'data', 'exclusive', 'priority'].includes(key) &&
176
+ value && typeof value === 'object' && !Array.isArray(value)) {
177
+ validateProjectedDraws(readRecord(value, 'layer'), styles, effectiveDraws, allowElevation);
178
+ }
179
+ }
180
+ }
181
+
182
+ /** Preserve nested attached-text settings when applying style defaults or child-layer overrides. */
183
+ function mergeAnnotationDraws(defaults: Record<string, unknown>, draw: Record<string, unknown>): Record<string, unknown> {
184
+ const merged = {...defaults, ...draw};
185
+ if (defaults.text && typeof defaults.text === 'object' && !Array.isArray(defaults.text) &&
186
+ draw.text && typeof draw.text === 'object' && !Array.isArray(draw.text)) {
187
+ merged.text = {...readRecord(defaults.text, 'attached text defaults'), ...readRecord(draw.text, 'attached text')};
188
+ }
189
+ return merged;
190
+ }
191
+
192
+ /** Apply height opt-in to layer draws and inherited style defaults; interactive draws opt into selection. */
193
+ function validateFlatDraw(draw: Record<string, unknown>, allowElevation = false): void {
194
+ if (!allowElevation && ((draw.extrude !== undefined && draw.extrude !== false) || draw.z !== undefined)) {
195
+ throw new Error('Projected basemaps require flat draws unless allowElevation is enabled');
196
+ }
197
+ }
198
+
199
+ /** Billboards may have altitude, not extrusion; attached text inherits its point's anchor height. */
200
+ function validateAnnotationDraw(draw: Record<string, unknown>): void {
201
+ if (draw.extrude !== undefined && draw.extrude !== false) throw new Error('Projected annotations do not support extrusion');
202
+ if (draw.text !== undefined && draw.text !== false) validateFlatDraw(readRecord(draw.text, 'attached text'));
203
+ }
204
+
205
+ /** Copy validated object records without accepting arrays or prototype properties as configuration. */
206
+ function readRecord(value: unknown, label: string): Record<string, unknown> {
207
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`Projected ${label} must be an object`);
208
+ return Object.fromEntries(Object.entries(value));
209
+ }
210
+
211
+ /** Fixed and zoom-stop meter/pixel roads share the builder; reject functions and mixed-unit offsets/outlines. */
212
+ function validateRoadDraw(draw: Record<string, unknown>, requireWidth: boolean): void {
213
+ if (draw.outline !== undefined) readRecord(draw.outline, 'road outline');
214
+ if (['next_width', 'next_offset', 'texture'].some(key => draw[key] !== undefined)) {
215
+ throw new Error('Projected roads require static ribbons without dynamic widths or external textures');
216
+ }
217
+ if (draw.width !== undefined || requireWidth) {
218
+ const unit = getProjectedRoadUnit(draw.width);
219
+ if (draw.offset !== undefined && getProjectedRoadUnit(draw.offset, false) !== unit) throw new Error('Projected road offsets must use the width unit');
220
+ if (draw.outline !== undefined) {
221
+ const outline = readRecord(draw.outline, 'road outline');
222
+ validateFlatDraw(outline);
223
+ if (outline.style !== undefined) throw new Error('Projected road outlines must use the parent line style');
224
+ if (getProjectedRoadUnit(outline.width) !== unit) throw new Error('Projected road outlines must use the width unit');
225
+ validateRoadDraw(outline, true);
226
+ }
227
+ }
228
+ if (draw.animated !== undefined && typeof draw.animated !== 'boolean') throw new Error('Projected road animated must be boolean');
229
+ if (draw.dash !== undefined && (!Array.isArray(draw.dash) || draw.dash.length === 0 ||
230
+ !draw.dash.every(value => typeof value === 'number' && Number.isFinite(value) && value > 0))) {
231
+ throw new Error('Projected road dash must contain positive finite lengths');
232
+ }
233
+ }
234
+
235
+ /** Opt-in deck.gl basemap layer; the ordinary package root still uses its existing view adapters. */
236
+ const BaseProjectedBasemapLayer = createTangramLayerClass({Layer, ClassicWebGLRenderer: Renderer,
237
+ Renderer: undefined}, {
238
+ getFrame(viewport: ProjectedViewport, properties: {scene: unknown; projectedVisibleBounds?: ProjectedViewOptions['visibleBounds'];
239
+ projectedTileZoom?: number; projectedProjection?: ProjectedBasemapOptions;
240
+ projectedStyleZoom?: number | null; projectedMaxTiles?: number | null;
241
+ tileResources?: HostTileResourceOptions | null}, dimensions: {width: number; height: number}) {
242
+ const scene = readRecord(properties.scene, 'scene');
243
+ const settings = readRecord(scene.scene, 'scene settings');
244
+ const projection = normalizeProjectedBasemapOptions(properties.projectedProjection ?? settings.cpu_projection);
245
+ return getProjectedViewFrame(viewport, {
246
+ projection,
247
+ visibleBounds: properties.projectedVisibleBounds ?? (projection.type === 'albers' ? [-170, 5, -40, 75] :
248
+ [-180, -85.0511287798066, 180, 85.0511287798066]),
249
+ tileZoom: properties.projectedTileZoom ?? 2,
250
+ ...(properties.projectedStyleZoom == null ? {} : {styleZoom: properties.projectedStyleZoom}),
251
+ ...(properties.projectedMaxTiles == null ? {} : {maxTiles: properties.projectedMaxTiles}),
252
+ ...(properties.tileResources == null ? {} : {tileResources: properties.tileResources})
253
+ }, dimensions);
254
+ }
255
+ });
256
+
257
+ /** Opt-in projection changes rebuild meshes on the existing renderer instead of reloading the scene. */
258
+ export class ProjectedBasemapLayer extends BaseProjectedBasemapLayer {
259
+ /**
260
+ * Query interactive Tangram geometry asynchronously in viewport-local CSS pixels.
261
+ * This is not deck.gl's synchronous picking API. The host must keep rendering
262
+ * until the selection pass and worker lookup finish; the layer requests a redraw.
263
+ */
264
+ getFeatureAt(pixel: {x: number; y: number}, options: import('@tangram.gl/tangram-renderer/core').FeatureSelectionOptions = {}): Promise<import('@tangram.gl/tangram-renderer/core').FeatureSelectionResult | undefined> {
265
+ return super.getFeatureAt(pixel, options);
266
+ }
267
+ /** deck.gl's stable layer identity for state transfer between property updates. */
268
+ static layerName = 'ProjectedBasemapLayer';
269
+ /** Optional projection override and completion notification, independent of source scene identity. */
270
+ static defaultProps = {...BaseProjectedBasemapLayer.defaultProps,
271
+ projectedVisibleBounds: null, projectedTileZoom: 2, projectedProjection: null,
272
+ projectedStyleZoom: null, projectedMaxTiles: null,
273
+ onProjectionChange: {type: 'function', value: () => {}}};
274
+
275
+ /** Keep the ordinary scene lifecycle, then queue an opt-in projection-only update after loading. */
276
+ updateState(parameters: {props: Partial<import('../tangram-layer').TangramLayerProps>}): void {
277
+ super.updateState(parameters);
278
+ const record = this.state.tangramRecord;
279
+ if (!record) return;
280
+ const settings = readRecord(readRecord(record.sceneSource, 'scene').scene, 'scene settings');
281
+ const projection = normalizeProjectedBasemapOptions(parameters.props.projectedProjection ?? settings.cpu_projection);
282
+ const key = JSON.stringify(projection);
283
+ if (record.projectedProjectionKey === key) return;
284
+ record.projectedProjectionKey = key;
285
+ Promise.resolve(record.loadPromise).then(async () => {
286
+ if (record.disposed || record.loadFailed || record.projectedProjectionKey !== key) return;
287
+ await record.renderer.setProjectedBasemapProjection(projection);
288
+ if (!record.disposed && record.projectedProjectionKey === key) {
289
+ record.owner.props.onProjectionChange!(projection);
290
+ record.owner.setNeedsRedraw();
291
+ }
292
+ }).catch(error => {
293
+ if (!record.disposed && record.projectedProjectionKey === key) {
294
+ record.projectedProjectionKey = null;
295
+ record.owner._reportSceneError(record, error);
296
+ }
297
+ });
298
+ }
299
+ }
300
+
301
+ export type {ProjectedBasemapOptions};
@@ -0,0 +1,116 @@
1
+ // tangram-layers
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) vis.gl contributors
4
+
5
+ import {countProjectedTileCoordinates, WebMercatorVisibilityAdapter, projectGeographicPosition,
6
+ getTileGeographicBounds} from '@tangram.gl/tangram-renderer/core';
7
+ import {validateProjectedGeographicBounds, validateProjectedNavigationViewport} from './projected-navigation';
8
+ import type {ProjectedBasemapNavigation, ProjectedBasemapType, ProjectedGeographicBounds,
9
+ ProjectedNavigationViewport} from './projected-navigation';
10
+
11
+ /** Explicit opt-in uniform source detail policy, independent of geographic style zoom. */
12
+ export interface ProjectedTileDetailOptions {
13
+ /** Fixed geographic loading region; this policy does not infer visible geographic bounds. */
14
+ visibleBounds: ProjectedGeographicBounds;
15
+ /** Lowest permissible data level in [0, 6]; defaults to 0. */
16
+ minZoom?: number;
17
+ /** Highest permissible data level in [0, 6]; defaults to 6. */
18
+ maxZoom?: number;
19
+ /** Desired maximum sampled tile span in CSS pixels; defaults to 256. */
20
+ targetTilePixels?: number;
21
+ /** Positive per-source candidate limit; defaults to 256. */
22
+ maxTiles?: number;
23
+ /** Previous level, used only if it remains in range and within budget. */
24
+ currentTileZoom?: number;
25
+ /** Fractional hysteresis around adjacent-level thresholds in [0, 0.5); defaults to 0.15. */
26
+ hysteresis?: number;
27
+ }
28
+
29
+ /** Diagnostic result; exhausting resource/detail bounds is explicit rather than hidden. */
30
+ export interface ProjectedTileDetail {
31
+ /** Selected source detail, suitable for projectedTileZoom. */
32
+ tileZoom: number;
33
+ /** Actual single-world logical candidates per source at that detail. */
34
+ candidateCount: number;
35
+ /** Maximum screen span of the sampled, domain-clipped tile footprints. */
36
+ estimatedTilePixels: number;
37
+ /** Whether a finer level was unavailable because it exceeds maxTiles. */
38
+ budgetLimited: boolean;
39
+ /** Whether the maximum configured detail still cannot reach the target pixel span. */
40
+ detailLimited: boolean;
41
+ }
42
+
43
+ /** Choose uniform XYZ detail from projected tile samples, camera scale, hysteresis and candidate limits. */
44
+ export async function selectProjectedTileDetail(navigation: ProjectedBasemapNavigation, viewport: ProjectedNavigationViewport,
45
+ type: ProjectedBasemapType, options: ProjectedTileDetailOptions): Promise<ProjectedTileDetail> {
46
+ validateProjectedNavigationViewport(viewport);
47
+ const {visibleBounds, minZoom = 0, maxZoom = 6, targetTilePixels = 256, maxTiles = 256, currentTileZoom,
48
+ hysteresis = 0.15} = options;
49
+ validateProjectedGeographicBounds(visibleBounds, type);
50
+ if (![minZoom, maxZoom].every(value => Number.isSafeInteger(value) && value >= 0 && value <= 6) || minZoom > maxZoom ||
51
+ !Number.isFinite(targetTilePixels) || targetTilePixels <= 0 || !Number.isSafeInteger(maxTiles) || maxTiles < 1 ||
52
+ !Number.isFinite(hysteresis) || hysteresis < 0 || hysteresis >= 0.5 ||
53
+ (currentTileZoom !== undefined && (!Number.isSafeInteger(currentTileZoom) || currentTileZoom < 0 || currentTileZoom > 6))) {
54
+ throw new Error('Projected tile detail requires ordered levels 0–6, positive pixel/candidate limits and finite hysteresis');
55
+ }
56
+ const candidates: {zoom: number; count: number}[] = [];
57
+ for (let zoom = minZoom; zoom <= maxZoom; zoom++) {
58
+ const count = countProjectedTileCoordinates(visibleBounds, zoom);
59
+ if (count > maxTiles) break;
60
+ candidates.push({zoom, count});
61
+ }
62
+ if (!candidates.length) throw new Error('Projected minimum tile detail exceeds the candidate budget');
63
+ const southwest = projectGeographicPosition([visibleBounds[0], visibleBounds[1], 0], 'web-mercator');
64
+ const northeast = projectGeographicPosition([visibleBounds[2], visibleBounds[3], 0], 'web-mercator');
65
+ const traversal = new WebMercatorVisibilityAdapter();
66
+ const measurements = new Map<number, number>();
67
+ const measure = async (zoom: number) => {
68
+ const cached = measurements.get(zoom);
69
+ if (cached !== undefined) return cached;
70
+ const tiles = traversal.findVisibleTileCoordinates({center: {lng: 0, lat: 0}, zoom, tile_zoom: zoom,
71
+ size: {css: {width: viewport.width, height: viewport.height}}, buffer: 0, wrap: false,
72
+ bounds: {sw: {x: southwest[0], y: southwest[1]}, ne: {x: northeast[0], y: northeast[1]}}});
73
+ const coordinates: number[] = [];
74
+ for (const tile of tiles) {
75
+ const bounds = getTileGeographicBounds(tile);
76
+ const west = Math.max(visibleBounds[0], bounds.west), south = Math.max(visibleBounds[1], bounds.south);
77
+ const east = Math.min(visibleBounds[2], bounds.east), north = Math.min(visibleBounds[3], bounds.north);
78
+ // Nine samples per clipped candidate; interior samples capture bowed regional projections.
79
+ for (let column = 0; column <= 2; column++) for (let row = 0; row <= 2; row++) {
80
+ coordinates.push(west + (east - west) * column / 2, south + (north - south) * row / 2);
81
+ }
82
+ }
83
+ const positions = await navigation.projectPositions(new Float64Array(coordinates), type);
84
+ let maximum = 0;
85
+ for (let offset = 0; offset < positions.length; offset += 18) {
86
+ let west = Infinity, south = Infinity, east = -Infinity, north = -Infinity;
87
+ for (let index = offset; index < offset + 18; index += 2) {
88
+ const screen = viewport.project([positions[index], positions[index + 1], 0]);
89
+ if (![screen[0], screen[1]].every(Number.isFinite)) throw new Error('Projected tile detail produced nonfinite screen positions');
90
+ west = Math.min(west, screen[0]); east = Math.max(east, screen[0]);
91
+ south = Math.min(south, screen[1]); north = Math.max(north, screen[1]);
92
+ }
93
+ maximum = Math.max(maximum, east - west, north - south);
94
+ }
95
+ measurements.set(zoom, maximum);
96
+ return maximum;
97
+ };
98
+ let selected = candidates[candidates.length - 1];
99
+ const current = candidates.find(candidate => candidate.zoom === currentTileZoom);
100
+ if (current) {
101
+ const pixels = await measure(current.zoom);
102
+ const coarser = candidates.find(candidate => candidate.zoom === current.zoom - 1);
103
+ if (pixels <= targetTilePixels * (1 + hysteresis) &&
104
+ (!coarser || await measure(coarser.zoom) > targetTilePixels * (1 - hysteresis))) selected = current;
105
+ else {
106
+ for (const candidate of candidates) if (await measure(candidate.zoom) <= targetTilePixels) {selected = candidate; break;}
107
+ }
108
+ } else {
109
+ for (const candidate of candidates) if (await measure(candidate.zoom) <= targetTilePixels) {selected = candidate; break;}
110
+ }
111
+ const estimatedTilePixels = await measure(selected.zoom);
112
+ const unmet = estimatedTilePixels > targetTilePixels * (1 + hysteresis);
113
+ return {tileZoom: selected.zoom, candidateCount: selected.count, estimatedTilePixels,
114
+ budgetLimited: unmet && candidates[candidates.length - 1].zoom < maxZoom,
115
+ detailLimited: unmet && selected.zoom === maxZoom};
116
+ }