vega-tooltip
Version:
A tooltip plugin for Vega-Lite and Vega visualizations.
358 lines (349 loc) • 12.3 kB
JavaScript
import { isArray, isString, isObject } from 'vega-util';
var version$1 = "1.0.0";
var pkg = {
version: version$1};
/**
* Format the value to be shown in the tooltip.
*
* @param value The value to show in the tooltip.
* @param valueToHtml Function to convert a single cell value to an HTML string
*/
function formatValue(value, valueToHtml, maxDepth, baseURL) {
if (isArray(value)) {
return `[${value.map((v) => valueToHtml(isString(v) ? v : stringify(v, maxDepth))).join(', ')}]`;
}
if (isObject(value)) {
let content = '';
const { title, image, ...rest } = value;
if (title) {
content += `<h2>${valueToHtml(title)}</h2>`;
}
if (image) {
content += `<img src="${new URL(valueToHtml(image), baseURL || location.href).href}">`;
}
const keys = Object.keys(rest);
if (keys.length > 0) {
content += '<table>';
for (const key of keys) {
let val = rest[key];
// ignore undefined properties
if (val === undefined) {
continue;
}
if (isObject(val)) {
val = stringify(val, maxDepth);
}
content += `<tr><td class="key">${valueToHtml(key)}</td><td class="value">${valueToHtml(val)}</td></tr>`;
}
content += `</table>`;
}
return content || '{}'; // show empty object if there are no properties
}
return valueToHtml(value);
}
function replacer(maxDepth) {
const stack = [];
return function (key, value) {
if (typeof value !== 'object' || value === null) {
return value;
}
const pos = stack.indexOf(this) + 1;
stack.length = pos;
if (stack.length > maxDepth) {
return '[Object]';
}
if (stack.indexOf(value) >= 0) {
return '[Circular]';
}
stack.push(value);
return value;
};
}
/**
* Stringify any JS object to valid JSON
*/
function stringify(obj, maxDepth) {
return JSON.stringify(obj, replacer(maxDepth));
}
// generated with build-style.sh
var defaultStyle = `#vg-tooltip-element {
visibility: hidden;
padding: 8px;
position: fixed;
z-index: 1000;
font-family: sans-serif;
font-size: 11px;
border-radius: 3px;
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
/* The default theme is the light theme. */
background-color: rgba(255, 255, 255, 0.95);
border: 1px solid #d9d9d9;
color: black;
}
#vg-tooltip-element.visible {
visibility: visible;
}
#vg-tooltip-element h2 {
margin-top: 0;
margin-bottom: 10px;
font-size: 13px;
}
#vg-tooltip-element table {
border-spacing: 0;
}
#vg-tooltip-element table tr {
border: none;
}
#vg-tooltip-element table tr td {
overflow: hidden;
text-overflow: ellipsis;
padding-top: 2px;
padding-bottom: 2px;
}
#vg-tooltip-element table tr td.key {
color: #808080;
max-width: 150px;
text-align: right;
padding-right: 4px;
}
#vg-tooltip-element table tr td.value {
display: block;
max-width: 300px;
max-height: 7em;
text-align: left;
}
#vg-tooltip-element.dark-theme {
background-color: rgba(32, 32, 32, 0.9);
border: 1px solid #f5f5f5;
color: white;
}
#vg-tooltip-element.dark-theme td.key {
color: #bfbfbf;
}
`;
const EL_ID = 'vg-tooltip-element';
const DEFAULT_OPTIONS = {
offsetX: 10,
offsetY: 10,
id: EL_ID,
styleId: 'vega-tooltip-style',
theme: 'light',
disableDefaultStyle: false,
sanitize: escapeHTML,
maxDepth: 2,
formatTooltip: formatValue,
baseURL: '',
anchor: 'cursor',
position: ['top', 'bottom', 'left', 'right', 'top-left', 'top-right', 'bottom-left', 'bottom-right'],
};
/**
* Escape special HTML characters.
*
* @param value A value to convert to string and HTML-escape.
*/
function escapeHTML(value) {
return String(value).replace(/&/g, '&').replace(/</g, '<');
}
function createDefaultStyle(id) {
// Just in case this id comes from a user, ensure these is no security issues
if (!/^[A-Za-z]+[-:.\w]*$/.test(id)) {
throw new Error('Invalid HTML ID');
}
return defaultStyle.toString().replaceAll(EL_ID, id);
}
/**
* Position the tooltip
*
* @param event The mouse event.
* @param tooltipBox
* @param options Tooltip handler options.
*/
function calculatePositionRelativeToCursor(event, tooltipBox, { offsetX, offsetY }) {
// the possible positions for the tooltip
const positions = getPositions({ x1: event.clientX, x2: event.clientX, y1: event.clientY, y2: event.clientY }, tooltipBox, offsetX, offsetY);
// order of positions to try
const postionArr = ['bottom-right', 'bottom-left', 'top-right', 'top-left'];
// test positions till a valid one is found
for (const p of postionArr) {
if (tooltipIsInViewport(positions[p], tooltipBox)) {
return positions[p];
}
}
// default to top-left if a valid position is not found
// this is legacy behavior
return positions['top-left'];
}
/**
* Calculates the position of the tooltip relative to the mark.
* @param handler The handler instance.
* @param event The mouse event.
* @param item The item that the tooltip is being shown for.
* @param tooltipBox Client rect of the tooltip element.
* @param options Tooltip handler options.
* @returns
*/
function calculatePositionRelativeToMark(handler, event, item, tooltipBox, options) {
const { position, offsetX, offsetY } = options;
const containerBox = handler._el.getBoundingClientRect();
const origin = handler._origin;
// bounds of the mark relative to the viewport
const markBounds = getMarkBounds(containerBox, origin, item);
// the possible positions for the tooltip
const positions = getPositions(markBounds, tooltipBox, offsetX, offsetY);
// positions to test
const positionArr = Array.isArray(position) ? position : [position];
// test positions till a valid one is found
for (const p of positionArr) {
// verify that the tooltip is in the view and the mouse is not where the tooltip would be
if (tooltipIsInViewport(positions[p], tooltipBox) && !mouseIsOnTooltip(event, positions[p], tooltipBox)) {
return positions[p];
}
}
// default to cursor position if a valid position is not found
return calculatePositionRelativeToCursor(event, tooltipBox, options);
}
// Calculates the bounds of the mark relative to the viewport.
function getMarkBounds(containerBox, origin, item) {
// if this is a voronoi mark, we want to use the bounds of the point that voronoi cell represents
const markBounds = item.isVoronoi ? item.datum.bounds : item.bounds;
let left = containerBox.left + origin[0] + markBounds.x1;
let top = containerBox.top + origin[1] + markBounds.y1;
// traverse mark groups, summing their offsets to get the total offset
// item bounds are relative to their group so if there are multiple nested groups we need to add them all
let parentItem = item;
while (parentItem.mark.group) {
parentItem = parentItem.mark.group;
left += parentItem.x ?? 0;
top += parentItem.y ?? 0;
}
const markWidth = markBounds.x2 - markBounds.x1;
const markHeight = markBounds.y2 - markBounds.y1;
return {
x1: left,
x2: left + markWidth,
y1: top,
y2: top + markHeight,
};
}
// Calculates the tooltip xy for each possible position.
function getPositions(markBounds, tooltipBox, offsetX, offsetY) {
const xc = (markBounds.x1 + markBounds.x2) / 2;
const yc = (markBounds.y1 + markBounds.y2) / 2;
// x positions
const left = markBounds.x1 - tooltipBox.width - offsetX;
const center = xc - tooltipBox.width / 2;
const right = markBounds.x2 + offsetX;
// y positions
const top = markBounds.y1 - tooltipBox.height - offsetY;
const middle = yc - tooltipBox.height / 2;
const bottom = markBounds.y2 + offsetY;
const positions = {
top: { x: center, y: top },
bottom: { x: center, y: bottom },
left: { x: left, y: middle },
right: { x: right, y: middle },
'top-left': { x: left, y: top },
'top-right': { x: right, y: top },
'bottom-left': { x: left, y: bottom },
'bottom-right': { x: right, y: bottom },
};
return positions;
}
// Checks if the tooltip would be in the viewport at the given position
function tooltipIsInViewport(position, tooltipBox) {
return (position.x >= 0 &&
position.y >= 0 &&
position.x + tooltipBox.width <= window.innerWidth &&
position.y + tooltipBox.height <= window.innerHeight);
}
// Checks if the mouse is within the tooltip area
function mouseIsOnTooltip(event, position, tooltipBox) {
return (event.clientX >= position.x &&
event.clientX <= position.x + tooltipBox.width &&
event.clientY >= position.y &&
event.clientY <= position.y + tooltipBox.height);
}
/**
* The tooltip handler class.
*/
class Handler {
/**
* The handler function. We bind this to this function in the constructor.
*/
call;
/**
* Complete tooltip options.
*/
options;
/**
* The tooltip html element.
*/
el;
/**
* Create the tooltip handler and initialize the element and style.
*
* @param options Tooltip Options
*/
constructor(options) {
this.options = { ...DEFAULT_OPTIONS, ...options };
const elementId = this.options.id;
this.el = null;
// bind this to call
this.call = this.tooltipHandler.bind(this);
// prepend a default stylesheet for tooltips to the head
if (!this.options.disableDefaultStyle && !document.getElementById(this.options.styleId)) {
const style = document.createElement('style');
style.setAttribute('id', this.options.styleId);
style.innerHTML = createDefaultStyle(elementId);
const head = document.head;
if (head.childNodes.length > 0) {
head.insertBefore(style, head.childNodes[0]);
}
else {
head.appendChild(style);
}
}
}
/**
* The tooltip handler function.
*/
tooltipHandler(handler, event, item, value) {
// append a div element that we use as a tooltip unless it already exists
this.el = document.getElementById(this.options.id);
if (!this.el) {
this.el = document.createElement('div');
this.el.setAttribute('id', this.options.id);
this.el.classList.add('vg-tooltip');
const tooltipContainer = document.fullscreenElement ?? document.body;
tooltipContainer.appendChild(this.el);
}
// hide tooltip for null, undefined, or empty string values
if (value == null || value === '') {
this.el.classList.remove('visible', `${this.options.theme}-theme`);
return;
}
// set the tooltip content
this.el.innerHTML = this.options.formatTooltip(value, this.options.sanitize, this.options.maxDepth, this.options.baseURL);
// make the tooltip visible
this.el.classList.add('visible', `${this.options.theme}-theme`);
const { x, y } = this.options.anchor === 'mark'
? calculatePositionRelativeToMark(handler, event, item, this.el.getBoundingClientRect(), this.options)
: calculatePositionRelativeToCursor(event, this.el.getBoundingClientRect(), this.options);
this.el.style.top = `${y}px`;
this.el.style.left = `${x}px`;
}
}
const version = pkg.version;
/**
* Create a tooltip handler and register it with the provided view.
*
* @param view The Vega view.
* @param opt Tooltip options.
*/
function index (view, opt) {
const handler = new Handler(opt);
view.tooltip(handler.call).run();
return handler;
}
export { DEFAULT_OPTIONS, Handler, calculatePositionRelativeToCursor, calculatePositionRelativeToMark, createDefaultStyle, index as default, escapeHTML, formatValue, getMarkBounds, getPositions, mouseIsOnTooltip, replacer, stringify, tooltipIsInViewport, version };
//# sourceMappingURL=index.js.map