PowerShell is good at acquiring data and turning it into objects. Vega-Lite is good at turning structured data into interactive graphics. A small function is enough to connect the two without introducing a reporting framework, a notebook, or application-specific chart objects.

In this article we will download the Palmer Penguins dataset, clean it with ordinary PowerShell, describe three views as a single Vega-Lite specification, and save the finished report as HTML.

The important design choice is that Show-VegaLite does not open a browser or write a file. It returns HTML to the PowerShell pipeline. The caller decides where that HTML belongs:

$reportSpec |
    Show-VegaLite -PageTitle 'Palmer Penguins report' |
    Set-Content -Path ./penguins-report.html -Encoding utf8

That same function can later feed a notebook display command instead of Set-Content.

The finished report

The example below is the actual HTML emitted by the PowerShell script during this site’s build. Vega-Embed supplies tooltips and the action menu, so the result remains interactive rather than becoming a screenshot.

The three charts deliberately answer progressively richer questions:

Load and shape the data

The simplified penguins dataset has 344 rows and eight columns. It is small enough for an article but still contains categories, measurements, missing values, and several useful relationships. The data are available under CC0; the project site also documents the original Palmer Station LTER sources and the requested citation.

PowerShell can download the CSV directly:

$dataUrl = 'https://raw.githubusercontent.com/allisonhorst/palmerpenguins/main/inst/extdata/penguins.csv'

$penguins = Invoke-RestMethod -Uri $dataUrl |
    ConvertFrom-Csv |
    Where-Object {
        $_.bill_length_mm -ne 'NA' -and
        $_.bill_depth_mm -ne 'NA' -and
        $_.flipper_length_mm -ne 'NA' -and
        $_.body_mass_g -ne 'NA' -and
        $_.sex -ne 'NA'
    } |
    ForEach-Object {
        [pscustomobject]@{
            species           = $_.species
            island            = $_.island
            bill_length_mm    = [double]$_.bill_length_mm
            bill_depth_mm     = [double]$_.bill_depth_mm
            flipper_length_mm = [int]$_.flipper_length_mm
            body_mass_g       = [int]$_.body_mass_g
            sex               = $_.sex
            year              = [int]$_.year
        }
    }

The explicit casts matter. ConvertFrom-Csv initially creates strings, while Vega-Lite should receive JSON numbers for quantitative fields. Removing incomplete records also keeps the introductory specification focused. A production report could retain them and expose data-quality counts separately.

Describe several charts in one specification

Vega-Lite specifications are JSON documents. In PowerShell, ordered hashtables and arrays provide a readable way to construct the same structure while keeping the data as objects until the final serialization step.

The report places a bar chart above two side-by-side views. vconcat and hconcat make the layout one specification, so one call to Show-VegaLite produces the whole report:

$reportSpec = [ordered]@{
    '$schema' = 'https://vega.github.io/schema/vega-lite/v6.json'
    data      = @{ values = @($penguins) }
    spacing   = 24
    vconcat   = @(
        @{
            width  = 760
            height = 150
            title  = 'Observations by species'
            mark   = @{ type = 'bar'; cornerRadiusEnd = 3 }
            encoding = @{
                x = @{ field = 'species'; type = 'nominal'; title = $null }
                y = @{ aggregate = 'count'; type = 'quantitative'; title = 'Penguins' }
                color = @{ field = 'species'; type = 'nominal'; legend = $null }
            }
        },
        @{
            hconcat = @(
                @{
                    width  = 365
                    height = 300
                    title  = 'Bill dimensions'
                    mark   = @{ type = 'point'; filled = $true; opacity = 0.72 }
                    encoding = @{
                        x = @{ field = 'bill_length_mm'; type = 'quantitative'; title = 'Bill length (mm)' }
                        y = @{ field = 'bill_depth_mm'; type = 'quantitative'; title = 'Bill depth (mm)' }
                        color = @{ field = 'species'; type = 'nominal'; title = 'Species' }
                        shape = @{ field = 'sex'; type = 'nominal'; title = 'Sex' }
                    }
                },
                @{
                    width  = 365
                    height = 300
                    title  = 'Body mass distribution'
                    mark   = @{ type = 'boxplot'; extent = 'min-max' }
                    encoding = @{
                        x = @{ field = 'species'; type = 'nominal'; title = $null }
                        y = @{ field = 'body_mass_g'; type = 'quantitative'; title = 'Body mass (g)' }
                        color = @{ field = 'species'; type = 'nominal'; legend = $null }
                    }
                }
            )
        }
    )
}

The complete example adds tooltips, consistent colors, axes, and a little spacing. Those details do not change the architecture: PowerShell prepares objects, and Vega-Lite describes how to encode them.

Return HTML instead of taking control

Show-VegaLite performs four operations:

  1. Serialize the specification with enough JSON depth for nested encodings.
  2. Encode the JSON as UTF-8 Base64, avoiding quoting and </script> problems inside the page.
  3. Create a small HTML document that loads pinned Vega, Vega-Lite, and Vega-Embed versions.
  4. Return the document as a string.

The core of the function is intentionally uncomplicated:

function Show-VegaLite {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [System.Collections.IDictionary]$Spec,

        [string]$PageTitle = 'Vega-Lite report',

        [ValidateSet('svg', 'canvas')]
        [string]$Renderer = 'svg'
    )

    process {
        $specJson = $Spec | ConvertTo-Json -Depth 100 -Compress
        $specBase64 = [Convert]::ToBase64String(
            [Text.Encoding]::UTF8.GetBytes($specJson)
        )

        @"
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>$PageTitle</title>
  <script src="https://cdn.jsdelivr.net/npm/vega@6.3.1"></script>
  <script src="https://cdn.jsdelivr.net/npm/vega-lite@6.4.3"></script>
  <script src="https://cdn.jsdelivr.net/npm/vega-embed@7.1.0"></script>
</head>
<body>
  <div id="vis"></div>
  <script>
    const binary = atob("$specBase64");
    const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
    const spec = JSON.parse(new TextDecoder().decode(bytes));
    vegaEmbed("#vis", spec, {
      mode: "vega-lite",
      renderer: "$Renderer",
      actions: true
    });
  </script>
</body>
</html>
"@
    }
}

The generated file is not completely offline: it contains the data and specification, but loads the JavaScript runtime from jsDelivr when opened. A fully offline variant can download those runtime files and reference local copies, at the cost of shipping several additional assets.

Save, publish, or pass it on

Because HTML is normal pipeline output, the same command works in scripts, scheduled jobs, and CI:

$outputPath = Join-Path $PWD 'penguins-report.html'

$reportSpec |
    Show-VegaLite -PageTitle 'Palmer Penguins report' |
    Set-Content -Path $outputPath -Encoding utf8

The result can be attached to a ticket, uploaded as a build artifact, copied to static hosting, or opened locally. Show-VegaLite does not need a browser process and does not need to know which destination you chose.

That separation becomes even more useful inside a notebook. In the next article, we will send the exact same HTML to Verso’s rich display pipeline and examine a larger notebook containing a collection of Vega and Vega-Lite visualizations.

References