Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 141 additions & 1 deletion hvplot/tests/testui.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@

import hvplot.pandas
import hvplot.xarray
from hvplot.ui import MAX_ROWS, hvDataFrameExplorer, hvGridExplorer
from hvplot.ui import (
CATEGORICAL_CMAP,
DEFAULT_CMAPS,
MAX_ROWS,
hvDataFrameExplorer,
hvGridExplorer,
)

df = penguins.data
ds_air_temperature = xr.tutorial.open_dataset('air_temperature')
Expand Down Expand Up @@ -425,3 +431,137 @@ def test_explorer_geo_no_import_error_when_false():
da = ds_air_temperature['air'].isel(time=0)
with patch('hvplot.util.import_geoviews', return_value=None):
assert hvplot.explorer(da, x='lon', y='lat', geo=False)


def test_explorer_color_key_accepts_mapping():
color_key = {'Adelie': '#e41a1c', 'Chinstrap': '#377eb8', 'Gentoo': '#4daf4a'}
explorer = hvplot.explorer(
df,
kind='points',
x='bill_length_mm',
y='bill_depth_mm',
by=['species'],
datashade=True,
color_key=color_key,
)

assert explorer.colormapping.color_key == color_key


def test_explorer_color_key_replaces_cmap():
"""cmap always has a value to emit, and the converter refuses both at once."""
explorer = hvplot.explorer(
df,
kind='points',
x='bill_length_mm',
y='bill_depth_mm',
by=['species'],
datashade=True,
color_key={'Adelie': '#e41a1c'},
)

kwargs = explorer.colormapping.kwargs

assert 'cmap' not in kwargs
assert kwargs['color_key'] == {'Adelie': '#e41a1c'}


def test_explorer_datashade_by_uses_categorical_cmap():
explorer = hvplot.explorer(
df,
kind='points',
x='bill_length_mm',
y='bill_depth_mm',
by=['species'],
datashade=True,
)

assert explorer.colormapping.cmap == CATEGORICAL_CMAP


def test_explorer_datashade_toggled_after_by_uses_categorical_cmap():
explorer = hvplot.explorer(
df, kind='points', x='bill_length_mm', y='bill_depth_mm', by=['species']
)
assert explorer.colormapping.cmap == DEFAULT_CMAPS['linear']

explorer.operations.datashade = True

assert explorer.colormapping.cmap == CATEGORICAL_CMAP


def test_explorer_by_without_datashade_keeps_default_cmap():
"""A plain by overlay takes its colors from a Cycle, so cmap is untouched."""
explorer = hvplot.explorer(
df, kind='scatter', x='bill_length_mm', y='bill_depth_mm', by=['species']
)

assert explorer.colormapping.cmap == DEFAULT_CMAPS['linear']
assert 'cmap' not in explorer.plot_code()


def test_explorer_categorical_color_sets_valid_cmap():
"""cmap is a Selector over names, so the categorical default cannot be a list."""
explorer = hvplot.explorer(df, kind='scatter', x='bill_length_mm', y='bill_depth_mm')

explorer.colormapping.color = 'species'

assert explorer.colormapping.cmap == CATEGORICAL_CMAP


def test_explorer_dict_cmap_redirects_to_color_key():
"""hvPlot takes explicit colors as cmap everywhere else, so a spec written
against that API must not be refused by the explorer."""
colors = {'Adelie': '#e41a1c', 'Chinstrap': '#377eb8', 'Gentoo': '#4daf4a'}
explorer = hvplot.explorer(
df,
kind='points',
x='bill_length_mm',
y='bill_depth_mm',
by=['species'],
datashade=True,
cmap=colors,
)

assert explorer.colormapping.color_key == colors
assert 'cmap' not in explorer.colormapping.kwargs


def test_explorer_color_key_wins_over_dict_cmap():
explorer = hvplot.explorer(
df,
kind='points',
x='bill_length_mm',
y='bill_depth_mm',
by=['species'],
datashade=True,
cmap={'Adelie': '#111111'},
color_key={'Adelie': '#e41a1c'},
)

assert explorer.colormapping.color_key == {'Adelie': '#e41a1c'}


def test_explorer_color_key_accepts_list():
colors = ['#e41a1c', '#377eb8', '#4daf4a']
explorer = hvplot.explorer(
df,
kind='points',
x='bill_length_mm',
y='bill_depth_mm',
by=['species'],
datashade=True,
color_key=colors,
)

assert explorer.colormapping.color_key == colors


def test_explorer_named_cmap_still_selectable():
"""Redirecting explicit colors must not disturb the dropdown."""
explorer = hvplot.explorer(
df, kind='scatter', x='bill_length_mm', y='bill_depth_mm', cmap='viridis'
)

assert explorer.colormapping.cmap == 'viridis'
assert explorer.colormapping.color_key is None
69 changes: 64 additions & 5 deletions hvplot/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@

CMAPS = [cm for cm in list_cmaps() if not cm.endswith('_r_r')]
DEFAULT_CMAPS = _hvConverter._default_cmaps
# ``DEFAULT_CMAPS['categorical']`` is a bare list of colors, which the ``cmap``
# Selector below cannot hold. glasbey_hv is the named stand-in: like that
# list it carries 256 distinct hues, where a Category palette repeats after
# 10 or 20 and would give two categories the same color.
CATEGORICAL_CMAP = 'glasbey_hv'
GEO_FEATURES = ['borders', 'coastline', 'land', 'lakes', 'ocean', 'rivers', 'states', 'grid']
GEO_TILES = [None, *sorted(tile_sources)]
GEO_KEYS = [
Expand Down Expand Up @@ -160,6 +165,16 @@ class Colormapping(Controls):

color = param.String(default=None)

color_key = param.ClassSelector(
default=None,
class_=(dict, list),
label='Color Key (color_key)',
doc="""
Explicit colors for the categories being datashaded, either a mapping
of category to color or a list of colors. Takes precedence over
``cmap``.""",
)

colorbar = param.Boolean(default=None)

cmap = param.Selector(default=DEFAULT_CMAPS['linear'], label='Colormap', objects=CMAPS)
Expand All @@ -179,18 +194,49 @@ def __init__(self, data, **params):
if 'symmetric' not in params:
self.symmetric = self.explorer._converter._plot_opts.get('symmetric', False)

@property
def kwargs(self):
"""Return the set control values as plot keyword arguments."""
kwargs = super().kwargs
if kwargs.get('color_key') is not None:
# The converter accepts at most one of cmap, colormap and color_key,
# and cmap always has a value to emit, so drop it in favor of the
# explicit key.
kwargs.pop('cmap', None)
return kwargs

@property
def colormapped(self):
"""Return whether the current plot uses colormapping."""
if self.explorer.kind in _hvConverter._colorbar_types:
return True
return self.color is not None and self.color in self._data

@param.depends('color', 'explorer.kind', 'symmetric', watch=True)
def _update_coloropts(self):
if not self.colormapped or self.cmap not in list(DEFAULT_CMAPS.values()):
@property
def _datashading_by(self):
"""Whether the plot aggregates a categorical ``by`` column per pixel.

Only this combination colors the categories through ``cmap``; a plain
``by`` overlay takes its colors from a Cycle instead.
"""
if not self.explorer.by:
return False
operations = self.explorer.operations
return operations.datashade or operations.rasterize

@param.depends('color', 'explorer.kind', 'explorer.by', 'symmetric', watch=True)
def _update_coloropts(self, *events):
# Only keep steering the colormap while it is one this method chose; a
# value outside that set was picked by the user and is left alone.
if self.cmap not in [*DEFAULT_CMAPS.values(), CATEGORICAL_CMAP]:
return
if self.explorer.kind in _hvConverter._colorbar_types:
if self._datashading_by:
# Aggregating categories per pixel against a linear ramp renders
# every category as a near-identical shade of one hue.
key = 'categorical'
elif not self.colormapped:
return
elif self.explorer.kind in _hvConverter._colorbar_types:
key = 'diverging' if self.symmetric else 'linear'
self.colorbar = True
elif self.color in self._data:
Expand All @@ -203,7 +249,7 @@ def _update_coloropts(self):
key = 'linear'
else:
return
self.cmap = DEFAULT_CMAPS[key]
self.cmap = CATEGORICAL_CMAP if key == 'categorical' else DEFAULT_CMAPS[key]


class Style(Controls):
Expand Down Expand Up @@ -571,6 +617,13 @@ def __panel__(self):
return self._layout

def __init__(self, df, **params):
# The cmap control is a dropdown of colormap names, but everywhere else
# in hvPlot cmap also accepts explicit colors. Redirect those to
# color_key here, before the converter is built, so it never sees both.
if isinstance(params.get('cmap'), (dict, list)):
# An explicit color_key wins if both were given.
params.setdefault('color_key', params.pop('cmap'))
params.pop('cmap', None)
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']]
Expand Down Expand Up @@ -619,6 +672,12 @@ def __init__(self, df, **params):
self.param.watch(self._refresh, list(self.param))
for controller in self._controllers.values():
controller.param.watch(self._refresh, list(controller.param))
# Wired here rather than declared on Colormapping: the controls are built
# before this assignment, so a nested param.depends on operations cannot
# resolve when its watcher is registered.
self.operations.param.watch(
self.colormapping._update_coloropts, ['datashade', 'rasterize']
)
self.statusbar.param.watch(self._refresh, list(self.statusbar.param))
self._alert = pn.pane.Alert(
alert_type='danger', visible=False, sizing_mode='stretch_width'
Expand Down