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:
- How many observations are available for each species?
- How do bill length and bill depth separate the species, and where do male and female observations appear?
- How different are the body-mass distributions?
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:
- Serialize the specification with enough JSON depth for nested encodings.
- Encode the JSON as UTF-8 Base64, avoiding quoting and
</script>problems inside the page. - Create a small HTML document that loads pinned Vega, Vega-Lite, and Vega-Embed versions.
- 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.