src/controllers/controller.bar.js
import DatasetController from '../core/core.datasetController.js';
import {
_arrayUnique, isArray, isNullOrUndef,
valueOrDefault, resolveObjectKey, sign, defined
} from '../helpers/index.js';
function getAllScaleValues(scale, type) {
if (!scale._cache.$bar) {
const visibleMetas = scale.getMatchingVisibleMetas(type);
let values = [];
for (let i = 0, ilen = visibleMetas.length; i < ilen; i++) {
values = values.concat(visibleMetas[i].controller.getAllParsedValues(scale));
}
scale._cache.$bar = _arrayUnique(values.sort((a, b) => a - b));
}
return scale._cache.$bar;
}
/**
* Computes the "optimal" sample size to maintain bars equally sized while preventing overlap.
* @private
*/
function computeMinSampleSize(meta) {
const scale = meta.iScale;
const values = getAllScaleValues(scale, meta.type);
let min = scale._length;
let i, ilen, curr, prev;
const updateMinAndPrev = () => {
if (curr === 32767 || curr === -32768) {
// Ignore truncated pixels
return;
}
if (defined(prev)) {
// curr - prev === 0 is ignored
min = Math.min(min, Math.abs(curr - prev) || min);
}
prev = curr;
};
for (i = 0, ilen = values.length; i < ilen; ++i) {
curr = scale.getPixelForValue(values[i]);
updateMinAndPrev();
}
prev = undefined;
for (i = 0, ilen = scale.ticks.length; i < ilen; ++i) {
curr = scale.getPixelForTick(i);
updateMinAndPrev();
}
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, stackCount) {
const thickness = options.barThickness;
let size, ratio;
if (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 * stackCount;
ratio = 1;
}
return {
chunk: size / stackCount,
ratio,
start: ruler.pixels[index] - (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, stackCount) {
const pixels = ruler.pixels;
const curr = pixels[index];
let prev = index > 0 ? pixels[index - 1] : null;
let next = index < pixels.length - 1 ? pixels[index + 1] : null;
const percent = options.categoryPercentage;
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 size.
prev = curr - (next === null ? ruler.end - ruler.start : next - curr);
}
if (next === null) {
// last data: its size is also double based on the previous point.
next = curr + curr - prev;
}
const start = curr - (curr - Math.min(prev, next)) / 2 * percent;
const size = Math.abs(next - prev) / 2 * percent;
return {
chunk: size / stackCount,
ratio: options.barPercentage,
start
};
}
function parseFloatBar(entry, item, vScale, i) {
const startValue = vScale.parse(entry[0], i);
const endValue = vScale.parse(entry[1], i);
const min = Math.min(startValue, endValue);
const max = Math.max(startValue, endValue);
let barStart = min;
let barEnd = max;
if (Math.abs(min) > Math.abs(max)) {
barStart = max;
barEnd = min;
}
// Store `barEnd` (furthest away from origin) as parsed value,
// to make stacking straight forward
item[vScale.axis] = barEnd;
item._custom = {
barStart,
barEnd,
start: startValue,
end: endValue,
min,
max
};
}
function parseValue(entry, item, vScale, i) {
if (isArray(entry)) {
parseFloatBar(entry, item, vScale, i);
} else {
item[vScale.axis] = vScale.parse(entry, i);
}
return item;
}
function parseArrayOrPrimitive(meta, data, start, count) {
const iScale = meta.iScale;
const vScale = meta.vScale;
const labels = iScale.getLabels();
const singleScale = iScale === vScale;
const parsed = [];
let i, ilen, item, entry;
for (i = start, ilen = start + count; i < ilen; ++i) {
entry = data[i];
item = {};
item[iScale.axis] = singleScale || iScale.parse(labels[i], i);
parsed.push(parseValue(entry, item, vScale, i));
}
return parsed;
}
function isFloatBar(custom) {
return custom && custom.barStart !== undefined && custom.barEnd !== undefined;
}
function barSign(size, vScale, actualBase) {
if (size !== 0) {
return sign(size);
}
return (vScale.isHorizontal() ? 1 : -1) * (vScale.min >= actualBase ? 1 : -1);
}
function borderProps(properties) {
let reverse, start, end, top, bottom;
if (properties.horizontal) {
reverse = properties.base > properties.x;
start = 'left';
end = 'right';
} else {
reverse = properties.base < properties.y;
start = 'bottom';
end = 'top';
}
if (reverse) {
top = 'end';
bottom = 'start';
} else {
top = 'start';
bottom = 'end';
}
return {start, end, reverse, top, bottom};
}
function setBorderSkipped(properties, options, stack, index) {
let edge = options.borderSkipped;
const res = {};
if (!edge) {
properties.borderSkipped = res;
return;
}
if (edge === true) {
properties.borderSkipped = {top: true, right: true, bottom: true, left: true};
return;
}
const {start, end, reverse, top, bottom} = borderProps(properties);
if (edge === 'middle' && stack) {
properties.enableBorderRadius = true;
if ((stack._top || 0) === index) {
edge = top;
} else if ((stack._bottom || 0) === index) {
edge = bottom;
} else {
res[parseEdge(bottom, start, end, reverse)] = true;
edge = top;
}
}
res[parseEdge(edge, start, end, reverse)] = true;
properties.borderSkipped = res;
}
function parseEdge(edge, a, b, reverse) {
if (reverse) {
edge = swap(edge, a, b);
edge = startEnd(edge, b, a);
} else {
edge = startEnd(edge, a, b);
}
return edge;
}
function swap(orig, v1, v2) {
return orig === v1 ? v2 : orig === v2 ? v1 : orig;
}
function startEnd(v, start, end) {
return v === 'start' ? start : v === 'end' ? end : v;
}
function setInflateAmount(properties, {inflateAmount}, ratio) {
properties.inflateAmount = inflateAmount === 'auto'
? ratio === 1 ? 0.33 : 0
: inflateAmount;
}
export default class BarController extends DatasetController {
static id = 'bar';
/**
* @type {any}
*/
static defaults = {
datasetElementType: false,
dataElementType: 'bar',
categoryPercentage: 0.8,
barPercentage: 0.9,
grouped: true,
animations: {
numbers: {
type: 'number',
properties: ['x', 'y', 'base', 'width', 'height']
}
}
};
/**
* @type {any}
*/
static overrides = {
scales: {
_index_: {
type: 'category',
offset: true,
grid: {
offset: true
}
},
_value_: {
type: 'linear',
beginAtZero: true,
}
}
};
/**
* Overriding primitive data parsing since we support mixed primitive/array
* data for float bars
* @protected
*/
parsePrimitiveData(meta, data, start, count) {
return parseArrayOrPrimitive(meta, data, start, count);
}
/**
* Overriding array data parsing since we support mixed primitive/array
* data for float bars
* @protected
*/
parseArrayData(meta, data, start, count) {
return parseArrayOrPrimitive(meta, data, start, count);
}
/**
* Overriding object data parsing since we support mixed primitive/array
* value-scale data for float bars
* @protected
*/
parseObjectData(meta, data, start, count) {
const {iScale, vScale} = meta;
const {xAxisKey = 'x', yAxisKey = 'y'} = this._parsing;
const iAxisKey = iScale.axis === 'x' ? xAxisKey : yAxisKey;
const vAxisKey = vScale.axis === 'x' ? xAxisKey : yAxisKey;
const parsed = [];
let i, ilen, item, obj;
for (i = start, ilen = start + count; i < ilen; ++i) {
obj = data[i];
item = {};
item[iScale.axis] = iScale.parse(resolveObjectKey(obj, iAxisKey), i);
parsed.push(parseValue(resolveObjectKey(obj, vAxisKey), item, vScale, i));
}
return parsed;
}
/**
* @protected
*/
updateRangeFromParsed(range, scale, parsed, stack) {
super.updateRangeFromParsed(range, scale, parsed, stack);
const custom = parsed._custom;
if (custom && scale === this._cachedMeta.vScale) {
// float bar: only one end of the bar is considered by `super`
range.min = Math.min(range.min, custom.min);
range.max = Math.max(range.max, custom.max);
}
}
/**
* @return {number|boolean}
* @protected
*/
getMaxOverflow() {
return 0;
}
/**
* @protected
*/
getLabelAndValue(index) {
const meta = this._cachedMeta;
const {iScale, vScale} = meta;
const parsed = this.getParsed(index);
const custom = parsed._custom;
const value = isFloatBar(custom)
? '[' + custom.start + ', ' + custom.end + ']'
: '' + vScale.getLabelForValue(parsed[vScale.axis]);
return {
label: '' + iScale.getLabelForValue(parsed[iScale.axis]),
value
};
}
initialize() {
this.enableOptionSharing = true;
super.initialize();
const meta = this._cachedMeta;
meta.stack = this.getDataset().stack;
}
update(mode) {
const meta = this._cachedMeta;
this.updateElements(meta.data, 0, meta.data.length, mode);
}
updateElements(bars, start, count, mode) {
const reset = mode === 'reset';
const {index, _cachedMeta: {vScale}} = this;
const base = vScale.getBasePixel();
const horizontal = vScale.isHorizontal();
const ruler = this._getRuler();
const {sharedOptions, includeOptions} = this._getSharedOptions(start, mode);
for (let i = start; i < start + count; i++) {
const parsed = this.getParsed(i);
const vpixels = reset || isNullOrUndef(parsed[vScale.axis]) ? {base, head: base} : this._calculateBarValuePixels(i);
const ipixels = this._calculateBarIndexPixels(i, ruler);
const stack = (parsed._stacks || {})[vScale.axis];
const properties = {
horizontal,
base: vpixels.base,
enableBorderRadius: !stack || isFloatBar(parsed._custom) || (index === stack._top || index === stack._bottom),
x: horizontal ? vpixels.head : ipixels.center,
y: horizontal ? ipixels.center : vpixels.head,
height: horizontal ? ipixels.size : Math.abs(vpixels.size),
width: horizontal ? Math.abs(vpixels.size) : ipixels.size
};
if (includeOptions) {
properties.options = sharedOptions || this.resolveDataElementOptions(i, bars[i].active ? 'active' : mode);
}
const options = properties.options || bars[i].options;
setBorderSkipped(properties, options, stack, index);
setInflateAmount(properties, options, ruler.ratio);
this.updateElement(bars[i], i, properties, mode);
}
}
/**
* Returns the stacks based on groups and bar visibility.
* @param {number} [last] - The dataset index
* @param {number} [dataIndex] - The data index of the ruler
* @returns {string[]} The list of stack IDs
* @private
*/
_getStacks(last, dataIndex) {
const {iScale} = this._cachedMeta;
const metasets = iScale.getMatchingVisibleMetas(this._type)
.filter(meta => meta.controller.options.grouped);
const stacked = iScale.options.stacked;
const stacks = [];
const skipNull = (meta) => {
const parsed = meta.controller.getParsed(dataIndex);
const val = parsed && parsed[meta.vScale.axis];
if (isNullOrUndef(val) || isNaN(val)) {
return true;
}
};
for (const meta of metasets) {
if (dataIndex !== undefined && skipNull(meta)) {
continue;
}
// stacked | meta.stack
// | found | not found | undefined
// false | x | x | x
// true | | x |
// undefined | | x | x
if (stacked === false || stacks.indexOf(meta.stack) === -1 ||
(stacked === undefined && meta.stack === undefined)) {
stacks.push(meta.stack);
}
if (meta.index === last) {
break;
}
}
// No stacks? that means there is no visible data. Let's still initialize an `undefined`
// stack where possible invisible bars will be located.
// https://github.com/chartjs/Chart.js/issues/6368
if (!stacks.length) {
stacks.push(undefined);
}
return stacks;
}
/**
* Returns the effective number of stacks based on groups and bar visibility.
* @private
*/
_getStackCount(index) {
return this._getStacks(undefined, index).length;
}
/**
* Returns the stack index for the given dataset based on groups and bar visibility.
* @param {number} [datasetIndex] - The dataset index
* @param {string} [name] - The stack name to find
* @param {number} [dataIndex]
* @returns {number} The stack index
* @private
*/
_getStackIndex(datasetIndex, name, dataIndex) {
const stacks = this._getStacks(datasetIndex, dataIndex);
const index = (name !== undefined)
? stacks.indexOf(name)
: -1; // indexOf returns -1 if element is not present
return (index === -1)
? stacks.length - 1
: index;
}
/**
* @private
*/
_getRuler() {
const opts = this.options;
const meta = this._cachedMeta;
const iScale = meta.iScale;
const pixels = [];
let i, ilen;
for (i = 0, ilen = meta.data.length; i < ilen; ++i) {
pixels.push(iScale.getPixelForValue(this.getParsed(i)[iScale.axis], i));
}
const barThickness = opts.barThickness;
const min = barThickness || computeMinSampleSize(meta);
return {
min,
pixels,
start: iScale._startPixel,
end: iScale._endPixel,
stackCount: this._getStackCount(),
scale: iScale,
grouped: opts.grouped,
// bar thickness ratio used for non-grouped bars
ratio: barThickness ? 1 : opts.categoryPercentage * opts.barPercentage
};
}
/**
* Note: pixel values are not clamped to the scale area.
* @private
*/
_calculateBarValuePixels(index) {
const {_cachedMeta: {vScale, _stacked, index: datasetIndex}, options: {base: baseValue, minBarLength}} = this;
const actualBase = baseValue || 0;
const parsed = this.getParsed(index);
const custom = parsed._custom;
const floating = isFloatBar(custom);
let value = parsed[vScale.axis];
let start = 0;
let length = _stacked ? this.applyStack(vScale, parsed, _stacked) : value;
let head, size;
if (length !== value) {
start = length - value;
length = value;
}
if (floating) {
value = custom.barStart;
length = custom.barEnd - custom.barStart;
// bars crossing origin are not stacked
if (value !== 0 && sign(value) !== sign(custom.barEnd)) {
start = 0;
}
start += value;
}
const startValue = !isNullOrUndef(baseValue) && !floating ? baseValue : start;
let base = vScale.getPixelForValue(startValue);
if (this.chart.getDataVisibility(index)) {
head = vScale.getPixelForValue(start + length);
} else {
// When not visible, no height
head = base;
}
size = head - base;
if (Math.abs(size) < minBarLength) {
size = barSign(size, vScale, actualBase) * minBarLength;
if (value === actualBase) {
base -= size / 2;
}
const startPixel = vScale.getPixelForDecimal(0);
const endPixel = vScale.getPixelForDecimal(1);
const min = Math.min(startPixel, endPixel);
const max = Math.max(startPixel, endPixel);
base = Math.max(Math.min(base, max), min);
head = base + size;
if (_stacked && !floating) {
// visual data coordinates after applying minBarLength
parsed._stacks[vScale.axis]._visualValues[datasetIndex] = vScale.getValueForPixel(head) - vScale.getValueForPixel(base);
}
}
if (base === vScale.getPixelForValue(actualBase)) {
const halfGrid = sign(size) * vScale.getLineWidthForValue(actualBase) / 2;
base += halfGrid;
size -= halfGrid;
}
return {
size,
base,
head,
center: head + size / 2
};
}
/**
* @private
*/
_calculateBarIndexPixels(index, ruler) {
const scale = ruler.scale;
const options = this.options;
const skipNull = options.skipNull;
const maxBarThickness = valueOrDefault(options.maxBarThickness, Infinity);
let center, size;
if (ruler.grouped) {
const stackCount = skipNull ? this._getStackCount(index) : ruler.stackCount;
const range = options.barThickness === 'flex'
? computeFlexCategoryTraits(index, ruler, options, stackCount)
: computeFitCategoryTraits(index, ruler, options, stackCount);
const stackIndex = this._getStackIndex(this.index, this._cachedMeta.stack, skipNull ? index : undefined);
center = range.start + (range.chunk * stackIndex) + (range.chunk / 2);
size = Math.min(maxBarThickness, range.chunk * range.ratio);
} else {
// For non-grouped bar charts, exact pixel values are used
center = scale.getPixelForValue(this.getParsed(index)[scale.axis], index);
size = Math.min(maxBarThickness, ruler.min * ruler.ratio);
}
return {
base: center - size / 2,
head: center + size / 2,
center,
size
};
}
draw() {
const meta = this._cachedMeta;
const vScale = meta.vScale;
const rects = meta.data;
const ilen = rects.length;
let i = 0;
for (; i < ilen; ++i) {
if (this.getParsed(i)[vScale.axis] !== null && !rects[i].hidden) {
rects[i].draw(this._ctx);
}
}
}
}