UNPKG

15.2 kBJavaScriptView Raw
1/* global Highcharts module window:true */
2(function (factory) {
3 if (typeof module === 'object' && module.exports) {
4 module.exports = factory;
5 } else {
6 factory(Highcharts);
7 }
8}(function (HC) {
9 'use strict';
10 /**
11 * Grouped Categories v1.1.7 (2021-03-03)
12 *
13 * (c) 2012-2021 Black Label
14 *
15 * License: Creative Commons Attribution (CC)
16 */
17
18 /* jshint expr:true, boss:true */
19 var UNDEFINED = void 0,
20 mathRound = Math.round,
21 mathMin = Math.min,
22 mathMax = Math.max,
23 merge = HC.merge,
24 pick = HC.pick,
25 each = HC.each,
26
27 // cache prototypes
28 axisProto = HC.Axis.prototype,
29 tickProto = HC.Tick.prototype,
30
31 // cache original methods
32 protoAxisInit = axisProto.init,
33 protoAxisRender = axisProto.render,
34 protoAxisSetCategories = axisProto.setCategories,
35 protoTickGetLabelSize = tickProto.getLabelSize,
36 protoTickAddLabel = tickProto.addLabel,
37 protoTickDestroy = tickProto.destroy,
38 protoTickRender = tickProto.render;
39
40 function deepClone(thing) {
41 return JSON.parse(JSON.stringify(thing));
42 }
43
44 function Category(obj, parent) {
45 this.userOptions = deepClone(obj);
46 this.name = obj.name || obj;
47 this.parent = parent;
48
49 return this;
50 }
51
52 Category.prototype.toString = function () {
53 var parts = [],
54 cat = this;
55
56 while (cat) {
57 parts.push(cat.name);
58 cat = cat.parent;
59 }
60
61 return parts.join(', ');
62 };
63
64 // returns sum of an array
65 function sum(arr) {
66 var l = arr.length,
67 x = 0;
68
69 while (l--) {
70 x += arr[l];
71 }
72
73 return x;
74 }
75
76 // Adds category leaf to array
77 function addLeaf(out, cat, parent) {
78 out.unshift(new Category(cat, parent));
79
80 while (parent) {
81 parent.leaves = parent.leaves ? (parent.leaves + 1) : 1;
82 parent = parent.parent;
83 }
84 }
85
86 // Builds reverse category tree
87 function buildTree(cats, out, options, parent, depth) {
88 var len = cats.length,
89 cat;
90
91 depth = depth ? depth : 0;
92 options.depth = options.depth ? options.depth : 0;
93
94 while (len--) {
95 cat = cats[len];
96
97 if (cat.categories) {
98 if (parent) {
99 cat.parent = parent;
100 }
101 buildTree(cat.categories, out, options, cat, depth + 1);
102 } else {
103 addLeaf(out, cat, parent);
104 }
105 }
106 options.depth = mathMax(options.depth, depth);
107 }
108
109 // Pushes part of grid to path
110 function addGridPart(path, d, width) {
111 // Based on crispLine from HC (#65)
112 if (d[0] === d[2]) {
113 d[0] = d[2] = mathRound(d[0]) - (width % 2 / 2);
114 }
115 if (d[1] === d[3]) {
116 d[1] = d[3] = mathRound(d[1]) + (width % 2 / 2);
117 }
118
119 path.push(
120 'M',
121 d[0], d[1],
122 'L',
123 d[2], d[3]
124 );
125 }
126
127 // Returns tick position
128 function tickPosition(tick, pos) {
129 return tick.getPosition(tick.axis.horiz, pos, tick.axis.tickmarkOffset);
130 }
131
132 function walk(arr, key, fn) {
133 var l = arr.length,
134 children;
135
136 while (l--) {
137 children = arr[l][key];
138
139 if (children) {
140 walk(children, key, fn);
141 }
142 fn(arr[l]);
143 }
144 }
145
146 //
147 // Axis prototype
148 //
149
150 axisProto.init = function (chart, options) {
151 // default behaviour
152 protoAxisInit.call(this, chart, options);
153
154 if (typeof options === 'object' && options.categories) {
155 this.setupGroups(options);
156 }
157 };
158
159 // setup required axis options
160 axisProto.setupGroups = function (options) {
161 var categories = deepClone(options.categories),
162 reverseTree = [],
163 stats = {},
164 labelOptions = this.options.labels,
165 userAttr = labelOptions.groupedOptions,
166 css = labelOptions.style;
167
168 // build categories tree
169 buildTree(categories, reverseTree, stats);
170
171 // set axis properties
172 this.categoriesTree = categories;
173 this.categories = reverseTree;
174 this.isGrouped = stats.depth !== 0;
175 this.labelsDepth = stats.depth;
176 this.labelsSizes = [];
177 this.labelsGridPath = [];
178 this.tickLength = options.tickLength || this.tickLength || null;
179 // #66: tickWidth for x axis defaults to 1, for y to 0
180 this.tickWidth = pick(options.tickWidth, this.isXAxis ? 1 : 0);
181 this.directionFactor = [-1, 1, 1, -1][this.side];
182 this.options.lineWidth = pick(options.lineWidth, 1);
183 // #85: align labels vertically
184 this.groupFontHeights = [];
185 for (var i = 0; i <= stats.depth; i++) {
186 var hasOptions = userAttr && userAttr[i - 1],
187 mergedCSS = hasOptions && userAttr[i - 1].style ? merge(css, userAttr[i - 1].style) : css;
188 this.groupFontHeights[i] = Math.round(this.chart.renderer.fontMetrics(mergedCSS ? mergedCSS.fontSize : 0).b * 0.3);
189 }
190 };
191
192
193 axisProto.render = function () {
194 // clear grid path
195 if (this.isGrouped) {
196 this.labelsGridPath = [];
197 }
198
199 // cache original tick length
200 if (this.originalTickLength === UNDEFINED) {
201 this.originalTickLength = this.options.tickLength;
202 }
203
204 // use default tickLength for not-grouped axis
205 // and generate grid on grouped axes,
206 // use tiny number to force highcharts to hide tick
207 this.options.tickLength = this.isGrouped ? 0.001 : this.originalTickLength;
208
209 protoAxisRender.call(this);
210
211 if (!this.isGrouped) {
212 if (this.labelsGrid) {
213 this.labelsGrid.attr({
214 visibility: 'hidden'
215 });
216 }
217 return false;
218 }
219
220 var axis = this,
221 options = axis.options,
222 top = axis.top,
223 left = axis.left,
224 right = left + axis.width,
225 bottom = top + axis.height,
226 visible = axis.hasVisibleSeries || axis.hasData,
227 depth = axis.labelsDepth,
228 grid = axis.labelsGrid,
229 horiz = axis.horiz,
230 d = axis.labelsGridPath,
231 i = options.drawHorizontalBorders === false ? (depth + 1) : 0,
232 offset = axis.opposite ? (horiz ? top : right) : (horiz ? bottom : left),
233 tickWidth = axis.tickWidth,
234 part;
235
236 if (axis.userTickLength) {
237 depth -= 1;
238 }
239
240 // render grid path for the first time
241 if (!grid) {
242 grid = axis.labelsGrid = axis.chart.renderer.path()
243 .attr({
244 // #58: use tickWidth/tickColor instead of lineWidth/lineColor:
245 strokeWidth: tickWidth, // < 4.0.3
246 'stroke-width': tickWidth, // 4.0.3+ #30
247 stroke: options.tickColor || '' // for styled mode (tickColor === undefined)
248 })
249 .add(axis.axisGroup);
250 // for styled mode - add class
251 if (!options.tickColor) {
252 grid.addClass('highcharts-tick');
253 }
254 }
255
256 // go through every level and draw horizontal grid line
257 while (i <= depth) {
258 offset += axis.groupSize(i);
259
260 part = horiz ?
261 [left, offset, right, offset] :
262 [offset, top, offset, bottom];
263
264 addGridPart(d, part, tickWidth);
265 i++;
266 }
267
268 // draw grid path
269 grid.attr({
270 d: d,
271 visibility: visible ? 'visible' : 'hidden'
272 });
273
274 axis.labelGroup.attr({
275 visibility: visible ? 'visible' : 'hidden'
276 });
277
278
279 walk(axis.categoriesTree, 'categories', function (group) {
280 var tick = group.tick;
281
282 if (!tick) {
283 return false;
284 }
285 if (tick.startAt + tick.leaves - 1 < axis.min || tick.startAt > axis.max) {
286 tick.label.hide();
287 tick.destroyed = 0;
288 } else {
289 tick.label.attr({
290 visibility: visible ? 'visible' : 'hidden'
291 });
292 }
293 return true;
294 });
295 return true;
296 };
297
298 axisProto.setCategories = function (newCategories, doRedraw) {
299 if (this.categories) {
300 this.cleanGroups();
301 }
302 this.setupGroups({
303 categories: newCategories
304 });
305 this.categories = this.userOptions.categories = newCategories;
306 protoAxisSetCategories.call(this, this.categories, doRedraw);
307 };
308
309 // cleans old categories
310 axisProto.cleanGroups = function () {
311 var ticks = this.ticks,
312 n;
313
314 for (n in ticks) {
315 if (ticks[n].parent) {
316 delete ticks[n].parent;
317 }
318 }
319 walk(this.categoriesTree, 'categories', function (group) {
320 var tick = group.tick;
321
322 if (!tick) {
323 return false;
324 }
325 tick.label.destroy();
326
327 each(tick, function (v, i) {
328 delete tick[i];
329 });
330 delete group.tick;
331
332 return true;
333 });
334 this.labelsGrid = null;
335 };
336
337 // keeps size of each categories level
338 axisProto.groupSize = function (level, position) {
339 var positions = this.labelsSizes,
340 direction = this.directionFactor,
341 groupedOptions = this.options.labels.groupedOptions ? this.options.labels.groupedOptions[level - 1] : false,
342 userXY = 0;
343
344 if (groupedOptions) {
345 if (direction === -1) {
346 userXY = groupedOptions.x ? groupedOptions.x : 0;
347 } else {
348 userXY = groupedOptions.y ? groupedOptions.y : 0;
349 }
350 }
351
352 if (position !== UNDEFINED) {
353 positions[level] = mathMax(positions[level] || 0, position + 10 + Math.abs(userXY));
354 }
355
356 if (level === true) {
357 return sum(positions) * direction;
358 } else if (positions[level]) {
359 return positions[level] * direction;
360 }
361
362 return 0;
363 };
364
365 //
366 // Tick prototype
367 //
368
369 // Override methods prototypes
370 tickProto.addLabel = function () {
371 var tick = this,
372 axis = tick.axis,
373 category;
374
375 protoTickAddLabel.call(tick);
376
377 if (!axis.categories || !(category = axis.categories[tick.pos])) {
378 return false;
379 }
380
381 // set label text - but applied after formatter #46
382 if (tick.label) {
383 tick.label.attr('text', tick.axis.labelFormatter.call({
384 axis: axis,
385 chart: axis.chart,
386 isFirst: tick.isFirst,
387 isLast: tick.isLast,
388 value: category.name,
389 pos: tick.pos
390 }));
391
392 // update with new text length, since textSetter removes the size caches when text changes. #137
393 tick.label.textPxLength = tick.label.getBBox().width;
394 }
395
396 // create elements for parent categories
397 if (axis.isGrouped && axis.options.labels.enabled) {
398 tick.addGroupedLabels(category);
399 }
400 return true;
401 };
402
403 // render ancestor label
404 tickProto.addGroupedLabels = function (category) {
405 var tick = this,
406 axis = this.axis,
407 chart = axis.chart,
408 options = axis.options.labels,
409 useHTML = options.useHTML,
410 css = options.style,
411 userAttr = options.groupedOptions,
412 attr = {
413 align: 'center',
414 rotation: options.rotation,
415 x: 0,
416 y: 0
417 },
418 size = axis.horiz ? 'height' : 'width',
419 depth = 0,
420 label;
421
422
423 while (tick) {
424 if (depth > 0 && !category.tick) {
425 // render label element
426 this.value = category.name;
427 var name = options.formatter ? options.formatter.call(this, category) : category.name,
428 hasOptions = userAttr && userAttr[depth - 1],
429 mergedAttrs = hasOptions ? merge(attr, userAttr[depth - 1]) : attr,
430 mergedCSS = hasOptions && userAttr[depth - 1].style ? merge(css, userAttr[depth - 1].style) : css;
431
432 // #63: style is passed in CSS and not as an attribute
433 delete mergedAttrs.style;
434
435 label = chart.renderer.text(name, 0, 0, useHTML)
436 .attr(mergedAttrs)
437 .add(axis.labelGroup);
438
439 // css should only be set for non styledMode configuration. #167
440 if (label && !chart.styledMode) {
441 label.css(mergedCSS);
442 }
443
444 // tick properties
445 tick.startAt = this.pos;
446 tick.childCount = category.categories.length;
447 tick.leaves = category.leaves;
448 tick.visible = this.childCount;
449 tick.label = label;
450 tick.labelOffsets = {
451 x: mergedAttrs.x,
452 y: mergedAttrs.y
453 };
454
455 // link tick with category
456 category.tick = tick;
457 }
458
459 // set level size, #93
460 if (tick && tick.label) {
461 axis.groupSize(depth, tick.label.getBBox()[size]);
462 }
463
464 // go up to the parent category
465 category = category.parent;
466
467 if (category) {
468 tick = tick.parent = category.tick || {};
469 } else {
470 tick = null;
471 }
472
473 depth++;
474 }
475 };
476
477 // set labels position & render categories grid
478 tickProto.render = function (index, old, opacity) {
479 protoTickRender.call(this, index, old, opacity);
480
481 var treeCat = this.axis.categories[this.pos];
482
483 if (!this.axis.isGrouped || !treeCat || this.pos > this.axis.max) {
484 return;
485 }
486
487 var tick = this,
488 group = tick,
489 axis = tick.axis,
490 tickPos = tick.pos,
491 isFirst = tick.isFirst,
492 max = axis.max,
493 min = axis.min,
494 horiz = axis.horiz,
495 grid = axis.labelsGridPath,
496 size = axis.groupSize(0),
497 tickWidth = axis.tickWidth,
498 xy = tickPosition(tick, tickPos),
499 start = horiz ? xy.y : xy.x,
500 baseLine = axis.chart.renderer.fontMetrics(axis.options.labels.style ? axis.options.labels.style.fontSize : 0).b,
501 depth = 1,
502 reverseCrisp = ((horiz && xy.x === axis.pos + axis.len) || (!horiz && xy.y === axis.pos)) ? -1 : 0, // adjust grid lines for edges
503 gridAttrs,
504 lvlSize,
505 minPos,
506 maxPos,
507 attrs,
508 bBox;
509
510 // render grid for "normal" categories (first-level), render left grid line only for the first category
511 if (isFirst) {
512 gridAttrs = horiz ?
513 [axis.left, xy.y, axis.left, xy.y + axis.groupSize(true)] : axis.isXAxis ?
514 [xy.x, axis.top, xy.x + axis.groupSize(true), axis.top] : [xy.x, axis.top + axis.len, xy.x + axis.groupSize(true), axis.top + axis.len];
515
516 addGridPart(grid, gridAttrs, tickWidth);
517 }
518
519 if (horiz && axis.left < xy.x) {
520 addGridPart(grid, [xy.x - reverseCrisp, xy.y, xy.x - reverseCrisp, xy.y + size], tickWidth);
521 } else if (!horiz && axis.top <= xy.y) {
522 addGridPart(grid, [xy.x, xy.y + reverseCrisp, xy.x + size, xy.y + reverseCrisp], tickWidth);
523 }
524
525 size = start + size;
526
527 function fixOffset(tCat) {
528 var ret = 0;
529 if (isFirst) {
530 ret = tCat.parent.categories.indexOf(tCat.name);
531 ret = ret < 0 ? 0 : ret;
532 return ret;
533 }
534 return ret;
535 }
536
537
538 while (group.parent) {
539 group = group.parent;
540
541 var fix = fixOffset(treeCat),
542 userX = group.labelOffsets.x,
543 userY = group.labelOffsets.y;
544
545 minPos = tickPosition(tick, mathMax(group.startAt - 1, min - 1));
546 maxPos = tickPosition(tick, mathMin(group.startAt + group.leaves - 1 - fix, max));
547 bBox = group.label.getBBox(true);
548 lvlSize = axis.groupSize(depth);
549 // check if on the edge to adjust
550 reverseCrisp = ((horiz && maxPos.x === axis.pos + axis.len) || (!horiz && maxPos.y === axis.pos)) ? -1 : 0;
551
552 attrs = horiz ? {
553 x: (minPos.x + maxPos.x) / 2 + userX,
554 y: size + axis.groupFontHeights[depth] + lvlSize / 2 + userY / 2
555 } : {
556 x: size + lvlSize / 2 + userX,
557 y: (minPos.y + maxPos.y - bBox.height) / 2 + baseLine + userY
558 };
559
560 if (!isNaN(attrs.x) && !isNaN(attrs.y)) {
561 group.label.attr(attrs);
562
563 if (grid) {
564 if (horiz && axis.left < maxPos.x) {
565 addGridPart(grid, [maxPos.x - reverseCrisp, size, maxPos.x - reverseCrisp, size + lvlSize], tickWidth);
566 } else if (!horiz && axis.top <= maxPos.y) {
567 addGridPart(grid, [size, maxPos.y + reverseCrisp, size + lvlSize, maxPos.y + reverseCrisp], tickWidth);
568 }
569 }
570 }
571
572 size += lvlSize;
573 depth++;
574 }
575 };
576
577 tickProto.destroy = function () {
578 var group = this.parent;
579
580 while (group) {
581 group.destroyed = group.destroyed ? (group.destroyed + 1) : 1;
582 group = group.parent;
583 }
584
585 protoTickDestroy.call(this);
586 };
587
588 // return size of the label (height for horizontal, width for vertical axes)
589 tickProto.getLabelSize = function () {
590 if (this.axis.isGrouped === true) {
591 // #72, getBBox might need recalculating when chart is tall
592 var size = protoTickGetLabelSize.call(this) + 10,
593 topLabelSize = this.axis.labelsSizes[0];
594 if (topLabelSize < size) {
595 this.axis.labelsSizes[0] = size;
596 }
597 return sum(this.axis.labelsSizes);
598 }
599 return protoTickGetLabelSize.call(this);
600 };
601
602 // Since datasorting is not supported by the plugin,
603 // override replaceMovedLabel method, #146.
604 HC.wrap(HC.Tick.prototype, 'replaceMovedLabel', function (proceed) {
605 if (!this.axis.isGrouped) {
606 proceed.apply(this, Array.prototype.slice.call(arguments, 1));
607 }
608 });
609
610}));