debian-python-pygal/pygal/util.py

387 lines
11 KiB
Python

# -*- coding: utf-8 -*-
# This file is part of pygal
#
# A python svg graph plotting library
# Copyright © 2012-2016 Kozea
#
# This library is free software: you can redistribute it and/or modify it under
# the terms of the GNU Lesser General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# This library is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with pygal. If not, see <http://www.gnu.org/licenses/>.
"""Various utility functions"""
from __future__ import division
import re
from decimal import Decimal
from math import ceil, cos, floor, log10, pi, sin
from pygal._compat import _ellipsis, to_unicode, u
def float_format(number):
"""Format a float to a precision of 3, without zeroes or dots"""
return ("%.3f" % number).rstrip('0').rstrip('.')
def majorize(values):
"""Filter sequence to return only major considered numbers"""
sorted_values = sorted(values)
if len(values) <= 3 or (
abs(2 * sorted_values[1] - sorted_values[0] - sorted_values[2]) >
abs(1.5 * (sorted_values[1] - sorted_values[0]))):
return []
values_step = sorted_values[1] - sorted_values[0]
full_range = sorted_values[-1] - sorted_values[0]
step = 10 ** int(log10(full_range))
if step == values_step:
step *= 10
step_factor = 10 ** (int(log10(step)) + 1)
if round(step * step_factor) % (round(values_step * step_factor) or 1):
# TODO: Find lower common multiple instead
step *= values_step
if full_range <= 2 * step:
step *= .5
elif full_range >= 5 * step:
step *= 5
major_values = [
value for value in values if value / step == round(value / step)]
return [value for value in sorted_values if value in major_values]
def round_to_int(number, precision):
"""Round a number to a precision"""
precision = int(precision)
rounded = (int(number) + precision / 2) // precision * precision
return rounded
def round_to_float(number, precision):
"""Round a float to a precision"""
rounded = Decimal(
str(floor((number + precision / 2) // precision))
) * Decimal(str(precision))
return float(rounded)
def round_to_scale(number, precision):
"""Round a number or a float to a precision"""
if precision < 1:
return round_to_float(number, precision)
return round_to_int(number, precision)
def cut(list_, index=0):
"""Cut a list by index or arg"""
if isinstance(index, int):
cut_ = lambda x: x[index]
else:
cut_ = lambda x: getattr(x, index)
return list(map(cut_, list_))
def rad(degrees):
"""Convert degrees in radiants"""
return pi * degrees / 180
def deg(radiants):
"""Convert radiants in degrees"""
return 180 * radiants / pi
def _swap_curly(string):
"""Swap single and double curly brackets"""
return (string
.replace('{{ ', '{{')
.replace('{{', '\x00')
.replace('{', '{{')
.replace('\x00', '{')
.replace(' }}', '}}')
.replace('}}', '\x00')
.replace('}', '}}')
.replace('\x00', '}'))
def template(string, **kwargs):
"""Format a string using double braces"""
return _swap_curly(string).format(**kwargs)
swap = lambda tuple_: tuple(reversed(tuple_))
ident = lambda x: x
def compute_logarithmic_scale(min_, max_, min_scale, max_scale):
"""Compute an optimal scale for logarithmic"""
if max_ <= 0 or min_ <= 0:
return []
min_order = int(floor(log10(min_)))
max_order = int(ceil(log10(max_)))
positions = []
amplitude = max_order - min_order
if amplitude <= 1:
return []
detail = 10.
while amplitude * detail < min_scale * 5:
detail *= 2
while amplitude * detail > max_scale * 3:
detail /= 2
for order in range(min_order, max_order + 1):
for i in range(int(detail)):
tick = (10 * i / detail or 1) * 10 ** order
tick = round_to_scale(tick, tick)
if min_ <= tick <= max_ and tick not in positions:
positions.append(tick)
return positions
def compute_scale(
min_, max_, logarithmic, order_min,
min_scale, max_scale):
"""Compute an optimal scale between min and max"""
if min_ == 0 and max_ == 0:
return [0]
if max_ - min_ == 0:
return [min_]
if logarithmic:
log_scale = compute_logarithmic_scale(
min_, max_, min_scale, max_scale)
if log_scale:
return log_scale
# else we fallback to normal scalling
order = round(log10(max(abs(min_), abs(max_)))) - 1
if order_min is not None and order < order_min:
order = order_min
else:
while ((max_ - min_) / (10 ** order) < min_scale and
(order_min is None or order > order_min)):
order -= 1
step = float(10 ** order)
while (max_ - min_) / step > max_scale:
step *= 2.
positions = []
position = round_to_scale(min_, step)
while position < (max_ + step):
rounded = round_to_scale(position, step)
if min_ <= rounded <= max_:
if rounded not in positions:
positions.append(rounded)
position += step
if len(positions) < 2:
return [min_, max_]
return positions
def text_len(length, fs):
"""Approximation of text width"""
return length * 0.6 * fs
def reverse_text_len(width, fs):
"""Approximation of text length"""
return int(width / (0.6 * fs))
def get_text_box(text, fs):
"""Approximation of text bounds"""
return (fs, text_len(len(text), fs))
def get_texts_box(texts, fs):
"""Approximation of multiple texts bounds"""
max_len = max(map(len, texts))
return (fs, text_len(max_len, fs))
def decorate(svg, node, metadata):
"""Add metedata next to a node"""
if not metadata:
return node
xlink = metadata.get('xlink')
if xlink:
if not isinstance(xlink, dict):
xlink = {'href': xlink, 'target': '_blank'}
node = svg.node(node, 'a', **xlink)
svg.node(node, 'desc', class_='xlink').text = to_unicode(
xlink.get('href'))
if 'tooltip' in metadata:
svg.node(node, 'title').text = to_unicode(
metadata['tooltip'])
if 'color' in metadata:
color = metadata.pop('color')
node.attrib['style'] = 'fill: %s; stroke: %s' % (
color, color)
if 'style' in metadata:
node.attrib['style'] = metadata.pop('style')
if 'label' in metadata:
svg.node(node, 'desc', class_='label').text = to_unicode(
metadata['label'])
return node
def alter(node, metadata):
"""Override nodes attributes from metadata node mapping"""
if node is not None and metadata and 'node' in metadata:
node.attrib.update(
dict((k, str(v)) for k, v in metadata['node'].items()))
def truncate(string, index):
"""Truncate a string at index and add ..."""
if len(string) > index and index > 0:
string = string[:index - 1] + u('')
return string
# # Stolen partly from brownie http://packages.python.org/Brownie/
class cached_property(object):
"""Memoize a property"""
def __init__(self, getter, doc=None):
"""Initialize the decorator"""
self.getter = getter
self.__module__ = getter.__module__
self.__name__ = getter.__name__
self.__doc__ = doc or getter.__doc__
def __get__(self, obj, type_=None):
"""
Get descriptor calling the property function and replacing it with
its value or on state if we are in the transient state.
"""
if obj is None:
return self
value = self.getter(obj)
if hasattr(obj, 'state'):
setattr(obj.state, self.__name__, value)
else:
obj.__dict__[self.__name__] = self.getter(obj)
return value
css_comments = re.compile(r'/\*.*?\*/', re.MULTILINE | re.DOTALL)
def minify_css(css):
"""Little css minifier"""
# Inspired by slimmer by Peter Bengtsson
remove_next_comment = 1
for css_comment in css_comments.findall(css):
if css_comment[-3:] == '\*/':
remove_next_comment = 0
continue
if remove_next_comment:
css = css.replace(css_comment, '')
else:
remove_next_comment = 1
# >= 2 whitespace becomes one whitespace
css = re.sub(r'\s\s+', ' ', css)
# no whitespace before end of line
css = re.sub(r'\s+\n', '', css)
# Remove space before and after certain chars
for char in ('{', '}', ':', ';', ','):
css = re.sub(char + r'\s', char, css)
css = re.sub(r'\s' + char, char, css)
css = re.sub(r'}\s(#|\w)', r'}\1', css)
# no need for the ; before end of attributes
css = re.sub(r';}', r'}', css)
css = re.sub(r'}//-->', r'}\n//-->', css)
return css.strip()
def compose(f, g):
"""Chain functions"""
fun = lambda *args, **kwargs: f(g(*args, **kwargs))
fun.__name__ = "%s o %s" % (f.__name__, g.__name__)
return fun
def safe_enumerate(iterable):
"""Enumerate which does not yield None values"""
for i, v in enumerate(iterable):
if v is not None:
yield i, v
def split_title(title, width, title_fs):
"""Split a string for a specified width and font size"""
titles = []
if not title:
return titles
size = reverse_text_len(width, title_fs * 1.1)
title_lines = title.split("\n")
for title_line in title_lines:
while len(title_line) > size:
title_part = title_line[:size]
i = title_part.rfind(' ')
if i == -1:
i = len(title_part)
titles.append(title_part[:i])
title_line = title_line[i:].strip()
titles.append(title_line)
return titles
def filter_kwargs(fun, kwargs):
if not hasattr(fun, '__code__'):
return {}
args = fun.__code__.co_varnames[1:]
return dict((k, v) for k, v in kwargs.items() if k in args)
def coord_project(rho, alpha):
return rho * sin(-alpha), rho * cos(-alpha)
def coord_diff(x, y):
return (x[0] - y[0], x[1] - y[1])
def coord_format(x):
return '%f %f' % x
def coord_dual(r):
return coord_format((r, r))
def coord_abs_project(center, rho, theta):
return coord_format(coord_diff(center, coord_project(rho, theta)))
def mergextend(list1, list2):
if list1 is None or _ellipsis not in list1:
return list1
index = list1.index(_ellipsis)
return list(list1[:index]) + list(list2) + list(list1[index + 1:])
def merge(dict1, dict2):
from pygal.config import CONFIG_ITEMS, Key
_list_items = [item.name for item in CONFIG_ITEMS if item.type == list]
for key, val in dict2.items():
if isinstance(val, Key):
val = val.value
if key in _list_items:
dict1[key] = mergextend(val, dict1.get(key, ()))
else:
dict1[key] = val