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 range selection to RangeTool #13855

Merged
merged 6 commits into from
May 30, 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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
11 changes: 11 additions & 0 deletions bokehjs/src/lib/core/kinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,16 @@ export namespace Kinds {
}
}

export class NonEmptyList<ItemType> extends List<ItemType> {
override valid(value: unknown): value is ItemType[] {
return super.valid(value) && value.length != 0
}

override toString(): string {
return `NonEmptyList(${this.item_type.toString()})`
}
}

export class Null extends Primitive<null> {
valid(value: unknown): value is null {
return value === null
Expand Down Expand Up @@ -639,6 +649,7 @@ export const PartialStruct = <T extends {[key: string]: unknown}>(struct_type: K
export const Iterable = <ItemType>(item_type: Kind<ItemType>) => new Kinds.Iterable(item_type)
export const Arrayable = <ItemType>(item_type: Kind<ItemType>) => new Kinds.Arrayable(item_type)
export const List = <ItemType>(item_type: Kind<ItemType>) => new Kinds.List(item_type)
export const NonEmptyList = <ItemType>(item_type: Kind<ItemType>) => new Kinds.NonEmptyList(item_type)
export const Dict = <V>(item_type: Kind<V>) => new Kinds.Dict(item_type)
export const Mapping = <K, V>(key_type: Kind<K>, item_type: Kind<V>) => new Kinds.Mapping(key_type, item_type)
export const Set = <V>(item_type: Kind<V>) => new Kinds.Set(item_type)
Expand Down
8 changes: 6 additions & 2 deletions bokehjs/src/lib/models/tickers/composite_ticker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ export class CompositeTicker extends ContinuousTicker {
}

static {
this.define<CompositeTicker.Props>(({List, Ref}) => ({
tickers: [ List(Ref(ContinuousTicker)), [] ],
this.define<CompositeTicker.Props>(({NonEmptyList, Ref}) => ({
tickers: [ NonEmptyList(Ref(ContinuousTicker)) ],
}))
}

Expand All @@ -52,6 +52,10 @@ export class CompositeTicker extends ContinuousTicker {

get_best_ticker(data_low: number, data_high: number, desired_n_ticks: number): ContinuousTicker {
const data_range = data_high - data_low
if (data_range == 0) {
return this.tickers[0]
}

const ideal_interval = this.get_ideal_interval(data_low, data_high, desired_n_ticks)
const ticker_ndxs = [
sorted_index(this.min_intervals, ideal_interval) - 1,
Expand Down
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 StartGesture = Enum("pan", "tap", "none")
type StartGesture = typeof StartGesture["__type__"]

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

Expand Down Expand Up @@ -46,6 +54,158 @@ 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.start_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]
} else {
this._update_overlay(sx, sy)
this._base_point = null
}
}

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

override _pan_start(ev: PanEvent): void {
assert(this.model.start_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" && this._is_selecting) {
this._stop()
}
}
}

const DEFAULT_RANGE_OVERLAY = () => {
Expand Down Expand Up @@ -75,18 +235,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>
start_gesture: p.Property<StartGesture>
}
}

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 +259,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 ],
start_gesture: [ StartGesture, "none" ],
}))

this.override<RangeTool.Props>({
Expand Down Expand Up @@ -174,18 +336,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 +359,20 @@ export class RangeTool extends Tool {
override tool_name = "Range Tool"
override tool_icon = tool_icon_range

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

readonly default_order = 40

override supports_auto(): boolean {
return true
}

override tool_button(): OnOffButton {
return new OnOffButton({tool: this})
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Figure bbox=[0, 0, 400, 200]
Canvas bbox=[0, 0, 400, 200]
CartesianFrame bbox=[29, 5, 366, 173]
LinearAxis bbox=[29, 178, 366, 22]
LinearAxis bbox=[0, 5, 29, 173]
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Figure bbox=[0, 0, 400, 200]
Canvas bbox=[0, 0, 400, 200]
CartesianFrame bbox=[29, 5, 366, 173]
LinearAxis bbox=[29, 178, 366, 22]
LinearAxis bbox=[0, 5, 29, 173]
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Figure bbox=[0, 0, 400, 200]
Canvas bbox=[0, 0, 400, 200]
CartesianFrame bbox=[29, 5, 366, 173]
LinearAxis bbox=[29, 178, 366, 22]
LinearAxis bbox=[0, 5, 29, 173]
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.