@sakuzu/maplibre-gl-draw 2.1.2 → 2.1.4

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,6 +6,60 @@ the project follows semantic versioning.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.1.4] - 2026-10-07
10
+
11
+ Two fixes to how sizes follow the zoom: the paths of a dataset that drew
12
+ at a fixed size, and sizes below one pixel that never disappeared.
13
+
14
+ ### Fixed in 2.1.4
15
+
16
+ - A dataset applies the draw factors of its `zoomScale` (size and
17
+ opacity) on every path it draws through. The selection highlight and
18
+ the selected feature drawn on top of it, dashed and dotted lines and
19
+ polygon outlines, and shapes that go through immediate mode used to be
20
+ drawn at their stored size, so a selected point or a dashed line did
21
+ not change with the zoom. The dash pattern is now made from the scaled
22
+ width. `BatchManager.beginFrame` and `DisplayBatchTarget.beginFrame`
23
+ take an optional `factors` that is combined with the layer factors
24
+ ([#28](https://github.com/sakuzu/maplibre-gl-draw/issues/28)).
25
+ - Sizes below one pixel are drawn so that the ink on screen follows the
26
+ real size and fades out smoothly. A point whose diameter is below
27
+ 1 px is drawn at 1 px with its alpha multiplied by the ratio of the
28
+ areas, and a line or a polygon outline thinner than 1 px is drawn at
29
+ 1 px with its alpha multiplied by the width, on the flat map and on
30
+ the terrain drape alike. Before, a shrinking point kept a faint blob
31
+ of about the antialiasing width until its size was exactly 0. This
32
+ applies to datasets and hand-drawn features. Nothing changes at 1 px
33
+ and above, and no minimum or maximum size is added
34
+ ([#28](https://github.com/sakuzu/maplibre-gl-draw/issues/28)).
35
+
36
+ ## [2.1.3] - 2026-10-04
37
+
38
+ Two fixes: the snap indicator that stayed after its input, and the
39
+ selection frame of a point on the globe and on the terrain at a high
40
+ pitch.
41
+
42
+ ### Fixed in 2.1.3
43
+
44
+ - The snap indicator stayed after the input that made it: after leaving
45
+ the drawing mode, turning snapping off, Escape, or a pan, it went on
46
+ showing the last result, and `snap.changed` did not report the end of
47
+ the snap. The engine now clears the snap result when the mode changes,
48
+ when snapping (or a kind of it) is turned off, when a mode declines to
49
+ snap an input, and on cancel, and `snap.changed` reports `null` once
50
+ ([#22](https://github.com/sakuzu/maplibre-gl-draw/issues/22)).
51
+ - The select mode snapped every input, so hovering near a vertex drew
52
+ the indicator, against the guide ("only while drawing and while moving
53
+ a vertex"). It now snaps its drags only. A select-mode plugin that
54
+ read `event.snapped` on a hover or a click receives the raw position
55
+ ([#22](https://github.com/sakuzu/maplibre-gl-draw/issues/22)).
56
+ - The selection frame of a point is drawn as a figure of the screen,
57
+ like the mark: it keeps the size of the mark and the margin on the
58
+ terrain at any pitch and at the edge of the globe, and a point on the
59
+ far side of the globe gets no frame. The frame of a selection of
60
+ several features is drawn on the box of its handles
61
+ ([#12](https://github.com/sakuzu/maplibre-gl-draw/issues/12)).
62
+
9
63
  ## [2.1.2] - 2026-10-02
10
64
 
11
65
  Three fixes: a plugin's fill, the click that finishes a plugin's
@@ -1192,6 +1246,7 @@ available from Kasika, Inc.
1192
1246
  - Plugins, custom modes and custom feature types.
1193
1247
 
1194
1248
  [Unreleased]: https://github.com/sakuzu/maplibre-gl-draw/compare/v2.1.2...HEAD
1249
+ [2.1.3]: https://github.com/sakuzu/maplibre-gl-draw/compare/v2.1.2...v2.1.3
1195
1250
  [2.1.2]: https://github.com/sakuzu/maplibre-gl-draw/compare/v2.1.1...v2.1.2
1196
1251
  [2.1.1]: https://github.com/sakuzu/maplibre-gl-draw/compare/v2.1.0...v2.1.1
1197
1252
  [2.1.0]: https://github.com/sakuzu/maplibre-gl-draw/compare/v2.0.0...v2.1.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;
@@ -313,11 +313,10 @@ export class DisplayChunkSet {
313
313
  }
314
314
  drawChunkBatches(state.batches, renderers, zoom, projectionData, factors);
315
315
  // The features that cannot be retained are drawn immediately, right after the retained
316
- // batches of the chunk, the points last (the immediate-mode path is not multiplied by the
317
- // factors)
316
+ // batches of the chunk, the points last (with the same factors as the retained batches)
318
317
  const { fallback, pointFallback } = state.batches;
319
318
  if (fallback.length > 0 || pointFallback.length > 0) {
320
- target.beginFrame(projectionData, zoom);
319
+ target.beginFrame(projectionData, zoom, undefined, factors);
321
320
  for (const feature of fallback)
322
321
  target.processFeature(feature, false);
323
322
  for (const feature of pointFallback)
@@ -608,12 +608,13 @@ export class DatasetImpl {
608
608
  this.chunks.draw({ target, renderers, terrain, projectionData, zoom, bounds, factors });
609
609
  }
610
610
  else {
611
- this.drawImmediate(target, projectionData, zoom, bounds);
611
+ this.drawImmediate(target, projectionData, zoom, bounds, factors);
612
612
  }
613
613
  drawSelectionHighlight({
614
614
  target,
615
615
  projectionData,
616
616
  zoom,
617
+ factors,
617
618
  bounds,
618
619
  selected: this.selected,
619
620
  styles: this.chunks.retainedRenderers?.styles,
@@ -710,9 +711,10 @@ export class DatasetImpl {
710
711
  * Draws in immediate mode (the path for an environment without the retained-mode renderers)
711
712
  *
712
713
  * The frame of the batch is started without passing a layer. The rules have already been
713
- * evaluated here, so the rule evaluation of the rendering side (FeatureDrawer) is not used.
714
+ * evaluated here, so the rule evaluation of the rendering side (FeatureDrawer) is not used. The
715
+ * factors of the frame are passed, the same as to the retained batches.
714
716
  */
715
- drawImmediate(target, projectionData, zoom, bounds) {
717
+ drawImmediate(target, projectionData, zoom, bounds, factors) {
716
718
  const rows = this.source.index.search(bounds).filter((row) => this.thinning.isDrawnRow(row));
717
719
  let visible = this.styler.prepareAll(this.featuresOf(rows));
718
720
  // A point drawn by an external renderer is not drawn by core (without a predicate it passes
@@ -722,7 +724,7 @@ export class DatasetImpl {
722
724
  }
723
725
  if (visible.length === 0)
724
726
  return;
725
- target.beginFrame(projectionData, zoom);
727
+ target.beginFrame(projectionData, zoom, undefined, factors);
726
728
  for (const feature of visible) {
727
729
  target.processFeature(feature, false);
728
730
  }
@@ -812,7 +812,7 @@ function collectPoint(feature, coord, draft, styles, isExternallyRenderedPoint)
812
812
  const style = styles.getPointStyle(feature);
813
813
  const shape = toInstancedPointShape(style.shape);
814
814
  if (!shape) {
815
- // The path that goes to immediate mode is not multiplied by the factors of zoomScale
815
+ // The immediate-mode path gets the same factors of zoomScale (DisplayBatchTarget.beginFrame)
816
816
  pushFallback(draft, feature);
817
817
  return;
818
818
  }
@@ -836,7 +836,7 @@ function collectLine(feature, coords, draft, styles, skipDraped = false) {
836
836
  if (skipDraped && strokeStyle.opacity > 0 && strokeStyle.width > 0)
837
837
  return;
838
838
  if (strokeStyle.lineStyle !== 'solid') {
839
- // The path that goes to immediate mode is not multiplied by the factors of zoomScale
839
+ // The immediate-mode path gets the same factors of zoomScale (DisplayBatchTarget.beginFrame)
840
840
  pushFallback(draft, feature);
841
841
  return;
842
842
  }
@@ -870,7 +870,7 @@ function collectPolygon(feature, rings, draft, styles, partIndex = 0, skipDraped
870
870
  if (skipDraped)
871
871
  return;
872
872
  if (hasStroke && strokeStyle.lineStyle !== 'solid') {
873
- // The path that goes to immediate mode is not multiplied by the factors of zoomScale
873
+ // The immediate-mode path gets the same factors of zoomScale (DisplayBatchTarget.beginFrame)
874
874
  pushFallback(draft, feature);
875
875
  return;
876
876
  }
@@ -39,7 +39,7 @@ export function drawSelectionHighlight(input) {
39
39
  const visible = input.selected.filter((feature) => feature.visible && boundsIntersect(getBoundingBox(feature), bounds));
40
40
  if (visible.length === 0)
41
41
  return;
42
- target.beginFrame(projectionData, zoom);
42
+ target.beginFrame(projectionData, zoom, undefined, input.factors);
43
43
  for (const feature of visible) {
44
44
  const prepared = input.prepare(feature);
45
45
  // The hand-over decision (drapedFills) switches a few frames late, so looking only at it
@@ -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));
@@ -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, terrain);
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,6 +76,7 @@ export function initRenderers(deps) {
74
76
  tentativeRenderer,
75
77
  quadShader,
76
78
  strokeRenderer,
79
+ outlineRenderer,
77
80
  pointShapeRenderer,
78
81
  pointInstanceRenderer,
79
82
  sdfLineRenderer,
@@ -95,6 +98,7 @@ export function initRenderers(deps) {
95
98
  quadShader,
96
99
  imageRenderer,
97
100
  strokeRenderer,
101
+ outlineRenderer,
98
102
  sdfLineRenderer,
99
103
  pointShapeRenderer,
100
104
  pointInstanceRenderer,
@@ -125,6 +129,7 @@ const GPU_OWNERS = [
125
129
  'textureCache',
126
130
  'quadShader',
127
131
  'strokeRenderer',
132
+ 'outlineRenderer',
128
133
  'pointShapeRenderer',
129
134
  'pointInstanceRenderer',
130
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
+ }