A cartesian graph

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:

Layout concepts and terminology

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.

Margin region
Plot region
a point

Graph structure

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,
    }
}

Displaying a Graph using D3

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&lt;d3.NumberValue&gt; {
    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&lt;d3.NumberValue&gt; {
    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&lt;HTMLDivElement&gt;('#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.