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:
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 fromPixelCoords
and 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:
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 rotatable
property.
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)
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)
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)
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.