Add PMTiles support (#938)

Add support for pmtiles sources. Solves #807 

<strike>
There is still an error, probably when adding the source to the Maputnik
sources:

```
Failed to process sources for 'pmtiles://https://example.com/data/switzerland.pmtiles' TypeError: NetworkError when attempting to fetch resource.
    fetchSources App.tsx:642
```

@bdon How did you solve this for https://editor.protomaps.com/ ?
</strike>

---------

Co-authored-by: Brandon Liu <bdon@bdon.org>
Co-authored-by: WebFreak001 <gh@webfreak.org>
Co-authored-by: ShellyDCMS <60476837+ShellyDCMS@users.noreply.github.com>
Co-authored-by: Harel M <harel.mazor@gmail.com>
This commit is contained in:
Pirmin Kalberer
2025-01-23 09:59:08 +01:00
committed by GitHub
parent a2345a7308
commit 5af2cc2f9e
12 changed files with 117 additions and 26 deletions

View File

@@ -83,6 +83,24 @@ describe("modals", () => {
}); });
}); });
it("add new pmtiles source", () => {
const sourceId = "pmtilestest";
when.setValue("modal:sources.add.source_id", sourceId);
when.select("modal:sources.add.source_type", "pmtiles_vector");
when.setValue("modal:sources.add.source_url", "https://data.source.coop/protomaps/openstreetmap/v4.pmtiles");
when.click("modal:sources.add.add_source");
when.click("modal:sources.add.add_source");
when.wait(200);
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sources: {
pmtilestest: {
type: "vector",
url: "pmtiles://https://data.source.coop/protomaps/openstreetmap/v4.pmtiles",
},
},
});
});
it("add new raster source", () => { it("add new raster source", () => {
const sourceId = "rastertest"; const sourceId = "rastertest";
when.setValue("modal:sources.add.source_id", sourceId); when.setValue("modal:sources.add.source_id", sourceId);

16
package-lock.json generated
View File

@@ -40,6 +40,7 @@
"maputnik-design": "github:maputnik/design#172b06c", "maputnik-design": "github:maputnik/design#172b06c",
"ol": "^10.3.1", "ol": "^10.3.1",
"ol-mapbox-style": "^12.4.0", "ol-mapbox-style": "^12.4.0",
"pmtiles": "^4.1.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-accessible-accordion": "^5.0.0", "react-accessible-accordion": "^5.0.0",
@@ -6170,6 +6171,12 @@
"pend": "~1.2.0" "pend": "~1.2.0"
} }
}, },
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/figures": { "node_modules/figures": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz",
@@ -10110,6 +10117,15 @@
"integrity": "sha512-VJK1SRmXBpjwsB4YOHYSturx48rLKMzHgCqDH2ZDa6ZbMS/N5huoNqyQdK5Fj/xayu3fqbXckn5SeCS1EbMDZg==", "integrity": "sha512-VJK1SRmXBpjwsB4YOHYSturx48rLKMzHgCqDH2ZDa6ZbMS/N5huoNqyQdK5Fj/xayu3fqbXckn5SeCS1EbMDZg==",
"dev": true "dev": true
}, },
"node_modules/pmtiles": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.2.1.tgz",
"integrity": "sha512-Z73aph49f7KpU7JPb+zDWr+62wPv9jF3p+tvvL26/XeECnzUHnQ0nGopXGPYnq+OQXqyaXZPrsNdKxSD+2HlLA==",
"license": "BSD-3-Clause",
"dependencies": {
"fflate": "^0.8.2"
}
},
"node_modules/pngjs": { "node_modules/pngjs": {
"version": "3.4.0", "version": "3.4.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz",

View File

@@ -54,6 +54,7 @@
"maputnik-design": "github:maputnik/design#172b06c", "maputnik-design": "github:maputnik/design#172b06c",
"ol": "^10.3.1", "ol": "^10.3.1",
"ol-mapbox-style": "^12.4.0", "ol-mapbox-style": "^12.4.0",
"pmtiles": "^4.1.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-accessible-accordion": "^5.0.0", "react-accessible-accordion": "^5.0.0",

View File

@@ -8,6 +8,7 @@ import get from 'lodash.get'
import {unset} from 'lodash' import {unset} from 'lodash'
import {arrayMoveMutable} from 'array-move' import {arrayMoveMutable} from 'array-move'
import hash from "string-hash"; import hash from "string-hash";
import { PMTiles } from "pmtiles";
import {Map, LayerSpecification, StyleSpecification, ValidationError, SourceSpecification} from 'maplibre-gl' import {Map, LayerSpecification, StyleSpecification, ValidationError, SourceSpecification} from 'maplibre-gl'
import {latest, validateStyleMin} from '@maplibre/maplibre-gl-style-spec' import {latest, validateStyleMin} from '@maplibre/maplibre-gl-style-spec'
@@ -641,33 +642,41 @@ export default class App extends React.Component<any, AppState> {
console.warn("Failed to setFetchAccessToken: ", err); console.warn("Failed to setFetchAccessToken: ", err);
} }
fetch(url!, { const setVectorLayers = (json:any) => {
mode: 'cors', if(!Object.prototype.hasOwnProperty.call(json, "vector_layers")) {
}) return;
.then(response => response.json()) }
.then(json => {
if(!Object.prototype.hasOwnProperty.call(json, "vector_layers")) { // Create new objects before setState
return; const sources = Object.assign({}, {
} [key]: this.state.sources[key],
// Create new objects before setState
const sources = Object.assign({}, {
[key]: this.state.sources[key],
});
for(const layer of json.vector_layers) {
(sources[key] as any).layers.push(layer.id)
}
console.debug("Updating source: "+key);
this.setState({
sources: sources
});
})
.catch(err => {
console.error("Failed to process sources for '%s'", url, err);
}); });
for(const layer of json.vector_layers) {
(sources[key] as any).layers.push(layer.id)
}
this.setState({
sources: sources
});
};
if (url!.startsWith("pmtiles://")) {
(new PMTiles(url!.substr(10))).getTileJson("")
.then(json => setVectorLayers(json))
.catch(err => {
console.error("Failed to process sources for '%s'", url, err);
});
} else {
fetch(url!, {
mode: 'cors',
})
.then(response => response.json())
.then(json => setVectorLayers(json))
.catch(err => {
console.error("Failed to process sources for '%s'", url, err);
});
}
} }
else { else {
sourceList[key] = this.state.sources[key] || this.state.mapStyle.sources[key]; sourceList[key] = this.state.sources[key] || this.state.mapStyle.sources[key];

View File

@@ -15,6 +15,7 @@ import MaplibreGeocoder, { MaplibreGeocoderApi, MaplibreGeocoderApiConfig } from
import '@maplibre/maplibre-gl-geocoder/dist/maplibre-gl-geocoder.css'; import '@maplibre/maplibre-gl-geocoder/dist/maplibre-gl-geocoder.css';
import { withTranslation, WithTranslation } from 'react-i18next' import { withTranslation, WithTranslation } from 'react-i18next'
import i18next from 'i18next' import i18next from 'i18next'
import { Protocol } from "pmtiles";
function renderPopup(popup: JSX.Element, mountNode: ReactDOM.Container): HTMLElement { function renderPopup(popup: JSX.Element, mountNode: ReactDOM.Container): HTMLElement {
ReactDOM.render(popup, mountNode); ReactDOM.render(popup, mountNode);
@@ -148,6 +149,8 @@ class MapMaplibreGlInternal extends React.Component<MapMaplibreGlInternalProps,
localIdeographFontFamily: false localIdeographFontFamily: false
} satisfies MapOptions; } satisfies MapOptions;
const protocol = new Protocol({metadata: true});
MapLibreGl.addProtocol("pmtiles",protocol.tile);
const map = new MapLibreGl.Map(mapOpts); const map = new MapLibreGl.Map(mapOpts);
const mapViewChange = () => { const mapViewChange = () => {

View File

@@ -51,6 +51,7 @@ function editorMode(source: SourceSpecification) {
} }
if(source.type === 'vector') { if(source.type === 'vector') {
if(source.tiles) return 'tile_vector' if(source.tiles) return 'tile_vector'
if(source.url && source.url.startsWith("pmtiles://")) return 'pmtiles_vector'
return 'tilejson_vector' return 'tilejson_vector'
} }
if(source.type === 'geojson') { if(source.type === 'geojson') {
@@ -129,6 +130,10 @@ class AddSource extends React.Component<AddSourceProps, AddSourceState> {
const {protocol} = window.location; const {protocol} = window.location;
switch(mode) { switch(mode) {
case 'pmtiles_vector': return {
type: 'vector',
url: `${protocol}//localhost:3000/file.pmtiles`
}
case 'geojson_url': return { case 'geojson_url': return {
type: 'geojson', type: 'geojson',
data: `${protocol}//localhost:3000/geojson.json` data: `${protocol}//localhost:3000/geojson.json`
@@ -240,6 +245,7 @@ class AddSource extends React.Component<AddSourceProps, AddSourceState> {
['tile_raster', t('Raster (Tile URLs)')], ['tile_raster', t('Raster (Tile URLs)')],
['tilejson_raster-dem', t('Raster DEM (TileJSON URL)')], ['tilejson_raster-dem', t('Raster DEM (TileJSON URL)')],
['tilexyz_raster-dem', t('Raster DEM (XYZ URLs)')], ['tilexyz_raster-dem', t('Raster DEM (XYZ URLs)')],
['pmtiles_vector', t('Vector (PMTiles)')],
['image', t('Image')], ['image', t('Image')],
['video', t('Video')], ['video', t('Video')],
]} ]}

View File

@@ -11,7 +11,7 @@ import FieldCheckbox from './FieldCheckbox'
import { WithTranslation, withTranslation } from 'react-i18next'; import { WithTranslation, withTranslation } from 'react-i18next';
import { TFunction } from 'i18next' import { TFunction } from 'i18next'
export type EditorMode = "video" | "image" | "tilejson_vector" | "tile_raster" | "tilejson_raster" | "tilexyz_raster-dem" | "tilejson_raster-dem" | "tile_vector" | "geojson_url" | "geojson_json" | null; export type EditorMode = "video" | "image" | "tilejson_vector" | "tile_raster" | "tilejson_raster" | "tilexyz_raster-dem" | "tilejson_raster-dem" | "pmtiles_vector" | "tile_vector" | "geojson_url" | "geojson_json" | null;
type TileJSONSourceEditorProps = { type TileJSONSourceEditorProps = {
source: { source: {
@@ -286,6 +286,33 @@ class GeoJSONSourceFieldJsonEditor extends React.Component<GeoJSONSourceFieldJso
} }
} }
type PMTilesSourceEditorProps = {
source: {
url: string
}
onChange(...args: unknown[]): unknown
children?: React.ReactNode
} & WithTranslation;
class PMTilesSourceEditor extends React.Component<PMTilesSourceEditorProps> {
render() {
const t = this.props.t;
return <div>
<FieldUrl
label={t("PMTiles URL")}
fieldSpec={latest.source_vector.url}
value={this.props.source.url}
data-wd-key="modal:sources.add.source_url"
onChange={(url: string) => this.props.onChange({
...this.props.source,
url: url.startsWith("pmtiles://") ? url : `pmtiles://${url}`
})}
/>
{this.props.children}
</div>
}
}
type ModalSourcesTypeEditorInternalProps = { type ModalSourcesTypeEditorInternalProps = {
mode: EditorMode mode: EditorMode
source: any source: any
@@ -343,6 +370,7 @@ class ModalSourcesTypeEditorInternal extends React.Component<ModalSourcesTypeEdi
value={this.props.source.encoding || latest.source_raster_dem.encoding.default} value={this.props.source.encoding || latest.source_raster_dem.encoding.default}
/> />
</TileURLSourceEditor> </TileURLSourceEditor>
case 'pmtiles_vector': return <PMTilesSourceEditor {...commonProps} />
case 'image': return <ImageSourceEditor {...commonProps} /> case 'image': return <ImageSourceEditor {...commonProps} />
case 'video': return <VideoSourceEditor {...commonProps} /> case 'video': return <VideoSourceEditor {...commonProps} />
default: return null default: return null

View File

@@ -149,6 +149,7 @@
"Raster (Tile URLs)": "Raster (Tile URLs)", "Raster (Tile URLs)": "Raster (Tile URLs)",
"Raster DEM (TileJSON URL)": "Raster DEM (TileJSON URL)", "Raster DEM (TileJSON URL)": "Raster DEM (TileJSON URL)",
"Raster DEM (XYZ URLs)": "Raster DEM (XYZ URLs)", "Raster DEM (XYZ URLs)": "Raster DEM (XYZ URLs)",
"Vector (PMTiles)": "Vektor (PMTiles)",
"Image": "Bild", "Image": "Bild",
"Video": "Video", "Video": "Video",
"Add Source": "Quelle hinzufügen", "Add Source": "Quelle hinzufügen",
@@ -170,6 +171,7 @@
"GeoJSON URL": "GeoJSON URL", "GeoJSON URL": "GeoJSON URL",
"GeoJSON": "GeoJSON", "GeoJSON": "GeoJSON",
"Cluster": "Cluster", "Cluster": "Cluster",
"PMTiles URL": "PMTiles URL",
"Tile Size": "Kachelgröße", "Tile Size": "Kachelgröße",
"Encoding": "Kodierung", "Encoding": "Kodierung",
"Error:": "Fehler:", "Error:": "Fehler:",

View File

@@ -149,6 +149,7 @@
"Raster (Tile URLs)": "Raster (URLs Tile)", "Raster (Tile URLs)": "Raster (URLs Tile)",
"Raster DEM (TileJSON URL)": "Raster DEM (URL TileJSON)", "Raster DEM (TileJSON URL)": "Raster DEM (URL TileJSON)",
"Raster DEM (XYZ URLs)": "Raster DEM (URLs XYZ)", "Raster DEM (XYZ URLs)": "Raster DEM (URLs XYZ)",
"Vector (PMTiles)": "Vecteur (PMTiles)",
"Image": "Image", "Image": "Image",
"Video": "Vidéo", "Video": "Vidéo",
"Add Source": "Ajouter une source", "Add Source": "Ajouter une source",
@@ -170,6 +171,7 @@
"GeoJSON URL": "URL GeoJSON", "GeoJSON URL": "URL GeoJSON",
"GeoJSON": "GeoJSON", "GeoJSON": "GeoJSON",
"Cluster": "Cluster", "Cluster": "Cluster",
"PMTiles URL": "URL PMTiles",
"Tile Size": "Dimension d'une tuile", "Tile Size": "Dimension d'une tuile",
"Encoding": "Encodage", "Encoding": "Encodage",
"Error:": "Erreur :", "Error:": "Erreur :",

View File

@@ -149,6 +149,7 @@
"Raster (Tile URLs)": "Raster (Tile URLs)", "Raster (Tile URLs)": "Raster (Tile URLs)",
"Raster DEM (TileJSON URL)": "Raster DEM (TileJSON URL)", "Raster DEM (TileJSON URL)": "Raster DEM (TileJSON URL)",
"Raster DEM (XYZ URLs)": "Raster DEM (XYZ URLs)", "Raster DEM (XYZ URLs)": "Raster DEM (XYZ URLs)",
"Vector (PMTiles)": "Vector (PMTiles)",
"Image": "תמונה", "Image": "תמונה",
"Video": "וידאו", "Video": "וידאו",
"Add Source": "הוספת מקור", "Add Source": "הוספת מקור",
@@ -170,6 +171,7 @@
"GeoJSON URL": "כתובת GeoJSON", "GeoJSON URL": "כתובת GeoJSON",
"GeoJSON": "GeoJSON", "GeoJSON": "GeoJSON",
"Cluster": "קיבוץ", "Cluster": "קיבוץ",
"PMTiles URL": "כתובת PMTiles",
"Tile Size": "גודל אריח", "Tile Size": "גודל אריח",
"Encoding": "קידוד", "Encoding": "קידוד",
"Error:": "שגיאה", "Error:": "שגיאה",

View File

@@ -149,6 +149,7 @@
"Raster (Tile URLs)": "ラスタ (Tile URLs)", "Raster (Tile URLs)": "ラスタ (Tile URLs)",
"Raster DEM (TileJSON URL)": "ラスタ DEM (TileJSON URL)", "Raster DEM (TileJSON URL)": "ラスタ DEM (TileJSON URL)",
"Raster DEM (XYZ URLs)": "ラスタ DEM (XYZ URL)", "Raster DEM (XYZ URLs)": "ラスタ DEM (XYZ URL)",
"Vector (PMTiles)": "__STRING_NOT_TRANSLATED__",
"Image": "画像", "Image": "画像",
"Video": "動画", "Video": "動画",
"Add Source": "ソースを追加", "Add Source": "ソースを追加",
@@ -170,6 +171,7 @@
"GeoJSON URL": "GeoJSON URL", "GeoJSON URL": "GeoJSON URL",
"GeoJSON": "GeoJSON", "GeoJSON": "GeoJSON",
"Cluster": "クラスタ", "Cluster": "クラスタ",
"PMTiles URL": "__STRING_NOT_TRANSLATED__",
"Tile Size": "タイルサイズ", "Tile Size": "タイルサイズ",
"Encoding": "エンコーディング", "Encoding": "エンコーディング",
"Error:": "エラー:", "Error:": "エラー:",

View File

@@ -149,6 +149,7 @@
"Raster (Tile URLs)": "栅格数据 (Tile URLs)", "Raster (Tile URLs)": "栅格数据 (Tile URLs)",
"Raster DEM (TileJSON URL)": "栅格高程数据 (TileJSON URL)", "Raster DEM (TileJSON URL)": "栅格高程数据 (TileJSON URL)",
"Raster DEM (XYZ URLs)": "栅格高程数据 (XYZ URLs)", "Raster DEM (XYZ URLs)": "栅格高程数据 (XYZ URLs)",
"Vector (PMTiles)": "__STRING_NOT_TRANSLATED__",
"Image": "图像", "Image": "图像",
"Video": "视频", "Video": "视频",
"Add Source": "添加源", "Add Source": "添加源",
@@ -170,6 +171,7 @@
"GeoJSON URL": "GeoJSON URL", "GeoJSON URL": "GeoJSON URL",
"GeoJSON": "GeoJSON", "GeoJSON": "GeoJSON",
"Cluster": "聚合", "Cluster": "聚合",
"PMTiles URL": "__STRING_NOT_TRANSLATED__",
"Tile Size": "__STRING_NOT_TRANSLATED__", "Tile Size": "__STRING_NOT_TRANSLATED__",
"Encoding": "编码", "Encoding": "编码",
"Error:": "错误:", "Error:": "错误:",