@faclon-labs/app-sdk 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -1
- package/dist/IOSenseApp.js +26 -6
- package/dist/SignIn.js +2 -2
- package/dist/auth.d.ts +7 -0
- package/dist/auth.js +35 -1
- package/dist/config.d.ts +5 -0
- package/dist/config.js +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/map/globe.d.ts +5 -0
- package/dist/map/globe.js +5 -0
- package/dist/map/index.d.ts +21 -0
- package/dist/map/index.js +21 -0
- package/dist/map/source.d.ts +48 -0
- package/dist/map/source.js +73 -0
- package/dist/map/testing.d.ts +5 -0
- package/dist/map/testing.js +5 -0
- package/dist/session.d.ts +1 -1
- package/dist/session.js +1 -1
- package/package.json +55 -5
- package/vendor/SOURCE.txt +1 -0
- package/vendor/map-engine/cesium/camera.d.ts +10 -0
- package/vendor/map-engine/cesium/camera.js +25 -0
- package/vendor/map-engine/cesium/index.d.ts +2 -0
- package/vendor/map-engine/cesium/index.js +2 -0
- package/vendor/map-engine/cesium/renderer.d.ts +149 -0
- package/vendor/map-engine/cesium/renderer.js +837 -0
- package/vendor/map-engine/commands/index.d.ts +2 -0
- package/vendor/map-engine/commands/index.js +2 -0
- package/vendor/map-engine/commands/post-message.d.ts +32 -0
- package/vendor/map-engine/commands/post-message.js +77 -0
- package/vendor/map-engine/commands/webmcp.d.ts +17 -0
- package/vendor/map-engine/commands/webmcp.js +59 -0
- package/vendor/map-engine/core/commands.d.ts +82 -0
- package/vendor/map-engine/core/commands.js +46 -0
- package/vendor/map-engine/core/emitter.d.ts +9 -0
- package/vendor/map-engine/core/emitter.js +38 -0
- package/vendor/map-engine/core/equal.d.ts +2 -0
- package/vendor/map-engine/core/equal.js +28 -0
- package/vendor/map-engine/core/errors.d.ts +20 -0
- package/vendor/map-engine/core/errors.js +34 -0
- package/vendor/map-engine/core/geometry-family.d.ts +3 -0
- package/vendor/map-engine/core/geometry-family.js +13 -0
- package/vendor/map-engine/core/index.d.ts +4 -0
- package/vendor/map-engine/core/index.js +3 -0
- package/vendor/map-engine/core/types.d.ts +709 -0
- package/vendor/map-engine/core/types.js +7 -0
- package/vendor/map-engine/data/annotations.d.ts +3 -0
- package/vendor/map-engine/data/annotations.js +63 -0
- package/vendor/map-engine/data/contracts.d.ts +172 -0
- package/vendor/map-engine/data/contracts.js +12 -0
- package/vendor/map-engine/data/index.d.ts +4 -0
- package/vendor/map-engine/data/index.js +3 -0
- package/vendor/map-engine/data/schema.d.ts +7 -0
- package/vendor/map-engine/data/schema.js +40 -0
- package/vendor/map-engine/geofence/evaluator.d.ts +35 -0
- package/vendor/map-engine/geofence/evaluator.js +172 -0
- package/vendor/map-engine/geofence/history.d.ts +9 -0
- package/vendor/map-engine/geofence/history.js +19 -0
- package/vendor/map-engine/geofence/index.d.ts +2 -0
- package/vendor/map-engine/geofence/index.js +2 -0
- package/vendor/map-engine/index.d.ts +27 -0
- package/vendor/map-engine/index.js +14 -0
- package/vendor/map-engine/layers/index.d.ts +1 -0
- package/vendor/map-engine/layers/index.js +1 -0
- package/vendor/map-engine/layers/layers.d.ts +49 -0
- package/vendor/map-engine/layers/layers.js +316 -0
- package/vendor/map-engine/maplibre/deck-host.d.ts +105 -0
- package/vendor/map-engine/maplibre/deck-host.js +401 -0
- package/vendor/map-engine/maplibre/icons.d.ts +17 -0
- package/vendor/map-engine/maplibre/icons.js +65 -0
- package/vendor/map-engine/maplibre/index.d.ts +3 -0
- package/vendor/map-engine/maplibre/index.js +2 -0
- package/vendor/map-engine/maplibre/map-like.d.ts +56 -0
- package/vendor/map-engine/maplibre/map-like.js +1 -0
- package/vendor/map-engine/maplibre/renderer.d.ts +173 -0
- package/vendor/map-engine/maplibre/renderer.js +889 -0
- package/vendor/map-engine/maplibre/style.d.ts +84 -0
- package/vendor/map-engine/maplibre/style.js +493 -0
- package/vendor/map-engine/present/decorations.d.ts +25 -0
- package/vendor/map-engine/present/decorations.js +115 -0
- package/vendor/map-engine/present/index.d.ts +2 -0
- package/vendor/map-engine/present/index.js +2 -0
- package/vendor/map-engine/present/present.d.ts +47 -0
- package/vendor/map-engine/present/present.js +149 -0
- package/vendor/map-engine/query/index.d.ts +1 -0
- package/vendor/map-engine/query/index.js +1 -0
- package/vendor/map-engine/query/planner.d.ts +26 -0
- package/vendor/map-engine/query/planner.js +47 -0
- package/vendor/map-engine/react/MapView.d.ts +87 -0
- package/vendor/map-engine/react/MapView.js +270 -0
- package/vendor/map-engine/react/context.d.ts +39 -0
- package/vendor/map-engine/react/context.js +94 -0
- package/vendor/map-engine/react/index.d.ts +2 -0
- package/vendor/map-engine/react/index.js +2 -0
- package/vendor/map-engine/react/store.d.ts +19 -0
- package/vendor/map-engine/react/store.js +18 -0
- package/vendor/map-engine/realtime/apply.d.ts +74 -0
- package/vendor/map-engine/realtime/apply.js +155 -0
- package/vendor/map-engine/realtime/index.d.ts +1 -0
- package/vendor/map-engine/realtime/index.js +1 -0
- package/vendor/map-engine/render/index.d.ts +1 -0
- package/vendor/map-engine/render/index.js +1 -0
- package/vendor/map-engine/render/offline.d.ts +8 -0
- package/vendor/map-engine/render/offline.js +47 -0
- package/vendor/map-engine/render/renderer.d.ts +74 -0
- package/vendor/map-engine/render/renderer.js +1 -0
- package/vendor/map-engine/render/terrarium.d.ts +48 -0
- package/vendor/map-engine/render/terrarium.js +110 -0
- package/vendor/map-engine/runtime/drawing.d.ts +28 -0
- package/vendor/map-engine/runtime/drawing.js +58 -0
- package/vendor/map-engine/runtime/index.d.ts +5 -0
- package/vendor/map-engine/runtime/index.js +2 -0
- package/vendor/map-engine/runtime/offline.d.ts +24 -0
- package/vendor/map-engine/runtime/offline.js +37 -0
- package/vendor/map-engine/runtime/overlays.d.ts +25 -0
- package/vendor/map-engine/runtime/overlays.js +65 -0
- package/vendor/map-engine/runtime/runtime.d.ts +4 -0
- package/vendor/map-engine/runtime/runtime.js +2105 -0
- package/vendor/map-engine/runtime/types.d.ts +407 -0
- package/vendor/map-engine/runtime/types.js +1 -0
- package/vendor/map-engine/scene/index.d.ts +1 -0
- package/vendor/map-engine/scene/index.js +1 -0
- package/vendor/map-engine/scene/scene.d.ts +31 -0
- package/vendor/map-engine/scene/scene.js +216 -0
- package/vendor/map-engine/selection/index.d.ts +1 -0
- package/vendor/map-engine/selection/index.js +1 -0
- package/vendor/map-engine/selection/selection.d.ts +12 -0
- package/vendor/map-engine/selection/selection.js +28 -0
- package/vendor/map-engine/spatial/geojson.d.ts +15 -0
- package/vendor/map-engine/spatial/geojson.js +83 -0
- package/vendor/map-engine/spatial/geometry.d.ts +13 -0
- package/vendor/map-engine/spatial/geometry.js +206 -0
- package/vendor/map-engine/spatial/import.d.ts +27 -0
- package/vendor/map-engine/spatial/import.js +96 -0
- package/vendor/map-engine/spatial/index.d.ts +38 -0
- package/vendor/map-engine/spatial/index.js +37 -0
- package/vendor/map-engine/spatial/line-of-sight.d.ts +35 -0
- package/vendor/map-engine/spatial/line-of-sight.js +43 -0
- package/vendor/map-engine/spatial/measure.d.ts +28 -0
- package/vendor/map-engine/spatial/measure.js +90 -0
- package/vendor/map-engine/spatial/operations.d.ts +21 -0
- package/vendor/map-engine/spatial/operations.js +171 -0
- package/vendor/map-engine/spatial/query.d.ts +17 -0
- package/vendor/map-engine/spatial/query.js +88 -0
- package/vendor/map-engine/spatial/spatial-index.d.ts +24 -0
- package/vendor/map-engine/spatial/spatial-index.js +36 -0
- package/vendor/map-engine/spatial/volume-mesh.d.ts +19 -0
- package/vendor/map-engine/spatial/volume-mesh.js +56 -0
- package/vendor/map-engine/temporal/index.d.ts +3 -0
- package/vendor/map-engine/temporal/index.js +3 -0
- package/vendor/map-engine/temporal/journal.d.ts +20 -0
- package/vendor/map-engine/temporal/journal.js +34 -0
- package/vendor/map-engine/temporal/playback.d.ts +41 -0
- package/vendor/map-engine/temporal/playback.js +80 -0
- package/vendor/map-engine/temporal/tracks.d.ts +33 -0
- package/vendor/map-engine/temporal/tracks.js +119 -0
- package/vendor/map-engine/testing/annotation-store.d.ts +24 -0
- package/vendor/map-engine/testing/annotation-store.js +43 -0
- package/vendor/map-engine/testing/contracts.d.ts +41 -0
- package/vendor/map-engine/testing/contracts.js +328 -0
- package/vendor/map-engine/testing/fixtures-monitoring.d.ts +22 -0
- package/vendor/map-engine/testing/fixtures-monitoring.js +103 -0
- package/vendor/map-engine/testing/fixtures.d.ts +18 -0
- package/vendor/map-engine/testing/fixtures.js +28 -0
- package/vendor/map-engine/testing/index.d.ts +7 -0
- package/vendor/map-engine/testing/index.js +7 -0
- package/vendor/map-engine/testing/mock-ontology.d.ts +13 -0
- package/vendor/map-engine/testing/mock-ontology.js +24 -0
- package/vendor/map-engine/testing/mock-provider.d.ts +97 -0
- package/vendor/map-engine/testing/mock-provider.js +369 -0
- package/vendor/map-engine/testing/test-renderer.d.ts +72 -0
- package/vendor/map-engine/testing/test-renderer.js +153 -0
- package/vendor/map-engine/viewport/index.d.ts +1 -0
- package/vendor/map-engine/viewport/index.js +1 -0
- package/vendor/map-engine/viewport/viewport.d.ts +19 -0
- package/vendor/map-engine/viewport/viewport.js +102 -0
- package/vendor/map-provider-uns/client.d.ts +75 -0
- package/vendor/map-provider-uns/client.js +111 -0
- package/vendor/map-provider-uns/index.d.ts +2 -0
- package/vendor/map-provider-uns/index.js +2 -0
- package/vendor/map-provider-uns/provider.d.ts +131 -0
- package/vendor/map-provider-uns/provider.js +561 -0
|
@@ -0,0 +1,889 @@
|
|
|
1
|
+
import { deepEqual } from '../core/equal.js';
|
|
2
|
+
import { DemSampler } from '../render/terrarium.js';
|
|
3
|
+
import { fromLngLat, spatial, toFeature, toLngLat } from '../spatial/index.js';
|
|
4
|
+
import { isLocalUrl, styleUrls } from '../render/offline.js';
|
|
5
|
+
import { ICON_PIXELS, shapeImage } from './icons.js';
|
|
6
|
+
import { DeckHost, hostedByDeck, loadDeck, shimTransformForDeck } from './deck-host.js';
|
|
7
|
+
import { CONTOUR_LABELS, CONTOUR_LINES, CONTOUR_SOURCE, contourLayers, isPmtiles, PREFIX, DEFAULT_THEME, HILLSHADE_LAYER, HILLSHADE_SOURCE, TERRAIN_SOURCE, demSourceFor, hillshadeLayer, LABEL_POINT_PROP, LAYER_ID_PROP, OBJECT_ID_PROP, basemapStyle, needsLabelPoints, withLabelFont, imagesFor, sourceId, sourceSpecFor, styleLayersFor } from './style.js';
|
|
8
|
+
/** The label point of an area shares its source; its feature id is the shape's id plus this offset. */
|
|
9
|
+
const LABEL_ID_OFFSET = 2 ** 30;
|
|
10
|
+
const splitKey = (key) => {
|
|
11
|
+
const i = key.indexOf(':'); // layer ids cannot contain ':'; object ids may
|
|
12
|
+
return [key.slice(0, i), key.slice(i + 1)];
|
|
13
|
+
};
|
|
14
|
+
/** MapLibre reports ErrorLike ({ message }) — not always a real Error. */
|
|
15
|
+
const toError = (e, fallback) => (e instanceof Error ? e : new Error(e?.message ?? fallback));
|
|
16
|
+
const normalizeBearing = (b) => ((b % 360) + 360) % 360;
|
|
17
|
+
/** MapLibre modules that already speak pmtiles:// (the protocol is global to the module). */
|
|
18
|
+
const pmtilesReady = new WeakSet();
|
|
19
|
+
/** An image's four corners (top-left, top-right, bottom-right, bottom-left) as [lng, lat]: a four-corner polygon as given, else the bounds. */
|
|
20
|
+
function imageCorners(g) {
|
|
21
|
+
if (g.type === 'Polygon') {
|
|
22
|
+
const ring = g.coordinates[0] ?? [];
|
|
23
|
+
const pts = ring.length > 1 && ring[0].latitude === ring[ring.length - 1].latitude && ring[0].longitude === ring[ring.length - 1].longitude ? ring.slice(0, -1) : ring;
|
|
24
|
+
if (pts.length === 4)
|
|
25
|
+
return pts.map((p) => toLngLat(p));
|
|
26
|
+
}
|
|
27
|
+
if (g.type === 'Point' || g.type === 'LineString' || g.type === 'MultiPoint')
|
|
28
|
+
return null;
|
|
29
|
+
const b = spatial.bounds(g);
|
|
30
|
+
return [[b.west, b.north], [b.east, b.north], [b.east, b.south], [b.west, b.south]];
|
|
31
|
+
}
|
|
32
|
+
const clampLon = (v) => Math.max(-180, Math.min(180, v));
|
|
33
|
+
const clampLat = (v) => Math.max(-85.051129, Math.min(85.051129, v));
|
|
34
|
+
export class MapLibreRenderer {
|
|
35
|
+
options;
|
|
36
|
+
/**
|
|
37
|
+
* Globe, terrain and ground heights (3B); heights and models through deck.gl, extrusions natively (3C).
|
|
38
|
+
* Known limit: with MapLibre v6 and deck.gl 9.4, deck.gl layers do not draw in the globe projection — so in globe
|
|
39
|
+
* mode heights and models are not claimed (the runtime reports the gap) and those layers are drawn flat by
|
|
40
|
+
* MapLibre instead of disappearing. A full 3D globe is the Cesium renderer's job (phase 3D).
|
|
41
|
+
*/
|
|
42
|
+
get capabilities() {
|
|
43
|
+
const lifted = this.projection !== 'globe';
|
|
44
|
+
return { globe: true, terrain: true, elevation: true, extrusion: true, altitude: lifted, models: lifted, volumes: lifted };
|
|
45
|
+
}
|
|
46
|
+
/** The layers last given by the scene (before any globe-mode flattening). */
|
|
47
|
+
sceneLayers = [];
|
|
48
|
+
/** deck.gl host for layers at a height and models (created with the map, loads deck.gl on first use). */
|
|
49
|
+
deckHost = null;
|
|
50
|
+
fontName;
|
|
51
|
+
groundRefresh = null;
|
|
52
|
+
projection = 'mercator';
|
|
53
|
+
terrain = null;
|
|
54
|
+
dem = null;
|
|
55
|
+
map = null;
|
|
56
|
+
lib = null;
|
|
57
|
+
offline = null;
|
|
58
|
+
/** Image layers: per layer, per object, the image source drawn (doc 28 G12). */
|
|
59
|
+
images = new Map();
|
|
60
|
+
/** Contour tiles from the terrain's DEM (doc 28 G11), created on first use. */
|
|
61
|
+
contourDem = null;
|
|
62
|
+
theme;
|
|
63
|
+
layers = new Map();
|
|
64
|
+
features = new Map();
|
|
65
|
+
mapHandlers = [];
|
|
66
|
+
hoverKey = null;
|
|
67
|
+
hasFont = false;
|
|
68
|
+
warnedNoFont = false;
|
|
69
|
+
/** Bumped by destroy(): an initialize() or setBasemap() that started before it is stale. */
|
|
70
|
+
gen = 0;
|
|
71
|
+
/** Rejects the style load being waited for (destroy() during initialize or a basemap swap). */
|
|
72
|
+
cancelWait = null;
|
|
73
|
+
/** The one-off check that engine data really reached MapLibre's worker (see workerCheckMs). */
|
|
74
|
+
workerCheck = null;
|
|
75
|
+
workerChecked = false;
|
|
76
|
+
listeners = {
|
|
77
|
+
viewport: new Set(),
|
|
78
|
+
pick: new Set(),
|
|
79
|
+
click: new Set(),
|
|
80
|
+
hover: new Set(),
|
|
81
|
+
error: new Set(),
|
|
82
|
+
move: new Set(),
|
|
83
|
+
};
|
|
84
|
+
constructor(options = {}) {
|
|
85
|
+
this.options = options;
|
|
86
|
+
this.theme = { ...DEFAULT_THEME, ...options.theme };
|
|
87
|
+
}
|
|
88
|
+
async initialize(container, options) {
|
|
89
|
+
const gen = this.gen;
|
|
90
|
+
const lib = this.options.maplibre ?? (await import('maplibre-gl'));
|
|
91
|
+
if (gen !== this.gen)
|
|
92
|
+
return;
|
|
93
|
+
this.lib = lib;
|
|
94
|
+
this.offline = options.offline?.allowedOrigins ?? null;
|
|
95
|
+
const archives = [options.basemap.tileUrl, options.basemap.styleUrl, options.basemap.rasterFallback, options.terrain?.tileUrl];
|
|
96
|
+
if (archives.some(isPmtiles)) {
|
|
97
|
+
await this.ensurePmtiles(archives);
|
|
98
|
+
if (gen !== this.gen)
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
this.hasFont = options.basemap.font !== undefined;
|
|
102
|
+
this.fontName = options.basemap.font?.name;
|
|
103
|
+
this.warnedNoFont = false;
|
|
104
|
+
this.hoverKey = null;
|
|
105
|
+
this.projection = options.projection ?? 'mercator';
|
|
106
|
+
this.terrain = options.terrain ?? null;
|
|
107
|
+
if (this.options.workerUrl)
|
|
108
|
+
lib.setWorkerUrl?.(this.options.workerUrl);
|
|
109
|
+
const v = options.viewport;
|
|
110
|
+
const style = await this.resolveStyle(options.basemap);
|
|
111
|
+
if (gen !== this.gen)
|
|
112
|
+
return;
|
|
113
|
+
const map = new lib.Map({
|
|
114
|
+
// A style declares its sources' credits (often required, e.g. OpenStreetMap), so it always gets the control.
|
|
115
|
+
attributionControl: options.basemap.attribution || options.basemap.kind === 'style' ? { compact: true } : false,
|
|
116
|
+
...this.options.mapOptions,
|
|
117
|
+
container,
|
|
118
|
+
style,
|
|
119
|
+
center: toLngLat(v.center),
|
|
120
|
+
zoom: v.zoom,
|
|
121
|
+
bearing: v.bearing ?? 0,
|
|
122
|
+
pitch: v.pitch ?? 0,
|
|
123
|
+
// A tilted 3D view (terrain, objects in the air) needs more than MapLibre's default 60°.
|
|
124
|
+
maxPitch: 85,
|
|
125
|
+
});
|
|
126
|
+
this.map = map;
|
|
127
|
+
await this.waitFor(map, 'load');
|
|
128
|
+
if (gen !== this.gen)
|
|
129
|
+
return;
|
|
130
|
+
this.applyEnvironment();
|
|
131
|
+
this.deckHost = new DeckHost({
|
|
132
|
+
addControl: (o) => {
|
|
133
|
+
shimTransformForDeck(map);
|
|
134
|
+
map.addControl(o);
|
|
135
|
+
},
|
|
136
|
+
removeControl: (o) => map.removeControl(o),
|
|
137
|
+
theme: this.theme,
|
|
138
|
+
font: () => this.fontName,
|
|
139
|
+
terrainExaggeration: () => (this.terrain ? (this.terrain.exaggeration ?? 1) : 1),
|
|
140
|
+
ground: (lng, lat) => (this.terrain ? (map.queryTerrainElevation([lng, lat]) ?? 0) : 0),
|
|
141
|
+
warn: (e) => this.emitError(e),
|
|
142
|
+
}, this.options.deck ? async () => this.options.deck : loadDeck);
|
|
143
|
+
this.bindMapEvents(map);
|
|
144
|
+
}
|
|
145
|
+
setProjection(projection) {
|
|
146
|
+
const changed = projection !== this.projection;
|
|
147
|
+
this.projection = projection;
|
|
148
|
+
if (!this.map)
|
|
149
|
+
return;
|
|
150
|
+
this.applyEnvironment();
|
|
151
|
+
// Heights and models move between deck.gl and flat MapLibre drawing with the projection.
|
|
152
|
+
if (changed && this.sceneLayers.length)
|
|
153
|
+
this.applyLayers(this.sceneLayers.map((l) => this.drawable(l)));
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* The layer as this renderer can draw it now: in globe mode, layers at a height and models are drawn flat by
|
|
157
|
+
* MapLibre (see `capabilities`), so their objects stay visible.
|
|
158
|
+
*/
|
|
159
|
+
drawable(layer) {
|
|
160
|
+
if (this.projection !== 'globe' || !hostedByDeck(layer))
|
|
161
|
+
return layer;
|
|
162
|
+
const { elevation: _e, model: _m, volume: _v, ...vz } = layer.visualization;
|
|
163
|
+
return { ...layer, visualization: { ...vz, type: vz.type === 'model' || vz.type === 'volume' ? 'marker' : vz.type } };
|
|
164
|
+
}
|
|
165
|
+
setTerrain(terrain) {
|
|
166
|
+
this.terrain = terrain;
|
|
167
|
+
if (!this.map)
|
|
168
|
+
return;
|
|
169
|
+
if (isPmtiles(terrain?.tileUrl) && !(this.lib && pmtilesReady.has(this.lib))) {
|
|
170
|
+
void this.ensurePmtiles([terrain?.tileUrl]).then(() => this.map && this.terrain === terrain && this.applyEnvironment());
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
this.applyEnvironment();
|
|
174
|
+
}
|
|
175
|
+
/** pmtiles:// archives (offline tile packs, doc 28 G14): the protocol is loaded the first time a URL uses it. */
|
|
176
|
+
async ensurePmtiles(urls) {
|
|
177
|
+
const lib = this.lib;
|
|
178
|
+
if (!lib || pmtilesReady.has(lib) || !urls.some(isPmtiles))
|
|
179
|
+
return;
|
|
180
|
+
if (!lib.addProtocol)
|
|
181
|
+
return this.emitError(new Error('pmtiles:// needs a MapLibre module with addProtocol'));
|
|
182
|
+
const { Protocol } = await import('pmtiles');
|
|
183
|
+
if (pmtilesReady.has(lib))
|
|
184
|
+
return;
|
|
185
|
+
lib.addProtocol('pmtiles', new Protocol().tile);
|
|
186
|
+
pmtilesReady.add(lib);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Ground heights from the terrain the map shows. MapLibre answers only where terrain tiles are loaded (null
|
|
190
|
+
* elsewhere) and multiplies by the exaggeration; the engine reports true metres, so that is divided out.
|
|
191
|
+
*/
|
|
192
|
+
/**
|
|
193
|
+
* True heights from the DEM tiles, decoded by the engine. Not MapLibre's queryTerrainElevation: it answers 0 (sea
|
|
194
|
+
* level, not "unknown") wherever the view has no tile loaded, so a line of sight off screen came out flat.
|
|
195
|
+
*/
|
|
196
|
+
async sampleElevation(positions) {
|
|
197
|
+
if (!this.map || !this.terrain)
|
|
198
|
+
return positions.map(() => null);
|
|
199
|
+
if (this.dem?.terrain !== this.terrain)
|
|
200
|
+
this.dem = { terrain: this.terrain, sampler: new DemSampler(this.terrain, undefined, undefined, this.options.loadDemTile) };
|
|
201
|
+
return this.dem.sampler.sample(positions);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Projection, terrain (3D surface + hillshade) and sky — the map's environment, under every engine layer.
|
|
205
|
+
* Applied after load and again after a basemap swap (a new style drops them).
|
|
206
|
+
*/
|
|
207
|
+
applyEnvironment() {
|
|
208
|
+
const map = this.map;
|
|
209
|
+
map.setProjection({ type: this.projection });
|
|
210
|
+
const t = this.terrain;
|
|
211
|
+
if (t) {
|
|
212
|
+
if (!map.getSource(TERRAIN_SOURCE))
|
|
213
|
+
map.addSource(TERRAIN_SOURCE, demSourceFor(t));
|
|
214
|
+
if (!map.getSource(HILLSHADE_SOURCE))
|
|
215
|
+
map.addSource(HILLSHADE_SOURCE, demSourceFor(t));
|
|
216
|
+
// Under the engine's layers: they are moved to the top as they are added.
|
|
217
|
+
if (!map.getLayer(HILLSHADE_LAYER))
|
|
218
|
+
map.addLayer(hillshadeLayer(this.theme), this.firstEngineLayer());
|
|
219
|
+
map.setTerrain({ source: TERRAIN_SOURCE, exaggeration: t.exaggeration ?? 1 });
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
map.setTerrain(null);
|
|
223
|
+
if (map.getLayer(HILLSHADE_LAYER))
|
|
224
|
+
map.removeLayer(HILLSHADE_LAYER);
|
|
225
|
+
for (const id of [HILLSHADE_SOURCE, TERRAIN_SOURCE])
|
|
226
|
+
if (map.getSource(id))
|
|
227
|
+
map.removeSource(id);
|
|
228
|
+
}
|
|
229
|
+
this.deckHost?.refresh(); // heights stand on the ground and scale with its exaggeration
|
|
230
|
+
void this.applyContours();
|
|
231
|
+
// A sky shows when the camera tilts above the horizon, and gives the globe its atmosphere.
|
|
232
|
+
map.setSky(t || this.projection === 'globe' ? { 'sky-color': this.theme.skyColor, 'horizon-color': this.theme.horizonColor, 'sky-horizon-blend': 0.5, 'atmosphere-blend': this.projection === 'globe' ? 0.8 : 0 } : {});
|
|
233
|
+
}
|
|
234
|
+
/** Contour lines from the terrain's DEM tiles (doc 28 G11), under every engine layer; removed when off. */
|
|
235
|
+
async applyContours() {
|
|
236
|
+
const map = this.map;
|
|
237
|
+
if (!map)
|
|
238
|
+
return;
|
|
239
|
+
const t = this.terrain;
|
|
240
|
+
const c = t?.contours;
|
|
241
|
+
const drop = () => {
|
|
242
|
+
for (const id of [CONTOUR_LABELS, CONTOUR_LINES])
|
|
243
|
+
if (map.getLayer(id))
|
|
244
|
+
map.removeLayer(id);
|
|
245
|
+
if (map.getSource(CONTOUR_SOURCE))
|
|
246
|
+
map.removeSource(CONTOUR_SOURCE);
|
|
247
|
+
};
|
|
248
|
+
if (!t || !c)
|
|
249
|
+
return drop();
|
|
250
|
+
if (isPmtiles(t.tileUrl))
|
|
251
|
+
return this.emitError(new Error('contours read DEM tiles by URL template; a pmtiles:// terrain has no contours yet'));
|
|
252
|
+
if (!this.lib?.addProtocol)
|
|
253
|
+
return this.emitError(new Error('contours need a MapLibre module with addProtocol'));
|
|
254
|
+
const gen = this.gen;
|
|
255
|
+
const mod = (await import('maplibre-contour')).default;
|
|
256
|
+
if (gen !== this.gen || this.map !== map || this.terrain !== t)
|
|
257
|
+
return;
|
|
258
|
+
if (!this.contourDem || this.contourDem.url !== t.tileUrl) {
|
|
259
|
+
const source = new mod.DemSource({ url: t.tileUrl, encoding: t.encoding ?? 'terrarium', maxzoom: Math.min(t.maxZoom ?? 14, 14), worker: false });
|
|
260
|
+
source.setupMaplibre(this.lib);
|
|
261
|
+
this.contourDem = { url: t.tileUrl, source };
|
|
262
|
+
}
|
|
263
|
+
const major = c.majorEvery ?? 5;
|
|
264
|
+
// Coarser lines when zoomed out, the configured interval from zoom 13.
|
|
265
|
+
const tiles = this.contourDem.source.contourProtocolUrl({ thresholds: { 9: [c.interval * 4, c.interval * 4 * major], 11: [c.interval * 2, c.interval * 2 * major], 13: [c.interval, c.interval * major] }, contourLayer: 'contours', elevationKey: 'ele', levelKey: 'level' });
|
|
266
|
+
drop();
|
|
267
|
+
map.addSource(CONTOUR_SOURCE, { type: 'vector', tiles: [tiles], minzoom: 9, maxzoom: 15 });
|
|
268
|
+
for (const spec of contourLayers(this.theme, c.color, this.hasFont))
|
|
269
|
+
map.addLayer(spec, this.firstEngineLayer());
|
|
270
|
+
}
|
|
271
|
+
/** The first engine style layer in draw order, so environment layers can go beneath it. */
|
|
272
|
+
firstEngineLayer() {
|
|
273
|
+
for (const layer of [...this.layers.values()].sort((a, b) => a.zIndex - b.zIndex)) {
|
|
274
|
+
const first = styleLayersFor(layer, this.theme, this.hasFont)[0];
|
|
275
|
+
if (first && this.map?.getLayer(first.id))
|
|
276
|
+
return first.id;
|
|
277
|
+
}
|
|
278
|
+
return undefined;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* A style swap (MapLibre setStyle): the camera, canvas and GPU context stay. Every engine source and layer goes
|
|
282
|
+
* with the old style, so the caller renders the full scene again afterwards (Renderer contract).
|
|
283
|
+
*/
|
|
284
|
+
async setBasemap(basemap) {
|
|
285
|
+
const map = this.map;
|
|
286
|
+
if (!map)
|
|
287
|
+
return;
|
|
288
|
+
const gen = this.gen;
|
|
289
|
+
await this.ensurePmtiles([basemap.tileUrl, basemap.styleUrl, basemap.rasterFallback]);
|
|
290
|
+
const style = await this.resolveStyle(basemap);
|
|
291
|
+
if (gen !== this.gen || this.map !== map)
|
|
292
|
+
return;
|
|
293
|
+
this.hasFont = basemap.font !== undefined;
|
|
294
|
+
this.fontName = basemap.font?.name;
|
|
295
|
+
this.warnedNoFont = false;
|
|
296
|
+
this.hoverKey = null;
|
|
297
|
+
this.layers.clear();
|
|
298
|
+
this.features.clear();
|
|
299
|
+
this.images.clear();
|
|
300
|
+
const loaded = this.waitFor(map, 'style.load');
|
|
301
|
+
map.setStyle(style, { diff: false });
|
|
302
|
+
await loaded;
|
|
303
|
+
if (gen !== this.gen || this.map !== map)
|
|
304
|
+
return;
|
|
305
|
+
this.applyEnvironment(); // the new style has no terrain, projection or sky of ours (and re-places deck.gl layers)
|
|
306
|
+
}
|
|
307
|
+
/** The style for a basemap. A street-map style brings its own glyph server; engine labels use the app font, so it is added. */
|
|
308
|
+
async resolveStyle(basemap) {
|
|
309
|
+
const style = basemapStyle(basemap, this.theme);
|
|
310
|
+
if (typeof style !== 'string' || (!basemap.font && !this.offline))
|
|
311
|
+
return style;
|
|
312
|
+
const fetchFn = this.options.fetch ?? ((url) => fetch(url));
|
|
313
|
+
const res = await fetchFn(style);
|
|
314
|
+
if (!res.ok)
|
|
315
|
+
throw new Error(`basemap style ${style} returned HTTP ${res.status}`);
|
|
316
|
+
const json = (await res.json());
|
|
317
|
+
if (this.offline) {
|
|
318
|
+
// Offline profile: what the style file itself points at must stay on the site too (doc 28 G14).
|
|
319
|
+
const leaving = styleUrls(json).filter((u) => !isLocalUrl(u, this.offline));
|
|
320
|
+
if (leaving.length)
|
|
321
|
+
throw new Error(`offline profile: the basemap style points outside the site: ${leaving.slice(0, 5).join(', ')}`);
|
|
322
|
+
}
|
|
323
|
+
return (basemap.font ? withLabelFont(json, style, basemap.font.url) : json);
|
|
324
|
+
}
|
|
325
|
+
/** Waits for a style to load ('load' first time, 'style.load' after a swap); destroy() cancels the wait. */
|
|
326
|
+
waitFor(map, event) {
|
|
327
|
+
return new Promise((resolve, reject) => {
|
|
328
|
+
const timeout = setTimeout(() => {
|
|
329
|
+
cleanup();
|
|
330
|
+
reject(new Error(`map style did not load within ${this.options.loadTimeoutMs ?? 15000} ms`));
|
|
331
|
+
}, this.options.loadTimeoutMs ?? 15000);
|
|
332
|
+
const onLoad = () => {
|
|
333
|
+
cleanup();
|
|
334
|
+
resolve();
|
|
335
|
+
};
|
|
336
|
+
const onError = (e) => {
|
|
337
|
+
// A tile that fails (offline, 404, rate limit) is not a failed map: keep waiting for the style.
|
|
338
|
+
if (e?.tile !== undefined || e?.sourceId !== undefined)
|
|
339
|
+
return;
|
|
340
|
+
cleanup();
|
|
341
|
+
reject(toError(e?.error, 'map failed to load'));
|
|
342
|
+
};
|
|
343
|
+
const cancel = (reason) => {
|
|
344
|
+
cleanup();
|
|
345
|
+
reject(reason);
|
|
346
|
+
};
|
|
347
|
+
const cleanup = () => {
|
|
348
|
+
clearTimeout(timeout);
|
|
349
|
+
map.off(event, onLoad);
|
|
350
|
+
map.off('error', onError);
|
|
351
|
+
if (this.cancelWait === cancel)
|
|
352
|
+
this.cancelWait = null;
|
|
353
|
+
};
|
|
354
|
+
this.cancelWait = cancel;
|
|
355
|
+
map.on(event, onLoad);
|
|
356
|
+
map.on('error', onError);
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
setViewport(viewport, options) {
|
|
360
|
+
if (!this.map)
|
|
361
|
+
return;
|
|
362
|
+
const camera = { center: toLngLat(viewport.center), zoom: viewport.zoom, bearing: viewport.bearing ?? 0, pitch: viewport.pitch ?? 0 };
|
|
363
|
+
if (options?.animate)
|
|
364
|
+
this.map.easeTo(camera);
|
|
365
|
+
else
|
|
366
|
+
this.map.jumpTo(camera);
|
|
367
|
+
}
|
|
368
|
+
render(scene) {
|
|
369
|
+
if (!this.map)
|
|
370
|
+
return;
|
|
371
|
+
for (const id of [...this.layers.keys()])
|
|
372
|
+
this.removeEngineLayer(id);
|
|
373
|
+
this.applyLayers(scene.layers.map((l) => this.drawable(l)));
|
|
374
|
+
this.sceneLayers = scene.layers;
|
|
375
|
+
this.applyObjects({ added: scene.objects });
|
|
376
|
+
}
|
|
377
|
+
update(patch) {
|
|
378
|
+
if (!this.map)
|
|
379
|
+
return;
|
|
380
|
+
if (patch.layers) {
|
|
381
|
+
this.sceneLayers = patch.layers;
|
|
382
|
+
this.applyLayers(patch.layers.map((l) => this.drawable(l)));
|
|
383
|
+
}
|
|
384
|
+
this.applyObjects(patch);
|
|
385
|
+
}
|
|
386
|
+
resize() {
|
|
387
|
+
if (!this.map)
|
|
388
|
+
return;
|
|
389
|
+
this.map.resize();
|
|
390
|
+
// MapLibre does not fire moveend on resize, but the visible bounds changed.
|
|
391
|
+
const v = this.currentViewport();
|
|
392
|
+
for (const l of this.listeners.viewport)
|
|
393
|
+
l(v);
|
|
394
|
+
}
|
|
395
|
+
project(position) {
|
|
396
|
+
if (!this.map)
|
|
397
|
+
return null;
|
|
398
|
+
const p = this.map.project(toLngLat(position));
|
|
399
|
+
return { x: p.x, y: p.y };
|
|
400
|
+
}
|
|
401
|
+
onCameraMove(l) {
|
|
402
|
+
const fn = () => l();
|
|
403
|
+
this.listeners.move.add(fn);
|
|
404
|
+
return () => this.listeners.move.delete(fn);
|
|
405
|
+
}
|
|
406
|
+
getSize() {
|
|
407
|
+
const c = this.map?.getContainer();
|
|
408
|
+
return { width: c?.clientWidth ?? 0, height: c?.clientHeight ?? 0 };
|
|
409
|
+
}
|
|
410
|
+
destroy() {
|
|
411
|
+
this.gen++;
|
|
412
|
+
this.cancelWait?.(new Error('renderer destroyed'));
|
|
413
|
+
this.deckHost?.destroy();
|
|
414
|
+
this.deckHost = null;
|
|
415
|
+
if (this.groundRefresh !== null)
|
|
416
|
+
clearTimeout(this.groundRefresh);
|
|
417
|
+
this.groundRefresh = null;
|
|
418
|
+
if (this.workerCheck !== null)
|
|
419
|
+
clearTimeout(this.workerCheck);
|
|
420
|
+
this.workerCheck = null;
|
|
421
|
+
this.workerChecked = false;
|
|
422
|
+
const map = this.map;
|
|
423
|
+
this.map = null;
|
|
424
|
+
if (map) {
|
|
425
|
+
for (const off of this.mapHandlers)
|
|
426
|
+
off();
|
|
427
|
+
try {
|
|
428
|
+
map.remove();
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
// Removing must always complete.
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
this.mapHandlers.length = 0;
|
|
435
|
+
this.layers.clear();
|
|
436
|
+
this.features.clear();
|
|
437
|
+
for (const set of Object.values(this.listeners))
|
|
438
|
+
set.clear();
|
|
439
|
+
}
|
|
440
|
+
onViewportChange(l) {
|
|
441
|
+
return this.add(this.listeners.viewport, l);
|
|
442
|
+
}
|
|
443
|
+
onPick(l) {
|
|
444
|
+
return this.add(this.listeners.pick, l);
|
|
445
|
+
}
|
|
446
|
+
onMapClick(l) {
|
|
447
|
+
return this.add(this.listeners.click, l);
|
|
448
|
+
}
|
|
449
|
+
onHover(l) {
|
|
450
|
+
return this.add(this.listeners.hover, l);
|
|
451
|
+
}
|
|
452
|
+
onError(l) {
|
|
453
|
+
return this.add(this.listeners.error, l);
|
|
454
|
+
}
|
|
455
|
+
// ---------------------------------------------------------------- layers
|
|
456
|
+
/** Adds, removes, restyles, shows/hides and reorders engine layers. Basemap layers are never touched. */
|
|
457
|
+
applyLayers(next) {
|
|
458
|
+
const map = this.map;
|
|
459
|
+
const nextIds = new Set(next.map((l) => l.id));
|
|
460
|
+
for (const id of [...this.layers.keys()])
|
|
461
|
+
if (!nextIds.has(id))
|
|
462
|
+
this.removeEngineLayer(id);
|
|
463
|
+
if (!this.hasFont && !this.warnedNoFont && next.some((l) => l.visualization.label)) {
|
|
464
|
+
this.warnedNoFont = true;
|
|
465
|
+
this.emitError(new Error('labels need a label font: set basemap.font = { name, url } (no glyph server is used)'));
|
|
466
|
+
}
|
|
467
|
+
for (const layer of next) {
|
|
468
|
+
const prev = this.layers.get(layer.id);
|
|
469
|
+
this.ensureImages(layer);
|
|
470
|
+
if (!prev) {
|
|
471
|
+
map.addSource(sourceId(layer.id), sourceSpecFor(layer));
|
|
472
|
+
this.features.set(layer.id, { ids: new Map(), objects: new Map(), next: 1 });
|
|
473
|
+
for (const spec of styleLayersFor(layer, this.theme, this.hasFont))
|
|
474
|
+
map.addLayer(spec);
|
|
475
|
+
}
|
|
476
|
+
else if (!deepEqual(sourceSpecFor(prev), sourceSpecFor(layer))) {
|
|
477
|
+
// Clustering is a source option: rebuild this layer's source, then put its objects back (same feature ids).
|
|
478
|
+
for (const spec of styleLayersFor(prev, this.theme, this.hasFont))
|
|
479
|
+
if (map.getLayer(spec.id))
|
|
480
|
+
map.removeLayer(spec.id);
|
|
481
|
+
map.removeSource(sourceId(layer.id));
|
|
482
|
+
map.addSource(sourceId(layer.id), sourceSpecFor(layer));
|
|
483
|
+
for (const spec of styleLayersFor(layer, this.theme, this.hasFont))
|
|
484
|
+
map.addLayer(spec);
|
|
485
|
+
const lf = this.features.get(layer.id);
|
|
486
|
+
this.layers.set(layer.id, structuredClone(layer));
|
|
487
|
+
const src = this.source(layer.id);
|
|
488
|
+
if (src)
|
|
489
|
+
this.writeSource(layer.id, src, [], [...lf.objects.values()].flatMap((so) => this.featuresOf(so, lf.ids.get(so.object.objectId), layer)));
|
|
490
|
+
for (const so of lf.objects.values())
|
|
491
|
+
if (so.selected || so.hovered)
|
|
492
|
+
map.setFeatureState({ source: sourceId(layer.id), id: lf.ids.get(so.object.objectId) }, { selected: so.selected, hovered: so.hovered });
|
|
493
|
+
}
|
|
494
|
+
else if (!deepEqual(prev.visualization, layer.visualization)) {
|
|
495
|
+
// Style or type changed: rebuild only this layer's style layers; the source and its data stay.
|
|
496
|
+
for (const spec of styleLayersFor(prev, this.theme, this.hasFont))
|
|
497
|
+
if (map.getLayer(spec.id))
|
|
498
|
+
map.removeLayer(spec.id);
|
|
499
|
+
for (const spec of styleLayersFor(layer, this.theme, this.hasFont))
|
|
500
|
+
map.addLayer(spec);
|
|
501
|
+
if (needsLabelPoints(prev) !== needsLabelPoints(layer) || prev.visualization.label?.placement !== layer.visualization.label?.placement) {
|
|
502
|
+
// Label points come, go or move with the label: rewrite this layer's features.
|
|
503
|
+
const lf = this.features.get(layer.id);
|
|
504
|
+
const src = this.source(layer.id);
|
|
505
|
+
const ids = [...lf.ids.values()];
|
|
506
|
+
this.layers.set(layer.id, structuredClone(layer));
|
|
507
|
+
if (src)
|
|
508
|
+
this.writeSource(layer.id, src, [...ids, ...ids.map((fid) => fid + LABEL_ID_OFFSET)], [...lf.objects.values()].flatMap((so) => this.featuresOf(so, lf.ids.get(so.object.objectId), layer)));
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
else if (prev.visible !== layer.visible) {
|
|
512
|
+
for (const spec of styleLayersFor(layer, this.theme, this.hasFont))
|
|
513
|
+
map.setLayoutProperty(spec.id, 'visibility', layer.visible ? 'visible' : 'none');
|
|
514
|
+
}
|
|
515
|
+
this.layers.set(layer.id, structuredClone(layer));
|
|
516
|
+
}
|
|
517
|
+
// Layers at a height and models live in the deck.gl host; keep it in step with the layer list.
|
|
518
|
+
this.syncDeck(next);
|
|
519
|
+
// zIndex order: move each engine style layer to the top, lowest zIndex first (an image layer's pictures go
|
|
520
|
+
// first, under its outline).
|
|
521
|
+
const ordered = [...next].sort((a, b) => a.zIndex - b.zIndex || a.id.localeCompare(b.id));
|
|
522
|
+
for (const layer of ordered) {
|
|
523
|
+
if (layer.visualization.type === 'image')
|
|
524
|
+
this.syncImages(layer);
|
|
525
|
+
for (const id of this.imageLayerIds(layer.id))
|
|
526
|
+
map.moveLayer(id);
|
|
527
|
+
for (const spec of styleLayersFor(layer, this.theme, this.hasFont))
|
|
528
|
+
map.moveLayer(spec.id);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
// ---------------------------------------------------------------- image layers (doc 28 G12)
|
|
532
|
+
imageId(layerId, objectId) {
|
|
533
|
+
return `${PREFIX}:img:${layerId}:${objectId}`;
|
|
534
|
+
}
|
|
535
|
+
imageLayerIds(layerId) {
|
|
536
|
+
return [...(this.images.get(layerId)?.keys() ?? [])].map((oid) => this.imageId(layerId, oid));
|
|
537
|
+
}
|
|
538
|
+
/** One image source and raster layer per object, placed by its corners; follows moves, image changes and removal. */
|
|
539
|
+
syncImages(layer) {
|
|
540
|
+
const map = this.map;
|
|
541
|
+
const have = this.images.get(layer.id) ?? new Map();
|
|
542
|
+
const seen = new Set();
|
|
543
|
+
const cfg = layer.visualization.image;
|
|
544
|
+
const visibility = layer.visible ? 'visible' : 'none';
|
|
545
|
+
for (const so of this.features.get(layer.id)?.objects.values() ?? []) {
|
|
546
|
+
const corners = imageCorners(so.object.geometry);
|
|
547
|
+
const raw = cfg?.urlProperty ? so.object.properties?.[cfg.urlProperty] : undefined;
|
|
548
|
+
const url = typeof raw === 'string' && raw ? raw : cfg?.url;
|
|
549
|
+
if (!corners || !url)
|
|
550
|
+
continue;
|
|
551
|
+
const oid = so.object.objectId;
|
|
552
|
+
seen.add(oid);
|
|
553
|
+
const id = this.imageId(layer.id, oid);
|
|
554
|
+
const coords = JSON.stringify(corners);
|
|
555
|
+
const prev = have.get(oid);
|
|
556
|
+
if (!prev || !map.getSource(id)) {
|
|
557
|
+
map.addSource(id, { type: 'image', url, coordinates: corners });
|
|
558
|
+
map.addLayer({ id, type: 'raster', source: id, layout: { visibility }, paint: { 'raster-opacity': cfg?.opacity ?? 0.85, 'raster-fade-duration': 0 } }, styleLayersFor(layer, this.theme, this.hasFont)[0]?.id);
|
|
559
|
+
}
|
|
560
|
+
else if (prev.url !== url)
|
|
561
|
+
map.getSource(id).updateImage({ url, coordinates: corners });
|
|
562
|
+
else if (prev.coords !== coords)
|
|
563
|
+
map.getSource(id).setCoordinates(corners);
|
|
564
|
+
else
|
|
565
|
+
map.setLayoutProperty(id, 'visibility', visibility);
|
|
566
|
+
have.set(oid, { url, coords });
|
|
567
|
+
}
|
|
568
|
+
for (const oid of [...have.keys()]) {
|
|
569
|
+
if (seen.has(oid))
|
|
570
|
+
continue;
|
|
571
|
+
const id = this.imageId(layer.id, oid);
|
|
572
|
+
if (map.getLayer(id))
|
|
573
|
+
map.removeLayer(id);
|
|
574
|
+
if (map.getSource(id))
|
|
575
|
+
map.removeSource(id);
|
|
576
|
+
have.delete(oid);
|
|
577
|
+
}
|
|
578
|
+
this.images.set(layer.id, have);
|
|
579
|
+
}
|
|
580
|
+
dropImages(layerId) {
|
|
581
|
+
const map = this.map;
|
|
582
|
+
for (const id of this.imageLayerIds(layerId)) {
|
|
583
|
+
if (map.getLayer(id))
|
|
584
|
+
map.removeLayer(id);
|
|
585
|
+
if (map.getSource(id))
|
|
586
|
+
map.removeSource(id);
|
|
587
|
+
}
|
|
588
|
+
this.images.delete(layerId);
|
|
589
|
+
}
|
|
590
|
+
/** Registers the icon images a layer uses (built-in shapes synchronously, URLs when they load). */
|
|
591
|
+
ensureImages(layer) {
|
|
592
|
+
const map = this.map;
|
|
593
|
+
for (const img of imagesFor(layer)) {
|
|
594
|
+
if (map.hasImage(img.id))
|
|
595
|
+
continue;
|
|
596
|
+
if (img.shape) {
|
|
597
|
+
map.addImage(img.id, shapeImage(img.shape), { sdf: true, pixelRatio: 1 });
|
|
598
|
+
}
|
|
599
|
+
else if (img.url) {
|
|
600
|
+
map.loadImage(img.url).then((res) => {
|
|
601
|
+
// Icon sizes assume a 32-pixel image; any other size is declared through its pixel ratio.
|
|
602
|
+
if (this.map && !this.map.hasImage(img.id))
|
|
603
|
+
this.map.addImage(img.id, res.data, { pixelRatio: Math.max(0.1, res.data.width / ICON_PIXELS) });
|
|
604
|
+
}, (e) => this.emitError(new Error(`icon image ${img.url} failed to load: ${e instanceof Error ? e.message : String(e)}`)));
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
removeEngineLayer(id) {
|
|
609
|
+
const map = this.map;
|
|
610
|
+
this.dropImages(id);
|
|
611
|
+
const layer = this.layers.get(id);
|
|
612
|
+
if (layer)
|
|
613
|
+
for (const spec of styleLayersFor(layer, this.theme, this.hasFont))
|
|
614
|
+
if (map.getLayer(spec.id))
|
|
615
|
+
map.removeLayer(spec.id);
|
|
616
|
+
if (map.getSource(sourceId(id)))
|
|
617
|
+
map.removeSource(sourceId(id));
|
|
618
|
+
this.layers.delete(id);
|
|
619
|
+
this.features.delete(id);
|
|
620
|
+
}
|
|
621
|
+
// ---------------------------------------------------------------- objects
|
|
622
|
+
applyObjects(patch) {
|
|
623
|
+
const diffs = new Map();
|
|
624
|
+
const diffFor = (layerId) => {
|
|
625
|
+
let d = diffs.get(layerId);
|
|
626
|
+
if (!d)
|
|
627
|
+
diffs.set(layerId, (d = { remove: [], add: [], states: [], cleared: [] }));
|
|
628
|
+
return d;
|
|
629
|
+
};
|
|
630
|
+
for (const key of patch.removed ?? []) {
|
|
631
|
+
const [layerId, objectId] = splitKey(key);
|
|
632
|
+
const lf = this.features.get(layerId);
|
|
633
|
+
const fid = lf?.ids.get(objectId);
|
|
634
|
+
if (!lf || fid === undefined)
|
|
635
|
+
continue;
|
|
636
|
+
lf.ids.delete(objectId);
|
|
637
|
+
lf.objects.delete(objectId);
|
|
638
|
+
const d = diffFor(layerId);
|
|
639
|
+
d.remove.push(fid);
|
|
640
|
+
if (this.layers.get(layerId) && needsLabelPoints(this.layers.get(layerId)))
|
|
641
|
+
d.remove.push(fid + LABEL_ID_OFFSET);
|
|
642
|
+
d.cleared.push(fid);
|
|
643
|
+
}
|
|
644
|
+
for (const so of [...(patch.added ?? []), ...(patch.updated ?? [])]) {
|
|
645
|
+
const lf = this.features.get(so.layerId);
|
|
646
|
+
if (!lf)
|
|
647
|
+
continue;
|
|
648
|
+
const objectId = so.object.objectId;
|
|
649
|
+
const prev = lf.objects.get(objectId);
|
|
650
|
+
let fid = lf.ids.get(objectId);
|
|
651
|
+
const d = diffFor(so.layerId);
|
|
652
|
+
const layer = this.layers.get(so.layerId);
|
|
653
|
+
if (fid === undefined) {
|
|
654
|
+
fid = lf.next++;
|
|
655
|
+
lf.ids.set(objectId, fid);
|
|
656
|
+
d.add.push(...this.featuresOf(so, fid, layer));
|
|
657
|
+
}
|
|
658
|
+
else if (!prev || !deepEqual(prev.object, so.object)) {
|
|
659
|
+
d.remove.push(fid);
|
|
660
|
+
if (layer && needsLabelPoints(layer))
|
|
661
|
+
d.remove.push(fid + LABEL_ID_OFFSET);
|
|
662
|
+
d.add.push(...this.featuresOf(so, fid, layer));
|
|
663
|
+
}
|
|
664
|
+
if (!prev || prev.selected !== so.selected || prev.hovered !== so.hovered)
|
|
665
|
+
d.states.push([fid, so]);
|
|
666
|
+
lf.objects.set(objectId, so);
|
|
667
|
+
}
|
|
668
|
+
for (const [layerId, d] of diffs) {
|
|
669
|
+
const layer = this.layers.get(layerId);
|
|
670
|
+
if (layer?.visualization.type === 'image')
|
|
671
|
+
this.syncImages(layer);
|
|
672
|
+
if (layer && hostedByDeck(layer)) {
|
|
673
|
+
this.deckHost?.setLayer(layer, [...(this.features.get(layerId)?.objects.values() ?? [])]);
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
const src = this.source(layerId);
|
|
677
|
+
if (!src)
|
|
678
|
+
continue;
|
|
679
|
+
if (d.remove.length || d.add.length)
|
|
680
|
+
this.writeSource(layerId, src, d.remove, d.add);
|
|
681
|
+
for (const fid of d.cleared)
|
|
682
|
+
this.map.removeFeatureState({ source: sourceId(layerId), id: fid });
|
|
683
|
+
for (const [fid, so] of d.states)
|
|
684
|
+
this.map.setFeatureState({ source: sourceId(layerId), id: fid }, { selected: so.selected, hovered: so.hovered });
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Hands deck.gl the layers it draws; a layer that moves back to MapLibre (its height removed) gets its features
|
|
689
|
+
* written to its source again, since they were not kept there while deck.gl drew it.
|
|
690
|
+
*/
|
|
691
|
+
syncDeck(next) {
|
|
692
|
+
const host = this.deckHost;
|
|
693
|
+
if (!host)
|
|
694
|
+
return;
|
|
695
|
+
const ids = new Set(next.map((l) => l.id));
|
|
696
|
+
for (const layer of next) {
|
|
697
|
+
const lf = this.features.get(layer.id);
|
|
698
|
+
if (hostedByDeck(layer))
|
|
699
|
+
host.setLayer(layer, [...(lf?.objects.values() ?? [])]);
|
|
700
|
+
else if (host.has(layer.id)) {
|
|
701
|
+
host.removeLayer(layer.id);
|
|
702
|
+
const src = this.source(layer.id);
|
|
703
|
+
if (src && lf)
|
|
704
|
+
this.writeSource(layer.id, src, [], [...lf.objects.values()].flatMap((so) => this.featuresOf(so, lf.ids.get(so.object.objectId), layer)));
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
for (const id of [...this.layers.keys()])
|
|
708
|
+
if (!ids.has(id) && host.has(id))
|
|
709
|
+
host.removeLayer(id);
|
|
710
|
+
}
|
|
711
|
+
/** The shape, plus for labelled areas its one label point (an area is labelled once, not once per map tile). */
|
|
712
|
+
featuresOf(so, fid, layer) {
|
|
713
|
+
const shape = this.feature(so, fid);
|
|
714
|
+
if (!layer || !needsLabelPoints(layer))
|
|
715
|
+
return [shape];
|
|
716
|
+
const at = toFeature({ type: 'Point', coordinates: spatial.labelPoint(so.object.geometry, layer.visualization.label?.placement) }, { ...shape.properties, [LABEL_POINT_PROP]: true });
|
|
717
|
+
return [shape, { ...at, id: fid + LABEL_ID_OFFSET }];
|
|
718
|
+
}
|
|
719
|
+
feature(so, fid) {
|
|
720
|
+
const props = {};
|
|
721
|
+
for (const [k, v] of Object.entries(so.object.properties ?? {})) {
|
|
722
|
+
// Only primitives can drive style expressions; nested data stays in the engine.
|
|
723
|
+
if (v === null || ['string', 'number', 'boolean'].includes(typeof v))
|
|
724
|
+
props[k] = v;
|
|
725
|
+
}
|
|
726
|
+
props[OBJECT_ID_PROP] = so.object.objectId;
|
|
727
|
+
props[LAYER_ID_PROP] = so.layerId;
|
|
728
|
+
return { ...toFeature(so.object.geometry, props), id: fid };
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* MapLibre's v6 module needs its worker bundled by the app; without it nothing errors, the map just stays empty.
|
|
732
|
+
* Once, after the first objects are written, check that their source finished loading — and name the fix if not.
|
|
733
|
+
*/
|
|
734
|
+
watchWorker(layerId) {
|
|
735
|
+
if (this.workerChecked)
|
|
736
|
+
return;
|
|
737
|
+
this.workerChecked = true;
|
|
738
|
+
const gen = this.gen;
|
|
739
|
+
this.workerCheck = setTimeout(() => {
|
|
740
|
+
this.workerCheck = null;
|
|
741
|
+
const map = this.map;
|
|
742
|
+
if (gen !== this.gen || !map || !map.getSource(sourceId(layerId)) || map.isSourceLoaded(sourceId(layerId)))
|
|
743
|
+
return;
|
|
744
|
+
this.emitError(new Error("map data did not load: MapLibre's web worker is probably not wired. Pass `workerUrl` to MapLibreRenderer (with Vite: import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url')."));
|
|
745
|
+
}, this.options.workerCheckMs ?? 8000);
|
|
746
|
+
}
|
|
747
|
+
writeSource(layerId, src, remove, add) {
|
|
748
|
+
if (add.length)
|
|
749
|
+
this.watchWorker(layerId);
|
|
750
|
+
const report = (e) => this.emitError(e instanceof Error ? e : new Error(String(e)));
|
|
751
|
+
try {
|
|
752
|
+
if (typeof src.updateData === 'function') {
|
|
753
|
+
const r = src.updateData({ remove, add });
|
|
754
|
+
if (r instanceof Promise)
|
|
755
|
+
r.catch(report);
|
|
756
|
+
}
|
|
757
|
+
else {
|
|
758
|
+
// Fallback for sources without diff support: rewrite this one layer's collection.
|
|
759
|
+
const lf = this.features.get(layerId);
|
|
760
|
+
const layer = this.layers.get(layerId);
|
|
761
|
+
const features = [...lf.objects.values()].flatMap((so) => this.featuresOf(so, lf.ids.get(so.object.objectId), layer));
|
|
762
|
+
const r = src.setData({ type: 'FeatureCollection', features });
|
|
763
|
+
if (r instanceof Promise)
|
|
764
|
+
r.catch(report);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
catch (e) {
|
|
768
|
+
report(e);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
source(layerId) {
|
|
772
|
+
return this.map?.getSource(sourceId(layerId));
|
|
773
|
+
}
|
|
774
|
+
// ---------------------------------------------------------------- events
|
|
775
|
+
bindMapEvents(map) {
|
|
776
|
+
const on = (type, fn) => {
|
|
777
|
+
map.on(type, fn);
|
|
778
|
+
this.mapHandlers.push(() => map.off(type, fn));
|
|
779
|
+
};
|
|
780
|
+
on('move', () => {
|
|
781
|
+
for (const l of this.listeners.move)
|
|
782
|
+
l(undefined);
|
|
783
|
+
});
|
|
784
|
+
on('moveend', () => {
|
|
785
|
+
const v = this.currentViewport();
|
|
786
|
+
for (const l of this.listeners.viewport)
|
|
787
|
+
l(v);
|
|
788
|
+
});
|
|
789
|
+
on('click', (ev) => {
|
|
790
|
+
const e = ev;
|
|
791
|
+
const hit = this.hitTest(e.point);
|
|
792
|
+
const position = fromLngLat(e.lngLat);
|
|
793
|
+
if (hit && 'clusterId' in hit)
|
|
794
|
+
this.zoomIntoCluster(hit);
|
|
795
|
+
else if (hit)
|
|
796
|
+
for (const l of this.listeners.pick)
|
|
797
|
+
l({ ...hit, position });
|
|
798
|
+
else
|
|
799
|
+
for (const l of this.listeners.click)
|
|
800
|
+
l({ position });
|
|
801
|
+
});
|
|
802
|
+
on('mousemove', (ev) => {
|
|
803
|
+
const hit = this.hitTest(ev.point);
|
|
804
|
+
this.setHover(hit && 'clusterId' in hit ? null : hit, hit !== null);
|
|
805
|
+
});
|
|
806
|
+
on('mouseout', () => this.setHover(null));
|
|
807
|
+
// Heights stand on terrain: as its tiles arrive, place objects again (debounced; no loop, unlike 'idle').
|
|
808
|
+
on('sourcedata', (ev) => {
|
|
809
|
+
const e = ev;
|
|
810
|
+
if (e.sourceId !== TERRAIN_SOURCE || !this.terrain)
|
|
811
|
+
return;
|
|
812
|
+
if (this.groundRefresh !== null)
|
|
813
|
+
clearTimeout(this.groundRefresh);
|
|
814
|
+
this.groundRefresh = setTimeout(() => {
|
|
815
|
+
this.groundRefresh = null;
|
|
816
|
+
this.deckHost?.refresh();
|
|
817
|
+
}, 250);
|
|
818
|
+
});
|
|
819
|
+
on('error', (e) => this.emitError(toError(e.error, 'map error')));
|
|
820
|
+
on('webglcontextlost', () => this.emitError(new Error('WebGL context lost')));
|
|
821
|
+
}
|
|
822
|
+
setHover(hit, pointer = hit !== null) {
|
|
823
|
+
const canvas = this.map?.getCanvas();
|
|
824
|
+
if (canvas)
|
|
825
|
+
canvas.style.cursor = pointer ? 'pointer' : '';
|
|
826
|
+
const key = hit ? `${hit.layerId}:${hit.objectId}` : null;
|
|
827
|
+
if (key === this.hoverKey)
|
|
828
|
+
return;
|
|
829
|
+
this.hoverKey = key;
|
|
830
|
+
for (const l of this.listeners.hover)
|
|
831
|
+
l(hit);
|
|
832
|
+
}
|
|
833
|
+
/** Top-most engine feature under the pointer: an object (stable objectId) or a cluster bubble. Heatmaps are not pickable. */
|
|
834
|
+
hitTest(point) {
|
|
835
|
+
if (!this.map)
|
|
836
|
+
return null;
|
|
837
|
+
// Objects in the air and models sit above everything MapLibre draws: they are picked first.
|
|
838
|
+
const lifted = this.deckHost?.pick(point.x, point.y, this.options.hitTolerance ?? 4);
|
|
839
|
+
if (lifted)
|
|
840
|
+
return lifted;
|
|
841
|
+
const layers = [...this.layers.values()]
|
|
842
|
+
.filter((l) => l.visible)
|
|
843
|
+
.flatMap((l) => styleLayersFor(l, this.theme, this.hasFont).filter((s) => s.type !== 'heatmap').map((s) => s.id));
|
|
844
|
+
if (!layers.length)
|
|
845
|
+
return null;
|
|
846
|
+
const t = this.options.hitTolerance ?? 4;
|
|
847
|
+
const hits = this.map.queryRenderedFeatures([[point.x - t, point.y - t], [point.x + t, point.y + t]], { layers });
|
|
848
|
+
for (const f of hits) {
|
|
849
|
+
if (f.properties.cluster === true && typeof f.properties.cluster_id === 'number') {
|
|
850
|
+
const layerId = f.layer.id.split(':')[1];
|
|
851
|
+
const c = f.geometry?.type === 'Point' ? f.geometry.coordinates : undefined;
|
|
852
|
+
return { layerId, clusterId: f.properties.cluster_id, ...(c ? { lngLat: c } : {}) };
|
|
853
|
+
}
|
|
854
|
+
const objectId = f.properties[OBJECT_ID_PROP];
|
|
855
|
+
const layerId = f.properties[LAYER_ID_PROP];
|
|
856
|
+
if (typeof objectId === 'string' && typeof layerId === 'string')
|
|
857
|
+
return { objectId, layerId };
|
|
858
|
+
}
|
|
859
|
+
return null;
|
|
860
|
+
}
|
|
861
|
+
/** A click on a cluster bubble zooms to where it splits; the camera move then reaches the engine as usual. */
|
|
862
|
+
zoomIntoCluster(hit) {
|
|
863
|
+
const src = this.source(hit.layerId);
|
|
864
|
+
if (!src?.getClusterExpansionZoom || !this.map)
|
|
865
|
+
return;
|
|
866
|
+
src.getClusterExpansionZoom(hit.clusterId).then((zoom) => this.map?.easeTo({ ...(hit.lngLat ? { center: hit.lngLat } : {}), zoom }), (e) => this.emitError(e instanceof Error ? e : new Error(String(e))));
|
|
867
|
+
}
|
|
868
|
+
currentViewport() {
|
|
869
|
+
const map = this.map;
|
|
870
|
+
const c = map.getCenter();
|
|
871
|
+
const center = c.wrap ? c.wrap() : c;
|
|
872
|
+
const b = map.getBounds();
|
|
873
|
+
return {
|
|
874
|
+
center: fromLngLat(center),
|
|
875
|
+
zoom: map.getZoom(),
|
|
876
|
+
bearing: normalizeBearing(map.getBearing()),
|
|
877
|
+
pitch: map.getPitch(),
|
|
878
|
+
bounds: { west: clampLon(b.getWest()), south: clampLat(b.getSouth()), east: clampLon(b.getEast()), north: clampLat(b.getNorth()) },
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
emitError(error) {
|
|
882
|
+
for (const l of this.listeners.error)
|
|
883
|
+
l(error);
|
|
884
|
+
}
|
|
885
|
+
add(set, l) {
|
|
886
|
+
set.add(l);
|
|
887
|
+
return () => set.delete(l);
|
|
888
|
+
}
|
|
889
|
+
}
|