Radial Charts API (Part Two)

Continuing the development of a project to provide an API to draw ‘stacked doughnut’ charts (see Part One for the story so far).

Can I Get A Title?

When we left off we had a working proof that could draw a series of arcs (assumed to be three) based on data passed in ‘JSON-like’ format. The next part of the task is to add labels to form the chart legend.

I want to be able to use a font of my choosing rather than rely on whatever default PIL/Pillow uses. In theory that’s fine — there’s a function to read in a font file — but because I can’t be absolutely certain (for the time being) what system I’ll be deploying to whilst testing, and therefore what fonts may be available, I need to worry about bringing fonts in through code.

Enter the Python fonts package, courtesy of Phil Howard(otherwise known as Gadgetoid) at Pimoroni. This provides a nice method for finding and importing fonts, and alongside it there are a number of open-source fonts that have been packaged up to also easily be importable through code.

To start with we need to install the fonts package and one font — I elected to use Roboto:

pip3 install fonts font-roboto

Now we need to import the font, and also amend our imports from PIL/Pillow to include ImageFont:

from PIL import Image, ImageDraw, ImageFont
from fonts.ttf import Roboto

Next we need to extend our 'data' so that it contains the names of the series

data = [
  {'percent': 82.0, 'color': 'purple', 'name': 'People'},
  {'percent': 75.0, 'color': '#00788a', 'name': 'Business'},
  {'percent': 80.0, 'color': 'gray', 'name': 'Future'}
]

The last thing we did in the previous post was to resize the image; because we've done that we also need to renew our ImageDraw.Draw object (otherwise it can't draw properly in the resized image).

d = ImageDraw.Image(img, mode="RGBA")

And we need to get the Roboto font as an object; I'm going to use a variable MF_SIZE to declare what size font we're using so that it's easy to change that later.

MF_SIZE = 45
font_main = ImageFont.truetype(Roboto, MF_SIZE)

And now we can cycle through the items in data and draw our labels. We need to understand how large each string will be (which we can get using the get_size() method) and use that to position the text boxes so that they're right-aligned:

for idx, item in enumerate(data):
  s = f"{item['name']} {int(item['percent'])}%"
  w, h = font_main.getsize(s)
  x = 480 - w
  y = ( ( LW + SP ) * idx + SP ) + (LW - h)/2
  d.text((x,y), s, fill=item['color'], font=font_main)

And now we have:

Radial chart with caption

Captions added!


The Central Thing

The image that we've been provided as a guide has a percentage in the centre of the image in vivid purple; the percentage bears no immediate relationship to the data, so for now we'll assume that it's going to be passed as another parameter in the JSON. This means changing the structure of our data a little:

data = {
  'items': [
    {'percent': 82.0, 'color': 'purple', 'name': 'People'},
    {'percent': 75.0, 'color': '#00788a', 'name': 'Business'},
    {'percent': 80.0, 'color': 'gray', 'name': 'Future'}
  ],
  'o_all' : 76.0
}

And as a consequence, where previously we've iterated over for idx, item in enumerate(data) we will now have to iterate for idx, item in enumerate(data['items']).

Adding the central percentage means creating a second font object, and again I'm using a variable (C_SIZE this time) to hold the font size.

C_SIZE = 200
font_cent = ImageFont.truetype(Roboto, C_SIZE)
s = f"{int(data['o_all'])}%"
w, h = font_cent.getsize(s)
x = 500 - w/2
y = 500 - h/2
d.text((x,y), s, fill='purple', font=font_cent)

Radial chart with central percentage

Centre percentage success!


The Story So Far

from PIL import Image, ImageDraw, ImageFont
from fonts.ttf import Roboto
img = Image.new("RGB", (4000,4000), color="white")

data = {
  'items': [
    {'percent': 82.0, 'color': 'purple', 'name': 'People'},
    {'percent': 75.0, 'color': '#00788a', 'name': 'Business'},
    {'percent': 80.0, 'color': 'gray', 'name': 'Future'}
  ],
  'o_all' : 76.0
}
LW = 60
SP = 10
d = ImageDraw.Draw(img, mode="RGBA")

for idx, item in enumerate(data['items']):
  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)

img = img.resize((1000,1000), resample=Image.LANCZOS)
d = ImageDraw.Draw(img, mode="RGBA")
MF_SIZE = 45
font_main = ImageFont.truetype(Roboto, MF_SIZE)

for idx, item in enumerate(data['items']):
  s = f"{item['name']} {int(item['percent'])}%"
  w, h = font_main.getsize(s)
  x = 480 - w
  y = ( ( LW + SP ) * idx + SP ) + (LW - h)/2
  d.text((x,y), s, fill=item['color'], font=font_main)

C_SIZE = 200
font_cent = ImageFont.truetype(Roboto, C_SIZE)
s = f"{int(data['o_all'])}%"
w, h = font_cent.getsize(s)
x = 500 - w/2
y = 500 - h/2
d.text((x,y), s, fill='purple', font=font_cent)

img.save('example.png')

Next Steps

Now we just need to add those pesky icons and we'll be set ...