diff --git a/CHANGELOG b/CHANGELOG index 6df8408..ebb6711 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,9 +1,30 @@ -TO BE RELEASED: V 1.4.0 +V 1.4.6 -- UNRELEASED + Add support for \n separated multiline titles (thanks sirlark) + +V 1.4.5 + Fix y_labels map iterator exhaustion in python 3 + +V 1.4.4 + Fix division by zero in spark text (thanks laserpony) + Fix config metaclass problem in python 3 + Fix --version in pygal_gen + +V 1.4.3 + Allow arbitrary number of x-labels on line plot (thanks nsmgr8) + +V 1.4.2 + Fix broken tests + +V 1.4.1 + Fix value formatting in maps + +V 1.4.0 Finally a changelog ! Hopefully fix weird major scale algorithm Add options to customize major labels (y_labels_major, y_labels_major_every, y_labels_major_count) Css can now be inline with the "inline:" prefix Visited links bug fixed + Add french maps by department and region (This will be externalized in an extension later) V 1.3.x Whisker Box Plot diff --git a/demo/moulinrouge/tests.py b/demo/moulinrouge/tests.py index fa5bbab..813a4f6 100644 --- a/demo/moulinrouge/tests.py +++ b/demo/moulinrouge/tests.py @@ -2,8 +2,11 @@ # This file is part of pygal from pygal import ( Bar, Gauge, Pyramid, Funnel, Dot, StackedBar, XY, - CHARTS_BY_NAME, Config, Line, DateY, Worldmap, Histogram, Box) + CHARTS_BY_NAME, Config, Line, DateY, Worldmap, Histogram, Box, + FrenchMap_Departments, FrenchMap_Regions) from pygal.style import styles +from pygal.graph.frenchmap import DEPARTMENTS, REGIONS +from random import randint, choice def get_test_routes(app): @@ -89,6 +92,15 @@ def get_test_routes(app): '1 12 123 1234 12345 123456 1234567 12345678 123456789 1234567890') return bar.render_response() + @app.route('/test/multiline_title') + def test_multiline_title(): + bar = Bar() + bar.add('Looooooooooooooooooooooooooooooooooong', [2, None, 12]) + bar.title = ( + 'First line \n Second line \n Third line' + ) + return bar.render_response() + @app.route('/test/long_labels') def test_long_labels(): bar = Bar() @@ -253,6 +265,16 @@ def get_test_routes(app): hist.add('2', [(2, 2, 8)]) return hist.render_response() + + @app.route('/test/ylabels') + def test_ylabels(): + chart = Line() + chart.x_labels = 'Red', 'Blue', 'Green' + chart.y_labels = .0001, .0003, .0004, .00045, .0005 + chart.add('line', [.0002, .0005, .00035]) + return chart.render_response() + + @app.route('/test/secondary/') def test_secondary_for(chart): chart = CHARTS_BY_NAME[chart](fill=True) @@ -329,14 +351,13 @@ def get_test_routes(app): @app.route('/test/worldmap') def test_worldmap(): - import random - map = Worldmap(style=random.choice(styles.values())) - - map.add('1st', [('fr', 100), ('us', 10)]) - map.add('2nd', [('jp', 1), ('ru', 7), ('uk', 0)]) - map.add('3rd', ['ch', 'cz', 'ca', 'cn']) - map.add('4th', {'br': 12, 'bo': 1, 'bu': 23, 'fr': 34}) - map.add('5th', [{ + wmap = Worldmap(style=choice(list(styles.values()))) + + wmap.add('1st', [('fr', 100), ('us', 10)]) + wmap.add('2nd', [('jp', 1), ('ru', 7), ('uk', 0)]) + wmap.add('3rd', ['ch', 'cz', 'ca', 'cn']) + wmap.add('4th', {'br': 12, 'bo': 1, 'bu': 23, 'fr': 34}) + wmap.add('5th', [{ 'value': ('tw', 10), 'label': 'First label', 'xlink': 'http://google.com?q=tw' @@ -348,8 +369,64 @@ def get_test_routes(app): 'value': ('mw', 40), 'label': 'Last' }]) - map.add('6th', [3, 5, 34, 12]) - map.title = 'World Map !!' - return map.render_response() + wmap.add('6th', [3, 5, 34, 12]) + wmap.title = 'World Map !!' + return wmap.render_response() + + @app.route('/test/frenchmapdepartments') + def test_frenchmapdepartments(): + fmap = FrenchMap_Departments(style=choice(list(styles.values()))) + for i in range(10): + fmap.add('s%d' % i, [ + (choice(list(DEPARTMENTS.keys())), randint(0, 100)) for _ in range(randint(1, 5))]) + + fmap.add('links', [{ + 'value': ('69', 10), + 'label': '\o/', + 'xlink': 'http://google.com?q=69' + }, { + 'value': ('42', 20), + 'label': 'Y', + }]) + fmap.add('6th', [3, 5, 34, 12]) + fmap.title = 'French map' + return fmap.render_response() + + @app.route('/test/frenchmapregions') + def test_frenchmapregions(): + fmap = FrenchMap_Regions(style=choice(list(styles.values()))) + for i in range(10): + fmap.add('s%d' % i, [ + (choice(list(REGIONS.keys())), randint(0, 100)) + for _ in range(randint(1, 5))]) + + fmap.add('links', [{ + 'value': ('02', 10), + 'label': '\o/', + 'xlink': 'http://google.com?q=69' + }, { + 'value': ('72', 20), + 'label': 'Y', + }]) + fmap.add('6th', [91, 2, 41]) + fmap.title = 'French map' + return fmap.render_response() + + @app.route('/test/labels') + def test_labels(): + line = Line() + line.add('test1', range(100)) + line.x_labels = map(str, range(11)) + return line.render_response() + + @app.route('/test/major_dots') + def test_major_dots(): + line = Line(x_labels_major_every=3, show_only_major_dots=True) + line.add('test', range(12)) + line.x_labels = [ + 'lol', 'lol1', 'lol2', 'lol3', 'lol4', 'lol5', + 'lol6', 'lol7', 'lol8', 'lol9', 'lol10', 'lol11'] + line.x_labels_major = ['lol3'] + return line.render_response() return filter(lambda x: x.startswith('test'), locals()) diff --git a/pygal/__init__.py b/pygal/__init__.py index 45803ec..c37b512 100644 --- a/pygal/__init__.py +++ b/pygal/__init__.py @@ -21,20 +21,20 @@ Pygal - A python svg graph plotting library """ -__version__ = '1.3.1' +__version__ = '1.4.6' import sys from pygal.config import Config -from pygal.ghost import Ghost -from pygal.graph import CHARTS_NAMES +from pygal.ghost import Ghost, REAL_CHARTS CHARTS = [] CHARTS_BY_NAME = {} -for NAME in CHARTS_NAMES: +for NAME in REAL_CHARTS.keys(): _CHART = type(NAME, (Ghost,), {}) CHARTS.append(_CHART) CHARTS_BY_NAME[NAME] = _CHART setattr(sys.modules[__name__], NAME, _CHART) -__all__ = CHARTS_NAMES + [Config.__name__, 'CHARTS', 'CHARTS_BY_NAME'] +__all__ = list(CHARTS_BY_NAME.keys()) + [ + Config.__name__, 'CHARTS', 'CHARTS_BY_NAME'] diff --git a/pygal/config.py b/pygal/config.py index d17f337..08f23c9 100644 --- a/pygal/config.py +++ b/pygal/config.py @@ -101,11 +101,9 @@ class MetaConfig(type): return type.__new__(mcs, classname, bases, classdict) -class Config(object): +class Config(MetaConfig('ConfigBase', (object,), {})): """Class holding config values""" - __metaclass__ = MetaConfig - style = Key( DefaultStyle, Style, "Style", "Style holding values injected in css") @@ -142,6 +140,10 @@ class Config(object): show_dots = Key(True, bool, "Look", "Set to false to remove dots") + show_only_major_dots = Key( + False, bool, "Look", + "Set to true to show only major dots according to their majored label") + dots_size = Key(2.5, float, "Look", "Radius of the dots") stroke = Key( @@ -230,7 +232,8 @@ class Config(object): 0, int, "Label", "Specify y labels rotation angles", "in degrees") x_label_format = Key( - "%Y-%m-%d %H:%M:%S.%f", str, "Label", "Date format for strftime to display the DateY X labels") + "%Y-%m-%d %H:%M:%S.%f", str, "Label", + "Date format for strftime to display the DateY X labels") ############ Value ############ human_readable = Key( diff --git a/pygal/css/style.css b/pygal/css/style.css index 35fa8e9..bc8f31e 100644 --- a/pygal/css/style.css +++ b/pygal/css/style.css @@ -113,9 +113,9 @@ fill: {{ style.foreground_light }}; } -{{ id }}.country { +{{ id }}.map-element { fill: {{ style.foreground }}; - stroke: {{ style.plot_background }} !important; + stroke: {{ style.foreground_dark }} !important; opacity: .9; stroke-width: 3; -webkit-transition: 250ms; @@ -124,7 +124,7 @@ transition: 250ms; } -{{ id }}.country:hover { +{{ id }}.map-element:hover { opacity: 1; stroke-width: 10; } diff --git a/pygal/ghost.py b/pygal/ghost.py index 635a19e..abd9dee 100644 --- a/pygal/ghost.py +++ b/pygal/ghost.py @@ -33,12 +33,23 @@ from pygal.util import prepare_values from uuid import uuid4 +class ChartCollection(object): + pass + + REAL_CHARTS = {} for NAME in CHARTS_NAMES: mod_name = 'pygal.graph.%s' % NAME.lower() __import__(mod_name) mod = sys.modules[mod_name] - REAL_CHARTS[NAME] = getattr(mod, NAME) + chart = getattr(mod, NAME) + if issubclass(chart, ChartCollection): + for name, chart in chart.__dict__.items(): + if name.startswith('_'): + continue + REAL_CHARTS['%s_%s' % (NAME, name)] = chart + else: + REAL_CHARTS[NAME] = chart class Ghost(object): @@ -135,6 +146,11 @@ class Ghost(object): vmax = max(values) if relative_to is None: relative_to = min(values) + + if (vmax - relative_to) == 0: + chart = bars[0] * len(values) + return chart + divisions = len(bars) - 1 for value in values: chart += bars[int(divisions * diff --git a/pygal/graph/__init__.py b/pygal/graph/__init__.py index 500e698..77ae278 100644 --- a/pygal/graph/__init__.py +++ b/pygal/graph/__init__.py @@ -40,5 +40,6 @@ CHARTS_NAMES = [ 'Worldmap', 'SupranationalWorldmap', 'Histogram', - 'Box' + 'Box', + 'FrenchMap' ] diff --git a/pygal/graph/box.py b/pygal/graph/box.py index dae5152..de928e0 100644 --- a/pygal/graph/box.py +++ b/pygal/graph/box.py @@ -74,7 +74,7 @@ class Box(Graph): y_pos = compute_scale( 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 list(map(float, self.y_labels)) self._x_labels = self.x_labels and list(zip(self.x_labels, [ (i + .5) / self._order for i in range(self._order)])) diff --git a/pygal/graph/fr.departments.svg b/pygal/graph/fr.departments.svg new file mode 100644 index 0000000..8b386f4 --- /dev/null +++ b/pygal/graph/fr.departments.svg @@ -0,0 +1,328 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pygal/graph/fr.regions.svg b/pygal/graph/fr.regions.svg new file mode 100644 index 0000000..1465de0 --- /dev/null +++ b/pygal/graph/fr.regions.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pygal/graph/frenchmap.py b/pygal/graph/frenchmap.py new file mode 100644 index 0000000..951c86d --- /dev/null +++ b/pygal/graph/frenchmap.py @@ -0,0 +1,387 @@ +# -*- coding: utf-8 -*- +# This file is part of pygal +# +# A python svg graph plotting library +# Copyright © 2012-2014 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 . +""" +Worldmap chart + +""" + +from __future__ import division +from collections import defaultdict +from pygal.ghost import ChartCollection +from pygal.util import cut, cached_property, decorate +from pygal.graph.graph import Graph +from pygal._compat import u +from numbers import Number +from lxml import etree +import os + + +DEPARTMENTS = { + '01': u("Ain"), + '02': u("Aisne"), + '03': u("Allier"), + '04': u("Alpes-de-Haute-Provence"), + '05': u("Hautes-Alpes"), + '06': u("Alpes-Maritimes"), + '07': u("Ardèche"), + '08': u("Ardennes"), + '09': u("Ariège"), + '10': u("Aube"), + '11': u("Aude"), + '12': u("Aveyron"), + '13': u("Bouches-du-Rhône"), + '14': u("Calvados"), + '15': u("Cantal"), + '16': u("Charente"), + '17': u("Charente-Maritime"), + '18': u("Cher"), + '19': u("Corrèze"), + '2A': u("Corse-du-Sud"), + '2B': u("Haute-Corse"), + '21': u("Côte-d'Or"), + '22': u("Côtes-d'Armor"), + '23': u("Creuse"), + '24': u("Dordogne"), + '25': u("Doubs"), + '26': u("Drôme"), + '27': u("Eure"), + '28': u("Eure-et-Loir"), + '29': u("Finistère"), + '30': u("Gard"), + '31': u("Haute-Garonne"), + '32': u("Gers"), + '33': u("Gironde"), + '34': u("Hérault"), + '35': u("Ille-et-Vilaine"), + '36': u("Indre"), + '37': u("Indre-et-Loire"), + '38': u("Isère"), + '39': u("Jura"), + '40': u("Landes"), + '41': u("Loir-et-Cher"), + '42': u("Loire"), + '43': u("Haute-Loire"), + '44': u("Loire-Atlantique"), + '45': u("Loiret"), + '46': u("Lot"), + '47': u("Lot-et-Garonne"), + '48': u("Lozère"), + '49': u("Maine-et-Loire"), + '50': u("Manche"), + '51': u("Marne"), + '52': u("Haute-Marne"), + '53': u("Mayenne"), + '54': u("Meurthe-et-Moselle"), + '55': u("Meuse"), + '56': u("Morbihan"), + '57': u("Moselle"), + '58': u("Nièvre"), + '59': u("Nord"), + '60': u("Oise"), + '61': u("Orne"), + '62': u("Pas-de-Calais"), + '63': u("Puy-de-Dôme"), + '64': u("Pyrénées-Atlantiques"), + '65': u("Hautes-Pyrénées"), + '66': u("Pyrénées-Orientales"), + '67': u("Bas-Rhin"), + '68': u("Haut-Rhin"), + '69': u("Rhône"), + '70': u("Haute-Saône"), + '71': u("Saône-et-Loire"), + '72': u("Sarthe"), + '73': u("Savoie"), + '74': u("Haute-Savoie"), + '75': u("Paris"), + '76': u("Seine-Maritime"), + '77': u("Seine-et-Marne"), + '78': u("Yvelines"), + '79': u("Deux-Sèvres"), + '80': u("Somme"), + '81': u("Tarn"), + '82': u("Tarn-et-Garonne"), + '83': u("Var"), + '84': u("Vaucluse"), + '85': u("Vendée"), + '86': u("Vienne"), + '87': u("Haute-Vienne"), + '88': u("Vosges"), + '89': u("Yonne"), + '90': u("Territoire de Belfort"), + '91': u("Essonne"), + '92': u("Hauts-de-Seine"), + '93': u("Seine-Saint-Denis"), + '94': u("Val-de-Marne"), + '95': u("Val-d'Oise"), + '971': u("Guadeloupe"), + '972': u("Martinique"), + '973': u("Guyane"), + '974': u("Réunion"), + # Not a area anymore but in case of... + '975': u("Saint Pierre et Miquelon"), + '976': u("Mayotte") +} + + +REGIONS = { + '11': u("Île-de-France"), + '21': u("Champagne-Ardenne"), + '22': u("Picardie"), + '23': u("Haute-Normandie"), + '24': u("Centre"), + '25': u("Basse-Normandie"), + '26': u("Bourgogne"), + '31': u("Nord-Pas-de-Calais"), + '41': u("Lorraine"), + '42': u("Alsace"), + '43': u("Franche-Comté"), + '52': u("Pays-de-la-Loire"), + '53': u("Bretagne"), + '54': u("Poitou-Charentes"), + '72': u("Aquitaine"), + '73': u("Midi-Pyrénées"), + '74': u("Limousin"), + '82': u("Rhône-Alpes"), + '83': u("Auvergne"), + '91': u("Languedoc-Roussillon"), + '93': u("Provence-Alpes-Côte d'Azur"), + '94': u("Corse"), + '01': u("Guadeloupe"), + '02': u("Martinique"), + '03': u("Guyane"), + '04': u("Réunion"), + # Not a region anymore but in case of... + '05': u("Saint Pierre et Miquelon"), + '06': u("Mayotte") +} + + +with open(os.path.join( + os.path.dirname(__file__), + 'fr.departments.svg')) as file: + DPT_MAP = file.read() + + +with open(os.path.join( + os.path.dirname(__file__), + 'fr.regions.svg')) as file: + REG_MAP = file.read() + + +class FrenchMapDepartments(Graph): + """French department map""" + _dual = True + x_labels = list(DEPARTMENTS.keys()) + area_names = DEPARTMENTS + area_prefix = 'z' + svg_map = DPT_MAP + + + @cached_property + def _values(self): + """Getter for series values (flattened)""" + return [val[1] + for serie in self.series + for val in serie.values + if val[1] is not None] + + def _plot(self): + map = etree.fromstring(self.svg_map) + map.set('width', str(self.view.width)) + map.set('height', str(self.view.height)) + + for i, serie in enumerate(self.series): + safe_vals = list(filter( + lambda x: x is not None, cut(serie.values, 1))) + if not safe_vals: + continue + min_ = min(safe_vals) + max_ = max(safe_vals) + for j, (area_code, value) in enumerate(serie.values): + if isinstance(area_code, Number): + area_code = '%2d' % area_code + if value is None: + continue + if max_ == min_: + ratio = 1 + else: + ratio = .3 + .7 * (value - min_) / (max_ - min_) + areae = map.xpath( + "//*[contains(concat(' ', normalize-space(@class), ' ')," + " ' %s%s ')]" % (self.area_prefix, area_code)) + + if not areae: + continue + for area in areae: + cls = area.get('class', '').split(' ') + cls.append('color-%d' % i) + area.set('class', ' '.join(cls)) + area.set('style', 'fill-opacity: %f' % (ratio)) + + metadata = serie.metadata.get(j) + if metadata: + parent = area.getparent() + node = decorate(self.svg, area, metadata) + if node != area: + area.remove(node) + index = parent.index(area) + parent.remove(area) + node.append(area) + parent.insert(index, node) + + last_node = len(area) > 0 and area[-1] + if last_node is not None and last_node.tag == 'title': + title_node = last_node + text = title_node.text + '\n' + else: + title_node = self.svg.node(area, 'title') + text = '' + title_node.text = text + '[%s] %s: %s' % ( + serie.title, + self.area_names[area_code], self._format(value)) + + self.nodes['plot'].append(map) + + +class FrenchMapRegions(FrenchMapDepartments): + """French regions map""" + x_labels = list(REGIONS.keys()) + area_names = REGIONS + area_prefix = 'a' + svg_map = REG_MAP + + +class FrenchMap(ChartCollection): + Regions = FrenchMapRegions + Departments = FrenchMapDepartments + + +DEPARTMENTS_REGIONS = { + "01": "82", + "02": "22", + "03": "83", + "04": "93", + "05": "93", + "06": "93", + "07": "82", + "08": "21", + "09": "73", + "10": "21", + "11": "91", + "12": "73", + "13": "93", + "14": "25", + "15": "83", + "16": "54", + "17": "54", + "18": "24", + "19": "74", + "21": "26", + "22": "53", + "23": "74", + "24": "72", + "25": "43", + "26": "82", + "27": "23", + "28": "24", + "29": "53", + "2A": "94", + "2B": "94", + "30": "91", + "31": "73", + "32": "73", + "33": "72", + "34": "91", + "35": "53", + "36": "24", + "37": "24", + "38": "82", + "39": "43", + "40": "72", + "41": "24", + "42": "82", + "43": "83", + "44": "52", + "45": "24", + "46": "73", + "47": "72", + "48": "91", + "49": "52", + "50": "25", + "51": "21", + "52": "21", + "53": "52", + "54": "41", + "55": "41", + "56": "53", + "57": "41", + "58": "26", + "59": "31", + "60": "22", + "61": "25", + "62": "31", + "63": "83", + "64": "72", + "65": "73", + "66": "91", + "67": "42", + "68": "42", + "69": "82", + "70": "43", + "71": "26", + "72": "52", + "73": "82", + "74": "82", + "75": "11", + "76": "23", + "77": "11", + "78": "11", + "79": "54", + "80": "22", + "81": "73", + "82": "73", + "83": "93", + "84": "93", + "85": "52", + "86": "54", + "87": "74", + "88": "41", + "89": "26", + "90": "43", + "91": "11", + "92": "11", + "93": "11", + "94": "11", + "95": "11", + "971": "01", + "972": "02", + "973": "03", + "974": "04", + "975": "05", + "976": "06" +} + + +def aggregate_regions(values): + if isinstance(values, dict): + values = values.items() + regions = defaultdict(int) + for department, value in values: + regions[DEPARTMENTS_REGIONS[department]] += value + return list(regions.items()) diff --git a/pygal/graph/funnel.py b/pygal/graph/funnel.py index 3f3c6ce..0fa6aba 100644 --- a/pygal/graph/funnel.py +++ b/pygal/graph/funnel.py @@ -85,7 +85,7 @@ class Funnel(Graph): y_pos = compute_scale( 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 list(map(float, self.y_labels)) self._x_labels = list( zip(cut(self.series, 'title'), diff --git a/pygal/graph/graph.py b/pygal/graph/graph.py index c3e1bb5..3bcdc0f 100644 --- a/pygal/graph/graph.py +++ b/pygal/graph/graph.py @@ -404,7 +404,7 @@ class Graph(BaseGraph): if self.title: for i, title_line in enumerate(self.title, 1): self.svg.node( - self.nodes['title'], 'text', class_='title', + self.nodes['title'], 'text', class_='title plot_title', x=self.width / 2, y=i * (self.title_font_size + self.spacing) ).text = title_line diff --git a/pygal/graph/line.py b/pygal/graph/line.py index ccf6dcc..bf34ef6 100644 --- a/pygal/graph/line.py +++ b/pygal/graph/line.py @@ -67,10 +67,30 @@ class Line(Graph): points = serie.points view_values = list(map(self.view, points)) if self.show_dots: + if self.show_only_major_dots: + major_dots_index = [] + if self.x_labels: + if self.x_labels_major: + major_dots_index = [] + for major in self.x_labels_major: + start = -1 + while True: + try: + index = self.x_labels.index( + major, start + 1) + except ValueError: + break + else: + major_dots_index.append(index) + start = index + elif self.x_labels_major_every: + major_dots_index = range( + 0, len(self.x_labels), self.x_labels_major_every) + for i, (x, y) in enumerate(view_values): - if None in (x, y): + if None in (x, y) or (self.show_only_major_dots + and i not in major_dots_index): continue - metadata = serie.metadata.get(i) classes = [] if x > self.view.width / 2: @@ -110,7 +130,14 @@ class Line(Graph): self._points(x_pos) if self.x_labels: - self._x_labels = list(zip(self.x_labels, x_pos)) + label_len = len(self.x_labels) + if label_len != self._len: + label_pos = [0.5] if label_len == 1 else [ + x / (label_len - 1) for x in range(label_len) + ] + self._x_labels = list(zip(self.x_labels, label_pos)) + else: + self._x_labels = list(zip(self.x_labels, x_pos)) else: self._x_labels = None @@ -124,7 +151,7 @@ class Line(Graph): y_pos = compute_scale( 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 list(map(float, self.y_labels)) self._y_labels = list(zip(map(self._format, y_pos), y_pos)) diff --git a/pygal/graph/radar.py b/pygal/graph/radar.py index 2c7a650..a0a7e42 100644 --- a/pygal/graph/radar.py +++ b/pygal/graph/radar.py @@ -182,7 +182,7 @@ class Radar(Line): y_pos = compute_scale( self._rmin, self._rmax, self.logarithmic, self.order_min, max_scale=8 - ) if not self.y_labels else map(int, self.y_labels) + ) if not self.y_labels else list(map(int, self.y_labels)) self._x_labels = self.x_labels and list(zip(self.x_labels, x_pos)) self._y_labels = list(zip(map(self._format, y_pos), y_pos)) diff --git a/pygal/graph/stackedbar.py b/pygal/graph/stackedbar.py index 14d20d3..8bd129c 100644 --- a/pygal/graph/stackedbar.py +++ b/pygal/graph/stackedbar.py @@ -68,7 +68,7 @@ class StackedBar(Bar): self._points(x_pos) y_pos = compute_scale( 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 list(map(float, self.y_labels)) self._x_ranges = zip(x_pos, x_pos[1:]) self._x_labels = self.x_labels and list(zip(self.x_labels, [ diff --git a/pygal/graph/worldmap.py b/pygal/graph/worldmap.py index 1ba17a4..8e144d6 100644 --- a/pygal/graph/worldmap.py +++ b/pygal/graph/worldmap.py @@ -102,8 +102,8 @@ class Worldmap(Graph): else: title_node = self.svg.node(country, 'title') text = '' - title_node.text = text + '[%s] %s: %d' % ( + title_node.text = text + '[%s] %s: %s' % ( serie.title, - self.country_names[country_code], value) + self.country_names[country_code], self._format(value)) self.nodes['plot'].append(map) diff --git a/pygal/graph/worldmap.svg b/pygal/graph/worldmap.svg index 1b1f73f..0adfc9b 100644 --- a/pygal/graph/worldmap.svg +++ b/pygal/graph/worldmap.svg @@ -1,24 +1,24 @@ - diff --git a/pygal/i18n.py b/pygal/i18n.py index 3b0b714..181ab48 100644 --- a/pygal/i18n.py +++ b/pygal/i18n.py @@ -202,6 +202,7 @@ NAFTA = ['ca', 'mx', 'us'] SUPRANATIONAL = {'europe': EUROPE, 'oecd': OECD, 'nafta': NAFTA, 'eur': EUR} -def set_countries(countries): - global COUNTRIES - COUNTRIES = countries +def set_countries(countries, clear=False): + if clear: + COUNTRIES.clear() + COUNTRIES.update(countries) diff --git a/pygal/test/__init__.py b/pygal/test/__init__.py index e0d7c6e..da94e4a 100644 --- a/pygal/test/__init__.py +++ b/pygal/test/__init__.py @@ -21,7 +21,7 @@ import pygal from pygal.util import cut from datetime import datetime from pygal.i18n import COUNTRIES -COUNTRY_KEYS = list(COUNTRIES.keys()) +from pygal.graph.frenchmap import DEPARTMENTS, REGIONS def get_data(i): @@ -58,8 +58,14 @@ def adapt(chart, data): data = cut(data) if isinstance(chart, pygal.Worldmap): - return list(map(lambda x: COUNTRY_KEYS[x % len(COUNTRIES)] + return list(map(lambda x: list(COUNTRIES.keys())[x % len(COUNTRIES)] if x is not None else None, data)) + elif isinstance(chart, pygal.FrenchMap_Regions): + return list(map(lambda x: list(REGIONS.keys())[x % len(REGIONS)] + if x is not None else None, data)) + elif isinstance(chart, pygal.FrenchMap_Departments): + return list(map(lambda x: list(DEPARTMENTS.keys())[x % len(DEPARTMENTS)] + if x is not None else None, data)) return data diff --git a/pygal/test/test_config.py b/pygal/test/test_config.py index bf0f29c..6510cae 100644 --- a/pygal/test/test_config.py +++ b/pygal/test/test_config.py @@ -18,10 +18,12 @@ # along with pygal. If not, see . from pygal import ( Line, Dot, Pie, Radar, Config, Bar, Funnel, Worldmap, - SupranationalWorldmap, Histogram, Gauge, Box) + SupranationalWorldmap, Histogram, Gauge, Box, + FrenchMap_Regions, FrenchMap_Departments) from pygal._compat import u from pygal.test.utils import texts from pygal.test import pytest_generate_tests, make_data +from uuid import uuid4 def test_config_behaviours(): @@ -270,7 +272,8 @@ def test_no_data(): def test_include_x_axis(Chart): chart = Chart() if Chart in (Pie, Radar, Funnel, Dot, Gauge, Worldmap, - SupranationalWorldmap, Histogram, Box): + SupranationalWorldmap, Histogram, Box, + FrenchMap_Regions, FrenchMap_Departments): return if not chart.cls._dual: data = 100, 200, 150 @@ -292,7 +295,7 @@ def test_include_x_axis(Chart): def test_css(Chart): css = "{{ id }}text { fill: #bedead; }\n" - css_file = '/tmp/pygal_custom_style.css' + css_file = '/tmp/pygal_custom_style-%s.css' % uuid4() with open(css_file, 'w') as f: f.write(css) @@ -314,3 +317,8 @@ def test_inline_css(Chart): chart.add('/', [10, 1, 5]) svg = chart.render().decode('utf-8') assert '#bedead' in svg + + +def test_meta_config(): + from pygal.config import CONFIG_ITEMS + assert all(c.name != 'Unbound' for c in CONFIG_ITEMS) diff --git a/pygal/test/test_graph.py b/pygal/test/test_graph.py index 9ba8da0..dbefd1f 100644 --- a/pygal/test/test_graph.py +++ b/pygal/test/test_graph.py @@ -21,6 +21,7 @@ import pygal import uuid import sys from pygal import i18n +from pygal.graph.frenchmap import DEPARTMENTS, REGIONS from pygal.util import cut from pygal._compat import u from pygal.test import pytest_generate_tests, make_data @@ -73,7 +74,11 @@ def test_metadata(Chart): elif Chart == pygal.XY: v = list(map(lambda x: (x, x + 1), v)) elif Chart == pygal.Worldmap or Chart == pygal.SupranationalWorldmap: - v = list(map(lambda x: x, i18n.COUNTRIES)) + v = [(i, k) for k, i in enumerate(i18n.COUNTRIES.keys())] + elif Chart == pygal.FrenchMap_Regions: + v = [(i, k) for k, i in enumerate(REGIONS.keys())] + elif Chart == pygal.FrenchMap_Departments: + v = [(i, k) for k, i in enumerate(DEPARTMENTS.keys())] chart.add('Serie with metadata', [ v[0], @@ -96,8 +101,10 @@ def test_metadata(Chart): if Chart == pygal.Pie: # Slices with value 0 are not rendered assert len(v) - 1 == len(q('.tooltip-trigger').siblings('.value')) - elif Chart != pygal.Worldmap and Chart != pygal.SupranationalWorldmap: - # Tooltip are not working on worldmap + elif Chart not in ( + pygal.Worldmap, pygal.SupranationalWorldmap, + pygal.FrenchMap_Regions, pygal.FrenchMap_Departments): + # Tooltip are not working on maps assert len(v) == len(q('.tooltip-trigger').siblings('.value')) @@ -152,7 +159,10 @@ def test_values_by_dict(Chart): chart1 = Chart(no_prefix=True) chart2 = Chart(no_prefix=True) - if not issubclass(Chart, pygal.Worldmap): + if not issubclass(Chart, ( + pygal.Worldmap, + pygal.FrenchMap_Departments, + pygal.FrenchMap_Regions)): chart1.add('A', {'red': 10, 'green': 12, 'blue': 14}) chart1.add('B', {'green': 11, 'red': 7}) chart1.add('C', {'blue': 7}) @@ -335,7 +345,9 @@ def test_labels_with_links(Chart): q = chart.render_pyquery() links = q('a') - if issubclass(chart.cls, pygal.graph.worldmap.Worldmap): + if issubclass(chart.cls, + (pygal.graph.worldmap.Worldmap, + pygal.graph.frenchmap.FrenchMapDepartments)): # No country is found in this case so: assert len(links) == 4 # 3 links and 1 tooltip else: diff --git a/pygal/test/test_line.py b/pygal/test/test_line.py index a82e023..b673f14 100644 --- a/pygal/test/test_line.py +++ b/pygal/test/test_line.py @@ -81,3 +81,22 @@ def test_no_dot(): def test_no_dot_at_all(): q = Line().render_pyquery() assert q(".text-overlay text").text() == 'No data' + + +def test_not_equal_x_labels(): + line = Line() + line.add('test1', range(100)) + line.x_labels = map(str, range(11)) + q = line.render_pyquery() + assert len(q(".dots")) == 100 + assert len(q(".axis.x")) == 1 + assert q(".axis.x text").map(texts) == ['0', '1', '2', '3', '4', '5', '6', + '7', '8', '9', '10'] + + +def test_only_major_dots(): + line = Line(show_only_major_dots=True, x_labels_major_every=3) + line.add('test', range(12)) + line.x_labels = map(str, range(12)) + q = line.render_pyquery() + assert len(q(".dots")) == 4 diff --git a/pygal/test/test_sparktext.py b/pygal/test/test_sparktext.py index 5240da7..fd18e00 100644 --- a/pygal/test/test_sparktext.py +++ b/pygal/test/test_sparktext.py @@ -60,3 +60,13 @@ def test_negative_and_float_and_no_data_sparktext(): chart3 = Line() assert chart3.render_sparktext() == u('') + + +def test_same_max_and_relative_values_sparktext(): + chart = Line() + chart.add('_', [0, 0, 0, 0, 0]) + assert chart.render_sparktext() == u('▁▁▁▁▁') + + chart2 = Line() + chart2.add('_', [1, 1, 1, 1, 1]) + assert chart2.render_sparktext(relative_to=1) == u('▁▁▁▁▁') diff --git a/pygal/util.py b/pygal/util.py index 0b4f48d..a0608a9 100644 --- a/pygal/util.py +++ b/pygal/util.py @@ -326,9 +326,10 @@ def prepare_values(raw, config, cls): from pygal.graph.datey import DateY from pygal.graph.histogram import Histogram from pygal.graph.worldmap import Worldmap + from pygal.graph.frenchmap import FrenchMapDepartments if config.x_labels is None and hasattr(cls, 'x_labels'): config.x_labels = cls.x_labels - if config.zero == 0 and issubclass(cls, Worldmap): + if config.zero == 0 and issubclass(cls, (Worldmap, FrenchMapDepartments)): config.zero = 1 for key in ('x_labels', 'y_labels'): @@ -358,7 +359,7 @@ def prepare_values(raw, config, cls): metadata = {} values = [] if isinstance(raw_values, dict): - if issubclass(cls, Worldmap): + if issubclass(cls, (Worldmap, FrenchMapDepartments)): raw_values = list(raw_values.items()) else: value_list = [None] * width @@ -390,7 +391,8 @@ def prepare_values(raw, config, cls): value = (None, None) elif not is_list_like(value): value = (value, config.zero) - if issubclass(cls, DateY) or issubclass(cls, Worldmap): + if issubclass(cls, DateY) or issubclass( + cls, (Worldmap, FrenchMapDepartments)): value = (adapter(value[0]), value[1]) else: value = list(map(adapter, value)) @@ -406,13 +408,14 @@ def split_title(title, width, title_fs): if not title: return titles size = reverse_text_len(width, title_fs * 1.1) - title = title.strip() - while len(title) > size: - title_part = title[:size] - i = title_part.rfind(' ') - if i == -1: - i = len(title_part) - titles.append(title_part[:i]) - title = title[i:].strip() - titles.append(title) + 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 diff --git a/pygal_gen.py b/pygal_gen.py index 981cd87..9374b6f 100755 --- a/pygal_gen.py +++ b/pygal_gen.py @@ -22,8 +22,7 @@ import pygal parser = argparse.ArgumentParser( description='Generate pygal chart in command line', - prog='pygal_gen', - version=pygal.__version__) + prog='pygal_gen') parser.add_argument('-t', '--type', dest='type', default='Line', choices=map(lambda x: x.__name__, pygal.CHARTS), @@ -35,6 +34,9 @@ parser.add_argument('-o', '--output', dest='filename', default='pygal_out.svg', parser.add_argument('-s', '--serie', dest='series', nargs='+', action='append', help='Add a serie in the form (title val1 val2...)') +parser.add_argument('--version', action='version', + version='pygal %s' % pygal.__version__) + for key in pygal.config.CONFIG_ITEMS: opt_name = key.name val = key.value diff --git a/setup.py b/setup.py index 1bd88ef..83ffa2d 100644 --- a/setup.py +++ b/setup.py @@ -64,7 +64,7 @@ setup( "svg", "chart", "graph", "diagram", "plot", "histogram", "kiviat"], tests_require=["pytest", "pyquery", "flask", "cairosvg"], cmdclass={'test': PyTest}, - package_data={'pygal': ['css/*', 'graph/worldmap.svg']}, + package_data={'pygal': ['css/*', 'graph/*.svg']}, install_requires=['lxml'], classifiers=[ "Development Status :: 4 - Beta",