@orbat-mapper/tactical-draw-adapter-maplibre 0.3.21 → 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,1145 @@
1
- import { project, unproject } from "@orbat-mapper/control-measures";
2
- 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 } from "@orbat-mapper/tactical-draw";
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
1033
+ //#region src/mercator.ts
1034
+ /**
1035
+ * Web Mercator world circumference in meters (2π × 6378137). The single source
1036
+ * of truth for this package's meters↔pixels math: `getResolution`, the ground
1037
+ * text ramp, and the ground icon ramp all derive from it, so they cannot drift
1038
+ * apart.
1039
+ */
1040
+ const WEB_MERCATOR_CIRCUMFERENCE = 40075016.686;
1041
+ /**
1042
+ * Pixels per ground meter at zoom 0. MapLibre's world is 512 px wide at zoom 0
1043
+ * and doubles each zoom level, so the size at any zoom `z` is this × 2^z —
1044
+ * which is exactly what an `["exponential", 2]` zoom interpolation computes.
1045
+ */
1046
+ const PIXELS_PER_METER_AT_ZOOM_0 = 512 / WEB_MERCATOR_CIRCUMFERENCE;
1047
+ //#endregion
1048
+ //#region src/text-sizing.ts
1049
+ /** `text-halo-blur` as a fraction of the halo's outward depth (MapLibre only). */
1050
+ const TEXT_HALO_BLUR_RATIO = .25;
1051
+ /**
1052
+ * Exact clamped ground scaling, with only the two actual clamp boundaries as
1053
+ * stops. MapLibre's glyph renderer interpolates between stops covering the
1054
+ * tile's whole zoom interval, so inserting fractional stops into a composite
1055
+ * expression does NOT fix its rendering near a clamp. A graphic's own camera
1056
+ * expression has no interior kink: base-2 interpolation is exact even when
1057
+ * the covering interval spans many zoom levels. No zoom listener is needed.
1058
+ *
1059
+ * Shared by the ground text ramp and the ground icon ramp — both scale a
1060
+ * meters-derived size with zoom and pin it at two per-feature bounds.
1061
+ */
1062
+ function groundRamp(pixelsAtZoom0, min, max) {
1063
+ if (min >= max) return max;
1064
+ return [
1065
+ "interpolate",
1066
+ ["exponential", 2],
1067
+ ["zoom"],
1068
+ Math.log2(min / pixelsAtZoom0),
1069
+ min,
1070
+ Math.log2(max / pixelsAtZoom0),
1071
+ max
1072
+ ];
1073
+ }
1074
+ /** Each grouped text surface paints exactly one feature (ADR-0039). */
1075
+ function buildTextSizing(text, props) {
1076
+ const meters = props?.textSizeMeters;
1077
+ const pixels = props?.textSizePixels;
1078
+ const maximum = props?.textMaxSizePixels;
1079
+ const fixedHalo = resolveTextHaloWidth(pixels ?? 14);
1080
+ let size = pixels ?? 14;
1081
+ let scaledHalo = fixedHalo;
1082
+ let scaledBlur = fixedHalo * TEXT_HALO_BLUR_RATIO;
1083
+ let opacity = 1;
1084
+ if (pixels === void 0 && typeof meters === "number" && meters > 0) {
1085
+ const pixelsAtZoom0 = meters * PIXELS_PER_METER_AT_ZOOM_0;
1086
+ const min = maximum !== void 0 ? pixelsAtZoom0 : Math.max(pixelsAtZoom0, text.textSizeMinPixels ?? DEFAULT_TEXT_SIZE_MIN_PIXELS);
1087
+ const max = maximum ?? text.textSizeMaxPixels ?? DEFAULT_TEXT_SIZE_MAX_PIXELS;
1088
+ const haloPixelsAtZoom0 = pixelsAtZoom0 / 8;
1089
+ const minHalo = resolveTextHaloWidth(min);
1090
+ const maxHalo = resolveTextHaloWidth(max);
1091
+ size = groundRamp(pixelsAtZoom0, min, max);
1092
+ scaledHalo = groundRamp(haloPixelsAtZoom0, minHalo, maxHalo);
1093
+ scaledBlur = groundRamp(haloPixelsAtZoom0 * TEXT_HALO_BLUR_RATIO, minHalo * TEXT_HALO_BLUR_RATIO, maxHalo * TEXT_HALO_BLUR_RATIO);
1094
+ if (maximum !== void 0) {
1095
+ const hideZoom = Math.log2(maximum / pixelsAtZoom0);
1096
+ opacity = maximum <= 0 ? 0 : [
1097
+ "step",
1098
+ ["zoom"],
1099
+ 1,
1100
+ hideZoom + Number.EPSILON * Math.max(1, Math.abs(hideZoom)),
1101
+ 0
1102
+ ];
1103
+ }
1104
+ }
1105
+ if (pixels === void 0 && meters === 0) {
1106
+ size = maximum !== void 0 ? 0 : Math.min(text.textSizeMaxPixels ?? DEFAULT_TEXT_SIZE_MAX_PIXELS, Math.max(0, text.textSizeMinPixels ?? DEFAULT_TEXT_SIZE_MIN_PIXELS));
1107
+ scaledHalo = resolveTextHaloWidth(size);
1108
+ scaledBlur = scaledHalo * TEXT_HALO_BLUR_RATIO;
1109
+ }
1110
+ const haloWidth = props?.textHalo ? scaledHalo : text.strokeColor ? text.strokeWidth ?? 1 : props?.textBackground ? 3 : 0;
1111
+ return {
1112
+ size,
1113
+ haloWidth,
1114
+ haloBlur: props?.textHalo ? scaledBlur : 0,
1115
+ opacity
1116
+ };
1117
+ }
1118
+ //#endregion
1119
+ //#region src/icon-sizing.ts
1120
+ /** Stand-in for "no upper bound", matching the unclamped icon default. */
1121
+ const UNBOUNDED_PIXELS = 1e9;
1122
+ /**
1123
+ * Exact per-feature `icon-size` for a ground-sized point symbol, the icon
1124
+ * counterpart of `buildTextSizing` (#522). `icon-size` is a *scale factor* —
1125
+ * rendered pixels ÷ the icon image's intrinsic octagon size — so the whole
1126
+ * ramp is divided by `iconOctagonSize`; dividing every stop by the same
1127
+ * constant leaves the clamp zooms unchanged.
1128
+ *
1129
+ * Returns `undefined` for a feature that is not ground-sized, so the caller
1130
+ * keeps the shared `iconScale` snapshot expression.
1131
+ */
1132
+ function buildIconSizing(props) {
1133
+ const meters = props?.iconSizeMeters;
1134
+ if (typeof meters !== "number" || !(meters > 0)) return void 0;
1135
+ const octagon = typeof props?.iconOctagonSize === "number" && props.iconOctagonSize > 0 ? props.iconOctagonSize : 1;
1136
+ const pixelsAtZoom0 = meters * PIXELS_PER_METER_AT_ZOOM_0;
1137
+ const min = Math.max(props?.iconMinPixels ?? 0, pixelsAtZoom0);
1138
+ const max = props?.iconMaxPixels ?? UNBOUNDED_PIXELS;
1139
+ if (max <= min) return min / octagon;
1140
+ return groundRamp(pixelsAtZoom0 / octagon, min / octagon, max / octagon);
1141
+ }
1142
+ //#endregion
3
1143
  //#region src/ml-adapter.ts
4
1144
  /** Feature property key for the resolved hatch-pattern image id (see `promoteFeature`). */
5
1145
  const FILL_PATTERN_IMAGE_ID_PROPERTY = "_fillPatternImageId";
@@ -8,6 +1148,27 @@ const DEFAULT_LINE_WIDTH = 2;
8
1148
  function isHatchPattern(value) {
9
1149
  return HATCH_PATTERNS.includes(value);
10
1150
  }
1151
+ /**
1152
+ * The sizing-relevant inputs for a surface, joined into a comparable string.
1153
+ * `undefined` for channels whose size never varies per feature, which skips
1154
+ * the comparison entirely.
1155
+ */
1156
+ function sizingSignature(channel, props, style) {
1157
+ if (channel === "icon") return `${props?.iconSizeMeters}|${props?.iconOctagonSize}|${props?.iconMinPixels}|${props?.iconMaxPixels}`;
1158
+ if (channel !== "text") return void 0;
1159
+ const text = style?.text;
1160
+ return [
1161
+ props?.textSizeMeters,
1162
+ props?.textSizePixels,
1163
+ props?.textMaxSizePixels,
1164
+ props?.textHalo,
1165
+ props?.textBackground,
1166
+ text?.textSizeMinPixels,
1167
+ text?.textSizeMaxPixels,
1168
+ text?.strokeColor,
1169
+ text?.strokeWidth
1170
+ ].join("|");
1171
+ }
11
1172
  function coordinatesEqual(a, b) {
12
1173
  for (let index = 0; index < 4; index++) {
13
1174
  const pa = a[index];
@@ -124,6 +1285,25 @@ function emptyFC() {
124
1285
  };
125
1286
  }
126
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
+ /**
127
1307
  * Build the paint expression for a circle layer with per-feature
128
1308
  * `properties.style` overlay (ADR-0006). Each property prefers a value
129
1309
  * pulled from `properties.style.<field>`, falling back to the layer default.
@@ -180,7 +1360,7 @@ function buildCirclePointFilter(style) {
180
1360
  ["!has", "iconId"]
181
1361
  ];
182
1362
  }
183
- function buildIconLayer(id, source) {
1363
+ function buildIconLayer(id, source, iconSize = buildIconSizeExpression()) {
184
1364
  return {
185
1365
  id,
186
1366
  type: "symbol",
@@ -196,7 +1376,7 @@ function buildIconLayer(id, source) {
196
1376
  ],
197
1377
  layout: {
198
1378
  "icon-image": ["get", "iconId"],
199
- "icon-size": buildIconSizeExpression(),
1379
+ "icon-size": iconSize,
200
1380
  "icon-rotate": [
201
1381
  "*",
202
1382
  [
@@ -227,120 +1407,8 @@ function centeredSvg(resource) {
227
1407
  const height = halfHeight * 2;
228
1408
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><g transform="translate(${halfWidth - resource.anchor[0]} ${halfHeight - resource.anchor[1]})">${resource.svg}</g></svg>`;
229
1409
  }
230
- /** Web Mercator world circumference in meters (2π × 6378137). */
231
- const WEB_MERCATOR_CIRCUMFERENCE = 40075016.686;
232
1410
  /**
233
- * Per-feature text size (ADR-0028): a ground-anchored label carries
234
- * `textSizeMeters` and renders at `textSizeMeters / resolution`, clamped to
235
- * the layer's `textSizeMinPixels`/`textSizeMaxPixels` band; a screen-anchored
236
- * one carries `textSizePixels` and renders at that constant; neither renders
237
- * the 14px engine default. Layout properties only take zoom as an
238
- * `interpolate`/`step` input, so the ground conversion is evaluated at every
239
- * integer zoom stop (resolution = world circumference / 512·2^zoom pixels,
240
- * matching `getResolution`); the linear ramp between stops approximates the
241
- * exponential exactly closely enough at 1-zoom granularity.
242
- */
243
- function textSizeAtZoom(text, zoom) {
244
- const minPx = text.textSizeMinPixels ?? DEFAULT_TEXT_SIZE_MIN_PIXELS;
245
- const maxPx = text.textSizeMaxPixels ?? DEFAULT_TEXT_SIZE_MAX_PIXELS;
246
- const screenOrDefault = [
247
- "coalesce",
248
- ["get", "textSizePixels"],
249
- 14
250
- ];
251
- const groundPx = [
252
- "*",
253
- ["get", "textSizeMeters"],
254
- Math.pow(2, zoom + 9) / WEB_MERCATOR_CIRCUMFERENCE
255
- ];
256
- return [
257
- "case",
258
- ["has", "textSizeMeters"],
259
- [
260
- "case",
261
- ["has", "textMaxSizePixels"],
262
- [
263
- "min",
264
- ["get", "textMaxSizePixels"],
265
- groundPx
266
- ],
267
- [
268
- "min",
269
- maxPx,
270
- [
271
- "max",
272
- minPx,
273
- groundPx
274
- ]
275
- ]
276
- ],
277
- screenOrDefault
278
- ];
279
- }
280
- /**
281
- * Wrap a per-zoom expression builder in the top-level zoom `interpolate` every
282
- * zoom-driven property here needs. MapLibre only accepts `["zoom"]` as the
283
- * input of a top-level `step`/`interpolate`, never nested inside a `case`, so
284
- * the ramp must be outermost and the per-feature branching evaluated at each
285
- * stop — not the other way round.
286
- */
287
- function interpolateOverZoom(at) {
288
- return [
289
- "interpolate",
290
- ["linear"],
291
- ["zoom"],
292
- ...Array.from({ length: 25 }, (_, zoom) => [zoom, at(zoom)]).flat()
293
- ];
294
- }
295
- function buildTextSizeExpression(text) {
296
- return interpolateOverZoom((zoom) => textSizeAtZoom(text, zoom));
297
- }
298
- /**
299
- * Companion to `buildTextSizeExpression` (ADR-0037): drives `text-opacity` to
300
- * 0 once a ground-anchored Text graphic's derived on-screen size exceeds its
301
- * `textMaxSizePixels` threshold, 1 otherwise (including every feature without
302
- * a threshold, e.g. amplifier labels). Layout properties like `text-size`
303
- * cannot represent "hidden", so hiding is a paint-property concern; `step`
304
- * (rather than `interpolate`) is enough since opacity only ever needs to
305
- * flip at a stop, evaluated at the same per-zoom granularity as the size
306
- * expression.
307
- */
308
- const TEXT_OPACITY_EXPRESSION = (() => {
309
- const opacityAtZoom = (zoom) => {
310
- return [
311
- "case",
312
- [
313
- "all",
314
- ["has", "textSizeMeters"],
315
- ["has", "textMaxSizePixels"]
316
- ],
317
- [
318
- "case",
319
- [
320
- ">",
321
- [
322
- "*",
323
- ["get", "textSizeMeters"],
324
- Math.pow(2, zoom + 9) / WEB_MERCATOR_CIRCUMFERENCE
325
- ],
326
- ["get", "textMaxSizePixels"]
327
- ],
328
- 0,
329
- 1
330
- ],
331
- 1
332
- ];
333
- };
334
- const stops = Array.from({ length: 25 }, (_, zoom) => zoom);
335
- return [
336
- "step",
337
- ["zoom"],
338
- opacityAtZoom(0),
339
- ...stops.slice(1).flatMap((zoom) => [zoom, opacityAtZoom(zoom)])
340
- ];
341
- })();
342
- /**
343
- * Per-feature icon size, same zoom-stop scheme as `buildTextSizeExpression`:
1411
+ * Per-feature icon size, using integer zoom stops:
344
1412
  * a ground-sized point symbol carries `iconSizeMeters`/`iconOctagonSize` (and
345
1413
  * optional `iconMinPixels`/`iconMaxPixels`), so the meters→pixels clamp is
346
1414
  * evaluated inside the style layer at every integer zoom stop and the symbol
@@ -453,84 +1521,12 @@ function buildTextHaloColorExpression(text) {
453
1521
  ];
454
1522
  }
455
1523
  /**
456
- * Per-feature `text-halo-width` value, mirroring
457
- * `buildTextHaloColorExpression`'s precedence. MapLibre's `text-halo-width`
458
- * already extends outward from the glyph edge, matching
459
- * `resolveTextHaloWidth`'s own outward-depth semantics, so the `textHalo`
460
- * case uses its formula directly (unlike OpenLayers's straddling canvas
461
- * stroke, which must double it).
462
- *
463
- * The width is derived from `textSizeAtZoom` — the same size `text-size`
464
- * renders at — not from `textSizePixels` alone. A ground-anchored label
465
- * (ADR-0028) carries only `textSizeMeters`, so reading the pixel size directly
466
- * would fall back to a constant and pin the halo at one width while the glyphs
467
- * grew and shrank with zoom: far too heavy on small text, hairline on large.
468
- * OL and Leaflet already halo off their resolved size; this brings MapLibre in
469
- * line. The ratio stays well under MapLibre's SDF halo ceiling (~¼ em), so the
470
- * halo never clips into blocky corners.
471
- */
472
- function buildTextHaloWidthExpression(text) {
473
- const fallback = text.strokeColor ? text.strokeWidth ?? 1 : [
474
- "case",
475
- HAS_TEXT_BACKGROUND,
476
- 3,
477
- 0
478
- ];
479
- return interpolateOverZoom((zoom) => [
480
- "case",
481
- HAS_TEXT_HALO,
482
- haloWidthAtZoom(text, zoom),
483
- fallback
484
- ]);
485
- }
486
- /**
487
- * `text-halo-blur` as a fraction of the halo width. MapLibre's halo edge is
488
- * hard at blur 0, which reads harsher over a vector basemap than the basemap's
489
- * own labels — OpenFreeMap Liberty, the preview's default style, blurs nearly
490
- * every one of its halos. Scaled off the width rather than a flat pixel value
491
- * so the softness stays proportional as the halo grows with zoom.
492
- *
493
- * MapLibre-only: OpenLayers canvas strokes have no blur, and Leaflet's stacked
494
- * `text-shadow` halo would need a blur radius threaded through each shadow.
495
- * The three engines' halos therefore differ slightly at the edge.
496
- */
497
- const TEXT_HALO_BLUR_RATIO = .25;
498
- /** The halo's outward depth at one zoom stop; shared by the width and blur builders. */
499
- function haloWidthAtZoom(text, zoom) {
500
- return [
501
- "max",
502
- 1.5,
503
- [
504
- "/",
505
- textSizeAtZoom(text, zoom),
506
- 8
507
- ]
508
- ];
509
- }
510
- /**
511
- * Per-feature `text-halo-blur`, softening only a `textHalo` halo's edge.
512
- * Everything else stays at 0 so the `textBackground` backdrop and a
513
- * layer-level text stroke keep the crisp edge they render today.
514
- */
515
- function buildTextHaloBlurExpression(text) {
516
- return interpolateOverZoom((zoom) => [
517
- "case",
518
- HAS_TEXT_HALO,
519
- [
520
- "*",
521
- TEXT_HALO_BLUR_RATIO,
522
- haloWidthAtZoom(text, zoom)
523
- ],
524
- 0
525
- ]);
526
- }
527
- /**
528
1524
  * Build the layout and paint objects for a symbol (text) layer.
529
1525
  * Centralises the rotation convention so `createLayer` and `setLayerStyle`
530
1526
  * stay in sync. Rotation follows the OpenLayers convention: `π − r` radians
531
1527
  * (converted to degrees for MapLibre).
532
1528
  */
533
- function buildSymbolLayer(symbolId, sourceId, text) {
1529
+ function buildSymbolLayer(symbolId, sourceId, text, sizing = buildTextSizing(text, null)) {
534
1530
  const textRotate = text.labelProperty ? [
535
1531
  "-",
536
1532
  180,
@@ -611,7 +1607,7 @@ function buildSymbolLayer(symbolId, sourceId, text) {
611
1607
  ["literal", ["Open Sans Light"]],
612
1608
  ["literal", ["Open Sans Regular"]]
613
1609
  ] : ["Open Sans Regular"],
614
- "text-size": buildTextSizeExpression(text),
1610
+ "text-size": sizing.size,
615
1611
  "text-rotate": textRotate,
616
1612
  "text-anchor": textAnchor,
617
1613
  "text-justify": textJustify,
@@ -624,9 +1620,9 @@ function buildSymbolLayer(symbolId, sourceId, text) {
624
1620
  paint: {
625
1621
  "text-color": textColor,
626
1622
  "text-halo-color": buildTextHaloColorExpression(text),
627
- "text-halo-width": buildTextHaloWidthExpression(text),
628
- "text-halo-blur": buildTextHaloBlurExpression(text),
629
- "text-opacity": TEXT_OPACITY_EXPRESSION
1623
+ "text-halo-width": sizing.haloWidth,
1624
+ "text-halo-blur": sizing.haloBlur,
1625
+ "text-opacity": sizing.opacity
630
1626
  }
631
1627
  };
632
1628
  }
@@ -878,12 +1874,36 @@ var MapLibreAdapter = class extends BaseMapAdapter {
878
1874
  }
879
1875
  destroyLayer(handle) {
880
1876
  this.clearGroupBundles(handle);
1877
+ handle.pinResources?.dispose();
881
1878
  for (const mlId of handle.mlLayerIds) if (this.map.getLayer(mlId)) this.map.removeLayer(mlId);
882
1879
  if (this.map.getSource(handle.sourceId)) this.map.removeSource(handle.sourceId);
883
1880
  }
884
- 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
+ });
885
1896
  const handle = this.layers.get(layerId);
886
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
+ }
887
1907
  const desiredGroupIds = new Set(groups.map((group) => group.id));
888
1908
  const previousGroupOrder = handle.groupOrder.filter((groupId) => desiredGroupIds.has(groupId));
889
1909
  const bundlesNeedingOrder = /* @__PURE__ */ new Set();
@@ -899,6 +1919,26 @@ var MapLibreAdapter = class extends BaseMapAdapter {
899
1919
  bundle = this.createGroupBundle(layerId, handle);
900
1920
  handle.groupBundles.set(group.id, bundle);
901
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
+ }
902
1942
  let contentChanged;
903
1943
  if (!isNewBundle && bundle.revision !== void 0 && group.revision !== void 0) {
904
1944
  contentChanged = bundle.revision !== group.revision;
@@ -917,6 +1957,11 @@ var MapLibreAdapter = class extends BaseMapAdapter {
917
1957
  if (this.reconcileGroupPaintSurfaces(handle, bundle, group.features)) bundlesNeedingOrder.add(group.id);
918
1958
  if (contentChanged || resourcesChanged) this.updateGroupBundleSource(bundle);
919
1959
  }
1960
+ if (!allPins.length) {
1961
+ handle.pinResources?.dispose();
1962
+ handle.pinResources = void 0;
1963
+ handle.lastPinBatch = void 0;
1964
+ }
920
1965
  handle.groupOrder = groups.map((group) => group.id);
921
1966
  this.orderGroupBundles(handle, previousGroupOrder, handle.groupOrder, bundlesNeedingOrder);
922
1967
  }
@@ -941,6 +1986,7 @@ var MapLibreAdapter = class extends BaseMapAdapter {
941
1986
  };
942
1987
  }
943
1988
  destroyGroupBundle(bundle) {
1989
+ if (bundle.pinLayer && this.map.getLayer(bundle.pinLayer.id)) this.map.removeLayer(bundle.pinLayer.id);
944
1990
  for (const overlay of bundle.overlays.values()) {
945
1991
  if (this.map.getLayer(overlay.layerId)) this.map.removeLayer(overlay.layerId);
946
1992
  if (this.map.getSource(overlay.sourceId)) this.map.removeSource(overlay.sourceId);
@@ -1007,6 +2053,9 @@ var MapLibreAdapter = class extends BaseMapAdapter {
1007
2053
  for (const bundle of handle.groupBundles.values()) this.destroyGroupBundle(bundle);
1008
2054
  handle.groupBundles.clear();
1009
2055
  handle.groupOrder = [];
2056
+ handle.pinResources?.dispose();
2057
+ handle.pinResources = void 0;
2058
+ handle.lastPinBatch = void 0;
1010
2059
  }
1011
2060
  requiredIconIds(group) {
1012
2061
  const referenced = new Set(group.features.flatMap((feature) => {
@@ -1019,7 +2068,8 @@ var MapLibreAdapter = class extends BaseMapAdapter {
1019
2068
  const desired = features.flatMap((feature) => featurePaintChannels(feature, handle.style).map((channel) => ({
1020
2069
  key: `${typeof feature.id}:${JSON.stringify(feature.id)}:${channel}`,
1021
2070
  featureId: feature.id,
1022
- channel
2071
+ channel,
2072
+ feature
1023
2073
  })));
1024
2074
  const desiredKeys = new Set(desired.map(({ key }) => key));
1025
2075
  let structureChanged = false;
@@ -1029,11 +2079,17 @@ var MapLibreAdapter = class extends BaseMapAdapter {
1029
2079
  bundle.paintSurfaces.delete(key);
1030
2080
  structureChanged = true;
1031
2081
  }
1032
- for (const { key, featureId, channel } of desired) {
1033
- if (bundle.paintSurfaces.has(key)) continue;
2082
+ for (const { key, featureId, channel, feature } of desired) {
2083
+ const existing = bundle.paintSurfaces.get(key);
2084
+ const sizingKey = sizingSignature(channel, feature.properties, handle.style);
2085
+ if (existing) {
2086
+ if (sizingKey !== void 0 && sizingKey !== existing.sizingKey) this.updateSurfaceSizing(existing, channel, feature.properties, handle.style, sizingKey);
2087
+ continue;
2088
+ }
1034
2089
  const layerId = `${bundle.sourceId}-surface-${++bundle.nextPaintSurfaceId}-${channel}`;
2090
+ const textSizing = channel === "text" ? buildTextSizing(handle.style.text, feature.properties) : void 0;
1035
2091
  const layer = {
1036
- ...this.buildGroupLayer(layerId, bundle.sourceId, channel, handle.style),
2092
+ ...this.buildGroupLayer(layerId, bundle.sourceId, channel, handle.style, textSizing, channel === "icon" ? buildIconSizing(feature.properties) : void 0),
1037
2093
  filter: [
1038
2094
  "==",
1039
2095
  "_fid",
@@ -1044,7 +2100,9 @@ var MapLibreAdapter = class extends BaseMapAdapter {
1044
2100
  bundle.paintSurfaces.set(key, {
1045
2101
  layerId,
1046
2102
  channel,
1047
- featureId
2103
+ featureId,
2104
+ textSizing,
2105
+ sizingKey
1048
2106
  });
1049
2107
  structureChanged = true;
1050
2108
  }
@@ -1053,7 +2111,24 @@ var MapLibreAdapter = class extends BaseMapAdapter {
1053
2111
  bundle.paintOrder = desiredOrder;
1054
2112
  return structureChanged || orderChanged;
1055
2113
  }
1056
- buildGroupLayer(id, sourceId, channel, style) {
2114
+ updateSurfaceSizing(surface, channel, props, style, sizingKey) {
2115
+ surface.sizingKey = sizingKey;
2116
+ if (channel === "icon") {
2117
+ const size = buildIconSizing(props);
2118
+ if (size !== void 0) this.map.setLayoutProperty(surface.layerId, "icon-size", size);
2119
+ return;
2120
+ }
2121
+ const next = buildTextSizing(style.text, props);
2122
+ const previous = surface.textSizing;
2123
+ if (JSON.stringify(previous?.size) !== JSON.stringify(next.size)) this.map.setLayoutProperty(surface.layerId, "text-size", next.size);
2124
+ for (const [key, property] of [
2125
+ ["haloWidth", "text-halo-width"],
2126
+ ["haloBlur", "text-halo-blur"],
2127
+ ["opacity", "text-opacity"]
2128
+ ]) if (JSON.stringify(previous?.[key]) !== JSON.stringify(next[key])) this.map.setPaintProperty(surface.layerId, property, next[key]);
2129
+ surface.textSizing = next;
2130
+ }
2131
+ buildGroupLayer(id, sourceId, channel, style, textSizing, iconSizing) {
1057
2132
  switch (channel) {
1058
2133
  case "fill": return {
1059
2134
  id,
@@ -1099,10 +2174,10 @@ var MapLibreAdapter = class extends BaseMapAdapter {
1099
2174
  filter: buildCirclePointFilter(style),
1100
2175
  paint: buildCirclePaint(style)
1101
2176
  };
1102
- case "icon": return buildIconLayer(id, sourceId);
2177
+ case "icon": return buildIconLayer(id, sourceId, iconSizing);
1103
2178
  case "text":
1104
2179
  if (!style?.text) throw new Error("Cannot create a MapLibre text channel without text style");
1105
- return buildSymbolLayer(id, sourceId, style.text);
2180
+ return buildSymbolLayer(id, sourceId, style.text, textSizing);
1106
2181
  }
1107
2182
  }
1108
2183
  updateGroupBundleSource(bundle) {
@@ -1126,13 +2201,14 @@ var MapLibreAdapter = class extends BaseMapAdapter {
1126
2201
  const bundle = handle.groupBundles.get(groupId);
1127
2202
  if (!bundle) continue;
1128
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);
1129
2205
  for (const overlay of bundle.overlays.values()) if (this.map.getLayer(overlay.layerId)) this.map.moveLayer(overlay.layerId, beforeId);
1130
2206
  for (const key of bundle.paintOrder) {
1131
2207
  const surface = bundle.paintSurfaces.get(key);
1132
2208
  if (surface && this.map.getLayer(surface.layerId)) this.map.moveLayer(surface.layerId, beforeId);
1133
2209
  }
1134
2210
  }
1135
- beforeId = firstBundleLayerId(bundle) ?? beforeId;
2211
+ beforeId = bundle.pinLayer?.id ?? firstBundleLayerId(bundle) ?? beforeId;
1136
2212
  }
1137
2213
  }
1138
2214
  nativeLayerIds(handle) {
@@ -1227,10 +2303,11 @@ var MapLibreAdapter = class extends BaseMapAdapter {
1227
2303
  for (const event of this.viewChangeEvents()) this.map.off(event, token.fn);
1228
2304
  }
1229
2305
  viewChangeEvents() {
1230
- return this.viewChangeMode === "settle" ? ["moveend"] : [
2306
+ return this.viewChangeMode === "settle" ? ["moveend", "projectiontransition"] : [
1231
2307
  "move",
1232
2308
  "resize",
1233
- "moveend"
2309
+ "moveend",
2310
+ "projectiontransition"
1234
2311
  ];
1235
2312
  }
1236
2313
  createEditPointerDriver() {
@@ -1419,9 +2496,10 @@ var MapLibreAdapter = class extends BaseMapAdapter {
1419
2496
  if (styleUpdate.text.text || styleUpdate.text.labelProperty) this.map.setLayoutProperty(layerId, "text-field", buildTextFieldExpression(resolvedStyle.text));
1420
2497
  if (styleUpdate.text.fillColor) this.map.setPaintProperty(layerId, "text-color", resolvedStyle.text.fillColor);
1421
2498
  if (styleUpdate.text.strokeColor) this.map.setPaintProperty(layerId, "text-halo-color", buildTextHaloColorExpression(resolvedStyle.text));
1422
- if (styleUpdate.text.strokeWidth !== void 0) {
1423
- this.map.setPaintProperty(layerId, "text-halo-width", buildTextHaloWidthExpression(resolvedStyle.text));
1424
- this.map.setPaintProperty(layerId, "text-halo-blur", buildTextHaloBlurExpression(resolvedStyle.text));
2499
+ if (featureId === void 0) {
2500
+ const sizing = buildTextSizing(resolvedStyle.text, null);
2501
+ this.map.setPaintProperty(layerId, "text-halo-width", sizing.haloWidth);
2502
+ this.map.setPaintProperty(layerId, "text-halo-blur", sizing.haloBlur);
1425
2503
  }
1426
2504
  break;
1427
2505
  }
@@ -1490,7 +2568,7 @@ var MapLibreAdapter = class extends BaseMapAdapter {
1490
2568
  id: feature.id,
1491
2569
  geometry: feature.geometry,
1492
2570
  properties: feature.properties
1493
- })), this.featureCache.get(layerId) ?? []);
2571
+ })), (this.featureCache.get(layerId) ?? []).filter((f) => !f.properties?.pinPresentation));
1494
2572
  const size = this.getViewportSize();
1495
2573
  const candidate = evaluatePickCandidates(relevantFeatures.map(({ feature, nativeHit, visualOrder }) => {
1496
2574
  const iconId = feature.properties?.iconId;
@@ -1521,6 +2599,17 @@ var MapLibreAdapter = class extends BaseMapAdapter {
1521
2599
  },
1522
2600
  bearing: this.getBearing()
1523
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
+ }
1524
2613
  if (!candidate) return;
1525
2614
  const cmId = candidate.graphicId;
1526
2615
  if (cmId == null) return;
@@ -1537,6 +2626,74 @@ var MapLibreAdapter = class extends BaseMapAdapter {
1537
2626
  return () => this.map.off("click", onMlClick);
1538
2627
  });
1539
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
+ }
1540
2697
  setGlobeMode(enabled) {
1541
2698
  if (enabled) this.map.setProjection({ type: "globe" });
1542
2699
  else this.map.setProjection({ type: "mercator" });