diff --git a/examples/reference/elements/bokeh/Tiles.ipynb b/examples/reference/elements/bokeh/Tiles.ipynb index 32be05b65a..34e571b773 100644 --- a/examples/reference/elements/bokeh/Tiles.ipynb +++ b/examples/reference/elements/bokeh/Tiles.ipynb @@ -8,7 +8,9 @@ "
\n", "
Title
Tiles Element
\n", "
Dependencies
Bokeh
\n", - "
Backends
Bokeh
\n", + "
Backends
\n", + "
Bokeh
\n", + "
Plotly
\n", "
\n", "" ] diff --git a/examples/reference/elements/plotly/Tiles.ipynb b/examples/reference/elements/plotly/Tiles.ipynb new file mode 100644 index 0000000000..025682efd9 --- /dev/null +++ b/examples/reference/elements/plotly/Tiles.ipynb @@ -0,0 +1,130 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "
\n", + "
\n", + "
Title
Tiles Element
\n", + "
Dependencies
Plotly
\n", + "
Backends
\n", + "
Bokeh
\n", + "
Plotly
\n", + "
\n", + "
" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import holoviews as hv\n", + "from holoviews import opts\n", + "hv.extension('plotly')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The ``Tiles`` element represents a so called web mapping tile source usually used for geographic plots, which fetches tiles appropriate to the current zoom level. To declare a ``Tiles`` element simply provide a URL to the tile server. A standard tile server URL has a number of templated variables that describe the location and zoom level. In the most common case of a WMTS tile source, the URL looks like this:\n", + "\n", + " 'https://maps.wikimedia.org/osm-intl/{Z}/{X}/{Y}@2x.png'\n", + "\n", + "Here ``{X}``, ``{Y}`` and ``{Z}`` describe the location and zoom level of each tile.\n", + "A simple example of a WMTS tile source is the Wikipedia maps:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "hv.Tiles('https://maps.wikimedia.org/osm-intl/{Z}/{X}/{Y}@2x.png', name=\"Wikipedia\").opts(width=600, height=550)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "One thing to note about tile sources is that they are always defined in the [pseudo-Mercator projection](https://epsg.io/3857), which means that if you want to overlay any data on top of a tile source the values have to be expressed as eastings and northings. If you have data in another projection, e.g. latitudes and longitudes, it may make sense to use [GeoViews](http://geoviews.org/) for it to handle the projections for you.\n", + "\n", + "Both HoloViews and GeoViews provides a number of tile sources by default, provided by CartoDB, Stamen, OpenStreetMap, Esri and Wikipedia. These can be imported from the ``holoviews.element.tiles`` module and are provided as callable functions that return a ``Tiles`` element:" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The full set of predefined tile sources can be accessed on the ``holoviews.element.tiles.tile_sources`` dictionary:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "pycharm": { + "name": "#%%\n" + } + }, + "outputs": [], + "source": [ + "hv.Layout([ts().relabel(name) for name, ts in hv.element.tiles.tile_sources.items()]).opts(\n", + " opts.Tiles(xaxis=None, yaxis=None, width=225, height=225),\n", + " opts.Layout(hspacing=10, vspacing=40)\n", + ").cols(4)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "For full documentation and the available style and plot options, use ``hv.help(hv.Tiles).``" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Vector Mapbox Tiles\n", + "\n", + "In addition to displaying raster tiles loaded from a tile source URL, the Plotly backend can also display vector tiles provided by Mapbox. A vector tile style is specified using the `mapboxstyle` option, and requires a Mapbox\n", + "access token to be provided as the `accesstoken` option.\n", + "\n", + "```python\n", + "hv.Tiles('').opts(\n", + " mapboxstyle=\"dark\",\n", + " accesstoken=\"pk...\",\n", + " width=600,\n", + " height=600\n", + ")\n", + "```" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.7.9" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/holoviews/element/tiles.py b/holoviews/element/tiles.py index b8bc98d925..5a66b26222 100644 --- a/holoviews/element/tiles.py +++ b/holoviews/element/tiles.py @@ -8,6 +8,7 @@ from ..core import util from ..core.dimension import Dimension from ..core.element import Element2D +from ..util.transform import lon_lat_to_easting_northing, easting_northing_to_lon_lat class Tiles(Element2D): @@ -54,6 +55,28 @@ def range(self, dim, data_range=True, dimension_range=True): def dimension_values(self, dimension, expanded=True, flat=True): return np.array([]) + @staticmethod + def lon_lat_to_easting_northing(longitude, latitude): + """ + Projects the given longitude, latitude values into Web Mercator + (aka Pseudo-Mercator or EPSG:3857) coordinates. + + See docstring for holoviews.util.transform.lon_lat_to_easting_northing + for more information + """ + return lon_lat_to_easting_northing(longitude, latitude) + + @staticmethod + def easting_northing_to_lon_lat(easting, northing): + """ + Projects the given easting, northing values into + longitude, latitude coordinates. + + See docstring for holoviews.util.transform.easting_northing_to_lon_lat + for more information + """ + return easting_northing_to_lon_lat(easting, northing) + # Mapping between patterns to match specified as tuples and tuples containing attributions _ATTRIBUTIONS = { diff --git a/holoviews/plotting/plotly/__init__.py b/holoviews/plotting/plotly/__init__.py index a50e1bc512..ac1c5d9923 100644 --- a/holoviews/plotting/plotly/__init__.py +++ b/holoviews/plotting/plotly/__init__.py @@ -15,6 +15,7 @@ from .renderer import PlotlyRenderer from .annotation import * # noqa (API import) +from .tiles import * # noqa (API import) from .element import * # noqa (API import) from .chart import * # noqa (API import) from .chart3d import * # noqa (API import) @@ -72,6 +73,7 @@ # Annotations Labels: LabelPlot, + Tiles: TilePlot, # Shapes Box: PathShapePlot, @@ -102,6 +104,7 @@ plot.padding = 0 dflt_cmap = 'fire' +dflt_shape_line_color = '#2a3f5f' # Line color of default plotly template point_size = np.sqrt(6) # Matches matplotlib default Cycle.default_cycles['default_colors'] = ['#30a2da', '#fc4f30', '#e5ae38', @@ -129,3 +132,10 @@ # Annotations options.VSpan = Options('style', fillcolor=Cycle(), opacity=0.5) options.HSpan = Options('style', fillcolor=Cycle(), opacity=0.5) + +# Shapes +options.Rectangles = Options('style', line_color=dflt_shape_line_color) +options.Bounds = Options('style', line_color=dflt_shape_line_color) +options.Path = Options('style', line_color=dflt_shape_line_color) +options.Segments = Options('style', line_color=dflt_shape_line_color) +options.Box = Options('style', line_color=dflt_shape_line_color) diff --git a/holoviews/plotting/plotly/annotation.py b/holoviews/plotting/plotly/annotation.py index 07d1d2723d..549e3582f3 100644 --- a/holoviews/plotting/plotly/annotation.py +++ b/holoviews/plotting/plotly/annotation.py @@ -3,6 +3,7 @@ import param from .chart import ScatterPlot +from ...element import Tiles class LabelPlot(ScatterPlot): @@ -17,12 +18,16 @@ class LabelPlot(ScatterPlot): _nonvectorized_styles = [] - trace_kwargs = {'type': 'scatter', 'mode': 'text'} - _style_key = 'textfont' - def get_data(self, element, ranges, style): - x, y = ('y', 'x') if self.invert_axes else ('x', 'y') + @classmethod + def trace_kwargs(cls, is_geo=False, **kwargs): + if is_geo: + return {'type': 'scattermapbox', 'mode': 'text'} + else: + return {'type': 'scatter', 'mode': 'text'} + + def get_data(self, element, ranges, style, is_geo=False, **kwargs): text_dim = element.vdims[0] xs = element.dimension_values(0) if self.xoffset: @@ -31,4 +36,10 @@ def get_data(self, element, ranges, style): if self.yoffset: ys = ys + self.yoffset text = [text_dim.pprint_value(v) for v in element.dimension_values(2)] - return [{x: xs, y: ys, 'text': text}] + + if is_geo: + lon, lat = Tiles.easting_northing_to_lon_lat(xs, ys) + return [{"lon": lon, "lat": lat, 'text': text}] + else: + x, y = ('y', 'x') if self.invert_axes else ('x', 'y') + return [{x: xs, y: ys, 'text': text}] diff --git a/holoviews/plotting/plotly/callbacks.py b/holoviews/plotting/plotly/callbacks.py index a69f4a3b04..5690a3e52d 100644 --- a/holoviews/plotting/plotly/callbacks.py +++ b/holoviews/plotting/plotly/callbacks.py @@ -9,6 +9,8 @@ from .util import _trace_to_subplot +from ...element import Tiles + class PlotlyCallbackMetaClass(type): """ @@ -46,8 +48,10 @@ def __init__(self, plot, streams, source, **params): self.last_event = None @classmethod - def update_streams_from_property_update(cls, property_value, fig_dict): - event_data = cls.get_event_data_from_property_update(property_value, fig_dict) + def update_streams_from_property_update(cls, property, property_value, fig_dict): + event_data = cls.get_event_data_from_property_update( + property, property_value, fig_dict + ) streams = [] for trace_uid, stream_data in event_data.items(): if trace_uid in cls.instances: @@ -69,15 +73,15 @@ def update_streams_from_property_update(cls, property_value, fig_dict): raise e @classmethod - def get_event_data_from_property_update(cls, property_value, fig_dict): + def get_event_data_from_property_update(cls, property, property_value, fig_dict): raise NotImplementedError class Selection1DCallback(PlotlyCallback): - callback_property = "selected_data" + callback_properties = ["selected_data"] @classmethod - def get_event_data_from_property_update(cls, selected_data, fig_dict): + def get_event_data_from_property_update(cls, property, selected_data, fig_dict): traces = fig_dict.get('data', []) @@ -100,26 +104,38 @@ def get_event_data_from_property_update(cls, selected_data, fig_dict): class BoundsCallback(PlotlyCallback): - callback_property = "selected_data" + callback_properties = ["selected_data"] boundsx = False boundsy = False @classmethod - def get_event_data_from_property_update(cls, selected_data, fig_dict): + def get_event_data_from_property_update(cls, property, selected_data, fig_dict): traces = fig_dict.get('data', []) - if not selected_data or 'range' not in selected_data: - # No valid box selection - box = None - else: - # Get x and y axis references - box = selected_data["range"] - axis_refs = list(box) - xref = [ref for ref in axis_refs if ref.startswith('x')][0] - yref = [ref for ref in axis_refs if ref.startswith('y')][0] + # Initialize event data by clearing box selection on everything + event_data = {} + for trace_ind, trace in enumerate(traces): + trace_uid = trace.get('uid', None) + if cls.boundsx and cls.boundsy: + stream_data = dict(bounds=None) + elif cls.boundsx: + stream_data = dict(boundsx=None) + elif cls.boundsy: + stream_data = dict(boundsy=None) + else: + stream_data = dict() + + event_data[trace_uid] = stream_data + range_data = (selected_data or {}).get("range", {}) + cls.update_event_data_xyaxis(range_data, traces, event_data) + cls.update_event_data_mapbox(range_data, traces, event_data) + + return event_data + + @classmethod + def update_event_data_xyaxis(cls, range_data, traces, event_data): # Process traces - event_data = {} for trace_ind, trace in enumerate(traces): trace_type = trace.get('type', 'scatter') trace_uid = trace.get('uid', None) @@ -127,10 +143,14 @@ def get_event_data_from_property_update(cls, selected_data, fig_dict): if _trace_to_subplot.get(trace_type, None) != ['xaxis', 'yaxis']: continue - if (box and trace.get('xaxis', 'x') == xref and - trace.get('yaxis', 'y') == yref): + xref = trace.get('xaxis', 'x') + yref = trace.get('yaxis', 'y') - new_bounds = (box[xref][0], box[yref][0], box[xref][1], box[yref][1]) + if xref in range_data and yref in range_data: + new_bounds = ( + range_data[xref][0], range_data[yref][0], + range_data[xref][1], range_data[yref][1] + ) if cls.boundsx and cls.boundsy: stream_data = dict(bounds=new_bounds) @@ -142,20 +162,36 @@ def get_event_data_from_property_update(cls, selected_data, fig_dict): stream_data = dict() event_data[trace_uid] = stream_data - else: + + @classmethod + def update_event_data_mapbox(cls, range_data, traces, event_data): + # Process traces + for trace_ind, trace in enumerate(traces): + trace_type = trace.get('type', 'scatter') + trace_uid = trace.get('uid', None) + + if _trace_to_subplot.get(trace_type, None) != ['mapbox']: + continue + + mapbox_ref = trace.get('subplot', 'mapbox') + if mapbox_ref in range_data: + lon_bounds = [range_data[mapbox_ref][0][0], range_data[mapbox_ref][1][0]] + lat_bounds = [range_data[mapbox_ref][0][1], range_data[mapbox_ref][1][1]] + + easting, northing = Tiles.lon_lat_to_easting_northing(lon_bounds, lat_bounds) + new_bounds = (easting[0], northing[0], easting[1], northing[1]) + if cls.boundsx and cls.boundsy: - stream_data = dict(bounds=None) + stream_data = dict(bounds=new_bounds) elif cls.boundsx: - stream_data = dict(boundsx=None) + stream_data = dict(boundsx=(new_bounds[0], new_bounds[2])) elif cls.boundsy: - stream_data = dict(boundsy=None) + stream_data = dict(boundsy=(new_bounds[1], new_bounds[3])) else: stream_data = dict() event_data[trace_uid] = stream_data - return event_data - class BoundsXYCallback(BoundsCallback): boundsx = True @@ -171,15 +207,23 @@ class BoundsYCallback(BoundsCallback): class RangeCallback(PlotlyCallback): - callback_property = "viewport" + callback_properties = ["viewport", "relayout_data"] x_range = False y_range = False @classmethod - def get_event_data_from_property_update(cls, viewport, fig_dict): - + def get_event_data_from_property_update(cls, property, property_value, fig_dict): traces = fig_dict.get('data', []) + if property == "viewport": + event_data = cls.build_event_data_from_viewport(traces, property_value) + else: + event_data = cls.build_event_data_from_relayout_data(traces, property_value) + + return event_data + + @classmethod + def build_event_data_from_viewport(cls, traces, property_value): # Process traces event_data = {} for trace_ind, trace in enumerate(traces): @@ -194,19 +238,65 @@ def get_event_data_from_property_update(cls, viewport, fig_dict): xprop = '{xaxis}.range'.format(xaxis=xaxis) yprop = '{yaxis}.range'.format(yaxis=yaxis) - if not viewport: + if not property_value: x_range = None y_range = None - elif xprop in viewport and yprop in viewport: - x_range = tuple(viewport[xprop]) - y_range = tuple(viewport[yprop]) - elif xprop + "[0]" in viewport and xprop + "[1]" in viewport and \ - yprop + "[0]" in viewport and yprop + "[1]" in viewport : - x_range = (viewport[xprop + "[0]"], viewport[xprop + "[1]"]) - y_range = (viewport[yprop + "[0]"], viewport[yprop + "[1]"]) + elif xprop in property_value and yprop in property_value: + x_range = tuple(property_value[xprop]) + y_range = tuple(property_value[yprop]) + elif xprop + "[0]" in property_value and xprop + "[1]" in property_value and \ + yprop + "[0]" in property_value and yprop + "[1]" in property_value: + x_range = (property_value[xprop + "[0]"],property_value[xprop + "[1]"]) + y_range = (property_value[yprop + "[0]"], property_value[yprop + "[1]"]) else: + continue + + stream_data = {} + if cls.x_range: + stream_data['x_range'] = x_range + + if cls.y_range: + stream_data['y_range'] = y_range + + event_data[trace_uid] = stream_data + return event_data + + @classmethod + def build_event_data_from_relayout_data(cls, traces, property_value): + # Process traces + event_data = {} + for trace_ind, trace in enumerate(traces): + trace_type = trace.get('type', 'scattermapbox') + trace_uid = trace.get('uid', None) + + if _trace_to_subplot.get(trace_type, None) != ['mapbox']: + continue + + subplot_id = trace.get("subplot", "mapbox") + derived_prop = subplot_id + "._derived" + + if not property_value: x_range = None y_range = None + elif "coordinates" in property_value.get(derived_prop, {}): + coords = property_value[derived_prop]["coordinates"] + ((lon_top_left, lat_top_left), + (lon_top_right, lat_top_right), + (lon_bottom_right, lat_bottom_right), + (lon_bottom_left, lat_bottom_left)) = coords + + lon_left = min(lon_top_left, lon_bottom_left) + lon_right = max(lon_top_right, lon_bottom_right) + lat_bottom = min(lat_bottom_left, lat_bottom_right) + lat_top = max(lat_top_left, lat_top_right) + + x_range, y_range = Tiles.lon_lat_to_easting_northing( + [lon_left, lon_right], [lat_bottom, lat_top] + ) + x_range = tuple(x_range) + y_range = tuple(y_range) + else: + continue stream_data = {} if cls.x_range: diff --git a/holoviews/plotting/plotly/chart.py b/holoviews/plotting/plotly/chart.py index 6e08d7e8e0..ac81083c12 100644 --- a/holoviews/plotting/plotly/chart.py +++ b/holoviews/plotting/plotly/chart.py @@ -5,18 +5,32 @@ from .selection import PlotlyOverlaySelectionDisplay from ...core import util from ...operation import interpolate_curve +from ...element import Tiles from ..mixins import AreaMixin, BarsMixin from .element import ElementPlot, ColorbarPlot class ChartPlot(ElementPlot): - trace_kwargs = {'type': 'scatter'} + @classmethod + def trace_kwargs(cls, is_geo=False, **kwargs): + return {'type': 'scatter'} - def get_data(self, element, ranges, style): - x, y = ('y', 'x') if self.invert_axes else ('x', 'y') - return [{x: element.dimension_values(0), - y: element.dimension_values(1)}] + def get_data(self, element, ranges, style, is_geo=False, **kwargs): + if is_geo: + if self.invert_axes: + x = element.dimension_values(1) + y = element.dimension_values(0) + else: + x = element.dimension_values(0) + y = element.dimension_values(1) + + lon, lat = Tiles.easting_northing_to_lon_lat(x, y) + return [{"lon": lon, "lat": lat}] + else: + x, y = ('y', 'x') if self.invert_axes else ('x', 'y') + return [{x: element.dimension_values(0), + y: element.dimension_values(1)}] class ScatterPlot(ChartPlot, ColorbarPlot): @@ -38,14 +52,21 @@ class ScatterPlot(ChartPlot, ColorbarPlot): _nonvectorized_styles = ['visible', 'cmap', 'alpha', 'sizemin', 'selectedpoints'] - trace_kwargs = {'type': 'scatter', 'mode': 'markers'} - _style_key = 'marker' selection_display = PlotlyOverlaySelectionDisplay() - def graph_options(self, element, ranges, style): - opts = super(ScatterPlot, self).graph_options(element, ranges, style) + _supports_geo = True + + @classmethod + def trace_kwargs(cls, is_geo=False, **kwargs): + if is_geo: + return {'type': 'scattermapbox', 'mode': 'markers'} + else: + return {'type': 'scatter', 'mode': 'markers'} + + def graph_options(self, element, ranges, style, **kwargs): + opts = super(ScatterPlot, self).graph_options(element, ranges, style, **kwargs) cdim = element.get_dimension(self.color_index) if cdim: copts = self.get_color_opts(cdim, element, ranges, style) @@ -69,18 +90,27 @@ class CurvePlot(ChartPlot, ColorbarPlot): padding = param.ClassSelector(default=(0, 0.1), class_=(int, float, tuple)) - trace_kwargs = {'type': 'scatter', 'mode': 'lines'} - style_opts = ['visible', 'color', 'dash', 'line_width'] _nonvectorized_styles = style_opts + unsupported_geo_style_opts = ["dash"] + _style_key = 'line' - def get_data(self, element, ranges, style): + _supports_geo = True + + @classmethod + def trace_kwargs(cls, is_geo=False, **kwargs): + if is_geo: + return {'type': 'scattermapbox', 'mode': 'lines'} + else: + return {'type': 'scatter', 'mode': 'lines'} + + def get_data(self, element, ranges, style, **kwargs): if 'steps' in self.interpolation: element = interpolate_curve(element, interpolation=self.interpolation) - return super(CurvePlot, self).get_data(element, ranges, style) + return super(CurvePlot, self).get_data(element, ranges, style, **kwargs) class AreaPlot(AreaMixin, ChartPlot): @@ -89,14 +119,16 @@ class AreaPlot(AreaMixin, ChartPlot): style_opts = ['visible', 'color', 'dash', 'line_width'] - trace_kwargs = {'type': 'scatter', 'mode': 'lines'} - _style_key = 'line' - def get_data(self, element, ranges, style): + @classmethod + def trace_kwargs(cls, is_geo=False, **kwargs): + return {'type': 'scatter', 'mode': 'lines'} + + def get_data(self, element, ranges, style, **kwargs): x, y = ('y', 'x') if self.invert_axes else ('x', 'y') if len(element.vdims) == 1: - kwargs = super(AreaPlot, self).get_data(element, ranges, style)[0] + kwargs = super(AreaPlot, self).get_data(element, ranges, style, **kwargs)[0] kwargs['fill'] = 'tozero'+y return [kwargs] xs = element.dimension_values(0) @@ -112,11 +144,13 @@ class SpreadPlot(ChartPlot): style_opts = ['visible', 'color', 'dash', 'line_width'] - trace_kwargs = {'type': 'scatter', 'mode': 'lines'} - _style_key = 'line' - def get_data(self, element, ranges, style): + @classmethod + def trace_kwargs(cls, is_geo=False, **kwargs): + return {'type': 'scatter', 'mode': 'lines'} + + def get_data(self, element, ranges, style, **kwargs): x, y = ('y', 'x') if self.invert_axes else ('x', 'y') xs = element.dimension_values(0) mean = element.dimension_values(1) @@ -131,8 +165,6 @@ def get_data(self, element, ranges, style): class ErrorBarsPlot(ChartPlot, ColorbarPlot): - trace_kwargs = {'type': 'scatter', 'mode': 'lines', 'line': {'width': 0}} - style_opts = ['visible', 'color', 'dash', 'line_width', 'thickness'] _nonvectorized_styles = style_opts @@ -141,7 +173,11 @@ class ErrorBarsPlot(ChartPlot, ColorbarPlot): selection_display = PlotlyOverlaySelectionDisplay() - def get_data(self, element, ranges, style): + @classmethod + def trace_kwargs(cls, is_geo=False, **kwargs): + return {'type': 'scatter', 'mode': 'lines', 'line': {'width': 0}} + + def get_data(self, element, ranges, style, **kwargs): x, y = ('y', 'x') if self.invert_axes else ('x', 'y') error_k = 'error_' + x if element.horizontal else 'error_' + y neg_error = element.dimension_values(2) @@ -168,10 +204,12 @@ class BarPlot(BarsMixin, ElementPlot): style_opts = ['visible'] - trace_kwargs = {'type': 'bar'} - selection_display = PlotlyOverlaySelectionDisplay() + @classmethod + def trace_kwargs(cls, is_geo=False, **kwargs): + return {'type': 'bar'} + def _get_axis_dims(self, element): if element.ndims > 1 and not self.stacked: xdims = element.kdims @@ -185,7 +223,7 @@ def get_extents(self, element, ranges, range_type='combined'): return x0, y0, x1, y1 return (None, y0, None, y1) - def get_data(self, element, ranges, style): + def get_data(self, element, ranges, style, **kwargs): # Get x, y, group, stack and color dimensions xdim = element.kdims[0] vdim = element.vdims[0] @@ -245,7 +283,7 @@ def get_data(self, element, ranges, style): y: element.dimension_values(vdim)}) return bars - def init_layout(self, key, element, ranges): + def init_layout(self, key, element, ranges, **kwargs): layout = super(BarPlot, self).init_layout(key, element, ranges) stack_dim = None if element.ndims > 1 and self.stacked: @@ -256,8 +294,6 @@ def init_layout(self, key, element, ranges): class HistogramPlot(ElementPlot): - trace_kwargs = {'type': 'bar'} - style_opts = [ 'visible', 'color', 'line_color', 'line_width', 'opacity', 'selectedpoints' ] @@ -266,7 +302,11 @@ class HistogramPlot(ElementPlot): selection_display = PlotlyOverlaySelectionDisplay() - def get_data(self, element, ranges, style): + @classmethod + def trace_kwargs(cls, is_geo=False, **kwargs): + return {'type': 'bar'} + + def get_data(self, element, ranges, style, **kwargs): xdim = element.kdims[0] ydim = element.vdims[0] values = element.interface.coords(element, ydim) @@ -286,7 +326,7 @@ def get_data(self, element, ranges, style): orientation = 'v' return [{'x': xs, 'y': ys, 'width': binwidth, 'orientation': orientation}] - def init_layout(self, key, element, ranges): + def init_layout(self, key, element, ranges, **kwargs): layout = super(HistogramPlot, self).init_layout(key, element, ranges) layout['barmode'] = 'overlay' return layout diff --git a/holoviews/plotting/plotly/chart3d.py b/holoviews/plotting/plotly/chart3d.py index d047cc27be..f8553485d8 100644 --- a/holoviews/plotting/plotly/chart3d.py +++ b/holoviews/plotting/plotly/chart3d.py @@ -32,7 +32,7 @@ class Chart3DPlot(ElementPlot): Ticks along z-axis specified as an integer, explicit list of tick locations, list of tuples containing the locations.""") - def get_data(self, element, ranges, style): + def get_data(self, element, ranges, style, **kwargs): return [dict(x=element.dimension_values(0), y=element.dimension_values(1), z=element.dimension_values(2))] @@ -40,18 +40,20 @@ def get_data(self, element, ranges, style): class SurfacePlot(Chart3DPlot, ColorbarPlot): - trace_kwargs = {'type': 'surface'} - style_opts = ['visible', 'alpha', 'lighting', 'lightposition', 'cmap'] selection_display = PlotlyOverlaySelectionDisplay(supports_region=False) - def graph_options(self, element, ranges, style): - opts = super(SurfacePlot, self).graph_options(element, ranges, style) + @classmethod + def trace_kwargs(cls, is_geo=False, **kwargs): + return {'type': 'surface'} + + def graph_options(self, element, ranges, style, **kwargs): + opts = super(SurfacePlot, self).graph_options(element, ranges, style, **kwargs) copts = self.get_color_opts(element.vdims[0], element, ranges, style) return dict(opts, **copts) - def get_data(self, element, ranges, style): + def get_data(self, element, ranges, style, **kwargs): return [dict(x=element.dimension_values(0, False), y=element.dimension_values(1, False), z=element.dimension_values(2, flat=False))] @@ -59,29 +61,35 @@ def get_data(self, element, ranges, style): class Scatter3DPlot(Chart3DPlot, ScatterPlot): - trace_kwargs = {'type': 'scatter3d', 'mode': 'markers'} - style_opts = [ 'visible', 'marker', 'color', 'cmap', 'alpha', 'opacity', 'size', 'sizemin' ] + _supports_geo = False + selection_display = PlotlyOverlaySelectionDisplay(supports_region=False) + @classmethod + def trace_kwargs(cls, is_geo=False, **kwargs): + return {'type': 'scatter3d', 'mode': 'markers'} -class Path3DPlot(Chart3DPlot, CurvePlot): - trace_kwargs = {'type': 'scatter3d', 'mode': 'lines'} +class Path3DPlot(Chart3DPlot, CurvePlot): _per_trace = True _nonvectorized_styles = [] - def graph_options(self, element, ranges, style): - opts = super(Path3DPlot, self).graph_options(element, ranges, style) + @classmethod + def trace_kwargs(cls, is_geo=False, **kwargs): + return {'type': 'scatter3d', 'mode': 'lines'} + + def graph_options(self, element, ranges, style, **kwargs): + opts = super(Path3DPlot, self).graph_options(element, ranges, style, **kwargs) opts['line'].pop('showscale', None) return opts - def get_data(self, element, ranges, style): + def get_data(self, element, ranges, style, **kwargs): return [dict(x=el.dimension_values(0), y=el.dimension_values(1), z=el.dimension_values(2)) for el in element.split()] @@ -93,7 +101,7 @@ class TriSurfacePlot(Chart3DPlot, ColorbarPlot): selection_display = PlotlyOverlaySelectionDisplay(supports_region=False) - def get_data(self, element, ranges, style): + def get_data(self, element, ranges, style, **kwargs): try: from scipy.spatial import Delaunay except: @@ -104,8 +112,10 @@ def get_data(self, element, ranges, style): simplices = tri.simplices return [dict(x=x, y=y, z=z, simplices=simplices)] - def graph_options(self, element, ranges, style): - opts = super(TriSurfacePlot, self).graph_options(element, ranges, style) + def graph_options(self, element, ranges, style, **kwargs): + opts = super(TriSurfacePlot, self).graph_options( + element, ranges, style, **kwargs + ) copts = self.get_color_opts(element.dimensions()[2], element, ranges, style) opts['colormap'] = [tuple(v/255. for v in colors.hex_to_rgb(c)) for _, c in copts['colorscale']] @@ -117,7 +127,7 @@ def graph_options(self, element, ranges, style): return {k: v for k, v in opts.items() if 'legend' not in k and k != 'name'} - def init_graph(self, datum, options, index=0): + def init_graph(self, datum, options, index=0, **kwargs): # Pop colorbar options since these aren't accepted by the trisurf # figure factory. diff --git a/holoviews/plotting/plotly/dash.py b/holoviews/plotting/plotly/dash.py index 1f96134f4c..2f0e734770 100644 --- a/holoviews/plotting/plotly/dash.py +++ b/holoviews/plotting/plotly/dash.py @@ -475,31 +475,39 @@ def update_figure(*args): graph_id = graph_ids[fig_ind] # plotly_stream_types for plotly_stream_type, uid_to_streams_for_type in uid_to_stream_ids.items(): - panel_prop = plotly_stream_type.callback_property - if panel_prop == "selected_data": - if graph_id + ".selectedData" in triggered_prop_ids: - # Only update selectedData values that just changed. - # This way we don't the the may have been cleared in the - # store above - stream_event_data = plotly_stream_type.get_event_data_from_property_update( - selected_dicts[fig_ind], initial_fig_dicts[fig_ind] - ) - for uid, event_data in stream_event_data.items(): - if uid in uid_to_streams_for_type: - for stream_id in uid_to_streams_for_type[uid]: - store_data["streams"][stream_id] = event_data - elif panel_prop == "viewport": - if graph_id + ".relayoutData" in triggered_prop_ids: - stream_event_data = plotly_stream_type.get_event_data_from_property_update( - relayout_dicts[fig_ind], initial_fig_dicts[fig_ind] - ) - - for uid, event_data in stream_event_data.items(): - if event_data["x_range"] is not None or event_data["y_range"] is not None: + for panel_prop in plotly_stream_type.callback_properties: + if panel_prop == "selected_data": + if graph_id + ".selectedData" in triggered_prop_ids: + # Only update selectedData values that just changed. + # This way we don't save values that may have been cleared + # from the store above by the reset button. + stream_event_data = plotly_stream_type.get_event_data_from_property_update( + panel_prop, selected_dicts[fig_ind], initial_fig_dicts[fig_ind] + ) + for uid, event_data in stream_event_data.items(): if uid in uid_to_streams_for_type: for stream_id in uid_to_streams_for_type[uid]: - store_data["streams"][ - stream_id] = event_data + store_data["streams"][stream_id] = event_data + elif panel_prop == "viewport": + if graph_id + ".relayoutData" in triggered_prop_ids: + stream_event_data = plotly_stream_type.get_event_data_from_property_update( + panel_prop, relayout_dicts[fig_ind], initial_fig_dicts[fig_ind] + ) + + for uid, event_data in stream_event_data.items(): + if event_data["x_range"] is not None or event_data["y_range"] is not None: + if uid in uid_to_streams_for_type: + for stream_id in uid_to_streams_for_type[uid]: + store_data["streams"][stream_id] = event_data + elif panel_prop == "relayout_data": + if graph_id + ".relayoutData" in triggered_prop_ids: + stream_event_data = plotly_stream_type.get_event_data_from_property_update( + panel_prop, relayout_dicts[fig_ind], initial_fig_dicts[fig_ind] + ) + for uid, event_data in stream_event_data.items(): + if uid in uid_to_streams_for_type: + for stream_id in uid_to_streams_for_type[uid]: + store_data["streams"][stream_id] = event_data # Update store with derived/history stream values for output_id in reversed(stream_callbacks): diff --git a/holoviews/plotting/plotly/element.py b/holoviews/plotting/plotly/element.py index 273c29f089..66efd8f9dc 100644 --- a/holoviews/plotting/plotly/element.py +++ b/holoviews/plotting/plotly/element.py @@ -5,6 +5,7 @@ import param import re +from ... import Tiles from ...core import util from ...core.element import Element from ...core.spaces import DynamicMap @@ -14,7 +15,7 @@ from ..util import dim_range_key from .plot import PlotlyPlot from .util import ( - STYLE_ALIASES, get_colorscale, merge_figure, legend_trace_types) + STYLE_ALIASES, get_colorscale, merge_figure, legend_trace_types, merge_layout) class ElementPlot(PlotlyPlot, GenericElementPlot): @@ -96,13 +97,14 @@ class ElementPlot(PlotlyPlot, GenericElementPlot): Ticks along z-axis specified as an integer, explicit list of tick locations, list of tuples containing the locations.""") - trace_kwargs = {} - _style_key = None # Whether vectorized styles are applied per trace _per_trace = False + # Whether plot type can be displayed on mapbox plot + _supports_geo = False + # Declare which styles cannot be mapped to a non-scalar dimension _nonvectorized_styles = [] @@ -112,13 +114,16 @@ def __init__(self, element, plot=None, **params): self.static = len(self.hmap) == 1 and len(self.keys) == len(self.hmap) self.callbacks = self._construct_callbacks() + @classmethod + def trace_kwargs(cls, **kwargs): + return {} - def initialize_plot(self, ranges=None): + def initialize_plot(self, ranges=None, is_geo=False): """ Initializes a new plot object with the last available frame. """ # Get element key and ranges for frame - fig = self.generate_plot(self.keys[-1], ranges) + fig = self.generate_plot(self.keys[-1], ranges, is_geo=is_geo) self.drawn = True trigger = self._trigger @@ -128,10 +133,16 @@ def initialize_plot(self, ranges=None): return fig - def generate_plot(self, key, ranges, element=None): + def generate_plot(self, key, ranges, element=None, is_geo=False): if element is None: element = self._get_frame(key) + if is_geo and not self._supports_geo: + raise ValueError( + "Elements of type {typ} cannot be overlaid with Tiles elements " + "using the plotly backend".format(typ=type(element)) + ) + if element is None: return self.handles['fig'] @@ -148,9 +159,24 @@ def generate_plot(self, key, ranges, element=None): self.style = self.lookup_options(element, 'style') style = self.style[self.cyclic_index] + # Validate style properties are supported in geo mode + if is_geo: + unsupported_opts = [ + style_opt for style_opt in style + if style_opt in self.unsupported_geo_style_opts + ] + if unsupported_opts: + raise ValueError( + "The following {typ} style options are not supported by the Plotly " + "backend when overlaid on Tiles:\n" + " {unsupported_opts}".format( + typ=type(element).__name__, unsupported_opts=unsupported_opts + ) + ) + # Get data and options and merge them - data = self.get_data(element, ranges, style) - opts = self.graph_options(element, ranges, style) + data = self.get_data(element, ranges, style, is_geo=is_geo) + opts = self.graph_options(element, ranges, style, is_geo=is_geo) components = { 'traces': [], @@ -161,7 +187,7 @@ def generate_plot(self, key, ranges, element=None): for i, d in enumerate(data): # Initialize traces - datum_components = self.init_graph(d, opts, index=i) + datum_components = self.init_graph(d, opts, index=i, is_geo=is_geo) # Handle traces traces = datum_components.get('traces', []) @@ -177,13 +203,21 @@ def generate_plot(self, key, ranges, element=None): for k in ['images', 'shapes', 'annotations']: components[k].extend(datum_components.get(k, [])) + # Handle mapbox + if "mapbox" in datum_components: + components["mapbox"] = datum_components["mapbox"] + self.handles['components'] = components # Initialize layout - layout = self.init_layout(key, element, ranges) + layout = self.init_layout(key, element, ranges, is_geo=is_geo) for k in ['images', 'shapes', 'annotations']: layout.setdefault(k, []) layout[k].extend(components.get(k, [])) + + if "mapbox" in components: + merge_layout(layout.setdefault("mapbox", {}), components["mapbox"]) + self.handles['layout'] = layout # Create figure and return it @@ -197,7 +231,7 @@ def generate_plot(self, key, ranges, element=None): return fig - def graph_options(self, element, ranges, style): + def graph_options(self, element, ranges, style, is_geo=False, **kwargs): if self.overlay_dims: legend = ', '.join([d.pprint_value_string(v) for d, v in self.overlay_dims.items()]) @@ -205,9 +239,9 @@ def graph_options(self, element, ranges, style): legend = element.label opts = dict( - name=legend, **self.trace_kwargs) + name=legend, **self.trace_kwargs(is_geo=is_geo)) - if self.trace_kwargs.get('type', None) in legend_trace_types: + if self.trace_kwargs(is_geo=is_geo).get('type', None) in legend_trace_types: opts.update( showlegend=self.show_legend, legendgroup=element.group) @@ -234,7 +268,7 @@ def graph_options(self, element, ranges, style): return opts - def init_graph(self, datum, options, index=0): + def init_graph(self, datum, options, index=0, **kwargs): """ Initialize the plotly components that will represent the element @@ -273,7 +307,7 @@ def init_graph(self, datum, options, index=0): return {'traces': [trace]} - def get_data(self, element, ranges, style): + def get_data(self, element, ranges, style, is_geo=False): return [] @@ -346,10 +380,19 @@ def _apply_transforms(self, element, ranges, style): return new_style - def init_layout(self, key, element, ranges): + def init_layout(self, key, element, ranges, is_geo=False): el = element.traverse(lambda x: x, [Element]) el = el[0] if el else element + layout = dict( + title=self._format_title(key, separator=' '), + plot_bgcolor=self.bgcolor, uirevision=True + ) + + if not self.responsive: + layout['width'] = self.width + layout['height'] = self.height + extent = self.get_extents(element, ranges) if len(extent) == 4: @@ -357,8 +400,6 @@ def init_layout(self, key, element, ranges): else: l, b, z0, r, t, z1 = extent - options = {'uirevision': True} - dims = self._get_axis_dims(el) if len(dims) > 2: xdim, ydim, zdim = dims @@ -368,6 +409,11 @@ def init_layout(self, key, element, ranges): xlabel, ylabel, zlabel = self._get_axis_labels(dims) if self.invert_axes: + if is_geo: + raise ValueError( + "The invert_axes parameter is not supported on Tiles elements " + "with the plotly backend" + ) xlabel, ylabel = ylabel, xlabel ydim, xdim = xdim, ydim l, b, r, t = b, l, t, r @@ -379,7 +425,8 @@ def init_layout(self, key, element, ranges): if 'z' not in self.labelled: zlabel = '' - if xdim: + xaxis = {} + if xdim and not is_geo: try: if any(np.isnan([r, l])): r, l = 0, 1 @@ -414,10 +461,9 @@ def init_layout(self, key, element, ranges): xaxis['side'] = 'top' else: xaxis['side'] = 'bottom' - else: - xaxis = {} - if ydim: + yaxis = {} + if ydim and not is_geo: try: if any(np.isnan([b, t])): b, t = 0, 1 @@ -452,8 +498,31 @@ def init_layout(self, key, element, ranges): else: yaxis['side'] = 'left' - else: - yaxis = {} + if is_geo: + mapbox = {} + if all(np.isfinite(v) for v in (l, b, r, t)): + x_center = (l + r) / 2.0 + y_center = (b + t) / 2.0 + lons, lats = Tiles.easting_northing_to_lon_lat([x_center], [y_center]) + + mapbox["center"] = dict(lat=lats[0], lon=lons[0]) + + # Compute zoom level + margin_left, margin_bottom, margin_right, margin_top = self.margins + viewport_width = self.width - margin_left - margin_right + viewport_height = self.height - margin_top - margin_bottom + mapbox_tile_size = 512 + + max_delta = 2 * np.pi * 6378137 + x_delta = r - l + y_delta = t - b + + max_x_zoom = (np.log2(max_delta / x_delta) - + np.log2(mapbox_tile_size / viewport_width)) + max_y_zoom = (np.log2(max_delta / y_delta) - + np.log2(mapbox_tile_size / viewport_height)) + mapbox["zoom"] = min(max_x_zoom, max_y_zoom) + layout["mapbox"] = mapbox if self.projection == '3d': scene = dict(xaxis=xaxis, yaxis=yaxis) @@ -469,19 +538,15 @@ def init_layout(self, key, element, ranges): else: scene['aspectmode'] = 'manual' scene['aspectratio'] = self.aspect - options['scene'] = scene + layout['scene'] = scene else: l, b, r, t = self.margins - options['xaxis'] = xaxis - options['yaxis'] = yaxis - options['margin'] = dict(l=l, r=r, b=b, t=t, pad=4) + layout['margin'] = dict(l=l, r=r, b=b, t=t, pad=4) + if not is_geo: + layout['xaxis'] = xaxis + layout['yaxis'] = yaxis - if not self.responsive: - options['width'] = self.width - options['height'] = self.height - - return dict(title=self._format_title(key, separator=' '), - plot_bgcolor=self.bgcolor, **options) + return layout def _get_ticks(self, axis, ticker): axis_props = {} @@ -496,12 +561,12 @@ def _get_ticks(self, axis, ticker): axis_props['tickvals'] = ticker axis.update(axis_props) - def update_frame(self, key, ranges=None, element=None): + def update_frame(self, key, ranges=None, element=None, is_geo=False): """ Updates an existing plot with data corresponding to the key. """ - self.generate_plot(key, ranges, element) + self.generate_plot(key, ranges, element, is_geo=is_geo) class ColorbarPlot(ElementPlot): @@ -591,15 +656,14 @@ class OverlayPlot(GenericOverlayPlot, ElementPlot): 'invert_xaxis', 'invert_yaxis', 'sizing_mode', 'title', 'title_format', 'padding', 'xlabel', 'ylabel', 'zlabel', 'xlim', 'ylim', 'zlim'] - def initialize_plot(self, ranges=None): + def initialize_plot(self, ranges=None, is_geo=False): """ Initializes a new plot object with the last available frame. """ # Get element key and ranges for frame - return self.generate_plot(list(self.hmap.data.keys())[0], ranges) - + return self.generate_plot(list(self.hmap.data.keys())[0], ranges, is_geo=is_geo) - def generate_plot(self, key, ranges, element=None): + def generate_plot(self, key, ranges, element=None, is_geo=False): if element is None: element = self._get_frame(key) items = [] if element is None else list(element.data.items()) @@ -614,6 +678,15 @@ def generate_plot(self, key, ranges, element=None): ranges = self.compute_ranges(self.hmap, key, ranges) figure = None + + # Check if elements should be overlayed in geographic coordinates using mapbox + # + # Pass this through to generate_plot to build geo version of plot + for _, el in items: + if isinstance(el, Tiles): + is_geo = True + break + for okey, subplot in self.subplots.items(): if element is not None and subplot.drawn: idx, spec, exact = self._match_subplot(okey, subplot, items, element) @@ -624,20 +697,24 @@ def generate_plot(self, key, ranges, element=None): else: el = None - fig = subplot.generate_plot(key, ranges, el) + # propagate plot options to subplots + subplot.param.set_param(**plot_opts) + + fig = subplot.generate_plot(key, ranges, el, is_geo=is_geo) if figure is None: figure = fig else: merge_figure(figure, fig) - layout = self.init_layout(key, element, ranges) - figure['layout'].update(layout) + layout = self.init_layout(key, element, ranges, is_geo=is_geo) + merge_layout(figure['layout'], layout) self.drawn = True self.handles['fig'] = figure return figure - def update_frame(self, key, ranges=None, element=None): + def update_frame(self, key, ranges=None, element=None, is_geo=False): + reused = isinstance(self.hmap, DynamicMap) and self.overlaid if not reused and element is None: element = self._get_frame(key) @@ -646,6 +723,10 @@ def update_frame(self, key, ranges=None, element=None): self.current_key = key items = [] if element is None else list(element.data.items()) + for _, el in items: + if isinstance(el, Tiles): + is_geo = True + # Instantiate dynamically added subplots for k, subplot in self.subplots.items(): # If in Dynamic mode propagate elements to subplots @@ -657,4 +738,4 @@ def update_frame(self, key, ranges=None, element=None): if isinstance(self.hmap, DynamicMap) and items: self._create_dynamic_subplots(key, items, ranges) - self.generate_plot(key, ranges, element) + self.generate_plot(key, ranges, element, is_geo=is_geo) diff --git a/holoviews/plotting/plotly/images.py b/holoviews/plotting/plotly/images.py index 5c71128c3f..2f55b9dc12 100644 --- a/holoviews/plotting/plotly/images.py +++ b/holoviews/plotting/plotly/images.py @@ -5,6 +5,7 @@ from plotly.graph_objs.layout import Image as _Image from ...core.util import VersionError +from ...element import Tiles from .element import ElementPlot from .selection import PlotlyOverlaySelectionDisplay @@ -17,28 +18,42 @@ class RGBPlot(ElementPlot): selection_display = PlotlyOverlaySelectionDisplay() - def init_graph(self, datum, options, index=0): - image = dict(datum, **options) - - # Create a dummy invisible scatter trace for this image. - # This serves two purposes - # 1. The two points placed on the corners of the image are used by the - # autoscale logic to allow using the autoscale button to property center - # the image. - # 2. This trace will be given a UID, and this UID will make it possible to - # associate callbacks with the image element. This is needed, in particular - # to support datashader - dummy_trace = { - 'type': 'scatter', - 'x': [image['x'], image['x'] + image['sizex']], - 'y': [image['y'] - image['sizey'], image['y']], - 'mode': 'markers', - 'marker': {'opacity': 0} - } - return dict(images=[image], - traces=[dummy_trace]) - - def get_data(self, element, ranges, style): + _supports_geo = True + + def init_graph(self, datum, options, index=0, is_geo=False, **kwargs): + if is_geo: + layer = dict(datum, **options) + dummy_trace = { + 'type': 'scattermapbox', + 'lat': [None], + 'lon': [None], + 'mode': 'markers', + 'showlegend': False + } + return dict(mapbox=dict(layers=[layer]), + traces=[dummy_trace]) + else: + image = dict(datum, **options) + # Create a dummy invisible scatter trace for this image. + # This serves two purposes + # 1. The two points placed on the corners of the image are used by the + # autoscale logic to allow using the autoscale button to properly center + # the image. + # 2. This trace will be given a UID, and this UID will make it possible to + # associate callbacks with the image element. This is needed, in particular + # to support datashader + dummy_trace = { + 'type': 'scatter', + 'x': [image['x'], image['x'] + image['sizex']], + 'y': [image['y'] - image['sizey'], image['y']], + 'mode': 'markers', + 'marker': {'opacity': 0}, + "showlegend": False, + } + return dict(images=[image], + traces=[dummy_trace]) + + def get_data(self, element, ranges, style, is_geo=False, **kwargs): try: import PIL.Image except ImportError: @@ -88,12 +103,29 @@ def get_data(self, element, ranges, style): source = _Image(source=pil_img).source - return [dict(source=source, - x=l, - y=t, - sizex=r - l, - sizey=t - b, - xref='x', - yref='y', - sizing='stretch', - layer='above')] + if is_geo: + lon_left, lat_top = Tiles.easting_northing_to_lon_lat(l, t) + lon_right, lat_bottom = Tiles.easting_northing_to_lon_lat(r, b) + coordinates = [ + [lon_left, lat_top], + [lon_right, lat_top], + [lon_right, lat_bottom], + [lon_left, lat_bottom], + ] + layer = { + "sourcetype": "image", + "source": source, + "coordinates": coordinates, + "below": 'traces', + } + return [layer] + else: + return [dict(source=source, + x=l, + y=t, + sizex=r - l, + sizey=t - b, + xref='x', + yref='y', + sizing='stretch', + layer='above')] diff --git a/holoviews/plotting/plotly/plot.py b/holoviews/plotting/plotly/plot.py index f2e088fa67..bd42f679da 100644 --- a/holoviews/plotting/plotly/plot.py +++ b/holoviews/plotting/plotly/plot.py @@ -23,6 +23,8 @@ class PlotlyPlot(DimensionedPlot, CallbackPlot): height = param.Integer(default=400) + unsupported_geo_style_opts = [] + @property def state(self): """ @@ -41,12 +43,12 @@ def _trigger_refresh(self, key): self.current_frame = None - def initialize_plot(self, ranges=None): - return self.generate_plot(self.keys[-1], ranges) + def initialize_plot(self, ranges=None, is_geo=False): + return self.generate_plot(self.keys[-1], ranges, is_geo=is_geo) - def update_frame(self, key, ranges=None): - return self.generate_plot(key, ranges) + def update_frame(self, key, ranges=None, is_geo=False): + return self.generate_plot(key, ranges, is_geo=is_geo) @@ -193,14 +195,14 @@ def _create_subplots(self, layout, positions, layout_dimensions, ranges, num=0): return subplots, adjoint_clone - def generate_plot(self, key, ranges=None): + def generate_plot(self, key, ranges=None, is_geo=False): ranges = self.compute_ranges(self.layout, self.keys[-1], None) plots = [[] for i in range(self.rows)] insert_rows = [] for r, c in self.coords: subplot = self.subplots.get((r, c), None) if subplot is not None: - subplots = subplot.generate_plot(key, ranges=ranges) + subplots = subplot.generate_plot(key, ranges=ranges, is_geo=is_geo) # Computes plotting offsets depending on # number of adjoined plots @@ -253,7 +255,7 @@ def __init__(self, layout, layout_type, subplots, **params): super(AdjointLayoutPlot, self).__init__(subplots=subplots, **params) - def initialize_plot(self, ranges=None): + def initialize_plot(self, ranges=None, is_geo=False): """ Plot all the views contained in the AdjointLayout Object using axes appropriate to the layout configuration. All the axes are @@ -261,17 +263,19 @@ def initialize_plot(self, ranges=None): invoke subplots with correct options and styles and hide any empty axes as necessary. """ - return self.generate_plot(self.keys[-1], ranges) + return self.generate_plot(self.keys[-1], ranges, is_geo=is_geo) - def generate_plot(self, key, ranges=None): + def generate_plot(self, key, ranges=None, is_geo=False): adjoined_plots = [] for pos in ['main', 'right', 'top']: # Pos will be one of 'main', 'top' or 'right' or None subplot = self.subplots.get(pos, None) # If no view object or empty position, disable the axis if subplot: - adjoined_plots.append(subplot.generate_plot(key, ranges=ranges)) + adjoined_plots.append( + subplot.generate_plot(key, ranges=ranges, is_geo=is_geo) + ) if not adjoined_plots: adjoined_plots = [None] return adjoined_plots @@ -345,14 +349,14 @@ def _create_subplots(self, layout, ranges): return subplots, collapsed_layout - def generate_plot(self, key, ranges=None): + def generate_plot(self, key, ranges=None, is_geo=False): ranges = self.compute_ranges(self.layout, self.keys[-1], None) plots = [[] for r in range(self.cols)] for i, coord in enumerate(self.layout.keys(full_grid=True)): r = i % self.cols subplot = self.subplots.get(wrap_tuple(coord), None) if subplot is not None: - plot = subplot.initialize_plot(ranges=ranges) + plot = subplot.initialize_plot(ranges=ranges, is_geo=is_geo) plots[r].append(plot) else: plots[r].append(None) diff --git a/holoviews/plotting/plotly/raster.py b/holoviews/plotting/plotly/raster.py index b31a0c4371..dd361c2138 100644 --- a/holoviews/plotting/plotly/raster.py +++ b/holoviews/plotting/plotly/raster.py @@ -15,17 +15,19 @@ class RasterPlot(ColorbarPlot): style_opts = ['visible', 'cmap', 'alpha'] - trace_kwargs = {'type': 'heatmap'} + @classmethod + def trace_kwargs(cls, is_geo=False, **kwargs): + return {'type': 'heatmap'} - def graph_options(self, element, ranges, style): - opts = super(RasterPlot, self).graph_options(element, ranges, style) + def graph_options(self, element, ranges, style, **kwargs): + opts = super(RasterPlot, self).graph_options(element, ranges, style, **kwargs) copts = self.get_color_opts(element.vdims[0], element, ranges, style) opts['zmin'] = copts.pop('cmin') opts['zmax'] = copts.pop('cmax') opts['zauto'] = copts.pop('cauto') return dict(opts, **copts) - def get_data(self, element, ranges, style): + def get_data(self, element, ranges, style, **kwargs): if isinstance(element, Image): l, b, r, t = element.bounds.lbrt() else: @@ -44,7 +46,7 @@ def get_data(self, element, ranges, style): class HeatMapPlot(HeatMapMixin, RasterPlot): - def init_layout(self, key, element, ranges): + def init_layout(self, key, element, ranges, **kwargs): layout = super(HeatMapPlot, self).init_layout(key, element, ranges) gridded = element.gridded xdim, ydim = gridded.dimensions()[:2] @@ -67,7 +69,7 @@ def init_layout(self, key, element, ranges): layout[yaxis]['ticktext'] = gridded.dimension_values(1, expanded=False) return layout - def get_data(self, element, ranges, style): + def get_data(self, element, ranges, style, **kwargs): if not element._unique: self.param.warning('HeatMap element index is not unique, ensure you ' 'aggregate the data before displaying it, e.g. ' @@ -99,7 +101,7 @@ def get_data(self, element, ranges, style): class QuadMeshPlot(RasterPlot): - def get_data(self, element, ranges, style): + def get_data(self, element, ranges, style, **kwargs): x, y, z = element.dimensions()[:3] irregular = element.interface.irregular(element, x) if irregular: diff --git a/holoviews/plotting/plotly/renderer.py b/holoviews/plotting/plotly/renderer.py index 723946a1e0..a2df048a43 100644 --- a/holoviews/plotting/plotly/renderer.py +++ b/holoviews/plotting/plotly/renderer.py @@ -36,10 +36,14 @@ def _PlotlyHoloviewsPane(fig_dict, **kwargs): # Register callbacks on pane for callback_cls in callbacks.values(): - plotly_pane.param.watch( - lambda event, cls=callback_cls: cls.update_streams_from_property_update(event.new, event.obj.object), - callback_cls.callback_property, - ) + for callback_prop in callback_cls.callback_properties: + plotly_pane.param.watch( + lambda event, cls=callback_cls, prop=callback_prop: + cls.update_streams_from_property_update( + prop, event.new, event.obj.object + ), + callback_prop, + ) return plotly_pane @@ -88,7 +92,6 @@ def get_plot_state(self_or_cls, obj, doc=None, renderer=None, **kwargs): fig_dict.get('layout', {}).pop('template', None) return fig_dict - def _figure_data(self, plot, fmt, as_script=False, **kwargs): if fmt == 'gif': import plotly.io as pio diff --git a/holoviews/plotting/plotly/shapes.py b/holoviews/plotting/plotly/shapes.py index 19cc1c7ff0..71b733ec9a 100644 --- a/holoviews/plotting/plotly/shapes.py +++ b/holoviews/plotting/plotly/shapes.py @@ -1,8 +1,9 @@ from __future__ import absolute_import, division, unicode_literals import param +import numpy as np -from ...element import HLine, VLine, HSpan, VSpan +from ...element import HLine, VLine, HSpan, VSpan, Tiles from ..mixins import GeomMixin from .element import ElementPlot @@ -13,9 +14,26 @@ class ShapePlot(ElementPlot): _shape_type = None style_opts = ['opacity', 'fillcolor', 'line_color', 'line_width', 'line_dash'] - def init_graph(self, datum, options, index=0): - shape = dict(type=self._shape_type, **dict(datum, **options)) - return dict(shapes=[shape]) + _supports_geo = True + + def init_graph(self, datum, options, index=0, is_geo=False, **kwargs): + if is_geo: + trace = { + 'type': 'scattermapbox', + 'mode': 'lines', + 'showlegend': False, + 'hoverinfo': 'skip', + } + trace.update(datum, **options) + + # Turn on self fill if a fillcolor is specified + if options.get("fillcolor", None): + trace["fill"] = "toself" + + return dict(traces=[trace]) + else: + shape = dict(type=self._shape_type, **dict(datum, **options)) + return dict(shapes=[shape]) @staticmethod def build_path(xs, ys, closed=True): @@ -33,27 +51,64 @@ def build_path(xs, ys, closed=True): class BoxShapePlot(GeomMixin, ShapePlot): _shape_type = 'rect' - def get_data(self, element, ranges, style): + def get_data(self, element, ranges, style, is_geo=False, **kwargs): inds = (1, 0, 3, 2) if self.invert_axes else (0, 1, 2, 3) x0s, y0s, x1s, y1s = (element.dimension_values(kd) for kd in inds) - return [dict(x0=x0, x1=x1, y0=y0, y1=y1, xref='x', yref='y') - for (x0, y0, x1, y1) in zip(x0s, y0s, x1s, y1s)] + + if is_geo: + if len(x0s) == 0: + lat = [] + lon = [] + else: + lon0s, lat0s = Tiles.easting_northing_to_lon_lat(easting=x0s, northing=y0s) + lon1s, lat1s = Tiles.easting_northing_to_lon_lat(easting=x1s, northing=y1s) + + lon_chunks, lat_chunks = zip(*[ + ([lon0, lon0, lon1, lon1, lon0, np.nan], + [lat0, lat1, lat1, lat0, lat0, np.nan]) + for (lon0, lat0, lon1, lat1) in zip(lon0s, lat0s, lon1s, lat1s) + ]) + + lon = np.concatenate(lon_chunks) + lat = np.concatenate(lat_chunks) + return [{"lat": lat, "lon": lon}] + else: + return [dict(x0=x0, x1=x1, y0=y0, y1=y1, xref='x', yref='y') + for (x0, y0, x1, y1) in zip(x0s, y0s, x1s, y1s)] class SegmentShapePlot(GeomMixin, ShapePlot): _shape_type = 'line' - def get_data(self, element, ranges, style): + def get_data(self, element, ranges, style, is_geo=False, **kwargs): inds = (1, 0, 3, 2) if self.invert_axes else (0, 1, 2, 3) x0s, y0s, x1s, y1s = (element.dimension_values(kd) for kd in inds) - return [dict(x0=x0, x1=x1, y0=y0, y1=y1, xref='x', yref='y') - for (x0, y0, x1, y1) in zip(x0s, y0s, x1s, y1s)] - + if is_geo: + if len(x0s) == 0: + lat = [] + lon = [] + else: + lon0s, lat0s = Tiles.easting_northing_to_lon_lat(easting=x0s, northing=y0s) + lon1s, lat1s = Tiles.easting_northing_to_lon_lat(easting=x1s, northing=y1s) + + lon_chunks, lat_chunks = zip(*[ + ([lon0, lon1, np.nan], + [lat0, lat1, np.nan]) + for (lon0, lat0, lon1, lat1) in zip(lon0s, lat0s, lon1s, lat1s) + ]) + + lon = np.concatenate(lon_chunks) + lat = np.concatenate(lat_chunks) + return [{"lat": lat, "lon": lon}] + else: + return [dict(x0=x0, x1=x1, y0=y0, y1=y1, xref='x', yref='y') + for (x0, y0, x1, y1) in zip(x0s, y0s, x1s, y1s)] + class PathShapePlot(ShapePlot): _shape_type = 'path' - def get_data(self, element, ranges, style): + def get_data(self, element, ranges, style, is_geo=False, **kwargs): if self.invert_axes: ys = element.dimension_values(0) xs = element.dimension_values(1) @@ -61,32 +116,55 @@ def get_data(self, element, ranges, style): xs = element.dimension_values(0) ys = element.dimension_values(1) - path = ShapePlot.build_path(xs, ys) - return [dict(path=path, xref='x', yref='y')] + if is_geo: + lon, lat = Tiles.easting_northing_to_lon_lat(easting=xs, northing=ys) + return [{"lat": lat, "lon": lon}] + else: + path = ShapePlot.build_path(xs, ys) + return [dict(path=path, xref='x', yref='y')] class PathsPlot(ShapePlot): _shape_type = 'path' - def get_data(self, element, ranges, style): - paths = [] - for el in element.split(): - xdim, ydim = (1, 0) if self.invert_axes else (0, 1) - xs = el.dimension_values(xdim) - ys = el.dimension_values(ydim) - path = ShapePlot.build_path(xs, ys) - paths.append(dict(path=path, xref='x', yref='y')) - return paths + def get_data(self, element, ranges, style, is_geo=False, **kwargs): + if is_geo: + lon_chunks = [] + lat_chunks = [] + for el in element.split(): + xdim, ydim = (1, 0) if self.invert_axes else (0, 1) + xs = el.dimension_values(xdim) + ys = el.dimension_values(ydim) + el_lon, el_lat = Tiles.easting_northing_to_lon_lat(xs, ys) + lon_chunks.extend([el_lon, [np.nan]]) + lat_chunks.extend([el_lat, [np.nan]]) + if lon_chunks: + lon = np.concatenate(lon_chunks) + lat = np.concatenate(lat_chunks) + else: + lon = [] + lat = [] + return [{"lat": lat, "lon": lon}] + else: + paths = [] + for el in element.split(): + xdim, ydim = (1, 0) if self.invert_axes else (0, 1) + xs = el.dimension_values(xdim) + ys = el.dimension_values(ydim) + path = ShapePlot.build_path(xs, ys) + paths.append(dict(path=path, xref='x', yref='y')) + return paths class HVLinePlot(ShapePlot): apply_ranges = param.Boolean(default=False, doc=""" Whether to include the annotation in axis range calculations.""") - + _shape_type = 'line' + _supports_geo = False - def get_data(self, element, ranges, style): + def get_data(self, element, ranges, style, **kwargs): if ((isinstance(element, HLine) and self.invert_axes) or (isinstance(element, VLine) and not self.invert_axes)): x = element.data @@ -103,14 +181,15 @@ def get_data(self, element, ranges, style): class HVSpanPlot(ShapePlot): - + apply_ranges = param.Boolean(default=False, doc=""" Whether to include the annotation in axis range calculations.""") _shape_type = 'rect' + _supports_geo = False + + def get_data(self, element, ranges, style, **kwargs): - def get_data(self, element, ranges, style): - if ((isinstance(element, HSpan) and self.invert_axes) or (isinstance(element, VSpan) and not self.invert_axes)): x0, x1 = element.data diff --git a/holoviews/plotting/plotly/stats.py b/holoviews/plotting/plotly/stats.py index b17e93b740..8dd8bb9af7 100644 --- a/holoviews/plotting/plotly/stats.py +++ b/holoviews/plotting/plotly/stats.py @@ -13,16 +13,18 @@ class BivariatePlot(ChartPlot, ColorbarPlot): ncontours = param.Integer(default=None) - trace_kwargs = {'type': 'histogram2dcontour'} - style_opts = ['visible', 'cmap', 'showlabels', 'labelfont', 'labelformat', 'showlines'] _style_key = 'contours' selection_display = PlotlyOverlaySelectionDisplay() - def graph_options(self, element, ranges, style): - opts = super(BivariatePlot, self).graph_options(element, ranges, style) + @classmethod + def trace_kwargs(cls, is_geo=False, **kwargs): + return {'type': 'histogram2dcontour'} + + def graph_options(self, element, ranges, style, **kwargs): + opts = super(BivariatePlot, self).graph_options(element, ranges, style, **kwargs) copts = self.get_color_opts(element.vdims[0], element, ranges, style) if self.ncontours: @@ -66,19 +68,21 @@ class DistributionPlot(ElementPlot): style_opts = ['visible', 'color', 'dash', 'line_width'] - trace_kwargs = {'type': 'scatter', 'mode': 'lines'} - _style_key = 'line' selection_display = PlotlyOverlaySelectionDisplay() + @classmethod + def trace_kwargs(cls, is_geo=False, **kwargs): + return {'type': 'scatter', 'mode': 'lines'} + class MultiDistributionPlot(ElementPlot): def _get_axis_dims(self, element): return element.kdims, element.vdims[0] - def get_data(self, element, ranges, style): + def get_data(self, element, ranges, style, **kwargs): if element.kdims: groups = element.groupby(element.kdims).items() else: @@ -122,14 +126,16 @@ class BoxWhiskerPlot(MultiDistributionPlot): style_opts = ['visible', 'color', 'alpha', 'outliercolor', 'marker', 'size'] - trace_kwargs = {'type': 'box'} - _style_key = 'marker' selection_display = PlotlyOverlaySelectionDisplay() - def graph_options(self, element, ranges, style): - options = super(BoxWhiskerPlot, self).graph_options(element, ranges, style) + @classmethod + def trace_kwargs(cls, is_geo=False, **kwargs): + return {'type': 'box'} + + def graph_options(self, element, ranges, style, **kwargs): + options = super(BoxWhiskerPlot, self).graph_options(element, ranges, style, **kwargs) options['boxmean'] = self.mean options['jitter'] = self.jitter return options @@ -148,12 +154,16 @@ class ViolinPlot(MultiDistributionPlot): style_opts = ['visible', 'color', 'alpha', 'outliercolor', 'marker', 'size'] - trace_kwargs = {'type': 'violin'} - _style_key = 'marker' - def graph_options(self, element, ranges, style): - options = super(ViolinPlot, self).graph_options(element, ranges, style) + @classmethod + def trace_kwargs(cls, is_geo=False, **kwargs): + return {'type': 'violin'} + + def graph_options(self, element, ranges, style, **kwargs): + options = super(ViolinPlot, self).graph_options( + element, ranges, style, **kwargs + ) options['meanline'] = {'visible': self.meanline} options['box'] = {'visible': self.box} return options diff --git a/holoviews/plotting/plotly/tabular.py b/holoviews/plotting/plotly/tabular.py index b9d12ad65a..26889ed01e 100644 --- a/holoviews/plotting/plotly/tabular.py +++ b/holoviews/plotting/plotly/tabular.py @@ -12,22 +12,24 @@ class TablePlot(ElementPlot): width = param.Number(default=400) - trace_kwargs = {'type': 'table'} - style_opts = ['visible', 'line', 'fill', 'align', 'font', 'cell_height'] _style_key = 'cells' selection_display = ColorListSelectionDisplay(color_prop='fill', backend='plotly') - def get_data(self, element, ranges, style): + @classmethod + def trace_kwargs(cls, is_geo=False, **kwargs): + return {'type': 'table'} + + def get_data(self, element, ranges, style, **kwargs): header = dict(values=[d.pprint_label for d in element.dimensions()]) cells = dict(values=[[d.pprint_value(v) for v in element.dimension_values(d)] for d in element.dimensions()]) return [{'header': header, 'cells': cells}] - def graph_options(self, element, ranges, style): - opts = super(TablePlot, self).graph_options(element, ranges, style) + def graph_options(self, element, ranges, style, **kwargs): + opts = super(TablePlot, self).graph_options(element, ranges, style, **kwargs) # Transpose fill_color array so values apply by rows not column if 'fill' in opts.get('cells', {}): @@ -38,7 +40,7 @@ def graph_options(self, element, ranges, style): return opts - def init_layout(self, key, element, ranges): + def init_layout(self, key, element, ranges, **kwargs): return dict(width=self.width, height=self.height, title=self._format_title(key, separator=' '), plot_bgcolor=self.bgcolor) diff --git a/holoviews/plotting/plotly/tiles.py b/holoviews/plotting/plotly/tiles.py new file mode 100644 index 0000000000..6cefeef26d --- /dev/null +++ b/holoviews/plotting/plotly/tiles.py @@ -0,0 +1,64 @@ +from holoviews.plotting.plotly import ElementPlot +from holoviews.plotting.plotly.util import STYLE_ALIASES +import numpy as np +from holoviews.element.tiles import _ATTRIBUTIONS + + +class TilePlot(ElementPlot): + style_opts = ['min_zoom', 'max_zoom', "alpha", "accesstoken", "mapboxstyle"] + + _supports_geo = True + + @classmethod + def trace_kwargs(cls, **kwargs): + return {'type': 'scattermapbox'} + + def get_data(self, element, ranges, style, **kwargs): + return [{ + "type": "scattermapbox", "lat": [], "lon": [], "subplot": "mapbox", + "showlegend": False, + }] + + def graph_options(self, element, ranges, style, **kwargs): + style = dict(style) + opts = dict( + style=style.pop("mapboxstyle", "white-bg"), + accesstoken=style.pop("accesstoken", None), + ) + # Extract URL and lower case wildcard characters for mapbox + url = element.data + if url: + layer = {} + opts["layers"] = [layer] + for v in ["X", "Y", "Z"]: + url = url.replace("{%s}" % v, "{%s}" % v.lower()) + layer["source"] = [url] + layer["below"] = 'traces' + layer["sourcetype"] = "raster" + # Remaining style options are layer options + layer.update({STYLE_ALIASES.get(k, k): v for k, v in style.items()}) + + for key, attribution in _ATTRIBUTIONS.items(): + if all(k in element.data for k in key): + layer['sourceattribution'] = attribution + + return opts + + def get_extents(self, element, ranges, range_type='combined'): + extents = super(TilePlot, self).get_extents(element, ranges, range_type) + if (not self.overlaid and all(e is None or not np.isfinite(e) for e in extents) + and range_type in ('combined', 'data')): + x0, x1 = (-20037508.342789244, 20037508.342789244) + y0, y1 = (-20037508.342789255, 20037508.342789244) + global_extent = (x0, y0, x1, y1) + return global_extent + return extents + + def init_graph(self, datum, options, index=0, **kwargs): + return {'traces': [datum], "mapbox": options} + + def generate_plot(self, key, ranges, element=None, is_geo=False): + """ + Override to force is_geo to True + """ + return super(TilePlot, self).generate_plot(key, ranges, element, is_geo=True) diff --git a/holoviews/plotting/plotly/util.py b/holoviews/plotting/plotly/util.py index b425a061ea..ae53217299 100644 --- a/holoviews/plotting/plotly/util.py +++ b/holoviews/plotting/plotly/util.py @@ -113,7 +113,10 @@ # Aliases - map common style options to more common names STYLE_ALIASES = {'alpha': 'opacity', - 'cell_height': 'height', 'marker': 'symbol'} + 'cell_height': 'height', + 'marker': 'symbol', + "max_zoom": "maxzoom", + "min_zoom": "minzoom",} # Regular expression to extract any trailing digits from a subplot-style # string. @@ -559,10 +562,10 @@ def merge_figure(fig, subfig): # layout layout = fig.setdefault('layout', {}) - _merge_layout_objs(layout, subfig.get('layout', {})) + merge_layout(layout, subfig.get('layout', {})) -def _merge_layout_objs(obj, subobj): +def merge_layout(obj, subobj): """ Merge layout objects recursively @@ -579,7 +582,7 @@ def _merge_layout_objs(obj, subobj): for prop, val in subobj.items(): if isinstance(val, dict) and prop in obj: # recursion - _merge_layout_objs(obj[prop], val) + merge_layout(obj[prop], val) elif (isinstance(val, list) and obj.get(prop, None) and isinstance(obj[prop][0], dict)): @@ -587,8 +590,14 @@ def _merge_layout_objs(obj, subobj): # append obj[prop].extend(val) else: - # init/overwrite - obj[prop] = copy.deepcopy(val) + # Handle special cases + if prop == "style" and val == "white-bg" and obj.get("style", None): + # Don't let layout.mapbox.style of "white-bg" override other + # background + pass + elif val is not None: + # init/overwrite + obj[prop] = copy.deepcopy(val) def _compute_subplot_domains(widths, spacing): diff --git a/holoviews/tests/element/testtiles.py b/holoviews/tests/element/testtiles.py new file mode 100644 index 0000000000..ecdbfa0bc9 --- /dev/null +++ b/holoviews/tests/element/testtiles.py @@ -0,0 +1,114 @@ +import numpy as np +import pandas as pd +from holoviews import Tiles + +from holoviews.element.comparison import ComparisonTestCase + + +class TestCoordinateConversion(ComparisonTestCase): + def test_spot_check_lonlat_to_eastingnorthing(self): + # Anchor implementation with a few hard-coded known values. + # Generated ad-hoc from https://epsg.io/transform#s_srs=4326&t_srs=3857 + easting, northing = Tiles.lon_lat_to_easting_northing(0, 0) + self.assertAlmostEqual(easting, 0) + self.assertAlmostEqual(northing, 0) + + easting, northing = Tiles.lon_lat_to_easting_northing(20, 10) + self.assertAlmostEqual(easting, 2226389.82, places=2) + self.assertAlmostEqual(northing, 1118889.97, places=2) + + easting, northing = Tiles.lon_lat_to_easting_northing(-33, -18) + self.assertAlmostEqual(easting, -3673543.20, places=2) + self.assertAlmostEqual(northing, -2037548.54, places=2) + + easting, northing = Tiles.lon_lat_to_easting_northing(85, -75) + self.assertAlmostEqual(easting, 9462156.72, places=2) + self.assertAlmostEqual(northing, -12932243.11, places=2) + + easting, northing = Tiles.lon_lat_to_easting_northing(180, 85) + self.assertAlmostEqual(easting, 20037508.34, places=2) + self.assertAlmostEqual(northing, 19971868.88, places=2) + + def test_spot_check_eastingnorthing_to_lonlat(self): + # Anchor implementation with a few hard-coded known values. + # Generated ad-hoc from https://epsg.io/transform#s_srs=3857&t_srs=4326 + + lon, lat = Tiles.easting_northing_to_lon_lat(0, 0) + self.assertAlmostEqual(lon, 0) + self.assertAlmostEqual(lat, 0) + + lon, lat = Tiles.easting_northing_to_lon_lat(1230020, -432501) + self.assertAlmostEqual(lon, 11.0494578, places=2) + self.assertAlmostEqual(lat, -3.8822487, places=2) + + lon, lat = Tiles.easting_northing_to_lon_lat(-2130123, 1829312) + self.assertAlmostEqual(lon, -19.1352205, places=2) + self.assertAlmostEqual(lat, 16.2122187, places=2) + + lon, lat = Tiles.easting_northing_to_lon_lat(-1000000, 5000000) + self.assertAlmostEqual(lon, -8.9831528, places=2) + self.assertAlmostEqual(lat, 40.9162745, places=2) + + lon, lat = Tiles.easting_northing_to_lon_lat(-20037508.34, 20037508.34) + self.assertAlmostEqual(lon, -180.0, places=2) + self.assertAlmostEqual(lat, 85.0511288, places=2) + + def test_check_lonlat_to_eastingnorthing_identity(self): + for lon in np.linspace(-180, 180, 100): + for lat in np.linspace(-85, 85, 100): + easting, northing = Tiles.lon_lat_to_easting_northing(lon, lat) + new_lon, new_lat = Tiles.easting_northing_to_lon_lat(easting, northing) + self.assertAlmostEqual(lon, new_lon, places=2) + self.assertAlmostEqual(lat, new_lat, places=2) + + def test_check_eastingnorthing_to_lonlat_identity(self): + for easting in np.linspace(-20037508.34, 20037508.34, 100): + for northing in np.linspace(-20037508.34, 20037508.34, 100): + lon, lat = Tiles.easting_northing_to_lon_lat(easting, northing) + new_easting, new_northing = Tiles.lon_lat_to_easting_northing(lon, lat) + self.assertAlmostEqual(easting, new_easting, places=2) + self.assertAlmostEqual(northing, new_northing, places=2) + + def check_array_type_preserved(self, constructor, array_type, check): + lons, lats = np.meshgrid( + np.linspace(-180, 180, 100), np.linspace(-85, 85, 100) + ) + lons = lons.flatten() + lats = lats.flatten() + + array_lons = constructor(lons) + array_lats = constructor(lats) + + self.assertIsInstance(array_lons, array_type) + self.assertIsInstance(array_lats, array_type) + + eastings, northings = Tiles.lon_lat_to_easting_northing( + array_lons, array_lats + ) + self.assertIsInstance(eastings, array_type) + self.assertIsInstance(northings, array_type) + + new_lons, new_lats = Tiles.easting_northing_to_lon_lat( + eastings, northings + ) + self.assertIsInstance(new_lons, array_type) + self.assertIsInstance(new_lats, array_type) + + check(array_lons, new_lons) + check(array_lats, new_lats) + + def test_check_numpy_array(self): + self.check_array_type_preserved( + np.array, np.ndarray, + lambda a, b: np.testing.assert_array_almost_equal(a, b, decimal=2) + ) + + def test_pandas_series(self): + self.check_array_type_preserved( + pd.Series, pd.Series, + lambda a, b: pd.testing.assert_series_equal( + a, b, check_exact=False, check_less_precise=True, + ) + ) + + diff --git a/holoviews/tests/plotting/plotly/testcallbacks.py b/holoviews/tests/plotting/plotly/testcallbacks.py index 63beb8bcf0..df614f4e8e 100644 --- a/holoviews/tests/plotting/plotly/testcallbacks.py +++ b/holoviews/tests/plotting/plotly/testcallbacks.py @@ -6,7 +6,7 @@ from mock import Mock import uuid - +from holoviews import Tiles try: import plotly.graph_objs as go except: @@ -96,6 +96,46 @@ def setUp(self): 'title': {'text': 'Figure Title'}} }).to_dict() + self.mapbox_fig_dict = go.Figure({ + 'data': [ + {'type': 'scattermapbox', 'uid': 'first', 'subplot': 'mapbox'}, + {'type': 'scattermapbox', 'uid': 'second', 'subplot': 'mapbox2'}, + {'type': 'scattermapbox', 'uid': 'third', 'subplot': 'mapbox3'} + ], + 'layout': { + 'title': {'text': 'Figure Title'}, + } + }).to_dict() + + # Precompute a pair of lat/lon, easting/northing, mapbox coord values + self.lon_range1, self.lat_range1 = (10, 30), (20, 40) + self.easting_range1, self.northing_range1 = Tiles.lon_lat_to_easting_northing( + self.lon_range1, self.lat_range1 + ) + self.easting_range1 = tuple(self.easting_range1) + self.northing_range1 = tuple(self.northing_range1) + + self.mapbox_coords1 = [ + [self.lon_range1[0], self.lat_range1[1]], + [self.lon_range1[1], self.lat_range1[1]], + [self.lon_range1[1], self.lat_range1[0]], + [self.lon_range1[0], self.lat_range1[0]] + ] + + self.lon_range2, self.lat_range2 = (-50, -30), (-70, -40) + self.easting_range2, self.northing_range2 = Tiles.lon_lat_to_easting_northing( + self.lon_range2, self.lat_range2 + ) + self.easting_range2 = tuple(self.easting_range2) + self.northing_range2 = tuple(self.northing_range2) + + self.mapbox_coords2 = [ + [self.lon_range2[0], self.lat_range2[1]], + [self.lon_range2[1], self.lat_range2[1]], + [self.lon_range2[1], self.lat_range2[0]], + [self.lon_range2[0], self.lat_range2[0]] + ] + def testCallbackClassInstanceTracking(self): # Each callback class should track all active instances of its own class in a # weak value dictionary. Here we make sure that instances stay separated per @@ -134,14 +174,12 @@ def testRangeXYCallbackEventData(self): 'yaxis.range[0]': -1, 'yaxis.range[1]': 5}, ]: event_data = RangeXYCallback.get_event_data_from_property_update( - viewport, self.fig_dict + "viewport", viewport, self.fig_dict ) self.assertEqual(event_data, { 'first': {'x_range': (1, 4), 'y_range': (-1, 5)}, 'second': {'x_range': (1, 4), 'y_range': (-1, 5)}, - 'third': {'x_range': None, 'y_range': None}, - 'forth': {'x_range': None, 'y_range': None} }) def testRangeXCallbackEventData(self): @@ -151,14 +189,12 @@ def testRangeXCallbackEventData(self): 'yaxis.range[0]': -1, 'yaxis.range[1]': 5}, ]: event_data = RangeXCallback.get_event_data_from_property_update( - viewport, self.fig_dict + "viewport", viewport, self.fig_dict ) self.assertEqual(event_data, { 'first': {'x_range': (1, 4)}, 'second': {'x_range': (1, 4)}, - 'third': {'x_range': None}, - 'forth': {'x_range': None} }) def testRangeYCallbackEventData(self): @@ -168,16 +204,59 @@ def testRangeYCallbackEventData(self): 'yaxis.range[0]': -1, 'yaxis.range[1]': 5}, ]: event_data = RangeYCallback.get_event_data_from_property_update( - viewport, self.fig_dict + "viewport", viewport, self.fig_dict ) self.assertEqual(event_data, { 'first': {'y_range': (-1, 5)}, 'second': {'y_range': (-1, 5)}, - 'third': {'y_range': None}, - 'forth': {'y_range': None} }) + def testMapboxRangeXYCallbackEventData(self): + relayout_data = { + 'mapbox._derived': {"coordinates": self.mapbox_coords1}, + 'mapbox3._derived': {"coordinates": self.mapbox_coords2} + } + + event_data = RangeXYCallback.get_event_data_from_property_update( + "relayout_data", relayout_data, self.mapbox_fig_dict + ) + + self.assertEqual(event_data, { + 'first': {'x_range': self.easting_range1, 'y_range': self.northing_range1}, + 'third': {'x_range': self.easting_range2, 'y_range': self.northing_range2}, + }) + + def testMapboxRangeXCallbackEventData(self): + relayout_data = { + 'mapbox._derived': {"coordinates": self.mapbox_coords1}, + 'mapbox3._derived': {"coordinates": self.mapbox_coords2} + } + + event_data = RangeXCallback.get_event_data_from_property_update( + "relayout_data", relayout_data, self.mapbox_fig_dict + ) + + self.assertEqual(event_data, { + 'first': {'x_range': self.easting_range1}, + 'third': {'x_range': self.easting_range2}, + }) + + def testMapboxRangeYCallbackEventData(self): + relayout_data = { + 'mapbox._derived': {"coordinates": self.mapbox_coords1}, + 'mapbox3._derived': {"coordinates": self.mapbox_coords2} + } + + event_data = RangeYCallback.get_event_data_from_property_update( + "relayout_data", relayout_data, self.mapbox_fig_dict + ) + + self.assertEqual(event_data, { + 'first': {'y_range': self.northing_range1}, + 'third': {'y_range': self.northing_range2}, + }) + def testRangeCallbacks(self): # Build callbacks @@ -205,7 +284,9 @@ def testRangeCallbacks(self): # Change viewport on first set of axes viewport1 = {'xaxis.range': [1, 4], 'yaxis.range': [-1, 5]} for cb_cls in range_classes: - cb_cls.update_streams_from_property_update(viewport1, self.fig_dict) + cb_cls.update_streams_from_property_update( + "viewport", viewport1, self.fig_dict + ) # Check that all streams attached to 'first' and 'second' plots were triggered for xystream, xstream, ystream in zip( @@ -232,7 +313,9 @@ def testRangeCallbacks(self): # Change viewport on second set of axes viewport2 = {'xaxis2.range': [2, 5], 'yaxis2.range': [0, 6]} for cb_cls in range_classes: - cb_cls.update_streams_from_property_update(viewport2, self.fig_dict) + cb_cls.update_streams_from_property_update( + "viewport", viewport2, self.fig_dict + ) # Check that all streams attached to 'third' were triggered for xystream, xstream, ystream in zip( @@ -246,7 +329,9 @@ def testRangeCallbacks(self): # Change viewport on third set of axes viewport3 = {'xaxis3.range': [3, 6], 'yaxis3.range': [1, 7]} for cb_cls in range_classes: - cb_cls.update_streams_from_property_update(viewport3, self.fig_dict) + cb_cls.update_streams_from_property_update( + "viewport", viewport3, self.fig_dict + ) # Check that all streams attached to 'forth' were triggered for xystream, xstream, ystream in zip( @@ -268,7 +353,7 @@ def testRangeCallbacks(self): def testBoundsXYCallbackEventData(self): selected_data1 = {'range': {'x': [1, 4], 'y': [-1, 5]}} event_data = BoundsXYCallback.get_event_data_from_property_update( - selected_data1, self.fig_dict + "selected_data", selected_data1, self.fig_dict ) self.assertEqual(event_data, { @@ -281,7 +366,7 @@ def testBoundsXYCallbackEventData(self): def testBoundsXCallbackEventData(self): selected_data1 = {'range': {'x': [1, 4], 'y': [-1, 5]}} event_data = BoundsXCallback.get_event_data_from_property_update( - selected_data1, self.fig_dict + "selected_data", selected_data1, self.fig_dict ) self.assertEqual(event_data, { @@ -294,7 +379,7 @@ def testBoundsXCallbackEventData(self): def testBoundsYCallbackEventData(self): selected_data1 = {'range': {'x': [1, 4], 'y': [-1, 5]}} event_data = BoundsYCallback.get_event_data_from_property_update( - selected_data1, self.fig_dict + "selected_data", selected_data1, self.fig_dict ) self.assertEqual(event_data, { @@ -304,6 +389,61 @@ def testBoundsYCallbackEventData(self): 'forth': {'boundsy': None} }) + def testMapboxBoundsXYCallbackEventData(self): + selected_data = {"range": {'mapbox2': [ + [self.lon_range1[0], self.lat_range1[0]], + [self.lon_range1[1], self.lat_range1[1]] + ]}} + + event_data = BoundsXYCallback.get_event_data_from_property_update( + "selected_data", selected_data, self.mapbox_fig_dict + ) + + self.assertEqual(event_data, { + 'first': {'bounds': None}, + 'second': {'bounds': ( + self.easting_range1[0], self.northing_range1[0], + self.easting_range1[1], self.northing_range1[1] + )}, + 'third': {'bounds': None} + }) + + def testMapboxBoundsXCallbackEventData(self): + selected_data = {"range": {'mapbox': [ + [self.lon_range1[0], self.lat_range1[0]], + [self.lon_range1[1], self.lat_range1[1]] + ]}} + + event_data = BoundsXCallback.get_event_data_from_property_update( + "selected_data", selected_data, self.mapbox_fig_dict + ) + + self.assertEqual(event_data, { + 'first': {'boundsx': ( + self.easting_range1[0], self.easting_range1[1], + )}, + 'second': {'boundsx': None}, + 'third': {'boundsx': None} + }) + + def testMapboxBoundsYCallbackEventData(self): + selected_data = {"range": {'mapbox3': [ + [self.lon_range1[0], self.lat_range1[0]], + [self.lon_range1[1], self.lat_range1[1]] + ]}} + + event_data = BoundsYCallback.get_event_data_from_property_update( + "selected_data", selected_data, self.mapbox_fig_dict + ) + + self.assertEqual(event_data, { + 'first': {'boundsy': None}, + 'second': {'boundsy': None}, + 'third': {'boundsy': ( + self.northing_range1[0], self.northing_range1[1] + )}, + }) + def testBoundsCallbacks(self): # Build callbacks @@ -327,7 +467,9 @@ def testBoundsCallbacks(self): # box selection on first set of axes selected_data1 = {'range': {'x': [1, 4], 'y': [-1, 5]}} for cb_cls in bounds_classes: - cb_cls.update_streams_from_property_update(selected_data1, self.fig_dict) + cb_cls.update_streams_from_property_update( + "selected_data", selected_data1, self.fig_dict + ) # Check that all streams attached to 'first' and 'second' plots were triggered for xystream, xstream, ystream in zip( @@ -353,7 +495,9 @@ def testBoundsCallbacks(self): # box select on second set of axes selected_data2 = {'range': {'x2': [2, 5], 'y2': [0, 6]}} for cb_cls in bounds_classes: - cb_cls.update_streams_from_property_update(selected_data2, self.fig_dict) + cb_cls.update_streams_from_property_update( + "selected_data", selected_data2, self.fig_dict + ) # Check that all streams attached to 'second' were triggered for xystream, xstream, ystream in zip( @@ -366,7 +510,9 @@ def testBoundsCallbacks(self): # box select on third set of axes selected_data3 = {'range': {'x3': [3, 6], 'y3': [1, 7]}} for cb_cls in bounds_classes: - cb_cls.update_streams_from_property_update(selected_data3, self.fig_dict) + cb_cls.update_streams_from_property_update( + "selected_data", selected_data3, self.fig_dict + ) # Check that all streams attached to 'third' were triggered for xystream, xstream, ystream in zip( @@ -380,7 +526,8 @@ def testBoundsCallbacks(self): selected_data_lasso = {'lassoPoints': {'x': [1, 4, 2], 'y': [-1, 5, 2]}} for cb_cls in bounds_classes: cb_cls.update_streams_from_property_update( - selected_data_lasso, self.fig_dict) + "selected_data", selected_data_lasso, self.fig_dict + ) # Check that all streams attached to this figure are called with None # to clear their bounds @@ -408,7 +555,7 @@ def testSelection1DCallbackEventData(self): ]} event_data = Selection1DCallback.get_event_data_from_property_update( - selected_data1, self.fig_dict + "selected_data", selected_data1, self.fig_dict ) self.assertEqual(event_data, { @@ -418,6 +565,22 @@ def testSelection1DCallbackEventData(self): 'forth': {'index': []} }) + def testMapboxSelection1DCallbackEventData(self): + selected_data1 = {'points': [ + {"pointNumber": 0, "curveNumber": 1}, + {"pointNumber": 2, "curveNumber": 1}, + ]} + + event_data = Selection1DCallback.get_event_data_from_property_update( + "selected_data", selected_data1, self.mapbox_fig_dict + ) + + self.assertEqual(event_data, { + 'first': {'index': []}, + 'second': {'index': [0, 2]}, + 'third': {'index': []}, + }) + def testSelection1DCallback(self): plots, streamss, callbacks, sel_events = build_callback_set( Selection1DCallback, ['first', 'second', 'third', 'forth', 'other'], @@ -430,7 +593,8 @@ def testSelection1DCallback(self): {"pointNumber": 2, "curveNumber": 0}, ]} Selection1DCallback.update_streams_from_property_update( - selected_data1, self.fig_dict) + "selected_data", selected_data1, self.fig_dict + ) # Check that all streams attached to the 'first' plots were triggered for stream, events in zip(streamss[0], sel_events[0]): @@ -450,7 +614,8 @@ def testSelection1DCallback(self): {"pointNumber": 2, "curveNumber": 1}, ]} Selection1DCallback.update_streams_from_property_update( - selected_data1, self.fig_dict) + "selected_data", selected_data1, self.fig_dict + ) # Check that all streams attached to the 'first' plot were triggered for stream in streamss[0]: @@ -471,7 +636,8 @@ def testSelection1DCallback(self): {"pointNumber": 2, "curveNumber": 3}, ]} Selection1DCallback.update_streams_from_property_update( - selected_data1, self.fig_dict) + "selected_data", selected_data1, self.fig_dict + ) # Check that all streams attached to the 'forth' plot were triggered for stream, events in zip(streamss[3], sel_events[3]): diff --git a/holoviews/tests/plotting/plotly/testcurveplot.py b/holoviews/tests/plotting/plotly/testcurveplot.py index e4ecbc2b14..be5b224c16 100644 --- a/holoviews/tests/plotting/plotly/testcurveplot.py +++ b/holoviews/tests/plotting/plotly/testcurveplot.py @@ -1,6 +1,6 @@ import numpy as np -from holoviews.element import Curve +from holoviews.element import Curve, Tiles from .testplot import TestPlotlyPlot @@ -57,3 +57,81 @@ def test_visible(self): element = Curve([1, 2, 3]).options(visible=False) state = self._get_plot_state(element) self.assertEqual(state['data'][0]['visible'], False) + + +class TestMapboxCurvePlot(TestPlotlyPlot): + + def setUp(self): + super(TestMapboxCurvePlot, self).setUp() + + # Precompute coordinates + self.xs = [3000000, 2000000, 1000000] + self.ys = [-3000000, -2000000, -1000000] + self.x_range = (-5000000, 4000000) + self.x_center = sum(self.x_range) / 2.0 + self.y_range = (-3000000, 2000000) + self.y_center = sum(self.y_range) / 2.0 + self.lon_centers, self.lat_centers = Tiles.easting_northing_to_lon_lat( + [self.x_center], [self.y_center] + ) + self.lon_center, self.lat_center = self.lon_centers[0], self.lat_centers[0] + self.lons, self.lats = Tiles.easting_northing_to_lon_lat(self.xs, self.ys) + + def test_curve_state(self): + curve = Tiles("") * Curve((self.xs, self.ys)).redim.range( + x=self.x_range, y=self.y_range + ) + state = self._get_plot_state(curve) + self.assertEqual(state['data'][1]['lon'], self.lons) + self.assertEqual(state['data'][1]['lat'], self.lats) + self.assertEqual(state['data'][1]['mode'], 'lines') + self.assertEqual( + state['layout']['mapbox']['center'], { + 'lat': self.lat_center, 'lon': self.lon_center + } + ) + + def test_curve_inverted(self): + curve = Tiles("") * Curve([1, 2, 3]).options(invert_axes=True) + with self.assertRaises(ValueError) as e: + self._get_plot_state(curve) + + self.assertIn("invert_axes", str(e.exception)) + + def test_curve_interpolation(self): + from holoviews.operation import interpolate_curve + interp_xs = np.array([0., 0.5, 0.5, 1.5, 1.5, 2.]) + interp_curve = interpolate_curve(Curve(self.ys), interpolation='steps-mid') + interp_ys = interp_curve.dimension_values("y") + interp_lons, interp_lats = Tiles.easting_northing_to_lon_lat(interp_xs, interp_ys) + + curve = Tiles("") * Curve(self.ys).options(interpolation='steps-mid') + state = self._get_plot_state(curve) + self.assertEqual(state['data'][1]['lat'], interp_lats) + self.assertEqual(state['data'][1]['lon'], interp_lons) + + def test_curve_color(self): + curve = Tiles("") * Curve([1, 2, 3]).options(color='red') + state = self._get_plot_state(curve) + self.assertEqual(state['data'][1]['line']['color'], 'red') + + def test_curve_color_mapping_error(self): + curve = Tiles("") * Curve([1, 2, 3]).options(color='x') + with self.assertRaises(ValueError): + self._get_plot_state(curve) + + def test_curve_dash(self): + curve = Tiles("") * Curve([1, 2, 3]).options(dash='dash') + with self.assertRaises(ValueError) as e: + self._get_plot_state(curve) + self.assertIn("dash", str(e.exception)) + + def test_curve_line_width(self): + curve = Tiles("") * Curve([1, 2, 3]).options(line_width=5) + state = self._get_plot_state(curve) + self.assertEqual(state['data'][1]['line']['width'], 5) + + def test_visible(self): + element = Tiles("") * Curve([1, 2, 3]).options(visible=False) + state = self._get_plot_state(element) + self.assertEqual(state['data'][1]['visible'], False) diff --git a/holoviews/tests/plotting/plotly/testfiguresize.py b/holoviews/tests/plotting/plotly/testfiguresize.py new file mode 100644 index 0000000000..7519efcd4e --- /dev/null +++ b/holoviews/tests/plotting/plotly/testfiguresize.py @@ -0,0 +1,12 @@ +from holoviews.element import Points +from .testplot import TestPlotlyPlot + + +class TestImagePlot(TestPlotlyPlot): + + def test_image_state(self): + img = Points([(0, 0)]).opts(width=345, height=456) + state = self._get_plot_state(img) + + self.assertEqual(state["layout"]["width"], 345) + self.assertEqual(state["layout"]["height"], 456) diff --git a/holoviews/tests/plotting/plotly/testlabelplot.py b/holoviews/tests/plotting/plotly/testlabelplot.py index c595d5dd62..dc0586b446 100644 --- a/holoviews/tests/plotting/plotly/testlabelplot.py +++ b/holoviews/tests/plotting/plotly/testlabelplot.py @@ -1,6 +1,6 @@ import numpy as np -from holoviews.element import Labels +from holoviews.element import Labels, Tiles from .testplot import TestPlotlyPlot @@ -50,3 +50,85 @@ def test_visible(self): element = Labels([(0, 3, 0), (1, 2, 1), (2, 1, 1)]).options(visible=False) state = self._get_plot_state(element) self.assertEqual(state['data'][0]['visible'], False) + + +class TestMapboxLabelsPlot(TestPlotlyPlot): + + def setUp(self): + super(TestMapboxLabelsPlot, self).setUp() + + # Precompute coordinates + self.xs = [3000000, 2000000, 1000000] + self.ys = [-3000000, -2000000, -1000000] + self.x_range = (-5000000, 4000000) + self.x_center = sum(self.x_range) / 2.0 + self.y_range = (-3000000, 2000000) + self.y_center = sum(self.y_range) / 2.0 + self.lon_centers, self.lat_centers = Tiles.easting_northing_to_lon_lat( + [self.x_center], [self.y_center] + ) + self.lon_center, self.lat_center = self.lon_centers[0], self.lat_centers[0] + self.lons, self.lats = Tiles.easting_northing_to_lon_lat(self.xs, self.ys) + + def test_labels_state(self): + labels = Tiles("") * Labels([ + (self.xs[0], self.ys[0], 'A'), + (self.xs[1], self.ys[1], 'B'), + (self.xs[2], self.ys[2], 'C') + ]).redim.range( + x=self.x_range, y=self.y_range + ) + state = self._get_plot_state(labels) + self.assertEqual(state['data'][1]['lon'], self.lons) + self.assertEqual(state['data'][1]['lat'], self.lats) + self.assertEqual(state['data'][1]['text'], ['A', 'B', 'C']) + self.assertEqual(state['data'][1]['mode'], 'text') + self.assertEqual( + state['layout']['mapbox']['center'], { + 'lat': self.lat_center, 'lon': self.lon_center + } + ) + + def test_labels_inverted(self): + labels = Tiles("") * Labels([(0, 3, 0), (1, 2, 1), (2, 1, 1)]).options( + invert_axes=True + ) + with self.assertRaises(ValueError) as e: + self._get_plot_state(labels) + + self.assertIn("invert_axes", str(e.exception)) + + def test_labels_size(self): + labels = Tiles("") * Labels([(0, 3, 0), (0, 2, 1), (0, 1, 1)]).options(size=23) + state = self._get_plot_state(labels) + self.assertEqual(state['data'][1]['textfont']['size'], 23) + + def test_labels_xoffset(self): + offset = 10000 + labels = Tiles("") * Labels([ + (self.xs[0], self.ys[0], 'A'), + (self.xs[1], self.ys[1], 'B'), + (self.xs[2], self.ys[2], 'C') + ]).options(xoffset=offset) + + state = self._get_plot_state(labels) + lons, lats = Tiles.easting_northing_to_lon_lat(np.array(self.xs) + offset, self.ys) + self.assertEqual(state['data'][1]['lon'], lons) + self.assertEqual(state['data'][1]['lat'], lats) + + def test_labels_yoffset(self): + offset = 20000 + labels = Tiles("") * Labels([ + (self.xs[0], self.ys[0], 'A'), + (self.xs[1], self.ys[1], 'B'), + (self.xs[2], self.ys[2], 'C') + ]).options(yoffset=offset) + state = self._get_plot_state(labels) + lons, lats = Tiles.easting_northing_to_lon_lat(self.xs, np.array(self.ys) + offset) + self.assertEqual(state['data'][1]['lon'], lons) + self.assertEqual(state['data'][1]['lat'], lats) + + def test_visible(self): + element = Tiles("") * Labels([(0, 3, 0), (1, 2, 1), (2, 1, 1)]).options(visible=False) + state = self._get_plot_state(element) + self.assertEqual(state['data'][1]['visible'], False) diff --git a/holoviews/tests/plotting/plotly/testrgb.py b/holoviews/tests/plotting/plotly/testrgb.py index 5e0aed9b53..954ff85f6a 100644 --- a/holoviews/tests/plotting/plotly/testrgb.py +++ b/holoviews/tests/plotting/plotly/testrgb.py @@ -7,7 +7,7 @@ except: go = None -from holoviews.element import RGB +from holoviews.element import RGB, Tiles from .testplot import TestPlotlyPlot, plotly_renderer @@ -245,3 +245,60 @@ def test_rgb_opacity(self): pil_img = self.rgb_element_to_pil_img(rgb_data) expected_source = go.layout.Image(source=pil_img).source self.assertEqual(image['source'], expected_source) + + +class TestMapboxRGBPlot(TestPlotlyPlot): + def setUp(self): + super(TestMapboxRGBPlot, self).setUp() + + # Precompute coordinates + self.xs = [3000000, 2000000, 1000000] + self.ys = [-3000000, -2000000, -1000000] + self.x_range = (-5000000, 4000000) + self.x_center = sum(self.x_range) / 2.0 + self.y_range = (-3000000, 2000000) + self.y_center = sum(self.y_range) / 2.0 + self.lon_range, self.lat_range = Tiles.easting_northing_to_lon_lat(self.x_range, self.y_range) + self.lon_centers, self.lat_centers = Tiles.easting_northing_to_lon_lat( + [self.x_center], [self.y_center] + ) + self.lon_center, self.lat_center = self.lon_centers[0], self.lat_centers[0] + self.lons, self.lats = Tiles.easting_northing_to_lon_lat(self.xs, self.ys) + + def test_rgb(self): + rgb_data = np.random.rand(10, 10, 3) + rgb = Tiles("") * RGB( + rgb_data, + bounds=(self.x_range[0], self.y_range[0], self.x_range[1], self.y_range[1]) + ).opts( + opacity=0.5 + ).redim.range(x=self.x_range, y=self.y_range) + + fig_dict = plotly_renderer.get_plot_state(rgb) + # Check dummy trace + self.assertEqual(fig_dict["data"][1]["type"], "scattermapbox") + self.assertEqual(fig_dict["data"][1]["lon"], [None]) + self.assertEqual(fig_dict["data"][1]["lat"], [None]) + self.assertEqual(fig_dict["data"][1]["showlegend"], False) + + # Check mapbox subplot + subplot = fig_dict["layout"]["mapbox"] + self.assertEqual(subplot["style"], "white-bg") + self.assertEqual( + subplot['center'], {'lat': self.lat_center, 'lon': self.lon_center} + ) + + # Check rgb layer + layers = fig_dict["layout"]["mapbox"]["layers"] + self.assertEqual(len(layers), 1) + rgb_layer = layers[0] + self.assertEqual(rgb_layer["below"], "traces") + self.assertEqual(rgb_layer["coordinates"], [ + [self.lon_range[0], self.lat_range[1]], + [self.lon_range[1], self.lat_range[1]], + [self.lon_range[1], self.lat_range[0]], + [self.lon_range[0], self.lat_range[0]] + ]) + self.assertTrue(rgb_layer["source"].startswith("data:image/png;base64,iVBOR")) + self.assertEqual(rgb_layer["opacity"], 0.5) + self.assertEqual(rgb_layer["sourcetype"], "image") diff --git a/holoviews/tests/plotting/plotly/testscatterplot.py b/holoviews/tests/plotting/plotly/testscatterplot.py index 8b8f394a08..e7ed144cdb 100644 --- a/holoviews/tests/plotting/plotly/testscatterplot.py +++ b/holoviews/tests/plotting/plotly/testscatterplot.py @@ -1,6 +1,6 @@ import numpy as np -from holoviews.element import Scatter +from holoviews.element import Scatter, Tiles from .testplot import TestPlotlyPlot @@ -10,6 +10,7 @@ class TestScatterPlot(TestPlotlyPlot): def test_scatter_state(self): scatter = Scatter([3, 2, 1]) state = self._get_plot_state(scatter) + self.assertEqual(state['data'][0]['type'], 'scatter') self.assertEqual(state['data'][0]['y'], np.array([3, 2, 1])) self.assertEqual(state['data'][0]['mode'], 'markers') self.assertEqual(state['layout']['yaxis']['range'], [1, 3]) @@ -64,3 +65,72 @@ def test_visible(self): element = Scatter([3, 2, 1]).options(visible=False) state = self._get_plot_state(element) self.assertEqual(state['data'][0]['visible'], False) + + +class TestMapboxScatterPlot(TestPlotlyPlot): + def test_scatter_state(self): + # Precompute coordinates + xs = [3000000, 2000000, 1000000] + ys = [-3000000, -2000000, -1000000] + x_range = (-5000000, 4000000) + x_center = sum(x_range) / 2.0 + y_range = (-3000000, 2000000) + y_center = sum(y_range) / 2.0 + lon_centers, lat_centers = Tiles.easting_northing_to_lon_lat([x_center], [y_center]) + lon_center, lat_center = lon_centers[0], lat_centers[0] + lons, lats = Tiles.easting_northing_to_lon_lat(xs, ys) + + scatter = Tiles('') * Scatter((xs, ys)).redim.range(x=x_range, y=y_range) + state = self._get_plot_state(scatter) + self.assertEqual(state['data'][1]['type'], 'scattermapbox') + self.assertEqual(state['data'][1]['lon'], lons) + self.assertEqual(state['data'][1]['lat'], lats) + self.assertEqual(state['data'][1]['mode'], 'markers') + self.assertEqual( + state['layout']['mapbox']['center'], {'lat': lat_center, 'lon': lon_center} + ) + + # There xaxis and yaxis should not be in the layout + self.assertFalse('xaxis' in state['layout']) + self.assertFalse('yaxis' in state['layout']) + + def test_scatter_color_mapped(self): + scatter = Tiles('') * Scatter([3, 2, 1]).options(color='x') + state = self._get_plot_state(scatter) + self.assertEqual(state['data'][1]['marker']['color'], np.array([0, 1, 2])) + self.assertEqual(state['data'][1]['marker']['cmin'], 0) + self.assertEqual(state['data'][1]['marker']['cmax'], 2) + + def test_scatter_size(self): + # size values should not go through meters-to-lnglat conversion + scatter = Tiles('') * Scatter([3, 2, 1]).options(size='y') + state = self._get_plot_state(scatter) + self.assertEqual(state['data'][1]['marker']['size'], np.array([3, 2, 1])) + + def test_scatter_colors(self): + scatter = Tiles('') * Scatter([ + (0, 1, 'red'), (1, 2, 'green'), (2, 3, 'blue') + ], vdims=['y', 'color']).options(color='color') + state = self._get_plot_state(scatter) + self.assertEqual(state['data'][1]['marker']['color'], + np.array(['red', 'green', 'blue'])) + + def test_scatter_markers(self): + scatter = Tiles('') * Scatter([ + (0, 1, 'square'), (1, 2, 'circle'), (2, 3, 'triangle-up') + ], vdims=['y', 'marker']).options(marker='marker') + state = self._get_plot_state(scatter) + self.assertEqual(state['data'][1]['marker']['symbol'], + np.array(['square', 'circle', 'triangle-up'])) + + def test_scatter_selectedpoints(self): + scatter = Tiles('') * Scatter([ + (0, 1,), (1, 2), (2, 3) + ]).options(selectedpoints=[1, 2]) + state = self._get_plot_state(scatter) + self.assertEqual(state['data'][1]['selectedpoints'], [1, 2]) + + def test_visible(self): + element = Tiles('') * Scatter([3, 2, 1]).options(visible=False) + state = self._get_plot_state(element) + self.assertEqual(state['data'][1]['visible'], False) diff --git a/holoviews/tests/plotting/plotly/testshapeplots.py b/holoviews/tests/plotting/plotly/testshapeplots.py index f226ecefbb..274b765deb 100644 --- a/holoviews/tests/plotting/plotly/testshapeplots.py +++ b/holoviews/tests/plotting/plotly/testshapeplots.py @@ -1,7 +1,10 @@ -from holoviews.element import VLine, HLine, Bounds, Box, Rectangles, Segments - +from holoviews.element import ( + VLine, HLine, Bounds, Box, Rectangles, Segments, Tiles, Path +) +import numpy as np from .testplot import TestPlotlyPlot +default_shape_color = '#2a3f5f' class TestShape(TestPlotlyPlot): def assert_shape_element_styling(self, element): @@ -17,8 +20,27 @@ def assert_shape_element_styling(self, element): state = self._get_plot_state(element) shapes = state['layout']['shapes'] self.assert_property_values(shapes[0], props) - - + + +class TestMapboxShape(TestPlotlyPlot): + def setUp(self): + super(TestMapboxShape, self).setUp() + + # Precompute coordinates + self.xs = [3000000, 2000000, 1000000] + self.ys = [-3000000, -2000000, -1000000] + self.x_range = (-5000000, 4000000) + self.x_center = sum(self.x_range) / 2.0 + self.y_range = (-3000000, 2000000) + self.y_center = sum(self.y_range) / 2.0 + self.lon_range, self.lat_range = Tiles.easting_northing_to_lon_lat(self.x_range, self.y_range) + self.lon_centers, self.lat_centers = Tiles.easting_northing_to_lon_lat( + [self.x_center], [self.y_center] + ) + self.lon_center, self.lat_center = self.lon_centers[0], self.lat_centers[0] + self.lons, self.lats = Tiles.easting_northing_to_lon_lat(self.xs, self.ys) + + class TestVLineHLine(TestShape): def assert_vline(self, shape, x, xref='x', ydomain=(0, 1)): @@ -91,7 +113,7 @@ def test_vline_styling(self): def test_hline_styling(self): self.assert_shape_element_styling(HLine(3)) - + class TestPathShape(TestShape): def assert_path_shape_element(self, shape, element, xref='x', yref='y'): # Check type @@ -108,6 +130,43 @@ def assert_path_shape_element(self, shape, element, xref='x', yref='y'): self.assertEqual(shape['xref'], xref) self.assertEqual(shape['yref'], yref) + def test_simple_path(self): + path = Path([(0, 0), (1, 1), (0, 1), (0, 0)]) + state = self._get_plot_state(path) + shapes = state['layout']['shapes'] + self.assertEqual(len(shapes), 1) + self.assert_path_shape_element(shapes[0], path) + self.assert_shape_element_styling(path) + + +class TestMapboxPathShape(TestMapboxShape): + def test_simple_path(self): + path = Tiles("") * Path([ + (self.x_range[0], self.y_range[0]), + (self.x_range[1], self.y_range[1]), + (self.x_range[0], self.y_range[1]), + (self.x_range[0], self.y_range[0]), + ]).redim.range( + x=self.x_range, y=self.y_range + ) + + state = self._get_plot_state(path) + self.assertEqual(state["data"][1]["type"], "scattermapbox") + self.assertEqual(state["data"][1]["mode"], "lines") + self.assertEqual(state["data"][1]["lon"], np.array([ + self.lon_range[i] for i in (0, 1, 0, 0) + ] + [np.nan])) + self.assertEqual(state["data"][1]["lat"], np.array([ + self.lat_range[i] for i in (0, 1, 1, 0) + ] + [np.nan])) + self.assertEqual(state["data"][1]["showlegend"], False) + self.assertEqual(state["data"][1]["line"]["color"], default_shape_color) + self.assertEqual( + state['layout']['mapbox']['center'], { + 'lat': self.lat_center, 'lon': self.lon_center + } + ) + class TestBounds(TestPathShape): @@ -144,6 +203,49 @@ def test_bounds_styling(self): self.assert_shape_element_styling(Bounds((1, 2, 3, 4))) +class TestMapboxBounds(TestMapboxShape): + def test_single_bounds(self): + bounds = Tiles("") * Bounds( + (self.x_range[0], self.y_range[0], self.x_range[1], self.y_range[1]) + ).redim.range( + x=self.x_range, y=self.y_range + ) + + state = self._get_plot_state(bounds) + self.assertEqual(state["data"][1]["type"], "scattermapbox") + self.assertEqual(state["data"][1]["mode"], "lines") + self.assertEqual(state["data"][1]["lon"], np.array([ + self.lon_range[i] for i in (0, 0, 1, 1, 0) + ])) + self.assertEqual(state["data"][1]["lat"], np.array([ + self.lat_range[i] for i in (0, 1, 1, 0, 0) + ])) + self.assertEqual(state["data"][1]["showlegend"], False) + self.assertEqual(state["data"][1]["line"]["color"], default_shape_color) + self.assertEqual( + state['layout']['mapbox']['center'], { + 'lat': self.lat_center, 'lon': self.lon_center + } + ) + + def test_bounds_layout(self): + bounds1 = Bounds((0, 0, 1, 1)) + bounds2 = Bounds((0, 0, 2, 2)) + bounds3 = Bounds((0, 0, 3, 3)) + bounds4 = Bounds((0, 0, 4, 4)) + + layout = (Tiles("") * bounds1 + Tiles("") * bounds2 + + Tiles("") * bounds3 + Tiles("") * bounds4).cols(2) + + state = self._get_plot_state(layout) + self.assertEqual(state['data'][1]["subplot"], "mapbox") + self.assertEqual(state['data'][3]["subplot"], "mapbox2") + self.assertEqual(state['data'][5]["subplot"], "mapbox3") + self.assertEqual(state['data'][7]["subplot"], "mapbox4") + self.assertNotIn("xaxis", state['layout']) + self.assertNotIn("yaxis", state['layout']) + + class TestBox(TestPathShape): def test_single_box(self): @@ -177,6 +279,35 @@ def test_box_styling(self): self.assert_shape_element_styling(Box(0, 0, (1, 1))) +class TestMapboxBox(TestMapboxShape): + def test_single_box(self): + box = Tiles("") * Box(0, 0, (1000000, 2000000)).redim.range( + x=self.x_range, y=self.y_range + ) + + x_box_range = [-500000, 500000] + y_box_range = [-1000000, 1000000] + lon_box_range, lat_box_range = Tiles.easting_northing_to_lon_lat(x_box_range, y_box_range) + + state = self._get_plot_state(box) + self.assertEqual(state["data"][1]["type"], "scattermapbox") + self.assertEqual(state["data"][1]["mode"], "lines") + self.assertEqual(state["data"][1]["showlegend"], False) + self.assertEqual(state["data"][1]["line"]["color"], default_shape_color) + self.assertEqual(state["data"][1]["lon"], np.array([ + lon_box_range[i] for i in (0, 0, 1, 1, 0) + ])) + self.assertEqual(state["data"][1]["lat"], np.array([ + lat_box_range[i] for i in (0, 1, 1, 0, 0) + ])) + + self.assertEqual( + state['layout']['mapbox']['center'], { + 'lat': self.lat_center, 'lon': self.lon_center + } + ) + + class TestRectangles(TestPathShape): def test_boxes_simple(self): @@ -185,13 +316,45 @@ def test_boxes_simple(self): shapes = state['layout']['shapes'] self.assertEqual(len(shapes), 2) self.assertEqual(shapes[0], {'type': 'rect', 'x0': 0, 'y0': 0, 'x1': 1, - 'y1': 1, 'xref': 'x', 'yref': 'y', 'name': ''}) + 'y1': 1, 'xref': 'x', 'yref': 'y', 'name': '', + 'line': {'color': default_shape_color}}) self.assertEqual(shapes[1], {'type': 'rect', 'x0': 2, 'y0': 2, 'x1': 4, - 'y1': 3, 'xref': 'x', 'yref': 'y', 'name': ''}) + 'y1': 3, 'xref': 'x', 'yref': 'y', 'name': '', + 'line': {'color': default_shape_color}}) self.assertEqual(state['layout']['xaxis']['range'], [0, 4]) self.assertEqual(state['layout']['yaxis']['range'], [0, 3]) +class TestMapboxRectangles(TestMapboxShape): + def test_rectangles_simple(self): + rectangles = Tiles("") * Rectangles([ + (0, 0, self.x_range[1], self.y_range[1]), + (self.x_range[0], self.y_range[0], 0, 0), + ]).redim.range( + x=self.x_range, y=self.y_range + ) + + state = self._get_plot_state(rectangles) + self.assertEqual(state["data"][1]["type"], "scattermapbox") + self.assertEqual(state["data"][1]["mode"], "lines") + self.assertEqual(state["data"][1]["showlegend"], False) + self.assertEqual(state["data"][1]["line"]["color"], default_shape_color) + self.assertEqual(state["data"][1]["lon"], np.array([ + 0, 0, self.lon_range[1], self.lon_range[1], 0, np.nan, + self.lon_range[0], self.lon_range[0], 0, 0, self.lon_range[0], np.nan + ])) + self.assertEqual(state["data"][1]["lat"], np.array([ + 0, self.lat_range[1], self.lat_range[1], 0, 0, np.nan, + self.lat_range[0], 0, 0, self.lat_range[0], self.lat_range[0], np.nan + ])) + + self.assertEqual( + state['layout']['mapbox']['center'], { + 'lat': self.lat_center, 'lon': self.lon_center + } + ) + + class TestSegments(TestPathShape): def test_segments_simple(self): @@ -200,8 +363,40 @@ def test_segments_simple(self): shapes = state['layout']['shapes'] self.assertEqual(len(shapes), 2) self.assertEqual(shapes[0], {'type': 'line', 'x0': 0, 'y0': 0, 'x1': 1, - 'y1': 1, 'xref': 'x', 'yref': 'y', 'name': ''}) + 'y1': 1, 'xref': 'x', 'yref': 'y', 'name': '', + 'line': {'color': default_shape_color}}) self.assertEqual(shapes[1], {'type': 'line', 'x0': 2, 'y0': 2, 'x1': 4, - 'y1': 3, 'xref': 'x', 'yref': 'y', 'name': ''}) + 'y1': 3, 'xref': 'x', 'yref': 'y', 'name': '', + 'line': {'color': default_shape_color}}) self.assertEqual(state['layout']['xaxis']['range'], [0, 4]) self.assertEqual(state['layout']['yaxis']['range'], [0, 3]) + + +class TestMapboxSegments(TestMapboxShape): + def test_segments_simple(self): + rectangles = Tiles("") * Segments([ + (0, 0, self.x_range[1], self.y_range[1]), + (self.x_range[0], self.y_range[0], 0, 0), + ]).redim.range( + x=self.x_range, y=self.y_range + ) + + state = self._get_plot_state(rectangles) + self.assertEqual(state["data"][1]["type"], "scattermapbox") + self.assertEqual(state["data"][1]["mode"], "lines") + self.assertEqual(state["data"][1]["showlegend"], False) + self.assertEqual(state["data"][1]["line"]["color"], default_shape_color) + self.assertEqual(state["data"][1]["lon"], np.array([ + 0, self.lon_range[1], np.nan, + self.lon_range[0], 0, np.nan + ])) + self.assertEqual(state["data"][1]["lat"], np.array([ + 0, self.lat_range[1], np.nan, + self.lat_range[0], 0, np.nan + ])) + + self.assertEqual( + state['layout']['mapbox']['center'], { + 'lat': self.lat_center, 'lon': self.lon_center + } + ) diff --git a/holoviews/tests/plotting/plotly/testtiles.py b/holoviews/tests/plotting/plotly/testtiles.py new file mode 100644 index 0000000000..13c48bf877 --- /dev/null +++ b/holoviews/tests/plotting/plotly/testtiles.py @@ -0,0 +1,213 @@ +from holoviews.element import RGB, Tiles, Points, Bounds +from holoviews.element.tiles import StamenTerrain, _ATTRIBUTIONS +from .testplot import TestPlotlyPlot, plotly_renderer +import numpy as np + + +class TestMapboxTilesPlot(TestPlotlyPlot): + def setUp(self): + super(TestMapboxTilesPlot, self).setUp() + + # Precompute coordinates + self.xs = [3000000, 2000000, 1000000] + self.ys = [-3000000, -2000000, -1000000] + self.x_range = (-5000000, 4000000) + self.x_center = sum(self.x_range) / 2.0 + self.y_range = (-3000000, 2000000) + self.y_center = sum(self.y_range) / 2.0 + self.lon_range, self.lat_range = Tiles.easting_northing_to_lon_lat(self.x_range, self.y_range) + self.lon_centers, self.lat_centers = Tiles.easting_northing_to_lon_lat( + [self.x_center], [self.y_center] + ) + self.lon_center, self.lat_center = self.lon_centers[0], self.lat_centers[0] + self.lons, self.lats = Tiles.easting_northing_to_lon_lat(self.xs, self.ys) + + def test_mapbox_tiles_defaults(self): + tiles = Tiles("").redim.range( + x=self.x_range, y=self.y_range + ) + + fig_dict = plotly_renderer.get_plot_state(tiles) + + # Check dummy trace + self.assertEqual(len(fig_dict["data"]), 1) + dummy_trace = fig_dict["data"][0] + self.assertEqual(dummy_trace["type"], "scattermapbox") + self.assertEqual(dummy_trace["lon"], []) + self.assertEqual(dummy_trace["lat"], []) + self.assertEqual(dummy_trace["showlegend"], False) + + # Check mapbox subplot + subplot = fig_dict["layout"]["mapbox"] + self.assertEqual(subplot["style"], "white-bg") + self.assertEqual( + subplot['center'], {'lat': self.lat_center, 'lon': self.lon_center} + ) + + # Check that xaxis and yaxis entries are not created + self.assertNotIn("xaxis", fig_dict["layout"]) + self.assertNotIn("yaxis", fig_dict["layout"]) + + # Check no layers are introduced when an empty tile server string is + # passed + layers = fig_dict["layout"]["mapbox"].get("layers", []) + self.assertEqual(len(layers), 0) + + def test_styled_mapbox_tiles(self): + tiles = Tiles("").opts(mapboxstyle="dark", accesstoken="token-str").redim.range( + x=self.x_range, y=self.y_range + ) + + fig_dict = plotly_renderer.get_plot_state(tiles) + + # Check mapbox subplot + subplot = fig_dict["layout"]["mapbox"] + self.assertEqual(subplot["style"], "dark") + self.assertEqual(subplot["accesstoken"], "token-str") + self.assertEqual( + subplot['center'], {'lat': self.lat_center, 'lon': self.lon_center} + ) + + def test_raster_layer(self): + tiles = StamenTerrain().redim.range( + x=self.x_range, y=self.y_range + ).opts(alpha=0.7, min_zoom=3, max_zoom=7) + + fig_dict = plotly_renderer.get_plot_state(tiles) + + # Check dummy trace + self.assertEqual(len(fig_dict["data"]), 1) + dummy_trace = fig_dict["data"][0] + self.assertEqual(dummy_trace["type"], "scattermapbox") + self.assertEqual(dummy_trace["lon"], []) + self.assertEqual(dummy_trace["lat"], []) + self.assertEqual(dummy_trace["showlegend"], False) + + # Check mapbox subplot + subplot = fig_dict["layout"]["mapbox"] + self.assertEqual(subplot["style"], "white-bg") + self.assertEqual( + subplot['center'], {'lat': self.lat_center, 'lon': self.lon_center} + ) + + # Check for raster layer + layers = fig_dict["layout"]["mapbox"].get("layers", []) + self.assertEqual(len(layers), 1) + layer = layers[0] + self.assertEqual(layer["source"][0].lower(), tiles.data.lower()) + self.assertEqual(layer["opacity"], 0.7) + self.assertEqual(layer["sourcetype"], "raster") + self.assertEqual(layer["minzoom"], 3) + self.assertEqual(layer["maxzoom"], 7) + self.assertEqual(layer["sourceattribution"], _ATTRIBUTIONS[('stamen', 'com/t')]) + + def test_overlay(self): + # Base layer is mapbox vector layer + tiles = Tiles("").opts(mapboxstyle="dark", accesstoken="token-str") + + # Raster tile layer + stamen_raster = StamenTerrain().opts(alpha=0.7) + + # RGB layer + rgb_data = np.random.rand(10, 10, 3) + rgb = RGB( + rgb_data, + bounds=(self.x_range[0], self.y_range[0], self.x_range[1], self.y_range[1]) + ).opts( + opacity=0.5 + ) + + # Points layer + points = Points([(0, 0), (self.x_range[1], self.y_range[1])]).opts( + show_legend=True + ) + + # Bounds + bounds = Bounds((self.x_range[0], self.y_range[0], 0, 0)) + + # Overlay + overlay = (tiles * stamen_raster * rgb * points * bounds).redim.range( + x=self.x_range, y=self.y_range + ) + + # Render to plotly figure dictionary + fig_dict = plotly_renderer.get_plot_state(overlay) + + # Check number of traces and layers + traces = fig_dict["data"] + subplot = fig_dict["layout"]["mapbox"] + layers = subplot["layers"] + + self.assertEqual(len(traces), 5) + self.assertEqual(len(layers), 2) + + # Check vector layer + dummy_trace = traces[0] + self.assertEqual(dummy_trace["type"], "scattermapbox") + self.assertEqual(dummy_trace["lon"], []) + self.assertEqual(dummy_trace["lat"], []) + self.assertFalse(dummy_trace["showlegend"]) + + self.assertEqual(subplot["style"], "dark") + self.assertEqual(subplot["accesstoken"], "token-str") + self.assertEqual( + subplot['center'], {'lat': self.lat_center, 'lon': self.lon_center} + ) + + # Check raster layer + dummy_trace = traces[1] + raster_layer = layers[0] + self.assertEqual(dummy_trace["type"], "scattermapbox") + self.assertEqual(dummy_trace["lon"], []) + self.assertEqual(dummy_trace["lat"], []) + self.assertFalse(dummy_trace["showlegend"]) + + # Check raster_layer + self.assertEqual(raster_layer["below"], "traces") + self.assertEqual(raster_layer["opacity"], 0.7) + self.assertEqual(raster_layer["sourcetype"], "raster") + self.assertEqual(raster_layer["source"][0].lower(), stamen_raster.data.lower()) + + # Check RGB layer + dummy_trace = traces[2] + rgb_layer = layers[1] + self.assertEqual(dummy_trace["type"], "scattermapbox") + self.assertEqual(dummy_trace["lon"], [None]) + self.assertEqual(dummy_trace["lat"], [None]) + self.assertFalse(dummy_trace["showlegend"]) + + # Check rgb_layer + self.assertEqual(rgb_layer["below"], "traces") + self.assertEqual(rgb_layer["opacity"], 0.5) + self.assertEqual(rgb_layer["sourcetype"], "image") + self.assertTrue(rgb_layer["source"].startswith("data:image/png;base64,iVBOR")) + self.assertEqual(rgb_layer["coordinates"], [ + [self.lon_range[0], self.lat_range[1]], + [self.lon_range[1], self.lat_range[1]], + [self.lon_range[1], self.lat_range[0]], + [self.lon_range[0], self.lat_range[0]] + ]) + + # Check Points layer + points_trace = traces[3] + self.assertEqual(points_trace["type"], "scattermapbox") + self.assertEqual(points_trace["lon"], np.array([0, self.lon_range[1]])) + self.assertEqual(points_trace["lat"], np.array([0, self.lat_range[1]])) + self.assertEqual(points_trace["mode"], "markers") + self.assertTrue(points_trace.get("showlegend", True)) + + # Check Bounds layer + bounds_trace = traces[4] + self.assertEqual(bounds_trace["type"], "scattermapbox") + self.assertEqual(bounds_trace["lon"], np.array([ + self.lon_range[0], self.lon_range[0], 0, 0, self.lon_range[0] + ])) + self.assertEqual(bounds_trace["lat"], np.array([ + self.lat_range[0], 0, 0, self.lat_range[0], self.lat_range[0] + ])) + self.assertEqual(bounds_trace["mode"], "lines") + self.assertTrue(points_trace["showlegend"], False) + + # No xaxis/yaxis + self.assertNotIn("xaxis", fig_dict["layout"]) + self.assertNotIn("yaxis", fig_dict["layout"]) diff --git a/holoviews/util/transform.py b/holoviews/util/transform.py index 46050b2eb7..cd97870079 100644 --- a/holoviews/util/transform.py +++ b/holoviews/util/transform.py @@ -859,3 +859,74 @@ def _coerce(self, dataset): if self.interface_applies(dataset, coerce=False): return dataset return dataset.clone(datatype=['xarray']) + + +def lon_lat_to_easting_northing(longitude, latitude): + """ + Projects the given longitude, latitude values into Web Mercator + (aka Pseudo-Mercator or EPSG:3857) coordinates. + + Longitude and latitude can be provided as scalars, Pandas columns, + or Numpy arrays, and will be returned in the same form. Lists + or tuples will be converted to Numpy arrays. + + Args: + longitude + latitude + + Returns: + (easting, northing) + + Examples: + easting, northing = lon_lat_to_easting_northing(-74,40.71) + + easting, northing = lon_lat_to_easting_northing( + np.array([-74]),np.array([40.71]) + ) + + df=pandas.DataFrame(dict(longitude=np.array([-74]),latitude=np.array([40.71]))) + df.loc[:, 'longitude'], df.loc[:, 'latitude'] = lon_lat_to_easting_northing( + df.longitude,df.latitude + ) + """ + if isinstance(longitude, (list, tuple)): + longitude = np.array(longitude) + if isinstance(latitude, (list, tuple)): + latitude = np.array(latitude) + + origin_shift = np.pi * 6378137 + easting = longitude * origin_shift / 180.0 + with np.errstate(divide='ignore', invalid='ignore'): + northing = np.log( + np.tan((90 + latitude) * np.pi / 360.0) + ) * origin_shift / np.pi + return easting, northing + + +def easting_northing_to_lon_lat(easting, northing): + """ + Projects the given easting, northing values into + longitude, latitude coordinates. + + easting and northing values are assumed to be in Web Mercator + (aka Pseudo-Mercator or EPSG:3857) coordinates. + + Args: + easting + northing + + Returns: + (longitude, latitude) + """ + if isinstance(easting, (list, tuple)): + easting = np.array(easting) + if isinstance(northing, (list, tuple)): + northing = np.array(northing) + + origin_shift = np.pi * 6378137 + longitude = easting * 180.0 / origin_shift + with np.errstate(divide='ignore'): + latitude = np.arctan( + np.exp(northing * np.pi / origin_shift) + ) * 360.0 / np.pi - 90 + return longitude, latitude