Skip to content

Commit

Permalink
Add support for range selection to RangeTool
Browse files Browse the repository at this point in the history
  • Loading branch information
mattpap committed Apr 30, 2024
1 parent 74a7d42 commit 557b028
Show file tree
Hide file tree
Showing 3 changed files with 199 additions and 16 deletions.
206 changes: 191 additions & 15 deletions bokehjs/src/lib/models/tools/gestures/range_tool.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
import {Tool, ToolView} from "../tool"
import {GestureTool, GestureToolView} from "../gestures/gesture_tool"
import {OnOffButton} from "../on_off_button"
import type {PlotView} from "../../plots/plot"
import {BoxAnnotation} from "../../annotations/box_annotation"
import {Range} from "../../ranges/range"
import type {RangeState} from "../../plots/range_manager"
import type {PanEvent, TapEvent, MoveEvent, KeyEvent, EventType} from "core/ui_events"
import {logger} from "core/logging"
import type * as p from "core/properties"
import {assert} from "core/util/assert"
import {assert, unreachable} from "core/util/assert"
import {isNumber, non_null} from "core/util/types"
import {tool_icon_range} from "styles/icons.css"
import {Node} from "../../coordinates/node"
import type {CoordinateMapper, LRTB} from "core/util/bbox"
import type {CoordinateUnits} from "core/enums"
import type {Scale} from "../../scales/scale"
import {Enum} from "core/kinds"

export class RangeToolView extends ToolView {
const SelectGesture = Enum("pan", "tap", "none")
type SelectGesture = typeof SelectGesture["__type__"]

export class RangeToolView extends GestureToolView {
declare model: RangeTool
declare readonly parent: PlotView

Expand Down Expand Up @@ -46,6 +54,162 @@ export class RangeToolView extends ToolView {
this.model.update_constraints()
})
}

protected _mappers(): LRTB<CoordinateMapper> {
const mapper = (units: CoordinateUnits, scale: Scale,
view: CoordinateMapper, canvas: CoordinateMapper) => {
switch (units) {
case "canvas": return canvas
case "screen": return view
case "data": return scale
}
}

const {overlay} = this.model
const {frame, canvas} = this.plot_view
const {x_scale, y_scale} = frame
const {x_view, y_view} = frame.bbox
const {x_screen, y_screen} = canvas.bbox

return {
left: mapper(overlay.left_units, x_scale, x_view, x_screen),
right: mapper(overlay.right_units, x_scale, x_view, x_screen),
top: mapper(overlay.top_units, y_scale, y_view, y_screen),
bottom: mapper(overlay.bottom_units, y_scale, y_view, y_screen),
}
}

protected _invert_lrtb({left, right, top, bottom}: LRTB): LRTB<number | Node> {
const lrtb = this._mappers()

const {x_range, y_range} = this.model
const has_x = x_range != null
const has_y = y_range != null

return {
left: has_x ? lrtb.left.invert(left) : this.model.nodes.left,
right: has_x ? lrtb.right.invert(right) : this.model.nodes.right,
top: has_y ? lrtb.top.invert(top) : this.model.nodes.top,
bottom: has_y ? lrtb.bottom.invert(bottom) : this.model.nodes.bottom,
}
}

protected _compute_limits(curr_point: [number, number]): [[number, number], [number, number]] {
const dims = (() => {
const {x_range, y_range} = this.model
const has_x = x_range != null
const has_y = y_range != null

if (has_x && has_y) {
return "both"
} else if (has_x) {
return "width"
} else if (has_y) {
return "height"
} else {
unreachable()
}
})()

assert(this._base_point != null)
let base_point = this._base_point
if (this.model.overlay.symmetric) {
const [cx, cy] = base_point
const [dx, dy] = curr_point
base_point = [cx - (dx - cx), cy - (dy - cy)]
}

const {frame} = this.plot_view
return this.model._get_dim_limits(base_point, curr_point, frame, dims)
}

protected _base_point: [number, number] | null

override _tap(ev: TapEvent): void {
assert(this.model.select_gesture == "tap")

const {sx, sy} = ev
const {frame} = this.plot_view
if (!frame.bbox.contains(sx, sy)) {
return
}

if (this._base_point == null) {
this._base_point = [sx, sy]
//this._update_overlay(sx, sy) TODO protect against zero width interval
} else {
this._update_overlay(sx, sy)
this._base_point = null
}
}

override _move(ev: MoveEvent): void {
if (this._base_point != null && this.model.select_gesture == "tap") {
const {sx, sy} = ev
this._update_overlay(sx, sy)
}
}

override _pan_start(ev: PanEvent): void {
assert(this.model.select_gesture == "pan")
assert(this._base_point == null)

const {sx, sy} = ev
const {frame} = this.plot_view
if (!frame.bbox.contains(sx, sy)) {
return
}

this._base_point = [sx, sy]
}

protected _update_overlay(sx: number, sy: number): void {
const [sxlim, sylim] = this._compute_limits([sx, sy])
const [[left, right], [top, bottom]] = [sxlim, sylim]
this.model.overlay.update(this._invert_lrtb({left, right, top, bottom}))
this.model.update_ranges_from_overlay()
}

override _pan(ev: PanEvent): void {
if (this._base_point == null) {
return
}

const {sx, sy} = ev
this._update_overlay(sx, sy)
}

override _pan_end(ev: PanEvent): void {
if (this._base_point == null) {
return
}

const {sx, sy} = ev
this._update_overlay(sx, sy)

this._base_point = null
}

protected get _is_selecting(): boolean {
return this._base_point != null
}

protected _stop(): void {
this._base_point = null
}

override _keyup(ev: KeyEvent): void {
if (!this.model.active) {
return
}

if (ev.key == "Escape") {
if (this._is_selecting) {
this._stop()
return
}
}
}
}

const DEFAULT_RANGE_OVERLAY = () => {
Expand Down Expand Up @@ -75,18 +239,19 @@ const DEFAULT_RANGE_OVERLAY = () => {
export namespace RangeTool {
export type Attrs = p.AttrsOf<Props>

export type Props = Tool.Props & {
export type Props = GestureTool.Props & {
x_range: p.Property<Range | null>
y_range: p.Property<Range | null>
x_interaction: p.Property<boolean>
y_interaction: p.Property<boolean>
overlay: p.Property<BoxAnnotation>
select_gesture: p.Property<SelectGesture>
}
}

export interface RangeTool extends RangeTool.Attrs {}

export class RangeTool extends Tool {
export class RangeTool extends GestureTool {
declare properties: RangeTool.Props
declare __view_type__: RangeToolView

Expand All @@ -98,11 +263,12 @@ export class RangeTool extends Tool {
this.prototype.default_view = RangeToolView

this.define<RangeTool.Props>(({Bool, Ref, Nullable}) => ({
x_range: [ Nullable(Ref(Range)), null ],
y_range: [ Nullable(Ref(Range)), null ],
x_interaction: [ Bool, true ],
y_interaction: [ Bool, true ],
overlay: [ Ref(BoxAnnotation), DEFAULT_RANGE_OVERLAY ],
x_range: [ Nullable(Ref(Range)), null ],
y_range: [ Nullable(Ref(Range)), null ],
x_interaction: [ Bool, true ],
y_interaction: [ Bool, true ],
overlay: [ Ref(BoxAnnotation), DEFAULT_RANGE_OVERLAY ],
select_gesture: [ SelectGesture, "none" ],
}))

this.override<RangeTool.Props>({
Expand Down Expand Up @@ -174,18 +340,18 @@ export class RangeTool extends Tool {
}
}

private _nodes = Node.frame.freeze()
readonly nodes = Node.frame.freeze()

update_overlay_from_ranges(): void {
const {x_range, y_range} = this
const has_x = x_range != null
const has_y = y_range != null

this.overlay.update({
left: has_x ? x_range.start : this._nodes.left,
right: has_x ? x_range.end : this._nodes.right,
top: has_y ? y_range.end : this._nodes.top,
bottom: has_y ? y_range.start : this._nodes.bottom,
left: has_x ? x_range.start : this.nodes.left,
right: has_x ? x_range.end : this.nodes.right,
top: has_y ? y_range.end : this.nodes.top,
bottom: has_y ? y_range.start : this.nodes.bottom,
})

if (!has_x && !has_y) {
Expand All @@ -197,6 +363,16 @@ export class RangeTool extends Tool {
override tool_name = "Range Tool"
override tool_icon = tool_icon_range

get event_type(): EventType | EventType[] {
switch (this.select_gesture) {
case "pan": return "pan" as "pan"
case "tap": return ["tap" as "tap", "move" as "move"]
case "none": return []
}
}

readonly default_order = 40

override tool_button(): OnOffButton {
return new OnOffButton({tool: this})
}
Expand Down
2 changes: 1 addition & 1 deletion examples/interaction/tools/range_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
x_axis_type="datetime", y_axis_type=None,
tools="", toolbar_location=None, background_fill_color="#efefef")

range_tool = RangeTool(x_range=p.x_range)
range_tool = RangeTool(x_range=p.x_range, select_gesture="pan")
range_tool.overlay.fill_color = "navy"
range_tool.overlay.fill_alpha = 0.2

Expand Down
7 changes: 7 additions & 0 deletions src/bokeh/models/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,13 @@ def __init__(self, *args, **kwargs) -> None:
A shaded annotation drawn to indicate the configured ranges.
""")

select_gesture = Enum("pan", "tap", "none", default="none", help="""
What kind of gesture is used to make range selection, if any.
Configuring this property allows to make this tool simultaneously co-exist
with another tool that would otherwise share a gesture.
""")

@error(NO_RANGE_TOOL_RANGES)
def _check_no_range_tool_ranges(self):
if self.x_range is None and self.y_range is None:
Expand Down

0 comments on commit 557b028

Please sign in to comment.