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