diff options
Diffstat (limited to 'share/extensions/nicechart.py')
-rwxr-xr-x | share/extensions/nicechart.py | 523 |
1 files changed, 523 insertions, 0 deletions
diff --git a/share/extensions/nicechart.py b/share/extensions/nicechart.py new file mode 100755 index 0000000..d60bdbf --- /dev/null +++ b/share/extensions/nicechart.py @@ -0,0 +1,523 @@ +#!/usr/bin/env python +# coding=utf-8 +# nicechart.py +# +# Copyright 2011-2016 +# +# Christoph Sterz +# Florian Weber +# Maren Hachmann +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, +# MA 02110-1301, USA. +# +# pylint: disable=attribute-defined-outside-init + +# TODO / Ideas: +# allow negative values for bar charts +# show values for stacked bar charts +# don't create a new layer for each chart, but a normal group +# correct bar height for stacked bars (it's only half as high as it should be, double) +# adjust position of heading +# use aliasing workaround for stacked bars (e.g. let the rectangles overlap) + +# The extension creates one chart for a single value column in one go, +# e.g. chart all temperatures for all months of the year 1978 into one chart. +# (for this, select column 0 for labels and column 1 for values). +# "1978" etc. can be used as heading (Need not be numeric. If not used delete the heading line.) +# Month names can be used as labels +# Values can be shown, in addition to labels (doesn't work with stacked bar charts) +# Values can contain commas as decimal separator, as long as delimiter isn't comma +# Negative values are not yet supported. + +# See tests/data/nicechart_01.csv for example data + +import re +import csv +import math + +from argparse import ArgumentTypeError + +import inkex +from inkex.utils import filename_arg +from inkex import Filter, TextElement, Circle, Rectangle +from inkex.paths import Move, line + +# www.sapdesignguild.org/goodies/diagram_guidelines/color_palettes.html#mss +COLOUR_TABLE = { + "red": ["#460101", "#980101", "#d40000", "#f44800", "#fb8b00", "#eec73e", "#d9bb7a", "#fdd99b"], + "blue": ["#000442", "#0F1781", "#252FB7", "#3A45E1", "#656DDE", "#8A91EC"], + "gray": ["#222222", "#444444", "#666666", "#888888", "#aaaaaa", "#cccccc", "#eeeeee"], + "contrast": ["#0000FF", "#FF0000", "#00FF00", "#CF9100", "#FF00FF", "#00FFFF"], + "sap": ["#f8d753", "#5c9746", "#3e75a7", "#7a653e", "#e1662a", "#74796f", "#c4384f", + "#fff8a3", "#a9cc8f", "#b2c8d9", "#bea37a", "#f3aa79", "#b5b5a9", "#e6a5a5"] +} + +class NiceChart(inkex.GenerateExtension): + """ + Inkscape extension that can draw pie charts and bar charts + (stacked, single, horizontally or vertically) + with optional drop shadow, from a csv file or from pasted text + """ + container_layer = True + + @property + def container_label(self): + """Layer title/label""" + return 'Chart-Layer: {}'.format(self.options.what) + + def add_arguments(self, pars): + pars.add_argument('--tab') + pars.add_argument('--encoding', default='utf-8') + pars.add_argument('-w', '--what', default='22,11,67', help='Chart Values') + pars.add_argument("-t", "--type", type=self.arg_method('render'), + default=self.render_bar, help="Chart Type") + pars.add_argument("-b", "--blur", type=inkex.Boolean, default=True, help="Blur Type") + pars.add_argument("-f", "--filename", type=filename_arg, help="Name of File") + pars.add_argument("-i", "--input_type", default='file', help="Chart Type") + pars.add_argument("-d", "--delimiter", default=';', help="delimiter") + pars.add_argument("-c", "--colors", default='default', help="color-scheme") + pars.add_argument("--colors_override", help="color-scheme-override") + pars.add_argument("--reverse_colors", type=inkex.Boolean, default=False, + help="reverse color-scheme") + pars.add_argument("-k", "--col_key", type=int, default=0, + help="column that contains the keys") + pars.add_argument("-v", "--col_val", type=int, default=1, + help="column that contains the values") + pars.add_argument("--headings", type=inkex.Boolean, default=True, + help="first line of the CSV file consists of headings for the columns") + pars.add_argument("-r", "--rotate", type=inkex.Boolean, default=False, + help="Draw barchart horizontally") + pars.add_argument("-W", "--bar-width", type=int, default=10, help="width of bars") + pars.add_argument("-p", "--pie-radius", type=int, default=100, help="radius of pie-charts") + pars.add_argument("-H", "--bar-height", type=int, default=100, help="height of bars") + pars.add_argument("-O", "--bar-offset", type=int, default=5, help="distance between bars") + pars.add_argument("--stroke-width", type=float, default=1.0) + pars.add_argument("-o", "--text-offset", type=int, default=5, + help="distance between bar and descriptions") + pars.add_argument("--heading-offset", type=int, default=50, + help="distance between chart and chart title") + pars.add_argument("--segment-overlap", type=inkex.Boolean, default=False, + help="Remove aliasing effects by letting pie chart segments overlap") + pars.add_argument("-F", "--font", default='sans-serif', help="font of description") + pars.add_argument("-S", "--font-size", type=int, default=10, + help="font size of description") + pars.add_argument("-C", "--font-color", default='black', help="font color of description") + + pars.add_argument("-V", "--show_values", type=inkex.Boolean, default=False, + help="Show values in chart") + + def get_data(self): + """Process the data""" + col_key = self.options.col_key + col_val = self.options.col_val + + def process_value(val): + """Confirm the values from files or direct""" + val = float(val) + if val < 0: + raise inkex.AbortExtension("Negative values are currently not supported!") + return val + + if self.options.input_type == "file": + if self.options.filename is None: + raise inkex.AbortExtension("Filename not specified!") + + # Future: use encoding when opening the file here (if ever needed) + with open(self.options.filename, "r") as fhl: + reader = csv.reader(fhl, delimiter=self.options.delimiter) + title = col_val + + if self.options.headings: + header = next(reader) + title = header[col_val] + + values = [(line[col_key], process_value(line[col_val])) for line in reader] + return (title,) + tuple(zip(*values)) + + elif self.options.input_type == "direct_input": + (keys, values) = zip(*[l.split(':', 1) for l in self.options.what.split(',')]) + return ('Direct Input', keys, [process_value(val) for val in values]) + + raise inkex.AbortExtension("Unknown input type") + + def get_blur(self): + """Add blur to the svg and return if needed""" + if self.options.blur: + defs = self.svg.defs + # Create new Filter + filt = defs.add(Filter(height='3', width='3', x='-0.5', y='-0.5')) + # Append Gaussian Blur to that Filter + filt.add_primitive('feGaussianBlur', stdDeviation='1.1') + return 'filter:url(#%s);' % filt.get_id() + return '' + + def get_color(self): + """Get the next available color""" + if not hasattr(self, '_colors'): + # Generate list of available colours + if self.options.colors_override: + colors = self.options.colors_override.strip() + else: + colors = self.options.colors + + if colors[0].isalpha(): + colors = COLOUR_TABLE.get(colors.lower(), COLOUR_TABLE['red']) + + else: + colors = re.findall("(#[0-9a-fA-F]{6})", colors) + # to be sure we create a fallback: + if not colors: + colors = COLOUR_TABLE['red'] + + if self.options.reverse_colors: + colors.reverse() + # Cache the list of colours for later use + self._colors = colors + self._color_index = 0 + + color = self._colors[self._color_index] + # Increase index to the next available color + self._color_index = (self._color_index + 1) % len(self._colors) + return color + + def generate(self): + """Generates a nice looking chart into SVG document.""" + + # Process the data from a file or text box + (self.title, keys, values) = self.get_data() + if not values: + raise inkex.AbortExtension("No data to render into a chart.") + + # Get the page attributes: + self.width = self.svg.unittouu(self.svg.get('width')) + self.height = self.svg.unittouu(self.svg.attrib['height']) + self.fontoff = float(self.options.font_size) / 3 + + # Check if a drop shadow should be drawn: + self.blur = self.get_blur() + + # Draw the right type of chart + for elem in self.options.type(keys, values): + yield elem + + def draw_header(self, heading_x): + """Draw an optional header text""" + if self.options.headings and self.title: + headingtext = self.draw_text(self.title, 4, anchor='end') + headingtext.set("y", str(self.height / 2 + self.options.heading_offset)) + headingtext.set("x", str(heading_x)) + return headingtext + return None + + def render_bar(self, keys, values): + """Draw bar chart""" + bar_height = self.options.bar_height + bar_width = self.options.bar_width + bar_offset = self.options.bar_offset + + # Normalize the bars to the largest value + value_max = max(list(values) + [0.0]) + + # Draw Single bars with their shadows + for cnt, value in enumerate(values): + # Draw each bar a set amount offset + offset = cnt * (bar_width + bar_offset) + bar_value = (value / value_max) * bar_height + + # Calculate the location of the bar + x = self.width / 2 + offset + y = self.height / 2 - int(bar_value) + width = bar_width + height = int(bar_value) + + if self.options.rotate: + # Rotate the bar and align to the left + x, y, width, height = y, x, height, width + x += width + + for elem in self.draw_rectangle(x, y, width, height): + yield elem + + # If keys are given, create text elements + if keys: + text = self.draw_text(keys[cnt], anchor='end') + if not self.options.rotate: # =vertical + text.set("transform", "rotate(-90)") + # y after rotation: + text.set("x", "-" + str(self.height / 2 + self.options.text_offset)) + # x after rotation: + text.set("y", str(self.width / 2 + offset + bar_width / 2 + self.fontoff)) + else: # =horizontal + text.set("y", str(self.width / 2 + offset + bar_width / 2 + self.fontoff)) + text.set("x", str(self.height / 2 - self.options.text_offset)) + + yield text + + if self.options.show_values: + vtext = self.draw_text(int(value)) + if not self.options.rotate: # =vertical + vtext.set("transform", "rotate(-90)") + # y after rotation: + vtext.set("x", "-" + str(self.height / 2 + value - self.options.text_offset)) + # x after rotation: + vtext.set("y", str(self.width / 2 + offset + bar_width / 2 + self.fontoff)) + else: # =horizontal + vtext.set("y", str(self.width / 2 + offset + bar_width / 2 + self.fontoff)) + vtext.set("x", str(self.height / 2 + value + self.options.text_offset)) + yield vtext + + yield self.draw_header(self.width / 2) + + def draw_rectangle(self, x, y, width, height): + """Draw a rectangle bar with optional shadow""" + if self.blur: + shadow = Rectangle(x=str(x+1), y=str(y+1), width=str(width), height=str(height)) + shadow.set("style", self.blur) + yield shadow + + rect = Rectangle(x=str(x), y=str(y), width=str(width), height=str(height)) + rect.set("style", "fill:" + self.get_color()) + yield rect + + def draw_text(self, text, add_size=0, anchor='start', **kwargs): + """Draw a textual label""" + vtext = TextElement(**kwargs) + vtext.style = { + 'fill': self.options.font_color, + 'font-family': self.options.font, + 'font-size': str(self.options.font_size + add_size) + 'px', + 'font-style': 'normal', + 'font-variant': 'normal', + 'font-weight': 'normal', + 'font-stretch': 'normal', + '-inkscape-font-specification': 'Bitstream Charter', + 'text-align': anchor, + 'text-anchor': anchor, + } + vtext.text = str(text) + return vtext + + def render_pie_abs(self, keys, values): + """Draw a pie chart, with absolute values""" + # pie_abs = True + for elem in self.render_pie(keys, values, True): + yield elem + + def render_pie(self, keys, values, pie_abs=False): + """Draw pie chart""" + pie_radius = self.options.pie_radius + + # Iterate all values to draw the different slices + color = 0 + x = float(self.width) / 2 + y = float(self.height) / 2 + + # Create the shadow first (if it should be created): + if self.blur: + shadow = Circle(cx=str(x), cy=str(y)) + shadow.set('r', str(pie_radius)) + shadow.set("style", self.blur + "fill:#000000") + yield shadow + + # Add a grey background circle with a light stroke + background = Circle(cx=str(x), cy=str(y)) + background.set("r", str(pie_radius)) + background.set("style", "stroke:#ececec;fill:#f9f9f9") + yield background + + # create value sum in order to divide the slices + try: + valuesum = sum(values) + except ValueError: + valuesum = 0 + + if pie_abs: + valuesum = 100 + + # Set an offsetangle + offset = 0 + + # Draw single slices + for cnt, value in enumerate(values): + # Calculate the PI-angles for start and end + angle = (2 * 3.141592) / valuesum * float(value) + start = offset + end = offset + angle + + # proper overlapping + if self.options.segment_overlap: + if cnt != len(values) - 1: + end += 0.09 # add a 5° overlap + if cnt == 0: + start -= 0.09 # let the first element overlap into the other direction + + # then add the slice + pieslice = inkex.PathElement() + pieslice.set('sodipodi:type', 'arc') + pieslice.set('sodipodi:cx', x) + pieslice.set('sodipodi:cy', y) + pieslice.set('sodipodi:rx', pie_radius) + pieslice.set('sodipodi:ry', pie_radius) + pieslice.set('sodipodi:start', start) + pieslice.set('sodipodi:end', end) + pieslice.set("style", "fill:" + self.get_color() + ";stroke:none;fill-opacity:1") + ang = angle / 2 + offset + + # If text is given, draw short paths and add the text + if keys: + elem = inkex.PathElement() + elem.path = [ + Move( + (self.width / 2) + pie_radius * math.cos(ang), + (self.height / 2) + pie_radius * math.sin(ang), + ), line( + (self.options.text_offset - 2) * math.cos(ang), + (self.options.text_offset - 2) * math.sin(ang), + ), + ] + + elem.style = { + 'fill': 'none', + 'stroke': self.options.font_color, + 'stroke-width': self.options.stroke_width, + 'stroke-linecap': 'butt', + } + yield elem + + label = keys[cnt] + if self.options.show_values: + label += ' ({}{})'.format(str(value), ('', '%')[pie_abs]) + + # check if it is right or left of the Pie + anchor = 'start' if math.cos(ang) > 0 else 'end' + text = self.draw_text(label, anchor=anchor) + + off = pie_radius + self.options.text_offset + text.set("x", (self.width / 2) + off * math.cos(ang)) + text.set("y", (self.height / 2) + off * math.sin(ang) + self.fontoff) + yield text + + # increase the rotation-offset and the colorcycle-position + offset = offset + angle + color = (color + 1) % 8 + + # append the objects to the extension-layer + yield pieslice + + yield self.draw_header(self.width / 2 - pie_radius) + + def render_stbar(self, keys, values): + """Draw stacked bar chart""" + + # Iterate over all values to draw the different slices + color = 0 + + # create value sum in order to divide the bars + try: + valuesum = sum(values) + except ValueError: + valuesum = 0.0 + + # Init offset + offset = 0 + width = self.options.bar_width + height = self.options.bar_height + x = self.width / 2 + y = self.height / 2 + + if self.blur: + if self.options.rotate: + width, height = height, width + shy = y + else: + shy = str(y - self.options.bar_height) + + # Create rectangle element + shadow = Rectangle( + x=str(x), y=str(shy), + width=str(width), height=str(height), + ) + + # Set shadow blur (connect to filter object in xml path) + shadow.set("style", self.blur) + yield shadow + + # Draw Single bars + for cnt, value in enumerate(values): + + # Calculate the individual heights normalized on 100units + normedvalue = (self.options.bar_height / valuesum) * float(value) + + # Create rectangle element + rect = Rectangle() + + # Set chart position to center of document. + if not self.options.rotate: + rect.set('x', str(self.width / 2)) + rect.set('y', str(self.height / 2 - offset - normedvalue)) + rect.set("width", str(self.options.bar_width)) + rect.set("height", str(normedvalue)) + else: + rect.set('x', str(self.width / 2 + offset)) + rect.set('y', str(self.height / 2)) + rect.set("height", str(self.options.bar_width)) + rect.set("width", str(normedvalue)) + + rect.set("style", "fill:" + self.get_color()) + + # If text is given, draw short paths and add the text + # TODO: apply overlap workaround for visible gaps in between + if keys: + if not self.options.rotate: + x1 = (self.width + self.options.bar_width) / 2 + y1 = y - offset - (normedvalue / 2) + x2 = self.options.bar_width / 2 + self.options.text_offset + y2 = 0 + txt = self.width / 2 + self.options.bar_width + self.options.text_offset + 1 + tyt = y - offset + self.fontoff - (normedvalue / 2) + else: + x1 = x + offset + normedvalue / 2 + y1 = y + self.options.bar_width / 2 + x2 = 0 + y2 = self.options.bar_width / 2 + (self.options.font_size \ + * cnt) + self.options.text_offset + txt = x + offset + normedvalue / 2 - self.fontoff + tyt = (y) + self.options.bar_width + (self.options.font_size \ + * (cnt + 1)) + self.options.text_offset + + elem = inkex.PathElement() + elem.path = [Move(x1, y1), line(x2, y2)] + elem.style = { + 'fill': 'none', + 'stroke': self.options.font_color, + 'stroke-width': self.options.stroke_width, + 'stroke-linecap': 'butt', + } + yield elem + yield self.draw_text(keys[cnt], x=str(txt), y=str(tyt)) + + # Increase Offset and Color + offset = offset + normedvalue + color = (color + 1) % 8 + + # Draw rectangle + yield rect + + yield self.draw_header(self.width / 2 + offset + normedvalue) + + +if __name__ == '__main__': + NiceChart().run() |