From 47e9b4389541ae53494f435aecf3558e0498f1b7 Mon Sep 17 00:00:00 2001 From: "SANDIA\\jaraco" Date: Wed, 14 Dec 2005 23:38:47 +0000 Subject: [PATCH] Beginning of SVG library as ported from the Ruby SVG::Graph library. --- __init__.py | 862 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 862 insertions(+) create mode 100644 __init__.py diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e28bd89 --- /dev/null +++ b/__init__.py @@ -0,0 +1,862 @@ +#!/usr/bin/env python + +from xml.dom import minidom as dom + +try: + import zlib + __have_zlib = True +except ImportError: + __have_zlib = False + +def CreateElement( nodeName, attributes={} ): + "Create an XML node and set the attributes from a dict" + node = dom.Element( nodeName ) + map( lambda a: node.setAttribute( *a ), attributes.items() ) + return node + +class Graph( object ): + """=== Base object for generating SVG Graphs + +== Synopsis + +This class is only used as a superclass of specialized charts. Do not +attempt to use this class directly, unless creating a new chart type. + +For examples of how to subclass this class, see the existing specific +subclasses, such as SVG.Pie. + +== Examples + +For examples of how to use this package, see either the test files, or +the documentation for the specific class you want to use. + +* file:test/plot.rb +* file:test/single.rb +* file:test/test.rb +* file:test/timeseries.rb + +== Description + +This package should be used as a base for creating SVG graphs. + +== Acknowledgements + +Sean E. Russel for creating the SVG::Graph Ruby package from which this +Python port is derived. + +Leo Lapworth for creating the SVG::TT::Graph package which the Ruby +port is based on. + +Stephen Morgan for creating the TT template and SVG. + +== See + +* SVG.BarHorizontal +* SVG.Bar +* SVG.Line +* SVG.Pie +* SVG.Plot +* SVG.TimeSeries + +== Author + +Jason R. Coombs + +Copyright 2005 Sandia National Laboratories +""" + defaults = { + 'width': 500, + 'height': 300, + 'show_x_guidelines': False, + 'show_y_guidelines': True, + 'show_data_values': True, + 'min_scale_value': 0, + 'show_x_labels': True, + 'stagger_x_labels': False, + 'rotate_x_labels': False, + 'step_x_labels': 1, + 'step_include_first_x_label': True, + 'show_y_labels': True, + 'rotate_y_labels': False, + 'stagger_y_labels': False, + 'scale_integers': False, + 'show_x_title': False, + 'x_title': 'X Field names', + 'show_y_title': False, + 'y_title_text_direction': 'bt', + 'y_title': 'Y Scale', + 'show_graph_title': False, + 'graph_title': 'Graph Title', + 'show_graph_subtitle': False, + 'graph_subtitle': 'Graph Subtitle', + 'key': True, + 'key_position': 'right', # 'bottom' or 'right', + + 'font_size': 12, + 'title_font_size': 16, + 'subtitle_font_size': 14, + 'x_label_font_size': 12, + 'x_title_font_size': 14, + 'y_label_font_size': 12, + 'y_title_font_size': 14, + 'key_font_size': 10, + + 'no_css': False, + 'add_popups': False, + } + #__slots__ = tuple( defaults ) + ( '__dict__', '__weakref__' ) + + def __init__( self, config ): + """Initialize the graph object with the graph settings. You won't +instantiate this class directly; see the subclass for options. +""" + self.top_align = self.top_font = self.right_align = self.right_font = 0 + self.load_config() + self.load_config( config ) + self.clear_data() + + def load_config( self, config = None ): + if not config: config = self.defaults + map( lambda pair: setattr( self, *pair ), config.items() ) + + def add_data( self, conf ): + """This method allows you do add data to the graph object. +It can be called several times to add more data sets in. + +>>> data_sales_02 = [12, 45, 21] +>>> graph.add_data({ +... 'data': data_sales_02, +... 'title': 'Sales 2002' +... }) +""" + try: + assert( isinstance( conf['data'], ( tuple, list ) ) ) + except TypeError, e: + raise TypeError, "conf should be dictionary with 'data' and other items" + except AssertionError: + if not hasattr( conf['data'], '__iter__' ): + raise TypeError, "conf['data'] should be tuple or list or iterable" + + self.data.append( conf ) + + def clear_data( self ): + """This method removes all data from the object so that you can +reuse it to create a new graph but with the same config options. + +>>> graph.clear_data() +""" + self.data = [] + + def burn( self ): + """ # This method processes the template with the data and +config which has been set and returns the resulting SVG. + +This method will croak unless at least one data set has +been added to the graph object. + +Ex: graph.burn() +""" + if not self.data: raise ValueError( "No data available" ) + + if hasattr( self, 'calculations' ): self.calculations() + + self.start_svg() + self.calculate_graph_dimensions() + self.foreground = dom.Element( "g" ) + self.draw_graph() + self.draw_titles() + self.draw_legend() + self.draw_data() + self.graph.add_element( self.foreground ) + self.style() + + data = self._doc.toprettyxml() + + if self.compress: + if __have_zlib: + data = zlib.compress( data ) + else: + data += '' + + return data + + KEY_BOX_SIZE = 12 + + def calculate_left_margin( self ): + """Override this (and call super) to change the margin to the left +of the plot area. Results in border_left being set.""" + bl = 7 + # Check for Y labels + if self.rotate_y_labels: + max_y_label_height_px = self.y_label_font_size + else: + label_lengths = map( len, self.get_y_labels() ) + max_y_label_len = reduce( max, label_lengths ) + max_y_label_height_px = 0.6 * max_y_label_len * self.y_label_font_size + if show_y_labels: bl += max_y_label_height_px + if stagger_y_labels: bl += max_y_label_height_px + 10 + if show_y_title: bl += self.y_title_font_size + 5 + self.border_left = bl + + def max_y_label_width_px( self ): + """Calculates the width of the widest Y label. This will be the +character height if the Y labels are rotated.""" + if self.rotate_y_labels: + return self.font_size + + def calculate_right_margin( self ): + """Override this (and call super) to change the margin to the right +of the plot area. Results in border_right being set.""" + br = 7 + if self.key and self.key_position == 'right': + max_key_len = max( map( len, self.keys ) ) + br += max_key_len * self.key_font_size * 0.6 + br += self.KEY_BOX_SIZE + br += 10 # Some padding around the box + self.border_right = br + + def calculate_top_margin( self ): + """Override this (and call super) to change the margin to the top +of the plot area. Results in border_top being set.""" + self.border_top = 5 + if self.show_graph_title: self.border_top += self.title_font_size + self.border_top += 5 + if self.show_graph_subtitle: self.border_top += self.subtitle_font_size + + def add_popup( self, x, y, label ): + "Adds pop-up point information to a graph." + txt_width = len( label ) * self.font_size * 0.6 + 10 + tx = x + [5,-5](x+txt_width > width) + t = dom.Element( 'text' ) + anchor = ['start', 'end'][x+txt_width > self.width] + style = 'fill: #000; text-anchor: %s;' % anchor + id = 'label-%s' % label + attributes = { 'x': str( tx ), + 'y': str( y - self.font_size ), + 'visibility': 'hidden', + 'style': style, + 'text': label, + 'id': id + } + map( lambda a: t.setAttribute( *a ), attributes.items() ) + self.foreground.appendChild( t ) + + visibility = "document.getElementById(%s).setAttribute('visibility', %%s )" % id + t = dom.Element( 'circle' ) + attributes = { 'cx': str( x ), + 'cy': str( y ), + 'r': 10, + 'style': 'opacity: 0;', + 'onmouseover': visibility % 'visible', + 'onmouseout': visibility % 'hidden', + } + map( lambda a: t.setAttribute( *a ), attributes.items() ) + + def calculate_bottom_margin( self ): + """Override this (and call super) to change the margin to the bottom +of the plot area. Results in border_bottom being set.""" + bb = 7 + if self.key and self.key_position == 'bottom': + bb += len( self.data ) * ( self.font_size + 5 ) + bb += 10 + if self.show_x_labels: + max_x_label_height_px = self.x_label_font_size + if self.rotate_x_labels: + label_lengths = map( len, self.get_x_labels() ) + max_x_label_len = reduce( max, label_lengths ) + max_x_label_height_px *= 0.6 * max_x_label_len + bb += max_x_label_height_px + if self.stagger_x_labels: bb += max_x_label_height_px + 10 + if self.show_x_title: bb += self.x_title_font_size + 5 + self.border_bottom = bb + + def draw_graph: + transform = 'translate ( %s %s )' % ( self.border_left, self.border_top ) + self.graph = CreateElement( 'g', { 'transform': transform } ) + self.root.appendChild( self.graph ) + + self.graph.appendChild( CreateElement( 'rect', { + 'x': '0', + 'y': '0', + 'width': str( self.graph_width ) + 'height': str( self.graph_height ) + 'class': 'graphBackground' + } ) ) + + #Axis + self.graph.appendChild( CreateElement( 'path', { + 'd': 'M 0 0 v%s' % self.graph_height, + 'class': 'axis', + 'id': 'xAxis' + } ) ) + self.graph.appendChild( CreateElement( 'path', { + 'd': 'M 0 %s h%s' % ( self.graph_height, self.graph_width ), + 'class': 'axis', + 'id': 'yAxis' + } ) ) + + self.draw_x_labels() + self.draw_y_labels() + + def x_label_offset( self, width ): + """Where in the X area the label is drawn +Centered in the field, should be width/2. Start, 0.""" + return 0 + + def make_datapoint_text( self, x, y, value, style='' ): + if self.show_data_values: + e = CreateElement( 'text', { + 'x': str( x ), + 'y': str( y ), + 'class': 'dataPointLabel', + 'style': '%(style)s stroke: #fff; stroke-width: 2;' % vars(), + } ) + e.nodeValue = value + self.foreground.appendChild( e ) + + def draw_x_labels( self ): + "Draw the X axis labels" + stagger = self.x_label_font_size + 5 + if self.show_x_labels: + label_width = self.field_width + + labels = self.get_x_labels() + count = len( labels ) + + labels = enumerate( iter( labels ) ) + start = int( !self.step_include_first_x_label ) + labels = itertools.islice( labels, start, None, self.step_x_labels ) + map( self.draw_x_label, labels ) + self.draw_x_guidelines( label_width, count ) + + def draw_x_label( self, label ): + index, label = label + text = CreateElement( 'text', { 'class': 'xAxisLabels' } ) + self.graph.appendChild( text ) + + def start_svg( self ): + "Base SVG Document Creation" + impl = dom.getDOMImplementation() + #dt = impl.createDocumentType( 'svg', 'PUBLIC' + self._doc = impl.createDocument( None, 'svg', None ) + self.root = self._doc.getDocumentElement() + +ruby = """ + # Draws the background, axis, and labels. + def draw_graph + @graph = @root.add_element( "g", { + "transform" => "translate( #@border_left #@border_top )" + }) + + # Background + @graph.add_element( "rect", { + "x" => "0", + "y" => "0", + "width" => @graph_width.to_s, + "height" => @graph_height.to_s, + "class" => "graphBackground" + }) + + # Axis + @graph.add_element( "path", { + "d" => "M 0 0 v#@graph_height", + "class" => "axis", + "id" => "xAxis" + }) + @graph.add_element( "path", { + "d" => "M 0 #@graph_height h#@graph_width", + "class" => "axis", + "id" => "yAxis" + }) + + draw_x_labels + draw_y_labels + end + + + # Where in the X area the label is drawn + # Centered in the field, should be width/2. Start, 0. + def x_label_offset( width ) + 0 + end + + def make_datapoint_text( x, y, value, style="" ) + if show_data_values + @foreground.add_element( "text", { + "x" => x.to_s, + "y" => y.to_s, + "class" => "dataPointLabel", + "style" => "#{style} stroke: #fff; stroke-width: 2;" + }).text = value.to_s + text = @foreground.add_element( "text", { + "x" => x.to_s, + "y" => y.to_s, + "class" => "dataPointLabel" + }) + text.text = value.to_s + text.attributes["style"] = style if style.length > 0 + end + end + + + # Draws the X axis labels + def draw_x_labels + stagger = x_label_font_size + 5 + if show_x_labels + label_width = field_width + + count = 0 + for label in get_x_labels + if step_include_first_x_label == true then + step = count % step_x_labels + else + step = (count + 1) % step_x_labels + end + + if step == 0 then + text = @graph.add_element( "text" ) + text.attributes["class"] = "xAxisLabels" + text.text = label.to_s + + x = count * label_width + x_label_offset( label_width ) + y = @graph_height + x_label_font_size + 3 + t = 0 - (font_size / 2) + + if stagger_x_labels and count % 2 == 1 + y += stagger + @graph.add_element( "path", { + "d" => "M#{x} #@graph_height v#{stagger}", + "class" => "staggerGuideLine" + }) + end + + text.attributes["x"] = x.to_s + text.attributes["y"] = y.to_s + if rotate_x_labels + text.attributes["transform"] = + "rotate( 90 #{x} #{y-x_label_font_size} )"+ + " translate( 0 -#{x_label_font_size/4} )" + text.attributes["style"] = "text-anchor: start" + else + text.attributes["style"] = "text-anchor: middle" + end + end + + draw_x_guidelines( label_width, count ) if show_x_guidelines + count += 1 + end + end + end + + + # Where in the Y area the label is drawn + # Centered in the field, should be width/2. Start, 0. + def y_label_offset( height ) + 0 + end + + + def field_width + (@graph_width.to_f - font_size*2*right_font) / + (get_x_labels.length - right_align) + end + + + def field_height + (@graph_height.to_f - font_size*2*top_font) / + (get_y_labels.length - top_align) + end + + + # Draws the Y axis labels + def draw_y_labels + stagger = y_label_font_size + 5 + if show_y_labels + label_height = field_height + + count = 0 + y_offset = @graph_height + y_label_offset( label_height ) + y_offset += font_size/1.2 unless rotate_y_labels + for label in get_y_labels + y = y_offset - (label_height * count) + x = rotate_y_labels ? 0 : -3 + + if stagger_y_labels and count % 2 == 1 + x -= stagger + @graph.add_element( "path", { + "d" => "M#{x} #{y} h#{stagger}", + "class" => "staggerGuideLine" + }) + end + + text = @graph.add_element( "text", { + "x" => x.to_s, + "y" => y.to_s, + "class" => "yAxisLabels" + }) + text.text = label.to_s + if rotate_y_labels + text.attributes["transform"] = "translate( -#{font_size} 0 ) "+ + "rotate( 90 #{x} #{y} ) " + text.attributes["style"] = "text-anchor: middle" + else + text.attributes["y"] = (y - (y_label_font_size/2)).to_s + text.attributes["style"] = "text-anchor: end" + end + draw_y_guidelines( label_height, count ) if show_y_guidelines + count += 1 + end + end + end + + + # Draws the X axis guidelines + def draw_x_guidelines( label_height, count ) + if count != 0 + @graph.add_element( "path", { + "d" => "M#{label_height*count} 0 v#@graph_height", + "class" => "guideLines" + }) + end + end + + + # Draws the Y axis guidelines + def draw_y_guidelines( label_height, count ) + if count != 0 + @graph.add_element( "path", { + "d" => "M0 #{@graph_height-(label_height*count)} h#@graph_width", + "class" => "guideLines" + }) + end + end + + + # Draws the graph title and subtitle + def draw_titles + if show_graph_title + @root.add_element( "text", { + "x" => (width / 2).to_s, + "y" => (title_font_size).to_s, + "class" => "mainTitle" + }).text = graph_title.to_s + end + + if show_graph_subtitle + y_subtitle = show_graph_title ? + title_font_size + 10 : + subtitle_font_size + @root.add_element("text", { + "x" => (width / 2).to_s, + "y" => (y_subtitle).to_s, + "class" => "subTitle" + }).text = graph_subtitle.to_s + end + + if show_x_title + y = @graph_height + @border_top + x_title_font_size + if show_x_labels + y += x_label_font_size + 5 if stagger_x_labels + y += x_label_font_size + 5 + end + x = width / 2 + + @root.add_element("text", { + "x" => x.to_s, + "y" => y.to_s, + "class" => "xAxisTitle", + }).text = x_title.to_s + end + + if show_y_title + x = y_title_font_size + (y_title_text_direction==:bt ? 3 : -3) + y = height / 2 + + text = @root.add_element("text", { + "x" => x.to_s, + "y" => y.to_s, + "class" => "yAxisTitle", + }) + text.text = y_title.to_s + if y_title_text_direction == :bt + text.attributes["transform"] = "rotate( -90, #{x}, #{y} )" + else + text.attributes["transform"] = "rotate( 90, #{x}, #{y} )" + end + end + end + + def keys + return @data.collect{ |d| d[:title] } + end + + # Draws the legend on the graph + def draw_legend + if key + group = @root.add_element( "g" ) + + key_count = 0 + for key_name in keys + y_offset = (KEY_BOX_SIZE * key_count) + (key_count * 5) + group.add_element( "rect", { + "x" => 0.to_s, + "y" => y_offset.to_s, + "width" => KEY_BOX_SIZE.to_s, + "height" => KEY_BOX_SIZE.to_s, + "class" => "key#{key_count+1}" + }) + group.add_element( "text", { + "x" => (KEY_BOX_SIZE + 5).to_s, + "y" => (y_offset + KEY_BOX_SIZE).to_s, + "class" => "keyText" + }).text = key_name.to_s + key_count += 1 + end + + case key_position + when :right + x_offset = @graph_width + @border_left + 10 + y_offset = @border_top + 20 + when :bottom + x_offset = @border_left + 20 + y_offset = @border_top + @graph_height + 5 + if show_x_labels + max_x_label_height_px = rotate_x_labels ? + get_x_labels.max{|a,b| + a.length<=>b.length + }.length * x_label_font_size : + x_label_font_size + y_offset += max_x_label_height_px + y_offset += max_x_label_height_px + 5 if stagger_x_labels + end + y_offset += x_title_font_size + 5 if show_x_title + end + group.attributes["transform"] = "translate(#{x_offset} #{y_offset})" + end + end + + + private + + def sort_multiple( arrys, lo=0, hi=arrys[0].length-1 ) + if lo < hi + p = partition(arrys,lo,hi) + sort_multiple(arrys, lo, p-1) + sort_multiple(arrys, p+1, hi) + end + arrys + end + + def partition( arrys, lo, hi ) + p = arrys[0][lo] + l = lo + z = lo+1 + while z <= hi + if arrys[0][z] < p + l += 1 + arrys.each { |arry| arry[z], arry[l] = arry[l], arry[z] } + end + z += 1 + end + arrys.each { |arry| arry[lo], arry[l] = arry[l], arry[lo] } + l + end + + def style + if no_css + styles = parse_css + @root.elements.each("//*[@class]") { |el| + cl = el.attributes["class"] + style = styles[cl] + style += el.attributes["style"] if el.attributes["style"] + el.attributes["style"] = style + } + end + end + + def parse_css + css = get_style + rv = {} + while css =~ /^(\.(\w+)(?:\s*,\s*\.\w+)*)\s*\{/m + names_orig = names = $1 + css = $' + css =~ /([^}]+)\}/m + content = $1 + css = $' + + nms = [] + while names =~ /^\s*,?\s*\.(\w+)/ + nms << $1 + names = $' + end + + content = content.tr( "\n\t", " ") + for name in nms + current = rv[name] + current = current ? current+"; "+content : content + rv[name] = current.strip.squeeze(" ") + end + end + return rv + end + + + # Override and place code to add defs here + def add_defs defs + end + + + def start_svg + # Base document + @doc = Document.new + @doc << XMLDecl.new + @doc << DocType.new( %q{svg PUBLIC "-//W3C//DTD SVG 1.0//EN" } + + %q{"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd"} ) + if style_sheet && style_sheet != '' + @doc << ProcessingInstruction.new( "xml-stylesheet", + %Q{href="#{style_sheet}" type="text/css"} ) + end + @root = @doc.add_element( "svg", { + "width" => width.to_s, + "height" => height.to_s, + "viewBox" => "0 0 #{width} #{height}", + "xmlns" => "http://www.w3.org/2000/svg", + "xmlns:xlink" => "http://www.w3.org/1999/xlink", + "xmlns:a3" => "http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/", + "a3:scriptImplementation" => "Adobe" + }) + @root << Comment.new( " "+"\\"*66 ) + @root << Comment.new( " Created with SVG::Graph " ) + @root << Comment.new( " SVG::Graph by Sean E. Russell " ) + @root << Comment.new( " Losely based on SVG::TT::Graph for Perl by"+ + " Leo Lapworth & Stephan Morgan " ) + @root << Comment.new( " "+"/"*66 ) + + defs = @root.add_element( "defs" ) + add_defs defs + if not(style_sheet && style_sheet != '') and !no_css + @root << Comment.new(" include default stylesheet if none specified ") + style = defs.add_element( "style", {"type"=>"text/css"} ) + style << CData.new( get_style ) + end + + @root << Comment.new( "SVG Background" ) + @root.add_element( "rect", { + "width" => width.to_s, + "height" => height.to_s, + "x" => "0", + "y" => "0", + "class" => "svgBackground" + }) + end + + + def calculate_graph_dimensions + calculate_left_margin + calculate_right_margin + calculate_bottom_margin + calculate_top_margin + @graph_width = width - @border_left - @border_right + @graph_height = height - @border_top - @border_bottom + end + + def get_style + return <