diff --git a/src/holoviz_mcp/config/resources/skills/panel-holoviews-interactions.md b/src/holoviz_mcp/config/resources/skills/panel-holoviews-interactions.md deleted file mode 100644 index d096ea6..0000000 --- a/src/holoviz_mcp/config/resources/skills/panel-holoviews-interactions.md +++ /dev/null @@ -1,255 +0,0 @@ ---- -name: panel-holoviews-interactions -description: Best practices for using HoloViews/hvPlot with Panel, especially DynamicMap patterns for live-updating dashboards. -metadata: - version: "1.0.0" - author: ahuang11 - category: web-development - difficulty: intermediate ---- - -# Panel + HoloViews Interaction Patterns - -## Core Principle: Don't Replace DOM — Update In Place - -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). - -### DON'T: Return new panes from `@param.depends` - -```python -# BAD — creates new pane/layout objects every time data changes → DOM replacement, flicker -@param.depends("data") -def my_chart(self): - return pn.pane.HoloViews(self.data.hvplot.scatter(...)) - -@param.depends("data") -def my_table(self): - return pn.widgets.Tabulator(self.data) -``` - -### DO: Create panes once, pass reactive method references as content - -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. - -```python -def __init__(self, **params): - super().__init__(**params) - # Pane created ONCE; method reference passed as content - self._summary_pane = pn.pane.Markdown(self._summary_text) - self._chart_pane = pn.pane.HoloViews() - -@param.depends("data") -def _summary_text(self): - self._summary_pane.object = f"**Rows**: {len(self.data)}" - -@param.depends("data", watch=True, on_init=True) -def _update_chart(self): - self._chart_pane.object = self.data.hvplot.scatter(...) -``` - -### DO: Use `param.depends` when orchestrating multiple component updates from one event - -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: - -```python -def __init__(self, **params): - super().__init__(**params) - self._table = pn.widgets.Tabulator(pd.DataFrame(), ...) - self._status_html = pn.pane.HTML("", sizing_mode="stretch_width") - -@param.depends("data", watch=True) -def _on_data_changed(self, *events): - self._status_html.object = self._render_status(self.data) - self._table.value = self._prepare_df(self.data) - self._chart_trigger += 1 - self._chart_section.visible = not self.data.empty -``` - -**When to use which**: -- `@param.depends("x")` (no watch) → return content, pass method ref to a pane. Preferred for simple 1-param → 1-pane reactivity. -- `@param.depends("x", watch=True)` → update state parameters or trigger side effects. - ---- - -## DynamicMap: Preserve Zoom/Pan Across Data Refreshes - -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. - -### DON'T: Replace chart object directly - -```python -# BAD — zoom resets every refresh -self._chart_pane.object = df.hvplot.scatter(...) -``` - -### DO: Use DynamicMap with a trigger parameter - -```python -class Monitor(pn.viewable.Viewer): - _chart_trigger = param.Integer(default=0) - - def __init__(self, **params): - super().__init__(**params) - dmap = hv.DynamicMap(pn.bind(self._render_scatter, self.param._chart_trigger)) - self._chart_pane = pn.pane.HoloViews(dmap, sizing_mode="stretch_width") - - def _render_scatter(self, trigger): - # Reads self.data directly; trigger is just a signal to re-invoke - df = self.data - if df is None or df.empty: - return hv.Scatter([], kdims=['x'], vdims=['y']) - return df.hvplot.scatter(x='x', y='y', ...) - - def _on_data_changed(self, *events): - # Increment trigger → DynamicMap re-invokes → Bokeh patches in place - self._chart_trigger += 1 -``` - ---- - -## One Element Per DynamicMap - -Returning an `hv.Overlay` from a DynamicMap causes two problems: - -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`. -2. **Lost hover tooltips** — when scatter + HLines are combined inside `hv.Overlay([...])`, the scatter's hover tool configuration doesn't propagate. - -### DON'T: Return mixed types or Overlays from a single DynamicMap - -```python -# BAD — type mismatch when data is empty vs populated -def render(trigger): - if no_data: - return hv.Text(0, 0, "empty") # Text type - plot = df.hvplot.scatter(...) - return plot * hv.HLine(avg) # Overlay type → AssertionError - -# BAD — hover tooltips lost -def render(trigger): - scatter = df.hvplot.scatter(..., tools=['hover']) - return hv.Overlay([scatter, hv.HLine(avg)]) # hover doesn't propagate -``` - -### DO: Separate DynamicMap per element, combine with `*` at layout level - -```python -def __init__(self, **params): - super().__init__(**params) - scatter_dmap = hv.DynamicMap(pn.bind(self._render_scatter, self.param._trigger)) - avg_dmap = hv.DynamicMap(pn.bind(self._render_avg_line, self.param._trigger)) - min_dmap = hv.DynamicMap(pn.bind(self._render_min_line, self.param._trigger)) - max_dmap = hv.DynamicMap(pn.bind(self._render_max_line, self.param._trigger)) - overlay = scatter_dmap * avg_dmap * min_dmap * max_dmap - self._chart_pane = pn.pane.HoloViews(overlay, sizing_mode="stretch_width") -``` - -Each callback returns exactly **one element type**, always: - -```python -def _render_scatter(self, trigger): - completed = self._get_completed() - if completed.empty: - # Same type as the populated case — just no data - return hv.Scatter([], kdims=['START_TIME'], vdims=['RUNTIME_SECONDS']).opts( - bgcolor='#0d1015', height=180, responsive=True, - ) - return completed.hvplot.scatter(x='START_TIME', y='RUNTIME_SECONDS', ...) - -def _render_avg_line(self, trigger): - avg = self._stats().get('avg_runtime', 0) - if avg > 0: - return hv.HLine(avg).opts(color='amber', line_dash='dashed', ...) - # Invisible but valid — same type always - return hv.HLine(0).opts(alpha=0) -``` - -Benefits: -- Each DynamicMap always returns the same HoloViews element type (no `AssertionError`) -- Scatter keeps its hover tools natively (tools are per-element, not per-overlay) -- Each layer updates independently -- Static layers (like a tile source) can be pulled out of DynamicMap entirely - ---- - -## Client-Side Interactions with `jslink` - -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. - -### Simple property binding - -```python -# Float slider → glyph fill alpha (also works for size, line_width, fill_color, etc.) -widget = pn.widgets.FloatSlider(value=1, step=0.01) -plot = hv.Points((x, y)).opts(size=10) -widget.jslink(plot, value='glyph.fill_alpha') - -# Text input → plot title -widget = pn.widgets.TextInput(value="Title") -plot = hv.Curve((x, y)).opts(title="Title") -widget.jslink(plot, value="plot.title.text") - -# Text input → axis label -widget = pn.widgets.TextInput(value="X Label") -plot = hv.Curve((x, y)).opts(xlabel="X Label") -widget.jslink(plot, value="xaxis.axis_label") - -# RadioButtonGroup → title alignment -widget = pn.widgets.RadioButtonGroup(options=["left", "center", "right"]) -widget.jslink(plot, value="plot.title.align") -``` - -### JavaScript code callbacks - -For transforms that need a bit of JS logic, use the `code` parameter: - -```python -# Range slider → axis limits -widget = pn.widgets.RangeSlider(start=0, end=10) -plot = hv.Curve((x, y)) -widget.jslink(plot, code={'value': """ - x_range.start = cb_obj.value[0]; - x_range.end = cb_obj.value[1]; -"""}) - -# Range slider → colorbar limits -widget.jslink(plot, code={'value': """ - color_mapper.low = cb_obj.value[0]; - color_mapper.high = cb_obj.value[1]; -"""}) - -# Float slider → modify data directly -widget.jslink(plot, code={'value': """ - var y = cds.data['y']; - for (var i = 0; i < y.length; i++) { y[i] = cb_obj.value; } - cds.change.emit(); -"""}) - -# Select → colormap with extra args -cmaps_colors = {name: hv.plotting.util.process_cmap(cmap, n) for ...} -widget.jslink(plot, code={'value': "color_mapper.palette = cmap_dict[source.value];"}, - args={"cmap_dict": cmaps_colors}) -``` - -**Bokeh property targets available via jslink**: -- `glyph.*` — fill_alpha, fill_color, size, line_width, line_color, etc. -- `plot.title.*` — text, text_font_size, align -- `xaxis.*` / `yaxis.*` — axis_label, etc. -- `x_range.*` / `y_range.*` — start, end -- `color_mapper.*` — low, high, palette -- `cds.*` — column data source for direct data manipulation - -**When to use jslink vs DynamicMap**: -- `jslink` — pure visual/styling changes, axis limits, color tweaks. No Python needed. -- `DynamicMap` — data transformations, aggregations, anything that needs Python computation. - ---- - -## Summary Checklist - -| Pattern | Do | Don't | -|---|---|---| -| Update UI | Pass `@param.depends` method ref to pane; or `pn.bind` for multi-component orchestration | Return new panes/layouts from `@param.depends` | -| Preserve zoom | `hv.DynamicMap(pn.bind(fn, trigger))` | `pane.object = new_plot` | -| Overlay composition | One DynamicMap per element, `*` at layout | `hv.Overlay([...])` inside callback | -| Empty chart state | `hv.Scatter([], kdims=..., vdims=...)` | `hv.Overlay([])` or `hv.Text(...)` | -| Side effects | `pn.bind(fn, obj.param.value)` | `@param.depends` with `watch=True` for UI | diff --git a/src/holoviz_mcp/config/resources/skills/panel-holoviews.md b/src/holoviz_mcp/config/resources/skills/panel-holoviews.md new file mode 100644 index 0000000..25a08ea --- /dev/null +++ b/src/holoviz_mcp/config/resources/skills/panel-holoviews.md @@ -0,0 +1,387 @@ +--- +name: panel-holoviews +description: Best practices for integrating HoloViews and hvPlot visualizations into Panel applications. Use when embedding HoloViews/hvPlot plots in Panel panes, preserving zoom/pan state across data refreshes with DynamicMap, composing DynamicMap overlays without type errors, using HoloViews streams (Selection1D, RangeXY, Tap, BoundsXY, Pipe, Buffer) with Panel, cross-filtering with link_selections, making HoloViews plots responsive in Panel layouts, or wiring Panel widgets to Bokeh plot properties with jslink. +metadata: + version: "1.0.0" + author: ahuang11 + category: web-development + difficulty: intermediate +--- + +# Panel + HoloViews Integration Patterns + +- DO let Panel control the renderer theme + - DON'T set `hv.renderer('bokeh').theme = 'dark_minimal'` + - DO use `pn.pane.HoloViews(plot, theme="dark_minimal")` to set the theme per-pane + +--- + +## DynamicMap: Preserve Zoom/Pan Across Data Refreshes + +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. + +### DON'T: Replace chart object directly + +```python +# BAD — zoom resets every refresh +self._chart_pane.object = df.hvplot.scatter(...) +``` + +### DO: Use DynamicMap with a trigger parameter + +```python +class Monitor(pn.viewable.Viewer): + _chart_trigger = param.Integer(default=0) + + def __init__(self, **params): + super().__init__(**params) + dmap = hv.DynamicMap(pn.bind(self._render_scatter, self.param._chart_trigger)) + self._chart_pane = pn.pane.HoloViews(dmap, sizing_mode="stretch_width") + + def _render_scatter(self, trigger): + # Reads self.data directly; trigger is just a signal to re-invoke + df = self.data + if df is None or df.empty: + return hv.Scatter([], kdims=['x'], vdims=['y']).opts(responsive=True, height=300) + # Pass responsive and height directly to hvplot — see "Responsive Sizing" section + return df.hvplot.scatter(x='x', y='y', responsive=True, height=300) + + def _on_data_changed(self, *events): + # Increment trigger → DynamicMap re-invokes → Bokeh patches in place + self._chart_trigger += 1 +``` + +--- + +## One Element Per DynamicMap + +Returning an `hv.Overlay` from a DynamicMap causes two problems: + +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`. +2. **Lost hover tooltips** — when scatter + HLines are combined inside `hv.Overlay([...])`, the scatter's hover tool configuration doesn't propagate. + +### DON'T: Return mixed types or Overlays from a single DynamicMap + +```python +# BAD — type mismatch when data is empty vs populated +def render(trigger): + if no_data: + return hv.Text(0, 0, "empty") # Text type + plot = df.hvplot.scatter(...) + return plot * hv.HLine(avg) # Overlay type → AssertionError + +# BAD — hover tooltips lost +def render(trigger): + scatter = df.hvplot.scatter(..., tools=['hover']) + return hv.Overlay([scatter, hv.HLine(avg)]) # hover doesn't propagate +``` + +### DO: Separate DynamicMap per element, combine with `*` at layout level + +```python +def __init__(self, **params): + super().__init__(**params) + scatter_dmap = hv.DynamicMap(pn.bind(self._render_scatter, self.param._trigger)) + avg_dmap = hv.DynamicMap(pn.bind(self._render_avg_line, self.param._trigger)) + min_dmap = hv.DynamicMap(pn.bind(self._render_min_line, self.param._trigger)) + max_dmap = hv.DynamicMap(pn.bind(self._render_max_line, self.param._trigger)) + overlay = scatter_dmap * avg_dmap * min_dmap * max_dmap + self._chart_pane = pn.pane.HoloViews(overlay, sizing_mode="stretch_width") +``` + +Each callback returns exactly **one element type**, always: + +```python +def _render_scatter(self, trigger): + completed = self._get_completed() + if completed.empty: + # Same type as the populated case — just no data + return hv.Scatter([], kdims=['START_TIME'], vdims=['RUNTIME_SECONDS']).opts( + bgcolor='#0d1015', height=180, responsive=True, + ) + return completed.hvplot.scatter(x='START_TIME', y='RUNTIME_SECONDS') + +def _render_avg_line(self, trigger): + avg = self._stats().get('avg_runtime', 0) + if avg > 0: + return hv.HLine(avg).opts(color='orange', line_dash='dashed') + # Invisible but valid — same type always + return hv.HLine(0).opts(alpha=0) +``` + +Benefits: + +- Each DynamicMap always returns the same HoloViews element type (no `AssertionError`) +- Scatter keeps its hover tools natively (tools are per-element, not per-overlay) +- Each layer updates independently +- Static layers (like a tile source) can be pulled out of DynamicMap entirely + +Note: `Element * DynamicMap` or `DynamicMap * DynamicMap` yields a `DynamicMap`, not a static `hv.Overlay`. This is expected — Panel handles it the same way via `pn.pane.HoloViews`. + +--- + +## Responsive Sizing + +HoloViews `.opts(width=, height=, responsive=)` and Panel's `sizing_mode` on `pn.pane.HoloViews` are two separate sizing systems. They conflict if misconfigured. + +### DO: Pass `responsive=True` and `height` directly to the hvplot call + +```python +# hvplot: pass responsive and height as arguments so hvplot does NOT set a default fixed width +plot = df.hvplot.scatter(x='time', y='value', responsive=True, height=300) +pane = pn.pane.HoloViews(plot, sizing_mode="stretch_width") + +# Pure HoloViews: .opts() is fine because HoloViews doesn't inject a default width +plot = hv.Curve(df, 'time', 'value').opts(responsive=True, height=300) +pane = pn.pane.HoloViews(plot, sizing_mode="stretch_width") +``` + +### DON'T: Use `.opts(responsive=True)` on an hvplot object + +```python +# BAD — hvplot internally sets width=700; .opts(responsive=True) doesn't remove it. +# Bokeh sees both fixed width and responsive=True → warning + broken layout. +# Inside a DynamicMap the plot shrinks on every refresh. +plot = df.hvplot.scatter(x='time', y='value').opts(responsive=True, height=300) + +# BAD — same problem with explicit fixed width +plot = df.hvplot.scatter(...).opts(width=600, height=300) +pane = pn.pane.HoloViews(plot, sizing_mode="stretch_width") +``` + +**Key rules**: + +- **hvplot**: always pass `responsive=True` + `height=` as **arguments to the hvplot call** (e.g. `df.hvplot.scatter(..., responsive=True, height=300)`), and `sizing_mode="stretch_width"` on the pane. Do NOT use `.opts()` for these — hvplot sets a default `width=700` internally and `.opts()` cannot remove it. +- **Pure HoloViews**: using `.opts(responsive=True, height=)` is fine because HoloViews elements don't inject a default fixed width. +- For fixed size: set `width=` + `height=` in `.opts()`, omit `sizing_mode` on the pane +- DON'T mix fixed `width` in opts with `sizing_mode` on the pane — Panel overrides the width but emits a warning; the conflict makes behavior ambiguous +- Never set both `width` and `responsive=True` in `.opts()` — `width` wins and responsive is silently ignored + +--- + +## HoloViews Streams with Panel + +HoloViews streams let you react to user interactions on plots (clicks, selections, zoom) inside a Panel app. Attach streams to a DynamicMap; Panel wires up the callbacks automatically. + +### Selection1D: React to selected points + +```python +import holoviews as hv +from holoviews import streams + +points = hv.Points(df, kdims=['x', 'y']).opts(tools=['tap', 'box_select'], size=8) +selection = streams.Selection1D(source=points) + +def show_selected(index): + if not index: + return hv.Table(df.iloc[:0], kdims=['x', 'y']) + return hv.Table(df.iloc[index], kdims=['x', 'y']) + +table_dmap = hv.DynamicMap(show_selected, streams=[selection]) +pn.Row(points, table_dmap).servable() +``` + +### RangeXY: React to zoom/pan range + +```python +curve = hv.Curve(df, 'time', 'value') +range_stream = streams.RangeXY(source=curve) + +def show_stats(x_range, y_range): + if x_range is None: + return hv.Text(0, 0, "Zoom to select range") + x0, x1 = x_range + subset = df[(df['time'] >= x0) & (df['time'] <= x1)] + return hv.Text(0, 0, f"Mean: {subset['value'].mean():.2f}") + +stats_dmap = hv.DynamicMap(show_stats, streams=[range_stream]) +pn.Column(curve, stats_dmap).servable() +``` + +### Tap: React to click location + +```python +points = hv.Points(df, kdims=['x', 'y']).opts(tools=['tap'], size=8) +tap_stream = streams.Tap(source=points) + +def on_tap(x, y): + if x is None: + return hv.Text(0, 0, "Click a point") + return hv.Text(x, y, f"({x:.1f}, {y:.1f})").opts(text_font_size='12pt') + +tap_dmap = hv.DynamicMap(on_tap, streams=[tap_stream]) +(points * tap_dmap).servable() +``` + +### Pipe / Buffer: Push streaming data + +```python +from holoviews.streams import Pipe, Buffer + +# Pipe — replace data entirely each push +pipe = Pipe(data=[]) +# framewise=True: recompute axis ranges on each push (without it, axes lock to first frame) +pipe_dmap = hv.DynamicMap(hv.Curve, streams=[pipe]).opts(framewise=True) + +def update(): + pipe.send(new_dataframe) + +# Buffer — append data, keep last N rows +buffer = Buffer(df.iloc[:0], length=500) +buffer_dmap = hv.DynamicMap(hv.Curve, streams=[buffer]).opts(framewise=True) + +def update(): + buffer.send(new_rows_df) +``` + +### Common stream pitfalls + +- **Forgot tools**: `Selection1D` needs `tools=['tap', 'box_select']` in `.opts()` — without them no events fire +- **Unhandled None**: Stream callbacks receive `None`/empty values on first render — always guard with `if not index:` or `if x is None:` +- **Don't mix mechanisms**: Use either streams OR `param.depends`/`pn.bind` for a given plot — not both +- **Frozen axes with Pipe/Buffer**: By default DynamicMap locks axis ranges to the first frame. Use `.opts(framewise=True)` so axes update when data ranges change + +--- + +## Linked Selections / Cross-Filtering + +`hv.link_selections` provides automatic cross-filtering across plots. LLMs often try to build this manually with streams — use the built-in API instead. + +### Basic usage + +```python +import holoviews as hv +from holoviews.operation import histogram + +ls = hv.link_selections.instance() + +scatter = hv.Points(df, kdims=['x', 'y']) +# DO use hv.operation.histogram — preserves link to source data so +# link_selections can filter by all source dimensions (x AND y) +hist_x = histogram(scatter, dimension='x', num_bins=20) +hist_y = histogram(scatter, dimension='y', num_bins=20) + +# Wrap each plot — selections in one propagate to all +layout = ls(scatter) + ls(hist_x) + ls(hist_y) +pn.pane.HoloViews(layout, sizing_mode="stretch_width").servable() +``` + +### DON'T: Use pre-binned `np.histogram` or pre-aggregated data + +```python +# BAD — pre-binned histogram loses data lineage; link_selections can't resolve +# the scatter's 'y' dimension on the histogram → CallbackError +hist = hv.Histogram(np.histogram(df['x'], bins=20), kdims='x') + +# BAD — pre-aggregated bars lose the original x/y columns; same problem +bars = hv.Bars(df.groupby('cat').size().reset_index(name='n'), kdims='cat', vdims='n') +``` + +### Accessing the selection for filtering + +```python +ls = hv.link_selections.instance() +# ... build linked plots ... + +# selection_expr is a HoloViews dim expression — apply it to a Dataset, not a raw DataFrame +ds = hv.Dataset(df, kdims=["x", "y"]) +expr = ls.selection_expr +if expr is not None: + mask = expr.apply(ds) # returns a boolean numpy array + filtered_df = df[mask] +``` + +**Key rules**: + +- DO use `.instance()` to create the link_selections object — calling `hv.link_selections(plot)` directly returns a plot, not a reusable linker +- DO apply `selection_expr` to a `hv.Dataset`, not a pandas DataFrame — `expr.apply(df)` raises `AttributeError` +- DO use `hv.operation.histogram(source_element, dimension='x')` for histograms — this preserves the data lineage so `link_selections` can filter by all source dimensions. DON'T use `hv.Histogram(np.histogram(...))` — pre-binned histograms lose the source data link, and `link_selections` raises `CallbackError` when the selection expression references dimensions not present on the histogram +- DON'T add selection tools manually — `link_selections` adds `box_select` and `lasso_select` automatically +- Each linked plot must share the same dimension names for cross-filtering to work +- **Dependencies**: `link_selections` requires `pyarrow` at runtime for selection display. Lasso selection additionally requires `shapely` (or `spatialpandas`). Install both: `pip install pyarrow shapely`. Without `pyarrow`, selections silently fail with `CallbackError`; without `shapely`, lasso raises `ImportError` while box-select still works + +--- + +## Client-Side Interactions with `jslink` + +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. + +### Simple property binding + +```python +# Float slider → glyph fill alpha (also works for size, line_width, fill_color, etc.) +widget = pn.widgets.FloatSlider(value=1, step=0.01) +plot = hv.Points((x, y)).opts(size=10) +widget.jslink(plot, value='glyph.fill_alpha') + +# Text input → plot title +widget = pn.widgets.TextInput(value="Title") +plot = hv.Curve((x, y)).opts(title="Title") +widget.jslink(plot, value="plot.title.text") + +# Text input → axis label +widget = pn.widgets.TextInput(value="X Label") +plot = hv.Curve((x, y)).opts(xlabel="X Label") +widget.jslink(plot, value="xaxis.axis_label") + +# RadioButtonGroup → title alignment +widget = pn.widgets.RadioButtonGroup(options=["left", "center", "right"]) +widget.jslink(plot, value="plot.title.align") +``` + +### JavaScript code callbacks + +For transforms that need a bit of JS logic, use the `code` parameter: + +```python +# Range slider → axis limits +widget = pn.widgets.RangeSlider(start=0, end=10) +plot = hv.Curve((x, y)) +widget.jslink(plot, code={'value': """ + x_range.start = cb_obj.value[0]; + x_range.end = cb_obj.value[1]; +"""}) +``` + +**Bokeh property targets available via jslink**: + +- `glyph.*` — fill_alpha, fill_color, size, line_width, line_color, etc. +- `plot.title.*` — text, text_font_size, align +- `xaxis.*` / `yaxis.*` — axis_label, etc. +- `x_range.*` / `y_range.*` — start, end +- `color_mapper.*` — low, high, palette + +**When to use jslink vs DynamicMap**: + +- `jslink` — pure visual/styling changes, axis limits, color tweaks. No Python needed. +- `DynamicMap` — data transformations, aggregations, anything that needs Python computation. + +--- + +## `pn.pane.HoloViews` Configuration + +| Parameter | Type | Description | +|---|---|---| +| `linked_axes` | bool (True) | Link axes ranges across plots in a layout | +| `widget_location` | str ('right') | Location of groupby/DynamicMap widgets: 'left', 'right', 'top', 'bottom', etc. | +| `center` | bool (False) | Center the plot in the pane | +| `theme` | str/Theme (None) | Bokeh theme override — use instead of `hv.renderer().theme` | +| `backend` | str ('bokeh') | HoloViews plotting backend | +| `sizing_mode` | str (None) | Panel sizing: 'stretch_width', 'stretch_both', 'fixed', etc. | + +--- + +## Summary Checklist + +| Pattern | Do | Don't | +|---|---|---| +| Preserve zoom | `hv.DynamicMap(pn.bind(fn, trigger))` | `pane.object = new_plot` | +| Overlay composition | One DynamicMap per element, `*` at layout | `hv.Overlay([...])` inside callback | +| Empty chart state | `hv.Scatter([], kdims=..., vdims=...)` | `hv.Overlay([])` or `hv.Text(...)` | +| Responsive width (hvplot) | `df.hvplot(..., responsive=True, height=N)` + `sizing_mode="stretch_width"` on pane | `.opts(responsive=True)` after hvplot (hvplot's default `width=700` persists, conflicts) | +| Responsive width (HoloViews) | `.opts(responsive=True, height=N)` + `sizing_mode="stretch_width"` on pane | Fixed `width` in opts (conflicts with responsive pane, emits warning) | +| React to selections | `streams.Selection1D(source=plot)` + DynamicMap | Manual click tracking with `param.depends` | +| Cross-filtering | `hv.link_selections.instance()` | Building manual stream wiring | +| Streaming data | `Pipe` (replace) or `Buffer` (append) streams | Replacing pane.object in a loop | +| Renderer theme | `pn.pane.HoloViews(plot, theme=...)` | `hv.renderer('bokeh').theme = ...` | +| Client-side styling | `widget.jslink(plot, value='glyph.fill_alpha')` | DynamicMap for pure visual tweaks | diff --git a/src/holoviz_mcp/config/resources/skills/panel.md b/src/holoviz_mcp/config/resources/skills/panel.md index 7ac2795..8b70aa9 100644 --- a/src/holoviz_mcp/config/resources/skills/panel.md +++ b/src/holoviz_mcp/config/resources/skills/panel.md @@ -535,10 +535,7 @@ def kpi_value(self): ### HoloViews/hvPlot -- DO let Panel control the renderer theme - - DON'T set `hv.renderer('bokeh').theme = 'dark_minimal'` - -DO follow the hvplot and holoviews best practice guides! +DO follow the relevant `hvplot`, `holoviews` and `panel-holoviews` skills/ best practice guides! ### Matplotlib diff --git a/src/holoviz_mcp/holoviz_mcp/server.py b/src/holoviz_mcp/holoviz_mcp/server.py index 0456356..9a777f9 100644 --- a/src/holoviz_mcp/holoviz_mcp/server.py +++ b/src/holoviz_mcp/holoviz_mcp/server.py @@ -163,7 +163,8 @@ def get_skill(name: str) -> str: Use list_skills tool to see available skills. Args: - name (str): The name of the skill to get. For example, "panel", "panel-material-ui", etc. + name (str): The name of the skill to get. For example, "panel", "panel-material-ui", + "panel-holoviews", "panel-custom-components" etc. Returns