Blog

All Blog Posts  |  Next Post  |  Previous Post

Extend TMS WEB Core with JS Libraries with Andrew:
Charting Part 4: D3.js Interaction

Bookmarks: 

Wednesday, September 7, 2022

Photo of Andrew Simard

Last time out, we had an introductory look at D3 and how it can be used to add regular charting functionality to our TMS WEB Core projects. D3 as a topic is a bit of a behemoth, however, with so much to cover. Considerably more than even something like Tabulator, for example. Much of its capability comes from it working at such a low level compared to the alternatives. Generally, this means we have to do a bit more work ourselves at the start, while at the same time, we've got considerably more options on where to go in terms of what we want a particular chart to be able to offer. This time out, we're going to focus on interactions with D3 charts.


Motivation.

For many applications, a chart may just be a static element on the page. This is especially the case with Sparklines, which we've already covered, but regular charts may be simple enough or convey enough information in a static presentation, and anything beyond that would introduce needless complications. Then there are charts that raise more questions than they can possibly answer. Being able to pan or zoom around in a chart becomes a basic necessity, with more interactivity available through the use of things like tooltips, interactive legends, and so on.

These are the kinds of things we've been able to deploy using Chart.js previously, or really any of the comparable charting libraries that were mentioned there. We'll use those kinds of things as the starting point for interacting with D3 charts, and then move on and explore a few of its other capabilities. For the example project, we're just going to pick up where we left off previously, with pie/donut charts, bar charts, and line charts, and then add in other chart types as we come up with interactions that might benefit them.


Panning and Zooming.

There are a few ways to approach panning and zooming in D3. To start with, we'll use the built-in functionality to provide panning and zooming for the entire chart. This can be added directly to our Delphi functions, so no coding changes are needed for the code that creates the charts unless you're interested in adding separate variations that enable, disable, or otherwise modify this kind of functionality. The D3 documentation is a bit light on the details, but there are plenty of examples around that can be scrutinized to find what we need. The following was adapted from this post. The new CreateD3LineChart looks like the following.


function TForm1.CreateD3LineChart(X, Y, Width, Height: Integer; XData: String; YData: String; Colors: String; LineWidth: Double): Integer;
var
  NewChart: TWebHTMLDiv;
  ChartID: String;
begin

  SetLength(Charts,Length(Charts)+1);
  Result := Length(Charts) - 1;
  ChartID := 'D3Chart-'+IntToStr(Result);

  NewChart := TWebHTMLDiv.Create(Self);
  NewChart.Parent := Self;
  NewChart.ElementID := 'div'+ChartID;
  NewChart.ElementClassName := 'rounded border border-secondary border-2 bg-dark';
  NewChart.Left := X;
  NewChart.Top := Y;
  NewChart.Width := Width;
  NewChart.Height := Height;

  NewChart.HTML.Text := '<div style="overflow:hidden;" width='+IntToStr(Width)+' height='+IntToStr(Height-40)+'>'+
    '<svg id="'+ChartID+'" width='+IntToStr(Width)+' height='+IntToStr(Height-40)+'></svg></div>'+

    // Reset Button
    '<button '+
      'class="btn btn-secondary btn-sm mx-1" '+
      ' title="Reset" '+
      '><i class="fa-solid fa-fw fa-rotate-right"></i>'+
    '</button>'+

    // Center Button
    '<button '+
      'class="btn btn-secondary btn-sm me-1" '+
      ' title="Center" '+
      '><i class="fa-solid fa-fw fa-arrows-to-dot"></i>'+
    '</button>'+

    // Zoom In Button
    '<button '+
      'class="btn btn-secondary btn-sm me-1" '+
      ' title="Zoom In" '+
      '><i class="fa-solid fa-fw fa-magnifying-glass-plus"></i>'+
    '</button>'+

    // Zoom Out Button
    '<button '+
      'class="btn btn-secondary btn-sm me-1" '+
      ' title="Zoom Out" '+
      '><i class="fa-solid fa-fw fa-magnifying-glass-minus"></i>'+
    '</button>'+

    // Pan Left Button
    '<button '+
      'class="btn btn-secondary btn-sm me-1" '+
      ' title="Pan Left" '+
      '><i class="fa-solid fa-fw fa-arrow-left"></i>'+
    '</button>'+

    // Pan Right Button
    '<button '+
      'class="btn btn-secondary btn-sm me-1" '+
      ' title="Pan Right" '+
      '><i class="fa-solid fa-fw fa-arrow-right"></i>'+
    '</button>'+

    // Pan Up Button
    '<button '+
      'class="btn btn-secondary btn-sm me-1" '+
      ' title="Pan Up" '+
      '><i class="fa-solid fa-fw fa-arrow-up"></i>'+
    '</button>'+

    // Pan Down Button
    '<button '+
      'class="btn btn-secondary btn-sm" '+
      ' title="Pan Down" '+
      '><i class="fa-solid fa-fw fa-arrow-down"></i>'+
    '</button>';

  asm

    // Setup Chart
    var svg = d3.select('#'+ChartID),
        width = svg.attr("width")-50,
        height = svg.attr("height")-60,
        g = svg.append("g").attr("transform", "translate(" + width / 2 + "," + height / 2 + ")");

    // Set chart within area (kind of like margin)
    var g = svg.append("g").attr("transform", "translate(25,25)");

    // Setup x-axis
    var xdat = JSON.parse(XData);
    var xScale = d3.scaleBand().range([0, width]).padding(0.5);
    xScale.domain( xdat );
    g.append("g")
     .attr("transform", "translate(0," + height + ")")
     .call(d3.axisBottom(xScale));

    // Setup y-axis
    var ydat = JSON.parse(YData);
    var yScale = d3.scaleLinear().range([height, 0]);
    yScale.domain([0, Math.max(...ydat)]);
    g.append("g")
     .call(d3.axisLeft(yScale));

    // Change the axes to be white
    d3.select('#'+ChartID)
      .style("color","#fff")

    // Draw the Lines
    // function(d,i) - interates data elements - d=data i=index
    // We're just iterating and using i to lookup xdat[i] and ydat[i] values
    g.append("path")
     .datum(xdat)
     .attr("fill", "none")
     .attr("stroke", Colors)
     .attr("stroke-width", LineWidth)
     .attr("d", d3.line()
       .x(function(d,i) { return xScale(xdat[i]); })
       .y(function(d,i) { return yScale(ydat[i]); })
     );

    // Add Zoom Function
    function handleZoom(e) {
      d3.select('#'+ChartID)
        .attr('transform', e.transform);
    }

    let zoom = d3.zoom('#'+ChartID)
                 .scaleExtent([0.5, 10])
                 .on('zoom', handleZoom);

    function initZoom() {
      d3.select('#'+ChartID)
        .call(zoom);
    }

    initZoom();

    function resetZoom() {
      d3.select('#'+ChartID)
        .transition()
        .call(zoom.scaleTo, 1);
    }

    function center() {
      d3.select('#'+ChartID)
        .transition()
            .call(zoom.translateTo, 0.5 * width +25, 0.5 * height +25);
    }

    function zoomIn() {
      d3.select('#'+ChartID)
            .transition()
            .call(zoom.scaleBy, 2);
    }

    function zoomOut() {
      d3.select('#'+ChartID)
            .transition()
            .call(zoom.scaleBy, 0.5);
    }

    function panLeft() {
      d3.select('#'+ChartID)
             .transition()
            .call(zoom.translateBy, -50, 0);
    }

    function panRight() {
      d3.select('#'+ChartID)
             .transition()
            .call(zoom.translateBy, 50, 0);
    }

    function panUp() {
      d3.select('#'+ChartID)
             .transition()
            .call(zoom.translateBy, 0, -50);
    }

    function panDown() {
      d3.select('#'+ChartID)
             .transition()
            .call(zoom.translateBy, 0, 50);
    }

    document.getElementById(ChartID).parentElement.parentElement.children[1].onclick = function(){ resetZoom(); }
    document.getElementById(ChartID).parentElement.parentElement.children[2].onclick = function(){ center(); }
    document.getElementById(ChartID).parentElement.parentElement.children[3].onclick = function(){ zoomIn(); }
    document.getElementById(ChartID).parentElement.parentElement.children[4].onclick = function(){ zoomOut(); }
    document.getElementById(ChartID).parentElement.parentElement.children[5].onclick = function(){ panLeft(); }
    document.getElementById(ChartID).parentElement.parentElement.children[6].onclick = function(){ panRight(); }
    document.getElementById(ChartID).parentElement.parentElement.children[7].onclick = function(){ panUp(); }
    document.getElementById(ChartID).parentElement.parentElement.children[8].onclick = function(){ panDown(); }
  end;
end;

Lots going on here. First, there are a handful of buttons added to the chart, so space needs to be taken away from the chart to accommodate those, all within the same <div>. The buttons are assigned functions at the end of the asm...end block so that the function declarations exist ahead of the assignment. Kind of a nuisance. The functions themselves are all simple enough to follow. When panning and zooming, really all that is happening is that a CSS transform is being applied to the entire chart. Here's what they look like before and after a bit of zooming about.


TMS Software Delphi  Components
Before Pan/Zoom.

TMS Software Delphi  Components
After Pan/Zoom.

While this works pretty well, generally if you have a chart that has axes, the axes should remain a fixed size and just scale to accommodate the new pan/zoom positions. This is typically how panning and zooming are handled in other charting libraries. To implement this kind of behavior in D3, we'll need to update the axes separately. 

Panning and Zooming, V2.

To implement this kind of behavior in D3, we'll need to update the axes separately from the chart data. And there are functions to help with this sort of thing. But as we've seen previously, it is a bit of work. Here's an example cobbled together from this post. We'll update the CreateD3BarChart to use this mechanism.


function TForm1.CreateD3BarChart(X, Y, Width, Height: Integer; XData: String; YData: String; Colors: String): Integer;
var
  NewChart: TWebHTMLDiv;
  ChartID: String;
begin

  SetLength(Charts,Length(Charts)+1);
  Result := Length(Charts) - 1;
  ChartID := 'D3Chart-'+IntToStr(Result);

  NewChart := TWebHTMLDiv.Create(Self);
  NewChart.Parent := Self;
  NewChart.ElementID := 'div'+ChartID;
  NewChart.ElementClassName := 'rounded border border-secondary border-2 bg-dark';
  NewChart.Left := X;
  NewChart.Top := Y;
  NewChart.Width := Width;
  NewChart.Height := Height;
  NewChart.HTML.Text := '<div style="overflow:hidden;" width='+IntToStr(Width)+' height='+IntToStr(Height)+'>'+
    '<svg id="'+ChartID+'" width='+IntToStr(Width)+' height='+IntToStr(Height-10)+'></svg></div>';

  asm
    var color = JSON.parse(Colors);
    var xdat = JSON.parse(XData);
    var ydat = JSON.parse(YData);

    var margin = {top: 10, right: 10, bottom: 20, left: 20};

    var svg = d3.select("#"+ChartID);
    var width = svg.attr("width");
    var height = svg.attr("height");

    svg.attr("viewBox", [0, 0, width, height])
       .call(zoom);

    // Change the axes to be white
    d3.select("#"+ChartID)
      .style("color","#fff")

    var x = d3.scaleBand()
              .domain(xdat)
              .range([margin.left, width - margin.right])
              .padding(0.5);

    var y = d3.scaleLinear()
              .domain([0, Math.max(...ydat)])
              .range([height - margin.bottom, margin.top]);

    var xAxis = g => g
        .attr("transform", `translate(0,${height - margin.bottom})`)
        .call(d3.axisBottom(x).tickSizeOuter(0))

    var yAxis = g => g
        .attr("transform", `translate(${margin.left},0)`)
        .call(d3.axisLeft(y))
        .call(g => g.select(".domain").remove());

    svg.append("g")
       .attr("class", "x-axis")
       .call(xAxis);

    svg.append("g")
       .attr("class", "y-axis")
       .call(yAxis);

    svg.append("g")
       .attr("class", "bars")
       .selectAll("rect")
       .data(xdat)
       .join("rect")
       .attr("x", function (d,i) { return x(xdat[i]); })
       .attr("y", function (d,i) { return y(ydat[i]); })
       .attr("width", x.bandwidth())
       .attr("height", function (d,i) { return (height - margin.bottom) - y(ydat[i]); })
       .attr("fill", function (d,i) { return color[i];
    });

    function zoom(svg) {
      const extent = [[margin.left, margin.top], [width - margin.right, height - margin.top]];

      svg.call(d3.zoom()
         .scaleExtent([1, 8])
         .translateExtent(extent)
         .extent(extent)
         .on("zoom", zoomed));

      function zoomed(event) {
        x.range([margin.left, width - margin.right].map(d => event.transform.applyX(d)));
        svg.selectAll(".bars rect").attr("x", function (d,i) { return x(xdat[i])}).attr("width", x.bandwidth());
        svg.selectAll(".x-axis").call(xAxis);
      }
    }

  end;
end;

Note in this case it is just the x-axis that is being panned and zoomed, and that the y-axis will remain fixed the entire time. Here are the before and after views of this chart.


TMS Software Delphi  Components
Before Pan/Zoom.

TMS Software Delphi  Components
After Pan/Zoom.

This is a much cleaner presentation of pan/zoom but with a little more work to understand what it is doing. The idea with D3 generally is to use its many functions to help out with the main element calculations. This is where other libraries that provide front-ends to D3 are helpful - not so much tedious work trying to figure out how to get these things to work properly. But the trade-off generally is that you end up with less access to these kinds of functions, often with an overhead penalty as well. Using D3 directly gets you the most control and the most performance but at likely a higher development cost, at least initially.


Tooltips.

The next obvious interaction we're interested in is some kind of tooltip when the mouse hovers over part of a chart.  Lots of options here, but the most basic is to just add some <div> elements when the mouse enters a chart area, and hide them when the mouse leaves. Pretty low-level stuff here, which is good if you want to customize things. 

Perhaps there's more data to display with a tooltip, or you want to customize the tooltip to fit in with the rest of your theme. Here, we've updated the CreateD3PieChart to display a tooltip when hovering over a pie chart slice.  Adapted from this example.


function TForm1.CreateD3PieChart(X, Y, Width, Height, InnerRadius, OuterRadius: Integer; Data: array of Integer; Colors: String): Integer;
var
  NewChart: TWebHTMLDiv;
  ChartID: String;
begin

  SetLength(Charts,Length(Charts)+1);
  Result := Length(Charts) - 1;
  ChartID := 'D3Chart-'+IntToStr(Result);

  NewChart := TWebHTMLDiv.Create(Self);
  NewChart.Parent := Self;
  NewChart.ElementID := 'div'+ChartID;
  NewChart.ElementClassName := 'rounded border border-secondary border-2 bg-dark';
  NewChart.Left := X;
  NewChart.Top := Y;
  NewChart.Width := Width;
  NewChart.Height := Height;
  NewChart.HTML.Text := '<svg id="'+ChartID+'" width='+IntToStr(Width)+' height='+IntToStr(Height)+'></svg>';

  asm

    var svg = d3.select("#"+ChartID),
        width = svg.attr("width"),
        height = svg.attr("height"),
        radius = OuterRadius,
        g = svg.append("g").attr("transform", "translate(" + width / 2 + "," + height / 2 + ")");

    var color = d3.scaleOrdinal(JSON.parse(Colors));
    var pie = d3.pie()
                .value(function(d,i) { return Data[i]; })
                .sort(null);

    var arc = d3.arc()
                .innerRadius(InnerRadius)
                .outerRadius(OuterRadius);

    var arcs = g.selectAll("arc")
                .data(pie(Data))
                .enter()
                .append("g")
                .attr("class", "arc")

    arcs.append("path")
        .attr("fill", function(d, i) {
            return color(i);
        })
        .attr("d", arc);


    var tooltips = d3.select('#'+ChartID);
    var tooltip = tooltips.select(function() { return this.parentNode; })
                          .append('div')
                          .attr('class', 'tooltip rounded p-2 border border-secondary')
                          .style('position','absolute')
                          .style('z-index','100')
                          .style('top',(height / 2)+'px')
                          .style('left',(width / 2)+'px')
                          .style('color','white')
                          .style('background','black')
                          .style('opacity','0')
                          .style('filter','drop-shadow(0px 0px 4px white)');

    tooltip.append('div')
           .attr('class', 'label');

    tooltip.append('div')
           .attr('class', 'count');

    tooltip.append('div')
           .attr('class', 'percent');


    arcs.on('mouseover', function(d,i) {
      var total = Data.reduce((a, b) => a + b, 0);
      var percent = Math.round(1000 * i.data / total) / 10;
      tooltip.select('.label').html('Value: '+i.data);
      tooltip.select('.count').html('Total: '+total);
      tooltip.select('.percent').html('Percent: '+percent + '%');
      tooltip.style('display', 'block');
      tooltip.style('opacity','1');
    });

    arcs.on('mouseout', function() {
      tooltip.style('display', 'none');
      tooltip.style('opacity','0');
    });

  end;

end;

The result is the following. In the example code, there are a number of different CSS classes and styles that are set directly (without using a separate CSS file). But as we're dealing with classes and CSS, that is also an option, particularly if you're looking to have tooltips and other formatting conventions applied consistently across your project. 


TMS Software Delphi  Components
PieChart Tooltips.


This could be further expanded or customized with better tooltip placement (maybe the middle of the slice) or by having the slice itself be expanded or altered in some way. Similarly, for other chart types, it doesn't take much effort to have lines drawn from the hovered data element to the axes or to apply different CSS filter functions. 

Download the full source code test project here.


Next Time.

There is a considerable amount of work needed to get D3 Charts up and running in any project, particularly compared to a number of other charting libraries. But for some projects, it may well be worth the effort to get that bespoke interface or to address a peculiar type of data that doesn't fit into the normal charting model. With D3, there's not really any limit to what you can do. If we're just after the basics, we'd be better served looking elsewhere. 

However, there's a great deal more to D3. Next time, we'll have a look at D3 animations. You've probably run across countless examples of D3 charts displaying animated data. So we'll have a look at two. First, we'll have a look at the very popular Bar Chart Race and see what it takes to apply the same concept to other data. And then we'll revisit our heart data and see if we can adapt this HeartBeat example to display real data.


Related Posts
Charting Part 1: Sparklines
Charting Part 2: Chart.js
Charting Part 3: D3.js Introduction
Charting Part 4: D3.js Interaction
Charting Part 5: D3.js Animation
Charting Part 6: Tag Clouds


Follow Andrew on 𝕏 at @WebCoreAndMore or join our
𝕏
Web Core and More Community.



Andrew Simard


Bookmarks: 

This blog post has not received any comments yet.



Add a new comment

You will receive a confirmation mail with a link to validate your comment, please use a valid email address.
All fields are required.



All Blog Posts  |  Next Post  |  Previous Post