chartjs-plugin-gradient
Version:
Easy gradient colors for Chart.js
349 lines (313 loc) • 10.7 kB
JavaScript
/*!
* chartjs-plugin-gradient v0.6.1
* https://github.com/kurkle/chartjs-plugin-gradient#readme
* (c) 2022 Jukka Kurkela
* Released under the MIT License
*/
import { isNumber, color, defined } from 'chart.js/helpers';
import { Chart } from 'chart.js';
const isChartV3 = Chart.version;
const parse = isChartV3
? (scale, value) => scale.parse(value)
: (scale, value) => value;
function scaleValue(scale, value) {
const normValue = isNumber(value) ? parseFloat(value) : parse(scale, value);
return scale.getPixelForValue(normValue);
}
/**
* @typedef { import("chart.js").Chart } Chart
* @typedef { import("chart.js").Scale } Scale
*/
/**
* check if the area is consistent
* @param {Object} area - area to check
* @returns {boolean}
*/
const areaIsValid = (area) => area && area.right > area.left && area.bottom > area.top;
/**
* Create a canvas gradient
* @param {CanvasRenderingContext2D} ctx - chart canvas context
* @param {string} axis - axis type of scale
* @param {Object} area - scale instance
* @returns {CanvasGradient} created gradient
*/
function createGradient(ctx, axis, area) {
if (axis === 'r') {
return ctx.createRadialGradient(area.xCenter, area.yCenter, 0, area.xCenter, area.yCenter, area.drawingArea);
}
if (axis === 'y') {
return ctx.createLinearGradient(0, area.bottom, 0, area.top);
}
return ctx.createLinearGradient(area.left, 0, area.right, 0);
}
/**
* Add color stop to a gradient
* @param {CanvasGradient} gradient - gradient instance
* @param {Array} colors - all colors to add
*/
function applyColors(gradient, colors) {
colors.forEach(function(item) {
gradient.addColorStop(
item.stop, item.color.rgbString()
);
});
}
/**
* Get the gradient plugin configuration from the state for a specific dataset option
* @param {Object} state - state of the plugin
* @param {{key: string, legendItemKey: string}} keyOption - option of the dataset where the gradient is applied
* @param {number} datasetIndex - dataset index
* @returns {Object|undefined} gradient plugin configuration from the state for a specific dataset option
*/
function getGradientData(state, keyOption, datasetIndex) {
if (state.options.has(keyOption.key)) {
const option = state.options.get(keyOption.key);
const gradientData = option.filter((el) => el.datasetIndex === datasetIndex);
if (gradientData.length) {
return gradientData[0];
}
}
}
/**
* Get the pixel and its percentage on the scale, used for color stop in the gradient, for the passed value
* @param {Scale} scale - scale used by dataset
* @param {string|number} value - value to search
* @returns {{pixel: number, stop: number}} the pixel and its percentage on the scale, used for color stop in the gradient
*/
function getPixelStop(scale, value) {
if (scale.type === 'radialLinear') {
const distance = scale.getDistanceFromCenterForValue(value);
return {pixel: distance, stop: distance / scale.drawingArea};
}
const reverse = scale.options.reverse;
const pixel = scaleValue(scale, value);
const stop = scale.getDecimalForPixel(pixel);
return {pixel, stop: reverse ? 1 - stop : stop};
}
// IEC 61966-2-1:1999
const toRGBs = (l) => l <= 0.0031308 ? l * 12.92 : Math.pow(l, 1.0 / 2.4) * 1.055 - 0.055;
// IEC 61966-2-1:1999
const fromRGBs = (srgb) => srgb <= 0.04045 ? srgb / 12.92 : Math.pow((srgb + 0.055) / 1.055, 2.4);
function interpolate(percent, startColor, endColor) {
const start = startColor.color.rgb;
const startR = fromRGBs(start.r / 255);
const startG = fromRGBs(start.g / 255);
const startB = fromRGBs(start.b / 255);
const end = endColor.color.rgb;
const endR = fromRGBs(end.r / 255);
const endG = fromRGBs(end.g / 255);
const endB = fromRGBs(end.b / 255);
return color({
r: Math.round(toRGBs(startR + percent * (endR - startR)) * 255),
g: Math.round(toRGBs(startG + percent * (endG - startG)) * 255),
b: Math.round(toRGBs(startB + percent * (endB - startB)) * 255),
a: start.a + percent * Math.abs(end.a - start.a)
});
}
/**
* Calculate a color from gradient stop color by a value of the dataset.
* @param {Object} state - state of the plugin
* @param {{key: string, legendItemKey: string}} keyOption - option of the dataset where the gradient is applied
* @param {number} datasetIndex - dataset index
* @param {number} value - value used for searching the color
* @returns {Object} calculated color
*/
function getInterpolatedColorByValue(state, keyOption, datasetIndex, value) {
const data = getGradientData(state, keyOption, datasetIndex);
if (!data || !data.stopColors.length) {
return;
}
const {stop: percent} = getPixelStop(data.scale, value);
let startColor, endColor;
for (const stopColor of data.stopColors) {
if (stopColor.stop === percent) {
return stopColor.color;
}
if (stopColor.stop < percent) {
startColor = stopColor;
} else if (stopColor.stop > percent && !endColor) {
endColor = stopColor;
}
}
if (!endColor) {
return startColor;
}
return interpolate(percent, startColor, endColor);
}
const legendOptions = [
{key: 'backgroundColor', legendItemKey: 'fillStyle'},
{key: 'borderColor', legendItemKey: 'strokeStyle'}];
const legendBoxHeight = (chart, options) => options.labels && options.labels.font && defined(options.labels.font.size)
? options.labels.font.size
: chart.options.font.size;
function setLegendItem(state, ctx, keyOption, item, area) {
const data = getGradientData(state, keyOption, item.datasetIndex);
if (!data || !data.stopColors.length) {
return;
}
const value = createGradient(ctx, data.axis, area);
applyColors(value, data.stopColors);
item[keyOption.legendItemKey] = value;
}
function buildArea(hitBox, {boxWidth, boxHeight}) {
return {
top: hitBox.top,
left: hitBox.left,
bottom: hitBox.top + boxHeight,
right: hitBox.left + boxWidth,
xCenter: hitBox.left + boxWidth / 2,
yCenter: hitBox.top + boxHeight / 2,
drawingArea: Math.max(boxWidth, boxHeight) / 2
};
}
function applyGradientToLegendByDatasetIndex(chart, state, item, boxSize) {
const hitBox = chart.legend.legendHitBoxes[item.datasetIndex];
const area = buildArea(hitBox, boxSize);
if (areaIsValid(area)) {
legendOptions.forEach(function(keyOption) {
setLegendItem(state, chart.ctx, keyOption, item, area);
});
}
}
function applyGradientToLegendByDataIndex(chart, state, dataset, datasetIndex) {
for (const item of chart.legend.legendItems) {
legendOptions.forEach(function(keyOption) {
const value = dataset.data[item.index];
const c = getInterpolatedColorByValue(state, keyOption, datasetIndex, value);
if (c && c.valid) {
item[keyOption.legendItemKey] = c.rgbString();
}
});
}
}
/**
* @typedef { import("chart.js").Chart } Chart
*/
/**
* Udpate the legend items, applying the gradients
* @param {Chart} chart - chart instance
* @param {Object} state - state of the plugin
*/
function updateLegendItems(chart, state) {
const legend = chart.legend;
const options = legend.options;
const boxHeight = options.labels.boxHeight
? options.labels.boxHeight
: legendBoxHeight(chart, options);
const boxWidth = options.labels.boxWidth;
const datasets = chart.data.datasets;
for (let i = 0; i < datasets.length; i++) {
const item = legend.legendItems[i];
if (item.datasetIndex === i) {
applyGradientToLegendByDatasetIndex(chart, state, item, {boxWidth, boxHeight});
} else {
applyGradientToLegendByDataIndex(chart, state, datasets[i], i);
}
}
}
const chartStates = new Map();
const getScale = isChartV3
? (meta, axis) => meta[axis + 'Scale']
: (meta, axis) => meta.controller['_' + axis + 'Scale'];
function addColors(scale, colors, stopColors) {
for (const value of Object.keys(colors)) {
const {pixel, stop} = getPixelStop(scale, value);
if (isFinite(pixel) && isFinite(stop)) {
const colorStop = color(colors[value]);
if (colorStop && colorStop.valid) {
stopColors.push({
stop: Math.max(0, Math.min(1, stop)),
color: colorStop
});
}
}
}
stopColors.sort((a, b) => a.stop - b.stop);
}
function setValue(meta, dataset, key, value) {
dataset[key] = value;
if (!meta.dataset) {
return;
}
if (meta.dataset.options) {
meta.dataset.options[key] = value;
} else {
meta.dataset[key] = value;
}
}
function getStateOptions(state, meta, key, datasetIndex) {
let stateOptions = state.options.get(key);
if (!stateOptions) {
stateOptions = [];
state.options.set(key, stateOptions);
} else if (!meta.hidden) {
stateOptions = stateOptions.filter((el) => el.datasetIndex !== datasetIndex);
state.options.set(key, stateOptions);
}
return stateOptions;
}
function updateDataset(chart, state, gradient, dataset, datasetIndex) {
const ctx = chart.ctx;
const meta = chart.getDatasetMeta(datasetIndex);
if (meta.hidden) {
return;
}
for (const [key, options] of Object.entries(gradient)) {
const {axis, colors} = options;
if (!colors) {
continue;
}
const scale = getScale(meta, axis);
if (!scale) {
console.warn(`Scale not found for '${axis}'-axis in datasets[${datasetIndex}] of chart id ${chart.id}, skipping.`);
continue;
}
const stateOptions = getStateOptions(state, meta, key, datasetIndex);
const option = {
datasetIndex,
axis,
scale,
stopColors: []
};
stateOptions.push(option);
const value = createGradient(ctx, axis, scale);
addColors(scale, colors, option.stopColors);
if (option.stopColors.length) {
applyColors(value, option.stopColors);
setValue(meta, dataset, key, value);
}
}
}
var index = {
id: 'gradient',
beforeInit(chart) {
const state = {};
state.options = new Map();
chartStates.set(chart, state);
},
beforeDatasetsUpdate(chart) {
const area = chart.chartArea;
if (!areaIsValid(area)) {
return;
}
const state = chartStates.get(chart);
const datasets = chart.data.datasets;
for (let i = 0; i < datasets.length; i++) {
const dataset = datasets[i];
const gradient = dataset.gradient;
if (gradient) {
updateDataset(chart, state, gradient, dataset, i);
}
}
},
afterUpdate(chart) {
const state = chartStates.get(chart);
if (chart.legend && chart.legend.options.display !== false && isChartV3) {
updateLegendItems(chart, state);
}
},
destroy(chart) {
chartStates.delete(chart);
}
};
export { index as default };