# HG changeset patch # User Paul Boddie # Date 1306626840 -7200 # Node ID 83154b6d9de71d553d2d412ed79c0c0d4e9fd2ee A tabular data parser which produces SVG charts. diff -r 000000000000 -r 83154b6d9de7 SVGChartSupport.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/SVGChartSupport.py Sun May 29 01:54:00 2011 +0200 @@ -0,0 +1,497 @@ +# -*- coding: iso-8859-1 -*- +""" + MoinMoin - SVGChart library + + @copyright: 2011 by Paul Boddie + @license: GNU GPL (v2 or later), see COPYING.txt for details. +""" + +import math + +template = u""" + + + + + + + + + + +%(chart)s + + +""" + +path_template = u""" +""" + +circle_template = u""" +""" + +label_template = u"""%(label)s +""" + +rect_template = u""" +""" + +labelled_point_template = u""" + %(rect)s + %(circle)s + %(large-circle)s + %(label)s + %(x-line)s + %(y-axis-label)s + %(y-line)s + %(x-axis-label)s + +""" + +class Plot: + + "Support for plotting points and lines." + + def __init__(self, xmin, ymin, xmax, ymax, xmultiplier, ymultiplier): + self.xmin = xmin + self.ymin = ymin + self.xmax = xmax + self.ymax = ymax + self.xmultiplier = xmultiplier + self.ymultiplier = ymultiplier + + def get_width(self): + return abs(self.xmax - self.xmin) + + def get_scaled_width(self): + return abs((self.xmax - self.xmin) * self.xmultiplier) + + def get_height(self): + return abs(self.ymax - self.ymin) + + def get_scaled_height(self): + return abs((self.ymax - self.ymin) * self.ymultiplier) + + def get_xbase(self): + return min(self.xmin * self.xmultiplier, self.xmax * self.xmultiplier) + + def get_ybase(self): + return min(self.ymin * self.ymultiplier, self.ymax * self.ymultiplier) + + def xpc(self, percent): + return percent * self.get_width() / 100.0 + + def ypc(self, percent): + return percent * self.get_height() / 100.0 + + def scaled_xpc(self, percent): + return percent * self.get_scaled_width() / 100.0 + + def scaled_ypc(self, percent): + return percent * self.get_scaled_height() / 100.0 + + def scale_x(self, x): + return x * self.xmultiplier + + def scale_y(self, y): + return y * self.ymultiplier + + # Plotting methods. + + def get_points(self, points, attributes=None): + circles = [] + for x, y, r in points: + circles.append(self.get_point(x, y, r, attributes)) + return "".join(circles) + + def get_point(self, x, y, r, attributes=None): + x, y = self._scale(x, y) + attrs = {"x" : x, "y" : y, "r" : r, "class" : ""} + if attributes: + attrs.update(attributes) + return circle_template % attrs + + def get_rect(self, xmin, ymin, xmax, ymax, attributes=None): + xmin, ymin = self._scale(xmin, ymin) + xmax, ymax = self._scale(xmax, ymax) + attrs = {"x" : xmin, "y" : ymin, "width" : abs(xmax - xmin), "height" : abs(ymax - ymin)} + + # Flip the rectangle around (xmin, ymin) if appropriate. + + if xmax < xmin and ymax < ymin: + attrs["transform"] = "translate(%d %d) matrix(-1 0 0 -1 0 0) translate(%d %d)" % (xmin, ymin, -xmin, -ymin) + elif xmax < xmin: + attrs["transform"] = "translate(%d %d) matrix(-1 0 0 1 0 0) translate(%d %d)" % (xmin, ymin, -xmin, -ymin) + elif ymax < ymin: + attrs["transform"] = "translate(%d %d) matrix(1 0 0 -1 0 0) translate(%d %d)" % (xmin, ymin, -xmin, -ymin) + else: + attrs["transform"] = "" + + if attributes: + attrs.update(attributes) + return rect_template % attrs + + def get_line(self, segments, attributes=None): + scaled = [] + for from_, to_ in segments: + from_x, from_y = from_[:2] + to_x, to_y = to_[:2] + scaled.append((self._scale(from_x, from_y), self._scale(to_x, to_y))) + attrs = {"data" : make_path_data(scaled), "class" : ""} + if attributes: + attrs.update(attributes) + return path_template % attrs + + def get_label(self, x, y, text, attributes=None): + x, y = self._scale(x, y) + attrs = {"x" : x, "y" : y, "label" : text, "class" : "", "rotate" : ""} + if attributes: + attrs.update(attributes) + if attrs["rotate"]: + attrs["rotate"] = 'transform="translate(%d %d) rotate(%d) translate(%d %d)"' % (x, y, attrs["rotate"], -x, -y) + return label_template % attrs + + def _scale(self, x, y): + return self.scale_x(x), self.scale_y(y) + +class Axis: + + "Support for an axis with labels." + + example_sequence = range(0, 100, 2) + + def __init__(self, plot, vertical=1, position=0, sequence=example_sequence, divisions=10, minorwidth=1, majorwidth=2): + + "Initialise an axis." + + self.plot = plot + self.vertical = vertical + self.position = position + self.sequence = sequence + self.divisions = divisions + self.minorwidth = minorwidth + self.majorwidth = majorwidth + + def get_axis(self, attributes=None): + + "Draw the axis itself using the given, optional 'attributes'." + + l = [((self.position, self.sequence[0]), (self.position, self.sequence[-1]))] + + # For each step, mark the axis with minor and major markings. + + for i, value in enumerate(self.sequence): + if i % self.divisions == 0: + markwidth = self.majorwidth + else: + markwidth = self.minorwidth + + l.append(( + (self.position - markwidth, value), + (self.position + markwidth, value) + )) + + # Transpose coordinates for horizontal axes, if necessary. + + l = [self._transpose_segment(segment) for segment in l] + + # Return the path. + + return self.plot.get_line(l, attributes) + + def get_labels(self, attributes=None, labeller=None, rotate=None): + + """ + Plot the labels using the given, optional 'attributes' and 'labeller' + callable. + """ + + labeller = labeller or (lambda x: x) + + # Label each major marking. + + l = [] + + for i, value in enumerate(self.sequence): + if i % self.divisions == 0: + l.append(( + (self.position - self.majorwidth * 2, value), + labeller(value) + )) + + # Transpose coordinates for horizontal axes, if necessary. + + l = [self._transpose_label(label) for label in l] + + # Return the labels. + + labels = [] + for (x, y), label in l: + attrs = {} + if attributes: + attrs.update(attributes) + attrs["class"] = attrs.get("class", "") + (self.vertical and " y-axis" or " x-axis") + if rotate: + attrs["rotate"] = rotate + labels.append(self.plot.get_label(x, y, label, attrs)) + + return "".join(labels) + + def _transpose_label(self, label): + if self.vertical: + return label + (from_x, from_y), text = label + return (from_y, from_x), text + + def _transpose_segment(self, segment): + if self.vertical: + return segment + (from_x, from_y), (to_x, to_y) = segment + return (from_y, from_x), (to_y, to_x) + +# Plotting functions. + +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): + + circles = [] + for x, y, r, text, y_axis_label, x_axis_label in points: + circles.append(get_labelled_point(plot, x_axis, y_axis, x, y, r, text, offset_x, offset_y, font_height, + y_axis_label, x_axis_label, axis_label_x, axis_label_y, attributes)) + return "".join(circles) + +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="", + axis_label_x=0, axis_label_y=0, attributes=None): + + if x > x_axis.position: + axis_label_x = x_axis.position - axis_label_x + y_axis_label_class = "west" + else: + axis_label_x = x_axis.position + axis_label_x + y_axis_label_class = "east" + + if y > y_axis.position: + axis_label_y = y_axis.position - axis_label_y + x_axis_label_class = "south" + else: + axis_label_y = y_axis.position + axis_label_y + x_axis_label_class = "north" + + circle = plot.get_point(x, y, r, attributes) + label = plot.get_label(x + offset_x, y + offset_y, text, attributes) + + # Make a special active area for the point details. + + if text: + attrs = {"class" : ""} + if attributes: + attrs.update(attributes) + attrs["class"] += " active" + rect = plot.get_rect(x, y - offset_y, axis_label_x, y + offset_y, attrs) + \ + plot.get_rect(x - offset_x, y, x + offset_x, axis_label_y, attrs) + else: + rect = "" + + # Make a special large point. + + attrs = {"class" : ""} + if attributes: + attrs.update(attributes) + attrs["class"] += " large" + large_circle = plot.get_point(x, y, r * 2, attrs) + + # Make lines intersecting the y-axis and x-axis. + + attrs = {"class" : ""} + if attributes: + attrs.update(attributes) + attrs["class"] += " to-axis" + x_line = plot.get_line([((axis_label_x, y), (x, y))], attrs) + y_line = plot.get_line([((x, axis_label_y), (x, y))], attrs) + + # Make axis labels. + + attrs = {} + if attributes: + attrs.update(attributes) + classes = attrs.get("class", "") + + if y_axis_label: + attrs["class"] = classes + " axis-label %s" % y_axis_label_class + y_axis_label = plot.get_label(axis_label_x, y, y_axis_label, attrs) + else: + y_axis_label = "" + + if x_axis_label: + attrs["class"] = classes + " axis-label %s" % x_axis_label_class + x_axis_label = plot.get_label(x, axis_label_y, x_axis_label, attrs) + else: + x_axis_label = "" + + # Combine the different parts. + + attrs = {"circle" : circle, "label" : label, "large-circle" : large_circle, "rect" : rect, + "x-line" : x_line, "x-axis-label" : x_axis_label, "y-line" : y_line, "y-axis-label" : y_axis_label} + if attributes: + attrs.update(attributes) + return labelled_point_template % attrs + +# Utility functions. + +def make_path_data(segments): + parts = [] + for (from_x, from_y), (to_x, to_y) in segments: + parts.append("M %f,%f" % (from_x, from_y)) + parts.append("L %f,%f" % (to_x, to_y)) + return " ".join(parts) + " z" + +def segments_from_points(points): + segments = [] + last = None + for point in points: + if last is not None: + segments.append((last, point)) + last = point + return segments + +def round(x, interval, direction=1): + q, r = divmod(x, interval) + if direction == -1 and q < 0: + direction = 0 + return (q + direction) * interval + +def frange(start, stop, step): + value = start + l = [] + while value < stop: + l.append(value) + value += step + return l + +def get_chart(data, chart_width=900, chart_height=None, xorigin=0, yorigin=0, xdivisions=10, ydivisions=10, encoding="utf-8", + styles_url=""): + + new_data = [] + for t in data: + x, y = t[:2] + new_data.append([float(x), float(y)] + t[2:]) + data = new_data + + # Get the data properties. + + min_x = min([t[0] for t in data]) + min_y = min([t[1] for t in data]) + max_x = max([t[0] for t in data]) + max_y = max([t[1] for t in data]) + + # Get the chart properties. + + xinterval = math.pow(10, math.floor(math.log10(max(abs(max_x), abs(min_x))))) + yinterval = math.pow(10, math.floor(math.log10(max(abs(max_y), abs(min_y))))) + + xmin, xmax = round(min_x, xinterval, -1), round(max_x, xinterval) + ymin, ymax = round(min_y, yinterval, -1), round(max_y, yinterval) + + xstep = float(xinterval) / xdivisions + ystep = float(yinterval) / ydivisions + + xsequence = frange(min(xorigin, xmin), max(xorigin, xmax) + xstep, xstep) + ysequence = frange(min(xorigin, ymin), max(yorigin, ymax) + ystep, ystep) + + xmultiplier = float(ymax) / xmax + ymultiplier = -1 + + # Initialise the chart components. + + plot = Plot(xmin, ymin, xmax, ymax, xmultiplier, ymultiplier) + + axis_label_x = plot.xpc(20) + axis_label_y = plot.ypc(15) + + width = plot.get_width() + height = plot.get_height() + + x_axis = Axis(plot, 0, yorigin, xsequence, xdivisions, plot.ypc(0.5), plot.ypc(1)) + y_axis = Axis(plot, 1, xorigin, ysequence, ydivisions, plot.xpc(0.5), plot.xpc(1)) + + # Render the chart. + + all_elements = [] + + # Point radius. + + radius = plot.scaled_xpc(1) + + # Label position. + + labelx, labely = plot.xpc(2), -plot.ypc(2) + font_height = -plot.ypc(4) + + text_styles = {"font-size" : plot.scaled_xpc(4)} + + # Render the axes. + # NOTE: Should detect the precision in the data and label appropriately. + + labeller = (lambda x: "%.2f" % x) + styles = {"class" : "axis"} + + all_elements.append(y_axis.get_axis(styles)) + all_elements.append(y_axis.get_labels(text_styles, labeller)) + + label_styles = {} + label_styles.update(text_styles) + + all_elements.append(x_axis.get_axis(styles)) + all_elements.append(x_axis.get_labels(label_styles, labeller)) + + # Render the data. + + points = [] + for t in data: + x, y = t[:2] + if len(t) > 2: + label_text = t[2] + else: + label_text = "(%s, %s)" % (x, y) + points.append((x, y, radius, label_text, y, x)) + + # Render the line and labelled points. + + styles = {} + styles.update(text_styles) + + all_elements.append(plot.get_line(segments_from_points(points), styles)) + + all_elements.append(get_labelled_points(plot, x_axis, y_axis, points, labelx, labely, font_height, + axis_label_x, axis_label_y, attributes=styles)) + + # Work out the extent of the chart. + + left = min(plot.get_xbase(), plot.scale_x(xorigin)) - plot.scaled_xpc(40) + left_to_right = plot.scaled_xpc(180) + bottom = min(plot.get_ybase(), plot.scale_y(yorigin)) - plot.scaled_ypc(10) + bottom_to_top = plot.scaled_ypc(180) + + if chart_width is not None: + if chart_height is None: + chart_height = chart_width / left_to_right * bottom_to_top + else: + if chart_height is not None: + chart_width = chart_height / bottom_to_top * left_to_right + + # Write the SVG file. + + return template % { + "width" : int(chart_width), + "height" : int(chart_height), + "viewBox" : "%d %d %d %d" % ( + left, bottom, left_to_right, bottom_to_top, + ), + "chart" : "".join(all_elements), + "styles_url" : styles_url, + "encoding" : encoding + } + +# vim: tabstop=4 expandtab shiftwidth=4 diff -r 000000000000 -r 83154b6d9de7 css/svgchart.css --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/css/svgchart.css Sun May 29 01:54:00 2011 +0200 @@ -0,0 +1,98 @@ +text, circle { + fill: #000; +} + +circle, path { + stroke-width: 0.5%; + stroke: #000; +} + +text.north.axis-label, +text.south.axis-label { + text-anchor: middle; +} + +text.north.axis-label { + dominant-baseline: text-after-edge; +} + +text.south.axis-label { + dominant-baseline: text-before-edge; +} + +text.east.axis-label, +text.west.axis-label { + dominant-baseline: middle; +} + +text.east.axis-label { + text-anchor: start; +} + +text.west.axis-label { + text-anchor: end; +} + +g.point text { + dominant-baseline: middle; +} + +.axis { + stroke: #000; + stroke-width: 0.25%; + stroke-opacity: 0.5; +} + +.to-axis { + stroke-width: 0.25%; + stroke-opacity: 0.5; +} + +text.y-axis { + text-anchor: end; + dominant-baseline: middle; + fill-opacity: 0.5; +} + +text.x-axis { + text-anchor: middle; + dominant-baseline: text-before-edge; + fill-opacity: 0.5; +} + +g.point rect.active { + stroke-opacity: 0; + opacity: 0; +} + +g.point text { + display: none; +} + +g.point:hover text { + display: block; +} + +g.point path { + display: none; +} + +g.point:hover path { + display: block; +} + +g.point circle { + display: block; +} + +g.point:hover circle { + display: none; +} + +g.point circle.large { + display: none; +} + +g.point:hover circle.large { + display: block; +} diff -r 000000000000 -r 83154b6d9de7 parsers/SVGChart.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/parsers/SVGChart.py Sun May 29 01:54:00 2011 +0200 @@ -0,0 +1,48 @@ +# -*- coding: iso-8859-1 -*- +""" + MoinMoin - SVGChart + + @copyright: 2011 by Paul Boddie + @license: GNU GPL (v2 or later), see COPYING.txt for details. +""" + +from MoinMoin.action import cache +from SVGChartSupport import get_chart + +Dependencies = ["pages"] + +# Parser support. + +class Parser: + + "Format CSV-like data as an SVG chart." + + extensions = [".csv"] + + def __init__(self, raw, request, **kw): + self.raw = raw + self.request = request + self.data = [row.split() for row in raw.split("\n")] + + def format(self, fmt): + + "Format a chart using the given formatter 'fmt'." + + request = self.request + page = request.page + _ = request.getText + + # NOTE: Store and retrieve the width and height. + + cache_key = cache.key(request, itemname=page.page_name, content=self.raw) + if not cache.exists(request, cache_key): + chart = get_chart(self.data, styles_url="%s/%s/css/svgchart.css" % (request.cfg.url_prefix_static, request.theme.name)) + cache.put(request, cache_key, chart, content_type="image/svg+xml") + + request.write(fmt.div(1, css_class="svgchart")) + request.write(fmt.transclusion(1, data=cache.url(request, cache_key))) + request.write(fmt.text(_("SVG chart of CSV data."))) + request.write(fmt.transclusion(0)) + request.write(fmt.div(0)) + +# vim: tabstop=4 expandtab shiftwidth=4 diff -r 000000000000 -r 83154b6d9de7 setup.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/setup.py Sun May 29 01:54:00 2011 +0200 @@ -0,0 +1,13 @@ +#! /usr/bin/env python + +from distutils.core import setup + +setup( + name = "SVGChart", + description = "Show CSV data as SVG charts", + author = "Paul Boddie", + author_email = "paul@boddie.org.uk", + url = "http://moinmo.in/ParserMarket/SVGChart", + version = "0.1", + py_modules = ["SVGChartSupport"] + )