@sakuzu/maplibre-gl-draw 2.1.1 → 2.1.2

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,36 @@ the project follows semantic versioning.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.1.2] - 2026-10-02
10
+
11
+ Three fixes: a plugin's fill, the click that finishes a plugin's
12
+ exclusive interaction, and the selection frame of a point on the
13
+ terrain. Nothing else changes.
14
+
15
+ ### Fixed
16
+
17
+ - A plugin's `fill.draw` drew nothing: the fill shared with the render
18
+ context of custom feature renderers was never given its shader, so a
19
+ renderer that filled an area with it left the area empty. It is now
20
+ prepared each frame with the other renderers.
21
+ - A click outside the element of a plugin's exclusive interaction
22
+ finished the interaction and did nothing else, so it took a second
23
+ click to select a feature. It now goes on as an ordinary click after
24
+ `finish()`: it selects the feature under it, or clears the selection
25
+ on the empty map. A click on the feature already selected does not
26
+ call `onFeatureClick`, so it does not start another interaction.
27
+ - With the terrain on and the camera pitched, the selection frame of a
28
+ point was stretched into a tall rectangle. It now lies flat at the
29
+ elevation of the point and keeps the size of the marker and the margin
30
+ ([#12](https://github.com/sakuzu/maplibre-gl-draw/issues/12)).
31
+
9
32
  ## [2.1.1] - 2026-10-02
10
33
 
11
34
  Three fixes to the lines drawn on the terrain: their thickness on a
12
35
  high-DPI display, their thickness between the vertices, and dashed lines
13
36
  that sank into the ground. Nothing else changes.
14
37
 
15
- ### Fixed
38
+ ### Fixed in 2.1.1
16
39
 
17
40
  - With the terrain on, a solid line or the outline of a polygon was drawn
18
41
  half as thick on a high-DPI display (a device pixel ratio of 2) as
@@ -1168,7 +1191,8 @@ available from Kasika, Inc.
1168
1191
  - A read-only mode and an interaction lock.
1169
1192
  - Plugins, custom modes and custom feature types.
1170
1193
 
1171
- [Unreleased]: https://github.com/sakuzu/maplibre-gl-draw/compare/v2.1.1...HEAD
1194
+ [Unreleased]: https://github.com/sakuzu/maplibre-gl-draw/compare/v2.1.2...HEAD
1195
+ [2.1.2]: https://github.com/sakuzu/maplibre-gl-draw/compare/v2.1.1...v2.1.2
1172
1196
  [2.1.1]: https://github.com/sakuzu/maplibre-gl-draw/compare/v2.1.0...v2.1.1
1173
1197
  [2.1.0]: https://github.com/sakuzu/maplibre-gl-draw/compare/v2.0.0...v2.1.0
1174
1198
  [2.0.0]: https://github.com/sakuzu/maplibre-gl-draw/compare/v1.0.0...v2.0.0
@@ -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
  }
@@ -35,7 +35,7 @@ export function initRenderers(deps) {
35
35
  const sdfPolygonRenderer = new SDFPolygonRenderer(gl, pixelRatio, polygonScope);
36
36
  const fillShaderManager = new FillShaderManager(gl, terrain);
37
37
  const selectionExtensions = scope.selection.extensions;
38
- const selectionUIRenderer = new SelectionUIRenderer(strokeRenderer, selectionConfig, selectionExtensions);
38
+ const selectionUIRenderer = new SelectionUIRenderer(strokeRenderer, selectionConfig, selectionExtensions, terrain);
39
39
  // The tile size of this instance's map (read by the custom bounding box calculators)
40
40
  const tileSize = mapInstance.transform?.tileSize ?? 512;
41
41
  selectionExtensions.setTileSize(tileSize);
@@ -79,6 +79,7 @@ export function initRenderers(deps) {
79
79
  sdfLineRenderer,
80
80
  polygonBatchRenderer,
81
81
  sdfPolygonRenderer,
82
+ fillShaderManager,
82
83
  });
83
84
  const batchManager = new BatchManager({
84
85
  gl,
@@ -3,6 +3,7 @@ import type { SDFLineRenderer } from '../renderers/line/sdf-line.js';
3
3
  import type { PointInstanceRenderer } from '../renderers/point/point-instance.js';
4
4
  import type { PointShapeRenderer } from '../renderers/point/point-shape.js';
5
5
  import type { PolygonBatchRenderer } from '../renderers/polygon/batch.js';
6
+ import type { FillShaderManager } from '../renderers/polygon/fill.js';
6
7
  import type { SDFPolygonRenderer } from '../renderers/polygon/sdf-polygon.js';
7
8
  import type { StrokeRenderer } from '../renderers/stroke.js';
8
9
  import type { TentativeRenderer } from '../ui/tentative.js';
@@ -20,6 +21,8 @@ export interface ShaderInitializerRenderers {
20
21
  sdfLineRenderer: SDFLineRenderer | null;
21
22
  polygonBatchRenderer: PolygonBatchRenderer | null;
22
23
  sdfPolygonRenderer: SDFPolygonRenderer | null;
24
+ /** The fill shared with the render context of custom feature renderers */
25
+ fillShaderManager: FillShaderManager | null;
23
26
  }
24
27
  /**
25
28
  * 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, pointShapeRenderer, pointInstanceRenderer, sdfLineRenderer, polygonBatchRenderer, sdfPolygonRenderer, fillShaderManager, } = this.renderers;
27
27
  if (featureDrawer) {
28
28
  featureDrawer.ensureShader(shaderData);
29
29
  }
@@ -51,6 +51,7 @@ export class ShaderInitializer {
51
51
  if (sdfPolygonRenderer) {
52
52
  sdfPolygonRenderer.ensureShader(shaderData);
53
53
  }
54
+ fillShaderManager?.ensureShader(shaderData);
54
55
  }
55
56
  /**
56
57
  * Sets the projection data on the renderers that need it
@@ -79,7 +80,7 @@ export class ShaderInitializer {
79
80
  * uniforms once per frame and applies them for each slot.
80
81
  */
81
82
  applyOffsetUniforms(uniforms) {
82
- const { featureDrawer, tentativeRenderer, quadShader, strokeRenderer, pointShapeRenderer, pointInstanceRenderer, sdfLineRenderer, polygonBatchRenderer, sdfPolygonRenderer, } = this.renderers;
83
+ const { featureDrawer, tentativeRenderer, quadShader, strokeRenderer, pointShapeRenderer, pointInstanceRenderer, sdfLineRenderer, polygonBatchRenderer, sdfPolygonRenderer, fillShaderManager, } = this.renderers;
83
84
  if (featureDrawer) {
84
85
  featureDrawer.setOffsetUniforms(uniforms);
85
86
  }
@@ -107,6 +108,7 @@ export class ShaderInitializer {
107
108
  if (sdfPolygonRenderer) {
108
109
  sdfPolygonRenderer.setOffsetUniforms(uniforms);
109
110
  }
111
+ fillShaderManager?.setOffsetUniforms(uniforms);
110
112
  }
111
113
  /**
112
114
  * 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;
@@ -10,11 +10,12 @@ 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
12
  import type { StrokeRenderer } from '../../renderers/stroke.js';
13
+ import { TerrainContext } from '../../terrain/context.js';
13
14
  import type { SelectionExtensionRegistry } from './extension-registry.js';
14
15
  import type { BoundingBoxCoords } from './types.js';
15
16
  export declare class SelectionUIRenderer {
16
17
  #private;
17
- constructor(strokeRenderer: StrokeRenderer, config: SelectionUIConfig, extensions: SelectionExtensionRegistry);
18
+ constructor(strokeRenderer: StrokeRenderer, config: SelectionUIConfig, extensions: SelectionExtensionRegistry, terrain?: TerrainContext);
18
19
  /** Compute the BoundingBox of a feature (also usable from outside) */
19
20
  computeFeatureBoundingBox(feature: Feature): BoundingBoxCoords | null;
20
21
  /** Compute the combined BoundingBox of several features (also usable from outside) */
@@ -11,21 +11,31 @@ 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_extensions, _SelectionUIRenderer_terrain, _SelectionUIRenderer_config, _SelectionUIRenderer_transform, _SelectionUIRenderer_projectionData, _SelectionUIRenderer_drawRotatingBoundingBox, _SelectionUIRenderer_drawIndividualBoundingBox, _SelectionUIRenderer_drawCombinedBoundingBox, _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';
16
18
  import { computeBoundingBox, computeCombinedBoundingBox, hasZeroArea, rotateBoundingBox, } from './bounding-box.js';
19
+ /**
20
+ * How far the positions differ that the scale of the screen around a point is measured
21
+ * between, in pixels at the zoom of the frame
22
+ */
23
+ const POINT_FRAME_PROBE_PX = 4;
17
24
  export class SelectionUIRenderer {
18
- constructor(strokeRenderer, config, extensions) {
25
+ constructor(strokeRenderer, config, extensions, terrain = new TerrainContext()) {
19
26
  _SelectionUIRenderer_instances.add(this);
20
27
  _SelectionUIRenderer_strokeRenderer.set(this, void 0);
21
28
  /** The Selection UI extension points of the draw instance this renderer belongs to */
22
29
  _SelectionUIRenderer_extensions.set(this, void 0);
30
+ /** The terrain state of the draw instance (an inactive one projects with the map) */
31
+ _SelectionUIRenderer_terrain.set(this, void 0);
23
32
  _SelectionUIRenderer_config.set(this, void 0);
24
33
  _SelectionUIRenderer_transform.set(this, null);
25
34
  _SelectionUIRenderer_projectionData.set(this, null);
26
35
  __classPrivateFieldSet(this, _SelectionUIRenderer_strokeRenderer, strokeRenderer, "f");
27
36
  __classPrivateFieldSet(this, _SelectionUIRenderer_config, config, "f");
28
37
  __classPrivateFieldSet(this, _SelectionUIRenderer_extensions, extensions, "f");
38
+ __classPrivateFieldSet(this, _SelectionUIRenderer_terrain, terrain, "f");
29
39
  }
30
40
  /** Compute the BoundingBox of a feature (also usable from outside) */
31
41
  computeFeatureBoundingBox(feature) {
@@ -80,7 +90,7 @@ export class SelectionUIRenderer {
80
90
  __classPrivateFieldGet(this, _SelectionUIRenderer_instances, "m", _SelectionUIRenderer_drawCombinedBoundingBox).call(this, features, zoom);
81
91
  }
82
92
  }
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) {
93
+ _SelectionUIRenderer_strokeRenderer = 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
94
  // When dragState.operation === 'rotate', rotateInfo always exists
85
95
  const rotateInfo = dragState.rotateInfo;
86
96
  if (!rotateInfo)
@@ -124,17 +134,16 @@ _SelectionUIRenderer_strokeRenderer = new WeakMap(), _SelectionUIRenderer_extens
124
134
  if (!bbox)
125
135
  continue;
126
136
  // 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)
137
+ // point frame + the margin, as its own frame is (#pointFrame). The coordinate used is
138
+ // the center of the bbox (features other than Point can be zero-area too, e.g. a
139
+ // MultiPoint with a single point, so coordinates must not be treated as a Coordinate)
130
140
  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);
141
+ const frame = __classPrivateFieldGet(this, _SelectionUIRenderer_instances, "m", _SelectionUIRenderer_pointFrame).call(this, feature, bbox.center, zoom);
142
+ for (const [lng, lat] of frame?.coords ?? []) {
143
+ minLng = Math.min(minLng, lng);
144
+ maxLng = Math.max(maxLng, lng);
145
+ minLat = Math.min(minLat, lat);
146
+ maxLat = Math.max(maxLat, lat);
138
147
  }
139
148
  continue;
140
149
  }
@@ -168,19 +177,37 @@ _SelectionUIRenderer_strokeRenderer = new WeakMap(), _SelectionUIRenderer_extens
168
177
  return;
169
178
  __classPrivateFieldGet(this, _SelectionUIRenderer_instances, "m", _SelectionUIRenderer_strokeBoundingBox).call(this, __classPrivateFieldGet(this, _SelectionUIRenderer_instances, "m", _SelectionUIRenderer_applyMargin).call(this, bbox, zoom), zoom);
170
179
  }, _SelectionUIRenderer_drawPointBoundingBox = function _SelectionUIRenderer_drawPointBoundingBox(feature, coord, zoom) {
171
- if (!__classPrivateFieldGet(this, _SelectionUIRenderer_transform, "f"))
180
+ const frame = __classPrivateFieldGet(this, _SelectionUIRenderer_instances, "m", _SelectionUIRenderer_pointFrame).call(this, feature, coord, zoom);
181
+ if (!frame)
172
182
  return;
183
+ __classPrivateFieldGet(this, _SelectionUIRenderer_instances, "m", _SelectionUIRenderer_strokePath).call(this, frame.coords, zoom, {
184
+ elevationMeters: frame.elevationMeters,
185
+ followTerrain: false,
186
+ });
187
+ }, _SelectionUIRenderer_pointFrame = function _SelectionUIRenderer_pointFrame(feature, coord, zoom) {
173
188
  const transform = __classPrivateFieldGet(this, _SelectionUIRenderer_transform, "f");
174
- const center = transform.project(coord);
189
+ if (!transform)
190
+ return null;
191
+ const terrain = __classPrivateFieldGet(this, _SelectionUIRenderer_terrain, "f");
192
+ const elevationMeters = anchorElevationMeters(terrain, coord[0], coord[1]);
193
+ const anchored = isAnchorActive(terrain);
194
+ const project = (at) => anchored ? projectAnchorAt(terrain, at[0], at[1], elevationMeters) : transform.project(at);
195
+ const center = project(coord);
196
+ if (!center)
197
+ return null;
175
198
  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) => {
199
+ const corners = __classPrivateFieldGet(this, _SelectionUIRenderer_extensions, "f").resolvePointFrameCorners(feature, center, margin);
200
+ const toPosition = anchored ? screenToPlane(project, coord, center, zoom) : null;
201
+ const coords = corners.map((corner) => {
202
+ const position = toPosition?.(corner);
203
+ if (position)
204
+ return position;
205
+ // No terrain, or the plane seen edge on: the corner is cast onto the map
179
206
  const at = transform.unproject(corner);
180
207
  return [at.lng, at.lat];
181
208
  });
182
209
  coords.push(coords[0]);
183
- __classPrivateFieldGet(this, _SelectionUIRenderer_instances, "m", _SelectionUIRenderer_strokePath).call(this, coords, zoom);
210
+ return { coords, elevationMeters };
184
211
  }, _SelectionUIRenderer_applyMargin = function _SelectionUIRenderer_applyMargin(bbox, zoom) {
185
212
  if (!__classPrivateFieldGet(this, _SelectionUIRenderer_transform, "f"))
186
213
  return bbox;
@@ -192,13 +219,49 @@ _SelectionUIRenderer_strokeRenderer = new WeakMap(), _SelectionUIRenderer_extens
192
219
  }, _SelectionUIRenderer_strokeBoundingBox = function _SelectionUIRenderer_strokeBoundingBox(bbox, zoom) {
193
220
  const corners = [bbox.topLeft, bbox.topRight, bbox.bottomRight, bbox.bottomLeft];
194
221
  __classPrivateFieldGet(this, _SelectionUIRenderer_instances, "m", _SelectionUIRenderer_strokePath).call(this, [...corners, bbox.topLeft], zoom);
195
- }, _SelectionUIRenderer_strokePath = function _SelectionUIRenderer_strokePath(coords, zoom) {
222
+ }, _SelectionUIRenderer_strokePath = function _SelectionUIRenderer_strokePath(coords, zoom, placement = { followTerrain: true }) {
196
223
  __classPrivateFieldGet(this, _SelectionUIRenderer_strokeRenderer, "f").draw(coords, __classPrivateFieldGet(this, _SelectionUIRenderer_config, "f").boundingBox.stroke, {
197
224
  widthUnit: 'pixels',
198
225
  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,
226
+ followTerrain: placement.followTerrain,
227
+ ...(placement.elevationMeters !== undefined && {
228
+ elevationMeters: placement.elevationMeters,
229
+ }),
203
230
  }, zoom, __classPrivateFieldGet(this, _SelectionUIRenderer_projectionData, "f") ?? undefined);
204
231
  };
232
+ /**
233
+ * The inverse of a projection around a point, to the first order: a point of the screen near
234
+ * `center` to the position whose projection it is
235
+ *
236
+ * The scale of the screen along the longitude and the latitude is measured by projecting
237
+ * positions a few pixels to either side of `coord`.
238
+ *
239
+ * @returns null when the projection is degenerate there (seen edge on, or a probe behind the
240
+ * camera)
241
+ */
242
+ function screenToPlane(project, coord, center, zoom) {
243
+ const [lng, lat] = coord;
244
+ // Degrees of longitude per pixel at the zoom on a 512 px world, a few pixels' worth
245
+ const step = (360 / (512 * 2 ** zoom)) * POINT_FRAME_PROBE_PX;
246
+ const latStep = Math.min(step, Math.max(1e-9, (89.9 - Math.abs(lat)) / 2));
247
+ const east = project([lng + step, lat]);
248
+ const west = project([lng - step, lat]);
249
+ const north = project([lng, lat + latStep]);
250
+ const south = project([lng, lat - latStep]);
251
+ if (!east || !west || !north || !south)
252
+ return null;
253
+ // The screen per degree of longitude (a, c) and of latitude (b, d)
254
+ const a = (east.x - west.x) / (2 * step);
255
+ const c = (east.y - west.y) / (2 * step);
256
+ const b = (north.x - south.x) / (2 * latStep);
257
+ const d = (north.y - south.y) / (2 * latStep);
258
+ const det = a * d - b * c;
259
+ const scale = Math.max(Math.abs(a), Math.abs(b), Math.abs(c), Math.abs(d));
260
+ if (!Number.isFinite(det) || Math.abs(det) <= 1e-12 * scale * scale)
261
+ return null;
262
+ return (point) => {
263
+ const dx = point.x - center.x;
264
+ const dy = point.y - center.y;
265
+ return [lng + (d * dx - b * dy) / det, lat + (a * dy - c * dx) / det];
266
+ };
267
+ }
@@ -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.2",
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",