How to create a custom view - Choropleth map example using D3

Modified on Mon, 23 Apr 2018 at 11:15 AM

Introduction

This article is a step by step tutorial on how to create custom views in Omniscope. The view that we will be creating is a choropleth map and we will be using a visualisation library called D3.D3 is a great library for manipulating SVG elements and make them data-driven. This tutorial will also cover how to use the library, but if you want to know more, there are good tutorials on the internet such as https://github.com/d3/d3/wiki/Tutorials and http://alignedleft.com/tutorials/d3/. Once you know the basics, it's very easy to create new views. You can find the full code at the bottom of this tutorial.




Adding a new custom view

To add a new custom view, open the report block and click on the button to add a  view. You will find at the bottom of the views panel an option to open the View Designer. If you click on that, it will show you a popup with a list of already created custom views. Create a new one, give it a name and you are ready to go. From now on you can select this view in reports.



Editing a custom view

If you now click on the edit view button on the right side of the View Designer, you will see that the new custom view consists of two files: manifest.json and index.html. The first one defines the view: name, options, configuration, etc. and the second will have all the code to render the view. It makes it easier if you keep one tab with the designer and another with the report, that way you can see how the view updates while changing the code.If you have a look at the index.html, you will find the following code:


// Naively redraw on any of these events
// (load = first init, update = external settings/state change, resize = browser window resized)
omniscope.view.on(["load", "update", "resize"], function() {
  
   // Retrieve the auto-query results, a 2d array ([row][column]):
   var data = omniscope.view.context().result.data.records;
   document.getElementById("main").textContent = JSON.stringify(data, null, 2);
});


This is the code that draws the view every time it's loaded, updated or the browser window is resized. For now, we should only be interested in this part of index.html, which is where we will add the code for our choropleth.



Basemap

We will start by adding a simple basemap in black and white. To do that, we need to find the data that describes the regions’ polygons and their names, so we can later bind it with external data containing regions' name and some other values. In this case, we will be using the regions of France, but any file that includes names and polygons could be used. The basemap data should look like this:


[
 {
   "name": "Ain",
   "path": "m542,347l-5.7,6.7l-11.2-15.2l-2.8,0.7l-3,5.1 l-6-2l-6.4,0.5l-3.7-5.7l-2.8,0.5l-3.1-9.2l1.5-8l5.9-20.9l5.9,1.5l5.4-1.3l4.8,3.3l4.3,7.7h2.9l0.1,3l2.9-0.1l4-4.4l3.4,1.6 l0.4,2.8l3.8-0.2l5.5-3.2l5.3-7.2l4.5,2.7l-1.8,4.7l0.3,2.5l-4.4,1.5l-1.9,2l0.2,2.8l0.46,0.19l-4.36,4.71h-2.9l0.8,9.3L542,347z"
 },
 {
   "name": "Aisne",
   "path": "m450.3,82.6l16.7,4.6l2.91,0.94L470.6,94l-1.3,3.5l1.3,3.1l-5,7.2 l-2.7,0.3l0.3,14.3l-1,2.8l-5.3-1.8l-8,4l-1.2,2.6l3.2,8l-5.5,2.3l1.6,2.4l-0.8,2.7l2.5,1.3l-7.7,10.2l-9.3-6l-3.9-4.2l0.7-2.8 l-1.8-2.5l-2.6-0.7l2.1-1.7l-0.5-2.8l-2.9-1.1l-2.4,1.5l-0.7-2.9l3,0.2l-2.9-4.5l2.6-1.7l2.4-5.7l2.6-1.1l-2.2-1.8l0.8-4.5 l-0.4-10.2l-2.3-7l3.9-8.1l0.4-3.8l12.6-0.6l2.6-2.2l2.3,1.7L450.3,82.6z"
 },
 [...]
]


To upload the data, copy the basemap JSON file into the custom view by dragging it to the files list on the left of the designer. Do the same for the D3 library, which you can download here.Before using the library we have to reference it in the head of the index.html:


<!-- Put SCRIPT tags to load 3rd party libraries here -->
<script src="d3.v4.min.js"></script>
</head>


And add an empty svg block inside the main element like this:


<main id="main"><svg width="100%" height="100%"></svg></main><!-- This element is where the view will render -->


Now we can load the data and draw the polygons inside this element:


omniscope.view.on(["load", "update", "resize"], function() {

  d3.json("regions_fr.json", function(error, regions) {
    if (error) throw error;
    var regions = d3.select("svg")
            .selectAll(".region")
            .data(regions);
       
    var regionsEnter = regions
            .enter()
            .append("path")
            .attr("class", "region");
       
    d3.selectAll(".region")
            .attr("d", function(d) {return d.path;});
       
     regions.exit().remove();
  });
     
});


On line 3, we load the json file, the method has two parameters: the path to the json file and a callback function that will be called once the data is read.


d3.json("regions_fr.json", function(error, regions) {


On line 4, we throw an error if there was any while reading the JSON file. If that's the case, we stop drawing the view.


if (error) throw error;


The following lines describe the typical code when adding SVG elements and binding them with data. This works by adding an element for each object in the data and styling them based on the objects' values:


var regions = d3.select("svg")  // Select the svg element that is used as a container of our view
    .selectAll(".region") // Select all regions, which will be the elements associated with the data but they haven't been created yet
    .data(regions); // Bind the data coming from the JSON file
       
var regionsEnter = regions
    .enter() // Create a new placeholder for each new object in the binding data
    .append("path") // For each created placeholders add a new SVG element
    .attr("class", "region"); // Add a class to each element, so we can easily select all regions in the future
       
d3.selectAll(".region") // Select all regions
    .attr("d", function(d) {return d.path;}); // Add the path defined in the JSON file to each element
       
regions.exit().remove(); // Remove from chart any old data


Once we have applied all these changes, the custom view should look like this in the report:






Making it responsive

This is starting to look good and we can see the shape of France, but still you can notice that it isn't responsive when we change the size of the browser window. To fix that we will need to scale the polygons based on the size of the SVG container every time the view changes.


var svg = d3.select("svg");
var width = +parseInt(svg.style("width"), 10);
var height = +parseInt(svg.style("height"), 10);
var maxSize = Math.min(width, height);
var scale = maxSize/ORIGINAL_MAP_SIZE;  // ORIGINAL_MAP_SIZE is the maximum size (width or height) of the original map.


And now we can add another line when drawing the polygons that will transform the regions by the calculated scale:


d3.selectAll(".region")
  .attr("transform", "scale("+scale+")")
  .attr("d", function(d) {return d.path;});


Finally, we can also add some margin so the view is centered:


var widthMargin = width - (ORIGINAL_MAP_WIDTH * scale);
var heightMargin = height - (ORIGINAL_MAP_HEIGHT * scale);
svg.attr("transform", "translate("+widthMargin/2+", "+heightMargin/2+")");



Making it data-driven

By default, the custom view will already include the options to select a measure and split fields. We will be using the measure to change the opacity of the regions and split fields to group rows by region.Right before loading the JSON file, we can retrieve the queried records and field mappings:


var records = omniscope.view.context().result.data.records;
var mappings = omniscope.view.context().result.mappings;


From these two, we can now create an array of objects that will contain the name of the region and any value defined in the view options:


var data = [];
records.forEach(function(record) {
  data.push({
    name: (mappings.split !== undefined) ? record[mappings.split] : null,
    opacity: (mappings.measures !== undefined) ? record[mappings.measures] : null
  });
});


Also, we need to have the max and min values of measures, so we can normalise it and have an opacity value between 0 and 1:


var opacityMinMax = getMinMax(data, "measures");


And the function that calculates the min and max of a mapping:


function getMinMax(data, field) {
  var min = null, max = null;
  data.forEach(function(record) {
    if (min == null || min > record[field]) min = record[field];
    if (max == null || max < record[field]) max = record[field];
  });
  return {min: min, max: max};
}


Once we have all this, and we have also loaded the JSON file, we can iterate through all regions to find if we have any of them in our queried data and apply the properties in that case.


d3.json("regions_fr.json", function(error, regions) {
  if (error) throw error;
  regions.forEach(function(region) {
    var opacity = 0;
    for (var i = 0; i < data.length; i++) {
      var record = data[i];
      if (record.name != null &amp;&amp; record.name.toUpperCase() === region.name.toUpperCase()) {
        if (record.opacity !== null &amp;&amp; record.opacity !== undefined) opacity = record.opacity;
        break; 
      }
    }
    region.opacity = opacity/opacityMinMax.max;
  });
[...]


Regions will now be a list of region objects including names, paths and opacity values. So, as we have done before, we can now add another attribute to the region SVG elements that will define the opacity which will be data-driven. Additionally, we  add a couple of fixed values to change the fill and stroke to make it more colourful.


d3.selectAll(".region")
  .attr("transform", "scale("+scale+")")
  .attr("d", function(d) {return d.path;});
  .attr("stroke", "#CBCDCC")
  .attr("fill", "#00BFB6")
  .attr("fill-opacity", function(d) {return d.opacity;});


If we have added all this code correctly, we should see the following:


As you can see, all regions are now in white, which means that we don't have any data yet coming in. The last step would be getting some data that includes the name of the region and a value and choose these fields from the view options, measures and split respectively.The end result should look something like this:


And the code:


<!doctype html>
<html>
<head>
    <meta charset="utf-8"/>

    <style>
        html, body, main {

            /* Ensure the body fills the view, so clicks are handled everywhere: */
            width: 100%;
            height: 100%;
        }
        main {
            font-family: monospace;
            font-size: 20px;
            white-space: pre;
        }
    </style>

    <!-- Put SCRIPT tags to load 3rd party libraries here -->
<script src="d3.v4.min.js"></script>

</head>

<body>

  <main id="main"><svg width="100%" height="100%"></svg></main><!-- This element is where the view will render -->

<script src="/_global_/customview/v1/omniscope.js"></script><!-- Add the Omniscope custom view API -->

<script>
    var ORIGINAL_MAP_WIDTH = 702;
    var ORIGINAL_MAP_HEIGHT = 610;
 
    if (!omniscope || !omniscope.view) throw new Error("Omniscope chart API is not loaded");
    omniscope.view.on("load", function() {
        window.onerror = function(msg) {
            omniscope.view.error(msg);
        }
    });

    // Ensure clicks on unoccupied space clear the selection in Omniscope, and close menus:
    document.body.addEventListener("click", function() {
        omniscope.view.whitespaceClick();
    });

    // Naively redraw on any of these events
    // (load = first init, update = external settings/state change, resize = browser window resized)
    omniscope.view.on(["load", "update", "resize"], function() {
     
      var svg = d3.select("svg");
      var width = +parseInt(svg.style("width"), 10);
      var height = +parseInt(svg.style("height"), 10);
      var maxSize = Math.min(width, height);
      // Using ORIGINAL_MAP_WIDTH as it's greater than ORIGINAL_MAP_HEIGHT
  var scale = maxSize/ORIGINAL_MAP_WIDTH;
     
      // Position map in the centre of the view
      var widthMargin = width - (ORIGINAL_MAP_WIDTH * scale);
      var heightMargin = height - (ORIGINAL_MAP_HEIGHT * scale);
      svg.attr("transform", "translate("+widthMargin/2+", "+heightMargin/2+")");
     
      // Retrieve the auto-query results, a 2d array ([row][column]):
  var records = omniscope.view.context().result.data.records;
  var mappings = omniscope.view.context().result.mappings;
           
      // Map records to field options
      var data = [];
      records.forEach(function(record) {
        data.push({
          name: (mappings.split !== undefined) ? record[mappings.split] : null,
          opacity: (mappings.measures !== undefined) ? record[mappings.measures] : null
        });
      });
           
      var opacityMinMax = getMinMax(data, "opacity");
     
      d3.json("regions_fr.json", function(error, regions) {
          if (error) throw error;
       
          // Add value to regions
          regions.forEach(function(region) {
            var opacity = 0;
            for (var i = 0; i < data.length; i++) {
              var record = data[i];
              if (record.name != null &amp;&amp; record.name.toUpperCase() === region.name.toUpperCase()) {
                if (record.opacity !== null &amp;&amp; record.opacity !== undefined) opacity = record.opacity;
                break; 
              }
            }
            region.opacity = opacity/opacityMinMax.max;
          });
               
          var regions = d3.select("svg")
           .selectAll(".region")
            .data(regions);
       
          var regionsEnter = regions
           .enter()
           .append("path")
            .attr("class", "region");
       
          d3.selectAll(".region")
            .attr("transform", "scale("+scale+")")
            .attr("d", function(d) {return d.path;})
            .attr("stroke", "#CBCDCC")
            .attr("fill", "#00BFB6")
            .attr("fill-opacity", function(d) {return d.opacity;});
       
          regions.exit().remove();
      });
     
    });
 
    function getMinMax(data, field) {
      var min = null, max = null;
      data.forEach(function(record) {
        if (min == null || min > record[field]) min = record[field];
        if (max == null || max < record[field]) max = record[field];
      });
      return {min: min, max: max};
    }

</script>

</body>
</html>


Was this article helpful?

That’s Great!

Thank you for your feedback

Sorry! We couldn't be helpful

Thank you for your feedback

Let us know how can we improve this article!

Select atleast one of the reasons
CAPTCHA verification is required.

Feedback sent

We appreciate your effort and will try to fix the article