public/controls/New-UiChart.ps1
|
function New-UiChart { <# .SYNOPSIS Creates a chart visualization that dynamically fills available space. .DESCRIPTION Renders bar, line, or pie charts using native WPF canvas drawing. By default, charts stretch to fill the width of their parent container and resize dynamically when the window is resized. When placed in a constrained parent (e.g. a Grid cell with star sizing), the chart scales to fit both width and height proportionally. Charts registered with -Variable can be updated from button actions using Update-UiChart, or by assigning new data to the variable directly (the hydration engine redraws automatically on dehydration). Omit -Data to create an empty chart with a placeholder, ready to be filled by a button action later. Specify -Width and -Height to opt into fixed display size instead. Colors are derived from the active theme's accent and semantic colors. .PARAMETER Type Chart type: Bar, Line, or Pie. .PARAMETER Data Chart data. Omit for an empty placeholder chart. Supported formats: - Ordered hashtable: [ordered]@{ "Label" = Value; ... } - Array of hashtables: @(@{Label="x"; Value=1}, ...) - Pipeline objects with configurable property names .PARAMETER LabelProperty Property name to use as labels when Data contains objects. Default "Label" or "Name". .PARAMETER ValueProperty Property name to use as values when Data contains objects. Default "Value" or "Count". .PARAMETER Title Optional chart title displayed above the chart. .PARAMETER XAxisLabel Label for the X-axis (bar and line charts only). .PARAMETER YAxisLabel Label for the Y-axis (bar and line charts only). .PARAMETER Width Fixed display width in pixels. When set, disables auto-stretch. .PARAMETER Height Fixed display height in pixels. When set, disables auto-stretch. .PARAMETER ShowLegend Show legend for pie charts. Default true for pie, ignored for others. .PARAMETER ShowValues Display values on bars or pie slices. .PARAMETER Variable Variable name to register the chart for later access. .EXAMPLE # Auto-sized chart - stretches to fill available width New-UiChart -Type Bar -Data ([ordered]@{ "C:" = 120; "D:" = 450; "E:" = 80 }) -Title "Disk Space" .EXAMPLE # Fixed size - explicit dimensions New-UiChart -Type Pie -Data ([ordered]@{ "A" = 60; "B" = 40 }) -Width 300 -Height 250 .EXAMPLE # Pipeline data with custom properties Get-Process | Group-Object Company | Select-Object -First 5 | New-UiChart -Type Pie -LabelProperty Name -ValueProperty Count .EXAMPLE # Empty chart updated by a button action New-UiChart -Type Bar -Variable 'diskChart' -Title 'Disk Usage' New-UiButton -Text 'Scan' -Action { $disks = Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" | Select-Object @{N='Label';E={$_.DeviceID}}, @{N='Value';E={[math]::Round($_.FreeSpace/1GB)}} Update-UiChart -Variable 'diskChart' -Data $disks } #> [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateSet('Bar', 'Line', 'Pie')] [string]$Type, [Parameter(ValueFromPipeline)] $Data, [string]$LabelProperty, [string]$ValueProperty, [string]$Title, [string]$XAxisLabel, [string]$YAxisLabel, [int]$Width, [int]$Height, [switch]$ShowLegend, [switch]$ShowValues, [string]$Variable ) begin { $collectedData = [System.Collections.Generic.List[object]]::new() } process { if ($null -eq $Data) { return } # Collect pipeline input if ($Data -is [System.Collections.IDictionary]) { foreach ($key in $Data.Keys) { $collectedData.Add(@{ Label = $key; Value = $Data[$key] }) } } elseif ($Data -is [array]) { foreach ($item in $Data) { $collectedData.Add($item) } } else { $collectedData.Add($Data) } } end { $session = Get-UiSession $parent = $session.CurrentParent # Fixed mode: user explicitly set dimensions. Auto mode: stretch to fill parent. $fixedSize = $PSBoundParameters.ContainsKey('Width') -or $PSBoundParameters.ContainsKey('Height') # Detect whether we're inside a Grid with star rows (e.g., FillParent dashboard). # Star grids constrain cell height, so a squarer canvas fills cells better. # Standalone charts use a wider canvas to prevent excessive vertical growth # when the Viewbox scales uniformly to fill parent width. $inStarGrid = $false if (!$fixedSize -and $parent -is [System.Windows.Controls.Grid]) { foreach ($rowDef in $parent.RowDefinitions) { if ($rowDef.Height.IsStar) { $inStarGrid = $true; break } } } # Canvas internal resolution (Viewbox scales this to the display size). # Wider canvases = shorter charts at full width, better for scrollable content. # Squarer canvases = fill dashboard cells more evenly. if ($PSBoundParameters.ContainsKey('Width')) { $canvasWidth = $Width } elseif ($inStarGrid) { $canvasWidth = 600 } else { $canvasWidth = if ($Type -eq 'Pie') { 700 } else { 900 } } if ($PSBoundParameters.ContainsKey('Height')) { $canvasHeight = $Height } elseif ($inStarGrid) { $canvasHeight = 400 } else { $canvasHeight = if ($Type -eq 'Pie') { 420 } else { 360 } } # When only one dimension given, derive the other from canvas aspect ratio if ($PSBoundParameters.ContainsKey('Width') -and !$PSBoundParameters.ContainsKey('Height')) { $canvasHeight = [int]($Width * ($canvasHeight / $canvasWidth)) } if ($PSBoundParameters.ContainsKey('Height') -and !$PSBoundParameters.ContainsKey('Width')) { $canvasWidth = [int]($Height * ($canvasWidth / $canvasHeight)) } # Determine legend visibility (Pie charts show legend by default) $showLegend = $Type -eq 'Pie' -and ($ShowLegend -or !$PSBoundParameters.ContainsKey('ShowLegend')) # DockPanel passes finite height to the Viewbox when available from the parent. # StackPanel throws away height constraints - DockPanel preserves them. # Title docks Top, legend docks Bottom, Viewbox fills remaining space. # Dashboard grids (star rows) constrain cell height, so Stretch fills cells. # Everything else uses Top to prevent infinite vertical expansion. $vertAlign = if ($inStarGrid) { 'Stretch' } else { 'Top' } $container = [System.Windows.Controls.DockPanel]@{ LastChildFill = $true HorizontalAlignment = 'Stretch' VerticalAlignment = $vertAlign Margin = [System.Windows.Thickness]::new(8) } # Store chart config so Invoke-ChartRedraw knows how to re-render $container.Tag = @{ ControlType = 'Chart' ChartType = $Type ShowValues = $ShowValues.IsPresent ShowLegend = $showLegend XAxisLabel = $XAxisLabel YAxisLabel = $YAxisLabel LabelProperty = $LabelProperty ValueProperty = $ValueProperty } # Title docked to top if ($Title) { $titleBlock = [System.Windows.Controls.TextBlock]@{ Text = $Title FontSize = 16 FontWeight = 'SemiBold' HorizontalAlignment = 'Center' Margin = [System.Windows.Thickness]::new(0, 0, 0, 8) } $titleBlock.SetResourceReference([System.Windows.Controls.TextBlock]::ForegroundProperty, 'ControlForegroundBrush') [System.Windows.Controls.DockPanel]::SetDock($titleBlock, [System.Windows.Controls.Dock]::Top) [void]$container.Children.Add($titleBlock) } # Canvas draws at internal resolution, Viewbox scales to display size $canvas = [System.Windows.Controls.Canvas]@{ Width = $canvasWidth Height = $canvasHeight Background = [System.Windows.Media.Brushes]::Transparent } $viewbox = [System.Windows.Controls.Viewbox]@{ Stretch = 'Uniform' Child = $canvas } # Viewbox must be last child - DockPanel gives the last child all remaining space [void]$container.Children.Add($viewbox) # Normalize collected data and render (or show placeholder if empty) $chartData = $null if ($collectedData.Count -gt 0) { $chartData = ConvertTo-ChartData -RawData $collectedData -LabelProperty $LabelProperty -ValueProperty $ValueProperty } # Invoke-ChartRedraw handles both data rendering and empty placeholder Invoke-ChartRedraw -Container $container -NewData $chartData # Register hydration callback - dehydration triggers this when $chartVar # is reassigned in a button action. Reads data from DataProperty. $containerRef = $container $redrawCallback = [Action]{ $storedData = [PsUi.UiHydration]::GetData($containerRef) Invoke-ChartRedraw -Container $containerRef -NewData $storedData }.GetNewClosure() [PsUi.UiHydration]::SetOnDataChanged($container, $redrawCallback) # Register with session for variable access and hydration if ($Variable) { $session.AddControlSafe($Variable, $container) } # Add to current parent if ($parent -is [System.Windows.Controls.Panel]) { [void]$parent.Children.Add($container) } elseif ($parent -is [System.Windows.Controls.ItemsControl]) { [void]$parent.Items.Add($container) } elseif ($parent -is [System.Windows.Controls.ContentControl] -and $null -eq $parent.Content) { $parent.Content = $container } # WrapPanel parents size children to their content width, so charts need # explicit Width to fill the available space and track parent resizes. # Other parents (StackPanel vertical, Grid with star columns) constrain # width naturally - the Viewbox Uniform stretch fits within those bounds. # Note: horizontal StackPanels give children their desired width, so charts # in side-by-side layouts should use New-UiGrid -Columns 2 instead. if (!$fixedSize -and $parent -is [System.Windows.Controls.WrapPanel]) { $chartRef = $container $parentRef = $parent $parentRef.Add_SizeChanged({ param($sender, $sizeArgs) $available = $sender.ActualWidth - 20 if ($available -gt 50) { $chartRef.Width = $available } }.GetNewClosure()) if ($parentRef.ActualWidth -gt 0) { $container.Width = $parentRef.ActualWidth - 20 } } return $container } } |