loading...
Cover image for Integrating an OpenLayers map in Vue.js, a step-by-step guide
Geospatial Solutions

Integrating an OpenLayers map in Vue.js, a step-by-step guide

jahow profile image Olivier Guyot Updated on ・10 min read

Cover art by Donato Giacola.

Introduction

Hi! Chances are, you have at least heard of Vue.js, the very popular frontend JavaScript framework. It has a reputation of being accessible, well documented and easily understandable.

On the other hand, you may or may not have heard about OpenLayers, one of the oldest web mapping libraries around. That’s OK, maps can be surprisingly complex at times and not everyone is eager to dive into this complexity when services like the Google Maps API make things much simpler. But keep in mind that there are many, many more things that a web mapping library can do for you besides showing a marker on a map!

Note that there are other similar libraries around (see this article for a quick tour). We will stick with OpenLayers since it offers the most possibilities in the long run.

In this article we will dive into how both Vue.js and OpenLayers work, and how to put an interactive map in a Vue app and make it actually useful! At the end of this article we will have built a simple geospatial object editor which will allow us to:

  • modify an object in GeoJSON format and see it appear on the map
  • edit an object geometry directly on the map

You can take a look at a running instance of the project here. Sources are available here.

Set up the application

There are already numerous tutorials on how to scaffold a Vue application out there, so we’re going to jump that part. Using the Vue CLI is very straightforward anyway, and calling vue create my-app will get you most of the way.

From now on let’s assume we have a simple application with the main page divided into three frames:

App.vue

<template>
  <div id="app">
    <div class="cell cell-map">
      Map
    </div>
    <div class="cell cell-edit">
      Edit
    </div>
    <div class="cell cell-inspect">
      Inspect
    </div>
  </div>
</template>

<script>
  export default {
    name: 'App'
  }
</script>

<style>
  html, body {
    height: 100%;
    margin: 0;
  }

  #app {
    font-family: Avenir, Helvetica, Arial, sans-serif;
    height: 100%;
    display: grid;
    grid-template-columns: 100vh;
    grid-auto-rows: 1fr;
    grid-gap: 1rem;
    padding: 1rem;
    box-sizing: border-box;
  }

  .cell {
    border-radius: 4px;
    background-color: lightgrey;
  }

  .cell-map {
    grid-column: 1;
    grid-row-start: 1;
    grid-row-end: 3;
  }

  .cell-edit {
    grid-column: 2;
    grid-row: 1;
  }

  .cell-inspect {
    grid-column: 2;
    grid-row: 2;
  }
</style>

The three frames are named Map, Edit and Inspect. Notice how we used CSS Grid to get the layout done? This is the result:

3 gray frames

Nice! This is how we’ll proceed next: create the Map component, then the Edit one and finally the Inspect one.

Give me the map!

Let’s create a MapContainer component, and include it in the main app. This is where we will need OpenLayers, so remember to install it first:

npm install --save ol

Then create the Vue component:

MapContainer.vue

<template>
  <div ref="map-root"
       style="width: 100%; height: 100%">
  </div>
</template>

<script>
  import View from 'ol/View'
  import Map from 'ol/Map'
  import TileLayer from 'ol/layer/Tile'
  import OSM from 'ol/source/OSM'

  // importing the OpenLayers stylesheet is required for having
  // good looking buttons!
  import 'ol/ol.css'

  export default {
    name: 'MapContainer',
    components: {},
    props: {},
    mounted() {
      // this is where we create the OpenLayers map
      new Map({
        // the map will be created using the 'map-root' ref
        target: this.$refs['map-root'],
        layers: [
          // adding a background tiled layer
          new TileLayer({
            source: new OSM() // tiles are served by OpenStreetMap
          }),
        ],

        // the map view will initially show the whole world
        view: new View({
          zoom: 0,
          center: [0, 0],
          constrainResolution: true
        }),
      })
    },
  }
</script>

This is getting interesting. See, creating a map with OpenLayers isn’t done in one line: you have to give it one or several Layer objects and also assign it a View. Have you noticed the constrainResolution: true option for the map view? This simply is to make sure that the map zoom snaps at the correct levels to have the OSM tiles looking crisp (see the API doc for more info).

Also notice that we kept a reference to the map root using the ref Vue directive like so:

<div ref="map-root"

The Map constructor can either take a CSS selector or an actual HTML element, so we then just have to fetch the map root element using this.$refs['map-root'].

The result should look like this:

OSM world map

Ok, we have a map, it’s interactive, but there’s not much more to it. Well of course it contains the whole world, but apart from that… How about we add an object on it?

MapContainer.vue

<script>
  // ...

  // we’ll need these additional imports
  import VectorLayer from 'ol/layer/Vector'
  import VectorSource from 'ol/source/Vector'
  import GeoJSON from 'ol/format/GeoJSON'

  // this is a simple triangle over the atlantic ocean
  const data = {
    type: 'Feature',
    properties: {},
    geometry: {
      type: 'Polygon',
      coordinates: [
        [
          [
            -27.0703125,
            43.58039085560784
          ],
          [
            -28.125,
            23.563987128451217
          ],
          [
            -10.8984375,
            32.84267363195431
          ],
          [
            -27.0703125,
            43.58039085560784
          ]
        ]
      ]
    }
  };

  export default {
    // ...

    mounted() {
      // a feature (geospatial object) is created from the GeoJSON
      const feature = new GeoJSON().readFeature(data, {
        // this is required since GeoJSON uses latitude/longitude,
        // but the map is rendered using “Web Mercator”
        featureProjection: 'EPSG:3857'
      });

      // a new vector layer is created with the feature
      const vectorLayer = new VectorLayer({
        source: new VectorSource({
          features: [feature],
        }),
      })

      new Map({
        // ...
        layers: [
          new TileLayer({
            source: new OSM(),
          }),
          // the vector layer is added above the tiled OSM layer
          vectorLayer
        ],
        // ...
      })
    }
  }
</script>

Simple!

OSM map with triangle

Modifying the object

The object we’re currently showing is expressed in GeoJSON. Good thing is, this format is easily edited by hand! Let’s create a new component for doing just that.

Edit.vue

<template>
  <textarea v-model="geojsonEdit"></textarea>
</template>

<script>
  export default {
    name: 'Edit',
    props: {
      geojson: Object
    },
    computed: {
      geojsonEdit: {
        set(value) {
          // when the text is modified, a `change` event is emitted
          // note: we’re emitting an object from a string
          this.$emit('change', JSON.parse(value))
        },
        get() {
          // the text content is taken from the `geojson` prop
          // note: we’re getting a string from an object
          return JSON.stringify(this.geojson, null, ' ')
        }
      }
    }
  }
</script>

<style>
  textarea {
    width: 100%;
    height: 100%;
    resize: none;
  }
</style>

This component takes a geojson prop as input, which will be the same as the one given to the MapContainer component.

OK, let's have a quick look at some Vue logic now.

The v-model="geojsonEdit" attribute in the component template is a Vue directive commonly used for form inputs. It defines a two-way data binding with the geojsonEdit local property, meaning any input by the user will be saved in the property and any change to the property will be reflected on the screen.

For this component to have any effect we want to inform the parent component whenever the GeoJSON text was modified. To do that, we'll dispatch an event, which is done in Vue like so:

this.$emit('change', JSON.parse(value))

Such an event can be captured in a parent component using the v-on directive:

v-on:change="doSomethingWithData($event)"

Note that v-on is useful for both custom-defined events and standard HTML5 events. Take a look at the Vue events guide for more details.

Now, how are we going to know when to emit that change event? The immediate response would be to set up a watcher on geojsonEdit and trigger an event whenever it changes.

In the code above we went with another solution: defining a computed property. Using a computed property is very helpful in this case as it allows us to specify two different behaviours (read and write) without resorting to watchers. This way, we simply have to emit the event on the set() method, and read from the input data on the get() method. As an added bonus, we're not maintaining any internal state on the component, which is always a good thing in the long run.

Let's get back on track now. The other components cannot yet handle an update of the spatial object as it’s currently hardcoded in the MapContainer component.

We're going to modify both the MapContainer as well the App components to handle varying data:

MapContainer.vue

<script>
  // ...

  export default {
    name: 'MapContainer',
    components: {},
    props: {
      // the GeoJSON data is now taken as an input
      geojson: Object
    },
    data: () => ({
      // store OL objects on the component instance
      olMap: null,
      vectorLayer: null
    }),
    mounted() {
      this.vectorLayer = new VectorLayer({
        source: new VectorSource({
          features: [], // the vector layer is now created empty
        }),
      })

      this.olMap = new Map({
        // ..
      })

      // we’re calling `updateSource` to show the object initially
      this.updateSource(this.geojson)
    },
    watch: {
      geojson(value) {
        // call `updateSource` whenever the input changes as well
        this.updateSource(value)
      }
    },
    methods: {
      // this will parse the input data and add it to the map
      updateSource(geojson) {
        const view = this.olMap.getView()
        const source = this.vectorLayer.getSource()

        const features = new GeoJSON({
          featureProjection: 'EPSG:3857',
        }).readFeatures(geojson)

        source.clear();
        source.addFeatures(features);

        // this zooms the view on the created object
        view.fit(source.getExtent())
      }
    }
  }
</script>

App.vue

<template>
  <div id="app">
    <div class="cell cell-map">
      <!-- the GeoJSON data is now given as input -->
      <MapContainer :geojson="geojson"></MapContainer>
    </div>
    <div class="cell cell-edit">
      <!-- update the app state on `change` events -->
      <Edit :geojson="geojson" v-on:change="geojson = $event">
      </Edit>
    </div>
    <div class="cell cell-inspect">
     Inspect
    </div>
  </div>
</template>

<script>
  import MapContainer from './components/MapContainer'
  import Edit from './components/Edit'
  export default {
    name: 'App',
    components: {
      Edit,
      MapContainer
    },
    data: () => ({
      // this is the initial GeoJSON data
      geojson: {
        type: 'Feature',
        properties: {},
        geometry: {
          type: 'Polygon',
          coordinates: [
            [
              [
                -27.0703125,
                43.58039085560784
              ],
              [
                -28.125,
                23.563987128451217
              ],
              [
                -10.8984375,
                32.84267363195431
              ],
              [
                -27.0703125,
                43.58039085560784
              ]
            ]
          ]
        }
      }
    })
  }
</script>

The modification to the App component was pretty straightforward. We’re really just storing the data on this level instead of on the MapContainer one, and passing it as input to the two child components.

As for MapContainer, the modification was a bit more involved but not by much: by watching the geojson input prop, we’re making sure the OpenLayers map stays in sync with the Vue component state.

The result should look like this:

Zoomed in triangle

As an added bonus, the map view now automatically zooms into the displayed object! But the best part is that if you change the GeoJSON definition on the right… the object is modified in real time! Pretty nice right?

Time for inspection

Spatial features often hold a series of so-called attributes, essentially key-value pairs. In GeoJSON you add some in the properties field of a feature. These are sometimes displayed on-screen (eg. labels) but often most of them are "hidden" and only shown in a tooltip or similar.

To push the exercise a bit further, let’s create a new Inspect component which will show all the attributes of the feature that’s currently beneath the pointer.

We’ll start by making it so that the MapContainer component emits a select event whenever a feature is found under the pointer:

MapContainer.vue

<script>
  // ...

  export default {
    // ...
    mounted() {
      // ...

      this.olMap = new Map({
        // ...
      })

      // this binds a callback to the `pointermove` event
      this.olMap.on('pointermove', (event) => {
        // will return the first feature under the pointer
        const hovered = this.olMap.forEachFeatureAtPixel(
          event.pixel,
          (feature) => feature
        )

        // emit a `select` event, either with a feature or without
        this.$emit('select', hovered)
      })

      this.updateSource(this.geojson)
    },
    // ...
  }
</script>

Again, we're emitting a custom event which will then be capture using v-on:select="..." on the parent component.

Also, we’re using the forEachFeatureAtPixel method for finding out features under the cursor, which looks for all features in any layer at a certain pixel, and applies the given callback for each of them. In this case we only want one feature, so we exit after the first match (since the callback (feature) => feature returns a truthy value).

We can then proceed to create the Inspect component, which will simply show all the feature’s attributes:

Inspect.vue

<template>
  <ul>
    <!-- loop on the feature’s attributes -->
    <li :key="prop" v-for="prop in props">
      <b>{{prop}}:</b> {{feature.get(prop)}}
    </li>
  </ul>
</template>

<script>
  import Feature from 'ol/Feature'
  export default {
    name: 'Inspect',
    props: {
      // the only input is an OpenLayers Feature instance
      feature: Feature
    },
    computed: {
      // this will return an empty array if no feature available
      props() {
        return this.feature
          ? this.feature
              .getKeys()
              .filter(key => key !== this.feature.getGeometryName())
          : []
      }
    }
  }
</script>

<style>
  ul {
    list-style: none;
  }
</style>

Notice the line this.feature.getKeys().filter(key => key !== this.feature.getGeometryName())? This will return the key of all the feature’s attributes, except the one that holds the geometry (because that would render things unreadable, you’re welcome to try). See the OpenLayers Feature API doc for more info.

Finally, let’s glue everything together in the App component:

App.vue

<template>
  <div id="app">
    <div class="cell cell-map">
      <!-- update app state when a feature is selected -->
      <MapContainer :geojson="geojson"
                    v-on:select="selected = $event">
      </MapContainer>
    </div>
    <div class="cell cell-edit">
      <Edit :geojson="geojson" v-on:change="geojson = $event">
      </Edit>
    </div>
    <div class="cell cell-inspect">
      <!-- give the selected feature as input -->
      <Inspect :feature="selected"></Inspect>
    </div>
  </div>
</template>

<script>
  import MapContainer from './components/MapContainer'
  import Edit from './components/Edit'
  import Inspect from './components/Inspect'

  export default {
    name: 'App',
    components: {
      Inspect,
      Edit,
      MapContainer
    },

    data: () => ({
      // the selected feature is part of the app state
      selected: undefined,
      geojson: {
        // ...
      }
    })
  }
</script>

Well, that’s pretty much it. Now hovering your cursor over a spatial object will give you the list of properties from that object:

Zoomed in triangle

You can try and add attributes to the GeoJSON object definition and see how they show up in the inspect frame!

If you want to, you can also try and copy-paste a GeoJSON file like this one (containing all countries with simplified shapes). After all, we’re not making any assumptions on what the GeoJSON data contains!

Countries GeoJSON

Note that there are optimizations to be made for better performance, especially making sure that the MapContainer component only emits an event when necessary (i.e. not emitting the same feature on every pointermove event).

Conclusion

Throughout this tutorial we managed to create a simple app that allows both editing and inspecting spatial objects. This wasn’t so hard considering we only needed three components, each of them with limited responsibility, and one root component for storing the application state.

Hopefully this helped shed some light both on some core Vue.js concepts, as well as OpenLayers ones.

You can take a look at the final source code here, which contains a few improvements over this tutorial.

I hope you enjoyed this tutorial, there are many other things we could do to expand on this basis, let me know if you have any ideas! Thanks for reading!

Discussion

pic
Editor guide