adding heatmap visualization

This commit is contained in:
ppisljar 2016-12-07 11:05:34 +01:00
parent d3b3065603
commit 9bc3380648
10 changed files with 666 additions and 0 deletions

View file

@ -0,0 +1,94 @@
<div>
<div class="kuiSideBarFormRow">
<label class="kuiSideBarFormRow__label" for="colorSchema">
Color Schema
</label>
<div class="kuiSideBarFormRow__control">
<select
id="colorSchema"
class="kuiSelect kuiSideBarSelect"
ng-model="vis.params.colorSchema"
ng-options="mode for mode in vis.type.params.colorSchemas"
></select>
</div>
</div>
<div class="kuiSideBarFormRow">
<label class="kuiSideBarFormRow__label" for="colorsNumber">
Number of colors
</label>
<div class="kuiSideBarFormRow__control">
<input
id="colorsNumber"
type="range"
step="1"
min="3"
max="10"
class="kuiInput kuiSideBarInput"
ng-model="vis.params.colorsNumber"
/>
</div>
</div>
<div class="kuiSideBarFormRow">
<label class="kuiSideBarFormRow__label" for="axisScale">
Z Axis Scale
</label>
<div class="kuiSideBarFormRow__control">
<select
id="axisScale"
class="kuiSelect kuiSideBarSelect"
ng-model="vis.params.scale"
ng-options="mode for mode in vis.type.params.scales"
></select>
</div>
</div>
<div class="kuiSideBarFormRow">
<label class="kuiSideBarFormRow__label" for="setColorRange">
Custom Range
</label>
<div class="kuiSideBarFormRow__control">
<input class="kuiCheckBox" id="setColorRange" type="checkbox" ng-model="vis.params.setColorRange">
</div>
</div>
<div ng-if="vis.params.setColorRange">
<div class="kuiSideBarCollapsibleTitle">
<div
class="kuiSideBarCollapsibleTitle__label"
ng-click="isColorRangeOpen = !isColorRangeOpen"
>
<span
aria-hidden="true"
ng-class="{ 'fa-caret-down': isColorRangeOpen, 'fa-caret-right': !isColorRangeOpen }"
class="fa fa-caret-right kuiSideBarCollapsibleTitle__caret"
></span>
<span class="kuiSideBarCollapsibleTitle__text">
Custom Ranges
</span>
</div>
</div>
<div ng-show="isColorRangeOpen" class="kuiSideBarCollapsibleSection">
<div class="kuiSideBarSection">
<div class="kuiSideBarFormRow" ng-repeat="color in vis.params.colorsRange track by $index">
<label class="kuiSideBarFormRow__label" for="{{ 'colorRange' + $index }}">
<div class="kuiSideBarFormRow__label__colorbox" ng-style="{ 'background-color': getColor($index) }"></div>
value over
</label>
<div class="kuiSideBarFormRow__control">
<input
id="{{ 'colorRange' + $index }}"
class="kuiInput kuiSideBarInput"
ng-model="color.value"
type="number"
ng-required="vis.params.setColorRange"
>
</div>
</div>
</div>
<div class="text text-center text-info">note: colors can be changed in the legend</div>
</div>
</div>
</div>

View file

@ -0,0 +1,39 @@
import uiModules from 'ui/modules';
import heatmapOptionsTemplate from 'plugins/kbn_vislib_vis_types/controls/heatmap_range_option.html';
import colorFunc from 'ui/vislib/components/color/heatmap_color';
const module = uiModules.get('kibana');
module.directive('heatmapOptions', function ($parse, $compile) {
return {
restrict: 'E',
template: heatmapOptionsTemplate,
replace: true,
link: function ($scope) {
$scope.isColorRangeOpen = false;
$scope.getColor = function (index) {
const colors = $scope.uiState.get('vis.colors');
return colors ? Object.values(colors)[index] : 'transparent';
};
function fillColorsRange() {
for (let i = $scope.vis.params.colorsRange.length; i < $scope.vis.params.colorsNumber; i++) {
$scope.vis.params.colorsRange.push({ value: 0 });
}
$scope.vis.params.colorsRange.length = $scope.vis.params.colorsNumber;
}
fillColorsRange();
$scope.$watch('vis.params.colorsNumber', newVal => {
if (newVal) {
fillColorsRange();
}
});
$scope.uiState.on('colorChanged', () => {
$scope.realVis.params.colorSchema = 'custom';
$scope.vis.params.colorSchema = 'custom';
});
}
};
});

View file

@ -0,0 +1,56 @@
<div class="kuiSideBarSection">
<div class="kuiSideBarSectionTitle">
<div class="kuiSideBarSectionTitle__text">
Global Settings
</div>
</div>
<div class="kuiSideBarFormRow">
<label class="kuiSideBarFormRow__label" for="addTooltip">
Show Tooltips
</label>
<div class="kuiSideBarFormRow__control">
<input class="kuiCheckBox" id="addTooltip" type="checkbox" ng-model="vis.params.addTooltip">
</div>
</div>
<div class="kuiSideBarFormRow">
<label class="kuiSideBarFormRow__label" for="enableHover">
Highlight
</label>
<div class="kuiSideBarFormRow__control">
<input class="kuiCheckBox" id="enableHover" type="checkbox" ng-model="vis.params.enableHover">
</div>
</div>
<div class="kuiSideBarFormRow" ng-show="vis.params.addLegend">
<label class="kuiSideBarFormRow__label" for="legendPosition">
Legend Position
</label>
<div class="kuiSideBarFormRow__control">
<select
id="legendPosition"
class="kuiSelect kuiSideBarSelect"
ng-model="vis.params.legendPosition"
ng-options="position.value as position.text for position in vis.type.params.legendPositions"
></select>
</div>
</div>
<div class="kuiSideBarFormRow">
<label class="kuiSideBarFormRow__label" for="defaultYExtents">
Scale to Data Bounds
</label>
<div class="kuiSideBarFormRow__control">
<input class="kuiCheckBox" id="defaultYExtents" type="checkbox" ng-model="vis.params.defaultYExtents">
</div>
</div>
<div class="kuiSideBarSectionTitle">
<div class="kuiSideBarSectionTitle__text">
Heatmap Settings
</div>
</div>
<heatmap-options></heatmap-options>
</div>

View file

@ -0,0 +1,86 @@
import VislibVisTypeVislibVisTypeProvider from 'ui/vislib_vis_type/vislib_vis_type';
import VisSchemasProvider from 'ui/vis/schemas';
import heatmapTemplate from 'plugins/kbn_vislib_vis_types/editors/heatmap.html';
export default function HeatmapVisType(Private) {
const VislibVisType = Private(VislibVisTypeVislibVisTypeProvider);
const Schemas = Private(VisSchemasProvider);
return new VislibVisType({
name: 'heatmap',
title: 'Heatmap chart',
icon: 'fa-barcode',
description: 'A heat map is a graphical representation of data' +
' where the individual values contained in a matrix are represented as colors. ',
params: {
defaults: {
addTooltip: true,
addLegend: true,
enableHover: false,
legendPosition: 'right',
scale: 'linear',
times: [],
addTimeMarker: false,
defaultYExtents: false,
setYExtents: false,
colorsNumber: 4,
colorSchema: 'yellow to red',
setColorRange: false,
colorsRange: [],
},
legendPositions: [{
value: 'left',
text: 'left',
}, {
value: 'right',
text: 'right',
}, {
value: 'top',
text: 'top',
}, {
value: 'bottom',
text: 'bottom',
}],
scales: ['linear', 'log', 'square root'],
colorSchemas: ['yellow to red', 'reds', 'greens', 'blues', 'custom'],
editor: heatmapTemplate
},
schemas: new Schemas([
{
group: 'metrics',
name: 'metric',
title: 'Value',
min: 1,
max: 1,
aggFilter: ['count', 'avg', 'median', 'sum', 'min', 'max', 'cardinality', 'std_dev'],
defaults: [
{ schema: 'metric', type: 'count' }
]
},
{
group: 'buckets',
name: 'segment',
title: 'X-Axis',
min: 0,
max: 1,
aggFilter: '!geohash_grid'
},
{
group: 'buckets',
name: 'group',
title: 'Y-Axis',
min: 0,
max: 1,
aggFilter: '!geohash_grid'
},
{
group: 'buckets',
name: 'split',
title: 'Split Chart',
min: 0,
max: 1,
aggFilter: '!geohash_grid'
}
])
});
};

View file

@ -4,3 +4,4 @@ visTypes.register(require('plugins/kbn_vislib_vis_types/line'));
visTypes.register(require('plugins/kbn_vislib_vis_types/pie'));
visTypes.register(require('plugins/kbn_vislib_vis_types/area'));
visTypes.register(require('plugins/kbn_vislib_vis_types/tile_map'));
visTypes.register(require('plugins/kbn_vislib_vis_types/heatmap'));

View file

@ -0,0 +1,62 @@
import expect from 'expect.js';
import ngMock from 'ng_mock';
import getColors from 'ui/vislib/components/color/heatmap_color';
describe('Vislib Heatmap Color Module Test Suite', function () {
const emptyObject = {};
const nullValue = null;
let notAValue;
beforeEach(ngMock.module('kibana'));
it('should throw an error if input is not a number', function () {
expect(function () {
getColors([200]);
}).to.throwError();
expect(function () {
getColors('help');
}).to.throwError();
expect(function () {
getColors(true);
}).to.throwError();
expect(function () {
getColors(notAValue);
}).to.throwError();
expect(function () {
getColors(nullValue);
}).to.throwError();
expect(function () {
getColors(emptyObject);
}).to.throwError();
});
it('should throw an error if input is less than 0', function () {
expect(function () {
getColors(-2);
}).to.throwError();
});
it('should throw an error if input is greater than 9', function () {
expect(function () {
getColors(10);
}).to.throwError();
});
it('should be a function', function () {
expect(typeof getColors).to.be('function');
});
it('should return a color for numbers from 0 to 9', function () {
const colorRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/;
const schema = 'reds';
for (let i = 0; i < 10; i++) {
expect(getColors(i, schema)).to.match(colorRegex);
}
});
});

View file

@ -0,0 +1,127 @@
import expect from 'expect.js';
import ngMock from 'ng_mock';
import _ from 'lodash';
import d3 from 'd3';
// Data
import series from 'fixtures/vislib/mock_data/date_histogram/_series';
import seriesPosNeg from 'fixtures/vislib/mock_data/date_histogram/_series_pos_neg';
import seriesNeg from 'fixtures/vislib/mock_data/date_histogram/_series_neg';
import termsColumns from 'fixtures/vislib/mock_data/terms/_columns';
import stackedSeries from 'fixtures/vislib/mock_data/date_histogram/_stacked_series';
import $ from 'jquery';
import FixturesVislibVisFixtureProvider from 'fixtures/vislib/_vis_fixture';
import PersistedStatePersistedStateProvider from 'ui/persisted_state/persisted_state';
// tuple, with the format [description, mode, data]
const dataTypesArray = [
['series', series],
['series with positive and negative values', seriesPosNeg],
['series with negative values', seriesNeg],
['terms columns', termsColumns],
['stackedSeries', stackedSeries],
];
describe('Vislib Heatmap Cyart Test Suite', function () {
dataTypesArray.forEach(function (dataType, i) {
const name = dataType[0];
const data = dataType[1];
describe('for ' + name + ' Data', function () {
let vis;
let persistedState;
const visLibParams = {
type: 'heatmap',
addLegend: true,
addTooltip: true,
colorsNumber: 4,
colorSchema: 'yellow to red',
};
beforeEach(ngMock.module('kibana'));
beforeEach(ngMock.inject(function (Private) {
vis = Private(FixturesVislibVisFixtureProvider)(visLibParams);
persistedState = new (Private(PersistedStatePersistedStateProvider))();
vis.on('brush', _.noop);
vis.render(data, persistedState);
}));
afterEach(function () {
vis.destroy();
});
describe('addSquares method', function () {
it('should append rects', function () {
let numOfSeries;
let numOfValues;
let product;
vis.handler.charts.forEach(function (chart) {
const numOfRects = chart.chartData.series.reduce((result, series) => {
return result + series.values.length;
}, 0);
expect($(chart.chartEl).find('.series rect')).to.have.length(numOfRects);
});
});
});
describe('addBarEvents method', function () {
function checkChart(chart) {
const rect = $(chart.chartEl).find('.series rect').get(0);
return {
click: !!rect.__onclick,
mouseOver: !!rect.__onmouseover,
// D3 brushing requires that a g element is appended that
// listens for mousedown events. This g element includes
// listeners, however, I was not able to test for the listener
// function being present. I will need to update this test
// in the future.
brush: !!d3.select('.brush')[0][0]
};
}
it('should attach the brush if data is a set of ordered dates', function () {
vis.handler.charts.forEach(function (chart) {
const has = checkChart(chart);
const ordered = vis.handler.data.get('ordered');
const date = Boolean(ordered && ordered.date);
expect(has.brush).to.be(date);
});
});
it('should attach a click event', function () {
vis.handler.charts.forEach(function (chart) {
const has = checkChart(chart);
expect(has.click).to.be(true);
});
});
it('should attach a hover event', function () {
vis.handler.charts.forEach(function (chart) {
const has = checkChart(chart);
expect(has.mouseOver).to.be(true);
});
});
});
describe('draw method', function () {
it('should return a function', function () {
vis.handler.charts.forEach(function (chart) {
expect(_.isFunction(chart.draw())).to.be(true);
});
});
it('should return a yMin and yMax', function () {
vis.handler.charts.forEach(function (chart) {
const yAxis = chart.handler.valueAxes[0];
const domain = yAxis.getScale().domain();
expect(domain[0]).to.not.be(undefined);
expect(domain[1]).to.not.be(undefined);
});
});
});
});
});
});

View file

@ -0,0 +1,36 @@
import _ from 'lodash';
const reds = [
'#99000E', '#A41926', '#AF333E', '#BB4C56', '#C6666E', '#D17F86', '#DD999E', '#E8B2B6', '#F3CCCE', '#FFE6E6'
];
const greens = [
'#E5F5F9', '#CBE1E0', '#B2CDC7', '#98BAAF', '#7FA696', '#65927D', '#4C7F65', '#326B4C', '#195733', '#00441B'
];
const blues = [
'#DEEBF7', '#C6D6E7', '#AEC1D7', '#96ACC8', '#7E97B8', '#6783A9', '#4F6E99', '#37598A', '#1F447A', '#08306B'
];
const yellowtored = [
'#F8F840', '#EEDA35', '#E4BC2A', '#DB9F1F', '#D18114', '#C8640A', '#D05415', '#D84520', '#E0362B', '#E82736'
];
export default function (value, colorSchema) {
if (!_.isNumber(value) || value < 0 || value > 9) {
throw new Error('heatmap_color expects a number from 0 to 9 as first parameter');
}
switch (colorSchema) {
case 'reds':
return reds[9 - value];
case 'greens':
return greens[value];
case 'blues':
return blues[value];
case 'yellow to red':
return yellowtored[value];
default:
const start = 120;
const end = 360;
const c = start + (end - start) * (value * 10);
return `hsl(${c},60%,50%)`;
}
};

View file

@ -0,0 +1,164 @@
import _ from 'lodash';
import moment from 'moment';
import VislibVisualizationsPointSeriesProvider from './_point_series';
import colorFunc from 'ui/vislib/components/color/heatmap_color';
export default function HeatmapChartFactory(Private) {
const PointSeries = Private(VislibVisualizationsPointSeriesProvider);
const defaults = {
color: undefined, // todo
fillColor: undefined // todo
};
/**
* Line Chart Visualization
*
* @class HeatmapChart
* @constructor
* @extends Chart
* @param handler {Object} Reference to the Handler Class Constructor
* @param el {HTMLElement} HTML element to which the chart will be appended
* @param chartData {Object} Elasticsearch query results for this specific chart
*/
class HeatmapChart extends PointSeries {
constructor(handler, chartEl, chartData, seriesConfigArgs) {
super(handler, chartEl, chartData, seriesConfigArgs);
this.seriesConfig = _.defaults(seriesConfigArgs || {}, defaults);
}
addSquares(svg, data) {
const xScale = this.getCategoryAxis().getScale();
const yScale = this.handler.categoryAxes[1].getScale();
const zScale = this.getValueAxis().getScale();
const ordered = this.handler.data.get('ordered');
const tooltip = this.baseChart.tooltip;
const isTooltip = this.handler.visConfig.get('tooltip.show');
const isHorizontal = this.getCategoryAxis().axisConfig.isHorizontal();
const colorsNumber = this.handler.visConfig.get('colorsNumber');
const setColorRange = this.handler.visConfig.get('setColorRange');
const colorsRange = this.handler.visConfig.get('colorsRange');
const color = this.handler.data.getColorFunc();
const layer = svg.append('g')
.attr('class', 'series');
const squares = layer
.selectAll('rect')
.data(data.values);
squares
.exit()
.remove();
let barWidth;
if (this.getCategoryAxis().axisConfig.isTimeDomain()) {
const { min, interval } = this.handler.data.get('ordered');
const start = min;
const end = moment(min).add(interval).valueOf();
barWidth = xScale(end) - xScale(start);
if (!isHorizontal) barWidth *= -1;
barWidth = barWidth - Math.min(barWidth * 0.25, 15);
}
function x(d) {
return xScale(d.x);
}
function y(d) {
return yScale(d.series);
}
const [min, max] = zScale.domain();
function getColorBucket(d) {
let val = 0;
if (setColorRange && colorsRange.length) {
if (d.y < colorsRange[0].value) return -1;
while (val + 1 < colorsRange.length && d.y > colorsRange[val + 1].value) val++;
} else {
if (isNaN(min) || isNaN(max)) {
val = colorsNumber - 1;
} else {
val = (d.y - min) / (max - min); /* get val from 0 - 1 */
val = Math.floor(val * (colorsNumber - 1));
}
}
return val;
}
function label(d) {
const colorBucket = getColorBucket(d);
let label;
if (colorBucket < 0) return '';
const val = Math.ceil(colorBucket * (100 / colorsNumber));
if (setColorRange) {
const greaterThan = colorsRange[colorBucket].value;
label = `> ${greaterThan}`;
} else {
const nextVal = Math.ceil((colorBucket + 1) * (100 / colorsNumber));
label = `${val}% - ${nextVal}%`;
}
return label;
}
function z(d) {
if (label(d) === '') return 'transparent';
return color(label(d));
}
function widthFunc() {
return barWidth || xScale.rangeBand();
}
function heightFunc() {
return yScale.rangeBand();
}
squares
.enter()
.append('rect')
.attr('x', isHorizontal ? x : y)
.attr('width', isHorizontal ? widthFunc : heightFunc)
.attr('y', isHorizontal ? y : x)
.attr('height', isHorizontal ? heightFunc : widthFunc)
.attr('data-label', label)
.attr('fill', z)
.attr('style', 'cursor: pointer');
if (isTooltip) {
squares.call(tooltip.render());
}
return squares;
};
/**
* Renders d3 visualization
*
* @method draw
* @returns {Function} Creates the line chart
*/
draw() {
const self = this;
return function (selection) {
selection.each(function () {
const svg = self.chartEl.append('g');
svg.data([self.chartData]);
const squares = self.addSquares(svg, self.chartData);
self.addCircleEvents(squares);
self.events.emit('rendered', {
chart: self.chartData
});
return svg;
});
};
};
}
return HeatmapChart;
};

View file

@ -3,6 +3,7 @@ import 'ui/vislib';
import 'plugins/kbn_vislib_vis_types/controls/vislib_basic_options';
import 'plugins/kbn_vislib_vis_types/controls/point_series_options';
import 'plugins/kbn_vislib_vis_types/controls/line_interpolation_option';
import 'plugins/kbn_vislib_vis_types/controls/heatmap_range_option';
import VisSchemasProvider from 'ui/vis/schemas';
import VisVisTypeProvider from 'ui/vis/vis_type';
import AggResponsePointSeriesPointSeriesProvider from 'ui/agg_response/point_series/point_series';