Skip to content

Commit

Permalink
Implement equally sized bars (chartjs#4994)
Browse files Browse the repository at this point in the history
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).
  • Loading branch information
simonbrunel authored and yofreke committed Dec 30, 2017
1 parent 54c6a35 commit 777c503
Show file tree
Hide file tree
Showing 27 changed files with 586 additions and 127 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@
bower.json
*.log
*.swp
*.stackdump
150 changes: 106 additions & 44 deletions src/controllers/controller.bar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;

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;
var range = options.barThickness === 'flex'
? computeFlexCategoryTraits(index, ruler, options)
: computeFitCategoryTraits(index, ruler, options);

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
};
},

Expand Down
1 change: 1 addition & 0 deletions test/.eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ globals:
rules:
# Best Practices
complexity: 0
max-statements: 0
42 changes: 42 additions & 0 deletions test/fixtures/controller.bar/bar-thickness-absolute.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
42 changes: 42 additions & 0 deletions test/fixtures/controller.bar/bar-thickness-flex-offset.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
41 changes: 41 additions & 0 deletions test/fixtures/controller.bar/bar-thickness-flex.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
41 changes: 41 additions & 0 deletions test/fixtures/controller.bar/bar-thickness-max.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit 777c503

Please sign in to comment.