diff --git a/examples/source_pmtiles_vector.html b/examples/source_pmtiles_vector.html new file mode 100644 index 0000000000..ee30ab1c36 --- /dev/null +++ b/examples/source_pmtiles_vector.html @@ -0,0 +1,358 @@ + + + Itowns - PMTiles Vector Source + + + + + + + + + + + + + +
+
+

PMTiles Vector Source

+

Load vector tiles from a PMTiles archive:

+ + + + +
+
+ + + + diff --git a/package-lock.json b/package-lock.json index 3d24ab60ff..54e0ae0d29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -140,7 +140,6 @@ "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -2523,7 +2522,6 @@ "version": "8.17.10", "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.17.10.tgz", "integrity": "sha512-S6bqa4DqUooEkInYv/W+Jklv2zjSYCXAhm6qKpAQyOXhTEt5gBXnA7W6aoJ0bjmp9pAeaSj/AZUoz1HCSof/uA==", - "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/debounce": "^1.2.1", @@ -2597,7 +2595,6 @@ "resolved": "https://registry.npmjs.org/@react-three/postprocessing/-/postprocessing-2.19.1.tgz", "integrity": "sha512-7P25LOSToH/I6b3UipNK17IIFlX4FDUmWcaomfwu82+CzhXTOz8Fcc1ZXEZ7vFA/5Fr/2peNlXgXZJvoa+aCdA==", "license": "MIT", - "peer": true, "dependencies": { "buffer": "^6.0.3", "maath": "^0.6.0", @@ -3066,7 +3063,6 @@ "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" @@ -3212,7 +3208,6 @@ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.174.0.tgz", "integrity": "sha512-De/+vZnfg2aVWNiuy1Ldu+n2ydgw1osinmiZTAn0necE++eOfsygL8JpZgFjR2uHmAPo89MkxBj3JJ+2BMe+Uw==", "license": "MIT", - "peer": true, "dependencies": { "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", @@ -3307,7 +3302,6 @@ "integrity": "sha512-mNtXP9LTVBy14ZF3o7JG69gRPBK/2QWtQd0j0oH26HcY/foyJJau6pNUez7QrM5UHnSvwlQcJXKsk0I99B9pOA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.26.0", "@typescript-eslint/types": "8.26.0", @@ -3843,7 +3837,6 @@ "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4650,7 +4643,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -5845,8 +5837,7 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1413902.tgz", "integrity": "sha512-yRtvFD8Oyk7C9Os3GmnFZLu53yAfsnyw1s+mLmHHUK0GQEc9zthHWvS1r67Zqzm5t7v56PILHIVZ7kmFMaL2yQ==", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/diff": { "version": "5.2.0", @@ -6235,7 +6226,6 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6380,7 +6370,6 @@ "integrity": "sha512-/mHNE9jINJfiD2EKkg1BKyPyUk4zdnT54YgbOgfjSakWT5oyX/qQLVNTkehyfpcMxZXMy1zyonZ2v7hZTX43Yw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.8", @@ -9118,7 +9107,6 @@ "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", @@ -10376,6 +10364,15 @@ "node": ">=12" } }, + "node_modules/pmtiles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.3.0.tgz", + "integrity": "sha512-wnzQeSiYT/MyO63o7AVxwt7+uKqU0QUy2lHrivM7GvecNy0m1A4voVyGey7bujnEW5Hn+ZzLdvHPoFaqrOzbPA==", + "license": "BSD-3-Clause", + "dependencies": { + "fflate": "^0.8.2" + } + }, "node_modules/possible-typed-array-names": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", @@ -10391,7 +10388,6 @@ "resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.37.1.tgz", "integrity": "sha512-fZszlSB8j+PaxtS8g4qMxdj+ifzvoCPnbHSOjclTlr4mbhd6/huQqOViM6lhhPIrW2fiZc+IRcnReoKYvyMwNg==", "license": "Zlib", - "peer": true, "peerDependencies": { "three": ">= 0.157.0 < 0.175.0" } @@ -10434,7 +10430,6 @@ "resolved": "https://registry.npmjs.org/proj4/-/proj4-2.19.10.tgz", "integrity": "sha512-uL6/C6kA8+ncJAEDmUeV8PmNJcTlRLDZZa4/87CzRpb8My4p+Ame4LhC4G3H/77z2icVqcu3nNL9h5buSdnY+g==", "license": "MIT", - "peer": true, "dependencies": { "mgrs": "1.0.0", "wkt-parser": "^1.5.1" @@ -10745,7 +10740,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -11408,7 +11402,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -12546,8 +12539,7 @@ "version": "0.174.0", "resolved": "https://registry.npmjs.org/three/-/three-0.174.0.tgz", "integrity": "sha512-p+WG3W6Ov74alh3geCMkGK9NWuT62ee21cV3jEnun201zodVF4tCE5aZa2U122/mkLRmhJJUQmLLW1BH00uQJQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/three-stdlib": { "version": "2.35.14", @@ -12789,8 +12781,7 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", "dev": true, - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", @@ -12931,7 +12922,6 @@ "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13219,7 +13209,6 @@ "integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.6", @@ -13267,7 +13256,6 @@ "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.6.1", "@webpack-cli/configtest": "^3.0.1", @@ -13863,6 +13851,7 @@ "js-priority-queue": "^0.1.5", "lru-cache": "^11.0.2", "pbf": "^4.0.1", + "pmtiles": "^4.0.0", "shpjs": "^6.1.0", "threads": "^1.7.0" }, diff --git a/packages/Main/package.json b/packages/Main/package.json index 53d5e1902e..92804fbe01 100644 --- a/packages/Main/package.json +++ b/packages/Main/package.json @@ -52,6 +52,7 @@ "dependencies": { "@itowns/geographic": "^2.46.0", "@mapbox/vector-tile": "^2.0.3", + "pmtiles": "^4.0.0", "@maplibre/maplibre-gl-style-spec": "^23.1.0", "@tmcw/togeojson": "^7.0.0", "@tweenjs/tween.js": "^25.0.0", diff --git a/packages/Main/src/Layer/ColorLayer.js b/packages/Main/src/Layer/ColorLayer.js index 5e022ff5a1..d735195c56 100644 --- a/packages/Main/src/Layer/ColorLayer.js +++ b/packages/Main/src/Layer/ColorLayer.js @@ -144,7 +144,9 @@ class ColorLayer extends RasterLayer { this.defineLayerProperty('sequence', this.sequence); this.transparent = transparent || (this.opacity < 1.0); - this.noTextureParentOutsideLimit = config.source ? config.source.isFileSource : false; + // Use config value if explicitly set, otherwise fall back to source's isFileSource property + this.noTextureParentOutsideLimit = config.noTextureParentOutsideLimit ?? + (config.source ? config.source.isFileSource : false); this.effect_type = effect_type; this.effect_parameter = effect_parameter; diff --git a/packages/Main/src/Main.js b/packages/Main/src/Main.js index a94d695bf0..7a578c5103 100644 --- a/packages/Main/src/Main.js +++ b/packages/Main/src/Main.js @@ -98,6 +98,8 @@ export { default as EntwinePointTileSource } from 'Source/EntwinePointTileSource export { default as CopcSource } from 'Source/CopcSource'; export { default as CogSource } from 'Source/CogSource'; export { default as VpcSource } from 'Source/VpcSource'; +export { default as PMTilesSource } from 'Source/PMTilesSource'; +export { default as PMTilesVectorSource } from 'Source/PMTilesVectorSource'; // Parsers provided by default in iTowns // Custom parser can be implemented as wanted, as long as the main function diff --git a/packages/Main/src/Source/PMTilesSource.js b/packages/Main/src/Source/PMTilesSource.js new file mode 100644 index 0000000000..be214659fa --- /dev/null +++ b/packages/Main/src/Source/PMTilesSource.js @@ -0,0 +1,128 @@ +import { PMTiles } from 'pmtiles'; +import Source from 'Source/Source'; +import { Extent } from '@itowns/geographic'; + +/** + * An object defining the source of resources to get from a + * [PMTiles](https://github.com/protomaps/PMTiles) archive. It inherits from + * {@link Source}. + * + * PMTiles is a single-file archive format for tiled data that enables + * efficient access via HTTP range requests, eliminating the need for + * a dedicated tile server. + * + * @extends Source + * + * @property {boolean} isPMTilesSource - Used to checkout whether this source is + * a PMTilesSource. Default is true. You should not change this, as it is used + * internally for optimisation. + * @property {PMTiles} pmtiles - The PMTiles instance for accessing the archive. + * @property {Object} zoom - Object containing the minimum and maximum values of + * the level, to zoom in the source. + * @property {number} zoom.min - The minimum zoom level of the source. + * @property {number} zoom.max - The maximum zoom level of the source. + */ +class PMTilesSource extends Source { + /** + * @param {Object} source - An object that can contain all properties of a + * PMTilesSource and {@link Source}. Only `url` is mandatory. + * @param {string} source.url - The URL of the PMTiles archive. + * @param {string} [source.crs='EPSG:3857'] - The CRS of the tile data. + * Most PMTiles use Web Mercator (EPSG:3857). + */ + constructor(source) { + // Set default CRS for PMTiles (Web Mercator is standard) + source.crs = source.crs || 'EPSG:3857'; + + super(source); + + this.isPMTilesSource = true; + + // Initialize PMTiles instance + this.pmtiles = new PMTiles(source.url); + + // Initialize zoom range (will be updated from header) + this.zoom = source.zoom || { min: 0, max: 22 }; + + // Promise that resolves when the PMTiles header is loaded + this.whenReady = this.pmtiles.getHeader().then((header) => { + this._header = header; + + // Update zoom range from header + this.zoom = { + min: header.minZoom ?? 0, + max: header.maxZoom ?? 22, + }; + + // Set extent from header bounds if not already specified + if (!this.extent && header.minLon != null) { + // PMTiles header bounds are in WGS84 (EPSG:4326) + // Convert to source CRS extent + this.extent = new Extent( + 'EPSG:4326', + header.minLon, + header.maxLon, + header.minLat, + header.maxLat, + ); + + // If source CRS is different, we'll need to handle this + // For now, store the WGS84 extent for limit checking + this._wgs84Extent = this.extent.clone(); + } + + return header; + }); + } + + /** + * Get tile data from the PMTiles archive. + * + * @param {number} z - Zoom level + * @param {number} x - Tile X coordinate + * @param {number} y - Tile Y coordinate + * @returns {Promise} The tile data or undefined if not found + */ + async getTile(z, x, y) { + const result = await this.pmtiles.getZxy(z, x, y); + return result ? result.data : undefined; + } + + /** + * Tests if an extent is inside the source limits. + * + * @param {Extent} extent - Extent to test. + * @param {number} zoom - The zoom level to test. + * @returns {boolean} True if the extent is inside the limit, false otherwise. + */ + extentInsideLimit(extent, zoom) { + // Check zoom limits + if (zoom < this.zoom.min || zoom > this.zoom.max) { + return false; + } + + // Check spatial extent if we have bounds + if (this._wgs84Extent) { + // Convert extent to WGS84 for comparison if needed + const extentWGS84 = extent.crs === 'EPSG:4326' + ? extent + : extent.as('EPSG:4326'); + + return this._wgs84Extent.intersectsExtent(extentWGS84); + } + + return true; + } + + /** + * Called when layer is removed. Cleans up resources. + * + * @param {Object} options - Options + */ + onLayerRemoved(options = {}) { + super.onLayerRemoved(options); + // PMTiles doesn't need explicit cleanup, but we could add it here if needed + } +} + +export default PMTilesSource; diff --git a/packages/Main/src/Source/PMTilesVectorSource.js b/packages/Main/src/Source/PMTilesVectorSource.js new file mode 100644 index 0000000000..39240dae4d --- /dev/null +++ b/packages/Main/src/Source/PMTilesVectorSource.js @@ -0,0 +1,180 @@ +import PMTilesSource from 'Source/PMTilesSource'; +import { FeatureCollection } from 'Core/Feature'; + +/** + * An object defining the source of vector tile resources from a PMTiles archive. + * It inherits from {@link PMTilesSource}. + * + * This source is designed to work with PMTiles archives containing MVT + * (Mapbox Vector Tiles) data. + * + * @extends PMTilesSource + * + * @property {boolean} isPMTilesVectorSource - Used to checkout whether this + * source is a PMTilesVectorSource. Default is true. You should not change this, + * as it is used internally for optimisation. + * @property {Object} layers - Object containing layer definitions for styling. + * @property {Object} styles - Object containing style definitions. + * @property {boolean} isInverted - Whether the Y coordinate is inverted (TMS vs XYZ). + * + * @example + * // Create a PMTilesVectorSource + * const pmtilesSource = new itowns.PMTilesVectorSource({ + * url: 'https://example.com/data.pmtiles', + * layers: { + * 'buildings': [{ id: 'buildings', filterExpression: { filter: () => true } }], + * 'roads': [{ id: 'roads', filterExpression: { filter: () => true } }], + * }, + * }); + * + * // Create a ColorLayer with the source + * const layer = new itowns.ColorLayer('pmtiles-layer', { + * source: pmtilesSource, + * style: new itowns.Style({ + * fill: { color: 'blue', opacity: 0.5 }, + * stroke: { color: 'white', width: 1 }, + * }), + * }); + * + * view.addLayer(layer); + * + * @example + * // Simpler usage with automatic layer detection + * const pmtilesSource = new itowns.PMTilesVectorSource({ + * url: 'https://example.com/data.pmtiles', + * }); + * + * const layer = new itowns.ColorLayer('pmtiles-layer', { + * source: pmtilesSource, + * style: new itowns.Style({ + * fill: { color: 'steelblue' }, + * }), + * }); + */ +class PMTilesVectorSource extends PMTilesSource { + /** + * @param {Object} source - An object that can contain all properties of a + * PMTilesVectorSource and {@link PMTilesSource}. + * @param {string} source.url - The URL of the PMTiles archive. + * @param {Object} [source.layers] - Layer definitions for filtering features. + * If not provided, all layers will be included. + * @param {Object} [source.styles] - Style definitions per layer. + * @param {boolean} [source.isInverted=true] - Whether Y coordinates are inverted. + * Default is true for standard web tile schemes (XYZ/TMS). + */ + constructor(source) { + // Set format for vector tiles + source.format = 'application/x-protobuf;type=mapbox-vector'; + + // PMTiles vector tiles are typically EPSG:3857 + source.crs = source.crs || 'EPSG:3857'; + + super(source); + + this.isPMTilesVectorSource = true; + // Note: isVectorSource is set by Source.js based on format + // Note: parser is set by Source.js from supportedParsers for this format + + // Y coordinate inversion (standard for web tiles) + this.isInverted = source.isInverted !== undefined ? source.isInverted : true; + + // Initialize layer and style storage + this.layers = source.layers || {}; + this.styles = source.styles || {}; + + // Chain onto whenReady to also parse metadata for layer info + this.whenReady = this.whenReady.then(async (header) => { + // Try to get metadata which may contain layer information + try { + const metadata = await this.pmtiles.getMetadata(); + if (metadata) { + this._metadata = metadata; + + // If no layers were provided, try to auto-detect from metadata + if (Object.keys(this.layers).length === 0 && metadata.vector_layers) { + this._setupLayersFromMetadata(metadata.vector_layers); + } + } + } catch (e) { + // Metadata is optional, continue without it + console.warn('PMTilesVectorSource: Could not load metadata', e); + } + + return header; + }); + } + + /** + * Setup layers from PMTiles metadata. + * + * @param {Array} vectorLayers - Array of vector layer definitions from metadata + * @private + */ + _setupLayersFromMetadata(vectorLayers) { + vectorLayers.forEach((layerDef, index) => { + const layerId = layerDef.id; + if (!this.layers[layerId]) { + this.layers[layerId] = [{ + id: layerId, + order: index, + filterExpression: { filter: () => true }, + }]; + } + }); + } + + /** + * Get all layer names from the source. + * + * @returns {string[]} Array of layer names + */ + getLayerNames() { + return Object.keys(this.layers); + } + + /** + * Load data for a given extent. + * + * @param {Extent|Object} extent - The extent to load data for + * @param {Object} out - Output options (layer configuration) + * @returns {Promise} Promise resolving to parsed features + */ + loadData(extent, out) { + const cache = this._featuresCaches[out.crs]; + const key = this.getDataKey(extent); + + // Try to get from cache + let features = cache.get(key); + if (features) { + return features; + } + + // Get tile coordinates + const z = extent.zoom; + const x = extent.col; + const y = extent.row; + + // Fetch and parse the tile + features = this.getTile(z, x, y) + .then((data) => { + if (!data) { + // No tile data - return empty FeatureCollection + return Promise.resolve(new FeatureCollection(out)); + } + + // Parse the MVT data + return this.parser(data, { + extent, + in: this, + out, + }); + }) + .catch(err => this.handlingError(err)); + + // Cache the promise + cache.set(key, features); + return features; + } +} + +export default PMTilesVectorSource;