Displaying a Squarified Treemap Using PowerShell and WPF

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?

vropsExample

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:

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

image

Probably a little hard to see as a Table, but each object would look like this:

image

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().

image

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

 

image

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

image

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

image

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

image

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

 

This entry was posted in powershell, WPF and tagged , , , , . Bookmark the permalink.

4 Responses to Displaying a Squarified Treemap Using PowerShell and WPF

  1. Pingback: Some Updates to Out-SquarifiedTreemap | Learn Powershell | Achieve More

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s