This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new dac69f9bcde feat(pie): geometric recentering and scaling for partial
arcs (#42151)
dac69f9bcde is described below
commit dac69f9bcde078c5baa7d495bbad4c447294bf68
Author: Evan Rusackas <[email protected]>
AuthorDate: Tue Jul 28 22:27:11 2026 -0700
feat(pie): geometric recentering and scaling for partial arcs (#42151)
Co-authored-by: Amin Ghadersohi <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
---
.../plugin-chart-echarts/src/Pie/controlPanel.tsx | 5 +-
.../plugin-chart-echarts/src/Pie/transformProps.ts | 263 ++++++++------
.../plugins/plugin-chart-echarts/src/Pie/types.ts | 33 --
.../test/Pie/transformProps.test.ts | 387 +++++++--------------
4 files changed, 274 insertions(+), 414 deletions(-)
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/controlPanel.tsx
b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/controlPanel.tsx
index a383014f58f..1ddde9c14c6 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/controlPanel.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/controlPanel.tsx
@@ -325,9 +325,8 @@ const config: ControlPanelConfig = {
description: t(
'Total angle covered by the chart, in degrees. ' +
'360° draws a full circle and 180° draws a half donut. ' +
- 'When the sweep is 180° or less and the start angle is a ' +
- 'multiple of 90°, the chart is automatically re-centered ' +
- 'to make use of the empty space.',
+ 'Partial arcs are automatically re-centered and scaled ' +
+ 'to make use of the available space.',
),
renderTrigger: true,
default: DEFAULT_FORM_DATA.sweptAngle,
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts
b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts
index 2e00a62bc1a..f282799458a 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts
@@ -39,9 +39,6 @@ import {
EchartsPieLabelType,
PieChartDataItem,
PieChartTransformedProps,
- TotalValuePaddingProps,
- PaddingResult,
- HalfDonut,
} from './types';
import { DEFAULT_LEGEND_FORM_DATA, OpacityEnum } from '../constants';
import {
@@ -76,120 +73,153 @@ export function parseParams({
return [name, formattedValue, formattedPercent];
}
-const HALF_DONUT_SWEEP_LIMIT = 180;
-
/**
- * Geometric configuration for each type of semi-circular layout.
- *
- * - `centerOffset` — offset of the chart center from the baseline 50% on the
X and Y axes.
- * Resulting position: `50% + offset`.
- * - `totalBase` — base position of the "Total" text as a percentage on the
X and Y axes.
- *
- * The values are empirically tuned so that the "Total" text visually remains
- * at the geometric center of the arc after the chart is re-centered. The
- * `left`/`right` totalBase values sit 5% inside the shifted chart center
- * (60% and 40% respectively) to compensate for the text being positioned by
- * its left edge rather than its midpoint.
+ * Bounding box of the pie arc in unit coordinates: outer radius = 1,
+ * mathematical y-up convention matching ECharts' angle convention
+ * (0° points right, 90° points up, angles sweep clockwise from
+ * `startAngle` to `startAngle - sweptAngle`).
*/
-const HALF_DONUT_LAYOUT: Record<
- HalfDonut,
- {
- centerOffset: { x: number; y: number };
- totalBase: { left: number; top: number };
- }
-> = {
- top: { centerOffset: { x: 0, y: 20 }, totalBase: { left: 50, top: 68.5 } },
- bottom: { centerOffset: { x: 0, y: -20 }, totalBase: { left: 50, top: 30 } },
- left: { centerOffset: { x: 10, y: 0 }, totalBase: { left: 55, top: 50 } },
- right: { centerOffset: { x: -10, y: 0 }, totalBase: { left: 35, top: 50 } },
- none: { centerOffset: { x: 0, y: 0 }, totalBase: { left: 50, top: 50 } },
-};
+export interface ArcBoundingBox {
+ minX: number;
+ maxX: number;
+ minY: number;
+ maxY: number;
+}
/**
- * Determines the type of semicircular layout based on the start angle and
swept angle.
+ * Computes the bounding box of an annular sector from its angles.
*
- * All four semicircle orientations are supported:
- * - `'top'` — the arc is positioned at the top; the chart center shifts
downwards.
- * - `'bottom'` — the arc is positioned at the bottom; the chart center shifts
upwards.
- * - `'left'` — the arc is positioned at the left; the chart center shifts
right.
- * - `'right'` — the arc is positioned at the right; the chart center shifts
left.
+ * The box is spanned by the outer arc endpoints, the inner arc endpoints
+ * (which collapse to the pie origin when `innerRatio` is 0), and every axis
+ * extreme (0°/90°/180°/270°) the arc sweeps through.
*
- * @param startAngle - The start angle of the arc in degrees (0–360).
- * @param sweptAngle - The swept angle of the arc in degrees (10–360).
- * @returns The type of semicircular layout.
+ * @param startAngle - The start angle of the arc in degrees.
+ * @param sweptAngle - The total angle covered by the arc in degrees.
+ * @param innerRatio - Inner radius as a fraction of the outer radius (0–1).
*/
-export const getHalfDonut = (
+export function getArcBoundingBox(
startAngle: number,
sweptAngle: number,
-): HalfDonut => {
- if (sweptAngle > HALF_DONUT_SWEEP_LIMIT) return 'none';
-
- const normalized = startAngle % 360;
-
- if (normalized === 180) return 'top';
- if (normalized === 0) return 'bottom';
- if (normalized === 270) return 'left';
- if (normalized === 90) return 'right';
-
- return 'none';
-};
+ innerRatio: number,
+): ArcBoundingBox {
+ if (sweptAngle >= 360) {
+ return { minX: -1, maxX: 1, minY: -1, maxY: 1 };
+ }
+ const toRad = (deg: number) => (deg * Math.PI) / 180;
+ const endAngle = startAngle - sweptAngle;
+ const points: [number, number][] = [];
+ [startAngle, endAngle].forEach(angle => {
+ const x = Math.cos(toRad(angle));
+ const y = Math.sin(toRad(angle));
+ points.push([x, y], [innerRatio * x, innerRatio * y]);
+ });
+ for (
+ let axis = Math.ceil(endAngle / 90) * 90;
+ axis <= startAngle;
+ axis += 90
+ ) {
+ points.push([Math.cos(toRad(axis)), Math.sin(toRad(axis))]);
+ }
+ const xs = points.map(([x]) => x);
+ const ys = points.map(([, y]) => y);
+ return {
+ minX: Math.min(...xs),
+ maxX: Math.max(...xs),
+ minY: Math.min(...ys),
+ maxY: Math.max(...ys),
+ };
+}
-const getHalfDonutLayout = (startAngle: number, sweptAngle: number) =>
- HALF_DONUT_LAYOUT[getHalfDonut(startAngle, sweptAngle)];
+/**
+ * Arcs covering only a sliver of the circle would otherwise scale up without
+ * bound; cap the fit scale at the factor a quarter arc reaches naturally.
+ */
+const MAX_RADIUS_SCALE = 2;
+
+export interface PieLayout {
+ /** Pie origin in px, relative to the padded series rect. */
+ center: [number, number];
+ /** Inner/outer radius percent strings, scaled to fit the arc's box. */
+ radius: [string, string];
+ /** Pie origin in px, in container coordinates (for the graphic component).
*/
+ totalAnchor: { x: number; y: number };
+}
-export function getTotalValuePadding({
- chartPadding,
- donut,
+/**
+ * Lays out the pie geometrically for any start/sweep angle combination:
+ * scales the radius until the arc's bounding box fills the available rect
+ * (so partial arcs reclaim the space a full circle would leave empty) and
+ * shifts the pie origin so that box is centered. A full circle reproduces
+ * ECharts' default layout exactly.
+ */
+export function getPieLayout({
width,
height,
+ padding,
startAngle,
sweptAngle,
-}: TotalValuePaddingProps): PaddingResult {
- const safeHeight = height || 1;
- const safeWidth = width || 1;
-
- const halfType = getHalfDonut(startAngle, sweptAngle);
- const layout = HALF_DONUT_LAYOUT[halfType];
- const isHalf = halfType !== 'none';
-
- const calculateTop = (): string => {
- if (chartPadding.bottom) {
- return donut
- ? `${layout.totalBase.top - (chartPadding.bottom / safeHeight) * 50}%`
- : '0';
- }
-
- if (chartPadding.top || isHalf) {
- if (donut) {
- return `${layout.totalBase.top + (chartPadding.top / safeHeight) *
50}%`;
- }
- return `${(chartPadding.top / safeHeight) * 100}%`;
- }
-
- return donut ? 'middle' : '0';
- };
-
- const calculateLeft = (): string => {
- if (chartPadding.right) {
- const rightPercent = (chartPadding.right / safeWidth) * 100;
- return `${layout.totalBase.left - rightPercent * 0.75}%`;
- }
-
- if (chartPadding.left) {
- const leftPercent = (chartPadding.left / safeWidth) * 100;
- return `${layout.totalBase.left + leftPercent * 0.25}%`;
- }
-
- if (isHalf && (halfType === 'left' || halfType === 'right')) {
- return `${layout.totalBase.left}%`;
- }
-
- return 'center';
- };
+ donut,
+ innerRadius,
+ outerRadius,
+}: {
+ width: number;
+ height: number;
+ padding: { top: number; bottom: number; left: number; right: number };
+ startAngle: number;
+ sweptAngle: number;
+ donut: boolean;
+ innerRadius: number;
+ outerRadius: number;
+}): PieLayout {
+ const rectWidth = Math.max(width - padding.left - padding.right, 1);
+ const rectHeight = Math.max(height - padding.top - padding.bottom, 1);
+ const innerRatio = donut ? innerRadius / Math.max(outerRadius, 1) : 0;
+ const box = getArcBoundingBox(startAngle, sweptAngle, innerRatio);
+ const boxWidth = box.maxX - box.minX;
+ const boxHeight = box.maxY - box.minY;
+
+ // ECharts resolves percentage radii against min(rect width, height) / 2.
+ // Grow that basis until the arc's bounding box hits the rect on one axis.
+ const fullBasis = Math.min(rectWidth, rectHeight) / 2;
+ const fitBasis = Math.min(rectWidth / boxWidth, rectHeight / boxHeight);
+ const scale = Math.min(fitBasis / fullBasis, MAX_RADIUS_SCALE);
+ const outerPx = (outerRadius / 100) * fullBasis * scale;
+
+ // Place the pie origin so the arc's box is centered in the rect. Unit y
+ // points up while screen y points down, hence the sign flip.
+ const round = (value: number) => Math.round(value * 100) / 100;
+ const centerX = rectWidth / 2 - ((box.minX + box.maxX) / 2) * outerPx;
+ const centerY = rectHeight / 2 + ((box.minY + box.maxY) / 2) * outerPx;
+
+ // The pie origin is the natural spot for the "Total" text (the middle of
+ // the hole, or the flat edge of a half donut), but for narrow arcs it can
+ // fall far outside the drawn shape, even off-canvas. Clamp it into the
+ // arc's bounding box, which always sits within the rect.
+ const clamp = (value: number, min: number, max: number) =>
+ Math.min(Math.max(value, min), max);
+ const halfBoxWidth = (boxWidth / 2) * outerPx;
+ const halfBoxHeight = (boxHeight / 2) * outerPx;
+ const anchorX = clamp(
+ centerX,
+ rectWidth / 2 - halfBoxWidth,
+ rectWidth / 2 + halfBoxWidth,
+ );
+ const anchorY = clamp(
+ centerY,
+ rectHeight / 2 - halfBoxHeight,
+ rectHeight / 2 + halfBoxHeight,
+ );
return {
- top: calculateTop(),
- left: calculateLeft(),
+ center: [round(centerX), round(centerY)],
+ radius: [
+ `${round(donut ? innerRadius * scale : 0)}%`,
+ `${round(outerRadius * scale)}%`,
+ ],
+ totalAnchor: {
+ x: round(padding.left + anchorX),
+ y: round(padding.top + anchorY),
+ },
};
}
@@ -478,7 +508,16 @@ export default function transformProps(
effectiveLegendMargin,
);
- const { centerOffset } = getHalfDonutLayout(startAngle, sweptAngle);
+ const pieLayout = getPieLayout({
+ width,
+ height,
+ padding: chartPadding,
+ startAngle,
+ sweptAngle,
+ donut,
+ innerRadius,
+ outerRadius,
+ });
const series: PieSeriesOption[] = [
{
@@ -486,8 +525,8 @@ export default function transformProps(
...chartPadding,
animation: false,
roseType: roseType || undefined,
- radius: [`${donut ? innerRadius : 0}%`, `${outerRadius}%`],
- center: [`${50 + centerOffset.x}%`, `${50 + centerOffset.y}%`],
+ radius: pieLayout.radius,
+ center: pieLayout.center,
startAngle,
endAngle: startAngle - sweptAngle,
avoidLabelOverlap: true,
@@ -550,16 +589,18 @@ export default function transformProps(
graphic: showTotal
? {
type: 'text',
- ...getTotalValuePadding({
- chartPadding,
- donut,
- width,
- height,
- startAngle,
- sweptAngle,
- }),
+ // Donut: center the text on the pie origin (the middle of the
+ // hole, or the flat edge of a partial arc). Pie: park it at the
+ // top center of the padded rect so it doesn't overlap the slices.
+ x: donut
+ ? pieLayout.totalAnchor.x
+ : chartPadding.left +
+ (width - chartPadding.left - chartPadding.right) / 2,
+ y: donut ? pieLayout.totalAnchor.y : chartPadding.top,
style: {
text: t('Total: %s', numberFormatter(totalValue)),
+ align: 'center',
+ verticalAlign: donut ? 'middle' : 'top',
fontSize: 16,
fontWeight: 'bold',
fill: theme.colorText,
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/types.ts
b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/types.ts
index 91b38865519..fc5ea601749 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/types.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/types.ts
@@ -103,36 +103,3 @@ export interface PieChartDataItem {
};
isOther?: boolean;
}
-
-interface ChartPadding {
- top: number;
- bottom: number;
- left: number;
- right: number;
-}
-
-export interface TotalValuePaddingProps {
- chartPadding: ChartPadding;
- donut: boolean;
- width: number;
- height: number;
- sweptAngle: number;
- startAngle: number;
-}
-
-export interface PaddingResult {
- top?: string;
- left?: string;
-}
-
-/**
- * Semicircular chart layout type.
- *
- * - `'top'` — arc at the top, center shifted downwards.
- * - `'bottom'` — arc at the bottom, center shifted upwards.
- * - `'left'` — arc on the left, center shifted to the right.
- * - `'right'` — arc on the right, center shifted to the left.
- * - `'none'` — full circle (no recentering).
- * @see getHalfDonut
- */
-export type HalfDonut = 'top' | 'bottom' | 'left' | 'right' | 'none';
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/test/Pie/transformProps.test.ts
b/superset-frontend/plugins/plugin-chart-echarts/test/Pie/transformProps.test.ts
index b6c2d768060..3174f4a1639 100644
---
a/superset-frontend/plugins/plugin-chart-echarts/test/Pie/transformProps.test.ts
+++
b/superset-frontend/plugins/plugin-chart-echarts/test/Pie/transformProps.test.ts
@@ -29,12 +29,20 @@ import type {
} from 'echarts/types/src/util/types';
import transformProps, {
parseParams,
- getHalfDonut,
- getTotalValuePadding,
+ getArcBoundingBox,
+ getPieLayout,
} from '../../src/Pie/transformProps';
import { EchartsPieChartProps, PieChartDataItem } from '../../src/Pie/types';
import { LegendOrientation, LegendType } from '../../src/types';
+const getGraphic = (transformed: ReturnType<typeof transformProps>) =>
+ transformed.echartOptions.graphic as {
+ type: string;
+ x: number;
+ y: number;
+ style: { text: string; align: string; verticalAlign: string };
+ };
+
describe('Pie transformProps', () => {
const formData: SqlaFormData = {
colorScheme: 'bnbColors',
@@ -352,109 +360,60 @@ describe('Total value positioning with legends', () => {
test('should center total text when legend is on the right', () => {
const props = getChartPropsWithLegend(true, true, 'right', true);
- const transformed = transformProps(props);
-
- expect(transformed.echartOptions.graphic).toEqual(
- expect.objectContaining({
- type: 'text',
- left: expect.stringMatching(/^\d+(\.\d+)?%$/),
- top: 'middle',
- style: expect.objectContaining({
- text: expect.stringContaining('Total:'),
- }),
- }),
- );
+ const graphic = getGraphic(transformProps(props));
- // The left position should be less than 50% (shifted left)
- const leftValue = parseFloat(
- (transformed.echartOptions.graphic as any).left.replace('%', ''),
- );
- expect(leftValue).toBeLessThan(50);
- expect(leftValue).toBeGreaterThan(30); // Should be reasonable positioning
+ expect(graphic.type).toBe('text');
+ expect(graphic.style.text).toContain('Total:');
+ expect(graphic.style.align).toBe('center');
+ expect(graphic.style.verticalAlign).toBe('middle');
+ // Anchored on the pie origin, which shifts left of the container center
+ // because the right legend narrows the series rect.
+ expect(graphic.x).toBeLessThan(400);
+ expect(graphic.y).toBe(300);
});
test('should center total text when legend is on the left', () => {
const props = getChartPropsWithLegend(true, true, 'left', true);
- const transformed = transformProps(props);
+ const graphic = getGraphic(transformProps(props));
- expect(transformed.echartOptions.graphic).toEqual(
- expect.objectContaining({
- type: 'text',
- left: expect.stringMatching(/^\d+(\.\d+)?%$/),
- top: 'middle',
- }),
- );
-
- // The left position should be greater than 50% (shifted right)
- const leftValue = parseFloat(
- (transformed.echartOptions.graphic as any).left.replace('%', ''),
- );
- expect(leftValue).toBeGreaterThan(50);
- expect(leftValue).toBeLessThan(70); // Should be reasonable positioning
+ // The left legend pads the rect, pushing the pie origin right.
+ expect(graphic.x).toBeGreaterThan(400);
+ expect(graphic.y).toBe(300);
});
test('should center total text when legend is on top', () => {
const props = getChartPropsWithLegend(true, true, 'top', true);
- const transformed = transformProps(props);
-
- expect(transformed.echartOptions.graphic).toEqual(
- expect.objectContaining({
- type: 'text',
- left: 'center',
- top: expect.stringMatching(/^\d+(\.\d+)?%$/),
- }),
- );
+ const graphic = getGraphic(transformProps(props));
- // The top position should be adjusted for top legend
- const topValue = parseFloat(
- (transformed.echartOptions.graphic as any).top.replace('%', ''),
- );
- expect(topValue).toBeGreaterThan(50); // Shifted down for top legend
+ expect(graphic.x).toBe(400);
+ expect(graphic.y).toBeGreaterThan(300);
});
test('should center total text when legend is on bottom', () => {
const props = getChartPropsWithLegend(true, true, 'bottom', true);
- const transformed = transformProps(props);
-
- expect(transformed.echartOptions.graphic).toEqual(
- expect.objectContaining({
- type: 'text',
- left: 'center',
- top: expect.stringMatching(/^\d+(\.\d+)?%$/),
- }),
- );
+ const graphic = getGraphic(transformProps(props));
- // The top position should be adjusted for bottom legend
- const topValue = parseFloat(
- (transformed.echartOptions.graphic as any).top.replace('%', ''),
- );
- expect(topValue).toBeLessThan(50); // Shifted up for bottom legend
+ expect(graphic.x).toBe(400);
+ expect(graphic.y).toBeLessThan(300);
});
- test('should use default positioning when no legend is shown', () => {
+ test('should center on the pie origin when no legend is shown', () => {
const props = getChartPropsWithLegend(true, false, 'right', true);
- const transformed = transformProps(props);
+ const graphic = getGraphic(transformProps(props));
- expect(transformed.echartOptions.graphic).toEqual(
- expect.objectContaining({
- type: 'text',
- left: 'center',
- top: 'middle',
- }),
- );
+ expect(graphic.x).toBe(400);
+ expect(graphic.y).toBe(300);
+ expect(graphic.style.verticalAlign).toBe('middle');
});
- test('should handle regular pie chart (non-donut) positioning', () => {
+ test('should park total at the top of the rect for non-donut charts', () => {
const props = getChartPropsWithLegend(true, true, 'right', false);
- const transformed = transformProps(props);
+ const graphic = getGraphic(transformProps(props));
- expect(transformed.echartOptions.graphic).toEqual(
- expect.objectContaining({
- type: 'text',
- top: '0', // Non-donut charts use '0' as default top position
- left: expect.stringMatching(/^\d+(\.\d+)?%$/), // Should still adjust
left for right legend
- }),
- );
+ expect(graphic.y).toBe(0);
+ expect(graphic.style.verticalAlign).toBe('top');
+ // Horizontally centered over the narrowed rect, not the container.
+ expect(graphic.x).toBeLessThan(400);
});
test('should not show total graphic when showTotal is false', () => {
@@ -655,6 +614,7 @@ const getAngleChartProps = (
startAngle,
sweptAngle,
show_total: true,
+ show_legend: false,
};
return new ChartProps({
@@ -673,217 +633,110 @@ const getAngleChartProps = (
}) as EchartsPieChartProps;
};
-test('sets center to 70% for half-donut', () => {
- const props = getAngleChartProps(true, 180);
- const transformed = transformProps(props);
- const series = transformed.echartOptions.series as PieSeriesOption[];
- expect(series[0].center).toEqual(['50%', '70%']);
-});
+const getSeries = (props: EchartsPieChartProps) =>
+ transformProps(props).echartOptions.series as PieSeriesOption[];
-test('keeps center at 50% for full donut', () => {
- const props = getAngleChartProps(true, 360);
- const transformed = transformProps(props);
- const series = transformed.echartOptions.series as PieSeriesOption[];
- expect(series[0].center).toEqual(['50%', '50%']);
+test('keeps ECharts default layout for a full donut', () => {
+ const series = getSeries(getAngleChartProps(true, 360, 90));
+ expect(series[0].center).toEqual([400, 300]);
+ expect(series[0].radius).toEqual(['30%', '70%']);
});
-test('calculates endAngle for a quarter donut', () => {
- const props = getAngleChartProps(true, 90);
- const transformed = transformProps(props);
- const series = transformed.echartOptions.series as PieSeriesOption[];
- expect(series[0].endAngle).toBe(90);
+test('recenters and scales up a top half-donut', () => {
+ // Bounding box is 2 wide x 1 tall, so an 800x600 canvas fits a radius
+ // basis of min(800/2, 600/1) = 400 instead of 300: scale 4/3.
+ const series = getSeries(getAngleChartProps(true, 180, 180));
+ expect(series[0].center).toEqual([400, 440]);
+ expect(series[0].radius).toEqual(['40%', '93.33%']);
});
-test('sets center to 30% for bottom half-donut (startAngle=0)', () => {
- const props = getAngleChartProps(true, 180, 0);
- const transformed = transformProps(props);
- const series = transformed.echartOptions.series as PieSeriesOption[];
- expect(series[0].center).toEqual(['50%', '30%']);
+test('recenters a bottom half-donut upwards', () => {
+ const series = getSeries(getAngleChartProps(true, 180, 0));
+ expect(series[0].center).toEqual([400, 160]);
});
-test('sets center to 30% for bottom half-donut (startAngle=360)', () => {
- const props = getAngleChartProps(true, 180, 360);
- const transformed = transformProps(props);
- const series = transformed.echartOptions.series as PieSeriesOption[];
- expect(series[0].center).toEqual(['50%', '30%']);
+test('recenters a right half-donut leftwards without scaling', () => {
+ // A lateral half is 1 wide x 2 tall; height binds at the full-circle
+ // basis, so the radius stays put and only the center shifts.
+ const series = getSeries(getAngleChartProps(true, 180, 90));
+ expect(series[0].center).toEqual([295, 300]);
+ expect(series[0].radius).toEqual(['30%', '70%']);
});
-test('shifts center left for right half-donut (startAngle=90)', () => {
- const props = getAngleChartProps(true, 180, 90);
- const transformed = transformProps(props);
- const series = transformed.echartOptions.series as PieSeriesOption[];
- expect(series[0].center).toEqual(['40%', '50%']);
+test('recenters a left half-donut rightwards', () => {
+ const series = getSeries(getAngleChartProps(true, 180, 270));
+ expect(series[0].center).toEqual([505, 300]);
});
-test('shifts center right for left half-donut (startAngle=270)', () => {
- const props = getAngleChartProps(true, 180, 270);
- const transformed = transformProps(props);
- const series = transformed.echartOptions.series as PieSeriesOption[];
- expect(series[0].center).toEqual(['60%', '50%']);
+test('recenters non-cardinal start angles too', () => {
+ const series = getSeries(getAngleChartProps(true, 180, 170));
+ expect(series[0].center).not.toEqual([400, 300]);
});
-test('keeps center at 50% for non-cardinal start angle even when sweep ≤ 180',
() => {
- const props = getAngleChartProps(true, 180, 45);
- const transformed = transformProps(props);
- const series = transformed.echartOptions.series as PieSeriesOption[];
- expect(series[0].center).toEqual(['50%', '50%']);
+test('scales a quarter donut to the fit cap', () => {
+ const series = getSeries(getAngleChartProps(true, 90, 180));
+ expect(series[0].center).toEqual([610, 510]);
+ expect(series[0].radius).toEqual(['60%', '140%']);
});
-test('allows endAngle to go negative for right half-donut', () => {
- const props = getAngleChartProps(true, 180, 90);
- const transformed = transformProps(props);
- const series = transformed.echartOptions.series as PieSeriesOption[];
+test('passes startAngle through and derives endAngle from the sweep', () => {
+ const series = getSeries(getAngleChartProps(true, 180, 90));
expect(series[0].startAngle).toBe(90);
expect(series[0].endAngle).toBe(-90);
});
-test.each([
- [180, 180, 'top'],
- [180, 90, 'top'],
- [180, 45, 'top'],
- [0, 180, 'bottom'],
- [360, 180, 'bottom'],
- [360, 90, 'bottom'],
- [90, 180, 'right'],
- [90, 90, 'right'],
- [270, 180, 'left'],
- [270, 90, 'left'],
- [45, 180, 'none'],
- [170, 180, 'none'],
- [180, 360, 'none'],
- [180, 181, 'none'],
- [0, 360, 'none'],
-])('startAngle=%i, sweptAngle=%i → %s', (start, swept, expected) => {
- expect(getHalfDonut(start, swept)).toBe(expected);
-});
-
-const baseProps = {
- donut: true,
- width: 800,
- height: 600,
- startAngle: 180,
- sweptAngle: 360,
-};
-
-test('returns "middle" for donut without padding and not half', () => {
- const result = getTotalValuePadding({
- ...baseProps,
- chartPadding: { top: 0, bottom: 0, left: 0, right: 0 },
- });
- expect(result.top).toBe('middle');
-});
-
-test('returns "0" for non-donut without padding and not half', () => {
- const result = getTotalValuePadding({
- ...baseProps,
- donut: false,
- chartPadding: { top: 0, bottom: 0, left: 0, right: 0 },
- });
- expect(result.top).toBe('0');
-});
-
-test('adjusts top for donut with bottom padding', () => {
- const result = getTotalValuePadding({
- ...baseProps,
- chartPadding: { top: 0, bottom: 60, left: 0, right: 0 },
- });
- expect(result.top).toBe('45%');
-});
-
-test('returns "0" for non-donut with bottom padding', () => {
- const result = getTotalValuePadding({
- ...baseProps,
- donut: false,
- chartPadding: { top: 0, bottom: 60, left: 0, right: 0 },
- });
- expect(result.top).toBe('0');
-});
-
-test('adjusts top for donut with top padding', () => {
- const result = getTotalValuePadding({
- ...baseProps,
- chartPadding: { top: 60, bottom: 0, left: 0, right: 0 },
- });
- expect(result.top).toBe('55%');
+test('anchors the total on the pie origin for a top half-donut', () => {
+ const graphic = getGraphic(
+ transformProps(getAngleChartProps(true, 180, 180)),
+ );
+ expect(graphic.x).toBe(400);
+ expect(graphic.y).toBe(440);
});
-test('adjusts top for non-donut with top padding', () => {
- const result = getTotalValuePadding({
- ...baseProps,
- donut: false,
- chartPadding: { top: 60, bottom: 0, left: 0, right: 0 },
- });
- expect(result.top).toBe('10%');
-});
-
-test('positions total at 68.5% for top half-donut without padding', () => {
- const result = getTotalValuePadding({
- ...baseProps,
- sweptAngle: 180,
- chartPadding: { top: 0, bottom: 0, left: 0, right: 0 },
- });
- expect(result.top).toBe('68.5%');
-});
-
-test('adjusts total position from 68.5% base for top half-donut with top
padding', () => {
- const result = getTotalValuePadding({
- ...baseProps,
- sweptAngle: 180,
- chartPadding: { top: 60, bottom: 0, left: 0, right: 0 },
- });
- expect(result.top).toBe('73.5%');
-});
-
-test('returns "center" when no left/right padding', () => {
- const result = getTotalValuePadding({
- ...baseProps,
- chartPadding: { top: 0, bottom: 0, left: 0, right: 0 },
- });
- expect(result.left).toBe('center');
-});
-
-test('adjusts left for left padding', () => {
- const result = getTotalValuePadding({
- ...baseProps,
- chartPadding: { top: 0, bottom: 0, left: 80, right: 0 },
- });
- expect(result.left).toBe('52.5%');
-});
-
-test('adjusts left for right padding', () => {
- const result = getTotalValuePadding({
- ...baseProps,
- chartPadding: { top: 0, bottom: 0, left: 0, right: 80 },
- });
- expect(result.left).toBe('42.5%');
+test.each([
+ ['full circle', 90, 360, 0.3, { minX: -1, maxX: 1, minY: -1, maxY: 1 }],
+ ['top half', 180, 180, 0, { minX: -1, maxX: 1, minY: 0, maxY: 1 }],
+ ['bottom half', 0, 180, 0, { minX: -1, maxX: 1, minY: -1, maxY: 0 }],
+ ['right half', 90, 180, 0, { minX: 0, maxX: 1, minY: -1, maxY: 1 }],
+ ['left half', 270, 180, 0, { minX: -1, maxX: 0, minY: -1, maxY: 1 }],
+ ['top-left quarter', 180, 90, 0.5, { minX: -1, maxX: 0, minY: 0, maxY: 1 }],
+])('getArcBoundingBox: %s', (_label, start, sweep, inner, expected) => {
+ const box = getArcBoundingBox(start, sweep, inner);
+ expect(box.minX).toBeCloseTo(expected.minX, 10);
+ expect(box.maxX).toBeCloseTo(expected.maxX, 10);
+ expect(box.minY).toBeCloseTo(expected.minY, 10);
+ expect(box.maxY).toBeCloseTo(expected.maxY, 10);
});
-test('prioritizes right padding over left padding', () => {
- const result = getTotalValuePadding({
- ...baseProps,
- chartPadding: { top: 0, bottom: 0, left: 80, right: 80 },
- });
- expect(result.left).toBe('42.5%');
+test('getArcBoundingBox includes inner arc endpoints for narrow donuts', () =>
{
+ // A 20-degree sliver straddling 12 o'clock: the lowest point of the
+ // annular sector is an inner endpoint, not an outer one.
+ const box = getArcBoundingBox(100, 20, 0.5);
+ expect(box.minY).toBeCloseTo(0.5 * Math.sin((80 * Math.PI) / 180), 10);
+ expect(box.maxY).toBeCloseTo(1, 10);
});
-test('positions total inside the shifted center for left half-donut', () => {
- const result = getTotalValuePadding({
- ...baseProps,
- startAngle: 270,
- sweptAngle: 180,
- chartPadding: { top: 0, bottom: 0, left: 0, right: 0 },
- });
- expect(result.left).toBe('55%');
- expect(result.top).toBe('50%');
+test('getPieLayout centers the bounding box within legend padding', () => {
+ const layout = getPieLayout({
+ width: 800,
+ height: 600,
+ padding: { top: 0, bottom: 0, left: 0, right: 200 },
+ startAngle: 90,
+ sweptAngle: 360,
+ donut: true,
+ innerRadius: 30,
+ outerRadius: 70,
+ });
+ // Rect is 600x600; the pie centers within it and the total anchor is
+ // reported in container coordinates.
+ expect(layout.center).toEqual([300, 300]);
+ expect(layout.totalAnchor).toEqual({ x: 300, y: 300 });
});
-test('positions total inside the shifted center for right half-donut', () => {
- const result = getTotalValuePadding({
- ...baseProps,
- startAngle: 90,
- sweptAngle: 180,
- chartPadding: { top: 0, bottom: 0, left: 0, right: 0 },
- });
- expect(result.left).toBe('35%');
- expect(result.top).toBe('50%');
+test('clamps the total anchor into the arc box for narrow arcs', () => {
+ // A 20-degree sliver's pie origin falls far below the drawn wedge; the
+ // anchor must stay within the arc's bounding box so the text is visible.
+ const graphic = getGraphic(transformProps(getAngleChartProps(true, 20,
100)));
+ expect(graphic.x).toBe(400);
+ expect(graphic.y).toBeCloseTo(421.37, 1);
});