@orbat-mapper/tactical-draw-adapter-maplibre 0.3.22 → 0.4.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/dist/index.mjs CHANGED
@@ -1,5 +1,1035 @@
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 { tileID, mercatorX, mercatorY } = terrain._getOverscaledTileIDFromLngLatZoom(new LngLat(position[0], position[1]), Math.min(terrain.tileManager.maxzoom, terrain.tileManager.getSource().maxzoom + terrain.tileManager.deltaZoom));
527
+ const sampler = terrain._getElevationSampler(tileID);
528
+ if (!sampler) return NaN;
529
+ const value = sampler(mercatorX % 8192, mercatorY % 8192, 8192) * terrain.exaggeration;
530
+ this.elevationCache.set(key, value);
531
+ return value;
532
+ }
533
+ has(id) {
534
+ return this.pins.some((pin) => pin.id === id);
535
+ }
536
+ constructor(id, resources, onStatus) {
537
+ this.id = id;
538
+ this.resources = resources;
539
+ this.onStatus = onStatus;
540
+ }
541
+ restoreContext = () => {
542
+ this.ready = false;
543
+ this.resourceRevision = -1;
544
+ this.textures = [];
545
+ this.pickSize = "";
546
+ this.pickTexture = void 0;
547
+ this.pickFramebuffer = void 0;
548
+ this.onRemoveListeners();
549
+ this.onAdd(this.map, this.gl);
550
+ this.setPins(this.pins);
551
+ };
552
+ onRemoveListeners() {
553
+ this.map.off("sourcedata", this.refreshTerrain);
554
+ this.map.off("terrain", this.refreshTerrain);
555
+ this.map.off("resize", this.refreshTerrain);
556
+ this.map.off("moveend", this.refreshTerrain);
557
+ this.map.off("webglcontextrestored", this.restoreContext);
558
+ }
559
+ onAdd(map, context) {
560
+ this.removed = false;
561
+ this.map = map;
562
+ this.gl = context;
563
+ const gl = this.gl;
564
+ try {
565
+ const program = gl.createProgram();
566
+ for (const [type, source] of [[gl.VERTEX_SHADER, PIN_VERTEX], [gl.FRAGMENT_SHADER, PIN_FRAGMENT]]) {
567
+ const s = shader(gl, type, source);
568
+ gl.attachShader(program, s);
569
+ gl.deleteShader(s);
570
+ }
571
+ gl.linkProgram(program);
572
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(program) ?? "Pin link failed");
573
+ this.program = program;
574
+ this.uniforms = this.resolveUniforms(gl);
575
+ this.buffer = gl.createBuffer();
576
+ this.vao = gl.createVertexArray();
577
+ map.on("webglcontextrestored", this.restoreContext);
578
+ map.on("sourcedata", this.refreshTerrain);
579
+ map.on("terrain", this.refreshTerrain);
580
+ map.on("resize", this.refreshTerrain);
581
+ map.on("moveend", this.refreshTerrain);
582
+ } catch (error) {
583
+ this.setStatus({
584
+ status: "unavailable",
585
+ reason: String(error)
586
+ });
587
+ }
588
+ }
589
+ /** Uniform locations are fixed once a program is linked; resolved here, not per draw. */
590
+ resolveUniforms(gl) {
591
+ const program = this.program;
592
+ const uniforms = {
593
+ matrix: gl.getUniformLocation(program, "u_matrix"),
594
+ globeMatrix: gl.getUniformLocation(program, "u_globeMatrix"),
595
+ clippingPlane: gl.getUniformLocation(program, "u_clippingPlane"),
596
+ origin: gl.getUniformLocation(program, "u_origin"),
597
+ globeTransition: gl.getUniformLocation(program, "u_globeTransition"),
598
+ world: gl.getUniformLocation(program, "u_world"),
599
+ view: gl.getUniformLocation(program, "u_view"),
600
+ buffer: gl.getUniformLocation(program, "u_buffer"),
601
+ pick: gl.getUniformLocation(program, "u_pick"),
602
+ atlas: gl.getUniformLocation(program, "u_atlas"),
603
+ terrain: gl.getUniformLocation(program, "u_terrain"),
604
+ hasTerrain: gl.getUniformLocation(program, "u_hasTerrain")
605
+ };
606
+ this.uniforms = uniforms;
607
+ return uniforms;
608
+ }
609
+ setStatus(status) {
610
+ if (status.status === this.status.status && status.reason === this.status.reason) return;
611
+ this.status = status;
612
+ this.onStatus?.({
613
+ ...status,
614
+ graphicIds: this.pins.filter((p) => !p.highlightOnly).map((p) => p.id)
615
+ });
616
+ }
617
+ getStatus(id) {
618
+ return id && this.pendingElevation.has(id) ? { status: "waiting-for-elevation" } : { ...this.status };
619
+ }
620
+ async setPins(pins, changedIndices) {
621
+ if (pins.length !== this.pins.length) this.dirty = true;
622
+ else {
623
+ const changed = changedIndices ?? pins.flatMap((pin, i) => pin !== this.pins[i] ? [i] : []);
624
+ for (const i of changed) {
625
+ if (pins[i].id !== this.pins[i].id) this.dirty = true;
626
+ this.changedPins.add(i);
627
+ }
628
+ }
629
+ this.pins = pins;
630
+ if (this.elevationCache.size > pins.length * 60 + 1e3) this.elevationCache.clear();
631
+ if (!this.program) {
632
+ this.onStatus?.({
633
+ ...this.status,
634
+ graphicIds: pins.map((pin) => pin.id)
635
+ });
636
+ return;
637
+ }
638
+ this.needsDepth = pins.some((p) => p.presentation.occlusion === "terrain");
639
+ if (this.resourceRevision === this.resources.revision && this.ready) {
640
+ this.map.triggerRepaint();
641
+ return;
642
+ }
643
+ this.dirty = true;
644
+ this.resourceRevision = this.resources.revision;
645
+ this.ready = false;
646
+ this.setStatus({ status: "loading" });
647
+ const version = ++this.version;
648
+ try {
649
+ const snapshot = await this.resources.load(this.gl);
650
+ if (!snapshot || version !== this.version || this.removed) return;
651
+ this.atlas = snapshot.atlas;
652
+ this.textures = snapshot.textures;
653
+ this.ready = true;
654
+ this.map.triggerRepaint();
655
+ } catch (error) {
656
+ this.setStatus({
657
+ status: "unavailable",
658
+ reason: `Pin resources: ${error}`
659
+ });
660
+ }
661
+ }
662
+ buildPin(i, center, data, ranges) {
663
+ const pin = this.pins[i], p = pin.presentation;
664
+ const anchor = pinMercator(pin.position);
665
+ anchor.x += Math.round(center.x - anchor.x);
666
+ const elevation = this.elevation(pin.position);
667
+ this.elevations[i] = elevation;
668
+ if (!Number.isFinite(elevation)) {
669
+ this.pendingElevation.add(pin.id);
670
+ return;
671
+ }
672
+ this.pendingElevation.delete(pin.id);
673
+ const a = [
674
+ anchor.x - center.x,
675
+ anchor.y - center.y,
676
+ elevation,
677
+ p.liftPixels
678
+ ];
679
+ const b = [
680
+ a[0],
681
+ a[1],
682
+ elevation + (p.mode === "world" ? p.heightMeters : 0),
683
+ p.plate.radius
684
+ ];
685
+ const image = this.atlas.entries.get(pin.resource.id);
686
+ const opacity = pin.opacity;
687
+ const highlightOnly = pin.highlightOnly;
688
+ const instanceId = i + 1;
689
+ const occlusionFlag = p.occlusion === "terrain" ? 1 : 0;
690
+ const worldMode = p.mode === "world" ? 1 : 0;
691
+ const haloColor = rgba(p.halo.color, p.halo.opacity * opacity);
692
+ const stemColor = rgba(p.stem.color, p.stem.opacity * opacity);
693
+ if (pin.direction !== void 0 && !pin.highlightOnly) {
694
+ const elevationAt = (ll) => this.elevation(ll);
695
+ const angle = pin.direction, length = 180;
696
+ const tip = [Math.sin(angle) * length, Math.cos(angle) * length];
697
+ const paths = [
698
+ [[0, 0], tip],
699
+ [[tip[0] - Math.sin(angle - .5) * length * .25, tip[1] - Math.cos(angle - .5) * length * .25], tip],
700
+ [[tip[0] - Math.sin(angle + .5) * length * .25, tip[1] - Math.cos(angle + .5) * length * .25], tip]
701
+ ];
702
+ const saved = [...a];
703
+ for (const [from, to] of paths) for (let segment = 0; segment < 8; segment++) {
704
+ const ground = arrowSegmentPoint(center, pin.position, elevationAt, from, to, segment / 8);
705
+ const target = arrowSegmentPoint(center, pin.position, elevationAt, from, to, (segment + 1) / 8);
706
+ if (!Number.isFinite(ground[2]) || !Number.isFinite(target[2])) continue;
707
+ a.splice(0, 4, ...ground);
708
+ appendPinInstance(data, ranges, image, highlightOnly, instanceId, occlusionFlag, PIN_KIND.STEM, [
709
+ p.stem.width + 2 * p.halo.width,
710
+ 0,
711
+ 0,
712
+ 0
713
+ ], haloColor, a, target, 1);
714
+ appendPinInstance(data, ranges, image, highlightOnly, instanceId, occlusionFlag, PIN_KIND.STEM, [
715
+ p.stem.width,
716
+ 0,
717
+ 0,
718
+ 0
719
+ ], stemColor, a, target, 1);
720
+ }
721
+ a.splice(0, 4, ...saved);
722
+ }
723
+ appendPinInstance(data, ranges, image, highlightOnly, instanceId, occlusionFlag, PIN_KIND.STEM, [
724
+ p.stem.width + 2 * p.halo.width,
725
+ 0,
726
+ 0,
727
+ 0
728
+ ], haloColor, a, b, worldMode);
729
+ appendPinInstance(data, ranges, image, highlightOnly, instanceId, occlusionFlag, PIN_KIND.STEM, [
730
+ p.stem.width,
731
+ 0,
732
+ 0,
733
+ 0
734
+ ], stemColor, a, b, worldMode);
735
+ appendPinInstance(data, ranges, image, highlightOnly, instanceId, occlusionFlag, PIN_KIND.DOT, [
736
+ 2 * (p.dot.radius + p.halo.width),
737
+ 2 * (p.dot.radius + p.halo.width),
738
+ 0,
739
+ 0
740
+ ], haloColor, a, b, worldMode);
741
+ appendPinInstance(data, ranges, image, highlightOnly, instanceId, occlusionFlag, PIN_KIND.DOT, [
742
+ 2 * p.dot.radius,
743
+ 2 * p.dot.radius,
744
+ 0,
745
+ 0
746
+ ], rgba(p.dot.color, p.dot.opacity * opacity), a, b, worldMode);
747
+ const scale = p.sizePixels / pin.octagonSize;
748
+ const width = pin.resource.width * scale, height = pin.resource.height * scale;
749
+ const offsetX = (pin.resource.width / 2 - pin.resource.anchor[0]) * scale;
750
+ const padding = p.plate.enabled ? p.plate.padding : 0;
751
+ if (p.plate.enabled) {
752
+ appendPinInstance(data, ranges, image, highlightOnly, instanceId, occlusionFlag, PIN_KIND.PLATE, [
753
+ width + padding * 2,
754
+ height + padding * 2,
755
+ offsetX,
756
+ 0
757
+ ], rgba(p.plate.borderColor, p.plate.opacity * opacity), a, b, worldMode);
758
+ appendPinInstance(data, ranges, image, highlightOnly, instanceId, occlusionFlag, PIN_KIND.PLATE, [
759
+ Math.max(0, width + padding * 2 - p.plate.borderWidth * 2),
760
+ Math.max(0, height + padding * 2 - p.plate.borderWidth * 2),
761
+ offsetX,
762
+ p.plate.borderWidth
763
+ ], rgba(p.plate.color, p.plate.opacity * opacity), a, b, worldMode);
764
+ }
765
+ if (p.symbolHalo.width > 0 && p.symbolHalo.opacity > 0) {
766
+ const radius = p.symbolHalo.width;
767
+ appendPinInstance(data, ranges, image, highlightOnly, instanceId, occlusionFlag, PIN_KIND.SYMBOL_HALO, [
768
+ width + radius * 2,
769
+ height + radius * 2,
770
+ offsetX,
771
+ padding - radius
772
+ ], rgba(p.symbolHalo.color, p.symbolHalo.opacity * opacity), a, [
773
+ b[0],
774
+ b[1],
775
+ b[2],
776
+ radius
777
+ ], worldMode);
778
+ }
779
+ appendPinInstance(data, ranges, image, highlightOnly, instanceId, occlusionFlag, PIN_KIND.SYMBOL, [
780
+ width,
781
+ height,
782
+ offsetX,
783
+ padding
784
+ ], [
785
+ 1,
786
+ 1,
787
+ 1,
788
+ opacity
789
+ ], a, b, worldMode);
790
+ if (pin.highlightOnly) {
791
+ const margin = Math.max(padding, p.symbolHalo.opacity > 0 ? p.symbolHalo.width : 0);
792
+ appendPinInstance(data, ranges, image, highlightOnly, instanceId, occlusionFlag, PIN_KIND.HIGHLIGHT, [
793
+ width + margin * 2 + 6,
794
+ height + margin * 2 + 6,
795
+ offsetX,
796
+ padding - margin - 3
797
+ ], rgba("#0284c7", 1), a, b, worldMode);
798
+ }
799
+ }
800
+ rebuild() {
801
+ if (!this.ready) return;
802
+ if (!this.dirty) {
803
+ const previousPending = new Set(this.pendingElevation);
804
+ const center = {
805
+ x: this.origin[0],
806
+ y: this.origin[1]
807
+ };
808
+ for (const i of this.changedPins) {
809
+ const data = [];
810
+ this.buildPin(i, center, data, []);
811
+ const slice = this.slices[i];
812
+ const page = this.atlas.entries.get(this.pins[i].resource.id)?.page;
813
+ if (data.length !== slice.length || page !== slice.page) {
814
+ this.pendingElevation = previousPending;
815
+ this.dirty = true;
816
+ break;
817
+ }
818
+ this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.buffer);
819
+ this.gl.bufferSubData(this.gl.ARRAY_BUFFER, slice.offset * Float32Array.BYTES_PER_ELEMENT, new Float32Array(data));
820
+ }
821
+ if (!this.dirty) {
822
+ this.changedPins.clear();
823
+ return;
824
+ }
825
+ }
826
+ const map = this.map, gl = this.gl;
827
+ if (!this.ready) return;
828
+ const viewCenter = map.getCenter();
829
+ const center = pinMercator([viewCenter.lng, viewCenter.lat]);
830
+ this.origin = [center.x, center.y];
831
+ const data = [];
832
+ const ranges = [];
833
+ this.elevations = [];
834
+ const previousPending = this.pendingElevation;
835
+ this.pendingElevation = /* @__PURE__ */ new Set();
836
+ this.setStatus({ status: "supported" });
837
+ this.slices = [];
838
+ for (let i = 0; i < this.pins.length; i++) {
839
+ const offset = data.length;
840
+ this.buildPin(i, center, data, ranges);
841
+ this.slices.push({
842
+ offset,
843
+ length: data.length - offset,
844
+ page: this.atlas.entries.get(this.pins[i].resource.id)?.page
845
+ });
846
+ }
847
+ const waiting = [...this.pendingElevation].filter((id) => !previousPending.has(id));
848
+ const available = [...previousPending].filter((id) => !this.pendingElevation.has(id));
849
+ if (waiting.length) this.onStatus?.({
850
+ status: "waiting-for-elevation",
851
+ graphicIds: waiting
852
+ });
853
+ if (available.length) this.onStatus?.({
854
+ status: "supported",
855
+ graphicIds: available
856
+ });
857
+ this.ranges = ranges;
858
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
859
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.DYNAMIC_DRAW);
860
+ this.dirty = false;
861
+ this.changedPins.clear();
862
+ }
863
+ render(context, input) {
864
+ if (!this.program || !this.ready || this.removed) return;
865
+ if (this.dirty || this.changedPins.size) try {
866
+ this.rebuild();
867
+ } catch (error) {
868
+ this.setStatus({
869
+ status: "unavailable",
870
+ reason: `Pin terrain: ${error}`
871
+ });
872
+ this.dirty = false;
873
+ return;
874
+ }
875
+ if (this.status.status !== "supported") return;
876
+ this.frame = input;
877
+ this.projection.update(input, this.origin, this.map.getCenter(), 512 * 2 ** this.map.getZoom(), this.map.getProjection()?.type === "globe");
878
+ this.draw(context, false);
879
+ }
880
+ draw(gl, pick) {
881
+ const u = this.uniforms ?? this.resolveUniforms(gl);
882
+ gl.useProgram(this.program);
883
+ gl.bindVertexArray(this.vao);
884
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
885
+ gl.disable(gl.DEPTH_TEST);
886
+ gl.depthRange(0, 1);
887
+ gl.depthMask(false);
888
+ gl.disable(gl.CULL_FACE);
889
+ gl.disable(gl.STENCIL_TEST);
890
+ if (pick) gl.disable(gl.BLEND);
891
+ else {
892
+ gl.enable(gl.BLEND);
893
+ gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
894
+ }
895
+ const width = this.map.getContainer().clientWidth, height = this.map.getContainer().clientHeight;
896
+ gl.uniformMatrix4fv(u.matrix, false, this.matrix);
897
+ gl.uniformMatrix4fv(u.globeMatrix, false, this.projection.globeMatrix);
898
+ gl.uniform4fv(u.clippingPlane, this.projection.clippingPlane);
899
+ gl.uniform2f(u.origin, this.origin[0], this.origin[1]);
900
+ gl.uniform1f(u.globeTransition, this.projection.transition);
901
+ gl.uniform1f(u.world, 512 * 2 ** this.map.getZoom());
902
+ gl.uniform2f(u.view, width, height);
903
+ gl.uniform2f(u.buffer, gl.drawingBufferWidth, gl.drawingBufferHeight);
904
+ gl.uniform1i(u.pick, pick ? 1 : 0);
905
+ gl.uniform1i(u.atlas, 0);
906
+ gl.uniform1i(u.terrain, 1);
907
+ const terrain = this.map.terrain;
908
+ const needsDepth = this.needsDepth && !!this.map.getTerrain();
909
+ const texture = needsDepth ? terrain?._fboDepthTexture?.texture : null;
910
+ if (needsDepth && !texture) {
911
+ this.setStatus({
912
+ status: "unavailable",
913
+ reason: "MapLibre terrain depth texture is unavailable"
914
+ });
915
+ this.dirty = true;
916
+ gl.bindVertexArray(null);
917
+ return;
918
+ }
919
+ gl.uniform1i(u.hasTerrain, texture ? 1 : 0);
920
+ gl.activeTexture(gl.TEXTURE1);
921
+ gl.bindTexture(gl.TEXTURE_2D, texture ?? this.textures[0]);
922
+ gl.activeTexture(gl.TEXTURE0);
923
+ for (const range of this.ranges) {
924
+ gl.bindTexture(gl.TEXTURE_2D, this.textures[range.page]);
925
+ for (let attr = 0; attr < 6; attr++) {
926
+ gl.enableVertexAttribArray(attr);
927
+ gl.vertexAttribPointer(attr, 4, gl.FLOAT, false, STRIDE * 4, (range.first * STRIDE + attr * 4) * 4);
928
+ gl.vertexAttribDivisor(attr, 1);
929
+ }
930
+ gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, range.count);
931
+ }
932
+ gl.bindVertexArray(null);
933
+ }
934
+ /** On-demand GPU pick shares the visual shader including alpha and terrain mask. */
935
+ pick(pixel, tolerance = 0) {
936
+ if (!this.frame || this.status.status !== "supported") return null;
937
+ const gl = this.gl, w = gl.drawingBufferWidth, h = gl.drawingBufferHeight;
938
+ const previous = gl.getParameter(gl.FRAMEBUFFER_BINDING);
939
+ if (this.pickSize !== `${w}/${h}`) {
940
+ if (this.pickTexture) gl.deleteTexture(this.pickTexture);
941
+ if (this.pickFramebuffer) gl.deleteFramebuffer(this.pickFramebuffer);
942
+ this.pickTexture = gl.createTexture();
943
+ gl.activeTexture(gl.TEXTURE0);
944
+ gl.bindTexture(gl.TEXTURE_2D, this.pickTexture);
945
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
946
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
947
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
948
+ this.pickFramebuffer = gl.createFramebuffer();
949
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.pickFramebuffer);
950
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.pickTexture, 0);
951
+ this.pickSize = `${w}/${h}`;
952
+ }
953
+ const dpr = w / this.map.getContainer().clientWidth;
954
+ 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);
955
+ if (rw <= 0 || rh <= 0) {
956
+ gl.bindFramebuffer(gl.FRAMEBUFFER, previous);
957
+ return null;
958
+ }
959
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.pickFramebuffer);
960
+ gl.viewport(0, 0, w, h);
961
+ gl.enable(gl.SCISSOR_TEST);
962
+ gl.scissor(x, y, rw, rh);
963
+ gl.colorMask(true, true, true, true);
964
+ gl.clearColor(0, 0, 0, 0);
965
+ gl.clear(gl.COLOR_BUFFER_BIT);
966
+ this.draw(gl, true);
967
+ const pixels = new Uint8Array(rw * rh * 4);
968
+ gl.readPixels(x, y, rw, rh, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
969
+ let id = 0;
970
+ for (let i = 0; i < pixels.length; i += 4) id = Math.max(id, pixels[i] + pixels[i + 1] * 256 + pixels[i + 2] * 65536);
971
+ gl.disable(gl.SCISSOR_TEST);
972
+ gl.bindFramebuffer(gl.FRAMEBUFFER, previous);
973
+ this.map.painter.context.setDirty();
974
+ this.map.painter.setBaseState();
975
+ this.map.triggerRepaint();
976
+ return id ? this.pins[id - 1]?.id ?? null : null;
977
+ }
978
+ /**
979
+ * Shared Mercator/globe clip-space projection converted to container pixels. Callers
980
+ * differ in elevation source and in whether/where a screen-mode lift offset applies
981
+ * — those stay at the call site, as they aren't part of the shared matrix math.
982
+ */
983
+ projectToPixel(worldX, worldY, z) {
984
+ const { clip: c, horizon } = this.projection.project(worldX, worldY, z);
985
+ if (horizon < 0 || c[3] <= 0) return [NaN, NaN];
986
+ const width = this.map.getContainer().clientWidth, height = this.map.getContainer().clientHeight;
987
+ return [(c[0] / c[3] + 1) * width / 2, (1 - c[1] / c[3]) * height / 2];
988
+ }
989
+ footprint(id) {
990
+ const i = this.pins.findIndex((p) => p.id === id);
991
+ const pin = this.pins[i];
992
+ if (!pin || !this.ready || !this.frame || !Number.isFinite(this.elevations[i])) return null;
993
+ const p = pin.presentation, coord = pinMercator(pin.position);
994
+ coord.x += Math.round(this.origin[0] - coord.x);
995
+ const world = 512 * 2 ** this.map.getZoom();
996
+ const project = (z) => this.projectToPixel((coord.x - this.origin[0]) * world, (coord.y - this.origin[1]) * world, z);
997
+ const ground = project(this.elevations[i] ?? 0), attachment = p.mode === "world" ? project((this.elevations[i] ?? 0) + p.heightMeters) : [ground[0], ground[1] - p.liftPixels];
998
+ if (!attachment.every(Number.isFinite)) return null;
999
+ 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);
1000
+ return {
1001
+ ground,
1002
+ attachment,
1003
+ min: [attachment[0] - pin.resource.anchor[0] * scale - padding - overhang, attachment[1] - pin.resource.height * scale - padding * 2 - overhang],
1004
+ max: [attachment[0] + (pin.resource.width - pin.resource.anchor[0]) * scale + padding + overhang, attachment[1] + overhang]
1005
+ };
1006
+ }
1007
+ moveFromPixel(id, attachment) {
1008
+ const pin = this.pins.find((pin) => pin.id === id);
1009
+ if (!pin || !this.frame) return null;
1010
+ const p = pin.presentation;
1011
+ const project = (ll) => {
1012
+ const c = pinMercator(ll);
1013
+ c.x += Math.round(this.origin[0] - c.x);
1014
+ const world = 512 * 2 ** this.map.getZoom();
1015
+ 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));
1016
+ return [px, py - (p.mode === "screen" ? p.liftPixels : 0)];
1017
+ };
1018
+ return invertPinProjection(project, pin.position, attachment, this.map.getProjection()?.type === "globe" ? 90 : void 0);
1019
+ }
1020
+ onRemove(_map, context) {
1021
+ this.removed = true;
1022
+ this.version++;
1023
+ const gl = context;
1024
+ this.onRemoveListeners();
1025
+ if (this.pickTexture) gl.deleteTexture(this.pickTexture);
1026
+ if (this.pickFramebuffer) gl.deleteFramebuffer(this.pickFramebuffer);
1027
+ if (this.vao) gl.deleteVertexArray(this.vao);
1028
+ if (this.buffer) gl.deleteBuffer(this.buffer);
1029
+ if (this.program) gl.deleteProgram(this.program);
1030
+ }
1031
+ };
1032
+ //#endregion
3
1033
  //#region src/mercator.ts
4
1034
  /**
5
1035
  * Web Mercator world circumference in meters (2π × 6378137). The single source
@@ -255,6 +1285,25 @@ function emptyFC() {
255
1285
  };
256
1286
  }
257
1287
  /**
1288
+ * Builds the pin-status emitter passed to each `PinLayer`. Bound only to the
1289
+ * `listeners` set (never to the adapter itself, via `this`), so a `PinLayer`
1290
+ * holding onto this callback for its lifetime does not transitively retain
1291
+ * the whole `MapLibreAdapter` object graph (map, all layer handles, etc.).
1292
+ */
1293
+ function createPinStatusDispatcher(listeners) {
1294
+ return (event) => {
1295
+ queueMicrotask(() => {
1296
+ const errors = [];
1297
+ for (const listener of listeners) try {
1298
+ listener(event);
1299
+ } catch (error) {
1300
+ errors.push(error);
1301
+ }
1302
+ if (errors.length) throw new AggregateError(errors, "Pin presentation status listener errors");
1303
+ });
1304
+ };
1305
+ }
1306
+ /**
258
1307
  * Build the paint expression for a circle layer with per-feature
259
1308
  * `properties.style` overlay (ADR-0006). Each property prefers a value
260
1309
  * pulled from `properties.style.<field>`, falling back to the layer default.
@@ -825,12 +1874,36 @@ var MapLibreAdapter = class extends BaseMapAdapter {
825
1874
  }
826
1875
  destroyLayer(handle) {
827
1876
  this.clearGroupBundles(handle);
1877
+ handle.pinResources?.dispose();
828
1878
  for (const mlId of handle.mlLayerIds) if (this.map.getLayer(mlId)) this.map.removeLayer(mlId);
829
1879
  if (this.map.getSource(handle.sourceId)) this.map.removeSource(handle.sourceId);
830
1880
  }
831
- applyFeatureGroups(layerId, groups) {
1881
+ applyFeatureGroups(layerId, input) {
1882
+ const groups = [];
1883
+ for (const group of input) if (group.pin && this.getPinPresentationCapability().status !== "unsupported") {
1884
+ const previous = groups[groups.length - 1];
1885
+ if (previous?.pinBatch) previous.pinBatch.push(group.pin);
1886
+ else groups.push({
1887
+ id: `pin-run:${group.id}`,
1888
+ features: [],
1889
+ iconResources: [],
1890
+ pinBatch: [group.pin]
1891
+ });
1892
+ } else groups.push({
1893
+ ...group,
1894
+ id: `ordinary:${group.id}`
1895
+ });
832
1896
  const handle = this.layers.get(layerId);
833
1897
  if (!handle) return;
1898
+ const allPins = groups.flatMap((group) => group.pinBatch ?? []);
1899
+ if (allPins.length) {
1900
+ handle.pinResources ??= new PinResources(this.map);
1901
+ const previousBatch = handle.lastPinBatch;
1902
+ if (!(previousBatch !== void 0 && previousBatch.length === allPins.length && allPins.every((pin, index) => pin === previousBatch[index]))) {
1903
+ handle.pinResources.setRoster(allPins);
1904
+ handle.lastPinBatch = allPins;
1905
+ }
1906
+ }
834
1907
  const desiredGroupIds = new Set(groups.map((group) => group.id));
835
1908
  const previousGroupOrder = handle.groupOrder.filter((groupId) => desiredGroupIds.has(groupId));
836
1909
  const bundlesNeedingOrder = /* @__PURE__ */ new Set();
@@ -846,6 +1919,26 @@ var MapLibreAdapter = class extends BaseMapAdapter {
846
1919
  bundle = this.createGroupBundle(layerId, handle);
847
1920
  handle.groupBundles.set(group.id, bundle);
848
1921
  }
1922
+ if (group.pinBatch) {
1923
+ if (!bundle.pinLayer) {
1924
+ const custom = new PinLayer(`${bundle.sourceId}-pins`, handle.pinResources, this.emitPinStatus);
1925
+ this.map.addLayer(custom, handle.mlLayerIds[0]);
1926
+ bundle.pinLayer = custom;
1927
+ bundlesNeedingOrder.add(group.id);
1928
+ }
1929
+ const previous = bundle.pinSignature;
1930
+ let changedIndices;
1931
+ if (previous?.length === group.pinBatch.length) {
1932
+ changedIndices = [];
1933
+ for (let i = 0; i < group.pinBatch.length; i++) if (group.pinBatch[i] !== previous[i]) changedIndices.push(i);
1934
+ }
1935
+ if (!previous || bundle.pinResourceRevision !== handle.pinResources?.revision || previous.length !== group.pinBatch.length || !!changedIndices?.length) {
1936
+ bundle.pinSignature = group.pinBatch;
1937
+ bundle.pinResourceRevision = handle.pinResources?.revision;
1938
+ bundle.pinLayer.setPins(group.pinBatch, changedIndices);
1939
+ }
1940
+ continue;
1941
+ }
849
1942
  let contentChanged;
850
1943
  if (!isNewBundle && bundle.revision !== void 0 && group.revision !== void 0) {
851
1944
  contentChanged = bundle.revision !== group.revision;
@@ -864,6 +1957,11 @@ var MapLibreAdapter = class extends BaseMapAdapter {
864
1957
  if (this.reconcileGroupPaintSurfaces(handle, bundle, group.features)) bundlesNeedingOrder.add(group.id);
865
1958
  if (contentChanged || resourcesChanged) this.updateGroupBundleSource(bundle);
866
1959
  }
1960
+ if (!allPins.length) {
1961
+ handle.pinResources?.dispose();
1962
+ handle.pinResources = void 0;
1963
+ handle.lastPinBatch = void 0;
1964
+ }
867
1965
  handle.groupOrder = groups.map((group) => group.id);
868
1966
  this.orderGroupBundles(handle, previousGroupOrder, handle.groupOrder, bundlesNeedingOrder);
869
1967
  }
@@ -888,6 +1986,7 @@ var MapLibreAdapter = class extends BaseMapAdapter {
888
1986
  };
889
1987
  }
890
1988
  destroyGroupBundle(bundle) {
1989
+ if (bundle.pinLayer && this.map.getLayer(bundle.pinLayer.id)) this.map.removeLayer(bundle.pinLayer.id);
891
1990
  for (const overlay of bundle.overlays.values()) {
892
1991
  if (this.map.getLayer(overlay.layerId)) this.map.removeLayer(overlay.layerId);
893
1992
  if (this.map.getSource(overlay.sourceId)) this.map.removeSource(overlay.sourceId);
@@ -954,6 +2053,9 @@ var MapLibreAdapter = class extends BaseMapAdapter {
954
2053
  for (const bundle of handle.groupBundles.values()) this.destroyGroupBundle(bundle);
955
2054
  handle.groupBundles.clear();
956
2055
  handle.groupOrder = [];
2056
+ handle.pinResources?.dispose();
2057
+ handle.pinResources = void 0;
2058
+ handle.lastPinBatch = void 0;
957
2059
  }
958
2060
  requiredIconIds(group) {
959
2061
  const referenced = new Set(group.features.flatMap((feature) => {
@@ -1099,13 +2201,14 @@ var MapLibreAdapter = class extends BaseMapAdapter {
1099
2201
  const bundle = handle.groupBundles.get(groupId);
1100
2202
  if (!bundle) continue;
1101
2203
  if (forcedGroupIds.has(groupId) || stableGroupIds !== null && !stableGroupIds.has(groupId)) {
2204
+ if (bundle.pinLayer && this.map.getLayer(bundle.pinLayer.id)) this.map.moveLayer(bundle.pinLayer.id, beforeId);
1102
2205
  for (const overlay of bundle.overlays.values()) if (this.map.getLayer(overlay.layerId)) this.map.moveLayer(overlay.layerId, beforeId);
1103
2206
  for (const key of bundle.paintOrder) {
1104
2207
  const surface = bundle.paintSurfaces.get(key);
1105
2208
  if (surface && this.map.getLayer(surface.layerId)) this.map.moveLayer(surface.layerId, beforeId);
1106
2209
  }
1107
2210
  }
1108
- beforeId = firstBundleLayerId(bundle) ?? beforeId;
2211
+ beforeId = bundle.pinLayer?.id ?? firstBundleLayerId(bundle) ?? beforeId;
1109
2212
  }
1110
2213
  }
1111
2214
  nativeLayerIds(handle) {
@@ -1200,10 +2303,11 @@ var MapLibreAdapter = class extends BaseMapAdapter {
1200
2303
  for (const event of this.viewChangeEvents()) this.map.off(event, token.fn);
1201
2304
  }
1202
2305
  viewChangeEvents() {
1203
- return this.viewChangeMode === "settle" ? ["moveend"] : [
2306
+ return this.viewChangeMode === "settle" ? ["moveend", "projectiontransition"] : [
1204
2307
  "move",
1205
2308
  "resize",
1206
- "moveend"
2309
+ "moveend",
2310
+ "projectiontransition"
1207
2311
  ];
1208
2312
  }
1209
2313
  createEditPointerDriver() {
@@ -1464,7 +2568,7 @@ var MapLibreAdapter = class extends BaseMapAdapter {
1464
2568
  id: feature.id,
1465
2569
  geometry: feature.geometry,
1466
2570
  properties: feature.properties
1467
- })), this.featureCache.get(layerId) ?? []);
2571
+ })), (this.featureCache.get(layerId) ?? []).filter((f) => !f.properties?.pinPresentation));
1468
2572
  const size = this.getViewportSize();
1469
2573
  const candidate = evaluatePickCandidates(relevantFeatures.map(({ feature, nativeHit, visualOrder }) => {
1470
2574
  const iconId = feature.properties?.iconId;
@@ -1495,6 +2599,17 @@ var MapLibreAdapter = class extends BaseMapAdapter {
1495
2599
  },
1496
2600
  bearing: this.getBearing()
1497
2601
  });
2602
+ const pinHit = this.pinHit(layerId, [x, y], tol);
2603
+ if (pinHit && (!candidate || pinHit.order >= (this.getFeatureGroupOrder(layerId, candidate.feature.id) ?? -1))) {
2604
+ handler({
2605
+ id: pinHit.id,
2606
+ feature: pinHit.feature,
2607
+ pixel: [x, y],
2608
+ coordinate: [e.lngLat.lng, e.lngLat.lat],
2609
+ originalEvent
2610
+ });
2611
+ return;
2612
+ }
1498
2613
  if (!candidate) return;
1499
2614
  const cmId = candidate.graphicId;
1500
2615
  if (cmId == null) return;
@@ -1511,6 +2626,74 @@ var MapLibreAdapter = class extends BaseMapAdapter {
1511
2626
  return () => this.map.off("click", onMlClick);
1512
2627
  });
1513
2628
  }
2629
+ pinStatusListeners = /* @__PURE__ */ new Set();
2630
+ emitPinStatus = createPinStatusDispatcher(this.pinStatusListeners);
2631
+ onPinPresentationStatusChange(handler) {
2632
+ this.pinStatusListeners.add(handler);
2633
+ return () => this.pinStatusListeners.delete(handler);
2634
+ }
2635
+ /**
2636
+ * Pin layers across this adapter's group bundles.
2637
+ * - With no `handle`: every layer's bundles, in bundle-map (creation)
2638
+ * order — fine for id-based lookups where only "does one match" matters.
2639
+ * - With `handle`: only that layer's bundles, walked topmost-first (reverse
2640
+ * paint order) — required for hit-testing, where the first hit must be
2641
+ * the visually topmost pin.
2642
+ */
2643
+ *pinLayers(handle) {
2644
+ if (handle) {
2645
+ for (const groupId of [...handle.groupOrder].reverse()) {
2646
+ const pinLayer = handle.groupBundles.get(groupId)?.pinLayer;
2647
+ if (pinLayer) yield pinLayer;
2648
+ }
2649
+ return;
2650
+ }
2651
+ for (const h of this.layers.values()) for (const bundle of h.groupBundles.values()) if (bundle.pinLayer) yield bundle.pinLayer;
2652
+ }
2653
+ /** First non-null/undefined result of `fn` across all pin layers, else null. */
2654
+ findPinLayer(fn) {
2655
+ for (const pinLayer of this.pinLayers()) {
2656
+ const result = fn(pinLayer);
2657
+ if (result != null) return result;
2658
+ }
2659
+ return null;
2660
+ }
2661
+ getPinPresentationCapability(graphicId) {
2662
+ if (!(maplibregl.getVersion?.() ?? "").startsWith("6.")) return {
2663
+ status: "unsupported",
2664
+ reason: "Pins require MapLibre 6"
2665
+ };
2666
+ if (graphicId) {
2667
+ const status = this.findPinLayer((pinLayer) => pinLayer.has(graphicId) ? pinLayer.getStatus(graphicId) : null);
2668
+ if (status) return status;
2669
+ }
2670
+ return { status: "supported" };
2671
+ }
2672
+ getPinFootprint(id) {
2673
+ return this.findPinLayer((pinLayer) => pinLayer.footprint(id));
2674
+ }
2675
+ movePinFromPixel(id, attachment) {
2676
+ return this.findPinLayer((pinLayer) => pinLayer.moveFromPixel(id, attachment));
2677
+ }
2678
+ pinHit(layerId, pixel, tolerance) {
2679
+ const handle = this.layers.get(layerId);
2680
+ if (!handle) return null;
2681
+ for (const pinLayer of this.pinLayers(handle)) {
2682
+ const id = pinLayer.pick(pixel, tolerance);
2683
+ if (!id) continue;
2684
+ const feature = this.featureCache.get(layerId)?.find((f) => f.properties?.graphicId === id);
2685
+ if (feature) return {
2686
+ id,
2687
+ feature,
2688
+ order: this.getFeatureGroupOrder(layerId, feature.id) ?? 0
2689
+ };
2690
+ }
2691
+ return null;
2692
+ }
2693
+ pickPin(id, pixel, tolerance) {
2694
+ for (const [layerId] of this.layers) if (this.pinHit(layerId, pixel, tolerance)?.id === id) return true;
2695
+ return false;
2696
+ }
1514
2697
  setGlobeMode(enabled) {
1515
2698
  if (enabled) this.map.setProjection({ type: "globe" });
1516
2699
  else this.map.setProjection({ type: "mercator" });