Hop in D3

On the web you can draw rudimentary data visualizations curating JavaScript and SVG syntax. Tools like Svelte might ease the authoring of the markup, but ultimately, the process is most direct and clear. There are inevitable shortcomings and for more robust logic, and ideally for more elaborate visualizations, it is beneficial to explore D3.

D3 offers many utilities to work with data and with Svelte, works to lay the foundations so you can focus on impressive displays instead.

SVG

Before diving into D3 it is helpful to reframe the data visualization from the previous effort, and specifically the svg element behind the drawing. Instead of deducing the dimensions of the viewBox attribute from the data.

const data = [2, 3, 4, 5, 4, 6, 2];
const width = data.length;
const height = Math.ceil(Math.max(...data) + 1);

The idea is to set the width, set the height immediately, making a decision on the desired aspect ratio.

const width = 100;
const height = 60;

And also, include another set of values for what you can think of as margin.

const [top, right, down, left] = [5, 5, 5, 5];

With these variables you are able to create a larger canvas, expanded by the numbers chosen on the different sides.

<svg viewBox="0 0 {width + right + left} {height + top + down}">
  <!-- ... -->
</svg>

The result is a greater surface, but you need one more adjustment to complete the setup. The shapes are indeed drawn relative to the origin, relative to the top left corner. To avoid cropping the graphics on the respective sides you need to inset the elements with the help of an additional group element g.

<g transform="translate({left} {top})">
  <!-- ... -->
</g>

With this sequence the visualization is already improved. The axis, for instance, are no longer drawn halfway through the parent container. Also, should you want to add more space on a specific side, perhaps to include a label, you just need to tweak the numbers up top.

-const [top, right, down, left] = [5, 5, 5, 5];
+const [top, right, down, left] = [12, 5, 5, 5];

It is technically possible to update the origin for the entire SVG through the viewBox attribute. The operation is slightly advanced, but in this instance the group element has its own reason; having a separate container means you can draw inside and outside the chosen bounds.

<svg> <g>

D3

With the new dimensions we stumble on an obvious challenge: where to position the shapes. Using the data to set the width and the height the process was quite intuitive, but here you need to map the values to pixels. In terms of D3, you need a scale.

The idea is to translate values from an interval to another, from a domain to a range. Considering for instance a linear scale for the vertical axis, the goal is to move between the domain, the set of values, and the range, the pixel space described by the height.

In practice you instantiate a scale with one of D3’s own functions.

import * as d3 from "d3";

const yScale = d3.scaleLinear();

At this juncture you specify the domain and range chaining two methods on the particular instance.

const yScale = d3.scaleLinear()
  .domain(???)
  .range(???)

For the upper limit of the domain we can certainly fall back to the logic used in the first data visualization and find the maximum value with Math.max. D3, however, has another utility in d3.max.

const max = d3.max(data);

Math.max certainly works, but the D3 alternative is designed specifically for data visualizations. The differences are often minute, but there are notable advantages, starting with how the function treats null and undefined values often present to describe missing entries. These might be edge cases, but remark on the ability of D3 to predict and solve the problems around complex data.

With this in mind the scale for the vertical dimension is a linear scale which maps your values, from 0 to the maximum value, to 0 and up to the height used in the SVG.

const yScale = d3.scaleLinear()
  .domain([0, max])
  .range([0, height]);

And here you have the opportunity of introducing another upgrade. Knowing the SVG coordinate system, knowing that shapes are drawn from a given origin to the right and to the bottom, you can immediately flip the y axis to reason in cartesian coordinates. Instead of translating the values from 0 to the height, you can immediately find the right value setting the opposite range, from the height and back to 0.

-  .range([0, height])
+  .range([height, 0])

With a similar logic we can map the indexes to explore the horizontal axis, but the discrete collection helps to describe a different scale and more D3 functions.

To create an array of incrementing values you can very well practice JavaScript for loops or array functions such as fill and map.

const indexes = Array(data.length)
  .fill("")
  .map((_, i) => i);

D3, however, offers a shorthand in d3.range, creating a list up to, but not including, the input argument.

const indexes = d3.range(data.length);

This is just a convenience, but what is far more essential is a scale. Here you want to move from a discrete domain to a continuous range, from the array of indexes to an interval between 0 and the width of the inset frame. Among D3 functions, the problem is solved with a point scale.

const xScale = d3.scalePoint()
  .domain(indexes)
  .range([0, width]);

With the instance of the specific scale function the first item in the array matches the beginning of the second collection. The other numbers are slotted at increments, until the last value is placed at the very edge. Be careful, however: the first and last values are place on the edges of the frame. If you want to inset the points so that the circles two don’t overlap with the axis, you can take advantage of how D3 allows to refine the scales chaining more functions. Past the functions to set the domain and the range tweak the position with padding, setting a value in the [0-1] range relative to the width of a step.

const xScale = scalePoint()
  // domain & range
  .padding(0.5);

Thanks to the two instances you are set to replicate the first basic visualization, positioning the circles in the right spot.

{#each data as value, i}
  <circle cx={xScale(i)} cy={yScale(value)} r="1.5" />
{/each}

This may seem a roundabout manner to reach the same end, but what you lose in simplicity you gain in wisdom and features. Consider drawing a line between the points. This is definitely a bigger challenge to tackle on your own as you need to understand how the path element draws lines with the d attribute. Good luck drawing something more than straight lines, like a sinuous curve with bezier functions. With the help of the D3 the task is far more manageable. Among the functions in the d3-shape module you have access to d3.line. The idea is to create an instance similar to how you instantiated the two scales.

const line = d3.line();

The function receives an array of values and returns a string for the d attribute of a path element. For every item in the array, it elaborates the horizontal and vertical coordinate with two methods, x and y. You chain these methods on the instance of the line function, similarly to the domain and the range. The two receive the same callback function of array functions like map, the items of the array and their respective index, so that you can plot the values with the defined scales.

const line = d3
  .line()
  .x((_, i) => xScale(i))
  .y((d) => yScale(d));

In this manner you find the sequence of characters for the d attribute and draw a line connecting the dots.

<path d={line(data)} fill="none" stroke="currentColor" />

Should you fancy a different way to draw the line, D3 has an entire section devoted to curves and interpolating functions. Instead of stressing over implementation details you can fuss over which curve suits best your needs, an efficient monotone spline, or a less orthodox piecewise sequence of steps. Who knows, it might just inspire a redesign creative enough to pop out of the page.