diff --git a/demo/moulinrouge/tests.py b/demo/moulinrouge/tests.py index d89c238..6f04344 100644 --- a/demo/moulinrouge/tests.py +++ b/demo/moulinrouge/tests.py @@ -2,11 +2,14 @@ # This file is part of pygal from pygal import ( Bar, Gauge, Pyramid, Funnel, Dot, StackedBar, StackedLine, XY, - CHARTS_BY_NAME, Config, Line, Worldmap, Histogram, Box, + CHARTS_BY_NAME, Config, Line, Worldmap, Histogram, Box, SwissMapCantons, FrenchMapDepartments, FrenchMapRegions, Pie, Treemap, TimeLine, DateLine) + + from pygal.style import styles, Style, RotateStyle from pygal.colors import rotate from pygal.graph.frenchmap import DEPARTMENTS, REGIONS +from pygal.graph.swissmap import CANTONS from random import randint, choice from datetime import datetime @@ -374,7 +377,6 @@ def get_test_routes(app): @app.route('/test/dateline') def test_dateline(): - from datetime import date datey = DateLine(show_dots=False) datey.add('1', [ (datetime(2013, 1, 2), 300), @@ -443,6 +445,26 @@ def get_test_routes(app): fmap.title = 'French map' return fmap.render_response() + @app.route('/test/swissmap') + def test_swissmap(): + smap = SwissMap_Cantons(style=choice(list(styles.values()))) + for i in range(10): + smap.add('s%d' % i, [ + (choice(list(CANTONS.keys())), randint(0, 100)) + for _ in range(randint(1, 5))]) + + smap.add('links', [{ + 'value': ('kt-vs', 10), + 'label': '\o/', + 'xlink': 'http://google.com?q=69' + }, { + 'value': ('bt', 20), + 'label': 'Y', + }]) + smap.add('6th', [3, 5, 34, 12]) + smap.title = 'Swiss map' + return smap.render_response() + @app.route('/test/frenchmapregions') def test_frenchmapregions(): fmap = FrenchMapRegions(style=choice(list(styles.values()))) @@ -591,4 +613,10 @@ def get_test_routes(app): graph.legend_at_bottom = True return graph.render_response() + @app.route('/test/inverse_y_axis/') + def test_inverse_y_axis_for(chart): + graph = CHARTS_BY_NAME[chart](**dict(inverse_y_axis=True)) + graph.add('inverse', [1, 2, 3, 12, 24, 36]) + return graph.render_response() + return list(sorted(filter(lambda x: x.startswith('test'), locals()))) diff --git a/pygal/__init__.py b/pygal/__init__.py index f37110e..f07cf30 100644 --- a/pygal/__init__.py +++ b/pygal/__init__.py @@ -27,6 +27,7 @@ from pygal.graph.bar import Bar from pygal.graph.box import Box from pygal.graph.dot import Dot from pygal.graph.frenchmap import FrenchMapDepartments, FrenchMapRegions +from pygal.graph.swissmap import SwissMapCantons from pygal.graph.funnel import Funnel from pygal.graph.gauge import Gauge from pygal.graph.histogram import Histogram diff --git a/pygal/config.py b/pygal/config.py index 9713bf3..a89239c 100644 --- a/pygal/config.py +++ b/pygal/config.py @@ -427,6 +427,8 @@ class Config(CommonConfig): False, bool, "Misc", "Don't prefix css") + inverse_y_axis = Key(False, bool, "Misc", "Inverse Y axis direction") + class SerieConfig(CommonConfig): """Class holding serie config values""" diff --git a/pygal/graph/base.py b/pygal/graph/base.py index 3f873ae..5850c45 100644 --- a/pygal/graph/base.py +++ b/pygal/graph/base.py @@ -86,9 +86,11 @@ class BaseGraph(object): def prepare_values(self, raw, offset=0): """Prepare the values to start with sane values""" - from pygal import Worldmap, FrenchMapDepartments, Histogram + from pygal import ( + Worldmap, FrenchMapDepartments, Histogram, SwissMapCantons) + # TODO: Generalize these conditions if self.zero == 0 and isinstance( - self, (Worldmap, FrenchMapDepartments)): + self, (Worldmap, FrenchMapDepartments, SwissMapCantons)): self.zero = 1 for key in ('x_labels', 'y_labels'): @@ -124,7 +126,8 @@ class BaseGraph(object): metadata = {} values = [] if isinstance(raw_values, dict): - if isinstance(self, (Worldmap, FrenchMapDepartments)): + if isinstance(self, ( + Worldmap, FrenchMapDepartments, SwissMapCantons)): raw_values = list(raw_values.items()) else: value_list = [None] * width @@ -159,7 +162,9 @@ class BaseGraph(object): if x_adapter: value = (x_adapter(value[0]), adapter(value[1])) if isinstance( - self, (Worldmap, FrenchMapDepartments)): + self, ( + Worldmap, FrenchMapDepartments, + SwissMapCantons)): value = (adapter(value[0]), value[1]) else: value = list(map(adapter, value)) diff --git a/pygal/graph/ch.cantons.svg b/pygal/graph/ch.cantons.svg new file mode 100644 index 0000000..62ab954 --- /dev/null +++ b/pygal/graph/ch.cantons.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pygal/graph/graph.py b/pygal/graph/graph.py index 4744331..1967375 100644 --- a/pygal/graph/graph.py +++ b/pygal/graph/graph.py @@ -24,7 +24,7 @@ Commmon graphing functions from __future__ import division from pygal.interpolate import INTERPOLATIONS from pygal.graph.base import BaseGraph -from pygal.view import View, LogView, XYLogView +from pygal.view import View, LogView, XYLogView, ReverseView from pygal.util import ( cached_property, majorize, humanize, split_title, truncate, reverse_text_len, get_text_box, get_texts_box, cut, rad, @@ -60,7 +60,7 @@ class Graph(BaseGraph): else: view_class = LogView else: - view_class = View + view_class = ReverseView if self.inverse_y_axis else View self.view = view_class( self.width - self.margin_box.x, @@ -213,7 +213,9 @@ class Graph(BaseGraph): self.show_y_guides): self.svg.node( axis, 'path', - d='M%f %f h%f' % (0, self.view.height, self.view.width), + d='M%f %f h%f' % ( + 0, 0 if self.inverse_y_axis else self.view.height, + self.view.width), class_='line' ) diff --git a/pygal/graph/swissmap.py b/pygal/graph/swissmap.py new file mode 100644 index 0000000..189a83b --- /dev/null +++ b/pygal/graph/swissmap.py @@ -0,0 +1,162 @@ +# -*- 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 pygal.util import cut, cached_property, decorate +from pygal.graph.graph import Graph +from pygal._compat import u +from pygal.etree import etree +from numbers import Number +import os + + +CANTONS = { + 'kt-zh': u("Zürich"), + 'kt-be': u("Bern"), + 'kt-lu': u("Luzern"), + 'kt-ju': u("Jura"), + 'kt-ur': u("Uri"), + 'kt-sz': u("Schwyz"), + 'kt-ow': u("Obwalden"), + 'kt-nw': u("Nidwalden"), + 'kt-gl': u("Glarus"), + 'kt-zg': u("Zug"), + 'kt-fr': u("Freiburg"), + 'kt-so': u("Solothurn"), + 'kt-bl': u("Basel-Stadt"), + 'kt-bs': u("Basle-Land"), + 'kt-sh': u("Schaffhausen"), + 'kt-ar': u("Appenzell Ausseroden"), + 'kt-ai': u("Appenzell Innerroden"), + 'kt-sg': u("St. Gallen"), + 'kt-gr': u("Graubünden"), + 'kt-ag': u("Aargau"), + 'kt-tg': u("Thurgau"), + 'kt-ti': u("Tessin"), + 'kt-vd': u("Waadt"), + 'kt-vs': u("Wallis"), + 'kt-ne': u("Neuenburg"), + 'kt-ge': u("Genf"), +} + + +with open(os.path.join( + os.path.dirname(__file__), + 'ch.cantons.svg')) as file: + CNT_MAP = file.read() + + +class SwissMapCantons(Graph): + """Swiss Cantons map""" + _dual = True + x_labels = list(CANTONS.keys()) + area_names = CANTONS + area_prefix = 'z' + kind = 'canton' + svg_map = CNT_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_) + try: + areae = map.findall( + ".//*[@class='%s%s %s map-element']" % ( + self.area_prefix, area_code, + self.kind)) + except SyntaxError: + # Python 2.6 (you'd better install lxml) + areae = [] + for g in map: + for e in g: + if '%s%s' % ( + self.area_prefix, area_code + ) in e.attrib.get('class', ''): + areae.append(e) + + 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: + node = decorate(self.svg, area, metadata) + if node != area: + area.remove(node) + for g in map: + if area not in g: + continue + index = list(g).index(area) + g.remove(area) + node.append(area) + g.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 SwissMapCantons(SwissMapCantons): + """French regions map""" + x_labels = list(CANTONS.keys()) + area_names = CANTONS + area_prefix = 'z' + svg_map = CNT_MAP + kind = 'canton' diff --git a/pygal/test/test_config.py b/pygal/test/test_config.py index 422f4ab..9a3f935 100644 --- a/pygal/test/test_config.py +++ b/pygal/test/test_config.py @@ -21,7 +21,7 @@ from pygal import ( Line, Dot, Pie, Treemap, Radar, Config, Bar, Funnel, Worldmap, SupranationalWorldmap, Histogram, Gauge, Box, XY, Pyramid, HorizontalBar, HorizontalStackedBar, - FrenchMapRegions, FrenchMapDepartments, + FrenchMapRegions, FrenchMapDepartments, SwissMapCantons, DateTimeLine, TimeLine, DateLine, TimeDeltaLine) from pygal._compat import u from pygal.test.utils import texts @@ -275,7 +275,7 @@ def test_include_x_axis(Chart): chart = Chart() if Chart in (Pie, Treemap, Radar, Funnel, Dot, Gauge, Worldmap, SupranationalWorldmap, Histogram, Box, - FrenchMapRegions, FrenchMapDepartments): + FrenchMapRegions, FrenchMapDepartments, SwissMapCantons): return if not chart._dual: data = 100, 200, 150 @@ -362,7 +362,7 @@ def test_x_label_major(Chart): if Chart in ( Pie, Treemap, Funnel, Dot, Gauge, Worldmap, SupranationalWorldmap, Histogram, Box, - FrenchMapRegions, FrenchMapDepartments, + FrenchMapRegions, FrenchMapDepartments, SwissMapCantons, Pyramid, DateTimeLine, TimeLine, DateLine, TimeDeltaLine): return @@ -407,7 +407,7 @@ def test_y_label_major(Chart): if Chart in ( Pie, Treemap, Funnel, Dot, Gauge, Worldmap, SupranationalWorldmap, Histogram, Box, - FrenchMapRegions, FrenchMapDepartments, + FrenchMapRegions, FrenchMapDepartments, SwissMapCantons, HorizontalBar, HorizontalStackedBar, Pyramid, DateTimeLine, TimeLine, DateLine, TimeDeltaLine): diff --git a/pygal/test/test_graph.py b/pygal/test/test_graph.py index 309a08b..268b187 100644 --- a/pygal/test/test_graph.py +++ b/pygal/test/test_graph.py @@ -24,6 +24,7 @@ import sys import pytest from pygal import i18n from pygal.graph.frenchmap import DEPARTMENTS, REGIONS +from pygal.graph.swissmap import CANTONS from pygal.util import cut from pygal._compat import u from pygal.test import make_data @@ -86,6 +87,8 @@ def test_metadata(Chart): v = [(i, k) for k, i in enumerate(REGIONS.keys())] elif Chart == pygal.FrenchMapDepartments: v = [(i, k) for k, i in enumerate(DEPARTMENTS.keys())] + elif Chart == pygal.SwissMapCantons: + v = [(i, k) for k, i in enumerate(CANTONS.keys())] chart.add('Serie with metadata', [ v[0], @@ -110,7 +113,9 @@ def test_metadata(Chart): assert len(v) - 1 == len(q('.tooltip-trigger').siblings('.value')) elif Chart not in ( pygal.Worldmap, pygal.SupranationalWorldmap, - pygal.FrenchMapRegions, pygal.FrenchMapDepartments): + pygal.FrenchMapRegions, pygal.FrenchMapDepartments, + pygal.SwissMapCantons): + # Tooltip are not working on maps assert len(v) == len(q('.tooltip-trigger').siblings('.value')) @@ -193,7 +198,9 @@ def test_values_by_dict(Chart): if not issubclass(Chart, ( pygal.Worldmap, pygal.FrenchMapDepartments, - pygal.FrenchMapRegions)): + pygal.FrenchMapRegions, + pygal.SwissMapCantons)): + chart1.add('A', {'red': 10, 'green': 12, 'blue': 14}) chart1.add('B', {'green': 11, 'red': 7}) chart1.add('C', {'blue': 7}) @@ -378,7 +385,8 @@ def test_labels_with_links(Chart): if isinstance(chart, (pygal.graph.worldmap.Worldmap, - pygal.graph.frenchmap.FrenchMapDepartments)): + pygal.graph.frenchmap.FrenchMapDepartments, + pygal.graph.swissmap.SwissMapCantons)): # No country is found in this case so: assert len(links) == 4 # 3 links and 1 tooltip else: diff --git a/pygal/view.py b/pygal/view.py index 0de1e48..e0a6ff5 100644 --- a/pygal/view.py +++ b/pygal/view.py @@ -155,6 +155,13 @@ class View(object): return (self.x(x), self.y(y)) +class ReverseView(View): + def y(self, y): + if y is None: + return None + return (self.height * (y - self.box.ymin) / self.box.height) + + class HorizontalView(View): def __init__(self, width, height, box): self._force_vertical = None