Skip to content
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
17 changes: 17 additions & 0 deletions bokehjs/src/lib/core/bokeh_events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {serialize} from "./serialization"
import {Deserializer} from "./serialization/deserializer"
import type {Equatable, Comparator} from "./util/eq"
import {equals} from "./util/eq"
import type {Legend} from "../models/annotations/legend"
import type {LegendItem} from "../models/annotations/legend_item"
import type {InputWidget} from "../models/widgets/input_widget"

Deserializer.register("event", (rep: BokehEventRep, deserializer: Deserializer): BokehEvent => {
Expand Down Expand Up @@ -35,6 +37,7 @@ export type ConnectionEventType =

export type ModelEventType =
"button_click" |
"legend_item_click" |
"menu_item_click" |
"value_submit" |
UIEventType
Expand Down Expand Up @@ -71,6 +74,7 @@ export type BokehEventMap = {
clear_input: ClearInput
connection_lost: ConnectionLost
button_click: ButtonClick
legend_item_click: LegendItemClick
menu_item_click: MenuItemClick
value_submit: ValueSubmit
lodstart: LODStart
Expand Down Expand Up @@ -166,6 +170,19 @@ export class ConnectionLost extends ConnectionEvent {
@event("button_click")
export class ButtonClick extends ModelEvent {}

@event("legend_item_click")
export class LegendItemClick extends ModelEvent {

constructor(readonly model: Legend, readonly item: LegendItem) {
super()
}

protected override get event_values(): Attrs {
const {item} = this
return {...super.event_values, item}
}
}

@event("menu_item_click")
export class MenuItemClick extends ModelEvent {

Expand Down
9 changes: 6 additions & 3 deletions bokehjs/src/lib/models/annotations/legend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import type {Size} from "core/layout"
import {SideLayout, SidePanel} from "core/layout/side_panel"
import {BBox} from "core/util/bbox"
import {every, some} from "core/util/array"
import {dict} from "core/util/object"
import {enumerate} from "core/util/iterator"
import {isString} from "core/util/types"
import type {Context2d} from "core/util/canvas"
import {TextBox} from "core/graphics"
import {Column, Row, Grid, ContentLayoutable, Sizeable, TextLayout} from "core/layout"
import {LegendItemClick} from "core/bokeh_events"

const {max, ceil} = Math

Expand Down Expand Up @@ -294,7 +296,7 @@ export class LegendView extends AnnotationView {
}

override cursor(sx: number, sy: number): string | null {
if (this.model.click_policy == "none") {
if (this.model.click_policy == "none" && !dict(this.model.js_event_callbacks).has("legend_item_click")) { // this doesn't cover server callbacks
return null
}
if (this._hit_test(sx, sy) != null) {
Expand All @@ -314,8 +316,9 @@ export class LegendView extends AnnotationView {

const target = this._hit_test(sx, sy)
if (target != null) {
const {renderers} = target.entry.item
for (const renderer of renderers) {
const {item} = target.entry
this.model.trigger_event(new LegendItemClick(this.model, item))
for (const renderer of item.renderers) {
fn(renderer)
}
return true
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Figure bbox=[0, 0, 200, 200]
Canvas bbox=[0, 0, 200, 200]
CartesianFrame bbox=[0, 0, 180, 178]
GlyphRenderer bbox=[8, 8, 164, 81]
Scatter bbox=[8, 8, 164, 81]
GlyphRenderer bbox=[8, 49, 164, 80]
Scatter bbox=[8, 49, 164, 80]
GlyphRenderer bbox=[8, 89, 164, 81]
Scatter bbox=[8, 89, 164, 81]
LinearAxis bbox=[0, 178, 180, 22]
LinearAxis bbox=[180, 0, 20, 178]
Legend bbox=[0, 0, 90, 86]
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
39 changes: 39 additions & 0 deletions bokehjs/test/integration/annotations/legend.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {display, fig} from "../_util"
import {PlotActions, xy} from "../../interactive"
import {expect} from "../../unit/assertions"

import {Legend, LegendItem, LinearAxis} from "@bokehjs/models"
import {Random} from "@bokehjs/core/util/random"
Expand All @@ -7,6 +9,8 @@ import type {CircleArgs, LineArgs} from "@bokehjs/api/glyph_api"
import type {Orientation} from "@bokehjs/core/enums"
import {Location} from "@bokehjs/core/enums"
import {linspace} from "@bokehjs/core/util/array"
import {LegendItemClick} from "@bokehjs/core/bokeh_events"
import type {Scatter} from "@bokehjs/models/glyphs"

describe("Legend annotation", () => {
it("should support various combinations of locations and orientations", async () => {
Expand Down Expand Up @@ -351,4 +355,39 @@ describe("Legend annotation", () => {
test(plot({orientation: "vertical"}), "vertical")
test_grid("vertical")
})

it("should support LegendItemClick events", async () => {
const p = fig([200, 200], {y_axis_location: "right", min_border: 0})

const r0 = p.scatter({x: [1, 2, 3], y: [3, 4, 5], size: 10, marker: "circle", color: "red"})
const r1 = p.scatter({x: [1, 2, 3], y: [2, 3, 4], size: 15, marker: "circle", color: "blue"})
const r2 = p.scatter({x: [1, 2, 3], y: [1, 2, 3], size: 20, marker: "circle", color: "green"})

const items = [
new LegendItem({label: "Item #0", renderers: [r0]}),
new LegendItem({label: "Item #1", renderers: [r1]}),
new LegendItem({label: "Item #2", renderers: [r2]}),
]

const legend = new Legend({items, location: "top_left", margin: 0})
p.add_layout(legend)

const clicked: LegendItem[] = []
legend.on_event(LegendItemClick, ({item}) => {
clicked.push(item)
item.renderers.forEach((r) => (r.glyph as Scatter).marker = {value: "triangle"})
})

const {view: pv} = await display(p)

const actions = new PlotActions(pv, {units: "screen"})
await actions.tap(xy(50, 20))
await pv.ready
await actions.tap(xy(50, 40))
await pv.ready
await actions.tap(xy(50, 60))
await pv.ready

expect(clicked).to.be.equal(items)
})
})
1 change: 1 addition & 0 deletions docs/bokeh/source/docs/releases/3.5.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ Bokeh version ``3.5.0`` (May 2024) is a minor milestone of Bokeh project.
* Added support for outline shapes to text-like glyphs (``Text``, ``TeX`` and ``MathML``) (:bokeh-pull:`13620`)
* Added support for range setting gesture to ``RangeTool`` and allowed a choice of gesture (pan, tap or none) (:bokeh-pull:`13855`)
* Added support for server-sent events, in particular for ``ClearInput`` event on input widgets (:bokeh-pull:`13890`)
* Added support for ``Legend`` item click events and ``Legend.{on_click,js_on_click}()`` APIs (:bokeh-pull:`13922`)
27 changes: 27 additions & 0 deletions examples/interaction/legends/legend_click.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from bokeh.core.enums import MarkerType
from bokeh.models import CustomJS
from bokeh.plotting import figure, show

p = figure(width=300, height=300, title="Click on legend entries to change\nmarkers of the corresponding glyphs")

p.scatter(x=[0, 1, 2], y=[1, 2, 3], size=[10, 20, 30], marker="circle", color="red", fill_alpha=0.5, legend_label="Red item")
p.scatter(x=[0, 1, 2], y=[2, 3, 4], size=[10, 20, 30], marker="circle", color="green", fill_alpha=0.5, legend_label="Green item")
p.scatter(x=[0, 1, 2], y=[3, 4, 5], size=[10, 20, 30], marker="circle", color="blue", fill_alpha=0.5, legend_label="Blue item")

callback = CustomJS(
args=dict(markers=list(MarkerType)),
code="""
export default ({markers}, {item}) => {
for (const renderer of item.renderers) {
const {value: marker} = renderer.glyph.marker
const i = markers.indexOf(marker)
const j = (i + 1) % markers.length
renderer.glyph.marker = {value: markers[j]}
}
}
""",
)
p.legend.js_on_click(callback)
p.legend.location = "top_left"

show(p)
12 changes: 12 additions & 0 deletions src/bokeh/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ def callback(event):
if TYPE_CHECKING:
from .core.types import GeometryData
from .model import Model
from .models.annotations import Legend, LegendItem
from .models.plots import Plot
from .models.widgets.buttons import AbstractButton
from .models.widgets.inputs import InputWidget, TextInput
Expand All @@ -99,6 +100,7 @@ def callback(event):
'Event',
'LODEnd',
'LODStart',
'LegendItemClick',
'MenuItemClick',
'ModelEvent',
'MouseEnter',
Expand Down Expand Up @@ -287,6 +289,16 @@ def __init__(self, model: AbstractButton | None) -> None:
raise ValueError(f"{clsname} event only applies to button and button group models")
super().__init__(model=model)

class LegendItemClick(ModelEvent):
''' Announce a click event on a Bokeh legend item.

'''
event_name = 'legend_item_click'

def __init__(self, model: Legend, item: LegendItem) -> None:
self.item = item
super().__init__(model=model)

class MenuItemClick(ModelEvent):
''' Announce a button click event on a Bokeh menu item.

Expand Down
15 changes: 14 additions & 1 deletion src/bokeh/models/annotations/legends.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
#-----------------------------------------------------------------------------

# Standard library imports
from typing import Any
from typing import TYPE_CHECKING, Any

# Bokeh imports
from ...core.enums import (
Expand Down Expand Up @@ -70,6 +70,7 @@
NON_MATCHING_DATA_SOURCES_ON_LEGEND_ITEM_RENDERERS,
NON_MATCHING_SCALE_BAR_UNIT,
)
from ...events import LegendItemClick
from ...model import Model
from ..formatters import TickFormatter
from ..labeling import LabelingPolicy, NoOverlap
Expand All @@ -80,6 +81,10 @@
from .annotation import Annotation
from .dimensional import Dimensional, MetricLength

if TYPE_CHECKING:
from ...util.callback_manager import EventCallback as PyEventCallback
from ..callbacks import Callback as JsEventCallback

#-----------------------------------------------------------------------------
# Globals and constants
#-----------------------------------------------------------------------------
Expand Down Expand Up @@ -518,6 +523,14 @@ def __init__(self, *args, **kwargs) -> None:
""").accepts(List(Tuple(String, List(Instance(GlyphRenderer)))),
lambda items: [LegendItem(label=item[0], renderers=item[1]) for item in items])

def on_click(self, handler: PyEventCallback) -> None:
""" Set up a handler for legend item clicks. """
self.on_event(LegendItemClick, handler)

def js_on_click(self, handler: JsEventCallback) -> None:
""" Set up a JavaScript handler for legend item clicks. """
self.js_on_event(LegendItemClick, handler)

class ScaleBar(Annotation):
""" Represents a scale bar annotation.
"""
Expand Down
12 changes: 10 additions & 2 deletions tests/unit/bokeh/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
Button,
Div,
FileInput,
Legend,
LegendItem,
Plot,
TextInput,
)
Expand Down Expand Up @@ -61,8 +63,12 @@ def test_common_decode_json() -> None:
if event_name is None:
continue # Skip abstract base class

legend_item = LegendItem()

if issubclass(event_cls, events.ButtonClick):
model = Button()
elif issubclass(event_cls, events.LegendItemClick):
model = Legend(items=[legend_item])
elif issubclass(event_cls, events.ValueSubmit):
model = TextInput()
elif issubclass(event_cls, events.ClearInput):
Expand All @@ -72,11 +78,13 @@ def test_common_decode_json() -> None:

entries = []
if issubclass(event_cls, events.ModelEvent):
entries.append(["model", dict(id=model.id)])
entries.append(["model", model.ref])
if issubclass(event_cls, events.LegendItemClick):
entries.append(["item", legend_item.ref])
if issubclass(event_cls, events.ValueSubmit):
entries.append(["value", ""])

decoder = Deserializer(references=[model])
decoder = Deserializer(references=[model, legend_item])
event = decoder.decode(dict(
type="event",
name=event_cls.event_name,
Expand Down