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:
Based on our design, we know that the process of generating a chart will involve the following three steps:
new_chart()
function.set_XXX()
functions.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:
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.
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.
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:
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 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.
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.
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 *
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.
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:
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:
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.
If you run the test_charter.py
script, you should see a complete bar chart:
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.
3.128.200.71