@bokeh/bokehjs
Version:
Interactive, novel data visualization
611 lines • 26 kB
JavaScript
import { build_view, build_views, remove_views, traverse_views } from "../../../core/build_views";
import { display, div, empty, span, undisplay } from "../../../core/dom";
import { Anchor, HoverMode, LinePolicy, MutedPolicy, PointPolicy, TooltipAttachment, BuiltinFormatter } from "../../../core/enums";
import * as hittest from "../../../core/hittest";
import { Signal } from "../../../core/signaling";
import { assert, unreachable } from "../../../core/util/assert";
import { color2css, color2hex } from "../../../core/util/color";
import { enumerate } from "../../../core/util/iterator";
import { execute } from "../../../core/util/callbacks";
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";
import { Tooltip } from "../../ui/tooltip";
import { DOMElement } from "../../dom/dom_element";
import { PlaceholderView } from "../../dom/placeholder";
import { TemplateView } from "../../dom/template";
import { HAreaView } from "../../glyphs/harea";
import { HAreaStepView } from "../../glyphs/harea_step";
import { ImageBaseView } from "../../glyphs/image_base";
import { LineView } from "../../glyphs/line";
import { MultiLineView } from "../../glyphs/multi_line";
import { PatchView } from "../../glyphs/patch";
import { VAreaView } from "../../glyphs/varea";
import { VAreaStepView } from "../../glyphs/varea_step";
import { DataRenderer } from "../../renderers/data_renderer";
import { GlyphRenderer } from "../../renderers/glyph_renderer";
import { GraphRenderer } from "../../renderers/graph_renderer";
import { compute_renderers } from "../../util";
import { CustomJSHover } from "./customjs_hover";
import { InspectTool, InspectToolView } from "./inspect_tool";
export function _nearest_line_hit(i, geometry, dx, dy) {
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, ys, i) {
return [[xs[i], ys[i]], i];
}
const COLOR_RE = /\$color(\[.*\])?:(\w*)/;
const SWATCH_RE = /\$swatch:(\w*)/;
export class HoverToolView extends InspectToolView {
static __name__ = "HoverToolView";
_current_sxy = null;
ttmodels = new Map();
_ttviews = new Map();
_template_el;
_template_view;
*children() {
yield* super.children();
yield* this._ttviews.values();
if (this._template_view != null) {
yield this._template_view;
}
}
async lazy_initialize() {
await super.lazy_initialize();
await this._update_ttmodels();
const { tooltips } = this.model;
if (tooltips instanceof DOMElement) {
this._template_view = await build_view(tooltips, { parent: this.plot_view.canvas });
this._template_view.render();
}
}
remove() {
this._template_view?.remove();
remove_views(this._ttviews);
super.remove();
}
connect_signals() {
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());
this.connect(this.plot_view.repainted, () => {
if (this.model.active && this._current_sxy != null) {
const [sx, sy, dims] = this._current_sxy;
this._inspect(sx, sy, dims);
}
});
}
async _update_ttmodels() {
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,
});
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() {
const { renderers } = this.model;
const all_renderers = this.plot_view.model.data_renderers;
return compute_renderers(renderers, all_renderers);
}
_clear() {
this._inspect(Infinity, Infinity, "xy");
for (const [, tooltip] of this.ttmodels) {
tooltip.clear();
}
}
_move(ev) {
if (!this.model.active) {
return;
}
const { sx, sy } = ev;
const dims = (() => {
if (this.plot_view.frame.bbox.contains(sx, sy)) {
return "xy";
}
const axis_view = this.plot_view.axis_views.find((view) => view.bbox.contains(sx, sy));
if (axis_view != null) {
switch (axis_view.dimension) {
case 0: return "x";
case 1: return "y";
}
}
return null;
})();
if (dims != null) {
this._current_sxy = [sx, sy, dims];
this._inspect(sx, sy, dims);
}
else {
this._clear();
}
}
_move_exit() {
this._current_sxy = null;
this._clear();
}
_inspect(sx, sy, dims) {
const geometry = (() => {
if (this.model.mode == "mouse") {
return { type: "point", sx, sy };
}
else {
const direction = this.model.mode == "vline" ? "h" : "v";
return { type: "span", direction, sx, sy };
}
})();
if (isFinite(sx + sy)) {
switch (geometry.type) {
case "point": {
if (dims != "xy") {
return;
}
break;
}
case "span": {
if ((dims == "x" || dims == "xy") && geometry.direction == "h") {
break;
}
if ((dims == "y" || dims == "xy") && geometry.direction == "v") {
break;
}
this._clear();
return;
}
}
}
for (const r of this.computed_renderers) {
const sm = r.get_selection_manager();
const rview = this.plot_view.views.find_one(r);
if (rview != null) {
sm.inspect(rview, geometry);
}
}
this._emit_callback(geometry);
}
_update(renderer, geometry, tooltip) {
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.views.find_one(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 = [];
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,
type: glyph.model.type,
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 HAreaStepView ||
glyph instanceof HAreaView ||
glyph instanceof VAreaStepView ||
glyph instanceof VAreaView) {
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,
type: glyph.model.type,
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;
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,
type: glyph.model.type,
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,
type: glyph.model.type,
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 && subset_indices.multiline_indices.size != 0) {
const { line_policy } = this.model;
for (const j of subset_indices.multiline_indices.get(i) ?? []) {
const [[snap_x, snap_y], [snap_sx, snap_sy], jj] = (() => {
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];
}
unreachable();
})();
const index = renderer.view.convert_indices_from_subset([i])[0];
const vars = {
index,
glyph_view: glyph,
type: glyph.model.type,
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.x?.[i];
const snap_y = glyph.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,
type: glyph.model.type,
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 Node);
empty(content);
for (const [, , node] of in_frame) {
if (node != null) {
content.appendChild(node);
}
}
const [x, y] = in_frame[in_frame.length - 1];
tooltip.show({ x, y });
}
}
update([renderer, { geometry }]) {
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) {
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.views.find_one(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;
void execute(callback, this.model, {
geometry: { x, y, ...geometry },
renderer,
index,
});
}
}
_create_template(tooltips) {
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, tooltips, ds, index, vars) {
const el = template.cloneNode(true);
const value_els = el.querySelectorAll("[data-value]");
const swatch_els = el.querySelectorAll("[data-swatch]");
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, index, 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(index) ? column[index] : 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 = isNumber(index) ? column[index] : 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, vars) {
const { tooltips } = this.model;
// if we have an image_index, that is what replace_placeholders needs
const i = is_undefined(vars.image_index) ? vars.index : vars.image_index;
if (isString(tooltips)) {
const content = replace_placeholders({ html: tooltips }, ds, i, this.model.formatters, vars);
return div(content);
}
else if (isFunction(tooltips)) {
return tooltips(ds, vars);
}
else if (tooltips instanceof DOMElement) {
const { _template_view } = this;
assert(_template_view != null);
this._update_template(_template_view, ds, i, vars);
return _template_view.el.cloneNode(true);
}
else if (tooltips != null) {
const template = this._template_el ?? (this._template_el = this._create_template(tooltips));
return this._render_template(template, tooltips, ds, i, vars);
}
else {
return null;
}
}
_update_template(template_view, ds, i, vars) {
const { formatters } = this.model;
if (template_view instanceof TemplateView) {
template_view.update(ds, i, vars, formatters);
}
else {
traverse_views([template_view], (view) => {
if (view instanceof PlaceholderView) {
view.update(ds, i, vars, formatters);
}
});
}
}
}
export class HoverTool extends InspectTool {
static __name__ = "HoverTool";
constructor(attrs) {
super(attrs);
}
static {
this.prototype.default_view = HoverToolView;
this.define(({ Any, Bool, Str, List, Tuple, Dict, Or, Ref, Func, Auto, Nullable }) => ({
tooltips: [Nullable(Or(Ref(DOMElement), Str, List(Tuple(Str, Str)), Func())), [
["index", "$index"],
["data (x, y)", "($x, $y)"],
["screen (x, y)", "($sx, $sy)"],
]],
formatters: [Dict(Or(Ref(CustomJSHover), BuiltinFormatter)), {}],
renderers: [Or(List(Ref(DataRenderer)), Auto), "auto"],
mode: [HoverMode, "mouse"],
muted_policy: [MutedPolicy, "show"],
point_policy: [PointPolicy, "snap_to_data"],
line_policy: [LinePolicy, "nearest"],
show_arrow: [Bool, true],
anchor: [Anchor, "center"],
attachment: [TooltipAttachment, "horizontal"],
callback: [Nullable(Any /*TODO*/), null],
}));
this.register_alias("hover", () => new HoverTool());
}
tool_name = "Hover";
tool_icon = tool_icon_hover;
}
//# sourceMappingURL=hover_tool.js.map