I was looking at VROPs recently and enjoyed looking at the data visualization that they used to show various states for things such as CPU, Memory and Disk Space for VMs and wanted to see if I could do the same thing in PowerShell, because why not?
After some searching on what this is, I found that it is called a Squarified Treemap that uses a heatmap, meaning that there are potentially 2 data points here being used to make up the visualization of data. The first data determines the size of the area of the square (or rectangle depending on how it turns out) and the second data set determines its color on the heat map. One is example would be your file system. You could have a large square to show the count of files in a folder but shows as green because the total file size isn’t that large compared to another folder which might have a smaller square to show its file count compared to others.
Starting out, I need to accomplish a few things if I want to make this:
- Figure out the Squarified Treemap Algorithm
- Determine the X and Y coordinates to be used on Canvas
- Find formula to calculate when a color should be Red or Yellow or Green with Red signifying that it is bad.
Working the Squarified Treemap
Of course, none of this really turned out easy to accomplish. Even with the algorithm known for the squarified treemap, it seems it would work for a very simple case, but would quickly fall apart when I start straying from examples shown. Some of the places that I looked at for inspiration are here:
- PDF that details the squarified treemap algorithm
- Provided a simple look at creating a squarified treemap
- This is what I first looked at to try and build out some code but while I was able to duplicate the approach initially, it quickly fell apart as I started using different numbers and actually blew up when the data point was larger than the width or height of the rectangle.
Reading all of these should give you a good idea on what I am trying to accomplish in PowerShell. Eventually I was able to figure out a good solution that didn’t really deal with trying to get as close as I could to the 1:1 aspect ratio but instead set a threshold on how much of the area of the rectangle that a single square could possible take up. I decided that I didn’t want more than 40% of the total area being used.
The end result is a helper function called New-SquareTreeMapData which takes the incoming data from a custom object or a single data point and starts using an algorithm to determine a number of things such as how big each rectangle will be as well as its X and Y coordinates on a window with a given Width and Height.
The helper function is available here:
Function New-SquareTreeMapData { [cmdletbinding()] Param ( [float]$Width, [float]$Height, [parameter(ValueFromPipeline=$True)] [object[]]$InputObject, [string]$LabelProperty, [string]$DataProperty, [string]$HeatmapProperty, [int64]$MaxHeatMapSize ) Begin { Try { [void][treemap.coordinate] } Catch { Add-Type -TypeDefinition @" using System; namespace TreeMap { public class Coordinate { public float X; public float Y; public Coordinate(float x, float y) { X = x; Y = y; } public Coordinate(float x) { X = x; Y = 0; } public Coordinate() { X = 0; Y = 0; } public override string ToString() { return X+","+Y; } } } "@ } Function Get-StartingOrientation { Param ([float]$Width, [float]$Height) Switch (($Width -ge $Height)) { $True {'Vertical'} $False {'Horizontal'} } } #region Starting Data $Row = 1 $Rectangle = New-Object System.Collections.ArrayList $DecimalThreshold = .4 $TempData = New-Object System.Collections.ArrayList If ($PSBoundParameters.ContainsKey('InputObject')) { $Pipeline = $False Write-Verbose "Adding `$InputObject to list" [void]$TempData.AddRange($InputObject) } Else { $Pipeline = $True } #Sort the data $List = New-Object System.Collections.ArrayList $Stack = New-Object System.Collections.Stack $FirstCoordRun = $True $CurrentX = 0 $CurrentY = 0 #endregion Starting Data } Process { If ($Pipeline) { #Write-Verbose "Adding $($_) to list" [void]$TempData.Add($_) } } End { If ($PSBoundParameters.ContainsKey('DataProperty')) { $TempData | Sort-Object $DataProperty | ForEach { #If it is 0, then it should not occupy space If ($_.$DataProperty -gt 0) { $Stack.Push($_) } } } Else { $TempData | Sort-Object | ForEach { #If it is 0, then it should not occupy space If ($_ -gt 0) { $Stack.Push($_) } } } $ElementCount = $Stack.Count #Begin building out the grid $Temp = New-Object System.Collections.ArrayList While ($Stack.Count -gt 0) { $PreviousWidth = 0 $PreviousHeight = 0 $TotalBlockHeight = 0 $TotalBlockWidth = 0 Write-Verbose "Width: $Width - Height: $Height" #Write-Verbose "StackCount: $($Stack.Count)" $FirstRun = $True #Write-Verbose "Row: $Row" If ($PSBoundParameters.ContainsKey('DataProperty')) { $TotalArea = ($Stack | Measure-Object -Property $DataProperty -Sum).Sum } Else { $TotalArea = ($Stack | Measure-Object -Sum).Sum } #Write-Verbose 'Getting starting orientation' $Orientation = Get-StartingOrientation -Width $Width -Height $Height Write-Verbose "Orientation: $Orientation" $Iteration = 0 Do { $Iteration++ #Write-Verbose "Iteration: $Iteration" #Write-Verbose "TotalArea: $($TotalArea)" [void]$List.Add($Stack.Pop()) If ($PSBoundParameters.ContainsKey('DataProperty')) { $PercentArea = (($List | Measure-Object -Property $DataProperty -Sum).Sum/$TotalArea) } Else { $PercentArea = (($List | Measure-Object -Sum).Sum/$TotalArea) } #Write-Verbose "PercentArea: $($PercentArea)" } Until (($PercentArea -ge $DecimalThreshold) -OR ($Stack.Count -eq 0)) #Write-Verbose "Threshold met!" If ($List.Count -gt 1) { If ($PSBoundParameters.ContainsKey('DataProperty')) { $_area = ($List | Measure-Object -Property $DataProperty -Sum).Sum } Else { $_area = ($List | Measure-Object -Sum).Sum } } $List | ForEach { If ($PSBoundParameters.ContainsKey('DataProperty')) { $Item = $_.$DataProperty } Else { $Item = $_ } If ($PSBoundParameters.ContainsKey('LabelProperty')) { $Label = $_.$LabelProperty } If ($PSBoundParameters.ContainsKey('HeatmapProperty')) { $HeatmapData = $_.$HeatmapProperty } ElseIf ($PSBoundParameters.ContainsKey('MaxHeatMapSize')){ $HeatmapData = $_ } Switch ($Orientation) { 'Vertical' { #Get block width $BlockWidth = ($PercentArea * $Width) Write-Verbose "BlockWidth: $($BlockWidth)" If ($Iteration -eq 1) { $BlockHeight = $Height } Else { #Get block height $_percentarea = ($Item / $_area) $BlockHeight = ($_percentarea*$Height) Write-Verbose "BlockHeight: $($BlockHeight)" } } 'Horizontal' { #Get block height $BlockHeight = ($PercentArea * $Height) Write-Verbose "BlockHeight: $($BlockHeight)" If ($Iteration -eq 1) { $BlockWidth = $Width } Else { #Get block width $_percentarea = ($Item / $_area) $BlockWidth = ($_percentarea*$Width) Write-Verbose "BlockWidth: $($BlockWidth)" } } } If ($FirstCoordRun) { Write-Verbose 'First run coordinates' $Coordinate = New-Object -Typename treemap.coordinate -ArgumentList $CurrentX,$CurrentY $FirstCoordRun=$False } Else { Write-Verbose 'Rest of coordinates' Switch ($Orientation) { 'Vertical' { Write-Verbose 'Setting Vertical coordinates' Write-Verbose "TotalHeight: $($TotalBlockHeight)" $Y = $TotalBlockHeight + $CurrentY $Coordinate = New-Object -Typename treemap.coordinate -ArgumentList $CurrentX,$Y } 'Horizontal' { Write-Verbose "TotalWidth: $($TotalBlockWidth)" Write-Verbose 'Setting Horizontal coordinates' $X = $TotalBlockWidth + $CurrentX $Coordinate = New-Object -Typename treemap.coordinate -ArgumentList $X,$CurrentY } } } [pscustomobject]@{ LabelProperty = $Label DataProperty = $Item HeatmapProperty = $HeatmapData Row = $Row Orientation = $Orientation Width = $BlockWidth Height = $BlockHeight Coordinate = $Coordinate ObjectData = $_ } $PreviousWidth = $BlockWidth $PreviousHeight = $BlockHeight $TotalBlockHeight = $TotalBlockHeight + $BlockHeight $TotalBlockWidth = $TotalBlockWidth + $BlockWidth } If ($Orientation -eq 'Vertical') { $CurrentX = $BlockWidth + $CurrentX $Width = $Width - $BlockWidth } Else { $CurrentY = $CurrentY + $BlockHeight $Height = $Height - $BlockHeight } Write-Verbose "CurrentX: $($CurrentX)" Write-Verbose "CurrentY: $($CurrentY)" $list.Clear() $Row++ $FirstCoordRun = $True } } }
The gist of what it is doing is that we are determining the starting orientation to begin building out the squares based on the Width and Height. From there we start working through the areas of each data point and once we surpass the .4 that tells us to stop and begin processing the information that will contain our size, X/Y coordinates as well as some other information.
A quick demo of this:
#region Example using Filesystem against my current drive $FileInfo = Get-ChildItem -Directory|ForEach { $Files = Get-ChildItem $_.fullname -Recurse -File|measure-object -Sum -Property length [pscustomobject]@{ Name = $_.name Fullname = $_.fullname Count = [int64]$Files.Count Size = [int64]$Files.Sum } } #endregion Example using Filesystem against my current drive $Params = @{ Width = 600 Height = 200 LabelProperty = 'Fullname' DataProperty = 'Count' HeatmapProperty = 'Size' } $FileInfo | New-SquareTreeMapData @Params
Probably a little hard to see as a Table, but each object would look like this:
Here you can easily see that I have spots for my label and data property, the row which is actually each iteration as it goes from the vertical to horizontal orientation. Also is the Width and Height of each data square and its coordinates. I also include the original object so we can create custom tooltips in the main function.
Determine the Heatmap Colors
Trying to determine how to handle the heatmap coloring was definitely a challenge. My goal was to utilize a Green –> Yellow –> Red transition to show the Good (Green) up to the Bad (Red). After some searching around, I came across this StackOverflow answer which provided a single line solution that came the closest to what I was looking for. I spent some time adjusting the code to meet my own expectations:
Function Color { Param($Decimal) If ($Decimal -gt 1) { $Decimal = 1 } $Red = ([float](2.0) * $Decimal) $Red = If ($Red -gt 1) { 255 } Else { $Red * 255 } $Green = (([float](2.0) * (1 - $Decimal))) $Green = If ($Green -gt 1) { 255 } Else { $Green * 255 } ([windows.media.color]::FromRgb($Red, $Green, 0)) }
I opt to keep the Media.Color object and then convert it to its hexadecimal counterpart using ToString().
What I end up getting is a nice transition from my colors. Although I feel that the Green might be a little too light, I am not going worry about this at the moment because as of right now, it still works properly but is something that I would like to come back to and adjust.
The Final Product
In the end, I put together a function which utilizes all of these helper functions and techniques to accept data from either the pipeline or as a parameter along with supplying some other parameters (if needed) to create a UI that displays a squarified treemap as well as a heatmap if desired. I also allow optional tooltips to display on each square that can provide more information about the square when you hover the mouse over it.
Some examples of it in action:
#region Example using Filesystem against my current drive $FileInfo = Get-ChildItem -Directory|ForEach { $Files = Get-ChildItem $_.fullname -Recurse -File|measure-object -Sum -Property length [pscustomobject]@{ Name = $_.name Fullname = $_.fullname Count = [int64]$Files.Count Size = [int64]$Files.Sum } } #endregion Example using Filesystem against my current drive #region Create a custom tooltip $Tooltip = { @" Fullname = $($This.LabelProperty) FileCount = $($This.Dataproperty) Size = $([math]::round(($This.HeatmapProperty/1MB),2)) MB "@ } #Create the UI $Params = @{ Width = 600 Height = 200 LabelProperty = 'Fullname' DataProperty = 'Count' HeatmapProperty = 'Size' Tooltip = $Tooltip } $FileInfo | Out-SquarifiedTreeMap @Params #endregion Create a custom tooltip
Finding the Process using the most memory
#region Example using Process WorkingSet Memory Get-Process | Out-SquarifiedTreeMap -LabelProperty ProcessName -DataProperty WS -HeatmapProperty WS -Width 600 -Height 400 #endregion Example using Process WorkingSet Memory
Another example showing the MaxHeatmapSize parameter.
#region Example using just data and no object and specifying a heatmap threshold 1..8 | Out-SquarifiedTreeMap -Width 600 -Height 200 -MaxHeatMapSize 15 #endregion Example using just data and no object and specifying a heatmap threshold
Note that you don’t actually have to specify a heatmaproperty and it will default to a darker green color (this might be something worth changing in the future or adding another parameter to change this).
#region Not using a HeatMap 1..8 | Out-SquarifiedTreeMap -Width 600 -Height 200 #endregion Not using a HeatMap
So with that, feel free to give this a download and let me know what you think. The download links are below for both my GitHub source and the Technet Script Repository. I’m also interested in hearing how you might (or end up) using this in your day to day activities.
Source Code (Contributions always welcome!)
https://github.com/proxb/SquarifiedTreemap
Download Out-SquarifiedTreemap
https://gallery.technet.microsoft.com/scriptcenter/Out-SquarifiedTreemap-939fb379
Pingback: Some Updates to Out-SquarifiedTreemap | Learn Powershell | Achieve More
Cool Stuff man
Thanks!
Very nice!