Repository: holoviz/hvplot

Issue:
Tile + multiple Point breaks
#### ALL software version info

- geoviews 1.10.0
- hvplot 0.8.4
- holoviews 1.16.2

#### Description of expected behavior and the observed behavior

Overlapping a tile plus multiple Points plots breaks.
This used to be working with earlier versions.

#### Complete, minimal, self-contained example code that reproduces the issue

```
import pandas as pd
import hvplot.pandas

df = pd.DataFrame(dict(lon=[0, 10], lat=[40, 50], v=[0,1]))

(
df.hvplot.points(x="lon", y="lat", s=200, c="y", geo=True, tiles='CartoLight')
*df.hvplot.points(x="lon", y="lat", c="v", geo=True)
)
```

#### Stack traceback and/or browser JavaScript console output

```
---------------------------------------------------------------------------
Exception                                 Traceback (most recent call last)
File ~/.miniconda3/envs/pynsitu/lib/python3.10/site-packages/IPython/core/formatters.py:974, in MimeBundleFormatter.__call__(self, obj, include, exclude)
    971     method = get_real_method(obj, self.print_method)
    973     if method is not None:
--> 974         return method(include=include, exclude=exclude)
    975     return None
    976 else:

File ~/.miniconda3/envs/pynsitu/lib/python3.10/site-packages/holoviews/core/dimension.py:1290, in Dimensioned._repr_mimebundle_(self, include, exclude)
   1283 def _repr_mimebundle_(self, include=None, exclude=None):
   1284     """
   1285     Resolves the class hierarchy for the class rendering the
   1286     object using any display hooks registered on Store.display
   1287     hooks.  The output of all registered display_hooks is then
   1288     combined and returned.
   1289     """
-> 1290     return Store.render(self)

File ~/.miniconda3/envs/pynsitu/lib/python3.10/site-packages/holoviews/core/options.py:1425, in Store.render(cls, obj)
   1423 data, metadata = {}, {}
   1424 for hook in hooks:
-> 1425     ret = hook(obj)
   1426     if ret is None:
   1427         continue

File ~/.miniconda3/envs/pynsitu/lib/python3.10/site-packages/holoviews/ipython/display_hooks.py:280, in pprint_display(obj)
    278 if not ip.display_formatter.formatters['text/plain'].pprint:
    279     return None
--> 280 return display(obj, raw_output=True)

File ~/.miniconda3/envs/pynsitu/lib/python3.10/site-packages/holoviews/ipython/display_hooks.py:248, in display(obj, raw_output, **kwargs)
    246 elif isinstance(obj, (CompositeOverlay, ViewableElement)):
    247     with option_state(obj):
--> 248         output = element_display(obj)
    249 elif isinstance(obj, (Layout, NdLayout, AdjointLayout)):
    250     with option_state(obj):

File ~/.miniconda3/envs/pynsitu/lib/python3.10/site-packages/holoviews/ipython/display_hooks.py:142, in display_hook.<locals>.wrapped(element)
    140 try:
    141     max_frames = OutputSettings.options['max_frames']
--> 142     mimebundle = fn(element, max_frames=max_frames)
    143     if mimebundle is None:
    144         return {}, {}

File ~/.miniconda3/envs/pynsitu/lib/python3.10/site-packages/holoviews/ipython/display_hooks.py:188, in element_display(element, max_frames)
    185 if type(element) not in Store.registry[backend]:
    186     return None
--> 188 return render(element)

File ~/.miniconda3/envs/pynsitu/lib/python3.10/site-packages/holoviews/ipython/display_hooks.py:69, in render(obj, **kwargs)
     66 if renderer.fig == 'pdf':
     67     renderer = renderer.instance(fig='png')
---> 69 return renderer.components(obj, **kwargs)

File ~/.miniconda3/envs/pynsitu/lib/python3.10/site-packages/holoviews/plotting/renderer.py:399, in Renderer.components(self, obj, fmt, comm, **kwargs)
    396 embed = (not (dynamic or streams or self.widget_mode == 'live') or config.embed)
    398 if embed or config.comms == 'default':
--> 399     return self._render_panel(plot, embed, comm)
    400 return self._render_ipywidget(plot)

File ~/.miniconda3/envs/pynsitu/lib/python3.10/site-packages/holoviews/plotting/renderer.py:406, in Renderer._render_panel(self, plot, embed, comm)
    404 doc = Document()
    405 with config.set(embed=embed):
--> 406     model = plot.layout._render_model(doc, comm)
    407 if embed:
    408     return render_model(model, comm)

File ~/.miniconda3/envs/pynsitu/lib/python3.10/site-packages/panel/viewable.py:736, in Viewable._render_model(self, doc, comm)
    734 if comm is None:
    735     comm = state._comm_manager.get_server_comm()
--> 736 model = self.get_root(doc, comm)
    738 if self._design and self._design.theme.bokeh_theme:
    739     doc.theme = self._design.theme.bokeh_theme

File ~/.miniconda3/envs/pynsitu/lib/python3.10/site-packages/panel/layout/base.py:286, in Panel.get_root(self, doc, comm, preprocess)
    282 def get_root(
    283     self, doc: Optional[Document] = None, comm: Optional[Comm] = None,
    284     preprocess: bool = True
    285 ) -> Model:
--> 286     root = super().get_root(doc, comm, preprocess)
    287     # ALERT: Find a better way to handle this
    288     if hasattr(root, 'styles') and 'overflow-x' in root.styles:

File ~/.miniconda3/envs/pynsitu/lib/python3.10/site-packages/panel/viewable.py:658, in Renderable.get_root(self, doc, comm, preprocess)
    656 wrapper = self._design._wrapper(self)
    657 if wrapper is self:
--> 658     root = self._get_model(doc, comm=comm)
    659     if preprocess:
    660         self._preprocess(root)

File ~/.miniconda3/envs/pynsitu/lib/python3.10/site-packages/panel/layout/base.py:170, in Panel._get_model(self, doc, root, parent, comm)
    168 root = root or model
    169 self._models[root.ref['id']] = (model, parent)
--> 170 objects, _ = self._get_objects(model, [], doc, root, comm)
    171 props = self._get_properties(doc)
    172 props[self._property_mapping['objects']] = objects

File ~/.miniconda3/envs/pynsitu/lib/python3.10/site-packages/panel/layout/base.py:155, in Panel._get_objects(self, model, old_objects, doc, root, comm)
    153 else:
    154     try:
--> 155         child = pane._get_model(doc, root, model, comm)
    156     except RerenderError:
    157         return self._get_objects(model, current_objects[:i], doc, root, comm)

File ~/.miniconda3/envs/pynsitu/lib/python3.10/site-packages/panel/pane/holoviews.py:380, in HoloViews._get_model(self, doc, root, parent, comm)
    378     plot = self.object
    379 else:
--> 380     plot = self._render(doc, comm, root)
    382 plot.pane = self
    383 backend = plot.renderer.backend

File ~/.miniconda3/envs/pynsitu/lib/python3.10/site-packages/panel/pane/holoviews.py:461, in HoloViews._render(self, doc, comm, root)
    458     if comm:
    459         kwargs['comm'] = comm
--> 461 return renderer.get_plot(self.object, **kwargs)

File ~/.miniconda3/envs/pynsitu/lib/python3.10/site-packages/holoviews/plotting/bokeh/renderer.py:70, in BokehRenderer.get_plot(self_or_cls, obj, doc, renderer, **kwargs)
     63 @bothmethod
     64 def get_plot(self_or_cls, obj, doc=None, renderer=None, **kwargs):
     65     """
     66     Given a HoloViews Viewable return a corresponding plot instance.
     67     Allows supplying a document attach the plot to, useful when
     68     combining the bokeh model with another plot.
     69     """
---> 70     plot = super().get_plot(obj, doc, renderer, **kwargs)
     71     if plot.document is None:
     72         plot.document = Document() if self_or_cls.notebook_context else curdoc()

File ~/.miniconda3/envs/pynsitu/lib/python3.10/site-packages/holoviews/plotting/renderer.py:236, in Renderer.get_plot(self_or_cls, obj, doc, renderer, comm, **kwargs)
    234 if isinstance(obj, AdjointLayout):
    235     obj = Layout(obj)
--> 236 plot = self_or_cls.plotting_class(obj)(obj, renderer=renderer,
    237                                        **plot_opts)
    238 defaults = [kd.default for kd in plot.dimensions]
    239 init_key = tuple(v if d is None else d for v, d in
    240                  zip(plot.keys[0], defaults))

File ~/.miniconda3/envs/pynsitu/lib/python3.10/site-packages/geoviews/plotting/bokeh/plot.py:179, in GeoOverlayPlot.__init__(self, element, **params)
    178 def __init__(self, element, **params):
--> 179     super().__init__(element, **params)
    180     self.geographic = any(element.traverse(is_geographic, [_Element]))
    181     if self.geographic:

File ~/.miniconda3/envs/pynsitu/lib/python3.10/site-packages/geoviews/plotting/bokeh/plot.py:57, in GeoPlot.__init__(self, element, **params)
     56 def __init__(self, element, **params):
---> 57     super().__init__(element, **params)
     58     self.geographic = is_geographic(self.hmap.last)
     59     if self.geographic and not isinstance(self.projection, (PlateCarree, Mercator)):

File ~/.miniconda3/envs/pynsitu/lib/python3.10/site-packages/holoviews/plotting/plot.py:1535, in GenericOverlayPlot.__init__(self, overlay, ranges, batched, keys, group_counter, **params)
   1533 def __init__(self, overlay, ranges=None, batched=True, keys=None, group_counter=None, **params):
   1534     if 'projection' not in params:
-> 1535         params['projection'] = self._get_projection(overlay)
   1537     super().__init__(overlay, ranges=ranges, keys=keys,
   1538                      batched=batched, **params)
   1540     # Apply data collapse

File ~/.miniconda3/envs/pynsitu/lib/python3.10/site-packages/geoviews/plotting/plot.py:42, in ProjectionPlot._get_projection(self, obj)
     39 custom_projs = [p for p in projections if p is not None]
     41 if len(set([type(p) for p in custom_projs])) > 1:
---> 42     raise Exception("An axis may only be assigned one projection type")
     43 elif custom_projs:
     44     return custom_projs[0]

Exception: An axis may only be assigned one projection type
```



Discussion:
A workaround is to add `tiles='CartoLight'` to both of the plots.
Thanks for the feeback !

I tried it earlier and it hides the first set of points unfortunately and is hence not satisfactory.
A bisect shows this regression was introduced in #1053.

For now, you should get the functionality back by downgrading it to 0.8.3. 

Or adding: `projection=cartopy.crs.GOOGLE_MERCATOR` to both of the plots
The suggested fix works like a charm, thx a bunch @Hoxbro !

Resolving patch:
diff --git a/hvplot/converter.py b/hvplot/converter.py
--- a/hvplot/converter.py
+++ b/hvplot/converter.py
@@ -446,20 +446,19 @@ def __init__(
                         "Projection must be defined as cartopy CRS or "
                         f"one of the following CRS string:\n {all_crs}")
 
-            projection = projection or (ccrs.GOOGLE_MERCATOR if tiles else self.crs)
-            if tiles and projection != ccrs.GOOGLE_MERCATOR:
+            self.output_projection = projection or (ccrs.GOOGLE_MERCATOR if tiles else self.crs)
+            if tiles and self.output_projection != ccrs.GOOGLE_MERCATOR:
                 raise ValueError(
                     "Tiles can only be used with output projection of "
                     "`cartopy.crs.GOOGLE_MERCATOR`. To get rid of this error "
                     "remove `projection=` or `tiles=`"
                 )
-
-            if self.crs != projection:
+            if self.crs != projection and (xlim or ylim):
                 px0, py0, px1, py1 = ccrs.GOOGLE_MERCATOR.boundary.bounds
                 x0, x1 = xlim or (px0, px1)
                 y0, y1 = ylim or (py0, py1)
                 extents = (x0, y0, x1, y1)
-                x0, y0, x1, y1 = project_extents(extents, self.crs, projection)
+                x0, y0, x1, y1 = project_extents(extents, self.crs, self.output_projection)
                 if xlim:
                     xlim = (x0, x1)
                 if ylim:
@@ -1363,6 +1362,10 @@ def method_wrapper(ds, x, y):
             if self._dim_ranges.get('c', (None, None)) != (None, None):
                 style['clim'] = self._dim_ranges['c']
 
+        if self.geo and self.crs != self.output_projection:
+            import geoviews as gv
+            obj = gv.project(obj, projection=self.output_projection)
+
         processed = operation(obj, **opts)
 
         if self.dynspread:
@@ -1389,7 +1392,7 @@ def _apply_layers(self, obj):
                 param.main.param.warning(
                     "coastline scale of %s not recognized, must be one "
                     "'10m', '50m' or '110m'." % self.coastline)
-            obj = obj * coastline
+            obj = obj * coastline.opts(projection=self.output_projection)
 
         if self.features:
             import geoviews as gv
@@ -1411,32 +1414,46 @@ def _apply_layers(self, obj):
                     else:
                         feature_obj = feature_obj.opts(scale=scale)
                 if feature_obj.group in ["Land", "Ocean"]:
-                    obj = feature_obj * obj  # Underlay land/ocean
+                    # Underlay land/ocean
+                    obj = feature_obj.opts(projection=self.output_projection) * obj
                 else:
-                    obj = obj * feature_obj  # overlay everything else
-
-        if self.tiles:
-            tile_source = 'EsriImagery' if self.tiles == 'ESRI' else self.tiles
-            warning = ("{} tiles not recognized, must be one of: {} or a tile object".format(tile_source, sorted(hv.element.tile_sources)))
-            if tile_source is True:
-                tiles = hv.element.tiles.OSM()
-            elif tile_source in hv.element.tile_sources.keys():
-                tiles = hv.element.tile_sources[tile_source]()
-            elif tile_source in hv.element.tile_sources.values():
-                tiles = tile_source()
-            elif isinstance(tile_source, hv.element.tiles.Tiles):
-                tiles = tile_source
-            elif self.geo:
-                from geoviews.element import WMTS
-                if isinstance(tile_source, WMTS):
-                    tiles = tile_source
-                else:
-                    param.main.param.warning(warning)
-            else:
-                param.main.param.warning(warning)
+                    # overlay everything else
+                    obj = obj * feature_obj.opts(projection=self.output_projection)
+
+        if self.tiles and not self.geo:
+            tiles = self._get_tiles(
+                self.tiles,
+                hv.element.tile_sources,
+                hv.element.tiles.Tiles
+            )
+            obj = tiles * obj
+        elif self.tiles and self.geo:
+            import geoviews as gv
+            tiles = self._get_tiles(
+                self.tiles,
+                gv.tile_sources.tile_sources,
+                (gv.element.WMTS, hv.element.tiles.Tiles),
+            )
             obj = tiles * obj
         return obj
 
+    def _get_tiles(self, source, sources, types):
+        tile_source = 'EsriImagery' if self.tiles == 'ESRI' else self.tiles
+        if tile_source is True:
+            tiles = sources["OSM"]()
+        elif tile_source in sources:
+            tiles = sources[tile_source]()
+        elif tile_source in sources.values():
+            tiles = tile_source()
+        elif isinstance(tile_source, types):
+            tiles = tile_source
+        else:
+            msg = (
+                f"{tile_source} tiles not recognized, must be one of: {sorted(sources)} or a tile object"
+            )
+            raise ValueError(msg)
+        return tiles
+
     def _merge_redim(self, ranges, attr='range'):
         redim = dict(self._redim)
         for k, r in ranges.items():


Test patch:
diff --git a/hvplot/tests/testgeo.py b/hvplot/tests/testgeo.py
--- a/hvplot/tests/testgeo.py
+++ b/hvplot/tests/testgeo.py
@@ -111,6 +111,43 @@ def test_plot_with_projection_raises_an_error_when_tiles_set(self):
         with self.assertRaisesRegex(ValueError, "Tiles can only be used with output projection"):
             da.hvplot.image('x', 'y', crs=self.crs, projection='Robinson', tiles=True)
 
+    def test_overlay_with_projection(self):
+        # Regression test for https://github.com/holoviz/hvplot/issues/1090
+        df = pd.DataFrame({"lon": [0, 10], "lat": [40, 50], "v": [0, 1]})
+
+        plot1 = df.hvplot.points(x="lon", y="lat", s=200, c="y", geo=True, tiles="CartoLight")
+        plot2 = df.hvplot.points(x="lon", y="lat", c="v", geo=True)
+
+        # This should work without erroring
+        plot = plot1 * plot2
+        hv.renderer("bokeh").get_plot(plot)
+
+    def test_geo_with_rasterize(self):
+        import xarray as xr
+        import cartopy.crs as ccrs
+        import geoviews as gv
+        try:
+            from holoviews.operation.datashader import rasterize
+        except:
+            raise SkipTest('datashader not available')
+
+        ds = xr.tutorial.open_dataset("air_temperature")
+        hvplot_output = ds.isel(time=0).hvplot.points(
+            "lon",
+            "lat",
+            crs=ccrs.PlateCarree(),
+            projection=ccrs.LambertConformal(),
+            rasterize=True,
+            dynamic=False,
+            aggregator="max",
+        )
+
+        p1 = gv.Points(ds.isel(time=0), kdims=["lon", "lat"], crs=ccrs.PlateCarree())
+        p2 = gv.project(p1, projection=ccrs.LambertConformal())
+        expected = rasterize(p2, dynamic=False, aggregator="max")
+
+        xr.testing.assert_allclose(hvplot_output.data, expected.data)
+
 
 class TestGeoAnnotation(TestCase):
 
@@ -141,27 +178,55 @@ def test_plot_with_coastline_sets_geo_by_default(self):
     def test_plot_with_coastline_scale(self):
         plot = self.df.hvplot.points('x', 'y', geo=True, coastline='10m')
         opts = plot.get(1).opts.get('plot')
-        self.assertEqual(opts.kwargs, {'scale': '10m'})
+        assert opts.kwargs["scale"] == '10m'
 
     def test_plot_with_tiles(self):
-        plot = self.df.hvplot.points('x', 'y', geo=True, tiles=True)
+        plot = self.df.hvplot.points('x', 'y', geo=False, tiles=True)
         self.assertEqual(len(plot), 2)
         self.assertIsInstance(plot.get(0), hv.Tiles)
         self.assertIn('openstreetmap', plot.get(0).data)
 
+    def test_plot_with_tiles_with_geo(self):
+        import geoviews as gv
+
+        plot = self.df.hvplot.points('x', 'y', geo=True, tiles=True)
+        self.assertEqual(len(plot), 2)
+        self.assertIsInstance(plot.get(0), gv.element.WMTS)
+        self.assertIn('openstreetmap', plot.get(0).data)
+
     def test_plot_with_specific_tiles(self):
-        plot = self.df.hvplot.points('x', 'y', geo=True, tiles='ESRI')
+        plot = self.df.hvplot.points('x', 'y', geo=False, tiles='ESRI')
         self.assertEqual(len(plot), 2)
         self.assertIsInstance(plot.get(0), hv.Tiles)
         self.assertIn('ArcGIS', plot.get(0).data)
 
+    def test_plot_with_specific_tiles_geo(self):
+        import geoviews as gv
+        plot = self.df.hvplot.points('x', 'y', geo=True, tiles='ESRI')
+        self.assertEqual(len(plot), 2)
+        self.assertIsInstance(plot.get(0), gv.element.WMTS)
+        self.assertIn('ArcGIS', plot.get(0).data)
+
     def test_plot_with_specific_tile_class(self):
-        plot = self.df.hvplot.points('x', 'y', geo=True, tiles=hv.element.tiles.EsriImagery)
+        plot = self.df.hvplot.points('x', 'y', geo=False, tiles=hv.element.tiles.EsriImagery)
         self.assertEqual(len(plot), 2)
         self.assertIsInstance(plot.get(0), hv.Tiles)
         self.assertIn('ArcGIS', plot.get(0).data)
 
+    def test_plot_with_specific_tile_class_with_geo(self):
+        import geoviews as gv
+        plot = self.df.hvplot.points('x', 'y', geo=True, tiles=gv.tile_sources.EsriImagery)
+        self.assertEqual(len(plot), 2)
+        self.assertIsInstance(plot.get(0), gv.element.WMTS)
+        self.assertIn('ArcGIS', plot.get(0).data)
+
     def test_plot_with_specific_tile_obj(self):
+        plot = self.df.hvplot.points('x', 'y', geo=False, tiles=hv.element.tiles.EsriImagery())
+        self.assertEqual(len(plot), 2)
+        self.assertIsInstance(plot.get(0), hv.Tiles)
+        self.assertIn('ArcGIS', plot.get(0).data)
+
+    def test_plot_with_specific_tile_obj_with_geo(self):
         plot = self.df.hvplot.points('x', 'y', geo=True, tiles=hv.element.tiles.EsriImagery())
         self.assertEqual(len(plot), 2)
         self.assertIsInstance(plot.get(0), hv.Tiles)
@@ -294,10 +359,16 @@ def test_points_hover_cols_with_by_set_to_name(self):
             assert element.vdims == []
 
     def test_points_project_xlim_and_ylim(self):
+        points = self.cities.hvplot(geo=False, xlim=(-10, 10), ylim=(-20, -10))
+        opts = hv.Store.lookup_options('bokeh', points, 'plot').options
+        np.testing.assert_equal(opts['xlim'], (-10, 10))
+        np.testing.assert_equal(opts['ylim'], (-20, -10))
+
+    def test_points_project_xlim_and_ylim_with_geo(self):
         points = self.cities.hvplot(geo=True, xlim=(-10, 10), ylim=(-20, -10))
         opts = hv.Store.lookup_options('bokeh', points, 'plot').options
-        assert opts['xlim'] == (-10, 10)
-        assert opts['ylim'] == (-20, -10)
+        np.testing.assert_allclose(opts['xlim'], (-10, 10))
+        np.testing.assert_allclose(opts['ylim'], (-20, -10))
 
     def test_polygons_by_subplots(self):
         polygons = self.polygons.hvplot(geo=True, by="name", subplots=True)