Making of: Line drawing tutorial

 from Red Blob Games
15 May 2017

People ask me how I write my interactive tutorials. I can point at the HTML+CSS+JS but that doesn’t show the process. On this page I’ll recreate the first half of my line drawing tutorial[1], showing the an implementation using D3.js v4[2]. The implementation style will be similar if you use jQuery. I also have another page showing an implementation using the Vue/React/Svelte declarative style[3].

In this tutorial I’m implementing diagrams using SVG instead of Canvas or WebGL. I usually choose SVG in part because DOM-manipulating libraries like D3 and Vue make HTML and SVG easier to work with.

The goal is to implement interactive diagrams like this:

The line drawing tutorial was a medium sized project for me, with multiple diagrams, multiple layers in each diagram, draggable handles, and scrubbable numbers. I’ll include pointers to the code in each step.

This is an interactive tutorial about making interactive tutorials.

You should know some Javascript to follow the tutorial. It will help if you know some SVG and HTML. If you’re interested in making your own interactive pages, I recommend trying to recreate the diagrams yourself while following the tutorial.

 1  Web page#

I usually start with a basic web page template that includes scripts, footers, and one blank SVG diagram:

I attach an id= to an html element so that I can get to it from Javascript, using document.getElementById or its d3 equivalent. Sometimes I’ll attach it to the <svg> and sometimes to a <div> outside the svg. For this page, I want interactive elements outside the svg so I’m putting the id on the <div>.

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8"/>
    <title>Line drawing</title>
    <link rel="stylesheet" href="../pagestyle.css">
    <script src="https://d3js.org/d3.v4.min.js"></script>
  </head>
  <body>
    <header>
      <h1>Line drawing</h1>
    </header>
    <p>
      This is a tutorial about line drawing and
      line of sight on grids.
    </p>
    <div id="demo">
      <svg viewBox="0 0 550 200" style="background:white">
      </svg>
    </div>
    <script src="line-drawing.js"></script>
    <footer xmlns:dct="http://purl.org/dc/terms/" xmlns:vcard="http://www.w3.org/2001/vcard-rdf/3.0#">
      <a rel="license"
         style="float:left;width:100px;margin-right:1em"
         href="http://creativecommons.org/publicdomain/zero/1.0/">
        <img src="http://i.creativecommons.org/p/zero/1.0/88x31.png" style="border-style: none;" alt="CC0" />
      </a>
      This page is in the <a href="https://creativecommons.org/publicdomain/zero/1.0/">public domain</a>.
      Use it as you wish. Attribution is not necessary, but appreciated.
    </footer>
  </body>
</html>

I’ll omit the header and footer from the rest of the examples. Click the filename on the upper right to see the entire page up to that point. Using a viewBox on <svg> tells it the coordinate system to for drawing. We can use that to keep a consistent coordinate system even if the diagram is resized.

 2  Diagram#

Sometimes I’ll add a diagram first and then add some text; other times I’ll start with the text and then figure out the diagrams. For this page I’ll start with a diagram.

The tutorial is about drawing lines on a square grid, so I need to draw a grid and also draw lines. I’ll draw a grid with Javascript:

Although d3 has a pattern for creating multiple elements with selectAll() + data() + enter(), I don’t need that here so I’m just creating the elements directly:

const scale = 22;
let root = d3.select("#demo svg");

for (let x = 0; x < 25; x++) {
    for (let y = 0; y < 10; y++) {
        root.append('rect')
            .attr('transform', `translate(${x*scale}, ${y*scale})`)
            .attr('width', scale)
            .attr('height', scale)
            .attr('fill', "white")
            .attr('stroke', "gray");
    }
}

I tried a few different grid sizes. I could parameterize it and calculate it properly but often times I will hard-code it when I’m just starting out, and only calculate it if I need to. Here my svg is 550px wide and I picked squares that are 22px, so 25 of them fit across. Vertically I can fit 10 squares in 220px so I changed the svg height from 200 to 220 to fit.

Those of you who know SVG might choose to use viewBox or transform to change the coordinate system to place points at the center of each grid square instead of at the top left, and also to scale things so that each grid square is 1 unit across instead of scale pixels. I did this in the original article but I didn’t for this tutorial.

 3  Algorithm#

The main algorithm I’m trying to demonstrate on the page is drawing a line on a grid. I need to implement that algorithm and a visualization for it.

let A = {x: 2, y: 2}, B = {x: 20, y: 8};
let N = Math.max(Math.abs(A.x-B.x), Math.abs(A.y-B.y));
for (let i = 0; i <= N; i++) {
    let t = i / N;
    let x = Math.round(A.x + (B.x - A.x) * t);
    let y = Math.round(A.y + (B.y - A.y) * t);
    root.append('rect')
        .attr('transform', `translate(${x*scale}, ${y*scale})`)
        .attr('width', scale-1)
        .attr('height', scale-1)
        .attr('fill', "hsl(0,40%,70%)");
}

Hooray, it works!

This is just the beginning. It’s a working implementation of the algorithm and a working diagram. But it’s not interactive.

 4  Interaction#

What I most often do for interaction is let the reader change the inputs to the algorithm and then I show the outputs. For line drawing, the inputs are the two endpoints, A and B in the code.

function makeDraggableCircle(point) {
    let circle = root.append('circle')
        .attr('class', "draggable")
        .attr('r', scale*0.75)
        .attr('fill', "hsl(0,50%,50%)")
        .call(d3.drag().on('drag', onDrag));

    function updatePosition() {
        circle.attr('transform',
                    `translate(${(point.x+0.5)*scale} ${(point.y+0.5)*scale})`);
    }
    
    function onDrag() {
        point.x = Math.floor(d3.event.x / scale);
        point.y = Math.floor(d3.event.y / scale);
        updatePosition();
    }

    updatePosition();
}

makeDraggableCircle(A);
makeDraggableCircle(B);

Great! It’s pretty easy with d3-drag[4]. To help the reader know which elements are interactive, I set the CSS cursor:move over the draggable circles.

This code lets me update the inputs A and B but it doesn’t recalculate the output line.

 5  Redraw function#

To be able to update the line, I need to move the drawing code into a function that I can call again, and I also need to reuse the <rect> elements I’ve previously created. It’s useful to use d3’s enter-exit pattern[5] here; it will let me reuse, create, or remove elements as my data changes. To use it, I need a container for the <rect> elements; I put it in a variable gPoints. I also need to separate the logic for the algorithm (function pointsOnLine) from the logic for drawing (function redraw).

let A = {x: 2, y: 2}, B = {x: 20, y: 8};
let gPoints = root.append('g');

function pointsOnLine(P, Q) {
    let points = [];
    let N = Math.max(Math.abs(P.x-Q.x), Math.abs(P.y-Q.y));
    for (let i = 0; i <= N; i++) {
        let t = i / N;
        let x = Math.round(P.x + (Q.x - P.x) * t);
        let y = Math.round(P.y + (Q.y - P.y) * t);
        points.push({x: x, y: y});
    }
    return points;
}

function redraw() {
    let rects = gPoints.selectAll('rect').data(pointsOnLine(A, B));
    rects.exit().remove();
    rects.enter().append('rect')
        .attr('width', scale-1)
        .attr('height', scale-1)
        .attr('fill', "hsl(0,40%,70%)")
        .merge(rects)
        .attr('transform', (p) => `translate(${p.x*scale}, ${p.y*scale})`);
}
    

function makeDraggableCircle(point) {
    let circle = root.append('circle')
        .attr('class', "draggable")
        .attr('r', scale*0.75)
        .attr('fill', "hsl(0,50%,50%)")
        .call(d3.drag().on('drag', onDrag));

    function updatePosition() {
        circle.attr('transform',
                    `translate(${(point.x+0.5)*scale} ${(point.y+0.5)*scale})`);
    }
    
    function onDrag() {
        point.x = Math.floor(d3.event.x / scale);
        point.y = Math.floor(d3.event.y / scale);
        updatePosition();
        redraw();
    }

    updatePosition();
}

makeDraggableCircle(A);
makeDraggableCircle(B);
redraw();

Great! Now I have an interactive diagram. But this isn’t an explanation.

 6  Steps#

To explain how an algorithm works, I sometimes break it down into the steps of the execution and sometimes into the steps of the code. For a tutorial like my introduction to A*[6], I showed the execution. For line drawing, I want to show the steps of the code:

Since I’m going to have multiple diagrams, it’ll be useful to encapsulate all those global variables and functions into a diagram object.

class Diagram {
    constructor(containerId) {
        this.A = {x: 2, y: 2};
        this.B = {x: 20, y: 8};
        this.parent = d3.select(`#${containerId} svg`);
        this.gGrid = this.parent.append('g');
        this.gPoints = this.parent.append('g');
        this.gHandles = this.parent.append('g');

        this.drawGrid();
        this.makeDraggableCircle(this.A);
        this.makeDraggableCircle(this.B);
        this.update();
    }

    update() {
        let rects = this.gPoints.selectAll('rect')
            .data(pointsOnLine(this.A, this.B));
        rects.exit().remove();
        rects.enter().append('rect')
            .attr('width', scale-1)
            .attr('height', scale-1)
            .attr('fill', "hsl(0,40%,70%)")
            .merge(rects)
            .attr('transform', (p) => `translate(${p.x*scale}, ${p.y*scale})`);
    }

    drawGrid() {
        for (let x = 0; x < 25; x++) {
            for (let y = 0; y < 10; y++) {
                this.gGrid.append('rect')
                    .attr('transform', `translate(${x*scale}, ${y*scale})`)
                    .attr('width', scale)
                    .attr('height', scale)
                    .attr('fill', "white")
                    .attr('stroke', "gray");
            }
        }
    }

    makeDraggableCircle(P) {
        let diagram = this;
        let circle = this.gHandles.append('circle')
            .attr('class', "draggable")
            .attr('r', scale*0.75)
            .attr('fill', "hsl(0,50%,50%)")
            .call(d3.drag().on('drag', onDrag));

        function updatePosition() {
            circle.attr('transform',
                        `translate(${(P.x+0.5)*scale} ${(P.y+0.5)*scale})`);
        }
        
        function onDrag() {
            P.x = Math.floor(d3.event.x / scale);
            P.y = Math.floor(d3.event.y / scale);
            updatePosition();
            diagram.update();
        }

        updatePosition();
    }
    
}


let diagram = new Diagram('demo');

A pattern is starting to form, but I haven’t made use of it yet. There’s a <g> for each visual layer:

Each of these layers has some code to draw it initially and sometimes some code to update it. As I add more layers to the diagram I’ll do something better with the draw and update code.

 7  Linear interpolation of numbers#

In this section I don’t actually have a diagram, but I do have some interaction, so I’m going to use a diagram object anyway without an svg. I want to drag a number left and right to change it, and see how it affects some calculations. Take a look at Bret Victor’s Tangle library[7] for inspiration. You might want to use his library directly. For this page I’m using d3-drag[8] instead.

How do I want this to work?

There are other things that you may want for scrubbable numbers but these are all I need for this tutorial. Within a named <div> section I’ll find all the <span data-name="XYZ"> and turn them into scrubable numbers stored in field XYZ of the diagram object.

Scrubbable numbers are cool but a little bit tricky. I’m using d3-drag to tell me how far left/right the mouse was dragged. Then I scale the relative mouse position from -100 pixels to +100 pixels to the desired low–high range, using a linear scaling (see positionToValue):

    makeScrubbableNumber(name, low, high, precision) {
        let diagram = this;
        let elements = diagram.root.selectAll(`[data-name='${name}']`);
        let positionToValue = d3.scaleLinear()
            .clamp(true)
            .domain([-100, +100])
            .range([low, high]);

        function updateNumbers() {
            elements.text(() => {
                let format = `.${precision}f`;
                return d3.format(format)(diagram[name]);
            });
        }

        updateNumbers();

        elements.call(d3.drag()
                      .subject(() => ({x: positionToValue.invert(diagram[name]), y: 0}))
                      .on('drag', () => {
                          diagram[name] = positionToValue(d3.event.x);
                          updateNumbers();
                          diagram.update();
                      }));
    }

When the reader drags the number, I update the display of the number, and I also call the diagram’s update function to update any other aspect of the diagram:

        let t = this.t;
        function set(id, fmt, lo, hi) {
            d3.select(id).text(d3.format(fmt)(lerp(lo, hi, t)));
        }
        set("#lerp1", ".2f", 0, 1);
        set("#lerp2", ".0f", 0, 100);
        set("#lerp3", ".1f", 3, 5);

I set the CSS to cursor:col-resize so that the reader can see it’s interactive.

This “diagram” doesn’t really fit the model that the rest of the diagrams use so it is a bit hacky. That’s ok though. Sometimes it’s easier to keep the hack than to try to build a general abstraction that’s only used once.

 8  Linear interpolation of points#

Using linear interpolation of numbers, I want to display the linear interpolation of points. I want a diagram that lets you modify 0 ≤ t ≤ 1, and shows the resulting point. Until now I had the final algorithm written in pointsOnLine. To split up the diagrams I also need to split the line drawing algorithm into separate steps.

function lerp(start, end, t) {
    return start + t * (end-start);
}

function lerpPoint(P, Q, t) {
    return {x: lerp(P.x, Q.x, t),
            y: lerp(P.y, Q.y, t)};
}

I’m starting to organize things in terms of layers, which have the creation code and the update code:

    addTrack() {
        this.gTrack = this.parent.append('line')
            .attr('fill', "none")
            .attr('stroke', "gray")
            .attr('stroke-width', 3);
    }

    updateTrack() {
        this.gTrack
            .attr('x1', (this.A.x + 0.5) * scale)
            .attr('y1', (this.A.y + 0.5) * scale)
            .attr('x2', (this.B.x + 0.5) * scale)
            .attr('y2', (this.B.y + 0.5) * scale);
    }
    addInterpolated() {
        this.gInterpolated = this.parent.append('circle')
            .attr('fill', "hsl(0,30%,50%)")
            .attr('r', 5);
    }
        
    updateInterpolated() {
        let interpolated = lerpPoint(this.A, this.B, this.t);
        this.gInterpolated
            .attr('cx', (interpolated.x + 0.5) * scale)
            .attr('cy', (interpolated.y + 0.5) * scale);
    }

Note that this diagram does not show the line drawn on a grid. That’s another reason I want to use diagram layers for this page. While working on this diagram I commented out the code for drawing the line.

 9  Layers#

There are now two diagrams on the page. Both display the grid. The first diagram displays the grid line. The second diagram shows a non-grid line and also the interpolated point. Well, that’s what should happen, but I broke the first diagram while making the second one work. There will be more diagrams soon. I need a way to make all of them work.

When I’m writing a tutorial that requires multiple diagrams, each with different features, I like to divide the diagrams into layers, and then stack them on top of each other. There are four layers in the previous diagram: grid, track, interpolation point, and drag handle. Click “Show layers” to see them:

I’ll create each layer by a method that adds a <g> for a layer, then adds its update function to the diagram object. Here’s the code for managing the update functions:

    onUpdate(f) {
        this._updateFunctions.push(f);
        this.update();
    }

    update() {
        this._updateFunctions.forEach((f) => f());
    }

I no longer need to put the <g> elements into fields in the diagram object (e.g. gGrid, gHandles, etc.); they can remain local variables in the add functions. Look at addTrack() now:

    addTrack() {
        let g = this.parent.append('g');
        let line = g.append('line')
            .attr('fill', "none")
            .attr('stroke', "gray")
            .attr('stroke-width', 3);
        this.onUpdate(() => {
            line
                .attr('x1', (this.A.x + 0.5) * scale)
                .attr('y1', (this.A.y + 0.5) * scale)
                .attr('x2', (this.B.x + 0.5) * scale)
                .attr('y2', (this.B.y + 0.5) * scale);
        });
        return this;
    }

I can now assemble a diagram by calling the addXYZ() functions:

let diagram3 = new Diagram('interpolate-t')
    .addGrid()
    .addTrack()
    .addInterpolated(0.5)
    .addHandles();

I don’t have a generic layer system that I use across my tutorials. I make one specific for each tutorial that needs it. Each tutorial’s needs have been different. The one here is only 8 lines of code; it’s not worth writing a separate library for that.

 10  Number of steps in the line#

The third diagram has yet a different visualization layer, but it will be easier to implement now that I have layers. Until now I had the line drawing algorithm choose N. I need to separate that out too.

function interpolationPoints(P, Q, N) {
    let points = [];
    for (let i = 0; i <= N; i++) {
        let t = i / N;
        points.push(lerpPoint(P, Q, t));
    }
    return points;
}

The drawing code turns out to be similar to the previous case so I made it read either t or N:

    addInterpolated(t, N) {
        this.t = t;
        this.N = N;
        this.makeScrubbableNumber('t', 0.0, 1.0, 2);
        this.makeScrubbableNumber('N', 1, 30, 0);
        let g = this.parent.append('g');
        this.onUpdate(() => {
            let points = this.t != null? [lerpPoint(this.A, this.B, this.t)]
                : this.N != null? interpolationPoints(this.A, this.B, this.N)
                : [];
            let circles = g.selectAll("circle").data(points);
            circles.exit().remove();
            circles.enter().append('circle')
                .attr('fill', "hsl(0,30%,50%)")
                .attr('r', 5)
                .merge(circles)
                .attr('transform',
                   (p) => `translate(${(p.x+0.5)*scale}, ${(p.y+0.5)*scale})`);
        });
        return this;
    }

I also need to extend my scrubbable number implementation. Previously, I wanted floating point values but here I want it to round to an integer. An easy way to implement this is to parseFloat the formatted output; that way it works no matter how many digits I’m rounding to. See the formatter in makeScrubbableNumber().

The labels are another layer:

    addInterpolationLabels() {
        // only works if we already have called addInterpolated()
        let g = this.parent.append('g');
        this.onUpdate(() => {
            let points = interpolationPoints(this.A, this.B, this.N);
            var offset = Math.abs(this.B.y - this.A.y)
                       > Math.abs(this.B.x - this.A.x)
                ? {x: 0.8 * scale, y: 0} : {x: 0, y: -0.8 * scale};
            let labels = g.selectAll("text").data(points);
            labels.exit().remove();
            labels.enter().append('text')
                .attr('text-anchor', "middle")
                .text((p, i) => i)
                .merge(labels)
                .attr('transform',
                      (p) => `translate(${p.x*scale},${p.y*scale}) 
                              translate(${offset.x},${offset.y}) 
                              translate(${0.5*scale},${0.75*scale})`);
        });
        return this;
    }

The labels are overlapping the drag handles. I’ll fix this soon.

 11  Snap to grid#

We already know how to round numbers to the nearest integer. To snap points to the grid we can round both the x and y values.

function roundPoint(P) {
    return {x: Math.round(P.x), y: Math.round(P.y) };
}

I drew this on another layer.

I also want to tell people when they’ve reached the optimal N, which is max(Δx,Δy).

This diagram has all the essential components but it could look nicer.

 12  Finishing touches#

Although I have the essentials working, I usually spend some time making the diagrams look good and feel good.

There are lots more ways to make the page better but these will give a lot of benefit for little effort.

 13  Putting it all together#

Here’s what the diagrams look like with the nicer styling.

Click the “13/index.html” link to see the full page and the “source” link to see the source code. The source code for the example tutorial is public domain, so please copy/fork it and use it to make your own tutorials!

This is my first full tutorial about making tutorials. Feedback? Comment below, or comment on the trello card[9].

Email me , or tweet @redblobgames, or comment: