public/controls/charts/New-UiChart.ps1

function New-UiChart {
    <#
    .SYNOPSIS
        Creates a bar, line, or pie chart that stretches to fill its parent.
    .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 chart catches up 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. When omitted, tries
        "Label", "Name", then "Key".
    .PARAMETER ValueProperty
        Property name to use as values when Data contains objects. When omitted, tries
        "Value", "Count", "Sum", then "Total".
    .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, line points, or pie slices.
    .PARAMETER Variable
        Variable name to register the chart for later access.
    .EXAMPLE
        # Auto sized chart. It stretches to fill available width.
        New-UiChart -Type Bar -Data ([ordered]@{ "C:" = 120; "D:" = 450; "E:" = 80 }) -Title "Disk Space"
    .EXAMPLE
        # Fixed size, from explicit dimensions.
        New-UiChart -Type Pie -Data ([ordered]@{ "A" = 60; "B" = 40 }) -Width 300 -Height 250
    .EXAMPLE
        # Pipeline data with custom properties
        $vendors = Get-Process | Where-Object Company | Group-Object Company | Sort-Object Count -Descending
        $vendors[0..4] | 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' -NoOutput -Action {
            $disks = [ordered]@{}
            foreach ($disk in Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3") {
                $disks[$disk.DeviceID] = [math]::Round($disk.FreeSpace / 1GB)
            }
            Update-UiChart -Variable 'diskChart' -Data $disks
        }
    .EXAMPLE
        # Line charts carry a trend over time
        New-UiChart -Type Line -Title 'Commits per month' -Data ([ordered]@{
            Mar = 14; Apr = 31; May = 27; Jun = 40; Jul = 22; Aug = 35
        })
    #>

    [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 [System.Collections.IList]) {
            foreach ($item in $Data) { $collectedData.Add($item) }
        }
        else {
            $collectedData.Add($Data)
        }
    }

    end {
        $session = Assert-UiSession -CallerName 'New-UiChart'
        $parent  = $session.CurrentParent

        # Fixed mode means the dimensions came in explicitly. Auto mode stretches to fill the parent.
        $fixedSize = $PSBoundParameters.ContainsKey('Width') -or $PSBoundParameters.ContainsKey('Height')

        # Detect a Grid with star rows around this (a FillParent dashboard, for example).
        # 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 ($inStarGrid) {
            $defaultWidth  = 600
            $defaultHeight = 400
        }
        else {
            $defaultWidth  = if ($Type -eq 'Pie') { 700 } else { 900 }
            $defaultHeight = if ($Type -eq 'Pie') { 420 } else { 360 }
        }

        $canvasWidth  = if ($PSBoundParameters.ContainsKey('Width'))  { $Width }  else { $defaultWidth }
        $canvasHeight = if ($PSBoundParameters.ContainsKey('Height')) { $Height } else { $defaultHeight }

        # One dimension given and the other scales to keep the default proportions. The ratio has to come off the defaults above, since the canvas already holds the given number and would divide it by itself.
        if ($PSBoundParameters.ContainsKey('Width') -and !$PSBoundParameters.ContainsKey('Height')) {
            $canvasHeight = [int]($Width * ($defaultHeight / $defaultWidth))
        }
        if ($PSBoundParameters.ContainsKey('Height') -and !$PSBoundParameters.ContainsKey('Width')) {
            $canvasWidth = [int]($Height * ($defaultWidth / $defaultHeight))
        }

        # 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 where DockPanel keeps 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' }
        $horizAlign = if ($fixedSize) { 'Left' } else { 'Stretch' }

        $container = [System.Windows.Controls.DockPanel]@{
            LastChildFill       = $true
            HorizontalAlignment = $horizAlign
            VerticalAlignment   = $vertAlign
            Margin              = [System.Windows.Thickness]::new(8)
        }

        # The help promises -Width and -Height give a fixed display size and turn auto stretch off, so they have to reach the container. Setting only the canvas leaves the Viewbox scaling the chart to whatever the parent offers.
        if ($PSBoundParameters.ContainsKey('Width'))  { $container.Width  = $Width }
        if ($PSBoundParameters.ContainsKey('Height')) { $container.Height = $Height }

        # 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 has to be the last child, since DockPanel gives that one all the space left over.
        [void]$container.Children.Add($viewbox)

        # Hand Invoke-ChartRedraw the raw collection: it runs ConvertTo-ChartData itself with the Tag's property names. Converting here too fed it Label/Value rows a custom -LabelProperty second pass dropped to zero.
        Invoke-ChartRedraw -Container $container -NewData $collectedData

        # The dehydrate pass fires this when a button action reassigns $chartVar, and it reads the data back off 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, so the Viewbox Uniform stretch fits within those bounds.
        # Horizontal StackPanels hand children their desired width, so charts in side-by-side layouts want 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
    }
}