@orbat-mapper/tactical-draw-adapter-maplibre 0.3.22 → 0.4.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.
package/dist/index.mjs CHANGED
@@ -1,5 +1,1046 @@
1
- import { BaseMapAdapter, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, HATCH_PATTERNS, PointerCallbackHub, TEXT_HALO_COLOR, TOUCH_HIT_TOLERANCE_PX, bindAbortable, evaluatePickCandidates, fillPatternImageId, makeFillPatternImage, mergeNativeAndCachedPickFeatures, nearestWorldCopyLng, pickHitTolerance, pickPointerType, resolveTextHaloWidth } from "@orbat-mapper/tactical-draw";
2
- import { project, unproject } from "@orbat-mapper/control-measures";
1
+ import * as maplibregl from "maplibre-gl";
2
+ import { LngLat, MercatorCoordinate } from "maplibre-gl";
3
+ import { BaseMapAdapter, DEFAULT_TEXT_SIZE_MAX_PIXELS, DEFAULT_TEXT_SIZE_MIN_PIXELS, HATCH_PATTERNS, PointerCallbackHub, TEXT_HALO_COLOR, TOUCH_HIT_TOLERANCE_PX, bindAbortable, evaluatePickCandidates, fillPatternImageId, makeFillPatternImage, mergeNativeAndCachedPickFeatures, nearestWorldCopyLng, parseColor, pickHitTolerance, pickPointerType, resolveTextHaloWidth } from "@orbat-mapper/tactical-draw";
4
+ import { EARTH_RADIUS, project, unproject } from "@orbat-mapper/control-measures";
5
+ //#region src/pin-atlas.ts
6
+ /**
7
+ * Rasterize one icon's SVG. Kept separate from packing so a whole roster can
8
+ * decode in parallel — shelf placement stays order-dependent, decoding is not.
9
+ */
10
+ async function decodeIcon(resource) {
11
+ const image = new Image();
12
+ image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(resource.svg)}`;
13
+ await image.decode();
14
+ return image;
15
+ }
16
+ /** Paged shelves avoid one texture per symbol and do not reorder draw instances. */
17
+ var PinAtlas = class {
18
+ size;
19
+ entries = /* @__PURE__ */ new Map();
20
+ pages = [];
21
+ x = 0;
22
+ y = 0;
23
+ rowHeight = 0;
24
+ constructor(size = 2048) {
25
+ this.size = size;
26
+ }
27
+ add(resource, scale, image) {
28
+ if (this.entries.has(resource.id)) return;
29
+ const width = Math.min(this.size - 4, Math.max(1, Math.ceil(resource.width * scale)));
30
+ const height = Math.min(this.size - 4, Math.max(1, Math.ceil(resource.height * scale)));
31
+ if (this.x + width + 4 > this.size) {
32
+ this.x = 0;
33
+ this.y += this.rowHeight;
34
+ this.rowHeight = 0;
35
+ }
36
+ if (!this.pages.length || this.y + height + 4 > this.size) {
37
+ const canvas = document.createElement("canvas");
38
+ canvas.width = canvas.height = this.size;
39
+ this.pages.push(canvas);
40
+ this.x = this.y = this.rowHeight = 0;
41
+ }
42
+ const canvas = this.pages[this.pages.length - 1];
43
+ const x = this.x + 2, y = this.y + 2;
44
+ canvas.getContext("2d").drawImage(image, x, y, width, height);
45
+ this.entries.set(resource.id, {
46
+ page: this.pages.length - 1,
47
+ uv: [
48
+ x / this.size,
49
+ y / this.size,
50
+ (x + width) / this.size,
51
+ (y + height) / this.size
52
+ ]
53
+ });
54
+ this.x += width + 4;
55
+ this.rowHeight = Math.max(this.rowHeight, height + 4);
56
+ }
57
+ };
58
+ //#endregion
59
+ //#region src/pin-resources.ts
60
+ /** One atlas roster per logical layer, shared across ordinary-graphic barriers. */
61
+ var PinResources = class {
62
+ map;
63
+ revision = 0;
64
+ key = "";
65
+ resources = /* @__PURE__ */ new Map();
66
+ pending;
67
+ textures = [];
68
+ context;
69
+ disposed = false;
70
+ needsDepth = false;
71
+ invalidateDepth = () => {
72
+ if (this.needsDepth && this.map.getTerrain() && this.map.getProjection()?.type === "globe") this.map.painter.terrainFacilitator.depthDirty = true;
73
+ };
74
+ restore = () => {
75
+ this.revision++;
76
+ this.pending = void 0;
77
+ this.textures = [];
78
+ };
79
+ constructor(map) {
80
+ this.map = map;
81
+ map.on("webglcontextrestored", this.restore);
82
+ map.on("move", this.invalidateDepth);
83
+ }
84
+ setRoster(pins) {
85
+ this.needsDepth = pins.some((pin) => pin.presentation.occlusion === "terrain");
86
+ const resources = /* @__PURE__ */ new Map();
87
+ for (const pin of pins) {
88
+ const scale = pin.presentation.sizePixels / pin.octagonSize * Math.min(devicePixelRatio, 2);
89
+ if (scale > (resources.get(pin.resource.id)?.scale ?? 0)) resources.set(pin.resource.id, {
90
+ pin,
91
+ scale
92
+ });
93
+ }
94
+ const key = JSON.stringify([...resources].map(([id, value]) => [id, value.scale]).sort(([a], [b]) => a.localeCompare(b)));
95
+ if (key === this.key) return;
96
+ this.key = key;
97
+ this.resources = resources;
98
+ this.revision++;
99
+ this.pending = void 0;
100
+ }
101
+ load(gl) {
102
+ this.context = gl;
103
+ return this.pending ??= (async () => {
104
+ const revision = this.revision;
105
+ const sizes = [...this.resources.values()].map(({ pin, scale }) => ({
106
+ w: pin.resource.width * scale + 4,
107
+ h: pin.resource.height * scale + 4
108
+ }));
109
+ const area = sizes.reduce((sum, s) => sum + s.w * s.h, 0);
110
+ const side = Math.max(256, Math.sqrt(area) * 1.3, ...sizes.flatMap((s) => [s.w, s.h]));
111
+ const atlas = new PinAtlas(Math.min(2048, gl.getParameter(gl.MAX_TEXTURE_SIZE), 2 ** Math.ceil(Math.log2(side))));
112
+ const icons = [...this.resources.values()];
113
+ const images = await Promise.all(icons.map(({ pin }) => decodeIcon(pin.resource)));
114
+ if (this.disposed || revision !== this.revision) return;
115
+ icons.forEach(({ pin, scale }, i) => atlas.add(pin.resource, scale, images[i]));
116
+ const textures = [];
117
+ try {
118
+ for (const page of atlas.pages) {
119
+ const texture = gl.createTexture();
120
+ if (!texture) throw new Error("Could not allocate pin texture");
121
+ textures.push(texture);
122
+ gl.activeTexture(gl.TEXTURE0);
123
+ gl.bindTexture(gl.TEXTURE_2D, texture);
124
+ gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
125
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
126
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, page);
127
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
128
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
129
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
130
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
131
+ }
132
+ } catch (error) {
133
+ for (const texture of textures) gl.deleteTexture(texture);
134
+ throw error;
135
+ }
136
+ for (const texture of this.textures) gl.deleteTexture(texture);
137
+ this.textures = textures;
138
+ this.map.painter.context.setDirty();
139
+ return {
140
+ atlas,
141
+ textures
142
+ };
143
+ })();
144
+ }
145
+ dispose() {
146
+ this.disposed = true;
147
+ this.revision++;
148
+ this.map.off("webglcontextrestored", this.restore);
149
+ this.map.off("move", this.invalidateDepth);
150
+ for (const texture of this.textures) this.context?.deleteTexture(texture);
151
+ this.textures = [];
152
+ this.resources.clear();
153
+ this.pending = void 0;
154
+ }
155
+ };
156
+ //#endregion
157
+ //#region src/pin-inversion.ts
158
+ /** Follow the local projection branch from the unit, never a foreground terrain ray hit. */
159
+ function invertPinProjection(project, initial, target, latitudeLimit = 85.051129) {
160
+ let position = [initial[0], initial[1]];
161
+ const valid = (p) => p.every(Number.isFinite) && Math.abs(p[1]) < latitudeLimit;
162
+ if (!valid(position) || !target.every(Number.isFinite)) return null;
163
+ for (let n = 0; n < 12; n++) {
164
+ const a = project(position), ex = target[0] - a[0], ey = target[1] - a[1], error = Math.hypot(ex, ey);
165
+ if (!Number.isFinite(error)) return null;
166
+ if (error < .05) return position;
167
+ const step = 1e-5, b = project([position[0] + step, position[1]]), c = project([position[0], position[1] + step]);
168
+ const jx = (b[0] - a[0]) / step, jy = (b[1] - a[1]) / step, kx = (c[0] - a[0]) / step, ky = (c[1] - a[1]) / step, det = jx * ky - jy * kx;
169
+ if (!Number.isFinite(det) || Math.abs(det) < 1e-12) return null;
170
+ const dx = (ex * ky - ey * kx) / det, dy = (ey * jx - ex * jy) / det;
171
+ let improved = false;
172
+ for (let damping = 1; damping >= 1 / 128; damping /= 2) {
173
+ const candidate = [position[0] + dx * damping, position[1] + dy * damping];
174
+ if (!valid(candidate)) continue;
175
+ const pixel = project(candidate);
176
+ if (Math.hypot(target[0] - pixel[0], target[1] - pixel[1]) < error) {
177
+ position = candidate;
178
+ improved = true;
179
+ break;
180
+ }
181
+ }
182
+ if (!improved) return null;
183
+ }
184
+ return null;
185
+ }
186
+ //#endregion
187
+ //#region src/pin-shaders.ts
188
+ const PIN_VERTEX = `#version 300 es
189
+ precision highp float;
190
+ layout(location=0) in vec4 a_ground;
191
+ layout(location=1) in vec4 a_target;
192
+ layout(location=2) in vec4 a_shape;
193
+ layout(location=3) in vec4 a_color;
194
+ layout(location=4) in vec4 a_uv;
195
+ layout(location=5) in vec4 a_flags;
196
+ uniform mat4 u_matrix;
197
+ uniform float u_world;
198
+ uniform mat4 u_globeMatrix;
199
+ uniform vec4 u_clippingPlane;
200
+ uniform vec2 u_origin;
201
+ uniform float u_globeTransition;
202
+ out float v_horizon;
203
+ vec4 projectPin(vec3 position, out float horizon) {
204
+ vec4 flatClip = u_matrix * vec4(position.xy * u_world, position.z, 1.);
205
+ horizon = 1.;
206
+ if(u_globeTransition == 0.) return flatClip;
207
+ vec2 mercator = position.xy + u_origin;
208
+ float angle = mercator.x * 6.283185307179586 + 3.141592653589793;
209
+ float t = exp(3.141592653589793 - mercator.y * 6.283185307179586);
210
+ vec3 sphere = vec3(sin(angle)*2.*t, t*t-1., cos(angle)*2.*t)/(t*t+1.);
211
+ sphere *= 1. + position.z / 6371008.8;
212
+ vec4 globe = u_globeMatrix * vec4(sphere, 1.);
213
+ float distance = dot(sphere,u_clippingPlane.xyz) + u_clippingPlane.w;
214
+ horizon = 1. - (1.-distance) * max(0.,(u_globeTransition-.2)/.8);
215
+ return mix(flatClip,globe,u_globeTransition);
216
+ }
217
+ uniform vec2 u_view;
218
+ out vec2 v_local;
219
+ out vec2 v_uv;
220
+ flat out vec4 v_bounds;
221
+ flat out vec4 v_color;
222
+ flat out vec4 v_shape;
223
+ flat out vec4 v_flags;
224
+ flat out float v_radius;
225
+ const vec2 corners[6] = vec2[6](vec2(0,0),vec2(1,0),vec2(0,1),vec2(0,1),vec2(1,0),vec2(1,1));
226
+ void main() {
227
+ vec2 c = corners[gl_VertexID];
228
+ float groundHorizon, targetHorizon;
229
+ vec4 ground = projectPin(a_ground.xyz, groundHorizon);
230
+ vec4 target = projectPin(a_target.xyz, targetHorizon);
231
+ if(a_flags.y < .5) { target = ground; targetHorizon = groundHorizon; target.y += a_ground.w * 2. / u_view.y * target.w; }
232
+ v_horizon = a_flags.x < .5 ? groundHorizon : (a_flags.x < 1.5 ? mix(groundHorizon,targetHorizon,c.y) : targetHorizon);
233
+ vec2 gp = ground.xy / ground.w * u_view * .5;
234
+ vec2 tp = target.xy / target.w * u_view * .5;
235
+ float kind = a_flags.x;
236
+ vec4 clip = target;
237
+ vec2 offset;
238
+ if(kind < .5) { clip = ground; offset = (c - .5) * a_shape.xy; }
239
+ else if(kind < 1.5) {
240
+ vec2 delta = tp-gp; float len = length(delta);
241
+ vec2 dir = len > .0001 ? delta/len : vec2(0,1);
242
+ vec2 p = mix(gp,tp,c.y) + vec2(-dir.y,dir.x) * (c.x-.5)*a_shape.x;
243
+ clip = vec4(p/u_view*2., mix(ground.z/ground.w,target.z/target.w,c.y), 1.);
244
+ offset = vec2(0);
245
+ } else { offset = (c - vec2(.5,0)) * a_shape.xy + a_shape.zw; }
246
+ clip.xy += offset * 2. / u_view * clip.w;
247
+ if (ground.w <= 0. || target.w <= 0.) clip = vec4(2,2,2,1);
248
+ gl_Position=clip;
249
+ v_local=(c-.5)*a_shape.xy;
250
+ v_uv=mix(a_uv.xy,a_uv.zw,vec2(c.x,1.-c.y));
251
+ v_bounds=a_uv; v_color=a_color; v_shape=a_shape; v_flags=a_flags; v_radius=a_target.w;
252
+ }`;
253
+ const PIN_FRAGMENT = `#version 300 es
254
+ precision highp float;
255
+ in float v_horizon;
256
+ in vec2 v_local;
257
+ in vec2 v_uv;
258
+ flat in vec4 v_bounds;
259
+ flat in vec4 v_color;
260
+ flat in vec4 v_shape;
261
+ flat in vec4 v_flags;
262
+ flat in float v_radius;
263
+ uniform sampler2D u_atlas;
264
+ // Texture return precision follows the sampler, not the default float precision.
265
+ // Preserve the packed depth channels on GPUs that implement lowp separately.
266
+ uniform highp sampler2D u_terrain;
267
+ uniform vec2 u_buffer;
268
+ uniform bool u_hasTerrain;
269
+ uniform bool u_pick;
270
+
271
+ out vec4 color;
272
+ float symbolAlpha(vec2 pixel, vec2 size) {
273
+ vec2 uv = pixel / size + .5;
274
+ if(any(lessThan(uv,vec2(0))) || any(greaterThan(uv,vec2(1)))) return 0.;
275
+ return texture(u_atlas,mix(v_bounds.xy,v_bounds.zw,vec2(uv.x,1.-uv.y))).a;
276
+ }
277
+ void main() {
278
+ if(v_horizon < 0.) discard;
279
+ if(u_hasTerrain && v_flags.z > .5) {
280
+ vec4 packed=texture(u_terrain,gl_FragCoord.xy/u_buffer);
281
+ float depth=dot(packed,vec4(1./16777216.,1./65536.,1./256.,1.));
282
+ float ours=gl_FragCoord.z*2.-1.;
283
+ // A transparent depth texel is sky (no terrain), not a near-plane occluder.
284
+ if(depth > 0. && ours > depth + .00012) discard;
285
+ }
286
+ vec4 paint=v_color;
287
+ float kind=v_flags.x;
288
+ if(kind < .5) {
289
+ float edge=length(v_local)-v_shape.x*.5;
290
+ paint.a *= 1.-smoothstep(-1.,0.,edge);
291
+ } else if(kind > 4.5) {
292
+ // One optional halo instance; reuse the canonical atlas, including text and HQ staff.
293
+ vec2 size = v_shape.xy - vec2(2. * v_radius);
294
+ float alpha = symbolAlpha(v_local,size);
295
+ for(int i=0;i<16;i++) {
296
+ float angle = float(i) * 6.28318530718 / 16.;
297
+ vec2 direction = vec2(cos(angle),sin(angle));
298
+ alpha = max(alpha,symbolAlpha(v_local + direction * v_radius,size));
299
+ alpha = max(alpha,symbolAlpha(v_local + direction * v_radius * .5,size));
300
+ }
301
+ paint.a *= alpha;
302
+ } else if(kind > 3.5) {
303
+ if(min(v_shape.x*.5-abs(v_local.x),v_shape.y*.5-abs(v_local.y)) > 2.) discard;
304
+ } else if(kind > 2.5) {
305
+ paint *= texture(u_atlas,v_uv);
306
+ } else if(kind > 1.5) {
307
+ float radius=min(v_radius,min(v_shape.x,v_shape.y)*.5);
308
+ vec2 q=abs(v_local)-v_shape.xy*.5+radius;
309
+ float edge=length(max(q,0.))+min(max(q.x,q.y),0.)-radius;
310
+ paint.a *= 1.-smoothstep(-1.,0.,edge);
311
+ }
312
+ if(paint.a < .01) discard;
313
+ if(u_pick) {
314
+ float id=v_flags.w;
315
+ color=vec4(mod(id,256.),mod(floor(id/256.),256.),floor(id/65536.),255.)/255.;
316
+ } else color=vec4(paint.rgb*paint.a,paint.a);
317
+ }`;
318
+ //#endregion
319
+ //#region src/pin-projection.ts
320
+ /**
321
+ * Single source of truth for the globe radius the pin pipeline works in. The
322
+ * GLSL in `pin-shaders.ts` repeats the literal because a shader cannot import;
323
+ * this export is what it must stay in step with.
324
+ */
325
+ const PIN_GLOBE_RADIUS = EARTH_RADIUS;
326
+ /** Mercator is singular at the poles; this sub-metre inset keeps GPU inputs finite. */
327
+ function pinMercator(position) {
328
+ return MercatorCoordinate.fromLngLat([position[0], Math.max(-89.999999, Math.min(89.999999, position[1]))]);
329
+ }
330
+ const multiply = (m, v) => [
331
+ 0,
332
+ 1,
333
+ 2,
334
+ 3
335
+ ].map((row) => v.reduce((sum, n, col) => sum + n * m[col * 4 + row], 0));
336
+ /** CPU counterpart of projectPin in pin-shaders.ts, also used by drag inversion. */
337
+ var PinProjection = class {
338
+ matrix = new Float32Array(16);
339
+ globeMatrix = new Float32Array(16);
340
+ clippingPlane = new Float32Array(4);
341
+ transition = 0;
342
+ origin = [0, 0];
343
+ world = 512;
344
+ update(input, origin, center, world, globe) {
345
+ this.origin = origin;
346
+ this.world = world;
347
+ const data = globe ? input.getProjectionData({
348
+ tileID: { canonical: {
349
+ x: 0,
350
+ y: 0,
351
+ z: 0
352
+ } },
353
+ applyGlobeMatrix: true
354
+ }) : void 0;
355
+ this.transition = data?.projectionTransition ?? 0;
356
+ if (data) {
357
+ this.globeMatrix.set(data.mainMatrix);
358
+ this.clippingPlane.set(data.clippingPlane);
359
+ }
360
+ const fallback = globe ? input.defaultProjectionData.fallbackMatrix : input.modelViewProjectionMatrix;
361
+ const xyScale = globe ? 1 / world : 1;
362
+ const zScale = globe ? MercatorCoordinate.fromLngLat(center).meterInMercatorCoordinateUnits() : 1;
363
+ for (let row = 0; row < 4; row++) {
364
+ this.matrix[row] = fallback[row] * xyScale;
365
+ this.matrix[4 + row] = fallback[4 + row] * xyScale;
366
+ this.matrix[8 + row] = fallback[8 + row] * zScale;
367
+ this.matrix[12 + row] = fallback[12 + row] + fallback[row] * xyScale * origin[0] * world + fallback[4 + row] * xyScale * origin[1] * world;
368
+ }
369
+ }
370
+ project(worldX, worldY, elevation) {
371
+ const flat = multiply(this.matrix, [
372
+ worldX,
373
+ worldY,
374
+ elevation,
375
+ 1
376
+ ]);
377
+ if (this.transition === 0) return {
378
+ clip: flat,
379
+ horizon: 1
380
+ };
381
+ const x = worldX / this.world + this.origin[0];
382
+ const y = worldY / this.world + this.origin[1];
383
+ const angle = x * Math.PI * 2 + Math.PI;
384
+ const t = Math.exp(Math.PI - y * Math.PI * 2);
385
+ const scale = 1 + elevation / PIN_GLOBE_RADIUS;
386
+ const sphere = [
387
+ Math.sin(angle) * 2 * t / (t * t + 1),
388
+ (t * t - 1) / (t * t + 1),
389
+ Math.cos(angle) * 2 * t / (t * t + 1)
390
+ ].map((n) => n * scale);
391
+ const globe = multiply(this.globeMatrix, [...sphere, 1]);
392
+ const horizon = 1 - (1 - sphere.reduce((sum, n, i) => sum + n * this.clippingPlane[i], this.clippingPlane[3])) * Math.max(0, (this.transition - .2) / .8);
393
+ return {
394
+ clip: flat.map((n, i) => n * (1 - this.transition) + globe[i] * this.transition),
395
+ horizon
396
+ };
397
+ }
398
+ };
399
+ //#endregion
400
+ //#region src/pin-kind.ts
401
+ /**
402
+ * Draw-kind tags shared between the pin instance data (pin-layer.ts) and the
403
+ * WebGL shaders (pin-shaders.ts).
404
+ *
405
+ * The GLSL side branches on numeric thresholds (`kind < .5`, `kind < 1.5`,
406
+ * `kind > 4.5`, ...) rather than importing this module — see the comment
407
+ * near those branches in pin-shaders.ts. If you renumber a value here, you
408
+ * must update the shader thresholds by hand or the two will silently drift.
409
+ */
410
+ const PIN_KIND = {
411
+ DOT: 0,
412
+ STEM: 1,
413
+ PLATE: 2,
414
+ SYMBOL: 3,
415
+ HIGHLIGHT: 4,
416
+ SYMBOL_HALO: 5
417
+ };
418
+ //#endregion
419
+ //#region src/pin-layer.ts
420
+ const STRIDE = 24;
421
+ /** Shader-ready `[r, g, b, a]` in 0-1, with the color's own alpha folded into `opacity`. */
422
+ function rgba(color, opacity) {
423
+ const { r, g, b, a } = parseColor(color);
424
+ return [
425
+ r / 255,
426
+ g / 255,
427
+ b / 255,
428
+ a * opacity
429
+ ];
430
+ }
431
+ function shader(gl, type, source) {
432
+ const s = gl.createShader(type);
433
+ gl.shaderSource(s, source);
434
+ gl.compileShader(s);
435
+ if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
436
+ const message = gl.getShaderInfoLog(s);
437
+ gl.deleteShader(s);
438
+ throw new Error(`Pin shader: ${message}`);
439
+ }
440
+ return s;
441
+ }
442
+ /**
443
+ * Appends one instance's worth of vertex-shader input to `data` and extends `ranges`
444
+ * accordingly. Hoisted out of rebuild()'s per-pin loop (ADR n/a) so pushing 10k+ pins
445
+ * never allocates a fresh closure per pin — every varying value is an explicit param.
446
+ */
447
+ function appendPinInstance(data, ranges, image, highlightOnly, instanceId, occlusionTerrain, kind, shape, color, a, target, mode) {
448
+ if (highlightOnly && kind !== PIN_KIND.HIGHLIGHT) return;
449
+ const first = data.length / STRIDE;
450
+ const previous = ranges[ranges.length - 1];
451
+ if (previous?.page === image.page) previous.count++;
452
+ else ranges.push({
453
+ page: image.page,
454
+ first,
455
+ count: 1
456
+ });
457
+ data.push(...a, ...target, ...shape, ...color, ...image.uv, kind, mode, occlusionTerrain, instanceId);
458
+ }
459
+ /**
460
+ * Ground-following point along a direction-arrow segment, `t` of the way from `from`
461
+ * to `to` (both east/north metre offsets from the pin). Hoisted out of rebuild()'s
462
+ * per-pin, per-segment loops — at 10k pins that loop can define this closure 240k+
463
+ * times per rebuild otherwise.
464
+ */
465
+ function arrowSegmentPoint(center, position, elevationAt, from, to, t) {
466
+ const east = from[0] + (to[0] - from[0]) * t;
467
+ const north = from[1] + (to[1] - from[1]) * t;
468
+ const ll = [position[0] + east / (111320 * Math.cos(position[1] * Math.PI / 180)), position[1] + north / 111320];
469
+ const mc = pinMercator(ll);
470
+ mc.x += Math.round(center.x - mc.x);
471
+ return [
472
+ mc.x - center.x,
473
+ mc.y - center.y,
474
+ elevationAt(ll) + 1,
475
+ 0
476
+ ];
477
+ }
478
+ /** Batched GPU pin rendering. Adjacent groups only; instance order is paint order. */
479
+ var PinLayer = class {
480
+ id;
481
+ resources;
482
+ onStatus;
483
+ type = "custom";
484
+ renderingMode = "2d";
485
+ map;
486
+ gl;
487
+ program;
488
+ uniforms;
489
+ buffer;
490
+ vao;
491
+ textures = [];
492
+ atlas = new PinAtlas();
493
+ pins = [];
494
+ ranges = [];
495
+ version = 0;
496
+ resourceRevision = -1;
497
+ needsDepth = false;
498
+ elevationCache = /* @__PURE__ */ new Map();
499
+ pendingElevation = /* @__PURE__ */ new Set();
500
+ removed = false;
501
+ ready = false;
502
+ dirty = true;
503
+ changedPins = /* @__PURE__ */ new Set();
504
+ slices = [];
505
+ origin = [0, 0];
506
+ elevations = [];
507
+ projection = new PinProjection();
508
+ matrix = this.projection.matrix;
509
+ frame;
510
+ pickFramebuffer;
511
+ pickTexture;
512
+ pickSize = "";
513
+ status = { status: "supported" };
514
+ refreshTerrain = (event) => {
515
+ if (event.sourceId && event.sourceId !== this.map.getTerrain()?.source) return;
516
+ if (event.type === "sourcedata" || event.type === "terrain") this.elevationCache.clear();
517
+ this.dirty = true;
518
+ this.map.triggerRepaint();
519
+ };
520
+ elevation(position) {
521
+ if (!this.map.getTerrain() || Math.abs(position[1]) >= 85.051129) return 0;
522
+ const key = `${position[0]}/${position[1]}`;
523
+ const cached = this.elevationCache.get(key);
524
+ if (cached !== void 0) return cached;
525
+ const terrain = this.map.terrain;
526
+ const manager = terrain.tileManager ?? terrain.sourceCache;
527
+ const source = terrain.tileManager ? terrain.tileManager.getSource() : terrain.sourceCache.sourceCache.getSource();
528
+ const { tileID, mercatorX, mercatorY } = terrain._getOverscaledTileIDFromLngLatZoom(new LngLat(position[0], position[1]), Math.min(manager.maxzoom, source.maxzoom + manager.deltaZoom));
529
+ const x = mercatorX % 8192;
530
+ const y = mercatorY % 8192;
531
+ const getSampler = terrain.getElevationSampler ?? terrain._getElevationSampler;
532
+ let value;
533
+ if (getSampler) {
534
+ const sampler = getSampler.call(terrain, tileID);
535
+ if (!sampler) return NaN;
536
+ value = sampler(x, y, 8192) * terrain.exaggeration;
537
+ } else {
538
+ if (!manager.getSourceTile(tileID, true)?.dem) return NaN;
539
+ value = terrain.getElevation(tileID, x, y, 8192);
540
+ }
541
+ this.elevationCache.set(key, value);
542
+ return value;
543
+ }
544
+ has(id) {
545
+ return this.pins.some((pin) => pin.id === id);
546
+ }
547
+ constructor(id, resources, onStatus) {
548
+ this.id = id;
549
+ this.resources = resources;
550
+ this.onStatus = onStatus;
551
+ }
552
+ restoreContext = () => {
553
+ this.ready = false;
554
+ this.resourceRevision = -1;
555
+ this.textures = [];
556
+ this.pickSize = "";
557
+ this.pickTexture = void 0;
558
+ this.pickFramebuffer = void 0;
559
+ this.onRemoveListeners();
560
+ this.onAdd(this.map, this.gl);
561
+ this.setPins(this.pins);
562
+ };
563
+ onRemoveListeners() {
564
+ this.map.off("sourcedata", this.refreshTerrain);
565
+ this.map.off("terrain", this.refreshTerrain);
566
+ this.map.off("resize", this.refreshTerrain);
567
+ this.map.off("moveend", this.refreshTerrain);
568
+ this.map.off("webglcontextrestored", this.restoreContext);
569
+ }
570
+ onAdd(map, context) {
571
+ this.removed = false;
572
+ this.map = map;
573
+ this.gl = context;
574
+ const gl = this.gl;
575
+ try {
576
+ const program = gl.createProgram();
577
+ for (const [type, source] of [[gl.VERTEX_SHADER, PIN_VERTEX], [gl.FRAGMENT_SHADER, PIN_FRAGMENT]]) {
578
+ const s = shader(gl, type, source);
579
+ gl.attachShader(program, s);
580
+ gl.deleteShader(s);
581
+ }
582
+ gl.linkProgram(program);
583
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(program) ?? "Pin link failed");
584
+ this.program = program;
585
+ this.uniforms = this.resolveUniforms(gl);
586
+ this.buffer = gl.createBuffer();
587
+ this.vao = gl.createVertexArray();
588
+ map.on("webglcontextrestored", this.restoreContext);
589
+ map.on("sourcedata", this.refreshTerrain);
590
+ map.on("terrain", this.refreshTerrain);
591
+ map.on("resize", this.refreshTerrain);
592
+ map.on("moveend", this.refreshTerrain);
593
+ } catch (error) {
594
+ this.setStatus({
595
+ status: "unavailable",
596
+ reason: String(error)
597
+ });
598
+ }
599
+ }
600
+ /** Uniform locations are fixed once a program is linked; resolved here, not per draw. */
601
+ resolveUniforms(gl) {
602
+ const program = this.program;
603
+ const uniforms = {
604
+ matrix: gl.getUniformLocation(program, "u_matrix"),
605
+ globeMatrix: gl.getUniformLocation(program, "u_globeMatrix"),
606
+ clippingPlane: gl.getUniformLocation(program, "u_clippingPlane"),
607
+ origin: gl.getUniformLocation(program, "u_origin"),
608
+ globeTransition: gl.getUniformLocation(program, "u_globeTransition"),
609
+ world: gl.getUniformLocation(program, "u_world"),
610
+ view: gl.getUniformLocation(program, "u_view"),
611
+ buffer: gl.getUniformLocation(program, "u_buffer"),
612
+ pick: gl.getUniformLocation(program, "u_pick"),
613
+ atlas: gl.getUniformLocation(program, "u_atlas"),
614
+ terrain: gl.getUniformLocation(program, "u_terrain"),
615
+ hasTerrain: gl.getUniformLocation(program, "u_hasTerrain")
616
+ };
617
+ this.uniforms = uniforms;
618
+ return uniforms;
619
+ }
620
+ setStatus(status) {
621
+ if (status.status === this.status.status && status.reason === this.status.reason) return;
622
+ this.status = status;
623
+ this.onStatus?.({
624
+ ...status,
625
+ graphicIds: this.pins.filter((p) => !p.highlightOnly).map((p) => p.id)
626
+ });
627
+ }
628
+ getStatus(id) {
629
+ return id && this.pendingElevation.has(id) ? { status: "waiting-for-elevation" } : { ...this.status };
630
+ }
631
+ async setPins(pins, changedIndices) {
632
+ if (pins.length !== this.pins.length) this.dirty = true;
633
+ else {
634
+ const changed = changedIndices ?? pins.flatMap((pin, i) => pin !== this.pins[i] ? [i] : []);
635
+ for (const i of changed) {
636
+ if (pins[i].id !== this.pins[i].id) this.dirty = true;
637
+ this.changedPins.add(i);
638
+ }
639
+ }
640
+ this.pins = pins;
641
+ if (this.elevationCache.size > pins.length * 60 + 1e3) this.elevationCache.clear();
642
+ if (!this.program) {
643
+ this.onStatus?.({
644
+ ...this.status,
645
+ graphicIds: pins.map((pin) => pin.id)
646
+ });
647
+ return;
648
+ }
649
+ this.needsDepth = pins.some((p) => p.presentation.occlusion === "terrain");
650
+ if (this.resourceRevision === this.resources.revision && this.ready) {
651
+ this.map.triggerRepaint();
652
+ return;
653
+ }
654
+ this.dirty = true;
655
+ this.resourceRevision = this.resources.revision;
656
+ this.ready = false;
657
+ this.setStatus({ status: "loading" });
658
+ const version = ++this.version;
659
+ try {
660
+ const snapshot = await this.resources.load(this.gl);
661
+ if (!snapshot || version !== this.version || this.removed) return;
662
+ this.atlas = snapshot.atlas;
663
+ this.textures = snapshot.textures;
664
+ this.ready = true;
665
+ this.map.triggerRepaint();
666
+ } catch (error) {
667
+ this.setStatus({
668
+ status: "unavailable",
669
+ reason: `Pin resources: ${error}`
670
+ });
671
+ }
672
+ }
673
+ buildPin(i, center, data, ranges) {
674
+ const pin = this.pins[i], p = pin.presentation;
675
+ const anchor = pinMercator(pin.position);
676
+ anchor.x += Math.round(center.x - anchor.x);
677
+ const elevation = this.elevation(pin.position);
678
+ this.elevations[i] = elevation;
679
+ if (!Number.isFinite(elevation)) {
680
+ this.pendingElevation.add(pin.id);
681
+ return;
682
+ }
683
+ this.pendingElevation.delete(pin.id);
684
+ const a = [
685
+ anchor.x - center.x,
686
+ anchor.y - center.y,
687
+ elevation,
688
+ p.liftPixels
689
+ ];
690
+ const b = [
691
+ a[0],
692
+ a[1],
693
+ elevation + (p.mode === "world" ? p.heightMeters : 0),
694
+ p.plate.radius
695
+ ];
696
+ const image = this.atlas.entries.get(pin.resource.id);
697
+ const opacity = pin.opacity;
698
+ const highlightOnly = pin.highlightOnly;
699
+ const instanceId = i + 1;
700
+ const occlusionFlag = p.occlusion === "terrain" ? 1 : 0;
701
+ const worldMode = p.mode === "world" ? 1 : 0;
702
+ const haloColor = rgba(p.halo.color, p.halo.opacity * opacity);
703
+ const stemColor = rgba(p.stem.color, p.stem.opacity * opacity);
704
+ if (pin.direction !== void 0 && !pin.highlightOnly) {
705
+ const elevationAt = (ll) => this.elevation(ll);
706
+ const angle = pin.direction, length = 180;
707
+ const tip = [Math.sin(angle) * length, Math.cos(angle) * length];
708
+ const paths = [
709
+ [[0, 0], tip],
710
+ [[tip[0] - Math.sin(angle - .5) * length * .25, tip[1] - Math.cos(angle - .5) * length * .25], tip],
711
+ [[tip[0] - Math.sin(angle + .5) * length * .25, tip[1] - Math.cos(angle + .5) * length * .25], tip]
712
+ ];
713
+ const saved = [...a];
714
+ for (const [from, to] of paths) for (let segment = 0; segment < 8; segment++) {
715
+ const ground = arrowSegmentPoint(center, pin.position, elevationAt, from, to, segment / 8);
716
+ const target = arrowSegmentPoint(center, pin.position, elevationAt, from, to, (segment + 1) / 8);
717
+ if (!Number.isFinite(ground[2]) || !Number.isFinite(target[2])) continue;
718
+ a.splice(0, 4, ...ground);
719
+ appendPinInstance(data, ranges, image, highlightOnly, instanceId, occlusionFlag, PIN_KIND.STEM, [
720
+ p.stem.width + 2 * p.halo.width,
721
+ 0,
722
+ 0,
723
+ 0
724
+ ], haloColor, a, target, 1);
725
+ appendPinInstance(data, ranges, image, highlightOnly, instanceId, occlusionFlag, PIN_KIND.STEM, [
726
+ p.stem.width,
727
+ 0,
728
+ 0,
729
+ 0
730
+ ], stemColor, a, target, 1);
731
+ }
732
+ a.splice(0, 4, ...saved);
733
+ }
734
+ appendPinInstance(data, ranges, image, highlightOnly, instanceId, occlusionFlag, PIN_KIND.STEM, [
735
+ p.stem.width + 2 * p.halo.width,
736
+ 0,
737
+ 0,
738
+ 0
739
+ ], haloColor, a, b, worldMode);
740
+ appendPinInstance(data, ranges, image, highlightOnly, instanceId, occlusionFlag, PIN_KIND.STEM, [
741
+ p.stem.width,
742
+ 0,
743
+ 0,
744
+ 0
745
+ ], stemColor, a, b, worldMode);
746
+ appendPinInstance(data, ranges, image, highlightOnly, instanceId, occlusionFlag, PIN_KIND.DOT, [
747
+ 2 * (p.dot.radius + p.halo.width),
748
+ 2 * (p.dot.radius + p.halo.width),
749
+ 0,
750
+ 0
751
+ ], haloColor, a, b, worldMode);
752
+ appendPinInstance(data, ranges, image, highlightOnly, instanceId, occlusionFlag, PIN_KIND.DOT, [
753
+ 2 * p.dot.radius,
754
+ 2 * p.dot.radius,
755
+ 0,
756
+ 0
757
+ ], rgba(p.dot.color, p.dot.opacity * opacity), a, b, worldMode);
758
+ const scale = p.sizePixels / pin.octagonSize;
759
+ const width = pin.resource.width * scale, height = pin.resource.height * scale;
760
+ const offsetX = (pin.resource.width / 2 - pin.resource.anchor[0]) * scale;
761
+ const padding = p.plate.enabled ? p.plate.padding : 0;
762
+ if (p.plate.enabled) {
763
+ appendPinInstance(data, ranges, image, highlightOnly, instanceId, occlusionFlag, PIN_KIND.PLATE, [
764
+ width + padding * 2,
765
+ height + padding * 2,
766
+ offsetX,
767
+ 0
768
+ ], rgba(p.plate.borderColor, p.plate.opacity * opacity), a, b, worldMode);
769
+ appendPinInstance(data, ranges, image, highlightOnly, instanceId, occlusionFlag, PIN_KIND.PLATE, [
770
+ Math.max(0, width + padding * 2 - p.plate.borderWidth * 2),
771
+ Math.max(0, height + padding * 2 - p.plate.borderWidth * 2),
772
+ offsetX,
773
+ p.plate.borderWidth
774
+ ], rgba(p.plate.color, p.plate.opacity * opacity), a, b, worldMode);
775
+ }
776
+ if (p.symbolHalo.width > 0 && p.symbolHalo.opacity > 0) {
777
+ const radius = p.symbolHalo.width;
778
+ appendPinInstance(data, ranges, image, highlightOnly, instanceId, occlusionFlag, PIN_KIND.SYMBOL_HALO, [
779
+ width + radius * 2,
780
+ height + radius * 2,
781
+ offsetX,
782
+ padding - radius
783
+ ], rgba(p.symbolHalo.color, p.symbolHalo.opacity * opacity), a, [
784
+ b[0],
785
+ b[1],
786
+ b[2],
787
+ radius
788
+ ], worldMode);
789
+ }
790
+ appendPinInstance(data, ranges, image, highlightOnly, instanceId, occlusionFlag, PIN_KIND.SYMBOL, [
791
+ width,
792
+ height,
793
+ offsetX,
794
+ padding
795
+ ], [
796
+ 1,
797
+ 1,
798
+ 1,
799
+ opacity
800
+ ], a, b, worldMode);
801
+ if (pin.highlightOnly) {
802
+ const margin = Math.max(padding, p.symbolHalo.opacity > 0 ? p.symbolHalo.width : 0);
803
+ appendPinInstance(data, ranges, image, highlightOnly, instanceId, occlusionFlag, PIN_KIND.HIGHLIGHT, [
804
+ width + margin * 2 + 6,
805
+ height + margin * 2 + 6,
806
+ offsetX,
807
+ padding - margin - 3
808
+ ], rgba("#0284c7", 1), a, b, worldMode);
809
+ }
810
+ }
811
+ rebuild() {
812
+ if (!this.ready) return;
813
+ if (!this.dirty) {
814
+ const previousPending = new Set(this.pendingElevation);
815
+ const center = {
816
+ x: this.origin[0],
817
+ y: this.origin[1]
818
+ };
819
+ for (const i of this.changedPins) {
820
+ const data = [];
821
+ this.buildPin(i, center, data, []);
822
+ const slice = this.slices[i];
823
+ const page = this.atlas.entries.get(this.pins[i].resource.id)?.page;
824
+ if (data.length !== slice.length || page !== slice.page) {
825
+ this.pendingElevation = previousPending;
826
+ this.dirty = true;
827
+ break;
828
+ }
829
+ this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.buffer);
830
+ this.gl.bufferSubData(this.gl.ARRAY_BUFFER, slice.offset * Float32Array.BYTES_PER_ELEMENT, new Float32Array(data));
831
+ }
832
+ if (!this.dirty) {
833
+ this.changedPins.clear();
834
+ return;
835
+ }
836
+ }
837
+ const map = this.map, gl = this.gl;
838
+ if (!this.ready) return;
839
+ const viewCenter = map.getCenter();
840
+ const center = pinMercator([viewCenter.lng, viewCenter.lat]);
841
+ this.origin = [center.x, center.y];
842
+ const data = [];
843
+ const ranges = [];
844
+ this.elevations = [];
845
+ const previousPending = this.pendingElevation;
846
+ this.pendingElevation = /* @__PURE__ */ new Set();
847
+ this.setStatus({ status: "supported" });
848
+ this.slices = [];
849
+ for (let i = 0; i < this.pins.length; i++) {
850
+ const offset = data.length;
851
+ this.buildPin(i, center, data, ranges);
852
+ this.slices.push({
853
+ offset,
854
+ length: data.length - offset,
855
+ page: this.atlas.entries.get(this.pins[i].resource.id)?.page
856
+ });
857
+ }
858
+ const waiting = [...this.pendingElevation].filter((id) => !previousPending.has(id));
859
+ const available = [...previousPending].filter((id) => !this.pendingElevation.has(id));
860
+ if (waiting.length) this.onStatus?.({
861
+ status: "waiting-for-elevation",
862
+ graphicIds: waiting
863
+ });
864
+ if (available.length) this.onStatus?.({
865
+ status: "supported",
866
+ graphicIds: available
867
+ });
868
+ this.ranges = ranges;
869
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
870
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.DYNAMIC_DRAW);
871
+ this.dirty = false;
872
+ this.changedPins.clear();
873
+ }
874
+ render(context, input) {
875
+ if (!this.program || !this.ready || this.removed) return;
876
+ if (this.dirty || this.changedPins.size) try {
877
+ this.rebuild();
878
+ } catch (error) {
879
+ this.setStatus({
880
+ status: "unavailable",
881
+ reason: `Pin terrain: ${error}`
882
+ });
883
+ this.dirty = false;
884
+ return;
885
+ }
886
+ if (this.status.status !== "supported") return;
887
+ this.frame = input;
888
+ this.projection.update(input, this.origin, this.map.getCenter(), 512 * 2 ** this.map.getZoom(), this.map.getProjection()?.type === "globe");
889
+ this.draw(context, false);
890
+ }
891
+ draw(gl, pick) {
892
+ const u = this.uniforms ?? this.resolveUniforms(gl);
893
+ gl.useProgram(this.program);
894
+ gl.bindVertexArray(this.vao);
895
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
896
+ gl.disable(gl.DEPTH_TEST);
897
+ gl.depthRange(0, 1);
898
+ gl.depthMask(false);
899
+ gl.disable(gl.CULL_FACE);
900
+ gl.disable(gl.STENCIL_TEST);
901
+ if (pick) gl.disable(gl.BLEND);
902
+ else {
903
+ gl.enable(gl.BLEND);
904
+ gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
905
+ }
906
+ const width = this.map.getContainer().clientWidth, height = this.map.getContainer().clientHeight;
907
+ gl.uniformMatrix4fv(u.matrix, false, this.matrix);
908
+ gl.uniformMatrix4fv(u.globeMatrix, false, this.projection.globeMatrix);
909
+ gl.uniform4fv(u.clippingPlane, this.projection.clippingPlane);
910
+ gl.uniform2f(u.origin, this.origin[0], this.origin[1]);
911
+ gl.uniform1f(u.globeTransition, this.projection.transition);
912
+ gl.uniform1f(u.world, 512 * 2 ** this.map.getZoom());
913
+ gl.uniform2f(u.view, width, height);
914
+ gl.uniform2f(u.buffer, gl.drawingBufferWidth, gl.drawingBufferHeight);
915
+ gl.uniform1i(u.pick, pick ? 1 : 0);
916
+ gl.uniform1i(u.atlas, 0);
917
+ gl.uniform1i(u.terrain, 1);
918
+ const terrain = this.map.terrain;
919
+ const needsDepth = this.needsDepth && !!this.map.getTerrain();
920
+ const texture = needsDepth ? terrain?._fboDepthTexture?.texture : null;
921
+ if (needsDepth && !texture) {
922
+ this.setStatus({
923
+ status: "unavailable",
924
+ reason: "MapLibre terrain depth texture is unavailable"
925
+ });
926
+ this.dirty = true;
927
+ gl.bindVertexArray(null);
928
+ return;
929
+ }
930
+ gl.uniform1i(u.hasTerrain, texture ? 1 : 0);
931
+ gl.activeTexture(gl.TEXTURE1);
932
+ gl.bindTexture(gl.TEXTURE_2D, texture ?? this.textures[0]);
933
+ gl.activeTexture(gl.TEXTURE0);
934
+ for (const range of this.ranges) {
935
+ gl.bindTexture(gl.TEXTURE_2D, this.textures[range.page]);
936
+ for (let attr = 0; attr < 6; attr++) {
937
+ gl.enableVertexAttribArray(attr);
938
+ gl.vertexAttribPointer(attr, 4, gl.FLOAT, false, STRIDE * 4, (range.first * STRIDE + attr * 4) * 4);
939
+ gl.vertexAttribDivisor(attr, 1);
940
+ }
941
+ gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, range.count);
942
+ }
943
+ gl.bindVertexArray(null);
944
+ }
945
+ /** On-demand GPU pick shares the visual shader including alpha and terrain mask. */
946
+ pick(pixel, tolerance = 0) {
947
+ if (!this.frame || this.status.status !== "supported") return null;
948
+ const gl = this.gl, w = gl.drawingBufferWidth, h = gl.drawingBufferHeight;
949
+ const previous = gl.getParameter(gl.FRAMEBUFFER_BINDING);
950
+ if (this.pickSize !== `${w}/${h}`) {
951
+ if (this.pickTexture) gl.deleteTexture(this.pickTexture);
952
+ if (this.pickFramebuffer) gl.deleteFramebuffer(this.pickFramebuffer);
953
+ this.pickTexture = gl.createTexture();
954
+ gl.activeTexture(gl.TEXTURE0);
955
+ gl.bindTexture(gl.TEXTURE_2D, this.pickTexture);
956
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
957
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
958
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
959
+ this.pickFramebuffer = gl.createFramebuffer();
960
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.pickFramebuffer);
961
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.pickTexture, 0);
962
+ this.pickSize = `${w}/${h}`;
963
+ }
964
+ const dpr = w / this.map.getContainer().clientWidth;
965
+ const radius = Math.ceil(tolerance * dpr), x = Math.max(0, Math.floor(pixel[0] * dpr) - radius), y = Math.max(0, h - 1 - Math.floor(pixel[1] * dpr) - radius), rw = Math.min(w - x, 2 * radius + 1), rh = Math.min(h - y, 2 * radius + 1);
966
+ if (rw <= 0 || rh <= 0) {
967
+ gl.bindFramebuffer(gl.FRAMEBUFFER, previous);
968
+ return null;
969
+ }
970
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.pickFramebuffer);
971
+ gl.viewport(0, 0, w, h);
972
+ gl.enable(gl.SCISSOR_TEST);
973
+ gl.scissor(x, y, rw, rh);
974
+ gl.colorMask(true, true, true, true);
975
+ gl.clearColor(0, 0, 0, 0);
976
+ gl.clear(gl.COLOR_BUFFER_BIT);
977
+ this.draw(gl, true);
978
+ const pixels = new Uint8Array(rw * rh * 4);
979
+ gl.readPixels(x, y, rw, rh, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
980
+ let id = 0;
981
+ for (let i = 0; i < pixels.length; i += 4) id = Math.max(id, pixels[i] + pixels[i + 1] * 256 + pixels[i + 2] * 65536);
982
+ gl.disable(gl.SCISSOR_TEST);
983
+ gl.bindFramebuffer(gl.FRAMEBUFFER, previous);
984
+ this.map.painter.context.setDirty();
985
+ this.map.painter.setBaseState();
986
+ this.map.triggerRepaint();
987
+ return id ? this.pins[id - 1]?.id ?? null : null;
988
+ }
989
+ /**
990
+ * Shared Mercator/globe clip-space projection converted to container pixels. Callers
991
+ * differ in elevation source and in whether/where a screen-mode lift offset applies
992
+ * — those stay at the call site, as they aren't part of the shared matrix math.
993
+ */
994
+ projectToPixel(worldX, worldY, z) {
995
+ const { clip: c, horizon } = this.projection.project(worldX, worldY, z);
996
+ if (horizon < 0 || c[3] <= 0) return [NaN, NaN];
997
+ const width = this.map.getContainer().clientWidth, height = this.map.getContainer().clientHeight;
998
+ return [(c[0] / c[3] + 1) * width / 2, (1 - c[1] / c[3]) * height / 2];
999
+ }
1000
+ footprint(id) {
1001
+ const i = this.pins.findIndex((p) => p.id === id);
1002
+ const pin = this.pins[i];
1003
+ if (!pin || !this.ready || !this.frame || !Number.isFinite(this.elevations[i])) return null;
1004
+ const p = pin.presentation, coord = pinMercator(pin.position);
1005
+ coord.x += Math.round(this.origin[0] - coord.x);
1006
+ const world = 512 * 2 ** this.map.getZoom();
1007
+ const project = (z) => this.projectToPixel((coord.x - this.origin[0]) * world, (coord.y - this.origin[1]) * world, z);
1008
+ const ground = project(this.elevations[i] ?? 0), attachment = p.mode === "world" ? project((this.elevations[i] ?? 0) + p.heightMeters) : [ground[0], ground[1] - p.liftPixels];
1009
+ if (!attachment.every(Number.isFinite)) return null;
1010
+ const scale = p.sizePixels / pin.octagonSize, padding = p.plate.enabled ? p.plate.padding : 0, halo = p.symbolHalo.opacity > 0 ? p.symbolHalo.width : 0, overhang = Math.max(0, halo - padding);
1011
+ return {
1012
+ ground,
1013
+ attachment,
1014
+ min: [attachment[0] - pin.resource.anchor[0] * scale - padding - overhang, attachment[1] - pin.resource.height * scale - padding * 2 - overhang],
1015
+ max: [attachment[0] + (pin.resource.width - pin.resource.anchor[0]) * scale + padding + overhang, attachment[1] + overhang]
1016
+ };
1017
+ }
1018
+ moveFromPixel(id, attachment) {
1019
+ const pin = this.pins.find((pin) => pin.id === id);
1020
+ if (!pin || !this.frame) return null;
1021
+ const p = pin.presentation;
1022
+ const project = (ll) => {
1023
+ const c = pinMercator(ll);
1024
+ c.x += Math.round(this.origin[0] - c.x);
1025
+ const world = 512 * 2 ** this.map.getZoom();
1026
+ const [px, py] = this.projectToPixel((c.x - this.origin[0]) * world, (c.y - this.origin[1]) * world, this.elevation(ll) + (p.mode === "world" ? p.heightMeters : 0));
1027
+ return [px, py - (p.mode === "screen" ? p.liftPixels : 0)];
1028
+ };
1029
+ return invertPinProjection(project, pin.position, attachment, this.map.getProjection()?.type === "globe" ? 90 : void 0);
1030
+ }
1031
+ onRemove(_map, context) {
1032
+ this.removed = true;
1033
+ this.version++;
1034
+ const gl = context;
1035
+ this.onRemoveListeners();
1036
+ if (this.pickTexture) gl.deleteTexture(this.pickTexture);
1037
+ if (this.pickFramebuffer) gl.deleteFramebuffer(this.pickFramebuffer);
1038
+ if (this.vao) gl.deleteVertexArray(this.vao);
1039
+ if (this.buffer) gl.deleteBuffer(this.buffer);
1040
+ if (this.program) gl.deleteProgram(this.program);
1041
+ }
1042
+ };
1043
+ //#endregion
3
1044
  //#region src/mercator.ts
4
1045
  /**
5
1046
  * Web Mercator world circumference in meters (2π × 6378137). The single source
@@ -255,6 +1296,25 @@ function emptyFC() {
255
1296
  };
256
1297
  }
257
1298
  /**
1299
+ * Builds the pin-status emitter passed to each `PinLayer`. Bound only to the
1300
+ * `listeners` set (never to the adapter itself, via `this`), so a `PinLayer`
1301
+ * holding onto this callback for its lifetime does not transitively retain
1302
+ * the whole `MapLibreAdapter` object graph (map, all layer handles, etc.).
1303
+ */
1304
+ function createPinStatusDispatcher(listeners) {
1305
+ return (event) => {
1306
+ queueMicrotask(() => {
1307
+ const errors = [];
1308
+ for (const listener of listeners) try {
1309
+ listener(event);
1310
+ } catch (error) {
1311
+ errors.push(error);
1312
+ }
1313
+ if (errors.length) throw new AggregateError(errors, "Pin presentation status listener errors");
1314
+ });
1315
+ };
1316
+ }
1317
+ /**
258
1318
  * Build the paint expression for a circle layer with per-feature
259
1319
  * `properties.style` overlay (ADR-0006). Each property prefers a value
260
1320
  * pulled from `properties.style.<field>`, falling back to the layer default.
@@ -825,12 +1885,36 @@ var MapLibreAdapter = class extends BaseMapAdapter {
825
1885
  }
826
1886
  destroyLayer(handle) {
827
1887
  this.clearGroupBundles(handle);
1888
+ handle.pinResources?.dispose();
828
1889
  for (const mlId of handle.mlLayerIds) if (this.map.getLayer(mlId)) this.map.removeLayer(mlId);
829
1890
  if (this.map.getSource(handle.sourceId)) this.map.removeSource(handle.sourceId);
830
1891
  }
831
- applyFeatureGroups(layerId, groups) {
1892
+ applyFeatureGroups(layerId, input) {
1893
+ const groups = [];
1894
+ for (const group of input) if (group.pin && this.getPinPresentationCapability().status !== "unsupported") {
1895
+ const previous = groups[groups.length - 1];
1896
+ if (previous?.pinBatch) previous.pinBatch.push(group.pin);
1897
+ else groups.push({
1898
+ id: `pin-run:${group.id}`,
1899
+ features: [],
1900
+ iconResources: [],
1901
+ pinBatch: [group.pin]
1902
+ });
1903
+ } else groups.push({
1904
+ ...group,
1905
+ id: `ordinary:${group.id}`
1906
+ });
832
1907
  const handle = this.layers.get(layerId);
833
1908
  if (!handle) return;
1909
+ const allPins = groups.flatMap((group) => group.pinBatch ?? []);
1910
+ if (allPins.length) {
1911
+ handle.pinResources ??= new PinResources(this.map);
1912
+ const previousBatch = handle.lastPinBatch;
1913
+ if (!(previousBatch !== void 0 && previousBatch.length === allPins.length && allPins.every((pin, index) => pin === previousBatch[index]))) {
1914
+ handle.pinResources.setRoster(allPins);
1915
+ handle.lastPinBatch = allPins;
1916
+ }
1917
+ }
834
1918
  const desiredGroupIds = new Set(groups.map((group) => group.id));
835
1919
  const previousGroupOrder = handle.groupOrder.filter((groupId) => desiredGroupIds.has(groupId));
836
1920
  const bundlesNeedingOrder = /* @__PURE__ */ new Set();
@@ -846,6 +1930,26 @@ var MapLibreAdapter = class extends BaseMapAdapter {
846
1930
  bundle = this.createGroupBundle(layerId, handle);
847
1931
  handle.groupBundles.set(group.id, bundle);
848
1932
  }
1933
+ if (group.pinBatch) {
1934
+ if (!bundle.pinLayer) {
1935
+ const custom = new PinLayer(`${bundle.sourceId}-pins`, handle.pinResources, this.emitPinStatus);
1936
+ this.map.addLayer(custom, handle.mlLayerIds[0]);
1937
+ bundle.pinLayer = custom;
1938
+ bundlesNeedingOrder.add(group.id);
1939
+ }
1940
+ const previous = bundle.pinSignature;
1941
+ let changedIndices;
1942
+ if (previous?.length === group.pinBatch.length) {
1943
+ changedIndices = [];
1944
+ for (let i = 0; i < group.pinBatch.length; i++) if (group.pinBatch[i] !== previous[i]) changedIndices.push(i);
1945
+ }
1946
+ if (!previous || bundle.pinResourceRevision !== handle.pinResources?.revision || previous.length !== group.pinBatch.length || !!changedIndices?.length) {
1947
+ bundle.pinSignature = group.pinBatch;
1948
+ bundle.pinResourceRevision = handle.pinResources?.revision;
1949
+ bundle.pinLayer.setPins(group.pinBatch, changedIndices);
1950
+ }
1951
+ continue;
1952
+ }
849
1953
  let contentChanged;
850
1954
  if (!isNewBundle && bundle.revision !== void 0 && group.revision !== void 0) {
851
1955
  contentChanged = bundle.revision !== group.revision;
@@ -864,6 +1968,11 @@ var MapLibreAdapter = class extends BaseMapAdapter {
864
1968
  if (this.reconcileGroupPaintSurfaces(handle, bundle, group.features)) bundlesNeedingOrder.add(group.id);
865
1969
  if (contentChanged || resourcesChanged) this.updateGroupBundleSource(bundle);
866
1970
  }
1971
+ if (!allPins.length) {
1972
+ handle.pinResources?.dispose();
1973
+ handle.pinResources = void 0;
1974
+ handle.lastPinBatch = void 0;
1975
+ }
867
1976
  handle.groupOrder = groups.map((group) => group.id);
868
1977
  this.orderGroupBundles(handle, previousGroupOrder, handle.groupOrder, bundlesNeedingOrder);
869
1978
  }
@@ -888,6 +1997,7 @@ var MapLibreAdapter = class extends BaseMapAdapter {
888
1997
  };
889
1998
  }
890
1999
  destroyGroupBundle(bundle) {
2000
+ if (bundle.pinLayer && this.map.getLayer(bundle.pinLayer.id)) this.map.removeLayer(bundle.pinLayer.id);
891
2001
  for (const overlay of bundle.overlays.values()) {
892
2002
  if (this.map.getLayer(overlay.layerId)) this.map.removeLayer(overlay.layerId);
893
2003
  if (this.map.getSource(overlay.sourceId)) this.map.removeSource(overlay.sourceId);
@@ -954,6 +2064,9 @@ var MapLibreAdapter = class extends BaseMapAdapter {
954
2064
  for (const bundle of handle.groupBundles.values()) this.destroyGroupBundle(bundle);
955
2065
  handle.groupBundles.clear();
956
2066
  handle.groupOrder = [];
2067
+ handle.pinResources?.dispose();
2068
+ handle.pinResources = void 0;
2069
+ handle.lastPinBatch = void 0;
957
2070
  }
958
2071
  requiredIconIds(group) {
959
2072
  const referenced = new Set(group.features.flatMap((feature) => {
@@ -1099,13 +2212,14 @@ var MapLibreAdapter = class extends BaseMapAdapter {
1099
2212
  const bundle = handle.groupBundles.get(groupId);
1100
2213
  if (!bundle) continue;
1101
2214
  if (forcedGroupIds.has(groupId) || stableGroupIds !== null && !stableGroupIds.has(groupId)) {
2215
+ if (bundle.pinLayer && this.map.getLayer(bundle.pinLayer.id)) this.map.moveLayer(bundle.pinLayer.id, beforeId);
1102
2216
  for (const overlay of bundle.overlays.values()) if (this.map.getLayer(overlay.layerId)) this.map.moveLayer(overlay.layerId, beforeId);
1103
2217
  for (const key of bundle.paintOrder) {
1104
2218
  const surface = bundle.paintSurfaces.get(key);
1105
2219
  if (surface && this.map.getLayer(surface.layerId)) this.map.moveLayer(surface.layerId, beforeId);
1106
2220
  }
1107
2221
  }
1108
- beforeId = firstBundleLayerId(bundle) ?? beforeId;
2222
+ beforeId = bundle.pinLayer?.id ?? firstBundleLayerId(bundle) ?? beforeId;
1109
2223
  }
1110
2224
  }
1111
2225
  nativeLayerIds(handle) {
@@ -1200,10 +2314,11 @@ var MapLibreAdapter = class extends BaseMapAdapter {
1200
2314
  for (const event of this.viewChangeEvents()) this.map.off(event, token.fn);
1201
2315
  }
1202
2316
  viewChangeEvents() {
1203
- return this.viewChangeMode === "settle" ? ["moveend"] : [
2317
+ return this.viewChangeMode === "settle" ? ["moveend", "projectiontransition"] : [
1204
2318
  "move",
1205
2319
  "resize",
1206
- "moveend"
2320
+ "moveend",
2321
+ "projectiontransition"
1207
2322
  ];
1208
2323
  }
1209
2324
  createEditPointerDriver() {
@@ -1464,7 +2579,7 @@ var MapLibreAdapter = class extends BaseMapAdapter {
1464
2579
  id: feature.id,
1465
2580
  geometry: feature.geometry,
1466
2581
  properties: feature.properties
1467
- })), this.featureCache.get(layerId) ?? []);
2582
+ })), (this.featureCache.get(layerId) ?? []).filter((f) => !f.properties?.pinPresentation));
1468
2583
  const size = this.getViewportSize();
1469
2584
  const candidate = evaluatePickCandidates(relevantFeatures.map(({ feature, nativeHit, visualOrder }) => {
1470
2585
  const iconId = feature.properties?.iconId;
@@ -1495,6 +2610,17 @@ var MapLibreAdapter = class extends BaseMapAdapter {
1495
2610
  },
1496
2611
  bearing: this.getBearing()
1497
2612
  });
2613
+ const pinHit = this.pinHit(layerId, [x, y], tol);
2614
+ if (pinHit && (!candidate || pinHit.order >= (this.getFeatureGroupOrder(layerId, candidate.feature.id) ?? -1))) {
2615
+ handler({
2616
+ id: pinHit.id,
2617
+ feature: pinHit.feature,
2618
+ pixel: [x, y],
2619
+ coordinate: [e.lngLat.lng, e.lngLat.lat],
2620
+ originalEvent
2621
+ });
2622
+ return;
2623
+ }
1498
2624
  if (!candidate) return;
1499
2625
  const cmId = candidate.graphicId;
1500
2626
  if (cmId == null) return;
@@ -1511,6 +2637,74 @@ var MapLibreAdapter = class extends BaseMapAdapter {
1511
2637
  return () => this.map.off("click", onMlClick);
1512
2638
  });
1513
2639
  }
2640
+ pinStatusListeners = /* @__PURE__ */ new Set();
2641
+ emitPinStatus = createPinStatusDispatcher(this.pinStatusListeners);
2642
+ onPinPresentationStatusChange(handler) {
2643
+ this.pinStatusListeners.add(handler);
2644
+ return () => this.pinStatusListeners.delete(handler);
2645
+ }
2646
+ /**
2647
+ * Pin layers across this adapter's group bundles.
2648
+ * - With no `handle`: every layer's bundles, in bundle-map (creation)
2649
+ * order — fine for id-based lookups where only "does one match" matters.
2650
+ * - With `handle`: only that layer's bundles, walked topmost-first (reverse
2651
+ * paint order) — required for hit-testing, where the first hit must be
2652
+ * the visually topmost pin.
2653
+ */
2654
+ *pinLayers(handle) {
2655
+ if (handle) {
2656
+ for (const groupId of [...handle.groupOrder].reverse()) {
2657
+ const pinLayer = handle.groupBundles.get(groupId)?.pinLayer;
2658
+ if (pinLayer) yield pinLayer;
2659
+ }
2660
+ return;
2661
+ }
2662
+ for (const h of this.layers.values()) for (const bundle of h.groupBundles.values()) if (bundle.pinLayer) yield bundle.pinLayer;
2663
+ }
2664
+ /** First non-null/undefined result of `fn` across all pin layers, else null. */
2665
+ findPinLayer(fn) {
2666
+ for (const pinLayer of this.pinLayers()) {
2667
+ const result = fn(pinLayer);
2668
+ if (result != null) return result;
2669
+ }
2670
+ return null;
2671
+ }
2672
+ getPinPresentationCapability(graphicId) {
2673
+ if (!(maplibregl.getVersion?.() ?? "").startsWith("6.")) return {
2674
+ status: "unsupported",
2675
+ reason: "Pins require MapLibre 6"
2676
+ };
2677
+ if (graphicId) {
2678
+ const status = this.findPinLayer((pinLayer) => pinLayer.has(graphicId) ? pinLayer.getStatus(graphicId) : null);
2679
+ if (status) return status;
2680
+ }
2681
+ return { status: "supported" };
2682
+ }
2683
+ getPinFootprint(id) {
2684
+ return this.findPinLayer((pinLayer) => pinLayer.footprint(id));
2685
+ }
2686
+ movePinFromPixel(id, attachment) {
2687
+ return this.findPinLayer((pinLayer) => pinLayer.moveFromPixel(id, attachment));
2688
+ }
2689
+ pinHit(layerId, pixel, tolerance) {
2690
+ const handle = this.layers.get(layerId);
2691
+ if (!handle) return null;
2692
+ for (const pinLayer of this.pinLayers(handle)) {
2693
+ const id = pinLayer.pick(pixel, tolerance);
2694
+ if (!id) continue;
2695
+ const feature = this.featureCache.get(layerId)?.find((f) => f.properties?.graphicId === id);
2696
+ if (feature) return {
2697
+ id,
2698
+ feature,
2699
+ order: this.getFeatureGroupOrder(layerId, feature.id) ?? 0
2700
+ };
2701
+ }
2702
+ return null;
2703
+ }
2704
+ pickPin(id, pixel, tolerance) {
2705
+ for (const [layerId] of this.layers) if (this.pinHit(layerId, pixel, tolerance)?.id === id) return true;
2706
+ return false;
2707
+ }
1514
2708
  setGlobeMode(enabled) {
1515
2709
  if (enabled) this.map.setProjection({ type: "globe" });
1516
2710
  else this.map.setProjection({ type: "mercator" });