Tips and Tricks

Here, I will share random tips and tricks, but note the following are not officially endorsed by the maintainers; they’re simply things that worked the best for me.

The tips can be read chronologically, or jumped around, but I do try to put the less complex ones upfront and build up to the complex ones.

Litter prints and check both your terminal + browser console for debugging

If you are on using panel serve, print statements get passed to either terminal (for panel serve) or browser console (for Jupyter after the first output). To get to the browser console on Chrome (and probably other browsers): Mouse-Right-Click -> Inspect -> Console tab, or simply F12 -> Console.

[1]:
import panel as pn

pn.extension()

button = pn.widgets.Button(name="Click")


def update(event):
    num_clicks = event.obj.clicks
    print(f"I was clicked {num_clicks} times!")


click_watch = button.param.watch(update, "clicks")
button
[1]:

It should look something like below.

[2]:
import panel as pn
pn.extension()

pn.pane.JPG("console.jpg")
[2]:

Use apply.opts and param.value for interactive updates

This is one way of doing it…

[3]:
import panel as pn
import holoviews as hv

pn.extension()


def update(alpha):
    return hv.Curve(([0, 1, 2], [3, 4, 5])).opts(alpha=alpha)


widget = pn.widgets.FloatSlider(value=1)
plot = pn.bind(update, widget.param.value)
col = pn.Column(widget, plot)

But actually there’s no need for a chunky function; take advantage of apply.opts!

[4]:
import panel as pn
import holoviews as hv

pn.extension()

widget = pn.widgets.FloatSlider(value=1)
plot = hv.Curve(([0, 1, 2], [3, 4, 5])).apply.opts(alpha=widget.param.value)
col = pn.Column(widget, plot)

Just remember the apply and param is important; without those the interactivity won’t work!

[5]:
import panel as pn
import holoviews as hv

pn.extension()

widget = pn.widgets.FloatSlider(value=1)
plot = hv.Curve(([0, 1, 2], [3, 4, 5])).opts(alpha=widget.value)  # not interactive
col = pn.Column(widget, plot)
[6]:
import panel as pn
pn.extension()
pn.pane.GIF("use_apply.opts_and_param.value_for_interactive_updates.gif")
[6]:

Just to be clear, this works with other param parameters like param.start, param.stop, param.options, etc!

Use set_param for simultaneous updates and duplicate computations

This is a poor way of updating multiple parameters simultaneously because select_update gets triggered twice.

The reason is because, when options gets updated from the click, the new options are out of range from the original value so value is forced to update, triggering select_update the first time, but then value later gets manually updated to the last option, triggering select_update again.

[7]:
import time
import panel as pn

pn.extension()

button = pn.widgets.Button(name="Click")
select = pn.widgets.Select(options=[0])
text = pn.widgets.StaticText(value=select.value)


def click_update(event):
    options = [button.clicks + 1, button.clicks + 2]
    select.options = options
    select.value = options[-1]


def select_update(event):
    time.sleep(1)  # mimic expensive computation
    text.value = str(event.new)
    print("I was updated!")


click_watch = button.param.watch(click_update, "clicks")
select_watch = select.param.watch(select_update, "value")

col = pn.Column(button, select, text)
[8]:
import panel as pn
pn.extension()
pn.pane.GIF("use_set_param_for_simultaneous_updates_and_duplicate_computations_before.gif")
[8]:

Use set_param to prevent the duplicate calls!

[9]:
import time
import panel as pn

pn.extension()

button = pn.widgets.Button(name="Click")
select = pn.widgets.Select(options=[0])
text = pn.widgets.StaticText(value=select.value)


def click_update(event):
    select.param.set_param(
        options=[button.clicks + 1, button.clicks + 2], value=button.clicks + 2
    )


def select_update(event):
    time.sleep(1)  # mimic expensive computation
    text.value = str(event.new)
    print("I was updated!")


click_watch = button.param.watch(click_update, "clicks")
select_watch = select.param.watch(select_update, "value")

pn.Column(button, select, text)
[9]:
[10]:
import panel as pn
pn.extension()
pn.pane.GIF("use_set_param_for_simultaneous_updates_and_duplicate_computations_after.gif")
[10]:

Just be sure to prepend the param namespace or else you’ll get a warning!

[11]:
import panel as pn

pn.extension()

select = pn.widgets.Select(options=[0])
select.set_param(options=[0, 1], value=1)
WARNING:param.Select: Use method 'set_param' via param namespace

Use value_throttled for updates on mouse-up and expensive computations

If you have a slider, you may want to bind updates to value_throttled instead of value so that the plot updates on mouse-up, rather than every step to the desired value. This is especially valuable if it takes long to update!

[12]:
import time
import numpy as np
import panel as pn
import holoviews as hv

pn.extension()


def update(func):
    y = getattr(np, func)([0, 1, 2])
    time.sleep(1)
    return hv.Curve(([0, 1, 2], y)).opts(title=func)


widget = pn.widgets.DiscreteSlider(value="sin", options=["sin", "cos", "tan"])
plot = pn.bind(update, widget.param.value_throttled)
col = pn.Column(widget, plot)
[13]:
import panel as pn
pn.extension()
pn.pane.GIF("use_value_throttled_for_updates_on_mouse-up_and_expensive_computations.gif")
[13]:

Add a built-in loading indicator for expensive computations

Panel has a loading_indicator built-in for panel objects; great for keeping the app look functional!

[14]:
import time
import numpy as np
import panel as pn
import holoviews as hv

pn.extension()
pn.param.ParamMethod.loading_indicator = True


def update(func):
    y = getattr(np, func)([0, 1, 2])
    time.sleep(1)
    return hv.Curve(([0, 1, 2], y))


widget = pn.widgets.DiscreteSlider(value="sin", options=["sin", "cos", "tan"])
plot = pn.bind(update, widget.param.value_throttled)
col = pn.Column(widget, plot)
[15]:
import panel as pn
pn.extension()
pn.pane.GIF("add_a_built-in_loading_indicator_for_expensive_computations.gif")
[15]:

Wrap DynamicMap around Panel methods to maintain extents and improve runtime

If you zoomed into the map and updated the slider’s value, the extent resets which makes it difficult to analyze it temporally.

[16]:
import panel as pn
import xarray as xr
import pandas as pd
import geoviews as gv
import cartopy.crs as ccrs
from random import randrange

pn.extension()
gv.extension("bokeh")

tiles = gv.tile_sources.EsriImagery()
ds = xr.tutorial.open_dataset("air_temperature")
slider = pn.widgets.DiscreteSlider(options=list(ds["time"].astype(str).values))


def plot(time):
    df = ds.sel(time=time).to_dataframe()
    df = df.sample(randrange(25))
    points = gv.Points(df, ["lon", "lat"]).opts(
        projection=ccrs.GOOGLE_MERCATOR, global_extent=True, color="white"
    )
    return tiles * points


points = pn.bind(plot, slider.param.value)
col = pn.Column(slider, points)
[17]:
import panel as pn
pn.extension()
pn.pane.GIF("wrap_dynamicmap_around_panel_methods_to_maintain_extents_and_improve_runtime_before.gif")
[17]:

If you wrap gv.DynamicMap around points, it no longer resets / flickers and it’s slightly more optimized because it doesn’t have to redraw the entire frame!

[18]:
import panel as pn
import xarray as xr
import pandas as pd
import geoviews as gv
import cartopy.crs as ccrs
from random import randrange

pn.extension()
gv.extension("bokeh")

tiles = gv.tile_sources.EsriImagery()
ds = xr.tutorial.open_dataset("air_temperature")
slider = pn.widgets.DiscreteSlider(options=list(ds["time"].astype(str).values))


def plot(time):
    df = ds.sel(time=time).to_dataframe()
    df = df.sample(randrange(25))
    points = gv.Points(df, ["lon", "lat"]).opts(
        projection=ccrs.GOOGLE_MERCATOR, global_extent=True, color="white"
    )
    return tiles * points


points = gv.DynamicMap(pn.bind(plot, slider.param.value))
col = pn.Column(slider, points)
[19]:
import panel as pn
pn.extension()
pn.pane.GIF("wrap_dynamicmap_around_panel_methods_to_maintain_extents_and_improve_runtime_after.gif")
[19]:

Remember to call value from the param namespace, e.g. param.value or else it won’t be interactive!

However, the downside is that the built-in loading indicator doesn’t work with DynamicMaps.

One element per DynamicMap for flexibility

If you return an overlay, you lose flexibility. Here’s the original code that subsets the xr.Dataset based on the x_range and y_range set.

[20]:
import cartopy.crs as ccrs
import holoviews as hv
import geoviews as gv
import numpy as np
import panel as pn
import xarray as xr
import time

gv.extension("bokeh")
pn.extension()


def update(x_range, y_range):
    tile = gv.tile_sources.ESRI()

    x1, x2 = x_range
    y1, y2 = y_range

    ds_sel = ds.sel(lat=slice(y2, y1), lon=slice(x1, x2), time="2013-01-01 00Z")
    image = gv.Image(ds_sel, ["lon", "lat"], ["air"], crs=ccrs.PlateCarree()).opts(
        projection=ccrs.GOOGLE_MERCATOR
    )
    time.sleep(0.5)  # mimic expensive computation

    corners = np.array([(x1, y1), (x2, y1), (x2, y2), (x1, y2)])
    polygon = gv.Polygons([{("Longitude", "Latitude"): corners}]).opts(
        projection=ccrs.GOOGLE_MERCATOR, fill_alpha=0.5)

    return tile * image * polygon


ds = xr.tutorial.open_dataset("air_temperature")
lon_slider = pn.widgets.RangeSlider(start=220, end=320)
lat_slider = pn.widgets.RangeSlider(start=16, end=74)

dmap = gv.DynamicMap(pn.bind(update, lon_slider, lat_slider))
dmap = dmap.opts(global_extent=True)

layout = pn.Row(pn.Column(lat_slider, lon_slider), dmap)
[21]:
import panel as pn
pn.extension()
pn.pane.GIF("one_element_per_dynamicmap_for_flexibility_before.gif")
[21]:

Here’s what I would do.

  1. Since tile is “static” (or externally dynamic), I would move it out.

  2. Create a separate update function for the polygon.

  3. Bind value_throttled to update_image

Now, users can adjust the bounds to their liking before the image gets subset and processed (which sometimes can be expensive)!

[22]:
import cartopy.crs as ccrs
import holoviews as hv
import geoviews as gv
import numpy as np
import panel as pn
import xarray as xr
import time

gv.extension("bokeh")
pn.extension()


def update_image(x_range, y_range):
    ds_sel = ds.sel(
        lat=slice(*y_range[::-1]), lon=slice(*x_range), time="2013-01-01 00Z"
    )
    image = gv.Image(ds_sel, ["lon", "lat"], ["air"], crs=ccrs.PlateCarree()).opts(
        projection=ccrs.GOOGLE_MERCATOR
    )
    time.sleep(0.5)  # mimic expensive computation
    return image


def update_polygon(x_range, y_range):
    x1, x2 = x_range
    y1, y2 = y_range

    corners = np.array([(x1, y1), (x2, y1), (x2, y2), (x1, y2)])
    polygon = gv.Polygons([{("Longitude", "Latitude"): corners}]).opts(
        projection=ccrs.GOOGLE_MERCATOR, fill_alpha=0.5
    )
    return polygon


ds = xr.tutorial.open_dataset("air_temperature")
lon_slider = pn.widgets.RangeSlider(start=220, end=320)
lat_slider = pn.widgets.RangeSlider(start=16, end=74)

tile = gv.tile_sources.ESRI()
image_dmap = gv.DynamicMap(
    pn.bind(
        update_image, lon_slider.param.value_throttled, lat_slider.param.value_throttled
    )
)

polygon_dmap = gv.DynamicMap(pn.bind(update_polygon, lon_slider, lat_slider))

overlay = (tile * image_dmap * polygon_dmap).opts(global_extent=True)

layout = pn.Row(pn.Column(lat_slider, lon_slider), overlay)
[23]:
import panel as pn
pn.extension()
pn.pane.GIF("one_element_per_dynamicmap_for_flexibility_after.gif")
[23]: