/* 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));
}
});
}));