1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000
1.2 +++ b/SVGChartSupport.py Sun May 29 01:54:00 2011 +0200
1.3 @@ -0,0 +1,497 @@
1.4 +# -*- coding: iso-8859-1 -*-
1.5 +"""
1.6 + MoinMoin - SVGChart library
1.7 +
1.8 + @copyright: 2011 by Paul Boddie <paul@boddie.org.uk>
1.9 + @license: GNU GPL (v2 or later), see COPYING.txt for details.
1.10 +"""
1.11 +
1.12 +import math
1.13 +
1.14 +template = u"""<?xml version="1.0" encoding="%(encoding)s"?>
1.15 +<?xml-stylesheet href="%(styles_url)s" type="text/css"?>
1.16 +<svg xmlns="http://www.w3.org/2000/svg" version="1.1"
1.17 + width="%(width)dpx" height="%(height)dpx" viewBox="%(viewBox)s">
1.18 +
1.19 +<g>
1.20 + <defs>
1.21 + <linearGradient id="label-gradient" x1="0%%" x2="100%%" y1="0%%" y2="100%%">
1.22 + <stop offset="20%%" stop-color="#fff" stop-opacity="0" />
1.23 + <stop offset="80%%" stop-color="#fff" stop-opacity="1" />
1.24 + </linearGradient>
1.25 + </defs>
1.26 +%(chart)s
1.27 +</g>
1.28 +</svg>
1.29 +"""
1.30 +
1.31 +path_template = u"""<path class="%(class)s" d="%(data)s" />
1.32 +"""
1.33 +
1.34 +circle_template = u"""<circle class="%(class)s" cx="%(x)f" cy="%(y)f" r="%(r)f" />
1.35 +"""
1.36 +
1.37 +label_template = u"""<text class="%(class)s"
1.38 + x="%(x)f" y="%(y)f" font-size="%(font-size)s" %(rotate)s>%(label)s</text>
1.39 +"""
1.40 +
1.41 +rect_template = u"""<rect x="%(x)f" y="%(y)f" width="%(width)f" height="%(height)f" class="%(class)s"
1.42 + transform="%(transform)s" />
1.43 +"""
1.44 +
1.45 +labelled_point_template = u"""<g class="point">
1.46 + %(rect)s
1.47 + %(circle)s
1.48 + %(large-circle)s
1.49 + %(label)s
1.50 + %(x-line)s
1.51 + %(y-axis-label)s
1.52 + %(y-line)s
1.53 + %(x-axis-label)s
1.54 +</g>
1.55 +"""
1.56 +
1.57 +class Plot:
1.58 +
1.59 + "Support for plotting points and lines."
1.60 +
1.61 + def __init__(self, xmin, ymin, xmax, ymax, xmultiplier, ymultiplier):
1.62 + self.xmin = xmin
1.63 + self.ymin = ymin
1.64 + self.xmax = xmax
1.65 + self.ymax = ymax
1.66 + self.xmultiplier = xmultiplier
1.67 + self.ymultiplier = ymultiplier
1.68 +
1.69 + def get_width(self):
1.70 + return abs(self.xmax - self.xmin)
1.71 +
1.72 + def get_scaled_width(self):
1.73 + return abs((self.xmax - self.xmin) * self.xmultiplier)
1.74 +
1.75 + def get_height(self):
1.76 + return abs(self.ymax - self.ymin)
1.77 +
1.78 + def get_scaled_height(self):
1.79 + return abs((self.ymax - self.ymin) * self.ymultiplier)
1.80 +
1.81 + def get_xbase(self):
1.82 + return min(self.xmin * self.xmultiplier, self.xmax * self.xmultiplier)
1.83 +
1.84 + def get_ybase(self):
1.85 + return min(self.ymin * self.ymultiplier, self.ymax * self.ymultiplier)
1.86 +
1.87 + def xpc(self, percent):
1.88 + return percent * self.get_width() / 100.0
1.89 +
1.90 + def ypc(self, percent):
1.91 + return percent * self.get_height() / 100.0
1.92 +
1.93 + def scaled_xpc(self, percent):
1.94 + return percent * self.get_scaled_width() / 100.0
1.95 +
1.96 + def scaled_ypc(self, percent):
1.97 + return percent * self.get_scaled_height() / 100.0
1.98 +
1.99 + def scale_x(self, x):
1.100 + return x * self.xmultiplier
1.101 +
1.102 + def scale_y(self, y):
1.103 + return y * self.ymultiplier
1.104 +
1.105 + # Plotting methods.
1.106 +
1.107 + def get_points(self, points, attributes=None):
1.108 + circles = []
1.109 + for x, y, r in points:
1.110 + circles.append(self.get_point(x, y, r, attributes))
1.111 + return "".join(circles)
1.112 +
1.113 + def get_point(self, x, y, r, attributes=None):
1.114 + x, y = self._scale(x, y)
1.115 + attrs = {"x" : x, "y" : y, "r" : r, "class" : ""}
1.116 + if attributes:
1.117 + attrs.update(attributes)
1.118 + return circle_template % attrs
1.119 +
1.120 + def get_rect(self, xmin, ymin, xmax, ymax, attributes=None):
1.121 + xmin, ymin = self._scale(xmin, ymin)
1.122 + xmax, ymax = self._scale(xmax, ymax)
1.123 + attrs = {"x" : xmin, "y" : ymin, "width" : abs(xmax - xmin), "height" : abs(ymax - ymin)}
1.124 +
1.125 + # Flip the rectangle around (xmin, ymin) if appropriate.
1.126 +
1.127 + if xmax < xmin and ymax < ymin:
1.128 + attrs["transform"] = "translate(%d %d) matrix(-1 0 0 -1 0 0) translate(%d %d)" % (xmin, ymin, -xmin, -ymin)
1.129 + elif xmax < xmin:
1.130 + attrs["transform"] = "translate(%d %d) matrix(-1 0 0 1 0 0) translate(%d %d)" % (xmin, ymin, -xmin, -ymin)
1.131 + elif ymax < ymin:
1.132 + attrs["transform"] = "translate(%d %d) matrix(1 0 0 -1 0 0) translate(%d %d)" % (xmin, ymin, -xmin, -ymin)
1.133 + else:
1.134 + attrs["transform"] = ""
1.135 +
1.136 + if attributes:
1.137 + attrs.update(attributes)
1.138 + return rect_template % attrs
1.139 +
1.140 + def get_line(self, segments, attributes=None):
1.141 + scaled = []
1.142 + for from_, to_ in segments:
1.143 + from_x, from_y = from_[:2]
1.144 + to_x, to_y = to_[:2]
1.145 + scaled.append((self._scale(from_x, from_y), self._scale(to_x, to_y)))
1.146 + attrs = {"data" : make_path_data(scaled), "class" : ""}
1.147 + if attributes:
1.148 + attrs.update(attributes)
1.149 + return path_template % attrs
1.150 +
1.151 + def get_label(self, x, y, text, attributes=None):
1.152 + x, y = self._scale(x, y)
1.153 + attrs = {"x" : x, "y" : y, "label" : text, "class" : "", "rotate" : ""}
1.154 + if attributes:
1.155 + attrs.update(attributes)
1.156 + if attrs["rotate"]:
1.157 + attrs["rotate"] = 'transform="translate(%d %d) rotate(%d) translate(%d %d)"' % (x, y, attrs["rotate"], -x, -y)
1.158 + return label_template % attrs
1.159 +
1.160 + def _scale(self, x, y):
1.161 + return self.scale_x(x), self.scale_y(y)
1.162 +
1.163 +class Axis:
1.164 +
1.165 + "Support for an axis with labels."
1.166 +
1.167 + example_sequence = range(0, 100, 2)
1.168 +
1.169 + def __init__(self, plot, vertical=1, position=0, sequence=example_sequence, divisions=10, minorwidth=1, majorwidth=2):
1.170 +
1.171 + "Initialise an axis."
1.172 +
1.173 + self.plot = plot
1.174 + self.vertical = vertical
1.175 + self.position = position
1.176 + self.sequence = sequence
1.177 + self.divisions = divisions
1.178 + self.minorwidth = minorwidth
1.179 + self.majorwidth = majorwidth
1.180 +
1.181 + def get_axis(self, attributes=None):
1.182 +
1.183 + "Draw the axis itself using the given, optional 'attributes'."
1.184 +
1.185 + l = [((self.position, self.sequence[0]), (self.position, self.sequence[-1]))]
1.186 +
1.187 + # For each step, mark the axis with minor and major markings.
1.188 +
1.189 + for i, value in enumerate(self.sequence):
1.190 + if i % self.divisions == 0:
1.191 + markwidth = self.majorwidth
1.192 + else:
1.193 + markwidth = self.minorwidth
1.194 +
1.195 + l.append((
1.196 + (self.position - markwidth, value),
1.197 + (self.position + markwidth, value)
1.198 + ))
1.199 +
1.200 + # Transpose coordinates for horizontal axes, if necessary.
1.201 +
1.202 + l = [self._transpose_segment(segment) for segment in l]
1.203 +
1.204 + # Return the path.
1.205 +
1.206 + return self.plot.get_line(l, attributes)
1.207 +
1.208 + def get_labels(self, attributes=None, labeller=None, rotate=None):
1.209 +
1.210 + """
1.211 + Plot the labels using the given, optional 'attributes' and 'labeller'
1.212 + callable.
1.213 + """
1.214 +
1.215 + labeller = labeller or (lambda x: x)
1.216 +
1.217 + # Label each major marking.
1.218 +
1.219 + l = []
1.220 +
1.221 + for i, value in enumerate(self.sequence):
1.222 + if i % self.divisions == 0:
1.223 + l.append((
1.224 + (self.position - self.majorwidth * 2, value),
1.225 + labeller(value)
1.226 + ))
1.227 +
1.228 + # Transpose coordinates for horizontal axes, if necessary.
1.229 +
1.230 + l = [self._transpose_label(label) for label in l]
1.231 +
1.232 + # Return the labels.
1.233 +
1.234 + labels = []
1.235 + for (x, y), label in l:
1.236 + attrs = {}
1.237 + if attributes:
1.238 + attrs.update(attributes)
1.239 + attrs["class"] = attrs.get("class", "") + (self.vertical and " y-axis" or " x-axis")
1.240 + if rotate:
1.241 + attrs["rotate"] = rotate
1.242 + labels.append(self.plot.get_label(x, y, label, attrs))
1.243 +
1.244 + return "".join(labels)
1.245 +
1.246 + def _transpose_label(self, label):
1.247 + if self.vertical:
1.248 + return label
1.249 + (from_x, from_y), text = label
1.250 + return (from_y, from_x), text
1.251 +
1.252 + def _transpose_segment(self, segment):
1.253 + if self.vertical:
1.254 + return segment
1.255 + (from_x, from_y), (to_x, to_y) = segment
1.256 + return (from_y, from_x), (to_y, to_x)
1.257 +
1.258 +# Plotting functions.
1.259 +
1.260 +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):
1.261 +
1.262 + circles = []
1.263 + for x, y, r, text, y_axis_label, x_axis_label in points:
1.264 + circles.append(get_labelled_point(plot, x_axis, y_axis, x, y, r, text, offset_x, offset_y, font_height,
1.265 + y_axis_label, x_axis_label, axis_label_x, axis_label_y, attributes))
1.266 + return "".join(circles)
1.267 +
1.268 +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="",
1.269 + axis_label_x=0, axis_label_y=0, attributes=None):
1.270 +
1.271 + if x > x_axis.position:
1.272 + axis_label_x = x_axis.position - axis_label_x
1.273 + y_axis_label_class = "west"
1.274 + else:
1.275 + axis_label_x = x_axis.position + axis_label_x
1.276 + y_axis_label_class = "east"
1.277 +
1.278 + if y > y_axis.position:
1.279 + axis_label_y = y_axis.position - axis_label_y
1.280 + x_axis_label_class = "south"
1.281 + else:
1.282 + axis_label_y = y_axis.position + axis_label_y
1.283 + x_axis_label_class = "north"
1.284 +
1.285 + circle = plot.get_point(x, y, r, attributes)
1.286 + label = plot.get_label(x + offset_x, y + offset_y, text, attributes)
1.287 +
1.288 + # Make a special active area for the point details.
1.289 +
1.290 + if text:
1.291 + attrs = {"class" : ""}
1.292 + if attributes:
1.293 + attrs.update(attributes)
1.294 + attrs["class"] += " active"
1.295 + rect = plot.get_rect(x, y - offset_y, axis_label_x, y + offset_y, attrs) + \
1.296 + plot.get_rect(x - offset_x, y, x + offset_x, axis_label_y, attrs)
1.297 + else:
1.298 + rect = ""
1.299 +
1.300 + # Make a special large point.
1.301 +
1.302 + attrs = {"class" : ""}
1.303 + if attributes:
1.304 + attrs.update(attributes)
1.305 + attrs["class"] += " large"
1.306 + large_circle = plot.get_point(x, y, r * 2, attrs)
1.307 +
1.308 + # Make lines intersecting the y-axis and x-axis.
1.309 +
1.310 + attrs = {"class" : ""}
1.311 + if attributes:
1.312 + attrs.update(attributes)
1.313 + attrs["class"] += " to-axis"
1.314 + x_line = plot.get_line([((axis_label_x, y), (x, y))], attrs)
1.315 + y_line = plot.get_line([((x, axis_label_y), (x, y))], attrs)
1.316 +
1.317 + # Make axis labels.
1.318 +
1.319 + attrs = {}
1.320 + if attributes:
1.321 + attrs.update(attributes)
1.322 + classes = attrs.get("class", "")
1.323 +
1.324 + if y_axis_label:
1.325 + attrs["class"] = classes + " axis-label %s" % y_axis_label_class
1.326 + y_axis_label = plot.get_label(axis_label_x, y, y_axis_label, attrs)
1.327 + else:
1.328 + y_axis_label = ""
1.329 +
1.330 + if x_axis_label:
1.331 + attrs["class"] = classes + " axis-label %s" % x_axis_label_class
1.332 + x_axis_label = plot.get_label(x, axis_label_y, x_axis_label, attrs)
1.333 + else:
1.334 + x_axis_label = ""
1.335 +
1.336 + # Combine the different parts.
1.337 +
1.338 + attrs = {"circle" : circle, "label" : label, "large-circle" : large_circle, "rect" : rect,
1.339 + "x-line" : x_line, "x-axis-label" : x_axis_label, "y-line" : y_line, "y-axis-label" : y_axis_label}
1.340 + if attributes:
1.341 + attrs.update(attributes)
1.342 + return labelled_point_template % attrs
1.343 +
1.344 +# Utility functions.
1.345 +
1.346 +def make_path_data(segments):
1.347 + parts = []
1.348 + for (from_x, from_y), (to_x, to_y) in segments:
1.349 + parts.append("M %f,%f" % (from_x, from_y))
1.350 + parts.append("L %f,%f" % (to_x, to_y))
1.351 + return " ".join(parts) + " z"
1.352 +
1.353 +def segments_from_points(points):
1.354 + segments = []
1.355 + last = None
1.356 + for point in points:
1.357 + if last is not None:
1.358 + segments.append((last, point))
1.359 + last = point
1.360 + return segments
1.361 +
1.362 +def round(x, interval, direction=1):
1.363 + q, r = divmod(x, interval)
1.364 + if direction == -1 and q < 0:
1.365 + direction = 0
1.366 + return (q + direction) * interval
1.367 +
1.368 +def frange(start, stop, step):
1.369 + value = start
1.370 + l = []
1.371 + while value < stop:
1.372 + l.append(value)
1.373 + value += step
1.374 + return l
1.375 +
1.376 +def get_chart(data, chart_width=900, chart_height=None, xorigin=0, yorigin=0, xdivisions=10, ydivisions=10, encoding="utf-8",
1.377 + styles_url=""):
1.378 +
1.379 + new_data = []
1.380 + for t in data:
1.381 + x, y = t[:2]
1.382 + new_data.append([float(x), float(y)] + t[2:])
1.383 + data = new_data
1.384 +
1.385 + # Get the data properties.
1.386 +
1.387 + min_x = min([t[0] for t in data])
1.388 + min_y = min([t[1] for t in data])
1.389 + max_x = max([t[0] for t in data])
1.390 + max_y = max([t[1] for t in data])
1.391 +
1.392 + # Get the chart properties.
1.393 +
1.394 + xinterval = math.pow(10, math.floor(math.log10(max(abs(max_x), abs(min_x)))))
1.395 + yinterval = math.pow(10, math.floor(math.log10(max(abs(max_y), abs(min_y)))))
1.396 +
1.397 + xmin, xmax = round(min_x, xinterval, -1), round(max_x, xinterval)
1.398 + ymin, ymax = round(min_y, yinterval, -1), round(max_y, yinterval)
1.399 +
1.400 + xstep = float(xinterval) / xdivisions
1.401 + ystep = float(yinterval) / ydivisions
1.402 +
1.403 + xsequence = frange(min(xorigin, xmin), max(xorigin, xmax) + xstep, xstep)
1.404 + ysequence = frange(min(xorigin, ymin), max(yorigin, ymax) + ystep, ystep)
1.405 +
1.406 + xmultiplier = float(ymax) / xmax
1.407 + ymultiplier = -1
1.408 +
1.409 + # Initialise the chart components.
1.410 +
1.411 + plot = Plot(xmin, ymin, xmax, ymax, xmultiplier, ymultiplier)
1.412 +
1.413 + axis_label_x = plot.xpc(20)
1.414 + axis_label_y = plot.ypc(15)
1.415 +
1.416 + width = plot.get_width()
1.417 + height = plot.get_height()
1.418 +
1.419 + x_axis = Axis(plot, 0, yorigin, xsequence, xdivisions, plot.ypc(0.5), plot.ypc(1))
1.420 + y_axis = Axis(plot, 1, xorigin, ysequence, ydivisions, plot.xpc(0.5), plot.xpc(1))
1.421 +
1.422 + # Render the chart.
1.423 +
1.424 + all_elements = []
1.425 +
1.426 + # Point radius.
1.427 +
1.428 + radius = plot.scaled_xpc(1)
1.429 +
1.430 + # Label position.
1.431 +
1.432 + labelx, labely = plot.xpc(2), -plot.ypc(2)
1.433 + font_height = -plot.ypc(4)
1.434 +
1.435 + text_styles = {"font-size" : plot.scaled_xpc(4)}
1.436 +
1.437 + # Render the axes.
1.438 + # NOTE: Should detect the precision in the data and label appropriately.
1.439 +
1.440 + labeller = (lambda x: "%.2f" % x)
1.441 + styles = {"class" : "axis"}
1.442 +
1.443 + all_elements.append(y_axis.get_axis(styles))
1.444 + all_elements.append(y_axis.get_labels(text_styles, labeller))
1.445 +
1.446 + label_styles = {}
1.447 + label_styles.update(text_styles)
1.448 +
1.449 + all_elements.append(x_axis.get_axis(styles))
1.450 + all_elements.append(x_axis.get_labels(label_styles, labeller))
1.451 +
1.452 + # Render the data.
1.453 +
1.454 + points = []
1.455 + for t in data:
1.456 + x, y = t[:2]
1.457 + if len(t) > 2:
1.458 + label_text = t[2]
1.459 + else:
1.460 + label_text = "(%s, %s)" % (x, y)
1.461 + points.append((x, y, radius, label_text, y, x))
1.462 +
1.463 + # Render the line and labelled points.
1.464 +
1.465 + styles = {}
1.466 + styles.update(text_styles)
1.467 +
1.468 + all_elements.append(plot.get_line(segments_from_points(points), styles))
1.469 +
1.470 + all_elements.append(get_labelled_points(plot, x_axis, y_axis, points, labelx, labely, font_height,
1.471 + axis_label_x, axis_label_y, attributes=styles))
1.472 +
1.473 + # Work out the extent of the chart.
1.474 +
1.475 + left = min(plot.get_xbase(), plot.scale_x(xorigin)) - plot.scaled_xpc(40)
1.476 + left_to_right = plot.scaled_xpc(180)
1.477 + bottom = min(plot.get_ybase(), plot.scale_y(yorigin)) - plot.scaled_ypc(10)
1.478 + bottom_to_top = plot.scaled_ypc(180)
1.479 +
1.480 + if chart_width is not None:
1.481 + if chart_height is None:
1.482 + chart_height = chart_width / left_to_right * bottom_to_top
1.483 + else:
1.484 + if chart_height is not None:
1.485 + chart_width = chart_height / bottom_to_top * left_to_right
1.486 +
1.487 + # Write the SVG file.
1.488 +
1.489 + return template % {
1.490 + "width" : int(chart_width),
1.491 + "height" : int(chart_height),
1.492 + "viewBox" : "%d %d %d %d" % (
1.493 + left, bottom, left_to_right, bottom_to_top,
1.494 + ),
1.495 + "chart" : "".join(all_elements),
1.496 + "styles_url" : styles_url,
1.497 + "encoding" : encoding
1.498 + }
1.499 +
1.500 +# vim: tabstop=4 expandtab shiftwidth=4