Plotting in Panel¶
Skill version 1.0.2
How to embed plots in Panel apps, across libraries: HoloViews/hvPlot, Matplotlib, Plotly, ECharts, and Bokeh. For standalone HoloViews concepts (elements, .opts(), streams, formatters, tools), see the HoloViews skill.
Examples build on the penguins Dashboard from the Panel skill.
Contents¶
- HoloViews and hvPlot
- pn.pane.HoloViews Configuration
- DynamicMap: Preserve Zoom/Pan Across Data Refreshes
- One Element Per DynamicMap
- Responsive Sizing
- Matplotlib
- Plotly
- ECharts
- Bokeh Toolbar Tools
HoloViews and hvPlot¶
The richest Panel integration — hvPlot and HoloViews render through pn.pane.HoloViews, with DynamicMap for live updates.
pn.pane.HoloViews Configuration¶
pn.pane.HoloViews(
plot,
sizing_mode="stretch_width",
theme="light_minimal", # Bokeh theme — set here, not globally
linked_axes=False, # disable axis linking across plots in layout
)
theme=sets the Bokeh theme on the pane. Options:"light_minimal","dark_minimal","caliber","night_sky",None. Do NOT set globally viahv.renderer("bokeh").theme. (Inside apmui.Page/ThemeToggle, plots auto-theme — see Using Material UI.)linked_axes=Falseprevents axis linking when combining charts with different axis types in a Layout (+). Pair with.opts(shared_axes=False)on the Layout itself.sizing_mode="stretch_width"is required for responsive HoloViews plots.
DynamicMap: Preserve Zoom/Pan Across Data Refreshes¶
- Setting
pane.object = new_plotresets axes. DynamicMap patches data in place, preserving zoom/pan. - Use a trigger parameter as a signal — DynamicMap caches by argument identity, so read actual data from
selfinside the callback.
import holoviews as hv
import hvplot.pandas # noqa
import panel as pn
import panel_material_ui as pmui
import param
pn.extension(throttled=True)
penguins = hvplot.sampledata.penguins("pandas").dropna()
species_list = sorted(penguins["species"].unique())
class Dashboard(pn.viewable.Viewer):
species = param.ListSelector(default=species_list, objects=species_list)
_trigger = param.Integer(default=0)
def __init__(self, **params):
super().__init__(**params)
dmap = hv.DynamicMap(pn.bind(self._render_scatter, self.param._trigger))
self._chart_pane = pn.pane.HoloViews(
dmap, sizing_mode="stretch_width", theme="light_minimal",
)
self._layout = pmui.Column(self._chart_pane)
def _filtered(self):
return penguins[penguins["species"].isin(self.species)]
def _render_scatter(self, trigger):
df = self._filtered()
if df.empty:
return hv.Scatter([], kdims=["bill_length_mm"], vdims=["bill_depth_mm"]).opts(
responsive=True, height=300,
)
return df.hvplot.scatter(
x="bill_length_mm", y="bill_depth_mm", by="species",
responsive=True, height=300,
)
@param.depends("species", watch=True, on_init=True)
def _on_species_changed(self):
self._trigger += 1
def __panel__(self):
return self._layout
One Element Per DynamicMap¶
- Returning mixed types (
hv.Scattersometimes,hv.Overlayother times) raisesAssertionError. - Combining scatter + HLines inside
hv.Overlay([...])loses hover tooltips. - Create one DynamicMap per element, combine with
*at layout level. Each callback always returns the same element type.
...
class Dashboard(pn.viewable.Viewer):
...
def __init__(self, **params):
super().__init__(**params)
scatter_dmap = hv.DynamicMap(pn.bind(self._render_scatter, self.param._trigger))
mean_dmap = hv.DynamicMap(pn.bind(self._render_mean_line, self.param._trigger))
self._chart_pane = pn.pane.HoloViews(scatter_dmap * mean_dmap, sizing_mode="stretch_width")
def _render_scatter(self, trigger):
df = self._filtered()
if df.empty:
return hv.Scatter([], kdims=["bill_length_mm"], vdims=["bill_depth_mm"]).opts(
responsive=True, height=300,
)
return df.hvplot.scatter(
x="bill_length_mm", y="bill_depth_mm", by="species",
responsive=True, height=300,
)
def _render_mean_line(self, trigger):
df = self._filtered()
avg = df["bill_depth_mm"].mean() if not df.empty else 0
return hv.HLine(avg).opts(color="orange", line_dash="dashed")
Responsive Sizing¶
hvPlot internally sets width=700. This conflicts with responsive=True if applied via .opts().
- hvPlot: pass
responsive=Trueandheight=Nas arguments to the hvplot call, not via.opts(). hvPlot's defaultwidth=700persists through.opts()and can't be removed. - Pure HoloViews:
.opts(responsive=True, height=N)is fine — HoloViews doesn't inject a default width. - Never set both
widthandresponsive=True—widthwins silently. - Set
sizing_mode="stretch_width"on thepn.pane.HoloViews. - Overlays: all elements must have consistent sizing. If one element has
responsive=Trueand another has hvPlot's defaultwidth=700, the overlay warns "responsive mode could not be enabled". Passresponsive=True, height=Nto every hvPlot call in the overlay. - Multi-chart layouts (
plot_a + plot_b): use.opts(shared_axes=False)on the Layout andlinked_axes=Falseonpn.pane.HoloViewswhen charts have different axis types (e.g. time series + categorical bars).
# ✅ hvPlot: responsive and height as arguments
plot = df.hvplot.scatter(x='x', y='y', responsive=True, height=300)
pane = pn.pane.HoloViews(plot, sizing_mode="stretch_width")
# ✅ Pure HoloViews: .opts() is fine
plot = hv.Curve(df, 'x', 'y').opts(responsive=True, height=300)
pane = pn.pane.HoloViews(plot, sizing_mode="stretch_width")
# ❌ BAD: hvplot sets width=700 internally; .opts(responsive=True) doesn't remove it
plot = df.hvplot.scatter(x='x', y='y').opts(responsive=True, height=300)
# ❌ BAD: overlay mixes responsive and non-responsive — triggers warning
area = df.hvplot.area(x='x', y='y', responsive=True, height=300)
line = df.hvplot.line(x='x', y='y2') # inherits width=700
overlay = area * line
# ✅ Fix: pass responsive=True, height=N to every element
area = df.hvplot.area(x='x', y='y', responsive=True, height=300)
line = df.hvplot.line(x='x', y='y2', responsive=True, height=300)
overlay = area * line
Matplotlib¶
- Set
matplotlib.use('agg')BEFORE importing pyplot — required for server-side rendering. - Don't add
'matplotlib'topn.extension()— not a JS extension. - Close figures after rendering:
plt.close(fig).
import matplotlib
matplotlib.use('agg')
import matplotlib.pyplot as plt
import panel as pn
pn.extension() # no 'matplotlib' needed
Plotly¶
- Add
"plotly"topn.extension("plotly"). - Match template to app theme, use transparent backgrounds:
template = "plotly_dark" if pn.state.theme == "dark" else "plotly_white"
fig.update_layout(
template=template,
paper_bgcolor='rgba(0,0,0,0)',
plot_bgcolor='rgba(0,0,0,0)',
)
ECharts¶
- Prefer dict config over pyecharts.
- Configs must be JSON-serializable — never use Python functions or lambdas (
SerializationError). - Template strings:
{b}(category),{c}(value),{d}(percentage),{value}(axis). Prefix/suffix:'{value}%'. - Use
replaceMergewhen series count changes dynamically, else old series persist:
chart_pane = pn.pane.ECharts(
self._chart_config,
options={"replaceMerge": ["series"]},
sizing_mode="stretch_width",
height=400,
)
Bokeh Toolbar Tools¶
For Bokeh-backed plots (including HoloViews/hvPlot output):
default_tools=["reset"]strips all default Bokeh toolbar tools except reset; add specific tools viatools=["hover", "xwheel_zoom"].active_tools=["xwheel_zoom"]sets which tools are active by default.- For cumulative/monotonic curves,
hover_mode="vline"gives a better tooltip experience.