Public/Add-DhRangeBar.ps1

function Add-DhRangeBar {
    <#
    .SYNOPSIS
        Add a range-gradient bar chart - min / avg / max per category, normalised
        onto a shared 0-100 axis, coloured by position on that axis.

    .DESCRIPTION
        One horizontal floating bar per category:

          * the bar spans Min -> Max (it does NOT start at the axis origin)
          * a vertical tick marks Avg, positioned independently of the bar -
            it is deliberately NOT clamped inside [Min, Max], so an average
            computed over a different window shows up as the anomaly it is
          * every row is normalised against its own High value, so categories
            of wildly different magnitude become visually comparable
          * the fill is a fixed slice of ONE gradient spanning the whole track,
            so a bar's colour says where it sits on the 0-100 axis - not which
            category it is

        The gradient runs --accent-ok -> --accent-warn -> --accent-danger, so it
        follows whichever theme is active.

        Two ways to supply data, mutually exclusive:

          -Items explicit rows (static)
          -TableId derive rows from a table already added to the report.
                     Aggregation runs in the browser against the table's
                     FILTERED rows, so filtering the table live-recomputes the
                     ranges (same behaviour as Add-DhBarChart).

    .PARAMETER Report
        Dashboard object from New-DhDashboard.

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

    .PARAMETER Title
        Block heading shown above the chart.

    .PARAMETER Items
        Explicit rows. Array of hashtables, each requiring all six keys:

          @{
              Name = 'cluster01-users' # row label (must be unique)
              Low = 0 # axis floor in real units
              High = 315 # the value that maps to 100 on the axis
              Min = 60 # minimum observed
              Avg = 97 # average observed
              Max = 120 # maximum observed
          }

        Min / Avg / Max are real, un-normalised units. Normalisation to the
        0-100 axis happens at render time.

    .PARAMETER TableId
        Source table to aggregate. Must already be added with Add-DhTable.

    .PARAMETER CategoryField
        Column whose distinct values become the rows.

    .PARAMETER ValueField
        Numeric column aggregated into Min / Avg / Max within each category.

    .PARAMETER HighField
        Column carrying each row's own 100% reference (capacity, quota, ceiling).
        The maximum value seen within the category wins, so an inconsistent
        column cannot silently under-scale a bar. Highest precedence.

    .PARAMETER High
        One constant High for every row - the axis then reads as "% of a fixed
        ceiling". Used when -HighField is not given.

    .PARAMETER Low
        Axis floor in real units. Default 0.

    .PARAMETER LowField
        Per-category axis floor, the mirror of -HighField (minimum within the
        category wins). Overrides -Low.

    .PARAMETER MaxRows
        Guard against a category field with runaway cardinality. Throws when the
        table yields more distinct categories than this. Default 60.

    .PARAMETER AxisLabel
        Small right-aligned caption naming what 0-100 means. Auto-derived from
        the High source when not supplied - the one non-obvious mechanic a new
        reader needs spelled out.

    .PARAMETER Unit
        Suffix for the real-value labels and tooltip (e.g. 'req/s', 'ms', '%').

    .PARAMETER ShowDataTable
        Render a toggleable raw-values table under the chart (Low / High / Min /
        Avg / Max per row), off by default. The accessible / printable escape
        hatch, so every number is reachable without hovering.

    .EXAMPLE
        # Explicit rows
        Add-DhRangeBar -Report $report -Id 'cluster-load' -Title 'Cluster load range' `
            -Unit 'req/s' -ShowDataTable `
            -Items @(
                @{ Name='cluster01-users'; Low=0; High=315; Min=60; Avg=97; Max=120 }
                @{ Name='cluster02-bigdata'; Low=0; High=104; Min=1; Avg=2; Max=10 }
            )

    .EXAMPLE
        # Derived from a table, each cluster scaled against its own capacity
        Add-DhRangeBar -Report $report -Id 'cluster-load' -Title 'Cluster load range' `
            -TableId 'clusters' -CategoryField 'Cluster' `
            -ValueField 'LoadReqSec' -HighField 'CapacityReqSec' -Unit 'req/s'

    .EXAMPLE
        # Derived from a table against one fixed ceiling
        Add-DhRangeBar -Report $report -Id 'latency' -Title 'Latency range per site' `
            -TableId 'probes' -CategoryField 'Site' -ValueField 'RttMs' `
            -High 250 -Unit 'ms' -AxisLabel '% of 250 ms SLA'
    #>

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

        # ---- Mode A: explicit rows ----
        [Parameter(Mandatory, ParameterSetName = 'Items')] [object[]] $Items,

        # ---- Mode B: aggregate from a table ----
        [Parameter(Mandatory, ParameterSetName = 'FromTable')] [string] $TableId,
        [Parameter(Mandatory, ParameterSetName = 'FromTable')] [string] $CategoryField,
        [Parameter(Mandatory, ParameterSetName = 'FromTable')] [string] $ValueField,
        [Parameter(ParameterSetName = 'FromTable')] [string] $HighField = '',
        [Parameter(ParameterSetName = 'FromTable')] [string] $LowField  = '',
        [Parameter(ParameterSetName = 'FromTable')] [Nullable[double]] $High = $null,
        [Parameter(ParameterSetName = 'FromTable')] [double] $Low = 0,
        [Parameter(ParameterSetName = 'FromTable')]
        [ValidateRange(1, 500)]
        [int] $MaxRows = 60,

        # ---- Presentation ----
        [string] $AxisLabel = '',
        [string] $Unit      = '',
        [switch] $ShowDataTable,
        [bool]   $Collapsible = $true,    # wrap the block in a collapsible header (chevron + click-to-toggle)
        [bool]   $DefaultOpen = $true,    # whether the collapsible body starts expanded (only used when -Collapsible is $true)
        [ValidateRange(0,12)]
        [int]    $GridSpan    = 0,   # v1.10.0 - columns (1-12) to span in -report-grid layout; 0 = default flow
        [string] $NavGroup    = '',
        [string] $NavSubGroup = '',
        # ---- v2.0.0 navigation (see readme-dev.md section 15) ----
        [string] $NavPath      = '',   # 'A/B/C' - where this item lives. '//' escapes a literal '/'
        [ValidateSet('','Exact','Subtree','Global')]
        [string] $NavScope     = '',   # Exact (default) | Subtree (this node and below) | Global (everywhere)
        [string] $NavLabel     = '',   # display text for this node, decoupled from the path key
        [int]    $NavOrder     = 0     # explicit sibling ordering; 0 = declaration order
    )

    # v2.0.0 - resolve nav declaration once, in one place. Throws if -NavPath is
    # combined with -NavGroup/-NavSubGroup rather than silently preferring one.
    $navDecl = Resolve-DhNavDeclaration -NavPath $NavPath -NavScope $NavScope `
                    -NavGroup $NavGroup -NavSubGroup $NavSubGroup `
                    -ItemKind 'Block' -Context 'Add-DhRangeBar'
    # Monotonic declaration number, shared by tables and blocks. The exporter
    # emits ALL blocks before ALL tables, so without this the menu would order
    # nodes by section type instead of by the order the author wrote them.
    if (-not $Report.Contains('_DhNavSeq')) { $Report['_DhNavSeq'] = 0 }
    $navSeqNo = [int]$Report['_DhNavSeq']; $Report['_DhNavSeq'] = $navSeqNo + 1

    if (-not $Report.Contains('Blocks')) {
        $Report['Blocks'] = [System.Collections.Generic.List[hashtable]]::new()
    }
    foreach ($existing in $Report.Blocks) {
        if ($existing.Id -eq $Id) {
            throw "Add-DhRangeBar: A block with Id '$Id' already exists in this report. Use a unique Id."
        }
    }

    # Local InvariantCulture parser - a comma decimal separator in the host
    # culture must not turn 1.5 into 15.
    function _rbNum {
        param([object] $v, [string] $label, [string] $ctx)
        $n = 0.0
        $ok = [double]::TryParse(
            [string]$v,
            [System.Globalization.NumberStyles]::Float -bor [System.Globalization.NumberStyles]::AllowThousands,
            [System.Globalization.CultureInfo]::InvariantCulture,
            [ref]$n)
        if (-not $ok) {
            throw "Add-DhRangeBar: $ctx - $label must be a number, got: $v"
        }
        return $n
    }

    $mode      = $PSCmdlet.ParameterSetName
    $normItems = @()

    if ($mode -eq 'Items') {
        if (@($Items).Count -lt 1) {
            throw "Add-DhRangeBar: -Items must contain at least one row."
        }
        $seenNames = @{}
        $normItems = foreach ($item in $Items) {
            if ($item -isnot [hashtable] -and $item -isnot [System.Collections.Specialized.OrderedDictionary]) {
                throw "Add-DhRangeBar: each item must be a hashtable. Got: $($item.GetType().Name)"
            }
            foreach ($req in 'Name','Low','High','Min','Avg','Max') {
                if (-not $item.Contains($req) -or $null -eq $item[$req] -or [string]::IsNullOrWhiteSpace([string]$item[$req])) {
                    throw "Add-DhRangeBar: each item must have a non-empty '$req' key. Offending item: $($item['Name'])"
                }
            }
            $nm = [string]$item['Name']
            if ($seenNames.ContainsKey($nm)) {
                throw "Add-DhRangeBar: duplicate item Name '$nm'. Row names must be unique."
            }
            $seenNames[$nm] = $true

            $lo = _rbNum $item['Low']  'Low'  "item '$nm'"
            $hi = _rbNum $item['High'] 'High' "item '$nm'"
            $mn = _rbNum $item['Min']  'Min'  "item '$nm'"
            $av = _rbNum $item['Avg']  'Avg'  "item '$nm'"
            $mx = _rbNum $item['Max']  'Max'  "item '$nm'"

            # Spec 3.2 - never divide by zero and never silently clamp. Both
            # conditions surface as warnings so a data problem stays visible;
            # the renderer flags the degenerate row visually.
            if ($hi -le $lo) {
                Write-Warning "Add-DhRangeBar '$Id': item '$nm' has High ($hi) <= Low ($lo). The row will render muted at 0% - fix the source data."
            }
            elseif ($mx -gt $hi -or $mn -lt $lo) {
                Write-Warning "Add-DhRangeBar '$Id': item '$nm' has values outside its own scale (Low=$lo High=$hi Min=$mn Max=$mx). The bar clips at the axis edge; real numbers stay in the label and tooltip."
            }
            if ($mn -gt $mx) {
                Write-Warning "Add-DhRangeBar '$Id': item '$nm' has Min ($mn) > Max ($mx)."
            }

            [ordered]@{ Name = $nm; Low = $lo; High = $hi; Min = $mn; Avg = $av; Max = $mx }
        }
        $normItems = @($normItems)
    }
    else {
        # ---- FromTable: validate now, aggregate in the browser ----
        $tbl = $null
        if ($Report.Tables) { $tbl = $Report.Tables | Where-Object { $_.Id -eq $TableId } | Select-Object -First 1 }
        if (-not $tbl) {
            throw "Add-DhRangeBar: Source table '$TableId' not found. Add the table before the range bar."
        }
        if ($null -ne $High -and -not [string]::IsNullOrWhiteSpace($HighField)) {
            throw "Add-DhRangeBar: -High and -HighField are mutually exclusive. -HighField scales each row against its own ceiling; -High applies one ceiling to every row."
        }

        # Field names must exist, or the chart silently renders empty.
        $known = @{}
        foreach ($c in @($tbl.Columns)) { if ($c.Field) { $known[[string]$c.Field] = $true } }
        foreach ($r in @($tbl.Data))    { foreach ($k in $r.Keys) { $known[[string]$k] = $true } }
        foreach ($pair in @(
                @{ N = 'CategoryField'; V = $CategoryField },
                @{ N = 'ValueField';    V = $ValueField },
                @{ N = 'HighField';     V = $HighField },
                @{ N = 'LowField';      V = $LowField })) {
            if (-not [string]::IsNullOrWhiteSpace($pair.V) -and -not $known.ContainsKey($pair.V)) {
                throw "Add-DhRangeBar: -$($pair.N) '$($pair.V)' is not a column of table '$TableId'. Known fields: $(($known.Keys | Sort-Object) -join ', ')"
            }
        }

        # Cardinality + numeric sanity, reported once at generation time rather
        # than as a console.warn nobody will ever read.
        $cats     = @{}
        $badVals  = 0
        $totalRow = 0
        foreach ($r in @($tbl.Data)) {
            $totalRow++
            $cv = if ($null -ne $r[$CategoryField]) { [string]$r[$CategoryField] } else { '(empty)' }
            $cats[$cv] = $true
            $probe = 0.0
            $ok = [double]::TryParse(
                [string]$r[$ValueField],
                [System.Globalization.NumberStyles]::Float -bor [System.Globalization.NumberStyles]::AllowThousands,
                [System.Globalization.CultureInfo]::InvariantCulture,
                [ref]$probe)
            if (-not $ok) { $badVals++ }
        }
        if ($cats.Count -gt $MaxRows) {
            throw "Add-DhRangeBar: '$CategoryField' yields $($cats.Count) distinct categories, over -MaxRows ($MaxRows). Filter the table, pick a coarser field, or raise -MaxRows deliberately."
        }
        if ($badVals -gt 0 -and $badVals -lt $totalRow) {
            Write-Warning "Add-DhRangeBar '$Id': $badVals of $totalRow rows have a non-numeric '$ValueField' and will be skipped when aggregating."
        }
        if ($totalRow -gt 0 -and $badVals -eq $totalRow) {
            throw "Add-DhRangeBar: no row of table '$TableId' has a numeric '$ValueField'. The chart would render empty."
        }
        if ([string]::IsNullOrWhiteSpace($HighField) -and $null -eq $High) {
            Write-Verbose "Add-DhRangeBar: '$Id' - neither -HighField nor -High given; the axis will read as % of the largest '$ValueField' seen."
        }
    }

    # Spec section 6 - the caption must say what 0-100 means. Derive one when the
    # caller does not, so the chart is never shipped without it.
    $axisCaption = $AxisLabel
    if ([string]::IsNullOrWhiteSpace($axisCaption)) {
        $axisCaption =
            if ($mode -eq 'Items')                                 { '% of high' }
            elseif (-not [string]::IsNullOrWhiteSpace($HighField)) { "% of $HighField" }
            elseif ($null -ne $High)                               { "% of $High$(if ($Unit) { ' ' + $Unit })" }
            else                                                   { "% of max $ValueField" }
    }

    $Report.Blocks.Add([ordered]@{
        BlockType     = 'rangebar'
        Id            = $Id
        Title         = $Title
        Mode          = $(if ($mode -eq 'Items') { 'items' } else { 'fromtable' })
        Items         = $normItems
        TableId       = $TableId
        CategoryField = $CategoryField
        ValueField    = $ValueField
        HighField     = $HighField
        LowField      = $LowField
        High          = $High
        Low           = $Low
        MaxRows       = $MaxRows
        AxisLabel     = $axisCaption
        Unit          = $Unit
        ShowDataTable = [bool]$ShowDataTable
        NavGroup      = $NavGroup
        NavSubGroup   = $NavSubGroup
        NavPath       = $NavPath
        NavSegments   = $navDecl.Path
        NavSeqNo      = $navSeqNo
        NavScope      = $navDecl.Scope
        NavLabel      = $NavLabel
        NavOrder      = $NavOrder
        GridSpan      = $GridSpan
        Collapsible   = $Collapsible
        DefaultOpen   = $DefaultOpen
    })

    if ($mode -eq 'Items') {
        Write-Verbose "Add-DhRangeBar: '$Id' - $($normItems.Count) explicit rows."
    } else {
        Write-Verbose "Add-DhRangeBar: '$Id' - from table '$TableId' by '$CategoryField' over '$ValueField'."
    }
}