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;