A Brief Introduction to OpenLayers and Web Mapping
Contextualizing the tools and getting a map plotted with AngularLucas Sartori
November 28, 2023
•
21 min read
Sometimes I catch myself wondering about how we take for granted most of the tech we have nowadays. One of these things is mapping. If one day Google Maps stops working and I have to get to even the most trivial places where I live, I’ll be in deep trouble. If you were to tell Lucas from 15 years ago that we’d have these devices that allow us not only to see where we are in an interactive map but also to trace a route from point A to point B, I’d never believe you.
Turns out that life has found its way of pushing me exactly to the place where I would be working with this specific piece of tech that fascinates me. In this article I’ll share a little of what I learned working with web mapping and OpenLayers.
The History
First let’s ask ourselves: how does this even work and what’s the history behind it? I won’t get into specifics, but it evolved alongside with modern browsers; in 2005 Google Maps was released completely revolutionizing the field of web mapping, before that there were only proprietary solutions that were pretty much inaccessible to the general public. Taking the momentum of the neat emerging tech, OpenLayers was released one year later as an Open Source alternative, allowing users to integrate maps from multiple sources, including Google Maps.
To sum it up, here’s a timeline:
- 2005 - Inception: OpenLayers, a JavaScript-based solution, emerged, offering pan/zoom capability, and layer switching. As Google Maps began to popularize AJAX-driven interactive maps, OpenLayers stood out with its open-source promise.
- 2007 - Evolution: OpenLayers 2.0 launched with tiled layers, vector layers, and client-side styling. It began using canvas for some vector rendering tasks around this time. Bing Maps, on the other hand, was enhancing its own satellite imagery and driving directions features.
- 2012 - Expansion: OpenLayers 2.12 arrived, enhanced with HTML5 features like multi-touch support and vector layers for mobile. Apple Maps debuted with 3D view and turn-by-turn navigation. Meanwhile, the minimalist Leaflet.js library was gaining traction for its simplicity and performance.
- 2015 - Revolution: OpenLayers 3 was a complete rewrite, now leveraging WebGL for faster rendering, offering better usability, and maintaining canvas support for broad compatibility. Mapbox GL was shaking things up with vector tile-based maps and fully customizable styles.
- 2018-2021 - Refinement: OpenLayers continued refining with features like decluttering for vector layers and smoother animations in OpenLayers 6. Mapbox and Leaflet matured, offering powerful features like 3D terrain and indoor maps. Meanwhile, Kepler.gl arrived, bringing advanced geospatial analysis capabilities.
Inner workings
But how does this all work nowadays? There are multiple ways that you can set up an interactive map on the web. Usually it’s a mix between consuming data from a server (something like GeoServer) that processes data from a database that can be specialized or not. One of the most notable specialized databases is PostGIS, it offers a lot of functionality tailored for mapping out of the box.
When it comes to rendering in the front-end, there are indeed multiple ways to choose from, all of them with their specific tradeoffs.
- Do you want to plot topological data? You probably need to pre-process your data in a server first and serve ready made vector tiles to the front-end.
- Do you want to plot a bunch of markers on a simple map and then add interactivity to said markers? You probably can get away with GeoJSON (a JSON standard for web mapping)
At the end of the day, it all comes down to a library (in our case OpenLayers) stacking images and elements on an html canvas.
Here’s a glossary of some common terms you will see in this world:
- WMS vs. WFS: WMS (Web Map Service) serves map images over the internet from a GIS (Geographic Information System) server. WFS (Web Feature Service) on the other hand, serves raw geographic features (vector data), which allows further client-side interactions and manipulations.
- Layers: Think of these as transparent sheets stacked on top of one another, each displaying different data types. You can have base layers (like a world map), overlay layers (weather data, for example), and interactive layers.
- Data from WMS/WFS: OpenLayers requests map images or features from a WMS or WFS server respectively (e.g: Geoserver), processes the returned data, and renders it onto your map.
- Vector vs. Raster Data: OpenLayers supports both. Vector data (points, lines, polygons) allows more dynamic and interactive maps while raster data (like images from WMS) are static but can provide rich visuals.
- GeoJSON: Ideal for RESTful APIs and smaller data sets, encodes geographic data structures.
- TopoJSON: An extension of GeoJSON for more efficient rendering of larger datasets.
- KML/GML: Widely used formats in GIS applications.
- Vector Tiles: For efficient rendering of large amounts of vector data. (e.g elevation, heatmaps)
Overview of Development Platforms
I’ve been talking about OpenLayers, but it should be known that even though it is indeed the grandfather of all open source web mapping libraries, it is not the only one out there. The ecosystem has grown a lot and evolved alongside modern web browsing and the javascript ecosystem. New libraries such as Leaflet.js offer a lot of flexibility and are easier to use and maintain depending on the use case. For more complex use cases OpenLayers still allows for a lot of customization and is and will be open source for the foreseeable feature.
Other than Leaflet, another one worth mentioning is Mapbox; tech giants love it. Platforms like Uber, Evernote, Foursquare all use it and there are a lot of baked inner goodies that you can’t find anywhere without significant and expensive developer work, such as built-in navigation libraries, 3D views, an out-of-the-box aesthetically pleasing experience (Openlayers really lacks in this regard, it gives nothing but the primitives necessary to build the maps). However it should be noted that all of this comes with vendor lock-in, it’s not particularly easy to emulate any of those services that mapbox offers without considerable effort.
Here are the Pros and Cons of the various platforms that are available in 2023:
OpenLayers
- Pros: High flexibility, wide array of features, support for various data formats, extensive customization options. Offers a pluggable architecture, with support for both Canvas and WebGL rendering.
- Cons: Steeper learning curve, larger file size, testing challenges, and not as straightforward integration with modern frameworks.
- Who's Using It: Government agencies, GIS professionals, organizations like National Geographic needing advanced, customizable map solutions.
- Backend: OpenLayers is generally backend agnostic and can work with various types of data sources including GeoJSON, TopoJSON, KML, GML, Mapbox vector tiles, and many more.
Leaflet.js
- Pros: Lightweight, easy to use, and highly efficient. Great for most general-purpose mapping needs, supports a wide range of plugins. It uses a simplified Canvas-based rendering engine.
- Cons: Lacks some advanced features out-of-the-box that come standard with OpenLayers and Mapbox GL.
- Who's Using It: Developers looking for simplicity, online media outlets, real estate platforms, applications like Foursquare and Pinterest.
- Backend: Leaflet doesn't dictate a specific backend. It can handle various types of data, including raster tiles, vector tiles in GeoJSON or TopoJSON format, and even image overlays.
Mapbox GL
- Pros: Powerful, smooth rendering of large datasets and vector tiles, highly customizable styles, robust mobile SDKs. It employs WebGL for rendering, providing robust performance.
- Cons: Less straightforward to extend with custom functionality compared to OpenLayers. Usage beyond free tier incurs costs.
- Who's Using It: Tech giants, startups, social media platforms like Snapchat and Facebook needing scalable, custom map solutions.
- Backend: Mapbox provides its own backend services, but the library can be used with other backend services as long as they can supply Mapbox Vector Tiles.
Let’s get practical:
Now that we talked and explored a little of the web mapping world, I want to propose a simple project to explore what we can do: plot a simple web map with the faces of the Beta Acid folks!
First of all, given these constraints, what exactly would make sense to use here? We need an interactive map which will plot a few markers around a world map, the user should be able to zoom in and out and interact with the markers.
Not a lot of data, simple interactive web mapping. Seems like an excellent fit for OpenLayers and a simple GeoJSON.
First of all, let’s get our data ready, I created a json file for us to use with random locations and fun lord of the rings-ish names!
You can see that each marker has the following data:
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [-64.58964736550732, -16.435949919838503]
},
"properties": {
"name": "Lucas Sartori Moraes",
"image": "https://betaacid.co/_next/image?url=https%3A%2F%2Fmedia.graphassets.com%2F79voaePJRgGLDTon1vko&w=1080&q=75",
"country": "North Tirithmoor",
"city": "East Shiretown"
}
},
Coordinates, which will be read by our mapping engine, and metadata about the specific marker (in the OpenLayers world, a Feature )
We basically need to read this GeoJSON and then display it in our map. Let’s get started with my trusty ol’ Angular framework to make our lifes a little easier:
In our root folder:
app.component.ts
, let’s scaffold our map:
export class AppComponent implements AfterViewInit {
async ngAfterViewInit() {
this.initializeSources();
await this.initializeMap();
this.addInteractions();
}
}
What we’re doing here is basically setting up what we want to do, let’s go ahead and implement initializeSources()
initializeSources(): void {
const geoJsonFormat = new GeoJSON();
this.pointSource = new VectorSource({
features: geoJsonFormat.readFeatures(geoData, {
featureProjection: 'EPSG:3857',
}),
});
}
What is happening here? We’re using OpenLayers’ built-in GeoJSON to initialize and read our data, and then attach it to a VectorSource, which is a data structure responsible for serving and sanitizing our data in a way that is usable by OpenLayers. Our file now looks like this:
import { AfterViewInit, Component } from '@angular/core';
import { Map } from 'ol';
import VectorSource from 'ol/source/Vector';
import * as geoData from './beta-acid.json';
import GeoJSON from 'ol/format/GeoJSON';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent implements AfterViewInit {
mapInstance!: Map;
pointSource!: VectorSource;
async ngAfterViewInit() {
this.initializeSources();
await this.initializeMap();
this.addInteractions();
}
async initializeMap(): Promise<void> {}
initializeSources(): void {
const geoJsonFormat = new GeoJSON();
this.pointSource = new VectorSource({
features: geoJsonFormat.readFeatures(geoData, {
featureProjection: 'EPSG:3857',
}),
});
}
addInteractions(): void {}
}
Let’s try to render something implementing the function initializeMap
async initializeMap(): Promise<void> {
const tileLayer = new TileLayer({
source: new OSM(),
zIndex: 10,
});
const markerLayer = new VectorLayer({
source: this.pointSource,
zIndex: 20,
});
this.mapInstance = new Map({
view: new View({
center: [0, 0],
zoom: 1,
}),
layers: [tileLayer, markerLayer],
target: 'map',
controls: []
});
}
Here we initialize our map, which is represented by TileLayer, importing the OSM module from OpenLayers. OSM stands for OpenStreetMaps, it is baked in the library and gives us a ready to use map out of the box. Still, we can’t see anything rendering! Let’s fix that.
Remember that in our mapInstance
object, we’re targeting ‘map’
. It is the ID of the element that it will look for in our page.
In app.component.html
:
<div class="map" id="map"></div>
This is where our map is gonna live; OpenLayers will render a canvas in this div.
Let’s also make it fullscreen, in app.component.css
:
.map {
width: 100vw;
height: 100vh;
}
Save it and refresh the page. You should be able to see something like this:
We can see our markers! Our code so far looks like this:
import { AfterViewInit, Component } from '@angular/core';
import { Map, View } from 'ol';
import VectorSource from 'ol/source/Vector';
import * as geoData from './beta-acid.json';
import GeoJSON from 'ol/format/GeoJSON';
import TileLayer from 'ol/layer/Tile';
import { OSM } from 'ol/source';
import VectorLayer from 'ol/layer/Vector';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent implements AfterViewInit {
mapInstance!: Map;
pointSource!: VectorSource;
async ngAfterViewInit() {
this.initializeSources();
await this.initializeMap();
this.addInteractions();
}
async initializeMap(): Promise<void> {
const tileLayer = new TileLayer({
source: new OSM(),
zIndex: 10,
});
const markerLayer = new VectorLayer({
source: this.pointSource,
zIndex: 20,
});
this.mapInstance = new Map({
view: new View({
center: [0, 0],
zoom: 1,
}),
layers: [tileLayer, markerLayer],
target: 'map',
controls: [], // just to get rid of outdate OL interface
});
}
initializeSources(): void {
const geoJsonFormat = new GeoJSON();
this.pointSource = new VectorSource({
features: geoJsonFormat.readFeatures(geoData, {
featureProjection: 'EPSG:3857',
}),
});
}
addInteractions(): void {}
}
This however is not exactly what we want to see, let’s try to render everyone’s custom photo from the website as their marker. For this, we’ll use OpenLayers’ Style API. We want to render names and photos, so it’ll look like this:
function createStyle(feature: FeatureLike) {
const name = feature.get('name');
const photo = feature.get('image');
if (name && photo) {
const individualStyle = new Style({
image: new Icon({
src: photo,
width: 50,
height: 50,
}),
text: new Text({
text: name,
font: 'bold 12px Arial',
fill: new Fill({
color: 'black',
}),
offsetY: 35,
stroke: new Stroke({
color: 'papayawhip',
width: 1,
}),
}),
});
return [individualStyle];
}
return [];
}
You can check out the Style API in OpenLayers’ official documentation. We are restricted in what we can do here, it is a canvas and we need to work with vectors. Most of the basic cases are covered but you can always use the canvas API if you need custom fancy stuff. Now we just need to add style: createStyle to our markerLayer and we should be good to go!
const markerLayer = new VectorLayer({
source: this.pointSource,
style: createStyle,
zIndex: 20,
});
Would you look at that! We got everyone’s faces and names rendering already, and fully interactively!
Still, we need to figure out what to do with people that are too close to each other, for instance those two people down in Saudi Arabia:
From this zoom level it looks okay, but if you zoom out it clearly becomes a problem:
The markers are overlapping with each other. How to solve that? Clustering. There are plenty of ways to do that, but here we’ll focus on handling it on the frontend.
Clusters are a Layer by themselves, you can mix and apply different styles to clusters depending on our needs.
In our small project we’ll ditch our normal tileLayer and work exclusively with a clusterLayer.
Let’s initialize it’s source at the top of the component:
clusterSource!: Cluster;
and in initializeSources
:
this.clusterSource = new Cluster({
distance: 50,
minDistance: 50,
source: this.pointSource,
});
Now our markerLayer
should use clusterSource
instead of pointSource
:
const markerLayer = new VectorLayer({
source: this.clusterSource,
style: createStyle,
zIndex: 20,
});
Neat! But you should notice that now nothing is rendering. That is because our createStyle
function isn’t prepared to deal with clusters. Let’s fix that.
function createStyle(feature: FeatureLike) {
const clusterProperties = feature.getProperties();
const isCluster = clusterProperties['features'].length > 1;
const clusterStyle = new Style({
image: new Icon({
src: 'https://images.crunchbase.com/image/upload/c_lpad,h_256,w_256,f_auto,q_auto:eco,dpr_1/mglhdx06vkmjnnmez7er',
scale: 0.2,
}),
text: new Text({
text: clusterProperties['features'].length.toString(),
font: 'bold 12px Arial',
offsetX: 35,
stroke: new Stroke({
color: 'white',
width: 3,
}),
}),
});
if (isCluster) {
return [clusterStyle];
}
const name = clusterProperties['features']?.[0]?.get('name');
const photo = clusterProperties['features']?.[0]?.get('image');
if (name && photo) {
const individualStyle = new Style({
image: new Icon({
src: photo,
width: 50,
height: 50,
}),
text: new Text({
text: name,
font: 'bold 12px Arial',
fill: new Fill({
color: 'black',
}),
offsetY: 35,
stroke: new Stroke({
color: 'papayawhip',
width: 1,
}),
}),
});
return [individualStyle];
}
return [clusterStyle];
}
As you can see, we’re checking whether we have more than 1 point at the specific point based on the distance we set when instantiating clusterSource
, and if there is more than 1 feature at that point, we render the Beta Acid logo, if not we construct the individual style instead. The API is indeed pretty imperative but remember that we’re working with the canvas API.
Here’s our result:
Feel free to play around with cluster distance to see how it behaves, zooming in will make new people appear!
Here’s the complete code at this point: https://gist.github.com/fang0rnz/a3e937c6f9ac024f135045b257b01072
Now last but not least, we want to add interactions to these markers!
addInteractions(): void {
const hoverSelect = new Select({
condition: pointerMove,
style: createStyle,
});
this.mapInstance.addInteraction(hoverSelect);
hoverSelect.on('select', (e) => {
if (e.selected.length > 0 && e.selected[0].get('features').length === 1) {
const feature = e.selected[0].get('features')[0];
console.log(feature);
this.showOverlay(feature);
} else {
this.hideOverlay();
}
});
}
OpenLayers provides a bunch of possible interactions baked inside it. We want to show an overlay when hovering above someone’s photo. But you can also add custom interactions such as specific key presses, clicks, etc. You can also change the selected marker’s style depending on interaction, here we don’t want to do that, so let’s try to just show an overlay.
In order to do that, let’s create a Details
component that will render inside the map.
@Component({
selector: 'details-component',
template: ` <div class="overlay">
<div>{{ name }}</div>
<div>{{ city }}</div>
</div>`,
styles: [
`
.overlay {
position: absolute;
background-color: white;
border-radius: 10px;
padding: 10px;
width: 150px;
border: 1px solid black;
}
`,
],
})
class Details {
@Input() name!: string;
@Input() city!: string;
}
Now onwards to the showOverlay
and hideOverlay
function:
showOverlay(feature: any): void {
const coordinate = feature.getGeometry().getCoordinates();
const componentRef = this.overlayContainer.createComponent(Details);
componentRef.instance.name = feature.get('name');
componentRef.instance.city = feature.get('city');
const overlay = new Overlay({
element: componentRef.location.nativeElement,
position: coordinate,
positioning: 'bottom-center',
offset: [30, -25],
});
if (this.overlay) {
this.mapInstance.removeOverlay(this.overlay);
}
this.mapInstance.addOverlay(overlay);
this.overlay = overlay;
}
hideOverlay() {
this.overlay.setPosition(undefined);
}
Great! Remember to initialize the Overlay
containers and the other goodies.
Here is the gist so far: https://gist.github.com/fang0rnz/8fb80be49046b74cd1c3fb78efd66986
This is it! There is a lot I didn’t dive in, such as decluttering, mixed clusters and more advanced things, but we have a fully working interactive map! I hope you were able to learn something from me with this code-along.
SHARE THIS STORY
Get in touch
Whether your plans are big or small, together, we'll get it done.
Let's get a conversation going. Shoot an email over to projects@betaacid.co, or do things the old fashioned way and fill out the handy dandy form below.