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

Redesign pinch zoom and improve its behavior #13595

Open
wants to merge 1 commit 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
4 changes: 4 additions & 0 deletions bokehjs/src/lib/core/util/cloneable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,7 @@ export class Cloner {
throw new CloningError(`${Object.prototype.toString.call(obj)} is not cloneable`)
}
}

export function clone_<T extends CloneableType>(obj: T): T {
return new Cloner().clone(obj)
}
22 changes: 5 additions & 17 deletions bokehjs/src/lib/core/util/zoom.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {Interval} from "../types"
import type {Range} from "models/ranges/range"
import type {Scale} from "models/scales/scale"
import type {RangeInfo, RangeState} from "models/plots/range_manager"
import {minmax} from "core/util/math"
Expand All @@ -17,39 +18,26 @@ export function scale_interval(range: Interval, factor: number, center?: number
return [x0, x1]
}

export function get_info(scales: Iterable<Scale>, [sxy0, sxy1]: Bounds): RangeState {
const info: RangeState = new Map()
for (const scale of scales) {
const [start, end] = scale.r_invert(sxy0, sxy1)
info.set(scale.source_range, {start, end})
}
return info
}

export function rescale(scales: Iterable<Scale>, factor: number, center?: number | null): RangeState {
export function rescale(scales: Map<Range, Scale>, factor: number, center?: number | null): RangeState {
const output: RangeState = new Map()
for (const scale of scales) {
for (const [source_range, scale] of scales) {
const [v0, v1] = scale_interval(scale.target_range, factor, center)
const [start, end] = scale.r_invert(v0, v1)
output.set(scale.source_range, {start, end})
output.set(source_range, {start, end})
}
return output
}

export function scale_range(x_scales: Iterable<Scale>, y_scales: Iterable<Scale>, _x_target: Interval, _y_range: Interval, factor: number,
export function scale_range(x_scales: Map<Range, Scale>, y_scales: Map<Range, Scale>, _x_target: Interval, _y_range: Interval, factor: number,
x_axis: boolean = true, y_axis: boolean = true, center?: {x?: number | null, y?: number | null} | null): ScaleRanges {
/*
* Utility function for zoom tools to calculate/create the zoom_info object
* of the form required by `PlotView.update_range`.
*/
const x_factor = x_axis ? factor : 0
//const [sx0, sx1] = scale_interval(x_target, x_factor, center?.x)
//const xrs = get_info(x_scales, [sx0, sx1])
const xrs = rescale(x_scales, x_factor, center?.x)

const y_factor = y_axis ? factor : 0
//const [sy0, sy1] = scale_interval(y_range, y_factor, center?.y)
//const yrs = get_info(y_scales, [sy0, sy1])
const yrs = rescale(y_scales, y_factor, center?.y)

// OK this sucks we can't set factor independently in each direction. It is used
Expand Down
141 changes: 101 additions & 40 deletions bokehjs/src/lib/models/tools/gestures/wheel_zoom_tool.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {GestureTool, GestureToolView} from "./gesture_tool"
import type {AxisView} from "../../axes/axis"
import {DataRenderer} from "../../renderers/data_renderer"
import type {Range} from "../../ranges/range"
import type {Scale} from "../../scales/scale"
import {CompositeScale} from "../../scales/composite_scale"
import {scale_range} from "core/util/zoom"
Expand All @@ -8,6 +10,8 @@ import type {PinchEvent, ScrollEvent} from "core/ui_events"
import {Dimensions} from "core/enums"
import {logger} from "core/logging"
import {assert} from "core/util/assert"
import {clone_ as clone} from "core/util/cloneable"
import type {XY, SXY} from "core/util/bbox"
import {tool_icon_wheel_zoom} from "styles/icons.css"
import {Enum, Array, Ref, Or, Auto} from "../../../core/kinds"

Expand All @@ -17,53 +21,120 @@ type ZoomTogether = typeof ZoomTogether["__type__"]
const Renderers = Or(Array(Ref(DataRenderer)), Auto)
type Renderers = typeof Renderers["__type__"]

type ZoomState = {
x_scales: Map<Range, Scale>
y_scales: Map<Range, Scale>
center: XY<number | null>
}

export class WheelZoomToolView extends GestureToolView {
declare model: WheelZoomTool

override _scroll(ev: ScrollEvent): void {
const {sx, sy, delta} = ev
this.zoom(sx, sy, delta)
const {sx, sy} = ev
const at = {sx, sy}

const axis = this._get_zoom_axis(at)
if (!this._can_zoom(at, axis)) {
return
}

const state = this._get_zoom_state(axis, at)

const factor = this.model.speed*ev.delta
this.zoom(state, factor)
}

protected _pinch_state: ZoomState | null = null
override _pinch_start(ev: PinchEvent): void {
assert(this._pinch_state == null)
const {sx, sy} = ev
const at = {sx, sy}

const axis = this._get_zoom_axis(at)
if (!this._can_zoom(at, axis)) {
return
}

this._pinch_state = this._get_zoom_state(axis, at)

const factor = ev.scale - 1
this.zoom(this._pinch_state, factor)
}

override _pinch(ev: PinchEvent): void {
const {sx, sy, scale} = ev
const delta = scale >= 1 ? (scale - 1)*20.0 : -20.0/scale
this.zoom(sx, sy, delta)
assert(this._pinch_state != null)

const factor = ev.scale - 1
this.zoom(this._pinch_state, factor)
}

zoom(sx: number, sy: number, delta: number): void {
const axis_view = this.plot_view.axis_views.find((view) => view.bbox.contains(sx, sy))
if (axis_view != null && !this.model.zoom_on_axis) {
return
override _pinch_end(_ev: PinchEvent): void {
assert(this._pinch_state != null)
this._pinch_state = null
}

zoom(state: ZoomState, factor: number): void {
// restrict to axis configured in tool's dimensions property and if
// zoom origin is inside of frame range/domain
const dims = this.model.dimensions
const x_axis = dims == "width" || dims == "both"
const y_axis = dims == "height" || dims == "both"

const {x_scales, y_scales, center} = state
const {x_target, y_target} = this.plot_view.frame
const zoom_info = scale_range(x_scales, y_scales, x_target, y_target, factor, x_axis, y_axis, center)

this.plot_view.state.push("wheel_zoom", {range: zoom_info})

const {maintain_focus} = this.model
this.plot_view.update_range(zoom_info, {scrolling: true, maintain_focus})

this.model.document?.interactive_start(this.plot_view.model, () => this.plot_view.trigger_ranges_update_event())
}

protected _can_zoom({sx, sy}: SXY, axis: AxisView | null): boolean {
if (axis != null && !this.model.zoom_on_axis) {
return false
}

const {frame} = this.plot_view
if (axis_view == null && !frame.bbox.contains(sx, sy)) {
return
if (axis == null && !frame.bbox.contains(sx, sy)) {
return false
}

return true
}

protected _get_zoom_axis({sx, sy}: SXY): AxisView | null {
return this.plot_view.axis_views.find((view) => view.bbox.contains(sx, sy)) ?? null
}

protected _get_zoom_state(axis: AxisView | null, xy: {sx: number, sy: number}): ZoomState {
const {frame} = this.plot_view

const [x_frame_scales_, y_frame_scales_] = (() => {
const x_frame = [...frame.x_scales.values()]
const y_frame = [...frame.y_scales.values()]

if (axis_view == null) {
if (axis == null) {
return [x_frame, y_frame]
} else {
const {zoom_together} = this.model
if (zoom_together == "all") {
if (axis_view.dimension == 0)
if (axis.dimension == 0)
return [x_frame, []]
else
return [[], y_frame]
} else {
const {x_scale, y_scale} = axis_view.coordinates
const {x_scale, y_scale} = axis.coordinates

switch (zoom_together) {
case "cross": {
return [[x_scale], [y_scale]]
}
case "none": {
if (axis_view.dimension == 0)
if (axis.dimension == 0)
return [[x_scale], []]
else
return [[], [y_scale]]
Expand Down Expand Up @@ -140,44 +211,34 @@ export class WheelZoomToolView extends GestureToolView {
}
}

const x_scales = new Set<Scale>()
const y_scales = new Set<Scale>()
const x_scales = new Map<Range, Scale>()
const y_scales = new Map<Range, Scale>()

for (const x_scale of x_all_scales) {
x_scales.add(traverse(x_scale, "x"))
const x_final = traverse(x_scale, "x")
x_scales.set(x_final.source_range, clone(x_final))
}
for (const y_scale of y_all_scales) {
y_scales.add(traverse(y_scale, "y"))
const y_final = traverse(y_scale, "y")
y_scales.set(y_final.source_range, clone(y_final))
}

const center = (() => {
const x = subcoord.x ? null : sx
const y = subcoord.y ? null : sy
const x = subcoord.x ? null : xy.sx
const y = subcoord.y ? null : xy.sy

if (axis_view != null) {
return axis_view.dimension == 0 ? {x, y: null} : {x: null, y}
if (axis != null) {
return axis.dimension == 0 ? {x, y: null} : {x: null, y}
} else {
return {x, y}
}
})()

// restrict to axis configured in tool's dimensions property and if
// zoom origin is inside of frame range/domain
const dims = this.model.dimensions
const x_axis = dims == "width" || dims == "both"
const y_axis = dims == "height" || dims == "both"

const {x_target, y_target} = frame
const factor = this.model.speed*delta

const zoom_info = scale_range(x_scales, y_scales, x_target, y_target, factor, x_axis, y_axis, center)

this.plot_view.state.push("wheel_zoom", {range: zoom_info})

const {maintain_focus} = this.model
this.plot_view.update_range(zoom_info, {scrolling: true, maintain_focus})

this.model.document?.interactive_start(this.plot_view.model, () => this.plot_view.trigger_ranges_update_event())
return {
x_scales,
y_scales,
center,
}
}
}

Expand Down