In this article we will illustrate how to create a cartesian graph with gridlines using D3 and typescript. This is part of a small series which culminates in the creation of a PeaucellierāLipkin linkage. An example of final graph we create is shown below:
The whole diagram is referred to as a graph. It is composed of different regions. Within the graph is the plot which is surrounded by margins. The axes are drawn in the margin region. Points, lines and other shapes can be drawn within the plot.
To implement this, we first create a couple of simple types to help describe the layout.
type Area = {
width: number,
height: number
}
type Margin = {
top: number,
bottom: number,
left: number,
right: number
}
type Point2 = {
x: number,
y: number
}
There is a necessary degree of coupling between the Graph
, PlotWindow
and Margin
regions.
We have chosen to construct Graph
and its regions with fixed dimensions set at the time of construction.
In a more versatile library, different layout modes could allow the regions to be sized using a different combinations of parameters.
For example, the size of the PlotWindow
and Graph
could be made to flexibly change in a responsive manner.
The PlotWindow
uses its own internal coordinate system which is referred to as
unit-coordinates. Each grid square corresponds to one unit. The bottom-left and top-right points of the
PlotWindow
determine which content is visible within the plot. This enables the origin of the
graph to be located at a point other than (0,0)
. The resolution
corresponds to the number of pixels per unit-length.
/**
* The PlotWindow is the region of the Graph which contains the plot.
* It uses its own internal (unit) coordinate system which is independent of the containing element.
*/
interface PlotWindow {
unitHeight(): number
unitWidth(): number
unitRangeX(): Array<number>
unitRangeY(): Array<number>
height(): number
width(): number
resolution(): number
}
class FixedSizePlotWindow implements PlotWindow {
_area: Area
_bottomLeft: Point2 // Unit coordinates
_resolution: number // The number of pixel per unit-length of the plot
constructor(area: Area, bottomLeft: Point2, resolution: number) {
this._area = area
this._resolution = resolution
this._bottomLeft = bottomLeft
}
/** The number of plot units shown vertically. */
public unitHeight(): number {
return this._area.height / this._resolution
}
/** The number of plot units shown horizontally. */
public unitWidth(): number {
return this._area.width / this._resolution
}
/** The height of the plot in pixels. */
public height(): number {
return this._area.height
}
/** The width of the plot in pixels. */
public width(): number {
return this._area.width
}
public unitRangeX(): [number, number] {
let min = this._bottomLeft.x
let max = this._bottomLeft.x + this._area.width / this._resolution
return [min, max]
}
public unitRangeY(): [number, number] {
let min = this._bottomLeft.y
let max = this._bottomLeft.y + this._area.height / this._resolution
return [min, max]
}
public resolution(): number {
return this._resolution
}
}
The dimensions of the plot region are calculated as follows:
function plotArea(graphDimensions: Area, margin: Margin): Area {
return {
width: graphDimensions.width - margin.left - margin.right,
height: graphDimensions.height - margin.top - margin.bottom
}
}
The GraphConfig
encapsulate the PlotWindow
and Margin
regions. A helper function createGraphConfig
is used to construct it.
On construction, the plot dimensions are resized to be an integer number of units (it looks better).
The size of the margins are adjusted (via recalculateMargins
) to ensure
that the PlotWindow
and Margin
regions sum to the same area as the parent Graph
. The size of the margin.top
and
margin.right
are increased because this maintains the original alignment while correcting for any distortion.
Without this adjustment, squares would not be square, and circles would be squeezed into ovals which would be a problem.
type GraphConfig = {
width: number,
height: number,
margin: Margin,
plot: PlotWindow
}
/**
* Create the configuration for a new graph.
*
* @param width The width (in pixels) of the diagram
* @param height The height (in pixels) of the diagram
* @param resolution The width (and height) of each unit square in pixels
* @param margin The (minimum) internal margins of the diagram. The right / top margins are increased to allow
* for an integer number of squares.
*/
function createGraphConfig(width: number, height: number, resolution: number, margin: Margin): GraphConfig {
let graphDimensions = {width, height}
let dimensions = plotArea(graphDimensions, margin)
// Shrink the plot area to be an integer number of units - it looks better
dimensions = {
width: Math.trunc(dimensions.width / resolution) * resolution,
height: Math.trunc(dimensions.height / resolution) * resolution
}
let bottomLeftCoords = {x: 0, y: 0}
let plotWindow = new FixedSizePlotWindow(dimensions, bottomLeftCoords, resolution)
// Grow the margins to offset the change in plot dimensions
let newMargin = recalculateMargins(graphDimensions, plotWindow, margin)
return {
width,
height,
margin: newMargin,
plot: plotWindow,
}
}
function recalculateMargins(graphArea: Area, plot: PlotConfig, margin: Margin): Margin {
return {
...margin,
top: graphArea.height - plot.height() - margin.bottom,
right: graphArea.width - plot.width() - margin.left
}
}
At this point we have covered how the size of the different regions are determined.
The Graph
type extends this with an SVGSVGElement
for drawing the graph.
It also includes methods for converting back and forth between unit-coordinates used within the plot
and pixel-coordinates used by the containing SVG element. These will be helpful to enable user interaction with the mouse.
type Graph = GraphConfig & {
svg: SVGSVGElement
/**
* Transform the coordinates of a point within the plot area to the pixel location on the Graph.
*
* @param point The coordinates of a point in plot-coordinates
* @return The pixel location of this point on the SVG
*/
toPixelCoords: (point: Point2) => Point2
/**
* Transform a pixel location on the Graph to coordinates within the plot area.
*
* @param point The pixel location of this point on the SVG
* @return The coordinates of a point in plot-coordinates
*/
fromPixelCoords: (point: Point2) => Point2
}
function toPixelCoords(context: Graph, point: Point2): Point2 {
let px = context.margin.left + point.x * context.plot.resolution();
let py = context.height - context.margin.bottom - point.y * context.plot.resolution();
return {x: px, y: py}
}
function fromPixelCoords(context: Graph, point: Point2): Point2 {
let ux = (point.x - context.margin.left) / context.plot.resolution();
let uy = (-point.y + context.height - context.margin.bottom) / context.plot.resolution();
return {x: ux, y: uy}
}
The createGraph
function is the primary way in which a new Graph
is instantiated.
It takes in a couple of parameters - width, height, margins, and resolution and calculates the rest.
We have chosen to hard-code the bottomLeftCoords
as (0,0)
for expediency.
This could be easily changed in the future to allow the focus of the PlotWindow
to be configurable.
const DEFAULT_MARGIN: Margin = {
top: 20,
bottom: 20,
left: 30,
right: 30
}
function createGraph(width: number, height: number, resolution: number = 36, margin: Margin = DEFAULT_MARGIN): Graph {
let config = createGraphConfig(width, height, resolution, margin)
let svg = createSVG(config)
return {
...config,
svg,
toPixelCoords(point: Point2) {
return toPixelCoords(this, point)
},
fromPixelCoords(point: Point2) {
return fromPixelCoords(this, point)
}
}
}
function createGraphConfig(width: number, height: number, resolution: number, margin: Margin): GraphConfig {
let graphDimensions = {width, height}
let plot = plotArea(graphDimensions, margin)
let bottomLeftCoords = {x: 0, y: 0}
let plotConfig = createPlotConfig(bottomLeftCoords, resolution, plot)
let newMargin = recalculateMargins(graphDimensions, plotConfig, margin)
return {
width,
height,
margin: newMargin,
plot: plotConfig,
}
}
We are almost ready to draw a Graph
.
The xAxis
and yAxis
functions are used to construct both the axes and also the grid lines.
They perform a linear mapping between the unit-coordinates of the plot and the pixel-coordinates.
They also ensure that the plot area is constrained within the margins.
It may seem strange at first, the tickSize
is set to the negative plot height to create the grid-lines.
The createSVG
function creates a graph of the specified dimensions.
The heavy lifting for the creating the x-axis is done by the xAxis
function and
the y-axis is constructed similarly. The transforms shown below are used to place the axes in the correct
locations.
function xAxis(config: GraphConfig): d3.Axis<d3.NumberValue> {
return d3.axisBottom(d3.scaleLinear()
.domain([config.plot.min.x, config.plot.max.x])
.range([config.margin.left, config.width - config.margin.right])
).ticks(config.plot.unitWidth())
}
function yAxis(config: GraphConfig): d3.Axis<d3.NumberValue> {
return d3.axisLeft(d3.scaleLinear()
.domain([config.plot.min.y, config.plot.max.y])
.range([config.height - config.margin.bottom, config.margin.top])
).ticks(config.plot.unitHeight())
}
function createSVG(config: GraphConfig): SVGSVGElement {
const svg = d3.create("svg")
.attr("width", config.width)
.attr("height", config.height);
// Add the x-axis.
svg.append("g")
.attr("transform", `translate(0,${config.height - config.margin.bottom})`)
.call(xAxis(config))
// Add the y-axis.
svg.append("g")
.attr("transform", `translate(${config.margin.left},0)`)
.call(yAxis(config))
// Add the y-gridlines
svg.append("g")
.attr("class", "grid")
.attr("transform", `translate(0, ${config.height - config.margin.bottom})`)
.call(xAxis(config)
// A tick is usually defaults to 6px. We extend them the full height of the plot to create the grid-lines
.tickSize(-config.plot.height())
.tickFormat(() => "")
)
// Add the x-gridlines.
svg.append("g")
.attr("class", "grid")
.attr("transform", `translate(${config.margin.left}, 0)`)
.call(yAxis(config)
// A tick is usually defaults to 6px. We extend them the full width of the plot to create the grid-lines
.tickSize(-config.plot.width())
.tickFormat(() => "")
)
return svg.node()!
}
And that is that! We can now create a new graph as follows:
let diagram = createGraph(640, 400)
let example_01 = document.querySelector<HTMLDivElement>('#graph_example')!
example_01.append(diagram.svg);
The source this grid can be found at D3 Grid Example . The next article in the series focuses on drawings points, a lines and adding a rotation handler to allow interaction using the mouse.