From f03a5cb61c51c0bf96d5121741b1defc6e323952 Mon Sep 17 00:00:00 2001 From: Anas <156536069+Nas01010101@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:44:24 -0400 Subject: [PATCH] fix(sunburst): redistribute angles constrained by minAngle. close #20602 --- src/chart/sunburst/sunburstLayout.ts | 84 +++++++++++---- test/ut/spec/series/sunburst.test.ts | 156 +++++++++++++++++++++++++++ 2 files changed, 219 insertions(+), 21 deletions(-) create mode 100644 test/ut/spec/series/sunburst.test.ts diff --git a/src/chart/sunburst/sunburstLayout.ts b/src/chart/sunburst/sunburstLayout.ts index 5459a1313a..6fe7cb63de 100644 --- a/src/chart/sunburst/sunburstLayout.ts +++ b/src/chart/sunburst/sunburstLayout.ts @@ -25,7 +25,7 @@ import SunburstSeriesModel, { SERIES_TYPE_SUNBURST, SunburstSeriesOption } from import { TreeNode } from '../../data/Tree'; import { createSimpleOverallStageHandler } from '../../util/model'; -// let PI2 = Math.PI * 2; +const PI2 = Math.PI * 2; const RADIAN = Math.PI / 180; export const sunburstLayoutStageHandler = createSimpleOverallStageHandler(SERIES_TYPE_SUNBURST, sunburstLayout); @@ -82,17 +82,68 @@ function sunburstLayout( const stillShowZeroSum = seriesModel.get('stillShowZeroSum'); - // In the case some sector angle is smaller than minAngle - // let restAngle = PI2; - // let valueSumLargerThanMinAngle = 0; - const dir = clockwise ? 1 : -1; + /** + * Calculate the angle of every node in a sibling group, which takes up + * `totalAngle`, the angle of their parent, in total. + * + * Sectors smaller than `minAngle` are enlarged, and the rest of them + * shrink so that the children still fit in their parent. Otherwise + * they overflow and overlap each other. See also `pieLayout`. + */ + const getChildrenAngles = function (children: TreeNode[], totalAngle: number) { + const angles: number[] = []; + + // In the case some sector angle is smaller than minAngle + let restAngle = totalAngle; + let valueSumLargerThanMinAngle = 0; + + zrUtil.each(children, function (child, idx) { + const value = child.getValue() as number; + let angle = (sum === 0 && stillShowZeroSum) + ? unitRadian : (value * unitRadian); + + if (angle < minAngle) { + angle = minAngle; + restAngle -= minAngle; + } + else { + valueSumLargerThanMinAngle += value; + } + + angles[idx] = angle; + }); + + // Some sector is constrained by minAngle. + // Rest sectors needs recalculate angle. + if (restAngle < totalAngle) { + if (restAngle <= 1e-3) { + // Average the angle if rest angle is not enough after all + // angles is constrained by minAngle + const angle = totalAngle / children.length; + for (let i = 0; i < angles.length; i++) { + angles[i] = angle; + } + } + else { + const restUnitRadian = restAngle / valueSumLargerThanMinAngle; + zrUtil.each(children, function (child, idx) { + if (angles[idx] !== minAngle) { + angles[idx] = (child.getValue() as number) * restUnitRadian; + } + }); + } + } + + return angles; + }; + /** * Render a tree * @return increased angle */ - const renderNode = function (node: TreeNode, startAngle: number) { + const renderNode = function (node: TreeNode, startAngle: number, angle: number) { if (!node) { return; } @@ -102,18 +153,6 @@ function sunburstLayout( // Render self if (node !== virtualRoot) { // Tree node is virtual, so it doesn't need to be drawn - const value = node.getValue() as number; - - let angle = (sum === 0 && stillShowZeroSum) - ? unitRadian : (value * unitRadian); - if (angle < minAngle) { - angle = minAngle; - // restAngle -= minAngle; - } - // else { - // valueSumLargerThanMinAngle += value; - // } - endAngle = startAngle + dir * angle; const depth = node.depth - rootDepth @@ -151,9 +190,10 @@ function sunburstLayout( // Render children if (node.children && node.children.length) { // currentAngle = startAngle; + const childAngles = getChildrenAngles(node.children, angle); let siblingAngle = 0; - zrUtil.each(node.children, function (node) { - siblingAngle += renderNode(node, startAngle + siblingAngle); + zrUtil.each(node.children, function (node, idx) { + siblingAngle += renderNode(node, startAngle + siblingAngle, childAngles[idx]); }); } @@ -178,7 +218,9 @@ function sunburstLayout( }); } - renderNode(treeRoot, startAngle); + // The view root always takes up the whole circle, no matter whether it + // is the virtual root (which is not drawn) or a rolled up node. + renderNode(treeRoot, startAngle, PI2); }); } diff --git a/test/ut/spec/series/sunburst.test.ts b/test/ut/spec/series/sunburst.test.ts new file mode 100644 index 0000000000..a3ba02fe1f --- /dev/null +++ b/test/ut/spec/series/sunburst.test.ts @@ -0,0 +1,156 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. +*/ + +import { createChart, removeChart } from '../../core/utHelper'; +import { EChartsType } from '@/src/echarts'; + +const RADIAN = Math.PI / 180; + +describe('sunburst', function () { + + describe('minAngle', function () { + + function createSunburst(minAngle: number): EChartsType { + const chart = createChart({ width: 500, height: 400 }); + chart.setOption({ + animation: false, + series: [{ + type: 'sunburst', + minAngle: minAngle, + data: [{ + name: 'A', + children: [ + { name: 'a', value: 100 }, + { name: 'b', value: 1 }, + { name: 'c', value: 1 }, + { name: 'd', value: 1 } + ] + }] + }] + }); + return chart; + } + + function getNodes(chart: EChartsType) { + const root = (chart as any).getModel().getSeriesByIndex(0).getData().tree.root; + const parent = root.children[0]; + return { parent: parent, children: parent.children }; + } + + it('should enlarge the sectors smaller than minAngle', function () { + const chart = createSunburst(30); + try { + const children = getNodes(chart).children; + for (let i = 0; i < children.length; i++) { + expect(children[i].getLayout().angle).toBeGreaterThanOrEqual(30 * RADIAN - 1e-6); + } + } + finally { + removeChart(chart); + } + }); + + it('should not make the children overflow their parent', function () { + const chart = createSunburst(30); + try { + const { parent, children } = getNodes(chart); + let sum = 0; + for (let i = 0; i < children.length; i++) { + sum += children[i].getLayout().angle; + } + expect(sum).toBeCloseTo(parent.getLayout().angle, 6); + expect(children[children.length - 1].getLayout().endAngle) + .toBeCloseTo(parent.getLayout().endAngle, 6); + } + finally { + removeChart(chart); + } + }); + + it('should make the grandchildren fit in a sector enlarged to minAngle', function () { + const chart = createChart({ width: 500, height: 400 }); + try { + chart.setOption({ + animation: false, + series: [{ + type: 'sunburst', + minAngle: 30, + data: [ + { name: 'a', value: 100 }, + { + name: 'b', + children: [ + { name: 'b1', value: 2 }, + { name: 'b2', value: 1 } + ] + } + ] + }] + }); + + const root = (chart as any).getModel().getSeriesByIndex(0).getData().tree.root; + const b = root.children[1]; + // `b` is only 3 / 103 of the circle, so it's enlarged to minAngle + expect(b.getLayout().angle).toBeCloseTo(30 * RADIAN, 6); + expect(b.children[0].getLayout().angle + b.children[1].getLayout().angle) + .toBeCloseTo(b.getLayout().angle, 6); + } + finally { + removeChart(chart); + } + }); + + it('should not change the layout when no sector is smaller than minAngle', function () { + const chart = createSunburst(0); + try { + const { parent, children } = getNodes(chart); + const unitRadian = parent.getLayout().angle / 103; + expect(children[0].getLayout().angle).toBeCloseTo(100 * unitRadian, 6); + expect(children[1].getLayout().angle).toBeCloseTo(unitRadian, 6); + } + finally { + removeChart(chart); + } + }); + + it('should not overflow the circle when the sum is zero', function () { + const chart = createChart({ width: 500, height: 400 }); + try { + const data = []; + for (let i = 0; i < 20; i++) { + data.push({ name: 'n' + i, value: 0 }); + } + chart.setOption({ + animation: false, + series: [{ type: 'sunburst', minAngle: 30, data: data }] + }); + + const root = (chart as any).getModel().getSeriesByIndex(0).getData().tree.root; + let sum = 0; + for (let i = 0; i < root.children.length; i++) { + sum += root.children[i].getLayout().angle; + } + expect(sum).toBeCloseTo(Math.PI * 2, 6); + } + finally { + removeChart(chart); + } + }); + }); +});