Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix and improve Plotly event handling #6753

Merged
merged 3 commits into from
Apr 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions panel/models/plotly.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,22 @@
from bokeh.core.properties import (
Any, Dict, Either, Enum, Instance, Int, List, Null, Nullable, String,
)
from bokeh.events import ModelEvent
from bokeh.models import ColumnDataSource, LayoutDOM

from ..io.resources import JS_URLS, bundled_files
from ..util import classproperty


class PlotlyEvent(ModelEvent):

event_name = 'plotly_event'

def __init__(self, model, data=None):
self.data = data
super().__init__(model=model)


class PlotlyPlot(LayoutDOM):
"""
A bokeh model that wraps around a plotly plot and renders it inside
Expand Down Expand Up @@ -53,10 +63,6 @@ def __js_skip__(cls):
# Callback properties
relayout_data = Dict(String, Any)
restyle_data = List(Any)
click_data = Either(Dict(String, Any), Null)
hover_data = Either(Dict(String, Any), Null)
clickannotation_data = Either(Dict(String, Any), Null)
selected_data = Either(Dict(String, Any), Null)
viewport = Either(Dict(String, Any), Null)
viewport_update_policy = Enum( "mouseup", "continuous", "throttle")
viewport_update_throttle = Int()
Expand Down
77 changes: 50 additions & 27 deletions panel/models/plotly.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import {ModelEvent} from "@bokehjs/core/bokeh_events"
import type {StyleSheetLike} from "@bokehjs/core/dom"
import {div} from "@bokehjs/core/dom"
import type * as p from "@bokehjs/core/properties"
import {isPlainObject} from "@bokehjs/core/util/types"
import {isPlainObject, isArray} from "@bokehjs/core/util/types"
import {clone} from "@bokehjs/core/util/object"
import {is_equal} from "@bokehjs/core/util/eq"
import type {Attrs} from "@bokehjs/core/types"
import {ColumnDataSource} from "@bokehjs/models/sources/column_data_source"

import {debounce} from "debounce"
Expand All @@ -13,8 +15,23 @@ import {HTMLBox, HTMLBoxView, set_size} from "./layout"

import plotly_css from "styles/models/plotly.css"

export class PlotlyEvent extends ModelEvent {
constructor(readonly data: any) {
super()
}

protected override get event_values(): Attrs {
return {model: this.origin, data: this.data}
}

static {
this.prototype.event_name = "plotly_event"
}
}

interface PlotlyHTMLElement extends HTMLDivElement {
_fullLayout: any
_hoverdata: any
layout: any
on(event: "plotly_relayout", callback: (eventData: any) => void): void
on(event: "plotly_relayouting", callback: (eventData: any) => void): void
Expand All @@ -28,15 +45,19 @@ interface PlotlyHTMLElement extends HTMLDivElement {
}

function convertUndefined(obj: any): any {
Object
.entries(obj)
.forEach(([key, value]) => {
if (isPlainObject(value)) {
convertUndefined(value)
} else if (value === undefined) {
obj[key] = null
}
})
if (isArray(obj)) {
return obj.map(convertUndefined)
} else if (isPlainObject(obj)) {
Object
.entries(obj)
.forEach(([key, value]) => {
if (isPlainObject(value) || isArray(value)) {
convertUndefined(value)
} else if (value === undefined) {
obj[key] = null
}
})
}
return obj
}

Expand Down Expand Up @@ -130,6 +151,7 @@ export class PlotlyPlotView extends HTMLBoxView {
_rendered: boolean = false
_reacting: boolean = false
_relayouting: boolean = false
_hoverdata: any = null
container: PlotlyHTMLElement
_watched_sources: string[]
_end_relayouting = debounce(() => {
Expand Down Expand Up @@ -279,37 +301,46 @@ export class PlotlyPlotView extends HTMLBoxView {

// - plotly_click
this.container.on("plotly_click", (eventData: any) => {
this.model.click_data = filterEventData(
this.container, eventData, "click")
const data = filterEventData(this.container, eventData, "click")
this.model.trigger_event(new PlotlyEvent({type: "click", data}))
})

// - plotly_hover
this.container.on("plotly_hover", (eventData: any) => {
this.model.hover_data = filterEventData(
this.container, eventData, "hover")
const data = filterEventData(this.container, eventData, "hover")
this.model.trigger_event(new PlotlyEvent({type: "hover", data}))
// Override hoverdata to ensure click event has context
// see https://github.com/holoviz/panel/pull/6753
this._hoverdata = this.container._hoverdata = eventData.points
})

// - plotly_selected
this.container.on("plotly_selected", (eventData: any) => {
this.model.selected_data = filterEventData(
this.container, eventData, "selected")
const data = filterEventData(this.container, eventData, "selected")
this.model.trigger_event(new PlotlyEvent({type: "selected", data}))
})

// - plotly_clickannotation
this.container.on("plotly_clickannotation", (eventData: any) => {
delete eventData.event
delete eventData.fullAnnotation
this.model.clickannotation_data = eventData
this.model.trigger_event(new PlotlyEvent({type: "clickannotation", data: eventData}))
})

// - plotly_deselect
this.container.on("plotly_deselect", () => {
this.model.selected_data = null
this.model.trigger_event(new PlotlyEvent({type: "selected", data: null}))
})

// - plotly_unhover
this.container.on("plotly_unhover", () => {
this.model.hover_data = null
// Override hoverdata to ensure click event has context
this.container._hoverdata = this._hoverdata
this.model.trigger_event(new PlotlyEvent({type: "hover", data: null}))
setTimeout(() => {
// Remove hoverdata once events have been processed
delete this.container._hoverdata
}, 0)
})
}

Expand Down Expand Up @@ -441,10 +472,6 @@ export namespace PlotlyPlot {
restyle: p.Property<any>
relayout_data: p.Property<any>
restyle_data: p.Property<any>
click_data: p.Property<any>
hover_data: p.Property<any>
clickannotation_data: p.Property<any>
selected_data: p.Property<any>
viewport: p.Property<any>
viewport_update_policy: p.Property<string>
viewport_update_throttle: p.Property<number>
Expand Down Expand Up @@ -476,10 +503,6 @@ export class PlotlyPlot extends HTMLBox {
restyle: [ Nullable(Any), {} ],
relayout_data: [ Any, {} ],
restyle_data: [ List(Any), [] ],
click_data: [ Any, {} ],
hover_data: [ Any, {} ],
clickannotation_data: [ Any, {} ],
selected_data: [ Any, {} ],
viewport: [ Any, {} ],
viewport_update_policy: [ Str, "mouseup" ],
viewport_update_throttle: [ Float, 200 ],
Expand Down
15 changes: 13 additions & 2 deletions panel/pane/plotly.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ class Plotly(ModelPane):
_updates: ClassVar[bool] = True

_rename: ClassVar[Mapping[str, str | None]] = {
'link_figure': None, 'object': None
'link_figure': None, 'object': None, 'click_data': None, 'clickannotation_data': None,
'hover_data': None, 'selected_data': None
}

@classmethod
Expand Down Expand Up @@ -310,7 +311,17 @@ def _get_model(
self._bokeh_model = lazy_load(
'panel.models.plotly', 'PlotlyPlot', isinstance(comm, JupyterComm), root
)
return super()._get_model(doc, root, parent, comm)
model = super()._get_model(doc, root, parent, comm)
self._register_events('plotly_event', model=model, doc=doc, comm=comm)
return model

def _process_event(self, event):
etype = event.data['type']
pname = f'{etype}_data'
if getattr(self, pname) == event.data['data']:
self.param.trigger(pname)
else:
self.param.update(**{pname: event.data['data']})

def _update(self, ref: str, model: Model) -> None:
if self.object is None:
Expand Down
33 changes: 19 additions & 14 deletions panel/tests/ui/pane/test_plotly.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import time

import pytest

pytest.importorskip("playwright")
Expand All @@ -22,7 +24,6 @@ def plotly_2d_plot():
plot_2d = Plotly({'data': [trace], 'layout': {'width': 350}})
return plot_2d


@pytest.fixture
def plotly_3d_plot():
xx = np.linspace(-3.5, 3.5, 100)
Expand Down Expand Up @@ -157,19 +158,23 @@ def test_plotly_click_data(page, plotly_2d_plot):
plotly_plot = page.locator('.js-plotly-plot .plot-container.plotly')
expect(plotly_plot).to_have_count(1)

# Select and click on first point
point = page.locator('.js-plotly-plot .plot-container.plotly path.point').nth(0)
point.click(force=True)

wait_until(lambda: plotly_2d_plot.click_data == {
'points': [{
'curveNumber': 0,
'pointIndex': 0,
'pointNumber': 0,
'x': 0,
'y': 2
}]
}, page)
# Select and click on points
for i in range(2):
point = page.locator('.js-plotly-plot .plot-container.plotly path.point').nth(i)
point.click(force=True)

def check_click(i=i):
return plotly_2d_plot.click_data == {
'points': [{
'curveNumber': 0,
'pointIndex': i,
'pointNumber': i,
'x': 0+i,
'y': 2+i
}]
}
wait_until(check_click, page)
time.sleep(0.2)


def test_plotly_select_data(page, plotly_2d_plot):
Expand Down
Loading