@bokeh/bokehjs
Version:
Interactive, novel data visualization
387 lines • 14.1 kB
JavaScript
import { UIElement, UIElementView } from "./ui_element";
import { DOMNode } from "../dom/dom_node";
import { Coordinate } from "../coordinates/coordinate";
import { Selector } from "../selectors/selector";
import { Anchor, TooltipAttachment } from "../../core/enums";
import { div, bounding_box, box_size } from "../../core/dom";
import { DOMElementView } from "../../core/dom_view";
import { isString, isArray } from "../../core/util/types";
import { assert } from "../../core/util/assert";
import { BBox } from "../../core/util/bbox";
import { logger } from "../../core/logging";
import { build_view } from "../../core/build_views";
import { Model } from "../../model";
const NativeNode = globalThis.Node;
import tooltips_css, * as tooltips from "../../styles/tooltips.css";
import icons_css from "../../styles/icons.css";
export class TooltipView extends UIElementView {
static __name__ = "TooltipView";
arrow_el;
content_el;
_observer;
_target;
get target() {
return this._target;
}
set target(el) {
this._target = el;
}
_init_target() {
const { target } = this.model;
const el = (() => {
if (target instanceof UIElement) {
return this.owner.find_one(target)?.el ?? null;
}
else if (target instanceof Selector) {
return target.find_one(document);
}
else if (target instanceof NativeNode) {
return target;
}
else {
const { parent } = this;
return parent instanceof DOMElementView ? parent.el : null;
}
})();
if (el instanceof Element) {
this._target = el;
}
else {
logger.warn(`unable to resolve target '${target}' for '${this}'`);
this._target = document.body;
}
}
initialize() {
super.initialize();
this._init_target();
}
_element_view = null;
*children() {
yield* super.children();
if (this._element_view != null) {
yield this._element_view;
}
}
async lazy_initialize() {
await super.lazy_initialize();
await this._build_content();
}
async _build_content() {
if (this._element_view != null) {
this._element_view.remove();
this._element_view = null;
}
const { content } = this.model;
if (content instanceof Model) {
this._element_view = await build_view(content, { parent: this });
}
}
_scroll_listener;
connect_signals() {
super.connect_signals();
this._observer = new ResizeObserver(() => {
this._reposition();
});
this._observer.observe(this.target);
let throttle = false;
document.addEventListener("scroll", this._scroll_listener = () => {
if (!throttle) {
requestAnimationFrame(() => {
this._reposition();
throttle = false;
});
throttle = true;
}
}, { capture: true });
const { target, content, closable, interactive, position, attachment, visible } = this.model.properties;
this.on_change(target, () => {
this._init_target();
this._observer.disconnect();
this._observer.observe(this.target);
this.render();
this.after_render();
});
this.on_change(content, async () => {
await this._build_content();
this.render();
this.after_render();
});
this.on_change([closable, interactive], () => {
this.render();
this.after_render();
});
this.on_change([position, attachment, visible], () => {
this._reposition();
});
}
disconnect_signals() {
if (this._scroll_listener != null) {
document.removeEventListener("scroll", this._scroll_listener, { capture: true });
delete this._scroll_listener;
}
super.disconnect_signals();
}
remove() {
this._element_view?.remove();
this._observer.disconnect();
super.remove();
}
stylesheets() {
return [...super.stylesheets(), tooltips_css, icons_css];
}
get content() {
const { content } = this.model;
if (isString(content)) {
return document.createTextNode(content);
}
else if (content instanceof Model) {
assert(this._element_view != null);
return this._element_view.el;
}
else {
return content;
}
}
_has_rendered = false;
render() {
super.render();
const { _element_view } = this;
if (_element_view != null) {
_element_view.render();
_element_view.r_after_render();
}
this.arrow_el = div({ class: [tooltips.arrow] });
this.content_el = div({ class: tooltips.tooltip_content }, this.content);
this.shadow_el.append(this.arrow_el, this.content_el);
this.class_list.toggle(tooltips.closable, this.model.closable);
const close_el = div({ class: tooltips.close });
this.shadow_el.append(close_el);
close_el.addEventListener("click", () => {
this.model.visible = false;
});
this.el.setAttribute("popover", "manual"); // allows multiple simultaneous popover elements
this.el.classList.toggle(tooltips.show_arrow, this.model.show_arrow);
this.el.classList.toggle(tooltips.non_interactive, !this.model.interactive);
this._has_rendered = true;
}
_after_render() {
super._after_render();
this._reposition();
}
_after_resize() {
super._after_resize();
this._reposition();
}
_anchor_to_align(anchor) {
anchor = (() => {
switch (anchor) {
case "top": return "top_center";
case "bottom": return "bottom_center";
case "left": return "center_left";
case "right": return "center_right";
default: return anchor;
}
})();
const [v, h] = anchor.split("_");
return { v, h };
}
_reposition() {
// Append to `body` to deal with CSS' `contain` interaction
// with `position: fixed`. We assume initial containment
// block in this function, but `contain` can introduce a
// new containment block and offset tooltip's position.
const target = document.body.shadowRoot ?? document.body;
if (!this._has_rendered) {
this.render_to(target);
this.after_render();
return; // render() calls _reposition()
}
const { position, visible } = this.model;
if (position == null || !visible) {
this.el.remove();
return;
}
target.append(this.el);
// If popover API isn't available, then tooltip will still show in most
// situations, but not in fullscreen or may be partially obscured by
// other elements or components.
if (typeof this.el.showPopover !== "undefined") {
this.el.showPopover();
}
const bbox = bounding_box(this.target);
const [sx, sy] = (() => {
if (isString(position)) {
const { v: v_align, h: h_align } = this._anchor_to_align(position);
const sx = (() => {
switch (h_align) {
case "left": return bbox.left;
case "center": return bbox.hcenter;
case "right": return bbox.right;
}
})();
const sy = (() => {
switch (v_align) {
case "top": return bbox.top;
case "center": return bbox.vcenter;
case "bottom": return bbox.bottom;
}
})();
return [sx, sy];
}
else if (isArray(position)) {
const [x, y] = position;
return [bbox.left + x, bbox.top + y];
}
else {
// XXX this assumes position is resolved relative to this.target
const { x, y } = this.resolve_as_xy(position);
return [bbox.left + x, bbox.top + y];
}
})();
const viewport = new BBox({
x: 0,
y: 0,
width: window.innerWidth,
height: window.innerHeight,
});
const arrow_size = box_size(this.arrow_el);
const side = (() => {
const attachment = (() => {
const { attachment } = this.model;
if (attachment == "auto") {
if (isString(position)) {
const { v: v_align, h: h_align } = this._anchor_to_align(position);
if (h_align != "center") {
return h_align == "left" ? "left" : "right";
}
if (v_align != "center") {
return v_align == "top" ? "above" : "below";
}
}
return "horizontal";
}
else {
return attachment;
}
})();
const el_size = box_size(this.el);
const width = el_size.width + arrow_size.width;
const height = el_size.height + arrow_size.height;
switch (attachment) {
case "horizontal": {
if (sx < bbox.hcenter) {
return sx + width <= viewport.right ? "right" : "left";
}
else {
return sx - width >= viewport.left ? "left" : "right";
}
}
case "vertical": {
if (sy < bbox.vcenter) {
return sy + height <= viewport.bottom ? "below" : "above";
}
else {
return sy - height >= viewport.top ? "above" : "below";
}
}
default:
return attachment;
}
})();
// slightly confusing: side "left" (for example) is relative to point that
// is being annotated but CS class ".bk-left" is relative to the tooltip itself
this.class_list.remove(tooltips.right, tooltips.left, tooltips.above, tooltips.below);
this.class_list.add((() => {
switch (side) {
case "left": return tooltips.right;
case "right": return tooltips.left;
case "above": return tooltips.below;
case "below": return tooltips.above;
}
})());
this.arrow_el.style.left = `${sx}px`;
this.arrow_el.style.top = `${sy}px`;
const { left, top } = (() => {
const { width, height } = box_size(this.el);
function adjust_top(top) {
if (top < viewport.top) {
return viewport.top;
}
else if (top + height > viewport.bottom) {
return viewport.bottom - height;
}
else {
return top;
}
}
function adjust_left(left) {
if (left < viewport.left) {
return viewport.left;
}
else if (left + width > viewport.right) {
return viewport.right - width;
}
else {
return left;
}
}
switch (side) {
case "left": {
return {
left: sx - width - arrow_size.width,
top: adjust_top(sy - height / 2),
};
}
case "right": {
return {
left: sx + arrow_size.width,
top: adjust_top(sy - height / 2),
};
}
case "above": {
return {
left: adjust_left(sx - width / 2),
top: sy - height - arrow_size.height,
};
}
case "below": {
return {
left: adjust_left(sx - width / 2),
top: sy + arrow_size.height,
};
}
}
})();
this.el.style.top = `${top}px`;
this.el.style.left = `${left}px`;
this.update_bbox();
}
}
export class Tooltip extends UIElement {
static __name__ = "Tooltip";
constructor(attrs) {
super(attrs);
}
static {
this.prototype.default_view = TooltipView;
this.define(({ Bool, Float, Str, Tuple, Or, Ref, Nullable, Auto }) => ({
target: [Or(Ref(UIElement), Ref(Selector), Ref(NativeNode), Auto), "auto"],
position: [Nullable(Or(Anchor, Tuple(Float, Float), Ref(Coordinate))), null],
content: [Or(Str, Ref(DOMNode), Ref(UIElement), Ref(NativeNode))],
attachment: [Or(TooltipAttachment, Auto), "auto"],
show_arrow: [Bool, true],
closable: [Bool, false],
interactive: [Bool, true],
}));
this.override({
visible: false,
});
}
show({ x, y }) {
this.setv({ position: [x, y], visible: true }, { check_eq: false }); // XXX: force update
}
clear() {
this.position = null;
}
}
//# sourceMappingURL=tooltip.js.map