UNPKG

highcharts

Version:
1,221 lines (1,220 loc) 49.4 kB
/* * * * (c) 2014-2025 Highsoft AS * * Authors: Jon Arild Nygard / Oystein Moseng * * License: www.highcharts.com/license * * !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!! * * */ 'use strict'; import Breadcrumbs from '../../Extensions/Breadcrumbs/Breadcrumbs.js'; import Color from '../../Core/Color/Color.js'; const { parse: color } = Color; import ColorMapComposition from '../ColorMapComposition.js'; import H from '../../Core/Globals.js'; const { composed, noop } = H; import Series from '../../Core/Series/Series.js'; import SeriesRegistry from '../../Core/Series/SeriesRegistry.js'; const { column: ColumnSeries, scatter: ScatterSeries } = SeriesRegistry.seriesTypes; import TreemapAlgorithmGroup from './TreemapAlgorithmGroup.js'; import TreemapNode from './TreemapNode.js'; import TreemapPoint from './TreemapPoint.js'; import TreemapSeriesDefaults from './TreemapSeriesDefaults.js'; import TreemapUtilities from './TreemapUtilities.js'; import TU from '../TreeUtilities.js'; const { getColor, getLevelOptions, updateRootId } = TU; import U from '../../Core/Utilities.js'; const { addEvent, arrayMax, clamp, correctFloat, crisp, defined, error, extend, fireEvent, isArray, isNumber, isObject, isString, merge, pick, pushUnique, splat, stableSort } = U; Series.keepProps.push('simulation', 'hadOutsideDataLabels'); /* * * * Constants * * */ const axisMax = 100; /* * * * Variables * * */ let treemapAxisDefaultValues = false; /* * * * Functions * * */ /** @private */ function onSeriesAfterBindAxes() { const series = this, xAxis = series.xAxis, yAxis = series.yAxis; let treeAxis; if (xAxis && yAxis) { if (series.is('treemap')) { treeAxis = { endOnTick: false, gridLineWidth: 0, lineWidth: 0, min: 0, minPadding: 0, max: axisMax, maxPadding: 0, startOnTick: false, title: void 0, tickPositions: [] }; extend(yAxis.options, treeAxis); extend(xAxis.options, treeAxis); treemapAxisDefaultValues = true; } else if (treemapAxisDefaultValues) { yAxis.setOptions(yAxis.userOptions); xAxis.setOptions(xAxis.userOptions); treemapAxisDefaultValues = false; } } } /* * * * Class * * */ /** * @private * @class * @name Highcharts.seriesTypes.treemap * * @augments Highcharts.Series */ class TreemapSeries extends ScatterSeries { constructor() { /* * * * Static Properties * * */ super(...arguments); this.simulation = 0; /* eslint-enable valid-jsdoc */ } /* * * * Static Functions * * */ static compose(SeriesClass) { if (pushUnique(composed, 'TreemapSeries')) { addEvent(SeriesClass, 'afterBindAxes', onSeriesAfterBindAxes); } } /* * * * Function * * */ /* eslint-disable valid-jsdoc */ algorithmCalcPoints(directionChange, last, group, childrenArea) { const plot = group.plot, end = group.elArr.length - 1; let pX, pY, pW, pH, gW = group.lW, gH = group.lH, keep, i = 0; if (last) { gW = group.nW; gH = group.nH; } else { keep = group.elArr[end]; } for (const p of group.elArr) { if (last || (i < end)) { if (group.direction === 0) { pX = plot.x; pY = plot.y; pW = gW; pH = p / pW; } else { pX = plot.x; pY = plot.y; pH = gH; pW = p / pH; } childrenArea.push({ x: pX, y: pY, width: pW, height: correctFloat(pH) }); if (group.direction === 0) { plot.y = plot.y + pH; } else { plot.x = plot.x + pW; } } i = i + 1; } // Reset variables group.reset(); if (group.direction === 0) { group.width = group.width - gW; } else { group.height = group.height - gH; } plot.y = plot.parent.y + (plot.parent.height - group.height); plot.x = plot.parent.x + (plot.parent.width - group.width); if (directionChange) { group.direction = 1 - group.direction; } // If not last, then add uncalculated element if (!last) { group.addElement(keep); } } algorithmFill(directionChange, parent, children) { const childrenArea = []; let pTot, direction = parent.direction, x = parent.x, y = parent.y, width = parent.width, height = parent.height, pX, pY, pW, pH; for (const child of children) { pTot = (parent.width * parent.height) * (child.val / parent.val); pX = x; pY = y; if (direction === 0) { pH = height; pW = pTot / pH; width = width - pW; x = x + pW; } else { pW = width; pH = pTot / pW; height = height - pH; y = y + pH; } childrenArea.push({ x: pX, y: pY, width: pW, height: pH, direction: 0, val: 0 }); if (directionChange) { direction = 1 - direction; } } return childrenArea; } algorithmLowAspectRatio(directionChange, parent, children) { const series = this, childrenArea = [], plot = { x: parent.x, y: parent.y, parent: parent }, direction = parent.direction, end = children.length - 1, group = new TreemapAlgorithmGroup(parent.height, parent.width, direction, plot); let pTot, i = 0; // Loop through and calculate all areas for (const child of children) { pTot = (parent.width * parent.height) * (child.val / parent.val); group.addElement(pTot); if (group.lP.nR > group.lP.lR) { series.algorithmCalcPoints(directionChange, false, group, childrenArea, plot // @todo no supported ); } // If last child, then calculate all remaining areas if (i === end) { series.algorithmCalcPoints(directionChange, true, group, childrenArea, plot // @todo not supported ); } ++i; } return childrenArea; } /** * Over the alignment method by setting z index. * @private */ alignDataLabel(point, // eslint-disable-next-line @typescript-eslint/no-unused-vars dataLabel, // eslint-disable-next-line @typescript-eslint/no-unused-vars labelOptions) { ColumnSeries.prototype.alignDataLabel.apply(this, arguments); if (point.dataLabel) { // `point.node.zIndex` could be undefined (#6956) point.dataLabel.attr({ zIndex: (point.node.zIndex || 0) + 1 }); } } applyTreeGrouping() { const series = this, parentList = series.parentList || {}, { cluster } = series.options, minimumClusterSize = cluster?.minimumClusterSize || 5; if (cluster?.enabled) { const parentGroups = {}; const checkIfHide = (node) => { if (node?.point?.shapeArgs) { const { width = 0, height = 0 } = node.point.shapeArgs, area = width * height; const { pixelWidth = 0, pixelHeight = 0 } = cluster, compareHeight = defined(pixelHeight), thresholdArea = pixelHeight ? pixelWidth * pixelHeight : pixelWidth * pixelWidth; if (width < pixelWidth || height < (compareHeight ? pixelHeight : pixelWidth) || area < thresholdArea) { if (!node.isGroup && defined(node.parent)) { if (!parentGroups[node.parent]) { parentGroups[node.parent] = []; } parentGroups[node.parent].push(node); } } } node?.children.forEach((child) => { checkIfHide(child); }); }; checkIfHide(series.tree); for (const parent in parentGroups) { if (parentGroups[parent]) { if (parentGroups[parent].length > minimumClusterSize) { parentGroups[parent].forEach((node) => { const index = parentList[parent].indexOf(node.i); if (index !== -1) { parentList[parent].splice(index, 1); const id = `highcharts-grouped-treemap-points-${node.parent || 'root'}`; let groupPoint = series.points .find((p) => p.id === id); if (!groupPoint) { const PointClass = series.pointClass, pointIndex = series.points.length; groupPoint = new PointClass(series, { className: cluster.className, color: cluster.color, id, index: pointIndex, isGroup: true, value: 0 }); extend(groupPoint, { formatPrefix: 'cluster' }); series.points.push(groupPoint); parentList[parent].push(pointIndex); parentList[id] = []; } const amount = groupPoint.groupedPointsAmount + 1, val = series.points[groupPoint.index] .options.value || 0, name = cluster.name || `+ ${amount}`; // Update the point directly in points array to // prevent wrong instance update series.points[groupPoint.index] .groupedPointsAmount = amount; series.points[groupPoint.index].options.value = val + (node.point.value || 0); series.points[groupPoint.index].name = name; parentList[id].push(node.point.index); } }); } } } series.nodeMap = {}; series.nodeList = []; series.parentList = parentList; const tree = series.buildTree('', -1, 0, series.parentList); series.translate(tree); } } /** * Recursive function which calculates the area for all children of a * node. * * @private * @function Highcharts.Series#calculateChildrenAreas * * @param {Object} parent * The node which is parent to the children. * * @param {Object} area * The rectangular area of the parent. */ calculateChildrenAreas(parent, area) { const series = this, options = series.options, mapOptionsToLevel = series.mapOptionsToLevel, level = mapOptionsToLevel[parent.level + 1], algorithm = pick((level?.layoutAlgorithm && series[level?.layoutAlgorithm] && level.layoutAlgorithm), options.layoutAlgorithm), alternate = options.alternateStartingDirection, // Collect all children which should be included children = parent.children.filter((n) => parent.isGroup || !n.ignore), groupPadding = level?.groupPadding ?? options.groupPadding ?? 0, rootNode = series.nodeMap[series.rootNode]; if (!algorithm) { return; } let childrenValues = [], axisWidth = rootNode.pointValues?.width || 0, axisHeight = rootNode.pointValues?.height || 0; if (level?.layoutStartingDirection) { area.direction = level.layoutStartingDirection === 'vertical' ? 0 : 1; } childrenValues = series[algorithm](area, children); let i = -1; for (const child of children) { const values = childrenValues[++i]; if (child === rootNode) { axisWidth = axisWidth || values.width; axisHeight = values.height; } const groupPaddingXValues = groupPadding / (series.xAxis.len / axisHeight), groupPaddingYValues = groupPadding / (series.yAxis.len / axisHeight); child.values = merge(values, { val: child.childrenTotal, direction: (alternate ? 1 - area.direction : area.direction) }); // Make room for outside data labels if (child.children.length && child.point.dataLabels?.length) { const dlHeight = arrayMax(child.point.dataLabels.map((dl) => dl.options ?.headers && dl.height || 0)) / (series.yAxis.len / axisHeight); // Make room for data label unless the group is too small if (dlHeight < child.values.height / 2) { child.values.y += dlHeight; child.values.height -= dlHeight; } } if (groupPadding) { const xPad = Math.min(groupPaddingXValues, child.values.width / 4), yPad = Math.min(groupPaddingYValues, child.values.height / 4); child.values.x += xPad; child.values.width -= 2 * xPad; child.values.y += yPad; child.values.height -= 2 * yPad; } child.pointValues = merge(values, { x: (values.x / series.axisRatio), // Flip y-values to avoid visual regression with csvCoord in // Axis.translate at setPointValues. #12488 y: axisMax - values.y - values.height, width: (values.width / series.axisRatio) }); // If node has children, then call method recursively if (child.children.length) { series.calculateChildrenAreas(child, child.values); } } const getChildrenRecursive = (node, result = [], getLeaves = true) => { node.children.forEach((child) => { if (getLeaves && child.isLeaf) { result.push(child.point); } else if (!getLeaves && !child.isLeaf) { result.push(child.point); } if (child.children.length) { getChildrenRecursive(child, result, getLeaves); } }); return result; }; // Experimental block to make space for the outside data labels if (options.nodeSizeBy === 'leaf' && parent === rootNode && this.hasOutsideDataLabels && // Sizing by leaf value is not possible if any of the groups have // explicit values !getChildrenRecursive(rootNode, void 0, false) .some((point) => isNumber(point.options.value)) && !isNumber(rootNode.point?.options.value)) { const leaves = getChildrenRecursive(rootNode), values = leaves.map((point) => point.options.value || 0), // Areas in terms of axis units squared areas = leaves.map(({ node: { pointValues } }) => (pointValues ? pointValues.width * pointValues.height : 0)), valueSum = values.reduce((sum, value) => sum + value, 0), areaSum = areas.reduce((sum, value) => sum + value, 0), expectedAreaPerValue = areaSum / valueSum; let minMiss = 0, maxMiss = 0; leaves.forEach((point, i) => { const areaPerValue = values[i] ? (areas[i] / values[i]) : 1, // Less than 1 => rendered too small, greater than 1 => // rendered too big fit = clamp(areaPerValue / expectedAreaPerValue, 0.8, 1.4); let miss = 1 - fit; if (point.value) { // Very small areas are more sensitive, and matter less to // the visual impression. Give them less weight. if (areas[i] < 20) { miss *= areas[i] / 20; } if (miss > maxMiss) { maxMiss = miss; } if (miss < minMiss) { minMiss = miss; } point.simulatedValue = (point.simulatedValue || point.value) / fit; } }); /* / console.log('--- simulation', this.simulation, 'worstMiss', minMiss, maxMiss ); // */ if ( // An area error less than 5% is acceptable, the human ability // to assess area size is not that accurate (minMiss < -0.05 || maxMiss > 0.05) && // In case an eternal loop is brewing, pull the emergency brake this.simulation < 10) { this.simulation++; this.setTreeValues(parent); area.val = parent.val; this.calculateChildrenAreas(parent, area); // Simulation is settled, proceed to rendering. Reset the simulated // values and set the tree values with real data. } else { leaves.forEach((point) => { delete point.simulatedValue; }); this.setTreeValues(parent); this.simulation = 0; } } } /** * Create level list. * @private */ createList(e) { const chart = this.chart, breadcrumbs = chart.breadcrumbs, list = []; if (breadcrumbs) { let currentLevelNumber = 0; list.push({ level: currentLevelNumber, levelOptions: chart.series[0] }); let node = e.target.nodeMap[e.newRootId]; const extraNodes = []; // When the root node is set and has parent, // recreate the path from the node tree. while (node.parent || node.parent === '') { extraNodes.push(node); node = e.target.nodeMap[node.parent]; } for (const node of extraNodes.reverse()) { list.push({ level: ++currentLevelNumber, levelOptions: node }); } // If the list has only first element, we should clear it if (list.length <= 1) { list.length = 0; } } return list; } /** * Extend drawDataLabels with logic to handle custom options related to * the treemap series: * * - Points which is not a leaf node, has dataLabels disabled by * default. * * - Options set on series.levels is merged in. * * - Width of the dataLabel is set to match the width of the point * shape. * * @private */ drawDataLabels() { const series = this, mapOptionsToLevel = series.mapOptionsToLevel, points = series.points.filter(function (n) { return n.node.visible || defined(n.dataLabel); }), padding = splat(series.options.dataLabels || {})[0]?.padding, positionsAreSet = points.some((p) => isNumber(p.plotY)); for (const point of points) { const style = {}, // Set options to new object to avoid problems with scope options = { style }, level = mapOptionsToLevel[point.node.level]; // If not a leaf, then label should be disabled as default if (!point.node.isLeaf && !point.node.isGroup || (point.node.isGroup && point.node.level <= series.nodeMap[series.rootNode].level)) { options.enabled = false; } // If options for level exists, include them as well if (level?.dataLabels) { merge(true, options, splat(level.dataLabels)[0]); series.hasDataLabels = () => true; } // Headers are always top-aligned. Leaf nodes no not support // headers. if (point.node.isLeaf) { options.inside = true; } else if (options.headers) { options.verticalAlign = 'top'; } // Set dataLabel width to the width of the point shape minus the // padding if (point.shapeArgs && positionsAreSet) { const { height = 0, width = 0 } = point.shapeArgs; if (width > 32 && height > 16 && point.shouldDraw()) { const dataLabelWidth = width - 2 * (options.padding || padding || 0); style.width = `${dataLabelWidth}px`; style.lineClamp ?? (style.lineClamp = Math.floor(height / 16)); style.visibility = 'inherit'; // Make the label box itself fill the width if (options.headers) { point.dataLabel?.attr({ width: dataLabelWidth }); } // Hide labels for shapes that are too small } else { style.width = `${width}px`; style.visibility = 'hidden'; } } // Merge custom options with point options point.dlOptions = merge(options, point.options.dataLabels); } super.drawDataLabels(points); } /** * Override drawPoints * @private */ drawPoints(points = this.points) { const series = this, chart = series.chart, renderer = chart.renderer, styledMode = chart.styledMode, options = series.options, shadow = styledMode ? {} : options.shadow, borderRadius = options.borderRadius, withinAnimationLimit = chart.pointCount < options.animationLimit, allowTraversingTree = options.allowTraversingTree; for (const point of points) { const levelDynamic = point.node.levelDynamic, animatableAttribs = {}, attribs = {}, css = {}, groupKey = 'level-group-' + point.node.level, hasGraphic = !!point.graphic, shouldAnimate = withinAnimationLimit && hasGraphic, shapeArgs = point.shapeArgs; // Don't bother with calculate styling if the point is not drawn if (point.shouldDraw()) { point.isInside = true; if (borderRadius) { attribs.r = borderRadius; } merge(true, // Extend object // Which object to extend shouldAnimate ? animatableAttribs : attribs, // Add shapeArgs to animate/attr if graphic exists hasGraphic ? shapeArgs : {}, // Add style attribs if !styleMode styledMode ? {} : series.pointAttribs(point, point.selected ? 'select' : void 0)); // In styled mode apply point.color. Use CSS, otherwise the // fill used in the style sheet will take precedence over // the fill attribute. if (series.colorAttribs && styledMode) { // Heatmap is loaded extend(css, series.colorAttribs(point)); } if (!series[groupKey]) { series[groupKey] = renderer.g(groupKey) .attr({ // @todo Set the zIndex based upon the number of // levels, instead of using 1000 zIndex: 1000 - (levelDynamic || 0) }) .add(series.group); series[groupKey].survive = true; } } // Draw the point point.draw({ animatableAttribs, attribs, css, group: series[groupKey], imageUrl: point.imageUrl, renderer, shadow, shapeArgs, shapeType: point.shapeType }); // If setRootNode is allowed, set a point cursor on clickables & // add drillId to point if (allowTraversingTree && point.graphic) { point.drillId = options.interactByLeaf ? series.drillToByLeaf(point) : series.drillToByGroup(point); } } } /** * Finds the drill id for a parent node. Returns false if point should * not have a click event. * @private */ drillToByGroup(point) { return (!point.node.isLeaf || point.node.isGroup) ? point.id : false; } /** * Finds the drill id for a leaf node. Returns false if point should not * have a click event * @private */ drillToByLeaf(point) { const { traverseToLeaf } = point.series.options; let drillId = false, nodeParent; if ((point.node.parent !== this.rootNode) && point.node.isLeaf) { if (traverseToLeaf) { drillId = point.id; } else { nodeParent = point.node; while (!drillId) { if (typeof nodeParent.parent !== 'undefined') { nodeParent = this.nodeMap[nodeParent.parent]; } if (nodeParent.parent === this.rootNode) { drillId = nodeParent.id; } } } } return drillId; } /** * @todo remove this function at a suitable version. * @private */ drillToNode(id, redraw) { error(32, false, void 0, { 'treemap.drillToNode': 'use treemap.setRootNode' }); this.setRootNode(id, redraw); } drillUp() { const series = this, node = series.nodeMap[series.rootNode]; if (node && isString(node.parent)) { series.setRootNode(node.parent, true, { trigger: 'traverseUpButton' }); } } getExtremes() { // Get the extremes from the value data const { dataMin, dataMax } = super.getExtremes(this.colorValueData); this.valueMin = dataMin; this.valueMax = dataMax; // Get the extremes from the y data return super.getExtremes(); } /** * Creates an object map from parent id to childrens index. * * @private * @function Highcharts.Series#getListOfParents * * @param {Highcharts.SeriesTreemapDataOptions} [data] * List of points set in options. * * @param {Array<string>} [existingIds] * List of all point ids. * * @return {Object} * Map from parent id to children index in data. */ getListOfParents(data, existingIds) { const arr = isArray(data) ? data : [], ids = isArray(existingIds) ? existingIds : [], listOfParents = arr.reduce(function (prev, curr, i) { const parent = pick(curr.parent, ''); if (typeof prev[parent] === 'undefined') { prev[parent] = []; } prev[parent].push(i); return prev; }, { '': [] // Root of tree }); // If parent does not exist, hoist parent to root of tree. for (const parent of Object.keys(listOfParents)) { const children = listOfParents[parent]; if ((parent !== '') && (ids.indexOf(parent) === -1)) { for (const child of children) { listOfParents[''].push(child); } delete listOfParents[parent]; } } return listOfParents; } /** * Creates a tree structured object from the series points. * @private */ getTree() { const series = this, allIds = this.data.map(function (d) { return d.id; }); series.parentList = series.getListOfParents(this.data, allIds); series.nodeMap = {}; series.nodeList = []; return series.buildTree('', -1, 0, series.parentList || {}); } buildTree(id, index, level, list, parent) { const series = this, children = [], point = series.points[index]; let height = 0, child; // Actions for (const i of (list[id] || [])) { child = series.buildTree(series.points[i].id, i, level + 1, list, id); height = Math.max(child.height + 1, height); children.push(child); } const node = new series.NodeClass().init(id, index, children, height, level, series, parent); for (const child of children) { child.parentNode = node; } series.nodeMap[node.id] = node; series.nodeList.push(node); if (point) { point.node = node; node.point = point; } return node; } /** * Define hasData function for non-cartesian series. Returns true if the * series has points at all. * @private */ hasData() { return !!this.dataTable.rowCount; } init(chart, options) { const series = this, breadcrumbsOptions = merge(options.drillUpButton, options.breadcrumbs), setOptionsEvent = addEvent(series, 'setOptions', (event) => { const options = event.userOptions; // Deprecated options if (defined(options.allowDrillToNode) && !defined(options.allowTraversingTree)) { options.allowTraversingTree = options.allowDrillToNode; delete options.allowDrillToNode; } if (defined(options.drillUpButton) && !defined(options.traverseUpButton)) { options.traverseUpButton = options.drillUpButton; delete options.drillUpButton; } // Check if we need to reserve space for headers const dataLabels = splat(options.dataLabels || {}); options.levels?.forEach((level) => { dataLabels.push.apply(dataLabels, splat(level.dataLabels || {})); }); this.hasOutsideDataLabels = dataLabels.some((dl) => dl.headers); }); super.init(chart, options); // Treemap's opacity is a different option from other series delete series.opacity; // Handle deprecated options. series.eventsToUnbind.push(setOptionsEvent); if (series.options.allowTraversingTree) { series.eventsToUnbind.push(addEvent(series, 'click', series.onClickDrillToNode)); series.eventsToUnbind.push(addEvent(series, 'setRootNode', function (e) { const chart = series.chart; if (chart.breadcrumbs) { // Create a list using the event after drilldown. chart.breadcrumbs.updateProperties(series.createList(e)); } })); series.eventsToUnbind.push(addEvent(series, 'update', // eslint-disable-next-line @typescript-eslint/no-unused-vars function (e, redraw) { const breadcrumbs = this.chart.breadcrumbs; if (breadcrumbs && e.options.breadcrumbs) { breadcrumbs.update(e.options.breadcrumbs); } this.hadOutsideDataLabels = this.hasOutsideDataLabels; })); series.eventsToUnbind.push(addEvent(series, 'destroy', function destroyEvents(e) { const chart = this.chart; if (chart.breadcrumbs && !e.keepEventsForUpdate) { chart.breadcrumbs.destroy(); chart.breadcrumbs = void 0; } })); } if (!chart.breadcrumbs) { chart.breadcrumbs = new Breadcrumbs(chart, breadcrumbsOptions); } series.eventsToUnbind.push(addEvent(chart.breadcrumbs, 'up', function (e) { const drillUpsNumber = this.level - e.newLevel; for (let i = 0; i < drillUpsNumber; i++) { series.drillUp(); } })); } /** * Add drilling on the suitable points. * @private */ onClickDrillToNode(event) { const series = this, point = event.point, drillId = point?.drillId; // If a drill id is returned, add click event and cursor. if (isString(drillId)) { point.setState(''); // Remove hover series.setRootNode(drillId, true, { trigger: 'click' }); } } /** * Get presentational attributes * @private */ pointAttribs(point, state) { const series = this, mapOptionsToLevel = (isObject(series.mapOptionsToLevel) ? series.mapOptionsToLevel : {}), level = point && mapOptionsToLevel[point.node.level] || {}, options = this.options, stateOptions = state && options.states && options.states[state] || {}, className = point?.getClassName() || '', // Set attributes by precedence. Point trumps level trumps series. // Stroke width uses pick because it can be 0. attr = { 'stroke': (point && point.borderColor) || level.borderColor || stateOptions.borderColor || options.borderColor, 'stroke-width': pick(point && point.borderWidth, level.borderWidth, stateOptions.borderWidth, options.borderWidth), 'dashstyle': point?.borderDashStyle || level.borderDashStyle || stateOptions.borderDashStyle || options.borderDashStyle, 'fill': point?.color || this.color }; // Hide levels above the current view if (className.indexOf('highcharts-above-level') !== -1) { attr.fill = 'none'; attr['stroke-width'] = 0; // Nodes with children that accept interaction } else if (className.indexOf('highcharts-internal-node-interactive') !== -1) { attr['fill-opacity'] = stateOptions.opacity ?? options.opacity ?? 1; attr.cursor = 'pointer'; // Hide nodes that have children } else if (className.indexOf('highcharts-internal-node') !== -1) { attr.fill = 'none'; } else if (state && stateOptions.brightness) { // Brighten and hoist the hover nodes attr.fill = color(attr.fill) .brighten(stateOptions.brightness) .get(); } return attr; } /** * Set the node's color recursively, from the parent down. * @private */ setColorRecursive(node, parentColor, colorIndex, index, siblings) { const series = this, chart = series?.chart, colors = chart?.options?.colors; if (node) { const colorInfo = getColor(node, { colors: colors, index: index, mapOptionsToLevel: series.mapOptionsToLevel, parentColor: parentColor, parentColorIndex: colorIndex, series: series, siblings: siblings }), point = series.points[node.i]; if (point) { point.color = colorInfo.color; point.colorIndex = colorInfo.colorIndex; } let i = -1; // Do it all again with the children for (const child of (node.children || [])) { series.setColorRecursive(child, colorInfo.color, colorInfo.colorIndex, ++i, node.children.length); } } } setPointValues() { const series = this; const { points, xAxis, yAxis } = series; const styledMode = series.chart.styledMode; // Get the crisp correction in classic mode. For this to work in // styled mode, we would need to first add the shape (without x, // y, width and height), then read the rendered stroke width // using point.graphic.strokeWidth(), then modify and apply the // shapeArgs. This applies also to column series, but the // downside is performance and code complexity. const getStrokeWidth = (point) => (styledMode ? 0 : (series.pointAttribs(point)['stroke-width'] || 0)); for (const point of points) { const { pointValues: values, visible } = point.node; // Points which is ignored, have no values. if (values && visible) { const { height, width, x, y } = values, strokeWidth = getStrokeWidth(point), xValue = xAxis.toPixels(x, true), x2Value = xAxis.toPixels(x + width, true), yValue = yAxis.toPixels(y, true), y2Value = yAxis.toPixels(y + height, true), // If the edge of a rectangle is on the edge, make sure it // stays within the plot area by adding or substracting half // of the stroke width. x1 = xValue === 0 ? strokeWidth / 2 : crisp(xAxis.toPixels(x, true), strokeWidth, true), x2 = x2Value === xAxis.len ? xAxis.len - strokeWidth / 2 : crisp(xAxis.toPixels(x + width, true), strokeWidth, true), y1 = yValue === yAxis.len ? yAxis.len - strokeWidth / 2 : crisp(yAxis.toPixels(y, true), strokeWidth, true), y2 = y2Value === 0 ? strokeWidth / 2 : crisp(yAxis.toPixels(y + height, true), strokeWidth, true); // Set point values const shapeArgs = { x: Math.min(x1, x2), y: Math.min(y1, y2), width: Math.abs(x2 - x1), height: Math.abs(y2 - y1) }; point.plotX = shapeArgs.x + (shapeArgs.width / 2); point.plotY = shapeArgs.y + (shapeArgs.height / 2); point.shapeArgs = shapeArgs; } else { // Reset visibility delete point.plotX; delete point.plotY; } } } /** * Sets a new root node for the series. * * @private * @function Highcharts.Series#setRootNode * * @param {string} id * The id of the new root node. * * @param {boolean} [redraw=true] * Whether to redraw the chart or not. * * @param {Object} [eventArguments] * Arguments to be accessed in event handler. * * @param {string} [eventArguments.newRootId] * Id of the new root. * * @param {string} [eventArguments.previousRootId] * Id of the previous root. * * @param {boolean} [eventArguments.redraw] * Whether to redraw the chart after. * * @param {Object} [eventArguments.series] * The series to update the root of. * * @param {string} [eventArguments.trigger] * The action which triggered the event. Undefined if the setRootNode is * called directly. * * @emits Highcharts.Series#event:setRootNode */ setRootNode(id, redraw, eventArguments) { const series = this, eventArgs = extend({ newRootId: id, previousRootId: series.rootNode, redraw: pick(redraw, true), series: series }, eventArguments); /** * The default functionality of the setRootNode event. * * @private * @param {Object} args The event arguments. * @param {string} args.newRootId Id of the new root. * @param {string} args.previousRootId Id of the previous root. * @param {boolean} args.redraw Whether to redraw the chart after. * @param {Object} args.series The series to update the root of. * @param {string} [args.trigger=undefined] The action which * triggered the event. Undefined if the setRootNode is called * directly. */ const defaultFn = function (args) { const series = args.series; // Store previous and new root ids on the series. series.idPreviousRoot = args.previousRootId; series.rootNode = args.newRootId; // Redraw the chart series.isDirty = true; // Force redraw if (args.redraw) { series.chart.redraw(); } }; // Fire setRootNode event. fireEvent(series, 'setRootNode', eventArgs, defaultFn); } /** * Workaround for `inactive` state. Since `series.opacity` option is * already reserved, don't use that state at all by disabling * `inactiveOtherPoints` and not inheriting states by points. * @private */ setState(state) { this.options.inactiveOtherPoints = true; super.setState(state, false); this.options.inactiveOtherPoints = false; } setTreeValues(tree) { const series = this, options = series.options, idRoot = series.rootNode, mapIdToNode = series.nodeMap, nodeRoot = mapIdToNode[idRoot], levelIsConstant = (typeof options.levelIsConstant === 'boolean' ? options.levelIsConstant : true), children = [], point = series.points[tree.i]; // First give the children some values let childrenTotal = 0; for (let child of tree.children) { child = series.setTreeValues(child); children.push(child); if (!child.ignore) { childrenTotal += child.val; } } // Sort the children stableSort(children, (a, b) => ((a.sortIndex || 0) - (b.sortIndex || 0))); // Set the values let val = pick(point?.simulatedValue, point?.options.value, childrenTotal); if (point) { point.value = val; } if (point?.isGroup && options.cluster?.reductionFactor) { val /= options.cluster.reductionFactor; } if (tree.parentNode?.point?.isGroup && series.rootNode !== tree.parent) { tree.visible = false; } extend(tree, { children: children, childrenTotal: childrenTotal, // Ignore this node if point is not visible ignore: !(pick(point?.visible, true) && (val > 0)), isLeaf: tree.visible && !childrenTotal, isGroup: point?.isGroup, levelDynamic: (tree.level - (levelIsConstant ? 0 : nodeRoot.level)), name: pick(point?.name, ''), sortIndex: pick(point?.sortIndex, -val), val: val }); return tree; } sliceAndDice(parent, children) { return this.algorithmFill(true, parent, children); } squarified(parent, children) { return this.algorithmLowAspectRatio(true, parent, children); } strip(parent, children) { return this.algorithmLowAspectRatio(false, parent, children); } stripes(parent, children) { return this.algorithmFill(false, parent, children); } translate(tree) { const series = this, options = series.options, applyGrouping = !tree; let // NOTE: updateRootId modifies series. rootId = updateRootId(series), rootNode, pointValues, seriesArea, val; if (!tree && !rootId.startsWith('highcharts-grouped-treemap-points-')) { // Group points are removed, but not destroyed during generatePoints (this.points || []).forEach((point) => { if (point.isGroup) { point.destroy(); } }); // Call prototype function super.translate(); // @todo Only if series.isDirtyData is true tree = series.getTree(); } // Ensure `tree` and `series.tree` are synchronized series.tree = tree = tree || series.tree; rootNode = series.nodeMap[rootId]; if (rootId !== '' && !rootNode) { series.setRootNode('', false); rootId = series.rootNode; rootNode = series.nodeMap[rootId]; } if (!rootNode.point?.isGroup) { series.mapOptionsToLevel = getLevelOptions({ from: rootNode.level + 1, levels: options.levels, to: tree.height, defaults: { levelIsConstant: series.options.levelIsConstant, colorByPoint: options.colorByPoint } }); } // Parents of the root node is by default visible TreemapUtilities.recursive(series.nodeMap[series.rootNode], (node) => { const p = node.parent; let next = false; node.visible = true; if (p || p === '') { next = series.nodeMap[p]; } return next; }); // Children of the root node is by default visible TreemapUtilities.recursive(series.nodeMap[series.rootNode].children, (children) => { let next = false; for (const child of children) { child.visible = true; if (child.children.length) { next = (next || []).concat(child.children); } } return next; }); series.setTreeValues(tree); // Calculate plotting values. series.axisRatio = (series.xAxis.len / series.yAxis.len); series.nodeMap[''].pointValues = pointValues = { x: 0, y: 0, width: axisMax, height: axisMax }; series.nodeMap[''].values = seriesArea = merge(pointValues, { width: (pointValues.width * series.axisRatio), direction: (options.layoutStartingDirection === 'vertical' ? 0 : 1), val: tree.val }); // We need to pre-render the data labels in order to measure the height // of data label group if (this.hasOutsideDataLabels || this.hadOutsideDataLabels) { this.drawDataLabels(); } series.calculateChildrenAreas(tree, seriesArea); // Logic for point colors if (!series.colorAxis && !options.colorByPoint) { series.setColorRecursive(series.tree); } // Update axis extremes according to the root node. if (options.allowTraversingTree && rootNode.pointValues) { val = rootNode.pointValues; series.xAxis.setExtremes(val.x, val.x + val.width, false); series.yAxis.setExtremes(val.y, val.y + val.height, false); series.xAxis.setScale(); series.yAxis.setScale(); } // Assign values to points. series.setPointValues(); if (applyGrouping) { series.applyTreeGrouping(); } } } TreemapSeries.defaultOptions = merge(ScatterSeries.defaultOptions, TreemapSeriesDefaults); extend(TreemapSeries.prototype, { buildKDTree: noop, colorAttribs: ColorMapComposition.seriesMembers.colorAttribs, colorKey: 'colorValue', // Point color option key directTouch: true, getExtremesFromAll: true, getSymbol: noop, optionalAxis: 'colorAxis', parallelArrays: ['x', 'y', 'value', 'colorValue'], pointArrayMap: ['value', 'colorValue'], pointClass: TreemapPoint, NodeClass: TreemapNode, trackerGroups: ['group', 'dataLabelsGroup'], utils: TreemapUtilities }); ColorMapComposition.compose(TreemapSeries); SeriesRegistry.registerSeriesType('treemap', TreemapSeries); /* * * * Default Export * * */ export default TreemapSeries;