mirror of
https://github.com/netdata/netdata.git
synced 2025-05-03 08:50:02 +00:00

* Remove code for bundling the dashoard on install. * Bundle the dashboard code directly into the agent repo. This diffstat looks huge, but it’s actually relatively simple. The only _actual_ changes are in the Makefiles, `configure.ac`, and the addition of `generate_dashboard_makefile.py`. Everything else consists of removing files that are included in the dashboard tarball, and extracting the contents of the tarball into `web/gui/dashboard`. * CI cleanup. * Automate bundling of the dashboard code. This replaces the makefile generator script with one that handles bundling of the dashboard code in it’s entirety, and updates the GHA workflow used for generating dashboard PRs to use that instead of the existing shell commands. It also removes the packaging/dashboard.* files, as they are no longer needed.
2124 lines
78 KiB
JavaScript
2124 lines
78 KiB
JavaScript
/*!
|
|
* d3pie
|
|
* @author Ben Keen
|
|
* @version 0.1.9
|
|
* @date June 17th, 2015
|
|
* @repo http://github.com/benkeen/d3pie
|
|
* SPDX-License-Identifier: MIT
|
|
*/
|
|
|
|
// UMD pattern from https://github.com/umdjs/umd/blob/master/returnExports.js
|
|
(function(root, factory) {
|
|
if (typeof define === 'function' && define.amd) {
|
|
// AMD. Register as an anonymous module
|
|
define([], factory);
|
|
} else if (typeof exports === 'object') {
|
|
// Node. Does not work with strict CommonJS, but only CommonJS-like environments that support module.exports,
|
|
// like Node
|
|
module.exports = factory();
|
|
} else {
|
|
// browser globals (root is window)
|
|
root.d3pie = factory(root);
|
|
}
|
|
}(this, function() {
|
|
|
|
var _scriptName = "d3pie";
|
|
var _version = "0.2.1";
|
|
|
|
// used to uniquely generate IDs and classes, ensuring no conflict between multiple pies on the same page
|
|
var _uniqueIDCounter = 0;
|
|
|
|
|
|
// this section includes all helper libs on the d3pie object. They're populated via grunt-template. Note: to keep
|
|
// the syntax highlighting from getting all messed up, I commented out each line. That REQUIRES each of the files
|
|
// to have an empty first line. Crumby, yes, but acceptable.
|
|
//// --------- _default-settings.js -----------/**
|
|
/**
|
|
* Contains the out-the-box settings for the script. Any of these settings that aren't explicitly overridden for the
|
|
* d3pie instance will inherit from these. This is also included on the main website for use in the generation script.
|
|
*/
|
|
var defaultSettings = {
|
|
header: {
|
|
title: {
|
|
text: "",
|
|
color: "#333333",
|
|
fontSize: 18,
|
|
fontWeight: "bold",
|
|
font: "arial"
|
|
},
|
|
subtitle: {
|
|
text: "",
|
|
color: "#666666",
|
|
fontSize: 14,
|
|
fontWeight: "bold",
|
|
font: "arial"
|
|
},
|
|
location: "top-center",
|
|
titleSubtitlePadding: 8
|
|
},
|
|
footer: {
|
|
text: "",
|
|
color: "#666666",
|
|
fontSize: 14,
|
|
fontWeight: "bold",
|
|
font: "arial",
|
|
location: "left"
|
|
},
|
|
size: {
|
|
canvasHeight: 500,
|
|
canvasWidth: 500,
|
|
pieInnerRadius: "0%",
|
|
pieOuterRadius: null
|
|
},
|
|
data: {
|
|
sortOrder: "none",
|
|
ignoreSmallSegments: {
|
|
enabled: false,
|
|
valueType: "percentage",
|
|
value: null
|
|
},
|
|
smallSegmentGrouping: {
|
|
enabled: false,
|
|
value: 1,
|
|
valueType: "percentage",
|
|
label: "Other",
|
|
color: "#cccccc"
|
|
},
|
|
content: []
|
|
},
|
|
labels: {
|
|
outer: {
|
|
format: "label",
|
|
hideWhenLessThanPercentage: null,
|
|
pieDistance: 30
|
|
},
|
|
inner: {
|
|
format: "percentage",
|
|
hideWhenLessThanPercentage: null
|
|
},
|
|
mainLabel: {
|
|
color: "#333333",
|
|
font: "arial",
|
|
fontWeight: "normal",
|
|
fontSize: 10
|
|
},
|
|
percentage: {
|
|
color: "#dddddd",
|
|
font: "arial",
|
|
fontWeight: "bold",
|
|
fontSize: 10,
|
|
decimalPlaces: 0
|
|
},
|
|
value: {
|
|
color: "#cccc44",
|
|
fontWeight: "bold",
|
|
font: "arial",
|
|
fontSize: 10
|
|
},
|
|
lines: {
|
|
enabled: true,
|
|
style: "curved",
|
|
color: "segment"
|
|
},
|
|
truncation: {
|
|
enabled: false,
|
|
truncateLength: 30
|
|
},
|
|
formatter: null
|
|
},
|
|
effects: {
|
|
load: {
|
|
effect: "none", // "default", commented in the code
|
|
speed: 1000
|
|
},
|
|
pullOutSegmentOnClick: {
|
|
effect: "none", // "bounce", commented in the code
|
|
speed: 300,
|
|
size: 10
|
|
},
|
|
highlightSegmentOnMouseover: false,
|
|
highlightLuminosity: -0.2
|
|
},
|
|
tooltips: {
|
|
enabled: false,
|
|
type: "placeholder", // caption|placeholder
|
|
string: "",
|
|
placeholderParser: null,
|
|
styles: {
|
|
fadeInSpeed: 250,
|
|
backgroundColor: "#000000",
|
|
backgroundOpacity: 0.5,
|
|
color: "#efefef",
|
|
borderRadius: 2,
|
|
font: "arial",
|
|
fontWeight: "bold",
|
|
fontSize: 10,
|
|
padding: 4
|
|
}
|
|
},
|
|
misc: {
|
|
colors: {
|
|
background: null,
|
|
segments: [
|
|
"#2484c1", "#65a620", "#7b6888", "#a05d56", "#961a1a", "#d8d23a", "#e98125", "#d0743c", "#635222", "#6ada6a",
|
|
"#0c6197", "#7d9058", "#207f33", "#44b9b0", "#bca44a", "#e4a14b", "#a3acb2", "#8cc3e9", "#69a6f9", "#5b388f",
|
|
"#546e91", "#8bde95", "#d2ab58", "#273c71", "#98bf6e", "#4daa4b", "#98abc5", "#cc1010", "#31383b", "#006391",
|
|
"#c2643f", "#b0a474", "#a5a39c", "#a9c2bc", "#22af8c", "#7fcecf", "#987ac6", "#3d3b87", "#b77b1c", "#c9c2b6",
|
|
"#807ece", "#8db27c", "#be66a2", "#9ed3c6", "#00644b", "#005064", "#77979f", "#77e079", "#9c73ab", "#1f79a7"
|
|
],
|
|
segmentStroke: "#ffffff"
|
|
},
|
|
gradient: {
|
|
enabled: false,
|
|
percentage: 95,
|
|
color: "#000000"
|
|
},
|
|
canvasPadding: {
|
|
top: 5,
|
|
right: 5,
|
|
bottom: 5,
|
|
left: 5
|
|
},
|
|
pieCenterOffset: {
|
|
x: 0,
|
|
y: 0
|
|
},
|
|
cssPrefix: null
|
|
},
|
|
callbacks: {
|
|
onload: null,
|
|
onMouseoverSegment: null,
|
|
onMouseoutSegment: null,
|
|
onClickSegment: null
|
|
}
|
|
};
|
|
|
|
//// --------- validate.js -----------
|
|
var validate = {
|
|
|
|
// called whenever a new pie chart is created
|
|
initialCheck: function(pie) {
|
|
var cssPrefix = pie.cssPrefix;
|
|
var element = pie.element;
|
|
var options = pie.options;
|
|
|
|
// confirm d3 is available [check minimum version]
|
|
if (!window.d3 || !window.d3.hasOwnProperty("version")) {
|
|
console.error("d3pie error: d3 is not available");
|
|
return false;
|
|
}
|
|
|
|
// confirm element is either a DOM element or a valid string for a DOM element
|
|
if (!(element instanceof HTMLElement || element instanceof SVGElement)) {
|
|
console.error("d3pie error: the first d3pie() param must be a valid DOM element (not jQuery) or a ID string.");
|
|
return false;
|
|
}
|
|
|
|
// confirm the CSS prefix is valid. It has to start with a-Z and contain nothing but a-Z0-9_-
|
|
if (!(/[a-zA-Z][a-zA-Z0-9_-]*$/.test(cssPrefix))) {
|
|
console.error("d3pie error: invalid options.misc.cssPrefix");
|
|
return false;
|
|
}
|
|
|
|
// confirm some data has been supplied
|
|
if (!helpers.isArray(options.data.content)) {
|
|
console.error("d3pie error: invalid config structure: missing data.content property.");
|
|
return false;
|
|
}
|
|
if (options.data.content.length === 0) {
|
|
console.error("d3pie error: no data supplied.");
|
|
return false;
|
|
}
|
|
|
|
// clear out any invalid data. Each data row needs a valid positive number and a label
|
|
var data = [];
|
|
for (var i=0; i<options.data.content.length; i++) {
|
|
if (typeof options.data.content[i].value !== "number" || isNaN(options.data.content[i].value)) {
|
|
console.log("not valid: ", options.data.content[i]);
|
|
continue;
|
|
}
|
|
if (options.data.content[i].value <= 0) {
|
|
console.log("not valid - should have positive value: ", options.data.content[i]);
|
|
continue;
|
|
}
|
|
data.push(options.data.content[i]);
|
|
}
|
|
pie.options.data.content = data;
|
|
|
|
// labels.outer.hideWhenLessThanPercentage - 1-100
|
|
// labels.inner.hideWhenLessThanPercentage - 1-100
|
|
|
|
return true;
|
|
}
|
|
};
|
|
|
|
//// --------- helpers.js -----------
|
|
var helpers = {
|
|
|
|
// creates the SVG element
|
|
addSVGSpace: function(pie) {
|
|
var element = pie.element;
|
|
var canvasWidth = pie.options.size.canvasWidth;
|
|
var canvasHeight = pie.options.size.canvasHeight;
|
|
var backgroundColor = pie.options.misc.colors.background;
|
|
|
|
var svg = d3.select(element).append("svg:svg")
|
|
.attr("width", canvasWidth)
|
|
.attr("height", canvasHeight);
|
|
|
|
if (backgroundColor !== "transparent") {
|
|
svg.style("background-color", function() { return backgroundColor; });
|
|
}
|
|
|
|
return svg;
|
|
},
|
|
|
|
shuffleArray: function(array) {
|
|
var currentIndex = array.length, tmpVal, randomIndex;
|
|
|
|
while (0 !== currentIndex) {
|
|
randomIndex = Math.floor(Math.random() * currentIndex);
|
|
currentIndex -= 1;
|
|
|
|
// and swap it with the current element
|
|
tmpVal = array[currentIndex];
|
|
array[currentIndex] = array[randomIndex];
|
|
array[randomIndex] = tmpVal;
|
|
}
|
|
return array;
|
|
},
|
|
|
|
processObj: function(obj, is, value) {
|
|
if (typeof is === 'string') {
|
|
return helpers.processObj(obj, is.split('.'), value);
|
|
} else if (is.length === 1 && value !== undefined) {
|
|
obj[is[0]] = value;
|
|
return obj[is[0]];
|
|
} else if (is.length === 0) {
|
|
return obj;
|
|
} else {
|
|
return helpers.processObj(obj[is[0]], is.slice(1), value);
|
|
}
|
|
},
|
|
|
|
getDimensions: function(el) {
|
|
if(typeof el === 'string')
|
|
el = document.getElementById(el);
|
|
|
|
var w = 0, h = 0;
|
|
if (el) {
|
|
var dimensions = el.getBBox();
|
|
w = dimensions.width;
|
|
h = dimensions.height;
|
|
}
|
|
else {
|
|
console.log("error: getDimensions() " + id + " not found.");
|
|
}
|
|
|
|
return { w: w, h: h };
|
|
},
|
|
|
|
/**
|
|
* This is based on the SVG coordinate system, where top-left is 0,0 and bottom right is n-n.
|
|
* @param r1
|
|
* @param r2
|
|
* @returns {boolean}
|
|
*/
|
|
rectIntersect: function(r1, r2) {
|
|
var returnVal = (
|
|
// r2.left > r1.right
|
|
(r2.x > (r1.x + r1.w)) ||
|
|
|
|
// r2.right < r1.left
|
|
((r2.x + r2.w) < r1.x) ||
|
|
|
|
// r2.top < r1.bottom
|
|
((r2.y + r2.h) < r1.y) ||
|
|
|
|
// r2.bottom > r1.top
|
|
(r2.y > (r1.y + r1.h))
|
|
);
|
|
|
|
return !returnVal;
|
|
},
|
|
|
|
/**
|
|
* Returns a lighter/darker shade of a hex value, based on a luminance value passed.
|
|
* @param hex a hex color value such as “#abc” or “#123456″ (the hash is optional)
|
|
* @param lum the luminosity factor: -0.1 is 10% darker, 0.2 is 20% lighter, etc.
|
|
* @returns {string}
|
|
*/
|
|
getColorShade: function(hex, lum) {
|
|
|
|
// validate hex string
|
|
hex = String(hex).replace(/[^0-9a-f]/gi, '');
|
|
if (hex.length < 6) {
|
|
hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
|
|
}
|
|
lum = lum || 0;
|
|
|
|
// convert to decimal and change luminosity
|
|
var newHex = "#";
|
|
for (var i=0; i<3; i++) {
|
|
var c = parseInt(hex.substr(i * 2, 2), 16);
|
|
c = Math.round(Math.min(Math.max(0, c + (c * lum)), 255)).toString(16);
|
|
newHex += ("00" + c).substr(c.length);
|
|
}
|
|
|
|
return newHex;
|
|
},
|
|
|
|
/**
|
|
* Users can choose to specify segment colors in three ways (in order of precedence):
|
|
* 1. include a "color" attribute for each row in data.content
|
|
* 2. include a misc.colors.segments property which contains an array of hex codes
|
|
* 3. specify nothing at all and rely on this lib provide some reasonable defaults
|
|
*
|
|
* This function sees what's included and populates this.options.colors with whatever's required
|
|
* for this pie chart.
|
|
* @param data
|
|
*/
|
|
initSegmentColors: function(pie) {
|
|
var data = pie.options.data.content;
|
|
var colors = pie.options.misc.colors.segments;
|
|
|
|
// TODO this needs a ton of error handling
|
|
|
|
var finalColors = [];
|
|
for (var i=0; i<data.length; i++) {
|
|
if (data[i].hasOwnProperty("color")) {
|
|
finalColors.push(data[i].color);
|
|
} else {
|
|
finalColors.push(colors[i]);
|
|
}
|
|
}
|
|
|
|
return finalColors;
|
|
},
|
|
|
|
applySmallSegmentGrouping: function(data, smallSegmentGrouping) {
|
|
var totalSize;
|
|
if (smallSegmentGrouping.valueType === "percentage") {
|
|
totalSize = math.getTotalPieSize(data);
|
|
}
|
|
|
|
// loop through each data item
|
|
var newData = [];
|
|
var groupedData = [];
|
|
var totalGroupedData = 0;
|
|
for (var i=0; i<data.length; i++) {
|
|
if (smallSegmentGrouping.valueType === "percentage") {
|
|
var dataPercent = (data[i].value / totalSize) * 100;
|
|
if (dataPercent <= smallSegmentGrouping.value) {
|
|
groupedData.push(data[i]);
|
|
totalGroupedData += data[i].value;
|
|
continue;
|
|
}
|
|
data[i].isGrouped = false;
|
|
newData.push(data[i]);
|
|
} else {
|
|
if (data[i].value <= smallSegmentGrouping.value) {
|
|
groupedData.push(data[i]);
|
|
totalGroupedData += data[i].value;
|
|
continue;
|
|
}
|
|
data[i].isGrouped = false;
|
|
newData.push(data[i]);
|
|
}
|
|
}
|
|
|
|
// we're done! See if there's any small segment groups to add
|
|
if (groupedData.length) {
|
|
newData.push({
|
|
color: smallSegmentGrouping.color,
|
|
label: smallSegmentGrouping.label,
|
|
value: totalGroupedData,
|
|
isGrouped: true,
|
|
groupedData: groupedData
|
|
});
|
|
}
|
|
|
|
return newData;
|
|
},
|
|
|
|
// for debugging
|
|
showPoint: function(svg, x, y) {
|
|
svg.append("circle").attr("cx", x).attr("cy", y).attr("r", 2).style("fill", "black");
|
|
},
|
|
|
|
isFunction: function(functionToCheck) {
|
|
var getType = {};
|
|
return functionToCheck && getType.toString.call(functionToCheck) === '[object Function]';
|
|
},
|
|
|
|
isArray: function(o) {
|
|
return Object.prototype.toString.call(o) === '[object Array]';
|
|
}
|
|
};
|
|
|
|
|
|
// taken from jQuery
|
|
var extend = function() {
|
|
var options, name, src, copy, copyIsArray, clone, target = arguments[0] || {},
|
|
i = 1,
|
|
length = arguments.length,
|
|
deep = false,
|
|
toString = Object.prototype.toString,
|
|
hasOwn = Object.prototype.hasOwnProperty,
|
|
class2type = {
|
|
"[object Boolean]": "boolean",
|
|
"[object Number]": "number",
|
|
"[object String]": "string",
|
|
"[object Function]": "function",
|
|
"[object Array]": "array",
|
|
"[object Date]": "date",
|
|
"[object RegExp]": "regexp",
|
|
"[object Object]": "object"
|
|
},
|
|
|
|
jQuery = {
|
|
isFunction: function (obj) {
|
|
return jQuery.type(obj) === "function";
|
|
},
|
|
isArray: Array.isArray ||
|
|
function (obj) {
|
|
return jQuery.type(obj) === "array";
|
|
},
|
|
isWindow: function (obj) {
|
|
return obj !== null && obj === obj.window;
|
|
},
|
|
isNumeric: function (obj) {
|
|
return !isNaN(parseFloat(obj)) && isFinite(obj);
|
|
},
|
|
type: function (obj) {
|
|
return obj === null ? String(obj) : class2type[toString.call(obj)] || "object";
|
|
},
|
|
isPlainObject: function (obj) {
|
|
if (!obj || jQuery.type(obj) !== "object" || obj.nodeType) {
|
|
return false;
|
|
}
|
|
try {
|
|
if (obj.constructor && !hasOwn.call(obj, "constructor") && !hasOwn.call(obj.constructor.prototype, "isPrototypeOf")) {
|
|
return false;
|
|
}
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
var key;
|
|
for (key in obj) {}
|
|
return key === undefined || hasOwn.call(obj, key);
|
|
}
|
|
};
|
|
if (typeof target === "boolean") {
|
|
deep = target;
|
|
target = arguments[1] || {};
|
|
i = 2;
|
|
}
|
|
if (typeof target !== "object" && !jQuery.isFunction(target)) {
|
|
target = {};
|
|
}
|
|
if (length === i) {
|
|
target = this;
|
|
--i;
|
|
}
|
|
for (i; i < length; i++) {
|
|
if ((options = arguments[i]) !== null) {
|
|
for (name in options) {
|
|
src = target[name];
|
|
copy = options[name];
|
|
if (target === copy) {
|
|
continue;
|
|
}
|
|
if (deep && copy && (jQuery.isPlainObject(copy) || (copyIsArray = jQuery.isArray(copy)))) {
|
|
if (copyIsArray) {
|
|
copyIsArray = false;
|
|
clone = src && jQuery.isArray(src) ? src : [];
|
|
} else {
|
|
clone = src && jQuery.isPlainObject(src) ? src : {};
|
|
}
|
|
// WARNING: RECURSION
|
|
target[name] = extend(deep, clone, copy);
|
|
} else if (copy !== undefined) {
|
|
target[name] = copy;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return target;
|
|
};
|
|
//// --------- math.js -----------
|
|
var math = {
|
|
|
|
toRadians: function(degrees) {
|
|
return degrees * (Math.PI / 180);
|
|
},
|
|
|
|
toDegrees: function(radians) {
|
|
return radians * (180 / Math.PI);
|
|
},
|
|
|
|
computePieRadius: function(pie) {
|
|
var size = pie.options.size;
|
|
var canvasPadding = pie.options.misc.canvasPadding;
|
|
|
|
// outer radius is either specified (e.g. through the generator), or omitted altogether
|
|
// and calculated based on the canvas dimensions. Right now the estimated version isn't great - it should
|
|
// be possible to calculate it to precisely generate the maximum sized pie, but it's fussy as heck. Something
|
|
// for the next release.
|
|
|
|
// first, calculate the default _outerRadius
|
|
var w = size.canvasWidth - canvasPadding.left - canvasPadding.right;
|
|
var h = size.canvasHeight - canvasPadding.top - canvasPadding.bottom;
|
|
|
|
// now factor in the footer, title & subtitle
|
|
if (pie.options.header.location !== "pie-center") {
|
|
h -= pie.textComponents.headerHeight;
|
|
}
|
|
|
|
if (pie.textComponents.footer.exists) {
|
|
h -= pie.textComponents.footer.h;
|
|
}
|
|
|
|
// for really teeny pies, h may be < 0. Adjust it back
|
|
h = (h < 0) ? 0 : h;
|
|
|
|
var outerRadius = ((w < h) ? w : h) / 3;
|
|
var innerRadius, percent;
|
|
|
|
// if the user specified something, use that instead
|
|
if (size.pieOuterRadius !== null) {
|
|
if (/%/.test(size.pieOuterRadius)) {
|
|
percent = parseInt(size.pieOuterRadius.replace(/[\D]/, ""), 10);
|
|
percent = (percent > 99) ? 99 : percent;
|
|
percent = (percent < 0) ? 0 : percent;
|
|
|
|
var smallestDimension = (w < h) ? w : h;
|
|
|
|
// now factor in the label line size
|
|
if (pie.options.labels.outer.format !== "none") {
|
|
var pieDistanceSpace = parseInt(pie.options.labels.outer.pieDistance, 10) * 2;
|
|
if (smallestDimension - pieDistanceSpace > 0) {
|
|
smallestDimension -= pieDistanceSpace;
|
|
}
|
|
}
|
|
|
|
outerRadius = Math.floor((smallestDimension / 100) * percent) / 2;
|
|
} else {
|
|
outerRadius = parseInt(size.pieOuterRadius, 10);
|
|
}
|
|
}
|
|
|
|
// inner radius
|
|
if (/%/.test(size.pieInnerRadius)) {
|
|
percent = parseInt(size.pieInnerRadius.replace(/[\D]/, ""), 10);
|
|
percent = (percent > 99) ? 99 : percent;
|
|
percent = (percent < 0) ? 0 : percent;
|
|
innerRadius = Math.floor((outerRadius / 100) * percent);
|
|
} else {
|
|
innerRadius = parseInt(size.pieInnerRadius, 10);
|
|
}
|
|
|
|
pie.innerRadius = innerRadius;
|
|
pie.outerRadius = outerRadius;
|
|
},
|
|
|
|
getTotalPieSize: function(data) {
|
|
var totalSize = 0;
|
|
for (var i=0; i<data.length; i++) {
|
|
totalSize += data[i].value;
|
|
}
|
|
return totalSize;
|
|
},
|
|
|
|
sortPieData: function(pie) {
|
|
var data = pie.options.data.content;
|
|
var sortOrder = pie.options.data.sortOrder;
|
|
|
|
switch (sortOrder) {
|
|
case "none":
|
|
// do nothing
|
|
break;
|
|
case "random":
|
|
data = helpers.shuffleArray(data);
|
|
break;
|
|
case "value-asc":
|
|
data.sort(function(a, b) { return (a.value < b.value) ? -1 : 1; });
|
|
break;
|
|
case "value-desc":
|
|
data.sort(function(a, b) { return (a.value < b.value) ? 1 : -1; });
|
|
break;
|
|
case "label-asc":
|
|
data.sort(function(a, b) { return (a.label.toLowerCase() > b.label.toLowerCase()) ? 1 : -1; });
|
|
break;
|
|
case "label-desc":
|
|
data.sort(function(a, b) { return (a.label.toLowerCase() < b.label.toLowerCase()) ? 1 : -1; });
|
|
break;
|
|
}
|
|
|
|
return data;
|
|
},
|
|
|
|
// var pieCenter = math.getPieCenter();
|
|
getPieTranslateCenter: function(pieCenter) {
|
|
return "translate(" + pieCenter.x + "," + pieCenter.y + ")";
|
|
},
|
|
|
|
/**
|
|
* Used to determine where on the canvas the center of the pie chart should be. It takes into account the
|
|
* height and position of the title, subtitle and footer, and the various paddings.
|
|
* @private
|
|
*/
|
|
calculatePieCenter: function(pie) {
|
|
var pieCenterOffset = pie.options.misc.pieCenterOffset;
|
|
var hasTopTitle = (pie.textComponents.title.exists && pie.options.header.location !== "pie-center");
|
|
var hasTopSubtitle = (pie.textComponents.subtitle.exists && pie.options.header.location !== "pie-center");
|
|
|
|
var headerOffset = pie.options.misc.canvasPadding.top;
|
|
if (hasTopTitle && hasTopSubtitle) {
|
|
headerOffset += pie.textComponents.title.h + pie.options.header.titleSubtitlePadding + pie.textComponents.subtitle.h;
|
|
} else if (hasTopTitle) {
|
|
headerOffset += pie.textComponents.title.h;
|
|
} else if (hasTopSubtitle) {
|
|
headerOffset += pie.textComponents.subtitle.h;
|
|
}
|
|
|
|
var footerOffset = 0;
|
|
if (pie.textComponents.footer.exists) {
|
|
footerOffset = pie.textComponents.footer.h + pie.options.misc.canvasPadding.bottom;
|
|
}
|
|
|
|
var x = ((pie.options.size.canvasWidth - pie.options.misc.canvasPadding.left - pie.options.misc.canvasPadding.right) / 2) + pie.options.misc.canvasPadding.left;
|
|
var y = ((pie.options.size.canvasHeight - footerOffset - headerOffset) / 2) + headerOffset;
|
|
|
|
x += pieCenterOffset.x;
|
|
y += pieCenterOffset.y;
|
|
|
|
pie.pieCenter = { x: x, y: y };
|
|
},
|
|
|
|
|
|
/**
|
|
* Rotates a point (x, y) around an axis (xm, ym) by degrees (a).
|
|
* @param x
|
|
* @param y
|
|
* @param xm
|
|
* @param ym
|
|
* @param a angle in degrees
|
|
* @returns {Array}
|
|
*/
|
|
rotate: function(x, y, xm, ym, a) {
|
|
|
|
a = a * Math.PI / 180; // convert to radians
|
|
|
|
var cos = Math.cos,
|
|
sin = Math.sin,
|
|
// subtract midpoints, so that midpoint is translated to origin and add it in the end again
|
|
xr = (x - xm) * cos(a) - (y - ym) * sin(a) + xm,
|
|
yr = (x - xm) * sin(a) + (y - ym) * cos(a) + ym;
|
|
|
|
return { x: xr, y: yr };
|
|
},
|
|
|
|
/**
|
|
* Translates a point x, y by distance d, and by angle a.
|
|
* @param x
|
|
* @param y
|
|
* @param dist
|
|
* @param a angle in degrees
|
|
*/
|
|
translate: function(x, y, d, a) {
|
|
var rads = math.toRadians(a);
|
|
return {
|
|
x: x + d * Math.sin(rads),
|
|
y: y - d * Math.cos(rads)
|
|
};
|
|
},
|
|
|
|
// from: http://stackoverflow.com/questions/19792552/d3-put-arc-labels-in-a-pie-chart-if-there-is-enough-space
|
|
pointIsInArc: function(pt, ptData, d3Arc) {
|
|
// Center of the arc is assumed to be 0,0
|
|
// (pt.x, pt.y) are assumed to be relative to the center
|
|
var r1 = d3Arc.innerRadius()(ptData), // Note: Using the innerRadius
|
|
r2 = d3Arc.outerRadius()(ptData),
|
|
theta1 = d3Arc.startAngle()(ptData),
|
|
theta2 = d3Arc.endAngle()(ptData);
|
|
|
|
var dist = pt.x * pt.x + pt.y * pt.y,
|
|
angle = Math.atan2(pt.x, -pt.y); // Note: different coordinate system
|
|
|
|
angle = (angle < 0) ? (angle + Math.PI * 2) : angle;
|
|
|
|
return (r1 * r1 <= dist) && (dist <= r2 * r2) &&
|
|
(theta1 <= angle) && (angle <= theta2);
|
|
}
|
|
};
|
|
|
|
//// --------- labels.js -----------
|
|
var labels = {
|
|
|
|
/**
|
|
* Adds the labels to the pie chart, but doesn't position them. There are two locations for the
|
|
* labels: inside (center) of the segments, or outside the segments on the edge.
|
|
* @param section "inner" or "outer"
|
|
* @param sectionDisplayType "percentage", "value", "label", "label-value1", etc.
|
|
* @param pie
|
|
*/
|
|
add: function(pie, section, sectionDisplayType) {
|
|
var include = labels.getIncludes(sectionDisplayType);
|
|
var settings = pie.options.labels;
|
|
|
|
// group the label groups (label, percentage, value) into a single element for simpler positioning
|
|
var outerLabel = pie.svg.insert("g", "." + pie.cssPrefix + "labels-" + section)
|
|
.attr("class", pie.cssPrefix + "labels-" + section);
|
|
|
|
var labelGroup = pie.__labels[section] = outerLabel.selectAll("." + pie.cssPrefix + "labelGroup-" + section)
|
|
.data(pie.options.data.content)
|
|
.enter()
|
|
.append("g")
|
|
.attr("id", function(d, i) { return pie.cssPrefix + "labelGroup" + i + "-" + section; })
|
|
.attr("data-index", function(d, i) { return i; })
|
|
.attr("class", pie.cssPrefix + "labelGroup-" + section)
|
|
.style("opacity", 0);
|
|
|
|
var formatterContext = { section: section, sectionDisplayType: sectionDisplayType };
|
|
|
|
// 1. Add the main label
|
|
if (include.mainLabel) {
|
|
labelGroup.append("text")
|
|
.attr("id", function(d, i) { return pie.cssPrefix + "segmentMainLabel" + i + "-" + section; })
|
|
.attr("class", pie.cssPrefix + "segmentMainLabel-" + section)
|
|
.text(function(d, i) {
|
|
var str = d.label;
|
|
|
|
// if a custom formatter has been defined, pass it the raw label string - it can do whatever it wants with it.
|
|
// we only apply truncation if it's not defined
|
|
if (settings.formatter) {
|
|
formatterContext.index = i;
|
|
formatterContext.part = 'mainLabel';
|
|
formatterContext.value = d.value;
|
|
formatterContext.label = str;
|
|
str = settings.formatter(formatterContext);
|
|
} else if (settings.truncation.enabled && d.label.length > settings.truncation.truncateLength) {
|
|
str = d.label.substring(0, settings.truncation.truncateLength) + "...";
|
|
}
|
|
return str;
|
|
})
|
|
.style("font-size", settings.mainLabel.fontSize + "px")
|
|
.style("font-family", settings.mainLabel.font)
|
|
.style("font-weight", settings.mainLabel.fontWeight)
|
|
.style("fill", function(d, i) {
|
|
return (settings.mainLabel.color === "segment") ? pie.options.colors[i] : settings.mainLabel.color;
|
|
});
|
|
}
|
|
|
|
// 2. Add the percentage label
|
|
if (include.percentage) {
|
|
labelGroup.append("text")
|
|
.attr("id", function(d, i) { return pie.cssPrefix + "segmentPercentage" + i + "-" + section; })
|
|
.attr("class", pie.cssPrefix + "segmentPercentage-" + section)
|
|
.text(function(d, i) {
|
|
var percentage = d.percentage;
|
|
if (settings.formatter) {
|
|
formatterContext.index = i;
|
|
formatterContext.part = "percentage";
|
|
formatterContext.value = d.value;
|
|
formatterContext.label = d.percentage;
|
|
percentage = settings.formatter(formatterContext);
|
|
} else {
|
|
percentage += "%";
|
|
}
|
|
return percentage;
|
|
})
|
|
.style("font-size", settings.percentage.fontSize + "px")
|
|
.style("font-family", settings.percentage.font)
|
|
.style("font-weight", settings.percentage.fontWeight)
|
|
.style("fill", settings.percentage.color);
|
|
}
|
|
|
|
// 3. Add the value label
|
|
if (include.value) {
|
|
labelGroup.append("text")
|
|
.attr("id", function(d, i) { return pie.cssPrefix + "segmentValue" + i + "-" + section; })
|
|
.attr("class", pie.cssPrefix + "segmentValue-" + section)
|
|
.text(function(d, i) {
|
|
formatterContext.index = i;
|
|
formatterContext.part = "value";
|
|
formatterContext.value = d.value;
|
|
formatterContext.label = d.value;
|
|
return settings.formatter ? settings.formatter(formatterContext, d.value) : d.value;
|
|
})
|
|
.style("font-size", settings.value.fontSize + "px")
|
|
.style("font-family", settings.value.font)
|
|
.style("font-weight", settings.value.fontWeight)
|
|
.style("fill", settings.value.color);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* @param section "inner" / "outer"
|
|
*/
|
|
positionLabelElements: function(pie, section, sectionDisplayType) {
|
|
labels["dimensions-" + section] = [];
|
|
|
|
// get the latest widths, heights
|
|
var labelGroups = pie.__labels[section];
|
|
labelGroups.each(function(d, i) {
|
|
var mainLabel = d3.select(this).selectAll("." + pie.cssPrefix + "segmentMainLabel-" + section);
|
|
var percentage = d3.select(this).selectAll("." + pie.cssPrefix + "segmentPercentage-" + section);
|
|
var value = d3.select(this).selectAll("." + pie.cssPrefix + "segmentValue-" + section);
|
|
|
|
labels["dimensions-" + section].push({
|
|
mainLabel: (mainLabel.node() !== null) ? mainLabel.node().getBBox() : null,
|
|
percentage: (percentage.node() !== null) ? percentage.node().getBBox() : null,
|
|
value: (value.node() !== null) ? value.node().getBBox() : null
|
|
});
|
|
});
|
|
|
|
var singleLinePad = 5;
|
|
var dims = labels["dimensions-" + section];
|
|
switch (sectionDisplayType) {
|
|
case "label-value1":
|
|
pie.svg.selectAll("." + pie.cssPrefix + "segmentValue-" + section)
|
|
.attr("dx", function(d, i) { return dims[i].mainLabel.width + singleLinePad; });
|
|
break;
|
|
case "label-value2":
|
|
pie.svg.selectAll("." + pie.cssPrefix + "segmentValue-" + section)
|
|
.attr("dy", function(d, i) { return dims[i].mainLabel.height; });
|
|
break;
|
|
case "label-percentage1":
|
|
pie.svg.selectAll("." + pie.cssPrefix + "segmentPercentage-" + section)
|
|
.attr("dx", function(d, i) { return dims[i].mainLabel.width + singleLinePad; });
|
|
break;
|
|
case "label-percentage2":
|
|
pie.svg.selectAll("." + pie.cssPrefix + "segmentPercentage-" + section)
|
|
.attr("dx", function(d, i) { return (dims[i].mainLabel.width / 2) - (dims[i].percentage.width / 2); })
|
|
.attr("dy", function(d, i) { return dims[i].mainLabel.height; });
|
|
break;
|
|
}
|
|
},
|
|
|
|
computeLabelLinePositions: function(pie) {
|
|
pie.lineCoordGroups = [];
|
|
pie.__labels.outer
|
|
.each(function(d, i) { return labels.computeLinePosition(pie, i); });
|
|
},
|
|
|
|
computeLinePosition: function(pie, i) {
|
|
var angle = segments.getSegmentAngle(i, pie.options.data.content, pie.totalSize, { midpoint: true });
|
|
var originCoords = math.rotate(pie.pieCenter.x, pie.pieCenter.y - pie.outerRadius, pie.pieCenter.x, pie.pieCenter.y, angle);
|
|
var heightOffset = pie.outerLabelGroupData[i].h / 5; // TODO check
|
|
var labelXMargin = 6; // the x-distance of the label from the end of the line [TODO configurable]
|
|
|
|
var quarter = Math.floor(angle / 90);
|
|
var midPoint = 4;
|
|
var x2, y2, x3, y3;
|
|
|
|
// this resolves an issue when the
|
|
if (quarter === 2 && angle === 180) {
|
|
quarter = 1;
|
|
}
|
|
|
|
switch (quarter) {
|
|
case 0:
|
|
x2 = pie.outerLabelGroupData[i].x - labelXMargin - ((pie.outerLabelGroupData[i].x - labelXMargin - originCoords.x) / 2);
|
|
y2 = pie.outerLabelGroupData[i].y + ((originCoords.y - pie.outerLabelGroupData[i].y) / midPoint);
|
|
x3 = pie.outerLabelGroupData[i].x - labelXMargin;
|
|
y3 = pie.outerLabelGroupData[i].y - heightOffset;
|
|
break;
|
|
case 1:
|
|
x2 = originCoords.x + (pie.outerLabelGroupData[i].x - originCoords.x) / midPoint;
|
|
y2 = originCoords.y + (pie.outerLabelGroupData[i].y - originCoords.y) / midPoint;
|
|
x3 = pie.outerLabelGroupData[i].x - labelXMargin;
|
|
y3 = pie.outerLabelGroupData[i].y - heightOffset;
|
|
break;
|
|
case 2:
|
|
var startOfLabelX = pie.outerLabelGroupData[i].x + pie.outerLabelGroupData[i].w + labelXMargin;
|
|
x2 = originCoords.x - (originCoords.x - startOfLabelX) / midPoint;
|
|
y2 = originCoords.y + (pie.outerLabelGroupData[i].y - originCoords.y) / midPoint;
|
|
x3 = pie.outerLabelGroupData[i].x + pie.outerLabelGroupData[i].w + labelXMargin;
|
|
y3 = pie.outerLabelGroupData[i].y - heightOffset;
|
|
break;
|
|
case 3:
|
|
var startOfLabel = pie.outerLabelGroupData[i].x + pie.outerLabelGroupData[i].w + labelXMargin;
|
|
x2 = startOfLabel + ((originCoords.x - startOfLabel) / midPoint);
|
|
y2 = pie.outerLabelGroupData[i].y + (originCoords.y - pie.outerLabelGroupData[i].y) / midPoint;
|
|
x3 = pie.outerLabelGroupData[i].x + pie.outerLabelGroupData[i].w + labelXMargin;
|
|
y3 = pie.outerLabelGroupData[i].y - heightOffset;
|
|
break;
|
|
}
|
|
|
|
/*
|
|
* x1 / y1: the x/y coords of the start of the line, at the mid point of the segments arc on the pie circumference
|
|
* x2 / y2: if "curved" line style is being used, this is the midpoint of the line. Other
|
|
* x3 / y3: the end of the line; closest point to the label
|
|
*/
|
|
if (pie.options.labels.lines.style === "straight") {
|
|
pie.lineCoordGroups[i] = [
|
|
{ x: originCoords.x, y: originCoords.y },
|
|
{ x: x3, y: y3 }
|
|
];
|
|
} else {
|
|
pie.lineCoordGroups[i] = [
|
|
{ x: originCoords.x, y: originCoords.y },
|
|
{ x: x2, y: y2 },
|
|
{ x: x3, y: y3 }
|
|
];
|
|
}
|
|
},
|
|
|
|
addLabelLines: function(pie) {
|
|
var lineGroups = pie.svg.insert("g", "." + pie.cssPrefix + "pieChart") // meaning, BEFORE .pieChart
|
|
.attr("class", pie.cssPrefix + "lineGroups")
|
|
.style("opacity", 1);
|
|
|
|
var lineGroup = lineGroups.selectAll("." + pie.cssPrefix + "lineGroup")
|
|
.data(pie.lineCoordGroups)
|
|
.enter()
|
|
.append("g")
|
|
.attr("class", pie.cssPrefix + "lineGroup");
|
|
|
|
var lineFunction = d3.line()
|
|
.curve(d3.curveBasis)
|
|
.x(function(d) { return d.x; })
|
|
.y(function(d) { return d.y; });
|
|
|
|
lineGroup.append("path")
|
|
.attr("d", lineFunction)
|
|
.attr("stroke", function(d, i) {
|
|
return (pie.options.labels.lines.color === "segment") ? pie.options.colors[i] : pie.options.labels.lines.color;
|
|
})
|
|
.attr("stroke-width", 1)
|
|
.attr("fill", "none")
|
|
.style("opacity", function(d, i) {
|
|
var percentage = pie.options.labels.outer.hideWhenLessThanPercentage;
|
|
var isHidden = (percentage !== null && d.percentage < percentage) || pie.options.data.content[i].label === "";
|
|
return isHidden ? 0 : 1;
|
|
});
|
|
},
|
|
|
|
positionLabelGroups: function(pie, section) {
|
|
if (pie.options.labels[section].format === "none")
|
|
return;
|
|
|
|
pie.__labels[section]
|
|
.style("opacity", function(d, i) {
|
|
var percentage = pie.options.labels[section].hideWhenLessThanPercentage;
|
|
return (percentage !== null && d.percentage < percentage) ? 0 : 1;
|
|
})
|
|
.attr("transform", function(d, i) {
|
|
var x, y;
|
|
if (section === "outer") {
|
|
x = pie.outerLabelGroupData[i].x;
|
|
y = pie.outerLabelGroupData[i].y;
|
|
} else {
|
|
var pieCenterCopy = extend(true, {}, pie.pieCenter);
|
|
|
|
// now recompute the "center" based on the current _innerRadius
|
|
if (pie.innerRadius > 0) {
|
|
var angle = segments.getSegmentAngle(i, pie.options.data.content, pie.totalSize, { midpoint: true });
|
|
var newCoords = math.translate(pie.pieCenter.x, pie.pieCenter.y, pie.innerRadius, angle);
|
|
pieCenterCopy.x = newCoords.x;
|
|
pieCenterCopy.y = newCoords.y;
|
|
}
|
|
|
|
var dims = helpers.getDimensions(pie.cssPrefix + "labelGroup" + i + "-inner");
|
|
var xOffset = dims.w / 2;
|
|
var yOffset = dims.h / 4; // confusing! Why 4? should be 2, but it doesn't look right
|
|
|
|
x = pieCenterCopy.x + (pie.lineCoordGroups[i][0].x - pieCenterCopy.x) / 1.8;
|
|
y = pieCenterCopy.y + (pie.lineCoordGroups[i][0].y - pieCenterCopy.y) / 1.8;
|
|
|
|
x = x - xOffset;
|
|
y = y + yOffset;
|
|
}
|
|
|
|
return "translate(" + x + "," + y + ")";
|
|
});
|
|
},
|
|
|
|
|
|
getIncludes: function(val) {
|
|
var addMainLabel = false;
|
|
var addValue = false;
|
|
var addPercentage = false;
|
|
|
|
switch (val) {
|
|
case "label":
|
|
addMainLabel = true;
|
|
break;
|
|
case "value":
|
|
addValue = true;
|
|
break;
|
|
case "percentage":
|
|
addPercentage = true;
|
|
break;
|
|
case "label-value1":
|
|
case "label-value2":
|
|
addMainLabel = true;
|
|
addValue = true;
|
|
break;
|
|
case "label-percentage1":
|
|
case "label-percentage2":
|
|
addMainLabel = true;
|
|
addPercentage = true;
|
|
break;
|
|
}
|
|
return {
|
|
mainLabel: addMainLabel,
|
|
value: addValue,
|
|
percentage: addPercentage
|
|
};
|
|
},
|
|
|
|
|
|
/**
|
|
* This does the heavy-lifting to compute the actual coordinates for the outer label groups. It does two things:
|
|
* 1. Make a first pass and position them in the ideal positions, based on the pie sizes
|
|
* 2. Do some basic collision avoidance.
|
|
*/
|
|
computeOuterLabelCoords: function(pie) {
|
|
|
|
// 1. figure out the ideal positions for the outer labels
|
|
pie.__labels.outer
|
|
.each(function(d, i) {
|
|
return labels.getIdealOuterLabelPositions(pie, i);
|
|
});
|
|
|
|
// 2. now adjust those positions to try to accommodate conflicts
|
|
labels.resolveOuterLabelCollisions(pie);
|
|
},
|
|
|
|
/**
|
|
* This attempts to resolve label positioning collisions.
|
|
*/
|
|
resolveOuterLabelCollisions: function(pie) {
|
|
if (pie.options.labels.outer.format === "none") {
|
|
return;
|
|
}
|
|
|
|
var size = pie.options.data.content.length;
|
|
labels.checkConflict(pie, 0, "clockwise", size);
|
|
labels.checkConflict(pie, size-1, "anticlockwise", size);
|
|
},
|
|
|
|
checkConflict: function(pie, currIndex, direction, size) {
|
|
var i, curr;
|
|
|
|
if (size <= 1) {
|
|
return;
|
|
}
|
|
|
|
var currIndexHemisphere = pie.outerLabelGroupData[currIndex].hs;
|
|
if (direction === "clockwise" && currIndexHemisphere !== "right") {
|
|
return;
|
|
}
|
|
if (direction === "anticlockwise" && currIndexHemisphere !== "left") {
|
|
return;
|
|
}
|
|
var nextIndex = (direction === "clockwise") ? currIndex+1 : currIndex-1;
|
|
|
|
// this is the current label group being looked at. We KNOW it's positioned properly (the first item
|
|
// is always correct)
|
|
var currLabelGroup = pie.outerLabelGroupData[currIndex];
|
|
|
|
// this one we don't know about. That's the one we're going to look at and move if necessary
|
|
var examinedLabelGroup = pie.outerLabelGroupData[nextIndex];
|
|
|
|
var info = {
|
|
labelHeights: pie.outerLabelGroupData[0].h,
|
|
center: pie.pieCenter,
|
|
lineLength: (pie.outerRadius + pie.options.labels.outer.pieDistance),
|
|
heightChange: pie.outerLabelGroupData[0].h + 1 // 1 = padding
|
|
};
|
|
|
|
// loop through *ALL* label groups examined so far to check for conflicts. This is because when they're
|
|
// very tightly fitted, a later label group may still appear high up on the page
|
|
if (direction === "clockwise") {
|
|
i = 0;
|
|
for (; i<=currIndex; i++) {
|
|
curr = pie.outerLabelGroupData[i];
|
|
|
|
// if there's a conflict with this label group, shift the label to be AFTER the last known
|
|
// one that's been properly placed
|
|
if (!labels.isLabelHidden(pie, i) && helpers.rectIntersect(curr, examinedLabelGroup)) {
|
|
labels.adjustLabelPos(pie, nextIndex, currLabelGroup, info);
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
i = size - 1;
|
|
for (; i >= currIndex; i--) {
|
|
curr = pie.outerLabelGroupData[i];
|
|
|
|
// if there's a conflict with this label group, shift the label to be AFTER the last known
|
|
// one that's been properly placed
|
|
if (!labels.isLabelHidden(pie, i) && helpers.rectIntersect(curr, examinedLabelGroup)) {
|
|
labels.adjustLabelPos(pie, nextIndex, currLabelGroup, info);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
labels.checkConflict(pie, nextIndex, direction, size);
|
|
},
|
|
|
|
isLabelHidden: function(pie, index) {
|
|
var percentage = pie.options.labels.outer.hideWhenLessThanPercentage;
|
|
return (percentage !== null && d.percentage < percentage) || pie.options.data.content[index].label === "";
|
|
},
|
|
|
|
// does a little math to shift a label into a new position based on the last properly placed one
|
|
adjustLabelPos: function(pie, nextIndex, lastCorrectlyPositionedLabel, info) {
|
|
var xDiff, yDiff, newXPos, newYPos;
|
|
newYPos = lastCorrectlyPositionedLabel.y + info.heightChange;
|
|
yDiff = info.center.y - newYPos;
|
|
|
|
if (Math.abs(info.lineLength) > Math.abs(yDiff)) {
|
|
xDiff = Math.sqrt((info.lineLength * info.lineLength) - (yDiff * yDiff));
|
|
} else {
|
|
xDiff = Math.sqrt((yDiff * yDiff) - (info.lineLength * info.lineLength));
|
|
}
|
|
|
|
if (lastCorrectlyPositionedLabel.hs === "right") {
|
|
newXPos = info.center.x + xDiff;
|
|
} else {
|
|
newXPos = info.center.x - xDiff - pie.outerLabelGroupData[nextIndex].w;
|
|
}
|
|
|
|
pie.outerLabelGroupData[nextIndex].x = newXPos;
|
|
pie.outerLabelGroupData[nextIndex].y = newYPos;
|
|
},
|
|
|
|
/**
|
|
* @param i 0-N where N is the dataset size - 1.
|
|
*/
|
|
getIdealOuterLabelPositions: function(pie, i) {
|
|
var labelGroupNode = pie.svg.select("#" + pie.cssPrefix + "labelGroup" + i + "-outer").node();
|
|
if (!labelGroupNode) return;
|
|
|
|
var labelGroupDims = labelGroupNode.getBBox();
|
|
var angle = segments.getSegmentAngle(i, pie.options.data.content, pie.totalSize, { midpoint: true });
|
|
|
|
var originalX = pie.pieCenter.x;
|
|
var originalY = pie.pieCenter.y - (pie.outerRadius + pie.options.labels.outer.pieDistance);
|
|
var newCoords = math.rotate(originalX, originalY, pie.pieCenter.x, pie.pieCenter.y, angle);
|
|
|
|
// if the label is on the left half of the pie, adjust the values
|
|
var hemisphere = "right"; // hemisphere
|
|
if (angle > 180) {
|
|
newCoords.x -= (labelGroupDims.width + 8);
|
|
hemisphere = "left";
|
|
} else {
|
|
newCoords.x += 8;
|
|
}
|
|
|
|
pie.outerLabelGroupData[i] = {
|
|
x: newCoords.x,
|
|
y: newCoords.y,
|
|
w: labelGroupDims.width,
|
|
h: labelGroupDims.height,
|
|
hs: hemisphere
|
|
};
|
|
}
|
|
};
|
|
|
|
//// --------- segments.js -----------
|
|
var segments = {
|
|
|
|
effectMap: {
|
|
"none": d3.easeLinear,
|
|
"bounce": d3.easeBounce,
|
|
"linear": d3.easeLinear,
|
|
"sin": d3.easeSin,
|
|
"elastic": d3.easeElastic,
|
|
"back": d3.easeBack,
|
|
"quad": d3.easeQuad,
|
|
"circle": d3.easeCircle,
|
|
"exp": d3.easeExp
|
|
},
|
|
|
|
/**
|
|
* Creates the pie chart segments and displays them according to the desired load effect.
|
|
* @private
|
|
*/
|
|
create: function(pie) {
|
|
var pieCenter = pie.pieCenter;
|
|
var colors = pie.options.colors;
|
|
var loadEffects = pie.options.effects.load;
|
|
var segmentStroke = pie.options.misc.colors.segmentStroke;
|
|
|
|
// we insert the pie chart BEFORE the title, to ensure the title overlaps the pie
|
|
var pieChartElement = pie.svg.insert("g", "#" + pie.cssPrefix + "title")
|
|
.attr("transform", function() { return math.getPieTranslateCenter(pieCenter); })
|
|
.attr("class", pie.cssPrefix + "pieChart");
|
|
|
|
var arc = d3.arc()
|
|
.innerRadius(pie.innerRadius)
|
|
.outerRadius(pie.outerRadius)
|
|
.startAngle(0)
|
|
.endAngle(function(d) {
|
|
return (d.value / pie.totalSize) * 2 * Math.PI;
|
|
});
|
|
|
|
var g = pieChartElement.selectAll("." + pie.cssPrefix + "arc")
|
|
.data(pie.options.data.content)
|
|
.enter()
|
|
.append("g")
|
|
.attr("class", pie.cssPrefix + "arc");
|
|
|
|
// if we're not fading in the pie, just set the load speed to 0
|
|
//var loadSpeed = loadEffects.speed;
|
|
//if (loadEffects.effect === "none") {
|
|
// loadSpeed = 0;
|
|
//}
|
|
|
|
g.append("path")
|
|
.attr("id", function(d, i) { return pie.cssPrefix + "segment" + i; })
|
|
.attr("fill", function(d, i) {
|
|
var color = colors[i];
|
|
if (pie.options.misc.gradient.enabled) {
|
|
color = "url(#" + pie.cssPrefix + "grad" + i + ")";
|
|
}
|
|
return color;
|
|
})
|
|
.style("stroke", segmentStroke)
|
|
.style("stroke-width", 1)
|
|
//.transition()
|
|
//.ease(d3.easeCubicInOut)
|
|
//.duration(loadSpeed)
|
|
.attr("data-index", function(d, i) { return i; })
|
|
.attr("d", arc);
|
|
/*
|
|
.attrTween("d", function(b) {
|
|
var i = d3.interpolate({ value: 0 }, b);
|
|
return function(t) {
|
|
var ret = pie.arc(i(t));
|
|
console.log(ret);
|
|
return ret;
|
|
};
|
|
});
|
|
*/
|
|
pie.svg.selectAll("g." + pie.cssPrefix + "arc")
|
|
.attr("transform",
|
|
function(d, i) {
|
|
var angle = 0;
|
|
if (i > 0) {
|
|
angle = segments.getSegmentAngle(i-1, pie.options.data.content, pie.totalSize);
|
|
}
|
|
return "rotate(" + angle + ")";
|
|
}
|
|
);
|
|
pie.arc = arc;
|
|
},
|
|
|
|
addGradients: function(pie) {
|
|
var grads = pie.svg.append("defs")
|
|
.selectAll("radialGradient")
|
|
.data(pie.options.data.content)
|
|
.enter().append("radialGradient")
|
|
.attr("gradientUnits", "userSpaceOnUse")
|
|
.attr("cx", 0)
|
|
.attr("cy", 0)
|
|
.attr("r", "120%")
|
|
.attr("id", function(d, i) { return pie.cssPrefix + "grad" + i; });
|
|
|
|
grads.append("stop").attr("offset", "0%").style("stop-color", function(d, i) { return pie.options.colors[i]; });
|
|
grads.append("stop").attr("offset", pie.options.misc.gradient.percentage + "%").style("stop-color", pie.options.misc.gradient.color);
|
|
},
|
|
|
|
addSegmentEventHandlers: function(pie) {
|
|
var arc = pie.svg.selectAll("." + pie.cssPrefix + "arc");
|
|
arc = arc.merge(pie.__labels.inner.merge(pie.__labels.outer));
|
|
|
|
arc.on("click", function() {
|
|
var currentEl = d3.select(this);
|
|
var segment;
|
|
|
|
// mouseover works on both the segments AND the segment labels, hence the following
|
|
if (currentEl.attr("class") === pie.cssPrefix + "arc") {
|
|
segment = currentEl.select("path");
|
|
} else {
|
|
var index = currentEl.attr("data-index");
|
|
segment = d3.select("#" + pie.cssPrefix + "segment" + index);
|
|
}
|
|
|
|
var isExpanded = segment.attr("class") === pie.cssPrefix + "expanded";
|
|
segments.onSegmentEvent(pie, pie.options.callbacks.onClickSegment, segment, isExpanded);
|
|
if (pie.options.effects.pullOutSegmentOnClick.effect !== "none") {
|
|
if (isExpanded) {
|
|
segments.closeSegment(pie, segment.node());
|
|
} else {
|
|
segments.openSegment(pie, segment.node());
|
|
}
|
|
}
|
|
});
|
|
|
|
arc.on("mouseover", function() {
|
|
var currentEl = d3.select(this);
|
|
var segment, index;
|
|
|
|
if (currentEl.attr("class") === pie.cssPrefix + "arc") {
|
|
segment = currentEl.select("path");
|
|
} else {
|
|
index = currentEl.attr("data-index");
|
|
segment = d3.select("#" + pie.cssPrefix + "segment" + index);
|
|
}
|
|
|
|
if (pie.options.effects.highlightSegmentOnMouseover) {
|
|
index = segment.attr("data-index");
|
|
var segColor = pie.options.colors[index];
|
|
segment.style("fill", helpers.getColorShade(segColor, pie.options.effects.highlightLuminosity));
|
|
}
|
|
|
|
if (pie.options.tooltips.enabled) {
|
|
index = segment.attr("data-index");
|
|
tt.showTooltip(pie, index);
|
|
}
|
|
|
|
var isExpanded = segment.attr("class") === pie.cssPrefix + "expanded";
|
|
segments.onSegmentEvent(pie, pie.options.callbacks.onMouseoverSegment, segment, isExpanded);
|
|
});
|
|
|
|
arc.on("mousemove", function() {
|
|
tt.moveTooltip(pie);
|
|
});
|
|
|
|
arc.on("mouseout", function() {
|
|
var currentEl = d3.select(this);
|
|
var segment, index;
|
|
|
|
if (currentEl.attr("class") === pie.cssPrefix + "arc") {
|
|
segment = currentEl.select("path");
|
|
} else {
|
|
index = currentEl.attr("data-index");
|
|
segment = d3.select("#" + pie.cssPrefix + "segment" + index);
|
|
}
|
|
|
|
if (pie.options.effects.highlightSegmentOnMouseover) {
|
|
index = segment.attr("data-index");
|
|
var color = pie.options.colors[index];
|
|
if (pie.options.misc.gradient.enabled) {
|
|
color = "url(#" + pie.cssPrefix + "grad" + index + ")";
|
|
}
|
|
segment.style("fill", color);
|
|
}
|
|
|
|
if (pie.options.tooltips.enabled) {
|
|
index = segment.attr("data-index");
|
|
tt.hideTooltip(pie, index);
|
|
}
|
|
|
|
var isExpanded = segment.attr("class") === pie.cssPrefix + "expanded";
|
|
segments.onSegmentEvent(pie, pie.options.callbacks.onMouseoutSegment, segment, isExpanded);
|
|
});
|
|
},
|
|
|
|
// helper function used to call the click, mouseover, mouseout segment callback functions
|
|
onSegmentEvent: function(pie, func, segment, isExpanded) {
|
|
if (!helpers.isFunction(func)) {
|
|
return;
|
|
}
|
|
var index = parseInt(segment.attr("data-index"), 10);
|
|
func({
|
|
segment: segment.node(),
|
|
index: index,
|
|
expanded: isExpanded,
|
|
data: pie.options.data.content[index]
|
|
});
|
|
},
|
|
|
|
openSegment: function(pie, segment) {
|
|
if (pie.isOpeningSegment) {
|
|
return;
|
|
}
|
|
pie.isOpeningSegment = true;
|
|
|
|
segments.maybeCloseOpenSegment(pie);
|
|
|
|
d3.select(segment)
|
|
.transition()
|
|
.ease(segments.effectMap[pie.options.effects.pullOutSegmentOnClick.effect])
|
|
.duration(pie.options.effects.pullOutSegmentOnClick.speed)
|
|
.attr("transform", function(d, i) {
|
|
var c = pie.arc.centroid(d),
|
|
x = c[0],
|
|
y = c[1],
|
|
h = Math.sqrt(x*x + y*y),
|
|
pullOutSize = parseInt(pie.options.effects.pullOutSegmentOnClick.size, 10);
|
|
|
|
return "translate(" + ((x/h) * pullOutSize) + ',' + ((y/h) * pullOutSize) + ")";
|
|
})
|
|
.on("end", function(d, i) {
|
|
pie.currentlyOpenSegment = segment;
|
|
pie.isOpeningSegment = false;
|
|
d3.select(segment).attr("class", pie.cssPrefix + "expanded");
|
|
});
|
|
},
|
|
|
|
maybeCloseOpenSegment: function(pie) {
|
|
if (typeof pie !== 'undefined' && pie.svg.selectAll("." + pie.cssPrefix + "expanded").size() > 0) {
|
|
segments.closeSegment(pie, pie.svg.select("." + pie.cssPrefix + "expanded").node());
|
|
}
|
|
},
|
|
|
|
closeSegment: function(pie, segment) {
|
|
d3.select(segment)
|
|
.transition()
|
|
.duration(400)
|
|
.attr("transform", "translate(0,0)")
|
|
.on("end", function(d, i) {
|
|
d3.select(segment).attr("class", "");
|
|
pie.currentlyOpenSegment = null;
|
|
});
|
|
},
|
|
|
|
getCentroid: function(el) {
|
|
var bbox = el.getBBox();
|
|
return {
|
|
x: bbox.x + bbox.width / 2,
|
|
y: bbox.y + bbox.height / 2
|
|
};
|
|
},
|
|
|
|
/**
|
|
* General helper function to return a segment's angle, in various different ways.
|
|
* @param index
|
|
* @param opts optional object for fine-tuning exactly what you want.
|
|
*/
|
|
getSegmentAngle: function(index, data, totalSize, opts) {
|
|
var options = extend({
|
|
// if true, this returns the full angle from the origin. Otherwise it returns the single segment angle
|
|
compounded: true,
|
|
|
|
// optionally returns the midpoint of the angle instead of the full angle
|
|
midpoint: false
|
|
}, opts);
|
|
|
|
var currValue = data[index].value;
|
|
var fullValue;
|
|
if (options.compounded) {
|
|
fullValue = 0;
|
|
|
|
// get all values up to and including the specified index
|
|
for (var i=0; i<=index; i++) {
|
|
fullValue += data[i].value;
|
|
}
|
|
}
|
|
|
|
if (typeof fullValue === 'undefined') {
|
|
fullValue = currValue;
|
|
}
|
|
|
|
// now convert the full value to an angle
|
|
var angle = (fullValue / totalSize) * 360;
|
|
|
|
// lastly, if we want the midpoint, factor that sucker in
|
|
if (options.midpoint) {
|
|
var currAngle = (currValue / totalSize) * 360;
|
|
angle -= (currAngle / 2);
|
|
}
|
|
|
|
return angle;
|
|
}
|
|
|
|
};
|
|
|
|
//// --------- text.js -----------
|
|
var text = {
|
|
offscreenCoord: -10000,
|
|
|
|
addTitle: function(pie) {
|
|
pie.__title = pie.svg.selectAll("." + pie.cssPrefix + "title")
|
|
.data([pie.options.header.title])
|
|
.enter()
|
|
.append("text")
|
|
.text(function(d) { return d.text; })
|
|
.attr("id", pie.cssPrefix + "title")
|
|
.attr("class", pie.cssPrefix + "title")
|
|
.attr("x", text.offscreenCoord)
|
|
.attr("y", text.offscreenCoord)
|
|
.attr("text-anchor", function() {
|
|
var location;
|
|
if (pie.options.header.location === "top-center" || pie.options.header.location === "pie-center") {
|
|
location = "middle";
|
|
} else {
|
|
location = "left";
|
|
}
|
|
return location;
|
|
})
|
|
.attr("fill", function(d) { return d.color; })
|
|
.style("font-size", function(d) { return d.fontSize + "px"; })
|
|
.style("font-weight", function(d) { return d.fontWeight; })
|
|
.style("font-family", function(d) { return d.font; });
|
|
},
|
|
|
|
positionTitle: function(pie) {
|
|
var textComponents = pie.textComponents;
|
|
var headerLocation = pie.options.header.location;
|
|
var canvasPadding = pie.options.misc.canvasPadding;
|
|
var canvasWidth = pie.options.size.canvasWidth;
|
|
var titleSubtitlePadding = pie.options.header.titleSubtitlePadding;
|
|
|
|
var x;
|
|
if (headerLocation === "top-left") {
|
|
x = canvasPadding.left;
|
|
} else {
|
|
x = ((canvasWidth - canvasPadding.right) / 2) + canvasPadding.left;
|
|
}
|
|
|
|
// add whatever offset has been added by user
|
|
x += pie.options.misc.pieCenterOffset.x;
|
|
|
|
var y = canvasPadding.top + textComponents.title.h;
|
|
|
|
if (headerLocation === "pie-center") {
|
|
y = pie.pieCenter.y;
|
|
|
|
// still not fully correct
|
|
if (textComponents.subtitle.exists) {
|
|
var totalTitleHeight = textComponents.title.h + titleSubtitlePadding + textComponents.subtitle.h;
|
|
y = y - (totalTitleHeight / 2) + textComponents.title.h;
|
|
} else {
|
|
y += (textComponents.title.h / 4);
|
|
}
|
|
}
|
|
|
|
pie.__title
|
|
.attr("x", x)
|
|
.attr("y", y);
|
|
},
|
|
|
|
addSubtitle: function(pie) {
|
|
var headerLocation = pie.options.header.location;
|
|
|
|
pie.__subtitle = pie.svg.selectAll("." + pie.cssPrefix + "subtitle")
|
|
.data([pie.options.header.subtitle])
|
|
.enter()
|
|
.append("text")
|
|
.text(function(d) { return d.text; })
|
|
.attr("x", text.offscreenCoord)
|
|
.attr("y", text.offscreenCoord)
|
|
.attr("id", pie.cssPrefix + "subtitle")
|
|
.attr("class", pie.cssPrefix + "subtitle")
|
|
.attr("text-anchor", function() {
|
|
var location;
|
|
if (headerLocation === "top-center" || headerLocation === "pie-center") {
|
|
location = "middle";
|
|
} else {
|
|
location = "left";
|
|
}
|
|
return location;
|
|
})
|
|
.attr("fill", function(d) { return d.color; })
|
|
.style("font-size", function(d) { return d.fontSize + "px"; })
|
|
.style("font-weight", function(d) { return d.fontWeight; })
|
|
.style("font-family", function(d) { return d.font; });
|
|
},
|
|
|
|
positionSubtitle: function(pie) {
|
|
var canvasPadding = pie.options.misc.canvasPadding;
|
|
var canvasWidth = pie.options.size.canvasWidth;
|
|
|
|
var x;
|
|
if (pie.options.header.location === "top-left") {
|
|
x = canvasPadding.left;
|
|
} else {
|
|
x = ((canvasWidth - canvasPadding.right) / 2) + canvasPadding.left;
|
|
}
|
|
|
|
// add whatever offset has been added by user
|
|
x += pie.options.misc.pieCenterOffset.x;
|
|
|
|
var y = text.getHeaderHeight(pie);
|
|
|
|
pie.__subtitle
|
|
.attr("x", x)
|
|
.attr("y", y);
|
|
},
|
|
|
|
addFooter: function(pie) {
|
|
pie.__footer = pie.svg.selectAll("." + pie.cssPrefix + "footer")
|
|
.data([pie.options.footer])
|
|
.enter()
|
|
.append("text")
|
|
.text(function(d) { return d.text; })
|
|
.attr("x", text.offscreenCoord)
|
|
.attr("y", text.offscreenCoord)
|
|
.attr("id", pie.cssPrefix + "footer")
|
|
.attr("class", pie.cssPrefix + "footer")
|
|
.attr("text-anchor", function() {
|
|
var location = "left";
|
|
if (pie.options.footer.location === "bottom-center") {
|
|
location = "middle";
|
|
} else if (pie.options.footer.location === "bottom-right") {
|
|
location = "left"; // on purpose. We have to change the x-coord to make it properly right-aligned
|
|
}
|
|
return location;
|
|
})
|
|
.attr("fill", function(d) { return d.color; })
|
|
.style("font-size", function(d) { return d.fontSize + "px"; })
|
|
.style("font-weight", function(d) { return d.fontWeight; })
|
|
.style("font-family", function(d) { return d.font; });
|
|
},
|
|
|
|
positionFooter: function(pie) {
|
|
var footerLocation = pie.options.footer.location;
|
|
var footerWidth = pie.textComponents.footer.w;
|
|
var canvasWidth = pie.options.size.canvasWidth;
|
|
var canvasHeight = pie.options.size.canvasHeight;
|
|
var canvasPadding = pie.options.misc.canvasPadding;
|
|
|
|
var x;
|
|
if (footerLocation === "bottom-left") {
|
|
x = canvasPadding.left;
|
|
} else if (footerLocation === "bottom-right") {
|
|
x = canvasWidth - footerWidth - canvasPadding.right;
|
|
} else {
|
|
x = canvasWidth / 2; // TODO - shouldn't this also take into account padding?
|
|
}
|
|
|
|
pie.__footer
|
|
.attr("x", x)
|
|
.attr("y", canvasHeight - canvasPadding.bottom);
|
|
},
|
|
|
|
getHeaderHeight: function(pie) {
|
|
var h;
|
|
if (pie.textComponents.title.exists) {
|
|
|
|
// if the subtitle isn't defined, it'll be set to 0
|
|
var totalTitleHeight = pie.textComponents.title.h + pie.options.header.titleSubtitlePadding + pie.textComponents.subtitle.h;
|
|
if (pie.options.header.location === "pie-center") {
|
|
h = pie.pieCenter.y - (totalTitleHeight / 2) + totalTitleHeight;
|
|
} else {
|
|
h = totalTitleHeight + pie.options.misc.canvasPadding.top;
|
|
}
|
|
} else {
|
|
if (pie.options.header.location === "pie-center") {
|
|
var footerPlusPadding = pie.options.misc.canvasPadding.bottom + pie.textComponents.footer.h;
|
|
h = ((pie.options.size.canvasHeight - footerPlusPadding) / 2) + pie.options.misc.canvasPadding.top + (pie.textComponents.subtitle.h / 2);
|
|
} else {
|
|
h = pie.options.misc.canvasPadding.top + pie.textComponents.subtitle.h;
|
|
}
|
|
}
|
|
return h;
|
|
}
|
|
};
|
|
|
|
//// --------- validate.js -----------
|
|
var tt = {
|
|
addTooltips: function(pie) {
|
|
|
|
// group the label groups (label, percentage, value) into a single element for simpler positioning
|
|
var tooltips = pie.svg.insert("g")
|
|
.attr("class", pie.cssPrefix + "tooltips");
|
|
|
|
tooltips.selectAll("." + pie.cssPrefix + "tooltip")
|
|
.data(pie.options.data.content)
|
|
.enter()
|
|
.append("g")
|
|
.attr("class", pie.cssPrefix + "tooltip")
|
|
.attr("id", function(d, i) { return pie.cssPrefix + "tooltip" + i; })
|
|
.style("opacity", 0)
|
|
.append("rect")
|
|
.attr("rx", pie.options.tooltips.styles.borderRadius)
|
|
.attr("ry", pie.options.tooltips.styles.borderRadius)
|
|
.attr("x", -pie.options.tooltips.styles.padding)
|
|
.attr("opacity", pie.options.tooltips.styles.backgroundOpacity)
|
|
.style("fill", pie.options.tooltips.styles.backgroundColor);
|
|
|
|
tooltips.selectAll("." + pie.cssPrefix + "tooltip")
|
|
.data(pie.options.data.content)
|
|
.append("text")
|
|
.attr("fill", function(d) { return pie.options.tooltips.styles.color; })
|
|
.style("font-size", function(d) { return pie.options.tooltips.styles.fontSize; })
|
|
.style("font-weight", function(d) { return pie.options.tooltips.styles.fontWeight; })
|
|
.style("font-family", function(d) { return pie.options.tooltips.styles.font; })
|
|
.text(function(d, i) {
|
|
var caption = pie.options.tooltips.string;
|
|
if (pie.options.tooltips.type === "caption") {
|
|
caption = d.caption;
|
|
}
|
|
return tt.replacePlaceholders(pie, caption, i, {
|
|
label: d.label,
|
|
value: d.value,
|
|
percentage: d.percentage
|
|
});
|
|
});
|
|
|
|
tooltips.selectAll("." + pie.cssPrefix + "tooltip rect")
|
|
.attr("width", function (d, i) {
|
|
var dims = helpers.getDimensions(pie.cssPrefix + "tooltip" + i);
|
|
return dims.w + (2 * pie.options.tooltips.styles.padding);
|
|
})
|
|
.attr("height", function (d, i) {
|
|
var dims = helpers.getDimensions(pie.cssPrefix + "tooltip" + i);
|
|
return dims.h + (2 * pie.options.tooltips.styles.padding);
|
|
})
|
|
.attr("y", function (d, i) {
|
|
var dims = helpers.getDimensions(pie.cssPrefix + "tooltip" + i);
|
|
return -(dims.h / 2) + 1;
|
|
});
|
|
},
|
|
|
|
showTooltip: function(pie, index) {
|
|
var fadeInSpeed = pie.options.tooltips.styles.fadeInSpeed;
|
|
if (tt.currentTooltip === index) {
|
|
fadeInSpeed = 1;
|
|
}
|
|
|
|
tt.currentTooltip = index;
|
|
d3.select("#" + pie.cssPrefix + "tooltip" + index)
|
|
.transition()
|
|
.duration(fadeInSpeed)
|
|
.style("opacity", function() { return 1; });
|
|
|
|
tt.moveTooltip(pie);
|
|
},
|
|
|
|
moveTooltip: function(pie) {
|
|
d3.selectAll("#" + pie.cssPrefix + "tooltip" + tt.currentTooltip)
|
|
.attr("transform", function(d) {
|
|
var mouseCoords = d3.mouse(this.parentNode);
|
|
var x = mouseCoords[0] + pie.options.tooltips.styles.padding + 2;
|
|
var y = mouseCoords[1] - (2 * pie.options.tooltips.styles.padding) - 2;
|
|
return "translate(" + x + "," + y + ")";
|
|
});
|
|
},
|
|
|
|
hideTooltip: function(pie, index) {
|
|
d3.select("#" + pie.cssPrefix + "tooltip" + index)
|
|
.style("opacity", function() { return 0; });
|
|
|
|
// move the tooltip offscreen. This ensures that when the user next mouseovers the segment the hidden
|
|
// element won't interfere
|
|
d3.select("#" + pie.cssPrefix + "tooltip" + tt.currentTooltip)
|
|
.attr("transform", function(d, i) {
|
|
// klutzy, but it accounts for tooltip padding which could push it onscreen
|
|
var x = pie.options.size.canvasWidth + 1000;
|
|
var y = pie.options.size.canvasHeight + 1000;
|
|
return "translate(" + x + "," + y + ")";
|
|
});
|
|
},
|
|
|
|
replacePlaceholders: function(pie, str, index, replacements) {
|
|
|
|
// if the user has defined a placeholderParser function, call it before doing the replacements
|
|
if (helpers.isFunction(pie.options.tooltips.placeholderParser)) {
|
|
pie.options.tooltips.placeholderParser(index, replacements);
|
|
}
|
|
|
|
var replacer = function() {
|
|
return function(match) {
|
|
var placeholder = arguments[1];
|
|
if (replacements.hasOwnProperty(placeholder)) {
|
|
return replacements[arguments[1]];
|
|
} else {
|
|
return arguments[0];
|
|
}
|
|
};
|
|
};
|
|
return str.replace(/\{(\w+)\}/g, replacer(replacements));
|
|
}
|
|
};
|
|
|
|
|
|
// --------------------------------------------------------------------------------------------
|
|
|
|
// our constructor
|
|
var d3pie = function(element, options) {
|
|
|
|
// element can be an ID or DOM element
|
|
this.element = element;
|
|
if (typeof element === "string") {
|
|
var el = element.replace(/^#/, ""); // replace any jQuery-like ID hash char
|
|
this.element = document.getElementById(el);
|
|
}
|
|
|
|
var opts = {};
|
|
extend(true, opts, defaultSettings, options);
|
|
this.options = opts;
|
|
|
|
// if the user specified a custom CSS element prefix (ID, class), use it
|
|
if (this.options.misc.cssPrefix !== null) {
|
|
this.cssPrefix = this.options.misc.cssPrefix;
|
|
} else {
|
|
this.cssPrefix = "p" + _uniqueIDCounter + "_";
|
|
_uniqueIDCounter++;
|
|
}
|
|
|
|
|
|
// now run some validation on the user-defined info
|
|
if (!validate.initialCheck(this)) {
|
|
return;
|
|
}
|
|
|
|
// add a data-role to the DOM node to let anyone know that it contains a d3pie instance, and the d3pie version
|
|
d3.select(this.element).attr(_scriptName, _version);
|
|
|
|
// things that are done once
|
|
_setupData.call(this);
|
|
_init.call(this);
|
|
};
|
|
|
|
d3pie.prototype.recreate = function() {
|
|
// now run some validation on the user-defined info
|
|
if (!validate.initialCheck(this)) {
|
|
return;
|
|
}
|
|
|
|
_setupData.call(this);
|
|
_init.call(this);
|
|
};
|
|
|
|
d3pie.prototype.redraw = function() {
|
|
this.element.innerHTML = "";
|
|
_init.call(this);
|
|
};
|
|
|
|
d3pie.prototype.destroy = function() {
|
|
this.element.innerHTML = ""; // clear out the SVG
|
|
d3.select(this.element).attr(_scriptName, null); // remove the data attr
|
|
};
|
|
|
|
/**
|
|
* Returns all pertinent info about the current open info. Returns null if nothing's open, or if one is, an object of
|
|
* the following form:
|
|
* {
|
|
* element: DOM NODE,
|
|
* index: N,
|
|
* data: {}
|
|
* }
|
|
*/
|
|
d3pie.prototype.getOpenSegment = function() {
|
|
var segment = this.currentlyOpenSegment;
|
|
if (segment !== null && typeof segment !== "undefined") {
|
|
var index = parseInt(d3.select(segment).attr("data-index"), 10);
|
|
return {
|
|
element: segment,
|
|
index: index,
|
|
data: this.options.data.content[index]
|
|
};
|
|
} else {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
d3pie.prototype.openSegment = function(index) {
|
|
index = parseInt(index, 10);
|
|
if (index < 0 || index > this.options.data.content.length-1) {
|
|
return;
|
|
}
|
|
segments.openSegment(this, d3.select("#" + this.cssPrefix + "segment" + index).node());
|
|
};
|
|
|
|
d3pie.prototype.closeSegment = function() {
|
|
segments.maybeCloseOpenSegment(this);
|
|
};
|
|
|
|
// this let's the user dynamically update aspects of the pie chart without causing a complete redraw. It
|
|
// intelligently re-renders only the part of the pie that the user specifies. Some things cause a repaint, others
|
|
// just redraw the single element
|
|
d3pie.prototype.updateProp = function(propKey, value) {
|
|
switch (propKey) {
|
|
case "header.title.text":
|
|
var oldVal = helpers.processObj(this.options, propKey);
|
|
helpers.processObj(this.options, propKey, value);
|
|
d3.select("#" + this.cssPrefix + "title").html(value);
|
|
if ((oldVal === "" && value !== "") || (oldVal !== "" && value === "")) {
|
|
this.redraw();
|
|
}
|
|
break;
|
|
|
|
case "header.subtitle.text":
|
|
var oldValue = helpers.processObj(this.options, propKey);
|
|
helpers.processObj(this.options, propKey, value);
|
|
d3.select("#" + this.cssPrefix + "subtitle").html(value);
|
|
if ((oldValue === "" && value !== "") || (oldValue !== "" && value === "")) {
|
|
this.redraw();
|
|
}
|
|
break;
|
|
|
|
case "callbacks.onload":
|
|
case "callbacks.onMouseoverSegment":
|
|
case "callbacks.onMouseoutSegment":
|
|
case "callbacks.onClickSegment":
|
|
case "effects.pullOutSegmentOnClick.effect":
|
|
case "effects.pullOutSegmentOnClick.speed":
|
|
case "effects.pullOutSegmentOnClick.size":
|
|
case "effects.highlightSegmentOnMouseover":
|
|
case "effects.highlightLuminosity":
|
|
helpers.processObj(this.options, propKey, value);
|
|
break;
|
|
|
|
// everything else, attempt to update it & do a repaint
|
|
default:
|
|
helpers.processObj(this.options, propKey, value);
|
|
|
|
this.destroy();
|
|
this.recreate();
|
|
break;
|
|
}
|
|
};
|
|
|
|
|
|
// ------------------------------------------------------------------------------------------------
|
|
|
|
var _setupData = function () {
|
|
this.options.data.content = math.sortPieData(this);
|
|
if (this.options.data.smallSegmentGrouping.enabled) {
|
|
this.options.data.content = helpers.applySmallSegmentGrouping(this.options.data.content, this.options.data.smallSegmentGrouping);
|
|
}
|
|
|
|
|
|
this.options.colors = helpers.initSegmentColors(this);
|
|
this.totalSize = math.getTotalPieSize(this.options.data.content);
|
|
|
|
var dp = this.options.labels.percentage.decimalPlaces;
|
|
|
|
// add in percentage data to content
|
|
for (var i=0; i<this.options.data.content.length; i++) {
|
|
this.options.data.content[i].percentage = _getPercentage(this.options.data.content[i].value, this.totalSize, dp);
|
|
}
|
|
|
|
// adjust the final item to ensure the percentage always adds up to precisely 100%. This is necessary
|
|
var totalPercentage = 0;
|
|
for (var j=0; j<this.options.data.content.length; j++) {
|
|
if (j === this.options.data.content.length - 1) {
|
|
this.options.data.content[j].percentage = (100 - totalPercentage).toFixed(dp);
|
|
}
|
|
totalPercentage += parseFloat(this.options.data.content[j].percentage);
|
|
}
|
|
};
|
|
|
|
var _init = function() {
|
|
|
|
// prep-work
|
|
this.svg = helpers.addSVGSpace(this);
|
|
|
|
// store info about the main text components as part of the d3pie object instance
|
|
this.textComponents = {
|
|
headerHeight: 0,
|
|
title: {
|
|
exists: this.options.header.title.text !== "",
|
|
h: 0,
|
|
w: 0
|
|
},
|
|
subtitle: {
|
|
exists: this.options.header.subtitle.text !== "",
|
|
h: 0,
|
|
w: 0
|
|
},
|
|
footer: {
|
|
exists: this.options.footer.text !== "",
|
|
h: 0,
|
|
w: 0
|
|
}
|
|
};
|
|
|
|
this.outerLabelGroupData = [];
|
|
|
|
// add the key text components offscreen (title, subtitle, footer). We need to know their widths/heights for later computation
|
|
if (this.textComponents.title.exists) text.addTitle(this);
|
|
if (this.textComponents.subtitle.exists) text.addSubtitle(this);
|
|
text.addFooter(this);
|
|
|
|
// console.log(this);
|
|
|
|
// the footer never moves. Put it in place now
|
|
var self = this;
|
|
text.positionFooter(self);
|
|
var d3 = helpers.getDimensions(self.__footer.node());
|
|
self.textComponents.footer.h = d3.h;
|
|
self.textComponents.footer.w = d3.w;
|
|
|
|
if (self.textComponents.title.exists) {
|
|
var d1 = helpers.getDimensions(self.__title.node());
|
|
self.textComponents.title.h = d1.h;
|
|
self.textComponents.title.w = d1.w;
|
|
}
|
|
|
|
if (self.textComponents.subtitle.exists) {
|
|
var d2 = helpers.getDimensions(self.__subtitle.node());
|
|
self.textComponents.subtitle.h = d2.h;
|
|
self.textComponents.subtitle.w = d2.w;
|
|
}
|
|
|
|
// now compute the full header height
|
|
if (self.textComponents.title.exists || self.textComponents.subtitle.exists) {
|
|
var headerHeight = 0;
|
|
if (self.textComponents.title.exists) {
|
|
headerHeight += self.textComponents.title.h;
|
|
if (self.textComponents.subtitle.exists) {
|
|
headerHeight += self.options.header.titleSubtitlePadding;
|
|
}
|
|
}
|
|
if (self.textComponents.subtitle.exists) {
|
|
headerHeight += self.textComponents.subtitle.h;
|
|
}
|
|
self.textComponents.headerHeight = headerHeight;
|
|
}
|
|
|
|
// at this point, all main text component dimensions have been calculated
|
|
math.computePieRadius(self);
|
|
|
|
// this value is used all over the place for placing things and calculating locations. We figure it out ONCE
|
|
// and store it as part of the object
|
|
math.calculatePieCenter(self);
|
|
|
|
// position the title and subtitle
|
|
text.positionTitle(self);
|
|
text.positionSubtitle(self);
|
|
|
|
// now create the pie chart segments, and gradients if the user desired
|
|
if (self.options.misc.gradient.enabled) {
|
|
segments.addGradients(self);
|
|
}
|
|
segments.create(self); // also creates this.arc
|
|
|
|
self.__labels = {};
|
|
labels.add(self, "inner", self.options.labels.inner.format);
|
|
labels.add(self, "outer", self.options.labels.outer.format);
|
|
|
|
// position the label elements relatively within their individual group (label, percentage, value)
|
|
labels.positionLabelElements(self, "inner", self.options.labels.inner.format);
|
|
labels.positionLabelElements(self, "outer", self.options.labels.outer.format);
|
|
labels.computeOuterLabelCoords(self);
|
|
|
|
// this is (and should be) dumb. It just places the outer groups at their calculated, collision-free positions
|
|
labels.positionLabelGroups(self, "outer");
|
|
|
|
// we use the label line positions for many other calculations, so ALWAYS compute them
|
|
labels.computeLabelLinePositions(self);
|
|
|
|
// only add them if they're actually enabled
|
|
if (self.options.labels.lines.enabled && self.options.labels.outer.format !== "none") {
|
|
labels.addLabelLines(self);
|
|
}
|
|
|
|
labels.positionLabelGroups(self, "inner");
|
|
|
|
if (helpers.isFunction(self.options.callbacks.onload)) {
|
|
try {
|
|
self.options.callbacks.onload();
|
|
} catch (e) { }
|
|
}
|
|
|
|
// add and position the tooltips
|
|
if (self.options.tooltips.enabled) {
|
|
tt.addTooltips(self);
|
|
}
|
|
|
|
segments.addSegmentEventHandlers(self);
|
|
};
|
|
|
|
var _getPercentage = function(value, total, decimalPlaces) {
|
|
var relativeAmount = value / total;
|
|
if (decimalPlaces <= 0) {
|
|
return Math.round(relativeAmount * 100);
|
|
} else {
|
|
return (relativeAmount * 100).toFixed(decimalPlaces);
|
|
}
|
|
};
|
|
|
|
return d3pie;
|
|
}));
|