Browse Source

Secondary axis support

pull/20/head
Wiktor Niesiobedzki 12 years ago
parent
commit
29aa0bf0e2
  1. 8
      pygal/css/graph.css
  2. 17
      pygal/ghost.py
  3. 3
      pygal/graph/__init__.py
  4. 46
      pygal/graph/base.py
  5. 92
      pygal/graph/graph.py
  6. 11
      pygal/util.py

8
pygal/css/graph.css

@ -49,8 +49,12 @@ text.no_data {
.axis.y text { .axis.y text {
text-anchor: end; text-anchor: end;
} }
.axis.2y text {
text-anchor: start;
}
.axis.y .logarithmic text:not(.major) { .axis.y .logarithmic text:not(.major) ,
.axis.2y .logarithmic text:not(.major) {
font-size: 50%; font-size: 50%;
} }
@ -66,11 +70,13 @@ text.no_data {
} }
.horizontal .axis.y .guide.line, .horizontal .axis.y .guide.line,
.horizontal .axis.2y .guide.line,
.vertical .axis.x .guide.line { .vertical .axis.x .guide.line {
opacity: 0; opacity: 0;
} }
.axis.y .guides:hover .guide.line, .axis.y .guides:hover .guide.line,
.axis.2y .guides:hover .guide.line,
.line-graph .axis.x .guides:hover .guide.line, .line-graph .axis.x .guides:hover .guide.line,
.gauge-graph .axis.x .guides:hover .guide.line, .gauge-graph .axis.x .guides:hover .guide.line,
.stackedline-graph .axis.x .guides:hover .guide.line, .stackedline-graph .axis.x .guides:hover .guide.line,

17
pygal/ghost.py

@ -55,20 +55,25 @@ class Ghost(object):
config(**kwargs) config(**kwargs)
self.config = config self.config = config
self.raw_series = [] self.raw_series = []
self.raw_series2 = []
def add(self, title, values): def add(self, title, values, secondary=False):
"""Add a serie to this graph""" """Add a serie to this graph"""
if not hasattr(values, '__iter__') and not isinstance(values, dict): if not hasattr(values, '__iter__') and not isinstance(values, dict):
values = [values] values = [values]
self.raw_series.append((title, values)) if secondary:
self.raw_series2.append((title, values))
else:
self.raw_series.append((title, values))
def make_series(self): def make_series(self, series):
return prepare_values(self.raw_series, self.config, self.cls) return prepare_values(series, self.config, self.cls)
def make_instance(self): def make_instance(self):
self.config(**self.__dict__) self.config(**self.__dict__)
series = self.make_series() series = self.make_series(self.raw_series)
self._last__inst = self.cls(self.config, series) secondary_series = self.make_series(self.raw_series2)
self._last__inst = self.cls(self.config, series, secondary_series)
return self._last__inst return self._last__inst
# Rendering # Rendering

3
pygal/graph/__init__.py

@ -35,5 +35,6 @@ CHARTS_NAMES = [
'Pyramid', 'Pyramid',
'VerticalPyramid', 'VerticalPyramid',
'Dot', 'Dot',
'Gauge' 'Gauge',
'DoubleYLine',
] ]

46
pygal/graph/base.py

@ -35,17 +35,20 @@ class BaseGraph(object):
_adapters = [] _adapters = []
def __init__(self, config, series): def __init__(self, config, series, secondary_series):
"""Init the graph""" """Init the graph"""
self.config = config self.config = config
self.series = series or [] self.series = series or []
self.secondary_series = secondary_series or []
self.horizontal = getattr(self, 'horizontal', False) self.horizontal = getattr(self, 'horizontal', False)
self.svg = Svg(self) self.svg = Svg(self)
self._x_labels = None self._x_labels = None
self._y_labels = None self._y_labels = None
self._y_2nd_labels = None
self.nodes = {} self.nodes = {}
self.margin = Margin(*([20] * 4)) self.margin = Margin(*([20] * 4))
self._box = Box() self._box = Box()
self._secondary_box = Box()
self.view = None self.view = None
if self.logarithmic and self.zero == 0: if self.logarithmic and self.zero == 0:
# Explicit min to avoid interpolation dependency # Explicit min to avoid interpolation dependency
@ -94,7 +97,7 @@ class BaseGraph(object):
if self.show_legend and self.series: if self.show_legend and self.series:
h, w = get_texts_box( h, w = get_texts_box(
map(lambda x: truncate(x, self.truncate_legend or 15), map(lambda x: truncate(x, self.truncate_legend or 15),
cut(self.series, 'title')), cut(self.series + self.secondary_series, 'title')),
self.legend_font_size) self.legend_font_size)
if self.legend_at_bottom: if self.legend_at_bottom:
h_max = max(h, self.legend_box_size) h_max = max(h, self.legend_box_size)
@ -123,13 +126,19 @@ class BaseGraph(object):
h, w = get_texts_box( h, w = get_texts_box(
cut(self._y_labels), self.label_font_size) cut(self._y_labels), self.label_font_size)
self.margin.left += 10 + max( self.margin.left += 10 + max(
w * cos(rad(self.y_label_rotation)), h) w * cos(rad(self.y_label_rotation)), w)
@cached_property @cached_property
def _legends(self): def _legends(self):
"""Getter for series title""" """Getter for series title"""
return [serie.title for serie in self.series] return [serie.title for serie in self.series]
@cached_property
def _secondary_legends(self):
"""Getter for series title on secondary y axis"""
return [serie.title for serie in self.secondary_series]
@cached_property @cached_property
def _values(self): def _values(self):
"""Getter for series values (flattened)""" """Getter for series values (flattened)"""
@ -138,10 +147,24 @@ class BaseGraph(object):
for val in serie.values for val in serie.values
if val is not None] if val is not None]
@cached_property
def _secondary_values(self):
"""Getter for secondary series values (flattened)"""
return [val
for serie in self.secondary_series
for val in serie.values
if val is not None]
@cached_property @cached_property
def _len(self): def _len(self):
"""Getter for the maximum series size""" """Getter for the maximum series size"""
return max([len(serie.values) for serie in self.series] or [0]) return max([len(serie.values) for serie in self.series + self.secondary_series] or [0])
@cached_property
def _secondary_min(self):
"""Getter for the minimum series value"""
return (self.range and self.range[0]) or (
min(self._secondary_values) if self._secondary_values else None)
@cached_property @cached_property
def _min(self): def _min(self):
@ -149,16 +172,29 @@ class BaseGraph(object):
return (self.range and self.range[0]) or ( return (self.range and self.range[0]) or (
min(self._values) if self._values else None) min(self._values) if self._values else None)
@cached_property
def _secondary_min(self):
"""Getter for the secondary minimum series value"""
return (self.range and self.range[0]) or (
min(self._secondary_values) if self._secondary_values else None)
@cached_property @cached_property
def _max(self): def _max(self):
"""Getter for the maximum series value""" """Getter for the maximum series value"""
return (self.range and self.range[1]) or ( return (self.range and self.range[1]) or (
max(self._values) if self._values else None) max(self._values) if self._values else None)
@cached_property
def _secondary_max(self):
"""Getter for the maximum series value"""
return (self.range and self.range[1]) or (
max(self._secondary_values) if self._secondary_values else None)
@cached_property @cached_property
def _order(self): def _order(self):
"""Getter for the maximum series value""" """Getter for the maximum series value"""
return len(self.series) return len(self.series + self.secondary_series)
def _draw(self): def _draw(self):
"""Draw all the things""" """Draw all the things"""

92
pygal/graph/graph.py

@ -25,8 +25,9 @@ from __future__ import division
from pygal.interpolate import interpolation from pygal.interpolate import interpolation
from pygal.graph.base import BaseGraph from pygal.graph.base import BaseGraph
from pygal.view import View, LogView, XYLogView from pygal.view import View, LogView, XYLogView
from pygal.util import is_major, truncate, reverse_text_len from pygal.util import is_major, truncate, reverse_text_len, get_texts_box, cut, rad
from math import isnan, pi, sqrt, ceil from math import isnan, pi, sqrt, ceil, cos
from itertools import repeat, izip, chain, count
class Graph(BaseGraph): class Graph(BaseGraph):
@ -60,6 +61,8 @@ class Graph(BaseGraph):
self.height - self.margin.y, self.height - self.margin.y,
self._box) self._box)
def _make_graph(self): def _make_graph(self):
"""Init common graph svg structure""" """Init common graph svg structure"""
self.nodes['graph'] = self.svg.node( self.nodes['graph'] = self.svg.node(
@ -128,18 +131,21 @@ class Graph(BaseGraph):
self.svg.node(axis, 'path', self.svg.node(axis, 'path',
d='M%f %f v%f' % (0, 0, self.view.height), d='M%f %f v%f' % (0, 0, self.view.height),
class_='line') class_='line')
lastlabel = self._x_labels[-1][0]
for label, position in self._x_labels: for label, position in self._x_labels:
major = is_major(position) major = is_major(position)
guides = self.svg.node(axis, class_='guides') guides = self.svg.node(axis, class_='guides')
x = self.view.x(position) x = self.view.x(position)
y = self.view.height + 5 y = self.view.height + 5
if draw_axes: if draw_axes:
last_guide = (self._y_2nd_labels and label == lastlabel)
self.svg.node( self.svg.node(
guides, 'path', guides, 'path',
d='M%f %f v%f' % (x, 0, self.view.height), d='M%f %f v%f' % (x, 0, self.view.height),
class_='%s%sline' % ( class_='%s%sline' % (
'major ' if major else '', 'major ' if major else '',
'guide ' if position != 0 else '')) 'guide ' if position != 0 and not last_guide
else ''))
y += .5 * self.label_font_size + 5 y += .5 * self.label_font_size + 5
text = self.svg.node( text = self.svg.node(
guides, 'text', guides, 'text',
@ -192,6 +198,37 @@ class Graph(BaseGraph):
text.attrib['transform'] = "rotate(%d %f %f)" % ( text.attrib['transform'] = "rotate(%d %f %f)" % (
self.y_label_rotation, x, y) self.y_label_rotation, x, y)
# TODO:
# - shall we do separate axis 2y node, or use the above (and have an inner
# loop condition)
# - it
# 10 is a magic number around here - margin size, don't know,
# what stands for the additional 2 px
if self._y_2nd_labels:
secondary_ax = self.svg.node(self.nodes['plot'], class_="axis 2y")
#self.svg.node(secondary_ax, 'path',
# d='M%f %f v%f' % (self.view.width-12, 0, self.view.height),
# class_='major line'
#)
for label, position in self._y_2nd_labels:
major = is_major(position)
# it is needed, to have the same structure
guides = self.svg.node(secondary_ax, class_='guides')
x = self.view.width + 5
y = self.view.y(position)
text = self.svg.node(guides, 'text',
x = x,
# XXX: plus or minus?
y = y + .35 * self.label_font_size,
class_ = 'major' if major else ''
)
text.text = label
if self.y_label_rotation:
text.attrib['transform'] = "rotate(%d %f %f)" % (
self.y_label_rotation, x, y)
def _legend(self): def _legend(self):
"""Make the legend box""" """Make the legend box"""
if not self.show_legend: if not self.show_legend:
@ -209,7 +246,15 @@ class Graph(BaseGraph):
truncation = reverse_text_len( truncation = reverse_text_len(
available_space, self.legend_font_size) available_space, self.legend_font_size)
else: else:
x = self.margin.left + self.view.width + 10 # draw primary y axis on left
x = 0
h, w = get_texts_box(
cut(self._y_labels), self.label_font_size)
#x -= 10 + max(w * cos(rad(self.y_label_rotation)), h)
x -= 10 + w
h, w = get_texts_box(self._legends + self._secondary_legends, self.label_font_size)
x -= w
y = self.margin.top + 10 y = self.margin.top + 10
cols = 1 cols = 1
if not truncation: if not truncation:
@ -219,15 +264,44 @@ class Graph(BaseGraph):
self.nodes['graph'], class_='legends', self.nodes['graph'], class_='legends',
transform='translate(%d, %d)' % (x, y)) transform='translate(%d, %d)' % (x, y))
h = max(self.legend_box_size, self.legend_font_size) h = max(self.legend_box_size, self.legend_font_size)
x_step = self.view.width / cols x_step = self.view.width / cols
for i, title in enumerate(self._legends): if self.legend_at_bottom:
# if legends at the bottom, we dont split the windows
counter = count()
# gen structure - (i, (j, (l, tf)))
gen = enumerate(enumerate(chain(
izip(self._legends, repeat(False)),
izip(self._secondary_legends, repeat(True)))))
secondary_legends = legends
else:
gen = enumerate(chain(
enumerate(izip(self._legends, repeat(False))),
enumerate(izip(self._secondary_legends, repeat(True)))))
# draw secondary axis on right
x = self.margin.left + self.view.width + 10
if self._y_2nd_labels:
h, w = get_texts_box(
cut(self._y_labels), self.label_font_size)
x += 10 + max(w * cos(rad(self.y_label_rotation)), h)
y = self.margin.top + 10
secondary_legends = self.svg.node(
self.nodes['graph'], class_='legends',
transform='translate(%d, %d)' % (x, y))
for (global_serie_number, (i, (title, is_secondary))) in gen:
col = i % cols col = i % cols
row = i // cols row = i // cols
legend = self.svg.node( legend = self.svg.node(
legends, class_='legend reactive activate-serie', secondary_legends if is_secondary else legends,
id="activate-serie-%d" % i) class_='legend reactive activate-serie',
id="activate-serie-%d" % global_serie_number)
self.svg.node( self.svg.node(
legend, 'rect', legend, 'rect',
x=col * x_step, x=col * x_step,
@ -237,7 +311,7 @@ class Graph(BaseGraph):
) / 2, ) / 2,
width=self.legend_box_size, width=self.legend_box_size,
height=self.legend_box_size, height=self.legend_box_size,
class_="color-%d reactive" % (i % 16) class_="color-%d reactive" % (global_serie_number % 16)
) )
truncated = truncate(title, truncation) truncated = truncate(title, truncation)
# Serious magical numbers here # Serious magical numbers here
@ -327,7 +401,7 @@ class Graph(BaseGraph):
return self._format(values[i][1]) return self._format(values[i][1])
def _points(self, x_pos): def _points(self, x_pos):
for serie in self.series: for serie in self.series + self.secondary_series:
serie.points = [ serie.points = [
(x_pos[i], v) (x_pos[i], v)
for i, v in enumerate(serie.values)] for i, v in enumerate(serie.values)]

11
pygal/util.py

@ -143,12 +143,21 @@ def compute_logarithmic_scale(min_, max_, min_scale, max_scale):
def compute_scale( def compute_scale(
min_, max_, logarithmic=False, order_min=None, min_, max_, logarithmic=False, order_min=None,
min_scale=4, max_scale=20): min_scale=4, max_scale=20, force_steps=None):
"""Compute an optimal scale between min and max""" """Compute an optimal scale between min and max"""
if min_ == 0 and max_ == 0: if min_ == 0 and max_ == 0:
return [0] return [0]
if max_ - min_ == 0: if max_ - min_ == 0:
return [min_] return [min_]
if force_steps:
# TODO: handle logarithmic scale
step = float(max_ - min_) / (force_steps - 1)
curr = min_
ret = []
for i in range(force_steps):
ret.append(curr)
curr += step
return ret
if logarithmic: if logarithmic:
log_scale = compute_logarithmic_scale( log_scale = compute_logarithmic_scale(
min_, max_, min_scale, max_scale) min_, max_, min_scale, max_scale)

Loading…
Cancel
Save