Public/Add-DhPieChart.ps1

function Add-DhPieChart {
    <#
    .SYNOPSIS
        Add a standalone pie or donut chart block to the dashboard.

    .DESCRIPTION
        Renders a pie / donut chart as a top-level block in the dashboard flow.
        Two data modes:

          Mode A — derive from a registered table:
            -TableId / -Field [ -TopN ] [ -ClickFilters ]
            Counts distinct values of the field across the table's rows.
            With -ClickFilters, clicking a slice applies the slice label as a
            text filter on the source table.

          Mode B — explicit slices:
            -Slices @( @{ Label='...'; Value=...; Color='...' } ... )
            Values are taken as-is. Use this for non-tabular data such as
            cost breakdowns or weighted distributions.

        The 'donut' style (default) leaves a hole in the centre and prints
        the total value there. 'pie' renders a solid disc.

        Per the IT-infrastructure KPI dashboard specification:
        "Avoid Pie Charts for more than 5 segments — use a horizontal bar
        chart instead." Use -TopN to cap segments or switch to Add-DhBarChart.

    .PARAMETER Report
        Dashboard object from New-DhDashboard.

    .PARAMETER Id
        Unique identifier (alphanumeric, dash, underscore).

    .PARAMETER Title
        Chart heading shown above the SVG.

    .PARAMETER Style
        'donut' (default) leaves a hole in the centre for the total.
        'pie' renders a solid disc with no hole.

    .PARAMETER TableId
        (Mode A) Id of a registered table to aggregate from. Must already be
        added via Add-DhTable before this cmdlet is called.

    .PARAMETER Field
        (Mode A) Field name on the source table. The chart counts distinct
        values of this field.

    .PARAMETER TopN
        (Mode A) Maximum number of segments to show. Slices beyond TopN are
        discarded (no 'Other' bucket — keep the chart honest). Default 10.

    .PARAMETER ClickFilters
        (Mode A) When set, clicking a slice applies the slice label as a text
        filter on the source table.

    .PARAMETER Slices
        (Mode B) Array of slice definitions:
          @{
              Label = 'us-east' # REQUIRED
              Value = 12500 # REQUIRED — numeric
              Color = '#0088BB' # optional — defaults to the chart palette
          }

    .PARAMETER ShowTotal
        Show the total value in the donut hole. Default $true.
        Ignored when -Style is 'pie'.

    .PARAMETER ShowLegend
        Show the legend with label / count / percentage rows. Default $true.

    .PARAMETER NavGroup
        Primary nav group label (enables two-tier nav).

    .PARAMETER NavSubGroup
        Optional second-level group under NavGroup (enables three-tier nav).

    .EXAMPLE
        # Mode A — derive from a table column
        Add-DhPieChart -Report $report -Id 'status-pie' -Title 'Items by status' `
            -TableId 'items' -Field 'Status' -ClickFilters

    .EXAMPLE
        # Mode B — explicit slices (e.g. cost by region)
        Add-DhPieChart -Report $report -Id 'cost-pie' -Title 'Monthly cost by region' `
            -Style 'donut' `
            -Slices @(
                @{ Label='us-east'; Value=12500 }
                @{ Label='eu-west'; Value=8200 }
                @{ Label='ap-south'; Value=3400 }
            )

    .EXAMPLE
        # Pie style, no donut hole, custom colours
        Add-DhPieChart -Report $report -Id 'license-pie' -Title 'License usage' `
            -Style 'pie' -ShowLegend $false `
            -Slices @(
                @{ Label='Used'; Value=820; Color='#1B6B35' }
                @{ Label='Available'; Value=180; Color='#8B5200' }
            )
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [System.Collections.Specialized.OrderedDictionary] $Report,
        [Parameter(Mandatory)]
        [ValidatePattern('^[A-Za-z0-9_-]+$')]
        [string] $Id,
        [Parameter(Mandatory)] [string] $Title,

        [ValidateSet('pie','donut')]
        [string] $Style = 'donut',

        # Mode A — derive from a registered table
        [string]   $TableId,
        [string]   $Field,
        [ValidateRange(1, [int]::MaxValue)]
        [int]      $TopN         = 10,
        [switch]   $ClickFilters,

        # Mode B — explicit slices
        [object[]] $Slices       = @(),

        # Display
        [bool]     $ShowTotal    = $true,
        [bool]     $ShowLegend   = $true,

        # Nav
        [string]   $NavGroup     = '',
        [string]   $NavSubGroup  = ''
    )

    # ---- Mode resolution --------------------------------------------------------
    $hasTable    = -not [string]::IsNullOrWhiteSpace($TableId)
    $hasSlices   = $Slices -and $Slices.Count -gt 0

    if (-not $hasTable -and -not $hasSlices) {
        throw "Add-DhPieChart: must specify either -TableId (with -Field) to derive from a table, or -Slices for explicit data."
    }
    if ($hasTable -and $hasSlices) {
        throw "Add-DhPieChart: cannot specify both -TableId and -Slices. Choose one data mode."
    }

    # Mode A validation
    if ($hasTable) {
        if ([string]::IsNullOrWhiteSpace($Field)) {
            throw "Add-DhPieChart: -Field is required when -TableId is used."
        }
        if ($Report.Tables -and -not ($Report.Tables | Where-Object { $_.Id -eq $TableId })) {
            throw "Add-DhPieChart: Source table '$TableId' not found. Add the table before the pie chart."
        }
    }

    # Mode B normalisation
    $normSlices = @()
    if ($hasSlices) {
        foreach ($s in $Slices) {
            if ($s -isnot [hashtable] -and $s -isnot [System.Collections.Specialized.OrderedDictionary]) {
                throw "Add-DhPieChart: Each slice must be a hashtable. Got: $($s.GetType().Name)"
            }
            if (-not $s.Contains('Label') -or $null -eq $s['Label']) {
                throw "Add-DhPieChart: Each slice must have a 'Label' key."
            }
            if (-not $s.Contains('Value') -or $null -eq $s['Value']) {
                throw "Add-DhPieChart: Each slice must have a 'Value' key."
            }
            $val = 0.0
            # InvariantCulture: PowerShell [string] on a double uses period decimal,
            # so the parse must too — otherwise locales like it-IT misinterpret it.
            $okV = [double]::TryParse(
                [string]$s['Value'],
                [System.Globalization.NumberStyles]::Float -bor [System.Globalization.NumberStyles]::AllowThousands,
                [System.Globalization.CultureInfo]::InvariantCulture,
                [ref]$val)
            if (-not $okV) {
                throw "Add-DhPieChart: Slice '$($s.Label)' has a non-numeric Value: '$($s.Value)'."
            }
            if ($val -lt 0) {
                throw "Add-DhPieChart: Slice '$($s.Label)' has a negative Value ($val). Pie segments must be non-negative."
            }
            $normSlices += @{
                Label = [string]$s.Label
                Value = $val
                Color = if ($s.Contains('Color')) { [string]$s.Color } else { '' }
            }
        }
    }

    if (-not $Report.Contains('Blocks')) {
        $Report['Blocks'] = [System.Collections.Generic.List[hashtable]]::new()
    }

    $Report.Blocks.Add([ordered]@{
        BlockType    = 'piechart'
        Id           = $Id
        Title        = $Title
        Style        = $Style
        Mode         = if ($hasTable) { 'fromtable' } else { 'explicit' }
        TableId      = if ($hasTable) { $TableId } else { '' }
        Field        = if ($hasTable) { $Field }   else { '' }
        TopN         = $TopN
        ClickFilters = [bool]$ClickFilters
        Slices       = @($normSlices)
        ShowTotal    = $ShowTotal
        ShowLegend   = $ShowLegend
        NavGroup     = $NavGroup
        NavSubGroup  = $NavSubGroup
    })

    Write-Verbose ("Add-DhPieChart: '$Id' (style=$Style, mode=" +
        $(if ($hasTable) { "fromtable '$TableId/$Field'" } else { "$($normSlices.Count) slice(s)" }) + ').')
}