pie chart labels (#12174)

* pie labels

* add simple unit test

* fixing dashboard test

* fixing basedo on review

* simplifying conflict resolution

* removing unused code

* cleanup code

* minor changes based on review

* updating option templates to match new design

* updating truncate_labels to work with chars instead pixels
This commit is contained in:
Peter Pisljar 2017-11-17 09:57:02 +01:00 committed by GitHub
parent 1d8d835ab7
commit 5793410a35
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 421 additions and 172 deletions

View file

@ -1,20 +1,25 @@
<div>
<div class="vis-option-item">
<label for="visualizeBasicSettingsLegendPosition">
<div class="form-group">
<div class="kuiSideBarFormRow">
<label class="kuiSideBarFormRow__label" for="visualizeBasicSettingsLegendPosition">
Legend Position
</label>
<select
id="visualizeBasicSettingsLegendPosition"
class="form-control"
ng-model="vis.params.legendPosition"
ng-options="position.value as position.text for position in vis.type.editorConfig.collections.legendPositions"
>
</select>
<div class="kuiSideBarFormRow__control">
<select
id="visualizeBasicSettingsLegendPosition"
class="form-control"
ng-model="vis.params.legendPosition"
ng-options="position.value as position.text for position in vis.type.editorConfig.collections.legendPositions"
>
</select>
</div>
</div>
<div class="vis-option-item">
<label id="visualizeBasicSettingsShowTooltip">
<input type="checkbox" ng-model="vis.params.addTooltip">
<div class="kuiSideBarFormRow">
<label class="kuiSideBarFormRow__label" for="showTooltip">
Show Tooltip
</label>
<div class="kuiSideBarFormRow__control">
<input id="showTooltip" type="checkbox" ng-model="vis.params.addTooltip">
</div>
</div>
</div>

View file

@ -1,8 +1,75 @@
<!-- vis type specific options -->
<div class="kuiSideBarSection">
<label>
<input type="checkbox" value="{{isDonut}}" ng-model="vis.params.isDonut" name="isDonut" ng-checked="vis.params.isDonut">
Donut
</label>
<div class="form-group">
<div class="kuiSideBarSectionTitle">
<div class="kuiSideBarSectionTitle__text">
Pie Settings
</div>
</div>
<div class="kuiSideBarFormRow">
<label class="kuiSideBarFormRow__label" for="isDonut">
Donut
</label>
<div class="kuiSideBarFormRow__control">
<input id="isDonut" name="isDonut" type="checkbox" value="{{isDonut}}"
ng-checked="vis.params.isDonut"
ng-model="vis.params.isDonut"
>
</div>
</div>
</div>
<vislib-basic-options></vislib-basic-options>
</div>
<!-- Labels -->
<div class="kuiSideBarSection">
<div class="form-group">
<div class="kuiSideBarSectionTitle">
<div class="kuiSideBarSectionTitle__text">
Labels Settings
</div>
</div>
<div class="kuiSideBarFormRow">
<label class="kuiSideBarFormRow__label" for="showLabels">
Show Labels
</label>
<div class="kuiSideBarFormRow__control">
<input class="kuiCheckBox" id="showLabels" type="checkbox" ng-model="vis.params.labels.show">
</div>
</div>
<div class="kuiSideBarFormRow">
<label class="kuiSideBarFormRow__label" for="showLastLevel">
Show Top Level Only
</label>
<div class="kuiSideBarFormRow__control">
<input class="kuiCheckBox" id="showLastLevel" type="checkbox" ng-model="vis.params.labels.last_level">
</div>
</div>
<div class="kuiSideBarFormRow">
<label class="kuiSideBarFormRow__label" for="showValues">
Show Values
</label>
<div class="kuiSideBarFormRow__control">
<input class="kuiCheckBox" id="showValues" type="checkbox" ng-model="vis.params.labels.values">
</div>
</div>
<div class="kuiSideBarFormRow">
<label class="kuiSideBarFormRow__label" for="truncateLabels">
Truncate
</label>
<div class="kuiSideBarFormRow__control">
<input
id="truncateLabels"
class="kuiInput kuiSideBarInput"
type="number"
ng-model="vis.params.labels.truncate"
>
</div>
</div>
</div>
</div>

View file

@ -20,7 +20,13 @@ export default function HistogramVisType(Private) {
addTooltip: true,
addLegend: true,
legendPosition: 'right',
isDonut: true
isDonut: true,
labels: {
show: false,
values: true,
last_level: true,
truncate: 100
}
},
},
editorConfig: {

View file

@ -154,144 +154,157 @@ describe('No global chart settings', function () {
});
});
aggArray.forEach(function (dataAgg, i) {
describe('Vislib PieChart Class Test Suite for ' + names[i] + ' data', function () {
const visLibParams = {
type: 'pie',
addLegend: true,
addTooltip: true
};
let vis;
let Vis;
let persistedState;
let indexPattern;
let buildHierarchicalData;
let data;
beforeEach(ngMock.module('kibana'));
beforeEach(ngMock.inject(function (Private, $injector) {
vis = Private(FixturesVislibVisFixtureProvider)(visLibParams);
Vis = Private(VisProvider);
persistedState = new ($injector.get('PersistedState'))();
indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider);
buildHierarchicalData = Private(BuildHierarchicalDataProvider);
let id = 1;
const stubVis = new Vis(indexPattern, {
describe('Vislib PieChart Class Test Suite', function () {
aggArray.forEach(function (dataAgg, i) {
describe('Vislib PieChart Class Test Suite for ' + names[i] + ' data', function () {
const visLibParams = {
type: 'pie',
aggs: dataAgg
});
addLegend: true,
addTooltip: true
};
let vis;
let Vis;
let persistedState;
let indexPattern;
let buildHierarchicalData;
let data;
// We need to set the aggs to a known value.
_.each(stubVis.aggs, function (agg) { agg.id = 'agg_' + id++; });
beforeEach(ngMock.module('kibana'));
beforeEach(ngMock.inject(function (Private, $injector) {
vis = Private(FixturesVislibVisFixtureProvider)(visLibParams);
Vis = Private(VisProvider);
persistedState = new ($injector.get('PersistedState'))();
indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider);
buildHierarchicalData = Private(BuildHierarchicalDataProvider);
data = buildHierarchicalData(stubVis, fixtures.threeTermBuckets);
vis.render(data, persistedState);
}));
afterEach(function () {
vis.destroy();
});
describe('addPathEvents method', function () {
let path;
let d3selectedPath;
let onClick;
let onMouseOver;
beforeEach(function () {
vis.handler.charts.forEach(function (chart) {
path = $(chart.chartEl).find('path')[0];
d3selectedPath = d3.select(path)[0][0];
// d3 instance of click and hover
onClick = (!!d3selectedPath.__onclick);
onMouseOver = (!!d3selectedPath.__onmouseover);
let id = 1;
const stubVis = new Vis(indexPattern, {
type: 'pie',
aggs: dataAgg
});
});
it('should attach a click event', function () {
vis.handler.charts.forEach(function () {
expect(onClick).to.be(true);
});
});
// We need to set the aggs to a known value.
_.each(stubVis.aggs, function (agg) { agg.id = 'agg_' + id++; });
it('should attach a hover event', function () {
vis.handler.charts.forEach(function () {
expect(onMouseOver).to.be(true);
});
});
});
data = buildHierarchicalData(stubVis, fixtures.threeTermBuckets);
describe('addPath method', function () {
let width;
let height;
let svg;
let slices;
beforeEach(ngMock.inject(function () {
vis.handler.charts.forEach(function (chart) {
width = $(chart.chartEl).width();
height = $(chart.chartEl).height();
svg = d3.select($(chart.chartEl).find('svg')[0]);
slices = chart.chartData.slices;
});
vis.render(data, persistedState);
}));
it('should return an SVG object', function () {
vis.handler.charts.forEach(function (chart) {
expect(_.isObject(chart.addPath(width, height, svg, slices))).to.be(true);
});
afterEach(function () {
vis.destroy();
});
it('should draw path elements', function () {
vis.handler.charts.forEach(function (chart) {
describe('addPathEvents method', function () {
let path;
let d3selectedPath;
let onClick;
let onMouseOver;
// test whether path elements are drawn
expect($(chart.chartEl).find('path').length).to.be.greaterThan(0);
});
});
});
describe('draw method', function () {
it('should return a function', function () {
vis.handler.charts.forEach(function (chart) {
expect(_.isFunction(chart.draw())).to.be(true);
});
});
});
sizes.forEach(function (size) {
describe('containerTooSmall error', function () {
it('should throw an error', function () {
// 20px is the minimum height and width
beforeEach(function () {
vis.handler.charts.forEach(function (chart) {
$(chart.chartEl).height(size);
$(chart.chartEl).width(size);
path = $(chart.chartEl).find('path')[0];
d3selectedPath = d3.select(path)[0][0];
if (size < 20) {
expect(function () {
chart.render();
}).to.throwError();
}
// d3 instance of click and hover
onClick = (!!d3selectedPath.__onclick);
onMouseOver = (!!d3selectedPath.__onmouseover);
});
});
it('should not throw an error', function () {
vis.handler.charts.forEach(function (chart) {
$(chart.chartEl).height(size);
$(chart.chartEl).width(size);
it('should attach a click event', function () {
vis.handler.charts.forEach(function () {
expect(onClick).to.be(true);
});
});
if (size > 20) {
expect(function () {
chart.render();
}).to.not.throwError();
}
it('should attach a hover event', function () {
vis.handler.charts.forEach(function () {
expect(onMouseOver).to.be(true);
});
});
});
});
describe('addPath method', function () {
let width;
let height;
let svg;
let slices;
it('should return an SVG object', function () {
vis.handler.charts.forEach(function (chart) {
$(chart.chartEl).find('svg').empty();
width = $(chart.chartEl).width();
height = $(chart.chartEl).height();
svg = d3.select($(chart.chartEl).find('svg')[0]);
slices = chart.chartData.slices;
expect(_.isObject(chart.addPath(width, height, svg, slices))).to.be(true);
});
});
it('should draw path elements', function () {
vis.handler.charts.forEach(function (chart) {
// test whether path elements are drawn
expect($(chart.chartEl).find('path').length).to.be.greaterThan(0);
});
});
it ('should draw labels', function () {
vis.handler.charts.forEach(function (chart) {
$(chart.chartEl).find('svg').empty();
width = $(chart.chartEl).width();
height = $(chart.chartEl).height();
svg = d3.select($(chart.chartEl).find('svg')[0]);
slices = chart.chartData.slices;
chart._attr.labels.show = true;
chart.addPath(width, height, svg, slices);
expect($(chart.chartEl).find('text.label-text').length).to.be.greaterThan(0);
});
});
});
describe('draw method', function () {
it('should return a function', function () {
vis.handler.charts.forEach(function (chart) {
expect(_.isFunction(chart.draw())).to.be(true);
});
});
});
sizes.forEach(function (size) {
describe('containerTooSmall error', function () {
it('should throw an error', function () {
// 20px is the minimum height and width
vis.handler.charts.forEach(function (chart) {
$(chart.chartEl).height(size);
$(chart.chartEl).width(size);
if (size < 20) {
expect(function () {
chart.render();
}).to.throwError();
}
});
});
it('should not throw an error', function () {
vis.handler.charts.forEach(function (chart) {
$(chart.chartEl).height(size);
$(chart.chartEl).width(size);
if (size > 20) {
expect(function () {
chart.render();
}).to.not.throwError();
}
});
});
});
});
});
});
});

View file

@ -0,0 +1,18 @@
import $ from 'jquery';
import d3 from 'd3';
/***
*
* @param text (d3 node containing text)
* @param size (number of characters to leave)
* @returns {text} the updated text
*/
const truncateLabel = function (text, size) {
const node = d3.select(text).node();
const str = $(node).text();
if (size === 0) return str;
if (size >= str.length) return str;
return str.substr(0, size) + '…';
};
export { truncateLabel };

View file

@ -1,5 +1,6 @@
import d3 from 'd3';
import $ from 'jquery';
import { truncateLabel } from '../../components/labels/truncate_labels';
export function VislibAxisLabelsProvider() {
class AxisLabels {
@ -56,34 +57,14 @@ export function VislibAxisLabelsProvider() {
};
}
truncateLabel(text, size) {
const node = d3.select(text).node();
let str = $(node).text();
const width = node.getBBox().width;
const chars = str.length;
const pxPerChar = width / chars;
let endChar = 0;
const ellipsesPad = 4;
if (width > size) {
endChar = Math.floor((size / pxPerChar) - ellipsesPad);
while (str[endChar - 1] === ' ' || str[endChar - 1] === '-' || str[endChar - 1] === ',') {
endChar = endChar - 1;
}
str = str.substr(0, endChar) + '...';
}
return str;
}
truncateLabels() {
const self = this;
const config = this.axisConfig;
return function (selection) {
if (!config.get('labels.truncate')) return;
selection.selectAll('.tick text')
.text(function () {
return self.truncateLabel(this, config.get('labels.truncate'));
return truncateLabel(this, config.get('labels.truncate'));
});
};
}

View file

@ -5,7 +5,11 @@ export function VislibPieConfigProvider() {
return function (config) {
if (!config.chart) {
config.chart = _.defaults({}, config, {
type: 'pie'
type: 'pie',
labels: {
show: false,
truncate: 100
}
});
}
return config;

View file

@ -199,3 +199,15 @@
margin-bottom: 0px;
margin-right: 0px;
}
.label-line {
opacity: .3;
stroke: black;
stroke-width: 2px;
fill: none;
}
.label-text {
font-size: 130%;
font-weight: normal;
}

View file

@ -1,8 +1,10 @@
import d3 from 'd3';
import _ from 'lodash';
import $ from 'jquery';
import numeral from 'numeral';
import { PieContainsAllZeros, ContainerTooSmall } from 'ui/errors';
import { VislibVisualizationsChartProvider } from './_chart';
import { truncateLabel } from '../components/labels/truncate_labels';
export function VislibVisualizationsPieChartProvider(Private) {
@ -111,25 +113,37 @@ export function VislibVisualizationsPieChartProvider(Private) {
const tooltip = self.tooltip;
const isTooltip = self._attr.addTooltip;
const arcs = svg.append('g').attr('class', 'arcs');
const labels = svg.append('g').attr('class', 'labels');
const showLabels = self._attr.labels.show;
const showValues = self._attr.labels.values;
const truncateLabelLength = self._attr.labels.truncate;
const showOnlyOnLastLevel = self._attr.labels.last_level;
const partition = d3.layout.partition()
.sort(null)
.value(function (d) {
return d.percentOfParent * 100;
});
const x = d3.scale.linear()
.range([0, 2 * Math.PI]);
const y = d3.scale.sqrt()
.range([0, radius]);
const x = d3.scale.linear().range([0, 2 * Math.PI]);
const y = d3.scale.sqrt().range([0, radius * 0.7]);
const startAngle = function (d) {
return Math.max(0, Math.min(2 * Math.PI, x(d.x)));
};
const endAngle = function (d) {
if (d.dx < 1e-8) return x(d.x);
return Math.max(0, Math.min(2 * Math.PI, x(d.x + d.dx)));
};
const arc = d3.svg.arc()
.startAngle(function (d) {
return Math.max(0, Math.min(2 * Math.PI, x(d.x)));
})
.endAngle(function (d) {
if (d.dx < 1e-8) return x(d.x);
return Math.max(0, Math.min(2 * Math.PI, x(d.x + d.dx)));
})
.startAngle(startAngle)
.endAngle(endAngle)
.innerRadius(function (d) {
// option for a single layer, i.e pie chart
// option for a single layer, i.e pie chart
if (d.depth === 1 && !isDonut) {
// return no inner radius
return 0;
@ -141,7 +155,14 @@ export function VislibVisualizationsPieChartProvider(Private) {
return Math.max(0, y(d.y + d.dy));
});
const path = svg
const outerArc = d3.svg.arc()
.startAngle(startAngle)
.endAngle(endAngle)
.innerRadius(radius * 0.8)
.outerRadius(radius * 0.8);
let maxDepth = 0;
const path = arcs
.datum(slices)
.selectAll('path')
.data(partition.nodes)
@ -152,6 +173,7 @@ export function VislibVisualizationsPieChartProvider(Private) {
if (d.depth === 0) {
return;
}
if (d.depth > maxDepth) maxDepth = d.depth;
return 'slice';
})
.call(self._addIdentifier, 'name')
@ -162,6 +184,127 @@ export function VislibVisualizationsPieChartProvider(Private) {
return color(d.name);
});
// add labels
if (showLabels) {
const labelGroups = labels
.datum(slices)
.selectAll('.label')
.data(partition.nodes);
// create an empty quadtree to hold label positions
const svgBBox = {
width: svg.node().parentElement.clientWidth,
height: svg.node().parentElement.clientHeight
};
const labelLayout = d3.geom.quadtree()
.extent([[-svgBBox.width, -svgBBox.height], [svgBBox.width, svgBBox.height] ])
.x(function (d) { return d.position.x; })
.y(function (d) { return d.position.y; })
([]);
labelGroups
.enter()
.append('g')
.attr('class', 'label')
.append('text')
.text(function (d) {
if (d.depth === 0) {
this.parentElement.remove();
return;
}
if (showValues) {
const value = numeral(d.value / 100).format('0.[00]%');
return `${d.name} (${value})`;
}
return d.name;
}).text(function () {
return truncateLabel(this, truncateLabelLength);
}).attr('text-anchor', function (d) {
const midAngle = startAngle(d) + (endAngle(d) - startAngle(d)) / 2;
return (midAngle < Math.PI) ? 'start' : 'end';
})
.attr('class', 'label-text')
.each(function resolveConflicts(d) {
if (d.depth === 0) return;
const parentElement = this.parentElement;
if (showOnlyOnLastLevel && maxDepth !== d.depth) {
parentElement.remove();
return;
}
const bbox = this.getBBox();
const pos = outerArc.centroid(d);
const midAngle = startAngle(d) + (endAngle(d) - startAngle(d)) / 2;
pos[1] += 4;
pos[0] = (0.7 + d.depth / 10) * radius * (midAngle < Math.PI ? 1 : -1);
d.position = {
x: pos[0],
y: pos[1],
left: midAngle < Math.PI ? pos[0] : pos[0] - bbox.width,
right: midAngle > Math.PI ? pos[0] + bbox.width : pos[0],
bottom: pos[1] + 5,
top: pos[1] - bbox.height - 5,
};
const conflicts = [];
labelLayout.visit(function (node) {
if (!node.point) return;
if (conflicts.length) return true;
const point = node.point.position;
const current = d.position;
if (point) {
const horizontalConflict = (point.left < 0 && current.left < 0) || (point.left > 0 && current.left > 0);
const verticalConflict = (point.top > current.top && point.top <= current.bottom) ||
(point.top < current.top && point.bottom >= current.top);
if (horizontalConflict && verticalConflict) {
point.point = node.point;
conflicts.push(point);
}
return true;
}
});
if (conflicts.length) {
parentElement.remove();
return;
}
labelLayout.add(d);
})
.attr('x', function (d) {
if (d.depth === 0 || !d.position) {
return;
}
return d.position.x;
})
.attr('y', function (d) {
if (d.depth === 0 || !d.position) {
return;
}
return d.position.y;
});
labelGroups
.append('polyline')
.attr('points', function (d) {
if (d.depth === 0 || !d.position) {
return;
}
const pos1 = outerArc.centroid(d);
const x2 = d.position.x > 0 ? d.position.x - 10 : d.position.x + 10;
const pos2 = [x2, d.position.y - 4];
pos1[1] = pos2[1];
return [arc.centroid(d), pos1, pos2];
})
.attr('class', 'label-line');
}
if (isTooltip) {
path.call(tooltip.render());
}

View file

@ -515,7 +515,7 @@ export function DashboardPageProvider({ getService, getPageObjects }) {
async filterOnPieSlice() {
log.debug('Filtering on a pie slice');
await retry.try(async () => {
const slices = await find.allByCssSelector('svg > g > path.slice');
const slices = await find.allByCssSelector('svg > g > g.arcs > path.slice');
log.debug('Slices found:' + slices.length);
return slices[0].click();
});