@osmix/vt 0.0.1 → 0.0.6

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/src/encode.ts CHANGED
@@ -1,8 +1,19 @@
1
- import type { Osmix } from "@osmix/core"
2
- import { wayIsArea } from "@osmix/json"
1
+ /**
2
+ * Vector tile encoding for OSM data.
3
+ *
4
+ * The OsmixVtEncoder class converts Osmix datasets into Mapbox Vector Tiles,
5
+ * handling geometry projection, clipping, area detection, and proper
6
+ * MVT encoding for nodes, ways, and relations.
7
+ *
8
+ * @module
9
+ */
10
+
11
+ import type { Osm } from "@osmix/core"
12
+ import { bboxContainsOrIntersects } from "@osmix/shared/bbox-intersects"
3
13
  import { clipPolygon, clipPolyline } from "@osmix/shared/lineclip"
4
- import SphericalMercatorTile from "@osmix/shared/spherical-mercator"
14
+ import { llToTilePx, tileToBbox } from "@osmix/shared/tile"
5
15
  import type { GeoBbox2D, LonLat, Tile, XY } from "@osmix/shared/types"
16
+ import { wayIsArea } from "@osmix/shared/way-is-area"
6
17
  import type {
7
18
  VtSimpleFeature,
8
19
  VtSimpleFeatureGeometry,
@@ -10,7 +21,9 @@ import type {
10
21
  } from "./types"
11
22
  import writeVtPbf from "./write-vt-pbf"
12
23
 
24
+ /** Default tile extent (coordinate resolution). */
13
25
  const DEFAULT_EXTENT = 4096
26
+ /** Default buffer around tile in extent units. */
14
27
  const DEFAULT_BUFFER = 64
15
28
 
16
29
  const SF_TYPE: VtSimpleFeatureType = {
@@ -34,48 +47,79 @@ function dedupePoints(points: XY[]): XY[] {
34
47
  return result
35
48
  }
36
49
 
50
+ /**
51
+ * Returns a projection function that converts [lon, lat] to [x, y] pixel coordinates
52
+ * relative to the given tile. The extent determines the resolution of the tile
53
+ * (e.g. 4096 means coordinates range from 0 to 4096).
54
+ */
37
55
  export function projectToTile(
38
56
  tile: Tile,
39
57
  extent = DEFAULT_EXTENT,
40
58
  ): (ll: LonLat) => XY {
41
- const sm = new SphericalMercatorTile({ size: extent, tile })
42
- return (lonLat) => sm.llToTilePx(lonLat)
59
+ return (lonLat) => llToTilePx(lonLat, tile, extent)
43
60
  }
44
61
 
62
+ /**
63
+ * Encode an Osm instance into a Mapbox Vector Tile PBF.
64
+ */
45
65
  export class OsmixVtEncoder {
46
- private readonly nodeLayerName: string
47
- private readonly wayLayerName: string
48
- private readonly osmix: Osmix
66
+ readonly nodeLayerName: string
67
+ readonly wayLayerName: string
68
+ readonly relationLayerName: string
69
+ private readonly osm: Osm
49
70
  private readonly extent: number
50
71
  private readonly extentBbox: [number, number, number, number]
51
72
 
52
- constructor(osmix: Osmix, extent = DEFAULT_EXTENT, buffer = DEFAULT_BUFFER) {
53
- this.osmix = osmix
73
+ static layerNames(id: string) {
74
+ return {
75
+ nodeLayerName: `@osmix:${id}:nodes`,
76
+ wayLayerName: `@osmix:${id}:ways`,
77
+ relationLayerName: `@osmix:${id}:relations`,
78
+ }
79
+ }
80
+
81
+ constructor(osm: Osm, extent = DEFAULT_EXTENT, buffer = DEFAULT_BUFFER) {
82
+ this.osm = osm
54
83
 
55
84
  const min = -buffer
56
85
  const max = extent + buffer
57
86
  this.extent = extent
58
87
  this.extentBbox = [min, min, max, max]
59
88
 
60
- const layerName = `@osmix:${osmix.id}`
89
+ const layerName = `@osmix:${osm.id}`
61
90
  this.nodeLayerName = `${layerName}:nodes`
62
91
  this.wayLayerName = `${layerName}:ways`
92
+ this.relationLayerName = `${layerName}:relations`
63
93
  }
64
94
 
95
+ /**
96
+ * Get a vector tile PBF for a specific tile coordinate.
97
+ * Returns an empty buffer if the tile does not intersect with the OSM dataset.
98
+ */
65
99
  getTile(tile: Tile): ArrayBuffer {
66
- const sm = new SphericalMercatorTile({ size: this.extent, tile })
67
- // const proj = projectToTile(tile, this.extent)
68
- const bbox = sm.bbox(tile[0], tile[1], tile[2]) as GeoBbox2D
69
- return this.getTileForBbox(bbox, (ll) => sm.llToTilePx(ll))
100
+ const bbox = tileToBbox(tile)
101
+ const osmBbox = this.osm.bbox()
102
+ if (!bboxContainsOrIntersects(bbox, osmBbox)) {
103
+ return new ArrayBuffer(0)
104
+ }
105
+ return this.getTileForBbox(bbox, (ll) => llToTilePx(ll, tile, this.extent))
70
106
  }
71
107
 
108
+ /**
109
+ * Get a vector tile PBF for a specific geographic bounding box.
110
+ * @param bbox The bounding box to include features from.
111
+ * @param proj A function to project [lon, lat] to [x, y] within the tile extent.
112
+ */
72
113
  getTileForBbox(bbox: GeoBbox2D, proj: (ll: LonLat) => XY): ArrayBuffer {
73
- const layer = writeVtPbf([
114
+ // Get way IDs that are part of relations (to exclude from individual rendering)
115
+ const relationWayIds = this.osm.relations.getWayMemberIds()
116
+
117
+ const layers = [
74
118
  {
75
119
  name: this.wayLayerName,
76
120
  version: 2,
77
121
  extent: this.extent,
78
- features: this.wayFeatures(bbox, proj),
122
+ features: this.wayFeatures(bbox, proj, relationWayIds),
79
123
  },
80
124
  {
81
125
  name: this.nodeLayerName,
@@ -83,26 +127,32 @@ export class OsmixVtEncoder {
83
127
  extent: this.extent,
84
128
  features: this.nodeFeatures(bbox, proj),
85
129
  },
86
- ])
87
- return layer
130
+ {
131
+ name: this.relationLayerName,
132
+ version: 2,
133
+ extent: this.extent,
134
+ features: this.relationFeatures(bbox, proj),
135
+ },
136
+ ]
137
+ return writeVtPbf(layers)
88
138
  }
89
139
 
90
140
  *nodeFeatures(
91
141
  bbox: GeoBbox2D,
92
142
  proj: (ll: LonLat) => XY,
93
143
  ): Generator<VtSimpleFeature> {
94
- const nodeIndexes = this.osmix.nodes.withinBbox(bbox)
144
+ const nodeIndexes = this.osm.nodes.findIndexesWithinBbox(bbox)
95
145
  for (let i = 0; i < nodeIndexes.length; i++) {
96
146
  const nodeIndex = nodeIndexes[i]
97
147
  if (nodeIndex === undefined) continue
98
- const tags = this.osmix.nodes.tags.getTags(nodeIndex)
148
+ const tags = this.osm.nodes.tags.getTags(nodeIndex)
99
149
  if (!tags || Object.keys(tags).length === 0) continue
100
- const id = this.osmix.nodes.ids.at(nodeIndex)
101
- const ll = this.osmix.nodes.getNodeLonLat({ index: nodeIndex })
150
+ const id = this.osm.nodes.ids.at(nodeIndex)
151
+ const ll = this.osm.nodes.getNodeLonLat({ index: nodeIndex })
102
152
  yield {
103
153
  id,
104
154
  type: SF_TYPE.POINT,
105
- properties: { type: "node", ...tags },
155
+ properties: { ...tags, type: "node" },
106
156
  geometry: [[proj(ll)]],
107
157
  }
108
158
  }
@@ -111,36 +161,48 @@ export class OsmixVtEncoder {
111
161
  *wayFeatures(
112
162
  bbox: GeoBbox2D,
113
163
  proj: (ll: LonLat) => XY,
164
+ relationWayIds?: Set<number>,
114
165
  ): Generator<VtSimpleFeature> {
115
- const wayIndexes = this.osmix.ways.intersects(bbox)
166
+ const wayIndexes = this.osm.ways.intersects(bbox)
116
167
  for (let i = 0; i < wayIndexes.length; i++) {
117
168
  const wayIndex = wayIndexes[i]
118
169
  if (wayIndex === undefined) continue
119
- const id = this.osmix.ways.ids.at(wayIndex)
120
- const tags = this.osmix.ways.tags.getTags(wayIndex)
121
- const count = this.osmix.ways.refCount.at(wayIndex)
122
- const start = this.osmix.ways.refStart.at(wayIndex)
123
- const points: XY[] = new Array(count)
124
- for (let i = 0; i < count; i++) {
125
- const ref = this.osmix.ways.refs.at(start + i)
126
- const ll = this.osmix.nodes.getNodeLonLat({ id: ref })
127
- points[i] = proj(ll)
128
- }
129
- const isArea = wayIsArea({ id, refs: new Array(count).fill(0), tags })
170
+ const id = this.osm.ways.ids.at(wayIndex)
171
+ // Skip ways that are part of relations (they will be rendered via relations)
172
+ if (id !== undefined && relationWayIds?.has(id)) continue
173
+ const tags = this.osm.ways.tags.getTags(wayIndex)
174
+ // Skip ways without tags (they are likely only for relations)
175
+ if (!tags || Object.keys(tags).length === 0) continue
176
+ const wayLine = this.osm.ways.getCoordinates(wayIndex)
177
+ const points: XY[] = wayLine.map((ll) => proj(ll))
178
+
179
+ const isArea = wayIsArea({
180
+ id,
181
+ refs: this.osm.ways.getRefIds(wayIndex),
182
+ tags,
183
+ })
130
184
  const geometry: VtSimpleFeatureGeometry = []
131
185
  if (isArea) {
132
- // 1. clip polygon in tile coords
133
- const clippedPoly = this.clipProjectedPolygon(points)
134
- // clipProjectedPolygon currently returns XY[], not XY[][]
135
- // i.e. assumes single ring. We'll treat it as one ring.
186
+ // 1. clip polygon in tile coords (returns array of rings)
187
+ const clippedRings = this.clipProjectedPolygon(points)
136
188
 
137
- // 2. round/clamp, dedupe, close, orient
138
- const processedRing = this.processClippedPolygonRing(clippedPoly)
189
+ // 2. process each ring (first is outer, rest would be holes if from relations)
190
+ for (let ringIndex = 0; ringIndex < clippedRings.length; ringIndex++) {
191
+ const clippedRing = clippedRings[ringIndex]
192
+ if (!clippedRing) continue
139
193
 
140
- // TODO handle MultiPolygons with holes
194
+ // Normalize winding order using rewind before processing
195
+ // GeoJSON: outer counterclockwise, inner clockwise
196
+ // MVT: outer clockwise, inner counterclockwise
197
+ const isOuter = ringIndex === 0
198
+ const processedRing = this.processClippedPolygonRing(
199
+ clippedRing,
200
+ isOuter,
201
+ )
141
202
 
142
- if (processedRing.length > 0) {
143
- geometry.push(processedRing)
203
+ if (processedRing.length > 0) {
204
+ geometry.push(processedRing)
205
+ }
144
206
  }
145
207
  } else {
146
208
  const clippedSegmentsRaw = this.clipProjectedPolyline(points)
@@ -156,7 +218,7 @@ export class OsmixVtEncoder {
156
218
  yield {
157
219
  id,
158
220
  type: isArea ? SF_TYPE.POLYGON : SF_TYPE.LINE,
159
- properties: { type: "way", ...tags },
221
+ properties: { ...tags, type: "way" },
160
222
  geometry,
161
223
  }
162
224
  }
@@ -166,11 +228,14 @@ export class OsmixVtEncoder {
166
228
  return clipPolyline(points, this.extentBbox)
167
229
  }
168
230
 
169
- clipProjectedPolygon(points: XY[]): XY[] {
170
- return clipPolygon(points, this.extentBbox)
231
+ clipProjectedPolygon(points: XY[]): XY[][] {
232
+ // clipPolygon returns a single ring, but we return as array for consistency
233
+ // with multi-ring support (e.g., from relations)
234
+ const clipped = clipPolygon(points, this.extentBbox)
235
+ return [clipped]
171
236
  }
172
237
 
173
- processClippedPolygonRing(rawRing: XY[]): XY[] {
238
+ processClippedPolygonRing(rawRing: XY[], isOuter: boolean): XY[] {
174
239
  // 1. round & clamp EVERY point
175
240
  const snapped = rawRing.map((xy) => this.clampAndRoundPoint(xy))
176
241
 
@@ -178,12 +243,130 @@ export class OsmixVtEncoder {
178
243
  const cleaned = cleanRing(snapped)
179
244
  if (cleaned.length === 0) return []
180
245
 
181
- // 3. enforce clockwise for outer ring
182
- const oriented = ensureClockwise(cleaned)
246
+ // 3. enforce winding order per MVT spec:
247
+ // - Outer rings: clockwise
248
+ // - Inner rings (holes): counterclockwise
249
+ const oriented = isOuter
250
+ ? ensureClockwise(cleaned)
251
+ : ensureCounterclockwise(cleaned)
183
252
 
184
253
  return oriented
185
254
  }
186
255
 
256
+ /**
257
+ * Super relations and logical relations are not directly rendered; they would need recursive expansion.
258
+ */
259
+ *relationFeatures(
260
+ bbox: GeoBbox2D,
261
+ proj: (ll: LonLat) => XY,
262
+ ): Generator<VtSimpleFeature> {
263
+ const relationIndexes = this.osm.relations.intersects(bbox)
264
+
265
+ for (const relIndex of relationIndexes) {
266
+ const relation = this.osm.relations.getByIndex(relIndex)
267
+ const relationGeometry = this.osm.relations.getRelationGeometry(relIndex)
268
+ if (
269
+ !relation ||
270
+ (!relationGeometry.lineStrings &&
271
+ !relationGeometry.rings &&
272
+ !relationGeometry.points)
273
+ )
274
+ continue
275
+
276
+ const id = this.osm.relations.ids.at(relIndex)
277
+ const tags = this.osm.relations.tags.getTags(relIndex)
278
+
279
+ if (relationGeometry.rings) {
280
+ // Area relations (multipolygon, boundary)
281
+ const { rings } = relationGeometry
282
+ if (rings.length === 0) continue
283
+
284
+ // Process each polygon in the relation
285
+ for (const polygon of rings) {
286
+ const geometry: VtSimpleFeatureGeometry = []
287
+
288
+ // Process outer ring and inner rings (holes)
289
+ for (let ringIndex = 0; ringIndex < polygon.length; ringIndex++) {
290
+ const ring = polygon[ringIndex]
291
+ if (!ring || ring.length < 3) continue
292
+
293
+ // Project ring to tile coordinates
294
+ const projectedRing: XY[] = ring.map((ll: LonLat) => proj(ll))
295
+
296
+ // Clip polygon ring
297
+ const clipped = clipPolygon(projectedRing, this.extentBbox)
298
+ if (clipped.length < 3) continue
299
+
300
+ // Process ring (round/clamp, dedupe, close, orient)
301
+ const isOuter = ringIndex === 0
302
+ const processedRing = this.processClippedPolygonRing(
303
+ clipped,
304
+ isOuter,
305
+ )
306
+
307
+ if (processedRing.length > 0) {
308
+ geometry.push(processedRing)
309
+ }
310
+ }
311
+
312
+ if (geometry.length === 0) continue
313
+
314
+ yield {
315
+ id: id ?? 0,
316
+ type: SF_TYPE.POLYGON,
317
+ properties: { ...tags, type: "relation" },
318
+ geometry,
319
+ }
320
+ }
321
+ } else if (relationGeometry.lineStrings) {
322
+ // Line relations (route, multilinestring)
323
+ const { lineStrings } = relationGeometry
324
+ if (lineStrings.length === 0) continue
325
+
326
+ for (const lineString of lineStrings) {
327
+ const geometry: VtSimpleFeatureGeometry = []
328
+ const points: XY[] = lineString.map((ll) => proj(ll))
329
+ const clippedSegmentsRaw = this.clipProjectedPolyline(points)
330
+ for (const segment of clippedSegmentsRaw) {
331
+ const rounded = segment.map((xy) => this.clampAndRoundPoint(xy))
332
+ const deduped = dedupePoints(rounded)
333
+ if (deduped.length >= 2) {
334
+ geometry.push(deduped)
335
+ }
336
+ }
337
+ if (geometry.length === 0) continue
338
+
339
+ yield {
340
+ id: id ?? 0,
341
+ type: SF_TYPE.LINE,
342
+ properties: { ...tags, type: "relation" },
343
+ geometry,
344
+ }
345
+ }
346
+ } else if (relationGeometry.points) {
347
+ // Point relations (multipoint)
348
+ const { points } = relationGeometry
349
+ if (points.length === 0) continue
350
+
351
+ const geometry: VtSimpleFeatureGeometry = []
352
+ for (const point of points) {
353
+ const projected = proj(point)
354
+ const clamped = this.clampAndRoundPoint(projected)
355
+ geometry.push([clamped])
356
+ }
357
+
358
+ if (geometry.length === 0) continue
359
+
360
+ yield {
361
+ id: id ?? 0,
362
+ type: SF_TYPE.POINT,
363
+ properties: { ...tags, type: "relation" },
364
+ geometry,
365
+ }
366
+ }
367
+ }
368
+ }
369
+
187
370
  clampAndRoundPoint(xy: XY): XY {
188
371
  const clampedX = Math.round(
189
372
  clamp(xy[0], this.extentBbox[0], this.extentBbox[2]),
@@ -195,6 +378,10 @@ export class OsmixVtEncoder {
195
378
  }
196
379
  }
197
380
 
381
+ /**
382
+ * Ensures the ring is closed (first and last points are identical).
383
+ * If not, appends the first point to the end.
384
+ */
198
385
  function closeRing(ring: XY[]): XY[] {
199
386
  const first = ring[0]
200
387
  const last = ring[ring.length - 1]
@@ -205,8 +392,10 @@ function closeRing(ring: XY[]): XY[] {
205
392
  return ring
206
393
  }
207
394
 
208
- // Signed area via shoelace formula.
209
- // Positive area => CCW, Negative => CW.
395
+ /**
396
+ * Signed area via shoelace formula.
397
+ * Positive area => CCW, Negative => CW.
398
+ */
210
399
  function ringArea(ring: XY[]): number {
211
400
  let sum = 0
212
401
  for (let i = 0; i < ring.length - 1; i++) {
@@ -221,15 +410,14 @@ function ensureClockwise(ring: XY[]): XY[] {
221
410
  return ringArea(ring) < 0 ? ring : [...ring].reverse()
222
411
  }
223
412
 
224
- /**
225
- * TODO handle MultiPolygons with holes
226
- *
227
- function _ensureCounterClockwise(ring: XY[]): XY[] {
413
+ function ensureCounterclockwise(ring: XY[]): XY[] {
228
414
  return ringArea(ring) > 0 ? ring : [...ring].reverse()
229
415
  }
230
- */
231
416
 
232
- // Remove consecutive duplicates *after* rounding
417
+ /**
418
+ * Clean a polygon ring by removing consecutive duplicates, ensuring it's closed,
419
+ * and checking that it has at least 4 coordinates (3 unique points).
420
+ */
233
421
  function cleanRing(ring: XY[]): XY[] {
234
422
  const deduped = dedupePoints(ring)
235
423
  // After dedupe, we still must ensure closure, and a polygon
package/src/index.ts CHANGED
@@ -1 +1,33 @@
1
- export { OsmixVtEncoder } from "./encode"
1
+ /**
2
+ * @osmix/vt - Mapbox Vector Tile encoding for OSM data.
3
+ *
4
+ * Converts `@osmix/core` OSM datasets into Mapbox Vector Tiles (MVT) format.
5
+ * Generates PBF-encoded tiles with separate layers for nodes, ways, and relations.
6
+ *
7
+ * Key features:
8
+ * - **Per-entity layers**: Separate layers for nodes, ways, and relations.
9
+ * - **Geometry conversion**: Ways become lines or polygons based on area heuristics.
10
+ * - **Multipolygon support**: Renders multipolygon relations as proper polygons with holes.
11
+ * - **Clipping**: Geometry is clipped to tile bounds with configurable buffer.
12
+ * - **Winding order**: Automatically enforces MVT spec (CW outer, CCW inner rings).
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * import { OsmixVtEncoder } from "@osmix/vt"
17
+ *
18
+ * const encoder = new OsmixVtEncoder(osm)
19
+ * const pbfBuffer = encoder.getTile([9372, 12535, 15])
20
+ *
21
+ * // Use with MapLibre or other vector tile renderers
22
+ * map.addSource("osmix", {
23
+ * type: "vector",
24
+ * tiles: [/* ... generate tile URLs ... *\/]
25
+ * })
26
+ * ```
27
+ *
28
+ * @module @osmix/vt
29
+ */
30
+
31
+ export { OsmixVtEncoder, projectToTile } from "./encode"
32
+ export * from "./types"
33
+ export { default as writeVtPbf } from "./write-vt-pbf"
package/src/types.ts CHANGED
@@ -1,20 +1,43 @@
1
- import type { OsmEntityType, OsmTags } from "@osmix/json"
2
- import type { XY } from "@osmix/shared/types"
1
+ /**
2
+ * Type definitions for vector tile encoding.
3
+ *
4
+ * These types represent the intermediate format used when converting
5
+ * OSM entities to Mapbox Vector Tile features.
6
+ *
7
+ * @module
8
+ */
3
9
 
10
+ import type { OsmTags, XY } from "@osmix/shared/types"
11
+
12
+ /**
13
+ * Geometry for a vector tile feature.
14
+ * Array of rings/segments, where each ring/segment is an array of [x, y] coordinates.
15
+ */
4
16
  export type VtSimpleFeatureGeometry = XY[][]
5
17
 
18
+ /**
19
+ * Properties attached to a vector tile feature.
20
+ * Includes OSM tags plus optional source metadata.
21
+ */
6
22
  export type VtSimpleFeatureProperties = {
7
23
  sourceId?: string
8
- type: OsmEntityType
9
24
  tileKey?: string
10
25
  } & OsmTags
11
26
 
27
+ /**
28
+ * MVT geometry type constants.
29
+ * Point = 1, Line = 2, Polygon = 3.
30
+ */
12
31
  export type VtSimpleFeatureType = {
13
32
  POINT: 1
14
33
  LINE: 2
15
34
  POLYGON: 3
16
35
  }
17
36
 
37
+ /**
38
+ * A simplified vector tile feature ready for encoding.
39
+ * Geometry coordinates are in tile extent units (typically 0-4096).
40
+ */
18
41
  export interface VtSimpleFeature {
19
42
  id: number
20
43
  type: VtSimpleFeatureType[keyof VtSimpleFeatureType]
@@ -22,6 +45,10 @@ export interface VtSimpleFeature {
22
45
  geometry: VtSimpleFeatureGeometry
23
46
  }
24
47
 
48
+ /**
49
+ * A layer to be written to the vector tile PBF.
50
+ * Features are provided via a generator to support lazy evaluation.
51
+ */
25
52
  export type VtPbfLayer = {
26
53
  name: string
27
54
  version: number
@@ -1,6 +1,20 @@
1
+ /**
2
+ * Vector tile PBF writer.
3
+ *
4
+ * Encodes vector tile layers and features into the Mapbox Vector Tile
5
+ * binary format (PBF/protobuf). Handles key/value deduplication,
6
+ * geometry encoding with delta compression, and proper command sequences.
7
+ *
8
+ * @see https://github.com/mapbox/vector-tile-spec
9
+ *
10
+ * @module
11
+ */
12
+
13
+ import { zigzag, zigzag32 } from "@osmix/shared/zigzag"
1
14
  import Pbf from "pbf"
2
15
  import type { VtPbfLayer, VtSimpleFeature } from "./types"
3
16
 
17
+ /** Internal context for encoding a layer's features. */
4
18
  type VtLayerContext = {
5
19
  feature: VtSimpleFeature
6
20
  keys: string[]
@@ -10,7 +24,10 @@ type VtLayerContext = {
10
24
  }
11
25
 
12
26
  /**
13
- * Write Vector Tile Layers to a PBF buffer
27
+ * Write vector tile layers to a PBF buffer.
28
+ *
29
+ * @param layers - Array of layers to encode.
30
+ * @returns ArrayBuffer containing the encoded vector tile.
14
31
  */
15
32
  export default function writeVtPbf(layers: VtPbfLayer[]) {
16
33
  const pbf = new Pbf()
@@ -53,7 +70,12 @@ function writeLayer(layer: VtPbfLayer, pbf: Pbf) {
53
70
 
54
71
  function writeFeature(ctx: VtLayerContext, pbf: Pbf) {
55
72
  if (ctx.feature.id !== undefined) {
56
- pbf.writeVarintField(1, ctx.feature.id)
73
+ const id = ctx.feature.id
74
+
75
+ // Use zigzag encoding for IDs to convert negative IDs to positive numbers
76
+ // that can be properly decoded. Uses arithmetic-based encoding to support
77
+ // the full safe integer range.
78
+ pbf.writeVarintField(1, zigzag(id))
57
79
  }
58
80
 
59
81
  pbf.writeMessage(2, writeProperties, ctx)
@@ -93,10 +115,6 @@ function command(cmd: number, length: number) {
93
115
  return (length << 3) + (cmd & 0x7)
94
116
  }
95
117
 
96
- function zigzag(num: number) {
97
- return (num << 1) ^ (num >> 31)
98
- }
99
-
100
118
  function writeGeometry(feature: VtSimpleFeature, pbf: Pbf) {
101
119
  const type = feature.type
102
120
  let x = 0
@@ -116,8 +134,9 @@ function writeGeometry(feature: VtSimpleFeature, pbf: Pbf) {
116
134
  }
117
135
  const dx = xy[0] - x
118
136
  const dy = xy[1] - y
119
- pbf.writeVarint(zigzag(dx))
120
- pbf.writeVarint(zigzag(dy))
137
+ // Use bitwise zigzag for geometry deltas (small values, 32-bit is sufficient)
138
+ pbf.writeVarint(zigzag32(dx))
139
+ pbf.writeVarint(zigzag32(dy))
121
140
  x += dx
122
141
  y += dy
123
142
  })