DEV Community

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

Posted on • Edited on

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

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>


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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>


Enter fullscreen mode Exit fullscreen mode

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"


Enter fullscreen mode Exit fullscreen mode

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>


Enter fullscreen mode Exit fullscreen mode

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>


Enter fullscreen mode Exit fullscreen mode

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))


Enter fullscreen mode Exit fullscreen mode

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



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


Enter fullscreen mode Exit fullscreen mode

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>


Enter fullscreen mode Exit fullscreen mode

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>


Enter fullscreen mode Exit fullscreen mode

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>


Enter fullscreen mode Exit fullscreen mode

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>


Enter fullscreen mode Exit fullscreen mode

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>


Enter fullscreen mode Exit fullscreen mode

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!

Top comments (12)

Collapse
 
rowild profile image
Robert Wildling

Thank you for this article! Very interesting and a great introduction into how to use OpenLayers with Vue! – Question: OpenLayers has several async functions, too, like "geolocation.on('change' ...". How can those function be used in a Vue component?

Collapse
 
jahow profile image
Olivia Guyot

Hi, thanks for reading!

It might be tempting to put all map-related logic in the MapContainer component, and for example do:

geolocation.on('change', (event) => {
  // we have direct access to the OpenLayers map here
  this.olMap.getView().setCenter(...)
})
Enter fullscreen mode Exit fullscreen mode

I would personally try to split concerns a bit more and maybe create a specific component for the geolocation logic for example. Event if this component does not output any HTML, it will be responsible for encapsulating the logic and simply output for example a center and radius (or a geometry).

IMO the main pitfall when integrating a library such as OpenLayers in a JS framework would be to end up with a large "black box" component which ends up being almost impossible to split up as complexity grows.

My 2 cents!

Collapse
 
rowild profile image
Robert Wildling

First of all: Thank you s much for your reply! Highly appreciated!

I am still not sure how you think of implementing it. When you say "in the MapContainer component" - where exactly do you mean? All the Vue logic live within the "export default {}" object. Usually, methods are put in the "methods" parameter of that object, but I cannot just put a function with an event handler there. At least I didn't get it to work. And even if: they won't cause any reactions since they are, at that point, simply methods that aren't called from anywhere...
I was thinking of using watchers, but watchers – as far as I know – are fired on each $tick and therefore not very performant if there is a huge number of events to be observed...
So I am still in the dark about using OL's event system in Vue...

Thread Thread
 
jahow profile image
Olivia Guyot

Ok, I think I see what you mean. Event handlers should be defined in the mounted callback, which is also where the OL map is initialized.

See vuejs.org/v2/guide/instance.html#I... for more info on Vue components lifecycle. Hope that helps!

Thread Thread
 
rowild profile image
Robert Wildling

Thank you again! I tried your solution already, but I unfortunately I did not achieve a "beautiful" result. The whole code organisation soon gets clunky and things need to be duplicated etc.

I only get results when implementing watchers and, since there are more components involved, managing several parameters using vuex (e.g. the current lon-lat values, which a needed in the map component as well as in the "trackNewRoute" component as well as in a sidebar component, where there is a button that zooms into the current location, which is necessary in case the user changed the map's center and zoom manually...)

I wonder if I could motivate you to write another tutorials, where you show how to implement a simple tracker? Something like bikemap, where the app updates you position and draws a line of your route?

Anyway, thank you again for your article and your valuable feedback!

Thread Thread
 
jahow profile image
Olivia Guyot

Hmm, that is a very nice idea for a follow-up tutorial, which I was planning to write sooner than later. Thanks, I'll keep that in mind!

Thread Thread
 
rowild profile image
Robert Wildling

Great! – And I just wanted to let you know that you have provided the path to y solutions. A bit of refactoring, removing the watchers and setting up the listeners in the mounted() cycle already helped me a lot! That's great! Thank you for making this Sunday evening a successful one!
Looking forward to your tutorial!

Collapse
 
jduncanradblue profile image
Jen Duncan

I am having trouble with the new vue3 composition API and getting this to work. The source is not allowing me to store it in the component instance. If I do, I get the following error:

events.js?781a:63 Uncaught (in promise) TypeError: target.addEventListener is not a function
    at listen (events.js?781a:63:1)
    at LayerGroup.registerLayerListeners_ (Group.js?4337:182:1)
    at LayerGroup.handleLayersChanged_ (Group.js?4337:172:1)
    at LayerGroup.dispatchEvent (Target.js?363c:113:1)
    at LayerGroup.notify (Object.js?1b38:176:1)
    at LayerGroup.set (Object.js?1b38:215:1)
    at LayerGroup.setLayers (Group.js?4337:271:1)
    at new LayerGroup (Group.js?4337:141:1)
    at createOptionsInternal (Map.js?985a:1752:1)
    at new Map (Map.js?985a:259:1)
Enter fullscreen mode Exit fullscreen mode

Here's the code:

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

<script setup>
import { onMounted, ref } from "vue";
import View from 'ol/View'
import Map from 'ol/Map'
import TileLayer from 'ol/layer/Tile'
import OSM from 'ol/source/OSM'
import VectorLayer from 'ol/layer/Vector'
import VectorSource from 'ol/source/Vector'
import GeoJSON from 'ol/format/GeoJSON'
import 'ol/ol.css'

const olMap = ref(null);
const vectorLayer = ref(null);

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
        ]
      ]
    ]
  }
};
const updateSource = () => {
  const view = olMap.value.getView();
  const source = vectorLayer.value.getSource();
  const features = new GeoJSON({
    featureProjection: 'EPSG:3857',
  }).readFeatures(data);
  source.clear();
  source.addFeatures(features);
  view.fit(source.getExtent())
}
const initMap = () => {
  // a new vector layer is created with the feature
  vectorLayer.value = new VectorLayer({
    source: new VectorSource({
      features: [],
    }),
  })
  olMap.value = new Map({
    target: "map-root",
    layers: [
      new TileLayer({
        source: new OSM() // tiles are served by OpenStreetMap
      }),
        vectorLayer
    ],
    view: new View({
      center: [0, 0],
      zoom: 2
    })
  })
  updateSource();
}

onMounted(() => {
  initMap();

})

</script>
Enter fullscreen mode Exit fullscreen mode

If I change :

vectorLayer.value = new VectorLayer({
    source: new VectorSource({
      features: [],
    }),
Enter fullscreen mode Exit fullscreen mode

to

const vectorLayer = new VectorLayer({
    source: new VectorSource({
      features: [],
    }),
  })
Enter fullscreen mode Exit fullscreen mode

it works, but then I can not access the source in the updateSource function to add features, modify, delete, etc.

Any ideas?

Collapse
 
jahow profile image
Olivia Guyot

Looks like you're missing a .value when using vectorLayer in the map initialization:

olMap.value = new Map({
    // ...
    layers: [
      new TileLayer({
        source: new OSM()
      }),
        vectorLayer.value // <-- here!
    ],
    // ...
Enter fullscreen mode Exit fullscreen mode

Does that change anything?

Collapse
 
jduncanradblue profile image
Jen Duncan

YES! Thank you. I can't believe I missed that!

Collapse
 
melihaltintas profile image
Melih Altıntaş

I wrote an openlayers wrapper for vue 3.x. You can easily use it.
Github: github.com/MelihAltintas/vue3-open...
Doc: vue3openlayers.netlify.app/

Collapse
 
jahow profile image
Olivia Guyot

Thanks for sharing! Very interesting use of the Vue composition API, great work. How much of the OpenLayers API does this cover?

Wrappers definitely have their use in some contexts. Their downside is, in my opinion, that they need additional maintenance work compared to just using the core library (OpenLayers in this case).