Skip to content

Writing custom graphics

This tutorial will give you an introduction to using our graphics tool, Diagra, to create a custom chart. The end result will be a Python class that knows how to draw the graphic in response to varying input data. These widgets might take anywhere between a few hours and a day or two to realise, but they can then be used easily forever in PDF reports and also to make dynamic charts for the web. Get in touch with us if you'd like one created quickly!

Aim and Background

A customer approached ReportLab for a custom graphical widget within a larger report, with the output to look like the following:

Image

The customer provided the data and wanted the chart to be dynamically drawn based on the input. The data was to have 5 values;

    "chart_data": {
        "Customer Emissions": 10.7,
        "State Average Emissions": 12.1,
        "Group One": 42,
        "Group Two": 23,
        "Group Three":17
    }

"Customer Emissions" and "State Average Emissions" correspond to the two labels beside the grey, and either one might be higher. The next three figures are percentages which will divide up the lower part of the chart into four coloured regions.

Custom features you won't find in an out-of-the-box chart library include the position of the bars and labels, the rounded "thermometer" look at the bottom and the "CO2" cloud icon.

Here is a simplification of the steps we took to produce the output.

Pre-requisites

Get the right dimensions and colours from the start

Although dimensions can be edited later, a good place to start is getting the dimensions right.

Dimensions to think about;

  • dimensions of the overall canvas/drawing - such as width and height
  • dimensions and positions of chart and other graphics within drawing/canvas

If someone has give you a sample design - what are the x and y co-ordinates of the key objects? Do you have the values for the respective colours? Perhaps use a colour picker to note the RGB values of the colours.

Installing rlextra

Follow these instructions.

Verify you have successfully installed, by activating your virtual environment and run diagra from the command line.

Running the Code

If you are using a virtual environment, activate it. If you have a working/existing chart, either

  1. Open in the chart editor itself diagra and open the file in question or supply the file name as an argument diagra mychart.py, or

  2. run in a terminal $ python graphicsample.py and look at the save method to see how the output gets saved, in this instance we write to pdf and png files in the current directory;

    Thermometer().save(formats=['pdf','png'],outDir='.',fnRoot=None)
    

Step 1 - Create a new drawing and adding a vertical bar chart

Start the Diagra GUI editor;

$ diagra which should open a new blank drawing.

  1. file -> new -> drawing and give it a name, in this case Thermometer

  2. Change the width to 200 (points)

  3. actions -> add new widget -> verticalbarchart naming it chart

  4. The initialisation will have some dummy data should show output;

Image

Step 2 - Change to stacked bar chart and amend data

Our aim is a stacked bar chart so we now change the category axis style to stacked;

    self.chart.categoryAxis.style='stacked'

Image

We only want want one set of data in out chart. Let's start with percentages that add up to 100, and only one element in each tuple;

    self.chart.data            = [(10,), (25,), (20,), (45,) ]

Image

Step 3 - Improve chart dimensions

Let's change the width of the drawing to match the final width in pixels we measured earlier.

    self.chart.height          = 180
    self.chart.width           = 80
    self.chart.x               = 60

Or we can set the width a bit more programmatically, subtracting the width of the chart from the width of the drawing and diving by 2.

    self.chart.x               = (self.width-self.chart.width)/2

Image

Step 4 - Improve colours

We can experiment with the colours on the bars. chart -> bars -> colours

If we the project requires us to follow a style guidelines, we can change colours either using or RGB with values (0-9) (Further info on colours in ReportLab);

    self.chart.bars[0].fillColor   = limegreen

Or we can use rgb values ;

    self.chart.bars[1].fillColor   = Color(.9,1,0) # a yellow

Image

Step 5 Adding labels

We want that labels to appear in the bars (boxTarget='mid') and make other adjustments;

    self.chart.barLabels.boxTarget='mid'
    self.chart.barLabels.fillColor        = white
    self.chart.barLabels.fontSize         = 14
    self.chart.barLabels.fontName         = 'Helvetica-Bold'
    self.chart.barLabelFormat  = '%s%%'
    self.chart.bars.strokeColor     = white

We also want to hide the standard category and value axis;

    self.chart.categoryAxis.visible              = 0
    self.chart.valueAxis.visible                 = 0

Image

Step 5 Adding the Curved Base

We want to add the curve for the first element. In terms of our graphics library we say we want to change the symbol from a flat base to a rounded base.

The work for this is done in VThermometerBar, in which we have class VThermometerBar(Widget). This class has a method called draw which draws correct paths via ArcPath (imported from reportlab.graphics.shapes), which draws the curve at the base of the graphic. Here is the custom draw method we wrote for this widget, which creates a vector Path object:

def draw(self):
    P = ArcPath()
    P.strokeWidth = self.strokeWidth
    P.strokeColor = self.strokeColor
    P.fillColor = self.fillColor
    P.strokeDashArray = self.strokeDashArray
    x = self.x
    y = self.y
    width = self.width
    height = self.height
    width = abs(width)
    height = abs(height)
    y1 = y + height
    x1 = x + width
    P.moveTo(x,y1)
    rx = width*0.5
    ry = min(height,rx)
    cx = x + rx
    cy = y + ry
    if ry>=height:
        P.addArc(cx,cy,rx,180,0, yradius=ry,reverse=0, degreedelta=self.degreedelta)
    else:
        P.lineTo(x,cy)
        P.addArc(cx,cy,rx,180,0, yradius=ry,reverse=0, degreedelta=self.degreedelta)
    P.lineTo(x1,y1)
    P.closePath()
    return P

We need to import VThermometerBar

    from rml.vthermometerbar import VThermometerBar

Then we can declare that bar[0] should be an instance of VThermometerBar

    self.chart.bars[0].symbol = VThermometerBar(fillColor=limegreen,strokeColor=white,strokeWidth=self.chart.bars.strokeWidth)

Image

Step 6 - Helpers and getContents()

If we want to add some fancy extra labels and pointers, it would be helpful to know the positions of the bars. getContents() is a special method which can be called on Group and derived class instances (especially Drawings) it is commonly used for assembling the data and setting various other attributes at the time of drawing, after data has been passed in.

In this instance would want labels to point at various sections on the chart, so one way to do this is to call _computeBarPositions within getContents():

    chart._computeBarPositions()
    bp = [p[0] for p in chart._barPositions]

Then adding a print statement we can see:

    chart._barPositions=[[(73.33333333333333, 10.0, 53.33333333333333, 18.0)], [(73.33333333333333, 28.0, 53.33333333333333, 45.0)], [(73.33333333333333, 73.0, 53.33333333333333, 36.0)], [(73.33333333333333, 109.0, 53.33333333333333, 81.0)]]

We can then use those positions later on to add further labels to point to specific regions - eg base of a bar, top of a bar or calculate the mid point (eg top/2)

Step 7 - Adding further labels and lines

We want extra lines and labels to be part of a group.

    self._add(self,Group(),name='extras',validate=None,desc=None)

We add labels (stings) to the chart;

    extras.add(Label(_text=l0,x=x_start_labels,y=pymid[0],maxWidth=lmw, angle=0,boxAnchor='w', fontName='Helvetica-Bold', fontSize=8, fillColor=chart.bars[0].fillColor))

In the class we add a special label pointer with defined colours, width and dashes point from Labels to the relevant bar

    self._labelPointer = lambda x0,y0,x1,y1: Line(x0,y0,x1,y1,strokeColor=lightgrey,strokeWidth=0.5,strokeDashArray=[1,1])

Which we then call in getContents, with the relevant arguments;

    extras.add(self._labelPointer(barx,pyhi[4],x_start_labels,pyhi[4]))

Image

Step 8 - Adding the custom cloud graphic

The code for drawing the cloud and related text contains a class CO2. It contains the paths for the cloud, the string Now and an underline. This was a conceptually fairly simple line drawing, so we have chosen to work out the paths and control points and create a vector widget to draw them. With a more complex design, you might prefer to just treat it as a separate bitmap provided by a designer. The advantage of coding it is that it can easily be reused at any size or with any future colour scheme, and it will always be drawn at vector resolution.

Original Image

Image

Using diagra to create the image - note the long line containing the paths!

Image

Final python Code to generate the image

#Autogenerated by ReportLab guiedit do not edit
from reportlab.graphics.shapes import Drawing, _DrawingEditorMixin, Path, ArcPath, String, Line
from reportlab.lib.colors import red, black, gray, silver,PCMYKColor,toColor, Color

class CO2(_DrawingEditorMixin,Drawing):
    def __init__(self,width=54,height=34,*args,**kw):
        Drawing.__init__(self,width,height,*args,**kw)
        self.width       = 54
        self.height       = 34

        # Use Path of the C02 cloud extracted from the image
        self.transform = (1,0,0,1,0,0)         
        paths = [25.7362694, 0.029153300000000004, 22.2255654, 0.029153300000000004, 21.874971000000002, 0.029153300000000004, 21.577729400000003, 0.35400392, 21.604405800000002, 0.76735552, 21.604405800000002, 4.2199372, 21.577729400000003, 4.6332888, 21.874971000000002, 4.987290000000001, 22.2255654, 4.987290000000001, 25.115109800000003, 4.987290000000001, 25.115109800000003, 7.112358000000001, 22.2255654, 7.112358000000001, 21.874971000000002, 7.171705000000001, 21.604405800000002, 7.525709600000001, 21.6320342, 7.939061200000001, 21.6853836, 8.2930624, 21.9283238, 8.5585684, 22.2255654, 8.5887604, 25.7362694, 8.5887604, 26.0878158, 8.5887604, 26.3850608, 8.263910800000001, 26.3850608, 7.8797108, 26.3850608, 7.850559200000001, 26.3850608, 4.2199372, 26.3850608, 3.8065856000000005, 26.1144922, 3.482773, 25.7362694, 3.4525810000000003, 22.8467284, 3.4525810000000003, 22.8467284, 1.504517, 25.7362694, 1.504517, 26.1144922, 1.4753637000000002, 26.3850608, 1.09220648, 26.330756, 0.6788545400000001, 26.3031276, 0.32485062000000003, 26.0601874, 0.05830660000000001, 25.7362694, 0.029153300000000004, 19.228366800000003, 0.029153300000000004, 14.7449568, 0.029153300000000004, 14.366734000000001, 0.029153300000000004, 14.0971208, 0.35400392, 14.0971208, 0.76735552, 14.0971208, 12.985698200000002, 14.0971208, 13.369898200000002, 14.366734000000001, 13.723902800000001, 14.7449568, 13.723902800000001, 19.228366800000003, 13.723902800000001, 19.578961200000002, 13.723902800000001, 19.876206200000002, 13.3990498, 19.876206200000002, 12.985698200000002, 19.876206200000002, 0.7371611400000001, 19.876206200000002, 0.32485062000000003, 19.606589600000003, 0.0, 19.228366800000003, 0.0, 19.228366800000003, 0.029153300000000004, 15.3927928, 1.4753637000000002, 18.5795788, 1.4753637000000002, 18.5795788, 12.158995, 15.3927928, 12.158995, 15.3927928, 1.4753637000000002, 11.747758200000002, 0.029153300000000004, 8.101771600000001, 0.029153300000000004, 7.7502252, 0.029153300000000004, 7.4539356, 0.35400392, 7.4539356, 0.76735552, 7.4539356, 12.956546600000001, 7.4539356, 13.339702800000001, 7.7235488000000005, 13.723902800000001, 8.101771600000001, 13.723902800000001, 11.747758200000002, 13.723902800000001, 12.125981000000001, 13.664552400000002, 12.395594200000001, 13.281396200000001, 12.3412928, 12.8680446, 12.3146164, 12.51404, 12.0716762, 12.2485374, 11.747758200000002, 12.218342000000002, 8.749607600000001, 12.218342000000002, 8.749607600000001, 1.504517, 11.747758200000002, 1.504517, 12.125981000000001, 1.4753637000000002, 12.395594200000001, 1.09220648, 12.3412928, 0.6788545400000001, 12.3146164, 0.32485062000000003, 12.0716762, 0.05830660000000001, 11.747758200000002, 0.029153300000000004, 12.854798200000001, 20.924759400000003, 12.6928392, 20.924759400000003, 12.503251800000001, 21.0132614, 12.395594200000001, 21.161107, 11.6667804, 22.1356592, 10.667394600000002, 22.8728234, 9.5060532, 23.2268246, 9.182135200000001, 23.3746736, 8.9925478, 23.7588736, 9.101154000000001, 24.142029800000003, 9.2088082, 24.526229800000003, 9.532729600000001, 24.732385400000002, 9.8566476, 24.673035000000002, 11.2342528, 24.230531800000005, 12.422270600000001, 23.345522000000003, 13.3139988, 22.1356592, 13.583612, 21.839964600000002, 13.583612, 21.397458000000004, 13.3139988, 21.101760000000002, 13.205392600000001, 20.9841064, 13.0434336, 20.8956044, 12.8814712, 20.8956044, 12.854798200000001, 20.924759400000003, 5.4818438, 7.142550000000001, 2.1874019000000002, 8.410719400000001, 0.0, 11.8643408, 0.08097950000000001, 15.701118400000002, 0.08097950000000001, 16.025968, 0.0, 20.630101800000002, 3.1324815400000006, 24.526229800000003, 7.3186496000000005, 24.968733, 7.669244000000001, 24.968733, 9.829971200000001, 25.0863866, 11.96307, 24.053527800000005, 13.3139988, 22.1939658, 12.3689212, 21.161107, 11.2609292, 22.726014800000005, 9.4793768, 23.611024600000004, 7.669244000000001, 23.493371000000003, 7.3729544, 23.493371000000003, 3.8889268, 23.080019399999998, 1.26899798, 19.833594, 1.34997748, 16.025968, 1.34997748, 15.701118400000002, 1.26899798, 12.483844600000001, 3.10580582, 9.591427200000002, 5.8600666, 8.529413400000001, 5.4818438, 7.142550000000001, 28.301895800000004, 7.023856, 27.951301400000002, 8.4700664, 30.759864600000004, 9.502925200000002, 32.65002320000001, 12.4255414, 32.5957184, 15.671963400000001, 32.5957184, 15.966621000000002, 32.704328000000004, 19.9804026, 29.814783600000002, 23.345522000000003, 26.1411686, 23.4631756, 26.1144922, 23.4631756, 26.1144922, 23.4631756, 26.0878158, 23.4631756, 24.277683000000003, 23.4631756, 23.9004156, 23.4631756, 23.629847, 23.788025200000003, 23.629847, 24.201376800000002, 23.629847, 24.880234400000003, 23.7108282, 28.982514600000002, 20.740306, 32.4059406, 16.9866618, 32.4944426, 13.205392600000001, 32.4059406, 10.208194, 28.982514600000002, 10.315848200000001, 24.880234400000003, 10.315848200000001, 23.9358742, 9.0468492, 23.9358742, 9.0468492, 24.880234400000003, 8.939195000000002, 29.7790224, 12.476575400000002, 33.852151000000006, 16.9866618, 34.0, 21.442446800000003, 33.852151000000006, 24.979823800000005, 29.838369400000005, 24.9264744, 24.968733, 26.0878158, 24.968733, 30.516924400000004, 24.850039000000002, 34.0, 20.807105800000002, 33.8923458, 15.966621000000002, 33.8923458, 15.671963400000001, 33.9466472, 11.7758388, 31.677313800000004, 8.263910800000001, 28.301895800000004, 7.023856]
        self.add(Path(points=paths,operators=[0,1,2,1,2,1,1,1,2,2,1,2,1,1,2,1,1,1,2,2,3,0,1,2,1,2,1,2,1,2,1,3,0,1,1,1,1,3,0,1,2,1,2,1,2,2,1,1,1,2,2,3,0,2,2,2,2,2,2,2,1,3,0,2,1,2,1,2,1,2,1,2,1,2,1,3,0,1,2,1,2,2,1,2,1,2,2,1,1,1,2,2,1,2,1,2,3],isClipPath=0,autoclose=None,fillMode=1,fillColor=Color(.28235,.29019,.35294,1),fillOpacity=None,strokeColor=Color(.28235,.29019,.35294,1),strokeWidth=0,strokeLineCap=0,strokeLineJoin=0,strokeMiterLimit=0,strokeDashArray=None,strokeOpacity=None))

        # Draw Underline on NOW using the Line Class
        self._add(self,Line(0,0,10,10),name='grayline',validate=None,desc=None)
        self.grayline.strokeWidth      = 1
        self.grayline.x1               = 34
        self.grayline.strokeColor      = toColor(PCMYKColor(16,11,11,0))
        self.grayline.y1               = 0.5
        self.grayline.y2               = 0.5
        self.grayline.x2 
                      = 51.8
        #Draw "NOW" String using the String Class
        self._add(self,String(10,10,'text'),name='now',validate=None,desc=None)
        self.now.x          = 36
        self.now.y          = 3.5
        self.now.text       = 'NOW'
        self.now.fontSize   = 6.8
        self.now.fontName   = 'Helvetica'


if __name__=="__main__": #NORUNTESTS
    CO2().save(formats=['pdf'],outDir='.',fnRoot=None)

Output file

Image

Step 9 - Passing dynamic data from the PDF template to the customer graphic

In the prep file (which is similar to a PDF template) we can pass in the customer data dynamically and also make other dynamic adjustments to height, width, etc, to the chart, as needed;

        <drawing module="rml.vbarchart" function="ThermometerChart">
            <param name="contents[0].data">{{chart_data}}</param>
            <param name="chart.height">{{chart_height}}</param>
            <param name="chart.barWidth">{{0.3}}</param>
        </drawing>

Final Output

The output and screenshots above, involved some simplifications for readability.

When we use the actual code samples this project, we get the following, based on different input;

Sample Output 1

Data:

    "chart_data": {
        "Customer Emissions": 10.7,
        "State Average Emissions": 12.1,
        "Group One": 42,
        "Group Two": 23,
        "Group Three":17
    }

Output:

Image

Sample Output 2

Data:

    "chart_data": {
        "Customer Emissions": 14.7,
        "State Average Emissions": 12.1,
        "Group One": 42,
        "Group Two": 23,
        "Group Three":17
    }

Output:

Image