@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 CHANGED
@@ -1,5 +1,51 @@
1
1
  # @osmix/vt
2
2
 
3
+ ## 0.0.7
4
+
5
+ ### Patch Changes
6
+
7
+ - 68d6bd8: Fix publishing for packages.
8
+ - Updated dependencies [68d6bd8]
9
+ - @osmix/shared@0.0.7
10
+
11
+ ## 0.0.6
12
+
13
+ ### Patch Changes
14
+
15
+ - 2c03b6c: Add shortbread vector tile generation option
16
+ - 0cd8a2e: Explore patterns for extending Osmix worker
17
+ - Updated dependencies [0cd8a2e]
18
+ - @osmix/shared@0.0.6
19
+
20
+ ## 0.0.5
21
+
22
+ ### Patch Changes
23
+
24
+ - bb629cf: Simplify raster drawing when geometry is smaller than a pixel
25
+ - edbb26b: Handle more Relation types
26
+ - Updated dependencies [bb629cf]
27
+ - Updated dependencies [edbb26b]
28
+ - Updated dependencies [69a36bd]
29
+ - @osmix/shared@0.0.5
30
+
31
+ ## 0.0.4
32
+
33
+ ### Patch Changes
34
+
35
+ - d001d9a: Refactor to align around new main external API
36
+ - Updated dependencies [572cbd8]
37
+ - Updated dependencies [d001d9a]
38
+ - @osmix/shared@0.0.4
39
+
40
+ ## 0.0.3
41
+
42
+ ### Patch Changes
43
+
44
+ - b4a3ff2: Improve Relation handling and display
45
+ - Updated dependencies [b4a3ff2]
46
+ - @osmix/shared@0.0.3
47
+ - @osmix/json@0.0.3
48
+
3
49
  ## 0.0.2
4
50
 
5
51
  ### Patch Changes
package/README.md CHANGED
@@ -1,23 +1,23 @@
1
1
  # @osmix/vt
2
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.
3
+ `@osmix/vt` converts `@osmix/core` OSM into Mapbox Vector Tiles.
4
4
 
5
5
  ## Installation
6
6
 
7
7
  ```sh
8
- npm install @osmix/vt
8
+ bun install @osmix/vt
9
9
  ```
10
10
 
11
11
  ## Usage
12
12
 
13
- ### Encode a single vector tile from an Osmix dataset
13
+ ### Encode a single vector tile from an Osm dataset
14
14
 
15
15
  ```ts
16
- import { Osmix } from "@osmix/core"
16
+ import { Osm } from "@osmix/core"
17
17
  import { OsmixVtEncoder } from "@osmix/vt"
18
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()))
19
+ // Load your Osm dataset
20
+ const osm = await Osmix.fromPbf(Bun.file('./monaco.pbf').stream())
21
21
 
22
22
  // Create an encoder. Defaults: extent=4096, buffer=64px
23
23
  const encoder = new OsmixVtEncoder(osm)
@@ -25,71 +25,68 @@ const encoder = new OsmixVtEncoder(osm)
25
25
  // XYZ tile tuple: [x, y, z]
26
26
  const tile: [number, number, number] = [9372, 12535, 15]
27
27
 
28
- // Returns an ArrayBuffer containing an MVT PBF for two layers:
29
- // "@osmix:<id>:ways" and "@osmix:<id>:nodes"
28
+ // Returns an ArrayBuffer containing up to three layers:
29
+ // "@osmix:<id>:ways", "@osmix:<id>:nodes", "@osmix:<id>:relations"
30
30
  const pbfBuffer = encoder.getTile(tile)
31
-
32
- // Persist or send somewhere
33
- await Deno.writeFile("tile.mvt", new Uint8Array(pbfBuffer))
34
31
  ```
35
32
 
36
- ### Custom bounding box and projection
33
+ ### Displaying in a browser (manual Blob URL)
37
34
 
38
- If you already have a WGS84 bbox and a lon/lat tile-pixel projection, you can render directly:
35
+ Most viewers expect tile URLs. To see a Maplibre implementation in the [example merge app](/apps/merge/src/lib/osmix-vector-protocol.ts).
39
36
 
40
- ```ts
41
- import { OsmixVtEncoder, projectToTile } from "@osmix/vt"
37
+ ## What gets encoded
42
38
 
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)
39
+ - Ways become LINE features; AREA-like ways (per `wayIsArea`) become POLYGON features.
40
+ - Multipolygon relations render as POLYGON features in a dedicated layer so holes and shared ways stay intact.
41
+ - Nodes with tags become POINT features. Untagged nodes are skipped.
42
+ - Each feature includes properties `{ type: "node" | "way" | "relation", ...tags }` and `id`.
43
+ - Three layers are emitted per tile: `@osmix:<datasetId>:ways`, `@osmix:<datasetId>:nodes`, and `@osmix:<datasetId>:relations` (empty layers are omitted automatically).
46
44
 
47
- const pbf = encoder.getTileForBbox(bbox, proj)
48
- ```
45
+ ## API
49
46
 
50
- ### Displaying in a browser (manual Blob URL)
47
+ ### `OsmixVtEncoder`
51
48
 
52
- Most viewers expect tile URLs. For quick inspection, you can create a Blob URL for a single tile:
49
+ The main class for encoding vector tiles.
53
50
 
54
51
  ```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.)
52
+ constructor(osm: Osm, extent = 4096, buffer = 64)
58
53
  ```
59
54
 
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`.
55
+ - `osm`: The `@osmix/core` dataset to encode.
56
+ - `extent`: Tile extent (default 4096). Higher values offer more precision.
57
+ - `buffer`: Buffer around the tile in extent units (default 64).
61
58
 
62
- ## What gets encoded
59
+ #### `getTile(tile: [x, y, z]): ArrayBuffer`
63
60
 
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`.
61
+ Encodes a single tile identified by its XYZ coordinates. Returns a PBF `ArrayBuffer`.
68
62
 
69
- ## API overview
63
+ #### `getTileForBbox(bbox: [w, s, e, n], proj: (ll) => [x, y]): ArrayBuffer`
70
64
 
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 }`
65
+ Lower-level method to encode a specific bounding box with a custom projection function. Useful if you are projecting to non-Mercator tiles or need custom bounds.
79
66
 
80
67
  ## Environment and limitations
81
68
 
82
69
  - 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.
70
+ - Multipolygon relations are supported, but other relation types are skipped.
84
71
  - Ways are clipped to tile bounds; nodes outside the tile are omitted.
85
72
  - Extent defaults to 4096; set a larger extent if you need higher precision.
86
73
 
87
74
  ### Tags and metadata
88
75
 
89
76
  - 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.
77
+ - Way features normalize `color`/`colour` tags into a single `color` property formatted as `#RRGGBB` (or `#RRGGBBAA` when provided).
78
+
79
+ ## Related Packages
80
+
81
+ - [`@osmix/core`](../core/README.md) – In-memory index used to source node/way geometry.
82
+ - [`@osmix/shared`](../shared/README.md) – Supplies `wayIsArea` heuristics and entity types.
83
+ - [`@osmix/raster`](../raster/README.md) – Raster tile rendering if you prefer PNG/WebP output.
84
+ - [`osmix`](../osmix/README.md) – High-level API with `getVectorTile()` helper.
85
+
86
+ ## Development
90
87
 
91
- ## See also
88
+ - `bun run test packages/vt`
89
+ - `bun run lint packages/vt`
90
+ - `bun run typecheck packages/vt`
92
91
 
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.
92
+ Run `bun run check` at the repo root before publishing to ensure formatting, lint, and type coverage.
package/dist/encode.d.ts CHANGED
@@ -1,21 +1,57 @@
1
- import type { Osmix } from "@osmix/core";
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
+ import type { Osm } from "@osmix/core";
2
11
  import type { GeoBbox2D, LonLat, Tile, XY } from "@osmix/shared/types";
3
12
  import type { VtSimpleFeature } from "./types";
13
+ /**
14
+ * Returns a projection function that converts [lon, lat] to [x, y] pixel coordinates
15
+ * relative to the given tile. The extent determines the resolution of the tile
16
+ * (e.g. 4096 means coordinates range from 0 to 4096).
17
+ */
4
18
  export declare function projectToTile(tile: Tile, extent?: number): (ll: LonLat) => XY;
19
+ /**
20
+ * Encode an Osm instance into a Mapbox Vector Tile PBF.
21
+ */
5
22
  export declare class OsmixVtEncoder {
6
23
  readonly nodeLayerName: string;
7
24
  readonly wayLayerName: string;
8
- private readonly osmix;
25
+ readonly relationLayerName: string;
26
+ private readonly osm;
9
27
  private readonly extent;
10
28
  private readonly extentBbox;
11
- constructor(osmix: Osmix, extent?: number, buffer?: number);
29
+ static layerNames(id: string): {
30
+ nodeLayerName: string;
31
+ wayLayerName: string;
32
+ relationLayerName: string;
33
+ };
34
+ constructor(osm: Osm, extent?: number, buffer?: number);
35
+ /**
36
+ * Get a vector tile PBF for a specific tile coordinate.
37
+ * Returns an empty buffer if the tile does not intersect with the OSM dataset.
38
+ */
12
39
  getTile(tile: Tile): ArrayBuffer;
40
+ /**
41
+ * Get a vector tile PBF for a specific geographic bounding box.
42
+ * @param bbox The bounding box to include features from.
43
+ * @param proj A function to project [lon, lat] to [x, y] within the tile extent.
44
+ */
13
45
  getTileForBbox(bbox: GeoBbox2D, proj: (ll: LonLat) => XY): ArrayBuffer;
14
46
  nodeFeatures(bbox: GeoBbox2D, proj: (ll: LonLat) => XY): Generator<VtSimpleFeature>;
15
- wayFeatures(bbox: GeoBbox2D, proj: (ll: LonLat) => XY): Generator<VtSimpleFeature>;
47
+ wayFeatures(bbox: GeoBbox2D, proj: (ll: LonLat) => XY, relationWayIds?: Set<number>): Generator<VtSimpleFeature>;
16
48
  clipProjectedPolyline(points: XY[]): XY[][];
17
- clipProjectedPolygon(points: XY[]): XY[];
18
- processClippedPolygonRing(rawRing: XY[]): XY[];
49
+ clipProjectedPolygon(points: XY[]): XY[][];
50
+ processClippedPolygonRing(rawRing: XY[], isOuter: boolean): XY[];
51
+ /**
52
+ * Super relations and logical relations are not directly rendered; they would need recursive expansion.
53
+ */
54
+ relationFeatures(bbox: GeoBbox2D, proj: (ll: LonLat) => XY): Generator<VtSimpleFeature>;
19
55
  clampAndRoundPoint(xy: XY): XY;
20
56
  }
21
57
  //# sourceMappingURL=encode.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"encode.d.ts","sourceRoot":"","sources":["../src/encode.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAA;AAIxC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,EAAE,EAAE,MAAM,qBAAqB,CAAA;AACtE,OAAO,KAAK,EACX,eAAe,EAGf,MAAM,SAAS,CAAA;AA2BhB,wBAAgB,aAAa,CAC5B,IAAI,EAAE,IAAI,EACV,MAAM,SAAiB,GACrB,CAAC,EAAE,EAAE,MAAM,KAAK,EAAE,CAGpB;AAED,qBAAa,cAAc;IAC1B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAO;IAC7B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAQ;IAC/B,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAkC;gBAEjD,KAAK,EAAE,KAAK,EAAE,MAAM,SAAiB,EAAE,MAAM,SAAiB;IAa1E,OAAO,CAAC,IAAI,EAAE,IAAI,GAAG,WAAW;IAMhC,cAAc,CAAC,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,EAAE,GAAG,WAAW;IAkBrE,YAAY,CACZ,IAAI,EAAE,SAAS,EACf,IAAI,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,EAAE,GACtB,SAAS,CAAC,eAAe,CAAC;IAkB5B,WAAW,CACX,IAAI,EAAE,SAAS,EACf,IAAI,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,EAAE,GACtB,SAAS,CAAC,eAAe,CAAC;IAmD7B,qBAAqB,CAAC,MAAM,EAAE,EAAE,EAAE,GAAG,EAAE,EAAE,EAAE;IAI3C,oBAAoB,CAAC,MAAM,EAAE,EAAE,EAAE,GAAG,EAAE,EAAE;IAIxC,yBAAyB,CAAC,OAAO,EAAE,EAAE,EAAE,GAAG,EAAE,EAAE;IAc9C,kBAAkB,CAAC,EAAE,EAAE,EAAE,GAAG,EAAE;CAS9B"}
1
+ {"version":3,"file":"encode.d.ts","sourceRoot":"","sources":["../src/encode.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,aAAa,CAAA;AAKtC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,EAAE,EAAE,MAAM,qBAAqB,CAAA;AAEtE,OAAO,KAAK,EACX,eAAe,EAGf,MAAM,SAAS,CAAA;AA6BhB;;;;GAIG;AACH,wBAAgB,aAAa,CAC5B,IAAI,EAAE,IAAI,EACV,MAAM,SAAiB,GACrB,CAAC,EAAE,EAAE,MAAM,KAAK,EAAE,CAEpB;AAED;;GAEG;AACH,qBAAa,cAAc;IAC1B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAA;IAClC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAK;IACzB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAQ;IAC/B,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAkC;IAE7D,MAAM,CAAC,UAAU,CAAC,EAAE,EAAE,MAAM;;;;;gBAQhB,GAAG,EAAE,GAAG,EAAE,MAAM,SAAiB,EAAE,MAAM,SAAiB;IActE;;;OAGG;IACH,OAAO,CAAC,IAAI,EAAE,IAAI,GAAG,WAAW;IAShC;;;;OAIG;IACH,cAAc,CAAC,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,EAAE,GAAG,WAAW;IA2BrE,YAAY,CACZ,IAAI,EAAE,SAAS,EACf,IAAI,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,EAAE,GACtB,SAAS,CAAC,eAAe,CAAC;IAkB5B,WAAW,CACX,IAAI,EAAE,SAAS,EACf,IAAI,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,EAAE,EACxB,cAAc,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAC1B,SAAS,CAAC,eAAe,CAAC;IAmE7B,qBAAqB,CAAC,MAAM,EAAE,EAAE,EAAE,GAAG,EAAE,EAAE,EAAE;IAI3C,oBAAoB,CAAC,MAAM,EAAE,EAAE,EAAE,GAAG,EAAE,EAAE,EAAE;IAO1C,yBAAyB,CAAC,OAAO,EAAE,EAAE,EAAE,EAAE,OAAO,EAAE,OAAO,GAAG,EAAE,EAAE;IAkBhE;;OAEG;IACF,gBAAgB,CAChB,IAAI,EAAE,SAAS,EACf,IAAI,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,EAAE,GACtB,SAAS,CAAC,eAAe,CAAC;IA4G7B,kBAAkB,CAAC,EAAE,EAAE,EAAE,GAAG,EAAE;CAS9B"}
package/dist/encode.js CHANGED
@@ -1,8 +1,21 @@
1
- import { wayIsArea } from "@osmix/json";
1
+ /**
2
+ * Vector tile encoding for OSM data.
3
+ *
4
+ * The OsmixVtEncoder class converts Osmix datasets into Mapbox Vector Tiles,
5
+ * handling geometry projection, clipping, area detection, and proper
6
+ * MVT encoding for nodes, ways, and relations.
7
+ *
8
+ * @module
9
+ */
10
+ import { bboxContainsOrIntersects } from "@osmix/shared/bbox-intersects";
11
+ import { normalizeHexColor } from "@osmix/shared/color";
2
12
  import { clipPolygon, clipPolyline } from "@osmix/shared/lineclip";
3
- import SphericalMercatorTile from "@osmix/shared/spherical-mercator";
13
+ import { llToTilePx, tileToBbox } from "@osmix/shared/tile";
14
+ import { wayIsArea } from "@osmix/shared/way-is-area";
4
15
  import writeVtPbf from "./write-vt-pbf";
16
+ /** Default tile extent (coordinate resolution). */
5
17
  const DEFAULT_EXTENT = 4096;
18
+ /** Default buffer around tile in extent units. */
6
19
  const DEFAULT_BUFFER = 64;
7
20
  const SF_TYPE = {
8
21
  POINT: 1,
@@ -23,38 +36,68 @@ function dedupePoints(points) {
23
36
  }
24
37
  return result;
25
38
  }
39
+ /**
40
+ * Returns a projection function that converts [lon, lat] to [x, y] pixel coordinates
41
+ * relative to the given tile. The extent determines the resolution of the tile
42
+ * (e.g. 4096 means coordinates range from 0 to 4096).
43
+ */
26
44
  export function projectToTile(tile, extent = DEFAULT_EXTENT) {
27
- const sm = new SphericalMercatorTile({ size: extent, tile });
28
- return (lonLat) => sm.llToTilePx(lonLat);
45
+ return (lonLat) => llToTilePx(lonLat, tile, extent);
29
46
  }
47
+ /**
48
+ * Encode an Osm instance into a Mapbox Vector Tile PBF.
49
+ */
30
50
  export class OsmixVtEncoder {
31
51
  nodeLayerName;
32
52
  wayLayerName;
33
- osmix;
53
+ relationLayerName;
54
+ osm;
34
55
  extent;
35
56
  extentBbox;
36
- constructor(osmix, extent = DEFAULT_EXTENT, buffer = DEFAULT_BUFFER) {
37
- this.osmix = osmix;
57
+ static layerNames(id) {
58
+ return {
59
+ nodeLayerName: `@osmix:${id}:nodes`,
60
+ wayLayerName: `@osmix:${id}:ways`,
61
+ relationLayerName: `@osmix:${id}:relations`,
62
+ };
63
+ }
64
+ constructor(osm, extent = DEFAULT_EXTENT, buffer = DEFAULT_BUFFER) {
65
+ this.osm = osm;
38
66
  const min = -buffer;
39
67
  const max = extent + buffer;
40
68
  this.extent = extent;
41
69
  this.extentBbox = [min, min, max, max];
42
- const layerName = `@osmix:${osmix.id}`;
70
+ const layerName = `@osmix:${osm.id}`;
43
71
  this.nodeLayerName = `${layerName}:nodes`;
44
72
  this.wayLayerName = `${layerName}:ways`;
73
+ this.relationLayerName = `${layerName}:relations`;
45
74
  }
75
+ /**
76
+ * Get a vector tile PBF for a specific tile coordinate.
77
+ * Returns an empty buffer if the tile does not intersect with the OSM dataset.
78
+ */
46
79
  getTile(tile) {
47
- const sm = new SphericalMercatorTile({ size: this.extent, tile });
48
- const bbox = sm.bbox(tile[0], tile[1], tile[2]);
49
- return this.getTileForBbox(bbox, (ll) => sm.llToTilePx(ll));
80
+ const bbox = tileToBbox(tile);
81
+ const osmBbox = this.osm.bbox();
82
+ if (!bboxContainsOrIntersects(bbox, osmBbox)) {
83
+ return new ArrayBuffer(0);
84
+ }
85
+ return this.getTileForBbox(bbox, (ll) => llToTilePx(ll, tile, this.extent));
50
86
  }
87
+ /**
88
+ * Get a vector tile PBF for a specific geographic bounding box.
89
+ * @param bbox The bounding box to include features from.
90
+ * @param proj A function to project [lon, lat] to [x, y] within the tile extent.
91
+ */
51
92
  getTileForBbox(bbox, proj) {
52
- const layer = writeVtPbf([
93
+ // Get way IDs that are part of relations (to exclude from individual rendering)
94
+ const relationWayIds = this.osm.relations.getWayMemberIds();
95
+ const layers = [
53
96
  {
54
97
  name: this.wayLayerName,
55
98
  version: 2,
56
99
  extent: this.extent,
57
- features: this.wayFeatures(bbox, proj),
100
+ features: this.wayFeatures(bbox, proj, relationWayIds),
58
101
  },
59
102
  {
60
103
  name: this.nodeLayerName,
@@ -62,56 +105,73 @@ export class OsmixVtEncoder {
62
105
  extent: this.extent,
63
106
  features: this.nodeFeatures(bbox, proj),
64
107
  },
65
- ]);
66
- return layer;
108
+ {
109
+ name: this.relationLayerName,
110
+ version: 2,
111
+ extent: this.extent,
112
+ features: this.relationFeatures(bbox, proj),
113
+ },
114
+ ];
115
+ return writeVtPbf(layers);
67
116
  }
68
117
  *nodeFeatures(bbox, proj) {
69
- const nodeIndexes = this.osmix.nodes.withinBbox(bbox);
118
+ const nodeIndexes = this.osm.nodes.findIndexesWithinBbox(bbox);
70
119
  for (let i = 0; i < nodeIndexes.length; i++) {
71
120
  const nodeIndex = nodeIndexes[i];
72
121
  if (nodeIndex === undefined)
73
122
  continue;
74
- const tags = this.osmix.nodes.tags.getTags(nodeIndex);
123
+ const tags = this.osm.nodes.tags.getTags(nodeIndex);
75
124
  if (!tags || Object.keys(tags).length === 0)
76
125
  continue;
77
- const id = this.osmix.nodes.ids.at(nodeIndex);
78
- const ll = this.osmix.nodes.getNodeLonLat({ index: nodeIndex });
126
+ const id = this.osm.nodes.ids.at(nodeIndex);
127
+ const ll = this.osm.nodes.getNodeLonLat({ index: nodeIndex });
79
128
  yield {
80
129
  id,
81
130
  type: SF_TYPE.POINT,
82
- properties: { type: "node", ...tags },
131
+ properties: { ...tags, type: "node" },
83
132
  geometry: [[proj(ll)]],
84
133
  };
85
134
  }
86
135
  }
87
- *wayFeatures(bbox, proj) {
88
- const wayIndexes = this.osmix.ways.intersects(bbox);
136
+ *wayFeatures(bbox, proj, relationWayIds) {
137
+ const wayIndexes = this.osm.ways.intersects(bbox);
89
138
  for (let i = 0; i < wayIndexes.length; i++) {
90
139
  const wayIndex = wayIndexes[i];
91
140
  if (wayIndex === undefined)
92
141
  continue;
93
- const id = this.osmix.ways.ids.at(wayIndex);
94
- const tags = this.osmix.ways.tags.getTags(wayIndex);
95
- const count = this.osmix.ways.refCount.at(wayIndex);
96
- const start = this.osmix.ways.refStart.at(wayIndex);
97
- const points = new Array(count);
98
- for (let i = 0; i < count; i++) {
99
- const ref = this.osmix.ways.refs.at(start + i);
100
- const ll = this.osmix.nodes.getNodeLonLat({ id: ref });
101
- points[i] = proj(ll);
102
- }
103
- const isArea = wayIsArea({ id, refs: new Array(count).fill(0), tags });
142
+ const id = this.osm.ways.ids.at(wayIndex);
143
+ // Skip ways that are part of relations (they will be rendered via relations)
144
+ if (id !== undefined && relationWayIds?.has(id))
145
+ continue;
146
+ const tags = this.osm.ways.tags.getTags(wayIndex);
147
+ // Skip ways without tags (they are likely only for relations)
148
+ if (!tags || Object.keys(tags).length === 0)
149
+ continue;
150
+ const normalizedColor = normalizeHexColor(tags["color"] ?? tags["colour"]);
151
+ const wayLine = this.osm.ways.getCoordinates(wayIndex);
152
+ const points = wayLine.map((ll) => proj(ll));
153
+ const isArea = wayIsArea({
154
+ id,
155
+ refs: this.osm.ways.getRefIds(wayIndex),
156
+ tags,
157
+ });
104
158
  const geometry = [];
105
159
  if (isArea) {
106
- // 1. clip polygon in tile coords
107
- const clippedPoly = this.clipProjectedPolygon(points);
108
- // clipProjectedPolygon currently returns XY[], not XY[][]
109
- // i.e. assumes single ring. We'll treat it as one ring.
110
- // 2. round/clamp, dedupe, close, orient
111
- const processedRing = this.processClippedPolygonRing(clippedPoly);
112
- // TODO handle MultiPolygons with holes
113
- if (processedRing.length > 0) {
114
- geometry.push(processedRing);
160
+ // 1. clip polygon in tile coords (returns array of rings)
161
+ const clippedRings = this.clipProjectedPolygon(points);
162
+ // 2. process each ring (first is outer, rest would be holes if from relations)
163
+ for (let ringIndex = 0; ringIndex < clippedRings.length; ringIndex++) {
164
+ const clippedRing = clippedRings[ringIndex];
165
+ if (!clippedRing)
166
+ continue;
167
+ // Normalize winding order using rewind before processing
168
+ // GeoJSON: outer counterclockwise, inner clockwise
169
+ // MVT: outer clockwise, inner counterclockwise
170
+ const isOuter = ringIndex === 0;
171
+ const processedRing = this.processClippedPolygonRing(clippedRing, isOuter);
172
+ if (processedRing.length > 0) {
173
+ geometry.push(processedRing);
174
+ }
115
175
  }
116
176
  }
117
177
  else {
@@ -129,7 +189,11 @@ export class OsmixVtEncoder {
129
189
  yield {
130
190
  id,
131
191
  type: isArea ? SF_TYPE.POLYGON : SF_TYPE.LINE,
132
- properties: { type: "way", ...tags },
192
+ properties: {
193
+ ...tags,
194
+ ...(normalizedColor ? { color: normalizedColor } : {}),
195
+ type: "way",
196
+ },
133
197
  geometry,
134
198
  };
135
199
  }
@@ -138,25 +202,135 @@ export class OsmixVtEncoder {
138
202
  return clipPolyline(points, this.extentBbox);
139
203
  }
140
204
  clipProjectedPolygon(points) {
141
- return clipPolygon(points, this.extentBbox);
205
+ // clipPolygon returns a single ring, but we return as array for consistency
206
+ // with multi-ring support (e.g., from relations)
207
+ const clipped = clipPolygon(points, this.extentBbox);
208
+ return [clipped];
142
209
  }
143
- processClippedPolygonRing(rawRing) {
210
+ processClippedPolygonRing(rawRing, isOuter) {
144
211
  // 1. round & clamp EVERY point
145
212
  const snapped = rawRing.map((xy) => this.clampAndRoundPoint(xy));
146
213
  // 2. clean (dedupe + close + min length)
147
214
  const cleaned = cleanRing(snapped);
148
215
  if (cleaned.length === 0)
149
216
  return [];
150
- // 3. enforce clockwise for outer ring
151
- const oriented = ensureClockwise(cleaned);
217
+ // 3. enforce winding order per MVT spec:
218
+ // - Outer rings: clockwise
219
+ // - Inner rings (holes): counterclockwise
220
+ const oriented = isOuter
221
+ ? ensureClockwise(cleaned)
222
+ : ensureCounterclockwise(cleaned);
152
223
  return oriented;
153
224
  }
225
+ /**
226
+ * Super relations and logical relations are not directly rendered; they would need recursive expansion.
227
+ */
228
+ *relationFeatures(bbox, proj) {
229
+ const relationIndexes = this.osm.relations.intersects(bbox);
230
+ for (const relIndex of relationIndexes) {
231
+ const relation = this.osm.relations.getByIndex(relIndex);
232
+ const relationGeometry = this.osm.relations.getRelationGeometry(relIndex);
233
+ if (!relation ||
234
+ (!relationGeometry.lineStrings &&
235
+ !relationGeometry.rings &&
236
+ !relationGeometry.points))
237
+ continue;
238
+ const id = this.osm.relations.ids.at(relIndex);
239
+ const tags = this.osm.relations.tags.getTags(relIndex);
240
+ if (relationGeometry.rings) {
241
+ // Area relations (multipolygon, boundary)
242
+ const { rings } = relationGeometry;
243
+ if (rings.length === 0)
244
+ continue;
245
+ // Process each polygon in the relation
246
+ for (const polygon of rings) {
247
+ const geometry = [];
248
+ // Process outer ring and inner rings (holes)
249
+ for (let ringIndex = 0; ringIndex < polygon.length; ringIndex++) {
250
+ const ring = polygon[ringIndex];
251
+ if (!ring || ring.length < 3)
252
+ continue;
253
+ // Project ring to tile coordinates
254
+ const projectedRing = ring.map((ll) => proj(ll));
255
+ // Clip polygon ring
256
+ const clipped = clipPolygon(projectedRing, this.extentBbox);
257
+ if (clipped.length < 3)
258
+ continue;
259
+ // Process ring (round/clamp, dedupe, close, orient)
260
+ const isOuter = ringIndex === 0;
261
+ const processedRing = this.processClippedPolygonRing(clipped, isOuter);
262
+ if (processedRing.length > 0) {
263
+ geometry.push(processedRing);
264
+ }
265
+ }
266
+ if (geometry.length === 0)
267
+ continue;
268
+ yield {
269
+ id: id ?? 0,
270
+ type: SF_TYPE.POLYGON,
271
+ properties: { ...tags, type: "relation" },
272
+ geometry,
273
+ };
274
+ }
275
+ }
276
+ else if (relationGeometry.lineStrings) {
277
+ // Line relations (route, multilinestring)
278
+ const { lineStrings } = relationGeometry;
279
+ if (lineStrings.length === 0)
280
+ continue;
281
+ for (const lineString of lineStrings) {
282
+ const geometry = [];
283
+ const points = lineString.map((ll) => proj(ll));
284
+ const clippedSegmentsRaw = this.clipProjectedPolyline(points);
285
+ for (const segment of clippedSegmentsRaw) {
286
+ const rounded = segment.map((xy) => this.clampAndRoundPoint(xy));
287
+ const deduped = dedupePoints(rounded);
288
+ if (deduped.length >= 2) {
289
+ geometry.push(deduped);
290
+ }
291
+ }
292
+ if (geometry.length === 0)
293
+ continue;
294
+ yield {
295
+ id: id ?? 0,
296
+ type: SF_TYPE.LINE,
297
+ properties: { ...tags, type: "relation" },
298
+ geometry,
299
+ };
300
+ }
301
+ }
302
+ else if (relationGeometry.points) {
303
+ // Point relations (multipoint)
304
+ const { points } = relationGeometry;
305
+ if (points.length === 0)
306
+ continue;
307
+ const geometry = [];
308
+ for (const point of points) {
309
+ const projected = proj(point);
310
+ const clamped = this.clampAndRoundPoint(projected);
311
+ geometry.push([clamped]);
312
+ }
313
+ if (geometry.length === 0)
314
+ continue;
315
+ yield {
316
+ id: id ?? 0,
317
+ type: SF_TYPE.POINT,
318
+ properties: { ...tags, type: "relation" },
319
+ geometry,
320
+ };
321
+ }
322
+ }
323
+ }
154
324
  clampAndRoundPoint(xy) {
155
325
  const clampedX = Math.round(clamp(xy[0], this.extentBbox[0], this.extentBbox[2]));
156
326
  const clampedY = Math.round(clamp(xy[1], this.extentBbox[1], this.extentBbox[3]));
157
327
  return [clampedX, clampedY];
158
328
  }
159
329
  }
330
+ /**
331
+ * Ensures the ring is closed (first and last points are identical).
332
+ * If not, appends the first point to the end.
333
+ */
160
334
  function closeRing(ring) {
161
335
  const first = ring[0];
162
336
  const last = ring[ring.length - 1];
@@ -167,8 +341,10 @@ function closeRing(ring) {
167
341
  }
168
342
  return ring;
169
343
  }
170
- // Signed area via shoelace formula.
171
- // Positive area => CCW, Negative => CW.
344
+ /**
345
+ * Signed area via shoelace formula.
346
+ * Positive area => CCW, Negative => CW.
347
+ */
172
348
  function ringArea(ring) {
173
349
  let sum = 0;
174
350
  for (let i = 0; i < ring.length - 1; i++) {
@@ -181,14 +357,13 @@ function ringArea(ring) {
181
357
  function ensureClockwise(ring) {
182
358
  return ringArea(ring) < 0 ? ring : [...ring].reverse();
183
359
  }
184
- /**
185
- * TODO handle MultiPolygons with holes
186
- *
187
- function _ensureCounterClockwise(ring: XY[]): XY[] {
188
- return ringArea(ring) > 0 ? ring : [...ring].reverse()
360
+ function ensureCounterclockwise(ring) {
361
+ return ringArea(ring) > 0 ? ring : [...ring].reverse();
189
362
  }
190
- */
191
- // Remove consecutive duplicates *after* rounding
363
+ /**
364
+ * Clean a polygon ring by removing consecutive duplicates, ensuring it's closed,
365
+ * and checking that it has at least 4 coordinates (3 unique points).
366
+ */
192
367
  function cleanRing(ring) {
193
368
  const deduped = dedupePoints(ring);
194
369
  // After dedupe, we still must ensure closure, and a polygon