UNPKG

22.5 kBJavaScriptView Raw
1/**
2 * @license Highcharts JS v5.0.0 (2016-09-29)
3 *
4 * (c) 2009-2016 Torstein Honsi
5 *
6 * License: www.highcharts.com/license
7 */
8(function(factory) {
9 if (typeof module === 'object' && module.exports) {
10 module.exports = factory;
11 } else {
12 factory(Highcharts);
13 }
14}(function(Highcharts) {
15 (function(H) {
16 /**
17 * (c) 2009-2016 Torstein Honsi
18 *
19 * License: www.highcharts.com/license
20 */
21 /**
22 * EXPERIMENTAL Highcharts module to place labels next to a series in a natural position.
23 *
24 * TODO:
25 * - add column support (box collision detection, boxesToAvoid logic)
26 * - other series types, area etc.
27 * - avoid data labels, when data labels above, show series label below.
28 * - add more options (connector, format, formatter)
29 *
30 * http://jsfiddle.net/highcharts/L2u9rpwr/
31 * http://jsfiddle.net/highcharts/y5A37/
32 * http://jsfiddle.net/highcharts/264Nm/
33 * http://jsfiddle.net/highcharts/y5A37/
34 */
35
36 'use strict';
37
38 var labelDistance = 3,
39 wrap = H.wrap,
40 each = H.each,
41 extend = H.extend,
42 isNumber = H.isNumber,
43 Series = H.Series,
44 SVGRenderer = H.SVGRenderer,
45 Chart = H.Chart;
46
47 H.setOptions({
48 plotOptions: {
49 series: {
50 label: {
51 enabled: true,
52 // Allow labels to be placed distant to the graph if necessary, and
53 // draw a connector line to the graph
54 connectorAllowed: true,
55 connectorNeighbourDistance: 24, // If the label is closer than this to a neighbour graph, draw a connector
56 styles: {
57 fontWeight: 'bold'
58 }
59 // boxesToAvoid: []
60 }
61 }
62 }
63 });
64
65 /**
66 * Counter-clockwise, part of the fast line intersection logic
67 */
68 function ccw(x1, y1, x2, y2, x3, y3) {
69 var cw = ((y3 - y1) * (x2 - x1)) - ((y2 - y1) * (x3 - x1));
70 return cw > 0 ? true : cw < 0 ? false : true;
71 }
72
73 /**
74 * Detect if two lines intersect
75 */
76 function intersectLine(x1, y1, x2, y2, x3, y3, x4, y4) {
77 return ccw(x1, y1, x3, y3, x4, y4) !== ccw(x2, y2, x3, y3, x4, y4) &&
78 ccw(x1, y1, x2, y2, x3, y3) !== ccw(x1, y1, x2, y2, x4, y4);
79 }
80
81 /**
82 * Detect if a box intersects with a line
83 */
84 function boxIntersectLine(x, y, w, h, x1, y1, x2, y2) {
85 return (
86 intersectLine(x, y, x + w, y, x1, y1, x2, y2) || // top of label
87 intersectLine(x + w, y, x + w, y + h, x1, y1, x2, y2) || // right of label
88 intersectLine(x, y + h, x + w, y + h, x1, y1, x2, y2) || // bottom of label
89 intersectLine(x, y, x, y + h, x1, y1, x2, y2) // left of label
90 );
91 }
92
93 /**
94 * General symbol definition for labels with connector
95 */
96 SVGRenderer.prototype.symbols.connector = function(x, y, w, h, options) {
97 var anchorX = options && options.anchorX,
98 anchorY = options && options.anchorY,
99 path,
100 yOffset,
101 lateral = w / 2;
102
103 if (isNumber(anchorX) && isNumber(anchorY)) {
104
105 path = ['M', anchorX, anchorY];
106
107 // Prefer 45 deg connectors
108 yOffset = y - anchorY;
109 if (yOffset < 0) {
110 yOffset = -h - yOffset;
111 }
112 if (yOffset < w) {
113 lateral = anchorX < x + (w / 2) ? yOffset : w - yOffset;
114 }
115
116 // Anchor below label
117 if (anchorY > y + h) {
118 path.push('L', x + lateral, y + h);
119
120 // Anchor above label
121 } else if (anchorY < y) {
122 path.push('L', x + lateral, y);
123
124 // Anchor left of label
125 } else if (anchorX < x) {
126 path.push('L', x, y + h / 2);
127
128 // Anchor right of label
129 } else if (anchorX > x + w) {
130 path.push('L', x + w, y + h / 2);
131 }
132 }
133 return path || [];
134 };
135
136 /**
137 * Points to avoid. In addition to actual data points, the label should avoid
138 * interpolated positions.
139 */
140 Series.prototype.getPointsOnGraph = function() {
141 var distance = 16,
142 points = this.points,
143 point,
144 last,
145 interpolated = [],
146 i,
147 deltaX,
148 deltaY,
149 delta,
150 len,
151 n,
152 j,
153 d,
154 graph = this.graph || this.area,
155 node = graph.element,
156 inverted = this.chart.inverted,
157 paneLeft = inverted ? this.yAxis.pos : this.xAxis.pos,
158 paneTop = inverted ? this.xAxis.pos : this.yAxis.pos;
159
160 // For splines, get the point at length (possible caveat: peaks are not correctly detected)
161 if (this.getPointSpline && node.getPointAtLength) {
162 // If it is animating towards a path definition, use that briefly, and reset
163 if (graph.toD) {
164 d = graph.attr('d');
165 graph.attr({
166 d: graph.toD
167 });
168 }
169 len = node.getTotalLength();
170 for (i = 0; i < len; i += distance) {
171 point = node.getPointAtLength(i);
172 interpolated.push({
173 chartX: paneLeft + point.x,
174 chartY: paneTop + point.y,
175 plotX: point.x,
176 plotY: point.y
177 });
178 }
179 if (d) {
180 graph.attr({
181 d: d
182 });
183 }
184 // Last point
185 point = points[points.length - 1];
186 point.chartX = paneLeft + point.plotX;
187 point.chartY = paneTop + point.plotY;
188 interpolated.push(point);
189
190 // Interpolate
191 } else {
192 len = points.length;
193 for (i = 0; i < len; i += 1) {
194
195 point = points[i];
196 last = points[i - 1];
197
198 // Absolute coordinates so we can compare different panes
199 point.chartX = paneLeft + point.plotX;
200 point.chartY = paneTop + point.plotY;
201
202 // Add interpolated points
203 if (i > 0) {
204 deltaX = Math.abs(point.chartX - last.chartX);
205 deltaY = Math.abs(point.chartY - last.chartY);
206 delta = Math.max(deltaX, deltaY);
207 if (delta > distance) {
208
209 n = Math.ceil(delta / distance);
210
211 for (j = 1; j < n; j += 1) {
212 interpolated.push({
213 chartX: last.chartX + (point.chartX - last.chartX) * (j / n),
214 chartY: last.chartY + (point.chartY - last.chartY) * (j / n),
215 plotX: last.plotX + (point.plotX - last.plotX) * (j / n),
216 plotY: last.plotY + (point.plotY - last.plotY) * (j / n)
217 });
218 }
219 }
220 }
221
222 // Add the real point in order to find positive and negative peaks
223 if (isNumber(point.plotY)) {
224 interpolated.push(point);
225 }
226 }
227 }
228 return interpolated;
229 };
230
231 /**
232 * Check whether a proposed label position is clear of other elements
233 */
234 Series.prototype.checkClearPoint = function(x, y, bBox, checkDistance) {
235 var distToOthersSquared = Number.MAX_VALUE, // distance to other graphs
236 distToPointSquared = Number.MAX_VALUE,
237 dist,
238 connectorPoint,
239 connectorEnabled = this.options.label.connectorAllowed,
240
241 chart = this.chart,
242 series,
243 points,
244 leastDistance = 16,
245 withinRange,
246 i,
247 j;
248
249 function intersectRect(r1, r2) {
250 return !(r2.left > r1.right ||
251 r2.right < r1.left ||
252 r2.top > r1.bottom ||
253 r2.bottom < r1.top);
254 }
255
256 /**
257 * Get the weight in order to determine the ideal position. Larger distance to
258 * other series gives more weight. Smaller distance to the actual point (connector points only)
259 * gives more weight.
260 */
261 function getWeight(distToOthersSquared, distToPointSquared) {
262 return distToOthersSquared - distToPointSquared;
263 }
264
265 // First check for collision with existing labels
266 for (i = 0; i < chart.boxesToAvoid.length; i += 1) {
267 if (intersectRect(chart.boxesToAvoid[i], {
268 left: x,
269 right: x + bBox.width,
270 top: y,
271 bottom: y + bBox.height
272 })) {
273 return false;
274 }
275 }
276
277 // For each position, check if the lines around the label intersect with any of the
278 // graphs
279 for (i = 0; i < chart.series.length; i += 1) {
280 series = chart.series[i];
281 points = series.interpolatedPoints;
282 if (series.visible && points) {
283 for (j = 1; j < points.length; j += 1) {
284 // If any of the box sides intersect with the line, return
285 if (boxIntersectLine(
286 x,
287 y,
288 bBox.width,
289 bBox.height,
290 points[j - 1].chartX,
291 points[j - 1].chartY,
292 points[j].chartX,
293 points[j].chartY
294 )) {
295 return false;
296 }
297
298 // But if it is too far away (a padded box doesn't intersect), also return
299 if (this === series && !withinRange && checkDistance) {
300 withinRange = boxIntersectLine(
301 x - leastDistance,
302 y - leastDistance,
303 bBox.width + 2 * leastDistance,
304 bBox.height + 2 * leastDistance,
305 points[j - 1].chartX,
306 points[j - 1].chartY,
307 points[j].chartX,
308 points[j].chartY
309 );
310 }
311
312 // Find the squared distance from the center of the label
313 if (this !== series) {
314 distToOthersSquared = Math.min(
315 distToOthersSquared,
316 Math.pow(x + bBox.width / 2 - points[j].chartX, 2) + Math.pow(y + bBox.height / 2 - points[j].chartY, 2),
317 Math.pow(x - points[j].chartX, 2) + Math.pow(y - points[j].chartY, 2),
318 Math.pow(x + bBox.width - points[j].chartX, 2) + Math.pow(y - points[j].chartY, 2),
319 Math.pow(x + bBox.width - points[j].chartX, 2) + Math.pow(y + bBox.height - points[j].chartY, 2),
320 Math.pow(x - points[j].chartX, 2) + Math.pow(y + bBox.height - points[j].chartY, 2)
321 );
322 }
323 }
324
325 // Do we need a connector?
326 if (connectorEnabled && this === series && ((checkDistance && !withinRange) ||
327 distToOthersSquared < Math.pow(this.options.label.connectorNeighbourDistance, 2))) {
328 for (j = 1; j < points.length; j += 1) {
329 dist = Math.min(
330 Math.pow(x + bBox.width / 2 - points[j].chartX, 2) + Math.pow(y + bBox.height / 2 - points[j].chartY, 2),
331 Math.pow(x - points[j].chartX, 2) + Math.pow(y - points[j].chartY, 2),
332 Math.pow(x + bBox.width - points[j].chartX, 2) + Math.pow(y - points[j].chartY, 2),
333 Math.pow(x + bBox.width - points[j].chartX, 2) + Math.pow(y + bBox.height - points[j].chartY, 2),
334 Math.pow(x - points[j].chartX, 2) + Math.pow(y + bBox.height - points[j].chartY, 2)
335 );
336 if (dist < distToPointSquared) {
337 distToPointSquared = dist;
338 connectorPoint = points[j];
339 }
340 }
341 withinRange = true;
342 }
343 }
344 }
345
346 return !checkDistance || withinRange ? {
347 x: x,
348 y: y,
349 weight: getWeight(distToOthersSquared, connectorPoint ? distToPointSquared : 0),
350 connectorPoint: connectorPoint
351 } : false;
352
353 };
354
355
356 /**
357 * The main initiator method that runs on chart level after initiation and redraw. It runs in
358 * a timeout to prevent locking, and loops over all series, taking all series and labels into
359 * account when placing the labels.
360 */
361 function drawLabels(proceed) {
362
363 var chart = this;
364
365 proceed.call(chart);
366
367 clearTimeout(chart.seriesLabelTimer);
368
369 chart.seriesLabelTimer = setTimeout(function() {
370
371 chart.boxesToAvoid = [];
372
373 // Build the interpolated points
374 each(chart.series, function(series) {
375 var options = series.options.label;
376 if (options.enabled && series.visible && (series.graph || series.area)) {
377 series.interpolatedPoints = series.getPointsOnGraph();
378
379 each(options.boxesToAvoid || [], function(box) {
380 chart.boxesToAvoid.push(box);
381 });
382 }
383 });
384
385 each(chart.series, function(series) {
386 var bBox,
387 x,
388 y,
389 results = [],
390 clearPoint,
391 i,
392 best,
393 inverted = chart.inverted,
394 paneLeft = inverted ? series.yAxis.pos : series.xAxis.pos,
395 paneTop = inverted ? series.xAxis.pos : series.yAxis.pos,
396 paneWidth = chart.inverted ? series.yAxis.len : series.xAxis.len,
397 paneHeight = chart.inverted ? series.xAxis.len : series.yAxis.len,
398 points = series.interpolatedPoints;
399
400 function insidePane(x, y, bBox) {
401 return x > paneLeft && x <= paneLeft + paneWidth - bBox.width &&
402 y >= paneTop && y <= paneTop + paneHeight - bBox.height;
403 }
404
405 if (series.visible && points) {
406
407 if (!series.labelBySeries) {
408 series.labelBySeries = chart.renderer.label(series.name, 0, -9999, 'connector')
409 .css(extend({
410 color: series.color
411 }, series.options.label.styles))
412 .attr({
413 padding: 0,
414 opacity: 0,
415 stroke: series.color,
416 'stroke-width': 1
417 })
418 .add(series.group)
419 .animate({
420 opacity: 1
421 }, {
422 duration: 200
423 });
424 }
425
426 bBox = series.labelBySeries.getBBox();
427 bBox.width = Math.round(bBox.width);
428
429 // Ideal positions are centered above or below a point on right side of chart
430 for (i = points.length - 1; i > 0; i -= 1) {
431
432 // Right - up
433 x = points[i].chartX + labelDistance;
434 y = points[i].chartY - bBox.height - labelDistance;
435 if (insidePane(x, y, bBox)) {
436 best = series.checkClearPoint(
437 x,
438 y,
439 bBox
440 );
441 }
442 if (best) {
443 results.push(best);
444 }
445
446 // Right - down
447 x = points[i].chartX + labelDistance;
448 y = points[i].chartY + labelDistance;
449 if (insidePane(x, y, bBox)) {
450 best = series.checkClearPoint(
451 x,
452 y,
453 bBox
454 );
455 }
456 if (best) {
457 results.push(best);
458 }
459
460 // Left - down
461 x = points[i].chartX - bBox.width - labelDistance;
462 y = points[i].chartY + labelDistance;
463 if (insidePane(x, y, bBox)) {
464 best = series.checkClearPoint(
465 x,
466 y,
467 bBox
468 );
469 }
470 if (best) {
471 results.push(best);
472 }
473
474 // Left - up
475 x = points[i].chartX - bBox.width - labelDistance;
476 y = points[i].chartY - bBox.height - labelDistance;
477 if (insidePane(x, y, bBox)) {
478 best = series.checkClearPoint(
479 x,
480 y,
481 bBox
482 );
483 }
484 if (best) {
485 results.push(best);
486 }
487
488 }
489
490 // Brute force, try all positions on the chart in a 16x16 grid
491 if (!results.length) {
492 for (x = paneLeft + paneWidth - bBox.width; x >= paneLeft; x -= 16) {
493 for (y = paneTop; y < paneTop + paneHeight - bBox.height; y += 16) {
494 clearPoint = series.checkClearPoint(x, y, bBox, true);
495 if (clearPoint) {
496 results.push(clearPoint);
497 }
498 }
499 }
500 }
501
502 if (results.length) {
503
504 results.sort(function(a, b) {
505 return b.weight - a.weight;
506 });
507
508 best = results[0];
509
510 chart.boxesToAvoid.push({
511 left: best.x,
512 right: best.x + bBox.width,
513 top: best.y,
514 bottom: best.y + bBox.height
515 });
516
517 // Move it if needed
518 if (Math.round(best.x) !== Math.round(series.labelBySeries.x) || Math.round(best.y) !== Math.round(series.labelBySeries.y)) {
519 series.labelBySeries
520 .attr({
521 x: best.x - paneLeft,
522 y: best.y - paneTop,
523 anchorX: best.connectorPoint && best.connectorPoint.plotX,
524 anchorY: best.connectorPoint && best.connectorPoint.plotY,
525 opacity: 0
526 })
527 .animate({
528 opacity: 1
529 });
530 }
531
532 } else if (series.labelBySeries) {
533 series.labelBySeries = series.labelBySeries.destroy();
534 }
535 }
536 });
537 }, 350);
538
539 }
540 wrap(Chart.prototype, 'render', drawLabels);
541 wrap(Chart.prototype, 'redraw', drawLabels);
542
543 }(Highcharts));
544}));