Custom Graphics/Charts
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:
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
-
Open in the chart editor itself
diagra
and open the file in question or supply the file name as an argumentdiagra mychart.py
, or -
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.
-
file
->new
->drawing
and give it a name, in this case Thermometer -
Change the width to 200 (points)
-
actions
->add new widget
->verticalbarchart
naming itchart
-
The initialisation will have some dummy data should show output;
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'
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,) ]
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
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
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
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)
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]))
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
Using diagra to create the image - note the long line containing the paths!
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
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:
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: