diff --git a/examples/reference/panes/ECharts.ipynb b/examples/reference/panes/ECharts.ipynb index c255e0c608..d9e6ea0cad 100644 --- a/examples/reference/panes/ECharts.ipynb +++ b/examples/reference/panes/ECharts.ipynb @@ -39,7 +39,7 @@ "metadata": {}, "outputs": [], "source": [ - "echart = {\n", + "echart_bar = {\n", " 'title': {\n", " 'text': 'ECharts entry example'\n", " },\n", @@ -57,7 +57,7 @@ " 'data': [5, 20, 36, 10, 10, 20]\n", " }],\n", "};\n", - "echart_pane = pn.pane.ECharts(echart, height=480, width=640)\n", + "echart_pane = pn.pane.ECharts(echart_bar, height=480, width=640)\n", "echart_pane" ] }, @@ -74,7 +74,7 @@ "metadata": {}, "outputs": [], "source": [ - "echart['series'] = [dict(echart['series'][0], type= 'line')]\n", + "echart_bar['series'] = [dict(echart_bar['series'][0], type='line')]\n", "echart_pane.param.trigger('object')" ] }, @@ -91,7 +91,7 @@ "metadata": {}, "outputs": [], "source": [ - "responsive_spec = dict(echart, responsive=True)\n", + "responsive_spec = dict(echart_bar, responsive=True)\n", "\n", "pn.pane.ECharts(responsive_spec, height=400)" ] @@ -162,6 +162,139 @@ "pn.Column(slider, gauge_pane)" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Events\n", + "\n", + "The `EChart` object allows you to listen to any event defined in the Javascript API, either by listening to the event in Python using the `on_event` method or by triggering a Javascript callback with the `js_on_event` method.\n", + "\n", + "For details on what events you can [ECharts events documentation](https://echarts.apache.org/handbook/en/concepts/event).\n", + "\n", + "### Python\n", + "\n", + "Let us start with a simple click event we want to listen to from Python. To add an event listener we simple call the `on_event` method with the event type (in this case 'click') and our Python handler." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "echart_pane = pn.pane.ECharts(echart_bar, height=480, width=640)\n", + "json = pn.pane.JSON()\n", + "\n", + "def callback(event):\n", + " json.object = event.data\n", + "\n", + "echart_pane.on_event('click', callback)\n", + "\n", + "pn.Row(echart_pane, json)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Try clicking on a point on the line. When inspecting the `json.object` after a click you should see something like this:\n", + "\n", + "```python\n", + "{'componentType': 'series',\n", + " 'componentSubType': 'line',\n", + " 'componentIndex': 0,\n", + " 'seriesType': 'line',\n", + " 'seriesIndex': 0,\n", + " 'seriesId': '\\x00Sales\\x000',\n", + " 'seriesName': 'Sales',\n", + " 'name': 'shirt',\n", + " 'dataIndex': 0,\n", + " 'data': 5,\n", + " 'value': 5,\n", + " 'color': '#5470c6',\n", + " 'dimensionNames': ['x', 'y'],\n", + " 'encode': {'x': [0], 'y': [1]},\n", + " '$vars': ['seriesName', 'name', 'value'],\n", + " 'event': {'detail': 1,\n", + " 'altKey': False,\n", + " 'button': 0,\n", + " 'buttons': 0,\n", + " 'clientX': 507,\n", + " 'clientY': 911,\n", + " 'ctrlKey': False,\n", + " 'metaKey': False,\n", + " 'pageX': 507,\n", + " 'pageY': 911,\n", + " 'screenX': 3739,\n", + " 'screenY': 762,\n", + " 'shiftKey': False,\n", + " 'target': {'boundingClientRect': {}},\n", + " 'currentTarget': {'boundingClientRect': {}},\n", + " 'relatedTarget': None},\n", + " 'type': 'click'}\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To restrict what types of objects a particular event applies to you can also provide a `query` argument to the `on_event` method. The format of the `query` should be `mainType` or `mainType.subType`, such as:\n", + "\n", + "- `'series'`: Fire event when clicking on data series\n", + "- `'series.line'`: Fire event only when clicking on a line data series.\n", + "- `'dataZoom'`: Fire event when clicking on zoom.\n", + "- `'xAxis.category'`: Fire event when clicking on a category on the xaxis." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Javascript\n", + "\n", + "The same concepts apply in Javascript, however here we pass in Javascript code a JS snippet. The namespace allows you to access the event data `cb_data` and the ECharts chart itself as `cb_obj`. In this way you have access to the event and can manipulate the plot yourself:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "echart_pane = pn.pane.ECharts(echart_bar, height=480, width=640)\n", + "\n", + "echart_pane.js_on_event('click', 'alert(`Clicked on point: ${cb_data.dataIndex}`)')\n", + "\n", + "echart_pane" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "If you want to modify another object in response to an event triggered on the chart you can pass additional objects to the `json_on_event` method. The corresponding Bokeh model will then be made available in the callback. As an example here we make the `JSON` pane available so that we can update it on a click event:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "echart_pane = pn.pane.ECharts(echart_bar, height=480, width=640)\n", + "json = pn.pane.JSON()\n", + "\n", + "echart_pane.js_on_event('click', \"\"\"\n", + "event = {...cb_data}\n", + "delete event.event\n", + "json.text = JSON.stringify(event)\n", + "\"\"\", json=json)\n", + "\n", + "pn.Row(echart_pane, json)" + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/panel/models/echarts.py b/panel/models/echarts.py index 25cfcf5ddd..5498d075d1 100644 --- a/panel/models/echarts.py +++ b/panel/models/echarts.py @@ -1,9 +1,10 @@ """ -Defines custom ECharts bokeh model to render Vega json plots. +Defines custom bokeh model to render ECharts plots. """ from bokeh.core.properties import ( - Any, Dict, Enum, String, + Any, Dict, Enum, List, Nullable, String, ) +from bokeh.events import ModelEvent from bokeh.models import LayoutDOM from ..config import config @@ -11,6 +12,17 @@ from ..util import classproperty +class EChartsEvent(ModelEvent): + + event_name = 'echarts_event' + + def __init__(self, model, type=None, data=None, query=None): + self.type = type + self.data = data + self.query = query + super().__init__(model=model) + + class ECharts(LayoutDOM): """ A Bokeh model that wraps around an ECharts plot and renders it @@ -40,7 +52,11 @@ def __js_skip__(cls): 'exports': {} } - data = Dict(String, Any) + data = Nullable(Dict(String, Any)) + + event_config = Dict(String, Any) + + js_events = Dict(String, List(Any)) renderer = Enum("canvas", "svg") diff --git a/panel/models/echarts.ts b/panel/models/echarts.ts index 091bd6f023..92f9531572 100644 --- a/panel/models/echarts.ts +++ b/panel/models/echarts.ts @@ -1,18 +1,53 @@ +import {ModelEvent} from "@bokehjs/core/bokeh_events" import {div} from "@bokehjs/core/dom" import * as p from "@bokehjs/core/properties" +import {Attrs} from "@bokehjs/core/types" + +import {serializeEvent} from "./event-to-object" import {HTMLBox, HTMLBoxView} from "./layout" +const mouse_events = [ + 'click', 'dblclick', 'mousedown', 'mousemove', 'mouseup', 'mouseover', 'mouseout', + 'globalout', 'contextmenu' +]; + +const events = [ + 'highlight', 'downplay', 'selectchanged', 'legendselectchangedEvent', 'legendselected', + 'legendunselected', 'legendselectall', 'legendinverseselect', 'legendscroll', 'datazoom', + 'datarangeselected', 'timelineplaychanged', 'restore', 'dataviewchanged', 'magictypechanged', + 'geoselectchanged', 'geoselected', 'geounselected', 'axisareaselected', 'brush', 'brushEnd', + 'rushselected', 'globalcursortaken', 'rendered', 'finished' +]; + +const all_events = mouse_events.concat(events); + +export class EChartsEvent extends ModelEvent { + constructor(readonly type: string, readonly data: any, readonly query: string) { + super() + } + + protected get event_values(): Attrs { + return {model: this.origin, type: this.type, data: this.data, query: this.query} + } + + static { + this.prototype.event_name = "echarts_event" + } +} + export class EChartsView extends HTMLBoxView { model: ECharts - _chart: any container: Element + _chart: any + _callbacks: Array[] = [] connect_signals(): void { super.connect_signals() this.connect(this.model.properties.data.change, () => this._plot()) - const {width, height, renderer, theme} = this.model.properties + const {width, height, renderer, theme, event_config, js_events} = this.model.properties this.on_change([width, height], () => this._resize()) this.on_change([theme, renderer], () => this.render()) + this.on_change([event_config, js_events], () => this._subscribe()) } render(): void { @@ -27,8 +62,8 @@ export class EChartsView extends HTMLBoxView { config ) this._plot() + this._subscribe() this.shadow_el.append(this.container) - this._chart.resize() } override remove(): void { @@ -51,12 +86,59 @@ export class EChartsView extends HTMLBoxView { _resize(): void { this._chart.resize({width: this.model.width, height: this.model.height}); } + + _subscribe(): void { + if ((window as any).echarts == null) + return + for (const [event_type, callback] of this._callbacks) + this._chart.off(event_type, callback) + this._callbacks = [] + for (const event_type in this.model.event_config) { + if (!all_events.includes(event_type)) { + console.warn(`Could not subscribe to unknown Echarts event: ${event_type}.`); + continue + } + const queries = this.model.event_config[event_type]; + for (const query of queries) { + const callback = (event: any) => { + const processed = {...event} + processed.event = serializeEvent(event.event?.event) + const serialized = JSON.parse(JSON.stringify(processed)) + this.model.trigger_event(new EChartsEvent(event_type, serialized, query)) + } + if (query == null) + this._chart.on(event_type, query, callback) + else + this._chart.on(event_type, callback) + this._callbacks.push([event_type, callback]) + } + } + for (const event_type in this.model.js_events) { + if (!all_events.includes(event_type)) { + console.warn(`Could not subscribe to unknown Echarts event: ${event_type}.`); + continue + } + const handlers = this.model.js_events[event_type]; + for (const handler of handlers) { + const callback = (event: any) => { + handler.callback.execute(this._chart, event) + } + if ('query' in handler) + this._chart.on(event_type, handler.query, callback) + else + this._chart.on(event_type, callback) + this._callbacks.push([event_type, callback]) + } + } + } } export namespace ECharts { export type Attrs = p.AttrsOf export type Props = HTMLBox.Props & { data: p.Property + event_config: p.Property + js_events: p.Property renderer: p.Property theme: p.Property } @@ -76,10 +158,12 @@ export class ECharts extends HTMLBox { static { this.prototype.default_view = EChartsView - this.define(({Any, String}) => ({ - data: [ Any, {} ], - theme: [ String, "default" ], - renderer: [ String, "canvas" ] + this.define(({ Any, String }) => ({ + data: [ Any, {} ], + event_config: [ Any, {} ], + js_events: [ Any, {} ], + theme: [ String, "default"], + renderer: [ String, "canvas"] })) } } diff --git a/panel/models/event-to-object.ts b/panel/models/event-to-object.ts index de25fbc571..cc582152c7 100644 --- a/panel/models/event-to-object.ts +++ b/panel/models/event-to-object.ts @@ -81,7 +81,11 @@ const elementTransformCategories: any = { }; function defaultElementTransform(element: Element) { - return { boundingClientRect: {...element.getBoundingClientRect()} }; + try { + return { boundingClientRect: {...element.getBoundingClientRect()} }; + } catch { + return {} + } } const elementTagCategories: any = { diff --git a/panel/pane/echarts.py b/panel/pane/echarts.py index 3143117bae..11526e03dd 100644 --- a/panel/pane/echarts.py +++ b/panel/pane/echarts.py @@ -3,15 +3,18 @@ import json import sys +from collections import defaultdict from typing import ( - TYPE_CHECKING, Any, ClassVar, List, Mapping, Optional, + TYPE_CHECKING, Any, Callable, ClassVar, List, Mapping, Optional, ) import param +from bokeh.models import CustomJS from pyviz_comms import JupyterComm from ..util import lazy_load +from ..viewable import Viewable from .base import ModelPane if TYPE_CHECKING: @@ -49,6 +52,11 @@ class ECharts(ModelPane): _updates: ClassVar[bool] = True + def __init__(self, object=None, **params): + super().__init__(object, **params) + self._py_callbacks = defaultdict(lambda: defaultdict(list)) + self._js_callbacks = defaultdict(list) + @classmethod def applies(cls, obj: Any, **params) -> float | bool | None: if isinstance(obj, dict): @@ -64,14 +72,25 @@ def is_pyecharts(cls, obj): return isinstance(obj, pyecharts.charts.chart.Chart) return False - def _get_model( - self, doc: Document, root: Optional[Model] = None, - parent: Optional[Model] = None, comm: Optional[Comm] = None - ) -> Model: - self._bokeh_model = lazy_load( - 'panel.models.echarts', 'ECharts', isinstance(comm, JupyterComm), root - ) - return super()._get_model(doc, root, parent, comm) + def _process_event(self, event): + callbacks = self._py_callbacks.get(event.type, {}) + for cb in callbacks.get(None, []): + cb(event) + if event.query is None: + return + for cb in callbacks.get(event.query, []): + cb(event) + + def _get_js_events(self, ref): + js_events = defaultdict(list) + for event, specs in self._js_callbacks.items(): + for (query, code, args) in specs: + models = { + name: viewable._models[ref][0] for name, viewable in args.items() + if ref in viewable._models + } + js_events[event].append({'query': query, 'callback': CustomJS(code=code, args=models)}) + return dict(js_events) def _process_param_change(self, params): props = super()._process_param_change(params) @@ -90,3 +109,77 @@ def _process_param_change(self, params): if data.get('responsive'): props['sizing_mode'] = 'stretch_both' return props + + def _get_properties(self, document: Document): + props = super()._get_properties(document) + props['event_config'] = { + event: list(queries) for event, queries in self._py_callbacks.items() + } + return props + + def _get_model( + self, doc: Document, root: Optional[Model] = None, + parent: Optional[Model] = None, comm: Optional[Comm] = None + ) -> Model: + self._bokeh_model = lazy_load( + 'panel.models.echarts', 'ECharts', isinstance(comm, JupyterComm), root + ) + model = super()._get_model(doc, root, parent, comm) + self._register_events('echarts_event', model=model, doc=doc, comm=comm) + return model + + def on_event(self, event: str, callback: Callable, query: str | None = None): + """ + Register anevent handler which triggers when the specified event is triggered. + + Reference: https://apache.github.io/echarts-handbook/en/concepts/event/ + + Arguments + --------- + event: str + The name of the event to register a handler on, e.g. 'click'. + callback: str | CustomJS + The event handler to be executed when the event fires. + query: str | None + A query that determines when the event fires. + """ + self._py_callbacks[event][query].append(callback) + event_config = {event: list(queries) for event, queries in self._py_callbacks.items()} + for ref, (model, _) in self._models.items(): + self._apply_update({}, {'event_config': event_config}, model, ref) + + def js_on_event(self, event: str, callback: str | CustomJS, query: str | None = None, **args): + """ + Register a Javascript event handler which triggers when the + specified event is triggered. The callback can be a snippet + of Javascript code or a bokeh CustomJS object making it possible + to manipulate other models in response to an event. + + Reference: https://apache.github.io/echarts-handbook/en/concepts/event/ + + Arguments + --------- + event: str + The name of the event to register a handler on, e.g. 'click'. + code: str + The event handler to be executed when the event fires. + query: str | None + A query that determines when the event fires. + args: Viewable + A dictionary of Viewables to make available in the namespace + of the object. + """ + self._js_callbacks[event].append((query, callback, args)) + for ref, (model, _) in self._models.items(): + js_events = self._get_js_events(ref) + self._apply_update({}, {'js_events': js_events}, model, ref) + + +def setup_js_callbacks(root_view, root_model): + if 'panel.models.echarts' not in sys.modules: + return + ref = root_model.ref['id'] + for pane in root_view.select(ECharts): + pane._models[ref][0].js_events = pane._get_js_events(ref) + +Viewable._preprocessing_hooks.append(setup_js_callbacks) diff --git a/panel/tests/pane/test_echart.py b/panel/tests/pane/test_echart.py index 938605f0fb..d7f08047ec 100644 --- a/panel/tests/pane/test_echart.py +++ b/panel/tests/pane/test_echart.py @@ -1,65 +1,59 @@ -import panel as pn +from panel.layout import Row +from panel.pane import ECharts, Markdown ECHART = { - "xAxis": { - "type": 'category', - "data": ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] - }, - "yAxis": { - "type": 'value' - }, - "series": [{ - "data": [820, 932, 901, 934, 1290, 1330, 1320], - "type": 'line' - }] - } - -def test_echart(): - echart = ECHART - pane = pn.pane.ECharts(echart, width=500, height=500) - assert pane.object == echart - -def manualtest_echart(): - echart = ECHART - pane = pn.pane.ECharts(echart, width=500, height=500) - assert pane.object == echart - return pane - -def get_pyechart(): - from pyecharts import options as opts - from pyecharts.charts import Bar - - bar = ( - Bar() - .add_xaxis(["A", "B", "C", "D", "E", "F", "G"]) - .add_yaxis("Series1", [114, 55, 27, 101, 125, 27, 105]) - .add_yaxis("Series2", [57, 134, 137, 129, 145, 60, 49]) - .set_global_opts(title_opts=opts.TitleOpts(title="PyeCharts")) - ) - pane = pn.pane.ECharts(bar, width=500, height=500) - assert pane.object == bar - return pane - -def get_pyechart2(): - from pyecharts.charts import Bar - - import panel as pn - - bar1 = pn.widgets.IntSlider(start=1, end=100, value=50) - bar2 = pn.widgets.IntSlider(start=1, end=100, value=50) - - @pn.depends(bar1.param.value, bar2.param.value) - def plot(bar1, bar2): - my_plot= (Bar() - .add_xaxis(['Bar1', 'Bar2']) - .add_yaxis('Values', [bar1, bar2]) - ) - return pn.pane.ECharts(my_plot, width=500, height=250) - return pn.Row(pn.Column(bar1, bar2), plot) - -if pn.state.served: - # manualtest_echart().servable() - get_pyechart2().servable() -if __name__.startswith("__main__"): - manualtest_echart().show(port=5007) - get_pyechart().show(port=5007) + "xAxis": { + "type": 'category', + "data": ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] + }, + "yAxis": { + "type": 'value' + }, + "series": [{ + "data": [820, 932, 901, 934, 1290, 1330, 1320], + "type": 'line' + }] +} + +def test_echart(document, comm): + echart = ECharts(ECHART, width=500, height=500) + model = echart.get_root(document, comm) + assert model.data == ECHART + +def test_echart_event(document, comm): + echart = ECharts(ECHART, width=500, height=500) + echart.on_event('click', print) + model = echart.get_root(document, comm) + + assert model.data == ECHART + assert model.event_config == {'click': [None]} + +def test_echart_event_query(document, comm): + echart = ECharts(ECHART, width=500, height=500) + echart.on_event('click', print, 'series.line') + model = echart.get_root(document, comm) + assert model.data == ECHART + assert model.event_config == {'click': ['series.line']} + +def test_echart_js_event(document, comm): + echart = ECharts(ECHART, width=500, height=500) + echart.js_on_event('click', 'console.log(cb_data)') + model = echart.get_root(document, comm) + assert model.data == ECHART + assert 'click' in model.js_events + assert len(model.js_events['click']) == 1 + assert model.js_events['click'][0]['callback'].code == 'console.log(cb_data)' + +def test_echart_js_event_with_arg(document, comm): + echart = ECharts(ECHART, width=500, height=500) + md = Markdown() + echart.js_on_event('click', 'console.log(cb_data)', md=md) + root = Row(echart, md).get_root(document, comm) + ref = root.ref['id'] + model = echart._models[ref][0] + assert model.data == ECHART + assert 'click' in model.js_events + assert len(model.js_events['click']) == 1 + handler = model.js_events['click'][0] + assert handler['callback'].code == 'console.log(cb_data)' + assert handler['callback'].args == {'md': md._models[ref][0]}