@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.d.mts +154 -2
- package/dist/index.mjs +1367 -210
- package/package.json +3 -3
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,1145 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
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":
|
|
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
|
|
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":
|
|
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":
|
|
628
|
-
"text-halo-blur":
|
|
629
|
-
"text-opacity":
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
1423
|
-
|
|
1424
|
-
this.map.setPaintProperty(layerId, "text-halo-
|
|
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" });
|