diff --git a/cypress/e2e/modals.cy.ts b/cypress/e2e/modals.cy.ts index c1b6b56f..92d6732a 100644 --- a/cypress/e2e/modals.cy.ts +++ b/cypress/e2e/modals.cy.ts @@ -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", () => { const sourceId = "rastertest"; when.setValue("modal:sources.add.source_id", sourceId); diff --git a/package-lock.json b/package-lock.json index 163e36db..d91d630e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "maputnik-design": "github:maputnik/design#172b06c", "ol": "^10.3.1", "ol-mapbox-style": "^12.4.0", + "pmtiles": "^4.1.0", "prop-types": "^15.8.1", "react": "^18.2.0", "react-accessible-accordion": "^5.0.0", @@ -6170,6 +6171,12 @@ "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": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -10110,6 +10117,15 @@ "integrity": "sha512-VJK1SRmXBpjwsB4YOHYSturx48rLKMzHgCqDH2ZDa6ZbMS/N5huoNqyQdK5Fj/xayu3fqbXckn5SeCS1EbMDZg==", "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": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz", diff --git a/package.json b/package.json index 3306f1d8..84ccd37e 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "maputnik-design": "github:maputnik/design#172b06c", "ol": "^10.3.1", "ol-mapbox-style": "^12.4.0", + "pmtiles": "^4.1.0", "prop-types": "^15.8.1", "react": "^18.2.0", "react-accessible-accordion": "^5.0.0", diff --git a/src/components/App.tsx b/src/components/App.tsx index 86a94de2..31e2cf57 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -8,6 +8,7 @@ import get from 'lodash.get' import {unset} from 'lodash' import {arrayMoveMutable} from 'array-move' import hash from "string-hash"; +import { PMTiles } from "pmtiles"; import {Map, LayerSpecification, StyleSpecification, ValidationError, SourceSpecification} from 'maplibre-gl' import {latest, validateStyleMin} from '@maplibre/maplibre-gl-style-spec' @@ -641,33 +642,41 @@ export default class App extends React.Component { console.warn("Failed to setFetchAccessToken: ", err); } - fetch(url!, { - mode: 'cors', - }) - .then(response => response.json()) - .then(json => { + const setVectorLayers = (json:any) => { + if(!Object.prototype.hasOwnProperty.call(json, "vector_layers")) { + return; + } - if(!Object.prototype.hasOwnProperty.call(json, "vector_layers")) { - return; - } + // Create new objects before setState + 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) + } - for(const layer of json.vector_layers) { - (sources[key] as any).layers.push(layer.id) - } + this.setState({ + sources: sources + }); + }; - console.debug("Updating source: "+key); - 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', }) - .catch(err => { - console.error("Failed to process sources for '%s'", url, err); - }); + .then(response => response.json()) + .then(json => setVectorLayers(json)) + .catch(err => { + console.error("Failed to process sources for '%s'", url, err); + }); + } } else { sourceList[key] = this.state.sources[key] || this.state.mapStyle.sources[key]; diff --git a/src/components/MapMaplibreGl.tsx b/src/components/MapMaplibreGl.tsx index d3f05a9d..31e08b9e 100644 --- a/src/components/MapMaplibreGl.tsx +++ b/src/components/MapMaplibreGl.tsx @@ -15,6 +15,7 @@ import MaplibreGeocoder, { MaplibreGeocoderApi, MaplibreGeocoderApiConfig } from import '@maplibre/maplibre-gl-geocoder/dist/maplibre-gl-geocoder.css'; import { withTranslation, WithTranslation } from 'react-i18next' import i18next from 'i18next' +import { Protocol } from "pmtiles"; function renderPopup(popup: JSX.Element, mountNode: ReactDOM.Container): HTMLElement { ReactDOM.render(popup, mountNode); @@ -148,6 +149,8 @@ class MapMaplibreGlInternal extends React.Component { diff --git a/src/components/ModalSources.tsx b/src/components/ModalSources.tsx index eabb5a8b..3db41bad 100644 --- a/src/components/ModalSources.tsx +++ b/src/components/ModalSources.tsx @@ -51,6 +51,7 @@ function editorMode(source: SourceSpecification) { } if(source.type === 'vector') { if(source.tiles) return 'tile_vector' + if(source.url && source.url.startsWith("pmtiles://")) return 'pmtiles_vector' return 'tilejson_vector' } if(source.type === 'geojson') { @@ -129,6 +130,10 @@ class AddSource extends React.Component { const {protocol} = window.location; switch(mode) { + case 'pmtiles_vector': return { + type: 'vector', + url: `${protocol}//localhost:3000/file.pmtiles` + } case 'geojson_url': return { type: 'geojson', data: `${protocol}//localhost:3000/geojson.json` @@ -240,6 +245,7 @@ class AddSource extends React.Component { ['tile_raster', t('Raster (Tile URLs)')], ['tilejson_raster-dem', t('Raster DEM (TileJSON URL)')], ['tilexyz_raster-dem', t('Raster DEM (XYZ URLs)')], + ['pmtiles_vector', t('Vector (PMTiles)')], ['image', t('Image')], ['video', t('Video')], ]} diff --git a/src/components/ModalSourcesTypeEditor.tsx b/src/components/ModalSourcesTypeEditor.tsx index da4f7a3c..20fd9fa6 100644 --- a/src/components/ModalSourcesTypeEditor.tsx +++ b/src/components/ModalSourcesTypeEditor.tsx @@ -11,7 +11,7 @@ import FieldCheckbox from './FieldCheckbox' import { WithTranslation, withTranslation } from 'react-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 = { source: { @@ -286,6 +286,33 @@ class GeoJSONSourceFieldJsonEditor extends React.Component { + render() { + const t = this.props.t; + return
+ this.props.onChange({ + ...this.props.source, + url: url.startsWith("pmtiles://") ? url : `pmtiles://${url}` + })} + /> + {this.props.children} +
+ } +} + type ModalSourcesTypeEditorInternalProps = { mode: EditorMode source: any @@ -343,6 +370,7 @@ class ModalSourcesTypeEditorInternal extends React.Component + case 'pmtiles_vector': return case 'image': return case 'video': return default: return null diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 75072939..435d9b04 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -149,6 +149,7 @@ "Raster (Tile URLs)": "Raster (Tile URLs)", "Raster DEM (TileJSON URL)": "Raster DEM (TileJSON URL)", "Raster DEM (XYZ URLs)": "Raster DEM (XYZ URLs)", + "Vector (PMTiles)": "Vektor (PMTiles)", "Image": "Bild", "Video": "Video", "Add Source": "Quelle hinzufügen", @@ -170,6 +171,7 @@ "GeoJSON URL": "GeoJSON URL", "GeoJSON": "GeoJSON", "Cluster": "Cluster", + "PMTiles URL": "PMTiles URL", "Tile Size": "Kachelgröße", "Encoding": "Kodierung", "Error:": "Fehler:", diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index e4957469..3460a8c3 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -149,6 +149,7 @@ "Raster (Tile URLs)": "Raster (URLs Tile)", "Raster DEM (TileJSON URL)": "Raster DEM (URL TileJSON)", "Raster DEM (XYZ URLs)": "Raster DEM (URLs XYZ)", + "Vector (PMTiles)": "Vecteur (PMTiles)", "Image": "Image", "Video": "Vidéo", "Add Source": "Ajouter une source", @@ -170,6 +171,7 @@ "GeoJSON URL": "URL GeoJSON", "GeoJSON": "GeoJSON", "Cluster": "Cluster", + "PMTiles URL": "URL PMTiles", "Tile Size": "Dimension d'une tuile", "Encoding": "Encodage", "Error:": "Erreur :", diff --git a/src/locales/he/translation.json b/src/locales/he/translation.json index 5ed72d4d..7be17f2b 100644 --- a/src/locales/he/translation.json +++ b/src/locales/he/translation.json @@ -149,6 +149,7 @@ "Raster (Tile URLs)": "Raster (Tile URLs)", "Raster DEM (TileJSON URL)": "Raster DEM (TileJSON URL)", "Raster DEM (XYZ URLs)": "Raster DEM (XYZ URLs)", + "Vector (PMTiles)": "Vector (PMTiles)", "Image": "תמונה", "Video": "וידאו", "Add Source": "הוספת מקור", @@ -170,6 +171,7 @@ "GeoJSON URL": "כתובת GeoJSON", "GeoJSON": "GeoJSON", "Cluster": "קיבוץ", + "PMTiles URL": "כתובת PMTiles", "Tile Size": "גודל אריח", "Encoding": "קידוד", "Error:": "שגיאה", diff --git a/src/locales/ja/translation.json b/src/locales/ja/translation.json index ba380259..df84cacc 100644 --- a/src/locales/ja/translation.json +++ b/src/locales/ja/translation.json @@ -149,6 +149,7 @@ "Raster (Tile URLs)": "ラスタ (Tile URLs)", "Raster DEM (TileJSON URL)": "ラスタ DEM (TileJSON URL)", "Raster DEM (XYZ URLs)": "ラスタ DEM (XYZ URL)", + "Vector (PMTiles)": "__STRING_NOT_TRANSLATED__", "Image": "画像", "Video": "動画", "Add Source": "ソースを追加", @@ -170,6 +171,7 @@ "GeoJSON URL": "GeoJSON URL", "GeoJSON": "GeoJSON", "Cluster": "クラスタ", + "PMTiles URL": "__STRING_NOT_TRANSLATED__", "Tile Size": "タイルサイズ", "Encoding": "エンコーディング", "Error:": "エラー:", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 63a76063..c892585a 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -149,6 +149,7 @@ "Raster (Tile URLs)": "栅格数据 (Tile URLs)", "Raster DEM (TileJSON URL)": "栅格高程数据 (TileJSON URL)", "Raster DEM (XYZ URLs)": "栅格高程数据 (XYZ URLs)", + "Vector (PMTiles)": "__STRING_NOT_TRANSLATED__", "Image": "图像", "Video": "视频", "Add Source": "添加源", @@ -170,6 +171,7 @@ "GeoJSON URL": "GeoJSON URL", "GeoJSON": "GeoJSON", "Cluster": "聚合", + "PMTiles URL": "__STRING_NOT_TRANSLATED__", "Tile Size": "__STRING_NOT_TRANSLATED__", "Encoding": "编码", "Error:": "错误:",