Nicholas Felton’s 2013 Annual Report contained two distinct histogram variants. This post covers how I created the second variant, which looks like this:
This is similar to the first variant that I covered in my last post, with the following changes:
- Two measures are plotted on the same x-axis, with their y-axes facing in opposite directions
- Irregular stippling is used to differentiate the lower measure
- Measure series labels are added, while value labels are removed
Plotting with Multiple Axes
This histogram variant adds a second measure that has its y-axis pointing downward, in opposition to the y-axis of the top measure. Since the y-axis in SVG increases from top to bottom, the range of the top scale will be from the y midpoint to the top margin (from x-axis upwards), and the range of the bottom scale will be from the y midpoint to the height minus the bottom margin (from x-axis downwards).
$: xScale = d3
.scaleTime()
.domain([dateParser("2020-01-01"), dateParser("2021-01-01")])
.range([margins.left, width - margins.right]);
// The top scale goes from midpoint to top margin
// Also add 10% of the max top value to the end of the domain
// to ensure there's headroom on the chart
$: topScale = d3
.scaleLinear()
.domain([0, d3.max(data, topAccessor) * 1.1])
.range([
(height - margins.bottom - margins.top) / 2 + margins.top,
margins.top,
])
.nice();
// The bottom scale goes from midpoint to bottom margin
// Also add 10% of the max bottom value to the end of the domain
// to ensure there's footroom (?) on the chart
$: bottomScale = d3
.scaleLinear()
.domain([0, d3.max(data, bottomAccessor) * 1.1])
.range([
(height - margins.bottom - margins.top) / 2 + margins.top,
height - margins.bottom,
])
.nice();
With these scales, the same line drawing techniques from the last post can be used to create the two measure lines.
First, the line paths were created using the generateFeltonLine
method:
$: topLine = d3.line()(
generateFeltonLine(
augmentedData,
xScale,
xAccessor,
topScale,
topAccessor
)
);
$: bottomLine = d3.line()(
generateFeltonLine(
augmentedData,
xScale,
xAccessor,
bottomScale,
bottomAccessor
)
);
Then they were placed within the SVG along with some CSS classes for styling:
<svg viewBox="0 0 {width} {height}" ...>
<path class="top-line" d={topLine} />
<path class="bottom-line" d={bottomLine} />
...
</svg>
Note: Much of the boilerplate in the code has been omitted to keep things short. These omissions are represented by ...
in the source snippets. See the GitHub repository for the working implementation.
Generating Irregular Stippling
The original histogram used a form of stippling to differentiate the bottom measure from the top measure. My goal was to find an algorithm to generate realistic-looking stippling, generate a polygon that represents the area to be shaded, and then use the polygon as a mask to determine which points should be shown.
Aesthetically pleasing random point distribution
It turns out that generating stippling that looks good is actually somewhat difficult. Luckily I was able to lean on the community to find an existing implementation of Bridson’s Algorithm, which can be used to approximate human stippling. Thanks (again) Mike Bostock!
I added a version of this generator function to my code and was able to use it to generate a set of points like so:
import { poissonDiscSampler } from "../utils.ts";
// Use the generator to generate all of the points
// so that they can be iterated over by svelte
function generatePoissonPoints(width, height, radius) {
const sampler = poissonDiscSampler(width, height, radius);
let points = [];
let a: [float, float] = sampler();
while (a) {
points.push(a);
a = sampler();
}
return points;
}
// generate points covering the entire bottom of the SVG
// they'll be filtered later
$: points = generatePoissonPoints(
width,
(height - margins.top - margins.bottom) / 2,
10
);
(The actual radius value of 10
that I used was the result of some visual trial and error.)
If you were to plot these points without any filtering, you get this:
The stippling looks great! Now we just need to get rid of everything outside of the measure area.
Creating the bounding polygon
Unsurprisingly, D3 has the ability to perform polygon containment checks out of the box via polygonContains. Since I had already created a function to generate the set of points that constitute the measure line, creating a polygon from those points was straightforward.
function generateClosedFeltonPolygon(data, xScale, xAccessor,
yScale, yAccessor) {
const lineSegments = generateFeltonLine(data, xScale, xAccessor,
yScale, yAccessor);
return [
// First point - on the origin
[xScale(xAccessor(data[0])), yScale(0)],
// Now the generated points for the measure lines
...lineSegments,
// Bring the line back to the x-axis
[xScale(xAccessor(data[data.length - 1])), yScale(0)],
// Now close the polygon by going back to the origin
[xScale(xAccessor(data[0])), yScale(0)],
];
}
$: closedPoly = generateClosedFeltonPolygon(
augmentedData,
xScale,
xAccessor,
bottomScale,
bottomAccessor
);
Once the polygon was created, it could be used to filter the point array.
<svg ...>
...
{#each points as point}
{#if d3.polygonContains(closedPoly, [point[0], height / 2 + point[1]])}
<circle
class="stipple"
cx={point[0]}
cy={height / 2 + point[1]}
r={2.5}
/>
{/if}
{/each}
</svg>
At this point, the filtered histogram looked like this:
Adding Labels
The final step was to add measure labels above and below the x-axis:
<text
class="top-label"
text-anchor="end"
x={width - margins.right}
y={(height - margins.top - margins.bottom) / 2 +
margins.top - textHeight / 2}>Top</text
>
<text
class="bottom-label"
text-anchor="end"
x={width - margins.right}
y={(height - margins.top - margins.bottom) / 2 +
margins.top + textHeight}>Bottom</text
>
Unfortunately the lower label was somewhat obscured by the stippling:
I experimented with CSS-based text shadows in an attempt to create an outline that would mask the nearby points, but that didn’t work as well as I hoped it would. I did a little more research and eventually came across an article describing the feMorphology
filters available in SVGs. (It’s still amazing to me what’s built into SVG!)
These filters can be used to define a series of image transformations, which in this case were used to implement a very clean text outline (see the linked article for a detailed description of how this actually works).
<svg ...>
<filter id="outline">
<feMorphology
in="SourceAlpha"
result="DILATED"
operator="dilate"
radius="5"
/>
<feFlood
flood-color="rgba(55, 65, 81, 1)"
flood-opacity="1"
result="BACKGROUND"
/>
<feComposite
in="BACKGROUND"
in2="DILATED"
operator="in"
result="OUTLINE"
/>
<feMerge>
<feMergeNode in="OUTLINE" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<text
class="top-label"
filter="url(#outline)"
text-anchor="end"
x={width - margins.right}
y={(height - margins.top - margins.bottom) / 2 +
margins.top - textHeight / 2}>Top</text
>
<text
class="bottom-label"
filter="url(#outline)"
text-anchor="end"
x={width - margins.right}
y={(height - margins.top - margins.bottom) / 2 +
margins.top + textHeight}>Bottom</text
>
</svg>
Once the SVG text
elements were altered to include a reference to the filter via filter="url(#outline)"
, the label text in the stippled area became significantly more legible:
Final Result
At this point, the final version of the histogram was complete:
The code I used to write this post is available on GitHub.
If you have any feedback, please let me know on Twitter or feel free to contact me here.
Top comments (0)