isard 0.0.0-stage → 0.2.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 (84) hide show
  1. package/CHANGELOG.md +176 -0
  2. package/LICENSE +21 -0
  3. package/README.md +150 -2
  4. package/dist/basemap.worker.d.ts +76 -0
  5. package/dist/basemap.worker.js +4 -0
  6. package/dist/basemap.worker.js.map +13 -0
  7. package/dist/basemaps.d.ts +48 -0
  8. package/dist/basemaps.js +97 -0
  9. package/dist/basemaps.js.map +1 -0
  10. package/dist/color.d.ts +8 -0
  11. package/dist/color.js +98 -0
  12. package/dist/color.js.map +1 -0
  13. package/dist/controls.d.ts +43 -0
  14. package/dist/controls.js +115 -0
  15. package/dist/controls.js.map +1 -0
  16. package/dist/decoder.worker.js +6 -0
  17. package/dist/decoder.worker.js.map +12 -0
  18. package/dist/decoders.d.ts +27 -0
  19. package/dist/decoders.js +109 -0
  20. package/dist/decoders.js.map +1 -0
  21. package/dist/equirectangular.d.ts +35 -0
  22. package/dist/equirectangular.js +143 -0
  23. package/dist/equirectangular.js.map +1 -0
  24. package/dist/extrusions.d.ts +79 -0
  25. package/dist/extrusions.js +224 -0
  26. package/dist/extrusions.js.map +1 -0
  27. package/dist/features.d.ts +159 -0
  28. package/dist/features.js +387 -0
  29. package/dist/features.js.map +1 -0
  30. package/dist/geoplateforme.d.ts +35 -0
  31. package/dist/geoplateforme.js +56 -0
  32. package/dist/geoplateforme.js.map +1 -0
  33. package/dist/glyphs.d.ts +104 -0
  34. package/dist/glyphs.js +383 -0
  35. package/dist/glyphs.js.map +1 -0
  36. package/dist/index.d.ts +34 -0
  37. package/dist/index.js +25 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/interaction.d.ts +53 -0
  40. package/dist/interaction.js +259 -0
  41. package/dist/interaction.js.map +1 -0
  42. package/dist/isard_bg.wasm +0 -0
  43. package/dist/isard_core_bg.wasm +0 -0
  44. package/dist/layers.d.ts +103 -0
  45. package/dist/layers.js +290 -0
  46. package/dist/layers.js.map +1 -0
  47. package/dist/loader.d.ts +30 -0
  48. package/dist/loader.js +102 -0
  49. package/dist/loader.js.map +1 -0
  50. package/dist/map.d.ts +211 -0
  51. package/dist/map.js +1222 -0
  52. package/dist/map.js.map +1 -0
  53. package/dist/mvt.d.ts +19 -0
  54. package/dist/mvt.js +231 -0
  55. package/dist/mvt.js.map +1 -0
  56. package/dist/overlay.d.ts +118 -0
  57. package/dist/overlay.js +334 -0
  58. package/dist/overlay.js.map +1 -0
  59. package/dist/protocol.d.ts +30 -0
  60. package/dist/protocol.js +2 -0
  61. package/dist/protocol.js.map +1 -0
  62. package/dist/style.d.ts +7 -0
  63. package/dist/style.js +130 -0
  64. package/dist/style.js.map +1 -0
  65. package/dist/symbols.d.ts +61 -0
  66. package/dist/symbols.js +238 -0
  67. package/dist/symbols.js.map +1 -0
  68. package/dist/terrain.d.ts +14 -0
  69. package/dist/terrain.js +56 -0
  70. package/dist/terrain.js.map +1 -0
  71. package/dist/types.d.ts +312 -0
  72. package/dist/types.js +2 -0
  73. package/dist/types.js.map +1 -0
  74. package/dist/vectorstyle.d.ts +83 -0
  75. package/dist/vectorstyle.js +518 -0
  76. package/dist/vectorstyle.js.map +1 -0
  77. package/dist/vectortiles.d.ts +124 -0
  78. package/dist/vectortiles.js +523 -0
  79. package/dist/vectortiles.js.map +1 -0
  80. package/dist/wasm/isard.d.ts +612 -0
  81. package/dist/wasm/isard.js +2623 -0
  82. package/dist/wasm/isard_core.d.ts +469 -0
  83. package/dist/wasm/isard_core.js +2385 -0
  84. package/package.json +49 -4
package/dist/map.js ADDED
@@ -0,0 +1,1222 @@
1
+ /**
2
+ * The map: canvas, camera, layers, events and the frame loop. Frames are drawn on demand only: an idle map costs
3
+ * nothing. Hover events are coalesced to one per frame, so the values computed for a mouse position are drawn in the
4
+ * same frame.
5
+ */
6
+ import { BASEMAPS, BODY_TERRAINS, basemapName, HILLSHADE, IGN_ATTRIBUTION, IMAGE_SERVER, imageServerTerrain, SEA, TERRAIN_TILES, TERRAIN_TILES_ATTRIBUTION, TERRAIN_URL, } from "./basemaps.js";
7
+ import { parseCssColor } from "./color.js";
8
+ import { AttributionControl } from "./controls.js";
9
+ import { DEFAULT_DECODERS } from "./decoders.js";
10
+ import { equirectangularLoader } from "./equirectangular.js";
11
+ import { ExtrusionLayer, extrusionStyle } from "./extrusions.js";
12
+ import { CircleLayer, circleStyle, FillLayer, fillStyle, LineLayer, lineStyle, } from "./features.js";
13
+ import { Glyphs } from "./glyphs.js";
14
+ import { Interaction } from "./interaction.js";
15
+ import { GroundLayer, NetworkLayer, RasterLayer, Tiles3dLayer } from "./layers.js";
16
+ import { TileLoader } from "./loader.js";
17
+ import { injectStyle } from "./style.js";
18
+ import { SymbolLayer, symbolStyle } from "./symbols.js";
19
+ import { encodedTerrainLoader } from "./terrain.js";
20
+ import { BasemapWorkers, defaultWorkerCount, defaultWorkerUrl, VECTOR_BASEMAPS, VectorTiles, } from "./vectortiles.js";
21
+ /** The engine of a destroyed map: every method a no-op returning undefined, but the camera (the last one) and the
22
+ * bounds, which late callers read */
23
+ function destroyed(camera) {
24
+ return new Proxy({}, {
25
+ get: (_, name) => {
26
+ if (name === 'camera')
27
+ return () => camera;
28
+ if (name === 'bounds')
29
+ return () => Float64Array.of(-180, -85, 180, 85);
30
+ return () => undefined;
31
+ },
32
+ });
33
+ }
34
+ /** Reports an error without throwing it here: the console shows it, as an uncaught one */
35
+ function report(error) {
36
+ if (typeof reportError === 'function')
37
+ reportError(error);
38
+ else
39
+ queueMicrotask(() => {
40
+ throw error;
41
+ });
42
+ }
43
+ /** The two builds of the engine, each compiled once per page: `core` without 3D Tiles (a third smaller), loaded by
44
+ * every map; `full` with them, loaded for the first 3D Tiles layer, its module shared with the decoding workers. The
45
+ * core engine has the same API but the 3D Tiles methods: its class is typed as the full one. */
46
+ const engines = {};
47
+ /** The modules compiled (downloaded and compiled off the main thread, not instantiated): `tiles3d: 'preload'`
48
+ * compiles the full engine ahead, its first 3D Tiles layer then only instantiates it */
49
+ const modules = {};
50
+ /** @internal Forgets the engines loaded (tests) */
51
+ export function forgetEngines() {
52
+ for (const kind of ['core', 'full']) {
53
+ delete engines[kind];
54
+ delete modules[kind];
55
+ }
56
+ }
57
+ function compileEngine(kind, url) {
58
+ const compiled = modules[kind];
59
+ if (compiled)
60
+ return compiled;
61
+ const compiling = compile(url).catch((error) => {
62
+ // A later call tries again (a network error, a wrong URL fixed)
63
+ delete modules[kind];
64
+ throw error;
65
+ });
66
+ modules[kind] = compiling;
67
+ return compiling;
68
+ }
69
+ function loadEngine(kind, url) {
70
+ const loaded = engines[kind];
71
+ if (loaded)
72
+ return loaded;
73
+ const loading = (async () => {
74
+ // The bindings by dynamic import: an application bundles the ones it loads only
75
+ const [module, bindings] = await Promise.all([
76
+ compileEngine(kind, url),
77
+ kind === 'core' ? import('./wasm/isard_core.js') : import('./wasm/isard.js'),
78
+ ]);
79
+ await bindings.default({ module_or_path: module });
80
+ return { module, Engine: bindings.Engine };
81
+ })().catch((error) => {
82
+ delete engines[kind];
83
+ throw error;
84
+ });
85
+ engines[kind] = loading;
86
+ return loading;
87
+ }
88
+ async function compile(url) {
89
+ const response = await fetch(url);
90
+ if (!response.ok)
91
+ throw new Error(`isard: failed to load the engine from ${url}: ${response.status}`);
92
+ try {
93
+ return await WebAssembly.compileStreaming(response.clone());
94
+ }
95
+ catch {
96
+ // Servers that do not send application/wasm
97
+ return WebAssembly.compile(await response.arrayBuffer());
98
+ }
99
+ }
100
+ /** Checks a network's arrays before the engine reads them: node index pairs, indices of existing nodes */
101
+ function validateNetwork(nodes, edges) {
102
+ if (nodes.length % 2 !== 0)
103
+ throw new Error(`isard: network nodes must be lon, lat pairs (length ${nodes.length})`);
104
+ if (edges.length % 2 !== 0)
105
+ throw new Error(`isard: network edges must be node index pairs (length ${edges.length})`);
106
+ const count = nodes.length / 2;
107
+ for (let i = 0; i < edges.length; i += 1) {
108
+ const node = edges[i];
109
+ if (node >= count)
110
+ throw new Error(`isard: network edge ${i >> 1} refers to node ${node}, of ${count} nodes`);
111
+ }
112
+ }
113
+ /** The named bodies; a body's `color` is that of its globe under the tiles (the Earth's follows its basemap), its
114
+ * `relief` the range of its ground (the Earth's dry land, from the Dead Sea to Everest). Mars: the sphere of its
115
+ * equatorial radius (its data take planetocentric latitudes), a faint butterscotch halo of dust */
116
+ const BODIES = {
117
+ earth: { radius: 6378137, flattening: 1 / 298.257223563, atmosphere: true, color: '#cacaca', relief: [-450, 8900] },
118
+ moon: { radius: 1737400, flattening: 0, atmosphere: false, color: '#77756f', relief: [-9200, 10800] },
119
+ mars: {
120
+ radius: 3396190,
121
+ flattening: 0,
122
+ atmosphere: 'rgba(232, 168, 120, 0.6)',
123
+ color: '#b5714a',
124
+ relief: [-8300, 21300],
125
+ },
126
+ };
127
+ /** The Earth's halo, RGB 0-1 and its strength: what `atmosphere: true` draws */
128
+ const EARTH_HALO = Float32Array.of(0.42, 0.66, 1, 1);
129
+ /** A body from the option, its defaults filled in */
130
+ function bodySettings(body) {
131
+ if (body === undefined || typeof body === 'string')
132
+ return BODIES[body ?? 'earth'];
133
+ const relief = [-0.01 * body.radius, 0.01 * body.radius];
134
+ return { flattening: 0, atmosphere: false, color: '#808080', relief, ...body };
135
+ }
136
+ /** The halo of an atmosphere for the engine (RGB 0-1 and its strength, the color's alpha): undefined for none */
137
+ function haloSettings(atmosphere) {
138
+ if (atmosphere === true)
139
+ return EARTH_HALO;
140
+ const color = atmosphere === false ? undefined : parseCssColor(atmosphere);
141
+ return color && Float32Array.from(color);
142
+ }
143
+ /** The loaders of the relief by encoding, URL and floor: the same loader for the same tiles (the engine keeps its
144
+ * tiles when only the exaggeration changes) */
145
+ const terrainLoaders = new Map();
146
+ function terrainLoader(url, encoding, floor) {
147
+ const key = `${encoding} ${floor ?? ''} ${url}`;
148
+ let loader = terrainLoaders.get(key);
149
+ if (!loader) {
150
+ loader = encodedTerrainLoader(url, encoding, floor);
151
+ terrainLoaders.set(key, loader);
152
+ }
153
+ return loader;
154
+ }
155
+ /** Relief settings from the option: undefined when off. On the Earth the Terrain Tiles by default (worldwide,
156
+ * Terrarium PNGs decoded in the page) or the IGN terrain model (France, raised by the geoid); NASA's on the Moon and
157
+ * Mars; an ArcGIS image service asked for in the body's Mercator */
158
+ function terrainSettings(terrain, body, radius) {
159
+ if (!terrain)
160
+ return undefined;
161
+ let options = {};
162
+ if (typeof terrain === 'string')
163
+ options = { model: terrain };
164
+ else if (terrain !== true)
165
+ options = terrain;
166
+ const earth = (body ?? 'earth') === 'earth';
167
+ const exaggeration = options.exaggeration ?? 1;
168
+ if (options.url === undefined && earth) {
169
+ if (options.model === 'ign') {
170
+ const geoid = options.geoid ?? true;
171
+ return { url: TERRAIN_URL, exaggeration, maxZoom: options.maxZoom ?? 15, geoid, attribution: IGN_ATTRIBUTION };
172
+ }
173
+ return {
174
+ url: TERRAIN_TILES.url,
175
+ exaggeration,
176
+ maxZoom: options.maxZoom ?? TERRAIN_TILES.maxZoom,
177
+ geoid: options.geoid ?? false,
178
+ // The sea floor of the model at sea level: the oceans stay flat under the basemap's water
179
+ loader: terrainLoader(TERRAIN_TILES.url, 'terrarium', 0),
180
+ attribution: TERRAIN_TILES_ATTRIBUTION,
181
+ };
182
+ }
183
+ const preset = body === 'moon' || body === 'mars' ? BODY_TERRAINS[body] : undefined;
184
+ const url = options.url ?? preset?.url;
185
+ if (url === undefined)
186
+ throw new Error('isard: the relief of this body needs a url');
187
+ const encoding = options.encoding ?? 'float32';
188
+ // PNG encodings are fetched tile by tile: a {bbox} or an image service would be fetched as it is
189
+ if (encoding !== 'float32' &&
190
+ (IMAGE_SERVER.test(url) || !/\{z\}/.test(url) || !/\{x\}/.test(url) || !/\{y\}/.test(url))) {
191
+ throw new Error(`isard: the '${encoding}' terrain encoding takes a tile URL with {z}, {x} and {y} (${url})`);
192
+ }
193
+ return {
194
+ url: IMAGE_SERVER.test(url) ? imageServerTerrain(url, earth, radius) : url,
195
+ exaggeration,
196
+ maxZoom: options.maxZoom ?? (options.url === undefined && preset ? preset.maxZoom : 15),
197
+ geoid: options.geoid ?? false,
198
+ ...(encoding === 'float32' ? {} : { loader: terrainLoader(url, encoding) }),
199
+ // NASA's credits for its models
200
+ ...(options.url === undefined && preset ? { attribution: preset.attribution } : {}),
201
+ };
202
+ }
203
+ /** Sky settings for the engine: colors (RGB 0-1) and blends, MapLibre's defaults */
204
+ function atmosphereSettings(options) {
205
+ if (!options)
206
+ return undefined;
207
+ return Float32Array.from([
208
+ ...rgb(options.skyColor ?? '#9cc7ec'),
209
+ ...rgb(options.horizonColor ?? '#e8f1f8'),
210
+ ...rgb(options.fogColor ?? '#f2f5f8'),
211
+ options.skyHorizonBlend ?? 0.6,
212
+ options.horizonFogBlend ?? 0.7,
213
+ options.fogGroundBlend ?? 0.4,
214
+ ]);
215
+ }
216
+ /** MapLibre's default easing: cubic Bézier (0.25, 0.1, 0.25, 1) */
217
+ export function defaultEasing(t) {
218
+ if (t <= 0 || t >= 1)
219
+ return Math.max(0, Math.min(1, t));
220
+ // Solve x(u) = t for the curve parameter, Newton then bisection
221
+ const [x1, y1, x2, y2] = [0.25, 0.1, 0.25, 1];
222
+ const bezier = (u, a, b) => 3 * a * u * (1 - u) ** 2 + 3 * b * u * u * (1 - u) + u ** 3;
223
+ let u = t;
224
+ for (let i = 0; i < 8; i += 1) {
225
+ const x = bezier(u, x1, x2) - t;
226
+ const dx = 3 * x1 * (1 - u) ** 2 + 6 * (x2 - x1) * u * (1 - u) + 3 * (1 - x2) * u * u;
227
+ if (Math.abs(x) < 1e-6 || dx === 0)
228
+ break;
229
+ u = Math.max(0, Math.min(1, u - x / dx));
230
+ }
231
+ return bezier(u, y1, y2);
232
+ }
233
+ const MERCATOR_LAT = 85.051128779806589;
234
+ const toMercator = ([lon, lat]) => {
235
+ const phi = (Math.max(-MERCATOR_LAT, Math.min(MERCATOR_LAT, lat)) * Math.PI) / 180;
236
+ return [(lon + 180) / 360, 0.5 - Math.log(Math.tan(Math.PI / 4 + phi / 2)) / (2 * Math.PI)];
237
+ };
238
+ const fromMercator = ([x, y]) => [
239
+ x * 360 - 180,
240
+ (Math.atan(Math.sinh(Math.PI * (1 - 2 * y))) * 180) / Math.PI,
241
+ ];
242
+ /** A CSS color to RGB 0-1 (its alpha dropped); black if unknown */
243
+ function rgb(color) {
244
+ const [r, g, b] = parseCssColor(color) ?? [0, 0, 0, 1];
245
+ return [r, g, b];
246
+ }
247
+ const CORNERS = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
248
+ /** Frames the hover picking waits for its answer at most (usually one) */
249
+ const MAX_POLLS = 30;
250
+ /** The name of a basemap, checked: an unknown one throws */
251
+ function checkBasemap(basemap) {
252
+ const name = basemapName(basemap);
253
+ if (name !== 'none' && !(name in VECTOR_BASEMAPS) && !(name in BASEMAPS)) {
254
+ throw new Error(`isard: unknown basemap '${basemap}'`);
255
+ }
256
+ return name;
257
+ }
258
+ /** A map (see `GeoMap.create`); layers take it as their host */
259
+ export class GeoMap {
260
+ canvas;
261
+ #glyphs;
262
+ /** Layers new layers go under */
263
+ onTop = new Set();
264
+ /** The vector basemap, if any: its tiles' workers and its labels */
265
+ vector;
266
+ glyphsUrl;
267
+ basemapWorkerUrl;
268
+ container;
269
+ /** Markers and popups, over the canvas */
270
+ overlay;
271
+ corners;
272
+ controls = new Map();
273
+ positioned = new Set();
274
+ /** Canvas size in CSS pixels, kept on resize so placing overlays never reads the layout */
275
+ size = [0, 0];
276
+ #engine;
277
+ /** Layers in drawing order (bottom first), to recreate them after a context loss */
278
+ layers = [];
279
+ settings;
280
+ /** The WebGL context is lost: nothing is drawn until it is restored */
281
+ lost = false;
282
+ listeners = {
283
+ movestart: new Set(),
284
+ move: new Set(),
285
+ moveend: new Set(),
286
+ resize: new Set(),
287
+ hover: new Set(),
288
+ hoverfeature: new Set(),
289
+ click: new Set(),
290
+ render: new Set(),
291
+ idle: new Set(),
292
+ leave: new Set(),
293
+ };
294
+ resizeObserver;
295
+ interaction;
296
+ loader;
297
+ /** The class of the engine: without 3D Tiles until a 3D Tiles layer is added, unless asked for from the start */
298
+ engineClass;
299
+ /** The module of the engine with 3D Tiles once it runs this map (the decoding workers run it too) */
300
+ tiles3dModule;
301
+ /** The engine with 3D Tiles is loading, for the 3D Tiles layers added on the core engine */
302
+ upgrading;
303
+ /** What loading the engine with 3D Tiles and its workers needs */
304
+ tiles3dOptions;
305
+ gone = false;
306
+ /** The map is of the Earth: a basemap by default */
307
+ earth;
308
+ /** The body, as the option named it */
309
+ body;
310
+ basemap;
311
+ hillshade;
312
+ /** Called when the credits of the layers or relief change (the attribution controls) */
313
+ attributionListeners = new Set();
314
+ /** Credits of the raster layers that have some (basemaps, hillshade, the Géoplateforme's rasters…) */
315
+ rasterCredits = new Map();
316
+ animation;
317
+ hoverPoint;
318
+ /** Frame polling the engine for the answer of the hover picking, 0 if none */
319
+ hoverPoll = 0;
320
+ /** Polls of the hover picking under way: given up after `MAX_POLLS` (an answer that never comes) */
321
+ hoverPolls = 0;
322
+ /** What 'hoverfeature' looks for: within `radius` CSS pixels, in `layers` only if given */
323
+ hoverPicking = {};
324
+ frameRequest = 0;
325
+ lastCamera = '';
326
+ /** The camera moved since the last 'moveend' */
327
+ moving = false;
328
+ /** The last frame found everything loaded ('idle' sent) */
329
+ settled = false;
330
+ inFrame = false;
331
+ /** Vector tiles made by `vectorTiles()`, destroyed with the map */
332
+ vectorSets = new Set();
333
+ /** The workers drawing vector tiles, shared by every `VectorTiles` of the map; created on first use */
334
+ basemapPool;
335
+ /** Stops watching the device pixel ratio */
336
+ unwatchRatio;
337
+ /** Stops listening to the canvas's context loss and restoration */
338
+ unwatchContext;
339
+ /** Loads the engine (once per page) and creates a map filling `container` */
340
+ static async create(container, options = {}) {
341
+ // With 3D Tiles from the start when asked, or when only that engine's URL is given (as before the core engine)
342
+ const full = options.tiles3d === true || (options.wasmUrl !== undefined && options.coreWasmUrl === undefined);
343
+ // Before anything is created
344
+ if (options.basemap !== undefined)
345
+ checkBasemap(options.basemap);
346
+ const bindings = full
347
+ ? await loadEngine('full', options.wasmUrl ?? new URL('./isard_bg.wasm', import.meta.url))
348
+ : await loadEngine('core', options.coreWasmUrl ?? new URL('./isard_core_bg.wasm', import.meta.url));
349
+ const map = new GeoMap(container, options, bindings.Engine, full ? bindings.module : undefined);
350
+ if (options.tiles3d === 'preload' && !full)
351
+ map.preloadTiles3d();
352
+ return map;
353
+ }
354
+ constructor(container, options, engineClass, tiles3dModule) {
355
+ this.engineClass = engineClass;
356
+ this.tiles3dModule = tiles3dModule;
357
+ this.tiles3dOptions = { ...options };
358
+ this.glyphsUrl = options.glyphs;
359
+ this.basemapWorkerUrl = options.basemapWorkerUrl;
360
+ this.container = container;
361
+ this.canvas = document.createElement('canvas');
362
+ Object.assign(this.canvas.style, { display: 'block', width: '100%', height: '100%', touchAction: 'none' });
363
+ // Overlays and controls are positioned against the container
364
+ injectStyle(container.ownerDocument);
365
+ if (getComputedStyle(container).position === 'static')
366
+ container.style.position = 'relative';
367
+ container.classList.add('isard-map');
368
+ this.overlay = document.createElement('div');
369
+ this.overlay.className = 'isard-overlay';
370
+ const corner = (position) => {
371
+ const element = document.createElement('div');
372
+ element.className = `isard-ctrl-${position}`;
373
+ return element;
374
+ };
375
+ this.corners = {
376
+ 'top-left': corner('top-left'),
377
+ 'top-right': corner('top-right'),
378
+ 'bottom-left': corner('bottom-left'),
379
+ 'bottom-right': corner('bottom-right'),
380
+ };
381
+ container.append(this.canvas, this.overlay, ...CORNERS.map((position) => this.corners[position]));
382
+ this.earth = (options.body ?? 'earth') === 'earth';
383
+ this.body = options.body;
384
+ const body = bodySettings(options.body);
385
+ this.settings = {
386
+ body,
387
+ zoomRange: [options.minZoom ?? 0, options.maxZoom ?? 22],
388
+ maxPitch: options.maxPitch ?? 70,
389
+ globe: (options.projection ?? 'globe') === 'globe',
390
+ sky: options.sky ?? true,
391
+ atmosphere: atmosphereSettings(options.atmosphere),
392
+ terrain: terrainSettings(options.terrain, options.body, body.radius),
393
+ loading: options.loading ?? 'immediate',
394
+ background: rgb(options.background ?? (this.earth ? '#f2f2f0' : '#000000')),
395
+ globeColor: this.earth ? SEA['osm-positron'] : rgb(body.color),
396
+ };
397
+ this.#engine = this.createEngine();
398
+ // The browser may take the GPU away (driver reset, background tab on mobile): the map waits, then rebuilds
399
+ const lost = (event) => {
400
+ event.preventDefault();
401
+ this.lost = true;
402
+ cancelAnimationFrame(this.frameRequest);
403
+ this.frameRequest = 0;
404
+ };
405
+ const restored = () => this.restore();
406
+ this.canvas.addEventListener('webglcontextlost', lost);
407
+ this.canvas.addEventListener('webglcontextrestored', restored);
408
+ this.unwatchContext = () => {
409
+ this.canvas.removeEventListener('webglcontextlost', lost);
410
+ this.canvas.removeEventListener('webglcontextrestored', restored);
411
+ };
412
+ const start = this.earth
413
+ ? { center: [2.4246, 48.8457], zoom: 13, pitch: 0, bearing: 0 }
414
+ : { center: [0, 0], zoom: 2, pitch: 0, bearing: 0 };
415
+ this.setCamera({ ...start, ...options });
416
+ this.setBasemap(options.basemap ?? (this.earth ? 'osm-positron' : 'none'), options.hillshade ?? 0);
417
+ this.resizeObserver = new ResizeObserver(() => this.resize());
418
+ this.resizeObserver.observe(container);
419
+ this.watchRatio();
420
+ this.resize();
421
+ const interactive = options.interactive ?? true;
422
+ const keyboard = interactive && (options.keyboard ?? true);
423
+ this.interaction = interactive ? new Interaction(this, { keyboard }) : undefined;
424
+ // Reached with Tab when the keys move it; named for screen readers either way
425
+ this.canvas.classList.add('isard-canvas');
426
+ this.canvas.setAttribute('role', keyboard ? 'application' : 'region');
427
+ this.canvas.setAttribute('aria-roledescription', 'map');
428
+ this.canvas.setAttribute('aria-label', options.ariaLabel ?? 'Map');
429
+ if (keyboard)
430
+ this.canvas.tabIndex = 0;
431
+ const attribution = options.attributionControl ?? true;
432
+ if (attribution !== false) {
433
+ this.addControl(new AttributionControl(attribution === true ? {} : attribution), 'bottom-right');
434
+ }
435
+ }
436
+ /** The element the map was created in */
437
+ getContainer() {
438
+ return this.container;
439
+ }
440
+ getCanvas() {
441
+ return this.canvas;
442
+ }
443
+ /** @internal The engine (replaced after a context loss) */
444
+ get engine() {
445
+ return this.#engine;
446
+ }
447
+ createEngine() {
448
+ const engine = new this.engineClass(this.canvas, () => this.requestRender());
449
+ // The body first: the layers' shaders may take its size
450
+ const { radius, flattening, atmosphere: air, relief } = this.settings.body;
451
+ engine.setBody(radius, flattening, haloSettings(air), ...relief);
452
+ const { zoomRange, maxPitch, globe, sky, atmosphere, terrain, loading, background, globeColor } = this.settings;
453
+ engine.setZoomRange(...zoomRange);
454
+ engine.setMaxPitch(maxPitch);
455
+ engine.setGlobe(globe);
456
+ engine.setSky(sky);
457
+ engine.setAtmosphere(atmosphere);
458
+ engine.setTerrain(terrain?.url, terrain?.exaggeration ?? 1, terrain?.maxZoom, terrain?.geoid, terrain?.loader);
459
+ engine.setImmediateLoading(loading === 'immediate');
460
+ engine.setBackground(...background);
461
+ engine.setGlobeColor(...globeColor);
462
+ this.loader ??= this.startLoader(engine);
463
+ if (this.loader)
464
+ engine.setTileLoader(this.loader);
465
+ return engine;
466
+ }
467
+ /** The decoding workers of 3D Tiles, with the engine that has them (they run its module) and unless turned off */
468
+ startLoader(engine) {
469
+ const { workers: count, workerUrl, decoders } = this.tiles3dOptions;
470
+ const workers = count ?? Math.min(4, Math.max(1, (navigator.hardwareConcurrency || 2) - 1));
471
+ if (!this.tiles3dModule || workers === false || workers <= 0 || typeof Worker === 'undefined')
472
+ return undefined;
473
+ return new TileLoader(this.tiles3dModule, workerUrl ?? new URL('./decoder.worker.js', import.meta.url), workers, { ...DEFAULT_DECODERS, ...decoders }, engine.textureFormats());
474
+ }
475
+ /** A 3D Tiles layer on the core engine: loads the engine with 3D Tiles, then rebuilds the map on it (camera,
476
+ * settings and layers kept, as after a context loss); the layer is created then */
477
+ upgrade() {
478
+ const url = this.tiles3dOptions.wasmUrl ?? new URL('./isard_bg.wasm', import.meta.url);
479
+ this.upgrading ??= loadEngine('full', url)
480
+ // The map is rebuilt in a task of its own, not in the one that instantiated the engine: two shorter tasks
481
+ .then((bindings) => new Promise((resolve) => setTimeout(() => resolve(bindings), 0)))
482
+ .then(({ module, Engine }) => {
483
+ this.upgrading = undefined;
484
+ if (this.gone)
485
+ return;
486
+ this.engineClass = Engine;
487
+ this.tiles3dModule = module;
488
+ // A lost context: the engine is rebuilt on it once restored
489
+ if (!this.lost)
490
+ this.restore();
491
+ }, (error) => {
492
+ // The 3D Tiles layers stay empty; the next one added tries again
493
+ this.upgrading = undefined;
494
+ if (this.gone)
495
+ return;
496
+ report(error);
497
+ this.requestRender();
498
+ });
499
+ }
500
+ /** Downloads and compiles the engine with 3D Tiles once the map is idle (`tiles3d: 'preload'`), without running
501
+ * it: the first 3D Tiles layer then only instantiates it */
502
+ preloadTiles3d() {
503
+ const url = this.tiles3dOptions.wasmUrl ?? new URL('./isard_bg.wasm', import.meta.url);
504
+ const start = () => {
505
+ if (!this.gone && !this.tiles3dModule)
506
+ compileEngine('full', url).catch(() => undefined);
507
+ };
508
+ const later = () => {
509
+ if (typeof requestIdleCallback === 'function')
510
+ requestIdleCallback(start, { timeout: 2000 });
511
+ else
512
+ setTimeout(start, 0);
513
+ };
514
+ if (this.settled)
515
+ later();
516
+ else {
517
+ const off = this.on('idle', () => {
518
+ off();
519
+ later();
520
+ });
521
+ }
522
+ }
523
+ /** After a context loss: a new engine with the same camera, settings and layers (same handles, same order) */
524
+ restore() {
525
+ if (this.gone)
526
+ return;
527
+ this.cancelHoverPick();
528
+ const camera = this.getCamera();
529
+ this.#engine.free();
530
+ this.#engine = this.createEngine();
531
+ this.lost = false;
532
+ this.setCamera(camera);
533
+ this.#glyphs?.attach(this.#engine);
534
+ const engine = this.#engine;
535
+ // No layer refers to an id of the old engine while the others are created
536
+ for (const layer of this.layers)
537
+ layer.forgetEngine();
538
+ for (const layer of this.layers) {
539
+ // Still waiting for the engine with 3D Tiles (loading, or failed): created with it
540
+ if (layer instanceof Tiles3dLayer && !this.tiles3dModule)
541
+ continue;
542
+ // A layer that fails is reported and left out; the others are created
543
+ try {
544
+ layer.attach(engine);
545
+ }
546
+ catch (error) {
547
+ report(error);
548
+ }
549
+ }
550
+ // Links between layers, once every layer has its new id (buildings colored by a network above them)
551
+ for (const layer of this.layers) {
552
+ if (layer.id < 0)
553
+ continue;
554
+ try {
555
+ layer.relink(engine);
556
+ }
557
+ catch (error) {
558
+ report(error);
559
+ }
560
+ }
561
+ this.resize();
562
+ }
563
+ /** @internal Forgets a removed layer */
564
+ detach(layer) {
565
+ this.unlist(layer);
566
+ this.onTop.delete(layer);
567
+ if (this.rasterCredits.delete(layer))
568
+ this.attributionChanged();
569
+ }
570
+ /** @internal The layer is on the map */
571
+ hasLayer(layer) {
572
+ return !this.gone && this.layers.includes(layer);
573
+ }
574
+ /** Takes a layer out of the drawing order (to put it back elsewhere, or for good) */
575
+ unlist(layer) {
576
+ const index = this.layers.indexOf(layer);
577
+ if (index >= 0)
578
+ this.layers.splice(index, 1);
579
+ }
580
+ add(layer) {
581
+ // On the core engine, a 3D Tiles layer waits for the engine with 3D Tiles: it is created with the others on it
582
+ if (layer instanceof Tiles3dLayer && !this.tiles3dModule)
583
+ this.upgrade();
584
+ else
585
+ layer.attach(this.engine);
586
+ this.layers.push(layer);
587
+ // Under the layers kept on top (the basemap's labels)
588
+ const top = this.layers.find((l) => this.onTop.has(l) && l !== layer);
589
+ if (top)
590
+ this.moveLayer(layer, top);
591
+ this.requestRender();
592
+ return layer;
593
+ }
594
+ /** @internal Keeps a layer above the layers added after it (they go under it), as the labels of a basemap */
595
+ keepOnTop(layer) {
596
+ this.onTop.add(layer);
597
+ }
598
+ /** @internal Moves a layer under `before`, or on top of all */
599
+ moveLayer(layer, before) {
600
+ const handle = layer;
601
+ this.unlist(handle);
602
+ const index = before ? this.layers.indexOf(before) : -1;
603
+ const at = index < 0 ? this.layers.length : index;
604
+ this.layers.splice(at, 0, handle);
605
+ if (handle.id >= 0)
606
+ this.engine.moveLayer(handle.id, this.engineIndex(at));
607
+ this.requestRender();
608
+ }
609
+ /** @internal See `LayerHost.replaceLayer` */
610
+ replaceLayer(layer, old) {
611
+ this.engine.removeLayer(old);
612
+ const index = this.layers.indexOf(layer);
613
+ if (index >= 0)
614
+ this.engine.moveLayer(layer.id, this.engineIndex(index));
615
+ }
616
+ /** Index in the engine of the layer at `index`: the layers waiting for the engine with 3D Tiles are not in it yet */
617
+ engineIndex(index) {
618
+ let count = 0;
619
+ for (let i = 0; i < index; i += 1)
620
+ if ((this.layers[i]?.id ?? -1) >= 0)
621
+ count += 1;
622
+ return count;
623
+ }
624
+ /** Moves a layer under all the others */
625
+ toBottom(layer) {
626
+ this.engine.moveLayer(layer.id, 0);
627
+ this.unlist(layer);
628
+ this.layers.unshift(layer);
629
+ }
630
+ // ── Events ──
631
+ /** Listens to an event; returns the function that stops listening */
632
+ on(type, listener) {
633
+ const listeners = this.listeners[type];
634
+ listeners.add(listener);
635
+ return () => listeners.delete(listener);
636
+ }
637
+ /** @internal Calls the listeners; one that throws is reported and does not stop the others nor the frame */
638
+ emit(type, event) {
639
+ const listeners = this.listeners[type];
640
+ for (const listener of listeners) {
641
+ try {
642
+ listener(event);
643
+ }
644
+ catch (error) {
645
+ report(error);
646
+ }
647
+ }
648
+ }
649
+ // ── Camera ──
650
+ getCamera() {
651
+ return this.engine.camera();
652
+ }
653
+ setCamera(camera) {
654
+ const c = { ...this.getCamera(), ...camera };
655
+ this.engine.setCamera(c.center[0], c.center[1], c.zoom, c.pitch, c.bearing);
656
+ this.requestRender();
657
+ }
658
+ /** Animated move to a camera: over `duration` milliseconds (or the options), ease-out by default */
659
+ easeTo(camera, options = 600) {
660
+ const { duration = 600, easing = (t) => 1 - (1 - t) ** 3 } = typeof options === 'number' ? { duration: options } : options;
661
+ const from = this.getCamera();
662
+ const to = { ...from, ...camera };
663
+ // Shortest way round
664
+ const turn = ((to.bearing - from.bearing + 540) % 360) - 180;
665
+ const start = performance.now();
666
+ this.animate((now) => {
667
+ if (now - start >= duration)
668
+ this.engine.clearPreload();
669
+ const t = duration > 0 ? Math.min(1, (now - start) / duration) : 1;
670
+ const k = easing(t);
671
+ const mix = (a, b) => a + (b - a) * k;
672
+ this.engine.setCamera(mix(from.center[0], to.center[0]), mix(from.center[1], to.center[1]), mix(from.zoom, to.zoom), mix(from.pitch, to.pitch), from.bearing + turn * k);
673
+ return t < 1;
674
+ });
675
+ // The destination's tiles load during the flight (as Cesium does)
676
+ this.engine.preload(to.center[0], to.center[1], to.zoom, to.pitch, to.bearing);
677
+ }
678
+ /**
679
+ * Flight to a camera that zooms out on its way and back in (van Wijk and Nuij's optimal path, as MapLibre's
680
+ * `flyTo`): long moves stay readable. Pitch and bearing follow the progress.
681
+ */
682
+ flyTo(camera, options = {}) {
683
+ const from = this.getCamera();
684
+ const to = { ...from, ...camera };
685
+ const { curve: rho = 1.42, speed = 1.2, easing = defaultEasing } = options;
686
+ const [x0, y0] = toMercator(from.center);
687
+ const [x1, y1] = toMercator(to.center);
688
+ // Across the antimeridian, the short way
689
+ const dx = x1 - x0 - Math.round(x1 - x0);
690
+ const dy = y1 - y0;
691
+ // In pixels at the start zoom (512 px world at zoom 0)
692
+ const world = 512 * 2 ** from.zoom;
693
+ const u1 = Math.hypot(dx, dy) * world;
694
+ const w0 = Math.max(this.canvas.clientWidth, this.canvas.clientHeight, 1);
695
+ const w1 = w0 / 2 ** (to.zoom - from.zoom);
696
+ const rho2 = rho * rho;
697
+ const r = (i) => {
698
+ const b = (w1 * w1 - w0 * w0 + (i ? -1 : 1) * rho2 * rho2 * u1 * u1) / (2 * (i ? w1 : w0) * rho2 * u1);
699
+ return Math.log(Math.sqrt(b * b + 1) - b);
700
+ };
701
+ // No move on the ground (or nearly): a plain zoom
702
+ const still = u1 < 1e-6 || !Number.isFinite(r(0)) || !Number.isFinite(r(1));
703
+ const r0 = still ? 0 : r(0);
704
+ const length = still ? Math.abs(Math.log(w1 / w0)) / rho : (r(1) - r0) / rho;
705
+ const width = (s) => still ? Math.exp((w1 < w0 ? -1 : 1) * rho * s) : Math.cosh(r0) / Math.cosh(r0 + rho * s);
706
+ const along = (s) => still ? 0 : (w0 * ((Math.cosh(r0) * Math.tanh(r0 + rho * s) - Math.sinh(r0)) / rho2)) / u1;
707
+ const duration = options.duration ?? (1000 * length) / speed;
708
+ const turn = ((to.bearing - from.bearing + 540) % 360) - 180;
709
+ const start = performance.now();
710
+ this.animate((now) => {
711
+ if (now - start >= duration)
712
+ this.engine.clearPreload();
713
+ const t = duration > 0 ? Math.min(1, (now - start) / duration) : 1;
714
+ const k = easing(t);
715
+ const s = k * length;
716
+ const u = t >= 1 ? 1 : along(s);
717
+ const zoom = t >= 1 ? to.zoom : from.zoom + Math.log2(1 / width(s));
718
+ const [lon, lat] = fromMercator([x0 + dx * u, y0 + dy * u]);
719
+ this.engine.setCamera(lon, lat, zoom, from.pitch + (to.pitch - from.pitch) * k, from.bearing + turn * k);
720
+ return t < 1;
721
+ });
722
+ this.engine.preload(to.center[0], to.center[1], to.zoom, to.pitch, to.bearing);
723
+ }
724
+ /** Longitudes and latitudes the view covers on the ground: `[west, south, east, north]` */
725
+ getBounds() {
726
+ const [west = -180, south = -85, east = 180, north = 85] = this.engine.bounds();
727
+ return [west, south, east, north];
728
+ }
729
+ /** Lowest zoom the camera accepts (the widest view) */
730
+ setMinZoom(zoom) {
731
+ this.settings.zoomRange[0] = zoom;
732
+ this.engine.setZoomRange(...this.settings.zoomRange);
733
+ this.requestRender();
734
+ }
735
+ setMaxZoom(zoom) {
736
+ this.settings.zoomRange[1] = zoom;
737
+ this.engine.setZoomRange(...this.settings.zoomRange);
738
+ this.requestRender();
739
+ }
740
+ /** Highest pitch, degrees, up to 85 */
741
+ setMaxPitch(pitch) {
742
+ this.settings.maxPitch = pitch;
743
+ this.engine.setMaxPitch(pitch);
744
+ this.requestRender();
745
+ }
746
+ /** Sky over the plane and the fog of the ground (see `GeoMapOptions.atmosphere`); undefined to remove them */
747
+ setAtmosphere(options) {
748
+ this.settings.atmosphere = atmosphereSettings(options);
749
+ this.engine.setAtmosphere(this.settings.atmosphere);
750
+ this.requestRender();
751
+ }
752
+ setProjection(projection) {
753
+ this.settings.globe = projection === 'globe';
754
+ this.engine.setGlobe(this.settings.globe);
755
+ this.requestRender();
756
+ }
757
+ /** How tiles load and show (see `LoadingMode`) */
758
+ setLoading(mode) {
759
+ this.settings.loading = mode;
760
+ this.engine.setImmediateLoading(mode === 'immediate');
761
+ this.requestRender();
762
+ }
763
+ /** Space behind the globe (stars, atmosphere halo) */
764
+ setSky(enabled) {
765
+ this.settings.sky = enabled;
766
+ this.engine.setSky(enabled);
767
+ this.requestRender();
768
+ }
769
+ /** Relief under the basemap: true for the body's terrain model (on the Earth the worldwide Terrain Tiles, NASA's
770
+ * on the Moon and Mars), one of the Earth's models by name (`'ign'`), its options, or false; on another body, its
771
+ * options with a `url` */
772
+ setTerrain(terrain) {
773
+ this.settings.terrain = terrainSettings(terrain, this.body, this.settings.body.radius);
774
+ const t = this.settings.terrain;
775
+ this.engine.setTerrain(t?.url, t?.exaggeration ?? 1, t?.maxZoom, t?.geoid, t?.loader);
776
+ this.attributionChanged();
777
+ this.requestRender();
778
+ }
779
+ /** @internal The credits of the raster layers (the basemap first) and of the relief in use, each once */
780
+ attributions() {
781
+ const layers = this.layers.map((layer) => this.rasterCredits.get(layer));
782
+ const credits = [...layers, this.settings.terrain?.attribution];
783
+ return [...new Set(credits.filter((c) => c !== undefined))];
784
+ }
785
+ /** @internal Calls `listener` when the credits change; returns what stops it */
786
+ onAttributionChange(listener) {
787
+ this.attributionListeners.add(listener);
788
+ return () => this.attributionListeners.delete(listener);
789
+ }
790
+ attributionChanged() {
791
+ for (const listener of this.attributionListeners)
792
+ listener();
793
+ }
794
+ /** Color behind the map, a CSS color (its alpha ignored) */
795
+ setBackground(color) {
796
+ this.settings.background = rgb(color);
797
+ this.engine.setBackground(...this.settings.background);
798
+ this.requestRender();
799
+ }
800
+ /** Tile counters of the raster layers, for diagnostics */
801
+ stats() {
802
+ return this.engine.stats();
803
+ }
804
+ /**
805
+ * The feature under a screen point (CSS pixels), undefined if none: its layer id and index. Reads one pixel of an
806
+ * offscreen picking pass, whatever the number of features, but waits for the GPU to finish the frame (a stall): for
807
+ * clicks and one-off queries. On hover, listen to 'hoverfeature' instead, which picks without waiting.
808
+ */
809
+ pick(point, radius = 0, layers) {
810
+ const only = layers && Uint32Array.from(layers.map((l) => l.id));
811
+ return this.engine.pick(point[0], point[1], radius, only);
812
+ }
813
+ /**
814
+ * The feature under a screen point (CSS pixels), or the nearest one within `radius` pixels: its layer handle, its
815
+ * index there and, for line, circle and fill layers, the feature itself; undefined if none. `layers` restricts the
816
+ * search to some layers (the others hide nothing).
817
+ */
818
+ pickFeature(point, options = {}) {
819
+ const hit = this.pick(point, options.radius ?? 0, options.layers);
820
+ return hit && this.featureHit(hit);
821
+ }
822
+ /** A hit with its layer handle and feature; undefined if the layer is gone */
823
+ featureHit(hit) {
824
+ const layer = this.layers.find((l) => l.id === hit.layer);
825
+ if (!layer)
826
+ return undefined;
827
+ const feature = 'featureAt' in layer && typeof layer.featureAt === 'function'
828
+ ? layer.featureAt(hit.index)
829
+ : undefined;
830
+ return { layer, index: hit.index, feature };
831
+ }
832
+ /** Asks the engine what is under the pointer; its answer, read without waiting for the GPU, comes on a later frame */
833
+ requestHoverPick(point) {
834
+ const { radius = 0, layers } = this.hoverPicking;
835
+ const only = layers && Uint32Array.from(layers.map((l) => l.id));
836
+ // The engine cannot pick (no request in flight): the poll under way would wait for nothing
837
+ if (!this.engine.requestPick(point[0], point[1], radius, only)) {
838
+ this.cancelHoverPick();
839
+ return;
840
+ }
841
+ // A new request replaces the one in flight (in the engine too): one poll loop at most
842
+ this.hoverPolls = 0;
843
+ if (!this.hoverPoll)
844
+ this.hoverPoll = requestAnimationFrame(this.pollHoverPick);
845
+ }
846
+ pollHoverPick = () => {
847
+ this.hoverPoll = 0;
848
+ if (this.lost)
849
+ return;
850
+ const result = this.engine.pollPick();
851
+ if (!result) {
852
+ // The GPU is not done yet (usually a frame): asked again next frame, without drawing; given up at last
853
+ this.hoverPolls += 1;
854
+ if (this.hoverPolls < MAX_POLLS)
855
+ this.hoverPoll = requestAnimationFrame(this.pollHoverPick);
856
+ return;
857
+ }
858
+ this.emit('hoverfeature', {
859
+ point: [result.x, result.y],
860
+ lonLat: result.lonLat,
861
+ hit: result.hit && this.featureHit(result.hit),
862
+ });
863
+ };
864
+ cancelHoverPick() {
865
+ cancelAnimationFrame(this.hoverPoll);
866
+ this.hoverPoll = 0;
867
+ }
868
+ /** Screen point (CSS pixels) of a position, undefined behind the camera */
869
+ project(lonLat) {
870
+ return this.engine.project(lonLat[0], lonLat[1]);
871
+ }
872
+ /** Position under a screen point (CSS pixels), undefined above the horizon */
873
+ unproject(point) {
874
+ return this.engine.ground(point[0], point[1]);
875
+ }
876
+ // ── Overlays and controls ──
877
+ /** Adds a control in a corner of the map; controls stack from the corner inward */
878
+ addControl(control, position = 'top-right') {
879
+ if (this.controls.has(control))
880
+ return this;
881
+ const element = control.onAdd(this);
882
+ element.classList.add('isard-ctrl');
883
+ // In the bottom corners the first control added stays lowest, as in MapLibre
884
+ if (position.startsWith('bottom'))
885
+ this.corners[position].prepend(element);
886
+ else
887
+ this.corners[position].append(element);
888
+ this.controls.set(control, element);
889
+ return this;
890
+ }
891
+ removeControl(control) {
892
+ const element = this.controls.get(control);
893
+ if (!element)
894
+ return this;
895
+ this.controls.delete(control);
896
+ control.onRemove(this);
897
+ element.remove();
898
+ return this;
899
+ }
900
+ /** @internal Shows a marker or popup, placed after every frame from now on */
901
+ attachOverlay(item, element) {
902
+ this.overlay.append(element);
903
+ this.positioned.add(item);
904
+ item.position(this.size);
905
+ }
906
+ /** @internal */
907
+ detachOverlay(item, element) {
908
+ this.positioned.delete(item);
909
+ element.remove();
910
+ }
911
+ /** @internal Places one overlay now (it moved), without drawing a frame */
912
+ positionOverlay(item) {
913
+ if (this.positioned.has(item))
914
+ item.position(this.size);
915
+ }
916
+ // ── Layers ──
917
+ /** Replaces the basemap and the IGN hillshade, under the other layers (hillshade by default: 0, none). The former
918
+ * names of the Géoplateforme basemaps (`LegacyBasemap`) are taken under their new ones */
919
+ setBasemap(basemap, hillshade = 0) {
920
+ const name = checkBasemap(basemap);
921
+ if (this.basemap && this.hasLayer(this.basemap))
922
+ this.basemap.remove();
923
+ if (this.hillshade && this.hasLayer(this.hillshade))
924
+ this.hillshade.remove();
925
+ // Its raster and labels go with it
926
+ this.vector?.tiles.destroy();
927
+ this.vector = undefined;
928
+ const vector = VECTOR_BASEMAPS[name];
929
+ if (vector) {
930
+ const tiles = new VectorTiles(this, {
931
+ style: vector,
932
+ ...(this.basemapWorkerUrl ? { workerUrl: this.basemapWorkerUrl } : {}),
933
+ });
934
+ this.basemap = tiles.raster();
935
+ this.vector = { tiles, labels: tiles.labels() };
936
+ }
937
+ else {
938
+ this.basemap = name === 'none' ? undefined : this.addRaster(BASEMAPS[name]);
939
+ }
940
+ // Off the Earth, the body's own color
941
+ if (this.earth)
942
+ this.settings.globeColor = SEA[name];
943
+ this.engine.setGlobeColor(...this.settings.globeColor);
944
+ this.hillshade = hillshade > 0 ? this.addRaster({ ...HILLSHADE, opacity: hillshade }) : undefined;
945
+ if (this.hillshade)
946
+ this.toBottom(this.hillshade);
947
+ if (this.basemap)
948
+ this.toBottom(this.basemap);
949
+ this.attributionChanged();
950
+ }
951
+ /** Raster tiles ({z}, {x}, {y} URL; Web Mercator, or reprojected from an equirectangular `grid`), over the layers
952
+ * already added */
953
+ addRaster(options) {
954
+ const { url, grid = 'mercator', minZoom = 0, maxZoom = 19, opacity = 1, saturation = 1, size, before } = options;
955
+ let layer;
956
+ if (grid === 'equirectangular' && !options.loader) {
957
+ // The source's level n has the pixel size of Mercator level n + 1 at the equator
958
+ const loader = equirectangularLoader(url, maxZoom, size ?? 256, minZoom);
959
+ const levels = { minZoom: minZoom + 1, maxZoom: maxZoom + 1 };
960
+ layer = new RasterLayer(this, { url, ...levels, opacity, saturation, size, loader });
961
+ }
962
+ else {
963
+ layer = new RasterLayer(this, { url, minZoom, maxZoom, opacity, saturation, size, loader: options.loader });
964
+ }
965
+ this.place(layer, before);
966
+ if (options.attribution) {
967
+ this.rasterCredits.set(layer, options.attribution);
968
+ this.attributionChanged();
969
+ }
970
+ return layer;
971
+ }
972
+ /** Line network colored by a value per node; `layer.setValues` recolors it in one texture upload */
973
+ addNetwork(options) {
974
+ const { nodes, edges, lines, costs, edgeWidths, width = 2, max = 1800, contour = 0, opacity = 1 } = options;
975
+ const { halo = 0, haloOpacity = 0.11, ramp, unreachedColor = 'rgba(140, 140, 140, 0.25)', fade = 0 } = options;
976
+ const style = { width, max, contour, opacity, halo, haloOpacity, ramp, unreachedColor, fade };
977
+ validateNetwork(nodes, edges);
978
+ return this.add(new NetworkLayer(this, { nodes, edges, lines }, costs, style, edgeWidths));
979
+ }
980
+ /** Lines from GeoJSON (line strings, and polygon outlines), styled per feature */
981
+ addLines(options) {
982
+ return this.place(new LineLayer(this, options.data, lineStyle(options)), options.before);
983
+ }
984
+ /** Discs from GeoJSON points, styled per feature */
985
+ addCircles(options) {
986
+ return this.place(new CircleLayer(this, options.data, circleStyle(options)), options.before);
987
+ }
988
+ /** Filled polygons from GeoJSON (holes included), styled per feature; over the relief they lie on the ground */
989
+ addFills(options) {
990
+ return this.place(new FillLayer(this, options.data, fillStyle(options)), options.before);
991
+ }
992
+ /** The application's fragment shader over the ground (see `GroundOptions`); under `before` if given */
993
+ addGround(options) {
994
+ return this.place(new GroundLayer(this, options), options.before);
995
+ }
996
+ /** Polygons raised into blocks with depth (buildings), styled per feature; tiles of them come with `setTile` */
997
+ addExtrusions(options = {}) {
998
+ return this.place(new ExtrusionLayer(this, options.data, extrusionStyle(options)), options.before);
999
+ }
1000
+ /** Text labels from GeoJSON (points, the middle of lines, the center of polygons), placed without overlaps */
1001
+ addSymbols(options) {
1002
+ return this.place(new SymbolLayer(this, options.data, symbolStyle(options)), options.before);
1003
+ }
1004
+ /** @internal The glyphs of the symbol layers, loaded on first use */
1005
+ get glyphs() {
1006
+ if (!this.#glyphs) {
1007
+ // The engine's context: the atlas stays within its texture size limit
1008
+ const gl = this.canvas.getContext('webgl2');
1009
+ const max = gl?.getParameter(gl.MAX_TEXTURE_SIZE);
1010
+ this.#glyphs = new Glyphs(this.glyphsUrl, typeof max === 'number' ? max : undefined);
1011
+ this.#glyphs.attach(this.engine);
1012
+ }
1013
+ return this.#glyphs;
1014
+ }
1015
+ /**
1016
+ * Vector tiles with a MapLibre style (OpenFreeMap's Positron by default): `tiles.raster(…)` draws its fills and lines
1017
+ * (or a subset) as a raster layer, `tiles.labels(…)` its labels. `setBasemap('osm-positron')` does both for the
1018
+ * basemap.
1019
+ */
1020
+ vectorTiles(options = {}) {
1021
+ const tiles = new VectorTiles(this, {
1022
+ ...(this.basemapWorkerUrl ? { workerUrl: this.basemapWorkerUrl } : {}),
1023
+ ...options,
1024
+ });
1025
+ // Its workers stop with the map
1026
+ this.vectorSets.add(tiles);
1027
+ return tiles;
1028
+ }
1029
+ /** @internal Forgets a destroyed `VectorTiles` */
1030
+ dropVectorTiles(tiles) {
1031
+ this.vectorSets.delete(tiles);
1032
+ }
1033
+ /**
1034
+ * @internal The workers for a `VectorTiles`: the map's shared pool, or a pool of its own (`own`, to stop with it)
1035
+ * when it asks for another count or URL
1036
+ */
1037
+ basemapWorkers(count, url) {
1038
+ const sharedUrl = this.basemapWorkerUrl ?? defaultWorkerUrl();
1039
+ const shared = defaultWorkerCount();
1040
+ const workers = Math.max(1, count ?? shared);
1041
+ const workerUrl = url ?? sharedUrl;
1042
+ if (workers === shared && String(workerUrl) === String(sharedUrl)) {
1043
+ this.basemapPool ??= new BasemapWorkers(shared, sharedUrl);
1044
+ return { pool: this.basemapPool, own: false };
1045
+ }
1046
+ return { pool: new BasemapWorkers(workers, workerUrl), own: true };
1047
+ }
1048
+ /** Adds a layer, under `before` if given */
1049
+ place(layer, before) {
1050
+ this.add(layer);
1051
+ if (before)
1052
+ this.moveLayer(layer, before);
1053
+ return layer;
1054
+ }
1055
+ /** Textured meshes in 3D Tiles (photogrammetry, buildings), drawn with depth over the basemap */
1056
+ addTiles3d(options) {
1057
+ const { url, maxScreenSpaceError = 16, opacity = 1 } = options;
1058
+ return this.add(new Tiles3dLayer(this, url, { maxScreenSpaceError, opacity }));
1059
+ }
1060
+ // ── Frames ──
1061
+ /** Asks for a frame; several calls before the next frame draw it once */
1062
+ requestRender() {
1063
+ // Changes made by listeners during a frame are drawn by that frame; nothing is drawn without a context
1064
+ if (this.inFrame || this.lost)
1065
+ return;
1066
+ this.frameRequest ||= requestAnimationFrame((now) => this.frame(now));
1067
+ }
1068
+ /** @internal Runs `step` every frame until it returns false; replaces the running animation */
1069
+ animate(step) {
1070
+ // A flight cut short no longer needs its destination
1071
+ if (this.animation)
1072
+ this.engine.clearPreload();
1073
+ this.animation = step;
1074
+ this.requestRender();
1075
+ }
1076
+ /** @internal The pointer moved over the map (undefined: it left). Without 'hover' and 'hoverfeature' listeners,
1077
+ * nothing is drawn: a still map stays idle under a moving mouse */
1078
+ hover(point) {
1079
+ if (!point) {
1080
+ this.hoverPoint = undefined;
1081
+ this.cancelHoverPick();
1082
+ return;
1083
+ }
1084
+ if (this.listeners.hover.size === 0 && this.listeners.hoverfeature.size === 0)
1085
+ return;
1086
+ this.hoverPoint = point;
1087
+ this.requestRender();
1088
+ }
1089
+ frame(now) {
1090
+ this.frameRequest = 0;
1091
+ try {
1092
+ this.inFrame = true;
1093
+ this.drawFrame(now);
1094
+ }
1095
+ finally {
1096
+ // Whatever throws in a frame, the next ones are still drawn
1097
+ this.inFrame = false;
1098
+ }
1099
+ }
1100
+ drawFrame(now) {
1101
+ if (this.animation && !this.animation(now))
1102
+ this.animation = undefined;
1103
+ const camera = this.getCamera();
1104
+ const key = `${camera.center}|${camera.zoom}|${camera.pitch}|${camera.bearing}`;
1105
+ const moved = key !== this.lastCamera;
1106
+ if (moved) {
1107
+ // The first frame is no move
1108
+ if (!this.moving && this.lastCamera !== '') {
1109
+ this.moving = true;
1110
+ this.emit('movestart', camera);
1111
+ }
1112
+ this.lastCamera = key;
1113
+ this.emit('move', camera);
1114
+ }
1115
+ else if (this.moving && !this.animation && !this.interaction?.active) {
1116
+ this.moving = false;
1117
+ this.emit('moveend', camera);
1118
+ }
1119
+ // Values for the pointer position are computed before drawing, in the same frame
1120
+ let hovered;
1121
+ if (this.hoverPoint) {
1122
+ const point = this.hoverPoint;
1123
+ hovered = point;
1124
+ this.hoverPoint = undefined;
1125
+ // The position under the pointer for its listeners only (a 'hoverfeature' alone does not need it)
1126
+ if (this.listeners.hover.size > 0)
1127
+ this.emit('hover', { point, lonLat: this.unproject(point) });
1128
+ }
1129
+ const t0 = performance.now();
1130
+ // The engine asks for the next frame while it draws (uploads past the frame budget, fades): not ignored
1131
+ this.inFrame = false;
1132
+ const loading = this.engine.render();
1133
+ this.inFrame = true;
1134
+ // The picking pass sees the frame just drawn; its answer comes on a later frame
1135
+ if (hovered && this.listeners.hoverfeature.size > 0)
1136
+ this.requestHoverPick(hovered);
1137
+ // Overlays follow the frame just drawn (camera, relief altitudes): all in one pass, transforms only
1138
+ for (const item of this.positioned)
1139
+ item.position(this.size);
1140
+ this.emit('render', { ms: performance.now() - t0 });
1141
+ this.inFrame = false;
1142
+ // A move goes on until a frame finds the camera still: that frame sends 'moveend'
1143
+ if (this.animation || (moved && this.moving))
1144
+ this.requestRender();
1145
+ // Idle: nothing loading (the engine with 3D Tiles neither), no animation, no frame asked for (uploads waiting,
1146
+ // fades, listeners)
1147
+ if (loading || this.upgrading || this.animation || this.frameRequest !== 0) {
1148
+ this.settled = false;
1149
+ }
1150
+ else if (!this.settled) {
1151
+ this.settled = true;
1152
+ this.emit('idle', {});
1153
+ }
1154
+ }
1155
+ /** Resizes when the device pixel ratio changes (browser zoom, a window moved to another screen) */
1156
+ watchRatio() {
1157
+ if (typeof matchMedia !== 'function')
1158
+ return;
1159
+ const query = matchMedia(`(resolution: ${window.devicePixelRatio || 1}dppx)`);
1160
+ const changed = () => {
1161
+ // A query for the new ratio
1162
+ this.unwatchRatio?.();
1163
+ this.watchRatio();
1164
+ this.resize();
1165
+ };
1166
+ query.addEventListener('change', changed, { once: true });
1167
+ this.unwatchRatio = () => query.removeEventListener('change', changed);
1168
+ }
1169
+ resize() {
1170
+ if (this.lost)
1171
+ return;
1172
+ const { clientWidth: width, clientHeight: height } = this.canvas;
1173
+ const ratio = window.devicePixelRatio || 1;
1174
+ this.canvas.width = Math.round(width * ratio);
1175
+ this.canvas.height = Math.round(height * ratio);
1176
+ this.engine.resize(width, height, ratio);
1177
+ this.size = [width, height];
1178
+ this.emit('resize', { width, height });
1179
+ // Draw at once: a resized canvas is cleared, waiting one frame would flash
1180
+ cancelAnimationFrame(this.frameRequest);
1181
+ this.frame(performance.now());
1182
+ }
1183
+ destroy() {
1184
+ if (this.gone)
1185
+ return;
1186
+ this.gone = true;
1187
+ // A context restored later rebuilds nothing
1188
+ this.unwatchContext();
1189
+ const camera = this.getCamera();
1190
+ cancelAnimationFrame(this.frameRequest);
1191
+ this.cancelHoverPick();
1192
+ this.vector?.tiles.destroy();
1193
+ for (const tiles of [...this.vectorSets])
1194
+ tiles.destroy();
1195
+ this.vectorSets.clear();
1196
+ this.basemapPool?.terminate();
1197
+ this.basemapPool = undefined;
1198
+ this.unwatchRatio?.();
1199
+ this.unwatchRatio = undefined;
1200
+ this.resizeObserver.disconnect();
1201
+ this.interaction?.destroy();
1202
+ this.loader?.destroy();
1203
+ this.engine.free();
1204
+ // Work still under way (labels being shaped, tiles arriving, timers) may call the engine after this: it finds one
1205
+ // that does nothing, and nothing is drawn again
1206
+ this.#engine = destroyed(camera);
1207
+ // Glyph ranges still arriving upload to it, not to the freed engine
1208
+ this.#glyphs?.attach(this.#engine);
1209
+ this.lost = true;
1210
+ for (const control of [...this.controls.keys()])
1211
+ this.removeControl(control);
1212
+ this.positioned.clear();
1213
+ this.canvas.remove();
1214
+ this.overlay.remove();
1215
+ for (const position of CORNERS)
1216
+ this.corners[position].remove();
1217
+ this.container.classList.remove('isard-map');
1218
+ for (const listeners of Object.values(this.listeners))
1219
+ listeners.clear();
1220
+ }
1221
+ }
1222
+ //# sourceMappingURL=map.js.map