Browse Source

Merge remote-tracking branch 'wiktorn/2ndary_axis'

pull/26/merge
Florian Mounier 12 years ago
parent
commit
70a6bcbf9d
  1. 8
      pygal/css/graph.css
  2. 15
      pygal/ghost.py
  3. 45
      pygal/graph/base.py
  4. 74
      pygal/graph/graph.py
  5. 51
      pygal/graph/line.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.y2 text {
text-anchor: start;
}
.axis.y .logarithmic text:not(.major) { .axis.y .logarithmic text:not(.major) ,
.axis.y2 .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.y2 .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.y2 .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,

15
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]
if secondary:
self.raw_series2.append((title, values))
else:
self.raw_series.append((title, values)) 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

45
pygal/graph/base.py

@ -35,14 +35,16 @@ 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()
@ -100,6 +102,18 @@ class BaseGraph(object):
h_max = max(h, self.legend_box_size) h_max = max(h, self.legend_box_size)
self.margin.bottom += 10 + h_max * round( self.margin.bottom += 10 + h_max * round(
sqrt(self._order) - 1) * 1.5 + h_max sqrt(self._order) - 1) * 1.5 + h_max
else:
self.margin.left += 10 + w + self.legend_box_size
if self.show_legend and self.secondary_series:
h, w = get_texts_box(
map(lambda x: truncate(x, self.truncate_legend or 15),
cut(self.secondary_series, 'title')),
self.legend_font_size)
if self.legend_at_bottom:
h_max = max(h, self.legend_box_size)
self.margin.bottom += 10 + h_max * round(
sqrt(self._order) - 1) * 1.5 + h_max
else: else:
self.margin.right += 10 + w + self.legend_box_size self.margin.right += 10 + w + self.legend_box_size
@ -130,6 +144,11 @@ class BaseGraph(object):
"""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 +157,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):
@ -155,10 +188,16 @@ class BaseGraph(object):
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"""

74
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):
@ -128,18 +129,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 +196,25 @@ 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)
if self._y_2nd_labels:
secondary_ax = self.svg.node(self.nodes['plot'], class_="axis y2")
for label, position in self._y_2nd_labels:
major = is_major(position)
# it is needed, to have the same structure as primary axis
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,
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 +232,7 @@ 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 x = 10
y = self.margin.top + 10 y = self.margin.top + 10
cols = 1 cols = 1
if not truncation: if not truncation:
@ -219,15 +242,48 @@ 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)))
# i - global serie number - used for coloring and identification
# j - position within current legend box
# l - label
# tf - whether it is secondary label
gen = enumerate(enumerate(chain(
izip(self._legends, repeat(False)),
izip(self._secondary_legends, repeat(True)))))
secondary_legends = legends # svg node is the same
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 +293,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 +383,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)]

51
pygal/graph/line.py

@ -48,9 +48,13 @@ class Line(Graph):
values + values +
[(values[-1][0], zero)]) [(values[-1][0], zero)])
def line(self, serie_node, serie): def line(self, serie_node, serie, rescale=False):
"""Draw the line serie""" """Draw the line serie"""
view_values = map(self.view, serie.points) if rescale and self.secondary_series:
points = list ((x, self._scale_diff+(y - self._scale_min_2nd) * self._scale) for x, y in serie.points)
else:
points = serie.points
view_values = map(self.view, points)
if self.show_dots: if self.show_dots:
for i, (x, y) in enumerate(view_values): for i, (x, y) in enumerate(view_values):
if None in (x, y): if None in (x, y):
@ -71,7 +75,11 @@ class Line(Graph):
val = self._get_value(serie.points, i) val = self._get_value(serie.points, i)
self.svg.node(dots, 'circle', cx=x, cy=y, r=self.dots_size, self.svg.node(dots, 'circle', cx=x, cy=y, r=self.dots_size,
class_='dot reactive tooltip-trigger') class_='dot reactive tooltip-trigger')
self._tooltip_data(dots, val, x, y) self._tooltip_data(dots,
"%s: %s" % (self.x_labels[i], val) if self.x_labels and
self.x_labels_num_limit
else val,
x, y)
self._static_value( self._static_value(
serie_node, val, serie_node, val,
x + self.value_font_size, x + self.value_font_size,
@ -87,12 +95,22 @@ class Line(Graph):
class_='line reactive' + (' nofill' if not self.fill else '')) class_='line reactive' + (' nofill' if not self.fill else ''))
def _compute(self): def _compute(self):
# X Labels
x_pos = [ x_pos = [
x / (self._len - 1) for x in range(self._len) x / (self._len - 1) for x in range(self._len)
] if self._len != 1 else [.5] # Center if only one value ] if self._len != 1 else [.5] # Center if only one value
self._points(x_pos) self._points(x_pos)
x_labels = zip(self.x_labels, x_pos)
if self.x_labels_num_limit and len(x_labels)>self.x_labels_num_limit:
step = (len(x_labels)-1)/(self.x_labels_num_limit-1)
x_labels = list(x_labels[int(i*step)] for i in range(self.x_labels_num_limit))
self._x_labels = self.x_labels and x_labels
# Y Label
if self.include_x_axis: if self.include_x_axis:
self._box.ymin = min(self._min, 0) self._box.ymin = min(self._min, 0)
self._box.ymax = max(self._max, 0) self._box.ymax = max(self._max, 0)
@ -104,15 +122,28 @@ class Line(Graph):
self._box.ymin, self._box.ymax, self.logarithmic, self.order_min self._box.ymin, self._box.ymax, self.logarithmic, self.order_min
) if not self.y_labels else map(float, self.y_labels) ) if not self.y_labels else map(float, self.y_labels)
x_labels = zip(self.x_labels, x_pos)
if self.x_labels_num_limit and len(x_labels)>self.x_labels_num_limit:
step = (len(x_labels)-1)/(self.x_labels_num_limit-1)
x_labels = list(x_labels[int(i*step)] for i in range(self.x_labels_num_limit))
self._x_labels = self.x_labels and x_labels
self._y_labels = zip(map(self._format, y_pos), y_pos) self._y_labels = zip(map(self._format, y_pos), y_pos)
# secondary y axis support
if self.secondary_series:
if self.include_x_axis:
ymin = min(self._secondary_min, 0)
ymax = max(self._secondary_max, 0)
else:
ymin = self._secondary_min
ymax = self._secondary_max
steps = len(y_pos)
left_range = abs(y_pos[-1] - y_pos[0])
right_range = abs(ymax - ymin)
scale = right_range / (steps-1)
self._y_2nd_labels = list((self._format(ymin+i*scale), pos) for i, pos in enumerate(y_pos))
min_2nd = float(self._y_2nd_labels[0][0])
self._scale = left_range / right_range
self._scale_diff = y_pos[0]
self._scale_min_2nd = min_2nd
def _plot(self): def _plot(self):
for index, serie in enumerate(self.series): for index, serie in enumerate(self.series):
self.line(self._serie(index), serie) self.line(self._serie(index), serie)
for index, serie in enumerate(self.secondary_series, len(self.series)):
self.line(self._serie(index), serie, True)

Loading…
Cancel
Save