Audience: AI agents and developers working on this codebase. This is the in-depth companion to the repository-level
CLAUDE.md. Read the relevant section before changing renderers, scales, or the JSON layer. Every claim below is grounded in the current source; when this doc and the code disagree, the code wins — fix the doc.
ChartCS renders static chart images (PNG) on the server with semantics ported from
Chart.js v3/v4. There is no browser, no JS runtime, no
interactivity. A ChartConfig object — built in C#, via the fluent builder, or
deserialized from real Chart.js JSON — is rasterized through SkiaSharp.
Design North Star: fidelity to Chart.js. Option names, defaults, tick math, spline
math, color resolution, legend semantics, and draw order are all modeled after the
Chart.js source. When behavior is ambiguous or missing, the correct reference is the
Chart.js implementation (chart.js/src/**), not invention. Several files are
deliberate line-by-line ports and say so in their doc comments.
ChartCS.slnx # solution (slnx format)
src/
ChartCS.Core/ # POCO config model + JSON (no rendering deps)
ChartCS.Fluent/ # ChartBuilder/DatasetBuilder (depends on Core only)
ChartCS.SkiaSharp/ # all rendering (depends on Core + SkiaSharp 2.88)
samples/
ChartCS.Demo/ # console app rendering every chart type to PNG
ChartCS.Demo/samples/*.json # Chart.js-format configs, rendered on every run
out/ # generated PNGs (gitignored)
docs/ARCHITECTURE.md # this file
CLAUDE.md # condensed agent guidance
Dependency direction is strict: Fluent → Core, SkiaSharp → Core, demo → both.
Nothing depends on Fluent. Everything in ChartCS.SkiaSharp is internal except
the ChartRenderer facade.
dotnet build # net8.0, zero warnings expected
dotnet run --project samples/ChartCS.Demo # writes PNGs to samples/out/There is no test project. The verification loop is visual: run the demo, open the
PNGs in samples/out/, and compare against what Chart.js would render for the same
config. When you change rendering behavior:
- If no existing demo case exercises the behavior, add a
RenderX()local function insamples/ChartCS.Demo/Program.csor drop a Chart.js-format JSON file intosamples/ChartCS.Demo/samples/(all*.jsonthere are rendered automatically asjson_<name>.png). - Re-run the demo and inspect the output image.
- When in doubt about expected output, reproduce the config in a real Chart.js sandbox and compare shapes, scale bounds, tick labels, and colors.
Entry point: ChartRenderer.RenderToPng(config, width, height) /
RenderToPngFile(...) in src/ChartCS.SkiaSharp/ChartRenderer.cs.
RenderToPng
└─ RenderInternal(ctx, config)
├─ canvas.Clear(options.BackgroundColor) # default white
└─ ResolveRenderer(config.Type).Render(ctx, config)
└─ (ChartRendererBase) Render() → RenderCommon()
├─ DrawTitle() # against the FULL canvas
├─ DrawLegend() # against the FULL canvas
└─ DrawAxesAndChart(ReserveTitleLegendAxis())
# subclass gets remaining box
Key facts:
RenderContext(RenderContext.cs) owns theSKCanvas, the two typefaces (sans-serif normal/bold), and the drawing primitives (DrawText,DrawRect,DrawCircle,DrawLine,MeasureText,TextHeight = size * 1.2). All drawing goes through it; renderers only create rawSKPaint/SKPathfor path-based fills and strokes.- Layout is single-pass, not Chart.js's iterative fit loop. Title and legend
heights/widths are measured, drawn on the full canvas, then
ReserveTitleLegendAxis()returns the leftoverPlotAreafor the chart body.MeasureLegendLayout()andDrawLegend()intentionally repeat the same measurement so their coordinates agree — if you change one, change the other. PlotArea(Layout.cs) is a plain float rect withLeft/Top/Right/Bottom/CenterX/CenterY.
ChartRendererBase # title/legend measure+draw, RenderCommon()
├── CartRendererBase # BuildScales(), DrawAxes(), ValueToX/Y
│ ├── BarChartRenderer # category X (center=true) + linear Y
│ ├── LineChartRenderer # category X (center=false) + linear Y
│ └── XYRendererBase # linear X + linear Y from ChartPoint data
│ ├── ScatterChartRenderer
│ └── BubbleChartRenderer
├── CircleRendererBase # center/radius geometry for arc charts
│ └── DoughnutChartRenderer # the arc implementation; virtual DefaultCutout = 50
│ └── PieChartRenderer # overrides DefaultCutout = 0 (PieController parity)
├── RadarChartRenderer # own radial grid/spokes/scale
└── PolarAreaChartRenderer # own circular grid/scale
ChartRenderer.ResolveRenderer(ChartType) is the only registry — adding a chart type
means adding an enum member, a renderer class, and a switch arm there.
Chart.js draws datasets back-to-front (for i = count-1 .. 0) so dataset 0 ends up
visually on top. Line, radar, scatter, and bubble renderers replicate this with a
descending loop over Data.Datasets (skipping Hidden). Bar iterates forward
because stacked-bar base accumulation must process datasets in declaration order;
bars don't overlap, so draw order is invisible there. Palette fallback indexes use the
absolute dataset index (di), not the position among visible datasets, matching
Chart.js color assignment when some datasets are hidden.
This is the densest Chart.js-parity area. Treat the constants and branch structure of
ScaleComputer as load-bearing; they mirror scale.linearbase.js / helpers.math.js.
Ported behavior (all Chart.js defaults):
bounds: 'ticks'— the scale snaps outward to nice tick boundaries (niceMin = floor(rmin/spacing)*spacing,niceMax = ceil(rmax/spacing)*spacing). Example: data max 89 → axis ends at 90.includeBounds: true— an explicitly configuredmin/maxalways gets its own tick (grid ticks beyond it are dropped; a near-coincident last tick is snapped onto the explicit max).- Explicit
min/maxare merged into the range before tick generation, exactly likethis.min = options.min ?? dataMinin Chart.jsdetermineDataLimits. - Degenerate range guard: equal min/max expands to
max+1(andmin-1unlessbeginAtZero), mirroringhandleTickRangeOptions.LinearScale.Rangeadditionally clamps the span to ≥1e-9 so pixel mapping can never divide by zero. NiceNumis the verbatim 1/2/5/10 helper fromhelpers.math.js.
ScaleOptions.BeginAtZero is bool? — null means "unset" and each renderer applies
its controller's Chart.js default:
| Chart type | default when unset | where applied |
|---|---|---|
| bar | true | BarChartRenderer.ComputeYScale |
| radar | true | RadarChartRenderer |
| line | false | LineChartRenderer.ComputeYScale |
| scatter / bubble | false | XYRendererBase.ComputeLinearAxis (both axes) |
beginAtZero extends the range toward zero from both directions (all-negative
data pulls max up to 0). SuggestedMin/SuggestedMax (double?) widen — never
shrink — the data range, exactly like Chart.js.
- Line:
center: false→ tick i at value i, scale[0, n-1](points at edges). - Bar:
center: true→ scale[-0.5, n-0.5](each category is a 1-unit slot; Chart.jsoffset: true). A slot's center is at valuei, so renderers useValueToX(xScale, i, plot)for the category center. - Single-label guard: a 1-unit span is substituted so the scale is never degenerate.
CartRendererBase.ValueToX/ValueToY are the only value→pixel functions; everything
(gridlines, tick labels, bars, lines, points) must go through them. They honor
ScaleOptions.Reverse by flipping the normalized fraction, so axis reversal works
uniformly across all cartesian charts. Do not hand-roll (v - min) / (max - min)
anywhere else.
CartRendererBase.BuildScales measures y-tick label widths (left margin), x-tick
heights (bottom margin), axis titles, and reserves half-label overflow padding at the
plot edges. X-tick autoskip mirrors Chart.js: maxTicks = plotWidth / tickUnit where
tickUnit = max(labelWidth + 3, lineHeight), then every ceil(count/maxTicks)-th
label is kept (last label always kept).
Verbatim port of helpers.curve.ts:
SplineCurve— the tension-based cardinal spline (cp1/cp2per point). Used by line (open path) and radar (loop: true, cyclic neighbors).ComputeControlPointsMonotone—splineCurveMonotone, the Fritsch–Carlson monotone cubic. Selected byChartDataset.CubicInterpolationMode == Monotone; ignores tension; never overshoots data. Control points sit ⅓ of the way to each neighbor with monotonicity-restrained tangents.
Tension resolution: LineTension == -1 (sentinel, unset) → 0 (straight lines, the
Chart.js default). JSON tension: true → 0.4, false → 0 (TensionJsonConverter).
RGBA byte struct with a CSS-ish parser: #rgb, #rrggbb, #rrggbbaa, rgb()/rgba()
(components are parsed as doubles then clamped 0–255 like CSS; alpha clamped 0–1),
transparent, and the full named-color table (NamedColors.cs). WithAlpha(double)
rounds and clamps. Colors live in the model as raw strings (BackgroundColors
etc.) and are parsed only at render time — keep it that way so JSON round-trips
losslessly.
| Element | resolution order |
|---|---|
| dataset background | BackgroundColors[idx % len] → DefaultPalette.BackgroundAt(datasetIndex) (palette color at 0.5 alpha) |
| dataset border | BorderColors[idx % len] → DefaultPalette.BorderAt(datasetIndex) |
| arc border (pie/doughnut/polar) | BorderColors[i % len] → white (#fff, the Chart.js arc element default — slices separate with white strokes, never palette colors) |
| point fill | PointBackgroundColors → dataset background (Chart.js point element inheritance) |
| point border | PointBorderColors → dataset border |
Index semantics: Background(ds, datasetIndex, itemIndex) uses the item index to
pick from a color array (single-color lists cycle back to the same color) while the
palette fallback keys off the dataset index. Arc charts pass the slice index;
bar and point elements pass the data index (Chart.js indexable options — a color
array on a bar dataset colors each bar individually); line/radar strokes and fills
are dataset-level.
Fills use the dataset's backgroundColor exactly — no alpha rewriting. Chart.js
samples pass rgba(...) fills for line/radar; opaque fills produce opaque areas, and
that is correct parity. If a demo image looks too heavy, fix the demo config (use
rgba), not the renderer.
| property | sentinel | resolved default |
|---|---|---|
BorderWidth |
-1 |
per type: bar 0, line/radar/scatter 3, bubble 1, arcs 2 (DatasetColors.ResolveBorderWidth) |
PointRadius |
-1 |
3 (DatasetColors.ResolvePointRadius) |
LineTension |
-1 |
0 |
CutoutPercentage |
-1 |
doughnut 50, pie 0 (DoughnutChartRenderer.DefaultCutout) |
ScaleOptions.BeginAtZero |
null |
per type — see table in §5 |
Never replace a sentinel with a concrete default in the model — that erases the "unset" state and breaks per-type resolution.
Chart.js has two legend modes and ChartCS mirrors both:
- Arc charts (pie / doughnut / polarArea): one legend item per data label
(slice), filled with that slice's background color, bordered with the arc border
(white default). Built by
LegendManager.BuildArcItemsfromData.Labelsand dataset 0. - Everything else: one item per dataset (
Label??"Dataset N"), box filled with dataset background, stroked with dataset border at the dataset's resolved border width. Hidden datasets stay listed with faded text.
legend.Reverse reverses item order. legend.Labels.UsePointStyle swaps the box for
a circle marker and shrinks the box slot to a square (boxH), Chart.js-style. Legend
measurement (MeasureLegendLayout) supports horizontal wrap (top/bottom/center) and
vertical stacking (left/right); positions honor legend.Align start/center/end.
ChartJsonOptions.Deserialize(json) accepts real Chart.js config JSON. Policies:
camelCase, case-insensitive, enums as camelCase strings, comments + trailing commas
tolerated. Custom converters bridge the JS-flexible shapes:
| converter | accepts |
|---|---|
ChartDatasetJsonConverter |
detects the shape of data: {x,y,r} object elements are copied into PointData (with data still yielding y-values for category charts), [x,y] tuple elements are moved into PointData. This is what makes real Chart.js scatter/bubble JSON work. |
ColorListJsonConverter |
single color string or array of strings (Chart.js scalar-or-array) |
DataJsonConverter |
numbers, numeric strings, nulls, {x,y} objects |
ChartPointListJsonConverter |
{x,y,r} objects or [x,y,r] arrays or bare numbers |
ChartPaddingJsonConverter |
bare number (uniform) or {top,right,bottom,left,x,y} object |
TensionJsonConverter |
number, true (→0.4), false (→0) — attribute-scoped to ChartDataset.LineTension, not global (a global double converter would make every numeric option accept booleans) |
ChartColorJsonConverter / nullable variant |
any string ChartColor.Parse understands |
Property-name mapping: Chart.js dataset color options are singular
(backgroundColor, borderColor, pointBackgroundColor, pointBorderColor,
hoverBackgroundColor, hoverBorderColor) and tension has no "line" prefix. The
model stores plural lists (BackgroundColors, …) and LineTension, bridged with
[JsonPropertyName] attributes on ChartDataset. If you add a model property whose
Chart.js JSON name differs, add the attribute — camelCase policy alone won't save you,
and the failure mode is silent (the option just never binds; colors quietly fall back
to the default palette).
When a Chart.js JSON shape doesn't bind, the fix almost always belongs in a converter
or a [JsonPropertyName] here — not in reshaping the model.
Known JSON gaps: Chart.js v3 cutout (number = px, "NN%" = percent) is not
mapped; use v2-style cutoutPercentage (percent number). rotation/circumference
for arcs are not modeled.
Sample corpus: samples/ChartCS.Demo/samples/*.json holds one English-language
Chart.js-format config per chart type (bar, bar_stacked, pie, doughnut, radar, polar,
scatter, bubble) plus feature samples (line fills + tension, line_monotone with
cubicInterpolationMode/spanGaps, sprint with explicit max). Every file is
rendered as json_<name>.png on each demo run — treat the corpus as the JSON-path
regression suite and extend it when you add JSON-visible behavior.
- Bar (
BarChartRenderer): geometry is slot →categoryPercentage(default 0.8) → per-dataset chunk →barPercentage(default 0.9), leaving gaps between groups and between bars inside a group.x.stackedputs all datasets in one full-width slot;y.stackedaccumulates positive and negative bases separately per category (the y-scale then ranges over the per-category signed sums, not raw values). Both dataset-level percentages are honored. - Line (
LineChartRenderer): builds null-split segments (SpanGapsjoins across nulls), bezier via tension or monotone mode,Fillfills to the y=0 baseline (clamped into scale) with the dataset's exact background color,ShowLine=falsehides stroke and fill but keeps points. - Scatter / Bubble (
XYRendererBasechildren): numeric bounds fromPointData(hidden datasets excluded). Bubbleris a pixel radius used as-is (Chart.js semantics) — never rescale it against the data range; missingrfalls back to the point radius default. - Pie / Doughnut: single implementation in
DoughnutChartRenderer(pie is a cutout-0 subclass). Values areMath.Abs'd for sweep angles; slices start at -90° (12 o'clock). Cutout is clamped 0–100. - Radar: closed-loop spline (
loop: true), concentric polygon rings from a 5-tick linear scale, value fraction clamped ≥0 so out-of-range values don't render inside-out. - PolarArea: circular rings, slice angle =
360/n, radius ∝ value/max, per-slice colors, labels around the rim.
Adding any of these requires renderer work; the model already carries the options:
IndexAxis.Y— horizontal bars (bar geometry + axis swap).XAxisID/YAxisID/Scales.Custom— multiple axes.ScaleType.Logarithmic/Time— only linear + category exist.ChartDataset.Type— mixed charts (e.g. line over bars).PointStyleshapes other than circle (legend draws circle for usePointStyle; data points are always circles).- Border dash patterns,
BorderSkipped,BorderJoinStyle/BorderCapStyle(modeled; strokes currently use butt/miter). Tooltip,Animation,Responsive/MaintainAspectRatio/DevicePixelRatio— interaction/browser concepts; meaningless for fixed-size static PNGs. Do not implement; do not remove from the model (JSON must still bind).- Line dataset stacking (
y.stackedaffects bars only). - SVG output (
ChartRenderer.OutputFormat.Svgenum exists; only PNG is wired).
- Add the enum member to
Core/ChartType.cs(camelCase name = the Chart.jstypestring, matched byJsonStringEnumConverter). - Create
<Name>ChartRendererinChartCS.SkiaSharp, extending the closest base:CartRendererBase(category x + linear y),XYRendererBase(numeric x/y),CircleRendererBase(arc), orChartRendererBase(custom geometry). - Register it in
ChartRenderer.ResolveRenderer. - If the Chart.js controller overrides scale defaults (like bar's
beginAtZero), apply them in yourComputeYScale/ComputeXScalewith the?? defaultpattern. - Decide legend semantics (per-dataset vs per-label) in
LegendManager. - Add a
RenderX()case to the demo and visually verify against real Chart.js.
- Three packages:
ChartCS.Core,ChartCS.SkiaSharp(depends on Core + SkiaSharp),ChartCS.Fluent(depends on Core). Shared metadata (version, MIT license, tags, README embedding,GenerateDocumentationFile, SourceLink, snupkg symbols) lives insrc/Directory.Build.props; per-packageDescriptionlives in each csproj. The demo setsIsPackable=false. dotnet pack ChartCS.slnx -c Release -o artifactsproduces all six artifacts (3 nupkg + 3 snupkg) locally.- CI (
.github/workflows/ci.yml): restore → build-warnaserror→ run the demo as a rendering smoke test → upload the PNGs → pack. Runs on ubuntu; the demo carriesSkiaSharp.NativeAssets.Linuxfor that. - Release (
.github/workflows/release.yml): pushing av*tag builds with-p:Version=<tag minus v>and pushes to nuget.org via Trusted Publishing —NuGet/login@v1exchanges the workflow's OIDC token (permissions: id-token: write) for a short-lived API key; the nuget.org policy is bound to repoaimtune/chartcsand workflow filerelease.yml. No API-key secret exists in the repo. A GitHub release with generated notes is created afterwards. - Public API surfaces XML doc comments (shipped in the package for IntelliSense).
CS1591 is suppressed, so keep the convention by hand: new public types/members get
<summary>docs; note "Not applied by the renderers yet" on modeled-but-unimplemented options so consumers aren't misled. - README chart images are committed copies under
docs/images/(demo output) and are referenced with absoluteraw.githubusercontent.comURLs so nuget.org renders them. Refresh them after visual changes: run the demo, copy fromsamples/out/.
- Don't compute pixels from scale values manually — use
ValueToX/ValueToY(they ownReversehandling and the zero-span guard). - Don't "fix" translucent-looking fills by rewriting alpha in a renderer — Chart.js uses the configured color verbatim; adjust the config instead.
- Don't reorder the bar dataset loop to descending — stacking accumulators depend on forward order.
- Keep
MeasureLegendLayoutandDrawLegendin lockstep (same boxW/usePointStyle logic in both) or legend text will misalign. - Sentinels (
-1,null) mean "unset" — check>= 0/HasValuebefore using model values that carry them. samples/out/is gitignored output; never commit PNGs.- Zero build warnings is the baseline; keep it.