Skip to content

Commit 7110f1e

Browse files
Merge pull request #139 from ahuang11/panel-holoviews-interaction
Create panel-holoviews-interactions resource
2 parents d9c488c + 2aa1d08 commit 7110f1e

1 file changed

Lines changed: 255 additions & 0 deletions

File tree

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
---
2+
name: panel-holoviews-interactions
3+
description: Best practices for using HoloViews/hvPlot with Panel, especially DynamicMap patterns for live-updating dashboards.
4+
metadata:
5+
version: "1.0.0"
6+
author: ahuang11
7+
category: web-development
8+
difficulty: intermediate
9+
---
10+
11+
# Panel + HoloViews Interaction Patterns
12+
13+
## Core Principle: Don't Replace DOM — Update In Place
14+
15+
Panel re-renders the entire DOM subtree when a `@param.depends` method returns a new component. This causes flicker, resets scroll position, and destroys client-side state (zoom, selection, hover).
16+
17+
### DON'T: Return new panes from `@param.depends`
18+
19+
```python
20+
# BAD — creates new pane/layout objects every time data changes → DOM replacement, flicker
21+
@param.depends("data")
22+
def my_chart(self):
23+
return pn.pane.HoloViews(self.data.hvplot.scatter(...))
24+
25+
@param.depends("data")
26+
def my_table(self):
27+
return pn.widgets.Tabulator(self.data)
28+
```
29+
30+
### DO: Create panes once, pass reactive method references as content
31+
32+
The preferred Panel pattern: create panes in `__init__`, pass `@param.depends` methods as content references. The pane calls the method reactively and updates its own content — no DOM replacement.
33+
34+
```python
35+
def __init__(self, **params):
36+
super().__init__(**params)
37+
# Pane created ONCE; method reference passed as content
38+
self._summary_pane = pn.pane.Markdown(self._summary_text)
39+
self._chart_pane = pn.pane.HoloViews()
40+
41+
@param.depends("data")
42+
def _summary_text(self):
43+
self._summary_pane.object = f"**Rows**: {len(self.data)}"
44+
45+
@param.depends("data", watch=True, on_init=True)
46+
def _update_chart(self):
47+
self._chart_pane.object = self.data.hvplot.scatter(...)
48+
```
49+
50+
### DO: Use `param.depends` when orchestrating multiple component updates from one event
51+
52+
When a single data change needs to update many components at once (status HTML, chart trigger, table value, visibility flags), a single watcher is cleaner than having each component independently depend on the same param:
53+
54+
```python
55+
def __init__(self, **params):
56+
super().__init__(**params)
57+
self._table = pn.widgets.Tabulator(pd.DataFrame(), ...)
58+
self._status_html = pn.pane.HTML("", sizing_mode="stretch_width")
59+
60+
@param.depends("data", watch=True)
61+
def _on_data_changed(self, *events):
62+
self._status_html.object = self._render_status(self.data)
63+
self._table.value = self._prepare_df(self.data)
64+
self._chart_trigger += 1
65+
self._chart_section.visible = not self.data.empty
66+
```
67+
68+
**When to use which**:
69+
- `@param.depends("x")` (no watch) → return content, pass method ref to a pane. Preferred for simple 1-param → 1-pane reactivity.
70+
- `@param.depends("x", watch=True)` → update state parameters or trigger side effects.
71+
72+
---
73+
74+
## DynamicMap: Preserve Zoom/Pan Across Data Refreshes
75+
76+
When you set `pane.object = new_plot`, Bokeh resets all axes ranges. Wrap the plot function in `hv.DynamicMap` so Bokeh updates data in the existing figure rather than replacing it.
77+
78+
### DON'T: Replace chart object directly
79+
80+
```python
81+
# BAD — zoom resets every refresh
82+
self._chart_pane.object = df.hvplot.scatter(...)
83+
```
84+
85+
### DO: Use DynamicMap with a trigger parameter
86+
87+
```python
88+
class Monitor(pn.viewable.Viewer):
89+
_chart_trigger = param.Integer(default=0)
90+
91+
def __init__(self, **params):
92+
super().__init__(**params)
93+
dmap = hv.DynamicMap(pn.bind(self._render_scatter, self.param._chart_trigger))
94+
self._chart_pane = pn.pane.HoloViews(dmap, sizing_mode="stretch_width")
95+
96+
def _render_scatter(self, trigger):
97+
# Reads self.data directly; trigger is just a signal to re-invoke
98+
df = self.data
99+
if df is None or df.empty:
100+
return hv.Scatter([], kdims=['x'], vdims=['y'])
101+
return df.hvplot.scatter(x='x', y='y', ...)
102+
103+
def _on_data_changed(self, *events):
104+
# Increment trigger → DynamicMap re-invokes → Bokeh patches in place
105+
self._chart_trigger += 1
106+
```
107+
108+
---
109+
110+
## One Element Per DynamicMap
111+
112+
Returning an `hv.Overlay` from a DynamicMap causes two problems:
113+
114+
1. **Type mismatch errors** — if you sometimes return `hv.Scatter` and sometimes `hv.Overlay`, DynamicMap raises `AssertionError: DynamicMap must only contain one type of object`.
115+
2. **Lost hover tooltips** — when scatter + HLines are combined inside `hv.Overlay([...])`, the scatter's hover tool configuration doesn't propagate.
116+
117+
### DON'T: Return mixed types or Overlays from a single DynamicMap
118+
119+
```python
120+
# BAD — type mismatch when data is empty vs populated
121+
def render(trigger):
122+
if no_data:
123+
return hv.Text(0, 0, "empty") # Text type
124+
plot = df.hvplot.scatter(...)
125+
return plot * hv.HLine(avg) # Overlay type → AssertionError
126+
127+
# BAD — hover tooltips lost
128+
def render(trigger):
129+
scatter = df.hvplot.scatter(..., tools=['hover'])
130+
return hv.Overlay([scatter, hv.HLine(avg)]) # hover doesn't propagate
131+
```
132+
133+
### DO: Separate DynamicMap per element, combine with `*` at layout level
134+
135+
```python
136+
def __init__(self, **params):
137+
super().__init__(**params)
138+
scatter_dmap = hv.DynamicMap(pn.bind(self._render_scatter, self.param._trigger))
139+
avg_dmap = hv.DynamicMap(pn.bind(self._render_avg_line, self.param._trigger))
140+
min_dmap = hv.DynamicMap(pn.bind(self._render_min_line, self.param._trigger))
141+
max_dmap = hv.DynamicMap(pn.bind(self._render_max_line, self.param._trigger))
142+
overlay = scatter_dmap * avg_dmap * min_dmap * max_dmap
143+
self._chart_pane = pn.pane.HoloViews(overlay, sizing_mode="stretch_width")
144+
```
145+
146+
Each callback returns exactly **one element type**, always:
147+
148+
```python
149+
def _render_scatter(self, trigger):
150+
completed = self._get_completed()
151+
if completed.empty:
152+
# Same type as the populated case — just no data
153+
return hv.Scatter([], kdims=['START_TIME'], vdims=['RUNTIME_SECONDS']).opts(
154+
bgcolor='#0d1015', height=180, responsive=True,
155+
)
156+
return completed.hvplot.scatter(x='START_TIME', y='RUNTIME_SECONDS', ...)
157+
158+
def _render_avg_line(self, trigger):
159+
avg = self._stats().get('avg_runtime', 0)
160+
if avg > 0:
161+
return hv.HLine(avg).opts(color='amber', line_dash='dashed', ...)
162+
# Invisible but valid — same type always
163+
return hv.HLine(0).opts(alpha=0)
164+
```
165+
166+
Benefits:
167+
- Each DynamicMap always returns the same HoloViews element type (no `AssertionError`)
168+
- Scatter keeps its hover tools natively (tools are per-element, not per-overlay)
169+
- Each layer updates independently
170+
- Static layers (like a tile source) can be pulled out of DynamicMap entirely
171+
172+
---
173+
174+
## Client-Side Interactions with `jslink`
175+
176+
For styling/visual controls that don't need Python computation, use `jslink` to wire Panel widgets directly to Bokeh properties. No server roundtrip, works in saved HTML files.
177+
178+
### Simple property binding
179+
180+
```python
181+
# Float slider → glyph fill alpha (also works for size, line_width, fill_color, etc.)
182+
widget = pn.widgets.FloatSlider(value=1, step=0.01)
183+
plot = hv.Points((x, y)).opts(size=10)
184+
widget.jslink(plot, value='glyph.fill_alpha')
185+
186+
# Text input → plot title
187+
widget = pn.widgets.TextInput(value="Title")
188+
plot = hv.Curve((x, y)).opts(title="Title")
189+
widget.jslink(plot, value="plot.title.text")
190+
191+
# Text input → axis label
192+
widget = pn.widgets.TextInput(value="X Label")
193+
plot = hv.Curve((x, y)).opts(xlabel="X Label")
194+
widget.jslink(plot, value="xaxis.axis_label")
195+
196+
# RadioButtonGroup → title alignment
197+
widget = pn.widgets.RadioButtonGroup(options=["left", "center", "right"])
198+
widget.jslink(plot, value="plot.title.align")
199+
```
200+
201+
### JavaScript code callbacks
202+
203+
For transforms that need a bit of JS logic, use the `code` parameter:
204+
205+
```python
206+
# Range slider → axis limits
207+
widget = pn.widgets.RangeSlider(start=0, end=10)
208+
plot = hv.Curve((x, y))
209+
widget.jslink(plot, code={'value': """
210+
x_range.start = cb_obj.value[0];
211+
x_range.end = cb_obj.value[1];
212+
"""})
213+
214+
# Range slider → colorbar limits
215+
widget.jslink(plot, code={'value': """
216+
color_mapper.low = cb_obj.value[0];
217+
color_mapper.high = cb_obj.value[1];
218+
"""})
219+
220+
# Float slider → modify data directly
221+
widget.jslink(plot, code={'value': """
222+
var y = cds.data['y'];
223+
for (var i = 0; i < y.length; i++) { y[i] = cb_obj.value; }
224+
cds.change.emit();
225+
"""})
226+
227+
# Select → colormap with extra args
228+
cmaps_colors = {name: hv.plotting.util.process_cmap(cmap, n) for ...}
229+
widget.jslink(plot, code={'value': "color_mapper.palette = cmap_dict[source.value];"},
230+
args={"cmap_dict": cmaps_colors})
231+
```
232+
233+
**Bokeh property targets available via jslink**:
234+
- `glyph.*` — fill_alpha, fill_color, size, line_width, line_color, etc.
235+
- `plot.title.*` — text, text_font_size, align
236+
- `xaxis.*` / `yaxis.*` — axis_label, etc.
237+
- `x_range.*` / `y_range.*` — start, end
238+
- `color_mapper.*` — low, high, palette
239+
- `cds.*` — column data source for direct data manipulation
240+
241+
**When to use jslink vs DynamicMap**:
242+
- `jslink` — pure visual/styling changes, axis limits, color tweaks. No Python needed.
243+
- `DynamicMap` — data transformations, aggregations, anything that needs Python computation.
244+
245+
---
246+
247+
## Summary Checklist
248+
249+
| Pattern | Do | Don't |
250+
|---|---|---|
251+
| Update UI | Pass `@param.depends` method ref to pane; or `pn.bind` for multi-component orchestration | Return new panes/layouts from `@param.depends` |
252+
| Preserve zoom | `hv.DynamicMap(pn.bind(fn, trigger))` | `pane.object = new_plot` |
253+
| Overlay composition | One DynamicMap per element, `*` at layout | `hv.Overlay([...])` inside callback |
254+
| Empty chart state | `hv.Scatter([], kdims=..., vdims=...)` | `hv.Overlay([])` or `hv.Text(...)` |
255+
| Side effects | `pn.bind(fn, obj.param.value)` | `@param.depends` with `watch=True` for UI |

0 commit comments

Comments
 (0)