Skip to content

Commit

Permalink
Adding Echarts events (#2174)
Browse files Browse the repository at this point in the history
  • Loading branch information
JackDapid authored Mar 14, 2023
1 parent 9f25c1a commit 04aac83
Show file tree
Hide file tree
Showing 6 changed files with 411 additions and 87 deletions.
141 changes: 137 additions & 4 deletions examples/reference/panes/ECharts.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"metadata": {},
"outputs": [],
"source": [
"echart = {\n",
"echart_bar = {\n",
" 'title': {\n",
" 'text': 'ECharts entry example'\n",
" },\n",
Expand All @@ -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"
]
},
Expand All @@ -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')"
]
},
Expand All @@ -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)"
]
Expand Down Expand Up @@ -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": {},
Expand Down
22 changes: 19 additions & 3 deletions panel/models/echarts.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
"""
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
from ..io.resources import bundled_files
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
Expand Down Expand Up @@ -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")

Expand Down
98 changes: 91 additions & 7 deletions panel/models/echarts.ts
Original file line number Diff line number Diff line change
@@ -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<any>[] = []

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 {
Expand All @@ -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 {
Expand All @@ -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<Props>
export type Props = HTMLBox.Props & {
data: p.Property<any>
event_config: p.Property<any>
js_events: p.Property<any>
renderer: p.Property<string>
theme: p.Property<string>
}
Expand All @@ -76,10 +158,12 @@ export class ECharts extends HTMLBox {
static {
this.prototype.default_view = EChartsView

this.define<ECharts.Props>(({Any, String}) => ({
data: [ Any, {} ],
theme: [ String, "default" ],
renderer: [ String, "canvas" ]
this.define<ECharts.Props>(({ Any, String }) => ({
data: [ Any, {} ],
event_config: [ Any, {} ],
js_events: [ Any, {} ],
theme: [ String, "default"],
renderer: [ String, "canvas"]
}))
}
}
6 changes: 5 additions & 1 deletion panel/models/event-to-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading

0 comments on commit 04aac83

Please sign in to comment.