Skip to content

Latest commit

 

History

History
394 lines (322 loc) · 21.7 KB

File metadata and controls

394 lines (322 loc) · 21.7 KB

ChartCS Architecture & Chart.js Parity Reference

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.

1. What ChartCS is

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.

2. Repository layout

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.

3. Build / run / verify

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:

  1. If no existing demo case exercises the behavior, add a RenderX() local function in samples/ChartCS.Demo/Program.cs or drop a Chart.js-format JSON file into samples/ChartCS.Demo/samples/ (all *.json there are rendered automatically as json_<name>.png).
  2. Re-run the demo and inspect the output image.
  3. When in doubt about expected output, reproduce the config in a real Chart.js sandbox and compare shapes, scale bounds, tick labels, and colors.

4. Rendering pipeline

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 the SKCanvas, 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 raw SKPaint/SKPath for 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 leftover PlotArea for the chart body. MeasureLegendLayout() and DrawLegend() intentionally repeat the same measurement so their coordinates agree — if you change one, change the other.
  • PlotArea (Layout.cs) is a plain float rect with Left/Top/Right/Bottom/CenterX/CenterY.

Renderer class hierarchy

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.

Dataset draw order (important Chart.js behavior)

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.

5. Scales and ticks (ScaleComputer.cs, CartRendererBase.cs)

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.

Linear scales — ScaleComputer.ComputeLinear

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 configured min/max always gets its own tick (grid ticks beyond it are dropped; a near-coincident last tick is snapped onto the explicit max).
  • Explicit min/max are merged into the range before tick generation, exactly like this.min = options.min ?? dataMin in Chart.js determineDataLimits.
  • Degenerate range guard: equal min/max expands to max+1 (and min-1 unless beginAtZero), mirroring handleTickRangeOptions. LinearScale.Range additionally clamps the span to ≥1e-9 so pixel mapping can never divide by zero.
  • NiceNum is the verbatim 1/2/5/10 helper from helpers.math.js.

Per-chart-type beginAtZero defaults

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.

Category scales — ScaleComputer.ComputeCategory

  • 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.js offset: true). A slot's center is at value i, so renderers use ValueToX(xScale, i, plot) for the category center.
  • Single-label guard: a 1-unit span is substituted so the scale is never degenerate.

Pixel mapping and axis reverse

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.

Axis drawing / autoskip

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).

6. Splines (SplineHelpers.cs)

Verbatim port of helpers.curve.ts:

  • SplineCurve — the tension-based cardinal spline (cp1/cp2 per point). Used by line (open path) and radar (loop: true, cyclic neighbors).
  • ComputeControlPointsMonotonesplineCurveMonotone, the Fritsch–Carlson monotone cubic. Selected by ChartDataset.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).

7. Color model and resolution

ChartColor (Core)

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.

Resolution chain (DatasetColors.cs, DefaultPalette.cs)

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 PointBackgroundColorsdataset background (Chart.js point element inheritance)
point border PointBorderColorsdataset 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.

Sentinel defaults (ChartDataset)

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.

8. Legend semantics (LegendManager.cs, ChartRendererBase.cs)

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.BuildArcItems from Data.Labels and 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.

9. Chart.js JSON compatibility (ChartJsonOptions.cs, JsonConverters.cs)

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.

10. Per-renderer notes

  • 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.stacked puts all datasets in one full-width slot; y.stacked accumulates 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 (SpanGaps joins across nulls), bezier via tension or monotone mode, Fill fills to the y=0 baseline (clamped into scale) with the dataset's exact background color, ShowLine=false hides stroke and fill but keeps points.
  • Scatter / Bubble (XYRendererBase children): numeric bounds from PointData (hidden datasets excluded). Bubble r is a pixel radius used as-is (Chart.js semantics) — never rescale it against the data range; missing r falls back to the point radius default.
  • Pie / Doughnut: single implementation in DoughnutChartRenderer (pie is a cutout-0 subclass). Values are Math.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.

11. Not implemented (modeled in Core but ignored by renderers)

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).
  • PointStyle shapes 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.stacked affects bars only).
  • SVG output (ChartRenderer.OutputFormat.Svg enum exists; only PNG is wired).

12. How to add a new chart type

  1. Add the enum member to Core/ChartType.cs (camelCase name = the Chart.js type string, matched by JsonStringEnumConverter).
  2. Create <Name>ChartRenderer in ChartCS.SkiaSharp, extending the closest base: CartRendererBase (category x + linear y), XYRendererBase (numeric x/y), CircleRendererBase (arc), or ChartRendererBase (custom geometry).
  3. Register it in ChartRenderer.ResolveRenderer.
  4. If the Chart.js controller overrides scale defaults (like bar's beginAtZero), apply them in your ComputeYScale/ComputeXScale with the ?? default pattern.
  5. Decide legend semantics (per-dataset vs per-label) in LegendManager.
  6. Add a RenderX() case to the demo and visually verify against real Chart.js.

13. Packaging & release

  • 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 in src/Directory.Build.props; per-package Description lives in each csproj. The demo sets IsPackable=false.
  • dotnet pack ChartCS.slnx -c Release -o artifacts produces 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 carries SkiaSharp.NativeAssets.Linux for that.
  • Release (.github/workflows/release.yml): pushing a v* tag builds with -p:Version=<tag minus v> and pushes to nuget.org via Trusted PublishingNuGet/login@v1 exchanges the workflow's OIDC token (permissions: id-token: write) for a short-lived API key; the nuget.org policy is bound to repo aimtune/chartcs and workflow file release.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 absolute raw.githubusercontent.com URLs so nuget.org renders them. Refresh them after visual changes: run the demo, copy from samples/out/.

14. Pitfalls checklist for agents

  • Don't compute pixels from scale values manually — use ValueToX/ValueToY (they own Reverse handling 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 MeasureLegendLayout and DrawLegend in lockstep (same boxW/usePointStyle logic in both) or legend text will misalign.
  • Sentinels (-1, null) mean "unset" — check >= 0 / HasValue before using model values that carry them.
  • samples/out/ is gitignored output; never commit PNGs.
  • Zero build warnings is the baseline; keep it.