Graphing Time Series Data through Unity
Using a graph is one of the best ways to visualize and present data for users because it allows them to more easily identify trends, abnormalities, areas of concern, and just generally better comprehend the data they’re working with. If that data involves any type of variation over time, like a health application tracking users’ progress or a game allowing users to track their economies, then most likely a line graph will be your best bet for displaying those changes in information.
Like the x and y-axis of the Move gizmo in Unity, a line graph is made up of both a x and y-axis representing the horizontal and vertical areas on the graph, respectively, and when plotting time series data, time is typically represented across the x-axis while numeric values are represented on the y. For this example, we’re going to be working with data that’s compiled over the course of a single day, but the same principle can be applied if working across multiple days, months, and years.
Setting Up The Graph
To piece the graph together in Unity, the first steps involve setting up the area for the data to be displayed and positioning a few objects that the graph will be utilizing. Note that this graph will be displayed within the UI so start by right clicking in the Hierarchy and going to UI -> Canvas. The Canvas will house all the other objects for the graph and in order to maintain relative scaling across different screen sizes, go down to the Canvas Scaler component and select “Scale With Screen Size” for UI Scale Mode.
The first UI item that will be childed within the Canvas is an image to act as the graph’s background, the GraphWindow. Instead of a sprite, choose a color to help differentiate this portion of the graph and then set the anchor to horizontal and vertical stretch. The graph will be the only thing showing in this Canvas so the left, right, top, and bottom of the Rect Transform is set to zero.
Next, as a child to the GraphWindow, create another UI image that will serve as the actual container for the graphed data, the GraphContainer. Make the GraphContainer slightly smaller than the GraphWindow so that a border is created to allow room for the x and y-axis labels. With this object and all the remaining ones, the anchor point will be set to the bottom left in order to maintain accurate positioning, but like the GraphWindow, select another color to act as the background for this Image.
Then, as children to the GraphContainer, create four UI text objects that will be the labels (XAxisLabel / YAxisLabel) and data position markers (TempValueX / TempValueY) for each axis. The design, alignment, and layout of these is based on personal preference, but make sure the axis labels are shown far enough off the GraphContainer to allow plenty of room for the data markers as they will populate along the full length of each axis.
Create the next two child objects of the GraphContainer as UI images and these will be used to visualize the graph’s horizontal (TempGridlineX) and vertical (TempGridlineY) gridlines. Note: these have been renamed from TempDashX and TempLineY and also aren’t required, but they do allow data points to be more clearly understood. Unlike the other images, these two will use a source image instead of just a background. The image I’ve chosen for the horizontal gridlines resembles a dash while the image for the vertical gridlines is a simple line, again, just personal preference. After importing the selected images, set the Texture Type to “Sprite 2D and UI”, and if going for a dash, half of the image will need to be transparent with the Wrap Mode for the sprite set to “Repeat”. Also, change the Image Type within the Image component to “Tiled” so that the image will be tiled on top of itself like a dash. The default Wrap Mode and Image Type for a line image can be used.
It will take a little finagling to get the images set up correctly in the GraphContainer with how you’d like them to be shown, but once they are, line one of each across their respective axes so that they cover the entire length or width of the GraphContainer. These images will act as a template for the other gridlines which will be generated at run time based on the range of the data set.
Finally, the last graph object to import will be whatever image you would like to use to represent the plotted data points. The Texture Type for this image will also need to be set as a “Sprite (2D and UI)”, but it will not be attached to it’s own game object in the scene. Instead, it will be cached as a sprite variable within the code.
Getting to the Code (Beware of Oncoming Math!)
The graph will simulate pulling data from a key-value database like AWS DynamoDB where each entry is stored as a collection of key-value pairs with the primary key serving as a unique identifier. That primary key may also then be broken down into a partition key and a sort key. To keep it simple, the partition key may be nothing more than a username or email address to partition each entry while a sort key may be the date and time for when that entry occurred, like the data point time stamps used in this graph. Those date and time values are stored as strings in the database and must first be converted to DateTime values in order to display them. However, the numerical values are stored as ints and will not need to be converted.
The functionality for this graph is broken down between one LineGraph class inheriting from MonoBehavior and three custom classes: DataPoint, XAxis, and YAxis. The DataPoint class is included within the LineGraph script and only includes a string timeStamp value and int recordedValue while the XAxis and YAxis classes are each contained within their own scripts. Each of the custom classes also only include variables specific to those items (data points, x-axis, and y-axis), but this was mainly done for organization.
In LineGraph, there are roughly eight primary methods which have been broken out in order to build the graph: 1) CompileGraphData, 2) OrderDataByAscending, 3) GraphDataSeries, 4) SetXAxisMinMax, 5) SetYAxisMinMax, 6) PlotXAxisLabels, 7) PlotYAxisLabels, and 8) PlotDataPoints. Two other return-type methods, CreateDataPoint and CreateDataConnector, return game objects to display each data point and connecting line on the graph.
1: CompileGraphData(DateTime selectedDate)
This method is originally called in OnEnable(), takes a DateTime parameter, and is responsible for filtering through all the available data to populate a separate DataPoint list with data that matches the selected date. Although in OnEnable(), this method can also be linked to a date picker button to allow the user to select which data to show. A foreach loop is used to loop through the data so that DateTime.Parse() can be used on each individual entry to convert its date and time string as mentioned before into a DateTime value. That value is then compared against the selected date, and if they match, the data point is added to the list of items to graph. Once the foreach loop finishes, the next method is called to further organize the data.
2: OrderDataByAscending(List<DataPoint> dataToGraph)
Displaying information according to when an even occurred is the entire point of using time series data so to correctly order the data based on its DateTime value, LINQ’s OrderByDecending() is used. OrderByDecending() orders the values from the largest to the smallest, but the more recent DateTime entries will have a larger value so they will be displayed first in the graph. To avoid this, at the end of OrderByDecending() use .Reverse() and that will reorder the data from smallest to largest, or oldest to most recent. Finally, .ToList() is used to re-store that data as a list to be passed into the next method.
3: GraphDataSeries(List<DataPoint> dataSeries)
This method is fairly straight forward and acts as a container for each of the remaining functions, but it is responsible for cleaning the list of graphed objects before new objects are added as part of a new data set. These objects include the labels and gridlines for each axis as well as the visual data points and the lines connecting them.
With this graph displaying data over the course of a 24-hour period, I’ve split it up into seven time periods from midnight to midnight with four hours between each period:
Since the x-axis holds the time values, the minimum x values is set to _timePeriod representing midnight, and the maximum x value is set to the second to last period, _timePeriod. Unfortunately, using _timePeriod.Length will not work here as the maximum because it will interpret the midnight value as the exact same value used for the minimum. By using the second to last value (5) and adding minutes to it, the full 24 hours will be recognized. Also, this method may be called in OnEnable() as well because the time periods are fixed meaning that the min and max values will not be changing.
5: SetYAxisMinMax(List<DataPoint> dataSeries)
Setting the minimum and maximum values for the y-axis is much more dynamic and must be called each time the graph is compiled since this is the axis representing the numeric value of each data point. The first check within this method is to see if there is any data to plot, and if there isn’t the min and max y values are set to predefined default values. If there is data available, then the min and max values are reset to zero, and a for loop runs to determine if the current data point value is less than the minimum or greater than the maximum. If it is, then min or max is set to that current value.
The range between the min and max values is also calculated to help determine the positioning of labels later in the code, but if there is no range, meaning all the values are the same, then the range is given a default value of one.
After that, a buffer using the range is applied to both min and max values so that those values aren’t shown directly on the edge of the graph. These two values also don’t impact the data itself and are just used to evenly position the labels and gridlines.
Plotting the labels, gridlines, and data points begins to get much more involved and complicated as you need to convert a specific time value within a specific time span into a position based on the length, width, and buffer of a virtual transform in Unity. Like with setting the min and max y values, when plotting the x-axis labels, start by resetting the min and max label position to zero as well as the label index. The label index will be used to track the number of labels currently instantiated in the graph and the total number of labels to use will be set to the previous _timePeriods.Length.
Next, a for loop will be initiated which runs as many times as the label count. Within the for loop, distance between each label is set by dividing the width of the graph by the sum of the label count and axis buffer. That distance is then multiplied by the index and added to itself to provide the correct positioning for the label.
If the for loop is on its first or last iteration, then the current label position calculated above is set to the min and max label position for the axis, respectively.
Finally, the TempValueX and TempGridlineX objects that were created when the graph was being set up are instantiated at the current label position and added to the list of graphed objects. These objects are also childed to the GraphContainer, set active, anchored to their original y positions to keep them at the same height, and given a default local scale of one to prevent any weird Unity scaling issues that may be encountered. Also, because the text of the label will need to be changed depending on its position, the current for loop iteration is used to reference the corresponding time period and that time period is converted to a string with the format “h tt”. Other formats may also be used, but this one will format the label to show just the hour and whether its AM or PM.
The instantiation of labels and gridlines for the y-axis is similar to what was used for the x, however, that’s about it. The number of labels to use for the y-axis is set within the inspector and then passed to a temporary value to be manipulated. That temporary value is then checked against the range calculated earlier to provide even spacing for both odd and even values. This check basically sees if the range is an odd or even number, and then if it’s odd, it adds a value to the label count to evenly distribute the values. This is highly important because if not, then labels for limited-range values would duplicate and/or omit values that should otherwise be represented at those positions. Think instead of numbers going from 93, 94, 95, 96, and 97, they went 93, 94, 94, 96, and 97 (not very good when trying to visualize data).
Next, like with the x-axis, a for loop is initiated to instantiate the TempValueY and TempGridlineY objects as the desired positions. For each iteration, the label’s position normal is first calculated based on the temporary label count value being divided into the current loop value. Then, the label position is found by multiplying the normal by the variance between the min and max y values before being added back to the y min value (sorry, I warned you about the math).
The last main difference with the y label is that with the values being numeric, the text component is set to the label position calculated above.
We’re almost done, only a few remaining methods to wrap up! You got this!
8: PlotDataPoints(List<DataPoint> dataSeries)
The two key parts of this final primary method involve comparing the time and numeric value of each data point to the total minutes of the day on the x-axis and the min and max bounds on the y-axis, respectively
X Pos: The total time is a TimeSpan value calculated by subtracting the minimum DateTime on the x-axis from the maximum. Since this is a TimeSpan value, DateTime.Ticks will need to be used in order to calculate the time between the min and max. The total minutes can then be extracted from the total time value by using .TotalMinutes. The minutes for each data point is extracted by again parsing the DateTime valuev and then extending that value by .TimeOfDay.TotalMinutes. The total minutes from the minimum DateTime value must then be subtracted from the data point minutes to make up for the buffer used on the x-axis. Next, find the variance between the min and max label positions and multiply that to the division of the data point minutes and total minutes. Finally, add that value to the minimum label position to get the accurate data position along the x-axis.
Y Pos: Three values are needed for the data’s position along the y-axis. These are (a) the difference between the data’s value and the minimum position on the y-axis, (b) the difference between the min and max y-value positions, and (c)the GraphContainer height. The data point’s y-position is then calculated by dividing a by b and then multiplying c.
The x and y positions are then passed into a Vector2 which is used by the CreateDataPoint() method, and the game object created by that method then has it’s position passed into the CreateDataConnector() method only if there is a previously created data point.
GameObject CreateDataPoint(Vector2 pos) & Game Object CreateDataConnector(Vector2 pointA, Vector2 pointB)
These final two game object return-type methods both involve creating an image game object, setting the parent as the GraphContainer, and anchoring the rect transform position. For the data point object, the method takes in the current data position as a parameter and the image’s sprite will be set as the data point sprite that was created when building the graph. For the data connector, the method takes in the current and previous data positions to calculate the direction, distance, and angle of the connection and uses those values along with the image’s color component to draw a connecting line.
If you’ve managed to stay awake and follow along, you should have a basic time series graph ready to deploy to your application or game, but feel free to check out the source code HERE on GitHub and feel even more free to modify and extend well past a single 24-hour period.