雷达图

新建Scene命名为RadarChart,在Scripts文件夹内新建同名文件夹,并在其中新建RadarChart脚本。

生成背景标记点

每一个背景标记点都是一个GameObject,我们将这些点称为Point,修改RadarChart脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class RadarChart : Image
{
[SerializeField] private int mPointCount;
[SerializeField] private List<RectTransform> mPoints;

public void InitPoint()
{
ClearPoints();
mPoints = new List<RectTransform>();
SpawnPoints();
SetPointPos();
}
public void InitHandlers()
{

}
private void ClearPoints()
{
if (mPoints != null)
{
foreach (var point in mPoints)
{
if(point != null)
{
DestroyImmediate(point.gameObject);
}
}
}
}
private void SpawnPoints()
{
for (int i = 0; i < mPointCount; i++)
{
GameObject point = new GameObject("Point" + i);
var rect = point.AddComponent<RectTransform>();
point.transform.SetParent(transform);
mPoints.Add(rect);
}
}

private void SetPointPos()
{
//雷达图单位弧长
float radian = 2 * Mathf.PI / mPointCount;
//雷达图半径
float radius = 100;
//雷达图顶点的起点在正上方,也就是弧度在Π/2的位置
float curRadian = 2 * Mathf.PI / 4.0f;
for(int i = 0;i < mPointCount;i++)
{
//计算当前点的坐标,Mathf使用弧度计算
float x = Mathf.Cos(curRadian) * radius;
float y = Mathf.Sin(curRadian) * radius;
curRadian += radian;
mPoints[i].anchoredPosition = new Vector2(x, y);
}
}

}

生成可拖拽点

每一个可拖拽点都是一个GameObject,我们将这些点称为Handler,修改RadarChart脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
public class RadarChart : Image
{
[SerializeField] private int mPointCount;
[SerializeField] private List<RectTransform> mPoints;
[SerializeField] private Sprite mPointSprite;//+++
[SerializeField] private Vector2 mPointSize = new Vector2(10,10);//+++
[SerializeField] private Color mPointColor = Color.white;//+++
[SerializeField] private float[] mHandlerRatio;//Handler所占的比例
[SerializeField] private List<RadarChartHandler> mChartHandlers;//+++

public void InitHandlers()
{
ClearHandlers();
mChartHandlers = new List<RadarChartHandler>();
SpawnHandlers();
SetHandlerPos();
}
//...
private void ClearHandlers()
{
if (mChartHandlers != null)
{
foreach (var handler in mChartHandlers)
{
if (handler != null)
{
DestroyImmediate (handler.gameObject);
}
}
}
}

private void SpawnHandlers()
{
RadarChartHandler chartHandler;
for (int i = 0; i < mPointCount; i++)
{
GameObject handler = new GameObject("Handler" + i);
handler.AddComponent<RectTransform>();
handler.AddComponent<Image>();
chartHandler = handler.AddComponent<RadarChartHandler>();
chartHandler.SetParent(transform);
chartHandler.ChangeSprite(mPointSprite);
chartHandler.ChangeColor(mPointColor);
chartHandler.SetSize(mPointSize);

mChartHandlers.Add(chartHandler);
}
}
private void SetHandlerPos()
{
//如果当前Handler没有指定起始位置的比例,那么默认情况下和背景标记点的位置相同
if(mHandlerRatio == null || mHandlerRatio.Length != mPointCount)
{
for(int i = 0; i < mPointCount;i++)
{
mChartHandlers[i].SetPos(mPoints[i].anchoredPosition);
}
}
else
{
//如果当前Handler指定了起始位置的比例,那么直接和背景标记点的位置相乘就是Handler的位置
//因为Handler和Points都是RadarChart的子物体,它们的中心点都是(0,0)
for (int i = 0; i < mPointCount; i++)
{
mChartHandlers[i].SetPos(mPoints[i].anchoredPosition * mHandlerRatio[i]);
}
}
}
}

在RadarChart文件夹内新建RadarChartHandler脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
using UnityEngine;
using UnityEngine.UI;

public class RadarChartHandler : MonoBehaviour
{
private Image mImage;
public Image Image
{
get
{
if (mImage == null)
mImage = GetComponent<Image>();
return mImage;
}
}
private RectTransform mRectTransform;
public RectTransform Rect
{
get
{
if(mRectTransform == null)
mRectTransform = GetComponent<RectTransform>();
return mRectTransform;
}
}
public void SetParent(Transform parent)
{
transform.SetParent(parent);
}
public void ChangeSprite(Sprite sprite)
{
Image.sprite = sprite;
}
public void ChangeColor(Color color)
{
Image.color = color;
}
public void SetPos(Vector2 pos)
{
Rect.anchoredPosition = pos;
}
public void SetSize(Vector2 size)
{
Rect.sizeDelta = size;
}
}

编辑器代码

在RadarChart文件夹内新建Editor文件夹,并在其中新建RadarChartEditor脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
using UnityEditor;
using UnityEngine;

[CustomEditor(typeof(RadarChart),true)]
[CanEditMultipleObjects]
public class RadarChartEditor : UnityEditor.UI.ImageEditor
{
SerializedProperty mPointCount;
SerializedProperty mPointSprite;
SerializedProperty mPointColor;
SerializedProperty mPointSize;
SerializedProperty mHandlerRatio;

protected override void OnEnable()
{
base.OnEnable();
mPointCount = serializedObject.FindProperty("mPointCount");
mPointSprite = serializedObject.FindProperty("mPointSprite");
mPointColor = serializedObject.FindProperty("mPointColor");
mPointSize = serializedObject.FindProperty("mPointSize");
mHandlerRatio = serializedObject.FindProperty("mHandlerRatio");
}
public override void OnInspectorGUI()
{
base.OnInspectorGUI();
serializedObject.Update();
EditorGUILayout.PropertyField(mPointCount,new GUIContent("顶点个数"));
EditorGUILayout.PropertyField(mPointSize,new GUIContent("顶点大小"));
EditorGUILayout.PropertyField(mPointSprite,new GUIContent("顶点图片"));
EditorGUILayout.PropertyField(mPointColor,new GUIContent("顶点颜色"));
EditorGUILayout.PropertyField(mHandlerRatio,new GUIContent("顶点比例"),true);

RadarChart chart = (RadarChart)target;
if (chart != null)
{
if (GUILayout.Button("生成雷达图顶点"))
{
chart.InitPoint();
}
if (GUILayout.Button("生成内部可操作顶点"))
{
chart.InitHandlers();
}
}
serializedObject.ApplyModifiedProperties();
}
}

使用方法

我们在Canvas下新建一个子对象,命名为RadarChart。

给RadarChart挂载RadarChart脚本,修改脚本的“顶点个数”,然后点击脚本的“生成雷达图顶点”按钮,此时RadarChart就会生成对应个数的子对象,我们可以随意移动这些命名为“Point”的子对象。这些子对象anchorPoint就是可操作顶点生成的位置。可以随时点击“生成雷达图顶点”按钮来重新生成这些点。

然后点击“生成内部可操作顶点”的按钮,这些按钮的样子可以在“顶点颜色”、“顶点图片”和“顶点大小”中设置。这些可操作顶点的位置和Point的中心重合。可以随时点击“生成内部可操作顶点”按钮来重新生成这些点。

生成Mesh

修改RadarChart脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
protected override void OnPopulateMesh(VertexHelper toFill)
{
toFill.Clear();
AddVerts(toFill);
AddTriangle(toFill);
}
private void AddVerts(VertexHelper toFill)
{
for (int i = 0; i < mChartHandlers.Count; i++)
{
toFill.AddVert(mChartHandlers[i].transform.localPosition, color, Vector4.zero);
}
}
private void AddTriangle(VertexHelper toFill)
{
for (int i = 1; i < mPointCount - 1; i++)//五边形只需要三个三角形就可以绘制,依次类推
{
toFill.AddTriangle(0,i,i+1);//逆时针
}
}

注意,这里把uv直接设置成了Vector4.zero,如果需要贴图的话需要参考CircleImage脚本来设置uv。

注意,这里说的“五边形只需要三个三角形就可以绘制”是以凸多边形作为讨论的,因为雷达图其实可以是凹多边形,所以这里的三角形绘制方式仅供参考。后面会修改绘制方式。

一般情况下,Unity的三角形顺时针是正面,逆时针是背面,这里的三角形顶点顺序是逆时针的,但是依然可以绘制,因为UnityUI的默认shader是关闭了剔除的:Cull Off

如果想要顺时针,修改成toFill.AddTriangle(0,i+1,i);

移动Handler来重绘Mesh

每次我们修改RadarChart组件的Inspector面板时,OnPopulateMesh都会更新,但是我们如果在Scene面板拖动Handler,OnPopulateMesh是不会更新的。此时我们在RadarChart脚本中添加如下的方法:

1
2
3
4
private void Update()
{
SetVerticesDirty();
}

这个方法的意思是每一帧都给Image的顶点设置脏标记,这个标记意味着此时顶点是可以更新并移动的。这样我们不需要每次都修改Inspector面板,也能控制顶点的位置了。

此时我们在Scene窗口中移动Handler,顶点也会跟着移动了。

运行时移动Handler来重绘Mesh

修改RadarChartHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
using UnityEngine.EventSystems;
using UnityEngine.UI;

public class RadarChartHandler : MonoBehaviour,IDragHandler
{
//...
public void OnDrag(PointerEventData eventData)
{
Rect.anchoredPosition += eventData.delta / GetScale();
}
private float GetScale()
{
return Rect.lossyScale.x;
}
}

如果我们的RadarChart组件经过父级层层放大,我们直接调用Rect.anchoredPosition += eventData.delta会让移动效果放大响应的倍数。所以需要除Rect.lossyScale.x,lossyScale记录的是层层放大后倍数的总量,比如RadarChart的父级放大倍数是2倍,父级的父级放大倍数是4倍,那么此时的lossyScale就是8.

关于uv

由于雷达图多种多样,每个顶点的uv坐标需要我们单独指定。

这里我们假设生成的是一张5边形雷达图,我们给RadarChart添加新的AddVertsTemplate方法

1
2
3
4
5
6
7
8
private void AddVertsTemplate(VertexHelper vh)
{
vh.AddVert(mChartHandlers[0].transform.localPosition, color, new Vector2(0.5f,1f));
vh.AddVert(mChartHandlers[1].transform.localPosition, color, new Vector2(0f,1f));
vh.AddVert(mChartHandlers[2].transform.localPosition, color, new Vector2(0f,0f));
vh.AddVert(mChartHandlers[3].transform.localPosition, color, new Vector2(1f,0f));
vh.AddVert(mChartHandlers[4].transform.localPosition, color, new Vector2(1f,1f));
}

假设我们把一张方形贴图贴在五边形内,我们把每一个顶点的uv一个一个加进去,这样就能得到一张拉伸的图片

修改Bug

修改上面生成Mesh时,如果是凹多边形生成的面会重合的Bug。

我们可以想办法防止生成重合的面,但是之前的绘制方式是无法绘制凹多边形的。

image-20231003162306929

如上图所示,如果想防止生成重合的面,我们先计算出0——2所在的直线,然后判断和顶点1相同y坐标的在直线0——2上的点,也就是m点,m点的x坐标比顶点1的x坐标大,那么就绘制这个三角面,否则不绘制。

这个原理和之前CircleImage判断点击位置的原理相同。但是这样只能防止生成重合的面,不能绘制出凹多边形。

我们直接在多边形中央添加一个虚拟顶点来解决这个问题。

修改RadarChart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private void AddVerts(VertexHelper toFill)
{
toFill.AddVert(Vector3.zero, color, Vector4.zero);//添加中心点
for (int i = 0; i < mChartHandlers.Count; i++)
{
toFill.AddVert(mChartHandlers[i].transform.localPosition, color, Vector4.zero);
}
}
private void AddTriangle(VertexHelper toFill)
{
for (int i = 1; i < mPointCount; i++)
{
toFill.AddTriangle(0,i+1,i);
}
toFill.AddTriangle(0, mPointCount, 1);//绘制最后一个面
}