Skip to content

Instantly share code, notes, and snippets.

@Meorawr
Last active January 28, 2021 00:47
Show Gist options
  • Save Meorawr/1b1e667990e1d051acbf7f4e5b86c46b to your computer and use it in GitHub Desktop.
Save Meorawr/1b1e667990e1d051acbf7f4e5b86c46b to your computer and use it in GitHub Desktop.
Radar Chart Template
local VertexCorner = tInvert({
"TopLeft",
"BottomLeft",
"TopRight",
"BottomRight",
});
local function CreateLinePool(parent, layer, subLayer)
local function CreateLine()
return parent:CreateLine(nil, layer, nil, subLayer);
end
return CreateObjectPool(CreateLine, TexturePool_HideAndClearAnchors);
end
local function GetLineIntersectionCoordinates(S1x, S1y, E1x, E1y, S2x, S2y, E2x, E2y)
local a1 = E1y - S1y;
local b1 = S1x - E1x;
local c1 = (a1 * S1x) + (b1 * S1y);
local a2 = E2y - S2y;
local b2 = S2x - E2x;
local c2 = (a2 * S2x) + (b2 * S2y);
local delta = (a1 * b2) - (a2 * b1);
local x = ((b2 * c1) - (b1 * c2)) / delta;
local y = ((a1 * c2) - (a2 * c1)) / delta;
return x, y;
end
local function GetPolygonVectorCoordinates(point, count)
local angle = (math.pi * 2) * (point - 1);
local x = math.sin(angle / count);
local y = math.cos(angle / count);
return x, y;
end
RadarChartPointMixin = {};
function RadarChartPointMixin:OnHide()
self:SetScript("OnUpdate", nil);
end
function RadarChartPointMixin:OnMouseDown()
self:SetScript("OnUpdate", self.OnUpdate);
end
function RadarChartPointMixin:OnMouseUp()
self:SetScript("OnUpdate", nil);
end
function RadarChartPointMixin:OnUpdate()
local chartFrame = self:GetParent();
local chartScale = chartFrame:GetEffectiveScale();
local chartPoints = chartFrame:GetNumDataPoints();
local chartRadius = chartFrame:GetRadius();
-- This is essentially getting the closest point on a line segment AB
-- from point P, where AB is the segment from the centre of the parent
-- chart frame to the point at the edge of the radius, and P is the
-- mouse cursor.
--
-- At the end, the distance represents a [0-1] floating point value for
-- how far along the line the closest point is, where 0 would mean we're
-- at the center and 1 is at the edge.
local Ax, Ay = chartFrame:GetCenter();
local ABx, ABy = Vector2D_ScaleBy(chartRadius, GetPolygonVectorCoordinates(self:GetID(), chartPoints));
local Px, Py = Vector2D_DivideBy(chartScale, GetCursorPosition());
local APx, APy = Vector2D_Subtract(Px, Py, Ax, Ay);
local magnitude = Vector2D_GetLengthSquared(ABx, ABy);
local product = Vector2D_Dot(APx, APy, ABx, ABy);
local distance = Saturate(product / magnitude);
chartFrame:SetDataPointValue(self:GetID(), distance);
end
RadarChartMixin = {};
RadarChartMixin.LabelAnchors = {
"BOTTOM",
"BOTTOMLEFT",
"LEFT",
"TOPLEFT",
"TOP",
"TOPRIGHT",
"RIGHT",
"BOTTOMRIGHT",
};
function RadarChartMixin:OnLoad()
self.edgeColor = self.edgeColor or CreateColor(0, 0.4, 0.65, 0.75);
self.edgeThickness = self.edgeThickness or 2;
self.fillColor = self.fillColor or CreateColor(0.1647, 0.6353, 1, 0.75);
self.ringColor = self.ringColor or CreateColor(0.51, 0.38, 0.33, 0.5);
self.ringCount = self.ringCount or 5;
self.ringThickness = self.ringThickness or 2;
self.labelDistance = 16;
self.calculatedRadius = 0;
self.dataPoints = self.dataPoints or {};
self.dirtyDataIndex = nil;
self.isDataDirty = false;
self.isRadiusDirty = false;
self.isRingDirty = false;
self.edgePool = CreateLinePool(self, "OVERLAY", 1);
self.fillPool = CreateTexturePool(self, "OVERLAY", 0);
self.labelPool = CreateFontStringPool(self, "OVERLAY", 2, self.labelTemplate or "GameFontHighlightOutline");
self.ringPool = CreateLinePool(self, "ARTWORK");
self.thumbPool = CreateFramePool("Button", self, self.thumbTemplate or "RadarChartPointTemplate");
self.metricsString = self:CreateFontString(nil, nil, self.labelTemplate or "GameFontHighlightOutline");
self:MarkVisualizationDirty();
self:SetDataPoints({
{ text = "Strength", value = 1, color = CreateColor(0.6353, 0.1647, 1, 0.75) },
{ text = "Perception", value = 1 },
{ text = "Endurance", value = 1, color = CreateColor(1, 0.1647, 0.6353, 0.75) },
{ text = "Charisma", value = 1 },
{ text = "Intelligence", value = 1, color = CreateColor(1, 0.6353, 0.1647, 0.75) },
{ text = "Agility", value = 1, color = CreateColor(0.1647, 1, 0.6353, 0.75) },
{ text = "Luck", value = 1, color = CreateColor(0.6353, 1, 0.1647, 0.75) },
})
end
function RadarChartMixin:OnShow()
self:MarkVisualizationDirty();
end
function RadarChartMixin:OnSizeChanged()
self:MarkVisualizationDirty();
end
function RadarChartMixin:OnUpdate()
self:UpdateVisualizationIfNecessary();
end
function RadarChartMixin:AddDataPoint(pointData)
self:InsertDataPoint(#self.dataPoints + 1, pointData);
end
function RadarChartMixin:AddMultipleDataPoints(points)
self:InsertMultipleDataPoints(#self.dataPoints + 1, points);
end
function RadarChartMixin:GetDataPoint(pointIndex)
local point = self.dataPoints[pointIndex];
if not point then
return nil;
end
return {
index = pointIndex,
text = point.text,
value = point.value,
color = point.color and CopyTable(point.color) or nil,
};
end
function RadarChartMixin:GetNumDataPoints()
return #self.dataPoints;
end
function RadarChartMixin:InsertDataPoint(pointIndex, pointData)
assert(pointIndex > 0 and pointIndex <= (#self.dataPoints + 1), "point index out of range");
local point = {
value = Saturate(pointData.value),
color = pointData.color and CopyTable(pointData.color) or nil,
text = tostring(pointData.text),
edge = self.edgePool:Acquire(),
fill = self.fillPool:Acquire(),
label = self.labelPool:Acquire(),
thumb = self.thumbPool:Acquire(),
};
table.insert(self.dataPoints, pointIndex, point);
self:MarkVisualizationDirty();
end
function RadarChartMixin:RemoveDataPoint(pointIndex)
local point = assert(table.remove(self.dataPoints, pointIndex), "point index out of range");
self.edgePool:Release(point.edge);
self.fillPool:Release(point.fill);
self.labelPool:Release(point.label);
self.thumbPool:Release(point.thumb);
self:MarkVisualizationDirty();
end
function RadarChartMixin:RemoveAllDataPoints()
self.dataPoints = {};
self.edgePool:ReleaseAll();
self.fillPool:ReleaseAll();
self.labelPool:ReleaseAll();
self.thumbPool:ReleaseAll();
self:MarkVisualizationDirty();
end
function RadarChartMixin:ReplaceDataPoint(pointIndex, pointData)
local point = assert(self.dataPoints[pointIndex], "point index out of range");
point.value = Saturate(pointData.value);
point.color = pointData.color and CopyTable(pointData.color) or nil;
point.text = tostring(pointData.text);
self:MarkDataPointDirty(pointIndex);
self:MarkRadiusDirty();
end
function RadarChartMixin:InsertMultipleDataPoints(pointIndex, points)
for i, pointData in ipairs(points) do
self:InsertDataPoint(pointIndex + (i - 1), pointData);
end
end
function RadarChartMixin:SetDataPoints(points)
self:RemoveAllDataPoints();
self:AddMultipleDataPoints(points);
end
function RadarChartMixin:SetDataPointColor(pointIndex, pointColor)
local point = assert(self.dataPoints[pointIndex], "point index out of range");
if point.color and pointColor and point.color:IsEqualTo(pointColor) then
return;
end
point.color = pointColor and CopyTable(pointColor) or nil;
self:MarkDataPointDirty(pointIndex);
end
function RadarChartMixin:SetDataPointText(pointIndex, pointText)
local point = assert(self.dataPoints[pointIndex], "point index out of range");
if point.text == pointText then
return;
end
point.text = tostring(pointText);
self:MarkDataPointDirty(pointIndex);
self:MarkRadiusDirty();
end
function RadarChartMixin:SetDataPointValue(pointIndex, pointValue)
local point = assert(self.dataPoints[pointIndex], "point index out of range");
if point.value == pointValue then
return;
end
point.value = Saturate(pointValue);
self:MarkDataPointDirty(pointIndex);
end
function RadarChartMixin:GetEdgeColor()
return CopyTable(self.edgeColor);
end
function RadarChartMixin:SetEdgeColor(edgeColor)
self.edgeColor = CopyTable(edgeColor);
self:MarkDataDirty();
end
function RadarChartMixin:GetEdgeThickness()
return self.edgeThickness;
end
function RadarChartMixin:SetEdgeThickness(edgeThickness)
self.edgeThickness = tonumber(edgeThickness) or 2;
self:MarkDataDirty();
end
function RadarChartMixin:GetFillColor()
return CopyTable(self.fillColor);
end
function RadarChartMixin:SetFillColor(fillColor)
self.fillColor = CopyTable(fillColor);
self:MarkDataDirty();
end
function RadarChartMixin:GetLabelDistance()
return self.labelDistance;
end
function RadarChartMixin:SetLabelDistance(labelDistance)
self.labelDistance = tonumber(labelDistance) or 16;
self:MarkRadiusDirty();
end
function RadarChartMixin:GetRingColor()
return CopyTable(self.ringColor);
end
function RadarChartMixin:SetRingColor(ringColor)
self.ringColor = CopyTable(ringColor);
self:MarkRingDirty();
end
function RadarChartMixin:GetRingThickness()
return self.ringThickness;
end
function RadarChartMixin:SetRingThickness(ringThickness)
self.ringThickness = tonumber(ringThickness) or 2;
self:MarkRingDirty();
end
function RadarChartMixin:GetRingCount()
return self.ringCount;
end
function RadarChartMixin:SetRingCount(ringCount)
self.ringCount = tonumber(ringCount) or 5;
self:MarkRingDirty();
end
function RadarChartMixin:MarkDataDirty()
self.dirtyDataIndex = nil;
self.isDataDirty = true;
end
function RadarChartMixin:MarkDataPointDirty(pointIndex)
if not self.isDataDirty and not self.dirtyDataIndex then
self.dirtyDataIndex = pointIndex;
else
-- Another point has already been marked as dirty.
self:MarkDataDirty();
end
end
function RadarChartMixin:MarkRadiusDirty()
-- Marking the radius as dirty is the same as marking the whole
-- visualization dirty, since everything relies upon it.
self.isRadiusDirty = true;
self.dirtyDataIndex = nil;
self.isDataDirty = true;
self.isRingDirty = true;
end
function RadarChartMixin:MarkRingDirty()
self.isRingDirty = true;
end
function RadarChartMixin:MarkVisualizationDirty()
self:MarkDataDirty();
self:MarkRadiusDirty();
self:MarkRingDirty();
end
function RadarChartMixin:GetRadius()
if self.isRadiusDirty then
local w, h = self:GetSize();
w, h = Vector2D_DivideBy(2, w, h);
w, h = Vector2D_Subtract(w, h, self:CalculateLargestLabelSize());
w, h = Vector2D_Subtract(w, h, self.labelDistance, self.labelDistance);
self.calculatedRadius = math.min(w, h);
end
self.isRadiusDirty = false;
return self.calculatedRadius;
end
function RadarChartMixin:UpdateVisualization()
self:UpdateRingVisualization();
self:UpdateAllPointVisualizations();
end
function RadarChartMixin:UpdateVisualizationIfNecessary()
if self.isRingDirty then
self:UpdateRingVisualization();
end
if self.isDataDirty then
self:UpdateAllPointVisualizations();
elseif self.dirtyDataIndex then
self:UpdatePointVisualization(self.dirtyDataIndex);
end
end
function RadarChartMixin:UpdateRingVisualization()
local pointCount = #self.dataPoints;
local radius = self:GetRadius();
local ringColor = self.ringColor;
local ringCount = self.ringCount;
local ringThickness = self.ringThickness;
self.ringPool:ReleaseAll();
self.isRingDirty = false;
--
-- Center-to-Point Lines
--
for pointIndex = 1, pointCount do
local Ax, Ay = GetPolygonVectorCoordinates(pointIndex, pointCount);
local line = self.ringPool:Acquire();
line:SetStartPoint("CENTER");
line:SetEndPoint("CENTER", Vector2D_ScaleBy(radius, Ax, Ay));
line:SetColorTexture(ringColor:GetRGBA());
line:SetThickness(ringThickness);
line:Show();
end
--
-- Point-to-Point Lines
--
for ringIndex = 1, ringCount do
local distance = ringIndex / ringCount;
for pointIndex = 1, pointCount do
local nextIndex = Wrap(pointIndex + 1, pointCount);
local Ax, Ay = GetPolygonVectorCoordinates(pointIndex, pointCount);
local Bx, By = GetPolygonVectorCoordinates(nextIndex, pointCount);
local ring = self.ringPool:Acquire();
ring:SetStartPoint("CENTER", Vector2D_ScaleBy(radius * distance, Ax, Ay));
ring:SetEndPoint("CENTER", Vector2D_ScaleBy(radius * distance, Bx, By));
ring:SetColorTexture(ringColor:GetRGBA());
ring:SetThickness(ringThickness);
ring:Show();
end
end
end
function RadarChartMixin:UpdateAllPointVisualizations()
self.isDataDirty = false;
for pointIndex = 1, #self.dataPoints do
self:UpdateSinglePointVisualization(pointIndex);
end
end
function RadarChartMixin:UpdatePointVisualization(pointIndex)
local pointCount = #self.dataPoints;
local prevIndex = Wrap(pointIndex - 1, pointCount);
local nextIndex = Wrap(pointIndex + 1, pointCount);
self:UpdateSinglePointVisualization(prevIndex);
self:UpdateSinglePointVisualization(pointIndex);
self:UpdateSinglePointVisualization(nextIndex);
end
function RadarChartMixin:UpdateSinglePointVisualization(pointIndex)
local pointCount = #self.dataPoints;
local point = self.dataPoints[pointIndex];
local prevIndex = Wrap(pointIndex - 1, pointCount);
local prevPoint = self.dataPoints[prevIndex];
local nextIndex = Wrap(pointIndex + 1, pointCount);
local nextPoint = self.dataPoints[nextIndex];
local radius = self:GetRadius();
if self.dirtyDataIndex == pointIndex then
self.dirtyDataIndex = nil;
end
--
-- Coordinate Calculations
--
-- A/B/C represent the vectors for the previous, current, and next
-- points respectively. The position of these vectors is relative to
-- the center of the chart, and represents the outermost edge for the
-- data point - or the border, if you want to think of it that way.
local Ax, Ay = GetPolygonVectorCoordinates(prevIndex, pointCount);
local Bx, By = GetPolygonVectorCoordinates(pointIndex, pointCount);
local Cx, Cy = GetPolygonVectorCoordinates(nextIndex, pointCount);
-- AV/BV/CV are the same as A/B/C except these represent the positions
-- of the actual value for this data point - and so will lie between the
-- center and their respective A/B/C value above.
local AVx, AVy = Vector2D_ScaleBy(prevPoint.value, Ax, Ay);
local BVx, BVy = Vector2D_ScaleBy(point.value, Bx, By);
local CVx, CVy = Vector2D_ScaleBy(nextPoint.value, Cx, Cy);
-- AB/BC are imaginary half points between vectors A/B and B/C
-- respectively, as if we had twice as many points as we do now. Their
-- location will be outside the circumcircle of our own polygon, but we
-- don't care as this is later used for a line-line intersection.
local ABx, ABy = GetPolygonVectorCoordinates((pointIndex - 1) * 2, pointCount * 2);
local BCx, BCy = GetPolygonVectorCoordinates(pointIndex * 2, pointCount * 2);
-- D/E represent the point at which the following lines intersect:
--
-- Dx, Dy = Intersect(Line(Zero, AB), Line(AV, BV))
-- Ex, Ey = Intersect(Line(Zero, BC), Line(BV, CV))
--
-- This is primarily used for fill coloring.
local Dx, Dy = GetLineIntersectionCoordinates(0, 0, ABx, ABy, AVx, AVy, BVx, BVy);
local Ex, Ey = GetLineIntersectionCoordinates(0, 0, BCx, BCy, BVx, BVy, CVx, CVy);
--
-- Fill Region
--
do
local fill = point.fill;
local color = point.color or self.fillColor;
-- Our approach to rendering is to use a quad with vertices offset
-- to our desired coordinates.
--
-- The vertices need a constant offset applied based on their corner
-- in order to reset them to a (0, 0) location initially, which would
-- represent the center of the chart.
local TLx, TLy = Vector2D_Add( 1, -1, Dx, Dy);
local TRx, TRy = Vector2D_Add(-1, -1, BVx, BVy);
local BRx, BRy = Vector2D_Add(-1, 1, Ex, Ey);
local BLx, BLy = Vector2D_Add( 1, 1, 0, 0);
fill:ClearAllPoints();
fill:SetColorTexture(color:GetRGBA());
fill:SetPoint("CENTER");
fill:SetSize(radius * 2, radius * 2);
fill:SetVertexOffset(VertexCorner.TopLeft, Vector2D_ScaleBy(radius, TLx, TLy));
fill:SetVertexOffset(VertexCorner.TopRight, Vector2D_ScaleBy(radius, TRx, TRy));
fill:SetVertexOffset(VertexCorner.BottomRight, Vector2D_ScaleBy(radius, BRx, BRy));
fill:SetVertexOffset(VertexCorner.BottomLeft, Vector2D_ScaleBy(radius, BLx, BLy));
fill:Show();
end
--
-- Edge Region
--
do
local edge = point.edge;
local color = self.edgeColor;
local thickness = self.edgeThickness;
edge:SetStartPoint("CENTER", Vector2D_ScaleBy(radius, BVx, BVy));
edge:SetEndPoint("CENTER", Vector2D_ScaleBy(radius, CVx, CVy));
edge:SetColorTexture(color:GetRGBA());
edge:SetThickness(thickness);
edge:Show();
end
--
-- Label Region
--
do
local label = point.label;
local anchor = self:GetAnchorForPointLabel(pointIndex);
local text = point.text;
local fudge = 1 + (self.labelDistance / radius);
label:ClearAllPoints();
label:SetPoint(anchor, self, "CENTER", Vector2D_ScaleBy(radius * fudge, Bx, By));
label:SetText(text);
label:Show();
end
--
-- Thumb Region
--
do
local thumb = point.thumb;
thumb:ClearAllPoints();
thumb:SetPoint("CENTER", Vector2D_ScaleBy(radius, BVx, BVy));
thumb:SetID(pointIndex);
thumb:Show();
end
end
function RadarChartMixin:CalculateLargestLabelSize()
local metricsString = self.metricsString;
local maxWidth = 0;
local maxHeight = 0;
for _, point in ipairs(self.dataPoints) do
metricsString:SetText(point.text);
local width = metricsString:GetWrappedWidth();
local height = metricsString:GetHeight();
maxWidth = math.max(width, maxWidth);
maxHeight = math.max(height, maxHeight);
end
return maxWidth, maxHeight;
end
function RadarChartMixin:GetAnchorForPointLabel(pointIndex)
local pointCount = #self.dataPoints;
local anchorCount = #self.LabelAnchors;
local anchorIndex = math.floor(((pointIndex - 1) * (anchorCount / pointCount))) + 1;
return self.LabelAnchors[anchorIndex];
end
<Ui xmlns="http://www.blizzard.com/wow/ui/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.blizzard.com/wow/ui/">
<Include file="RadarChart.lua"/>
<Frame name="RadarChartPointTemplate" virtual="true" mixin="RadarChartPointMixin" enableMouse="true">
<Size x="16" y="16"/>
<Layers>
<Layer level="ARTWORK">
<Texture parentKey="Icon" atlas="playerpartyblip" setAllPoints="true"/>
</Layer>
</Layers>
<Scripts>
<OnMouseDown method="OnMouseDown"/>
<OnMouseUp method="OnMouseUp"/>
<OnHide method="OnHide"/>
</Scripts>
</Frame>
<Frame name="RadarChartTemplate" virtual="true" mixin="RadarChartMixin">
<Scripts>
<OnLoad method="OnLoad"/>
<OnShow method="UpdateVisualization"/>
<OnSizeChanged method="UpdateVisualization"/>
</Scripts>
</Frame>
<Frame name="ChartTest" parent="UIParent" toplevel="true" enableMouse="true">
<Size x="459" y="707"/>
<Anchors>
<Anchor point="CENTER"/>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture atlas="questbg-oribos" useAtlasSize="true"/>
</Layer>
</Layers>
<Frames>
<Frame parentKey="Chart" inherits="RadarChartTemplate" setAllPoints="true"/>
</Frames>
</Frame>
</Ui>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment