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