Implement equally sized bars (#4994)

When `barThickness: undefined|null` (default), we compute an optimal sample size based on the smallest tick interval reduced to prevent any bar to overlap (bar equally sized). Also added support for a special `barThickness: 'flex'` value (previous default) that globally arranges bars side by side to prevent any gap when percentage options are 1 (variable bar sizes).
This commit is contained in:
Simon Brunel 2017-12-02 12:38:36 +01:00 committed by GitHub
parent b835df02cd
commit 15d1056b53
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 586 additions and 127 deletions

1
.gitignore vendored
View File

@ -12,3 +12,4 @@
bower.json
*.log
*.swp
*.stackdump

View File

@ -95,6 +95,93 @@ defaults._set('horizontalBar', {
}
});
/**
* Computes the "optimal" sample size to maintain bars equally sized while preventing overlap.
* @private
*/
function computeMinSampleSize(scale, pixels) {
var min = scale.isHorizontal() ? scale.width : scale.height;
var ticks = scale.getTicks();
var prev, curr, i, ilen;
for (i = 1, ilen = pixels.length; i < ilen; ++i) {
min = Math.min(min, pixels[i] - pixels[i - 1]);
}
for (i = 0, ilen = ticks.length; i < ilen; ++i) {
curr = scale.getPixelForTick(i);
min = i > 0 ? Math.min(min, curr - prev) : min;
prev = curr;
}
return min;
}
/**
* Computes an "ideal" category based on the absolute bar thickness or, if undefined or null,
* uses the smallest interval (see computeMinSampleSize) that prevents bar overlapping. This
* mode currently always generates bars equally sized (until we introduce scriptable options?).
* @private
*/
function computeFitCategoryTraits(index, ruler, options) {
var thickness = options.barThickness;
var count = ruler.stackCount;
var curr = ruler.pixels[index];
var size, ratio;
if (helpers.isNullOrUndef(thickness)) {
size = ruler.min * options.categoryPercentage;
ratio = options.barPercentage;
} else {
// When bar thickness is enforced, category and bar percentages are ignored.
// Note(SB): we could add support for relative bar thickness (e.g. barThickness: '50%')
// and deprecate barPercentage since this value is ignored when thickness is absolute.
size = thickness * count;
ratio = 1;
}
return {
chunk: size / count,
ratio: ratio,
start: curr - (size / 2)
};
}
/**
* Computes an "optimal" category that globally arranges bars side by side (no gap when
* percentage options are 1), based on the previous and following categories. This mode
* generates bars with different widths when data are not evenly spaced.
* @private
*/
function computeFlexCategoryTraits(index, ruler, options) {
var pixels = ruler.pixels;
var curr = pixels[index];
var prev = index > 0 ? pixels[index - 1] : null;
var next = index < pixels.length - 1 ? pixels[index + 1] : null;
var percent = options.categoryPercentage;
var start, size;
if (prev === null) {
// first data: its size is double based on the next point or,
// if it's also the last data, we use the scale end extremity.
prev = curr - (next === null ? ruler.end - curr : next - curr);
}
if (next === null) {
// last data: its size is also double based on the previous point.
next = curr + curr - prev;
}
start = curr - ((curr - prev) / 2) * percent;
size = ((next - prev) / 2) * percent;
return {
chunk: size / ruler.stackCount,
ratio: options.barPercentage,
start: start
};
}
module.exports = function(Chart) {
Chart.controllers.bar = Chart.DatasetController.extend({
@ -262,17 +349,22 @@ module.exports = function(Chart) {
var scale = me.getIndexScale();
var stackCount = me.getStackCount();
var datasetIndex = me.index;
var pixels = [];
var isHorizontal = scale.isHorizontal();
var start = isHorizontal ? scale.left : scale.top;
var end = start + (isHorizontal ? scale.width : scale.height);
var i, ilen;
var pixels = [];
var i, ilen, min;
for (i = 0, ilen = me.getMeta().data.length; i < ilen; ++i) {
pixels.push(scale.getPixelForValue(null, i, datasetIndex));
}
min = helpers.isNullOrUndef(scale.options.barThickness)
? computeMinSampleSize(scale, pixels)
: -1;
return {
min: min,
pixels: pixels,
start: start,
end: end,
@ -332,51 +424,21 @@ module.exports = function(Chart) {
calculateBarIndexPixels: function(datasetIndex, index, ruler) {
var me = this;
var options = ruler.scale.options;
var meta = me.getMeta();
var stackIndex = me.getStackIndex(datasetIndex, meta.stack);
var pixels = ruler.pixels;
var base = pixels[index];
var length = pixels.length;
var start = ruler.start;
var end = ruler.end;
var leftSampleSize, rightSampleSize, leftCategorySize, rightCategorySize, fullBarSize, size;
var range = options.barThickness === 'flex'
? computeFlexCategoryTraits(index, ruler, options)
: computeFitCategoryTraits(index, ruler, options);
if (length === 1) {
leftSampleSize = base > start ? base - start : end - base;
rightSampleSize = base < end ? end - base : base - start;
} else {
if (index > 0) {
leftSampleSize = (base - pixels[index - 1]) / 2;
if (index === length - 1) {
rightSampleSize = leftSampleSize;
}
}
if (index < length - 1) {
rightSampleSize = (pixels[index + 1] - base) / 2;
if (index === 0) {
leftSampleSize = rightSampleSize;
}
}
}
leftCategorySize = leftSampleSize * options.categoryPercentage;
rightCategorySize = rightSampleSize * options.categoryPercentage;
fullBarSize = (leftCategorySize + rightCategorySize) / ruler.stackCount;
size = fullBarSize * options.barPercentage;
size = Math.min(
helpers.valueOrDefault(options.barThickness, size),
helpers.valueOrDefault(options.maxBarThickness, Infinity));
base -= leftCategorySize;
base += fullBarSize * stackIndex;
base += (fullBarSize - size) / 2;
var stackIndex = me.getStackIndex(datasetIndex, me.getMeta().stack);
var center = range.start + (range.chunk * stackIndex) + (range.chunk / 2);
var size = Math.min(
helpers.valueOrDefault(options.maxBarThickness, Infinity),
range.chunk * range.ratio);
return {
size: size,
base: base,
head: base + size,
center: base + size / 2
base: center - size / 2,
head: center + size / 2,
center: center,
size: size
};
},

View File

@ -11,3 +11,4 @@ globals:
rules:
# Best Practices
complexity: 0
max-statements: 0

View File

@ -0,0 +1,42 @@
{
"config": {
"type": "bar",
"data": {
"labels": ["2017", "2018", "2019", "2024", "2025"],
"datasets": [{
"backgroundColor": "rgba(255, 99, 132, 0.5)",
"data": [1, null, 3, 4, 5]
}]
},
"options": {
"responsive": false,
"legend": false,
"title": false,
"scales": {
"xAxes": [{
"type": "time",
"offset": true,
"display": false,
"barPercentage": 1,
"categoryPercentage": 1,
"barThickness": 128,
"ticks": {
"source": "labels"
}
}],
"yAxes": [{
"display": false,
"ticks": {
"beginAtZero": true
}
}]
}
}
},
"options": {
"canvas": {
"height": 256,
"width": 512
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@ -0,0 +1,42 @@
{
"config": {
"type": "bar",
"data": {
"labels": ["2017", "2018", "2020", "2024", "2038"],
"datasets": [{
"backgroundColor": "#FF6384",
"data": [1, null, 3, 4, 5]
}]
},
"options": {
"responsive": false,
"legend": false,
"title": false,
"scales": {
"xAxes": [{
"type": "time",
"offset": true,
"display": false,
"barPercentage": 1,
"categoryPercentage": 1,
"barThickness": "flex",
"ticks": {
"source": "labels"
}
}],
"yAxes": [{
"display": false,
"ticks": {
"beginAtZero": true
}
}]
}
}
},
"options": {
"canvas": {
"height": 256,
"width": 512
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@ -0,0 +1,41 @@
{
"config": {
"type": "bar",
"data": {
"labels": ["2017", "2018", "2020", "2024", "2038"],
"datasets": [{
"backgroundColor": "#FF6384",
"data": [1, null, 3, 4, 5]
}]
},
"options": {
"responsive": false,
"legend": false,
"title": false,
"scales": {
"xAxes": [{
"type": "time",
"display": false,
"barPercentage": 1,
"categoryPercentage": 1,
"barThickness": "flex",
"ticks": {
"source": "labels"
}
}],
"yAxes": [{
"display": false,
"ticks": {
"beginAtZero": true
}
}]
}
}
},
"options": {
"canvas": {
"height": 256,
"width": 512
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@ -0,0 +1,41 @@
{
"config": {
"type": "bar",
"data": {
"labels": ["2016", "2018", "2020", "2024", "2030"],
"datasets": [{
"backgroundColor": "#FF6384",
"data": [1, null, 3, 4, 5]
}]
},
"options": {
"responsive": false,
"legend": false,
"title": false,
"scales": {
"xAxes": [{
"type": "time",
"display": false,
"barPercentage": 1,
"categoryPercentage": 1,
"maxBarThickness": 8,
"ticks": {
"source": "labels"
}
}],
"yAxes": [{
"display": false,
"ticks": {
"beginAtZero": true
}
}]
}
}
},
"options": {
"canvas": {
"height": 256,
"width": 512
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -0,0 +1,40 @@
{
"config": {
"type": "bar",
"data": {
"labels": ["2016", "2018", "2020", "2024", "2030"],
"datasets": [{
"backgroundColor": "#FF6384",
"data": [1, null, 3, 4, 5]
}]
},
"options": {
"responsive": false,
"legend": false,
"title": false,
"scales": {
"xAxes": [{
"type": "time",
"display": false,
"barPercentage": 1,
"categoryPercentage": 1,
"ticks": {
"source": "labels"
}
}],
"yAxes": [{
"display": false,
"ticks": {
"beginAtZero": true
}
}]
}
}
},
"options": {
"canvas": {
"height": 256,
"width": 512
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

@ -0,0 +1,46 @@
{
"config": {
"type": "bar",
"data": {
"labels": ["2016", "2018", "2020", "2024", "2030"],
"datasets": [{
"backgroundColor": "#FF6384",
"data": [1, null, 3, 4, 5]
}, {
"backgroundColor": "#36A2EB",
"data": [5, 4, 3, null, 1]
}, {
"backgroundColor": "#FFCE56",
"data": [3, 5, 2, null, 4]
}]
},
"options": {
"responsive": false,
"legend": false,
"title": false,
"scales": {
"xAxes": [{
"type": "time",
"display": false,
"barPercentage": 1,
"categoryPercentage": 1,
"ticks": {
"source": "labels"
}
}],
"yAxes": [{
"display": false,
"ticks": {
"beginAtZero": true
}
}]
}
}
},
"options": {
"canvas": {
"height": 256,
"width": 512
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@ -0,0 +1,46 @@
{
"config": {
"type": "bar",
"data": {
"labels": ["2016", "2018", "2020", "2024", "2030"],
"datasets": [{
"backgroundColor": "#FF6384",
"data": [
{"y": "1", "t": "2016"},
{"y": "2", "t": "2017"},
{"y": "3", "t": "2017-08"},
{"y": "4", "t": "2024"},
{"y": "5", "t": "2030"}
]
}]
},
"options": {
"responsive": false,
"legend": false,
"title": false,
"scales": {
"xAxes": [{
"type": "time",
"display": false,
"barPercentage": 1,
"categoryPercentage": 1,
"ticks": {
"source": "labels"
}
}],
"yAxes": [{
"display": false,
"ticks": {
"beginAtZero": true
}
}]
}
}
},
"options": {
"canvas": {
"height": 256,
"width": 512
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@ -0,0 +1,47 @@
{
"config": {
"type": "bar",
"data": {
"labels": ["2016", "2018", "2020", "2024", "2030"],
"datasets": [{
"backgroundColor": "#FF6384",
"data": [1, null, 3, 4, 5]
}, {
"backgroundColor": "#36A2EB",
"data": [5, 4, 3, null, 1]
}, {
"backgroundColor": "#FFCE56",
"data": [3, 5, 2, null, 4]
}]
},
"options": {
"responsive": false,
"legend": false,
"title": false,
"scales": {
"xAxes": [{
"type": "time",
"offset": true,
"display": false,
"barPercentage": 1,
"categoryPercentage": 1,
"ticks": {
"source": "labels"
}
}],
"yAxes": [{
"display": false,
"ticks": {
"beginAtZero": true
}
}]
}
}
},
"options": {
"canvas": {
"height": 256,
"width": 512
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

@ -0,0 +1,40 @@
{
"config": {
"type": "bar",
"data": {
"labels": ["2016", "2018", "2020", "2024", "2030"],
"datasets": [{
"backgroundColor": "#FF6384",
"data": [{"x": "2022", "y": 42}]
}]
},
"options": {
"responsive": false,
"legend": false,
"title": false,
"scales": {
"xAxes": [{
"type": "time",
"display": false,
"barPercentage": 1,
"categoryPercentage": 1,
"ticks": {
"source": "labels"
}
}],
"yAxes": [{
"display": false,
"ticks": {
"beginAtZero": true
}
}]
}
}
},
"options": {
"canvas": {
"height": 256,
"width": 512
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@ -0,0 +1,43 @@
{
"config": {
"type": "bar",
"data": {
"labels": ["2016", "2018", "2020", "2024", "2030"],
"datasets": [{
"backgroundColor": "#FF6384",
"data": [1]
}]
},
"options": {
"responsive": false,
"legend": false,
"title": false,
"scales": {
"xAxes": [{
"type": "time",
"display": false,
"barPercentage": 1,
"categoryPercentage": 1,
"ticks": {
"source": "labels"
},
"time": {
"min": "2013"
}
}],
"yAxes": [{
"display": false,
"ticks": {
"beginAtZero": true
}
}]
}
}
},
"options": {
"canvas": {
"height": 256,
"width": 512
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -0,0 +1,48 @@
{
"config": {
"type": "bar",
"data": {
"labels": ["2016", "2018", "2020", "2024", "2030"],
"datasets": [{
"backgroundColor": "#FF6384",
"data": [1, null, 3, 4, 5]
}, {
"backgroundColor": "#36A2EB",
"data": [5, 4, 3, null, 1]
}, {
"backgroundColor": "#FFCE56",
"data": [3, 5, 2, null, 4]
}]
},
"options": {
"responsive": false,
"legend": false,
"title": false,
"scales": {
"xAxes": [{
"type": "time",
"stacked": true,
"display": false,
"barPercentage": 1,
"categoryPercentage": 1,
"ticks": {
"source": "labels"
}
}],
"yAxes": [{
"display": false,
"stacked": true,
"ticks": {
"beginAtZero": true
}
}]
}
}
},
"options": {
"canvas": {
"height": 256,
"width": 512
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@ -118,9 +118,6 @@ function specFromFixture(description, inputs) {
var chart = acquireChart(json.config, json.options);
if (!inputs.png) {
fail('Missing PNG comparison file for ' + inputs.json);
if (!json.debug) {
releaseChart(chart);
}
done();
}

View File

@ -1,4 +1,6 @@
describe('Chart.controllers.bar', function() {
describe('auto', jasmine.specsFromFixtures('controller.bar'));
it('should be constructed', function() {
var chart = window.acquireChart({
type: 'bar',
@ -1630,84 +1632,4 @@ describe('Chart.controllers.bar', function() {
});
});
});
describe('Bar thickness with a time scale', function() {
['auto', 'data', 'labels'].forEach(function(source) {
['series', 'linear'].forEach(function(distribution) {
describe('When ticks.source is "' + source + '", distribution is "' + distribution + '"', function() {
beforeEach(function() {
this.chart = window.acquireChart({
type: 'bar',
data: {
datasets: [{
data: [1, 2, 3]
}, {
data: [1, 2, 3]
}],
labels: ['2017', '2018', '2020']
},
options: {
legend: false,
title: false,
scales: {
xAxes: [{
id: 'x',
type: 'time',
time: {
unit: 'year',
parser: 'YYYY'
},
ticks: {
source: source
},
offset: true,
distribution: distribution
}],
yAxes: [{
type: 'linear'
}]
}
}
});
});
it('should correctly set bar width', function() {
var chart = this.chart;
var scale = chart.scales.x;
var options = chart.options.scales.xAxes[0];
var categoryPercentage = options.categoryPercentage;
var barPercentage = options.barPercentage;
var firstInterval = scale.getPixelForValue('2018') - scale.getPixelForValue('2017');
var firstExpected = firstInterval * categoryPercentage / 2 * barPercentage;
var lastInterval = scale.getPixelForValue('2020') - scale.getPixelForValue('2018');
var lastExpected = lastInterval * categoryPercentage / 2 * barPercentage;
var i, ilen, meta;
for (i = 0, ilen = chart.data.datasets.length; i < ilen; ++i) {
meta = chart.getDatasetMeta(i);
expect(meta.data[0]._model.width).toBeCloseToPixel(firstExpected);
expect(meta.data[1]._model.width).toBeCloseToPixel((firstExpected + lastExpected) / 2);
expect(meta.data[2]._model.width).toBeCloseToPixel(lastExpected);
}
});
it('should correctly set bar width if maxBarThickness is specified', function() {
var chart = this.chart;
var options = chart.options.scales.xAxes[0];
var i, ilen, meta;
options.maxBarThickness = 10;
chart.update();
for (i = 0, ilen = chart.data.datasets.length; i < ilen; ++i) {
meta = chart.getDatasetMeta(i);
expect(meta.data[0]._model.width).toBeCloseToPixel(10);
expect(meta.data[1]._model.width).toBeCloseToPixel(10);
expect(meta.data[2]._model.width).toBeCloseToPixel(10);
}
});
});
});
});
});
});