To complete this workshop you will need:
Resources and Geodata
Languages used:
DeckGL
is a WebGL-powered framework for visual exploratory data analysis of large datasets. Based on Luma.GL, DeckGL
provides high-performance visualisations of large datasets using GPU computation and a Layer Approach.
In this first section, GeoJsonLayer
and TextLayer
will be used.
To get started with DeckGL
, create a new index.html
file. Add an element div
with id="map"
as child of another element div
with id="container"
, the former will contain the WebGL renderer of DeckGL
<!DOCTYPE html>
<html>
<title>DeckGL, Hello Data!</title>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- DeckGL Library -->
<script src="https://unpkg.com/deck.gl@latest/dist.min.js"></script>
<style type="text/css">
body {
margin: 0;
padding: 0;
}
#container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
#map {
position: absolute;
top: 0;
bottom: 0;
width: 100%;
}
</style>
</head>
<body>
<div id="container">
<div id="map"></div>
</div>
</body>
</html>
The next step is to add a JS script to initialise DeckGL
with the essential parameters (i.e. name of the HTML container, latitude, longitude and controller). The script can be added as last tag in the body
element of the index.html
file or as an external file.
<script type="text/JavaScript">
const deckgl = new deck.DeckGL({
container: 'map',
initialViewState: {
latitude: 51.47,
longitude: 0.45,
zoom: 4,
bearing: 0,
pitch: 30
},
parameters: {
//Canvas background color, it can be applied as DIV CSS as well
clearColor: [0, 0.8, 0.8, 0.5] //RGB 0-1+ opacity
},
controller: true,
});
</script>
The result is an empty, light blue, interactive page... let's add some data. DeckGL
uses a Layer Approach
to visualise data. It is possible to add a simple base map using a GeoJson dataset. The GeoJsonLayer
is one of the Core Layers.
The layers are passed to the parameters of the DeckGL
object in form of array:
const deckgl = new deck.DeckGL({
container: 'map', // the id of the div element
initialViewState: {
latitude: 51.47,
longitude: 0.45,
zoom: 4,
bearing: 0,
pitch: 0
},
parameters: {
//Canvas background color, it can be applied to DIV CSS as well
clearColor: [0, 0.8, 0.8, 0.5] //RGB 0-1+ opacity
},
controller: true, //activate the mouse control
layers: [...]
});
The first dataset we are going to use, ne_50m_admin_0_countries.geojson
, contains the world countries in form of polygons; the second dataset, ne_10m_populated_places_simple.geojson
, is used to visualise, in form of points, the most populated places on earth:
//base map
const COUNTRIES = 'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson'
const deckgl = new deck.DeckGL({
container: 'map', // the id of the div element
initialViewState: {
latitude: 51.47,
longitude: 0.45,
zoom: 4,
bearing: 0,
pitch: 0
},
parameters: {
//Canvas background color, it can be applied to DIV CSS as well
clearColor: [0.38, 0.89, 0.94, 0.5] //RGB 0-1+ opacity
},
controller: true, //activate the mouse control
layers: [
//First layer
new deck.GeoJsonLayer({
id: 'base-map', //every layer needs to have a unique ID
data: COUNTRIES, //data can be passed as variable or added inline
// Styles
stroked: true,
filled: true,
lineWidthMinPixels: 1,
opacity: 0.7,
getLineColor: [252, 148, 3], //RGB 0 - 255
getFillColor: [79, 75, 69]
}),
]
});
The result is a base map of the world that can be easily customised using the properties after the Style comment
The order of visualisation in DeckGL
follows the order of the layers in the array. The last layer of the array is the one on the top.
The next layer is the ne_10m_populated_places_simple.geojson
const COUNTRIES = 'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson'
const POPULATION = 'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_populated_places_simple.geojson'
const deckgl = new deck.DeckGL({
container: 'map',
initialViewState: {
latitude: 51.47,
longitude: 0.45,
zoom: 4,
bearing: 0,
pitch: 0
},
parameters: {
//Canvas background color, it can be applied to DIV CSS as well
clearColor: [0.38, 0.89, 0.94, 0.5] //RGB 0-1+ opacity
},
controller: true,
layers: [
//First layer
new deck.GeoJsonLayer({
id: 'base-map',
data: COUNTRIES,
// Styles
stroked: true,
filled: true,
lineWidthMinPixels: 1,
opacity: 0.7,
getLineColor: [252, 148, 3], //RGB 0 - 255
getFillColor: [79, 75, 69]
}),
//Second layer
new deck.GeoJsonLayer({
id: 'population',
data: POPULATION,
dataTransform: d => d.features.filter(f => f.properties.featurecla === 'Admin-0 capital'),
// Styles
filled: true,
pointRadiusScale: 10,
getPointRadius: f => f.properties.pop_max / 1000,
getFillColor: [200, 0, 80, 190],
}),
]
});
Using Deck.GL
it is possible transform the data on-the-fly using the dataTransform
. In this example using arrows functions
dataTransform: d => d.features.filter(f => f.properties.featurecla === 'Admin-0 capital'),
If we are not sure about the result of the dataTransform, we can log it in the console by changing the syntax of the arrow function
dataTransform: d =>{ console.log(d.features); return d.features.filter(f => f.properties.featurecla === 'Admin-0 capital')},
the features are filtered by countries' capitals, and by using
getPointRadius: f => f.properties.pop_max / 1000,
the radius of the buffer is created according to the population value of the city (divided by 1000 for visualisation reasons).
A common layer is the TextLayer
used to add labels to the map features
layers: [
//First layer
new deck.GeoJsonLayer({
id: 'base-map',
data: COUNTRIES,
// Styles
stroked: true,
filled: true,
lineWidthMinPixels: 1,
opacity: 0.7,
getLineColor: [252, 148, 3], //RGB 0 - 255
getFillColor: [79, 75, 69]
}),
//Second layer
new deck.GeoJsonLayer({
id: 'population',
data: POPULATION,
dataTransform: d => d.features.filter(f => f.properties.featurecla === 'Admin-0 capital'),
// Styles
filled: true,
pointRadiusScale: 10,
getPointRadius: f => f.properties.pop_max / 1000,
getFillColor: [200, 0, 80, 190],
}),
//Third layer
new deck.TextLayer({
id: 'text-layer',
data: POPULATION,
dataTransform: d => d.features.filter(f => f.properties.featurecla === 'Admin-0 capital'),
getPosition: f => f.geometry.coordinates,
getText: f => { return f.properties.pop_max.toString(); },
getSize: 22,
getColor: [0, 0, 0, 180],
getAngle: 0,
getTextAnchor: 'middle',
getAlignmentBaseline: 'bottom',
fontFamily: 'Gill Sans',
background: true,
getBackgroundColor: [255, 255, 255,180]
}),
]
In addition to the TextLayer
it is possible to display information using the getTooltip
function. getTooltip
is used per layer and it is essential to set the parameter pickable: true
to each layer we want to interact with
//Second layer
new deck.GeoJsonLayer({
id: 'population',
data: POPULATION,
dataTransform: d => d.features.filter(f => f.properties.featurecla === 'Admin-0 capital'),
// Styles
filled: true,
pointRadiusScale: 10,
getPointRadius: f => f.properties.pop_max / 1000,
getFillColor: [200, 0, 80, 190],
//Interactivity
pickable: true,
}),
The getTooltip
function needs to be added outside the layers' array
getTooltip: ({ object }) => object && { //object is the reference to the hover feature
html: `<b>${object.properties.name}:</b> ${object.properties.pop_max}`, //html of the tooltip content
style: {
backgroundColor: 'steelblue',
fontSize: '0.8em',
color: 'white',
}
}
The simple base map we used is too limited to provide advanced and multi-scalar spatial visualisations. DeckGL
accepts multiple map providers to enhance the visualisation outcomes, such as MapLibre, MapboxGL, HERE, CARTO and Google Maps. Each of them provides a different level of detail and features.
Some of them require signing up to obtain an API Token (e.g. HERE
and Google Map
) others can be used without any authorization system (e.g. MapboxGL
using the version 1 of the library or the open project Maplibre).
MapboxGL
integrates nicely with DeckGL
. It provides various vector base map styles and, through the DeckGL
Mapbox Layer, it is possible to overlay seamlessly the DeckGL
Layers. The MapboxGL
library needs a CSS style based on the same version to properly work.
<!-- Mapbox Library -->
<script src='https://api.mapbox.com/mapbox-gl-js/v2.14.1/mapbox-gl.js'></script>
<link href='https://api.mapbox.com/mapbox-gl-js/v2.14.1/mapbox-gl.css' rel='stylesheet' />
We need to remove the content of the script
tag (or create a new index.html
) add both Mapbox
library and CSS.
<!-- Mapbox Library -->
<script src='https://api.mapbox.com/mapbox-gl-js/v2.14.1/mapbox-gl.js'></script>
<link href='https://api.mapbox.com/mapbox-gl-js/v2.14.1/mapbox-gl.css' rel='stylesheet' />
In the script
element, or in an external JavaScript file, we need to add the access token API_TOKEN_Mapbox
obtained from Mapbox
website to be passed to the mapboxgl
instance:
// Set your API key here
const API_TOKEN_Mapbox = 'Your_API-Token';
mapboxgl.accessToken = API_TOKEN_Mapbox;
and the initial status of the map view in a separate variable. As in the previous example, the HTML
will contain just two div
elements with id container
and map
and their CSS style.
A way to pass parameters to the map object is to create a JavaScript object
const INITIAL_VIEW_STATE = {
longitude: 0.12,
latitude: 51.5,
zoom: 10,
bearing: 0,
pitch: 0
};
The Object is added to the Mapbox
map function, together with the style of the vector tiles to use
// MapBox Vector Tile
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/light-v11', //vector tiles require a Mapbox API to access them
// Note: deck.gl will be in charge of interaction and event handling
interactive: true,
center: [INITIAL_VIEW_STATE.longitude, INITIAL_VIEW_STATE.latitude],
zoom: INITIAL_VIEW_STATE.zoom,
bearing: INITIAL_VIEW_STATE.bearing,
pitch: INITIAL_VIEW_STATE.pitch,
projection: 'mercator' //see https://docs.mapbox.com/mapbox-gl-js/api/map/ for a list of available projections
});
The result is a Mapbox
map centred on London
Mapbox
uses OpenStreetMap data. It is possible to extrude the buildings to create a 3D model of urban environment
map.on('load', () => {
const firstLabelLayerId = map.getStyle().layers.find(layer => layer.type === 'symbol').id;
map.addLayer({
'id': '3d-buildings',
'source': 'composite',
'source-layer': 'building',
'filter': ['==', 'extrude', 'true'],
'type': 'fill-extrusion',
'minzoom': 15,
'paint': {
'fill-extrusion-color': '#aaa',
// use an 'interpolate' expression to add a smooth transition effect to the
// buildings as the user zooms in
'fill-extrusion-height':["get", "height"],
'fill-extrusion-base': ["get", "min_height"],
'fill-extrusion-opacity': 0.8
}
},
firstLabelLayerId
);
});
the event load
on the map.on
function is used to ensure that the 3D layer is added once the map is ready. The firsLabelLayerId
forces all the labels of the map to be displayed over the other layers.
'fill-extrusion-height':[
"interpolate", ["linear"], ["zoom"],
// zoom is 15 (or less) -> buildings height is 0
15, 0,
// zoom is 15.05 (or greater) -> buildings height is actual value
15.05, ["get", "height"]
],
'fill-extrusion-base':[
"interpolate", ["linear"], ["zoom"],
// zoom is 15 (or less) -> buildings height is 0
15, 0,
// zoom is 15.05 (or greater) -> buildings height is actual value
15.05, ["get", "min_height"]
],
DeckGL
layers can be added to the MapboxGL
vector tiles canvas by using deck.MapboxLayer
method inside the map.on('load')
function. The following script adds a GeoJsonLayer
and an ArcLayer
to the base map using the same dataset:
// source: Natural Earth http://www.naturalearthdata.com/ via geojson.xyz
const AIR_PORTS =
'https://d2ad6b4ur7yvpq.cloudfront.net/naturalearth-3.3.0/ne_10m_airports.geojson';
map.on('load', () => {
const AirportPointLayer = new deck.MapboxLayer({
id: 'airports',
type: deck.GeoJsonLayer,
data: AIR_PORTS,
// Styles
filled: true,
pointRadiusMinPixels: 2,
pointRadiusScale: 200,
getPointRadius: f => (11 - f.properties.scalerank),
getFillColor: [200, 0, 80, 180],
// Interactive props
pickable: true,
autoHighlight: true,
onClick: info => info.object && alert(`${info.object.properties.name} (${info.object.properties.abbrev})`)
});
const AirportArcLayer = new deck.MapboxLayer({
id: 'arcs',
type: deck.ArcLayer,
data: AIR_PORTS,
dataTransform: d => d.features.filter(f => f.properties.scalerank < 4),
// Styles
getSourcePosition: f => [-0.4531566,51.4709959], // London
getTargetPosition: f => f.geometry.coordinates,
getSourceColor: [0, 128, 200],
getTargetColor: [200, 0, 80],
getWidth: 1
});
map.addLayer(AirportPointLayer);
map.addLayer(AirportArcLayer);
});
DeckGL
provides also layers that are specifically focused and optimised for 3D visualisation. One of them is the ScenegraphLayer
.
The next script is used to visualise a 3D model for each tree in the Camden borough. The data is passed as GeoJSON. The relative path of the 3D model is set in the scenegraph
option.
ATTENTION 3D models could introduce performance issues on mobile devices if too complex (e.g. multiple materials, high number of vertices). GLTF
file format or GLB
file format with DRACO
compression are suggested file format, however FBX
and OBJ
file formats can be used as well.
const CamdenTrees= 'https://opendata.camden.gov.uk/resource/csqp-kdss.geojson?$limit=50000' //limit attribute is needed to get all the data more info on https://dev.socrata.com/docs/queries/limit.html
const treeCamden3DLayer = new deck.MapboxLayer({
id: 'tree3D',
type: deck.ScenegraphLayer,
data:CamdenTrees,
dataTransform: d => d.features.filter(f => f.geometry !=null), //filter the data in case of null values
getPosition: d => d.geometry.coordinates,
getOrientation: d => [0, Math.random() * 180, 90],
scenegraph: 'resources/tree.glb',
sizeScale: 3,
_lighting: 'pbr',
});
map.addLayer(treeCamden3DLayer);
Another option is to use the IconLayer
const treeCamdenIconLayer = new deck.MapboxLayer({
id: 'icon-tree',
type: deck.IconLayer,
data:CamdenTrees,
pickable: true,
// iconAtlas and iconMapping are required
iconAtlas: 'https://upload.wikimedia.org/wikipedia/commons/b/ba/Icon_Tree_256x256.png',
iconMapping: {marker: {x: 0, y: 0, width: 256, height: 256, anchorY: 256, mask: false}},
billboard: true,
dataTransform: d => d.features.filter(f => f.geometry !=null),
getPosition: d => d.geometry.coordinates,
getIcon: d => 'marker',
sizeScale: 10,
getSize: d => 5,
getColor: d => [120, 140, 0],
pickable: true,
autoHighlight: true,
onClick: info => console.log(info.object),
});
map.addLayer(treeCamdenIconLayer);
map.setLayerZoomRange('icon-tree', 15, 20); //visualise the layer between zoom 15 and 20
The icon used by the IconLayer
does not need to be stored locally. In this example we also used a MapBox
native function map.setLayerZoomRange(['LAYER_ID'], [MIN_ZOOM], [MAX_ZOOM])
to limit the visibility of a specific layer between different levels of zoom. The icon can be used as a billboard (i.e. facing the camera) or not (i.e. z up).
A way to know the level of zoom of the current visualisation is to use a moveend
event on the main map.
map.on("moveend", e => {
const ViewState = {
zoom: map.getZoom(),
};
console.log(ViewState);
});
Finally, custom models can be added also using the GeoJsonLayer
. Add RussellSquare.geojson to the resources
folder of the website. Create a new MapboxLayer of type deck.GeoJsonLayer
and point the getElevation
function to the value of the elevation of the GeoJSON file (in this case properties._mean)
const russellSqmodel = new deck.MapboxLayer({
id: 'geojson-layer',
type: deck.GeoJsonLayer,
data: './resources/russellSq.geojson',
pickable: true,
filled: true,
extruded: true,
autoHighlight: true,
getFillColor: [238,231,215, 255],
getElevation: d=>d.properties._mean, //User generated from QGis
});
map.addLayer(russellSqmodel);
DeckGL
can be used to effectively visualise real-time data from various sources (REST API, MQTT brokers).
Create a new index.html
. In addition to the DeckGL
and Mapbox
libraries, we are going to use the library D3.js
:
<html>
<head>
<!-- DeckGL Library -->
<script src="https://unpkg.com/deck.gl@latest/dist.min.js"></script>
<!--MapBox 2.10.0-->
<script src='https://api.mapbox.com/mapbox-gl-js/v2.10.0/mapbox-gl.js'></script>
<link href='https://api.mapbox.com/mapbox-gl-js/v2.10.0/mapbox-gl.css' rel='stylesheet' />
<!--D3.js-->
<script src="https://d3js.org/d3.v7.min.js"></script>
A style for the container
and map
elements
<style>
#container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
#map {
position: absolute;
top: 0;
bottom: 0;
width: 100%;
}
</style>
</head>
and the container
and map
elements in the body
of the page:
<body>
<div id="container">
<div id="map"></div>
</div>
</body>
Add a Javascript
file as part of the index.html
or as external file. Any base map provider can be used at this point, in this example we will use the Vector Map from Mapbox
, therefore the Mapbox API Key is needed:
const API_TOKEN_Mapbox = "YOUR API KEY FROM MAPBOX";
mapboxgl.accessToken = API_TOKEN_Mapbox;
const INITIAL_VIEW_STATE = {
longitude: -0.01132798084514334,
latitude: 51.54256346290661,
zoom: 15,
bearing: 30,
pitch: 45,
};
const map = new mapboxgl.Map({
container: "map",
style: "mapbox://styles/mapbox/dark-v10", //vector tiles require a Mapbox API to access them
// Note: deck.gl will be in charge of interaction and event handling
interactive: true,
center: [INITIAL_VIEW_STATE.longitude, INITIAL_VIEW_STATE.latitude],
zoom: INITIAL_VIEW_STATE.zoom,
bearing: INITIAL_VIEW_STATE.bearing,
pitch: INITIAL_VIEW_STATE.pitch,
projection: "mercator",
});
map.on("load", () => {
const firstLabelLayerId = map
.getStyle()
.layers.find((layer) => layer.type === "symbol").id;
map.addLayer(
{
id: "3d-buildings",
source: "composite",
"source-layer": "building",
filter: ["==", "extrude", "true"],
type: "fill-extrusion",
minzoom: 15,
paint: {
"fill-extrusion-color": "#aaa",
"fill-extrusion-height": ["get", "height"],
"fill-extrusion-base": ["get", "min_height"],
"fill-extrusion-opacity": 0.8,
},
},
firstLabelLayerId
);
});
Transport for London Unified API provides a series of publicly accessible real-time APIs, in this case, we are going to use the bike sharing API that returns the location of all the Santander Dock Stations in London together with the number of bikes available for each station updated every 5 minutes.
As the result of the API, the request is a JSON
response, and not a GeoJSON one, we are going to use ScatterplotLayer
instead of the GeoJSONLayer
. The JSON
response contains various information for each bike dock. For this visualisation the essential values are latitude and longitude, which are part of the root of the JSON
file, data.lat
and data.lon
, and the values of the number of free bikes and empty docks that are part of the array additionaProperties
and they are respectively the index 6
and 7
. The value of the free bikes is used to define the radius of the point on the map
map.on("load", () => {
[..]//existing map
const BikeTFL = new deck.MapboxLayer({
id: "bike-tfl",
data: "https://api.tfl.gov.uk/BikePoint",
type: deck.ScatterplotLayer,
pickable: true,
opacity: 0.8,
stroked: true,
filled: true,
radiusScale: 50,
radiusMinPixels: 10,
radiusMaxPixels: 150,
lineWidthMinPixels: 1,
getPosition: (d) => [d.lon, d.lat],
getPointRadius: (d) => d.additionalProperties[6].value,
getFillColor: (d) => [255, 140, 0],
getLineColor: (d) => [0, 0, 30],
});
map.addLayer(BikeTFL);
});
Using the D3.js function scaleThreshold it is possible to dynamically change the colours of the docks depending on the number of bikes available. At the top of the script create a new const colorScaleFunction
const INITIAL_VIEW_STATE = {
longitude: -0.01132798084514334,
latitude: 51.54256346290661,
zoom: 15,
bearing: 30,
pitch: 45,
};
const colorScaleFunction = d3
.scaleThreshold()
.domain([0, 5, 10, 15, 20, 25]) //number of bikes
.range([
//RGB format
[255, 13, 13],
[255, 78, 17],
[255, 142, 21],
[250, 183, 51],
[172, 179, 52],
[105, 179, 76],
]);
in the BikeTFL layer change the getFillColor
to getFillColor: (d) => colorScaleFunction(d.additionalProperties[6].value),
To access all the variables of the layer we can add a popup to each point to display the data that are part of the JSON
response. The events onHover
and onClick
can be used for this purpose. In this example the popup is styled using a CSS Grid to an empty div
named tooltip using D3.js.
Update the style with the following elements:
#tooltip {
position: absolute;
visibility: hidden;
z-index: 2;
right: 0;
top: 0;
width: 20%;
height: auto;
min-height: 100px;
display: grid;
grid-template-columns: auto auto auto;
grid-template-rows: auto;
grid-template-areas:
"icon title title"
"item-a item-a ."
"item-b item-b .";
background-color: rgb(255, 216, 145);
border: 1px solid #888;
}
.title {
grid-area: title;
font-weight: bold;
margin: 4px;
}
.item-a {
grid-area: item-a;
margin: 4px;
}
.item-b {
grid-area: item-b;
margin: 4px;
}
.icon {
grid-area: icon;
margin: 4px;
max-width: 50%;
height: auto;
place-self: center;
}
Then, add a new variable let isHovering = false;
just after the const API_TOKEN_Mapbox
and, to the layer BikeTFL
, the onHover
function:
[...]
getLineColor: d => [0, 0, 30],
onHover: ({ object }, info) => {
(isHovering = Boolean(object));
if (isHovering == true) {
console.log(object.additionalProperties[6].value);
showTooltip(info, object);
map.getCanvas().style.cursor = 'pointer';
}
else { hideTooltip();
map.getCanvas().style.cursor = 'grab';}
},
});
map.addLayer(BikeTFL);
map.__deck.props.getCursor = () => map.getCanvas().style.cursor;
When the user is hovering over one of the bike docks:
isHovering
variable is set to TrueshowTooltip(info, object)
is triggered with the event info
and the information of the object
.The showTooltip
function is used to make the tooltip visible
and to populate the div element with id tooltip
using the D3.js
library.
If the mouse leaves the object, a different function hideTooltip
is used to change the visibility of the popup to hidden. The two functions can be added anywhere in the script:
function hideTooltip() {
d3.select("#tooltip").style("visibility", "hidden");
}
function showTooltip(info, object) {
//varibales to keep the tooltip inside the view of the browser
let mapSize={
height: d3.select("#map").node().getBoundingClientRect().height,
width: d3.select("#map").node().getBoundingClientRect().width
}
let tooltipSize={
height: d3.select("#tooltip").node().getBoundingClientRect().height,
width: d3.select("#tooltip").node().getBoundingClientRect().width
}
d3
.select("#tooltip")
.style("top", function(){
if(info.center.y+tooltipSize.height>mapSize.height)
{ return info.center.y - tooltipSize.height + "px"; }
else { return info.center.y + 3 + "px" }
})
.style("left", function(){
if(info.center.x+tooltipSize.width>mapSize.width)
{return info.center.x - tooltipSize.width + "px";}
else{return info.center.x + 3 + "px"}
}
)
.style("visibility", "visible")
.style("pointer-events", "none")
.html(`<img src="https://upload.wikimedia.org/wikipedia/commons/d/d4/Cyclist_Icon_Germany_A1.svg" class='icon'/>
<p class='title'> ${object.commonName}</p>
<p class='item-a'> Free Bikes: ${object.additionalProperties[6].value}</p>
<p class='item-b'> Empty Docks: ${object.additionalProperties[7].value}</p>`);
}
In this case, we used D3.js
to select the div
element, change dynamically its position, its visibility and the style of the pointer, and inject the HTML
code and variables from the JSON
response.
We can now access, and further customise, the bike sharing data. In order to update the values in real time, we need to add a final step. At the moment the request is called just once when the map is loaded. In order to update the entire JSON
on a predefined interval we need to add a setInterval function inside the map.on('load')
function
map.addLayer(BikeTFL);
const timer = setInterval(() => {
BikeTFL.setProps({ data: "https://api.tfl.gov.uk/BikePoint" });
}, 300000); //API called every 5 min => 300000 ms
Among the various visualisation libraries, ECharts is an Open Source solution that provides a large number of options and customisations with detailed examples. From basic line chart to more complex 3D charts and animations, ECharts
is an interesting tool to explore and that can be a good addition to improve the legibility, and user engagement, of a web-map and not only.
Let's create a first standalone chart to familiarise ourselves with the structure of EChart
before embedding it to DeckGL
.
In a new index.html
, add the EChart
and D3.js
libraries.
<!--D3.js-->
<script charset="utf-8" src="https://d3js.org/d3.v7.min.js"></script>
<!--ECharts-->
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.0/dist/echarts.min.js"></script>
A simple stylesheet for two elements named container
and main
#container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
#main {
width: 1000px;
height: 600px;
margin: auto;
margin-top: 50px;
}
And the elements container
and main
in the body of the html
<body>
<div id="container">
<div id="main"></div>
</div>
</body>
We can start now to write the Javascript
as part of the index.html
or as an external file
d3.csv(
"https://raw.githubusercontent.com/vsigno/publicResources/main/CityData_WUP2018_top20.csv", d3.autoType).then(
function (CityData) {
//initialise ECharts in the Div id=main
var myChartEchart = echarts.init(document.getElementById("main"), {
width: 1000,
height: 650
}); //height is used to avoid cut the text on the Xaxis
The first part of the script is used to parse the dataset using the D3.js
function d3.csv
and then to initialise the chart in the html element main
. Both width and height can be omitted, if not defined the parameter of the CSS style will be used
The chart is then created using a series of options
to the initialised object via the function myChartEchart.setOption(option)
. There are multiple variables and settings that can be used, here we will see some of them but for a complete overview the Option section of the ECharts
documentation is a good starting point
/* An example of 'option' as variable
//https://echarts.apache.org/en/option.html#series-bar.label
var labelOption = {
show: true,
position: 'inside',
rotate: 90,
align: 'left',
verticalAlign: 'middle',
fontSize: 12,
formatter: '{@pop1950} millions',
};*/
// All the settings of the chart are provided in this variable
var option = {
title: { //Title and sub-title of the chart
//ref https://echarts.apache.org/en/option.html#title
text: "World's Largest Urban Agglomerations 2020",
textStyle: {
color: "blue",
fontSize: 22,
fontWeight: "bold"
},
subtext: "Population data UN World Urbanization Prospects",
subtextStyle: {
color: "coral",
fontWeight: "bold"
}
},
xAxis: { //style of the X
type: "category",
axisLabel: {
interval: 0,
rotate: 30, //If the label names are too long you can manage this by rotating the label.
fontSize: 9
}
},
yAxis: { //Style of the Y
type: "value",
name: "Population 2020 (millions)",
nameLocation: "center",
nameGap: 30,
nameTextStyle: {
align: "center"
},
splitNumber: 10
},
tooltip: { //a tooltip is shown onHover if true
show: true
},
dataset: [ //the actual Dataset
// ref https://echarts.apache.org/en/option.html#dataset
{
source: CityData
}
],
series: [ //How the dataset is represented
{
// ref https://echarts.apache.org/en/option.html#series-bar.type
type: "bar", //type of chart and style
showBackground: true,
itemStyle: {
color: "#ffcf7d",
borderColor: "#ffac1f",
borderWidth: 1.5,
borderType: "dashed"
},
barWidth: "70%",
//label: labelOption, //option can be coded in external variable, see above. ref https://echarts.apache.org/en/option.html#series-bar.label
label: { //a label inside the bar
show: true,
position: "inside",
rotate: 90,
align: "left",
verticalAlign: "middle",
fontSize: 12,
//formatter: '{@pop2035} millions', //pop value as strin
formatter: function (params) {
var pop2035_float = parseFloat(params.data.pop2035);
return pop2035_float.toFixed(2) + ` millions`;
}
},
encode: { //what value is disply on the X, Y and tooltip (based on the header of the CSV file used)
x: "CityName",
y: ["pop2020"],
tooltip: ["pop1950", "pop2020", "pop2035"]
},
tooltip: {
formatter: function (params) {
//as pop values are strings, we parsed them to Float to better control the number of decimal places
var pop1950_float = parseFloat(params.data.pop1950);
var pop2020_float = parseFloat(params.data.pop2020);
var pop2035_float = parseFloat(params.data.pop2035);
return (
`${params.name}<br />
Population 1950 : ` +
pop1950_float.toFixed(2) +
` millions <br />
Population 2020 : ` +
pop2020_float.toFixed(2) +
` millions <br />
Population 2035 : ` +
pop2035_float.toFixed(2) +
` millions`
);
}
},
animationDuration: 2000,
animationEasing: "elasticOut" //https://echarts.apache.org/examples/en/editor.html?c=line-easing
}
]
};
// All the above is applied to the chart
myChartEchart.setOption(option);
}
);
To apply the various options to the chart we use the function setOption
. The result is an animated and interactive bar chart that can be embedded in our website.
The use of ECharts
in DeckGL
is straightforward. The chart can be added in any position of the web-map: it could be on top of it (in this case the div
element where the map is created needs to be placed outside the container
element of the map) or, as we are going to see, inside the popup triggered by the onHover
function.
In the last index.html
created for Real Time data with DeckGL
, add the ECharts
library.
The CSS
of the tooltip need to be changed as we need an additional row item-c on the grid-template-areas
grid-template-areas:
"icon title title"
"item-a item-a ."
"item-b item-b ."
"item-c item-c item-c";
add the a new style for the element item-c
#item-c {
grid-area: item-c;
margin: 4px;
height: 300px;
}
Then, we need to change the onHover
function. D3.js
will create the div
element for the chart. Finally, we call the function that creates ECharts
d3
.select("#tooltip")
.style("top", function(){
if(info.center.y+tooltipSize.height>mapSize.height)
{ return info.center.y - tooltipSize.height + "px"; }
else { return info.center.y + 3 + "px" }
})
.style("left", function(){
if(info.center.x+tooltipSize.width>mapSize.width)
{return info.center.x - tooltipSize.width + "px";}
else{return info.center.x + 3 + "px"}
}
)
.style("visibility", "visible")
.style("pointer-events", "none")
.html(`<img src="https://upload.wikimedia.org/wikipedia/commons/d/d4/Cyclist_Icon_Germany_A1.svg" class='icon'/>
<p class='title'> ${object.commonName}</p>
<p class='item-a'> Free Bikes: ${object.additionalProperties[6].value}</p>
<p class='item-b'> Empty Docks: ${object.additionalProperties[7].value}</p>
<div id='item-c'></div>`);
chart(object);
The function chart(object)
is passing to EChart
the object hovered by the user. As the object is already a JSON
structure, we do not need to use D3.js
to pass the values to the chart as we did in the previous section. In the chart
function the first step is to initialise ECharts
on the element with id item-c
function chart(dataset) {
var myChartEchart = echarts.init(document.getElementById("item-c"));
The JSON
response, in this case named dataset, is now accessible by the function chart
var option = {
title: {
text:dataset.commonName,
textStyle: {
color: "blue",
fontSize: 10,
fontWeight: "bold"
},
},
using the respective keys the options can be populated using the JSON
response (e.g. in this example the title of the chart uses the commonName of the bike station)
xAxis: {
type: "category",
// data:[dataset.additionalProperties[6].key, dataset.additionalProperties[7].key],
data:['Free Bikes', 'Empty Docks'],
axisLabel: {
interval: 0,
rotate: 30, //If the label names are too long you can manage this by rotating the label.
fontSize: 10
}
},
yAxis: {
type: "value",
nameLocation: "center",
nameTextStyle: {
align: "center"
},
In this case the keys for the free bikes and empty docks are not immediately intelligible, thus the text for these labels is manually encoded using the data
array ([‘Free Bikes', ‘Empty Docks']).
},
series: [
{
type: 'bar',
name: '2015',
//data: [dataset.additionalProperties[6].value,dataset.additionalProperties[7].value]
data: [
{
value: dataset.additionalProperties[6].value,
itemStyle: {color:'rgb('+colorScaleFunction(dataset.additionalProperties[6].value)+')'}
},
{
value: dataset.additionalProperties[7].value,
itemStyle:
itemStyle: {color:'rgb('+colorScaleFunction(dataset.additionalProperties[7].value)+')'}
}
]
}
]
}
To further customise the visualisation, it is possible to pass conditional operations to style the single elements of the charts, in this example, the bars change colour from green to red depending on the value of the free bikes or empty docks
The final step is to set the options on the charts
myChartEchart.setOption(option);
}