[vega] support HTML tooltips (#17632) (#18642)

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.
![image](https://user-images.githubusercontent.com/1641515/39344487-7abc9eb0-49eb-11e8-89dd-bf562563ae1c.png)

## 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:
Yuri Astrakhan 2018-04-27 22:15:36 +03:00 committed by GitHub
parent d74866dd0d
commit e71719f2d3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 298 additions and 2 deletions

View file

@ -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",

View file

@ -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"}
}
}
}
]
}

View file

@ -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();
}
});
});

View file

@ -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 () => {

View file

@ -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: *}}

View file

@ -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;
}
}

View file

@ -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
*/

View file

@ -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);

View 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();
}
}

View file

@ -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;
}
}

View file

@ -12990,6 +12990,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"