diff --git a/gmaps/figure.py b/gmaps/figure.py index bac8623..2b2ccfb 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 @@ -45,6 +45,8 @@ class Figure(GMapsWidgetMixin, widgets.DOMWidget): 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: kwargs['layout'] = self._default_layout() @@ -58,6 +60,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): return FigureLayout() @@ -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 @@ -157,6 +162,8 @@ def figure( {mouse_handling} + {styles} + :param layout: Control the layout of the figure, e.g. its width, height, border etc. For instance, passing ``layout={{'width': '400px', 'height': '300px'}}`` @@ -194,6 +201,22 @@ def figure( >>> fig = gmaps.figure(map_type='HYBRID') + To have a map with custom 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: @@ -218,5 +241,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..a12376b 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,22 @@ 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 + + Using `this ` page # noqa: E501 + for reference. + """ + 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..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 +from .geotraitlets import (Point, ZoomLevel, MapType, + MouseHandling, Tilt, StylesString) from ._docutils import doc_subst from ._version import CLIENT_VERSION @@ -38,6 +39,16 @@ """ +map_params_doc_snippets['styles'] = """ + :param styles: + A JSON formatted Google Maps styles string. + + Using `this ` page # noqa: E501 + for reference. + :type styles: str, optional +""" + + def configure(api_key=None): """ Configure access to the GoogleMaps API. @@ -73,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) @@ -135,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') @@ -166,6 +178,8 @@ class Map(ConfigurationMixin, GMapsWidgetMixin, widgets.DOMWidget): {mouse_handling} + {styles} + :Examples: >>> m = gmaps.Map() @@ -185,6 +199,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) @@ -192,11 +227,13 @@ 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) + styles = StylesString('{}').tag(sync=True) + def add_layer(self, layer): self.layers = tuple([l for l in self.layers] + [layer]) 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)]) 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: '{}', }; }