radial-progress-chart
Version:
A customizable Radial Progress Chart written on the top of D3.js.
472 lines (402 loc) • 13.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 = [];
function innerRadius(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;
}
function outerRadius(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.svg.arc()
.startAngle(0)
.endAngle(function (item) {
return item.percentage / 100 * τ;
})
.innerRadius(innerRadius)
.outerRadius(outerRadius)
.cornerRadius(function (d) {
// Workaround for d3 bug https://github.com/mbostock/d3/issues/2249
// Reduce corner radius when corners are close each other
var m = d.percentage >= 90 ? (100 - d.percentage) * 0.1 : 1;
return (self.options.stroke.width / 2) * m;
});
var background = d3.svg.arc()
.startAngle(0)
.endAngle(τ)
.innerRadius(innerRadius)
.outerRadius(outerRadius);
// create svg
self.svg = d3.select(query).append("svg")
.attr("preserveAspectRatio","xMinYMin meet")
.attr("viewBox", dim)
.append("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
defs = self.svg.append("svg:defs");
var dropshadowId = "dropshadow-" + Math.random();
var filter = defs.append("filter").attr("id", dropshadowId);
if(self.options.shadow.width > 0) {
filter.append("feGaussianBlur")
.attr("in", "SourceAlpha")
.attr("stdDeviation", self.options.shadow.width)
.attr("result", "blur");
filter.append("feOffset")
.attr("in", "blur")
.attr("dx", 1)
.attr("dy", 1)
.attr("result", "offsetBlur");
}
var feMerge = filter.append("feMerge");
feMerge.append("feMergeNode").attr("in", "offsetBlur");
feMerge.append("feMergeNode").attr("in", "SourceGraphic");
// 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").attr("filter", "url(#" + dropshadowId +")");
self.field.append("path").attr("class", "bg")
.style("fill", function (item) {
return item.color.background;
})
.style("opacity", 0.2)
.attr("d", background);
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;
});
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("elastic")
.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 + ')';
}
});
};
/**
* 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 || 2
},
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,
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.ns.prefix.svg, gradientType))
.attr(options[gradientType])
.attr('id', id);
options.stops.forEach(function (stopAttrs) {
gradient.append("svg:stop").attr(stopAttrs);
});
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;