@osmix/vt 0.0.1

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 ADDED
@@ -0,0 +1,10 @@
1
+ # @osmix/vt
2
+
3
+ ## 0.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Initial release
8
+ - Updated dependencies
9
+ - @osmix/json@0.0.1
10
+ - @osmix/shared@0.0.1
package/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # @osmix/vt
2
+
3
+ `@osmix/vt` converts Osmix binary overlays (typed-array node and way payloads) into Mapbox Vector Tiles. It also ships a lightweight tile index with LRU caching so workers or web apps can request tiles on demand without re-projecting data for every request.
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ npm install @osmix/vt
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### Encode a single vector tile from an Osmix dataset
14
+
15
+ ```ts
16
+ import { Osmix } from "@osmix/core"
17
+ import { OsmixVtEncoder } from "@osmix/vt"
18
+
19
+ // Load or build an Osmix dataset (indexed nodes/ways/relations)
20
+ const osm = await Osmix.fromPbf(fetch("/fixtures/monaco.pbf").then((r) => r.arrayBuffer()))
21
+
22
+ // Create an encoder. Defaults: extent=4096, buffer=64px
23
+ const encoder = new OsmixVtEncoder(osm)
24
+
25
+ // XYZ tile tuple: [x, y, z]
26
+ const tile: [number, number, number] = [9372, 12535, 15]
27
+
28
+ // Returns an ArrayBuffer containing an MVT PBF for two layers:
29
+ // "@osmix:<id>:ways" and "@osmix:<id>:nodes"
30
+ const pbfBuffer = encoder.getTile(tile)
31
+
32
+ // Persist or send somewhere
33
+ await Deno.writeFile("tile.mvt", new Uint8Array(pbfBuffer))
34
+ ```
35
+
36
+ ### Custom bounding box and projection
37
+
38
+ If you already have a WGS84 bbox and a lon/lat → tile-pixel projection, you can render directly:
39
+
40
+ ```ts
41
+ import { OsmixVtEncoder, projectToTile } from "@osmix/vt"
42
+
43
+ const encoder = new OsmixVtEncoder(osm, 4096, 64)
44
+ const bbox: [number, number, number, number] = [-73.99, 40.73, -73.98, 40.74]
45
+ const proj = projectToTile([9372, 12535, 15], 4096)
46
+
47
+ const pbf = encoder.getTileForBbox(bbox, proj)
48
+ ```
49
+
50
+ ### Displaying in a browser (manual Blob URL)
51
+
52
+ Most viewers expect tile URLs. For quick inspection, you can create a Blob URL for a single tile:
53
+
54
+ ```ts
55
+ const buf = encoder.getTile([9372, 12535, 15])
56
+ const url = URL.createObjectURL(new Blob([buf], { type: "application/x-protobuf" }))
57
+ // Use `url` anywhere a single MVT URL is accepted (debug tooling, downloads, etc.)
58
+ ```
59
+
60
+ For full map integration, serve tiles from a handler that calls `getTile([x,y,z])` and returns the bytes. MapLibre/Mapbox GL can then point a `vector` source at `https://your-host/tiles/{z}/{x}/{y}.mvt`.
61
+
62
+ ## What gets encoded
63
+
64
+ - Ways become LINE features; AREA-like ways (per `wayIsArea`) become POLYGON features.
65
+ - Nodes with tags become POINT features. Untagged nodes are skipped.
66
+ - Each feature includes properties `{ type: "node" | "way", ...tags }` and `id`.
67
+ - Two layers are emitted per tile: `@osmix:<datasetId>:ways` and `@osmix:<datasetId>:nodes`.
68
+
69
+ ## API overview
70
+
71
+ - `class OsmixVtEncoder(osmix: Osmix, extent=4096, buffer=64)`
72
+ - `getTile(tile: [x, y, z]): ArrayBuffer` – Encodes a tile using internal bbox/projection.
73
+ - `getTileForBbox(bbox, proj): ArrayBuffer` – Encode for a WGS84 bbox with a lon/lat → tile-pixel projector.
74
+ - Internals expose generators for `nodeFeatures` and `wayFeatures` if you need to post-process.
75
+ - `projectToTile(tile: [x, y, z], extent=4096): (lonLat) => [x, y]` – Helper to build a projector matching the encoder.
76
+ - Types (from `src/types.ts`):
77
+ - `VtSimpleFeature` – `{ id, type, properties, geometry }`
78
+ - `VtPbfLayer` – `{ name, version, extent, features }`
79
+
80
+ ## Environment and limitations
81
+
82
+ - Designed for modern runtimes (Node 20+, Bun, browser workers). Uses typed arrays throughout.
83
+ - Polygon handling currently assumes a single outer ring; holes/multi-polygons (relations) are not encoded in v0.0.1.
84
+ - Ways are clipped to tile bounds; nodes outside the tile are omitted.
85
+ - Extent defaults to 4096; set a larger extent if you need higher precision.
86
+
87
+ ### Tags and metadata
88
+
89
+ - Feature properties include the OSM tags available in the source dataset. Styling keys can be derived at ingestion time; for very large tag sets consider pre-filtering to a stable subset to keep tile size reasonable.
90
+
91
+ ## See also
92
+
93
+ - `@osmix/core` – In-memory index used to source node/way geometry.
94
+ - `@osmix/json` – Supplies `wayIsArea` heuristics and entity types used by the encoder.
95
+ - `@osmix/raster` – If you prefer raster previews or a protocol helper for MapLibre.
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/package",
3
+ "name": "@osmix/vt",
4
+ "type": "module",
5
+ "version": "0.0.1",
6
+ "description": "Encode Osmix binary overlay tiles directly into Mapbox Vector Tiles",
7
+ "main": "./src/index.ts",
8
+ "publishConfig": {
9
+ "access": "public",
10
+ "main": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "import": "./dist/index.js",
15
+ "types": "./dist/index.d.ts"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "README.md",
21
+ "LICENSE"
22
+ ]
23
+ },
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/conveyal/osmix.git"
28
+ },
29
+ "homepage": "https://github.com/conveyal/osmix#readme",
30
+ "bugs": {
31
+ "url": "https://github.com/conveyal/osmix/issues"
32
+ },
33
+ "sideEffects": false,
34
+ "scripts": {
35
+ "build": "tsc",
36
+ "test": "vitest",
37
+ "typecheck": "tsc --noEmit"
38
+ },
39
+ "dependencies": {
40
+ "@osmix/shared": "workspace:*",
41
+ "@osmix/json": "workspace:*",
42
+ "pbf": "catalog:"
43
+ },
44
+ "devDependencies": {
45
+ "@mapbox/vector-tile": "^2.0.4",
46
+ "@osmix/core": "workspace:*",
47
+ "typescript": "catalog:",
48
+ "vitest": "catalog:"
49
+ }
50
+ }
@@ -0,0 +1,104 @@
1
+ import { VectorTile } from "@mapbox/vector-tile"
2
+ import { Osmix } from "@osmix/core"
3
+ import SphericalMercatorTile from "@osmix/shared/spherical-mercator"
4
+ import type { GeoBbox2D, Tile } from "@osmix/shared/types"
5
+ import Protobuf from "pbf"
6
+ import { assert, describe, expect, it } from "vitest"
7
+ import { OsmixVtEncoder } from "./encode"
8
+
9
+ const osm = new Osmix()
10
+ osm.nodes.addNode({
11
+ id: 1,
12
+ lat: 40,
13
+ lon: -74,
14
+ tags: {
15
+ name: "Test Node",
16
+ },
17
+ })
18
+
19
+ // Add nodes for way
20
+ osm.nodes.addNode({
21
+ id: 2,
22
+ lat: 40.72,
23
+ lon: -74.01,
24
+ })
25
+
26
+ osm.nodes.addNode({
27
+ id: 3,
28
+ lat: 40.715,
29
+ lon: -74.005,
30
+ })
31
+
32
+ osm.nodes.addNode({
33
+ id: 4,
34
+ lat: 40.7122,
35
+ lon: -74.001,
36
+ })
37
+
38
+ osm.ways.addWay({
39
+ id: 5,
40
+ refs: [1, 2],
41
+ })
42
+ osm.buildIndexes()
43
+ osm.buildSpatialIndexes()
44
+
45
+ const WAY_LAYER_ID = `@osmix:${osm.id}:ways`
46
+ const NODE_LAYER_ID = `@osmix:${osm.id}:nodes`
47
+
48
+ function decodeTile(data: ArrayBuffer) {
49
+ const tile = new VectorTile(new Protobuf(data))
50
+ return tile.layers
51
+ }
52
+
53
+ const extent = 4096
54
+ const merc = new SphericalMercatorTile({ size: extent })
55
+
56
+ function pointToTile(lon: number, lat: number, z: number): Tile {
57
+ const [px, py] = merc.px([lon, lat], z)
58
+ const x = Math.floor(px / extent)
59
+ const y = Math.floor(py / extent)
60
+ return [x, y, z]
61
+ }
62
+ function bboxToTile(bbox: GeoBbox2D, z = 8): Tile {
63
+ const [minX, minY, maxX, maxY] = bbox
64
+ const centerLon = (minX + maxX) / 2
65
+ const centerLat = (minY + maxY) / 2
66
+ return pointToTile(centerLon, centerLat, z)
67
+ }
68
+
69
+ describe("OsmixVtEncoder", () => {
70
+ it("encodes nodes and ways with expected metadata", () => {
71
+ const bbox = osm.bbox()
72
+ const tile = bboxToTile(bbox)
73
+ const encoder = new OsmixVtEncoder(osm)
74
+ const result = encoder.getTile(tile)
75
+
76
+ expect(result.byteLength).toBeGreaterThan(0)
77
+
78
+ const layers = decodeTile(result)
79
+ assert.isDefined(layers[NODE_LAYER_ID])
80
+ expect(layers[NODE_LAYER_ID].length).toBe(1)
81
+ assert.isDefined(layers[WAY_LAYER_ID])
82
+ expect(layers[WAY_LAYER_ID].length).toBe(1)
83
+
84
+ const features = [
85
+ layers[NODE_LAYER_ID].feature(0),
86
+ layers[WAY_LAYER_ID].feature(0),
87
+ ]
88
+
89
+ const node = features.find(
90
+ (feature) => feature.properties["type"] === "node",
91
+ )
92
+ expect(node?.id).toBe(1)
93
+ expect(node?.type).toBe(1)
94
+ const nodeGeom = node?.loadGeometry()
95
+ expect(nodeGeom?.[0]?.[0]?.x).toBeTypeOf("number")
96
+ expect(nodeGeom?.[0]?.[0]?.y).toBeTypeOf("number")
97
+
98
+ const way = features.find((feature) => feature.properties["type"] === "way")
99
+ expect(way?.id).toBe(5)
100
+ expect(way?.type).toBe(2)
101
+ const wayGeom = way?.loadGeometry()
102
+ expect(wayGeom?.[0]?.length).toBeGreaterThanOrEqual(2)
103
+ })
104
+ })
package/src/encode.ts ADDED
@@ -0,0 +1,240 @@
1
+ import type { Osmix } from "@osmix/core"
2
+ import { wayIsArea } from "@osmix/json"
3
+ import { clipPolygon, clipPolyline } from "@osmix/shared/lineclip"
4
+ import SphericalMercatorTile from "@osmix/shared/spherical-mercator"
5
+ import type { GeoBbox2D, LonLat, Tile, XY } from "@osmix/shared/types"
6
+ import type {
7
+ VtSimpleFeature,
8
+ VtSimpleFeatureGeometry,
9
+ VtSimpleFeatureType,
10
+ } from "./types"
11
+ import writeVtPbf from "./write-vt-pbf"
12
+
13
+ const DEFAULT_EXTENT = 4096
14
+ const DEFAULT_BUFFER = 64
15
+
16
+ const SF_TYPE: VtSimpleFeatureType = {
17
+ POINT: 1,
18
+ LINE: 2,
19
+ POLYGON: 3,
20
+ }
21
+
22
+ const clamp = (value: number, min: number, max: number) =>
23
+ Math.min(Math.max(value, min), max)
24
+
25
+ function dedupePoints(points: XY[]): XY[] {
26
+ if (points.length < 2) return points
27
+ const result: XY[] = []
28
+ let lastPoint: XY = [Number.NaN, Number.NaN]
29
+ for (const point of points) {
30
+ if (point[0] === lastPoint[0] && point[1] === lastPoint[1]) continue
31
+ result.push(point)
32
+ lastPoint = point
33
+ }
34
+ return result
35
+ }
36
+
37
+ export function projectToTile(
38
+ tile: Tile,
39
+ extent = DEFAULT_EXTENT,
40
+ ): (ll: LonLat) => XY {
41
+ const sm = new SphericalMercatorTile({ size: extent, tile })
42
+ return (lonLat) => sm.llToTilePx(lonLat)
43
+ }
44
+
45
+ export class OsmixVtEncoder {
46
+ private readonly nodeLayerName: string
47
+ private readonly wayLayerName: string
48
+ private readonly osmix: Osmix
49
+ private readonly extent: number
50
+ private readonly extentBbox: [number, number, number, number]
51
+
52
+ constructor(osmix: Osmix, extent = DEFAULT_EXTENT, buffer = DEFAULT_BUFFER) {
53
+ this.osmix = osmix
54
+
55
+ const min = -buffer
56
+ const max = extent + buffer
57
+ this.extent = extent
58
+ this.extentBbox = [min, min, max, max]
59
+
60
+ const layerName = `@osmix:${osmix.id}`
61
+ this.nodeLayerName = `${layerName}:nodes`
62
+ this.wayLayerName = `${layerName}:ways`
63
+ }
64
+
65
+ 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))
70
+ }
71
+
72
+ getTileForBbox(bbox: GeoBbox2D, proj: (ll: LonLat) => XY): ArrayBuffer {
73
+ const layer = writeVtPbf([
74
+ {
75
+ name: this.wayLayerName,
76
+ version: 2,
77
+ extent: this.extent,
78
+ features: this.wayFeatures(bbox, proj),
79
+ },
80
+ {
81
+ name: this.nodeLayerName,
82
+ version: 2,
83
+ extent: this.extent,
84
+ features: this.nodeFeatures(bbox, proj),
85
+ },
86
+ ])
87
+ return layer
88
+ }
89
+
90
+ *nodeFeatures(
91
+ bbox: GeoBbox2D,
92
+ proj: (ll: LonLat) => XY,
93
+ ): Generator<VtSimpleFeature> {
94
+ const nodeIndexes = this.osmix.nodes.withinBbox(bbox)
95
+ for (let i = 0; i < nodeIndexes.length; i++) {
96
+ const nodeIndex = nodeIndexes[i]
97
+ if (nodeIndex === undefined) continue
98
+ const tags = this.osmix.nodes.tags.getTags(nodeIndex)
99
+ 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 })
102
+ yield {
103
+ id,
104
+ type: SF_TYPE.POINT,
105
+ properties: { type: "node", ...tags },
106
+ geometry: [[proj(ll)]],
107
+ }
108
+ }
109
+ }
110
+
111
+ *wayFeatures(
112
+ bbox: GeoBbox2D,
113
+ proj: (ll: LonLat) => XY,
114
+ ): Generator<VtSimpleFeature> {
115
+ const wayIndexes = this.osmix.ways.intersects(bbox)
116
+ for (let i = 0; i < wayIndexes.length; i++) {
117
+ const wayIndex = wayIndexes[i]
118
+ 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 })
130
+ const geometry: VtSimpleFeatureGeometry = []
131
+ 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.
136
+
137
+ // 2. round/clamp, dedupe, close, orient
138
+ const processedRing = this.processClippedPolygonRing(clippedPoly)
139
+
140
+ // TODO handle MultiPolygons with holes
141
+
142
+ if (processedRing.length > 0) {
143
+ geometry.push(processedRing)
144
+ }
145
+ } else {
146
+ const clippedSegmentsRaw = this.clipProjectedPolyline(points)
147
+ for (const segment of clippedSegmentsRaw) {
148
+ const rounded = segment.map((xy) => this.clampAndRoundPoint(xy))
149
+ const deduped = dedupePoints(rounded)
150
+ if (deduped.length >= 2) {
151
+ geometry.push(deduped)
152
+ }
153
+ }
154
+ }
155
+ if (geometry.length === 0) continue
156
+ yield {
157
+ id,
158
+ type: isArea ? SF_TYPE.POLYGON : SF_TYPE.LINE,
159
+ properties: { type: "way", ...tags },
160
+ geometry,
161
+ }
162
+ }
163
+ }
164
+
165
+ clipProjectedPolyline(points: XY[]): XY[][] {
166
+ return clipPolyline(points, this.extentBbox)
167
+ }
168
+
169
+ clipProjectedPolygon(points: XY[]): XY[] {
170
+ return clipPolygon(points, this.extentBbox)
171
+ }
172
+
173
+ processClippedPolygonRing(rawRing: XY[]): XY[] {
174
+ // 1. round & clamp EVERY point
175
+ const snapped = rawRing.map((xy) => this.clampAndRoundPoint(xy))
176
+
177
+ // 2. clean (dedupe + close + min length)
178
+ const cleaned = cleanRing(snapped)
179
+ if (cleaned.length === 0) return []
180
+
181
+ // 3. enforce clockwise for outer ring
182
+ const oriented = ensureClockwise(cleaned)
183
+
184
+ return oriented
185
+ }
186
+
187
+ clampAndRoundPoint(xy: XY): XY {
188
+ const clampedX = Math.round(
189
+ clamp(xy[0], this.extentBbox[0], this.extentBbox[2]),
190
+ )
191
+ const clampedY = Math.round(
192
+ clamp(xy[1], this.extentBbox[1], this.extentBbox[3]),
193
+ )
194
+ return [clampedX, clampedY] as XY
195
+ }
196
+ }
197
+
198
+ function closeRing(ring: XY[]): XY[] {
199
+ const first = ring[0]
200
+ const last = ring[ring.length - 1]
201
+ if (first === undefined || last === undefined) return ring
202
+ if (first[0] !== last[0] || first[1] !== last[1]) {
203
+ return [...ring, first]
204
+ }
205
+ return ring
206
+ }
207
+
208
+ // Signed area via shoelace formula.
209
+ // Positive area => CCW, Negative => CW.
210
+ function ringArea(ring: XY[]): number {
211
+ let sum = 0
212
+ for (let i = 0; i < ring.length - 1; i++) {
213
+ const [x1, y1] = ring[i]!
214
+ const [x2, y2] = ring[i + 1]!
215
+ sum += x1 * y2 - x2 * y1
216
+ }
217
+ return sum / 2
218
+ }
219
+
220
+ function ensureClockwise(ring: XY[]): XY[] {
221
+ return ringArea(ring) < 0 ? ring : [...ring].reverse()
222
+ }
223
+
224
+ /**
225
+ * TODO handle MultiPolygons with holes
226
+ *
227
+ function _ensureCounterClockwise(ring: XY[]): XY[] {
228
+ return ringArea(ring) > 0 ? ring : [...ring].reverse()
229
+ }
230
+ */
231
+
232
+ // Remove consecutive duplicates *after* rounding
233
+ function cleanRing(ring: XY[]): XY[] {
234
+ const deduped = dedupePoints(ring)
235
+ // After dedupe, we still must ensure closure, and a polygon
236
+ // ring needs at least 4 coords (A,B,C,A).
237
+ const closed = closeRing(deduped)
238
+ if (closed.length < 4) return []
239
+ return closed
240
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { OsmixVtEncoder } from "./encode"
package/src/types.ts ADDED
@@ -0,0 +1,30 @@
1
+ import type { OsmEntityType, OsmTags } from "@osmix/json"
2
+ import type { XY } from "@osmix/shared/types"
3
+
4
+ export type VtSimpleFeatureGeometry = XY[][]
5
+
6
+ export type VtSimpleFeatureProperties = {
7
+ sourceId?: string
8
+ type: OsmEntityType
9
+ tileKey?: string
10
+ } & OsmTags
11
+
12
+ export type VtSimpleFeatureType = {
13
+ POINT: 1
14
+ LINE: 2
15
+ POLYGON: 3
16
+ }
17
+
18
+ export interface VtSimpleFeature {
19
+ id: number
20
+ type: VtSimpleFeatureType[keyof VtSimpleFeatureType]
21
+ properties: VtSimpleFeatureProperties
22
+ geometry: VtSimpleFeatureGeometry
23
+ }
24
+
25
+ export type VtPbfLayer = {
26
+ name: string
27
+ version: number
28
+ extent: number
29
+ features: Generator<VtSimpleFeature>
30
+ }
@@ -0,0 +1,148 @@
1
+ import Pbf from "pbf"
2
+ import type { VtPbfLayer, VtSimpleFeature } from "./types"
3
+
4
+ type VtLayerContext = {
5
+ feature: VtSimpleFeature
6
+ keys: string[]
7
+ values: unknown[]
8
+ keycache: Record<string, number>
9
+ valuecache: Record<string, number>
10
+ }
11
+
12
+ /**
13
+ * Write Vector Tile Layers to a PBF buffer
14
+ */
15
+ export default function writeVtPbf(layers: VtPbfLayer[]) {
16
+ const pbf = new Pbf()
17
+ for (const layer of layers) {
18
+ pbf.writeMessage(3, writeLayer, layer)
19
+ }
20
+ return pbf.finish().buffer as ArrayBuffer
21
+ }
22
+
23
+ function writeLayer(layer: VtPbfLayer, pbf: Pbf) {
24
+ pbf.writeVarintField(15, layer.version ?? 1)
25
+ pbf.writeStringField(1, layer.name ?? "")
26
+ pbf.writeVarintField(5, layer.extent ?? 4096)
27
+
28
+ let context: VtLayerContext | undefined
29
+ for (const feature of layer.features) {
30
+ if (!context) {
31
+ context = {
32
+ feature,
33
+ keys: [] as string[],
34
+ values: [],
35
+ keycache: {},
36
+ valuecache: {},
37
+ }
38
+ } else {
39
+ context.feature = feature
40
+ }
41
+ pbf.writeMessage(2, writeFeature, context)
42
+ }
43
+
44
+ if (!context) return
45
+ context.keys.forEach((key) => {
46
+ pbf.writeStringField(3, key)
47
+ })
48
+
49
+ context.values.forEach((value) => {
50
+ pbf.writeMessage(4, writeValue, value)
51
+ })
52
+ }
53
+
54
+ function writeFeature(ctx: VtLayerContext, pbf: Pbf) {
55
+ if (ctx.feature.id !== undefined) {
56
+ pbf.writeVarintField(1, ctx.feature.id)
57
+ }
58
+
59
+ pbf.writeMessage(2, writeProperties, ctx)
60
+ pbf.writeVarintField(3, ctx.feature.type)
61
+ pbf.writeMessage(4, writeGeometry, ctx.feature)
62
+ }
63
+
64
+ function writeProperties(ctx: VtLayerContext, pbf: Pbf) {
65
+ Object.entries(ctx.feature.properties).forEach(([key, value]) => {
66
+ let keyIndex = ctx.keycache[key]
67
+ if (value === null) return // don't encode null value properties
68
+
69
+ if (typeof keyIndex === "undefined") {
70
+ ctx.keys.push(key)
71
+ keyIndex = ctx.keys.length - 1
72
+ ctx.keycache[key] = keyIndex
73
+ }
74
+ pbf.writeVarint(keyIndex)
75
+
76
+ const type = typeof value
77
+ const valueStr =
78
+ type !== "string" && type !== "boolean" && type !== "number"
79
+ ? JSON.stringify(value)
80
+ : value
81
+ const valueKey = `${type}:${valueStr}`
82
+ let valueIndex = ctx.valuecache[valueKey]
83
+ if (typeof valueIndex === "undefined") {
84
+ ctx.values.push(value)
85
+ valueIndex = ctx.values.length - 1
86
+ ctx.valuecache[valueKey] = valueIndex
87
+ }
88
+ pbf.writeVarint(valueIndex)
89
+ })
90
+ }
91
+
92
+ function command(cmd: number, length: number) {
93
+ return (length << 3) + (cmd & 0x7)
94
+ }
95
+
96
+ function zigzag(num: number) {
97
+ return (num << 1) ^ (num >> 31)
98
+ }
99
+
100
+ function writeGeometry(feature: VtSimpleFeature, pbf: Pbf) {
101
+ const type = feature.type
102
+ let x = 0
103
+ let y = 0
104
+ feature.geometry.forEach((ring) => {
105
+ let count = 1
106
+ if (type === 1) {
107
+ count = ring.length
108
+ }
109
+ pbf.writeVarint(command(1, count)) // moveto
110
+ // do not write polygon closing path as lineto
111
+ const lineCount = type === 3 ? ring.length - 1 : ring.length
112
+ ring.forEach((xy, i) => {
113
+ if (i >= lineCount) return
114
+ if (i === 1 && type !== 1) {
115
+ pbf.writeVarint(command(2, lineCount - 1)) // lineto
116
+ }
117
+ const dx = xy[0] - x
118
+ const dy = xy[1] - y
119
+ pbf.writeVarint(zigzag(dx))
120
+ pbf.writeVarint(zigzag(dy))
121
+ x += dx
122
+ y += dy
123
+ })
124
+ if (type === 3) {
125
+ pbf.writeVarint(command(7, 1)) // closepath
126
+ }
127
+ })
128
+ }
129
+
130
+ function writeValue(value: unknown, pbf: Pbf) {
131
+ if (typeof value === "string") {
132
+ pbf.writeStringField(1, value)
133
+ } else if (typeof value === "boolean") {
134
+ pbf.writeBooleanField(7, value)
135
+ } else if (typeof value === "number") {
136
+ if (!Number.isFinite(value)) {
137
+ pbf.writeDoubleField(3, value)
138
+ } else if (value % 1 !== 0) {
139
+ pbf.writeDoubleField(3, value)
140
+ } else if (!Number.isSafeInteger(value)) {
141
+ pbf.writeDoubleField(3, value)
142
+ } else if (value < 0) {
143
+ pbf.writeSVarintField(6, value)
144
+ } else {
145
+ pbf.writeVarintField(5, value)
146
+ }
147
+ }
148
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "extends": "@osmix/shared/tsconfig/base.json",
4
+ "exclude": ["node_modules", "dist"],
5
+ "include": ["src"],
6
+ "compilerOptions": {
7
+ "outDir": "./dist"
8
+ }
9
+ }