Radial Charts API (Part Four)

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

It's been a few weeks since I posted an offer for this project, I've heard nothing and the project deadline is very long-gone, so posting more of my code!

Functionally Mute

At the moment our code is written to run once, on a static set of data, when the code is called. We need to instead turn it into a function which is called with a data parameter.

We also need to think about what the final API is going to look like; from the specification, the intention is that we can POST to the API with some data; this generates an image and returns a link to that image. So our function should save an image and return a link.

You could write this all another way, of course — have the POST 'simply' return a URL with parameter encoding, and have a GET to the parameter-encoded URL generate and return the image 'in memory'. This would require no disk storage (pro) but would require computation every time that a file was 'retrieved' (con). Without knowing the detailed use-case it's hard to tell which route would be most beneficial, and I've decided to follow the specification.

Since we need to save the file to disk and return a link, we need to generate a unique filename. For simplicity I'm going to use the time/date that the file is generated as a name (no other instructions given).

To change our current code into a function, we need to:

  • remove (or in my case rename) the data dictionary in the current code (since we're going to pass the data in as a parameter)
  • 'wrap' everything below the current code's import statements in a def to make a new function
  • add code to get the current time (to use as a filename)
  • add code to generate a file path (which means working with OS to understand current path)
  • change the img.save() to use the new path, and then return a URL that points to that path

The new code here is around knowing time and file paths, for which we're going to need to add a couple of imports from the standard library:

import time
import os

The time module has a method time.time() which generates "the duration, in milliseconds, since the Epoch'. For our purposes, that means that it will always generate a unique number, so that works fine for naming files; we can use:

imgname = str(int(time.time())) + '.png'

to generate a new filename.

We also need to know where to save the file, for which we can use the os module's getcwd() method to get the current working directory and combine that with the new imgname to save the file:

CWD = os.getcwd()
img.save(CWD + '/tmp/' + imgname)

And then finally we know we're going to need to get to that file, so we can return the imgname (I'm not going to return the whole path, since we're going to need to add a router/webapp that can deal with retrieving a file given it's name).

The full code looks like this (I've renamed my old data variable to d and moved it outside of the function def, which means I can use it as a default data set when testing if necessary):

import time
import os

from PIL import Image, ImageDraw, ImageFont
from fonts.ttf import Roboto
from fonts.otf import FontAwesome5FreeSolid as FA

d = {
  'items': [
    {'percent': 82.0, 'color': 'purple', 'name': 'People', 'icon': '0xf0c0'},
    {'percent': 75.0, 'color': '#00788a', 'name': 'Business', 'icon': '0xf013'},
    {'percent': 80.0, 'color': 'gray', 'name': 'Future', 'icon': '0xf135'}
  ],
  'o_all' : 76.0
}

def generateChart(data)
    img = Image.new("RGB", (4000,4000), color="white")
    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)

    minx = 1000
    hs = []
    for idx, item in enumerate(data['items']):
    s = f"{item['name']} {int(item['percent'])}%"
    w, h = font_main.getsize(s)
    hs.append(h)
    x = 480 - w
    minx = min(minx, x)
    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)

    I_SIZE = 45
    font_icon = ImageFont.truetype(FA, I_SIZE)
    for idx, item in enumerate(data['items']):
    s = chr(int(item['icon'], 16))
    w, h = font_icon.getsize(s)
    x = minx - w/2 - I_SIZE
    y = ( ( LW + SP ) * idx + SP ) + (LW - hs[idx])/2
    d.text((x,y), s, fill='gray', font=font_icon)

    CWD = os.getcwd()
    imgname = str(int(time.time())) + '.png'
    img.save(CWD + '/tmp/' + imgname)
    return imgname

Next Up

The majority of the hard work is now done; what's needed is a 'router' that can receive POST and GET requests and react accordingly ...