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

Add support for wheel zoom renderers under the cursor #13826

Open
wants to merge 8 commits into
base: branch-3.5
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions bokehjs/src/lib/models/renderers/contour_renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import type * as p from "core/properties"
import type {IterViews} from "core/build_views"
import {build_view} from "core/build_views"
import type {SelectionManager} from "core/selection_manager"
import type {Geometry} from "core/geometry"
import type {HitTestResult} from "core/hittest"

export class ContourRendererView extends DataRendererView {
declare model: ContourRenderer
Expand Down Expand Up @@ -45,6 +47,10 @@ export class ContourRendererView extends DataRendererView {
this.fill_view.paint()
this.line_view.paint()
}

hit_test(geometry: Geometry): HitTestResult {
return this.fill_view.hit_test(geometry)
}
}

export namespace ContourRenderer {
Expand Down
4 changes: 4 additions & 0 deletions bokehjs/src/lib/models/renderers/data_renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type {Scale} from "../scales/scale"
import type {AutoRanged} from "../ranges/data_range1d"
import {auto_ranged} from "../ranges/data_range1d"
import type {SelectionManager} from "core/selection_manager"
import type {Geometry} from "core/geometry"
import type {HitTestResult} from "core/hittest"
import type * as p from "core/properties"
import type {Rect} from "core/types"

Expand All @@ -30,6 +32,8 @@ export abstract class DataRendererView extends RendererView implements AutoRange
log_bounds(): Rect {
return this.glyph_view.log_bounds()
}

abstract hit_test(geometry: Geometry): HitTestResult
}

export namespace DataRenderer {
Expand Down
6 changes: 6 additions & 0 deletions bokehjs/src/lib/models/renderers/graph_renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type * as p from "core/properties"
import type {IterViews} from "core/build_views"
import {build_view} from "core/build_views"
import {logger} from "core/logging"
import type {Geometry} from "core/geometry"
import type {HitTestResult} from "core/hittest"
import type {SelectionManager} from "core/selection_manager"
import {XYGlyph} from "../glyphs/xy_glyph"
import {MultiLine} from "../glyphs/multi_line"
Expand Down Expand Up @@ -125,6 +127,10 @@ export class GraphRendererView extends DataRendererView {
override get has_webgl(): boolean {
return this.edge_view.has_webgl || this.node_view.has_webgl
}

hit_test(geometry: Geometry): HitTestResult {
return this.model.inspection_policy.hit_test(geometry, this)
}
}

export namespace GraphRenderer {
Expand Down
57 changes: 56 additions & 1 deletion bokehjs/src/lib/models/tools/gestures/wheel_zoom_tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type * as p from "core/properties"
import type {PinchEvent, ScrollEvent} from "core/ui_events"
import {Dimensions} from "core/enums"
import {logger} from "core/logging"
import type {Geometry} from "core/geometry"
import {remove_by} from "core/util/array"
import {tool_icon_wheel_zoom} from "styles/icons.css"
import {Enum, List, Ref, Or, Auto} from "core/kinds"

Expand Down Expand Up @@ -88,7 +90,54 @@ export class WheelZoomToolView extends GestureToolView {
const y_renderer_scales = new Set<Scale>()

const {renderers} = this.model
const data_renderers = renderers != "auto" ? renderers : this.plot_view.model.data_renderers
const data_renderers = [...renderers != "auto" ? renderers : this.plot_view.model.data_renderers]

if (this.model.hit_test) {
const hit = new Set()
const not_hit = new Set()

for (const renderer of data_renderers) {
if (renderer.coordinates == null) {
continue
}

const rv = this.plot_view.views.get_one(renderer)

const geometry: Geometry = (() => {
switch (this.model.hit_test_mode) {
case "point": return {type: "point", sx, sy}
case "hline": return {type: "span", sx, sy, direction: "v"}
case "vline": return {type: "span", sx, sy, direction: "h"}
}
})()

const did_hit = rv.hit_test(geometry)
if (did_hit != null && !did_hit.is_empty()) {
hit.add(rv.model)
} else {
not_hit.add(rv.model)
}
}

function remove_not_hit() {
remove_by(data_renderers, (dr) => not_hit.has(dr))
}

if (hit.size == 0) {
remove_not_hit()
} else {
switch (this.model.hit_test_behavior) {
case "hit": {
remove_not_hit()
break
}
case "all": {
// keep all hit and not hit renderers
break
}
}
}
}

for (const renderer of data_renderers) {
if (renderer.coordinates == null) {
Expand Down Expand Up @@ -194,6 +243,9 @@ export namespace WheelZoomTool {
dimensions: p.Property<Dimensions>
renderers: p.Property<Renderers>
level: p.Property<number>
hit_test: p.Property<boolean>
hit_test_mode: p.Property<"point" | "hline" | "vline">
hit_test_behavior: p.Property<"hit" | "all">
maintain_focus: p.Property<boolean>
zoom_on_axis: p.Property<boolean>
zoom_together: p.Property<ZoomTogether>
Expand All @@ -219,6 +271,9 @@ export class WheelZoomTool extends GestureTool {
dimensions: [ Dimensions, "both" ],
renderers: [ Renderers, "auto" ],
level: [ NonNegative(Int), 0 ],
hit_test: [ Bool, false ],
hit_test_mode: [ Enum("point", "hline", "vline"), "point" ],
hit_test_behavior: [ Enum("hit", "all"), "hit" ],
maintain_focus: [ Bool, true ],
zoom_on_axis: [ Bool, true ],
zoom_together: [ ZoomTogether, "all" ],
Expand Down
2 changes: 2 additions & 0 deletions bokehjs/src/lib/models/tools/inspectors/crosshair_tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ export class CrosshairTool extends InspectTool {
}))

this.register_alias("crosshair", () => new CrosshairTool())
this.register_alias("xcrosshair", () => new CrosshairTool({dimensions: "width"}))
this.register_alias("ycrosshair", () => new CrosshairTool({dimensions: "height"}))
}

override tool_name = "Crosshair"
Expand Down
2 changes: 2 additions & 0 deletions bokehjs/src/lib/models/tools/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export type ToolAliases = {
click: TapTool
tap: TapTool
crosshair: CrosshairTool
xcrosshair: CrosshairTool
ycrosshair: CrosshairTool
box_select: BoxSelectTool
xbox_select: BoxSelectTool
ybox_select: BoxSelectTool
Expand Down
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 @@ -11,3 +11,4 @@ Bokeh version ``3.5.0`` (May 2024) is a minor milestone of Bokeh project.
* Added support for CSS variable based styling to plot renderers (:bokeh-pull:`13828`)
* 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 wheel zoom of renderers under the cursor when using sub-coordinates (:bokeh-pull:`13826`)
31 changes: 22 additions & 9 deletions examples/interaction/tools/subcoordinates_zoom.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
x_range = Range1d(start=time.min(), end=time.max())
y_range = FactorRange(factors=channels)

p = figure(x_range=x_range, y_range=y_range, lod_threshold=None, tools="pan,reset")
p = figure(x_range=x_range, y_range=y_range, lod_threshold=None, tools="pan,reset,xcrosshair")

source = ColumnDataSource(data=dict(time=time))
renderers = []
Expand All @@ -43,16 +43,18 @@
renderers.append(line)

level = 1
hit_test = False

ywheel_zoom = WheelZoomTool(renderers=renderers, level=level, dimensions="height")
xwheel_zoom = WheelZoomTool(renderers=renderers, level=level, dimensions="width")
ywheel_zoom = WheelZoomTool(renderers=renderers, level=level, hit_test=hit_test, hit_test_mode="hline", hit_test_behavior="hit", dimensions="height")
xwheel_zoom = WheelZoomTool(renderers=renderers, level=level, hit_test=hit_test, hit_test_mode="hline", hit_test_behavior="hit", dimensions="width")
zoom_in = ZoomInTool(renderers=renderers, level=level, dimensions="height")
zoom_out = ZoomOutTool(renderers=renderers, level=level, dimensions="height")

p.add_tools(ywheel_zoom, xwheel_zoom, zoom_in, zoom_out, hover)
p.toolbar.active_scroll = ywheel_zoom

on_change = CustomJS(
level_switch = Switch(active=level == 1)
level_switch.js_on_change("active", CustomJS(
args=dict(tools=[ywheel_zoom, zoom_in, zoom_out]),
code="""
export default ({tools}, obj) => {
Expand All @@ -61,10 +63,21 @@
tool.level = level
}
}
""")
"""))

label = Div(text="Zoom sub-coordinates:")
widget = Switch(active=level == 1)
widget.js_on_change("active", on_change)
hit_test_switch = Switch(active=hit_test)
hit_test_switch.js_on_change("active", CustomJS(
args=dict(tool=ywheel_zoom),
code="""
export default ({tool}, obj) => {
tool.hit_test = obj.active
}
"""))

layout = column(
row(Div(text="Zoom sub-coordinates:"), level_switch),
row(Div(text="Zoom hit-tested:"), hit_test_switch),
p,
)

show(column(row(label, widget), p))
show(layout)
2 changes: 1 addition & 1 deletion src/bokeh/core/property/descriptors.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ def _warn(self) -> None:

def __get__(self, obj: HasProps | None, owner: type[HasProps] | None) -> T:
if obj is not None:
# Warn only when accesing descriptor's value, otherwise there would
# Warn only when accessing descriptor's value, otherwise there would
# be a lot of spurious warnings from parameter resolution, etc.
self._warn()
return super().__get__(obj, owner)
Expand Down
36 changes: 36 additions & 0 deletions src/bokeh/models/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,40 @@ def __init__(self, *args, **kwargs) -> None:
""")
# }

hit_test = Bool(default=False, help="""
Whether to zoom only those renderer that are being pointed at.

This setting only applies when zooming renderers that were configured with
sub-coordinates, otherwise it has no effect.

If ``True``, then ``hit_test_mode`` property defines how hit testing
is performed and ``hit_test_behavior`` allows to configure other aspects
of this setup. See respective properties for details.

.. note::
This property is experimental and may change at any point
""")

hit_test_mode = Enum("point", "hline", "vline", default="point", help="""
Allows to configure what geometry to use when ``hit_test`` is enabled.

Supported modes are ``"point"`` for single point hit testing, and ``hline``
and ``vline`` for either horizontal or vertical span hit testing.

.. note::
This property is experimental and may change at any point
""")

hit_test_behavior = Enum("hit", "all", default="hit", help="""
Allows to configure which renderers will be zoomed when ``hit_test`` is enabled.

By default (``hit``) only actually hit renderers will be zoomed. If set
to ``all``, then all renderers will be zoomed.

.. note::
This property is experimental and may change at any point
""")

maintain_focus = Bool(default=True, help="""
If True, then hitting a range bound in any one dimension will prevent all
further zooming all dimensions. If False, zooming can continue
Expand Down Expand Up @@ -1994,6 +2028,8 @@ def __init__(self, *args, **kwargs) -> None:
Tool.register_alias("tap", lambda: TapTool())
Tool.register_alias("doubletap", lambda: TapTool(gesture="doubletap"))
Tool.register_alias("crosshair", lambda: CrosshairTool())
Tool.register_alias("xcrosshair", lambda: CrosshairTool(dimensions="width"))
Tool.register_alias("ycrosshair", lambda: CrosshairTool(dimensions="height"))
Tool.register_alias("box_select", lambda: BoxSelectTool())
Tool.register_alias("xbox_select", lambda: BoxSelectTool(dimensions="width"))
Tool.register_alias("ybox_select", lambda: BoxSelectTool(dimensions="height"))
Expand Down
3 changes: 3 additions & 0 deletions tests/baselines/defaults.json5
Original file line number Diff line number Diff line change
Expand Up @@ -6391,6 +6391,9 @@
dimensions: "both",
renderers: "auto",
level: 0,
hit_test: false,
hit_test_mode: "point",
hit_test_behavior: "hit",
maintain_focus: true,
zoom_on_axis: true,
zoom_together: "all",
Expand Down
2 changes: 2 additions & 0 deletions tests/unit/bokeh/models/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ def test_Tool_from_string() -> None:
assert isinstance(Tool.from_string("click"), t.TapTool)
assert isinstance(Tool.from_string("tap"), t.TapTool)
assert isinstance(Tool.from_string("crosshair"), t.CrosshairTool)
assert isinstance(Tool.from_string("xcrosshair"), t.CrosshairTool)
assert isinstance(Tool.from_string("ycrosshair"), t.CrosshairTool)
assert isinstance(Tool.from_string("box_select"), t.BoxSelectTool)
assert isinstance(Tool.from_string("xbox_select"), t.BoxSelectTool)
assert isinstance(Tool.from_string("ybox_select"), t.BoxSelectTool)
Expand Down