From 97af75bb6aa6e79ef9948504ffe5bd9e08b78299 Mon Sep 17 00:00:00 2001 From: Eric Brelsford Date: Fri, 28 Jun 2024 08:49:54 -0400 Subject: [PATCH] Add mlt-vector-tile-js (#212) This PR adds a way to wrap MLT tiles in a similar API to [mapbox/vector-tile-js](https://github.com/mapbox/vector-tile-js/), which makes it possible to use MLTs in MapLibre GL JS. Eventually we would expect this to be a separate package but since this is experimental and the MLT API is changing we think it's reasonable to incubate this here for now. --- js/jest.config.json | 2 +- js/package-lock.json | 100 +----------------- js/src/mlt-vector-tile-js/README.md | 3 + js/src/mlt-vector-tile-js/VectorTile.ts | 16 +++ .../mlt-vector-tile-js/VectorTileFeature.ts | 38 +++++++ js/src/mlt-vector-tile-js/VectorTileLayer.ts | 23 ++++ js/src/mlt-vector-tile-js/index.ts | 3 + .../unit/mlt-vector-tile-js/LoadMLTTile.ts | 14 +++ .../unit/mlt-vector-tile-js/LoadMVTTile.ts | 11 ++ .../mlt-vector-tile-js/VectorTile.spec.ts | 38 +++++++ .../VectorTileFeature.spec.ts | 59 +++++++++++ .../VectorTileLayer.spec.ts | 28 +++++ 12 files changed, 235 insertions(+), 100 deletions(-) create mode 100644 js/src/mlt-vector-tile-js/README.md create mode 100644 js/src/mlt-vector-tile-js/VectorTile.ts create mode 100644 js/src/mlt-vector-tile-js/VectorTileFeature.ts create mode 100644 js/src/mlt-vector-tile-js/VectorTileLayer.ts create mode 100644 js/src/mlt-vector-tile-js/index.ts create mode 100644 js/test/unit/mlt-vector-tile-js/LoadMLTTile.ts create mode 100644 js/test/unit/mlt-vector-tile-js/LoadMVTTile.ts create mode 100644 js/test/unit/mlt-vector-tile-js/VectorTile.spec.ts create mode 100644 js/test/unit/mlt-vector-tile-js/VectorTileFeature.spec.ts create mode 100644 js/test/unit/mlt-vector-tile-js/VectorTileLayer.spec.ts diff --git a/js/jest.config.json b/js/jest.config.json index af7c181e..9d8a5b21 100644 --- a/js/jest.config.json +++ b/js/jest.config.json @@ -2,7 +2,7 @@ "transform": { "^.+\\.ts": "ts-jest" }, - "testRegex": "test/unit/(.*|(\\.|/)(test|spec))\\.(js|ts)$", + "testRegex": "test/unit/.*.spec\\.(js|ts)$", "moduleFileExtensions": ["ts", "js", "json", "node"], "workerThreads": true } diff --git a/js/package-lock.json b/js/package-lock.json index c46fdbe0..253b1fa6 100644 --- a/js/package-lock.json +++ b/js/package-lock.json @@ -651,54 +651,6 @@ "@bufbuild/buf-win32-x64": "1.34.0" } }, - "node_modules/@bufbuild/buf-darwin-arm64": { - "version": "1.34.0", - "resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-arm64/-/buf-darwin-arm64-1.34.0.tgz", - "integrity": "sha512-3+h/jSAr7H+KT8MWWRMbN/gQ87KlGLkTGwm4/mpry1ap9Thw/UdOrk5MfmbK3CRM/rlw4mAn1Egu/Q7R5eO98g==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@bufbuild/buf-darwin-x64": { - "version": "1.34.0", - "resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-x64/-/buf-darwin-x64-1.34.0.tgz", - "integrity": "sha512-Jdm0COuA2CMKoef2H8rBsRnc16mJUmCQ2KvJH5otvFrMhzPmr1MUyicCybY26HXFD/6DcnbWZvf6W8LfDMMyGQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@bufbuild/buf-linux-aarch64": { - "version": "1.34.0", - "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-aarch64/-/buf-linux-aarch64-1.34.0.tgz", - "integrity": "sha512-utSspJlPmVPh4Ugvn9k7MEEMHDZMI13jvwHkBE6wNSkYxxYTRR5zLHtmysaYQo51Fx+3ar6mL4HnhTqLrgO5GA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, "node_modules/@bufbuild/buf-linux-x64": { "version": "1.34.0", "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-x64/-/buf-linux-x64-1.34.0.tgz", @@ -715,38 +667,6 @@ "node": ">=12" } }, - "node_modules/@bufbuild/buf-win32-arm64": { - "version": "1.34.0", - "resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-arm64/-/buf-win32-arm64-1.34.0.tgz", - "integrity": "sha512-g1EogebjJ93bzmyn/fEi47tTz57M+7WYZ7/vX+DFXgLLYIxTWHDK4YN+3Hs+K7Sbx7KaVdsdEqof8xZ4WoVFnQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@bufbuild/buf-win32-x64": { - "version": "1.34.0", - "resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-x64/-/buf-win32-x64-1.34.0.tgz", - "integrity": "sha512-0rPXP7pV7+2twhcpN8hDdgV68UCiazLRcMBjWKubwcSJhAP8jRLqSJv3VGnXmpdYPbYGDQ0htfcgLNUvzllRhQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, "node_modules/@bufbuild/protobuf": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.0.tgz", @@ -1364,7 +1284,6 @@ "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { "@mapbox/point-geometry": "~0.1.0" } @@ -1575,7 +1494,6 @@ "resolved": "https://registry.npmjs.org/@types/varint/-/varint-6.0.3.tgz", "integrity": "sha512-DHukoGWdJ2aYkveZJTB2rN2lp6m7APzVsoJQ7j/qy1fQxyamJTPD5xQzCMoJ2Qtgn0mE3wWeNOpbTyBFvF+dyA==", "dev": true, - "license": "MIT", "dependencies": { "@types/node": "*" } @@ -3070,20 +2988,6 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -4930,7 +4834,6 @@ "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.2.1.tgz", "integrity": "sha512-ClrV7pNOn7rtmoQVF4TS1vyU0WhYRnP92fzbfF75jAIwpnzdJXf8iTd4CMEqO4yUenH6NDqLiwjqlh6QgZzgLQ==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { "ieee754": "^1.1.12", "resolve-protobuf-schema": "^2.1.0" @@ -5833,8 +5736,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/walker": { "version": "1.0.8", diff --git a/js/src/mlt-vector-tile-js/README.md b/js/src/mlt-vector-tile-js/README.md new file mode 100644 index 00000000..6e19da06 --- /dev/null +++ b/js/src/mlt-vector-tile-js/README.md @@ -0,0 +1,3 @@ +# mlt-vector-tile-js + +This directory contains an API that is intended to mirror [mapbox/vector-tile-js](https://github.com/mapbox/vector-tile-js/) for MLTs to make it easier to use MLTs where the vector-tile-js API is expected (such as MapLibre GL JS). diff --git a/js/src/mlt-vector-tile-js/VectorTile.ts b/js/src/mlt-vector-tile-js/VectorTile.ts new file mode 100644 index 00000000..98ebb8f9 --- /dev/null +++ b/js/src/mlt-vector-tile-js/VectorTile.ts @@ -0,0 +1,16 @@ +import { MltDecoder } from '../../src/decoder/MltDecoder'; +import { VectorTileLayer } from './VectorTileLayer'; + +class VectorTile { + layers: { [_: string]: VectorTileLayer } = {}; + + constructor(data: Uint8Array, featureTables: any) { + const decoded = MltDecoder.decodeMlTile(data, featureTables); + + for (const layerName of Object.keys(decoded.layers)) { + this.layers[layerName] = new VectorTileLayer(decoded.layers[layerName]); + } + } +} + +export { VectorTile }; diff --git a/js/src/mlt-vector-tile-js/VectorTileFeature.ts b/js/src/mlt-vector-tile-js/VectorTileFeature.ts new file mode 100644 index 00000000..6e101cf8 --- /dev/null +++ b/js/src/mlt-vector-tile-js/VectorTileFeature.ts @@ -0,0 +1,38 @@ +import Point = require("@mapbox/point-geometry"); + +class VectorTileFeature { + properties: { [key: string]: any } = {}; + extent: number; + type: 0|1|2|3 = 0; + id: number; + + _raw: any; + + constructor(feature) { + this.properties = feature.properties; + this.extent = feature.extent; + this._raw = feature; + if (feature.id !== null) { + this.id = Number(feature.id); + } + } + + loadGeometry(): Point[][] { + // TODO: optimize to avoid needing this deep copy + const newGeometry = []; + const oldGeometry = this._raw.loadGeometry(); + for (let i = 0; i < oldGeometry.length; i++) { + newGeometry[i] = []; + for (let j = 0; j < oldGeometry[i].length; j++) { + newGeometry[i][j] = new Point(oldGeometry[i][j].x, oldGeometry[i][j].y); + } + } + return newGeometry; + } + + toGeoJSON(x: Number, y: Number, z: Number): any { + return this._raw.toGeoJSON(x, y, z); + } +} + +export { VectorTileFeature }; diff --git a/js/src/mlt-vector-tile-js/VectorTileLayer.ts b/js/src/mlt-vector-tile-js/VectorTileLayer.ts new file mode 100644 index 00000000..a8a56d46 --- /dev/null +++ b/js/src/mlt-vector-tile-js/VectorTileLayer.ts @@ -0,0 +1,23 @@ +import { Layer } from '../data/Layer'; +import { VectorTileFeature } from './VectorTileFeature'; + +class VectorTileLayer { + version: number; + name: string | null; + extent: number; + length: number = 0; + + _raw: Layer; + + constructor(layer: Layer) { + this.name = layer.name; + this._raw = layer; + this.length = layer.features.length; + } + + feature(i: number): VectorTileFeature { + return new VectorTileFeature(this._raw.features[i]); + } +} + +export { VectorTileLayer }; diff --git a/js/src/mlt-vector-tile-js/index.ts b/js/src/mlt-vector-tile-js/index.ts new file mode 100644 index 00000000..eb99880e --- /dev/null +++ b/js/src/mlt-vector-tile-js/index.ts @@ -0,0 +1,3 @@ +export { VectorTile } from './VectorTile'; +export { VectorTileLayer } from './VectorTileLayer'; +export { VectorTileFeature } from './VectorTileFeature'; diff --git a/js/test/unit/mlt-vector-tile-js/LoadMLTTile.ts b/js/test/unit/mlt-vector-tile-js/LoadMLTTile.ts new file mode 100644 index 00000000..81748c4e --- /dev/null +++ b/js/test/unit/mlt-vector-tile-js/LoadMLTTile.ts @@ -0,0 +1,14 @@ +import * as fs from 'fs'; + +import { MltDecoder, TileSetMetadata } from '../../../src/index'; +import * as vt from '../../../src/mlt-vector-tile-js/index'; + +const loadTile = (tilePath, metadataPath) : vt.VectorTile => { + const data : Buffer = fs.readFileSync(tilePath); + const metadata : Buffer = fs.readFileSync(metadataPath); + const tilesetMetadata = TileSetMetadata.fromBinary(metadata); + const tile : vt.VectorTile = new vt.VectorTile(data, tilesetMetadata); + return tile; +}; + +export default loadTile; diff --git a/js/test/unit/mlt-vector-tile-js/LoadMVTTile.ts b/js/test/unit/mlt-vector-tile-js/LoadMVTTile.ts new file mode 100644 index 00000000..2bd1a71d --- /dev/null +++ b/js/test/unit/mlt-vector-tile-js/LoadMVTTile.ts @@ -0,0 +1,11 @@ +import * as fs from 'fs'; + +import { VectorTile } from '@mapbox/vector-tile'; +import Protobuf from 'pbf'; + +const loadTile = (tilePath) => { + const data : Buffer = fs.readFileSync(tilePath); + return new VectorTile(new Protobuf(data)); +}; + +export default loadTile; diff --git a/js/test/unit/mlt-vector-tile-js/VectorTile.spec.ts b/js/test/unit/mlt-vector-tile-js/VectorTile.spec.ts new file mode 100644 index 00000000..e4adc4e0 --- /dev/null +++ b/js/test/unit/mlt-vector-tile-js/VectorTile.spec.ts @@ -0,0 +1,38 @@ +import * as fs from 'fs'; + +import type { VectorTile as MvtVectorTile } from '@mapbox/vector-tile'; +import * as vt from '../../../src/mlt-vector-tile-js/index'; +import loadTile from './LoadMLTTile'; + +describe("VectorTile", () => { + it("should have all layers", () => { + const tile: vt.VectorTile = loadTile('../test/expected/bing/4-13-6.mlt', '../test/expected/bing/4-13-6.mlt.meta.pbf'); + + expect(Object.keys(tile.layers)).toEqual([ + "water_feature", + "road", + "land_cover_grass", + "country_region", + "land_cover_forest", + "road_hd", + "vector_background", + "populated_place", + "admin_division1", + ]); + }) + + it("should return valid GeoJSON for a feature", () => { + const tile: vt.VectorTile = loadTile('../test/expected/bing/4-13-6.mlt', '../test/expected/bing/4-13-6.mlt.meta.pbf'); + + const geojson = tile.layers['road'].feature(0).toGeoJSON(13, 6, 4); + + expect(geojson.type).toEqual('Feature'); + expect(geojson.geometry.type).toEqual('MultiLineString'); + }) + + it("should be equivalent to MVT VectorTile", () => { + const tile: vt.VectorTile = loadTile('../test/expected/bing/4-13-6.mlt', '../test/expected/bing/4-13-6.mlt.meta.pbf'); + + const mvtType : MvtVectorTile = tile; + }); +}); diff --git a/js/test/unit/mlt-vector-tile-js/VectorTileFeature.spec.ts b/js/test/unit/mlt-vector-tile-js/VectorTileFeature.spec.ts new file mode 100644 index 00000000..5e0a831e --- /dev/null +++ b/js/test/unit/mlt-vector-tile-js/VectorTileFeature.spec.ts @@ -0,0 +1,59 @@ +import * as fs from 'fs'; + +import { MltDecoder, TileSetMetadata } from '../../../src/index'; +import * as vt from '../../../src/mlt-vector-tile-js/index'; +import type { VectorTileFeature as MvtVectorTileFeature } from '@mapbox/vector-tile'; +import loadTile from './LoadMLTTile'; +import { default as loadMvtTile } from './LoadMVTTile'; + +describe("VectorTileFeature", () => { + it("should be assignable to MVT VectorTileFeature", () => { + const tile: vt.VectorTile = loadTile('../test/expected/bing/4-13-6.mlt', '../test/expected/bing/4-13-6.mlt.meta.pbf'); + const feature : vt.VectorTileFeature = tile.layers['road'].feature(0); + const mvtFeature : MvtVectorTileFeature = feature; + }); + + it("should return valid GeoJSON for a feature", () => { + const tile: vt.VectorTile = loadTile('../test/expected/bing/4-13-6.mlt', '../test/expected/bing/4-13-6.mlt.meta.pbf'); + + const feature = tile.layers['road'].feature(0); + const geojsonFeature: any = feature.toGeoJSON(13, 6, 4); + const geometry: any = geojsonFeature.geometry; + expect(geometry.coordinates[0][0][0]).not.toBe(undefined); + }); + + it("should load geometry", () => { + const tile: vt.VectorTile = loadTile('../test/expected/bing/4-13-6.mlt', '../test/expected/bing/4-13-6.mlt.meta.pbf'); + const feature = tile.layers['road'].feature(0); + expect(feature.loadGeometry()[0][0].x).not.toBe(undefined); + }); + + it("should have a valid extent", () => { + const tile: vt.VectorTile = loadTile('../test/expected/bing/4-13-6.mlt', '../test/expected/bing/4-13-6.mlt.meta.pbf'); + expect(tile.layers['road'].feature(0).extent).not.toBe(undefined); + }); + + it("should have same loadGeometry output as MVT", () => { + const tile: vt.VectorTile = loadTile('../test/expected/bing/4-13-6.mlt', '../test/expected/bing/4-13-6.mlt.meta.pbf'); + + const mvtTile = loadMvtTile('../test/fixtures/bing/4-13-6.mvt'); + + for (let i = 0; i < tile.layers['country_region'].length; i++) { + const mltFeature = tile.layers['country_region'].feature(i); + const mvtFeature = mvtTile.layers['country_region'].feature(i); + expect(mltFeature.loadGeometry()).toEqual(mvtFeature.loadGeometry()); + } + }); + + it.skip("should have same type output as MVT", () => { + const tile: vt.VectorTile = loadTile('../test/expected/bing/4-13-6.mlt', '../test/expected/bing/4-13-6.mlt.meta.pbf'); + + const mvtTile = loadMvtTile('../test/fixtures/bing/4-13-6.mvt'); + + for (let i = 0; i < tile.layers['land_cover_grass'].length; i++) { + const mltFeature = tile.layers['land_cover_grass'].feature(i); + const mvtFeature = mvtTile.layers['land_cover_grass'].feature(i); + expect(mltFeature.type).toEqual(mvtFeature.type); + } + }); +}); diff --git a/js/test/unit/mlt-vector-tile-js/VectorTileLayer.spec.ts b/js/test/unit/mlt-vector-tile-js/VectorTileLayer.spec.ts new file mode 100644 index 00000000..9d8d0341 --- /dev/null +++ b/js/test/unit/mlt-vector-tile-js/VectorTileLayer.spec.ts @@ -0,0 +1,28 @@ +import * as fs from 'fs'; + +import * as vt from '../../../src/mlt-vector-tile-js/index'; +import type { VectorTileLayer as MvtVectorTileLayer } from '@mapbox/vector-tile'; +import loadTile from './LoadMLTTile'; + +describe("VectorTileLayer", () => { + it("should be assignable to MVT VectorTileLayer", () => { + const tile: vt.VectorTile = loadTile('../test/expected/bing/4-13-6.mlt', '../test/expected/bing/4-13-6.mlt.meta.pbf'); + + const layer: vt.VectorTileLayer = tile.layers['road']; + const mvtLayer: MvtVectorTileLayer = layer; + }); + + it("should have the correct number of features", () => { + const tile: vt.VectorTile = loadTile('../test/expected/bing/4-13-6.mlt', '../test/expected/bing/4-13-6.mlt.meta.pbf'); + + expect(tile.layers['water_feature'].length).toEqual(20); + expect(tile.layers['road'].length).toEqual(18); + expect(tile.layers['land_cover_grass'].length).toEqual(1); + expect(tile.layers['country_region'].length).toEqual(6); + expect(tile.layers['land_cover_forest'].length).toEqual(1); + expect(tile.layers['road_hd'].length).toEqual(2); + expect(tile.layers['vector_background'].length).toEqual(1); + expect(tile.layers['populated_place'].length).toEqual(28); + expect(tile.layers['admin_division1'].length).toEqual(10); + }); +});