1295 lines
61 KiB
C#
1295 lines
61 KiB
C#
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
using UnityEngine.UIElements;
|
|
using EasyChart;
|
|
|
|
namespace EasyChart.Layers
|
|
{
|
|
public class PieSeriesRenderer : BaseSeriesRenderer, IExclusiveTooltipRenderer, IChartInteractionStateConsumer
|
|
{
|
|
public const string OthersSliceId = "__ec_pie_others__";
|
|
|
|
public ISet<string> HiddenSliceIds { get; set; }
|
|
|
|
void IChartInteractionStateConsumer.SetInteractionState(ChartInteractionState state)
|
|
{
|
|
HiddenSliceIds = state != null ? state.HiddenPieSliceIds : null;
|
|
}
|
|
|
|
private struct LeaderLine
|
|
{
|
|
public Vector2 A;
|
|
public Vector2 B;
|
|
public Vector2 C;
|
|
public Color Color;
|
|
public float Width;
|
|
}
|
|
|
|
private readonly List<LeaderLine> _leaderLines = new List<LeaderLine>();
|
|
|
|
private struct PieSlice
|
|
{
|
|
public int SourceIndex;
|
|
public float Value;
|
|
public string Name;
|
|
public Color Color;
|
|
public bool IsOthers;
|
|
}
|
|
|
|
private readonly List<PieSlice> _tmpSlices = new List<PieSlice>(32);
|
|
|
|
// Simple palette for slices since we don't have per-point color yet
|
|
public static readonly Color[] Palette = new Color[]
|
|
{
|
|
new Color(0.2f, 0.6f, 1.0f), // Blue
|
|
new Color(1.0f, 0.4f, 0.4f), // Red
|
|
new Color(0.4f, 0.8f, 0.4f), // Green
|
|
new Color(1.0f, 0.8f, 0.2f), // Yellow
|
|
new Color(0.8f, 0.4f, 1.0f), // Purple
|
|
new Color(0.4f, 0.9f, 0.9f), // Cyan
|
|
new Color(1.0f, 0.6f, 0.2f), // Orange
|
|
new Color(0.6f, 0.6f, 0.6f) // Grey
|
|
};
|
|
|
|
// Private alias for internal use if needed, or just replace usages
|
|
private Color[] _palette => Palette;
|
|
|
|
private int? _hoverSliceIndex;
|
|
|
|
private static bool UsesExplodeDistance(PieExplodeType type)
|
|
{
|
|
return type == PieExplodeType.Translate || type == PieExplodeType.Pull;
|
|
}
|
|
|
|
private static Color GetHoverFillColor(Color baseColor)
|
|
{
|
|
Color c = Color.Lerp(baseColor, Color.white, 0.25f);
|
|
c.a = baseColor.a;
|
|
return c;
|
|
}
|
|
|
|
private static Color GetHoverStrokeColor(Color baseColor)
|
|
{
|
|
Color c = Color.Lerp(baseColor, Color.white, 0.6f);
|
|
c.a = Mathf.Clamp01(baseColor.a + 0.2f);
|
|
return c;
|
|
}
|
|
|
|
private static void StrokePieShape(Painter2D painter, Vector2 outerOrigin, Vector2 innerOrigin, float outerRadius, float innerRadius, float startAngleDeg, float endAngleDeg, bool clockwise)
|
|
{
|
|
if (outerRadius <= 0.001f) return;
|
|
|
|
float sweep = endAngleDeg - startAngleDeg;
|
|
if (Mathf.Abs(sweep) <= 0.001f) return;
|
|
|
|
bool isFullCircle = Mathf.Abs(Mathf.Abs(sweep) - 360f) <= 0.01f;
|
|
if (isFullCircle)
|
|
{
|
|
painter.BeginPath();
|
|
painter.Arc(outerOrigin, outerRadius, 0f, 360f);
|
|
painter.Stroke();
|
|
if (innerRadius > 0.001f)
|
|
{
|
|
painter.BeginPath();
|
|
painter.Arc(innerOrigin, innerRadius, 0f, 360f);
|
|
painter.Stroke();
|
|
}
|
|
return;
|
|
}
|
|
|
|
float startRad = startAngleDeg * Mathf.Deg2Rad;
|
|
float endRad = endAngleDeg * Mathf.Deg2Rad;
|
|
Vector2 outerStart = outerOrigin + new Vector2(Mathf.Cos(startRad), Mathf.Sin(startRad)) * outerRadius;
|
|
Vector2 innerEnd = innerOrigin + new Vector2(Mathf.Cos(endRad), Mathf.Sin(endRad)) * innerRadius;
|
|
Vector2 innerStart = innerOrigin + new Vector2(Mathf.Cos(startRad), Mathf.Sin(startRad)) * innerRadius;
|
|
|
|
int outerSegments = Mathf.Clamp(Mathf.CeilToInt(Mathf.Abs(sweep) / 8f), 2, 96);
|
|
int innerSegments = outerSegments;
|
|
|
|
painter.BeginPath();
|
|
painter.MoveTo(outerStart);
|
|
LineArc(painter, outerOrigin, outerRadius, startAngleDeg, sweep, outerSegments);
|
|
|
|
if (innerRadius > 0.001f)
|
|
{
|
|
painter.LineTo(innerEnd);
|
|
LineArc(painter, innerOrigin, innerRadius, endAngleDeg, -sweep, innerSegments);
|
|
painter.LineTo(innerStart);
|
|
}
|
|
else
|
|
{
|
|
painter.LineTo(innerOrigin);
|
|
}
|
|
|
|
painter.ClosePath();
|
|
painter.Stroke();
|
|
}
|
|
|
|
private static float ResolveOuterRadius(float outerRadiusSetting, float maxAutoRadius)
|
|
{
|
|
if (maxAutoRadius <= 0f) return 0f;
|
|
if (outerRadiusSetting <= 0f) return maxAutoRadius;
|
|
if (outerRadiusSetting <= 1f) return Mathf.Clamp01(outerRadiusSetting) * maxAutoRadius;
|
|
return Mathf.Min(outerRadiusSetting, maxAutoRadius);
|
|
}
|
|
|
|
private static float ResolveInnerRadius(float innerRadiusSetting, float resolvedOuterRadius)
|
|
{
|
|
if (resolvedOuterRadius <= 0f) return 0f;
|
|
if (innerRadiusSetting <= 0f) return 0f;
|
|
|
|
float inner = innerRadiusSetting <= 1f
|
|
? Mathf.Clamp01(innerRadiusSetting) * resolvedOuterRadius
|
|
: innerRadiusSetting;
|
|
|
|
return Mathf.Clamp(inner, 0f, resolvedOuterRadius);
|
|
}
|
|
|
|
private static float ResolveRoundedCapRadius(float roundedCapSetting, float thickness)
|
|
{
|
|
if (thickness <= 0f) return 0f;
|
|
if (roundedCapSetting <= 0f) return 0f;
|
|
|
|
// roundedCap is always in pixels.
|
|
return Mathf.Clamp(roundedCapSetting, 0f, thickness * 0.5f);
|
|
}
|
|
|
|
private struct PieCandidate
|
|
{
|
|
public int SourceIndex;
|
|
public float Value;
|
|
public SeriesData Data;
|
|
}
|
|
|
|
private readonly List<PieCandidate> _tmpCandidates = new List<PieCandidate>(32);
|
|
|
|
private static readonly System.Comparison<PieCandidate> s_compareCandidateValueDesc = CompareCandidateValueDesc;
|
|
|
|
private static int CompareCandidateValueDesc(PieCandidate a, PieCandidate b)
|
|
{
|
|
return b.Value.CompareTo(a.Value);
|
|
}
|
|
|
|
private List<PieSlice> BuildSlices(Serie serie, PieSettings settings)
|
|
{
|
|
_tmpSlices.Clear();
|
|
var slices = _tmpSlices;
|
|
if (serie == null || serie.seriesData == null || serie.seriesData.Count == 0) return slices;
|
|
|
|
// For Pie coordinate system, skip axis labels fallback - only use SeriesData.name
|
|
bool isNoneCoord = Data != null && Data.CoordinateSystem == CoordinateSystemType.None;
|
|
List<string> labels = null;
|
|
if (!isNoneCoord && Data != null && Data.Axes != null)
|
|
{
|
|
AxisId preferredAxisId = Data.Cartesian != null ? Data.Cartesian.xAxisId : AxisId.XBottom;
|
|
for (int j = 0; j < Data.Axes.Count; j++)
|
|
{
|
|
var a = Data.Axes[j];
|
|
if (a != null && a.id == preferredAxisId && a.axisType == AxisType.Category)
|
|
{
|
|
labels = a.labels;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (labels == null)
|
|
{
|
|
for (int j = 0; j < Data.Axes.Count; j++)
|
|
{
|
|
var a = Data.Axes[j];
|
|
if (a != null && a.axisType == AxisType.Category)
|
|
{
|
|
labels = a.labels;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
_tmpCandidates.Clear();
|
|
var candidates = _tmpCandidates;
|
|
for (int i = 0; i < serie.seriesData.Count; i++)
|
|
{
|
|
var dp = serie.seriesData[i];
|
|
if (dp == null) continue;
|
|
if (dp.value <= 0) continue;
|
|
if (HiddenSliceIds != null)
|
|
{
|
|
string sliceId = !string.IsNullOrEmpty(dp.id) ? dp.id : FormatIntCached(i);
|
|
if (HiddenSliceIds.Contains(sliceId)) continue;
|
|
}
|
|
|
|
candidates.Add(new PieCandidate
|
|
{
|
|
SourceIndex = i,
|
|
Value = dp.value,
|
|
Data = dp
|
|
});
|
|
}
|
|
|
|
if (candidates.Count == 0) return slices;
|
|
|
|
bool doAgg = settings != null && settings.aggregation != null && settings.aggregation.enabled;
|
|
int keepTopN = doAgg ? Mathf.Max(0, settings.aggregation.keepTopN) : 0;
|
|
bool sortByValue = settings != null && settings.sortByValue;
|
|
|
|
if (sortByValue)
|
|
{
|
|
candidates.Sort(s_compareCandidateValueDesc);
|
|
}
|
|
|
|
float othersValue = 0f;
|
|
int limit = (doAgg && keepTopN > 0) ? Mathf.Min(keepTopN, candidates.Count) : candidates.Count;
|
|
|
|
for (int i = 0; i < candidates.Count; i++)
|
|
{
|
|
if (i < limit)
|
|
{
|
|
var c = candidates[i];
|
|
string name = null;
|
|
if (c.Data != null && !string.IsNullOrEmpty(c.Data.name)) name = c.Data.name;
|
|
else if (labels != null && c.SourceIndex < labels.Count) name = labels[c.SourceIndex];
|
|
if (string.IsNullOrEmpty(name)) name = $"Slice {c.SourceIndex}";
|
|
|
|
Color color = (c.Data != null && c.Data.useColor) ? c.Data.color : _palette[slices.Count % _palette.Length];
|
|
|
|
slices.Add(new PieSlice
|
|
{
|
|
SourceIndex = c.SourceIndex,
|
|
Value = c.Value,
|
|
Name = name,
|
|
Color = color,
|
|
IsOthers = false
|
|
});
|
|
}
|
|
else
|
|
{
|
|
othersValue += candidates[i].Value;
|
|
}
|
|
}
|
|
|
|
if (doAgg && keepTopN > 0 && candidates.Count > keepTopN && othersValue > 0f)
|
|
{
|
|
if (HiddenSliceIds != null && HiddenSliceIds.Contains(OthersSliceId))
|
|
{
|
|
return slices;
|
|
}
|
|
|
|
string name = settings != null && settings.aggregation != null && !string.IsNullOrEmpty(settings.aggregation.othersName)
|
|
? settings.aggregation.othersName
|
|
: "Others";
|
|
|
|
Color color = _palette[slices.Count % _palette.Length];
|
|
if (settings != null && settings.aggregation != null && settings.aggregation.useOthersColor) color = settings.aggregation.othersColor;
|
|
|
|
slices.Add(new PieSlice
|
|
{
|
|
SourceIndex = -1,
|
|
Value = othersValue,
|
|
Name = name,
|
|
Color = color,
|
|
IsOthers = true
|
|
});
|
|
}
|
|
|
|
return slices;
|
|
}
|
|
|
|
public override void ClearHover()
|
|
{
|
|
if (_hoverSliceIndex != null)
|
|
{
|
|
_hoverSliceIndex = null;
|
|
MarkDirtyRepaint();
|
|
}
|
|
}
|
|
|
|
protected override void OnGenerateVisualContent(MeshGenerationContext context)
|
|
{
|
|
if (Data == null || Data.Series == null) return;
|
|
|
|
float width = contentRect.width;
|
|
float height = contentRect.height;
|
|
if (width <= 0 || height <= 0) return;
|
|
|
|
var painter = context.painter2D;
|
|
|
|
// For now, render the first visible Pie series
|
|
foreach (var serie in Data.Series)
|
|
{
|
|
if (!serie.visible || serie.type != SerieType.Pie) continue;
|
|
if (serie.seriesData == null || serie.seriesData.Count == 0) continue;
|
|
|
|
// Handle Pie with PieSettings
|
|
if (!(serie.settings is PieSettings settings)) continue;
|
|
|
|
var layout = settings != null ? settings.layout : null;
|
|
|
|
float padding = layout != null && layout.plot != null ? layout.plot.padding : 0f;
|
|
float outerRadius = layout != null ? layout.outerRadius : 0f;
|
|
float innerRadius = layout != null ? layout.innerRadius : 0f;
|
|
|
|
float startAngleDeg = layout != null ? layout.startAngleDeg : -90f;
|
|
bool clockwise = layout != null ? layout.clockwise : true;
|
|
float angleRangeDeg = layout != null ? layout.angleRangeDeg : 360f;
|
|
|
|
Vector2 centerOffset = layout != null && layout.plot != null ? layout.plot.centerOffset : Vector2.zero;
|
|
bool hoverEnabled = settings != null && settings.hover != null && settings.hover.enabled;
|
|
PieExplodeType explodeType = (settings != null && settings.hover != null) ? settings.hover.explodeType : PieExplodeType.Translate;
|
|
float explodeDistance = hoverEnabled && UsesExplodeDistance(explodeType) ? Mathf.Max(0f, settings.hover.explodeDistance) : 0f;
|
|
|
|
// Reserve explode distance so hovered slice won't be clipped by chart bounds/padding.
|
|
float maxAutoRadius = Mathf.Max(0, Mathf.Min(width, height) * 0.5f - padding - explodeDistance);
|
|
float radius = ResolveOuterRadius(outerRadius, maxAutoRadius);
|
|
float innerRadiusPx = ResolveInnerRadius(innerRadius, radius);
|
|
Vector2 center = new Vector2(width / 2f, height / 2f) + new Vector2(centerOffset.x, -centerOffset.y);
|
|
|
|
DrawPieSerie(painter, serie, settings, center, radius, innerRadiusPx, startAngleDeg, clockwise, angleRangeDeg);
|
|
|
|
if (serie.labelSettings != null && serie.labelSettings.enabled && serie.labelSettings.position == LabelPosition.Outside && _leaderLines.Count > 0)
|
|
{
|
|
// Draw leader lines for outside labels.
|
|
painter.lineWidth = 1f;
|
|
for (int i = 0; i < _leaderLines.Count; i++)
|
|
{
|
|
var l = _leaderLines[i];
|
|
painter.strokeColor = l.Color;
|
|
painter.lineWidth = l.Width;
|
|
painter.BeginPath();
|
|
painter.MoveTo(l.A);
|
|
painter.LineTo(l.B);
|
|
painter.LineTo(l.C);
|
|
painter.Stroke();
|
|
}
|
|
}
|
|
|
|
// Only draw one pie series for now to avoid overlapping mess
|
|
break;
|
|
}
|
|
}
|
|
|
|
public override void UpdateLabels()
|
|
{
|
|
if (!TryGetChartAndLabelController(out var chart, out var labelController)) return;
|
|
|
|
_leaderLines.Clear();
|
|
|
|
if (Data == null || Data.Series == null) return;
|
|
|
|
float width = contentRect.width;
|
|
float height = contentRect.height;
|
|
if (width <= 0 || height <= 0) return;
|
|
|
|
// Only handle the first visible pie series (consistent with drawing)
|
|
foreach (var serie in Data.Series)
|
|
{
|
|
if (!serie.visible || serie.type != SerieType.Pie) continue;
|
|
if (serie.seriesData == null || serie.seriesData.Count == 0) continue;
|
|
if (!(serie.settings is PieSettings settings)) continue;
|
|
if (serie.labelSettings == null || !serie.labelSettings.enabled) break;
|
|
|
|
var layout = settings != null ? settings.layout : null;
|
|
|
|
float padding = layout != null && layout.plot != null ? layout.plot.padding : 0f;
|
|
float outerRadius = layout != null ? layout.outerRadius : 0f;
|
|
float innerRadius = layout != null ? layout.innerRadius : 0f;
|
|
|
|
float startAngleDeg = layout != null ? layout.startAngleDeg : -90f;
|
|
bool clockwise = layout != null ? layout.clockwise : true;
|
|
float angleRangeDeg = layout != null ? layout.angleRangeDeg : 360f;
|
|
|
|
Vector2 centerOffset = layout != null && layout.plot != null ? layout.plot.centerOffset : Vector2.zero;
|
|
bool hoverEnabled = settings != null && settings.hover != null && settings.hover.enabled;
|
|
PieExplodeType explodeType = (settings != null && settings.hover != null) ? settings.hover.explodeType : PieExplodeType.Translate;
|
|
float explodeDistance = hoverEnabled && UsesExplodeDistance(explodeType) ? Mathf.Max(0f, settings.hover.explodeDistance) : 0f;
|
|
|
|
float maxAutoRadius = Mathf.Max(0, Mathf.Min(width, height) * 0.5f - padding - explodeDistance);
|
|
float radius = ResolveOuterRadius(outerRadius, maxAutoRadius);
|
|
float innerRadiusPx = ResolveInnerRadius(innerRadius, radius);
|
|
Vector2 center = new Vector2(width / 2f, height / 2f) + new Vector2(centerOffset.x, -centerOffset.y);
|
|
|
|
if (radius <= 0f) break;
|
|
|
|
var slices = BuildSlices(serie, settings);
|
|
if (slices == null || slices.Count == 0) break;
|
|
|
|
float total = 0f;
|
|
for (int i = 0; i < slices.Count; i++) total += Mathf.Max(0f, slices[i].Value);
|
|
if (total <= 0f) break;
|
|
|
|
float fontSize = Mathf.Max(1, serie.labelSettings.fontSize);
|
|
float estimatedLineHeight = fontSize + 4f;
|
|
|
|
// Leader line geometry
|
|
float radialLen = Mathf.Max(6f, fontSize * 0.6f);
|
|
float labelPad = 8f;
|
|
float horizLen = Mathf.Max(20f, fontSize * 2.0f);
|
|
|
|
// Create labels + leader lines
|
|
float currentAngle = startAngleDeg;
|
|
for (int i = 0; i < slices.Count; i++)
|
|
{
|
|
var slice = slices[i];
|
|
if (slice.Value <= 0) continue;
|
|
|
|
float sliceAngle = (slice.Value / total) * angleRangeDeg;
|
|
float sliceGapPx = layout != null ? Mathf.Max(0f, layout.sliceGapPx) : 0f;
|
|
SliceGapType labelGapType = layout != null ? layout.sliceGapType : SliceGapType.Radial;
|
|
float gapAngle = (labelGapType == SliceGapType.Radial && sliceGapPx > 0f && radius > 0.001f) ? (sliceGapPx / radius) * Mathf.Rad2Deg : 0f;
|
|
float halfGap = gapAngle * 0.5f;
|
|
bool wrapGaps = Mathf.Abs(angleRangeDeg) >= 359.99f;
|
|
float shrink = wrapGaps
|
|
? gapAngle
|
|
: ((i == 0 || i == slices.Count - 1) ? halfGap : gapAngle);
|
|
float beforeGap = (i == 0 && !wrapGaps) ? 0f : halfGap;
|
|
float drawAngle = Mathf.Max(0f, sliceAngle - shrink);
|
|
|
|
float midAngle = clockwise
|
|
? ((currentAngle + beforeGap) + drawAngle * 0.5f)
|
|
: ((currentAngle - beforeGap) - drawAngle * 0.5f);
|
|
float midRad = midAngle * Mathf.Deg2Rad;
|
|
Vector2 dir = new Vector2(Mathf.Cos(midRad), Mathf.Sin(midRad));
|
|
bool isRight = dir.x >= 0f;
|
|
|
|
float labelTranslateOffset = (labelGapType == SliceGapType.Translate) ? sliceGapPx * 0.5f : 0f;
|
|
float labelUniformShrink = (labelGapType == SliceGapType.Uniform) ? sliceGapPx * 0.5f : 0f;
|
|
Vector2 labelCenter = center + dir * labelTranslateOffset;
|
|
float labelRadius = radius - labelUniformShrink;
|
|
float labelInnerRadius = innerRadiusPx + labelUniformShrink;
|
|
|
|
bool showName = serie.labelSettings.showName;
|
|
string nameText = slice.Name;
|
|
int dpPlaces = Mathf.Clamp(serie.labelSettings != null ? serie.labelSettings.decimalPlaces : 2, 0, 8);
|
|
string valueText = FormatAxisValue(slice.Value, null, dpPlaces);
|
|
var bg = serie.labelSettings.background;
|
|
var bgColor = bg != null ? bg.color : default;
|
|
var bgTex = bg != null ? bg.texture : null;
|
|
|
|
if (serie.labelSettings.position == LabelPosition.Center)
|
|
{
|
|
float labelR = labelInnerRadius > 0 ? (labelInnerRadius + labelRadius) * 0.5f : labelRadius * 0.5f;
|
|
Vector2 finalPos = labelCenter + dir * labelR + serie.labelSettings.offset;
|
|
|
|
if (showName)
|
|
{
|
|
float nameY = finalPos.y - estimatedLineHeight * 0.5f;
|
|
float valueY = finalPos.y + estimatedLineHeight * 0.5f;
|
|
|
|
var descName = BuildSeriesLabelDesc(
|
|
$"pie:{i}:name",
|
|
nameText,
|
|
new Vector2(finalPos.x, nameY),
|
|
Vector2.zero,
|
|
clipToPlot: false,
|
|
(int)fontSize,
|
|
serie.labelSettings.color,
|
|
ChartLabelAnchor.Center,
|
|
bgColor,
|
|
bgTex);
|
|
labelController.Submit(descName);
|
|
|
|
var descValue = BuildSeriesLabelDesc(
|
|
$"pie:{i}:value",
|
|
valueText,
|
|
new Vector2(finalPos.x, valueY),
|
|
Vector2.zero,
|
|
clipToPlot: false,
|
|
(int)fontSize,
|
|
serie.labelSettings.color,
|
|
ChartLabelAnchor.Center,
|
|
bgColor,
|
|
bgTex);
|
|
labelController.Submit(descValue);
|
|
}
|
|
else
|
|
{
|
|
var descValue = BuildSeriesLabelDesc(
|
|
$"pie:{i}:value",
|
|
valueText,
|
|
finalPos,
|
|
Vector2.zero,
|
|
clipToPlot: false,
|
|
(int)fontSize,
|
|
serie.labelSettings.color,
|
|
ChartLabelAnchor.Center,
|
|
bgColor,
|
|
bgTex);
|
|
labelController.Submit(descValue);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Outside with fixed leader segments: A->B radial, B->C horizontal.
|
|
Vector2 p0 = labelCenter + dir * labelRadius;
|
|
Vector2 p1 = labelCenter + dir * (labelRadius + radialLen);
|
|
Vector2 p2 = new Vector2(p1.x + (isRight ? horizLen : -horizLen), p1.y);
|
|
|
|
float y = p2.y;
|
|
float x = p2.x + (isRight ? labelPad : -labelPad);
|
|
|
|
ChartLabelAnchor anchor = isRight ? ChartLabelAnchor.Left : ChartLabelAnchor.Right;
|
|
|
|
if (showName)
|
|
{
|
|
float nameY = y - estimatedLineHeight * 0.5f;
|
|
float valueY = y + estimatedLineHeight * 0.5f;
|
|
|
|
var descName = BuildSeriesLabelDesc(
|
|
$"pie:{i}:name",
|
|
nameText,
|
|
new Vector2(x, nameY),
|
|
Vector2.zero,
|
|
clipToPlot: false,
|
|
(int)fontSize,
|
|
serie.labelSettings.color,
|
|
anchor,
|
|
bgColor,
|
|
bgTex);
|
|
labelController.Submit(descName);
|
|
|
|
var descValue = BuildSeriesLabelDesc(
|
|
$"pie:{i}:value",
|
|
valueText,
|
|
new Vector2(x, valueY),
|
|
Vector2.zero,
|
|
clipToPlot: false,
|
|
(int)fontSize,
|
|
serie.labelSettings.color,
|
|
anchor,
|
|
bgColor,
|
|
bgTex);
|
|
labelController.Submit(descValue);
|
|
}
|
|
else
|
|
{
|
|
var descValue = BuildSeriesLabelDesc(
|
|
$"pie:{i}:value",
|
|
valueText,
|
|
new Vector2(x, y),
|
|
Vector2.zero,
|
|
clipToPlot: false,
|
|
(int)fontSize,
|
|
serie.labelSettings.color,
|
|
anchor,
|
|
bgColor,
|
|
bgTex);
|
|
labelController.Submit(descValue);
|
|
}
|
|
|
|
_leaderLines.Add(new LeaderLine
|
|
{
|
|
A = p0,
|
|
B = p1,
|
|
C = p2,
|
|
Color = new Color(serie.labelSettings.color.r, serie.labelSettings.color.g, serie.labelSettings.color.b, Mathf.Clamp01(serie.labelSettings.color.a * 0.8f)),
|
|
Width = 1f
|
|
});
|
|
}
|
|
|
|
currentAngle = clockwise ? (currentAngle + sliceAngle) : (currentAngle - sliceAngle);
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
private void DrawPieSerie(Painter2D painter, Serie serie, PieSettings settings, Vector2 center, float radius, float innerRadius, float startAngleDeg, bool clockwise, float angleRangeDeg)
|
|
{
|
|
var slices = BuildSlices(serie, settings);
|
|
if (slices == null || slices.Count == 0) return;
|
|
|
|
float total = 0f;
|
|
for (int i = 0; i < slices.Count; i++)
|
|
{
|
|
total += Mathf.Max(0f, slices[i].Value);
|
|
}
|
|
|
|
if (total <= 0) return;
|
|
|
|
if (radius <= 0f) return;
|
|
|
|
float currentAngle = startAngleDeg;
|
|
|
|
float maxAngle = angleRangeDeg * _animationProgress;
|
|
float accumulatedAngle = 0f;
|
|
|
|
bool hoverEnabled = settings != null && settings.hover != null && settings.hover.enabled;
|
|
PieExplodeType explodeType = (settings != null && settings.hover != null) ? settings.hover.explodeType : PieExplodeType.Translate;
|
|
float explodeDistance = hoverEnabled && UsesExplodeDistance(explodeType) ? Mathf.Max(0f, settings.hover.explodeDistance) : 0f;
|
|
|
|
float roundedCapSetting = 0f;
|
|
if (settings != null && settings.layout != null)
|
|
{
|
|
roundedCapSetting = settings.layout.cornerRadius;
|
|
}
|
|
float thickness = Mathf.Max(0f, radius - Mathf.Max(0f, innerRadius));
|
|
float capRadius = ResolveRoundedCapRadius(roundedCapSetting, thickness);
|
|
|
|
float sliceGapPx = settings != null && settings.layout != null ? Mathf.Max(0f, settings.layout.sliceGapPx) : 0f;
|
|
if (slices.Count <= 1) sliceGapPx = 0f;
|
|
SliceGapType gapType = settings != null && settings.layout != null ? settings.layout.sliceGapType : SliceGapType.Radial;
|
|
float gapAngle = (gapType == SliceGapType.Radial && sliceGapPx > 0f && radius > 0.001f) ? (sliceGapPx / radius) * Mathf.Rad2Deg : 0f;
|
|
float halfGap = gapAngle * 0.5f;
|
|
bool wrapGaps = Mathf.Abs(angleRangeDeg) >= 359.99f;
|
|
float translateGapOffset = (gapType == SliceGapType.Translate) ? sliceGapPx * 0.5f : 0f;
|
|
float uniformGapShrink = (gapType == SliceGapType.Uniform) ? sliceGapPx * 0.5f : 0f;
|
|
|
|
for (int i = 0; i < slices.Count; i++)
|
|
{
|
|
var slice = slices[i];
|
|
if (slice.Value <= 0) continue;
|
|
|
|
float sliceAngle = (slice.Value / total) * angleRangeDeg;
|
|
|
|
// Check animation bounds
|
|
if (accumulatedAngle >= maxAngle) break;
|
|
|
|
float visibleSliceAngle = sliceAngle;
|
|
if (accumulatedAngle + sliceAngle > maxAngle)
|
|
{
|
|
visibleSliceAngle = maxAngle - accumulatedAngle;
|
|
}
|
|
|
|
bool isHovered = hoverEnabled && _hoverSliceIndex.HasValue && _hoverSliceIndex.Value == i;
|
|
bool hoverColor = isHovered && explodeType == PieExplodeType.Color;
|
|
bool hoverStroke = isHovered && explodeType == PieExplodeType.Stroke;
|
|
|
|
Color color = hoverColor ? GetHoverFillColor(slice.Color) : slice.Color;
|
|
painter.fillColor = color;
|
|
float endAngle = clockwise
|
|
? (currentAngle + visibleSliceAngle)
|
|
: (currentAngle - visibleSliceAngle);
|
|
|
|
float shrink = wrapGaps
|
|
? gapAngle
|
|
: ((i == 0 || i == slices.Count - 1) ? halfGap : gapAngle);
|
|
float beforeGap = (i == 0 && !wrapGaps) ? 0f : halfGap;
|
|
float drawAngle = Mathf.Max(0f, visibleSliceAngle - shrink);
|
|
float drawStartAngle = clockwise ? (currentAngle + beforeGap) : (currentAngle - beforeGap);
|
|
float drawEndAngle = clockwise ? (drawStartAngle + drawAngle) : (drawStartAngle - drawAngle);
|
|
|
|
if (drawAngle <= 0f)
|
|
{
|
|
currentAngle = endAngle;
|
|
accumulatedAngle += visibleSliceAngle;
|
|
continue;
|
|
}
|
|
|
|
float sliceMidAngle = clockwise
|
|
? (drawStartAngle + drawAngle * 0.5f)
|
|
: (drawStartAngle - drawAngle * 0.5f);
|
|
float sliceMidRad = sliceMidAngle * Mathf.Deg2Rad;
|
|
Vector2 sliceMidDir = new Vector2(Mathf.Cos(sliceMidRad), Mathf.Sin(sliceMidRad));
|
|
|
|
Vector2 sliceCenter = center;
|
|
float sliceRadius = radius;
|
|
float sliceInnerRadius = innerRadius;
|
|
|
|
if (gapType == SliceGapType.Translate && translateGapOffset > 0f)
|
|
{
|
|
sliceCenter = center + sliceMidDir * translateGapOffset;
|
|
}
|
|
else if (gapType == SliceGapType.Uniform && uniformGapShrink > 0f)
|
|
{
|
|
sliceRadius = Mathf.Max(0f, radius - uniformGapShrink);
|
|
sliceInnerRadius = Mathf.Max(0f, innerRadius + uniformGapShrink);
|
|
}
|
|
|
|
if (explodeDistance > 0f && _hoverSliceIndex.HasValue && _hoverSliceIndex.Value == i)
|
|
{
|
|
sliceCenter = sliceCenter + sliceMidDir * explodeDistance;
|
|
}
|
|
|
|
// Painter2D.Arc can produce degenerate geometry when sweeping a full circle.
|
|
// This happens frequently when only one slice is visible (e.g. after legend toggles).
|
|
if (Mathf.Abs(visibleSliceAngle - angleRangeDeg) <= 0.01f)
|
|
{
|
|
painter.BeginPath();
|
|
if (innerRadius > 0)
|
|
{
|
|
// Exploding a full donut ring looks odd (it becomes a whole ring translated).
|
|
// Keep it centered regardless of explode type.
|
|
Vector2 outerStart = center + Vector2.right * radius;
|
|
Vector2 innerStart = center + Vector2.right * innerRadius;
|
|
|
|
painter.MoveTo(outerStart);
|
|
// Avoid Painter2D.Arc here: reverse sweeps can fill the whole disk and cause flicker.
|
|
LineArc(painter, center, radius, 0f, 360f, 128);
|
|
painter.LineTo(innerStart);
|
|
LineArc(painter, center, innerRadius, 0f, -360f, 128);
|
|
painter.ClosePath();
|
|
}
|
|
else
|
|
{
|
|
painter.Arc(sliceCenter, radius, 0, 360);
|
|
painter.ClosePath();
|
|
}
|
|
painter.Fill();
|
|
|
|
if (hoverStroke)
|
|
{
|
|
painter.strokeColor = GetHoverStrokeColor(slice.Color);
|
|
painter.lineWidth = 2f;
|
|
StrokePieShape(painter, center, center, radius, innerRadius, 0f, 360f, true);
|
|
}
|
|
|
|
// Full circle consumes the whole range.
|
|
currentAngle = endAngle;
|
|
accumulatedAngle += visibleSliceAngle;
|
|
break;
|
|
}
|
|
|
|
bool didRoundedDonut = false;
|
|
if (sliceInnerRadius > 0f && capRadius > 0f)
|
|
{
|
|
// Rounded-corner donut requires concentric geometry.
|
|
// When explodeType == Pull and the hovered slice is exploded, outer/inner boundaries are not
|
|
// concentric, so fillet math would be invalid. Fall back to the non-rounded path in that case.
|
|
bool isExplodedPullSlice = explodeType == PieExplodeType.Pull
|
|
&& explodeDistance > 0f
|
|
&& _hoverSliceIndex.HasValue
|
|
&& _hoverSliceIndex.Value == i;
|
|
|
|
if (!isExplodedPullSlice)
|
|
{
|
|
if (hoverStroke)
|
|
{
|
|
painter.strokeColor = GetHoverStrokeColor(slice.Color);
|
|
painter.lineWidth = 2f;
|
|
}
|
|
didRoundedDonut = TryDrawRoundedDonutSlice(painter, sliceCenter, sliceRadius, sliceInnerRadius, drawStartAngle, drawEndAngle, clockwise, capRadius, hoverStroke);
|
|
}
|
|
}
|
|
|
|
bool didRoundedPie = false;
|
|
if (!didRoundedDonut && sliceInnerRadius <= 0f && capRadius > 0f)
|
|
{
|
|
// For regular pie (no inner radius), rounded corners work for all hover types except Pull
|
|
// (Pull moves the slice center, making the geometry non-concentric with the origin).
|
|
bool isExplodedPullSlice = explodeType == PieExplodeType.Pull
|
|
&& explodeDistance > 0f
|
|
&& _hoverSliceIndex.HasValue
|
|
&& _hoverSliceIndex.Value == i;
|
|
|
|
if (!isExplodedPullSlice)
|
|
{
|
|
if (hoverStroke)
|
|
{
|
|
painter.strokeColor = GetHoverStrokeColor(slice.Color);
|
|
painter.lineWidth = 2f;
|
|
}
|
|
didRoundedPie = TryDrawRoundedPieSlice(painter, sliceCenter, sliceRadius, drawStartAngle, drawEndAngle, clockwise, capRadius, hoverStroke);
|
|
}
|
|
}
|
|
|
|
if (!didRoundedDonut && !didRoundedPie)
|
|
{
|
|
painter.BeginPath();
|
|
if (sliceInnerRadius > 0)
|
|
{
|
|
if (explodeType == PieExplodeType.Translate)
|
|
{
|
|
// Proper donut sector (no center mask needed). Translate both outer and inner arcs.
|
|
float startRad = drawStartAngle * Mathf.Deg2Rad;
|
|
float endRad = drawEndAngle * Mathf.Deg2Rad;
|
|
Vector2 outerStart = sliceCenter + new Vector2(Mathf.Cos(startRad), Mathf.Sin(startRad)) * sliceRadius;
|
|
Vector2 innerEnd = sliceCenter + new Vector2(Mathf.Cos(endRad), Mathf.Sin(endRad)) * sliceInnerRadius;
|
|
Vector2 innerStart = sliceCenter + new Vector2(Mathf.Cos(startRad), Mathf.Sin(startRad)) * sliceInnerRadius;
|
|
|
|
painter.MoveTo(outerStart);
|
|
int outerSegments = Mathf.Clamp(Mathf.CeilToInt(Mathf.Abs(drawAngle) / 8f), 2, 64);
|
|
float outerSweep = clockwise ? drawAngle : -drawAngle;
|
|
LineArc(painter, sliceCenter, sliceRadius, drawStartAngle, outerSweep, outerSegments);
|
|
painter.LineTo(innerEnd);
|
|
int innerSegments = Mathf.Clamp(Mathf.CeilToInt(Mathf.Abs(drawAngle) / 8f), 2, 64);
|
|
float innerSweep = clockwise ? -drawAngle : drawAngle;
|
|
LineArc(painter, sliceCenter, sliceInnerRadius, drawEndAngle, innerSweep, innerSegments);
|
|
painter.LineTo(innerStart);
|
|
}
|
|
else
|
|
{
|
|
// Pull: keep the inner boundary anchored to the original center so the donut hole doesn't
|
|
// look like it is being dragged out during hover.
|
|
float startRad = drawStartAngle * Mathf.Deg2Rad;
|
|
float endRad = drawEndAngle * Mathf.Deg2Rad;
|
|
Vector2 outerStart = sliceCenter + new Vector2(Mathf.Cos(startRad), Mathf.Sin(startRad)) * sliceRadius;
|
|
Vector2 innerEnd = center + new Vector2(Mathf.Cos(endRad), Mathf.Sin(endRad)) * sliceInnerRadius;
|
|
Vector2 innerStart = center + new Vector2(Mathf.Cos(startRad), Mathf.Sin(startRad)) * sliceInnerRadius;
|
|
|
|
painter.MoveTo(outerStart);
|
|
int outerSegments = Mathf.Clamp(Mathf.CeilToInt(Mathf.Abs(drawAngle) / 8f), 2, 64);
|
|
float outerSweep = clockwise ? drawAngle : -drawAngle;
|
|
LineArc(painter, sliceCenter, sliceRadius, drawStartAngle, outerSweep, outerSegments);
|
|
painter.LineTo(innerEnd);
|
|
int innerSegments = Mathf.Clamp(Mathf.CeilToInt(Mathf.Abs(drawAngle) / 8f), 2, 64);
|
|
float innerSweep = clockwise ? -drawAngle : drawAngle;
|
|
LineArc(painter, center, sliceInnerRadius, drawEndAngle, innerSweep, innerSegments);
|
|
painter.LineTo(innerStart);
|
|
}
|
|
painter.ClosePath();
|
|
}
|
|
else
|
|
{
|
|
if (explodeType == PieExplodeType.Translate)
|
|
{
|
|
// Rigid translation: the whole slice (including its center) is moved.
|
|
painter.MoveTo(sliceCenter);
|
|
Vector2 outerStart = sliceCenter + new Vector2(Mathf.Cos(drawStartAngle * Mathf.Deg2Rad), Mathf.Sin(drawStartAngle * Mathf.Deg2Rad)) * sliceRadius;
|
|
painter.LineTo(outerStart);
|
|
int outerSegments = Mathf.Clamp(Mathf.CeilToInt(Mathf.Abs(drawAngle) / 8f), 2, 64);
|
|
float outerSweep = clockwise ? drawAngle : -drawAngle;
|
|
LineArc(painter, sliceCenter, sliceRadius, drawStartAngle, outerSweep, outerSegments);
|
|
painter.ClosePath();
|
|
}
|
|
else
|
|
{
|
|
// Pull: keep the slice center anchored, but pull the outer arc outward.
|
|
painter.MoveTo(center);
|
|
Vector2 outerStart = sliceCenter + new Vector2(Mathf.Cos(drawStartAngle * Mathf.Deg2Rad), Mathf.Sin(drawStartAngle * Mathf.Deg2Rad)) * sliceRadius;
|
|
painter.LineTo(outerStart);
|
|
int outerSegments = Mathf.Clamp(Mathf.CeilToInt(Mathf.Abs(drawAngle) / 8f), 2, 64);
|
|
float outerSweep = clockwise ? drawAngle : -drawAngle;
|
|
LineArc(painter, sliceCenter, sliceRadius, drawStartAngle, outerSweep, outerSegments);
|
|
painter.ClosePath();
|
|
}
|
|
}
|
|
|
|
painter.Fill();
|
|
}
|
|
|
|
if (hoverStroke && !didRoundedDonut && !didRoundedPie)
|
|
{
|
|
painter.strokeColor = GetHoverStrokeColor(slice.Color);
|
|
painter.lineWidth = 2f;
|
|
|
|
if (sliceInnerRadius > 0)
|
|
{
|
|
if (explodeType == PieExplodeType.Translate)
|
|
{
|
|
StrokePieShape(painter, sliceCenter, sliceCenter, sliceRadius, sliceInnerRadius, drawStartAngle, drawEndAngle, clockwise);
|
|
}
|
|
else
|
|
{
|
|
StrokePieShape(painter, sliceCenter, center, sliceRadius, sliceInnerRadius, drawStartAngle, drawEndAngle, clockwise);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (explodeType == PieExplodeType.Translate)
|
|
{
|
|
StrokePieShape(painter, sliceCenter, sliceCenter, sliceRadius, 0f, drawStartAngle, drawEndAngle, clockwise);
|
|
}
|
|
else
|
|
{
|
|
StrokePieShape(painter, sliceCenter, center, sliceRadius, 0f, drawStartAngle, drawEndAngle, clockwise);
|
|
}
|
|
}
|
|
}
|
|
|
|
currentAngle = endAngle;
|
|
accumulatedAngle += visibleSliceAngle;
|
|
}
|
|
|
|
// Draw Inner Hole (Donut)
|
|
if (innerRadius > 0)
|
|
{
|
|
// Optional: Fill the hole area with a color. If alpha is 0, the hole is truly transparent.
|
|
Color holeColor = settings != null && settings.layout != null
|
|
? settings.layout.innerRadiusColor
|
|
: BackgroundColor;
|
|
|
|
if (holeColor.a > 0f)
|
|
{
|
|
painter.fillColor = holeColor;
|
|
painter.BeginPath();
|
|
painter.Arc(center, innerRadius, 0, 360);
|
|
painter.Fill();
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void LineArc(Painter2D painter, Vector2 center, float radius, float startAngleDeg, float sweepDeg, int segments)
|
|
{
|
|
if (segments <= 0) return;
|
|
for (int s = 1; s <= segments; s++)
|
|
{
|
|
float t = s / (float)segments;
|
|
float a = startAngleDeg + sweepDeg * t;
|
|
float rad = a * Mathf.Deg2Rad;
|
|
painter.LineTo(center + new Vector2(Mathf.Cos(rad), Mathf.Sin(rad)) * radius);
|
|
}
|
|
}
|
|
|
|
private static bool TryDrawRoundedDonutSlice(Painter2D painter, Vector2 origin, float outerRadius, float innerRadius, float startAngleDeg, float endAngleDeg, bool clockwise, float cornerRadius, bool strokeAfterFill = false)
|
|
{
|
|
if (outerRadius <= 0f) return false;
|
|
if (innerRadius <= 0f) return false;
|
|
if (cornerRadius <= 0f) return false;
|
|
if (innerRadius >= outerRadius) return false;
|
|
|
|
float sweepSign = clockwise ? 1f : -1f;
|
|
float sliceSweep = endAngleDeg - startAngleDeg;
|
|
if (Mathf.Abs(sliceSweep) <= 0.001f) return false;
|
|
|
|
// Limit corner radius by local edge lengths.
|
|
// For each corner, the adjacent edges are: (1) the circular arc (outer/inner) and (2) the radial edge (thickness).
|
|
// To avoid invalid geometry, clamp to half of the shorter adjacent edge length.
|
|
float thickness = outerRadius - innerRadius;
|
|
float absSliceSweepRad = Mathf.Abs(sliceSweep) * Mathf.Deg2Rad;
|
|
float outerArcLen = outerRadius * absSliceSweepRad;
|
|
float innerArcLen = innerRadius * absSliceSweepRad;
|
|
float maxByThickness = thickness * 0.5f;
|
|
float maxByOuterArc = outerArcLen * 0.5f;
|
|
float maxByInnerArc = innerArcLen * 0.5f;
|
|
|
|
// Avoid impossible geometry for outer circle: f <= R/2
|
|
if (cornerRadius > outerRadius * 0.5f) cornerRadius = outerRadius * 0.5f;
|
|
cornerRadius = Mathf.Min(cornerRadius, maxByThickness, maxByOuterArc, maxByInnerArc);
|
|
if (cornerRadius <= 0f) return false;
|
|
|
|
if (!TryBuildCorner(origin, startAngleDeg, sweepSign, outerRadius, innerRadius, cornerRadius,
|
|
out var soOuterCircle, out var soOuterLine, out var soOuterCenter,
|
|
out var soInnerCircle, out var soInnerLine, out var soInnerCenter)) return false;
|
|
|
|
if (!TryBuildCorner(origin, endAngleDeg, -sweepSign, outerRadius, innerRadius, cornerRadius,
|
|
out var eoOuterCircle, out var eoOuterLine, out var eoOuterCenter,
|
|
out var eoInnerCircle, out var eoInnerLine, out var eoInnerCenter)) return false;
|
|
|
|
float angOuterStart = Mathf.Atan2(soOuterCircle.y - origin.y, soOuterCircle.x - origin.x) * Mathf.Rad2Deg;
|
|
float angOuterEnd = Mathf.Atan2(eoOuterCircle.y - origin.y, eoOuterCircle.x - origin.x) * Mathf.Rad2Deg;
|
|
float outerSweep = SignedAngleDelta(angOuterStart, angOuterEnd, sweepSign);
|
|
|
|
float angInnerEnd = Mathf.Atan2(eoInnerCircle.y - origin.y, eoInnerCircle.x - origin.x) * Mathf.Rad2Deg;
|
|
float angInnerStart = Mathf.Atan2(soInnerCircle.y - origin.y, soInnerCircle.x - origin.x) * Mathf.Rad2Deg;
|
|
float innerSweep = SignedAngleDelta(angInnerEnd, angInnerStart, -sweepSign);
|
|
|
|
// If computed sweeps are larger than the slice sweep itself, the fillet geometry is not valid.
|
|
// This commonly happens during animation when the slice sweep is very small, and would otherwise
|
|
// take the long way around (~360 deg), causing a full-ring flicker.
|
|
float absOuterSweep = Mathf.Abs(outerSweep);
|
|
float absInnerSweep = Mathf.Abs(innerSweep);
|
|
float absSlice = Mathf.Abs(sliceSweep);
|
|
if (absOuterSweep > 359f || absInnerSweep > 359f) return false;
|
|
if (absSlice > 0.001f && (absOuterSweep > absSlice + 0.01f || absInnerSweep > absSlice + 0.01f)) return false;
|
|
|
|
// If the slice is too small, corners overlap.
|
|
if (Mathf.Abs(outerSweep) <= 0.5f) return false;
|
|
if (Mathf.Abs(innerSweep) <= 0.5f) return false;
|
|
|
|
int outerSeg = Mathf.Clamp(Mathf.CeilToInt(Mathf.Abs(outerSweep) / 8f), 2, 96);
|
|
int innerSeg = Mathf.Clamp(Mathf.CeilToInt(Mathf.Abs(innerSweep) / 8f), 2, 96);
|
|
|
|
painter.BeginPath();
|
|
painter.MoveTo(soOuterCircle);
|
|
LineArc(painter, origin, outerRadius, angOuterStart, outerSweep, outerSeg);
|
|
|
|
LineCornerArc(painter, eoOuterCenter, cornerRadius, eoOuterCircle, eoOuterLine);
|
|
painter.LineTo(eoInnerLine);
|
|
LineCornerArc(painter, eoInnerCenter, cornerRadius, eoInnerLine, eoInnerCircle);
|
|
LineArc(painter, origin, innerRadius, angInnerEnd, innerSweep, innerSeg);
|
|
LineCornerArc(painter, soInnerCenter, cornerRadius, soInnerCircle, soInnerLine);
|
|
painter.LineTo(soOuterLine);
|
|
LineCornerArc(painter, soOuterCenter, cornerRadius, soOuterLine, soOuterCircle);
|
|
painter.ClosePath();
|
|
painter.Fill();
|
|
if (strokeAfterFill)
|
|
{
|
|
painter.Stroke();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private static bool TryDrawRoundedPieSlice(Painter2D painter, Vector2 origin, float radius, float startAngleDeg, float endAngleDeg, bool clockwise, float cornerRadius, bool strokeAfterFill = false)
|
|
{
|
|
if (radius <= 0f) return false;
|
|
if (cornerRadius <= 0f) return false;
|
|
float sweepSign = clockwise ? 1f : -1f;
|
|
float sliceSweep = endAngleDeg - startAngleDeg;
|
|
if (Mathf.Abs(sliceSweep) <= 0.001f) return false;
|
|
|
|
// Limit corner radius by local edge lengths.
|
|
// Adjacent edges at the outer corners are: (1) the outer circular arc and (2) the radial edge from center to the outer arc.
|
|
// Clamp to half of the shorter edge length.
|
|
float absSliceSweepRad = Mathf.Abs(sliceSweep) * Mathf.Deg2Rad;
|
|
float outerArcLen = radius * absSliceSweepRad;
|
|
float maxByRadius = radius * 0.5f;
|
|
float maxByOuterArc = outerArcLen * 0.5f;
|
|
|
|
if (cornerRadius > radius * 0.5f) cornerRadius = radius * 0.5f;
|
|
cornerRadius = Mathf.Min(cornerRadius, maxByRadius, maxByOuterArc);
|
|
if (cornerRadius <= 0f) return false;
|
|
|
|
if (!TryBuildOuterCornerOnly(origin, startAngleDeg, sweepSign, radius, cornerRadius,
|
|
out var soOuterCircle, out var soOuterLine, out var soOuterCenter)) return false;
|
|
|
|
if (!TryBuildOuterCornerOnly(origin, endAngleDeg, -sweepSign, radius, cornerRadius,
|
|
out var eoOuterCircle, out var eoOuterLine, out var eoOuterCenter)) return false;
|
|
|
|
float angOuterStart = Mathf.Atan2(soOuterCircle.y - origin.y, soOuterCircle.x - origin.x) * Mathf.Rad2Deg;
|
|
float angOuterEnd = Mathf.Atan2(eoOuterCircle.y - origin.y, eoOuterCircle.x - origin.x) * Mathf.Rad2Deg;
|
|
float outerSweep = SignedAngleDelta(angOuterStart, angOuterEnd, sweepSign);
|
|
|
|
float absOuterSweep = Mathf.Abs(outerSweep);
|
|
float absSlice = Mathf.Abs(sliceSweep);
|
|
if (absOuterSweep > 359f) return false;
|
|
if (absSlice > 0.001f && absOuterSweep > absSlice + 0.01f) return false;
|
|
if (absOuterSweep <= 0.5f) return false;
|
|
|
|
int outerSeg = Mathf.Clamp(Mathf.CeilToInt(absOuterSweep / 8f), 2, 96);
|
|
|
|
painter.BeginPath();
|
|
painter.MoveTo(origin);
|
|
painter.LineTo(soOuterLine);
|
|
LineCornerArc(painter, soOuterCenter, cornerRadius, soOuterLine, soOuterCircle);
|
|
LineArc(painter, origin, radius, angOuterStart, outerSweep, outerSeg);
|
|
LineCornerArc(painter, eoOuterCenter, cornerRadius, eoOuterCircle, eoOuterLine);
|
|
painter.LineTo(origin);
|
|
painter.ClosePath();
|
|
painter.Fill();
|
|
if (strokeAfterFill)
|
|
{
|
|
painter.Stroke();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private static bool TryBuildOuterCornerOnly(Vector2 origin, float boundaryAngleDeg, float interiorSign, float outerRadius, float cornerRadius,
|
|
out Vector2 outerCircle, out Vector2 outerLine, out Vector2 outerCenter)
|
|
{
|
|
Vector2 u = new Vector2(Mathf.Cos(boundaryAngleDeg * Mathf.Deg2Rad), Mathf.Sin(boundaryAngleDeg * Mathf.Deg2Rad));
|
|
Vector2 t = new Vector2(-u.y, u.x);
|
|
|
|
float f = cornerRadius;
|
|
float yc = interiorSign * f;
|
|
|
|
float outerBase = outerRadius - f;
|
|
float outerSq = outerBase * outerBase - f * f;
|
|
if (outerSq <= 0f)
|
|
{
|
|
outerCircle = outerLine = outerCenter = Vector2.zero;
|
|
return false;
|
|
}
|
|
|
|
float xo = Mathf.Sqrt(outerSq);
|
|
Vector2 outerC = origin + u * xo + t * yc;
|
|
Vector2 outerCirclePoint = origin + (outerRadius / outerBase) * (outerC - origin);
|
|
Vector2 outerLinePoint = origin + u * xo;
|
|
|
|
outerCircle = outerCirclePoint;
|
|
outerLine = outerLinePoint;
|
|
outerCenter = outerC;
|
|
return true;
|
|
}
|
|
|
|
private static bool TryBuildCorner(Vector2 origin, float boundaryAngleDeg, float interiorSign, float outerRadius, float innerRadius, float cornerRadius,
|
|
out Vector2 outerCircle, out Vector2 outerLine, out Vector2 outerCenter,
|
|
out Vector2 innerCircle, out Vector2 innerLine, out Vector2 innerCenter)
|
|
{
|
|
Vector2 u = new Vector2(Mathf.Cos(boundaryAngleDeg * Mathf.Deg2Rad), Mathf.Sin(boundaryAngleDeg * Mathf.Deg2Rad));
|
|
Vector2 t = new Vector2(-u.y, u.x);
|
|
|
|
float f = cornerRadius;
|
|
float yc = interiorSign * f;
|
|
|
|
float outerBase = outerRadius - f;
|
|
float outerSq = outerBase * outerBase - f * f;
|
|
if (outerSq <= 0f)
|
|
{
|
|
outerCircle = outerLine = outerCenter = Vector2.zero;
|
|
innerCircle = innerLine = innerCenter = Vector2.zero;
|
|
return false;
|
|
}
|
|
|
|
float xo = Mathf.Sqrt(outerSq);
|
|
Vector2 outerC = origin + u * xo + t * yc;
|
|
Vector2 outerCirclePoint = origin + (outerRadius / outerBase) * (outerC - origin);
|
|
Vector2 outerLinePoint = origin + u * xo;
|
|
|
|
float innerBase = innerRadius + f;
|
|
float innerSq = innerBase * innerBase - f * f;
|
|
float xi = innerSq <= 0f ? 0f : Mathf.Sqrt(innerSq);
|
|
Vector2 innerC = origin + u * xi + t * yc;
|
|
Vector2 innerCirclePoint = origin + (innerRadius / innerBase) * (innerC - origin);
|
|
Vector2 innerLinePoint = origin + u * xi;
|
|
|
|
outerCircle = outerCirclePoint;
|
|
outerLine = outerLinePoint;
|
|
outerCenter = outerC;
|
|
innerCircle = innerCirclePoint;
|
|
innerLine = innerLinePoint;
|
|
innerCenter = innerC;
|
|
return true;
|
|
}
|
|
|
|
private static float SignedAngleDelta(float fromDeg, float toDeg, float preferredSign)
|
|
{
|
|
float d = Mathf.DeltaAngle(fromDeg, toDeg);
|
|
if (Mathf.Sign(d) != Mathf.Sign(preferredSign) && Mathf.Abs(d) > 0.001f)
|
|
{
|
|
d += 360f * Mathf.Sign(preferredSign);
|
|
}
|
|
return d;
|
|
}
|
|
|
|
private static void LineCornerArc(Painter2D painter, Vector2 center, float radius, Vector2 from, Vector2 to)
|
|
{
|
|
float a0 = Mathf.Atan2(from.y - center.y, from.x - center.x) * Mathf.Rad2Deg;
|
|
float a1 = Mathf.Atan2(to.y - center.y, to.x - center.x) * Mathf.Rad2Deg;
|
|
float sweep = Mathf.DeltaAngle(a0, a1);
|
|
int seg = Mathf.Clamp(Mathf.CeilToInt(Mathf.Abs(sweep) / 8f), 2, 32);
|
|
LineArc(painter, center, radius, a0, sweep, seg);
|
|
}
|
|
|
|
public override bool GetTooltip(TooltipContext context, List<TooltipItem> items, ref Vector2? cursorPosition, ref string categoryLabel)
|
|
{
|
|
// Pie tooltip logic: check distance from center and angle
|
|
if (Data == null || Data.Series == null) return false;
|
|
|
|
float width = context.Width;
|
|
float height = context.Height;
|
|
Vector2 mousePos = context.LocalPos;
|
|
bool hit = false;
|
|
|
|
// Iterate first visible pie series
|
|
foreach (var serie in Data.Series)
|
|
{
|
|
if (!serie.visible || serie.type != SerieType.Pie) continue;
|
|
if (serie.seriesData == null || serie.seriesData.Count == 0) continue;
|
|
if (!(serie.settings is PieSettings settings)) continue;
|
|
|
|
var layout = settings != null ? settings.layout : null;
|
|
|
|
float padding = layout != null && layout.plot != null ? layout.plot.padding : 0f;
|
|
float outerRadius = layout != null ? layout.outerRadius : 0f;
|
|
float innerRadius = layout != null ? layout.innerRadius : 0f;
|
|
float startAngleDeg = layout != null ? layout.startAngleDeg : -90f;
|
|
bool clockwise = layout != null ? layout.clockwise : true;
|
|
float angleRangeDeg = layout != null ? layout.angleRangeDeg : 360f;
|
|
|
|
Vector2 centerOffset = layout != null && layout.plot != null ? layout.plot.centerOffset : Vector2.zero;
|
|
bool hoverEnabled = settings != null && settings.hover != null && settings.hover.enabled;
|
|
PieExplodeType explodeType = (settings != null && settings.hover != null) ? settings.hover.explodeType : PieExplodeType.Translate;
|
|
float explodeDistance = hoverEnabled && UsesExplodeDistance(explodeType) ? Mathf.Max(0f, settings.hover.explodeDistance) : 0f;
|
|
|
|
// Keep hit-testing consistent with drawing: reserve explode distance.
|
|
float maxAutoRadius = Mathf.Max(0, Mathf.Min(width, height) * 0.5f - padding - explodeDistance);
|
|
float radius = ResolveOuterRadius(outerRadius, maxAutoRadius);
|
|
float innerRadiusPx = ResolveInnerRadius(innerRadius, radius);
|
|
Vector2 center = new Vector2(width / 2f, height / 2f) + new Vector2(centerOffset.x, -centerOffset.y);
|
|
|
|
var slices = BuildSlices(serie, settings);
|
|
if (slices == null || slices.Count == 0) return false;
|
|
|
|
float total = 0f;
|
|
for (int i = 0; i < slices.Count; i++) total += Mathf.Max(0f, slices[i].Value);
|
|
if (total <= 0) return false;
|
|
|
|
// IMPORTANT: hit-testing should be based on the original (non-exploded) geometry.
|
|
// If we shift the center during hit-testing, the cursor can fall into the vacated gap
|
|
// and cause hover state to toggle (flicker).
|
|
float dist = Vector2.Distance(mousePos, center);
|
|
|
|
Vector2 dir = mousePos - center;
|
|
float mouseAngle = Mathf.Atan2(dir.y, dir.x) * Mathf.Rad2Deg;
|
|
|
|
float startAngle = startAngleDeg;
|
|
int? hitSlice = null;
|
|
float sliceGapPx = layout != null ? Mathf.Max(0f, layout.sliceGapPx) : 0f;
|
|
if (slices.Count <= 1) sliceGapPx = 0f;
|
|
SliceGapType gapType = layout != null ? layout.sliceGapType : SliceGapType.Radial;
|
|
float gapAngle = (gapType == SliceGapType.Radial && sliceGapPx > 0f && radius > 0.001f) ? (sliceGapPx / radius) * Mathf.Rad2Deg : 0f;
|
|
float halfGap = gapAngle * 0.5f;
|
|
bool wrapGaps = Mathf.Abs(angleRangeDeg) >= 359.99f;
|
|
float uniformGapShrink = (gapType == SliceGapType.Uniform) ? sliceGapPx * 0.5f : 0f;
|
|
float hitOuterRadius = radius - uniformGapShrink;
|
|
float hitInnerRadius = innerRadiusPx + uniformGapShrink;
|
|
if (dist > hitOuterRadius + explodeDistance) return false;
|
|
if (hitInnerRadius > 0 && dist < hitInnerRadius) return false;
|
|
for (int i = 0; i < slices.Count; i++)
|
|
{
|
|
var slice = slices[i];
|
|
if (slice.Value <= 0) continue;
|
|
|
|
float sliceAngle = (slice.Value / total) * angleRangeDeg;
|
|
|
|
float endAngle = clockwise ? (startAngle + sliceAngle) : (startAngle - sliceAngle);
|
|
|
|
float shrink = wrapGaps
|
|
? gapAngle
|
|
: ((i == 0 || i == slices.Count - 1) ? halfGap : gapAngle);
|
|
float beforeGap = (i == 0 && !wrapGaps) ? 0f : halfGap;
|
|
float effectiveAngle = Mathf.Max(0f, sliceAngle - shrink);
|
|
|
|
float hitStart = clockwise ? (startAngle + beforeGap) : (startAngle - beforeGap);
|
|
float hitEnd = clockwise ? (hitStart + effectiveAngle) : (hitStart - effectiveAngle);
|
|
|
|
if (effectiveAngle > 0f && IsAngleBetween(mouseAngle, hitStart, hitEnd, clockwise))
|
|
{
|
|
items.Add(new TooltipItem
|
|
{
|
|
Name = serie.name,
|
|
Value = FormatAxisValue(slice.Value, null, Mathf.Clamp(serie.labelSettings != null ? serie.labelSettings.decimalPlaces : 2, 0, 8)),
|
|
Color = slice.Color
|
|
});
|
|
|
|
if (string.IsNullOrEmpty(categoryLabel)) categoryLabel = slice.Name;
|
|
|
|
cursorPosition = null;
|
|
hit = true;
|
|
hitSlice = i;
|
|
break;
|
|
}
|
|
|
|
startAngle = endAngle;
|
|
}
|
|
|
|
bool hoverChanged = false;
|
|
if (hitSlice.HasValue)
|
|
{
|
|
if (!_hoverSliceIndex.HasValue || _hoverSliceIndex.Value != hitSlice.Value)
|
|
{
|
|
_hoverSliceIndex = hitSlice.Value;
|
|
hoverChanged = true;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (_hoverSliceIndex != null)
|
|
{
|
|
_hoverSliceIndex = null;
|
|
hoverChanged = true;
|
|
}
|
|
}
|
|
|
|
if (hoverChanged) MarkDirtyRepaint();
|
|
}
|
|
return hit;
|
|
}
|
|
|
|
private static bool IsAngleBetweenCW(float target, float start, float end)
|
|
{
|
|
// Normalize all to 0..360
|
|
float t = Mathf.Repeat(target, 360f);
|
|
float s = Mathf.Repeat(start, 360f);
|
|
float e = Mathf.Repeat(end, 360f);
|
|
|
|
if (e >= s)
|
|
{
|
|
return t >= s && t < e;
|
|
}
|
|
else
|
|
{
|
|
// Crossing 360 boundary (e.g. 350 to 10)
|
|
return t >= s || t < e;
|
|
}
|
|
}
|
|
|
|
private static bool IsAngleBetween(float target, float start, float end, bool clockwise)
|
|
{
|
|
if (clockwise) return IsAngleBetweenCW(target, start, end);
|
|
// If we travel CCW from start to end, it's equivalent to traveling CW from end to start.
|
|
return IsAngleBetweenCW(target, end, start);
|
|
}
|
|
}
|
|
}
|