@osmix/vt 0.0.2 → 0.0.7
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 +46 -0
- package/README.md +42 -45
- package/dist/encode.d.ts +42 -6
- package/dist/encode.d.ts.map +1 -1
- package/dist/encode.js +231 -56
- package/dist/encode.js.map +1 -1
- package/dist/encode.test.js +365 -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 +13 -12
- package/src/encode.test.ts +412 -21
- package/src/encode.ts +251 -56
- package/src/index.ts +33 -1
- package/src/types.ts +30 -3
- package/src/write-vt-pbf.ts +27 -8
- package/tsconfig.build.json +5 -0
package/src/encode.ts
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
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"
|
|
13
|
+
import { normalizeHexColor } from "@osmix/shared/color"
|
|
3
14
|
import { clipPolygon, clipPolyline } from "@osmix/shared/lineclip"
|
|
4
|
-
import
|
|
15
|
+
import { llToTilePx, tileToBbox } from "@osmix/shared/tile"
|
|
5
16
|
import type { GeoBbox2D, LonLat, Tile, XY } from "@osmix/shared/types"
|
|
17
|
+
import { wayIsArea } from "@osmix/shared/way-is-area"
|
|
6
18
|
import type {
|
|
7
19
|
VtSimpleFeature,
|
|
8
20
|
VtSimpleFeatureGeometry,
|
|
@@ -10,7 +22,9 @@ import type {
|
|
|
10
22
|
} from "./types"
|
|
11
23
|
import writeVtPbf from "./write-vt-pbf"
|
|
12
24
|
|
|
25
|
+
/** Default tile extent (coordinate resolution). */
|
|
13
26
|
const DEFAULT_EXTENT = 4096
|
|
27
|
+
/** Default buffer around tile in extent units. */
|
|
14
28
|
const DEFAULT_BUFFER = 64
|
|
15
29
|
|
|
16
30
|
const SF_TYPE: VtSimpleFeatureType = {
|
|
@@ -34,47 +48,79 @@ function dedupePoints(points: XY[]): XY[] {
|
|
|
34
48
|
return result
|
|
35
49
|
}
|
|
36
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Returns a projection function that converts [lon, lat] to [x, y] pixel coordinates
|
|
53
|
+
* relative to the given tile. The extent determines the resolution of the tile
|
|
54
|
+
* (e.g. 4096 means coordinates range from 0 to 4096).
|
|
55
|
+
*/
|
|
37
56
|
export function projectToTile(
|
|
38
57
|
tile: Tile,
|
|
39
58
|
extent = DEFAULT_EXTENT,
|
|
40
59
|
): (ll: LonLat) => XY {
|
|
41
|
-
|
|
42
|
-
return (lonLat) => sm.llToTilePx(lonLat)
|
|
60
|
+
return (lonLat) => llToTilePx(lonLat, tile, extent)
|
|
43
61
|
}
|
|
44
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Encode an Osm instance into a Mapbox Vector Tile PBF.
|
|
65
|
+
*/
|
|
45
66
|
export class OsmixVtEncoder {
|
|
46
67
|
readonly nodeLayerName: string
|
|
47
68
|
readonly wayLayerName: string
|
|
48
|
-
|
|
69
|
+
readonly relationLayerName: string
|
|
70
|
+
private readonly osm: Osm
|
|
49
71
|
private readonly extent: number
|
|
50
72
|
private readonly extentBbox: [number, number, number, number]
|
|
51
73
|
|
|
52
|
-
|
|
53
|
-
|
|
74
|
+
static layerNames(id: string) {
|
|
75
|
+
return {
|
|
76
|
+
nodeLayerName: `@osmix:${id}:nodes`,
|
|
77
|
+
wayLayerName: `@osmix:${id}:ways`,
|
|
78
|
+
relationLayerName: `@osmix:${id}:relations`,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
constructor(osm: Osm, extent = DEFAULT_EXTENT, buffer = DEFAULT_BUFFER) {
|
|
83
|
+
this.osm = osm
|
|
54
84
|
|
|
55
85
|
const min = -buffer
|
|
56
86
|
const max = extent + buffer
|
|
57
87
|
this.extent = extent
|
|
58
88
|
this.extentBbox = [min, min, max, max]
|
|
59
89
|
|
|
60
|
-
const layerName = `@osmix:${
|
|
90
|
+
const layerName = `@osmix:${osm.id}`
|
|
61
91
|
this.nodeLayerName = `${layerName}:nodes`
|
|
62
92
|
this.wayLayerName = `${layerName}:ways`
|
|
93
|
+
this.relationLayerName = `${layerName}:relations`
|
|
63
94
|
}
|
|
64
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Get a vector tile PBF for a specific tile coordinate.
|
|
98
|
+
* Returns an empty buffer if the tile does not intersect with the OSM dataset.
|
|
99
|
+
*/
|
|
65
100
|
getTile(tile: Tile): ArrayBuffer {
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
|
|
101
|
+
const bbox = tileToBbox(tile)
|
|
102
|
+
const osmBbox = this.osm.bbox()
|
|
103
|
+
if (!bboxContainsOrIntersects(bbox, osmBbox)) {
|
|
104
|
+
return new ArrayBuffer(0)
|
|
105
|
+
}
|
|
106
|
+
return this.getTileForBbox(bbox, (ll) => llToTilePx(ll, tile, this.extent))
|
|
69
107
|
}
|
|
70
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Get a vector tile PBF for a specific geographic bounding box.
|
|
111
|
+
* @param bbox The bounding box to include features from.
|
|
112
|
+
* @param proj A function to project [lon, lat] to [x, y] within the tile extent.
|
|
113
|
+
*/
|
|
71
114
|
getTileForBbox(bbox: GeoBbox2D, proj: (ll: LonLat) => XY): ArrayBuffer {
|
|
72
|
-
|
|
115
|
+
// Get way IDs that are part of relations (to exclude from individual rendering)
|
|
116
|
+
const relationWayIds = this.osm.relations.getWayMemberIds()
|
|
117
|
+
|
|
118
|
+
const layers = [
|
|
73
119
|
{
|
|
74
120
|
name: this.wayLayerName,
|
|
75
121
|
version: 2,
|
|
76
122
|
extent: this.extent,
|
|
77
|
-
features: this.wayFeatures(bbox, proj),
|
|
123
|
+
features: this.wayFeatures(bbox, proj, relationWayIds),
|
|
78
124
|
},
|
|
79
125
|
{
|
|
80
126
|
name: this.nodeLayerName,
|
|
@@ -82,26 +128,32 @@ export class OsmixVtEncoder {
|
|
|
82
128
|
extent: this.extent,
|
|
83
129
|
features: this.nodeFeatures(bbox, proj),
|
|
84
130
|
},
|
|
85
|
-
|
|
86
|
-
|
|
131
|
+
{
|
|
132
|
+
name: this.relationLayerName,
|
|
133
|
+
version: 2,
|
|
134
|
+
extent: this.extent,
|
|
135
|
+
features: this.relationFeatures(bbox, proj),
|
|
136
|
+
},
|
|
137
|
+
]
|
|
138
|
+
return writeVtPbf(layers)
|
|
87
139
|
}
|
|
88
140
|
|
|
89
141
|
*nodeFeatures(
|
|
90
142
|
bbox: GeoBbox2D,
|
|
91
143
|
proj: (ll: LonLat) => XY,
|
|
92
144
|
): Generator<VtSimpleFeature> {
|
|
93
|
-
const nodeIndexes = this.
|
|
145
|
+
const nodeIndexes = this.osm.nodes.findIndexesWithinBbox(bbox)
|
|
94
146
|
for (let i = 0; i < nodeIndexes.length; i++) {
|
|
95
147
|
const nodeIndex = nodeIndexes[i]
|
|
96
148
|
if (nodeIndex === undefined) continue
|
|
97
|
-
const tags = this.
|
|
149
|
+
const tags = this.osm.nodes.tags.getTags(nodeIndex)
|
|
98
150
|
if (!tags || Object.keys(tags).length === 0) continue
|
|
99
|
-
const id = this.
|
|
100
|
-
const ll = this.
|
|
151
|
+
const id = this.osm.nodes.ids.at(nodeIndex)
|
|
152
|
+
const ll = this.osm.nodes.getNodeLonLat({ index: nodeIndex })
|
|
101
153
|
yield {
|
|
102
154
|
id,
|
|
103
155
|
type: SF_TYPE.POINT,
|
|
104
|
-
properties: { type: "node"
|
|
156
|
+
properties: { ...tags, type: "node" },
|
|
105
157
|
geometry: [[proj(ll)]],
|
|
106
158
|
}
|
|
107
159
|
}
|
|
@@ -110,36 +162,49 @@ export class OsmixVtEncoder {
|
|
|
110
162
|
*wayFeatures(
|
|
111
163
|
bbox: GeoBbox2D,
|
|
112
164
|
proj: (ll: LonLat) => XY,
|
|
165
|
+
relationWayIds?: Set<number>,
|
|
113
166
|
): Generator<VtSimpleFeature> {
|
|
114
|
-
const wayIndexes = this.
|
|
167
|
+
const wayIndexes = this.osm.ways.intersects(bbox)
|
|
115
168
|
for (let i = 0; i < wayIndexes.length; i++) {
|
|
116
169
|
const wayIndex = wayIndexes[i]
|
|
117
170
|
if (wayIndex === undefined) continue
|
|
118
|
-
const id = this.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const isArea = wayIsArea({
|
|
171
|
+
const id = this.osm.ways.ids.at(wayIndex)
|
|
172
|
+
// Skip ways that are part of relations (they will be rendered via relations)
|
|
173
|
+
if (id !== undefined && relationWayIds?.has(id)) continue
|
|
174
|
+
const tags = this.osm.ways.tags.getTags(wayIndex)
|
|
175
|
+
// Skip ways without tags (they are likely only for relations)
|
|
176
|
+
if (!tags || Object.keys(tags).length === 0) continue
|
|
177
|
+
const normalizedColor = normalizeHexColor(tags["color"] ?? tags["colour"])
|
|
178
|
+
const wayLine = this.osm.ways.getCoordinates(wayIndex)
|
|
179
|
+
const points: XY[] = wayLine.map((ll) => proj(ll))
|
|
180
|
+
|
|
181
|
+
const isArea = wayIsArea({
|
|
182
|
+
id,
|
|
183
|
+
refs: this.osm.ways.getRefIds(wayIndex),
|
|
184
|
+
tags,
|
|
185
|
+
})
|
|
129
186
|
const geometry: VtSimpleFeatureGeometry = []
|
|
130
187
|
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.
|
|
188
|
+
// 1. clip polygon in tile coords (returns array of rings)
|
|
189
|
+
const clippedRings = this.clipProjectedPolygon(points)
|
|
135
190
|
|
|
136
|
-
// 2.
|
|
137
|
-
|
|
191
|
+
// 2. process each ring (first is outer, rest would be holes if from relations)
|
|
192
|
+
for (let ringIndex = 0; ringIndex < clippedRings.length; ringIndex++) {
|
|
193
|
+
const clippedRing = clippedRings[ringIndex]
|
|
194
|
+
if (!clippedRing) continue
|
|
138
195
|
|
|
139
|
-
|
|
196
|
+
// Normalize winding order using rewind before processing
|
|
197
|
+
// GeoJSON: outer counterclockwise, inner clockwise
|
|
198
|
+
// MVT: outer clockwise, inner counterclockwise
|
|
199
|
+
const isOuter = ringIndex === 0
|
|
200
|
+
const processedRing = this.processClippedPolygonRing(
|
|
201
|
+
clippedRing,
|
|
202
|
+
isOuter,
|
|
203
|
+
)
|
|
140
204
|
|
|
141
|
-
|
|
142
|
-
|
|
205
|
+
if (processedRing.length > 0) {
|
|
206
|
+
geometry.push(processedRing)
|
|
207
|
+
}
|
|
143
208
|
}
|
|
144
209
|
} else {
|
|
145
210
|
const clippedSegmentsRaw = this.clipProjectedPolyline(points)
|
|
@@ -155,7 +220,11 @@ export class OsmixVtEncoder {
|
|
|
155
220
|
yield {
|
|
156
221
|
id,
|
|
157
222
|
type: isArea ? SF_TYPE.POLYGON : SF_TYPE.LINE,
|
|
158
|
-
properties: {
|
|
223
|
+
properties: {
|
|
224
|
+
...tags,
|
|
225
|
+
...(normalizedColor ? { color: normalizedColor } : {}),
|
|
226
|
+
type: "way",
|
|
227
|
+
},
|
|
159
228
|
geometry,
|
|
160
229
|
}
|
|
161
230
|
}
|
|
@@ -165,11 +234,14 @@ export class OsmixVtEncoder {
|
|
|
165
234
|
return clipPolyline(points, this.extentBbox)
|
|
166
235
|
}
|
|
167
236
|
|
|
168
|
-
clipProjectedPolygon(points: XY[]): XY[] {
|
|
169
|
-
|
|
237
|
+
clipProjectedPolygon(points: XY[]): XY[][] {
|
|
238
|
+
// clipPolygon returns a single ring, but we return as array for consistency
|
|
239
|
+
// with multi-ring support (e.g., from relations)
|
|
240
|
+
const clipped = clipPolygon(points, this.extentBbox)
|
|
241
|
+
return [clipped]
|
|
170
242
|
}
|
|
171
243
|
|
|
172
|
-
processClippedPolygonRing(rawRing: XY[]): XY[] {
|
|
244
|
+
processClippedPolygonRing(rawRing: XY[], isOuter: boolean): XY[] {
|
|
173
245
|
// 1. round & clamp EVERY point
|
|
174
246
|
const snapped = rawRing.map((xy) => this.clampAndRoundPoint(xy))
|
|
175
247
|
|
|
@@ -177,12 +249,130 @@ export class OsmixVtEncoder {
|
|
|
177
249
|
const cleaned = cleanRing(snapped)
|
|
178
250
|
if (cleaned.length === 0) return []
|
|
179
251
|
|
|
180
|
-
// 3. enforce
|
|
181
|
-
|
|
252
|
+
// 3. enforce winding order per MVT spec:
|
|
253
|
+
// - Outer rings: clockwise
|
|
254
|
+
// - Inner rings (holes): counterclockwise
|
|
255
|
+
const oriented = isOuter
|
|
256
|
+
? ensureClockwise(cleaned)
|
|
257
|
+
: ensureCounterclockwise(cleaned)
|
|
182
258
|
|
|
183
259
|
return oriented
|
|
184
260
|
}
|
|
185
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Super relations and logical relations are not directly rendered; they would need recursive expansion.
|
|
264
|
+
*/
|
|
265
|
+
*relationFeatures(
|
|
266
|
+
bbox: GeoBbox2D,
|
|
267
|
+
proj: (ll: LonLat) => XY,
|
|
268
|
+
): Generator<VtSimpleFeature> {
|
|
269
|
+
const relationIndexes = this.osm.relations.intersects(bbox)
|
|
270
|
+
|
|
271
|
+
for (const relIndex of relationIndexes) {
|
|
272
|
+
const relation = this.osm.relations.getByIndex(relIndex)
|
|
273
|
+
const relationGeometry = this.osm.relations.getRelationGeometry(relIndex)
|
|
274
|
+
if (
|
|
275
|
+
!relation ||
|
|
276
|
+
(!relationGeometry.lineStrings &&
|
|
277
|
+
!relationGeometry.rings &&
|
|
278
|
+
!relationGeometry.points)
|
|
279
|
+
)
|
|
280
|
+
continue
|
|
281
|
+
|
|
282
|
+
const id = this.osm.relations.ids.at(relIndex)
|
|
283
|
+
const tags = this.osm.relations.tags.getTags(relIndex)
|
|
284
|
+
|
|
285
|
+
if (relationGeometry.rings) {
|
|
286
|
+
// Area relations (multipolygon, boundary)
|
|
287
|
+
const { rings } = relationGeometry
|
|
288
|
+
if (rings.length === 0) continue
|
|
289
|
+
|
|
290
|
+
// Process each polygon in the relation
|
|
291
|
+
for (const polygon of rings) {
|
|
292
|
+
const geometry: VtSimpleFeatureGeometry = []
|
|
293
|
+
|
|
294
|
+
// Process outer ring and inner rings (holes)
|
|
295
|
+
for (let ringIndex = 0; ringIndex < polygon.length; ringIndex++) {
|
|
296
|
+
const ring = polygon[ringIndex]
|
|
297
|
+
if (!ring || ring.length < 3) continue
|
|
298
|
+
|
|
299
|
+
// Project ring to tile coordinates
|
|
300
|
+
const projectedRing: XY[] = ring.map((ll: LonLat) => proj(ll))
|
|
301
|
+
|
|
302
|
+
// Clip polygon ring
|
|
303
|
+
const clipped = clipPolygon(projectedRing, this.extentBbox)
|
|
304
|
+
if (clipped.length < 3) continue
|
|
305
|
+
|
|
306
|
+
// Process ring (round/clamp, dedupe, close, orient)
|
|
307
|
+
const isOuter = ringIndex === 0
|
|
308
|
+
const processedRing = this.processClippedPolygonRing(
|
|
309
|
+
clipped,
|
|
310
|
+
isOuter,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
if (processedRing.length > 0) {
|
|
314
|
+
geometry.push(processedRing)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (geometry.length === 0) continue
|
|
319
|
+
|
|
320
|
+
yield {
|
|
321
|
+
id: id ?? 0,
|
|
322
|
+
type: SF_TYPE.POLYGON,
|
|
323
|
+
properties: { ...tags, type: "relation" },
|
|
324
|
+
geometry,
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
} else if (relationGeometry.lineStrings) {
|
|
328
|
+
// Line relations (route, multilinestring)
|
|
329
|
+
const { lineStrings } = relationGeometry
|
|
330
|
+
if (lineStrings.length === 0) continue
|
|
331
|
+
|
|
332
|
+
for (const lineString of lineStrings) {
|
|
333
|
+
const geometry: VtSimpleFeatureGeometry = []
|
|
334
|
+
const points: XY[] = lineString.map((ll) => proj(ll))
|
|
335
|
+
const clippedSegmentsRaw = this.clipProjectedPolyline(points)
|
|
336
|
+
for (const segment of clippedSegmentsRaw) {
|
|
337
|
+
const rounded = segment.map((xy) => this.clampAndRoundPoint(xy))
|
|
338
|
+
const deduped = dedupePoints(rounded)
|
|
339
|
+
if (deduped.length >= 2) {
|
|
340
|
+
geometry.push(deduped)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (geometry.length === 0) continue
|
|
344
|
+
|
|
345
|
+
yield {
|
|
346
|
+
id: id ?? 0,
|
|
347
|
+
type: SF_TYPE.LINE,
|
|
348
|
+
properties: { ...tags, type: "relation" },
|
|
349
|
+
geometry,
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
} else if (relationGeometry.points) {
|
|
353
|
+
// Point relations (multipoint)
|
|
354
|
+
const { points } = relationGeometry
|
|
355
|
+
if (points.length === 0) continue
|
|
356
|
+
|
|
357
|
+
const geometry: VtSimpleFeatureGeometry = []
|
|
358
|
+
for (const point of points) {
|
|
359
|
+
const projected = proj(point)
|
|
360
|
+
const clamped = this.clampAndRoundPoint(projected)
|
|
361
|
+
geometry.push([clamped])
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (geometry.length === 0) continue
|
|
365
|
+
|
|
366
|
+
yield {
|
|
367
|
+
id: id ?? 0,
|
|
368
|
+
type: SF_TYPE.POINT,
|
|
369
|
+
properties: { ...tags, type: "relation" },
|
|
370
|
+
geometry,
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
186
376
|
clampAndRoundPoint(xy: XY): XY {
|
|
187
377
|
const clampedX = Math.round(
|
|
188
378
|
clamp(xy[0], this.extentBbox[0], this.extentBbox[2]),
|
|
@@ -194,6 +384,10 @@ export class OsmixVtEncoder {
|
|
|
194
384
|
}
|
|
195
385
|
}
|
|
196
386
|
|
|
387
|
+
/**
|
|
388
|
+
* Ensures the ring is closed (first and last points are identical).
|
|
389
|
+
* If not, appends the first point to the end.
|
|
390
|
+
*/
|
|
197
391
|
function closeRing(ring: XY[]): XY[] {
|
|
198
392
|
const first = ring[0]
|
|
199
393
|
const last = ring[ring.length - 1]
|
|
@@ -204,8 +398,10 @@ function closeRing(ring: XY[]): XY[] {
|
|
|
204
398
|
return ring
|
|
205
399
|
}
|
|
206
400
|
|
|
207
|
-
|
|
208
|
-
|
|
401
|
+
/**
|
|
402
|
+
* Signed area via shoelace formula.
|
|
403
|
+
* Positive area => CCW, Negative => CW.
|
|
404
|
+
*/
|
|
209
405
|
function ringArea(ring: XY[]): number {
|
|
210
406
|
let sum = 0
|
|
211
407
|
for (let i = 0; i < ring.length - 1; i++) {
|
|
@@ -220,15 +416,14 @@ function ensureClockwise(ring: XY[]): XY[] {
|
|
|
220
416
|
return ringArea(ring) < 0 ? ring : [...ring].reverse()
|
|
221
417
|
}
|
|
222
418
|
|
|
223
|
-
|
|
224
|
-
* TODO handle MultiPolygons with holes
|
|
225
|
-
*
|
|
226
|
-
function _ensureCounterClockwise(ring: XY[]): XY[] {
|
|
419
|
+
function ensureCounterclockwise(ring: XY[]): XY[] {
|
|
227
420
|
return ringArea(ring) > 0 ? ring : [...ring].reverse()
|
|
228
421
|
}
|
|
229
|
-
*/
|
|
230
422
|
|
|
231
|
-
|
|
423
|
+
/**
|
|
424
|
+
* Clean a polygon ring by removing consecutive duplicates, ensuring it's closed,
|
|
425
|
+
* and checking that it has at least 4 coordinates (3 unique points).
|
|
426
|
+
*/
|
|
232
427
|
function cleanRing(ring: XY[]): XY[] {
|
|
233
428
|
const deduped = dedupePoints(ring)
|
|
234
429
|
// 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
|
})
|