653 lines
22 KiB
Python
Executable file
653 lines
22 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
# 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()
|