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);
 });

Reply via email to