This workshop will show you how to:

Real Time Data on Google Tiles

To complete this workshop you will need:

Resources and Geodata

Languages used:

There are several approaches to developing and deploying web-mapping visualizations. These range from an immediate method using pure JavaScript and HTML, where entire libraries are loaded in the head of the page, to more contemporary methods employing frameworks like React, Angular, and Vue.js.
In this workshop, we are not going to use any of the above frameworks, but we will explore the use of JavaScript modules that allow us import only the code (the module) we need, rather than entire libraries, helping in keeping a cleaner, more maintainable, and faster code.
We will be using ViteJS, a frontend build tool for web projects. ViteJS is designed to offer an instant server start and features on-demand file serving over native ES modules eliminating the need for bundling during development. For production, it bundles code with Rollup, ensuring highly optimized static assets.

Let's begin by creating a new folder on our system and opening it in VSCode.

Provide a name for the project, ViteJS will create a folder with the same name;

Select the framework Vanilla;

Select the variant JavaScript;

do not use the Experimental features;

Finally, Install with npm and start now? No

Navigate to the folder you just created, as instructed in the terminal (cd [name of the folder]).

Run npm install

NPM will download the packages needed to run a basic version of ViteJS and a sample webpage.

This will launch ViteJS in developer mode with a local server accessible from the local network at the address http://localhost:5173/. Open this address in any browser to see the webpage. The port number may differ if it is already in use by another service.

In addition to the basic packages, we now need to install some additional libraries that we will use in this workshop:

  1. @deck.gl/core
  2. @deck.gl/carto
  3. maplibre-gl
  4. @deck.gl/layers
  5. @deck.gl/maplibre
npm install "@deck.gl/core" "@deck.gl/carto" maplibre-gl "@deck.gl/layers" "@deck.gl/maplibre" 

We are now ready to develop our web visualization. We might need to install additional packages later on, but the ones listed above provide a solid starting point.

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 the GeoJsonLayer and ScatterplotLayer will be used.

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Mapping</title>
</head>
<body>
  <div id="app">
    <div id="container">
      <div id="map"></div>
    </div>
    <script type="module" src="/src/main.js"></script>
</body>
</html>
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%;
}

The next step is to add the JavaScript code to initialize a basemap, in this case provided by CARTO and served by MapLibre.

Among the parameters that are used to visualise the basemap we have the style, the center of the map, as an array of longitude and latitude and the zoom of the map itself.

We are going to use the file main.js that has been created by Vitejs. Delete all the content except the first line import './style.css' and save the file. You'll notice that the page in the browser, if the live server is running, will be completely blank. This is because Vitejs provides a function of hot reload that shows immediately the content of the code we are writing.

import './style.css'
import { BASEMAP } from '@deck.gl/carto';
import { Map, setWorkerUrl} from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';

// Configure the worker URL
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url';
setWorkerUrl(workerUrl);

//Initialise Maplibre BaseMap
const map = new Map({
  container: 'map',
  style: BASEMAP.POSITRON,
  interactive: true,
  center:[-0.12262486445294093,51.50756471490389],
  zoom: 12
})

maplibre with carto positron basemap

Let's add some data. DeckGL uses a Layer Approach to visualise data. The layers are passed in form of array to the parameters of the DeckGL object:

In order to overlay a DeckGL layer over the MapLibre basemap we need to use the MapLibreOverlay module. In this example we are going to use a ScatterplotLayer to place a circle at the centre of the map.
While the layers can be added directly to the layers array, it is more effective and easier to manage, especially as the number of layers grows—by, to first declaring the layer and then adding it to the array.

import './style.css'
import { BASEMAP } from '@deck.gl/carto';
import { Map, setWorkerUrl } from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';

// Configure the worker URL
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url';
setWorkerUrl(workerUrl);


import { MapLibreOverlay } from '@deck.gl/maplibre';
import { ScatterplotLayer } from '@deck.gl/layers';

//Initialise Maplibre BaseMap
const map = new Map({
  container: 'map',
  style: BASEMAP.POSITRON,
  interactive: true,
  center:[-0.12262486445294093,51.50756471490389],
  zoom: 12
})

//before loading any data, wait until the map is fully loaded
await map.once('load');

//Declaring the layers
let layerCircle = new ScatterplotLayer({
      id: 'deckgl-circle',
      data: [
        {position: [-0.12262486445294093,51.50756471490389]},
      ],
      getPosition: d => d.position,
      getFillColor: [242, 133, 0, 200],
      getRadius: 1000,
    })

//Overlay DeckGL layers on MapLibre: Overlaid - Deckgl on a separate element | Interleaved - Same WebGL context
const deckOverlay = new MapLibreOverlay({
  interleaved: true,
  layers: [
    layerCircle
  ]
});

map.addControl(deckOverlay);

maplibre with carto positron basemap

Another common way to load data into our visualisation is to use GeoJson datasets and the GeoJsonLayer, as it is part of the Core layers we can added it to the ScatterplotLayer import.

import { ScatterplotLayer,GeoJsonLayer } from '@deck.gl/layers';

Ensure that the Benches GeoJSON file is placed in the website public folder.

Then, add a new GeoJSON layer that points to the file just after the existing ScatterplotLayer.

//Declaring the Bench/sensors Layer, just after the Scatterplot Layer
let sensorBench= new GeoJsonLayer({
  id:'sensor-bench',
  data:'./GEOJSON_DATASET.geojson',
  filled: true,
  pointRadiusScale: 1,
        getPointRadius: 5,
        getFillColor: [200, 0, 80, 190],
})

and add it to the layer array

//Overlay DeckGL layers on MapLibre: Overlaid - Deckgl on a separate element | Interleaved - Same WebGL context
const deckOverlay = new MapLibreOverlay({
  interleaved: true,
  layers: [
    layerCircle,
    sensorBench
  ]
});

maplibre with carto positron basemap

Deck.gl does not include a built‑in clustering function to clearly visualise points that are very close together. We can address this either with some additional JavaScript (e.g. MapLibre Cluster) or an external library (e.g. Supercluster), or by switching to a different layer that varies symbol size with zoom and offers a more legible representation, in this case the IconLayer.

The IconLayer is part of the core Deck.gl layers, so we just need to add it to the import statement and configure it to scale with the map zoom.

import { ScatterplotLayer,GeoJsonLayer,IconLayer } from '@deck.gl/layers';

We can keep the same layer name by commenting out the existing one and then adding the new IconLayer

//Declaring the Bench/sensors Layer
// let sensorBench= new GeoJsonLayer({
//   id:'sensor-bench',
//   data:'./GEOJSON_DATASET.geojson',
//   filled: true,
//   pointRadiusScale: map.getZoom(),
//   getPointRadius: 2,
//     getFillColor: [200, 0, 80, 190],
//     updateTriggers: { pointRadiusScale: map.getZoom() }
// })

let sensorBench= new IconLayer({
  id:'sensor-bench-icons',
  data:'./GEOJSON_DATASET.geojson',
  dataTransform:d=>d.features.filter(f=>f.geometry !=null), //just be sure that every feature has a geometry
  getPosition:f=>f.geometry.coordinates,
  getIcon: d => 'marker', //need to match the key in `iconMapping`
  getSize: 20,
  iconAtlas: 'https://upload.wikimedia.org/wikipedia/commons/e/ed/Map_pin_icon.svg',
  iconMapping: {marker: {x: 0, y: 0, width: 94, height: 128, anchorY: 128, mask:false}},
  })

icons benches

Icons can be loaded remotely, as shown in the example, or stored locally using SVG files or other image formats for the iconAtlas property. The iconMapping must be configured to match the icons being used, specifically defining the icon size and anchor point.

At this stage, the map is only being used to display icons at their geographic locations. While the additional data associated with each icon can be inspected during development using console.log, this approach is mainly useful for debugging and not for end users. To make the information accessible within the application itself, a common and user-friendly solution is to use popups. Popups allow the associated data to be displayed directly on the map when users interact with an icon.

let sensorBench= new IconLayer({
  id:'sensor-bench-icons',

pickable:true,

  data:'./GEOJSON_DATASET.geojson',
  dataTransform:d=>d.features.filter(f=>f.geometry !=null), //just be sure that every feature has a geometry
  getPosition:f=>f.geometry.coordinates,
  getIcon: d => 'marker', //need to match the key in `iconMapping`
  getSize: 25,
  iconAtlas: 'https://upload.wikimedia.org/wikipedia/commons/e/ed/Map_pin_icon.svg',
  iconMapping: {marker: {x: 0, y: 0, width: 94, height: 128, anchorY: 128, mask:false}},

onHover: info => {
      const {coordinate, object} = info;
      if(object){map.getCanvas().style.cursor = 'pointer'; 
        console.log(object)}
      else{map.getCanvas().style.cursor = 'grab';}

    },
  })

We also need to add a line at the very bottom of our code to fix a visualisation issue with DeckGL

deckOverlay.setProps({getCursor: () => map.getCanvas().style.cursor});

icons benches

import { Map, setWorkerUrl, Popup } from 'maplibre-gl';

The popup will be trigged by the onHover function. To make this works, we need to create a variable popup just before the const deckOverlay

const popup = new Popup({closeOnClick: false, closeOnMove:true})

//Overlay DeckGL layers on MapLibre: Overlaid - Deckgl on a separate element | Interleaved - Same WebGL context
const deckOverlay = new MapLibreOverlay({
onHover: info => {
  const { coordinate, object } = info;

  if (!object) {
    popup.remove();
    map.getCanvas().style.cursor = 'grab';
    return;
  }

  map.getCanvas().style.cursor = 'pointer';
  console.log(object);
      
    const description = `<div> 
      <p>${object.id?object.id:""}<br></p>
      <p>${object.properties?("Properties: "+JSON.stringify(object.properties)):""}<br></p>
      </div>`;   
//MapLibre Popup
    popup
    .setLngLat(coordinate)
    .setHTML(description)
    .addTo(map);
    },

It is possible to customise the appearance of the popup by adding to the style.css the following style

/* Deck.gl layer is added as an overlay, popup needs to be displayed over it */
.maplibregl-popup {
  z-index: 2;
  max-width: 400px;
  min-width: 300px;
  font-size:16px;
  font-family: 'Courier New', Courier, monospace;
}

/* Actual content box inside the popup */
.maplibregl-popup-content {
  box-sizing: border-box;      
  padding: 10px 12px;
  white-space: normal;         
  overflow-wrap: anywhere;     
  word-break: break-word;      
  line-height: 1.4;        
}

For a more structured and readable output, we can loop through the feature's properties and dynamically generate an HTML table, creating one row for each property and its corresponding value.

onHover: info => {
  const { coordinate, object } = info;

  if (!object) {
    popup.remove();
    map.getCanvas().style.cursor = 'grab';
    return;
  }

  map.getCanvas().style.cursor = 'pointer';
  console.log(object);

  //Use object.properties if it exists; otherwise use an empty object {}.
  const data = object.properties || {};

 // Build table rows automatically
 // Create one table row for every property
let rows = '';

for (const [key, value] of Object.entries(object.properties)) {
  if (value) {
    rows += `
      <tr>
        <td><b>${key}</b></td>
        <td>${value}</td>
      </tr>
    `;
  }
}

const description = `
  <div>
    <h4>Bench</h4>
    <table>
      ${rows}
    </table>
  </div>
`;

  popup
    .setLngLat(coordinate)
    .setHTML(description)
    .addTo(map);
}

icons popup

Deck.gl can be used to effectively visualize real‑time data streams from a variety of sources, including REST APIs and MQTT brokers. In this example, we will work with simulated sensor data; however, the same approach can be extended to other real‑time urban data sources such as CCTV systems, noise meters, and environmental sensors.

To support real‑time data handling and visualization, we first need to add two additional modules to the project, mqtt.js and D3.

mqtt.js is a lightweight JavaScript client that allows web applications to connect to MQTT brokers. It is commonly used to subscribe to real‑time data streams produced by sensors and IoT devices, making it suitable for receiving live updates such as occupancy states, environmental readings, or system heartbeats directly in the browser.

D3.js is a data‑driven visualization library that provides low‑level control over how data is mapped to visual attributes. In this workflow, D3 is used to process and scale incoming sensor values—such as normalising ranges or mapping values to colour and size—which are then passed to Deck.gl layers for geographic visualization.

npm install d3 mqtt
import mqtt from 'mqtt';
// Use your broker's WS or WSS endpoint:
const WS_PROTOCOL = location.protocol === 'https:' ? 'wss' : 'ws';
const BROKER_URL = `${WS_PROTOCOL}://your-broker-host:port`;

const OPTIONS = {
  keepalive: 60,
  reconnectPeriod: 2000,
  connectTimeout: 30_000,
  clean: true
};

const client = mqtt.connect(BROKER_URL, OPTIONS);

client.on('connect', () => console.log('[MQTT] connected'));
client.on('reconnect', () => console.log('[MQTT] reconnecting...'));
client.on('error', err => console.error('[MQTT] error', err));

/**
 * Subscribe to all topics under a prefix.
 * Calls onMessage(Id, payloadString) whenever a message arrives.
 */
export function subscribe(prefix, onMessage) {
  const wildcard = `${prefix}/+`;
  client.subscribe(wildcard, err => {
    if (err) console.error('[MQTT] subscribe error', err);
    else console.log(`[MQTT] subscribed to ${wildcard}`);
  });

  client.on('message', (topic, payload) => {
    if (!topic.startsWith(prefix + '/')) return;
    const Id = topic.split('/').pop();
    onMessage(Id, payload.toString());
  });
}

export default client;

While in the main.js we are going to import the module just created using

import { subscribe } from './mqttClient';

This will be sufficient to enable the connection; whether it is successful or not will be shown in the browser console.

To change the colours of the icons according to the values received by the sensors, we use D3 as a colour‑mapping utility. In this case, a scaleThreshold is used to divide the data into discrete ranges and assign a specific colour to each range. Lower values are mapped to red, indicating low occupation, while higher values progressively shift towards green.

import * as d3 from 'd3';
const colorScaleFunction = d3.scaleThreshold()
  .domain([0, 1, 2, 3, 4])
  .range([ //RGB format
    [255, 13, 13,220], //red
    [255, 78, 17,220],
    [255, 142, 21,220],
    [172, 179, 52,220],
    [105, 179, 76,220], //green
]);

Next, we update how the layer is created. In Deck.gl, colours are calculated once and then cached to improve performance. Because of this, updating the sensor values alone will not automatically update the icon colours on the map.
To display the new occupancy values, we recreate (or clone) the layer, forcing Deck.gl to recalculate the colours. We also update the logic so that both the icon colour and the popup content use the occupancy value received from the MQTT broker.

const benchState = new globalThis.Map(); //store the data of each sensor when received by the MQTT broker using the build in Map function
let benchStateVersion=0 //a simple counter used to trigger the update of the colours
function createBenchLayer() {
// Create and return a new IconLayer.
// Recreating the layer allows Deck.gl to refresh icon colours when sensor values change.
  
  return new IconLayer({
    id: 'sensor-bench-icons',
    pickable: true,
    data:'./GEOJSON_DATA.geojson',
    dataTransform: d => d.features.filter(f => f.geometry),// Keep only features that contain valid geometry data.
    getPosition: f => f.geometry.coordinates,
    getIcon: () => 'marker', //need to match the key in `iconMapping`
    getSize: 20,
      // Set the icon colour according to the latest occupancy value
      // received from the MQTT broker.
      //added Line for the colours
      getColor: (f) => colorScaleFunction(benchState.get(f.properties.sensor_name) ?? 0),
      //added Line for the colours
    iconAtlas: 'https://upload.wikimedia.org/wikipedia/commons/e/ed/Map_pin_icon.svg',
    iconMapping: { marker: { x: 0, y: 0, width: 94, height: 128, anchorY: 128, mask: true } }, //mask is set to true
      // Force Deck.gl to recalculate icon colours whenever
      // benchStateVersion changes.
      //added line to trigger the attribute colour
      updateTriggers: {getColor: benchStateVersion},
          onHover: info => {
            const { coordinate, object } = info;
            if (!object) {
                popup.remove();
                map.getCanvas().style.cursor = 'grab';
                return;
              }
            
            map.getCanvas().style.cursor = 'pointer';

            //Use object.properties if it exists; otherwise use an empty object {}.
            const data = object.properties || {};

            // Build table rows automatically
            // Create one table row for every property
            let rows = '';
            let occupancyData='';

            for (const [key, value] of Object.entries(object.properties)) {
                if (value) {
                    rows += `
                    <tr>
                    <td><b>${key}</b></td>
                    <td>${value}</td>
                    </tr>
                    `;
                }

                if(key==="sensor_name"){
                    occupancyData = benchState.get(value) ?? 0; //if the value is found get it, otherwise set 0
                }

                const description = `
                  <div>
                  <h4>Bench</h4>
                  <table>
                  ${rows}

                  <tr>
                  <td><b>Occupancy:</b></td>
                  <td>${occupancyData}</td>
                  </tr>

                  </table>
                  </div>
                  `;

                popup
                .setLngLat(coordinate)
                .setHTML(description)
                .addTo(map);
            }
          } //end of the onHover

}); //end of the IconLayer

}// end of the createBenchLayer

// Create initial layer
let sensorBench = createBenchLayer(); //trigger the function
// Subscribe to MQTT wildcard for all benches
const TOPIC_PREFIX = 'TOPIC/TO/SUBSCRIBE';

subscribe(TOPIC_PREFIX, (Id, msg) => {
  const parsed=JSON.parse(msg);

// Update sensors state and redraw
  benchState.set(Id, parsed.occupied);
  
  benchStateVersion++

// Replace only the bench layer with a newly created version and keep all
// other Deck.gl layers unchanged. Recreating the layer forces Deck.gl to
// recalculate the colours based on the latest sensor values.
const updatedLayers = deckOverlay._deck.props.layers.map(layer => layer.id === 'sensor-bench-icons' ? createBenchLayer() : layer);
deckOverlay.setProps({layers: updatedLayers});
});

icons popup

Another way to work with real-time or regularly updated data is through REST APIs. Unlike MQTT, where data is pushed to subscribers, APIs are typically queried at regular intervals to retrieve the latest information. In this example, we use a bike-sharing dataset, but the same approach can be applied to many other sources, including transport systems, environmental monitoring networks, occupancy sensors, and others.

CityBikes is a free service that collect bike sharing real time data of more than 400 cities every 5 minutes.
The data is accessible using the CityBikes API.

In London the TFL bike sharing is sponsored by Santander.

 "stations": [
  {
      "id": "001420f03e8b4f08f1bcc9bdc0260651",
      "name": "300073 - Prince of Wales Drive, Battersea Park",
      "latitude": 51.47515398,
      "longitude": -0.159169801,
      "timestamp": "2024-09-02T12:22:10.462767Z",
      "free_bikes": 1,
      "empty_slots": 17,
      "extra": {
          "uid": 756,
          "name": "Prince of Wales Drive, Battersea Park",
          "terminalName": "300073",
          "locked": false,
          "installed": true,
          "temporary": false,
          "installDate": "1390223040000",
          "removalDate": ""
      }
  },

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, and the values of the number of free_bikes and empty_slots.

const londonBikes= new ScatterplotLayer({
  id:'london-bikes',
  data:'https://api.citybik.es/v2/networks/santander-cycles',
  dataTransform: d => d.network.stations,
  pickable: true,
      opacity: 0.8,
      stroked: true,
      filled: true,
      radiusScale: 2,
      radiusMinPixels: 10,
      radiusMaxPixels: 150,
      lineWidthMinPixels: 1,
      getPosition: (d) => [d.longitude, d.latitude, 5],
      getRadius: (d) => d.extra.free_bikes,
      getFillColor: (d) => [255, 140, 0],
      getLineColor: (d) => [0, 0, 30],
})
const deckOverlay = new MapLibreOverlay({
  interleaved: true,
  layers: [
    //OTHER LAYERS,
    londonBikes
  ]
});

Using the D3.js function scaleThreshold it is possible to dynamically change the colours of the docks depending on the number of bikes available.

const colorBikesFunction = d3.scaleThreshold()
  .domain([0.15, 0.35, 0.45, 0.55, 0.75]) //% of free bikes
  .range([ //RGB format
    [255, 13, 13], //red
    [255, 78, 17],
    [255, 142, 21],
    [250, 183, 51],
    [172, 179, 52],
    [105, 179, 76], //green
]);
getFillColor: (d) =>colorBikesFunction((d.free_bikes/(d.free_bikes+d.empty_slots))),

Bike docks location

To visualise the actual numbers of bikes, we can use the Popup function of Maplibre. In this example the popup is styled using a CSS Grid.

.bike-popup {
  display: grid;
  grid-template-columns: 48px 1fr;
  gap: 4px;
  grid-template-areas:
    "icon title"
    "item-a item-a"
    "item-b item-b";
}

.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;
  width: 40px;
  height: 40px;
  object-fit: contain;
  align-self: start;
}

Add to the let londonBikes layer the onHover and onClick functions

let londonBikes= new ScatterplotLayer({
  id:'london-bikes',
  data:'https://api.citybik.es/v2/networks/santander-cycles',
  //dataTransform: d => d.network.stations,
    dataTransform: d => d.network.stations.filter(s =>
          s.latitude >= 51.5307 && //min Lat
          s.latitude <= 51.5519 && //max Lat
          s.longitude >= -0.0264 && //min Long
          s.longitude <= -0.0071    //max Long
    ),

  pickable: true,
    opacity: 0.8,
    stroked: true,
    filled: true,
    radiusScale: 2,
    radiusMinPixels: 10,
    radiusMaxPixels: 150,
    lineWidthMinPixels: 1,
    getPosition: (d) => [d.longitude, d.latitude, 5],
    getRadius: (d) => d.extra.free_bikes,
    getFillColor: (d) =>colorBikesFunction((d.free_bikes/(d.free_bikes+d.empty_slots))),
    getLineColor: (d) => [0, 0, 30],

    onHover: info => {
    const {coordinate, object} = info;
      if (!object) {
          popup.remove();
          map.getCanvas().style.cursor = 'grab';
          return;
        }
        map.getCanvas().style.cursor = 'pointer';
    },

    onClick: (info) => {
      console.log(info);
      const {coordinate, object} = info;
      const description = `
      <div class="bike-popup">
      <img src="https://upload.wikimedia.org/wikipedia/commons/d/d4/Cyclist_Icon_Germany_A1.svg" class='icon'/>
        <p class='title'> ${object.name}</p>
        <p class='item-a'> Free Bikes: ${object.free_bikes}</p>
        <p class='item-b'> Empty Docks: ${object.empty_slots}</p>
        </div>`;
      //MapLibre Popup
       const popup = document.getElementsByClassName('maplibregl-popup');
if ( popup.length ) {popup[0].remove();}
      new Popup({closeOnClick: false, closeOnMove:true})
          .setLngLat(coordinate)
          .setHTML(description)
          .addTo(map);
}
}) 

When the user is click on one of the bike docks a pop-up is opening with the icon of a bike, the name of the station and the number of free bikes and empty slots.

Bike docks location

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 at the end of the main.js. The syntax is similar to the setTimeout function, but instead of being executed once, the function is repeated every 20min (in this case)

const timer = setInterval(() => {
//in case other layers are used, keep them and update just the bike one
const updatedLayers= deckOverlay._deck.props.layers.map(layer => layer.id === 'london-bikes' ? londonBikes : layer);
deckOverlay.setProps({ //update the layers
       layers: 
       updatedLayers})
 console.log('Data Updated');
}, 1200000); //API called every 20 min => 1200000 ms

The final library we are going to use is ECharts. It 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.

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 onClick function.

npm install echarts
import * as echarts from 'echarts';
grid-template-areas:
    "icon title"
    "item-a item-a"
    "item-b item-b"
    "item-c item-c";
#item-c {
  grid-area: item-c;
  margin: 4px;
  height: 300px;
}
onClick: (info) => {
      console.log(info);
      const {coordinate, object} = info;
      const description = `
      <div class="bike-popup">
      <img src="https://upload.wikimedia.org/wikipedia/commons/d/d4/Cyclist_Icon_Germany_A1.svg" class='icon'/>
        <p class='title'> ${object.name}</p>
        <p class='item-a'> Free Bikes: ${object.free_bikes}</p>
        <p class='item-b'> Empty Docks: ${object.empty_slots}</p>
        <div id='item-c'></div>
        </div>`;
      //MapLibre Popup
      const popup = document.getElementsByClassName('maplibregl-popup');
if ( popup.length ) {popup[0].remove();}

        new Popup({closeOnClick: false, closeOnMove:true})
            .setLngLat(coordinate)
            .setHTML(description)
            .addTo(map);
            
        chart(object); 
}

The new div is the placeholder for the chart. In case a popup is already open, it will be remove from the page (this avoid to have the wrong item-c updated). The Popup is then created.

The function chart(object) is passing to EChart the object clicked 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

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)

  var option = {
    xAxis: {
      type: "category",
      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",
      min:0,
      max: dataset.free_bikes+dataset.empty_slots>0 ? dataset.free_bikes+dataset.empty_slots : 1,
      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']).

3 of 4

series: [
  {
    type: 'bar',
    barWidth: 25,
    data: [
      {
        value: dataset.free_bikes,
        itemStyle: {color:'rgb('+colorBikesFunction((dataset.free_bikes/(dataset.free_bikes+dataset.empty_slots)))+')'}
      },
      {
        value: dataset.empty_slots,
        itemStyle: {color:'rgb('+colorBikesFunction((dataset.empty_slots/(dataset.free_bikes+dataset.empty_slots)))+')'}
      }
    ]
  }
  ] 
}
    

Finally, we can add the series: the type of chart to use (i.e. bar) and the data to to visualise (i.e. 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 last step is to set the options on the charts

   myChartEchart.setOption(option);
  }

Bike docks location

We can now build and publish the web visualisation as an actual website. As we are using some features that are not supported by older browser (e.g. top-level-await Internet Explorer) we need to tell ViteJS to ignore certain errors.

In the main folder of the project, create a new file named vite.config.js

export default ({
  esbuild: {
    supported: {
      'top-level-await': true
    },
  },
  base: '',
  // plugins: [
  //   basicSsl() //not needed in production
  // ]  
});

Stop the local server if it is running using CTRL+C or Option+C.

We can now run the command npm run build.

ViteJS will create a new dist folder that contains the build website. To test it locally, we can use the command npm run preview.

DeckGL provide support to the Google 3DTiles. Google 3DTiles provides high resolution models using the highly efficient opensource 3DTiles format.

There are two main approaches to access Google 3DTiles:

Both solutions access the same endpoint and same free tier, however, Google Map Tiles API require a billing method, while the CESIUM approach not.

At the time of writing, the CesiumIONLoader used to load Cesium dataset in DeckGL doesn't support directly the native request to the Google Tiles, so a workaround is needed to load them.

///create request
let CESIUM_EndPoint="https://api.cesium.com/v1/assets/2275207/endpoint" //Google 3D Tiles endpoint
let CESIUM_AccessToken="CESIUM Access Token from the ION Dashboard"

let Cesium_GTiles

async function fetchRemoteJSON(inputData) {
  const response = await fetch(inputData);
  const dataJson = await response.json();
  return dataJson;
}

await fetchRemoteJSON(CESIUM_EndPoint+"?access_token="+CESIUM_AccessToken)
.then(dataJson => {Cesium_GTiles=dataJson; console.log(Cesium_GTiles.options.url);
});
import {Tile3DLayer} from '@deck.gl/geo-layers';
const tilesGM= new Tile3DLayer({
  id: 'tiles',
  pickable: true,
  data: Cesium_GTiles.options.url,//this is the url returned by the fetch request
})
const deckOverlay = new MapLibreOverlay({ 
  interleaved: true, 
    layers: [
    //[..other layers..]
    tilesGM
  ]
})

It is possible to overlay most of the Deck Layers, such as the IconLayer, on the 3DTiles by using the experimental TerrainExtension module.

const tilesGM= new Tile3DLayer({
  id: 'tiles',
  pickable: true,
  data: Cesium_GTiles.options.url,
  operation: 'terrain+draw' //removing the draw will not rendering the tiles
})
let layerCircle = new ScatterplotLayer({
      id: 'deckgl-circle',
      data: [
        {position: [-0.12262486445294093,51.50756471490389]},
      ],
      getPosition: d => d.position,
      getFillColor: [242, 133, 0, 200],
      getRadius: 1000,
      extensions: [new TerrainExtension()]
    })

Real Time Data on Google Tiles