scale.radialLinear.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  1. 'use strict';
  2. module.exports = function(Chart) {
  3. var helpers = Chart.helpers;
  4. var globalDefaults = Chart.defaults.global;
  5. var defaultConfig = {
  6. display: true,
  7. // Boolean - Whether to animate scaling the chart from the centre
  8. animate: true,
  9. lineArc: false,
  10. position: 'chartArea',
  11. angleLines: {
  12. display: true,
  13. color: 'rgba(0, 0, 0, 0.1)',
  14. lineWidth: 1
  15. },
  16. // label settings
  17. ticks: {
  18. // Boolean - Show a backdrop to the scale label
  19. showLabelBackdrop: true,
  20. // String - The colour of the label backdrop
  21. backdropColor: 'rgba(255,255,255,0.75)',
  22. // Number - The backdrop padding above & below the label in pixels
  23. backdropPaddingY: 2,
  24. // Number - The backdrop padding to the side of the label in pixels
  25. backdropPaddingX: 2,
  26. callback: Chart.Ticks.formatters.linear
  27. },
  28. pointLabels: {
  29. // Number - Point label font size in pixels
  30. fontSize: 10,
  31. // Function - Used to convert point labels
  32. callback: function(label) {
  33. return label;
  34. }
  35. }
  36. };
  37. var LinearRadialScale = Chart.LinearScaleBase.extend({
  38. getValueCount: function() {
  39. return this.chart.data.labels.length;
  40. },
  41. setDimensions: function() {
  42. var me = this;
  43. var opts = me.options;
  44. var tickOpts = opts.ticks;
  45. // Set the unconstrained dimension before label rotation
  46. me.width = me.maxWidth;
  47. me.height = me.maxHeight;
  48. me.xCenter = Math.round(me.width / 2);
  49. me.yCenter = Math.round(me.height / 2);
  50. var minSize = helpers.min([me.height, me.width]);
  51. var tickFontSize = helpers.getValueOrDefault(tickOpts.fontSize, globalDefaults.defaultFontSize);
  52. me.drawingArea = opts.display ? (minSize / 2) - (tickFontSize / 2 + tickOpts.backdropPaddingY) : (minSize / 2);
  53. },
  54. determineDataLimits: function() {
  55. var me = this;
  56. var chart = me.chart;
  57. me.min = null;
  58. me.max = null;
  59. helpers.each(chart.data.datasets, function(dataset, datasetIndex) {
  60. if (chart.isDatasetVisible(datasetIndex)) {
  61. var meta = chart.getDatasetMeta(datasetIndex);
  62. helpers.each(dataset.data, function(rawValue, index) {
  63. var value = +me.getRightValue(rawValue);
  64. if (isNaN(value) || meta.data[index].hidden) {
  65. return;
  66. }
  67. if (me.min === null) {
  68. me.min = value;
  69. } else if (value < me.min) {
  70. me.min = value;
  71. }
  72. if (me.max === null) {
  73. me.max = value;
  74. } else if (value > me.max) {
  75. me.max = value;
  76. }
  77. });
  78. }
  79. });
  80. // Common base implementation to handle ticks.min, ticks.max, ticks.beginAtZero
  81. me.handleTickRangeOptions();
  82. },
  83. getTickLimit: function() {
  84. var tickOpts = this.options.ticks;
  85. var tickFontSize = helpers.getValueOrDefault(tickOpts.fontSize, globalDefaults.defaultFontSize);
  86. return Math.min(tickOpts.maxTicksLimit ? tickOpts.maxTicksLimit : 11, Math.ceil(this.drawingArea / (1.5 * tickFontSize)));
  87. },
  88. convertTicksToLabels: function() {
  89. var me = this;
  90. Chart.LinearScaleBase.prototype.convertTicksToLabels.call(me);
  91. // Point labels
  92. me.pointLabels = me.chart.data.labels.map(me.options.pointLabels.callback, me);
  93. },
  94. getLabelForIndex: function(index, datasetIndex) {
  95. return +this.getRightValue(this.chart.data.datasets[datasetIndex].data[index]);
  96. },
  97. fit: function() {
  98. /*
  99. * Right, this is really confusing and there is a lot of maths going on here
  100. * The gist of the problem is here: https://gist.github.com/nnnick/696cc9c55f4b0beb8fe9
  101. *
  102. * Reaction: https://dl.dropboxusercontent.com/u/34601363/toomuchscience.gif
  103. *
  104. * Solution:
  105. *
  106. * We assume the radius of the polygon is half the size of the canvas at first
  107. * at each index we check if the text overlaps.
  108. *
  109. * Where it does, we store that angle and that index.
  110. *
  111. * After finding the largest index and angle we calculate how much we need to remove
  112. * from the shape radius to move the point inwards by that x.
  113. *
  114. * We average the left and right distances to get the maximum shape radius that can fit in the box
  115. * along with labels.
  116. *
  117. * Once we have that, we can find the centre point for the chart, by taking the x text protrusion
  118. * on each side, removing that from the size, halving it and adding the left x protrusion width.
  119. *
  120. * This will mean we have a shape fitted to the canvas, as large as it can be with the labels
  121. * and position it in the most space efficient manner
  122. *
  123. * https://dl.dropboxusercontent.com/u/34601363/yeahscience.gif
  124. */
  125. var pointLabels = this.options.pointLabels;
  126. var pointLabelFontSize = helpers.getValueOrDefault(pointLabels.fontSize, globalDefaults.defaultFontSize);
  127. var pointLabeFontStyle = helpers.getValueOrDefault(pointLabels.fontStyle, globalDefaults.defaultFontStyle);
  128. var pointLabeFontFamily = helpers.getValueOrDefault(pointLabels.fontFamily, globalDefaults.defaultFontFamily);
  129. var pointLabeFont = helpers.fontString(pointLabelFontSize, pointLabeFontStyle, pointLabeFontFamily);
  130. // Get maximum radius of the polygon. Either half the height (minus the text width) or half the width.
  131. // Use this to calculate the offset + change. - Make sure L/R protrusion is at least 0 to stop issues with centre points
  132. var largestPossibleRadius = helpers.min([(this.height / 2 - pointLabelFontSize - 5), this.width / 2]),
  133. pointPosition,
  134. i,
  135. textWidth,
  136. halfTextWidth,
  137. furthestRight = this.width,
  138. furthestRightIndex,
  139. furthestRightAngle,
  140. furthestLeft = 0,
  141. furthestLeftIndex,
  142. furthestLeftAngle,
  143. xProtrusionLeft,
  144. xProtrusionRight,
  145. radiusReductionRight,
  146. radiusReductionLeft;
  147. this.ctx.font = pointLabeFont;
  148. for (i = 0; i < this.getValueCount(); i++) {
  149. // 5px to space the text slightly out - similar to what we do in the draw function.
  150. pointPosition = this.getPointPosition(i, largestPossibleRadius);
  151. textWidth = this.ctx.measureText(this.pointLabels[i] ? this.pointLabels[i] : '').width + 5;
  152. // Add quarter circle to make degree 0 mean top of circle
  153. var angleRadians = this.getIndexAngle(i) + (Math.PI / 2);
  154. var angle = (angleRadians * 360 / (2 * Math.PI)) % 360;
  155. if (angle === 0 || angle === 180) {
  156. // At angle 0 and 180, we're at exactly the top/bottom
  157. // of the radar chart, so text will be aligned centrally, so we'll half it and compare
  158. // w/left and right text sizes
  159. halfTextWidth = textWidth / 2;
  160. if (pointPosition.x + halfTextWidth > furthestRight) {
  161. furthestRight = pointPosition.x + halfTextWidth;
  162. furthestRightIndex = i;
  163. }
  164. if (pointPosition.x - halfTextWidth < furthestLeft) {
  165. furthestLeft = pointPosition.x - halfTextWidth;
  166. furthestLeftIndex = i;
  167. }
  168. } else if (angle < 180) {
  169. // Less than half the values means we'll left align the text
  170. if (pointPosition.x + textWidth > furthestRight) {
  171. furthestRight = pointPosition.x + textWidth;
  172. furthestRightIndex = i;
  173. }
  174. // More than half the values means we'll right align the text
  175. } else if (pointPosition.x - textWidth < furthestLeft) {
  176. furthestLeft = pointPosition.x - textWidth;
  177. furthestLeftIndex = i;
  178. }
  179. }
  180. xProtrusionLeft = furthestLeft;
  181. xProtrusionRight = Math.ceil(furthestRight - this.width);
  182. furthestRightAngle = this.getIndexAngle(furthestRightIndex);
  183. furthestLeftAngle = this.getIndexAngle(furthestLeftIndex);
  184. radiusReductionRight = xProtrusionRight / Math.sin(furthestRightAngle + Math.PI / 2);
  185. radiusReductionLeft = xProtrusionLeft / Math.sin(furthestLeftAngle + Math.PI / 2);
  186. // Ensure we actually need to reduce the size of the chart
  187. radiusReductionRight = (helpers.isNumber(radiusReductionRight)) ? radiusReductionRight : 0;
  188. radiusReductionLeft = (helpers.isNumber(radiusReductionLeft)) ? radiusReductionLeft : 0;
  189. this.drawingArea = Math.round(largestPossibleRadius - (radiusReductionLeft + radiusReductionRight) / 2);
  190. this.setCenterPoint(radiusReductionLeft, radiusReductionRight);
  191. },
  192. setCenterPoint: function(leftMovement, rightMovement) {
  193. var me = this;
  194. var maxRight = me.width - rightMovement - me.drawingArea,
  195. maxLeft = leftMovement + me.drawingArea;
  196. me.xCenter = Math.round(((maxLeft + maxRight) / 2) + me.left);
  197. // Always vertically in the centre as the text height doesn't change
  198. me.yCenter = Math.round((me.height / 2) + me.top);
  199. },
  200. getIndexAngle: function(index) {
  201. var angleMultiplier = (Math.PI * 2) / this.getValueCount();
  202. var startAngle = this.chart.options && this.chart.options.startAngle ?
  203. this.chart.options.startAngle :
  204. 0;
  205. var startAngleRadians = startAngle * Math.PI * 2 / 360;
  206. // Start from the top instead of right, so remove a quarter of the circle
  207. return index * angleMultiplier - (Math.PI / 2) + startAngleRadians;
  208. },
  209. getDistanceFromCenterForValue: function(value) {
  210. var me = this;
  211. if (value === null) {
  212. return 0; // null always in center
  213. }
  214. // Take into account half font size + the yPadding of the top value
  215. var scalingFactor = me.drawingArea / (me.max - me.min);
  216. if (me.options.reverse) {
  217. return (me.max - value) * scalingFactor;
  218. }
  219. return (value - me.min) * scalingFactor;
  220. },
  221. getPointPosition: function(index, distanceFromCenter) {
  222. var me = this;
  223. var thisAngle = me.getIndexAngle(index);
  224. return {
  225. x: Math.round(Math.cos(thisAngle) * distanceFromCenter) + me.xCenter,
  226. y: Math.round(Math.sin(thisAngle) * distanceFromCenter) + me.yCenter
  227. };
  228. },
  229. getPointPositionForValue: function(index, value) {
  230. return this.getPointPosition(index, this.getDistanceFromCenterForValue(value));
  231. },
  232. getBasePosition: function() {
  233. var me = this;
  234. var min = me.min;
  235. var max = me.max;
  236. return me.getPointPositionForValue(0,
  237. me.beginAtZero? 0:
  238. min < 0 && max < 0? max :
  239. min > 0 && max > 0? min :
  240. 0);
  241. },
  242. draw: function() {
  243. var me = this;
  244. var opts = me.options;
  245. var gridLineOpts = opts.gridLines;
  246. var tickOpts = opts.ticks;
  247. var angleLineOpts = opts.angleLines;
  248. var pointLabelOpts = opts.pointLabels;
  249. var getValueOrDefault = helpers.getValueOrDefault;
  250. if (opts.display) {
  251. var ctx = me.ctx;
  252. // Tick Font
  253. var tickFontSize = getValueOrDefault(tickOpts.fontSize, globalDefaults.defaultFontSize);
  254. var tickFontStyle = getValueOrDefault(tickOpts.fontStyle, globalDefaults.defaultFontStyle);
  255. var tickFontFamily = getValueOrDefault(tickOpts.fontFamily, globalDefaults.defaultFontFamily);
  256. var tickLabelFont = helpers.fontString(tickFontSize, tickFontStyle, tickFontFamily);
  257. helpers.each(me.ticks, function(label, index) {
  258. // Don't draw a centre value (if it is minimum)
  259. if (index > 0 || opts.reverse) {
  260. var yCenterOffset = me.getDistanceFromCenterForValue(me.ticksAsNumbers[index]);
  261. var yHeight = me.yCenter - yCenterOffset;
  262. // Draw circular lines around the scale
  263. if (gridLineOpts.display && index !== 0) {
  264. ctx.strokeStyle = helpers.getValueAtIndexOrDefault(gridLineOpts.color, index - 1);
  265. ctx.lineWidth = helpers.getValueAtIndexOrDefault(gridLineOpts.lineWidth, index - 1);
  266. if (opts.lineArc) {
  267. // Draw circular arcs between the points
  268. ctx.beginPath();
  269. ctx.arc(me.xCenter, me.yCenter, yCenterOffset, 0, Math.PI * 2);
  270. ctx.closePath();
  271. ctx.stroke();
  272. } else {
  273. // Draw straight lines connecting each index
  274. ctx.beginPath();
  275. for (var i = 0; i < me.getValueCount(); i++) {
  276. var pointPosition = me.getPointPosition(i, yCenterOffset);
  277. if (i === 0) {
  278. ctx.moveTo(pointPosition.x, pointPosition.y);
  279. } else {
  280. ctx.lineTo(pointPosition.x, pointPosition.y);
  281. }
  282. }
  283. ctx.closePath();
  284. ctx.stroke();
  285. }
  286. }
  287. if (tickOpts.display) {
  288. var tickFontColor = getValueOrDefault(tickOpts.fontColor, globalDefaults.defaultFontColor);
  289. ctx.font = tickLabelFont;
  290. if (tickOpts.showLabelBackdrop) {
  291. var labelWidth = ctx.measureText(label).width;
  292. ctx.fillStyle = tickOpts.backdropColor;
  293. ctx.fillRect(
  294. me.xCenter - labelWidth / 2 - tickOpts.backdropPaddingX,
  295. yHeight - tickFontSize / 2 - tickOpts.backdropPaddingY,
  296. labelWidth + tickOpts.backdropPaddingX * 2,
  297. tickFontSize + tickOpts.backdropPaddingY * 2
  298. );
  299. }
  300. ctx.textAlign = 'center';
  301. ctx.textBaseline = 'middle';
  302. ctx.fillStyle = tickFontColor;
  303. ctx.fillText(label, me.xCenter, yHeight);
  304. }
  305. }
  306. });
  307. if (!opts.lineArc) {
  308. ctx.lineWidth = angleLineOpts.lineWidth;
  309. ctx.strokeStyle = angleLineOpts.color;
  310. var outerDistance = me.getDistanceFromCenterForValue(opts.reverse ? me.min : me.max);
  311. // Point Label Font
  312. var pointLabelFontSize = getValueOrDefault(pointLabelOpts.fontSize, globalDefaults.defaultFontSize);
  313. var pointLabeFontStyle = getValueOrDefault(pointLabelOpts.fontStyle, globalDefaults.defaultFontStyle);
  314. var pointLabeFontFamily = getValueOrDefault(pointLabelOpts.fontFamily, globalDefaults.defaultFontFamily);
  315. var pointLabeFont = helpers.fontString(pointLabelFontSize, pointLabeFontStyle, pointLabeFontFamily);
  316. for (var i = me.getValueCount() - 1; i >= 0; i--) {
  317. if (angleLineOpts.display) {
  318. var outerPosition = me.getPointPosition(i, outerDistance);
  319. ctx.beginPath();
  320. ctx.moveTo(me.xCenter, me.yCenter);
  321. ctx.lineTo(outerPosition.x, outerPosition.y);
  322. ctx.stroke();
  323. ctx.closePath();
  324. }
  325. // Extra 3px out for some label spacing
  326. var pointLabelPosition = me.getPointPosition(i, outerDistance + 5);
  327. // Keep this in loop since we may support array properties here
  328. var pointLabelFontColor = getValueOrDefault(pointLabelOpts.fontColor, globalDefaults.defaultFontColor);
  329. ctx.font = pointLabeFont;
  330. ctx.fillStyle = pointLabelFontColor;
  331. var pointLabels = me.pointLabels;
  332. // Add quarter circle to make degree 0 mean top of circle
  333. var angleRadians = this.getIndexAngle(i) + (Math.PI / 2);
  334. var angle = (angleRadians * 360 / (2 * Math.PI)) % 360;
  335. if (angle === 0 || angle === 180) {
  336. ctx.textAlign = 'center';
  337. } else if (angle < 180) {
  338. ctx.textAlign = 'left';
  339. } else {
  340. ctx.textAlign = 'right';
  341. }
  342. // Set the correct text baseline based on outer positioning
  343. if (angle === 90 || angle === 270) {
  344. ctx.textBaseline = 'middle';
  345. } else if (angle > 270 || angle < 90) {
  346. ctx.textBaseline = 'bottom';
  347. } else {
  348. ctx.textBaseline = 'top';
  349. }
  350. ctx.fillText(pointLabels[i] ? pointLabels[i] : '', pointLabelPosition.x, pointLabelPosition.y);
  351. }
  352. }
  353. }
  354. }
  355. });
  356. Chart.scaleService.registerScaleType('radialLinear', LinearRadialScale, defaultConfig);
  357. };