|
| 1 | +--- |
| 2 | +title: "Build Vega-Lite HTML Reports with PowerShell" |
| 3 | +description: "Use PowerShell objects and Vega-Lite specifications to generate an interactive HTML report without adopting a separate reporting framework." |
| 4 | +author: Andrey Vernigora |
| 5 | +authors: |
| 6 | + - Andrey Vernigora |
| 7 | +date: "2026-10-05T00:00:00+00:00" |
| 8 | +categories: |
| 9 | + - PowerShell for Developers |
| 10 | +tags: |
| 11 | + - powershell |
| 12 | + - vega-lite |
| 13 | + - data-visualization |
| 14 | + - html |
| 15 | + - reporting |
| 16 | +--- |
| 17 | + |
| 18 | +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 adopting a reporting framework or opening a browser from the script. |
| 19 | + |
| 20 | +In this article, we download the [Palmer Penguins](https://allisonhorst.github.io/palmerpenguins/) dataset, clean it with ordinary PowerShell, describe three views as one Vega-Lite specification, and save the finished report as an HTML file. |
| 21 | + |
| 22 | +The key design choice is that `Show-VegaLite` doesn't decide what to do with the HTML. It returns the document to the pipeline: |
| 23 | + |
| 24 | +```powershell |
| 25 | +$reportSpec | |
| 26 | + Show-VegaLite -PageTitle 'Palmer Penguins report' | |
| 27 | + Set-Content -Path ./penguins-report.html -Encoding utf8 |
| 28 | +``` |
| 29 | + |
| 30 | +That makes the renderer useful in a console script, scheduled job, CI pipeline, or notebook. |
| 31 | + |
| 32 | +## The finished report |
| 33 | + |
| 34 | +The example produces one HTML document containing a bar chart, scatter plot, and box plot. |
| 35 | + |
| 36 | + |
| 37 | + |
| 38 | +The charts answer progressively richer questions: |
| 39 | + |
| 40 | +- How many complete observations are available for each species? |
| 41 | +- How do bill length and bill depth separate the species? |
| 42 | +- How different are the body-mass distributions? |
| 43 | + |
| 44 | +The saved report remains interactive. Vega-Embed provides tooltips and an action menu that can export individual views. |
| 45 | + |
| 46 | +## Load and shape the data |
| 47 | + |
| 48 | +The simplified Palmer Penguins dataset has 344 rows and eight columns. It contains categories, measurements, missing values, and several visible relationships, which makes it a useful alternative to the classic Iris dataset. The project publishes the data under CC0 and documents the original Palmer Station LTER sources. |
| 49 | + |
| 50 | +PowerShell can download the CSV directly: |
| 51 | + |
| 52 | +```powershell |
| 53 | +$dataUrl = 'https://raw.githubusercontent.com/' + |
| 54 | + 'allisonhorst/palmerpenguins/main/inst/extdata/penguins.csv' |
| 55 | +
|
| 56 | +$penguins = Invoke-RestMethod -Uri $dataUrl | |
| 57 | + ConvertFrom-Csv | |
| 58 | + Where-Object { |
| 59 | + $_.bill_length_mm -ne 'NA' -and |
| 60 | + $_.bill_depth_mm -ne 'NA' -and |
| 61 | + $_.flipper_length_mm -ne 'NA' -and |
| 62 | + $_.body_mass_g -ne 'NA' -and |
| 63 | + $_.sex -ne 'NA' |
| 64 | + } | |
| 65 | + ForEach-Object { |
| 66 | + [pscustomobject]@{ |
| 67 | + species = $_.species |
| 68 | + island = $_.island |
| 69 | + bill_length_mm = [double]$_.bill_length_mm |
| 70 | + bill_depth_mm = [double]$_.bill_depth_mm |
| 71 | + flipper_length_mm = [int]$_.flipper_length_mm |
| 72 | + body_mass_g = [int]$_.body_mass_g |
| 73 | + sex = $_.sex |
| 74 | + year = [int]$_.year |
| 75 | + } |
| 76 | + } |
| 77 | +``` |
| 78 | + |
| 79 | +The explicit casts matter. `ConvertFrom-Csv` initially creates strings, while Vega-Lite should receive JSON numbers for quantitative fields. Removing incomplete records keeps this example focused. A production report could retain them and add a separate data-quality summary. |
| 80 | + |
| 81 | +## Describe several charts in one specification |
| 82 | + |
| 83 | +Vega-Lite specifications are JSON documents. PowerShell ordered hashtables and arrays let us construct the same structure while keeping the data as objects until the final serialization step. |
| 84 | + |
| 85 | +The top-level `data` property makes the cleaned data available to every view. `vconcat` places the count chart above an `hconcat` containing the scatter and box plots: |
| 86 | + |
| 87 | +```powershell |
| 88 | +$reportSpec = [ordered]@{ |
| 89 | + '$schema' = 'https://vega.github.io/schema/vega-lite/v6.json' |
| 90 | + data = @{ values = @($penguins) } |
| 91 | + spacing = 24 |
| 92 | + vconcat = @( |
| 93 | + @{ |
| 94 | + width = 760 |
| 95 | + height = 150 |
| 96 | + title = 'Observations by species' |
| 97 | + mark = @{ type = 'bar'; cornerRadiusEnd = 3 } |
| 98 | + encoding = @{ |
| 99 | + x = @{ |
| 100 | + field = 'species' |
| 101 | + type = 'nominal' |
| 102 | + title = $null |
| 103 | + sort = '-y' |
| 104 | + } |
| 105 | + y = @{ |
| 106 | + aggregate = 'count' |
| 107 | + type = 'quantitative' |
| 108 | + title = 'Penguins' |
| 109 | + } |
| 110 | + color = @{ |
| 111 | + field = 'species' |
| 112 | + type = 'nominal' |
| 113 | + legend = $null |
| 114 | + } |
| 115 | + tooltip = @( |
| 116 | + @{ field = 'species'; type = 'nominal'; title = 'Species' } |
| 117 | + @{ aggregate = 'count'; type = 'quantitative'; title = 'Observations' } |
| 118 | + ) |
| 119 | + } |
| 120 | + } |
| 121 | + @{ |
| 122 | + hconcat = @( |
| 123 | + @{ |
| 124 | + width = 365 |
| 125 | + height = 300 |
| 126 | + title = 'Bill dimensions' |
| 127 | + mark = @{ |
| 128 | + type = 'point' |
| 129 | + filled = $true |
| 130 | + opacity = 0.72 |
| 131 | + size = 65 |
| 132 | + } |
| 133 | + encoding = @{ |
| 134 | + x = @{ |
| 135 | + field = 'bill_length_mm' |
| 136 | + type = 'quantitative' |
| 137 | + title = 'Bill length (mm)' |
| 138 | + scale = @{ zero = $false } |
| 139 | + } |
| 140 | + y = @{ |
| 141 | + field = 'bill_depth_mm' |
| 142 | + type = 'quantitative' |
| 143 | + title = 'Bill depth (mm)' |
| 144 | + scale = @{ zero = $false } |
| 145 | + } |
| 146 | + color = @{ |
| 147 | + field = 'species' |
| 148 | + type = 'nominal' |
| 149 | + title = 'Species' |
| 150 | + } |
| 151 | + shape = @{ |
| 152 | + field = 'sex' |
| 153 | + type = 'nominal' |
| 154 | + title = 'Sex' |
| 155 | + } |
| 156 | + } |
| 157 | + } |
| 158 | + @{ |
| 159 | + width = 365 |
| 160 | + height = 300 |
| 161 | + title = 'Body mass distribution' |
| 162 | + mark = @{ |
| 163 | + type = 'boxplot' |
| 164 | + extent = 'min-max' |
| 165 | + size = 34 |
| 166 | + } |
| 167 | + encoding = @{ |
| 168 | + x = @{ |
| 169 | + field = 'species' |
| 170 | + type = 'nominal' |
| 171 | + title = $null |
| 172 | + } |
| 173 | + y = @{ |
| 174 | + field = 'body_mass_g' |
| 175 | + type = 'quantitative' |
| 176 | + title = 'Body mass (g)' |
| 177 | + scale = @{ zero = $false } |
| 178 | + } |
| 179 | + color = @{ |
| 180 | + field = 'species' |
| 181 | + type = 'nominal' |
| 182 | + legend = $null |
| 183 | + } |
| 184 | + } |
| 185 | + } |
| 186 | + ) |
| 187 | + } |
| 188 | + ) |
| 189 | + config = @{ |
| 190 | + view = @{ stroke = $null } |
| 191 | + axis = @{ |
| 192 | + labelColor = '#42506a' |
| 193 | + titleColor = '#27334a' |
| 194 | + gridColor = '#e6eaf0' |
| 195 | + } |
| 196 | + title = @{ |
| 197 | + anchor = 'start' |
| 198 | + color = '#17233c' |
| 199 | + fontSize = 16 |
| 200 | + } |
| 201 | + range = @{ |
| 202 | + category = @('#4c78a8', '#f58518', '#54a24b') |
| 203 | + } |
| 204 | + } |
| 205 | +} |
| 206 | +``` |
| 207 | + |
| 208 | +This is still only data. No chart process has started, and PowerShell hasn't emitted HTML yet. |
| 209 | + |
| 210 | +## Return HTML instead of taking control |
| 211 | + |
| 212 | +`Show-VegaLite` performs four operations: |
| 213 | + |
| 214 | +1. Serialize the specification with enough JSON depth for nested encodings. |
| 215 | +2. Encode that JSON as UTF-8 Base64 to avoid quoting and `</script>` problems inside the page. |
| 216 | +3. Create a small HTML document that loads pinned Vega, Vega-Lite, and Vega-Embed versions. |
| 217 | +4. Return the document as a string. |
| 218 | + |
| 219 | +```powershell |
| 220 | +function Show-VegaLite { |
| 221 | + [CmdletBinding()] |
| 222 | + param( |
| 223 | + [Parameter(Mandatory, ValueFromPipeline)] |
| 224 | + [System.Collections.IDictionary]$Spec, |
| 225 | +
|
| 226 | + [string]$PageTitle = 'Vega-Lite report', |
| 227 | +
|
| 228 | + [ValidateSet('svg', 'canvas')] |
| 229 | + [string]$Renderer = 'svg' |
| 230 | + ) |
| 231 | +
|
| 232 | + process { |
| 233 | + $specJson = $Spec | ConvertTo-Json -Depth 100 -Compress |
| 234 | + $specBase64 = [Convert]::ToBase64String( |
| 235 | + [Text.Encoding]::UTF8.GetBytes($specJson) |
| 236 | + ) |
| 237 | + $encodedTitle = [Net.WebUtility]::HtmlEncode($PageTitle) |
| 238 | +
|
| 239 | + @" |
| 240 | +<!doctype html> |
| 241 | +<html lang="en"> |
| 242 | +<head> |
| 243 | + <meta charset="utf-8"> |
| 244 | + <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 245 | + <title>$encodedTitle</title> |
| 246 | + <script src="https://cdn.jsdelivr.net/npm/vega@6.3.1"></script> |
| 247 | + <script src="https://cdn.jsdelivr.net/npm/vega-lite@6.4.3"></script> |
| 248 | + <script src="https://cdn.jsdelivr.net/npm/vega-embed@7.1.0"></script> |
| 249 | + <style> |
| 250 | + body { font-family: system-ui, sans-serif; margin: 2rem; } |
| 251 | + #vis { overflow-x: auto; } |
| 252 | + .error { color: #a61b1b; white-space: pre-wrap; } |
| 253 | + </style> |
| 254 | +</head> |
| 255 | +<body> |
| 256 | + <h1>$encodedTitle</h1> |
| 257 | + <div id="vis"></div> |
| 258 | + <script> |
| 259 | + const binary = atob("$specBase64"); |
| 260 | + const bytes = Uint8Array.from( |
| 261 | + binary, |
| 262 | + character => character.charCodeAt(0) |
| 263 | + ); |
| 264 | + const spec = JSON.parse(new TextDecoder().decode(bytes)); |
| 265 | +
|
| 266 | + vegaEmbed("#vis", spec, { |
| 267 | + mode: "vega-lite", |
| 268 | + renderer: "$Renderer", |
| 269 | + actions: true |
| 270 | + }).catch(error => { |
| 271 | + const message = document.createElement("div"); |
| 272 | + message.className = "error"; |
| 273 | + message.textContent = error.stack || error.message; |
| 274 | + document.querySelector("#vis").replaceChildren(message); |
| 275 | + }); |
| 276 | + </script> |
| 277 | +</body> |
| 278 | +</html> |
| 279 | +"@ |
| 280 | + } |
| 281 | +} |
| 282 | +``` |
| 283 | + |
| 284 | +The function doesn't call `Set-Content`, `Out-File`, `Start-Process`, or a notebook-specific display command. That separation lets the caller choose the destination. |
| 285 | + |
| 286 | +## Save the report |
| 287 | + |
| 288 | +Pipe the specification through the renderer and save the returned string: |
| 289 | + |
| 290 | +```powershell |
| 291 | +$outputPath = Join-Path $PWD 'penguins-report.html' |
| 292 | +
|
| 293 | +$reportSpec | |
| 294 | + Show-VegaLite -PageTitle 'Palmer Penguins report' | |
| 295 | + Set-Content -Path $outputPath -Encoding utf8 |
| 296 | +
|
| 297 | +Get-Item $outputPath |
| 298 | +``` |
| 299 | + |
| 300 | +The resulting file contains the selected data and complete Vega-Lite specification. You can attach it to a ticket, publish it as a build artifact, copy it to static hosting, or open it locally. |
| 301 | + |
| 302 | +It isn't completely offline: the HTML contains the data and chart definition, but it loads the JavaScript runtimes from jsDelivr. A fully offline variant can download those runtime files and reference local copies, at the cost of shipping several additional assets. |
| 303 | + |
| 304 | +## Reuse the same output elsewhere |
| 305 | + |
| 306 | +Returning HTML to the pipeline leaves room for other destinations. For example, a host that supports rich MIME output can render the same document directly: |
| 307 | + |
| 308 | +```powershell |
| 309 | +$reportSpec | |
| 310 | + Show-VegaLite | |
| 311 | + Display -MimeType 'text/html' |
| 312 | +``` |
| 313 | + |
| 314 | +The boundary stays simple: PowerShell prepares objects, Vega-Lite describes the visualization, and the last command decides whether the result becomes a file, build artifact, web page, or interactive cell. |
| 315 | + |
| 316 | +## References |
| 317 | + |
| 318 | +- [Palmer Penguins project and data documentation](https://allisonhorst.github.io/palmerpenguins/) |
| 319 | +- [Vega-Lite documentation](https://vega.github.io/vega-lite/) |
| 320 | +- [Vega-Embed documentation](https://github.com/vega/vega-embed) |
0 commit comments