PSparklines.psm1

using namespace System.Collections;
using namespace System.Collections.Generic;

<#
  
   ____ ____ _ _ _
  | _ \/ ___| _ __ __ _ _ __| | _| (_)_ __ ___ ___
  | |_) \___ \| '_ \ / _` | '__| |/ / | | '_ \ / _ \/ __|
  | __/ ___) | |_) | (_| | | | <| | | | | | __/\__ \
  |_| |____/| .__/ \__,_|_| |_|\_\_|_|_| |_|\___||___/
              |_|
  
#>

<#
.Synopsis
  This module is a very simple way to show text sparklines in the console.
.Description
  This module is a very simple way to show text sparklines in the console.
  It was ported to PowerShell from Python. The original package is sparklines.py.
  It is hosted on github.com at github.com/deeplook/sparklines.
   
  This module does implement emphasis in a manner similar to the original.
  However, instead of a simple string pattern, it uses Emphasis objects.
  Objects are added to a dictionary with functions that support auto-completion.
 
  This module does not implement the batching (array splitting) that sparklines used.
  This module does not implement ANSI colors, although the new consoles for PowerShell support color codes.
 
  This module also outputs Sparklines as objects and uses two different functions to write them.
  `Show-Sparkline` will write the sparkline to the host and STDINFO (6) and colorize based on an emphasis table.
  `Write-Sparkline` will write the sparkline to STDOUT (1) as a string for further parsing or use.
  Because `Get-Sparkline` will write objects, the user can write a custom function to write the sparkline
  how they need if the default functions are inadequate.
 
  Cmdlets/Functions for Sparklines:
    Get-Sparkline
    Write-Sparkline
    Show-Sparkline
 
  Cmdlets/Functions for Emphasis:
    New-EmphasisTable
    Add-Emphasis
    Set-Emphasis
.Example
  PS> Get-Sparkline 25, 50, 75, 100, 25 -EmphasisTable (New-EmphasisTable | Add-Emphasis Red Gt 50) |
    Show-Sparkline
 
    Display a sparkline in the host of line height 1 with every bar representing a number greater than 50
    as ConsoleColor.Red
#>


param()

<#
  
   ___ _
  / __| ___| |_ _ _ _ __
  \__ \/ -_) _| || | '_ \
  |___/\___|\__|\_,_| .__/
                    |_|
  
#>

#region Setup ------------------------------------------------------------------

$ErrorActionPreference = 'Stop' 
$ModuleRoot = Split-Path $PSScriptRoot -Leaf
$ResourceFile = @{ 
    BindingVariable = 'Resources'
    BaseDirectory = $PSScriptRoot
    FileName = $ModuleRoot + '.Resources.psd1'
}
$ConfigFile = @{
    BindingVariable = 'Config'
    BaseDirectory = $PSScriptRoot
    FileName = $ModuleRoot + '.Config.psd1'
}

# Try to import the resource file
try {
    Import-LocalizedData @ResourceFile 
}
catch {
    # Uh-oh. The module is likely broken if this file cannot be found.
    Import-LocalizedData @ResourceFile -UICulture en-US
}

# Try to import the config file.
try {
    Import-LocalizedData @ConfigFile
}
catch { 
    # The config file is missing. Not a big deal! Here's a default Config.
    $Config = @{
        Blocks = @'
 ▁▂▃▄▅▆▇█
'@

    } 
}

# Module variables go here
Set-Variable PSparklines -Option ReadOnly -Value @{
    DefaultForegroundColor = [Console]::ForegroundColor
}

#endregion

<#
  
    ___ _
   / __| |__ _ ______ ___ ___
  | (__| / _` (_-<_-</ -_|_-<
   \___|_\__,_/__/__/\___/__/
                              
  
#>

#region Module Classes ---------------------------------------------------------


enum Comparer {
    Eq
    Ne
    Gt
    Ge
    Lt
    Le 
}


class Emphasis {
    [ConsoleColor] $Color
    [Comparer] $Comparer
    [double] $Target
}


class Spark {
    [int] $Row
    [int] $Col
    [int] $Val
    [string] $Block
    
    [AllowNull()]
    [ConsoleColor] $Color
}


#endregion

<#
  
   _ _ _
  | || |___| |_ __ ___ _ _ ___
  | __ / -_) | '_ \/ -_) '_(_-<
  |_||_\___|_| .__/\___|_| /__/
             |_|
  
#>

#region Class Helpers ----------------------------------------------------------


function New-Emphasis ($Color, $Comparer, $Target) {
    # .Synopsis
    # Creates a new Emphasis object.
    # ConsoleColor -> Comparer -> double -> Emphasis
    # .Notes
    # Replaces the emph pattern used in sparklines.py

    [Emphasis] @{
        Color    = $Color
        Comparer = $Comparer
        Target   = $Target
    } 
}


function Get-EmphasisIndex ($a, $d) {
    # .Synopsis
    # Creates a filter function from an Emphasis Dictionary. Then, creates a hashtable keyed with
    # the indices of the array whose values pass through the filter. The keys hold the color object
    # for that index.
    # double[] -> Dictionary<string, Emphasis> -> hashtable<int, ConsoleColor>
    # .Notes
    # Replaces _check_emphasis(numbers, emph)

    $emphasized = @{}
    $setEmphasized = {
        $x = $_.Target
        $filter = 
            switch ($_.Comparer) {
                eq { { $a[$_] -eq $x } }
                ne { { $a[$_] -ne $x } }
                gt { { $a[$_] -gt $x } }
                ge { { $a[$_] -ge $x } }
                lt { { $a[$_] -lt $x } }
                le { { $a[$_] -le $x } }
            }
        $color = $_.Color
        $setIdx = { $emphasized[$_] = $color } 

        0..($a.Length - 1) |
            Where-Object $filter | 
            ForEach-Object $setIdx
    }
    
    $d.Values.ForEach($setEmphasized) 
    $emphasized
}


function Get-Max ($a, $b) { [Math]::Max($a, $b) }
function Get-Min ($a, $b) { [Math]::Min($a, $b) }
function Get-RoundUp ($n) { [Math]::Round($n) }


function Get-ScaledValues {
    # .Synopsis
    # Scale input numbers to appropriate range.
    # double[] -> int -> double? -> double? -> double[]
    # .Notes
    # Replaces scale_values(numbers, num_lines=1, minium=None, maximum=None)

    param( 
        [double[]] $Numbers
        , 
        [int] $NumLines = 1
        , 
        [double] $Minimum
        ,
        [double] $Maximum
    )

    $Numbers |
        Measure-Object -Minimum -Maximum |
        Set-Variable mo

    $min = ($Minimum, $mo.Minimum)[!$Minimum]
    $max = ($Maximum, $mo.Maximum)[!$Maximum]
    $dv = $max - $min 
    $nums = $Numbers.ForEach{ Max (Min $_ $max) $min }
    $getValue = {
        $maxIndex = $NumLines * ($Config.Blocks.Length - 1)

        (($maxIndex - 1) * ($_ - $min)) / $dv + 1 
    } 
    $roundValue = { 
        $v = RoundUp $_ 

        (1, $v)[$v -gt 0]
    }

    switch ($dv) {
        { $dv -eq 0 } { $nums.ForEach{ 4 * $NumLines } }
        { $dv -gt 0 } { $nums.ForEach($getValue).ForEach($roundValue) }
        default { }
    }
}


#endregion

<#
  
   ___ _ _ _
  | _ \_ _| |__| (_)__
  | _/ || | '_ \ | / _|
  |_| \_,_|_.__/_|_\__|
                         
  
#>

#region Public Commands --------------------------------------------------------

function New-EmphasisTable {
    <#
    .Synopsis
      A very simple function that creates a new table consumable by `Get-Sparkline`.
      () -> Dictionary<string, Emphasis>
    .Description
      A very simple function that creates a new table consumable by `Get-Sparkline`.
      You probably should not try modifiy the underlying Dictionary yourself.
      Use `Add-` and `Set-Emphasis` instead.
      The underlying Dictionary type will not allow duplicate key entries.
    .Example
      PS> New-EmphasisTable | Add-Emphasis Red -Gt 50
       Creates an emphasis dictionary with an emphasis that colors any sparkline representing
       a number greater than 50 red. Add to `Get-Sparkline`.
    .Link
      Add-Emphasis
    .Link
      Set-Emphasis
    .Link
      Get-Sparkline
    .Outputs
      Dictionary<string, Emphasis>
    .Inputs
      ()
    .Notes
      Replaces the emph pattern used in sparklines.py
    #>


    [Dictionary[string, Emphasis]]::new()
}


function Add-Emphasis { 
    <#
    .Synopsis
      A simple filter function that adds an emphasis to an Emphasis Dictionary.
      Dictionary<string, Emphasis> -> Dictionary<string, Emphasis>
    .Description
      A simple filter function that adds an emphasis to an Emphasis Dictionary.
      A dictionary must be piped into this filter.
      Because the underlying Dictionary will not accept duplicate keys, the type will throw
      an exception if you try to add duplicate color keys. Use `Set-Emphasis` to change an emphasis entry.
    .Example
      PS> New-EmphasisTable | Add-Emphasis Red -Gt 50
       Creates an emphasis dictionary with an emphasis that colors any sparkline representing
       a number greater than 50 red. Save to a variable and add to `Get-Sparkline`.
    .Link
      New-EmphasisTable
    .Link
      Set-EmphasisTable
    .Link
      Get-Sparkline
    .Outputs
      Dictionary<string, Emphasis>
    .Inputs
      Dictionary<string, Emphasis>
    .Notes
      Replaces the emph pattern used in sparklines.py
    #>


    param(
        # The color to highlight numbers meeting the emphasis test.
        [Parameter(Position = 0)]
        [ConsoleColor] $Color
        ,
        # The a numeric representing the target to test against. Must be castable to a double.
        [Parameter(Position = 2)]
        [double] $Target
        ,
        # An equals comparison.
        [Parameter(Position = 1, ParameterSetName = 'EqualSet')]
        [switch] $Eq
        ,
        # A not equals comparison.
        [Parameter(Position = 1, ParameterSetName = 'NotEqualSet')]
        [switch] $Ne
        ,
        # A less-than comparison.
        [Parameter(Position = 1, ParameterSetName = 'LessThanSet')]
        [switch] $Lt
        ,
        # A less-than-or-equals comparison.
        [Parameter(Position = 1, ParameterSetName = 'LessThanOrEqualSet')]
        [switch] $Le
        ,
        # A greater-than comparison.
        [Parameter(Position = 1, ParameterSetName = 'GreaterThanSet')]
        [switch] $Gt
        ,
        # A greater-than-or-equals comparison.
        [Parameter(Position = 1, ParameterSetName = 'GreaterThanOrEqualSet')]
        [switch] $Ge
        ,
        # The incoming Emphasis object
        [Parameter(ValueFromPipeline)]
        [Dictionary[string, Emphasis]] $InputObject
    )

    process { 
        [Comparer] $comparer = 
            switch ($PSCmdlet.ParameterSetName) {
                EqualSet              { 'Eq' }
                NotEqualSet           { 'Ne' }
                LessThanSet           { 'Lt' }
                LessThanOrEqualSet    { 'Le' }
                GreaterThanSet        { 'Gt' }
                GreaterThanOrEqualSet { 'Ge' }
            }

        $o = New-Emphasis $Color $comparer $Target 

        [void] $InputObject.Add($o.Color.ToString(), $o)
        $InputObject
    }
}

function Set-Emphasis { 
    <#
    .Synopsis
      A simple filter function that sets an emphasis to an Emphasis Dictionary.
      Dictionary<string, Emphasis> -> Dictionary<string, Emphasis>
    .Description
      A simple filter function that sets an emphasis to an Emphasis Dictionary.
      A dictionary must be piped into this filter.
      Use `Set-Emphasis` to change the emphasis for an existing color key.
    .Example
      PS> $t | Set-Emphasis Red -Gt 70
       Changes the Emphasis for the Red entry in the EmphasisTable t.
       If the entry does not exist, Set-Emphasis will add it.
    .Link
      New-EmphasisTable
    .Link
      Set-EmphasisTable
    .Link
      Get-Sparkline
    .Outputs
      Dictionary<string, Emphasis>
    .Inputs
      Dictionary<string, Emphasis>
    .Notes
      Replaces the emph pattern used in sparklines.py
    #>


    param(
        # The color to highlight numbers meeting the emphasis test.
        [Parameter(Position = 0)]
        [ConsoleColor] $Color
        ,
        # The a numeric representing the target to test against. Must be castable to a double.
        [Parameter(Position = 2)]
        [double] $Target
        ,
        # An equals comparison.
        [Parameter(Position = 1, ParameterSetName = 'EqualSet')]
        [switch] $Eq
        ,
        # A not equals comparison.
        [Parameter(Position = 1, ParameterSetName = 'NotEqualSet')]
        [switch] $Ne
        ,
        # A less-than comparison.
        [Parameter(Position = 1, ParameterSetName = 'LessThanSet')]
        [switch] $Lt
        ,
        # A less-than-or-equals comparison.
        [Parameter(Position = 1, ParameterSetName = 'LessThanOrEqualSet')]
        [switch] $Le
        ,
        # A greater-than comparison.
        [Parameter(Position = 1, ParameterSetName = 'GreaterThanSet')]
        [switch] $Gt
        ,
        # A greater-than-or-equals comparison.
        [Parameter(Position = 1, ParameterSetName = 'GreaterThanOrEqualSet')]
        [switch] $Ge
        ,
        # The incoming Emphasis object
        [Parameter(ValueFromPipeline)]
        [Dictionary[string, Emphasis]] $InputObject 
    )

    process { 
        [Comparer] $comparer = 
            switch ($PSCmdlet.ParameterSetName) {
                EqualSet              { 'Eq' }
                NotEqualSet           { 'Ne' }
                LessThanSet           { 'Lt' }
                LessThanOrEqualSet    { 'Le' }
                GreaterThanSet        { 'Gt' }
                GreaterThanOrEqualSet { 'Ge' }
            }

        $o = New-Emphasis $Color $comparer $Target 
        $InputObject[$o.Color.ToString()] = $o

        $InputObject
    }
}


function Get-Sparkline {
    <#
  .Synopsis
    Return an array of sparkline objects for a given list of input numbers.
  .Description
    Return an array of sparkline objects for a given list of input numbers.
  .Example
    PS> Get-Sparkline -Numbers 20, 80, 60, 100
      Returns sparkline objects representing the numbers 20, 80, 60, 100.
  .Example
    PS> Get-Sparkline -Numbers 20, 80, 60, 100 | Write-Sparkline
       
    ▁▆▄█
  .Example
    PS> Get-Sparkline -Numbers 20, 80, 60, 100 -NumLines 3 | Write-Sparkline
     ▂ █
     █▄█
    ▁███
  .Example
    PS> Get-Sparkline -Numbers 20, 80, 60, 100 -EmphasisTable (New-Emphasis |
      Add-Emphasis Red -Gt 70) | Show-Sparkline
   
    This will display a sparkline in the host with the second and fourth bar colored red,
    if the host is capable.
  .Example
    PS> -join (Get-Sparkline 1,2,3,4 | Show-Sparkline 6>&1)
 
    One possible way to capture the output of `Show-Sparkline`.
  .Link
    New-EmphasisTable
  .Link
    Add-Emphasis
  .Link
    Set-Emphasis
  .Link
    Write-Sparkline
  .Link
    Show-Sparkline
  .Inputs
    double[]
  .Outputs
    Sparkline[]
  .Notes
    Replaces sparklines(numbers=[], num_lines=1, emph=None, verboe=False,
      minimum=None, maximum=None, wrap=None). Wrap is not
  #>
 

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] 
    param( 
        # An array of numbers to turn into a sparkline.
        [Parameter(ValueFromPipeline)]
        [double[]] $Numbers 
        , 
        # The number of lines to write or show the sparkline on. Must be positive.
        [ValidateScript({ Assert-Positive $_ })]
        [int] $NumLines = 1
        , 
        # A Dictionary that will color certain sparks based on simple logical tests.
        [System.Collections.Generic.Dictionary[string, Emphasis]] $EmphasisTable
        , 
        # The lowest number to display on the sparkline--a high-pass filter.
        [double] $Minimum
        ,
        # The highest number to display on the sparkline--a low-pass filter.
        [double] $Maximum
    )

    begin {
        $ls = [System.Collections.ArrayList] @()
    }

    process {
        [void] $Numbers.ForEach($ls.Add($_))
    }

    end {
        $PSBoundParameters.Numbers = $ls.ToArray()
  
        $t = $EmphasisTable

        # Remove params from the hashtable to allow for easy-reuse
        [void] $PSBoundParameters.Remove('EmphasisTable')

        Test-NegativeNumber $ls.ToArray()

        $x = Get-EmphasisIndex $ls.ToArray() $t

        # At this point, the original python script uses batch()
        # Batch is a Split-Array function that chunks an array into subarrays

        Get-ScaledValues @PSBoundParameters | ForEach-Object { $c = 0 } {
            $v = $_ 

            1..$NumLines | ForEach-Object { $r = 0 } {         
                $vs = Min $v 8
                $v = Max 0 ($v - 8)

                [Spark] @{
                    Row   = $r
                    Col   = $c
                    Val   = $vs
                    Block = $Config.Blocks[$vs]
                    Color = ($PSparklines.DefaultForegroundColor, $x[$c])[$x.ContainsKey($c)]
                }

                $r++
            }

            $c++
        }
    }
}


function Show-Sparkline {
    <#
    .Synopsis
      Format the pipelined Sparkline and send it to the information stream and write it to the host.
    .Description
      Format the pipelined Sparkline and send it to the information stream and write it to the host.
      Allows for in host formatting and colorization if the Sparkline array was defined with an EmphasisTable.
    .Example
      PS> Get-Sparkline 1,2,3,4 | Show-Sparkline
    #>


    param(
        # Do not terminate the sparkline with a newline.
        [switch] $NoNewline
    )

    $input |
        Sort-Object @{ Expression="Row"; Descending=$true }, Col -OutVariable sparks |
        Measure-Object -Property Row -Maximum |
        Set-Variable mo 

    $r = $mo.Maximum

    foreach ($x in $sparks) {

        if ($x.Row -ne $r) {
            Write-Host
            $r--
        }
        
        Write-Host $x.Block -ForegroundColor $x.Color -NoNewline
    }

    Write-Host -NoNewline:$NoNewline.IsPresent
}

function Write-Sparkline {
    <#
    .Synopsis
      Format the pipelines Sparkline and send it the standard output stream and write it as a string.
    .Example
      PS> Get-Sparkline 1,2,3,4 | Write-Sparkline
    #>
 

    $input |
        Group-Object Row |
        Sort-Object Name -Descending | 
        ForEach-Object { -join $_.Group.Block }
}

#endregion

<#
  
     __ ,
   ,-| ~ , ,,
  ('||/__, _ || || ' _
 (( ||| | < \, =||= _-_ ||/\ _-_ _-_ -_-_ \\ \\/\\ / \\
 (( |||==| /-|| || || \\ ||_< || \\ || \\ || \\ || || || || ||
  ( / | , (( || || ||/ || | ||/ ||/ || || || || || || ||
   -____/ \/\\ \\, \\,/ \\,\ \\,/ \\,/ ||-' \\ \\ \\ \\_-|
                                             |/ / \
                                             ' '----`
  
#>

#region Gatekeeping ------------------------------------------------------------


function Assert-Positive ($n) {
    # .Synopsis
    # Returns true if the number is greater than zero.
    # Otherwise, throws an exception.
    # double -> bool

    $isNumeric = [double]::TryParse($n, [ref] $null)

    if (!$isNumeric) {
        throw $Resources.InvalidNumeric -f $n
    }

    if ($n -le 0) {
        throw $Resources.InvalidNegative -f $n
    }

    $true 
}


filter Get-NegativeNumbers {
    # .Synopsis
    # Passes numbers less than zero.
    # double[] -> double

    if ($_ -lt 0) {
        $_
    }
}


function Write-Scolding {
    # .Synopsis
    # Writes a warning for every negative number caught.
    # Writes a general warning if any negative number is caught.
    # double[] -> ()

    process { 
        Write-Warning ($Resources.FoundNegativeNum -f $_)

        $b = $null -ne $_ 
    }

    end {
        if ($b) {
            Write-Warning $Resources.UnexpectedOutput
        }
    }
}


function Test-NegativeNumber ($a) {
    # .Synopsis
    # Raise warning for negative numbers.
    # double[] -> ()

    $a |
        Get-NegativeNumbers |
        Write-Scolding 
}


#endregion