@bunetz/radial-progress-chart-with-tooltip
Version:
Radial Progress Chart extension with tooltip
547 lines (473 loc) • 16.9 kB
JavaScript
;
var d3;
// RadialProgressChart object
function RadialProgressChart(query, options) {
// verify d3 is loaded
d3 = (typeof window !== 'undefined' && window.d3) ? window.d3 : typeof require !== 'undefined' ? require("d3") : undefined;
if(!d3) throw new Error('d3 object is missing. D3.js library has to be loaded before.');
var self = this;
self.options = RadialProgressChart.normalizeOptions(options);
// internal variables
var series = self.options.series
, width = 15 + ((self.options.diameter / 2) + (self.options.stroke.width * self.options.series.length) + (self.options.stroke.gap * self.options.series.length - 1)) * 2
, height = width
, dim = "0 0 " + height + " " + width
, τ = 2 * Math.PI
, inner = []
, outer = [];
self.innerRadius = function(item) {
var radius = inner[item.index];
if (radius) return radius;
// first ring based on diameter and the rest based on the previous outer radius plus gap
radius = item.index === 0 ? self.options.diameter / 2 : outer[item.index - 1] + self.options.stroke.gap;
inner[item.index] = radius;
return radius;
}
self.outerRadius = function(item) {
var radius = outer[item.index];
if (radius) return radius;
// based on the previous inner radius + stroke width
radius = inner[item.index] + self.options.stroke.width;
outer[item.index] = radius;
return radius;
}
self.progress = d3.arc()
.startAngle(0)
.endAngle(function (item) {
return item.percentage / 100 * τ;
})
.innerRadius(self.innerRadius)
.outerRadius(self.outerRadius)
.cornerRadius(function (d) {
return (self.options.stroke.width / 2);
});
var background = d3.arc()
.startAngle(0)
.endAngle(τ)
.innerRadius(self.innerRadius)
.outerRadius(self.outerRadius);
// create svg
self.svg = d3.select(query).append("svg:svg")
.attr("preserveAspectRatio","xMinYMin meet")
.attr("viewBox", dim)
.append("svg:g")
.attr("transform", "translate(" + width / 2 + "," + height / 2 + ")");
// add gradients defs
var defs = self.svg.append("svg:defs");
series.forEach(function (item) {
if (item.color.linearGradient || item.color.radialGradient) {
var gradient = RadialProgressChart.Gradient.toSVGElement('gradient' + item.index, item.color);
defs.node().appendChild(gradient);
}
});
// add shadows defs
var filter = defs.append("filter").attr("id", "dropshadow");
filter.append("feDropShadow")
.attr("stdDeviation", "1 0")
.attr("dx", 3)
.attr("dy", 0);
// add inner text
if (self.options.center) {
self.svg.append("text")
.attr('class', 'rbc-center-text')
.attr("text-anchor", "middle")
.attr('x', self.options.center.x + 'px')
.attr('y', self.options.center.y + 'px')
.selectAll('tspan')
.data(self.options.center.content).enter()
.append('tspan')
.attr("dominant-baseline", function () {
// Single lines can easily centered in the middle using dominant-baseline, multiline need to use y
if (self.options.center.content.length === 1) {
return 'central';
}
})
.attr('class', function (d, i) {
return 'rbc-center-text-line' + i;
})
.attr('x', 0)
.attr('dy', function (d, i) {
if (i > 0) {
return '1.1em';
}
})
.each(function (d) {
if (typeof d === 'function') {
this.callback = d;
}
})
.text(function (d) {
if (typeof d === 'string') {
return d;
}
return '';
});
}
// add ring structure
self.field = self.svg.selectAll("g")
.data(series)
.enter().append("g");
self.field.append("path")
.attr("class", "progress");
self.field.append("path").attr("class", "bg")
.style("fill", function (item) {
return item.color.background;
})
.style("opacity", 0.2)
.attr("d", background);
//create line end
self.field.append("circle")
.attr("cx", 0)
.attr("cy", function(item) {
return -((self.outerRadius(item) + self.innerRadius(item))/2)
})
.attr("r", function(item) {
return (self.outerRadius(item) - self.innerRadius(item))/2;
})
.attr("transform", function (item) {
var circleRadius = ((self.outerRadius(item) - self.innerRadius(item))/2)
var radius = ((self.outerRadius(item) + self.innerRadius(item))/2)
var offset = Math.asin(circleRadius/radius)/(Math.PI*2)*360
var angle = item.fromPercentage*360/100 - offset
while (angle >= 360)
angle -= 360
if (isNaN(angle)) angle = 0
return "rotate(" + angle + ')'
})
.style("opacity", 0);
self.field.append("text")
.classed('rbc-label rbc-label-start', true)
.attr("dominant-baseline", "central")
.attr("x", "10")
.attr("y", function (item) {
return -(
self.options.diameter / 2 +
item.index * (self.options.stroke.gap + self.options.stroke.width) +
self.options.stroke.width / 2
);
})
.text(function (item) {
return item.labelStart;
});
//create tooltip
self.tooltip = d3.select(query)
.append("div")
.attr("class", "chart-tooltip")
.style("position", "fixed")
.style("z-index", "10")
.style("visibility", "hidden");
self.tooltip.append("p");
self.update();
}
/**
* Update data to be visualized in the chart.
*
* @param {Object|Array} data Optional data you'd like to set for the chart before it will update. If not specified the update method will use the data that is already configured with the chart.
* @example update([70, 10, 45])
* @example update({series: [{value: 70}, 10, 45]})
*
*/
RadialProgressChart.prototype.update = function (data) {
var self = this;
// parse new data
if (data) {
if (typeof data === 'number') {
data = [data];
}
var series;
if (Array.isArray(data)) {
series = data;
} else if (typeof data === 'object') {
series = data.series || [];
}
for (var i = 0; i < series.length; i++) {
this.options.series[i].previousValue = this.options.series[i].value;
var item = series[i];
if (typeof item === 'number') {
this.options.series[i].value = item;
} else if (typeof item === 'object') {
this.options.series[i].value = item.value;
}
}
}
// calculate from percentage and new percentage for the progress animation
self.options.series.forEach(function (item) {
item.fromPercentage = item.percentage ? item.percentage : 5;
item.percentage = (item.value - self.options.min) * 100 / (self.options.max - self.options.min);
});
var center = self.svg.select("text.rbc-center-text");
// progress
self.field.select("path.progress")
.interrupt()
.transition()
.duration(self.options.animation.duration)
.delay(function (d, i) {
// delay between each item
return i * self.options.animation.delay;
})
.ease(d3.easeElastic)
.attrTween("d", function (item) {
var interpolator = d3.interpolateNumber(item.fromPercentage, item.percentage);
return function (t) {
item.percentage = interpolator(t);
return self.progress(item);
};
})
.tween("center", function (item) {
// Execute callbacks on each line
if (self.options.center) {
var interpolate = self.options.round ? d3.interpolateRound : d3.interpolateNumber;
var interpolator = interpolate(item.previousValue || 0, item.value);
return function (t) {
center
.selectAll('tspan')
.each(function () {
if (this.callback) {
d3.select(this).text(this.callback(interpolator(t), item.index, item));
}
});
};
}
})
.tween("interpolate-color", function (item) {
if (item.color.interpolate && item.color.interpolate.length == 2) {
var colorInterpolator = d3.interpolateHsl(item.color.interpolate[0], item.color.interpolate[1]);
return function (t) {
var color = colorInterpolator(item.percentage / 100);
d3.select(this).style('fill', color);
d3.select(this.parentNode).select('path.bg').style('fill', color);
};
}
})
.style("fill", function (item) {
if (item.color.solid) {
return item.color.solid;
}
if (item.color.linearGradient || item.color.radialGradient) {
return "url(#gradient" + item.index + ')';
}
});
self.field.select("circle")
.interrupt()
.transition()
.duration(self.options.animation.duration)
.delay(function (d, i) {
// delay between each item
return i * self.options.animation.delay;
})
.ease(d3.easeElastic)
.on('start',function(item) {
if (item.percentage >= 5)
d3.select(this).style("opacity", 1)
})
.attrTween("transform", function (item) {
var interpolator = d3.interpolateNumber(item.fromPercentage, item.percentage);
var circleRadius = ((self.outerRadius(item) - self.innerRadius(item))/2)
var radius = ((self.outerRadius(item) + self.innerRadius(item))/2)
var offset = Math.asin(circleRadius/radius)/(Math.PI*2)*360
return function (t) {
var angle = interpolator(t)*360/100 - offset
while (angle >= 360)
angle -= 360
if (isNaN(angle)) angle = 0
return "rotate(" + angle + ')'
};
})
.style("fill", function (item) {
if (item.color.solid) {
return item.color.solid;
}
if (item.color.linearGradient || item.color.radialGradient) {
return "url(#gradient" + item.index + ')';
}
})
.style("filter", function (item) {
if (item.percentage >= 100)
return "url(#dropshadow)"
else return null
});
d3.selectAll("path")
.on("mouseover", function(item){
self.tooltip.select('div > p').html(item.title + '<br/>' + item.value + '%')
return self.tooltip.style("visibility", "visible");
})
.on("mousemove", function(){
let width = self.tooltip.node().getBoundingClientRect().width
let height = self.tooltip.node().getBoundingClientRect().height
return self.tooltip.style("top", (event.y-height-10)+"px").style("left",(event.x-(width*0.2))+"px");
})
.on("mouseout", function(){
return self.tooltip.style("visibility", "hidden");
});
};
/**
* Remove svg and clean some references
*/
RadialProgressChart.prototype.destroy = function () {
this.svg.remove();
delete this.svg;
};
/**
* Detach and normalize user's options input.
*/
RadialProgressChart.normalizeOptions = function (options) {
if (!options || typeof options !== 'object') {
options = {};
}
var _options = {
diameter: options.diameter || 100,
stroke: {
width: options.stroke && options.stroke.width || 40,
gap: (!options.stroke || options.stroke.gap === undefined) ? 2 : options.stroke.gap
},
shadow: {
width: (!options.shadow || options.shadow.width === null) ? 4 : options.shadow.width
},
animation: {
duration: options.animation && options.animation.duration || 1750,
delay: options.animation && options.animation.delay || 200
},
min: options.min || 0,
max: options.max || 100,
round: options.round !== undefined ? !!options.round : true,
series: options.series || [],
center: RadialProgressChart.normalizeCenter(options.center)
};
var defaultColorsIterator = new RadialProgressChart.ColorsIterator();
for (var i = 0, length = _options.series.length; i < length; i++) {
var item = options.series[i];
// convert number to object
if (typeof item === 'number') {
item = {value: item};
}
_options.series[i] = {
index: i,
value: item.value,
labelStart: item.labelStart,
title: item.title,
color: RadialProgressChart.normalizeColor(item.color, defaultColorsIterator)
};
}
return _options;
};
/**
* Normalize different notations of color property
*
* @param {String|Array|Object} color
* @example '#fe08b5'
* @example { solid: '#fe08b5', background: '#000000' }
* @example ['#000000', '#ff0000']
* @example {
linearGradient: { x1: '0%', y1: '100%', x2: '50%', y2: '0%'},
stops: [
{offset: '0%', 'stop-color': '#fe08b5', 'stop-opacity': 1},
{offset: '100%', 'stop-color': '#ff1410', 'stop-opacity': 1}
]
}
* @example {
radialGradient: {cx: '60', cy: '60', r: '50'},
stops: [
{offset: '0%', 'stop-color': '#fe08b5', 'stop-opacity': 1},
{offset: '100%', 'stop-color': '#ff1410', 'stop-opacity': 1}
]
}
*
*/
RadialProgressChart.normalizeColor = function (color, defaultColorsIterator) {
if (!color) {
color = {solid: defaultColorsIterator.next()};
} else if (typeof color === 'string') {
color = {solid: color};
} else if (Array.isArray(color)) {
color = {interpolate: color};
} else if (typeof color === 'object') {
if (!color.solid && !color.interpolate && !color.linearGradient && !color.radialGradient) {
color.solid = defaultColorsIterator.next();
}
}
// Validate interpolate syntax
if (color.interpolate) {
if (color.interpolate.length !== 2) {
throw new Error('interpolate array should contain two colors');
}
}
// Validate gradient syntax
if (color.linearGradient || color.radialGradient) {
if (!color.stops || !Array.isArray(color.stops) || color.stops.length !== 2) {
throw new Error('gradient syntax is malformed');
}
}
// Set background when is not provided
if (!color.background) {
if (color.solid) {
color.background = color.solid;
} else if (color.interpolate) {
color.background = color.interpolate[0];
} else if (color.linearGradient || color.radialGradient) {
color.background = color.stops[0]['stop-color'];
}
}
return color;
};
/**
* Normalize different notations of center property
*
* @param {String|Array|Function|Object} center
* @example 'foo bar'
* @example { content: 'foo bar', x: 10, y: 4 }
* @example function(value, index, item) {}
* @example ['foo bar', function(value, index, item) {}]
*/
RadialProgressChart.normalizeCenter = function (center) {
if (!center) return null;
// Convert to object notation
if (center.constructor !== Object) {
center = {content: center};
}
// Defaults
center.content = center.content || [];
center.x = center.x || 0;
center.y = center.y || 0;
// Convert content to array notation
if (!Array.isArray(center.content)) {
center.content = [center.content];
}
return center;
};
// Linear or Radial Gradient internal object
RadialProgressChart.Gradient = (function () {
function Gradient() {
}
Gradient.toSVGElement = function (id, options) {
var gradientType = options.linearGradient ? 'linearGradient' : 'radialGradient';
var gradient = d3.select(document.createElementNS(d3.namespaces.svg, gradientType)).attr("id", id);
Object.entries(options[gradientType]).forEach(function(option) {
gradient.attr(option[0], option[1]);
})
Object.entries(options.stops).forEach(function (stopAttrs) {
var stop = gradient.append("svg:stop");
Object.entries(stopAttrs[1]).forEach(function (stopAttr) {
stop.attr(stopAttr[0], stopAttr[1]);
})
});
this.background = options.stops[0]['stop-color'];
return gradient.node();
};
return Gradient;
})();
// Default colors iterator
RadialProgressChart.ColorsIterator = (function () {
ColorsIterator.DEFAULT_COLORS = ["#1ad5de", "#a0ff03", "#e90b3a", '#ff9500', '#007aff', '#ffcc00', '#5856d6', '#8e8e93'];
function ColorsIterator() {
this.index = 0;
}
ColorsIterator.prototype.next = function () {
if (this.index === ColorsIterator.DEFAULT_COLORS.length) {
this.index = 0;
}
return ColorsIterator.DEFAULT_COLORS[this.index++];
};
return ColorsIterator;
})();
// Export RadialProgressChart object
if (typeof module !== "undefined")module.exports = RadialProgressChart;