1 # -*- coding: iso-8859-1 -*- 2 """ 3 MoinMoin - SVGChart library 4 5 @copyright: 2011 by Paul Boddie <paul@boddie.org.uk> 6 @license: GNU GPL (v2 or later), see COPYING.txt for details. 7 """ 8 9 import math 10 11 template = u"""<?xml version="1.0" encoding="%(encoding)s"?> 12 <?xml-stylesheet href="%(styles_url)s" type="text/css"?> 13 <svg xmlns="http://www.w3.org/2000/svg" version="1.1" 14 width="%(width)dpx" height="%(height)dpx" viewBox="%(viewBox)s"> 15 16 <g> 17 <defs> 18 <linearGradient id="label-gradient" x1="0%%" x2="100%%" y1="0%%" y2="100%%"> 19 <stop offset="20%%" stop-color="#fff" stop-opacity="0" /> 20 <stop offset="80%%" stop-color="#fff" stop-opacity="1" /> 21 </linearGradient> 22 </defs> 23 %(chart)s 24 </g> 25 </svg> 26 """ 27 28 path_template = u"""<path class="%(class)s" d="%(data)s" /> 29 """ 30 31 circle_template = u"""<circle class="%(class)s" cx="%(x)f" cy="%(y)f" r="%(r)f" /> 32 """ 33 34 label_template = u"""<text class="%(class)s" 35 x="%(x)f" y="%(y)f" font-size="%(font-size)s" %(rotate)s>%(label)s</text> 36 """ 37 38 rect_template = u"""<rect x="%(x)f" y="%(y)f" width="%(width)f" height="%(height)f" class="%(class)s" 39 transform="%(transform)s" /> 40 """ 41 42 labelled_point_template = u"""<g class="point"> 43 %(rect)s 44 %(circle)s 45 %(large-circle)s 46 %(label)s 47 %(x-line)s 48 %(y-axis-label)s 49 %(y-line)s 50 %(x-axis-label)s 51 </g> 52 """ 53 54 class Plot: 55 56 "Support for plotting points and lines." 57 58 def __init__(self, xmin, ymin, xmax, ymax, xmultiplier, ymultiplier): 59 self.xmin = xmin 60 self.ymin = ymin 61 self.xmax = xmax 62 self.ymax = ymax 63 self.xmultiplier = xmultiplier 64 self.ymultiplier = ymultiplier 65 66 def get_width(self): 67 return abs(self.xmax - self.xmin) 68 69 def get_scaled_width(self): 70 return abs((self.xmax - self.xmin) * self.xmultiplier) 71 72 def get_height(self): 73 return abs(self.ymax - self.ymin) 74 75 def get_scaled_height(self): 76 return abs((self.ymax - self.ymin) * self.ymultiplier) 77 78 def get_xbase(self): 79 return min(self.xmin * self.xmultiplier, self.xmax * self.xmultiplier) 80 81 def get_ybase(self): 82 return min(self.ymin * self.ymultiplier, self.ymax * self.ymultiplier) 83 84 def xpc(self, percent): 85 return percent * self.get_width() / 100.0 86 87 def ypc(self, percent): 88 return percent * self.get_height() / 100.0 89 90 def scaled_xpc(self, percent): 91 return percent * self.get_scaled_width() / 100.0 92 93 def scaled_ypc(self, percent): 94 return percent * self.get_scaled_height() / 100.0 95 96 def scale_x(self, x): 97 return x * self.xmultiplier 98 99 def scale_y(self, y): 100 return y * self.ymultiplier 101 102 # Plotting methods. 103 104 def get_points(self, points, attributes=None): 105 circles = [] 106 for x, y, r in points: 107 circles.append(self.get_point(x, y, r, attributes)) 108 return "".join(circles) 109 110 def get_point(self, x, y, r, attributes=None): 111 x, y = self._scale(x, y) 112 attrs = {"x" : x, "y" : y, "r" : r, "class" : ""} 113 if attributes: 114 attrs.update(attributes) 115 return circle_template % attrs 116 117 def get_rect(self, xmin, ymin, xmax, ymax, attributes=None): 118 xmin, ymin = self._scale(xmin, ymin) 119 xmax, ymax = self._scale(xmax, ymax) 120 attrs = {"x" : xmin, "y" : ymin, "width" : abs(xmax - xmin), "height" : abs(ymax - ymin)} 121 122 # Flip the rectangle around (xmin, ymin) if appropriate. 123 124 if xmax < xmin and ymax < ymin: 125 attrs["transform"] = "translate(%d %d) matrix(-1 0 0 -1 0 0) translate(%d %d)" % (xmin, ymin, -xmin, -ymin) 126 elif xmax < xmin: 127 attrs["transform"] = "translate(%d %d) matrix(-1 0 0 1 0 0) translate(%d %d)" % (xmin, ymin, -xmin, -ymin) 128 elif ymax < ymin: 129 attrs["transform"] = "translate(%d %d) matrix(1 0 0 -1 0 0) translate(%d %d)" % (xmin, ymin, -xmin, -ymin) 130 else: 131 attrs["transform"] = "" 132 133 if attributes: 134 attrs.update(attributes) 135 return rect_template % attrs 136 137 def get_line(self, segments, attributes=None): 138 scaled = [] 139 for from_, to_ in segments: 140 from_x, from_y = from_[:2] 141 to_x, to_y = to_[:2] 142 scaled.append((self._scale(from_x, from_y), self._scale(to_x, to_y))) 143 attrs = {"data" : make_path_data(scaled), "class" : ""} 144 if attributes: 145 attrs.update(attributes) 146 return path_template % attrs 147 148 def get_label(self, x, y, text, attributes=None): 149 x, y = self._scale(x, y) 150 attrs = {"x" : x, "y" : y, "label" : text, "class" : "", "rotate" : ""} 151 if attributes: 152 attrs.update(attributes) 153 if attrs["rotate"]: 154 attrs["rotate"] = 'transform="translate(%d %d) rotate(%d) translate(%d %d)"' % (x, y, attrs["rotate"], -x, -y) 155 return label_template % attrs 156 157 def _scale(self, x, y): 158 return self.scale_x(x), self.scale_y(y) 159 160 class Axis: 161 162 "Support for an axis with labels." 163 164 example_sequence = range(0, 100, 2) 165 166 def __init__(self, plot, vertical=1, position=0, sequence=example_sequence, divisions=10, minorwidth=1, majorwidth=2): 167 168 "Initialise an axis." 169 170 self.plot = plot 171 self.vertical = vertical 172 self.position = position 173 self.sequence = sequence 174 self.divisions = divisions 175 self.minorwidth = minorwidth 176 self.majorwidth = majorwidth 177 178 def get_axis(self, attributes=None): 179 180 "Draw the axis itself using the given, optional 'attributes'." 181 182 l = [((self.position, self.sequence[0]), (self.position, self.sequence[-1]))] 183 184 # For each step, mark the axis with minor and major markings. 185 186 for i, value in enumerate(self.sequence): 187 if i % self.divisions == 0: 188 markwidth = self.majorwidth 189 else: 190 markwidth = self.minorwidth 191 192 l.append(( 193 (self.position - markwidth, value), 194 (self.position + markwidth, value) 195 )) 196 197 # Transpose coordinates for horizontal axes, if necessary. 198 199 l = [self._transpose_segment(segment) for segment in l] 200 201 # Return the path. 202 203 return self.plot.get_line(l, attributes) 204 205 def get_labels(self, attributes=None, labeller=None, rotate=None): 206 207 """ 208 Plot the labels using the given, optional 'attributes' and 'labeller' 209 callable. 210 """ 211 212 labeller = labeller or (lambda x: x) 213 214 # Label each major marking. 215 216 l = [] 217 218 for i, value in enumerate(self.sequence): 219 if i % self.divisions == 0: 220 l.append(( 221 (self.position - self.majorwidth * 2, value), 222 labeller(value) 223 )) 224 225 # Transpose coordinates for horizontal axes, if necessary. 226 227 l = [self._transpose_label(label) for label in l] 228 229 # Return the labels. 230 231 labels = [] 232 for (x, y), label in l: 233 attrs = {} 234 if attributes: 235 attrs.update(attributes) 236 attrs["class"] = attrs.get("class", "") + (self.vertical and " y-axis" or " x-axis") 237 if rotate: 238 attrs["rotate"] = rotate 239 labels.append(self.plot.get_label(x, y, label, attrs)) 240 241 return "".join(labels) 242 243 def _transpose_label(self, label): 244 if self.vertical: 245 return label 246 (from_x, from_y), text = label 247 return (from_y, from_x), text 248 249 def _transpose_segment(self, segment): 250 if self.vertical: 251 return segment 252 (from_x, from_y), (to_x, to_y) = segment 253 return (from_y, from_x), (to_y, to_x) 254 255 # Plotting functions. 256 257 def get_labelled_points(plot, x_axis, y_axis, points, offset_x, offset_y, font_height, axis_label_x=0, axis_label_y=0, attributes=None): 258 259 circles = [] 260 for x, y, r, text, y_axis_label, x_axis_label in points: 261 circles.append(get_labelled_point(plot, x_axis, y_axis, x, y, r, text, offset_x, offset_y, font_height, 262 y_axis_label, x_axis_label, axis_label_x, axis_label_y, attributes)) 263 return "".join(circles) 264 265 def get_labelled_point(plot, x_axis, y_axis, x, y, r, text, offset_x, offset_y, font_height, y_axis_label="", x_axis_label="", 266 axis_label_x=0, axis_label_y=0, attributes=None): 267 268 if x > x_axis.position: 269 axis_label_x = x_axis.position - axis_label_x 270 y_axis_label_class = "west" 271 else: 272 axis_label_x = x_axis.position + axis_label_x 273 y_axis_label_class = "east" 274 275 if y > y_axis.position: 276 axis_label_y = y_axis.position - axis_label_y 277 x_axis_label_class = "south" 278 else: 279 axis_label_y = y_axis.position + axis_label_y 280 x_axis_label_class = "north" 281 282 circle = plot.get_point(x, y, r, attributes) 283 label = plot.get_label(x + offset_x, y + offset_y, text, attributes) 284 285 # Make a special active area for the point details. 286 287 if text: 288 attrs = {"class" : ""} 289 if attributes: 290 attrs.update(attributes) 291 attrs["class"] += " active" 292 rect = plot.get_rect(x, y - offset_y, axis_label_x, y + offset_y, attrs) + \ 293 plot.get_rect(x - offset_x, y, x + offset_x, axis_label_y, attrs) 294 else: 295 rect = "" 296 297 # Make a special large point. 298 299 attrs = {"class" : ""} 300 if attributes: 301 attrs.update(attributes) 302 attrs["class"] += " large" 303 large_circle = plot.get_point(x, y, r * 2, attrs) 304 305 # Make lines intersecting the y-axis and x-axis. 306 307 attrs = {"class" : ""} 308 if attributes: 309 attrs.update(attributes) 310 attrs["class"] += " to-axis" 311 x_line = plot.get_line([((axis_label_x, y), (x, y))], attrs) 312 y_line = plot.get_line([((x, axis_label_y), (x, y))], attrs) 313 314 # Make axis labels. 315 316 attrs = {} 317 if attributes: 318 attrs.update(attributes) 319 classes = attrs.get("class", "") 320 321 if y_axis_label: 322 attrs["class"] = classes + " axis-label %s" % y_axis_label_class 323 y_axis_label = plot.get_label(axis_label_x, y, y_axis_label, attrs) 324 else: 325 y_axis_label = "" 326 327 if x_axis_label: 328 attrs["class"] = classes + " axis-label %s" % x_axis_label_class 329 x_axis_label = plot.get_label(x, axis_label_y, x_axis_label, attrs) 330 else: 331 x_axis_label = "" 332 333 # Combine the different parts. 334 335 attrs = {"circle" : circle, "label" : label, "large-circle" : large_circle, "rect" : rect, 336 "x-line" : x_line, "x-axis-label" : x_axis_label, "y-line" : y_line, "y-axis-label" : y_axis_label} 337 if attributes: 338 attrs.update(attributes) 339 return labelled_point_template % attrs 340 341 # Utility functions. 342 343 def make_path_data(segments): 344 parts = [] 345 for (from_x, from_y), (to_x, to_y) in segments: 346 parts.append("M %f,%f" % (from_x, from_y)) 347 parts.append("L %f,%f" % (to_x, to_y)) 348 return " ".join(parts) + " z" 349 350 def segments_from_points(points): 351 segments = [] 352 last = None 353 for point in points: 354 if last is not None: 355 segments.append((last, point)) 356 last = point 357 return segments 358 359 def round(x, interval, direction=1): 360 q, r = divmod(x, interval) 361 if direction == -1 and q < 0: 362 direction = 0 363 return (q + direction) * interval 364 365 def frange(start, stop, step): 366 value = start 367 l = [] 368 while value < stop: 369 l.append(value) 370 value += step 371 return l 372 373 def get_chart(data, chart_width=900, chart_height=None, xorigin=0, yorigin=0, xdivisions=10, ydivisions=10, encoding="utf-8", 374 styles_url=""): 375 376 new_data = [] 377 for t in data: 378 x, y = t[:2] 379 new_data.append([float(x), float(y)] + t[2:]) 380 data = new_data 381 382 # Get the data properties. 383 384 min_x = min([t[0] for t in data]) 385 min_y = min([t[1] for t in data]) 386 max_x = max([t[0] for t in data]) 387 max_y = max([t[1] for t in data]) 388 389 # Get the chart properties. 390 391 xinterval = math.pow(10, math.floor(math.log10(max(abs(max_x), abs(min_x))))) 392 yinterval = math.pow(10, math.floor(math.log10(max(abs(max_y), abs(min_y))))) 393 394 xmin, xmax = round(min_x, xinterval, -1), round(max_x, xinterval) 395 ymin, ymax = round(min_y, yinterval, -1), round(max_y, yinterval) 396 397 xstep = float(xinterval) / xdivisions 398 ystep = float(yinterval) / ydivisions 399 400 xsequence = frange(min(xorigin, xmin), max(xorigin, xmax) + xstep, xstep) 401 ysequence = frange(min(xorigin, ymin), max(yorigin, ymax) + ystep, ystep) 402 403 xmultiplier = float(ymax) / xmax 404 ymultiplier = -1 405 406 # Initialise the chart components. 407 408 plot = Plot(xmin, ymin, xmax, ymax, xmultiplier, ymultiplier) 409 410 axis_label_x = plot.xpc(20) 411 axis_label_y = plot.ypc(15) 412 413 width = plot.get_width() 414 height = plot.get_height() 415 416 x_axis = Axis(plot, 0, yorigin, xsequence, xdivisions, plot.ypc(0.5), plot.ypc(1)) 417 y_axis = Axis(plot, 1, xorigin, ysequence, ydivisions, plot.xpc(0.5), plot.xpc(1)) 418 419 # Render the chart. 420 421 all_elements = [] 422 423 # Point radius. 424 425 radius = plot.scaled_xpc(1) 426 427 # Label position. 428 429 labelx, labely = plot.xpc(2), -plot.ypc(2) 430 font_height = -plot.ypc(4) 431 432 text_styles = {"font-size" : plot.scaled_xpc(4)} 433 434 # Render the axes. 435 # NOTE: Should detect the precision in the data and label appropriately. 436 437 labeller = (lambda x: "%.2f" % x) 438 styles = {"class" : "axis"} 439 440 all_elements.append(y_axis.get_axis(styles)) 441 all_elements.append(y_axis.get_labels(text_styles, labeller)) 442 443 label_styles = {} 444 label_styles.update(text_styles) 445 446 all_elements.append(x_axis.get_axis(styles)) 447 all_elements.append(x_axis.get_labels(label_styles, labeller)) 448 449 # Render the data. 450 451 points = [] 452 for t in data: 453 x, y = t[:2] 454 if len(t) > 2: 455 label_text = t[2] 456 else: 457 label_text = "(%s, %s)" % (x, y) 458 points.append((x, y, radius, label_text, y, x)) 459 460 # Render the line and labelled points. 461 462 styles = {} 463 styles.update(text_styles) 464 465 all_elements.append(plot.get_line(segments_from_points(points), styles)) 466 467 all_elements.append(get_labelled_points(plot, x_axis, y_axis, points, labelx, labely, font_height, 468 axis_label_x, axis_label_y, attributes=styles)) 469 470 # Work out the extent of the chart. 471 472 left = min(plot.get_xbase(), plot.scale_x(xorigin)) - plot.scaled_xpc(40) 473 left_to_right = plot.scaled_xpc(180) 474 bottom = min(plot.get_ybase(), plot.scale_y(yorigin)) - plot.scaled_ypc(10) 475 bottom_to_top = plot.scaled_ypc(180) 476 477 if chart_width is not None: 478 if chart_height is None: 479 chart_height = chart_width / left_to_right * bottom_to_top 480 else: 481 if chart_height is not None: 482 chart_width = chart_height / bottom_to_top * left_to_right 483 484 # Write the SVG file. 485 486 return template % { 487 "width" : int(chart_width), 488 "height" : int(chart_height), 489 "viewBox" : "%d %d %d %d" % ( 490 left, bottom, left_to_right, bottom_to_top, 491 ), 492 "chart" : "".join(all_elements), 493 "styles_url" : styles_url, 494 "encoding" : encoding 495 } 496 497 # vim: tabstop=4 expandtab shiftwidth=4