Chart js different background for y axis - javascript

I have a line chart in chart js. I want to give it a different background on the y axis say, 0-40 is red,40-70 is yellow and 70-100 is green. The limit for the y axis will always be 100.
var scatterChart = new Chart(ctx, {
type: 'line',
data: {
datasets: [{
label: ' Dataset',
data: [{
x: 1,
y: 10
}, {
x: 2,
y: 50
}, {
x: 3,
y: 88
}, {
x: 4,
y: 5
}]
}]
},
options: {
scales: {
xAxes: [{
type: 'linear',
position: 'bottom'
}]
}
}
});
How do i set the background?

There is not a built in option, but we can achieve the result with some code.
var ctx = document.getElementById("chart").getContext("2d");
var scatterChart = new Chart(ctx, {
type: "line",
data: {
datasets: [{
label: " Dataset",
data: [{
x: 1,
y: 10
},
{
x: 2,
y: 50
},
{
x: 3,
y: 88
},
{
x: 4,
y: 5
}
]
}]
},
options: {
backgroundRules: [{
backgroundColor: "red",
yAxisSegement: 40
},
{
backgroundColor: "yellow",
yAxisSegement: 70
},
{
backgroundColor: "green",
yAxisSegement: Infinity
}
],
scales: {
xAxes: [{
type: "linear",
position: "bottom"
}],
yAxes: [{
color: ["#123456", "#234567"]
}]
}
},
plugins: [{
beforeDraw: function(chart) {
var ctx = chart.chart.ctx;
var ruleIndex = 0;
var rules = chart.chart.options.backgroundRules;
var yaxis = chart.chart.scales["y-axis-0"];
var xaxis = chart.chart.scales["x-axis-0"];
var partPercentage = 1 / (yaxis.ticksAsNumbers.length - 1);
for (var i = yaxis.ticksAsNumbers.length - 1; i > 0; i--) {
if (yaxis.ticksAsNumbers[i] < rules[ruleIndex].yAxisSegement) {
ctx.fillStyle = rules[ruleIndex].backgroundColor;
ctx.fillRect(xaxis.left, yaxis.top + (i - 1) * (yaxis.height * partPercentage), xaxis.width, yaxis.height * partPercentage);
} else {
ruleIndex++;
i++;
}
}
}
}]
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.3/Chart.bundle.js"></script>
<div class="container">
<canvas id="chart"></canvas>
</div>

Let me present a generic approach that works with any such chart aslong as its dataset contains zero or positive values only.
The background colors together with the upper values can simply be defined inside the dataset as follows:
bgColors: [
{ color: 'red', upTo: 40 },
{ color: 'yellow', upTo: 70 },
{ color: 'green', upTo: 100 }
]
Then you could extend an existing line chart (i.e. 'lineDiffBgColors') and overwrite its update function. In there, you would create a linear CanvasGradient and add color stops that correspond to the definitions of bgColors mentioned above. At the end, the linear gradient needs to be assigned to the backgroundColor option of your dataset.
this.chart.data.datasets[0].backgroundColor = gradient;
Please have a look at your enhanced code below.
Chart.defaults.lineDiffBgColors = Chart.defaults.line;
Chart.controllers.lineDiffBgColors = Chart.controllers.line.extend({
update: function(reset) {
var yAxis = this.chart.scales['y-axis-0'];
var bgColors = this.chart.data.datasets[0].bgColors.slice().reverse();
var max = Math.max.apply(null, bgColors.map(o => o.upTo));
var min = yAxis.getValueForPixel(yAxis.bottom);
var yTop = yAxis.getPixelForValue(max);
var gradient = this.chart.chart.ctx.createLinearGradient(0, yTop, 0, yAxis.bottom);
let offset = 0;
bgColors.forEach((bgc, i) => {
gradient.addColorStop(offset, bgc.color);
if (i + 1 == bgColors.length) {
offset = 1;
} else {
offset = (max - bgColors[i + 1].upTo) / (max - min);
}
gradient.addColorStop(offset, bgc.color);
});
this.chart.data.datasets[0].backgroundColor = gradient;
return Chart.controllers.line.prototype.update.apply(this, arguments);
}
});
new Chart('myChart', {
type: 'lineDiffBgColors',
data: {
datasets: [{
label: 'Dataset',
data: [
{ x: 1, y: 10 },
{ x: 2, y: 50 },
{ x: 3, y: 88 },
{ x: 4, y: 5 }
],
bgColors: [
{ color: 'red', upTo: 40 },
{ color: 'yellow', upTo: 70 },
{ color: 'green', upTo: 100 }
]
}]
},
options: {
scales: {
xAxes: [{
type: 'linear'
}]
}
}
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.3/Chart.js"></script>
<canvas id="myChart" height="100"></canvas>
In case you prefer kind of smooth gradient, you could change the bgColors.forEach loop inside the update function as follows.
bgColors.forEach((bgc, i) => {
gradient.addColorStop(offset == 0 ? 0 : offset + 0.05, bgc.color);
if (i + 1 == bgColors.length) {
offset = 1;
} else {
offset = (max - bgColors[i + 1].upTo) / (max - min);
}
gradient.addColorStop(offset == 1 ? 1 : offset - 0.05, bgc.color);
});

Well you can try something (ugly) like this, there is a comment in the snippet where the gradient is defined.
Surely the colors and composition of the gradient can be determined by input properties like you want. Also, one could play with the gradient position or make a radial gradient instead of a linear one.
One last thing, this snippet can really be improved, basically what I did is to identify how the chart is drawn by the library and in which part of it's lifecycle is done, then I copy it into a plugin and replace the solid background color for a canvas linear gradient ;)
To improve the snippet I would start by trying to use the methods defined inside the Chart object (like lineTo() or drawArea()) instead of copying them inside the plugin, then implement the options defined inside the options object to create the linear gradient.
var ctx = document.getElementById("chart").getContext("2d");
var scatterChart = new Chart(ctx, {
type: "line",
data: {
datasets: [{
label: " Dataset",
data: [{
x: 1,
y: 10
},
{
x: 2,
y: 50
},
{
x: 3,
y: 88
},
{
x: 4,
y: 5
}
]
}]
},
options: {
scales: {
xAxes: [{
type: "linear",
position: "bottom"
}]
}
},
plugins: [{
beforeDatasetDraw: function(chart, options) {
var metasets = chart._getSortedVisibleDatasetMetas();
var ctx = chart.ctx;
var meta, i, el, view, points, mapper, color;
var clipArea = (ctx, area) => {
ctx.save();
ctx.beginPath();
ctx.rect(area.left, area.top, area.right - area.left, area.bottom - area.top);
ctx.clip();
};
var unclipArea = (ctx) => {
ctx.restore();
};
var isDrawable = (point) => {
return point && !point.skip;
}
var lineTo = (ctx, previous, target, flip) => {
var stepped = target.steppedLine;
if (stepped) {
if (stepped === 'middle') {
var midpoint = (previous.x + target.x) / 2.0;
ctx.lineTo(midpoint, flip ? target.y : previous.y);
ctx.lineTo(midpoint, flip ? previous.y : target.y);
} else if ((stepped === 'after' && !flip) || (stepped !== 'after' && flip)) {
ctx.lineTo(previous.x, target.y);
} else {
ctx.lineTo(target.x, previous.y);
}
ctx.lineTo(target.x, target.y);
return;
}
if (!target.tension) {
ctx.lineTo(target.x, target.y);
return;
}
ctx.bezierCurveTo(
flip ? previous.controlPointPreviousX : previous.controlPointNextX,
flip ? previous.controlPointPreviousY : previous.controlPointNextY,
flip ? target.controlPointNextX : target.controlPointPreviousX,
flip ? target.controlPointNextY : target.controlPointPreviousY,
target.x,
target.y);
}
var drawArea = (ctx, curve0, curve1, len0, len1) => {
var i, cx, cy, r;
if (!len0 || !len1) {
return;
}
// building first area curve (normal)
ctx.moveTo(curve0[0].x, curve0[0].y);
for (i = 1; i < len0; ++i) {
lineTo(ctx, curve0[i - 1], curve0[i]);
}
if (curve1[0].angle !== undefined) {
cx = curve1[0].cx;
cy = curve1[0].cy;
r = Math.sqrt(Math.pow(curve1[0].x - cx, 2) + Math.pow(curve1[0].y - cy, 2));
for (i = len1 - 1; i > 0; --i) {
ctx.arc(cx, cy, r, curve1[i].angle, curve1[i - 1].angle, true);
}
return;
}
// joining the two area curves
ctx.lineTo(curve1[len1 - 1].x, curve1[len1 - 1].y);
// building opposite area curve (reverse)
for (i = len1 - 1; i > 0; --i) {
lineTo(ctx, curve1[i], curve1[i - 1], true);
}
}
var doFill = (ctx, points, mapper, view, color, loop) => {
var count = points.length;
var span = view.spanGaps;
var curve0 = [];
var curve1 = [];
var len0 = 0;
var len1 = 0;
var i, ilen, index, p0, p1, d0, d1, loopOffset;
ctx.beginPath();
for (i = 0, ilen = count; i < ilen; ++i) {
index = i % count;
p0 = points[index]._view;
p1 = mapper(p0, index, view);
d0 = isDrawable(p0);
d1 = isDrawable(p1);
if (loop && loopOffset === undefined && d0) {
loopOffset = i + 1;
ilen = count + loopOffset;
}
if (d0 && d1) {
len0 = curve0.push(p0);
len1 = curve1.push(p1);
} else if (len0 && len1) {
if (!span) {
drawArea(ctx, curve0, curve1, len0, len1);
len0 = len1 = 0;
curve0 = [];
curve1 = [];
} else {
if (d0) {
curve0.push(p0);
}
if (d1) {
curve1.push(p1);
}
}
}
}
drawArea(ctx, curve0, curve1, len0, len1);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
}
for (i = metasets.length - 1; i >= 0; --i) {
meta = metasets[i].$filler;
if (!meta || !meta.visible) {
continue;
}
el = meta.el;
view = el._view;
points = el._children || [];
mapper = meta.mapper;
// NOTE: HERE IS WHERE THE GRADIENT IS DEFINED. ONE COULD PROBABLY CREATE THE GRADIENT BASED ON INPUT DATA INSIDE THE OPTIONS OBJECT.
color = ctx.createLinearGradient(chart.width / 2, chart.height, chart.width / 2, 0);
color.addColorStop(0, 'red');
color.addColorStop(0.2, 'red');
color.addColorStop(0.4, 'yellow');
color.addColorStop(0.6, 'yellow');
color.addColorStop(0.8, 'green');
color.addColorStop(1, 'green');
if (mapper && color && points.length) {
clipArea(ctx, chart.chartArea);
doFill(ctx, points, mapper, view, color, el._loop);
unclipArea(ctx);
}
}
}
}]
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.3/Chart.bundle.js"></script>
<div class="container">
<canvas id="chart"></canvas>
</div>

Related

Chart.Js how to hide datapoints with "0" value in Stacked Bar Chart

I want to hide some bars that dont have a useful value ("0") but I am not sure about how to do it, since I cannot use any Chart.Js plugins and I cannot just hide the whole Dataset since I am using a SQL Table to get the Values "into" Chart.Js.
I would like to hide the 0-Values on the chart
Note: I only want to hide the Value not the whole bar itself.
//coloring the bars from light blue (receiverColor) to dark blue (performancePayColor)
var receiverColor = "#7ec0ee";
var timeWageColor = "#368DD6";
var performancePayColor = "#214B7D";
//removing of chart when openening a new OE
// var chartContainer = document.getElementById('chartContainer');
// chartContainer.innerHTML = "";
for (var x = 0; x < charts.length; x++) {
var chartData = charts[x];
var sumChartValues = [];
//adding up the Arrays
for (var i = 0; i < chartData.GE.length; i++) {
sumChartValues.push(chartData.GE[i] + chartData.LL[i] + chartData.ZL[i]);
}
//creating a TempChart canvas with same values as chart1 to be able to delete Chart1 canvas
var chart = document.createElement("canvas");
chart.id = "Chart" + x;
//make the Charts responsive
var width = $(window).width();
var paddingL; //= 0;
var paddingR; //= 0;
if (width >= 2560) {
// chart.height = height / 2.2;
chart.width = width / 2.2;
paddingL = 25;
paddingR = 25;
}
else if (width >= 1440) {
// chart.height = height / 2.2;
chart.width = width / 2.2;
paddingL = 25;
paddingR = 25;
}
else if (width >= 1024) {
// chart.height = height / 2.2;
chart.width = width / 2.5;
paddingL = 20;
paddingR = 20;
}
else if (width >= 768) {
// chart.height = height / 2;
chart.width = width / 1.25;
paddingL = 30;
paddingR = 30;
}
else {
//chart.height = height;
chart.width = width / 1.25;
}
document.getElementById("chartContainer").appendChild(chart);
//var originalOnClick = Chart.defaults.global.legend.onClick;
new Chart("Chart" + x, {
//barchart is what we use normally, change type if something else is needed.
type: "bar",
data: {
//labels is for the Values of the first x-Axis (we normally use KW)
labels: chartData.KW,
//datasets are the Bars that are displayed on the charts (each Dataset is one different Bar part a.e. a different color and value on one bar and KW)
datasets: [
{
label: "Leistungslohn",
backgroundColor: performancePayColor,
data: chartData.LL,
xAxesID: "lowerXAxis",
},
{
label: "Zeitlohn",
backgroundColor: timeWageColor,
data: chartData.ZL,
xAxesID: "lowerXAxis"
},
{
label: "Gehaltsempfänger",
backgroundColor: receiverColor,
data: chartData.GE,
xAxesID: "lowerXAxis"
}]
},
options: {
//Set the Chart responsive
maintainAspectRatio: true,
responsive: false,
layout: {
padding: {
left: paddingL,
right: paddingR
}
},
//Dynamic Chart title display on top of the Legend
title: {
size: '200',
display: true,
text: chartData.O_Long[0] + ' (' + chartData.O_Short[0] + ')',
},
//Enables legend Over the bars (Leistungslohn, Zeitlohn and Gehaltsempfänger)
legend: {
display: true,
position: 'bottom'
},
//Enables tooltips when hovering over bars
tooltips: {
enabled: true
},
hover: {
//disables number flickering when moving mouse across bars
animationDuration: 0,
//disables hover effect (change of bar color when hovering over a bar)
mode: null
},
animation: {
//duration = build up time for the chart in ms, 0 = no visible animation.
duration: 0,
onComplete: function () {
//Shows bar values on top of each bar and Formats them
var ctx = this.chart.ctx;
var legendItems = this.chart.legend.legendItems;
var max = this.chart.scales['y-axis-0'].max;
ctx.font = Chart.helpers.fontString(Chart.defaults.global.defaultFontSize, 'bold', Chart.defaults.global.defaultFontFamily);
//coloring the values white
ctx.fillStyle = this.chart.config.options.defaultFontColor = "#FFF";
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
//Calculation of the required scale
var chartHeight = this.height;
this.data.datasets.forEach(function (dataset) {
var index = -1;
for (var i = 0; i < legendItems.length; i++) {
if (legendItems[i].text == dataset.label && !legendItems[i].hidden) {
index = i;
break;
}
}
if (index != -1) {
for (var i = 0; i < dataset.data.length; i++) {
var model = dataset._meta[Object.keys(dataset._meta)[0]].data[i]._model;
ctx.fillText(dataset.data[i], model.x, model.y + (dataset.data[i] / max * ((chartHeight / 2) - 50)));
}
}
})
//hide zero values
},
},
scales: {
xAxes: [{
//Stacks mutliple Datasets on top of each other
ID: "lowerXAxis",
stacked: true,
gridLines: {
//removes x-Axes grid lines
display: false
}
},
//Second x-Axes:
{
ID: "upperXAxis",
position: "top",
offset: true,
gridLines: {
//removes x-Axes grid lines
display: false
}
}],
yAxes: [{
//Stacks mutliple Datasets on top of each other
stacked: true,
scaleLabel: {
offset: true,
display: true,
labelString: 'Anzahl Personen',
},
ticks: {
//Makes the chart start at 0 to prevent negative values and to prevent formatting errors
beginAtZero: true,
}
}]
}
}
});
}
}```
Inside the options.animation.onComplete callback function, you draw the text using ctx.fillText(...). Simply don't draw the text in case the value is zero.
To do so, you should add an if statement as follows:
if (dataset.data[i] > 0) {
ctx.fillText(dataset.data[i], model.x, model.y + (dataset.data[i] / max * ((chartHeight / 2) - 50)));
}
In case the values are defined as strings as your question could suggest, the if statement may have to be changed slightly (i.e if (dataset.data[i] != '0') {).
This solution works for me (using ChartJS version 4.2):
Prerequisites: OP says he cannot use plugins, so step 3 of this solution would need to be modified if that is still the case - however if you can use the newly separated label plugins for version 4.0 and higher of ChartJS, the below will work (but you have to import the below plugin CDN below your ChartJS import). Use of this plugin is now the officially supported means of displaying labels on top of charts for v4 and above of ChartJS.
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels#2.0.0"></script>
First define a set of formatter functions (I have 4 such functions: one simply returns the value as the label, another returns the value formatted as a percentage; the other two do the same, but return null when the value is equal to 0. We use these for chart types with touching data regions (e.g. stacked bars, pies and donuts).
const absoluteFormatter = (value) => {
return value;
}
const percentageFormatter = (value) => {
return Math.round(value) + '%';
}
const absoluteFormatterTouching = (value) => {
if (value > 0) {
return value;
} else {
return null;
}
}
const percentageFormatterTouching = (value) => {
if (value > 0) {
return Math.round(value) + '%';
} else {
return null;
}
}
Note the above formatters won't work if you deal with any negative values or string values, so you'd need to adjust them accordingly in that case.
Then when working with plugins, we use a config file like the below for a stacked bar chart:
const absoluteFormatterTouching = (value) => {
if (value > 0) {
return value;
} else {
return null;
}
}
const labels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul'];
const data = {
labels: labels,
datasets: [
{
label: 'Dataset 1',
data: [0,4,5,2,10,20,30],
backgroundColor: 'rgb(255, 99, 132)',
},
{
label: 'Dataset 2',
data: [12,19,3,0,2,3,10],
backgroundColor: 'rgb(54, 162, 235)',
},
{
label: 'Dataset 3',
data: [11,20,30,4,51,60,0],
backgroundColor: 'rgb(255, 205, 86)',
},
]
};
const config = {
type: "bar",
data: data,
plugins: [ChartDataLabels],
options: {
plugins: {
title: {
display: true,
text: "Chart.js Bar Chart - Stacked",
},
datalabels: {
display: true,
formatter: absoluteFormatterTouching,
},
},
responsive: true,
scales: {
x: {
stacked: true,
},
y: {
stacked: true,
},
},
},
};
const ctx = document.getElementById('myChart').getContext('2d');
const myChart = new Chart(ctx, config);
Note the inclusion of the relevant formatter within the chart config.options.plugins.datalabels.formatter: absoluteFormatterTouching. This is where you place a pointer to the formatter you wish to use. You also have to include the line config.plugins: [ChartDataLabels] to have access to the imported DataLabels plugin from the CDN.

Setting Color With Array Data Format in Highcharts Funnel

I have attached the funnel visualization code that I have so far.
$(function() {
var dataEx = [
['1 Visit', 352000],
['2 Visits', 88000],
['3+ Visits', 42000]
],
len = dataEx.length,
sum = 0,
minHeight = 0.05,
data = [];
//specify your percent of prior visit value manually here:
var perc = [100, 25, 48];
for (var i = 0; i < len; i++) {
sum += dataEx[i][1];
}
for (var i = 0; i < len; i++) {
var t = dataEx[i],
r = t[1] / sum;
data[i] = {
name: t[0],
y: (r > minHeight ? t[1] : sum * minHeight),
percent: perc[i], // <----- this here is manual input
//percent: Math.round(r * 100), <--- this here is mathematical
label: t[1]
}
}
console.log(dataEx, data)
$('#container').highcharts({
chart: {
type: 'funnel',
marginRight: 100,
events: {
load: function() {
var chart = this;
Highcharts.each(chart.series[0].data, function(p, i) {
var bBox = p.dataLabel.getBBox()
p.dataLabel.attr({
x: (chart.plotWidth - chart.plotLeft) / 2,
'text-anchor': 'middle',
y: p.labelPos.y - (bBox.height / 2)
})
})
},
redraw: function() {
var chart = this;
Highcharts.each(chart.series[0].data, function(p, i) {
p.dataLabel.attr({
x: (chart.plotWidth - chart.plotLeft) / 2,
'text-anchor': 'middle',
y: p.labelPos.y - (bBox.height / 2)
})
})
}
},
},
title: {
text: 'Guest Return Funnel',
x: -50
},
tooltip: {
//enabled: false
formatter: function() {
return '<b>' + this.key +
'</b><br/>Percent of Prior Visit: '+ this.point.percent + '%<br/>Guests: ' + Highcharts.numberFormat(this.point.label, 0);
}
},
plotOptions: {
series: {
allowPointSelect: true,
borderWidth: 12,
animation: {
duration: 400
},
dataLabels: {
enabled: true,
connectorWidth: 0,
distance: 0,
formatter: function() {
var point = this.point;
console.log(point);
return '<b>' + point.name + '</b> (' + Highcharts.numberFormat(point.label, 0) + ')<br/>' + point.percent + '%';
},
minSize: '10%',
color: 'black',
softConnector: true
},
neckWidth: '30%',
neckHeight: '0%',
width: '50%',
height: '110%'
//old options are as follows:
//neckWidth: '50%',
//neckHeight: '50%',
//-- Other available options
//height: '200'
// width: pixels or percent
}
},
legend: {
enabled: false
},
series: [{
name: 'Unique users',
data: data
}]
});
});
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js"></script>
<script src="http://code.highcharts.com/highcharts.js"></script>
<script src="http://code.highcharts.com/modules/funnel.js"></script>
<script src="http://code.highcharts.com/modules/exporting.js"></script>
<div id="container" style="width: 500px; height: 400px; margin: 0 auto"></div>
What I want to do is manually change the color of each category (piece) of the funnel (for example, maybe first category red, second category orange, third category yellow). I know that there are some ways to enter in data into a series in Highcharts such as:
[['CATEOGRY', 'VALUE],...['CATEGORY','VALUE']]
or you can do an array with names value and specify something like
color: "#00FF00" inside of it.
So maybe I can use the second form of writing data into a series cause you can specify color.
However, how would I be able to specify color of the pieces WHILE ALSO ensuring that the data processing algorithm to scale when there are small values works and the rest of the code works?
Also, is there any way to specify color given the current array of data that I have in my code? Being dataEx = [['CATEOGRY', 'VALUE],...['CATEGORY','VALUE']]
You can simply set the color as the third element in dataEx array and then set it as a point color:
var dataEx = [
['1 Visit', 352000, 'red'],
['2 Visits', 88000, 'orange'],
['3+ Visits', 42000, 'yellow']
],
len = dataEx.length,
sum = 0,
minHeight = 0.05,
data = [];
//specify your percent of prior visit value manually here:
var perc = [100, 25, 48];
for (var i = 0; i < len; i++) {
sum += dataEx[i][1];
}
for (var i = 0; i < len; i++) {
var t = dataEx[i],
r = t[1] / sum;
data[i] = {
name: t[0],
color: t[2],
y: (r > minHeight ? t[1] : sum * minHeight),
percent: perc[i], // <----- this here is manual input
//percent: Math.round(r * 100), <--- this here is mathematical
label: t[1]
}
}
Live demo: https://jsfiddle.net/BlackLabel/8be3c1sm/
API Reference: https://api.highcharts.com/highcharts/series.funnel.data.color

Highcharts capture selection

I have a high chart on my web page which is a line chart. It has a functionality to zoom and I capture the zoom event using chart.events.selection. That all is working fine.
But I want to continuously capture the selection event (i.e. basically click and drag event) to show a tooltip in the beginning and end of the selection to show the time user has selected. Couldn't find in high charts documentation. Any help would be appreciated.
Here is my current code to capture selection event:
$(obj.id).highcharts({
chart: {
type: 'areaspline',
backgroundColor:"rgba(0,0,0,0)",
zoomType:"x",
events: {
selection: function(event){
if(!event.xAxis)
return;
.....
Updated:
Updated example in which labels following selection marker: http://jsfiddle.net/pq0wn0xx/2/
I do not think there is a drag event (not for points), but you can wrap drag pointer's method.
Highcharts.wrap(Highcharts.Pointer.prototype, 'drag', function (p, e) {
p.call(this, e);
var H = Highcharts,
chart = this.chart,
selectionMarker = this.selectionMarker,
bBox,
xAxis,
labelLeft,
labelRight,
labelY,
attr,
css,
timerLeft,
timerRight;
if (selectionMarker) {
if (!chart.customLabels) {
chart.customLabels = [];
}
bBox = selectionMarker.getBBox();
xAxis = chart.xAxis[0];
labelLeft = chart.customLabels[0];
labelRight = chart.customLabels[1];
labelY = chart.plotTop + 10;
if (!labelLeft || !labelRight) {
attr = {
fill: Highcharts.getOptions().colors[0],
padding: 10,
r: 5,
zIndex: 8
};
css = {
color: '#FFFFFF'
};
labelLeft = chart.renderer.label('', 0, 0).attr(attr).css(css).add();
labelRight = chart.renderer.label('', 0, 0).attr(attr).css(css).add();
chart.customLabels.push(labelLeft, labelRight);
}
clearTimeout(timerLeft);
clearTimeout(timerRight);
labelLeft.attr({
x: bBox.x - labelLeft.getBBox().width,
y: labelY,
text: 'min: ' + H.numberFormat(xAxis.toValue(bBox.x), 2),
opacity: 1
});
labelRight.attr({
x: bBox.x + bBox.width,
y: labelY,
text: 'max: ' + H.numberFormat(xAxis.toValue(bBox.x + bBox.width), 2),
opacity: 1
});
timerLeft = setTimeout(function () {
labelLeft.fadeOut();
}, 3000);
timerRight = setTimeout(function () {
labelRight.fadeOut();
}, 3000);
}
});
Old answer:
The example from the official API
can be extended to what you need.
The code and the example on jsfiddle are below:
function positionLabels(e, chart) {
if (!chart.customLabels) {
chart.customLabels = [];
}
var labelLeft,
labelRight,
attr,
css,
xAxis,
xMin,
xMax,
yAxis,
yMin,
yMax,
yMiddle,
timerLeft,
timerRight;
if (!e.resetSelection) {
labelLeft = chart.customLabels[0];
labelRight = chart.customLabels[1];
if (!labelLeft || !labelRight) {
attr = {
fill: Highcharts.getOptions().colors[0],
padding: 10,
r: 5,
zIndex: 8
};
css = {
color: '#FFFFFF'
};
labelLeft = chart.renderer.label('', 0, 0).attr(attr).css(css).add();
labelRight = chart.renderer.label('', 0, 0).attr(attr).css(css).add();
chart.customLabels.push(labelLeft, labelRight);
}
clearTimeout(timerLeft);
clearTimeout(timerRight);
xAxis = e.xAxis[0].axis;
xMin = e.xAxis[0].min;
xMax = e.xAxis[0].max;
yAxis = chart.yAxis[0];
yMin = yAxis.min;
yMax = yAxis.max;
yMiddle = (yMax - yMin) * 0.95;
labelLeft.attr({
x: xAxis.toPixels(xMin) - labelLeft.getBBox().width,
y: yAxis.toPixels(yMiddle),
text: 'min: ' + Highcharts.numberFormat(xMin, 2),
opacity: 1
});
labelRight.attr({
x: xAxis.toPixels(xMax),
y: yAxis.toPixels(yMiddle),
text: 'max: ' + Highcharts.numberFormat(xMax, 2),
opacity: 1
});
timerLeft = setTimeout(function () {
labelLeft.fadeOut();
}, 2000);
timerRight = setTimeout(function () {
labelRight.fadeOut();
}, 2000);
}
}
example: http://jsfiddle.net/pq0wn0xx/

How can I move a label left, paint it black, or remove it (Chart.JS)?

I've got a horizontal bar chart displaying like so:
As the second data value on the bars (1.0, 0.8, etc.) are partially obscured, I would like to do one of the following things, in order of preference:
Move them to the left, so that they are completely visible
Change their font from white to back, so that they are completely visible
Remove them altogether, so that they are completely invisible
The code that is causing them to be written in the first (second?) place is this:
Chart.pluginService.register({
afterDraw: function (chartInstance) {
if (chartInstance.id !== 1) return; // affect this one only
var ctx = chartInstance.chart.ctx;
// render the value of the chart above the bar
ctx.font = Chart.helpers.fontString(14, 'bold',
Chart.defaults.global.defaultFontFamily);
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
chartInstance.data.datasets.forEach(function (dataset) {
for (var i = 0; i < dataset.data.length; i++) {
var model = dataset._meta[Object.keys(dataset._me
[0]].data[i]._model;
ctx.fillText(dataset.data[i]
(Number.isInteger(dataset.data[i]) ? ".0" : "") + "%", ((model.x
model.base) / 2), model.y + (model.height / 3));
}
});
}
});
...but I don't see just where there I can manipulate those values as desired.
For context and full disclosure, here is all the code for the chart:
Chart.pluginService.register({
afterDraw: function (chartInstance) {
if (chartInstance.id !== 1) return; // affect this one only
var ctx = chartInstance.chart.ctx;
// render the value of the chart above the bar
ctx.font = Chart.helpers.fontString(14, 'bold'
Chart.defaults.global.defaultFontFamily);
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
chartInstance.data.datasets.forEach(function (dataset) {
for (var i = 0; i < dataset.data.length; i++) {
var model = dataset._meta[Object.keys(dataset._met
[0]].data[i]._model;
ctx.fillText(dataset.data[i]
(Number.isInteger(dataset.data[i]) ? ".0" : "") + "%", ((model.x
model.base) / 2), model.y + (model.height / 3));
}
});
}
});
var ctxBarChart
$("#priceComplianceBarChart").get(0).getContext("2d");
var priceComplianceData = {
labels: [
"Bix Produce", "Capitol City", "Charlies Portland", "Cost
Fruit and Produce",
"Get Fresh Sales",
"Loffredo East", "Loffredo West", "Paragon", "Piazz
Produce"
],
datasets: [
{
label: "Price Compliant",
backgroundColor: "rgba(34,139,34,0.5)",
hoverBackgroundColor: "rgba(34,139,34,1)",
data: [99.0, 99.2, 99.4, 98.9, 99.1, 99.5, 99.6, 99.2, 99.7]
},
{
label: "Non-Compliant",
backgroundColor: "rgba(255, 0, 0, 0.5)",
hoverBackgroundColor: "rgba(255, 0, 0, 1)",
data: [1.0, 0.8, 0.6, 1.1, 0.9, 0.5, 0.4, 0.8, 0.3]
}
]
}
var priceComplianceOptions = {
scales: {
xAxes: [
{
stacked: true
}
],
yAxes: [
{
stacked: true
}
]
},
tooltips: {
enabled: false
}
};
var priceBarChart = new Chart(ctxBarChart,
{
type: 'horizontalBar',
data: priceComplianceData,
options: priceComplianceOptions
});
I am using Chart.js version 2.2.2
1st solution: Move to the left
In your plugin, set the context textAlign property to right if it is the second dataset:
chartInstance.data.datasets.forEach(function(dataset) {
for (var i = 0; i < dataset.data.length; i++) {
var model = dataset._meta[Object.keys(dataset._meta)[0]].data[i]._model;
// If it is the second dataset (red color) ..
if (dataset._meta[0].controller.index== 1) {
// .. align to the right
ctx.textAlign ="right";
// .. and write it at the right bound of the chart
ctx.fillText(parseFloat(dataset.data[i]).toFixed(2) + "%", (model.x - 2), (model.y + model.height / 3));
// This looks like it has been moved a bit to the left
}
// Else ..
else {
// .. write as usual
ctx.fillText(parseFloat(dataset.data[i]).toFixed(2) + "%", ((model.base + model.x) / 2), (model.y + model.height / 3));
}
}
});
Check the result on this jsFiddle.
2nd solution: Put the text in black
In your plugin, set the context fillStyle property to the color you want (#000 for instance):
afterDraw: function(chartInstance) {
var ctx = chartInstance.chart.ctx;
// render the value of the chart above the bar
ctx.font = Chart.helpers.fontString(14, 'bold',
Chart.defaults.global.defaultFontFamily);
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
// Here:
ctx.fillStyle = "#000";
chartInstance.data.datasets.forEach(function(dataset) {
// ...
});
});
Check the result on this jsFiddle.
3rd solution: Remove it, pure and simple
Add a condition in your plugin to check which dataset you are currently working on:
chartInstance.data.datasets.forEach(function(dataset) {
for (var i = 0; i < dataset.data.length; i++) {
// If it is the second dataset (red color), we break out of the loop
if (dataset._meta[0].controller.index == 1) break;
var model = dataset._meta[Object.keys(dataset._meta)[0]].data[i]._model;
ctx.fillText(parseFloat(dataset.data[i]).toFixed(2) + "%", ((model.base + model.x) / 2), (model.y + model.height / 3));
}
});
Check the result on this jsFiddle.

Chart Js change text label orientation on Ox axis

I am using chartJS library - http://www.chartjs.org/
If the text label is too big, chartJs displays text at 45 degrees
Please tell me how can I change the text rotation to 30 degrees for instance.
Please see the following :
var data = { labels:['Large Text Very Large','Text1','Text2'],
datasets:[ { label:'DS1',
fillColor:'rgba(228,218,86,0.5)',
strokeColor:'rgba(228,218,86,0.8)',
highlightFill:'rgba(228,218,86,0.75)',
highlightStroke: 'rgba(228,218,86,1)', data:[0,1,0]
}
]
}
var options = {
animation: false
};
//Get the context of the canvas element we want to select
var c = $('#myChart');
var ct = c.get(0).getContext('2d');
var ctx = document.getElementById("myChart").getContext("2d");
/*************************************************************************/
myNewChart = new Chart(ct).Bar(data, options);
Also on jsfiddle:
http://jsfiddle.net/cvL9fk2t/
Thanks !
If you are using chart.js 2.x, just set maxRotation: 90 and minRotation: 90 in ticks options. It works for me!
And if you want to all x-labels, you may want to set autoSkip: false.
The following is an example.
var myChart = new Chart(ctx, {
type: 'bar',
data: chartData,
options: {
scales: {
xAxes: [{
ticks: {
autoSkip: false,
maxRotation: 90,
minRotation: 90
}
}]
}
}
});
You would need to extend the scale class and override the calculateXLabelRotation method to use a user inputted rotation rather than trying to work it out it's self. If you do this you would then need to extend the bar or line chart and override the init method to make use of this scale class. (or you could make these changes directly to the scale, bar and line classes and then no need to override).
so first extend scale class and make it use a user defined option
var helpers = Chart.helpers;
Chart.MyScale = Chart.Scale.extend({
calculateXLabelRotation: function() {
//Get the width of each grid by calculating the difference
//between x offsets between 0 and 1.
this.ctx.font = this.font;
var firstWidth = this.ctx.measureText(this.xLabels[0]).width,
lastWidth = this.ctx.measureText(this.xLabels[this.xLabels.length - 1]).width,
firstRotated,
lastRotated;
this.xScalePaddingRight = lastWidth / 2 + 3;
this.xScalePaddingLeft = (firstWidth / 2 > this.yLabelWidth + 10) ? firstWidth / 2 : this.yLabelWidth + 10;
this.xLabelRotation = 0;
if (this.display) {
var originalLabelWidth = helpers.longestText(this.ctx, this.font, this.xLabels),
cosRotation,
firstRotatedWidth;
this.xLabelWidth = originalLabelWidth;
//Allow 3 pixels x2 padding either side for label readability
var xGridWidth = Math.floor(this.calculateX(1) - this.calculateX(0)) - 6;
//check if option is set if so use that
if (this.overrideRotation) {
// do the same as before but manualy set the rotation rather than looping
this.xLabelRotation = this.overrideRotation;
cosRotation = Math.cos(helpers.radians(this.xLabelRotation));
// We're right aligning the text now.
if (firstRotated + this.fontSize / 2 > this.yLabelWidth + 8) {
this.xScalePaddingLeft = firstRotated + this.fontSize / 2;
}
this.xScalePaddingRight = this.fontSize / 2;
this.xLabelWidth = cosRotation * originalLabelWidth;
} else {
//Max label rotate should be 90 - also act as a loop counter
while ((this.xLabelWidth > xGridWidth && this.xLabelRotation === 0) || (this.xLabelWidth > xGridWidth && this.xLabelRotation <= 90 && this.xLabelRotation > 0)) {
cosRotation = Math.cos(helpers.radians(this.xLabelRotation));
firstRotated = cosRotation * firstWidth;
lastRotated = cosRotation * lastWidth;
// We're right aligning the text now.
if (firstRotated + this.fontSize / 2 > this.yLabelWidth + 8) {
this.xScalePaddingLeft = firstRotated + this.fontSize / 2;
}
this.xScalePaddingRight = this.fontSize / 2;
this.xLabelRotation++;
this.xLabelWidth = cosRotation * originalLabelWidth;
}
}
if (this.xLabelRotation > 0) {
this.endPoint -= Math.sin(helpers.radians(this.xLabelRotation)) * originalLabelWidth + 3;
}
} else {
this.xLabelWidth = 0;
this.xScalePaddingRight = this.padding;
this.xScalePaddingLeft = this.padding;
}
},
});
then in the extend the bar class to create a new graph type and override the init method to use the new
Chart.types.Bar.extend({
name: "MyBar",
initialize: function(data) {
//Expose options as a scope variable here so we can access it in the ScaleClass
var options = this.options;
this.ScaleClass = Chart.MyScale.extend({
overrideRotation: options.overrideRotation,
offsetGridLines: true,
calculateBarX: function(datasetCount, datasetIndex, barIndex) {
//Reusable method for calculating the xPosition of a given bar based on datasetIndex & width of the bar
var xWidth = this.calculateBaseWidth(),
xAbsolute = this.calculateX(barIndex) - (xWidth / 2),
barWidth = this.calculateBarWidth(datasetCount);
return xAbsolute + (barWidth * datasetIndex) + (datasetIndex * options.barDatasetSpacing) + barWidth / 2;
},
calculateBaseWidth: function() {
return (this.calculateX(1) - this.calculateX(0)) - (2 * options.barValueSpacing);
},
calculateBarWidth: function(datasetCount) {
//The padding between datasets is to the right of each bar, providing that there are more than 1 dataset
var baseWidth = this.calculateBaseWidth() - ((datasetCount - 1) * options.barDatasetSpacing);
return (baseWidth / datasetCount);
}
});
this.datasets = [];
//Set up tooltip events on the chart
if (this.options.showTooltips) {
helpers.bindEvents(this, this.options.tooltipEvents, function(evt) {
var activeBars = (evt.type !== 'mouseout') ? this.getBarsAtEvent(evt) : [];
this.eachBars(function(bar) {
bar.restore(['fillColor', 'strokeColor']);
});
helpers.each(activeBars, function(activeBar) {
activeBar.fillColor = activeBar.highlightFill;
activeBar.strokeColor = activeBar.highlightStroke;
});
this.showTooltip(activeBars);
});
}
//Declare the extension of the default point, to cater for the options passed in to the constructor
this.BarClass = Chart.Rectangle.extend({
strokeWidth: this.options.barStrokeWidth,
showStroke: this.options.barShowStroke,
ctx: this.chart.ctx
});
//Iterate through each of the datasets, and build this into a property of the chart
helpers.each(data.datasets, function(dataset, datasetIndex) {
var datasetObject = {
label: dataset.label || null,
fillColor: dataset.fillColor,
strokeColor: dataset.strokeColor,
bars: []
};
this.datasets.push(datasetObject);
helpers.each(dataset.data, function(dataPoint, index) {
//Add a new point for each piece of data, passing any required data to draw.
datasetObject.bars.push(new this.BarClass({
value: dataPoint,
label: data.labels[index],
datasetLabel: dataset.label,
strokeColor: dataset.strokeColor,
fillColor: dataset.fillColor,
highlightFill: dataset.highlightFill || dataset.fillColor,
highlightStroke: dataset.highlightStroke || dataset.strokeColor
}));
}, this);
}, this);
this.buildScale(data.labels);
this.BarClass.prototype.base = this.scale.endPoint;
this.eachBars(function(bar, index, datasetIndex) {
helpers.extend(bar, {
width: this.scale.calculateBarWidth(this.datasets.length),
x: this.scale.calculateBarX(this.datasets.length, datasetIndex, index),
y: this.scale.endPoint
});
bar.save();
}, this);
this.render();
},
});
now you can declare a chart using this chart type and pass in the option overrideRotation
here is a fiddle example http://jsfiddle.net/leighking2/ye3usuhu/
and a snippet
var helpers = Chart.helpers;
Chart.MyScale = Chart.Scale.extend({
calculateXLabelRotation: function() {
//Get the width of each grid by calculating the difference
//between x offsets between 0 and 1.
this.ctx.font = this.font;
var firstWidth = this.ctx.measureText(this.xLabels[0]).width,
lastWidth = this.ctx.measureText(this.xLabels[this.xLabels.length - 1]).width,
firstRotated,
lastRotated;
this.xScalePaddingRight = lastWidth / 2 + 3;
this.xScalePaddingLeft = (firstWidth / 2 > this.yLabelWidth + 10) ? firstWidth / 2 : this.yLabelWidth + 10;
this.xLabelRotation = 0;
if (this.display) {
var originalLabelWidth = helpers.longestText(this.ctx, this.font, this.xLabels),
cosRotation,
firstRotatedWidth;
this.xLabelWidth = originalLabelWidth;
//Allow 3 pixels x2 padding either side for label readability
var xGridWidth = Math.floor(this.calculateX(1) - this.calculateX(0)) - 6;
if (this.overrideRotation) {
this.xLabelRotation = this.overrideRotation;
cosRotation = Math.cos(helpers.radians(this.xLabelRotation));
// We're right aligning the text now.
if (firstRotated + this.fontSize / 2 > this.yLabelWidth + 8) {
this.xScalePaddingLeft = firstRotated + this.fontSize / 2;
}
this.xScalePaddingRight = this.fontSize / 2;
this.xLabelWidth = cosRotation * originalLabelWidth;
} else {
//Max label rotate should be 90 - also act as a loop counter
while ((this.xLabelWidth > xGridWidth && this.xLabelRotation === 0) || (this.xLabelWidth > xGridWidth && this.xLabelRotation <= 90 && this.xLabelRotation > 0)) {
cosRotation = Math.cos(helpers.radians(this.xLabelRotation));
firstRotated = cosRotation * firstWidth;
lastRotated = cosRotation * lastWidth;
// We're right aligning the text now.
if (firstRotated + this.fontSize / 2 > this.yLabelWidth + 8) {
this.xScalePaddingLeft = firstRotated + this.fontSize / 2;
}
this.xScalePaddingRight = this.fontSize / 2;
this.xLabelRotation++;
this.xLabelWidth = cosRotation * originalLabelWidth;
}
}
if (this.xLabelRotation > 0) {
this.endPoint -= Math.sin(helpers.radians(this.xLabelRotation)) * originalLabelWidth + 3;
}
} else {
this.xLabelWidth = 0;
this.xScalePaddingRight = this.padding;
this.xScalePaddingLeft = this.padding;
}
},
});
Chart.types.Bar.extend({
name: "MyBar",
initialize: function(data) {
//Expose options as a scope variable here so we can access it in the ScaleClass
var options = this.options;
this.ScaleClass = Chart.MyScale.extend({
overrideRotation: options.overrideRotation,
offsetGridLines: true,
calculateBarX: function(datasetCount, datasetIndex, barIndex) {
//Reusable method for calculating the xPosition of a given bar based on datasetIndex & width of the bar
var xWidth = this.calculateBaseWidth(),
xAbsolute = this.calculateX(barIndex) - (xWidth / 2),
barWidth = this.calculateBarWidth(datasetCount);
return xAbsolute + (barWidth * datasetIndex) + (datasetIndex * options.barDatasetSpacing) + barWidth / 2;
},
calculateBaseWidth: function() {
return (this.calculateX(1) - this.calculateX(0)) - (2 * options.barValueSpacing);
},
calculateBarWidth: function(datasetCount) {
//The padding between datasets is to the right of each bar, providing that there are more than 1 dataset
var baseWidth = this.calculateBaseWidth() - ((datasetCount - 1) * options.barDatasetSpacing);
return (baseWidth / datasetCount);
}
});
this.datasets = [];
//Set up tooltip events on the chart
if (this.options.showTooltips) {
helpers.bindEvents(this, this.options.tooltipEvents, function(evt) {
var activeBars = (evt.type !== 'mouseout') ? this.getBarsAtEvent(evt) : [];
this.eachBars(function(bar) {
bar.restore(['fillColor', 'strokeColor']);
});
helpers.each(activeBars, function(activeBar) {
activeBar.fillColor = activeBar.highlightFill;
activeBar.strokeColor = activeBar.highlightStroke;
});
this.showTooltip(activeBars);
});
}
//Declare the extension of the default point, to cater for the options passed in to the constructor
this.BarClass = Chart.Rectangle.extend({
strokeWidth: this.options.barStrokeWidth,
showStroke: this.options.barShowStroke,
ctx: this.chart.ctx
});
//Iterate through each of the datasets, and build this into a property of the chart
helpers.each(data.datasets, function(dataset, datasetIndex) {
var datasetObject = {
label: dataset.label || null,
fillColor: dataset.fillColor,
strokeColor: dataset.strokeColor,
bars: []
};
this.datasets.push(datasetObject);
helpers.each(dataset.data, function(dataPoint, index) {
//Add a new point for each piece of data, passing any required data to draw.
datasetObject.bars.push(new this.BarClass({
value: dataPoint,
label: data.labels[index],
datasetLabel: dataset.label,
strokeColor: dataset.strokeColor,
fillColor: dataset.fillColor,
highlightFill: dataset.highlightFill || dataset.fillColor,
highlightStroke: dataset.highlightStroke || dataset.strokeColor
}));
}, this);
}, this);
this.buildScale(data.labels);
this.BarClass.prototype.base = this.scale.endPoint;
this.eachBars(function(bar, index, datasetIndex) {
helpers.extend(bar, {
width: this.scale.calculateBarWidth(this.datasets.length),
x: this.scale.calculateBarX(this.datasets.length, datasetIndex, index),
y: this.scale.endPoint
});
bar.save();
}, this);
this.render();
},
});
var randomScalingFactor = function() {
return Math.round(Math.random() * 100)
};
var barChartData = {
labels: ["January", "February", "March", "April", "May", "June", "July"],
datasets: [{
fillColor: "rgba(220,220,220,0.5)",
strokeColor: "rgba(220,220,220,0.8)",
highlightFill: "rgba(220,220,220,0.75)",
highlightStroke: "rgba(220,220,220,1)",
data: [randomScalingFactor(), randomScalingFactor(), randomScalingFactor(), randomScalingFactor(), randomScalingFactor(), randomScalingFactor(), randomScalingFactor()]
}, {
fillColor: "rgba(151,187,205,0.5)",
strokeColor: "rgba(151,187,205,0.8)",
highlightFill: "rgba(151,187,205,0.75)",
highlightStroke: "rgba(151,187,205,1)",
data: [randomScalingFactor(), randomScalingFactor(), randomScalingFactor(), randomScalingFactor(), randomScalingFactor(), randomScalingFactor(), randomScalingFactor()]
}, {
fillColor: "rgba(15,18,20,0.5)",
strokeColor: "rgba(15,18,20,0.8)",
highlightFill: "rgba(15,18,20,0.75)",
highlightStroke: "rgba(15,18,20,1)",
data: [randomScalingFactor(), randomScalingFactor(), randomScalingFactor(), randomScalingFactor(), randomScalingFactor(), randomScalingFactor(), randomScalingFactor()]
}]
}
window.onload = function() {
var ctx = document.getElementById("canvas").getContext("2d");
window.myBar = new Chart(ctx).MyBar(barChartData, {
overrideRotation: 30
});
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/1.0.1/Chart.js"></script>
<canvas id="canvas" height="150" width="300"></canvas>
Note that for chart.js 3.x the way of specifying the axis scale options has changed: see https://www.chartjs.org/docs/master/getting-started/v3-migration.html#scales
Consequently in the above answer for 2.x you need to remove the square brackets like this:
var myChart = new Chart(ctx, {
type: 'bar',
data: chartData,
options: {
scales: {
xAxes: {
ticks: {
autoSkip: false,
maxRotation: 90,
minRotation: 90
}
}
}
}
});

Categories

Resources