Skip to content

Commit

Permalink
Working GroupedBars implementation. wireservice#26
Browse files Browse the repository at this point in the history
  • Loading branch information
nbedi committed Jun 9, 2016
1 parent 41eeee8 commit 6ce0fda
Show file tree
Hide file tree
Showing 8 changed files with 236 additions and 9 deletions.
4 changes: 4 additions & 0 deletions examples/charts/grouped_bars.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions examples/grouped_bars.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import leather
from datetime import date

data = [
(1, 'Hello', 'first'),
(5, 'World', 'first'),
(7, 'Hello', 'second'),
(4, 'Goodbye', 'second'),
(7, 'Hello', 'third'),
(3, 'Goodbye', 'third'),
(4, 'Yellow', 'third')
]

chart = leather.Chart('Bars')
chart.add_grouped_bars(data)
chart.to_svg('examples/charts/grouped_bars.svg')
32 changes: 25 additions & 7 deletions leather/chart.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@
from leather.data_types import Date, DateTime
from leather.scales import Scale, Linear, Temporal
from leather.series import Series, CategorySeries
from leather.shapes import Bars, Columns, Dots, Line
from leather.shapes import Bars, Columns, Dots, Line, GroupedBars
import leather.svg as svg
from leather import theme
from leather.utils import X, Y, Box, IPythonSVG
from leather.utils import X, Y, Z, Box, IPythonSVG


class Chart(object):
Expand All @@ -28,9 +28,9 @@ def __init__(self, title=None):
self._series_colors = theme.default_series_colors

self._layers = []
self._types = [None, None]
self._scales = [None, None]
self._axes = [None, None]
self._types = [None, None, None]
self._scales = [None, None, None]
self._axes = [None, None, None]

def _palette(self):
"""
Expand Down Expand Up @@ -166,6 +166,16 @@ def add_line(self, data, x=None, y=None, name=None, stroke_color=None, width=Non
Line(stroke_color, width)
)

def add_grouped_bars(self, data, x=None, y=None, z=None, name=None, fill_color=None):
"""
Create and add a :class:`.CategorySeries` rendered with
:class:`.GroupedBars`.
"""
self.add_series(
CategorySeries(data, x=x, y=y, z=z, name=name),
GroupedBars(fill_color)
)

def _validate_dimension(self, dimension):
"""
Validates that the given scale and axis are valid for the data that
Expand Down Expand Up @@ -291,8 +301,16 @@ def to_svg_group(self, width=None, height=None):
margin_group.append(body_group)

# Axes
x_scale, x_axis = self._validate_dimension(X)
y_scale, y_axis = self._validate_dimension(Y)
if isinstance(self._layers[0][0], CategorySeries):
if self._layers[0][1].legend_dimension() is X:
x_scale, x_axis = self._validate_dimension(Z)
y_scale, y_axis = self._validate_dimension(Y)
elif self._layers[0][1].legend_dimension() is Y:
x_scale, x_axis = self._validate_dimension(X)
y_scale, y_axis = self._validate_dimension(Z)
else:
x_scale, x_axis = self._validate_dimension(X)
y_scale, y_axis = self._validate_dimension(Y)

bottom_margin = x_axis.estimate_label_margin(x_scale, 'bottom')
left_margin = y_axis.estimate_label_margin(y_scale, 'left')
Expand Down
3 changes: 2 additions & 1 deletion leather/scales/ordinal.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ class Ordinal(Scale):
A scale that maps individual values (e.g. strings) to a range.
"""
def __init__(self, domain):
self._domain = list(set(domain))
seen = set()
self._domain = [v for v in domain if v not in seen and not seen.add(v)]

def project(self, value, range_min, range_max):
"""
Expand Down
1 change: 1 addition & 0 deletions leather/shapes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
from leather.shapes.columns import Columns
from leather.shapes.dots import Dots
from leather.shapes.line import Line
from leather.shapes.grouped_bars import GroupedBars
96 changes: 96 additions & 0 deletions leather/shapes/category.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#!/usr/bin/env python

import six
import xml.etree.ElementTree as ET

from leather import theme
from leather.shapes.base import Shape


class CategoryShape(Shape):
"""
Base class for shapes that can be used to render data :class:`.CategorySeries`.
Extends the base :class:`.Shape` class.
"""
def legend_dimension(self):
return self._legend_dimension

def legend_labels(self, series, palette):
"""
Generate a dictionary of labels mappeds to colors for the legend.
"""
label_colors = []
legend_dimension = self._legend_dimension

seen = set()
legend_values = [v for v in series.values(self._legend_dimension) if v not in seen and not seen.add(v)]

colors = list(palette)
color_count = len(colors)

for i, value in enumerate(legend_values):
label_colors.append((value, colors[i % color_count]))

return label_colors

def legend_to_svg(self, series, palette):
"""
Render the legend entries for these shapes.
"""
label_colors = self.legend_labels(series, palette)
item_groups = []

if hasattr(self, '_stroke_color'):
if self._stroke_color:
if callable(self._stroke_color):
# TODO
stroke_color = 'black'
else:
stroke_color = self._stroke_color
else:
stroke_color = next(palette)
else:
stroke_color = None

bubble_width = theme.legend_bubble_size + theme.legend_bubble_offset

for label, fill_color in label_colors:
text = six.text_type(label) if label is not None else 'Unnamed label'
text_width = (len(text) + 4) * theme.legend_font_char_width

item_width = text_width + bubble_width

# Group
item_group = ET.Element('g')

# Bubble
bubble = ET.Element('rect',
x=six.text_type(0),
y=six.text_type(-theme.legend_font_char_height + theme.legend_bubble_offset),
width=six.text_type(theme.legend_bubble_size),
height=six.text_type(theme.legend_bubble_size)
)

if fill_color:
bubble.set('fill', fill_color)
elif stroke_color:
bubble.set('fill', stroke_color)

item_group.append(bubble)

# Label
label = ET.Element('text',
x=six.text_type(bubble_width),
y=six.text_type(0),
fill=theme.legend_color
)
label.set('font-family', theme.legend_font_family)
label.set('font-size', six.text_type(theme.legend_font_size))
label.text = text

item_group.append(label)

item_groups.append((item_group, item_width))

return item_groups
83 changes: 83 additions & 0 deletions leather/shapes/grouped_bars.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#!/usr/bin/env python

import xml.etree.ElementTree as ET

import six

from leather.series import CategorySeries
from leather.shapes.category import CategoryShape
from leather.utils import issequence, Y, Z
from leather import theme


class GroupedBars(CategoryShape):
"""
Render a categorized series of data as grouped bars.
:param fill_color:
A sequence of colors to fill the bars. If the sequence is shorter than
the number of values in any category, the colors will be repeated.
"""
def __init__(self, fill_color=None):
self._fill_color = fill_color
self._legend_dimension = Y

def validate_series(self, series):
"""
Verify this shape can be used to render a given series.
"""
if issequence(self._fill_color) and len(series.categories()) > len(self._fill_color.keys()):
raise ValueError('fill_color must have an element for every category in the series.')

def to_svg(self, width, height, x_scale, y_scale, series, palette):
"""
Render bars to SVG elements.
"""
group = ET.Element('g')
group.set('class', 'series grouped-bars')

zero_x = x_scale.project(0, 0, width)

if self._fill_color:
fill_color = self._fill_color
else:
fill_color = list(palette)

label_colors = self.legend_labels(series, fill_color)

categories = series.categories()
category_counts = {c: series.values(Z).count(c) for c in categories}
seen_counts = {c: 0 for c in categories}

# Bars display "top-down"
for i, d in enumerate(series.data()):
if d.x is None or d.y is None:
continue

y1, y2 = y_scale.project_interval(d.z, height, 0)

group_height = (y1 - y2) / category_counts[d.z]
y1 = y2 + (group_height * (seen_counts[d.z] + 1)) - 1
y2 = y2 + (group_height * seen_counts[d.z])

proj_x = x_scale.project(d.x, 0, width)

if d.x < 0:
bar_x = proj_x
bar_width = zero_x - proj_x
else:
bar_x = zero_x
bar_width = proj_x - zero_x

color = dict(label_colors)[d.y]
seen_counts[d.z] += 1

group.append(ET.Element('rect',
x=six.text_type(bar_x),
y=six.text_type(y2),
width=six.text_type(bar_width),
height=six.text_type(y1 - y2),
fill=color
))

return group
10 changes: 9 additions & 1 deletion leather/utils.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
#!/usr/bin/env python

from collections import namedtuple
from collections import namedtuple, Sequence
from datetime import date, datetime, timedelta
from decimal import Decimal
import math
import six
import sys

try:
Expand Down Expand Up @@ -172,3 +173,10 @@ def from_microsecond_count(n, t=datetime):
n microseconds > date
"""
return t.min + timedelta(microseconds=n)

def issequence(obj):
"""
Returns :code:`True` if the given object is an instance of
:class:`.Sequence` that is not also a string.
"""
return isinstance(obj, Sequence) and not isinstance(obj, six.string_types)

0 comments on commit 6ce0fda

Please sign in to comment.