@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/CHANGELOG.md +38 -0
- package/README.md +41 -45
- package/dist/encode.d.ts +42 -6
- package/dist/encode.d.ts.map +1 -1
- package/dist/encode.js +225 -56
- package/dist/encode.js.map +1 -1
- package/dist/encode.test.js +364 -21
- package/dist/encode.test.js.map +1 -1
- package/dist/index.d.ts +32 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +32 -1
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +29 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -1
- package/dist/write-vt-pbf.d.ts +15 -1
- package/dist/write-vt-pbf.d.ts.map +1 -1
- package/dist/write-vt-pbf.js +24 -7
- package/dist/write-vt-pbf.js.map +1 -1
- package/package.json +6 -7
- package/src/encode.test.ts +411 -21
- package/src/encode.ts +245 -56
- package/src/index.ts +33 -1
- package/src/types.ts +30 -3
- package/src/write-vt-pbf.ts +27 -8
package/src/encode.ts
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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:${
|
|
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
|
|
67
|
-
const
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
148
|
+
const tags = this.osm.nodes.tags.getTags(nodeIndex)
|
|
98
149
|
if (!tags || Object.keys(tags).length === 0) continue
|
|
99
|
-
const id = this.
|
|
100
|
-
const ll = this.
|
|
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"
|
|
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.
|
|
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.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
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.
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
|
181
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2
|
-
|
|
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
|
package/src/write-vt-pbf.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
pbf.writeVarint(
|
|
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
|
})
|