Building reactive GIS user interfaces with Vue, Cesium, Node and MySQL

Baruch Kogan
7 min readDec 19, 2019

If your application requires an interactive map interface, there are lots of great options available. I’d like to describe a basic implementation using Vue.js, Cesium.js, Node and MySQL. The general context is that I’m building an application which maps communities worldwide and gives each community a space where it can store and share various documents, pictures and other media-a sort of online museum. What I’d like to achieve is to display an interactive world map, loading interactive map markers based on the camera position. We’re going to load a map layer, send HTTP requests to the back end, take the response to those requests and render it on the map layer in the form of interactive icons.

There are a couple of nuances. We want to manage the rate at which we ping our backend and the amount of data which we push to our front end. The first requirement keeps us from overloading our backend and database, and the second from overloading our browser. So I am going to monitor the user’s camera movements, and once the movement stops, take the field of vision and send information about it to the back end. I will use back-end pagination to limit the amount of communities returned to the front end, and render them there.

Backend

So, let’s set up the back end first. I’m running MySQL 8.0, which has tons of cool new GIS functions. For ease of use, I use Bookshelf.js as an ORM. Ironically, since GIS is implemented very differently between the various databases which Bookshelf supports, and even between their various versions, I have to drop down through Knex, which Bookshelf wraps, and into raw SQL, as we will see below. Still, lots of convenience comes with Bookshelf in other scenarios which are outside the scope of this tutorial.

In my Knex migration file, which I use to set up the structure of my database, I set up a community table like this:

'use strict';
exports.up = async function (knex) {
try{
const hasCommunity = await knex
.schema
.hasTable('community');

if (!hasCommunity)
await knex
.schema
.createTable('community', table => {
table.increments('id').primary().unsigned();
table.string('name');
table.string('address');
table.string('city');
table.string('country');
table.string('phone');
table.specificType('coordinates', 'POINT');
table.json('data');
table.bool('private');
table.bool('deleted').default(false);
});
}
catch (e) {
console.log('knex migrations failed with error ', e);
}
};

Notice that the migration has a ‘coordinates’ column, which is of type ‘POINT’.

We’re going to add a model, community.js, to allow Bookshelf to wrap this table.

'use strict';

const bookshelf = require('../bootstrap/bookshelf_instance').bookshelf;

const Community = bookshelf.Model.extend({
tableName: 'community',
format (attrs) {
return {
...attrs,
coordinates: bookshelf.knex.raw('POINT(?, ?)', [
attrs.coordinates.x, attrs.coordinates.y
])
}
}
},

{
jsonColumns: ['data']
}
);

module.exports.model = Community;

While it’s outside the scope of this article, notice the format() function, which allows us to save coordinates into the appropriate column as a POINT data type automatically. In other words, I don’t have to think about this at all when saving new communities-I just pass a key called ‘coordinates’, with a nested object with keys ‘x’ and ‘y’, and the values are stored.

Now, on my router, I’m going to set up a GET route for communities.

'use strict';

const express = require('express');
const router = express.Router();
const Community = require('../models/community').model;
...router.get('/communities',
async (req, res) => {
try {
let {pageSize, page, orderBy, corners} = req.query;

if (pageSize)
pageSize = parseInt(pageSize);

if (page)
page = parseInt(page);

if (corners) {
corners[0] = JSON.parse(corners[0]);
corners[1] = JSON.parse(corners[1]);
}

const communities = await new Community()
.query((qb) => {
qb
.whereRaw(`not private = 'true'`)
if (corners)
qb.andWhereRaw(`ST_CONTAINS(ST_MakeEnvelope(Point(${corners[0].longitude}, ${corners[0].latitude}), Point(${corners[1].longitude}, ${corners[1].latitude})), coordinates)`)
})
.orderBy(orderBy || '-name')
.fetchPage({
pageSize: pageSize || 10, // Defaults to 10 if not specified
page: page || 1
});

return ReS(res, {communities}, 200);
} catch (e) {
return ReE(res, e, 500);
}
}
);

This is quite straightforward-we get a GET request, and parse its query parameters to the appropriate form. If the query has a parameter called ‘corners’, we want to parse its first two elements to JSON. Then we build our query. We use a template string to build a GIS query, where we parse each corner into a Point, use ST_MakeEnvelope to build a bounding rectangle from the two points, and then pass that rectangle as well as the ‘coordinates’ column into ST_CONTAINS. What we are telling MySQL here is, “bring me all the points which are within the rectangle whose opposite corners I’m passing to you.”

And that’s it for the backend!

Frontend

For the frontend, I use Vue 2. Insert obligatory Vue plug here. Fortunately, Zouyaoji has integrated Cesium with Vue, in VueCesium. I can’t recommend this highly enough-it works very intuitively and (s)he’s been great about answering questions on Github.

I’m using Vuex to deal with my backend and store the results. In app.service.js, I have this function:

async getCommunities (queryObject) {
return (await axios.get('/community/communities', {
params: queryObject
})).data;
},

That’s called by this function in my Vuex store actions:

async getCommunities (context, queryObject) {
try{
const result = await appService.getCommunities(queryObject);
if(result){
context.commit('communities', result.communities);
}
}
catch (e) {
throw e
}
},

The resulting data is stored in my Vuex state with this mutation:

communities (state, data) {
state.communities = data.filter(x => !!x.name)
.sort((a, b) => a.name.localeCompare(b.name));
}

Let’s look at our template:

<template>
<vc-viewer :infoBox="false" :geocoder="geocoder" :terrainExaggeration="100" @ready="ready" :camera="camera"
@selectedEntityChanged="selectedEntityChanged" @moveEnd="onMoveEnd">
<vc-entity v-if="billboards.length > 0" v-for="billboard in billboards"
:ref="billboard.id"
:position="billboard.position"
:billboard="billboard"
:key="billboard.id"
:id="billboard.id.toString()"></vc-entity>
<vc-layer-imagery :alpha="alpha" :brightness="brightness" :contrast="contrast">
<vc-provider-imagery-bingmaps :url="url" :bmKey="bmKey" :mapStyle="mapStyle">
</vc-provider-imagery-bingmaps>
</vc-layer-imagery>
</vc-viewer>
</template>

This is a VC Viewer component, holding a group of VC Billboards (those interactive icons,) and an imagery layer holding Bing Maps images.

Notice the events “selectedEntityChanged” and “moveEnd” in the VC Viewer. I need these to know when the user clicks on an icon, and when he finishes dragging the scene.

Here’s the script:

<script>
const tentLogo = require('../../public/img/TentLogo.png');

export default {
data() {
return {
scratchRectangle: null,
geocoder: true,
viewer: null,
frame: null,
billboards: [],
camera: {
position: {
lng: 31.77,
lat: 35.21,
height: 10000000
},
heading: 360,
pitch: -90,
roll: 0
},
animation: false,
url: 'https://dev.virtualearth.net',
bmKey: <BING MAPS KEY>,
mapStyle: 'AerialWithLabels',
alpha: 1,
brightness: 1,
contrast: 1,
queryObject: {}
};
},
methods: {
async ready(cesiumInstance) {
const {Cesium, viewer} = cesiumInstance;
this.scratchRectangle = new Cesium.Rectangle();
this.viewer = viewer;
const location = await this.$getLocation();
if (location) {
this.camera.position.lng = location.lng;
this.camera.position.lat = location.lat;
this.camera.position.height = 50000;
this.animation = true;
}
},
async selectedEntityChanged(entity) {
if (entity) {
await this.selectCommunity(parseInt(entity.id));
this.communityDetailsDialog = true;
} else {
await this.selectCommunity(null);
this.communityDetailsKey = Math.random();
this.communityDetailsDialog = false;
}
},
async selectCommunity(value) {
if (this.$store.selectedCommunityId)
await this.$store.dispatch('selectCommunity', null);
else
await this.$store.dispatch('selectCommunity', value);
},
async onMoveEnd() {
const rawViewRectangle = this.viewer.camera.computeViewRectangle(this.viewer.scene.globe.ellipsoid, this.scratchRectangle);
const rawCorners = [Cesium.Rectangle.southwest(rawViewRectangle), Cesium.Rectangle.northeast(rawViewRectangle)];
const corners = rawCorners.map(corner => {
return {
longitude: Number(Cesium.Math.toDegrees(corner.longitude).toFixed(4)),
latitude: Number(Cesium.Math.toDegrees(corner.latitude).toFixed(4))
}
});
this.queryObject.corners = corners;
await this.$store.dispatch('getCommunities', this.queryObject);
this.billboards = this.$store.state.communities.map(community => {
return {
position: {lng: community.coordinates.x, lat: community.coordinates.y},
image: tentLogo,
scale: 0.1,
id: community.id
};
})
}
}
};
</script>

Man, that’s a bunch of stuff. But it covers everything we need to make this work.

The first thing we do is to require our logo from our local filesystem and store it for future use:

const tentLogo = require('../../public/img/TentLogo.png');

Now, in our data, we set up the initial values for the VC Viewer component:

data() {
return {
scratchRectangle: null,
geocoder: true,
viewer: null,
frame: null,
billboards: [],
camera: {
position: {
lng: 31.77,
lat: 35.21,
height: 10000000
},
heading: 360,
pitch: -90,
roll: 0
},
animation: false,
url: 'https://dev.virtualearth.net',
bmKey: <BING MAPS KEY>,
mapStyle: 'AerialWithLabels',
alpha: 1,
brightness: 1,
contrast: 1,
queryObject: {}
};
},

Except for the scratchRectangle and queryObject, this is all boilerplate setup stuff. We will see the purpose for these two later.

Let’s look at our methods:

methods: {
async ready(cesiumInstance) {
const {Cesium, viewer} = cesiumInstance;
this.scratchRectangle = new Cesium.Rectangle();
this.viewer = viewer;
const location = await this.$getLocation();
if (location) {
this.camera.position.lng = location.lng;
this.camera.position.lat = location.lat;
this.camera.position.height = 50000;
this.animation = true;
}
}

This is boilerplate setup stuff too, but it’s very useful. First, here we get our Cesium and viewer objects, which give us access to everything Cesium provides, not just the fraction which is documented in the Cesium Vue documentation. Second, here we make this.scratchRectangle an instance of a Cesium.rectangle. Below, I will show why this is important.

async selectedEntityChanged(entity) {
if (entity) {
await this.selectCommunity(parseInt(entity.id));
} else {
await this.selectCommunity(null);
}
}

This is a bit outside the scope of this article, but this method lets us know which entity (community billboard, in this case) was clicked and react appropriately.

async onMoveEnd() {
const rawViewRectangle = this.viewer.camera.computeViewRectangle(this.viewer.scene.globe.ellipsoid, this.scratchRectangle);
const rawCorners = [Cesium.Rectangle.southwest(rawViewRectangle), Cesium.Rectangle.northeast(rawViewRectangle)];
const corners = rawCorners.map(corner => {
return {
longitude: Number(Cesium.Math.toDegrees(corner.longitude).toFixed(4)),
latitude: Number(Cesium.Math.toDegrees(corner.latitude).toFixed(4))
}
});
this.queryObject.corners = corners;
await this.$store.dispatch('getCommunities', this.queryObject);
this.billboards = this.$store.state.communities.map(community => {
return {
position: {lng: community.coordinates.x, lat: community.coordinates.y},
image: tentLogo,
scale: 0.1,
id: community.id
};
})
}
}

This method is the heart of our use case. Fired when the camera stops moving, it computes the view rectangle, i.e., the frame, and stores it in this.scratchRectangle. That last part is important, because if we hadn’t passed the existing Cesium rectangle to the method, it would create a new entity. Depending how much we move around, this could garbage up our browser’s memory. Then, we get the Southwest and Northeast corners of the rectangle, convert their longitude and latitude to degrees (Cesium’s default is radians,) store them on this.queryObject, and fire the Vuex store’s ‘getCommunities’ method with the query object as an input. Once that has come back, we take the updated communities in our Vuex store state (which are those that are contained in the view rectangle-remember that part?) and map them to populate this.billboards. That puts these communities’ icons on our map.

With that, this tutorial is complete. We’ve implemented map UI-based database querying, with pagination. If you have any questions. comments or suggestions for ways I could improve this tutorial, please let me know!

--

--

Baruch Kogan

Settler in the Shomron. Tech/manufacturing/marketing/history.