Skip to content

Commit

Permalink
Add CustomJSTicker (bokeh#13988)
Browse files Browse the repository at this point in the history
* Add CustomJSTicker

* Apply suggestions from code review

Co-authored-by: Moritz Schreiber <[email protected]>

---------

Co-authored-by: Moritz Schreiber <[email protected]>
  • Loading branch information
bryevdv and mosc9575 authored Jul 31, 2024
1 parent b902fb5 commit 54252f8
Show file tree
Hide file tree
Showing 8 changed files with 342 additions and 10 deletions.
93 changes: 93 additions & 0 deletions bokehjs/src/lib/models/tickers/customjs_ticker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import type {FactorTickSpec} from "./categorical_ticker"
import type {TickSpec} from "./ticker"
import {Ticker} from "./ticker"
import {FactorRange} from "../ranges/factor_range"
import type {Range} from "../ranges/range"
import type * as p from "core/properties"
import type {Dict} from "core/types"
import {keys, values} from "core/util/object"
import {use_strict} from "core/util/string"

type MajorCBData = {
start: number
end: number
range: Range
cross_loc: number
}

type MinorCBData = MajorCBData & {
major_ticks: any[]
}

export namespace CustomJSTicker {
export type Attrs = p.AttrsOf<Props>

export type Props = Ticker.Props & {
args: p.Property<Dict<unknown>>
major_code: p.Property<string>
minor_code: p.Property<string>
}
}

export interface CustomJSTicker extends CustomJSTicker.Attrs {}

export class CustomJSTicker extends Ticker {
declare properties: CustomJSTicker.Props

constructor(attrs?: Partial<CustomJSTicker.Attrs>) {
super(attrs)
}

static {
this.define<CustomJSTicker.Props>(({Unknown, Str, Dict}) => ({
args: [ Dict(Unknown), {} ],
major_code: [ Str, "" ],
minor_code: [ Str, "" ],
}))
}

get names(): string[] {
return keys(this.args)
}

get values(): unknown[] {
return values(this.args)
}

get_ticks(start: number, end: number, range: Range, cross_loc: number): TickSpec<number> | FactorTickSpec {
const major_cb_data = {start, end, range, cross_loc}
const major_ticks = this.major_ticks(major_cb_data)

// CustomJSTicker for categorical axes only support a single level of major ticks
if (range instanceof FactorRange) {
return {major: major_ticks, minor: [], tops: [], mids: []}
}

const minor_cb_data = {major_ticks, ...major_cb_data}
const minor_ticks = this.minor_ticks(minor_cb_data)

return {
major: major_ticks,
minor: minor_ticks,
}
}

protected major_ticks(cb_data: MajorCBData): any[] {
if (this.major_code == "") {
return []
}
const code = use_strict(this.major_code)
const func = new Function("cb_data", ...this.names, code)
return func(cb_data, ...this.values)
}

protected minor_ticks(cb_data: MinorCBData): any[] {
if (this.minor_code == "") {
return []
}
const code = use_strict(this.minor_code)
const func = new Function("cb_data", ...this.names, code)
return func(cb_data, ...this.values)
}

}
1 change: 1 addition & 0 deletions bokehjs/src/lib/models/tickers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export {BasicTicker} from "./basic_ticker"
export {CategoricalTicker} from "./categorical_ticker"
export {CompositeTicker} from "./composite_ticker"
export {ContinuousTicker} from "./continuous_ticker"
export {CustomJSTicker} from "./customjs_ticker"
export {DatetimeTicker} from "./datetime_ticker"
export {DaysTicker} from "./days_ticker"
export {FixedTicker} from "./fixed_ticker"
Expand Down
1 change: 1 addition & 0 deletions bokehjs/test/unit/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ describe("default model resolver", () => {
"CustomJSFilter",
"CustomJSHover",
"CustomJSTickFormatter",
"CustomJSTicker",
"CustomJSTransform",
"CustomLabelingPolicy",
"DataCube",
Expand Down
106 changes: 106 additions & 0 deletions bokehjs/test/unit/models/tickers/customjs_ticker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import {expect} from "assertions"

import {CustomJSTicker} from "@bokehjs/models/tickers/customjs_ticker"
import type {FactorTickSpec} from "@bokehjs/models/tickers/categorical_ticker"
import {FactorRange} from "@bokehjs/models/ranges/factor_range"
import {Range1d} from "@bokehjs/models/ranges/range1d"

describe("CustomJSTicker Model", () => {

describe("Continuous get_ticks method", () => {
it("should handle case with no major_code", () => {
const ticker = new CustomJSTicker()
const range = new Range1d({start: -1, end: 11})
const ticks = ticker.get_ticks(0, 10, range, NaN)
expect(ticks.major).to.be.equal([])
expect(ticks.minor).to.be.equal([])
})

it("should return major_code result", () => {
const ticker = new CustomJSTicker({major_code: "return [2,4,6,8]"})
const range = new Range1d({start: -1, end: 11})
const ticks = ticker.get_ticks(0, 10, range, NaN)
expect(ticks.major).to.be.equal([2, 4, 6, 8])
expect(ticks.minor).to.be.equal([])
})

it("should pass start and end to major_code", () => {
const ticker = new CustomJSTicker({major_code: "return [cb_data.start, cb_data.end]"})
const range = new Range1d({start: -1, end: 11})
const ticks = ticker.get_ticks(0, 10, range, NaN)
expect(ticks.major).to.be.equal([0, 10])
expect(ticks.minor).to.be.equal([])
})

it("should pass range to major_code", () => {
const ticker = new CustomJSTicker({major_code: "return [cb_data.range.start, cb_data.range.end]"})
const range = new Range1d({start: -1, end: 11})
const ticks = ticker.get_ticks(0, 10, range, NaN)
expect(ticks.major).to.be.equal([-1, 11])
expect(ticks.minor).to.be.equal([])
})

it("should pass cross_loc to major_code", () => {
const ticker = new CustomJSTicker({major_code: "return [cb_data.cross_loc]"})
const range = new Range1d({start: -1, end: 11})
const ticks = ticker.get_ticks(0, 10, range, 20)
expect(ticks.major).to.be.equal([20])
expect(ticks.minor).to.be.equal([])
})

})

describe("Categorical get_ticks method", () => {

it("should handle case with no major_code", () => {
const ticker = new CustomJSTicker()
const range = new FactorRange({factors: ["foo", "bar", "baz"]})
const ticks = ticker.get_ticks(0, 10, range, NaN) as FactorTickSpec
expect(ticks.major).to.be.equal([])
expect(ticks.minor).to.be.equal([])
expect(ticks.mids).to.be.equal([])
expect(ticks.tops).to.be.equal([])
})

it("should handle case where range has factors", () => {
const ticker = new CustomJSTicker({major_code: "return['foo', 'baz']"})
const range = new FactorRange({factors: ["foo", "bar", "baz"]})
const ticks = ticker.get_ticks(0, 3, range, NaN) as FactorTickSpec
expect(ticks.major).to.be.equal(["foo", "baz"])
expect(ticks.minor).to.be.equal([])
expect(ticks.mids).to.be.equal([])
expect(ticks.tops).to.be.equal([])
})

it("should pass start and end to major_code", () => {
const ticker = new CustomJSTicker({major_code: "return [cb_data.start.toString(), cb_data.end.toString()]"})
const range = new FactorRange({factors: ["foo", "bar", "baz"]})
const ticks = ticker.get_ticks(0, 10, range, NaN) as FactorTickSpec
expect(ticks.major).to.be.equal(["0", "10"])
expect(ticks.minor).to.be.equal([])
expect(ticks.mids).to.be.equal([])
expect(ticks.tops).to.be.equal([])
})

it("should pass range to major_code", () => {
const ticker = new CustomJSTicker({major_code: "return cb_data.range.factors"})
const range = new FactorRange({factors: ["foo", "bar", "baz"]})
const ticks = ticker.get_ticks(0, 10, range, NaN) as FactorTickSpec
expect(ticks.major).to.be.equal(["foo", "bar", "baz"])
expect(ticks.minor).to.be.equal([])
expect(ticks.mids).to.be.equal([])
expect(ticks.tops).to.be.equal([])
})

it("should pass cross_loc to major_code", () => {
const ticker = new CustomJSTicker({major_code: "return [cb_data.cross_loc.toString()]"})
const range = new FactorRange({factors: ["foo", "bar", "baz"]})
const ticks = ticker.get_ticks(0, 10, range, 20) as FactorTickSpec
expect(ticks.major).to.be.equal(["20"])
expect(ticks.minor).to.be.equal([])
expect(ticks.mids).to.be.equal([])
expect(ticks.tops).to.be.equal([])
})

})
})
13 changes: 13 additions & 0 deletions docs/bokeh/source/docs/user_guide/styling/plots.rst
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,18 @@ As a shortcut, you can also supply the list of ticks directly to an axis'
.. bokeh-plot:: __REPO__/examples/styling/plots/fixed_ticker.py
:source-position: above

``CustomJSTicker``
''''''''''''''''''

To fully customize the location of axis ticks, use the |CustomJSTicker| in
combination with a JavaScript snippet as its ``major_code`` and ``minor_code``
properties.

These code snippets should return lists of tick locations:

.. bokeh-plot:: __REPO__/examples/styling/plots/custom_js_ticker.py
:source-position: above

Tick lines
~~~~~~~~~~

Expand Down Expand Up @@ -843,6 +855,7 @@ You can see a complete example with output in the section
.. |BasicTickFormatter| replace:: :class:`~bokeh.models.formatters.BasicTickFormatter`
.. |CategoricalTickFormatter| replace:: :class:`~bokeh.models.formatters.CategoricalTickFormatter`
.. |DatetimeTickFormatter| replace:: :class:`~bokeh.models.formatters.DatetimeTickFormatter`
.. |CustomJSTicker| replace:: :class:`~bokeh.models.tickers.CustomJSTicker`
.. |CustomJSTickFormatter| replace:: :class:`~bokeh.models.formatters.CustomJSTickFormatter`
.. |LogTickFormatter| replace:: :class:`~bokeh.models.formatters.LogTickFormatter`
.. |NumeralTickFormatter| replace:: :class:`~bokeh.models.formatters.NumeralTickFormatter`
Expand Down
34 changes: 34 additions & 0 deletions examples/styling/plots/custom_js_ticker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from bokeh.models import CustomJSTicker
from bokeh.plotting import figure, show

xticker = CustomJSTicker(
# always three equally spaced ticks
major_code="""
const {start, end} = cb_data.range
const interval = (end-start) / 4
return [start + interval, start + 2*interval, start + 3*interval]
""",
# minor ticks in between the major ticks
minor_code="""
const {start, end, major_ticks} = cb_data
return [
(start+major_ticks[0])/2,
(major_ticks[0]+major_ticks[1])/2,
(major_ticks[1]+major_ticks[2])/2,
(major_ticks[2]+end)/2,
]
""",
)

yticker = CustomJSTicker(major_code="return ['a', 'c', 'e', 'g']")

p = figure(y_range=list("abcdefg"))
p.scatter([1, 2, 3, 4, 5], ["a", "d", "b", "f", "c"], size=30)

p.xaxis.ticker = xticker

# keep the grid lines at all original tick locations
p.ygrid.ticker = p.yaxis.ticker
p.yaxis.ticker = yticker

show(p)
Loading

0 comments on commit 54252f8

Please sign in to comment.