GG.Net Data Visualization

August 3, 2026 · View on GitHub

License Package Version

GG.Net Data Visualization

GG.Net lets Data Scientists and Developers create interactive and flexible charts for .NET and Blazor Web Apps.

Taking its inspiration from the highly popular ggpplot2 R package, GG.Net provides natively rich features for your Data Analysis Workflow. Build publication quality charts with just a few lines of code in C# and F#.

Learn more about GG.Net

Install

2.0 builds publish to GitHub Packages. That feed requires authentication even for public packages, so add a nuget.config next to your solution with a personal access token carrying the read:packages scope:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
	<packageSources>
		<add key="ggnet" value="https://nuget.pkg.github.com/pablofrommars/index.json" />
	</packageSources>
	<packageSourceCredentials>
		<ggnet>
			<add key="Username" value="USERNAME" />
			<add key="ClearTextPassword" value="TOKEN" />
		</ggnet>
	</packageSourceCredentials>
</configuration>
dotnet add package GGNet --prerelease

The 1.4.0 release remains on nuget.org.

The DSL

A plot is one fluent chain: PlotContext.Build(source, x, y) establishes the data source and default selectors, each Geom_* call adds a layer configured in place, Scale_* calls shape the axes and legends, and .Style() finishes the plot.

var plot = PlotContext.Build(points, o => o.X, o => o.Y)
	.Geom_Line(strokeWidth: 2, color: "#23d0fc")
	.Geom_HLine([1.0], y: o => o, label: o => "Baseline", lineType: LineType.Dashed)
	.Scale_Y_Continuous(formatter: new DoubleFormatter("N2"))
	.Style();

Conventions

  • xxxBy means data-driven. colorBy, fillBy, sizeBy, lineTypeBy take an aesthetic mapping (built by Scale_Color_Discrete, Scale_Fill_Continuous, …): the value is computed per item, trains a scale, and feeds the legend. The unsuffixed twin (color, fill, size, lineType) is a constant applied to the whole layer. When a mapping is present it wins for its own aesthetic; the constant then still serves as the base for other aesthetics' legend swatches (a line-type legend draws its swatches in the constant color), so setting both is meaningful rather than an error.
  • Positional arguments stop at the selectors. Source and selector parameters (x, y, ymin, open, …) may be passed positionally; every aesthetic, event, or option after them is passed by name. The signatures are wide by design — configuration lives in one call — and named arguments are what keep call sites readable and stable.
  • The vocabulary is SVG's. strokeWidth, opacity, fillOpacity, strokeOpacity, strokeColor mean exactly what they mean in SVG. width and height are reserved for geometric extent in data units (Geom_Bar, Geom_Tile, Geom_Violin, Geom_RidgeLine).
  • Interactivity is a uniform block. Every data-mark geom takes onclick, onmouseover, onmouseout, and (where a hover surface makes sense) tooltip. When tooltip is set and no explicit hover handlers are given, the default hover shows it. Annotation geoms (Geom_ABLine, Geom_HLine, Geom_VLine, Geom_Text) and statistical summaries (Geom_Boxplot, Geom_Violin, Geom_RidgeLine) deliberately take no event block.

Stats

Stats are sources, not layers: each Stat.* call returns a typed source that any geom draws unchanged, recomputed on every render pass so streaming data stays current.

// a histogram is Stat.Bin + Geom_Bar — there is no Histogram geom
PlotContext.Build(Stat.Bin(readings, r => r.Value, bins: 20), b => b.Mid, b => b.Count)
	.Geom_Bar(width: 1.0)
	.Style();
StatOutputDraw with
Stat.BinBin / Bin<TKey> (min, mid, max, count, density)Geom_Bar(x: b => b.Mid, y: b => b.Count)
Stat.DensityDensityPoint / DensityPoint<TKey> (at, density)Geom_Area, Geom_Line, Geom_Violin(width: d => d.Density)
Stat.CountCount<TKey> (key, n)Geom_Bar over categories
Stat.SummarySummary / Summary<TKey> (x, center, lower, upper)Geom_ErrorBar(y: s => s.Center, ymin: s => s.Lower, ymax: s => s.Upper)

Per-facet statistics are grouped statistics. Compute with groupBy: and facet the output on the same key — the key is deliberately stated twice; a mismatch between them is almost certainly a bug:

PlotContext.Build(Stat.Bin(readings, r => r.Value, r => r.Tank, bins: 10), b => b.Mid, b => b.Count)
	.Geom_Bar(width: 1.0)
	.Facet_Wrap(b => b.Group)
	.Style();

Statistics run over the whole source (per group when grouped). Stats that would depend on panel-trained state — a function traced over each panel's free-scale range — are out of scope by design.

Geoms

GeomSelectorsMappingsConstantsEventsTooltip
Geom_Pointx, ysizeBy, colorBysize, color, opacity
Geom_Linex, ycolorBy, lineTypeBystrokeWidth, color, opacity, lineType, piecewise
Geom_Barx, yfillByfill, fillOpacity, strokeColor, strokeOpacity, strokeWidth, position, width
Geom_Areax, yfillByfill, fillOpacity, position
Geom_Ribbonx, ymin, ymaxfillByfill, fillOpacity
Geom_ErrorBarx, y, ymin, ymaxcolorBystrokeWidth, color, opacity, lineType, radius, position
Geom_Segmentx, xend, y, yendstrokeWidth, color, opacity, lineType
Geom_Tilex, y, width, heightfillByfill, fillOpacity, strokeColor, strokeOpacity, strokeWidth
Geom_Hexx, y, dx, dyfillByfill, opacity
Geom_Radarx, yfillByfill, fillOpacity, strokeWidth
Geom_MappolygonsfillByfill, fillOpacity, strokeColor, strokeWidth
Geom_Candlestickx, open, high, low, closestrokeWidth, color, opacity, lineType
Geom_OHLCx, open, high, low, closestrokeWidth, color, opacity, lineType
Geom_Volumex, volumefill, opacity
Geom_Boxplotx, yfillBysize, fill, fillOpacity, strokeWidth
Geom_Violinx, y, widthfillByfill, fillOpacity, strokeColor, position
Geom_RidgeLinex, y, heightfillByfill, fillOpacity
Geom_Textx, y, angleBy, textcolorBysize, anchor, weight, style, color, angle
Geom_ABLinea, b, labelstrokeWidth, color, opacity, lineType, size, anchor, weight, style
Geom_HLiney, labelstrokeWidth, color, opacity, lineType, size, anchor, weight, style
Geom_VLinex, labelstrokeWidth, color, opacity, lineType, size, anchor, weight, style

Theming

Styling is split by one rule: if it moves layout it's C# (Style — font sizes, margins, positions, because the server measures them); if it's paint it's CSS. Paint targets stable semantic classes (panel, x-break, legend-title, …) scoped under .ggnet[theme=name], selected by the Theme parameter on the Plot component.

A theme is a block of variable overrides, not a stylesheet fork:

.ggnet[theme=mytheme] {
	--ggnet-bg: #1e1e1e;
	--ggnet-grid: #333;
	--ggnet-break-label: #9ca3af;
}

The base rules in Themes/Default.css read every paint through a --ggnet-* variable (backgrounds, grid, labels, titles, strips, legend, spinner — the file documents the full set), so a theme overrides only what it changes, anything it omits degrades to the default instead of rendering unstyled, and classes added by future GGNet versions are painted automatically. A test (ThemeContractTests) enforces the contract: every emitted class painted, every referenced variable defined, theme files only setting known variables.

Notes:

  • Geom parameters accept css custom properties — color: "var(--color-temperature)" wires a layer to your design tokens.
  • Changing --ggnet-font affects rendering only: server-side text measurement assumes Inter until font metrics ship with the theme.
  • Self-contained export: plot.AsStringAsync(selfContained: true) / SaveAsync(..., selfContained: true) embeds the bundled theme as a <style> element so the SVG renders standalone; off by default — app-hosted output is styled by the app's stylesheet.

Agent Skill

The repo ships a model-facing skill for AI coding agents under skills/ggnet/ — the DSL manual, a data-shape → chart selection guide, 30 compile- and render-verified example recipes, and a snippet validator (skills/ggnet/scripts/validate.cs). Nothing in it is hand-maintained prose: signatures are extracted from source, examples are pinned gallery tests, and drift is caught by the test suite — the skill version is the library version.

The repo is its own single-plugin marketplace (.claude-plugin/marketplace.json), so consuming projects install the skill straight from it.

Claude Code, from GitHub:

/plugin marketplace add pablofrommars/GGNet
/plugin install ggnet@ggnet

Claude Code, from a local checkout (e.g. a repo that vendors GGNet):

/plugin marketplace add path/to/GGNet
/plugin install ggnet@ggnet

Codex consumes the same skill via the .codex-plugin/plugin.json manifest; the agent-neutral manifest at the root (plugin.json) follows the agentskills.io layout for everything else.

Two pieces of the skill are live tooling and need the GGNet source tree present (a clone or a vendored copy — not just the installed skill): scripts/validate.cs compiles snippets against the in-repo projects, and the MCP server is registered with

claude mcp add ggnet -- dotnet run --project path/to/GGNet/src/GGNet.Mcp