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:
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:
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!
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:
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:
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!
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!