diff --git a/.eslint-default-config.yml b/.eslint-default-config.yml deleted file mode 100644 index 83e30a7..0000000 --- a/.eslint-default-config.yml +++ /dev/null @@ -1,106 +0,0 @@ -# Copied from https://github.com/eslint/eslint/blob/master/packages/eslint-config-eslint/default.yml -# Updated 2015-04-06 - -extends: - "eslint:recommended" - -rules: - array-callback-return: "error" - indent: ["error", 4, {SwitchCase: 1}] - block-spacing: "error" - brace-style: ["error", "1tbs"] - camelcase: ["error", { properties: "never" }] - callback-return: ["error", ["cb", "callback", "next"]] - comma-spacing: "error" - comma-style: ["error", "last"] - consistent-return: "error" - curly: ["error", "all"] - default-case: "error" - dot-notation: ["error", { allowKeywords: true }] - eol-last: "error" - eqeqeq: "error" - func-style: ["error", "declaration"] - guard-for-in: "error" - key-spacing: ["error", { beforeColon: false, afterColon: true }] - keyword-spacing: "error" - lines-around-comment: ["error", { - beforeBlockComment: true, - afterBlockComment: false, - beforeLineComment: true, - afterLineComment: false - }] - new-cap: "error" - newline-after-var: "error" - new-parens: "error" - no-alert: "error" - no-array-constructor: "error" - no-caller: "error" - no-console: 0 - no-delete-var: "error" - no-eval: "error" - no-extend-native: "error" - no-extra-bind: "error" - no-fallthrough: "error" - no-floating-decimal: "error" - no-implied-eval: "error" - no-invalid-this: "error" - no-iterator: "error" - no-label-var: "error" - no-labels: "error" - no-lone-blocks: "error" - no-loop-func: "error" - no-mixed-spaces-and-tabs: ["error", false] - no-multi-spaces: "error" - no-multi-str: "error" - no-native-reassign: "error" - no-nested-ternary: "error" - no-new: "error" - no-new-func: "error" - no-new-object: "error" - no-new-wrappers: "error" - no-octal: "error" - no-octal-escape: "error" - no-process-exit: "error" - no-proto: "error" - no-redeclare: "error" - no-return-assign: "error" - no-script-url: "error" - no-self-assign: "error" - no-sequences: "error" - no-shadow: "error" - no-shadow-restricted-names: "error" - no-spaced-func: "error" - no-trailing-spaces: "error" - no-undef: "error" - no-undef-init: "error" - no-undefined: "error" - no-underscore-dangle: ["error", {allowAfterThis: true}] - no-unmodified-loop-condition: "error" - no-unused-expressions: "error" - no-unused-vars: ["error", {vars: "all", args: "after-used"}] - no-use-before-define: "error" - no-useless-concat: "error" - no-with: "error" - one-var-declaration-per-line: "error" - quotes: ["error", "double"] - radix: "error" - require-jsdoc: "error" - semi: "error" - semi-spacing: ["error", {before: false, after: true}] - space-before-blocks: "error" - space-before-function-paren: ["error", "never"] - space-in-parens: "error" - space-infix-ops: "error" - space-unary-ops: ["error", {words: true, nonwords: false}] - spaced-comment: ["error", "always", { exceptions: ["-"]}] - strict: ["error", "global"] - valid-jsdoc: ["error", { prefer: { "return": "returns"}}] - wrap-iife: "error" - yoda: ["error", "never"] - - # Previously on by default in node environment - no-catch-shadow: "off" - no-mixed-requires: "error" - no-new-require: "error" - no-path-concat: "error" - handle-callback-err: ["error", "err"] \ No newline at end of file diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index fe1d162..0000000 --- a/.eslintrc +++ /dev/null @@ -1,28 +0,0 @@ -extends: - ".eslint-default-config.yml" - -rules: - camelcase: [2, {"properties": "always"}] - comma-dangle: [2, "never"] - dot-location: [2, "property"] - lines-around-comment: 0 - newline-after-var: 0 - no-alert: 2 - no-console: 2 - no-debugger: 2 - no-else-return: 2 - no-unmodified-loop-condition: 0 - object-curly-spacing: [2, "always"] - operator-linebreak: [2, "after"] - space-before-function-paren: [2, {"anonymous": "always", "named": "never"}] # JSLint style - strict: 0 - quotes: [2, "single"] - - no-trailing-spaces: ["error", { "skipBlankLines": true }] - indent: ["error", "tab", {SwitchCase: 1}] - no-nested-ternary: 0 - no-invalid-this: 0 - eol-last: 0 - require-jsdoc: 0 - brace-style: [2, "1tbs", { "allowSingleLine": true }] - wrap-iife: [2, "any"] \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..df371ef --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,115 @@ +{ + "env": { + "browser": true + }, + "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint"], + "parserOptions": { + "project": "./tsconfig.json", + "ecmaVersion": 2020, + "sourceType": "module" + }, + "ignorePatterns": [ + "node_modules", + "dist", + "**/*.d.ts", + "**/*.test.ts", + "gulpfile.js", + "jest.config.js", + "test-setup.ts", + "compileExterns.js", + "grouped-categories-old.js" + ], + "rules": { + "camelcase": [ + 2, + { + "properties": "always" + } + ], + "capitalized-comments": ["warn", "always", { "ignoreConsecutiveComments": true }], + "class-methods-use-this": 0, + "comma-dangle": [ + 2, + "never" + ], + "consistent-return": 1, + "default-case": 1, + "default-param-last": 1, + "dot-notation": 0, + "function-paren-newline": 0, + "func-style": 0, + "indent": 2, + "max-len": [ + "error", + { + "code": 120, + "comments": 120, + "ignoreUrls": true, + "ignoreComments": true + } + ], + "no-dupe-class-members": 2, + "no-inner-declarations": 2, + "no-invalid-this": 0, + "no-shadow": 2, + "no-undef": 0, + "no-underscore-dangle": 2, + "no-unused-expressions": 0, + "no-use-before-define": 2, + "no-useless-constructor": 2, + "no-useless-escape": 2, + "no-useless-return": 2, + "object-curly-spacing": [2, "always"], + "object-shorthand": 2, + "prefer-const": 1, + "prefer-spread": 0, + "prefer-rest-params": 0, + "require-unicode-regexp": 2, + "quote-props": [2, "as-needed", { "keywords": true, "unnecessary": false }], + "semi": 2, + "@typescript-eslint/array-type": [2, { "default": "array-simple" }], + "@typescript-eslint/consistent-type-assertions": 2, + "@typescript-eslint/explicit-function-return-type": [ + "error", + { + "allowExpressions": false, + "allowTypedFunctionExpressions": false + } + ], + "@typescript-eslint/indent": [ + "error", + 4, + { + "FunctionExpression": { "parameters": 1 }, + "SwitchCase": 1 + } + ], + "@typescript-eslint/no-empty-function": 2, + "@typescript-eslint/no-empty-interface": 2, + "@typescript-eslint/no-explicit-any": 2, + "@typescript-eslint/no-floating-promises": 1, + "@typescript-eslint/no-inferrable-types": 1, + "@typescript-eslint/no-namespace": 2, + "@typescript-eslint/no-this-alias": 0, + "@typescript-eslint/no-unnecessary-type-assertion": 2, + "@typescript-eslint/no-unsafe-argument": 2, + "@typescript-eslint/no-unsafe-assignment": 2, + "@typescript-eslint/no-unsafe-call": 2, + "@typescript-eslint/no-unsafe-member-access": 2, + "@typescript-eslint/no-unsafe-return": 2, + "@typescript-eslint/no-unsafe-declaration-merging": 2, + "@typescript-eslint/no-unused-vars": 2, + "@typescript-eslint/no-use-before-define": 2, + "@typescript-eslint/no-useless-constructor": 2, + "@typescript-eslint/prefer-as-const": 1, + "@typescript-eslint/prefer-includes": 2, + "@typescript-eslint/prefer-regexp-exec": 2, + "@typescript-eslint/prefer-string-starts-ends-with": 0, + "@typescript-eslint/restrict-plus-operands": 1, + "@typescript-eslint/restrict-template-expressions": 1, + "@typescript-eslint/semi": 2, + "@typescript-eslint/unbound-method": 0 + } +} diff --git a/.gitignore b/.gitignore index 5e52727..de7063e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /node_modules package-lock.json +.DS_Store +**/.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7225562 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,55 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [Unreleased] + +### Added +- TypeScript rewrite of the plugin in `ts/groupedCategories.ts`, aligned to Highcharts APIs. +- Demo page `demo.html` showcasing basic, nested, and styled grouped categories. +- Build pipeline with Gulp + TypeScript + Closure Compiler outputting to `dist/`. +- Source maps for minified build. +- ESLint configuration with TypeScript rules and stable parser settings. +- tsconfig with ES2020 targets and declaration output. +- NPM scripts: `build`, `build:gulp`, `lint`, `compile`, `clean`, `test`, `test:watch`. +- README overhaul with usage, development, and testing instructions. + +### Changed +- Path rendering logic to match legacy plugin behavior (using 'M'/'L' commands). +- Grid path buffer type to accept string/number for correct SVG path serialization. +- `groupSize` calculations to mirror legacy spacing/offset behavior. +- Font metrics handling for compatibility across HC versions. +- Relaxed type boundaries at Highcharts internal interop points to avoid false negatives. +- Gulp lint task to correctly complete and ignore declaration/test files. +- Closure Compiler flow to avoid duplicate sourcemap key collisions by splitting stages. +- Babel configuration to preserve ES2020 when desired. + +### Fixed +- #111 +- #144 +- #148 +- #149 +- #150 +- #163 +- #177 +- #179 +- #181 +- #185 +- #197 +- #220 +- #227 +- #228 +- #206 +- #212 +- #232 + +### Notes / Migration +- Use `npm run build` for TypeScript build only, `npm run build:gulp` for full pipeline. +- Tests require Highcharts installed (dev dep recommended): `npm i -D highcharts`. +- To install Highcharts from GitHub, prefer `--ignore-scripts` or use the published package. +- Recommended editor settings: ensure ESLint extension uses the workspace config. + +--- + +## [1.3.2] - 2025-09-23 +- Legacy JS plugin version; current release supersedes it with TS rewrite and modern tooling. diff --git a/bug1.html b/bug1.html new file mode 100644 index 0000000..f655af0 --- /dev/null +++ b/bug1.html @@ -0,0 +1,68 @@ + + + + + + + +Grouped Categories - Highcharts module + + + + + + +
+ +
+ +
+ + + + + + diff --git a/bug2.html b/bug2.html new file mode 100644 index 0000000..0fe2062 --- /dev/null +++ b/bug2.html @@ -0,0 +1,98 @@ + + + + + + + +Grouped Categories - Highcharts module + + + + + + + +
+ +
+ +
+ + + + + + diff --git a/bug3.html b/bug3.html new file mode 100644 index 0000000..f41a0d3 --- /dev/null +++ b/bug3.html @@ -0,0 +1,709 @@ + + + + + + + +Grouped Categories - Highcharts module + + + + + + + +
+ +
+ +
+ + + + + + diff --git a/bug4.html b/bug4.html new file mode 100644 index 0000000..b9bb750 --- /dev/null +++ b/bug4.html @@ -0,0 +1,52 @@ + + + + + + + +Grouped Categories - Highcharts module + + + + + + + +
+ +
+ +
+ + + + + + diff --git a/bug5.html b/bug5.html new file mode 100644 index 0000000..13b9926 --- /dev/null +++ b/bug5.html @@ -0,0 +1,82 @@ + + + + + + + +Grouped Categories - Highcharts module + + + + + + + +
+ +
+ +
+ + + + + + diff --git a/compileExterns.js b/compileExterns.js new file mode 100644 index 0000000..a005255 --- /dev/null +++ b/compileExterns.js @@ -0,0 +1,2 @@ +function Highcharts () {} +function module () {} \ No newline at end of file diff --git a/demo.html b/demo.html new file mode 100644 index 0000000..a33e9d4 --- /dev/null +++ b/demo.html @@ -0,0 +1,203 @@ + + + + + + Highcharts Grouped Categories Demo + + + + + + +
+

Highcharts Grouped Categories Demo

+ +
+

Basic Grouped Categories

+

This chart demonstrates simple two-level grouping with fruits and vegetables.

+
+ +
+
+
+ +
+

Nested Grouped Categories

+

This chart shows three-level nesting with continents, countries, and cities.

+
+ +
+
+
+ +
+

Custom Styling

+

This chart demonstrates custom styling for different group levels.

+
+ +
+
+
+
+ + + + diff --git a/dist/grouped-categories.js b/dist/grouped-categories.js new file mode 100644 index 0000000..bde495a --- /dev/null +++ b/dist/grouped-categories.js @@ -0,0 +1,597 @@ +/** +---- +* +* (c) 2012-2025 Black Label +* +* License: MIT +*/ +(function (factory) { + if (typeof module === 'object' && module.exports) { + module.exports = factory; + } else { + factory(Highcharts); + } +}(function (Highcharts) { + + + + + +const { merge, pick, objectEach, isNumber, isObject, isString, pInt, format, Tick, Axis, SVGElement } = Highcharts; + +// Utility functions +const deepClone = (obj) => JSON.parse(JSON.stringify(obj)); +const sum = (arr) => arr.reduce((acc, val) => acc + val, 0); +const walk = (arr, key, fn) => { + for (let i = arr.length - 1; i >= 0; i--) { + const children = arr[i][key]; + if (children) { + walk(children, key, fn); + } + fn(arr[i]); + } +}; +// Category class +class Category { + constructor(obj, parent) { + this.userOptions = deepClone(obj); + this.name = typeof obj === 'string' ? obj : (obj.name || ''); + this.parent = parent; + } + toString() { + const parts = []; + let cat = this; + while (cat) { + parts.push(cat.name); + cat = cat.parent; + } + return parts.join(', '); + } +} +// Add category leaf to array +const addLeaf = (out, cat, parent) => { + out.unshift(new Category(cat, parent)); + let currentParent = parent; + while (currentParent) { + currentParent.leaves = (currentParent.leaves || 0) + 1; + currentParent = currentParent.parent; + } +}; +// Builds reverse category tree +const buildTree = (cats, out, options, parent, depth = 0) => { + options.depth = options.depth || 0; + for (let i = cats.length - 1; i >= 0; i--) { + const cat = cats[i]; + if (typeof cat === 'object' && cat.categories) { + if (parent) { + cat.parent = parent; + } + buildTree(cat.categories, out, options, cat, depth + 1); + } + else { + addLeaf(out, cat, parent); + } + } + options.depth = Math.max(options.depth, depth); +}; +// Pushes part of grid to path +const addGridPart = (path, d, width) => { + // Based on crispLine from HC (#65) + if (d[0] === d[2]) { + d[0] = d[2] = Math.round(d[0]) - (width % 2 / 2); + } + if (d[1] === d[3]) { + d[1] = d[3] = Math.round(d[1]) + (width % 2 / 2); + } + path.push('M', d[0], d[1], 'L', d[2], d[3]); +}; +// Returns tick position +const tickPosition = (tick, pos) => { + return tick.getPosition(tick.axis.horiz, pos, tick.axis.tickmarkOffset); +}; +// Create local function `fontMetrics` to provide compatibility with HC 11 (#200) +const fontMetrics = (fontSize, chart, elem) => { + let fontSizeNum; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore: workaround for old IE, window.getComputedStyle always exists in modern browsers + if ((chart?.renderer.styledMode || (isString(fontSize) && fontSize.includes('px'))) && window.getComputedStyle) { + fontSizeNum = elem && SVGElement.prototype.getStyle.call(elem, 'font-size'); + } + else { + fontSizeNum = fontSize || elem?.styles.fontSize || chart?.renderer.style.fontSize; + } + if (isString(fontSizeNum) && fontSizeNum.includes('px')) { + fontSizeNum = pInt(fontSizeNum); + } + else if (!isNumber(fontSizeNum) || isNaN(fontSizeNum)) { + fontSizeNum = 12; + } + const lineHeight = (fontSizeNum < 24 ? fontSizeNum + 3 : Math.round(fontSizeNum * 1.2)); + const baseline = Math.round(lineHeight * 0.8); + return { + h: lineHeight, + b: baseline, + f: fontSizeNum + }; +}; +// Adjusts the tick label's CSS to handle overflow, hiding or truncating the label +// if it does not fit within its allocated slot width. This ensures that labels +// do not overlap or extend beyond their bounds in grouped category axes. +const adjustTickLabelOverflow = (axis, groupedTick, leaves, depth) => { + if (groupedTick.label) { + const horiz = axis.horiz; + const categoriesLength = axis.categories?.length || 1; + const labelHeight = fontMetrics(groupedTick.label?.styles.fontSize || 0, axis.chart).h; + const groupSlotHeight = horiz ? + Math.abs(axis.groupSize(depth, groupedTick.label.getBBox().height)) : + (axis.height / categoriesLength) * leaves; + const groupSlotWidth = horiz ? + (axis.width / categoriesLength) * leaves : + Math.abs(axis.groupSize(depth, groupedTick.label.getBBox().width)); + if (axis.options.labels.step === 1) { + // Handle width case, #220 + if (groupSlotWidth < 15) { + groupedTick.label.css({ + display: 'none', + width: undefined, + textOverflow: undefined + }); + } + else if (groupedTick.rotation !== -90 && + groupedTick.rotation !== 90 && + groupedTick.label && + (groupedTick.label.getBBox().width > groupSlotWidth || + (groupedTick.label.getBBox().width === 0 && + groupedTick.label.styles.width && + groupSlotWidth === +groupedTick.label.styles.width))) { + groupedTick.label.css({ + display: 'block', + width: groupSlotWidth.toString(), + textOverflow: 'ellipsis' + }); + } + else if (groupedTick.label.styles.textOverflow !== 'ellipsis' || + (groupedTick.label.styles.width && groupSlotWidth > +groupedTick.label.styles.width)) { + groupedTick.label.css({ + display: 'block', + width: undefined, + textOverflow: undefined + }); + } + // Handle height case, #177 + if (labelHeight > groupSlotHeight) { + groupedTick.label.css({ + display: 'none', + width: undefined, + textOverflow: undefined + }); + } + } + } +}; +// Main plugin implementation +// Cache prototypes +const axisProto = Axis.prototype; +const tickProto = Tick.prototype; +// Cache original methods +const protoAxisInit = axisProto.init; +const protoAxisRender = axisProto.render; +const protoAxisSetCategories = axisProto.setCategories; +const protoTickGetLabelSize = tickProto.getLabelSize; +const protoTickAddLabel = tickProto.addLabel; +const protoTickDestroy = tickProto.destroy; +const protoTickRender = tickProto.render; +const protoTickReplaceMovedLabel = tickProto.replaceMovedLabel; +// Axis prototype extensions +axisProto.init = function (chart, options, coll) { + if (typeof options === 'object' && options.categories) { + options = merge(options, { labels: { step: 1 } }); // #220 + } + protoAxisInit.call(this, chart, options, coll); + if (isObject(options) && options.categories) { + this.setupGroups(options); + } +}; +// Setup required axis options +axisProto.setupGroups = function (options) { + const axis = this; + const chart = axis.chart; + const categories = deepClone(options.categories || []); + const reverseTree = []; + const stats = { depth: 0 }; + const labelOptions = axis.options.labels; + const userAttr = labelOptions && labelOptions.groupedOptions; + const css = labelOptions && labelOptions.style; + buildTree(categories, reverseTree, stats); + axis.isGrouped = stats.depth !== 0; + if (axis.isGrouped) { + axis.categoriesTree = categories; + axis.categories = reverseTree; + axis.labelsDepth = stats.depth; + axis.labelsSizes = []; + axis.labelsGridPath = []; + axis.tickLength = options.tickLength || axis.tickLength || null; + axis.tickWidth = pick(options.tickWidth, axis.isXAxis ? 1 : 0); + axis.directionFactor = [-1, 1, 1, -1][axis.side]; + axis.options.lineWidth = pick(options.lineWidth, 1); + axis.groupFontHeights = []; + for (let i = 0; i <= stats.depth; i++) { + const hasOptions = userAttr && userAttr[i - 1]; + const mergedCSS = hasOptions && userAttr[i - 1].style ? merge(css, userAttr[i - 1].style) : css; + axis.groupFontHeights[i] = + Math.round(fontMetrics(mergedCSS.fontSize ? mergedCSS.fontSize : 0, chart).b * 0.3); // TODO why * 0.3? + } + } +}; +axisProto.render = function () { + const axis = this; + if (axis.isGrouped) { + axis.labelsGridPath = []; + } + if (axis.originalTickLength === undefined) { + axis.originalTickLength = axis.options.tickLength; + } + axis.options.tickLength = axis.isGrouped ? 0.001 : axis.originalTickLength; + protoAxisRender.call(axis); + if (!axis.isGrouped) { + if (axis.labelsGrid) { + axis.labelsGrid.attr({ visibility: 'hidden' }); + } + return false; + } + const options = axis.options; + const top = axis.top; + const left = axis.left; + const right = left + axis.width; + const bottom = top + axis.height; + const visible = axis.hasVisibleSeries || axis.hasData(); // #185 + let depth = axis.labelsDepth || 0; + let grid = axis.labelsGrid; + const horiz = axis.horiz; + const d = axis.labelsGridPath; + let i = options.drawHorizontalBorders === false ? (depth + 1) : 0; // TODO: check if this is needed + let offset = axis.opposite ? (horiz ? top : right) : (horiz ? bottom : left); + const tickWidth = axis.tickWidth || 0; + let part; + if (axis.userTickLength) { + depth -= 1; + } + if (!grid) { + grid = axis.labelsGrid = axis.chart.renderer.path() + .attr({ + strokeWidth: tickWidth, + 'stroke-width': tickWidth, + stroke: options.tickColor || '' + }) + .add(axis.axisGroup); + if (!options.tickColor) { + grid.addClass('highcharts-tick'); + } + } + while (i <= depth) { + offset += axis.groupSize(i); + part = horiz ? [left, offset, right, offset] : [offset, top, offset, bottom]; + addGridPart(d, part, tickWidth); + i++; + } + // TODO: fix it + grid.attr({ d: d, visibility: visible ? 'visible' : 'hidden' }); + axis.labelGroup?.attr({ visibility: visible ? 'visible' : 'hidden' }); + // TODO check if this assertion is correct, fix it + walk((axis.categoriesTree || []), 'categories', (group) => { + const tick = group.tick; + if (!tick) { + return false; + } + if ((axis.min && tick.startAt + tick.leaves - 1 < axis.min) || (axis.max && tick.startAt > axis.max)) { + tick.label?.hide(); + tick.destroyed = 0; // TODO do we need this? + } + else { + tick.label?.attr({ visibility: visible ? 'visible' : 'hidden' }); + } + return true; + }); + return true; +}; +axisProto.setCategories = function (newCategories, doRedraw) { + const axis = this; + if (axis.categories) { + axis.cleanGroups(); + } + axis.setupGroups({ categories: newCategories }); + axis.categories = axis.userOptions.categories = newCategories; + if (axis.categories.every((cat) => isString(cat))) { + protoAxisSetCategories.call(axis, axis.categories, doRedraw); + } +}; +axisProto.cleanGroups = function () { + const axis = this; + const ticks = axis.ticks; + for (const n in ticks) { + if (ticks[n].parent) { + delete (ticks[n]).parent; + } + } + // TODO check it here, fix it + walk((axis.categoriesTree || []), 'categories', (group) => { + const tick = group.tick; + if (!tick) { + return false; + } + tick.label?.destroy(); + objectEach(tick, (_v, i) => delete tick[i]); + delete group.tick; + return true; + }); + axis.labelsGrid = null; +}; +axisProto.groupSize = function (level, position) { + const axis = this; + const positions = (axis.labelsSizes || []); + const direction = (axis.directionFactor || 1); + const groupedOptions = axis.options.labels && axis.options.labels.groupedOptions && isNumber(level) ? + axis.options.labels.groupedOptions[level - 1] : false; + let userXY = 0; + if (groupedOptions) { + if (direction === -1) { + userXY = groupedOptions.x || 0; + } + else { + userXY = groupedOptions.y || 0; + } + } + if (isNumber(level) && position !== undefined) { + // TODO - Why + 10? Should be like this for sure? Try use label.distance here + positions[level] = Math.max(positions[level] || 0, position + 10 + Math.abs(userXY)); + } + if (level === true) { + return sum(positions) * direction; + } + else if (isNumber(level) && positions[level]) { + return positions[level] * direction; + } + return 0; +}; +// Tick prototype extensions +tickProto.addLabel = function () { + const tick = this; + const axis = tick.axis; + const labelOptions = pick(tick.options && tick.options.labels, axis.options.labels); + let category; + // Initialize topLabelSize on the axis + axis.topLabelSize = 0; + protoTickAddLabel.call(tick); + // Not grouped axis should not be affected, #228 + if (!axis.isGrouped) { + return false; + } + if (!axis.categories || !(category = axis.categories[tick.pos])) { + return false; + } + if (tick.label) { + const formatter = function (ctx) { + if (labelOptions.formatter) { + return labelOptions.formatter.call(ctx, ctx); + } + if (labelOptions.format) { + ctx.text = axis.defaultLabelFormatter.call(ctx); + return format(labelOptions.format, ctx, axis.chart); + } + return axis.defaultLabelFormatter.call(ctx); + }; + tick.label.attr('text', formatter({ + axis: axis, // TODO: fix it + tick: tick, // TODO: fix it + chart: axis.chart, + isFirst: !!tick.isFirst, + isLast: !!tick.isLast, + value: isObject(category) ? category.name : category, + pos: tick.pos + })); + // #232 - calculate textPxLength based on rotation + const absRotation = Math.abs(tick.label.rotation || 0); + const reducedRotation = absRotation > 180 ? absRotation - (Math.floor(absRotation / 180) * 180) : absRotation; + tick.label.textPxLength = reducedRotation > 45 && reducedRotation < 135 ? + tick.label.getBBox().height : tick.label.getBBox().width; + } + if (axis.isGrouped && axis.options.labels.enabled && !isString(category)) { + tick.addGroupedLabels(category); + } + return true; +}; +tickProto.addGroupedLabels = function (category) { + const tick = this; + const axis = tick.axis; + const chart = axis.chart; + const options = axis.options.labels; + const useHTML = options.useHTML; + const css = options.style; + const userAttr = options.groupedOptions; + const attr = { align: 'center', rotation: options.rotation, x: 0, y: 0, style: undefined }; + const sizeKey = axis.horiz ? 'height' : 'width'; + let depth = 0; + let label; + let currentTick = tick; + let currentCategory = category; + while (currentTick) { + if (currentCategory && depth > 0 && !currentCategory.tick) { + tick.value = currentCategory.name; + const ctx = { + chart, + axis: axis, // TODO: fix it + tick: tick, // TODO: fix it + isFirst: !!tick.isFirst, + isLast: !!tick.isLast, + value: currentCategory.name, + pos: tick.pos + }; + const name = options.formatter ? options.formatter.call(ctx, ctx) : currentCategory.name; + const hasOptions = userAttr && userAttr[depth - 1]; + const mergedAttrs = hasOptions ? merge(attr, userAttr[depth - 1]) : attr; + const mergedCSS = hasOptions && userAttr[depth - 1].style ? merge(css, userAttr[depth - 1].style) : css; + delete mergedAttrs.style; + label = chart.renderer.text(name, 0, 0, useHTML).attr(mergedAttrs).add(axis.labelGroup); + if (label && !chart.styledMode) { + label.css(mergedCSS); + } + currentTick.startAt = tick.pos; + currentTick.childCount = (currentCategory.categories || []).length; + currentTick.leaves = currentCategory.leaves; + currentTick.visible = !!currentTick.childCount; + currentTick.label = label; + currentTick.labelOffsets = { x: mergedAttrs.x, y: mergedAttrs.y }; + currentCategory.tick = currentTick; + } + if (currentTick && currentTick.label) { + axis.groupSize(depth, (currentTick.label.getBBox())[sizeKey]); + } + currentCategory = currentCategory?.parent; + if (currentCategory) { + currentTick = currentTick.parent = currentCategory.tick || {}; + } + else { + currentTick = undefined; + } + depth++; + } +}; +tickProto.render = function (index, old, opacity) { + protoTickRender.call(this, index, old, opacity); + const tick = this; + const axis = tick.axis; + const treeCat = axis.categories && axis.categories[tick.pos]; + if (!axis.isGrouped || !treeCat || (axis.max && tick.pos > axis.max)) { + return; + } + const tickPos = tick.pos; + const isFirst = tick.isFirst; + const max = axis.max; + const min = axis.min; + const horiz = axis.horiz; + const grid = axis.labelsGridPath; + const tickWidth = axis.tickWidth || 0; + const xy = tickPosition(tick, tickPos); + const start = horiz ? xy.y : xy.x; + let group = tick; + let size = axis.groupSize(0); + let depth = 1; + let reverseCrisp = ((horiz && xy.x === axis.pos + axis.len) || (!horiz && xy.y === axis.pos)) ? -1 : 0; + let gridAttrs; + let lvlSize; + let minPos; + let maxPos; + let attrs; + if (isFirst) { + // Grid part for first tick, handles reversed axis, #144 + if (horiz) { + const gridX = axis.reversed ? axis.pos + axis.len : axis.left; + gridAttrs = [gridX, xy.y, gridX, xy.y + axis.groupSize(true)]; + } + else { + const gridY = axis.reversed ? axis.top : axis.pos + axis.len; + gridAttrs = axis.isXAxis ? + [xy.x, gridY, xy.x + axis.groupSize(true), gridY] : + [xy.x, axis.top + axis.len, xy.x + axis.groupSize(true), axis.top + axis.len]; + } + addGridPart(grid, gridAttrs, tickWidth); + } + 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) { + let ret = 0; + if (isFirst && !isString(tCat)) { + ret = (tCat.parent?.categories || []).indexOf(tCat.name); + ret = ret < 0 ? 0 : ret; + return ret; + } + return ret; + } + adjustTickLabelOverflow(axis, tick, tick.leaves || 1, 0); // #220 + while (group.parent) { + group = group.parent; + const fix = fixOffset(treeCat); + const userX = group.labelOffsets?.x || 0; + const userY = group.labelOffsets?.y || 0; + const groupFontHeight = axis.groupFontHeights?.[depth] || 0; + minPos = tickPosition(tick, min ? Math.max(group.startAt - 1, min - 1) : group.startAt - 1); + maxPos = tickPosition(tick, max ? Math.min(group.startAt + group.leaves - 1 - fix, max) : group.startAt + group.leaves - 1 - fix); + lvlSize = axis.groupSize(depth); + reverseCrisp = ((horiz && maxPos.x === axis.pos + axis.len) || (!horiz && maxPos.y === axis.pos)) ? -1 : 0; + adjustTickLabelOverflow(axis, group, group.leaves || 1, depth); // #220 + attrs = horiz ? { + x: (minPos.x + maxPos.x) / 2 + userX, + y: size + groupFontHeight + lvlSize / 2 + userY / 2 + } : { + x: size + lvlSize / 2 + userX, + y: (minPos.y + maxPos.y) / 2 + groupFontHeight + userY // #197 + }; + 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 = size + lvlSize; + depth++; + } +}; +tickProto.destroy = function () { + const tick = this; + const useHTML = tick.axis.options.labels.useHTML; + let group = tick.parent; + while (group) { + group.destroyed = group.destroyed ? (group.destroyed + 1) : 1; // TODO do we need this? + // Destroy label if it's HTML, #163 + if (useHTML && group.label?.element) { + group.label?.destroy(); + } + group = group.parent; + } + protoTickDestroy.call(tick); +}; +tickProto.getLabelSize = function () { + const tick = this; + const axis = tick.axis; + if (!axis.isGrouped) { + return protoTickGetLabelSize.call(tick); + } + if (!axis.labelsSizes) { + axis.labelsSizes = []; + } + // Axis labels distance option should be taken into account, #206 + // The default for a.reduce((b,c)=>b+c,0),A=(a,b,c)=>{for(let d=a.length-1;0<=d;d--){const h=a[d][b];h&&A(h,b,c);c(a[d])}};class Q{constructor(a,b){this.userOptions=JSON.parse(JSON.stringify(a));this.name="string"===typeof a?a:a.name||"";this.parent=b}toString(){const a=[];let b=this;for(;b;)a.push(b.name), +b=b.parent;return a.join(", ")}}const D=(a,b,c,d,h=0)=>{c.depth=c.depth||0;for(let l=a.length-1;0<=l;l--){const p=a[l];if("object"===typeof p&&p.categories)d&&(p.parent=d),D(p.categories,b,c,p,h+1);else{var e=d;for(b.unshift(new Q(p,e));e;)e.leaves=(e.leaves||0)+1,e=e.parent}}c.depth=Math.max(c.depth,h)},v=(a,b,c)=>{b[0]===b[2]&&(b[0]=b[2]=Math.round(b[0])-c%2/2);b[1]===b[3]&&(b[1]=b[3]=Math.round(b[1])+c%2/2);a.push("M",b[0],b[1],"L",b[2],b[3])},E=(a,b,c)=>{a=(b?.renderer.styledMode||w(a)&&a.includes("px"))&& +window.getComputedStyle?c&&P.prototype.getStyle.call(c,"font-size"):a||c?.styles.fontSize||b?.renderer.style.fontSize;if(w(a)&&a.includes("px"))a=L(a);else if(!y(a)||isNaN(a))a=12;b=24>a?a+3:Math.round(1.2*a);return{h:b,b:Math.round(.8*b),f:a}},F=(a,b,c,d)=>{if(b.label){const h=a.horiz,e=a.categories?.length||1,l=E(b.label?.styles.fontSize||0,a.chart).h,p=h?Math.abs(a.groupSize(d,b.label.getBBox().height)):a.height/e*c;c=h?a.width/e*c:Math.abs(a.groupSize(d,b.label.getBBox().width));1===a.options.labels.step&& +(15>c?b.label.css({display:"none",width:void 0,textOverflow:void 0}):-90!==b.rotation&&90!==b.rotation&&b.label&&(b.label.getBBox().width>c||0===b.label.getBBox().width&&b.label.styles.width&&c===+b.label.styles.width)?b.label.css({display:"block",width:c.toString(),textOverflow:"ellipsis"}):("ellipsis"!==b.label.styles.textOverflow||b.label.styles.width&&c>+b.label.styles.width)&&b.label.css({display:"block",width:void 0,textOverflow:void 0}),l>p&&b.label.css({display:"none",width:void 0,textOverflow:void 0}))}}; +t=O.prototype;const u=N.prototype,R=t.init,S=t.render,T=t.setCategories,G=u.getLabelSize,U=u.addLabel,V=u.destroy,W=u.render,X=u.replaceMovedLabel;t.init=function(a,b,c){"object"===typeof b&&b.categories&&(b=x(b,{labels:{step:1}}));R.call(this,a,b,c);B(b)&&b.categories&&this.setupGroups(b)};t.setupGroups=function(a){const b=this.chart;var c=JSON.parse(JSON.stringify(a.categories||[]));const d=[],h={depth:0};var e=this.options.labels;const l=e&&e.groupedOptions;e=e&&e.style;D(c,d,h);if(this.isGrouped= +0!==h.depth)for(this.categoriesTree=c,this.categories=d,this.labelsDepth=h.depth,this.labelsSizes=[],this.labelsGridPath=[],this.tickLength=a.tickLength||this.tickLength||null,this.tickWidth=z(a.tickWidth,this.isXAxis?1:0),this.directionFactor=[-1,1,1,-1][this.side],this.options.lineWidth=z(a.lineWidth,1),this.groupFontHeights=[],a=0;a<=h.depth;a++)c=l&&l[a-1]&&l[a-1].style?x(e,l[a-1].style):e,this.groupFontHeights[a]=Math.round(.3*E(c.fontSize?c.fontSize:0,b).b)};t.render=function(){const a=this; +a.isGrouped&&(a.labelsGridPath=[]);void 0===a.originalTickLength&&(a.originalTickLength=a.options.tickLength);a.options.tickLength=a.isGrouped?.001:a.originalTickLength;S.call(a);if(!a.isGrouped)return a.labelsGrid&&a.labelsGrid.attr({visibility:"hidden"}),!1;var b=a.options;const c=a.top,d=a.left,h=d+a.width,e=c+a.height,l=a.hasVisibleSeries||a.hasData();let p=a.labelsDepth||0,f=a.labelsGrid;const q=a.horiz,g=a.labelsGridPath;let n=!1===b.drawHorizontalBorders?p+1:0,r=a.opposite?q?c:h:q?e:d;const m= +a.tickWidth||0;a.userTickLength&&--p;f||(f=a.labelsGrid=a.chart.renderer.path().attr({strokeWidth:m,"stroke-width":m,stroke:b.tickColor||""}).add(a.axisGroup),b.tickColor||f.addClass("highcharts-tick"));for(;n<=p;)r+=a.groupSize(n),b=q?[d,r,h,r]:[r,c,r,e],v(g,b,m),n++;f.attr({d:g,visibility:l?"visible":"hidden"});a.labelGroup?.attr({visibility:l?"visible":"hidden"});A(a.categoriesTree||[],"categories",k=>{k=k.tick;if(!k)return!1;a.min&&k.startAt+k.leaves-1a.max?(k.label?.hide(), +k.destroyed=0):k.label?.attr({visibility:l?"visible":"hidden"});return!0});return!0};t.setCategories=function(a,b){this.categories&&this.cleanGroups();this.setupGroups({categories:a});this.categories=this.userOptions.categories=a;this.categories.every(c=>w(c))&&T.call(this,this.categories,b)};t.cleanGroups=function(){const a=this.ticks;for(const b in a)a[b].parent&&delete a[b].parent;A(this.categoriesTree||[],"categories",b=>{const c=b.tick;if(!c)return!1;c.label?.destroy();K(c,(d,h)=>delete c[h]); +delete b.tick;return!0});this.labelsGrid=null};t.groupSize=function(a,b){const c=this.labelsSizes||[],d=this.directionFactor||1,h=this.options.labels&&this.options.labels.groupedOptions&&y(a)?this.options.labels.groupedOptions[a-1]:!1;let e=0;h&&(e=-1===d?h.x||0:h.y||0);y(a)&&void 0!==b&&(c[a]=Math.max(c[a]||0,b+10+Math.abs(e)));return!0===a?C(c)*d:y(a)&&c[a]?c[a]*d:0};u.addLabel=function(){const a=this.axis;var b=z(this.options&&this.options.labels,a.options.labels);let c;a.topLabelSize=0;U.call(this); +if(!a.isGrouped||!a.categories||!(c=a.categories[this.pos]))return!1;if(this.label){var d=this.label,h=d.attr;var e={axis:a,tick:this,chart:a.chart,isFirst:!!this.isFirst,isLast:!!this.isLast,value:B(c)?c.name:c,pos:this.pos};b.formatter?b=b.formatter.call(e,e):b.format?(e.text=a.defaultLabelFormatter.call(e),b=M(b.format,e,a.chart)):b=a.defaultLabelFormatter.call(e);h.call(d,"text",b);d=Math.abs(this.label.rotation||0);d=180d?this.label.getBBox().height: +this.label.getBBox().width}a.isGrouped&&a.options.labels.enabled&&!w(c)&&this.addGroupedLabels(c);return!0};u.addGroupedLabels=function(a){const b=this.axis,c=b.chart,d=b.options.labels,h=d.useHTML,e=d.style,l=d.groupedOptions,p={align:"center",rotation:d.rotation,x:0,y:0,style:void 0},f=b.horiz?"height":"width";let q=0;let g=this;for(;g;){if(a&&0a.max)){c=this.isFirst;var d=a.max,h=a.min,e=a.horiz,l=a.labelsGridPath,p=a.tickWidth||0,f=this.getPosition(this.axis.horiz,this.pos,this.axis.tickmarkOffset),q=e?f.y:f.x,g=this,n=a.groupSize(0),r=1,m=e&&f.x===a.pos+a.len||!e&&f.y===a.pos?-1:0;if(c){if(e){var k=a.reversed?a.pos+a.len:a.left;k=[k,f.y,k,f.y+a.groupSize(!0)]}else k=a.reversed?a.top:a.pos+a.len,k=a.isXAxis?[f.x,k,f.x+a.groupSize(!0),k]:[f.x,a.top+a.len,f.x+a.groupSize(!0),a.top+ +a.len];v(l,k,p)}e&&a.left<=f.x?v(l,[f.x-m,f.y,f.x-m,f.y+n],p):!e&&a.top<=f.y&&v(l,[f.x,f.y+m,f.x+n,f.y+m],p);n=q+n;for(F(a,this,this.leaves||1,0);g.parent;){g=g.parent;m=0;c&&!w(b)&&(m=(b.parent?.categories||[]).indexOf(b.name),m=0>m?0:m);const H=g.labelOffsets?.x||0,I=g.labelOffsets?.y||0,J=a.groupFontHeights?.[r]||0;q=this.getPosition(this.axis.horiz,h?Math.max(g.startAt-1,h-1):g.startAt-1,this.axis.tickmarkOffset);k=this.getPosition(this.axis.horiz,d?Math.min(g.startAt+g.leaves-1-m,d):g.startAt+ +g.leaves-1-m,this.axis.tickmarkOffset);f=a.groupSize(r);m=e&&k.x===a.pos+a.len||!e&&k.y===a.pos?-1:0;F(a,g,g.leaves||1,r);q=e?{x:(q.x+k.x)/2+H,y:n+J+f/2+I/2}:{x:n+f/2+H,y:(q.y+k.y)/2+J+I};isNaN(q.x)||isNaN(q.y)||(g.label?.attr(q),l&&(e&&a.left<=k.x?v(l,[k.x-m,n,k.x-m,n+f],p):!e&&a.top<=k.y&&v(l,[n,k.y+m,n+f,k.y+m],p)));n+=f;r++}}};u.destroy=function(){const a=this.axis.options.labels.useHTML;let b=this.parent;for(;b;)b.destroyed=b.destroyed?b.destroyed+1:1,a&&b.label?.element&&b.label?.destroy(), +b=b.parent;V.call(this)};u.getLabelSize=function(){const a=this.axis;if(!a.isGrouped)return G.call(this);a.labelsSizes||(a.labelsSizes=[]);const b=a.options.labels.distance||8,c=a.labelsSizes[0]||0,d=G.call(this)+2*b;c { + return tsProject + .src() + .pipe(tsProject()) + .js + .pipe(rename('grouped-categories.js')) + .pipe(through2.obj(async function (file, _encoding, callback) { + if (file.isBuffer()) { + let fileContent = file.contents.toString('utf8'); + const removedSpecifiers = [], + removedPaths = [], + importPathReg = /import (.+?) from ["'](.+?)["'];/g, + formattedPathReg = /^highcharts-github\/ts\//, + exportReg = /\bexport\s*{[^}]*};?/g, + utilsPathReg = /^.*Utilities.*$/m, + templatingPathReg = /^.*Templating.*$/m; + + fileContent = fileContent.replace(importPathReg, (_match, specifier, path) => { + removedSpecifiers.push(specifier); + removedPaths.push(`${path.replace(formattedPathReg, "")}.js`); + return ''; + }); + + fileContent = fileContent.replace( + utilsPathReg, + 'const { merge, pick, objectEach, isNumber, isObject, isString, pInt, format, Tick, Axis, SVGElement } = Highcharts;' + ); + fileContent = fileContent.replace(templatingPathReg, ''); + fileContent = fileContent.replace(exportReg, ''); + + const wrappedFileContent = decorator.join('\n') + +`(function (factory) { + if (typeof module === 'object' && module.exports) { + module.exports = factory; + } else { + factory(Highcharts); + } +}(function (Highcharts) { +${fileContent} +}));`; + file.contents = Buffer.from(wrappedFileContent, 'utf8'); + } + + this.push(file); + callback(); + })) + .pipe(gulp.dest('dist')) + .pipe(sourcemaps.init()) + .pipe(babel({ + presets: ['@babel/preset-env'], + overrides: [{ + presets: [["@babel/preset-env", { targets: "defaults" }]] + }] + })) + .pipe(closureCompiler({ + compilation_level: 'SIMPLE', + warning_level: 'DEFAULT', // VERBOSE + language_in: 'ECMASCRIPT_2020', + language_out: 'ECMASCRIPT_2020', + output_wrapper: '(function(){\n%output%\n}).call(this)', + js_output_file: 'grouped-categories.min.js', + externs: 'compileExterns.js' + })) + .pipe(sourcemaps.write('/')) + .pipe(gulp.dest('dist')) +}); gulp.task('lint', function () { - return gulp.src(['grouped-categories.js']) - .pipe(eslint()) - .pipe(eslint.failOnError()) - .pipe(eslint.formatEach()); -}); \ No newline at end of file + return gulp.src(['ts/**/*.ts', '!ts/**/*.d.ts', '!ts/**/*.test.ts'], { allowEmpty: true }) + .pipe(eslint()) + .pipe(eslint.format()) + .pipe(eslint.failAfterError()); +}); + +gulp.task('build', gulp.series('lint', 'compile')); + +gulp.task('watch', function () { + return gulp.watch('ts/*.ts', gulp.parallel(function(cb) { + gulp.series('compile')(function(err) { + if (err) console.log('Compile failed:', err.message); + cb(); + }); + })); +}); diff --git a/index.html b/index.html index 6d75756..d518ff6 100644 --- a/index.html +++ b/index.html @@ -9,8 +9,8 @@ Grouped Categories - Highcharts module - - + + @@ -41,13 +41,15 @@

Grouped Categories - Highcharts module

Go to project page to see this module in action: https://blacklabel.github.io/grouped_categories/

+
+ +

Requirements

@@ -176,7 +194,19 @@

Usage and demos

series: [{ data: [19, 6, 2, 1, 9, 4, 15, 2, 9, 11, 16, 18] }], - xAxis: { + xAxis: { + labels: { + groupedOptions: [{ + style: { + color: 'red' // set red font for labels in 1st-Level + }, + rotation: -90, + }, { + rotation: -90, // rotate labels for a 2nd-level + align: 'right' + }], + rotation: -90 // 0-level options aren't changed, use them as always + }, categories: [{ name: "America", categories: [{ diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..a4d5383 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,17 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'jsdom', + roots: ['/ts'], + testMatch: ['**/*.test.ts'], + transform: { + '^.+\\.ts$': 'ts-jest', + }, + collectCoverageFrom: [ + 'ts/**/*.ts', + '!ts/**/*.test.ts', + '!ts/**/*.d.ts' + ], + coverageDirectory: 'coverage', + coverageReporters: ['text', 'lcov', 'html'], + setupFilesAfterEnv: ['/test-setup.ts'], +}; diff --git a/package.json b/package.json index e613c62..33a05b3 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,20 @@ { "name": "highcharts-grouped-categories", - "version": "1.3.2", + "version": "2.0.0", "description": "Highcharts plugin to add grouped categories to charts.", - "main": "grouped-categories.js", + "types": "ts/groupedCategories.d.ts", + "main": "./dist/grouped-categories.js", + "scripts": { + "build": "tsc", + "build:ts": "tsc", + "build:gulp": "npx gulp build", + "build:gulp:watch": "npx gulp watch", + "test": "jest", + "test:watch": "jest --watch", + "lint": "npx gulp lint", + "compile": "npx gulp compile", + "clean": "rm -rf ts/*.js ts/*.d.ts ts/*.map" + }, "repository": { "type": "git", "url": "git+https://github.com/blacklabel/grouped_categories.git" @@ -11,19 +23,46 @@ "highcharts", "grouped", "categories", - "highcharts-addon" + "highcharts-addon", + "typescript" ], - "author": "Black Label (https://blacklabel.pl/highcharts/)", + "author": "Black Label (https://blacklabel.net)", "license": "SEE LICENSE IN license.txt", "bugs": { "url": "https://github.com/blacklabel/grouped_categories/issues" }, "homepage": "https://github.com/blacklabel/grouped_categories#readme", "files": [ - "grouped-categories.js" + "grouped-categories.js", + "ts/groupedCategories.js", + "ts/groupedCategories.d.ts" ], "devDependencies": { - "gulp": "^4.0.2", - "gulp-eslint": "^3.0.0" + "@babel/core": "^7.25.2", + "@babel/preset-env": "^7.25.4", + "@types/jest": "^29.5.0", + "@types/node": "^20.0.0", + "@types/trusted-types": "^2.0.2", + "@typescript-eslint/eslint-plugin": "^6.11.0", + "@typescript-eslint/parser": "^6.11.0", + "eslint": "^8.0.1", + "google-closure-compiler": "^20240317.0.0", + "gulp": "^5.0.0", + "gulp-babel": "^8.0.0", + "gulp-bump": "^3.2.0", + "gulp-eslint": "^6.0.0", + "gulp-git": "^2.11.0", + "gulp-rename": "^2.0.0", + "gulp-replace": "^1.1.4", + "gulp-sourcemaps": "3.0.0", + "gulp-typescript": "^6.0.0-alpha.1", + "highcharts": "^11.0.0", + "highcharts-github": "github:highcharts/highcharts", + "jest": "^29.5.0", + "jest-environment-jsdom": "^29.5.0", + "through2": "^4.0.2", + "ts-jest": "^29.1.1", + "ts-node": "^10.9.1", + "typescript": "^5.2.2" } } diff --git a/test-setup.ts b/test-setup.ts new file mode 100644 index 0000000..65cfc3d --- /dev/null +++ b/test-setup.ts @@ -0,0 +1,24 @@ +// Test setup file for Jest + +// Mock Highcharts if not available in test environment +if (typeof window === 'undefined') { + (global as any).window = {} as any; +} + +// Mock document if not available +if (typeof document === 'undefined') { + (global as any).document = { + createElement: jest.fn(), + body: { + appendChild: jest.fn(), + removeChild: jest.fn() + } + } as any; +} + +// Mock console methods to reduce noise in tests +(global as any).console = { + ...console, + warn: jest.fn(), + error: jest.fn(), +}; diff --git a/ts/HighchartsConfig.d.ts b/ts/HighchartsConfig.d.ts new file mode 100644 index 0000000..007b4fb --- /dev/null +++ b/ts/HighchartsConfig.d.ts @@ -0,0 +1,148 @@ +import 'highcharts-github/ts/masters/highcharts.src'; +import 'highcharts-github/ts/masters/highstock.src'; +import 'highcharts-github/ts/masters/highmaps.src'; +import 'highcharts-github/ts/masters/highcharts-3d.src'; +import 'highcharts-github/ts/masters/highcharts-gantt.src'; +import 'highcharts-github/ts/masters/highcharts-more.src'; + +import 'highcharts-github/ts/masters/modules/accessibility.src'; +import 'highcharts-github/ts/masters/modules/annotations-advanced.src'; +import 'highcharts-github/ts/masters/modules/annotations.src'; +import 'highcharts-github/ts/masters/modules/arc-diagram.src'; +import 'highcharts-github/ts/masters/modules/arrow-symbols.src'; +import 'highcharts-github/ts/masters/modules/boost-canvas.src'; +import 'highcharts-github/ts/masters/modules/boost.src'; +import 'highcharts-github/ts/masters/modules/broken-axis.src'; +import 'highcharts-github/ts/masters/modules/bullet.src'; +import 'highcharts-github/ts/masters/modules/coloraxis.src'; +import 'highcharts-github/ts/masters/modules/current-date-indicator.src'; +import 'highcharts-github/ts/masters/modules/cylinder.src'; +import 'highcharts-github/ts/masters/modules/data-tools.src'; +import 'highcharts-github/ts/masters/modules/data.src'; +import 'highcharts-github/ts/masters/modules/datagrouping.src'; +import 'highcharts-github/ts/masters/modules/debugger.src'; +import 'highcharts-github/ts/masters/modules/dependency-wheel.src'; +import 'highcharts-github/ts/masters/modules/dotplot.src'; +import 'highcharts-github/ts/masters/modules/drag-panes.src'; +import 'highcharts-github/ts/masters/modules/draggable-points.src'; +import 'highcharts-github/ts/masters/modules/drilldown.src'; +import 'highcharts-github/ts/masters/modules/dumbbell.src'; +import 'highcharts-github/ts/masters/modules/export-data.src'; +import 'highcharts-github/ts/masters/modules/exporting.src'; +import 'highcharts-github/ts/masters/modules/flowmap.src'; +import 'highcharts-github/ts/masters/modules/full-screen.src'; +import 'highcharts-github/ts/masters/modules/funnel.src'; +import 'highcharts-github/ts/masters/modules/funnel3d.src'; +import 'highcharts-github/ts/masters/modules/gantt.src'; +import 'highcharts-github/ts/masters/modules/geoheatmap.src'; +import 'highcharts-github/ts/masters/modules/grid-axis.src'; +import 'highcharts-github/ts/masters/modules/heatmap.src'; +import 'highcharts-github/ts/masters/modules/heikinashi.src'; +import 'highcharts-github/ts/masters/modules/histogram-bellcurve.src'; +import 'highcharts-github/ts/masters/modules/hollowcandlestick.src'; +import 'highcharts-github/ts/masters/modules/item-series.src'; +import 'highcharts-github/ts/masters/modules/lollipop.src'; +import 'highcharts-github/ts/masters/modules/map.src'; +import 'highcharts-github/ts/masters/modules/marker-clusters.src'; +import 'highcharts-github/ts/masters/modules/mouse-wheel-zoom.src'; +import 'highcharts-github/ts/masters/modules/networkgraph.src'; +import 'highcharts-github/ts/masters/modules/no-data-to-display.src'; + +// eslint-disable-next-line max-len +import 'highcharts-github/ts/Extensions/OfflineExporting/OfflineExportingVendor'; + +import 'highcharts-github/ts/masters/modules/offline-exporting.src'; +import 'highcharts-github/ts/masters/modules/organization.src'; +import 'highcharts-github/ts/masters/modules/overlapping-datalabels.src'; +import 'highcharts-github/ts/masters/modules/parallel-coordinates.src'; +import 'highcharts-github/ts/masters/modules/pareto.src'; +import 'highcharts-github/ts/masters/modules/pathfinder.src'; +import 'highcharts-github/ts/masters/modules/pattern-fill.src'; +import 'highcharts-github/ts/masters/modules/pictorial.src'; +import 'highcharts-github/ts/masters/modules/price-indicator.src'; +import 'highcharts-github/ts/masters/modules/pyramid3d.src'; +import 'highcharts-github/ts/masters/modules/sankey.src'; +import 'highcharts-github/ts/masters/modules/series-label.src'; +import 'highcharts-github/ts/masters/modules/series-on-point.src'; +import 'highcharts-github/ts/masters/modules/solid-gauge.src'; +import 'highcharts-github/ts/masters/modules/sonification.src'; +import 'highcharts-github/ts/masters/modules/static-scale.src'; +import 'highcharts-github/ts/masters/modules/stock-tools.src'; +import 'highcharts-github/ts/masters/modules/stock.src'; +import 'highcharts-github/ts/masters/modules/streamgraph.src'; +import 'highcharts-github/ts/masters/modules/sunburst.src'; +import 'highcharts-github/ts/masters/modules/tiledwebmap.src'; +import 'highcharts-github/ts/masters/modules/tilemap.src'; +import 'highcharts-github/ts/masters/modules/timeline.src'; +import 'highcharts-github/ts/masters/modules/treegraph.src'; +import 'highcharts-github/ts/masters/modules/treegrid.src'; +import 'highcharts-github/ts/masters/modules/treemap.src'; +import 'highcharts-github/ts/masters/modules/variable-pie.src'; +import 'highcharts-github/ts/masters/modules/variwide.src'; +import 'highcharts-github/ts/masters/modules/vector.src'; +import 'highcharts-github/ts/masters/modules/venn.src'; +import 'highcharts-github/ts/masters/modules/windbarb.src'; +import 'highcharts-github/ts/masters/modules/wordcloud.src'; +import 'highcharts-github/ts/masters/modules/xrange.src'; + +import 'highcharts-github/ts/masters/indicators/acceleration-bands.src'; +import 'highcharts-github/ts/masters/indicators/accumulation-distribution.src'; +import 'highcharts-github/ts/masters/indicators/ao.src'; +import 'highcharts-github/ts/masters/indicators/apo.src'; +import 'highcharts-github/ts/masters/indicators/aroon-oscillator.src'; +import 'highcharts-github/ts/masters/indicators/aroon.src'; +import 'highcharts-github/ts/masters/indicators/atr.src'; +import 'highcharts-github/ts/masters/indicators/bollinger-bands.src'; +import 'highcharts-github/ts/masters/indicators/cci.src'; +import 'highcharts-github/ts/masters/indicators/chaikin.src'; +import 'highcharts-github/ts/masters/indicators/cmf.src'; +import 'highcharts-github/ts/masters/indicators/cmo.src'; +import 'highcharts-github/ts/masters/indicators/dema.src'; +import 'highcharts-github/ts/masters/indicators/disparity-index.src'; +import 'highcharts-github/ts/masters/indicators/dmi.src'; +import 'highcharts-github/ts/masters/indicators/dpo.src'; +import 'highcharts-github/ts/masters/indicators/ema.src'; +import 'highcharts-github/ts/masters/indicators/ichimoku-kinko-hyo.src'; +import 'highcharts-github/ts/masters/indicators/indicators-all.src'; +import 'highcharts-github/ts/masters/indicators/indicators.src'; +import 'highcharts-github/ts/masters/indicators/keltner-channels.src'; +import 'highcharts-github/ts/masters/indicators/klinger.src'; +import 'highcharts-github/ts/masters/indicators/macd.src'; +import 'highcharts-github/ts/masters/indicators/mfi.src'; +import 'highcharts-github/ts/masters/indicators/momentum.src'; +import 'highcharts-github/ts/masters/indicators/natr.src'; +import 'highcharts-github/ts/masters/indicators/obv.src'; +import 'highcharts-github/ts/masters/indicators/pivot-points.src'; +import 'highcharts-github/ts/masters/indicators/ppo.src'; +import 'highcharts-github/ts/masters/indicators/price-channel.src'; +import 'highcharts-github/ts/masters/indicators/price-envelopes.src'; +import 'highcharts-github/ts/masters/indicators/psar.src'; +import 'highcharts-github/ts/masters/indicators/regressions.src'; +import 'highcharts-github/ts/masters/indicators/roc.src'; +import 'highcharts-github/ts/masters/indicators/rsi.src'; +import 'highcharts-github/ts/masters/indicators/slow-stochastic.src'; +import 'highcharts-github/ts/masters/indicators/stochastic.src'; +import 'highcharts-github/ts/masters/indicators/supertrend.src'; +import 'highcharts-github/ts/masters/indicators/tema.src'; +import 'highcharts-github/ts/masters/indicators/trendline.src'; +import 'highcharts-github/ts/masters/indicators/trix.src'; +import 'highcharts-github/ts/masters/indicators/volume-by-price.src'; +import 'highcharts-github/ts/masters/indicators/vwap.src'; +import 'highcharts-github/ts/masters/indicators/williams-r.src'; +import 'highcharts-github/ts/masters/indicators/wma.src'; +import 'highcharts-github/ts/masters/indicators/zigzag.src'; + +import 'highcharts-github/ts/masters/themes/avocado.src'; +import 'highcharts-github/ts/masters/themes/brand-dark.src'; +import 'highcharts-github/ts/masters/themes/brand-light.src'; +import 'highcharts-github/ts/masters/themes/dark-blue.src'; +import 'highcharts-github/ts/masters/themes/dark-green.src'; +import 'highcharts-github/ts/masters/themes/dark-unica.src'; +import 'highcharts-github/ts/masters/themes/gray.src'; +import 'highcharts-github/ts/masters/themes/grid-light.src'; +import 'highcharts-github/ts/masters/themes/grid.src'; +import 'highcharts-github/ts/masters/themes/high-contrast-dark.src'; +import 'highcharts-github/ts/masters/themes/high-contrast-light.src'; +import 'highcharts-github/ts/masters/themes/sand-signika.src'; +import 'highcharts-github/ts/masters/themes/skies.src'; +import 'highcharts-github/ts/masters/themes/sunset.src'; diff --git a/ts/groupedCategories.ts b/ts/groupedCategories.ts new file mode 100644 index 0000000..9cbcd37 --- /dev/null +++ b/ts/groupedCategories.ts @@ -0,0 +1,756 @@ +import Templating from 'highcharts-github/ts/Core/Templating'; +import Tick from 'highcharts-github/ts/Core/Axis/Tick'; +import Axis from 'highcharts-github/ts/Core/Axis/Axis'; +import Utilities from 'highcharts-github/ts/Core/Utilities'; +import SVGElement from 'highcharts-github/ts/Core/Renderer/SVG/SVGElement'; + +import type PositionObject from 'highcharts-github/ts/Core/Renderer/PositionObject'; +import type Chart from 'highcharts-github/ts/Core/Chart/Chart'; +import type { AxisCollectionKey } from 'highcharts-github/ts/Core/Axis/AxisOptions'; +import type SVGPath from 'highcharts-github/ts/Core/Renderer/SVG/SVGPath'; +import type { + GroupedCategory, + GroupedTick, + GroupedAxis, + GroupedAxisOptions, + AxisLabelFormatterContextObject +} from './../types'; + +const { merge, pick, objectEach, isNumber, isObject, isString, pInt } = Utilities; +const { format } = Templating; + +// Utility functions +const deepClone = (obj: T): T => JSON.parse(JSON.stringify(obj)) as T; +const sum = (arr: number[]): number => arr.reduce((acc, val): number => acc + val, 0); +const walk = ( + arr: T[], + key: keyof T, + fn: (item: T) => boolean | void +): void => { + for (let i = arr.length - 1; i >= 0; i--) { + const children = arr[i][key] as T[]; + if (children) { + walk(children, key, fn); + } + fn(arr[i]); + } +}; + +// Category class +class Category { + public userOptions: GroupedCategory; + public name: string; + public parent?: GroupedCategory; + + constructor(obj: GroupedCategory | string, parent?: GroupedCategory) { + this.userOptions = deepClone(obj as GroupedCategory); + this.name = typeof obj === 'string' ? obj : (obj.name || ''); + this.parent = parent; + } + + toString(): string { + const parts: string[] = []; + let cat: GroupedCategory | undefined = this; + + while (cat) { + parts.push(cat.name); + cat = cat.parent; + } + + return parts.join(', '); + } +} + +// Add category leaf to array +const addLeaf = ( + out: GroupedCategory[], + cat: GroupedCategory | string, + parent?: GroupedCategory +): void => { + out.unshift(new Category(cat, parent)); + + let currentParent = parent; + while (currentParent) { + currentParent.leaves = (currentParent.leaves || 0) + 1; + currentParent = currentParent.parent; + } +}; + +// Builds reverse category tree +const buildTree = ( + cats: Array, + out: GroupedCategory[], + options: { depth: number }, + parent?: GroupedCategory, + depth = 0 +): void => { + options.depth = options.depth || 0; + + for (let i = cats.length - 1; i >= 0; i--) { + const cat = cats[i]; + + if (typeof cat === 'object' && cat.categories) { + if (parent) { + cat.parent = parent; + } + buildTree(cat.categories, out, options, cat, depth + 1); + } else { + addLeaf(out, cat, parent); + } + } + options.depth = Math.max(options.depth, depth); +}; + +// Pushes part of grid to path +const addGridPart = (path: Array, d: number[], width: number): void => { + // Based on crispLine from HC (#65) + if (d[0] === d[2]) { + d[0] = d[2] = Math.round(d[0]) - (width % 2 / 2); + } + if (d[1] === d[3]) { + d[1] = d[3] = Math.round(d[1]) + (width % 2 / 2); + } + + path.push( + 'M', + d[0], d[1], + 'L', + d[2], d[3] + ); +}; + +// Returns tick position +const tickPosition = (tick: GroupedTick, pos: number): PositionObject => { + return tick.getPosition(tick.axis.horiz, pos, tick.axis.tickmarkOffset); +}; + +// Create local function `fontMetrics` to provide compatibility with HC 11 (#200) +const fontMetrics = (fontSize: string | number, chart?: Chart, elem?: SVGElement): { + h: number; + b: number; + f: number; +} => { + let fontSizeNum: number | string | undefined; + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore: workaround for old IE, window.getComputedStyle always exists in modern browsers + if ((chart?.renderer.styledMode || (isString(fontSize) && fontSize.includes('px'))) && window.getComputedStyle) { + fontSizeNum = elem && SVGElement.prototype.getStyle.call(elem, 'font-size'); + } else { + fontSizeNum = fontSize || elem?.styles.fontSize || chart?.renderer.style.fontSize; + } + + if (isString(fontSizeNum) && fontSizeNum.includes('px')) { + fontSizeNum = pInt(fontSizeNum); + } else if (!isNumber(fontSizeNum) || isNaN(fontSizeNum)) { + fontSizeNum = 12; + } + + const lineHeight = (fontSizeNum < 24 ? fontSizeNum + 3 : Math.round(fontSizeNum * 1.2)); + const baseline = Math.round(lineHeight * 0.8); + + return { + h: lineHeight, + b: baseline, + f: fontSizeNum + }; +}; + +// Adjusts the tick label's CSS to handle overflow, hiding or truncating the label +// if it does not fit within its allocated slot width. This ensures that labels +// do not overlap or extend beyond their bounds in grouped category axes. +const adjustTickLabelOverflow = (axis: GroupedAxis, groupedTick: GroupedTick, leaves: number, depth: number): void => { + if (groupedTick.label) { + const horiz = axis.horiz; + const categoriesLength = axis.categories?.length || 1; + const labelHeight = fontMetrics(groupedTick.label?.styles.fontSize || 0, axis.chart).h; + const groupSlotHeight = horiz ? + Math.abs(axis.groupSize(depth, groupedTick.label.getBBox().height)) : + (axis.height / categoriesLength) * leaves; + const groupSlotWidth = horiz ? + (axis.width / categoriesLength) * leaves : + Math.abs(axis.groupSize(depth, groupedTick.label.getBBox().width)); + + if (axis.options.labels.step === 1) { + // Handle width case, #220 + if (groupSlotWidth < 15) { + groupedTick.label.css({ + display: 'none', + width: undefined, + textOverflow: undefined + }); + } else if ( + groupedTick.rotation !== -90 && + groupedTick.rotation !== 90 && + groupedTick.label && + ( + groupedTick.label.getBBox().width > groupSlotWidth || + ( + groupedTick.label.getBBox().width === 0 && + groupedTick.label.styles.width && + groupSlotWidth === +groupedTick.label.styles.width + ) + ) + ) { + groupedTick.label.css({ + display: 'block', + width: groupSlotWidth.toString(), + textOverflow: 'ellipsis' + }); + } else if ( + groupedTick.label.styles.textOverflow !== 'ellipsis' || + (groupedTick.label.styles.width && groupSlotWidth > +groupedTick.label.styles.width) + ) { + groupedTick.label.css({ + display: 'block', + width: undefined, + textOverflow: undefined + }); + } + + // Handle height case, #177 + if (labelHeight > groupSlotHeight) { + groupedTick.label.css({ + display: 'none', + width: undefined, + textOverflow: undefined + }); + } + } + } +}; + +// Main plugin implementation + +// Cache prototypes +const axisProto = Axis.prototype as GroupedAxis; +const tickProto = Tick.prototype as GroupedTick; + +// Cache original methods +const protoAxisInit = axisProto.init; +const protoAxisRender = axisProto.render; +const protoAxisSetCategories = axisProto.setCategories; +const protoTickGetLabelSize = tickProto.getLabelSize; +const protoTickAddLabel = tickProto.addLabel; +const protoTickDestroy = tickProto.destroy; +const protoTickRender = tickProto.render; +const protoTickReplaceMovedLabel = tickProto.replaceMovedLabel; + +// Axis prototype extensions +axisProto.init = function ( + this: GroupedAxis, + chart: Chart, + options: Partial, + coll?: AxisCollectionKey +): void { + if (typeof options === 'object' && options.categories) { + options = merge(options, { labels: { step: 1 } }); // #220 + } + + protoAxisInit.call(this, chart, options, coll); + + if (isObject(options) && options.categories) { + this.setupGroups(options); + } +}; + +// Setup required axis options +axisProto.setupGroups = function (this: GroupedAxis, options: Partial): void { + const axis = this; + const chart = axis.chart; + const categories = deepClone(options.categories || []); + const reverseTree: GroupedCategory[] = []; + const stats: { depth: number } = { depth: 0 }; + const labelOptions = axis.options.labels; + const userAttr = labelOptions && labelOptions.groupedOptions; + const css = labelOptions && labelOptions.style; + + buildTree(categories, reverseTree, stats); + axis.isGrouped = stats.depth !== 0; + + if (axis.isGrouped) { + axis.categoriesTree = categories; + axis.categories = reverseTree; + axis.labelsDepth = stats.depth; + axis.labelsSizes = []; + axis.labelsGridPath = []; + axis.tickLength = options.tickLength || axis.tickLength || null; + axis.tickWidth = pick(options.tickWidth, axis.isXAxis ? 1 : 0); + axis.directionFactor = [-1, 1, 1, -1][axis.side]; + axis.options.lineWidth = pick(options.lineWidth, 1); + axis.groupFontHeights = []; + + for (let i = 0; i <= stats.depth; i++) { + const hasOptions = userAttr && userAttr[i - 1]; + const mergedCSS = hasOptions && userAttr[i - 1].style ? merge(css, userAttr[i - 1].style) : css; + axis.groupFontHeights[i] = + Math.round(fontMetrics(mergedCSS.fontSize ? mergedCSS.fontSize : 0, chart).b * 0.3); // TODO why * 0.3? + } + } +}; + +axisProto.render = function (this: GroupedAxis): boolean | void { + const axis = this; + + if (axis.isGrouped) { + axis.labelsGridPath = []; + } + + if (axis.originalTickLength === undefined) { + axis.originalTickLength = axis.options.tickLength; + } + + axis.options.tickLength = axis.isGrouped ? 0.001 : axis.originalTickLength; + + protoAxisRender.call(axis); + + if (!axis.isGrouped) { + if (axis.labelsGrid) { + axis.labelsGrid.attr({ visibility: 'hidden' }); + } + return false; + } + + const options = axis.options; + const top = axis.top; + const left = axis.left; + const right = left + axis.width; + const bottom = top + axis.height; + const visible = axis.hasVisibleSeries || axis.hasData(); // #185 + let depth = axis.labelsDepth || 0; + let grid = axis.labelsGrid; + const horiz = axis.horiz; + const d = axis.labelsGridPath; + let i = options.drawHorizontalBorders === false ? (depth + 1) : 0; // TODO: check if this is needed + let offset = axis.opposite ? (horiz ? top : right) : (horiz ? bottom : left); + const tickWidth = axis.tickWidth || 0; + let part: number[]; + + if (axis.userTickLength) { + depth -= 1; + } + + if (!grid) { + grid = axis.labelsGrid = axis.chart.renderer.path() + .attr({ + strokeWidth: tickWidth, + 'stroke-width': tickWidth, + stroke: options.tickColor || '' + }) + .add(axis.axisGroup); + + if (!options.tickColor) { + grid.addClass('highcharts-tick'); + } + } + + while (i <= depth) { + offset += axis.groupSize(i); + part = horiz ? [left, offset, right, offset] : [offset, top, offset, bottom]; + addGridPart(d, part, tickWidth); + i++; + } + + // TODO: fix it + grid.attr({ d: d as unknown as SVGPath, visibility: visible ? 'visible' : 'hidden' }); + axis.labelGroup?.attr({ visibility: visible ? 'visible' : 'hidden' }); + + // TODO check if this assertion is correct, fix it + walk((axis.categoriesTree || []) as GroupedCategory[], 'categories', (group: GroupedCategory): boolean => { + const tick = group.tick; + + if (!tick) { return false; } + + if ((axis.min && tick.startAt! + tick.leaves! - 1 < axis.min) || (axis.max && tick.startAt! > axis.max)) { + tick.label?.hide(); + tick.destroyed = 0; // TODO do we need this? + } else { + tick.label?.attr({ visibility: visible ? 'visible' : 'hidden' }); + } + + return true; + }); + + return true; +}; + +axisProto.setCategories = function ( + this: GroupedAxis, + newCategories: Array, + doRedraw?: boolean +): void { + const axis = this; + + if (axis.categories) { + axis.cleanGroups(); + } + + axis.setupGroups({ categories: newCategories }); + axis.categories = axis.userOptions.categories = newCategories; + + if (axis.categories.every((cat): boolean => isString(cat))) { + protoAxisSetCategories.call(axis, axis.categories as string[], doRedraw); + } +}; + +axisProto.cleanGroups = function (): void { + const axis = this; + const ticks = axis.ticks; + + for (const n in ticks) { + if (ticks[n].parent) { + delete (ticks[n]).parent; + } + } + + // TODO check it here, fix it + walk((axis.categoriesTree || []) as GroupedCategory[], 'categories', (group: GroupedCategory): boolean => { + const tick = group.tick; + + if (!tick) { return false; } + + tick.label?.destroy(); + objectEach(tick, (_v: GroupedTick[keyof GroupedTick], i: keyof GroupedTick): boolean => delete tick[i]); + + delete group.tick; + return true; + }); + + axis.labelsGrid = null; +}; + +axisProto.groupSize = function (this: GroupedAxis, level: number | boolean, position?: number): number { + const axis = this; + const positions = (axis.labelsSizes || []); + const direction = (axis.directionFactor || 1); + const groupedOptions = axis.options.labels && axis.options.labels.groupedOptions && isNumber(level) ? + axis.options.labels.groupedOptions[level - 1] : false; + let userXY = 0; + + if (groupedOptions) { + if (direction === -1) { + userXY = groupedOptions.x || 0; + } else { + userXY = groupedOptions.y || 0; + } + } + + if (isNumber(level) && position !== undefined) { + // TODO - Why + 10? Should be like this for sure? Try use label.distance here + positions[level] = Math.max(positions[level] || 0, position + 10 + Math.abs(userXY)); + } + + if (level === true) { + return sum(positions) * direction; + } else if (isNumber(level) && positions[level]) { + return positions[level] * direction; + } + + return 0; +}; + +// Tick prototype extensions +tickProto.addLabel = function (this: GroupedTick): boolean { + const tick = this; + const axis = tick.axis; + const labelOptions = pick(tick.options && tick.options.labels, axis.options.labels); + + let category: string | GroupedCategory; + + // Initialize topLabelSize on the axis + axis.topLabelSize = 0; + + protoTickAddLabel.call(tick); + + // Not grouped axis should not be affected, #228 + if (!axis.isGrouped) { + return false; + } + + if (!axis.categories || !(category = axis.categories[tick.pos])) { + return false; + } + + if (tick.label) { + const formatter = function (ctx: AxisLabelFormatterContextObject): string { + if (labelOptions.formatter) { + return labelOptions.formatter.call(ctx, ctx); + } + + if (labelOptions.format) { + ctx.text = axis.defaultLabelFormatter.call(ctx); + return format(labelOptions.format, ctx, axis.chart); + } + + return axis.defaultLabelFormatter.call(ctx); + }; + + tick.label.attr('text', formatter({ + axis: axis as Axis, // TODO: fix it + tick: tick as Tick, // TODO: fix it + chart: axis.chart, + isFirst: !!tick.isFirst, + isLast: !!tick.isLast, + value: isObject(category) ? category.name : category, + pos: tick.pos + })); + + // #232 - calculate textPxLength based on rotation + const absRotation = Math.abs(tick.label.rotation || 0); + const reducedRotation = absRotation > 180 ? absRotation - (Math.floor(absRotation / 180) * 180) : absRotation; + tick.label.textPxLength = reducedRotation > 45 && reducedRotation < 135 ? + tick.label.getBBox().height : tick.label.getBBox().width; + } + + if (axis.isGrouped && axis.options.labels.enabled && !isString(category)) { + tick.addGroupedLabels(category); + } + + return true; +}; + +tickProto.addGroupedLabels = function (this: GroupedTick, category: GroupedCategory): void { + const tick = this; + const axis = tick.axis; + const chart = axis.chart; + const options = axis.options.labels; + const useHTML = options.useHTML; + const css = options.style; + const userAttr = options.groupedOptions; + const attr = { align: 'center' as const, rotation: options.rotation, x: 0, y: 0, style: undefined }; + const sizeKey = axis.horiz ? 'height' : 'width'; + + let depth = 0; + let label: SVGElement; + let currentTick: GroupedTick | undefined = tick; + let currentCategory: GroupedCategory | undefined = category; + + while (currentTick) { + if (currentCategory && depth > 0 && !currentCategory.tick) { + tick.value = currentCategory.name; + const ctx = { + chart, + axis: axis as Axis, // TODO: fix it + tick: tick as Tick, // TODO: fix it + isFirst: !!tick.isFirst, + isLast: !!tick.isLast, + value: currentCategory.name, + pos: tick.pos + }; + const name = options.formatter ? options.formatter.call(ctx, ctx) : currentCategory.name; + const hasOptions = userAttr && userAttr[depth - 1]; + const mergedAttrs = hasOptions ? merge(attr, userAttr[depth - 1]) : attr; + const mergedCSS = hasOptions && userAttr[depth - 1].style ? merge(css, userAttr[depth - 1].style) : css; + + delete mergedAttrs.style; + + label = chart.renderer.text(name, 0, 0, useHTML).attr(mergedAttrs).add(axis.labelGroup); + + if (label && !chart.styledMode) { + label.css(mergedCSS); + } + + currentTick.startAt = tick.pos; + currentTick.childCount = (currentCategory.categories || []).length; + currentTick.leaves = currentCategory.leaves; + currentTick.visible = !!currentTick.childCount; + currentTick.label = label; + currentTick.labelOffsets = { x: mergedAttrs.x, y: mergedAttrs.y }; + + currentCategory.tick = currentTick; + } + + if (currentTick && currentTick.label) { + axis.groupSize(depth, (currentTick.label.getBBox())[sizeKey]); + } + + currentCategory = currentCategory?.parent; + + if (currentCategory) { + currentTick = currentTick.parent = currentCategory.tick || {} as GroupedTick; + } else { + currentTick = undefined; + } + + depth++; + } +}; + +tickProto.render = function (index: number, old?: boolean, opacity?: number): void { + protoTickRender.call(this, index, old, opacity); + + const tick = this; + const axis = tick.axis; + const treeCat = axis.categories && axis.categories[tick.pos]; + + if (!axis.isGrouped || !treeCat || (axis.max && tick.pos > axis.max)) { + return; + } + + const tickPos = tick.pos; + const isFirst = tick.isFirst; + const max = axis.max; + const min = axis.min; + const horiz = axis.horiz; + const grid = axis.labelsGridPath; + const tickWidth = axis.tickWidth || 0; + const xy = tickPosition(tick, tickPos); + const start = horiz ? xy.y : xy.x; + + let group = tick; + let size = axis.groupSize(0); + let depth = 1; + let reverseCrisp = ((horiz && xy.x === axis.pos + axis.len) || (!horiz && xy.y === axis.pos)) ? -1 : 0; + let gridAttrs: number[]; + let lvlSize: number; + let minPos: PositionObject; + let maxPos: PositionObject; + let attrs: { x: number; y: number }; + + if (isFirst) { + // Grid part for first tick, handles reversed axis, #144 + if (horiz) { + const gridX = axis.reversed ? axis.pos + axis.len : axis.left; + gridAttrs = [gridX, xy.y, gridX, xy.y + axis.groupSize(true)]; + } else { + const gridY = axis.reversed ? axis.top : axis.pos + axis.len; + gridAttrs = axis.isXAxis ? + [xy.x, gridY, xy.x + axis.groupSize(true), gridY] : + [xy.x, axis.top + axis.len, xy.x + axis.groupSize(true), axis.top + axis.len]; + } + + addGridPart(grid, gridAttrs, tickWidth); + } + + 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: string | GroupedCategory): number { + let ret = 0; + + if (isFirst && !isString(tCat)) { + ret = (tCat.parent?.categories || []).indexOf(tCat.name); + ret = ret < 0 ? 0 : ret; + return ret; + } + + return ret; + } + + adjustTickLabelOverflow(axis, tick, tick.leaves || 1, 0); // #220 + + while (group.parent) { + group = group.parent; + + const fix = fixOffset(treeCat); + const userX = group.labelOffsets?.x || 0; + const userY = group.labelOffsets?.y || 0; + const groupFontHeight = axis.groupFontHeights?.[depth] || 0; + + minPos = tickPosition(tick, min ? Math.max(group.startAt! - 1, min - 1) : group.startAt! - 1); + maxPos = tickPosition( + tick, + max ? Math.min(group.startAt! + group.leaves! - 1 - fix, max) : group.startAt! + group.leaves! - 1 - fix + ); + lvlSize = axis.groupSize(depth); + reverseCrisp = ((horiz && maxPos.x === axis.pos + axis.len) || (!horiz && maxPos.y === axis.pos)) ? -1 : 0; + + adjustTickLabelOverflow(axis, group, group.leaves || 1, depth); // #220 + + attrs = horiz ? { + x: (minPos.x + maxPos.x) / 2 + userX, + y: size + groupFontHeight + lvlSize / 2 + userY / 2 + } : { + x: size + lvlSize / 2 + userX, + y: (minPos.y + maxPos.y) / 2 + groupFontHeight + userY // #197 + }; + + 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 = size + lvlSize; + depth++; + } +}; + +tickProto.destroy = function (): void { + const tick = this; + const useHTML = tick.axis.options.labels.useHTML; + let group = tick.parent; + + while (group) { + group.destroyed = group.destroyed ? (group.destroyed + 1) : 1; // TODO do we need this? + + // Destroy label if it's HTML, #163 + if (useHTML && group.label?.element) { + group.label?.destroy(); + } + + group = group.parent; + } + + protoTickDestroy.call(tick); +}; + +tickProto.getLabelSize = function (): number { + const tick = this; + const axis = tick.axis; + + if (!axis.isGrouped) { + return protoTickGetLabelSize.call(tick); + } + + if (!axis.labelsSizes) { + axis.labelsSizes = []; + } + + // Axis labels distance option should be taken into account, #206 + // The default for ; + rotation?: number; +} + +interface GroupedCategory { + name: string; + categories?: Array; + parent?: GroupedCategory; + leaves?: number; + tick?: GroupedTick; +} + +interface GroupedAxisOptions extends Omit { + categories: Array; + tickLength: number; + tickWidth: number; + lineWidth: number; + tickColor: string; + labels: GroupedLabelOptions; + drawHorizontalBorders?: boolean; // TODO: check if this is needed +} + +interface GroupedTick extends Omit { + value?: string; + childCount?: number; + labelOffsets?: { + x: number; + y: number; + }; + leaves?: number; + startAt?: number; + visible?: boolean; + destroyed?: number; + parent?: GroupedTick; + options?: GroupedAxisOptions; + axis: GroupedAxis; + addGroupedLabels: (this: GroupedTick, category: GroupedCategory) => void; +} + +interface AxisLabelFormatterContextObject { + axis: Axis; + chart: Chart; + dateTimeLabelFormat?: Time.DateTimeFormat; + isFirst: boolean; + isLast: boolean; + pos: number; + text?: string; + tick: Tick; + value: number|string; +} + +interface GroupedAxis extends Omit { + options: GroupedAxisOptions; + userOptions: GroupedAxisOptions; + categoriesTree?: Array; + categories?: Array; + isGrouped?: boolean; + labelsDepth?: number; + labelsSizes?: number[]; + labelsGridPath: Array; + labelsGrid?: SVGElement | null; + groupFontHeights?: number[]; + directionFactor?: number; + userTickLength?: boolean; + originalTickLength?: number; + tickLength?: number | null; + tickWidth?: number; + topLabelSize?: number; + ticks: Record; + init: (chart: Chart, options: Partial, coll?: AxisCollectionKey) => void; + setupGroups: (options: Partial) => void; + groupSize: (level: number | boolean, position?: number) => number; + cleanGroups: () => void; +} + +export { + GroupedLabelOptions, + GroupedCategory, + GroupedAxisOptions, + GroupedTick, + AxisLabelFormatterContextObject, + GroupedAxis +};