@versatiles/png-renderer 2.2.0 → 2.3.0

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/dist/index.d.ts CHANGED
@@ -2741,6 +2741,12 @@ declare class Feature implements Feature$1 {
2741
2741
  * clipped edges along the tile border are not part of the outline.
2742
2742
  */
2743
2743
  readonly outline?: Geometry;
2744
+ /**
2745
+ * For each line of the geometry, how far along the line in its tile it starts, in pixels
2746
+ * — where the line was clipped to its tile, which cut off its start. A dash pattern
2747
+ * starts where the line in the tile does.
2748
+ */
2749
+ readonly lineStarts?: number[];
2744
2750
  constructor(opt: {
2745
2751
  type: 'LineString' | 'Point' | 'Polygon';
2746
2752
  id?: unknown;
@@ -2748,6 +2754,7 @@ declare class Feature implements Feature$1 {
2748
2754
  patterns?: Patterns;
2749
2755
  geometry: Geometry;
2750
2756
  outline?: Geometry;
2757
+ lineStarts?: number[];
2751
2758
  });
2752
2759
  getBbox(): Bbox;
2753
2760
  doesOverlap(bbox: Bbox): boolean;
@@ -2774,6 +2781,18 @@ interface Padding {
2774
2781
  left?: number;
2775
2782
  }
2776
2783
 
2784
+ /**
2785
+ * The dashes of a line with `line-dasharray`, shared by every backend, as MapLibre GL JS
2786
+ * draws them (`line_sdf.vertex.glsl`, `LineAtlas`).
2787
+ */
2788
+ /** A dash pattern in pixels. */
2789
+ interface LineDash {
2790
+ /** The lengths of dashes and gaps in turn, starting with a dash: an even number of them. */
2791
+ lengths: number[];
2792
+ /** How far into the pattern a line starts. */
2793
+ offset: number;
2794
+ }
2795
+
2777
2796
  /**
2778
2797
  * A drawing surface the render pipeline paints a map onto. It only describes *how* to
2779
2798
  * draw; how the result is handed back is up to the backend, because those differ in
@@ -2890,14 +2909,15 @@ interface LineStyle {
2890
2909
  blur: number;
2891
2910
  cap: 'butt' | 'round' | 'square';
2892
2911
  color: Color;
2893
- dasharray?: number[];
2912
+ /** `line-dasharray`, in pixels (see `dashPattern`). */
2913
+ dash?: LineDash;
2894
2914
  join: 'bevel' | 'miter' | 'round';
2895
2915
  miterLimit: number;
2896
2916
  offset: number;
2897
2917
  opacity: number;
2898
2918
  translate: [number, number];
2899
2919
  width: number;
2900
- /** `line-pattern`: drawn instead of `color` and `dasharray`. */
2920
+ /** `line-pattern`: drawn instead of `color` and `dash`. */
2901
2921
  pattern?: LinePattern;
2902
2922
  }
2903
2923
  /**
package/dist/index.js CHANGED
@@ -119,6 +119,12 @@ class Feature {
119
119
  * clipped edges along the tile border are not part of the outline.
120
120
  */
121
121
  outline;
122
+ /**
123
+ * For each line of the geometry, how far along the line in its tile it starts, in pixels
124
+ * — where the line was clipped to its tile, which cut off its start. A dash pattern
125
+ * starts where the line in the tile does.
126
+ */
127
+ lineStarts;
122
128
  #bbox;
123
129
  constructor(opt) {
124
130
  this.type = opt.type;
@@ -127,6 +133,7 @@ class Feature {
127
133
  this.patterns = opt.patterns;
128
134
  this.geometry = opt.geometry;
129
135
  this.outline = opt.outline;
136
+ this.lineStarts = opt.lineStarts;
130
137
  }
131
138
  getBbox() {
132
139
  if (this.#bbox)
@@ -2133,32 +2140,49 @@ function clipPolygonOutline(rings, min, max) {
2133
2140
  }
2134
2141
  /** Clips a polyline to the square [min, max]², returning the parts inside of it. */
2135
2142
  function clipLine(line, min, max) {
2143
+ return clipLineMeasured(line, min, max).map(({ points }) => points);
2144
+ }
2145
+ /**
2146
+ * Clips a polyline to the square [min, max]², like `clipLine`, and measures where each part
2147
+ * starts: its distance from the polyline's start, which is where MapLibre starts a dash
2148
+ * pattern. MapLibre stores that distance in a few bits and starts again from zero at a
2149
+ * vertex further along than `resetAfter` (`LineBucket.addCurrentVertex`).
2150
+ */
2151
+ function clipLineMeasured(line, min, max, resetAfter = Infinity) {
2136
2152
  const parts = [];
2137
2153
  let current = [];
2154
+ let start = 0;
2155
+ let distance = 0;
2156
+ const flush = () => {
2157
+ if (current.length > 1)
2158
+ parts.push({ points: current, start });
2159
+ current = [];
2160
+ };
2138
2161
  for (let i = 0; i + 1 < line.length; i++) {
2139
- const segment = clipSegment(line[i], line[i + 1], min, max);
2140
- if (!segment) {
2141
- if (current.length > 1)
2142
- parts.push(current);
2143
- current = [];
2144
- continue;
2145
- }
2146
- const [a, b] = segment;
2147
- const last = current[current.length - 1];
2148
- if (last?.x !== a.x || last.y !== a.y) {
2149
- if (current.length > 1)
2150
- parts.push(current);
2151
- current = [a];
2162
+ const from = line[i];
2163
+ const to = line[i + 1];
2164
+ const segment = clipSegment(from, to, min, max);
2165
+ if (segment) {
2166
+ const [a, b] = segment;
2167
+ const last = current[current.length - 1];
2168
+ if (last?.x !== a.x || last.y !== a.y) {
2169
+ flush();
2170
+ current = [a];
2171
+ start = distance + Math.hypot(a.x - from.x, a.y - from.y);
2172
+ }
2173
+ current.push(b);
2174
+ // The segment left the square: the line continues elsewhere.
2175
+ if (b !== to)
2176
+ flush();
2152
2177
  }
2153
- current.push(b);
2154
- // The segment left the square: the line continues elsewhere.
2155
- if (b !== line[i + 1]) {
2156
- parts.push(current);
2157
- current = [];
2178
+ else {
2179
+ flush();
2158
2180
  }
2181
+ distance += Math.hypot(to.x - from.x, to.y - from.y);
2182
+ if (distance > resetAfter)
2183
+ distance = 0;
2159
2184
  }
2160
- if (current.length > 1)
2161
- parts.push(current);
2185
+ flush();
2162
2186
  return parts;
2163
2187
  }
2164
2188
  /** Liang–Barsky: the part of the segment a→b inside the square, or undefined. */
@@ -2218,6 +2242,11 @@ function lerpAtY(a, b, y) {
2218
2242
  }
2219
2243
 
2220
2244
  const TILE_EXTENT = 4096;
2245
+ /**
2246
+ * How far along a line MapLibre measures before it starts again from zero: two tiles
2247
+ * (`MAX_LINE_DISTANCE / 2` in its `line_bucket.ts`, for its tile extent of 8192).
2248
+ */
2249
+ const MAX_LINE_DISTANCE = 2 * TILE_EXTENT;
2221
2250
  const VTFeatureType = { Unknown: 0, Point: 1, LineString: 2, Polygon: 3 };
2222
2251
  async function loadVectorSource(source, job, layerFeatures, loadTile = getTile) {
2223
2252
  const tiles = source.tiles;
@@ -2236,7 +2265,7 @@ async function loadVectorSource(source, job, layerFeatures, loadTile = getTile)
2236
2265
  });
2237
2266
  }
2238
2267
  /** Decodes one vector tile and adds its features, projected to the screen. */
2239
- function addTileFeatures(buffer, { project, clipToTile }, layerFeatures, width, height) {
2268
+ function addTileFeatures(buffer, { project, clipToTile, pixelsPerUnit }, layerFeatures, width, height) {
2240
2269
  const vectorTile = new VectorTile(new PbfReader(buffer));
2241
2270
  for (const [name, layer] of Object.entries(vectorTile.layers)) {
2242
2271
  let features = layerFeatures.get(name);
@@ -2266,6 +2295,7 @@ function addTileFeatures(buffer, { project, clipToTile }, layerFeatures, width,
2266
2295
  }
2267
2296
  let rings = featureSrc.loadGeometry();
2268
2297
  let outline;
2298
+ let lineStarts;
2269
2299
  // Clip polygons and lines to the tile, like MapLibre's stencil clipping: otherwise
2270
2300
  // the parts in the tile buffer are drawn twice, which shows when translucent.
2271
2301
  if (clipToTile && type !== 'Point' && exceedsSquare(rings, 0, TILE_EXTENT)) {
@@ -2274,7 +2304,11 @@ function addTileFeatures(buffer, { project, clipToTile }, layerFeatures, width,
2274
2304
  rings = clipPolygon(rings, 0, TILE_EXTENT);
2275
2305
  }
2276
2306
  else {
2277
- rings = rings.flatMap((line) => clipLine(line, 0, TILE_EXTENT));
2307
+ const parts = rings.flatMap((line) => clipLineMeasured(line, 0, TILE_EXTENT, MAX_LINE_DISTANCE));
2308
+ rings = parts.map(({ points }) => points);
2309
+ if (pixelsPerUnit !== undefined) {
2310
+ lineStarts = parts.map(({ start }) => start * pixelsPerUnit);
2311
+ }
2278
2312
  }
2279
2313
  }
2280
2314
  const geometry = project(type, rings);
@@ -2298,6 +2332,7 @@ function addTileFeatures(buffer, { project, clipToTile }, layerFeatures, width,
2298
2332
  type,
2299
2333
  geometry,
2300
2334
  outline,
2335
+ lineStarts,
2301
2336
  id: featureSrc.id,
2302
2337
  properties: featureSrc.properties,
2303
2338
  });
@@ -2352,6 +2387,7 @@ function getTileProjections(source, job) {
2352
2387
  y,
2353
2388
  z: zoomLevel,
2354
2389
  clipToTile: true,
2390
+ pixelsPerUnit: scale,
2355
2391
  project: (_type, rings) => rings.map((ring) => ring.map((point) => {
2356
2392
  const p = new Point2D(point.x, point.y).scale(scale).translate(offset);
2357
2393
  return moved ? moved.fromNorthUp(p.x, p.y) : p;
@@ -11749,6 +11785,55 @@ function stretchZonesToCuts(zones, fixedSize, stretchSize) {
11749
11785
  return cuts;
11750
11786
  }
11751
11787
 
11788
+ /**
11789
+ * The dashes of a line with `line-dasharray`, shared by every backend, as MapLibre GL JS
11790
+ * draws them (`line_sdf.vertex.glsl`, `LineAtlas`).
11791
+ */
11792
+ /**
11793
+ * The dash pattern of `dasharray`, or undefined for a line without dashes.
11794
+ *
11795
+ * MapLibre measures a dash array in line widths, but not in the width the line is drawn
11796
+ * with: in its width at the zoom level's integer part (`floorWidth`), scaled with the map
11797
+ * from that integer zoom level up to `zoom`, as its tiles are. And where a dash array has
11798
+ * an odd number of lengths, it is not repeated to an even one, as SVG and the canvas do: its
11799
+ * last and first dash are joined to one.
11800
+ */
11801
+ function dashPattern(dasharray, floorWidth, zoom) {
11802
+ if (!dasharray || dasharray.length === 0)
11803
+ return undefined;
11804
+ const unit = floorWidth * 2 ** (zoom - Math.floor(zoom));
11805
+ if (!(unit > 0))
11806
+ return undefined;
11807
+ const lengths = dasharray.map((length) => Math.max(length, 0) * unit);
11808
+ let offset = 0;
11809
+ if (lengths.length % 2 === 1) {
11810
+ // A single length is a dash without a gap.
11811
+ if (lengths.length === 1)
11812
+ return undefined;
11813
+ // The last dash goes before the first: the pattern starts that far into their sum.
11814
+ offset = lengths.pop();
11815
+ lengths[0] += offset;
11816
+ }
11817
+ // Without a gap, the line is solid.
11818
+ const hasGap = lengths.some((length, i) => i % 2 === 1 && length > 0);
11819
+ return hasGap ? { lengths, offset } : undefined;
11820
+ }
11821
+ /** `offset` within one period of `dash`, for a line that starts `start` pixels in. */
11822
+ function dashOffset(dash, start) {
11823
+ const period = dash.lengths.reduce((sum, length) => sum + length, 0);
11824
+ const offset = (dash.offset + start) % period;
11825
+ return offset < 0 ? offset + period : offset;
11826
+ }
11827
+ /**
11828
+ * How far along a ring its first vertex is: MapLibre measures a ring from its last vertex,
11829
+ * so by the length of its closing segment (`LineBucket.addLine`).
11830
+ */
11831
+ function closingLength(ring) {
11832
+ const first = ring[0];
11833
+ const last = ring[ring.length - 1];
11834
+ return first && last ? Math.hypot(first.x - last.x, first.y - last.y) : 0;
11835
+ }
11836
+
11752
11837
  /**
11753
11838
  * Geometry for drawing a line with `line-pattern`, shared by every backend: the sprite image
11754
11839
  * repeated along the line, its height across the line's width, as MapLibre GL JS draws it
@@ -12219,9 +12304,10 @@ async function renderLineLayer(layer) {
12219
12304
  if (lineStringFeatures.length === 0)
12220
12305
  return;
12221
12306
  const { getPaint, getLayout } = evaluateLayer(layer);
12222
- // A pattern is scaled to the line's width at the zoom level's integer part, as in MapLibre.
12307
+ // A pattern and the dashes are scaled to the line's width at the zoom level's integer
12308
+ // part, as in MapLibre.
12223
12309
  const { zoom } = layer.job.view;
12224
- const atFloorZoom = layer.layerStyle.usesPattern
12310
+ const atFloorZoom = layer.layerStyle.usesPattern || getPaint('line-dasharray') !== undefined
12225
12311
  ? evaluateLayer(layer, Math.floor(zoom))
12226
12312
  : undefined;
12227
12313
  const sorted = sortByKey(lineStringFeatures, (feature) => getLayout('line-sort-key', feature));
@@ -12230,21 +12316,26 @@ async function renderLineLayer(layer) {
12230
12316
  // A pattern whose image is not in the sprite draws nothing, as in MapLibre.
12231
12317
  if (found === null)
12232
12318
  return [];
12319
+ const width = getPaint('line-width', feature);
12320
+ const floorWidth = atFloorZoom?.getPaint('line-width', feature) ?? width;
12321
+ const dash = dashPattern(getPaint('line-dasharray', feature), floorWidth, zoom);
12322
+ const cap = getLayout('line-cap', feature);
12233
12323
  const style = {
12234
12324
  blur: getPaint('line-blur', feature),
12235
12325
  color: getPaint('line-color', feature),
12236
12326
  translate: screenTranslate(layer.job, getPaint('line-translate', feature), getPaint('line-translate-anchor', feature)),
12237
- cap: getLayout('line-cap', feature),
12238
- dasharray: getPaint('line-dasharray', feature),
12327
+ // MapLibre's dashes end flat unless the caps are round: with square caps, an SVG or
12328
+ // a canvas would lengthen every dash. (That leaves out the caps at the line's ends.)
12329
+ cap: dash && cap === 'square' ? 'butt' : cap,
12330
+ dash,
12239
12331
  join: getLayout('line-join', feature),
12240
12332
  miterLimit: getLayout('line-miter-limit', feature),
12241
12333
  offset: getPaint('line-offset', feature),
12242
12334
  opacity: getPaint('line-opacity', feature),
12243
- width: getPaint('line-width', feature),
12335
+ width,
12244
12336
  };
12245
12337
  if (found) {
12246
12338
  const { sprite } = found;
12247
- const floorWidth = atFloorZoom?.getPaint('line-width', feature) ?? style.width;
12248
12339
  style.pattern = {
12249
12340
  name: found.name,
12250
12341
  sprite,
@@ -14045,6 +14136,10 @@ function strokeAttr(color, width) {
14045
14136
  function formatScaled(v) {
14046
14137
  return formatNum(Math.round(v * 10));
14047
14138
  }
14139
+ /** How far into its dash pattern a line starts, in hundredths of a pixel. */
14140
+ function formatDash(v) {
14141
+ return (Math.round(v * 100) / 100).toString();
14142
+ }
14048
14143
  function formatUnit(v) {
14049
14144
  return (Math.round(v * 100000) / 100000).toString();
14050
14145
  }
@@ -14690,9 +14785,9 @@ class SVGRenderer {
14690
14785
  ? ''
14691
14786
  : ` transform="translate(${formatPoint(style.translate)})"`;
14692
14787
  const roundedWidth = formatScaled(style.width);
14693
- const dasharrayStr = style.dasharray
14694
- ? style.dasharray.map((v) => formatScaled(v * style.width)).join(',')
14695
- : '';
14788
+ const { dash } = style;
14789
+ // The lengths are written precisely: what is rounded off adds up along the line.
14790
+ const dasharrayStr = dash ? dash.lengths.map(formatUnit).join(',') : '';
14696
14791
  // MapLibre's line-blur fades the line as the blur approaches its width
14697
14792
  // (the feather eats into the opaque core). A Gaussian conserves total ink,
14698
14793
  // so approximate that fade with an extra opacity factor; it tends to 1 for
@@ -14715,33 +14810,55 @@ class SVGRenderer {
14715
14810
  filterAttr,
14716
14811
  ].join('\0');
14717
14812
  const translucent = effectiveOpacity < 1 || color.alpha < 255;
14718
- if (translucent || key !== currentKey) {
14719
- const attrs = [
14720
- 'fill="none"',
14721
- strokeAttr(color, roundedWidth),
14722
- `stroke-linecap="${style.cap}"`,
14723
- `stroke-linejoin="${style.join}"`,
14724
- `stroke-miterlimit="${String(style.miterLimit)}"`,
14725
- ];
14726
- if (dasharrayStr)
14727
- attrs.push(`stroke-dasharray="${dasharrayStr}"`);
14728
- groups.push({
14729
- segments: [],
14730
- rings: [],
14731
- attrs: attrs.join(' ') + translate + opacityAttr + filterAttr,
14732
- separate: translucent,
14813
+ // The group of paths with these attributes; `dashOffsetStr` is how far into the dash
14814
+ // pattern they start.
14815
+ const groupFor = (dashOffsetStr) => {
14816
+ const groupKey = key + '\0' + dashOffsetStr;
14817
+ if (translucent || groupKey !== currentKey) {
14818
+ const attrs = [
14819
+ 'fill="none"',
14820
+ strokeAttr(color, roundedWidth),
14821
+ `stroke-linecap="${style.cap}"`,
14822
+ `stroke-linejoin="${style.join}"`,
14823
+ `stroke-miterlimit="${String(style.miterLimit)}"`,
14824
+ ];
14825
+ if (dasharrayStr)
14826
+ attrs.push(`stroke-dasharray="${dasharrayStr}"`);
14827
+ if (dashOffsetStr !== '0')
14828
+ attrs.push(`stroke-dashoffset="${dashOffsetStr}"`);
14829
+ groups.push({
14830
+ segments: [],
14831
+ rings: [],
14832
+ attrs: attrs.join(' ') + translate + opacityAttr + filterAttr,
14833
+ separate: translucent,
14834
+ dashed: dash !== undefined,
14835
+ });
14836
+ currentKey = translucent ? undefined : groupKey;
14837
+ }
14838
+ return groups[groups.length - 1];
14839
+ };
14840
+ const { open, closed } = strokeLines(feature.geometry, feature.type === 'Polygon', style.offset);
14841
+ if (dash) {
14842
+ // Every part has its dashes where MapLibre draws them: from the start of the line
14843
+ // in its tile, and a ring from the start of its closing segment.
14844
+ open.forEach((line, i) => {
14845
+ const offset = formatDash(dashOffset(dash, feature.lineStarts?.[i] ?? 0));
14846
+ groupFor(offset).segments.push(line.map((p) => roundXY(p.x, p.y)));
14733
14847
  });
14734
- currentKey = translucent ? undefined : key;
14848
+ for (const ring of closed) {
14849
+ const offset = formatDash(dashOffset(dash, closingLength(ring)));
14850
+ groupFor(offset).rings.push(ring.map((p) => roundXY(p.x, p.y)));
14851
+ }
14852
+ return;
14735
14853
  }
14736
- const group = groups[groups.length - 1];
14737
- const { open, closed } = strokeLines(feature.geometry, feature.type === 'Polygon', style.offset);
14854
+ const group = groupFor('0');
14738
14855
  for (const line of open)
14739
14856
  group.segments.push(line.map((p) => roundXY(p.x, p.y)));
14740
14857
  for (const ring of closed)
14741
14858
  group.rings.push(ring.map((p) => roundXY(p.x, p.y)));
14742
14859
  });
14743
14860
  this.#svg.push(`<g id="${escapeXml(id)}">`);
14744
- for (const { segments, rings, attrs, separate, patterned } of groups) {
14861
+ for (const { segments, rings, attrs, separate, dashed, patterned } of groups) {
14745
14862
  if (patterned) {
14746
14863
  this.#svg.push(patterned);
14747
14864
  continue;
@@ -14755,7 +14872,7 @@ class SVGRenderer {
14755
14872
  }
14756
14873
  continue;
14757
14874
  }
14758
- const chains = chainSegments(segments);
14875
+ const chains = dashed ? segments : chainSegments(segments);
14759
14876
  const d = segmentsToPath(chains) + segmentsToPath(rings, true);
14760
14877
  this.#svg.push(`<path d="${d}" ${attrs} />`);
14761
14878
  }
@@ -16087,8 +16204,23 @@ class CanvasRenderer {
16087
16204
  ctx.lineCap = style.cap;
16088
16205
  ctx.lineJoin = style.join;
16089
16206
  ctx.miterLimit = style.miterLimit;
16090
- if (style.dasharray)
16091
- ctx.setLineDash(style.dasharray.map((v) => v * style.width));
16207
+ const { dash } = style;
16208
+ if (dash) {
16209
+ // Every part has its dashes where MapLibre draws them: from the start of the
16210
+ // line in its tile, and a ring from the start of its closing segment.
16211
+ ctx.setLineDash(dash.lengths);
16212
+ segments.forEach((segment, i) => {
16213
+ ctx.lineDashOffset = dashOffset(dash, feature.lineStarts?.[i] ?? 0);
16214
+ this.#trace(ctx, [segment], false);
16215
+ ctx.stroke();
16216
+ });
16217
+ lines.closed.forEach((ring, i) => {
16218
+ ctx.lineDashOffset = dashOffset(dash, closingLength(ring));
16219
+ this.#trace(ctx, [rings[i]], true);
16220
+ ctx.stroke();
16221
+ });
16222
+ return;
16223
+ }
16092
16224
  if (translucent) {
16093
16225
  for (const segment of segments) {
16094
16226
  this.#trace(ctx, [segment], false);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@versatiles/png-renderer",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "Renders MapLibre styles as PNG (and SVG) in Node.js.",
5
5
  "keywords": [
6
6
  "png",
@@ -41,7 +41,7 @@
41
41
  "node": ">= 22"
42
42
  },
43
43
  "dependencies": {
44
- "@napi-rs/canvas": "^1.0.9",
44
+ "@napi-rs/canvas": "^1.0.10",
45
45
  "@types/geojson": "^7946.0.16"
46
46
  }
47
47
  }