From c2a50c94c48d85126e3ba6a522b19f828154aaf0 Mon Sep 17 00:00:00 2001 From: Krystof Celba Date: Tue, 17 Dec 2019 17:33:27 +0100 Subject: [PATCH 1/2] feat: add support for gmaps styles --- gmaps/figure.py | 29 +++++++++++++++++++++++++---- gmaps/geotraitlets.py | 17 +++++++++++++++++ gmaps/maps.py | 34 +++++++++++++++++++++++++++++++++- js/src/Map.js | 8 ++++++++ 4 files changed, 83 insertions(+), 5 deletions(-) diff --git a/gmaps/figure.py b/gmaps/figure.py index bac8623..f200698 100644 --- a/gmaps/figure.py +++ b/gmaps/figure.py @@ -6,7 +6,7 @@ from .maps import ( Map, InitialViewport, GMapsWidgetMixin, map_params_doc_snippets ) -from .geotraitlets import MapType, MouseHandling, Tilt +from .geotraitlets import MapType, MouseHandling, Tilt, StylesString from .toolbar import Toolbar from .errors_box import ErrorsBox from ._docutils import doc_subst @@ -44,6 +44,8 @@ class Figure(GMapsWidgetMixin, widgets.DOMWidget): mouse_handling = MouseHandling('COOPERATIVE') layout = widgets.trait_types.InstanceDict(FigureLayout).tag( sync=True, **widgets.widget_serialization) + + styles = StylesString('{}') def __init__(self, *args, **kwargs): if kwargs.get('layout') is None: @@ -57,6 +59,9 @@ def __init__(self, *args, **kwargs): self._map.mouse_handling = self.mouse_handling link((self._map, 'mouse_handling'), (self, 'mouse_handling')) + + self._map.styles = self.styles + link((self._map, 'styles'), (self, 'styles')) @default('layout') def _default_layout(self): @@ -116,7 +121,7 @@ def add_layer(self, layer): def figure( display_toolbar=True, display_errors=True, zoom_level=None, tilt=45, center=None, layout=None, map_type='ROADMAP', - mouse_handling='COOPERATIVE'): + mouse_handling='COOPERATIVE', styles='{}'): """ Create a gmaps figure @@ -156,6 +161,8 @@ def figure( {map_type} {mouse_handling} + + {styles} :param layout: Control the layout of the figure, e.g. its width, height, border etc. @@ -193,7 +200,21 @@ def figure( To have a satellite map: >>> fig = gmaps.figure(map_type='HYBRID') - + + To have a map with custom styles: + + >>> fig = gmaps.figure(styles='''[{ + "featureType": "road", + "elementType": "geometry", + "stylers": [ + { + "visibility": "on" + }, + { + "color": "#000000" + } + ] + }]''') """ # noqa: E501 if zoom_level is not None or center is not None: if zoom_level is None or center is None: @@ -218,5 +239,5 @@ def figure( fig = Figure( _map=_map, _toolbar=_toolbar, _errors_box=_errors_box, layout=layout, map_type=map_type, tilt=tilt, - mouse_handling=mouse_handling) + mouse_handling=mouse_handling, styles=styles) return fig diff --git a/gmaps/geotraitlets.py b/gmaps/geotraitlets.py index c7e72f3..210ef2e 100644 --- a/gmaps/geotraitlets.py +++ b/gmaps/geotraitlets.py @@ -1,6 +1,8 @@ import re +import json + import traitlets from .locations import locations_to_list @@ -323,3 +325,18 @@ def _validate_longitude(longitude): 'Longitudes must lie between ' '-180 and 180.'.format(longitude) ) + +class StylesString(traitlets.Unicode): + """ + A string holding a google maps styles as JSON formatted string + """ + info_text = 'JSON formatted styles string' + default_value = traitlets.Undefined + + def validate(self, obj, value): + try: + value_as_string = super(StylesString, self).validate(obj, value) + json.loads(value_as_string) + return value_as_string + except TypeError: + return self.error(obj, value) diff --git a/gmaps/maps.py b/gmaps/maps.py index cc9cbe0..f9cee92 100644 --- a/gmaps/maps.py +++ b/gmaps/maps.py @@ -4,7 +4,7 @@ observe, Dict, HasTraits, Enum, Union) from .bounds import merge_longitude_bounds -from .geotraitlets import Point, ZoomLevel, MapType, MouseHandling, Tilt +from .geotraitlets import Point, ZoomLevel, MapType, MouseHandling, Tilt, StylesString from ._docutils import doc_subst from ._version import CLIENT_VERSION @@ -37,6 +37,13 @@ :type mouse_handling: str, optional """ +map_params_doc_snippets['styles'] = """ + :param styles: + A string holding a google maps styles as JSON formatted string + https://developers.google.com/maps/documentation/javascript/style-reference + :type styles: str, optional +""" + def configure(api_key=None): """ @@ -165,6 +172,8 @@ class Map(ConfigurationMixin, GMapsWidgetMixin, widgets.DOMWidget): {map_type} {mouse_handling} + + {styles} :Examples: @@ -185,6 +194,27 @@ class Map(ConfigurationMixin, GMapsWidgetMixin, widgets.DOMWidget): You can also change this dynamically: >>> m.map_type = 'TERRAIN' + + To have a map with custom styles: + + styles = '''[{ + "featureType": "road", + "elementType": "geometry", + "stylers": [ + { + "visibility": "on" + }, + { + "color": "#000000" + } + ] + }]''' + + >>> m = gmaps.Map(styles=styles) + + You can also change this dynamically: + + >>> m.styles = 'styles' """ _view_name = Unicode('PlainmapView').tag(sync=True) _model_name = Unicode('PlainmapModel').tag(sync=True) @@ -197,6 +227,8 @@ class Map(ConfigurationMixin, GMapsWidgetMixin, widgets.DOMWidget): tilt = Tilt().tag(sync=True) mouse_handling = MouseHandling('COOPERATIVE').tag(sync=True) + styles = StylesString('{}').tag(sync=True) + def add_layer(self, layer): self.layers = tuple([l for l in self.layers] + [layer]) diff --git a/js/src/Map.js b/js/src/Map.js index 5cef090..0088e24 100644 --- a/js/src/Map.js +++ b/js/src/Map.js @@ -110,10 +110,12 @@ export class PlainmapView extends ConfigurationMixin(widgets.DOMWidgetView) { } readOptions(google) { + const styles = this.model.get('styles'); const options = { mapTypeId: stringToMapType(google, this.model.get('map_type')), gestureHandling: this.model.get('mouse_handling').toLowerCase(), tilt: this.model.get('tilt'), + styles: styles ? JSON.parse(styles) : '', }; return options; } @@ -137,6 +139,11 @@ export class PlainmapView extends ConfigurationMixin(widgets.DOMWidgetView) { .toLowerCase(); this.setMapOptions({gestureHandling}); }); + + this.model.on('change:styles', () => { + const styles = JSON.parse(this.model.get('styles')); + this.setMapOptions({styles}); + }); } _viewEvents(google) { @@ -240,6 +247,7 @@ export class PlainmapModel extends widgets.DOMWidgetModel { initial_viewport: {type: DATA_BOUNDS}, map_type: 'ROADMAP', mouse_handling: 'COOPERATIVE', + styles: '{}', }; } From 690a448fddda3f37c1b86aa1286be96c3f0cafc7 Mon Sep 17 00:00:00 2001 From: Krystof Celba Date: Tue, 17 Dec 2019 18:19:37 +0100 Subject: [PATCH 2/2] test: add tests for styles --- gmaps/figure.py | 22 ++++++++-------- gmaps/geotraitlets.py | 4 +++ gmaps/maps.py | 43 ++++++++++++++++++-------------- gmaps/tests/test_figure.py | 18 +++++++++++++ gmaps/tests/test_geotraitlets.py | 29 +++++++++++++++++++++ gmaps/tests/test_maps.py | 7 +++++- 6 files changed, 93 insertions(+), 30 deletions(-) diff --git a/gmaps/figure.py b/gmaps/figure.py index f200698..2b2ccfb 100644 --- a/gmaps/figure.py +++ b/gmaps/figure.py @@ -44,7 +44,7 @@ class Figure(GMapsWidgetMixin, widgets.DOMWidget): mouse_handling = MouseHandling('COOPERATIVE') layout = widgets.trait_types.InstanceDict(FigureLayout).tag( sync=True, **widgets.widget_serialization) - + styles = StylesString('{}') def __init__(self, *args, **kwargs): @@ -59,7 +59,7 @@ def __init__(self, *args, **kwargs): self._map.mouse_handling = self.mouse_handling link((self._map, 'mouse_handling'), (self, 'mouse_handling')) - + self._map.styles = self.styles link((self._map, 'styles'), (self, 'styles')) @@ -161,7 +161,7 @@ def figure( {map_type} {mouse_handling} - + {styles} :param layout: @@ -200,21 +200,23 @@ def figure( To have a satellite map: >>> fig = gmaps.figure(map_type='HYBRID') - + To have a map with custom styles: - >>> fig = gmaps.figure(styles='''[{ + styles = '''[{{ "featureType": "road", "elementType": "geometry", "stylers": [ - { + {{ "visibility": "on" - }, - { + }}, + {{ "color": "#000000" - } + }} ] - }]''') + }}]''' + + >>> fig = gmaps.figure(styles=styles) """ # noqa: E501 if zoom_level is not None or center is not None: if zoom_level is None or center is None: diff --git a/gmaps/geotraitlets.py b/gmaps/geotraitlets.py index 210ef2e..a12376b 100644 --- a/gmaps/geotraitlets.py +++ b/gmaps/geotraitlets.py @@ -326,9 +326,13 @@ def _validate_longitude(longitude): '-180 and 180.'.format(longitude) ) + class StylesString(traitlets.Unicode): """ A string holding a google maps styles as JSON formatted string + + Using `this ` page # noqa: E501 + for reference. """ info_text = 'JSON formatted styles string' default_value = traitlets.Undefined diff --git a/gmaps/maps.py b/gmaps/maps.py index f9cee92..1dc71ba 100644 --- a/gmaps/maps.py +++ b/gmaps/maps.py @@ -4,7 +4,8 @@ observe, Dict, HasTraits, Enum, Union) from .bounds import merge_longitude_bounds -from .geotraitlets import Point, ZoomLevel, MapType, MouseHandling, Tilt, StylesString +from .geotraitlets import (Point, ZoomLevel, MapType, + MouseHandling, Tilt, StylesString) from ._docutils import doc_subst from ._version import CLIENT_VERSION @@ -37,10 +38,13 @@ :type mouse_handling: str, optional """ + map_params_doc_snippets['styles'] = """ :param styles: - A string holding a google maps styles as JSON formatted string - https://developers.google.com/maps/documentation/javascript/style-reference + A JSON formatted Google Maps styles string. + + Using `this ` page # noqa: E501 + for reference. :type styles: str, optional """ @@ -80,10 +84,11 @@ class InitialViewport(Union): """ Traitlet defining the initial viewport for a map. """ + def __init__(self, **metadata): trait_types = [ - Enum(['DATA_BOUNDS']), - Instance(_ZoomCenter) + Enum(['DATA_BOUNDS']), + Instance(_ZoomCenter) ] super(InitialViewport, self).__init__(trait_types, **metadata) @@ -142,9 +147,9 @@ def _serialize_viewport(viewport, manager): else: try: payload = { - 'type': 'ZOOM_CENTER', - 'center': viewport.center, - 'zoom_level': viewport.zoom_level + 'type': 'ZOOM_CENTER', + 'center': viewport.center, + 'zoom_level': viewport.zoom_level } except AttributeError: raise ValueError('viewport') @@ -172,7 +177,7 @@ class Map(ConfigurationMixin, GMapsWidgetMixin, widgets.DOMWidget): {map_type} {mouse_handling} - + {styles} :Examples: @@ -194,24 +199,24 @@ class Map(ConfigurationMixin, GMapsWidgetMixin, widgets.DOMWidget): You can also change this dynamically: >>> m.map_type = 'TERRAIN' - + To have a map with custom styles: - - styles = '''[{ + + styles = '''[{{ "featureType": "road", "elementType": "geometry", "stylers": [ - { + {{ "visibility": "on" - }, - { + }}, + {{ "color": "#000000" - } + }} ] - }]''' + }}]''' >>> m = gmaps.Map(styles=styles) - + You can also change this dynamically: >>> m.styles = 'styles' @@ -222,7 +227,7 @@ class Map(ConfigurationMixin, GMapsWidgetMixin, widgets.DOMWidget): sync=True, **widgets.widget_serialization) data_bounds = List(DEFAULT_BOUNDS).tag(sync=True) initial_viewport = InitialViewport(default_value='DATA_BOUNDS').tag( - sync=True, to_json=_serialize_viewport) + sync=True, to_json=_serialize_viewport) map_type = MapType('ROADMAP').tag(sync=True) tilt = Tilt().tag(sync=True) mouse_handling = MouseHandling('COOPERATIVE').tag(sync=True) diff --git a/gmaps/tests/test_figure.py b/gmaps/tests/test_figure.py index 215bc27..5d92699 100644 --- a/gmaps/tests/test_figure.py +++ b/gmaps/tests/test_figure.py @@ -7,6 +7,9 @@ from ..toolbar import Toolbar from ..errors_box import ErrorsBox +STYLES = '[{}]' +STYLES_1 = '[{}, {}]' + class TestFigure(unittest.TestCase): @@ -63,6 +66,16 @@ def test_catch_mouse_handling_change_in_map(self): fig._map.mouse_handling = 'NONE' assert fig.mouse_handling == 'NONE' + def test_proxy_styles(self): + fig = Figure(_map=Map(), styles=STYLES) + assert fig.styles == STYLES + assert fig._map.styles == STYLES + + def test_proxy_styles_change(self): + fig = Figure(_map=Map(), styles=STYLES) + fig.styles = STYLES_1 + assert fig._map.styles == STYLES_1 + class TestFigureFactory(unittest.TestCase): @@ -72,6 +85,7 @@ def test_defaults(self): assert fig._errors_box is not None assert fig.map_type == 'ROADMAP' assert fig.mouse_handling == 'COOPERATIVE' + assert fig.styles == '{}' map_ = fig._map assert map_ is not None assert map_.initial_viewport == 'DATA_BOUNDS' @@ -142,3 +156,7 @@ def test_default_tilt(self): def test_custom_mouse_handling(self): fig = figure(mouse_handling='NONE') assert fig.mouse_handling == 'NONE' + + def test_custom_styles(self): + fig = figure(styles=STYLES_1) + assert fig.styles == STYLES_1 diff --git a/gmaps/tests/test_geotraitlets.py b/gmaps/tests/test_geotraitlets.py index 7d04fba..e242f22 100644 --- a/gmaps/tests/test_geotraitlets.py +++ b/gmaps/tests/test_geotraitlets.py @@ -387,3 +387,32 @@ def test_over_max(self): def test_wrong_type(self): with self.assertRaises(traitlets.TraitError): self.A(x='not-a-float') + + +STYLES = '''[{ + "featureType": "road", + "elementType": "geometry", + "stylers": [ + { + "visibility": "on" + }, + { + "color": "#000000" + } + ] +}]''' + + +class StylesString(unittest.TestCase): + def setUp(self): + class A(traitlets.HasTraits): + x = geotraitlets.StylesString() + self.A = A + + def test_accept_styles_json_string(self): + a = self.A(x=STYLES) + assert a.x == STYLES + + def test_reject_invalid_json_string(self): + with self.assertRaises(ValueError): + self.A(x='{') diff --git a/gmaps/tests/test_maps.py b/gmaps/tests/test_maps.py index 4b78746..c99f3ca 100644 --- a/gmaps/tests/test_maps.py +++ b/gmaps/tests/test_maps.py @@ -5,6 +5,8 @@ from .. import maps, heatmap_layer +STYLES = '[{}]' + class Map(unittest.TestCase): @@ -15,6 +17,7 @@ def test_defaults(self): assert state['mouse_handling'] == 'COOPERATIVE' assert state['initial_viewport'] == {'type': 'DATA_BOUNDS'} assert state['layers'] == [] + assert state['styles'] == '{}' def test_custom_traits(self): test_layer = heatmap_layer([(1.0, 2.0), (3.0, 4.0)]) @@ -23,7 +26,8 @@ def test_custom_traits(self): mouse_handling='NONE', initial_viewport=maps.InitialViewport.from_zoom_center( 10, (5.0, 10.0)), - layers=[test_layer] + layers=[test_layer], + styles=STYLES ) state = m.get_state() assert state['map_type'] == 'HYBRID' @@ -34,6 +38,7 @@ def test_custom_traits(self): } assert state['layers'] == ['IPY_MODEL_' + test_layer.model_id] assert state['mouse_handling'] == 'NONE' + assert state['styles'] == STYLES def test_change_layer(self): test_layer = heatmap_layer([(1.0, 2.0), (3.0, 4.0)])