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 {
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%;
}
@ -66,11 +70,13 @@ text.no_data {
}
.horizontal .axis.y .guide.line,
.horizontal .axis.2y .guide.line,
.vertical .axis.x .guide.line {
opacity: 0;
}
.axis.y .guides:hover .guide.line,
.axis.2y .guides:hover .guide.line,
.line-graph .axis.x .guides:hover .guide.line,
.gauge-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)
self.config = config
self.raw_series = []
self.raw_series2 = []
def add(self, title, values):
def add(self, title, values, secondary=False):
"""Add a serie to this graph"""
if not hasattr(values, '__iter__') and not isinstance(values, dict):
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):
return prepare_values(self.raw_series, self.config, self.cls)
def make_series(self, series):
return prepare_values(series, self.config, self.cls)
def make_instance(self):
self.config(**self.__dict__)
series = self.make_series()
self._last__inst = self.cls(self.config, series)
series = self.make_series(self.raw_series)
secondary_series = self.make_series(self.raw_series2)
self._last__inst = self.cls(self.config, series, secondary_series)
return self._last__inst
# Rendering

3
pygal/graph/__init__.py

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

46
pygal/graph/base.py

@ -35,17 +35,20 @@ class BaseGraph(object):
_adapters = []
def __init__(self, config, series):
def __init__(self, config, series, secondary_series):
"""Init the graph"""
self.config = config
self.series = series or []
self.secondary_series = secondary_series or []
self.horizontal = getattr(self, 'horizontal', False)
self.svg = Svg(self)
self._x_labels = None
self._y_labels = None
self._y_2nd_labels = None
self.nodes = {}
self.margin = Margin(*([20] * 4))
self._box = Box()
self._secondary_box = Box()
self.view = None
if self.logarithmic and self.zero == 0:
# Explicit min to avoid interpolation dependency
@ -94,7 +97,7 @@ class BaseGraph(object):
if self.show_legend and self.series:
h, w = get_texts_box(
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)
if self.legend_at_bottom:
h_max = max(h, self.legend_box_size)
@ -123,12 +126,18 @@ class BaseGraph(object):
h, w = get_texts_box(
cut(self._y_labels), self.label_font_size)
self.margin.left += 10 + max(
w * cos(rad(self.y_label_rotation)), h)
w * cos(rad(self.y_label_rotation)), w)
@cached_property
def _legends(self):
"""Getter for series title"""
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
def _values(self):
@ -138,10 +147,24 @@ class BaseGraph(object):
for val in serie.values
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
def _len(self):
"""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
def _min(self):
@ -149,16 +172,29 @@ class BaseGraph(object):
return (self.range and self.range[0]) or (
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
def _max(self):
"""Getter for the maximum series value"""
return (self.range and self.range[1]) or (
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
def _order(self):
"""Getter for the maximum series value"""
return len(self.series)
return len(self.series + self.secondary_series)
def _draw(self):
"""Draw all the things"""

92
pygal/graph/graph.py

@ -25,8 +25,9 @@ from __future__ import division
from pygal.interpolate import interpolation
from pygal.graph.base import BaseGraph
from pygal.view import View, LogView, XYLogView
from pygal.util import is_major, truncate, reverse_text_len
from math import isnan, pi, sqrt, ceil
from pygal.util import is_major, truncate, reverse_text_len, get_texts_box, cut, rad
from math import isnan, pi, sqrt, ceil, cos
from itertools import repeat, izip, chain, count
class Graph(BaseGraph):
@ -60,6 +61,8 @@ class Graph(BaseGraph):
self.height - self.margin.y,
self._box)
def _make_graph(self):
"""Init common graph svg structure"""
self.nodes['graph'] = self.svg.node(
@ -128,18 +131,21 @@ class Graph(BaseGraph):
self.svg.node(axis, 'path',
d='M%f %f v%f' % (0, 0, self.view.height),
class_='line')
lastlabel = self._x_labels[-1][0]
for label, position in self._x_labels:
major = is_major(position)
guides = self.svg.node(axis, class_='guides')
x = self.view.x(position)
y = self.view.height + 5
if draw_axes:
last_guide = (self._y_2nd_labels and label == lastlabel)
self.svg.node(
guides, 'path',
d='M%f %f v%f' % (x, 0, self.view.height),
class_='%s%sline' % (
'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
text = self.svg.node(
guides, 'text',
@ -192,6 +198,37 @@ class Graph(BaseGraph):
text.attrib['transform'] = "rotate(%d %f %f)" % (
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):
"""Make the legend box"""
if not self.show_legend:
@ -209,7 +246,15 @@ class Graph(BaseGraph):
truncation = reverse_text_len(
available_space, self.legend_font_size)
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
cols = 1
if not truncation:
@ -219,15 +264,44 @@ class Graph(BaseGraph):
self.nodes['graph'], class_='legends',
transform='translate(%d, %d)' % (x, y))
h = max(self.legend_box_size, self.legend_font_size)
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
row = i // cols
legend = self.svg.node(
legends, class_='legend reactive activate-serie',
id="activate-serie-%d" % i)
secondary_legends if is_secondary else legends,
class_='legend reactive activate-serie',
id="activate-serie-%d" % global_serie_number)
self.svg.node(
legend, 'rect',
x=col * x_step,
@ -237,7 +311,7 @@ class Graph(BaseGraph):
) / 2,
width=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)
# Serious magical numbers here
@ -327,7 +401,7 @@ class Graph(BaseGraph):
return self._format(values[i][1])
def _points(self, x_pos):
for serie in self.series:
for serie in self.series + self.secondary_series:
serie.points = [
(x_pos[i], v)
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(
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"""
if min_ == 0 and max_ == 0:
return [0]
if max_ - min_ == 0:
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:
log_scale = compute_logarithmic_scale(
min_, max_, min_scale, max_scale)

Loading…
Cancel
Save