UNPKG

@bokeh/bokehjs

Version:

Interactive, novel data visualization

390 lines 14.4 kB
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_views() { const this_element_view = this._element_view != null ? [this._element_view] : []; return [...super.children_views(), ...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 }, div({ class: tooltips.arrow_inner })); 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, }); // TODO box_size(this.arrow_el), but currently doesn't work reliably const el_style = getComputedStyle(this.el); const arrow_size = { width: parseFloat(el_style.getPropertyValue("--tooltip-arrow-width")), height: parseFloat(el_style.getPropertyValue("--tooltip-arrow-height")), }; 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