Repository: holoviz/hvplot

Issue:
Add options for saving hvPlotExplorer settings and only show the plot
Maybe I have overlooked something, but it could be nice to be able to save the settings from `hvPlotExplorer` so it can easily be recreated in another session.  

I'm thinking about something like this:

``` python
## First input cell in a notebook
import hvplot.ui
import hvplot.pandas
import panel as pn
pn.extension()

from bokeh.sampledata import penguins

df = penguins.data
dfe = hvplot.ui.hvPlotExplorer(df)

## First output cell
# Play around with the Explorer until you have the plot you want

## Second input cell
dfe_settings = dfe.save()  # Returns a dict with the settings, which could be saved to a JSON file.

## Third input cell or another notebook where you have loaded the settings
# Load the settings into a new hvPlotExplorer
dfe2 = hvplot.ui.hvPlotExplorer.load(df, dfe_settings)
# or 
dfe2 = hvplot.ui.hvPlotExplorer(df, settings=dfe_settings)
```

Here a natural thing would be to only show the plot because all the settings have already been determined. Maybe with a function call like `dfe2.output()` or `dfe2.plot()`. 
 



Discussion:
Yes, definitely! To demo in the docs I think you'd use code like:

```python
# First notebook cell
settings = {}
if os.exists("somefile.json"):
    with open("settings.json") as f:
        settings = json.load(f)
dfe = hvplot.ui.hvPlotExplorer(df, **settings)

# Second notebook cell
with open("settings.json", 'w') as f:
    json.dump(settings, f)
```

That way the notebook will run properly the first time and when used with Run All, but when run cell-by-cell it will save the settings selected interactively for use next time.



Resolving patch:
diff --git a/hvplot/__init__.py b/hvplot/__init__.py
--- a/hvplot/__init__.py
+++ b/hvplot/__init__.py
@@ -68,6 +68,7 @@
 
 from .converter import HoloViewsConverter
 from .interactive import Interactive
+from .ui import explorer  # noqa
 from .utilities import hvplot_extension, output, save, show # noqa
 from .plotting import (hvPlot, hvPlotTabular,  # noqa
                        andrews_curves, lag_plot,
diff --git a/hvplot/ui.py b/hvplot/ui.py
--- a/hvplot/ui.py
+++ b/hvplot/ui.py
@@ -1,3 +1,4 @@
+import holoviews as _hv
 import numpy as np
 import panel as pn
 import param
@@ -7,17 +8,18 @@
 from holoviews.plotting.util import list_cmaps
 from panel.viewable import Viewer
 
-from .converter import HoloViewsConverter as hvConverter
+from .converter import HoloViewsConverter as _hvConverter
+from .plotting import hvPlot as _hvPlot
 from .util import is_geodataframe, is_xarray
 
 # Defaults
-DATAFRAME_KINDS = sorted(set(hvConverter._kind_mapping) - set(hvConverter._gridded_types))
-GRIDDED_KINDS = sorted(hvConverter._kind_mapping)
+DATAFRAME_KINDS = sorted(set(_hvConverter._kind_mapping) - set(_hvConverter._gridded_types))
+GRIDDED_KINDS = sorted(_hvConverter._kind_mapping)
 GEOM_KINDS = ['paths', 'polygons', 'points']
 STATS_KINDS = ['hist', 'kde', 'boxwhisker', 'violin', 'heatmap', 'bar', 'barh']
 TWOD_KINDS = ['bivariate', 'heatmap', 'hexbin', 'labels', 'vectorfield'] + GEOM_KINDS
 CMAPS = [cm for cm in list_cmaps() if not cm.endswith('_r_r')]
-DEFAULT_CMAPS = hvConverter._default_cmaps
+DEFAULT_CMAPS = _hvConverter._default_cmaps
 GEO_FEATURES = [
     'borders', 'coastline', 'land', 'lakes', 'ocean', 'rivers',
     'states', 'grid'
@@ -27,6 +29,26 @@
 MAX_ROWS = 10000
 
 
+def explorer(data, **kwargs):
+    """Explore your data by building a plot in a Panel UI component.
+
+    This function returns a Panel component that has on the right-side
+    hand a plot view and on the left-side hand a number of widgets that
+    control the plot.
+
+    Parameters
+    ----------
+    data : pandas.DataFrame
+        Data structure to explore.
+
+    Returns
+    -------
+    hvplotExporer
+        Panel component to explore a dataset.
+    """
+    return hvPlotExplorer.from_data(data, **kwargs)
+
+
 class Controls(Viewer):
 
     explorer = param.ClassSelector(class_=Viewer, precedence=-1)
@@ -81,7 +103,7 @@ def __init__(self, data, **params):
 
     @property
     def colormapped(self):
-        if self.explorer.kind in hvConverter._colorbar_types:
+        if self.explorer.kind in _hvConverter._colorbar_types:
             return True
         return self.color is not None and self.color in self._data
 
@@ -89,7 +111,7 @@ def colormapped(self):
     def _update_coloropts(self):
         if not self.colormapped or self.cmap not in list(DEFAULT_CMAPS.values()):
             return
-        if self.explorer.kind in hvConverter._colorbar_types:
+        if self.explorer.kind in _hvConverter._colorbar_types:
             key = 'diverging' if self.symmetric else 'linear'
             self.colorbar = True
         elif self.color in self._data:
@@ -112,7 +134,7 @@ class Style(Controls):
 
 class Axes(Controls):
 
-    legend = param.Selector(default='right', objects=hvConverter._legend_positions)
+    legend = param.Selector(default='right', objects=_hvConverter._legend_positions)
 
     logx = param.Boolean(default=False)
 
@@ -278,23 +300,29 @@ class hvPlotExplorer(Viewer):
 
     groupby = param.ListSelector(default=[])
 
+    # Controls that will show up as new tabs, must be ClassSelector
+
     axes = param.ClassSelector(class_=Axes)
 
     colormapping = param.ClassSelector(class_=Colormapping)
 
     labels = param.ClassSelector(class_=Labels)
 
-    geo = param.ClassSelector(class_=Geo)
+    # Hide the geo tab until it's better supported
+    # geo = param.ClassSelector(class_=Geo)
 
     operations = param.ClassSelector(class_=Operations)
 
     style = param.ClassSelector(class_=Style)
 
-    def __new__(cls, data, **params):
+    @classmethod
+    def from_data(cls, data, **params):
         if is_geodataframe(data):
-            cls = hvGeomExplorer
+            # cls = hvGeomExplorer
+            raise TypeError('GeoDataFrame objects not yet supported.')
         elif is_xarray(data):
-            cls = hvGridExplorer
+            # cls = hvGridExplorer
+            raise TypeError('Xarray objects not yet supported.')
         else:
             cls = hvDataFrameExplorer
         return cls(data, **params)
@@ -306,11 +334,19 @@ def __init__(self, df, **params):
         x, y = params.get('x'), params.get('y')
         if 'y' in params:
             params['y_multi'] = params.pop('y') if isinstance(params['y'], list) else [params['y']]
-        converter = hvConverter(
-            df, x, y, **{k: v for k, v in params.items() if k not in ('x', 'y', 'y_multi')}
+        converter = _hvConverter(
+            df, x, y,
+            **{k: v for k, v in params.items() if k not in ('x', 'y', 'y_multi')}
         )
         controller_params = {}
-        for cls in param.concrete_descendents(Controls).values():
+        # Assumes the controls aren't passed on instantiation.
+        controls = [
+            p.class_
+            for p in self.param.params().values()
+            if isinstance(p, param.ClassSelector)
+            and issubclass(p.class_, Controls)
+        ]
+        for cls in controls:
             controller_params[cls] = {
                 k: params.pop(k) for k, v in dict(params).items()
                 if k in cls.param
@@ -320,7 +356,7 @@ def __init__(self, df, **params):
         self._converter = converter
         self._controls = pn.Param(
             self.param, parameters=['kind', 'x', 'y', 'by', 'groupby'],
-            sizing_mode='stretch_width', max_width=300
+            sizing_mode='stretch_width', max_width=300, show_name=False,
         )
         self.param.watch(self._toggle_controls, 'kind')
         self.param.watch(self._check_y, 'y_multi')
@@ -329,13 +365,13 @@ def __init__(self, df, **params):
         self._tabs = pn.Tabs(
             tabs_location='left', width=400
         )
-        controllers = {
+        self._controllers = {
             cls.name.lower(): cls(df, explorer=self, **params)
             for cls, params in controller_params.items()
         }
-        self.param.set_param(**controllers)
+        self.param.set_param(**self._controllers)
         self.param.watch(self._plot, list(self.param))
-        for controller in controllers.values():
+        for controller in self._controllers.values():
             controller.param.watch(self._plot, list(controller.param))
         self._alert = pn.pane.Alert(
             alert_type='danger', visible=False, sizing_mode='stretch_width'
@@ -350,7 +386,8 @@ def __init__(self, df, **params):
             pn.layout.HSpacer(),
             sizing_mode='stretch_both'
         )
-        self.param.trigger('kind')
+        self._toggle_controls()
+        self._plot()
 
     def _populate(self):
         variables = self._converter.variables
@@ -366,12 +403,11 @@ def _populate(self):
                 else:
                     p.objects = variables_no_index
 
-                # Setting the default value
-                if pname == "x" or pname == "y":
+                # Setting the default value if not set
+                if (pname == "x" or pname == "y") and getattr(self, pname, None) is None:
                     setattr(self, pname, p.objects[0])
 
     def _plot(self, *events):
-        self._layout.loading = True
         y = self.y_multi if 'y_multi' in self._controls.parameters else self.y
         if isinstance(y, list) and len(y) == 1:
             y = y[0]
@@ -390,14 +426,15 @@ def _plot(self, *events):
         df = self._data
         if len(df) > MAX_ROWS and not (self.kind in STATS_KINDS or kwargs.get('rasterize') or kwargs.get('datashade')):
             df = df.sample(n=MAX_ROWS)
+        self._layout.loading = True
         try:
-            plot = df.hvplot(
+            self._hvplot = _hvPlot(df)(
                 kind=self.kind, x=self.x, y=y, by=self.by, groupby=self.groupby, **kwargs
             )
-            hvplot = pn.pane.HoloViews(
-                plot, sizing_mode='stretch_width', margin=(0, 20, 0, 20)
+            self._hvpane = pn.pane.HoloViews(
+                self._hvplot, sizing_mode='stretch_width', margin=(0, 20, 0, 20)
             ).layout
-            self._layout[1][1] = hvplot
+            self._layout[1][1] = self._hvpane
             self._alert.visible = False
         except Exception as e:
             self._alert.param.set_param(
@@ -413,15 +450,15 @@ def _single_y(self):
             return True
         return False
 
-    def _toggle_controls(self, event):
+    def _toggle_controls(self, event=None):
         # Control high-level parameters
         visible = True
-        if event.new in ('table', 'dataset'):
+        if event and event.new in ('table', 'dataset'):
             parameters = ['kind', 'columns']
             visible = False
-        elif event.new in TWOD_KINDS:
+        elif event and event.new in TWOD_KINDS:
             parameters = ['kind', 'x', 'y', 'by', 'groupby']
-        elif event.new in ('hist', 'kde', 'density'):
+        elif event and event.new in ('hist', 'kde', 'density'):
             self.x = None
             parameters = ['kind', 'y_multi', 'by', 'groupby']
         else:
@@ -435,15 +472,15 @@ def _toggle_controls(self, event):
                 ('Axes', pn.Param(self.axes, widgets={
                     'xlim': {'throttled': True},
                     'ylim': {'throttled': True}
-                })),
+                }, show_name=False)),
                 ('Labels', pn.Param(self.labels, widgets={
                     'rot': {'throttled': True}
-                })),
+                }, show_name=False)),
                 ('Style', self.style),
                 ('Operations', self.operations),
-                ('Geo', self.geo)
+                # ('Geo', self.geo)
             ]
-            if event.new not in ('area', 'kde', 'line', 'ohlc', 'rgb', 'step'):
+            if event and event.new not in ('area', 'kde', 'line', 'ohlc', 'rgb', 'step'):
                 tabs.insert(5, ('Colormapping', self.colormapping))
         self._tabs[:] = tabs
 
@@ -455,14 +492,79 @@ def _check_by(self, event):
         if event.new and 'y_multi' in self._controls.parameters and self.y_multi and len(self.y_multi) > 1:
             self.by = []
 
+    #----------------------------------------------------------------
+    # Public API
+    #----------------------------------------------------------------
+
+    def hvplot(self):
+        """Return the plot as a HoloViews object.
+        """
+        return self._hvplot.clone()
+
+    def plot_code(self, var_name='df'):
+        """Return a string representation that can be easily copy-pasted
+        in a notebook cell to create a plot from a call to the `.hvplot`
+        accessor, and that includes all the customized settings of the explorer.
+
+        >>> hvexplorer.plot_code(var_name='data')
+        "data.hvplot(x='time', y='value')"
+
+        Parameters
+        ----------
+        var_name: string
+            Data variable name by which the returned string will start.
+        """
+        settings = self.settings()
+        args = ''
+        if settings:
+            for k, v in settings.items():
+                args += f'{k}={v!r}, '
+            args = args[:-2]
+        return f'{var_name}.hvplot({args})'
+    
+    def save(self, filename, **kwargs):
+        """Save the plot to file.
+        
+        Calls the `holoviews.save` utility, refer to its documentation
+        for a full description of the available kwargs.
+
+        Parameters
+        ----------
+        filename: string, pathlib.Path or IO object
+            The path or BytesIO/StringIO object to save to
+        """
+        _hv.save(self._hvplot, filename, **kwargs)
+
+    def settings(self):
+        """Return a dictionary of the customized settings.
+        
+        This dictionary can be reused as an unpacked input to the explorer or
+        a call to the `.hvplot` accessor.
+
+        >>> hvplot.explorer(df, **settings)
+        >>> df.hvplot(**settings)
+        """
+        settings = {}
+        for controller in self._controllers.values():
+            params = set(controller.param) - set(['name', 'explorer'])
+            for p in params:
+                value = getattr(controller, p)
+                if value != controller.param[p].default:
+                    settings[p] = value
+        for p in self._controls.parameters:
+            value = getattr(self, p)
+            if value != self.param[p].default:
+                settings[p] = value
+        if 'y_multi' in settings:
+            settings['y'] = settings.pop('y_multi')
+        settings = {k: v for k, v in sorted(list(settings.items()))}
+        return settings
+
 
 class hvGeomExplorer(hvPlotExplorer):
 
     kind = param.Selector(default=None, objects=sorted(GEOM_KINDS))
 
-    def __new__(cls, data, **params):
-        return super(hvPlotExplorer, cls).__new__(cls)
-
     @property
     def _single_y(self):
         return True
@@ -488,9 +590,6 @@ class hvGridExplorer(hvPlotExplorer):
 
     kind = param.Selector(default=None, objects=sorted(GRIDDED_KINDS))
 
-    def __new__(cls, data, **params):
-        return super(hvPlotExplorer, cls).__new__(cls)
-
     @property
     def _x(self):
         return (self._converter.x or self._converter.indexes[0]) if self.x is None else self.x
@@ -527,9 +626,6 @@ class hvDataFrameExplorer(hvPlotExplorer):
 
     kind = param.Selector(default='line', objects=sorted(DATAFRAME_KINDS))
 
-    def __new__(cls, data, **params):
-        return super(hvPlotExplorer, cls).__new__(cls)
-
     @property
     def xcat(self):
         if self.kind in ('bar', 'box', 'violin'):


Test patch:
diff --git a/hvplot/tests/testui.py b/hvplot/tests/testui.py
new file mode 100644
--- /dev/null
+++ b/hvplot/tests/testui.py
@@ -0,0 +1,97 @@
+import holoviews as hv
+import hvplot.pandas
+import pytest
+
+try:
+    from bokeh.sampledata import penguins
+except ImportError:
+    penguins = None
+
+from hvplot.ui import hvDataFrameExplorer
+
+pytestmark = pytest.mark.skipif(
+    penguins is None,
+    reason='Penguins dataset not available on Python 3.6',
+)
+
+df = penguins.data if penguins is not None else None
+
+
+def test_explorer_basic():
+    explorer = hvplot.explorer(df)
+
+    assert isinstance(explorer, hvDataFrameExplorer)
+    assert explorer.kind == 'line'
+    assert explorer.x == 'index'
+    assert explorer.y == 'species'
+
+
+def test_explorer_settings():
+    explorer = hvplot.explorer(df)
+
+    explorer.param.set_param(
+        kind='scatter',
+        x='bill_length_mm',
+        y_multi=['bill_depth_mm'],
+        by=['species'],
+    )
+
+    settings = explorer.settings()
+
+    assert settings == dict(
+        by=['species'],
+        kind='scatter',
+        x='bill_length_mm',
+        y=['bill_depth_mm'],
+    )
+
+
+def test_explorer_plot_code():
+    explorer = hvplot.explorer(df)
+
+    explorer.param.set_param(
+        kind='scatter',
+        x='bill_length_mm',
+        y_multi=['bill_depth_mm'],
+        by=['species'],
+    )
+
+    hvplot_code = explorer.plot_code()
+
+    assert hvplot_code == "df.hvplot(by=['species'], kind='scatter', x='bill_length_mm', y=['bill_depth_mm'])"
+    
+    hvplot_code = explorer.plot_code(var_name='othername')
+
+    assert hvplot_code == "othername.hvplot(by=['species'], kind='scatter', x='bill_length_mm', y=['bill_depth_mm'])"
+
+
+def test_explorer_hvplot():
+    explorer = hvplot.explorer(df)
+
+    explorer.param.set_param(
+        kind='scatter',
+        x='bill_length_mm',
+        y_multi=['bill_depth_mm'],
+    )
+
+    plot = explorer.hvplot()
+
+    assert isinstance(plot, hv.Scatter)
+    assert plot.kdims[0].name == 'bill_length_mm'
+    assert plot.vdims[0].name == 'bill_depth_mm'
+
+
+def test_explorer_save(tmp_path):
+    explorer = hvplot.explorer(df)
+
+    explorer.param.set_param(
+        kind='scatter',
+        x='bill_length_mm',
+        y_multi=['bill_depth_mm'],
+    )
+
+    outfile = tmp_path / 'plot.html'
+
+    explorer.save(outfile)
+
+    assert outfile.exists()