/* global Highcharts module */ (function (factory) { if (typeof module === 'object' && module.exports) { module.exports = factory; } else { factory(Highcharts); } }(function (HC) { 'use strict'; /** * Grouped Categories v1.2.0 (2021-05-10) * * (c) 2012-2021 Black Label * * License: Creative Commons Attribution (CC) */ /* jshint expr:true, boss:true */ var UNDEFINED = void 0, mathRound = Math.round, mathMin = Math.min, mathMax = Math.max, merge = HC.merge, pick = HC.pick, each = HC.each, // cache prototypes axisProto = HC.Axis.prototype, tickProto = HC.Tick.prototype, // cache original methods protoAxisInit = axisProto.init, protoAxisRender = axisProto.render, protoAxisSetCategories = axisProto.setCategories, protoTickGetLabelSize = tickProto.getLabelSize, protoTickAddLabel = tickProto.addLabel, protoTickDestroy = tickProto.destroy, protoTickRender = tickProto.render; function deepClone(thing) { return JSON.parse(JSON.stringify(thing)); } function Category(obj, parent) { this.userOptions = deepClone(obj); this.name = obj.name || obj; this.parent = parent; return this; } Category.prototype.toString = function () { return this.name; /*var parts = [], cat = this; while (cat) { parts.push(cat.name); cat = cat.parent; } return parts.join(', ');*/ }; // returns sum of an array function sum(arr) { var l = arr.length, x = 0; while (l--) { x += arr[l]; } return x; } // Adds category leaf to array function addLeaf(out, cat, parent) { out.unshift(new Category(cat, parent)); while (parent) { parent.leaves = parent.leaves ? (parent.leaves + 1) : 1; parent = parent.parent; } } // Builds reverse category tree function buildTree(cats, out, options, parent, depth) { var len = cats.length, cat; depth = depth ? depth : 0; options.depth = options.depth ? options.depth : 0; while (len--) { cat = cats[len]; if (cat.categories) { if (parent) { cat.parent = parent; } buildTree(cat.categories, out, options, cat, depth + 1); } else { addLeaf(out, cat, parent); } } options.depth = mathMax(options.depth, depth); } // Pushes part of grid to path function addGridPart(path, d, width) { // Based on crispLine from HC (#65) if (d[0] === d[2]) { d[0] = d[2] = mathRound(d[0]) - (width % 2 / 2); } if (d[1] === d[3]) { d[1] = d[3] = mathRound(d[1]) + (width % 2 / 2); } path.push( 'M', d[0], d[1], 'L', d[2], d[3] ); } // Returns tick position function tickPosition(tick, pos) { return tick.getPosition(tick.axis.horiz, pos, tick.axis.tickmarkOffset); } function walk(arr, key, fn) { var l = arr.length, children; while (l--) { children = arr[l][key]; if (children) { walk(children, key, fn); } fn(arr[l]); } } // // Axis prototype // axisProto.init = function (chart, options) { // default behaviour protoAxisInit.call(this, chart, options); if (typeof options === 'object' && options.categories) { this.setupGroups(options); } }; // setup required axis options axisProto.setupGroups = function (options) { var categories = deepClone(options.categories), reverseTree = [], stats = {}, labelOptions = this.options.labels, userAttr = labelOptions.groupedOptions, css = labelOptions.style; // build categories tree buildTree(categories, reverseTree, stats); // set axis properties this.categoriesTree = categories; this.categories = reverseTree; this.isGrouped = stats.depth !== 0; this.labelsDepth = stats.depth; this.labelsSizes = []; this.labelsGridPath = []; this.tickLength = options.tickLength || this.tickLength || null; // #66: tickWidth for x axis defaults to 1, for y to 0 this.tickWidth = pick(options.tickWidth, this.isXAxis ? 1 : 0); this.directionFactor = [-1, 1, 1, -1][this.side]; this.options.lineWidth = pick(options.lineWidth, 1); // #85: align labels vertically this.groupFontHeights = []; for (var i = 0; i <= stats.depth; i++) { var hasOptions = userAttr && userAttr[i - 1], mergedCSS = hasOptions && userAttr[i - 1].style ? merge(css, userAttr[i - 1].style) : css; this.groupFontHeights[i] = Math.round(this.chart.renderer.fontMetrics(mergedCSS ? mergedCSS.fontSize : 0).b * 0.3); } }; axisProto.render = function () { // clear grid path if (this.isGrouped) { this.labelsGridPath = []; } // cache original tick length if (this.originalTickLength === UNDEFINED) { this.originalTickLength = this.options.tickLength; } // use default tickLength for not-grouped axis // and generate grid on grouped axes, // use tiny number to force highcharts to hide tick this.options.tickLength = this.isGrouped ? 0.001 : this.originalTickLength; protoAxisRender.call(this); if (!this.isGrouped) { if (this.labelsGrid) { this.labelsGrid.attr({ visibility: 'hidden' }); } return false; } var axis = this, options = axis.options, top = axis.top, left = axis.left, right = left + axis.width, bottom = top + axis.height, visible = axis.hasVisibleSeries || axis.hasData, depth = axis.labelsDepth, grid = axis.labelsGrid, horiz = axis.horiz, d = axis.labelsGridPath, i = options.drawHorizontalBorders === false ? (depth + 1) : options.hideFirstLevelLabels ? 1 : 0, offset = axis.opposite ? (horiz ? top : right) : (horiz ? bottom : left), tickWidth = axis.tickWidth, part; if (axis.userTickLength) { depth -= 1; } // render grid path for the first time if (!grid) { grid = axis.labelsGrid = axis.chart.renderer.path() .attr({ // #58: use tickWidth/tickColor instead of lineWidth/lineColor: strokeWidth: tickWidth, // < 4.0.3 'stroke-width': tickWidth, // 4.0.3+ #30 stroke: options.tickColor || '' // for styled mode (tickColor === undefined) }) .add(axis.axisGroup); // for styled mode - add class if (!options.tickColor) { grid.addClass('highcharts-tick'); } } // go through every level and draw horizontal grid line while (i <= depth) { offset += axis.groupSize(i); part = horiz ? [left, offset, right, offset] : [offset, top, offset, bottom]; addGridPart(d, part, tickWidth); i++; } // draw grid path grid.attr({ d: d, visibility: visible ? 'visible' : 'hidden' }); axis.labelGroup.attr({ visibility: visible ? 'visible' : 'hidden' }); walk(axis.categoriesTree, 'categories', function (group) { var tick = group.tick; if (!tick) { return false; } if (tick.startAt + tick.leaves - 1 < axis.min || tick.startAt > axis.max) { tick.label.hide(); tick.destroyed = 0; } else { tick.label.attr({ visibility: visible ? 'visible' : 'hidden' }); } return true; }); return true; }; axisProto.setCategories = function (newCategories, doRedraw) { if (this.categories) { this.cleanGroups(); } this.setupGroups({ categories: newCategories }); this.categories = this.userOptions.categories = newCategories; protoAxisSetCategories.call(this, this.categories, doRedraw); }; // cleans old categories axisProto.cleanGroups = function () { var ticks = this.ticks, n; for (n in ticks) { if (ticks[n].parent) { delete ticks[n].parent; } } walk(this.categoriesTree, 'categories', function (group) { var tick = group.tick; if (!tick) { return false; } tick.label.destroy(); each(tick, function (v, i) { delete tick[i]; }); delete group.tick; return true; }); this.labelsGrid = null; }; // keeps size of each categories level axisProto.groupSize = function (level, position) { var positions = this.labelsSizes, direction = this.directionFactor, groupedOptions = this.options.labels.groupedOptions ? this.options.labels.groupedOptions[level - 1] : false, userXY = 0; if (groupedOptions) { if (direction === -1) { userXY = groupedOptions.x ? groupedOptions.x : 0; } else { userXY = groupedOptions.y ? groupedOptions.y : 0; } } if (position !== UNDEFINED) { positions[level] = mathMax(positions[level] || 0, position + 10 + Math.abs(userXY)); } if (level === true) { return sum(positions) * direction; } else if (positions[level]) { return positions[level] * direction; } return 0; }; // // Tick prototype // // Override methods prototypes tickProto.addLabel = function () { var tick = this, axis = tick.axis, labelOptions = pick( tick.options && tick.options.labels, axis.options.labels ), category, formatter; protoTickAddLabel.call(tick); if (!axis.categories || !(category = axis.categories[tick.pos])) { return false; } // set label text - but applied after formatter #46 if (tick.label) { formatter = function (ctx) { if (axis.options.hideFirstLevelLabels) { return ''; } if (labelOptions.formatter) { return labelOptions.formatter.call(ctx, ctx); } if (labelOptions.format) { ctx.text = axis.defaultLabelFormatter.call(ctx); return HC.format(labelOptions.format, ctx, axis.chart); } return axis.defaultLabelFormatter.call(ctx, ctx); }; tick.label.attr('text', formatter({ axis: axis, chart: axis.chart, isFirst: tick.isFirst, isLast: tick.isLast, value: category.name, pos: tick.pos })); // update with new text length, since textSetter removes the size caches when text changes. #137 // fix: should get the larger axis (height/width) depending on the orientation of the label tick.label.textPxLength = tick.label.rotation > 45 || tick.label.rotation < -45 ? tick.label.getBBox().height : tick.label.getBBox().width; } // create elements for parent categories if (axis.isGrouped && axis.options.labels.enabled) { tick.addGroupedLabels(category); } return true; }; // render ancestor label tickProto.addGroupedLabels = function (category) { var tick = this, axis = this.axis, chart = axis.chart, options = axis.options.labels, useHTML = options.useHTML, css = options.style, userAttr = options.groupedOptions, attr = { align: 'center', rotation: options.rotation, x: 0, y: 0 }, size = axis.horiz ? 'height' : 'width', depth = 0, label; while (tick) { if (depth > 0 && !category.tick) { // render label element this.value = category.name; var name = options.formatter ? options.formatter.call(this, category) : category.name, hasOptions = userAttr && userAttr[depth - 1], mergedAttrs = hasOptions ? merge(attr, userAttr[depth - 1]) : attr, mergedCSS = hasOptions && userAttr[depth - 1].style ? merge(css, userAttr[depth - 1].style) : css; // #63: style is passed in CSS and not as an attribute delete mergedAttrs.style; label = chart.renderer.text(name, 0, 0, useHTML) .attr(mergedAttrs) .add(axis.labelGroup); // css should only be set for non styledMode configuration. #167 if (label && !chart.styledMode) { label.css(mergedCSS); } // tick properties tick.startAt = this.pos; tick.childCount = category.categories.length; tick.leaves = category.leaves; tick.visible = this.childCount; tick.label = label; tick.labelOffsets = { x: mergedAttrs.x, y: mergedAttrs.y }; // link tick with category category.tick = tick; } // set level size, #93 if (tick && tick.label) { axis.groupSize(depth, tick.label.getBBox()[size]); } // go up to the parent category category = category.parent; if (category) { tick = tick.parent = category.tick || {}; } else { tick = null; } depth++; } }; // set labels position & render categories grid tickProto.render = function (index, old, opacity) { protoTickRender.call(this, index, old, opacity); var treeCat = this.axis.categories != null ? this.axis.categories[this.pos] : null; if (!this.axis.isGrouped || !treeCat || this.pos > this.axis.max) { return; } var tick = this, group = tick, axis = tick.axis, tickPos = tick.pos, isFirst = tick.isFirst, max = axis.max, min = axis.min, horiz = axis.horiz, grid = axis.labelsGridPath, size = !axis.options.hideFirstLevelLabels ? axis.groupSize(0) : 0, tickWidth = axis.tickWidth, xy = tickPosition(tick, tickPos), start = horiz ? xy.y : xy.x, baseLine = axis.chart.renderer.fontMetrics(axis.options.labels.style ? axis.options.labels.style.fontSize : 0).b, depth = 1, reverseCrisp = ((horiz && xy.x === axis.pos + axis.len) || (!horiz && xy.y === axis.pos)) ? -1 : 0, // adjust grid lines for edges gridAttrs, lvlSize, minPos, maxPos, attrs, bBox; // render grid for "normal" categories (first-level), render left grid line only for the first category if (isFirst) { var groupSize = axis.options.hideFirstLevelLabels ? axis.groupSize(true) - axis.groupSize(0) : axis.groupSize(true); gridAttrs = horiz ? [axis.left, xy.y, axis.left, xy.y + groupSize] : axis.isXAxis ? [xy.x, axis.top, xy.x + groupSize, axis.top] : [xy.x, axis.top + axis.len, xy.x + groupSize, axis.top + axis.len]; addGridPart(grid, gridAttrs, tickWidth); } if (!axis.options.hideFirstLevelLabels) { //draw first-level ticks if (horiz && axis.left < xy.x) { addGridPart(grid, [xy.x - reverseCrisp, xy.y, xy.x - reverseCrisp, xy.y + size], tickWidth); } else if (!horiz && axis.top <= xy.y) { addGridPart(grid, [xy.x, xy.y + reverseCrisp, xy.x + size, xy.y + reverseCrisp], tickWidth); } } size = start + size; function fixOffset(tCat) { var ret = 0; if (isFirst) { ret = tCat.parent.categories.indexOf(tCat.name); ret = ret < 0 ? 0 : ret; return ret; } return ret; } while (group.parent) { group = group.parent; var fix = fixOffset(treeCat), userX = group.labelOffsets.x, userY = group.labelOffsets.y; minPos = tickPosition(tick, mathMax(group.startAt - 1, min - 1)); maxPos = tickPosition(tick, mathMin(group.startAt + group.leaves - 1 - fix, max)); bBox = group.label.getBBox(true); lvlSize = axis.groupSize(depth); // check if on the edge to adjust reverseCrisp = ((horiz && maxPos.x === axis.pos + axis.len) || (!horiz && maxPos.y === axis.pos)) ? -1 : 0; attrs = horiz ? { x: (minPos.x + maxPos.x) / 2 + userX, y: size + axis.groupFontHeights[depth] + lvlSize / 2 + userY / 2 } : { x: size + lvlSize / 2 + userX, y: (minPos.y + maxPos.y - bBox.height) / 2 + baseLine + userY }; if (!isNaN(attrs.x) && !isNaN(attrs.y)) { group.label.attr(attrs); if (grid) { if (horiz && axis.left < maxPos.x) { addGridPart(grid, [maxPos.x - reverseCrisp, size, maxPos.x - reverseCrisp, size + lvlSize], tickWidth); } else if (!horiz && axis.top <= maxPos.y) { addGridPart(grid, [size, maxPos.y + reverseCrisp, size + lvlSize, maxPos.y + reverseCrisp], tickWidth); } } } size += lvlSize; depth++; } }; tickProto.destroy = function () { var group = this.parent; while (group) { group.destroyed = group.destroyed ? (group.destroyed + 1) : 1; group = group.parent; } protoTickDestroy.call(this); }; // return size of the label (height for horizontal, width for vertical axes) tickProto.getLabelSize = function () { if (this.axis.isGrouped === true) { // #72, getBBox might need recalculating when chart is tall var size = protoTickGetLabelSize.call(this) + 10, topLabelSize = this.axis.labelsSizes[0]; if (topLabelSize < size) { this.axis.labelsSizes[0] = size; } return sum(this.axis.labelsSizes); } return protoTickGetLabelSize.call(this); }; // Since datasorting is not supported by the plugin, // override replaceMovedLabel method, #146. HC.wrap(HC.Tick.prototype, 'replaceMovedLabel', function (proceed) { if (!this.axis.isGrouped) { proceed.apply(this, Array.prototype.slice.call(arguments, 1)); } }); }));