Skip to content

Frontend D3

nfroidevaux edited this page May 30, 2018 · 32 revisions

Install D3

Imports

  • That we can use the D3 Data Visualization Framework we needed to import D3 in the index.html and the angular-cli.json file
  • The scripts that we use in the Javascript files we downloaded that we can access them directly from the assets folder
  • All scripts and styles that are loaded in the index.html are globally accessible in the whole Frontend
      "scripts": [
        "../node_modules/jquery/dist/jquery.min.js",
        "../node_modules/popper.js/dist/umd/popper.min.js",
        "../node_modules/bootstrap/dist/js/bootstrap.min.js",
        "assets/mapInitializer.js",
        "assets/d3.min.js",
        "assets/leaflet.js",
        "assets/leafletAdd.js"
      ],
  <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.5/d3.min.js"></script>
  <script src="http://labratrevenge.com/d3-tip/javascripts/d3.tip.v0.6.3.js"></script>
  <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.6.9/angular.min.js"></script>

General Information D3

  • D3.js is a JavaScript library for manipulating documents based on data. D3 helps you bring data to life using HTML, SVG, and CSS. D3’s emphasis on web standards gives you the full capabilities of modern browsers without tying yourself to a proprietary framework, combining powerful visualization components and a data-driven approach to DOM manipulation.
  • Examples and a documentation is found here: https://d3js.org/

The Data Visualization

The map and its layers

  • The map gets initialized in the maps.component.ts and maps.component.html, like explained in the Frontend Map Documentation: https://github.com/eonum/geopitalsuisse/wiki/Frontend-map
  • There you'll find the function call of the mapDrawer function, which is defined in the mapInitializer.js file. Several initial actions according to the map and the necessary layers happens here:
  1. definition of the map and the default view
  // defines map and sets default view when page is loaded
  map = L.map('mapid').setView([46.818188, 8.97512], 8);
  1. application of OpenStreetMap tiles and custom design using mapbox
   // basic map using OpenStreetMap tiles with custom design using mapbox
  L.tileLayer('https://api.mapbox.com/styles/v1/nathi/cjf8cggx93p3u2qrqrgwoh5nh/tiles/256/{z}/{x}/{y}?access_token=pk.eyJ1IjoibmF0aGkiLCJhIjoiY2pmOGJ4ZXJmMXMyZDJ4bzRoYWRxbzhteCJ9.x2dbGjsVZTA9HLw6VWaQow', {
    maxZoom: 18,
    attribution: 'Map data &copy; <a href="http://openstreetmap.org">OpenStreetMap</a> contributors, ' +
    '<a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>, ' +
    'Imagery © <a href="http://mapbox.com">Mapbox</a>',
    id: 'mapbox.streets'
  }).addTo(map);
  1. That we can work on the map we put a Leaflet layer on it (see Frontend Map documentation). The D3 part is on a new layer on this Leaflet layer, called the SVG:
  // add SVG element to leaflet's overlay pane (group layers)
  svg = d3.select(map.getPanes().overlayPane).append("svg").attr('id', 'circleSVG');
  1. We need the SVG element to adapt to our hospitals, that the range of the visible part is right. This is the function where we calculate the bounds of the SVG Layer:
  function calculateSVGBounds(hospitals) {
    var xMax = 0;
    var yMax = 0;
    var heightPadding = 100;
    var widthPadding = 300;
    hospitals.forEach(function(d) {
      xMax = Math.max(projectPoint(d.longitude, d.latitude).x, xMax);
      yMax = Math.max(projectPoint(d.longitude, d.latitude).y, yMax);
    });
    svg
      .style("left", 0)
      .style("width", xMax + widthPadding)
      .style("top", 0)
      .style("height", yMax + heightPadding);
  }
  1. Zooming behaviour for the map and the SVG element. That the points are adapted to the new map size while the zoom we have to implement to calculate the bounds new. The points get invisible when zooming starts and visible again when zooming has finished. The points are drawn again on the map according the new calculation of the bounds.
  // makes points invisible when user starts zooming
  map.on('zoomstart', function () {
    d3.select('#circleSVG').style('visibility', 'hidden');
  });

  // makes points visible again after user has finished zooming
  map.on('zoomend', function() {
    var maxValue = getMaxValue(hospitalData);
    circles
      .attr("cx", function(d) {return projectPoint(d.longitude, d.latitude).x})
      .attr("cy", function(d) {return projectPoint(d.longitude, d.latitude).y})
      .attr("r", function(d) {return getCircleRadius(d, maxValue)});

    calculateSVGBounds(hospitalData);
    d3.select('#circleSVG').style('visibility', 'visible');
  });
};

The default data on the map

  • After gathering the data by using services in the maps.component.ts, the initial data to be displayed on the map is defined in the mapDrawer function
  • All relevant information for the default visualization of the circles, the tooltips, the characteristics and the filter options are stored here as global variables

Initialize the data

  • The initData function builds up the data to be displayed
  • If there is no filter applied, the input data are all hospitals with the selected type.
  • If filter options are applied, the input data contains the filtered data set.
  • This function builds up an array with the hospitals to be displayed together with the according coordinates, the current numerical attribute and the according hospital type
/**
 * Stores data in array for displaying it. Builds up array with the important information.
 *
 * @param data array that contains hospital information
 * @param type type (from attributes) of hospitals that should be displayed
 */
function initData(data, type){

  // initially empty array to be filled up with hospitals to be displayed on map
  hospitalData = [];

  for (var i = 0; i < data.length; i++){

    // stores name, coordinates (latitude, longitude), size attribute value
    // and type of each hospital in a variable to save in array
    if(data[i].name!=null && data[i].latitude!=null && data[i].longitude!=null){
      var hospitalName = data[i].name;
      var latitude = data[i].latitude;
      var longitude = data[i].longitude;

      // access attributes of hospital
      var attr = data[i].hospital_attributes;

      // filters code attribute and saves it in variable
      var sizeResult = attr.filter(function( obj ) {
        return obj.code == currentNumAttribute.code;
      });

      // saves value of code attribute in variable
      if(sizeResult[0]!=null && sizeResult[0].value!=null){
        var sizeAttribute = Number(sizeResult[0].value);
      } else {
        continue;
      }

      // filters type attribute and saves it in variable
      var typResult = attr.filter(function ( obj ) {
        return obj.code == "Typ";
      });

      // saves value of type attribute in variable
      if(typResult[0]!=null && typResult[0].value!=null){
        var typAttribute = String(typResult[0].value);
      } else {
        continue;
      }

      // store only hospitals with right attribute type in array
      // type "none" stands for default value (all hospitals)
      for(var j = 0; j < type.length; j++){
        if(type[j]!="none"){
          if(typAttribute==type[j]){
            var basicInformation = {longitude: longitude, latitude: latitude, name:hospitalName, radius: sizeAttribute, Typ: typAttribute};
            hospitalData.push(basicInformation);
          }
        }
        if(type[j]=="none"){
          var basicInformation = {longitude: longitude, latitude: latitude, name:hospitalName, radius: sizeAttribute, Typ: typAttribute};
          hospitalData.push(basicInformation);
        }
      }
    }
  }
}

The circles

  • The initCircles function draws the circles of the selected and filtered data
  • For every coordinate we display a circle with the radius according to the numerical value and the colour according to the hospital type
  • Additionally we prepare the tooltip (defined earlier in the mapDrawer function) and the characteristics for every coordinate
  • This is a typical D3 implementation
/**
 * Draws circles on svg layer on map
 *
 * @param hospitalData data that is visualized as circles (with x- and y-coordinates and radius r)
 */
var initCircles = function(hospitalData){

  // get maximal value of radius to calculate radius of circles
  var maxValue = getMaxValue(hospitalData);

  // define circles
  circles = svg.selectAll('circle')
    .data(hospitalData)
    .enter()
    .append('circle')
    .style("fill-opacity", 0.7)
    .attr("r", function(d){
      return getCircleRadius(d, maxValue);
    })
    .attr('fill', function(d) {
      return getCircleColour(d);
    })
    .attr('stroke', function(d) {
      return getCircleBorderColour(d);
    })
    .attr("cx", function(d) {
      return projectPoint(d.longitude, d.latitude).x;
    })
    .attr("cy", function(d) {
      return projectPoint(d.longitude, d.latitude).y;
    })
    .on("mouseover", function(d) {
      return showTooltip(d);
    })
    .on("mouseout", function(d) {
      return removeTooltip(d);
    })
    .on("click", function(d) {
      return callCharComponent(d);
     })
};

The circle radius

  • The circle size represents the current numerical attribute of each hospital.
  • We use the maximum value of the current numerical attribute as normalization variable.
  • We extract the maximum value using a for-loop through the data set to be displayed.
/**
 * Returns the maximal value of the chosen numerical attribute
 * @param hospitalData data which is displayed as a circle
 * @returns {number} maximal radius of the chosen attribute
 */
function getMaxValue(hospitalData) {
  var maxValue = 0;
  // get max value of radius attribute (to calculate radius of circles)
  for(var i=0; i<hospitalData.length; i++){
    if(hospitalData[i]!=null && hospitalData[i].radius!=null){
      if(hospitalData[i].radius>maxValue){
        maxValue = hospitalData[i].radius;
      }
      else{
        continue;
      }
    }
  }
  return maxValue;
}
  • Then we use this maximum value as an upper bound.
  • We chose the root-function to calculate the radius to model a nice distribution of the radius size.
  • If a numerical value is 0, we decided to display the circle with a significant smaller radius.
  • Additionally the radius depends on the zoomlevel, hence the radius "grows" when you zoom in and prevents having too small circles
/**
 * Gives markers different radius according to the numerical attribute
 * @param d data which is displayed as a circle
 * @returns {number} radius of the marker (according numerical attribute)
 */
function getCircleRadius(d, maxValue) {
  var zoomLevel = map.getZoom();
  if (d.radius == null) {
    return 3*zoomLevel*zoomLevel/100; // circles with value 0 have radius 3
  } else {
    return (Math.sqrt(d.radius/maxValue)*10+5)*zoomLevel*zoomLevel/100;
  }
}

The circle color

/**
 * Gives markers different color according to its type attribute
 * @param d data which is displayed as a circle
 * @returns {string} color of the marker (according to type)
 */
function getCircleColour(d)  {
  if (d.Typ == "K111") // Universitätspitäler
    return ('#a82a2a');
  if (d.Typ == "K112") // Zentrumsspitäler
    return ('#a89f2a');
  if (d.Typ == "K121" || d.Typ == "K122" || d.Typ == "K123") // Grundversorgung
    return ('#2ca82a');
  if (d.Typ == "K211" || d.Typ == "K212") // Psychiatrische Kliniken
    return ('#2a8ea8');
  if (d.Typ == "K221") // Rehabilitationskliniken
    return ('#2c2aa8');
  if (d.Typ == "K231" || d.Typ == "K232" || d.Typ == "K233" || d.Typ == "K234" || d.Typ == "K235") //Spezialkliniken
    return ('#772aa8');
  else
    return ('#d633ff');
}

The Tool-Tip

  • This tool-tip is shown when the user hovers over a circle on the map and displays the name of the hospital
  • The tool-tip is defined earlier in the mapDrawer function as a div (initially, it's hidden):
  // Define the div for the tooltip (used for mouseover functionality)
  div = d3.select("body").append("div")
    .attr("class", "tooltip")
    .style("opacity", 0.0);
  • When you hover over a circle, this function is called and makes the transition from hidden to visible:
/**
 * Displays tooltip when hovering over a marker
 * @param d data which is displayed as a circle
 */
function showTooltip(d) {
  div.transition()
        .duration(1)
        .style("opacity", .98);
      div.html(d.name)
        .style("left", (d3.event.pageX) + "px")
        .style("top", (d3.event.pageY - 0) + "px");
}
  • When you exit the tool-tip, the transition goes back from visible to hidden:
/**
 * Let's the tooltip disappear when hovering out of a marker
 * @param d data which is displayed as a circle
 */
function removeTooltip(d) {
  div.transition()
        .duration(500)
        .style("opacity", 0);
}

The characteristics

  • When you click on a circle the characteristics of the selected hospital appears (characteristics-component)
  • The characteristics contain the name of the hospital, the address and the values of the current numerical and categorical attribute
  • The default data of the hospital (name, address, values) is defined in the mapDrawer function
  • After you select a hospital, the relevant data is filtered and displayed in the characteristics.component.html using the HTML DOM method document.getElementById(), like in the following snippet:
// displays the name and address of the clicked hospital in characteristics (Steckbrief)
  document.getElementById('hospitalName').innerHTML = clickedHospital.name;
  if (clickedHospitalData.streetAndNumber != null) {
    document.getElementById('hospitalAddress').innerHTML = clickedHospitalData.streetAndNumber + "<br/>"
    + clickedHospitalData.zipCodeAndCity;
  } else {
    document.getElementById('hospitalAddress').innerHTML = "" + clickedHospitalData.zipCodeAndCity;
  }

  // displays the values of the current numerical and categorical attribute of clicked hospital
  if (sizeResult != null) {
    document.getElementById('numericalAttributeName').innerHTML = currentNumAttribute.nameDE;
    document.getElementById('numericalAttributeValue').innerHTML = sizeResult.value;
  } else {
    document.getElementById('numericalAttributeName').innerHTML = currentNumAttribute.nameDE;
    document.getElementById('numericalAttributeValue').innerHTML = "Keine Daten";
  }

  if (catResult != null) {
    document.getElementById('categoricalAttributeName').innerHTML = currentCatAttribute.nameDE;
    document.getElementById('categoricalAttributeValue').innerHTML = catResult.value;
  } else {
    document.getElementById('categoricalAttributeName').innerHTML = currentCatAttribute.nameDE;
    document.getElementById('categoricalAttributeValue').innerHTML = "Keine Daten";
  }

Update the map

Update after hospital type selection

  • The updateMap function updates the circles when you select or deselect different hospital types.
  • The functions is called when you click on a checkbox in the navbar-component.
  • The parameters are numbers which represent how often the different checkboxes have been clicked. The function checks for every hospital type, if the checkbox is selected (even number of clicks) or deselected (odd number of clicks) and if it's selected, the hospital type is used for the dataset to be displayed.
  • If there is an additional filter in use (from categorial-component), we only use the filtered hospitaldata, if there is no additional filter, we use the entire dataset of hospitals.
/**
 * Updates map with new data
 *
 * For the next parameters: number of times checkbox was pressed
 * --> since default is checked, even numbers (0,2,4,6, ...) mean that this type should be displayed
 * @param numUniSp
 * @param numZentSp
 * @param numGrundVers
 * @param numPsychKl
 * @param numRehaKl
 * @param numSpezKl
 */
var updateMap = function(numUniSp, numZentSp, numGrundVers, numPsychKl, numRehaKl, numSpezKl) {

  var data;

  // use only filtered hospitals (categorical attributes) but only if a filter is active
  if(filteredHospitals[0]!="none"){
    data = filteredHospitals;
  }
  else{
    data = allHospitalData;
  }

  // remove circles that are already defined so we can initialize them again with other data
  removeCircles();

  // first empty type
  type = [];

  // build up data array
  // even numbers of clicks mean that the checkbox is checked and hospitals with that type should be drawn
  if ((numUniSp % 2) === 0) {
    this.type.push("K111");
  }
  if((numZentSp % 2) === 0){
    this.type.push("K112");
  }
  if((numGrundVers % 2) === 0){
    this.type.push("K121", "K122", "K123");
  }
  if((numPsychKl % 2) === 0){
    this.type.push("K211", "K212");
  }
  if((numRehaKl % 2) === 0){
    this.type.push("K221");
  }
  if((numSpezKl % 2) === 0){
    this.type.push("K231", "K232", "K233", "K234", "K235");
  }

  initData(data, this.type);

  // draw circles with the data that is build above
  initCircles(hospitalData);
};

Update after numerical attribute selection (Dropdown 2)

  • With the help of this dropdown the user can select a numerical attribute. The selected attribute defines the radius of each circle so the map must be updated each time a different attribute is selected.
  • The function updateCircleRadius is called with the selected numerical attribute as an argument.
  • The selected attribute is saved as the current numerical attribute which is globally initialized.
  • The first if-else-block makes sure that when no filter is selected all hospitals are displayed.
  • The removeCircles function deletes the currently displayed circles so we can re-draw them with the help of the initData and initCircles functions according to the new numerical attribute.
  • In addition we update the displayed data of the currently selected attribute with the callCharComponent function.
/**
 * Updates the current numerical attribute for characteristics (Steckbrief)
 * and initializes the change of circles' radius according to the chosen
 * numerical attribute
 * @param numericalAttribute selected numerical Attribute from Dropdown1
 */
var updateCircleRadius = function(numericalAttribute) {
  currentNumAttribute = numericalAttribute;

  var data;
  if(filteredHospitals[0]!="none"){
    data = filteredHospitals;
  }
  else{
    data = allHospitalData;
  }

  removeCircles();
  initData(data, this.type);
  initCircles(hospitalData);
  callCharComponent(selectedHospital);
};

Update after categorical option selection (Dropdown 1)

  • In the first dropdown you can choose a categorical attribute and you'll find the according filter options below (categorial-component).
  • The function showCatOptions updates the display in the characteristics according to the selected categorical attribute. Furthermore it displays the options of the new selected categorical attribute.
  • Since the filter options work independent from each other, there is a reset of previous option selections. Hence if any circles have been hidden through a previous filter option, they appear again when you change the categorical attribute.
/**
 * Updates the current categorical attribute and the characteristics of the 
 * selected hospital (Steckbrief), resets the previous selection of filter options
 * and shows the new options for the selected categorical attribute
 * @param categoricalAttribute selected categorical Attribute from Dropdown1
 */
var showCatOptions = function(categoricalAttribute) {
  currentCatAttribute = categoricalAttribute;
  callCharComponent(selectedHospital);
  
  // reset selection when changing the category
  resetCheckBoxes();

  updateCatOptions(categoricalAttribute);
  filteredHospitals = ["none"];
  removeCircles();
  initData(allHospitalData, this.type);
  initCircles(hospitalData);
};
  • When you select a filter option (categorial-component) all hospitals get filtered according to the selected options.
  • The current selection is globally stored as "checkBoxDictionary".
  • the filter function returns only those hospitals who contains one of the options who are selected according to the checkBoxDictionary which contains true and false values.
/**
 * Initializes the dataset for displaying only the hospitals according to the
 * selected options from the categorical attributes.
 * @param category the categorical attribute
 * @param code the code of the selected/deselected option
 */

function updateCirclesFromSelection(category, code){

  // toggle dictionary entry of selected/deselected checkbox, described by category and code
  checkBoxDictionary[category][code] = !checkBoxDictionary[category][code]

  // update the dataset of hospitals who match to the selected options
  filteredHospitals = filter(allHospitalData, checkBoxDictionary);
  initData(filteredHospitals, this.type);

  //update circles accordingly
  removeCircles();
  initCircles(hospitalData);
}

More

  • These are the main functions of the visualization
  • The other functions of the code are well documented in the mapinitializer.js