@sakuzu/maplibre-gl-draw 2.1.3 → 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,33 @@ 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
+
9
36
  ## [2.1.3] - 2026-10-04
10
37
 
11
38
  Two fixes: the snap indicator that stayed after its input, and the
@@ -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
@@ -6,7 +6,7 @@ import { getCircleRadius, getCreatedZoom } from '../../shared/utils/property.js'
6
6
  import { densifyPathForGlobe } from '../globe-subdivision.js';
7
7
  import { TerrainContext } from '../terrain/context.js';
8
8
  import { anchorGhostOpacity } from '../terrain/occlusion.js';
9
- import { layerDrawFactors, NEUTRAL_DRAW_FACTORS, } from './draw-factors.js';
9
+ import { combineDrawFactors, layerDrawFactors, NEUTRAL_DRAW_FACTORS, } from './draw-factors.js';
10
10
  import { getStrokeDashPattern, splitIntoDashes } from './line/dash.js';
11
11
  import { toInstancedPointShape, } from './point/point-instance.js';
12
12
  /**
@@ -310,12 +310,15 @@ export class BatchManager {
310
310
  *
311
311
  * @param layer The layer to draw (used to evaluate the style rules, and its opacity is
312
312
  * multiplied into everything the frame draws; no factor when omitted)
313
+ * @param factors The draw factors of the frame itself (a dataset passes those of its
314
+ * `zoomScale`). They are combined with the factors of the layer and multiplied into
315
+ * everything the frame draws, the same as on the retained path; no factor when omitted
313
316
  */
314
- beginFrame(projectionData, zoom, layer) {
317
+ beginFrame(projectionData, zoom, layer, factors = NEUTRAL_DRAW_FACTORS) {
315
318
  this.projectionData = projectionData;
316
319
  this.zoom = zoom;
317
320
  this.layer = layer;
318
- this.factors = layerDrawFactors(layer);
321
+ this.factors = combineDrawFactors(layerDrawFactors(layer), factors);
319
322
  this.currentBatchType = null;
320
323
  }
321
324
  /**
@@ -408,7 +411,7 @@ export class BatchManager {
408
411
  }
409
412
  // Shapes that do not support instancing are drawn individually, one point at a time
410
413
  // (MultiPoint comes into this function part by part, so they are drawn per point)
411
- this.featureDrawer.drawPointShape(coord, this.withLayerOpacity(style), this.zoom, feature.id);
414
+ this.featureDrawer.drawPointShape(coord, this.withFrameFactors(style), this.zoom, feature.id);
412
415
  }
413
416
  else {
414
417
  // Color, size and stroke are instance attributes, so the batch key only needs the shape
@@ -474,8 +477,9 @@ export class BatchManager {
474
477
  // the look does not change).
475
478
  const fixedWidth = getCreatedZoom(feature) === undefined;
476
479
  const createdZoom = getCreatedZoom(feature) ?? this.zoom;
477
- // Compute the effective line width at the current zoom level
478
- const effectiveStrokeWidth = strokeStyle.width * 2 ** (this.zoom - createdZoom);
480
+ // Compute the effective line width at the current zoom level (with the size factor of the
481
+ // frame, which the shader multiplies into the width, so the dashes follow the drawn width)
482
+ const effectiveStrokeWidth = strokeStyle.width * 2 ** (this.zoom - createdZoom) * this.factors.scale;
479
483
  // For dashed/dotted lines the dashes are computed on the CPU side
480
484
  const dashPattern = getStrokeDashPattern(strokeStyle.lineStyle, effectiveStrokeWidth);
481
485
  if (dashPattern) {
@@ -588,7 +592,8 @@ export class BatchManager {
588
592
  // convention as withFixedWidth; collectPolygons in retained mode does the same).
589
593
  const fixedWidth = getCreatedZoom(feature) === undefined;
590
594
  const createdZoom = getCreatedZoom(feature) ?? this.zoom;
591
- const effectiveStrokeWidth = strokeStyle.width * 2 ** (this.zoom - createdZoom);
595
+ // The drawn width of the outline (with the size factor of the frame; the dashes follow it)
596
+ const effectiveStrokeWidth = strokeStyle.width * 2 ** (this.zoom - createdZoom) * this.factors.scale;
592
597
  const isDashed = strokeStyle.lineStyle !== 'solid';
593
598
  if (strokeStyle.opacity > 0 && !isDashed) {
594
599
  // For a solid line: drawn all at once by SDFPolygonRenderer
@@ -618,7 +623,7 @@ export class BatchManager {
618
623
  if (fillColor[3] > 0) {
619
624
  this.polygonFillBatch.push({
620
625
  coordinates: rings,
621
- color: this.colorWithLayerOpacity(fillColor),
626
+ color: this.colorWithFrameOpacity(fillColor),
622
627
  featureId: feature.id,
623
628
  partIndex,
624
629
  });
@@ -634,7 +639,7 @@ export class BatchManager {
634
639
  if (fillColor[3] > 0) {
635
640
  this.polygonFillBatch.push({
636
641
  coordinates: rings,
637
- color: this.colorWithLayerOpacity(fillColor),
642
+ color: this.colorWithFrameOpacity(fillColor),
638
643
  featureId,
639
644
  partIndex,
640
645
  });
@@ -678,23 +683,26 @@ export class BatchManager {
678
683
  }
679
684
  }
680
685
  /**
681
- * A color with the opacity of the layer multiplied into its alpha (for the fill-only polygon
682
- * batch, whose colors are per vertex and which has no factor uniform)
686
+ * A color with the opacity factor of the frame multiplied into its alpha (for the fill-only
687
+ * polygon batch, whose colors are per vertex and which has no factor uniform)
683
688
  */
684
- colorWithLayerOpacity(color) {
689
+ colorWithFrameOpacity(color) {
685
690
  const opacity = this.factors.opacity;
686
691
  return opacity === 1 ? color : [color[0], color[1], color[2], color[3] * opacity];
687
692
  }
688
693
  /**
689
- * A point style with the opacity of the layer multiplied in (for the per-point renderer, which
690
- * has no factor uniform)
694
+ * A point style with the factors of the frame multiplied in (for the per-point renderer, which
695
+ * has no factor uniform): the size and the stroke width by the size factor, the opacities by
696
+ * the opacity factor
691
697
  */
692
- withLayerOpacity(style) {
693
- const opacity = this.factors.opacity;
694
- if (opacity === 1)
698
+ withFrameFactors(style) {
699
+ const { scale, opacity } = this.factors;
700
+ if (scale === 1 && opacity === 1)
695
701
  return style;
696
702
  return {
697
703
  ...style,
704
+ size: style.size * scale,
705
+ strokeWidth: style.strokeWidth * scale,
698
706
  fillOpacity: style.fillOpacity * opacity,
699
707
  strokeOpacity: style.strokeOpacity * opacity,
700
708
  };
@@ -10,8 +10,11 @@
10
10
  * Conventions:
11
11
  *
12
12
  * - The default is `NEUTRAL_DRAW_FACTORS` (1x scale, opacity 1), so calls that
13
- * pass no factors (Store rendering, immediate mode) look exactly as before,
13
+ * pass no factors (the Store rendering, for example) look exactly as before,
14
14
  * down to the pixel
15
+ * - The immediate-mode frame of a dataset takes its factors through
16
+ * `BatchManager.beginFrame`, which combines them with the factors of the layer
17
+ * (`combineDrawFactors`)
15
18
  * - A uniform is per-program residual state, so even rendering paths that do
16
19
  * not use the factors must write 1 (a value left over from a previous draw
17
20
  * makes another draw come out thinner; this is the same family of accident as
@@ -58,3 +61,10 @@ export declare function sanitizeDrawFactors(factors: {
58
61
  export declare function layerDrawFactors(layer: {
59
62
  readonly opacity?: number;
60
63
  } | null | undefined): RetainedDrawFactors;
64
+ /**
65
+ * The product of two sets of factors (the frame's own factors over those of the layer)
66
+ *
67
+ * Both are taken as already rounded (`sanitizeDrawFactors`, `layerDrawFactors`). Neutral
68
+ * factors on either side give the other set itself.
69
+ */
70
+ export declare function combineDrawFactors(a: RetainedDrawFactors, b: RetainedDrawFactors): RetainedDrawFactors;
@@ -33,3 +33,16 @@ export function layerDrawFactors(layer) {
33
33
  return NEUTRAL_DRAW_FACTORS;
34
34
  return sanitizeDrawFactors({ scale: 1, opacity });
35
35
  }
36
+ /**
37
+ * The product of two sets of factors (the frame's own factors over those of the layer)
38
+ *
39
+ * Both are taken as already rounded (`sanitizeDrawFactors`, `layerDrawFactors`). Neutral
40
+ * factors on either side give the other set itself.
41
+ */
42
+ export function combineDrawFactors(a, b) {
43
+ if (b === NEUTRAL_DRAW_FACTORS || (b.scale === 1 && b.opacity === 1))
44
+ return a;
45
+ if (a === NEUTRAL_DRAW_FACTORS || (a.scale === 1 && a.opacity === 1))
46
+ return b;
47
+ return { scale: a.scale * b.scale, opacity: a.opacity * b.opacity };
48
+ }
@@ -254,6 +254,11 @@ void main() {
254
254
  float lineWidth = (strokeWidth > 0.0
255
255
  ? strokeWidth * pow(2.0, u_zoom - createdZoom)
256
256
  : (strokeWidth < 0.0 ? -strokeWidth : u_width)) * u_size_scale;
257
+ // Below one pixel the line is drawn one pixel wide and fainter by its width, so the ink per
258
+ // unit of length follows the width and goes to nothing with it (the caps of every dash
259
+ // included, which would otherwise keep a blob of a fixed size)
260
+ float ink = clamp(lineWidth, 0.0, 1.0);
261
+ lineWidth = max(lineWidth, 1.0);
257
262
  float halfWidth = lineWidth / 2.0;
258
263
 
259
264
  // HHAA: expand by w+1 so that partially covered pixels are included too
@@ -298,7 +303,7 @@ void main() {
298
303
  v_linesofar = segmentStart + t * screenSegmentLength; // cumulative distance (for dashes)
299
304
  v_line_pos = linePos; // position along the line
300
305
  v_seg_length = screenSegmentLength; // segment length (computed on the GPU)
301
- v_color = a_color; // line color (instance attribute)
306
+ v_color = a_color * ink; // line color (instance attribute), by the ink
302
307
  }`;
303
308
  }
304
309
  /**
@@ -55,6 +55,15 @@ export interface PointInstanceData {
55
55
  * Cutting the billboard exactly at the outer radius loses the outer half of the antialiasing.
56
56
  */
57
57
  export declare const POINT_AA_PADDING_PX = 1;
58
+ /**
59
+ * The smallest outer radius a point is drawn at (device pixels): a diameter of one pixel
60
+ *
61
+ * A smaller point cannot be drawn smaller, since the antialiasing band alone is a pixel wide: its
62
+ * coverage would tend to a blob of a fixed size as the radius goes to 0, and the point would never
63
+ * fade. Below this radius the shape is drawn at it, fainter by the ratio of the areas
64
+ * (`pointInkFactor`), so the ink it leaves follows its area and goes to nothing with it.
65
+ */
66
+ export declare const POINT_MIN_OUTER_PX = 0.5;
58
67
  /**
59
68
  * SDF boundaries of one instance (device pixels)
60
69
  */
@@ -85,6 +94,16 @@ export interface PointSdfEdges {
85
94
  * @param sizeScale Size factor at draw time (default 1)
86
95
  */
87
96
  export declare function pointSdfEdges(fillSizePx: number, strokeWidthPx: number, sizeScale?: number): PointSdfEdges;
97
+ /**
98
+ * The factor the alpha of a point is multiplied by (the same computation as the shader)
99
+ *
100
+ * 1 at an outer radius of `POINT_MIN_OUTER_PX` and above. Below it the point is drawn at that
101
+ * radius (`pointSdfEdges`), and the factor is the ratio of its true area to the area drawn, so the
102
+ * ink it leaves follows its area and goes to 0 with it. A radius of 0 or below draws nothing (0).
103
+ *
104
+ * @param outerPx The outer radius (radius + stroke width, with the size factor; device pixels)
105
+ */
106
+ export declare function pointInkFactor(outerPx: number): number;
88
107
  /**
89
108
  * Point batch of the retained mode (GPU resources)
90
109
  *
@@ -34,6 +34,15 @@ const AA_HALF_WIDTH_PX = 0.5;
34
34
  * Cutting the billboard exactly at the outer radius loses the outer half of the antialiasing.
35
35
  */
36
36
  export const POINT_AA_PADDING_PX = 1;
37
+ /**
38
+ * The smallest outer radius a point is drawn at (device pixels): a diameter of one pixel
39
+ *
40
+ * A smaller point cannot be drawn smaller, since the antialiasing band alone is a pixel wide: its
41
+ * coverage would tend to a blob of a fixed size as the radius goes to 0, and the point would never
42
+ * fade. Below this radius the shape is drawn at it, fainter by the ratio of the areas
43
+ * (`pointInkFactor`), so the ink it leaves follows its area and goes to nothing with it.
44
+ */
45
+ export const POINT_MIN_OUTER_PX = 0.5;
37
46
  // Stride of the instance data (floats per instance)
38
47
  // position(2) + fillColor(4) + strokeColor(4) + size(1) + strokeWidth(1)
39
48
  // + elevation(1) = 13 floats
@@ -58,8 +67,34 @@ const SHAPE_UNIFORM = POINT_SHAPE_CODE;
58
67
  export function pointSdfEdges(fillSizePx, strokeWidthPx, sizeScale = 1) {
59
68
  const radius = Math.max(0, fillSizePx) * sizeScale;
60
69
  const outer = radius + Math.max(0, strokeWidthPx) * sizeScale;
70
+ // Below a diameter of one pixel the shape is drawn at that diameter (pointInkFactor)
71
+ if (outer > 0 && outer < POINT_MIN_OUTER_PX) {
72
+ const grow = POINT_MIN_OUTER_PX / outer;
73
+ return {
74
+ radius: radius * grow,
75
+ outer: POINT_MIN_OUTER_PX,
76
+ extent: POINT_MIN_OUTER_PX + POINT_AA_PADDING_PX,
77
+ };
78
+ }
61
79
  return { radius, outer, extent: outer + POINT_AA_PADDING_PX };
62
80
  }
81
+ /**
82
+ * The factor the alpha of a point is multiplied by (the same computation as the shader)
83
+ *
84
+ * 1 at an outer radius of `POINT_MIN_OUTER_PX` and above. Below it the point is drawn at that
85
+ * radius (`pointSdfEdges`), and the factor is the ratio of its true area to the area drawn, so the
86
+ * ink it leaves follows its area and goes to 0 with it. A radius of 0 or below draws nothing (0).
87
+ *
88
+ * @param outerPx The outer radius (radius + stroke width, with the size factor; device pixels)
89
+ */
90
+ export function pointInkFactor(outerPx) {
91
+ if (!(outerPx > 0))
92
+ return 0;
93
+ if (outerPx >= POINT_MIN_OUTER_PX)
94
+ return 1;
95
+ const ratio = outerPx / POINT_MIN_OUTER_PX;
96
+ return ratio * ratio;
97
+ }
63
98
  /**
64
99
  * Rounds the origin of the relative coordinates to Float32
65
100
  */
@@ -326,6 +361,7 @@ flat out vec4 v_stroke_color;
326
361
  out vec2 v_offset_px;
327
362
  flat out float v_radius_px;
328
363
  flat out float v_outer_px;
364
+ flat out float v_ink;
329
365
 
330
366
  void main() {
331
367
  // The anchor elevation comes from the CPU (the same source as hit testing).
@@ -338,6 +374,15 @@ void main() {
338
374
 
339
375
  float radius = max(a_instance_size, 0.0) * u_size_scale;
340
376
  float outer = radius + max(a_instance_stroke_width, 0.0) * u_size_scale;
377
+ // Below a diameter of one pixel the shape is drawn at that diameter, fainter by the ratio of
378
+ // the areas, so the ink it leaves follows its area (pointSdfEdges, pointInkFactor)
379
+ v_ink = 1.0;
380
+ if (outer > 0.0 && outer < ${POINT_MIN_OUTER_PX.toFixed(1)}) {
381
+ float grow = ${POINT_MIN_OUTER_PX.toFixed(1)} / outer;
382
+ v_ink = 1.0 / (grow * grow);
383
+ radius *= grow;
384
+ outer = ${POINT_MIN_OUTER_PX.toFixed(1)};
385
+ }
341
386
  // The billboard is sized as the outer radius plus the antialiasing padding
342
387
  // (so that the edge is not clipped)
343
388
  vec2 offsetPx = a_quad * (outer + ${POINT_AA_PADDING_PX.toFixed(1)});
@@ -357,6 +402,7 @@ flat in vec4 v_stroke_color;
357
402
  in vec2 v_offset_px;
358
403
  flat in float v_radius_px;
359
404
  flat in float v_outer_px;
405
+ flat in float v_ink; // the factor of a point below one pixel (1 otherwise)
360
406
  uniform float u_opacity; // opacity factor at draw time (default 1)
361
407
  uniform float u_shape; // shape (0 = circle, 1 = square, 2 = triangle, 3 = star)
362
408
  out vec4 fragColor;
@@ -384,7 +430,7 @@ void main() {
384
430
  vec4 color = mix(v_stroke_color, v_fill_color, fillMix);
385
431
 
386
432
  // Blending is non-premultiplied, so the factors are applied to the alpha only
387
- fragColor = vec4(color.rgb, color.a * u_opacity * coverage);
433
+ fragColor = vec4(color.rgb, color.a * u_opacity * coverage * v_ink);
388
434
  }`;
389
435
  return createProgram(this.gl, vertexSource, fragmentSource);
390
436
  }
@@ -376,6 +376,10 @@ void main() {
376
376
  float lineWidth = (strokeWidth >= 0.0
377
377
  ? strokeWidth * pow(2.0, u_zoom - createdZoom)
378
378
  : -strokeWidth) * u_size_scale;
379
+ // Below one pixel the outline is drawn one pixel wide and fainter by its width, so the
380
+ // ink per unit of length follows the width and goes to nothing with it
381
+ float ink = clamp(lineWidth, 0.0, 1.0);
382
+ lineWidth = max(lineWidth, 1.0);
379
383
  float halfWidth = lineWidth / 2.0;
380
384
 
381
385
  // Expand by 1 pixel for HHAA
@@ -389,7 +393,7 @@ void main() {
389
393
  vec2 finalNdc = (finalScreenPos / u_viewport) * 2.0 - 1.0;
390
394
  gl_Position = vec4(finalNdc * clipPos.w, clipPos.z, clipPos.w);
391
395
 
392
- v_color = a_color;
396
+ v_color = vec4(a_color.rgb, a_color.a * ink);
393
397
  v_type = 1.0;
394
398
  v_dist = a_side * expandedHalfWidth;
395
399
  v_halfWidth = halfWidth;
@@ -207,6 +207,15 @@ float dashDistance(vec2 sa, vec2 sb, float start, float len, float dash, float p
207
207
  return best;
208
208
  }
209
209
 
210
+ // The coverage of an outline of a half width at a screen distance from it. Below one
211
+ // pixel of width the outline is drawn one pixel wide and fainter by its width, so the ink
212
+ // per unit of length follows the width and goes to nothing with it (the rule of the
213
+ // vertex path's line and outline shaders)
214
+ float strokeCoverage(float halfWidth, float dist) {
215
+ float ink = clamp(2.0 * halfWidth, 0.0, 1.0);
216
+ return clamp(max(halfWidth, 0.5) + 0.5 - dist, 0.0, 1.0) * ink;
217
+ }
218
+
210
219
  // Compose on top (premultiplied alpha)
211
220
  vec4 over(vec4 below, vec3 rgb, float a) {
212
221
  return vec4(rgb * a + below.rgb * (1.0 - a), a + below.a * (1.0 - a));
@@ -342,7 +351,7 @@ void main() {
342
351
  }
343
352
 
344
353
  if (paints && halfWidth > 0.0 && stroke.a > 0.0) {
345
- float coverage = clamp(halfWidth + 0.5 - strokeDist, 0.0, 1.0);
354
+ float coverage = strokeCoverage(halfWidth, strokeDist);
346
355
  if (coverage > 0.0) acc = over(acc, stroke.rgb, stroke.a * coverage * factors.x);
347
356
  }
348
357
 
@@ -367,7 +376,7 @@ void main() {
367
376
  // for features that have no outline (the same as the immediate mode), and
368
377
  // it is solid under a dashed outline too
369
378
  float selHalf = halfWidth + u_selection_stroke_extra * 0.5;
370
- selStroke = max(selStroke, clamp(selHalf + 0.5 - minDist, 0.0, 1.0) * factors.x);
379
+ selStroke = max(selStroke, strokeCoverage(selHalf, minDist) * factors.x);
371
380
  }
372
381
  }
373
382
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sakuzu/maplibre-gl-draw",
3
- "version": "2.1.3",
3
+ "version": "2.1.4",
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",