Graph Module- Easily draw graphs of your data

Graph Module

by boatbomber

I was working on a benchmarking plugin tool (coming soon™) and needed to draw graphs to visualize the results. I decided that the graph module would be useful enough on its own to merit an open source release, so I made it more user friendly and this is the result.

This module is designed to be easy to use while still being a complex tool. It was a hard balance to find. Utilizing metatables, it will automatically render your graph whenever you change the data or settings, so that you don’t have to concern yourself with making sure you’ve rendered the latest set.

It’s still very early in its development, as I’ve only worked on this for one afternoon, but I figure that open sourcing this first stable build will be beneficial to everyone since we can improve upon it together.


API:

function Graph.new(Frame)

Frame is a GUI Instance that the graph will display inside of

returns GraphHandler


function GraphHandler.Theme(ThemeData)

ThemeData is a dictionary that contains the following:

  • Name - Must be “Dark” or “Light” so it knows what kind of colors to use for the lines
  • Background - A Color3 that will be behind the graph lines
  • LightBackground - A Color3 that will be behind the sidebar background
  • Text - A Color3 that will be the Y axis marker text color

returns void

GraphHandler.Data

The most important property of all- this is the dictionary that is used to draw the lines.
It must be a dictionary of arrays with no holes. Arrays should never have fewer than 3 values.

Example:
GraphHandler.Data = { -- This will draw two lines named "LineA" and "LineB" that cross in the center
	LineA = {0,1,2,3,4,5,6,7,8,9};
	LineB = {9,8,7,6,5,4,3,2,1,0};
}
GraphHandler.Resolution

A number that defines how many points will be displayed on the graph. Best kept around 75-150. Never set this above the number of points in your Data.

GraphHandler.BaselineZero

A boolean that defines whether the bottom of the graph starts at zero. If true, the graph starts at zero and ends a bit above the max value displayed. If false, the graph bottom will start at the lowest displayed value and “zoom in” to the relevant range.


Sample:

Here’s a sample usage that draws all the various easing styles for us:

local Graph = require(script.Graph)
local TweenService = game:GetService("TweenService")

local Data = {}

for _,Style in pairs(Enum.EasingStyle:GetEnumItems()) do
	local LineData = table.create(100)
	
	for i=1, 100 do
		LineData[i] = TweenService:GetValue(i/100, Style, Enum.EasingDirection.In)
	end
	
	Data[Style.Name] = LineData
	
	wait() -- Prevent studio from freezing
end

-- Create the graph and give it the data
local GraphHandler = Graph.new(script.Parent.Frame)
GraphHandler.Resolution = 100
GraphHandler.Data = Data


Here’s what I’m using this module for!



Source:

Code
--[=[

Docs: https://devforum.roblox.com/t/graph-module-easily-draw-graphs-of-your-data/828982

API:

function Graph.new(Frame)
	returns a GraphHandler
	
GraphHandler.Resolution = The number of points it renders
GraphHandler.BaselineZero = Whether the bottom of the graph should start at zero (or at the minimum value)
GraphHandler.Data = The dictionary of data sets
	(Data must be a dictionary of arrays with no holes)
	
function GraphHandler.Theme(ThemeDictionary)
	Updates the Colors of the graph

--]=]

local TextService = game:GetService("TextService")

local Graph = {}

local Theme = {
	Name = "Dark";
	
	Background = Color3.fromRGB(35,35,40);
	LightBackground = Color3.fromRGB(45,45,50);
	Text = Color3.fromRGB(220,220,230)
}
local isDark = true

local function getKeyColor(name)
	-- Shoutout to Vocksel for the core of this function
	
	local seed = 0
	for i=1, #name do
		seed = seed + (name:byte(i))
	end
	local rng = Random.new(seed)
	local hue = rng:NextInteger(0,50)/50

	return Color3.fromHSV(hue, isDark and 0.63 or 1, isDark and 0.84 or 0.8)
end


function Graph.new(Frame)
	if not Frame then error("Must give graph a frame") end
	local GraphHandler = {Frame = Frame; Resolution = 75;}
	
	-- Private variables
	local Busy = false
	
	-- Create the GUIs

	local Background = Instance.new("Frame")
	Background.Name = "Background"
	Background.BackgroundColor3 = Theme.Background
	Background.Size = UDim2.new(1,0,1,0)
	Background.Parent = GraphHandler.Frame

	local MarkerBG = Instance.new("Frame")
	MarkerBG.Name = "MarkerBackground"
	MarkerBG.Size = UDim2.new(0.1,0,1,0)
	MarkerBG.BackgroundColor3 = Theme.LightBackground
	MarkerBG.BorderSizePixel = 0
	MarkerBG.ZIndex = 1
	MarkerBG.Parent = GraphHandler.Frame

	local YMarkers = Instance.new("Frame")
	YMarkers.Name = "Markers"
	YMarkers.Size = UDim2.new(0.1,0,0.85,0)
	YMarkers.Position = UDim2.new(0,0,0.15,0)
	YMarkers.BackgroundTransparency = 1
	YMarkers.BorderSizePixel = 0
	YMarkers.ZIndex = 2
	YMarkers.Parent = GraphHandler.Frame

	local GraphingFrame = Instance.new("Frame")
	GraphingFrame.Name = "GraphingFrame"
	GraphingFrame.Size = UDim2.new(0.9,0,0.85,0)
	GraphingFrame.Position = UDim2.new(0.1,0,0.15,0)
	GraphingFrame.BackgroundTransparency = 1
	GraphingFrame.ZIndex = 4
	GraphingFrame.Parent = GraphHandler.Frame
	
	local KeyNames = Instance.new("Frame")
	KeyNames.Name = "KeyNames"
	KeyNames.Size = UDim2.new(1,0,0.1,0)
	KeyNames.Position = UDim2.new(0,0,0,0)
	KeyNames.BackgroundColor3 = Theme.LightBackground
	KeyNames.BorderSizePixel = 0
	KeyNames.ZIndex = 4
	KeyNames.Parent = GraphHandler.Frame
	
	-- Rerender if the frame changes size since our lines will be all wonky
	GraphHandler.Frame:GetPropertyChangedSignal("AbsoluteSize"):Connect(function()
		local Size = GraphHandler.Frame.AbsoluteSize
		wait(0.04)
		if Size == GraphHandler.Frame.AbsoluteSize then
			GraphHandler.Render()
		end
	end)
	
	function GraphHandler.Theme(newTheme)
		wait()
		-- Make sure we have latest theme data
		Theme = {
			Name = newTheme.Name or "Dark";

			Background = newTheme.Background or Color3.fromRGB(46,46,46);
			LightBackground = newTheme.LightBackground or Color3.fromRGB(70,70,70);
			Text = newTheme.Text or Color3.fromRGB(220,220,230);
		}
		
		-- Update GUIs
		Background.BackgroundColor3 = Theme.Background
		MarkerBG.BackgroundColor3 = Theme.LightBackground
		KeyNames.BackgroundColor3 = Theme.LightBackground
		
		-- Redraw graph with new colors
		GraphHandler.Render()
	end
	
	function GraphHandler.Render()
		-- Validate we have stuff to render
		if not GraphHandler.Frame or not GraphHandler.Data or not GraphHandler.Resolution then
			return
		end
		
		while Busy do wait(0.1) end
		Busy = true
		
		-- Clear old graph values
		YMarkers:ClearAllChildren()
		GraphingFrame:ClearAllChildren()
		KeyNames:ClearAllChildren()
		
		local KeyLayout = Instance.new("UIListLayout")
		KeyLayout.FillDirection = Enum.FillDirection.Horizontal
		KeyLayout.HorizontalAlignment = Enum.HorizontalAlignment.Left
		KeyLayout.VerticalAlignment = Enum.VerticalAlignment.Center
		KeyLayout.Padding = UDim.new(0.01,0)
		KeyLayout.Parent = KeyNames
		
		local Max, Min = -math.huge,math.huge
		local Range
		
		-- Calculate our range of values

		for Key, Set in pairs(GraphHandler.Data) do
			local SetAmount = #Set

			for i=1,SetAmount, math.ceil(SetAmount/GraphHandler.Resolution) do
				local SortedChunk = {}
				for x=i,i+math.ceil(SetAmount/GraphHandler.Resolution) do
					SortedChunk[#SortedChunk+1] = Set[x]
				end
				table.sort(SortedChunk)

				local Value = SortedChunk[math.round(#SortedChunk*0.55)]
				if not Value then continue end

				-- Record for our range calc
				Min = math.min(Min, Value)
				Max = math.max(Max, Value)
			end
		end
		
		if GraphHandler.BaselineZero then
			Min = 0
			Max = Max * 1.75
		end

		Range = Max-Min
		
		-- Mark our Y axis values along the derived range
		
		for y=0,1,0.2 do
			local Marker = Instance.new("TextLabel")
			Marker.Name = y
			Marker.Size = UDim2.new(1,0,0.08,0)
			Marker.AnchorPoint = Vector2.new(0,0.5)
			Marker.Position = UDim2.new(0,0,0.9 - (y*0.9),0)
			Marker.Text = string.format("%.2f  ",(Min + (Range*y)))
			Marker.TextXAlignment = Enum.TextXAlignment.Right

			Marker.TextColor3 = Theme.Text
			Marker.Font = Enum.Font.SourceSans
			Marker.BackgroundTransparency = 1
			Marker.TextSize = (GraphHandler.Frame.AbsoluteSize.X*0.03)
			Marker.ZIndex = 6
			Marker.Parent = YMarkers
		end
		
		-- Draw the graph at this range
		local KeyColors = {}
		for Key, Set in pairs(GraphHandler.Data) do
			-- Designate a color for this dataset
			KeyColors[Key] = getKeyColor(Key)
			
			local TextSize = GraphHandler.Frame.AbsoluteSize.Y*0.08
			local Size = TextService:GetTextSize(Key, TextSize, Enum.Font.SourceSansSemibold, KeyNames.AbsoluteSize)
			local KeyMarker = Instance.new("TextLabel")
			KeyMarker.Text = Key
			KeyMarker.TextColor3 = KeyColors[Key]
			KeyMarker.Font = Enum.Font.SourceSansSemibold
			KeyMarker.BackgroundTransparency = 1
			KeyMarker.TextSize = TextSize
			KeyMarker.Size = UDim2.new(0,Size.X+TextSize,1,0)
			KeyMarker.Parent = KeyNames
			
			-- Graph the set

			local SetAmount = #Set
			local LastPoint

			--print("  "..Key, Set)

			for i=1,SetAmount, math.ceil(SetAmount/GraphHandler.Resolution) do

				local SortedChunk = {}
				for x=i,i+math.ceil(SetAmount/GraphHandler.Resolution) do
					SortedChunk[#SortedChunk+1] = Set[x]
				end
				table.sort(SortedChunk)

				local Value = SortedChunk[math.round(#SortedChunk*0.55)]
				if not Value then continue end

				-- Create the point
				local Point = Instance.new("ImageLabel")
				Point.Name = Key..i
				Point.Position = UDim2.new(0.05+((i/SetAmount)*0.9),0, 0.9 - (((Value-Min)/Range)*0.9),0)
				Point.AnchorPoint = Vector2.new(0.5,0.5)
				Point.SizeConstraint = Enum.SizeConstraint.RelativeXX
				Point.Size = UDim2.new(math.clamp(0.5/GraphHandler.Resolution, 0.003,0.016),0,math.clamp(0.5/GraphHandler.Resolution, 0.003,0.016),0)

				Point.ImageColor3 = KeyColors[Key]
				Point.BorderSizePixel = 0
				Point.BackgroundTransparency = 1
				Point.Image = "rbxassetid://200182847"
				Point.ZIndex = 15

				local Label = Instance.new("TextLabel")
				Label.Visible = false
				Label.Text = string.format("%.7f",Value)
				Label.BackgroundColor3 = Theme.LightBackground
				Label.TextColor3 = Theme.Text
				Label.Position = UDim2.new(1,0,0.4,0)
				Label.Font = Enum.Font.Code
				Label.TextSize = (GraphHandler.Frame.AbsoluteSize.X*0.025)
				Label.Size = UDim2.new(0,Label.TextSize * 0.6 * #Label.Text,0,Label.TextSize * 1.1)
				Label.Parent = Point
				Label.ZIndex = 20

				Point.MouseEnter:Connect(function()
					Label.Visible = true
				end)
				Point.MouseLeave:Connect(function()
					Label.Visible = false
				end)

				-- Create the line
				if LastPoint then
					local Connector = Instance.new("Frame")
					Connector.Name = Key..i.."-"..i-1
					Connector.BackgroundColor3 = KeyColors[Key]
					Connector.BorderSizePixel = 0
					Connector.SizeConstraint = Enum.SizeConstraint.RelativeXX
					Connector.AnchorPoint = Vector2.new(0.5, 0.5)

					local Size = GraphingFrame.AbsoluteSize
					local startX, startY = Point.Position.X.Scale*Size.X, Point.Position.Y.Scale*Size.Y
					local endX, endY = LastPoint.Position.X.Scale*Size.X, LastPoint.Position.Y.Scale*Size.Y

					local Distance = (Vector2.new(startX, startY) - Vector2.new(endX, endY)).Magnitude

					Connector.Size = UDim2.new(0, Distance, math.clamp(0.2/GraphHandler.Resolution, 0.002,0.0035), 0)
					Connector.Position = UDim2.new(0, (startX + endX) / 2, 0, (startY + endY) / 2)
					Connector.Rotation = math.atan2(endY - startY, endX - startX) * (180 / math.pi)

					Connector.Parent = GraphingFrame
				end

				LastPoint = Point
				Point.Parent = GraphingFrame

			end

		end
		
		Busy = false
	end
	
	return setmetatable({}, {
		__index = function(t,Key)
			return GraphHandler[Key]
		end;
		__newindex = function(t, Key, Value)
			if Key == "Data" and type(Value) == "table" then
				GraphHandler.Data = Value
				GraphHandler.Render()
			elseif Key == "Resolution" and type(Value) == "number" then
				GraphHandler.Resolution = math.clamp(Value, 3, 500)
				GraphHandler.Render()
			elseif Key == "BaselineZero" and type(Value) == "boolean" then
				GraphHandler.BaselineZero = Value
				GraphHandler.Render()
			end
		end;
	})
	
end


return Graph

GitHub:

Model:

Uncopylocked Demo Place:




Enjoying my work? I love to create and share with the community, for free.

If you’d like to help fund my work, consider sponsoring me on GitHub/Patreon or donating on BuyMeACoffee/PayPal!

274 Likes

I’m a little confused, what can I use this for?

5 Likes

You can use this for comparing between 2 graphs or more and do other stuff with it. I did get confused at first until i edited his place where he used the graph module

I know but I mean what scenarios could I use this for?

It can be used for comparing user stats history,Compare someone (e.g: Pewdipie vs T-Series sub count) and ect

8 Likes

That’ll definitely be handy, i bet. Thanks for making/sharing.

1 Like

You could graph out total game history for ex. Player count in past coulpe hours/days/months.
Or, you could also use it for a custom in-game marketplace for example, showing how price of different items has changed in past hours/days/months etc
There are plenty of uses.

4 Likes

That makes a lot more sense, especially in the cultural scope too

Thank you so much for this! I’ve been looking for a decent graphing module for awhile and ended up having to make my own a few days ago. Yours looks like a much better improvement to my tiny lines! :grin:

Can’t wait to use it.

4 Likes

Woow! Thanks man! I might make a tutorial on this :slight_smile:

2 Likes

The instant usage that came up in my mind was to combine this with KNN for better analysis of the training because i had a plain orange bargraph that awkwardly shows the progress back then XD Nice work!

2 Likes

Using roblox-ts and want to use this module? Check out this npm package: @rbxts/graph - npm

8 Likes

Updated this module to properly support global ZIndex behavior. Thank you to @TheNickmaster21 for finding this and opening an issue on the GitHub!

4 Likes

Thanks for this awesome module.
I created a graph calculator using this :sweat_smile:
I hope you like it man!

13 Likes

I love it! Thanks for sharing this with me!

3 Likes

Out of interest, how do you deal with the huge amounts of frames? Do you reuse them, and if so how? (i.e. Re-parenting them, changing transparency, changing the ZIndex etc.)

Nope, it completely destroys and recreates them every re-render.

2 Likes

This is really helpful, I’ve written some 1D compressible flow simulation for some coursework and can use this to make nice graphs! Is there anyway to get a numbered X axis like you have inside your benchmarker plugin?

5 Likes

Glad to hear you’re finding a nice use for this.

The Benchmarker plugin uses the core of this module, but very heavily altered for its specific use.

This module takes an array of size N and displays it across the whole X axis. It essentially treats the X axis as 0 - N, so you would need to define your “real” X axis values specifically. It might be easier to just write the X axis values under the graph from the script that calls the graph, since that layer knows the real X values.

2 Likes

Great resource! The simplistic and modern design makes it easy-to-use and it fits well with most projects.

My personal usecase:

7 Likes