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.
+a.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 +};