Skip to content

Commit

Permalink
Enable gradient styles on the legend items (#29)
Browse files Browse the repository at this point in the history
* Enable gradient styles on the legend items

* manages colors also at data index leven

* changes check on array length

* improves some parts of code

* adds JSDoc to expose functions

* fixes lint error

* changes name of variable

* adds test cases

* Update src/colors.js

Co-authored-by: Jukka Kurkela <[email protected]>

* apply some reviews

* apply some reviews 2

* adds additional tests and check if legend can be changed

* fixes code smells

* adds alpha interpolation

* adds test cases to radial linear axis and on hover border color

* changes image in README

* reduces dimension of image

* sets the precise dimension of image

* Update src/index.js

Co-authored-by: Jukka Kurkela <[email protected]>

* Update src/index.js

Co-authored-by: Jukka Kurkela <[email protected]>

* Update src/index.js

Co-authored-by: Jukka Kurkela <[email protected]>

Co-authored-by: Jukka Kurkela <[email protected]>
  • Loading branch information
stockiNail and kurkle authored Mar 30, 2022
1 parent a57e1c6 commit e3e82eb
Show file tree
Hide file tree
Showing 42 changed files with 1,077 additions and 56 deletions.
Binary file modified sample.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
57 changes: 57 additions & 0 deletions src/colors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import {color} from 'chart.js/helpers';
import {getGradientData, getPixelStop} from './helpers';

// 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
*/
export 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);
}
89 changes: 89 additions & 0 deletions src/helpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import {Chart} from 'chart.js';
import {isNumber} from 'chart.js/helpers';

export 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}
*/
export 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
*/
export 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
*/
export 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
*/
export 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
*/
export 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};
}
131 changes: 76 additions & 55 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,48 +1,27 @@
import {Chart} from 'chart.js';
import {isNumber, color} from 'chart.js/helpers';
import {color} from 'chart.js/helpers';
import {updateLegendItems} from './legend';
import {areaIsValid, createGradient, applyColors, getPixelStop, isChartV3} from './helpers';

function createGradient(ctx, axis, scale) {
if (axis === 'r') {
return ctx.createRadialGradient(scale.xCenter, scale.yCenter, 0, scale.xCenter, scale.yCenter, scale.drawingArea);
}
if (axis === 'y') {
return ctx.createLinearGradient(0, scale.bottom, 0, scale.top);
}
return ctx.createLinearGradient(scale.left, 0, scale.right, 0);
}

const parse = Chart.version
? (scale, value) => scale.parse(value)
: (scale, value) => value;
const chartStates = new Map();

function scaleValue(scale, value) {
const normValue = isNumber(value) ? parseFloat(value) : parse(scale, value);
return scale.getPixelForValue(normValue);
}

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};
}
const getScale = isChartV3
? (meta, axis) => meta[axis + 'Scale']
: (meta, axis) => meta.controller['_' + axis + 'Scale'];

function addColors(gradient, scale, colors) {
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) {
gradient.addColorStop(
Math.max(0, Math.min(1, stop)), colorStop.rgbString()
);
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) {
Expand All @@ -58,42 +37,84 @@ function setValue(meta, dataset, key, value) {
}
}

const getScale = Chart.version
? (meta, axis) => meta[axis + 'Scale']
: (meta, axis) => meta.controller['_' + axis + 'Scale'];
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;
}

const areaIsValid = (area) => area && area.right > area.left && area.bottom > area.top;
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);
}
}
}

export default {
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 ctx = chart.ctx;
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) {
continue;
if (gradient) {
updateDataset(chart, state, gradient, dataset, i);
}
const meta = chart.getDatasetMeta(i);
}
},

for (const [key, options] of Object.entries(gradient)) {
const {axis, colors} = options;
const scale = getScale(meta, axis);
if (!scale) {
console.warn(`Scale not found for '${axis}'-axis in datasets[${i}] of chart id ${chart.id}, skipping.`);
continue;
}
if (colors) {
const value = createGradient(ctx, axis, scale);
addColors(value, scale, colors);
setValue(meta, dataset, key, value);
}
}
afterUpdate(chart) {
const state = chartStates.get(chart);
if (chart.legend && isChartV3) {
updateLegendItems(chart, state);
}
},

destroy(chart) {
chartStates.delete(chart);
}
};
82 changes: 82 additions & 0 deletions src/legend.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import {defined} from 'chart.js/helpers';
import {getInterpolatedColorByValue} from './colors';
import {areaIsValid, createGradient, applyColors, getGradientData} from './helpers';

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
*/
export 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);
}
}
}
Loading

0 comments on commit e3e82eb

Please sign in to comment.