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:
parent
d74866dd0d
commit
e71719f2d3
|
@ -205,6 +205,7 @@
|
||||||
"validate-npm-package-name": "2.2.2",
|
"validate-npm-package-name": "2.2.2",
|
||||||
"vega-lib": "^3.3.1",
|
"vega-lib": "^3.3.1",
|
||||||
"vega-lite": "^2.4.0",
|
"vega-lite": "^2.4.0",
|
||||||
|
"vega-tooltip": "^0.9.14",
|
||||||
"vega-schema-url-parser": "1.0.0",
|
"vega-schema-url-parser": "1.0.0",
|
||||||
"vision": "4.1.0",
|
"vision": "4.1.0",
|
||||||
"webpack": "3.6.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 expect from 'expect.js';
|
||||||
import ngMock from 'ng_mock';
|
import ngMock from 'ng_mock';
|
||||||
|
import $ from 'jquery';
|
||||||
import { VegaVisualizationProvider } from '../vega_visualization';
|
import { VegaVisualizationProvider } from '../vega_visualization';
|
||||||
import LogstashIndexPatternStubProvider from 'fixtures/stubbed_logstash_index_pattern';
|
import LogstashIndexPatternStubProvider from 'fixtures/stubbed_logstash_index_pattern';
|
||||||
import * as visModule from 'ui/vis';
|
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 vegaGraph from '!!raw-loader!./vega_graph.hjson';
|
||||||
import vegaImage512 from './vega_image_512.png';
|
import vegaImage512 from './vega_image_512.png';
|
||||||
|
|
||||||
|
import vegaTooltipGraph from '!!raw-loader!./vega_tooltip_test.hjson';
|
||||||
|
|
||||||
import { VegaParser } from '../data_model/vega_parser';
|
import { VegaParser } from '../data_model/vega_parser';
|
||||||
import { SearchCache } from '../data_model/search_cache';
|
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));
|
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', () => {
|
describe('VegaParser._parseMapConfig', () => {
|
||||||
function test(config, expected, warnCount) {
|
function test(config, expected, warnCount) {
|
||||||
return () => {
|
return () => {
|
||||||
|
|
|
@ -67,6 +67,7 @@ export class VegaParser {
|
||||||
this.hideWarnings = !!this._config.hideWarnings;
|
this.hideWarnings = !!this._config.hideWarnings;
|
||||||
this.useMap = this._config.type === 'map';
|
this.useMap = this._config.type === 'map';
|
||||||
this.renderer = this._config.renderer === 'svg' ? 'svg' : 'canvas';
|
this.renderer = this._config.renderer === 'svg' ? 'svg' : 'canvas';
|
||||||
|
this.tooltips = this._parseTooltips();
|
||||||
|
|
||||||
this._setDefaultColors();
|
this._setDefaultColors();
|
||||||
this._parseControlPlacement(this._config);
|
this._parseControlPlacement(this._config);
|
||||||
|
@ -211,6 +212,37 @@ export class VegaParser {
|
||||||
return result || {};
|
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
|
* Parse map-specific configuration
|
||||||
* @returns {{mapStyle: *|string, delayRepaint: boolean, latitude: number, longitude: number, zoom, minZoom, maxZoom, zoomControl: *|boolean, maxBounds: *}}
|
* @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 * as vegaLite from 'vega-lite';
|
||||||
import { Utils } from '../data_model/utils';
|
import { Utils } from '../data_model/utils';
|
||||||
import { VISUALIZATION_COLORS } from '@elastic/eui';
|
import { VISUALIZATION_COLORS } from '@elastic/eui';
|
||||||
|
import { TooltipHandler } from './vega_tooltip';
|
||||||
|
|
||||||
vega.scheme('elastic', VISUALIZATION_COLORS);
|
vega.scheme('elastic', VISUALIZATION_COLORS);
|
||||||
|
|
||||||
|
@ -141,6 +142,20 @@ export class VegaBaseView {
|
||||||
return false;
|
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
|
* 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._kibanaMap.addLayer(vegaMapLayer);
|
||||||
|
|
||||||
this.setDebugValues(vegaMapLayer.getVegaView(), vegaMapLayer.getVegaSpec());
|
this.setDebugValues(vegaMapLayer.getVegaView(), vegaMapLayer.getVegaSpec());
|
||||||
|
this.setView(vegaMapLayer.getVegaView());
|
||||||
|
|
||||||
this._addDestroyHandler(() => {
|
this._addDestroyHandler(() => {
|
||||||
this._kibanaMap.removeLayer(vegaMapLayer);
|
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();
|
if (this._parser.useHover) view.hover();
|
||||||
|
|
||||||
this._addDestroyHandler(() => {
|
this._addDestroyHandler(() => {
|
||||||
this._view = null;
|
this.setView(null);
|
||||||
view.finalize();
|
view.finalize();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.setView(view);
|
||||||
|
|
||||||
await view.runAsync();
|
await view.runAsync();
|
||||||
this._view = view;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12990,6 +12990,13 @@ vega-statistics@^1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
d3-array "1"
|
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:
|
vega-transforms@^1.2:
|
||||||
version "1.3.1"
|
version "1.3.1"
|
||||||
resolved "https://registry.yarnpkg.com/vega-transforms/-/vega-transforms-1.3.1.tgz#c570702760917a007a12cb35df9387270bfb6b21"
|
resolved "https://registry.yarnpkg.com/vega-transforms/-/vega-transforms-1.3.1.tgz#c570702760917a007a12cb35df9387270bfb6b21"
|
||||||
|
|
Loading…
Reference in a new issue