simplecharts

SVG charts without dependencies
git clone https://git.ce9e.org/simplecharts.git

commit
7b5e2b849230ae9f8e87332b8a72978c8f8b33e9
parent
1451363f6d2cd2fe61e23c56127ef07a306f8e15
Author
Tobias Bengfort <tobias.bengfort@posteo.de>
Date
2019-02-19 12:41
init

Diffstat

A chart.py 257 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

1 files changed, 257 insertions, 0 deletions


diff --git a/chart.py b/chart.py

@@ -0,0 +1,257 @@
   -1     1 from xml.sax.saxutils import escape
   -1     2 
   -1     3 COLORS = ['#e41a1c', '#377eb8', '#4daf4a', '#984ea3', '#ff7f00', '#ffff33']
   -1     4 
   -1     5 
   -1     6 def round_max(value):
   -1     7     s = str(value)
   -1     8     head = int(s[0])
   -1     9     tail = 10 ** (len(s) - 1)
   -1    10     head += 1
   -1    11     if head & 1:
   -1    12         head += 1
   -1    13     return head * tail
   -1    14 
   -1    15 
   -1    16 class BaseRenderer:
   -1    17     stacked = False
   -1    18 
   -1    19     def __init__(
   -1    20         self, height=480, width=640, padding=5, colors=COLORS, ui_color='#333'
   -1    21     ):
   -1    22         self.height = height
   -1    23         self.width = width
   -1    24         self.padding = padding
   -1    25         self.colors = colors
   -1    26         self.ui_color = ui_color
   -1    27 
   -1    28         self.char_width = 10
   -1    29         self.char_padding = 4
   -1    30         self.x_labels = self.char_width * 5
   -1    31         self.y_labels = 20
   -1    32         self.y_legend = 20
   -1    33 
   -1    34     def get_color(self, i):
   -1    35         return self.colors[i % len(self.colors)]
   -1    36 
   -1    37     def attrs(self, **kwargs):
   -1    38         return ''.join(
   -1    39             ' {}="{}"'.format(key.replace('_', '-'), escape(str(value)))
   -1    40             for key, value in kwargs.items()
   -1    41         )
   -1    42 
   -1    43     def element(self, tag, content=None, **attrs):
   -1    44         if content:
   -1    45             if '\n' in content:
   -1    46                 lines = content.strip().split('\n')
   -1    47                 content = '\n' + '\n'.join('\t' + l for l in lines) + '\n'
   -1    48             return '<{tag}{attrs}>{content}</{tag}>\n'.format(
   -1    49                 tag=tag,
   -1    50                 attrs=self.attrs(**attrs),
   -1    51                 content=content,
   -1    52             )
   -1    53         else:
   -1    54             return '<{tag}{attrs} />\n'.format(
   -1    55                 tag=tag,
   -1    56                 attrs=self.attrs(**attrs),
   -1    57             )
   -1    58 
   -1    59     def line(self, x1, x2, y1, y2, color, **kwargs):
   -1    60         return self.element(
   -1    61             'line', x1=x1, x2=x2, y1=y1, y2=y2, stroke=color, **kwargs)
   -1    62 
   -1    63     def text(self, s, x, y, **kwargs):
   -1    64         return self.element(
   -1    65             'text', escape(str(s)), x=x, y=y, fill=self.ui_color, **kwargs)
   -1    66 
   -1    67     def rect(self, x, y, width, height, title=None, **kwargs):
   -1    68         content = None
   -1    69         if title:
   -1    70             content = self.element('title', escape(str(title)))
   -1    71         return self.element(
   -1    72             'rect', content, x=x, y=y, width=width, height=height, **kwargs)
   -1    73 
   -1    74     def path(self, points, **kwargs):
   -1    75         d = 'M {},{} L'.format(*points[0])
   -1    76         for x, y in points[1:]:
   -1    77             d += ' {},{}'.format(x, y)
   -1    78         return self.element('path', d=d, **kwargs)
   -1    79 
   -1    80     def get_title(self, rows, legend, i, j):
   -1    81         key = rows[i]['label']
   -1    82         if legend:
   -1    83             key += ' - ' + legend[j]
   -1    84         return '{}: {}'.format(key, rows[i]['values'][j])
   -1    85 
   -1    86     def render_axes(self, rows, max_value):
   -1    87         s = ''
   -1    88         s += self.line(0, 0, 0, self.height, self.ui_color)
   -1    89         s += self.line(0, self.width, self.height, self.height, self.ui_color)
   -1    90 
   -1    91         for y, value in [
   -1    92             (self.height, 0),
   -1    93             (self.height / 2, max_value // 2),
   -1    94             (0, max_value),
   -1    95         ]:
   -1    96             s += self.text(value, -self.char_padding, y, **{
   -1    97                 'dominant-baseline': 'middle',
   -1    98                 'text-anchor': 'end',
   -1    99             })
   -1   100 
   -1   101         width = self.width / len(rows)
   -1   102         y = self.height + self.y_labels / 2
   -1   103         for i, row in enumerate(rows):
   -1   104             x = (i + 0.5) * width
   -1   105             s += self.text(row['label'], x, y, **{
   -1   106                 'dominant-baseline': 'middle',
   -1   107                 'text-anchor': 'middle',
   -1   108             })
   -1   109 
   -1   110         return s
   -1   111 
   -1   112     def render_legend(self, legend):
   -1   113         s = ''
   -1   114 
   -1   115         width = 2 * self.char_padding - self.char_width
   -1   116         for label in legend:
   -1   117             width += self.char_width + self.char_padding  # square
   -1   118             width += len(label) * self.char_width  # text
   -1   119             width += self.char_width  # margin
   -1   120 
   -1   121         x = self.width - width
   -1   122 
   -1   123         s += self.rect(x, -self.y_legend, width, self.y_legend, **{
   -1   124             'fill': 'none',
   -1   125             'stroke': self.ui_color,
   -1   126         })
   -1   127         x += self.char_padding
   -1   128 
   -1   129         y = -self.y_legend / 2
   -1   130         for i, label in enumerate(legend):
   -1   131             # square
   -1   132             size = self.char_width
   -1   133             s += self.rect(x, y - size / 2, size, size, fill=self.get_color(i))
   -1   134             x += self.char_width + self.char_padding
   -1   135             # text
   -1   136             s += self.text(label, x, y, **{
   -1   137                 'dominant-baseline': 'middle',
   -1   138             })
   -1   139             x += len(label) * self.char_width
   -1   140             # margin
   -1   141             x += self.char_width
   -1   142 
   -1   143         return self.element('g', s)
   -1   144 
   -1   145     def render_rows(self, rows, legend, max_value):
   -1   146         raise NotImplementedError
   -1   147 
   -1   148     def get_view_box(self):
   -1   149         p = 2 * self.padding
   -1   150         x = -(self.padding + self.x_labels)
   -1   151         y = -(self.padding + self.y_legend)
   -1   152         width = self.width + p + self.x_labels
   -1   153         height = self.height + p + self.y_legend + self.y_legend
   -1   154         return '{} {} {} {}'.format(x, y, width, height)
   -1   155 
   -1   156     def render(self, data):
   -1   157         if self.stacked:
   -1   158             max_value = max(sum(row['values']) for row in data['rows'])
   -1   159         else:
   -1   160             max_value = max(max(row['values']) for row in data['rows'])
   -1   161         max_value = round_max(max_value)
   -1   162 
   -1   163         content = ''
   -1   164         content += self.render_axes(data['rows'], max_value)
   -1   165         if 'legend' in data:
   -1   166             content += self.render_legend(data['legend'])
   -1   167         content += self.render_rows(data['rows'], data['legend'], max_value)
   -1   168 
   -1   169         return self.element(
   -1   170             'svg',
   -1   171             content,
   -1   172             xmlns='http://www.w3.org/2000/svg',
   -1   173             viewBox=self.get_view_box(),
   -1   174         )
   -1   175 
   -1   176 
   -1   177 class ColumnRenderer(BaseRenderer):
   -1   178     def render_rows(self, rows, legend, max_value):
   -1   179         s = ''
   -1   180         n = len(rows)
   -1   181         k = len(rows[0]['values'])
   -1   182         width = self.width / n / (k + 2)
   -1   183         for i, row in enumerate(rows):
   -1   184             group = ''
   -1   185             for j, value in enumerate(row['values']):
   -1   186                 height = self.height * value / max_value
   -1   187                 x = width * (i * (k + 2) + j + 1)
   -1   188                 group += self.rect(
   -1   189                     x,
   -1   190                     self.height - height - 1,
   -1   191                     width,
   -1   192                     height,
   -1   193                     fill=self.get_color(j),
   -1   194                     stroke='white',
   -1   195                     title=self.get_title(rows, legend, i, j),
   -1   196                 )
   -1   197             s += self.element('g', group)
   -1   198         return s
   -1   199 
   -1   200 
   -1   201 class StackedColumnRenderer(BaseRenderer):
   -1   202     stacked = True
   -1   203 
   -1   204     def render_rows(self, rows, legend, max_value):
   -1   205         s = ''
   -1   206         n = len(rows)
   -1   207         width = self.width / n
   -1   208         for i, row in enumerate(rows):
   -1   209             group = ''
   -1   210             y = self.height - 1
   -1   211             for j, value in enumerate(row['values']):
   -1   212                 height = self.height * value / max_value
   -1   213                 x = width * (i + 0.5)
   -1   214                 y -= height
   -1   215                 group += self.rect(x - width / 6, y, width / 3, height, **{
   -1   216                     'fill': self.get_color(j),
   -1   217                     'stroke': 'white',
   -1   218                     'title': self.get_title(rows, legend, i, j),
   -1   219                 })
   -1   220             s += self.element('g', group)
   -1   221         return s
   -1   222 
   -1   223 
   -1   224 class LineRenderer(BaseRenderer):
   -1   225     def render_rows(self, rows, legend, max_value):
   -1   226         s = ''
   -1   227         k = len(rows[0]['values'])
   -1   228         width = self.width / len(rows)
   -1   229         for j in range(k):
   -1   230             points = []
   -1   231             for i, row in enumerate(rows):
   -1   232                 x = width * (i + 0.5)
   -1   233                 y = self.height * row['values'][j] / max_value
   -1   234                 points.append((x, self.height - y))
   -1   235             s += self.path(points, fill='none', stroke=self.get_color(j))
   -1   236         return s
   -1   237 
   -1   238 
   -1   239 class StackedAreaRenderer(BaseRenderer):
   -1   240     stacked = True
   -1   241 
   -1   242     def render_rows(self, rows, legend, max_value):
   -1   243         s = ''
   -1   244         k = len(rows[0]['values'])
   -1   245         width = self.width / len(rows)
   -1   246         prev = [(width * (i + 0.5), 1) for i in range(len(rows))]
   -1   247         for j in range(k):
   -1   248             points = []
   -1   249             for i, row in enumerate(rows):
   -1   250                 x = width * (i + 0.5)
   -1   251                 y = self.height * row['values'][j] / max_value
   -1   252                 points.append((x, prev[i][1] + y))
   -1   253             s += self.path([
   -1   254                 (x, self.height - y) for x, y in points + list(reversed(prev))
   -1   255             ], fill=self.get_color(j), stroke='white')
   -1   256             prev = points
   -1   257         return s