Search code examples
javascriptmapboxmapbox-gl-jsmap-projectionsdeck.gl

How to display an image that can be changed at any time as a map using Mapbox?


In my app, user must have an ability to add a floor plan to the map. User uploads a PNG image using a simple form, and then this image must be displayed as a map background. So what we're having here is:

  • A PNG image that can be changed by the user at any time. I receive a URL of this image from the server.
  • Dimensions of the image (width and height).

Mapbox has sources and layers which I need to utilize to add this image as a background of the map, and the actual world map must not display at all.

I've seen a lot of examples like this (this one is using mapbox-gl-js):

...
"sources": {
    "overlay": {
        "type": "image",
        "url": "https://www.mapbox.com/mapbox-gl-js/assets/radar.gif",
        "coordinates": [
            [-80.425, 46.437],
            [-71.516, 46.437],
            [-71.516, 37.936],
            [-80.425, 37.936]
        ]
    }
},
...

And this (this one is using deck.gl layers):

import DeckGL from '@deck.gl/react';
import {BitmapLayer} from '@deck.gl/layers';

const App = ({data, viewport}) => {

  const layer = new BitmapLayer({
    id: 'bitmap-layer',
    bounds: [-122.5190, 37.7045, -122.355, 37.829],
    image: 'https://raw.githubusercontent.com/uber-common/deck.gl-data/master/website/sf-districts.png'
  });

  return (<DeckGL {...viewport} layers={[layer]} />);
}

But they always have predefined coordinates for the image. Because my image can be updated by the user at any time, I need to somehow calculate these coordinates, taking into account aspect ratio of the image. I'm not any good at math, so can you please help me out? deck.gl has the ability to specify coordinate system of the layer and even 4x4 projection matrix, but I don't quite understand how I can use that for my case.


Solution

  • Alright, I solved the problem. The key to the solution was to stop trying to make the plan fill the entire map, but instead to resize the image to make it really small and place it at [0, 0] coordinates on the map. That way we can assume that the world is flat here and not worry about the curvature of it at all.

    So when the map loads, I'm loading the image to get its dimensions:

    this.map.current.on('load', () => {
      const img = new Image()
      const self = this
      img.addEventListener('load', function () {
        // ...
      })
      img.src = planUrl
    })
    

    Then when it's done, in the image's load handler I'm resizing the image and creating LngLatBounds of it. I'm just simply dividing width and height here to get lng and lat of the image — they will be less than 1 on both lng and lat, so I don't think the curvature of the earth will be a problem on this level:

    img.addEventListener('load', function () {
      const maxWidth = 1
      const maxHeight = 0.5
    
      const [width, height] = resizeImage(
        this.naturalWidth,
        this.naturalHeight,
        maxWidth,
        maxHeight
      )
    
      const sw = [-width / 2, -height / 2]
      const ne = [width / 2, height / 2]
    
      const bounds = new LngLatBounds(sw, ne)
      // ...
    })
    

    Then I'm adding a source with the floor plan and a layer displaying the plan to the map:

    self.map.current.addSource('plan', {
      type: 'image',
      url: planUrl,
      coordinates: [
        bounds.getNorthWest().toArray(),
        bounds.getNorthEast().toArray(),
        bounds.getSouthEast().toArray(),
        bounds.getSouthWest().toArray()
      ]
    })
    
    self.map.current.addLayer({
      id: 'image',
      source: 'plan',
      type: 'raster'
    })
    

    And then I'm setting map bounds equal to 2 times the size of the image's bounds, so the plan will have a nice padding around it.

    const mapBounds = new LngLatBounds(sw, ne)
    mapBounds.extend(new LngLatBounds([-width, -height], [width, height]))
    self.map.current.setMaxBounds(mapBounds)
    

    There are probably better solutions to it than this one but looks like it works just fine for me. Hopefully it'll help someone else.