diff --git a/AsBuiltReport.Chart/AsBuiltReport.Chart.psd1 b/AsBuiltReport.Chart/AsBuiltReport.Chart.psd1 index 0d300dc..7b6487d 100644 --- a/AsBuiltReport.Chart/AsBuiltReport.Chart.psd1 +++ b/AsBuiltReport.Chart/AsBuiltReport.Chart.psd1 @@ -69,7 +69,7 @@ # NestedModules = @() # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. - FunctionsToExport = 'New-PieChart', 'New-BarChart', 'New-StackedBarChart', 'New-SignalChart', 'New-SingleStackedBarChart', 'New-DonutChart', 'New-RadarChart' + FunctionsToExport = 'New-PieChart', 'New-BarChart', 'New-StackedBarChart', 'New-SignalChart', 'New-SingleStackedBarChart', 'New-DonutChart', 'New-RadarChart', 'New-HeatMapChart' # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. # CmdletsToExport = '*' diff --git a/CHANGELOG.md b/CHANGELOG.md index c77acd1..313d700 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add SkiaSharp.NativeAssets.macOS dependency to the module to support macOS platform - Add support for macOS intel in the Pester workflow to test the module on macOS intel platform - Add Pester test for missing OutputFolderPath in New-PieChart +- Add Heatmap chart support: + - Implement New-HeatmapChart cmdlet and associated classes + - Add pester test to validate the functionality of the New-HeatmapChart cmdlet + - Add example 16 to document on how to use the New-HeatmapChart cmdlet ### Changed diff --git a/Examples/Example16.ps1 b/Examples/Example16.ps1 new file mode 100644 index 0000000..b658189 --- /dev/null +++ b/Examples/Example16.ps1 @@ -0,0 +1,70 @@ +<# + .SYNOPSIS + Example 16 - Heat Map Chart + + .DESCRIPTION + This example demonstrates how to create a Heat Map Chart using the AsBuiltReport.Chart module. + The chart displays cluster utilization across resource types for multiple clusters. +#> + +[CmdletBinding()] +param ( + [System.IO.DirectoryInfo] $Path = (Get-Location).Path, + [string] $Format = 'png' +) + +<# + Starting with PowerShell v3, modules are auto-imported when needed. Importing the module here + ensures clarity and avoids ambiguity. +#> + +# Import-Module AsBuiltReport.Chart -Force -Verbose:$false + +<# + Since the chart output is a file, specify the output folder path using $OutputFolderPath. +#> + +$OutputFolderPath = Resolve-Path $Path + +<# + Define the data to be displayed in the chart. + In a real-world scenario these values would come from your infrastructure query. +#> + +$ChartTitle = 'Cluster Utilization' +$Values = @( + @(78, 81, 74), + @(66, 72, 80), + @(88, 91, 85) +) +$RowLabels = @('Cluster A', 'Cluster B', 'Cluster C') +$ColumnLabels = @('CPU', 'Memory', 'Storage') + +<# + The New-HeatMapChart cmdlet generates the Heat Map image. + + -Title : Sets the chart title displayed at the top of the image. + -Values : 2D matrix of numeric values for the heat map. + -RowLabels : Labels for each row in the heat map. + -ColumnLabels : Labels for each column in the heat map. + -Format : Output file format (e.g. png, jpg, svg). + -OutputFolderPath : Directory where the generated chart file will be saved. + -Width : Width of the chart image in pixels. + -Height : Height of the chart image in pixels. + -Filename : Name of the output file (without extension). + -EnableLegend : Boolean to enable or disable the legend on the chart. +#> + +New-HeatMapChart ` + -Title $ChartTitle ` + -Values $Values ` + -RowLabels $RowLabels ` + -ColumnLabels $ColumnLabels ` + -LabelXAxis 'Resources' ` + -LabelYAxis 'Clusters' ` + -EnableLegend ` + -Format $Format ` + -OutputFolderPath $OutputFolderPath ` + -Width 700 ` + -Height 500 ` + -Filename 'Example16-HeatMapChart' diff --git a/README.md b/README.md index 5cb3005..540b824 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,16 @@ New-RadarChart -Title 'Test' -Values @(@(1, 2, 5, 8),@(3,5,4,2)) -LegendLabels @ ``` ![RadarChart](./Samples/RadarChart.png) +### Heat Map Chart +```powershell +# Generate a Heat Map Chart showing utilization across clusters and metrics. +New-HeatMapChart -Title 'Cluster Utilization' -Values @( + @(78, 81, 74), + @(66, 72, 80), + @(88, 91, 85) +) -RowLabels @('Cluster A', 'Cluster B', 'Cluster C') -ColumnLabels @('CPU', 'Memory', 'Storage') -Format 'png' -OutputFolderPath $PWD +``` + ### :blue_book: Example Index All examples in the latest release of AsBuiltReport.Chart can be found in the table below. @@ -143,6 +153,7 @@ All examples in the latest release of AsBuiltReport.Chart can be found in the ta | [Example13](./Examples/Example13.ps1) | Donut Chart with Legend, Custom Colors and Border | | [Example14](./Examples/Example14.ps1) | Basic Radar Chart with Legend | | [Example15](./Examples/Example15.ps1) | Radar Chart with Legend, Custom Colors and Border | +| [Example16](./Examples/Example16.ps1) | Heat Map Chart for cluster utilization | ## Watermark Support diff --git a/Sources/HeatMapChart.cs b/Sources/HeatMapChart.cs new file mode 100644 index 0000000..ed5c22a --- /dev/null +++ b/Sources/HeatMapChart.cs @@ -0,0 +1,129 @@ +using System; +using System.IO; +using System.Linq; +using ScottPlot; + +namespace AsBuiltReportChart +{ + internal class HeatMap : Chart + { + public object Chart(double[][] values, string[] rowLabels, string[] columnLabels, string filename = "output", int width = 400, int height = 300) + { + if (values == null || values.Length == 0 || values[0] == null || values[0].Length == 0) + { + throw new ArgumentException("Error: Values cannot be null or empty."); + } + + int rows = values.Length; + int columns = values[0].Length; + + for (int rowIndex = 0; rowIndex < rows; rowIndex++) + { + if (values[rowIndex] == null || values[rowIndex].Length != columns) + { + throw new ArgumentException("Error: All rows in Values must have the same length."); + } + } + + if (rowLabels != null && rowLabels.Length != rows) + { + throw new ArgumentException("Error: Row labels length must match the number of rows."); + } + + if (columnLabels != null && columnLabels.Length != columns) + { + throw new ArgumentException("Error: Column labels length must match the number of columns."); + } + + Plot myPlot = new Plot(); + double[,] matrix = new double[rows, columns]; + + for (int rowIndex = 0; rowIndex < rows; rowIndex++) + { + for (int columnIndex = 0; columnIndex < columns; columnIndex++) + { + matrix[rowIndex, columnIndex] = values[rowIndex][columnIndex]; + } + } + + var heatmap = myPlot.Add.Heatmap(matrix); + + if (EnableLegend) + { + var colorBar = myPlot.Add.ColorBar(heatmap, ScottPlot.Edge.Right); + colorBar.Width = 20; + colorBar.Label = "Value"; + } + + myPlot.Axes.Left.Label.Text = LabelYAxis; + myPlot.Axes.Bottom.Label.Text = LabelXAxis; + myPlot.Axes.Left.Label.FontSize = LabelFontSize; + myPlot.Axes.Left.Label.ForeColor = GetDrawingColor(LabelFontColor); + myPlot.Axes.Left.Label.FontName = FontName; + myPlot.Axes.Left.Label.Bold = LabelBold; + myPlot.Axes.Bottom.Label.FontSize = LabelFontSize; + myPlot.Axes.Bottom.Label.ForeColor = GetDrawingColor(LabelFontColor); + myPlot.Axes.Bottom.Label.FontName = FontName; + myPlot.Axes.Bottom.Label.Bold = LabelBold; + + myPlot.Axes.Left.TickLabelStyle.FontSize = LabelFontSize; + myPlot.Axes.Left.TickLabelStyle.ForeColor = GetDrawingColor(LabelFontColor); + myPlot.Axes.Left.TickLabelStyle.FontName = FontName; + myPlot.Axes.Bottom.TickLabelStyle.FontSize = LabelFontSize; + myPlot.Axes.Bottom.TickLabelStyle.ForeColor = GetDrawingColor(LabelFontColor); + myPlot.Axes.Bottom.TickLabelStyle.FontName = FontName; + + if (rowLabels != null && rowLabels.Length > 0) + { + double[] positions = Enumerable.Range(0, rowLabels.Length).Select(i => (double)i).ToArray(); + myPlot.Axes.Left.SetTicks(positions, rowLabels); + } + + if (columnLabels != null && columnLabels.Length > 0) + { + double[] positions = Enumerable.Range(0, columnLabels.Length).Select(i => (double)i).ToArray(); + myPlot.Axes.Bottom.SetTicks(positions, columnLabels); + } + + myPlot.HideGrid(); + myPlot.Axes.Top.IsVisible = false; + myPlot.Axes.Right.IsVisible = false; + + if (Title != null) + { + myPlot.Title(Title); + myPlot.Axes.Title.Label.FontSize = TitleFontSize; + myPlot.Axes.Title.Label.ForeColor = GetDrawingColor(TitleFontColor); + myPlot.Axes.Title.Label.Bold = TitleFontBold; + myPlot.Axes.Title.Label.FontName = FontName; + } + + myPlot.Axes.Margins(left: AxesMarginsLeft, right: AxesMarginsRight, bottom: AxesMarginsDown, top: AxesMarginsTop); + + if (FigureBackgroundColor.HasValue) + { + myPlot.FigureBackground.Color = GetDrawingColor(FigureBackgroundColor.Value); + } + + if (DataBackgroundColor.HasValue) + { + myPlot.DataBackground.Color = GetDrawingColor(DataBackgroundColor.Value); + } + + if (EnableChartBorder) + { + myPlot.FigureBorder = new LineStyle() + { + Color = GetDrawingColor(ChartBorderColor), + Width = ChartBorderSize, + Pattern = ChartBorderStyleMap[ChartBorderStyle], + }; + } + + ApplyWatermark(myPlot); + + string filepath = _outputFolderPath ?? Directory.GetCurrentDirectory(); + return SaveInFormat(myPlot, width, height, filepath, filename, Format); + } + } +} diff --git a/Sources/PowerShell/HeatMapChartPwsh.cs b/Sources/PowerShell/HeatMapChartPwsh.cs new file mode 100644 index 0000000..b006b37 --- /dev/null +++ b/Sources/PowerShell/HeatMapChartPwsh.cs @@ -0,0 +1,167 @@ +using System; +using System.IO; +using System.Management.Automation; + +namespace AsBuiltReportChart.PowerShell +{ + [Cmdlet(VerbsCommon.New, "HeatMapChart")] + public class NewHeatMapChartCommand : Cmdlet + { + [Parameter(Mandatory = false, HelpMessage = "Output filename for the chart. If not specified, a random token will be generated.")] + public string Filename { get; set; } = Chart.GenerateToken(8); + + [Parameter(Mandatory = true, HelpMessage = "Matrix of numeric values to display in the heat map.")] + public double[][] Values { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Row labels for the heat map.")] + public string[] RowLabels { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Column labels for the heat map.")] + public string[] ColumnLabels { get; set; } + + [Parameter(Mandatory = true, HelpMessage = "Title text for the chart.")] + public string Title { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Make the title font bold.")] + public SwitchParameter TitleFontBold { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Font size for the title in points.")] + public int TitleFontSize { get; set; } = 14; + + [Parameter(Mandatory = false, HelpMessage = "Font color for the title.")] + public BasicColors TitleFontColor { get; set; } = BasicColors.Black; + + [Parameter(Mandatory = false, HelpMessage = "Enable the heat map color scale legend.")] + public SwitchParameter EnableLegend { get; set; } + + [Parameter(Mandatory = true, HelpMessage = "Output format for the chart (PNG, JPEG, etc.).")] + public Formats Format { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Border color for the chart area.")] + public BasicColors ChartBorderColor { get; set; } = BasicColors.Black; + + [Parameter(Mandatory = false, HelpMessage = "Border size for the chart area in pixels.")] + public int ChartBorderSize { get; set; } = 1; + + [Parameter(Mandatory = false, HelpMessage = "Border style for the chart area.")] + public Enums.BorderStyles ChartBorderStyle { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Enable border around the chart area.")] + public SwitchParameter EnableChartBorder { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Font name to use for all text in the chart.")] + public string FontName { get; set; } = "Arial"; + + [Parameter(Mandatory = false, HelpMessage = "Font size for axis labels in points.")] + public int LabelFontSize { get; set; } = 14; + + [Parameter(Mandatory = false, HelpMessage = "Font color for axis labels.")] + public BasicColors LabelFontColor { get; set; } = BasicColors.Black; + + [Parameter(Mandatory = false, HelpMessage = "Make axis label fonts bold.")] + public SwitchParameter LabelBold { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Label text for the Y-axis.")] + public string LabelYAxis { get; set; } = "Rows"; + + [Parameter(Mandatory = false, HelpMessage = "Label text for the X-axis.")] + public string LabelXAxis { get; set; } = "Columns"; + + [Parameter(Mandatory = false, HelpMessage = "Top margin for the chart area as a percentage (0-1).")] + public double AxesMarginsTop { get; set; } = 0.05; + + [Parameter(Mandatory = false, HelpMessage = "Bottom margin for the chart area as a percentage (0-1).")] + public double AxesMarginsDown { get; set; } = 0.05; + + [Parameter(Mandatory = false, HelpMessage = "Left margin for the chart area as a percentage (0-1).")] + public double AxesMarginsLeft { get; set; } = 0.05; + + [Parameter(Mandatory = false, HelpMessage = "Right margin for the chart area as a percentage (0-1).")] + public double AxesMarginsRight { get; set; } = 0.05; + + [Parameter(Mandatory = false, HelpMessage = "Background color of the entire figure (canvas).")] + public BasicColors? FigureBackgroundColor { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Background color of the data area inside the axes.")] + public BasicColors? DataBackgroundColor { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Enable a watermark on the chart.")] + public SwitchParameter EnableWatermark { get; set; } + + [Parameter(Mandatory = false, HelpMessage = "Text to display as the watermark. Defaults to 'Confidential'.")] + public string WatermarkText { get; set; } = "Confidential"; + + [Parameter(Mandatory = false, HelpMessage = "Alignment of the watermark text. Defaults to 'MiddleCenter'.")] + public Enums.Alignments WatermarkAlignment { get; set; } = Enums.Alignments.MiddleCenter; + + [Parameter(Mandatory = false, HelpMessage = "Font name for the watermark text.")] + public string WatermarkFontName { get; set; } = "Arial"; + + [Parameter(Mandatory = false, HelpMessage = "Font size for the watermark text in points. Defaults to 24.")] + public int WatermarkFontSize { get; set; } = 24; + + [Parameter(Mandatory = false, HelpMessage = "Color of the watermark text.")] + public BasicColors WatermarkColor { get; set; } = BasicColors.Gray; + + [Parameter(Mandatory = false, HelpMessage = "Opacity of the watermark (0.0 fully transparent to 1.0 fully opaque). Defaults to 0.3.")] + public double WatermarkOpacity { get; set; } = 0.3; + + [Parameter(Mandatory = false, HelpMessage = "Rotation angle of the watermark text in degrees. Defaults to 0.")] + public float WatermarkRotation { get; set; } = 0; + + [Parameter(Mandatory = false, HelpMessage = "Width of the output chart in pixels. Defaults to 400.")] + public int Width { get; set; } = 400; + + [Parameter(Mandatory = false, HelpMessage = "Height of the output chart in pixels. Defaults to 300.")] + public int Height { get; set; } = 300; + + [Parameter(Mandatory = false, HelpMessage = "Output folder path where the chart will be saved.")] + [ValidatePath()] + public string OutputFolderPath { get; set; } = Directory.GetCurrentDirectory(); + + protected override void ProcessRecord() + { + Chart.Reset(); + + if (Values == null) + { + throw new ArgumentException("Error: Values cannot be null."); + } + + Chart.Title = Title; + Chart.TitleFontBold = TitleFontBold; + Chart.TitleFontSize = TitleFontSize; + Chart.TitleFontColor = TitleFontColor; + Chart.FontName = FontName; + Chart.LabelFontSize = LabelFontSize; + Chart.LabelFontColor = LabelFontColor; + Chart.LabelBold = LabelBold; + Chart.LabelXAxis = LabelXAxis; + Chart.LabelYAxis = LabelYAxis; + Chart.AxesMarginsTop = AxesMarginsTop; + Chart.AxesMarginsDown = AxesMarginsDown; + Chart.AxesMarginsLeft = AxesMarginsLeft; + Chart.AxesMarginsRight = AxesMarginsRight; + Chart.FigureBackgroundColor = FigureBackgroundColor; + Chart.DataBackgroundColor = DataBackgroundColor; + Chart.EnableLegend = EnableLegend; + Chart.EnableChartBorder = EnableChartBorder; + Chart.ChartBorderColor = ChartBorderColor; + Chart.ChartBorderSize = ChartBorderSize; + Chart.ChartBorderStyle = ChartBorderStyle; + Chart.EnableWatermark = EnableWatermark; + Chart.WatermarkText = WatermarkText; + Chart.WatermarkAlignment = WatermarkAlignment; + Chart.WatermarkFontName = WatermarkFontName; + Chart.WatermarkFontSize = WatermarkFontSize; + Chart.WatermarkColor = WatermarkColor; + Chart.WatermarkOpacity = WatermarkOpacity; + Chart.WatermarkRotation = WatermarkRotation; + Chart.OutputFolderPath = OutputFolderPath; + Chart.Format = Format; + + HeatMap heatMap = new HeatMap(); + WriteObject(heatMap.Chart(Values, RowLabels, ColumnLabels, Filename, Width, Height)); + } + } +} diff --git a/Tests/AsBuiltReport.Chart.Functions.Tests.ps1 b/Tests/AsBuiltReport.Chart.Functions.Tests.ps1 index f77bdc1..f4fede4 100644 --- a/Tests/AsBuiltReport.Chart.Functions.Tests.ps1 +++ b/Tests/AsBuiltReport.Chart.Functions.Tests.ps1 @@ -29,6 +29,9 @@ Describe 'AsBuiltReport.Chart Exported Functions' { It 'Should export New-RadarChart' { Get-Command -Module AsBuiltReport.Chart -Name New-RadarChart | Should -Not -BeNullOrEmpty } + It 'Should export New-HeatMapChart' { + Get-Command -Module AsBuiltReport.Chart -Name New-HeatMapChart | Should -Not -BeNullOrEmpty + } Context 'New-PieChart' { It 'Should run without error with sample input' { @@ -244,6 +247,23 @@ Describe 'AsBuiltReport.Chart Exported Functions' { } } + Context 'New-HeatMapChart' { + It 'Should run without error with sample input' { + { New-HeatMapChart -Title 'Test' -Values @(@(1, 2), @(3, 4)) -RowLabels @('R1', 'R2') -ColumnLabels @('C1', 'C2') -Format 'png' -OutputFolderPath $TestDrive } | Should -Not -Throw + } + It 'Should return a file path as output' { + $result = New-HeatMapChart -Title 'Test' -Values @(@(1, 2), @(3, 4)) -RowLabels @('R1', 'R2') -ColumnLabels @('C1', 'C2') -Format 'png' -OutputFolderPath $TestDrive + $result | Should -BeOfType 'System.IO.FileSystemInfo' + Test-Path $result | Should -BeTrue + } + It 'Should run without error when the color scale legend is enabled' { + { New-HeatMapChart -Title 'Test' -Values @(@(1, 2), @(3, 4)) -RowLabels @('R1', 'R2') -ColumnLabels @('C1', 'C2') -Format 'png' -OutputFolderPath $TestDrive -EnableLegend } | Should -Not -Throw + } + It 'Should throw error for mismatched row counts' { + { New-HeatMapChart -Title 'Test' -Values @(@(1, 2), @(3, 4, 5)) -RowLabels @('R1', 'R2') -ColumnLabels @('C1', 'C2') -Format 'png' -OutputFolderPath $TestDrive } | Should -Throw -ExpectedMessage "Error: All rows in Values must have the same length." + } + } + Context 'New-RadarChart' { It 'Should run without error with sample input' { { New-RadarChart -Title 'Test' -Values @(@(1, 2, 5, 8), @(3, 5, 4, 2)) -LegendLabels @('A', 'B') -SpokeLabels @('Wins', 'Poles', 'Podiums', 'Points') -Format 'png' -OutputFolderPath $TestDrive } | Should -Not -Throw