diff --git a/src/chart/pie/pieLayout.ts b/src/chart/pie/pieLayout.ts index 03a670604e..28026e105f 100644 --- a/src/chart/pie/pieLayout.ts +++ b/src/chart/pie/pieLayout.ts @@ -50,16 +50,12 @@ function pieLayout( const minAngle = seriesModel.get('minAngle') * RADIAN; - const minAndPadAngle = minAngle + padAngle; - let validDataCount = 0; data.each(valueDim, function (value: number) { !isNaN(value) && validDataCount++; }); const sum = data.getSum(valueDim); - // Sum may be 0 - let unitRadian = Math.PI / (sum || validDataCount) * 2; const clockwise = seriesModel.get('clockwise'); @@ -88,9 +84,118 @@ function pieLayout( const angleRange = Math.abs(endAngle - startAngle); - // In the case some sector angle is smaller than minAngle - let restAngle = angleRange; - let valueSumLargerThanMinAngle = 0; + // The angle of each sector (not including `padAngle`), by data + // index. `padAngle` is reserved for the gaps between the sectors + // first, and the remaining angle is then distributed over the + // sectors, so that the sector angles stay proportional to the + // values and `minAngle` applies to the sector angles themselves. + // (See #20603: previously the angles were distributed over the + // whole range and `padAngle` was carved out of each sector + // afterwards, which shrank sectors down to `minAngle` even when + // their fair angle was larger than it.) + const availableAngle = angleRange - padAngle * validDataCount; + + // The weight each sector gets in the distribution: its value, or + // 1 for every sector when the angles are distributed evenly + // (roseType 'area', or a zero sum still shown by + // `stillShowZeroSum`). + const distributeByValue = roseType !== 'area' + && !(sum === 0 && stillShowZeroSum); + const weights: number[] = []; + let weightSum = 0; + data.each(valueDim, function (value: number) { + const weight = isNaN(value) ? NaN + : distributeByValue ? value : 1; + weights.push(weight); + if (!isNaN(weight)) { + weightSum += weight; + } + }); + + const sectorAngles: number[] = []; + if (validDataCount) { + const unitAngle = availableAngle > 0 && weightSum > 0 + ? availableAngle / weightSum : 0; + for (let i = 0; i < weights.length; i++) { + const weight = weights[i]; + sectorAngles.push(isNaN(weight) ? NaN : weight * unitAngle); + } + + if (availableAngle > 0) { + // Raise the sectors smaller than `minAngle` to + // `minAngle` and distribute the remaining angle over + // the other sectors. This is repeated because + // redistributing can push another sector below + // `minAngle` again. + const fixed: boolean[] = []; + for (let i = 0; i < weights.length; i++) { + fixed.push(isNaN(weights[i])); + } + let fixedCount = 0; + while (fixedCount < validDataCount) { + let restAngle = availableAngle; + let restWeight = 0; + let restCount = 0; + for (let i = 0; i < weights.length; i++) { + if (fixed[i]) { + restAngle -= minAngle; + } + else if (!isNaN(weights[i])) { + restWeight += weights[i]; + restCount++; + } + } + if (restAngle < 0) { + // `minAngle` cannot be satisfied for every + // sector: fall back to distributing the angle + // evenly over the sectors. + for (let i = 0; i < weights.length; i++) { + if (!isNaN(weights[i])) { + sectorAngles[i] = availableAngle / validDataCount; + } + } + break; + } + let raised = false; + for (let i = 0; i < weights.length; i++) { + if (fixed[i] || isNaN(weights[i])) { + continue; + } + const angle = restWeight > 0 + ? weights[i] * restAngle / restWeight + : restAngle / restCount; + sectorAngles[i] = angle; + if (angle < minAngle) { + sectorAngles[i] = minAngle; + fixed[i] = true; + fixedCount++; + raised = true; + } + } + if (!raised) { + break; + } + } + + // If every sector had to be raised to `minAngle` and + // together they exceed the available angle, `minAngle` + // cannot be satisfied either: fall back to distributing + // the angle evenly over the sectors. + let totalAngle = 0; + for (let i = 0; i < weights.length; i++) { + if (!isNaN(weights[i])) { + totalAngle += sectorAngles[i]; + } + } + if (totalAngle - availableAngle > 1e-10) { + for (let i = 0; i < weights.length; i++) { + if (!isNaN(weights[i])) { + sectorAngles[i] = availableAngle / validDataCount; + } + } + } + } + } let currentAngle = startAngle; @@ -98,7 +203,6 @@ function pieLayout( data.setLayout({ viewRect, r }); data.each(valueDim, function (value: number, idx: number) { - let angle; if (isNaN(value)) { data.setItemLayout(idx, { angle: NaN, @@ -115,23 +219,9 @@ function pieLayout( return; } - // FIXME 兼容 2.0 但是 roseType 是 area 的时候才是这样? - if (roseType !== 'area') { - angle = (sum === 0 && stillShowZeroSum) - ? unitRadian : (value * unitRadian); - } - else { - angle = angleRange / validDataCount; - } - - - if (angle < minAndPadAngle) { - angle = minAndPadAngle; - restAngle -= minAndPadAngle; - } - else { - valueSumLargerThanMinAngle += value; - } + // The span this sector takes on the whole circle, including + // the `padAngle` gap reserved after it. + const angle = sectorAngles[idx] + padAngle; const endAngle = currentAngle + dir * angle; @@ -163,64 +253,6 @@ function pieLayout( currentAngle = endAngle; }); - - // Some sector is constrained by minAngle and padAngle - // Rest sectors needs recalculate angle - if (restAngle < PI2 && validDataCount) { - // Average the angle if rest angle is not enough after all angles is - // Constrained by minAngle and padAngle - if (restAngle <= 1e-3) { - const angle = angleRange / validDataCount; - data.each(valueDim, function (value: number, idx: number) { - if (!isNaN(value)) { - const layout = data.getItemLayout(idx); - layout.angle = angle; - - let actualStartAngle = 0; - let actualEndAngle = 0; - - if (angle < padAngle) { - actualStartAngle = startAngle + dir * (idx + 1 / 2) * angle; - actualEndAngle = actualStartAngle; - } - else { - actualStartAngle = startAngle + dir * idx * angle + halfPadAngle; - actualEndAngle = startAngle + dir * (idx + 1) * angle - halfPadAngle; - } - - layout.startAngle = actualStartAngle; - layout.endAngle = actualEndAngle; - } - }); - } - else { - unitRadian = restAngle / valueSumLargerThanMinAngle; - currentAngle = startAngle; - data.each(valueDim, function (value: number, idx: number) { - if (!isNaN(value)) { - const layout = data.getItemLayout(idx); - const angle = layout.angle === minAndPadAngle - ? minAndPadAngle : value * unitRadian; - - let actualStartAngle = 0; - let actualEndAngle = 0; - - if (angle < padAngle) { - actualStartAngle = currentAngle + dir * angle / 2; - actualEndAngle = actualStartAngle; - } - else { - actualStartAngle = currentAngle + halfPadAngle; - actualEndAngle = currentAngle + dir * angle - halfPadAngle; - } - - layout.startAngle = actualStartAngle; - layout.endAngle = actualEndAngle; - currentAngle += dir * angle; - } - }); - } - } }); } diff --git a/test/ut/spec/series/pie.test.ts b/test/ut/spec/series/pie.test.ts new file mode 100644 index 0000000000..41522b7a42 --- /dev/null +++ b/test/ut/spec/series/pie.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 } from '../../core/utHelper'; +import { EChartsOption } from '../../../../src/export/option'; + + +const RADIAN = Math.PI / 180; + +interface SectorLayout { + // The full span reserved for the sector, including its padAngle share. + span: number; + // The actually drawn sector angle (span minus padAngle). + angle: number; +} + +function getSectorLayouts(option: EChartsOption): SectorLayout[] { + const chart = createChart({width: 400, height: 300}); + try { + chart.setOption(option); + const seriesModel = (chart as any).getModel().getSeriesByIndex(0); + const data = seriesModel.getData(); + const layouts: SectorLayout[] = []; + data.each(function (idx: number) { + const layout = data.getItemLayout(idx); + layouts.push({ + span: layout.angle / RADIAN, + angle: Math.abs(layout.endAngle - layout.startAngle) / RADIAN + }); + }); + return layouts; + } + finally { + chart.dispose(); + } +} + +function makePieOption(seriesOption: object): EChartsOption { + return { + animation: false, + series: [Object.assign({ + type: 'pie', + radius: ['30%', '60%'], + data: [] + }, seriesOption)] + } as EChartsOption; +} + +function expectAngles(layouts: SectorLayout[], expectedAngles: number[], expectedSpans?: number[]) { + expect(layouts.length).toEqual(expectedAngles.length); + layouts.forEach(function (layout, idx) { + expect(layout.angle).toBeCloseTo(expectedAngles[idx], 5); + if (expectedSpans) { + expect(layout.span).toBeCloseTo(expectedSpans[idx], 5); + } + }); +} + + +describe('pie', function () { + + describe('padAngle and minAngle', function () { + + it('should proportion sector angles over the range left after reserving padAngle', function () { + // Issue #20603: values 2 and 10 with padAngle 15. + // 360 - 15 * 2 = 330 degrees are left for the sectors themselves, + // so the sector angles should be 330 * 2 / 12 = 55 and 330 * 10 / 12 = 275. + const layouts = getSectorLayouts(makePieOption({ + padAngle: 15, + data: [{value: 2}, {value: 10}] + })); + expectAngles(layouts, [55, 275], [70, 290]); + }); + + it('should not shrink a sector to minAngle when its fair angle is larger (issue #20603)', function () { + // Same configuration as the issue demo, with minAngle 45 set. + // The fair angle of the first sector (55) is larger than minAngle, + // so minAngle must not change anything. + const layouts = getSectorLayouts(makePieOption({ + padAngle: 15, + minAngle: 45, + data: [{value: 2}, {value: 10}] + })); + expectAngles(layouts, [55, 275], [70, 290]); + }); + + it('should keep plain proportions when neither padAngle nor minAngle is set', function () { + const layouts = getSectorLayouts(makePieOption({ + data: [{value: 2}, {value: 10}] + })); + expectAngles(layouts, [60, 300], [60, 300]); + }); + + it('should raise sectors below minAngle and redistribute the rest', function () { + const layouts = getSectorLayouts(makePieOption({ + minAngle: 45, + data: [{value: 1}, {value: 100}] + })); + expectAngles(layouts, [45, 315], [45, 315]); + }); + + it('should raise sectors below minAngle when padAngle is also set', function () { + const layouts = getSectorLayouts(makePieOption({ + padAngle: 10, + minAngle: 45, + data: [{value: 1}, {value: 100}] + })); + expectAngles(layouts, [45, 295], [55, 305]); + }); + + it('should still respect minAngle for sectors pushed below it by redistribution', function () { + // The fair angle of the second sector (about 88.2) is above minAngle 80, + // but after the first sector is raised to minAngle its redistributed + // angle would drop below 80, so it must be raised to minAngle as well. + const layouts = getSectorLayouts(makePieOption({ + minAngle: 80, + data: [{value: 8}, {value: 11.5}, {value: 30}] + })); + expectAngles(layouts, [80, 80, 200], [80, 80, 200]); + }); + + it('should average the angles when minAngle cannot be satisfied for all sectors', function () { + const layouts = getSectorLayouts(makePieOption({ + minAngle: 150, + data: [{value: 1}, {value: 1}, {value: 1}] + })); + expectAngles(layouts, [120, 120, 120], [120, 120, 120]); + }); + + it('should split evenly for zero sum data when stillShowZeroSum is set', function () { + const layouts = getSectorLayouts(makePieOption({ + stillShowZeroSum: true, + data: [{value: 0}, {value: 0}] + })); + expectAngles(layouts, [180, 180], [180, 180]); + }); + + }); + +});