You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

554 lines
20 KiB

9 years ago
  1. /**
  2. * This is an experimental Highcharts module that draws long data series on a canvas
  3. * in order to increase performance of the initial load time and tooltip responsiveness.
  4. *
  5. * Compatible with HTML5 canvas compatible browsers (not IE < 9).
  6. *
  7. * Author: Torstein Honsi
  8. *
  9. *
  10. * Development plan
  11. * - Column range.
  12. * - Heatmap.
  13. * - Treemap.
  14. * - Check how it works with Highstock and data grouping.
  15. * - Check inverted charts.
  16. * - Check reversed axes.
  17. * - Chart callback should be async after last series is drawn. (But not necessarily, we don't do
  18. that with initial series animation).
  19. * - Cache full-size image so we don't have to redraw on hide/show and zoom up. But k-d-tree still
  20. * needs to be built.
  21. * - Test IE9 and IE10.
  22. * - Stacking is not perhaps not correct since it doesn't use the translation given in
  23. * the translate method. If this gets to complicated, a possible way out would be to
  24. * have a simplified renderCanvas method that simply draws the areaPath on a canvas.
  25. *
  26. * If this module is taken in as part of the core
  27. * - All the loading logic should be merged with core. Update styles in the core.
  28. * - Most of the method wraps should probably be added directly in parent methods.
  29. *
  30. * Notes for boost mode
  31. * - Area lines are not drawn
  32. * - Point markers are not drawn
  33. * - Zones and negativeColor don't work
  34. * - Columns are always one pixel wide. Don't set the threshold too low.
  35. *
  36. * Optimizing tips for users
  37. * - For scatter plots, use a marker.radius of 1 or less. It results in a rectangle being drawn, which is
  38. * considerably faster than a circle.
  39. * - Set extremes (min, max) explicitly on the axes in order for Highcharts to avoid computing extremes.
  40. * - Set enableMouseTracking to false on the series to improve total rendering time.
  41. * - The default threshold is set based on one series. If you have multiple, dense series, the combined
  42. * number of points drawn gets higher, and you may want to set the threshold lower in order to
  43. * use optimizations.
  44. */
  45. /*global document, Highcharts, HighchartsAdapter, setTimeout */
  46. (function (H, HA) {
  47. 'use strict';
  48. var noop = function () { return undefined; },
  49. Color = H.Color,
  50. Series = H.Series,
  51. seriesTypes = H.seriesTypes,
  52. each = H.each,
  53. extend = H.extend,
  54. addEvent = HA.addEvent,
  55. fireEvent = HA.fireEvent,
  56. merge = H.merge,
  57. pick = H.pick,
  58. wrap = H.wrap,
  59. plotOptions = H.getOptions().plotOptions,
  60. CHUNK_SIZE = 50000;
  61. function eachAsync(arr, fn, callback, chunkSize, i) {
  62. i = i || 0;
  63. chunkSize = chunkSize || CHUNK_SIZE;
  64. each(arr.slice(i, i + chunkSize), fn);
  65. if (i + chunkSize < arr.length) {
  66. setTimeout(function () {
  67. eachAsync(arr, fn, callback, chunkSize, i + chunkSize);
  68. });
  69. } else if (callback) {
  70. callback();
  71. }
  72. }
  73. // Set default options
  74. each(['area', 'arearange', 'column', 'line', 'scatter'], function (type) {
  75. if (plotOptions[type]) {
  76. plotOptions[type].boostThreshold = 5000;
  77. }
  78. });
  79. /**
  80. * Override a bunch of methods the same way. If the number of points is below the threshold,
  81. * run the original method. If not, check for a canvas version or do nothing.
  82. */
  83. each(['translate', 'generatePoints', 'drawTracker', 'drawPoints', 'render'], function (method) {
  84. function branch(proceed) {
  85. var letItPass = this.options.stacking && (method === 'translate' || method === 'generatePoints');
  86. if ((this.processedXData || this.options.data).length < (this.options.boostThreshold || Number.MAX_VALUE) ||
  87. letItPass) {
  88. // Clear image
  89. if (method === 'render' && this.image) {
  90. this.image.attr({ href: '' });
  91. this.animate = null; // We're zooming in, don't run animation
  92. }
  93. proceed.call(this);
  94. // If a canvas version of the method exists, like renderCanvas(), run
  95. } else if (this[method + 'Canvas']) {
  96. this[method + 'Canvas']();
  97. }
  98. }
  99. wrap(Series.prototype, method, branch);
  100. // A special case for some types - its translate method is already wrapped
  101. if (method === 'translate') {
  102. if (seriesTypes.column) {
  103. wrap(seriesTypes.column.prototype, method, branch);
  104. }
  105. if (seriesTypes.arearange) {
  106. wrap(seriesTypes.arearange.prototype, method, branch);
  107. }
  108. }
  109. });
  110. /**
  111. * Do not compute extremes when min and max are set.
  112. * If we use this in the core, we can add the hook to hasExtremes to the methods directly.
  113. */
  114. wrap(Series.prototype, 'getExtremes', function (proceed) {
  115. if (!this.hasExtremes()) {
  116. proceed.apply(this, Array.prototype.slice.call(arguments, 1));
  117. }
  118. });
  119. wrap(Series.prototype, 'setData', function (proceed) {
  120. if (!this.hasExtremes(true)) {
  121. proceed.apply(this, Array.prototype.slice.call(arguments, 1));
  122. }
  123. });
  124. wrap(Series.prototype, 'processData', function (proceed) {
  125. if (!this.hasExtremes(true)) {
  126. proceed.apply(this, Array.prototype.slice.call(arguments, 1));
  127. }
  128. });
  129. H.extend(Series.prototype, {
  130. pointRange: 0,
  131. hasExtremes: function (checkX) {
  132. var options = this.options,
  133. data = options.data,
  134. xAxis = this.xAxis.options,
  135. yAxis = this.yAxis.options;
  136. return data.length > (options.boostThreshold || Number.MAX_VALUE) && typeof yAxis.min === 'number' && typeof yAxis.max === 'number' &&
  137. (!checkX || (typeof xAxis.min === 'number' && typeof xAxis.max === 'number'));
  138. },
  139. /**
  140. * If implemented in the core, parts of this can probably be shared with other similar
  141. * methods in Highcharts.
  142. */
  143. destroyGraphics: function () {
  144. var series = this,
  145. points = this.points,
  146. point,
  147. i;
  148. for (i = 0; i < points.length; i = i + 1) {
  149. point = points[i];
  150. if (point && point.graphic) {
  151. point.graphic = point.graphic.destroy();
  152. }
  153. }
  154. each(['graph', 'area'], function (prop) {
  155. if (series[prop]) {
  156. series[prop] = series[prop].destroy();
  157. }
  158. });
  159. },
  160. /**
  161. * Create a hidden canvas to draw the graph on. The contents is later copied over
  162. * to an SVG image element.
  163. */
  164. getContext: function () {
  165. var width = this.chart.plotWidth,
  166. height = this.chart.plotHeight;
  167. if (!this.canvas) {
  168. this.canvas = document.createElement('canvas');
  169. this.image = this.chart.renderer.image('', 0, 0, width, height).add(this.group);
  170. this.ctx = this.canvas.getContext('2d');
  171. } else {
  172. this.ctx.clearRect(0, 0, width, height);
  173. }
  174. this.canvas.setAttribute('width', width);
  175. this.canvas.setAttribute('height', height);
  176. this.image.attr({
  177. width: width,
  178. height: height
  179. });
  180. return this.ctx;
  181. },
  182. /**
  183. * Draw the canvas image inside an SVG image
  184. */
  185. canvasToSVG: function () {
  186. this.image.attr({ href: this.canvas.toDataURL('image/png') });
  187. },
  188. cvsLineTo: function (ctx, clientX, plotY) {
  189. ctx.lineTo(clientX, plotY);
  190. },
  191. renderCanvas: function () {
  192. var series = this,
  193. options = series.options,
  194. chart = series.chart,
  195. xAxis = this.xAxis,
  196. yAxis = this.yAxis,
  197. ctx,
  198. i,
  199. c = 0,
  200. xData = series.processedXData,
  201. yData = series.processedYData,
  202. rawData = options.data,
  203. xExtremes = xAxis.getExtremes(),
  204. xMin = xExtremes.min,
  205. xMax = xExtremes.max,
  206. yExtremes = yAxis.getExtremes(),
  207. yMin = yExtremes.min,
  208. yMax = yExtremes.max,
  209. pointTaken = {},
  210. lastClientX,
  211. sampling = !!series.sampling,
  212. points,
  213. r = options.marker && options.marker.radius,
  214. cvsDrawPoint = this.cvsDrawPoint,
  215. cvsLineTo = options.lineWidth ? this.cvsLineTo : false,
  216. cvsMarker = r <= 1 ? this.cvsMarkerSquare : this.cvsMarkerCircle,
  217. enableMouseTracking = options.enableMouseTracking !== false,
  218. lastPoint,
  219. threshold = options.threshold,
  220. yBottom = yAxis.getThreshold(threshold),
  221. hasThreshold = typeof threshold === 'number',
  222. translatedThreshold = yBottom,
  223. doFill = this.fill,
  224. isRange = series.pointArrayMap && series.pointArrayMap.join(',') === 'low,high',
  225. isStacked = !!options.stacking,
  226. cropStart = series.cropStart || 0,
  227. loadingOptions = chart.options.loading,
  228. requireSorting = series.requireSorting,
  229. wasNull,
  230. connectNulls = options.connectNulls,
  231. useRaw = !xData,
  232. minVal,
  233. maxVal,
  234. minI,
  235. maxI,
  236. fillColor = series.fillOpacity ?
  237. new Color(series.color).setOpacity(pick(options.fillOpacity, 0.75)).get() :
  238. series.color,
  239. stroke = function () {
  240. if (doFill) {
  241. ctx.fillStyle = fillColor;
  242. ctx.fill();
  243. } else {
  244. ctx.strokeStyle = series.color;
  245. ctx.lineWidth = options.lineWidth;
  246. ctx.stroke();
  247. }
  248. },
  249. drawPoint = function (clientX, plotY, yBottom) {
  250. if (c === 0) {
  251. ctx.beginPath();
  252. }
  253. if (wasNull) {
  254. ctx.moveTo(clientX, plotY);
  255. } else {
  256. if (cvsDrawPoint) {
  257. cvsDrawPoint(ctx, clientX, plotY, yBottom, lastPoint);
  258. } else if (cvsLineTo) {
  259. cvsLineTo(ctx, clientX, plotY);
  260. } else if (cvsMarker) {
  261. cvsMarker(ctx, clientX, plotY, r);
  262. }
  263. }
  264. // We need to stroke the line for every 1000 pixels. It will crash the browser
  265. // memory use if we stroke too infrequently.
  266. c = c + 1;
  267. if (c === 1000) {
  268. stroke();
  269. c = 0;
  270. }
  271. // Area charts need to keep track of the last point
  272. lastPoint = {
  273. clientX: clientX,
  274. plotY: plotY,
  275. yBottom: yBottom
  276. };
  277. },
  278. addKDPoint = function (clientX, plotY, i) {
  279. // The k-d tree requires series points. Reduce the amount of points, since the time to build the
  280. // tree increases exponentially.
  281. if (enableMouseTracking && !pointTaken[clientX + ',' + plotY]) {
  282. points.push({
  283. clientX: clientX,
  284. plotX: clientX,
  285. plotY: plotY,
  286. i: cropStart + i
  287. });
  288. pointTaken[clientX + ',' + plotY] = true;
  289. }
  290. };
  291. // If we are zooming out from SVG mode, destroy the graphics
  292. if (this.points) {
  293. this.destroyGraphics();
  294. }
  295. // The group
  296. series.plotGroup(
  297. 'group',
  298. 'series',
  299. series.visible ? 'visible' : 'hidden',
  300. options.zIndex,
  301. chart.seriesGroup
  302. );
  303. series.getAttribs();
  304. series.markerGroup = series.group;
  305. addEvent(series, 'destroy', function () {
  306. series.markerGroup = null;
  307. });
  308. points = this.points = [];
  309. ctx = this.getContext();
  310. series.buildKDTree = noop; // Do not start building while drawing
  311. // Display a loading indicator
  312. if (rawData.length > 99999) {
  313. chart.options.loading = merge(loadingOptions, {
  314. labelStyle: {
  315. backgroundColor: 'rgba(255,255,255,0.75)',
  316. padding: '1em',
  317. borderRadius: '0.5em'
  318. },
  319. style: {
  320. backgroundColor: 'none',
  321. opacity: 1
  322. }
  323. });
  324. chart.showLoading('Drawing...');
  325. chart.options.loading = loadingOptions; // reset
  326. if (chart.loadingShown === true) {
  327. chart.loadingShown = 1;
  328. } else {
  329. chart.loadingShown = chart.loadingShown + 1;
  330. }
  331. }
  332. // Loop over the points
  333. i = 0;
  334. eachAsync(isStacked ? series.data : (xData || rawData), function (d) {
  335. var x,
  336. y,
  337. clientX,
  338. plotY,
  339. isNull,
  340. low,
  341. isYInside = true;
  342. if (useRaw) {
  343. x = d[0];
  344. y = d[1];
  345. } else {
  346. x = d;
  347. y = yData[i];
  348. }
  349. // Resolve low and high for range series
  350. if (isRange) {
  351. if (useRaw) {
  352. y = d.slice(1, 3);
  353. }
  354. low = y[0];
  355. y = y[1];
  356. } else if (isStacked) {
  357. x = d.x;
  358. y = d.stackY;
  359. low = y - d.y;
  360. }
  361. isNull = y === null;
  362. // Optimize for scatter zooming
  363. if (!requireSorting) {
  364. isYInside = y >= yMin && y <= yMax;
  365. }
  366. if (!isNull && x >= xMin && x <= xMax && isYInside) {
  367. clientX = Math.round(xAxis.toPixels(x, true));
  368. if (sampling) {
  369. if (minI === undefined || clientX === lastClientX) {
  370. if (!isRange) {
  371. low = y;
  372. }
  373. if (maxI === undefined || y > maxVal) {
  374. maxVal = y;
  375. maxI = i;
  376. }
  377. if (minI === undefined || low < minVal) {
  378. minVal = low;
  379. minI = i;
  380. }
  381. }
  382. if (clientX !== lastClientX) { // Add points and reset
  383. if (minI !== undefined) { // then maxI is also a number
  384. plotY = yAxis.toPixels(maxVal, true);
  385. yBottom = yAxis.toPixels(minVal, true);
  386. drawPoint(
  387. clientX,
  388. hasThreshold ? Math.min(plotY, translatedThreshold) : plotY,
  389. hasThreshold ? Math.max(yBottom, translatedThreshold) : yBottom
  390. );
  391. addKDPoint(clientX, plotY, maxI);
  392. if (yBottom !== plotY) {
  393. addKDPoint(clientX, yBottom, minI);
  394. }
  395. }
  396. minI = maxI = undefined;
  397. lastClientX = clientX;
  398. }
  399. } else {
  400. plotY = Math.round(yAxis.toPixels(y, true));
  401. drawPoint(clientX, plotY, yBottom);
  402. addKDPoint(clientX, plotY, i);
  403. }
  404. }
  405. wasNull = isNull && !connectNulls;
  406. i = i + 1;
  407. if (i % CHUNK_SIZE === 0) {
  408. series.canvasToSVG();
  409. }
  410. }, function () {
  411. var loadingDiv = chart.loadingDiv,
  412. loadingShown = +chart.loadingShown;
  413. stroke();
  414. series.canvasToSVG();
  415. fireEvent(series, 'renderedCanvas');
  416. // Do not use chart.hideLoading, as it runs JS animation and will be blocked by buildKDTree.
  417. // CSS animation looks good, but then it must be deleted in timeout. If we add the module to core,
  418. // change hideLoading so we can skip this block.
  419. if (loadingShown === 1) {
  420. extend(loadingDiv.style, {
  421. transition: 'opacity 250ms',
  422. opacity: 0
  423. });
  424. chart.loadingShown = false;
  425. setTimeout(function () {
  426. if (loadingDiv.parentNode) { // In exporting it is falsy
  427. loadingDiv.parentNode.removeChild(loadingDiv);
  428. }
  429. chart.loadingDiv = chart.loadingSpan = null;
  430. }, 250);
  431. }
  432. if (loadingShown) {
  433. chart.loadingShown = loadingShown - 1;
  434. }
  435. // Pass tests in Pointer.
  436. // TODO: Replace this with a single property, and replace when zooming in
  437. // below boostThreshold.
  438. series.directTouch = false;
  439. series.options.stickyTracking = true;
  440. delete series.buildKDTree; // Go back to prototype, ready to build
  441. series.buildKDTree();
  442. // Don't do async on export, the exportChart, getSVGForExport and getSVG methods are not chained for it.
  443. }, chart.renderer.forExport ? Number.MAX_VALUE : undefined);
  444. }
  445. });
  446. seriesTypes.scatter.prototype.cvsMarkerCircle = function (ctx, clientX, plotY, r) {
  447. ctx.moveTo(clientX, plotY);
  448. ctx.arc(clientX, plotY, r, 0, 2 * Math.PI, false);
  449. };
  450. // Rect is twice as fast as arc, should be used for small markers
  451. seriesTypes.scatter.prototype.cvsMarkerSquare = function (ctx, clientX, plotY, r) {
  452. ctx.moveTo(clientX, plotY);
  453. ctx.rect(clientX - r, plotY - r, r * 2, r * 2);
  454. };
  455. seriesTypes.scatter.prototype.fill = true;
  456. extend(seriesTypes.area.prototype, {
  457. cvsDrawPoint: function (ctx, clientX, plotY, yBottom, lastPoint) {
  458. if (lastPoint && clientX !== lastPoint.clientX) {
  459. ctx.moveTo(lastPoint.clientX, lastPoint.yBottom);
  460. ctx.lineTo(lastPoint.clientX, lastPoint.plotY);
  461. ctx.lineTo(clientX, plotY);
  462. ctx.lineTo(clientX, yBottom);
  463. }
  464. },
  465. fill: true,
  466. fillOpacity: true,
  467. sampling: true
  468. });
  469. extend(seriesTypes.column.prototype, {
  470. cvsDrawPoint: function (ctx, clientX, plotY, yBottom) {
  471. ctx.rect(clientX - 1, plotY, 1, yBottom - plotY);
  472. },
  473. fill: true,
  474. sampling: true
  475. });
  476. /**
  477. * Return a point instance from the k-d-tree
  478. */
  479. wrap(Series.prototype, 'searchPoint', function (proceed, e) {
  480. var point = proceed.call(this, e),
  481. ret = point;
  482. if (point && !(point instanceof this.pointClass)) {
  483. ret = (new this.pointClass()).init(this, this.options.data[point.i]);
  484. ret.dist = point.dist;
  485. ret.category = ret.x;
  486. ret.plotX = point.plotX;
  487. ret.plotY = point.plotY;
  488. }
  489. return ret;
  490. });
  491. }(Highcharts, HighchartsAdapter));