@osmix/vt 0.0.2 → 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,47 +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
66
  readonly nodeLayerName: string
47
67
  readonly wayLayerName: string
48
- private readonly osmix: Osmix
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 bbox = sm.bbox(tile[0], tile[1], tile[2]) as GeoBbox2D
68
- 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))
69
106
  }
70
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
+ */
71
113
  getTileForBbox(bbox: GeoBbox2D, proj: (ll: LonLat) => XY): ArrayBuffer {
72
- 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 = [
73
118
  {
74
119
  name: this.wayLayerName,
75
120
  version: 2,
76
121
  extent: this.extent,
77
- features: this.wayFeatures(bbox, proj),
122
+ features: this.wayFeatures(bbox, proj, relationWayIds),
78
123
  },
79
124
  {
80
125
  name: this.nodeLayerName,
@@ -82,26 +127,32 @@ export class OsmixVtEncoder {
82
127
  extent: this.extent,
83
128
  features: this.nodeFeatures(bbox, proj),
84
129
  },
85
- ])
86
- 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)
87
138
  }
88
139
 
89
140
  *nodeFeatures(
90
141
  bbox: GeoBbox2D,
91
142
  proj: (ll: LonLat) => XY,
92
143
  ): Generator<VtSimpleFeature> {
93
- const nodeIndexes = this.osmix.nodes.withinBbox(bbox)
144
+ const nodeIndexes = this.osm.nodes.findIndexesWithinBbox(bbox)
94
145
  for (let i = 0; i < nodeIndexes.length; i++) {
95
146
  const nodeIndex = nodeIndexes[i]
96
147
  if (nodeIndex === undefined) continue
97
- const tags = this.osmix.nodes.tags.getTags(nodeIndex)
148
+ const tags = this.osm.nodes.tags.getTags(nodeIndex)
98
149
  if (!tags || Object.keys(tags).length === 0) continue
99
- const id = this.osmix.nodes.ids.at(nodeIndex)
100
- 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 })
101
152
  yield {
102
153
  id,
103
154
  type: SF_TYPE.POINT,
104
- properties: { type: "node", ...tags },
155
+ properties: { ...tags, type: "node" },
105
156
  geometry: [[proj(ll)]],
106
157
  }
107
158
  }
@@ -110,36 +161,48 @@ export class OsmixVtEncoder {
110
161
  *wayFeatures(
111
162
  bbox: GeoBbox2D,
112
163
  proj: (ll: LonLat) => XY,
164
+ relationWayIds?: Set<number>,
113
165
  ): Generator<VtSimpleFeature> {
114
- const wayIndexes = this.osmix.ways.intersects(bbox)
166
+ const wayIndexes = this.osm.ways.intersects(bbox)
115
167
  for (let i = 0; i < wayIndexes.length; i++) {
116
168
  const wayIndex = wayIndexes[i]
117
169
  if (wayIndex === undefined) continue
118
- const id = this.osmix.ways.ids.at(wayIndex)
119
- const tags = this.osmix.ways.tags.getTags(wayIndex)
120
- const count = this.osmix.ways.refCount.at(wayIndex)
121
- const start = this.osmix.ways.refStart.at(wayIndex)
122
- const points: XY[] = new Array(count)
123
- for (let i = 0; i < count; i++) {
124
- const ref = this.osmix.ways.refs.at(start + i)
125
- const ll = this.osmix.nodes.getNodeLonLat({ id: ref })
126
- points[i] = proj(ll)
127
- }
128
- 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
+ })
129
184
  const geometry: VtSimpleFeatureGeometry = []
130
185
  if (isArea) {
131
- // 1. clip polygon in tile coords
132
- const clippedPoly = this.clipProjectedPolygon(points)
133
- // clipProjectedPolygon currently returns XY[], not XY[][]
134
- // 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)
135
188
 
136
- // 2. round/clamp, dedupe, close, orient
137
- 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
138
193
 
139
- // 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
+ )
140
202
 
141
- if (processedRing.length > 0) {
142
- geometry.push(processedRing)
203
+ if (processedRing.length > 0) {
204
+ geometry.push(processedRing)
205
+ }
143
206
  }
144
207
  } else {
145
208
  const clippedSegmentsRaw = this.clipProjectedPolyline(points)
@@ -155,7 +218,7 @@ export class OsmixVtEncoder {
155
218
  yield {
156
219
  id,
157
220
  type: isArea ? SF_TYPE.POLYGON : SF_TYPE.LINE,
158
- properties: { type: "way", ...tags },
221
+ properties: { ...tags, type: "way" },
159
222
  geometry,
160
223
  }
161
224
  }
@@ -165,11 +228,14 @@ export class OsmixVtEncoder {
165
228
  return clipPolyline(points, this.extentBbox)
166
229
  }
167
230
 
168
- clipProjectedPolygon(points: XY[]): XY[] {
169
- 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]
170
236
  }
171
237
 
172
- processClippedPolygonRing(rawRing: XY[]): XY[] {
238
+ processClippedPolygonRing(rawRing: XY[], isOuter: boolean): XY[] {
173
239
  // 1. round & clamp EVERY point
174
240
  const snapped = rawRing.map((xy) => this.clampAndRoundPoint(xy))
175
241
 
@@ -177,12 +243,130 @@ export class OsmixVtEncoder {
177
243
  const cleaned = cleanRing(snapped)
178
244
  if (cleaned.length === 0) return []
179
245
 
180
- // 3. enforce clockwise for outer ring
181
- 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)
182
252
 
183
253
  return oriented
184
254
  }
185
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
+
186
370
  clampAndRoundPoint(xy: XY): XY {
187
371
  const clampedX = Math.round(
188
372
  clamp(xy[0], this.extentBbox[0], this.extentBbox[2]),
@@ -194,6 +378,10 @@ export class OsmixVtEncoder {
194
378
  }
195
379
  }
196
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
+ */
197
385
  function closeRing(ring: XY[]): XY[] {
198
386
  const first = ring[0]
199
387
  const last = ring[ring.length - 1]
@@ -204,8 +392,10 @@ function closeRing(ring: XY[]): XY[] {
204
392
  return ring
205
393
  }
206
394
 
207
- // Signed area via shoelace formula.
208
- // Positive area => CCW, Negative => CW.
395
+ /**
396
+ * Signed area via shoelace formula.
397
+ * Positive area => CCW, Negative => CW.
398
+ */
209
399
  function ringArea(ring: XY[]): number {
210
400
  let sum = 0
211
401
  for (let i = 0; i < ring.length - 1; i++) {
@@ -220,15 +410,14 @@ function ensureClockwise(ring: XY[]): XY[] {
220
410
  return ringArea(ring) < 0 ? ring : [...ring].reverse()
221
411
  }
222
412
 
223
- /**
224
- * TODO handle MultiPolygons with holes
225
- *
226
- function _ensureCounterClockwise(ring: XY[]): XY[] {
413
+ function ensureCounterclockwise(ring: XY[]): XY[] {
227
414
  return ringArea(ring) > 0 ? ring : [...ring].reverse()
228
415
  }
229
- */
230
416
 
231
- // 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
+ */
232
421
  function cleanRing(ring: XY[]): XY[] {
233
422
  const deduped = dedupePoints(ring)
234
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
  })