@sakuzu/maplibre-gl-draw 2.1.1 → 2.1.3

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/CHANGELOG.md CHANGED
@@ -6,13 +6,63 @@ the project follows semantic versioning.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.1.3] - 2026-10-04
10
+
11
+ Two fixes: the snap indicator that stayed after its input, and the
12
+ selection frame of a point on the globe and on the terrain at a high
13
+ pitch.
14
+
15
+ ### Fixed in 2.1.3
16
+
17
+ - The snap indicator stayed after the input that made it: after leaving
18
+ the drawing mode, turning snapping off, Escape, or a pan, it went on
19
+ showing the last result, and `snap.changed` did not report the end of
20
+ the snap. The engine now clears the snap result when the mode changes,
21
+ when snapping (or a kind of it) is turned off, when a mode declines to
22
+ snap an input, and on cancel, and `snap.changed` reports `null` once
23
+ ([#22](https://github.com/sakuzu/maplibre-gl-draw/issues/22)).
24
+ - The select mode snapped every input, so hovering near a vertex drew
25
+ the indicator, against the guide ("only while drawing and while moving
26
+ a vertex"). It now snaps its drags only. A select-mode plugin that
27
+ read `event.snapped` on a hover or a click receives the raw position
28
+ ([#22](https://github.com/sakuzu/maplibre-gl-draw/issues/22)).
29
+ - The selection frame of a point is drawn as a figure of the screen,
30
+ like the mark: it keeps the size of the mark and the margin on the
31
+ terrain at any pitch and at the edge of the globe, and a point on the
32
+ far side of the globe gets no frame. The frame of a selection of
33
+ several features is drawn on the box of its handles
34
+ ([#12](https://github.com/sakuzu/maplibre-gl-draw/issues/12)).
35
+
36
+ ## [2.1.2] - 2026-10-02
37
+
38
+ Three fixes: a plugin's fill, the click that finishes a plugin's
39
+ exclusive interaction, and the selection frame of a point on the
40
+ terrain. Nothing else changes.
41
+
42
+ ### Fixed
43
+
44
+ - A plugin's `fill.draw` drew nothing: the fill shared with the render
45
+ context of custom feature renderers was never given its shader, so a
46
+ renderer that filled an area with it left the area empty. It is now
47
+ prepared each frame with the other renderers.
48
+ - A click outside the element of a plugin's exclusive interaction
49
+ finished the interaction and did nothing else, so it took a second
50
+ click to select a feature. It now goes on as an ordinary click after
51
+ `finish()`: it selects the feature under it, or clears the selection
52
+ on the empty map. A click on the feature already selected does not
53
+ call `onFeatureClick`, so it does not start another interaction.
54
+ - With the terrain on and the camera pitched, the selection frame of a
55
+ point was stretched into a tall rectangle. It now lies flat at the
56
+ elevation of the point and keeps the size of the marker and the margin
57
+ ([#12](https://github.com/sakuzu/maplibre-gl-draw/issues/12)).
58
+
9
59
  ## [2.1.1] - 2026-10-02
10
60
 
11
61
  Three fixes to the lines drawn on the terrain: their thickness on a
12
62
  high-DPI display, their thickness between the vertices, and dashed lines
13
63
  that sank into the ground. Nothing else changes.
14
64
 
15
- ### Fixed
65
+ ### Fixed in 2.1.1
16
66
 
17
67
  - With the terrain on, a solid line or the outline of a polygon was drawn
18
68
  half as thick on a high-DPI display (a device pixel ratio of 2) as
@@ -1168,7 +1218,9 @@ available from Kasika, Inc.
1168
1218
  - A read-only mode and an interaction lock.
1169
1219
  - Plugins, custom modes and custom feature types.
1170
1220
 
1171
- [Unreleased]: https://github.com/sakuzu/maplibre-gl-draw/compare/v2.1.1...HEAD
1221
+ [Unreleased]: https://github.com/sakuzu/maplibre-gl-draw/compare/v2.1.2...HEAD
1222
+ [2.1.3]: https://github.com/sakuzu/maplibre-gl-draw/compare/v2.1.2...v2.1.3
1223
+ [2.1.2]: https://github.com/sakuzu/maplibre-gl-draw/compare/v2.1.1...v2.1.2
1172
1224
  [2.1.1]: https://github.com/sakuzu/maplibre-gl-draw/compare/v2.1.0...v2.1.1
1173
1225
  [2.1.0]: https://github.com/sakuzu/maplibre-gl-draw/compare/v2.0.0...v2.1.0
1174
1226
  [2.0.0]: https://github.com/sakuzu/maplibre-gl-draw/compare/v1.0.0...v2.0.0
@@ -196,6 +196,12 @@ export function createEngine(map, options = {}, build = {}) {
196
196
  const repaintOnSnapChange = () => map.triggerRepaint();
197
197
  eventEmitter.on('snap.change', repaintOnSnapChange);
198
198
  teardown.add(() => eventEmitter.off('snap.change', repaintOnSnapChange));
199
+ // A snap belongs to the input of the mode that made it: leaving the mode clears it (any
200
+ // mode, built-in or added), so neither the indicator nor snap.changed keeps a stale target
201
+ teardown.add(store.subscribe((changes) => {
202
+ if (changes.mode)
203
+ snapService.clear();
204
+ }));
199
205
  // 4. Create the RenderCoordinator (triggers a repaint automatically when the
200
206
  // Store changes)
201
207
  const renderCoordinator = createRenderCoordinator({ map, store });
@@ -83,7 +83,9 @@ export function createExtensionHost(deps) {
83
83
  install: (name, factory) => modeManager.registerMode(name, () => {
84
84
  const handle = createModeContext(modeServices, screen);
85
85
  try {
86
- return bridgeMode(name, factory(handle.context), handle.dispose, handle.entering);
86
+ return bridgeMode(name, factory(handle.context), handle.dispose, handle.entering,
87
+ // An interrupted mode leaves no snap behind (leaving it is cleared by the engine)
88
+ () => context.snapService.clear());
87
89
  }
88
90
  catch (error) {
89
91
  handle.dispose();
@@ -83,9 +83,10 @@ const BRIDGED = Symbol('extension mode');
83
83
  * @param onExit - Called after the mode was left, to end what its context holds
84
84
  * @param entering - Runs the entering of the mode, so that its context knows the cursor the
85
85
  * mode sets then
86
+ * @param onCancelled - Called after the mode was interrupted, to clear what its input left
86
87
  * @internal
87
88
  */
88
- export function bridgeMode(name, handler, onExit, entering = (run) => run()) {
89
+ export function bridgeMode(name, handler, onExit, entering = (run) => run(), onCancelled = () => { }) {
89
90
  const bridged = {
90
91
  [BRIDGED]: handler,
91
92
  modeName: name,
@@ -102,7 +103,15 @@ export function bridgeMode(name, handler, onExit, entering = (run) => run()) {
102
103
  }
103
104
  },
104
105
  onExternalStateChange() {
105
- handler.onCancel?.();
106
+ bridged.cancel();
107
+ },
108
+ cancel() {
109
+ try {
110
+ handler.onCancel?.();
111
+ }
112
+ finally {
113
+ onCancelled();
114
+ }
106
115
  },
107
116
  undoVertex: () => handler.onUndoVertex?.() === true,
108
117
  redoVertex: () => handler.onRedoVertex?.() === true,
@@ -136,7 +145,7 @@ export function routeToMode(handler, event, snapped, snap) {
136
145
  return undefined;
137
146
  const consumed = deliver(mode, event, snapped, snap);
138
147
  if (!consumed && event.type === 'keydown' && event.key === 'Escape' && mode.onCancel) {
139
- mode.onCancel();
148
+ handler.cancel();
140
149
  return true;
141
150
  }
142
151
  return consumed;
@@ -123,12 +123,14 @@ export function createInputRouter(deps) {
123
123
  *
124
124
  * Whether snapping is wanted is asked of the current mode per input type
125
125
  * (isSnapEnabledFor). A mode that does not declare it snaps for every input type
126
- * (as before).
126
+ * (as before). An input that is not snapped clears the previous result, so the
127
+ * indicator of an earlier snap does not stay behind it.
127
128
  */
128
129
  function applySnap(event, inputType) {
129
- if (!snapService || event.snap === false)
130
+ if (!snapService)
130
131
  return { event, result: null };
131
- if (modeManager.getHandler()?.isSnapEnabledFor?.(inputType) === false) {
132
+ if (event.snap === false || modeManager.getHandler()?.isSnapEnabledFor?.(inputType) === false) {
133
+ snapService.clear();
132
134
  return { event, result: null };
133
135
  }
134
136
  const result = snapService.resolve(event.lngLat, event.point, buildSnapContext(event.modifiers));
@@ -3,7 +3,10 @@
3
3
  *
4
4
  * Single click: the priority order is plugin intercept -> vertex click -> feature selection
5
5
  * 1. While a plugin is interacting, clicks inside its container are consumed; anything else
6
- * finishes it
6
+ * finishes it and then goes on as an ordinary click (it selects the feature under it, or
7
+ * clears the selection on the empty map), except that re-clicking the selected feature is
8
+ * not delegated to plugins.handleFeatureClick (the click that finished an interaction does
9
+ * not start another)
7
10
  * 2. A click on a vertex handle of a singly selected feature that supports vertex editing
8
11
  * (LineString / Polygon / the Multi types) -> vertex selection
9
12
  * 3. Unified z traversal (hitTestTopmost) -> if it is a Store feature, the selection is
@@ -11,7 +11,8 @@ import { getSelectedFeatures } from '../../view/ui/helper.js';
11
11
  * The main logic of onClick
12
12
  */
13
13
  export function handleSelectClick(event, context, config) {
14
- if (handlePluginInteractionClick(event, context)) {
14
+ const interaction = handlePluginInteractionClick(event, context);
15
+ if (interaction === 'inside') {
15
16
  return;
16
17
  }
17
18
  const { store } = context;
@@ -21,7 +22,7 @@ export function handleSelectClick(event, context, config) {
21
22
  return;
22
23
  }
23
24
  store.setSelectedVertices(null);
24
- handleFeatureClick(event, selection, selectedIds, context);
25
+ handleFeatureClick(event, selection, selectedIds, context, interaction === 'finished');
25
26
  }
26
27
  /**
27
28
  * The main logic of onDoubleClick
@@ -48,21 +49,21 @@ export function handleSelectDoubleClick(event, context) {
48
49
  /**
49
50
  * Click handling during a plugin interaction
50
51
  *
51
- * @returns true when the click was consumed (= the caller skips the rest of the handling)
52
+ * Only `inside` consumes the click; after `finished` the caller handles it as an ordinary click.
52
53
  */
53
54
  function handlePluginInteractionClick(event, context) {
54
55
  const pm = context.plugins;
55
56
  if (!pm?.isPluginInteracting())
56
- return false;
57
+ return 'none';
57
58
  const container = pm.getPluginInteractionContainer();
58
59
  if (container) {
59
60
  const target = event.originalEvent.target;
60
61
  if (container.contains(target)) {
61
- return true;
62
+ return 'inside';
62
63
  }
63
64
  }
64
65
  pm.finishPluginInteraction();
65
- return true;
66
+ return 'finished';
66
67
  }
67
68
  /**
68
69
  * Click handling for vertex handles
@@ -108,8 +109,11 @@ function handleVertexClick(event, selectedIds, context, config) {
108
109
  }
109
110
  /**
110
111
  * Feature selection handling
112
+ *
113
+ * @param finishedInteraction The click finished a plugin interaction: re-clicking the selected
114
+ * feature is not delegated to the plugins
111
115
  */
112
- function handleFeatureClick(event, selection, selectedIds, context) {
116
+ function handleFeatureClick(event, selection, selectedIds, context, finishedInteraction) {
113
117
  const { store } = context;
114
118
  const orderedFeatures = getDisplayFeatures(store);
115
119
  let top = context.hitTestTopmost(event.point, { orderedFeatures });
@@ -134,8 +138,12 @@ function handleFeatureClick(event, selection, selectedIds, context) {
134
138
  return;
135
139
  }
136
140
  const feature = top.feature;
137
- // Re-clicking an already selected feature -> delegate to the plugins
138
- if (!event.modifiers.shift && selectedIds.length === 1 && selectedIds.includes(feature.id)) {
141
+ // Re-clicking an already selected feature -> delegate to the plugins (not when the click
142
+ // finished an interaction)
143
+ if (!finishedInteraction &&
144
+ !event.modifiers.shift &&
145
+ selectedIds.length === 1 &&
146
+ selectedIds.includes(feature.id)) {
139
147
  if (context.plugins?.handleFeatureClick(feature.id, event)) {
140
148
  return;
141
149
  }
@@ -80,6 +80,14 @@ export class SelectMode {
80
80
  store.setSelection(null, []);
81
81
  }, 'silent');
82
82
  }
83
+ /**
84
+ * Snapping applies only while a vertex or a feature is dragged: a click or a hover in
85
+ * select is not snapped (and draws no indicator). A mode of the contract that wants the
86
+ * snapped position of a point asks `snap` of its ModeContext.
87
+ */
88
+ isSnapEnabledFor(inputType) {
89
+ return inputType === 'dragstart' || inputType === 'dragmove' || inputType === 'dragend';
90
+ }
83
91
  /**
84
92
  * Called after a state change from the outside (a change applied from outside the mode).
85
93
  * Resets the mode's internal state to keep it consistent.
@@ -110,6 +110,17 @@ export function createSnapService(deps = {}) {
110
110
  }
111
111
  return result;
112
112
  }
113
+ /**
114
+ * Drops the result when it holds a target, emitting the change once without a target (the
115
+ * same event as a snap that comes off). A result without a target is left as it is.
116
+ */
117
+ function clear() {
118
+ if (!lastResult?.target)
119
+ return;
120
+ const { lngLat } = lastResult;
121
+ lastResult = null;
122
+ deps.eventEmitter?.emit('snap.change', { lngLat });
123
+ }
113
124
  /**
114
125
  * Evaluates a single candidate and updates best
115
126
  *
@@ -248,7 +259,10 @@ export function createSnapService(deps = {}) {
248
259
  },
249
260
  setEnabled(value) {
250
261
  enabled = value;
262
+ if (!value)
263
+ clear();
251
264
  },
265
+ clear,
252
266
  setTolerance(px) {
253
267
  options.tolerancePx = px;
254
268
  },
@@ -260,6 +274,8 @@ export function createSnapService(deps = {}) {
260
274
  },
261
275
  setKindEnabled(kind, value) {
262
276
  options.kinds[kind] = value;
277
+ if (!value)
278
+ clear();
263
279
  },
264
280
  isKindEnabled(kind) {
265
281
  return options.kinds[kind];
@@ -12,6 +12,7 @@ import type { PixelRatioInput } from '../../shared/utils/pixel-ratio.js';
12
12
  import type { SpatialIndex } from '../../store/spatial/spatial-index.js';
13
13
  import type { Store } from '../../store/store.js';
14
14
  import { TextureCache } from '../cache/texture.js';
15
+ import { AnchoredOutlineRenderer } from '../renderers/anchored-outline.js';
15
16
  import { BatchManager } from '../renderers/batch-manager.js';
16
17
  import { FeatureDrawer } from '../renderers/drawer.js';
17
18
  import { type ImageErrorFn, ImageRenderer } from '../renderers/image.js';
@@ -37,6 +38,8 @@ export interface Renderers {
37
38
  quadShader: QuadShader;
38
39
  imageRenderer: ImageRenderer;
39
40
  strokeRenderer: StrokeRenderer;
41
+ /** The outlines laid on the screen around an anchor (the frames of points) */
42
+ outlineRenderer: AnchoredOutlineRenderer;
40
43
  sdfLineRenderer: SDFLineRenderer;
41
44
  pointShapeRenderer: PointShapeRenderer;
42
45
  pointInstanceRenderer: PointInstanceRenderer;
@@ -1,6 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2026 SAKAIDA Atsushi
2
2
  // SPDX-License-Identifier: AGPL-3.0-only
3
3
  import { TextureCache } from '../cache/texture.js';
4
+ import { AnchoredOutlineRenderer } from '../renderers/anchored-outline.js';
4
5
  import { BatchManager } from '../renderers/batch-manager.js';
5
6
  import { FeatureDrawer } from '../renderers/drawer.js';
6
7
  import { ImageRenderer } from '../renderers/image.js';
@@ -27,6 +28,7 @@ export function initRenderers(deps) {
27
28
  const quadShader = new QuadShader(gl).useTerrainState(terrain);
28
29
  const imageRenderer = new ImageRenderer(mapInstance, gl, textureCache, quadShader, (fileId) => store.getFile(fileId), onImageError);
29
30
  const strokeRenderer = new StrokeRenderer(mapInstance, gl, pixelRatio, terrain);
31
+ const outlineRenderer = new AnchoredOutlineRenderer(mapInstance, gl, pixelRatio, terrain);
30
32
  const sdfLineRenderer = new SDFLineRenderer(mapInstance, gl, pixelRatio, terrain);
31
33
  const pointShapeRenderer = new PointShapeRenderer(mapInstance, gl, pixelRatio, terrain);
32
34
  const pointInstanceRenderer = new PointInstanceRenderer(mapInstance, gl, pixelRatio, terrain);
@@ -35,7 +37,7 @@ export function initRenderers(deps) {
35
37
  const sdfPolygonRenderer = new SDFPolygonRenderer(gl, pixelRatio, polygonScope);
36
38
  const fillShaderManager = new FillShaderManager(gl, terrain);
37
39
  const selectionExtensions = scope.selection.extensions;
38
- const selectionUIRenderer = new SelectionUIRenderer(strokeRenderer, selectionConfig, selectionExtensions);
40
+ const selectionUIRenderer = new SelectionUIRenderer(strokeRenderer, outlineRenderer, selectionConfig, selectionExtensions, terrain);
39
41
  // The tile size of this instance's map (read by the custom bounding box calculators)
40
42
  const tileSize = mapInstance.transform?.tileSize ?? 512;
41
43
  selectionExtensions.setTileSize(tileSize);
@@ -74,11 +76,13 @@ export function initRenderers(deps) {
74
76
  tentativeRenderer,
75
77
  quadShader,
76
78
  strokeRenderer,
79
+ outlineRenderer,
77
80
  pointShapeRenderer,
78
81
  pointInstanceRenderer,
79
82
  sdfLineRenderer,
80
83
  polygonBatchRenderer,
81
84
  sdfPolygonRenderer,
85
+ fillShaderManager,
82
86
  });
83
87
  const batchManager = new BatchManager({
84
88
  gl,
@@ -94,6 +98,7 @@ export function initRenderers(deps) {
94
98
  quadShader,
95
99
  imageRenderer,
96
100
  strokeRenderer,
101
+ outlineRenderer,
97
102
  sdfLineRenderer,
98
103
  pointShapeRenderer,
99
104
  pointInstanceRenderer,
@@ -124,6 +129,7 @@ const GPU_OWNERS = [
124
129
  'textureCache',
125
130
  'quadShader',
126
131
  'strokeRenderer',
132
+ 'outlineRenderer',
127
133
  'pointShapeRenderer',
128
134
  'pointInstanceRenderer',
129
135
  'sdfLineRenderer',
@@ -0,0 +1,5 @@
1
+ /** A point of the screen relative to the anchor, in CSS px (y points down) */
2
+ export interface OutlineOffset {
3
+ x: number;
4
+ y: number;
5
+ }
@@ -0,0 +1,352 @@
1
+ // SPDX-FileCopyrightText: 2026 SAKAIDA Atsushi
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ import { getDashPattern, RENDERING_DEFAULTS } from '../../shared/math/index.js';
4
+ import { resolvePixelRatio } from '../../shared/utils/pixel-ratio.js';
5
+ import { calculateLngLatOffset, createProgram, getProjectionTransitionUniform, OFFSET_MODE_GLSL, } from '../shaders/helpers.js';
6
+ import { ProjectionUniformManager } from '../shaders/projection.js';
7
+ import { TerrainContext } from '../terrain/context.js';
8
+ import { drawBillboardsWithoutDepth } from './point/billboard-depth.js';
9
+ /** Floats per vertex: the offset (x, y in device px), the signed distance across, the distance along */
10
+ const FLOATS_PER_VERTEX = 4;
11
+ const STRIDE = FLOATS_PER_VERTEX * 4;
12
+ /**
13
+ * The vertices of a closed outline as a triangle strip: two per corner (the two sides of the
14
+ * line) and the first corner again at the end
15
+ *
16
+ * Each vertex is the corner moved along the miter of its two edges by half the width plus one
17
+ * pixel (the band the HHAA coverage fades in), the same join as StrokeRenderer: a miter longer
18
+ * than the limit becomes a bevel along the edge that arrives. The distance along the outline is
19
+ * in CSS px, the unit of the dash patterns.
20
+ *
21
+ * @param corners The corners relative to the anchor, in CSS px, in order (not closed)
22
+ * @param widthPx The width of the line in device px
23
+ * @param pixelRatio Device px per CSS px
24
+ * @param miterLimit The longest miter, in half widths
25
+ * @returns The interleaved vertices (x, y, signed distance, distance), empty for fewer than two
26
+ * distinct corners
27
+ * @internal
28
+ */
29
+ export function buildOutlineStrip(corners, widthPx, pixelRatio, miterLimit = RENDERING_DEFAULTS.MITER_LIMIT) {
30
+ // Repeated corners make no edge
31
+ const ring = corners.filter((corner, i) => {
32
+ const next = corners[(i + 1) % corners.length];
33
+ return corners.length === 1 || corner.x !== next.x || corner.y !== next.y;
34
+ });
35
+ const n = ring.length;
36
+ if (n < 2)
37
+ return new Float32Array(0);
38
+ const expanded = widthPx / 2 + 1;
39
+ const unit = (from, to) => {
40
+ const dx = to.x - from.x;
41
+ const dy = to.y - from.y;
42
+ const length = Math.hypot(dx, dy);
43
+ return { x: dx / length, y: dy / length };
44
+ };
45
+ const data = new Float32Array((n + 1) * 2 * FLOATS_PER_VERTEX);
46
+ let along = 0;
47
+ for (let i = 0; i <= n; i++) {
48
+ const corner = ring[i % n];
49
+ if (i > 0) {
50
+ const before = ring[i - 1];
51
+ along += Math.hypot(corner.x - before.x, corner.y - before.y);
52
+ }
53
+ const arriving = unit(ring[(i + n - 1) % n], corner);
54
+ const leaving = unit(corner, ring[(i + 1) % n]);
55
+ const n1 = { x: -arriving.y, y: arriving.x };
56
+ const n2 = { x: -leaving.y, y: leaving.x };
57
+ let normal = n1;
58
+ let scale = 1;
59
+ const sum = { x: n1.x + n2.x, y: n1.y + n2.y };
60
+ const sumLength = Math.hypot(sum.x, sum.y);
61
+ if (sumLength > 0.001) {
62
+ const miter = { x: sum.x / sumLength, y: sum.y / sumLength };
63
+ const cosHalf = miter.x * n1.x + miter.y * n1.y;
64
+ if (cosHalf > 0.001 && 1 / cosHalf <= miterLimit) {
65
+ normal = miter;
66
+ scale = 1 / cosHalf;
67
+ }
68
+ }
69
+ for (const side of [1, -1]) {
70
+ const base = (i * 2 + (side === 1 ? 0 : 1)) * FLOATS_PER_VERTEX;
71
+ data[base] = corner.x * pixelRatio + normal.x * side * expanded * scale;
72
+ data[base + 1] = corner.y * pixelRatio + normal.y * side * expanded * scale;
73
+ data[base + 2] = side * expanded;
74
+ data[base + 3] = along;
75
+ }
76
+ }
77
+ return data;
78
+ }
79
+ /**
80
+ * Draws closed outlines laid in pixels around an anchor projected like a marker
81
+ *
82
+ * @internal
83
+ */
84
+ export class AnchoredOutlineRenderer {
85
+ constructor(_map, gl, pixelRatio, terrain = new TerrainContext()) {
86
+ Object.defineProperty(this, "gl", {
87
+ enumerable: true,
88
+ configurable: true,
89
+ writable: true,
90
+ value: void 0
91
+ });
92
+ Object.defineProperty(this, "program", {
93
+ enumerable: true,
94
+ configurable: true,
95
+ writable: true,
96
+ value: null
97
+ });
98
+ Object.defineProperty(this, "variantName", {
99
+ enumerable: true,
100
+ configurable: true,
101
+ writable: true,
102
+ value: ''
103
+ });
104
+ Object.defineProperty(this, "projectionUniformManager", {
105
+ enumerable: true,
106
+ configurable: true,
107
+ writable: true,
108
+ value: void 0
109
+ });
110
+ Object.defineProperty(this, "offsetUniforms", {
111
+ enumerable: true,
112
+ configurable: true,
113
+ writable: true,
114
+ value: null
115
+ });
116
+ /** The injected device pixel ratio (read from window each time when not injected) */
117
+ Object.defineProperty(this, "pixelRatio", {
118
+ enumerable: true,
119
+ configurable: true,
120
+ writable: true,
121
+ value: void 0
122
+ });
123
+ Object.defineProperty(this, "colorLoc", {
124
+ enumerable: true,
125
+ configurable: true,
126
+ writable: true,
127
+ value: null
128
+ });
129
+ Object.defineProperty(this, "widthLoc", {
130
+ enumerable: true,
131
+ configurable: true,
132
+ writable: true,
133
+ value: null
134
+ });
135
+ Object.defineProperty(this, "pixelToClipLoc", {
136
+ enumerable: true,
137
+ configurable: true,
138
+ writable: true,
139
+ value: null
140
+ });
141
+ Object.defineProperty(this, "anchorOffsetLoc", {
142
+ enumerable: true,
143
+ configurable: true,
144
+ writable: true,
145
+ value: null
146
+ });
147
+ Object.defineProperty(this, "anchorElevationLoc", {
148
+ enumerable: true,
149
+ configurable: true,
150
+ writable: true,
151
+ value: null
152
+ });
153
+ Object.defineProperty(this, "dashArrayLoc", {
154
+ enumerable: true,
155
+ configurable: true,
156
+ writable: true,
157
+ value: null
158
+ });
159
+ Object.defineProperty(this, "dashEnabledLoc", {
160
+ enumerable: true,
161
+ configurable: true,
162
+ writable: true,
163
+ value: null
164
+ });
165
+ Object.defineProperty(this, "vao", {
166
+ enumerable: true,
167
+ configurable: true,
168
+ writable: true,
169
+ value: void 0
170
+ });
171
+ Object.defineProperty(this, "vertexBuffer", {
172
+ enumerable: true,
173
+ configurable: true,
174
+ writable: true,
175
+ value: void 0
176
+ });
177
+ this.gl = gl;
178
+ this.pixelRatio = pixelRatio;
179
+ // An outline around an anchor is a symbol: it keeps the height of its anchor on the frames
180
+ // that lay the surfaces flat, as the marker does
181
+ this.projectionUniformManager = new ProjectionUniformManager(gl).useTerrainState(terrain);
182
+ this.vertexBuffer = gl.createBuffer();
183
+ this.vao = gl.createVertexArray();
184
+ gl.bindVertexArray(this.vao);
185
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
186
+ gl.enableVertexAttribArray(0);
187
+ gl.vertexAttribPointer(0, 2, gl.FLOAT, false, STRIDE, 0); // a_offset
188
+ gl.enableVertexAttribArray(1);
189
+ gl.vertexAttribPointer(1, 1, gl.FLOAT, false, STRIDE, 8); // a_side_dist
190
+ gl.enableVertexAttribArray(2);
191
+ gl.vertexAttribPointer(2, 1, gl.FLOAT, false, STRIDE, 12); // a_distance
192
+ gl.bindVertexArray(null);
193
+ }
194
+ /** Makes sure the shader of the projection variant exists */
195
+ ensureShader(shaderData) {
196
+ if (this.variantName === shaderData.variantName)
197
+ return;
198
+ if (this.program)
199
+ this.gl.deleteProgram(this.program);
200
+ this.program = this.createShaderProgram(shaderData.vertexShaderPrelude, shaderData.define);
201
+ this.variantName = shaderData.variantName;
202
+ this.projectionUniformManager.getLocations(this.program);
203
+ const gl = this.gl;
204
+ this.colorLoc = gl.getUniformLocation(this.program, 'u_color');
205
+ this.widthLoc = gl.getUniformLocation(this.program, 'u_width');
206
+ this.pixelToClipLoc = gl.getUniformLocation(this.program, 'u_pixel_to_clip');
207
+ this.anchorOffsetLoc = gl.getUniformLocation(this.program, 'u_anchor_offset');
208
+ this.anchorElevationLoc = gl.getUniformLocation(this.program, 'u_anchor_elevation_m');
209
+ this.dashArrayLoc = gl.getUniformLocation(this.program, 'u_dashArray');
210
+ this.dashEnabledLoc = gl.getUniformLocation(this.program, 'u_dashEnabled');
211
+ }
212
+ /** Sets the uniforms of the offset mode (the camera of the copy of the world being drawn) */
213
+ setOffsetUniforms(uniforms) {
214
+ this.offsetUniforms = uniforms;
215
+ }
216
+ createShaderProgram(prelude, define) {
217
+ const projTransitionUniform = getProjectionTransitionUniform(prelude);
218
+ const vertexSource = `#version 300 es
219
+ ${prelude}
220
+ ${define}
221
+ ${projTransitionUniform}${OFFSET_MODE_GLSL}
222
+ layout(location = 0) in vec2 a_offset; // the vertex around the anchor (device px, y down)
223
+ layout(location = 1) in float a_side_dist; // the signed distance across the line (device px)
224
+ layout(location = 2) in float a_distance; // the distance along the outline (CSS px)
225
+
226
+ uniform vec2 u_anchor_offset; // the anchor relative to the center (64-bit on the CPU)
227
+ uniform float u_anchor_elevation_m; // the height of the anchor, from the CPU as for a marker
228
+ uniform vec2 u_pixel_to_clip; // device px to clip space
229
+ uniform float u_width; // the width of the line (device px)
230
+
231
+ out float v_dist;
232
+ out float v_width;
233
+ out float v_distance;
234
+
235
+ void main() {
236
+ // The height comes from the CPU, the source hit testing and the marker use
237
+ g_anchor_elevation_m = u_anchor_elevation_m;
238
+ g_anchor_elevation_on = 1.0;
239
+ vec4 centerClip = project_position_to_clipspace_from_offset(u_anchor_offset, u_projection_matrix);
240
+ // The depth of the anchor for every vertex: the far side of the globe clips the outline
241
+ // with its anchor, as it clips a marker
242
+ gl_Position = vec4(
243
+ centerClip.xy / centerClip.w + vec2(a_offset.x, -a_offset.y) * u_pixel_to_clip,
244
+ centerClip.z / centerClip.w,
245
+ 1.0
246
+ );
247
+ v_dist = a_side_dist;
248
+ v_width = u_width / 2.0;
249
+ v_distance = a_distance;
250
+ }`;
251
+ const fragmentSource = `#version 300 es
252
+ precision highp float;
253
+
254
+ uniform vec4 u_color;
255
+ uniform vec4 u_dashArray; // [dashLength, gapLength, patternLength, 0]
256
+ uniform float u_dashEnabled;
257
+
258
+ in float v_dist;
259
+ in float v_width;
260
+ in float v_distance;
261
+
262
+ out vec4 fragColor;
263
+
264
+ // HHAA: the part of the pixel on the inner side of a half plane
265
+ float pixelLine(float x) {
266
+ return clamp(x + 0.5, 0.0, 1.0);
267
+ }
268
+
269
+ void main() {
270
+ if (u_dashEnabled > 0.5) {
271
+ float patternLength = u_dashArray.z;
272
+ if (patternLength > 0.0 && mod(v_distance, patternLength) > u_dashArray.x) discard;
273
+ }
274
+ float coverage = pixelLine(v_dist + v_width) - pixelLine(v_dist - v_width);
275
+ // Blending is non-premultiplied, so the coverage goes to the alpha only
276
+ fragColor = vec4(u_color.rgb, u_color.a * coverage);
277
+ }`;
278
+ return createProgram(this.gl, vertexSource, fragmentSource);
279
+ }
280
+ /**
281
+ * Draws a closed outline around an anchor
282
+ *
283
+ * @param anchor The position the outline is laid around, `[lng, lat]`
284
+ * @param elevationMeters The height of the anchor (the height its marker is drawn at)
285
+ * @param corners The corners relative to the anchor's point on the screen, in CSS px
286
+ * @param style The line (its width is in CSS px)
287
+ * @param opacity A factor on the opacity of the line (the terrain's ghost of the marker)
288
+ * @param zoom The zoom of the frame
289
+ * @param projectionData The projection of the copy of the world being drawn
290
+ */
291
+ draw(anchor, elevationMeters, corners, style, opacity, zoom, projectionData) {
292
+ if (!this.program)
293
+ return;
294
+ const gl = this.gl;
295
+ const pixelRatio = resolvePixelRatio(this.pixelRatio);
296
+ const widthPx = style.width * pixelRatio;
297
+ const data = buildOutlineStrip(corners, widthPx, pixelRatio);
298
+ if (data.length === 0)
299
+ return;
300
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
301
+ gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_DRAW);
302
+ gl.bindVertexArray(this.vao);
303
+ gl.useProgram(this.program);
304
+ this.projectionUniformManager.setUniforms(projectionData, zoom, this.offsetUniforms);
305
+ const center = this.offsetUniforms?.centerLngLat ?? [0, 0];
306
+ const offset = calculateLngLatOffset([anchor[0], anchor[1]], center);
307
+ if (this.anchorOffsetLoc)
308
+ gl.uniform2f(this.anchorOffsetLoc, offset[0], offset[1]);
309
+ if (this.anchorElevationLoc)
310
+ gl.uniform1f(this.anchorElevationLoc, elevationMeters);
311
+ if (this.pixelToClipLoc) {
312
+ gl.uniform2f(this.pixelToClipLoc, 2 / gl.drawingBufferWidth, 2 / gl.drawingBufferHeight);
313
+ }
314
+ if (this.widthLoc)
315
+ gl.uniform1f(this.widthLoc, widthPx);
316
+ if (this.colorLoc) {
317
+ gl.uniform4fv(this.colorLoc, [
318
+ style.color[0],
319
+ style.color[1],
320
+ style.color[2],
321
+ style.color[3] * style.opacity * opacity,
322
+ ]);
323
+ }
324
+ const dashPattern = style.dashArray ?? getDashPattern(style.lineStyle);
325
+ const dashed = style.lineStyle !== 'solid' && dashPattern.length >= 2;
326
+ if (this.dashEnabledLoc)
327
+ gl.uniform1f(this.dashEnabledLoc, dashed ? 1 : 0);
328
+ if (dashed && this.dashArrayLoc) {
329
+ const [dash, gap] = dashPattern;
330
+ gl.uniform4fv(this.dashArrayLoc, [dash, gap, dash + gap, 0]);
331
+ }
332
+ drawBillboardsWithoutDepth(gl, () => {
333
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, data.length / FLOATS_PER_VERTEX);
334
+ });
335
+ gl.bindVertexArray(null);
336
+ }
337
+ /** Releases the GPU resources */
338
+ dispose() {
339
+ if (this.program) {
340
+ this.gl.deleteProgram(this.program);
341
+ this.program = null;
342
+ }
343
+ if (this.vao) {
344
+ this.gl.deleteVertexArray(this.vao);
345
+ this.vao = null;
346
+ }
347
+ if (this.vertexBuffer) {
348
+ this.gl.deleteBuffer(this.vertexBuffer);
349
+ this.vertexBuffer = null;
350
+ }
351
+ }
352
+ }
@@ -1,8 +1,10 @@
1
+ import type { AnchoredOutlineRenderer } from '../renderers/anchored-outline.js';
1
2
  import type { FeatureDrawer } from '../renderers/drawer.js';
2
3
  import type { SDFLineRenderer } from '../renderers/line/sdf-line.js';
3
4
  import type { PointInstanceRenderer } from '../renderers/point/point-instance.js';
4
5
  import type { PointShapeRenderer } from '../renderers/point/point-shape.js';
5
6
  import type { PolygonBatchRenderer } from '../renderers/polygon/batch.js';
7
+ import type { FillShaderManager } from '../renderers/polygon/fill.js';
6
8
  import type { SDFPolygonRenderer } from '../renderers/polygon/sdf-polygon.js';
7
9
  import type { StrokeRenderer } from '../renderers/stroke.js';
8
10
  import type { TentativeRenderer } from '../ui/tentative.js';
@@ -15,11 +17,15 @@ export interface ShaderInitializerRenderers {
15
17
  tentativeRenderer: TentativeRenderer | null;
16
18
  quadShader: QuadShader | null;
17
19
  strokeRenderer: StrokeRenderer | null;
20
+ /** The outlines laid on the screen around an anchor (the frames of points) */
21
+ outlineRenderer: AnchoredOutlineRenderer | null;
18
22
  pointShapeRenderer: PointShapeRenderer | null;
19
23
  pointInstanceRenderer: PointInstanceRenderer | null;
20
24
  sdfLineRenderer: SDFLineRenderer | null;
21
25
  polygonBatchRenderer: PolygonBatchRenderer | null;
22
26
  sdfPolygonRenderer: SDFPolygonRenderer | null;
27
+ /** The fill shared with the render context of custom feature renderers */
28
+ fillShaderManager: FillShaderManager | null;
23
29
  }
24
30
  /**
25
31
  * Creates a ShaderInitializer
@@ -23,7 +23,7 @@ export class ShaderInitializer {
23
23
  * Makes every renderer ensure its shader
24
24
  */
25
25
  ensureShaders(shaderData) {
26
- const { featureDrawer, tentativeRenderer, quadShader, strokeRenderer, pointShapeRenderer, pointInstanceRenderer, sdfLineRenderer, polygonBatchRenderer, sdfPolygonRenderer, } = this.renderers;
26
+ const { featureDrawer, tentativeRenderer, quadShader, strokeRenderer, outlineRenderer, pointShapeRenderer, pointInstanceRenderer, sdfLineRenderer, polygonBatchRenderer, sdfPolygonRenderer, fillShaderManager, } = this.renderers;
27
27
  if (featureDrawer) {
28
28
  featureDrawer.ensureShader(shaderData);
29
29
  }
@@ -36,6 +36,9 @@ export class ShaderInitializer {
36
36
  if (strokeRenderer) {
37
37
  strokeRenderer.ensureShader(shaderData);
38
38
  }
39
+ if (outlineRenderer) {
40
+ outlineRenderer.ensureShader(shaderData);
41
+ }
39
42
  if (pointShapeRenderer) {
40
43
  pointShapeRenderer.ensureShader(shaderData);
41
44
  }
@@ -51,6 +54,7 @@ export class ShaderInitializer {
51
54
  if (sdfPolygonRenderer) {
52
55
  sdfPolygonRenderer.ensureShader(shaderData);
53
56
  }
57
+ fillShaderManager?.ensureShader(shaderData);
54
58
  }
55
59
  /**
56
60
  * Sets the projection data on the renderers that need it
@@ -79,7 +83,7 @@ export class ShaderInitializer {
79
83
  * uniforms once per frame and applies them for each slot.
80
84
  */
81
85
  applyOffsetUniforms(uniforms) {
82
- const { featureDrawer, tentativeRenderer, quadShader, strokeRenderer, pointShapeRenderer, pointInstanceRenderer, sdfLineRenderer, polygonBatchRenderer, sdfPolygonRenderer, } = this.renderers;
86
+ const { featureDrawer, tentativeRenderer, quadShader, strokeRenderer, outlineRenderer, pointShapeRenderer, pointInstanceRenderer, sdfLineRenderer, polygonBatchRenderer, sdfPolygonRenderer, fillShaderManager, } = this.renderers;
83
87
  if (featureDrawer) {
84
88
  featureDrawer.setOffsetUniforms(uniforms);
85
89
  }
@@ -92,6 +96,9 @@ export class ShaderInitializer {
92
96
  if (strokeRenderer) {
93
97
  strokeRenderer.setOffsetUniforms(uniforms);
94
98
  }
99
+ if (outlineRenderer) {
100
+ outlineRenderer.setOffsetUniforms(uniforms);
101
+ }
95
102
  if (pointShapeRenderer) {
96
103
  pointShapeRenderer.setOffsetUniforms(uniforms);
97
104
  }
@@ -107,6 +114,7 @@ export class ShaderInitializer {
107
114
  if (sdfPolygonRenderer) {
108
115
  sdfPolygonRenderer.setOffsetUniforms(uniforms);
109
116
  }
117
+ fillShaderManager?.setOffsetUniforms(uniforms);
110
118
  }
111
119
  /**
112
120
  * Increments the frame count of SDFLineRenderer
@@ -140,11 +140,30 @@ export function drawnLongitude(context, lng) {
140
140
  * @internal
141
141
  */
142
142
  export function projectAnchor(context, lng, lat) {
143
+ const frame = context.anchorFrame;
144
+ if (!frame.mainMatrix || !frame.terrain)
145
+ return null;
146
+ return projectAnchorAt(context, lng, lat, anchorElevationMeters(context, lng, lat));
147
+ }
148
+ /**
149
+ * Maps a position at a given elevation to screen coordinates, with the projection of
150
+ * {@link projectAnchor}
151
+ *
152
+ * Something drawn flat at one elevation, such as the frame of a selected point at the
153
+ * elevation of the point, is projected with this so that it lands where the shader draws it.
154
+ *
155
+ * @param elevationMeters The elevation in meters (the exaggeration included, as
156
+ * {@link anchorElevationMeters} gives it)
157
+ * @returns null when the terrain is disabled, the frame state has not been established or the
158
+ * position is behind the camera
159
+ * @internal
160
+ */
161
+ export function projectAnchorAt(context, lng, lat, elevationMeters) {
143
162
  const frame = context.anchorFrame;
144
163
  const { mainMatrix: m, offsetUniforms, terrain, width, height } = frame;
145
164
  if (!m || !terrain || width <= 0 || height <= 0)
146
165
  return null;
147
- const elevationMercator = anchorElevationMeters(context, lng, lat) * frame.elevationScale;
166
+ const elevationMercator = elevationMeters * frame.elevationScale;
148
167
  // The anchor is projected on the copy of the world it is drawn on
149
168
  const drawnLng = drawnLongitude(context, lng);
150
169
  let clipX;
@@ -9,12 +9,14 @@ import type { ProjectionData } from 'maplibre-gl';
9
9
  import type { SelectionUIConfig } from '../../../shared/config/selection.js';
10
10
  import { type CoordinateTransform } from '../../../shared/math/index.js';
11
11
  import type { DragState, Feature } from '../../../store/types.js';
12
+ import type { AnchoredOutlineRenderer } from '../../renderers/anchored-outline.js';
12
13
  import type { StrokeRenderer } from '../../renderers/stroke.js';
14
+ import { TerrainContext } from '../../terrain/context.js';
13
15
  import type { SelectionExtensionRegistry } from './extension-registry.js';
14
16
  import type { BoundingBoxCoords } from './types.js';
15
17
  export declare class SelectionUIRenderer {
16
18
  #private;
17
- constructor(strokeRenderer: StrokeRenderer, config: SelectionUIConfig, extensions: SelectionExtensionRegistry);
19
+ constructor(strokeRenderer: StrokeRenderer, outlineRenderer: AnchoredOutlineRenderer, config: SelectionUIConfig, extensions: SelectionExtensionRegistry, terrain?: TerrainContext);
18
20
  /** Compute the BoundingBox of a feature (also usable from outside) */
19
21
  computeFeatureBoundingBox(feature: Feature): BoundingBoxCoords | null;
20
22
  /** Compute the combined BoundingBox of several features (also usable from outside) */
@@ -11,21 +11,30 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
11
11
  if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
12
12
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
13
13
  };
14
- var _SelectionUIRenderer_instances, _SelectionUIRenderer_strokeRenderer, _SelectionUIRenderer_extensions, _SelectionUIRenderer_config, _SelectionUIRenderer_transform, _SelectionUIRenderer_projectionData, _SelectionUIRenderer_drawRotatingBoundingBox, _SelectionUIRenderer_drawIndividualBoundingBox, _SelectionUIRenderer_drawCombinedBoundingBox, _SelectionUIRenderer_drawBoundingBoxWithMargin, _SelectionUIRenderer_drawPointBoundingBox, _SelectionUIRenderer_applyMargin, _SelectionUIRenderer_strokeBoundingBox, _SelectionUIRenderer_strokePath;
15
- import { applyMarginToBoundingBox } from '../../../shared/math/index.js';
14
+ var _SelectionUIRenderer_instances, _SelectionUIRenderer_strokeRenderer, _SelectionUIRenderer_outlineRenderer, _SelectionUIRenderer_extensions, _SelectionUIRenderer_terrain, _SelectionUIRenderer_config, _SelectionUIRenderer_transform, _SelectionUIRenderer_projectionData, _SelectionUIRenderer_drawRotatingBoundingBox, _SelectionUIRenderer_drawIndividualBoundingBox, _SelectionUIRenderer_drawBoundingBoxWithMargin, _SelectionUIRenderer_drawPointBoundingBox, _SelectionUIRenderer_pointFrame, _SelectionUIRenderer_applyMargin, _SelectionUIRenderer_strokeBoundingBox, _SelectionUIRenderer_strokePath;
15
+ import { applyMarginToBoundingBox, } from '../../../shared/math/index.js';
16
+ import { anchorElevationMeters, isAnchorActive, projectAnchorAt } from '../../terrain/anchor.js';
17
+ import { TerrainContext } from '../../terrain/context.js';
18
+ import { anchorGhostOpacity } from '../../terrain/occlusion.js';
16
19
  import { computeBoundingBox, computeCombinedBoundingBox, hasZeroArea, rotateBoundingBox, } from './bounding-box.js';
17
20
  export class SelectionUIRenderer {
18
- constructor(strokeRenderer, config, extensions) {
21
+ constructor(strokeRenderer, outlineRenderer, config, extensions, terrain = new TerrainContext()) {
19
22
  _SelectionUIRenderer_instances.add(this);
20
23
  _SelectionUIRenderer_strokeRenderer.set(this, void 0);
24
+ /** Draws the frames of points, which are figures of the screen */
25
+ _SelectionUIRenderer_outlineRenderer.set(this, void 0);
21
26
  /** The Selection UI extension points of the draw instance this renderer belongs to */
22
27
  _SelectionUIRenderer_extensions.set(this, void 0);
28
+ /** The terrain state of the draw instance (an inactive one projects with the map) */
29
+ _SelectionUIRenderer_terrain.set(this, void 0);
23
30
  _SelectionUIRenderer_config.set(this, void 0);
24
31
  _SelectionUIRenderer_transform.set(this, null);
25
32
  _SelectionUIRenderer_projectionData.set(this, null);
26
33
  __classPrivateFieldSet(this, _SelectionUIRenderer_strokeRenderer, strokeRenderer, "f");
34
+ __classPrivateFieldSet(this, _SelectionUIRenderer_outlineRenderer, outlineRenderer, "f");
27
35
  __classPrivateFieldSet(this, _SelectionUIRenderer_config, config, "f");
28
36
  __classPrivateFieldSet(this, _SelectionUIRenderer_extensions, extensions, "f");
37
+ __classPrivateFieldSet(this, _SelectionUIRenderer_terrain, terrain, "f");
29
38
  }
30
39
  /** Compute the BoundingBox of a feature (also usable from outside) */
31
40
  computeFeatureBoundingBox(feature) {
@@ -77,10 +86,10 @@ export class SelectionUIRenderer {
77
86
  __classPrivateFieldGet(this, _SelectionUIRenderer_instances, "m", _SelectionUIRenderer_drawIndividualBoundingBox).call(this, feature, zoom);
78
87
  }
79
88
  }
80
- __classPrivateFieldGet(this, _SelectionUIRenderer_instances, "m", _SelectionUIRenderer_drawCombinedBoundingBox).call(this, features, zoom);
89
+ __classPrivateFieldGet(this, _SelectionUIRenderer_instances, "m", _SelectionUIRenderer_drawBoundingBoxWithMargin).call(this, features, zoom);
81
90
  }
82
91
  }
83
- _SelectionUIRenderer_strokeRenderer = new WeakMap(), _SelectionUIRenderer_extensions = new WeakMap(), _SelectionUIRenderer_config = new WeakMap(), _SelectionUIRenderer_transform = new WeakMap(), _SelectionUIRenderer_projectionData = new WeakMap(), _SelectionUIRenderer_instances = new WeakSet(), _SelectionUIRenderer_drawRotatingBoundingBox = function _SelectionUIRenderer_drawRotatingBoundingBox(features, zoom, dragState) {
92
+ _SelectionUIRenderer_strokeRenderer = new WeakMap(), _SelectionUIRenderer_outlineRenderer = new WeakMap(), _SelectionUIRenderer_extensions = new WeakMap(), _SelectionUIRenderer_terrain = new WeakMap(), _SelectionUIRenderer_config = new WeakMap(), _SelectionUIRenderer_transform = new WeakMap(), _SelectionUIRenderer_projectionData = new WeakMap(), _SelectionUIRenderer_instances = new WeakSet(), _SelectionUIRenderer_drawRotatingBoundingBox = function _SelectionUIRenderer_drawRotatingBoundingBox(features, zoom, dragState) {
84
93
  // When dragState.operation === 'rotate', rotateInfo always exists
85
94
  const rotateInfo = dragState.rotateInfo;
86
95
  if (!rotateInfo)
@@ -105,82 +114,32 @@ _SelectionUIRenderer_strokeRenderer = new WeakMap(), _SelectionUIRenderer_extens
105
114
  if (!bbox)
106
115
  return;
107
116
  __classPrivateFieldGet(this, _SelectionUIRenderer_instances, "m", _SelectionUIRenderer_strokeBoundingBox).call(this, __classPrivateFieldGet(this, _SelectionUIRenderer_instances, "m", _SelectionUIRenderer_applyMargin).call(this, bbox, zoom), zoom);
108
- }, _SelectionUIRenderer_drawCombinedBoundingBox = function _SelectionUIRenderer_drawCombinedBoundingBox(features, zoom) {
109
- if (!__classPrivateFieldGet(this, _SelectionUIRenderer_transform, "f")) {
110
- // The fallback when there is no transform (without a margin)
111
- const bbox = computeCombinedBoundingBox(features, __classPrivateFieldGet(this, _SelectionUIRenderer_extensions, "f"));
112
- if (bbox)
113
- __classPrivateFieldGet(this, _SelectionUIRenderer_instances, "m", _SelectionUIRenderer_strokeBoundingBox).call(this, bbox, zoom);
114
- return;
115
- }
116
- const margin = __classPrivateFieldGet(this, _SelectionUIRenderer_config, "f").boundingBox.margin;
117
- const transform = __classPrivateFieldGet(this, _SelectionUIRenderer_transform, "f");
118
- let minLng = Number.POSITIVE_INFINITY;
119
- let maxLng = Number.NEGATIVE_INFINITY;
120
- let minLat = Number.POSITIVE_INFINITY;
121
- let maxLat = Number.NEGATIVE_INFINITY;
122
- for (const feature of features) {
123
- const bbox = computeBoundingBox(feature, __classPrivateFieldGet(this, _SelectionUIRenderer_extensions, "f"));
124
- if (!bbox)
125
- continue;
126
- // A single-coordinate feature (such as Point) is computed from the extent of the
127
- // point frame + the margin. The coordinate used is the center of the bbox (features
128
- // other than Point can be zero-area too, e.g. a MultiPoint with a single point, so
129
- // coordinates must not be treated as a Coordinate)
130
- if (hasZeroArea(bbox)) {
131
- const center = transform.project(bbox.center);
132
- for (const corner of __classPrivateFieldGet(this, _SelectionUIRenderer_extensions, "f").resolvePointFrameCorners(feature, center, margin)) {
133
- const at = transform.unproject(corner);
134
- minLng = Math.min(minLng, at.lng);
135
- maxLng = Math.max(maxLng, at.lng);
136
- minLat = Math.min(minLat, at.lat);
137
- maxLat = Math.max(maxLat, at.lat);
138
- }
139
- continue;
140
- }
141
- const marginedBbox = applyMarginToBoundingBox(bbox, margin, transform, zoom);
142
- const corners = [
143
- marginedBbox.topLeft,
144
- marginedBbox.topRight,
145
- marginedBbox.bottomRight,
146
- marginedBbox.bottomLeft,
147
- ];
148
- for (const corner of corners) {
149
- minLng = Math.min(minLng, corner[0]);
150
- maxLng = Math.max(maxLng, corner[0]);
151
- minLat = Math.min(minLat, corner[1]);
152
- maxLat = Math.max(maxLat, corner[1]);
153
- }
154
- }
155
- if (!Number.isFinite(minLng))
156
- return;
157
- const coords = [
158
- [minLng, maxLat],
159
- [maxLng, maxLat],
160
- [maxLng, minLat],
161
- [minLng, minLat],
162
- [minLng, maxLat],
163
- ];
164
- __classPrivateFieldGet(this, _SelectionUIRenderer_instances, "m", _SelectionUIRenderer_strokePath).call(this, coords, zoom);
165
117
  }, _SelectionUIRenderer_drawBoundingBoxWithMargin = function _SelectionUIRenderer_drawBoundingBoxWithMargin(features, zoom) {
166
118
  const bbox = computeCombinedBoundingBox(features, __classPrivateFieldGet(this, _SelectionUIRenderer_extensions, "f"));
167
119
  if (!bbox)
168
120
  return;
169
121
  __classPrivateFieldGet(this, _SelectionUIRenderer_instances, "m", _SelectionUIRenderer_strokeBoundingBox).call(this, __classPrivateFieldGet(this, _SelectionUIRenderer_instances, "m", _SelectionUIRenderer_applyMargin).call(this, bbox, zoom), zoom);
170
122
  }, _SelectionUIRenderer_drawPointBoundingBox = function _SelectionUIRenderer_drawPointBoundingBox(feature, coord, zoom) {
171
- if (!__classPrivateFieldGet(this, _SelectionUIRenderer_transform, "f"))
123
+ const frame = __classPrivateFieldGet(this, _SelectionUIRenderer_instances, "m", _SelectionUIRenderer_pointFrame).call(this, feature, coord);
124
+ const projectionData = __classPrivateFieldGet(this, _SelectionUIRenderer_projectionData, "f");
125
+ if (!frame || !projectionData)
172
126
  return;
127
+ const { x, y } = frame.center;
128
+ __classPrivateFieldGet(this, _SelectionUIRenderer_outlineRenderer, "f").draw([coord[0], coord[1]], frame.elevationMeters, frame.corners.map((corner) => ({ x: corner.x - x, y: corner.y - y })), __classPrivateFieldGet(this, _SelectionUIRenderer_config, "f").boundingBox.stroke, anchorGhostOpacity(__classPrivateFieldGet(this, _SelectionUIRenderer_terrain, "f"), coord[0], coord[1]), zoom, projectionData);
129
+ }, _SelectionUIRenderer_pointFrame = function _SelectionUIRenderer_pointFrame(feature, coord) {
173
130
  const transform = __classPrivateFieldGet(this, _SelectionUIRenderer_transform, "f");
174
- const center = transform.project(coord);
131
+ if (!transform)
132
+ return null;
133
+ const terrain = __classPrivateFieldGet(this, _SelectionUIRenderer_terrain, "f");
134
+ const elevationMeters = anchorElevationMeters(terrain, coord[0], coord[1]);
135
+ const center = isAnchorActive(terrain)
136
+ ? projectAnchorAt(terrain, coord[0], coord[1], elevationMeters)
137
+ : transform.project(coord);
138
+ if (!center)
139
+ return null;
175
140
  const margin = __classPrivateFieldGet(this, _SelectionUIRenderer_config, "f").boundingBox.margin;
176
- const coords = __classPrivateFieldGet(this, _SelectionUIRenderer_extensions, "f")
177
- .resolvePointFrameCorners(feature, center, margin)
178
- .map((corner) => {
179
- const at = transform.unproject(corner);
180
- return [at.lng, at.lat];
181
- });
182
- coords.push(coords[0]);
183
- __classPrivateFieldGet(this, _SelectionUIRenderer_instances, "m", _SelectionUIRenderer_strokePath).call(this, coords, zoom);
141
+ const corners = __classPrivateFieldGet(this, _SelectionUIRenderer_extensions, "f").resolvePointFrameCorners(feature, center, margin);
142
+ return { anchor: coord, elevationMeters, center, corners };
184
143
  }, _SelectionUIRenderer_applyMargin = function _SelectionUIRenderer_applyMargin(bbox, zoom) {
185
144
  if (!__classPrivateFieldGet(this, _SelectionUIRenderer_transform, "f"))
186
145
  return bbox;
@@ -193,12 +152,5 @@ _SelectionUIRenderer_strokeRenderer = new WeakMap(), _SelectionUIRenderer_extens
193
152
  const corners = [bbox.topLeft, bbox.topRight, bbox.bottomRight, bbox.bottomLeft];
194
153
  __classPrivateFieldGet(this, _SelectionUIRenderer_instances, "m", _SelectionUIRenderer_strokePath).call(this, [...corners, bbox.topLeft], zoom);
195
154
  }, _SelectionUIRenderer_strokePath = function _SelectionUIRenderer_strokePath(coords, zoom) {
196
- __classPrivateFieldGet(this, _SelectionUIRenderer_strokeRenderer, "f").draw(coords, __classPrivateFieldGet(this, _SelectionUIRenderer_config, "f").boundingBox.stroke, {
197
- widthUnit: 'pixels',
198
- closed: true,
199
- // The frame follows the ground. The elevation comes from the same function as the
200
- // handles, so the corners of the frame always match the corner handles and never
201
- // drift from the position that can be grabbed
202
- followTerrain: true,
203
- }, zoom, __classPrivateFieldGet(this, _SelectionUIRenderer_projectionData, "f") ?? undefined);
155
+ __classPrivateFieldGet(this, _SelectionUIRenderer_strokeRenderer, "f").draw(coords, __classPrivateFieldGet(this, _SelectionUIRenderer_config, "f").boundingBox.stroke, { widthUnit: 'pixels', closed: true, followTerrain: true }, zoom, __classPrivateFieldGet(this, _SelectionUIRenderer_projectionData, "f") ?? undefined);
204
156
  };
@@ -1,6 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2026 SAKAIDA Atsushi
2
2
  // SPDX-License-Identifier: AGPL-3.0-only
3
3
  import { THINNING_VIEWPORT_MARGIN_PX } from '../../shared/config/selection.js';
4
+ import { createCoordinateTransform } from '../../shared/math/index.js';
4
5
  import { supportsVertexEditing } from '../../shared/utils/coordinates.js';
5
6
  import { isGlobeProjection } from '../../shared/utils/map.js';
6
7
  import { isLocallyHidden } from '../../store/local-visibility.js';
@@ -25,14 +26,9 @@ isLocked = false) {
25
26
  if (pointInstanceRenderer) {
26
27
  pointInstanceRenderer.setProjectionData(projectionData);
27
28
  }
28
- // Set the coordinate transform
29
- const coordinateTransform = {
30
- project: (lngLat) => map.project([lngLat[0], lngLat[1]]),
31
- unproject: (point) => {
32
- const ll = map.unproject([point.x, point.y]);
33
- return { lng: ll.lng, lat: ll.lat };
34
- },
35
- };
29
+ // Set the coordinate transform: the projection the markers are drawn with and the hit
30
+ // testing uses (the anchor projection while the terrain is on, the map's otherwise)
31
+ const coordinateTransform = createCoordinateTransform(map);
36
32
  selectionUIRenderer.setTransform(coordinateTransform);
37
33
  selectionUIRenderer.setProjectionData(projectionData);
38
34
  selectionHandlesRenderer.setTransform(coordinateTransform);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sakuzu/maplibre-gl-draw",
3
- "version": "2.1.1",
3
+ "version": "2.1.3",
4
4
  "description": "WebGL2 drawing and editing engine for MapLibre GL JS: points, lines, and polygons rendered in a custom layer, with selection, hit testing, and geometry utilities.",
5
5
  "keywords": [
6
6
  "maplibre",