Learn how to create an Interactive Data Visualisation on a Map using Chart.js, OpenLayers and Next.js.

Shreyas IngaleShreyas Ingale
7 min read

In recent past, I found myself confronted with a compelling challenge: how to seamlessly integrate data visualization with interactive maps. It was a puzzle I hadn't encountered before, but one that intrigued me deeply. As I delved into the intricacies of blending these two elements, I realized the immense potential it held for creating engaging user experiences.

The task wasn't just about writing code; it was about crafting an environment where users could explore data in a spatial context. It required me to rethink how I approached both data representation and map interaction. It led me to explore the combination of Chart.js' data visualizations with OpenLayers' maps—a journey filled with discovery and learning. Lets learn how to do this with an example case of show forest cover of each of India's states and UTs with the help of data available on RBI's website.

To start, we need to set up our development environment. In our toolkit, we'll be leveraging Next.js for building our web application, OpenLayers for creating interactive maps, and Chart.js for dynamic data visualization.

1. Setting up Next.js:

First, ensure you have Node.js installed on your system. Then, follow these steps to set up a new Next.js project:

npx create-next-app mapvisualisation
cd mapvisualisation

This will create a new Next.js project.

2. Integrating OpenLayers:

Install OpenLayers using npm:

npm install ol

Then, you can use OpenLayers to create a map component. Here we will create the components in a components folder inside the src folder, components/MapComponent.js:

'use client';
import { useEffect } from "react";
import "ol/ol.css";
import Map from "ol/Map.js";
import TileLayer from "ol/layer/Tile.js";
import View from "ol/View.js";
import XYZ from "ol/source/XYZ.js";

const MapComponent = () => {

  useEffect(() => { 

    // OpenLayers map with a maptiler streets tileLayer
    const mapInstance = new Map({
      target: "map",
      layers: [
        new TileLayer({
          source: new XYZ({
            attributions:
              '<a href="https://www.maptiler.com/copyright/" target="_blank">&copy; MapTiler</a> ' +
              '<a href="https://www.openstreetmap.org/copyright" target="_blank">&copy; OpenStreetMap contributors</a>',
            url:
              "https://api.maptiler.com/maps/streets/{z}/{x}/{y}.png?key=" +
              process.env.NEXT_PUBLIC_MAPTILER_API_KEY, // get your own api key at maptiler.com
            tileSize: 512,          
          }),
        }),        
      ],
      view: new View({
        center: [79.035645, 23], // Longitude, Latitude
        zoom: 4.8,
        projection: "EPSG:4326", //WGS84 projection (because it's based on WGS84's ellipsoid)
      }),
    });

    return () => {
      // on component unmount remove the map to avoid unexpected results
      mapInstance.setTarget(null);
    };
  }, []);

  return (
    <div className="absolute inset-0">
      <div id="map" className="map"></div>      
    </div>
  );
};

export default MapComponent;

This sets up a basic OpenLayers map in your Next.js project. Here we have used maptiler-streets view as the baselayer. Also don't forget to get you own API key at maptilers.com and append it to the url.
Now before implementing chart.js, we will have to add a vectorLayer on map which will show a set of points onclick of which will open a popup which will contain a linechart showing forest cover of all the states and UT's of India over last 5-10 years.

...
import { GeoJSON } from "ol/format.js";
import VectorSource from "ol/source/Vector";
import Style from "ol/style/Style";
import Circle from "ol/style/Circle";
import Fill from "ol/style/Fill";
import Stroke from "ol/style/Stroke";
import pointFeatures from "@/util/features.json";

const MapComponent = () => {

  useEffect(() => {
    // on component mount create a OpenLayers map with two layers

    // set features to be displayed in the vectorLayer
    const features = new GeoJSON().readFeatures(pointFeatures);
    features.forEach((feature) => {
      feature.set("cursor", "pointer");
    });

    // OpenLayers map with a maptiler streets tileLayer and a vetorLayer to show mark each state and UT of Bharat
    const mapInstance = new Map({
      ...
        new VectorLayer({
          source: new VectorSource({
            features,
          }),
          style: new Style({
            image: new Circle({
              radius: 6,
              fill: new Fill({
                color: "orange",
              }),
              stroke: new Stroke({
                color: "black",
                width: 2,
              }),
            }),
          }),
        }),
      ],
      ...

Here we have used a pointFeaturesgeoJSON file (available in the source code) to mark different states and UTs in India with a point marker. Now lets implement the popup from an overlay in OpenLayers . To do this we will refer the popup example on Openlayers site, plus we will add a state to maintain the current selected location. Here's the final MapComponent:

'use client';
import { useEffect, useRef, useState } from "react";
import "ol/ol.css";
import Map from "ol/Map.js";
import Overlay from "ol/Overlay.js";
import TileLayer from "ol/layer/Tile.js";
import VectorLayer from "ol/layer/Vector.js";
import View from "ol/View.js";
import XYZ from "ol/source/XYZ.js";
import { GeoJSON } from "ol/format.js";
import VectorSource from "ol/source/Vector";
import Style from "ol/style/Style";
import Circle from "ol/style/Circle";
import Fill from "ol/style/Fill";
import Stroke from "ol/style/Stroke";
import pointFeatures from "@/util/features.json";

const MapComponent = () => {
  // set ref on different popup elements to access them
  const popupContainerRef = useRef(null);
  const popupCloserRef = useRef(null);
  const popupContentRef = useRef(null);
  // using state variable to manage the data location based on feature click
  const [location, setLocation] = useState(null)

  useEffect(() => {
    // on component mount create a OpenLayers map with two layers

    // set features to be displayed in the vectorLayer
    const features = new GeoJSON().readFeatures(pointFeatures);
    features.forEach((feature) => {
      feature.set("cursor", "pointer");
    });

    // OpenLayers map with a maptiler streets tileLayer and a vetorLayer to show mark each state and UT of Bharat
    const mapInstance = new Map({
      target: "map",
      layers: [
        new TileLayer({
          source: new XYZ({
            attributions:
              '<a href="https://www.maptiler.com/copyright/" target="_blank">&copy; MapTiler</a> ' +
              '<a href="https://www.openstreetmap.org/copyright" target="_blank">&copy; OpenStreetMap contributors</a>',
            url:
              "https://api.maptiler.com/maps/streets/{z}/{x}/{y}.png?key=" +
              process.env.NEXT_PUBLIC_MAPTILER_API_KEY, // get your own api key at maptiler.com
            tileSize: 512,          
          }),
        }),
        new VectorLayer({
          source: new VectorSource({
            features,
          }),
          style: new Style({
            image: new Circle({
              radius: 6,
              fill: new Fill({
                color: "orange",
              }),
              stroke: new Stroke({
                color: "black",
                width: 2,
              }),
            }),
          }),
        }),
      ],
      view: new View({
        center: [79.035645, 23], // Longitude, Latitude
        zoom: 4.8,
        projection: "EPSG:4326", //WGS84 projection (because it's based on WGS84's ellipsoid)
      }),
    });

    // creating an overLay to show the popup on.
    const popupOverlay = new Overlay({
      element: popupContainerRef.current,
      autoPan: {
        animation: {
          duration: 250, // smooth operator
        },
      },
    });

    // create a handler to close the popup 
    popupCloserRef.current.onclick = function () {
      popupOverlay.setPosition(undefined);
      popupCloserRef.current.blur();
      return false;
    };

    // add popup to map
    mapInstance.addOverlay(popupOverlay);

    // click event listner to check if clicked location has a feature on it
    mapInstance.on("singleclick", function (evt) {
      mapInstance.forEachFeatureAtPixel(evt.pixel, function (feature) {
        if (feature) {
          // if it has a feature then save the location to state variable hence create the visualisation and show the popup.          
          const coordinate = evt.coordinate;     
          setLocation(feature.get("location"));     
          popupOverlay.setPosition(coordinate);
        }
      });
    });

    // handler to change the cursor to pointer on hover of a feature on the map.

    mapInstance.on("pointermove", function (evt) {
      let hit = false;
      mapInstance.forEachFeatureAtPixel(evt.pixel, function (feature) {
        hit = true;
      });
      mapInstance.getTargetElement().style.cursor = hit ? "pointer" : "";
    });

    return () => {
      // on component unmount remove the map to avoid unexpected results
      mapInstance.setTarget(null);
    };
  }, []);

  return (
    <div className="absolute inset-0">
      <div id="map" className="map"></div>
      <div ref={popupContainerRef} id="popup" className="ol-popup">
        <a
          ref={popupCloserRef}
          href="#"
          id="popup-closer"
          className="ol-popup-closer"
        ></a>
        <div ref={popupContentRef} id="popup-content"></div>
      </div>
    </div>
  );
};

export default MapComponent;

Now inside the div with id popup-content, we will add a component called DataVisualization which will contain our linechart made with chart.js based on clicked location which will be passed by location prop.

<div ref={popupContentRef} id="popup-content"><DataVisualization location={location}/></div>

3. Incorporating Chart.js:

Install Chart.js and it's react wrapper using npm:

npm install chart.js react-chartjs-2

Lets create this DataVisualization component based on example given at chart.js site.

import { useEffect, useState } from 'react';
import {
    Chart as ChartJS,
    CategoryScale,
    LinearScale,
    PointElement,
    LineElement,
    Title,
    Tooltip,
    Legend,
} from 'chart.js';
import { Line } from 'react-chartjs-2';
import chartData from '@/util/data.json';

function DataVisualization({ location }) {
    const [data, setData] = useState(null);
    const [options, setOptions] = useState(null);

    useEffect(() => {
        // on component mount check if a feature is clicked by checking the value of location state.
        if (!location) return;
        // if yes then setup the chart.js instance
        ChartJS.register(
            CategoryScale,
            LinearScale,
            PointElement,
            LineElement,
            Title,
            Tooltip,
            Legend
        );

        // from the dataset get the data of selected location
        let locationData = chartData.find(
            (data) => data.location === location
        ).data;

        // set x axis labels
        const labels = Object.keys(locationData);

        // set general options for the chart
        setOptions({
            responsive: true,
            plugins: {
                legend: {
                    display: false,
                },
                title: {
                    display: true,
                    text: `${location} Forest Cover`,
                },
            },
            scales: {
                y: {
                    title: {
                        display: true,
                        text: 'Sq. Kms.',
                    },
                },
                x: {
                    title: {
                        display: true,
                        text: 'Year',
                    },
                },
            },
        });

        // set the overall data
        setData({
            labels,
            datasets: [
                {
                    label: 'Forest Cover',
                    data: labels.map((year) => locationData[year]),
                    borderColor: 'rgb(53, 162, 235)',
                    backgroundColor: 'rgba(53, 162, 235, 0.5)',
                },
            ],
        });
    }, [location]);

    return <>{data && <Line options={options} data={data} />}</>;
}

export default DataVisualization;

Here we have used chartData JSON file which has the actual data. We use a useEffect with location as dependency, so after mount whenever location changes a new linechart will be created.

Now lets add MapComponent in app/page.js to complete our development.

import MapComponent from "@/components/MapComponent";
export default function Home() {
  return (
    <MapComponent /> 
  );
}

Run the server with npm run dev and visit http://localhost:3000/ in your browser to see the map.

References:

With this we have fully integrated Next.js, OpenLayers and Chart.js, hence creating an interactive map with a linechart to visualise data in spatial manner.

I hope this helps you in some way in your development journey.

🗺
Happy Mapping!
0
Subscribe to my newsletter

Read articles from Shreyas Ingale directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Shreyas Ingale
Shreyas Ingale