用数学思维实现雷达分析图

前言

前段时间回看里约奥运会的国球比赛,岛国媒体给我龙队一个响亮的称号—— 六边形战士

用数学思维实现雷达分析图_第1张图片

马龙是我的偶像,看到这样的称号当然很骄傲。

分析图片可以知道:六个定点分别标识个技术点名称,对应 半径 所填充长度表示分值,龙队在各方面的分数都是满分,所以在雷达分析图上覆盖区全部填充。

作为程序员的我,不免要从技术实现的角度思考问题,接下来我们一起造轮子:

先上效果图:

用数学思维实现雷达分析图_第2张图片

设计思路

用数学思维实现雷达分析图_第3张图片

回顾两个知识点:

  • 在一平面中,确定一坐标原点(0,0),水平向右为x轴正方向,竖直向上为y轴正方向,从右上方开始顺时针依次为第一象限、第二象限、第三象限和第四象限。
  • 平面中的任一点的坐标应该是:其与原点所在直线的倾斜角的余弦为x,正弦值为y。

从效果图来看,我们应该把view区域按照数学中的平面坐标来区分,雷达图中心点(外接圆圆心)为坐标原点,水平向右的半径为x轴正方向,竖直向上的半径为y轴正方向,从右上方开始顺时针依次为第一象限、第二象限、第三象限和第四象限。

大致的思路是这样的,我们需要自定义属性,自定义view:重写构造、onDraw方法,这些都是必须的。我们可以在onSizeChanged方法中进行计算和确认各组成内容的位置与大小。在计算好大小和位置后,在onDraw中进行绘制。

关键的是:我们的目标是将各数据绘制在各半径上,最后链接起来构成完整区域,那就需要将各半径上所有点计算出,找到对应数据对应的点的坐标,然后绘制。


1、自定义View

A、定义属性:

    
    <declare-styleable name="RadarChart">
        
        <attr name="radarLineWidth" format="dimension"/>
        
        <attr name="radarLineColor" format="color"/>
        
        <attr name="radarLineSegments" format="integer"/>
        
        <attr name="radarTextColor" format="color"/>
        
        <attr name="radarTextSize" format="integer"/>
        
        <attr name="radarCoverColor" format="color"/>
    declare-styleable>

主要就是一些线条颜色、字体颜色、大小等属性。

B、继承View,声明各属性

/**
 * Description: 雷达表
 * Created by jia on 2017/10/18.
 * 人之所以能,是相信能
 */
public class JsRadarChart extends View {

    private static final String TAG = "JsRadarChart";

    /**
     * 雷达边数 默认6
     */
    private int mPieceNumber = 6;

    /**
     * 外接圆半径
     */
    private int mRadius = 50;

    /**
     * 线条宽度 默认10
     */
    private int mLineWidth = 8;

    /**
     * 线条颜色 默认灰色
     */
    private int mLineColor = 0xffd0d6dc;

    /**
     * 半径分为4段
     */
    private int mLineSegments = 4;

    /**
     * 字体颜色和字体大小
     */
    private int mTextColor = 0xff647d91;
    private int mTextSize = 10;

    /**
     * 覆盖物颜色
     */
    private int mCoverColor = 0x55ced657;

    /**
     * 中心位置
     */
    private int mPositionX = 0;
    private int mPositionY = 0;

    /**
     * 多边形 边的角度
     */
    private double mAverageAngle = 0;

C、定义画笔和数据集合

注意:覆盖物区域我们使用Path实现。

    /**
     * 三只画笔
     */
    private Paint mRadarPaint;
    private TextPaint mTextPaint;
    private Paint mCoverPaint;

    private Path mCoverPath;

    /**
     * 所有边对应的点的集合,每一个item是每条边上所有的结点的集合
     */
    List mRadarPointses = new ArrayList<>();
    /**
     * 实体类集合
     */
    List mRadarEntries = new ArrayList<>();
    /**
     * 覆盖物的顶点的集合
     */
    List mCoverPoints = new ArrayList<>();
    /**
     * 文字对应点的集合
     */
    List mTextPoints = new ArrayList<>();

其中,创建了两个类:RadarEntry是数据实体类,RadarPoints 是每条半径上的所有点,其中用List存放各店,index表识哪条半径。

    /**
     * 雷达图数据载体
     */
    public static class RadarEntry {
        private String title;
        private double level;

        public RadarEntry(String title, double level) {
            this.title = title;
            this.level = level;
        }
    }

    /**
     * 每一条线上的所有点集合
     */
    class RadarPoints {
        int lineIndex;
        List mPointFs;

        public RadarPoints(int lineIndex, List pointFs) {
            this.lineIndex = lineIndex;
            mPointFs = pointFs;
        }

        public int getLineIndex() {
            return lineIndex;
        }

        public void setLineIndex(int lineIndex) {
            this.lineIndex = lineIndex;
        }

        public List getPointFs() {
            return mPointFs;
        }

        public void setPointFs(List pointFs) {
            mPointFs = pointFs;
        }
    }

D、重写构造方法

    public JsRadarChart(Context context) {
        this(context, null);
    }

    public JsRadarChart(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public JsRadarChart(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);

        // 获取属性类型集
        TypedArray attributes = getContext().obtainStyledAttributes(attrs, R.styleable.RadarChart);

        mLineWidth = (int) attributes.getDimension(R.styleable.RadarChart_radarLineWidth, 5);
        mLineColor = attributes.getColor(R.styleable.RadarChart_radarLineColor, 0xffd0d6dc);
        mLineSegments = attributes.getInteger(R.styleable.RadarChart_radarLineSegments, 4);
        mTextColor = attributes.getColor(R.styleable.RadarChart_radarTextColor, 0xff647d91);
        mTextSize = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP,
                attributes.getInteger(R.styleable.RadarChart_radarTextSize, 10), getResources().getDisplayMetrics());
        mCoverColor = attributes.getColor(R.styleable.RadarChart_radarCoverColor, 0x55ced6dc);

        init();
    }

显而易见得:在构造方法中获取来自xml文件中的各属性值。

E、初始化

将各画笔根据设置的属性进行初始化。

    private void init() {

        // 蜘蛛网 画笔初始化
        mRadarPaint = new Paint();
        mRadarPaint.setColor(mLineColor);
        mRadarPaint.setStrokeWidth(mLineWidth);
        mRadarPaint.setAntiAlias(true);
        mRadarPaint.setStyle(Paint.Style.STROKE);

        // 文字绘制 画笔初始化
        mTextPaint = new TextPaint();
        mTextPaint.setColor(mTextColor);
        mTextPaint.setTextSize(mTextSize);
        mTextPaint.setAntiAlias(true);
        mTextPaint.setStyle(Paint.Style.STROKE);

        // 覆盖物 画笔初始化
        mCoverPaint = new Paint();
        mCoverPaint.setColor(mCoverColor);
        mCoverPaint.setAntiAlias(true);
        mCoverPaint.setStyle(Paint.Style.FILL);

        mCoverPath = new Path();
    }

2、在onSizeChanged中计算各组成部分位置和大小

A、中心点位置、每部分对应圆形角

// 计算中心点位置
mPositionX = w / 2;
mPositionY = h / 2;

// 计算角度
mAverageAngle = 360.0 / mPieceNumber;

B、计算文字所占大小,进而得出半径大小

// 计算出文字所占大小
int textSizeMax = 0;
for (RadarEntry entry : mRadarEntries) {
    Rect textRect = new Rect();
    mTextPaint.getTextBounds(entry.title, 0, entry.title.length(), textRect);
    textSizeMax = Math.max(textRect.width(), textSizeMax);
}

// 出去文字区域,算出最合适半径
mRadius = Math.min(mPositionX - textSizeMax, mPositionY);

if (mRadarEntries == null || mRadarEntries.size() == 0) {
    throw new NullPointerException("请先设置数据集");
}

因为文字和各半径处在同一条线上,而view创建后,每条线的长度就已经确定,那我们就需要将文字计算出大小,去除就是雷达半径的最佳长度。

在计算文字大小时,应该使用Rect和Paint结合计算得出。

循环各文字大小,找到最大的值,用图形半径减去最大值,就是雷达半径的最佳长度。

C、创建根据百分比计算位置的工具方法

    public float getPloygonX(double angle, double percent) {
        return Float.parseFloat(
                String.valueOf(
                        mPositionX + Math.cos(angle / 360.0 * 2 * Math.PI) * mRadius * percent));
    }

    public float getPloygonY(double angle, double percent) {
        return Float.parseFloat(String.valueOf(
                mPositionY + Math.sin(angle / 360.0 * 2 * Math.PI) * mRadius * percent));
    }

因为直角三角形一个角的邻边,等于直角边*该角的余弦值。

所以,横坐标x的值,应该是对应半径*角度的余弦值乘以百分比,当然,此图中的原点实际为外接圆的圆心,并不在屏幕的原点上,所以需要在+圆心的横坐标。

计算y值也是如此。

D、计算每条边上的结点位置,计算各数据对应点

        /**
         * 计算每一条轴线上的所有结点
         *      x轴正方向为第一条轴线,顺时针旋转
         */
        for (int i = 0; i < mPieceNumber; i++) {
            List pointFs = new ArrayList<>();
            for (int j = 0; j < mLineSegments; j++) {
                PointF point = new PointF();
                double percent = j * 1.0 / (mLineSegments - 1);
                point.set(getPloygonX(mAverageAngle * i, percent),
                        getPloygonY(mAverageAngle * i, percent));
                pointFs.add(point);
            }
            RadarPoints radarPoints = new RadarPoints(i, pointFs);
            mRadarPointses.add(radarPoints);
        }

        /**
         * 根据数据集计算覆盖多变形的点
         */
        for (int m = 0; m < mPieceNumber; m++) {
            PointF pointF = new PointF();
            double percent = mRadarEntries.get(m).level / 100.0;
            pointF.set(getPloygonX(mAverageAngle * m, percent),
                    getPloygonY(mAverageAngle * m, percent));
            mCoverPoints.add(pointF);
        }

以上就用到了刚刚创建的根据百分比获取坐标的方法。

注意:x轴正方向为第一条轴线,顺时针旋转。

其实我们只是将每条半径上的所有节点的坐标进行了计算。

E、计算文字的位置

        /**
         * 设置文字显示位置
         */
        for (int m = 0; m < mPieceNumber; m++) {
            PointF pointF = new PointF();
            String title = mRadarEntries.get(m).title;
            Rect textBound = new Rect();
            mTextPaint.getTextBounds(title, 0, title.length(),
                    textBound);
            // 每条边最后一个点的位置,设置文字
            float boundx = mRadarPointses.get(m).getPointFs().get(mLineSegments - 1).x;
            float boundy = mRadarPointses.get(m).getPointFs().get(mLineSegments - 1).y;

            if (boundx > mRadius && boundy <= mRadius) {
                pointF.set(getPloygonX(mAverageAngle * m, 1),
                        getPloygonY(mAverageAngle * m, 1) - textBound.height() * 2);
            } else if (boundx <= mRadius && boundy <= mRadius) {
                pointF.set(getPloygonX(mAverageAngle * m, 1) - textBound.width(),
                        getPloygonY(mAverageAngle * m, 1) - textBound.height() * 2);
            } else if (boundx <= mRadius && boundy > mRadius) {
                pointF.set(getPloygonX(mAverageAngle * m, 1) - textBound.width(),
                        getPloygonY(mAverageAngle * m, 1));
            } else {
                pointF.set(getPloygonX(mAverageAngle * m, 1),
                        getPloygonY(mAverageAngle * m, 1));
            }
            mTextPoints.add(pointF);
        }

此步骤是将文字大小计算并设置到对应List中。

我们将文字位置分了四类:类似于数学中的四各象限,不同象限的位置应该分别处理。

到这里,我们的所有计算相关的操作就做完了,接下来开始绘制。


3、将各半径、边框、文字、覆盖物进行绘制

一下步骤都在onDraw方法中执行。

A、绘制中心点、根据各节点绘制环形网

        /**
         * 绘制中心点
         */
        canvas.drawPoint(mPositionX, mPositionY, mRadarPaint);

        /**
         * 绘制蜘蛛网
         */
        for (int i = 0; i < mLineSegments; i++) {
            for (int j = 0; j < mPieceNumber - 1; j++) {
                canvas.drawLine(mRadarPointses.get(j).getPointFs().get(i).x, mRadarPointses.get(
                        j).getPointFs().get(i).y,
                        mRadarPointses.get(j + 1).getPointFs().get(i).x, mRadarPointses.get(
                                j + 1).getPointFs().get(i).y, mRadarPaint);
            }

            canvas.drawLine(mRadarPointses.get(mPieceNumber - 1).getPointFs().get(i).x,
                    mRadarPointses.get(mPieceNumber - 1).getPointFs().get(i).y,
                    mRadarPointses.get(0).getPointFs().get(i).x, mRadarPointses.get(
                            0).getPointFs().get(i).y, mRadarPaint);
        }

因为各节点我们已经计算得出,并且全部设置在对应List中,所以直接循环进行绘制各点,并将各点进行连接,就可以得到环形网。

B、当然还有半径

        /**
         * 绘制轴线
         */
        for (int k = 0; k < mPieceNumber; k++) {
            canvas.drawLine(mRadarPointses.get(k).getPointFs().get(0).x,
                    mRadarPointses.get(k).getPointFs().get(0).y,
                    mRadarPointses.get(k).getPointFs().get(mLineSegments - 1).x, mRadarPointses.get(
                            k).getPointFs().get(mLineSegments - 1).y, mRadarPaint);

        }

也是将各点连接起来就可以。

C、将覆盖物绘出

        /**
         * 绘制数据
         */
        if (mCoverPoints != null && mCoverPoints.size() == mPieceNumber) {
            mCoverPath.reset();
            mCoverPath.moveTo(mCoverPoints.get(0).x, mCoverPoints.get(0).y);
            for (int i = 1; i < mPieceNumber; i++) {
                mCoverPath.lineTo(mCoverPoints.get(i).x, mCoverPoints.get(i).y);
            }
            mCoverPath.close();
            canvas.drawPath(mCoverPath, mCoverPaint);
        } else {
            throw new NullPointerException("请先设置数据集");
        }

将各半径上的数据对应的点全部循环连接,但要注意,循环结束后,并没有将最后一个点和第一个点连接起来,所以需要在特意将它俩连接。完成后填充闭合图形。

到此覆盖物便画好了。

D、绘制文字

        /**
         * 绘制文字,使用StaticLayout进行换行文字的绘制
         */
        for (int i = 0; i < mPieceNumber; i++) {
            canvas.save();
            String str = mRadarEntries.get(i).title + "\n" +
                    Math.floor(mRadarEntries.get(i).level * 10) / 10;
            StaticLayout layout = new StaticLayout(str, mTextPaint, 300,
                    Layout.Alignment.ALIGN_NORMAL, 1.0F, 0.0F, true);
            canvas.translate(mTextPoints.get(i).x, mTextPoints.get(i).y);
            layout.draw(canvas);
            canvas.restore();
        }

根据计算得出的文字位置进行绘制,不再累赘。

到此,所以的绘制全部结束!


4、暴露设置数据的方法

    /**
     * 设置数据集,数据集的index决定位置,顺时针方向,起始角度为0度
     */
    public void setRadatEntries(List entries) {
        this.mRadarEntries = entries;
        mPieceNumber = entries.size();
        postInvalidate();
    }

注意要调用postInvalidate方法进行刷新。


5、总结

怎么样,有木有很简单,有木有感觉把中学的数学再一次拾起。哈哈。

大家的支持就是我前进的动力!

大家还可以关注我的微信公众号获取更多精彩内容。

用数学思维实现雷达分析图_第4张图片

你可能感兴趣的:(用数学思维实现雷达分析图)