diff options
Diffstat (limited to '')
-rwxr-xr-x | share/extensions/nicechart.py | 654 |
1 files changed, 654 insertions, 0 deletions
diff --git a/share/extensions/nicechart.py b/share/extensions/nicechart.py new file mode 100755 index 0000000..e25865f --- /dev/null +++ b/share/extensions/nicechart.py @@ -0,0 +1,654 @@ +#!/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 +from inkex.localization import inkex_gettext as _ + +# 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="apples:3,bananas:5,oranges:10,pears:4", + 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=False, 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=False, + 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="#000000", 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 inkex.Style(filter=filt.get_id(as_url=2)) + return inkex.Style() + + 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.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.style = self.blur + inkex.Style(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.arc( + [x, y], pie_radius, pie_radius, start=start, 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.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() |