Java:实现饼图旋转角度(附带源码)

一、项目介绍

1.1 背景

在现代数据可视化领域,饼图(Pie Chart)因其直观展示各部分占整体比例而被广泛采用。为了增强互动性和吸引力,常会赋予饼图 旋转 动画:自动、平滑地旋转,让用户从不同角度重点查看扇区。旋转角度可以突出数据变化、引导观看顺序、提升界面动感。然而,要在 Java Swing/Java2D 环境下实现一个既平滑又可交互的饼图旋转,需要深入掌握以下难点:

  • 角度映射:将时间或帧数映射到旋转角度,并与饼图扇区正确对齐。

  • 绘制顺序:在旋转过程中,正确处理扇区的绘制顺序,避免前后扇区遮挡错乱。

  • 动画驱动:使用 javax.swing.Timer 或高精度定时器控制旋转流畅度。

  • 交互响应:支持暂停/继续、方向切换、速率调节及拖拽控制。

1.2 目标

本文将从零开始,手把手实现一个 Java2D Swing 版可旋转饼图组件,重点在于:

  1. 自动旋转:按设定速率平滑且连续地旋转。

  2. 角度控制:可随时获取与设置当前旋转角度,实现“瞬时跳转”或动画过渡。

  3. 方向切换:顺时针或逆时针旋转可动态切换。

  4. 拖拽控制:鼠标拖拽实时控制饼图角度,打断/恢复自动旋转。

  5. 完整封装:提供易用 API,支持在任意 Swing 界面中嵌入。


二、相关技术与知识

要实现以上功能,需要掌握和理解以下技术要点。

2.1 Java2D 绘图基础

  • Graphics2D:Java2D 的核心渲染上下文,支持抗锯齿、变换、复合等。

  • 形状构造Arc2D 绘制扇形,Path2D 构造侧面形状。

  • 抗锯齿:通过 RenderingHint.KEY_ANTIALIASING 提升绘图质量。

  • 透明度:使用 AlphaComposite 控制半透明效果。

2.2 动画驱动

  • Swing Timerjavax.swing.Timer 在事件分发线程(EDT)触发周期性事件,安全刷图。

  • 帧率与速率:根据延迟(delay)和每分钟旋转度数(RPM)计算每帧增量角度 delta = rpm * 360° / (60_000ms / delay)

  • 平滑度:选择合适的 delay(例如 16ms≈60FPS 或 40ms≈25FPS)平衡流畅度与性能。

2.3 深度排序

虽然我们演示的是 2D 饼图,但若添加 3D 侧面阴影,则需要 深度排序:在每帧根据扇区当前中心角度的正余弦值判断其“前后”关系,先画远处扇区再画近处扇区,保证遮挡效果自然。

2.4 交互处理

  • 鼠标拖拽MouseListener + MouseMotionListener 捕获按下、拖拽、释放事件,实时映射拖动距离到角度偏移。

  • 暂停/恢复:拖拽开始时停止自动旋转,释放时可继续。

  • 方向切换与速率调节:通过暴露 API 允许调用者动态更改 rpmclockwise 标志。


三、实现思路

结合上述技术栈,我们将按以下思路实现:

  1. 数据模型

    • 定义内部 PieSlice 类:保存扇区 valuecolorlabelstartAnglearcAngle

    • totalValue 累加所有扇区数值。

    • computeAngles() 方法按比例分配角度。

  2. 组件封装

    • 继承 JPanel,命名为 RotatingPieChartPanel,暴露 API:

      • addSlice(value, color, label)

      • setRotateSpeed(rpm)

      • setClockwise(boolean)

      • start() / stop()

      • setAngle(double) / getAngle() 实现“瞬时跳转”。

  3. 动画与绘制

    • 在构造器中创建 Timer(animationDelay, e->{ advanceOffset(); repaint(); })

    • advanceOffset() 根据 rpmclockwise 计算 angleOffset

    • paintComponent() 中调用 drawPie(),分三步:阴影 → 侧面(需深度排序) → 顶面。

  4. 交互

    • 添加 MouseAdapter

      • mousePressed 开始拖拽,记录初始 angleOffset 与鼠标点;

      • mouseDragged 根据水平方向位移映射到增量角度,更新 angleOffsetrepaint()

      • mouseReleased 结束拖拽,重启动画。

  5. 深度排序

    • 在绘制侧面时,先复制扇区列表,按每个扇区 中心角度 的正弦值(或余弦值)排序;

    • depthKey = Math.sin(Math.toRadians(startAngle + arcAngle/2 + angleOffset)),值大者后绘制。


四、完整实现代码

import javax.swing.*;
import java.awt.*;
import java.awt.event.*;
import java.awt.geom.*;
import java.util.*;
import java.util.List;

/**
 * RotatingPieChartPanel:可自动/手动旋转且正确排序的饼图组件
 */
public class RotatingPieChartPanel extends JPanel {

    /** 内部扇区模型 */
    private static class PieSlice {
        double value;          // 扇区数值
        Color color;           // 扇区颜色
        String label;          // 扇区标签
        double startAngle;     // 起始角度(度)
        double arcAngle;       // 扇区角度(度)
        boolean highlighted;   // 是否高亮

        PieSlice(double value, Color color, String label) {
            this.value = value;
            this.color = color;
            this.label = label;
            this.highlighted = false;
        }
    }

    private final List<PieSlice> slices = new ArrayList<>();
    private double totalValue = 0.0;

    // 旋转控制
    private double angleOffset = 0.0;  // 当前偏移角度
    private double rpm = 1.0;          // 每分钟度数
    private boolean clockwise = true;  // 旋转方向
    private Timer animationTimer;      // 用于自动旋转

    // 3D 效果深度(像素)
    private double depth = 50.0;

    // 拖拽交互状态
    private boolean dragging = false;
    private double dragStartOffset;
    private Point dragStartPoint;

    public RotatingPieChartPanel() {
        setBackground(Color.WHITE);
        setPreferredSize(new Dimension(600, 400));
        initInteraction();
    }

    /** 初始化鼠标交互:拖拽控制 */
    private void initInteraction() {
        MouseAdapter ma = new MouseAdapter() {
            @Override
            public void mousePressed(MouseEvent e) {
                // 停止自动旋转,进入拖拽状态
                stop();
                dragging = true;
                dragStartOffset = angleOffset;
                dragStartPoint = e.getPoint();
            }

            @Override
            public void mouseDragged(MouseEvent e) {
                if (!dragging) return;
                Point pt = e.getPoint();
                double dx = pt.x - dragStartPoint.x;
                // 每像素对应 0.5 度
                angleOffset = dragStartOffset + dx * 0.5;
                repaint();
            }

            @Override
            public void mouseReleased(MouseEvent e) {
                dragging = false;
                start(); // 恢复自动旋转
            }
        };
        addMouseListener(ma);
        addMouseMotionListener(ma);
    }

    /** 添加扇区 */
    public void addSlice(double value, Color color, String label) {
        slices.add(new PieSlice(value, color, label));
        totalValue += value;
        computeAngles();
        repaint();
    }

    /** 重新计算扇区角度 */
    private void computeAngles() {
        double angle = 0.0;
        for (PieSlice s : slices) {
            s.startAngle = angle;
            s.arcAngle = s.value / totalValue * 360.0;
            angle += s.arcAngle;
        }
    }

    /** 设置旋转速率(RPM) */
    public void setRotateSpeed(double rpm) {
        this.rpm = rpm;
        if (animationTimer != null && animationTimer.isRunning()) {
            stop();
            start();
        }
    }

    /** 设置旋转方向 */
    public void setClockwise(boolean cw) {
        this.clockwise = cw;
    }

    /** 设置 3D 深度 */
    public void setDepth(double depth) {
        this.depth = depth;
        repaint();
    }

    /** 启动自动旋转 */
    public void start() {
        if (animationTimer != null && animationTimer.isRunning()) return;
        int delay = 40; // 25 FPS
        double deltaDeg = rpm * 360.0 / (60_000.0 / delay);
        animationTimer = new Timer(delay, e -> {
            angleOffset += (clockwise ? -deltaDeg : deltaDeg);
            repaint();
        });
        animationTimer.start();
    }

    /** 停止自动旋转 */
    public void stop() {
        if (animationTimer != null) {
            animationTimer.stop();
            animationTimer = null;
        }
    }

    /** 获取当前角度 */
    public double getAngle() {
        return angleOffset;
    }

    /** 直接设置角度(瞬时跳转) */
    public void setAngle(double angle) {
        this.angleOffset = angle % 360.0;
        repaint();
    }

    @Override
    protected void paintComponent(Graphics g) {
        super.paintComponent(g);
        renderPie((Graphics2D) g);
    }

    /** 绘制饼图:阴影 → 侧面(深度排序) → 顶面 */
    private void renderPie(Graphics2D g2) {
        // 抗锯齿
        g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
                            RenderingHints.VALUE_ANTIALIAS_ON);

        int w = getWidth(), h = getHeight();
        double cx = w / 2.0, cy = h / 2.0 - depth / 2.0;
        double r = Math.min(w, h - depth) / 2.0 - 20.0;

        // 1. 绘制阴影
        drawShadow(g2, cx, cy, r);

        // 2. 深度排序并绘制侧面
        List<PieSlice> sorted = new ArrayList<>(slices);
        sorted.sort(Comparator.comparingDouble(this::depthKey));
        for (PieSlice s : sorted) {
            drawSide(g2, cx, cy, r, s);
        }

        // 3. 绘制顶面
        for (PieSlice s : sorted) {
            drawTop(g2, cx, cy, r, s);
        }
    }

    /** 计算深度排序 key:扇区中心角度的 sin 值 */
    private double depthKey(PieSlice s) {
        double mid = s.startAngle + s.arcAngle / 2.0 + angleOffset;
        return Math.sin(Math.toRadians(mid));
    }

    /** 绘制底部阴影 */
    private void drawShadow(Graphics2D g2,
                            double cx, double cy, double r) {
        Ellipse2D shadow = new Ellipse2D.Double(
            cx - r, cy + depth - r / 3.0 * 2, 2 * r, r / 2.0
        );
        Composite old = g2.getComposite();
        g2.setComposite(AlphaComposite.getInstance(
            AlphaComposite.SRC_OVER, 0.3f
        ));
        g2.setColor(Color.BLACK);
        g2.fill(shadow);
        g2.setComposite(old);
    }

    /** 绘制扇区侧面 */
    private void drawSide(Graphics2D g2,
                          double cx, double cy, double r, PieSlice s) {
        double sa = Math.toRadians(s.startAngle + angleOffset);
        double ea = Math.toRadians(s.startAngle + s.arcAngle + angleOffset);

        Point2D p1 = new Point2D.Double(
            cx + r * Math.cos(sa), cy + r * Math.sin(sa)
        );
        Point2D p2 = new Point2D.Double(
            cx + r * Math.cos(ea), cy + r * Math.sin(ea)
        );
        Point2D p3 = new Point2D.Double(p2.getX(), p2.getY() + depth);
        Point2D p4 = new Point2D.Double(p1.getX(), p1.getY() + depth);

        Path2D side = new Path2D.Double();
        side.moveTo(p1.getX(), p1.getY());
        side.lineTo(p4.getX(), p4.getY());
        side.lineTo(p3.getX(), p3.getY());
        side.lineTo(p2.getX(), p2.getY());
        side.closePath();

        g2.setColor(s.color.darker());
        g2.fill(side);
        if (s.highlighted) {
            g2.setColor(Color.WHITE);
            g2.setStroke(new BasicStroke(2));
            g2.draw(side);
        }
    }

    /** 绘制扇区顶面 */
    private void drawTop(Graphics2D g2,
                         double cx, double cy, double r, PieSlice s) {
        Arc2D top = new Arc2D.Double(
            cx - r, cy - r, 2 * r, 2 * r,
            s.startAngle + angleOffset,
            s.arcAngle, Arc2D.PIE
        );
        g2.setColor(s.color);
        g2.fill(top);
        if (s.highlighted) {
            g2.setColor(Color.WHITE);
            g2.setStroke(new BasicStroke(2));
            g2.draw(top);
        }
    }

    // 可扩展:添加高亮与提示功能
}

/**
 * DemoMain:演示 RotatingPieChartPanel 用法
 */
class DemoMain {
    public static void main(String[] args) {
        SwingUtilities.invokeLater(() -> {
            RotatingPieChartPanel pie = new RotatingPieChartPanel();
            pie.addSlice(30, Color.RED,   "红");
            pie.addSlice(20, Color.BLUE,  "蓝");
            pie.addSlice(40, Color.GREEN, "绿");
            pie.addSlice(10, Color.ORANGE,"橙");
            pie.setDepth(60);
            pie.setRotateSpeed(2.5);
            pie.setClockwise(false);
            pie.start();

            JFrame f = new JFrame("可旋转饼图示例");
            f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
            f.add(pie);
            f.pack();
            f.setLocationRelativeTo(null);
            f.setVisible(true);
        });
    }
}

五、方法级功能解读

  1. addSlice(value, color, label)

    • 创建 PieSlice 对象,累加 totalValue,调用 computeAngles() 重新计算所有扇区的角度分布。

  2. computeAngles()

    • 遍历 slices 列表,按比例 (value / totalValue) * 360° 分配各扇区 arcAngle,并依次累加 startAngle

  3. start() / stop()

    • 使用 javax.swing.Timerdelay = 40ms,每次 actionPerformed 中计算增量

double deltaDeg = rpm * 360.0 / (60_000.0 / delay);
angleOffset += clockwise ? -deltaDeg : deltaDeg;

 

    • 调用 repaint() 刷新组件。

  1. paintComponent(...)

    • super.paintComponent(g) 清除背景,然后调用 renderPie(g2)

      • 启用抗锯齿

      • 计算中心 (cx,cy) 与半径 r

      • 调用 drawShadowdrawSide(深度排序)和 drawTop

  2. depthKey(PieSlice s)

    • 计算扇区中心角度:s.startAngle + s.arcAngle/2 + angleOffset

    • 取正弦值作为深度排序依据(越大越“前”),并对列表排序,保证先画“后面”的侧面,再画“前面”的侧面与顶面。

  3. drawShadow

    • 底部绘制半透明黑色椭圆,使用 AlphaComposite 设为 0.3f。

  4. drawSide

    • 计算扇区边缘两点 (p1, p2),并向下延伸 depth 得到底部两点 (p4, p3)

    • 构造 Path2D 四边形填充较暗颜色;

  5. drawTop

    • 使用 Arc2D.PIE 绘制扇形顶面;

  6. 拖拽交互

    • mousePressed 中停止自动旋转并记录初始状态;

    • mouseDragged 根据水平位移映射到增量角度更新 angleOffset

    • mouseReleased 中恢复自动旋转。


六、项目总结与扩展思考

6.1 核心收获

  • 深入理解 Java2D 在复杂动态图形中的应用技巧;

  • 掌握 旋转动画帧率控制 的实现;

  • 学会使用 深度排序 解决旋转遮挡问题;

  • 熟悉 拖拽交互 在图形组件中的集成。

6.2 性能优化建议

  1. Shape 缓存:对每个扇区在固定角度步长下预生成 Path2DArc2D,避免每帧大量对象创建。

  2. 离屏缓冲:使用 BufferedImageVolatileImage 离屏渲染静态部分(阴影、侧面基础形状),只动态绘制旋转部分。

  3. OpenGL 加速:设置系统属性 -Dsun.java2d.opengl=true 启用硬件加速。

6.3 扩展功能

  • 渐变与纹理:为扇面添加渐变填充或贴图。

  • 多层饼图/环形图:支持环形(Donut)或嵌套饼图。

  • 标签与引导线:在旋转中动态显示标签,引导线可选显示。

  • JavaFX 版本:基于 JavaFX Canvas 或 3D API 实现更高性能和光照效果。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值