I want to create my own hovertool extension. To do this, I first copy the Typescript code in bokeh/bokehjs/src/lib/models/tools/inspectors/hover_tool.ts at 3.1.1 · bokeh/bokeh · GitHub and do some change:
- relative import path → absolute import path
- HoverToolView → myHoverToolView
- HoverTool → myHoverTool
- this.register_alias(“hover”, () => new HoverTool()) ->this.register_alias(“myhover”, () => new myHoverTool())
- override tool_name = “Hover” → override tool_name = “myHover”
The complete extension code is here:
CODE = """
import {build_view, build_views, remove_views, ViewStorage, IterViews} from "core/build_views"
import {display, div, empty, span, undisplay} from "core/dom"
import {Anchor, HoverMode, LinePolicy, MutedPolicy, PointPolicy, TooltipAttachment} from "core/enums"
import {Geometry, GeometryData, PointGeometry, SpanGeometry} from "core/geometry"
import * as hittest from "core/hittest"
import * as p from "core/properties"
import {Signal} from "core/signaling"
import {Arrayable, Color} from "core/types"
import {MoveEvent} from "core/ui_events"
import {assert} from "core/util/assert"
import {color2css, color2hex} from "core/util/color"
import {enumerate} from "core/util/iterator"
import {is_empty} from "core/util/object"
import {Formatters, FormatterType, 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"
import {Tooltip} from "models/ui/tooltip"
import {CallbackLike1} from "models/callbacks/callback"
import {Template, TemplateView} from "models/dom/template"
import {GlyphView} from "models/glyphs/glyph"
import {HAreaView} from "models/glyphs/harea"
import {ImageBaseView} from "models/glyphs/image_base"
import {LineView} from "models/glyphs/line"
import {MultiLineView} from "models/glyphs/multi_line"
import {PatchView} from "models/glyphs/patch"
import {VAreaView} from "models/glyphs/varea"
import {DataRenderer} from "models/renderers/data_renderer"
import {GlyphRenderer} from "models/renderers/glyph_renderer"
import {GraphRenderer} from "models/renderers/graph_renderer"
import {Renderer} from "models/renderers/renderer"
import {ImageIndex, MultiIndices, OpaqueIndices, Selection} from "models/selections/selection"
import {ColumnarDataSource} from "models/sources/columnar_data_source"
import {compute_renderers} from "models/util"
import {CustomJSHover} from "models/tools/inspectors/customjs_hover"
import {InspectTool, InspectToolView} from "models/tools/inspectors/inspect_tool"
export type TooltipVars = {
index: number | null
glyph_view: GlyphView
x: number
y: number
sx: number
sy: number
snap_x: number
snap_y: number
snap_sx: number
snap_sy: number
name: string | null
indices?: MultiIndices | OpaqueIndices
segment_index?: number
image_index?: ImageIndex
}
export function _nearest_line_hit(
i: number,
geometry: PointGeometry | SpanGeometry,
dx: Arrayable<number>, dy: Arrayable<number>,
): [[number, number], number] {
const p1 = {x: dx[i], y: dy[i]}
const p2 = {x: dx[i+1], y: dy[i+1]}
const {sx, sy} = geometry
const [d1, d2] = (function() {
if (geometry.type == "span") {
if (geometry.direction == "h")
return [Math.abs(p1.x - sx), Math.abs(p2.x - sx)]
else
return [Math.abs(p1.y - sy), Math.abs(p2.y - sy)]
}
// point geometry case
const s = {x: sx, y: sy}
const d1 = hittest.dist_2_pts(p1, s)
const d2 = hittest.dist_2_pts(p2, s)
return [d1, d2]
})()
return d1 < d2 ? [[p1.x, p1.y], i] : [[p2.x, p2.y], i+1]
}
export function _line_hit(
xs: Arrayable<number>,
ys: Arrayable<number>,
i: number,
): [[number, number], number] {
return [[xs[i], ys[i]], i]
}
export class myHoverToolView extends InspectToolView {
declare model: myHoverTool
public readonly ttmodels: Map<GlyphRenderer, Tooltip> = new Map()
protected readonly _ttviews: ViewStorage<Tooltip> = new Map()
protected _template_el?: HTMLElement
protected _template_view?: TemplateView
override *children(): IterViews {
yield* super.children()
yield* this._ttviews.values()
if (this._template_view != null)
yield this._template_view
}
override async lazy_initialize(): Promise<void> {
await super.lazy_initialize()
await this._update_ttmodels()
const {tooltips} = this.model
if (tooltips instanceof Template) {
this._template_view = await build_view(tooltips, {parent: this.plot_view.canvas})
this._template_view.render()
}
}
override remove(): void {
this._template_view?.remove()
remove_views(this._ttviews)
super.remove()
}
override connect_signals(): void {
super.connect_signals()
const plot_renderers = this.plot_view.model.properties.renderers
const {renderers, tooltips} = this.model.properties
this.on_change(tooltips, () => delete this._template_el)
this.on_change([plot_renderers, renderers, tooltips], async () => await this._update_ttmodels())
}
protected async _update_ttmodels(): Promise<void> {
const {ttmodels} = this
ttmodels.clear()
const {tooltips} = this.model
if (tooltips == null)
return
const {computed_renderers} = this
for (const r of computed_renderers) {
const tooltip = new Tooltip({
content: document.createElement("div"),
attachment: this.model.attachment,
show_arrow: this.model.show_arrow,
interactive: false,
visible: true,
position: null,
target: this.parent.canvas.overlays_el,
})
if (r instanceof GlyphRenderer) {
ttmodels.set(r, tooltip)
} else if (r instanceof GraphRenderer) {
ttmodels.set(r.node_renderer, tooltip)
ttmodels.set(r.edge_renderer, tooltip)
}
}
await build_views(this._ttviews, [...ttmodels.values()], {parent: this.plot_view})
const glyph_renderers = [...(function* () {
for (const r of computed_renderers) {
if (r instanceof GlyphRenderer)
yield r
else if (r instanceof GraphRenderer) {
yield r.node_renderer
yield r.edge_renderer
}
}
})()]
const slot = this._slots.get(this.update)
if (slot != null) {
const except = new Set(glyph_renderers.map((r) => r.data_source))
Signal.disconnect_receiver(this, slot, except)
}
for (const r of glyph_renderers) {
this.connect(r.data_source.inspect, this.update)
}
}
get computed_renderers(): DataRenderer[] {
const {renderers} = this.model
const all_renderers = this.plot_view.model.data_renderers
return compute_renderers(renderers, all_renderers)
}
_clear(): void {
this._inspect(Infinity, Infinity)
for (const [, tooltip] of this.ttmodels) {
tooltip.clear()
}
}
override _move(ev: MoveEvent): void {
if (!this.model.active)
return
const {sx, sy} = ev
if (!this.plot_view.frame.bbox.contains(sx, sy))
this._clear()
else
this._inspect(sx, sy)
}
override _move_exit(): void {
this._clear()
}
_inspect(sx: number, sy: number): void {
let geometry: PointGeometry | SpanGeometry
if (this.model.mode == "mouse")
geometry = {type: "point", sx, sy}
else {
const direction = this.model.mode == "vline" ? "h" : "v"
geometry = {type: "span", direction, sx, sy}
}
for (const r of this.computed_renderers) {
const sm = r.get_selection_manager()
const rview = this.plot_view.renderer_view(r)
if (rview != null)
sm.inspect(rview, geometry)
}
this._emit_callback(geometry)
}
_update(renderer: GlyphRenderer, geometry: PointGeometry | SpanGeometry, tooltip: Tooltip): void {
const selection_manager = renderer.get_selection_manager()
const fullset_indices = selection_manager.inspectors.get(renderer)!
const subset_indices = renderer.view.convert_selection_to_subset(fullset_indices)
// XXX: https://github.com/bokeh/bokeh/pull/11992#pullrequestreview-897552484
if (fullset_indices.is_empty() && fullset_indices.view == null) {
tooltip.clear()
return
}
const ds = selection_manager.source
const renderer_view = this.plot_view.renderer_view(renderer)
if (renderer_view == null)
return
const {sx, sy} = geometry
const xscale = renderer_view.coordinates.x_scale
const yscale = renderer_view.coordinates.y_scale
const x = xscale.invert(sx)
const y = yscale.invert(sy)
const {glyph} = renderer_view
const tooltips: [number, number, HTMLElement | null][] = []
if (glyph instanceof PatchView) {
const [snap_sx, snap_sy] = [sx, sy]
const [snap_x, snap_y] = [x, y]
const vars = {
index: null,
glyph_view: glyph,
x, y, sx, sy, snap_x, snap_y, snap_sx, snap_sy,
name: renderer.name,
}
const rendered = this._render_tooltips(ds, vars)
tooltips.push([snap_sx, snap_sy, rendered])
} else if (glyph instanceof VAreaView || glyph instanceof HAreaView) {
for (const i of subset_indices.line_indices) {
const [snap_x, snap_y] = [x, y]
const [snap_sx, snap_sy] = [sx, sy]
const vars = {
index: i,
glyph_view: glyph,
x, y, sx, sy, snap_x, snap_y, snap_sx, snap_sy,
name: renderer.name,
indices: subset_indices.line_indices,
}
const rendered = this._render_tooltips(ds, vars)
tooltips.push([snap_sx, snap_sy, rendered])
}
} else if (glyph instanceof LineView) {
const {line_policy} = this.model
for (const i of subset_indices.line_indices) {
const [[snap_x, snap_y], [snap_sx, snap_sy], ii] = (() => {
const [x, y] = [glyph._x, glyph._y]
switch (line_policy) {
case "interp": {
const [snap_x, snap_y] = glyph.get_interpolation_hit(i, geometry)
const snap_sxy = [xscale.compute(snap_x), yscale.compute(snap_y)]
return [[snap_x, snap_y], snap_sxy, i]
}
case "prev": {
const [snap_sxy, ii] = _line_hit(glyph.sx, glyph.sy, i)
return [[x[i+1], y[i+1]], snap_sxy, ii]
}
case "next": {
const [snap_sxy, ii] = _line_hit(glyph.sx, glyph.sy, i+1)
return [[x[i+1], y[i+1]], snap_sxy, ii]
}
case "nearest": {
const [snap_sxy, ii] = _nearest_line_hit(i, geometry, glyph.sx, glyph.sy)
return [[x[ii], y[ii]], snap_sxy, ii]
}
case "none": {
const xscale = renderer_view.coordinates.x_scale
const yscale = renderer_view.coordinates.y_scale
const x = xscale.invert(sx)
const y = yscale.invert(sy)
return [[x, y], [sx, sy], i]
}
}
})()
const vars = {
index: ii,
glyph_view: glyph,
x, y, sx, sy, snap_x, snap_y, snap_sx, snap_sy,
name: renderer.name,
indices: subset_indices.line_indices,
}
const rendered = this._render_tooltips(ds, vars)
tooltips.push([snap_sx, snap_sy, rendered])
}
} else if (glyph instanceof ImageBaseView) {
for (const image_index of fullset_indices.image_indices) {
const [snap_sx, snap_sy] = [sx, sy]
const [snap_x, snap_y] = [x, y]
const vars = {
index: image_index.index,
glyph_view: glyph,
x, y, sx, sy, snap_x, snap_y, snap_sx, snap_sy,
name: renderer.name,
image_index,
}
const rendered = this._render_tooltips(ds, vars)
tooltips.push([snap_sx, snap_sy, rendered])
}
} else {
for (const i of subset_indices.indices) {
// multiglyphs set additional indices, e.g. multiline_indices for different tooltips
if (glyph instanceof MultiLineView && !is_empty(subset_indices.multiline_indices)) {
const {line_policy} = this.model
for (const j of subset_indices.multiline_indices[i.toString()]) { // TODO: subset_indices.multiline_indices.get(i)
const [[snap_x, snap_y], [snap_sx, snap_sy], jj] = (function() {
if (line_policy == "interp") {
const [snap_x, snap_y] = glyph.get_interpolation_hit(i, j, geometry)
const snap_sxy = [xscale.compute(snap_x), yscale.compute(snap_y)]
return [[snap_x, snap_y], snap_sxy, j]
}
const [xs, ys] = [glyph._xs.get(i), glyph._ys.get(i)]
if (line_policy == "prev") {
const [snap_sxy, jj] = _line_hit(glyph.sxs.get(i), glyph.sys.get(i), j)
return [[xs[j], ys[j]], snap_sxy, jj]
}
if (line_policy=="next") {
const [snap_sxy, jj] = _line_hit(glyph.sxs.get(i), glyph.sys.get(i), j+1)
return [[xs[j], ys[j]], snap_sxy, jj]
}
if (line_policy == "nearest") {
const [snap_sxy, jj] = _nearest_line_hit(j, geometry, glyph.sxs.get(i), glyph.sys.get(i))
return [[xs[jj], ys[jj]], snap_sxy, jj]
}
throw new Error("shouldn't have happened")
})()
const index = renderer.view.convert_indices_from_subset([i])[0]
const vars = {
index,
glyph_view: glyph,
x, y, sx, sy, snap_x, snap_y, snap_sx, snap_sy,
name: renderer.name,
indices: subset_indices.multiline_indices,
segment_index: jj,
}
const rendered = this._render_tooltips(ds, vars)
tooltips.push([snap_sx, snap_sy, rendered])
}
} else {
// handle non-multiglyphs
const snap_x = (glyph as any)._x?.[i]
const snap_y = (glyph as any)._y?.[i]
const {point_policy, anchor} = this.model
const [snap_sx, snap_sy] = (function() {
if (point_policy == "snap_to_data") {
const pt = glyph.get_anchor_point(anchor, i, [sx, sy])
if (pt != null)
return [pt.x, pt.y]
const ptc = glyph.get_anchor_point("center", i, [sx, sy])
if (ptc != null)
return [ptc.x, ptc.y]
return [sx, sy]
}
return [sx, sy]
})()
const index = renderer.view.convert_indices_from_subset([i])[0]
const vars = {
index,
glyph_view: glyph,
x, y, sx, sy, snap_x, snap_y, snap_sx, snap_sy,
name: renderer.name,
indices: subset_indices.indices,
}
const rendered = this._render_tooltips(ds, vars)
tooltips.push([snap_sx, snap_sy, rendered])
}
}
}
const {bbox} = this.plot_view.frame
const in_frame = tooltips.filter(([sx, sy]) => bbox.contains(sx, sy))
if (in_frame.length == 0)
tooltip.clear()
else {
const {content} = tooltip
assert(content instanceof Element)
empty(content)
for (const [,, node] of in_frame) {
if (node != null)
content.appendChild(node)
}
const [x, y] = in_frame[in_frame.length-1]
tooltip.setv({position: [x, y]}, {check_eq: false}) // XXX: force update
}
}
update([renderer, {geometry}]: [GlyphRenderer, {geometry: Geometry}]): void {
if (!this.model.active)
return
if (!(geometry.type == "point" || geometry.type == "span"))
return
if (this.model.muted_policy == "ignore" && renderer.muted)
return
const tooltip = this.ttmodels.get(renderer)
if (is_undefined(tooltip))
return
this._update(renderer, geometry, tooltip)
}
_emit_callback(geometry: PointGeometry | SpanGeometry): void {
const {callback} = this.model
if (callback == null)
return
for (const renderer of this.computed_renderers) {
if (!(renderer instanceof GlyphRenderer))
continue
const glyph_renderer_view = this.plot_view.renderer_view(renderer)
if (glyph_renderer_view == null)
continue
const {x_scale, y_scale} = glyph_renderer_view.coordinates
const x = x_scale.invert(geometry.sx)
const y = y_scale.invert(geometry.sy)
const index = renderer.data_source.inspected
callback.execute(this.model, {
geometry: {x, y, ...geometry},
renderer,
index,
})
}
}
_create_template(tooltips: [string, string][]): HTMLElement {
const rows = div({style: {display: "table", borderSpacing: "2px"}})
for (const [label] of tooltips) {
const row = div({style: {display: "table-row"}})
rows.appendChild(row)
const label_cell = div({style: {display: "table-cell"}, class: styles.tooltip_row_label}, label.length != 0 ? `${label}: ` : "")
row.appendChild(label_cell)
const value_el = span()
value_el.dataset.value = ""
const swatch_el = span({class: styles.tooltip_color_block}, " ")
swatch_el.dataset.swatch = ""
undisplay(swatch_el)
const value_cell = div({style: {display: "table-cell"}, class: styles.tooltip_row_value}, value_el, swatch_el)
row.appendChild(value_cell)
}
return rows
}
_render_template(template: HTMLElement, tooltips: [string, string][], ds: ColumnarDataSource, vars: TooltipVars): HTMLElement {
const el = template.cloneNode(true) as HTMLElement
// if we have an image_index, that is what replace_placeholders needs
const i = is_undefined(vars.image_index) ? vars.index : vars.image_index
const value_els = el.querySelectorAll<HTMLElement>("[data-value]")
const swatch_els = el.querySelectorAll<HTMLElement>("[data-swatch]")
const color_re = /\$color(\[.*\])?:(\w*)/
const swatch_re = /\$swatch:(\w*)/
for (const [[, value], j] of enumerate(tooltips)) {
const swatch_match = value.match(swatch_re)
const color_match = value.match(color_re)
if (swatch_match == null && color_match == null) {
const content = replace_placeholders(value.replace("$~", "$data_"), ds, i, this.model.formatters, vars)
if (isString(content)) {
value_els[j].textContent = content
} else {
for (const el of content) {
value_els[j].appendChild(el)
}
}
continue
}
if (swatch_match != null) {
const [, colname] = swatch_match
const column = ds.get_column(colname)
if (column == null) {
value_els[j].textContent = `${colname} unknown`
} else {
const color = isNumber(i) ? column[i] : null
if (color != null) {
swatch_els[j].style.backgroundColor = color2css(color)
display(swatch_els[j])
}
}
}
if (color_match != null) {
const [, opts = "", colname] = color_match
const column = ds.get_column(colname) // XXX: change to columnar ds
if (column == null) {
value_els[j].textContent = `${colname} unknown`
continue
}
const hex = opts.indexOf("hex") >= 0
const swatch = opts.indexOf("swatch") >= 0
const color: Color | null = isNumber(i) ? column[i] : null
if (color == null) {
value_els[j].textContent = "(null)"
continue
}
value_els[j].textContent = hex ? color2hex(color) : color2css(color) // TODO: color2pretty
if (swatch) {
swatch_els[j].style.backgroundColor = color2css(color)
display(swatch_els[j])
}
}
}
return el
}
_render_tooltips(ds: ColumnarDataSource, vars: TooltipVars): HTMLElement | null {
const {tooltips} = this.model
const i = vars.index
if (isString(tooltips)) {
const content = replace_placeholders({html: tooltips}, ds, i, this.model.formatters, vars)
return div(content)
}
if (isFunction(tooltips))
return tooltips(ds, vars)
if (tooltips instanceof Template) {
this._template_view!.update(ds, i, vars)
return this._template_view!.el
}
if (tooltips != null) {
const template = this._template_el ?? (this._template_el = this._create_template(tooltips))
return this._render_template(template, tooltips, ds, vars)
}
return null
}
}
export namespace myHoverTool {
export type Attrs = p.AttrsOf<Props>
export type Props = InspectTool.Props & {
tooltips: p.Property<null | Template | string | [string, string][] | ((source: ColumnarDataSource, vars: TooltipVars) => HTMLElement)>
formatters: p.Property<Formatters>
renderers: p.Property<DataRenderer[] | "auto">
mode: p.Property<HoverMode>
muted_policy: p.Property<MutedPolicy>
point_policy: p.Property<PointPolicy>
line_policy: p.Property<LinePolicy>
show_arrow: p.Property<boolean>
anchor: p.Property<Anchor>
attachment: p.Property<TooltipAttachment>
callback: p.Property<CallbackLike1<myHoverTool, {geometry: GeometryData, renderer: Renderer, index: Selection}> | null>
}
}
export interface myHoverTool extends myHoverTool.Attrs {}
export class myHoverTool extends InspectTool {
declare properties: myHoverTool.Props
declare __view_type__: myHoverToolView
constructor(attrs?: Partial<myHoverTool.Attrs>) {
super(attrs)
}
static {
this.prototype.default_view = myHoverToolView
this.define<myHoverTool.Props>(({Any, Boolean, String, Array, Tuple, Dict, Or, Ref, Function, Auto, Nullable}) => ({
tooltips: [ Nullable(Or(Ref(Template), String, Array(Tuple(String, String)), Function<[ColumnarDataSource, TooltipVars], HTMLElement>())), [
["index", "$index" ],
["data (x, y)", "($x, $y)" ],
["screen (x, y)", "($sx, $sy)"],
]],
formatters: [ Dict(Or(Ref(CustomJSHover), FormatterType)), {} ],
renderers: [ Or(Array(Ref(DataRenderer)), Auto), "auto" ],
mode: [ HoverMode, "mouse" ],
muted_policy: [ MutedPolicy, "show" ],
point_policy: [ PointPolicy, "snap_to_data" ],
line_policy: [ LinePolicy, "nearest" ],
show_arrow: [ Boolean, true ],
anchor: [ Anchor, "center" ],
attachment: [ TooltipAttachment, "horizontal" ],
callback: [ Nullable(Any /*TODO*/), null ],
}))
this.register_alias("myhover", () => new myHoverTool())
}
override tool_name = "myHover"
override tool_icon = tool_icon_hover
}
"""
from bokeh.core.properties import Instance, Float, Either, Auto, List, Null, String, Tuple, Dict, Enum, Bool
from bokeh.models import InspectTool, CustomJSHover
from bokeh.util.compiler import TypeScript
from bokeh.models.renderers import DataRenderer
from bokeh.models.dom import Template
from bokeh.core.enums import TooltipFieldFormatter
class myHoverTool(InspectTool):
__implementation__ = TypeScript(CODE)
renderers = Either(Auto, List(Instance(DataRenderer)), default='auto')
tooltips = Either(Null, Instance(Template), String, List(Tuple(String, String)),
default=[
("index", "$index"),
("data (x, y)", "($x, $y)"),
("screen (x, y)", "($sx, $sy)"),
])
formatters = Dict(String, Either(Enum(TooltipFieldFormatter), Instance(CustomJSHover)), default=lambda: dict())
show_arrow = Bool(default=True)
from bokeh.resources import INLINE
from bokeh.io import output_notebook
output_notebook(INLINE)
I get a compilation failed:
myHoverTool.ts:178:23 - error TS2339: Property ‘_slots’ does not exist on type ‘myHoverToolView’.
178 const slot = this._slots.get(this.update)
However, I confirmed that Property ‘_slots’ exists on its basis class View