PowerShellUniversal.Component.ECharts.psm1

$ErrorActionPreference = 'Stop'

#region Shared code and asset registration
# The build copies the repository's shared/powershell/*.ps1 into Private/; from a source checkout
# they are loaded from there directly.
$private = Join-Path $PSScriptRoot 'Private'
if (-not (Test-Path -LiteralPath $private)) { $private = Join-Path $PSScriptRoot '..' '..' 'shared' 'powershell' }
foreach ($file in Get-ChildItem -LiteralPath $private -Filter '*.ps1' -File) { . $file.FullName }

# the bundle's asset id for the hashtable PSU renders (shared/powershell/Register-PSUComponentAsset.ps1)
$Script:AssetId = Register-PSUComponentAsset -Path $PSScriptRoot
#endregion


function New-UDEChart {
    <#
    .SYNOPSIS
        An interactive Apache ECharts chart for PowerShell Universal apps.

    .DESCRIPTION
        Pass the data as it comes - a DataTable from SQL, objects from Invoke-RestMethod or any
        PowerShell command - name the columns, and the chart is built in the browser. Column names are
        matched without regard to case.

        Zoom (mouse wheel, box), the toolbox (box zoom, line/bar/stack switch, restore, PNG, CSV and
        Excel export), tooltips, hover highlighting and legend toggles are on by default. Light and
        dark follow the app's theme switch. -Options takes any Apache ECharts option
        (https://echarts.apache.org/en/option.html) and is merged over the generated one, so every
        ECharts feature stays reachable: objects merge key by key, arrays element by element, an object
        given for an array (yAxis = @{...}) applies to every element.

        Chart types and the columns they use:
        - line, area, step, bar, scatter: -X (category, date or number), -Y (one or more numbers) or
          -Y with -GroupBy (long data: one series per group value); -SecondaryY on a second axis
        - pie, donut, funnel: -X (name), -Y (value)
        - heatmap: -X, -Y (both categories), -Value
        - calendar: -X (date), -Y or -Value
        - boxplot: -X (group, optional), -Y (values) - quartiles, whiskers and outliers are computed
        - histogram: -Y (values), -Bins - with a median line unless -MarkLine says otherwise
        - candlestick: -X (date), -Open, -Close, -Low, -High, -Volume (optional, own panel)
        - gauge: -Y (value) of the first row, -Min, -Max
        - radar: -X (name per series), -Y (the axes)
        - sankey: -Source, -Target, -Value
        - treemap, sunburst: -Path (columns from the root down, or one column with "a/b/c"), -Y (value)
        - waterfall: -X (step), -Y (change), -Totals (steps that show the running total)
        - gantt: -X (row: server, job, person), -Start, -End, -GroupBy (colour, e.g. status), -Value
          (text in the bar) - maintenance windows, job runs, on-call shifts
        - graph: -Source, -Target, optional -Value, -GroupBy (group of the source node), -TargetGroup
          (group of the target node) - dependency and topology maps; drag, zoom, neighbours on hover
        - tree: -Path (levels, e.g. vCenter, Cluster, Host, VM), -Orient, -Depth - collapsible hierarchy
        - chord: -Source, -Target, -Value - overlaps or hand-overs between the same set of things
        - parallel: -Y (dimensions), -X (name), -GroupBy (colour) - compare many items across many
          measures; drag along an axis to filter
        - themeriver: -X (date), -GroupBy (category), -Y (value) - how a mix changes over time
        - matrix: -Y (rows, e.g. customer), -GroupBy (columns, e.g. KPI), -X (time inside a cell),
          -Value, -CellType - a table of sparklines with one scale per column
        - polar: -X (categories around the circle, e.g. hours), -Y or -GroupBy, -Stacked
        - radialbar: -X (one ring per category), -Y, -Max (the longest ring, three quarters of the circle)
        - bump: -X (period, in the order of the data), -GroupBy (item), -Y (value) - the rank of each
          item per period; equal values rank in the order the items first appear

    .PARAMETER Id
        Component id for Get-UDElement, Set-UDElement, Sync-UDElement and Set-UDEChartData.
    .PARAMETER Data
        Rows: a DataTable or DataView, objects, hashtables, or a script block returning them.
    .PARAMETER Type
        Chart type, see the description.
    .PARAMETER X
        Column for the x axis, the names (pie, funnel, radar) or the dates (calendar, candlestick).
    .PARAMETER Y
        Value column(s). Without -Y, line/bar/area use every numeric column except -X and -GroupBy.
    .PARAMETER GroupBy
        Column whose values become separate series (long data, e.g. one row per day and team).
    .PARAMETER Value
        Value column for heatmap, calendar and sankey.
    .PARAMETER Path
        Hierarchy columns for treemap and sunburst.
    .PARAMETER Source
        Source column for sankey.
    .PARAMETER Target
        Target column for sankey.
    .PARAMETER Open
        Opening price column for candlestick.
    .PARAMETER Close
        Closing price column for candlestick.
    .PARAMETER Low
        Low price column for candlestick.
    .PARAMETER High
        High price column for candlestick.
    .PARAMETER Volume
        Volume column for candlestick (shown in a second panel).
    .PARAMETER Totals
        Waterfall steps (values of -X) that show the running total, e.g. 'Q1', 'Year'.
    .PARAMETER Start
        Start column for gantt.
    .PARAMETER End
        End column for gantt.
    .PARAMETER Size
        Bubble size column for scatter (area grows with the value).
    .PARAMETER Band
        Two columns, lower and upper bound, drawn as a shaded range behind the series (forecast,
        confidence or normal operating range).
    .PARAMETER BandName
        Name of the band in the legend. Default: "<lower> - <upper>".
    .PARAMETER MarkArea
        Shaded ranges: @{ from = '2026-11-03T02:00'; to = '2026-11-03T04:00'; name = 'Maintenance' }
        on the x axis, or with axis = 'y' a value range (e.g. the SLA corridor); color optional.
    .PARAMETER GaugeStyle
        Progress (default, one value), Ring (one ring per row, e.g. availability per service) or Grade
        (arc coloured by -Thresholds, with a needle).
    .PARAMETER Thresholds
        Upper bounds with colours, ascending:
        @{ value = 95; color = '#e53935' }, @{ value = 99; color = '#fb8c00' }, @{ value = 100; color = '#43a047' }
        For -GaugeStyle Grade the bands of the dial; for line and area each stretch of the line in the
        colour of its band (e.g. red above an SLA), above the last bound in the last colour.
    .PARAMETER TargetGroup
        For graph: column naming the group of the target node (colour and legend).
    .PARAMETER Orient
        For tree: LR (default), RL, TB, BT or Radial.
    .PARAMETER Depth
        For tree: levels open at the start (default 2); click a node to open or close it.
    .PARAMETER RootName
        For tree: name of the common root when the data has several top-level values.
    .PARAMETER Layout
        For graph: Force (default; nodes arrange themselves, drag to move), Circular, or Layered (left
        to right along the links, e.g. firewall > core switch > access switch > ESXi > VM).
    .PARAMETER CellType
        For matrix: Line (default), Area or Bar in each cell.
    .PARAMETER EdgeLabel
        For graph: column written on each link, e.g. the ports of a connection ('Gi1/0/12 > vmnic2').
    .PARAMETER Undirected
        For graph: lines without arrows (cabling, peerings).
    .PARAMETER Rose
        Pie and donut as a rose (Nightingale) chart: the radius shows the value.
    .PARAMETER Title
        Title above the chart.
    .PARAMETER Subtitle
        Second line under the title.
    .PARAMETER Height
        CSS height. Default: 400px.
    .PARAMETER Width
        CSS width. Default: 100%.
    .PARAMETER ClassName
        CSS class of the chart's outer element, as on PSU's own components.
    .PARAMETER Style
        CSS styles of the chart's outer element, e.g. @{ marginTop = '8px'; border = '1px solid #ccc' }.
    .PARAMETER Theme
        Auto (follows the app's light/dark switch), Light or Dark.
    .PARAMETER LightTheme
        Corporate design for the light mode: an Apache ECharts theme as a hashtable, or the path of a
        theme JSON file as the ECharts theme builder downloads it ("Download theme", JSON; the builder's
        project file is refused). Merged over the module's own theme: what the theme
        sets wins, the rest stays. For every chart of an app at once:
        $PSDefaultParameterValues['New-UDEChart:LightTheme'] = '/path/on/the/psu/server/corporate-light.json'
    .PARAMETER DarkTheme
        Corporate design for the dark mode, as -LightTheme.
    .PARAMETER Palette
        Series colours, e.g. '#5470c6', '#91cc75'.
    .PARAMETER Legend
        Bottom, Top, Left, Right or None. Default: Bottom when there is more than one series.
    .PARAMETER LegendType
        Scroll (default): one line or column with paging arrows when the entries do not fit. Plain: all
        entries, wrapped over several lines (pie and donut make room for them).
    .PARAMETER Format
        Value format for axis, tooltip and labels: number, number:2, integer, currency, currency:USD,
        percent (0.25 -> 25 %), percent100 (25 -> 25 %), compact, compactcurrency:EUR, bytes, duration (seconds),
        duration:min, duration:h, date, datetime, time, text. Numbers use the browser's locale or -Locale.
    .PARAMETER LabelFormat
        Format for value labels (-DataLabels, pie, waterfall). Default: the short form of -Format
        (currency:EUR -> compactcurrency:EUR, "9,9 Mio. EUR" in de-DE), so long numbers do not overlap.
    .PARAMETER XFormat
        Format for x axis labels, e.g. date.
    .PARAMETER Locale
        Locale for numbers and dates, e.g. de-DE. Default: the browser's language.
    .PARAMETER XTitle
        Name of the x axis.
    .PARAMETER YTitle
        Name of the value axis.
    .PARAMETER SecondaryY
        Columns drawn against a second value axis on the right.
    .PARAMETER SecondaryTitle
        Name of the second value axis.
    .PARAMETER XAxisType
        Auto (dates become a time axis), Category, Time or Value.
    .PARAMETER Min
        Lower bound (gauge, heatmap/calendar colour scale, radar).
    .PARAMETER Max
        Upper bound (gauge, heatmap/calendar colour scale, radar).
    .PARAMETER YMin
        Lower bound of the value axis.
    .PARAMETER YMax
        Upper bound of the value axis.
    .PARAMETER Bins
        Number of histogram bins. Default: from the number of values (Sturges).
    .PARAMETER Aggregate
        Combine rows with the same x (and group): Sum, Avg, Count, Min or Max (line, area, bar, step,
        scatter, pie, donut, funnel, polar, radialbar; bump ranks by Sum by default). Without it the last
        row wins. Count counts rows; the others ignore empty values and leave a gap when there is none.
        On a time axis only rows with exactly the same timestamp combine: round dates first (to the day,
        the hour) to count events per day.
    .PARAMETER EmptyText
        Text shown when -Data has no rows. Default: No data.
    .PARAMETER Stacked
        Stack the series (line, area, bar).
    .PARAMETER Horizontal
        Horizontal bars (bar, boxplot).
    .PARAMETER Normalize
        Stack the series to 100 % per category (shares); the tooltip shows share and original value.
    .PARAMETER Smooth
        Smooth lines.
    .PARAMETER LogScale
        Logarithmic value axis.
    .PARAMETER DataLabels
        Values on bars, points and slices (pie, donut and funnel show them by default).
    .PARAMETER MarkLine
        Reference lines: 'median', 'average', 'min', 'max' (per series) or
        @{ value = 48; name = 'SLA'; color = '#e53935' } (fixed; add axis = 'x' for a vertical line).
    .PARAMETER MarkPoint
        Pins on single values: 'min', 'max', 'average' (per series) or
        @{ x = 'Thu'; y = 306; label = 'Outage'; series = 'Mail' } (one point; without series on the first).
    .PARAMETER LabelColumn
        Show this column of the row behind each point as its label (every chart type).
    .PARAMETER LabelTemplate
        Label text from the row: '{Name} ({Cpu} vCPU, {RamGB:integer} GB)'. A placeholder is a column,
        optionally with a format of -Format ('{Size:bytes}', '{Share:percent100:1}'); a list column is
        joined with commas; `n starts a new line. Points without a row of their own (a tree's folders,
        aggregated nodes) keep their name.
    .PARAMETER TooltipColumns
        Columns of the row shown in the tooltip, one line each, optionally with a format and a label:
        'Cpu', 'RamGB:integer', 'Used:bytes', 'Memory (GB)=RamGB:integer'.
    .PARAMETER TooltipTemplate
        Tooltip text from the row, placeholders as in -LabelTemplate; `n starts a new line. Values are
        HTML-escaped.
    .PARAMETER Trend
        Linear: a dashed least-squares trend line per series (line, area, bar, scatter).
    .PARAMETER Forecast
        With -Trend on a time or value axis: continue the trend for this many steps of the data.
    .PARAMETER TrendTarget
        With -Trend: continue the trend until it reaches this value and mark the date (a datastore at
        100 %, a licence limit). Only when that lies ahead and within five times the data's span.
    .PARAMETER TrendName
        Legend name of the trend line. Default: '<series> (trend)'.
    .PARAMETER NegativeColor
        Colour for negative bars (bar, waterfall).
    .PARAMETER PositiveColor
        Colour for rising waterfall steps.
    .PARAMETER Breaks
        Gaps cut out of a time or value x axis: @{ start = ...; end = ...; gap = '1%' }, start and end
        as dates (time axis) or numbers. New-UDEChartTimeBreak builds them for nights and weekends.
        Not possible on category axes.
    .PARAMETER YBreaks
        Gaps cut out of the value axis, e.g. @{ start = 200; end = 5000 } when one bar dwarfs the rest.
    .PARAMETER Facet
        Small multiples: one small chart per value of this column (e.g. one per weekday), all with the
        same value scale (line, area, step, bar, scatter).
    .PARAMETER FacetColumns
        Small multiples per row. Default: up to 4.
    .PARAMETER Sparkline
        A tiny trend without axes, legend and toolbox, for tiles and tables (line, area, bar).
        Use with -Height, e.g. 60px.
    .PARAMETER Zoom
        Auto (default: mouse wheel and drag; plus a slider with a preview of the data on time axes and
        with more than 60 categories), Inside (wheel and drag only), Slider, Both or None.
    .PARAMETER ZoomAxis
        X (default: along the data), Y (the value axis, to look into small values next to large ones)
        or Both. With Both the mouse wheel zooms X, Shift + wheel zooms Y.
    .PARAMETER Brush
        Select a range with the mouse (toolbox button); fires -OnSelect.
    .PARAMETER NoToolbox
        Hide the toolbox.
    .PARAMETER NoExport
        Hide the CSV and Excel buttons.
    .PARAMETER NoFullscreen
        Hide the toolbox button that shows the chart alone on the whole screen (Esc returns). The
        button is hidden anyway where the browser cannot show an element full screen (iPhone Safari).
    .PARAMETER ExportColumns
        Columns of the CSV/Excel export, in order: names, or hashtables
        @{ field = 'leadTime'; title = 'Lead time (h)'; type = 'number'; format = 'number:1' }
        with type text, number, integer, currency, percent, date, datetime or boolean. Default: all
        columns of -Data with their types guessed.
    .PARAMETER EventColumns
        Columns of the data row that events send back to the server ($EventData.row of a click, the rows
        of a parallel selection). Default: the whole row. Keeps messages small with wide rows; it does
        not hide data - everything in -Data is in the browser anyway.
    .PARAMETER ExportFileName
        File name without extension for PNG, CSV and Excel. Default: the title.
    .PARAMETER Group
        Charts with the same group share tooltip, zoom and legend.
    .PARAMETER Decal
        Patterns on top of colours (helps colour-blind readers and black-and-white prints).
    .PARAMETER Renderer
        Canvas (default, fast) or Svg (sharp when printed or scaled).
    .PARAMETER Options
        Any Apache ECharts option, merged over the generated one.
    .PARAMETER OnClick
        Runs when a bar, point or slice is clicked. $EventData: seriesName, seriesIndex, dataIndex,
        name, value, row (the clicked data row, when the chart has fewer than 20000 points).
    .PARAMETER OnSelect
        Runs when a range is selected with -Brush. $EventData: from, to (ISO dates on a time axis,
        category names on a category axis, numbers otherwise). For parallel, after dragging along an
        axis: filtered, count and rows (the data rows of the lines still shown, at most 1000).
    .PARAMETER OnZoom
        Runs after zooming. $EventData: from, to, start, end (percent).
    .PARAMETER OnLegendChange
        Runs when a series is shown or hidden. $EventData: name, selected (map of all series).

    .EXAMPLE
        New-UDEChart -Data (Get-Process | Sort-Object CPU -Descending | Select-Object -First 10 Name, CPU) -Type bar -X Name -Y CPU

        Ten busiest processes as bars.

    .EXAMPLE
        $tickets = Invoke-Sqlcmd -Query 'SELECT Day, Team, Opened FROM dbo.TicketsPerDay' -OutputAs DataTables
        New-UDEChart -Data $tickets -Type line -X Day -Y Opened -GroupBy Team -Zoom Both -Brush -OnSelect {
            Sync-UDElement -Id 'ticket-table' # reload a table for $EventData.from .. $EventData.to
        }

        One line per team from a DataTable, range selection drives another element.

    .EXAMPLE
        New-UDEChart -Data $leadTimes -Type histogram -Y Hours -Format 'duration:h' -MarkLine 'median', @{ value = 48; name = 'SLA' }

        Distribution of lead times with median and SLA line.

    .EXAMPLE
        New-UDEChart -Data $rows -Type bar -X Month -Y Revenue -Options @{
            yAxis = @{ splitLine = @{ show = $false } }
            series = @(@{ itemStyle = @{ borderRadius = 4 } })
        }

        Simple parameters plus any ECharts option.

    .OUTPUTS
        System.Collections.Hashtable
    #>

    # describes a chart (or its axis breaks) for PSU to render; changes no state, so no ShouldProcess
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [string]$Id = [guid]::NewGuid().ToString(),
        [Parameter(Mandatory)][AllowNull()][object]$Data,
        [ValidateSet('line', 'area', 'bar', 'scatter', 'step', 'pie', 'donut', 'funnel', 'heatmap', 'calendar', 'boxplot', 'histogram', 'candlestick', 'gauge', 'radar', 'sankey', 'treemap', 'sunburst', 'waterfall', 'gantt', 'graph', 'tree', 'chord', 'parallel', 'themeriver', 'matrix', 'polar', 'radialbar', 'bump')]
        [string]$Type = 'line',
        [string]$X,
        [string[]]$Y,
        [string]$GroupBy,
        [string]$Value,
        [string[]]$Path,
        [string]$Source,
        [string]$Target,
        [string]$Open,
        [string]$Close,
        [string]$Low,
        [string]$High,
        [string]$Volume,
        [string[]]$Totals,
        [string]$Start,
        [string]$End,
        [string]$Size,
        [ValidateCount(2, 2)][string[]]$Band,
        [string]$BandName,
        [object[]]$MarkArea,
        [ValidateSet('Progress', 'Ring', 'Grade')][string]$GaugeStyle = 'Progress',
        [object[]]$Thresholds,
        [switch]$Rose,
        [string]$TargetGroup,
        [ValidateSet('LR', 'RL', 'TB', 'BT', 'Radial')][string]$Orient,
        [ValidateRange(1, 20)][int]$Depth,
        [string]$RootName,
        [ValidateSet('Force', 'Circular', 'Layered')][string]$Layout,
        [string]$EdgeLabel,
        [ValidateSet('Line', 'Area', 'Bar')][string]$CellType,
        [switch]$Undirected,
        [object[]]$MarkPoint,
        [string]$LabelColumn,
        [string]$LabelTemplate,
        [string[]]$TooltipColumns,
        [string]$TooltipTemplate,
        [ValidateSet('Linear')][string]$Trend,
        [ValidateRange(1, 10000)][int]$Forecast,
        [Nullable[double]]$TrendTarget,
        [string]$TrendName,
        [string]$Title,
        [string]$Subtitle,
        [string]$Height = '400px',
        [string]$Width,
        [string]$ClassName,
        [hashtable]$Style,
        [ValidateSet('Auto', 'Light', 'Dark')][string]$Theme = 'Auto',
        [object]$LightTheme,
        [object]$DarkTheme,
        [string[]]$Palette,
        [ValidateSet('Bottom', 'Top', 'Left', 'Right', 'None')][string]$Legend,
        [ValidateSet('Scroll', 'Plain')][string]$LegendType,
        [ValidatePattern('^(number|integer|currency|compactcurrency|percent|percent100|compact|bytes|duration|date|datetime|time|text)(:[A-Za-z0-9]+)?$')][string]$Format,
        [ValidatePattern('^(number|integer|currency|compactcurrency|percent|percent100|compact|bytes|duration|date|datetime|time|text)(:[A-Za-z0-9]+)?$')][string]$LabelFormat,
        [ValidatePattern('^(number|integer|currency|percent|percent100|compact|bytes|duration|date|datetime|time|text)(:[A-Za-z0-9]+)?$')][string]$XFormat,
        [ValidatePattern('^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$')][string]$Locale,
        [string]$XTitle,
        [string]$YTitle,
        [string[]]$SecondaryY,
        [string]$SecondaryTitle,
        [ValidateSet('Auto', 'Category', 'Time', 'Value')][string]$XAxisType = 'Auto',
        [Nullable[double]]$Min,
        [Nullable[double]]$Max,
        [Nullable[double]]$YMin,
        [Nullable[double]]$YMax,
        [ValidateRange(1, 500)][int]$Bins,
        [ValidateSet('Sum', 'Avg', 'Count', 'Min', 'Max')][string]$Aggregate,
        [string]$EmptyText,
        [switch]$Stacked,
        [switch]$Horizontal,
        [switch]$Normalize,
        [switch]$Smooth,
        [switch]$LogScale,
        [switch]$DataLabels,
        [object[]]$MarkLine,
        [string]$NegativeColor,
        [string]$PositiveColor,
        [object[]]$Breaks,
        [object[]]$YBreaks,
        [string]$Facet,
        [ValidateRange(1, 12)][int]$FacetColumns,
        [switch]$Sparkline,
        [ValidateSet('Auto', 'Inside', 'Slider', 'Both', 'None')][string]$Zoom = 'Auto',
        [ValidateSet('X', 'Y', 'Both')][string]$ZoomAxis = 'X',
        [switch]$Brush,
        [switch]$NoToolbox,
        [switch]$NoExport,
        [switch]$NoFullscreen,
        [object[]]$ExportColumns,
        [string[]]$EventColumns,
        [string]$ExportFileName,
        [string]$Group,
        [switch]$Decal,
        [ValidateSet('Canvas', 'Svg')][string]$Renderer = 'Canvas',
        [hashtable]$Options,
        [Endpoint]$OnClick,
        [Endpoint]$OnSelect,
        [Endpoint]$OnZoom,
        [Endpoint]$OnLegendChange
    )

    if (-not $Script:AssetId) {
        throw 'PowerShellUniversal.Component.ECharts: the JavaScript bundle (index.<hash>.bundle.js) was not found next to the module, or the module was imported outside PowerShell Universal.'
    }
    foreach ($endpoint in $OnClick, $OnSelect, $OnZoom, $OnLegendChange) {
        if ($endpoint) { $endpoint.Register($Id, $PSCmdlet) }
    }

    $props = @{
        assetId   = $Script:AssetId
        isPlugin  = $true
        type      = 'ud-echart'
        id        = $Id
        data      = ConvertTo-PSUComponentData -InputObject $Data
        chartType = $Type
        height    = $Height
        theme     = $Theme.ToLowerInvariant()
        zoom      = $Zoom.ToLowerInvariant()
        renderer  = $Renderer.ToLowerInvariant()
        toolbox   = -not $NoToolbox
        export    = -not $NoExport
        fullscreen = -not $NoFullscreen
        stacked   = [bool]$Stacked
        horizontal = [bool]$Horizontal
        normalize = [bool]$Normalize
        zoomAxis  = $ZoomAxis.ToLowerInvariant()
        smooth    = [bool]$Smooth
        logScale  = [bool]$LogScale
        brush     = [bool]$Brush
        decal     = [bool]$Decal
        sparkline = [bool]$Sparkline
        rose      = [bool]$Rose
        gaugeStyle = $GaugeStyle.ToLowerInvariant()
        xAxisType = $XAxisType.ToLowerInvariant()
    }
    # DataLabels: unset means the chart type's default (pie shows labels, bars do not)
    if ($PSBoundParameters.ContainsKey('DataLabels')) { $props['dataLabels'] = [bool]$DataLabels }
    # only what was given; the browser side has a default for everything else
    $map = [ordered]@{
        X = 'x'; Y = 'y'; GroupBy = 'groupBy'; Value = 'value'; Path = 'path'; Source = 'source'; Target = 'target'
        Open = 'open'; Close = 'close'; Low = 'low'; High = 'high'; Volume = 'volume'; Totals = 'totals'
        Title = 'title'; Subtitle = 'subtitle'; Width = 'width'; ClassName = 'className'; Style = 'style'; Palette = 'palette'; Format = 'format'; LabelFormat = 'labelFormat'; XFormat = 'xFormat'
        Locale = 'locale'; XTitle = 'xTitle'; YTitle = 'yTitle'; SecondaryY = 'secondaryY'; SecondaryTitle = 'secondaryTitle'
        Min = 'min'; Max = 'max'; YMin = 'yMin'; YMax = 'yMax'; Bins = 'bins'; MarkLine = 'markLine'
        NegativeColor = 'negativeColor'; PositiveColor = 'positiveColor'; ExportColumns = 'exportColumns'; EventColumns = 'eventColumns'
        ExportFileName = 'exportFileName'; Group = 'group'; Options = 'options'
        Breaks = 'breaks'; YBreaks = 'yBreaks'; Facet = 'facet'; FacetColumns = 'facetColumns'
        Start = 'start'; End = 'end'; Size = 'size'; Band = 'band'; BandName = 'bandName'; MarkArea = 'markArea'; Thresholds = 'thresholds'; TargetGroup = 'targetGroup'
        Orient = 'orient'; Depth = 'depth'; RootName = 'rootName'; EdgeLabel = 'edgeLabel'; MarkPoint = 'markPoint'; LabelColumn = 'labelColumn'; LabelTemplate = 'labelTemplate'; TooltipColumns = 'tooltipColumns'; TooltipTemplate = 'tooltipTemplate'; Forecast = 'forecast'; TrendTarget = 'trendTarget'; TrendName = 'trendName'
    }
    foreach ($name in $map.Keys) {
        if ($PSBoundParameters.ContainsKey($name)) { $props[$map[$name]] = $PSBoundParameters[$name] }
    }
    if ($PSBoundParameters.ContainsKey('CellType')) { $props['cellType'] = $CellType.ToLowerInvariant() }
    if ($Undirected) { $props['directed'] = $false }
    foreach ($name in 'LightTheme', 'DarkTheme') {
        if ($PSBoundParameters.ContainsKey($name)) { $props[$name.Substring(0, 1).ToLowerInvariant() + $name.Substring(1)] = ConvertTo-UDEChartTheme -Theme $PSBoundParameters[$name] }
    }
    if ($PSBoundParameters.ContainsKey('LegendType')) { $props['legendType'] = $LegendType.ToLowerInvariant() }
    if ($PSBoundParameters.ContainsKey('Aggregate')) { $props['aggregate'] = $Aggregate.ToLowerInvariant() }
    if ($PSBoundParameters.ContainsKey('EmptyText')) { $props['emptyText'] = $EmptyText }
    # parameters that only one family of chart types reads: say so instead of ignoring them silently
    $onlyFor = @{
        Source = 'sankey', 'graph', 'chord'; Target = 'sankey', 'graph', 'chord'; TargetGroup = 'graph'; Layout = 'graph'; EdgeLabel = 'graph'; Undirected = 'graph'
        Path = 'treemap', 'sunburst', 'tree'; Orient = 'tree'; Depth = 'tree'; RootName = 'tree'
        Open = 'candlestick'; Close = 'candlestick'; Low = 'candlestick'; High = 'candlestick'; Volume = 'candlestick'
        Start = 'gantt'; End = 'gantt'; Bins = 'histogram'; GaugeStyle = 'gauge'; Rose = 'pie', 'donut'; Totals = 'waterfall'; CellType = 'matrix'
    }
    foreach ($name in $onlyFor.Keys) {
        if ($PSBoundParameters.ContainsKey($name) -and $Type -notin $onlyFor[$name]) {
            Write-Warning ('New-UDEChart: -{0} is used by -Type {1} only; -Type {2} ignores it.' -f $name, ($onlyFor[$name] -join ', '), $Type)
        }
    }
    if ($PSBoundParameters.ContainsKey('Layout')) { $props['layout'] = $Layout.ToLowerInvariant() }
    if ($PSBoundParameters.ContainsKey('Trend')) { $props['trend'] = $Trend.ToLowerInvariant() }
    if ($PSBoundParameters.ContainsKey('Legend')) { $props['legend'] = $Legend.ToLowerInvariant() }
    if ($OnClick) { $props['onClick'] = $OnClick }
    if ($OnSelect) { $props['onSelect'] = $OnSelect }
    if ($OnZoom) { $props['onZoom'] = $OnZoom }
    if ($OnLegendChange) { $props['onLegendChange'] = $OnLegendChange }
    $props
}

function ConvertTo-UDEChartTheme {
    <#
    .SYNOPSIS
        Turns a theme parameter (hashtable, object or JSON file path) into the theme object the chart uses.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param([Parameter(Mandatory)][AllowNull()][object]$Theme)

    if ($null -eq $Theme) { return $null }
    if ($Theme -is [string]) {
        if (-not (Test-Path -LiteralPath $Theme -PathType Leaf)) { throw ('Theme file not found: {0}' -f $Theme) }
        $Theme = Get-Content -LiteralPath $Theme -Raw | ConvertFrom-Json -AsHashtable
    }
    elseif ($Theme -is [System.Management.Automation.PSCustomObject]) {
        $Theme = $Theme | ConvertTo-Json -Depth 30 | ConvertFrom-Json -AsHashtable
    }
    if ($Theme -isnot [System.Collections.IDictionary]) { throw 'A theme is a hashtable, an object or the path of a theme JSON file.' }
    # the theme builder's project file ("Export config", *.project.json) holds the builder's own
    # settings, not an ECharts theme (apache/echarts-theme-builder, src/stores/theme.ts)
    if ($Theme.Contains('themeName') -and $Theme.Contains('version') -and $Theme.Contains('theme')) {
        throw 'This is an ECharts theme builder project file. Use "Download theme" in the builder and choose JSON.'
    }
    $Theme
}

function New-UDEChartTimeBreak {
    <#
    .SYNOPSIS
        Axis breaks that cut nights and/or weekends out of a time axis (intraday charts, business hours).
    .DESCRIPTION
        Returns one break per night (between -DayEnd and the next -DayStart) and, with -Weekends, one per
        weekend, between -From and -To, ready for New-UDEChart -Breaks. Dates are written in ISO 8601 with
        the local offset, like the chart's own data, so the cuts line up with the points.
    .PARAMETER From
        Start of the period.
    .PARAMETER To
        End of the period.
    .PARAMETER DayStart
        Start of the visible part of a day. Default: 08:00.
    .PARAMETER DayEnd
        End of the visible part of a day. Default: 18:00.
    .PARAMETER Weekends
        Also cut out Saturdays and Sundays completely.
    .PARAMETER Gap
        Visual width of each cut, in percent of the axis. Default: 1%.
    .EXAMPLE
        $breaks = New-UDEChartTimeBreak -From (Get-Date).AddDays(-14) -To (Get-Date) -DayStart '07:00' -DayEnd '19:00' -Weekends
        New-UDEChart -Data $samples -Type line -X Time -Y Latency -Breaks $breaks
    .OUTPUTS
        System.Collections.Hashtable
    #>

    # describes a chart (or its axis breaks) for PSU to render; changes no state, so no ShouldProcess
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)][datetime]$From,
        [Parameter(Mandatory)][datetime]$To,
        [timespan]$DayStart = '08:00',
        [timespan]$DayEnd = '18:00',
        [switch]$Weekends,
        [ValidatePattern('^\d+(\.\d+)?%$')][string]$Gap = '1%'
    )
    if ($DayEnd -le $DayStart) { throw 'DayEnd must be later than DayStart.' }
    $day = $From.Date
    $cuts = [System.Collections.Generic.List[object]]::new()
    while ($day -le $To.Date) {
        $isWeekend = $Weekends -and $day.DayOfWeek -in 'Saturday', 'Sunday'
        if ($isWeekend) {
            $cuts.Add(@($day, ($day.AddDays(1))))
        }
        else {
            # parentheses: the comma binds tighter than +
            $cuts.Add(@($day, ($day + $DayStart)))
            $cuts.Add(@(($day + $DayEnd), ($day.AddDays(1))))
        }
        $day = $day.AddDays(1)
    }
    # neighbouring cuts (evening + night + next morning, whole weekends) become one break
    $merged = [System.Collections.Generic.List[object]]::new()
    foreach ($c in $cuts) {
        if ($merged.Count -gt 0 -and $merged[$merged.Count - 1][1] -ge $c[0]) { $merged[$merged.Count - 1][1] = $c[1] }
        else { $merged.Add(@($c[0], $c[1])) }
    }
    foreach ($m in $merged) {
        $start = if ($m[0] -lt $From) { $From } else { $m[0] }
        $end = if ($m[1] -gt $To) { $To } else { $m[1] }
        if ($end -gt $start) { @{ start = $start.ToString('o'); end = $end.ToString('o'); gap = $Gap } }
    }
}

function Set-UDEChartData {
    <#
    .SYNOPSIS
        Replaces the data of a chart on the page without redrawing the page (live updates).
    .DESCRIPTION
        Converts the data like New-UDEChart -Data and sends it with Set-UDElement. Zoom and hidden
        series stay as they are. Call it from an endpoint or a timer (New-UDDynamic -AutoRefresh,
        New-UDElement -AutoRefresh) inside the app.
    .PARAMETER Id
        Id of the chart (New-UDEChart -Id).
    .PARAMETER Data
        Rows, as for New-UDEChart.
    .EXAMPLE
        $sample = Get-Counter '\Processor(_Total)\% Processor Time' -MaxSamples 1
        $Cache:Cpu = @($Cache:Cpu) + [pscustomobject]@{ Time = $sample.Timestamp; Load = [math]::Round($sample.CounterSamples[0].CookedValue, 1) } | Select-Object -Last 120
        Set-UDEChartData -Id 'cpu' -Data $Cache:Cpu
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)][string]$Id,
        [Parameter(Mandatory)][AllowNull()][object]$Data
    )
    if ($PSCmdlet.ShouldProcess($Id, 'Set chart data')) {
        Set-UDElement -Id $Id -Properties @{ data = ConvertTo-PSUComponentData -InputObject $Data }
    }
}

Export-ModuleMember -Function New-UDEChart, New-UDEChartTimeBreak, Set-UDEChartData