spider.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  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/spider.py
  4. # spider chart, also known as radar chart
  5. __version__='3.3.0'
  6. __doc__="""Spider Chart
  7. Normal use shows variation of 5-10 parameters against some 'norm' or target.
  8. When there is more than one series, place the series with the largest
  9. numbers first, as it will be overdrawn by each successive one.
  10. """
  11. import copy
  12. from math import sin, cos, pi
  13. from reportlab.lib import colors
  14. from reportlab.lib.validators import isColor, isNumber, isListOfNumbersOrNone,\
  15. isListOfNumbers, isColorOrNone, isString,\
  16. isListOfStringsOrNone, OneOf, SequenceOf,\
  17. isBoolean, isListOfColors, isNumberOrNone,\
  18. isNoneOrListOfNoneOrStrings, isTextAnchor,\
  19. isNoneOrListOfNoneOrNumbers, isBoxAnchor,\
  20. isStringOrNone, isStringOrNone, EitherOr,\
  21. isCallable
  22. from reportlab.lib.attrmap import *
  23. from reportlab.pdfgen.canvas import Canvas
  24. from reportlab.graphics.shapes import Group, Drawing, Line, Rect, Polygon, PolyLine, Ellipse, \
  25. Wedge, String, STATE_DEFAULTS
  26. from reportlab.graphics.widgetbase import Widget, TypedPropertyCollection, PropHolder
  27. from reportlab.graphics.charts.areas import PlotArea
  28. from reportlab.graphics.charts.legends import _objStr
  29. from reportlab.graphics.charts.piecharts import WedgeLabel
  30. from reportlab.graphics.widgets.markers import makeMarker, uSymbol2Symbol, isSymbol
  31. class StrandProperty(PropHolder):
  32. _attrMap = AttrMap(
  33. strokeWidth = AttrMapValue(isNumber,desc='width'),
  34. fillColor = AttrMapValue(isColorOrNone,desc='filling color'),
  35. strokeColor = AttrMapValue(isColorOrNone,desc='stroke color'),
  36. strokeDashArray = AttrMapValue(isListOfNumbersOrNone,desc='dashing pattern, e.g. (3,2)'),
  37. symbol = AttrMapValue(EitherOr((isStringOrNone,isSymbol)), desc='Widget placed at data points.',advancedUsage=1),
  38. symbolSize= AttrMapValue(isNumber, desc='Symbol size.',advancedUsage=1),
  39. name = AttrMapValue(isStringOrNone, desc='Name of the strand.'),
  40. )
  41. def __init__(self):
  42. self.strokeWidth = 1
  43. self.fillColor = None
  44. self.strokeColor = STATE_DEFAULTS["strokeColor"]
  45. self.strokeDashArray = STATE_DEFAULTS["strokeDashArray"]
  46. self.symbol = None
  47. self.symbolSize = 5
  48. self.name = None
  49. class SpokeProperty(PropHolder):
  50. _attrMap = AttrMap(
  51. strokeWidth = AttrMapValue(isNumber,desc='width'),
  52. fillColor = AttrMapValue(isColorOrNone,desc='filling color'),
  53. strokeColor = AttrMapValue(isColorOrNone,desc='stroke color'),
  54. strokeDashArray = AttrMapValue(isListOfNumbersOrNone,desc='dashing pattern, e.g. (2,1)'),
  55. labelRadius = AttrMapValue(isNumber,desc='label radius',advancedUsage=1),
  56. visible = AttrMapValue(isBoolean,desc="True if the spoke line is to be drawn"),
  57. )
  58. def __init__(self,**kw):
  59. self.strokeWidth = 0.5
  60. self.fillColor = None
  61. self.strokeColor = STATE_DEFAULTS["strokeColor"]
  62. self.strokeDashArray = STATE_DEFAULTS["strokeDashArray"]
  63. self.visible = 1
  64. self.labelRadius = 1.05
  65. class SpokeLabel(WedgeLabel):
  66. def __init__(self,**kw):
  67. WedgeLabel.__init__(self,**kw)
  68. if '_text' not in list(kw.keys()): self._text = ''
  69. class StrandLabel(SpokeLabel):
  70. _attrMap = AttrMap(BASE=SpokeLabel,
  71. format = AttrMapValue(EitherOr((isStringOrNone,isCallable)),desc="Format for the label"),
  72. dR = AttrMapValue(isNumberOrNone,desc="radial shift for label"),
  73. )
  74. def __init__(self,**kw):
  75. self.format = ''
  76. self.dR = 0
  77. SpokeLabel.__init__(self,**kw)
  78. def _setupLabel(labelClass, text, radius, cx, cy, angle, car, sar, sty):
  79. L = labelClass()
  80. L._text = text
  81. L.x = cx + radius*car
  82. L.y = cy + radius*sar
  83. L._pmv = angle*180/pi
  84. L.boxAnchor = sty.boxAnchor
  85. L.dx = sty.dx
  86. L.dy = sty.dy
  87. L.angle = sty.angle
  88. L.boxAnchor = sty.boxAnchor
  89. L.boxStrokeColor = sty.boxStrokeColor
  90. L.boxStrokeWidth = sty.boxStrokeWidth
  91. L.boxFillColor = sty.boxFillColor
  92. L.strokeColor = sty.strokeColor
  93. L.strokeWidth = sty.strokeWidth
  94. L.leading = sty.leading
  95. L.width = sty.width
  96. L.maxWidth = sty.maxWidth
  97. L.height = sty.height
  98. L.textAnchor = sty.textAnchor
  99. L.visible = sty.visible
  100. L.topPadding = sty.topPadding
  101. L.leftPadding = sty.leftPadding
  102. L.rightPadding = sty.rightPadding
  103. L.bottomPadding = sty.bottomPadding
  104. L.fontName = sty.fontName
  105. L.fontSize = sty.fontSize
  106. L.fillColor = sty.fillColor
  107. return L
  108. class SpiderChart(PlotArea):
  109. _attrMap = AttrMap(BASE=PlotArea,
  110. data = AttrMapValue(None, desc='Data to be plotted, list of (lists of) numbers.'),
  111. labels = AttrMapValue(isListOfStringsOrNone, desc="optional list of labels to use for each data point"),
  112. startAngle = AttrMapValue(isNumber, desc="angle of first slice; like the compass, 0 is due North"),
  113. direction = AttrMapValue( OneOf('clockwise', 'anticlockwise'), desc="'clockwise' or 'anticlockwise'"),
  114. strands = AttrMapValue(None, desc="collection of strand descriptor objects"),
  115. spokes = AttrMapValue(None, desc="collection of spoke descriptor objects"),
  116. strandLabels = AttrMapValue(None, desc="collection of strand label descriptor objects"),
  117. spokeLabels = AttrMapValue(None, desc="collection of spoke label descriptor objects"),
  118. )
  119. def makeSwatchSample(self, rowNo, x, y, width, height):
  120. baseStyle = self.strands
  121. styleIdx = rowNo % len(baseStyle)
  122. style = baseStyle[styleIdx]
  123. strokeColor = getattr(style, 'strokeColor', getattr(baseStyle,'strokeColor',None))
  124. fillColor = getattr(style, 'fillColor', getattr(baseStyle,'fillColor',None))
  125. strokeDashArray = getattr(style, 'strokeDashArray', getattr(baseStyle,'strokeDashArray',None))
  126. strokeWidth = getattr(style, 'strokeWidth', getattr(baseStyle, 'strokeWidth',0))
  127. symbol = getattr(style, 'symbol', getattr(baseStyle, 'symbol',None))
  128. ym = y+height/2.0
  129. if fillColor is None and strokeColor is not None and strokeWidth>0:
  130. bg = Line(x,ym,x+width,ym,strokeWidth=strokeWidth,strokeColor=strokeColor,
  131. strokeDashArray=strokeDashArray)
  132. elif fillColor is not None:
  133. bg = Rect(x,y,width,height,strokeWidth=strokeWidth,strokeColor=strokeColor,
  134. strokeDashArray=strokeDashArray,fillColor=fillColor)
  135. else:
  136. bg = None
  137. if symbol:
  138. symbol = uSymbol2Symbol(symbol,x+width/2.,ym,color)
  139. if bg:
  140. g = Group()
  141. g.add(bg)
  142. g.add(symbol)
  143. return g
  144. return symbol or bg
  145. def getSeriesName(self,i,default=None):
  146. '''return series name i or default'''
  147. return _objStr(getattr(self.strands[i],'name',default))
  148. def __init__(self):
  149. PlotArea.__init__(self)
  150. self.data = [[10,12,14,16,14,12], [6,8,10,12,9,11]]
  151. self.labels = None # or list of strings
  152. self.labels = ['a','b','c','d','e','f']
  153. self.startAngle = 90
  154. self.direction = "clockwise"
  155. self.strands = TypedPropertyCollection(StrandProperty)
  156. self.spokes = TypedPropertyCollection(SpokeProperty)
  157. self.spokeLabels = TypedPropertyCollection(SpokeLabel)
  158. self.spokeLabels._text = None
  159. self.strandLabels = TypedPropertyCollection(StrandLabel)
  160. self.x = 10
  161. self.y = 10
  162. self.width = 180
  163. self.height = 180
  164. def demo(self):
  165. d = Drawing(200, 200)
  166. d.add(SpiderChart())
  167. return d
  168. def normalizeData(self, outer = 0.0):
  169. """Turns data into normalized ones where each datum is < 1.0,
  170. and 1.0 = maximum radius. Adds 10% at outside edge by default"""
  171. data = self.data
  172. assert min(list(map(min,data))) >=0, "Cannot do spider plots of negative numbers!"
  173. norm = max(list(map(max,data)))
  174. norm *= (1.0+outer)
  175. if norm<1e-9: norm = 1.0
  176. self._norm = norm
  177. return [[e/norm for e in row] for row in data]
  178. def _innerDrawLabel(self, sty, radius, cx, cy, angle, car, sar, labelClass=StrandLabel):
  179. "Draw a label for a given item in the list."
  180. fmt = sty.format
  181. value = radius*self._norm
  182. if not fmt:
  183. text = None
  184. elif isinstance(fmt,str):
  185. if fmt == 'values':
  186. text = sty._text
  187. else:
  188. text = fmt % value
  189. elif hasattr(fmt,'__call__'):
  190. text = fmt(value)
  191. else:
  192. raise ValueError("Unknown formatter type %s, expected string or function" % fmt)
  193. if text:
  194. dR = sty.dR
  195. if dR:
  196. radius += dR/self._radius
  197. L = _setupLabel(labelClass, text, radius, cx, cy, angle, car, sar, sty)
  198. if dR<0: L._anti = 1
  199. else:
  200. L = None
  201. return L
  202. def draw(self):
  203. # normalize slice data
  204. g = self.makeBackground() or Group()
  205. xradius = self.width/2.0
  206. yradius = self.height/2.0
  207. self._radius = radius = min(xradius, yradius)
  208. cx = self.x + xradius
  209. cy = self.y + yradius
  210. data = self.normalizeData()
  211. self._seriesCount = len(data)
  212. n = len(data[0])
  213. #labels
  214. if self.labels is None:
  215. labels = [''] * n
  216. else:
  217. labels = self.labels
  218. #there's no point in raising errors for less than enough errors if
  219. #we silently create all for the extreme case of no labels.
  220. i = n-len(labels)
  221. if i>0:
  222. labels = labels + ['']*i
  223. S = []
  224. STRANDS = []
  225. STRANDAREAS = []
  226. syms = []
  227. labs = []
  228. csa = []
  229. angle = self.startAngle*pi/180
  230. direction = self.direction == "clockwise" and -1 or 1
  231. angleBetween = direction*(2 * pi)/float(n)
  232. spokes = self.spokes
  233. spokeLabels = self.spokeLabels
  234. for i in range(n):
  235. car = cos(angle)*radius
  236. sar = sin(angle)*radius
  237. csa.append((car,sar,angle))
  238. si = self.spokes[i]
  239. if si.visible:
  240. spoke = Line(cx, cy, cx + car, cy + sar, strokeWidth = si.strokeWidth, strokeColor=si.strokeColor, strokeDashArray=si.strokeDashArray)
  241. S.append(spoke)
  242. sli = spokeLabels[i]
  243. text = sli._text
  244. if not text: text = labels[i]
  245. if text:
  246. S.append(_setupLabel(WedgeLabel, text, si.labelRadius, cx, cy, angle, car, sar, sli))
  247. angle += angleBetween
  248. # now plot the polygons
  249. rowIdx = 0
  250. strands = self.strands
  251. strandLabels = self.strandLabels
  252. for row in data:
  253. # series plot
  254. rsty = strands[rowIdx]
  255. points = []
  256. car, sar = csa[-1][:2]
  257. r = row[-1]
  258. points.append(cx+car*r)
  259. points.append(cy+sar*r)
  260. for i in range(n):
  261. car, sar, angle = csa[i]
  262. r = row[i]
  263. points.append(cx+car*r)
  264. points.append(cy+sar*r)
  265. L = self._innerDrawLabel(strandLabels[(rowIdx,i)], r, cx, cy, angle, car, sar, labelClass=StrandLabel)
  266. if L: labs.append(L)
  267. sty = strands[(rowIdx,i)]
  268. uSymbol = sty.symbol
  269. # put in a marker, if it needs one
  270. if uSymbol:
  271. s_x = cx+car*r
  272. s_y = cy+sar*r
  273. s_fillColor = sty.fillColor
  274. s_strokeColor = sty.strokeColor
  275. s_strokeWidth = sty.strokeWidth
  276. s_angle = 0
  277. s_size = sty.symbolSize
  278. if type(uSymbol) is type(''):
  279. symbol = makeMarker(uSymbol,
  280. size = s_size,
  281. x = s_x,
  282. y = s_y,
  283. fillColor = s_fillColor,
  284. strokeColor = s_strokeColor,
  285. strokeWidth = s_strokeWidth,
  286. angle = s_angle,
  287. )
  288. else:
  289. symbol = uSymbol2Symbol(uSymbol,s_x,s_y,s_fillColor)
  290. for k,v in (('size', s_size), ('fillColor', s_fillColor),
  291. ('x', s_x), ('y', s_y),
  292. ('strokeColor',s_strokeColor), ('strokeWidth',s_strokeWidth),
  293. ('angle',s_angle),):
  294. if getattr(symbol,k,None) is None:
  295. try:
  296. setattr(symbol,k,v)
  297. except:
  298. pass
  299. syms.append(symbol)
  300. # make up the 'strand'
  301. if rsty.fillColor:
  302. strand = Polygon(points)
  303. strand.fillColor = rsty.fillColor
  304. strand.strokeColor = None
  305. strand.strokeWidth = 0
  306. STRANDAREAS.append(strand)
  307. if rsty.strokeColor and rsty.strokeWidth:
  308. strand = PolyLine(points)
  309. strand.strokeColor = rsty.strokeColor
  310. strand.strokeWidth = rsty.strokeWidth
  311. strand.strokeDashArray = rsty.strokeDashArray
  312. STRANDS.append(strand)
  313. rowIdx += 1
  314. for s in (STRANDAREAS+STRANDS+syms+S+labs): g.add(s)
  315. return g
  316. def sample1():
  317. "Make a simple spider chart"
  318. d = Drawing(400, 400)
  319. sp = SpiderChart()
  320. sp.x = 50
  321. sp.y = 50
  322. sp.width = 300
  323. sp.height = 300
  324. sp.data = [[10,12,14,16,14,12], [6,8,10,12,9,15],[7,8,17,4,12,8]]
  325. sp.labels = ['a','b','c','d','e','f']
  326. sp.strands[0].strokeColor = colors.cornsilk
  327. sp.strands[1].strokeColor = colors.cyan
  328. sp.strands[2].strokeColor = colors.palegreen
  329. sp.strands[0].fillColor = colors.cornsilk
  330. sp.strands[1].fillColor = colors.cyan
  331. sp.strands[2].fillColor = colors.palegreen
  332. sp.spokes.strokeDashArray = (2,2)
  333. d.add(sp)
  334. return d
  335. def sample2():
  336. "Make a spider chart with markers, but no fill"
  337. d = Drawing(400, 400)
  338. sp = SpiderChart()
  339. sp.x = 50
  340. sp.y = 50
  341. sp.width = 300
  342. sp.height = 300
  343. sp.data = [[10,12,14,16,14,12], [6,8,10,12,9,15],[7,8,17,4,12,8]]
  344. sp.labels = ['U','V','W','X','Y','Z']
  345. sp.strands.strokeWidth = 1
  346. sp.strands[0].fillColor = colors.pink
  347. sp.strands[1].fillColor = colors.lightblue
  348. sp.strands[2].fillColor = colors.palegreen
  349. sp.strands[0].strokeColor = colors.red
  350. sp.strands[1].strokeColor = colors.blue
  351. sp.strands[2].strokeColor = colors.green
  352. sp.strands.symbol = "FilledDiamond"
  353. sp.strands[1].symbol = makeMarker("Circle")
  354. sp.strands[1].symbol.strokeWidth = 0.5
  355. sp.strands[1].symbol.fillColor = colors.yellow
  356. sp.strands.symbolSize = 6
  357. sp.strandLabels[0,3]._text = 'special'
  358. sp.strandLabels[0,1]._text = 'one'
  359. sp.strandLabels[0,0]._text = 'zero'
  360. sp.strandLabels[1,0]._text = 'Earth'
  361. sp.strandLabels[2,2]._text = 'Mars'
  362. sp.strandLabels.format = 'values'
  363. sp.strandLabels.dR = -5
  364. d.add(sp)
  365. return d
  366. if __name__=='__main__':
  367. d = sample1()
  368. from reportlab.graphics.renderPDF import drawToFile
  369. drawToFile(d, 'spider.pdf')
  370. d = sample2()
  371. drawToFile(d, 'spider2.pdf')