Radial Charts API (Part One)
Posted on 02 August, 2020
So I have an account over on People Per Hour and came across a fun little project over the weekend. I'm waiting to see whether my proposal will be accepted, so there's a chance this will never be posted (!), but it was an interesting little challenge to solve.
Project Requirements
The requirements were:
- provide a server-based API (absolutely NOT a front-end) ...
- ... which generates 'stacked' doughnut charts ...
- ... with text overlays ...
- ... and returns an image URL that can then be embedded within a document
An example of the required output image was provided:
Example image provided
Split It Down
When faced with this kind of project, the key (I believe) is to break it down into component parts. For this project, the key parts are:
- be able to draw a series of doughnuts into an image
- be able to add text and icons
- be able to save the final image
- have an API that accepts information to generate these images (presumably a POST request with JSON providing the information) and returns a URL
- have an API that takes an image URL and returns that image
Draw The Doughnuts
So I'll be honest - I started down the route of using MatPlotLib to draw the doughnuts. There were
a few reasons for this:
- The project is about charting, so
MatPlotLibfelt like the right place to start - I've recently learned how to use
MatPlotLiband wanted to use that knowledge! - The option I had was to use
PIL/Pillow, but a quick test of drawing an arc showed that it would be horribly pixelated (PIL/Pillowdoesn't anti-alias its curves)
However, there are some downsides of using MatPlotLib. Take for instance the following code:
import matplotlib.pyplot as plt
import matplotlib.patches as patch
figure, axes = plt.subplots(figsize=(10,10))
arc1 = patch.Arc((0.5, 0.5), 0.92, 0.92, theta1=180.0, theta2=90.0, color='red', linewidth=40.0)
axes.add_artist(arc1)
axes.text(0.48, 0.96, "Example 75%", verticalalignment="center", horizontalalignment="right", fontsize=18.0, color='red', fontweight='bold')
plt.show()
Which generates this image:
Axes are a a problem
The problems with this are:
- chart axes are outside the plot area but within the overall image size — whilst we can hide the axes, they’ll still be impacting on image size
- not obvious, but there are very limited (none?) font options in
MatPlotLib - we will need to deal with
PIL/Pillowregardless, because (AFAIK) there’s no way to add the icons usingMatPlotLib
The first problem is the biggie — as soon as we start to use PIL/Pillow on top of MatPlotLib
(which means saving the image, whether on disk or in memory, and then reading it into a PIL.Image)
we hit problems over exact spacing of the arcs and therefore with lining up text/icons appropriately.
This irritated me to the point that I decided to give up on MatPlotLib and instead look to use
just PIL/Pillow.
The Problem With Pillow
The reason that I'd begged off using PIL/Pillow to draw the arcs in the first instance was that
they weren't being anti-aliased.
What's anti-aliasing? Well, it's complicated, but essentially when you examine a curve or ellipse at the pixel level you see jagged lines where a pixel is either part of the curve or part of the background — anti-aliasing is the process of 'fading' the edges so that when you see the whole image you don't see those jagged edges.
Example of anti-aliased circle
Turns out that an easy way to deal with this is to draw the arcs to a much larger scale than required, and then downscale — and then the module will anti-alias the downscaled image. I've decided to go over-board and draw my arcs at 4x the required size ...
Drawing Arcs With PIL
First off we need to import PIL/Pillow and get an appropriate-sized image; I want to end up with
something that's 1000x1000 after I've downsized it ...
from PIL import Image, ImageDraw
img = Image.new("RGB", (4000,4000), color="white")
Now we want to draw our three arcs; for the sake of ease (and because I want this to work with JSON
later on) I'm going to define some data to draw, and define two constants LW (linewidth) and SP
(spacing). Lastly we need an ImageDraw object that actually does the drawing ...
data = [
{'percent': 82.0, 'color': 'purple'},
{'percent': 75.0, 'color': '#00788a'},
{'percent': 80.0, 'color': 'gray'}
]
LW = 60
SP = 10
d = ImageDraw.Draw(img, mode="RGBA")
And now to draw the arcs; PIL/Pillow defines it's arcs by the 'bounding box' that they're drawn
within, and then the start/end angles of the arc with 0deg pointing 'east' drawing clockwise; since
we want an arc that starts at 'north' we have to start from -90deg ...
for idx, item in enumerate(data):
offset = 4 * ( ( LW + SP ) * idx + SP )
box = (offset, offset, 4000 - offset, 4000 - offset)
deg = int(360 * item['percent']/100) - 90
d.arc(box, -90, deg, fill=item['color'], width=4*LW)
And lastly we need to downsize the image from 4000x4000 to 1000x1000 —the LANCZOS resample
parameter is the best one for anti-aliasing — and save it.
img = img.resize((1000,1000), resample=Image.LANCZOS)
img.save('example.png')
And the resulting image ...
Three anti-aliased arcs!
Next time we'll look at how we can add text and icons ...
Posted in code, matplotlob, projects, python