DEV Community

Jeff Palmer
Jeff Palmer

Posted on • Originally published at jpalmer.dev on

The 2013 Felton Histogram - Variant 1

Nicholas Felton’s 2013 Annual Report contained two distinct histogram variants. This post covers how I created the first variant, which looks like this:

Histogram (Variant 1) detail from 2013 Annual Report
Histogram (Variant 1) detail from 2013 Annual Report (Nicholas Felton)

A pretty typical histogram. However, the styling aspects are what I found most interesting:

  • Truncated measure labels with a scale factor in the upper-right corner
  • Slanted measure transition lines that join the entire series of measurements into a single line

I’ll cover how I achieved both of these below.

Truncated Measure Labels

This was pretty straightforward, as there’s a math function that can be used directly to determine how many significant digits you need to represent a number: Math.log10. The log10 function calculates the base 10 logarithm of a number, which can be used to calculate the number of digits within it. For example,

Math.log10(623) = 2.7944880466591697

The scale factor is calculated by using the whole part of the result (using Math.floor) as the exponent in a call to Math.pow.

// We know we're showing 12 months so divide into 24 intervals
labelXOffset = (width - margins.left - margins.right) / 24;
// Calculate the scale factor used to get the most-significant digit
const yMin = d3.min(data, yAccessor);
const scale = Math.pow(10, Math.floor(Math.log10(yMin)));
const formatter = d3.format(".0f");

Enter fullscreen mode Exit fullscreen mode

The minimum value was used to ensure that the labels weren’t unnecessarily “flattened” by a scale that was off by a factor of 10 (or more), and D3’s formatting functions were used to discard the fractional part of each measure.

Inside the SVG, the measure labels were added using text elements:

{#each data as datum}
  <text
    class="label"
    x={xScale(xAccessor(datum)) + labelXOffset}
    y={yScale(yAccessor(datum)) - labelYOffset}
    >{formatter(yAccessor(datum) / scale)}</text>
{/each}
Enter fullscreen mode Exit fullscreen mode

The title and scale indicator was added using additional text elements:

<text class="title" x={margins.left} y={margins.top + textHeight}>
  TOTAL MONTHLY MEASUREMENT
</text>
<text
  class="scale-label"
  x={width - margins.right}
  y={margins.top + textHeight}>&times;{formatter(scale)}
</text>
Enter fullscreen mode Exit fullscreen mode

Measure Transitions

I implemented two separate line styles while building this visualization. The first used the built-in D3 line generation tools, and the second used a custom line generator.

First Attempt Using D3 Curves

At first I tried to use the line function to generate a segmented line that represented the measures in the histogram. There is a curve function that can be used to specify how points in a line should be joined, and in particular curveStepAfter seemed like it might do the job.

💣 However, in order for this to work I needed to ensure that there was one extra entry in the data, or D3 wouldn’t create the final flat value segment. I simply copied the last point and set the date to one month in the future.

// Massage the data so that there are enough points to complete the path
// Copy the final entry in the array
const myClonedData = R.clone(data[11]);
// Now set the date to one month after
myClonedData.month = "2021-01-01";
const myData = [...R.clone(data), myClonedData];

// First attempt - using 'curveStepAfter'
const yLine = d3
  .line()
  .x((d) => xScale(xAccessor(d)))
  .y((d) => yScale(yAccessor(d)))
  .curve(d3.curveStepAfter)(myData);
Enter fullscreen mode Exit fullscreen mode

This resulted in the following:

First attempt using D3 line and curveStepAfter
First attempt using D3 line and curveStepAfter

This was actually a pretty good starting point, and was what I used until I had time to revisit the visualization.

Second Attempt Using Custom Line Generator

Once I had a little more time to revisit my first attempt, I decided to implement the slanted segment connectors that were used in the original.

I had to replace the use of the line built-in x and y functions with a custom function that took the spacing between measured values into consideration. My goal was to allocate 5% of each measure line at the start and end to be used for the transition. As a result, each line segment was drawn by creating a horizontal line for the measure itself, and then creating a line from the end of that measure to the start of the next. This logic is contained in the following function:

function generateFeltonLine(data, xScale, xAccessor, yScale, yAccessor) {
  // this is only correct because of 0-based arrays
  // and # segments = # points - 1
  const segments = data.length;

  // Calculate displayed segment width and connector width
  const segmentWidth =
    xScale(xAccessor(data[1])) - xScale(xAccessor(data[0]));
  const connectorWidth = segmentWidth * 0.05; // 5% on each side

  // start with the first point, as it (and the last point)
  // are special cases
  let result = [
    [ xScale(xAccessor(data[0])), yScale(yAccessor(data[0])) ]
  ];

  // now all of the interior points
  for (let i = 1; i < data.length - 1; i++) {
    result.push([
      xScale(xAccessor(data[i])) - connectorWidth,
      yScale(yAccessor(data[i - 1])),
    ]);
    result.push([
      xScale(xAccessor(data[i])) + connectorWidth,
      yScale(yAccessor(data[i])),
    ]);
  }
  // add the final point
  result.push([
    xScale(xAccessor(data[data.length - 1])),
    yScale(yAccessor(data[data.length - 1])),
  ]);
  return result;
}
Enter fullscreen mode Exit fullscreen mode

Once that was done I could create the measure line as follows:

// Second attempt - Felton-style connectors
const yLine = d3
  .line()(
    generateFeltonLine(myData, xScale, xAccessor, yScale, yAccessor)
  );
Enter fullscreen mode Exit fullscreen mode

Resulting in a much-improved (in my opinion) final version of the visualization:

Final histogram version - variant 1
Final histogram version - variant 1

The code I used to write this post is available on GitHub.

Please feel free to contact me if you have any questions or comments.

AWS Security LIVE!

Join us for AWS Security LIVE!

Discover the future of cloud security. Tune in live for trends, tips, and solutions from AWS and AWS Partners.

Learn More

Top comments (0)

Billboard image

The Next Generation Developer Platform

Coherence is the first Platform-as-a-Service you can control. Unlike "black-box" platforms that are opinionated about the infra you can deploy, Coherence is powered by CNC, the open-source IaC framework, which offers limitless customization.

Learn more

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay