const pluginVariableSortedHorizontalBars = {
// for https://stackoverflow.com/a/78523992/16466946
// floating horizontal bars are redistributed to y axis categories, depending on
// their `y` value; this may result in variable sized categories,
// see https://stackoverflow.com/a/78273931/16466946 for details
// the bars in a category are then vertically-sorted according to their first x values
// to which `Data.parse` is applied, creating a Gantt-like sequence, if data matches
_computeFlexBarCoordinates(categoryWidths, start = 0){
this.$idxGridLinesValues = categoryWidths.reduce((a, w)=>[...a, a[a.length-1]+w], [0]);
this.$idxAxisMax = this.$idxGridLinesValues[this.$idxGridLinesValues.length-1];
this.$idxGridLinesValues = this.$idxGridLinesValues.map(v=>this.$idxAxisMax-v);
// computes the coordinates of the labels of the bar to achieve categoryWidths spacing of categories
this.$idxBarPositions = categoryWidths.reduce(
(ax, wi, i) => {
let next = 0;
if(i === categoryWidths.length - 1){
next = ax[i] + categoryWidths[i];
}
else{
next = ax[i] + 2 * categoryWidths[i + 1];
if(next < ax[i]){
next = ax[i] - 2 * categoryWidths[i + 1];
}
}
return [...ax, next];
},
[start + categoryWidths[0] / 2, start + 3 * categoryWidths[0] / 2]);
this.$labelPositions = categoryWidths.reduce(
(ax, wi, i) => [...ax, ax[i] + (wi + categoryWidths[i + 1]) / 2],
[start + categoryWidths[0] / 2]
);
this.$labelPositions.pop();
this.$idxBarPositions = this.$idxBarPositions.map(pos => this.$idxAxisMax - pos);
this.$labelPositions = this.$labelPositions.map(pos => this.$idxAxisMax - pos);
},
beforeInit(chart){
const allData = chart.data;
const yIndexedData = {};
for(let datasetIndex = 0; datasetIndex < allData.datasets.length; datasetIndex++){
const dataset = allData.datasets[datasetIndex];
const data = dataset.data;
for(let dataIndex = 0; dataIndex < data.length; dataIndex++){
const dataPoint = data[dataIndex];
yIndexedData[dataPoint.y] = (yIndexedData[dataPoint.y] ?? []).concat([{...dataPoint, dataIndex, datasetIndex}]);
}
}
for(const yDataPoints of Object.values(yIndexedData)){
yDataPoints.sort(({x: [x1]}, {x: [x2]}) => Date.parse(x1) - Date.parse(x2));
}
let groupBase = 0;
for(const [yIndex, yDataPoints] of Object.entries(yIndexedData)){
const groupEnd = groupBase + yDataPoints.length;
yDataPoints.forEach(yDataPoint => {
yDataPoint.yCategory = [groupBase, groupEnd]
yDataPoint.yCategoryIndex = parseFloat(yIndex);
});
groupBase = groupEnd;
}
const categoryWidths = Array.from({length: Object.keys(yIndexedData).length}, (_, i)=>yIndexedData[i].length)
this._computeFlexBarCoordinates(categoryWidths)
this.$originalLabels = allData.labels;
allData.labels = this.$idxBarPositions;
allData.datasets.forEach(
dataset => {
dataset.data.push({x: null, y: null});
dataset.barThickness = "flex";
}
);
chart.options.scales.y.offset = false;
chart.options.scales.y.type = "linear";
chart.options.scales.y.min = 0;
chart.options.scales.y.max = this.$idxAxisMax;
let overAllIndex = 0;
for(const yBars of Object.values(yIndexedData)){
for(const yBar of yBars){
const barData = allData.datasets[yBar.datasetIndex].data[yBar.dataIndex];
barData.yCategory = yBar.yCategory.map(y=>this.$idxAxisMax - y);
barData.yCategoryIndex = yBar.yCategoryIndex;
barData.y = this.$idxAxisMax - ++overAllIndex;
}
}
const plugin = this;
chart.options.plugins.tooltip.callbacks.title = function(tooltipItems){
return [...new Set(tooltipItems.map(item=>plugin.$originalLabels[item.raw.yCategoryIndex]))].join(' - ');
};
chart.options.plugins.tooltip.callbacks.label = function(tooltipItems){
tooltipItems.formattedValue = tooltipItems.raw.x.join(' - ');
}
},
beforeUpdate(chart){
const plugin = this;
const yAxis = chart.scales.y;
yAxis.afterTickToLabelConversion = function(){
this.ticks = Array.from({length: plugin.$labelPositions.length}, (_, i) => ({
value: plugin.$labelPositions[i], label: plugin.$originalLabels[i] || ''
}));
}
if(!this.$gridLineComputerModified){
this.$gridLineComputerModified = true;
let original_computeGridLineItems = yAxis._computeGridLineItems.bind(yAxis);
yAxis._computeGridLineItems = function(...args){
const gridLines = original_computeGridLineItems(...args);
gridLines.forEach((gridLine, idx) => {
gridLine.y1 = gridLine.y2 = gridLine.ty1 = gridLine.ty2 =
yAxis.getPixelForValue(plugin.$idxGridLinesValues[idx])
});
return gridLines;
}
}
},
_setBarElementPxProp(bar, prop, value){
bar[prop] = value;
if(bar.$animations?.[prop]){
bar.$animations[prop]._from = value;
bar.$animations[prop]._to = value;
}
},
_setBarElementYPx(bar, scale, categoryPercentage, barPercentage){
const {y, yCategory} = bar.$context.raw;
if(y === null){
return 0;
}
const maxPxCat = scale.getPixelForValue(yCategory[1]),
minPxCat = scale.getPixelForValue(yCategory[0]),
pxCat = maxPxCat - minPxCat,
nCat = yCategory[0] - yCategory[1],
hPxRaw = pxCat / nCat * categoryPercentage;
const h = hPxRaw *barPercentage,
orderInCat = y - yCategory[1],
yPx = maxPxCat - pxCat * (1 - categoryPercentage) / 2 - hPxRaw / 2 - orderInCat * hPxRaw;
this._setBarElementPxProp(bar, 'height', h);
this._setBarElementPxProp(bar, 'y', yPx);
},
afterUpdate(chart){
const categoryPercentage = chart.options.categoryPercentage,
barPercentage = chart.options.barPercentage;
for(let iDataset = 0; iDataset < chart.getVisibleDatasetCount(); iDataset++){
const bars = chart.getDatasetMeta(iDataset)?.data ?? [];
bars.forEach(
bar => {
this._setBarElementYPx(bar, chart.scales.y, categoryPercentage, barPercentage);
}
)
}
}
};
const data = {
labels: ['Record1', 'Record2', 'Record3', 'Record4', 'Record5'],
datasets: [{
label: 'Open',
data: [
{x: '2024-02-01', y: 0, x2: '2024-02-04'},
{x: '2024-02-01', y: 1, x2: '2024-02-03'},
{x: '2024-02-01', y: 2, x2: '2024-02-02'},
{x: '2024-02-01', y: 3, x2: '2024-02-02'},
{x: '2024-02-04', y: 3, x2: '2024-02-10'},
{x: '2024-02-04', y: 4, x2: '2024-02-05'}
].map(({x, x2, y}) => ({x: [x, x2], y})),
backgroundColor: 'rgba(255, 99, 132, 0.2)',
borderColor: 'rgba(255, 99, 132, 1)',
borderWidth: 1,
borderSkipped: false
},
{
label: 'Under Review',
data: [
{x: '2024-02-04', y: 0, x2: '2024-02-07'},
{x: '2024-02-03', y: 1, x2: '2024-02-04'},
{x: '2024-02-02', y: 2, x2: '2024-02-08'},
{x: '2024-02-05', y: 4, x2: '2024-02-06'}
].map(({x, x2, y}) => ({x: [x, x2], y})),
backgroundColor: 'rgba(54, 162, 235, 0.2)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 1,
borderSkipped: false
},
{
label: 'Closed',
data: [
{x: '2024-02-07', y: 0, x2: '2024-02-10'},
{x: '2024-02-04', y: 1, x2: '2024-02-10'},
{x: '2024-02-08', y: 2, x2: '2024-02-10'},
{x: '2024-02-02', y: 3, x2: '2024-02-07'},
{x: '2024-02-01', y: 4, x2: '2024-02-04'},
{x: '2024-02-06', y: 4, x2: '2024-02-10'}
].map(({x, x2, y}) => ({x: [x, x2], y})),
backgroundColor: 'rgba(75, 212, 0, 0.2)',
borderColor: 'rgba(75, 212, 128, 1)',
borderWidth: 1,
borderSkipped: false
}]
};
const config = {
type: 'bar',
data,
options: {
indexAxis: 'y',
maintainAspectRatio: false,
scales: {
x: {
//stacked: true,
grid:{
color: 'rgba(0,0,0,0.15)',
},
type: 'time',
time: {
unit: 'day'
},
min: '2024-01-31',
max: '2024-02-10'
},
y: {
//stacked: true
grid:{
color: 'rgba(0,0,0,0.2)',
},
}
}
},
plugins: [pluginVariableSortedHorizontalBars]
};
const chart = new Chart('myChart', config);
<div style="min-height: 350px">
<canvas id="myChart">
</canvas>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns/dist/chartjs-adapter-date-fns.bundle.min.js"></script>