@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 +10 -0
- package/README.md +95 -0
- package/package.json +50 -0
- package/src/encode.test.ts +104 -0
- package/src/encode.ts +240 -0
- package/src/index.ts +1 -0
- package/src/types.ts +30 -0
- package/src/write-vt-pbf.ts +148 -0
- package/tsconfig.json +9 -0
package/CHANGELOG.md
ADDED
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
|
+
}
|