How to draw a smooth curve chart
Some of us need to draw charts from time to time. Usually you have little choice, you either use a bar chart:

or a polygon chart:

In this post I will describe how to draw a pretty smooth curve chart using python and cairo. You can adapt this routine to be used with django, pycha or any other image creation library, code snippets in comments are appreciated.
The main problem is, even if your image creation library has curve drawing functions (Bézier splines), those functions need some control points which DO NOT reside on the curve. So how do those points affect the curve? Let’s look at the example (it may seem complex, but it’s not, I just tried to make it more readable and easier to understand):
import cairo
from math import pi
def draw_point(x, y):
cr.move_to(x + 2, y)
cr.arc(x, y, 2, 0, 2 * pi)
cr.set_source_rgba(0, 0, 0, 1)
cr.stroke()
surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, 100, 100)
cr = cairo.Context(surface)
cr.set_line_width(2)
x0, y0 = 5, 5 # starting point
x3, y3 = 95, 95 # end point
x1, y1 = 95, 5 # control point 1
x2, y2 = 5, 95 # control point 2
"""let's draw all the points to see everything clearly"""
draw_point(x0, y0)
draw_point(x1, y1)
draw_point(x2, y2)
draw_point(x3, y3)
"""the line from starting point to control point 1"""
cr.move_to(x0, y0)
cr.line_to(x1, y1)
cr.set_source_rgba(0, 0, 0, 0.1)
cr.stroke()
"""the line from starting point to control point 2"""
cr.move_to(x3, y3)
cr.line_to(x2, y2)
cr.set_source_rgba(0, 0, 0, 0.1)
cr.stroke()
"""the curve itself"""
cr.move_to(x0, y0)
cr.curve_to(x1, y1, x2, y2, x3, y3)
cr.set_line_width(5)
cr.set_source_rgba(0, 0, 1, 1)
cr.stroke()
surface.write_to_png('curve.png')
So we are going to draw a curve from upper left corner down to right lower, and control points are in upper right and down left respectively. That will look like this:

You can clearly see that the lines between control and starting and end points are tangents to the curve, and the farther the control point gets from the starting or end point, the more “curvy” it becomes:


And that leads us to an obvious solution. We need to connect each two points of the chart with curves, and the tangents in those points must be the same from both sides, otherwise the curve won’t be smooth. If we don’t look ahead and don’t look back on the curve, we must make those tangents horizontal. So the first control point will be a little on the left from the starting point and the second one little on the right from the ending point, but on the same vertical position. Here is the code with some debug functions letting us see the control points and tangents, we’ll comment them out later:
import cairo
from math import pi, sqrt
width = 500
height = 100
graph_data = [
(0, 10),(20, 50),(40, 80),(60, 5),(80, 10),(100, 20),
(120, 30),(140, 60),(160, 95),(180, 30),(200, 50),
(220, 70),(240, 80),(260, 10),(280, 60),(300, 30),
(320, 90),(340, 95),(360, 30),(380, 10),(400, 5),
(420, 20),(440, 80),(460, 70),(480, 20),(500, 40)
]
def prepare_curve_data(graph_data):
prepared_data = []
for i in range(0, len(graph_data)):
x, y = graph_data[i][0], graph_data[i][1]
if i == 0:
cx1, cy1 = x, y
else:
step_x = x - graph_data[i - 1][0]
cx1, cy1 = x - step_x/2, y
if i == len(graph_data) - 1:
cx2, cy2 = x, y
else:
step_x = graph_data[i + 1][0] - x
cx2, cy2 = x + step_x/2, y
prepared_data.append((x, y, cx1, cy1, cx2, cy2))
return prepared_data
def draw_point(x, y, opacity):
cr.move_to(x + 2, y)
cr.arc(x, y, 2, 0, 2 * pi)
cr.set_source_rgba(0, 0, 0, opacity)
cr.stroke()
def debug_points(cr, prepared_data):
for i in range(0, len(prepared_data)):
x, y = prepared_data[i][0], prepared_data[i][1]
cx1, cy1 = prepared_data[i][2], prepared_data[i][3]
cx2, cy2 = prepared_data[i][4], prepared_data[i][5]
draw_point(x, y, 1)
if cx1 != x or cy1 != y:
draw_point(cx1, cy1, 0.3)
cr.move_to(x, y)
cr.line_to(cx1, cy1)
cr.set_source_rgba(0, 0, 0, 0.1)
cr.stroke()
if cx2 != x or cy2 != y:
draw_point(cx2, cy2, 0.3)
cr.move_to(x, y)
cr.line_to(cx2, cy2)
cr.set_source_rgba(0, 0, 0, 0.1)
cr.stroke()
def poly_curve(cr, prepared_data):
for i in range(0, len(prepared_data) - 1):
x, y = prepared_data[i][0], prepared_data[i][1]
cx1, cy1 = prepared_data[i][4], prepared_data[i][5]
cx2, cy2 = prepared_data[i + 1][2], prepared_data[i + 1][3]
x2, y2 = prepared_data[i + 1][0], prepared_data[i + 1][1]
cr.move_to(x, y)
cr.curve_to(cx1, cy1, cx2, cy2, x2, y2)
surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height)
cr = cairo.Context(surface)
cr.set_line_width(2)
prepared_data = prepare_curve_data(graph_data)
debug_points(cr, prepared_data)
poly_curve(cr, prepared_data)
cr.set_source_rgba(0, 0, 0, 1)
cr.stroke()
surface.write_to_png('curve.png')
And this is the result:

It is quite satisfactory, but it has some flaws. In the areas where the curve must go up or down steadily, we see some kind of bumps. It would be right to lean the tangents on those sections, so the control points would reside on the line parallel to the line connecting previous and next points. To make this more clear, I’ll rewrite the prepare_curve_data() and show you the result:
def prepare_curve_data(graph_data):
prepared_data = []
for i in range(0, len(graph_data)):
x, y = graph_data[i][0], graph_data[i][1]
if (i != 0) and (i != len(graph_data) - 1):
x_left, y_left = graph_data[i - 1][0], graph_data[i - 1][1]
x_right, y_right = graph_data[i + 1][0], graph_data[i + 1][1]
step_x_left = (x - x_left) / 2
step_x_right = (x_right - x) / 2
dx, dy = x_right - x_left, y_right - y_left
h = sqrt(dx*dx + dy*dy)
if h == 0:
cx1, cy1, cx2, cy2 = x, y, x, y
else:
dx1, dy1 = (dx * step_x_left) / h, (dy * step_x_left) / h
dx2, dy2 = (dx * step_x_right) / h, (dy * step_x_right) / h
cx1, cx2 = x - dx1, x + dx2
cy1, cy2 = y - dy1, y + dy2
else:
cx1, cy1, cx2, cy2 = x, y, x, y
prepared_data.append((x, y, cx1, cy1, cx2, cy2))
return prepared_data

And finally: the snippet ready to be put in your code:
import cairo
from math import pi, sqrt
width = 500
height = 100
graph_data = [
(0, 10),(20, 50),(40, 80),(60, 5),(80, 10),(100, 20),
(120, 30),(140, 60),(160, 95),(180, 30),(200, 50),
(220, 70),(240, 80),(260, 10),(280, 60),(300, 30),
(320, 90),(340, 95),(360, 30),(380, 10),(400, 5),
(420, 20),(440, 80),(460, 70),(480, 20),(500, 40)
]
def poly_curve(cr, graph_data):
prepared_data = []
for i in range(0, len(graph_data)):
x, y = graph_data[i][0], graph_data[i][1]
if (i != 0) and (i != len(graph_data) - 1):
x_left, y_left = graph_data[i - 1][0], graph_data[i - 1][1]
x_right, y_right = graph_data[i + 1][0], graph_data[i + 1][1]
step_x_left = (x - x_left) / 2
step_x_right = (x_right - x) / 2
dx, dy = x_right - x_left, y_right - y_left
h = sqrt(dx*dx + dy*dy)
if h == 0:
cx1, cy1, cx2, cy2 = x, y, x, y
else:
dx1, dy1 = (dx * step_x_left) / h, (dy * step_x_left) / h
dx2, dy2 = (dx * step_x_right) / h, (dy * step_x_right) / h
cx1, cx2 = x - dx1, x + dx2
cy1, cy2 = y - dy1, y + dy2
else:
cx1, cy1, cx2, cy2 = x, y, x, y
prepared_data.append((x, y, cx1, cy1, cx2, cy2))
for i in range(0, len(prepared_data) - 1):
x, y = prepared_data[i][0], prepared_data[i][1]
cx1, cy1 = prepared_data[i][4], prepared_data[i][5]
cx2, cy2 = prepared_data[i + 1][2], prepared_data[i + 1][3]
x2, y2 = prepared_data[i + 1][0], prepared_data[i + 1][1]
cr.move_to(x, y)
cr.curve_to(cx1, cy1, cx2, cy2, x2, y2)
surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height)
cr = cairo.Context(surface)
cr.set_line_width(2)
poly_curve(cr, graph_data)
cr.set_source_rgba(0, 0, 0, 1)
cr.stroke()
surface.write_to_png('curve.png')
And the result:

And now compare this to the polygon chart:

Neat, eh?
References and things to read:
Cairo — vector graphics library: http://cairographics.org/
Cairo for Python (pycairo): http://www.cairographics.org/pycairo/
Nice pycairo tutorial: http://www.tortall.net/mu/wiki/CairoTutorial
Great post mate.
Too bad I only found your post now, as I struggled with these tangents for some time some months ago. I’m glad we got to the same conclusions though