doughnut.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477
  1. #Copyright ReportLab Europe Ltd. 2000-2017
  2. #see license.txt for license details
  3. #history https://hg.reportlab.com/hg-public/reportlab/log/tip/src/reportlab/graphics/charts/doughnut.py
  4. # doughnut chart
  5. __version__='3.3.0'
  6. __doc__="""Doughnut chart
  7. Produces a circular chart like the doughnut charts produced by Excel.
  8. Can handle multiple series (which produce concentric 'rings' in the chart).
  9. """
  10. import copy
  11. from math import sin, cos, pi
  12. from reportlab.lib import colors
  13. from reportlab.lib.validators import isColor, isNumber, isListOfNumbersOrNone,\
  14. isListOfNumbers, isColorOrNone, isString,\
  15. isListOfStringsOrNone, OneOf, SequenceOf,\
  16. isBoolean, isListOfColors,\
  17. isNoneOrListOfNoneOrStrings,\
  18. isNoneOrListOfNoneOrNumbers,\
  19. isNumberOrNone, isListOfNoneOrNumber,\
  20. isListOfListOfNoneOrNumber, EitherOr
  21. from reportlab.lib.attrmap import *
  22. from reportlab.pdfgen.canvas import Canvas
  23. from reportlab.graphics.shapes import Group, Drawing, Line, Rect, Polygon, Ellipse, \
  24. Wedge, String, SolidShape, UserNode, STATE_DEFAULTS
  25. from reportlab.graphics.widgetbase import Widget, TypedPropertyCollection, PropHolder
  26. from reportlab.graphics.charts.piecharts import AbstractPieChart, WedgeProperties, _addWedgeLabel, fixLabelOverlaps
  27. from reportlab.graphics.charts.textlabels import Label
  28. from reportlab.graphics.widgets.markers import Marker
  29. from functools import reduce
  30. class SectorProperties(WedgeProperties):
  31. """This holds descriptive information about the sectors in a doughnut chart.
  32. It is not to be confused with the 'sector itself'; this just holds
  33. a recipe for how to format one, and does not allow you to hack the
  34. angles. It can format a genuine Sector object for you with its
  35. format method.
  36. """
  37. _attrMap = AttrMap(BASE=WedgeProperties,
  38. )
  39. class Doughnut(AbstractPieChart):
  40. _attrMap = AttrMap(
  41. x = AttrMapValue(isNumber, desc='X position of the chart within its container.'),
  42. y = AttrMapValue(isNumber, desc='Y position of the chart within its container.'),
  43. width = AttrMapValue(isNumber, desc='width of doughnut bounding box. Need not be same as width.'),
  44. height = AttrMapValue(isNumber, desc='height of doughnut bounding box. Need not be same as height.'),
  45. data = AttrMapValue(EitherOr((isListOfNoneOrNumber,isListOfListOfNoneOrNumber)), desc='list of numbers defining sector sizes; need not sum to 1'),
  46. labels = AttrMapValue(isListOfStringsOrNone, desc="optional list of labels to use for each data point"),
  47. startAngle = AttrMapValue(isNumber, desc="angle of first slice; like the compass, 0 is due North"),
  48. direction = AttrMapValue(OneOf('clockwise', 'anticlockwise'), desc="'clockwise' or 'anticlockwise'"),
  49. slices = AttrMapValue(None, desc="collection of sector descriptor objects"),
  50. simpleLabels = AttrMapValue(isBoolean, desc="If true(default) use String not super duper WedgeLabel"),
  51. # advanced usage
  52. checkLabelOverlap = AttrMapValue(isBoolean, desc="If true check and attempt to fix\n standard label overlaps(default off)",advancedUsage=1),
  53. sideLabels = AttrMapValue(isBoolean, desc="If true attempt to make chart with labels along side and pointers", advancedUsage=1),
  54. innerRadiusFraction = AttrMapValue(isNumberOrNone,
  55. desc='None or the fraction of the radius to be used as the inner hole.\nIf not a suitable default will be used.'),
  56. )
  57. def __init__(self):
  58. self.x = 0
  59. self.y = 0
  60. self.width = 100
  61. self.height = 100
  62. self.data = [1,1]
  63. self.labels = None # or list of strings
  64. self.startAngle = 90
  65. self.direction = "clockwise"
  66. self.simpleLabels = 1
  67. self.checkLabelOverlap = 0
  68. self.sideLabels = 0
  69. self.innerRadiusFraction = None
  70. self.slices = TypedPropertyCollection(SectorProperties)
  71. self.slices[0].fillColor = colors.darkcyan
  72. self.slices[1].fillColor = colors.blueviolet
  73. self.slices[2].fillColor = colors.blue
  74. self.slices[3].fillColor = colors.cyan
  75. self.slices[4].fillColor = colors.pink
  76. self.slices[5].fillColor = colors.magenta
  77. self.slices[6].fillColor = colors.yellow
  78. def demo(self):
  79. d = Drawing(200, 100)
  80. dn = Doughnut()
  81. dn.x = 50
  82. dn.y = 10
  83. dn.width = 100
  84. dn.height = 80
  85. dn.data = [10,20,30,40,50,60]
  86. dn.labels = ['a','b','c','d','e','f']
  87. dn.slices.strokeWidth=0.5
  88. dn.slices[3].popout = 10
  89. dn.slices[3].strokeWidth = 2
  90. dn.slices[3].strokeDashArray = [2,2]
  91. dn.slices[3].labelRadius = 1.75
  92. dn.slices[3].fontColor = colors.red
  93. dn.slices[0].fillColor = colors.darkcyan
  94. dn.slices[1].fillColor = colors.blueviolet
  95. dn.slices[2].fillColor = colors.blue
  96. dn.slices[3].fillColor = colors.cyan
  97. dn.slices[4].fillColor = colors.aquamarine
  98. dn.slices[5].fillColor = colors.cadetblue
  99. dn.slices[6].fillColor = colors.lightcoral
  100. d.add(dn)
  101. return d
  102. def normalizeData(self, data=None):
  103. from operator import add
  104. sum = float(reduce(add,data,0))
  105. return abs(sum)>=1e-8 and list(map(lambda x,f=360./sum: f*x, data)) or len(data)*[0]
  106. def makeSectors(self):
  107. # normalize slice data
  108. data = self.data
  109. multi = isListOfListOfNoneOrNumber(data)
  110. if multi:
  111. #it's a nested list, more than one sequence
  112. normData = []
  113. n = []
  114. for l in data:
  115. t = self.normalizeData(l)
  116. normData.append(t)
  117. n.append(len(t))
  118. self._seriesCount = max(n)
  119. else:
  120. normData = self.normalizeData(data)
  121. n = len(normData)
  122. self._seriesCount = n
  123. #labels
  124. checkLabelOverlap = self.checkLabelOverlap
  125. L = []
  126. L_add = L.append
  127. labels = self.labels
  128. if labels is None:
  129. labels = []
  130. if not multi:
  131. labels = [''] * n
  132. else:
  133. for m in n:
  134. labels = list(labels) + [''] * m
  135. else:
  136. #there's no point in raising errors for less than enough labels if
  137. #we silently create all for the extreme case of no labels.
  138. if not multi:
  139. i = n-len(labels)
  140. if i>0:
  141. labels = list(labels) + [''] * i
  142. else:
  143. tlab = 0
  144. for m in n:
  145. tlab += m
  146. i = tlab-len(labels)
  147. if i>0:
  148. labels = list(labels) + [''] * i
  149. self.labels = labels
  150. xradius = self.width/2.0
  151. yradius = self.height/2.0
  152. centerx = self.x + xradius
  153. centery = self.y + yradius
  154. if self.direction == "anticlockwise":
  155. whichWay = 1
  156. else:
  157. whichWay = -1
  158. g = Group()
  159. startAngle = self.startAngle #% 360
  160. styleCount = len(self.slices)
  161. irf = self.innerRadiusFraction
  162. if multi:
  163. #multi-series doughnut
  164. ndata = len(data)
  165. if irf is None:
  166. yir = (yradius/2.5)/ndata
  167. xir = (xradius/2.5)/ndata
  168. else:
  169. yir = yradius*irf
  170. xir = xradius*irf
  171. ydr = (yradius-yir)/ndata
  172. xdr = (xradius-xir)/ndata
  173. for sn,series in enumerate(normData):
  174. for i,angle in enumerate(series):
  175. endAngle = (startAngle + (angle * whichWay)) #% 360
  176. aa = abs(startAngle-endAngle)
  177. if aa<1e-5:
  178. startAngle = endAngle
  179. continue
  180. if startAngle < endAngle:
  181. a1 = startAngle
  182. a2 = endAngle
  183. else:
  184. a1 = endAngle
  185. a2 = startAngle
  186. startAngle = endAngle
  187. #if we didn't use %stylecount here we'd end up with the later sectors
  188. #all having the default style
  189. sectorStyle = self.slices[sn,i%styleCount]
  190. # is it a popout?
  191. cx, cy = centerx, centery
  192. if sectorStyle.popout != 0:
  193. # pop out the sector
  194. averageAngle = (a1+a2)/2.0
  195. aveAngleRadians = averageAngle * pi/180.0
  196. popdistance = sectorStyle.popout
  197. cx = centerx + popdistance * cos(aveAngleRadians)
  198. cy = centery + popdistance * sin(aveAngleRadians)
  199. yr1 = yir+sn*ydr
  200. yr = yr1 + ydr
  201. xr1 = xir+sn*xdr
  202. xr = xr1 + xdr
  203. if len(series) > 1:
  204. theSector = Wedge(cx, cy, xr, a1, a2, yradius=yr, radius1=xr1, yradius1=yr1)
  205. else:
  206. theSector = Wedge(cx, cy, xr, a1, a2, yradius=yr, radius1=xr1, yradius1=yr1, annular=True)
  207. theSector.fillColor = sectorStyle.fillColor
  208. theSector.strokeColor = sectorStyle.strokeColor
  209. theSector.strokeWidth = sectorStyle.strokeWidth
  210. theSector.strokeDashArray = sectorStyle.strokeDashArray
  211. shader = sectorStyle.shadingKind
  212. if shader:
  213. nshades = aa / float(sectorStyle.shadingAngle)
  214. if nshades > 1:
  215. shader = colors.Whiter if shader=='lighten' else colors.Blacker
  216. nshades = 1+int(nshades)
  217. shadingAmount = 1-sectorStyle.shadingAmount
  218. if sectorStyle.shadingDirection=='normal':
  219. dsh = (1-shadingAmount)/float(nshades-1)
  220. shf1 = shadingAmount
  221. else:
  222. dsh = (shadingAmount-1)/float(nshades-1)
  223. shf1 = 1
  224. shda = (a2-a1)/float(nshades)
  225. shsc = sectorStyle.fillColor
  226. theSector.fillColor = None
  227. for ish in range(nshades):
  228. sha1 = a1 + ish*shda
  229. sha2 = a1 + (ish+1)*shda
  230. shc = shader(shsc,shf1 + dsh*ish)
  231. if len(series)>1:
  232. shSector = Wedge(cx, cy, xr, sha1, sha2, yradius=yr, radius1=xr1, yradius1=yr1)
  233. else:
  234. shSector = Wedge(cx, cy, xr, sha1, sha2, yradius=yr, radius1=xr1, yradius1=yr1, annular=True)
  235. shSector.fillColor = shc
  236. shSector.strokeColor = None
  237. shSector.strokeWidth = 0
  238. g.add(shSector)
  239. g.add(theSector)
  240. if sn == 0 and sectorStyle.visible and sectorStyle.label_visible:
  241. text = self.getSeriesName(i,'')
  242. if text:
  243. averageAngle = (a1+a2)/2.0
  244. aveAngleRadians = averageAngle*pi/180.0
  245. labelRadius = sectorStyle.labelRadius
  246. rx = xradius*labelRadius
  247. ry = yradius*labelRadius
  248. labelX = centerx + (0.5 * self.width * cos(aveAngleRadians) * labelRadius)
  249. labelY = centery + (0.5 * self.height * sin(aveAngleRadians) * labelRadius)
  250. l = _addWedgeLabel(self,text,averageAngle,labelX,labelY,sectorStyle)
  251. if checkLabelOverlap:
  252. l._origdata = { 'x': labelX, 'y':labelY, 'angle': averageAngle,
  253. 'rx': rx, 'ry':ry, 'cx':cx, 'cy':cy,
  254. 'bounds': l.getBounds(),
  255. }
  256. L_add(l)
  257. else:
  258. #single series doughnut
  259. if irf is None:
  260. yir = yradius/2.5
  261. xir = xradius/2.5
  262. else:
  263. yir = yradius*irf
  264. xir = xradius*irf
  265. for i,angle in enumerate(normData):
  266. endAngle = (startAngle + (angle * whichWay)) #% 360
  267. aa = abs(startAngle-endAngle)
  268. if aa<1e-5:
  269. startAngle = endAngle
  270. continue
  271. if startAngle < endAngle:
  272. a1 = startAngle
  273. a2 = endAngle
  274. else:
  275. a1 = endAngle
  276. a2 = startAngle
  277. startAngle = endAngle
  278. #if we didn't use %stylecount here we'd end up with the later sectors
  279. #all having the default style
  280. sectorStyle = self.slices[i%styleCount]
  281. # is it a popout?
  282. cx, cy = centerx, centery
  283. if sectorStyle.popout != 0:
  284. # pop out the sector
  285. averageAngle = (a1+a2)/2.0
  286. aveAngleRadians = averageAngle * pi/180.0
  287. popdistance = sectorStyle.popout
  288. cx = centerx + popdistance * cos(aveAngleRadians)
  289. cy = centery + popdistance * sin(aveAngleRadians)
  290. if n > 1:
  291. theSector = Wedge(cx, cy, xradius, a1, a2, yradius=yradius, radius1=xir, yradius1=yir)
  292. elif n==1:
  293. theSector = Wedge(cx, cy, xradius, a1, a2, yradius=yradius, radius1=xir, yradius1=yir, annular=True)
  294. theSector.fillColor = sectorStyle.fillColor
  295. theSector.strokeColor = sectorStyle.strokeColor
  296. theSector.strokeWidth = sectorStyle.strokeWidth
  297. theSector.strokeDashArray = sectorStyle.strokeDashArray
  298. shader = sectorStyle.shadingKind
  299. if shader:
  300. nshades = aa / float(sectorStyle.shadingAngle)
  301. if nshades > 1:
  302. shader = colors.Whiter if shader=='lighten' else colors.Blacker
  303. nshades = 1+int(nshades)
  304. shadingAmount = 1-sectorStyle.shadingAmount
  305. if sectorStyle.shadingDirection=='normal':
  306. dsh = (1-shadingAmount)/float(nshades-1)
  307. shf1 = shadingAmount
  308. else:
  309. dsh = (shadingAmount-1)/float(nshades-1)
  310. shf1 = 1
  311. shda = (a2-a1)/float(nshades)
  312. shsc = sectorStyle.fillColor
  313. theSector.fillColor = None
  314. for ish in range(nshades):
  315. sha1 = a1 + ish*shda
  316. sha2 = a1 + (ish+1)*shda
  317. shc = shader(shsc,shf1 + dsh*ish)
  318. if n > 1:
  319. shSector = Wedge(cx, cy, xradius, sha1, sha2, yradius=yradius, radius1=xir, yradius1=yir)
  320. elif n==1:
  321. shSector = Wedge(cx, cy, xradius, sha1, sha2, yradius=yradius, radius1=xir, yradius1=yir, annular=True)
  322. shSector.fillColor = shc
  323. shSector.strokeColor = None
  324. shSector.strokeWidth = 0
  325. g.add(shSector)
  326. g.add(theSector)
  327. # now draw a label
  328. if labels[i] and sectorStyle.visible and sectorStyle.label_visible:
  329. averageAngle = (a1+a2)/2.0
  330. aveAngleRadians = averageAngle*pi/180.0
  331. labelRadius = sectorStyle.labelRadius
  332. labelX = centerx + (0.5 * self.width * cos(aveAngleRadians) * labelRadius)
  333. labelY = centery + (0.5 * self.height * sin(aveAngleRadians) * labelRadius)
  334. rx = xradius*labelRadius
  335. ry = yradius*labelRadius
  336. l = _addWedgeLabel(self,labels[i],averageAngle,labelX,labelY,sectorStyle)
  337. if checkLabelOverlap:
  338. l._origdata = { 'x': labelX, 'y':labelY, 'angle': averageAngle,
  339. 'rx': rx, 'ry':ry, 'cx':cx, 'cy':cy,
  340. 'bounds': l.getBounds(),
  341. }
  342. L_add(l)
  343. if checkLabelOverlap and L:
  344. fixLabelOverlaps(L)
  345. for l in L: g.add(l)
  346. return g
  347. def draw(self):
  348. g = Group()
  349. g.add(self.makeSectors())
  350. return g
  351. def sample1():
  352. "Make up something from the individual Sectors"
  353. d = Drawing(400, 400)
  354. g = Group()
  355. s1 = Wedge(centerx=200, centery=200, radius=150, startangledegrees=0, endangledegrees=120, radius1=100)
  356. s1.fillColor=colors.red
  357. s1.strokeColor=None
  358. d.add(s1)
  359. s2 = Wedge(centerx=200, centery=200, radius=150, startangledegrees=120, endangledegrees=240, radius1=100)
  360. s2.fillColor=colors.green
  361. s2.strokeColor=None
  362. d.add(s2)
  363. s3 = Wedge(centerx=200, centery=200, radius=150, startangledegrees=240, endangledegrees=260, radius1=100)
  364. s3.fillColor=colors.blue
  365. s3.strokeColor=None
  366. d.add(s3)
  367. s4 = Wedge(centerx=200, centery=200, radius=150, startangledegrees=260, endangledegrees=360, radius1=100)
  368. s4.fillColor=colors.gray
  369. s4.strokeColor=None
  370. d.add(s4)
  371. return d
  372. def sample2():
  373. "Make a simple demo"
  374. d = Drawing(400, 400)
  375. dn = Doughnut()
  376. dn.x = 50
  377. dn.y = 50
  378. dn.width = 300
  379. dn.height = 300
  380. dn.data = [10,20,30,40,50,60]
  381. d.add(dn)
  382. return d
  383. def sample3():
  384. "Make a more complex demo"
  385. d = Drawing(400, 400)
  386. dn = Doughnut()
  387. dn.x = 50
  388. dn.y = 50
  389. dn.width = 300
  390. dn.height = 300
  391. dn.data = [[10,20,30,40,50,60], [10,20,30,40]]
  392. dn.labels = ['a','b','c','d','e','f']
  393. d.add(dn)
  394. return d
  395. def sample4():
  396. "Make a more complex demo with Label Overlap fixing"
  397. d = Drawing(400, 400)
  398. dn = Doughnut()
  399. dn.x = 50
  400. dn.y = 50
  401. dn.width = 300
  402. dn.height = 300
  403. dn.data = [[10,20,30,40,50,60], [10,20,30,40]]
  404. dn.labels = ['a','b','c','d','e','f']
  405. dn.checkLabelOverlap = True
  406. d.add(dn)
  407. return d
  408. if __name__=='__main__':
  409. from reportlab.graphics.renderPDF import drawToFile
  410. d = sample1()
  411. drawToFile(d, 'doughnut1.pdf')
  412. d = sample2()
  413. drawToFile(d, 'doughnut2.pdf')
  414. d = sample3()
  415. drawToFile(d, 'doughnut3.pdf')