Drawing SVG Graphs with Rust

Using tera to draw SVG graphs with rust
rust lorikeet 2020-05-22

I have just released the first version of lorikeet-dash and part of that exercise was to find out a way to draw SVG graphs using rust.

I thought my findings & approach may be useful for others looking to do the same thing and so I have documented the design evolution in this blog.

Here's what the end result looks like in the lorikeet-dash project:

Defining the Graph

First we want to define what a graph is. A minimal example of what a graph would have is:

  • A List of Points
  • A Name/Description
  • A Colour for the line

So, in a rust struct, this would look like the following:

#[derive(Clone, Debug)]
pub struct Graph {
    pub name: String,
    pub points: Vec<Point>,
    pub colour: String
}

#[derive(Clone, Debug, Copy)]
pub struct Point {
    pub x: f64,
    pub y: f64,
}

Let's add some initial helper methods, one to create a new graph, and another to add points to it:

impl Graph {
    pub fn new(name: String, colour: String) -> Self {
        Graph {
            name,
            points: Vec::new(),
            colour,
        }
    }
    pub fn add_point(&mut self, x: f64, y: f64) {
        self.points.push(Point { x, y });
    }
}

SVG Template

An SVG image is an XML file, which means that we can use standard templating engines to generate SVG with some parameters. I am going to use tera in this example, as lorikeet already uses it. You could, however, use any template engine.

Let's start with a simple SVG template, of which just simply draws the graph name:

<?xml version="1.0" standalone="no"?>
<svg
  width="100%"
  height="100%"
  viewBox="0 0 {{height + padding * 2}} {{width + padding * 2}}"
  preserveAspectRatio="xMidYMid meet"
  xmlns="http://www.w3.org/2000/svg"
  >
  <text 
    x="{{width/2 + padding}}"
    y="{{padding / 2}}"
    font-family="-apple-system, system-ui, BlinkMacSystemFont, Roboto"
    dominant-baseline="middle"
    text-anchor="middle"
    font-size="18"
    fill="#74838f"
    font-weight="700"
    >
    {{name}}
  </text>
</svg>

Let's visit some decisions I have made here:

  • We are including a width & height for the viewbox. Having the SVG display at its natural resolution increases the clarity of the line, so we'll allow them to be adjusted when generating an SVG.
  • There is a padding value to include around the graph so that axis labels and the name of the graph will fit on the SVG. To make things simple this can be hardset to a value such as 50. We'll subtract that x 2 from the provided width/height.
  • We're using a big font-family list which should display roughly similar fonts depending on your OS.

With our tera template svg ready, we can use the include_str macro to have this compiled in:

Tera::one_off(include_str!("graph.svg"), &context, true).expect("Could not draw graph")

Drawing the Name

We'll add a draw_svg(&self, width: usize, height: usize) method to the Graph struct which should start by drawing the name as per the above template. The arguments are simply the width & height to draw. To ensure that the viewbox of the svg comes out the right size, we'll subtract the padding from both x 2.

To put it another way, the width and height become the width and height of the graph itself rather than the SVG.

pub fn draw_svg(&self, width: usize, height: usize) -> String {

    let mut context = Context::new();

    //hardset the padding around the graph
    let padding = 50;

    //ensure the viewbox is as per input
    let width = width - padding * 2;
    let height = height - padding * 2;

    context.insert("name", &self.name);
    context.insert("width", &width);
    context.insert("height", &height);
    context.insert("padding", &padding);

    Tera::one_off(include_str!("graph.svg"), &context, true).expect("Could not draw graph")

}

The Context here is what tera uses to provide variables to a template. Anything that implements serde::Serialize can be put into the context. At this moment we're providing a few strings and width/height parameters.

This can be run to spit out an SVG:

fn main() {
    let graph = Graph::new("Example".into(), "#8ff0a4".into());

    println!("{}", graph.draw_svg(800, 400));
}

Running this, should output the following SVG:

Example

Drawing the Points

Let's fill up the graph with some dummy points so we have something to draw (note: we are assuming you'll add the points in order here):

graph.add_point(1.0, 1.0);
graph.add_point(2.0, 3.0);
graph.add_point(3.0, 2.5);
graph.add_point(4.0, 6.0);
graph.add_point(5.0, 3.0);

Scaling the Axis

To keep things simple, we'll start from the origin & scale both the x and y axis by the max value of either. We'll pull out both of those values and use these to scale the points within the svg:

let max_x = self
  .points
  .iter()
  .map(|point| point.x)
  .fold(0. / 0., f64::max);

let max_y = self
  .points
  .iter()
  .map(|point| point.y)
  .fold(0. / 0., f64::max);

Drawing a Line

To draw a line between points in an SVG, you can use the path element with draw commands. A simple line would look like:

<path d="M10 10 L15 15"/>

That is: move to point 10,10 and then draw a line to point 15,15. These points are in the SVG space, so we will need to translate our graph points onto this space, keeping in mind padding, width and height and scaling.

Translating the points

We want to draw a line between each of the points in the SVG but scaled to fit within the viewport.

To add another complication, the chart origin is in the bottom left, whereas the SVG origin is the top left.

For both the x and y axis, we want to scale down based upon the max value (width and height here are the chart width):

x = x / max_x * width
y = y / max_y * height

For the x axis, we need to shift them along the right based upon padding, otherwise they will sit on the left side of the svg:

x = x / max_x * width + padding

For the y axis, we want to flip this based upon the height, to translate the origin, then add height + padding:

y = y / max_y * (height * -1.0) + height + padding

Iterating through

We have our points commands, now let's iterate through and convert them to a bunch of draw commands. First we will use M to move to start drawing, then we will use L to draw a line between each.

We can use enumerate() on the iterator to get whether this is the first or subsequent value, and then join it together as a big string:

let path = self
    .points
    .iter()
    .map(|val| Point {
        x: (val.x / max_x * width as f64) + padding as f64,
        y: (val.y / max_y * (height as f64 * -1.0)) + (padding + height) as f64,
    })
    .enumerate()
    .map(|(i, point)| {
        if i == 0 {
            format!("M {} {}", point.x, point.y)
        } else {
            format!("L {} {}", point.x, point.y)
        }
    })
    .collect::<Vec<String>>().join(" ");

This will output a path string like so:

M 190 383.33333333333337 L 330 250 L 470 283.3333333333333 L 610 50 L 750 250

Adding to the SVG

We can add the path & colour to the Context:

context.insert("path", &path);
context.insert("colour", &self.colour);

Then add the following to have it drawn in the SVG:

<path stroke="{{colour}}" stroke-linejoin="round" d="{{path}}" stroke-width="2.0" fill="none" />

If all goes well, the graph should start looking more like a graph:

Example

Adding in the Axis

We need to add in some axis scale so we know what the graph values represent. I'm gonna cheat here & break up both the x and y axis the same amount of times each. We'll break up the axis 5 times each, but have this set in the context to be changes later:

context.insert("lines", &5);

Tera allows us to do a loop within the template, so we'll use that to add some lines and nudges.

{% for i in range(end=(lines + 1)) %}
... draw some lines here
{% endfor %}

Let's set some variables within the template, based upon the current line we're drawing. These are the offsets for both the axis:

{% set offset_x = padding + loop.index0/lines * width%}
{% set offset_y = padding + loop.index0/lines * height%}

Horizontal Lines

We will draw 5 horizontal dashed lines, to give scale to the y value. This will start on the left side of the graph and continue to the end of the graph. We'll draw a solid horizontal line when y = 0 (keep in mind the axis is flipped in SVG), which means we skip drawing this for the last line:

{% if loop.last == false %}
<path stroke="#74838f" stroke-dasharray="10 6" stroke-width="0.5"  d="M {{padding}} {{offset_y}} L {{width + padding}} {{offset_y}}" />
{% else %}
<path stroke="#74838f" stroke-width="2" fill="none"  d="M {{padding}} {{offset_y}} L {{width + padding}} {{offset_y}}" />
{% endif %}

Now our graph will be a bit easier to read:

Example

Vertical Lines

We will just put little 10px nudges in the axis rather than dotted vertical lines.

In the existing for loop, we can add the following:

<path stroke="#74838f" stroke-width="2.0" d="M {{offset_x}} {{height + padding}} L {{offset_x}} {{height + padding + 10}}" />

This will create nudges where we'll put our axis labels:

Example

Axis Labels

We can add in axis labels in the same for loop.

For the y-axis, the loop starts at the top of the graph and goes down, so we will use the following formula to present the 1/5th value:

(lines - loop.index0)/lines * max_y

We can use the tera round helper to round the value up, so there is not a lot of decimal places:

{{((lines - loop.index0)/lines * max_y) | round}}

Putting this together:

<text
  x="{{padding - 5}}"
  font-family="-apple-system, system-ui, BlinkMacSystemFont, Roboto"
  y="{{offset_y}}"
  dominant-baseline="middle"
  text-anchor="end"
  font-size="12"
  fill="#74838f"
  font-weight="bold"
 >
 {{((lines - loop.index0)/lines * max_y) | round}}
</text>

And quite similarly, the x-axis can be generated the same way (width y being fixed to where the nudge starts & x being the offset):

<text
  x="{{offset_x}}"
  font-family="-apple-system, system-ui, BlinkMacSystemFont, Roboto"
  y="{{height + padding + 10}}"
  dominant-baseline="hanging"
  text-anchor="middle"
  font-size="12"
  fill="#74838f"
  font-weight="bold"
 >
 {{loop.index0/lines * max_x | round}}
</text>

Putting this all together, we should have our labels in the right place:

Example 6 0 5 1 4 2 2 3 1 4 0 5

Bonus: Smooth Lines

One trick I have found is that you can make simple smoth lines by treating the points as a Catmull-Rom spline, and then converting to Cubic Beziers, which SVG will draw happily.

Using this method, you can make your charts nice and smooth:

Example 6 0 5 1 4 2 2 3 1 4 0 5

Second Bonus: Scuba Dive Graph

I have also used a similar method to plot a dive, displaying the Depth and Air on a split axis, with my maximum depth displayed:

Conclusion

We used tera, as a template engine, to generate an SVG chart with rust. Some basic geometry was needed, but the results show for themselves. I hope you have found this educational and gives you some ideas.

Also: try out lorikeet-dash and let me know what you think!