@bokeh/bokehjs
Version:
Interactive, novel data visualization
755 lines • 31.8 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 { entries } from "../../../core/util/object";
import { execute, execute_sync } from "../../../core/util/callbacks";
import { CustomJS } from "../../callbacks/customjs";
import { replace_placeholders_html, get_value, Skip } from "../../../core/util/templating";
import { isFunction, isArray, isNumber, isBoolean, 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";
import { Nullable, Or, Str, Tuple, Enum, List } from "../../../core/kinds";
import { FilterDef } from "../../dom/value_ref";
const Field = Str;
const SortDirection = Or(Enum("ascending", "descending"), Enum(1, -1));
const SortColumn = Tuple(Field, SortDirection);
const SortBy = Nullable(Or(Field, List(Or(Field, SortColumn))));
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;
_current_bbox = null;
ttmodels = new Map();
_ttviews = new Map();
_template_el;
_template_view;
children_views() {
const this_template_view = this._template_view != null ? [this._template_view] : [];
return [...super.children_views(), ...this._ttviews.values(), ...this_template_view];
}
async _update_filters() {
for (const [_, filter] of entries(this.model.filters)) {
for (const fn of isArray(filter) ? filter : [filter]) {
if (fn instanceof CustomJS) {
await fn.compile();
}
}
}
}
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();
}
await this._update_filters();
}
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;
// Avoid triggering inspections if the bbox moves below, as this can lead to infinite
// loops if bbox changes are caused by the inspection itself.
if (this._current_bbox != null && this._current_bbox.equals(this.plot_view.frame.bbox)) {
this._inspect(sx, sy, dims);
}
}
});
const { filters } = this.model.properties;
this.on_change(filters, () => this._update_filters());
}
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._current_bbox = this.plot_view.frame.bbox.clone();
this._inspect(sx, sy, dims);
}
else {
this._clear();
}
}
_move_exit() {
this._current_sxy = null;
this._current_bbox = 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);
}
render_entries(renderer, geometry) {
const selection_manager = renderer.get_selection_manager();
const fullset_indices = selection_manager.inspectors.get(renderer);
assert(fullset_indices != null);
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 subset_indices = renderer.view.convert_selection_to_subset(fullset_indices);
const collected = [];
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,
};
collected.push({ ds, vars });
}
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,
};
collected.push({ ds, vars });
}
}
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,
};
collected.push({ ds, vars });
}
}
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,
};
collected.push({ ds, vars });
}
}
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,
};
collected.push({ ds, vars });
}
}
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,
};
collected.push({ ds, vars });
}
}
}
const { bbox } = this.plot_view.frame;
const entries = collected
.map((entry, i) => ({ ...entry, i }))
.filter(({ vars }) => bbox.contains(vars.snap_sx, vars.snap_sy))
.filter(({ ds, vars }) => this._can_render_tooltip(ds, vars))
.map(({ ds, vars, i }) => ({ html: this._render_tooltips_if_can(ds, vars), vars, i }))
.filter((entry) => entry.html != null)
.map((entry, j) => ({ ...entry, j }));
const { sort_by } = this.model;
if (sort_by != null) {
const sign = (dir) => {
switch (dir) {
case 1:
case "ascending": return 1;
case -1:
case "descending": return -1;
}
};
const columns = (() => {
if (isString(sort_by)) {
return [[sort_by, 1]];
}
else {
return sort_by.map((val) => {
if (isString(val)) {
return [val, 1];
}
else {
const [field, dir] = val;
return [field, sign(dir)];
}
});
}
})();
const records = Array.from(entries, ({ vars }) => {
const record = new Map();
for (const [field] of columns) {
const value = this._get_value(field, ds, vars);
record.set(field, value);
}
return record;
});
function lookup(i, field) {
return records[i].get(field) ?? NaN;
}
entries.sort((e0, e1) => {
for (const [field, sign] of columns) {
const v0 = lookup(e0.j, field);
const v1 = lookup(e1.j, field);
if (v0 === v1) {
continue;
}
if (isNumber(v0) && isNumber(v1)) {
/* eslint-disable @typescript-eslint/strict-boolean-expressions */
return sign * (v0 - v1 || +isNaN(v0) - +isNaN(v1));
}
else {
const result = `${v0}`.localeCompare(`${v1}`);
if (result == 0) {
continue;
}
else {
return sign * result;
}
}
}
return 0;
});
}
const { limit } = this.model;
if (limit != null) {
entries.splice(limit);
}
return entries; // because filter() can't narrow null
}
/**
* This is used exclusively for testing.
*/
_current_entries = [];
_update(renderer, geometry, tooltip) {
const selection_manager = renderer.get_selection_manager();
const fullset_indices = selection_manager.inspectors.get(renderer);
assert(fullset_indices != null);
// XXX: https://github.com/bokeh/bokeh/pull/11992#pullrequestreview-897552484
if (fullset_indices.is_empty() && fullset_indices.view == null) {
this._current_entries = [];
tooltip.clear();
return;
}
const entries = this.render_entries(renderer, geometry);
this._current_entries = entries;
if (entries.length == 0) {
tooltip.clear();
}
else {
const { content } = tooltip;
assert(content instanceof Node);
empty(content);
for (const { html } of entries) {
content.appendChild(html);
}
const { vars } = entries.at(-1);
tooltip.show({ x: vars.snap_sx, y: vars.snap_sy });
}
}
_get_value(field, ds, vars) {
const [type, name] = (() => {
switch (field[0]) {
case "@": return ["@", field.substring(1)];
case "$": return ["$", field.substring(1)];
default: return ["@", field];
}
})();
const index = vars.image_index ?? vars.index;
return get_value(type, name, ds, index, vars);
}
_can_render_tooltip(data_source, vars) {
const { filters } = this.model;
for (const [field, filter] of entries(filters)) {
const value = this._get_value(field, data_source, vars);
const index = vars.image_index ?? vars.index;
const row = index != null ? data_source.get_row(index) : {};
for (const fn of isArray(filter) ? filter : [filter]) {
const args = { value, field, row, data_source, vars };
const result = (() => {
if (fn instanceof CustomJS) {
return fn.execute_sync(this.model, args);
}
else {
return execute_sync(fn, this.model, args);
}
})();
if (isBoolean(result) && !result) {
return false;
}
}
}
return true;
}
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_html(value.replace("$~", "$data_"), ds, index, this.model.formatters, vars);
value_els[j].append(...content);
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_if_can(ds, vars) {
try {
return this._render_tooltips(ds, vars);
}
catch (error) {
if (error instanceof Skip) {
return null;
}
else {
throw error;
}
}
}
_render_tooltips(ds, vars) {
const { tooltips } = this.model;
// if we have an image_index, that is what replace_placeholders needs
const index = vars.image_index ?? vars.index;
if (isString(tooltips)) {
const content = replace_placeholders_html(tooltips, ds, index, 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, index, 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, index, 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, Int, Str, Positive, 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)), {}],
filters: [Dict(Or(FilterDef, List(FilterDef))), {}], // XXX `any` cast because of CustomJS/Func types
sort_by: [SortBy, null],
limit: [Nullable(Positive(Int)), null],
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