@sakuzu/maplibre-gl-draw 2.1.0 → 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.
Files changed (35) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/dataset/retained.js +19 -18
  3. package/dist/dataset/selection.d.ts +3 -3
  4. package/dist/dataset/selection.js +3 -7
  5. package/dist/dataset/table-source.js +13 -11
  6. package/dist/modes/select/click-handler.d.ts +4 -1
  7. package/dist/modes/select/click-handler.js +17 -9
  8. package/dist/view/layer/custom-layer.js +2 -1
  9. package/dist/view/layer/drape-planner.d.ts +3 -0
  10. package/dist/view/layer/drape-planner.js +9 -7
  11. package/dist/view/layer/frame-render.js +2 -3
  12. package/dist/view/layer/frame-state.d.ts +3 -0
  13. package/dist/view/layer/frame-state.js +2 -1
  14. package/dist/view/layer/gl-state.d.ts +2 -3
  15. package/dist/view/layer/gl-state.js +2 -3
  16. package/dist/view/layer/renderers.js +2 -1
  17. package/dist/view/renderers/line/dash.d.ts +27 -0
  18. package/dist/view/renderers/line/dash.js +23 -12
  19. package/dist/view/shaders/initializer.d.ts +3 -0
  20. package/dist/view/shaders/initializer.js +4 -2
  21. package/dist/view/terrain/anchor.js +20 -1
  22. package/dist/view/terrain/drape/bin-store.d.ts +11 -2
  23. package/dist/view/terrain/drape/bin-store.js +26 -2
  24. package/dist/view/terrain/drape/binning.d.ts +32 -0
  25. package/dist/view/terrain/drape/binning.js +15 -7
  26. package/dist/view/terrain/drape/geometry.d.ts +27 -0
  27. package/dist/view/terrain/drape/geometry.js +57 -6
  28. package/dist/view/terrain/drape/pass.d.ts +8 -3
  29. package/dist/view/terrain/drape/pass.js +39 -28
  30. package/dist/view/terrain/drape/renderer.d.ts +13 -0
  31. package/dist/view/terrain/drape/renderer.js +157 -30
  32. package/dist/view/ui/selection-ui/renderer.d.ts +2 -1
  33. package/dist/view/ui/selection-ui/renderer.js +88 -25
  34. package/dist/view/ui/selection-ui-drawer.js +4 -8
  35. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -6,6 +6,51 @@ 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
+
32
+ ## [2.1.1] - 2026-10-02
33
+
34
+ Three fixes to the lines drawn on the terrain: their thickness on a
35
+ high-DPI display, their thickness between the vertices, and dashed lines
36
+ that sank into the ground. Nothing else changes.
37
+
38
+ ### Fixed in 2.1.1
39
+
40
+ - With the terrain on, a solid line or the outline of a polygon was drawn
41
+ half as thick on a high-DPI display (a device pixel ratio of 2) as
42
+ without the terrain, when its width follows the zoom. It is now as
43
+ thick with the terrain as without it.
44
+ - With the terrain on and the camera pitched, a solid line looked like a
45
+ string of beads: it was thinner between its vertices than at them, and
46
+ the outline of a polygon was thinner along its edges. Its thickness is
47
+ now the same all along.
48
+ - With the terrain on and the camera pitched, a dashed or dotted line,
49
+ and the dashed outline of a polygon, sank into the ground on the side
50
+ towards the camera and showed a jagged edge. They are now painted on
51
+ the ground like solid lines, with the same dashes as without the
52
+ terrain.
53
+
9
54
  ## [2.1.0] - 2026-10-01
10
55
 
11
56
  The standard UI package, the documentation site with twenty examples and
@@ -1146,5 +1191,9 @@ available from Kasika, Inc.
1146
1191
  - A read-only mode and an interaction lock.
1147
1192
  - Plugins, custom modes and custom feature types.
1148
1193
 
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
1196
+ [2.1.1]: https://github.com/sakuzu/maplibre-gl-draw/compare/v2.1.0...v2.1.1
1197
+ [2.1.0]: https://github.com/sakuzu/maplibre-gl-draw/compare/v2.0.0...v2.1.0
1149
1198
  [2.0.0]: https://github.com/sakuzu/maplibre-gl-draw/compare/v1.0.0...v2.0.0
1150
1199
  [1.0.0]: https://github.com/sakuzu/maplibre-gl-draw/releases/tag/v1.0.0
@@ -324,8 +324,8 @@ export function isEmptyChunkBatches(batches) {
324
324
  * @param isExternallyRenderedPoint Predicate that returns whether a point is drawn by an external
325
325
  * renderer (every point is pushed when omitted). A point for which it is true goes neither onto
326
326
  * a batch nor into fallback
327
- * @param skipDrapedFills Do not push the solid polygons and lines (a frame where the analytic
328
- * drape draws them as ground pixels)
327
+ * @param skipDrapedFills Do not push the polygons and lines, solid or dashed (a frame where the
328
+ * analytic drape draws them as ground pixels)
329
329
  * @returns The batches that were built. When the GPU resources could not be created (the shaders
330
330
  * are not initialized) null is returned and the caller rebuilds them on the next frame
331
331
  *
@@ -739,7 +739,7 @@ export function isDrapedGeometry(feature) {
739
739
  * @internal
740
740
  */
741
741
  export function collectFeature(feature, draft, styles, isExternallyRenderedPoint, skipDrapedFills = false) {
742
- const skipSolid = skipDrapedFills && isDrapedGeometry(feature);
742
+ const skipDraped = skipDrapedFills && isDrapedGeometry(feature);
743
743
  switch (feature.type) {
744
744
  case 'Point':
745
745
  collectPoint(feature, coordinatesOf(feature), draft, styles, isExternallyRenderedPoint);
@@ -753,20 +753,20 @@ export function collectFeature(feature, draft, styles, isExternallyRenderedPoint
753
753
  return;
754
754
  case 'LineString':
755
755
  case 'Freehand':
756
- collectLine(feature, coordinatesOf(feature), draft, styles, skipSolid);
756
+ collectLine(feature, coordinatesOf(feature), draft, styles, skipDraped);
757
757
  return;
758
758
  case 'MultiLineString':
759
759
  for (const coords of coordinatesOf(feature)) {
760
- collectLine(feature, coords, draft, styles, skipSolid);
760
+ collectLine(feature, coords, draft, styles, skipDraped);
761
761
  }
762
762
  return;
763
763
  case 'Polygon':
764
- collectPolygon(feature, coordinatesOf(feature), draft, styles, 0, skipSolid);
764
+ collectPolygon(feature, coordinatesOf(feature), draft, styles, 0, skipDraped);
765
765
  return;
766
766
  case 'MultiPolygon': {
767
767
  const parts = coordinatesOf(feature);
768
768
  for (let i = 0; i < parts.length; i++) {
769
- collectPolygon(feature, parts[i], draft, styles, i, skipSolid);
769
+ collectPolygon(feature, parts[i], draft, styles, i, skipDraped);
770
770
  }
771
771
  return;
772
772
  }
@@ -827,18 +827,19 @@ function collectPoint(feature, coord, draft, styles, isExternallyRenderedPoint)
827
827
  * Pushes a line (a dashed line is sent to immediate mode because its CPU-side splitting depends
828
828
  * on the zoom)
829
829
  */
830
- function collectLine(feature, coords, draft, styles, skipSolid = false) {
830
+ function collectLine(feature, coords, draft, styles, skipDraped = false) {
831
831
  if (coords.length < 2)
832
832
  return;
833
833
  const strokeStyle = styles.getLineStringStrokeStyle(feature);
834
+ // A line, solid or dashed, is drawn by the analytic drape as ground pixels (in this frame
835
+ // only)
836
+ if (skipDraped && strokeStyle.opacity > 0 && strokeStyle.width > 0)
837
+ return;
834
838
  if (strokeStyle.lineStyle !== 'solid') {
835
839
  // The path that goes to immediate mode is not multiplied by the factors of zoomScale
836
840
  pushFallback(draft, feature);
837
841
  return;
838
842
  }
839
- // A solid line is drawn by the analytic drape as ground pixels (in this frame only)
840
- if (skipSolid && strokeStyle.opacity > 0 && strokeStyle.width > 0)
841
- return;
842
843
  const createdZoom = getCreatedZoom(feature);
843
844
  const target = createdZoom === undefined ? draft.fixedLines : draft.scaledLines;
844
845
  const item = toLineBatchItem(coords, feature.id, strokeStyle, createdZoom ?? FIXED_WIDTH_ZOOM);
@@ -856,23 +857,23 @@ function collectLine(feature, coords, draft, styles, skipSolid = false) {
856
857
  * A fill-only polygon is also pushed as an SDFPolygon without an outline (immediate mode uses
857
858
  * PolygonBatchRenderer, but the appearance of the fill is the same).
858
859
  */
859
- function collectPolygon(feature, rings, draft, styles, partIndex = 0, skipSolid = false) {
860
+ function collectPolygon(feature, rings, draft, styles, partIndex = 0, skipDraped = false) {
860
861
  const outerRing = rings[0];
861
862
  if (!outerRing || outerRing.length < 3)
862
863
  return;
863
864
  const { fillColor, strokeStyle } = styles.getPolygonStyles(feature);
864
865
  const hasStroke = strokeStyle.opacity > 0 && strokeStyle.width > 0;
866
+ if (!hasStroke && fillColor[3] <= 0)
867
+ return;
868
+ // The fill and the outline, solid or dashed, are drawn by the analytic drape as ground pixels
869
+ // (in this frame only)
870
+ if (skipDraped)
871
+ return;
865
872
  if (hasStroke && strokeStyle.lineStyle !== 'solid') {
866
873
  // The path that goes to immediate mode is not multiplied by the factors of zoomScale
867
874
  pushFallback(draft, feature);
868
875
  return;
869
876
  }
870
- if (!hasStroke && fillColor[3] <= 0)
871
- return;
872
- // The solid fill and outline are drawn by the analytic drape as ground pixels (in this frame
873
- // only)
874
- if (skipSolid)
875
- return;
876
877
  const createdZoom = getCreatedZoom(feature);
877
878
  const target = createdZoom === undefined ? draft.fixedPolygons : draft.scaledPolygons;
878
879
  // A fixed line width is declared by the negative convention (the same reason as collectLine).
@@ -6,8 +6,8 @@ import type { RetainedStyleResolver } from '../view/renderers/retained.js';
6
6
  * It is paired with `collectDataset` in `view/terrain/drape/pass.ts`. Changing only one of
7
7
  * them would show the selection highlight twice or in neither of them.
8
8
  *
9
- * A dashed outline does not go onto the drape (solving the accumulated distance along the outline
10
- * from the pixels is not implemented, so drawing it as a solid line would change the appearance).
11
- * For what does not go onto it, the immediate-mode path draws the highlight as before.
9
+ * Dashed lines and dashed outlines go onto the drape like solid ones (the drape lays the pattern
10
+ * out along the path). For what does not go onto it, the immediate-mode path draws the highlight
11
+ * as before.
12
12
  */
13
13
  export declare function isHandedToDrape(feature: Feature, styles: RetainedStyleResolver): boolean;
@@ -150,24 +150,20 @@ export function sameFeatureIds(a, b) {
150
150
  * It is paired with `collectDataset` in `view/terrain/drape/pass.ts`. Changing only one of
151
151
  * them would show the selection highlight twice or in neither of them.
152
152
  *
153
- * A dashed outline does not go onto the drape (solving the accumulated distance along the outline
154
- * from the pixels is not implemented, so drawing it as a solid line would change the appearance).
155
- * For what does not go onto it, the immediate-mode path draws the highlight as before.
153
+ * Dashed lines and dashed outlines go onto the drape like solid ones (the drape lays the pattern
154
+ * out along the path). For what does not go onto it, the immediate-mode path draws the highlight
155
+ * as before.
156
156
  */
157
157
  export function isHandedToDrape(feature, styles) {
158
158
  if (feature.type === 'Polygon' || feature.type === 'MultiPolygon') {
159
159
  const { fillColor, strokeStyle } = styles.getPolygonStyles(feature);
160
160
  const hasStroke = strokeStyle.opacity > 0 && strokeStyle.width > 0;
161
- if (hasStroke && strokeStyle.lineStyle !== 'solid')
162
- return false;
163
161
  return hasStroke || fillColor[3] > 0;
164
162
  }
165
163
  if (feature.type === 'LineString' ||
166
164
  feature.type === 'MultiLineString' ||
167
165
  feature.type === 'Freehand') {
168
166
  const strokeStyle = styles.getLineStringStrokeStyle(feature);
169
- if (strokeStyle.lineStyle !== 'solid')
170
- return false;
171
167
  return strokeStyle.opacity > 0 && strokeStyle.width > 0;
172
168
  }
173
169
  return false;
@@ -329,7 +329,8 @@ export class TableSource {
329
329
  }
330
330
  collector(rows, context) {
331
331
  const table = this.table;
332
- // Whether the solid lines and polygons of each child are left to the analytic drape
332
+ // Whether the lines and polygons (solid or dashed) of each child are left to the analytic
333
+ // drape
333
334
  const skipDraped = table.children.map((column) => context.skipDrapedFills === true && isDraped(column.type));
334
335
  const createdZoom = table.columns[CREATED_ZOOM_COLUMN];
335
336
  let next = 0;
@@ -497,18 +498,19 @@ class RowCollector {
497
498
  * A line over the coordinates `[start, end)` of the geometry column of the row (a dashed line
498
499
  * goes to immediate mode)
499
500
  */
500
- line(row, column, start, end, createdZoom, skipSolid) {
501
+ line(row, column, start, end, createdZoom, skipDraped) {
501
502
  if (end - start < 2)
502
503
  return;
503
504
  const slot = this.slots.line(column.type, this.slots.ruleColorOf(row));
504
505
  const stroke = slot.stroke;
506
+ // A line, solid or dashed, is drawn by the analytic drape as ground pixels (in this frame
507
+ // only)
508
+ if (skipDraped && stroke.opacity > 0 && stroke.width > 0)
509
+ return;
505
510
  if (stroke.lineStyle !== 'solid') {
506
511
  this.fallback(row);
507
512
  return;
508
513
  }
509
- // A solid line is drawn by the analytic drape as ground pixels (in this frame only)
510
- if (skipSolid && stroke.opacity > 0 && stroke.width > 0)
511
- return;
512
514
  const draft = this.draft;
513
515
  const fixed = createdZoom === undefined;
514
516
  let builder = fixed ? draft.packedFixedLines : draft.packedScaledLines;
@@ -528,21 +530,21 @@ class RowCollector {
528
530
  *
529
531
  * @param ringOffsets The offsets from a ring to its coordinates
530
532
  */
531
- polygon(row, column, ringStart, ringEnd, ringOffsets, partIndex, createdZoom, skipSolid) {
533
+ polygon(row, column, ringStart, ringEnd, ringOffsets, partIndex, createdZoom, skipDraped) {
532
534
  if (ringEnd <= ringStart)
533
535
  return;
534
536
  if (ringOffsets[ringStart + 1] - ringOffsets[ringStart] < 3)
535
537
  return;
536
538
  const slot = this.slots.polygon(column.type, this.slots.ruleColorOf(row));
539
+ if (!slot.hasStroke && slot.fillColor[3] <= 0)
540
+ return;
541
+ // The fill and the outline, solid or dashed, are drawn by the analytic drape as ground pixels
542
+ if (skipDraped)
543
+ return;
537
544
  if (slot.hasStroke && slot.strokeStyle.lineStyle !== 'solid') {
538
545
  this.fallback(row);
539
546
  return;
540
547
  }
541
- if (!slot.hasStroke && slot.fillColor[3] <= 0)
542
- return;
543
- // The solid fill and outline are drawn by the analytic drape as ground pixels
544
- if (skipSolid)
545
- return;
546
548
  const rings = new Array(ringEnd - ringStart);
547
549
  for (let r = ringStart; r < ringEnd; r++) {
548
550
  rings[r - ringStart] = column.positions(ringOffsets[r], ringOffsets[r + 1]);
@@ -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
  }
@@ -1,6 +1,6 @@
1
1
  // SPDX-FileCopyrightText: 2026 SAKAIDA Atsushi
2
2
  // SPDX-License-Identifier: AGPL-3.0-only
3
- import { resolvePixelRatio } from '../../shared/utils/pixel-ratio.js';
3
+ import { resolveContentPixelRatio, resolvePixelRatio } from '../../shared/utils/pixel-ratio.js';
4
4
  import { updateGlobeSubdivision } from '../globe-subdivision.js';
5
5
  import { beginRenderFrame } from '../shaders/frame.js';
6
6
  import { installAnchorProjector, uninstallAnchorProjector } from '../terrain/anchor.js';
@@ -350,6 +350,7 @@ export function createCustomLayer(deps) {
350
350
  zoom,
351
351
  rawZoom,
352
352
  dpr,
353
+ contentDpr: resolveContentPixelRatio(pixelRatio),
353
354
  terrainState,
354
355
  retainedAvailable: engine.storeRetainedCache !== null,
355
356
  overlayRenderers: dynamicOverlayRenderers,
@@ -119,7 +119,10 @@ export interface DrapeFrameInput {
119
119
  resolveStyles: () => RetainedStyleResolver;
120
120
  /** The terrain of the map (null when the terrain state of the frame is inactive) */
121
121
  mapTerrain: MapTerrain;
122
+ /** The rendering pixel ratio, for widths fixed in screen pixels (the rendering scale included) */
122
123
  dpr: number;
124
+ /** The rendering pixel ratio for widths that follow the zoom (the rendering scale not applied) */
125
+ contentDpr: number;
123
126
  /** The style zoom (what the rendering reads) */
124
127
  zoom: number;
125
128
  /** The real zoom of the camera (the band is decided with it) */
@@ -281,7 +281,7 @@ export class DrapePlanner {
281
281
  */
282
282
  planFrame(input) {
283
283
  const { terrain } = this.deps;
284
- const { mapTerrain, dpr, zoom, rawZoom } = input;
284
+ const { mapTerrain, dpr, contentDpr, zoom, rawZoom } = input;
285
285
  let drapeUsable = false;
286
286
  this.pendingWork = false;
287
287
  // The analytic drape is used even in a wide band when building the index is light. Cutting
@@ -297,10 +297,10 @@ export class DrapePlanner {
297
297
  // cost has to be known first). The collected result is folded behind a key, so there is no
298
298
  // cost as long as the contents do not change
299
299
  if (mapTerrain !== null)
300
- this.collect(input.resolveStyles, dpr, zoom);
300
+ this.collect(input.resolveStyles, dpr, contentDpr, zoom);
301
301
  const analyticBand = rawZoom > TERRAIN_ANALYTIC_MIN_ZOOM || this.lightEnough;
302
302
  if (mapTerrain !== null && analyticBand) {
303
- drapeUsable = this.prepare(input.gl, mapTerrain, input.resolveStyles, dpr, zoom);
303
+ drapeUsable = this.prepare(input.gl, mapTerrain, input.resolveStyles, dpr, contentDpr, zoom);
304
304
  }
305
305
  else if (mapTerrain !== null) {
306
306
  // A wide band is handled by the vertex displacement path (no index is built either)
@@ -447,6 +447,7 @@ export class DrapePlanner {
447
447
  cellOffset: item.plan.cellOffset,
448
448
  grid: item.plan.grid,
449
449
  groundMeters: item.plan.groundMeters,
450
+ tileZ: item.plan.tile.z,
450
451
  edges,
451
452
  },
452
453
  projection: item.projection,
@@ -500,12 +501,13 @@ export class DrapePlanner {
500
501
  * "polygons are not drawn at low zooms"). The collected result is folded behind a key, so from
501
502
  * the second time on nothing happens as long as the contents do not change.
502
503
  */
503
- collect(resolveStyles, dpr, zoom) {
504
+ collect(resolveStyles, dpr, contentDpr, zoom) {
504
505
  const { store, datasets: manager } = this.deps;
505
506
  const datasets = manager?.listInternal() ?? [];
506
507
  const key = [
507
508
  this.revision,
508
509
  dpr,
510
+ contentDpr,
509
511
  marginZoom(zoom),
510
512
  store.getLayerOrder().join('/'),
511
513
  datasets.map((c) => `${c.id}:${c.drapeRevision}:${c.visible ? 1 : 0}:${c.order}`).join(','),
@@ -513,7 +515,7 @@ export class DrapePlanner {
513
515
  ].join('|');
514
516
  let collected = this.collected;
515
517
  if (key !== this.collectKey || !collected) {
516
- collected = collectDrapeElements(store, resolveStyles(), dpr, datasets);
518
+ collected = collectDrapeElements(store, resolveStyles(), dpr, contentDpr, datasets);
517
519
  this.collectKey = key;
518
520
  this.collected = collected;
519
521
  this.lightEnough = collected.edgeCount <= DRAPE_WIDE_EDGE_BUDGET;
@@ -529,14 +531,14 @@ export class DrapePlanner {
529
531
  * not collected again just because the view moved (collecting them again would mean rebuilding
530
532
  * the whole index).
531
533
  */
532
- prepare(gl, terrain, resolveStyles, dpr, zoom) {
534
+ prepare(gl, terrain, resolveStyles, dpr, contentDpr, zoom) {
533
535
  const { map, datasets: manager } = this.deps;
534
536
  const context = this.deps.terrain;
535
537
  const tiles = getRenderableTerrainTiles(terrain);
536
538
  if (tiles.length === 0)
537
539
  return false;
538
540
  const datasets = manager?.listInternal() ?? [];
539
- const collected = this.collect(resolveStyles, dpr, zoom);
541
+ const collected = this.collect(resolveStyles, dpr, contentDpr, zoom);
540
542
  // The factors per source (which follow the zoom) are rewritten every frame, because baking
541
543
  // them into the elements would mean rebuilding the index on every zoom movement
542
544
  const drapeSourceFactors = this.sourceFactors;
@@ -107,9 +107,8 @@ function drawDrapeSegment(deps, f, index) {
107
107
  drawSegment(paintCursor, to, last);
108
108
  }
109
109
  f.restoreBlendState();
110
- // Back to the depth state of the layer rendering. What the drape could not paint (dashed
111
- // lines, polygons with a dashed outline, the geometry being drawn, the features of
112
- // extensions) is drawn next, by the vertex displacement path, and the terrain must hide it
110
+ // Back to the depth state of the layer rendering. What the drape could not paint (the
111
+ // geometry being drawn, the features of extensions) is drawn next, by the vertex displacement path, and the terrain must hide it
113
112
  // as it does in a frame without the drape. Symbols switch the depth test off themselves.
114
113
  applySegmentDepthState(gl, deps.terrainContext, f);
115
114
  }
@@ -111,7 +111,10 @@ export interface FrameBuildInput {
111
111
  zoom: number;
112
112
  /** The real zoom of the camera */
113
113
  rawZoom: number;
114
+ /** The rendering pixel ratio, for sizes fixed in screen pixels (the rendering scale included) */
114
115
  dpr: number;
116
+ /** The rendering pixel ratio for sizes that follow the zoom (the rendering scale not applied) */
117
+ contentDpr: number;
115
118
  /** The terrain state of the frame (already set on the terrain context) */
116
119
  terrainState: TerrainRenderState;
117
120
  /** Whether the retained batches of the Store rendering exist */
@@ -139,7 +139,7 @@ function sameCameraPosition(a, b) {
139
139
  */
140
140
  export function buildFrameState(input) {
141
141
  const { gl, renderers: r, map, store, terrainContext, drape, renderOptions } = input;
142
- const { zoom, rawZoom, dpr, terrainState } = input;
142
+ const { zoom, rawZoom, dpr, contentDpr, terrainState } = input;
143
143
  const { shaderData, defaultProjectionData } = renderOptions;
144
144
  const mapTerrain = terrainState.active ? getMapTerrain(map) : null;
145
145
  // Shader initialization
@@ -193,6 +193,7 @@ export function buildFrameState(input) {
193
193
  resolveStyles: () => r.batchManager.getRetainedRenderers()?.styles ?? r.featureDrawer,
194
194
  mapTerrain,
195
195
  dpr,
196
+ contentDpr,
196
197
  zoom,
197
198
  rawZoom,
198
199
  });
@@ -34,9 +34,8 @@ export declare function applySegmentGlState(gl: WebGL2RenderingContext, terrainC
34
34
  * `applySegmentGlState` sets it at the start of a slot, and the drape of the segment, which
35
35
  * changes the depth function and the offset, hands it back with this before the layers are
36
36
  * drawn. The layers are therefore drawn in one depth state whether or not the drape was used.
37
- * What the drape cannot paint (dashed lines, polygons with a dashed outline, the geometry being
38
- * drawn, the features of extensions, the datasets not handed over to it) is
39
- * drawn after it by the vertex displacement path and must be hidden by the terrain in the same
37
+ * What the drape cannot paint (the geometry being drawn, the features of extensions, the datasets
38
+ * not handed over to it) is drawn after it by the vertex displacement path and must be hidden by the terrain in the same
40
39
  * way. Symbols switch the depth test off themselves (`drawBillboardsWithoutDepth`).
41
40
  */
42
41
  export declare function applySegmentDepthState(gl: WebGL2RenderingContext, terrainContext: TerrainContext, f: Pick<SegmentGlFrame, 'terrainState' | 'wideFallback'>): void;
@@ -33,9 +33,8 @@ export function applySegmentGlState(gl, terrainContext, f) {
33
33
  * `applySegmentGlState` sets it at the start of a slot, and the drape of the segment, which
34
34
  * changes the depth function and the offset, hands it back with this before the layers are
35
35
  * drawn. The layers are therefore drawn in one depth state whether or not the drape was used.
36
- * What the drape cannot paint (dashed lines, polygons with a dashed outline, the geometry being
37
- * drawn, the features of extensions, the datasets not handed over to it) is
38
- * drawn after it by the vertex displacement path and must be hidden by the terrain in the same
36
+ * What the drape cannot paint (the geometry being drawn, the features of extensions, the datasets
37
+ * not handed over to it) is drawn after it by the vertex displacement path and must be hidden by the terrain in the same
39
38
  * way. Symbols switch the depth test off themselves (`drawBillboardsWithoutDepth`).
40
39
  */
41
40
  export function applySegmentDepthState(gl, terrainContext, f) {
@@ -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,
@@ -18,6 +18,33 @@ export interface DashSegment {
18
18
  * @returns The dashes, in order along the line. An empty array for fewer than 2 positions
19
19
  */
20
20
  export declare function splitIntoDashes(coords: Coordinate[], dashLength: number, gapLength: number, zoom: number): DashSegment[];
21
+ /**
22
+ * The coefficients of a dash pattern
23
+ *
24
+ * The dash is `max(a, b * w)` and the gap `max(w + 1, c * w)` for a line `w` pixels wide (the
25
+ * smallest gap, `w + 1`, keeps the round caps of two dashes apart). This table is the one
26
+ * source of the patterns: the subdividing path computes them here on the CPU, and the analytic
27
+ * drape hands the same coefficients to its shader, which evaluates the same two formulas.
28
+ */
29
+ export interface DashCoefficients {
30
+ /** The shortest dash (px) */
31
+ readonly a: number;
32
+ /** The dash per pixel of width */
33
+ readonly b: number;
34
+ /** The gap per pixel of width */
35
+ readonly c: number;
36
+ }
37
+ /**
38
+ * The coefficients of each line style that is not solid
39
+ *
40
+ * Dashed: dashes 4 times the width (12 px at least) and gaps twice the width. Dotted: short,
41
+ * nearly round dots and gaps about the width.
42
+ */
43
+ export declare const DASH_COEFFICIENTS: Readonly<Record<'dashed' | 'dotted', DashCoefficients>>;
44
+ /**
45
+ * The coefficients of a line style, or null for a solid line
46
+ */
47
+ export declare function dashCoefficientsOf(lineStyle: 'solid' | 'dashed' | 'dotted'): DashCoefficients | null;
21
48
  /**
22
49
  * Returns the dash and gap lengths in px that suit a line style at a line width.
23
50
  *
@@ -126,6 +126,22 @@ export function splitIntoDashes(coords, dashLength, gapLength, zoom) {
126
126
  }
127
127
  return segments;
128
128
  }
129
+ /**
130
+ * The coefficients of each line style that is not solid
131
+ *
132
+ * Dashed: dashes 4 times the width (12 px at least) and gaps twice the width. Dotted: short,
133
+ * nearly round dots and gaps about the width.
134
+ */
135
+ export const DASH_COEFFICIENTS = {
136
+ dashed: { a: 12, b: 4, c: 2 },
137
+ dotted: { a: 1, b: 0.5, c: 1.2 },
138
+ };
139
+ /**
140
+ * The coefficients of a line style, or null for a solid line
141
+ */
142
+ export function dashCoefficientsOf(lineStyle) {
143
+ return lineStyle === 'dashed' || lineStyle === 'dotted' ? DASH_COEFFICIENTS[lineStyle] : null;
144
+ }
129
145
  /**
130
146
  * Returns the dash and gap lengths in px that suit a line style at a line width.
131
147
  *
@@ -137,16 +153,11 @@ export function splitIntoDashes(coords, dashLength, gapLength, zoom) {
137
153
  * @returns [dashLength, gapLength], or null (for a solid line)
138
154
  */
139
155
  export function getStrokeDashPattern(lineStyle, strokeWidth = 2) {
140
- // The minimum gap at which round caps do not overlap
141
- const minGap = strokeWidth + 1;
142
- switch (lineStyle) {
143
- case 'dashed':
144
- // Dashed: longer dashes (4x the line width), moderate gaps (2x the line width)
145
- return [Math.max(12, strokeWidth * 4), Math.max(minGap, strokeWidth * 2)];
146
- case 'dotted':
147
- // Dotted: short dots (nearly round), short gaps (about the line width)
148
- return [Math.max(1, strokeWidth * 0.5), Math.max(minGap, strokeWidth * 1.2)];
149
- default:
150
- return null;
151
- }
156
+ const coefficients = dashCoefficientsOf(lineStyle);
157
+ if (!coefficients)
158
+ return null;
159
+ return [
160
+ Math.max(coefficients.a, coefficients.b * strokeWidth),
161
+ Math.max(strokeWidth + 1, coefficients.c * strokeWidth),
162
+ ];
152
163
  }
@@ -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