mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[vega] support HTML tooltips (#17632)
Implement support for the richer style Vega tooltips, per https://github.com/elastic/kibana/issues/17215 request. Uses [Vega tooltip plugin](https://github.com/vega/vega-tooltip) for formatting. Always enabled unless user sets `tooltips=false` flag in the `config.kibana` section of the spec.  ## Test code ```js { $schema: https://vega.github.io/schema/vega/v3.json config: { kibana: { tooltips: { // always center on the mark, not mouse x,y centerOnMark: true position: top padding: 20 } } } data: [ { name: table values: [ { title: This is a long title fieldA: value of fld1 fld2: 42 } ] } ] marks: [ { from: {data: "table"} type: rect encode: { enter: { fill: {value: "#060"} x: {signal: "40"} y: {signal: "40"} width: {signal: "40"} height: {signal: "40"} tooltip: {signal: "datum || null"} } } } ] } ```
This commit is contained in:
parent
5a4263ad2d
commit
b300818612
11 changed files with 298 additions and 2 deletions
|
@ -205,6 +205,7 @@
|
|||
"validate-npm-package-name": "2.2.2",
|
||||
"vega-lib": "^3.3.1",
|
||||
"vega-lite": "^2.4.0",
|
||||
"vega-tooltip": "^0.9.14",
|
||||
"vega-schema-url-parser": "1.0.0",
|
||||
"vision": "4.1.0",
|
||||
"webpack": "3.6.0",
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
# This graph creates a single rectangle for the whole graph,
|
||||
# backed by a datum with two fields - fld1 & fld2
|
||||
# On mouse over, with 0 delay, it should show tooltip
|
||||
{
|
||||
v: 1
|
||||
config: {
|
||||
kibana: {
|
||||
tooltips: {
|
||||
// always center on the mark, not mouse x,y
|
||||
centerOnMark: false
|
||||
position: top
|
||||
padding: 20
|
||||
}
|
||||
}
|
||||
}
|
||||
data: [
|
||||
{
|
||||
name: table
|
||||
values: [
|
||||
{
|
||||
title: This is a long title
|
||||
fieldA: value of fld1
|
||||
fld2: 42
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
$schema: https://vega.github.io/schema/vega/v3.json
|
||||
marks: [
|
||||
{
|
||||
from: {data: "table"}
|
||||
type: rect
|
||||
encode: {
|
||||
enter: {
|
||||
fill: {value: "#060"}
|
||||
x: {signal: "0"}
|
||||
y: {signal: "0"}
|
||||
width: {signal: "width"}
|
||||
height: {signal: "height"}
|
||||
tooltip: {signal: "datum || null"}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -1,5 +1,7 @@
|
|||
import Promise from 'bluebird';
|
||||
import expect from 'expect.js';
|
||||
import ngMock from 'ng_mock';
|
||||
import $ from 'jquery';
|
||||
import { VegaVisualizationProvider } from '../vega_visualization';
|
||||
import LogstashIndexPatternStubProvider from 'fixtures/stubbed_logstash_index_pattern';
|
||||
import * as visModule from 'ui/vis';
|
||||
|
@ -12,6 +14,8 @@ import vegaliteImage512 from './vegalite_image_512.png';
|
|||
import vegaGraph from '!!raw-loader!./vega_graph.hjson';
|
||||
import vegaImage512 from './vega_image_512.png';
|
||||
|
||||
import vegaTooltipGraph from '!!raw-loader!./vega_tooltip_test.hjson';
|
||||
|
||||
import { VegaParser } from '../data_model/vega_parser';
|
||||
import { SearchCache } from '../data_model/search_cache';
|
||||
|
||||
|
@ -94,6 +98,52 @@ describe('VegaVisualizations', () => {
|
|||
|
||||
});
|
||||
|
||||
it('should show vegatooltip on mouseover over a vega graph', async () => {
|
||||
|
||||
let vegaVis;
|
||||
try {
|
||||
|
||||
vegaVis = new VegaVisualization(domNode, vis);
|
||||
const vegaParser = new VegaParser(vegaTooltipGraph, new SearchCache());
|
||||
await vegaParser.parseAsync();
|
||||
await vegaVis.render(vegaParser, { data: true });
|
||||
|
||||
|
||||
const $el = $(domNode);
|
||||
const offset = $el.offset();
|
||||
|
||||
const event = new MouseEvent('mousemove', {
|
||||
view: window,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: offset.left + 10,
|
||||
clientY: offset.top + 10,
|
||||
});
|
||||
|
||||
$el.find('canvas')[0].dispatchEvent(event);
|
||||
|
||||
await Promise.delay(10);
|
||||
|
||||
let tooltip = document.getElementById('vega-kibana-tooltip');
|
||||
expect(tooltip).to.be.ok();
|
||||
expect(tooltip.innerHTML).to.be(
|
||||
'<h2>This is a long title</h2>' +
|
||||
'<table><tbody>' +
|
||||
'<tr><td class="key">fieldA:</td><td class="value">value of fld1</td></tr>' +
|
||||
'<tr><td class="key">fld2:</td><td class="value">42</td></tr>' +
|
||||
'</tbody></table>');
|
||||
|
||||
vegaVis.destroy();
|
||||
|
||||
tooltip = document.getElementById('vega-kibana-tooltip');
|
||||
expect(tooltip).to.not.be.ok();
|
||||
|
||||
} finally {
|
||||
vegaVis.destroy();
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
|
|
@ -102,6 +102,40 @@ describe('VegaParser._parseSchema', () => {
|
|||
it('vega-lite old', test('https://vega.github.io/schema/vega-lite/v3.0.json', true, 1));
|
||||
});
|
||||
|
||||
describe('VegaParser._parseTooltips', () => {
|
||||
function test(tooltips, position, padding, centerOnMark) {
|
||||
return () => {
|
||||
const vp = new VegaParser(tooltips !== undefined ? { config: { kibana: { tooltips } } } : {});
|
||||
vp._config = vp._parseConfig();
|
||||
if (position === undefined) {
|
||||
// error
|
||||
expect(() => vp._parseTooltips()).to.throwError();
|
||||
} else if (position === false) {
|
||||
expect(vp._parseTooltips()).to.eql(false);
|
||||
} else {
|
||||
expect(vp._parseTooltips()).to.eql({ position, padding, centerOnMark });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
it('undefined', test(undefined, 'top', 16, 50));
|
||||
it('{}', test({}, 'top', 16, 50));
|
||||
it('left', test({ position: 'left' }, 'left', 16, 50));
|
||||
it('padding', test({ position: 'bottom', padding: 60 }, 'bottom', 60, 50));
|
||||
it('padding2', test({ padding: 70 }, 'top', 70, 50));
|
||||
it('centerOnMark', test({}, 'top', 16, 50));
|
||||
it('centerOnMark=10', test({ centerOnMark: 10 }, 'top', 16, 10));
|
||||
it('centerOnMark=true', test({ centerOnMark: true }, 'top', 16, Number.MAX_VALUE));
|
||||
it('centerOnMark=false', test({ centerOnMark: false }, 'top', 16, -1));
|
||||
|
||||
it('false', test(false, false));
|
||||
|
||||
it('err1', test(true, undefined));
|
||||
it('err2', test({ position: 'foo' }, undefined));
|
||||
it('err3', test({ padding: 'foo' }, undefined));
|
||||
it('err4', test({ centerOnMark: {} }, undefined));
|
||||
});
|
||||
|
||||
describe('VegaParser._parseMapConfig', () => {
|
||||
function test(config, expected, warnCount) {
|
||||
return () => {
|
||||
|
|
|
@ -67,6 +67,7 @@ export class VegaParser {
|
|||
this.hideWarnings = !!this._config.hideWarnings;
|
||||
this.useMap = this._config.type === 'map';
|
||||
this.renderer = this._config.renderer === 'svg' ? 'svg' : 'canvas';
|
||||
this.tooltips = this._parseTooltips();
|
||||
|
||||
this._setDefaultColors();
|
||||
this._parseControlPlacement(this._config);
|
||||
|
@ -211,6 +212,37 @@ export class VegaParser {
|
|||
return result || {};
|
||||
}
|
||||
|
||||
_parseTooltips() {
|
||||
if (this._config.tooltips === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = this._config.tooltips || {};
|
||||
|
||||
if (result.position === undefined) {
|
||||
result.position = 'top';
|
||||
} else if (['top', 'right', 'bottom', 'left'].indexOf(result.position) === -1) {
|
||||
throw new Error('Unexpected value for the result.position configuration');
|
||||
}
|
||||
|
||||
if (result.padding === undefined) {
|
||||
result.padding = 16;
|
||||
} else if (typeof result.padding !== 'number') {
|
||||
throw new Error('config.kibana.result.padding is expected to be a number');
|
||||
}
|
||||
|
||||
if (result.centerOnMark === undefined) {
|
||||
// if mark's width & height is less than this value, center on it
|
||||
result.centerOnMark = 50;
|
||||
} else if (typeof result.centerOnMark === 'boolean') {
|
||||
result.centerOnMark = result.centerOnMark ? Number.MAX_VALUE : -1;
|
||||
} else if (typeof result.centerOnMark !== 'number') {
|
||||
throw new Error('config.kibana.result.centerOnMark is expected to be true, false, or a number');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse map-specific configuration
|
||||
* @returns {{mapStyle: *|string, delayRepaint: boolean, latitude: number, longitude: number, zoom, minZoom, maxZoom, zoomControl: *|boolean, maxBounds: *}}
|
||||
|
|
|
@ -77,3 +77,34 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Style tooltip popup (gets created dynamically at the top level if dashboard has a Vega vis
|
||||
Adapted from https://github.com/vega/vega-tooltip
|
||||
*/
|
||||
#vega-kibana-tooltip {
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
table {
|
||||
border-spacing: 0;
|
||||
}
|
||||
td {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding-top: 2px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
td.key {
|
||||
color: #808080;
|
||||
max-width: 150px;
|
||||
text-align: right;
|
||||
padding-right: 4px;
|
||||
}
|
||||
td.value {
|
||||
max-width: 200px;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ import * as vega from 'vega-lib';
|
|||
import * as vegaLite from 'vega-lite';
|
||||
import { Utils } from '../data_model/utils';
|
||||
import { VISUALIZATION_COLORS } from '@elastic/eui';
|
||||
import { TooltipHandler } from './vega_tooltip';
|
||||
|
||||
vega.scheme('elastic', VISUALIZATION_COLORS);
|
||||
|
||||
|
@ -141,6 +142,20 @@ export class VegaBaseView {
|
|||
return false;
|
||||
}
|
||||
|
||||
setView(view) {
|
||||
this._view = view;
|
||||
|
||||
if (view && this._parser.tooltips) {
|
||||
// position and padding can be specified with
|
||||
// {config:{kibana:{tooltips: {position: 'top', padding: 15 } }}}
|
||||
const tthandler = new TooltipHandler(this._$container[0], view, this._parser.tooltips);
|
||||
|
||||
// Vega bug workaround - need to destroy tooltip by hand
|
||||
this._addDestroyHandler(() => tthandler.hideTooltip());
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set global debug variable to simplify vega debugging in console. Show info message first time
|
||||
*/
|
||||
|
|
|
@ -80,6 +80,7 @@ export class VegaMapView extends VegaBaseView {
|
|||
this._kibanaMap.addLayer(vegaMapLayer);
|
||||
|
||||
this.setDebugValues(vegaMapLayer.getVegaView(), vegaMapLayer.getVegaSpec());
|
||||
this.setView(vegaMapLayer.getVegaView());
|
||||
|
||||
this._addDestroyHandler(() => {
|
||||
this._kibanaMap.removeLayer(vegaMapLayer);
|
||||
|
|
79
src/core_plugins/vega/public/vega_view/vega_tooltip.js
Normal file
79
src/core_plugins/vega/public/vega_view/vega_tooltip.js
Normal file
|
@ -0,0 +1,79 @@
|
|||
import { calculatePopoverPosition } from '@elastic/eui';
|
||||
import { formatValue as createTooltipContent } from 'vega-tooltip';
|
||||
import _ from 'lodash';
|
||||
|
||||
// Some of this code was adapted from https://github.com/vega/vega-tooltip
|
||||
|
||||
const tooltipId = 'vega-kibana-tooltip';
|
||||
|
||||
/**
|
||||
* Simulate the result of the DOM's getBoundingClientRect()
|
||||
*/
|
||||
function createRect(left, top, width, height) {
|
||||
return {
|
||||
left, top, width, height,
|
||||
x: left, y: top, right: left + width, bottom: top + height,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The tooltip handler class.
|
||||
*/
|
||||
export class TooltipHandler {
|
||||
constructor(container, view, opts) {
|
||||
this.container = container;
|
||||
this.position = opts.position;
|
||||
this.padding = opts.padding;
|
||||
this.centerOnMark = opts.centerOnMark;
|
||||
|
||||
view.tooltip(this.handler.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* The handler function.
|
||||
*/
|
||||
handler(view, event, item, value) {
|
||||
this.hideTooltip();
|
||||
|
||||
// hide tooltip for null, undefined, or empty string values
|
||||
if (value == null || value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.setAttribute('id', tooltipId);
|
||||
el.classList.add('euiToolTipPopover', 'euiToolTip', `euiToolTip--${this.position}`);
|
||||
|
||||
// Sanitized HTML is created by the tooltip library,
|
||||
// with a largue nmuber of tests, hence supressing eslint here.
|
||||
// eslint-disable-next-line no-unsanitized/property
|
||||
el.innerHTML = createTooltipContent(value, _.escape);
|
||||
|
||||
// add to DOM to calculate tooltip size
|
||||
document.body.appendChild(el);
|
||||
|
||||
// if centerOnMark numeric value is smaller than the size of the mark, use mouse [x,y]
|
||||
let anchorBounds;
|
||||
if (item.bounds.width() > this.centerOnMark || item.bounds.height() > this.centerOnMark) {
|
||||
// I would expect clientX/Y, but that shows incorrectly
|
||||
anchorBounds = createRect(event.pageX, event.pageY, 0, 0);
|
||||
} else {
|
||||
const containerBox = this.container.getBoundingClientRect();
|
||||
anchorBounds = createRect(
|
||||
containerBox.left + view._origin[0] + item.bounds.x1,
|
||||
containerBox.top + view._origin[1] + item.bounds.y1,
|
||||
item.bounds.width(),
|
||||
item.bounds.height()
|
||||
);
|
||||
}
|
||||
|
||||
const pos = calculatePopoverPosition(anchorBounds, el.getBoundingClientRect(), this.position, this.padding);
|
||||
|
||||
el.setAttribute('style', `top: ${pos.top}px; left: ${pos.left}px`);
|
||||
}
|
||||
|
||||
hideTooltip() {
|
||||
const el = document.getElementById(tooltipId);
|
||||
if (el) el.remove();
|
||||
}
|
||||
}
|
|
@ -17,11 +17,12 @@ export class VegaView extends VegaBaseView {
|
|||
if (this._parser.useHover) view.hover();
|
||||
|
||||
this._addDestroyHandler(() => {
|
||||
this._view = null;
|
||||
this.setView(null);
|
||||
view.finalize();
|
||||
});
|
||||
|
||||
this.setView(view);
|
||||
|
||||
await view.runAsync();
|
||||
this._view = view;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13054,6 +13054,13 @@ vega-statistics@^1.2:
|
|||
dependencies:
|
||||
d3-array "1"
|
||||
|
||||
vega-tooltip@^0.9.14:
|
||||
version "0.9.14"
|
||||
resolved "https://registry.yarnpkg.com/vega-tooltip/-/vega-tooltip-0.9.14.tgz#c10bcacf69bf60a02c598ec46b905f94f28c54ac"
|
||||
dependencies:
|
||||
json-stringify-safe "^5.0.1"
|
||||
vega-util "^1.7.0"
|
||||
|
||||
vega-transforms@^1.2:
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/vega-transforms/-/vega-transforms-1.3.1.tgz#c570702760917a007a12cb35df9387270bfb6b21"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue