Migrating vega_vis from plugin (#15014)

Large PR to migrate Vega plugin into the core.
https://github.com/nyurik/kibana-vega-vis

The code underwent a large number of changes and
reviews discussed in the PR thread:
https://github.com/elastic/kibana/pull/15014
This commit is contained in:
Yuri Astrakhan 2018-01-13 15:14:04 -05:00 committed by GitHub
parent c5001b1f55
commit 1b70e7400a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 3114 additions and 10 deletions

View file

@ -50,6 +50,7 @@ data sets.
<<tagcloud-chart,Tag cloud>>:: Display words as a cloud in which the size of the word correspond to its importance
<<markdown-widget,Markdown widget>>:: Display free-form information or
instructions.
<<vega-graph,Vega graph>>:: Support for user-defined graphs, external data sources, images, and user-defined interactivity.
. Specify a search query to retrieve the data for your visualization:
** To enter new search criteria, select the index pattern for the indices that
contain the data you want to visualize. This opens the visualization builder
@ -159,3 +160,5 @@ include::visualize/tagcloud.asciidoc[]
include::visualize/heatmap.asciidoc[]
include::visualize/visualization-raw-data.asciidoc[]
include::visualize/vega.asciidoc[]

View file

@ -0,0 +1,289 @@
[[vega-graph]]
== Vega Graphs
__________________________________________________________________________________________________________________________________________________________________________________
Build https://vega.github.io/vega/examples/[Vega] and
https://vega.github.io/vega-lite/examples/[VegaLite] data visualizations
into Kibana.
__________________________________________________________________________________________________________________________________________________________________________________
[[vega-introduction-video]]
=== Watch a short introduction video
https://www.youtube.com/watch?v=lQGCipY3th8[image:https://i.ytimg.com/vi_webp/lQGCipY3th8/maxresdefault.webp[Kibana Vega Visualization Video]]
[[vega-quick-demo]]
=== Quick Demo
* In Kibana, choose Visualize, and add Vega visualization.
* You should immediately see a default graph
* Try changing `mark` from `line` to `point`, `area`, `bar`, `circle`,
`square`, ... (see
https://vega.github.io/vega-lite/docs/mark.html#mark-def[docs])
* Try other https://vega.github.io/vega/examples/[Vega] or
https://vega.github.io/vega-lite/examples/[VegaLite] visualizations. You
may need to make URLs absolute, e.g. replace
`"url": "data/world-110m.json"` with
`"url": "https://vega.github.io/editor/data/world-110m.json"`. (see
link:#Using%20Vega%20and%20VegaLite%20examples[notes below])
* Using https://www.npmjs.com/package/makelogs[makelogs util], generate
some logstash data and try link:public/examples/logstash[logstash
examples]. *(Do not use makelogs on a production cluster!)*
[[vega-vs-vegalite]]
=== Vega vs VegaLite
VegaLite is a simplified version of Vega, useful to quickly get started,
but has a number of limitations. VegaLite is automatically converted
into Vega before rendering. Compare
link:public/examples/logstash/logstash-simple_line-vega.json[logstash-simple_line-vega]
and
link:public/examples/logstash/logstash-simple_line-vegalite.json[logstash-simple_line-vegalite]
(both use the same ElasticSearch logstash data). You may use
https://vega.github.io/editor/[this editor] to convert VegaLite into
Vega.
[[vega-querying-elasticsearch]]
== Querying ElasticSearch
By default, Vega's https://vega.github.io/vega/docs/data/[data] element
can use embedded and external data with a `"url"` parameter. Kibana adds support for the direct ElasticSearch queries by overloading
the `"url"` value.
Here is an example of an ES query that counts the number of documents in all indexes. The query uses *@timestamp* field to filter the time range, and break it into histogram buckets.
[source,yaml]
----
// An object instead of a string for the url value
// is treated as a context-aware Elasticsearch query.
url: {
// Filter the time picker (upper right corner) with this field
%timefield%: @timestamp
// Apply dashboard context filters when set
%context%: true
// Which indexes to search
index: _all
// The body element may contain "aggs" and "query" subfields
body: {
aggs: {
time_buckets: {
date_histogram: {
// Use date histogram aggregation on @timestamp field
field: @timestamp
// interval value will depend on the daterange picker
// Use an integer to set approximate bucket count
interval: { %autointerval%: true }
// Make sure we get an entire range, even if it has no data
extended_bounds: {
min: { %timefilter%: "min" }
max: { %timefilter%: "max" }
}
// Use this for linear (e.g. line, area) graphs
// Without it, empty buckets will not show up
min_doc_count: 0
}
}
}
// Speed up the response by only including aggregation results
size: 0
}
}
----
The full ES result has this kind of structure:
[source,yaml]
----
{
"aggregations": {
"time_buckets": {
"buckets": [{
"key_as_string": "2015-11-30T22:00:00.000Z",
"key": 1448920800000,
"doc_count": 28
}, {
"key_as_string": "2015-11-30T23:00:00.000Z",
"key": 1448924400000,
"doc_count": 330
}, ...
----
Note that `"key"` is a unix timestamp, and can be used without conversions by the
Vega date expressions.
For most graphs we only need the list of the bucket values, so we use `format: {property: "aggregations.time_buckets.buckets"}` expression to focus on just the data we need.
Query may be specified with individual range and dashboard context as
well. This query is equivalent to `"%context%": true, "%timefield%": "@timestamp"`,
except that the timerange is shifted back by 10 minutes:
[source,yaml]
----
{
body: {
query: {
bool: {
must: [
// This string will be replaced
// with the auto-generated "MUST" clause
"%dashboard_context-must_clause%"
{
range: {
// apply timefilter (upper right corner)
// to the @timestamp variable
@timestamp: {
// "%timefilter%" will be replaced with
// the current values of the time filter
// (from the upper right corner)
"%timefilter%": true
// Only work with %timefilter%
// Shift current timefilter by 10 units back
shift: 10
// week, day (default), hour, minute, second
unit: minute
}
}
}
]
must_not: [
// This string will be replaced with
// the auto-generated "MUST-NOT" clause
"%dashboard_context-must_not_clause%"
]
}
}
}
}
----
The `"%timefilter%"` can also be used to specify a single min or max
value. As shown above, the date_histogram's `extended_bounds` can be set
with two values - min and max. Instead of hardcoding a value, you may
use `"min": {"%timefilter%": "min"}`, which will be replaced with the
beginning of the current time range. The `shift` and `unit` values are
also supported. The `"interval"` can also be set dynamically, depending
on the currently picked range: `"interval": {"%autointerval%": 10}` will
try to get about 10-15 data points (buckets).
[[vega-esmfiles]]
=== Elastic Map Files
It is possible to access Elastic Map Service's files via the same mechanism
[source,yaml]
----
url: {
// "type" defaults to "elasticsearch" otherwise
type: emsfile
// Name of the file, exactly as in the Region map visualization
name: World Countries
}
// The result is a geojson file, get its features to use
// this data source with the "shape" marks
// https://vega.github.io/vega/docs/marks/shape/
format: {property: "features"}
----
[[vega-debugging]]
== Debugging
[[vega-browser-debugging-console]]
=== Browser Debugging console
Use browser debugging tools (e.g. F12 or Ctrl+Shift+J in Chrome) to
inspect the `VEGA_DEBUG` variable:
* `view` - access to the Vega View object. See https://vega.github.io/vega/docs/api/debugging/[Vega Debugging Guide]
on how to inspect data and signals at runtime. For VegaLite, `VEGA_DEBUG.view.data('source_0')` gets the main data set.
For Vega, it uses the data name as defined in your Vega spec.
* `vega_spec` - Vega JSON graph specification after some modifications by Kibana. In case
of VegaLite, this is the output of the VegaLite compiler.
* `vegalite_spec` - If this is a VegaLite graph, JSON specification of the graph before
VegaLite compilation.
[[vega-data]]
=== Data
If you are using ElasticSearch query, make sure your resulting data is
what you expected. The easiest way to view it is by using "networking"
tab in the browser debugging tools (e.g. F12). Modify the graph slightly
so that it makes a search request, and view the response from the
server. Another approach is to use
https://www.elastic.co/guide/en/kibana/current/console-kibana.html[Kibana
Dev Tools] tab - place the index name into the first line:
`GET <INDEX_NAME>/_search`, and add your query as the following lines
(just the value of the `"query"` field)
If you need to share your graph with someone, you may want to copy the
raw data response to https://gist.github.com/[gist.github.com], possibly
with a `.json` extension, use the `[raw]` button, and use that url
directly in your graph.
To restrict Vega from using non-ES data sources, add `vega.enableExternalUrls: false`
to your kibana.yml file.
[[vega-notes]]
== Notes
[[vega-useful-links]]
=== Useful Links
* https://vega.github.io/editor/[Editor] - includes examples for Vega &
VegaLite, but does not support any Kibana-specific features like
ElasticSearch requests and interactive base maps.
* VegaLite
https://vega.github.io/vega-lite/tutorials/getting_started.html[Tutorials],
https://vega.github.io/vega-lite/docs/[docs], and
https://vega.github.io/vega-lite/examples/[examples]
* Vega https://vega.github.io/vega/tutorials/[Tutorial],
https://vega.github.io/vega/docs/[docs],
https://vega.github.io/vega/examples/[examples]
[[vega-using-vega-and-vegalite-examples]]
==== Using Vega and VegaLite examples
When using https://vega.github.io/vega/examples/[Vega] and
https://vega.github.io/vega-lite/examples/[VegaLite] examples, you may
need to modify the "data" section to use absolute URL. For example,
replace `"url": "data/world-110m.json"` with
`"url": "https://vega.github.io/editor/data/world-110m.json"`. Also,
regular Vega examples use `"autosize": "pad"` layout model, whereas
Kibana uses `fit`. Remove all `autosize`, `width`, and `height`
values. See link:#sizing-and-positioning[sizing and positioning] below.
[[vega-additional-configuration-options]]
==== Additional configuration options
These options are specific to the Kibana.
[source,yaml]
----
{
config: {
kibana: {
// Placement of the Vega-defined signal bindings.
// Can be `left`, `right`, `top`, or `bottom` (default).
controlsLocation: top
// Can be `vertical` or `horizontal` (default).
controlsDirection: vertical
// If true, hides most of Vega and VegaLite warnings
hideWarnings: true
// Vega renderer to use: `svg` or `canvas` (default)
renderer: canvas
}
}
/* the rest of Vega code */
}
----
[[vega-sizing-and-positioning]]
==== Sizing and positioning
[[vega-and-vegalite]]
Vega and VegaLite
By default, Kibana Vega graphs will use
`autosize = { type: 'fit', contains: 'padding' }` layout model for Vega
and VegaLite graphs. The `fit` model uses all available space, ignores
`width` and `height` values, but respects the padding values. You may
override this behaviour by specifying a different `autosize` value.

View file

@ -37,7 +37,8 @@
"Nicolás Bevacqua <nico@elastic.co>",
"Shelby Sturgis <shelby@elastic.co>",
"Spencer Alger <spencer.alger@elastic.co>",
"Tim Sullivan <tim@elastic.co>"
"Tim Sullivan <tim@elastic.co>",
"Yuri Astrakhan <yuri@elastic.co>"
],
"scripts": {
"test": "grunt test",
@ -111,6 +112,7 @@
"check-hash": "1.0.1",
"color": "1.0.3",
"commander": "2.8.1",
"compare-versions": "3.1.0",
"css-loader": "0.28.7",
"d3": "3.5.6",
"d3-cloud": "1.2.1",
@ -132,6 +134,7 @@
"h2o2": "5.1.1",
"handlebars": "4.0.5",
"hapi": "14.2.0",
"hjson": "3.1.0",
"http-proxy-agent": "1.0.0",
"imports-loader": "0.7.1",
"inert": "4.0.2",
@ -140,6 +143,7 @@
"joi": "10.4.1",
"jquery": "3.2.1",
"js-yaml": "3.4.1",
"json-stringify-pretty-compact": "1.0.4",
"json-stringify-safe": "5.0.1",
"jstimezonedetect": "1.0.5",
"leaflet": "1.0.3",
@ -207,6 +211,9 @@
"url-loader": "0.5.9",
"uuid": "3.0.1",
"validate-npm-package-name": "2.2.2",
"vega": "3.0.8",
"vega-lite": "2.0.3",
"vega-schema-url-parser": "1.0.0",
"vision": "4.1.0",
"webpack": "3.6.0",
"webpack-merge": "4.1.0",

View file

@ -32,7 +32,11 @@ window.__KBN__ = {
},
mapConfig: {
manifestServiceUrl: 'https://staging-dot-catalogue-dot-elastic-layer.appspot.com/v1/manifest'
}
},
vegaConfig: {
enabled: true,
enableExternalUrls: true
},
},
uiSettings: {
defaults: ${JSON.stringify(defaultUiSettings, null, 2).split('\n').join('\n ')},

View file

@ -0,0 +1,15 @@
export default kibana => new kibana.Plugin({
id: 'vega',
require: ['elasticsearch'],
uiExports: {
visTypes: ['plugins/vega/vega_type'],
injectDefaultVars: server => ({ vegaConfig: server.config().get('vega') }),
},
config: (Joi) => Joi.object({
enabled: Joi.boolean().default(true),
enableExternalUrls: Joi.boolean().default(false)
}).default(),
});

View file

@ -0,0 +1,6 @@
{
"author": "Yuri Astrakhan<yuri@elastic.co>",
"name": "vega",
"version": "kibana"
}

View file

@ -0,0 +1,76 @@
{
// Adapted from Vega's https://vega.github.io/vega/examples/stacked-area-chart/
$schema: https://vega.github.io/schema/vega/v3.0.json
data: [
{
name: table
values: [
{x: 0, y: 28, c: 0}, {x: 0, y: 55, c: 1}, {x: 1, y: 43, c: 0}, {x: 1, y: 91, c: 1},
{x: 2, y: 81, c: 0}, {x: 2, y: 53, c: 1}, {x: 3, y: 19, c: 0}, {x: 3, y: 87, c: 1},
{x: 4, y: 52, c: 0}, {x: 4, y: 48, c: 1}, {x: 5, y: 24, c: 0}, {x: 5, y: 49, c: 1},
{x: 6, y: 87, c: 0}, {x: 6, y: 66, c: 1}, {x: 7, y: 17, c: 0}, {x: 7, y: 27, c: 1},
{x: 8, y: 68, c: 0}, {x: 8, y: 16, c: 1}, {x: 9, y: 49, c: 0}, {x: 9, y: 15, c: 1}
]
transform: [
{
type: stack
groupby: ["x"]
sort: {field: "c"}
field: y
}
]
}
]
scales: [
{
name: x
type: point
range: width
domain: {data: "table", field: "x"}
}
{
name: y
type: linear
range: height
nice: true
zero: true
domain: {data: "table", field: "y1"}
}
{
name: color
type: ordinal
range: category
domain: {data: "table", field: "c"}
}
]
marks: [
{
type: group
from: {
facet: {name: "series", data: "table", groupby: "c"}
}
marks: [
{
type: area
from: {data: "series"}
encode: {
enter: {
interpolate: {value: "monotone"}
x: {scale: "x", field: "x"}
y: {scale: "y", field: "y0"}
y2: {scale: "y", field: "y1"}
fill: {scale: "color", field: "c"}
}
update: {
fillOpacity: {value: 1}
}
hover: {
fillOpacity: {value: 0.5}
}
}
}
]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View file

@ -0,0 +1,124 @@
import expect from 'expect.js';
import ngMock from 'ng_mock';
import { VegaVisualizationProvider } from '../vega_visualization';
import LogstashIndexPatternStubProvider from 'fixtures/stubbed_logstash_index_pattern';
import * as visModule from 'ui/vis';
import { ImageComparator } from 'test_utils/image_comparator';
import vegaliteGraph from '!!raw-loader!./vegalite_graph.hjson';
import vegaliteImage256 from './vegalite_image_256.png';
import vegaliteImage512 from './vegalite_image_512.png';
import vegaGraph from '!!raw-loader!./vega_graph.hjson';
import vegaImage512 from './vega_image_512.png';
import { VegaParser } from '../data_model/vega_parser';
import { SearchCache } from '../data_model/search_cache';
const THRESHOLD = 0.10;
const PIXEL_DIFF = 10;
describe('VegaVisualizations', () => {
let domNode;
let VegaVisualization;
let Vis;
let indexPattern;
let vis;
let imageComparator;
beforeEach(ngMock.module('kibana'));
beforeEach(ngMock.inject((Private) => {
Vis = Private(visModule.VisProvider);
VegaVisualization = Private(VegaVisualizationProvider);
indexPattern = Private(LogstashIndexPatternStubProvider);
}));
describe('VegaVisualization - basics', () => {
beforeEach(async function () {
setupDOM('512px', '512px');
imageComparator = new ImageComparator();
vis = new Vis(indexPattern, { type: 'vega' });
});
afterEach(function () {
teardownDOM();
imageComparator.destroy();
});
it('should show vegalite graph and update on resize', async function () {
let vegaVis;
try {
vegaVis = new VegaVisualization(domNode, vis);
const vegaParser = new VegaParser(vegaliteGraph, new SearchCache());
await vegaParser.parseAsync();
await vegaVis.render(vegaParser, { data: true });
const mismatchedPixels1 = await compareImage(vegaliteImage512);
expect(mismatchedPixels1).to.be.lessThan(PIXEL_DIFF);
domNode.style.width = '256px';
domNode.style.height = '256px';
await vegaVis.render(vegaParser, { resize: true });
const mismatchedPixels2 = await compareImage(vegaliteImage256);
expect(mismatchedPixels2).to.be.lessThan(PIXEL_DIFF);
} finally {
vegaVis.destroy();
}
});
it('should show vega graph', async function () {
let vegaVis;
try {
vegaVis = new VegaVisualization(domNode, vis);
const vegaParser = new VegaParser(vegaGraph, new SearchCache());
await vegaParser.parseAsync();
await vegaVis.render(vegaParser, { data: true });
const mismatchedPixels = await compareImage(vegaImage512);
expect(mismatchedPixels).to.be.lessThan(PIXEL_DIFF);
} finally {
vegaVis.destroy();
}
});
});
async function compareImage(expectedImageSource) {
const elementList = domNode.querySelectorAll('canvas');
expect(elementList.length).to.equal(1);
const firstCanvasOnMap = elementList[0];
return imageComparator.compareImage(firstCanvasOnMap, expectedImageSource, THRESHOLD);
}
function setupDOM(width, height) {
domNode = document.createElement('div');
domNode.style.top = '0';
domNode.style.left = '0';
domNode.style.width = width;
domNode.style.height = height;
domNode.style.position = 'fixed';
domNode.style.border = '1px solid blue';
domNode.style['pointer-events'] = 'none';
document.body.appendChild(domNode);
}
function teardownDOM() {
domNode.innerHTML = '';
document.body.removeChild(domNode);
}
});

View file

@ -0,0 +1,45 @@
{
$schema: https://vega.github.io/schema/vega-lite/v2.json
data: {
format: {property: "aggregations.time_buckets.buckets"}
values: {
aggregations: {
time_buckets: {
buckets: [
{key: 1512950400000, doc_count: 0}
{key: 1513036800000, doc_count: 0}
{key: 1513123200000, doc_count: 0}
{key: 1513209600000, doc_count: 4545}
{key: 1513296000000, doc_count: 4667}
{key: 1513382400000, doc_count: 4660}
{key: 1513468800000, doc_count: 133}
{key: 1513555200000, doc_count: 0}
{key: 1513641600000, doc_count: 0}
{key: 1513728000000, doc_count: 0}
]
}
}
status: 200
}
}
mark: line
encoding: {
x: {
field: key
type: temporal
axis: null
}
y: {
field: doc_count
type: quantitative
axis: null
}
}
config: {
range: {
category: {scheme: "elastic"}
}
mark: {color: "#00A69B"}
}
autosize: {type: "fit", contains: "padding"}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View file

@ -0,0 +1,131 @@
import _ from 'lodash';
import expect from 'expect.js';
import { EsQueryParser } from '../es_query_parser';
const second = 1000;
const minute = 60 * second;
const hour = 60 * minute;
const day = 24 * hour;
const rangeStart = 10 * day;
const rangeEnd = 12 * day;
const ctxArr = { bool: { must: [{ match_all: { c: 3 } }], must_not: [{ d: 4 }] } };
const ctxObj = { bool: { must: { match_all: { a: 1 } }, must_not: { b: 2 } } };
function create(min, max, dashboardCtx) {
const inst = new EsQueryParser(
{
getTimeBounds: () => ({ min, max })
},
() => {},
() => _.cloneDeep(dashboardCtx),
() => (inst.$$$warnCount = (inst.$$$warnCount || 0) + 1)
);
return inst;
}
describe(`EsQueryParser time`, () => {
it(`roundInterval(4s)`, () => expect(EsQueryParser._roundInterval(4 * second)).to.be(`1s`));
it(`roundInterval(4hr)`, () => expect(EsQueryParser._roundInterval(4 * hour)).to.be(`3h`));
it(`getTimeBound`, () => expect(create(1000, 2000)._getTimeBound({}, `min`)).to.be(1000));
it(`getTimeBound(shift 2d)`, () => expect(create(5, 2000)._getTimeBound({ shift: 2 }, `min`)).to.be(5 + 2 * day));
it(`getTimeBound(shift -2hr)`, () => expect(create(10 * day, 20 * day)
._getTimeBound({ shift: -2, unit: `h` }, `min`))
.to.be(10 * day - 2 * hour));
it(`createRangeFilter({})`, () => {
const obj = {};
expect(create(1000, 2000)._createRangeFilter(obj))
.to.eql({ format: `epoch_millis`, gte: 1000, lte: 2000 }).and.to.be(obj);
});
it(`createRangeFilter(shift 1s)`, () => {
const obj = { shift: 5, unit: 's' };
expect(create(1000, 2000)._createRangeFilter(obj))
.to.eql({ format: `epoch_millis`, gte: 6000, lte: 7000 }).and.to.be(obj);
});
});
describe(`EsQueryParser.injectQueryContextVars`, () => {
function test(obj, expected, ctx) {
return () => {
create(rangeStart, rangeEnd, ctx)._injectContextVars(obj, true);
expect(obj).to.eql(expected);
};
}
it(`empty`, test({}, {}));
it(`simple`, () => {
const obj = { a: { c: 10 }, b: [{ d: 2 }, 4, 5], c: [], d: {} };
test(obj, _.cloneDeep(obj));
});
it(`must clause empty`, test({ arr: ['%dashboard_context-must_clause%'] }, { arr: [] }, {}));
it(`must clause arr`, test({ arr: ['%dashboard_context-must_clause%'] }, { arr: [...ctxArr.bool.must] }, ctxArr));
it(`must clause obj`, test({ arr: ['%dashboard_context-must_clause%'] }, { arr: [ctxObj.bool.must] }, ctxObj));
it(`mixed clause arr`, test(
{ arr: [1, '%dashboard_context-must_clause%', 2, '%dashboard_context-must_not_clause%'] },
{ arr: [1, ...ctxArr.bool.must, 2, ...ctxArr.bool.must_not] }, ctxArr));
it(`mixed clause obj`, test(
{ arr: ['%dashboard_context-must_clause%', 1, '%dashboard_context-must_not_clause%', 2] },
{ arr: [ctxObj.bool.must, 1, ctxObj.bool.must_not, 2] }, ctxObj));
it(`%autointerval% = true`, test({ interval: { '%autointerval%': true } }, { interval: `1h` }, ctxObj));
it(`%autointerval% = 10`, test({ interval: { '%autointerval%': 10 } }, { interval: `3h` }, ctxObj));
it(`%timefilter% = min`, test({ a: { '%timefilter%': 'min' } }, { a: rangeStart }));
it(`%timefilter% = max`, test({ a: { '%timefilter%': 'max' } }, { a: rangeEnd }));
it(`%timefilter% = true`, test(
{ a: { '%timefilter%': true } },
{ a: { format: `epoch_millis`, gte: rangeStart, lte: rangeEnd } }));
});
describe(`EsQueryParser.parseEsRequest`, () => {
function test(req, ctx, expected) {
return () => {
create(rangeStart, rangeEnd, ctx).parseUrl({}, req);
expect(req).to.eql(expected);
};
}
it(`%context_query%=true`, test(
{ index: '_all', '%context_query%': true }, ctxArr,
{ index: '_all', body: { query: ctxArr } }));
it(`%context%=true`, test(
{ index: '_all', '%context%': true }, ctxArr,
{ index: '_all', body: { query: ctxArr } }));
const expectedForCtxAndTimefield = {
index: '_all',
body: {
query: {
bool: {
must: [
{ match_all: { c: 3 } },
{ range: { abc: { format: 'epoch_millis', gte: rangeStart, lte: rangeEnd } } }
],
must_not: [{ 'd': 4 }]
}
}
}
};
it(`%context_query%='abc'`, test(
{ index: '_all', '%context_query%': 'abc' }, ctxArr, expectedForCtxAndTimefield));
it(`%context%=true, %timefield%='abc'`, test(
{ index: '_all', '%context%': true, '%timefield%': 'abc' }, ctxArr, expectedForCtxAndTimefield));
it(`%timefield%='abc'`, test({ index: '_all', '%timefield%': 'abc' }, ctxArr,
{
index: '_all',
body: { query: { range: { abc: { format: 'epoch_millis', gte: rangeStart, lte: rangeEnd } } } }
}
));
it(`no esRequest`, test({ index: '_all' }, ctxArr, { index: '_all', body: {} }));
it(`esRequest`, test({ index: '_all', body: { query: 2 } }, ctxArr, {
index: '_all',
body: { query: 2 }
}));
});

View file

@ -0,0 +1,54 @@
import expect from 'expect.js';
import { SearchCache } from '../search_cache';
describe(`SearchCache`, () => {
class FauxEs {
constructor() {
// contains all request batches, separated by 0
this.searches = [];
}
async search(request) {
this.searches.push(request);
return { req: request };
}
}
const request1 = { body: 'b1' };
const expected1 = { req: { body: 'b1' } };
const request2 = { body: 'b2' };
const expected2 = { req: { body: 'b2' } };
const request3 = { body: 'b3' };
const expected3 = { req: { body: 'b3' } };
it(`sequence`, async () => {
const sc = new SearchCache(new FauxEs());
// empty request
let res = await sc.search([]);
expect(res).to.eql([]);
expect(sc._es.searches).to.eql([]);
// single request
res = await sc.search([request1]);
expect(res).to.eql([expected1]);
expect(sc._es.searches).to.eql([request1]);
// repeat the same search, use array notation
res = await sc.search([request1]);
expect(res).to.eql([expected1]);
expect(sc._es.searches).to.eql([request1]); // no new entries
// new single search
res = await sc.search([request2]);
expect(res).to.eql([expected2]);
expect(sc._es.searches).to.eql([request1, request2]);
// multiple search, some new, some old
res = await sc.search([request1, request3, request2]);
expect(res).to.eql([expected1, expected3, expected2]);
expect(sc._es.searches).to.eql([request1, request2, request3]);
});
});

View file

@ -0,0 +1,82 @@
import expect from 'expect.js';
import { TimeCache } from '../time_cache';
describe(`TimeCache`, () => {
class FauxTimefilter {
constructor(min, max) {
// logs all requests
this.searches = [];
this.time = {};
this.setTime(min, max);
this._accessCount = 0;
}
setTime(min, max) {
this._min = min;
this._max = max;
}
getBounds() {
this._accessCount++;
return {
min: { valueOf: () => this._min },
max: { valueOf: () => this._max },
};
}
}
class FauxTime {
constructor() {
this._time = 10000;
this._accessCount = 0;
}
now() {
this._accessCount++;
return this._time;
}
increment(inc) {
this._time += inc;
}
}
it(`sequence`, async () => {
const timefilter = new FauxTimefilter(10000, 20000, 'quick');
const tc = new TimeCache(timefilter, 5000);
const time = new FauxTime();
tc._now = () => time.now();
let timeAccess = 0;
let filterAccess = 0;
// first call - gets bounds
expect(tc.getTimeBounds()).to.eql({ min: 10000, max: 20000 });
expect(time._accessCount).to.be(++timeAccess);
expect(timefilter._accessCount).to.be(++filterAccess);
// short diff, same result
time.increment(10);
timefilter.setTime(10010, 20010);
expect(tc.getTimeBounds()).to.eql({ min: 10000, max: 20000 });
expect(time._accessCount).to.be(++timeAccess);
expect(timefilter._accessCount).to.be(filterAccess);
// longer diff, gets bounds but returns original
time.increment(200);
timefilter.setTime(10210, 20210);
expect(tc.getTimeBounds()).to.eql({ min: 10000, max: 20000 });
expect(time._accessCount).to.be(++timeAccess);
expect(timefilter._accessCount).to.be(++filterAccess);
// long diff, new result
time.increment(10000);
timefilter.setTime(20220, 30220);
expect(tc.getTimeBounds()).to.eql({ min: 20220, max: 30220 });
expect(time._accessCount).to.be(++timeAccess);
expect(timefilter._accessCount).to.be(++filterAccess);
});
});

View file

@ -0,0 +1,192 @@
import _ from 'lodash';
import expect from 'expect.js';
import { VegaParser } from '../vega_parser';
describe(`VegaParser._setDefaultValue`, () => {
function test(spec, expected, ...params) {
return () => {
const vp = new VegaParser(spec);
vp._setDefaultValue(...params);
expect(vp.spec).to.eql(expected);
expect(vp.warnings).to.have.length(0);
};
}
it(`empty`, test({}, { config: { test: 42 } }, 42, 'config', 'test'));
it(`exists`, test({ config: { test: 42 } }, { config: { test: 42 } }, 1, 'config', 'test'));
it(`exists non-obj`, test({ config: false }, { config: false }, 42, 'config', 'test'));
});
describe(`VegaParser._setDefaultColors`, () => {
function test(spec, isVegaLite, expected) {
return () => {
const vp = new VegaParser(spec);
vp.isVegaLite = isVegaLite;
vp._setDefaultColors();
expect(vp.spec).to.eql(expected);
expect(vp.warnings).to.have.length(0);
};
}
it(`vegalite`, test({}, true, {
config: {
range: { category: { scheme: 'elastic' } },
mark: { color: '#00A69B' }
}
}));
it(`vega`, test({}, false, {
config: {
range: { category: { scheme: 'elastic' } },
arc: { fill: '#00A69B' },
area: { fill: '#00A69B' },
line: { stroke: '#00A69B' },
path: { stroke: '#00A69B' },
rect: { fill: '#00A69B' },
rule: { stroke: '#00A69B' },
shape: { stroke: '#00A69B' },
symbol: { fill: '#00A69B' },
trail: { fill: '#00A69B' }
}
}));
});
describe('VegaParser._resolveEsQueries', () => {
function test(spec, expected, warnCount) {
return async () => {
const vp = new VegaParser(spec, { search: async () => [[42]] }, 0, 0, {
getFileLayers: async () => [{ name: 'file1', url: 'url1' }]
});
await vp._resolveDataUrls();
expect(vp.spec).to.eql(expected);
expect(vp.warnings).to.have.length(warnCount || 0);
};
}
it('no data', test({}, {}));
it('no data2', test({ a: 1 }, { a: 1 }));
it('non-es data', test({ data: { a: 10 } }, { data: { a: 10 } }));
it('es', test({ data: { url: { index: 'a' }, x: 1 } }, { data: { values: [42], x: 1 } }));
it('es', test({ data: { url: { '%type%': 'elasticsearch', index: 'a' } } }, { data: { values: [42] } }));
it('es arr', test({ arr: [{ data: { url: { index: 'a' }, x: 1 } }] }, { arr: [{ data: { values: [42], x: 1 } }] }));
it('emsfile', test({ data: { url: { '%type%': 'emsfile', name: 'file1' } } }, { data: { url: 'url1' } }));
});
describe('VegaParser._parseSchema', () => {
function test(schema, isVegaLite, warningCount) {
return () => {
const vp = new VegaParser({ $schema: schema });
expect(vp._parseSchema()).to.be(isVegaLite);
expect(vp.spec).to.eql({ $schema: schema });
expect(vp.warnings).to.have.length(warningCount);
};
}
it('no schema', () => {
const vp = new VegaParser({});
expect(vp._parseSchema()).to.be(false);
expect(vp.spec).to.eql({ $schema: 'https://vega.github.io/schema/vega/v3.0.json' });
expect(vp.warnings).to.have.length(1);
});
it('vega', test('https://vega.github.io/schema/vega/v3.0.json', false, 0));
it('vega old', test('https://vega.github.io/schema/vega/v4.0.json', false, 1));
it('vega-lite', test('https://vega.github.io/schema/vega-lite/v2.0.json', true, 0));
it('vega-lite old', test('https://vega.github.io/schema/vega-lite/v3.0.json', true, 1));
});
describe('VegaParser._parseMapConfig', () => {
function test(config, expected, warnCount) {
return () => {
const vp = new VegaParser();
vp._config = config;
expect(vp._parseMapConfig()).to.eql(expected);
expect(vp.warnings).to.have.length(warnCount);
};
}
it('empty', test({}, {
delayRepaint: true,
latitude: 0,
longitude: 0,
mapStyle: 'default',
zoomControl: true
}, 0));
it('filled', test({
delayRepaint: true,
latitude: 0,
longitude: 0,
mapStyle: 'default',
zoomControl: true,
maxBounds: [1, 2, 3, 4],
}, {
delayRepaint: true,
latitude: 0,
longitude: 0,
mapStyle: 'default',
zoomControl: true,
maxBounds: [1, 2, 3, 4],
}, 0));
it('warnings', test({
delayRepaint: true,
latitude: 0,
longitude: 0,
zoom: 'abc', // ignored
mapStyle: 'abc',
zoomControl: 'abc',
maxBounds: [2, 3, 4],
}, {
delayRepaint: true,
latitude: 0,
longitude: 0,
mapStyle: 'default',
zoomControl: true,
}, 4));
});
describe('VegaParser._parseConfig', () => {
function test(spec, expectedConfig, expectedSpec, warnCount) {
return async () => {
expectedSpec = expectedSpec || _.cloneDeep(spec);
const vp = new VegaParser(spec);
const config = await vp._parseConfig();
expect(config).to.eql(expectedConfig);
expect(vp.spec).to.eql(expectedSpec);
expect(vp.warnings).to.have.length(warnCount || 0);
};
}
it('no config', test({}, {}, {}));
it('simple config', test({ config: { a: 1 } }, {}));
it('kibana config', test({ config: { kibana: { a: 1 } } }, { a: 1 }, { config: {} }));
it('_hostConfig', test({ _hostConfig: { a: 1 } }, { a: 1 }, {}, 1));
});
describe('VegaParser._calcSizing', () => {
function test(spec, useResize, paddingWidth, paddingHeight, isVegaLite, expectedSpec, warnCount) {
return async () => {
expectedSpec = expectedSpec || _.cloneDeep(spec);
const vp = new VegaParser(spec);
vp.isVegaLite = !!isVegaLite;
vp._calcSizing();
expect(vp.useResize).to.eql(useResize);
expect(vp.paddingWidth).to.eql(paddingWidth);
expect(vp.paddingHeight).to.eql(paddingHeight);
expect(vp.spec).to.eql(expectedSpec);
expect(vp.warnings).to.have.length(warnCount || 0);
};
}
it('no size', test({ autosize: {} }, false, 0, 0));
it('fit', test({ autosize: 'fit' }, true, 0, 0));
it('fit obj', test({ autosize: { type: 'fit' } }, true, 0, 0));
it('padding const', test({ autosize: 'fit', padding: 10 }, true, 20, 20));
it('padding obj', test({ autosize: 'fit', padding: { left: 5, bottom: 7, right: 6, top: 8 } }, true, 11, 15));
it('width height', test({ autosize: 'fit', width: 1, height: 2 }, true, 0, 0, false, false, 1));
it('VL width height', test({ autosize: 'fit', width: 1, height: 2 }, true, 0, 0, true, { autosize: 'fit' }, 0));
});

View file

@ -0,0 +1,45 @@
/**
* This class processes all Vega spec customizations,
* converting url object parameters into query results.
*/
export class EmsFileParser {
constructor(serviceSettings) {
this._serviceSettings = serviceSettings;
}
// noinspection JSMethodCanBeStatic
/**
* Update request object, expanding any context-aware keywords
*/
parseUrl(obj, url) {
if (typeof url.name !== 'string') {
throw new Error(`data.url with {"%type%": "emsfile"} is missing the "name" of the file`);
}
// Optimization: so initiate remote request as early as we know that we will need it
if (!this._fileLayersP) {
this._fileLayersP = this._serviceSettings.getFileLayers();
}
return { obj, name: url.name };
}
/**
* Process items generated by parseUrl()
* @param {object[]} requests each object is generated by parseUrl()
* @returns {Promise<void>}
*/
async populateData(requests) {
if (requests.length === 0) return;
const layers = await this._fileLayersP;
for (const { obj, name } of requests) {
const foundLayer = layers.find(v => v.name === name);
if (!foundLayer) {
throw new Error(`emsfile ${JSON.stringify(name)} does not exist`);
}
obj.url = foundLayer.url;
}
}
}

View file

@ -0,0 +1,286 @@
import _ from 'lodash';
const TIMEFILTER = '%timefilter%';
const AUTOINTERVAL = '%autointerval%';
const MUST_CLAUSE = '%dashboard_context-must_clause%';
const MUST_NOT_CLAUSE = '%dashboard_context-must_not_clause%';
// These values may appear in the 'url': { ... } object
const LEGACY_CONTEXT = '%context_query%';
const CONTEXT = '%context%';
const TIMEFIELD = '%timefield%';
/**
* This class parses ES requests specified in the data.url objects.
*/
export class EsQueryParser {
constructor(timeCache, searchCache, dashboardContext, onWarning) {
this._timeCache = timeCache;
this._searchCache = searchCache;
this._dashboardContext = dashboardContext;
this._onWarning = onWarning;
}
// noinspection JSMethodCanBeStatic
/**
* Update request object, expanding any context-aware keywords
*/
parseUrl(dataObject, url) {
let body = url.body;
let context = url[CONTEXT];
delete url[CONTEXT];
let timefield = url[TIMEFIELD];
delete url[TIMEFIELD];
let usesContext = context !== undefined || timefield !== undefined;
if (body === undefined) {
url.body = body = {};
} else if (!_.isPlainObject(body)) {
throw new Error('url.body must be an object');
}
// Migrate legacy %context_query% into context & timefield values
const legacyContext = url[LEGACY_CONTEXT];
delete url[LEGACY_CONTEXT];
if (legacyContext !== undefined) {
if (body.query !== undefined) {
throw new Error(`Data url must not have legacy "${LEGACY_CONTEXT}" and "body.query" values at the same time`);
} else if (usesContext) {
throw new Error(`Data url must not have "${LEGACY_CONTEXT}" together with "${CONTEXT}" or "${TIMEFIELD}"`);
} else if (legacyContext !== true && (typeof legacyContext !== 'string' || legacyContext.length === 0)) {
throw new Error(`Legacy "${LEGACY_CONTEXT}" can either be true (ignores time range picker), ` +
'or it can be the name of the time field, e.g. "@timestamp"');
}
usesContext = true;
context = true;
let result = `"url": {"${CONTEXT}": true`;
if (typeof legacyContext === 'string') {
timefield = legacyContext;
result += `, "${TIMEFIELD}": ${JSON.stringify(timefield)}`;
}
result += '}';
this._onWarning(
`Legacy "url": {"${LEGACY_CONTEXT}": ${JSON.stringify(legacyContext)}} should change to ${result}`);
}
if (body.query !== undefined) {
if (usesContext) {
throw new Error(`url.${CONTEXT} and url.${TIMEFIELD} must not be used when url.body.query is set`);
}
this._injectContextVars(body.query, true);
} else if (usesContext) {
if (timefield) {
// Inject range filter based on the timefilter values
body.query = { range: { [timefield]: this._createRangeFilter({ [TIMEFILTER]: true }) } };
}
if (context) {
// Use dashboard context
const newQuery = this._dashboardContext();
if (timefield) {
newQuery.bool.must.push(body.query);
}
body.query = newQuery;
}
}
this._injectContextVars(body.aggs, false);
return { dataObject, url };
}
/**
* Process items generated by parseUrl()
* @param {object[]} requests each object is generated by parseUrl()
* @returns {Promise<void>}
*/
async populateData(requests) {
const esSearches = requests.map((r) => r.url);
const results = await this._searchCache.search(esSearches);
for (let i = 0; i < requests.length; i++) {
requests[i].dataObject.values = results[i];
}
}
/**
* Modify ES request by processing magic keywords
* @param {*} obj
* @param {boolean} isQuery - if true, the `obj` belongs to the req's query portion
*/
_injectContextVars(obj, isQuery) {
if (obj && typeof obj === 'object') {
if (Array.isArray(obj)) {
// For arrays, replace MUST_CLAUSE and MUST_NOT_CLAUSE string elements
for (let pos = 0; pos < obj.length;) {
const item = obj[pos];
if (isQuery && (item === MUST_CLAUSE || item === MUST_NOT_CLAUSE)) {
const ctxTag = item === MUST_CLAUSE ? 'must' : 'must_not';
const ctx = this._dashboardContext();
if (ctx && ctx.bool && ctx.bool[ctxTag]) {
if (Array.isArray(ctx.bool[ctxTag])) {
// replace one value with an array of values
obj.splice(pos, 1, ...ctx.bool[ctxTag]);
pos += ctx.bool[ctxTag].length;
} else {
obj[pos++] = ctx.bool[ctxTag];
}
} else {
obj.splice(pos, 1); // remove item, keep pos at the same position
}
} else {
this._injectContextVars(item, isQuery);
pos++;
}
}
} else {
for (const prop of Object.keys(obj)) {
const subObj = obj[prop];
if (!subObj || typeof obj !== 'object') continue;
// replace "interval": { "%autointerval%": true|integer } with
// auto-generated range based on the timepicker
if (prop === 'interval' && subObj[AUTOINTERVAL]) {
let size = subObj[AUTOINTERVAL];
if (size === true) {
size = 50; // by default, try to get ~80 values
} else if (typeof size !== 'number') {
throw new Error(`"${AUTOINTERVAL}" must be either true or a number`);
}
const bounds = this._timeCache.getTimeBounds();
obj.interval = EsQueryParser._roundInterval((bounds.max - bounds.min) / size);
continue;
}
// handle %timefilter%
switch (subObj[TIMEFILTER]) {
case 'min':
case 'max':
// Replace {"%timefilter%": "min|max", ...} object with a timestamp
obj[prop] = this._getTimeBound(subObj, subObj[TIMEFILTER]);
continue;
case true:
// Replace {"%timefilter%": true, ...} object with the "range" object
this._createRangeFilter(subObj);
continue;
case undefined:
this._injectContextVars(subObj, isQuery);
continue;
default:
throw new Error(`"${TIMEFILTER}" property must be set to true, "min", or "max"`);
}
}
}
}
}
/**
* replaces given object that contains `%timefilter%` key with the timefilter bounds and optional shift & unit parameters
* @param {object} obj
* @return {object}
*/
_createRangeFilter(obj) {
obj.gte = this._getTimeBound(obj, 'min');
obj.lte = this._getTimeBound(obj, 'max');
obj.format = 'epoch_millis';
delete obj[TIMEFILTER];
delete obj.shift;
delete obj.unit;
return obj;
}
/**
*
* @param {object} opts
* @param {number} [opts.shift]
* @param {string} [opts.unit]
* @param {'min'|'max'} type
* @returns {*}
*/
_getTimeBound(opts, type) {
const bounds = this._timeCache.getTimeBounds();
let result = bounds[type];
if (opts.shift) {
const shift = opts.shift;
if (typeof shift !== 'number') {
throw new Error('shift must be a numeric value');
}
let multiplier;
switch (opts.unit || 'd') {
case 'w':
case 'week':
multiplier = 1000 * 60 * 60 * 24 * 7;
break;
case 'd':
case 'day':
multiplier = 1000 * 60 * 60 * 24;
break;
case 'h':
case 'hour':
multiplier = 1000 * 60 * 60;
break;
case 'm':
case 'minute':
multiplier = 1000 * 60;
break;
case 's':
case 'second':
multiplier = 1000;
break;
default:
throw new Error('Unknown unit value. Must be one of: [week, day, hour, minute, second]');
}
result += shift * multiplier;
}
return result;
}
/**
* Adapted from src/core_plugins/timelion/common/lib/calculate_interval.js
* @param interval (ms)
* @returns {string}
*/
static _roundInterval(interval) {
switch (true) {
case (interval <= 500): // <= 0.5s
return '100ms';
case (interval <= 5000): // <= 5s
return '1s';
case (interval <= 7500): // <= 7.5s
return '5s';
case (interval <= 15000): // <= 15s
return '10s';
case (interval <= 45000): // <= 45s
return '30s';
case (interval <= 180000): // <= 3m
return '1m';
case (interval <= 450000): // <= 9m
return '5m';
case (interval <= 1200000): // <= 20m
return '10m';
case (interval <= 2700000): // <= 45m
return '30m';
case (interval <= 7200000): // <= 2h
return '1h';
case (interval <= 21600000): // <= 6h
return '3h';
case (interval <= 86400000): // <= 24h
return '12h';
case (interval <= 604800000): // <= 1w
return '24h';
case (interval <= 1814400000): // <= 3w
return '1w';
case (interval < 3628800000): // < 2y
return '30d';
default:
return '1y';
}
}
}

View file

@ -0,0 +1,30 @@
import LruCache from 'lru-cache';
export class SearchCache {
constructor(es, cacheOpts) {
this._es = es;
this._cache = new LruCache(cacheOpts);
}
/**
* Execute multiple searches, possibly combining the results of the cached searches
* with the new ones already in cache
* @param {object[]} requests array of search requests
*/
search(requests) {
const promises = [];
for (const request of requests) {
const key = JSON.stringify(request);
let pending = this._cache.get(key);
if (pending === undefined) {
pending = this._es.search(request);
this._cache.set(key, pending);
}
promises.push(pending);
}
return Promise.all(promises);
}
}

View file

@ -0,0 +1,73 @@
/**
* Optimization caching - always return the same value if queried within this time
* @type {number}
*/
const AlwaysCacheMaxAge = 40;
/**
* This class caches timefilter's bounds to minimize number of server requests
*/
export class TimeCache {
constructor(timefilter, maxAge) {
this._timefilter = timefilter;
this._maxAge = maxAge;
this._cachedBounds = null;
this._cacheTS = 0;
}
// Simplifies unit testing
// noinspection JSMethodCanBeStatic
_now() {
return Date.now();
}
/**
* Get cached time range values
* @returns {{min: number, max: number}}
*/
getTimeBounds() {
const ts = this._now();
let bounds;
if (this._cachedBounds) {
const diff = ts - this._cacheTS;
// For very rapid usage (multiple calls within a few milliseconds)
// Avoids expensive time parsing
if (diff < AlwaysCacheMaxAge) {
return this._cachedBounds;
}
// If the time is relative, mode hasn't changed, and time hasn't changed more than maxAge,
// return old time to avoid multiple near-identical server calls
if (diff < this._maxAge) {
bounds = this._getBounds();
if (
(Math.abs(bounds.min - this._cachedBounds.min) < this._maxAge) &&
(Math.abs(bounds.max - this._cachedBounds.max) < this._maxAge)
) {
return this._cachedBounds;
}
}
}
this._cacheTS = ts;
this._cachedBounds = bounds || this._getBounds();
return this._cachedBounds;
}
/**
* Get parsed min/max values
* @returns {{min: number, max: number}}
* @private
*/
_getBounds() {
const bounds = this._timefilter.getBounds();
return {
min: bounds.min.valueOf(),
max: bounds.max.valueOf()
};
}
}

View file

@ -0,0 +1,39 @@
import $ from 'jquery';
/**
* This class processes all Vega spec customizations,
* converting url object parameters into query results.
*/
export class UrlParser {
constructor(onWarning) {
this._onWarning = onWarning;
}
// noinspection JSMethodCanBeStatic
/**
* Update request object
*/
parseUrl(obj, urlObj) {
let url = urlObj.url;
if (!url) {
throw new Error(`data.url requires a url parameter in a form 'https://example.org/path/subpath'`);
}
const query = urlObj.query;
if (!query) {
this._onWarning(`Using a "url": {"%type%": "url", "url": ...} should have a "query" sub-object`);
} else {
url += (url.includes('?') ? '&' : '?') + $.param(query);
}
obj.url = url;
}
/**
* No-op - the url is already set during the parseUrl
*/
populateData() {
}
}

View file

@ -0,0 +1,32 @@
import compactStringify from 'json-stringify-pretty-compact';
export class Utils {
/**
* If the 2nd array parameter in args exists, append it to the warning/error string value
*/
static formatWarningToStr(value) {
if (arguments.length >= 2) {
try {
if (typeof arguments[1] === 'string') {
value += `\n${arguments[1]}`;
} else {
value += '\n' + compactStringify(arguments[1], { maxLength: 70 });
}
} catch (err) {
// ignore
}
}
return value;
}
static formatErrorToStr(error) {
if (!error) {
error = 'ERR';
} else if (error instanceof Error) {
error = error.message;
}
return Utils.formatWarningToStr(error, ...Array.from(arguments).slice(1));
}
}

View file

@ -0,0 +1,390 @@
import _ from 'lodash';
import * as vega from 'vega';
import * as vegaLite from 'vega-lite';
import schemaParser from 'vega-schema-url-parser';
import versionCompare from 'compare-versions';
import { EsQueryParser } from './es_query_parser';
import hjson from 'hjson';
import { Utils } from './utils';
import { EmsFileParser } from './ems_file_parser';
import { UrlParser } from './url_parser';
const DEFAULT_SCHEMA = 'https://vega.github.io/schema/vega/v3.0.json';
const locToDirMap = {
left: 'row-reverse',
right: 'row',
top: 'column-reverse',
bottom: 'column'
};
// If there is no "%type%" parameter, use this parser
const DEFAULT_PARSER = 'elasticsearch';
export class VegaParser {
constructor(spec, searchCache, timeCache, dashboardContext, serviceSettings) {
this.spec = spec;
this.hideWarnings = false;
this.error = undefined;
this.warnings = [];
const onWarn = this._onWarning.bind(this);
this._urlParsers = {
elasticsearch: new EsQueryParser(timeCache, searchCache, dashboardContext, onWarn),
emsfile: new EmsFileParser(serviceSettings),
url: new UrlParser(onWarn),
};
}
async parseAsync() {
try {
await this._parseAsync();
} catch (err) {
// if we reject current promise, it will use the standard Kibana error handling
this.error = Utils.formatErrorToStr(err);
}
return this;
}
async _parseAsync() {
if (this.isVegaLite !== undefined) throw new Error();
if (typeof this.spec === 'string') {
this.spec = hjson.parse(this.spec, { legacyRoot: false });
}
if (!_.isPlainObject(this.spec)) {
throw new Error('Invalid Vega spec');
}
this.isVegaLite = this._parseSchema();
this.useHover = !this.isVegaLite;
this._config = this._parseConfig();
this.hideWarnings = !!this._config.hideWarnings;
this.useMap = this._config.type === 'map';
this.renderer = this._config.renderer === 'svg' ? 'svg' : 'canvas';
this._setDefaultColors();
this._parseControlPlacement(this._config);
if (this.useMap) {
this.mapConfig = this._parseMapConfig();
} else if (this.spec.autosize === undefined) {
// Default autosize should be fit, unless it's a map (leaflet-vega handles that)
this.spec.autosize = { type: 'fit', contains: 'padding' };
}
await this._resolveDataUrls();
if (this.isVegaLite) {
this._compileVegaLite();
}
this._calcSizing();
}
/**
* Convert VegaLite to Vega spec
* @private
*/
_compileVegaLite() {
if (this.useMap) {
throw new Error('"_map" configuration is not compatible with vega-lite spec');
}
this.vlspec = this.spec;
const logger = vega.logger(vega.Warn);
logger.warn = this._onWarning.bind(this);
this.spec = vegaLite.compile(this.vlspec, logger).spec;
}
/**
* Process graph size and padding
* @private
*/
_calcSizing() {
this.useResize = !this.useMap && (this.spec.autosize === 'fit' || this.spec.autosize.type === 'fit');
// Padding is not included in the width/height by default
this.paddingWidth = 0;
this.paddingHeight = 0;
if (this.useResize && this.spec.padding && this.spec.autosize.contains !== 'padding') {
if (typeof this.spec.padding === 'object') {
this.paddingWidth += (+this.spec.padding.left || 0) + (+this.spec.padding.right || 0);
this.paddingHeight += (+this.spec.padding.top || 0) + (+this.spec.padding.bottom || 0);
} else {
this.paddingWidth += 2 * (+this.spec.padding || 0);
this.paddingHeight += 2 * (+this.spec.padding || 0);
}
}
if (this.useResize && (this.spec.width || this.spec.height)) {
if (this.isVegaLite) {
delete this.spec.width;
delete this.spec.height;
} else {
this._onWarning(`The 'width' and 'height' params are ignored with autosize=fit`);
}
}
}
/**
* Calculate container-direction CSS property for binding placement
* @private
*/
_parseControlPlacement() {
this.containerDir = locToDirMap[this._config.controlsLocation];
if (this.containerDir === undefined) {
if (this._config.controlsLocation === undefined) {
this.containerDir = 'column';
} else {
throw new Error('Unrecognized controlsLocation value. Expecting one of ["' +
locToDirMap.keys().join('", "') + '"]');
}
}
const dir = this._config.controlsDirection;
if (dir !== undefined && dir !== 'horizontal' && dir !== 'vertical') {
throw new Error('Unrecognized dir value. Expecting one of ["horizontal", "vertical"]');
}
this.controlsDir = dir === 'horizontal' ? 'row' : 'column';
}
/**
* Parse {config: kibana: {...}} portion of the Vega spec (or root-level _hostConfig for backward compat)
* @returns {object} kibana config
* @private
*/
_parseConfig() {
let result;
if (this.spec._hostConfig !== undefined) {
result = this.spec._hostConfig;
delete this.spec._hostConfig;
if (!_.isPlainObject(result)) {
throw new Error('If present, _hostConfig must be an object');
}
this._onWarning('_hostConfig has been deprecated. Use config.kibana instead.');
}
if (_.isPlainObject(this.spec.config) && this.spec.config.kibana !== undefined) {
result = this.spec.config.kibana;
delete this.spec.config.kibana;
if (!_.isPlainObject(result)) {
throw new Error('If present, config.kibana must be an object');
}
}
return result || {};
}
/**
* Parse map-specific configuration
* @returns {{mapStyle: *|string, delayRepaint: boolean, latitude: number, longitude: number, zoom, minZoom, maxZoom, zoomControl: *|boolean, maxBounds: *}}
* @private
*/
_parseMapConfig() {
const res = {
delayRepaint: this._config.delayRepaint === undefined ? true : this._config.delayRepaint,
};
const validate = (name, isZoom) => {
const val = this._config[name];
if (val !== undefined) {
const parsed = Number.parseFloat(val);
if (Number.isFinite(parsed) && (!isZoom || (parsed >= 0 && parsed <= 30))) {
res[name] = parsed;
return;
}
this._onWarning(`config.kibana.${name} is not valid`);
}
if (!isZoom) res[name] = 0;
};
validate(`latitude`, false);
validate(`longitude`, false);
validate(`zoom`, true);
validate(`minZoom`, true);
validate(`maxZoom`, true);
// `false` is a valid value
res.mapStyle = this._config.mapStyle === undefined ? `default` : this._config.mapStyle;
if (res.mapStyle !== `default` && res.mapStyle !== false) {
this._onWarning(`config.kibana.mapStyle may either be false or "default"`);
res.mapStyle = `default`;
}
const zoomControl = this._config.zoomControl;
if (zoomControl === undefined) {
res.zoomControl = true;
} else if (typeof zoomControl !== 'boolean') {
this._onWarning('config.kibana.zoomControl must be a boolean value');
res.zoomControl = true;
} else {
res.zoomControl = zoomControl;
}
const maxBounds = this._config.maxBounds;
if (maxBounds !== undefined) {
if (!Array.isArray(maxBounds) || maxBounds.length !== 4 ||
!maxBounds.every(v => typeof v === 'number' && Number.isFinite(v))
) {
this._onWarning(`config.kibana.maxBounds must be an array with four numbers`);
} else {
res.maxBounds = maxBounds;
}
}
return res;
}
/**
* Parse Vega schema element
* @returns {boolean} is this a VegaLite schema?
* @private
*/
_parseSchema() {
if (!this.spec.$schema) {
this._onWarning(`The input spec does not specify a "$schema", defaulting to "${DEFAULT_SCHEMA}"`);
this.spec.$schema = DEFAULT_SCHEMA;
}
const schema = schemaParser(this.spec.$schema);
const isVegaLite = schema.library === 'vega-lite';
const libVersion = isVegaLite ? vegaLite.version : vega.version;
if (versionCompare(schema.version, libVersion) > 0) {
this._onWarning(
`The input spec uses ${schema.library} ${schema.version}, but ` +
`current version of ${schema.library} is ${libVersion}.`
);
}
return isVegaLite;
}
/**
* Replace all instances of ES requests with raw values.
* Also handle any other type of url: {type: xxx, ...}
* @private
*/
async _resolveDataUrls() {
const pending = {};
this._findObjectDataUrls(this.spec, (obj) => {
const url = obj.url;
delete obj.url;
let type = url['%type%'];
delete url['%type%'];
if (type === undefined) {
type = DEFAULT_PARSER;
}
const parser = this._urlParsers[type];
if (parser === undefined) {
throw new Error(`url: {"%type%": "${type}"} is not supported`);
}
let pendingArr = pending[type];
if (pendingArr === undefined) {
pending[type] = pendingArr = [];
}
pendingArr.push(parser.parseUrl(obj, url));
});
const pendingParsers = Object.keys(pending);
if (pendingParsers.length > 0) {
// let each parser populate its data in parallel
await Promise.all(pendingParsers.map(type => this._urlParsers[type].populateData(pending[type])));
}
}
/**
* Recursively find and callback every instance of the data.url as an object
* @param {*} obj current location in the object tree
* @param {function({object})} onFind Call this function for all url objects
* @param {string} [key] field name of the current object
* @private
*/
_findObjectDataUrls(obj, onFind, key) {
if (Array.isArray(obj)) {
for (const elem of obj) {
this._findObjectDataUrls(elem, onFind, key);
}
} else if (_.isPlainObject(obj)) {
if (key === 'data' && _.isPlainObject(obj.url)) {
// Assume that any "data": {"url": {...}} is a request for data
if (obj.values !== undefined || obj.source !== undefined) {
throw new Error('Data must not have more than one of "url", "values", and "source"');
}
onFind(obj);
} else {
for (const k of Object.keys(obj)) {
this._findObjectDataUrls(obj[k], onFind, k);
}
}
}
}
/**
* Inject default colors into the spec.config
* @private
*/
_setDefaultColors() {
// Default category coloring to the Elastic color scheme
this._setDefaultValue({ scheme: 'elastic' }, 'config', 'range', 'category');
// Set default single color to match other Kibana visualizations
const defaultColor = '#00A69B';
if (this.isVegaLite) {
// Vega-Lite: set default color, works for fill and strike -- config: { mark: { color: '#00A69B' }}
this._setDefaultValue(defaultColor, 'config', 'mark', 'color');
} else {
// Vega - global mark has very strange behavior, must customize each mark type individually
// https://github.com/vega/vega/issues/1083
// Don't set defaults if spec.config.mark.color or fill are set
if (!this.spec.config.mark || (this.spec.config.mark.color === undefined && this.spec.config.mark.fill === undefined)) {
this._setDefaultValue(defaultColor, 'config', 'arc', 'fill');
this._setDefaultValue(defaultColor, 'config', 'area', 'fill');
this._setDefaultValue(defaultColor, 'config', 'line', 'stroke');
this._setDefaultValue(defaultColor, 'config', 'path', 'stroke');
this._setDefaultValue(defaultColor, 'config', 'rect', 'fill');
this._setDefaultValue(defaultColor, 'config', 'rule', 'stroke');
this._setDefaultValue(defaultColor, 'config', 'shape', 'stroke');
this._setDefaultValue(defaultColor, 'config', 'symbol', 'fill');
this._setDefaultValue(defaultColor, 'config', 'trail', 'fill');
}
}
}
/**
* Set default value if it doesn't exist.
* Given an object, and an array of fields, ensure that obj.fld1.fld2. ... .fldN is set to value if it doesn't exist.
* @param {*} value
* @param {string} fields
* @private
*/
_setDefaultValue(value, ...fields) {
let o = this.spec;
for (let i = 0; i < fields.length - 1; i++) {
const field = fields[i];
const subObj = o[field];
if (subObj === undefined) {
o[field] = {};
} else if (!_.isPlainObject(subObj)) {
return;
}
o = o[field];
}
const lastField = fields[fields.length - 1];
if (o[lastField] === undefined) {
o[lastField] = value;
}
}
/**
* Add a warning to the warnings array
* @private
*/
_onWarning() {
if (!this.hideWarnings) {
this.warnings.push(Utils.formatWarningToStr(...arguments));
}
}
}

View file

@ -0,0 +1,100 @@
{
/*
Welcome to Vega visualizations. Here you can design your own dataviz from scratch using a declarative language called Vega, or its simpler form Vega-Lite. In Vega, you have the full control of what data is loaded, even from multiple sources, how that data is transformed, and what visual elements are used to show it. Use help icon to view Vega examples, tutorials, and other docs. Use the wrench icon to reformat this text, or to remove comments.
This example graph shows the document count in all indexes in the current time range. You might need to adjust the time filter in the upper right corner.
*/
$schema: https://vega.github.io/schema/vega-lite/v2.json
title: Event counts from all indexes
// Define the data source
data: {
url: {
/*
An object instead of a string for the "url" param is treated as an Elasticsearch query. Anything inside this object is not part of the Vega language, but only understood by Kibana and Elasticsearch server. This query counts the number of documents per time interval, assuming you have a @timestamp field in your data.
Kibana has a special handling for the fields surrounded by "%". They are processed before the the query is sent to Elasticsearch. This way the query becomes context aware, and can use the time range and the dashboard filters.
*/
// Apply dashboard context filters when set
%context%: true
// Filter the time picker (upper right corner) with this field
%timefield%: @timestamp
/*
See .search() documentation for : https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/api-reference.html#api-search
*/
// Which index to search
index: _all
// Aggregate data by the time field into time buckets, counting the number of documents in each bucket.
body: {
aggs: {
time_buckets: {
date_histogram: {
// Use date histogram aggregation on @timestamp field
field: @timestamp
// The interval value will depend on the daterange picker (true), or use an integer to set an approximate bucket count
interval: {%autointerval%: true}
// Make sure we get an entire range, even if it has no data
extended_bounds: {
// Use the current time range's start and end
min: {%timefilter%: "min"}
max: {%timefilter%: "max"}
}
// Use this for linear (e.g. line, area) graphs. Without it, empty buckets will not show up
min_doc_count: 0
}
}
}
// Speed up the response by only including aggregation results
size: 0
}
}
/*
Elasticsearch will return results in this format:
aggregations: {
time_buckets: {
buckets: [
{
key_as_string: 2015-11-30T22:00:00.000Z
key: 1448920800000
doc_count: 0
},
{
key_as_string: 2015-11-30T23:00:00.000Z
key: 1448924400000
doc_count: 0
}
...
]
}
}
For our graph, we only need the list of bucket values. Use the format.property to discard everything else.
*/
format: {property: "aggregations.time_buckets.buckets"}
}
// "mark" is the graphics element used to show our data. Other mark values are: area, bar, circle, line, point, rect, rule, square, text, and tick. See https://vega.github.io/vega-lite/docs/mark.html
mark: line
// "encoding" tells the "mark" what data to use and in what way. See https://vega.github.io/vega-lite/docs/encoding.html
encoding: {
x: {
// The "key" value is the timestamp in milliseconds. Use it for X axis.
field: key
type: temporal
axis: {title: false} // Customize X axis format
}
y: {
// The "doc_count" is the count per bucket. Use it for Y axis.
field: doc_count
type: quantitative
axis: {title: "Document count"}
}
}
}

View file

@ -0,0 +1,79 @@
.vega-main {
display: flex;
flex: 1 1 100%;
position: relative;
// align-self: flex-start;
> .vega-messages {
position: absolute;
top: 0;
width: 100%;
margin: auto;
opacity: 0.8;
z-index: 1;
list-style: none;
li {
margin: 0.5em;
}
.vega-message-warn pre {
background: #ffff83;
}
.vega-message-err pre {
background: #ffdddd;
}
pre {
white-space: pre-wrap;
padding: 0.7em;
}
}
.vega-view-container {
z-index: 0;
flex: 1 1 100%;
display: block;
max-width: 100%;
max-height: 100%;
width: 100%;
height: 100%;
overflow: hidden;
}
.vega-controls-container {
display: flex;
font-size: 14px;
line-height: 20px;
> .vega-bind {
flex: 1; /*grow*/
.vega-bind-name {
display: inline-block;
width: 105px;
}
input[type="range"] {
width: 120px;
color: rgb(157, 150, 142);
display: inline-block;
vertical-align: middle;
}
label {
margin: 0 0 0 0.6em;
}
select {
max-width: 200px;
}
.vega-bind-radio label {
margin: 0 0.6em 0 0.3em;
}
}
}
}

View file

@ -0,0 +1,42 @@
@import (reference) "~ui/styles/variables";
.vis-type-vega {
.vis-editor-config {
// Vega controls its own padding, no need for additional padding
padding: 0 !important;
}
}
visualization-editor {
.vis-editor-canvas {
padding-left: 0;
}
.vegaEditor {
display: flex;
flex: 1 1 auto;
position: relative;
// If the screen too small, make sure the editor has some height
min-height: 18em;
@media (min-width: @screen-md-min) {
min-height: unset;
}
#editor_actions {
position: absolute;
margin: 0.6em 1.3em;
z-index: 80;
ul.dropdown-menu {
left: auto;
right: 0;
}
}
#vegaAceEditor {
flex: 1 1 auto;
}
}
}

View file

@ -0,0 +1,70 @@
import compactStringify from 'json-stringify-pretty-compact';
import hjson from 'hjson';
import { Notifier } from 'ui/notify';
import { uiModules } from 'ui/modules';
import 'ui/accessibility/kbn_ui_ace_keyboard_mode';
const module = uiModules.get('kibana/vega', ['kibana']);
module.controller('VegaEditorController', ($scope /*, kbnUiAceKeyboardModeService*/) => {
const notify = new Notifier({ location: 'Vega' });
return new (class VegaEditorController {
constructor() {
$scope.aceLoaded = (editor) => {
editor.$blockScrolling = Infinity;
const session = editor.getSession();
session.setTabSize(2);
session.setUseSoftTabs(true);
this.aceEditor = editor;
};
$scope.formatJson = (event) => {
this._format(event, compactStringify, {
maxLength: this._getCodeWidth(),
});
};
$scope.formatHJson = (event) => {
this._format(event, hjson.stringify, {
condense: this._getCodeWidth(),
bracesSameLine: true,
keepWsc: true,
});
};
}
_getCodeWidth() {
return this.aceEditor.getSession().getWrapLimit();
}
_format(event, stringify, opts) {
event.preventDefault();
let newSpec;
try {
const spec = hjson.parse(this.aceEditor.getSession().doc.getValue(), { legacyRoot: false, keepWsc: true });
newSpec = stringify(spec, opts);
} catch (err) {
// This is a common case - user tries to format an invalid HJSON text
notify.error(err);
return;
}
// ui-ace only accepts changes from the editor when they
// happen outside of a digest cycle
// Per @spalger, we used $$postDigest() instead of setTimeout(() => {}, 0)
// because it better described the intention.
$scope.$$postDigest(() => {
// set the new value to the session doc so that it
// is treated as an edit by ace: ace adds it to the
// undo stack and emits it as a change like all
// other edits
this.aceEditor.getSession().doc.setValue(newSpec);
});
}
})();
});

View file

@ -0,0 +1,76 @@
<div class="vegaEditor" ng-controller="VegaEditorController">
<div
kbn-ui-ace-keyboard-mode
ui-ace="{
onLoad: aceLoaded,
useWrapMode: true,
advanced: {
highlightActiveLine: false
},
rendererOptions: {
showPrintMargin: false
},
mode: 'hjson'
}"
id="vegaAceEditor"
ng-model="vis.params.spec"
data-test-subj="vega-editor"
></div>
<div id="editor_actions">
<span dropdown>
<button
id="vegaHelp"
class="editor_action"
dropdown-toggle
aria-label="Vega help"
>
<span class="kuiIcon fa-question-circle"></span>
</button>
<ul
class="dropdown-menu"
role="menu"
aria-labelledby="vegaHelp"
>
<li role="menuitem">
<a target="_blank" rel="noopener noreferrer" href="https://www.elastic.co/guide/en/kibana/master/vega-graph.html">
Kibana Vega Help
</a>
</li>
<li role="menuitem">
<a target="_blank" rel="noopener noreferrer" href="https://vega.github.io/vega-lite/docs/">
Vega-Lite Documentation
</a>
</li>
<li role="menuitem">
<a target="_blank" rel="noopener noreferrer" href="https://vega.github.io/vega/docs/">
Vega Documentation
</a>
</li>
</ul>
</span>
<span dropdown>
<button
id="vegaOptions"
class="editor_action"
dropdown-toggle
aria-label="Vega editor options"
>
<span class="kuiIcon fa-wrench"></span>
</button>
<ul
class="dropdown-menu"
role="menu"
aria-labelledby="vegaOptions"
>
<li role="menuitem">
<button ng-click="formatHJson($event)">Reformat as HJSON</button>
</li>
<li role="menuitem">
<button ng-click="formatJson($event)">Reformat as JSON, delete comments</button>
</li>
</ul>
</span>
</div>
</div>

View file

@ -0,0 +1,22 @@
import { VegaParser } from './data_model/vega_parser';
import { dashboardContextProvider } from 'plugins/kibana/dashboard/dashboard_context';
import { SearchCache } from './data_model/search_cache';
import { TimeCache } from './data_model/time_cache';
export function VegaRequestHandlerProvider(Private, es, timefilter, serviceSettings) {
const dashboardContext = Private(dashboardContextProvider);
const searchCache = new SearchCache(es, { max: 10, maxAge: 4 * 1000 });
const timeCache = new TimeCache(timefilter, 3 * 1000);
return {
name: 'vega',
handler(vis) {
const vp = new VegaParser(vis.params.spec, searchCache, timeCache, dashboardContext, serviceSettings);
return vp.parseAsync();
}
};
}

View file

@ -0,0 +1,42 @@
import { VisTypesRegistryProvider } from 'ui/registry/vis_types';
import { VisFactoryProvider } from 'ui/vis/vis_factory';
import { CATEGORY } from 'ui/vis/vis_category';
import { DefaultEditorSize } from 'ui/vis/editor_size';
import { VegaRequestHandlerProvider } from './vega_request_handler';
import { VegaVisualizationProvider } from './vega_visualization';
import './vega.less';
// Editor-specific code
import 'brace/mode/hjson';
import 'brace/ext/searchbox';
import './vega_editor.less';
import './vega_editor_controller';
import vegaEditorTemplate from './vega_editor_template.html';
import defaultSpec from '!!raw-loader!./default.spec.hjson';
VisTypesRegistryProvider.register((Private) => {
const VisFactory = Private(VisFactoryProvider);
const vegaRequestHandler = Private(VegaRequestHandlerProvider).handler;
const VegaVisualization = Private(VegaVisualizationProvider);
return VisFactory.createBaseVisualization({
name: 'vega',
title: 'Vega',
description: 'Create custom visualizations using Vega and VegaLite',
icon: 'fa-code',
category: CATEGORY.OTHER,
visConfig: { defaults: { spec: defaultSpec } },
editorConfig: {
optionsTemplate: vegaEditorTemplate,
enableAutoApply: true,
defaultSize: DefaultEditorSize.MEDIUM,
},
visualization: VegaVisualization,
requestHandler: vegaRequestHandler,
responseHandler: 'none',
options: { showIndexSelection: false },
stage: 'lab',
});
});

View file

@ -0,0 +1,178 @@
import $ from 'jquery';
import * as vega from 'vega';
import * as vegaLite from 'vega-lite';
import { Utils } from '../data_model/utils';
//https://github.com/elastic/kibana/issues/13327
vega.scheme('elastic',
['#00B3A4', '#3185FC', '#DB1374', '#490092', '#FEB6DB', '#F98510', '#E6C220', '#BFA180', '#920000', '#461A0A']
);
export class VegaBaseView {
constructor(vegaConfig, editorMode, parentEl, vegaParser, serviceSettings) {
this._vegaConfig = vegaConfig;
this._editorMode = editorMode;
this._$parentEl = $(parentEl);
this._parser = vegaParser;
this._serviceSettings = serviceSettings;
this._view = null;
this._vegaViewConfig = null;
this._$messages = null;
this._destroyHandlers = [];
this._initialized = false;
}
async init() {
if (this._initialized) throw new Error(); // safety
this._initialized = true;
try {
this._$parentEl.empty()
.addClass('vega-main')
.css('flex-direction', this._parser.containerDir);
// bypass the onWarn warning checks - in some cases warnings may still need to be shown despite being disabled
for (const warn of this._parser.warnings) {
this._addMessage('warn', warn);
}
if (this._parser.error) {
this._addMessage('err', this._parser.error);
return;
}
this._$container = $('<div class="vega-view-container">')
.appendTo(this._$parentEl);
this._$controls = $('<div class="vega-controls-container">')
.css('flex-direction', this._parser.controlsDir)
.appendTo(this._$parentEl);
this._addDestroyHandler(() => {
this._$container.remove();
this._$container = null;
this._$controls.remove();
this._$controls = null;
if (this._$messages) {
this._$messages.remove();
this._$messages = null;
}
});
this._vegaViewConfig = {
logLevel: vega.Warn,
renderer: this._parser.renderer,
};
if (!this._vegaConfig.enableExternalUrls) {
// Override URL loader and sanitizer to disable all URL-based requests
const errorFunc = () => {
throw new Error('External URLs are not enabled. Add "vega": {"enableExternalUrls": true} to kibana.yml');
};
const loader = vega.loader();
loader.load = errorFunc;
loader.sanitize = errorFunc;
this._vegaViewConfig.loader = loader;
}
// The derived class should create this method
await this._initViewCustomizations();
} catch (err) {
this.onError(err);
}
}
onError() {
this._addMessage('err', Utils.formatErrorToStr(...arguments));
}
onWarn() {
if (!this._parser || !this._parser.hideWarnings) {
this._addMessage('warn', Utils.formatWarningToStr(...arguments));
}
}
_addMessage(type, text) {
if (!this._$messages) {
this._$messages = $(`<ul class="vega-messages">`).appendTo(this._$parentEl);
}
this._$messages.append(
$(`<li class="vega-message-${type}">`).append(
$(`<pre>`).text(text)
)
);
}
resize() {
if (this._parser.useResize && this._view && this.updateVegaSize(this._view)) {
return this._view.runAsync();
}
}
updateVegaSize(view) {
// For some reason the object is slightly scrollable without the extra padding.
// This might be due to https://github.com/jquery/jquery/issues/3808
// Which is being fixed as part of jQuery 3.3.0
const heightExtraPadding = 6;
const width = Math.max(0, this._$container.width() - this._parser.paddingWidth);
const height = Math.max(0, this._$container.height() - this._parser.paddingHeight) - heightExtraPadding;
if (view.width() !== width || view.height() !== height) {
view.width(width).height(height);
return true;
}
return false;
}
/**
* Set global debug variable to simplify vega debugging in console. Show info message first time
*/
setDebugValues(view, spec, vlspec) {
if (!this._editorMode) {
// VEGA_DEBUG should only be enabled in the editor mode
return;
}
if (window) {
if (!view) {
// disposing, get rid of the stale debug info
delete window.VEGA_DEBUG;
} else {
if (window.VEGA_DEBUG === undefined && console) {
console.log('%cWelcome to Kibana Vega Plugin!', 'font-size: 16px; font-weight: bold;');
console.log('You can access the Vega view with VEGA_DEBUG. ' +
'Learn more at https://vega.github.io/vega/docs/api/debugging/.');
}
window.VEGA_DEBUG = window.VEGA_DEBUG || {};
window.VEGA_DEBUG.VEGA_VERSION = vega.version;
window.VEGA_DEBUG.VEGA_LITE_VERSION = vegaLite.version;
window.VEGA_DEBUG.view = view;
window.VEGA_DEBUG.vega_spec = spec;
window.VEGA_DEBUG.vegalite_spec = vlspec;
}
}
}
destroy() {
// properly handle multiple destroy() calls by converting this._destroyHandlers
// into the _ongoingDestroy promise, while handlers are being disposed
if (this._destroyHandlers) {
// If no destroy is yet running, execute all handlers and wait for all of them to resolve.
// Once done, keep the resolved promise, and get rid of any values returned from handlers.
this._ongoingDestroy = Promise.all(this._destroyHandlers.map(v => v())).then(() => 0);
this._destroyHandlers = null;
}
return this._ongoingDestroy;
}
_addDestroyHandler(handler) {
// If disposing hasn't started yet, enqueue it, otherwise dispose right away
// This creates a minor issue - if disposing has started but not yet finished,
// and we dispose the new handler right away, the destroy() does not wait for it.
// This behavior is no different from the case when disposing has already completed,
// so it shouldn't create any issues.
if (this._destroyHandlers) {
this._destroyHandlers.push(handler);
} else {
handler();
}
}
}

View file

@ -0,0 +1,27 @@
import * as vega from 'vega';
import { VegaBaseView } from './vega_base_view';
export class VegaView extends VegaBaseView {
async _initViewCustomizations() {
// In some cases, Vega may be initialized twice... TBD
if (!this._$container) return;
const view = new vega.View(vega.parse(this._parser.spec), this._vegaViewConfig);
this.setDebugValues(view, this._parser.spec, this._parser.vlspec);
view.warn = this.onWarn.bind(this);
view.error = this.onError.bind(this);
if (this._parser.useResize) this.updateVegaSize(view);
view.initialize(this._$container.get(0), this._$controls.get(0));
if (this._parser.useHover) view.hover();
this._addDestroyHandler(() => {
this._view = null;
view.finalize();
});
await view.runAsync();
this._view = view;
}
}

View file

@ -0,0 +1,67 @@
import { Notifier } from 'ui/notify';
import { VegaView } from './vega_view/vega_view';
export function VegaVisualizationProvider(vegaConfig, serviceSettings) {
const notify = new Notifier({ location: 'Vega' });
return class VegaVisualization {
constructor(el, vis) {
this._el = el;
this._vis = vis;
}
/**
*
* @param {VegaParser} visData
* @param {*} status
* @returns {Promise<void>}
*/
async render(visData, status) {
if (!visData && !this._vegaView) {
notify.warning('Unable to render without data');
return;
}
try {
await this._render(visData, status);
} catch (error) {
if (this._vegaView) {
this._vegaView.onError(error);
} else {
notify.error(error);
}
}
}
async _render(vegaParser, status) {
if (vegaParser && (status.data || !this._vegaView)) {
// New data received, rebuild the graph
if (this._vegaView) {
await this._vegaView.destroy();
this._vegaView = null;
}
if (vegaParser.useMap) {
throw new Error('Map mode is not yet supported in Kibana Core. You must use Kibana Vega plugin');
} else {
this._vegaView = new VegaView(vegaConfig, this._vis.editorMode, this._el, vegaParser, serviceSettings);
}
await this._vegaView.init();
} else if (status.resize) {
// the graph has been resized
await this._vegaView.resize();
}
}
destroy() {
return this._vegaView && this._vegaView.destroy();
}
};
}

View file

@ -32,6 +32,7 @@ export default function ({ getService, getPageObjects }) {
'Controls',
'Markdown',
'Tag Cloud',
'Vega',
];
// find all the chart types and make sure there all there

View file

@ -0,0 +1,41 @@
import expect from 'expect.js';
export default function ({ getService, getPageObjects }) {
const log = getService('log');
const PageObjects = getPageObjects(['common', 'visualize', 'header']);
describe('visualize app', () => {
before(async () => {
log.debug('navigateToApp visualize');
await PageObjects.common.navigateToUrl('visualize', 'new');
log.debug('clickVega');
await PageObjects.visualize.clickVega();
});
describe('vega chart', () => {
it('should not display spy panel toggle button', async function () {
const spyToggleExists = await PageObjects.visualize.getSpyToggleExists();
expect(spyToggleExists).to.be(false);
});
it('should have some initial vega spec text', async function () {
const vegaSpec = await PageObjects.visualize.getVegaSpec();
expect(vegaSpec).to.contain('{').and.to.contain('data');
expect(vegaSpec.length).to.be.above(500);
});
it('should have view and control containers', async function () {
const view = await PageObjects.visualize.getVegaViewContainer();
expect(view).to.be.ok();
const size = await view.getSize();
expect(size).to.have.property('width').and.to.have.property('height');
expect(size.width).to.be.above(0);
expect(size.height).to.be.above(0);
const controls = await PageObjects.visualize.getVegaControlContainer();
expect(controls).to.be.ok();
});
});
});
}

View file

@ -32,5 +32,6 @@ export default function ({ getService, loadTestFile }) {
loadTestFile(require.resolve('./_shared_item'));
loadTestFile(require.resolve('./_input_control_vis'));
loadTestFile(require.resolve('./_histogram_request_start'));
loadTestFile(require.resolve('./_vega_chart'));
});
}

View file

@ -1,5 +1,6 @@
import { VisualizeConstants } from '../../../src/core_plugins/kibana/public/visualize/visualize_constants';
import Keys from 'leadfoot/keys';
import Bluebird from 'bluebird';
export function VisualizePageProvider({ getService, getPageObjects }) {
const remote = getService('remote');
@ -61,6 +62,10 @@ export function VisualizePageProvider({ getService, getPageObjects }) {
await find.clickByPartialLinkText('Tag Cloud');
}
async clickVega() {
await find.clickByPartialLinkText('Vega');
}
async clickVisualBuilder() {
await find.clickByPartialLinkText('Visual Builder');
}
@ -161,6 +166,22 @@ export function VisualizePageProvider({ getService, getPageObjects }) {
return element.getVisibleText();
}
async getVegaSpec() {
// Adapted from console_page.js:getVisibleTextFromAceEditor(). Is there a common utilities file?
const editor = await testSubjects.find('vega-editor');
const lines = await editor.findAllByClassName('ace_line_group');
const linesText = await Bluebird.map(lines, l => l.getVisibleText());
return linesText.join('\n');
}
async getVegaViewContainer() {
return await find.byCssSelector('div.vega-view-container');
}
async getVegaControlContainer() {
return await find.byCssSelector('div.vega-controls-container');
}
async setFromTime(timeString) {
const input = await find.byCssSelector('input[ng-model="absolute.from"]', defaultFindTimeout * 2);
await input.clearValue();

430
yarn.lock
View file

@ -1787,6 +1787,18 @@ caniuse-db@^1.0.30000529, caniuse-db@^1.0.30000539, caniuse-db@^1.0.30000597, ca
version "1.0.30000789"
resolved "https://registry.yarnpkg.com/caniuse-db/-/caniuse-db-1.0.30000789.tgz#5cf3fec75480041ab162ca06413153141e234325"
canvas-prebuilt@^1.6:
version "1.6.0"
resolved "https://registry.yarnpkg.com/canvas-prebuilt/-/canvas-prebuilt-1.6.0.tgz#f8dd9abe81fdc2103a39d8362df3219d6d83f788"
dependencies:
node-pre-gyp "^0.6.29"
canvas@^1.6:
version "1.6.9"
resolved "https://registry.yarnpkg.com/canvas/-/canvas-1.6.9.tgz#e3f95cec7b16bf2d6f3fc725c02d940d3258f69b"
dependencies:
nan "^2.4.0"
capture-stack-trace@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/capture-stack-trace/-/capture-stack-trace-1.0.0.tgz#4a6fa07399c26bba47f0b2496b4d0fb408c5550d"
@ -2229,14 +2241,14 @@ combined-stream@^1.0.5, combined-stream@~1.0.1, combined-stream@~1.0.5:
dependencies:
delayed-stream "~1.0.0"
commander@2, commander@2.12.x, commander@^2.8.1, commander@~2.12.1:
version "2.12.2"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.12.2.tgz#0f5946c427ed9ec0d91a46bb9def53e54650e555"
commander@2.11.0:
version "2.11.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.11.0.tgz#157152fd1e7a6c8d98a5b715cf376df928004563"
commander@2.12.x, commander@^2.8.1, commander@~2.12.1:
version "2.12.2"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.12.2.tgz#0f5946c427ed9ec0d91a46bb9def53e54650e555"
commander@2.8.1, commander@2.8.x:
version "2.8.1"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.8.1.tgz#06be367febfda0c330aa1e2a072d3dc9762425d4"
@ -2257,6 +2269,10 @@ commondir@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
compare-versions@3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-3.1.0.tgz#43310256a5c555aaed4193c04d8f154cf9c6efd5"
component-bind@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/component-bind/-/component-bind-1.0.0.tgz#00c608ab7dcd93897c0009651b1d3a8e1e73bbd1"
@ -2701,16 +2717,134 @@ custom-event@~1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/custom-event/-/custom-event-1.0.1.tgz#5d02a46850adf1b4a317946a3928fccb5bfd0425"
d3-array@1, d3-array@^1.1.1, d3-array@^1.2.0:
version "1.2.1"
resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.1.tgz#d1ca33de2f6ac31efadb8e050a021d7e2396d5dc"
d3-cloud@1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/d3-cloud/-/d3-cloud-1.2.1.tgz#a9cfdf3fb855804a9800866229bf016f71bd379a"
dependencies:
d3-dispatch "0.2.x"
d3-collection@1:
version "1.0.4"
resolved "https://registry.yarnpkg.com/d3-collection/-/d3-collection-1.0.4.tgz#342dfd12837c90974f33f1cc0a785aea570dcdc2"
d3-color@1:
version "1.0.3"
resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-1.0.3.tgz#bc7643fca8e53a8347e2fbdaffa236796b58509b"
d3-contour@1:
version "1.1.2"
resolved "https://registry.yarnpkg.com/d3-contour/-/d3-contour-1.1.2.tgz#21f5456fcf57645922d69a27a58e782c91f842b3"
dependencies:
d3-array "^1.1.1"
d3-dispatch@0.2.x:
version "0.2.6"
resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-0.2.6.tgz#e57df25906cdce5badeae79809ec0f73bbb184ab"
d3-dispatch@1:
version "1.0.3"
resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-1.0.3.tgz#46e1491eaa9b58c358fce5be4e8bed626e7871f8"
d3-dsv@1:
version "1.0.8"
resolved "https://registry.yarnpkg.com/d3-dsv/-/d3-dsv-1.0.8.tgz#907e240d57b386618dc56468bacfe76bf19764ae"
dependencies:
commander "2"
iconv-lite "0.4"
rw "1"
d3-force@1:
version "1.1.0"
resolved "https://registry.yarnpkg.com/d3-force/-/d3-force-1.1.0.tgz#cebf3c694f1078fcc3d4daf8e567b2fbd70d4ea3"
dependencies:
d3-collection "1"
d3-dispatch "1"
d3-quadtree "1"
d3-timer "1"
d3-format@1:
version "1.2.2"
resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.2.2.tgz#1a39c479c8a57fe5051b2e67a3bee27061a74e7a"
d3-geo@1:
version "1.9.1"
resolved "https://registry.yarnpkg.com/d3-geo/-/d3-geo-1.9.1.tgz#157e3b0f917379d0f73bebfff3be537f49fa7356"
dependencies:
d3-array "1"
d3-hierarchy@1:
version "1.1.5"
resolved "https://registry.yarnpkg.com/d3-hierarchy/-/d3-hierarchy-1.1.5.tgz#a1c845c42f84a206bcf1c01c01098ea4ddaa7a26"
d3-interpolate@1:
version "1.1.6"
resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-1.1.6.tgz#2cf395ae2381804df08aa1bf766b7f97b5f68fb6"
dependencies:
d3-color "1"
d3-path@1:
version "1.0.5"
resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.5.tgz#241eb1849bd9e9e8021c0d0a799f8a0e8e441764"
d3-quadtree@1:
version "1.0.3"
resolved "https://registry.yarnpkg.com/d3-quadtree/-/d3-quadtree-1.0.3.tgz#ac7987e3e23fe805a990f28e1b50d38fcb822438"
d3-request@1:
version "1.0.6"
resolved "https://registry.yarnpkg.com/d3-request/-/d3-request-1.0.6.tgz#a1044a9ef4ec28c824171c9379fae6d79474b19f"
dependencies:
d3-collection "1"
d3-dispatch "1"
d3-dsv "1"
xmlhttprequest "1"
d3-scale-chromatic@^1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/d3-scale-chromatic/-/d3-scale-chromatic-1.1.1.tgz#811406e8e09dab78a49dac4a32047d5d3edd0c44"
dependencies:
d3-interpolate "1"
d3-scale@1:
version "1.0.7"
resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-1.0.7.tgz#fa90324b3ea8a776422bd0472afab0b252a0945d"
dependencies:
d3-array "^1.2.0"
d3-collection "1"
d3-color "1"
d3-format "1"
d3-interpolate "1"
d3-time "1"
d3-time-format "2"
d3-shape@1:
version "1.2.0"
resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-1.2.0.tgz#45d01538f064bafd05ea3d6d2cb748fd8c41f777"
dependencies:
d3-path "1"
d3-time-format@2:
version "2.1.1"
resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-2.1.1.tgz#85b7cdfbc9ffca187f14d3c456ffda268081bb31"
dependencies:
d3-time "1"
d3-time@1:
version "1.0.8"
resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-1.0.8.tgz#dbd2d6007bf416fe67a76d17947b784bffea1e84"
d3-timer@1:
version "1.0.7"
resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-1.0.7.tgz#df9650ca587f6c96607ff4e60cc38229e8dd8531"
d3-voronoi@1:
version "1.1.2"
resolved "https://registry.yarnpkg.com/d3-voronoi/-/d3-voronoi-1.1.2.tgz#1687667e8f13a2d158c80c1480c5a29cb0d8973c"
d3@3.5.6:
version "3.5.6"
resolved "https://registry.yarnpkg.com/d3/-/d3-3.5.6.tgz#9451c651ca733fb9672c81fb7f2655164a73a42d"
@ -5071,6 +5205,10 @@ history@^4.7.2:
value-equal "^0.4.0"
warning "^3.0.0"
hjson@3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/hjson/-/hjson-3.1.0.tgz#dd468d0a74fe227b79afd85b0df677433a633501"
hmac-drbg@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"
@ -5256,7 +5394,7 @@ husky@0.8.1:
version "0.8.1"
resolved "https://registry.yarnpkg.com/husky/-/husky-0.8.1.tgz#ecc797b8c4c6893a33f48703bc97a9a5e50d860f"
iconv-lite@0.4.19, iconv-lite@^0.4.13, iconv-lite@^0.4.17, iconv-lite@~0.4.13:
iconv-lite@0.4, iconv-lite@0.4.19, iconv-lite@^0.4.13, iconv-lite@^0.4.17, iconv-lite@~0.4.13:
version "0.4.19"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b"
@ -6468,6 +6606,10 @@ json-stable-stringify@^1.0.1:
dependencies:
jsonify "~0.0.0"
json-stringify-pretty-compact@1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/json-stringify-pretty-compact/-/json-stringify-pretty-compact-1.0.4.tgz#d5161131be27fd9748391360597fcca250c6c5ce"
json-stringify-safe@5.0.1, json-stringify-safe@5.0.x, json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.0, json-stringify-safe@~5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
@ -6927,7 +7069,7 @@ lodash._topath@^3.0.0:
dependencies:
lodash.isarray "^3.0.0"
lodash.assign@^4.2.0:
lodash.assign@^4.0.3, lodash.assign@^4.0.6, lodash.assign@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-4.2.0.tgz#0d99f3ccd7a6d261d19bdaeb9245005d285808e7"
@ -7669,7 +7811,7 @@ mv@~2:
ncp "~2.0.0"
rimraf "~2.4.0"
nan@^2.0.8, nan@^2.3.0, nan@^2.3.2:
nan@^2.0.8, nan@^2.3.0, nan@^2.3.2, nan@^2.4.0:
version "2.8.0"
resolved "https://registry.yarnpkg.com/nan/-/nan-2.8.0.tgz#ed715f3fe9de02b57a5e6252d90a96675e1f085a"
@ -7823,7 +7965,7 @@ node-notifier@^5.0.2:
shellwords "^0.1.0"
which "^1.2.12"
node-pre-gyp@^0.6.39:
node-pre-gyp@^0.6.29, node-pre-gyp@^0.6.39:
version "0.6.39"
resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.6.39.tgz#c00e96860b23c0e1420ac7befc5044e1d78d8649"
dependencies:
@ -9923,6 +10065,10 @@ run-async@^2.0.0, run-async@^2.2.0:
dependencies:
is-promise "^2.1.0"
rw@1:
version "1.3.3"
resolved "https://registry.yarnpkg.com/rw/-/rw-1.3.3.tgz#3f862dfa91ab766b14885ef4d01124bfda074fb4"
rx-lite-aggregates@^4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/rx-lite-aggregates/-/rx-lite-aggregates-4.0.8.tgz#753b87a89a11c95467c4ac1626c4efc4e05c67be"
@ -10998,6 +11144,12 @@ topo@2.x.x:
dependencies:
hoek "4.x.x"
topojson-client@3:
version "3.0.0"
resolved "https://registry.yarnpkg.com/topojson-client/-/topojson-client-3.0.0.tgz#1f99293a77ef42a448d032a81aa982b73f360d2f"
dependencies:
commander "2"
tough-cookie@>=0.12.0, tough-cookie@^2.0.0, tough-cookie@^2.3.1, tough-cookie@^2.3.2, tough-cookie@~2.3.0, tough-cookie@~2.3.3:
version "2.3.3"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.3.3.tgz#0b618a5565b6dea90bf3425d04d55edc475a7561"
@ -11070,6 +11222,10 @@ trunc-text@1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/trunc-text/-/trunc-text-1.0.2.tgz#b582bb3ddea9c9adc25017d737c48ebdd2157406"
tslib@^1.8.0:
version "1.8.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.8.1.tgz#6946af2d1d651a7b1863b531d6e5afa41aa44eac"
tty-browserify@0.0.0:
version "0.0.0"
resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6"
@ -11416,6 +11572,230 @@ vary@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
vega-crossfilter@2:
version "2.0.0"
resolved "https://registry.yarnpkg.com/vega-crossfilter/-/vega-crossfilter-2.0.0.tgz#29a8d789add5a2d0f25a4cdedb16713bf4f39061"
dependencies:
d3-array "1"
vega-dataflow "3"
vega-util "1"
vega-dataflow@3:
version "3.0.4"
resolved "https://registry.yarnpkg.com/vega-dataflow/-/vega-dataflow-3.0.4.tgz#403a79d797a61016c66a90fb58c6a6530759384b"
dependencies:
vega-loader "2"
vega-util "1"
vega-encode@2:
version "2.0.6"
resolved "https://registry.yarnpkg.com/vega-encode/-/vega-encode-2.0.6.tgz#ba8c81e9b0b4fe04b879b643855222c6dd8d4849"
dependencies:
d3-array "1"
d3-format "1"
d3-interpolate "1"
vega-dataflow "3"
vega-scale "^2.1"
vega-util "1"
vega-event-selector@2, vega-event-selector@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/vega-event-selector/-/vega-event-selector-2.0.0.tgz#6af8dc7345217017ceed74e9155b8d33bad05d42"
vega-expression@2:
version "2.3.1"
resolved "https://registry.yarnpkg.com/vega-expression/-/vega-expression-2.3.1.tgz#d802a329190bdeb999ce6d8083af56b51f686e83"
dependencies:
vega-util "1"
vega-force@2:
version "2.0.0"
resolved "https://registry.yarnpkg.com/vega-force/-/vega-force-2.0.0.tgz#03084bfcb6f762d01162fb71dee165067fe0e7af"
dependencies:
d3-force "1"
vega-dataflow "3"
vega-util "1"
vega-geo@^2.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/vega-geo/-/vega-geo-2.1.1.tgz#eaf128927cd146e1c0843d15f25a0a08d5dbf524"
dependencies:
d3-array "1"
d3-contour "1"
d3-geo "1"
vega-dataflow "3"
vega-projection "1"
vega-util "1"
vega-hierarchy@^2.1:
version "2.1.0"
resolved "https://registry.yarnpkg.com/vega-hierarchy/-/vega-hierarchy-2.1.0.tgz#92119c43a9dc8f534c1836446661161dc9e42196"
dependencies:
d3-collection "1"
d3-hierarchy "1"
vega-dataflow "3"
vega-util "1"
vega-lite@2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/vega-lite/-/vega-lite-2.0.3.tgz#3b42a3ee002cfd0d6c082a2b029d30f01c987ab4"
dependencies:
json-stable-stringify "^1.0.1"
tslib "^1.8.0"
vega-event-selector "^2.0.0"
vega-util "^1.6.2"
yargs "^10.0.3"
vega-loader@2:
version "2.0.3"
resolved "https://registry.yarnpkg.com/vega-loader/-/vega-loader-2.0.3.tgz#1c1c221128c27a85f1fe5b4e9f8d709d541723e6"
dependencies:
d3-dsv "1"
d3-request "1"
d3-time-format "2"
topojson-client "3"
vega-util "1"
vega-parser@2, vega-parser@^2.2:
version "2.3.2"
resolved "https://registry.yarnpkg.com/vega-parser/-/vega-parser-2.3.2.tgz#ba5e6cd789369604d066139a1d9b16934228191d"
dependencies:
d3-array "1"
d3-color "1"
d3-format "1"
d3-time-format "2"
vega-dataflow "3"
vega-event-selector "2"
vega-expression "2"
vega-scale "2"
vega-scenegraph "2"
vega-statistics "^1.2"
vega-util "^1.6"
vega-projection@1:
version "1.0.0"
resolved "https://registry.yarnpkg.com/vega-projection/-/vega-projection-1.0.0.tgz#8875aefe9bc0b7b215ff2fa3358626efffa5c664"
dependencies:
d3-geo "1"
vega-runtime@2:
version "2.0.1"
resolved "https://registry.yarnpkg.com/vega-runtime/-/vega-runtime-2.0.1.tgz#ef971ca3496df1cdbc0725699540952276c5f145"
dependencies:
vega-dataflow "3"
vega-util "1"
vega-scale@2, vega-scale@^2.1:
version "2.1.0"
resolved "https://registry.yarnpkg.com/vega-scale/-/vega-scale-2.1.0.tgz#2b992cbb652a64d64b66015bf3a329ecaa7a3d32"
dependencies:
d3-array "1"
d3-interpolate "1"
d3-scale "1"
d3-scale-chromatic "^1.1"
d3-time "1"
vega-util "1"
vega-scenegraph@2:
version "2.0.4"
resolved "https://registry.yarnpkg.com/vega-scenegraph/-/vega-scenegraph-2.0.4.tgz#891afdfce9964a434e640712ee8a135ea528becc"
dependencies:
d3-path "1"
d3-shape "1"
vega-loader "2"
vega-util "^1.1"
optionalDependencies:
canvas "^1.6"
canvas-prebuilt "^1.6"
vega-schema-url-parser@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/vega-schema-url-parser/-/vega-schema-url-parser-1.0.0.tgz#fc17631e354280d663ed39e3fa8eddb62145402e"
vega-statistics@^1.2:
version "1.2.1"
resolved "https://registry.yarnpkg.com/vega-statistics/-/vega-statistics-1.2.1.tgz#a35b3fc3d0039f8bb0a8ba1381d42a1df79ecb34"
dependencies:
d3-array "1"
vega-transforms@^1.1:
version "1.1.2"
resolved "https://registry.yarnpkg.com/vega-transforms/-/vega-transforms-1.1.2.tgz#3881512569d4d1c1e62c3272ee06023f1f622468"
dependencies:
d3-array "1"
vega-dataflow "3"
vega-statistics "^1.2"
vega-util "1"
vega-util@1, vega-util@^1.1, vega-util@^1.6, vega-util@^1.6.2:
version "1.6.2"
resolved "https://registry.yarnpkg.com/vega-util/-/vega-util-1.6.2.tgz#9d9d7cc65dfc9cd70eeb8dba8bb5c9924be5dacc"
vega-view-transforms@^1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/vega-view-transforms/-/vega-view-transforms-1.1.1.tgz#191110f5f586fdf4fce5cee652afde193a4d28be"
dependencies:
vega-dataflow "3"
vega-scenegraph "2"
vega-util "1"
vega-view@2:
version "2.0.4"
resolved "https://registry.yarnpkg.com/vega-view/-/vega-view-2.0.4.tgz#a67895f0e45623a3a1181a0725afedfd560f4797"
dependencies:
d3-array "1"
vega-dataflow "3"
vega-parser "2"
vega-runtime "2"
vega-scenegraph "2"
vega-util "1"
vega-voronoi@2:
version "2.0.0"
resolved "https://registry.yarnpkg.com/vega-voronoi/-/vega-voronoi-2.0.0.tgz#6df399181dc070a2ef52234ebfe5d7cebd0f3802"
dependencies:
d3-voronoi "1"
vega-dataflow "3"
vega-util "1"
vega-wordcloud@2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/vega-wordcloud/-/vega-wordcloud-2.0.2.tgz#853be1b1492ba749001e5483be30380662ff2c59"
dependencies:
vega-dataflow "3"
vega-scale "2"
vega-statistics "^1.2"
vega-util "1"
optionalDependencies:
canvas "^1.6"
canvas-prebuilt "^1.6"
vega@3.0.8:
version "3.0.8"
resolved "https://registry.yarnpkg.com/vega/-/vega-3.0.8.tgz#14c38908ab101048309866f1655f7d031dca7e3f"
dependencies:
vega-crossfilter "2"
vega-dataflow "3"
vega-encode "2"
vega-expression "2"
vega-force "2"
vega-geo "^2.1"
vega-hierarchy "^2.1"
vega-loader "2"
vega-parser "^2.2"
vega-projection "1"
vega-runtime "2"
vega-scale "^2.1"
vega-scenegraph "2"
vega-statistics "^1.2"
vega-transforms "^1.1"
vega-util "^1.6"
vega-view "2"
vega-view-transforms "^1.1"
vega-voronoi "2"
vega-wordcloud "2"
yargs "4"
vendors@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/vendors/-/vendors-1.0.1.tgz#37ad73c8ee417fb3d580e785312307d274847f22"
@ -11718,6 +12098,10 @@ window-size@0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.1.0.tgz#5438cd2ea93b202efa3a19fe8887aee7c94f9c9d"
window-size@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.2.0.tgz#b4315bb4214a3d7058ebeee892e13fa24d98b075"
with@~4.0.0:
version "4.0.3"
resolved "https://registry.yarnpkg.com/with/-/with-4.0.3.tgz#eefd154e9e79d2c8d3417b647a8f14d9fecce14e"
@ -11891,6 +12275,10 @@ xmlhttprequest-ssl@1.5.3:
version "1.5.3"
resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.3.tgz#185a888c04eca46c3e4070d99f7b49de3528992d"
xmlhttprequest@1:
version "1.8.0"
resolved "https://registry.yarnpkg.com/xmlhttprequest/-/xmlhttprequest-1.8.0.tgz#67fe075c5c24fef39f9d65f5f7b7fe75171968fc"
"xtend@>=4.0.0 <4.1.0-0", xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.0, xtend@~4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af"
@ -11903,6 +12291,13 @@ yallist@^2.0.0, yallist@^2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
yargs-parser@^2.4.1:
version "2.4.1"
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-2.4.1.tgz#85568de3cf150ff49fa51825f03a8c880ddcc5c4"
dependencies:
camelcase "^3.0.0"
lodash.assign "^4.0.6"
yargs-parser@^4.2.0:
version "4.2.1"
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-4.2.1.tgz#29cceac0dc4f03c6c87b4a9f217dd18c9f74871c"
@ -11927,6 +12322,25 @@ yargs-parser@^8.1.0:
dependencies:
camelcase "^4.1.0"
yargs@4:
version "4.8.1"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-4.8.1.tgz#c0c42924ca4aaa6b0e6da1739dfb216439f9ddc0"
dependencies:
cliui "^3.2.0"
decamelize "^1.1.1"
get-caller-file "^1.0.1"
lodash.assign "^4.0.3"
os-locale "^1.4.0"
read-pkg-up "^1.0.1"
require-directory "^2.1.1"
require-main-filename "^1.0.1"
set-blocking "^2.0.0"
string-width "^1.0.1"
which-module "^1.0.0"
window-size "^0.2.0"
y18n "^3.2.1"
yargs-parser "^2.4.1"
yargs@^10.0.3:
version "10.1.1"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-10.1.1.tgz#5fe1ea306985a099b33492001fa19a1e61efe285"