|
| 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