Skip to content

Commit

Permalink
Add support for formatters to ValueRef
Browse files Browse the repository at this point in the history
  • Loading branch information
mattpap committed Feb 16, 2024
1 parent a00470e commit 5606cb2
Show file tree
Hide file tree
Showing 10 changed files with 121 additions and 33 deletions.
3 changes: 3 additions & 0 deletions bokehjs/src/lib/core/enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ export const HatchPatternType = Enum(
" ", ".", "o", "-", "|", "+", '"', ":", "@", "/", "\\", "x", ",", "`", "v", ">", "*",
)

export const FormatterType = Enum("raw", "basic", "numeral", "printf", "datetime")
export type FormatterType = typeof FormatterType["__type__"]

export type HTTPMethod = "POST" | "GET"
export const HTTPMethod = Enum("POST", "GET")

Expand Down
39 changes: 20 additions & 19 deletions bokehjs/src/lib/core/util/templating.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,26 @@ import type {CustomJSHover} from "models/tools/inspectors/customjs_hover"
import {sprintf as sprintf_js} from "sprintf-js"
import tz from "timezone"
import type {Dict} from "../types"
import {Enum} from "../kinds"
import type {FormatterType} from "../enums"
import {logger} from "../logging"
import {dict} from "./object"
import {is_NDArray} from "./ndarray"
import {isArray, isNumber, isString, isTypedArray} from "./types"

export const FormatterType = Enum("numeral", "printf", "datetime")
export type FormatterType = "numeral" | "printf" | "datetime"
const {abs} = Math

export type FormatterSpec = CustomJSHover | FormatterType
export type Formatters = Dict<FormatterSpec>
export type FormatterFunc = (value: unknown, format: string, special_vars: Vars) => string
export type Index = number | ImageIndex
export type Vars = {[key: string]: unknown}

export const DEFAULT_FORMATTERS = {
numeral: (value: unknown, format: string, _special_vars: Vars) => Numbro.format(value, format),
datetime: (value: unknown, format: string, _special_vars: Vars) => tz(value, format),
printf: (value: unknown, format: string, _special_vars: Vars) => sprintf(format, value),
export const DEFAULT_FORMATTERS: {[key in FormatterType]: FormatterFunc} = {
raw: (value: unknown, _format: string, _special_vars: Vars) => `${value}`,
basic: (value: unknown, format: string, special_vars: Vars) => basic_formatter(value, format, special_vars),
numeral: (value: unknown, format: string, _special_vars: Vars) => Numbro.format(value, format),
datetime: (value: unknown, format: string, _special_vars: Vars) => tz(value, format),
printf: (value: unknown, format: string, _special_vars: Vars) => sprintf(format, value),
}

export function sprintf(format: string, ...args: unknown[]): string {
Expand All @@ -33,25 +34,25 @@ export function sprintf(format: string, ...args: unknown[]): string {
export function basic_formatter(value: unknown, _format: string, _special_vars: Vars): string {
if (isNumber(value)) {
const format = (() => {
switch (false) {
case Math.floor(value) != value:
return "%d"
case !(Math.abs(value) > 0.1) || !(Math.abs(value) < 1000):
return "%0.3f"
default:
return "%0.3e"
if (Number.isInteger(value)) {
return "%d"
} else if (0.1 < abs(value) && abs(value) < 1000) {
return "%0.3f"
} else {
return "%0.3e"
}
})()

return sprintf(format, value)
} else
return `${value}` // get strings for categorical types
} else {
return `${value}` // get strings for categorical types
}
}

export function get_formatter(raw_spec: string, format?: string, formatters?: Formatters): FormatterFunc {
// no format, use default built in formatter
if (format == null) {
return basic_formatter
return DEFAULT_FORMATTERS.basic
}

// format spec in the formatters dict, use that
Expand All @@ -76,9 +77,9 @@ export function get_formatter(raw_spec: string, format?: string, formatters?: Fo
return DEFAULT_FORMATTERS.numeral
}

const MISSING = "???"
export const MISSING = "???"

function _get_special_value(name: string, special_vars: Vars) {
function _get_special_value(name: string, special_vars: Vars) {
if (name in special_vars) {
return special_vars[name]
} else {
Expand Down
2 changes: 2 additions & 0 deletions bokehjs/src/lib/models/dom/placeholder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type * as p from "core/properties"

export abstract class PlaceholderView extends DOMNodeView {
declare model: Placeholder

declare el: HTMLElement
static override tag_name = "span" as const

override render(): void {
Expand Down
59 changes: 52 additions & 7 deletions bokehjs/src/lib/models/dom/value_ref.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,66 @@
import {Placeholder, PlaceholderView} from "./placeholder"
import {CustomJS} from "../callbacks/customjs"
import {CustomJSHover} from "../tools/inspectors/customjs_hover"
import type {ColumnarDataSource} from "../sources/columnar_data_source"
import type {Index as DataIndex} from "core/util/templating"
import {_get_column_value} from "core/util/templating"
import type {Index} from "core/util/templating"
import {_get_column_value, MISSING, DEFAULT_FORMATTERS} from "core/util/templating"
import {execute} from "core/util/callbacks"
import {isArray} from "core/util/types"
import {FormatterType} from "core/enums"
import type * as p from "core/properties"
import type {PlainObject} from "core/types"
import {Or, Ref} from "core/kinds"

const Formatter = Or(FormatterType, Ref(CustomJS), Ref(CustomJSHover))
type Formatter = typeof Formatter["__type__"]

export class ValueRefView extends PlaceholderView {
declare model: ValueRef

update(source: ColumnarDataSource, i: DataIndex | null, _vars: object/*, formatters?: Formatters*/): void {
const value = _get_column_value(this.model.field, source, i)
const text = value == null ? "???" : `${value}` //.toString()
this.el.textContent = text
update(source: ColumnarDataSource, i: Index | null, vars: PlainObject/*, formatters?: Formatters*/): void {
const {field, format, formatter} = this.model
const value = _get_column_value(field, source, i)

const render = (output: unknown) => {
if (output == null) {
this.el.textContent = MISSING
} else if (output instanceof Node) {
this.el.replaceChildren(output)
} else if (isArray(output)) {
this.el.replaceChildren(...output.map((item) => item instanceof Node ? item : `${item}`))
} else {
this.el.textContent = `${output}`
}
}

if (formatter instanceof CustomJS) {
void (async () => {
const output = await execute(formatter, this.model, {value, format, vars})
render(output)
})()
} else {
const output = (() => {
if (format == null) {
return DEFAULT_FORMATTERS.basic(value, "", vars)
} else {
if (formatter instanceof CustomJSHover) {
return formatter.format(value, format, vars)
} else {
return DEFAULT_FORMATTERS[formatter](value, format, vars)
}
}
})()
render(output)
}
}
}

export namespace ValueRef {
export type Attrs = p.AttrsOf<Props>
export type Props = Placeholder.Props & {
field: p.Property<string>
format: p.Property<string | null>
formatter: p.Property<Formatter>
}
}

Expand All @@ -33,8 +76,10 @@ export class ValueRef extends Placeholder {

static {
this.prototype.default_view = ValueRefView
this.define<ValueRef.Props>(({String}) => ({
this.define<ValueRef.Props>(({String, Nullable}) => ({
field: [ String ],
format: [ Nullable(String), null ],
formatter: [ Formatter, "raw" ],
}))
}
}
10 changes: 6 additions & 4 deletions bokehjs/src/lib/models/tools/inspectors/hover_tool.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type {ViewStorage, IterViews} from "core/build_views"
import {build_view, build_views, remove_views} from "core/build_views"
import {display, div, empty, span, undisplay} from "core/dom"
import {Anchor, HoverMode, LinePolicy, MutedPolicy, PointPolicy, TooltipAttachment} from "core/enums"
import {Anchor, HoverMode, LinePolicy, MutedPolicy, PointPolicy, TooltipAttachment, FormatterType} from "core/enums"
import type {Geometry, GeometryData, PointGeometry, SpanGeometry} from "core/geometry"
import * as hittest from "core/hittest"
import type * as p from "core/properties"
Expand All @@ -14,7 +14,7 @@ import {enumerate} from "core/util/iterator"
import type {CallbackLike1} from "core/util/callbacks"
import {execute} from "core/util/callbacks"
import type {Formatters} from "core/util/templating"
import {FormatterType, replace_placeholders} from "core/util/templating"
import {replace_placeholders} from "core/util/templating"
import {isFunction, isNumber, isString, is_undefined} from "core/util/types"
import {tool_icon_hover} from "styles/icons.css"
import * as styles from "styles/tooltips.css"
Expand Down Expand Up @@ -609,8 +609,10 @@ export class HoverToolView extends InspectToolView {
return tooltips(ds, vars)

if (tooltips instanceof Template) {
this._template_view!.update(ds, i, vars)
return this._template_view!.el
const {_template_view} = this
assert(_template_view != null)
_template_view.update(ds, i, vars)
return _template_view.el
}

if (tooltips != null) {
Expand Down
4 changes: 2 additions & 2 deletions bokehjs/test/unit/core/util/templating.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ describe("templating module", () => {
describe("DEFAULT_FORMATTERS", () => {

it("should have 3 entries", () => {
expect(keys(tmpl.DEFAULT_FORMATTERS).length).to.be.equal(3)
expect(keys(tmpl.DEFAULT_FORMATTERS).length).to.be.equal(5)
})

it("should have a numeral formatter", () => {
Expand Down Expand Up @@ -65,7 +65,7 @@ describe("templating module", () => {

it("should return basic_formatter for null format", () => {
const f = tmpl.get_formatter("@x")
expect(f).to.be.equal(tmpl.basic_formatter)
expect(f).to.be.equal(tmpl.DEFAULT_FORMATTERS.basic)
})

it("should return numeral formatter for specs not in formatters dict", () => {
Expand Down
4 changes: 4 additions & 0 deletions src/bokeh/core/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ class MyModel(Model):
'Direction',
'FlowMode',
'FontStyle',
'FormatterType',
'HAlign',
'HatchPattern',
'HatchPatternAbbreviation',
Expand Down Expand Up @@ -314,6 +315,9 @@ def enumeration(*values: Any, case_sensitive: bool = True, quote: bool = False)
#: Specify the font style for rendering text
FontStyle = enumeration("normal", "italic", "bold", "bold italic")

#: Names of built-in value formatters.
FormatterType = enumeration("raw", "basic", "numeral", "printf", "datetime")

_hatch_patterns = (
(" ", "blank"),
(".", "dot"),
Expand Down
27 changes: 26 additions & 1 deletion src/bokeh/models/dom.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,16 @@
from typing import Any

# Bokeh imports
from ..core.enums import FormatterType
from ..core.has_props import HasProps, abstract
from ..core.properties import (
Bool,
Dict,
Either,
Enum,
Instance,
List,
Nullable,
Required,
String,
)
Expand Down Expand Up @@ -170,12 +173,34 @@ def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)

class ValueRef(Placeholder):
""" Allows to reference a value in a column of a data source.
"""

# explicit __init__ to support Init signatures
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)

field = Required(String)
field = Required(String, help="""
The name of the field to reference, which is equivalent to using ``"@{field}``.
""")

format = Nullable(String, default=None, help="""
Optional format string, which is equivalent to using ``"@{field}{format}"``.
""")

formatter = Either(
Enum(FormatterType),
Instance(".models.callbacks.CustomJS"),
Instance(".models.tools.CustomJSHover"), default="raw", help="""
Either a named value formatter or an instance of ``CustomJS`` or ``CustomJSHover``.
.. note::
Custom JS formatters can return a value of any type, not necessarily a string.
If a non-string value is returned then, if it's an instance of DOM ``Node``
(in particular it can be a DOM ``Document`` or a ``DocumentFragment``), then
it will be added to the DOM tree as-is, otherwise it will be converted to a
string and added verbatim. No HTML parsing is attempted in any case.
""")

class ColorRef(ValueRef):

Expand Down
2 changes: 2 additions & 0 deletions tests/baselines/defaults.json5
Original file line number Diff line number Diff line change
Expand Up @@ -2306,6 +2306,8 @@
type: "symbol",
name: "unset",
},
format: null,
formatter: "raw",
},
CoordinateTransform: {
__extends__: "Expression",
Expand Down
4 changes: 4 additions & 0 deletions tests/unit/bokeh/core/test_enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
'Direction',
'FlowMode',
'FontStyle',
'FormatterType',
'HAlign',
'HatchPattern',
'HatchPatternAbbreviation',
Expand Down Expand Up @@ -191,6 +192,9 @@ def test_Direction(self) -> None:
def test_FontStyle(self) -> None:
assert tuple(bce.FontStyle) == ('normal', 'italic', 'bold', 'bold italic')

def test_FormatterType(self) -> None:
assert tuple(bce.FormatterType) == ("raw", "basic", "numeral", "printf", "datetime")

def test_HatchPattern(self) -> None:
assert tuple(bce.HatchPattern) == (
"blank", "dot", "ring", "horizontal_line", "vertical_line", "cross", "horizontal_dash", "vertical_dash",
Expand Down

0 comments on commit 5606cb2

Please sign in to comment.