Implementing Charter

We know that the Charter library's public interface will consist of a number of functions accessed at the package level, for example charter.new_chart(). However, using the techniques covered in the previous chapter, we know that we don't have to define our library's API in the package initialization file to make these functions available at the package level. Instead, we can define the functions elsewhere, and import them into the __init__.py file so that they are available for others to use.

Let's start by creating a directory to hold our charter package. Create a new directory named charter, and create within it an empty package initialization file, __init__.py. This gives us the basic framework within which to write our library:

Implementing Charter

Based on our design, we know that the process of generating a chart will involve the following three steps:

  1. Create a new chart by calling the new_chart() function.
  2. Define the contents and appearance of the chart by calling the various set_XXX() functions.
  3. Generate the chart and save it as an image file by calling the generate_chart() function.

To keep our code nicely organized, we're going to separate the process of generating a chart from the process of creating and defining a chart. To do this, we'll have a module named chart, which handles the chart creation and definition, and a separate module named generator which handles the chart generation.

Go ahead and create these two new empty modules, placing them inside the charter package:

Implementing Charter

Now that we have an overall structure for our package, let's create some placeholders for the various functions that we know we're going to have to implement. Edit the chart.py module, and enter the following into this file:

def new_chart():
    pass

def set_title(chart, title):
    pass

def set_x_axis(chart, x_axis):
    pass

def set_y_axis(chart, minimum, maximum, labels):
    pass

def set_series_type(chart, series_type):
    pass

def set_series(chart, series):
    pass

Similarly, edit the generator.py module, and enter the following into it:

def generate_chart(chart, filename):
    pass

These are all the functions that we know we'll need to implement for the Charter library. However, they're not in the correct place yet—we want the user to be able to call charter.new_chart(), not charter.chart.new_chart(). To get around this, edit the __init__.py file, and enter the following into this file:

from .chart     import *
from .generator import *

As you can see, we're using relative imports to load all the functions from these modules into the main charter package's namespace.

Our Charter library is starting to take shape! Let's now work on each of the two modules in turn.

Implementing the chart.py module

Since we're eschewing the use of object-oriented programming techniques in our implementation of the Charter library, we can't use an object to store the information about a chart. Instead, the new_chart() function is going to return a chart value, and the various set_XXX() functions will take that chart and add information to it.

The easiest way to store information about a chart is to use a Python dictionary. This makes the implementation of our new_chart() function very simple; edit the chart.py module and replace the placeholder for new_chart() with the following:

def new_chart():
    return {}

Once we have a dictionary that will hold the chart's data, it's easy to store the various values we want into this dictionary. For example, edit the definition for the set_title() function so that it looks like the following:

def set_title(chart, title):
    chart['title'] = title

In a similar way, we can implement the rest of the set_XXX() functions:

def set_x_axis(chart, x_axis):
    chart['x_axis'] = x_axis

def set_y_axis(chart, minimum, maximum, labels):
    chart['y_min']    = minimum
    chart['y_max']    = maximum
    chart['y_labels'] = labels

def set_series_type(chart, series_type):
    chart['series_type'] = series_type

def set_series(chart, series):
    chart['series'] = series

This completes the implementation for our chart.py module.

Implementing the generator.py module

Unfortunately, the generate_chart() function is going to be more difficult to implement, which is why we moved this function into a separate module. The process of generating a chart will involve the following steps:

  1. Create an empty image to hold the generated chart.
  2. Draw the chart's title.
  3. Draw the x axis.
  4. Draw the y axis.
  5. Draw the data series.
  6. Save the resulting image file to disk.

Because the process of generating a chart requires us to work with images, we're going to need to find a library that allows us to generate image files. Let's grab one now.

The Pillow library

The Python Imaging Library (PIL) is a venerable library used to generate images. Unfortunately, PIL is no longer being actively developed. There is, however, a newer version of PIL, named Pillow, that continues to be supported and will allow us to create and save image files.

The main web site for the Pillow library can be found at http://python-pillow.org/, and the documentation is available at http://pillow.readthedocs.org/.

Let's go ahead and install Pillow. The easiest way to do this is to use pip install pillow, although the installation guide (http://pillow.readthedocs.org/en/3.0.x/installation.html) gives you a variety of options if this won't work for you.

Looking through the Pillow documentation, it appears that we can create an empty image using the following code:

from PIL import Image
image = Image.new("RGB", (CHART_WIDTH, CHART_HEIGHT), "#7f00ff")

This creates a new RGB (red, green, blue) image with the given width and height, filled with the given color.

Note

#7f00ff is a hexadecimal color code for purple. Each pair of hexadecimal digits represents a color value: 7f for red, 00 for green, and ff for blue.

To draw into this image, we will use the ImageDraw module. For example:

from PIL import ImageDraw
drawer = ImageDraw.Draw(image)
drawer.line(50, 50, 150, 200, fill="#ff8010", width=2)

Once the chart has been drawn, we can save the image to disk in the following way:

image.save("image.png", format="png")

This brief introduction to the Pillow library tells us how to implement steps 1 and 6 of the chart-generation process we described earlier. It also tells us that for steps 2 to 5, we are going to use the ImageDraw module to draw the various chart elements.

Renderers

When we draw the chart, we want to be able to choose the elements to draw. For example, we might select between the "bar" and "line" elements depending on the type of data series the user wants to display. A very simple way of doing this would be to structure our drawing code like this:

if chart['series_type'] == "bar":
    ...draw the data series using bars
elif chart['series_type'] == "line":
    ...draw the data series using lines

However, this isn't very expandable and would quickly get hard to read if the drawing logic gets complicated, or if we added more charting options to the library. To make the Charter library more modular, and to support enhancing it down the track, we will make use of renderer modules to do the actual drawing for us.

In computer graphics, a renderer is a part of a program that draws something. The idea is that you can select the appropriate renderer and ask it to draw the element you want without having to worry about the details of how that element will be drawn.

Using renderer modules, our drawing logic would look something like the following:

from renderers import bar_series, line_series

if chart['series_type'] == "bar":
    bar_series.draw(chart, drawer)
elif chart['series_type'] == "line":
    line_series.draw(chart, drawer)

This means that we can leave the actual details of how each element is drawn to the renderer module itself and not clutter up our generate_chart() function with lots of detailed drawing code.

To keep track of our renderer modules, we're going to create a sub-package named renderers, and place all our renderer modules inside this sub-package. Let's create this sub-package now.

Create a new directory named renderers within the main charter directory, and create a new file inside it called __init__.py to act as the package initialization file. This file can be empty as we don't need to do anything special to initialize this sub-package.

We are going to need a total of five different renderer modules for the Charter library:

  • title.py
  • x_axis.py
  • y_axis.py
  • bar_series.py
  • line_series.py

Go ahead and create these five files within the charter.renderers directory, and enter the following placeholder text into each one:

def draw(chart, drawer):
    pass

This gives us the overall structure for our renderer modules. Let's now use these renderers to implement our generate_chart() function.

Edit the generate.py module, and replace the placeholder definition for the generate_chart() function with the following:

def generate_chart(chart, filename):
    image  = Image.new("RGB", (CHART_WIDTH, CHART_HEIGHT),
                       "#ffffff")
    drawer = ImageDraw.Draw(image)

    title.draw(chart, drawer)
    x_axis.draw(chart, drawer)
    y_axis.draw(chart, drawer)
    if chart['series_type'] == "bar":
        bar_series.draw(chart, drawer)
    elif chart['series_type'] == "line":
        line_series.draw(chart, drawer)

    image.save(filename, format="png")

As you can see, we create an Image object to hold our generated chart, initializing it to white using the hex color code #ffffff. We then use the ImageDraw module to define a drawer object to draw into the chart and call the various renderer modules to do all the work. Finally, we call image.save() to save the image file to disk.

For this function to work, we need to add a few import statements to the top of our generator.py module:

from PIL import Image, ImageDraw
from .renderers import (title, x_axis, y_axis,
                        bar_series, line_series)

There's one more thing that we haven't dealt with yet: when we create the image, we make use of two constants which tell Pillow the dimensions of the image to create:

    image = Image.new("RGB", (CHART_WIDTH, CHART_HEIGHT),
                       "#ffffff")

We need to define these two constants somewhere.

As it turns out, we are going to need to define several more constants and use them throughout the Charter library. To allow for this, we'll create a special module just to hold our various constants.

Create a new file named constants.py within the top-level charter directory. Inside this module, add the following values:

CHART_WIDTH  = 600
CHART_HEIGHT = 400

Then, add the following import statement to your generator.py module:

from .constants import *

Testing the code

While we haven't implemented any of our renderers, we have enough code in place to start testing. To do this, create an empty file named test_charter.py, and place it in the directory containing the charter package. Then, enter the following into this file:

import charter
chart = charter.new_chart()
charter.set_title(chart, "Wild Parrot Deaths per Year")
charter.set_x_axis(chart,
                   ["2009", "2010", "2011", "2012", "2013",
                    "2014", "2015"])
charter.set_y_axis(chart, minimum=0, maximum=700,
                   labels=[0, 100, 200, 300, 400, 500, 600, 700])
charter.set_series(chart, [250, 270, 510, 420, 680, 580, 450])
charter.set_series_type(chart, "bar")
charter.generate_chart(chart, "chart.png")

This is just a copy of the example code we saw earlier. This script will allow you to test the Charter library; open up a terminal or command-line window, cd into the directory containing the test_charter.py file, and type the following:

python test_charter.py

All going well, the program should finish without any errors. You can then look at the chart.png file, which should be an empty image file filled with a white background.

Rendering the title

We next need to implement our various renderer modules, starting with the chart's title. Edit the renderers/title.py file, and replace your placeholder definition of the draw() function with the following:

def draw(chart, drawer):
    font = ImageFont.truetype("Helvetica", 24)
    text_width,text_height = font.getsize(chart['title'])

    left = CHART_WIDTH/2 - text_width/2
    top  = TITLE_HEIGHT/2 - text_height/2

    drawer.text((left, top), chart['title'], "#4040a0", font)

This renderer starts by obtaining a font to use when drawing the title. It then calculates the size (in pixels) of the title text and the position to use for the label so that it is nicely centered on the chart. Notice that we use a constant named TITLE_HEIGHT to specify the amount of space to use for the chart's title.

The final line in this function draws the title onto the chart using the specified position and font. The string #4040a0 is the hexadecimal color code to use for the text—this is a dark blue color.

Because this module uses the ImageFont module to load the font, as well as some constants from our constants.py module, we need to add the following import statements to the top of our module:

from PIL import ImageFont
from ..constants import *

Note that we use .. to import the constants module from our parent package.

Finally, we need to add the TITLE_HEIGHT constant to our constants.py module:

TITLE_HEIGHT = 50

If you now run your test_charter.py script, you should see the chart's title appear in the generated image:

Rendering the title

Rendering the x axis

If you remember, the x axis is a discrete axis with labels displayed between each tick mark. To draw this, we are going to have to calculate the width of each "bucket" on the axis, and then draw lines to represent the axis and the tick marks, as well as drawing the label for each bucket.

Start by editing the renderers/x_axis.py file, and replace your placeholder draw() function with the following:

def draw(chart, drawer):
    font = ImageFont.truetype("Helvetica", 12)
    label_height = font.getsize("Test")[1]

    avail_width = CHART_WIDTH - Y_AXIS_WIDTH - MARGIN
    bucket_width = avail_width / len(chart['x_axis'])

    axis_top = CHART_HEIGHT - X_AXIS_HEIGHT
    drawer.line([(Y_AXIS_WIDTH, axis_top),
                 (CHART_WIDTH - MARGIN, axis_top)],
                "#4040a0", 2) # Draw main axis line.

    left = Y_AXIS_WIDTH
    for bucket_num in range(len(chart['x_axis'])):
        drawer.line([(left, axis_top),
                     (left, axis_top + TICKMARK_HEIGHT)],
                    "#4040a0", 1) # Draw tickmark.

        label_width = font.getsize(chart['x_axis'][bucket_num])[0]
        label_left = max(left,
                         left + bucket_width/2 - label_width/2)
        label_top  = axis_top + TICKMARK_HEIGHT + 4

        drawer.text((label_left, label_top),
                    chart['x_axis'][bucket_num], "#000000", font)

        left = left + bucket_width

    drawer.line([(left, axis_top),
                 (left, axis_top + TICKMARK_HEIGHT)],
                "#4040a0", 1) # Draw final tickmark.

You'll also need to add the following import statements at the top of your module:

from PIL import ImageFont
from ..constants import *

Finally, you should add the following definitions to your constants.py module:

X_AXIS_HEIGHT   = 50
Y_AXIS_WIDTH    = 50
MARGIN          = 20
TICKMARK_HEIGHT = 8

These define the sizes of the fixed elements within the chart.

If you now run your test_charter.py script, you should see the x axis displayed along the bottom of the chart:

Rendering the x axis

The remaining renderers

As you can see, the generated image is starting to look more chart-like. Since the purpose of this package is to show how to structure your code, rather than the details of how these modules are implemented, let's skip ahead and add the remaining renderers without further discussion.

Start by editing your renderers/y_axis.py file to look like the following:

from PIL import ImageFont

from ..constants import *

def draw(chart, drawer):
    font = ImageFont.truetype("Helvetica", 12)
    label_height = font.getsize("Test")[1]

    axis_top    = TITLE_HEIGHT
    axis_bottom = CHART_HEIGHT - X_AXIS_HEIGHT
    axis_height = axis_bottom - axis_top


    drawer.line([(Y_AXIS_WIDTH, axis_top),
                 (Y_AXIS_WIDTH, axis_bottom)],
                "#4040a0", 2) # Draw main axis line.

    for y_value in chart['y_labels']:
        y = ((y_value - chart['y_min']) /
             (chart['y_max']-chart['y_min']))
        
        y_pos = axis_top + (axis_height - int(y * axis_height))

        drawer.line([(Y_AXIS_WIDTH - TICKMARK_HEIGHT, y_pos),
                     (Y_AXIS_WIDTH, y_pos)],
                    "#4040a0", 1) # Draw tickmark.

        label_width,label_height = font.getsize(str(y_value))
        label_left = Y_AXIS_WIDTH-TICKMARK_HEIGHT-label_width-4
        label_top = y_pos - label_height / 2

        drawer.text((label_left, label_top), str(y_value),
                    "#000000", font)

Next, edit renderers/bar_series.py to look like this:

from PIL import ImageFont
from ..constants import *

def draw(chart, drawer):
    avail_width  = CHART_WIDTH - Y_AXIS_WIDTH - MARGIN
    bucket_width = avail_width / len(chart['x_axis'])

    max_top      = TITLE_HEIGHT
    bottom       = CHART_HEIGHT - X_AXIS_HEIGHT
    avail_height = bottom - max_top

    left = Y_AXIS_WIDTH
    for y_value in chart['series']:

        bar_left = left + MARGIN / 2
        bar_right = left + bucket_width - MARGIN / 2

        y = ((y_value - chart['y_min']) /
             (chart['y_max'] - chart['y_min']))

        bar_top = max_top + (avail_height - int(y * avail_height))

        drawer.rectangle([(bar_left, bar_top),
                          (bar_right + 1,
                           bottom)],
                         fill="#e8e8f4", outline="#4040a0")

        left = left + bucket_width

Finally, edit renderers.line_series.py to look like the following:

from PIL import ImageFont
from ..constants import *

def draw(chart, drawer):
    avail_width  = CHART_WIDTH - Y_AXIS_WIDTH - MARGIN
    bucket_width = avail_width / len(chart['x_axis'])

    max_top      = TITLE_HEIGHT
    bottom       = CHART_HEIGHT - X_AXIS_HEIGHT
    avail_height = bottom - max_top

    left   = Y_AXIS_WIDTH
    prev_y = None
    for y_value in chart['series']:
        y = ((y_value - chart['y_min']) /
             (chart['y_max'] - chart['y_min']))

        cur_y = max_top + (avail_height - int(y * avail_height))

        if prev_y != None:
            drawer.line([(left - bucket_width / 2, prev_y),
                         (left + bucket_width / 2), cur_y],
                        fill="#4040a0", width=1)
        prev_y = cur_y
        left = left + bucket_width

This completes our implementation of the Charter library.

Testing Charter

If you run the test_charter.py script, you should see a complete bar chart:

Testing Charter

There is obviously a lot more that we could do with the Charter library, but even in its current state, it works well. If you want, you can use it to generate line and bar charts for all sorts of data. For our purposes, we can declare the Charter library to be complete, and start using it as part of our production system.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.128.200.71