A rotatable linkage

In this article we demonstrate how to create a linkage (line) which can be grabbed and rotated using the mouse. The focus here is to develop the foundations to enable user interaction with a Peaucellier–Lipkin linkage. It builds upon the previous example of Creating a Cartesian Graph. An example of final result is shown below:

Plotting some points

To begin, we need to represent points. The Vector2 class can be used for both points and as the name suggests, vectors. It provides a fluent API for performing calculations with them. Classes are sometimes avoided in JavaScript because JavaScript doesn't have true classes and instead uses a prototypical inheritance. The methods of Vector2 accept plain JavaScript objects as arguments. This simplifies many use-cases and alleviates some friction which can come from classes.

It is important to note that each Vector2 instance is immutable. This protects against accidental side effects when we perform calculations. It also means we avoid littering the code with defensive copies and generally makes any code more concise. The small library geometry-lib-2d provides the Vector2 class so it can be reused for this series. A snippet of the class is shown below:

/**
 * TVector2 is the simple type analogous to a Vector2.
 */
type TVector2 = {
    x: number;
    y: number;
}

class Vector2 {
    readonly x: number;
    readonly y: number;

    /**
     * Add this vector with another
     *
     * @param other A vector
     * @return a new Vector2
     */
    public add(other: TVector2): Vector2 {
        return new Vector2(this.x + other.x, this.y + other.y)
    }

    // ... other methods
}

The D3 framework uses references of the data passed to the join function to track state and update any diagrams. The PointRef class is a thin wrapper around a Vector2. By providing a wrapper, we are able to update the location of a point in a way which works naturally with how D3.js is designed. We can update the locations while retaining the original reference which is tracked by D3.

Using a mutable Vector2 instead of a PointRef was considered, but it would have resulted in poor ergonomics. To change the locations of a point, the x and y properties would need to be changed individually. Attempting to change the location by setting it to a new Vector2 would not be reflected on our plot because D3 instead would be tracking the original reference.

The LabeledPoint class has a field for a text annotation. The offsetX and offsetY properties are used to reposition the label on the diagram and correspond directly to properties found in D3. The simpler PointRef can be used in cases where a test annotation or label does not need to be displayed.

import {Vector2} from "@cheynewilson/geometry-lib-2d";

interface PointRef {
    location: Vector2
}

class LabeledPoint implements PointRef {
    location: Vector2
    text: string | null
    offsetX: string
    offsetY: string

    /**
     * Construct a point with an associated text label.
     *
     * @param x The x-coordinate of the point
     * @param y The y-coordinate of the point
     * @param text The label text
     * @param offsetX Offset the label along the x-axis. Used to set the 'dx' attribute. Defaults to ".2em"
     * @param offsetY Offset the label along the y-axis. Used to set the 'dy' attribute. Defaults to "-.2em"
     */
    constructor(x: number, y: number, text: string | null = null, offsetX: string = ".2em", offsetY: string = "-.2em") {
        this.location = new Vector2(x, y)
        this.text = text
        this.offsetX = offsetX
        this.offsetY = offsetY
    }
}

export {LabeledPoint, type PointRef}

A central design pattern of D3 is that it operates with collections of data. The drawPoints function takes in an array of points and draws to the Plot region of a Graph. The construction of Graph object can previous article here. The Plot regions uses its own internal coordinate system to position geometric objects such as lines and points. The fromPixelCoordsand toPixelCoords functions are used to convert from/to the pixel coordinates of the SVG. All the geometric calculations are done in unit-coordinates and these are converted back to pixel for display.

If we dive into the details of drawPoints function we see it calls d3.select(graph.svg) method, then selectAll('circle.point') uses a CSS selection to locate all the child circle elements within it which have the .point class. Initially, this will be an empty set.

New points are inserted by the chained selectAll, data and join method calls. This creates a new point for each element in the points array not already present. Labels are inserted to the Graph in a similar manner. See the D3 reference on joining for more info on how this works.

// Convenience function
function pixelCoords(graph: Graph, point: PointRef): TVector2 {
    return graph.toPixelCoords(point.location)
}

/**
 * Draw labeled points on a graph.
 *
 * @param graph The graph to draw to
 * @param points An array of points to draw
 */
function drawPoints(graph: Graph, points: LabeledPoint[]) {
    d3.select(graph.svg)
        .selectAll('circle.point')
        .data(points)
        .join(enter => enter.append('circle').attr("class", "point"))
        .attr('cx', d => pixelCoords(graph, d).x)
        .attr('cy', d => pixelCoords(graph, d).y)
        .attr('r', 0.1 * graph.plot.resolution())
        .attr('fill', '#000000');

    d3.select(graph.svg)
        .selectAll('text.label')
        .data(points)
        .join(enter => enter.append("text").attr("class", "label"))
        .attr("x", d => pixelCoords(graph, d).x)
        .attr("y", d => pixelCoords(graph, d).y)
        .attr("dx", d => d.offsetX)
        .attr("dy", d => d.offsetY)
        .text(d => d.text)
}

Below is an example of how we can draw a couple of points using what we have developed so far.

import {createGraph, Graph} from "d3-example-grid";
import {LabeledPoint} from "./modules/LabeledPoint.ts";
import {drawPoints} from "./modules/draw.ts";

let diagram: Graph = createGraph(640, 400)
let b = new LabeledPoint(5, 4, "b")
let c = new LabeledPoint(4, 7.5, "c")

drawPoints(diagram, [b, c])

let example = document.querySelector<HTMLDivElement>('#example_02')!
example.append(diagram.svg)

Which results in the following graph:

Drawing and rotating a line

The next step is to draw a line between the points and allow it to be rotated by grabbing it with the mouse. We start with defining the lightweight RotatableLine class — all it has is a constructor. This means that a simple object with the same properties can easily be used in its stead if that's your preference.

The classes property is included to support attaching arbitrary CSS classes for styling. It will be helpful later to allow customization of different lines. For example, to differentiate between rotatable and fixed linkages.

import {PointRef} from "./LabeledPoint.ts";

class RotatableLine {
    start: PointRef
    end: PointRef
    classes: string[]
    rotatable: boolean

    /**
     * Create a new line between two points
     *
     * @param start     The origin of the line
     * @param end       The destination of the line
     * @param classes   CSS classes to style the line with
     * @param rotatable When true, allow the line to be rotated around the start point
     */
    constructor(start: PointRef, end: PointRef, classes: string[] = [], rotatable: boolean = false) {
        this.start = start
        this.end = end
        this.classes = classes
        this.rotatable = rotatable
    }

}

export {RotatableLine}

The drawLines function is presented below. It can draw both rotatable and fixed lines. This function takes in an array of callbacks which are invoked when the line is moved. The rotationHandler is attached to each line which has the rotatableproperty. Each line is given the .line class to distinguish between these lines and the gridlines in the background of the plot.

The .attr("class", function(d, _, e) { ... }) method call appends additional classes to allow for custom styling specific to each line.

/**
 * Draw lines on a plot.
 *
 * If the line is rotatable, attach a rotationHandler to it.
 *
 * @param graph      The graph containing the plot to draw to
 * @param lines      An array of lines to draw
 * @param redraw     A callback to refresh other plot elements when the line is rotated
 */
function drawLines(graph: Graph, lines: RotatableLine[], redraw: () => void) {
    d3.select(graph.svg)
        .selectAll("line.line")
        .data(lines)
        .join(
            enter => enter.append("line").attr("class", "line")
        )
        .each((d, i, el) => {
            if (d.rotatable) {
                d3.select(el[i])
                    .classed("rotatable", true)
                    .call(rotationHandler(graph, redraw))
            }
        })
        // Append additional classes to style the elment
        .attr("class", function (d, _, e) {
            let existingClasses = d3.selectAll(e).attr("class").split(" ")
            existingClasses.push(...d.classes)
            return existingClasses.join(" ");
        })
        .attr("x1", d => pixelCoords(graph, d.start).x)
        .attr("y1", d => pixelCoords(graph, d.start).y)
        .attr("x2", d => pixelCoords(graph, d.end).x)
        .attr("y2", d => pixelCoords(graph, d.end).y);
}

The key styles used for the lines examples are shown below:

.rotatable {
    cursor: grab;
}

.line {
    stroke: #646cff;
    stroke-width: 3;
}

The rotationHandler attach is attached to each rotatable line to handle drag events. It "closes over" the Graph context to allow the transformation between pixel-coordinates and unit-coordinates. It also supports a redraw callback which can be used to trigger updates other elements affected by the rotations such as the points and labels connected to the end of the line.

The dragged method rotates the line to the location of the cursor. This is done by calculating the angle to the current location mouse and moving the end of the line accordingly. The dragStarted and dragEnded methods change the style to provide some visual feedback.

/**
 * Create a rotation handler for an SVGLineElement to handle when it is dragged
 *
 * @param graph     The Graph the element belongs to
 * @param redraw
 */
let rotationHandler = (graph: Graph, redraw: () => void) => {
        /**
         * Rotate this line element when it is dragged with the mouse
         *
         * @param event The mouse event
         * @param d     The line data
         */
        function dragged(this: SVGLineElement, event: MouseEvent, d: RotatableLine) {
            // Convert the grid location to plot coordinates
            let gripLocation = Vector2.from(graph.fromPixelCoords({x: event.x, y: event.y}))

            // Calculate the grip location relative to the start of the line
            let relativeGripLocation = gripLocation.subtract(d.start.location)

            // Calculate the angle and relative location of the new end point
            let newAngle = relativeGripLocation.angle()

            let length = Vector2.from(d.start.location).distanceTo(d.end.location)
            let relativeEnd = Vector2.fromPolar(length, newAngle)

            // Transform from relative back to absolute plot coordinates and update the original datum
            d.end.location = d.start.location.add(relativeEnd)

            // Convert back to pixel coords
            let p = pixelCoords(graph, d.end)

            // Change the end location of the SVGLineElement
            d3.select(this)
                .attr("x2", p.x)
                .attr("y2", p.y);

            // Propagate changes to callbacks
            redraw()
        }

        function dragStarted(this: Element) {
            d3.select(this).style("cursor", "grabbing");
        }

        function dragEnded(this: Element) {
            d3.select(this).style("cursor", null);
        }

        return <any>d3.drag()
            .on("start", dragStarted)
            .on("drag", <any>dragged)
            .on("end", dragEnded)
    }

With the new drawLines function in the example below you can now drag the line to rotate it. ...but it has a problem! The point c doesn't rotate with the line.

import {createGraph, Graph} from "d3-example-grid";

import {LabeledPoint} from "./modules/LabeledPoint.ts";
import {drawLines, drawPoints} from "./modules/draw.ts";

let diagram = createGraph(640, 400)

let b = new LabeledPoint(5, 4, "b")
let c = new LabeledPoint(4, 7.5, "c")

let bc = new RotatableLine(b, c, [], true)

drawLines(diagram, [bc], () => null)
// We call drawPoints after drawLines because it looks crisper
drawPoints(diagram, [b, c])

let example = document.querySelector<HTMLDivElement>('#example_03')!
example.append(diagram.svg)

Fixed Rotation Example

To fix this problem we declare the redraw function pass it through to the roationHandler. Now when the line is rotated it also triggers drawPoints to update the points displayed.

// .. imports

let diagram = createGraph(640, 400)
let b = new LabeledPoint(5, 4, "b")
let c = new LabeledPoint(4, 7.5, "c")
let bc = new RotatableLine(b, c, [], true)

const redraw = () => drawPoints(diagram, [b, c])

drawLines(diagram, [bc], redraw)
redraw()

let example = document.querySelector<HTMLDivElement>('#example_04')!
example.append(diagram.svg)

Adding an angle annotation

To improve the utility we can add annotate the size of the angle . The AngleAnnotation is used to represent the angle between two intersecting lines. The point of intersection is at the origin. Because there are two complementary angles, the angle is calculated from the start to the end point. A PointRef is used instead of a Vector2 this allows the AngleAnnotation to share the same data as the other objects. The annotation will be updated automatically when the line is rotated or otherwise moved.

import {LabeledPoint, PointRef} from "./LabeledPoint.ts";

/**
 * Used to drawing an annotation of the angle between two lines.
 */
class AngleAnnotation {
    origin: PointRef
    start: PointRef
    end: PointRef

    /**
     * Draw an annotation for an angle
     *
     * @param origin The center point of the annotations
     * @param start The starting point of the annotation curve
     * @param end The ending point of the annotation
     */
    constructor(origin: PointRef, start: PointRef, end: PointRef) {
        // We want an angle of 0 to be inline with the x-axis, and the angle to be expressed anticlockwise
        this.origin = origin
        this.start = start
        this.end = end
    }

    public angle(): number {
        let oa = this.start.location.subtract(this.origin.location)
        let oc = this.end.location.subtract(this.origin.location)
        return oa.angleBetween(oc)
    }

    public angleStart(): number {
        let oa = this.start.location.subtract(this.origin.location)
        return oa.angle()
    }

    public label(): LabeledPoint {
        // Degrees are more intuitive to display for many people
        let text = (this.angle() * 180 / Math.PI).toFixed(0) + "°";
        // The label is placed outside the semicircle radius
        let labelRadius = 0.7
        let x = this.origin.location.x + labelRadius * Math.cos(this.angle() / 2)
        let y = this.origin.location.y + labelRadius * Math.sin(this.angle() / 2)
        return new LabeledPoint(x, y, text, "0", "0")
    }
}

export {AngleAnnotation}

The draw function makes use of d3.arc to draw an angle symbol between lines.

/**
 * Draw the angle and angle symbol between two lines.
 *
 * @param graph       The graph to draw to
 * @param annotations The angle annotation data
 */
function drawAngles(graph: Graph, annotations: AngleAnnotation[]) {
    // Draw the angel arch
    d3.select(graph.svg)
        .selectAll("path.angle-arc")
        .data(annotations)
        .join(enter => enter.append("path").attr("class", "angle-arc"))
        .attr("transform", d => {
            let p = pixelCoords(graph, d.origin)
            return `translate(${p.x},${p.y})`
        })
        .attr("d", d => {
            let radius = 1.5
            return d3.arc()({
                innerRadius: radius * graph.plot.resolution,
                outerRadius: radius * graph.plot.resolution + 1,
                // We want an angle of 0  to be inline with the x-axis, and the angle to be expressed anticlockwise
                startAngle: d.angleStart() + Math.PI / 2,
                endAngle: -d.angle() + Math.PI / 2
            })
        })

    // Also draw the labels
    d3.select(graph.svg)
        .selectAll('text.angle')
        .data(annotations)
        .join(enter => enter.append("text").attr("class", "angle"))
        .attr("x", d => {
            let radius = 0.85 * graph.plot.resolution
            let midAngle = -(d.angle() - d.angleStart()) / 2
            return radius * Math.cos(midAngle) + pixelCoords(graph, d.origin).x
        })
        .attr("y", d => {
            let radius = 0.85 * graph.plot.resolution
            let midAngle = -(d.angle() - d.angleStart()) / 2
            return radius * Math.sin(midAngle) + pixelCoords(graph, d.origin).y
        })
        .text(d => d.label().text)
}

With a minor alteration, we can now include the angle as follows:

let diagram = createGraph(640, 400)
let b = new LabeledPoint(5, 4, "b")
let c = new LabeledPoint(4, 7.5, "c")

let bx = {
    location: b.location.add({x: 1, y: 0})
}

let cbx = new AngleAnnotation(b, bx, c)
let bc = new RotatableLine(b, c, [], true)

const redraw = () => {
    drawPoints(diagram, [b, c])
    drawAngles(diagram, [cbx])
}

drawLines(diagram, [bc], redraw)
redraw()

let example = document.querySelector<HTMLDivElement>('#example_05')!
example.append(diagram.svg)

Conclusion

We have illustrated how d3 can be used to create a line which we can grab and rotate with the mouse. One limitation is we cannot rotate the line by grabbing an end point. We will address this limitation in the next article in which we finally construct the Peaucellier–Lipkin linkage we have been working towards. You can find a copy of the code at D3 Rotation Example.