From e71719f2d358dc31a6b99e88082d1fba397359a9 Mon Sep 17 00:00:00 2001 From: Yuri Astrakhan Date: Fri, 27 Apr 2018 22:15:36 +0300 Subject: [PATCH] [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"} } } } ] } ``` --- package.json | 1 + .../public/__tests__/vega_tooltip_test.hjson | 45 +++++++++++ .../public/__tests__/vega_visualization.js | 50 ++++++++++++ .../data_model/__tests__/vega_parser.js | 34 ++++++++ .../vega/public/data_model/vega_parser.js | 32 ++++++++ src/core_plugins/vega/public/vega.less | 31 ++++++++ .../vega/public/vega_view/vega_base_view.js | 15 ++++ .../vega/public/vega_view/vega_map_view.js | 1 + .../vega/public/vega_view/vega_tooltip.js | 79 +++++++++++++++++++ .../vega/public/vega_view/vega_view.js | 5 +- yarn.lock | 7 ++ 11 files changed, 298 insertions(+), 2 deletions(-) create mode 100644 src/core_plugins/vega/public/__tests__/vega_tooltip_test.hjson create mode 100644 src/core_plugins/vega/public/vega_view/vega_tooltip.js diff --git a/package.json b/package.json index 398f7c47f8d5..09e15fb17baf 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/core_plugins/vega/public/__tests__/vega_tooltip_test.hjson b/src/core_plugins/vega/public/__tests__/vega_tooltip_test.hjson new file mode 100644 index 000000000000..20eb4a08a59c --- /dev/null +++ b/src/core_plugins/vega/public/__tests__/vega_tooltip_test.hjson @@ -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"} + } + } + } + ] +} diff --git a/src/core_plugins/vega/public/__tests__/vega_visualization.js b/src/core_plugins/vega/public/__tests__/vega_visualization.js index 4c523df592d0..f988fb4d244d 100644 --- a/src/core_plugins/vega/public/__tests__/vega_visualization.js +++ b/src/core_plugins/vega/public/__tests__/vega_visualization.js @@ -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( + '

This is a long title

' + + '' + + '' + + '' + + '
fieldA:value of fld1
fld2:42
'); + + vegaVis.destroy(); + + tooltip = document.getElementById('vega-kibana-tooltip'); + expect(tooltip).to.not.be.ok(); + + } finally { + vegaVis.destroy(); + } + + }); + }); diff --git a/src/core_plugins/vega/public/data_model/__tests__/vega_parser.js b/src/core_plugins/vega/public/data_model/__tests__/vega_parser.js index e8f9989157ed..14e49671cc2c 100644 --- a/src/core_plugins/vega/public/data_model/__tests__/vega_parser.js +++ b/src/core_plugins/vega/public/data_model/__tests__/vega_parser.js @@ -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 () => { diff --git a/src/core_plugins/vega/public/data_model/vega_parser.js b/src/core_plugins/vega/public/data_model/vega_parser.js index 5dcadffe0f30..b5796b31b6e6 100644 --- a/src/core_plugins/vega/public/data_model/vega_parser.js +++ b/src/core_plugins/vega/public/data_model/vega_parser.js @@ -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: *}} diff --git a/src/core_plugins/vega/public/vega.less b/src/core_plugins/vega/public/vega.less index 0b2a8e3b0040..f6e2340a7dbf 100644 --- a/src/core_plugins/vega/public/vega.less +++ b/src/core_plugins/vega/public/vega.less @@ -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; + } +} diff --git a/src/core_plugins/vega/public/vega_view/vega_base_view.js b/src/core_plugins/vega/public/vega_view/vega_base_view.js index dd161ac41618..8b7564b88760 100644 --- a/src/core_plugins/vega/public/vega_view/vega_base_view.js +++ b/src/core_plugins/vega/public/vega_view/vega_base_view.js @@ -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 */ diff --git a/src/core_plugins/vega/public/vega_view/vega_map_view.js b/src/core_plugins/vega/public/vega_view/vega_map_view.js index 4fa3186b9aeb..6ff9e84e7504 100644 --- a/src/core_plugins/vega/public/vega_view/vega_map_view.js +++ b/src/core_plugins/vega/public/vega_view/vega_map_view.js @@ -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); diff --git a/src/core_plugins/vega/public/vega_view/vega_tooltip.js b/src/core_plugins/vega/public/vega_view/vega_tooltip.js new file mode 100644 index 000000000000..93a86c05bac0 --- /dev/null +++ b/src/core_plugins/vega/public/vega_view/vega_tooltip.js @@ -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(); + } +} diff --git a/src/core_plugins/vega/public/vega_view/vega_view.js b/src/core_plugins/vega/public/vega_view/vega_view.js index d3640ab5d7c8..896649ecf871 100644 --- a/src/core_plugins/vega/public/vega_view/vega_view.js +++ b/src/core_plugins/vega/public/vega_view/vega_view.js @@ -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; } } diff --git a/yarn.lock b/yarn.lock index 345863492922..b185f0bcd139 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"