openlayersopenlayers-6vector-tilesproj4js

Is there a way in OpenLayers to render EPSG:3857 tiles on an EPSG:4326 map?


I have a map in the EPSG:4326 projection, and I'd like to use an EPSG:3857 tileset such as the one from Mapbox. However, I've so far not been able to make it work.

In this discussion ahocevar explains that arbitrary reprojection of vector tiles is not supported or planned because the vector clipping would be complex and introduce artifacts from curved clip-paths. The 3857 and 4326 are square relative to each other, but the code to support reprojecting between the two would complexify the library.

ahocevar also mentions a workaround involving image tiles sourced from a hidden map. However, this doesn't make sense because in any case, 3857 tiles do not line up with a 4326 tile grid, because no amount of transformation can change where the tile boundaries are.

I know there are examples of rendering to an offscreen canvas and using Mapbox GL, but both of these aren't ideal because they are going around the library (e.g. it doesn't work with map.getFeaturesAtPixel). I'm wondering if openlayers itself has a way to do this.

Here is an attempt: https://codesandbox.io/s/mvt-3857-to-4326-attempt-qsf07

Here's the relevant code:

const mapboxSource = new VectorTileSource({
        attributions: '© <a href="https://www.mapbox.com/map-feedback/">Mapbox</a> ' +
            '© <a href="https://www.openstreetmap.org/copyright">' +
            'OpenStreetMap contributors</a>',
        projection: 'EPSG:4326',
        tileUrlFunction: (tileCoord) => {
            // Use the tile coordinate as a pseudo URL for caching purposes
            return JSON.stringify(tileCoord);
        },
        tileLoadFunction: async (tile, urlToken) => {
            const tileCoord = JSON.parse(urlToken);
            console.log('tileCoord', tileCoord);
            const [z, x, y] = tileCoord;

            const tileUrl = url
                .replace('{z}', String(z))
                .replace('{x}', String(x))
                .replace('{y}', String(y))
                .replace('{a-d}', 'abcd'.substr(((x << z) + y) % 4, 1))
            ;

            try {
                const response = await fetch(tileUrl);
                if (!response.ok) throw new Error();
                const arrayBuffer = await response.arrayBuffer();

                // Transform the vector tile's arrayBuffer into features and add them to the tile.
                const {layers} = new VectorTile(new Protobuf(arrayBuffer));
                const geojsonFeatures = [];
                Object.keys(layers).forEach((layerName) => {
                    const layer = layers[layerName];
                    for (let i = 0, len = layer.length; i < len; i++) {
                        const geojson = layer.feature(i).toGeoJSON(x, y, z);
                        geojson.properties.layer = layerName;
                        geojsonFeatures.push(geojson);
                    }
                });

                const features = geojsonFormat.readFeatures({
                    type: 'FeatureCollection',
                    features: geojsonFeatures,
                });
                tile.setFeatures(features);
            } catch (e) {
                console.log(e);
                debugger;
                tile.setState(TileState.ERROR);
            }
        },

This only loads z:0 correctly. As soon as you zoom in, the MVT layer no longer aligns with the OSM base map.

I also experimented with drawing straight to TileImages with toContext, but couldn't figure out the mapping from coordinates to tile pixels, and the raster reprojection looked pretty blurry anyway. So I gave up and just used Maptiler's 4326 tileset, but I'd like to settle this question. Is it possible to render 3857 tiles on a 4326 map?

Thank you


Solution

  • If it can be done for one tile as in https://codesandbox.io/s/drag-and-drop-custom-mvt-forked-2jr6n it can be done for all tiles in the view extent. To maintain a styleable vector format after reprojection it needs to be a Vector layer (using the MVT featureClass: Feature option to overcome the coordinates to tile pixels issue) as tile grids must have the same tile size within a zoom level, which would not be the case if you attempted to use a grid from one projection as tiles in another projection.

    <!doctype html>
    <html lang="en">
      <head>
        <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/openlayers/openlayers.github.io@master/en/v6.6.1/css/ol.css" type="text/css">
        <style>
          html, body, .map {
            margin: 0;
            padding: 0;
            width: 100%;
            height: 100%;
          }
        </style>
        <script src="https://cdn.jsdelivr.net/gh/openlayers/openlayers.github.io@master/en/v6.6.1/build/ol.js"></script>
      </head>
      <body>
        <div id="map" class="map"></div>
        <script type="text/javascript">
    
    let map;
    const format = new ol.format.MVT({ featureClass: ol.Feature });
    
    const vectorTileSource = new ol.source.VectorTile({
      format: format,
      url:
        "https://basemaps.arcgis.com/v1/arcgis/rest/services/World_Basemap/VectorTileServer/tile/{z}/{y}/{x}.pbf"
    });
    const tileGrid = vectorTileSource.getTileGrid();
    
    const vectorLayer = new ol.layer.Vector();
    const vectorSources = [];
    
    function loader(zoom) {
      const loadedTiles = [];
      return function (extent, resolution, projection) {
        const tileProjection = vectorTileSource.getProjection();
        const maxExtent = ol.proj.transformExtent(
          tileProjection.getExtent(),
          tileProjection,
          projection
        );
        const safeExtent = ol.extent.getIntersection(extent, maxExtent);
        const gridExtent = ol.proj.transformExtent(safeExtent, projection, tileProjection);
        tileGrid.forEachTileCoord(gridExtent, zoom, function (tileCoord) {
          const key = tileCoord.toString();
          if (loadedTiles.indexOf(key) < 0) {
            loadedTiles.push(key);
            fetch(vectorTileSource.getTileUrlFunction()(tileCoord))
              .then(function (response) {
                return response.arrayBuffer();
              })
              .then(function (result) {
                const features = format.readFeatures(result, {
                  extent: tileGrid.getTileCoordExtent(tileCoord),
                  featureProjection: tileProjection
                });
                features.forEach(function (feature) {
                  feature.getGeometry().transform(tileProjection, projection);
                });
                vectorSources[zoom].addFeatures(features);
              })
              .catch(function () {
                loadedTiles.splice(loadedTiles.indexOf(key), 1);
              });
          }
        });
      };
    }
    
    tileGrid.getResolutions().forEach(function (resolutiom, zoom) {
      vectorSources.push(
        new ol.source.Vector({
          loader: loader(zoom),
          strategy: ol.loadingstrategy.bbox
        })
      );
    });
    
    map = new ol.Map({
      target: "map",
      view: new ol.View({
        center: [0, 0],
        zoom: 2,
        projection: "EPSG:4326"
      }),
      layers: [
        new ol.layer.Tile({
          source: new ol.source.OSM()
        }),
        vectorLayer
      ]
    });
    
    let zoom;
    function setSource() {
      const newZoom = Math.round(map.getView().getZoom());
      if (newZoom !== zoom && newZoom < vectorSources.length) {
        zoom = newZoom;
        vectorLayer.setSource(vectorSources[zoom]);
      }
    }
    
    map.getView().on("change:resolution", setSource);
    
    setSource();
    
        </script>
      </body>
    </html>